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 `/repos///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 `; 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//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 }