367 lines
11 KiB
Go
367 lines
11 KiB
Go
package forge
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// GitHubAPIBaseURL is the root of the github.com REST API.
|
|
const GitHubAPIBaseURL = "https://api.github.com"
|
|
|
|
// gitHubAPIVersion pins the GitHub REST API version, so a newer default version cannot change response shapes under spx.
|
|
const gitHubAPIVersion = "2022-11-28"
|
|
|
|
// restForge is the [Forge] for both Gitea and GitHub: their REST endpoints for issues, comments and pull requests
|
|
// share paths and JSON shapes, so the two differ only in API root, auth headers, page-size parameter and where a pull
|
|
// request names its branches.
|
|
type restForge struct {
|
|
kind Kind
|
|
apiBase string
|
|
owner string
|
|
repo string
|
|
header http.Header
|
|
pageParam string
|
|
pageSize int
|
|
// branchOf reads the branch name out of a pull request's `head` or `base`.
|
|
branchOf func(apiBranchRef) string
|
|
http *http.Client
|
|
}
|
|
|
|
// NewGitea returns the [Forge] for repo `owner`/`repo` on the Gitea instance at `baseURL`, e.g. `https://gitea.example.com`.
|
|
//
|
|
// Gitea serves at most 50 items per page by default (`MAX_RESPONSE_ITEMS`), so lists ask for that many.
|
|
func NewGitea(baseURL, token, owner, repo string) Forge {
|
|
h := http.Header{}
|
|
h.Set("Authorization", "token "+token)
|
|
h.Set("Accept", "application/json")
|
|
return &restForge{
|
|
kind: KindGitea,
|
|
apiBase: strings.TrimRight(baseURL, "/") + "/api/v1",
|
|
owner: owner,
|
|
repo: repo,
|
|
header: h,
|
|
pageParam: "limit",
|
|
pageSize: 50,
|
|
branchOf: giteaBranch,
|
|
http: &http.Client{Timeout: 30 * time.Second},
|
|
}
|
|
}
|
|
|
|
// NewGitHub returns the [Forge] for repo `owner`/`repo` on GitHub, whose REST API sits at `apiBaseURL`, normally [GitHubAPIBaseURL].
|
|
func NewGitHub(apiBaseURL, token, owner, repo string) Forge {
|
|
h := http.Header{}
|
|
h.Set("Authorization", "Bearer "+token)
|
|
h.Set("Accept", "application/vnd.github+json")
|
|
h.Set("X-GitHub-Api-Version", gitHubAPIVersion)
|
|
return &restForge{
|
|
kind: KindGitHub,
|
|
apiBase: strings.TrimRight(apiBaseURL, "/"),
|
|
owner: owner,
|
|
repo: repo,
|
|
header: h,
|
|
pageParam: "per_page",
|
|
pageSize: 100,
|
|
branchOf: func(r apiBranchRef) string { return r.Ref },
|
|
http: &http.Client{Timeout: 30 * time.Second},
|
|
}
|
|
}
|
|
|
|
func (f *restForge) Kind() Kind { return f.kind }
|
|
|
|
// repoURL builds the API URL of `suffix` under this repo, e.g. `/issues/3` becomes `<api>/repos/<owner>/<repo>/issues/3`.
|
|
func (f *restForge) repoURL(suffix string) string {
|
|
return f.apiBase + "/repos/" + f.owner + "/" + f.repo + suffix
|
|
}
|
|
|
|
// do sends one authenticated request to `rawURL`, sending `payload` as the JSON body when not nil, and decodes the
|
|
// JSON response into `out` when not nil.
|
|
//
|
|
// @return the response headers, so list calls can read the `Link` header for the next page
|
|
func (f *restForge) do(method, rawURL string, payload, out any) (http.Header, error) {
|
|
var body io.Reader
|
|
if payload != nil {
|
|
buf, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("编码请求体失败: %w", err)
|
|
}
|
|
body = bytes.NewReader(buf)
|
|
}
|
|
req, err := http.NewRequest(method, rawURL, body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("构造请求失败: %w", err)
|
|
}
|
|
for k, v := range f.header {
|
|
req.Header[k] = v
|
|
}
|
|
if payload != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
resp, err := f.http.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s %s 请求失败: %w", method, rawURL, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
respBytes, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("%s %s 返回 %d: %s", method, rawURL, resp.StatusCode, strings.TrimSpace(string(respBytes)))
|
|
}
|
|
if out != nil && len(respBytes) > 0 {
|
|
if err := json.Unmarshal(respBytes, out); err != nil {
|
|
return nil, fmt.Errorf("解析响应失败 (%s %s): %w; body=%s", method, rawURL, err, string(respBytes))
|
|
}
|
|
}
|
|
return resp.Header, nil
|
|
}
|
|
|
|
// listAll reads every page of the list at `rawURL`, asking for the largest page size and then following each
|
|
// `Link: rel="next"` header until there is none.
|
|
//
|
|
// An endpoint that does not paginate, such as Gitea's issue comments, sends everything with no `Link`, so one request is enough.
|
|
func listAll[T any](f *restForge, rawURL string) ([]T, error) {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("构造列表 URL 失败: %w", err)
|
|
}
|
|
q := u.Query()
|
|
q.Set(f.pageParam, strconv.Itoa(f.pageSize))
|
|
u.RawQuery = q.Encode()
|
|
var all []T
|
|
for next := u.String(); next != ""; {
|
|
var page []T
|
|
header, err := f.do(http.MethodGet, next, nil, &page)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
all = append(all, page...)
|
|
if next, err = f.nextPage(next, header.Get("Link")); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return all, nil
|
|
}
|
|
|
|
// nextPage resolves the `rel="next"` URL of a `Link` header against the page just read, or returns "" on the last page.
|
|
//
|
|
// It refuses a next URL on another scheme or host than the API root, so the token is never sent anywhere else.
|
|
func (f *restForge) nextPage(current, linkHeader string) (string, error) {
|
|
target := nextLink(linkHeader)
|
|
if target == "" {
|
|
return "", nil
|
|
}
|
|
cur, err := url.Parse(current)
|
|
if err != nil {
|
|
return "", fmt.Errorf("解析当前页 URL %q 失败: %w", current, err)
|
|
}
|
|
ref, err := url.Parse(target)
|
|
if err != nil {
|
|
return "", fmt.Errorf("解析分页链接 %q 失败: %w", target, err)
|
|
}
|
|
abs := cur.ResolveReference(ref)
|
|
base, err := url.Parse(f.apiBase)
|
|
if err != nil {
|
|
return "", fmt.Errorf("解析 API 根地址 %q 失败: %w", f.apiBase, err)
|
|
}
|
|
if !strings.EqualFold(abs.Scheme, base.Scheme) || !strings.EqualFold(abs.Host, base.Host) {
|
|
return "", fmt.Errorf("分页链接 %s 不在 API 地址 %s 下,拒绝带 token 访问", abs, f.apiBase)
|
|
}
|
|
return abs.String(), nil
|
|
}
|
|
|
|
// linkEntryRegex matches one `<url>; params` entry of a `Link` header; the params run until the next `<`.
|
|
var linkEntryRegex = regexp.MustCompile(`<([^>]*)>([^<]*)`)
|
|
|
|
// nextLink picks the URL marked `rel="next"` out of a `Link` header, or returns "" when there is none.
|
|
func nextLink(header string) string {
|
|
for _, m := range linkEntryRegex.FindAllStringSubmatch(header, -1) {
|
|
for _, param := range strings.Split(m[2], ";") {
|
|
name, value, ok := strings.Cut(strings.TrimSpace(strings.TrimRight(strings.TrimSpace(param), ",")), "=")
|
|
if !ok || !strings.EqualFold(strings.TrimSpace(name), "rel") {
|
|
continue
|
|
}
|
|
for _, rel := range strings.Fields(strings.Trim(strings.TrimSpace(value), `"`)) {
|
|
if strings.EqualFold(rel, "next") {
|
|
return m[1]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// apiUser, apiIssue, apiComment and apiPull mirror the response fields spx reads; Gitea and GitHub name them alike.
|
|
type apiUser struct {
|
|
Login string `json:"login"`
|
|
}
|
|
|
|
type apiIssue struct {
|
|
Number int `json:"number"`
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
State string `json:"state"`
|
|
HTMLURL string `json:"html_url"`
|
|
User apiUser `json:"user"`
|
|
Assignees []apiUser `json:"assignees"`
|
|
}
|
|
|
|
func (a apiIssue) issue() *Issue {
|
|
assignees := make([]string, 0, len(a.Assignees))
|
|
for _, u := range a.Assignees {
|
|
assignees = append(assignees, u.Login)
|
|
}
|
|
return &Issue{
|
|
Number: a.Number,
|
|
Title: a.Title,
|
|
Body: a.Body,
|
|
State: a.State,
|
|
Author: a.User.Login,
|
|
Assignees: assignees,
|
|
HTMLURL: a.HTMLURL,
|
|
}
|
|
}
|
|
|
|
type apiComment struct {
|
|
ID int64 `json:"id"`
|
|
Body string `json:"body"`
|
|
HTMLURL string `json:"html_url"`
|
|
CreatedAt string `json:"created_at"`
|
|
User apiUser `json:"user"`
|
|
}
|
|
|
|
func (a apiComment) comment() Comment {
|
|
return Comment{ID: a.ID, Author: a.User.Login, Body: a.Body, CreatedAt: a.CreatedAt, HTMLURL: a.HTMLURL}
|
|
}
|
|
|
|
type apiBranchRef struct {
|
|
Label string `json:"label"`
|
|
Ref string `json:"ref"`
|
|
SHA string `json:"sha"`
|
|
}
|
|
|
|
// giteaBranch reads the branch name the way Gitea reports it: `label` is always the branch, while `ref` becomes
|
|
// `refs/pull/<n>/head` once the branch is deleted. AGit pull requests leave `label` empty, so `ref` is the fallback.
|
|
func giteaBranch(r apiBranchRef) string {
|
|
if r.Label != "" {
|
|
return r.Label
|
|
}
|
|
return r.Ref
|
|
}
|
|
|
|
type apiPull struct {
|
|
Number int `json:"number"`
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
State string `json:"state"`
|
|
Merged bool `json:"merged"`
|
|
HTMLURL string `json:"html_url"`
|
|
User apiUser `json:"user"`
|
|
Head apiBranchRef `json:"head"`
|
|
Base apiBranchRef `json:"base"`
|
|
}
|
|
|
|
// pull converts the response, naming branches with this forge's `branchOf`.
|
|
func (f *restForge) pull(a apiPull) *PullRequest {
|
|
return &PullRequest{
|
|
Number: a.Number,
|
|
Title: a.Title,
|
|
Body: a.Body,
|
|
State: a.State,
|
|
Merged: a.Merged,
|
|
Author: a.User.Login,
|
|
Head: BranchRef{Branch: f.branchOf(a.Head), SHA: a.Head.SHA},
|
|
Base: BranchRef{Branch: f.branchOf(a.Base), SHA: a.Base.SHA},
|
|
HTMLURL: a.HTMLURL,
|
|
}
|
|
}
|
|
|
|
func (f *restForge) CurrentUserLogin() (string, error) {
|
|
var out apiUser
|
|
if _, err := f.do(http.MethodGet, f.apiBase+"/user", nil, &out); err != nil {
|
|
return "", err
|
|
}
|
|
return out.Login, nil
|
|
}
|
|
|
|
func (f *restForge) CreateIssue(title, body string, assignees []string) (*Issue, error) {
|
|
payload := map[string]any{"title": title, "body": body}
|
|
if len(assignees) > 0 {
|
|
payload["assignees"] = assignees
|
|
}
|
|
var out apiIssue
|
|
if _, err := f.do(http.MethodPost, f.repoURL("/issues"), payload, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out.issue(), nil
|
|
}
|
|
|
|
func (f *restForge) GetIssue(number int) (*Issue, error) {
|
|
var out apiIssue
|
|
if _, err := f.do(http.MethodGet, f.repoURL(fmt.Sprintf("/issues/%d", number)), nil, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out.issue(), nil
|
|
}
|
|
|
|
func (f *restForge) UpdateIssueBody(number int, body string) error {
|
|
_, err := f.do(http.MethodPatch, f.repoURL(fmt.Sprintf("/issues/%d", number)), map[string]any{"body": body}, nil)
|
|
return err
|
|
}
|
|
|
|
func (f *restForge) ListIssueComments(number int) ([]Comment, error) {
|
|
raw, err := listAll[apiComment](f, f.repoURL(fmt.Sprintf("/issues/%d/comments", number)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]Comment, 0, len(raw))
|
|
for _, c := range raw {
|
|
out = append(out, c.comment())
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *restForge) CreateIssueComment(number int, body string) (*Comment, error) {
|
|
var out apiComment
|
|
if _, err := f.do(http.MethodPost, f.repoURL(fmt.Sprintf("/issues/%d/comments", number)), map[string]any{"body": body}, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
c := out.comment()
|
|
return &c, nil
|
|
}
|
|
|
|
func (f *restForge) GetPullRequest(number int) (*PullRequest, error) {
|
|
var out apiPull
|
|
if _, err := f.do(http.MethodGet, f.repoURL(fmt.Sprintf("/pulls/%d", number)), nil, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return f.pull(out), nil
|
|
}
|
|
|
|
func (f *restForge) CreatePullRequest(pr NewPullRequest) (*PullRequest, error) {
|
|
payload := map[string]any{"title": pr.Title, "body": pr.Body, "head": pr.Head, "base": pr.Base}
|
|
var out apiPull
|
|
if _, err := f.do(http.MethodPost, f.repoURL("/pulls"), payload, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return f.pull(out), nil
|
|
}
|
|
|
|
func (f *restForge) DefaultBranch() (string, error) {
|
|
var out struct {
|
|
DefaultBranch string `json:"default_branch"`
|
|
}
|
|
if _, err := f.do(http.MethodGet, f.repoURL(""), nil, &out); err != nil {
|
|
return "", err
|
|
}
|
|
if out.DefaultBranch == "" {
|
|
return "", fmt.Errorf("仓库 %s/%s 没有返回默认分支", f.owner, f.repo)
|
|
}
|
|
return out.DefaultBranch, nil
|
|
}
|