diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index 507c9d1c6..013b9479f 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -203,9 +203,10 @@ See https://abhinav.github.io/git-spice/cli/json/ for details. * `-a`, `--all` ([:material-wrench:{ .middle title="spice.log.all" }](/cli/config.md#spicelogall)): Show all tracked branches, not just the current stack. * `-S`, `--[no-]cr-status` ([:material-wrench:{ .middle title="spice.log.crStatus" }](/cli/config.md#spicelogcrstatus)): Request and include information about the Change Request * `-c`, `--[no-]cr-comments` ([:material-wrench:{ .middle title="spice.log.crComments" }](/cli/config.md#spicelogcrcomments)): Include comment resolution counts for changes +* `--[no-]cr-checks` ([:material-wrench:{ .middle title="spice.log.crChecks" }](/cli/config.md#spicelogcrchecks)): Include forge CI/check rollup and per-run detail for changes * `--json`: Write to stdout as a stream of JSON objects in an unspecified order :material-tag:{ title="Released in version" }[v0.18.0](/changelog.md#v0.18.0) -**Configuration**: [spice.log.all](/cli/config.md#spicelogall), [spice.log.crComments](/cli/config.md#spicelogcrcomments), [spice.log.crFormat](/cli/config.md#spicelogcrformat), [spice.log.crStatus](/cli/config.md#spicelogcrstatus), [spice.log.pushStatusFormat](/cli/config.md#spicelogpushstatusformat), [spice.logLong.crFormat](/cli/config.md#spiceloglongcrformat), [spice.logShort.crFormat](/cli/config.md#spicelogshortcrformat) +**Configuration**: [spice.log.all](/cli/config.md#spicelogall), [spice.log.crChecks](/cli/config.md#spicelogcrchecks), [spice.log.crComments](/cli/config.md#spicelogcrcomments), [spice.log.crFormat](/cli/config.md#spicelogcrformat), [spice.log.crStatus](/cli/config.md#spicelogcrstatus), [spice.log.pushStatusFormat](/cli/config.md#spicelogpushstatusformat), [spice.logLong.crFormat](/cli/config.md#spiceloglongcrformat), [spice.logShort.crFormat](/cli/config.md#spicelogshortcrformat) ### git-spice log long {#gs-log-long} @@ -227,9 +228,10 @@ See https://abhinav.github.io/git-spice/cli/json/ for details. * `-a`, `--all` ([:material-wrench:{ .middle title="spice.log.all" }](/cli/config.md#spicelogall)): Show all tracked branches, not just the current stack. * `-S`, `--[no-]cr-status` ([:material-wrench:{ .middle title="spice.log.crStatus" }](/cli/config.md#spicelogcrstatus)): Request and include information about the Change Request * `-c`, `--[no-]cr-comments` ([:material-wrench:{ .middle title="spice.log.crComments" }](/cli/config.md#spicelogcrcomments)): Include comment resolution counts for changes +* `--[no-]cr-checks` ([:material-wrench:{ .middle title="spice.log.crChecks" }](/cli/config.md#spicelogcrchecks)): Include forge CI/check rollup and per-run detail for changes * `--json`: Write to stdout as a stream of JSON objects in an unspecified order :material-tag:{ title="Released in version" }[v0.18.0](/changelog.md#v0.18.0) -**Configuration**: [spice.log.all](/cli/config.md#spicelogall), [spice.log.crComments](/cli/config.md#spicelogcrcomments), [spice.log.crFormat](/cli/config.md#spicelogcrformat), [spice.log.crStatus](/cli/config.md#spicelogcrstatus), [spice.log.pushStatusFormat](/cli/config.md#spicelogpushstatusformat), [spice.logLong.crFormat](/cli/config.md#spiceloglongcrformat), [spice.logShort.crFormat](/cli/config.md#spicelogshortcrformat) +**Configuration**: [spice.log.all](/cli/config.md#spicelogall), [spice.log.crChecks](/cli/config.md#spicelogcrchecks), [spice.log.crComments](/cli/config.md#spicelogcrcomments), [spice.log.crFormat](/cli/config.md#spicelogcrformat), [spice.log.crStatus](/cli/config.md#spicelogcrstatus), [spice.log.pushStatusFormat](/cli/config.md#spicelogpushstatusformat), [spice.logLong.crFormat](/cli/config.md#spiceloglongcrformat), [spice.logShort.crFormat](/cli/config.md#spicelogshortcrformat) ## Stack diff --git a/internal/handler/list/handler.go b/internal/handler/list/handler.go index f71702562..a5458b5e9 100644 --- a/internal/handler/list/handler.go +++ b/internal/handler/list/handler.go @@ -100,7 +100,12 @@ const ( // branches that have an associated ChangeID. IncludeCommentCounts - needsRemoteID = IncludeChangeURL | IncludeChangeState | IncludeCommentCounts + // IncludeChecks includes per-change rolled-up and per-run check + // state for branches that have an associated ChangeID. + IncludeChecks + + needsRemoteID = IncludeChangeURL | IncludeChangeState | + IncludeCommentCounts | IncludeChecks ) // BranchesRequest holds the parameters for the log command. @@ -141,6 +146,7 @@ type BranchItem struct { ChangeURL string // only if IncludeChangeURL is set ChangeState forge.ChangeState // populated if RemoteRepository is available CommentCounts *forge.CommentCounts // only if IncludeCommentCounts is set + Checks *forge.ChecksReport // only if IncludeChecks is set PushStatus *PushStatus // only if IncludePushStatus is set // Worktree is the absolute path to the worktree where this branch is checked out. @@ -365,7 +371,7 @@ func (h *Handler) ListBranches(ctx context.Context, req *BranchesRequest) (*Bran } // If requested and possible, batch-resolve remote change metadata. - if req.Include&(IncludeChangeState|IncludeCommentCounts) != 0 && remoteForge != nil { + if req.Include&(IncludeChangeState|IncludeCommentCounts|IncludeChecks) != 0 && remoteForge != nil { // Try to load remote metadata, but don't fail the whole operation // if something goes wrong. if err := h.loadRemoteChangeData(ctx, remoteForge, remoteRepoID, req.Include, items); err != nil { @@ -445,6 +451,24 @@ func (h *Handler) loadRemoteChangeData( }) } + if include&IncludeChecks != 0 { + var checks []*forge.ChecksReport + updates = append(updates, func() { + for j, idx := range branchesIdx { + branches[idx].Checks = checks[j] + } + }) + + wg.Go(func() error { + var err error + checks, err = remoteRepo.ChecksByChange(ctx, changeIDs) + if err != nil { + return fmt.Errorf("retrieve checks: %w", err) + } + return nil + }) + } + if err := wg.Wait(); err != nil { return err } diff --git a/log.go b/log.go index 58c03dac3..52ba5b285 100644 --- a/log.go +++ b/log.go @@ -76,6 +76,8 @@ type branchLogCmd struct { Comments bool `name:"cr-comments" short:"c" config:"log.crComments" help:"Include comment resolution counts for changes" default:"false" negatable:""` + Checks bool `name:"cr-checks" config:"log.crChecks" help:"Include forge CI/check rollup and per-run detail for changes" default:"false" negatable:""` + PushStatusFormat pushStatusFormat `config:"log.pushStatusFormat" help:"Show indicator for branches that are out of sync with their remotes. One of 'true', 'false' and 'aheadbehind'." hidden:"" default:"true"` JSON bool `name:"json" released:"v0.18.0" help:"Write to stdout as a stream of JSON objects in an unspecified order"` @@ -100,12 +102,13 @@ func (cmd *branchLogCmd) run( } var presenter logPresenter - var wantPushStatus, wantChangeState, wantCommentCounts bool + var wantPushStatus, wantChangeState, wantCommentCounts, wantChecks bool if cmd.JSON { - // JSON always wants URLs and push status, but respects flags for change state/comments. + // JSON always wants URLs and push status, but respects flags for change state/comments/checks. wantPushStatus = true wantChangeState = cmd.CRStatus wantCommentCounts = cmd.Comments + wantChecks = cmd.Checks presenter = &jsonLogPresenter{ Stdout: kctx.Stdout, @@ -124,6 +127,11 @@ func (cmd *branchLogCmd) run( wantPushStatus = cmd.PushStatusFormat.Enabled() wantChangeState = cmd.CRStatus wantCommentCounts = cmd.Comments + // Graph presenter doesn't surface checks yet — UI surface + // for the rollup is tracked in the extension. The flag still + // fetches and includes the data so other consumers + // (e.g. piped tooling) can use it via --json. + _ = wantChecks stderrView := ui.NewFileView(kctx.Stderr) presenter = &graphLogPresenter{ @@ -154,6 +162,9 @@ func (cmd *branchLogCmd) run( if wantCommentCounts { req.Include |= list.IncludeCommentCounts } + if wantChecks { + req.Include |= list.IncludeChecks + } res, err := listHandler.ListBranches(ctx, &req) if err != nil { @@ -335,6 +346,23 @@ func (p *jsonLogPresenter) Present(res *list.BranchesResponse, currentBranch str Unresolved: cc.Unresolved, } } + if checks := branch.Checks; checks != nil { + jchecks := &jsonLogChecks{ + Rollup: checks.Rollup.String(), + URL: checks.URL, + } + if len(checks.Runs) > 0 { + jchecks.Runs = make([]jsonLogCheckRun, len(checks.Runs)) + for i, r := range checks.Runs { + jchecks.Runs[i] = jsonLogCheckRun{ + Name: r.Name, + State: r.State, + URL: r.URL, + } + } + } + jc.Checks = jchecks + } logBranch.Change = jc } @@ -435,6 +463,40 @@ type jsonLogChange struct { // Comments contains comment resolution counts for the change. Comments *jsonLogComments `json:"comments,omitempty"` + + // Checks contains the forge CI/check rollup and per-run detail + // for the change. Unset when --cr-checks is not enabled or when + // no checks are reported. + Checks *jsonLogChecks `json:"checks,omitempty"` +} + +// jsonLogChecks is the forge check status payload for a change. +// +// Rollup is the single rolled-up state. Runs preserves forge-native +// per-check state strings. URL is the forge's summary page for the +// change's checks (click target). +type jsonLogChecks struct { + // Rollup is one of "pending", "passed", "failed", or "none". + Rollup string `json:"rollup"` + + // Runs is the per-check detail in forge-defined order. + // Omitted when no per-check data is available. + Runs []jsonLogCheckRun `json:"runs,omitempty"` + + // URL is the forge's summary page for the change's checks. + URL string `json:"url,omitempty"` +} + +// jsonLogCheckRun is a single check reported by the forge. +type jsonLogCheckRun struct { + // Name is the forge-defined display name of the check. + Name string `json:"name"` + + // State is the forge-native state for the run. + State string `json:"state"` + + // URL is the forge's detail page for the run. + URL string `json:"url,omitempty"` } type jsonLogComments struct { diff --git a/testdata/help/log_long.txt b/testdata/help/log_long.txt index 3edb840bd..c03ceccfd 100644 --- a/testdata/help/log_long.txt +++ b/testdata/help/log_long.txt @@ -15,6 +15,8 @@ Flags: Request (🔧 spice.log.crStatus) -c, --[no-]cr-comments Include comment resolution counts for changes (🔧 spice.log.crComments) + --[no-]cr-checks Include forge CI/check rollup and per-run detail for + changes (🔧 spice.log.crChecks) --json Write to stdout as a stream of JSON objects in an unspecified order diff --git a/testdata/help/log_short.txt b/testdata/help/log_short.txt index 1ca9118f9..a8dd2ab3e 100644 --- a/testdata/help/log_short.txt +++ b/testdata/help/log_short.txt @@ -15,6 +15,8 @@ Flags: Request (🔧 spice.log.crStatus) -c, --[no-]cr-comments Include comment resolution counts for changes (🔧 spice.log.crComments) + --[no-]cr-checks Include forge CI/check rollup and per-run detail for + changes (🔧 spice.log.crChecks) --json Write to stdout as a stream of JSON objects in an unspecified order diff --git a/testdata/script/log_checks.txt b/testdata/script/log_checks.txt new file mode 100644 index 000000000..94bcb028e --- /dev/null +++ b/testdata/script/log_checks.txt @@ -0,0 +1,67 @@ +# 'gs log --json' emits forge check status when --cr-checks is set. + +as 'Test ' +at '2026-06-12T17:00:00Z' + +mkdir repo +cd repo +git init +git commit --allow-empty -m 'Initial commit' +gs repo init + +shamhub init +shamhub register alice +shamhub new origin alice/example.git +git push origin main + +env SHAMHUB_USERNAME=alice +gs auth login + +git add feat1.txt +gs bc feat1 -m 'feat1' +git add feat2.txt +gs bc feat2 -m 'feat2' +git add feat3.txt +gs bc feat3 -m 'feat3' + +gs bco feat3 +gs dss --fill + +# feat1: failing build with one passed + one failed run. +shamhub set-checks alice/example 1 '{"rollup":"failed","runs":[{"name":"unit","state":"success","url":"https://ci.example/feat1/unit"},{"name":"lint","state":"failure","url":"https://ci.example/feat1/lint"}],"url":"https://ci.example/feat1"}' + +# feat2: passing rollup with one neutral run preserved in the per-run state. +shamhub set-checks alice/example 2 '{"rollup":"passed","runs":[{"name":"unit","state":"neutral","url":"https://ci.example/feat2/unit"}],"url":"https://ci.example/feat2"}' + +# feat3 has no checks seeded -> rollup is "none". + +# Without --cr-checks: no checks field in any JSON output. +gs ls --json --no-cr-checks +cmpenv stdout $WORK/golden/ls-without-checks.json + +# With --cr-checks: checks appear for changes with seeded data. +gs ls --json --cr-checks +cmpenv stdout $WORK/golden/ls-with-checks.json + +# Config also enables checks. +git config spice.log.crChecks true +gs ls --json +cmpenv stdout $WORK/golden/ls-with-checks.json + +-- repo/feat1.txt -- +feat1 +-- repo/feat2.txt -- +feat2 +-- repo/feat3.txt -- +feat3 + +-- golden/ls-without-checks.json -- +{"name":"feat1","down":{"name":"main"},"ups":[{"name":"feat2"}],"change":{"id":"#1","url":"$SHAMHUB_URL/alice/example/changes/1"},"push":{"ahead":0,"behind":0}} +{"name":"feat2","down":{"name":"feat1"},"ups":[{"name":"feat3"}],"change":{"id":"#2","url":"$SHAMHUB_URL/alice/example/changes/2"},"push":{"ahead":0,"behind":0}} +{"name":"feat3","current":true,"down":{"name":"feat2"},"change":{"id":"#3","url":"$SHAMHUB_URL/alice/example/changes/3"},"push":{"ahead":0,"behind":0}} +{"name":"main","ups":[{"name":"feat1"}]} +-- golden/ls-with-checks.json -- +{"name":"feat1","down":{"name":"main"},"ups":[{"name":"feat2"}],"change":{"id":"#1","url":"$SHAMHUB_URL/alice/example/changes/1","checks":{"rollup":"failed","runs":[{"name":"unit","state":"success","url":"https://ci.example/feat1/unit"},{"name":"lint","state":"failure","url":"https://ci.example/feat1/lint"}],"url":"https://ci.example/feat1"}},"push":{"ahead":0,"behind":0}} +{"name":"feat2","down":{"name":"feat1"},"ups":[{"name":"feat3"}],"change":{"id":"#2","url":"$SHAMHUB_URL/alice/example/changes/2","checks":{"rollup":"passed","runs":[{"name":"unit","state":"neutral","url":"https://ci.example/feat2/unit"}],"url":"https://ci.example/feat2"}},"push":{"ahead":0,"behind":0}} +{"name":"feat3","current":true,"down":{"name":"feat2"},"change":{"id":"#3","url":"$SHAMHUB_URL/alice/example/changes/3","checks":{"rollup":"none"}},"push":{"ahead":0,"behind":0}} +{"name":"main","ups":[{"name":"feat1"}]}