Files
superwork/vscode/cli/internal/forge/forgetest/server.go
T

449 lines
13 KiB
Go

// 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)
}