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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/cli/list-formatters.mjs +10 -4
- package/bin/commands/iterate/check-instructions.mjs +10 -0
- package/bin/commands/iterate/render.mjs +2 -4
- package/bin/commands/poll.mjs +1 -3
- package/bin/comments/sha-poll.mjs +1 -3
- package/bin/github/batch-parser-helpers.mjs +13 -0
- package/bin/github/batch-parsers.mjs +13 -3
- package/bin/github/gql/batch-pr.gql +3 -0
- package/bin/github/http-request.mjs +23 -2
- package/bin/github/http-utils.mjs +22 -0
- package/bin/util/sleep.mjs +3 -0
- package/package.json +1 -1
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +1 -1
|
@@ -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
|
-
|
|
98
|
-
|
|
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
|
|
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
|
|
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\`.`);
|
package/bin/commands/poll.mjs
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { runIterate } from "./iterate/index.mjs";
|
|
2
|
-
|
|
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
|
-
|
|
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) =>
|
|
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() !== "")
|
|
@@ -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
|
|
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
|
}
|
package/package.json
CHANGED
|
@@ -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
|
|