pr-shepherd 0.27.0 → 0.29.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/README.md +3 -1
- package/bin/classify/loader.mjs +0 -19
- 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/index.mjs +0 -0
- package/bin/util/sleep.mjs +3 -0
- package/package.json +3 -4
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +1 -1
- package/bin/pr-shepherd +0 -2
package/README.md
CHANGED
|
@@ -210,11 +210,13 @@ export default rule;
|
|
|
210
210
|
|
|
211
211
|
`suppress: true` hides the item from agent output. `autoResolve: true` queues it for the minimize/resolve mutation. Both can apply together.
|
|
212
212
|
|
|
213
|
+
TypeScript rules are loaded by the runtime's native TypeScript support; keep them to erasable syntax such as type annotations and `import type`. Runtime TypeScript features that need transpilation, such as enums, namespaces, parameter properties, and decorators, are not supported. Use `.mts` for portable ESM rules across Node, Bun, and Deno.
|
|
214
|
+
|
|
213
215
|
Ready-to-use examples for common patterns are in [`examples/classification/`](examples/classification/).
|
|
214
216
|
|
|
215
217
|
## Requirements
|
|
216
218
|
|
|
217
|
-
- Node.js >= 22.
|
|
219
|
+
- Node.js >= 22.18.0, Bun, or Deno
|
|
218
220
|
- A GitHub token or authenticated `gh` CLI; private repositories require `repo` scope.
|
|
219
221
|
- `git`
|
|
220
222
|
|
package/bin/classify/loader.mjs
CHANGED
|
@@ -32,22 +32,6 @@ function collectRuleFiles(dir) {
|
|
|
32
32
|
.map((name) => join(dir, name))
|
|
33
33
|
.sort();
|
|
34
34
|
}
|
|
35
|
-
let tsxAttempted = false;
|
|
36
|
-
async function ensureTsxRegistered() {
|
|
37
|
-
if (tsxAttempted)
|
|
38
|
-
return;
|
|
39
|
-
tsxAttempted = true;
|
|
40
|
-
try {
|
|
41
|
-
const { register } = await import("tsx/esm/api");
|
|
42
|
-
register();
|
|
43
|
-
/* c8 ignore start */
|
|
44
|
-
}
|
|
45
|
-
catch (err) {
|
|
46
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
47
|
-
process.stderr.write(`pr-shepherd: failed to register tsx — .ts/.mts classification rules will not load: ${msg}\n`);
|
|
48
|
-
}
|
|
49
|
-
/* c8 ignore stop */
|
|
50
|
-
}
|
|
51
35
|
const ruleCache = new Map();
|
|
52
36
|
export async function loadRules(files) {
|
|
53
37
|
if (files.length === 0)
|
|
@@ -56,9 +40,6 @@ export async function loadRules(files) {
|
|
|
56
40
|
const cached = ruleCache.get(cacheKey);
|
|
57
41
|
if (cached !== undefined)
|
|
58
42
|
return cached;
|
|
59
|
-
const hasTs = files.some((f) => f.endsWith(".ts") || f.endsWith(".mts"));
|
|
60
|
-
if (hasTs)
|
|
61
|
-
await ensureTsxRegistered();
|
|
62
43
|
const rules = [];
|
|
63
44
|
for (const file of files) {
|
|
64
45
|
try {
|
|
@@ -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/bin/index.mjs
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pr-shepherd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.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",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"bin": {
|
|
9
|
-
"pr-shepherd": "bin/
|
|
9
|
+
"pr-shepherd": "bin/index.mjs"
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"bin/**",
|
|
@@ -20,11 +20,10 @@
|
|
|
20
20
|
"LICENSE"
|
|
21
21
|
],
|
|
22
22
|
"engines": {
|
|
23
|
-
"node": ">=22.
|
|
23
|
+
"node": ">=22.18.0"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"picomatch": "^4.0.4",
|
|
27
|
-
"tsx": "^4.22.4",
|
|
28
27
|
"yaml": "^2.7.0"
|
|
29
28
|
},
|
|
30
29
|
"exports": {
|
|
@@ -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
|
|
package/bin/pr-shepherd
DELETED