✨ feat(spx): 按 git origin 支持 GitHub 仓库,state 评论改成隐藏注释格式,新增 issue view / pr view / pr create

This commit is contained in:
cruldra committed 2026-10-19 07:41:13 +11:00
1 parent 64d5961b62
commit c90d84d7f2
17 files changed
+2683 -400

No files matched your search

+90
View File
@@ -0,0 +1,90 @@
// Package forge talks to the code host a repo lives on, Gitea or GitHub, through one [Forge] interface, so spx
// commands work the same on either.
package forge
import "strings"
// Kind names a forge flavor.
type Kind string
const (
KindGitea Kind = "gitea"
KindGitHub Kind = "github"
)
// KindForHost tells which forge serves `host`: only `github.com` is GitHub, every other host is taken as Gitea.
func KindForHost(host string) Kind {
if strings.EqualFold(host, "github.com") {
return KindGitHub
}
return KindGitea
}
// Issue is the part of an issue spx reads; its JSON form is what `spx issue view --json` prints.
type Issue struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
State string `json:"state"`
Author string `json:"author"`
Assignees []string `json:"assignees"`
HTMLURL string `json:"html_url"`
}
// Comment is one comment on an issue or pull request.
type Comment struct {
ID int64 `json:"id"`
Author string `json:"author"`
Body string `json:"body"`
CreatedAt string `json:"created_at"`
HTMLURL string `json:"html_url"`
}
// BranchRef is one side of a pull request: the branch and the commit it pointed at.
type BranchRef struct {
Branch string `json:"branch"`
SHA string `json:"sha"`
}
// PullRequest is the part of a pull request spx reads; its JSON form is what `spx pr view --json` prints.
type PullRequest struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
State string `json:"state"`
Merged bool `json:"merged"`
Author string `json:"author"`
Head BranchRef `json:"head"`
Base BranchRef `json:"base"`
HTMLURL string `json:"html_url"`
}
// NewPullRequest is what it takes to open a pull request from branch `Head` into branch `Base`.
type NewPullRequest struct {
Title string
Body string
Head string
Base string
}
// Forge is one repo on a code host, with the few calls spx makes against it.
//
// Pull requests share numbering with issues on both forges, so the issue and comment calls also work on a pull
// request number; that is how pull request comments are read and posted.
type Forge interface {
// Kind tells which forge this is.
Kind() Kind
// CurrentUserLogin returns the login of the user the token belongs to.
CurrentUserLogin() (string, error)
// CreateIssue opens an issue assigned to `assignees`.
CreateIssue(title, body string, assignees []string) (*Issue, error)
GetIssue(number int) (*Issue, error)
UpdateIssueBody(number int, body string) error
// ListIssueComments returns every comment, oldest first, reading all pages.
ListIssueComments(number int) ([]Comment, error)
CreateIssueComment(number int, body string) (*Comment, error)
GetPullRequest(number int) (*PullRequest, error)
CreatePullRequest(pr NewPullRequest) (*PullRequest, error)
// DefaultBranch returns the repo's default branch, the usual base of a new pull request.
DefaultBranch() (string, error)
}
@@ -0,0 +1,448 @@
// Package forgetest runs an in-memory Gitea or GitHub REST server for tests, the way [httptest] does for plain handlers.
//
// It serves only the endpoints spx calls, for the single repo [Owner]/[Repo], and mimics each forge's quirks that
// matter to spx: Gitea lives under `/api/v1` and returns every issue comment at once, while GitHub pages comments
// with `per_page` and a `Link` header.
package forgetest
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
)
// Flavor picks which forge's REST dialect the fake speaks.
type Flavor int
const (
Gitea Flavor = iota
GitHub
)
// The only repo the fake serves; requests for any other repo get 404.
const (
Owner = "acme"
Repo = "widgets"
)
// Issue is an issue stored in the fake.
type Issue struct {
Number int
Title string
Body string
State string
Author string
Assignees []string
}
// Comment is an issue or pull request comment stored in the fake.
type Comment struct {
ID int64
Author string
Body string
CreatedAt string
}
// Pull is a pull request stored in the fake; it shares numbering with [Issue] as on both real forges.
type Pull struct {
Number int
Title string
Body string
State string
Merged bool
Author string
HeadBranch string
HeadSHA string
BaseBranch string
BaseSHA string
// HeadDeleted marks a head branch deleted after merging, which changes what Gitea reports as its `ref`.
HeadDeleted bool
}
// Server is a running fake forge; read its `URL` to point a client at it.
type Server struct {
*httptest.Server
Flavor Flavor
Token string
Login string
DefaultBranch string
mu sync.Mutex
issues map[int]*Issue
pulls map[int]*Pull
comments map[int][]Comment
nextNumber int
nextCommentID int64
requests []string
}
// NewServer starts a fake forge of the given flavor that is shut down when the test ends.
func NewServer(t testing.TB, flavor Flavor) *Server {
s := &Server{
Flavor: flavor,
Token: "test-token",
Login: "alice",
DefaultBranch: "main",
issues: map[int]*Issue{},
pulls: map[int]*Pull{},
comments: map[int][]Comment{},
nextNumber: 1,
nextCommentID: 1,
}
s.Server = httptest.NewServer(http.HandlerFunc(s.serve))
t.Cleanup(s.Close)
return s
}
// AddIssue stores an issue under the next free number and returns that number; an empty `State` means `open`.
func (s *Server) AddIssue(is Issue) int {
s.mu.Lock()
defer s.mu.Unlock()
return s.addIssueLocked(is)
}
// AddPull stores a pull request under the next free number and returns that number; an empty `State` means `open`.
func (s *Server) AddPull(p Pull) int {
s.mu.Lock()
defer s.mu.Unlock()
return s.addPullLocked(p)
}
// AddComment appends a comment to issue or pull request `number`, as if `author` had posted it last.
func (s *Server) AddComment(number int, author, body string) Comment {
s.mu.Lock()
defer s.mu.Unlock()
return s.addCommentLocked(number, author, body)
}
// Comments returns a copy of the comments on issue or pull request `number`, oldest first.
func (s *Server) Comments(number int) []Comment {
s.mu.Lock()
defer s.mu.Unlock()
return append([]Comment(nil), s.comments[number]...)
}
// Issue returns a copy of issue `number` and whether it exists.
func (s *Server) Issue(number int) (Issue, bool) {
s.mu.Lock()
defer s.mu.Unlock()
is, ok := s.issues[number]
if !ok {
return Issue{}, false
}
return *is, true
}
// Pull returns a copy of pull request `number` and whether it exists.
func (s *Server) Pull(number int) (Pull, bool) {
s.mu.Lock()
defer s.mu.Unlock()
p, ok := s.pulls[number]
if !ok {
return Pull{}, false
}
return *p, true
}
// Requests returns every request served so far as `METHOD /path?query`, so tests can check which endpoints were hit.
func (s *Server) Requests() []string {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string(nil), s.requests...)
}
func (s *Server) addIssueLocked(is Issue) int {
is.Number = s.nextNumber
s.nextNumber++
if is.State == "" {
is.State = "open"
}
s.issues[is.Number] = &is
return is.Number
}
func (s *Server) addPullLocked(p Pull) int {
p.Number = s.nextNumber
s.nextNumber++
if p.State == "" {
p.State = "open"
}
s.pulls[p.Number] = &p
return p.Number
}
func (s *Server) addCommentLocked(number int, author, body string) Comment {
c := Comment{
ID: s.nextCommentID,
Author: author,
Body: body,
CreatedAt: fmt.Sprintf("2026-10-19T10:%02d:%02dZ", (s.nextCommentID/60)%60, s.nextCommentID%60),
}
s.nextCommentID++
s.comments[number] = append(s.comments[number], c)
return c
}
// apiPrefix is the path every API route sits under: Gitea mounts its API at `/api/v1`, GitHub at the root.
func (s *Server) apiPrefix() string {
if s.Flavor == Gitea {
return "/api/v1"
}
return ""
}
// authorized checks the headers each real forge expects, so a client that sends the wrong scheme fails here too.
func (s *Server) authorized(r *http.Request) bool {
if s.Flavor == Gitea {
return r.Header.Get("Authorization") == "token "+s.Token
}
return r.Header.Get("Authorization") == "Bearer "+s.Token &&
r.Header.Get("Accept") == "application/vnd.github+json" &&
r.Header.Get("X-GitHub-Api-Version") == "2022-11-28"
}
func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
s.requests = append(s.requests, r.Method+" "+r.URL.RequestURI())
if !s.authorized(r) {
writeJSON(w, http.StatusUnauthorized, map[string]any{"message": "Bad credentials"})
return
}
prefix := s.apiPrefix()
if !strings.HasPrefix(r.URL.Path, prefix+"/") {
notFound(w)
return
}
path := strings.TrimPrefix(r.URL.Path, prefix)
if path == "/user" && r.Method == http.MethodGet {
writeJSON(w, http.StatusOK, map[string]any{"login": s.Login})
return
}
repoPath := "/repos/" + Owner + "/" + Repo
if path != repoPath && !strings.HasPrefix(path, repoPath+"/") {
notFound(w)
return
}
rest := strings.TrimPrefix(path, repoPath)
segs := strings.Split(strings.Trim(rest, "/"), "/")
switch {
case rest == "" && r.Method == http.MethodGet:
writeJSON(w, http.StatusOK, map[string]any{"full_name": Owner + "/" + Repo, "default_branch": s.DefaultBranch})
case rest == "/issues" && r.Method == http.MethodPost:
s.createIssue(w, r)
case rest == "/pulls" && r.Method == http.MethodPost:
s.createPull(w, r)
case len(segs) == 2 && segs[0] == "issues":
s.serveIssue(w, r, atoi(segs[1]))
case len(segs) == 3 && segs[0] == "issues" && segs[2] == "comments":
s.serveComments(w, r, atoi(segs[1]))
case len(segs) == 2 && segs[0] == "pulls" && r.Method == http.MethodGet:
p, ok := s.pulls[atoi(segs[1])]
if !ok {
notFound(w)
return
}
writeJSON(w, http.StatusOK, s.pullJSON(p))
default:
notFound(w)
}
}
func (s *Server) createIssue(w http.ResponseWriter, r *http.Request) {
var in struct {
Title string `json:"title"`
Body string `json:"body"`
Assignees []string `json:"assignees"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Title == "" {
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": "title is required"})
return
}
n := s.addIssueLocked(Issue{Title: in.Title, Body: in.Body, Author: s.Login, Assignees: in.Assignees})
writeJSON(w, http.StatusCreated, s.issueJSON(s.issues[n]))
}
func (s *Server) createPull(w http.ResponseWriter, r *http.Request) {
var in struct {
Title string `json:"title"`
Body string `json:"body"`
Head string `json:"head"`
Base string `json:"base"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Title == "" || in.Head == "" || in.Base == "" {
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": "title, head and base are required"})
return
}
n := s.addPullLocked(Pull{
Title: in.Title, Body: in.Body, Author: s.Login,
HeadBranch: in.Head, HeadSHA: "head-sha-" + in.Head,
BaseBranch: in.Base, BaseSHA: "base-sha-" + in.Base,
})
writeJSON(w, http.StatusCreated, s.pullJSON(s.pulls[n]))
}
// serveIssue answers `GET` and `PATCH` on one issue; a pull request number is answered as an issue, as both forges do.
func (s *Server) serveIssue(w http.ResponseWriter, r *http.Request, n int) {
if p, ok := s.pulls[n]; ok && r.Method == http.MethodGet {
writeJSON(w, http.StatusOK, s.issueJSON(&Issue{Number: p.Number, Title: p.Title, Body: p.Body, State: p.State, Author: p.Author}))
return
}
is, ok := s.issues[n]
if !ok {
notFound(w)
return
}
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, s.issueJSON(is))
case http.MethodPatch:
var in struct {
Body *string `json:"body"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": err.Error()})
return
}
if in.Body != nil {
is.Body = *in.Body
}
writeJSON(w, http.StatusOK, s.issueJSON(is))
default:
notFound(w)
}
}
func (s *Server) serveComments(w http.ResponseWriter, r *http.Request, n int) {
if _, isIssue := s.issues[n]; !isIssue {
if _, isPull := s.pulls[n]; !isPull {
notFound(w)
return
}
}
switch r.Method {
case http.MethodPost:
var in struct {
Body string `json:"body"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": err.Error()})
return
}
c := s.addCommentLocked(n, s.Login, in.Body)
writeJSON(w, http.StatusCreated, s.commentJSON(n, c))
case http.MethodGet:
all := s.comments[n]
if s.Flavor == GitHub {
all = s.paginate(w, r, all)
}
out := make([]map[string]any, 0, len(all))
for _, c := range all {
out = append(out, s.commentJSON(n, c))
}
writeJSON(w, http.StatusOK, out)
default:
notFound(w)
}
}
// paginate cuts out the page GitHub would return for `per_page` and `page` and sets the `Link` header to the next one.
func (s *Server) paginate(w http.ResponseWriter, r *http.Request, all []Comment) []Comment {
perPage := 30
if v, err := strconv.Atoi(r.URL.Query().Get("per_page")); err == nil && v > 0 {
perPage = min(v, 100)
}
page := 1
if v, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && v > 0 {
page = v
}
start := min((page-1)*perPage, len(all))
end := min(start+perPage, len(all))
if end < len(all) {
last := (len(all) + perPage - 1) / perPage
base := "http://" + r.Host + r.URL.Path
w.Header().Set("Link", fmt.Sprintf(`<%s?per_page=%d&page=%d>; rel="next", <%s?per_page=%d&page=%d>; rel="last"`,
base, perPage, page+1, base, perPage, last))
}
return all[start:end]
}
func (s *Server) issueJSON(is *Issue) map[string]any {
var assignees any
if len(is.Assignees) > 0 || s.Flavor == GitHub {
list := make([]map[string]any, 0, len(is.Assignees))
for _, a := range is.Assignees {
list = append(list, map[string]any{"login": a})
}
assignees = list
}
return map[string]any{
"number": is.Number,
"title": is.Title,
"body": is.Body,
"state": is.State,
"html_url": fmt.Sprintf("%s/%s/%s/issues/%d", s.URL, Owner, Repo, is.Number),
"user": map[string]any{"login": is.Author},
"assignees": assignees,
}
}
// pullJSON renders a pull request the way each forge does; the two disagree on the head branch fields:
// GitHub's `label` is `owner:branch` and `ref` the branch, while Gitea's `label` is the branch and `ref` turns into
// `refs/pull/<n>/head` once the branch is gone.
func (s *Server) pullJSON(p *Pull) map[string]any {
segment := "pulls"
head := map[string]any{"label": p.HeadBranch, "ref": p.HeadBranch, "sha": p.HeadSHA}
base := map[string]any{"label": p.BaseBranch, "ref": p.BaseBranch, "sha": p.BaseSHA}
if s.Flavor == GitHub {
segment = "pull"
head["label"] = Owner + ":" + p.HeadBranch
base["label"] = Owner + ":" + p.BaseBranch
} else if p.HeadDeleted {
head["ref"] = fmt.Sprintf("refs/pull/%d/head", p.Number)
}
return map[string]any{
"number": p.Number,
"title": p.Title,
"body": p.Body,
"state": p.State,
"merged": p.Merged,
"html_url": fmt.Sprintf("%s/%s/%s/%s/%d", s.URL, Owner, Repo, segment, p.Number),
"user": map[string]any{"login": p.Author},
"head": head,
"base": base,
}
}
func (s *Server) commentJSON(n int, c Comment) map[string]any {
return map[string]any{
"id": c.ID,
"body": c.Body,
"user": map[string]any{"login": c.Author},
"created_at": c.CreatedAt,
"html_url": fmt.Sprintf("%s/%s/%s/issues/%d#issuecomment-%d", s.URL, Owner, Repo, n, c.ID),
}
}
func atoi(s string) int {
n, err := strconv.Atoi(s)
if err != nil {
return -1
}
return n
}
func notFound(w http.ResponseWriter) {
writeJSON(w, http.StatusNotFound, map[string]any{"message": "Not Found"})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
+83
View File
@@ -0,0 +1,83 @@
package forge
import (
"errors"
"fmt"
"os"
"os/exec"
"strings"
"github.com/cruldra/superpowers-vscode/cli/internal/repo"
"github.com/cruldra/superpowers-vscode/cli/internal/tea"
)
// Env is where [Open] gets credentials from; tests swap in canned answers, real runs use [SystemEnv].
type Env struct {
// GitHubToken returns the token the GitHub CLI holds for `host`.
GitHubToken func(host string) (string, error)
// TeaLogin returns the tea login whose URL points at `host`, or `nil` when there is none.
TeaLogin func(host string) (*tea.TeaLogin, error)
// Getenv reads an environment variable.
Getenv func(key string) string
}
// SystemEnv reads credentials from this machine: `gh auth token`, the tea config file and the process environment.
func SystemEnv() Env {
return Env{
GitHubToken: ghAuthToken,
TeaLogin: func(host string) (*tea.TeaLogin, error) {
path, err := tea.DefaultConfigPath()
if err != nil {
return nil, err
}
return tea.LoginForHost(path, host)
},
Getenv: os.Getenv,
}
}
// ghAuthToken asks the GitHub CLI for the token it stores for `host`.
func ghAuthToken(host string) (string, error) {
out, err := exec.Command("gh", "auth", "token", "--hostname", host).Output()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 {
return "", fmt.Errorf("gh auth token --hostname %s 失败: %w: %s", host, err, strings.TrimSpace(string(exitErr.Stderr)))
}
return "", fmt.Errorf("gh auth token --hostname %s 失败: %w", host, err)
}
return string(out), nil
}
// Open connects to the repo at `o` on the forge its host names, picking credentials the way that forge expects.
//
// GitHub takes the token from `gh auth token`. Gitea takes the token of the tea login for the same host, and its
// URL as the API root; with no such login, or one without a token, it falls back to `GITEA_TOKEN` and the origin's web root.
func Open(o repo.Origin, env Env) (Forge, error) {
if KindForHost(o.Host) == KindGitHub {
token, err := env.GitHubToken(o.Host)
if err != nil {
return nil, fmt.Errorf("取 GitHub token 失败,请先运行 `gh auth login`: %w", err)
}
token = strings.TrimSpace(token)
if token == "" {
return nil, fmt.Errorf("gh 没有 %s 的 token,请先运行 `gh auth login`", o.Host)
}
return NewGitHub(GitHubAPIBaseURL, token, o.Owner, o.Repo), nil
}
login, err := env.TeaLogin(o.Host)
if err != nil {
return nil, err
}
baseURL, token := o.WebURL, ""
if login != nil {
baseURL, token = login.URL, login.Token
}
if token == "" {
token = env.Getenv("GITEA_TOKEN")
}
if token == "" {
return nil, fmt.Errorf("没有 %s 的 Gitea token:tea config 里没有该 host 的 login 或其 token 为空,GITEA_TOKEN 也没设置;请运行 `tea login add` 或设置 GITEA_TOKEN", o.Host)
}
return NewGitea(baseURL, token, o.Owner, o.Repo), nil
}
+154
View File
@@ -0,0 +1,154 @@
package forge
import (
"errors"
"strings"
"testing"
"github.com/cruldra/superpowers-vscode/cli/internal/repo"
"github.com/cruldra/superpowers-vscode/cli/internal/tea"
)
// fakeEnv builds an [Env] whose credential sources are canned answers, recording which hosts were asked.
type fakeEnv struct {
ghToken string
ghErr error
logins map[string]*tea.TeaLogin
env map[string]string
ghAsked []string
teaAsked []string
}
func (f *fakeEnv) Env() Env {
return Env{
GitHubToken: func(host string) (string, error) {
f.ghAsked = append(f.ghAsked, host)
return f.ghToken, f.ghErr
},
TeaLogin: func(host string) (*tea.TeaLogin, error) {
f.teaAsked = append(f.teaAsked, host)
return f.logins[host], nil
},
Getenv: func(key string) string { return f.env[key] },
}
}
func githubOrigin() repo.Origin {
return repo.Origin{Host: "github.com", WebURL: "https://github.com", Owner: "acme", Repo: "widgets"}
}
func giteaOrigin() repo.Origin {
return repo.Origin{Host: "gitea.example.com", WebURL: "https://gitea.example.com", Owner: "acme", Repo: "widgets"}
}
func TestKindForHost(t *testing.T) {
cases := map[string]Kind{
"github.com": KindGitHub,
"GitHub.com": KindGitHub,
"gitea.example.com": KindGitea,
"github.example.com": KindGitea,
"api.github.com": KindGitea,
"localhost": KindGitea,
}
for host, want := range cases {
if got := KindForHost(host); got != want {
t.Errorf("KindForHost(%q) = %s, want %s", host, got, want)
}
}
}
func TestOpenGitHubUsesGhToken(t *testing.T) {
fe := &fakeEnv{ghToken: "gho_abc\n", env: map[string]string{"GITEA_TOKEN": "ignored"}}
f, err := Open(githubOrigin(), fe.Env())
if err != nil {
t.Fatalf("Open 出错: %v", err)
}
if f.Kind() != KindGitHub {
t.Fatalf("github.com 应选 GitHub,得到 %s", f.Kind())
}
rf := f.(*restForge)
if rf.apiBase != GitHubAPIBaseURL || rf.header.Get("Authorization") != "Bearer gho_abc" {
t.Fatalf("GitHub 客户端配置不对: base=%s auth=%q", rf.apiBase, rf.header.Get("Authorization"))
}
if rf.owner != "acme" || rf.repo != "widgets" {
t.Fatalf("owner/repo 不对: %s/%s", rf.owner, rf.repo)
}
if len(fe.ghAsked) != 1 || fe.ghAsked[0] != "github.com" || len(fe.teaAsked) != 0 {
t.Fatalf("应只向 gh 要 github.com 的 token: gh=%v tea=%v", fe.ghAsked, fe.teaAsked)
}
}
func TestOpenGitHubFailsWithLoginHintWhenGhFails(t *testing.T) {
fe := &fakeEnv{ghErr: errors.New("exit status 1: no oauth token found")}
_, err := Open(githubOrigin(), fe.Env())
if err == nil || !strings.Contains(err.Error(), "gh auth login") {
t.Fatalf("gh 取 token 失败应提示运行 gh auth login,得到 %v", err)
}
if !strings.Contains(err.Error(), "no oauth token found") {
t.Fatalf("错误里应带上 gh 的原因: %v", err)
}
}
func TestOpenGitHubFailsWithLoginHintWhenGhTokenEmpty(t *testing.T) {
fe := &fakeEnv{ghToken: " \n"}
_, err := Open(githubOrigin(), fe.Env())
if err == nil || !strings.Contains(err.Error(), "gh auth login") {
t.Fatalf("gh 返回空 token 应提示运行 gh auth login,得到 %v", err)
}
}
func TestOpenGiteaUsesTeaLoginOfOriginHost(t *testing.T) {
fe := &fakeEnv{
logins: map[string]*tea.TeaLogin{
"gitea.example.com": {Name: "work", URL: "https://gitea.example.com:8443/", Token: "tea-tok"},
},
env: map[string]string{"GITEA_TOKEN": "env-tok"},
}
f, err := Open(giteaOrigin(), fe.Env())
if err != nil {
t.Fatalf("Open 出错: %v", err)
}
rf := f.(*restForge)
if f.Kind() != KindGitea || rf.apiBase != "https://gitea.example.com:8443/api/v1" || rf.header.Get("Authorization") != "token tea-tok" {
t.Fatalf("Gitea 客户端配置不对: kind=%s base=%s auth=%q", f.Kind(), rf.apiBase, rf.header.Get("Authorization"))
}
if len(fe.teaAsked) != 1 || fe.teaAsked[0] != "gitea.example.com" || len(fe.ghAsked) != 0 {
t.Fatalf("应只按 origin host 查 tea login: tea=%v gh=%v", fe.teaAsked, fe.ghAsked)
}
}
func TestOpenGiteaFallsBackToEnvTokenWhenLoginHasNone(t *testing.T) {
fe := &fakeEnv{
logins: map[string]*tea.TeaLogin{
"gitea.example.com": {Name: "work", URL: "https://gitea.example.com", Token: ""},
},
env: map[string]string{"GITEA_TOKEN": "env-tok"},
}
f, err := Open(giteaOrigin(), fe.Env())
if err != nil {
t.Fatalf("Open 出错: %v", err)
}
if got := f.(*restForge).header.Get("Authorization"); got != "token env-tok" {
t.Fatalf("login 没 token 时应回退 GITEA_TOKEN,得到 %q", got)
}
}
func TestOpenGiteaWithoutLoginUsesOriginWebURLAndEnvToken(t *testing.T) {
fe := &fakeEnv{env: map[string]string{"GITEA_TOKEN": "env-tok"}}
f, err := Open(giteaOrigin(), fe.Env())
if err != nil {
t.Fatalf("Open 出错: %v", err)
}
rf := f.(*restForge)
if rf.apiBase != "https://gitea.example.com/api/v1" || rf.header.Get("Authorization") != "token env-tok" {
t.Fatalf("没有 tea login 时应用 origin 地址 + GITEA_TOKEN: base=%s auth=%q", rf.apiBase, rf.header.Get("Authorization"))
}
}
func TestOpenGiteaWithoutAnyTokenFails(t *testing.T) {
fe := &fakeEnv{}
_, err := Open(giteaOrigin(), fe.Env())
if err == nil || !strings.Contains(err.Error(), "GITEA_TOKEN") || !strings.Contains(err.Error(), "gitea.example.com") {
t.Fatalf("没有 token 时应报错并点名 host 与 GITEA_TOKEN,得到 %v", err)
}
}
@@ -0,0 +1,131 @@
package forge
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
func TestNextLink(t *testing.T) {
cases := map[string]string{
``: ``,
`<https://api.github.com/repositories/1/issues?page=2>; rel="next", <https://api.github.com/repositories/1/issues?page=9>; rel="last"`: `https://api.github.com/repositories/1/issues?page=2`,
`<https://x/a?page=1>; rel="prev", <https://x/a?page=3>; rel="next"`: `https://x/a?page=3`,
`<https://x/a?page=1>; rel="first", <https://x/a?page=1>; rel="prev"`: ``,
`<https://x/a?labels=a,b&page=2>; rel=next`: `https://x/a?labels=a,b&page=2`,
`<http://localhost/api/v1/repos/search?limit=1&page=2>; rel="next",<http://localhost/api/v1/repos/search?limit=1&page=5252>; rel="last"`: `http://localhost/api/v1/repos/search?limit=1&page=2`,
}
for header, want := range cases {
if got := nextLink(header); got != want {
t.Errorf("nextLink(%q) = %q, want %q", header, got, want)
}
}
}
// pagedServer serves `total` comments `size` at a time at any path, linking each page to the next one under `nextPath`.
func pagedServer(t *testing.T, total, size int, nextPath string, check func(r *http.Request)) (*httptest.Server, *int32) {
t.Helper()
var calls int32
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
check(r)
page := 1
fmt.Sscan(r.URL.Query().Get("page"), &page)
start := (page - 1) * size
end := min(start+size, total)
if end < total {
w.Header().Set("Link", fmt.Sprintf(`<%s%s?page=%d>; rel="next"`, srv.URL, nextPath, page+1))
}
out := []map[string]any{}
for i := start; i < end; i++ {
out = append(out, map[string]any{"id": i + 1, "body": fmt.Sprintf("c%d", i+1), "user": map[string]any{"login": "bob"}})
}
_ = json.NewEncoder(w).Encode(out)
}))
t.Cleanup(srv.Close)
return srv, &calls
}
func TestGitHubPaginationFollowsLinkToOtherPath(t *testing.T) {
srv, calls := pagedServer(t, 230, 100, "/repositories/42/issues/7/comments", func(r *http.Request) {
if r.Header.Get("Authorization") != "Bearer tok" ||
r.Header.Get("Accept") != "application/vnd.github+json" ||
r.Header.Get("X-GitHub-Api-Version") != "2022-11-28" {
t.Errorf("GitHub 请求头不对: %v", r.Header)
}
if r.URL.Query().Get("page") == "" {
if r.URL.Path != "/repos/o/r/issues/7/comments" || r.URL.Query().Get("per_page") != "100" {
t.Errorf("第一页请求不对: %s", r.URL.RequestURI())
}
} else if r.URL.Path != "/repositories/42/issues/7/comments" {
t.Errorf("后续页应照 Link 走: %s", r.URL.RequestURI())
}
})
list, err := NewGitHub(srv.URL, "tok", "o", "r").ListIssueComments(7)
if err != nil {
t.Fatalf("ListIssueComments 出错: %v", err)
}
if len(list) != 230 || list[229].Body != "c230" || *calls != 3 {
t.Fatalf("应 3 次请求取全 230 条,得到 %d 条 / %d 次", len(list), *calls)
}
}
func TestGiteaPaginationFollowsLinkWithLimit(t *testing.T) {
srv, calls := pagedServer(t, 120, 50, "/api/v1/repos/o/r/issues/7/comments", func(r *http.Request) {
if r.Header.Get("Authorization") != "token tok" {
t.Errorf("Gitea 认证头不对: %q", r.Header.Get("Authorization"))
}
if !strings.HasPrefix(r.URL.Path, "/api/v1/") {
t.Errorf("Gitea 请求应在 /api/v1 下: %s", r.URL.Path)
}
if r.URL.Query().Get("page") == "" {
if r.URL.Query().Get("limit") != "50" {
t.Errorf("第一页应带 limit=50: %s", r.URL.RequestURI())
}
}
})
list, err := NewGitea(srv.URL, "tok", "o", "r").ListIssueComments(7)
if err != nil {
t.Fatalf("ListIssueComments 出错: %v", err)
}
if len(list) != 120 || *calls != 3 {
t.Fatalf("应 3 次请求取全 120 条,得到 %d 条 / %d 次", len(list), *calls)
}
}
func TestPaginationStopsWithoutLinkHeader(t *testing.T) {
srv, calls := pagedServer(t, 120, 1000, "", func(*http.Request) {})
list, err := NewGitea(srv.URL, "tok", "o", "r").ListIssueComments(7)
if err != nil {
t.Fatalf("ListIssueComments 出错: %v", err)
}
if len(list) != 120 || *calls != 1 {
t.Fatalf("没有 Link 头时应一次取完,得到 %d 条 / %d 次", len(list), *calls)
}
}
func TestPaginationRefusesLinkToAnotherHost(t *testing.T) {
var foreignCalls int32
foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&foreignCalls, 1)
_, _ = w.Write([]byte("[]"))
}))
t.Cleanup(foreign.Close)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Link", fmt.Sprintf(`<%s/steal?page=2>; rel="next"`, foreign.URL))
_, _ = w.Write([]byte(`[{"id":1,"body":"x","user":{"login":"bob"}}]`))
}))
t.Cleanup(srv.Close)
_, err := NewGitHub(srv.URL, "tok", "o", "r").ListIssueComments(7)
if err == nil {
t.Fatal("Link 指向别的主机时应报错")
}
if foreignCalls != 0 {
t.Fatalf("不应带 token 请求别的主机,实际请求了 %d 次", foreignCalls)
}
}
+366
View File
@@ -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
}
+241
View File
@@ -0,0 +1,241 @@
package forge_test
import (
"fmt"
"reflect"
"strings"
"testing"
"github.com/cruldra/superpowers-vscode/cli/internal/forge"
"github.com/cruldra/superpowers-vscode/cli/internal/forge/forgetest"
)
// flavors runs fn once against a fake Gitea and once against a fake GitHub, each wired to a matching client.
func flavors(t *testing.T, fn func(t *testing.T, srv *forgetest.Server, f forge.Forge)) {
cases := []struct {
name string
flavor forgetest.Flavor
}{
{"gitea", forgetest.Gitea},
{"github", forgetest.GitHub},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
srv := forgetest.NewServer(t, c.flavor)
fn(t, srv, clientFor(srv))
})
}
}
func clientFor(srv *forgetest.Server) forge.Forge {
if srv.Flavor == forgetest.GitHub {
return forge.NewGitHub(srv.URL, srv.Token, forgetest.Owner, forgetest.Repo)
}
return forge.NewGitea(srv.URL, srv.Token, forgetest.Owner, forgetest.Repo)
}
func TestKindMatchesConstructor(t *testing.T) {
if k := forge.NewGitea("https://g.example.com", "t", "o", "r").Kind(); k != forge.KindGitea {
t.Fatalf("NewGitea().Kind() = %s", k)
}
if k := forge.NewGitHub(forge.GitHubAPIBaseURL, "t", "o", "r").Kind(); k != forge.KindGitHub {
t.Fatalf("NewGitHub().Kind() = %s", k)
}
}
func TestCurrentUserLogin(t *testing.T) {
flavors(t, func(t *testing.T, srv *forgetest.Server, f forge.Forge) {
login, err := f.CurrentUserLogin()
if err != nil || login != "alice" {
t.Fatalf("CurrentUserLogin() = (%q, %v)", login, err)
}
})
}
func TestWrongTokenSurfacesHTTPError(t *testing.T) {
flavors(t, func(t *testing.T, srv *forgetest.Server, _ forge.Forge) {
var f forge.Forge
if srv.Flavor == forgetest.GitHub {
f = forge.NewGitHub(srv.URL, "wrong", forgetest.Owner, forgetest.Repo)
} else {
f = forge.NewGitea(srv.URL, "wrong", forgetest.Owner, forgetest.Repo)
}
_, err := f.CurrentUserLogin()
if err == nil || !strings.Contains(err.Error(), "401") {
t.Fatalf("错误 token 应返回带 401 的错误,得到 %v", err)
}
})
}
func TestCreateGetAndUpdateIssue(t *testing.T) {
flavors(t, func(t *testing.T, srv *forgetest.Server, f forge.Forge) {
created, err := f.CreateIssue("标题", "正文", []string{"alice"})
if err != nil {
t.Fatalf("CreateIssue 出错: %v", err)
}
if created.Number <= 0 || created.HTMLURL == "" {
t.Fatalf("CreateIssue 返回不完整: %+v", created)
}
if err := f.UpdateIssueBody(created.Number, "新正文"); err != nil {
t.Fatalf("UpdateIssueBody 出错: %v", err)
}
got, err := f.GetIssue(created.Number)
if err != nil {
t.Fatalf("GetIssue 出错: %v", err)
}
want := &forge.Issue{
Number: created.Number,
Title: "标题",
Body: "新正文",
State: "open",
Author: "alice",
Assignees: []string{"alice"},
HTMLURL: fmt.Sprintf("%s/acme/widgets/issues/%d", srv.URL, created.Number),
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("GetIssue = %+v\nwant %+v", got, want)
}
})
}
func TestGetIssueWithoutAssigneesGivesEmptyList(t *testing.T) {
flavors(t, func(t *testing.T, srv *forgetest.Server, f forge.Forge) {
n := srv.AddIssue(forgetest.Issue{Title: "t", Author: "bob"})
got, err := f.GetIssue(n)
if err != nil {
t.Fatalf("GetIssue 出错: %v", err)
}
if got.Assignees == nil || len(got.Assignees) != 0 {
t.Fatalf("没有指派人时应是非 nil 空切片,得到 %#v", got.Assignees)
}
})
}
func TestCommentsRoundTrip(t *testing.T) {
flavors(t, func(t *testing.T, srv *forgetest.Server, f forge.Forge) {
n := srv.AddIssue(forgetest.Issue{Title: "t", Author: "bob"})
srv.AddComment(n, "bob", "第一条")
posted, err := f.CreateIssueComment(n, "第二条")
if err != nil {
t.Fatalf("CreateIssueComment 出错: %v", err)
}
if posted.ID == 0 || posted.HTMLURL == "" || posted.Author != "alice" {
t.Fatalf("CreateIssueComment 返回不完整: %+v", posted)
}
list, err := f.ListIssueComments(n)
if err != nil {
t.Fatalf("ListIssueComments 出错: %v", err)
}
if len(list) != 2 || list[0].Body != "第一条" || list[0].Author != "bob" || list[1].Body != "第二条" {
t.Fatalf("评论列表不对: %+v", list)
}
if list[0].CreatedAt == "" {
t.Fatalf("评论应带 created_at: %+v", list[0])
}
})
}
func TestListIssueCommentsReadsEveryPageOnGitHub(t *testing.T) {
srv := forgetest.NewServer(t, forgetest.GitHub)
f := clientFor(srv)
n := srv.AddIssue(forgetest.Issue{Title: "t", Author: "bob"})
for i := 1; i <= 250; i++ {
srv.AddComment(n, "bob", fmt.Sprintf("c%d", i))
}
list, err := f.ListIssueComments(n)
if err != nil {
t.Fatalf("ListIssueComments 出错: %v", err)
}
if len(list) != 250 || list[0].Body != "c1" || list[249].Body != "c250" {
t.Fatalf("应读全 250 条且保持顺序,得到 %d 条", len(list))
}
var commentCalls []string
for _, r := range srv.Requests() {
if strings.Contains(r, "/comments") {
commentCalls = append(commentCalls, r)
}
}
if len(commentCalls) != 3 {
t.Fatalf("250 条应按 per_page=100 分 3 页取,实际请求: %v", commentCalls)
}
if !strings.Contains(commentCalls[0], "per_page=100") {
t.Fatalf("第一页应带 per_page=100: %s", commentCalls[0])
}
}
func TestGetPullRequest(t *testing.T) {
flavors(t, func(t *testing.T, srv *forgetest.Server, f forge.Forge) {
n := srv.AddPull(forgetest.Pull{
Title: "加功能", Body: "说明", State: "closed", Merged: true, Author: "bob",
HeadBranch: "feat/x", HeadSHA: "aaa111", BaseBranch: "main", BaseSHA: "bbb222",
})
got, err := f.GetPullRequest(n)
if err != nil {
t.Fatalf("GetPullRequest 出错: %v", err)
}
if got.Number != n || got.Title != "加功能" || got.Body != "说明" || got.State != "closed" || !got.Merged || got.Author != "bob" {
t.Fatalf("PR 字段不对: %+v", got)
}
if got.Head != (forge.BranchRef{Branch: "feat/x", SHA: "aaa111"}) || got.Base != (forge.BranchRef{Branch: "main", SHA: "bbb222"}) {
t.Fatalf("head/base 不对: %+v / %+v", got.Head, got.Base)
}
if got.HTMLURL == "" {
t.Fatal("PR 应带 html_url")
}
})
}
func TestGetPullRequestReportsHeadBranchAfterItIsDeleted(t *testing.T) {
flavors(t, func(t *testing.T, srv *forgetest.Server, f forge.Forge) {
n := srv.AddPull(forgetest.Pull{
Title: "t", State: "closed", Merged: true, Author: "bob",
HeadBranch: "feat/gone", HeadSHA: "aaa111", BaseBranch: "main", BaseSHA: "bbb222", HeadDeleted: true,
})
got, err := f.GetPullRequest(n)
if err != nil {
t.Fatalf("GetPullRequest 出错: %v", err)
}
if got.Head.Branch != "feat/gone" || got.Base.Branch != "main" {
t.Fatalf("head 分支删掉后仍应报分支名,得到 head=%+v base=%+v", got.Head, got.Base)
}
})
}
func TestCreatePullRequestAndDefaultBranch(t *testing.T) {
flavors(t, func(t *testing.T, srv *forgetest.Server, f forge.Forge) {
srv.DefaultBranch = "trunk"
base, err := f.DefaultBranch()
if err != nil || base != "trunk" {
t.Fatalf("DefaultBranch() = (%q, %v)", base, err)
}
pr, err := f.CreatePullRequest(forge.NewPullRequest{Title: "T", Body: "B", Head: "feat/y", Base: base})
if err != nil {
t.Fatalf("CreatePullRequest 出错: %v", err)
}
stored, ok := srv.Pull(pr.Number)
if !ok || stored.Title != "T" || stored.Body != "B" || stored.HeadBranch != "feat/y" || stored.BaseBranch != "trunk" {
t.Fatalf("服务端收到的 PR 不对: %+v", stored)
}
if pr.HTMLURL == "" || pr.Head.Branch != "feat/y" || pr.Base.Branch != "trunk" {
t.Fatalf("CreatePullRequest 返回不完整: %+v", pr)
}
})
}
func TestPullRequestCommentsUseIssueCommentsEndpoint(t *testing.T) {
flavors(t, func(t *testing.T, srv *forgetest.Server, f forge.Forge) {
n := srv.AddPull(forgetest.Pull{Title: "t", Author: "bob", HeadBranch: "h", BaseBranch: "main"})
if _, err := f.CreateIssueComment(n, "看过了"); err != nil {
t.Fatalf("CreateIssueComment 出错: %v", err)
}
reqs := srv.Requests()
prefix := ""
if srv.Flavor == forgetest.Gitea {
prefix = "/api/v1"
}
want := fmt.Sprintf("POST %s/repos/acme/widgets/issues/%d/comments", prefix, n)
if reqs[len(reqs)-1] != want {
t.Fatalf("PR 评论应走 issues comments 接口,实际 %s,want %s", reqs[len(reqs)-1], want)
}
})
}