pr-shepherd 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +123 -21
  3. package/bin/checks/classify.mjs +2 -1
  4. package/bin/checks/triage.mjs +63 -61
  5. package/bin/cli/args.mjs +9 -3
  6. package/bin/cli/fence.mjs +4 -0
  7. package/bin/cli/fix-formatter.mjs +45 -19
  8. package/bin/cli/formatters.mjs +61 -44
  9. package/bin/cli/handlers.mjs +5 -4
  10. package/bin/cli/iterate-formatter.mjs +51 -45
  11. package/bin/cli/iterate-lean.mjs +95 -0
  12. package/bin/cli/list-formatters.mjs +32 -0
  13. package/bin/cli/suggestion-renderer.mjs +31 -0
  14. package/bin/cli-parser.iterate-fixtures.mjs +6 -5
  15. package/bin/cli-parser.mjs +27 -3
  16. package/bin/commands/check-status.mjs +7 -4
  17. package/bin/commands/check.mjs +39 -3
  18. package/bin/commands/commit-suggestion.mjs +12 -10
  19. package/bin/commands/iterate/classify.mjs +10 -54
  20. package/bin/commands/iterate/escalate.mjs +5 -14
  21. package/bin/commands/iterate/fix-code.mjs +15 -16
  22. package/bin/commands/iterate/helpers.mjs +8 -5
  23. package/bin/commands/iterate/index.mjs +18 -12
  24. package/bin/commands/iterate/render.mjs +28 -12
  25. package/bin/commands/iterate/stall.mjs +1 -1
  26. package/bin/commands/log-file.mjs +7 -0
  27. package/bin/commands/monitor.mjs +7 -4
  28. package/bin/commands/ready-delay.mjs +2 -2
  29. package/bin/commands/resolve-instructions.mjs +14 -5
  30. package/bin/commands/resolve.mjs +38 -37
  31. package/bin/commands/status.mjs +41 -29
  32. package/bin/comments/resolve.mjs +75 -65
  33. package/bin/config.json +4 -7
  34. package/bin/github/batch-parsers.mjs +4 -0
  35. package/bin/github/client.mjs +11 -32
  36. package/bin/github/gql/batch-pr.gql +6 -0
  37. package/bin/github/gql/get-pr-head-sha.gql +7 -0
  38. package/bin/github/http.mjs +166 -11
  39. package/bin/github/queries.mjs +7 -11
  40. package/bin/log/log-file.mjs +88 -0
  41. package/bin/log/session.mjs +100 -0
  42. package/bin/log/setup.mjs +54 -0
  43. package/bin/reporters/agent.mjs +21 -13
  44. package/bin/reporters/check-instructions.mjs +9 -14
  45. package/bin/reporters/text.mjs +93 -105
  46. package/bin/state/base.mjs +5 -0
  47. package/bin/state/fix-attempts.mjs +2 -2
  48. package/bin/state/iterate-stall.mjs +2 -2
  49. package/bin/state/seen-comments.mjs +76 -0
  50. package/bin/suggestions/extract.mjs +15 -0
  51. package/bin/suggestions/parse.mjs +1 -26
  52. package/bin/types/report.mjs +0 -1
  53. package/bin/util/markdown.mjs +7 -0
  54. package/bin/util/worktree.mjs +23 -0
  55. package/package.json +2 -2
  56. package/bin/commands/iterate/steps.mjs +0 -31
  57. package/bin/github/gql/dismiss-review.gql +0 -7
  58. package/bin/github/gql/minimize-comment.gql +0 -7
  59. package/bin/github/gql/multi-pr-status.gql +0 -32
  60. package/bin/github/gql/resolve-thread.gql +0 -7
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for Claude Code",
4
- "version": "0.8.1",
4
+ "version": "0.10.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -10,28 +10,130 @@ Example Workflow:
10
10
  3. Accept the plan
11
11
  4. Switch to Auto Mode
12
12
  5. Prompt: `make a PR, then run /pr-shepherd:monitor`
13
- 6. ...
14
- 7. Human reviews PR with passing CI, no open threads, and all comments minimized
13
+ 6. Agent makes a draft PR
14
+ 7. PR has passing CI -> draft is marked Ready for Review
15
+ 8. Review bots begin providing reviews
16
+ 9. Agent automatically classifies and fixes review comments based on the Plan
17
+ 10. Human reviews PR with a well-documented PR title and description, passing CI, and no open threads/comments/reviews
15
18
 
16
- ## Why pr-shepherd
19
+ ## How it works
17
20
 
18
- Concrete improvements to an agentic PR-review workflow:
21
+ `pr-shepherd` optimizes token management, rate limits, and agentic orchestration by moving **ALL** deterministic logic and prompts to code via a CLI tool, enshrining what would be a large skill or command prompt (of which the agent would inevitably make mistakes) into the code and returning a clear, actionable prompt.
19
22
 
20
- - **Faster monitor loops** one batched GraphQL query per tick (see [docs/graphql.md](docs/graphql.md)) instead of N REST round-trips
21
- - **Lower context usage per iteration** — classification lives in the CLI; the agent receives one decision per tick and never sees raw GraphQL payloads or resolved threads
22
- - **Prompt-cache friendly** — the 4-minute default tick is tuned to Claude's 5-minute prompt-cache TTL (tunable via `watch.interval`)
23
- - **Reduced GitHub rate-limit exposure** — one batched GraphQL read per tick; loop-state files (fix-attempts, stall detection, ready-delay timer) are kept in `$TMPDIR/pr-shepherd-state/`
24
- - **No MCP surface** — skills call the CLI via `npx`; no long-lived MCP server, no extra auth boundary, smaller reasoning surface
25
- - **Skills over subagents** — skill prompts inject into the main conversation rather than spawning a subagent that reloads CLAUDE.md every turn
26
- - **Safe to interrupt** — all state lives in the PR on GitHub; the cron loop self-terminates when the PR is merged, closed, or settles after ready-delay
23
+ At a high level, to start the monitor, the skill/command invokes a CLI that returns a prompt to be ingested by the agent _(schematic paraphrased for brevity; actual output is more detailed)_:
27
24
 
28
- ## Design principles
25
+ ```
26
+ /pr-shepherd:monitor
27
+
28
+ > npx pr-shepherd monitor 123
29
+
30
+ # PR #123 [MONITOR]
31
+
32
+ Loop tag: `# pr-shepherd-loop:pr=123`
33
+ Loop args: `4m --max-turns 50 --expires 8h`
34
+
35
+ ## Loop prompt
36
+
37
+ # pr-shepherd-loop:pr=123
38
+
39
+ **IMPORTANT — recurrence rules:** Do not call ScheduleWakeup or /loop. End the turn
40
+ after completing the actions below. The cron job handles the next fire.
41
+
42
+ Run in a single Bash call:
43
+ npx pr-shepherd iterate 123
44
+
45
+ …(self-dedup guidance, error-handling instructions)…
46
+
47
+ ## Instructions
48
+
49
+ 1. Run `CronList`. If any job's prompt contains the loop tag, run the ## Loop prompt inline then stop.
50
+ 2. Otherwise, invoke the /loop skill with Loop args and the full ## Loop prompt body.
51
+ ```
52
+
53
+ Each iteration calls `npx pr-shepherd iterate <PR>`, which provides actionable feedback directly to the agent:
54
+
55
+ ```
56
+ > npx pr-shepherd iterate 123
57
+
58
+ # PR #123 [FIX_CODE]
59
+
60
+ **status** `UNRESOLVED_COMMENTS` · **merge** `BLOCKED` · **state** `OPEN` · **repo** `owner/repo`
61
+ **summary** 3 passing
62
+
63
+ ## Review threads
64
+
65
+ ### `PRRT_kwDOSGizTs58XB1L` — `src/commands/iterate.mts:42` (@alice)
66
+
67
+ > The variable name is misleading.
68
+ >
69
+ > Consider renaming `x` to `remainingSeconds` so readers don't have to
70
+ > trace back to the declaration to understand its meaning.
71
+
72
+ ## Failing checks
29
73
 
30
- - **Reduced agent context** logic lives in the CLI, not the prompt
31
- - **Reduced GitHub rate-limit exhaustion** — primary PR state is fetched via a batched GraphQL query
32
- - **Fewer tool calls** — comment resolutions are batched; resolved threads never reach the agent
33
- - **Skills over subagents** — subagents reload all CLAUDE.md context on every turn; skills inject into the main conversation instead, keeping cost low
34
- - **JSON/text parity** — `--format=json` and `--format=text` carry equivalent information; every field in one has a representation in the other
74
+ - `24697658766``CI lint / typecheck / test (22.x)`
75
+ > npx oxfmt
76
+
77
+ ## Post-fix push
78
+
79
+ - base: `main`
80
+ - resolve: `npx pr-shepherd resolve 123 --resolve-thread-ids PRRT_kwDOSGizTs58XB1L --minimize-comment-ids IC_kwDOSGizTs7_ajT8,IC_kwDOSGizTs7_ajT9 --dismiss-review-ids PRR_kwDOSGizTs58XB1R --message "$DISMISS_MESSAGE" --require-sha "$HEAD_SHA"`
81
+
82
+ ## Instructions
83
+
84
+ _(schematic — actual steps depend on PR state)_
85
+
86
+ 1. Apply code fixes for each file referenced under `## Review threads`.
87
+ 2. For each failing check: examine the log tail to decide — rerun if transient, fix code if real.
88
+ 3. Commit changed files.
89
+ 4. Rebase and push: `git fetch origin && git rebase origin/main && git push --force-with-lease` — capture `HEAD_SHA=$(git rev-parse HEAD)`.
90
+ 5. Run the `resolve:` command above, substituting `"$HEAD_SHA"`.
91
+ 6. Add a `## Shepherd Journal` entry to the PR description for any large decisions made.
92
+ 7. Stop this iteration.
93
+ ```
94
+
95
+ On every iteration, a command is returned to instruct the agent exactly what to do. No guessing, no thinking, as few agentic turns as possible:
96
+
97
+ ```
98
+ npx pr-shepherd resolve 123 --resolve-thread-ids PRRT_kwDOSGizTs58XB1L --minimize-comment-ids IC_kwDOSGizTs7_ajT8,IC_kwDOSGizTs7_ajT9 --dismiss-review-ids PRR_kwDOSGizTs58XB1R --message "$DISMISS_MESSAGE" --require-sha "$HEAD_SHA"
99
+ ```
100
+
101
+ ## Workflow
102
+
103
+ This system makes opinionated decisions, which may or may not work for your team's workflow.
104
+
105
+ - The following PR branch protection rules are expected:
106
+ - There are required status checks
107
+ - All inline comments are resolved
108
+ - **ALL** comments/threads/reviews will be hidden by default except for PR approvals. The only option here is to hide PR approvals as well.
109
+ - The primary reason is to optimize tokens by avoiding re-fetching comments and re-adding them to the agent's context.
110
+ - This also ties hand-in-hand with requiring all inline comments to be resolved.
111
+ - We also want to avoid storing state as comments can be unresolved/minimized/hidden.
112
+ - `pr-shepherd` keeps the PR title and description up to date, including a journal of decisions with links to comments/threads/reviews (that would be hidden at this point).
113
+ - This may break your workflow if your PR titles and descriptions are restricted to a specific format.
114
+ - `pr-shepherd` does **NOT** reply to inline comments when resolving them. Doing so would require agentic loops and more tokens. Instead, it updates the PR title & description once per loop with only the relevant information.
115
+ - Branches are currently kept up-to-date with `git push --force-with-lease`. Please make a PR for making `merge <default branch>` an option.
116
+ - Branches are currently only rebased when 1) pushing a commit on a branch that is out of date or 2) there are merge conflicts. It does not continuously rebase the branch (use a merge queue for that).
117
+ - To optimize AI code reviewer tokens, create your pull requests initially as drafts and instruct your AI code reviewers to only code review PRs that are ready for review. `pr-shepherd` will automatically mark PRs as ready for review when all CI passes (can be disabled). If you have no intention of marking your PR as ready for review, then don't run `pr-shepherd`.
118
+
119
+ Some other workflow improvements:
120
+
121
+ - `pr-shepherd` knows whether a GitHub Copilot code review is in progress
122
+ - `pr-shepherd` waits 10 minutes (configurable) until after all comments are hidden and CI passes before exiting. The primary reason is to wait for any lingering automated code reviews that do not provide status updates via the GitHub GraphQL API.
123
+ - The agent is instructed to cancel failed CI runs and, when a failure looks transient (e.g. network timeout, runner setup crash), re-run them via `gh run rerun <id> --failed`. The primary reason is to minimize CI costs.
124
+ - `pr-shepherd` supports "commit suggestions" by converting them into a diff, applying them, and then committing them with attribution. This avoids a file read & write. One commit is always made per suggestion to avoid any merge conflicts — in these cases, the agent will resolve the comment manually.
125
+
126
+ Recommendations:
127
+
128
+ - Run `pr-shepherd` on all your PRs before you go to sleep so that you wake up to reviewable PRs. As it uses `/loop`, it will continue working when your rate limit window is reset. There is a default timeout of 8 hours and 50 loops before exiting automatically.
129
+
130
+ ## Design Principles
131
+
132
+ - **Reduced agent context and turns** — logic lives in the CLI, not the prompt. The relevant context is provided automatically to the agent, reducing tool calls.
133
+ - **Reduced GitHub rate-limit exposure** — GraphQL requests are batched when possible
134
+ - **Minimal state** — `pr-shepherd` stores minimal state in `$PR_SHEPHERD_STATE_DIR` (default `$TMPDIR/pr-shepherd-state/`), not in the repository
135
+ - **Classifications and decisions still happen at the agent level** — `pr-shepherd`'s goal is to provide sufficient context to make informed decisions and provide clear actionable steps without writing unreliable code-level heuristics
136
+ - **Configurable** — `pr-shepherd` is configurable via `.pr-shepherdrc.yml`, which is only possible with a light prompt that simply invokes the CLI which returns the prompt.
35
137
 
36
138
  ## Usage
37
139
 
@@ -72,9 +174,9 @@ GitHub has seen the push before resolving).
72
174
 
73
175
  See [docs/skills.md](docs/skills.md) for full argument reference.
74
176
 
75
- ## Workflow
177
+ ## Iterate decision loop
76
178
 
77
- On each tick (4-minute default, tunable via `watch.interval`): fetch PR state in one GraphQL batch → classify CI, comments, and merge status → take one action (fix code, rebase, rerun CI, mark ready, or wait). See [docs/iterate-flow.md](docs/iterate-flow.md) for the decision table and [docs/flow.md](docs/flow.md) for the end-to-end flow diagram.
179
+ On each tick (4-minute default, tunable via `watch.interval`): fetch PR state in one GraphQL batch → classify CI, comments, and merge status → take one action (`fix_code`, `mark_ready`, `cancel`, `escalate`, `wait`, or `cooldown`). See [docs/iterate-flow.md](docs/iterate-flow.md) for the decision table and [docs/flow.md](docs/flow.md) for the end-to-end flow diagram.
78
180
 
79
181
  ## Install
80
182
 
@@ -126,10 +228,10 @@ checks:
126
228
  - pull_request_target
127
229
  - merge_group # add for merge-queue repos
128
230
  actions:
129
- autoRebase: false # disable for repos that enforce merge commits
231
+ autoMarkReady: false # disable to stay draft until you manually promote
130
232
  ```
131
233
 
132
- Environment variables: `GH_TOKEN` / `GITHUB_TOKEN` (auth; falls back to `gh auth token`), `PR_SHEPHERD_STATE_DIR` (override loop-state base dir).
234
+ Environment variables: `GH_TOKEN` / `GITHUB_TOKEN` (auth; falls back to `gh auth token`), `PR_SHEPHERD_STATE_DIR` (override loop-state and log base dir), `PR_SHEPHERD_LOG_DISABLED=1` (disable the per-worktree debug log).
133
235
 
134
236
  See [docs/configuration.md](docs/configuration.md) for full semantics and deprecated-key migration.
135
237
 
@@ -49,6 +49,7 @@ export function getCiVerdict(classified) {
49
49
  // When there are no relevant checks (e.g. docs-only PR where all checks are filtered/skipped),
50
50
  // treat as allPassed rather than blocking — there's nothing to fail.
51
51
  const allPassed = !anyInProgress && !anyFailing;
52
+ const hasChecks = relevant.length > 0;
52
53
  const filteredNames = classified.filter((c) => c.category === "filtered").map((c) => c.name);
53
- return { allPassed, anyInProgress, anyFailing, filteredNames };
54
+ return { allPassed, hasChecks, anyInProgress, anyFailing, filteredNames };
54
55
  }
@@ -1,64 +1,25 @@
1
- /**
2
- * Triage failing check runs into three categories based solely on GitHub's
3
- * own conclusion field — no log-content classification:
4
- * - timeout: conclusion is TIMED_OUT.
5
- * - cancelled: conclusion is CANCELLED, STARTUP_FAILURE, or STALE.
6
- * - actionable: everything else (FAILURE, ACTION_REQUIRED, …).
7
- *
8
- * Exception: checks with runId === null are always classified as "actionable"
9
- * regardless of conclusion, so they surface in fix_code where the monitor
10
- * escalates to the user (no run to rerun/inspect).
11
- *
12
- * For all checks with a non-null runId, the jobs API is called once per runId
13
- * (results are cached across checks that share a run) to fetch workflow name
14
- * and, for actionable checks, the first failed step name. No log fetching is done.
15
- *
16
- * Note on infrastructure-killed FAILURE runs: GitHub reports these as
17
- * conclusion === "FAILURE", so they classify as "actionable". The jobs API
18
- * surfaces their failedStep (e.g. "Set up job") which gives the agent a
19
- * GitHub-native signal to distinguish runner setup deaths from real test
20
- * failures — without any log-pattern analysis at the CLI level.
21
- */
22
- import { rest } from "../github/http.mjs";
23
- // ---------------------------------------------------------------------------
24
- // Public API
25
- // ---------------------------------------------------------------------------
26
- /**
27
- * Triage each failing check: classify by GitHub conclusion and call the jobs
28
- * API for each check with a non-null runId to fetch workflow name and (for
29
- * actionable failures) the name of the first failed step.
30
- *
31
- * Jobs responses are cached by runId so checks that share a run (e.g. matrix
32
- * builds or multiple required steps in one workflow) make only one API call.
33
- */
34
- export function triageFailingChecks(failingChecks, repo) {
1
+ import { rest, restText } from "../github/http.mjs";
2
+ export function triageFailingChecks(failingChecks, repo, logTailLines, logTailChars = 200) {
35
3
  const jobsCache = new Map();
36
- return Promise.all(failingChecks.map((c) => triageCheck(c, repo, jobsCache)));
4
+ return Promise.all(failingChecks.map((c) => triageCheck(c, repo, jobsCache, logTailLines, logTailChars)));
37
5
  }
38
- // ---------------------------------------------------------------------------
39
- // Internal
40
- // ---------------------------------------------------------------------------
41
- async function triageCheck(check, repo, jobsCache) {
42
- const failureKind = check.runId === null ? "actionable" : classifyConclusion(check.conclusion);
6
+ async function triageCheck(check, repo, jobsCache, logTailLines, logTailChars) {
43
7
  if (check.runId === null) {
44
- return { ...check, failureKind };
8
+ return { ...check };
45
9
  }
46
10
  const jobs = await fetchJobs(check.runId, repo, jobsCache);
47
- const jobInfo = jobs ? pickJobInfo(jobs, check.name, failureKind) : undefined;
11
+ const jobInfo = jobs ? pickJobInfo(jobs, check.name) : undefined;
12
+ const logTail = jobInfo?.jobId !== undefined && logTailLines > 0
13
+ ? await fetchLogTail(jobInfo.jobId, repo, logTailLines, logTailChars, jobInfo.failedStep)
14
+ : undefined;
48
15
  return {
49
16
  ...check,
50
- failureKind,
51
- workflowName: jobInfo?.workflowName,
52
- ...(failureKind === "actionable" && { failedStep: jobInfo?.failedStep }),
17
+ ...(jobInfo?.workflowName !== undefined && { workflowName: jobInfo.workflowName }),
18
+ ...(jobInfo?.jobName !== undefined && { jobName: jobInfo.jobName }),
19
+ ...(jobInfo?.failedStep !== undefined && { failedStep: jobInfo.failedStep }),
20
+ ...(logTail !== undefined && { logTail }),
53
21
  };
54
22
  }
55
- function classifyConclusion(c) {
56
- if (c === "TIMED_OUT")
57
- return "timeout";
58
- if (c === "CANCELLED" || c === "STARTUP_FAILURE" || c === "STALE")
59
- return "cancelled";
60
- return "actionable";
61
- }
62
23
  function fetchJobs(runId, repo, cache) {
63
24
  const cached = cache.get(runId);
64
25
  if (cached)
@@ -90,17 +51,58 @@ async function fetchJobsUncached(runId, repo) {
90
51
  }
91
52
  return allJobs;
92
53
  }
93
- function pickJobInfo(jobs, checkName, failureKind) {
94
- // Match by name. For matrix jobs sharing a check name, prefer a failing one.
95
- // Fall back to prefix matching for matrix jobs whose workflow-API name includes
96
- // a suffix like "(ubuntu)" while checkName is just the base name.
54
+ function pickJobInfo(jobs, checkName) {
97
55
  const exactMatches = jobs.filter((j) => j.name === checkName);
98
56
  const matchedJobs = exactMatches.length > 0 ? exactMatches : jobs.filter((j) => j.name.startsWith(checkName));
99
- const job = matchedJobs.find((j) => j.conclusion === "failure") ?? matchedJobs[0];
57
+ const job = matchedJobs.find((j) => j.conclusion === "failure") ??
58
+ matchedJobs.find((j) => j.conclusion !== null && j.conclusion !== "success") ??
59
+ matchedJobs[0];
100
60
  if (!job)
101
61
  return undefined;
102
- const failedStep = failureKind === "actionable"
103
- ? job.steps?.find((s) => s.conclusion === "failure")?.name
104
- : undefined;
105
- return { workflowName: job.workflow_name, failedStep };
62
+ const failedStep = job.steps?.find((s) => s.conclusion !== null &&
63
+ s.conclusion !== "success" &&
64
+ s.conclusion !== "skipped" &&
65
+ s.conclusion !== "neutral")?.name;
66
+ return {
67
+ workflowName: job.workflow_name,
68
+ jobName: job.name,
69
+ failedStep,
70
+ jobId: job.id,
71
+ };
72
+ }
73
+ async function fetchLogTail(jobId, repo, logTailLines, logTailChars, failedStepName) {
74
+ const { owner, name } = repo;
75
+ try {
76
+ const text = await restText(`/repos/${owner}/${name}/actions/jobs/${jobId}/logs`);
77
+ const allLines = text.split("\n");
78
+ const stepLines = failedStepName ? extractStepLines(allLines, failedStepName) : null;
79
+ const lines = stepLines ?? allLines;
80
+ const tail = lines.length <= logTailLines ? lines.join("\n") : lines.slice(-logTailLines).join("\n");
81
+ return tail.length <= logTailChars ? tail : tail.slice(-logTailChars);
82
+ }
83
+ catch {
84
+ return undefined;
85
+ }
86
+ }
87
+ // Extract the lines inside the ##[group]..##[endgroup] section for the named step.
88
+ // Returns null when no matching group is found so the caller falls back to the full log.
89
+ function extractStepLines(lines, stepName) {
90
+ const lowerStep = stepName.toLowerCase();
91
+ let inStep = false;
92
+ const result = [];
93
+ for (const line of lines) {
94
+ const content = line.replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+/, "");
95
+ if (!inStep) {
96
+ if (content.startsWith("##[group]") && content.slice(9).toLowerCase().includes(lowerStep)) {
97
+ inStep = true;
98
+ }
99
+ }
100
+ else if (content.startsWith("##[endgroup]")) {
101
+ inStep = false;
102
+ }
103
+ else {
104
+ result.push(line);
105
+ }
106
+ }
107
+ return result.length > 0 ? result : null;
106
108
  }
package/bin/cli/args.mjs CHANGED
@@ -26,6 +26,7 @@ const BOOLEAN_FLAGS = new Set([
26
26
  "--no-auto-mark-ready",
27
27
  "--no-auto-cancel-actionable",
28
28
  "--dry-run",
29
+ "--verbose",
29
30
  ]);
30
31
  // ---------------------------------------------------------------------------
31
32
  // Strict integer parsing
@@ -44,18 +45,23 @@ export function parseCommonArgs(args) {
44
45
  tokens: true,
45
46
  options: {
46
47
  format: { type: "string" },
48
+ verbose: { type: "boolean" },
47
49
  },
48
50
  });
49
51
  const format = (values.format ?? "text");
52
+ const verbose = values.verbose === true;
50
53
  // Build the set of arg indices consumed by global flags so we can strip
51
54
  // them from `extra`. Subcommand-specific flags are left untouched.
52
55
  const consumedIndices = new Set();
53
56
  for (const tok of tokens ?? []) {
54
- if (tok.kind === "option" && tok.name === "format") {
57
+ if (tok.kind === "option" && (tok.name === "format" || tok.name === "verbose")) {
55
58
  consumedIndices.add(tok.index);
56
59
  // When the value is a separate arg (--flag value, not --flag=value),
57
60
  // inlineValue is false and the value occupies tok.index + 1.
58
- if ("inlineValue" in tok && tok.inlineValue === false && tok.value != null) {
61
+ if (tok.name === "format" &&
62
+ "inlineValue" in tok &&
63
+ tok.inlineValue === false &&
64
+ tok.value != null) {
59
65
  consumedIndices.add(tok.index + 1);
60
66
  }
61
67
  }
@@ -97,7 +103,7 @@ export function parseCommonArgs(args) {
97
103
  const extra = args.filter((_, i) => !consumedIndices.has(i));
98
104
  return {
99
105
  prNumber,
100
- global: { format },
106
+ global: { format, verbose },
101
107
  extra,
102
108
  };
103
109
  }
@@ -0,0 +1,4 @@
1
+ export function safeFence(content) {
2
+ const maxRun = Math.max(0, ...Array.from(content.matchAll(/`+/g), (m) => m[0].length));
3
+ return "`".repeat(Math.max(3, maxRun + 1));
4
+ }
@@ -1,61 +1,87 @@
1
1
  import { renderResolveCommand } from "../commands/iterate.mjs";
2
+ import { safeFence } from "./fence.mjs";
3
+ import { joinSections } from "../util/markdown.mjs";
4
+ import { renderSuggestionBlock, renderLineRange } from "./suggestion-renderer.mjs";
5
+ import { renderThreadBullet, renderCommentBullet, renderReviewBullet, renderFirstLookStatusTag, } from "./list-formatters.mjs";
2
6
  export function formatFixCodeResult(header, result) {
3
7
  const sections = [header];
4
8
  if (result.fix.threads.length > 0) {
5
9
  sections.push("## Review threads");
6
10
  for (const t of result.fix.threads) {
7
- const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
8
- sections.push(`### \`${t.id}\` — ${loc} (@${t.author})`);
11
+ const lineLabel = renderLineRange(t.startLine, t.line);
12
+ const loc = t.path ? `\`${t.path}:${lineLabel}\`` : "(no location)";
13
+ const heading = t.url ? `[threadId=${t.id}](${t.url})` : `\`threadId=${t.id}\``;
14
+ const suggestionMarker = t.suggestion ? " [suggestion]" : "";
15
+ sections.push(`### ${heading} — ${loc} (@${t.author})${suggestionMarker}`);
9
16
  sections.push(blockquote(t.body));
17
+ if (t.suggestion) {
18
+ sections.push(renderSuggestionBlock(t.suggestion, ""));
19
+ }
10
20
  }
11
21
  }
12
22
  if (result.fix.actionableComments.length > 0) {
13
23
  sections.push("## Actionable comments");
14
24
  for (const c of result.fix.actionableComments) {
15
- sections.push(`### \`${c.id}\` (@${c.author})`);
25
+ const heading = c.url ? `[commentId=${c.id}](${c.url})` : `\`commentId=${c.id}\``;
26
+ sections.push(`### ${heading} (@${c.author})`);
16
27
  sections.push(blockquote(c.body));
17
28
  }
18
29
  }
19
30
  if (result.fix.checks.length > 0) {
20
31
  sections.push("## Failing checks");
21
32
  const bullets = result.fix.checks.map((ch) => {
22
- const prefix = ch.workflowName ? `${ch.workflowName} › ` : "";
33
+ const workflowPrefix = ch.workflowName ? `${ch.workflowName} › ` : "";
34
+ const jobLabel = ch.jobName ? ch.jobName : ch.name;
23
35
  const locator = ch.runId
24
36
  ? `\`${ch.runId}\``
25
37
  : ch.detailsUrl
26
38
  ? `external \`${ch.detailsUrl}\``
27
39
  : "(no runId)";
28
- const lines = [`- ${locator} — \`${prefix}${ch.name}\``];
40
+ const lines = [`- ${locator} — \`${workflowPrefix}${jobLabel}\``];
29
41
  if (ch.failedStep)
30
42
  lines.push(` > ${ch.failedStep}`);
31
43
  if (ch.summary)
32
44
  lines.push(` > ${ch.summary}`);
45
+ if (ch.logTail) {
46
+ const fence = safeFence(ch.logTail);
47
+ lines.push(` ${fence}`);
48
+ lines.push(ch.logTail.replace(/^/gm, " ").trimEnd());
49
+ lines.push(` ${fence}`);
50
+ }
33
51
  return lines.join("\n");
34
52
  });
35
53
  sections.push(bullets.join("\n\n"));
36
54
  }
37
55
  if (result.fix.changesRequestedReviews.length > 0) {
38
56
  sections.push("## Changes-requested reviews");
39
- sections.push(result.fix.changesRequestedReviews.map((r) => `- \`${r.id}\` (@${r.author})`).join("\n"));
40
- }
41
- if (result.fix.noiseCommentIds.length > 0) {
42
- sections.push("## Noise (minimize only)");
43
- sections.push(result.fix.noiseCommentIds.map((id) => `\`${id}\``).join(", "));
57
+ sections.push(result.fix.changesRequestedReviews.map((r) => renderReviewBullet(r)).join("\n"));
44
58
  }
45
59
  if (result.fix.reviewSummaryIds.length > 0) {
46
60
  sections.push("## Review summaries (minimize only)");
47
- sections.push(result.fix.reviewSummaryIds.map((id) => `\`${id}\``).join(", "));
61
+ sections.push(result.fix.reviewSummaryIds.map((id) => `- \`${id}\``).join("\n"));
62
+ }
63
+ if (result.fix.surfacedApprovals.length > 0) {
64
+ sections.push("## Approvals (surfaced — not minimized)");
65
+ for (const r of result.fix.surfacedApprovals) {
66
+ sections.push(`### \`reviewId=${r.id}\` (@${r.author})`);
67
+ sections.push(r.body.trim() === "" ? "(no review body)" : blockquote(r.body));
68
+ }
48
69
  }
49
- if (result.fix.surfacedSummaries.length > 0) {
50
- sections.push("## Review summaries (surfaced not minimized)");
51
- for (const r of result.fix.surfacedSummaries) {
52
- sections.push(`### \`${r.id}\` (@${r.author})`);
53
- sections.push(blockquote(r.body));
70
+ const firstLookTotal = result.fix.firstLookThreads.length + result.fix.firstLookComments.length;
71
+ if (firstLookTotal > 0) {
72
+ sections.push(`## First-look items (${firstLookTotal}) already closed on GitHub; acknowledge only`);
73
+ const bullets = [];
74
+ for (const t of result.fix.firstLookThreads) {
75
+ bullets.push(renderThreadBullet(t, { statusTag: renderFirstLookStatusTag(t) }));
76
+ }
77
+ for (const c of result.fix.firstLookComments) {
78
+ bullets.push(renderCommentBullet(c, { statusTag: "[status: minimized]" }));
54
79
  }
80
+ sections.push(bullets.join("\n"));
55
81
  }
56
82
  if (result.cancelled.length > 0) {
57
83
  sections.push("## Cancelled runs");
58
- sections.push(result.cancelled.map((id) => `\`${id}\``).join(", "));
84
+ sections.push(result.cancelled.map((id) => `- \`${id}\``).join("\n"));
59
85
  }
60
86
  sections.push("## Post-fix push");
61
87
  const postFixLines = [`- base: \`${result.baseBranch}\``];
@@ -65,9 +91,9 @@ export function formatFixCodeResult(header, result) {
65
91
  sections.push(postFixLines.join("\n"));
66
92
  sections.push("## Instructions");
67
93
  sections.push(result.fix.instructions.map((inst, i) => `${i + 1}. ${inst}`).join("\n"));
68
- return sections.join("\n\n");
94
+ return joinSections(sections);
69
95
  }
70
- export function blockquote(body) {
96
+ function blockquote(body) {
71
97
  return body
72
98
  .replace(/\r\n/g, "\n")
73
99
  .split("\n")