pr-shepherd 0.16.3 → 0.17.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 (50) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +16 -2
  3. package/bin/checks/triage.test-support.mjs +61 -0
  4. package/bin/cli/clean-formatter.mjs +20 -0
  5. package/bin/cli/fix-formatter.mjs +3 -10
  6. package/bin/cli/formatters.mjs +6 -10
  7. package/bin/cli/handlers.mjs +57 -1
  8. package/bin/cli/iterate-instructions.mjs +4 -2
  9. package/bin/cli/iterate-lean.test-support.mjs +5 -0
  10. package/bin/cli/list-formatters.mjs +21 -1
  11. package/bin/cli/runner.mjs +13 -3
  12. package/bin/cli-parser.clean.test-support.mjs +45 -0
  13. package/bin/cli-parser.commit-suggestion.test-support.mjs +66 -0
  14. package/bin/cli-parser.iterate-fix.test-support.mjs +48 -0
  15. package/bin/cli-parser.iterate-fixtures.mjs +1 -1
  16. package/bin/cli-parser.iterate.test-support.mjs +49 -0
  17. package/bin/cli-parser.mjs +6 -2
  18. package/bin/cli-parser.test-support.mjs +43 -0
  19. package/bin/commands/check.test-support.mjs +140 -0
  20. package/bin/commands/clean.mjs +156 -0
  21. package/bin/commands/clean.test-support.mjs +48 -0
  22. package/bin/commands/commit-suggestion.apply.test-support.mjs +87 -0
  23. package/bin/commands/commit-suggestion.test-support.mjs +112 -0
  24. package/bin/commands/iterate/classify.mjs +37 -9
  25. package/bin/commands/iterate/escalate.mjs +10 -8
  26. package/bin/commands/iterate/fix-code.mjs +17 -6
  27. package/bin/commands/iterate/index.mjs +19 -16
  28. package/bin/commands/iterate/render.mjs +12 -6
  29. package/bin/commands/iterate-stall.test-support.mjs +25 -0
  30. package/bin/commands/iterate-test-support.mjs +149 -0
  31. package/bin/commands/iterate.fix-code-in-progress.test-support.mjs +118 -0
  32. package/bin/commands/resolve.test-support.mjs +114 -0
  33. package/bin/commands/shepherd-journal.test-support.mjs +9 -0
  34. package/bin/comments/resolve.mjs +70 -11
  35. package/bin/comments/resolve.test-support.mjs +40 -0
  36. package/bin/github/batch-parsers.test-support.mjs +67 -0
  37. package/bin/github/batch.test-support.mjs +67 -0
  38. package/bin/github/client.mjs +10 -1
  39. package/bin/github/graphql-http.mjs +73 -0
  40. package/bin/github/http-auth.mjs +48 -0
  41. package/bin/github/http-request.mjs +15 -0
  42. package/bin/github/http-utils.mjs +34 -0
  43. package/bin/github/http.mjs +4 -319
  44. package/bin/github/http.test-support.mjs +52 -0
  45. package/bin/github/rest-http.mjs +131 -0
  46. package/bin/state/base.mjs +2 -1
  47. package/bin/suggestions/patch.test-support.mjs +4 -0
  48. package/package.json +2 -2
  49. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  50. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +4 -4
@@ -55,7 +55,7 @@ export function validateBaseBranch(raw) {
55
55
  }
56
56
  export function buildEscalateHumanMessage(escalate, pr) {
57
57
  const lines = [];
58
- lines.push("⚠️ /pr-shepherd:pr-shepherd paused — needs human direction");
58
+ lines.push("⚠️ /pr-shepherd:pr-shepherd paused — manual intervention required");
59
59
  lines.push("");
60
60
  lines.push(`**Triggers:** ${escalate.triggers.map((t) => `\`${t}\``).join(", ")}`);
61
61
  lines.push("");
@@ -66,6 +66,7 @@ export function buildEscalateHumanMessage(escalate, pr) {
66
66
  if (hasItems) {
67
67
  lines.push("");
68
68
  lines.push("## Items needing attention");
69
+ lines.push("");
69
70
  for (const t of escalate.unresolvedThreads) {
70
71
  const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
71
72
  const firstLine = t.body.split("\n")[0] ?? "";
@@ -83,6 +84,7 @@ export function buildEscalateHumanMessage(escalate, pr) {
83
84
  if (escalate.thrashHistory && escalate.thrashHistory.length > 0) {
84
85
  lines.push("");
85
86
  lines.push("## Fix attempts");
87
+ lines.push("");
86
88
  for (const a of escalate.thrashHistory) {
87
89
  lines.push(`- thread \`${a.threadId}\` attempted ${a.attempts} times`);
88
90
  }
@@ -90,26 +92,26 @@ export function buildEscalateHumanMessage(escalate, pr) {
90
92
  lines.push("");
91
93
  lines.push("---");
92
94
  lines.push("");
93
- lines.push(`After fixing manually, rerun \`/pr-shepherd:pr-shepherd ${pr}\` to resume.`);
95
+ lines.push(`After completing manual fixes (and pushing if required), rerun \`/pr-shepherd:pr-shepherd ${pr}\` to resume.`);
94
96
  return lines.join("\n");
95
97
  }
96
98
  export function buildEscalateSuggestion(triggers, detail) {
97
99
  if (triggers.includes("stall-timeout")) {
98
100
  const mins = detail ?? "30";
99
- 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.`;
101
+ return `No progress detected for ${mins} minute${parseInt(mins, 10) === 1 ? "" : "s"} — state has not changed. This is a manual checkpoint: inspect the PR and apply a manual fix before resuming.`;
100
102
  }
101
103
  if (triggers.includes("base-branch-unknown")) {
102
104
  const reason = detail ? ` (${detail})` : "";
103
- 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.`;
105
+ return `Could not determine the PR's base branch${reason} — automated rebases are paused because branch safety is unclear. Run the rebase manually against the PR's real target branch.`;
104
106
  }
105
107
  if (triggers.includes("fix-thrash")) {
106
- return "Same thread(s) attempted multiple times without resolution — fix manually then rerun /pr-shepherd:pr-shepherd";
108
+ return "Same thread(s) reached the automated attempt limit — treat this as a manual handoff. Apply the fix by hand.";
107
109
  }
108
110
  if (triggers.includes("pr-level-changes-requested")) {
109
- return "Reviewer requested changes but left no inline comments — read the review and act manually";
111
+ return "Reviewer requested changes but left no inline comments — read the review and act manually.";
110
112
  }
111
113
  if (triggers.includes("thread-missing-location")) {
112
- return "Review thread has no file/line reference — cannot locate code to edit automatically";
114
+ return "Review thread has no file/line reference — automated location routing failed and manual handling is required.";
113
115
  }
114
- return "Ambiguous state — inspect the PR and act manually";
116
+ return "Ambiguous state — automated handling cannot proceed safely. Inspect the PR and act manually.";
115
117
  }
@@ -1,3 +1,4 @@
1
+ /* eslint-disable max-lines */
1
2
  import { readFixAttempts, writeFixAttempts } from "../../state/fix-attempts.mjs";
2
3
  import { toAgentThread, toAgentComment, toAgentChecks } from "../../reporters/agent.mjs";
3
4
  import { checkEscalateTriggers, validateBaseBranch, buildEscalateSuggestion, buildEscalateHumanMessage, } from "./escalate.mjs";
@@ -52,14 +53,24 @@ export async function handleFixCode(ctx) {
52
53
  const checks = toAgentChecks(failingChecks);
53
54
  const { changesRequestedReviews } = report;
54
55
  const hasConflicts = report.mergeStatus.status === "CONFLICTS";
55
- const hasGuaranteedSupersedingPush = threads.length > 0 || checks.length > 0 || changesRequestedReviews.length > 0 || hasConflicts;
56
- const inProgressRunIds = hasGuaranteedSupersedingPush
57
- ? buildInProgressRunIds(report, cancelledSet)
58
- : [];
56
+ const hasReviewRequestedCodeLikeChanges = changesRequestedReviews.length > 0 &&
57
+ (actionableComments.length > 0 || resolutionOnlyThreads.length > 0);
58
+ const hasGuaranteedPush = threads.length > 0 || checks.length > 0 || hasConflicts || hasReviewRequestedCodeLikeChanges;
59
+ const shouldPush = hasGuaranteedPush;
60
+ // Only cancel in-progress runs for paths that produce a new code commit. A
61
+ // conflict-only rebase push will supersede any in-progress run on its own.
62
+ const hasCodeLikePush = threads.length > 0 || checks.length > 0 || hasReviewRequestedCodeLikeChanges;
63
+ const inProgressRunIds = hasCodeLikePush ? buildInProgressRunIds(report, cancelledSet) : [];
59
64
  const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
60
65
  const allCommentIds = [...commentMinimizeIds, ...reviewSummaryIds];
61
66
  const resolveCommand = buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, checks, prNumber, cliRunner);
62
- if (baseLookup.isFallback && (resolveCommand.requiresHeadSha || hasConflicts)) {
67
+ const overlappingReviewIds = resolveCommand.droppedDismissReviewIds ?? [];
68
+ if (overlappingReviewIds.length > 0) {
69
+ process.stderr.write(`pr-shepherd: resolve command overlap: ${overlappingReviewIds.length} ` +
70
+ `review IDs were also in minimize/comment IDs and were dropped from --dismiss-review-ids: ` +
71
+ `${overlappingReviewIds.join(", ")}\n`);
72
+ }
73
+ if (baseLookup.isFallback && shouldPush) {
63
74
  const fallbackEscalateBase = {
64
75
  triggers: ["base-branch-unknown"],
65
76
  unresolvedThreads: [...threads, ...resolutionOnlyThreads.map(toAgentThread)],
@@ -78,7 +89,7 @@ export async function handleFixCode(ctx) {
78
89
  }
79
90
  const firstLookThreads = report.threads.firstLook;
80
91
  const firstLookComments = report.comments.firstLook;
81
- const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, cliRunner);
92
+ const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, cliRunner, shouldPush);
82
93
  return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, {
83
94
  ...base,
84
95
  baseBranch: baseLookup.branch,
@@ -51,8 +51,25 @@ export async function runIterate(opts) {
51
51
  log: `CANCEL: PR #${report.pr} is ${state} — stopping`,
52
52
  };
53
53
  }
54
- const isReady = report.status === "READY";
55
- const readyState = await updateReadyDelay(report.pr, isReady, readyDelaySeconds, repoOwner, repoName);
54
+ const { minimizeIds: reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, } = classifyReviewSummaries({
55
+ firstLook: report.firstLookSummaries,
56
+ seen: report.reviewSummaries,
57
+ edited: report.editedSummaries,
58
+ }, report.approvedReviews, config.iterate.minimizeApprovals, config.iterate.minimizeComments);
59
+ const hasActionableWork = report.threads.actionable.length > 0 ||
60
+ report.threads.resolutionOnly.length > 0 ||
61
+ report.threads.firstLook.length > 0 ||
62
+ report.comments.actionable.length > 0 ||
63
+ (report.comments.minimizeIds?.length ?? 0) > 0 ||
64
+ report.comments.firstLook.length > 0 ||
65
+ report.changesRequestedReviews.length > 0 ||
66
+ report.checks.failing.length > 0 ||
67
+ report.mergeStatus.status === "CONFLICTS" ||
68
+ reviewSummaryIds.length > 0 ||
69
+ firstLookSummaries.length > 0 ||
70
+ editedSummaries.length > 0;
71
+ const isCleanReadyHandoff = report.status === "READY" && !hasActionableWork;
72
+ const readyState = await updateReadyDelay(report.pr, isCleanReadyHandoff, readyDelaySeconds, repoOwner, repoName);
56
73
  const base = {
57
74
  pr: report.pr,
58
75
  repo: report.repo,
@@ -88,20 +105,6 @@ export async function runIterate(opts) {
88
105
  };
89
106
  }
90
107
  const headSha = (await getCurrentHeadSha()) ?? "unknown";
91
- const { minimizeIds: reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, } = classifyReviewSummaries({
92
- firstLook: report.firstLookSummaries,
93
- seen: report.reviewSummaries,
94
- edited: report.editedSummaries,
95
- }, report.approvedReviews, config.iterate.minimizeApprovals, config.iterate.minimizeComments);
96
- const hasActionableWork = report.threads.actionable.length > 0 ||
97
- report.threads.resolutionOnly.length > 0 ||
98
- report.comments.actionable.length > 0 ||
99
- report.changesRequestedReviews.length > 0 ||
100
- report.checks.failing.length > 0 ||
101
- report.mergeStatus.status === "CONFLICTS" ||
102
- reviewSummaryIds.length > 0 ||
103
- firstLookSummaries.length > 0 ||
104
- editedSummaries.length > 0;
105
108
  if (hasActionableWork) {
106
109
  return handleFixCode({
107
110
  base,
@@ -16,8 +16,10 @@ export function renderResolveCommand(rc) {
16
16
  }
17
17
  return renderShellCommand(parts);
18
18
  }
19
- export function buildFixInstructions(threads, actionableComments, checks, reviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = [], runner) {
19
+ export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = [], runner, needsPushInput) {
20
20
  const instructions = [];
21
+ const hasCodeWork = threads.length > 0 || checks.length > 0;
22
+ const needsPush = needsPushInput ?? (hasCodeWork || hasConflicts);
21
23
  if (inProgressRunIds.length > 0) {
22
24
  instructions.push(`Cancel in-progress CI runs first: for each ID under \`## In-progress runs\`, run \`gh run cancel <id>\` before applying code fixes. If \`gh\` reports a run is already completed, ignore it and continue with the next ID.`);
23
25
  }
@@ -35,15 +37,18 @@ export function buildFixInstructions(threads, actionableComments, checks, review
35
37
  instructions.push(`Resolve the threads under \`## Review threads to resolve\` with the \`resolve:\` command shown below. These threads are already outdated or minimized, so no code edit is required for them unless their body reveals separate work you choose to do.`);
36
38
  }
37
39
  instructions.push(...buildFailingCheckInstructions(checks));
38
- if (reviews.length > 0) {
40
+ if (changesRequestedReviews.length > 0) {
39
41
  instructions.push(`For each bullet under \`## Changes-requested reviews\` above: read the review body and apply the requested changes.`);
40
42
  }
41
- const hasCodeChanges = threads.length > 0 || checks.length > 0 || reviews.length > 0;
42
- const needsPush = hasCodeChanges || hasConflicts;
43
- if (hasCodeChanges) {
43
+ if (needsPush && (hasCodeWork || changesRequestedReviews.length > 0)) {
44
44
  instructions.push(`Commit changed files: \`git add <files> && git commit -m "<descriptive message>"\``);
45
+ }
46
+ if (changesRequestedReviews.length > 0) {
45
47
  instructions.push(`Keep the PR title and description current: if the changes alter the PR's scope or intent, run \`gh pr edit ${prNumber} --title "<new title>" --body "<new body>"\` to reflect them. Skip if the existing title/body still accurately describe the PR.`);
46
48
  }
49
+ if (!needsPush && resolveCommand.requiresHeadSha) {
50
+ instructions.push("Capture the current HEAD SHA before resolving with: `HEAD_SHA=$(git rev-parse HEAD)`.");
51
+ }
47
52
  if (needsPush) {
48
53
  const captureHint = resolveCommand.requiresHeadSha
49
54
  ? ` — capture \`HEAD_SHA=$(git rev-parse HEAD)\``
@@ -71,7 +76,8 @@ export function buildFixInstructions(threads, actionableComments, checks, review
71
76
  if (resolveCommand.hasMutations) {
72
77
  const substituteParts = [];
73
78
  if (resolveCommand.requiresHeadSha) {
74
- substituteParts.push(`"$HEAD_SHA" with the pushed commit SHA`);
79
+ const shaSource = needsPush ? "pushed commit SHA" : "current HEAD SHA";
80
+ substituteParts.push(`"$HEAD_SHA" with the ${shaSource}`);
75
81
  }
76
82
  if (resolveCommand.requiresDismissMessage) {
77
83
  substituteParts.push(`$DISMISS_MESSAGE with a one-sentence description of what you changed`);
@@ -0,0 +1,25 @@
1
+ // @ts-nocheck
2
+ import { vi } from "vitest";
3
+ import { NOW, makeOpts } from "./iterate-test-support.mjs";
4
+ import { readStallState, writeStallState } from "../state/iterate-stall.mjs";
5
+ const mockReadStallState = vi.mocked(readStallState);
6
+ const mockWriteStallState = vi.mocked(writeStallState);
7
+ const STALL_TIMEOUT_S = 1800;
8
+ const RESOLUTION_ONLY_THREAD = {
9
+ id: "thread-resolution-only",
10
+ isResolved: false,
11
+ isOutdated: true,
12
+ isMinimized: false,
13
+ path: "src/old.mts",
14
+ line: null,
15
+ startLine: null,
16
+ author: "reviewer",
17
+ authorType: "Unknown",
18
+ body: "Already addressed on an old diff",
19
+ url: "",
20
+ createdAtUnix: NOW - 3600,
21
+ };
22
+ function makeOpts30mStall(overrides = {}) {
23
+ return makeOpts({ stallTimeoutSeconds: STALL_TIMEOUT_S, noAutoMarkReady: true, ...overrides });
24
+ }
25
+ export { RESOLUTION_ONLY_THREAD, STALL_TIMEOUT_S, makeOpts30mStall, mockReadStallState, mockWriteStallState, };
@@ -0,0 +1,149 @@
1
+ import { vi, beforeEach, afterEach } from "vitest";
2
+ const mockFetch = vi.fn();
3
+ vi.stubGlobal("fetch", mockFetch);
4
+ const { mockExecFile } = vi.hoisted(() => ({ mockExecFile: vi.fn() }));
5
+ vi.mock("node:child_process", () => ({
6
+ execFile: (cmd, args, optsOrCb, maybeCb) => {
7
+ const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
8
+ mockExecFile(cmd, args)
9
+ .then((result) => cb(null, result))
10
+ .catch((err) => cb(err, { stdout: "", stderr: err.stderr ?? "" }));
11
+ },
12
+ }));
13
+ vi.mock("./check.mts", () => ({ runCheck: vi.fn() }));
14
+ vi.mock("./ready-delay.mts", () => ({ updateReadyDelay: vi.fn() }));
15
+ vi.mock("../github/client.mts", () => ({ getCurrentPrNumber: vi.fn().mockResolvedValue(42) }));
16
+ vi.mock("../state/fix-attempts.mts", () => ({
17
+ readFixAttempts: vi.fn().mockResolvedValue(null),
18
+ writeFixAttempts: vi.fn().mockResolvedValue(undefined),
19
+ }));
20
+ vi.mock("../state/iterate-stall.mts", () => ({
21
+ readStallState: vi.fn().mockResolvedValue(null),
22
+ writeStallState: vi.fn().mockResolvedValue(undefined),
23
+ clearStallState: vi.fn().mockResolvedValue(undefined),
24
+ }));
25
+ const { mockLoadConfig } = vi.hoisted(() => ({ mockLoadConfig: vi.fn() }));
26
+ vi.mock("../config/load.mts", () => ({ loadConfig: mockLoadConfig }));
27
+ import { runCheck } from "./check.mjs";
28
+ import { updateReadyDelay } from "./ready-delay.mjs";
29
+ import { getCurrentPrNumber } from "../github/client.mjs";
30
+ import { readFixAttempts, writeFixAttempts } from "../state/fix-attempts.mjs";
31
+ import { clearStallState, readStallState, writeStallState } from "../state/iterate-stall.mjs";
32
+ import { buildEscalateHumanMessage, buildEscalateSuggestion, checkEscalateTriggers, } from "./iterate/escalate.mjs";
33
+ import { buildRelevantChecks, buildWaitLog, getCurrentHeadSha } from "./iterate/helpers.mjs";
34
+ const mockRunCheck = vi.mocked(runCheck);
35
+ const mockUpdateReadyDelay = vi.mocked(updateReadyDelay);
36
+ const mockGetCurrentPrNumber = vi.mocked(getCurrentPrNumber);
37
+ const mockReadFixAttempts = vi.mocked(readFixAttempts);
38
+ const mockWriteFixAttempts = vi.mocked(writeFixAttempts);
39
+ const mockReadStallState = vi.mocked(readStallState);
40
+ const mockWriteStallState = vi.mocked(writeStallState);
41
+ const mockClearStallState = vi.mocked(clearStallState);
42
+ const NOW = 1_700_000_000;
43
+ const READY_STATE_DEFAULT = { isReady: true, shouldCancel: false, remainingSeconds: 300 };
44
+ function makeReport(overrides = {}) {
45
+ return {
46
+ pr: 42,
47
+ nodeId: "PR_kgDOAAA",
48
+ repo: "owner/repo",
49
+ status: "READY",
50
+ baseBranch: "main",
51
+ mergeStatus: {
52
+ status: "CLEAN",
53
+ state: "OPEN",
54
+ isDraft: false,
55
+ mergeable: "MERGEABLE",
56
+ reviewDecision: "APPROVED",
57
+ blockingBotReviewInProgress: false,
58
+ mergeStateStatus: "CLEAN",
59
+ },
60
+ checks: {
61
+ passing: [
62
+ {
63
+ name: "ci",
64
+ status: "COMPLETED",
65
+ conclusion: "SUCCESS",
66
+ detailsUrl: "https://github.com/owner/repo/actions/runs/1",
67
+ event: "pull_request",
68
+ runId: "run-1",
69
+ category: "passed",
70
+ },
71
+ ],
72
+ failing: [],
73
+ inProgress: [],
74
+ skipped: [],
75
+ filtered: [],
76
+ filteredNames: [],
77
+ blockedByFilteredCheck: false,
78
+ },
79
+ threads: {
80
+ actionable: [],
81
+ resolutionOnly: [],
82
+ autoResolved: [],
83
+ autoResolveErrors: [],
84
+ firstLook: [],
85
+ },
86
+ comments: { actionable: [], firstLook: [] },
87
+ changesRequestedReviews: [],
88
+ reviewSummaries: [],
89
+ firstLookSummaries: [],
90
+ editedSummaries: [],
91
+ approvedReviews: [],
92
+ ...overrides,
93
+ };
94
+ }
95
+ function makeOpts(overrides = {}) {
96
+ return { prNumber: 42, format: "json", readyDelaySeconds: 600, ...overrides };
97
+ }
98
+ function makeReview(id, author, body) {
99
+ return { id, author, authorType: "Unknown", body };
100
+ }
101
+ function defaultConfig() {
102
+ return {
103
+ iterate: {
104
+ fixAttemptsPerThread: 3,
105
+ stallTimeoutMinutes: 30,
106
+ minimizeApprovals: false,
107
+ minimizeComments: "all",
108
+ },
109
+ watch: { readyDelayMinutes: 10 },
110
+ resolve: {
111
+ concurrency: 4,
112
+ shaPoll: { intervalMs: 2000, maxAttempts: 10 },
113
+ fetchReviewSummaries: true,
114
+ },
115
+ checks: { ciTriggerEvents: ["pull_request", "pull_request_target"] },
116
+ mergeStatus: { blockingReviewerLogins: ["copilot"] },
117
+ actions: { autoResolveOutdated: true, autoMarkReady: true, commitSuggestions: true },
118
+ };
119
+ }
120
+ function registerIterateHooks(config = defaultConfig) {
121
+ beforeEach(() => {
122
+ vi.clearAllMocks();
123
+ mockLoadConfig.mockReturnValue(config());
124
+ process.env["GH_TOKEN"] = "test-token";
125
+ mockExecFile.mockImplementation((cmd, args) => {
126
+ if (cmd === "git" && args[0] === "rev-parse") {
127
+ return Promise.resolve({ stdout: "abc123", stderr: "" });
128
+ }
129
+ return Promise.resolve({ stdout: "", stderr: "" });
130
+ });
131
+ mockFetch.mockResolvedValue({
132
+ ok: true,
133
+ status: 200,
134
+ headers: new Headers({ "content-type": "application/json" }),
135
+ json: () => Promise.resolve({ data: {} }),
136
+ text: () => Promise.resolve('{"data":{}}'),
137
+ });
138
+ vi.useFakeTimers();
139
+ vi.setSystemTime(NOW * 1000);
140
+ mockUpdateReadyDelay.mockResolvedValue(READY_STATE_DEFAULT);
141
+ mockReadFixAttempts.mockResolvedValue(null);
142
+ mockWriteFixAttempts.mockResolvedValue(undefined);
143
+ mockReadStallState.mockResolvedValue(null);
144
+ mockWriteStallState.mockResolvedValue(undefined);
145
+ mockClearStallState.mockResolvedValue(undefined);
146
+ });
147
+ afterEach(() => vi.useRealTimers());
148
+ }
149
+ export { NOW, buildEscalateHumanMessage, buildEscalateSuggestion, buildRelevantChecks, buildWaitLog, checkEscalateTriggers, defaultConfig, getCurrentHeadSha, makeOpts, makeReport, makeReview, mockExecFile, mockFetch, mockGetCurrentPrNumber, mockClearStallState, mockLoadConfig, mockReadFixAttempts, mockReadStallState, mockRunCheck, mockUpdateReadyDelay, mockWriteFixAttempts, mockWriteStallState, registerIterateHooks, };
@@ -0,0 +1,118 @@
1
+ // @ts-nocheck
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
+ const mockFetch = vi.fn();
4
+ vi.stubGlobal("fetch", mockFetch);
5
+ const { mockExecFile } = vi.hoisted(() => ({ mockExecFile: vi.fn() }));
6
+ vi.mock("node:child_process", () => ({
7
+ execFile: (cmd, args, optsOrCb, maybeCb) => {
8
+ const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
9
+ mockExecFile(cmd, args)
10
+ .then((result) => cb(null, result))
11
+ .catch((err) => cb(err, { stdout: "", stderr: err.stderr ?? "" }));
12
+ },
13
+ }));
14
+ vi.mock("./check.mts", () => ({ runCheck: vi.fn() }));
15
+ vi.mock("./ready-delay.mts", () => ({ updateReadyDelay: vi.fn() }));
16
+ vi.mock("../github/client.mts", () => ({
17
+ getCurrentPrNumber: vi.fn().mockResolvedValue(42),
18
+ }));
19
+ vi.mock("../state/fix-attempts.mts", () => ({
20
+ readFixAttempts: vi.fn().mockResolvedValue(null),
21
+ writeFixAttempts: vi.fn().mockResolvedValue(undefined),
22
+ }));
23
+ vi.mock("../state/iterate-stall.mts", () => ({
24
+ readStallState: vi.fn().mockResolvedValue(null),
25
+ writeStallState: vi.fn().mockResolvedValue(undefined),
26
+ }));
27
+ const { mockLoadConfig } = vi.hoisted(() => ({ mockLoadConfig: vi.fn() }));
28
+ vi.mock("../config/load.mts", () => ({ loadConfig: mockLoadConfig }));
29
+ import { runIterate } from "./iterate/index.mjs";
30
+ import { runCheck } from "./check.mjs";
31
+ import { updateReadyDelay } from "./ready-delay.mjs";
32
+ import { readFixAttempts, writeFixAttempts } from "../state/fix-attempts.mjs";
33
+ import { readStallState, writeStallState } from "../state/iterate-stall.mjs";
34
+ const mockRunCheck = vi.mocked(runCheck);
35
+ const mockUpdateReadyDelay = vi.mocked(updateReadyDelay);
36
+ const mockReadFixAttempts = vi.mocked(readFixAttempts);
37
+ const mockWriteFixAttempts = vi.mocked(writeFixAttempts);
38
+ const mockReadStallState = vi.mocked(readStallState);
39
+ const mockWriteStallState = vi.mocked(writeStallState);
40
+ function makeReport(overrides = {}) {
41
+ return {
42
+ pr: 42,
43
+ nodeId: "PR_kgDOAAA",
44
+ repo: "owner/repo",
45
+ status: "READY",
46
+ baseBranch: "main",
47
+ mergeStatus: {
48
+ status: "CLEAN",
49
+ state: "OPEN",
50
+ isDraft: false,
51
+ mergeable: "MERGEABLE",
52
+ reviewDecision: "APPROVED",
53
+ blockingBotReviewInProgress: false,
54
+ mergeStateStatus: "CLEAN",
55
+ },
56
+ checks: {
57
+ passing: [],
58
+ failing: [],
59
+ inProgress: [],
60
+ skipped: [],
61
+ filtered: [],
62
+ filteredNames: [],
63
+ blockedByFilteredCheck: false,
64
+ },
65
+ threads: {
66
+ actionable: [],
67
+ resolutionOnly: [],
68
+ autoResolved: [],
69
+ autoResolveErrors: [],
70
+ firstLook: [],
71
+ },
72
+ comments: { actionable: [], firstLook: [] },
73
+ changesRequestedReviews: [],
74
+ reviewSummaries: [],
75
+ firstLookSummaries: [],
76
+ editedSummaries: [],
77
+ approvedReviews: [],
78
+ ...overrides,
79
+ };
80
+ }
81
+ function makeOpts(overrides = {}) {
82
+ return {
83
+ prNumber: 42,
84
+ format: "json",
85
+ readyDelaySeconds: 600,
86
+ ...overrides,
87
+ };
88
+ }
89
+ export function registerHooks() {
90
+ beforeEach(() => {
91
+ vi.clearAllMocks();
92
+ mockExecFile.mockResolvedValue({ stdout: "abc1234\n", stderr: "" });
93
+ mockLoadConfig.mockReturnValue({
94
+ iterate: {
95
+ fixAttemptsPerThread: 3,
96
+ stallTimeoutMinutes: 60,
97
+ minimizeApprovals: false,
98
+ },
99
+ watch: { readyDelayMinutes: 10 },
100
+ resolve: {
101
+ concurrency: 4,
102
+ shaPoll: { intervalMs: 2000, maxAttempts: 10 },
103
+ fetchReviewSummaries: true,
104
+ },
105
+ checks: { ciTriggerEvents: ["pull_request"] },
106
+ mergeStatus: { blockingReviewerLogins: [] },
107
+ actions: { autoResolveOutdated: false, autoMarkReady: false, commitSuggestions: false },
108
+ });
109
+ mockReadFixAttempts.mockResolvedValue(null);
110
+ mockWriteFixAttempts.mockResolvedValue(undefined);
111
+ mockReadStallState.mockResolvedValue(null);
112
+ mockWriteStallState.mockResolvedValue(undefined);
113
+ });
114
+ afterEach(() => {
115
+ vi.restoreAllMocks();
116
+ });
117
+ }
118
+ export { makeOpts, makeReport, mockExecFile, mockFetch, mockLoadConfig, mockReadFixAttempts, mockReadStallState, mockRunCheck, mockUpdateReadyDelay, mockWriteFixAttempts, mockWriteStallState, readFixAttempts, readStallState, runCheck, runIterate, updateReadyDelay, writeFixAttempts, writeStallState, };
@@ -0,0 +1,114 @@
1
+ // @ts-nocheck
2
+ import { describe, it, expect, vi, beforeEach } from "vitest";
3
+ vi.mock("../github/client.mts", () => ({
4
+ getRepoInfo: vi.fn().mockResolvedValue({ owner: "owner", name: "repo" }),
5
+ getCurrentPrNumber: vi.fn().mockResolvedValue(42),
6
+ }));
7
+ vi.mock("../state/seen-comments.mts", async (importOriginal) => {
8
+ const actual = await importOriginal();
9
+ return {
10
+ ...actual,
11
+ loadSeenMap: vi.fn().mockResolvedValue(new Map()),
12
+ markSeen: vi.fn().mockResolvedValue(undefined),
13
+ };
14
+ });
15
+ vi.mock("../github/batch.mts", () => ({
16
+ fetchPrBatch: vi.fn(),
17
+ }));
18
+ vi.mock("../comments/resolve.mts", () => ({
19
+ autoResolveOutdated: vi.fn(),
20
+ applyResolveOptions: vi.fn(),
21
+ }));
22
+ vi.mock("../config/load.mts", () => ({
23
+ loadConfig: vi.fn().mockReturnValue({
24
+ resolve: {
25
+ shaPoll: { intervalMs: 2000, maxAttempts: 10 },
26
+ fetchReviewSummaries: true,
27
+ },
28
+ actions: {
29
+ autoResolveOutdated: true,
30
+ autoMarkReady: true,
31
+ commitSuggestions: true,
32
+ },
33
+ }),
34
+ }));
35
+ import { runResolveFetch, runResolveMutate } from "./resolve.mjs";
36
+ import { getCurrentPrNumber } from "../github/client.mjs";
37
+ import { fetchPrBatch } from "../github/batch.mjs";
38
+ import { autoResolveOutdated, applyResolveOptions } from "../comments/resolve.mjs";
39
+ import { loadConfig } from "../config/load.mjs";
40
+ import { loadSeenMap, markSeen, hashBody } from "../state/seen-comments.mjs";
41
+ const mockGetCurrentPrNumber = vi.mocked(getCurrentPrNumber);
42
+ const mockFetchPrBatch = vi.mocked(fetchPrBatch);
43
+ const mockAutoResolveOutdated = vi.mocked(autoResolveOutdated);
44
+ const mockApplyResolveOptions = vi.mocked(applyResolveOptions);
45
+ const mockLoadConfig = vi.mocked(loadConfig);
46
+ const mockLoadSeenMap = vi.mocked(loadSeenMap);
47
+ const mockMarkSeen = vi.mocked(markSeen);
48
+ const BASE_OPTS = { format: "text" };
49
+ function makeBatchData(overrides = {}) {
50
+ return {
51
+ nodeId: "PR_kgDOAAA",
52
+ number: 42,
53
+ state: "OPEN",
54
+ isDraft: false,
55
+ mergeable: "MERGEABLE",
56
+ mergeStateStatus: "CLEAN",
57
+ reviewDecision: "APPROVED",
58
+ headRefOid: "abc123",
59
+ headRefName: "feature",
60
+ headRepoWithOwner: "owner/repo",
61
+ baseRefName: "main",
62
+ reviewRequests: [],
63
+ latestReviews: [],
64
+ reviewThreads: [],
65
+ comments: [],
66
+ changesRequestedReviews: [],
67
+ reviewSummaries: [],
68
+ approvedReviews: [],
69
+ checks: [],
70
+ ...overrides,
71
+ };
72
+ }
73
+ function makeThread(overrides = {}) {
74
+ return {
75
+ id: "t-1",
76
+ isResolved: false,
77
+ isOutdated: false,
78
+ isMinimized: false,
79
+ path: "src/foo.ts",
80
+ line: 1,
81
+ startLine: null,
82
+ author: "alice",
83
+ authorType: "Unknown",
84
+ body: "fix this",
85
+ url: "",
86
+ createdAtUnix: 1_700_000_000,
87
+ ...overrides,
88
+ };
89
+ }
90
+ function makeComment(overrides = {}) {
91
+ return {
92
+ id: "c-1",
93
+ isMinimized: false,
94
+ author: "bob",
95
+ authorType: "Unknown",
96
+ body: "nit",
97
+ url: "",
98
+ createdAtUnix: 1_700_000_000,
99
+ ...overrides,
100
+ };
101
+ }
102
+ export function registerHooks() {
103
+ beforeEach(() => {
104
+ vi.clearAllMocks();
105
+ mockAutoResolveOutdated.mockResolvedValue({ resolved: [], errors: [] });
106
+ mockApplyResolveOptions.mockResolvedValue({
107
+ resolvedThreads: [],
108
+ minimizedComments: [],
109
+ dismissedReviews: [],
110
+ errors: [],
111
+ });
112
+ });
113
+ }
114
+ export { BASE_OPTS, applyResolveOptions, autoResolveOutdated, fetchPrBatch, getCurrentPrNumber, hashBody, loadConfig, loadSeenMap, makeBatchData, makeComment, makeThread, markSeen, mockApplyResolveOptions, mockAutoResolveOutdated, mockFetchPrBatch, mockGetCurrentPrNumber, mockLoadConfig, mockLoadSeenMap, mockMarkSeen, runResolveFetch, runResolveMutate, };
@@ -0,0 +1,9 @@
1
+ // @ts-nocheck
2
+ import { describe, expect, it } from "vitest";
3
+ import { buildShepherdJournalInstruction, SHEPHERD_JOURNAL_APPEND_HINT, SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEMS, SHEPHERD_JOURNAL_SECTION, SHEPHERD_JOURNAL_SECTION_PATTERN, } from "./shepherd-journal.mjs";
4
+ import { buildFixInstructions } from "./iterate/render.mjs";
5
+ import { buildFetchInstructions } from "./resolve-instructions.mjs";
6
+ function countMentions(text, phrase) {
7
+ return (text.match(new RegExp(phrase, "g")) ?? []).length;
8
+ }
9
+ export { SHEPHERD_JOURNAL_APPEND_HINT, SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEMS, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS, SHEPHERD_JOURNAL_SECTION, SHEPHERD_JOURNAL_SECTION_PATTERN, buildFetchInstructions, buildFixInstructions, buildShepherdJournalInstruction, countMentions, };