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
package/bin/cli/args.mjs CHANGED
@@ -1,20 +1,31 @@
1
1
  /**
2
2
  * CLI argument-parsing helpers extracted from cli.mts for testability.
3
- * Note: parseCommonArgs calls loadConfig() for cache TTL defaults.
4
3
  */
5
4
  import { parseArgs } from "node:util";
6
- import { loadConfig } from "../config/load.mjs";
7
- import { deriveVerdict } from "../commands/status.mjs";
8
5
  // Flags that consume the next argument as their value (used for PR-number
9
6
  // detection only — prevents a flag's value from being mistaken for a PR number).
10
7
  const FLAGS_WITH_VALUES = new Set([
11
8
  "--format",
12
- "--cache-ttl",
13
- "--last-push-time",
14
9
  "--ready-delay",
15
10
  "--cooldown-seconds",
11
+ "--stall-timeout",
16
12
  "--require-sha",
17
13
  "--message",
14
+ "--description",
15
+ "--thread-id",
16
+ "--resolve-thread-ids",
17
+ "--minimize-comment-ids",
18
+ "--dismiss-review-ids",
19
+ ]);
20
+ // Boolean flags that do NOT consume the next argument. Any --flag not in this
21
+ // set and not in FLAGS_WITH_VALUES is treated conservatively as value-taking
22
+ // for PR-number detection — so removed flags don't silently cause their
23
+ // numeric value to be misidentified as the PR number.
24
+ const BOOLEAN_FLAGS = new Set([
25
+ "--fetch",
26
+ "--no-auto-mark-ready",
27
+ "--no-auto-cancel-actionable",
28
+ "--dry-run",
18
29
  ]);
19
30
  // ---------------------------------------------------------------------------
20
31
  // Strict integer parsing
@@ -26,7 +37,6 @@ export function parseIntStrict(value, flag) {
26
37
  return parseInt(value, 10);
27
38
  }
28
39
  export function parseCommonArgs(args) {
29
- const config = loadConfig();
30
40
  const { values, tokens } = parseArgs({
31
41
  args,
32
42
  strict: false,
@@ -34,22 +44,14 @@ export function parseCommonArgs(args) {
34
44
  tokens: true,
35
45
  options: {
36
46
  format: { type: "string" },
37
- "cache-ttl": { type: "string" },
38
- "no-cache": { type: "boolean" },
39
47
  },
40
48
  });
41
49
  const format = (values.format ?? "text");
42
- const noCache = (values["no-cache"] ?? false);
43
- const cacheTtlStr = values["cache-ttl"];
44
- const cacheTtlSeconds = cacheTtlStr
45
- ? parseIntStrict(cacheTtlStr, "--cache-ttl")
46
- : config.cache.ttlSeconds;
47
50
  // Build the set of arg indices consumed by global flags so we can strip
48
51
  // them from `extra`. Subcommand-specific flags are left untouched.
49
52
  const consumedIndices = new Set();
50
53
  for (const tok of tokens ?? []) {
51
- if (tok.kind === "option" &&
52
- (tok.name === "format" || tok.name === "cache-ttl" || tok.name === "no-cache")) {
54
+ if (tok.kind === "option" && tok.name === "format") {
53
55
  consumedIndices.add(tok.index);
54
56
  // When the value is a separate arg (--flag value, not --flag=value),
55
57
  // inlineValue is false and the value occupies tok.index + 1.
@@ -59,7 +61,9 @@ export function parseCommonArgs(args) {
59
61
  }
60
62
  }
61
63
  // Find the first positional arg that looks like a PR number, skipping values
62
- // that belong to flags in FLAGS_WITH_VALUES (subcommand flags included).
64
+ // that belong to value-taking flags. Any --flag not in BOOLEAN_FLAGS is
65
+ // treated conservatively as value-taking so that removed flags don't cause
66
+ // their numeric value to be misidentified as the PR number.
63
67
  const skipForPrDetect = new Set();
64
68
  for (let i = 0; i < args.length; i += 1) {
65
69
  const arg = args[i];
@@ -69,6 +73,14 @@ export function parseCommonArgs(args) {
69
73
  skipForPrDetect.add(i + 1);
70
74
  i += 1;
71
75
  }
76
+ else if (arg.startsWith("--") && !arg.includes("=") && !BOOLEAN_FLAGS.has(arg)) {
77
+ // Unknown non-boolean flag: conservatively skip the next non-flag arg.
78
+ skipForPrDetect.add(i);
79
+ if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
80
+ skipForPrDetect.add(i + 1);
81
+ i += 1;
82
+ }
83
+ }
72
84
  else {
73
85
  const eqIdx = arg.indexOf("=");
74
86
  if (eqIdx > 0 && FLAGS_WITH_VALUES.has(arg.slice(0, eqIdx))) {
@@ -85,7 +97,7 @@ export function parseCommonArgs(args) {
85
97
  const extra = args.filter((_, i) => !consumedIndices.has(i));
86
98
  return {
87
99
  prNumber,
88
- global: { format, noCache, cacheTtlSeconds },
100
+ global: { format },
89
101
  extra,
90
102
  };
91
103
  }
@@ -127,47 +139,3 @@ export function parseStatusPrNumbers(args) {
127
139
  }
128
140
  return prNumbers;
129
141
  }
130
- // ---------------------------------------------------------------------------
131
- // Duration parsing
132
- // ---------------------------------------------------------------------------
133
- export function parseDurationToMinutes(s, defaultMinutes) {
134
- const m = /^(\d+)(m|min|minutes?|h|hours?)?$/.exec(s.trim());
135
- if (!m)
136
- return defaultMinutes ?? loadConfig().watch.readyDelayMinutes;
137
- const n = parseInt(m[1], 10);
138
- const unit = m[2] ?? "m";
139
- if (unit.startsWith("h"))
140
- return n * 60;
141
- return n;
142
- }
143
- // ---------------------------------------------------------------------------
144
- // Exit code mapping
145
- // ---------------------------------------------------------------------------
146
- export function statusToExitCode(status) {
147
- switch (status) {
148
- case "READY":
149
- return 0;
150
- case "IN_PROGRESS":
151
- return 2;
152
- case "UNRESOLVED_COMMENTS":
153
- return 3;
154
- default:
155
- return 1;
156
- }
157
- }
158
- export function iterateActionToExitCode(action) {
159
- switch (action) {
160
- case "fix_code":
161
- case "rebase":
162
- return 1;
163
- case "cancel":
164
- return 2;
165
- case "escalate":
166
- return 3;
167
- default:
168
- return 0;
169
- }
170
- }
171
- export function deriveSimpleReady(s) {
172
- return deriveVerdict(s) === "READY";
173
- }
@@ -0,0 +1,39 @@
1
+ import { loadConfig } from "../config/load.mjs";
2
+ import { deriveVerdict } from "../commands/status.mjs";
3
+ export function parseDurationToMinutes(s, defaultMinutes) {
4
+ const m = /^(\d+)(m|min|minutes?|h|hours?)?$/.exec(s.trim());
5
+ if (!m)
6
+ return defaultMinutes ?? loadConfig().watch.readyDelayMinutes;
7
+ const n = parseInt(m[1], 10);
8
+ const unit = m[2] ?? "m";
9
+ if (unit.startsWith("h"))
10
+ return n * 60;
11
+ return n;
12
+ }
13
+ export function statusToExitCode(status) {
14
+ switch (status) {
15
+ case "READY":
16
+ return 0;
17
+ case "IN_PROGRESS":
18
+ return 2;
19
+ case "UNRESOLVED_COMMENTS":
20
+ return 3;
21
+ default:
22
+ return 1;
23
+ }
24
+ }
25
+ export function iterateActionToExitCode(action) {
26
+ switch (action) {
27
+ case "fix_code":
28
+ return 1;
29
+ case "cancel":
30
+ return 2;
31
+ case "escalate":
32
+ return 3;
33
+ default:
34
+ return 0;
35
+ }
36
+ }
37
+ export function deriveSimpleReady(s) {
38
+ return deriveVerdict(s) === "READY";
39
+ }
@@ -0,0 +1,76 @@
1
+ import { renderResolveCommand } from "../commands/iterate.mjs";
2
+ export function formatFixCodeResult(header, result) {
3
+ const sections = [header];
4
+ if (result.fix.threads.length > 0) {
5
+ sections.push("## Review threads");
6
+ for (const t of result.fix.threads) {
7
+ const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
8
+ sections.push(`### \`${t.id}\` — ${loc} (@${t.author})`);
9
+ sections.push(blockquote(t.body));
10
+ }
11
+ }
12
+ if (result.fix.actionableComments.length > 0) {
13
+ sections.push("## Actionable comments");
14
+ for (const c of result.fix.actionableComments) {
15
+ sections.push(`### \`${c.id}\` (@${c.author})`);
16
+ sections.push(blockquote(c.body));
17
+ }
18
+ }
19
+ if (result.fix.checks.length > 0) {
20
+ sections.push("## Failing checks");
21
+ const bullets = result.fix.checks.map((ch) => {
22
+ const prefix = ch.workflowName ? `${ch.workflowName} › ` : "";
23
+ const locator = ch.runId
24
+ ? `\`${ch.runId}\``
25
+ : ch.detailsUrl
26
+ ? `external \`${ch.detailsUrl}\``
27
+ : "(no runId)";
28
+ const lines = [`- ${locator} — \`${prefix}${ch.name}\``];
29
+ if (ch.failedStep)
30
+ lines.push(` > ${ch.failedStep}`);
31
+ if (ch.summary)
32
+ lines.push(` > ${ch.summary}`);
33
+ return lines.join("\n");
34
+ });
35
+ sections.push(bullets.join("\n\n"));
36
+ }
37
+ if (result.fix.changesRequestedReviews.length > 0) {
38
+ sections.push("## Changes-requested reviews");
39
+ sections.push(result.fix.changesRequestedReviews.map((r) => `- \`${r.id}\` (@${r.author})`).join("\n"));
40
+ }
41
+ if (result.fix.noiseCommentIds.length > 0) {
42
+ sections.push("## Noise (minimize only)");
43
+ sections.push(result.fix.noiseCommentIds.map((id) => `\`${id}\``).join(", "));
44
+ }
45
+ if (result.fix.reviewSummaryIds.length > 0) {
46
+ sections.push("## Review summaries (minimize only)");
47
+ sections.push(result.fix.reviewSummaryIds.map((id) => `\`${id}\``).join(", "));
48
+ }
49
+ if (result.fix.surfacedSummaries.length > 0) {
50
+ sections.push("## Review summaries (surfaced — not minimized)");
51
+ for (const r of result.fix.surfacedSummaries) {
52
+ sections.push(`### \`${r.id}\` (@${r.author})`);
53
+ sections.push(blockquote(r.body));
54
+ }
55
+ }
56
+ if (result.cancelled.length > 0) {
57
+ sections.push("## Cancelled runs");
58
+ sections.push(result.cancelled.map((id) => `\`${id}\``).join(", "));
59
+ }
60
+ sections.push("## Post-fix push");
61
+ const postFixLines = [`- base: \`${result.baseBranch}\``];
62
+ if (result.fix.resolveCommand.hasMutations) {
63
+ postFixLines.push(`- resolve: \`${renderResolveCommand(result.fix.resolveCommand)}\``);
64
+ }
65
+ sections.push(postFixLines.join("\n"));
66
+ sections.push("## Instructions");
67
+ sections.push(result.fix.instructions.map((inst, i) => `${i + 1}. ${inst}`).join("\n"));
68
+ return sections.join("\n\n");
69
+ }
70
+ export function blockquote(body) {
71
+ return body
72
+ .replace(/\r\n/g, "\n")
73
+ .split("\n")
74
+ .map((line) => (line === "" ? ">" : `> ${line}`))
75
+ .join("\n");
76
+ }
@@ -0,0 +1,108 @@
1
+ export { formatIterateResult } from "./iterate-formatter.mjs";
2
+ export function formatFetchResult(result) {
3
+ const total = result.actionableThreads.length +
4
+ result.actionableComments.length +
5
+ result.changesRequestedReviews.length +
6
+ result.reviewSummaries.length;
7
+ const sections = [];
8
+ sections.push(`# PR #${result.prNumber} — Resolve fetch (${total === 0 ? "0 actionable" : `${total} actionable`})`);
9
+ if (result.actionableThreads.length > 0) {
10
+ sections.push(`## Actionable Review Threads (${result.actionableThreads.length})` +
11
+ (result.commitSuggestionsEnabled ? " [commit-suggestions: enabled]" : ""));
12
+ sections.push(result.actionableThreads
13
+ .map((t) => {
14
+ const suggestionMarker = t.suggestion ? " [suggestion]" : "";
15
+ return `- \`threadId=${t.id}\` \`${t.path ?? ""}:${t.line ?? "?"}\` (@${t.author})${suggestionMarker}: ${t.body.split("\n")[0]?.slice(0, 100) ?? ""}`;
16
+ })
17
+ .join("\n"));
18
+ }
19
+ if (result.actionableComments.length > 0) {
20
+ sections.push(`## Actionable PR Comments (${result.actionableComments.length})`);
21
+ sections.push(result.actionableComments
22
+ .map((c) => `- \`commentId=${c.id}\` (@${c.author}): ${c.body.split("\n")[0]?.slice(0, 100) ?? ""}`)
23
+ .join("\n"));
24
+ }
25
+ if (result.changesRequestedReviews.length > 0) {
26
+ sections.push(`## Pending CHANGES_REQUESTED reviews (${result.changesRequestedReviews.length})`);
27
+ sections.push(result.changesRequestedReviews.map((r) => `- \`reviewId=${r.id}\` (@${r.author})`).join("\n"));
28
+ }
29
+ if (result.reviewSummaries.length > 0) {
30
+ sections.push(`## Review summaries (${result.reviewSummaries.length})`);
31
+ sections.push(result.reviewSummaries
32
+ .map((r) => `- \`reviewId=${r.id}\` (@${r.author}): ${r.body.split("\n")[0].slice(0, 100)}`)
33
+ .join("\n"));
34
+ }
35
+ sections.push("## Summary");
36
+ sections.push(total === 0 ? "0 actionable — all threads resolved/minimized" : `${total} actionable item(s)`);
37
+ sections.push("## Instructions");
38
+ sections.push(result.instructions.map((inst, i) => `${i + 1}. ${inst}`).join("\n"));
39
+ return `${sections.join("\n\n")}\n`;
40
+ }
41
+ export function formatCommitSuggestionResult(result) {
42
+ const lines = [];
43
+ if (result.dryRun) {
44
+ const range = result.startLine === result.endLine
45
+ ? `line ${result.startLine}`
46
+ : `lines ${result.startLine}-${result.endLine}`;
47
+ if (result.valid) {
48
+ lines.push(`Dry-run: would apply suggestion from @${result.author}:`);
49
+ lines.push(` ${result.path} (${range})`);
50
+ }
51
+ else {
52
+ lines.push(`Dry-run: suggestion cannot apply cleanly:`);
53
+ lines.push(`- path: ${result.path} (${range})`);
54
+ lines.push(`- author: @${result.author}`);
55
+ lines.push(`- reason: ${result.reason ?? "unknown"}`);
56
+ }
57
+ if (result.patch) {
58
+ lines.push("");
59
+ lines.push("```diff");
60
+ lines.push(result.patch.trimEnd());
61
+ lines.push("```");
62
+ }
63
+ }
64
+ else if (result.applied) {
65
+ lines.push(`Applied suggestion from @${result.author}:`);
66
+ const range = result.startLine === result.endLine
67
+ ? `line ${result.startLine}`
68
+ : `lines ${result.startLine}-${result.endLine}`;
69
+ lines.push(` ${result.path} (${range})`);
70
+ if (result.commitSha)
71
+ lines.push(`Commit: ${result.commitSha}`);
72
+ if (result.patch) {
73
+ lines.push("");
74
+ lines.push("```diff");
75
+ lines.push(result.patch.trimEnd());
76
+ lines.push("```");
77
+ }
78
+ }
79
+ else {
80
+ lines.push(`Failed to apply suggestion ${result.threadId}:`);
81
+ lines.push(`- path: ${result.path} (lines ${result.startLine}–${result.endLine})`);
82
+ lines.push(`- author: @${result.author}`);
83
+ lines.push(`- reason: ${result.reason ?? "unknown"}`);
84
+ if (result.patch) {
85
+ lines.push("");
86
+ lines.push("```diff");
87
+ lines.push(result.patch.trimEnd());
88
+ lines.push("```");
89
+ }
90
+ }
91
+ if (result.postActionInstruction) {
92
+ lines.push("");
93
+ lines.push(result.postActionInstruction);
94
+ }
95
+ return `${lines.join("\n")}\n`;
96
+ }
97
+ export function formatMutateResult(result) {
98
+ const lines = [];
99
+ if (result.resolvedThreads.length)
100
+ lines.push(`Resolved threads (${result.resolvedThreads.length}): ${result.resolvedThreads.join(", ")}`);
101
+ if (result.minimizedComments.length)
102
+ lines.push(`Minimized comments (${result.minimizedComments.length}): ${result.minimizedComments.join(", ")}`);
103
+ if (result.dismissedReviews.length)
104
+ lines.push(`Dismissed reviews (${result.dismissedReviews.length}): ${result.dismissedReviews.join(", ")}`);
105
+ if (result.errors.length)
106
+ lines.push(`Errors:\n ${result.errors.join("\n ")}`);
107
+ return `${lines.join("\n")}\n`;
108
+ }
@@ -0,0 +1,138 @@
1
+ import { runCommitSuggestion } from "../commands/commit-suggestion.mjs";
2
+ import { runIterate } from "../commands/iterate.mjs";
3
+ import { runMonitor, formatMonitorResult } from "../commands/monitor.mjs";
4
+ import { runStatus, formatStatusTable } from "../commands/status.mjs";
5
+ import { getRepoInfo } from "../github/client.mjs";
6
+ import { loadConfig } from "../config/load.mjs";
7
+ import { parseCommonArgs, getFlag, hasFlag, parseStatusPrNumbers, parseIntStrict, } from "./args.mjs";
8
+ import { parseDurationToMinutes, iterateActionToExitCode, deriveSimpleReady, } from "./exit-codes.mjs";
9
+ import { formatCommitSuggestionResult, formatIterateResult } from "./formatters.mjs";
10
+ export async function handleCommitSuggestion(args) {
11
+ const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
12
+ const threadId = getFlag(extra, "--thread-id");
13
+ if (!threadId) {
14
+ process.stderr.write("Usage: pr-shepherd commit-suggestion [PR] --thread-id ID [--message MSG] [--description DESC] [--dry-run]\n" +
15
+ " (--message is required unless --dry-run is set)\n");
16
+ process.exitCode = 1;
17
+ return;
18
+ }
19
+ const dryRun = hasFlag(extra, "--dry-run");
20
+ const message = getFlag(extra, "--message") ?? undefined;
21
+ if (!dryRun && (!message || message.trim() === "")) {
22
+ process.stderr.write("--message is required and must be non-empty\n");
23
+ process.exitCode = 1;
24
+ return;
25
+ }
26
+ const description = getFlag(extra, "--description") ?? undefined;
27
+ const result = await runCommitSuggestion({
28
+ ...globalOpts,
29
+ prNumber,
30
+ threadId,
31
+ message,
32
+ description,
33
+ dryRun,
34
+ });
35
+ process.stdout.write(globalOpts.format === "json"
36
+ ? `${JSON.stringify(result, null, 2)}\n`
37
+ : formatCommitSuggestionResult(result));
38
+ if (result.dryRun) {
39
+ process.exitCode = result.valid ? 0 : 1;
40
+ }
41
+ else {
42
+ process.exitCode = result.applied ? 0 : 1;
43
+ }
44
+ }
45
+ export async function handleIterate(args) {
46
+ const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
47
+ const readyDelayStr = getFlag(extra, "--ready-delay");
48
+ const cfg = loadConfig();
49
+ const readyDelaySeconds = parseDurationToMinutes(readyDelayStr ?? "", cfg.watch.readyDelayMinutes) * 60;
50
+ const cooldownSecondsStr = getFlag(extra, "--cooldown-seconds");
51
+ const cooldownSeconds = cooldownSecondsStr
52
+ ? parseIntStrict(cooldownSecondsStr, "--cooldown-seconds")
53
+ : cfg.iterate.cooldownSeconds;
54
+ const noAutoMarkReady = hasFlag(extra, "--no-auto-mark-ready");
55
+ const noAutoCancelActionable = hasFlag(extra, "--no-auto-cancel-actionable");
56
+ const stallTimeoutStr = getFlag(extra, "--stall-timeout");
57
+ const stallTimeoutSeconds = stallTimeoutStr
58
+ ? parseDurationToMinutes(stallTimeoutStr, cfg.iterate.stallTimeoutMinutes) * 60
59
+ : cfg.iterate.stallTimeoutMinutes * 60;
60
+ const result = await runIterate({
61
+ ...globalOpts,
62
+ prNumber,
63
+ readyDelaySeconds,
64
+ cooldownSeconds,
65
+ stallTimeoutSeconds,
66
+ noAutoMarkReady,
67
+ noAutoCancelActionable,
68
+ });
69
+ if (globalOpts.format === "json") {
70
+ process.stdout.write(`${JSON.stringify(result)}\n`);
71
+ }
72
+ else {
73
+ process.stdout.write(`${formatIterateResult(result)}\n`);
74
+ }
75
+ process.exitCode = iterateActionToExitCode(result.action);
76
+ }
77
+ export async function handleMonitor(args) {
78
+ const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
79
+ const readyDelayStr = getFlag(extra, "--ready-delay");
80
+ if (hasFlag(extra, "--ready-delay") &&
81
+ (readyDelayStr === null || readyDelayStr.startsWith("--"))) {
82
+ process.stderr.write("pr-shepherd monitor: --ready-delay requires a value (e.g. --ready-delay 15m)\n");
83
+ process.exitCode = 1;
84
+ return;
85
+ }
86
+ const remaining = [];
87
+ for (let i = 0; i < extra.length; i++) {
88
+ const a = extra[i];
89
+ if (a === "--ready-delay") {
90
+ i++;
91
+ continue;
92
+ }
93
+ if (a.startsWith("--ready-delay="))
94
+ continue;
95
+ remaining.push(a);
96
+ }
97
+ const unknownFlags = remaining.filter((a) => a.startsWith("--"));
98
+ if (unknownFlags.length > 0) {
99
+ process.stderr.write(`pr-shepherd monitor: ignoring unknown flags: ${unknownFlags.join(" ")}\n`);
100
+ }
101
+ const unknownPositionals = remaining.filter((a) => !a.startsWith("--"));
102
+ if (unknownPositionals.length > 0) {
103
+ process.stderr.write(`pr-shepherd monitor: unexpected positional arguments ignored: ${unknownPositionals.join(" ")}\n`);
104
+ }
105
+ let result;
106
+ try {
107
+ result = await runMonitor({
108
+ ...globalOpts,
109
+ prNumber,
110
+ readyDelaySuffix: readyDelayStr ?? undefined,
111
+ });
112
+ }
113
+ catch (err) {
114
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
115
+ process.exitCode = 1;
116
+ return;
117
+ }
118
+ process.stdout.write(globalOpts.format === "json"
119
+ ? `${JSON.stringify(result, null, 2)}\n`
120
+ : `${formatMonitorResult(result)}\n`);
121
+ }
122
+ export async function handleStatus(args) {
123
+ const { global: globalOpts } = parseCommonArgs(args);
124
+ const prNumbers = parseStatusPrNumbers(args);
125
+ if (prNumbers.length === 0) {
126
+ process.stderr.write("Usage: pr-shepherd status PR1 [PR2 …]\n");
127
+ process.exitCode = 1;
128
+ return;
129
+ }
130
+ const repo = await getRepoInfo();
131
+ const summaries = await runStatus({ ...globalOpts, prNumbers });
132
+ const output = globalOpts.format === "json"
133
+ ? JSON.stringify(summaries, null, 2)
134
+ : formatStatusTable(summaries, `${repo.owner}/${repo.name}`);
135
+ process.stdout.write(`${output}\n`);
136
+ const allReady = summaries.every((s) => deriveSimpleReady(s));
137
+ process.exitCode = allReady ? 0 : 1;
138
+ }
@@ -0,0 +1,78 @@
1
+ import { formatFixCodeResult } from "./fix-formatter.mjs";
2
+ /**
3
+ * Format an IterateResult as human-readable Markdown.
4
+ *
5
+ * Load-bearing conventions the monitor SKILL relies on:
6
+ * 1. The H1 heading on line 1 contains `[<ACTION>]` — the action tag identifies
7
+ * the output for logging and validation. Behavior is driven by `## Instructions`,
8
+ * not by dispatching on the tag.
9
+ * 2. `[FIX_CODE]` uses the `rebase-and-push` variant: the `resolve` bullet under
10
+ * `## Post-fix push` wraps the resolve command in backticks — the SKILL
11
+ * extracts the backticked content for execution.
12
+ * 3. Every action ends with a `## Instructions` section — numbered `1.`, `2.`, … —
13
+ * that tells the monitor exactly what to do with this output. The section is
14
+ * unconditional: every action, every variant, always emits at least one step.
15
+ * The SKILL simply follows those steps; it does not need its own dispatch table.
16
+ */
17
+ export function formatIterateResult(result) {
18
+ const heading = `# PR #${result.pr} [${result.action.toUpperCase()}]`;
19
+ const baseLine = `**status** \`${result.status}\` · **merge** \`${result.mergeStateStatus}\` · **state** \`${result.state}\` · **repo** \`${result.repo}\``;
20
+ 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}`;
21
+ const header = [heading, "", baseLine, summaryLine].join("\n");
22
+ switch (result.action) {
23
+ case "cooldown":
24
+ return [
25
+ header,
26
+ "",
27
+ result.log,
28
+ "",
29
+ "## Instructions",
30
+ "",
31
+ "1. End this iteration — the next cron fire will recheck once CI starts reporting.",
32
+ ].join("\n");
33
+ case "wait": {
34
+ const parts = [
35
+ header,
36
+ result.log,
37
+ "## Instructions",
38
+ "1. End this iteration — the next cron fire will recheck.",
39
+ ];
40
+ return parts.join("\n\n").replace(/\n\n\n+/g, "\n\n");
41
+ }
42
+ case "rerun_ci": {
43
+ const rerunInstructions = result.reran.map((r, i) => `${i + 1}. Run: \`gh run rerun ${r.runId} --failed\``);
44
+ rerunInstructions.push(`${rerunInstructions.length + 1}. End this iteration — wait for CI to report results after the re-run.`);
45
+ const parts = [header, result.log, "## Instructions", rerunInstructions.join("\n")];
46
+ return parts.join("\n\n").replace(/\n\n\n+/g, "\n\n");
47
+ }
48
+ case "mark_ready": {
49
+ const parts = [
50
+ header,
51
+ result.log,
52
+ "## Instructions",
53
+ "1. The CLI already marked the PR ready for review — end this iteration.",
54
+ ];
55
+ return parts.join("\n\n").replace(/\n\n\n+/g, "\n\n");
56
+ }
57
+ case "cancel": {
58
+ const parts = [
59
+ header,
60
+ result.log,
61
+ "## Instructions",
62
+ "1. Invoke `/loop cancel` via the Skill tool.\n2. Stop.",
63
+ ];
64
+ return parts.join("\n\n").replace(/\n\n\n+/g, "\n\n");
65
+ }
66
+ case "escalate": {
67
+ const parts = [
68
+ header,
69
+ result.escalate.humanMessage,
70
+ "## Instructions",
71
+ "1. Invoke `/loop cancel` via the Skill tool.\n2. Stop — the PR needs human direction before monitoring can resume.",
72
+ ];
73
+ return parts.join("\n\n").replace(/\n\n\n+/g, "\n\n");
74
+ }
75
+ case "fix_code":
76
+ return formatFixCodeResult(header, result);
77
+ }
78
+ }
@@ -0,0 +1,65 @@
1
+ export function makeIterateResult(action = "wait") {
2
+ const base = {
3
+ pr: 42,
4
+ repo: "owner/repo",
5
+ status: "IN_PROGRESS",
6
+ state: "OPEN",
7
+ mergeStateStatus: "BLOCKED",
8
+ copilotReviewInProgress: false,
9
+ isDraft: false,
10
+ shouldCancel: false,
11
+ remainingSeconds: 60,
12
+ summary: { passing: 0, skipped: 0, filtered: 0, inProgress: 1 },
13
+ baseBranch: "main",
14
+ checks: [],
15
+ };
16
+ if (action === "cooldown")
17
+ return { ...base, action: "cooldown", log: "SKIP: CI still starting" };
18
+ if (action === "wait")
19
+ return { ...base, action: "wait", log: "WAIT: 0 passing, 1 in-progress" };
20
+ if (action === "rerun_ci")
21
+ return { ...base, action: "rerun_ci", log: "RERAN: run-99 (typecheck — transient)", reran: [] };
22
+ if (action === "mark_ready")
23
+ return { ...base, action: "mark_ready", markedReady: true, log: "MARKED READY: PR 42" };
24
+ if (action === "fix_code") {
25
+ return {
26
+ ...base,
27
+ action: "fix_code",
28
+ fix: {
29
+ mode: "rebase-and-push",
30
+ threads: [],
31
+ actionableComments: [],
32
+ noiseCommentIds: [],
33
+ reviewSummaryIds: [],
34
+ surfacedSummaries: [],
35
+ checks: [],
36
+ changesRequestedReviews: [],
37
+ resolveCommand: {
38
+ argv: ["npx", "pr-shepherd", "resolve", "42"],
39
+ requiresHeadSha: true,
40
+ requiresDismissMessage: false,
41
+ hasMutations: false,
42
+ },
43
+ instructions: ["End this iteration."],
44
+ },
45
+ cancelled: [],
46
+ };
47
+ }
48
+ if (action === "cancel")
49
+ return { ...base, action: "cancel", log: "CANCEL: PR #42 — stopping monitor" };
50
+ if (action === "escalate") {
51
+ return {
52
+ ...base,
53
+ action: "escalate",
54
+ escalate: {
55
+ triggers: [],
56
+ unresolvedThreads: [],
57
+ ambiguousComments: [],
58
+ changesRequestedReviews: [],
59
+ suggestion: "check manually",
60
+ humanMessage: "⚠️ /pr-shepherd:monitor paused — needs human direction",
61
+ },
62
+ };
63
+ }
64
+ return { ...base, action: "wait", log: "WAIT: 0 passing, 1 in-progress" };
65
+ }