pr-shepherd 0.27.0 → 0.28.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.
@@ -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.27.0",
4
+ "version": "0.28.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
@@ -94,11 +94,17 @@ export function renderEditedCommentTag(c) {
94
94
  return c.edited ? "[edited since first look]" : undefined;
95
95
  }
96
96
  export function renderReviewBullet(r, opts = {}) {
97
- if (r.staleBotCr) {
98
- return `- \`reviewId=${r.id}\` (${renderAuthor(r.author, r.authorType)}) [pending dismissal — already surfaced; include in \`--dismiss-review-ids\`]`;
99
- }
97
+ const base = `- \`reviewId=${r.id}\` (${renderAuthor(r.author, r.authorType)})`;
98
+ const staleTag = r.staleReview
99
+ ? " [stale — review is on an old commit, all threads resolved]"
100
+ : "";
101
+ if (r.staleBotCr)
102
+ return `${base}${staleTag} [pending dismissal — already surfaced; include in \`--dismiss-review-ids\`]`;
103
+ const humanStaleTag = r.staleReview
104
+ ? " [stale — review is on an old commit, all threads resolved; ask reviewer to re-review or dismiss]"
105
+ : "";
100
106
  const bodySuffix = opts.includeBody && r.body != null && r.body !== "" ? `: ${renderBodyPreview(r.body)}` : "";
101
- return `- \`reviewId=${r.id}\` (${renderAuthor(r.author, r.authorType)})${bodySuffix}`;
107
+ return `${base}${humanStaleTag}${bodySuffix}`;
102
108
  }
103
109
  export function renderReviewListSection(heading, items) {
104
110
  if (items.length === 0)
@@ -1,3 +1,13 @@
1
+ /** Build the stale-CR clause appended to the `## Changes-requested reviews` instruction. */
2
+ export function buildCrStaleClause(reviews) {
3
+ const bot = reviews.some((r) => r.staleBotCr)
4
+ ? " `[pending dismissal — already surfaced]` bullets are bot CRs from a prior tick."
5
+ : "";
6
+ const human = reviews.some((r) => r.staleReview && !r.staleBotCr)
7
+ ? " `[stale]` bullets are human CRs on an old commit; ask reviewer to re-review."
8
+ : "";
9
+ return bot + human;
10
+ }
1
11
  export function buildFailingCheckInstructions(checks) {
2
12
  if (checks.length === 0)
3
13
  return [];
@@ -1,5 +1,5 @@
1
1
  import { renderShellCommand } from "../../cli/runner.mjs";
2
- import { buildFailingCheckInstructions } from "./check-instructions.mjs";
2
+ import { buildFailingCheckInstructions, buildCrStaleClause } from "./check-instructions.mjs";
3
3
  import { SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS, buildShepherdJournalInstruction, } from "../shepherd-journal.mjs";
4
4
  import { buildCommitSuggestionInstruction } from "../commit-suggestion-instruction.mjs";
5
5
  const FIX_INSTRUCTION_STOP = "Stop this iteration — if you pushed new commits, CI needs time before the next tick; otherwise stop before the next tick.";
@@ -72,9 +72,7 @@ resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], f
72
72
  instructions.push(`For each item under \`## Check annotations\`: inspect the referenced file range and decide whether the annotation requires a code change. These annotations are surfaced once per PR and do not need any resolve/minimize mutation.`);
73
73
  }
74
74
  if (changesRequestedReviews.length > 0) {
75
- const staleClause = changesRequestedReviews.some((r) => r.staleBotCr)
76
- ? " Bullets tagged `[pending dismissal — already surfaced]` are bot CR reviews you saw on a previous tick; the CLI hides re-surfaced bodies to keep output lean — re-read the prior tick if you need the body."
77
- : "";
75
+ const staleClause = buildCrStaleClause(changesRequestedReviews);
78
76
  instructions.push(`For each bullet under \`## Changes-requested reviews\` above: read the review body and apply the requested changes.${staleClause}`);
79
77
  if ((resolveCommand.dismissReviewIds?.length ?? 0) > 0)
80
78
  instructions.push(`Pass every ID listed in \`--dismiss-review-ids\` to the \`resolve:\` command verbatim — these are bot/non-human CR reviews that the agent (not the author) must dismiss. Dropping an ID leaves the PR in \`CHANGES_REQUESTED\` state; the next tick re-surfaces it as \`[pending dismissal]\` and an unattended bot CR escalates after \`iterate.stallTimeoutMinutes\`.`);
@@ -1,7 +1,5 @@
1
1
  import { runIterate } from "./iterate/index.mjs";
2
- function sleep(ms) {
3
- return new Promise((resolve) => setTimeout(resolve, ms));
4
- }
2
+ import { sleep } from "../util/sleep.mjs";
5
3
  function writeTickProgress(tick, elapsedSeconds, sleepSeconds, verbose) {
6
4
  if (verbose) {
7
5
  process.stderr.write(`[poll tick ${tick} / +${elapsedSeconds}s] WAIT — sleeping ${sleepSeconds}s\n`);
@@ -1,5 +1,6 @@
1
1
  import { getPrHeadSha } from "../github/client.mjs";
2
2
  import { loadConfig } from "../config/load.mjs";
3
+ import { sleep } from "../util/sleep.mjs";
3
4
  export async function waitForSha(pr, repo, expectedSha) {
4
5
  const { intervalMs: SHA_POLL_INTERVAL_MS, maxAttempts: SHA_POLL_MAX_ATTEMPTS } = loadConfig().resolve.shaPoll;
5
6
  for (let attempt = 0; attempt < SHA_POLL_MAX_ATTEMPTS; attempt++) {
@@ -20,6 +21,3 @@ export async function waitForSha(pr, repo, expectedSha) {
20
21
  }
21
22
  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
  }
23
- function sleep(ms) {
24
- return new Promise((resolve) => setTimeout(resolve, ms));
25
- }
@@ -27,6 +27,19 @@ export function latestApprovedLogins(latest) {
27
27
  .filter((r) => r.login !== "unknown" && (r.state === "APPROVED" || r.state === "DISMISSED"))
28
28
  .map((r) => r.login));
29
29
  }
30
+ /**
31
+ * A CR review is stale when its commit.oid differs from the PR head AND every
32
+ * associated review thread is resolved or outdated. Reviews with no associated
33
+ * threads are treated conservatively (not marked stale).
34
+ */
35
+ export function isReviewStale(review, headRefOid, reviewThreads) {
36
+ if (!review.commitOid || review.commitOid === headRefOid)
37
+ return false;
38
+ const associated = reviewThreads.filter((t) => t.reviewId === review.id);
39
+ if (associated.length === 0)
40
+ return false;
41
+ return associated.every((t) => t.isResolved || t.isOutdated);
42
+ }
30
43
  export function mapStatusContextState(state) {
31
44
  switch (state) {
32
45
  case "SUCCESS":
@@ -1,14 +1,18 @@
1
- import { mapAuthorType, parseCreatedAt, extractRunId, extractCheckRunSummary, mapStatusContextState, latestApprovedLogins, } from "./batch-parser-helpers.mjs";
1
+ import { mapAuthorType, parseCreatedAt, extractRunId, extractCheckRunSummary, mapStatusContextState, latestApprovedLogins, isReviewStale, } from "./batch-parser-helpers.mjs";
2
2
  import { buildPrActivitySummary } from "./activity.mjs";
3
3
  import { parseBranchProtection } from "./branch-protection.mjs";
4
4
  function parseReviewNode(r) {
5
- return {
5
+ const base = {
6
6
  id: r.id,
7
7
  author: r.author?.login ?? "unknown",
8
8
  authorType: mapAuthorType(r.author?.__typename, r.author?.login),
9
9
  body: r.body,
10
10
  createdAtUnix: r.createdAt ? parseCreatedAt(r.createdAt) : 0,
11
11
  };
12
+ if ("commit" in r && r.commit?.oid) {
13
+ base.commitOid = r.commit.oid;
14
+ }
15
+ return base;
12
16
  }
13
17
  export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawReviewSummaryNodes, rawApprovedReviewNodes, rawCheckNodes) {
14
18
  const reviewRequests = (raw.reviewRequests?.nodes ?? []).flatMap((n) => {
@@ -58,7 +62,13 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
58
62
  url: c.url,
59
63
  createdAtUnix: c.createdAt ? parseCreatedAt(c.createdAt) : 0,
60
64
  }));
61
- const allChangesRequestedReviews = rawReviewNodes.map((r) => parseReviewNode(r));
65
+ const allChangesRequestedReviews = rawReviewNodes.map((r) => {
66
+ const review = parseReviewNode(r);
67
+ if (isReviewStale(review, raw.headRefOid, reviewThreads)) {
68
+ review.staleReview = true;
69
+ }
70
+ return review;
71
+ });
62
72
  const changesRequestedReviews = allChangesRequestedReviews.filter((r) => !crDone.has(r.author));
63
73
  const reviewSummaries = rawReviewSummaryNodes
64
74
  .filter((r) => !r.isMinimized && r.body.trim() !== "")
@@ -132,6 +132,9 @@ query BatchPr(
132
132
  }
133
133
  body
134
134
  createdAt
135
+ commit {
136
+ oid
137
+ }
135
138
  }
136
139
  }
137
140
  reviewSummaries: reviews(states: COMMENTED, last: 50, before: $reviewSummariesCursor) {
@@ -1,6 +1,27 @@
1
1
  import { clearTokenCache, hasCachedToken } from "./http-auth.mjs";
2
+ import { isTransportError } from "./http-utils.mjs";
3
+ import { sleep } from "../util/sleep.mjs";
4
+ const TRANSPORT_RETRY_DELAYS = [250, 500];
5
+ async function fetchWithTransportRetry(fn) {
6
+ let lastErr;
7
+ for (let attempt = 1; attempt <= TRANSPORT_RETRY_DELAYS.length + 1; attempt++) {
8
+ try {
9
+ return await fn();
10
+ }
11
+ catch (err) {
12
+ if (!isTransportError(err))
13
+ throw err;
14
+ lastErr = err;
15
+ const delay = TRANSPORT_RETRY_DELAYS[attempt - 1];
16
+ if (delay === undefined)
17
+ break;
18
+ await sleep(delay);
19
+ }
20
+ }
21
+ throw lastErr;
22
+ }
2
23
  export async function requestWithTokenRetry(fn, t0, onIntermediate) {
3
- const res = await fn();
24
+ const res = await fetchWithTransportRetry(fn);
4
25
  if (res.status === 401 && hasCachedToken()) {
5
26
  onIntermediate?.(401, Math.round(performance.now() - t0));
6
27
  try {
@@ -9,7 +30,7 @@ export async function requestWithTokenRetry(fn, t0, onIntermediate) {
9
30
  catch { }
10
31
  clearTokenCache();
11
32
  const retryT0 = performance.now();
12
- return { res: await fn(), attempt: 2, retryT0 };
33
+ return { res: await fetchWithTransportRetry(fn), attempt: 2, retryT0 };
13
34
  }
14
35
  return { res, attempt: 1, retryT0: t0 };
15
36
  }
@@ -1,3 +1,25 @@
1
+ const TRANSPORT_ERROR_CODES = new Set([
2
+ "ECONNRESET",
3
+ "ETIMEDOUT",
4
+ "ECONNREFUSED",
5
+ "EAI_AGAIN",
6
+ "UND_ERR_CONNECT_TIMEOUT",
7
+ "UND_ERR_SOCKET",
8
+ ]);
9
+ export function isTransportError(err) {
10
+ if (!(err instanceof Error))
11
+ return false;
12
+ const code = err.code;
13
+ if (code && TRANSPORT_ERROR_CODES.has(code))
14
+ return true;
15
+ const cause = err.cause;
16
+ if (cause instanceof Error) {
17
+ const causeCode = cause.code;
18
+ if (causeCode && TRANSPORT_ERROR_CODES.has(causeCode))
19
+ return true;
20
+ }
21
+ return false;
22
+ }
1
23
  export function sanitizeBody(body) {
2
24
  return body.replace(/Bearer\s+\S+/gi, "[REDACTED]").slice(0, 200);
3
25
  }
@@ -0,0 +1,3 @@
1
+ export function sleep(ms) {
2
+ return new Promise((resolve) => setTimeout(resolve, ms));
3
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.27.0",
3
+ "version": "0.28.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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -16,7 +16,7 @@ Poll dispatcher for iterating a PR to completion.
16
16
 
17
17
  1. **Resolve the PR number** (`$N`): use the number or URL in `$ARGUMENTS`; otherwise infer it with `gh pr view --json number --jq .number`. If none is found, report an error and stop.
18
18
 
19
- 2. **Define the poll command once:** `pr-shepherd $N --interval 60s --timeout 4.5m`. Do not forward `$ARGUMENTS` as extra flags. Run `pr-shepherd --help` to inspect supported options.
19
+ 2. **Define the poll command once:** `pr-shepherd $N --interval 60s --timeout 4.5m --quiet-status`. Do not forward `$ARGUMENTS` as extra flags. Run `pr-shepherd --help` to inspect supported options.
20
20
 
21
21
  3. **Loop:** Run the poll, print its full output, and follow its `## Instructions` section exactly. Then run the poll again. Repeat until the CLI emits `[CANCEL]` or `[ESCALATE]`, unless the human directs you to stop. Every other action (`[WAIT]`, `[MARK_READY]`, `[FIX_CODE]`) is non-terminal: do its instructions, then poll again. The poll already bounds each wait via `--interval`/`--timeout`; do not add manual `sleep`s between ticks.
22
22