pr-shepherd 0.3.0 → 0.4.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/marketplace.json +18 -0
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +2 -0
- package/{dist → bin}/cache/file-cache.mjs +2 -1
- package/{dist → bin}/cli/args.mjs +56 -36
- package/{dist → bin}/cli.mjs +15 -14
- package/{dist → bin}/commands/iterate.mjs +6 -0
- package/{dist → bin}/commands/resolve.mjs +5 -5
- package/{dist → bin}/comments/resolve.mjs +2 -4
- package/{dist → bin}/config/load.mjs +19 -15
- package/{dist → bin}/github/client.mjs +0 -1
- package/bin/pr-shepherd +2 -0
- package/package.json +7 -7
- package/skills/check/SKILL.md +11 -13
- package/skills/monitor/SKILL.md +1 -1
- /package/{dist → bin}/cache/fix-attempts.mjs +0 -0
- /package/{dist → bin}/checks/classify.mjs +0 -0
- /package/{dist → bin}/checks/triage.mjs +0 -0
- /package/{dist → bin}/commands/check.mjs +0 -0
- /package/{dist → bin}/commands/ready-delay.mjs +0 -0
- /package/{dist → bin}/commands/status.mjs +0 -0
- /package/{dist → bin}/comments/outdated.mjs +0 -0
- /package/{dist → bin}/config.json +0 -0
- /package/{dist → bin}/github/batch.mjs +0 -0
- /package/{dist → bin}/github/gql/batch-pr.gql +0 -0
- /package/{dist → bin}/github/gql/dismiss-review.gql +0 -0
- /package/{dist → bin}/github/gql/minimize-comment.gql +0 -0
- /package/{dist → bin}/github/gql/multi-pr-status-paged.gql +0 -0
- /package/{dist → bin}/github/gql/multi-pr-status.gql +0 -0
- /package/{dist → bin}/github/gql/resolve-thread.gql +0 -0
- /package/{dist → bin}/github/pagination.mjs +0 -0
- /package/{dist → bin}/github/queries.mjs +0 -0
- /package/{dist → bin}/index.mjs +0 -0
- /package/{dist → bin}/merge-status/derive.mjs +0 -0
- /package/{dist → bin}/reporters/agent.mjs +0 -0
- /package/{dist → bin}/reporters/json.mjs +0 -0
- /package/{dist → bin}/reporters/text.mjs +0 -0
- /package/{dist → bin}/types.mjs +0 -0
- /package/{dist → bin}/util/path-segment.mjs +0 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
|
3
|
+
"name": "jonathanong",
|
|
4
|
+
"description": "Jonathan Ong's Claude Code plugins",
|
|
5
|
+
"owner": {
|
|
6
|
+
"name": "Jonathan Ong",
|
|
7
|
+
"email": "jonathanrichardong@gmail.com"
|
|
8
|
+
},
|
|
9
|
+
"plugins": [
|
|
10
|
+
{
|
|
11
|
+
"name": "pr-shepherd",
|
|
12
|
+
"description": "Autonomous PR CI monitor and review-comment resolver",
|
|
13
|
+
"source": "./.claude-plugin",
|
|
14
|
+
"category": "productivity",
|
|
15
|
+
"homepage": "https://github.com/jonathanong/pr-shepherd"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
package/README.md
CHANGED
|
@@ -28,6 +28,8 @@ claude /plugin marketplace add jonathanong/pr-shepherd
|
|
|
28
28
|
claude /plugin install pr-shepherd
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
+
This repo ships two `marketplace.json` files that serve different install flows: the root `marketplace.json` resolves the plugin from the npm registry (used by the `claude /plugin marketplace add` command above); `.claude-plugin/marketplace.json` is the owner-level registry manifest that resolves the plugin from the local plugin directory (used when Claude Code installs from a local or git-based source). Both files are needed to support these two install paths.
|
|
32
|
+
|
|
31
33
|
See [Usage](#usage) below.
|
|
32
34
|
|
|
33
35
|
### Without the plugin — custom slash command
|
|
@@ -36,7 +36,8 @@ export async function cacheGet(key, opts = {}) {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
/**
|
|
39
|
-
* Write a value to the cache
|
|
39
|
+
* Write a value to the cache. Errors are swallowed — callers can await
|
|
40
|
+
* to know when the write is done, but the write never rejects.
|
|
40
41
|
*/
|
|
41
42
|
export async function cacheSet(key, value, opts = {}) {
|
|
42
43
|
if (opts.disabled)
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
* CLI argument-parsing helpers extracted from cli.mts for testability.
|
|
3
3
|
* Note: parseCommonArgs calls loadConfig() for cache TTL defaults.
|
|
4
4
|
*/
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
5
6
|
import { loadConfig } from "../config/load.mjs";
|
|
6
7
|
import { deriveVerdict } from "../commands/status.mjs";
|
|
7
|
-
// Flags that consume the next argument as their value
|
|
8
|
+
// Flags that consume the next argument as their value (used for PR-number
|
|
9
|
+
// detection only — prevents a flag's value from being mistaken for a PR number).
|
|
8
10
|
const FLAGS_WITH_VALUES = new Set([
|
|
9
11
|
"--format",
|
|
10
12
|
"--cache-ttl",
|
|
@@ -14,33 +16,54 @@ const FLAGS_WITH_VALUES = new Set([
|
|
|
14
16
|
"--require-sha",
|
|
15
17
|
"--message",
|
|
16
18
|
]);
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Strict integer parsing
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
export function parseIntStrict(value, flag) {
|
|
23
|
+
if (!/^-?\d+$/.test(value.trim())) {
|
|
24
|
+
throw new Error(`Invalid value for ${flag}: "${value}" is not an integer`);
|
|
25
|
+
}
|
|
26
|
+
return parseInt(value, 10);
|
|
27
|
+
}
|
|
17
28
|
export function parseCommonArgs(args) {
|
|
18
29
|
const config = loadConfig();
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
30
|
+
const { values, tokens } = parseArgs({
|
|
31
|
+
args,
|
|
32
|
+
strict: false,
|
|
33
|
+
allowPositionals: true,
|
|
34
|
+
tokens: true,
|
|
35
|
+
options: {
|
|
36
|
+
format: { type: "string" },
|
|
37
|
+
"cache-ttl": { type: "string" },
|
|
38
|
+
"no-cache": { type: "boolean" },
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
const format = (values.format ?? "text");
|
|
42
|
+
const noCache = (values["no-cache"] ?? false);
|
|
43
|
+
const cacheTtlStr = values["cache-ttl"];
|
|
44
|
+
const cacheTtlSeconds = cacheTtlStr
|
|
45
|
+
? parseIntStrict(cacheTtlStr, "--cache-ttl")
|
|
46
|
+
: config.cache.ttlSeconds;
|
|
47
|
+
// Build the set of arg indices consumed by global flags so we can strip
|
|
48
|
+
// them from `extra`. Subcommand-specific flags are left untouched.
|
|
49
|
+
const consumedIndices = new Set();
|
|
50
|
+
for (const tok of tokens ?? []) {
|
|
51
|
+
if (tok.kind === "option" &&
|
|
52
|
+
(tok.name === "format" || tok.name === "cache-ttl" || tok.name === "no-cache")) {
|
|
53
|
+
consumedIndices.add(tok.index);
|
|
54
|
+
// When the value is a separate arg (--flag value, not --flag=value),
|
|
55
|
+
// inlineValue is false and the value occupies tok.index + 1.
|
|
56
|
+
if ("inlineValue" in tok && tok.inlineValue === false && tok.value != null) {
|
|
57
|
+
consumedIndices.add(tok.index + 1);
|
|
40
58
|
}
|
|
41
|
-
i += 1;
|
|
42
59
|
}
|
|
43
|
-
|
|
60
|
+
}
|
|
61
|
+
// Find the first positional arg that looks like a PR number, skipping values
|
|
62
|
+
// that belong to flags in FLAGS_WITH_VALUES (subcommand flags included).
|
|
63
|
+
const skipForPrDetect = new Set();
|
|
64
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
65
|
+
const arg = args[i];
|
|
66
|
+
if (FLAGS_WITH_VALUES.has(arg)) {
|
|
44
67
|
skipForPrDetect.add(i);
|
|
45
68
|
if (i + 1 < args.length)
|
|
46
69
|
skipForPrDetect.add(i + 1);
|
|
@@ -48,21 +71,18 @@ export function parseCommonArgs(args) {
|
|
|
48
71
|
}
|
|
49
72
|
else {
|
|
50
73
|
const eqIdx = arg.indexOf("=");
|
|
51
|
-
if (eqIdx > 0) {
|
|
52
|
-
|
|
53
|
-
if (FLAGS_WITH_VALUES.has(flagName)) {
|
|
54
|
-
skipForPrDetect.add(i);
|
|
55
|
-
if (globalFlagsWithValues.has(flagName))
|
|
56
|
-
excludeFromExtra.add(i);
|
|
57
|
-
}
|
|
74
|
+
if (eqIdx > 0 && FLAGS_WITH_VALUES.has(arg.slice(0, eqIdx))) {
|
|
75
|
+
skipForPrDetect.add(i);
|
|
58
76
|
}
|
|
59
77
|
}
|
|
60
78
|
}
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
79
|
+
const prIndex = args.findIndex((a, index) => !skipForPrDetect.has(index) && !a.startsWith("--") && /^\d+$/.test(a));
|
|
80
|
+
const prNumber = prIndex !== -1 ? parseInt(args[prIndex], 10) : undefined;
|
|
81
|
+
// Remove consumed global-flag indices (and the PR number itself) from extra.
|
|
82
|
+
if (prIndex !== -1) {
|
|
83
|
+
consumedIndices.add(prIndex);
|
|
84
|
+
}
|
|
85
|
+
const extra = args.filter((_, i) => !consumedIndices.has(i));
|
|
66
86
|
return {
|
|
67
87
|
prNumber,
|
|
68
88
|
global: { format, noCache, cacheTtlSeconds },
|
package/{dist → bin}/cli.mjs
RENAMED
|
@@ -17,7 +17,7 @@ import { getRepoInfo } from "./github/client.mjs";
|
|
|
17
17
|
import { formatJson } from "./reporters/json.mjs";
|
|
18
18
|
import { formatText } from "./reporters/text.mjs";
|
|
19
19
|
import { loadConfig } from "./config/load.mjs";
|
|
20
|
-
import { parseCommonArgs, getFlag, hasFlag, parseList, parseStatusPrNumbers, parseDurationToMinutes, statusToExitCode, iterateActionToExitCode, deriveSimpleReady, } from "./cli/args.mjs";
|
|
20
|
+
import { parseCommonArgs, getFlag, hasFlag, parseList, parseStatusPrNumbers, parseDurationToMinutes, parseIntStrict, statusToExitCode, iterateActionToExitCode, deriveSimpleReady, } from "./cli/args.mjs";
|
|
21
21
|
// ---------------------------------------------------------------------------
|
|
22
22
|
// Entry
|
|
23
23
|
// ---------------------------------------------------------------------------
|
|
@@ -40,7 +40,8 @@ export async function main(argv) {
|
|
|
40
40
|
default:
|
|
41
41
|
process.stderr.write(`Unknown subcommand: ${subcommand ?? "(none)"}\n`);
|
|
42
42
|
process.stderr.write("Usage: pr-shepherd <check|resolve|iterate|status> [options]\n");
|
|
43
|
-
process.
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
return;
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
47
|
// ---------------------------------------------------------------------------
|
|
@@ -51,7 +52,8 @@ async function handleCheck(args) {
|
|
|
51
52
|
const report = await runCheck({ ...globalOpts, prNumber, autoResolve: false });
|
|
52
53
|
const output = globalOpts.format === "json" ? formatJson(report) : formatText(report);
|
|
53
54
|
process.stdout.write(`${output}\n`);
|
|
54
|
-
process.
|
|
55
|
+
process.exitCode = statusToExitCode(report.status);
|
|
56
|
+
return;
|
|
55
57
|
}
|
|
56
58
|
async function handleResolve(args) {
|
|
57
59
|
const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
|
|
@@ -88,13 +90,15 @@ async function handleResolve(args) {
|
|
|
88
90
|
async function handleIterate(args) {
|
|
89
91
|
const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
|
|
90
92
|
const lastPushTimeStr = getFlag(extra, "--last-push-time");
|
|
91
|
-
const lastPushTime = lastPushTimeStr
|
|
93
|
+
const lastPushTime = lastPushTimeStr
|
|
94
|
+
? parseIntStrict(lastPushTimeStr, "--last-push-time")
|
|
95
|
+
: undefined;
|
|
92
96
|
const readyDelayStr = getFlag(extra, "--ready-delay");
|
|
93
97
|
const cfg = loadConfig();
|
|
94
98
|
const readyDelaySeconds = parseDurationToMinutes(readyDelayStr ?? "", cfg.watch.readyDelayMinutes) * 60;
|
|
95
99
|
const cooldownSecondsStr = getFlag(extra, "--cooldown-seconds");
|
|
96
100
|
const cooldownSeconds = cooldownSecondsStr
|
|
97
|
-
?
|
|
101
|
+
? parseIntStrict(cooldownSecondsStr, "--cooldown-seconds")
|
|
98
102
|
: cfg.iterate.cooldownSeconds;
|
|
99
103
|
const noAutoRerun = hasFlag(extra, "--no-auto-rerun");
|
|
100
104
|
const noAutoMarkReady = hasFlag(extra, "--no-auto-mark-ready");
|
|
@@ -115,14 +119,16 @@ async function handleIterate(args) {
|
|
|
115
119
|
else {
|
|
116
120
|
process.stdout.write(`${formatIterateResult(result)}\n`);
|
|
117
121
|
}
|
|
118
|
-
process.
|
|
122
|
+
process.exitCode = iterateActionToExitCode(result.action);
|
|
123
|
+
return;
|
|
119
124
|
}
|
|
120
125
|
async function handleStatus(args) {
|
|
121
126
|
const { global: globalOpts } = parseCommonArgs(args);
|
|
122
127
|
const prNumbers = parseStatusPrNumbers(args);
|
|
123
128
|
if (prNumbers.length === 0) {
|
|
124
129
|
process.stderr.write("Usage: pr-shepherd status PR1 [PR2 …]\n");
|
|
125
|
-
process.
|
|
130
|
+
process.exitCode = 1;
|
|
131
|
+
return;
|
|
126
132
|
}
|
|
127
133
|
const repo = await getRepoInfo();
|
|
128
134
|
const summaries = await runStatus({ ...globalOpts, prNumbers });
|
|
@@ -131,19 +137,14 @@ async function handleStatus(args) {
|
|
|
131
137
|
: formatStatusTable(summaries, `${repo.owner}/${repo.name}`);
|
|
132
138
|
process.stdout.write(`${output}\n`);
|
|
133
139
|
const allReady = summaries.every((s) => deriveSimpleReady(s));
|
|
134
|
-
process.
|
|
140
|
+
process.exitCode = allReady ? 0 : 1;
|
|
141
|
+
return;
|
|
135
142
|
}
|
|
136
143
|
// ---------------------------------------------------------------------------
|
|
137
144
|
// Output formatters
|
|
138
145
|
// ---------------------------------------------------------------------------
|
|
139
146
|
function formatFetchResult(result) {
|
|
140
147
|
const lines = [];
|
|
141
|
-
if (result.autoResolved.length > 0) {
|
|
142
|
-
lines.push(`Auto-resolved outdated (${result.autoResolved.length}):`);
|
|
143
|
-
for (const t of result.autoResolved) {
|
|
144
|
-
lines.push(` - threadId=${t.id} ${t.path ?? ""}:${t.line ?? "?"} (@${t.author})`);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
148
|
if (result.actionableThreads.length > 0) {
|
|
148
149
|
lines.push(`\nActionable Review Threads (${result.actionableThreads.length}):`);
|
|
149
150
|
for (const t of result.actionableThreads) {
|
|
@@ -88,6 +88,9 @@ export async function runIterate(opts) {
|
|
|
88
88
|
}
|
|
89
89
|
// Step 3: Ready-delay state machine.
|
|
90
90
|
const [repoOwner, repoName] = report.repo.split("/");
|
|
91
|
+
if (!repoOwner || !repoName) {
|
|
92
|
+
throw new Error(`Unexpected repo format: "${report.repo}" (expected "owner/name")`);
|
|
93
|
+
}
|
|
91
94
|
const isReady = report.status === "READY";
|
|
92
95
|
const readyState = await updateReadyDelay(report.pr, isReady, readyDelaySeconds, repoOwner, repoName);
|
|
93
96
|
const base = {
|
|
@@ -239,6 +242,9 @@ async function tryCancelRun(runId) {
|
|
|
239
242
|
}
|
|
240
243
|
catch (err) {
|
|
241
244
|
const msg = err instanceof Error ? err.message : String(err);
|
|
245
|
+
// gh returns this when the run reached a terminal state — expected, not worth logging.
|
|
246
|
+
if (/already completed|cannot cancel a workflow run that is completed/i.test(msg))
|
|
247
|
+
return null;
|
|
242
248
|
process.stderr.write(`pr-shepherd: gh run cancel ${runId} failed (ignored): ${msg}\n`);
|
|
243
249
|
return null;
|
|
244
250
|
}
|
|
@@ -32,15 +32,15 @@ export async function runResolveFetch(opts) {
|
|
|
32
32
|
const visibleComments = data.comments.filter((c) => !c.isMinimized);
|
|
33
33
|
// Auto-resolve outdated.
|
|
34
34
|
const outdated = getOutdatedThreads(unresolvedThreads);
|
|
35
|
-
let autoResolved = [];
|
|
36
35
|
if (outdated.length > 0) {
|
|
37
|
-
const {
|
|
38
|
-
|
|
36
|
+
const { errors } = await autoResolveOutdated(outdated.map((t) => t.id));
|
|
37
|
+
if (errors.length > 0) {
|
|
38
|
+
throw new Error(`Failed to auto-resolve outdated threads: ${errors.join(", ")}`);
|
|
39
|
+
}
|
|
39
40
|
}
|
|
40
41
|
const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated);
|
|
41
42
|
return {
|
|
42
|
-
|
|
43
|
-
actionableThreads: activeThreads,
|
|
43
|
+
actionableThreads: activeThreads.map(({ isResolved, isOutdated, ...rest }) => rest),
|
|
44
44
|
actionableComments: visibleComments,
|
|
45
45
|
changesRequestedReviews: data.changesRequestedReviews,
|
|
46
46
|
};
|
|
@@ -14,10 +14,6 @@
|
|
|
14
14
|
import { graphql, getPrHeadSha } from "../github/client.mjs";
|
|
15
15
|
import { RESOLVE_THREAD_MUTATION, MINIMIZE_COMMENT_MUTATION, DISMISS_REVIEW_MUTATION, } from "../github/queries.mjs";
|
|
16
16
|
import { loadConfig } from "../config/load.mjs";
|
|
17
|
-
const config = loadConfig();
|
|
18
|
-
const CONCURRENCY = config.resolve.concurrency;
|
|
19
|
-
const SHA_POLL_INTERVAL_MS = config.resolve.shaPoll.intervalMs;
|
|
20
|
-
const SHA_POLL_MAX_ATTEMPTS = config.resolve.shaPoll.maxAttempts;
|
|
21
17
|
/**
|
|
22
18
|
* Execute all requested resolve/minimize/dismiss mutations.
|
|
23
19
|
*
|
|
@@ -69,6 +65,7 @@ async function dismissReview(reviewId, message) {
|
|
|
69
65
|
// Concurrency helper
|
|
70
66
|
// ---------------------------------------------------------------------------
|
|
71
67
|
async function runBatched(ids, fn, successList, errorList) {
|
|
68
|
+
const CONCURRENCY = loadConfig().resolve.concurrency;
|
|
72
69
|
// Process in chunks of CONCURRENCY.
|
|
73
70
|
for (let i = 0; i < ids.length; i += CONCURRENCY) {
|
|
74
71
|
const chunk = ids.slice(i, i + CONCURRENCY);
|
|
@@ -88,6 +85,7 @@ async function runBatched(ids, fn, successList, errorList) {
|
|
|
88
85
|
// SHA polling
|
|
89
86
|
// ---------------------------------------------------------------------------
|
|
90
87
|
async function waitForSha(pr, repo, expectedSha) {
|
|
88
|
+
const { intervalMs: SHA_POLL_INTERVAL_MS, maxAttempts: SHA_POLL_MAX_ATTEMPTS } = loadConfig().resolve.shaPoll;
|
|
91
89
|
for (let attempt = 0; attempt < SHA_POLL_MAX_ATTEMPTS; attempt++) {
|
|
92
90
|
try {
|
|
93
91
|
// eslint-disable-next-line no-await-in-loop
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { join, dirname } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { parse } from "yaml";
|
|
@@ -9,13 +9,9 @@ function findRcFile(startDir) {
|
|
|
9
9
|
let current = startDir;
|
|
10
10
|
while (true) {
|
|
11
11
|
const candidate = join(current, RC_FILENAME);
|
|
12
|
-
|
|
13
|
-
readFileSync(candidate);
|
|
12
|
+
if (statSync(candidate, { throwIfNoEntry: false })?.isFile()) {
|
|
14
13
|
return candidate;
|
|
15
14
|
}
|
|
16
|
-
catch {
|
|
17
|
-
// not found here
|
|
18
|
-
}
|
|
19
15
|
if (current === home || current === dirname(current))
|
|
20
16
|
return null;
|
|
21
17
|
current = dirname(current);
|
|
@@ -56,7 +52,10 @@ function applyCompat(raw) {
|
|
|
56
52
|
const iterate = out["iterate"];
|
|
57
53
|
if (iterate && "maxFixAttempts" in iterate) {
|
|
58
54
|
process.stderr.write(`pr-shepherd: config key "iterate.maxFixAttempts" renamed to "iterate.fixAttemptsPerThread".\n`);
|
|
59
|
-
out["iterate"] = {
|
|
55
|
+
out["iterate"] = {
|
|
56
|
+
...iterate,
|
|
57
|
+
fixAttemptsPerThread: iterate["fixAttemptsPerThread"] ?? iterate["maxFixAttempts"],
|
|
58
|
+
};
|
|
60
59
|
delete out["iterate"]["maxFixAttempts"];
|
|
61
60
|
}
|
|
62
61
|
// Renamed watch keys
|
|
@@ -65,17 +64,18 @@ function applyCompat(raw) {
|
|
|
65
64
|
const watchOut = { ...watch };
|
|
66
65
|
if ("intervalDefault" in watch) {
|
|
67
66
|
process.stderr.write(`pr-shepherd: config key "watch.intervalDefault" renamed to "watch.interval".\n`);
|
|
68
|
-
watchOut["interval"] = watch["intervalDefault"];
|
|
67
|
+
watchOut["interval"] = watchOut["interval"] ?? watch["intervalDefault"];
|
|
69
68
|
delete watchOut["intervalDefault"];
|
|
70
69
|
}
|
|
71
70
|
if ("readyDelayMinutesDefault" in watch) {
|
|
72
71
|
process.stderr.write(`pr-shepherd: config key "watch.readyDelayMinutesDefault" renamed to "watch.readyDelayMinutes".\n`);
|
|
73
|
-
watchOut["readyDelayMinutes"] =
|
|
72
|
+
watchOut["readyDelayMinutes"] =
|
|
73
|
+
watchOut["readyDelayMinutes"] ?? watch["readyDelayMinutesDefault"];
|
|
74
74
|
delete watchOut["readyDelayMinutesDefault"];
|
|
75
75
|
}
|
|
76
76
|
if ("expiresHoursDefault" in watch) {
|
|
77
77
|
process.stderr.write(`pr-shepherd: config key "watch.expiresHoursDefault" renamed to "watch.expiresHours".\n`);
|
|
78
|
-
watchOut["expiresHours"] = watch["expiresHoursDefault"];
|
|
78
|
+
watchOut["expiresHours"] = watchOut["expiresHours"] ?? watch["expiresHoursDefault"];
|
|
79
79
|
delete watchOut["expiresHoursDefault"];
|
|
80
80
|
}
|
|
81
81
|
out["watch"] = watchOut;
|
|
@@ -88,13 +88,17 @@ function applyCompat(raw) {
|
|
|
88
88
|
let shaPollChanged = false;
|
|
89
89
|
if ("shaPollIntervalMs" in resolve) {
|
|
90
90
|
process.stderr.write(`pr-shepherd: config key "resolve.shaPollIntervalMs" moved to "resolve.shaPoll.intervalMs".\n`);
|
|
91
|
-
shaPollOut["intervalMs"] =
|
|
91
|
+
shaPollOut["intervalMs"] =
|
|
92
|
+
resolve["shaPoll"]?.["intervalMs"] ??
|
|
93
|
+
resolve["shaPollIntervalMs"];
|
|
92
94
|
delete resolveOut["shaPollIntervalMs"];
|
|
93
95
|
shaPollChanged = true;
|
|
94
96
|
}
|
|
95
97
|
if ("shaPollMaxAttempts" in resolve) {
|
|
96
98
|
process.stderr.write(`pr-shepherd: config key "resolve.shaPollMaxAttempts" moved to "resolve.shaPoll.maxAttempts".\n`);
|
|
97
|
-
shaPollOut["maxAttempts"] =
|
|
99
|
+
shaPollOut["maxAttempts"] =
|
|
100
|
+
resolve["shaPoll"]?.["maxAttempts"] ??
|
|
101
|
+
resolve["shaPollMaxAttempts"];
|
|
98
102
|
delete resolveOut["shaPollMaxAttempts"];
|
|
99
103
|
shaPollChanged = true;
|
|
100
104
|
}
|
|
@@ -112,17 +116,17 @@ function applyCompat(raw) {
|
|
|
112
116
|
const checksOut = { ...checks };
|
|
113
117
|
if ("relevantEvents" in checks) {
|
|
114
118
|
process.stderr.write(`pr-shepherd: config key "checks.relevantEvents" renamed to "checks.ciTriggerEvents".\n`);
|
|
115
|
-
checksOut["ciTriggerEvents"] = checks["relevantEvents"];
|
|
119
|
+
checksOut["ciTriggerEvents"] = checksOut["ciTriggerEvents"] ?? checks["relevantEvents"];
|
|
116
120
|
delete checksOut["relevantEvents"];
|
|
117
121
|
}
|
|
118
122
|
if ("logLinesKept" in checks) {
|
|
119
123
|
process.stderr.write(`pr-shepherd: config key "checks.logLinesKept" renamed to "checks.logMaxLines".\n`);
|
|
120
|
-
checksOut["logMaxLines"] = checks["logLinesKept"];
|
|
124
|
+
checksOut["logMaxLines"] = checksOut["logMaxLines"] ?? checks["logLinesKept"];
|
|
121
125
|
delete checksOut["logLinesKept"];
|
|
122
126
|
}
|
|
123
127
|
if ("logExcerptMaxChars" in checks) {
|
|
124
128
|
process.stderr.write(`pr-shepherd: config key "checks.logExcerptMaxChars" renamed to "checks.logMaxChars".\n`);
|
|
125
|
-
checksOut["logMaxChars"] = checks["logExcerptMaxChars"];
|
|
129
|
+
checksOut["logMaxChars"] = checksOut["logMaxChars"] ?? checks["logExcerptMaxChars"];
|
|
126
130
|
delete checksOut["logExcerptMaxChars"];
|
|
127
131
|
}
|
|
128
132
|
out["checks"] = checksOut;
|
|
@@ -89,7 +89,6 @@ export async function getCurrentPrNumber() {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
async function getCurrentBranch() {
|
|
92
|
-
await runGh(["--version"]); // warm up gh CLI before git call
|
|
93
92
|
// Use git directly for branch name — gh doesn't expose it.
|
|
94
93
|
const { stdout } = await execFile("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
95
94
|
return stdout.trim();
|
package/bin/pr-shepherd
ADDED
package/package.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pr-shepherd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Autonomous PR CI monitor and review-comment resolver for Claude Code",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jonathan Ong",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"bin": {
|
|
9
|
-
"pr-shepherd": "
|
|
9
|
+
"pr-shepherd": "bin/index.mjs"
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
|
-
"
|
|
12
|
+
"bin/**",
|
|
13
13
|
"skills/**",
|
|
14
14
|
".claude-plugin/**",
|
|
15
15
|
"marketplace.json",
|
|
@@ -17,21 +17,21 @@
|
|
|
17
17
|
"LICENSE"
|
|
18
18
|
],
|
|
19
19
|
"engines": {
|
|
20
|
-
"node": ">=
|
|
20
|
+
"node": ">=22.0.0"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"yaml": "^2.7.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/node": "^25.6.0",
|
|
27
|
-
"oxfmt": "
|
|
28
|
-
"oxlint": "
|
|
27
|
+
"oxfmt": "^0.45.0",
|
|
28
|
+
"oxlint": "^1.60.0",
|
|
29
29
|
"typescript": "^6.0.3",
|
|
30
30
|
"vitest": "^4.1.4",
|
|
31
31
|
"@vitest/coverage-v8": "^4.1.4"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
|
-
"build": "
|
|
34
|
+
"build": "node scripts/build.mjs",
|
|
35
35
|
"prepare": "npm run build",
|
|
36
36
|
"prepublishOnly": "npm run typecheck && npm test && npm run build",
|
|
37
37
|
"typecheck": "tsc --noEmit",
|
package/skills/check/SKILL.md
CHANGED
|
@@ -40,24 +40,22 @@ Parse the JSON output and report all three:
|
|
|
40
40
|
|
|
41
41
|
## Rebase policy
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
BASE_BRANCH=$(gh pr view <N> --json baseRefName --jq '.baseRefName')
|
|
45
|
-
```
|
|
43
|
+
The CLI already determines whether a rebase is warranted. Read `report.mergeStatus.status` directly:
|
|
46
44
|
|
|
47
|
-
|
|
45
|
+
- `CONFLICTS` — a rebase is required to resolve the merge conflict before the PR can land.
|
|
46
|
+
- `BEHIND` — a rebase may be appropriate; a `flaky` failure while `BEHIND` is the canonical rebase signal. If all checks pass but the PR is `BEHIND`, a rebase is optional.
|
|
47
|
+
- Any other status — no rebase needed.
|
|
48
48
|
|
|
49
|
-
-
|
|
50
|
-
- About to push commits and branch is behind main, OR
|
|
51
|
-
- `failureKind == 'flaky'` AND branch is behind main
|
|
52
|
-
|
|
53
|
-
Do NOT rebase when nothing to push, no conflicts, and no flaky failures.
|
|
49
|
+
Do not re-derive these conditions from raw branch state. For automated monitoring that acts on these signals, use `/pr-shepherd:monitor` — it handles rebase decisions end-to-end.
|
|
54
50
|
|
|
55
51
|
## CI budget policy
|
|
56
52
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
-
|
|
60
|
-
-
|
|
53
|
+
Each entry in `report.checks` carries a `failureKind` field. Read it directly rather than re-classifying failures:
|
|
54
|
+
|
|
55
|
+
- `actionable` — the failure is code-level and needs a fix.
|
|
56
|
+
- `infrastructure` — transient infra problem; re-run with `gh run rerun <runId> --failed`.
|
|
57
|
+
- `timeout` — job exceeded the time limit; re-run with `gh run rerun <runId> --failed`.
|
|
58
|
+
- `flaky` — known-flaky test; do NOT cancel. Rebase first if `mergeStatus.status` is `BEHIND`.
|
|
61
59
|
|
|
62
60
|
## Never declare ready to merge
|
|
63
61
|
|
package/skills/monitor/SKILL.md
CHANGED
|
@@ -79,7 +79,7 @@ Parse the `action` field and act:
|
|
|
79
79
|
1. For each item in `fix.threads` and `fix.comments`: read the referenced file/line and apply the fix (Edit/Write tools).
|
|
80
80
|
2. For each item in `fix.checks`:
|
|
81
81
|
- If `runId` is non-null: fetch the failure log with `gh run view <runId> --log-failed` (dangerouslyDisableSandbox: true), scan the output to identify the failure (e.g. grep for `FAIL` for test failures, `error:` for type/compile errors, lint rule names for lint failures), then read the relevant file and apply the fix (Edit/Write tools).
|
|
82
|
-
- If `runId` is null: the failed check is an external status check that cannot be inspected via run logs. Escalate — tell the user to open `detailsUrl` in the PR checks UI, inspect the failure manually, and rerun `/pr-shepherd:monitor
|
|
82
|
+
- If `runId` is null: the failed check is an external status check that cannot be inspected via run logs. Escalate — tell the user to open `detailsUrl` in the PR checks UI, inspect the failure manually, and rerun `/pr-shepherd:monitor <PR_NUMBER>` after addressing it. Do not attempt to fix these automatically.
|
|
83
83
|
3. For each item in `fix.changesRequestedReviews`: read the review body and apply the requested changes.
|
|
84
84
|
4. If files were changed, `git add <files> && git commit -m "<appropriate commit message>"`
|
|
85
85
|
5. `git fetch origin && git rebase origin/<BASE_BRANCH> && git push --force-with-lease` (dangerouslyDisableSandbox: true)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
/package/{dist → bin}/index.mjs
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
/package/{dist → bin}/types.mjs
RENAMED
|
File without changes
|
|
File without changes
|