From 5b35e8cd84da6af367cb4e5adbd1f2717b2c71c2 Mon Sep 17 00:00:00 2001 From: reachableceo Date: Sat, 29 Aug 2026 16:10:00 -0500 Subject: [PATCH] feat(gitea): pull-request create/list/get/merge endpoints --- gitea/pulls.go | 116 +++++++++++++++++++++++++ gitea_test.go | 149 ++++++++++++++++++++++++++++++++ internal/fakegitea/fakegitea.go | 19 ++-- 3 files changed, 278 insertions(+), 6 deletions(-) create mode 100644 gitea/pulls.go diff --git a/gitea/pulls.go b/gitea/pulls.go new file mode 100644 index 0000000..46b1959 --- /dev/null +++ b/gitea/pulls.go @@ -0,0 +1,116 @@ +package gitea + +import ( + "context" + "net/url" + "strconv" +) + +// Pull request states. +const ( + PRStateOpen = "open" + PRStateClosed = "closed" + PRStateAll = "all" +) + +// Merge styles (Gitea's closed set; empty means "merge"). +const ( + MergeMerge = "merge" + MergeRebase = "rebase" + MergeRebaseMerge = "rebase-merge" + MergeSquash = "squash" + MergeFastForward = "fast-forward" +) + +// PRBranch is one end of a pull request. +type PRBranch struct { + Ref string `json:"ref"` + SHA string `json:"sha"` + Repo *Repo `json:"repo"` +} + +// PullRequest is the PR projection (read shape). +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"` +} + +// PRParams is the PR-create write shape. Head may be "branch" (same +// repo) or "owner:branch" (fork syntax); the server resolves both. +type PRParams struct { + Title string `json:"title"` + Body string `json:"body"` + Base string `json:"base"` + Head string `json:"head"` +} + +// MergeParams is the merge write shape. Do is one of the Merge* +// constants (empty means "merge"); Title and Message are optional merge +// commit fields. +type MergeParams struct { + Do string + Title string + Message string +} + +type mergeWrite struct { + Do string `json:"Do"` + MergeTitle string `json:"MergeTitleField,omitempty"` + MergeMessage string `json:"MergeMessageField,omitempty"` +} + +// CreatePullRequest opens a PR and returns it. +func (c *Client) CreatePullRequest(ctx context.Context, owner, repo string, p PRParams) (*PullRequest, error) { + var out PullRequest + path := "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/pulls" + if err := c.do(ctx, "POST", path, nil, p, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ListPullRequests lists PRs filtered by state (PRStateOpen is the +// server default; PRStateAll lifts it). +func (c *Client) ListPullRequests(ctx context.Context, owner, repo, state string) ([]PullRequest, error) { + var out []PullRequest + var query url.Values + if state != "" { + query = url.Values{"state": []string{state}} + } + path := "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/pulls" + if err := c.do(ctx, "GET", path, query, nil, &out); err != nil { + return nil, err + } + return out, nil +} + +// GetPullRequest fetches one PR by number. +func (c *Client) GetPullRequest(ctx context.Context, owner, repo string, number int64) (*PullRequest, error) { + var out PullRequest + path := "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/pulls/" + strconv.FormatInt(number, 10) + if err := c.do(ctx, "GET", path, nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// MergePullRequest merges an open PR. Re-merging or merging a closed PR +// maps to ErrValidation (http 409). +func (c *Client) MergePullRequest(ctx context.Context, owner, repo string, number int64, p MergeParams) error { + if p.Do == "" { + p.Do = MergeMerge + } + path := "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/pulls/" + strconv.FormatInt(number, 10) + "/merge" + return c.do(ctx, "POST", path, nil, mergeWrite{Do: p.Do, MergeTitle: p.Title, MergeMessage: p.Message}, nil) +} diff --git a/gitea_test.go b/gitea_test.go index f0ac9bd..2240b89 100644 --- a/gitea_test.go +++ b/gitea_test.go @@ -376,3 +376,152 @@ func TestCommitStatusErrors(t *testing.T) { }) } } + +// --- pull requests ------------------------------------------------------------ + +func TestPullRequestFlow(t *testing.T) { + c, srv := newClient(t) + srv.AddRepo("ukrrs", "MOPAC", "main", "main", "feat/quota") + + pr, err := c.CreatePullRequest(context.Background(), "ukrrs", "MOPAC", gitea.PRParams{ + Title: "Quota accounting", + Body: "## Scope\n- usage accounting", + Base: "main", + Head: "feat/quota", + }) + if err != nil { + t.Fatalf("CreatePullRequest: %v", err) + } + if pr.Number != 1 || pr.State != "open" || !pr.Mergeable || pr.Merged { + t.Fatalf("pr = %+v", pr) + } + if pr.Base.Ref != "main" || pr.Head.Ref != "feat/quota" || pr.Head.SHA == "" { + t.Fatalf("pr ends = %+v %+v", pr.Base, pr.Head) + } + 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{"title", "body", "base", "head"} { + if _, ok := post[k]; !ok { + t.Errorf("POST body missing %q: %v", k, post) + } + } + + // Green the head, then merge. + if _, err := c.CreateCommitStatus(context.Background(), "ukrrs", "MOPAC", pr.Head.SHA, gitea.CommitStatusParams{ + State: gitea.StatusSuccess, Context: "ci/lint", + }); err != nil { + t.Fatalf("status on head: %v", err) + } + if err := c.MergePullRequest(context.Background(), "ukrrs", "MOPAC", pr.Number, gitea.MergeParams{Do: gitea.MergeSquash}); err != nil { + t.Fatalf("MergePullRequest: %v", err) + } + stored, ok := srv.PullRequest("ukrrs", "MOPAC", pr.Number) + if !ok || !stored.Merged || stored.State != "closed" { + t.Fatalf("stored pr after merge = %+v ok=%v", stored, ok) + } + + // State filters. + open, err := c.ListPullRequests(context.Background(), "ukrrs", "MOPAC", gitea.PRStateOpen) + if err != nil { + t.Fatalf("ListPullRequests open: %v", err) + } + if len(open) != 0 { + t.Fatalf("open after merge = %+v", open) + } + closed, err := c.ListPullRequests(context.Background(), "ukrrs", "MOPAC", gitea.PRStateClosed) + if err != nil { + t.Fatalf("ListPullRequests closed: %v", err) + } + if len(closed) != 1 || closed[0].Number != pr.Number { + t.Fatalf("closed = %+v", closed) + } + all, err := c.ListPullRequests(context.Background(), "ukrrs", "MOPAC", gitea.PRStateAll) + if err != nil || len(all) != 1 { + t.Fatalf("all = %+v err=%v", all, err) + } + + // Merging again is a 409 -> validation sentinel. + if err := c.MergePullRequest(context.Background(), "ukrrs", "MOPAC", pr.Number, gitea.MergeParams{}); !errors.Is(err, gitea.ErrValidation) { + t.Fatalf("re-merge err = %v, want ErrValidation", err) + } +} + +func TestGetPullRequest(t *testing.T) { + c, srv := newClient(t) + srv.AddRepo("ukrrs", "MOPAC", "main", "main", "feat/quota") + created, err := c.CreatePullRequest(context.Background(), "ukrrs", "MOPAC", gitea.PRParams{Title: "t", Base: "main", Head: "feat/quota"}) + if err != nil { + t.Fatalf("create: %v", err) + } + got, err := c.GetPullRequest(context.Background(), "ukrrs", "MOPAC", created.Number) + if err != nil { + t.Fatalf("GetPullRequest: %v", err) + } + if got.Title != "t" || got.Number != created.Number { + t.Fatalf("got = %+v", got) + } + if _, err := c.GetPullRequest(context.Background(), "ukrrs", "MOPAC", 999); !errors.Is(err, gitea.ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } +} + +func TestCreatePullRequestErrors(t *testing.T) { + c, srv := newClient(t) + srv.AddRepo("ukrrs", "MOPAC", "main", "main", "feat/quota") + base := gitea.PRParams{Title: "t", Base: "main", Head: "feat/quota"} + + tests := []struct { + name string + p gitea.PRParams + want error + }{ + {"missing title", gitea.PRParams{Base: "main", Head: "feat/quota"}, gitea.ErrValidation}, + {"missing base branch", gitea.PRParams{Title: "t", Base: "nope", Head: "feat/quota"}, gitea.ErrValidation}, + {"missing head branch", gitea.PRParams{Title: "t", Base: "main", Head: "nope"}, gitea.ErrValidation}, + {"head equals base", gitea.PRParams{Title: "t", Base: "main", Head: "main"}, gitea.ErrValidation}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := c.CreatePullRequest(context.Background(), "ukrrs", "MOPAC", tt.p); !errors.Is(err, tt.want) { + t.Fatalf("err = %v, want %v", err, tt.want) + } + }) + } + + // Duplicate open PR -> 409. + if _, err := c.CreatePullRequest(context.Background(), "ukrrs", "MOPAC", base); err != nil { + t.Fatalf("first create: %v", err) + } + if _, err := c.CreatePullRequest(context.Background(), "ukrrs", "MOPAC", base); !errors.Is(err, gitea.ErrValidation) { + t.Fatalf("duplicate err = %v, want ErrValidation (409)", err) + } + // Unknown repo -> 404. + if _, err := c.CreatePullRequest(context.Background(), "ukrrs", "absent", base); !errors.Is(err, gitea.ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } +} + +func TestMergeStyles(t *testing.T) { + for _, style := range []string{gitea.MergeMerge, gitea.MergeRebase, gitea.MergeRebaseMerge, gitea.MergeSquash, gitea.MergeFastForward, ""} { + t.Run("style_"+style, func(t *testing.T) { + c, srv := newClient(t) + srv.AddRepo("ukrrs", "MOPAC", "main", "main", "feat/x") + pr, err := c.CreatePullRequest(context.Background(), "ukrrs", "MOPAC", gitea.PRParams{Title: "t", Base: "main", Head: "feat/x"}) + if err != nil { + t.Fatalf("create: %v", err) + } + if err := c.MergePullRequest(context.Background(), "ukrrs", "MOPAC", pr.Number, gitea.MergeParams{Do: style}); err != nil { + t.Fatalf("merge %q: %v", style, err) + } + }) + } + // Invalid style -> 422. + c, srv := newClient(t) + srv.AddRepo("ukrrs", "MOPAC", "main", "main", "feat/x") + pr, _ := c.CreatePullRequest(context.Background(), "ukrrs", "MOPAC", gitea.PRParams{Title: "t", Base: "main", Head: "feat/x"}) + if err := c.MergePullRequest(context.Background(), "ukrrs", "MOPAC", pr.Number, gitea.MergeParams{Do: "blend"}); !errors.Is(err, gitea.ErrValidation) { + t.Fatalf("err = %v, want ErrValidation", err) + } +} diff --git a/internal/fakegitea/fakegitea.go b/internal/fakegitea/fakegitea.go index e55bd6f..d305582 100644 --- a/internal/fakegitea/fakegitea.go +++ b/internal/fakegitea/fakegitea.go @@ -9,6 +9,8 @@ package fakegitea import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" @@ -206,8 +208,10 @@ func (s *Server) addRepoLocked(owner, name, defaultBranch string, branches []str } func (st *repoState) addBranch(name string) string { - // Deterministic distinct 40-hex shas (fake but well-formed). - sha := fmt.Sprintf("%040x", len(st.order)+1) + // 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) } @@ -464,14 +468,17 @@ func (s *Server) listBranches(owner, name string) (int, string) { } // resolveRef maps a ref (full sha, sha prefix, or branch name) to a commit -// sha; ok=false when nothing matches. +// 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 } - for _, sha := range st.branches { - if strings.HasPrefix(sha, ref) && len(ref) >= 7 { - 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