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
@@ -0,0 +1,43 @@
1
+ // @ts-nocheck
2
+ import { readFileSync } from "node:fs";
3
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
4
+ vi.mock("./commands/resolve.mts", () => ({
5
+ runResolveFetch: vi.fn(),
6
+ runResolveMutate: vi.fn(),
7
+ }));
8
+ vi.mock("./commands/log-file.mts", () => ({
9
+ runLogFile: vi.fn(),
10
+ }));
11
+ vi.mock("./commands/commit-suggestion.mts", () => ({
12
+ runCommitSuggestion: vi.fn(),
13
+ }));
14
+ vi.mock("./commands/iterate/index.mts", async (importOriginal) => {
15
+ const actual = await importOriginal();
16
+ return { ...actual, runIterate: vi.fn() };
17
+ });
18
+ import { main } from "./cli-parser.mjs";
19
+ import { runLogFile } from "./commands/log-file.mjs";
20
+ import { runResolveFetch, runResolveMutate } from "./commands/resolve.mjs";
21
+ const mockRunResolveFetch = vi.mocked(runResolveFetch);
22
+ const mockRunResolveMutate = vi.mocked(runResolveMutate);
23
+ const mockRunLogFile = vi.mocked(runLogFile);
24
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
25
+ let stdoutSpy;
26
+ let stderrSpy;
27
+ function getStdout() {
28
+ return stdoutSpy.mock.calls.map((c) => c[0]).join("");
29
+ }
30
+ export function registerHooks() {
31
+ beforeEach(() => {
32
+ vi.clearAllMocks();
33
+ process.exitCode = undefined;
34
+ stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
35
+ stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
36
+ });
37
+ afterEach(() => {
38
+ process.exitCode = undefined;
39
+ stdoutSpy.mockRestore();
40
+ stderrSpy.mockRestore();
41
+ });
42
+ }
43
+ export { getStdout, main, mockRunLogFile, mockRunResolveFetch, mockRunResolveMutate, readFileSync, runLogFile, runResolveFetch, runResolveMutate, stderrSpy, stdoutSpy, };
@@ -0,0 +1,140 @@
1
+ // @ts-nocheck
2
+ import { describe, it, expect, vi, beforeEach } from "vitest";
3
+ vi.mock("../github/batch.mts", () => ({ fetchPrBatch: vi.fn() }));
4
+ vi.mock("../github/client.mts", () => ({
5
+ getRepoInfo: vi.fn().mockResolvedValue({ owner: "owner", name: "repo" }),
6
+ getCurrentPrNumber: vi.fn().mockResolvedValue(42),
7
+ getMergeableState: vi.fn(),
8
+ }));
9
+ vi.mock("../checks/triage.mts", () => ({
10
+ triageFailingChecks: vi.fn((checks) => Promise.resolve(checks)),
11
+ fetchStartupFailureChecks: vi.fn().mockResolvedValue([]),
12
+ }));
13
+ vi.mock("../comments/resolve.mts", () => ({
14
+ autoResolveOutdated: vi.fn().mockResolvedValue({ resolved: [], errors: [] }),
15
+ }));
16
+ vi.mock("../state/seen-comments.mts", async (importOriginal) => {
17
+ const actual = await importOriginal();
18
+ return {
19
+ ...actual,
20
+ loadSeenMap: vi.fn().mockResolvedValue(new Map()),
21
+ markSeen: vi.fn().mockResolvedValue(undefined),
22
+ };
23
+ });
24
+ const { mockLoadConfig } = vi.hoisted(() => ({ mockLoadConfig: vi.fn() }));
25
+ vi.mock("../config/load.mts", () => ({ loadConfig: mockLoadConfig }));
26
+ import { runCheck } from "./check.mjs";
27
+ import { fetchPrBatch } from "../github/batch.mjs";
28
+ import { getCurrentPrNumber, getMergeableState } from "../github/client.mjs";
29
+ import { fetchStartupFailureChecks, triageFailingChecks } from "../checks/triage.mjs";
30
+ import { loadSeenMap, markSeen, hashBody } from "../state/seen-comments.mjs";
31
+ import { autoResolveOutdated } from "../comments/resolve.mjs";
32
+ const mockFetchPrBatch = vi.mocked(fetchPrBatch);
33
+ const mockGetCurrentPrNumber = vi.mocked(getCurrentPrNumber);
34
+ const mockGetMergeableState = vi.mocked(getMergeableState);
35
+ const mockTriageFailingChecks = vi.mocked(triageFailingChecks);
36
+ const mockFetchStartupFailureChecks = vi.mocked(fetchStartupFailureChecks);
37
+ const mockLoadSeenMap = vi.mocked(loadSeenMap);
38
+ const mockMarkSeen = vi.mocked(markSeen);
39
+ const mockAutoResolveOutdated = vi.mocked(autoResolveOutdated);
40
+ const BASE_OPTS = { format: "text" };
41
+ function defaultConfig() {
42
+ return {
43
+ iterate: {
44
+ fixAttemptsPerThread: 3,
45
+ stallTimeoutMinutes: 30,
46
+ minimizeApprovals: false,
47
+ minimizeComments: "all",
48
+ },
49
+ watch: { readyDelayMinutes: 10 },
50
+ resolve: {
51
+ shaPoll: { intervalMs: 2000, maxAttempts: 10 },
52
+ fetchReviewSummaries: true,
53
+ },
54
+ checks: {
55
+ ciTriggerEvents: ["pull_request", "pull_request_target"],
56
+ },
57
+ mergeStatus: { blockingReviewerLogins: ["copilot"] },
58
+ actions: {
59
+ autoResolveOutdated: true,
60
+ autoMarkReady: true,
61
+ commitSuggestions: true,
62
+ },
63
+ };
64
+ }
65
+ function makeCheck(overrides = {}) {
66
+ return {
67
+ name: "tests",
68
+ status: "COMPLETED",
69
+ conclusion: "SUCCESS",
70
+ detailsUrl: "",
71
+ event: "pull_request",
72
+ runId: null,
73
+ category: "passed",
74
+ ...overrides,
75
+ };
76
+ }
77
+ function makeBatchData(overrides = {}) {
78
+ return {
79
+ nodeId: "PR_kgDOAAA",
80
+ number: 42,
81
+ state: "OPEN",
82
+ isDraft: false,
83
+ mergeable: "MERGEABLE",
84
+ mergeStateStatus: "CLEAN",
85
+ reviewDecision: "APPROVED",
86
+ headRefOid: "abc123",
87
+ headRefName: "feature",
88
+ headRepoWithOwner: "owner/repo",
89
+ baseRefName: "main",
90
+ reviewRequests: [],
91
+ latestReviews: [],
92
+ reviewThreads: [],
93
+ comments: [],
94
+ changesRequestedReviews: [],
95
+ reviewSummaries: [],
96
+ approvedReviews: [],
97
+ checks: [makeCheck()],
98
+ ...overrides,
99
+ };
100
+ }
101
+ function makeThread(overrides = {}) {
102
+ return {
103
+ id: "t1",
104
+ isResolved: false,
105
+ isOutdated: false,
106
+ isMinimized: false,
107
+ path: "src/foo.mts",
108
+ line: 10,
109
+ startLine: null,
110
+ author: "reviewer",
111
+ authorType: "Unknown",
112
+ body: "fix this",
113
+ url: "",
114
+ createdAtUnix: 0,
115
+ ...overrides,
116
+ };
117
+ }
118
+ function makeComment(overrides = {}) {
119
+ return {
120
+ id: "c1",
121
+ author: "commenter",
122
+ authorType: "Unknown",
123
+ body: "comment body",
124
+ url: "",
125
+ createdAtUnix: 0,
126
+ isMinimized: false,
127
+ ...overrides,
128
+ };
129
+ }
130
+ // No PR found
131
+ export function registerHooks() {
132
+ beforeEach(() => {
133
+ vi.clearAllMocks();
134
+ mockLoadConfig.mockReturnValue(defaultConfig());
135
+ mockFetchPrBatch.mockResolvedValue({ data: makeBatchData() });
136
+ mockGetMergeableState.mockResolvedValue({ mergeable: "MERGEABLE", mergeStateStatus: "CLEAN" });
137
+ mockFetchStartupFailureChecks.mockResolvedValue([]);
138
+ });
139
+ }
140
+ export { BASE_OPTS, autoResolveOutdated, defaultConfig, fetchPrBatch, fetchStartupFailureChecks, getCurrentPrNumber, getMergeableState, hashBody, loadSeenMap, makeBatchData, makeCheck, makeComment, makeThread, markSeen, mockAutoResolveOutdated, mockFetchPrBatch, mockFetchStartupFailureChecks, mockGetCurrentPrNumber, mockGetMergeableState, mockLoadConfig, mockLoadSeenMap, mockMarkSeen, mockTriageFailingChecks, runCheck, triageFailingChecks, };
@@ -0,0 +1,156 @@
1
+ import { rm, readdir, realpath, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { resolveStateBase } from "../state/base.mjs";
4
+ import { getRepoInfo, getCurrentPrNumber, getCurrentBranch, getPrNumberForBranch, } from "../github/client.mjs";
5
+ import { SAFE_SEGMENT } from "../util/path-segment.mjs";
6
+ export async function runClean(opts) {
7
+ const dryRun = opts.dryRun ?? false;
8
+ const rawBase = resolveStateBase();
9
+ const base = await realpath(rawBase).catch(() => rawBase);
10
+ let target;
11
+ try {
12
+ target = await resolveTarget(base, opts);
13
+ }
14
+ catch (e) {
15
+ return {
16
+ ok: false,
17
+ variant: opts.variant,
18
+ dryRun,
19
+ base,
20
+ target: "",
21
+ deleted: [],
22
+ skipped: [],
23
+ error: e instanceof Error ? e.message : String(e),
24
+ };
25
+ }
26
+ let targetExists = false;
27
+ try {
28
+ await stat(target);
29
+ targetExists = true;
30
+ }
31
+ catch (e) {
32
+ if (e.code !== "ENOENT") {
33
+ return {
34
+ ok: false,
35
+ variant: opts.variant,
36
+ dryRun,
37
+ base,
38
+ target,
39
+ deleted: [],
40
+ skipped: [],
41
+ error: `Failed to stat target: ${e.message}`,
42
+ };
43
+ }
44
+ }
45
+ if (!targetExists) {
46
+ return {
47
+ ok: true,
48
+ variant: opts.variant,
49
+ dryRun,
50
+ base,
51
+ target,
52
+ deleted: [],
53
+ skipped: [target],
54
+ };
55
+ }
56
+ let entries;
57
+ try {
58
+ const names = await readdir(target);
59
+ entries = names.map((n) => join(target, n));
60
+ }
61
+ catch {
62
+ entries = [target];
63
+ }
64
+ if (dryRun) {
65
+ return {
66
+ ok: true,
67
+ variant: opts.variant,
68
+ dryRun: true,
69
+ base,
70
+ target,
71
+ deleted: entries,
72
+ skipped: [],
73
+ };
74
+ }
75
+ try {
76
+ await rm(target, { recursive: true, force: true });
77
+ }
78
+ catch (e) {
79
+ return {
80
+ ok: false,
81
+ variant: opts.variant,
82
+ dryRun: false,
83
+ base,
84
+ target,
85
+ deleted: [],
86
+ skipped: [],
87
+ error: `Failed to remove target: ${e.message}`,
88
+ };
89
+ }
90
+ return {
91
+ ok: true,
92
+ variant: opts.variant,
93
+ dryRun: false,
94
+ base,
95
+ target,
96
+ deleted: entries,
97
+ skipped: [],
98
+ };
99
+ }
100
+ async function resolveTarget(base, opts) {
101
+ const { variant, value } = opts;
102
+ if (variant === "all") {
103
+ if (value !== undefined) {
104
+ throw new Error(`"clean all" does not accept a positional argument; got "${value}". Did you mean "clean repo" or "clean pr"?`);
105
+ }
106
+ return base;
107
+ }
108
+ const repo = await getRepoInfo();
109
+ const { owner, name } = repo;
110
+ for (const [field, val] of [
111
+ ["owner", owner],
112
+ ["repo", name],
113
+ ]) {
114
+ if (!SAFE_SEGMENT.test(val)) {
115
+ throw new Error(`Invalid repository segment "${field}": ${val}`);
116
+ }
117
+ }
118
+ const ownerRepo = `${owner}-${name}`;
119
+ if (variant === "repo") {
120
+ if (value !== undefined) {
121
+ throw new Error(`"clean repo" does not accept a positional argument; got "${value}". Did you mean "clean pr" or "clean branch"?`);
122
+ }
123
+ return join(base, ownerRepo);
124
+ }
125
+ let prNumber;
126
+ if (variant === "pr") {
127
+ if (value !== undefined) {
128
+ const n = parseInt(value, 10);
129
+ if (!Number.isFinite(n) || n <= 0 || String(n) !== value.trim()) {
130
+ throw new Error(`Invalid PR number: ${value}`);
131
+ }
132
+ prNumber = n;
133
+ }
134
+ else {
135
+ const n = await getCurrentPrNumber();
136
+ if (n === null)
137
+ throw new Error("No open PR found for current branch");
138
+ prNumber = n;
139
+ }
140
+ }
141
+ else {
142
+ // "branch" or "current"
143
+ if (variant === "current" && value !== undefined) {
144
+ throw new Error(`"clean current" does not accept a positional argument; got "${value}". Did you mean "clean branch"?`);
145
+ }
146
+ const branchName = value ?? (await getCurrentBranch());
147
+ if (branchName === "HEAD") {
148
+ throw new Error("Could not resolve current branch (detached HEAD)");
149
+ }
150
+ const n = await getPrNumberForBranch(branchName, owner, name);
151
+ if (n === null)
152
+ throw new Error(`No open PR found for branch: ${branchName}`);
153
+ prNumber = n;
154
+ }
155
+ return join(base, ownerRepo, String(prNumber));
156
+ }
@@ -0,0 +1,48 @@
1
+ // @ts-nocheck
2
+ import { vi, beforeEach, afterEach } from "vitest";
3
+ import { join } from "node:path";
4
+ import { mkdtemp, realpath, rm, mkdir, writeFile, stat } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ vi.mock("../github/client.mts", () => ({
7
+ getRepoInfo: vi.fn().mockResolvedValue({ owner: "acme", name: "widgets" }),
8
+ getCurrentBranch: vi.fn().mockResolvedValue("feature/test"),
9
+ getCurrentPrNumber: vi.fn().mockResolvedValue(42),
10
+ getPrNumberForBranch: vi.fn().mockResolvedValue(42),
11
+ }));
12
+ import { getRepoInfo, getCurrentBranch, getCurrentPrNumber, getPrNumberForBranch, } from "../github/client.mjs";
13
+ export const mockGetRepoInfo = vi.mocked(getRepoInfo);
14
+ export const mockGetCurrentBranch = vi.mocked(getCurrentBranch);
15
+ export const mockGetCurrentPrNumber = vi.mocked(getCurrentPrNumber);
16
+ export const mockGetPrNumberForBranch = vi.mocked(getPrNumberForBranch);
17
+ export let stateDir;
18
+ export function registerHooks() {
19
+ beforeEach(async () => {
20
+ const tmpPath = await mkdtemp(join(tmpdir(), "shepherd-clean-test-"));
21
+ stateDir = await realpath(tmpPath);
22
+ process.env["PR_SHEPHERD_STATE_DIR"] = stateDir;
23
+ vi.clearAllMocks();
24
+ mockGetRepoInfo.mockResolvedValue({ owner: "acme", name: "widgets" });
25
+ mockGetCurrentBranch.mockResolvedValue("feature/test");
26
+ mockGetCurrentPrNumber.mockResolvedValue(42);
27
+ mockGetPrNumberForBranch.mockResolvedValue(42);
28
+ });
29
+ afterEach(async () => {
30
+ delete process.env["PR_SHEPHERD_STATE_DIR"];
31
+ await rm(stateDir, { recursive: true, force: true });
32
+ });
33
+ }
34
+ export async function seedPrDir(dir, pr) {
35
+ const prDir = join(dir, "acme-widgets", String(pr));
36
+ await mkdir(join(prDir, "seen"), { recursive: true });
37
+ await writeFile(join(prDir, "fix-attempts.json"), "{}", "utf8");
38
+ return prDir;
39
+ }
40
+ export async function pathExists(p) {
41
+ try {
42
+ await stat(p);
43
+ return true;
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
@@ -0,0 +1,87 @@
1
+ // @ts-nocheck
2
+ import { describe, it, expect, vi, beforeEach } from "vitest";
3
+ // ---------------------------------------------------------------------------
4
+ // Hoisted mocks
5
+ // ---------------------------------------------------------------------------
6
+ const { mockExecFile } = vi.hoisted(() => ({ mockExecFile: vi.fn() }));
7
+ vi.mock("node:child_process", () => ({
8
+ execFile: (cmd, args, optsOrCb, maybeCb) => {
9
+ const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
10
+ mockExecFile(cmd, args)
11
+ .then((result) => cb(null, result))
12
+ .catch((err) => cb(err, { stdout: "", stderr: err.stderr ?? "" }));
13
+ },
14
+ }));
15
+ vi.mock("node:fs/promises", () => ({
16
+ readFile: vi.fn(),
17
+ }));
18
+ vi.mock("../github/client.mts", () => ({
19
+ getRepoInfo: vi.fn().mockResolvedValue({ owner: "owner", name: "repo" }),
20
+ getCurrentPrNumber: vi.fn().mockResolvedValue(42),
21
+ getCurrentBranch: vi.fn().mockResolvedValue("feature/foo"),
22
+ }));
23
+ vi.mock("../github/batch.mts", () => ({
24
+ fetchPrBatch: vi.fn(),
25
+ }));
26
+ import { runCommitSuggestion } from "./commit-suggestion.mjs";
27
+ import { getCurrentBranch } from "../github/client.mjs";
28
+ import { fetchPrBatch } from "../github/batch.mjs";
29
+ import { readFile } from "node:fs/promises";
30
+ const mockGetCurrentBranch = vi.mocked(getCurrentBranch);
31
+ const mockFetchBatch = vi.mocked(fetchPrBatch);
32
+ const mockReadFile = vi.mocked(readFile);
33
+ function makeThread(overrides = {}) {
34
+ return {
35
+ id: "PRRT_x",
36
+ isResolved: false,
37
+ isOutdated: false,
38
+ isMinimized: false,
39
+ path: "src/foo.ts",
40
+ line: 5,
41
+ startLine: null,
42
+ author: "alice",
43
+ authorType: "Unknown",
44
+ body: "Use a const here.\n\n```suggestion\nconst x = 10;\n```",
45
+ url: "",
46
+ createdAtUnix: 0,
47
+ ...overrides,
48
+ };
49
+ }
50
+ function makeBatch(threads) {
51
+ return {
52
+ nodeId: "PR_kgDOAAA",
53
+ number: 42,
54
+ state: "OPEN",
55
+ isDraft: false,
56
+ mergeable: "MERGEABLE",
57
+ mergeStateStatus: "CLEAN",
58
+ reviewDecision: "APPROVED",
59
+ headRefOid: "headsha",
60
+ headRefName: "feature/foo",
61
+ headRepoWithOwner: "owner/repo",
62
+ baseRefName: "main",
63
+ reviewRequests: [],
64
+ latestReviews: [],
65
+ reviewThreads: threads,
66
+ checks: [],
67
+ comments: [],
68
+ changesRequestedReviews: [],
69
+ reviewSummaries: [],
70
+ approvedReviews: [],
71
+ };
72
+ }
73
+ const FILE_CONTENT = "line1\n" +
74
+ "line2\n" +
75
+ "line3\n" +
76
+ "line4\n" +
77
+ "const x = 1;\n" + // line 5 — matches the suggestion anchor
78
+ "line6\n" +
79
+ "line7\n";
80
+ const GLOBAL_OPTS = { format: "text" };
81
+ function makeGitSuccess(stdout = "") {
82
+ return Promise.resolve({ stdout, stderr: "" });
83
+ }
84
+ // ---------------------------------------------------------------------------
85
+ // Output shape and instruction content
86
+ // ---------------------------------------------------------------------------
87
+ export { FILE_CONTENT, GLOBAL_OPTS, fetchPrBatch, getCurrentBranch, makeBatch, makeGitSuccess, makeThread, mockExecFile, mockFetchBatch, mockGetCurrentBranch, mockReadFile, readFile, runCommitSuggestion, };
@@ -0,0 +1,112 @@
1
+ // @ts-nocheck
2
+ import { describe, it, expect, vi, beforeEach } from "vitest";
3
+ // ---------------------------------------------------------------------------
4
+ // Hoisted mocks
5
+ // ---------------------------------------------------------------------------
6
+ const { mockExecFile } = vi.hoisted(() => ({ mockExecFile: vi.fn() }));
7
+ vi.mock("node:child_process", () => ({
8
+ execFile: (cmd, args, optsOrCb, maybeCb) => {
9
+ const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
10
+ mockExecFile(cmd, args)
11
+ .then((result) => cb(null, result))
12
+ .catch((err) => cb(err, { stdout: "", stderr: err.stderr ?? "" }));
13
+ },
14
+ }));
15
+ vi.mock("node:fs/promises", () => ({
16
+ readFile: vi.fn(),
17
+ }));
18
+ vi.mock("../github/client.mts", () => ({
19
+ getRepoInfo: vi.fn().mockResolvedValue({ owner: "owner", name: "repo" }),
20
+ getCurrentPrNumber: vi.fn().mockResolvedValue(42),
21
+ getCurrentBranch: vi.fn().mockResolvedValue("feature/foo"),
22
+ }));
23
+ vi.mock("../github/batch.mts", () => ({
24
+ fetchPrBatch: vi.fn(),
25
+ }));
26
+ const { mockLoadConfig } = vi.hoisted(() => ({ mockLoadConfig: vi.fn() }));
27
+ vi.mock("../config/load.mts", () => ({
28
+ loadConfig: mockLoadConfig,
29
+ }));
30
+ import { runCommitSuggestion } from "./commit-suggestion.mjs";
31
+ import { getCurrentBranch, getCurrentPrNumber } from "../github/client.mjs";
32
+ import { fetchPrBatch } from "../github/batch.mjs";
33
+ import { readFile } from "node:fs/promises";
34
+ const mockGetCurrentBranch = vi.mocked(getCurrentBranch);
35
+ const mockGetCurrentPrNumber = vi.mocked(getCurrentPrNumber);
36
+ const mockFetchBatch = vi.mocked(fetchPrBatch);
37
+ const mockReadFile = vi.mocked(readFile);
38
+ // ---------------------------------------------------------------------------
39
+ // Fixtures
40
+ // ---------------------------------------------------------------------------
41
+ function makeThread(overrides = {}) {
42
+ return {
43
+ id: "PRRT_x",
44
+ isResolved: false,
45
+ isOutdated: false,
46
+ isMinimized: false,
47
+ path: "src/foo.ts",
48
+ line: 5,
49
+ startLine: null,
50
+ author: "alice",
51
+ authorType: "Unknown",
52
+ body: "Use a const here.\n\n```suggestion\nconst x = 10;\n```",
53
+ url: "",
54
+ createdAtUnix: 0,
55
+ ...overrides,
56
+ };
57
+ }
58
+ function makeBatch(threads, headRepoWithOwner = "owner/repo") {
59
+ return {
60
+ nodeId: "PR_kgDOAAA",
61
+ number: 42,
62
+ state: "OPEN",
63
+ isDraft: false,
64
+ mergeable: "MERGEABLE",
65
+ mergeStateStatus: "CLEAN",
66
+ reviewDecision: "APPROVED",
67
+ headRefOid: "headsha",
68
+ headRefName: "feature/foo",
69
+ headRepoWithOwner,
70
+ baseRefName: "main",
71
+ reviewRequests: [],
72
+ latestReviews: [],
73
+ reviewThreads: threads,
74
+ checks: [],
75
+ comments: [],
76
+ changesRequestedReviews: [],
77
+ reviewSummaries: [],
78
+ approvedReviews: [],
79
+ };
80
+ }
81
+ const FILE_CONTENT = "line1\n" +
82
+ "line2\n" +
83
+ "line3\n" +
84
+ "line4\n" +
85
+ "const x = 1;\n" + // line 5 — matches the suggestion anchor
86
+ "line6\n" +
87
+ "line7\n";
88
+ const GLOBAL_OPTS = { format: "text" };
89
+ function makeGitSuccess(stdout = "") {
90
+ return Promise.resolve({ stdout, stderr: "" });
91
+ }
92
+ function setupHappyPath() {
93
+ mockFetchBatch.mockResolvedValue({ data: makeBatch([makeThread()]) });
94
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
95
+ mockReadFile.mockResolvedValue(FILE_CONTENT);
96
+ mockExecFile.mockImplementation((cmd, args) => {
97
+ if (cmd === "git" && args[0] === "rev-parse")
98
+ return makeGitSuccess("headsha\n");
99
+ if (cmd === "git" && args[0] === "status")
100
+ return makeGitSuccess(""); // file is clean
101
+ throw new Error(`Unexpected execFile call: ${cmd} ${args.join(" ")}`);
102
+ });
103
+ }
104
+ // ---------------------------------------------------------------------------
105
+ // Validation
106
+ // ---------------------------------------------------------------------------
107
+ export function registerHooks() {
108
+ beforeEach(() => {
109
+ mockLoadConfig.mockReturnValue({ cli: { runner: "auto" } });
110
+ });
111
+ }
112
+ export { FILE_CONTENT, GLOBAL_OPTS, fetchPrBatch, getCurrentBranch, getCurrentPrNumber, makeBatch, makeGitSuccess, makeThread, mockExecFile, mockFetchBatch, mockGetCurrentBranch, mockGetCurrentPrNumber, mockLoadConfig, mockReadFile, readFile, runCommitSuggestion, setupHappyPath, };
@@ -1,5 +1,16 @@
1
1
  import { buildPrShepherdCommand } from "../../cli/runner.mjs";
2
2
  import { shouldMinimizeAuthor } from "../../comments/minimize-policy.mjs";
3
+ function dedupeIds(ids) {
4
+ const seen = new Set();
5
+ const out = [];
6
+ for (const id of ids) {
7
+ if (seen.has(id))
8
+ continue;
9
+ seen.add(id);
10
+ out.push(id);
11
+ }
12
+ return out;
13
+ }
3
14
  export function classifyReviewSummaries(summaries, approvals, minimizeApprovals, minimizeComments = "all") {
4
15
  // First-look and seen summaries go into the minimize mutation; edited summaries do NOT —
5
16
  // they are already minimized server-side (body changed after minimize was applied).
@@ -28,26 +39,43 @@ export function classifyReviewSummaries(summaries, approvals, minimizeApprovals,
28
39
  }
29
40
  export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, reviews, checks, prNumber, runner) {
30
41
  const argv = buildPrShepherdCommand(["resolve", String(prNumber)], { runner }).argv;
31
- const threadIds = [...threads.map((t) => t.id), ...resolutionOnlyThreads.map((t) => t.id)];
42
+ const resolveThreadIds = dedupeIds(threads.map((t) => t.id));
43
+ const threadIds = dedupeIds([...resolveThreadIds, ...resolutionOnlyThreads.map((t) => t.id)]);
32
44
  if (threadIds.length > 0) {
33
45
  argv.push("--resolve-thread-ids", threadIds.join(","));
34
46
  }
35
47
  if (allCommentIds.length > 0) {
36
48
  argv.push("--minimize-comment-ids", allCommentIds.join(","));
37
49
  }
38
- const hasDismiss = reviews.length > 0;
50
+ const commentIdSet = new Set(allCommentIds);
51
+ const filteredReviewIds = [];
52
+ const droppedDismissReviewIds = [];
53
+ for (const review of reviews) {
54
+ if (commentIdSet.has(review.id))
55
+ droppedDismissReviewIds.push(review.id);
56
+ else
57
+ filteredReviewIds.push(review.id);
58
+ }
59
+ const hasDismiss = filteredReviewIds.length > 0;
39
60
  if (hasDismiss) {
40
- argv.push("--dismiss-review-ids", reviews.map((r) => r.id).join(","));
61
+ argv.push("--dismiss-review-ids", filteredReviewIds.join(","));
41
62
  argv.push("--message", "$DISMISS_MESSAGE");
42
63
  }
43
- // A push is required when threads, CI failures, or changes-requested reviews are present — the
44
- // CLI knows those imply code edits. Comments are surfaced for the agent to evaluate; the CLI
45
- // cannot know whether a given comment will require a push, so comments are excluded here.
46
- const requiresHeadSha = threads.length > 0 || checks.length > 0 || reviews.length > 0;
47
64
  // hasMutations = we appended at least one of --resolve-thread-ids,
48
65
  // --minimize-comment-ids, or --dismiss-review-ids. Returned explicitly
49
66
  // (rather than derived from argv.length) so callers don't couple to the
50
67
  // base-argv shape.
51
- const hasMutations = threadIds.length > 0 || allCommentIds.length > 0 || reviews.length > 0;
52
- return { argv, requiresHeadSha, requiresDismissMessage: hasDismiss, hasMutations };
68
+ const hasMutations = threadIds.length > 0 || allCommentIds.length > 0 || filteredReviewIds.length > 0;
69
+ // `requiresHeadSha` is only added when this resolve command includes a
70
+ // mutation that can race with a moving HEAD: resolving actionable threads,
71
+ // dismissing CHANGES_REQUESTED reviews, or addressing failing checks.
72
+ const hasCodeMutations = hasMutations && (threads.length > 0 || checks.length > 0 || filteredReviewIds.length > 0);
73
+ const requiresHeadSha = hasCodeMutations;
74
+ return {
75
+ argv,
76
+ requiresHeadSha,
77
+ requiresDismissMessage: hasDismiss,
78
+ ...(droppedDismissReviewIds.length > 0 ? { droppedDismissReviewIds } : undefined),
79
+ hasMutations,
80
+ };
53
81
  }