pr-shepherd 0.7.0 → 0.8.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 (58) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +37 -302
  3. package/bin/checks/classify.mjs +5 -4
  4. package/bin/checks/triage.mjs +76 -62
  5. package/bin/cli/args.mjs +29 -61
  6. package/bin/cli/exit-codes.mjs +39 -0
  7. package/bin/cli/fix-formatter.mjs +76 -0
  8. package/bin/cli/formatters.mjs +108 -0
  9. package/bin/cli/handlers.mjs +138 -0
  10. package/bin/cli/iterate-formatter.mjs +78 -0
  11. package/bin/cli-parser.iterate-fixtures.mjs +65 -0
  12. package/bin/cli-parser.mjs +110 -0
  13. package/bin/commands/check-status.mjs +35 -0
  14. package/bin/commands/check.mjs +14 -61
  15. package/bin/commands/commit-suggestion.mjs +159 -0
  16. package/bin/commands/iterate/classify.mjs +77 -0
  17. package/bin/commands/iterate/escalate.mjs +124 -0
  18. package/bin/commands/iterate/fix-code.mjs +97 -0
  19. package/bin/commands/iterate/helpers.mjs +103 -0
  20. package/bin/commands/iterate/index.mjs +122 -0
  21. package/bin/commands/iterate/render.mjs +119 -0
  22. package/bin/commands/iterate/stall.mjs +65 -0
  23. package/bin/commands/iterate/steps.mjs +31 -0
  24. package/bin/commands/iterate.mjs +2 -628
  25. package/bin/commands/monitor.mjs +78 -0
  26. package/bin/commands/ready-delay.mjs +3 -4
  27. package/bin/commands/resolve-instructions.mjs +39 -0
  28. package/bin/commands/resolve.mjs +34 -3
  29. package/bin/commands/status.mjs +7 -0
  30. package/bin/comments/resolve.mjs +1 -1
  31. package/bin/config/load.mjs +17 -113
  32. package/bin/config.json +10 -22
  33. package/bin/github/batch-parsers.mjs +140 -0
  34. package/bin/github/batch-raw-types.mjs +2 -0
  35. package/bin/github/batch.mjs +34 -129
  36. package/bin/github/client.mjs +47 -9
  37. package/bin/github/gql/batch-pr.gql +20 -0
  38. package/bin/github/http.mjs +32 -30
  39. package/bin/index.mjs +15 -2
  40. package/bin/merge-status/derive.mjs +11 -11
  41. package/bin/reporters/agent.mjs +13 -4
  42. package/bin/reporters/check-instructions.mjs +65 -0
  43. package/bin/reporters/json.mjs +3 -2
  44. package/bin/reporters/text.mjs +108 -61
  45. package/bin/{cache → state}/fix-attempts.mjs +3 -3
  46. package/bin/state/iterate-stall.mjs +74 -0
  47. package/bin/suggestions/parse.mjs +119 -0
  48. package/bin/suggestions/patch.mjs +52 -0
  49. package/bin/types/github.mjs +2 -0
  50. package/bin/types/iterate.mjs +2 -0
  51. package/bin/types/report.mjs +2 -0
  52. package/bin/types.mjs +3 -1
  53. package/package.json +3 -3
  54. package/plugin/skills/check/SKILL.md +15 -48
  55. package/plugin/skills/monitor/SKILL.md +11 -64
  56. package/plugin/skills/resolve/SKILL.md +10 -76
  57. package/bin/cache/file-cache.mjs +0 -79
  58. package/bin/cli.mjs +0 -286
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: monitor
3
3
  description: "Start continuous CI monitoring — marks PR ready for review when all checks pass"
4
- argument-hint: "[PR number or URL] [every <interval>] [--ready-delay <duration>]"
4
+ argument-hint: "[PR number or URL]"
5
5
  user-invocable: true
6
6
  allowed-tools:
7
7
  ["Bash", "Read", "Grep", "Edit", "Write", "Glob", "Skill", "CronCreate", "CronList", "CronDelete"]
@@ -9,72 +9,19 @@ allowed-tools:
9
9
 
10
10
  # pr-shepherd monitor — Continuous PR Monitor
11
11
 
12
- > Action reference (all 8 actions, JSON fields, examples): [docs/actions.md](../../../docs/actions.md)
13
-
14
12
  ## Arguments: $ARGUMENTS
15
13
 
16
- ## Resolve PR number
17
-
18
- 1. Strip any trailing `every <N> <unit>` interval clause from `$ARGUMENTS` first.
19
- 2. Extract `--ready-delay <duration>` if present (e.g. `--ready-delay 15m`). Default: `10m`. Keep the raw duration string (e.g. `10m`) — do **not** convert to seconds.
20
- 3. If the remaining text contains a PR number or GitHub PR URL, extract the number.
21
- 4. Otherwise, infer: `gh pr list --head "$(git rev-parse --abbrev-ref HEAD)" --json number --jq '.[0].number'`
22
- 5. If no PR found, report an error and stop.
23
-
24
- ## Start the loop
25
-
26
- **Before starting:** List existing cron jobs with `CronList`.
27
-
28
- - If any job's prompt contains `# pr-shepherd-loop:pr=<PR_NUMBER>`, **do not cancel+recreate** — perform one iteration now by following the logic in the CronCreate prompt below, then return.
29
-
30
- **Run the loop:**
31
-
32
- Invoke `/loop <INTERVAL> --max-turns 50 --expires 8h` via the Skill tool. Use the interval from the argument if provided (e.g. `every 30 minutes` → `30m`), otherwise use `4m`. The loop prompt should be:
33
-
34
- ````
35
- # pr-shepherd-loop:pr=<PR_NUMBER>
36
-
37
- **IMPORTANT — recurrence rules:**
38
- - **Do NOT call `ScheduleWakeup` or `/loop`.** This session is fired by a recurring cron job. Either call creates a duplicate runner, causing concurrent git operations and `.git/index.lock` collisions.
39
- - End the turn cleanly after completing the actions below. The cron job handles the next fire.
40
-
41
- **Self-dedup:** Run `CronList`. If more than one job contains `# pr-shepherd-loop:pr=<PR_NUMBER>`, keep the lowest job ID and `CronDelete` the rest (ignore errors — a concurrent runner may have already deleted them).
42
-
43
- Run in a single Bash call:
44
- npx pr-shepherd iterate <PR_NUMBER> --ready-delay <READY_DELAY_DURATION> --no-cache --last-push-time "$(git log -1 --format=%ct HEAD)"
45
- (`<READY_DELAY_DURATION>` is the raw duration string, e.g. `10m` — never a bare number of seconds)
46
-
47
- Exit codes 0–3 are all valid. If the command crashes (non-zero exit, no markdown output starting with `# PR #<N> [`), log the first line of stderr and continue — do not cancel the loop. The next cron fire will retry.
48
-
49
- The output is Markdown. The first line is an H1 heading of the form `# PR #<N> [<ACTION>]`. Read the `[<ACTION>]` tag to decide what to do (see [docs/actions.md](../../../docs/actions.md) for full output shapes):
50
-
51
- - `[COOLDOWN]` | `[WAIT]` | `[RERUN_CI]` | `[MARK_READY]` → print the output, continue.
52
- - `[CANCEL]` → print the output, then invoke `/loop cancel` via Skill tool and stop.
53
- - `[REBASE]` → print the output, then extract the shell script from the ` ```bash ` fenced block and run it in Bash.
54
- - `[ESCALATE]` → print the output, then invoke `/loop cancel` via Skill tool and stop.
55
- - `[FIX_CODE]` → follow the numbered items under `## Instructions` in order. Only run a `resolve` command if those instructions explicitly include a "Run the `resolve:` command…" step, or if the provided resolve command includes mutation flags. In that case, the `resolve` bullet under `## Rebase` holds the final resolve command inside backticks — strip the backticks and run it, substituting `"$HEAD_SHA"` with the pushed SHA and `$DISMISS_MESSAGE` with a one-sentence description of the actual fix (never generic text like "address review comments"). **Never manually run `gh run cancel` after your push** — stale runs listed under `## Cancelled runs` were already cancelled by the CLI (using the pre-push run IDs, as required by the "cancel CI runs before fixing and pushing" rule); running it again post-push would hit the NEW runs your push just triggered. If you run the resolve command, stop this iteration afterward — CI needs time before the next tick.
56
-
57
- ````
58
-
59
- **Do NOT call ScheduleWakeup** — the cron job handles its own recurrence. Calling ScheduleWakeup with a `/loop` prompt would create a duplicate cron job on the next fire.
60
-
61
- The default 4-minute interval is chosen for two reasons:
62
-
63
- 1. CI checks typically take 2-3 minutes to complete so concurrent agents won't stack.
64
- 2. 4 minutes keeps iterations within the 5-minute prompt cache TTL.
65
-
66
- ## Each iteration
67
-
68
- The loop prompt above handles each iteration directly — no subagent is spawned. The same iterate command can be run manually at any time:
14
+ ## Steps
69
15
 
70
- ```bash
71
- npx pr-shepherd iterate <PR_NUMBER> --ready-delay <READY_DELAY_DURATION> --no-cache --last-push-time "$(git log -1 --format=%ct HEAD)"
72
- ```
16
+ 1. **Resolve PR number:**
17
+ - If `$ARGUMENTS` contains a PR number or GitHub PR URL, extract the number.
18
+ - Otherwise, infer: `gh pr list --head "$(git rev-parse --abbrev-ref HEAD)" --json number --jq '.[0].number'`
19
+ - If no PR found, report an error and stop.
73
20
 
74
- To stop monitoring manually, use `/loop cancel` or close the session.
21
+ 2. **Run the bootstrap command and follow its instructions:**
75
22
 
76
- ## Handling multiple PRs
23
+ ```bash
24
+ npx pr-shepherd monitor <PR_NUMBER>
25
+ ```
77
26
 
78
- To monitor several PRs simultaneously, run `/pr-shepherd:monitor <PR>` once per PR.
79
- Each call creates its own cron job. Before creating a new loop, run `CronList`
80
- to verify a loop for that PR doesn't already exist.
27
+ Print the full output. Follow the `## Instructions` section exactly.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: resolve
3
3
  description: "Resolve all inline review comments on the current PR"
4
- argument-hint: "[PR number or URL] [--thread-id ID | --comment-id ID] [--require-sha SHA]"
4
+ argument-hint: "[PR number or URL]"
5
5
  user-invocable: true
6
6
  allowed-tools: ["Bash", "Read", "Grep", "Edit", "Write", "Glob", "Skill"]
7
7
  ---
@@ -14,89 +14,23 @@ Resolve unresolved review threads and minimize PR comments on the current PR —
14
14
 
15
15
  ## Steps
16
16
 
17
- 1. **Resolve PR number and get context:**
17
+ 1. **Resolve PR number:**
18
+ - If `$ARGUMENTS` contains a PR number or GitHub PR URL, extract the number.
19
+ - Otherwise, infer: `gh pr list --head "$(git rev-parse --abbrev-ref HEAD)" --json number --jq '.[0].number'`
20
+ - If no PR found, report an error and stop.
18
21
 
19
- Parse `$ARGUMENTS`:
20
- 1. Extract and remove any `--thread-id ID` or `--comment-id ID` flags.
21
- 2. Extract and remove any `--require-sha SHA` flag.
22
- 3. Look for a PR number or GitHub PR URL in the remaining text.
23
- 4. If not found, infer: `gh pr list --head "$(git rev-parse --abbrev-ref HEAD)" --json number --jq '.[0].number'`
24
- 5. If no PR found, report an error and stop.
25
-
26
- ```bash
27
- BRANCH=$(git rev-parse --abbrev-ref HEAD)
28
- LAST_PUSH=$(git log -1 --format=%ct HEAD)
29
- BASE_BRANCH=$(gh pr view <N> --json baseRefName --jq '.baseRefName')
30
- ```
31
-
32
- Check if the PR is already merged:
22
+ 2. **Short-circuit if merged:**
33
23
 
34
24
  ```bash
35
25
  gh pr view <N> --json state --jq '.state'
36
26
  ```
37
27
 
38
- If `MERGED`, invoke `/loop cancel` via Skill tool (to stop any active loop), output merged message, and stop.
39
-
40
- 2. **Fetch comments:**
41
-
42
- ```bash
43
- npx pr-shepherd resolve <N> --fetch --last-push-time "$LAST_PUSH" --format=json
44
- ```
45
-
46
- The CLI auto-resolves outdated threads.
47
- Parse the JSON for `actionableThreads`, `actionableComments`, `changesRequestedReviews`, `reviewSummaries`.
48
-
49
- 3. **Triage each actionable item** into exactly one of these five buckets. Before classifying, read the comment body and — for threads — the referenced file and line.
50
- - **Fixed** — already addressed in a prior commit; no new work needed.
51
- - **Actionable** — real issue, not yet fixed; proceed to step 4.
52
- - **Not relevant** — does not apply to this PR (e.g. comment is about unrelated code).
53
- - **Outdated** — refers to code that no longer exists.
54
- - **Acknowledge** — real comment, intentionally not acting on it (e.g. reviewer flagged it as "won't fix" or "not worth it," scope-out decision, deferring to a follow-up PR). Record the one-sentence reason — you will include it in the step 7 report so the user can override.
28
+ If `MERGED`, invoke `/loop cancel` via the Skill tool, output a merged message, and stop.
55
29
 
56
- Every item returned by step 2 **must** land in one of these buckets. Do not carry an item forward as "unclassified" or silently skip it. If you genuinely can't decide, that's the Acknowledge bucket with reason "unclear — flagging for human review."
57
-
58
- **Review summaries** (`reviewSummaries`): these are PR-level overview bodies from COMMENTED reviews. Bot-generated summaries (authors like `copilot-pull-request-reviewer`, `gemini-code-assist`, or other bot accounts) are almost always noise — default them to **Acknowledge** with reason "bot summary — no actionable content" unless the body explicitly calls out an unaddressed issue. Human-authored review summaries should be read carefully and classified like any other item.
59
-
60
- 4. **Fix actionable items.** For each Actionable item:
61
- - Read the relevant file(s) and apply the fix (Edit/Write tools)
62
- - Re-classify as **Fixed**
63
- - If too complex: leave as Actionable, report to user
64
-
65
- 5. **Commit and push** (only if code was changed):
66
- - `git add <file1> <file2> …` (NOT `git add -A`)
67
- - `git commit -m "<appropriate commit message>"`
68
- - If the fixes alter the PR's scope or intent, run `gh pr edit <N> --title "<new title>" --body "<new body>"` to keep the PR title and description in sync with what was committed. Skip if the existing text still accurately describes the PR.
69
- - `git fetch origin && git rebase origin/$BASE_BRANCH && git push --force-with-lease`
70
- - Cancel stale CI runs: `gh run list --branch "$BRANCH" --status in_progress --json databaseId --jq '.[].databaseId' | xargs -I{} gh run cancel {}`
71
-
72
- 6. **Resolve all verified items** — **only if at least one of the three ID lists is non-empty.** If all lists are empty, skip this step entirely (running resolve with no mutation IDs enters fetch mode as a side effect). Build the command from the non-empty ID lists; omit any flag whose list is empty. For Fixed items, this step runs only after the push; Acknowledge / Not relevant / Outdated items can be resolved without a push (and therefore without `--require-sha`).
73
-
74
- Each bucket maps to a mutation flag:
75
- - **Fixed** threads → `--resolve-thread-ids`; Fixed comments → `--minimize-comment-ids`; Fixed reviews (CHANGES_REQUESTED) → `--dismiss-review-ids --message "<what you changed>"`.
76
- - **Acknowledge / Not relevant / Outdated** threads → `--resolve-thread-ids`; same-bucket comments → `--minimize-comment-ids`; same-bucket reviews (CHANGES_REQUESTED) → `--dismiss-review-ids --message "<why you're not acting>"`.
77
- - **Review summaries** in any bucket (Fixed, Acknowledge, Not relevant, Outdated) → `--minimize-comment-ids`. Review summary IDs (`PRR_…` from `reviewSummaries`) are passed here, not to `--dismiss-review-ids`. Do not pass review summary IDs to `--dismiss-review-ids` — that flag is only for CHANGES_REQUESTED reviews.
30
+ 3. **Fetch and follow instructions:**
78
31
 
79
32
  ```bash
80
- npx pr-shepherd resolve <N> \
81
- --resolve-thread-ids <comma-separated-IDs> \
82
- --minimize-comment-ids <comma-separated-IDs> \
83
- --dismiss-review-ids <comma-separated-IDs> \
84
- --message "<specific description of the fix OR the reason you're not acting>" \
85
- --require-sha $(git rev-parse HEAD)
33
+ npx pr-shepherd resolve <N> --fetch
86
34
  ```
87
35
 
88
- `--message` belongs **only** with `--dismiss-review-ids`. Omit it entirely when not dismissing a review. When you are dismissing, write one sentence — either describing the actual fix (for Fixed) or the concrete reason for not acting (for Acknowledge). The text is sent to GitHub as the dismissal reason and is shown to the reviewer. Generic text like `"Addressed in <SHA>"` or `"address review comments"` is not acceptable.
89
-
90
- Include `--require-sha $(git rev-parse HEAD)` whenever a push happened in step 5 (it gates the whole command, not per-item — safe to mix Fixed and Acknowledge IDs under one `--require-sha`). Omit it when no code changed.
91
-
92
- 7. **Report results.** Echo the CLI's output, then append a one-line summary per Acknowledge item: `Acknowledged <threadId|commentId|reviewId> (@<author>): <reason>`. This surfaces the decisions so the user can override any that were wrong.
93
-
94
- If any fetched item was neither resolved nor acknowledged (step 3 is supposed to prevent this, but guard against it), **stop and escalate** to the user: `<N> item(s) fetched but not acted on or acknowledged — need human direction before closing`. Do not silently drop items.
95
-
96
- ## Rules
97
-
98
- - NEVER resolve **Fixed** threads before pushing the fix (use `--require-sha`). Acknowledge / Not relevant / Outdated do not require a push and omit `--require-sha`.
99
- - NEVER blindly resolve items — always read and verify first.
100
- - NEVER silently skip a fetched item. Every item must be resolved, acknowledged with a reason, or escalated.
101
- - Resolve from ALL authors — bots, AI reviewers, and humans alike.
102
- - `--message` is required when using `--dismiss-review-ids`, and must NOT be passed otherwise. The CLI throws if it is missing during dismissal. The message must describe the specific change that addressed the review or the concrete reason for not acting (e.g. `"Added null check in handler.ts:42"`, or `"Acknowledged as won't-fix — reviewer noted not worth refactoring"`); generic boilerplate like `"address review comments"` or `"Addressed in <SHA>"` is reviewer-hostile and forbidden.
36
+ Print the full output. Follow the `## Instructions` section exactly.
@@ -1,79 +0,0 @@
1
- /**
2
- * Simple filesystem-based cache for shepherd batch reads.
3
- *
4
- * Cache entries live in `${TMPDIR}/pr-shepherd-cache/<owner>-<repo>/<pr>/<shape>.json`.
5
- * TTL defaults to 5 minutes (configurable via PR_SHEPHERD_CACHE_TTL_SECONDS or --cache-ttl).
6
- *
7
- * Mutations are never cached — this module is read-path only.
8
- */
9
- import { readFile, writeFile, rename, mkdir, stat } from "node:fs/promises";
10
- import { randomUUID } from "node:crypto";
11
- import { join, dirname } from "node:path";
12
- import { tmpdir } from "node:os";
13
- import { loadConfig } from "../config/load.mjs";
14
- import { SAFE_SEGMENT } from "../util/path-segment.mjs";
15
- /**
16
- * Read a value from the cache. Returns null on miss or expiry.
17
- */
18
- export async function cacheGet(key, opts = {}) {
19
- if (opts.disabled)
20
- return null;
21
- const ttl = opts.ttlSeconds ?? ttlFromEnv() ?? loadConfig().cache.ttlSeconds;
22
- // A TTL of 0 (or negative) means "always expired" — skip the filesystem read entirely.
23
- if (ttl <= 0)
24
- return null;
25
- try {
26
- const path = resolvePath(key);
27
- const stats = await stat(path);
28
- const ageSeconds = (Date.now() - stats.mtimeMs) / 1000;
29
- if (ageSeconds >= ttl)
30
- return null;
31
- const raw = await readFile(path, "utf8");
32
- return JSON.parse(raw);
33
- }
34
- catch {
35
- return null;
36
- }
37
- }
38
- /**
39
- * Write a value to the cache. Errors are swallowed — callers can await
40
- * to know when the write is done, but the write never rejects.
41
- */
42
- export async function cacheSet(key, value, opts = {}) {
43
- if (opts.disabled)
44
- return;
45
- try {
46
- const path = resolvePath(key);
47
- const tmp = `${path}.${randomUUID()}.tmp`;
48
- await mkdir(dirname(path), { recursive: true });
49
- await writeFile(tmp, JSON.stringify(value), "utf8");
50
- // Atomic rename — prevents a partial read if two processes write concurrently.
51
- await rename(tmp, path);
52
- }
53
- catch {
54
- // Cache writes are best-effort.
55
- }
56
- }
57
- // ---------------------------------------------------------------------------
58
- // Internal helpers
59
- // ---------------------------------------------------------------------------
60
- function resolvePath(key) {
61
- for (const [field, value] of [
62
- ["owner", key.owner],
63
- ["repo", key.repo],
64
- ["shape", key.shape],
65
- ]) {
66
- if (!SAFE_SEGMENT.test(value)) {
67
- throw new Error(`Invalid cache key segment "${field}": ${value}`);
68
- }
69
- }
70
- const base = process.env["PR_SHEPHERD_CACHE_DIR"] ?? join(tmpdir(), "pr-shepherd-cache");
71
- return join(base, `${key.owner}-${key.repo}`, String(key.pr), `${key.shape}.json`);
72
- }
73
- function ttlFromEnv() {
74
- const raw = process.env["PR_SHEPHERD_CACHE_TTL_SECONDS"];
75
- if (!raw)
76
- return undefined;
77
- const parsed = parseInt(raw, 10);
78
- return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
79
- }
package/bin/cli.mjs DELETED
@@ -1,286 +0,0 @@
1
- /**
2
- * CLI argument parsing and subcommand dispatch for pr-shepherd.
3
- *
4
- * Usage:
5
- * pr-shepherd check [PR] [--format text|json] [--no-cache] [--cache-ttl N]
6
- * pr-shepherd resolve [PR] [--fetch] [--resolve-thread-ids A,B] [--minimize-comment-ids X,Y]
7
- * [--dismiss-review-ids Q] [--message MSG] [--require-sha SHA]
8
- * [--last-push-time N]
9
- * pr-shepherd iterate [PR] [--format text|json] [--cooldown-seconds N] [--ready-delay Nm] [--last-push-time N]
10
- * pr-shepherd status PR1 [PR2 …]
11
- */
12
- import { runCheck } from "./commands/check.mjs";
13
- import { runResolveFetch, runResolveMutate } from "./commands/resolve.mjs";
14
- import { runIterate, renderResolveCommand } from "./commands/iterate.mjs";
15
- import { runStatus, formatStatusTable } from "./commands/status.mjs";
16
- import { getRepoInfo } from "./github/client.mjs";
17
- import { formatJson } from "./reporters/json.mjs";
18
- import { formatText } from "./reporters/text.mjs";
19
- import { loadConfig } from "./config/load.mjs";
20
- import { parseCommonArgs, getFlag, hasFlag, parseList, parseStatusPrNumbers, parseDurationToMinutes, parseIntStrict, statusToExitCode, iterateActionToExitCode, deriveSimpleReady, } from "./cli/args.mjs";
21
- // ---------------------------------------------------------------------------
22
- // Entry
23
- // ---------------------------------------------------------------------------
24
- export async function main(argv) {
25
- const args = argv.slice(2); // strip node + script path
26
- const subcommand = args[0];
27
- switch (subcommand) {
28
- case "check":
29
- await handleCheck(args.slice(1));
30
- break;
31
- case "resolve":
32
- await handleResolve(args.slice(1));
33
- break;
34
- case "iterate":
35
- await handleIterate(args.slice(1));
36
- break;
37
- case "status":
38
- await handleStatus(args.slice(1));
39
- break;
40
- default:
41
- process.stderr.write(`Unknown subcommand: ${subcommand ?? "(none)"}\n`);
42
- process.stderr.write("Usage: pr-shepherd <check|resolve|iterate|status> [options]\n");
43
- process.exitCode = 1;
44
- return;
45
- }
46
- }
47
- // ---------------------------------------------------------------------------
48
- // Subcommand handlers
49
- // ---------------------------------------------------------------------------
50
- async function handleCheck(args) {
51
- const { prNumber, global: globalOpts } = parseCommonArgs(args);
52
- const report = await runCheck({ ...globalOpts, prNumber, autoResolve: false });
53
- const output = globalOpts.format === "json" ? formatJson(report) : formatText(report);
54
- process.stdout.write(`${output}\n`);
55
- process.exitCode = statusToExitCode(report.status);
56
- return;
57
- }
58
- async function handleResolve(args) {
59
- const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
60
- const resolveThreadIds = parseList(getFlag(extra, "--resolve-thread-ids"));
61
- const minimizeCommentIds = parseList(getFlag(extra, "--minimize-comment-ids"));
62
- const dismissReviewIds = parseList(getFlag(extra, "--dismiss-review-ids"));
63
- const dismissMessage = getFlag(extra, "--message") ?? undefined;
64
- const requireSha = getFlag(extra, "--require-sha") ?? undefined;
65
- const fetchMode = hasFlag(extra, "--fetch") ||
66
- (resolveThreadIds.length === 0 &&
67
- minimizeCommentIds.length === 0 &&
68
- dismissReviewIds.length === 0);
69
- if (fetchMode) {
70
- const result = await runResolveFetch({ ...globalOpts, prNumber });
71
- process.stdout.write(globalOpts.format === "json"
72
- ? `${JSON.stringify(result, null, 2)}\n`
73
- : formatFetchResult(result));
74
- }
75
- else {
76
- const result = await runResolveMutate({
77
- ...globalOpts,
78
- prNumber,
79
- resolveThreadIds,
80
- minimizeCommentIds,
81
- dismissReviewIds,
82
- dismissMessage,
83
- requireSha,
84
- });
85
- process.stdout.write(globalOpts.format === "json"
86
- ? `${JSON.stringify(result, null, 2)}\n`
87
- : formatMutateResult(result));
88
- }
89
- }
90
- async function handleIterate(args) {
91
- const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
92
- const lastPushTimeStr = getFlag(extra, "--last-push-time");
93
- const lastPushTime = lastPushTimeStr
94
- ? parseIntStrict(lastPushTimeStr, "--last-push-time")
95
- : undefined;
96
- const readyDelayStr = getFlag(extra, "--ready-delay");
97
- const cfg = loadConfig();
98
- const readyDelaySeconds = parseDurationToMinutes(readyDelayStr ?? "", cfg.watch.readyDelayMinutes) * 60;
99
- const cooldownSecondsStr = getFlag(extra, "--cooldown-seconds");
100
- const cooldownSeconds = cooldownSecondsStr
101
- ? parseIntStrict(cooldownSecondsStr, "--cooldown-seconds")
102
- : cfg.iterate.cooldownSeconds;
103
- const noAutoRerun = hasFlag(extra, "--no-auto-rerun");
104
- const noAutoMarkReady = hasFlag(extra, "--no-auto-mark-ready");
105
- const noAutoCancelActionable = hasFlag(extra, "--no-auto-cancel-actionable");
106
- const result = await runIterate({
107
- ...globalOpts,
108
- prNumber,
109
- lastPushTime,
110
- readyDelaySeconds,
111
- cooldownSeconds,
112
- noAutoRerun,
113
- noAutoMarkReady,
114
- noAutoCancelActionable,
115
- });
116
- if (globalOpts.format === "json") {
117
- process.stdout.write(`${JSON.stringify(result)}\n`);
118
- }
119
- else {
120
- process.stdout.write(`${formatIterateResult(result)}\n`);
121
- }
122
- process.exitCode = iterateActionToExitCode(result.action);
123
- return;
124
- }
125
- async function handleStatus(args) {
126
- const { global: globalOpts } = parseCommonArgs(args);
127
- const prNumbers = parseStatusPrNumbers(args);
128
- if (prNumbers.length === 0) {
129
- process.stderr.write("Usage: pr-shepherd status PR1 [PR2 …]\n");
130
- process.exitCode = 1;
131
- return;
132
- }
133
- const repo = await getRepoInfo();
134
- const summaries = await runStatus({ ...globalOpts, prNumbers });
135
- const output = globalOpts.format === "json"
136
- ? JSON.stringify(summaries, null, 2)
137
- : formatStatusTable(summaries, `${repo.owner}/${repo.name}`);
138
- process.stdout.write(`${output}\n`);
139
- const allReady = summaries.every((s) => deriveSimpleReady(s));
140
- process.exitCode = allReady ? 0 : 1;
141
- return;
142
- }
143
- // ---------------------------------------------------------------------------
144
- // Output formatters
145
- // ---------------------------------------------------------------------------
146
- function formatFetchResult(result) {
147
- const lines = [];
148
- if (result.actionableThreads.length > 0) {
149
- lines.push(`\nActionable Review Threads (${result.actionableThreads.length}):`);
150
- for (const t of result.actionableThreads) {
151
- lines.push(` - threadId=${t.id} ${t.path ?? ""}:${t.line ?? "?"} (@${t.author}): ${t.body.split("\n")[0]?.slice(0, 100) ?? ""}`);
152
- }
153
- }
154
- if (result.actionableComments.length > 0) {
155
- lines.push(`\nActionable PR Comments (${result.actionableComments.length}):`);
156
- for (const c of result.actionableComments) {
157
- lines.push(` - commentId=${c.id} (@${c.author}): ${c.body.split("\n")[0]?.slice(0, 100) ?? ""}`);
158
- }
159
- }
160
- if (result.changesRequestedReviews.length > 0) {
161
- lines.push(`\nPending CHANGES_REQUESTED reviews (${result.changesRequestedReviews.length}):`);
162
- for (const r of result.changesRequestedReviews) {
163
- lines.push(` - reviewId=${r.id} (@${r.author})`);
164
- }
165
- }
166
- if (result.reviewSummaries.length > 0) {
167
- lines.push(`\nReview summaries (${result.reviewSummaries.length}):`);
168
- for (const r of result.reviewSummaries) {
169
- lines.push(` - reviewId=${r.id} (@${r.author}): ${r.body.split("\n")[0].slice(0, 100)}`);
170
- }
171
- }
172
- const total = result.actionableThreads.length +
173
- result.actionableComments.length +
174
- result.changesRequestedReviews.length +
175
- result.reviewSummaries.length;
176
- lines.push(`\nSummary: ${total === 0 ? "0 actionable — all threads resolved/minimized" : `${total} actionable item(s)`}`);
177
- return `${lines.join("\n")}\n`;
178
- }
179
- function formatMutateResult(result) {
180
- const lines = [];
181
- if (result.resolvedThreads.length)
182
- lines.push(`Resolved threads (${result.resolvedThreads.length}): ${result.resolvedThreads.join(", ")}`);
183
- if (result.minimizedComments.length)
184
- lines.push(`Minimized comments (${result.minimizedComments.length}): ${result.minimizedComments.join(", ")}`);
185
- if (result.dismissedReviews.length)
186
- lines.push(`Dismissed reviews (${result.dismissedReviews.length}): ${result.dismissedReviews.join(", ")}`);
187
- if (result.errors.length)
188
- lines.push(`Errors:\n ${result.errors.join("\n ")}`);
189
- return `${lines.join("\n")}\n`;
190
- }
191
- /**
192
- * Format an IterateResult as human-readable Markdown.
193
- *
194
- * Load-bearing conventions the monitor SKILL relies on:
195
- * 1. The H1 heading on line 1 contains `[<ACTION>]` — the SKILL greps this tag.
196
- * 2. The `resolve` bullet under `## Rebase` wraps the command in backticks —
197
- * the SKILL extracts the backticked content for execution.
198
- * 3. The shell script under `[REBASE]` is inside a ```bash fenced block.
199
- * 4. `## Instructions` items are numbered `1.`, `2.`, … and executed in order.
200
- */
201
- function formatIterateResult(result) {
202
- const heading = `# PR #${result.pr} [${result.action.toUpperCase()}]`;
203
- const baseLine = `**status** \`${result.status}\` · **merge** \`${result.mergeStateStatus}\` · **state** \`${result.state}\` · **repo** \`${result.repo}\``;
204
- const summaryLine = `**summary** ${result.summary.passing} passing, ${result.summary.skipped} skipped, ${result.summary.filtered} filtered, ${result.summary.inProgress} inProgress · **remainingSeconds** ${result.remainingSeconds} · **copilotReviewInProgress** ${result.copilotReviewInProgress} · **isDraft** ${result.isDraft} · **shouldCancel** ${result.shouldCancel}`;
205
- const header = [heading, "", baseLine, summaryLine].join("\n");
206
- switch (result.action) {
207
- case "cooldown":
208
- case "wait":
209
- case "cancel":
210
- case "rerun_ci":
211
- case "mark_ready":
212
- return [header, "", result.log].join("\n");
213
- case "rebase":
214
- return [
215
- header,
216
- "",
217
- result.rebase.reason,
218
- "",
219
- "```bash",
220
- result.rebase.shellScript,
221
- "```",
222
- ].join("\n");
223
- case "escalate":
224
- return [header, "", result.escalate.humanMessage].join("\n");
225
- case "fix_code":
226
- return formatFixCodeResult(header, result);
227
- }
228
- }
229
- function formatFixCodeResult(header, result) {
230
- const sections = [header];
231
- if (result.fix.threads.length > 0) {
232
- sections.push("## Review threads");
233
- for (const t of result.fix.threads) {
234
- const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
235
- sections.push(`### \`${t.id}\` — ${loc} (@${t.author})`);
236
- sections.push(blockquote(t.body));
237
- }
238
- }
239
- if (result.fix.actionableComments.length > 0) {
240
- sections.push("## Actionable comments");
241
- for (const c of result.fix.actionableComments) {
242
- sections.push(`### \`${c.id}\` (@${c.author})`);
243
- sections.push(blockquote(c.body));
244
- }
245
- }
246
- if (result.fix.checks.length > 0) {
247
- sections.push("## Failing checks");
248
- const bullets = result.fix.checks.map((ch) => {
249
- const kind = ch.failureKind ?? "actionable";
250
- if (ch.runId)
251
- return `- \`${ch.runId}\` — \`${ch.name}\` (${kind})`;
252
- if (ch.detailsUrl)
253
- return `- external \`${ch.detailsUrl}\` — \`${ch.name}\` (${kind})`;
254
- return `- (no runId) — \`${ch.name}\` (${kind})`;
255
- });
256
- sections.push(bullets.join("\n"));
257
- }
258
- if (result.fix.changesRequestedReviews.length > 0) {
259
- sections.push("## Changes-requested reviews");
260
- sections.push(result.fix.changesRequestedReviews.map((r) => `- \`${r.id}\` (@${r.author})`).join("\n"));
261
- }
262
- if (result.fix.noiseCommentIds.length > 0) {
263
- sections.push("## Noise (minimize only)");
264
- sections.push(result.fix.noiseCommentIds.map((id) => `\`${id}\``).join(", "));
265
- }
266
- if (result.cancelled.length > 0) {
267
- sections.push("## Cancelled runs");
268
- sections.push(result.cancelled.map((id) => `\`${id}\``).join(", "));
269
- }
270
- sections.push("## Rebase");
271
- sections.push([
272
- `- base: \`${result.baseBranch}\``,
273
- `- resolve: \`${renderResolveCommand(result.fix.resolveCommand)}\``,
274
- ].join("\n"));
275
- if (result.fix.instructions.length > 0) {
276
- sections.push("## Instructions");
277
- sections.push(result.fix.instructions.map((inst, i) => `${i + 1}. ${inst}`).join("\n"));
278
- }
279
- return sections.join("\n\n");
280
- }
281
- function blockquote(body) {
282
- return body
283
- .split("\n")
284
- .map((line) => (line === "" ? ">" : `> ${line}`))
285
- .join("\n");
286
- }