pr-shepherd 0.23.0 → 0.25.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 (61) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +4 -2
  3. package/bin/cli/args.mjs +0 -1
  4. package/bin/cli/fix-formatter.mjs +2 -1
  5. package/bin/cli/formatters.mjs +0 -62
  6. package/bin/cli/help-command-pages.mjs +4 -10
  7. package/bin/cli/help-top-page.mjs +1 -1
  8. package/bin/cli/iterate-instructions.mjs +1 -1
  9. package/bin/cli/list-formatters.mjs +3 -2
  10. package/bin/cli-parser.mjs +28 -27
  11. package/bin/commands/check.mjs +6 -2
  12. package/bin/commands/commit-suggestion-instruction.mjs +3 -3
  13. package/bin/commands/iterate/classify.mjs +13 -7
  14. package/bin/commands/iterate/fix-code.mjs +2 -2
  15. package/bin/commands/iterate/index.mjs +4 -1
  16. package/bin/commands/iterate/render.mjs +3 -0
  17. package/bin/commands/iterate/stall.mjs +1 -1
  18. package/bin/commands/resolve-mutate.mjs +14 -6
  19. package/bin/commands/resolve.mjs +0 -72
  20. package/bin/comments/authors.mjs +19 -1
  21. package/bin/comments/minimize-policy.mjs +6 -5
  22. package/bin/comments/review-thread-markers.mjs +18 -0
  23. package/bin/comments/thread-visibility.mjs +5 -5
  24. package/bin/comments/visible-comments.mjs +2 -2
  25. package/bin/config/load.mjs +7 -0
  26. package/bin/config.json +15 -2
  27. package/bin/github/batch-parsers.mjs +2 -0
  28. package/bin/github/client.mjs +1 -2
  29. package/bin/github/gql/batch-pr.gql +3 -0
  30. package/bin/reporters/agent.mjs +1 -0
  31. package/bin/state/seen-comments.mjs +40 -4
  32. package/package.json +9 -5
  33. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  34. package/bin/checks/triage.test-support.mjs +0 -60
  35. package/bin/cli/iterate-lean.test-support.mjs +0 -3
  36. package/bin/cli-parser.clean.test-support.mjs +0 -44
  37. package/bin/cli-parser.commit-suggestion.test-support.mjs +0 -65
  38. package/bin/cli-parser.iterate-fix.test-support.mjs +0 -43
  39. package/bin/cli-parser.iterate-fixtures.mjs +0 -75
  40. package/bin/cli-parser.iterate.test-support.mjs +0 -44
  41. package/bin/cli-parser.test-support.mjs +0 -47
  42. package/bin/commands/check.test-support.mjs +0 -147
  43. package/bin/commands/clean.test-support.mjs +0 -47
  44. package/bin/commands/commit-suggestion.apply.test-support.mjs +0 -87
  45. package/bin/commands/commit-suggestion.test-support.mjs +0 -112
  46. package/bin/commands/iterate-stall.test-support.mjs +0 -24
  47. package/bin/commands/iterate-test-support.mjs +0 -150
  48. package/bin/commands/iterate-thread-test-support.mjs +0 -18
  49. package/bin/commands/iterate.fix-code-in-progress.test-support.mjs +0 -127
  50. package/bin/commands/poll.test-support.mjs +0 -77
  51. package/bin/commands/resolve-instructions.mjs +0 -59
  52. package/bin/commands/resolve.test-support.mjs +0 -116
  53. package/bin/commands/shepherd-journal.test-support.mjs +0 -7
  54. package/bin/comments/outdated.mjs +0 -15
  55. package/bin/comments/resolve.test-support.mjs +0 -44
  56. package/bin/github/batch-parsers.test-support.mjs +0 -66
  57. package/bin/github/batch.test-support.mjs +0 -66
  58. package/bin/github/client.test-support.mjs +0 -55
  59. package/bin/github/http.test-support.mjs +0 -51
  60. package/bin/state/seen-comments.test-support.mjs +0 -19
  61. package/bin/suggestions/patch.test-support.mjs +0 -2
@@ -1,5 +1,6 @@
1
1
  import { classifyItem } from "../state/seen-comments.mjs";
2
2
  import { threadTranscriptBody } from "../threads/transcript.mjs";
3
+ import { isConfiguredBotAuthor } from "./authors.mjs";
3
4
  function withEdited(thread, edited) {
4
5
  return edited ? { ...thread, edited: true } : thread;
5
6
  }
@@ -15,20 +16,19 @@ function classifyFirstLookThread(thread, seenMap, firstLookStatus) {
15
16
  return null;
16
17
  return { ...visible, firstLookStatus };
17
18
  }
18
- export function classifyThreadVisibility(threads, seenMap) {
19
+ export function classifyThreadVisibility(threads, seenMap, botUsernames = new Set()) {
19
20
  const unresolvedThreads = threads.filter((t) => !t.isResolved);
20
21
  const activeThreads = unresolvedThreads
21
22
  .filter((t) => !t.isOutdated && !t.isMinimized)
22
23
  .flatMap((t) => {
24
+ if (isConfiguredBotAuthor(t, botUsernames))
25
+ return [t];
23
26
  const visible = classifyVisibleThread(t, seenMap);
24
27
  return visible ? [visible] : [];
25
28
  });
26
29
  const resolutionOnlyThreads = unresolvedThreads
27
30
  .filter((t) => t.isOutdated || t.isMinimized)
28
- .flatMap((t) => {
29
- const visible = classifyVisibleThread(t, seenMap);
30
- return visible ? [visible] : [];
31
- });
31
+ .map((t) => classifyVisibleThread(t, seenMap) ?? t);
32
32
  const firstLookThreads = [
33
33
  ...threads.flatMap((t) => {
34
34
  if (!t.isOutdated)
@@ -1,11 +1,11 @@
1
1
  import { shouldMinimizeAuthor } from "./minimize-policy.mjs";
2
2
  import { classifyItem } from "../state/seen-comments.mjs";
3
- export function classifyVisibleComments(comments, seenMap, minimizeComments) {
3
+ export function classifyVisibleComments(comments, seenMap, minimizeComments, botUsernames = new Set()) {
4
4
  const actionable = [];
5
5
  const minimizeIds = [];
6
6
  const toMarkSeen = [];
7
7
  for (const c of comments.filter((comment) => !comment.isMinimized)) {
8
- if (shouldMinimizeAuthor(c.authorType, minimizeComments, c.author)) {
8
+ if (shouldMinimizeAuthor(c.authorType, minimizeComments, c.author, botUsernames)) {
9
9
  actionable.push(c);
10
10
  minimizeIds.push(c.id);
11
11
  continue;
@@ -45,6 +45,12 @@ function parseMinimizeCommentsPolicy(value) {
45
45
  return value;
46
46
  throw new Error(`Invalid config: iterate.minimizeComments must be one of "all", "bots", "users", or "none", got ${JSON.stringify(value)}`);
47
47
  }
48
+ function parseBotUsernames(value) {
49
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
50
+ throw new Error(`Invalid config: botUsernames must be an array of strings`);
51
+ }
52
+ return value;
53
+ }
48
54
  const defaults = builtins;
49
55
  const configCache = new Map();
50
56
  export function loadConfig() {
@@ -60,6 +66,7 @@ export function loadConfig() {
60
66
  const raw = readFileSync(rcPath, "utf8");
61
67
  const parsed = (parse(raw) ?? {});
62
68
  const config = deepMerge(defaults, parsed);
69
+ config.botUsernames = parseBotUsernames(config.botUsernames);
63
70
  config.iterate.minimizeComments = parseMinimizeCommentsPolicy(config.iterate.minimizeComments);
64
71
  configCache.set(cwd, config);
65
72
  return config;
package/bin/config.json CHANGED
@@ -1,4 +1,18 @@
1
1
  {
2
+ "botUsernames": [
3
+ "chatgpt-connector",
4
+ "openai-codex",
5
+ "codex",
6
+ "claude",
7
+ "copilot-pull-request-reviewer",
8
+ "gemini-code-assist",
9
+ "coderabbitai",
10
+ "greptile-apps",
11
+ "qodo-merge-pro",
12
+ "deepsource-io",
13
+ "sonarqubecloud",
14
+ "what-the-diff"
15
+ ],
2
16
  "iterate": {
3
17
  "fixAttemptsPerThread": 3,
4
18
  "stallTimeoutMinutes": 60,
@@ -12,8 +26,7 @@
12
26
  "shaPoll": {
13
27
  "intervalMs": 2000,
14
28
  "maxAttempts": 10
15
- },
16
- "fetchReviewSummaries": true
29
+ }
17
30
  },
18
31
  "checks": {
19
32
  "ciTriggerEvents": ["pull_request", "pull_request_target"]
@@ -13,6 +13,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
13
13
  const comments = t.comments.nodes.map((c) => ({
14
14
  id: c.id,
15
15
  isMinimized: c.isMinimized,
16
+ ...(c.pullRequestReview?.id ? { reviewId: c.pullRequestReview.id } : undefined),
16
17
  author: c.author?.login ?? "unknown",
17
18
  authorType: mapAuthorType(c.author?.__typename, c.author?.login),
18
19
  body: c.body,
@@ -27,6 +28,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
27
28
  path: t.path ?? comment?.path ?? null,
28
29
  line: t.line ?? comment?.line ?? null,
29
30
  startLine: t.startLine ?? comment?.startLine ?? null,
31
+ ...(comment?.pullRequestReview?.id ? { reviewId: comment.pullRequestReview.id } : undefined),
30
32
  author: comment?.author?.login ?? "unknown",
31
33
  authorType: mapAuthorType(comment?.author?.__typename, comment?.author?.login),
32
34
  body: comment?.body ?? "",
@@ -7,9 +7,8 @@
7
7
  */
8
8
  import { execFile as execFileCb } from "node:child_process";
9
9
  import { promisify } from "node:util";
10
- import { graphql as httpGraphql, rest, GitHubRequestError, } from "./http.mjs";
10
+ import { graphql as httpGraphql, rest } from "./http.mjs";
11
11
  import { PR_NUMBER_BY_BRANCH_QUERY, GET_PR_HEAD_SHA_QUERY } from "./queries.mjs";
12
- export { GitHubRequestError };
13
12
  const execFile = promisify(execFileCb);
14
13
  // ---------------------------------------------------------------------------
15
14
  // GraphQL — thin re-exports so callers don't need to import http.mts directly
@@ -83,6 +83,9 @@ query BatchPr(
83
83
  __typename
84
84
  login
85
85
  }
86
+ pullRequestReview {
87
+ id
88
+ }
86
89
  body
87
90
  path
88
91
  line
@@ -13,6 +13,7 @@ export function toAgentThread(t) {
13
13
  const suggestion = extractSuggestion(t) ?? undefined;
14
14
  return {
15
15
  id: t.id,
16
+ ...(t.reviewId !== undefined && { reviewId: t.reviewId }),
16
17
  path: t.path,
17
18
  line: t.line,
18
19
  ...(t.line !== null &&
@@ -1,3 +1,4 @@
1
+ /* eslint-disable max-lines */
1
2
  import { readFile, writeFile, rename, unlink, mkdir, access, readdir } from "node:fs/promises";
2
3
  import { join, dirname } from "node:path";
3
4
  import { createHash, randomUUID } from "node:crypto";
@@ -23,8 +24,14 @@ export function classifyItem(id, body, map) {
23
24
  const m = map.get(id);
24
25
  if (!m)
25
26
  return "new";
26
- if (typeof m.bodyHash === "string" && m.bodyHash !== hashBody(body))
27
+ const currentHash = hashBody(body);
28
+ if (typeof m.previousBodyHash === "string" && m.previousBodyHash === currentHash) {
29
+ return "unchanged";
30
+ }
31
+ if (typeof m.bodyHash === "string" && m.bodyHash !== currentHash)
27
32
  return "edited";
33
+ if (m.bodyHash === undefined && Array.isArray(m.inlineThreadIds))
34
+ return "new";
28
35
  return "unchanged";
29
36
  }
30
37
  /**
@@ -94,11 +101,31 @@ export async function hasSeen(key, id) {
94
101
  * All errors are silently swallowed — the marker is best-effort.
95
102
  */
96
103
  export async function markSeen(key, id, body) {
104
+ await writeSeenMarker(key, id, { bodyHash: hashBody(body) });
105
+ }
106
+ export async function markReviewInlineThreads(key, reviewId, inlineThreadIds) {
107
+ await writeSeenMarker(key, reviewId, {
108
+ inlineThreadIds: [...new Set(inlineThreadIds)].sort((a, b) => a.localeCompare(b)),
109
+ });
110
+ }
111
+ /**
112
+ * Write a marker after Shepherd successfully replies to a review thread.
113
+ *
114
+ * `previousBody` suppresses stale GitHub fetches that have not yet included the new reply.
115
+ * `body` suppresses the expected final transcript once GitHub includes Shepherd's reply.
116
+ */
117
+ export async function markReplySeen(key, id, previousBody, body, replyBody) {
118
+ await writeSeenMarker(key, id, {
119
+ bodyHash: hashBody(body),
120
+ previousBodyHash: hashBody(previousBody),
121
+ replyBodyHash: hashBody(replyBody),
122
+ });
123
+ }
124
+ async function writeSeenMarker(key, id, markerFields) {
97
125
  let tmp;
98
126
  try {
99
127
  const path = resolvePath(key, id);
100
128
  await mkdir(dirname(path), { recursive: true });
101
- const newHash = hashBody(body);
102
129
  let existing = null;
103
130
  try {
104
131
  const raw = await readFile(path, "utf8");
@@ -107,14 +134,15 @@ export async function markSeen(key, id, body) {
107
134
  catch {
108
135
  // no existing marker — will create below
109
136
  }
110
- if (existing !== null && existing.bodyHash === newHash)
137
+ const unchanged = Object.entries(markerFields).every(([field, value]) => markerFieldEqual(existing?.[field], value));
138
+ if (existing !== null && unchanged)
111
139
  return;
112
140
  const seenAt = existing?.seenAt ?? Date.now();
113
141
  tmp = `${path}.${randomUUID()}.tmp`;
114
142
  // Store `id` in the payload so loadSeenMap can key by the original ID
115
143
  // rather than the filename, guarding against case-insensitive filesystems
116
144
  // (e.g. macOS APFS) where IDs differing only in case would collide.
117
- await writeFile(tmp, JSON.stringify({ seenAt, bodyHash: newHash, id }), "utf8");
145
+ await writeFile(tmp, JSON.stringify({ ...existing, seenAt, ...markerFields, id }), "utf8");
118
146
  await rename(tmp, path);
119
147
  tmp = undefined;
120
148
  }
@@ -132,6 +160,14 @@ export async function markSeen(key, id, body) {
132
160
  }
133
161
  }
134
162
  }
163
+ function markerFieldEqual(left, right) {
164
+ if (Object.is(left, right))
165
+ return true;
166
+ if (Array.isArray(left) && Array.isArray(right)) {
167
+ return left.length === right.length && left.every((value, index) => value === right[index]);
168
+ }
169
+ return false;
170
+ }
135
171
  /** Read the full marker for inspection (returns null on miss or error). */
136
172
  export async function readSeenMarker(key, id) {
137
173
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
5
5
  "license": "MIT",
6
6
  "author": "Jonathan Ong",
@@ -28,7 +28,8 @@
28
28
  "@types/node": "^25.6.0",
29
29
  "@vitest/coverage-v8": "^4.1.4",
30
30
  "husky": "^9.1.7",
31
- "oxfmt": "^0.48.0",
31
+ "knip": "^6.14.1",
32
+ "oxfmt": "^0.50.0",
32
33
  "oxlint": "^1.60.0",
33
34
  "typescript": "^6.0.3",
34
35
  "vitest": "^4.1.4"
@@ -38,9 +39,12 @@
38
39
  "prepare": "node scripts/install-husky.mjs && npm run build",
39
40
  "prepublishOnly": "npm run typecheck && npm test && npm run build",
40
41
  "typecheck": "tsc --noEmit",
41
- "lint": "oxlint src/ plugins/ .agents/plugins/",
42
- "format": "oxfmt src/ plugins/ .agents/plugins/ docs/ README.md",
43
- "format:check": "oxfmt --check src/ plugins/ .agents/plugins/ docs/ README.md",
42
+ "knip": "knip",
43
+ "knip:production": "knip --production",
44
+ "lint:dead-code": "npm run --silent knip && npm run --silent knip:production",
45
+ "lint": "oxlint src/ test-helpers/ fixtures/*.mts test-cases/ plugins/ .agents/plugins/ && npm run --silent lint:dead-code",
46
+ "format": "oxfmt src/ test-helpers/ fixtures/*.mts test-cases/*.mts plugins/ .agents/plugins/ docs/ README.md",
47
+ "format:check": "oxfmt --check src/ test-helpers/ fixtures/*.mts test-cases/*.mts plugins/ .agents/plugins/ docs/ README.md",
44
48
  "test": "vitest run",
45
49
  "test:coverage": "vitest run --coverage && node scripts/strip-lcov-branches.mjs",
46
50
  "test:watch": "vitest"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -1,60 +0,0 @@
1
- import { vi, beforeEach } from "vitest";
2
- // ---------------------------------------------------------------------------
3
- // Stub fetch globally so http.mts uses our mock.
4
- // ---------------------------------------------------------------------------
5
- const mockFetch = vi.fn();
6
- vi.stubGlobal("fetch", mockFetch);
7
- import { fetchStartupFailureChecks, triageFailingChecks } from "./triage.mjs";
8
- import { mergeStartupFailureChecks } from "./startup-failures.mjs";
9
- const REPO = { owner: "owner", name: "repo" };
10
- // ---------------------------------------------------------------------------
11
- // Helpers
12
- // ---------------------------------------------------------------------------
13
- function makeCheck(overrides = {}) {
14
- return {
15
- name: "tests",
16
- status: "COMPLETED",
17
- conclusion: "FAILURE",
18
- detailsUrl: "https://github.com/owner/repo/actions/runs/99/jobs/1",
19
- event: "pull_request",
20
- runId: "run-99",
21
- category: "failing",
22
- ...overrides,
23
- };
24
- }
25
- function makeJobsResponse(jobs) {
26
- return {
27
- ok: true,
28
- status: 200,
29
- headers: new Headers({ "content-type": "application/json" }),
30
- json: () => Promise.resolve({ jobs }),
31
- text: () => Promise.resolve(JSON.stringify({ jobs })),
32
- };
33
- }
34
- function makeErrorResponse(status) {
35
- return {
36
- ok: false,
37
- status,
38
- headers: new Headers(),
39
- text: () => Promise.resolve("error"),
40
- };
41
- }
42
- function makeWorkflowRunsResponse(runs) {
43
- return {
44
- ok: true,
45
- status: 200,
46
- headers: new Headers({ "content-type": "application/json" }),
47
- json: () => Promise.resolve({ workflow_runs: runs }),
48
- text: () => Promise.resolve(JSON.stringify({ workflow_runs: runs })),
49
- };
50
- }
51
- // ---------------------------------------------------------------------------
52
- // Tests
53
- // ---------------------------------------------------------------------------
54
- export function registerHooks() {
55
- beforeEach(() => {
56
- mockFetch.mockReset();
57
- process.env["GH_TOKEN"] = "test-token";
58
- });
59
- }
60
- export { REPO, fetchStartupFailureChecks, makeCheck, makeErrorResponse, makeJobsResponse, makeWorkflowRunsResponse, mergeStartupFailureChecks, mockFetch, triageFailingChecks, };
@@ -1,3 +0,0 @@
1
- import { projectIterateLean, projectIterateVerbose } from "./iterate-lean.mjs";
2
- import { makeIterateResult } from "../cli-parser.iterate-fixtures.mjs";
3
- export { makeIterateResult, projectIterateLean, projectIterateVerbose };
@@ -1,44 +0,0 @@
1
- import { vi, beforeEach, afterEach } from "vitest";
2
- vi.mock("./commands/clean.mts", () => ({
3
- runClean: vi.fn(),
4
- }));
5
- vi.mock("./commands/resolve.mts", () => ({
6
- runResolveFetch: vi.fn(),
7
- runResolveMutate: vi.fn(),
8
- }));
9
- vi.mock("./commands/log-file.mts", () => ({
10
- runLogFile: vi.fn(),
11
- }));
12
- vi.mock("./commands/commit-suggestion.mts", () => ({
13
- runCommitSuggestion: vi.fn(),
14
- }));
15
- vi.mock("./commands/iterate/index.mts", async (importOriginal) => {
16
- const actual = await importOriginal();
17
- return { ...actual, runIterate: vi.fn() };
18
- });
19
- import { main } from "./cli-parser.mjs";
20
- import { runClean } from "./commands/clean.mjs";
21
- export const mockRunClean = vi.mocked(runClean);
22
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
23
- let stdoutSpy;
24
- let stderrSpy;
25
- export function getStdout() {
26
- return stdoutSpy.mock.calls.map((c) => c[0]).join("");
27
- }
28
- export function getStderr() {
29
- return stderrSpy.mock.calls.map((c) => c[0]).join("");
30
- }
31
- export function registerHooks() {
32
- beforeEach(() => {
33
- vi.clearAllMocks();
34
- process.exitCode = undefined;
35
- stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
36
- stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
37
- });
38
- afterEach(() => {
39
- process.exitCode = undefined;
40
- stdoutSpy.mockRestore();
41
- stderrSpy.mockRestore();
42
- });
43
- }
44
- export { main, stderrSpy, stdoutSpy };
@@ -1,65 +0,0 @@
1
- import { vi, beforeEach, afterEach } from "vitest";
2
- vi.mock("./commands/check.mts", () => ({ runCheck: vi.fn() }));
3
- vi.mock("./commands/resolve.mts", () => ({
4
- runResolveFetch: vi.fn(),
5
- runResolveMutate: vi.fn(),
6
- }));
7
- vi.mock("./commands/commit-suggestion.mts", () => ({
8
- runCommitSuggestion: vi.fn(),
9
- }));
10
- vi.mock("./commands/iterate/index.mts", async (importOriginal) => {
11
- const actual = await importOriginal();
12
- return { ...actual, runIterate: vi.fn() };
13
- });
14
- vi.mock("./github/client.mts", () => ({
15
- getRepoInfo: vi.fn().mockResolvedValue({ owner: "owner", name: "repo" }),
16
- }));
17
- import { main } from "./cli-parser.mjs";
18
- import { runCommitSuggestion } from "./commands/commit-suggestion.mjs";
19
- const mockRunCommitSuggestion = vi.mocked(runCommitSuggestion);
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
- // ---------------------------------------------------------------------------
27
- // Fixtures
28
- // ---------------------------------------------------------------------------
29
- const SUGGESTION_RESULT = {
30
- pr: 42,
31
- repo: "owner/repo",
32
- threadId: "t1",
33
- path: "a.ts",
34
- startLine: 5,
35
- endLine: 5,
36
- author: "alice",
37
- patch: "--- a/a.ts\n+++ b/a.ts\n@@ -5,1 +5,1 @@\n-old\n+new\n",
38
- commitMessage: "apply fix",
39
- commitBody: "Co-authored-by: alice <alice@users.noreply.github.com>",
40
- filesToStage: ["a.ts"],
41
- postActionInstructions: [
42
- "Apply the patch to `a.ts`: run `git apply` with the diff shown above.",
43
- "Stage the file: `git add -- a.ts`",
44
- 'Commit: `git commit -m "apply fix" -m "Co-authored-by: alice <alice@users.noreply.github.com>"`',
45
- "Resolve the thread on GitHub: `pr-shepherd resolve 42 --resolve-thread-ids t1`",
46
- "Push when ready: `git push` (or `git push --force-with-lease` after rebasing).",
47
- ],
48
- };
49
- // ---------------------------------------------------------------------------
50
- // commit-suggestion dispatch
51
- // ---------------------------------------------------------------------------
52
- export function registerHooks() {
53
- beforeEach(() => {
54
- vi.clearAllMocks();
55
- process.exitCode = undefined;
56
- stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
57
- stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
58
- });
59
- afterEach(() => {
60
- process.exitCode = undefined;
61
- stdoutSpy.mockRestore();
62
- stderrSpy.mockRestore();
63
- });
64
- }
65
- export { SUGGESTION_RESULT, getStdout, main, mockRunCommitSuggestion, runCommitSuggestion, stderrSpy, stdoutSpy, };
@@ -1,43 +0,0 @@
1
- import { vi, beforeEach, afterEach } from "vitest";
2
- vi.mock("./commands/check.mts", () => ({ runCheck: vi.fn() }));
3
- vi.mock("./commands/resolve.mts", () => ({
4
- runResolveFetch: vi.fn(),
5
- runResolveMutate: vi.fn(),
6
- }));
7
- vi.mock("./commands/commit-suggestion.mts", () => ({
8
- runCommitSuggestion: vi.fn(),
9
- }));
10
- vi.mock("./commands/iterate/index.mts", async (importOriginal) => {
11
- const actual = await importOriginal();
12
- return { ...actual, runIterate: vi.fn() };
13
- });
14
- vi.mock("./github/client.mts", () => ({
15
- getRepoInfo: vi.fn().mockResolvedValue({ owner: "owner", name: "repo" }),
16
- }));
17
- import { main } from "./cli-parser.mjs";
18
- import { runIterate } from "./commands/iterate/index.mjs";
19
- import { makeIterateResult } from "./cli-parser.iterate-fixtures.mjs";
20
- const mockRunIterate = vi.mocked(runIterate);
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
- // formatIterateResult — fix_code actions and ## Checks section
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, makeIterateResult, mockRunIterate, runIterate, stderrSpy, stdoutSpy };
@@ -1,75 +0,0 @@
1
- export function makeIterateResult(action = "wait") {
2
- const base = {
3
- pr: 42,
4
- repo: "owner/repo",
5
- status: "IN_PROGRESS",
6
- state: "OPEN",
7
- mergeStateStatus: "BLOCKED",
8
- mergeStatus: "BLOCKED",
9
- reviewDecision: null,
10
- blockingBotReviewInProgress: false,
11
- isDraft: false,
12
- shouldCancel: false,
13
- remainingSeconds: 60,
14
- summary: { passing: 0, skipped: 0, filtered: 0, inProgress: 1 },
15
- baseBranch: "main",
16
- branchProtection: null,
17
- checks: [],
18
- };
19
- if (action === "wait")
20
- return { ...base, action: "wait", log: "WAIT: 0 passing, 1 in-progress" };
21
- if (action === "mark_ready")
22
- return { ...base, action: "mark_ready", markedReady: true, log: "MARKED READY: PR 42" };
23
- if (action === "fix_code") {
24
- return {
25
- ...base,
26
- action: "fix_code",
27
- fix: {
28
- threads: [],
29
- resolutionOnlyThreads: [],
30
- actionableComments: [],
31
- reviewSummaryIds: [],
32
- firstLookSummaries: [],
33
- editedSummaries: [],
34
- surfacedApprovals: [],
35
- checks: [],
36
- changesRequestedReviews: [],
37
- resolveCommand: {
38
- argv: ["pr-shepherd", "resolve", "42"],
39
- requiresHeadSha: true,
40
- requiresDismissMessage: false,
41
- hasMutations: false,
42
- },
43
- instructions: [
44
- "Stop this iteration — if you pushed new commits, CI needs time before the next tick; otherwise stop before the next tick.",
45
- ],
46
- firstLookThreads: [],
47
- firstLookComments: [],
48
- inProgressRunIds: [],
49
- },
50
- cancelled: [],
51
- };
52
- }
53
- if (action === "cancel")
54
- return {
55
- ...base,
56
- action: "cancel",
57
- reason: "ready-delay-elapsed",
58
- log: "CANCEL: PR #42 — stopping",
59
- };
60
- if (action === "escalate") {
61
- return {
62
- ...base,
63
- action: "escalate",
64
- escalate: {
65
- triggers: [],
66
- unresolvedThreads: [],
67
- ambiguousComments: [],
68
- changesRequestedReviews: [],
69
- suggestion: "check manually",
70
- humanMessage: "⚠️ /pr-shepherd:pr-shepherd paused — manual intervention required",
71
- },
72
- };
73
- }
74
- return { ...base, action: "wait", log: "WAIT: 0 passing, 1 in-progress" };
75
- }
@@ -1,44 +0,0 @@
1
- import { vi, beforeEach, afterEach } from "vitest";
2
- vi.mock("./commands/check.mts", () => ({ runCheck: vi.fn() }));
3
- vi.mock("./commands/resolve.mts", () => ({
4
- runResolveFetch: vi.fn(),
5
- runResolveMutate: vi.fn(),
6
- }));
7
- vi.mock("./commands/commit-suggestion.mts", () => ({
8
- runCommitSuggestion: vi.fn(),
9
- }));
10
- vi.mock("./commands/iterate/index.mts", async (importOriginal) => {
11
- const actual = await importOriginal();
12
- return { ...actual, runIterate: vi.fn() };
13
- });
14
- import { main } from "./cli-parser.mjs";
15
- import { runIterate } from "./commands/iterate/index.mjs";
16
- import { formatIterateResult } from "./cli/iterate-formatter.mjs";
17
- import { makeIterateResult } from "./cli-parser.iterate-fixtures.mjs";
18
- const mockRunIterate = vi.mocked(runIterate);
19
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
- let stdoutSpy;
21
- let stderrSpy;
22
- function getStdout() {
23
- return stdoutSpy.mock.calls.map((c) => c[0]).join("");
24
- }
25
- function getStderr() {
26
- return stderrSpy.mock.calls.map((c) => c[0]).join("");
27
- }
28
- // ---------------------------------------------------------------------------
29
- // iterate dispatch
30
- // ---------------------------------------------------------------------------
31
- export function registerHooks() {
32
- beforeEach(() => {
33
- vi.clearAllMocks();
34
- process.exitCode = undefined;
35
- stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
36
- stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
37
- });
38
- afterEach(() => {
39
- process.exitCode = undefined;
40
- stdoutSpy.mockRestore();
41
- stderrSpy.mockRestore();
42
- });
43
- }
44
- export { formatIterateResult, getStderr, getStdout, main, makeIterateResult, mockRunIterate, runIterate, stderrSpy, stdoutSpy, };
@@ -1,47 +0,0 @@
1
- import { readFileSync } from "node:fs";
2
- import { vi, beforeEach, afterEach } from "vitest";
3
- vi.mock("./commands/resolve.mts", () => ({
4
- runResolveFetch: vi.fn(),
5
- runResolveMutate: vi.fn(),
6
- }));
7
- vi.mock("./commands/log-file.mts", () => ({
8
- runLogFile: vi.fn(),
9
- }));
10
- vi.mock("./commands/commit-suggestion.mts", () => ({
11
- runCommitSuggestion: vi.fn(),
12
- }));
13
- vi.mock("./commands/mark-files-as-viewed.mts", () => ({
14
- runMarkFilesAsViewed: vi.fn(),
15
- }));
16
- vi.mock("./commands/iterate/index.mts", async (importOriginal) => {
17
- const actual = await importOriginal();
18
- return { ...actual, runIterate: vi.fn() };
19
- });
20
- import { main } from "./cli-parser.mjs";
21
- import { runLogFile } from "./commands/log-file.mjs";
22
- import { runResolveFetch, runResolveMutate } from "./commands/resolve.mjs";
23
- import { runMarkFilesAsViewed } from "./commands/mark-files-as-viewed.mjs";
24
- const mockRunResolveFetch = vi.mocked(runResolveFetch);
25
- const mockRunResolveMutate = vi.mocked(runResolveMutate);
26
- const mockRunLogFile = vi.mocked(runLogFile);
27
- const mockRunMarkFilesAsViewed = vi.mocked(runMarkFilesAsViewed);
28
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
29
- let stdoutSpy;
30
- let stderrSpy;
31
- function getStdout() {
32
- return stdoutSpy.mock.calls.map((c) => c[0]).join("");
33
- }
34
- export function registerHooks() {
35
- beforeEach(() => {
36
- vi.clearAllMocks();
37
- process.exitCode = undefined;
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
- stdoutSpy.mockRestore();
44
- stderrSpy.mockRestore();
45
- });
46
- }
47
- export { getStdout, main, mockRunLogFile, mockRunMarkFilesAsViewed, mockRunResolveFetch, mockRunResolveMutate, readFileSync, runLogFile, runResolveFetch, runResolveMutate, stderrSpy, stdoutSpy, };