pr-shepherd 0.46.0 → 0.46.2

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 (33) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +6 -6
  3. package/bin/api.d.mts +2 -2
  4. package/bin/cli/help-command-pages.d.mts +3 -5
  5. package/bin/cli/help-command-pages.mjs +3 -5
  6. package/bin/cli/help-top-page.d.mts +1 -1
  7. package/bin/cli/help-top-page.mjs +1 -1
  8. package/bin/cli/help.d.mts +4 -6
  9. package/bin/cli/mark-files-as-viewed-formatter.mjs +7 -6
  10. package/bin/commands/check.mjs +4 -3
  11. package/bin/commands/iterate/escalate.mjs +1 -1
  12. package/bin/commands/iterate/fix-code.mjs +8 -5
  13. package/bin/commands/iterate/merge-state.mjs +8 -37
  14. package/bin/commands/iterate/merge.mjs +1 -0
  15. package/bin/commands/iterate/thread-mutation-routing.d.mts +1 -0
  16. package/bin/commands/iterate/thread-mutation-routing.mjs +7 -0
  17. package/bin/commands/journal/index.d.mts +1 -0
  18. package/bin/commands/journal/index.mjs +1 -10
  19. package/bin/commands/mark-files-as-viewed.d.mts +2 -1
  20. package/bin/commands/mark-files-as-viewed.mjs +116 -4
  21. package/bin/commands/resolve-mutate.mjs +17 -40
  22. package/bin/comments/resolve.d.mts +4 -0
  23. package/bin/comments/resolve.mjs +10 -4
  24. package/bin/github/client.d.mts +0 -1
  25. package/bin/github/client.mjs +0 -3
  26. package/bin/github/gql/get-pr-body.gql +0 -1
  27. package/bin/mcp/server.mjs +2 -2
  28. package/package.json +1 -1
  29. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  30. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  31. package/plugins/pr-shepherd/.mcp.json +1 -1
  32. package/plugins/pr-shepherd/skills/mark-files-as-viewed/SKILL.md +2 -2
  33. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +1 -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
4
- "version": "0.46.0",
4
+ "version": "0.46.2",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -25,14 +25,14 @@ Full reference: [docs/README.md](docs/README.md). Feature matrix: [docs/features
25
25
 
26
26
  `pr-shepherd` moves deterministic PR orchestration into a local MCP server, with a CLI for shells and CI. Both interfaces fetch the same GitHub state, emit raw-enough context, and return a numbered plan for the calling agent to follow.
27
27
 
28
- The MCP server exposes canonical `iterate`, `apply`, and `build_suggestion_patches` tools. `apply` accepts ordered review mutations, selection-only file-view diagnostics, and journal entries; the deprecated singular suggestion tool remains temporarily as an adapter. Direct MCP calls require a repository-qualified `pr`: a GitHub PR URL or `owner/repo#N`; the explicit repository is the target for GitHub I/O, even when it differs from the local checkout. The CLI and programmatic API also retain bare-number and current-branch PR discovery. The shipped skills are thin dispatchers for those tools.
28
+ The MCP server exposes canonical `iterate`, `apply`, and `build_suggestion_patches` tools. `apply` accepts ordered review mutations, file-view mutations, and journal entries; the deprecated singular suggestion tool remains temporarily as an adapter. Direct MCP calls require a repository-qualified `pr`: a GitHub PR URL or `owner/repo#N`; the explicit repository is the target for GitHub I/O, even when it differs from the local checkout. The CLI and programmatic API also retain bare-number and current-branch PR discovery. The shipped skills are thin dispatchers for those tools.
29
29
 
30
30
  Each tick returns exactly one action:
31
31
 
32
32
  - `WAIT` — no immediate action; continue with the next poll.
33
33
  - `MARK_READY` — the CLI converted an eligible draft PR to ready; continue polling.
34
34
  - `FIX_CODE` — agent work is required; complete it, push when needed, then continue polling. Push access to the PR head branch is a usage precondition.
35
- - `MERGE` — run the emitted auto-merge command only when GitHub reports `viewerCanEnableAutoMerge`; missing authorization returns `ESCALATE`.
35
+ - `MERGE` — run the emitted head-pinned auto-merge or queue command. Ordinary merges include a plain-merge fallback; queue merges include a GraphQL enqueue fallback. GitHub is authoritative for the result and reports any authorization failure.
36
36
  - `CANCEL` — stop polling because the PR merged, closed, or completed its ready-delay.
37
37
  - `ESCALATE` — stop polling until a human provides direction.
38
38
 
@@ -84,12 +84,12 @@ See [docs/actions.md](docs/actions.md) for the complete output contract and [doc
84
84
  This system is opinionated and works best with PRs that use required status checks and conversation resolution.
85
85
 
86
86
  - A human inline thread whose original comment has `viewerDidAuthor: true` is replied to and resolved when its latest comment is unmarked. An unmarked other-human inline thread remains reply-only; a marker-ended other-human thread is already acknowledged and receives no further mutation. Human items are never minimized.
87
- - Detected bots and configured `botUsernames` review threads are returned until resolved when the required mutation is authorized and the thread has a source location. Unauthorized or unlocated items are surfaced once and then marker-gated until edited. Bot/non-human threads, PR comments, and review summaries can be resolved or minimized when eligible. Review summaries are not minimized while known inline child threads from that review remain unresolved.
87
+ - Detected bots and configured `botUsernames` review threads are returned until resolved when the required mutation is authorized and the thread has a source location. Authorized outdated bot threads remain resolution-only work even when GitHub clears their source line, because resolving by thread ID does not require that location. Unauthorized or other unlocated items are surfaced once and then marker-gated until edited. Bot/non-human threads, PR comments, and review summaries can be resolved or minimized when eligible. Review summaries are not minimized while known inline child threads from that review remain unresolved.
88
88
  - Shepherd identifies its own latest reply only when that comment begins `<!-- pr-shepherd -->`, not from author equality. A marked viewer-authored thread can be resolved without another reply as a retry.
89
89
  - Every review thread/comment/review summary is surfaced at least once, even if already outdated, resolved, or minimized; edited items re-surface through seen markers.
90
90
  - Draft PRs can be marked ready automatically when clean; disable with `actions.autoMarkReady: false` or `--no-auto-mark-ready`.
91
91
  - The CLI never performs git mutations itself — it only emits commit/push instructions for the agent to run. Push access to the PR head is a usage precondition; GitHub viewer fields do not create a separate push-authorization handoff.
92
- - Every GitHub mutation is permission-aware. Shepherd uses raw viewer capability fields, omits unauthorized commands, and repeats authorization checks in direct `apply` commands. Missing capability data fails closed.
92
+ - Generated iterate mutations and automatic actions are capability-aware and omit unauthorized commands. Explicit `apply` operations honor the caller's intent and surface GitHub's result; semantic human-content protections still apply.
93
93
  - `build_suggestion_patches` turns one or more ordered GitHub suggestion threads into checked patches and commit metadata, but never edits the working tree or git history. Local HEAD may be ahead when the live PR head is its ancestor.
94
94
 
95
95
  ## Usage
@@ -128,7 +128,7 @@ pr-shepherd 42 --quiet-status # print only changed WAIT status snapshot
128
128
  pr-shepherd 42 --until-terminal # continue through WAIT/MARK_READY until work or terminal state
129
129
  pr-shepherd 42 --debounce 5m # wait 5m after first FIX_CODE, then return one batched tick
130
130
  pr-shepherd 42 --ready-delay 15m
131
- pr-shepherd 42 --merge # enable auto-merge when GitHub confirms viewer authorization
131
+ pr-shepherd 42 --merge # request head-pinned auto-merge/queue; GitHub reports the result
132
132
  pr-shepherd iterate 42 # single tick
133
133
  pr-shepherd owner/repo#42 # poll a PR in an explicit repository
134
134
  pr-shepherd https://github.com/owner/repo/pull/42
@@ -136,7 +136,7 @@ pr-shepherd https://github.com/owner/repo/pull/42
136
136
 
137
137
  ### Apply Review And Journal Changes, Or Select Files
138
138
 
139
- Use `apply` with ordered operations to reply/resolve/minimize/dismiss review items, select changed files for viewed-state authorization diagnostics, or append an idempotent Shepherd Journal item. File-view selection never mutates viewed state because GitHub exposes no exact viewer capability. Use `build_suggestion_patches` to turn ordered review suggestions into checked patches and commit metadata; it never changes the worktree or git history.
139
+ Use `apply` with ordered operations to reply/resolve/minimize/dismiss review items, mark selected changed files as viewed, or append an idempotent Shepherd Journal item. Explicit operations are attempted and surface GitHub's per-operation results; generated iterate guidance remains capability-filtered. Use `build_suggestion_patches` to turn ordered review suggestions into checked patches and commit metadata; it never changes the worktree or git history.
140
140
 
141
141
  ### Extract Shepherd Journal Entries
142
142
 
package/bin/api.d.mts CHANGED
@@ -22,7 +22,7 @@ export interface ReviewMutationsOperation {
22
22
  requireSha?: string;
23
23
  }
24
24
  export interface MarkFilesViewedOperation {
25
- /** Compatibility-named selection only; always skips mutation because authorization is unverifiable. */
25
+ /** Marks selected changed files as viewed. GitHub authorizes the requested mutation. */
26
26
  type: "mark_files_viewed";
27
27
  files?: string[];
28
28
  tests?: boolean;
@@ -33,7 +33,7 @@ export interface AppendJournalOperation {
33
33
  item: string;
34
34
  dryRun?: boolean;
35
35
  }
36
- /** Operations run in this exact list order after validation; file-view selection is non-mutating. */
36
+ /** Operations run in this exact list order after validation. */
37
37
  export type ApplyOperation = ReviewMutationsOperation | MarkFilesViewedOperation | AppendJournalOperation;
38
38
  export interface ApplyInput {
39
39
  /** PR shared by every operation in this ordered apply request. */
@@ -2,7 +2,7 @@ export declare const COMMAND_USAGE: {
2
2
  readonly default: string;
3
3
  readonly apply: `pr-shepherd apply
4
4
 
5
- Apply a review mutation, select changed files for authorization diagnostics, or append a PR journal item.
5
+ Apply review mutations, mark selected changed files as viewed, or append a PR journal item.
6
6
 
7
7
  Usage:
8
8
  pr-shepherd apply review [PR] [review-flags]
@@ -36,8 +36,7 @@ At least one action flag is required.
36
36
  --help, -h Print this help and exit before GitHub I/O.`;
37
37
  readonly "apply files": `pr-shepherd apply files
38
38
 
39
- Select changed files and report that viewed-state authorization cannot be verified.
40
- No file viewed-state mutation is attempted.
39
+ Mark selected changed files as viewed.
41
40
 
42
41
  Usage:
43
42
  pr-shepherd apply files [PR] [files...] [--tests] [--match REGEX]
@@ -176,8 +175,7 @@ Exit codes:
176
175
  See docs/exit-codes.md for the full sysexits.h table.`;
177
176
  readonly "mark-files-as-viewed": `pr-shepherd mark-files-as-viewed
178
177
 
179
- Deprecated compatibility alias for selection-only file-view authorization diagnostics.
180
- No file viewed-state mutation is attempted.
178
+ Deprecated compatibility alias for marking selected PR files as viewed.
181
179
 
182
180
  Usage:
183
181
  pr-shepherd mark-files-as-viewed [PR] [files...] [--tests] [--match REGEX]
@@ -5,7 +5,7 @@ export const COMMAND_USAGE = {
5
5
  default: DEFAULT_USAGE,
6
6
  apply: `pr-shepherd apply
7
7
 
8
- Apply a review mutation, select changed files for authorization diagnostics, or append a PR journal item.
8
+ Apply review mutations, mark selected changed files as viewed, or append a PR journal item.
9
9
 
10
10
  Usage:
11
11
  pr-shepherd apply review [PR] [review-flags]
@@ -39,8 +39,7 @@ At least one action flag is required.
39
39
  --help, -h Print this help and exit before GitHub I/O.`,
40
40
  "apply files": `pr-shepherd apply files
41
41
 
42
- Select changed files and report that viewed-state authorization cannot be verified.
43
- No file viewed-state mutation is attempted.
42
+ Mark selected changed files as viewed.
44
43
 
45
44
  Usage:
46
45
  pr-shepherd apply files [PR] [files...] [--tests] [--match REGEX]
@@ -179,8 +178,7 @@ Exit codes:
179
178
  See docs/exit-codes.md for the full sysexits.h table.`,
180
179
  "mark-files-as-viewed": `pr-shepherd mark-files-as-viewed
181
180
 
182
- Deprecated compatibility alias for selection-only file-view authorization diagnostics.
183
- No file viewed-state mutation is attempted.
181
+ Deprecated compatibility alias for marking selected PR files as viewed.
184
182
 
185
183
  Usage:
186
184
  pr-shepherd mark-files-as-viewed [PR] [files...] [--tests] [--match REGEX]
@@ -1 +1 @@
1
- export declare const TOP_USAGE = "pr-shepherd\n\nAutonomous PR CI monitor and review-comment resolver for agentic coding tools.\n\nUsage:\n pr-shepherd --version | -v\n pr-shepherd --help | -h\n pr-shepherd [PR] [poll-flags] [iterate-flags]\n pr-shepherd iterate [PR] [iterate-flags]\n pr-shepherd apply review [PR] [review-flags]\n pr-shepherd apply files [PR] [files...] [--tests] [--match REGEX]\n pr-shepherd apply journal [PR] <item> [--dry-run] [--format text|json]\n pr-shepherd journal extract --body-file <path>\n pr-shepherd build-suggestion-patches [PR] --thread-id ID --message MSG [groups...]\n pr-shepherd admin clean <pr|branch|current|repo|all> [value] [flags]\n pr-shepherd admin log-file [--format text|json]\n\nCommands:\n [PR] Poll until non-WAIT or timeout. This is the default command.\n iterate Run one iterate tick (single-tick alias).\n apply review Apply review-state mutations after fixes.\n apply files Select changed files; no viewed-state mutation is attempted.\n apply journal Append a list item to the Shepherd Journal details block of a PR body.\n journal extract Extract a validated Shepherd Journal from a local PR-body file as JSON.\n build-suggestion-patches\n Convert ordered GitHub suggestion threads into patches and commit instructions.\n admin clean Remove pr-shepherd state files.\n admin log-file Print the per-worktree debug log path.\n\nPR argument:\n PR may be a number such as 42 or a GitHub pull request URL.\n When omitted, pr-shepherd infers the current branch's pull request.\n\nCommon flags:\n --format text|json Output Markdown text or JSON. Default: text.\n --verbose Include verbose iterate fields and detailed poll-tick lines.\n --help, -h Print help and exit before any GitHub, git, config, or log I/O.\n\nIterate flags:\n --ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.\n --stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.\n --no-auto-mark-ready Do not convert draft PRs to ready for review.\n --no-auto-cancel-actionable Legacy no-op; workflow runs are never cancelled.\n\nPolling flags:\n --interval <duration> Delay between WAIT ticks. Bare number = seconds. Default: 60s.\n --timeout <duration> Poll wall-clock cap for WAIT ticks. Bare number = seconds. Default: 4.5m.\n --debounce <duration> Settle window after first FIX_CODE before returning. Bare number = seconds. Default: 60s. 0 disables.\n --quiet-status During WAIT polling, print only changed status snapshots.\n --until-terminal Continue through WAIT/MARK_READY until FIX_CODE/CANCEL/ESCALATE.\n\nClean variants:\n pr [number] Remove state for one PR. Defaults to current branch PR.\n branch [name] Remove state for a branch's PR. Defaults to current branch.\n current Alias for branch against the current branch.\n repo Remove all state for the current repository.\n all Remove all pr-shepherd state.\n\nExit codes: 0 done, 10-19 PR state, 64-78 shepherd failed (sysexits.h).\n 0 CANCEL (merged or ready-delay elapsed)\n 10 WAIT\n 11 MARK_READY\n 12 FIX_CODE\n 13 ESCALATE\n 14 CANCEL (closed without merging)\nSee docs/exit-codes.md for the full sysexits.h error-code table.\n\nDuration examples: 30s, 4.5m, 1h. A bare number uses each flag's default unit (see above); decimals are allowed with an explicit unit (4.5m).\n\nRun 'pr-shepherd <command> --help' for command-specific details.";
1
+ export declare const TOP_USAGE = "pr-shepherd\n\nAutonomous PR CI monitor and review-comment resolver for agentic coding tools.\n\nUsage:\n pr-shepherd --version | -v\n pr-shepherd --help | -h\n pr-shepherd [PR] [poll-flags] [iterate-flags]\n pr-shepherd iterate [PR] [iterate-flags]\n pr-shepherd apply review [PR] [review-flags]\n pr-shepherd apply files [PR] [files...] [--tests] [--match REGEX]\n pr-shepherd apply journal [PR] <item> [--dry-run] [--format text|json]\n pr-shepherd journal extract --body-file <path>\n pr-shepherd build-suggestion-patches [PR] --thread-id ID --message MSG [groups...]\n pr-shepherd admin clean <pr|branch|current|repo|all> [value] [flags]\n pr-shepherd admin log-file [--format text|json]\n\nCommands:\n [PR] Poll until non-WAIT or timeout. This is the default command.\n iterate Run one iterate tick (single-tick alias).\n apply review Apply review-state mutations after fixes.\n apply files Mark selected changed files as viewed.\n apply journal Append a list item to the Shepherd Journal details block of a PR body.\n journal extract Extract a validated Shepherd Journal from a local PR-body file as JSON.\n build-suggestion-patches\n Convert ordered GitHub suggestion threads into patches and commit instructions.\n admin clean Remove pr-shepherd state files.\n admin log-file Print the per-worktree debug log path.\n\nPR argument:\n PR may be a number such as 42 or a GitHub pull request URL.\n When omitted, pr-shepherd infers the current branch's pull request.\n\nCommon flags:\n --format text|json Output Markdown text or JSON. Default: text.\n --verbose Include verbose iterate fields and detailed poll-tick lines.\n --help, -h Print help and exit before any GitHub, git, config, or log I/O.\n\nIterate flags:\n --ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.\n --stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.\n --no-auto-mark-ready Do not convert draft PRs to ready for review.\n --no-auto-cancel-actionable Legacy no-op; workflow runs are never cancelled.\n\nPolling flags:\n --interval <duration> Delay between WAIT ticks. Bare number = seconds. Default: 60s.\n --timeout <duration> Poll wall-clock cap for WAIT ticks. Bare number = seconds. Default: 4.5m.\n --debounce <duration> Settle window after first FIX_CODE before returning. Bare number = seconds. Default: 60s. 0 disables.\n --quiet-status During WAIT polling, print only changed status snapshots.\n --until-terminal Continue through WAIT/MARK_READY until FIX_CODE/CANCEL/ESCALATE.\n\nClean variants:\n pr [number] Remove state for one PR. Defaults to current branch PR.\n branch [name] Remove state for a branch's PR. Defaults to current branch.\n current Alias for branch against the current branch.\n repo Remove all state for the current repository.\n all Remove all pr-shepherd state.\n\nExit codes: 0 done, 10-19 PR state, 64-78 shepherd failed (sysexits.h).\n 0 CANCEL (merged or ready-delay elapsed)\n 10 WAIT\n 11 MARK_READY\n 12 FIX_CODE\n 13 ESCALATE\n 14 CANCEL (closed without merging)\nSee docs/exit-codes.md for the full sysexits.h error-code table.\n\nDuration examples: 30s, 4.5m, 1h. A bare number uses each flag's default unit (see above); decimals are allowed with an explicit unit (4.5m).\n\nRun 'pr-shepherd <command> --help' for command-specific details.";
@@ -19,7 +19,7 @@ Commands:
19
19
  [PR] Poll until non-WAIT or timeout. This is the default command.
20
20
  iterate Run one iterate tick (single-tick alias).
21
21
  apply review Apply review-state mutations after fixes.
22
- apply files Select changed files; no viewed-state mutation is attempted.
22
+ apply files Mark selected changed files as viewed.
23
23
  apply journal Append a list item to the Shepherd Journal details block of a PR body.
24
24
  journal extract Extract a validated Shepherd Journal from a local PR-body file as JSON.
25
25
  build-suggestion-patches
@@ -2,7 +2,7 @@ export declare const USAGE: {
2
2
  readonly default: string;
3
3
  readonly apply: `pr-shepherd apply
4
4
 
5
- Apply a review mutation, select changed files for authorization diagnostics, or append a PR journal item.
5
+ Apply review mutations, mark selected changed files as viewed, or append a PR journal item.
6
6
 
7
7
  Usage:
8
8
  pr-shepherd apply review [PR] [review-flags]
@@ -36,8 +36,7 @@ At least one action flag is required.
36
36
  --help, -h Print this help and exit before GitHub I/O.`;
37
37
  readonly "apply files": `pr-shepherd apply files
38
38
 
39
- Select changed files and report that viewed-state authorization cannot be verified.
40
- No file viewed-state mutation is attempted.
39
+ Mark selected changed files as viewed.
41
40
 
42
41
  Usage:
43
42
  pr-shepherd apply files [PR] [files...] [--tests] [--match REGEX]
@@ -176,8 +175,7 @@ Exit codes:
176
175
  See docs/exit-codes.md for the full sysexits.h table.`;
177
176
  readonly "mark-files-as-viewed": `pr-shepherd mark-files-as-viewed
178
177
 
179
- Deprecated compatibility alias for selection-only file-view authorization diagnostics.
180
- No file viewed-state mutation is attempted.
178
+ Deprecated compatibility alias for marking selected PR files as viewed.
181
179
 
182
180
  Usage:
183
181
  pr-shepherd mark-files-as-viewed [PR] [files...] [--tests] [--match REGEX]
@@ -260,7 +258,7 @@ On POSIX, the final body-file path entry must be a readable regular file in a tr
260
258
  symlinks, FIFOs, devices, and unreadable paths exit 66. Unsupported platforms fail closed with exit 66.
261
259
  --help, -h Print this help and exit before any I/O.`;
262
260
  readonly "log-file": "pr-shepherd log-file\n\nPrint the per-worktree append-only debug log path for the current repository.\nThe log is created by the first non-help pr-shepherd command that initializes logging.\n\nUsage:\n pr-shepherd log-file [--format text|json]\n\nFlags:\n --format text|json Print a raw path or {\"path\": \"...\"} JSON. Default: text.\n --help, -h Print this help and exit before logging setup.\n\nEnvironment:\n PR_SHEPHERD_LOG_DISABLED=1 disables logging.\n PR_SHEPHERD_STATE_DIR overrides the base state directory.\n\nExit code: 0 on success; 1 if repository identity cannot be resolved.";
263
- readonly top: "pr-shepherd\n\nAutonomous PR CI monitor and review-comment resolver for agentic coding tools.\n\nUsage:\n pr-shepherd --version | -v\n pr-shepherd --help | -h\n pr-shepherd [PR] [poll-flags] [iterate-flags]\n pr-shepherd iterate [PR] [iterate-flags]\n pr-shepherd apply review [PR] [review-flags]\n pr-shepherd apply files [PR] [files...] [--tests] [--match REGEX]\n pr-shepherd apply journal [PR] <item> [--dry-run] [--format text|json]\n pr-shepherd journal extract --body-file <path>\n pr-shepherd build-suggestion-patches [PR] --thread-id ID --message MSG [groups...]\n pr-shepherd admin clean <pr|branch|current|repo|all> [value] [flags]\n pr-shepherd admin log-file [--format text|json]\n\nCommands:\n [PR] Poll until non-WAIT or timeout. This is the default command.\n iterate Run one iterate tick (single-tick alias).\n apply review Apply review-state mutations after fixes.\n apply files Select changed files; no viewed-state mutation is attempted.\n apply journal Append a list item to the Shepherd Journal details block of a PR body.\n journal extract Extract a validated Shepherd Journal from a local PR-body file as JSON.\n build-suggestion-patches\n Convert ordered GitHub suggestion threads into patches and commit instructions.\n admin clean Remove pr-shepherd state files.\n admin log-file Print the per-worktree debug log path.\n\nPR argument:\n PR may be a number such as 42 or a GitHub pull request URL.\n When omitted, pr-shepherd infers the current branch's pull request.\n\nCommon flags:\n --format text|json Output Markdown text or JSON. Default: text.\n --verbose Include verbose iterate fields and detailed poll-tick lines.\n --help, -h Print help and exit before any GitHub, git, config, or log I/O.\n\nIterate flags:\n --ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.\n --stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.\n --no-auto-mark-ready Do not convert draft PRs to ready for review.\n --no-auto-cancel-actionable Legacy no-op; workflow runs are never cancelled.\n\nPolling flags:\n --interval <duration> Delay between WAIT ticks. Bare number = seconds. Default: 60s.\n --timeout <duration> Poll wall-clock cap for WAIT ticks. Bare number = seconds. Default: 4.5m.\n --debounce <duration> Settle window after first FIX_CODE before returning. Bare number = seconds. Default: 60s. 0 disables.\n --quiet-status During WAIT polling, print only changed status snapshots.\n --until-terminal Continue through WAIT/MARK_READY until FIX_CODE/CANCEL/ESCALATE.\n\nClean variants:\n pr [number] Remove state for one PR. Defaults to current branch PR.\n branch [name] Remove state for a branch's PR. Defaults to current branch.\n current Alias for branch against the current branch.\n repo Remove all state for the current repository.\n all Remove all pr-shepherd state.\n\nExit codes: 0 done, 10-19 PR state, 64-78 shepherd failed (sysexits.h).\n 0 CANCEL (merged or ready-delay elapsed)\n 10 WAIT\n 11 MARK_READY\n 12 FIX_CODE\n 13 ESCALATE\n 14 CANCEL (closed without merging)\nSee docs/exit-codes.md for the full sysexits.h error-code table.\n\nDuration examples: 30s, 4.5m, 1h. A bare number uses each flag's default unit (see above); decimals are allowed with an explicit unit (4.5m).\n\nRun 'pr-shepherd <command> --help' for command-specific details.";
261
+ readonly top: "pr-shepherd\n\nAutonomous PR CI monitor and review-comment resolver for agentic coding tools.\n\nUsage:\n pr-shepherd --version | -v\n pr-shepherd --help | -h\n pr-shepherd [PR] [poll-flags] [iterate-flags]\n pr-shepherd iterate [PR] [iterate-flags]\n pr-shepherd apply review [PR] [review-flags]\n pr-shepherd apply files [PR] [files...] [--tests] [--match REGEX]\n pr-shepherd apply journal [PR] <item> [--dry-run] [--format text|json]\n pr-shepherd journal extract --body-file <path>\n pr-shepherd build-suggestion-patches [PR] --thread-id ID --message MSG [groups...]\n pr-shepherd admin clean <pr|branch|current|repo|all> [value] [flags]\n pr-shepherd admin log-file [--format text|json]\n\nCommands:\n [PR] Poll until non-WAIT or timeout. This is the default command.\n iterate Run one iterate tick (single-tick alias).\n apply review Apply review-state mutations after fixes.\n apply files Mark selected changed files as viewed.\n apply journal Append a list item to the Shepherd Journal details block of a PR body.\n journal extract Extract a validated Shepherd Journal from a local PR-body file as JSON.\n build-suggestion-patches\n Convert ordered GitHub suggestion threads into patches and commit instructions.\n admin clean Remove pr-shepherd state files.\n admin log-file Print the per-worktree debug log path.\n\nPR argument:\n PR may be a number such as 42 or a GitHub pull request URL.\n When omitted, pr-shepherd infers the current branch's pull request.\n\nCommon flags:\n --format text|json Output Markdown text or JSON. Default: text.\n --verbose Include verbose iterate fields and detailed poll-tick lines.\n --help, -h Print help and exit before any GitHub, git, config, or log I/O.\n\nIterate flags:\n --ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.\n --stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.\n --no-auto-mark-ready Do not convert draft PRs to ready for review.\n --no-auto-cancel-actionable Legacy no-op; workflow runs are never cancelled.\n\nPolling flags:\n --interval <duration> Delay between WAIT ticks. Bare number = seconds. Default: 60s.\n --timeout <duration> Poll wall-clock cap for WAIT ticks. Bare number = seconds. Default: 4.5m.\n --debounce <duration> Settle window after first FIX_CODE before returning. Bare number = seconds. Default: 60s. 0 disables.\n --quiet-status During WAIT polling, print only changed status snapshots.\n --until-terminal Continue through WAIT/MARK_READY until FIX_CODE/CANCEL/ESCALATE.\n\nClean variants:\n pr [number] Remove state for one PR. Defaults to current branch PR.\n branch [name] Remove state for a branch's PR. Defaults to current branch.\n current Alias for branch against the current branch.\n repo Remove all state for the current repository.\n all Remove all pr-shepherd state.\n\nExit codes: 0 done, 10-19 PR state, 64-78 shepherd failed (sysexits.h).\n 0 CANCEL (merged or ready-delay elapsed)\n 10 WAIT\n 11 MARK_READY\n 12 FIX_CODE\n 13 ESCALATE\n 14 CANCEL (closed without merging)\nSee docs/exit-codes.md for the full sysexits.h error-code table.\n\nDuration examples: 30s, 4.5m, 1h. A bare number uses each flag's default unit (see above); decimals are allowed with an explicit unit (4.5m).\n\nRun 'pr-shepherd <command> --help' for command-specific details.";
264
262
  };
265
263
  /** Resolve help keys for nested public commands before any command I/O. */
266
264
  export declare function helpKeyForArgs(args: string[]): keyof typeof USAGE;
@@ -3,12 +3,11 @@ export function formatMarkFilesAsViewedResult(result) {
3
3
  lines.push(`# PR #${result.prNumber} — File-view selection (${result.matchedPaths.length} selected)`);
4
4
  lines.push("");
5
5
  lines.push(`repo: ${result.repo}`);
6
- if (result.authorizationSkipped) {
7
- lines.push("");
8
- lines.push("## Authorization");
9
- lines.push("");
10
- lines.push("- Not marked: GitHub does not expose a capability that confirms the current viewer may mark PR files as viewed.");
11
- }
6
+ lines.push(`pullRequestId: ${result.pullRequestId}`);
7
+ appendPathSection(lines, "Requested exact paths", result.requestedPaths);
8
+ if (result.testSelector)
9
+ appendTextSection(lines, "Selectors", ["--tests"]);
10
+ appendTextSection(lines, "Match selectors", result.matchPatterns.map((pattern) => `--match ${pattern}`));
12
11
  appendPathSection(lines, "Matched files", result.matchedPaths);
13
12
  appendPathSection(lines, "Marked viewed", result.markedPaths);
14
13
  appendPathSection(lines, "Already viewed", result.alreadyViewedPaths);
@@ -22,9 +21,11 @@ export function formatMarkFilesAsViewedResult(result) {
22
21
  result.rateLimit.remaining !== undefined && result.rateLimit.limit !== undefined
23
22
  ? `remaining ${result.rateLimit.remaining}/${result.rateLimit.limit}`
24
23
  : null,
24
+ result.rateLimit.used !== undefined ? `used ${result.rateLimit.used}` : null,
25
25
  result.rateLimit.resetAt !== undefined
26
26
  ? `reset at ${new Date(result.rateLimit.resetAt * 1000).toISOString()}`
27
27
  : null,
28
+ result.rateLimit.resource !== undefined ? `resource ${result.rateLimit.resource}` : null,
28
29
  ]
29
30
  .filter(Boolean)
30
31
  .join(", ");
@@ -17,7 +17,7 @@ import { classifyReviewsForDisplay, classifyChangesRequestedReviewsForDisplay, }
17
17
  import { autoMinimizeComments, autoResolveThreads } from "../comments/resolve.mjs";
18
18
  import { markReviewInlineThreadMarkers } from "../comments/review-thread-markers.mjs";
19
19
  import { isConfiguredBotAuthor, isHumanAuthor, normalizeBotUsernames, } from "../comments/authors.mjs";
20
- import { buildThreadMutationRouting } from "./iterate/thread-mutation-routing.mjs";
20
+ import { buildThreadMutationRouting, canResolveOutdatedBotWithoutLocation, } from "./iterate/thread-mutation-routing.mjs";
21
21
  import { discoverRuleFiles, loadRules } from "../classify/loader.mjs";
22
22
  import { buildClassifyIndex, partitionBatch } from "../classify/apply.mjs";
23
23
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
@@ -86,10 +86,11 @@ export async function runCheck(opts) {
86
86
  const replyThreadIds = new Set(threadMutationRouting.replyThreadIds);
87
87
  const resolveThreadIds = new Set(threadMutationRouting.resolveThreadIds);
88
88
  const repeatableThreadIds = new Set(visibleThreadCandidates
89
- .filter((thread) => thread.path !== null &&
89
+ .filter((thread) => (thread.path !== null &&
90
90
  thread.line !== null &&
91
91
  (!replyThreadIds.has(thread.id) || thread.viewerCanReply === true) &&
92
- (!resolveThreadIds.has(thread.id) || thread.viewerCanResolve === true))
92
+ (!resolveThreadIds.has(thread.id) || thread.viewerCanResolve === true)) ||
93
+ canResolveOutdatedBotWithoutLocation(thread, botUsernames))
93
94
  .map((thread) => thread.id));
94
95
  const threadVisibility = classifyThreadVisibility(visibleThreadCandidates, seenMap, botUsernames, repeatableThreadIds);
95
96
  const firstLookComments = minimizedCommentCandidates.flatMap((c) => {
@@ -202,7 +202,7 @@ export function buildEscalateSuggestion(triggers, detail) {
202
202
  return "One or more failing checks have no autonomous follow-up available. Use the displayed conclusion, run or URL, and included evidence to handle them manually.";
203
203
  }
204
204
  if (triggers.includes("authorization-required")) {
205
- return "GitHub did not confirm that the current viewer may perform the requested mark-ready or merge/enqueue operation. Ask a repository maintainer to perform that state change; Shepherd will not recommend a command that would be denied.";
205
+ return "GitHub did not confirm that the current viewer may perform the automatic mark-ready operation. Ask a repository maintainer to mark the pull request ready before resuming Shepherd.";
206
206
  }
207
207
  if (triggers.includes("merge-queue-removed")) {
208
208
  const reason = detail ? ` GitHub reason: ${detail}.` : "";
@@ -5,7 +5,7 @@ import { toAgentThread, toAgentComment, toAgentChecks } from "../../reporters/ag
5
5
  import { hashBody, markSeen } from "../../state/seen-comments.mjs";
6
6
  import { checkEscalateTriggers, validateBaseBranch, buildEscalateSuggestion, buildEscalateHumanMessage, } from "./escalate.mjs";
7
7
  import { buildResolveCommand } from "./classify.mjs";
8
- import { buildThreadMutationRouting } from "./thread-mutation-routing.mjs";
8
+ import { buildThreadMutationRouting, canResolveOutdatedBotWithoutLocation, } from "./thread-mutation-routing.mjs";
9
9
  import { buildFixInstructions } from "./render.mjs";
10
10
  import { applyStallGuard } from "./stall.mjs";
11
11
  import { annotationMarkerBody, checksWithActionableAnnotations } from "../check-annotations.mjs";
@@ -157,7 +157,9 @@ export async function handleFixCode(ctx) {
157
157
  isConfiguredBotAuthor(review, botUsernames));
158
158
  const skippedDismissalIds = new Set(unauthorizedDismissals.map((review) => review.id));
159
159
  const changesRequestedReviewsForWork = actionableChangesRequestedReviews.filter((review) => !skippedDismissalIds.has(review.id));
160
- const resolutionOnlyThreadsForWork = resolutionOnlyThreads.filter((thread) => !skippedThreadIds.has(thread.id) && thread.path !== null && thread.line !== null);
160
+ const resolutionOnlyThreadsForWork = resolutionOnlyThreads.filter((thread) => !skippedThreadIds.has(thread.id) &&
161
+ ((thread.path !== null && thread.line !== null) ||
162
+ canResolveOutdatedBotWithoutLocation(thread, botUsernames)));
161
163
  const hasConflicts = report.mergeStatus.status === "CONFLICTS";
162
164
  const isBehind = report.mergeStatus.status === "BEHIND";
163
165
  const { behindBaseHint } = loadConfig().iterate;
@@ -209,15 +211,16 @@ export async function handleFixCode(ctx) {
209
211
  const { resolveCommand, resolveOnlyCommand } = buildResolveCommand(retryableAgentThreads, resolutionOnlyThreadsForWork, allCommentIds, changesRequestedReviewsForWork, failingAgentChecks, prReference, botUsernames, ruleAutoResolveThreadIds, report.viewerAuthorization, allThreads);
210
212
  // Safety: if the base branch is unknown, escalate when a push is plausible — the agent
211
213
  // would need the correct base to rebase safely. This is a conservative guard, not a
212
- // prediction that the agent *will* push. Intentionally broader than `pushLikely` above:
213
- // resolution-only threads also need a known base in case the agent does push.
214
+ // prediction that the agent *will* push. Located resolution-only threads retain that guard;
215
+ // an outdated bot thread resolved only by ID has no code or push path.
216
+ const locatedResolutionOnlyThreadsForWork = resolutionOnlyThreadsForWork.filter((thread) => thread.path !== null && thread.line !== null);
214
217
  const pushIsPlausible = retryableActionableThreads.length > 0 ||
215
218
  failingAgentChecks.length > 0 ||
216
219
  annotatedExtra.length > 0 ||
217
220
  hasConflicts ||
218
221
  changesRequestedReviewsForWork.length > 0 ||
219
222
  actionableComments.length > 0 ||
220
- resolutionOnlyThreadsForWork.length > 0;
223
+ locatedResolutionOnlyThreadsForWork.length > 0;
221
224
  if (baseLookup.isFallback && pushIsPlausible) {
222
225
  const fallbackEscalateBase = {
223
226
  triggers: ["base-branch-unknown"],
@@ -7,45 +7,16 @@ export function buildReadyMergeResult(enabled, readyElapsed, base, report) {
7
7
  return null;
8
8
  const queue = Boolean(report.mergeStatus.mergeRequirements?.mergeQueue?.required ||
9
9
  report.mergeStatus.mergeRequirements?.mergeQueue?.enabled);
10
- // A merge queue is enrolled the same way auto-merge is granted: enabling
11
- // auto-merge on a queue-required PR is what adds it to the queue, so
12
- // viewerCanEnableAutoMerge authorizes both the plain-merge and enqueue paths.
13
- if (report.viewerAuthorization?.viewerCanEnableAutoMerge === true) {
14
- return {
15
- ...base,
16
- action: "merge",
17
- merge: buildMergeCommandPlan({
18
- pr: report.pr,
19
- repo: report.repo,
20
- nodeId: report.nodeId,
21
- headSha: report.headSha ?? "unknown",
22
- queue,
23
- }),
24
- };
25
- }
26
- const authorizationEscalateBase = {
27
- triggers: ["authorization-required"],
28
- unresolvedThreads: [],
29
- ambiguousComments: [],
30
- changesRequestedReviews: [],
31
- authorization: [
32
- {
33
- action: "merge-or-enqueue",
34
- targetIds: [report.nodeId],
35
- reason: "denied-or-unverifiable",
36
- },
37
- ],
38
- suggestion: buildEscalateSuggestion(["authorization-required"]),
39
- };
40
10
  return {
41
11
  ...base,
42
- action: "escalate",
43
- escalate: {
44
- ...authorizationEscalateBase,
45
- humanMessage: buildEscalateHumanMessage(authorizationEscalateBase, report.pr, {
46
- merge: true,
47
- }),
48
- },
12
+ action: "merge",
13
+ merge: buildMergeCommandPlan({
14
+ pr: report.pr,
15
+ repo: report.repo,
16
+ nodeId: report.nodeId,
17
+ headSha: report.headSha ?? "unknown",
18
+ queue,
19
+ }),
49
20
  };
50
21
  }
51
22
  export async function handleActiveMergeState(input) {
@@ -38,6 +38,7 @@ export function buildMergeCommandPlan(input) {
38
38
  return {
39
39
  mode: "auto",
40
40
  command: { argv: [...base, "--auto", ...commandArgs] },
41
+ fallbackCommand: { argv: [...base, ...commandArgs] },
41
42
  };
42
43
  }
43
44
  export function renderMergeCommand(command) {
@@ -6,4 +6,5 @@ export interface ThreadMutationRouting {
6
6
  standaloneResolveThreadIds: string[];
7
7
  resolveThreadIds: string[];
8
8
  }
9
+ export declare function canResolveOutdatedBotWithoutLocation(thread: ReviewThread, botUsernames: NormalizedBotUsernames): boolean;
9
10
  export declare function buildThreadMutationRouting(threads: Array<AgentThread | ReviewThread>, botUsernames: NormalizedBotUsernames, ruleAutoResolveThreadIds: string[]): ThreadMutationRouting;
@@ -3,6 +3,13 @@ import { threadEndedByShepherd } from "../../comments/marker.mjs";
3
3
  function dedupeIds(ids) {
4
4
  return [...new Set(ids)];
5
5
  }
6
+ export function canResolveOutdatedBotWithoutLocation(thread, botUsernames) {
7
+ return (!thread.isResolved &&
8
+ thread.isOutdated &&
9
+ (thread.path === null || thread.line === null) &&
10
+ isConfiguredBotAuthor(thread, botUsernames) &&
11
+ thread.viewerCanResolve === true);
12
+ }
6
13
  export function buildThreadMutationRouting(threads, botUsernames, ruleAutoResolveThreadIds) {
7
14
  const isOrdinaryHuman = (thread) => isHumanAuthor(thread) && !isConfiguredBotAuthor(thread, botUsernames);
8
15
  const replyThreadIds = dedupeIds(threads
@@ -13,6 +13,7 @@ export interface JournalResult {
13
13
  sectionExisted: boolean;
14
14
  dryRun: boolean;
15
15
  previewBody?: string;
16
+ /** @deprecated Explicit journal requests now rely on GitHub's mutation response. */
16
17
  authorizationSkipped?: "denied-or-unverifiable";
17
18
  }
18
19
  /** @deprecated Hidden implementation for standalone `journal`; use `apply journal`. */
@@ -12,17 +12,8 @@ export async function runJournal(opts) {
12
12
  if (!prNumber) {
13
13
  throw new Error("PR number is required: no PR number provided and none found for current branch");
14
14
  }
15
- const { nodeId, body, viewerCanUpdate } = await getPullRequestBody(prNumber, owner, name);
15
+ const { nodeId, body } = await getPullRequestBody(prNumber, owner, name);
16
16
  const { body: newBody, mutated, sectionExisted } = appendJournalItem(body, item);
17
- if (mutated && !opts.dryRun && viewerCanUpdate !== true) {
18
- return {
19
- prNumber,
20
- mutated: false,
21
- sectionExisted,
22
- dryRun: false,
23
- authorizationSkipped: "denied-or-unverifiable",
24
- };
25
- }
26
17
  if (mutated && !opts.dryRun) {
27
18
  await updatePullRequestBody(nodeId, newBody);
28
19
  }
@@ -1,4 +1,4 @@
1
- import type { ResolveRateLimitStop } from "../comments/rate-limit.mts";
1
+ import { type ResolveRateLimitStop } from "../comments/rate-limit.mts";
2
2
  import type { GlobalOptions } from "../types.mts";
3
3
  export interface MarkFilesAsViewedOptions extends GlobalOptions {
4
4
  prNumber?: number;
@@ -21,6 +21,7 @@ export interface MarkFilesAsViewedResult {
21
21
  errors: string[];
22
22
  rateLimit?: ResolveRateLimitStop;
23
23
  unmarkedPaths?: string[];
24
+ /** @deprecated Explicit file-view requests are attempted; GitHub authorizes the mutation. */
24
25
  authorizationSkipped?: "unverifiable";
25
26
  }
26
27
  /** @deprecated Hidden implementation for `mark-files-as-viewed`; use `apply files`. */
@@ -1,6 +1,7 @@
1
1
  /* eslint-disable max-lines */
2
- import { graphql, getCurrentPrNumber, getRepoInfo } from "../github/client.mjs";
2
+ import { graphql, graphqlWithRateLimit, getCurrentPrNumber, getRepoInfo, } from "../github/client.mjs";
3
3
  import { paginateForward } from "../github/pagination.mjs";
4
+ import { isRateLimitMessage, rateLimitFromError, rateLimitFromGraphQlResult, } from "../comments/rate-limit.mjs";
4
5
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
5
6
  const FILES_QUERY = `query PullRequestFiles($owner: String!, $repo: String!, $pr: Int!, $filesCursor: String) {
6
7
  _shepherdRateLimit: rateLimit {
@@ -29,6 +30,8 @@ const FILES_QUERY = `query PullRequestFiles($owner: String!, $repo: String!, $pr
29
30
  }
30
31
  }`;
31
32
  const TEST_FILE_RE = /(^|\/)(tests?|__tests__|spec)(\/|$)|\.(test|spec)\.[cm]?[jt]sx?$|_tests?\.rs$|(^|\/)tests?\.rs$/i;
33
+ // Keep mutation batches small so rate-limit stops leave an ordered pending list.
34
+ const MARK_FILES_CHUNK_SIZE = 10;
32
35
  /** @deprecated Hidden implementation for `mark-files-as-viewed`; use `apply files`. */
33
36
  export async function runMarkFilesAsViewed(opts) {
34
37
  const repo = opts.targetRepository ?? (await getRepoInfo());
@@ -58,12 +61,121 @@ export async function runMarkFilesAsViewed(opts) {
58
61
  missingPaths: selected.missingPaths,
59
62
  unmatchedSelectors: selected.unmatchedSelectors,
60
63
  errors: [],
61
- ...(selected.pathsToMark.length > 0 && { authorizationSkipped: "unverifiable" }),
62
64
  };
63
- // GitHub exposes no exact viewer capability for markFileAsViewed. Repository role and
64
- // viewerCanEditFiles describe different operations, so this command fails closed.
65
+ await markFilesAsViewed(fetched.pullRequestId, selected.pathsToMark, result);
65
66
  return result;
66
67
  }
68
+ async function markFilesAsViewed(pullRequestId, paths, result) {
69
+ for (let offset = 0; offset < paths.length; offset += MARK_FILES_CHUNK_SIZE) {
70
+ const chunk = paths.slice(offset, offset + MARK_FILES_CHUNK_SIZE);
71
+ // eslint-disable-next-line no-await-in-loop
72
+ const pendingPaths = await markFilesChunk(pullRequestId, chunk, result, offset + MARK_FILES_CHUNK_SIZE < paths.length);
73
+ if (pendingPaths === null)
74
+ continue;
75
+ const unmarkedPaths = [...pendingPaths, ...paths.slice(offset + chunk.length)];
76
+ if (unmarkedPaths.length > 0)
77
+ result.unmarkedPaths = unmarkedPaths;
78
+ return;
79
+ }
80
+ }
81
+ /** Returns pending paths when a rate limit stops further mutation batches. */
82
+ async function markFilesChunk(pullRequestId, paths, result, hasPendingAfter) {
83
+ try {
84
+ const response = await graphqlWithRateLimit(buildMarkFilesMutation(pullRequestId, paths), {}, { allowPartialData: true });
85
+ const errors = (response.errors ?? []);
86
+ const rateLimit = rateLimitFromGraphQlResult(errors.map((error) => error.message), {
87
+ rateLimit: response.rateLimit,
88
+ retryAfterSeconds: response.retryAfterSeconds,
89
+ stopOnZeroRemaining: hasPendingAfter,
90
+ });
91
+ const classifiedErrors = classifyMarkFileErrors(errors, paths.length);
92
+ const pendingPaths = recordMarkFileResults(paths, response.data, errors, classifiedErrors, rateLimit, result);
93
+ return completeMarkFilesChunk(rateLimit, pendingPaths, result);
94
+ }
95
+ catch (error) {
96
+ return recordMarkFilesTransportError(error, paths, result);
97
+ }
98
+ }
99
+ function classifyMarkFileErrors(errors, pathCount) {
100
+ const aliasesWithNonRateErrors = new Set();
101
+ const aliasesWithRateLimitErrors = new Set();
102
+ const unscopedNonRateMessages = [];
103
+ for (const error of errors) {
104
+ const aliasIndex = markFileErrorAliasIndex(error);
105
+ if (aliasIndex === undefined || aliasIndex >= pathCount) {
106
+ if (!isRateLimitMessage(error.message))
107
+ unscopedNonRateMessages.push(error.message);
108
+ continue;
109
+ }
110
+ if (isRateLimitMessage(error.message))
111
+ aliasesWithRateLimitErrors.add(aliasIndex);
112
+ else
113
+ aliasesWithNonRateErrors.add(aliasIndex);
114
+ }
115
+ return { aliasesWithNonRateErrors, aliasesWithRateLimitErrors, unscopedNonRateMessages };
116
+ }
117
+ function recordMarkFileResults(paths, data, errors, classifiedErrors, rateLimit, result) {
118
+ const pendingPaths = [];
119
+ for (let index = 0; index < paths.length; index += 1) {
120
+ const path = paths[index];
121
+ if (data[`f${index}`] != null) {
122
+ result.markedPaths.push(path);
123
+ continue;
124
+ }
125
+ const messages = errorsForMarkFileAlias(errors, index);
126
+ const hasRateLimitError = classifiedErrors.aliasesWithRateLimitErrors.has(index);
127
+ const nonRateMessages = classifiedErrors.aliasesWithNonRateErrors.has(index)
128
+ ? messages.filter((message) => !isRateLimitMessage(message))
129
+ : classifiedErrors.unscopedNonRateMessages;
130
+ if (nonRateMessages.length > 0) {
131
+ for (const message of nonRateMessages)
132
+ result.errors.push(`${path}: ${message}`);
133
+ }
134
+ else if (!rateLimit) {
135
+ result.errors.push(`${path}: ${messages[0] ?? "mark returned null"}`);
136
+ }
137
+ if (rateLimit && (hasRateLimitError || nonRateMessages.length === 0))
138
+ pendingPaths.push(path);
139
+ }
140
+ return pendingPaths;
141
+ }
142
+ function completeMarkFilesChunk(rateLimit, pendingPaths, result) {
143
+ if (!rateLimit)
144
+ return null;
145
+ result.errors.push(`rate limit: ${rateLimit.message}`);
146
+ result.rateLimit = rateLimit;
147
+ return pendingPaths;
148
+ }
149
+ function recordMarkFilesTransportError(error, paths, result) {
150
+ const message = error instanceof Error ? error.message : String(error);
151
+ const stop = rateLimitFromError(error, message);
152
+ if (stop) {
153
+ result.errors.push(`rate limit: ${stop.message}`);
154
+ result.rateLimit = stop;
155
+ return paths;
156
+ }
157
+ for (const path of paths)
158
+ result.errors.push(`${path}: ${message}`);
159
+ return null;
160
+ }
161
+ function buildMarkFilesMutation(pullRequestId, paths) {
162
+ const operations = paths.map((path, index) => ` f${index}: markFileAsViewed(input: { pullRequestId: ${JSON.stringify(pullRequestId)}, path: ${JSON.stringify(path)} }) { clientMutationId }`);
163
+ return `mutation MarkFilesAsViewed {\n${operations.join("\n")}\n}`;
164
+ }
165
+ function markFileErrorAliasIndex(error) {
166
+ if (!Array.isArray(error.path))
167
+ return undefined;
168
+ const alias = error.path.find((part) => typeof part === "string" && /^f\d+$/.test(part));
169
+ if (typeof alias !== "string")
170
+ return undefined;
171
+ const index = Number.parseInt(alias.slice(1), 10);
172
+ return Number.isNaN(index) ? undefined : index;
173
+ }
174
+ function errorsForMarkFileAlias(errors, index) {
175
+ return errors
176
+ .filter((error) => markFileErrorAliasIndex(error) === index)
177
+ .map((error) => error.message);
178
+ }
67
179
  async function fetchPullRequestFiles(pr, repo) {
68
180
  const first = await graphql(FILES_QUERY, {
69
181
  owner: repo.owner,
@@ -27,45 +27,24 @@ export async function runResolveMutate(opts) {
27
27
  const humanReviewIds = new Set([...data.reviewSummaries, ...data.approvedReviews, ...data.changesRequestedReviews]
28
28
  .filter((r) => isHumanAuthor(r) && !isConfiguredBotAuthor(r, botUsernames))
29
29
  .map((r) => r.id));
30
- const replyAuthorizedIds = new Set(data.reviewThreads
31
- .filter((thread) => thread.viewerCanReply === true)
32
- .map((thread) => thread.id));
33
- const resolveAuthorizedIds = new Set(data.reviewThreads
34
- .filter((thread) => thread.viewerCanResolve === true)
35
- .map((thread) => thread.id));
36
- const minimizeAuthorizedIds = new Set([
37
- ...data.comments
38
- .filter((comment) => comment.viewerCanMinimize === true)
39
- .map((comment) => comment.id),
40
- ...data.reviewSummaries
41
- .filter((review) => review.viewerCanMinimize === true)
42
- .map((review) => review.id),
43
- ...data.approvedReviews
44
- .filter((review) => review.viewerCanMinimize === true)
45
- .map((review) => review.id),
46
- ]);
47
- const requestedReplyIds = new Set((opts.replyThreadIds ?? []).filter((id) => replyAuthorizedIds.has(id)));
30
+ // Iterate uses viewer capability fields while deciding which commands to
31
+ // print. Once a caller explicitly runs apply, GitHub's mutation response is
32
+ // authoritative and this path must not second-guess that intent.
33
+ const requestedReplyIds = new Set(opts.replyThreadIds ?? []);
48
34
  const allowedViewerHumanResolveIds = new Set(data.reviewThreads
49
35
  .filter((thread) => isViewerAuthoredHuman(thread, botUsernames) &&
50
36
  (requestedReplyIds.has(thread.id) || threadEndedByShepherd(thread)))
51
37
  .map((thread) => thread.id));
52
- const resolveThreadIds = (opts.resolveThreadIds ?? []).filter((id) => (!humanThreadIds.has(id) || allowedViewerHumanResolveIds.has(id)) &&
53
- resolveAuthorizedIds.has(id));
38
+ const resolveThreadIds = (opts.resolveThreadIds ?? []).filter((id) => !humanThreadIds.has(id) || allowedViewerHumanResolveIds.has(id));
54
39
  const skippedHumanResolves = (opts.resolveThreadIds ?? []).filter((id) => humanThreadIds.has(id) && !allowedViewerHumanResolveIds.has(id));
55
- const skippedUnauthorizedResolves = (opts.resolveThreadIds ?? []).filter((id) => (!humanThreadIds.has(id) || allowedViewerHumanResolveIds.has(id)) &&
56
- !resolveAuthorizedIds.has(id));
57
- const replyThreadIds = opts.replyThreadIds?.filter((id) => humanThreadIds.has(id) && replyAuthorizedIds.has(id));
40
+ const replyThreadIds = opts.replyThreadIds?.filter((id) => humanThreadIds.has(id));
58
41
  const skippedNonHumanReplies = (opts.replyThreadIds ?? []).filter((id) => !humanThreadIds.has(id));
59
- const skippedUnauthorizedReplies = (opts.replyThreadIds ?? []).filter((id) => humanThreadIds.has(id) && !replyAuthorizedIds.has(id));
60
- const minimizeCommentIds = (opts.minimizeCommentIds ?? []).filter((id) => !humanCommentIds.has(id) && !humanReviewIds.has(id) && minimizeAuthorizedIds.has(id));
42
+ const minimizeCommentIds = (opts.minimizeCommentIds ?? []).filter((id) => !humanCommentIds.has(id) && !humanReviewIds.has(id));
61
43
  const skippedHumanMinimizes = (opts.minimizeCommentIds ?? []).filter((id) => humanCommentIds.has(id) || humanReviewIds.has(id));
62
- const skippedUnauthorizedMinimizes = (opts.minimizeCommentIds ?? []).filter((id) => !humanCommentIds.has(id) && !humanReviewIds.has(id) && !minimizeAuthorizedIds.has(id));
63
- const dismissReviewIds = (opts.dismissReviewIds ?? []).filter((id) => !humanReviewIds.has(id) &&
64
- data.changesRequestedReviews.some((review) => review.id === id) &&
65
- data.viewerAuthorization?.viewerCanAdminister === true);
44
+ const dismissReviewIds = (opts.dismissReviewIds ?? []).filter((id) => !humanReviewIds.has(id) && data.changesRequestedReviews.some((review) => review.id === id));
66
45
  const skippedHumanDismissals = (opts.dismissReviewIds ?? []).filter((id) => humanReviewIds.has(id));
67
- const skippedUnauthorizedDismissals = (opts.dismissReviewIds ?? []).filter((id) => !humanReviewIds.has(id) && !dismissReviewIds.includes(id));
68
- const hasAuthorizedMutation = resolveThreadIds.length > 0 ||
46
+ const skippedIneligibleDismissals = (opts.dismissReviewIds ?? []).filter((id) => !humanReviewIds.has(id) && !dismissReviewIds.includes(id));
47
+ const hasMutation = resolveThreadIds.length > 0 ||
69
48
  (replyThreadIds?.length ?? 0) > 0 ||
70
49
  minimizeCommentIds.length > 0 ||
71
50
  dismissReviewIds.length > 0;
@@ -75,7 +54,7 @@ export async function runResolveMutate(opts) {
75
54
  minimizeCommentIds,
76
55
  dismissReviewIds,
77
56
  dismissMessage: opts.dismissMessage,
78
- requireSha: hasAuthorizedMutation ? opts.requireSha : undefined,
57
+ requireSha: hasMutation ? opts.requireSha : undefined,
79
58
  });
80
59
  if (skippedHumanResolves.length > 0)
81
60
  result.skippedHumanResolves = skippedHumanResolves;
@@ -85,14 +64,12 @@ export async function runResolveMutate(opts) {
85
64
  result.skippedHumanDismissals = skippedHumanDismissals;
86
65
  if (skippedNonHumanReplies.length > 0)
87
66
  result.skippedNonHumanReplies = skippedNonHumanReplies;
88
- if (skippedUnauthorizedReplies.length > 0)
89
- result.skippedUnauthorizedReplies = skippedUnauthorizedReplies;
90
- if (skippedUnauthorizedResolves.length > 0)
91
- result.skippedUnauthorizedResolves = skippedUnauthorizedResolves;
92
- if (skippedUnauthorizedMinimizes.length > 0)
93
- result.skippedUnauthorizedMinimizes = skippedUnauthorizedMinimizes;
94
- if (skippedUnauthorizedDismissals.length > 0)
95
- result.skippedUnauthorizedDismissals = skippedUnauthorizedDismissals;
67
+ if (skippedIneligibleDismissals.length > 0) {
68
+ result.skippedDismissals = [
69
+ ...(result.skippedDismissals ?? []),
70
+ ...skippedIneligibleDismissals,
71
+ ];
72
+ }
96
73
  if (opts.dismissMessage) {
97
74
  const markedMessage = addPrShepherdMarker(opts.dismissMessage);
98
75
  await Promise.all(result.repliedThreads.map((id) => {
@@ -12,9 +12,13 @@ export interface ResolveResult {
12
12
  skippedHumanMinimizes?: string[];
13
13
  skippedHumanDismissals?: string[];
14
14
  skippedNonHumanReplies?: string[];
15
+ /** @deprecated Direct apply requests now rely on GitHub's mutation response. */
15
16
  skippedUnauthorizedReplies?: string[];
17
+ /** @deprecated Direct apply requests now rely on GitHub's mutation response. */
16
18
  skippedUnauthorizedResolves?: string[];
19
+ /** @deprecated Direct apply requests now rely on GitHub's mutation response. */
17
20
  skippedUnauthorizedMinimizes?: string[];
21
+ /** @deprecated Direct apply requests now rely on GitHub's mutation response. */
18
22
  skippedUnauthorizedDismissals?: string[];
19
23
  rateLimit?: ResolveRateLimitStop;
20
24
  unrepliedThreads?: string[];
@@ -24,6 +24,12 @@ function isCommentedDismissError(message) {
24
24
  function dismissReviewNonDismissibleMessage(id) {
25
25
  return `Not dismissed: ${id} is a COMMENTED review. Use --minimize-comment-ids instead; --dismiss-review-ids is only for CHANGES_REQUESTED reviews.`;
26
26
  }
27
+ function mutationErrorMessage(errors, alias) {
28
+ const messages = errors
29
+ .filter((error) => Array.isArray(error.path) && error.path.includes(alias))
30
+ .map((error) => error.message);
31
+ return messages.length > 0 ? messages.join("; ") : undefined;
32
+ }
27
33
  export async function applyResolveOptions(pr, repo, opts) {
28
34
  const resolveThreadIds = dedupeIds(opts.resolveThreadIds ?? []);
29
35
  const replyThreadIds = dedupeIds(opts.replyThreadIds ?? []);
@@ -168,21 +174,21 @@ async function bulkApplyChunk(resolveIds, replyIds, minimizeIds, dismissIds, dis
168
174
  if (p?.comment?.id)
169
175
  result.repliedThreads.push(id);
170
176
  else if (!suppressCurrentChunkErrors)
171
- result.errors.push(`${id}: reply returned null or comment not created`);
177
+ result.errors.push(`${id}: ${mutationErrorMessage(graphQlErrors, `p${i}`) ?? "reply returned null or comment not created"}`);
172
178
  }
173
179
  for (let i = 0; i < resolveIds.length; i++) {
174
180
  const r = data[`r${i}`];
175
181
  if (r?.thread?.isResolved === true)
176
182
  result.resolvedThreads.push(resolveIds[i]);
177
183
  else if (!suppressCurrentChunkErrors)
178
- result.errors.push(`${resolveIds[i]}: resolve returned null or thread not resolved`);
184
+ result.errors.push(`${resolveIds[i]}: ${mutationErrorMessage(graphQlErrors, `r${i}`) ?? "resolve returned null or thread not resolved"}`);
179
185
  }
180
186
  for (let i = 0; i < minimizeIds.length; i++) {
181
187
  const m = data[`m${i}`];
182
188
  if (m?.minimizedComment?.isMinimized === true)
183
189
  result.minimizedComments.push(minimizeIds[i]);
184
190
  else if (!suppressCurrentChunkErrors)
185
- result.errors.push(`${minimizeIds[i]}: minimize returned null or comment not minimized`);
191
+ result.errors.push(`${minimizeIds[i]}: ${mutationErrorMessage(graphQlErrors, `m${i}`) ?? "minimize returned null or comment not minimized"}`);
186
192
  }
187
193
  const singleDismiss = dismissIds.length === 1;
188
194
  const commentedDismissErrorIndexes = new Set();
@@ -204,7 +210,7 @@ async function bulkApplyChunk(resolveIds, replyIds, minimizeIds, dismissIds, dis
204
210
  else if (!suppressCurrentChunkErrors)
205
211
  result.errors.push(commentedDismissErrorIndexes.has(i) || (singleDismiss && hasUnmappedCommentedDismissError)
206
212
  ? dismissReviewNonDismissibleMessage(dismissIds[i])
207
- : `${dismissIds[i]}: dismiss returned null`);
213
+ : `${dismissIds[i]}: ${mutationErrorMessage(graphQlErrors, `d${i}`) ?? "dismiss returned null"}`);
208
214
  }
209
215
  if (rateLimitStop) {
210
216
  result.errors.push(`rate limit: ${rateLimitStop.message}`);
@@ -30,7 +30,6 @@ export declare function getPrHeadSha(pr: number, owner: string, name: string): P
30
30
  export declare function getPullRequestBody(pr: number, owner: string, name: string): Promise<{
31
31
  nodeId: string;
32
32
  body: string;
33
- viewerCanUpdate?: boolean;
34
33
  }>;
35
34
  /** Overwrites the PR body. */
36
35
  export declare function updatePullRequestBody(pullRequestId: string, body: string): Promise<void>;
@@ -80,9 +80,6 @@ export async function getPullRequestBody(pr, owner, name) {
80
80
  return {
81
81
  nodeId: pullRequest.id,
82
82
  body: pullRequest.body ?? "",
83
- ...(pullRequest.viewerCanUpdate !== undefined && {
84
- viewerCanUpdate: pullRequest.viewerCanUpdate,
85
- }),
86
83
  };
87
84
  }
88
85
  /** Overwrites the PR body. */
@@ -11,7 +11,6 @@ query GetPrBody($owner: String!, $repo: String!, $pr: Int!) {
11
11
  pullRequest(number: $pr) {
12
12
  id
13
13
  body
14
- viewerCanUpdate
15
14
  }
16
15
  }
17
16
  }
@@ -41,7 +41,7 @@ const reviewMutationsOperationSchema = z.object({
41
41
  const markFilesViewedOperationSchema = z.object({
42
42
  type: z.literal("mark_files_viewed"),
43
43
  files: z.array(z.string().min(1)).optional(),
44
- tests: z.boolean().optional().describe("Select changed test files; no mutation occurs."),
44
+ tests: z.boolean().optional().describe("Select changed test files to mark as viewed."),
45
45
  matchPatterns: z.array(z.string().min(1)).optional(),
46
46
  });
47
47
  const appendJournalOperationSchema = z.object({
@@ -90,7 +90,7 @@ export function createPrShepherdMcpServer(options = {}) {
90
90
  },
91
91
  }, async (input) => runTool(() => shepherd.iterate(requireRepositoryQualifiedPr(input)), formatIterateResult));
92
92
  server.registerTool("apply", {
93
- description: "Apply ordered authorized review and journal operations, or run selection-only file-view diagnostics, after prevalidation.",
93
+ description: "Apply ordered review, journal, and file-view operations after prevalidation; explicit requests rely on GitHub's mutation response.",
94
94
  inputSchema: applyInputSchema,
95
95
  annotations: {
96
96
  readOnlyHint: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.46.0",
3
+ "version": "0.46.2",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
5
5
  "keywords": [
6
6
  "automation",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.46.0",
3
+ "version": "0.46.2",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "pr-shepherd": {
4
4
  "command": "npx",
5
- "args": ["--yes", "--package", "pr-shepherd@0.46.0", "pr-shepherd-mcp"]
5
+ "args": ["--yes", "--package", "pr-shepherd@0.46.2", "pr-shepherd-mcp"]
6
6
  }
7
7
  }
8
8
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pr-shepherd": {
3
3
  "command": "npx",
4
- "args": ["--yes", "--package", "pr-shepherd@0.46.0", "pr-shepherd-mcp"]
4
+ "args": ["--yes", "--package", "pr-shepherd@0.46.2", "pr-shepherd-mcp"]
5
5
  }
6
6
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: mark-files-as-viewed
3
- description: "Select changed pull-request files and report viewed-state authorization diagnostics with pr-shepherd (MCP or CLI)."
3
+ description: "Mark selected changed pull-request files as viewed with pr-shepherd (MCP or CLI)."
4
4
  user-invocable: true
5
5
  argument-hint: "[PR number or URL] [files|tests|--tests|--match REGEX]"
6
6
  allowed-tools: ["MCP", "Bash", "Read", "Grep", "Glob"]
@@ -8,7 +8,7 @@ allowed-tools: ["MCP", "Bash", "Read", "Grep", "Glob"]
8
8
 
9
9
  # mark-files-as-viewed
10
10
 
11
- Thin dispatcher for selection-only file-view authorization diagnostics. GitHub exposes no exact viewer capability for marking a PR file viewed, so this operation never recommends or attempts that mutation. Use the MCP server when it is available; otherwise use the CLI.
11
+ Thin dispatcher for explicit file-view mutations. The operation attempts `markFileAsViewed` for selected files and surfaces GitHub's per-file results. Use the MCP server when it is available; otherwise use the CLI.
12
12
 
13
13
  ## Arguments: $ARGUMENTS
14
14
 
@@ -64,7 +64,7 @@ When several bullets share one runId (matrix jobs from the same run), the `rerun
64
64
 
65
65
  Applies to every `apply review:` / `resolve-only:` command the CLI prints. Covers only what stays safe if you run the printed command **unmodified** — `$HEAD_SHA`/`$DISMISS_MESSAGE` substitution remains a separate CLI-printed step because the command is unsafe by default without those placeholders.
66
66
 
67
- The CLI only includes IDs whose per-object GitHub viewer capability authorizes the corresponding action, and `apply review` repeats that authorization check immediately before mutating. Do not reconstruct omitted review reply, thread resolution, or bot-review dismissal IDs and do not hand them off: denied or unverifiable review mutations are one-look skips that Shepherd suppresses until the item is edited. Threads without a path or line follow the same skip rule.
67
+ The CLI only includes IDs whose per-object GitHub viewer capability and semantic routing authorize the corresponding generated action. Direct `apply review` honors those emitted IDs without a second authorization preflight and surfaces GitHub's per-operation result. Do not reconstruct omitted review reply, thread resolution, or bot-review dismissal IDs and do not hand them off: denied or unverifiable generated mutations are one-look skips that Shepherd suppresses until the item is edited. Active threads without a path or line follow the same skip rule; authorized outdated bot threads are emitted for resolution by thread ID even when GitHub clears their source line.
68
68
 
69
69
  - Run every generated `apply review:` / `resolve-only:` command even when no code change is warranted. The command records the agent's disposition of the included review items; skipping it leaves bot threads active and can eventually trigger `fix-thrash`.
70
70
  - Never add first-look-only or check-annotation IDs to `--reply-thread-ids`, `--resolve-thread-ids`, `--dismiss-review-ids`, or `--minimize-comment-ids` — those flags are pre-populated by the CLI.