// Package fakegitea is a stateful in-memory Gitea API fake used by the // test suite and the smoke run. The real forge is NEVER contacted. It // implements exactly the endpoints the gitea client uses, enforces // "Authorization: token " auth on every one of them, and records // every request so tests can assert round-trips. Error responses // deliberately echo the presented token back in the body: any test that // survives that proves the client never surfaces response bodies // (token-redaction guarantee). package fakegitea import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "sort" "strconv" "strings" "sync" ) // Request is one recorded exchange (auth checked, body captured). type Request struct { Method string Path string // path without query Query string // raw query Body string } // User is the account shape. type User struct { ID int64 `json:"id"` Login string `json:"login"` FullName string `json:"full_name"` } // Repo is the stored repository (server side). type Repo struct { ID int64 `json:"id"` Owner User `json:"owner"` Name string `json:"name"` FullName string `json:"full_name"` Description string `json:"description"` Private bool `json:"private"` DefaultBranch string `json:"default_branch"` HTMLURL string `json:"html_url"` CloneURL string `json:"clone_url"` } // Branch is the branch shape (Commit carries the tip sha). type Branch struct { Name string `json:"name"` Commit CommitInfo `json:"commit"` Protected bool `json:"protected"` } // CommitInfo is the commit projection branches carry. type CommitInfo struct { ID string `json:"id"` Message string `json:"message"` } // StatusRecord is one stored commit status (append-only per sha). type StatusRecord struct { ID int64 `json:"id"` State string `json:"state"` Context string `json:"context"` Description string `json:"description"` TargetURL string `json:"target_url"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } // PullRequest is the stored PR. type PullRequest struct { Number int64 `json:"number"` Title string `json:"title"` Body string `json:"body"` State string `json:"state"` // open|closed User User `json:"user"` Base PRBranch `json:"base"` Head PRBranch `json:"head"` Mergeable bool `json:"mergeable"` Merged bool `json:"merged"` MergedAt string `json:"merged_at"` HTMLURL string `json:"html_url"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } // PRBranch is one end of a pull request. type PRBranch struct { Ref string `json:"ref"` SHA string `json:"sha"` Repo Repo `json:"repo"` } type repoState struct { repo Repo branches map[string]string // name -> tip sha order []string // insertion order of branch names statuses map[string][]StatusRecord prs map[int64]*PullRequest nextPR int64 } // Server is the fake Gitea. type Server struct { URL string Login string // the account the token authenticates as Token string mu sync.Mutex srv *httptest.Server requests []Request repos map[string]*repoState // "owner/name" -> state nextRepo int64 nextStat int64 // Fail, when non-nil, is returned instead of normal handling; it is // consumed by the first request that sees it. Fail *FailSpec } // FailSpec pins the next response (error-mapping tests). type FailSpec struct { Status int Body string // may contain one %s: the presented token } // New starts a fake on a random port authenticating as login. func New(token, login string) *Server { s := newServer(token, login) mux := http.NewServeMux() mux.HandleFunc("/", s.handler) s.srv = httptest.NewServer(mux) s.URL = s.srv.URL return s } // ListenAndServe runs the fake on a fixed address (the smoke run boots it // in a container). An empty token disables auth checking. func ListenAndServe(addr, token string) error { s := newServer(token, "ukrrs") mux := http.NewServeMux() mux.HandleFunc("/", s.handler) return http.ListenAndServe(addr, mux) } func newServer(token, login string) *Server { return &Server{ Token: token, Login: login, repos: map[string]*repoState{}, nextRepo: 1000, nextStat: 5000, } } // Close shuts the fake down. func (s *Server) Close() { s.srv.Close() } // Requests returns a copy of the recorded exchanges. func (s *Server) Requests() []Request { s.mu.Lock() defer s.mu.Unlock() out := make([]Request, len(s.requests)) copy(out, s.requests) return out } // AddRepo seeds a repository directly (bypassing REST) with the given // branches (first is the default when defaultBranch is empty) and returns // its "owner/name" key. func (s *Server) AddRepo(owner, name, defaultBranch string, branches ...string) string { s.mu.Lock() defer s.mu.Unlock() return s.addRepoLocked(owner, name, defaultBranch, branches) } func (s *Server) addRepoLocked(owner, name, defaultBranch string, branches []string) string { if defaultBranch == "" && len(branches) > 0 { defaultBranch = branches[0] } s.nextRepo++ st := &repoState{ repo: Repo{ ID: s.nextRepo, Owner: User{ID: 10, Login: owner, FullName: owner}, Name: name, FullName: owner + "/" + name, DefaultBranch: defaultBranch, HTMLURL: "https://git.example/" + owner + "/" + name, CloneURL: "https://git.example/" + owner + "/" + name + ".git", }, branches: map[string]string{}, statuses: map[string][]StatusRecord{}, prs: map[int64]*PullRequest{}, } for _, b := range branches { st.addBranch(b) } key := owner + "/" + name s.repos[key] = st return key } func (st *repoState) addBranch(name string) string { // Distinct, well-formed 40-hex shas derived from the branch key, so // short-sha (>= 7 chars) resolution is unambiguous like real git. sum := sha256.Sum256([]byte(st.repo.FullName + "/" + name)) sha := hex.EncodeToString(sum[:])[:40] if _, ok := st.branches[name]; !ok { st.order = append(st.order, name) } st.branches[name] = sha return sha } // AddBranch seeds a branch directly and returns its (new) tip sha. func (s *Server) AddBranch(owner, name, branch string) string { s.mu.Lock() defer s.mu.Unlock() st := s.repos[owner+"/"+name] return st.addBranch(branch) } // Repo returns a copy of a stored repo (for assertions). func (s *Server) Repo(owner, name string) (Repo, bool) { s.mu.Lock() defer s.mu.Unlock() st, ok := s.repos[owner+"/"+name] if !ok { return Repo{}, false } return st.repo, true } // PullRequest returns a copy of a stored PR (for assertions). func (s *Server) PullRequest(owner, name string, number int64) (PullRequest, bool) { s.mu.Lock() defer s.mu.Unlock() st, ok := s.repos[owner+"/"+name] if !ok { return PullRequest{}, false } pr, ok := st.prs[number] if !ok { return PullRequest{}, false } return *pr, true } // Statuses returns the raw status history of one sha (for assertions). func (s *Server) Statuses(owner, name, sha string) []StatusRecord { s.mu.Lock() defer s.mu.Unlock() st, ok := s.repos[owner+"/"+name] if !ok { return nil } out := make([]StatusRecord, len(st.statuses[sha])) copy(out, st.statuses[sha]) return out } func (s *Server) handler(w http.ResponseWriter, r *http.Request) { token := "" if s.Token != "" { auth := r.Header.Get("Authorization") if strings.HasPrefix(auth, "token ") { token = strings.TrimPrefix(auth, "token ") } } body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) s.mu.Lock() if s.Fail != nil { spec := *s.Fail s.Fail = nil s.requests = append(s.requests, Request{r.Method, r.URL.Path, r.URL.RawQuery, string(body)}) s.mu.Unlock() // The body deliberately embeds the presented token: surviving // this proves the client drops response bodies from errors. w.Header().Set("Content-Type", "application/json") w.WriteHeader(spec.Status) fmt.Fprintf(w, spec.Body, token) return } if s.Token != "" && token != s.Token { s.requests = append(s.requests, Request{r.Method, r.URL.Path, r.URL.RawQuery, ""}) s.mu.Unlock() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) fmt.Fprintf(w, `{"message":"bad credentials: token %s was rejected"}`, token) return } s.requests = append(s.requests, Request{r.Method, r.URL.Path, r.URL.RawQuery, string(body)}) var ( respStatus = http.StatusOK respBody string ) path := r.URL.Path q := r.URL.Query() switch { case r.Method == http.MethodGet && path == "/api/v1/user/repos": respStatus, respBody = s.listRepos(s.Login) case r.Method == http.MethodPost && path == "/api/v1/user/repos": respStatus, respBody = s.createRepo(s.Login, body) case r.Method == http.MethodGet && strings.HasPrefix(path, "/api/v1/users/") && strings.HasSuffix(path, "/repos"): owner := strings.TrimSuffix(strings.TrimPrefix(path, "/api/v1/users/"), "/repos") respStatus, respBody = s.listRepos(owner) case r.Method == http.MethodPost && strings.HasPrefix(path, "/api/v1/orgs/") && strings.HasSuffix(path, "/repos"): owner := strings.TrimSuffix(strings.TrimPrefix(path, "/api/v1/orgs/"), "/repos") respStatus, respBody = s.createRepo(owner, body) case r.Method == http.MethodGet && strings.HasPrefix(path, "/api/v1/repos/") && strings.Count(path, "/") == 5: owner, name := splitRepo(strings.TrimPrefix(path, "/api/v1/repos/")) respStatus, respBody = s.getRepo(owner, name) case r.Method == http.MethodGet && strings.HasSuffix(path, "/branches") && strings.HasPrefix(path, "/api/v1/repos/"): owner, name := splitRepo(strings.TrimSuffix(strings.TrimPrefix(path, "/api/v1/repos/"), "/branches")) respStatus, respBody = s.listBranches(owner, name) case r.Method == http.MethodPost && strings.Contains(path, "/statuses/") && strings.HasPrefix(path, "/api/v1/repos/"): rest := strings.TrimPrefix(path, "/api/v1/repos/") parts := strings.SplitN(rest, "/statuses/", 2) owner, name := splitRepo(parts[0]) respStatus, respBody = s.createStatus(owner, name, parts[1], body) case r.Method == http.MethodGet && strings.HasSuffix(path, "/statuses") && strings.HasPrefix(path, "/api/v1/repos/"): rest := strings.TrimSuffix(strings.TrimPrefix(path, "/api/v1/repos/"), "/statuses") parts := strings.SplitN(rest, "/commits/", 2) owner, name := splitRepo(parts[0]) respStatus, respBody = s.listStatuses(owner, name, parts[1]) case r.Method == http.MethodGet && strings.HasSuffix(path, "/status") && strings.HasPrefix(path, "/api/v1/repos/"): rest := strings.TrimSuffix(strings.TrimPrefix(path, "/api/v1/repos/"), "/status") parts := strings.SplitN(rest, "/commits/", 2) owner, name := splitRepo(parts[0]) respStatus, respBody = s.combinedStatus(owner, name, parts[1]) case r.Method == http.MethodGet && strings.HasSuffix(path, "/pulls") && strings.HasPrefix(path, "/api/v1/repos/"): owner, name := splitRepo(strings.TrimSuffix(strings.TrimPrefix(path, "/api/v1/repos/"), "/pulls")) respStatus, respBody = s.listPulls(owner, name, q.Get("state")) case r.Method == http.MethodPost && strings.HasSuffix(path, "/pulls") && strings.HasPrefix(path, "/api/v1/repos/"): owner, name := splitRepo(strings.TrimSuffix(strings.TrimPrefix(path, "/api/v1/repos/"), "/pulls")) respStatus, respBody = s.createPull(owner, name, body) case r.Method == http.MethodPost && strings.Contains(path, "/pulls/") && strings.HasSuffix(path, "/merge") && strings.HasPrefix(path, "/api/v1/repos/"): rest := strings.TrimPrefix(path, "/api/v1/repos/") parts := strings.SplitN(rest, "/pulls/", 2) owner, name := splitRepo(parts[0]) num, _ := strconv.ParseInt(strings.TrimSuffix(parts[1], "/merge"), 10, 64) respStatus, respBody = s.mergePull(owner, name, num, body) case r.Method == http.MethodGet && strings.Contains(path, "/pulls/") && strings.HasPrefix(path, "/api/v1/repos/"): rest := strings.TrimPrefix(path, "/api/v1/repos/") parts := strings.SplitN(rest, "/pulls/", 2) owner, name := splitRepo(parts[0]) num, _ := strconv.ParseInt(parts[1], 10, 64) respStatus, respBody = s.getPull(owner, name, num) default: respStatus, respBody = http.StatusNotFound, `{"message":"Not Found"}` } s.mu.Unlock() w.Header().Set("Content-Type", "application/json") w.WriteHeader(respStatus) if respBody != "" { io.WriteString(w, respBody) } } func splitRepo(s string) (owner, name string) { parts := strings.SplitN(s, "/", 2) if len(parts) != 2 { return "", "" } return parts[0], parts[1] } func jsonReply(v any) (int, string) { b, err := json.Marshal(v) if err != nil { return http.StatusInternalServerError, `{"message":"marshal"}` } return http.StatusOK, string(b) } // --- repos ----------------------------------------------------------------- func (s *Server) listRepos(owner string) (int, string) { var out []Repo for _, key := range s.sortedRepoKeys() { st := s.repos[key] if st.repo.Owner.Login == owner { out = append(out, st.repo) } } if out == nil { out = []Repo{} } return jsonReply(out) } func (s *Server) sortedRepoKeys() []string { keys := make([]string, 0, len(s.repos)) for k := range s.repos { keys = append(keys, k) } sort.Strings(keys) return keys } func (s *Server) getRepo(owner, name string) (int, string) { st, ok := s.repos[owner+"/"+name] if !ok { return http.StatusNotFound, `{"message":"The target couldn't be found."}` } return jsonReply(st.repo) } func (s *Server) createRepo(owner string, body []byte) (int, string) { var p struct { Name string `json:"name"` Description string `json:"description"` Private bool `json:"private"` AutoInit bool `json:"auto_init"` DefaultBranch string `json:"default_branch"` } if err := json.Unmarshal(body, &p); err != nil { return http.StatusBadRequest, `{"message":"bad json"}` } if p.Name == "" { return http.StatusUnprocessableEntity, `{"message":"name is required"}` } if _, exists := s.repos[owner+"/"+p.Name]; exists { return http.StatusConflict, `{"message":"The repository with the same name already exists."}` } branches := []string(nil) if p.AutoInit { branches = []string{"main"} } key := s.addRepoLocked(owner, p.Name, p.DefaultBranch, branches) st := s.repos[key] st.repo.Description = p.Description st.repo.Private = p.Private return http.StatusCreated, mustJSON(st.repo) } func mustJSON(v any) string { b, err := json.Marshal(v) if err != nil { panic(err) } return string(b) } // --- branches --------------------------------------------------------------- func (s *Server) listBranches(owner, name string) (int, string) { st, ok := s.repos[owner+"/"+name] if !ok { return http.StatusNotFound, `{"message":"The target couldn't be found."}` } out := make([]Branch, 0, len(st.order)) for _, b := range st.order { sha := st.branches[b] out = append(out, Branch{Name: b, Commit: CommitInfo{ID: sha, Message: "fake commit " + sha[:7]}}) } return jsonReply(out) } // resolveRef maps a ref (full sha, sha prefix, or branch name) to a commit // sha; ok=false when nothing matches. Prefix matches scan branches in // insertion order for determinism. func (st *repoState) resolveRef(ref string) (string, bool) { if sha, ok := st.branches[ref]; ok { return sha, true } if len(ref) >= 7 { for _, b := range st.order { if strings.HasPrefix(st.branches[b], ref) { return st.branches[b], true } } } return "", false } // --- statuses --------------------------------------------------------------- var validStates = map[string]bool{"pending": true, "success": true, "error": true, "failure": true} func (s *Server) createStatus(owner, name, sha string, body []byte) (int, string) { st, ok := s.repos[owner+"/"+name] if !ok { return http.StatusNotFound, `{"message":"The target couldn't be found."}` } full, ok := st.resolveRef(sha) if !ok { return http.StatusNotFound, fmt.Sprintf(`{"message":"commit %s not found"}`, sha) } var p StatusRecord if err := json.Unmarshal(body, &p); err != nil { return http.StatusBadRequest, `{"message":"bad json"}` } if !validStates[p.State] { return http.StatusUnprocessableEntity, `{"message":"invalid state"}` } if p.Context == "" { p.Context = "default" } s.nextStat++ p.ID = s.nextStat p.CreatedAt = "2026-08-29T16:00:00Z" p.UpdatedAt = p.CreatedAt st.statuses[full] = append(st.statuses[full], p) return http.StatusCreated, mustJSON(p) } // latestPerContext folds the append-only history into the newest status // per context (Gitea's reported shape), in insertion order. func latestPerContext(history []StatusRecord) []StatusRecord { idx := map[string]int{} var out []StatusRecord for _, sr := range history { if i, ok := idx[sr.Context]; ok { out[i] = sr } else { idx[sr.Context] = len(out) out = append(out, sr) } } return out } func (s *Server) listStatuses(owner, name, ref string) (int, string) { st, ok := s.repos[owner+"/"+name] if !ok { return http.StatusNotFound, `{"message":"The target couldn't be found."}` } full, ok := st.resolveRef(ref) if !ok { return http.StatusNotFound, `{"message":"commit not found"}` } out := latestPerContext(st.statuses[full]) if out == nil { out = []StatusRecord{} } return jsonReply(out) } func worstState(statuses []StatusRecord) string { rank := map[string]int{"success": 0, "pending": 1, "failure": 2, "error": 3} worst, seen := "", false for _, sr := range statuses { if !seen || rank[sr.State] > rank[worst] { worst, seen = sr.State, true } } return worst } func (s *Server) combinedStatus(owner, name, ref string) (int, string) { st, ok := s.repos[owner+"/"+name] if !ok { return http.StatusNotFound, `{"message":"The target couldn't be found."}` } full, ok := st.resolveRef(ref) if !ok { return http.StatusNotFound, `{"message":"commit not found"}` } latest := latestPerContext(st.statuses[full]) if len(latest) == 0 { return http.StatusNotFound, `{"message":"no statuses for this commit"}` } out := struct { State string `json:"state"` SHA string `json:"sha"` TotalCount int `json:"total_count"` Statuses []StatusRecord `json:"statuses"` }{worstState(latest), full, len(latest), latest} return jsonReply(out) } // --- pull requests ---------------------------------------------------------- func (s *Server) prBranch(st *repoState, ref string) (PRBranch, bool) { branch := ref if i := strings.IndexByte(ref, ':'); i >= 0 { // fork syntax owner:branch branch = ref[i+1:] } sha, ok := st.branches[branch] if !ok { return PRBranch{}, false } return PRBranch{Ref: branch, SHA: sha, Repo: st.repo}, true } func (s *Server) createPull(owner, name string, body []byte) (int, string) { st, ok := s.repos[owner+"/"+name] if !ok { return http.StatusNotFound, `{"message":"The target couldn't be found."}` } var p struct { Title string `json:"title"` Body string `json:"body"` Base string `json:"base"` Head string `json:"head"` } if err := json.Unmarshal(body, &p); err != nil { return http.StatusBadRequest, `{"message":"bad json"}` } if p.Title == "" { return http.StatusUnprocessableEntity, `{"message":"title is required"}` } base, ok := s.prBranch(st, p.Base) if !ok { return http.StatusUnprocessableEntity, fmt.Sprintf(`{"message":"base branch %s does not exist"}`, p.Base) } head, ok := s.prBranch(st, p.Head) if !ok { return http.StatusUnprocessableEntity, fmt.Sprintf(`{"message":"head branch %s does not exist"}`, p.Head) } if base.Ref == head.Ref { return http.StatusUnprocessableEntity, `{"message":"head and base cannot be the same"}` } for _, pr := range st.prs { if pr.State == "open" && pr.Base.Ref == base.Ref && pr.Head.Ref == head.Ref { return http.StatusConflict, `{"message":"The pull request has already been created."}` } } st.nextPR++ now := "2026-08-29T16:00:00Z" pr := &PullRequest{ Number: st.nextPR, Title: p.Title, Body: p.Body, State: "open", User: User{ID: 10, Login: s.Login}, Base: base, Head: head, Mergeable: true, HTMLURL: st.repo.HTMLURL + "/pulls/" + strconv.FormatInt(st.nextPR, 10), CreatedAt: now, UpdatedAt: now, } st.prs[pr.Number] = pr return http.StatusCreated, mustJSON(pr) } func (s *Server) listPulls(owner, name, state string) (int, string) { st, ok := s.repos[owner+"/"+name] if !ok { return http.StatusNotFound, `{"message":"The target couldn't be found."}` } if state == "" { state = "open" // Gitea default } var nums []int64 for n := range st.prs { nums = append(nums, n) } sort.Slice(nums, func(i, j int) bool { return nums[i] < nums[j] }) out := []PullRequest{} for _, n := range nums { pr := st.prs[n] if state == "all" || pr.State == state { out = append(out, *pr) } } return jsonReply(out) } func (s *Server) getPull(owner, name string, num int64) (int, string) { st, ok := s.repos[owner+"/"+name] if !ok { return http.StatusNotFound, `{"message":"The target couldn't be found."}` } pr, ok := st.prs[num] if !ok { return http.StatusNotFound, `{"message":"The pull request couldn't be found."}` } return jsonReply(pr) } var mergeStyles = map[string]bool{"merge": true, "rebase": true, "rebase-merge": true, "squash": true, "fast-forward": true} func (s *Server) mergePull(owner, name string, num int64, body []byte) (int, string) { st, ok := s.repos[owner+"/"+name] if !ok { return http.StatusNotFound, `{"message":"The target couldn't be found."}` } pr, ok := st.prs[num] if !ok { return http.StatusNotFound, `{"message":"The pull request couldn't be found."}` } var p struct { Do string `json:"Do"` Title string `json:"MergeTitleField"` Message string `json:"MergeMessageField"` } if len(body) > 0 { if err := json.Unmarshal(body, &p); err != nil { return http.StatusBadRequest, `{"message":"bad json"}` } } if p.Do == "" { p.Do = "merge" } if !mergeStyles[p.Do] { return http.StatusUnprocessableEntity, `{"message":"invalid merge style"}` } if pr.State != "open" || pr.Merged { return http.StatusConflict, `{"message":"The pull request has already been merged or closed."}` } pr.Merged = true pr.State = "closed" pr.Mergeable = false pr.MergedAt = "2026-08-29T16:05:00Z" pr.UpdatedAt = pr.MergedAt // The head lands on base: base now points at the head sha. st.branches[pr.Base.Ref] = pr.Head.SHA return http.StatusOK, `{"message":"pull request has been merged"}` }