pr-shepherd 0.13.1 → 0.14.1

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.
@@ -1,34 +1,36 @@
1
+ import { buildPrShepherdCommand, renderShellCommand } from "../../cli/runner.mjs";
1
2
  export const FIX_INSTRUCTION_STOP_AFTER_PUSH = "Stop this iteration — CI needs time to run on the new push before the next tick.";
2
3
  export const FIX_INSTRUCTION_STOP_BEFORE_NEXT_TICK = "Stop this iteration before the next tick.";
3
4
  export const FIX_INSTRUCTION_END_ITERATION = "End this iteration.";
4
5
  /**
5
- * Render a resolve command as a shell snippet. Wraps `$DISMISS_MESSAGE` and whitespace-bearing
6
- * argv entries in double quotes for placeholder substitution. Throws if argv contains `"`, `$`,
7
- * `` ` ``, or `\`. `$HEAD_SHA` is appended separately when `requiresHeadSha` is set.
6
+ * Render a resolve command as a shell snippet. Wraps `$DISMISS_MESSAGE`, `$HEAD_SHA`, and
7
+ * whitespace-bearing argv entries for placeholder substitution. `$HEAD_SHA` is appended separately
8
+ * when `requiresHeadSha` is set.
8
9
  */
9
10
  export function renderResolveCommand(rc) {
10
- const needsQuoting = (arg) => {
11
- if (arg === "$DISMISS_MESSAGE")
12
- return true;
13
- if (/["$`\\]/.test(arg)) {
14
- throw new Error(`Unexpected character in argv arg that needsQuoting can't handle: ${JSON.stringify(arg)}`);
15
- }
16
- return /\s/.test(arg);
17
- };
18
- const parts = rc.argv.map((a) => (needsQuoting(a) ? `"${a}"` : a));
11
+ const parts = [...rc.argv];
19
12
  if (rc.requiresHeadSha) {
20
- parts.push("--require-sha", '"$HEAD_SHA"');
13
+ parts.push("--require-sha", "$HEAD_SHA");
21
14
  }
22
- return parts.join(" ");
15
+ return renderShellCommand(parts);
23
16
  }
24
- export function buildFixInstructions(threads, actionableComments, checks, reviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = []) {
17
+ export function buildFixInstructions(threads, actionableComments, checks, reviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = [], runner) {
25
18
  const instructions = [];
26
19
  if (inProgressRunIds.length > 0) {
27
20
  instructions.push(`Cancel in-progress CI runs first: for each ID under \`## In-progress runs\`, run \`gh run cancel <id>\`. Do this before applying any code fixes — the push at the end of this iteration will supersede those runs anyway, so letting them continue burns CI minutes for results no one will read. If \`gh\` reports a run is already completed, ignore it and continue with the next ID.`);
28
21
  }
29
22
  const hasSuggestions = threads.some((t) => t.suggestion);
30
23
  if (hasSuggestions) {
31
- instructions.push(`For each thread marked \`[suggestion]\` under \`## Review threads\`: run \`npx pr-shepherd commit-suggestion ${prNumber} --thread-id <id> --message "<one-sentence headline>" --format=json\` to retrieve the patch and suggested commit. The CLI does not mutate the working tree — apply the patch yourself (run \`git apply\` with the diff shown, or edit the file directly using the line range), then stage the listed file and run the suggested \`git commit\` from the \`## Instructions\` section. Include the thread ID in \`--resolve-thread-ids\` in the \`resolve:\` command below (the thread is not auto-resolved). If the patch fails to apply, fall through to the manual-edit step. Do not retry the same command.`);
24
+ const commitSuggestionCommand = buildPrShepherdCommand([
25
+ "commit-suggestion",
26
+ String(prNumber),
27
+ "--thread-id",
28
+ "<id>",
29
+ "--message",
30
+ "<one-sentence headline>",
31
+ "--format=json",
32
+ ], { runner }).text;
33
+ instructions.push(`For each thread marked \`[suggestion]\` under \`## Review threads\`: run \`${commitSuggestionCommand}\` to retrieve the patch and suggested commit. The CLI does not mutate the working tree — apply the patch yourself (run \`git apply\` with the diff shown, or edit the file directly using the line range), then stage the listed file and run the suggested \`git commit\` from the \`## Instructions\` section. Include the thread ID in \`--resolve-thread-ids\` in the \`resolve:\` command below (the thread is not auto-resolved). If the patch fails to apply, fall through to the manual-edit step. Do not retry the same command.`);
32
34
  }
33
35
  if (threads.length > 0 || actionableComments.length > 0) {
34
36
  const suggestionFallback = hasSuggestions
@@ -1,6 +1,7 @@
1
1
  import { getCurrentPrNumber } from "../github/client.mjs";
2
2
  import { loadConfig } from "../config/load.mjs";
3
3
  import { joinSections } from "../util/markdown.mjs";
4
+ import { buildPrShepherdCommand } from "../cli/runner.mjs";
4
5
  export async function runMonitor(opts) {
5
6
  const config = loadConfig();
6
7
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
@@ -18,8 +19,8 @@ export async function runMonitor(opts) {
18
19
  // block depend on this exact string — don't change the format.
19
20
  const loopTag = `#pr-shepherd-loop:pr=${prNumber}:`;
20
21
  const loopArgs = interval;
21
- const reusableCommand = buildIterateCommand(prNumber, opts.readyDelaySuffix);
22
- const loopPrompt = buildLoopPrompt(prNumber, loopTag, reusableCommand, loopArgs, opts.runtime ?? "claude");
22
+ const reusableCommand = buildIterateCommand(prNumber, opts.readyDelaySuffix, config.cli?.runner);
23
+ const loopPrompt = buildLoopPrompt(prNumber, loopTag, reusableCommand, loopArgs, opts.runtime ?? "claude", config.cli?.runner);
23
24
  return {
24
25
  prNumber,
25
26
  loopTag,
@@ -72,11 +73,11 @@ function validateReadyDelaySuffix(readyDelaySuffix) {
72
73
  }
73
74
  return trimmed;
74
75
  }
75
- function buildIterateCommand(prNumber, readyDelaySuffix) {
76
+ function buildIterateCommand(prNumber, readyDelaySuffix, runner) {
76
77
  const validatedDelay = validateReadyDelaySuffix(readyDelaySuffix);
77
- return `npx pr-shepherd ${prNumber}${validatedDelay ? ` --ready-delay ${validatedDelay}` : ""}`;
78
+ return buildPrShepherdCommand([String(prNumber), ...(validatedDelay ? ["--ready-delay", validatedDelay] : [])], { runner }).text;
78
79
  }
79
- function buildLoopPrompt(prNumber, loopTag, iterateCmd, loopArgs, runtime = "claude") {
80
+ function buildLoopPrompt(prNumber, loopTag, iterateCmd, loopArgs, runtime = "claude", runner) {
80
81
  if (runtime === "codex") {
81
82
  return [
82
83
  loopTag,
@@ -85,7 +86,7 @@ function buildLoopPrompt(prNumber, loopTag, iterateCmd, loopArgs, runtime = "cla
85
86
  "- Run the command below once and follow its `## Instructions` exactly.",
86
87
  `- If the output tells you to continue the active Codex goal, wait about the configured interval (${loopArgs}) and rerun the reusable command from the monitor output.`,
87
88
  "- Stop only when Shepherd emits `[CANCEL]` because the ready-delay completed or the PR was merged/closed, or when Shepherd emits `[ESCALATE]` (including `stall-timeout` for repeated unchanged CI failures).",
88
- "- Do not call `/loop`, `ScheduleWakeup`, `CronCreate`, or `npx pr-shepherd monitor`; Codex recurrence is explicit `iterate` command cycles.",
89
+ `- Do not call \`/loop\`, \`ScheduleWakeup\`, \`CronCreate\`, or \`${buildPrShepherdCommand(["monitor", String(prNumber)], { runner }).text}\`; Codex recurrence is explicit \`iterate\` command cycles.`,
89
90
  "",
90
91
  "Run in a single Bash call:",
91
92
  ` ${iterateCmd}`,
@@ -1,9 +1,10 @@
1
+ import { buildPrShepherdCommand } from "../cli/runner.mjs";
1
2
  /**
2
3
  * Build the numbered triage/fix/resolve instruction steps for the agent to follow.
3
4
  * Steps are conditionally emitted based on what the fetch returned (mirrors
4
5
  * `buildFixInstructions` in `commands/iterate/render.mts`).
5
6
  */
6
- export function buildFetchInstructions(prNumber, result) {
7
+ export function buildFetchInstructions(prNumber, result, runner) {
7
8
  const { actionableThreads, resolutionOnlyThreads, firstLookThreads, actionableComments, firstLookComments, changesRequestedReviews, reviewSummaries, commitSuggestionsEnabled, } = result;
8
9
  const firstLookTotal = firstLookThreads.length + firstLookComments.length;
9
10
  const total = actionableThreads.length +
@@ -30,7 +31,16 @@ export function buildFetchInstructions(prNumber, result) {
30
31
  instructions.push(`First-look bullets tagged \`, edited\` were updated by their author after you previously acknowledged them. Read the updated body before deciding whether any matching \`## Review threads to resolve\` item should be resolved.`);
31
32
  }
32
33
  if (hasSuggestions) {
33
- instructions.push(`For each Actionable thread marked \`[suggestion]\` in \`## Actionable Review Threads\` above: run \`npx pr-shepherd commit-suggestion ${prNumber} --thread-id <id> --message "<one-sentence headline>" --format=json\` to retrieve the patch and suggested commit. The CLI does not mutate the working tree — apply the patch yourself (run \`git apply\` with the diff shown, or edit the file directly using the line range), then stage the listed file and run the suggested \`git commit\` from the \`## Instructions\` section. Include the thread ID in \`--resolve-thread-ids\` in the resolve command below (the thread is not auto-resolved). If the patch fails to apply (drift since the suggestion was written), fall through to the manual fix step. Do not retry the same \`commit-suggestion\` invocation.`);
34
+ const commitSuggestionCommand = buildPrShepherdCommand([
35
+ "commit-suggestion",
36
+ String(prNumber),
37
+ "--thread-id",
38
+ "<id>",
39
+ "--message",
40
+ "<one-sentence headline>",
41
+ "--format=json",
42
+ ], { runner }).text;
43
+ instructions.push(`For each Actionable thread marked \`[suggestion]\` in \`## Actionable Review Threads\` above: run \`${commitSuggestionCommand}\` to retrieve the patch and suggested commit. The CLI does not mutate the working tree — apply the patch yourself (run \`git apply\` with the diff shown, or edit the file directly using the line range), then stage the listed file and run the suggested \`git commit\` from the \`## Instructions\` section. Include the thread ID in \`--resolve-thread-ids\` in the resolve command below (the thread is not auto-resolved). If the patch fails to apply (drift since the suggestion was written), fall through to the manual fix step. Do not retry the same \`commit-suggestion\` invocation.`);
34
44
  }
35
45
  if (hasCodeItems) {
36
46
  instructions.push(`Read and edit each file referenced under \`## Actionable Review Threads\`, \`## Actionable PR Comments\`, and \`## Pending CHANGES_REQUESTED reviews\` above. Reclassify each fixed item as Fixed. If an item is too complex to address, leave it as Actionable for the final report.`);
@@ -50,7 +60,10 @@ export function buildFetchInstructions(prNumber, result) {
50
60
  : reviewSummaries.length > 0
51
61
  ? ` Review-summary IDs (\`PRR_…\` from \`## Review summaries\`) go into \`--minimize-comment-ids\`.`
52
62
  : "";
53
- instructions.push(`Run \`npx pr-shepherd resolve ${prNumber} [--resolve-thread-ids <ids>] [--minimize-comment-ids <ids>] [--dismiss-review-ids <ids> --message "<reason>"]\` with only the non-empty flag subsets. Skip the command entirely if all three ID lists are empty.${requireShaHint}${dismissNote}`);
63
+ const resolveCommand = `${buildPrShepherdCommand(["resolve", String(prNumber)], {
64
+ runner,
65
+ }).text} [--resolve-thread-ids <ids>] [--minimize-comment-ids <ids>] [--dismiss-review-ids <ids> --message "<reason>"]`;
66
+ instructions.push(`Run \`${resolveCommand}\` with only the non-empty flag subsets. Skip the command entirely if all three ID lists are empty.${requireShaHint}${dismissNote}`);
54
67
  instructions.push(`For any large decisions or rejections you made this iteration, add or update a \`## Shepherd Journal\` section in the PR description (\`gh pr edit ${prNumber} --body …\`) summarizing each decision. For threads and comments, use the markdown link shown in each item's bullet above; for reviews, reference the review ID.`);
55
68
  instructions.push(`Report: echo the CLI's mutation output, then one line per Acknowledged item: \`Acknowledged <id> (@<author>): <reason>\`. If any fetched item was neither resolved nor acknowledged, stop and escalate: "<N> item(s) fetched but not acted on or acknowledged — need human direction before closing".`);
56
69
  return instructions;
@@ -126,7 +126,7 @@ export async function runResolveFetch(opts) {
126
126
  reviewSummaries: cfg.resolve.fetchReviewSummaries ? data.reviewSummaries : [],
127
127
  commitSuggestionsEnabled: cfg.actions.commitSuggestions,
128
128
  };
129
- return { ...result, instructions: buildFetchInstructions(prNumber, result) };
129
+ return { ...result, instructions: buildFetchInstructions(prNumber, result, cfg.cli?.runner) };
130
130
  }
131
131
  export async function runResolveMutate(opts) {
132
132
  const repo = await getRepoInfo();
@@ -0,0 +1,20 @@
1
+ export function setPendingOps(result, ops) {
2
+ const resolved = new Set(result.resolvedThreads);
3
+ const minimized = new Set(result.minimizedComments);
4
+ const dismissed = new Set(result.dismissedReviews);
5
+ const unresolvedThreads = ops
6
+ .filter((op) => op.kind === "r" && !resolved.has(op.id))
7
+ .map((op) => op.id);
8
+ const unminimizedComments = ops
9
+ .filter((op) => op.kind === "m" && !minimized.has(op.id))
10
+ .map((op) => op.id);
11
+ const undismissedReviews = ops
12
+ .filter((op) => op.kind === "d" && !dismissed.has(op.id))
13
+ .map((op) => op.id);
14
+ if (unresolvedThreads.length > 0)
15
+ result.unresolvedThreads = unresolvedThreads;
16
+ if (unminimizedComments.length > 0)
17
+ result.unminimizedComments = unminimizedComments;
18
+ if (undismissedReviews.length > 0)
19
+ result.undismissedReviews = undismissedReviews;
20
+ }
@@ -0,0 +1,45 @@
1
+ export function rateLimitFromError(err, fallbackMessage) {
2
+ const maybe = err;
3
+ const message = err instanceof Error ? err.message : fallbackMessage;
4
+ const status = finiteNumber(maybe.status);
5
+ const hasRateLimitStatus = status === 403 || status === 429;
6
+ if (!isRateLimitMessage(message) &&
7
+ !(hasRateLimitStatus && maybe.retryAfterSeconds !== undefined) &&
8
+ maybe.rateLimit?.remaining !== 0)
9
+ return null;
10
+ return buildRateLimitStop(message, {
11
+ rateLimit: maybe.rateLimit,
12
+ retryAfterSeconds: maybe.retryAfterSeconds,
13
+ });
14
+ }
15
+ export function rateLimitFromGraphQlResult(messages, meta) {
16
+ const message = messages.find(isRateLimitMessage);
17
+ if (message)
18
+ return buildRateLimitStop(message, meta);
19
+ if (meta.stopOnZeroRemaining === true && meta.rateLimit?.remaining === 0) {
20
+ return buildRateLimitStop("GitHub GraphQL rate limit remaining is 0", meta);
21
+ }
22
+ return undefined;
23
+ }
24
+ function buildRateLimitStop(message, meta) {
25
+ const stop = { message };
26
+ const retryAfterSeconds = finiteNumber(meta.retryAfterSeconds);
27
+ const remaining = finiteNumber(meta.rateLimit?.remaining);
28
+ const limit = finiteNumber(meta.rateLimit?.limit);
29
+ const resetAt = finiteNumber(meta.rateLimit?.resetAt);
30
+ if (retryAfterSeconds !== undefined)
31
+ stop.retryAfterSeconds = retryAfterSeconds;
32
+ if (limit !== undefined)
33
+ stop.limit = limit;
34
+ if (remaining !== undefined)
35
+ stop.remaining = remaining;
36
+ if (resetAt !== undefined)
37
+ stop.resetAt = resetAt;
38
+ return stop;
39
+ }
40
+ function finiteNumber(value) {
41
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
42
+ }
43
+ export function isRateLimitMessage(message) {
44
+ return /rate limit|rate-limit|secondary limit|secondary rate/i.test(message);
45
+ }
@@ -1,5 +1,7 @@
1
- import { graphql, getPrHeadSha } from "../github/client.mjs";
2
- import { loadConfig } from "../config/load.mjs";
1
+ import { graphqlWithRateLimit } from "../github/client.mjs";
2
+ import { isRateLimitMessage, rateLimitFromError, rateLimitFromGraphQlResult, } from "./rate-limit.mjs";
3
+ import { setPendingOps } from "./pending-ops.mjs";
4
+ import { waitForSha } from "./sha-poll.mjs";
3
5
  export async function applyResolveOptions(pr, repo, opts) {
4
6
  if ((opts.dismissReviewIds?.length ?? 0) > 0 && !opts.dismissMessage) {
5
7
  throw new Error("--message is required when dismissing reviews");
@@ -28,8 +30,8 @@ export async function autoResolveOutdated(threadIds) {
28
30
  await bulkApply(threadIds, [], [], "", result);
29
31
  return { resolved: result.resolvedThreads, errors: result.errors };
30
32
  }
31
- // Chunk at 50 so a single oversized list never fails the entire call.
32
- const BULK_CHUNK_SIZE = 50;
33
+ // Keep mutation batches small so rate-limit stops leave a precise pending list.
34
+ const BULK_CHUNK_SIZE = 10;
33
35
  function buildBulkMutation(resolveIds, minimizeIds, dismissIds, dismissMessage) {
34
36
  const ops = [];
35
37
  for (let i = 0; i < resolveIds.length; i++) {
@@ -52,70 +54,72 @@ async function bulkApply(resolveIds, minimizeIds, dismissIds, dismissMessage, re
52
54
  for (let i = 0; i < allOps.length; i += BULK_CHUNK_SIZE) {
53
55
  const chunk = allOps.slice(i, i + BULK_CHUNK_SIZE);
54
56
  // eslint-disable-next-line no-await-in-loop
55
- await bulkApplyChunk(chunk.filter((o) => o.kind === "r").map((o) => o.id), chunk.filter((o) => o.kind === "m").map((o) => o.id), chunk.filter((o) => o.kind === "d").map((o) => o.id), dismissMessage, result);
57
+ const stopped = await bulkApplyChunk(chunk.filter((o) => o.kind === "r").map((o) => o.id), chunk.filter((o) => o.kind === "m").map((o) => o.id), chunk.filter((o) => o.kind === "d").map((o) => o.id), dismissMessage, result, i + BULK_CHUNK_SIZE < allOps.length);
58
+ if (stopped) {
59
+ setPendingOps(result, allOps.slice(i));
60
+ return;
61
+ }
56
62
  }
57
63
  }
58
- async function bulkApplyChunk(resolveIds, minimizeIds, dismissIds, dismissMessage, result) {
64
+ async function bulkApplyChunk(resolveIds, minimizeIds, dismissIds, dismissMessage, result, hasPendingAfter) {
59
65
  if (resolveIds.length === 0 && minimizeIds.length === 0 && dismissIds.length === 0)
60
- return;
66
+ return false;
61
67
  const doc = buildBulkMutation(resolveIds, minimizeIds, dismissIds, dismissMessage);
62
68
  let data;
69
+ let rateLimitStop;
70
+ let suppressCurrentChunkErrors = false;
63
71
  try {
64
- const resp = await graphql(doc, {});
72
+ const resp = await graphqlWithRateLimit(doc, {});
65
73
  data = resp.data;
74
+ const graphQlErrorMessages = resp.errors?.map((e) => e.message) ?? [];
75
+ suppressCurrentChunkErrors = graphQlErrorMessages.some(isRateLimitMessage);
76
+ rateLimitStop = rateLimitFromGraphQlResult(graphQlErrorMessages, {
77
+ rateLimit: resp.rateLimit,
78
+ retryAfterSeconds: resp.retryAfterSeconds,
79
+ stopOnZeroRemaining: hasPendingAfter,
80
+ });
66
81
  }
67
82
  catch (err) {
68
83
  const msg = err instanceof Error ? err.message : String(err);
84
+ const stop = rateLimitFromError(err, msg);
85
+ if (stop) {
86
+ result.errors.push(`rate limit: ${stop.message}`);
87
+ result.rateLimit = stop;
88
+ return true;
89
+ }
69
90
  for (const id of resolveIds)
70
91
  result.errors.push(`${id}: ${msg}`);
71
92
  for (const id of minimizeIds)
72
93
  result.errors.push(`${id}: ${msg}`);
73
94
  for (const id of dismissIds)
74
95
  result.errors.push(`${id}: ${msg}`);
75
- return;
96
+ return false;
76
97
  }
77
98
  for (let i = 0; i < resolveIds.length; i++) {
78
99
  const r = data[`r${i}`];
79
100
  if (r?.thread?.isResolved === true)
80
101
  result.resolvedThreads.push(resolveIds[i]);
81
- else
102
+ else if (!suppressCurrentChunkErrors)
82
103
  result.errors.push(`${resolveIds[i]}: resolve returned null or thread not resolved`);
83
104
  }
84
105
  for (let i = 0; i < minimizeIds.length; i++) {
85
106
  const m = data[`m${i}`];
86
107
  if (m?.minimizedComment?.isMinimized === true)
87
108
  result.minimizedComments.push(minimizeIds[i]);
88
- else
109
+ else if (!suppressCurrentChunkErrors)
89
110
  result.errors.push(`${minimizeIds[i]}: minimize returned null or comment not minimized`);
90
111
  }
91
112
  for (let i = 0; i < dismissIds.length; i++) {
92
113
  const d = data[`d${i}`];
93
114
  if (d?.pullRequestReview != null)
94
115
  result.dismissedReviews.push(dismissIds[i]);
95
- else
116
+ else if (!suppressCurrentChunkErrors)
96
117
  result.errors.push(`${dismissIds[i]}: dismiss returned null`);
97
118
  }
98
- }
99
- async function waitForSha(pr, repo, expectedSha) {
100
- const { intervalMs: SHA_POLL_INTERVAL_MS, maxAttempts: SHA_POLL_MAX_ATTEMPTS } = loadConfig().resolve.shaPoll;
101
- for (let attempt = 0; attempt < SHA_POLL_MAX_ATTEMPTS; attempt++) {
102
- try {
103
- // eslint-disable-next-line no-await-in-loop
104
- const currentSha = await getPrHeadSha(pr, repo.owner, repo.name);
105
- if (currentSha === expectedSha)
106
- return;
107
- }
108
- catch (err) {
109
- if (attempt === SHA_POLL_MAX_ATTEMPTS - 1)
110
- throw err;
111
- }
112
- if (attempt < SHA_POLL_MAX_ATTEMPTS - 1) {
113
- // eslint-disable-next-line no-await-in-loop
114
- await sleep(SHA_POLL_INTERVAL_MS);
115
- }
119
+ if (rateLimitStop) {
120
+ result.errors.push(`rate limit: ${rateLimitStop.message}`);
121
+ result.rateLimit = rateLimitStop;
122
+ return true;
116
123
  }
117
- throw new Error(`Timeout: GitHub PR #${pr} head SHA has not updated to ${expectedSha} after ${((SHA_POLL_MAX_ATTEMPTS - 1) * SHA_POLL_INTERVAL_MS) / 1000}s. Push may still be in transit — retry shortly.`);
118
- }
119
- function sleep(ms) {
120
- return new Promise((resolve) => setTimeout(resolve, ms));
124
+ return false;
121
125
  }
@@ -0,0 +1,25 @@
1
+ import { getPrHeadSha } from "../github/client.mjs";
2
+ import { loadConfig } from "../config/load.mjs";
3
+ export async function waitForSha(pr, repo, expectedSha) {
4
+ const { intervalMs: SHA_POLL_INTERVAL_MS, maxAttempts: SHA_POLL_MAX_ATTEMPTS } = loadConfig().resolve.shaPoll;
5
+ for (let attempt = 0; attempt < SHA_POLL_MAX_ATTEMPTS; attempt++) {
6
+ try {
7
+ // eslint-disable-next-line no-await-in-loop
8
+ const currentSha = await getPrHeadSha(pr, repo.owner, repo.name);
9
+ if (currentSha === expectedSha)
10
+ return;
11
+ }
12
+ catch (err) {
13
+ if (attempt === SHA_POLL_MAX_ATTEMPTS - 1)
14
+ throw err;
15
+ }
16
+ if (attempt < SHA_POLL_MAX_ATTEMPTS - 1) {
17
+ // eslint-disable-next-line no-await-in-loop
18
+ await sleep(SHA_POLL_INTERVAL_MS);
19
+ }
20
+ }
21
+ throw new Error(`Timeout: GitHub PR #${pr} head SHA has not updated to ${expectedSha} after ${((SHA_POLL_MAX_ATTEMPTS - 1) * SHA_POLL_INTERVAL_MS) / 1000}s. Push may still be in transit — retry shortly.`);
22
+ }
23
+ function sleep(ms) {
24
+ return new Promise((resolve) => setTimeout(resolve, ms));
25
+ }
@@ -3,6 +3,7 @@ import { join, dirname } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { parse } from "yaml";
5
5
  import builtins from "../config.json" with { type: "json" };
6
+ import { parseCliRunner } from "../cli/runner.mjs";
6
7
  const RC_FILENAME = ".pr-shepherdrc.yml";
7
8
  function findRcFile(startDir) {
8
9
  const home = homedir();
@@ -51,6 +52,15 @@ export function loadConfig() {
51
52
  const raw = readFileSync(rcPath, "utf8");
52
53
  const parsed = (parse(raw) ?? {});
53
54
  const config = deepMerge(defaults, parsed);
55
+ // Validate cli and watch at load time so misconfigurations are caught once
56
+ // with the rc file path in context, rather than during instruction rendering.
57
+ if (config.cli === null || Array.isArray(config.cli) || typeof config.cli !== "object") {
58
+ throw new Error(`Invalid config: cli must be a plain object, got ${JSON.stringify(config.cli)}`);
59
+ }
60
+ config.cli.runner = parseCliRunner(config.cli.runner);
61
+ if (typeof config.watch?.interval !== "string" || !/^\d+[smhd]$/.test(config.watch.interval)) {
62
+ throw new Error(`Invalid config: watch.interval must be a duration string like "4m" or "1h", got ${JSON.stringify(config.watch?.interval)}`);
63
+ }
54
64
  configCache.set(cwd, config);
55
65
  return config;
56
66
  }
package/bin/config.json CHANGED
@@ -1,4 +1,7 @@
1
1
  {
2
+ "cli": {
3
+ "runner": "auto"
4
+ },
2
5
  "iterate": {
3
6
  "cooldownSeconds": 30,
4
7
  "fixAttemptsPerThread": 3,
@@ -7,8 +7,9 @@
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 } from "./http.mjs";
10
+ import { graphql as httpGraphql, rest, GitHubRequestError, } from "./http.mjs";
11
11
  import { PR_NUMBER_BY_BRANCH_QUERY, GET_PR_HEAD_SHA_QUERY } from "./queries.mjs";
12
+ export { GitHubRequestError };
12
13
  const execFile = promisify(execFileCb);
13
14
  // ---------------------------------------------------------------------------
14
15
  // GraphQL — thin re-exports so callers don't need to import http.mts directly
@@ -0,0 +1,12 @@
1
+ export class GitHubRequestError extends Error {
2
+ status;
3
+ rateLimit;
4
+ retryAfterSeconds;
5
+ constructor(message, opts) {
6
+ super(message);
7
+ this.name = "GitHubRequestError";
8
+ this.status = opts.status;
9
+ this.rateLimit = opts.rateLimit;
10
+ this.retryAfterSeconds = opts.retryAfterSeconds;
11
+ }
12
+ }
@@ -2,6 +2,8 @@ import { execFile as execFileCb } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import { appendEntry, nextEntry } from "../log/log-file.mjs";
4
4
  import { formatRequestEntry, formatResponseEntry } from "../log/session.mjs";
5
+ import { GitHubRequestError } from "./errors.mjs";
6
+ export { GitHubRequestError };
5
7
  const execFile = promisify(execFileCb);
6
8
  const BASE_URL = "https://api.github.com";
7
9
  // ---------------------------------------------------------------------------
@@ -105,6 +107,7 @@ async function graphqlInner(query, vars) {
105
107
  });
106
108
  const durationMs = Math.round(performance.now() - retryT0);
107
109
  const rateLimit = parseRateLimit(res.headers);
110
+ const retryAfterSeconds = parseRetryAfter(res.headers);
108
111
  if (!res.ok) {
109
112
  const body = await res.text();
110
113
  appendEntry(formatResponseEntry({
@@ -117,7 +120,7 @@ async function graphqlInner(query, vars) {
117
120
  textBody: redactToken(body),
118
121
  attempt: attempt > 1 ? attempt : undefined,
119
122
  }));
120
- throw new Error(`GitHub GraphQL request failed: ${res.status} ${sanitizeBody(body)}`);
123
+ throw new GitHubRequestError(`GitHub GraphQL request failed: ${res.status} ${sanitizeBody(body)}`, { status: res.status, rateLimit: rateLimit ?? undefined, retryAfterSeconds });
121
124
  }
122
125
  const parsed = (await res.json());
123
126
  appendEntry(formatResponseEntry({
@@ -132,21 +135,25 @@ async function graphqlInner(query, vars) {
132
135
  }));
133
136
  if (parsed.data == null) {
134
137
  const messages = (parsed.errors ?? []).map((e) => e.message).join("; ");
135
- throw new Error(`GitHub GraphQL error (no data): ${messages}`);
138
+ throw new GitHubRequestError(`GitHub GraphQL error (no data): ${messages}`, {
139
+ status: res.status,
140
+ rateLimit: rateLimit ?? undefined,
141
+ retryAfterSeconds,
142
+ });
136
143
  }
137
144
  if (parsed.errors?.length) {
138
145
  const messages = parsed.errors.map((e) => e.message).join("; ");
139
146
  process.stderr.write(`pr-shepherd: GraphQL non-fatal errors: ${messages}\n`);
140
147
  }
141
- return { data: parsed.data, rateLimit };
148
+ return { data: parsed.data, rateLimit, retryAfterSeconds, errors: parsed.errors };
142
149
  }
143
150
  export async function graphql(query, vars = {}) {
144
151
  const { data } = await graphqlInner(query, vars);
145
152
  return { data };
146
153
  }
147
154
  export async function graphqlWithRateLimit(query, vars = {}) {
148
- const { data, rateLimit } = await graphqlInner(query, vars);
149
- return { data, rateLimit: rateLimit ?? undefined };
155
+ const { data, rateLimit, retryAfterSeconds, errors } = await graphqlInner(query, vars);
156
+ return { data, rateLimit: rateLimit ?? undefined, retryAfterSeconds, errors };
150
157
  }
151
158
  // ---------------------------------------------------------------------------
152
159
  // REST
@@ -305,3 +312,8 @@ function parseRateLimit(headers) {
305
312
  }
306
313
  return null;
307
314
  }
315
+ function parseRetryAfter(headers) {
316
+ const raw = headers.get("retry-after");
317
+ const seconds = Number(raw);
318
+ return raw !== null && Number.isFinite(seconds) && seconds >= 0 ? seconds : undefined;
319
+ }
@@ -1,3 +1,4 @@
1
+ import { buildPrShepherdCommand } from "../cli/runner.mjs";
1
2
  /**
2
3
  * Build the numbered instruction steps for the agent to follow after a `check` run.
3
4
  * All rebase policy, CI budget policy, and ready-to-merge gating live here so the
@@ -59,7 +60,7 @@ export function buildCheckInstructions(report, opts) {
59
60
  // 5. Continuous monitoring pointer (suppressed only when truly ready to merge)
60
61
  if (!isReady) {
61
62
  instructions.push(runtime === "codex"
62
- ? `This is a one-shot check. For follow-up monitoring, run \`npx pr-shepherd ${report.pr}\`.`
63
+ ? `This is a one-shot check. For follow-up monitoring, run \`${buildPrShepherdCommand([String(report.pr)], { runner: opts?.runner }).text}\`.`
63
64
  : "This is a one-shot check. For continuous monitoring that acts on these signals automatically, use `/pr-shepherd:monitor`.");
64
65
  }
65
66
  return instructions;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.13.1",
3
+ "version": "0.14.1",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
5
5
  "license": "MIT",
6
6
  "author": "Jonathan Ong",
@@ -29,7 +29,7 @@
29
29
  "@types/node": "^25.6.0",
30
30
  "@vitest/coverage-v8": "^4.1.4",
31
31
  "husky": "^9.1.7",
32
- "oxfmt": "^0.46.0",
32
+ "oxfmt": "^0.47.0",
33
33
  "oxlint": "^1.60.0",
34
34
  "typescript": "^6.0.3",
35
35
  "vitest": "^4.1.4"
@@ -25,9 +25,11 @@ allowed-tools: ["Bash"]
25
25
  If `MERGED`, output: `PR #N is already merged. Nothing to check.` and skip.
26
26
 
27
27
  3. **Run the check and follow instructions:**
28
+ Use the repository package runner selected by `packageManager` or lockfile
29
+ (for example, `pnpm exec`, `yarn run`, or `npx --no-install`).
28
30
 
29
31
  ```bash
30
- npx pr-shepherd check <N>
32
+ <runner> pr-shepherd check <N>
31
33
  ```
32
34
 
33
35
  Print the full output. Follow the `## Instructions` section exactly.
@@ -4,7 +4,18 @@ description: "Start continuous CI monitoring — marks PR ready for review when
4
4
  argument-hint: "[PR number or URL]"
5
5
  user-invocable: true
6
6
  allowed-tools:
7
- ["Bash", "Read", "Grep", "Edit", "Write", "Glob", "Skill", "CronCreate", "CronList", "CronDelete"]
7
+ [
8
+ "Bash",
9
+ "Read",
10
+ "Grep",
11
+ "Edit",
12
+ "Write",
13
+ "Glob",
14
+ "Skill",
15
+ "ScheduleWakeup",
16
+ "CronList",
17
+ "CronDelete",
18
+ ]
8
19
  ---
9
20
 
10
21
  # pr-shepherd monitor — Continuous PR Monitor
@@ -19,9 +30,11 @@ allowed-tools:
19
30
  - If no PR found, report an error and stop.
20
31
 
21
32
  2. **Run the bootstrap command and follow its instructions:**
33
+ Use the repository package runner selected by `packageManager` or lockfile
34
+ (for example, `pnpm exec`, `yarn run`, or `npx --no-install`).
22
35
 
23
36
  ```bash
24
- npx pr-shepherd monitor <PR_NUMBER>
37
+ <runner> pr-shepherd monitor <PR_NUMBER>
25
38
  ```
26
39
 
27
40
  Print the full output. Follow the `## Instructions` section exactly.
@@ -28,9 +28,11 @@ Resolve unresolved review threads and minimize PR comments on the current PR —
28
28
  If `MERGED`, invoke `/loop cancel` via the Skill tool, output a merged message, and stop.
29
29
 
30
30
  3. **Fetch and follow instructions:**
31
+ Use the repository package runner selected by `packageManager` or lockfile
32
+ (for example, `pnpm exec`, `yarn run`, or `npx --no-install`).
31
33
 
32
34
  ```bash
33
- npx pr-shepherd resolve <N> --fetch
35
+ <runner> pr-shepherd resolve <N> --fetch
34
36
  ```
35
37
 
36
38
  Print the full output. Follow the `## Instructions` section exactly.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.13.1",
3
+ "version": "0.14.1",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",