pr-shepherd 0.14.0 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +9 -3
- package/bin/cli/formatters.mjs +27 -2
- package/bin/comments/pending-ops.mjs +20 -0
- package/bin/comments/rate-limit.mjs +45 -0
- package/bin/comments/resolve.mjs +38 -34
- package/bin/comments/sha-poll.mjs +25 -0
- package/bin/github/client.mjs +2 -1
- package/bin/github/errors.mjs +12 -0
- package/bin/github/http.mjs +17 -5
- package/package.json +1 -1
- package/plugin/skills/check/SKILL.md +3 -1
- package/plugin/skills/monitor/SKILL.md +3 -1
- package/plugin/skills/resolve/SKILL.md +3 -1
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +22 -17
package/README.md
CHANGED
|
@@ -198,6 +198,8 @@ On each dynamic tick: fetch PR state in one GraphQL batch → classify CI, comme
|
|
|
198
198
|
> **Note:** Skill and plugin install methods add the skill definitions only — they do not install the `pr-shepherd` CLI. The skills invoke `pr-shepherd` through the repo package runner, so you also need the CLI available. If you're using `pr-shepherd` as development tooling for your repo, install it as a dev dependency so the selected runner resolves it without prompting:
|
|
199
199
|
>
|
|
200
200
|
> ```bash
|
|
201
|
+
> pnpm add -D pr-shepherd # pnpm repos
|
|
202
|
+
> yarn add -D pr-shepherd # yarn repos
|
|
201
203
|
> npm install --save-dev pr-shepherd
|
|
202
204
|
> ```
|
|
203
205
|
>
|
|
@@ -250,6 +252,8 @@ After adding the marketplace, open the Codex plugin directory, choose the `jonat
|
|
|
250
252
|
Install the CLI where Codex will run it:
|
|
251
253
|
|
|
252
254
|
```bash
|
|
255
|
+
pnpm add -D pr-shepherd # pnpm repos
|
|
256
|
+
yarn add -D pr-shepherd # yarn repos
|
|
253
257
|
npm install --save-dev pr-shepherd
|
|
254
258
|
```
|
|
255
259
|
|
|
@@ -261,16 +265,18 @@ If your Codex environment does not already set `CODEX_CI=1`, set `AGENT=codex` s
|
|
|
261
265
|
export AGENT=codex
|
|
262
266
|
```
|
|
263
267
|
|
|
264
|
-
Then start a PR monitor from Codex:
|
|
268
|
+
Then start a PR monitor from Codex with the target repository's package runner:
|
|
265
269
|
|
|
266
270
|
```bash
|
|
267
|
-
|
|
271
|
+
<runner> pr-shepherd monitor 42
|
|
268
272
|
```
|
|
269
273
|
|
|
274
|
+
For example, a repo like `~/filaments` that declares `packageManager: "pnpm@..."` and has `pnpm-lock.yaml` should use `pnpm exec pr-shepherd monitor 42`. For npm repos, use `npx --no-install pr-shepherd monitor 42`.
|
|
275
|
+
|
|
270
276
|
Or ask Codex to use the `pr-shepherd` skill, for example: `run pr-shepherd until this PR is ready`. Follow the output's `## Instructions`. The monitor bootstrap runs one tick and prints the reusable follow-up command, usually:
|
|
271
277
|
|
|
272
278
|
```bash
|
|
273
|
-
|
|
279
|
+
<runner> pr-shepherd 42
|
|
274
280
|
```
|
|
275
281
|
|
|
276
282
|
For an active Codex goal, rerun that command every `watch.interval` (default 4m) until Shepherd emits `[CANCEL]` for ready-delay completion or merged/closed, or `[ESCALATE]` (including `stall-timeout` for repeated unchanged CI failures). `pr-shepherd iterate 42` remains supported for existing workflows. There is no background `/loop` scheduler in Codex.
|
package/bin/cli/formatters.mjs
CHANGED
|
@@ -108,7 +108,32 @@ export function formatMutateResult(result) {
|
|
|
108
108
|
lines.push(`Minimized comments (${result.minimizedComments.length}): ${result.minimizedComments.join(", ")}`);
|
|
109
109
|
if (result.dismissedReviews.length)
|
|
110
110
|
lines.push(`Dismissed reviews (${result.dismissedReviews.length}): ${result.dismissedReviews.join(", ")}`);
|
|
111
|
-
if (result.
|
|
112
|
-
|
|
111
|
+
if (result.rateLimit) {
|
|
112
|
+
const details = [
|
|
113
|
+
result.rateLimit.retryAfterSeconds !== undefined
|
|
114
|
+
? `retry after ${result.rateLimit.retryAfterSeconds}s`
|
|
115
|
+
: null,
|
|
116
|
+
result.rateLimit.remaining !== undefined && result.rateLimit.limit !== undefined
|
|
117
|
+
? `remaining ${result.rateLimit.remaining}/${result.rateLimit.limit}`
|
|
118
|
+
: null,
|
|
119
|
+
result.rateLimit.resetAt !== undefined
|
|
120
|
+
? `reset at ${new Date(result.rateLimit.resetAt * 1000).toISOString()}`
|
|
121
|
+
: null,
|
|
122
|
+
]
|
|
123
|
+
.filter(Boolean)
|
|
124
|
+
.join(", ");
|
|
125
|
+
lines.push(`Stopped: GitHub rate limit hit — ${result.rateLimit.message}${details ? ` (${details})` : ""}`);
|
|
126
|
+
}
|
|
127
|
+
if (result.unresolvedThreads?.length)
|
|
128
|
+
lines.push(`Not resolved due to rate limit (${result.unresolvedThreads.length}): ${result.unresolvedThreads.join(", ")}`);
|
|
129
|
+
if (result.unminimizedComments?.length)
|
|
130
|
+
lines.push(`Not minimized due to rate limit (${result.unminimizedComments.length}): ${result.unminimizedComments.join(", ")}`);
|
|
131
|
+
if (result.undismissedReviews?.length)
|
|
132
|
+
lines.push(`Not dismissed due to rate limit (${result.undismissedReviews.length}): ${result.undismissedReviews.join(", ")}`);
|
|
133
|
+
const errors = result.rateLimit
|
|
134
|
+
? result.errors.filter((e) => !e.startsWith("rate limit:"))
|
|
135
|
+
: result.errors;
|
|
136
|
+
if (errors.length)
|
|
137
|
+
lines.push(`Errors:\n ${errors.join("\n ")}`);
|
|
113
138
|
return lines.join("\n");
|
|
114
139
|
}
|
|
@@ -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
|
+
}
|
package/bin/comments/resolve.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
-
//
|
|
32
|
-
const BULK_CHUNK_SIZE =
|
|
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
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/bin/github/client.mjs
CHANGED
|
@@ -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
|
+
}
|
package/bin/github/http.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
32
|
+
<runner> pr-shepherd check <N>
|
|
31
33
|
```
|
|
32
34
|
|
|
33
35
|
Print the full output. Follow the `## Instructions` section exactly.
|
|
@@ -30,9 +30,11 @@ allowed-tools:
|
|
|
30
30
|
- If no PR found, report an error and stop.
|
|
31
31
|
|
|
32
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`).
|
|
33
35
|
|
|
34
36
|
```bash
|
|
35
|
-
|
|
37
|
+
<runner> pr-shepherd monitor <PR_NUMBER>
|
|
36
38
|
```
|
|
37
39
|
|
|
38
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
|
-
|
|
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
|
-
description: 'Codex-only skill for checking, updating, monitoring, or resolving a GitHub pull request with pr-shepherd. Use for requests like "check this PR", "use pr-shepherd", "iterate PR #123", "resolve this PR''s comments", or "run pr-shepherd until this PR is ready". For open-ended requests, create a Codex goal and run explicit
|
|
3
|
+
description: 'Codex-only skill for checking, updating, monitoring, or resolving a GitHub pull request with pr-shepherd. Use for requests like "check this PR", "use pr-shepherd", "iterate PR #123", "resolve this PR''s comments", or "run pr-shepherd until this PR is ready". For open-ended requests, create a Codex goal and run explicit pr-shepherd cycles through the target repo package runner, picking a fresh sleep/timeout between 1 and 4 minutes before each rerun until Shepherd emits `[CANCEL]` for ready-delay completion or merged/closed, or `[ESCALATE]` including repeated unchanged CI failures.'
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# pr-shepherd
|
|
@@ -19,32 +19,37 @@ Codex-only workflow for getting actionable PR updates from `pr-shepherd`.
|
|
|
19
19
|
2. Decide whether this is one cycle or an open-ended goal.
|
|
20
20
|
- For one-off requests such as "check this PR", "run pr-shepherd once", or "resolve this PR's comments", run one explicit CLI command.
|
|
21
21
|
- For requests such as "continue", "until ready", "until this PR is ready", or "keep iterating", create a Codex goal before the first recurring cycle with this objective:
|
|
22
|
-
`Run
|
|
22
|
+
`Run pr-shepherd PR_NUMBER cycles through the target repo package runner, picking a fresh sleep/timeout between 1 and 4 minutes before each rerun, until Shepherd emits [CANCEL] for ready-delay completion or PR #PR_NUMBER is merged/closed, or pr-shepherd escalates, including repeated unchanged CI failures.`
|
|
23
23
|
|
|
24
|
-
3.
|
|
25
|
-
-
|
|
24
|
+
3. Select the package runner from the target repository root.
|
|
25
|
+
- Prefer `package.json` `packageManager`: `pnpm@...` -> `pnpm exec`, `yarn@...` -> `yarn run`, `npm@...` -> `npx --no-install`.
|
|
26
|
+
- If `packageManager` is absent, use lockfiles: `pnpm-lock.yaml` -> `pnpm exec`, `yarn.lock` -> `yarn run`, `package-lock.json` or no signal -> `npx --no-install`.
|
|
27
|
+
- Example: in `~/filaments`, use `pnpm exec pr-shepherd ...` because the root package declares `packageManager: "pnpm@..."` and has `pnpm-lock.yaml`.
|
|
28
|
+
|
|
29
|
+
4. Verify the CLI is available.
|
|
30
|
+
- Only when the target repository itself is the pr-shepherd source checkout, verify `bin/` and `node_modules/` exist before any local CLI invocation. If either is missing, run the source checkout's package-manager install command. This repository currently uses npm, so run:
|
|
26
31
|
`npm install`
|
|
27
|
-
- In other repositories,
|
|
32
|
+
- In other repositories, run through the selected package runner so Codex does not install packages implicitly. If the package is missing, tell the user to install `pr-shepherd` with the matching dev-dependency command: `pnpm add -D pr-shepherd`, `yarn add -D pr-shepherd`, or `npm install --save-dev pr-shepherd`.
|
|
28
33
|
|
|
29
|
-
|
|
34
|
+
5. Run the appropriate command from the repository root.
|
|
30
35
|
- For a status check:
|
|
31
|
-
|
|
36
|
+
`<runner> pr-shepherd check PR_NUMBER`
|
|
32
37
|
- For review comment resolution:
|
|
33
|
-
|
|
38
|
+
`<runner> pr-shepherd resolve PR_NUMBER --fetch`
|
|
34
39
|
- For a monitor bootstrap:
|
|
35
|
-
|
|
40
|
+
`<runner> pr-shepherd monitor PR_NUMBER`
|
|
36
41
|
- For the recurring explicit monitor tick:
|
|
37
|
-
|
|
38
|
-
- `
|
|
42
|
+
`<runner> pr-shepherd PR_NUMBER`
|
|
43
|
+
- `pr-shepherd iterate PR_NUMBER` remains supported as a legacy alias, but use the default `pr-shepherd PR_NUMBER` form for recurring Codex cycles.
|
|
39
44
|
|
|
40
|
-
|
|
45
|
+
6. Print or summarize the important status, then follow the output's `## Instructions` exactly.
|
|
41
46
|
|
|
42
|
-
|
|
47
|
+
7. Do not call `/loop`, `ScheduleWakeup`, `CronCreate`, or `pr-shepherd monitor` for recurrence. Codex does explicit `pr-shepherd PR_NUMBER` cycles.
|
|
43
48
|
|
|
44
|
-
|
|
49
|
+
8. For open-ended goal requests, complete the CLI-provided instructions for the current cycle. If the output says to continue the active Codex goal, pick a fresh sleep/timeout between 1 and 4 minutes, wait that long, and run another explicit `pr-shepherd PR_NUMBER` cycle through the same runner.
|
|
45
50
|
|
|
46
|
-
|
|
51
|
+
9. Do not stop an open-ended goal only because the output is `[WAIT]`, `[COOLDOWN]`, `[MARK_READY]`, or a post-fix CI wait. These are nonterminal Codex recurrence states.
|
|
47
52
|
|
|
48
|
-
|
|
53
|
+
10. Stop only when Shepherd emits `[CANCEL]` for ready-delay completion or merged/closed, or when it emits `[ESCALATE]`, including `stall-timeout` for repeated unchanged CI failures. If a Codex goal is active, mark it complete only when one of those terminal conditions is actually satisfied.
|
|
49
54
|
|
|
50
|
-
|
|
55
|
+
11. If the output includes fixes, pushes, rebases, or resolve commands, perform only the instructed scoped actions. Do not resolve, minimize, or dismiss comments until the CLI-provided post-push and `--require-sha` instructions are satisfied.
|