diff --git a/gitea/client.go b/gitea/client.go new file mode 100644 index 0000000..8a818da --- /dev/null +++ b/gitea/client.go @@ -0,0 +1,121 @@ +package gitea + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Sentinel errors. Wrap-check with errors.Is; every API failure maps to +// exactly one of these plus an "http NNN" code in the message. +var ( + ErrUnreachable = errors.New("gitea: unreachable") + ErrMalformedResponse = errors.New("gitea: malformed response") + ErrAuth = errors.New("gitea: auth failed") + ErrNotFound = errors.New("gitea: not found") + ErrValidation = errors.New("gitea: validation failed") + ErrServer = errors.New("gitea: server error") +) + +// Config configures a Client. +type Config struct { + BaseURL string // e.g. https://git.knownelement.com (no trailing slash needed) + Token string // Gitea API token; header-only, never logged + Timeout time.Duration +} + +// Client is a Gitea API v1 client. Safe for concurrent use. +type Client struct { + cfg Config + http *http.Client +} + +// New builds a client. A zero Timeout means 30s. +func New(cfg Config) *Client { + if cfg.Timeout == 0 { + cfg.Timeout = 30 * time.Second + } + cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/") + return &Client{cfg: cfg, http: &http.Client{Timeout: cfg.Timeout}} +} + +// do performs one JSON exchange against /api/v1. out may be nil for +// empty bodies. +func (c *Client) do(ctx context.Context, method, path string, query url.Values, in any, out any) error { + var body io.Reader + if in != nil { + b, err := json.Marshal(in) + if err != nil { + return fmt.Errorf("%w: cannot encode request", ErrMalformedResponse) + } + body = strings.NewReader(string(b)) + } + u := c.cfg.BaseURL + "/api/v1" + path + if len(query) > 0 { + u += "?" + query.Encode() + } + req, err := http.NewRequestWithContext(ctx, method, u, body) + if err != nil { + return fmt.Errorf("%w: bad endpoint", ErrUnreachable) + } + req.Header.Set("Authorization", "token "+c.cfg.Token) // token lives here and only here + req.Header.Set("Accept", "application/json") + if in != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.http.Do(req) + if err != nil { + // Transport errors embed URLs and peer text; drop them all. + return ErrUnreachable + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if err != nil { + return ErrUnreachable + } + return handleResponse(resp.StatusCode, raw, out) +} + +// handleResponse maps one HTTP exchange to typed errors. Response BODIES +// are never surfaced: a server echo is assumed to be able to contain the +// token (see the fakegitea package, which deliberately echoes it). +func handleResponse(status int, body []byte, out any) error { + switch { + case status >= 200 && status < 300: + if out == nil { + return nil + } + if len(body) == 0 { + return fmt.Errorf("%w: empty body", ErrMalformedResponse) + } + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("%w: body is not valid json", ErrMalformedResponse) + } + return nil + default: + return statusError(status) + } +} + +// statusError maps a non-2xx status to a sentinel + parseable one-liner. +func statusError(status int) error { + switch { + case status == http.StatusUnauthorized || status == http.StatusForbidden: + return fmt.Errorf("%w: http %d", ErrAuth, status) + case status == http.StatusNotFound: + return fmt.Errorf("%w: http %d", ErrNotFound, status) + case status == http.StatusUnprocessableEntity || status == http.StatusBadRequest || status == http.StatusConflict: + return fmt.Errorf("%w: http %d", ErrValidation, status) + case status >= 500: + return fmt.Errorf("%w: http %d", ErrServer, status) + default: + return fmt.Errorf("%w: http %d", ErrServer, status) + } +} diff --git a/gitea/repos.go b/gitea/repos.go new file mode 100644 index 0000000..09ae07d --- /dev/null +++ b/gitea/repos.go @@ -0,0 +1,90 @@ +package gitea + +import ( + "context" + "net/url" +) + +// User is the account projection repos and PRs carry. +type User struct { + ID int64 `json:"id"` + Login string `json:"login"` + FullName string `json:"full_name,omitempty"` +} + +// Repo is the repository projection (read shape). +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"` + Empty bool `json:"empty"` + DefaultBranch string `json:"default_branch"` + HTMLURL string `json:"html_url"` + CloneURL string `json:"clone_url"` +} + +// RepoParams is the repo-create write shape. Owner routes the create: +// empty creates under the authenticated account (POST /user/repos), any +// other value creates under that organization (POST /orgs/{owner}/repos). +type RepoParams struct { + Owner string + Name string + Description string + Private bool + AutoInit bool + DefaultBranch string +} + +type repoWrite struct { + Name string `json:"name"` + Description string `json:"description"` + Private bool `json:"private"` + AutoInit bool `json:"auto_init"` + DefaultBranch string `json:"default_branch,omitempty"` +} + +// CreateRepo creates a repository and returns it. +func (c *Client) CreateRepo(ctx context.Context, p RepoParams) (*Repo, error) { + path := "/user/repos" + if p.Owner != "" { + path = "/orgs/" + url.PathEscape(p.Owner) + "/repos" + } + var out Repo + if err := c.do(ctx, "POST", path, nil, repoWrite{ + Name: p.Name, + Description: p.Description, + Private: p.Private, + AutoInit: p.AutoInit, + DefaultBranch: p.DefaultBranch, + }, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ListRepos lists repositories. An empty owner lists the authenticated +// account's own repos (GET /user/repos); otherwise the named account's +// or organization's (GET /users/{owner}/repos). +func (c *Client) ListRepos(ctx context.Context, owner string) ([]Repo, error) { + path := "/user/repos" + if owner != "" { + path = "/users/" + url.PathEscape(owner) + "/repos" + } + var out []Repo + if err := c.do(ctx, "GET", path, nil, nil, &out); err != nil { + return nil, err + } + return out, nil +} + +// GetRepo fetches one repository. +func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error) { + var out Repo + if err := c.do(ctx, "GET", "/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(name), nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/gitea_test.go b/gitea_test.go new file mode 100644 index 0000000..90fd354 --- /dev/null +++ b/gitea_test.go @@ -0,0 +1,230 @@ +package gitea_test + +import ( + "context" + "encoding/json" + "errors" + "strconv" + "strings" + "testing" + "time" + + "git.knownelement.com/ukrrs/mopac-gitea-go/gitea" + "git.knownelement.com/ukrrs/mopac-gitea-go/internal/fakegitea" +) + +const fakeToken = "fake-gitea-token-0123456789" + +func newClient(t *testing.T) (*gitea.Client, *fakegitea.Server) { + t.Helper() + srv := fakegitea.New(fakeToken, "ukrrs") + t.Cleanup(srv.Close) + c := gitea.New(gitea.Config{BaseURL: srv.URL, Token: fakeToken, Timeout: 5 * time.Second}) + return c, srv +} + +// --- transport core: auth, error mapping, redaction -------------------------- + +func TestGetRepoRoundTrip(t *testing.T) { + c, srv := newClient(t) + srv.AddRepo("ukrrs", "MOPAC", "main", "main", "feat/quota") + + repo, err := c.GetRepo(context.Background(), "ukrrs", "MOPAC") + if err != nil { + t.Fatalf("GetRepo: %v", err) + } + if repo.FullName != "ukrrs/MOPAC" || repo.DefaultBranch != "main" || repo.Owner.Login != "ukrrs" { + t.Fatalf("repo = %+v", repo) + } + reqs := srv.Requests() + if len(reqs) != 1 || reqs[0].Method != "GET" || reqs[0].Path != "/api/v1/repos/ukrrs/MOPAC" { + t.Fatalf("requests = %+v", reqs) + } + // The token lives in the Authorization header only: never in the + // path, query, or body the server recorded. + for _, r := range reqs { + if strings.Contains(r.Path+r.Query+r.Body, fakeToken) { + t.Fatalf("token leaked into request: %+v", r) + } + } +} + +func TestErrorMapping(t *testing.T) { + tests := []struct { + status int + body string + wantSent error + }{ + {401, `{"message":"bad credentials: token %s"}`, gitea.ErrAuth}, + {403, `{"message":"forbidden for token %s"}`, gitea.ErrAuth}, + {404, `{"message":"The target couldn't be found. Echo %s"}`, gitea.ErrNotFound}, + {409, `{"message":"conflict, token %s"}`, gitea.ErrValidation}, + {422, `{"message":"validation failed for %s"}`, gitea.ErrValidation}, + {500, `{"message":"server exploded with token %s"}`, gitea.ErrServer}, + {503, `{"message":"down for %s"}`, gitea.ErrServer}, + } + for _, tt := range tests { + t.Run("http_"+strconv.Itoa(tt.status), func(t *testing.T) { + c, srv := newClient(t) + srv.Fail = &fakegitea.FailSpec{Status: tt.status, Body: tt.body} + _, err := c.GetRepo(context.Background(), "ukrrs", "MOPAC") + if err == nil { + t.Fatalf("want error for http %d", tt.status) + } + if !errors.Is(err, tt.wantSent) { + t.Fatalf("err = %v, want %v", err, tt.wantSent) + } + if !strings.Contains(err.Error(), "http "+strconv.Itoa(tt.status)) { + t.Fatalf("err = %q, want http code in message", err) + } + }) + } +} + +func TestTokenNeverLeaks(t *testing.T) { + c, srv := newClient(t) + srv.Fail = &fakegitea.FailSpec{Status: 401, Body: `{"message":"token %s was rejected"}`} + _, err := c.GetRepo(context.Background(), "ukrrs", "MOPAC") + if err == nil || !errors.Is(err, gitea.ErrAuth) { + t.Fatalf("err = %v, want auth failure", err) + } + if strings.Contains(err.Error(), fakeToken) { + t.Fatalf("error leaks the token: %q", err) + } + if strings.Contains(err.Error(), "was rejected") { + t.Fatalf("error leaks response body: %q", err) + } +} + +func TestUnreachable(t *testing.T) { + c, srv := newClient(t) + srv.Close() // port gone; every call must map to ErrUnreachable, not panic + _, err := c.GetRepo(context.Background(), "ukrrs", "MOPAC") + if !errors.Is(err, gitea.ErrUnreachable) { + t.Fatalf("err = %v, want ErrUnreachable", err) + } +} + +func TestMalformedResponse(t *testing.T) { + c, srv := newClient(t) + srv.Fail = &fakegitea.FailSpec{Status: 200, Body: `not json`} + _, err := c.GetRepo(context.Background(), "ukrrs", "MOPAC") + if !errors.Is(err, gitea.ErrMalformedResponse) { + t.Fatalf("err = %v, want ErrMalformedResponse", err) + } +} + +func TestBadTokenRejected(t *testing.T) { + srv := fakegitea.New(fakeToken, "ukrrs") + t.Cleanup(srv.Close) + c := gitea.New(gitea.Config{BaseURL: srv.URL, Token: "wrong-token-abcdef"}) + _, err := c.GetRepo(context.Background(), "ukrrs", "MOPAC") + if !errors.Is(err, gitea.ErrAuth) { + t.Fatalf("err = %v, want ErrAuth", err) + } + if strings.Contains(err.Error(), "wrong-token-abcdef") { + t.Fatalf("presented token echoed in error: %q", err) + } +} + +// --- repos ------------------------------------------------------------------ + +func TestCreateRepoRoundTrip(t *testing.T) { + c, srv := newClient(t) + + repo, err := c.CreateRepo(context.Background(), gitea.RepoParams{ + Owner: "", + Name: "vertical-x", + Description: "one vertical", + Private: true, + AutoInit: true, + DefaultBranch: "main", + }) + if err != nil { + t.Fatalf("CreateRepo: %v", err) + } + if repo.FullName != "ukrrs/vertical-x" || !repo.Private || repo.DefaultBranch != "main" { + t.Fatalf("repo = %+v", repo) + } + // POST /user/repos carries the write shape (scalars only). + var post map[string]any + if err := json.Unmarshal([]byte(srv.Requests()[0].Body), &post); err != nil { + t.Fatalf("POST body not json: %v", err) + } + for _, k := range []string{"name", "description", "private", "auto_init", "default_branch"} { + if _, ok := post[k]; !ok { + t.Errorf("POST body missing %q: %v", k, post) + } + } + if post["name"] != "vertical-x" || post["private"] != true { + t.Errorf("POST body = %v", post) + } +} + +func TestCreateRepoInOrg(t *testing.T) { + c, srv := newClient(t) + repo, err := c.CreateRepo(context.Background(), gitea.RepoParams{Owner: "ukrrs-labs", Name: "org-repo"}) + if err != nil { + t.Fatalf("CreateRepo: %v", err) + } + if repo.Owner.Login != "ukrrs-labs" || repo.FullName != "ukrrs-labs/org-repo" { + t.Fatalf("repo = %+v", repo) + } + if reqs := srv.Requests(); reqs[0].Path != "/api/v1/orgs/ukrrs-labs/repos" { + t.Fatalf("path = %s, want orgs endpoint", reqs[0].Path) + } +} + +func TestCreateRepoDuplicateConflict(t *testing.T) { + c, _ := newClient(t) + if _, err := c.CreateRepo(context.Background(), gitea.RepoParams{Name: "dup", AutoInit: true}); err != nil { + t.Fatalf("first create: %v", err) + } + _, err := c.CreateRepo(context.Background(), gitea.RepoParams{Name: "dup"}) + if !errors.Is(err, gitea.ErrValidation) { // 409 -> validation sentinel + t.Fatalf("err = %v, want ErrValidation (409)", err) + } +} + +func TestListRepos(t *testing.T) { + c, srv := newClient(t) + srv.AddRepo("ukrrs", "alpha", "main", "main") + srv.AddRepo("ukrrs", "beta", "main", "main") + srv.AddRepo("other", "gamma", "main", "main") + + tests := []struct { + name string + owner string + want []string + path string + }{ + {name: "self", owner: "", want: []string{"ukrrs/alpha", "ukrrs/beta"}, path: "/api/v1/user/repos"}, + {name: "other user", owner: "other", want: []string{"other/gamma"}, path: "/api/v1/users/other/repos"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repos, err := c.ListRepos(context.Background(), tt.owner) + if err != nil { + t.Fatalf("ListRepos: %v", err) + } + var got []string + for _, r := range repos { + got = append(got, r.FullName) + } + if strings.Join(got, ",") != strings.Join(tt.want, ",") { + t.Fatalf("got %v, want %v", got, tt.want) + } + if reqs := srv.Requests(); reqs[len(reqs)-1].Path != tt.path { + t.Fatalf("path = %s, want %s", reqs[len(reqs)-1].Path, tt.path) + } + }) + } +} + +func TestGetRepoMissing(t *testing.T) { + c, _ := newClient(t) + _, err := c.GetRepo(context.Background(), "ukrrs", "absent") + if !errors.Is(err, gitea.ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 096d79a..e300418 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,8 +15,8 @@ import ( // Keys understood in the environment and the env file. const ( - KeyURL = "GITEA_URL" - KeyKey = "GITEA_KEY" + KeyURL = "GITEA_URL" + KeyKey = "GITEA_KEY" ) // Config is the resolved connection set. diff --git a/internal/fakegitea/fakegitea.go b/internal/fakegitea/fakegitea.go new file mode 100644 index 0000000..e55bd6f --- /dev/null +++ b/internal/fakegitea/fakegitea.go @@ -0,0 +1,715 @@ +// 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 ( + "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 { + // Deterministic distinct 40-hex shas (fake but well-formed). + sha := fmt.Sprintf("%040x", len(st.order)+1) + 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. +func (st *repoState) resolveRef(ref string) (string, bool) { + if sha, ok := st.branches[ref]; ok { + return sha, true + } + for _, sha := range st.branches { + if strings.HasPrefix(sha, ref) && len(ref) >= 7 { + return sha, 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"}` +}