diff --git a/bitbucket/check_runs.go b/bitbucket/check_runs.go new file mode 100644 index 0000000..ae5b994 --- /dev/null +++ b/bitbucket/check_runs.go @@ -0,0 +1,17 @@ +package bitbucket + +import ( + "context" + + forge "github.com/git-pkgs/forge" +) + +type bitbucketCheckRunService struct{} + +func (f *bitbucketForge) CheckRuns() forge.CheckRunService { + return &bitbucketCheckRunService{} +} + +func (s *bitbucketCheckRunService) List(_ context.Context, _, _, _ string) ([]forge.CheckRun, error) { + return nil, forge.ErrNotSupported +} diff --git a/cmd/forge/main.go b/cmd/forge/main.go index 4517588..7533ab2 100644 --- a/cmd/forge/main.go +++ b/cmd/forge/main.go @@ -1,13 +1,23 @@ package main import ( + "errors" "os" "github.com/git-pkgs/forge/internal/cli" ) +// exitCoder is implemented by command errors that carry a process exit code. +type exitCoder interface { + ExitCode() int +} + func main() { if err := cli.Execute(); err != nil { + var ec exitCoder + if errors.As(err, &ec) { + os.Exit(ec.ExitCode()) + } os.Exit(1) } } diff --git a/forge.go b/forge.go index f95e861..01eb27f 100644 --- a/forge.go +++ b/forge.go @@ -75,6 +75,7 @@ type Forge interface { Files() FileService Collaborators() CollaboratorService CommitStatuses() CommitStatusService + CheckRuns() CheckRunService Commits() CommitService GetRateLimit(ctx context.Context) (*RateLimit, error) // ParsePath parses URL path segments into a resource reference. diff --git a/forges_test.go b/forges_test.go index da506ec..9cccdf4 100644 --- a/forges_test.go +++ b/forges_test.go @@ -761,6 +761,16 @@ func (m *mockForge) CommitStatuses() CommitStatusService { return &mockCommitStatusService{} } +type mockCheckRunService struct{} + +func (m *mockCheckRunService) List(context.Context, string, string, string) ([]CheckRun, error) { + return nil, nil +} + +func (m *mockForge) CheckRuns() CheckRunService { + return &mockCheckRunService{} +} + func (m *mockForge) Commits() CommitService { if m.commitService != nil { return m.commitService diff --git a/gerrit/stubs.go b/gerrit/stubs.go index 9daf1ac..6e52e61 100644 --- a/gerrit/stubs.go +++ b/gerrit/stubs.go @@ -19,6 +19,7 @@ type unsupportedSecretService struct{} type unsupportedNotificationService struct{} type unsupportedCollaboratorService struct{} type unsupportedCommitStatusService struct{} +type unsupportedCheckRunService struct{} type unsupportedCommitService struct{} func (f *gerritForge) Issues() forge.IssueService { return &unsupportedIssueService{} } @@ -38,6 +39,9 @@ func (f *gerritForge) Collaborators() forge.CollaboratorService { func (f *gerritForge) CommitStatuses() forge.CommitStatusService { return &unsupportedCommitStatusService{} } +func (f *gerritForge) CheckRuns() forge.CheckRunService { + return &unsupportedCheckRunService{} +} func (f *gerritForge) Commits() forge.CommitService { return &unsupportedCommitService{} } func (f *gerritForge) GetRateLimit(context.Context) (*forge.RateLimit, error) { return nil, forge.ErrNotSupported @@ -224,6 +228,10 @@ func (s *unsupportedCommitStatusService) Set(context.Context, string, string, st return nil, forge.ErrNotSupported } +func (s *unsupportedCheckRunService) List(context.Context, string, string, string) ([]forge.CheckRun, error) { + return nil, forge.ErrNotSupported +} + func (s *unsupportedCommitService) ResolveCommit(context.Context, string, string, string) (string, error) { return "", forge.ErrNotSupported } diff --git a/gitea/check_runs.go b/gitea/check_runs.go new file mode 100644 index 0000000..f396d16 --- /dev/null +++ b/gitea/check_runs.go @@ -0,0 +1,17 @@ +package gitea + +import ( + "context" + + forge "github.com/git-pkgs/forge" +) + +type giteaCheckRunService struct{} + +func (f *giteaForge) CheckRuns() forge.CheckRunService { + return &giteaCheckRunService{} +} + +func (s *giteaCheckRunService) List(_ context.Context, _, _, _ string) ([]forge.CheckRun, error) { + return nil, forge.ErrNotSupported +} diff --git a/github/check_runs.go b/github/check_runs.go new file mode 100644 index 0000000..c4b162b --- /dev/null +++ b/github/check_runs.go @@ -0,0 +1,57 @@ +package github + +import ( + "context" + "net/http" + + forge "github.com/git-pkgs/forge" + + "github.com/google/go-github/v82/github" +) + +type gitHubCheckRunService struct { + client *github.Client +} + +func (f *gitHubForge) CheckRuns() forge.CheckRunService { + return &gitHubCheckRunService{client: f.client} +} + +func (s *gitHubCheckRunService) List(ctx context.Context, owner, repo, sha string) ([]forge.CheckRun, error) { + var all []forge.CheckRun + opts := &github.ListCheckRunsOptions{ + ListOptions: github.ListOptions{PerPage: defaultPageSize}, + } + for { + result, resp, err := s.client.Checks.ListCheckRunsForRef(ctx, owner, repo, sha, opts) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, err + } + for _, r := range result.CheckRuns { + cr := forge.CheckRun{ + ID: r.GetID(), + Name: r.GetName(), + Status: forge.NormalizeCIStatus(r.GetStatus()), + Conclusion: forge.NormalizeCIConclusion(r.GetConclusion()), + HTMLURL: r.GetHTMLURL(), + } + if r.StartedAt != nil { + t := r.StartedAt.Time + cr.StartedAt = &t + } + if r.CompletedAt != nil { + t := r.CompletedAt.Time + cr.CompletedAt = &t + } + all = append(all, cr) + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return all, nil +} diff --git a/gitlab/check_runs.go b/gitlab/check_runs.go new file mode 100644 index 0000000..dfedd05 --- /dev/null +++ b/gitlab/check_runs.go @@ -0,0 +1,19 @@ +//go:build !tinygo + +package gitlab + +import ( + "context" + + forge "github.com/git-pkgs/forge" +) + +type gitLabCheckRunService struct{} + +func (f *gitLabForge) CheckRuns() forge.CheckRunService { + return &gitLabCheckRunService{} +} + +func (s *gitLabCheckRunService) List(_ context.Context, _, _, _ string) ([]forge.CheckRun, error) { + return nil, forge.ErrNotSupported +} diff --git a/gitlab/gitlab_tinygo.go b/gitlab/gitlab_tinygo.go index 4200457..d4bdb0d 100644 --- a/gitlab/gitlab_tinygo.go +++ b/gitlab/gitlab_tinygo.go @@ -92,6 +92,14 @@ type gitLabCommitStatusService struct{} func (f *gitLabForge) CommitStatuses() forge.CommitStatusService { return &gitLabCommitStatusService{} } +func (f *gitLabForge) CheckRuns() forge.CheckRunService { return &gitLabCheckRunService{} } + +type gitLabCheckRunService struct{} + +func (s *gitLabCheckRunService) List(ctx context.Context, owner, repo, sha string) ([]forge.CheckRun, error) { + return nil, errTinyGo +} + func (s *gitLabCommitStatusService) List(ctx context.Context, owner, repo, sha string) ([]forge.CommitStatus, error) { return nil, errTinyGo } diff --git a/internal/cli/pr.go b/internal/cli/pr.go index 944ef2b..b866dc7 100644 --- a/internal/cli/pr.go +++ b/internal/cli/pr.go @@ -40,6 +40,7 @@ func init() { prCmd.AddCommand(prReactionsCmd()) prCmd.AddCommand(prReactCmd()) prCmd.AddCommand(prCheckoutCmd()) + prCmd.AddCommand(prChecksCmd()) } func prViewCmd() *cobra.Command { diff --git a/internal/cli/pr_checkout_test.go b/internal/cli/pr_checkout_test.go index 9acc2a7..bd9da2c 100644 --- a/internal/cli/pr_checkout_test.go +++ b/internal/cli/pr_checkout_test.go @@ -84,10 +84,13 @@ func (m *mockPRService) ListURL(_ string) string { // mockForge implements forges.Forge for testing. type mockForge struct { - prService *mockPRService - repoService forges.RepoService - releaseService forges.ReleaseService - webhookService forges.WebhookService + prService *mockPRService + repoService forges.RepoService + releaseService forges.ReleaseService + webhookService forges.WebhookService + ciService forges.CIService + commitStatusService forges.CommitStatusService + checkRunService forges.CheckRunService } func (m *mockForge) Repos() forges.RepoService { return m.repoService } @@ -96,7 +99,7 @@ func (m *mockForge) PullRequests() forges.PullRequestService { return m.prSer func (m *mockForge) Labels() forges.LabelService { return nil } func (m *mockForge) Milestones() forges.MilestoneService { return nil } func (m *mockForge) Releases() forges.ReleaseService { return m.releaseService } -func (m *mockForge) CI() forges.CIService { return nil } +func (m *mockForge) CI() forges.CIService { return m.ciService } func (m *mockForge) Branches() forges.BranchService { return nil } func (m *mockForge) DeployKeys() forges.DeployKeyService { return nil } func (m *mockForge) Webhooks() forges.WebhookService { return m.webhookService } @@ -105,7 +108,8 @@ func (m *mockForge) Notifications() forges.NotificationService { return nil } func (m *mockForge) Reviews() forges.ReviewService { return nil } func (m *mockForge) Files() forges.FileService { return nil } func (m *mockForge) Collaborators() forges.CollaboratorService { return nil } -func (m *mockForge) CommitStatuses() forges.CommitStatusService { return nil } +func (m *mockForge) CommitStatuses() forges.CommitStatusService { return m.commitStatusService } +func (m *mockForge) CheckRuns() forges.CheckRunService { return m.checkRunService } func (m *mockForge) Commits() forges.CommitService { return nil } func (m *mockForge) GetRateLimit(_ context.Context) (*forges.RateLimit, error) { return nil, forges.ErrNotSupported diff --git a/internal/cli/pr_checks.go b/internal/cli/pr_checks.go new file mode 100644 index 0000000..f56aabf --- /dev/null +++ b/internal/cli/pr_checks.go @@ -0,0 +1,341 @@ +package cli + +import ( + "errors" + "fmt" + "os" + "strconv" + + "github.com/git-pkgs/forge" + "github.com/git-pkgs/forge/internal/output" + "github.com/git-pkgs/forge/internal/resolve" + "github.com/spf13/cobra" +) + +// defaultPRChecksRunLimit bounds how many CI runs are fetched for the head +// branch before narrowing them down to runs for the PR's head commit. +const defaultPRChecksRunLimit = 20 + +// Exit codes for pr checks. A failed check is more decisive than a pending +// one, so failures take precedence when both are present. +const ( + prChecksExitFailed = 1 + prChecksExitPending = 8 // matches 'gh pr checks' +) + +// exitError is a command error that carries a process exit code. cmd/forge +// maps it to the real process exit status. +type exitError struct { + code int + msg string +} + +func (e *exitError) Error() string { return e.msg } +func (e *exitError) ExitCode() int { return e.code } + +// prChecksResult is the combined check status of a pull request. +type prChecksResult struct { + PR *forges.PullRequest `json:"pr"` + CommitStatuses []forges.CommitStatus `json:"commit_statuses"` + CheckRuns []forges.CheckRun `json:"check_runs"` + CIRuns []forges.CIRun `json:"ci_runs"` +} + +func prChecksCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "checks []", + Short: "Show CI check status for a pull request", + Long: `Show the check status for a pull request by number, or for the +pull request of the current branch when no number is given. + +Lists commit statuses and check runs for the PR's head commit and CI +pipeline runs for the PR's head branch, like 'gh pr checks'. + +Exits 0 when every check passed, 1 when any check failed, and 8 when +no check failed but some are still pending.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + forge, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType) + if err != nil { + return err + } + + var number int + if len(args) > 0 { + number, err = strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid PR number: %s", args[0]) + } + } else { + number, err = findPRForCurrentBranch(cmd.Context(), forge, owner, repoName) + if err != nil { + return err + } + } + + pr, err := forge.PullRequests().Get(cmd.Context(), owner, repoName, number) + if err != nil { + return fmt.Errorf("getting PR #%d: %w", number, err) + } + + result := &prChecksResult{PR: pr} + statusesListed := false + checksListed := false + runsListed := false + + if pr.Head.SHA != "" { + statuses, err := forge.CommitStatuses().List(cmd.Context(), owner, repoName, pr.Head.SHA) + if err != nil { + if !errors.Is(err, forges.ErrNotSupported) { + return fmt.Errorf("listing commit statuses: %w", err) + } + } else { + result.CommitStatuses = latestCommitStatuses(statuses) + statusesListed = true + } + + // Check runs (the Checks API) cover third-party checks and + // individual Actions jobs that commit statuses and workflow + // runs omit. + checkRuns, err := forge.CheckRuns().List(cmd.Context(), owner, repoName, pr.Head.SHA) + if err != nil { + if !errors.Is(err, forges.ErrNotSupported) { + return fmt.Errorf("listing check runs: %w", err) + } + } else { + // Non-nil so an empty listed source still prints its section. + result.CheckRuns = append([]forges.CheckRun{}, checkRuns...) + checksListed = true + } + } + + // A full ref (e.g. refs/pull//head) is not a branch on the + // remote, so there are no branch CI runs to list for it. + if pr.Head.Ref != "" && !isFullRef(pr.Head.Ref) { + runs, err := forge.CI().ListRuns(cmd.Context(), owner, repoName, forges.ListCIRunOpts{ + Branch: pr.Head.Ref, + Limit: defaultPRChecksRunLimit, + }) + if err != nil { + if !errors.Is(err, forges.ErrNotSupported) { + return fmt.Errorf("listing CI runs: %w", err) + } + } else { + result.CIRuns = append([]forges.CIRun{}, filterCIRunsForSHA(runs, pr.Head.SHA)...) + runsListed = true + } + } + + if !statusesListed && !checksListed && !runsListed { + return fmt.Errorf("checks are not supported by this forge") + } + + return printPRChecks(result) + }, + } + return cmd +} + +// latestCommitStatuses collapses the full status history that ListStatuses +// returns for a ref to the newest entry per Context (by CreatedAt), keeping +// first-seen context order. Without this, a context that posted pending and +// then success keeps both entries: the table shows duplicate rows and the +// stale pending makes the command exit nonzero forever. +func latestCommitStatuses(statuses []forges.CommitStatus) []forges.CommitStatus { + latest := make(map[string]int, len(statuses)) + var order []string + for i, s := range statuses { + j, seen := latest[s.Context] + if !seen { + latest[s.Context] = i + order = append(order, s.Context) + continue + } + if s.CreatedAt.After(statuses[j].CreatedAt) { + latest[s.Context] = i + } + } + out := make([]forges.CommitStatus, 0, len(order)) + for _, context := range order { + out = append(out, statuses[latest[context]]) + } + return out +} + +// filterCIRunsForSHA keeps only runs for the given head commit. Runs that do +// not report a commit SHA are kept, since they cannot be filtered precisely. +func filterCIRunsForSHA(runs []forges.CIRun, sha string) []forges.CIRun { + if sha == "" { + return runs + } + filtered := runs[:0] + for _, r := range runs { + if r.SHA == "" || r.SHA == sha { + filtered = append(filtered, r) + } + } + return filtered +} + +// checkRunOutcome classifies a run's status/conclusion pair as failed or +// pending. A decisive conclusion wins; otherwise the status decides. +func checkRunOutcome(status forges.CIStatus, conclusion forges.CIConclusion) (failed, pending bool) { + switch conclusion { + case forges.CIConclusionFailure, forges.CIConclusionCancelled, + forges.CIConclusionTimedOut, forges.CIConclusionActionRequired: + return true, false + case forges.CIConclusionSuccess, forges.CIConclusionSkipped, + forges.CIConclusionNeutral: + return false, false + } + switch status { + case forges.CIStatusFailed: + return true, false + case forges.CIStatusQueued, forges.CIStatusRunning: + return false, true + } + return false, false +} + +// prChecksExitError returns a nonzero exit error when any check failed or is +// still pending, nil when everything passed. +func prChecksExitError(result *prChecksResult) error { + failed, pending := false, false + for _, s := range result.CommitStatuses { + switch s.State { + case forges.CommitStatusFailure, forges.CommitStatusError: + failed = true + case forges.CommitStatusPending: + pending = true + } + } + for _, c := range result.CheckRuns { + f, p := checkRunOutcome(c.Status, c.Conclusion) + failed, pending = failed || f, pending || p + } + for _, r := range result.CIRuns { + f, p := checkRunOutcome(r.Status, r.Conclusion) + failed, pending = failed || f, pending || p + } + switch { + case failed: + return &exitError{code: prChecksExitFailed, msg: "some checks failed"} + case pending: + return &exitError{code: prChecksExitPending, msg: "some checks are pending"} + default: + return nil + } +} + +// runDisplayStatus prefers the conclusion when the run reports one, so +// completed runs show their result instead of a bare "completed". +func runDisplayStatus(status forges.CIStatus, conclusion forges.CIConclusion) string { + if conclusion != "" { + return string(conclusion) + } + return string(status) +} + +func printPRChecks(result *prChecksResult) error { + p := printer() + if p.Format == output.JSON { + if err := p.PrintJSON(result); err != nil { + return err + } + return prChecksExitError(result) + } + + if p.Format == output.Plain { + var lines []string + for _, s := range result.CommitStatuses { + lines = append(lines, fmt.Sprintf("status\t%s\t%s", s.State, s.Context)) + } + for _, c := range result.CheckRuns { + lines = append(lines, fmt.Sprintf("check\t%s\t%s", c.Name, runDisplayStatus(c.Status, c.Conclusion))) + } + for _, r := range result.CIRuns { + lines = append(lines, fmt.Sprintf("ci\t%d\t%s\t%s", r.ID, r.Title, runDisplayStatus(r.Status, r.Conclusion))) + } + p.PrintPlain(lines) + return prChecksExitError(result) + } + + // The header is not a data line, so it is printed only after the plain + // branch above, like the other commands' plain output. + pr := result.PR + sha := pr.Head.SHA + if len(sha) > shortSHALength { + sha = sha[:shortSHALength] + } + _, _ = fmt.Fprintf(os.Stdout, "Checks for #%d %s (%s on %s)\n", + pr.Number, output.Sanitize(pr.Title), sha, pr.Head.Ref) + + if result.CommitStatuses != nil { + _, _ = fmt.Fprintln(os.Stdout) + _, _ = fmt.Fprintln(os.Stdout, "Commit statuses:") + if len(result.CommitStatuses) == 0 { + _, _ = fmt.Fprintln(os.Stdout, " No commit statuses reported") + } else { + headers := []string{"STATE", "CONTEXT", "DESCRIPTION", "URL"} + rows := make([][]string, len(result.CommitStatuses)) + for i, s := range result.CommitStatuses { + rows[i] = []string{ + string(s.State), + s.Context, + s.Description, + s.TargetURL, + } + } + p.PrintTable(headers, rows) + } + } + + if result.CheckRuns != nil { + _, _ = fmt.Fprintln(os.Stdout) + _, _ = fmt.Fprintln(os.Stdout, "Check runs:") + if len(result.CheckRuns) == 0 { + _, _ = fmt.Fprintln(os.Stdout, " No check runs reported") + } else { + headers := []string{"ID", "NAME", "STATUS", "CONCLUSION", "URL"} + rows := make([][]string, len(result.CheckRuns)) + for i, c := range result.CheckRuns { + rows[i] = []string{ + strconv.FormatInt(c.ID, 10), + c.Name, + string(c.Status), + string(c.Conclusion), + c.HTMLURL, + } + } + p.PrintTable(headers, rows) + } + } + + if result.CIRuns != nil { + _, _ = fmt.Fprintln(os.Stdout) + _, _ = fmt.Fprintln(os.Stdout, "CI runs:") + if len(result.CIRuns) == 0 { + _, _ = fmt.Fprintln(os.Stdout, " No CI runs for this commit") + } else { + headers := []string{"ID", "TITLE", "STATUS", "BRANCH", "EVENT", "CREATED"} + rows := make([][]string, len(result.CIRuns)) + for i, r := range result.CIRuns { + created := "" + if !r.CreatedAt.IsZero() { + created = r.CreatedAt.Format("2006-01-02 15:04") + } + rows[i] = []string{ + strconv.FormatInt(r.ID, 10), + r.Title, + runDisplayStatus(r.Status, r.Conclusion), + r.Branch, + r.Event, + created, + } + } + p.PrintTable(headers, rows) + } + } + + return prChecksExitError(result) +} diff --git a/internal/cli/pr_checks_test.go b/internal/cli/pr_checks_test.go new file mode 100644 index 0000000..d729e3e --- /dev/null +++ b/internal/cli/pr_checks_test.go @@ -0,0 +1,586 @@ +package cli + +import ( + "context" + "errors" + "io" + "os" + "strings" + "testing" + "time" + + "github.com/git-pkgs/forge" + "github.com/git-pkgs/forge/internal/resolve" + "github.com/spf13/cobra" +) + +// mockCIService implements forges.CIService for testing. +type mockCIService struct { + listResult []forges.CIRun + listErr error + listOpts forges.ListCIRunOpts + listCalls int +} + +func (m *mockCIService) ListRuns(_ context.Context, _, _ string, opts forges.ListCIRunOpts) ([]forges.CIRun, error) { + m.listCalls++ + m.listOpts = opts + return m.listResult, m.listErr +} + +func (m *mockCIService) GetRun(_ context.Context, _, _ string, _ int64) (*forges.CIRun, error) { + return nil, nil +} + +func (m *mockCIService) TriggerRun(_ context.Context, _, _ string, _ forges.TriggerCIRunOpts) error { + return nil +} + +func (m *mockCIService) CancelRun(_ context.Context, _, _ string, _ int64) error { + return nil +} + +func (m *mockCIService) RetryRun(_ context.Context, _, _ string, _ int64) error { + return nil +} + +func (m *mockCIService) GetJobLog(_ context.Context, _, _ string, _ int64) (io.ReadCloser, error) { + return nil, nil +} + +// mockCommitStatusService implements forges.CommitStatusService for testing. +type mockCommitStatusService struct { + listResult []forges.CommitStatus + listErr error + listSHA string + listCalls int +} + +func (m *mockCommitStatusService) List(_ context.Context, _, _, sha string) ([]forges.CommitStatus, error) { + m.listCalls++ + m.listSHA = sha + return m.listResult, m.listErr +} + +func (m *mockCommitStatusService) Set(_ context.Context, _, _, _ string, _ forges.SetCommitStatusOpts) (*forges.CommitStatus, error) { + return nil, nil +} + +// mockCheckRunService implements forges.CheckRunService for testing. +type mockCheckRunService struct { + listResult []forges.CheckRun + listErr error + listSHA string + listCalls int +} + +func (m *mockCheckRunService) List(_ context.Context, _, _, sha string) ([]forges.CheckRun, error) { + m.listCalls++ + m.listSHA = sha + return m.listResult, m.listErr +} + +// runPRChecksCmd executes prChecksCmd with the given args, capturing stdout. +func runPRChecksCmd(t *testing.T, args []string) (string, error) { + t.Helper() + + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + + cmd := prChecksCmd() + cmd.SetArgs(args) + runErr := cmd.Execute() + + _ = w.Close() + os.Stdout = old + out, _ := io.ReadAll(r) + _ = r.Close() + return string(out), runErr +} + +func TestPRChecksRegistered(t *testing.T) { + found := false + for _, sub := range prCmd.Commands() { + if sub.Name() == "checks" { + found = true + } + } + if !found { + t.Error("expected 'checks' subcommand on pr command") + } +} + +func TestPRChecksInvalidNumber(t *testing.T) { + resetCmd(rootCmd) + _, err := runPRChecksCmd(t, []string{"notanumber"}) + if err == nil { + t.Fatal("expected error for non-numeric PR number") + } + if !strings.Contains(err.Error(), "invalid PR number") { + t.Errorf("expected 'invalid PR number' in error, got: %s", err) + } +} + +func TestPRChecksHappyPath(t *testing.T) { + resetCmd(rootCmd) + + prs := &mockPRService{ + pr: &forges.PullRequest{ + Number: 42, + Title: "Fix the thing", + Head: forges.PRBranch{Ref: "feature", SHA: "abc123def456"}, + }, + } + statuses := &mockCommitStatusService{ + listResult: []forges.CommitStatus{ + {State: forges.CommitStatusSuccess, Context: "ci/build", Description: "Build passed"}, + {State: forges.CommitStatusSuccess, Context: "ci/test", Description: "Tests passed"}, + }, + } + checks := &mockCheckRunService{ + listResult: []forges.CheckRun{ + {ID: 11, Name: "lint", Status: forges.CIStatusCompleted, Conclusion: forges.CIConclusionSuccess}, + }, + } + ci := &mockCIService{ + listResult: []forges.CIRun{ + {ID: 1, Title: "build", Status: forges.CIStatusCompleted, Conclusion: forges.CIConclusionSuccess, SHA: "abc123def456", Branch: "feature", Event: "push"}, + {ID: 2, Title: "stale build", Status: forges.CIStatusCompleted, Conclusion: forges.CIConclusionSuccess, SHA: "older-sha", Branch: "feature", Event: "push"}, + }, + } + resolve.SetTestForge(&mockForge{prService: prs, ciService: ci, commitStatusService: statuses, checkRunService: checks}, "owner", "repo", "example.com") + t.Cleanup(resolve.ResetTestForge) + + out, err := runPRChecksCmd(t, []string{"42"}) + if err != nil { + t.Fatalf("pr checks: %v", err) + } + + // Commit statuses are requested for the PR's head SHA. + if statuses.listCalls != 1 { + t.Fatalf("CommitStatuses.List calls = %d, want 1", statuses.listCalls) + } + if statuses.listSHA != "abc123def456" { + t.Errorf("CommitStatuses.List SHA = %q, want %q", statuses.listSHA, "abc123def456") + } + + // Check runs are requested for the PR's head SHA. + if checks.listCalls != 1 { + t.Fatalf("CheckRuns.List calls = %d, want 1", checks.listCalls) + } + if checks.listSHA != "abc123def456" { + t.Errorf("CheckRuns.List SHA = %q, want %q", checks.listSHA, "abc123def456") + } + + // CI runs are requested for the head branch. + if ci.listCalls != 1 { + t.Fatalf("CI.ListRuns calls = %d, want 1", ci.listCalls) + } + if ci.listOpts.Branch != "feature" { + t.Errorf("CI.ListRuns branch = %q, want %q", ci.listOpts.Branch, "feature") + } + + for _, want := range []string{"Checks for #42", "Fix the thing", "ci/build", "ci/test", "success", "lint"} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } + + // Only the run matching the head SHA is shown. + if !strings.Contains(out, "1") || !strings.Contains(out, "build") { + t.Errorf("output missing matching CI run:\n%s", out) + } + if strings.Contains(out, "stale build") { + t.Errorf("output contains CI run for a different commit:\n%s", out) + } +} + +// TestPRChecksFailedExitCode exercises the command end to end: a failed +// commit status must produce exit code 1. +func TestPRChecksFailedExitCode(t *testing.T) { + resetCmd(rootCmd) + + prs := &mockPRService{ + pr: &forges.PullRequest{ + Number: 42, + Title: "Fix the thing", + Head: forges.PRBranch{Ref: "feature", SHA: "abc123"}, + }, + } + statuses := &mockCommitStatusService{ + listResult: []forges.CommitStatus{ + {State: forges.CommitStatusSuccess, Context: "ci/build"}, + {State: forges.CommitStatusFailure, Context: "ci/test"}, + }, + } + checks := &mockCheckRunService{} + ci := &mockCIService{ + listResult: []forges.CIRun{ + {ID: 1, Title: "build", Status: forges.CIStatusCompleted, Conclusion: forges.CIConclusionSuccess, SHA: "abc123"}, + }, + } + resolve.SetTestForge(&mockForge{prService: prs, ciService: ci, commitStatusService: statuses, checkRunService: checks}, "owner", "repo", "example.com") + t.Cleanup(resolve.ResetTestForge) + + out, err := runPRChecksCmd(t, []string{"42"}) + if err == nil { + t.Fatalf("expected error for failed checks, got none; output:\n%s", out) + } + var ee *exitError + if !errors.As(err, &ee) || ee.ExitCode() != prChecksExitFailed { + t.Errorf("error = %v, want exit code %d", err, prChecksExitFailed) + } +} + +// TestPRChecksPendingExitCode exercises the command end to end: with no +// failures but a pending status, the exit code is 8. +func TestPRChecksPendingExitCode(t *testing.T) { + resetCmd(rootCmd) + + prs := &mockPRService{ + pr: &forges.PullRequest{ + Number: 42, + Title: "Fix the thing", + Head: forges.PRBranch{Ref: "feature", SHA: "abc123"}, + }, + } + statuses := &mockCommitStatusService{ + listResult: []forges.CommitStatus{ + {State: forges.CommitStatusSuccess, Context: "ci/build"}, + {State: forges.CommitStatusPending, Context: "ci/test"}, + }, + } + checks := &mockCheckRunService{} + ci := &mockCIService{} + resolve.SetTestForge(&mockForge{prService: prs, ciService: ci, commitStatusService: statuses, checkRunService: checks}, "owner", "repo", "example.com") + t.Cleanup(resolve.ResetTestForge) + + _, err := runPRChecksCmd(t, []string{"42"}) + if err == nil { + t.Fatal("expected error for pending checks, got none") + } + var ee *exitError + if !errors.As(err, &ee) || ee.ExitCode() != prChecksExitPending { + t.Errorf("error = %v, want exit code %d", err, prChecksExitPending) + } +} + +// TestPRChecksFailedCheckRunExitCode covers a check run failure that is +// invisible to the legacy status and workflow-run sources. +func TestPRChecksFailedCheckRunExitCode(t *testing.T) { + resetCmd(rootCmd) + + prs := &mockPRService{ + pr: &forges.PullRequest{ + Number: 42, + Title: "Fix the thing", + Head: forges.PRBranch{Ref: "feature", SHA: "abc123"}, + }, + } + statuses := &mockCommitStatusService{} + checks := &mockCheckRunService{ + listResult: []forges.CheckRun{ + {ID: 7, Name: "third-party/scan", Status: forges.CIStatusCompleted, Conclusion: forges.CIConclusionFailure}, + }, + } + ci := &mockCIService{} + resolve.SetTestForge(&mockForge{prService: prs, ciService: ci, commitStatusService: statuses, checkRunService: checks}, "owner", "repo", "example.com") + t.Cleanup(resolve.ResetTestForge) + + out, err := runPRChecksCmd(t, []string{"42"}) + if err == nil { + t.Fatalf("expected error for failed check run, got none; output:\n%s", out) + } + var ee *exitError + if !errors.As(err, &ee) || ee.ExitCode() != prChecksExitFailed { + t.Errorf("error = %v, want exit code %d", err, prChecksExitFailed) + } + if !strings.Contains(out, "third-party/scan") { + t.Errorf("output missing check run:\n%s", out) + } +} + +// TestPRChecksCheckRunsAbsentFromOtherSources covers check runs that appear +// in neither commit statuses nor CI runs, the gap the Checks API fills. +func TestPRChecksCheckRunsAbsentFromOtherSources(t *testing.T) { + resetCmd(rootCmd) + + prs := &mockPRService{ + pr: &forges.PullRequest{ + Number: 42, + Title: "Fix the thing", + Head: forges.PRBranch{Ref: "feature", SHA: "abc123"}, + }, + } + statuses := &mockCommitStatusService{listResult: []forges.CommitStatus{}} + checks := &mockCheckRunService{ + listResult: []forges.CheckRun{ + {ID: 9, Name: "checks-only/sonar", Status: forges.CIStatusCompleted, Conclusion: forges.CIConclusionSuccess}, + {ID: 10, Name: "checks-only/deploy", Status: forges.CIStatusRunning}, + }, + } + ci := &mockCIService{listResult: []forges.CIRun{}} + resolve.SetTestForge(&mockForge{prService: prs, ciService: ci, commitStatusService: statuses, checkRunService: checks}, "owner", "repo", "example.com") + t.Cleanup(resolve.ResetTestForge) + + out, err := runPRChecksCmd(t, []string{"42"}) + // The running deploy check is pending, so the command exits 8. + var ee *exitError + if !errors.As(err, &ee) || ee.ExitCode() != prChecksExitPending { + t.Fatalf("error = %v, want exit code %d", err, prChecksExitPending) + } + for _, want := range []string{"Check runs:", "checks-only/sonar", "checks-only/deploy", "success", "running"} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } +} + +// TestPRChecksPlainOutputConclusions covers plain output showing the +// conclusion for CI runs instead of a bare status. +func TestPRChecksPlainOutputConclusions(t *testing.T) { + resetCmd(rootCmd) + + oldOutput := flagOutput + flagOutput = "plain" + defer func() { flagOutput = oldOutput }() + + prs := &mockPRService{ + pr: &forges.PullRequest{ + Number: 42, + Title: "Fix the thing", + Head: forges.PRBranch{Ref: "feature", SHA: "abc123"}, + }, + } + statuses := &mockCommitStatusService{} + checks := &mockCheckRunService{} + ci := &mockCIService{ + listResult: []forges.CIRun{ + {ID: 1, Title: "build", Status: forges.CIStatusCompleted, Conclusion: forges.CIConclusionSuccess, SHA: "abc123"}, + {ID: 2, Title: "test", Status: forges.CIStatusCompleted, Conclusion: forges.CIConclusionFailure, SHA: "abc123"}, + }, + } + resolve.SetTestForge(&mockForge{prService: prs, ciService: ci, commitStatusService: statuses, checkRunService: checks}, "owner", "repo", "example.com") + t.Cleanup(resolve.ResetTestForge) + + out, err := runPRChecksCmd(t, []string{"42"}) + if err == nil { + t.Fatalf("expected error for failed CI run, got none; output:\n%s", out) + } + for _, want := range []string{"ci\t1\tbuild\tsuccess", "ci\t2\ttest\tfailure"} { + if !strings.Contains(out, want) { + t.Errorf("plain output missing %q:\n%s", want, out) + } + } + // Plain output is data lines only: the "Checks for #N" header is not + // tab-separated and must not appear. + if strings.Contains(out, "Checks for") { + t.Errorf("plain output contains the header line:\n%s", out) + } + if strings.Contains(out, "ci\t1\tbuild\tcompleted") || strings.Contains(out, "ci\t2\ttest\tcompleted") { + t.Errorf("plain output shows bare status instead of conclusion:\n%s", out) + } +} + +// TestPRChecksStalePendingStatusIgnored exercises the command end to end: +// ListStatuses returns the full history for a ref, so a context that posted +// pending and then success yields both entries. Only the newest status per +// context counts, so the command exits 0 and prints a single row. +func TestPRChecksStalePendingStatusIgnored(t *testing.T) { + resetCmd(rootCmd) + + prs := &mockPRService{ + pr: &forges.PullRequest{ + Number: 42, + Title: "Fix the thing", + Head: forges.PRBranch{Ref: "feature", SHA: "abc123"}, + }, + } + earlier := time.Date(2026, 10, 20, 8, 0, 0, 0, time.UTC) + statuses := &mockCommitStatusService{ + listResult: []forges.CommitStatus{ + {State: forges.CommitStatusPending, Context: "ci/build", CreatedAt: earlier}, + {State: forges.CommitStatusSuccess, Context: "ci/build", CreatedAt: earlier.Add(time.Minute)}, + }, + } + checks := &mockCheckRunService{} + ci := &mockCIService{} + resolve.SetTestForge(&mockForge{prService: prs, ciService: ci, commitStatusService: statuses, checkRunService: checks}, "owner", "repo", "example.com") + t.Cleanup(resolve.ResetTestForge) + + out, err := runPRChecksCmd(t, []string{"42"}) + if err != nil { + t.Fatalf("pr checks: %v (a stale pending status must not fail the command); output:\n%s", err, out) + } + if n := strings.Count(out, "ci/build"); n != 1 { + t.Errorf("output has %d rows for ci/build, want 1:\n%s", n, out) + } + if strings.Contains(out, "pending") { + t.Errorf("output shows the stale pending status:\n%s", out) + } +} + +func TestPRChecksUnsupportedForge(t *testing.T) { + resetCmd(rootCmd) + + prs := &mockPRService{ + pr: &forges.PullRequest{ + Number: 42, + Title: "Fix the thing", + Head: forges.PRBranch{Ref: "feature", SHA: "abc123"}, + }, + } + ci := &mockCIService{listErr: forges.ErrNotSupported} + statuses := &mockCommitStatusService{listErr: forges.ErrNotSupported} + checks := &mockCheckRunService{listErr: forges.ErrNotSupported} + resolve.SetTestForge(&mockForge{prService: prs, ciService: ci, commitStatusService: statuses, checkRunService: checks}, "owner", "repo", "example.com") + t.Cleanup(resolve.ResetTestForge) + + _, err := runPRChecksCmd(t, []string{"42"}) + if err == nil || !strings.Contains(err.Error(), "checks are not supported by this forge") { + t.Fatalf("pr checks error = %v, want unsupported-forge error", err) + } +} + +func TestPRChecksSkipsCIRunsForFullRef(t *testing.T) { + resetCmd(rootCmd) + + prs := &mockPRService{ + pr: &forges.PullRequest{ + Number: 42, + Title: "AGit PR", + Head: forges.PRBranch{Ref: "refs/pull/42/head", SHA: "abc123"}, + }, + } + statuses := &mockCommitStatusService{ + listResult: []forges.CommitStatus{ + {State: forges.CommitStatusSuccess, Context: "ci/build"}, + }, + } + checks := &mockCheckRunService{listErr: forges.ErrNotSupported} + ci := &mockCIService{} + resolve.SetTestForge(&mockForge{prService: prs, ciService: ci, commitStatusService: statuses, checkRunService: checks}, "owner", "repo", "example.com") + t.Cleanup(resolve.ResetTestForge) + + out, err := runPRChecksCmd(t, []string{"42"}) + if err != nil { + t.Fatalf("pr checks: %v", err) + } + if ci.listCalls != 0 { + t.Errorf("CI.ListRuns calls = %d, want 0 for a full-ref head", ci.listCalls) + } + if !strings.Contains(out, "ci/build") { + t.Errorf("output missing commit statuses:\n%s", out) + } + if strings.Contains(out, "CI runs:") { + t.Errorf("output should not contain a CI runs section for a full-ref head:\n%s", out) + } +} + +func TestPRChecksJSONOutput(t *testing.T) { + resetCmd(rootCmd) + + oldOutput := flagOutput + flagOutput = "json" + defer func() { flagOutput = oldOutput }() + + prs := &mockPRService{ + pr: &forges.PullRequest{ + Number: 42, + Title: "Fix the thing", + Head: forges.PRBranch{Ref: "feature", SHA: "abc123"}, + }, + } + statuses := &mockCommitStatusService{ + listResult: []forges.CommitStatus{ + {State: forges.CommitStatusSuccess, Context: "ci/build"}, + }, + } + checks := &mockCheckRunService{ + listResult: []forges.CheckRun{ + {ID: 11, Name: "lint", Status: forges.CIStatusCompleted, Conclusion: forges.CIConclusionSuccess}, + }, + } + ci := &mockCIService{ + listResult: []forges.CIRun{ + {ID: 1, Title: "build", Status: forges.CIStatusCompleted, SHA: "abc123"}, + }, + } + resolve.SetTestForge(&mockForge{prService: prs, ciService: ci, commitStatusService: statuses, checkRunService: checks}, "owner", "repo", "example.com") + t.Cleanup(resolve.ResetTestForge) + + out, err := runPRChecksCmd(t, []string{"42"}) + if err != nil { + t.Fatalf("pr checks: %v", err) + } + for _, want := range []string{`"commit_statuses"`, `"ci_runs"`, `"ci/build"`, `"check_runs"`, `"lint"`} { + if !strings.Contains(out, want) { + t.Errorf("JSON output missing %q:\n%s", want, out) + } + } +} + +// TestPRChecksEmptySourcesShown covers backends that return nil when a listed +// source has nothing: each section still prints and JSON has [] not null. +func TestPRChecksEmptySourcesShown(t *testing.T) { + prs := &mockPRService{ + pr: &forges.PullRequest{ + Number: 42, + Title: "Fix the thing", + Head: forges.PRBranch{Ref: "feature", SHA: "abc123"}, + }, + } + resolve.SetTestForge(&mockForge{ + prService: prs, + ciService: &mockCIService{}, + commitStatusService: &mockCommitStatusService{}, + checkRunService: &mockCheckRunService{}, + }, "owner", "repo", "example.com") + t.Cleanup(resolve.ResetTestForge) + + resetCmd(rootCmd) + out, err := runPRChecksCmd(t, []string{"42"}) + if err != nil { + t.Fatalf("pr checks: %v", err) + } + for _, want := range []string{"No commit statuses reported", "No check runs reported", "No CI runs for this commit"} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } + + oldOutput := flagOutput + flagOutput = "json" + defer func() { flagOutput = oldOutput }() + + out, err = runPRChecksCmd(t, []string{"42"}) + if err != nil { + t.Fatalf("pr checks json: %v", err) + } + if strings.Contains(out, "null") { + t.Errorf("JSON output has null for a listed source:\n%s", out) + } +} + +func TestFilterCIRunsForSHA(t *testing.T) { + runs := []forges.CIRun{ + {ID: 1, SHA: "abc"}, + {ID: 2, SHA: "def"}, + {ID: 3, SHA: ""}, + } + + filtered := filterCIRunsForSHA(runs, "abc") + if len(filtered) != 2 || filtered[0].ID != 1 || filtered[1].ID != 3 { + t.Errorf("filterCIRunsForSHA = %v, want runs 1 and 3", filtered) + } + + if got := filterCIRunsForSHA(runs, ""); len(got) != 3 { + t.Errorf("filterCIRunsForSHA with empty sha = %d runs, want 3", len(got)) + } + + var _ *cobra.Command +} diff --git a/services.go b/services.go index e6204c9..f8517d6 100644 --- a/services.go +++ b/services.go @@ -156,6 +156,11 @@ type CommitStatusService interface { Set(ctx context.Context, owner, repo, sha string, opts SetCommitStatusOpts) (*CommitStatus, error) } +// CheckRunService provides operations on check runs (a forge's Checks API). +type CheckRunService interface { + List(ctx context.Context, owner, repo, sha string) ([]CheckRun, error) +} + // IssueService provides operations on issues. type IssueService interface { Get(ctx context.Context, owner, repo string, number int) (*Issue, error) diff --git a/statuses.go b/statuses.go index 8036306..d3dda77 100644 --- a/statuses.go +++ b/statuses.go @@ -1,6 +1,9 @@ package forges -import "strings" +import ( + "strings" + "time" +) // IssueState is the normalized lifecycle state for issues. type IssueState string @@ -148,6 +151,18 @@ func NormalizeCIConclusion(conclusion string) CIConclusion { } } +// CheckRun holds normalized metadata about a check run reported through a +// forge's Checks API for a commit (e.g. GitHub check runs). +type CheckRun struct { + ID int64 `json:"id"` + Name string `json:"name"` + Status CIStatus `json:"status"` // queued, in_progress, completed + Conclusion CIConclusion `json:"conclusion,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` +} + // NormalizeCommitStatusState maps forge-specific commit status states to common states. func NormalizeCommitStatusState(state string) CommitStatusState { switch normalizeToken(state) { diff --git a/tangled/tangled.go b/tangled/tangled.go index 04c1670..1ac272e 100644 --- a/tangled/tangled.go +++ b/tangled/tangled.go @@ -64,7 +64,8 @@ func (f *tangledForge) Collaborators() forges.CollaboratorService { func (f *tangledForge) CommitStatuses() forges.CommitStatusService { return unsupportedCommitStatusService{} } -func (f *tangledForge) Commits() forges.CommitService { return unsupportedCommitService{} } +func (f *tangledForge) CheckRuns() forges.CheckRunService { return unsupportedCheckRunService{} } +func (f *tangledForge) Commits() forges.CommitService { return unsupportedCommitService{} } func (f *tangledForge) GetRateLimit(context.Context) (*forges.RateLimit, error) { return nil, forges.ErrNotSupported diff --git a/tangled/unsupported.go b/tangled/unsupported.go index 8376ff1..b0738a7 100644 --- a/tangled/unsupported.go +++ b/tangled/unsupported.go @@ -266,6 +266,12 @@ func (unsupportedCommitStatusService) Set(context.Context, string, string, strin return nil, forges.ErrNotSupported } +type unsupportedCheckRunService struct{} + +func (unsupportedCheckRunService) List(context.Context, string, string, string) ([]forges.CheckRun, error) { + return nil, forges.ErrNotSupported +} + type unsupportedCommitService struct{} func (unsupportedCommitService) ResolveCommit(context.Context, string, string, string) (string, error) {