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
@@ -0,0 +1,110 @@
1
+ /**
2
+ * CLI argument parsing and subcommand dispatch for pr-shepherd.
3
+ *
4
+ * Usage:
5
+ * pr-shepherd --version
6
+ * pr-shepherd check [PR] [--format text|json]
7
+ * pr-shepherd resolve [PR] [--fetch] [--resolve-thread-ids A,B] [--minimize-comment-ids X,Y]
8
+ * [--dismiss-review-ids Q] [--message MSG] [--require-sha SHA]
9
+ * pr-shepherd commit-suggestion [PR] --thread-id ID [--message MSG] [--description DESC]
10
+ * [--dry-run] [--format text|json]
11
+ * (--message is required unless --dry-run is set)
12
+ * pr-shepherd iterate [PR] [--format text|json] [--cooldown-seconds N] [--ready-delay Nm]
13
+ * [--stall-timeout <duration>] [--no-auto-mark-ready]
14
+ * [--no-auto-cancel-actionable]
15
+ * pr-shepherd monitor [PR] [--format text|json]
16
+ * pr-shepherd status PR1 [PR2 …]
17
+ */
18
+ import { readFileSync } from "node:fs";
19
+ import { runCheck } from "./commands/check.mjs";
20
+ import { runResolveFetch, runResolveMutate } from "./commands/resolve.mjs";
21
+ import { formatJson } from "./reporters/json.mjs";
22
+ import { formatText } from "./reporters/text.mjs";
23
+ import { parseCommonArgs, getFlag, hasFlag, parseList } from "./cli/args.mjs";
24
+ import { statusToExitCode } from "./cli/exit-codes.mjs";
25
+ import { formatFetchResult, formatMutateResult } from "./cli/formatters.mjs";
26
+ import { handleCommitSuggestion, handleIterate, handleMonitor, handleStatus, } from "./cli/handlers.mjs";
27
+ // ---------------------------------------------------------------------------
28
+ // Entry
29
+ // ---------------------------------------------------------------------------
30
+ export async function main(argv) {
31
+ const args = argv.slice(2); // strip node + script path
32
+ const subcommand = args[0];
33
+ if (subcommand === "--version" || subcommand === "-v") {
34
+ process.stdout.write(`${readVersion()}\n`);
35
+ return;
36
+ }
37
+ switch (subcommand) {
38
+ case "check":
39
+ await handleCheck(args.slice(1));
40
+ break;
41
+ case "resolve":
42
+ await handleResolve(args.slice(1));
43
+ break;
44
+ case "commit-suggestion":
45
+ await handleCommitSuggestion(args.slice(1));
46
+ break;
47
+ case "iterate":
48
+ await handleIterate(args.slice(1));
49
+ break;
50
+ case "monitor":
51
+ await handleMonitor(args.slice(1));
52
+ break;
53
+ case "status":
54
+ await handleStatus(args.slice(1));
55
+ break;
56
+ default:
57
+ process.stderr.write(`Unknown subcommand: ${subcommand ?? "(none)"}\n`);
58
+ process.stderr.write("Usage: pr-shepherd <check|resolve|commit-suggestion|iterate|monitor|status> [options]\n" +
59
+ " pr-shepherd --version | -v\n");
60
+ process.exitCode = 1;
61
+ return;
62
+ }
63
+ }
64
+ function readVersion() {
65
+ const pkgUrl = new URL("../package.json", import.meta.url);
66
+ const pkg = JSON.parse(readFileSync(pkgUrl, "utf8"));
67
+ return pkg.version;
68
+ }
69
+ // ---------------------------------------------------------------------------
70
+ // Subcommand handlers
71
+ // ---------------------------------------------------------------------------
72
+ async function handleCheck(args) {
73
+ const { prNumber, global: globalOpts } = parseCommonArgs(args);
74
+ const report = await runCheck({ ...globalOpts, prNumber, autoResolve: false });
75
+ const output = globalOpts.format === "json" ? formatJson(report) : formatText(report);
76
+ process.stdout.write(`${output}\n`);
77
+ process.exitCode = statusToExitCode(report.status);
78
+ }
79
+ async function handleResolve(args) {
80
+ const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
81
+ const resolveThreadIds = parseList(getFlag(extra, "--resolve-thread-ids"));
82
+ const minimizeCommentIds = parseList(getFlag(extra, "--minimize-comment-ids"));
83
+ const dismissReviewIds = parseList(getFlag(extra, "--dismiss-review-ids"));
84
+ const dismissMessage = getFlag(extra, "--message") ?? undefined;
85
+ const requireSha = getFlag(extra, "--require-sha") ?? undefined;
86
+ const fetchMode = hasFlag(extra, "--fetch") ||
87
+ (resolveThreadIds.length === 0 &&
88
+ minimizeCommentIds.length === 0 &&
89
+ dismissReviewIds.length === 0);
90
+ if (fetchMode) {
91
+ const result = await runResolveFetch({ ...globalOpts, prNumber });
92
+ process.stdout.write(globalOpts.format === "json"
93
+ ? `${JSON.stringify(result, null, 2)}\n`
94
+ : formatFetchResult(result));
95
+ }
96
+ else {
97
+ const result = await runResolveMutate({
98
+ ...globalOpts,
99
+ prNumber,
100
+ resolveThreadIds,
101
+ minimizeCommentIds,
102
+ dismissReviewIds,
103
+ dismissMessage,
104
+ requireSha,
105
+ });
106
+ process.stdout.write(globalOpts.format === "json"
107
+ ? `${JSON.stringify(result, null, 2)}\n`
108
+ : formatMutateResult(result));
109
+ }
110
+ }
@@ -0,0 +1,35 @@
1
+ export function computeStatus(verdict, unresolvedThreads, unresolvedComments, mergeStatus, changesRequestedReviews) {
2
+ // Merge conflicts are always terminal regardless of CI state.
3
+ if (mergeStatus.status === "CONFLICTS")
4
+ return "FAILING";
5
+ // Check CI state before merge-blocking states: BLOCKED/UNSTABLE/BEHIND are
6
+ // often caused by CI not having passed yet, so they shouldn't mask IN_PROGRESS.
7
+ // These merge-blocking states become PENDING (not FAILING) once CI is resolved.
8
+ if (verdict.anyFailing)
9
+ return "FAILING";
10
+ if (verdict.anyInProgress)
11
+ return "IN_PROGRESS";
12
+ // BLOCKED solely because a human reviewer hasn't approved yet — shepherd is done, hand off.
13
+ // copilotReviewInProgress means a bot still owes a review, which is not this case.
14
+ if (verdict.allPassed &&
15
+ unresolvedThreads === 0 &&
16
+ unresolvedComments === 0 &&
17
+ changesRequestedReviews === 0 &&
18
+ mergeStatus.status === "BLOCKED" &&
19
+ !mergeStatus.copilotReviewInProgress &&
20
+ mergeStatus.reviewDecision === "REVIEW_REQUIRED") {
21
+ return "READY";
22
+ }
23
+ if (mergeStatus.status === "BLOCKED" ||
24
+ mergeStatus.status === "UNSTABLE" ||
25
+ mergeStatus.status === "BEHIND")
26
+ return "PENDING";
27
+ if (mergeStatus.status === "UNKNOWN")
28
+ return "UNKNOWN";
29
+ if (changesRequestedReviews > 0 || unresolvedThreads > 0 || unresolvedComments > 0)
30
+ return "UNRESOLVED_COMMENTS";
31
+ // DRAFT is treated the same as CLEAN for readiness — marking the PR ready resolves it.
32
+ if ((mergeStatus.status === "CLEAN" || mergeStatus.status === "DRAFT") && verdict.allPassed)
33
+ return "READY";
34
+ return "UNKNOWN";
35
+ }
@@ -14,43 +14,35 @@
14
14
  */
15
15
  import { fetchPrBatch } from "../github/batch.mjs";
16
16
  import { getRepoInfo, getCurrentPrNumber, getMergeableState } from "../github/client.mjs";
17
- import { cacheGet, cacheSet } from "../cache/file-cache.mjs";
18
17
  import { classifyChecks, getCiVerdict } from "../checks/classify.mjs";
19
18
  import { triageFailingChecks } from "../checks/triage.mjs";
20
19
  import { getOutdatedThreads } from "../comments/outdated.mjs";
21
20
  import { autoResolveOutdated } from "../comments/resolve.mjs";
22
21
  import { deriveMergeStatus } from "../merge-status/derive.mjs";
22
+ import { loadConfig } from "../config/load.mjs";
23
+ import { computeStatus } from "./check-status.mjs";
23
24
  export async function runCheck(opts) {
24
25
  const repo = await getRepoInfo();
25
26
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
26
27
  if (prNumber === null) {
27
28
  throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
28
29
  }
29
- const cacheKey = { owner: repo.owner, repo: repo.name, pr: prNumber, shape: "check" };
30
- // When autoResolve is enabled the command will mutate (resolve threads, minimize
31
- // comments) always bypass cache so we act on fresh data, not a stale snapshot.
32
- const cacheOpts = {
33
- disabled: opts.noCache || opts.autoResolve,
34
- ttlSeconds: opts.cacheTtlSeconds,
35
- };
36
- // Try cache first.
37
- let batchData = await cacheGet(cacheKey, cacheOpts);
38
- if (batchData === null) {
39
- const result = await fetchPrBatch(prNumber, repo);
40
- batchData = result.data;
41
- // Don't cache UNKNOWN merge state — it's transient and would poison the
42
- // cache for the full TTL window, causing stale UNKNOWN on the next sweep.
43
- if (batchData.mergeable !== "UNKNOWN" && batchData.mergeStateStatus !== "UNKNOWN") {
44
- await cacheSet(cacheKey, batchData, cacheOpts);
45
- }
46
- }
30
+ // Only paginate APPROVED reviews when the caller will actually minimize them.
31
+ // Otherwise the first-page cap of 50 (already in the batch) is plenty no extra round-trip.
32
+ const paginateApprovedReviews = loadConfig().iterate.minimizeReviewSummaries.approvals;
33
+ const result = await fetchPrBatch(prNumber, repo, { paginateApprovedReviews });
34
+ let batchData = result.data;
47
35
  // GraphQL sometimes returns UNKNOWN for mergeable/mergeStateStatus while the
48
36
  // REST API already has the correct value. Fall back to REST in that case.
49
37
  // Skip for non-OPEN PRs — REST also returns UNKNOWN for merged/closed PRs.
50
38
  if ((batchData.state ?? "OPEN") === "OPEN" &&
51
39
  (batchData.mergeable === "UNKNOWN" || batchData.mergeStateStatus === "UNKNOWN")) {
52
40
  const restState = await getMergeableState(prNumber, repo.owner, repo.name);
53
- batchData = { ...batchData, ...restState };
41
+ batchData = {
42
+ ...batchData,
43
+ mergeable: restState.mergeable ?? batchData.mergeable,
44
+ mergeStateStatus: restState.mergeStateStatus ?? batchData.mergeStateStatus,
45
+ };
54
46
  }
55
47
  // Classify checks.
56
48
  const classifiedChecks = classifyChecks(batchData.checks);
@@ -112,46 +104,7 @@ export async function runCheck(opts) {
112
104
  actionable: actionableComments,
113
105
  },
114
106
  changesRequestedReviews: batchData.changesRequestedReviews,
115
- lastPushTime: opts.lastPushTime,
107
+ reviewSummaries: batchData.reviewSummaries,
108
+ approvedReviews: batchData.approvedReviews,
116
109
  };
117
110
  }
118
- // ---------------------------------------------------------------------------
119
- // Helpers
120
- // ---------------------------------------------------------------------------
121
- function computeStatus(verdict, unresolvedThreads, unresolvedComments, mergeStatus, changesRequestedReviews) {
122
- // Merge conflicts are always terminal regardless of CI state.
123
- if (mergeStatus.status === "CONFLICTS")
124
- return "FAILING";
125
- // Check CI state before merge-blocking states: BLOCKED/UNSTABLE/BEHIND are
126
- // often caused by CI not having passed yet, so they shouldn't mask IN_PROGRESS.
127
- // These merge-blocking states become PENDING (not FAILING) once CI is resolved.
128
- if (verdict.anyFailing)
129
- return "FAILING";
130
- if (verdict.anyInProgress)
131
- return "IN_PROGRESS";
132
- // BLOCKED solely because a human reviewer hasn't approved yet — shepherd is done, hand off.
133
- // copilotReviewInProgress means a bot still owes a review, which is not this case.
134
- if (verdict.allPassed &&
135
- unresolvedThreads === 0 &&
136
- unresolvedComments === 0 &&
137
- changesRequestedReviews === 0 &&
138
- mergeStatus.status === "BLOCKED" &&
139
- !mergeStatus.copilotReviewInProgress &&
140
- mergeStatus.reviewDecision === "REVIEW_REQUIRED") {
141
- return "READY";
142
- }
143
- if (mergeStatus.status === "BLOCKED" ||
144
- mergeStatus.status === "UNSTABLE" ||
145
- mergeStatus.status === "BEHIND")
146
- return "PENDING";
147
- if (mergeStatus.status === "UNKNOWN")
148
- return "UNKNOWN";
149
- if (changesRequestedReviews > 0)
150
- return "UNRESOLVED_COMMENTS";
151
- if (unresolvedThreads > 0 || unresolvedComments > 0)
152
- return "UNRESOLVED_COMMENTS";
153
- // DRAFT is treated the same as CLEAN for readiness — marking the PR ready resolves it.
154
- if ((mergeStatus.status === "CLEAN" || mergeStatus.status === "DRAFT") && verdict.allPassed)
155
- return "READY";
156
- return "UNKNOWN";
157
- }
@@ -0,0 +1,159 @@
1
+ import { execFile as execFileCb } from "node:child_process";
2
+ import { readFile, writeFile, unlink } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { getRepoInfo, getCurrentPrNumber, getPrHead, getCurrentBranch } from "../github/client.mjs";
7
+ import { fetchPrBatch } from "../github/batch.mjs";
8
+ import { applyResolveOptions } from "../comments/resolve.mjs";
9
+ import { parseSuggestion, isCommittableSuggestion } from "../suggestions/parse.mjs";
10
+ import { buildUnifiedDiff } from "../suggestions/patch.mjs";
11
+ const execFile = promisify(execFileCb);
12
+ export async function runCommitSuggestion(opts) {
13
+ if (!opts.threadId) {
14
+ throw new Error("--thread-id is required");
15
+ }
16
+ if (!opts.dryRun && (!opts.message || opts.message.trim() === "")) {
17
+ throw new Error("--message is required and must be non-empty");
18
+ }
19
+ const { stdout: statusOut } = await execFile("git", ["status", "--porcelain"]);
20
+ if (statusOut.trim() !== "") {
21
+ throw new Error("Working tree has uncommitted changes. Commit or stash them before running commit-suggestion.");
22
+ }
23
+ const repo = await getRepoInfo();
24
+ const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
25
+ if (prNumber === null) {
26
+ throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
27
+ }
28
+ const head = await getPrHead(prNumber, repo.owner, repo.name);
29
+ const currentBranch = await getCurrentBranch();
30
+ if (currentBranch !== head.ref) {
31
+ throw new Error(`Current branch "${currentBranch}" does not match PR head branch "${head.ref}". ` +
32
+ `Check out "${head.ref}" before applying suggestions.`);
33
+ }
34
+ const { stdout: localHeadOut } = await execFile("git", ["rev-parse", "HEAD"]);
35
+ const localHeadSha = localHeadOut.trim();
36
+ if (localHeadSha !== head.sha) {
37
+ throw new Error(`Local HEAD ${localHeadSha} does not match PR head ${head.sha}. ` +
38
+ `Pull/rebase "${head.ref}" to the latest PR head and try again.`);
39
+ }
40
+ const { data } = await fetchPrBatch(prNumber, repo);
41
+ const thread = data.reviewThreads.find((t) => t.id === opts.threadId);
42
+ if (!thread) {
43
+ throw new Error(`Thread ${opts.threadId} not found on PR #${prNumber}.`);
44
+ }
45
+ if (thread.isResolved) {
46
+ throw new Error(`Thread ${opts.threadId} is already resolved.`);
47
+ }
48
+ if (thread.isOutdated) {
49
+ throw new Error(`Thread ${opts.threadId} is outdated.`);
50
+ }
51
+ if (thread.isMinimized) {
52
+ throw new Error(`Thread ${opts.threadId} is minimized.`);
53
+ }
54
+ if (!thread.path || thread.line === null) {
55
+ throw new Error(`Thread ${opts.threadId} has no file/line anchor.`);
56
+ }
57
+ const parsed = parseSuggestion(thread.body);
58
+ if (!parsed) {
59
+ throw new Error(`Thread ${opts.threadId} has no suggestion block in the comment body.`);
60
+ }
61
+ if (!isCommittableSuggestion(parsed)) {
62
+ throw new Error(`Thread ${opts.threadId}'s suggestion body contains nested suggestion fencing or unbalanced ` +
63
+ `3+ backtick fences — refusing to apply (could silently truncate).`);
64
+ }
65
+ const startLine = thread.startLine ?? thread.line;
66
+ const endLine = thread.line;
67
+ const filePath = thread.path;
68
+ const originalContent = await readFile(filePath, "utf8");
69
+ const patch = buildUnifiedDiff({
70
+ path: filePath,
71
+ originalContent,
72
+ startLine,
73
+ endLine,
74
+ replacementLines: parsed.lines,
75
+ });
76
+ const patchFile = join(tmpdir(), `pr-shepherd-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`);
77
+ let patchError = null;
78
+ try {
79
+ await writeFile(patchFile, patch, { mode: 0o600 });
80
+ try {
81
+ await execFile("git", ["apply", "--check", patchFile]);
82
+ }
83
+ catch (err) {
84
+ patchError = (err.stderr?.trim() || String(err)).trim();
85
+ }
86
+ if (opts.dryRun) {
87
+ return {
88
+ pr: prNumber,
89
+ repo: `${repo.owner}/${repo.name}`,
90
+ threadId: opts.threadId,
91
+ path: filePath,
92
+ startLine,
93
+ endLine,
94
+ author: thread.author,
95
+ applied: false,
96
+ dryRun: true,
97
+ valid: patchError === null,
98
+ reason: patchError !== null ? `git apply rejected the patch: ${patchError}` : null,
99
+ patch,
100
+ postActionInstruction: patchError === null ? "Re-run without --dry-run to apply and commit." : "",
101
+ };
102
+ }
103
+ if (patchError !== null) {
104
+ return {
105
+ pr: prNumber,
106
+ repo: `${repo.owner}/${repo.name}`,
107
+ threadId: opts.threadId,
108
+ path: filePath,
109
+ startLine,
110
+ endLine,
111
+ author: thread.author,
112
+ applied: false,
113
+ reason: `git apply rejected the patch: ${patchError}`,
114
+ patch,
115
+ postActionInstruction: "",
116
+ };
117
+ }
118
+ try {
119
+ await execFile("git", ["apply", patchFile]);
120
+ }
121
+ catch (applyErr) {
122
+ try {
123
+ await execFile("git", ["checkout", "--", filePath]);
124
+ }
125
+ catch {
126
+ // best-effort rollback
127
+ }
128
+ throw applyErr;
129
+ }
130
+ }
131
+ finally {
132
+ await unlink(patchFile).catch(() => undefined);
133
+ }
134
+ await execFile("git", ["add", "--", filePath]);
135
+ const coAuthor = `Co-authored-by: ${thread.author} <${thread.author}@users.noreply.github.com>`;
136
+ const commitBody = opts.description ? `${opts.description}\n\n${coAuthor}` : coAuthor;
137
+ await execFile("git", ["commit", "-m", opts.message, "-m", commitBody]);
138
+ const { stdout: shaOut } = await execFile("git", ["rev-parse", "HEAD"]);
139
+ const commitSha = shaOut.trim();
140
+ const resolveResult = await applyResolveOptions(prNumber, repo, {
141
+ resolveThreadIds: [opts.threadId],
142
+ });
143
+ const resolveErrors = resolveResult.errors;
144
+ return {
145
+ pr: prNumber,
146
+ repo: `${repo.owner}/${repo.name}`,
147
+ threadId: opts.threadId,
148
+ path: filePath,
149
+ startLine,
150
+ endLine,
151
+ author: thread.author,
152
+ applied: true,
153
+ commitSha,
154
+ patch,
155
+ postActionInstruction: resolveErrors.length > 0
156
+ ? `Commit created (${commitSha}), but failed to resolve thread ${opts.threadId}: ${resolveErrors.join("; ")}. Run \`git push\` then resolve manually.`
157
+ : "Run `git push` (or `git push --force-with-lease` after rebasing) to publish the commit.",
158
+ };
159
+ }
@@ -0,0 +1,77 @@
1
+ // Logins treated as bot authors regardless of the GitHub Bot/User user type.
2
+ // Mirrors plugin/skills/resolve/SKILL.md §3 — kept in sync with the resolve triage guidance.
3
+ const KNOWN_BOT_LOGINS = new Set([
4
+ "copilot-pull-request-reviewer",
5
+ "gemini-code-assist",
6
+ "coderabbitai",
7
+ ]);
8
+ function isBotAuthor(login) {
9
+ const bare = login.replace(/\[bot\]$/, "");
10
+ if (bare !== login)
11
+ return true;
12
+ return KNOWN_BOT_LOGINS.has(bare);
13
+ }
14
+ export function classifyReviewSummaries(summaries, approvals, cfg) {
15
+ const minimizeIds = [];
16
+ const surfacedSummaries = [];
17
+ for (const r of summaries) {
18
+ const enabled = isBotAuthor(r.author) ? cfg.bots : cfg.humans;
19
+ if (enabled)
20
+ minimizeIds.push(r.id);
21
+ else
22
+ surfacedSummaries.push(r);
23
+ }
24
+ if (cfg.approvals) {
25
+ for (const r of approvals)
26
+ minimizeIds.push(r.id);
27
+ }
28
+ return { minimizeIds, surfacedSummaries };
29
+ }
30
+ // Patterns that indicate a comment is bot-generated noise rather than actionable feedback.
31
+ // Conservative: only match explicit known patterns to avoid accidentally suppressing real reviews.
32
+ const NOISE_PATTERNS = [
33
+ /you have reached your daily quota/i,
34
+ /please wait up to \d+ hours?/i,
35
+ /rate[\s-]?limit(?:ed)?\s*[-—:]\s*try again/i,
36
+ /resuming (monitoring|watch|checking)/i,
37
+ /restarting (monitoring|watch)/i,
38
+ ];
39
+ function isNoiseComment(comment) {
40
+ return NOISE_PATTERNS.some((p) => p.test(comment.body));
41
+ }
42
+ export function classifyComments(comments) {
43
+ const actionable = [];
44
+ const noiseIds = [];
45
+ for (const c of comments) {
46
+ if (isNoiseComment(c)) {
47
+ noiseIds.push(c.id);
48
+ }
49
+ else {
50
+ actionable.push(c);
51
+ }
52
+ }
53
+ return { actionable, noiseIds };
54
+ }
55
+ export function buildResolveCommand(threads, actionableComments, allCommentIds, reviews, checks, prNumber) {
56
+ const argv = ["npx", "pr-shepherd", "resolve", String(prNumber)];
57
+ if (threads.length > 0) {
58
+ argv.push("--resolve-thread-ids", threads.map((t) => t.id).join(","));
59
+ }
60
+ if (allCommentIds.length > 0) {
61
+ argv.push("--minimize-comment-ids", allCommentIds.join(","));
62
+ }
63
+ const hasDismiss = reviews.length > 0;
64
+ if (hasDismiss) {
65
+ argv.push("--dismiss-review-ids", reviews.map((r) => r.id).join(","));
66
+ argv.push("--message", "$DISMISS_MESSAGE");
67
+ }
68
+ // A push happens when there is code to change — threads, actionable comments, CI checks, or reviews.
69
+ // Noise-only comment minimization skips commit/push, so requiresHeadSha must be false.
70
+ const requiresHeadSha = threads.length > 0 || actionableComments.length > 0 || checks.length > 0 || reviews.length > 0;
71
+ // hasMutations = we appended at least one of --resolve-thread-ids,
72
+ // --minimize-comment-ids, or --dismiss-review-ids. Returned explicitly
73
+ // (rather than derived from argv.length) so callers don't couple to the
74
+ // base-argv shape.
75
+ const hasMutations = threads.length > 0 || allCommentIds.length > 0 || reviews.length > 0;
76
+ return { argv, requiresHeadSha, requiresDismissMessage: hasDismiss, hasMutations };
77
+ }
@@ -0,0 +1,124 @@
1
+ import { loadConfig } from "../../config/load.mjs";
2
+ export function checkEscalateTriggers(actionableThreads, actionableComments, changesRequestedReviews, actionableChecks, threadAttempts, hasConflicts) {
3
+ const triggers = [];
4
+ const maxAttempts = loadConfig().iterate.fixAttemptsPerThread;
5
+ // Trigger 1: fix thrash — same thread dispatched too many times without resolving.
6
+ const thrashThreads = actionableThreads.filter((t) => (threadAttempts[t.id] ?? 0) >= maxAttempts);
7
+ if (thrashThreads.length > 0) {
8
+ triggers.push("fix-thrash");
9
+ }
10
+ // Trigger 2: PR-level CHANGES_REQUESTED with no inline threads/comments/CI to act on.
11
+ // Skip when there are merge conflicts — fix_code handles conflict resolution, not escalation.
12
+ if (changesRequestedReviews.length > 0 &&
13
+ actionableThreads.length === 0 &&
14
+ actionableComments.length === 0 &&
15
+ actionableChecks.length === 0 &&
16
+ !hasConflicts) {
17
+ triggers.push("pr-level-changes-requested");
18
+ }
19
+ // Trigger 3: actionable thread has no file/line — cannot locate code to edit.
20
+ const unlocatable = actionableThreads.filter((t) => t.path === null || t.line === null);
21
+ if (unlocatable.length > 0) {
22
+ triggers.push("thread-missing-location");
23
+ }
24
+ return {
25
+ triggers,
26
+ thrashHistory: thrashThreads.length > 0
27
+ ? thrashThreads.map((t) => ({ threadId: t.id, attempts: threadAttempts[t.id] ?? 0 }))
28
+ : undefined,
29
+ };
30
+ }
31
+ /**
32
+ * Validate the base branch name from the GraphQL batch (`report.baseBranch`)
33
+ * and fall back safely if it's missing/unsafe. The branch is interpolated into
34
+ * shell commands by `buildRebaseShellScript` and `buildFixInstructions`, so we
35
+ * reject anything outside `[A-Za-z0-9._/-]` to prevent shell injection.
36
+ */
37
+ export function validateBaseBranch(raw) {
38
+ const trimmed = raw.trim();
39
+ if (trimmed === "") {
40
+ return {
41
+ branch: "main",
42
+ isFallback: true,
43
+ failureReason: "GraphQL batch returned an empty base branch name",
44
+ };
45
+ }
46
+ if (!/^[A-Za-z0-9._/-]+$/.test(trimmed)) {
47
+ return {
48
+ branch: "main",
49
+ isFallback: true,
50
+ failureReason: `base branch ${JSON.stringify(trimmed)} contains unsafe characters`,
51
+ };
52
+ }
53
+ return { branch: trimmed, isFallback: false };
54
+ }
55
+ export function buildRebaseShellScript(baseBranch) {
56
+ return [
57
+ `if ! git diff --quiet || ! git diff --cached --quiet; then`,
58
+ ` echo "SKIP rebase: dirty worktree (uncommitted changes present)"`,
59
+ ` exit 1`,
60
+ `fi`,
61
+ `git fetch origin && git rebase origin/${baseBranch} && git push --force-with-lease`,
62
+ ].join("\n");
63
+ }
64
+ export function buildEscalateHumanMessage(escalate, pr) {
65
+ const lines = [];
66
+ lines.push("⚠️ /pr-shepherd:monitor paused — needs human direction");
67
+ lines.push("");
68
+ lines.push(`**Triggers:** ${escalate.triggers.map((t) => `\`${t}\``).join(", ")}`);
69
+ lines.push("");
70
+ lines.push(escalate.suggestion);
71
+ const hasItems = escalate.unresolvedThreads.length > 0 ||
72
+ escalate.changesRequestedReviews.length > 0 ||
73
+ escalate.ambiguousComments.length > 0;
74
+ if (hasItems) {
75
+ lines.push("");
76
+ lines.push("## Items needing attention");
77
+ for (const t of escalate.unresolvedThreads) {
78
+ const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
79
+ const firstLine = t.body.split("\n")[0] ?? "";
80
+ lines.push(`- thread \`${t.id}\` — ${loc} (@${t.author}): ${firstLine}`);
81
+ }
82
+ for (const r of escalate.changesRequestedReviews) {
83
+ const firstLine = r.body.split("\n")[0] ?? "";
84
+ lines.push(`- review \`${r.id}\` (@${r.author}): ${firstLine}`);
85
+ }
86
+ for (const c of escalate.ambiguousComments) {
87
+ const firstLine = c.body.split("\n")[0] ?? "";
88
+ lines.push(`- comment \`${c.id}\` (@${c.author}): ${firstLine}`);
89
+ }
90
+ }
91
+ if (escalate.attemptHistory && escalate.attemptHistory.length > 0) {
92
+ lines.push("");
93
+ lines.push("## Fix attempts");
94
+ for (const a of escalate.attemptHistory) {
95
+ lines.push(`- thread \`${a.threadId}\` attempted ${a.attempts} times`);
96
+ }
97
+ }
98
+ lines.push("");
99
+ lines.push("---");
100
+ lines.push("");
101
+ lines.push(`Run \`/pr-shepherd:check ${pr}\` to see current state.`);
102
+ lines.push(`After fixing manually, rerun \`/pr-shepherd:monitor ${pr}\` to resume.`);
103
+ return lines.join("\n");
104
+ }
105
+ export function buildEscalateSuggestion(triggers, detail) {
106
+ if (triggers.includes("stall-timeout")) {
107
+ const mins = detail ?? "30";
108
+ return `No progress detected for ${mins} minute${parseInt(mins, 10) === 1 ? "" : "s"} — state has not changed. Inspect the PR and resume manually once the blocking issue is resolved.`;
109
+ }
110
+ if (triggers.includes("base-branch-unknown")) {
111
+ const reason = detail ? ` (${detail})` : "";
112
+ return `Could not determine the PR's base branch${reason} — refusing to emit a rebase that could force-push onto the wrong base. Run the rebase manually against the PR's real target branch.`;
113
+ }
114
+ if (triggers.includes("fix-thrash")) {
115
+ return "Same thread(s) attempted multiple times without resolution — fix manually then rerun /pr-shepherd:monitor";
116
+ }
117
+ if (triggers.includes("pr-level-changes-requested")) {
118
+ return "Reviewer requested changes but left no inline comments — read the review and act manually";
119
+ }
120
+ if (triggers.includes("thread-missing-location")) {
121
+ return "Review thread has no file/line reference — cannot locate code to edit automatically";
122
+ }
123
+ return "Ambiguous state — inspect the PR and act manually";
124
+ }