Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,512 changes: 756 additions & 756 deletions .github/aw/actions-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .github/workflows/pr-sous-chef.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 9 additions & 8 deletions .github/workflows/pr-sous-chef.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,14 +288,13 @@ For each PR that is not skipped:
safeoutputs add_comment --pr_number 12345 --body $'<!-- gh-aw-pr-sous-chef-nudge -->\n@copilot please run the `pr-finisher` skill, address unresolved review comments, and rerun checks once the branch is up to date.'
```

3. **Dismiss stale `github-actions[bot]` blocking reviews only when all related feedback is addressed**
- **Actor guard**: `dismiss_pull_request_review` is actor-bound and can only dismiss reviews authored by the current workflow actor. Only perform this dismissal step when the workflow is running as `github-actions[bot]` (scheduled or automatic trigger). If triggered by a human actor (`workflow_dispatch` or slash-command `/souschef`), skip this dismissal step entirely and leave all `github-actions[bot]` reviews untouched — human-triggered runs cannot dismiss bot-authored reviews.
- Inspect PR reviews and collect open `CHANGES_REQUESTED` reviews authored by `github-actions[bot]`.
- For each such review, inspect its related review threads/comments and determine whether all actionable feedback has been addressed (for example, no unresolved related threads remain and no open follow-up from that review is left).
- Build a dismissal list containing only review IDs whose related feedback is fully addressed, and leave unresolved `github-actions[bot]` reviews untouched.
- For each review ID in that dismissal list, call the native safe-output tool:
3. **Dismiss stale `github-actions[bot]` blocking reviews when all PR review threads are resolved**
- **Slash-command guard**: If triggered via the `/souschef` slash command (`pull_request_comment` event), skip this dismissal step entirely — slash-command runs are acknowledgment nudges and must not perform automated review cleanup.
- For `schedule` and `workflow_dispatch` runs, use the `dismiss_reviews` list returned by the `pr-processor` sub-agent. The sub-agent populates this list only when ALL review threads on the PR are resolved; leave reviews untouched if any thread remains unresolved.
- `dismiss_pull_request_review` uses the `GITHUB_TOKEN`, which is always authenticated as `github-actions[bot]` regardless of the workflow trigger. It can therefore dismiss `github-actions[bot]`-authored reviews on any non-slash-command run.
- For each review ID in `dismiss_reviews`, call the native safe-output tool:
```bash
safeoutputs dismiss_pull_request_review --pull_request_number 12345 --review_id 4605056464 --justification "Dismissing stale github-actions review because all related review comments appear to be addressed."
safeoutputs dismiss_pull_request_review --pull_request_number 12345 --review_id 4605056464 --justification "Dismissing stale github-actions review because all PR review threads are resolved."
```
- If dismissing one review fails, record the failure and continue with the remaining review IDs; do not fail the entire run solely because one dismissal attempt failed.

Expand All @@ -310,11 +309,12 @@ At the end, call **exactly one** `noop` with a compact summary including counts
- branch_update_attempts
- formatter_pushes (number of PRs that had formatting fixes committed and pushed)
- merge_main_scheduled (number of PRs with CONFLICTING status that received a merge-main nudge)
- dismissed_reviews (number of `github-actions[bot]` reviews dismissed because all PR review threads were resolved)

Example (`noop` shell call):

```bash
safeoutputs noop --message "processed=4; skipped_checks_running=0; skipped_last_comment_from_sous_chef=1; skipped_cooldown=1; nudged=2; branch_update_attempts=0; formatter_pushes=0; merge_main_scheduled=1"
safeoutputs noop --message "processed=4; skipped_checks_running=0; skipped_last_comment_from_sous_chef=1; skipped_cooldown=1; nudged=2; branch_update_attempts=0; formatter_pushes=0; merge_main_scheduled=1; dismissed_reviews=1"
```

## Formatting Requirements
Expand Down Expand Up @@ -348,6 +348,7 @@ Given one PR number and compact metadata:
- a single combined nudge comment body:
- if `conflicting` is true: a targeted nudge asking `@copilot` to run `make merge-main` to resolve conflicts
- otherwise: a combined nudge covering unresolved review feedback, branch refresh, and any other forward-progress action including a direct instruction to run the `pr-finisher` skill — one comment only, never two; if unresolved PR reviews exist, include an explicit unresolved-reviews list (reviewer + direct link per unresolved review thread)
- `dismiss_reviews`: an array of review IDs — include a review ID only when the review was authored by `github-actions[bot]` with `CHANGES_REQUESTED` state AND all review threads on the PR are resolved (no unresolved threads remain); return an empty array if there are unresolved threads or no qualifying reviews
4. Make at most 8 tool calls total. If 8 calls are insufficient to reach a confident decision, set all fields to `null` and set `skip_reason: "insufficient_context"`.
5. Keep output compact JSON only — a single object, no prose.
6. If you cannot determine a field, set it to `null`.
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -953,8 +953,8 @@ clean-docs:
sync-action-pins:
@echo "Syncing actions-lock.json from .github/aw to pkg/actionpins/data/action_pins.json and pkg/workflow/data/action_pins.json..."
@if [ -f .github/aw/actions-lock.json ]; then \
cp .github/aw/actions-lock.json pkg/actionpins/data/action_pins.json; \
cp .github/aw/actions-lock.json pkg/workflow/data/action_pins.json; \
jq --indent 2 . .github/aw/actions-lock.json > pkg/actionpins/data/action_pins.json; \
jq --indent 2 . .github/aw/actions-lock.json > pkg/workflow/data/action_pins.json; \
echo "✓ Action pins synced successfully"; \
else \
echo "⚠ Warning: .github/aw/actions-lock.json does not exist yet"; \
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# ADR-43414: Options Struct for Continuation Builder and Defer-in-Loop Fix in pkg/cli

**Date**: 2026-07-05
**Status**: Draft
**Deciders**: Unknown (automated lint compliance via Copilot SWE agent)

---

### Context

Two mechanical lint violations existed in `pkg/cli`:

1. `buildContinuationIfNeeded` in `logs_orchestrator.go` accepted 11 positional parameters (`workflowName`, `startDate`, `endDate`, `engine`, `branch`, `afterRunID`, `count`, `timeoutMinutes`, plus three booleans). This violated the project's parameter-count lint rule and made call sites error-prone because two same-type arguments (e.g., adjacent `string` parameters) can be silently transposed with no compiler error.

2. `waitForServerReady` in `mcp_inspect_mcp_scripts_server.go` registered a `defer resp.Body.Close()` inside a polling loop. Per the `deferinloop` linter rule (see [ADR-40679](40679-add-deferinloop-linter.md)), deferred calls inside a loop do not execute until the enclosing function returns, not at the end of each iteration — potentially accumulating open response bodies.

The bulk of the line count in this PR (1512 of 1599 additions) is a JSON reformatting of `pkg/actionpins/data/action_pins.json` and `pkg/workflow/data/action_pins.json` from 2-space to 4-space indentation; this is cosmetic with no functional impact and does not represent a design decision.

### Decision

We will introduce a `continuationOptions` struct to group the eight configuration parameters of `buildContinuationIfNeeded`, reducing the positional argument count to three meaningful booleans plus one options value. We will extract an `isServerReady` helper so that `resp.Body.Close()` is deferred within the helper's scope rather than the polling loop body. Both changes preserve existing behavior while bringing the code into compliance with project lint rules established in [ADR-40679](40679-add-deferinloop-linter.md) and the parameter-count linter.

### Alternatives Considered

#### Alternative 1: Suppress lint violations with `//nolint` directives

Add inline `//nolint:paramcount` and `//nolint:deferinloop` comments to suppress the findings in-place without changing the code. This is the lowest-effort option and leaves the function signatures untouched. It was not chosen because suppression defeats the purpose of these lint rules — they enforce maintainability invariants that the project explicitly adopted via ADRs — and would set a precedent for bypassing them in newly written code.

#### Alternative 2: Close the response body explicitly (without defer) inside the loop

Replace `defer resp.Body.Close()` with an immediate `resp.Body.Close()` at the point where the success branch returns, keeping the defer-in-loop fix without extracting a helper function. For the parameter-count issue, defer changing the signature by splitting `buildContinuationIfNeeded` into two smaller focused functions instead of grouping parameters into a struct. These options were not chosen because the helper extraction makes the readiness check self-contained and reusable, while the struct approach is already established in this codebase (see [ADR-31336](31336-refactor-download-workflow-logs-to-options-struct.md)) and is the idiomatic Go solution for grouping related parameters.

### Consequences

#### Positive
- Call sites for `buildContinuationIfNeeded` are now self-documenting: named struct fields make the mapping from variable to parameter explicit, eliminating silent transposition bugs.
- Response bodies in `waitForServerReady` are closed immediately at the end of each successful polling iteration via the helper's deferred close, removing the risk of resource accumulation across iterations.
- The code passes the project's `deferinloop` and parameter-count lint rules without suppressions, keeping lint signal clean for future contributors.

#### Negative
- Callers of `buildContinuationIfNeeded` must construct a `continuationOptions` struct literal instead of passing positional arguments; existing call sites required mechanical updates.
- The `continuationOptions` type is unexported (`continuationOptions` vs `ContinuationOptions`), limiting it to `pkg/cli` — if the function is later promoted or tested from outside the package, the type must be exported at that point.

#### Neutral
- Test files (`logs_orchestrator_unit_test.go`) required mechanical refactoring to use struct literal syntax; no test behavior changed.
- JSON indentation in `pkg/actionpins/data/action_pins.json` and `pkg/workflow/data/action_pins.json` changed from 2-space to 4-space; no functional impact.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
42 changes: 30 additions & 12 deletions pkg/cli/logs_orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,11 +148,21 @@ func selectPaginationCursorDate(filteredRuns []WorkflowRun, oldestFetchedCreated
// - countLimitReached: in fetchAllInRange mode the count cap was hit before the
// date window was exhausted; the next page starts just before the oldest run
// returned in this batch.
type continuationOptions struct {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The continuationOptions struct is inserted between the buildContinuationIfNeeded godoc comment block (lines 142–150) and the function declaration. In Go, a doc comment is attached to the immediately following declaration — so godoc will now associate the multi-line comment with continuationOptions, leaving buildContinuationIfNeeded undocumented.

Fix: move the struct before the // buildContinuationIfNeeded returns... comment, and optionally add its own doc comment:

// continuationOptions holds the pagination parameters forwarded to the
// next fetch batch when a timeout or count limit interrupts the current one.
type continuationOptions struct { ... }

// buildContinuationIfNeeded returns a ContinuationData cursor...
func buildContinuationIfNeeded(...) *ContinuationData {

@copilot please address this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] continuationOptions is placed inside the doc-comment block for buildContinuationIfNeeded, visually breaking the comment-to-function association. Moving it above line 142 (before the doc-comment) with its own short comment keeps Go conventions and makes navigation easier.

💡 Suggested placement
// continuationOptions collects the pagination parameters forwarded to a
// ContinuationData cursor when a batch is incomplete.
type continuationOptions struct { ... }

// buildContinuationIfNeeded returns a ContinuationData cursor ...
func buildContinuationIfNeeded(...) *ContinuationData {

Go convention: a struct's doc-comment precedes the struct; the function doc-comment should stay immediately above the function.

@copilot please address this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

continuationOptions type declaration breaks the buildContinuationIfNeeded doc comment — Go attaches a doc comment to the immediately following declaration, so the multi-line comment above now documents continuationOptions, not buildContinuationIfNeeded. The function becomes undocumented.

💡 Suggested fix

Move continuationOptions before the doc comment block, or add a blank line between the doc comment and the type, then place the type immediately before the function:

type continuationOptions struct {
    workflowName   string
    // ...
}

// buildContinuationIfNeeded returns a ContinuationData cursor when more runs may
// be available after this batch, or nil if the full result set was collected.
//
// A continuation is emitted in two cases:
//   - timeoutReached: ...
//   - countLimitReached: ...
func buildContinuationIfNeeded(

Or alternatively, add a short standalone doc comment for continuationOptions so both declarations are documented correctly.

workflowName string
startDate string
endDate string
engine string
branch string
afterRunID int64
count int
timeoutMinutes int
}

func buildContinuationIfNeeded(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] continuationOptions mirrors 8 of ContinuationData's 9 fields — it's essentially a read-only projection of that struct. Consider whether callers should just build a partial ContinuationData directly (omitting BeforeRunID and Message which are computed), or document why a separate struct is intentionally preferred over a lighter approach.

💡 Analysis

ContinuationData fields: Message, WorkflowName, Count, StartDate, EndDate, Engine, Branch, AfterRunID, BeforeRunID, Timeout.

continuationOptions fields: workflowName, startDate, endDate, engine, branch, afterRunID, count, timeoutMinutes.

The only values not in continuationOptions are Message and BeforeRunID — both computed inside the function. If this function is only called from one place, the extra indirection adds minimal value; if it's called from multiple places with varied subsets, the struct is well-justified. A brief doc-comment on the struct explaining the rationale would help future readers.

@copilot please address this.

processedRuns []ProcessedRun,
timeoutReached, countLimitReached bool,
workflowName, startDate, endDate, engine, branch string,
afterRunID int64, count, timeoutMinutes int,
opts continuationOptions,
) *ContinuationData {
if len(processedRuns) == 0 || (!timeoutReached && !countLimitReached) {
return nil
Expand All @@ -166,15 +176,15 @@ func buildContinuationIfNeeded(
}
return &ContinuationData{
Message: message,
WorkflowName: workflowName,
Count: count,
StartDate: startDate,
EndDate: endDate,
Engine: engine,
Branch: branch,
AfterRunID: afterRunID,
WorkflowName: opts.workflowName,
Count: opts.count,
StartDate: opts.startDate,
EndDate: opts.endDate,
Engine: opts.engine,
Branch: opts.branch,
AfterRunID: opts.afterRunID,
BeforeRunID: oldestRunID,
Timeout: timeoutMinutes,
Timeout: opts.timeoutMinutes,
}
}

Expand Down Expand Up @@ -735,8 +745,16 @@ outerLoop:

// Build continuation data if timeout was reached and there are processed runs,
// OR if a date-range fetch hit the count limit (more runs may exist in the window).
continuation := buildContinuationIfNeeded(processedRuns, timeoutReached, countLimitReached,
workflowName, startDate, endDate, engine, ref, afterRunID, count, timeoutMinutes)
continuation := buildContinuationIfNeeded(processedRuns, timeoutReached, countLimitReached, continuationOptions{
workflowName: workflowName,
startDate: startDate,
endDate: endDate,
engine: engine,
branch: ref,
afterRunID: afterRunID,
count: count,
timeoutMinutes: timeoutMinutes,
})

return renderLogsOutput(processedRuns, renderLogsOutputOptions{
outputDir: outputDir,
Expand Down
54 changes: 40 additions & 14 deletions pkg/cli/logs_orchestrator_unit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,16 @@ func TestBuildContinuationIfNeeded(t *testing.T) {
}

t.Run("count limit reached emits cursor with correct message and BeforeRunID", func(t *testing.T) {
c := buildContinuationIfNeeded(runs, false, true,
"my-workflow", "2026-06-01", "2026-06-30", "claude", "main",
0, 100, 3,
)
c := buildContinuationIfNeeded(runs, false, true, continuationOptions{
workflowName: "my-workflow",
startDate: "2026-06-01",
endDate: "2026-06-30",
engine: "claude",
branch: "main",
afterRunID: 0,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] afterRunID: 0 is explicitly set in every test case but is also the zero-value for int64, making it invisible noise. This is fine if a future test with a non-zero afterRunID is anticipated, but there is currently no assertion that c.AfterRunID equals the value passed in — meaning the field is untested. Consider adding an assertion for AfterRunID propagation (e.g., pass afterRunID: 42 and assert c.AfterRunID == 42) to lock in that contract.

💡 Suggested addition
t.Run("afterRunID is propagated to cursor", func(t *testing.T) {
    c := buildContinuationIfNeeded(runs, false, true, continuationOptions{
        workflowName: "my-workflow",
        startDate:    "2026-06-01",
        afterRunID:   42,
        count:        100,
    })
    require.NotNil(t, c)
    assert.Equal(t, int64(42), c.AfterRunID)
})

@copilot please address this.

count: 100,
timeoutMinutes: 3,
})
require.NotNil(t, c, "expected continuation when countLimitReached=true")
assert.Equal(t, int64(2999), c.BeforeRunID, "BeforeRunID should be oldest processed run")
assert.Equal(t, "2026-06-01", c.StartDate)
Expand All @@ -157,26 +163,46 @@ func TestBuildContinuationIfNeeded(t *testing.T) {
})

t.Run("timeout reached emits cursor with timeout message", func(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The timeout test (line 165) doesn't assert c.Timeout or c.Engine — both are set in continuationOptions but not verified. With the new struct API it's easy to forget to assert a field; adding assertions for all propagated fields turns this test into a full contract spec.

💡 Suggested additions
assert.Equal(t, 10, c.Timeout, "timeoutMinutes should be forwarded")
assert.Equal(t, "claude", c.Engine, "engine should be forwarded")
assert.Equal(t, 50, c.Count)

@copilot please address this.

c := buildContinuationIfNeeded(runs, true, false,
"my-workflow", "2026-06-01", "", "claude", "",
0, 50, 10,
)
c := buildContinuationIfNeeded(runs, true, false, continuationOptions{
workflowName: "my-workflow",
startDate: "2026-06-01",
endDate: "",
engine: "claude",
branch: "",
afterRunID: 0,
count: 50,
timeoutMinutes: 10,
})
require.NotNil(t, c, "expected continuation when timeoutReached=true")
assert.Equal(t, int64(2999), c.BeforeRunID)
assert.Contains(t, c.Message, "Timeout reached")
})

t.Run("neither flag set returns nil", func(t *testing.T) {
c := buildContinuationIfNeeded(runs, false, false,
"my-workflow", "2026-06-01", "", "claude", "", 0, 100, 3,
)
c := buildContinuationIfNeeded(runs, false, false, continuationOptions{
workflowName: "my-workflow",
startDate: "2026-06-01",
endDate: "",
engine: "claude",
branch: "",
afterRunID: 0,
count: 100,
timeoutMinutes: 3,
})
assert.Nil(t, c, "expected nil when neither timeout nor count limit was reached")
})

t.Run("empty processedRuns returns nil even when count limit reached", func(t *testing.T) {
c := buildContinuationIfNeeded(nil, false, true,
"my-workflow", "2026-06-01", "", "claude", "", 0, 100, 3,
)
c := buildContinuationIfNeeded(nil, false, true, continuationOptions{
workflowName: "my-workflow",
startDate: "2026-06-01",
endDate: "",
engine: "claude",
branch: "",
afterRunID: 0,
count: 100,
timeoutMinutes: 3,
})
assert.Nil(t, c, "expected nil when no runs were processed")
})
}
Loading
Loading