diff --git a/internal/forge/github/checks_report.go b/internal/forge/github/checks_report.go index be06dca6..365bbffd 100644 --- a/internal/forge/github/checks_report.go +++ b/internal/forge/github/checks_report.go @@ -2,18 +2,190 @@ package github import ( "context" + "fmt" + "strings" + + "github.com/shurcooL/githubv4" "go.abhg.dev/gs/internal/forge" ) -// ChecksByChange reports per-change rolled-up and per-run check state -// for each of the given changes. +// GitHub StatusCheckRollup.state values, as plain strings (the batch +// checks query below reads the rollup state as an untyped string). +const ( + statusStateExpected = "EXPECTED" + statusStatePending = "PENDING" + statusStateError = "ERROR" + statusStateFailure = "FAILURE" + statusStateSuccess = "SUCCESS" +) + +// rollupFromStatusCheckRollupState collapses GitHub's +// StatusCheckRollup.state into the forge rollup taxonomy. +// +// An empty input means no rollup exists for the commit, i.e. no +// checks have been reported. Unknown states are treated as failure +// to preserve a fail-safe default; the per-run detail retains the +// native vocabulary. +func rollupFromStatusCheckRollupState(state string) forge.ChecksRollupState { + switch state { + case "": + return forge.ChecksRollupNone + case statusStateSuccess: + return forge.ChecksRollupPassed + case statusStatePending, statusStateExpected: + return forge.ChecksRollupPending + case statusStateError, statusStateFailure: + return forge.ChecksRollupFailed + default: + return forge.ChecksRollupFailed + } +} + +// nativeCheckRunState returns the forge-native lowercase string for +// a CheckRun: its conclusion when COMPLETED, otherwise its status. // -// TODO: real implementation lands on a follow-up branch. -// This stub returns one nil per id to satisfy the [forge.Repository] -// interface while the schema branch lands standalone. +// A COMPLETED run with no conclusion (rare in practice) falls back +// to "completed" so the caller still surfaces a non-empty state. +func nativeCheckRunState(status, conclusion string) string { + if status == "COMPLETED" { + if conclusion != "" { + return strings.ToLower(conclusion) + } + return "completed" + } + return strings.ToLower(status) +} + +// nativeStatusContextState returns the forge-native lowercase string +// for a StatusContext. +func nativeStatusContextState(state string) string { + return strings.ToLower(state) +} + +// checksContextLimit caps how many CheckRun/StatusContext entries we +// fetch per change in a single GraphQL query. GitHub's +// StatusCheckRollup.contexts is a connection; this avoids pagination +// for the common case of single-digit checks per PR. +const checksContextLimit = 50 + +// changeChecksGraphQL is the GraphQL query shape we issue per PR to +// fetch both the rollup and per-run detail. +// +// The PR's permalink + "/checks" gives the forge checks summary page +// — there is no first-class field for that on PullRequest. +type changeChecksGraphQL struct { + Node struct { + PullRequest struct { + Permalink string + Commits struct { + Nodes []struct { + Commit struct { + StatusCheckRollup *struct { + State string + Contexts struct { + Nodes []struct { + Typename string `graphql:"__typename"` + CheckRun checkRunNode `graphql:"... on CheckRun"` + StatusContext statusContextNode `graphql:"... on StatusContext"` + } + } `graphql:"contexts(first: $contextLimit)"` + } + } + } + } `graphql:"commits(last: 1)"` + } `graphql:"... on PullRequest"` + } `graphql:"node(id: $id)"` +} + +type checkRunNode struct { + Name string + Status string + Conclusion string + DetailsURL string `graphql:"detailsUrl"` +} + +type statusContextNode struct { + Context string + State string + TargetURL string `graphql:"targetUrl"` +} + +// ChecksByChange reports per-change rolled-up and per-run check state +// for each of the given PRs. One GraphQL query per PR; GitHub's +// batched-by-aliases shape is awkward and the per-PR queries are cheap +// relative to the rest of `gs ll` for the common case of single-digit +// stack depth. func (r *Repository) ChecksByChange( - _ context.Context, ids []forge.ChangeID, + ctx context.Context, ids []forge.ChangeID, ) ([]*forge.ChecksReport, error) { - return make([]*forge.ChecksReport, len(ids)), nil + out := make([]*forge.ChecksReport, len(ids)) + for i, id := range ids { + checks, err := r.changeChecks(ctx, id) + if err != nil { + return nil, fmt.Errorf("change %v: %w", id, err) + } + out[i] = checks + } + return out, nil +} + +func (r *Repository) changeChecks( + ctx context.Context, id forge.ChangeID, +) (*forge.ChecksReport, error) { + pr := mustPR(id) + gqlID, err := r.graphQLID(ctx, pr) + if err != nil { + return nil, fmt.Errorf("resolve PR ID: %w", err) + } + + var q changeChecksGraphQL + if err := r.client.Query(ctx, &q, map[string]any{ + "id": gqlID, + "contextLimit": githubv4.Int(checksContextLimit), + }); err != nil { + return nil, fmt.Errorf("query checks: %w", err) + } + + commits := q.Node.PullRequest.Commits.Nodes + if len(commits) == 0 { + return &forge.ChecksReport{Rollup: forge.ChecksRollupNone}, nil + } + + checksURL := "" + if pl := q.Node.PullRequest.Permalink; pl != "" { + checksURL = pl + "/checks" + } + + rollupNode := commits[0].Commit.StatusCheckRollup + if rollupNode == nil { + return &forge.ChecksReport{ + Rollup: forge.ChecksRollupNone, + URL: checksURL, + }, nil + } + + runs := make([]forge.CheckRun, 0, len(rollupNode.Contexts.Nodes)) + for _, node := range rollupNode.Contexts.Nodes { + switch node.Typename { + case "CheckRun": + runs = append(runs, forge.CheckRun{ + Name: node.CheckRun.Name, + State: nativeCheckRunState(node.CheckRun.Status, node.CheckRun.Conclusion), + URL: node.CheckRun.DetailsURL, + }) + case "StatusContext": + runs = append(runs, forge.CheckRun{ + Name: node.StatusContext.Context, + State: nativeStatusContextState(node.StatusContext.State), + URL: node.StatusContext.TargetURL, + }) + } + } + + return &forge.ChecksReport{ + Rollup: rollupFromStatusCheckRollupState(rollupNode.State), + Runs: runs, + URL: checksURL, + }, nil } diff --git a/internal/forge/github/checks_report_test.go b/internal/forge/github/checks_report_test.go new file mode 100644 index 00000000..f1dfbabc --- /dev/null +++ b/internal/forge/github/checks_report_test.go @@ -0,0 +1,72 @@ +package github + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.abhg.dev/gs/internal/forge" +) + +func TestRollupFromStatusCheckRollupState(t *testing.T) { + tests := []struct { + state string + want forge.ChecksRollupState + }{ + {"SUCCESS", forge.ChecksRollupPassed}, + {"PENDING", forge.ChecksRollupPending}, + {"EXPECTED", forge.ChecksRollupPending}, + {"FAILURE", forge.ChecksRollupFailed}, + {"ERROR", forge.ChecksRollupFailed}, + {"", forge.ChecksRollupNone}, + {"unknown", forge.ChecksRollupFailed}, + } + + for _, tt := range tests { + t.Run(tt.state, func(t *testing.T) { + assert.Equal(t, tt.want, rollupFromStatusCheckRollupState(tt.state)) + }) + } +} + +func TestNativeCheckRunState(t *testing.T) { + tests := []struct { + name string + status string + conclusion string + want string + }{ + {"CompletedSuccess", "COMPLETED", "SUCCESS", "success"}, + {"CompletedFailure", "COMPLETED", "FAILURE", "failure"}, + {"CompletedNeutral", "COMPLETED", "NEUTRAL", "neutral"}, + {"CompletedTimedOut", "COMPLETED", "TIMED_OUT", "timed_out"}, + {"InProgress", "IN_PROGRESS", "", "in_progress"}, + {"Queued", "QUEUED", "", "queued"}, + {"CompletedWithoutConclusion", "COMPLETED", "", "completed"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, nativeCheckRunState(tt.status, tt.conclusion)) + }) + } +} + +func TestNativeStatusContextState(t *testing.T) { + tests := []struct { + state string + want string + }{ + {"SUCCESS", "success"}, + {"FAILURE", "failure"}, + {"ERROR", "error"}, + {"PENDING", "pending"}, + {"EXPECTED", "expected"}, + {"", ""}, + } + + for _, tt := range tests { + t.Run(tt.state, func(t *testing.T) { + assert.Equal(t, tt.want, nativeStatusContextState(tt.state)) + }) + } +}