✨ 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,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)
|
||||
}
|
||||
Reference in new issue
Block a user