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
@@ -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, };
@@ -0,0 +1,40 @@
1
+ // @ts-nocheck
2
+ import { describe, it, expect, vi, beforeEach } from "vitest";
3
+ // ---------------------------------------------------------------------------
4
+ // Mock github/client.mts before any imports.
5
+ // ---------------------------------------------------------------------------
6
+ vi.mock("../github/client.mts", () => ({
7
+ graphqlWithRateLimit: vi.fn(),
8
+ getPrHeadSha: vi.fn(),
9
+ }));
10
+ import { applyResolveOptions, autoResolveOutdated } from "./resolve.mjs";
11
+ import { graphqlWithRateLimit, getPrHeadSha } from "../github/client.mjs";
12
+ const mockGraphql = vi.mocked(graphqlWithRateLimit);
13
+ const mockGetPrHeadSha = vi.mocked(getPrHeadSha);
14
+ const REPO = { owner: "owner", name: "repo" };
15
+ /** Build a mock response with the correct nested shape for each alias type (r/m/d). */
16
+ function makeBulkResponse(doc) {
17
+ const str = typeof doc === "string" ? doc : "";
18
+ const data = {};
19
+ for (const [, alias] of str.matchAll(/^\s+([a-z]\d+):/gm)) {
20
+ if (alias.startsWith("r"))
21
+ data[alias] = { thread: { isResolved: true } };
22
+ else if (alias.startsWith("m"))
23
+ data[alias] = { minimizedComment: { isMinimized: true } };
24
+ else if (alias.startsWith("d"))
25
+ data[alias] = { pullRequestReview: { state: "DISMISSED" } };
26
+ else
27
+ data[alias] = {};
28
+ }
29
+ return { data };
30
+ }
31
+ // ---------------------------------------------------------------------------
32
+ // applyResolveOptions
33
+ // ---------------------------------------------------------------------------
34
+ export function registerHooks() {
35
+ beforeEach(() => {
36
+ vi.clearAllMocks();
37
+ mockGraphql.mockImplementation(async (doc) => makeBulkResponse(doc));
38
+ });
39
+ }
40
+ export { REPO, applyResolveOptions, autoResolveOutdated, getPrHeadSha, graphqlWithRateLimit, makeBulkResponse, mockGetPrHeadSha, mockGraphql, };
@@ -0,0 +1,67 @@
1
+ // @ts-nocheck
2
+ import { describe, it, expect, vi, beforeEach } from "vitest";
3
+ vi.mock("./client.mts", () => ({
4
+ graphql: vi.fn(),
5
+ graphqlWithRateLimit: vi.fn(),
6
+ }));
7
+ import { fetchPrBatch } from "./batch.mjs";
8
+ import { graphql, graphqlWithRateLimit } from "./client.mjs";
9
+ const mockGraphql = vi.mocked(graphql);
10
+ const mockGraphqlWithRateLimit = vi.mocked(graphqlWithRateLimit);
11
+ const REPO = { owner: "owner", name: "repo" };
12
+ // ---------------------------------------------------------------------------
13
+ // Helpers
14
+ // ---------------------------------------------------------------------------
15
+ function makeRawPr(overrides = {}) {
16
+ return {
17
+ id: "PR_kgDOAAA",
18
+ number: 42,
19
+ state: "OPEN",
20
+ isDraft: false,
21
+ mergeable: "MERGEABLE",
22
+ mergeStateStatus: "CLEAN",
23
+ reviewDecision: "APPROVED",
24
+ headRefOid: "abc123",
25
+ headRefName: "feature",
26
+ headRepository: { nameWithOwner: "owner/repo" },
27
+ baseRefName: "main",
28
+ reviewRequests: { nodes: [] },
29
+ latestReviews: { nodes: [] },
30
+ reviewThreads: {
31
+ pageInfo: { hasPreviousPage: false, startCursor: null },
32
+ nodes: [],
33
+ },
34
+ comments: {
35
+ pageInfo: { hasPreviousPage: false, startCursor: null },
36
+ nodes: [],
37
+ },
38
+ changesRequestedReviews: {
39
+ pageInfo: { hasPreviousPage: false, startCursor: null },
40
+ nodes: [],
41
+ },
42
+ reviewSummaries: {
43
+ pageInfo: { hasPreviousPage: false, startCursor: null },
44
+ nodes: [],
45
+ },
46
+ approvedReviews: {
47
+ pageInfo: { hasPreviousPage: false, startCursor: null },
48
+ nodes: [],
49
+ },
50
+ commits: { nodes: [{ commit: { statusCheckRollup: null } }] },
51
+ ...overrides,
52
+ };
53
+ }
54
+ function makeResponse(pr = makeRawPr()) {
55
+ return { data: { repository: { pullRequest: pr } } };
56
+ }
57
+ // ---------------------------------------------------------------------------
58
+ // PR not found
59
+ // ---------------------------------------------------------------------------
60
+ export function registerHooks() {
61
+ beforeEach(() => {
62
+ vi.clearAllMocks();
63
+ mockGraphql.mockResolvedValue(makeResponse());
64
+ mockGraphqlWithRateLimit.mockResolvedValue(makeResponse());
65
+ });
66
+ }
67
+ export { REPO, fetchPrBatch, graphql, graphqlWithRateLimit, makeRawPr, makeResponse, mockGraphql, mockGraphqlWithRateLimit, };
@@ -0,0 +1,67 @@
1
+ // @ts-nocheck
2
+ import { describe, it, expect, vi, beforeEach } from "vitest";
3
+ vi.mock("./client.mts", () => ({
4
+ graphql: vi.fn(),
5
+ graphqlWithRateLimit: vi.fn(),
6
+ }));
7
+ import { fetchPrBatch } from "./batch.mjs";
8
+ import { graphql, graphqlWithRateLimit } from "./client.mjs";
9
+ const mockGraphql = vi.mocked(graphql);
10
+ const mockGraphqlWithRateLimit = vi.mocked(graphqlWithRateLimit);
11
+ const REPO = { owner: "owner", name: "repo" };
12
+ // ---------------------------------------------------------------------------
13
+ // Helpers
14
+ // ---------------------------------------------------------------------------
15
+ function makeRawPr(overrides = {}) {
16
+ return {
17
+ id: "PR_kgDOAAA",
18
+ number: 42,
19
+ state: "OPEN",
20
+ isDraft: false,
21
+ mergeable: "MERGEABLE",
22
+ mergeStateStatus: "CLEAN",
23
+ reviewDecision: "APPROVED",
24
+ headRefOid: "abc123",
25
+ headRefName: "feature",
26
+ headRepository: { nameWithOwner: "owner/repo" },
27
+ baseRefName: "main",
28
+ reviewRequests: { nodes: [] },
29
+ latestReviews: { nodes: [] },
30
+ reviewThreads: {
31
+ pageInfo: { hasPreviousPage: false, startCursor: null },
32
+ nodes: [],
33
+ },
34
+ comments: {
35
+ pageInfo: { hasPreviousPage: false, startCursor: null },
36
+ nodes: [],
37
+ },
38
+ changesRequestedReviews: {
39
+ pageInfo: { hasPreviousPage: false, startCursor: null },
40
+ nodes: [],
41
+ },
42
+ reviewSummaries: {
43
+ pageInfo: { hasPreviousPage: false, startCursor: null },
44
+ nodes: [],
45
+ },
46
+ approvedReviews: {
47
+ pageInfo: { hasPreviousPage: false, startCursor: null },
48
+ nodes: [],
49
+ },
50
+ commits: { nodes: [{ commit: { statusCheckRollup: null } }] },
51
+ ...overrides,
52
+ };
53
+ }
54
+ function makeResponse(pr = makeRawPr()) {
55
+ return { data: { repository: { pullRequest: pr } } };
56
+ }
57
+ // ---------------------------------------------------------------------------
58
+ // reviewSummaries — COMMENTED reviews surfaced for agent-driven minimize
59
+ // ---------------------------------------------------------------------------
60
+ export function registerHooks() {
61
+ beforeEach(() => {
62
+ vi.clearAllMocks();
63
+ mockGraphql.mockResolvedValue(makeResponse());
64
+ mockGraphqlWithRateLimit.mockResolvedValue(makeResponse());
65
+ });
66
+ }
67
+ export { REPO, fetchPrBatch, graphql, graphqlWithRateLimit, makeRawPr, makeResponse, mockGraphql, mockGraphqlWithRateLimit, };
@@ -0,0 +1,73 @@
1
+ import { appendEntry, nextEntry } from "../log/log-file.mjs";
2
+ import { formatRequestEntry, formatResponseEntry } from "../log/session.mjs";
3
+ import { GitHubRequestError } from "./errors.mjs";
4
+ import { makeHeaders } from "./http-auth.mjs";
5
+ import { requestWithTokenRetry } from "./http-request.mjs";
6
+ import { parseRateLimit, parseRetryAfter, redactToken, sanitizeBody, } from "./http-utils.mjs";
7
+ const BASE_URL = "https://api.github.com";
8
+ async function graphqlInner(query, vars) {
9
+ const url = `${BASE_URL}/graphql`;
10
+ const n = nextEntry();
11
+ appendEntry(formatRequestEntry({
12
+ n,
13
+ kind: "GraphQL",
14
+ method: "POST",
15
+ url,
16
+ body: { query, variables: vars },
17
+ }));
18
+ const t0 = performance.now();
19
+ const { res, attempt, retryT0 } = await requestWithTokenRetry(async () => fetch(url, {
20
+ method: "POST",
21
+ headers: await makeHeaders(),
22
+ body: JSON.stringify({ query, variables: vars }),
23
+ }), t0, (status, durationMs) => appendEntry(formatResponseEntry({ n, kind: "GraphQL", method: "POST", url, status, durationMs })));
24
+ const durationMs = Math.round(performance.now() - retryT0);
25
+ const rateLimit = parseRateLimit(res.headers);
26
+ const retryAfterSeconds = parseRetryAfter(res.headers);
27
+ if (!res.ok) {
28
+ const body = await res.text();
29
+ appendEntry(formatResponseEntry({
30
+ n,
31
+ kind: "GraphQL",
32
+ method: "POST",
33
+ url,
34
+ status: res.status,
35
+ durationMs,
36
+ textBody: redactToken(body),
37
+ attempt: attempt > 1 ? attempt : undefined,
38
+ }));
39
+ throw new GitHubRequestError(`GitHub GraphQL request failed: ${res.status} ${sanitizeBody(body)}`, { status: res.status, rateLimit: rateLimit ?? undefined, retryAfterSeconds });
40
+ }
41
+ const parsed = (await res.json());
42
+ appendEntry(formatResponseEntry({
43
+ n,
44
+ kind: "GraphQL",
45
+ method: "POST",
46
+ url,
47
+ status: res.status,
48
+ durationMs,
49
+ body: parsed,
50
+ attempt: attempt > 1 ? attempt : undefined,
51
+ }));
52
+ if (parsed.data == null) {
53
+ const messages = (parsed.errors ?? []).map((e) => e.message).join("; ");
54
+ throw new GitHubRequestError(`GitHub GraphQL error (no data): ${messages}`, {
55
+ status: res.status,
56
+ rateLimit: rateLimit ?? undefined,
57
+ retryAfterSeconds,
58
+ });
59
+ }
60
+ if (parsed.errors?.length) {
61
+ const messages = parsed.errors.map((e) => e.message).join("; ");
62
+ process.stderr.write(`pr-shepherd: GraphQL non-fatal errors: ${messages}\n`);
63
+ }
64
+ return { data: parsed.data, rateLimit, retryAfterSeconds, errors: parsed.errors };
65
+ }
66
+ export async function graphql(query, vars = {}) {
67
+ const { data } = await graphqlInner(query, vars);
68
+ return { data };
69
+ }
70
+ export async function graphqlWithRateLimit(query, vars = {}) {
71
+ const { data, rateLimit, retryAfterSeconds, errors } = await graphqlInner(query, vars);
72
+ return { data, rateLimit: rateLimit ?? undefined, retryAfterSeconds, errors };
73
+ }