From 7f63525f67d26b426c992847e2f1d1fea1c89a43 Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Fri, 12 Jun 2026 17:07:27 -0400 Subject: [PATCH] bitbucket: Implement ChecksByChange with per-status build detail Implements the Bitbucket side of the per-change checks API: rollup state from the PR head commit's build statuses, plus per-status name/state/url forwarded as runs[]. - gateway: CommitStatus grows Key, Name, and URL fields. Existing callers and fixtures keep working because the new fields are omitempty. - rollupFromCommitStatuses collapses Bitbucket build-status states to the upstream rollup taxonomy. Failure wins over pending wins over passed. Empty list means no CI configured -> ChecksNone. Stopped statuses are operator-cancelled non-failures and roll up as passed (changed from the merge handler's aggregateStatuses, which treated Stopped as Failed; that path is preserved for backwards compatibility but no longer informs the richer API). - ChecksByChange issues getPullRequest + CommitStatusList per PR. State is lowercased to match the spec's forge-native convention; Name falls back to Key when the build-status provider didn't supply a human name. - aggregateStatuses (the merge handler's path) keeps its pre-existing semantics and is now scoped narrowly so the refactor doesn't change merge behavior. Integration fixtures regenerate on the next forgetest -update pass (real Bitbucket credentials). --- internal/forge/bitbucket/checks_report.go | 100 ++++++++++++++++-- .../forge/bitbucket/checks_report_test.go | 68 ++++++++++++ internal/gateway/bitbucket/api.go | 7 ++ 3 files changed, 168 insertions(+), 7 deletions(-) create mode 100644 internal/forge/bitbucket/checks_report_test.go diff --git a/internal/forge/bitbucket/checks_report.go b/internal/forge/bitbucket/checks_report.go index 3bd94d60..2d578000 100644 --- a/internal/forge/bitbucket/checks_report.go +++ b/internal/forge/bitbucket/checks_report.go @@ -2,18 +2,104 @@ package bitbucket import ( "context" + "fmt" + "strings" "go.abhg.dev/gs/internal/forge" + "go.abhg.dev/gs/internal/gateway/bitbucket" ) -// ChecksByChange reports per-change rolled-up and per-run check state -// for each of the given changes. +// rollupFromCommitStatuses collapses a Bitbucket build-status list into +// the forge rollup taxonomy. // -// 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. +// Failure wins over pending; pending wins over passed; absence of any +// statuses returns ChecksRollupNone (no CI configured/reported). +// Stopped statuses are operator-cancelled non-failures and roll up as +// passed. +func rollupFromCommitStatuses( + statuses []bitbucket.CommitStatus, +) forge.ChecksRollupState { + if len(statuses) == 0 { + return forge.ChecksRollupNone + } + + pending := false + for _, s := range statuses { + switch s.State { + case bitbucket.CommitStatusFailed: + return forge.ChecksRollupFailed + case bitbucket.CommitStatusInProgress: + pending = true + } + } + if pending { + return forge.ChecksRollupPending + } + return forge.ChecksRollupPassed +} + +// ChecksByChange reports per-change rolled-up and per-run build state +// for each of the given pull requests. +// +// One getPullRequest + (when the PR has a source commit) a paginated +// CommitStatusList walk per PR. Build statuses are reported in +// Bitbucket's natural order. 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) { + prID := mustPR(id) + pr, err := r.getPullRequest(ctx, prID.Number) + if err != nil { + return nil, fmt.Errorf("get pull request: %w", err) + } + + if pr.Source.Commit == nil { + return &forge.ChecksReport{Rollup: forge.ChecksRollupNone}, nil + } + + var statuses []bitbucket.CommitStatus + opt := &bitbucket.CommitStatusListOptions{} + for { + page, resp, err := r.client.CommitStatusList( + ctx, r.workspace, r.repo, pr.Source.Commit.Hash, opt, + ) + if err != nil { + return nil, fmt.Errorf("get commit statuses: %w", err) + } + + statuses = append(statuses, page.Values...) + if resp.NextURL == "" { + break + } + opt.PageURL = resp.NextURL + } + + out := &forge.ChecksReport{Rollup: rollupFromCommitStatuses(statuses)} + out.Runs = make([]forge.CheckRun, 0, len(statuses)) + for _, s := range statuses { + name := s.Name + if name == "" { + name = s.Key + } + out.Runs = append(out.Runs, forge.CheckRun{ + Name: name, + State: strings.ToLower(s.State), + URL: s.URL, + }) + } + return out, nil } diff --git a/internal/forge/bitbucket/checks_report_test.go b/internal/forge/bitbucket/checks_report_test.go new file mode 100644 index 00000000..121cecb4 --- /dev/null +++ b/internal/forge/bitbucket/checks_report_test.go @@ -0,0 +1,68 @@ +package bitbucket + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.abhg.dev/gs/internal/forge" + "go.abhg.dev/gs/internal/gateway/bitbucket" +) + +func TestRollupFromCommitStatuses(t *testing.T) { + tests := []struct { + name string + statuses []bitbucket.CommitStatus + want forge.ChecksRollupState + }{ + { + name: "Empty", + want: forge.ChecksRollupNone, + }, + { + name: "AllSuccessful", + statuses: []bitbucket.CommitStatus{ + {State: bitbucket.CommitStatusSuccessful}, + {State: bitbucket.CommitStatusSuccessful}, + }, + want: forge.ChecksRollupPassed, + }, + { + name: "Failed", + statuses: []bitbucket.CommitStatus{ + {State: bitbucket.CommitStatusSuccessful}, + {State: bitbucket.CommitStatusFailed}, + }, + want: forge.ChecksRollupFailed, + }, + { + name: "InProgress", + statuses: []bitbucket.CommitStatus{ + {State: bitbucket.CommitStatusSuccessful}, + {State: bitbucket.CommitStatusInProgress}, + }, + want: forge.ChecksRollupPending, + }, + { + name: "FailedTrumpsInProgress", + statuses: []bitbucket.CommitStatus{ + {State: bitbucket.CommitStatusInProgress}, + {State: bitbucket.CommitStatusFailed}, + }, + want: forge.ChecksRollupFailed, + }, + { + name: "StoppedIsPassed", + statuses: []bitbucket.CommitStatus{ + {State: bitbucket.CommitStatusSuccessful}, + {State: bitbucket.CommitStatusStopped}, + }, + want: forge.ChecksRollupPassed, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, rollupFromCommitStatuses(tt.statuses)) + }) + } +} diff --git a/internal/gateway/bitbucket/api.go b/internal/gateway/bitbucket/api.go index 40d83300..b8db9390 100644 --- a/internal/gateway/bitbucket/api.go +++ b/internal/gateway/bitbucket/api.go @@ -404,6 +404,13 @@ func (c *Client) PullRequestMerge( type CommitStatus struct { Key string `json:"key"` State string `json:"state"` + + // Name is the human-readable label for the build status, if any. + // Falls back to Key when empty. + Name string `json:"name,omitempty"` + + // URL links to the build's detail page, if any. + URL string `json:"url,omitempty"` } // CommitStatusCreateRequest is the request body for creating a build status.