Files

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
}