feat(gitea): pull-request create/list/get/merge endpoints

This commit is contained in:
2026-08-29 16:11:58 -05:00
parent c251cd6a98
commit 5b35e8cd84
3 changed files with 278 additions and 6 deletions
+116
View File
@@ -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)
}
+149
View File
@@ -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)
}
}
+13 -6
View File
@@ -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