✨ 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,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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
// Package gitea is a minimal Gitea REST client for the three calls spx needs:
|
||||
// create issue, get/update issue body, create issue comment.
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client talks to a Gitea instance using a personal access token.
|
||||
type Client struct {
|
||||
BaseURL string // host without trailing slash, e.g. "https://gitea.example.com"
|
||||
Token string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// New builds a Client with sane defaults.
|
||||
func New(baseURL, token string) *Client {
|
||||
return &Client{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
Token: token,
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Issue is the subset of Gitea's Issue object spx cares about.
|
||||
type Issue struct {
|
||||
Number int `json:"number"`
|
||||
Body string `json:"body"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
|
||||
// Comment is the subset of Gitea's Comment object spx cares about.
|
||||
type Comment struct {
|
||||
ID int64 `json:"id"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// do executes an HTTP request with auth + JSON content type and decodes the
|
||||
// response into out (if non-nil and the body is non-empty).
|
||||
func (c *Client) do(method, path string, payload any, out any) error {
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
buf, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("编码请求体失败: %w", err)
|
||||
}
|
||||
body = bytes.NewReader(buf)
|
||||
}
|
||||
url := c.BaseURL + path
|
||||
req, err := http.NewRequest(method, url, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("构造请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "token "+c.Token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if payload != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s %s 请求失败: %w", method, url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBytes, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("%s %s 返回 %d: %s", method, url, resp.StatusCode, strings.TrimSpace(string(respBytes)))
|
||||
}
|
||||
if out == nil || len(respBytes) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(respBytes, out); err != nil {
|
||||
return fmt.Errorf("解析响应失败 (%s %s): %w; body=%s", method, url, err, string(respBytes))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CurrentUserLogin resolves the token owner's login.
|
||||
func (c *Client) CurrentUserLogin() (string, error) {
|
||||
var out struct {
|
||||
Login string `json:"login"`
|
||||
}
|
||||
if err := c.do(http.MethodGet, "/api/v1/user", nil, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out.Login, nil
|
||||
}
|
||||
|
||||
// CreateIssue creates a new issue and returns the created object.
|
||||
func (c *Client) CreateIssue(owner, repo, title, body string, assignees []string) (*Issue, error) {
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo)
|
||||
payload := map[string]any{"title": title, "body": body}
|
||||
if len(assignees) > 0 {
|
||||
payload["assignees"] = assignees
|
||||
}
|
||||
var out Issue
|
||||
if err := c.do(http.MethodPost, path, payload, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// GetIssue fetches an existing issue (or PR — Gitea treats PRs as issues for
|
||||
// metadata purposes).
|
||||
func (c *Client) GetIssue(owner, repo string, number int) (*Issue, error) {
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number)
|
||||
var out Issue
|
||||
if err := c.do(http.MethodGet, path, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// UpdateIssueBody patches an issue's body field.
|
||||
func (c *Client) UpdateIssueBody(owner, repo string, number int, body string) error {
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number)
|
||||
payload := map[string]any{"body": body}
|
||||
return c.do(http.MethodPatch, path, payload, nil)
|
||||
}
|
||||
|
||||
// ListIssueComments returns the issue/PR comments in Gitea's default order
|
||||
// (ascending by creation time). Used by `spx issue state get|merge` to find
|
||||
// the last comment, which is where the state JSON blob lives.
|
||||
func (c *Client) ListIssueComments(owner, repo string, number int) ([]Comment, error) {
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, number)
|
||||
var out []Comment
|
||||
if err := c.do(http.MethodGet, path, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CreateIssueComment posts a comment on an issue or PR.
|
||||
//
|
||||
// In Gitea, PR comments (not review threads) live under the same endpoint as
|
||||
// issue comments — pass the PR number as the issue number.
|
||||
func (c *Client) CreateIssueComment(owner, repo string, number int, body string) (*Comment, error) {
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, number)
|
||||
payload := map[string]any{"body": body}
|
||||
var out Comment
|
||||
if err := c.do(http.MethodPost, path, payload, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package repo detects the owner/repo pair from a git working directory's
|
||||
// origin remote.
|
||||
// Package repo reads where a repo lives, its forge host and owner/repo pair,
|
||||
// from a git working directory's origin remote.
|
||||
package repo
|
||||
|
||||
import (
|
||||
@@ -16,7 +16,17 @@ import (
|
||||
// url.Parse instead.
|
||||
var sshLikeRegex = regexp.MustCompile(`^[^@/:]+@([^:/]+):(.+?)/?$`)
|
||||
|
||||
// DetectOwnerRepo runs `git -C cwd remote get-url origin` and parses the URL.
|
||||
// Origin is where a repo lives: the forge host, the forge's web root and the owner/repo pair.
|
||||
type Origin struct {
|
||||
// Host is the lowercase hostname without port, e.g. `github.com`; it decides which forge to talk to.
|
||||
Host string
|
||||
// WebURL is the forge's web root such as `https://gitea.example.com`, the best guess at where its API sits.
|
||||
WebURL string
|
||||
Owner string
|
||||
Repo string
|
||||
}
|
||||
|
||||
// DetectOrigin runs `git -C cwd remote get-url origin` and reads which host and owner/repo the repo lives at.
|
||||
//
|
||||
// Supported formats:
|
||||
// - https://host/owner/repo(.git)
|
||||
@@ -26,37 +36,78 @@ var sshLikeRegex = regexp.MustCompile(`^[^@/:]+@([^:/]+):(.+?)/?$`)
|
||||
//
|
||||
// owner may include subgroups (e.g. "group/subgroup") if the host supports it;
|
||||
// repo is the last path segment without the .git suffix.
|
||||
func DetectOwnerRepo(cwd string) (owner, repo string, err error) {
|
||||
func DetectOrigin(cwd string) (Origin, error) {
|
||||
cmd := exec.Command("git", "-C", cwd, "remote", "get-url", "origin")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("git remote get-url origin 失败 (cwd=%s): %w", cwd, err)
|
||||
return Origin{}, fmt.Errorf("git remote get-url origin 失败 (cwd=%s): %w", cwd, err)
|
||||
}
|
||||
raw := strings.TrimSpace(string(out))
|
||||
if raw == "" {
|
||||
return "", "", fmt.Errorf("origin remote 是空的 (cwd=%s)", cwd)
|
||||
return Origin{}, fmt.Errorf("origin remote 是空的 (cwd=%s)", cwd)
|
||||
}
|
||||
return parseRemoteURL(raw)
|
||||
}
|
||||
|
||||
// parseRemoteURL is the pure-logic core of DetectOwnerRepo, extracted for testing.
|
||||
func parseRemoteURL(raw string) (owner, repo string, err error) {
|
||||
// parseRemoteURL is the pure-logic core of [DetectOrigin], extracted for testing.
|
||||
//
|
||||
// An SSH remote says nothing about the web side, so its `WebURL` is a guess of `https://<host>`.
|
||||
func parseRemoteURL(raw string) (Origin, error) {
|
||||
// Try scp-like ssh: `git@host:owner/repo.git` (only if not URI-shaped).
|
||||
if !strings.Contains(raw, "://") {
|
||||
if m := sshLikeRegex.FindStringSubmatch(raw); m != nil {
|
||||
return splitOwnerRepo(m[2])
|
||||
return newOrigin(m[1], "https://"+m[1], m[2])
|
||||
}
|
||||
}
|
||||
// Try URL form (http/https/ssh://).
|
||||
u, perr := url.Parse(raw)
|
||||
if perr != nil {
|
||||
return "", "", fmt.Errorf("无法解析 origin URL %q: %w", raw, perr)
|
||||
return Origin{}, fmt.Errorf("无法解析 origin URL %q: %w", raw, perr)
|
||||
}
|
||||
if u.Hostname() == "" {
|
||||
return Origin{}, fmt.Errorf("origin URL %q 没有 host 部分", raw)
|
||||
}
|
||||
path := strings.TrimPrefix(u.Path, "/")
|
||||
if path == "" {
|
||||
return "", "", fmt.Errorf("origin URL %q 没有 path 部分", raw)
|
||||
return Origin{}, fmt.Errorf("origin URL %q 没有 path 部分", raw)
|
||||
}
|
||||
return splitOwnerRepo(path)
|
||||
webURL := "https://" + u.Hostname()
|
||||
if u.Scheme == "http" || u.Scheme == "https" {
|
||||
webURL = u.Scheme + "://" + u.Host
|
||||
}
|
||||
return newOrigin(u.Hostname(), webURL, path)
|
||||
}
|
||||
|
||||
// newOrigin assembles an [Origin] from a remote's host, web root and `owner/repo(.git)` path, lowercasing the host.
|
||||
func newOrigin(host, webURL, path string) (Origin, error) {
|
||||
owner, repo, err := splitOwnerRepo(path)
|
||||
if err != nil {
|
||||
return Origin{}, err
|
||||
}
|
||||
return Origin{Host: strings.ToLower(host), WebURL: strings.ToLower(webURL), Owner: owner, Repo: repo}, nil
|
||||
}
|
||||
|
||||
// ParseHost reads a `--host` value, either a bare host like `github.com` or a URL like `https://gitea.example.com/`.
|
||||
//
|
||||
// @return the lowercase hostname without port, used to pick the forge, and the web root to call, which is the URL
|
||||
// itself without its trailing slash, or `https://<host>` for a bare host
|
||||
func ParseHost(value string) (host, webURL string, err error) {
|
||||
v := strings.TrimSpace(value)
|
||||
if v == "" {
|
||||
return "", "", fmt.Errorf("--host 不能为空")
|
||||
}
|
||||
if !strings.Contains(v, "://") {
|
||||
v = "https://" + v
|
||||
}
|
||||
u, err := url.Parse(v)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("无法解析 --host %q: %w", value, err)
|
||||
}
|
||||
if u.Hostname() == "" {
|
||||
return "", "", fmt.Errorf("--host %q 里没有主机名", value)
|
||||
}
|
||||
return strings.ToLower(u.Hostname()), u.Scheme + "://" + strings.ToLower(u.Host) + strings.TrimRight(u.Path, "/"), nil
|
||||
}
|
||||
|
||||
// splitOwnerRepo turns "owner/repo(.git)" into ("owner", "repo").
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
package repo
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os/exec"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseRemoteURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw, owner, repo string
|
||||
raw, host, webURL, owner, repo string
|
||||
}{
|
||||
{"https://gitea.example.com/foo/bar.git", "foo", "bar"},
|
||||
{"https://gitea.example.com/foo/bar", "foo", "bar"},
|
||||
{"http://localhost:3000/foo/bar.git", "foo", "bar"},
|
||||
{"git@gitea.example.com:foo/bar.git", "foo", "bar"},
|
||||
{"git@gitea.example.com:foo/bar", "foo", "bar"},
|
||||
{"ssh://git@gitea.example.com:22/foo/bar.git", "foo", "bar"},
|
||||
{"https://gitea.example.com/foo/bar.git", "gitea.example.com", "https://gitea.example.com", "foo", "bar"},
|
||||
{"https://gitea.example.com/foo/bar", "gitea.example.com", "https://gitea.example.com", "foo", "bar"},
|
||||
{"https://user:secret@gitea.example.com/foo/bar.git", "gitea.example.com", "https://gitea.example.com", "foo", "bar"},
|
||||
{"http://localhost:3000/foo/bar.git", "localhost", "http://localhost:3000", "foo", "bar"},
|
||||
{"https://GitHub.com/Foo/Bar.git", "github.com", "https://github.com", "Foo", "Bar"},
|
||||
{"git@gitea.example.com:foo/bar.git", "gitea.example.com", "https://gitea.example.com", "foo", "bar"},
|
||||
{"git@gitea.example.com:foo/bar", "gitea.example.com", "https://gitea.example.com", "foo", "bar"},
|
||||
{"git@github.com:foo/bar.git", "github.com", "https://github.com", "foo", "bar"},
|
||||
{"ssh://git@gitea.example.com:22/foo/bar.git", "gitea.example.com", "https://gitea.example.com", "foo", "bar"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.raw, func(t *testing.T) {
|
||||
owner, repo, err := parseRemoteURL(c.raw)
|
||||
o, err := parseRemoteURL(c.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if owner != c.owner || repo != c.repo {
|
||||
t.Fatalf("got %s/%s want %s/%s", owner, repo, c.owner, c.repo)
|
||||
want := Origin{Host: c.host, WebURL: c.webURL, Owner: c.owner, Repo: c.repo}
|
||||
if o != want {
|
||||
t.Fatalf("got %+v want %+v", o, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -34,8 +41,68 @@ func TestParseRemoteURLRejectsBadInput(t *testing.T) {
|
||||
"https://gitea.example.com/onlyowner",
|
||||
}
|
||||
for _, raw := range bad {
|
||||
if _, _, err := parseRemoteURL(raw); err == nil {
|
||||
if _, err := parseRemoteURL(raw); err == nil {
|
||||
t.Fatalf("expected error for %q", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHost(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, host, webURL string
|
||||
}{
|
||||
{"github.com", "github.com", "https://github.com"},
|
||||
{"GitHub.com", "github.com", "https://github.com"},
|
||||
{"gitea.example.com/", "gitea.example.com", "https://gitea.example.com"},
|
||||
{"https://gitea.example.com/", "gitea.example.com", "https://gitea.example.com"},
|
||||
{"http://localhost:3000", "localhost", "http://localhost:3000"},
|
||||
{"https://example.com/gitea/", "example.com", "https://example.com/gitea"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.in, func(t *testing.T) {
|
||||
host, webURL, err := ParseHost(c.in)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != c.host || webURL != c.webURL {
|
||||
t.Fatalf("got (%s, %s) want (%s, %s)", host, webURL, c.host, c.webURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, bad := range []string{"", " ", "https://", "https:///foo"} {
|
||||
if _, _, err := ParseHost(bad); err == nil {
|
||||
t.Fatalf("expected error for %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectOriginReadsGitRemote(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, args := range [][]string{
|
||||
{"init", "-q"},
|
||||
{"remote", "add", "origin", "git@github.com:acme/widgets.git"},
|
||||
} {
|
||||
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v 失败: %v: %s", args, err, out)
|
||||
}
|
||||
}
|
||||
o, err := DetectOrigin(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectOrigin 出错: %v", err)
|
||||
}
|
||||
want := Origin{Host: "github.com", WebURL: "https://github.com", Owner: "acme", Repo: "widgets"}
|
||||
if o != want {
|
||||
t.Fatalf("got %+v want %+v", o, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectOriginFailsWithoutRemote(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if out, err := exec.Command("git", "-C", dir, "init", "-q").CombinedOutput(); err != nil {
|
||||
t.Fatalf("git init 失败: %v: %s", err, out)
|
||||
}
|
||||
if _, err := DetectOrigin(dir); err == nil {
|
||||
t.Fatal("没有 origin 时应当报错")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// The state comment is wrapped in an HTML comment so the forge's web page hides it from readers.
|
||||
const (
|
||||
commentPrefix = "<!-- spx-state"
|
||||
commentSuffix = "-->"
|
||||
// legacyMarker heads the state comments the YouTrack integration wrote before the hidden format existed.
|
||||
legacyMarker = "<!--spx-state-->"
|
||||
)
|
||||
|
||||
// knownFields lists the top-level keys of the embedded schema, so a comment counts as state only if it uses a real state key.
|
||||
var knownFields = sync.OnceValue(func() map[string]struct{} {
|
||||
var doc struct {
|
||||
Properties map[string]json.RawMessage `json:"properties"`
|
||||
}
|
||||
if err := json.Unmarshal(schemaBytes, &doc); err != nil || len(doc.Properties) == 0 {
|
||||
panic(fmt.Sprintf("内嵌 schema 没有可用的 properties: %v", err))
|
||||
}
|
||||
set := make(map[string]struct{}, len(doc.Properties))
|
||||
for name := range doc.Properties {
|
||||
set[name] = struct{}{}
|
||||
}
|
||||
return set
|
||||
})
|
||||
|
||||
// FormatComment renders `s` as a comment body that the forge's web page hides: `<!-- spx-state`, compact JSON, `-->`.
|
||||
//
|
||||
// [json.Marshal] escapes `<`, `>` and `&` as `\u003c`, `\u003e` and `\u0026`, so no value can close the HTML comment early.
|
||||
func FormatComment(s map[string]any) (string, error) {
|
||||
buf, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("序列化 state JSON 失败: %w", err)
|
||||
}
|
||||
return commentPrefix + "\n" + string(buf) + "\n" + commentSuffix, nil
|
||||
}
|
||||
|
||||
// ParseComment reads the state out of a comment body, or reports `false` when the body is not a state comment.
|
||||
//
|
||||
// It accepts the hidden format [FormatComment] writes, the older YouTrack form (`<!--spx-state-->` then JSON) and an
|
||||
// older bare JSON object. The JSON must be an object carrying at least one schema field, so ordinary chatter that
|
||||
// happens to be JSON is not mistaken for state.
|
||||
func ParseComment(body string) (map[string]any, bool) {
|
||||
text := strings.TrimSpace(body)
|
||||
switch {
|
||||
case strings.HasPrefix(text, legacyMarker):
|
||||
text = text[len(legacyMarker):]
|
||||
case strings.HasPrefix(text, commentPrefix) && strings.HasSuffix(text, commentSuffix) &&
|
||||
len(text) >= len(commentPrefix)+len(commentSuffix):
|
||||
text = text[len(commentPrefix) : len(text)-len(commentSuffix)]
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &m); err != nil || m == nil {
|
||||
return nil, false
|
||||
}
|
||||
fields := knownFields()
|
||||
for k := range m {
|
||||
if _, ok := fields[k]; ok {
|
||||
return m, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Latest returns the state from the newest state comment among `bodies` (oldest first), or an empty map when there is none.
|
||||
//
|
||||
// Scanning from the end means comments posted after the state, such as review notes or chatter, never hide it.
|
||||
func Latest(bodies []string) map[string]any {
|
||||
for i := len(bodies) - 1; i >= 0; i-- {
|
||||
if m, ok := ParseComment(bodies[i]); ok {
|
||||
return m
|
||||
}
|
||||
}
|
||||
return map[string]any{}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatCommentWrapsCompactJSONInHiddenComment(t *testing.T) {
|
||||
body, err := FormatComment(map[string]any{"column": "review", "branch": "feat/x"})
|
||||
if err != nil {
|
||||
t.Fatalf("FormatComment 出错: %v", err)
|
||||
}
|
||||
want := "<!-- spx-state\n{\"branch\":\"feat/x\",\"column\":\"review\"}\n-->"
|
||||
if body != want {
|
||||
t.Fatalf("got %q\nwant %q", body, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCommentEscapesCommentTerminators(t *testing.T) {
|
||||
raw := "a --> b <!-- c & d"
|
||||
body, err := FormatComment(map[string]any{"handoffRequestError": raw})
|
||||
if err != nil {
|
||||
t.Fatalf("FormatComment 出错: %v", err)
|
||||
}
|
||||
inner := strings.TrimSuffix(strings.TrimPrefix(body, "<!-- spx-state\n"), "\n-->")
|
||||
if inner == body {
|
||||
t.Fatalf("输出不是隐藏注释格式: %q", body)
|
||||
}
|
||||
if strings.Contains(inner, "-->") || strings.ContainsAny(inner, "<>&") {
|
||||
t.Fatalf("JSON 正文里不应出现 `-->` 或裸的 < > &: %q", inner)
|
||||
}
|
||||
for _, esc := range []string{`\u003c`, `\u003e`, `\u0026`} {
|
||||
if !strings.Contains(inner, esc) {
|
||||
t.Fatalf("JSON 正文应把字符转义成 %s: %q", esc, inner)
|
||||
}
|
||||
}
|
||||
got, ok := ParseComment(body)
|
||||
if !ok {
|
||||
t.Fatalf("自己写出的评论应能读回: %q", body)
|
||||
}
|
||||
if got["handoffRequestError"] != raw {
|
||||
t.Fatalf("往返后值变了: got %q want %q", got["handoffRequestError"], raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommentAcceptsAllThreeFormats(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"隐藏注释": "<!-- spx-state\n{\"column\":\"review\"}\n-->",
|
||||
"隐藏注释带首尾空白": " \n<!-- spx-state\n{\"column\":\"review\"}\n-->\n ",
|
||||
"旧 YouTrack 格式": "<!--spx-state-->\n{\"column\":\"review\"}",
|
||||
"旧裸 JSON": `{"column":"review"}`,
|
||||
"旧裸 JSON 带空白": "\n {\"column\":\"review\"} \n",
|
||||
}
|
||||
for name, body := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
m, ok := ParseComment(body)
|
||||
if !ok {
|
||||
t.Fatalf("应当识别为 state 评论: %q", body)
|
||||
}
|
||||
if m["column"] != "review" {
|
||||
t.Fatalf("column 解析错误: %v", m)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommentRejectsNonStateBodies(t *testing.T) {
|
||||
bad := []string{
|
||||
"",
|
||||
"LGTM",
|
||||
`{"foo":1}`,
|
||||
`["column"]`,
|
||||
`null`,
|
||||
`"column"`,
|
||||
"<!-- spx-state\n{\"column\":\"review\"}",
|
||||
"<!-- spx-state\n[1]\n-->",
|
||||
"<!-- spx-state\n{\"foo\":1}\n-->",
|
||||
"<!--spx-state-->\n{\"foo\":1}",
|
||||
"状态如下: {\"column\":\"review\"}",
|
||||
"<!-- spx:review=1 -->\n\nLGTM",
|
||||
}
|
||||
for _, body := range bad {
|
||||
if m, ok := ParseComment(body); ok {
|
||||
t.Fatalf("不应识别为 state 评论: %q -> %v", body, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommentKnowsEverySchemaProperty(t *testing.T) {
|
||||
var doc struct {
|
||||
Properties map[string]json.RawMessage `json:"properties"`
|
||||
}
|
||||
if err := json.Unmarshal(schemaBytes, &doc); err != nil {
|
||||
t.Fatalf("解析内嵌 schema 失败: %v", err)
|
||||
}
|
||||
if len(doc.Properties) == 0 {
|
||||
t.Fatal("内嵌 schema 没有 properties")
|
||||
}
|
||||
for name := range doc.Properties {
|
||||
body := fmt.Sprintf(`{%q:null}`, name)
|
||||
if _, ok := ParseComment(body); !ok {
|
||||
t.Errorf("schema 字段 %s 应当被识别为 state 字段", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestPicksNewestValidStateComment(t *testing.T) {
|
||||
bodies := []string{
|
||||
`{"column":"todo","branch":"old"}`,
|
||||
"<!--spx-state-->\n{\"column\":\"in-progress\"}",
|
||||
"<!-- spx-state\n{\"column\":\"review\",\"branch\":\"new\"}\n-->",
|
||||
"LGTM",
|
||||
`{"unrelated":true}`,
|
||||
}
|
||||
got := Latest(bodies)
|
||||
if got["column"] != "review" || got["branch"] != "new" {
|
||||
t.Fatalf("应取最新一条有效 state,得到 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestReturnsEmptyMapWhenNoState(t *testing.T) {
|
||||
got := Latest([]string{"hello", `{"x":1}`})
|
||||
if got == nil || len(got) != 0 {
|
||||
t.Fatalf("没有 state 时应返回非 nil 空 map,得到 %#v", got)
|
||||
}
|
||||
if got := Latest(nil); got == nil {
|
||||
t.Fatal("空评论列表也应返回非 nil 空 map")
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
// Package tea reads the tea CLI config to extract Gitea host + token.
|
||||
// Package tea reads the tea CLI config to find the Gitea login, and its token, for a given host.
|
||||
//
|
||||
// tea config typically lives at $HOME/.config/tea/config.yml. Older versions
|
||||
// of tea (<v0.10) stored the token inline in the YAML; newer versions move
|
||||
// tokens into a system keyring and only keep metadata in the file.
|
||||
//
|
||||
// We support the inline form here (matches the spx task contract). If the
|
||||
// token field is missing, callers should consider falling back to a
|
||||
// GITEA_TOKEN environment variable and emit a clear error otherwise.
|
||||
// We support the inline form here. If the token field is missing, callers
|
||||
// fall back to the GITEA_TOKEN environment variable.
|
||||
package tea
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -53,52 +55,25 @@ func load(path string) (*TeaConfig, error) {
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// LoadDefault returns host and token from the default login entry.
|
||||
// LoginForHost returns the login in the tea config at `path` whose URL points at `host`, or `nil` when none does.
|
||||
//
|
||||
// If multiple logins have default=true, the first wins. If no entry is marked
|
||||
// default, the first entry is used.
|
||||
func LoadDefault() (host, token string, err error) {
|
||||
path, err := DefaultConfigPath()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
// Hosts are compared by hostname only, ignoring case, scheme, port and trailing slashes, so the host read from a git
|
||||
// SSH remote still finds the login saved with the forge's web URL. A missing config file is not an error either,
|
||||
// because the caller can still fall back to `GITEA_TOKEN`.
|
||||
func LoginForHost(path, host string) (*TeaLogin, error) {
|
||||
cfg, err := load(path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
return nil, err
|
||||
}
|
||||
if len(cfg.Logins) == 0 {
|
||||
return "", "", fmt.Errorf("tea config %s 里没有 logins 条目,请先运行 `tea login add`", path)
|
||||
}
|
||||
var chosen *TeaLogin
|
||||
want := strings.ToLower(host)
|
||||
for i := range cfg.Logins {
|
||||
if cfg.Logins[i].Default {
|
||||
chosen = &cfg.Logins[i]
|
||||
break
|
||||
u, perr := url.Parse(strings.TrimSpace(cfg.Logins[i].URL))
|
||||
if perr == nil && strings.ToLower(u.Hostname()) == want {
|
||||
return &cfg.Logins[i], nil
|
||||
}
|
||||
}
|
||||
if chosen == nil {
|
||||
chosen = &cfg.Logins[0]
|
||||
}
|
||||
return chosen.URL, chosen.Token, nil
|
||||
}
|
||||
|
||||
// LoadByHost returns the token for the login whose URL matches host.
|
||||
//
|
||||
// Host matching is case-insensitive and ignores trailing slashes.
|
||||
func LoadByHost(host string) (token string, err error) {
|
||||
path, err := DefaultConfigPath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cfg, err := load(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
want := strings.TrimRight(strings.ToLower(host), "/")
|
||||
for _, l := range cfg.Logins {
|
||||
if strings.TrimRight(strings.ToLower(l.URL), "/") == want {
|
||||
return l.Token, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("tea config 里没找到 host=%s 的 login", host)
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package tea
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeConfig(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("写 tea config 失败: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
const sampleConfig = `logins:
|
||||
- name: local
|
||||
url: http://localhost:3000
|
||||
token: tok-local
|
||||
default: true
|
||||
- name: work
|
||||
url: https://Gitea.Example.com/
|
||||
token: tok-work
|
||||
`
|
||||
|
||||
func TestLoginForHostMatchesByHostname(t *testing.T) {
|
||||
path := writeConfig(t, sampleConfig)
|
||||
cases := map[string]string{
|
||||
"gitea.example.com": "tok-work",
|
||||
"GITEA.example.com": "tok-work",
|
||||
"localhost": "tok-local",
|
||||
}
|
||||
for host, wantToken := range cases {
|
||||
l, err := LoginForHost(path, host)
|
||||
if err != nil {
|
||||
t.Fatalf("LoginForHost(%s) 出错: %v", host, err)
|
||||
}
|
||||
if l == nil || l.Token != wantToken {
|
||||
t.Fatalf("LoginForHost(%s) = %+v, want token %s", host, l, wantToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginForHostIgnoresDefaultLoginOfOtherHost(t *testing.T) {
|
||||
path := writeConfig(t, sampleConfig)
|
||||
l, err := LoginForHost(path, "git.other.org")
|
||||
if err != nil {
|
||||
t.Fatalf("没匹配上不应报错: %v", err)
|
||||
}
|
||||
if l != nil {
|
||||
t.Fatalf("host 不匹配时不应回退到默认 login,得到 %+v", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginForHostMissingConfigIsNotAnError(t *testing.T) {
|
||||
l, err := LoginForHost(filepath.Join(t.TempDir(), "absent.yml"), "gitea.example.com")
|
||||
if err != nil || l != nil {
|
||||
t.Fatalf("没有 tea config 时应返回 (nil, nil),得到 (%+v, %v)", l, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginForHostBadYAMLIsAnError(t *testing.T) {
|
||||
path := writeConfig(t, "logins: [this is: not valid")
|
||||
if _, err := LoginForHost(path, "gitea.example.com"); err == nil {
|
||||
t.Fatal("tea config 解析失败应当报错")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user