diff --git a/gitea/branches.go b/gitea/branches.go new file mode 100644 index 0000000..175b92d --- /dev/null +++ b/gitea/branches.go @@ -0,0 +1,29 @@ +package gitea + +import ( + "context" + "net/url" +) + +// CommitInfo is the commit projection branches carry. +type CommitInfo struct { + ID string `json:"id"` + Message string `json:"message,omitempty"` +} + +// Branch is the branch projection. +type Branch struct { + Name string `json:"name"` + Commit CommitInfo `json:"commit"` + Protected bool `json:"protected"` +} + +// ListBranches lists a repository's branches. +func (c *Client) ListBranches(ctx context.Context, owner, repo string) ([]Branch, error) { + var out []Branch + path := "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/branches" + if err := c.do(ctx, "GET", path, nil, nil, &out); err != nil { + return nil, err + } + return out, nil +} diff --git a/gitea/statuses.go b/gitea/statuses.go new file mode 100644 index 0000000..7350428 --- /dev/null +++ b/gitea/statuses.go @@ -0,0 +1,73 @@ +package gitea + +import ( + "context" + "net/url" +) + +// Commit status states (Gitea's closed set). +const ( + StatusPending = "pending" + StatusSuccess = "success" + StatusError = "error" + StatusFailure = "failure" +) + +// CommitStatusParams is the status-create write shape. +type CommitStatusParams struct { + State string // one of the Status* constants + Context string // e.g. "ci/lint"; server defaults to "default" + Description string + TargetURL string `json:"target_url"` +} + +// CommitStatus is the status projection (read shape). +type CommitStatus 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"` +} + +// CombinedStatus is the worst-of rollup Gitea reports per ref. +type CombinedStatus struct { + State string `json:"state"` + SHA string `json:"sha"` + TotalCount int `json:"total_count"` + Statuses []CommitStatus `json:"statuses"` +} + +// CreateCommitStatus reports one status on a commit (sha may be any ref +// the server resolves: full or short sha, branch, tag). +func (c *Client) CreateCommitStatus(ctx context.Context, owner, repo, sha string, p CommitStatusParams) (*CommitStatus, error) { + var out CommitStatus + path := "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/statuses/" + url.PathEscape(sha) + if err := c.do(ctx, "POST", path, nil, p, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ListCommitStatuses lists the latest status per context for a ref. +func (c *Client) ListCommitStatuses(ctx context.Context, owner, repo, ref string) ([]CommitStatus, error) { + var out []CommitStatus + path := "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/commits/" + url.PathEscape(ref) + "/statuses" + if err := c.do(ctx, "GET", path, nil, nil, &out); err != nil { + return nil, err + } + return out, nil +} + +// GetCombinedStatus fetches the worst-of rollup for a ref. Gitea answers +// 404 when the ref has no statuses at all. +func (c *Client) GetCombinedStatus(ctx context.Context, owner, repo, ref string) (*CombinedStatus, error) { + var out CombinedStatus + path := "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/commits/" + url.PathEscape(ref) + "/status" + if err := c.do(ctx, "GET", path, nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/gitea_test.go b/gitea_test.go index 90fd354..f0ac9bd 100644 --- a/gitea_test.go +++ b/gitea_test.go @@ -228,3 +228,151 @@ func TestGetRepoMissing(t *testing.T) { t.Fatalf("err = %v, want ErrNotFound", err) } } + +// --- branches --------------------------------------------------------------- + +func TestListBranches(t *testing.T) { + c, srv := newClient(t) + srv.AddRepo("ukrrs", "MOPAC", "main", "main", "feat/quota", "fix/lint") + + branches, err := c.ListBranches(context.Background(), "ukrrs", "MOPAC") + if err != nil { + t.Fatalf("ListBranches: %v", err) + } + if len(branches) != 3 { + t.Fatalf("got %d branches, want 3: %+v", len(branches), branches) + } + if branches[0].Name != "main" || branches[2].Name != "fix/lint" { + t.Fatalf("order = %+v", branches) + } + for _, b := range branches { + if len(b.Commit.ID) != 40 { + t.Errorf("branch %s tip sha = %q, want 40-hex", b.Name, b.Commit.ID) + } + } + if reqs := srv.Requests(); reqs[0].Path != "/api/v1/repos/ukrrs/MOPAC/branches" { + t.Fatalf("path = %s", reqs[0].Path) + } +} + +func TestListBranchesMissingRepo(t *testing.T) { + c, _ := newClient(t) + _, err := c.ListBranches(context.Background(), "ukrrs", "absent") + if !errors.Is(err, gitea.ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } +} + +// --- commit statuses ---------------------------------------------------------- + +func TestCommitStatusFlow(t *testing.T) { + c, srv := newClient(t) + srv.AddRepo("ukrrs", "MOPAC", "main", "main") + sha := srv.AddBranch("ukrrs", "MOPAC", "feat/quota") + + st, err := c.CreateCommitStatus(context.Background(), "ukrrs", "MOPAC", sha, gitea.CommitStatusParams{ + State: gitea.StatusPending, + Context: "ci/lint", + Description: "lint queued", + TargetURL: "https://ci.example/run/1", + }) + if err != nil { + t.Fatalf("CreateCommitStatus: %v", err) + } + if st.ID == 0 || st.State != "pending" || st.Context != "ci/lint" { + t.Fatalf("status = %+v", st) + } + if reqs := srv.Requests(); reqs[0].Path != "/api/v1/repos/ukrrs/MOPAC/statuses/"+sha { + t.Fatalf("path = %s", reqs[0].Path) + } + + // Same context flips to success; a second context reports failure. + if _, err := c.CreateCommitStatus(context.Background(), "ukrrs", "MOPAC", sha, gitea.CommitStatusParams{ + State: gitea.StatusSuccess, + Context: "ci/lint", + }); err != nil { + t.Fatalf("CreateCommitStatus flip: %v", err) + } + if _, err := c.CreateCommitStatus(context.Background(), "ukrrs", "MOPAC", sha, gitea.CommitStatusParams{ + State: gitea.StatusFailure, + Context: "ci/test", + }); err != nil { + t.Fatalf("CreateCommitStatus second context: %v", err) + } + + // Listing by branch NAME resolves to the tip sha and folds to the + // latest status per context. + listed, err := c.ListCommitStatuses(context.Background(), "ukrrs", "MOPAC", "feat/quota") + if err != nil { + t.Fatalf("ListCommitStatuses: %v", err) + } + if len(listed) != 2 { + t.Fatalf("got %d statuses, want 2 (latest per context): %+v", len(listed), listed) + } + byCtx := map[string]string{} + for _, s := range listed { + byCtx[s.Context] = s.State + } + if byCtx["ci/lint"] != "success" || byCtx["ci/test"] != "failure" { + t.Fatalf("folded = %v", byCtx) + } + + // Combined status is the worst of the latest per context. + comb, err := c.GetCombinedStatus(context.Background(), "ukrrs", "MOPAC", "feat/quota") + if err != nil { + t.Fatalf("GetCombinedStatus: %v", err) + } + if comb.State != "failure" || comb.SHA != sha || comb.TotalCount != 2 { + t.Fatalf("combined = %+v", comb) + } + + // Short sha (>= 7 chars) resolves too. + short := sha[:7] + if _, err := c.GetCombinedStatus(context.Background(), "ukrrs", "MOPAC", short); err != nil { + t.Fatalf("short sha: %v", err) + } +} + +func TestCommitStatusErrors(t *testing.T) { + c, srv := newClient(t) + srv.AddRepo("ukrrs", "MOPAC", "main", "main") + sha := srv.AddBranch("ukrrs", "MOPAC", "feat/quota") + + tests := []struct { + name string + run func() error + want error + }{ + { + name: "unknown sha", + run: func() error { + _, err := c.CreateCommitStatus(context.Background(), "ukrrs", "MOPAC", strings.Repeat("d", 40), gitea.CommitStatusParams{State: gitea.StatusSuccess}) + return err + }, + want: gitea.ErrNotFound, + }, + { + name: "invalid state", + run: func() error { + _, err := c.CreateCommitStatus(context.Background(), "ukrrs", "MOPAC", sha, gitea.CommitStatusParams{State: "green"}) + return err + }, + want: gitea.ErrValidation, + }, + { + name: "combined with no statuses", + run: func() error { + _, err := c.GetCombinedStatus(context.Background(), "ukrrs", "MOPAC", "main") + return err + }, + want: gitea.ErrNotFound, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.run(); !errors.Is(err, tt.want) { + t.Fatalf("err = %v, want %v", err, tt.want) + } + }) + } +}