✨ feat(spx): 按 git origin 支持 GitHub 仓库,state 评论改成隐藏注释格式,新增 issue view / pr view / pr create
This commit is contained in:
1 parent
64d5961b62
commit
c90d84d7f2
17 files changed
+2683
-400
No files matched your search
@@ -0,0 +1,366 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user