pr-shepherd 0.16.3 → 0.16.4

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 (29) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/checks/triage.test-support.mjs +61 -0
  3. package/bin/cli/iterate-lean.test-support.mjs +5 -0
  4. package/bin/cli-parser.commit-suggestion.test-support.mjs +66 -0
  5. package/bin/cli-parser.iterate-fix.test-support.mjs +48 -0
  6. package/bin/cli-parser.iterate.test-support.mjs +49 -0
  7. package/bin/cli-parser.test-support.mjs +43 -0
  8. package/bin/commands/check.test-support.mjs +140 -0
  9. package/bin/commands/commit-suggestion.apply.test-support.mjs +87 -0
  10. package/bin/commands/commit-suggestion.test-support.mjs +112 -0
  11. package/bin/commands/iterate/index.mjs +19 -16
  12. package/bin/commands/iterate-stall.test-support.mjs +25 -0
  13. package/bin/commands/iterate-test-support.mjs +149 -0
  14. package/bin/commands/iterate.fix-code-in-progress.test-support.mjs +118 -0
  15. package/bin/commands/resolve.test-support.mjs +114 -0
  16. package/bin/commands/shepherd-journal.test-support.mjs +9 -0
  17. package/bin/comments/resolve.test-support.mjs +40 -0
  18. package/bin/github/batch-parsers.test-support.mjs +67 -0
  19. package/bin/github/batch.test-support.mjs +67 -0
  20. package/bin/github/graphql-http.mjs +73 -0
  21. package/bin/github/http-auth.mjs +48 -0
  22. package/bin/github/http-request.mjs +15 -0
  23. package/bin/github/http-utils.mjs +34 -0
  24. package/bin/github/http.mjs +4 -319
  25. package/bin/github/http.test-support.mjs +52 -0
  26. package/bin/github/rest-http.mjs +131 -0
  27. package/bin/suggestions/patch.test-support.mjs +4 -0
  28. package/package.json +2 -2
  29. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
4
- "version": "0.16.3",
4
+ "version": "0.16.4",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
@@ -0,0 +1,61 @@
1
+ // @ts-nocheck
2
+ import { describe, it, expect, vi, beforeEach } from "vitest";
3
+ // ---------------------------------------------------------------------------
4
+ // Stub fetch globally so http.mts uses our mock.
5
+ // ---------------------------------------------------------------------------
6
+ const mockFetch = vi.fn();
7
+ vi.stubGlobal("fetch", mockFetch);
8
+ import { fetchStartupFailureChecks, triageFailingChecks } from "./triage.mjs";
9
+ import { mergeStartupFailureChecks } from "./startup-failures.mjs";
10
+ const REPO = { owner: "owner", name: "repo" };
11
+ // ---------------------------------------------------------------------------
12
+ // Helpers
13
+ // ---------------------------------------------------------------------------
14
+ function makeCheck(overrides = {}) {
15
+ return {
16
+ name: "tests",
17
+ status: "COMPLETED",
18
+ conclusion: "FAILURE",
19
+ detailsUrl: "https://github.com/owner/repo/actions/runs/99/jobs/1",
20
+ event: "pull_request",
21
+ runId: "run-99",
22
+ category: "failing",
23
+ ...overrides,
24
+ };
25
+ }
26
+ function makeJobsResponse(jobs) {
27
+ return {
28
+ ok: true,
29
+ status: 200,
30
+ headers: new Headers({ "content-type": "application/json" }),
31
+ json: () => Promise.resolve({ jobs }),
32
+ text: () => Promise.resolve(JSON.stringify({ jobs })),
33
+ };
34
+ }
35
+ function makeErrorResponse(status) {
36
+ return {
37
+ ok: false,
38
+ status,
39
+ headers: new Headers(),
40
+ text: () => Promise.resolve("error"),
41
+ };
42
+ }
43
+ function makeWorkflowRunsResponse(runs) {
44
+ return {
45
+ ok: true,
46
+ status: 200,
47
+ headers: new Headers({ "content-type": "application/json" }),
48
+ json: () => Promise.resolve({ workflow_runs: runs }),
49
+ text: () => Promise.resolve(JSON.stringify({ workflow_runs: runs })),
50
+ };
51
+ }
52
+ // ---------------------------------------------------------------------------
53
+ // Tests
54
+ // ---------------------------------------------------------------------------
55
+ export function registerHooks() {
56
+ beforeEach(() => {
57
+ mockFetch.mockReset();
58
+ process.env["GH_TOKEN"] = "test-token";
59
+ });
60
+ }
61
+ export { REPO, fetchStartupFailureChecks, makeCheck, makeErrorResponse, makeJobsResponse, makeWorkflowRunsResponse, mergeStartupFailureChecks, mockFetch, triageFailingChecks, };
@@ -0,0 +1,5 @@
1
+ // @ts-nocheck
2
+ import { describe, it, expect } from "vitest";
3
+ import { projectIterateLean, projectIterateVerbose } from "./iterate-lean.mjs";
4
+ import { makeIterateResult } from "../cli-parser.iterate-fixtures.mjs";
5
+ export { makeIterateResult, projectIterateLean, projectIterateVerbose };
@@ -0,0 +1,66 @@
1
+ // @ts-nocheck
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
+ vi.mock("./commands/check.mts", () => ({ runCheck: vi.fn() }));
4
+ vi.mock("./commands/resolve.mts", () => ({
5
+ runResolveFetch: vi.fn(),
6
+ runResolveMutate: vi.fn(),
7
+ }));
8
+ vi.mock("./commands/commit-suggestion.mts", () => ({
9
+ runCommitSuggestion: vi.fn(),
10
+ }));
11
+ vi.mock("./commands/iterate/index.mts", async (importOriginal) => {
12
+ const actual = await importOriginal();
13
+ return { ...actual, runIterate: vi.fn() };
14
+ });
15
+ vi.mock("./github/client.mts", () => ({
16
+ getRepoInfo: vi.fn().mockResolvedValue({ owner: "owner", name: "repo" }),
17
+ }));
18
+ import { main } from "./cli-parser.mjs";
19
+ import { runCommitSuggestion } from "./commands/commit-suggestion.mjs";
20
+ const mockRunCommitSuggestion = vi.mocked(runCommitSuggestion);
21
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
+ let stdoutSpy;
23
+ let stderrSpy;
24
+ function getStdout() {
25
+ return stdoutSpy.mock.calls.map((c) => c[0]).join("");
26
+ }
27
+ // ---------------------------------------------------------------------------
28
+ // Fixtures
29
+ // ---------------------------------------------------------------------------
30
+ const SUGGESTION_RESULT = {
31
+ pr: 42,
32
+ repo: "owner/repo",
33
+ threadId: "t1",
34
+ path: "a.ts",
35
+ startLine: 5,
36
+ endLine: 5,
37
+ author: "alice",
38
+ patch: "--- a/a.ts\n+++ b/a.ts\n@@ -5,1 +5,1 @@\n-old\n+new\n",
39
+ commitMessage: "apply fix",
40
+ commitBody: "Co-authored-by: alice <alice@users.noreply.github.com>",
41
+ filesToStage: ["a.ts"],
42
+ postActionInstructions: [
43
+ "Apply the patch to `a.ts`: run `git apply` with the diff shown above.",
44
+ "Stage the file: `git add -- a.ts`",
45
+ 'Commit: `git commit -m "apply fix" -m "Co-authored-by: alice <alice@users.noreply.github.com>"`',
46
+ "Resolve the thread on GitHub: `npx pr-shepherd resolve 42 --resolve-thread-ids t1`",
47
+ "Push when ready: `git push` (or `git push --force-with-lease` after rebasing).",
48
+ ],
49
+ };
50
+ // ---------------------------------------------------------------------------
51
+ // commit-suggestion dispatch
52
+ // ---------------------------------------------------------------------------
53
+ export function registerHooks() {
54
+ beforeEach(() => {
55
+ vi.clearAllMocks();
56
+ process.exitCode = undefined;
57
+ stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
58
+ stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
59
+ });
60
+ afterEach(() => {
61
+ process.exitCode = undefined;
62
+ stdoutSpy.mockRestore();
63
+ stderrSpy.mockRestore();
64
+ });
65
+ }
66
+ export { SUGGESTION_RESULT, getStdout, main, mockRunCommitSuggestion, runCommitSuggestion, stderrSpy, stdoutSpy, };
@@ -0,0 +1,48 @@
1
+ // @ts-nocheck
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
+ vi.mock("./commands/check.mts", () => ({ runCheck: vi.fn() }));
4
+ vi.mock("./commands/resolve.mts", () => ({
5
+ runResolveFetch: vi.fn(),
6
+ runResolveMutate: vi.fn(),
7
+ }));
8
+ vi.mock("./commands/commit-suggestion.mts", () => ({
9
+ runCommitSuggestion: vi.fn(),
10
+ }));
11
+ vi.mock("./commands/iterate/index.mts", async (importOriginal) => {
12
+ const actual = await importOriginal();
13
+ return { ...actual, runIterate: vi.fn() };
14
+ });
15
+ vi.mock("./github/client.mts", () => ({
16
+ getRepoInfo: vi.fn().mockResolvedValue({ owner: "owner", name: "repo" }),
17
+ }));
18
+ import { main } from "./cli-parser.mjs";
19
+ import { runIterate } from "./commands/iterate/index.mjs";
20
+ import { makeIterateResult } from "./cli-parser.iterate-fixtures.mjs";
21
+ const mockRunIterate = vi.mocked(runIterate);
22
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
23
+ let stdoutSpy;
24
+ let stderrSpy;
25
+ function getStdout() {
26
+ return stdoutSpy.mock.calls.map((c) => c[0]).join("");
27
+ }
28
+ // ---------------------------------------------------------------------------
29
+ // formatIterateResult — fix_code actions and ## Checks section
30
+ // ---------------------------------------------------------------------------
31
+ export function registerHooks() {
32
+ beforeEach(() => {
33
+ vi.clearAllMocks();
34
+ process.exitCode = undefined;
35
+ delete process.env.AGENT;
36
+ delete process.env.CODEX_CI;
37
+ stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
38
+ stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
39
+ });
40
+ afterEach(() => {
41
+ process.exitCode = undefined;
42
+ delete process.env.AGENT;
43
+ delete process.env.CODEX_CI;
44
+ stdoutSpy.mockRestore();
45
+ stderrSpy.mockRestore();
46
+ });
47
+ }
48
+ export { getStdout, main, makeIterateResult, mockRunIterate, runIterate, stderrSpy, stdoutSpy };
@@ -0,0 +1,49 @@
1
+ // @ts-nocheck
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
+ vi.mock("./commands/check.mts", () => ({ runCheck: vi.fn() }));
4
+ vi.mock("./commands/resolve.mts", () => ({
5
+ runResolveFetch: vi.fn(),
6
+ runResolveMutate: vi.fn(),
7
+ }));
8
+ vi.mock("./commands/commit-suggestion.mts", () => ({
9
+ runCommitSuggestion: vi.fn(),
10
+ }));
11
+ vi.mock("./commands/iterate/index.mts", async (importOriginal) => {
12
+ const actual = await importOriginal();
13
+ return { ...actual, runIterate: vi.fn() };
14
+ });
15
+ import { main } from "./cli-parser.mjs";
16
+ import { runIterate } from "./commands/iterate/index.mjs";
17
+ import { formatIterateResult } from "./cli/iterate-formatter.mjs";
18
+ import { makeIterateResult } from "./cli-parser.iterate-fixtures.mjs";
19
+ const mockRunIterate = vi.mocked(runIterate);
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ let stdoutSpy;
22
+ let stderrSpy;
23
+ function getStdout() {
24
+ return stdoutSpy.mock.calls.map((c) => c[0]).join("");
25
+ }
26
+ function getStderr() {
27
+ return stderrSpy.mock.calls.map((c) => c[0]).join("");
28
+ }
29
+ // ---------------------------------------------------------------------------
30
+ // iterate dispatch
31
+ // ---------------------------------------------------------------------------
32
+ export function registerHooks() {
33
+ beforeEach(() => {
34
+ vi.clearAllMocks();
35
+ process.exitCode = undefined;
36
+ delete process.env.AGENT;
37
+ delete process.env.CODEX_CI;
38
+ stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
39
+ stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
40
+ });
41
+ afterEach(() => {
42
+ process.exitCode = undefined;
43
+ delete process.env.AGENT;
44
+ delete process.env.CODEX_CI;
45
+ stdoutSpy.mockRestore();
46
+ stderrSpy.mockRestore();
47
+ });
48
+ }
49
+ export { formatIterateResult, getStderr, getStdout, main, makeIterateResult, mockRunIterate, runIterate, stderrSpy, stdoutSpy, };
@@ -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,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, };
@@ -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,