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