pr-shepherd 0.7.0 → 0.8.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 +37 -302
- package/bin/checks/classify.mjs +5 -4
- package/bin/checks/triage.mjs +76 -62
- package/bin/cli/args.mjs +29 -61
- package/bin/cli/exit-codes.mjs +39 -0
- package/bin/cli/fix-formatter.mjs +76 -0
- package/bin/cli/formatters.mjs +108 -0
- package/bin/cli/handlers.mjs +138 -0
- package/bin/cli/iterate-formatter.mjs +78 -0
- package/bin/cli-parser.iterate-fixtures.mjs +65 -0
- package/bin/cli-parser.mjs +110 -0
- package/bin/commands/check-status.mjs +35 -0
- package/bin/commands/check.mjs +14 -61
- package/bin/commands/commit-suggestion.mjs +159 -0
- package/bin/commands/iterate/classify.mjs +77 -0
- package/bin/commands/iterate/escalate.mjs +124 -0
- package/bin/commands/iterate/fix-code.mjs +97 -0
- package/bin/commands/iterate/helpers.mjs +103 -0
- package/bin/commands/iterate/index.mjs +122 -0
- package/bin/commands/iterate/render.mjs +119 -0
- package/bin/commands/iterate/stall.mjs +65 -0
- package/bin/commands/iterate/steps.mjs +31 -0
- package/bin/commands/iterate.mjs +2 -628
- package/bin/commands/monitor.mjs +78 -0
- package/bin/commands/ready-delay.mjs +3 -4
- package/bin/commands/resolve-instructions.mjs +39 -0
- package/bin/commands/resolve.mjs +34 -3
- package/bin/commands/status.mjs +7 -0
- package/bin/comments/resolve.mjs +1 -1
- package/bin/config/load.mjs +17 -113
- package/bin/config.json +10 -22
- package/bin/github/batch-parsers.mjs +140 -0
- package/bin/github/batch-raw-types.mjs +2 -0
- package/bin/github/batch.mjs +34 -129
- package/bin/github/client.mjs +47 -9
- package/bin/github/gql/batch-pr.gql +20 -0
- package/bin/github/http.mjs +32 -30
- package/bin/index.mjs +15 -2
- package/bin/merge-status/derive.mjs +11 -11
- package/bin/reporters/agent.mjs +13 -4
- package/bin/reporters/check-instructions.mjs +65 -0
- package/bin/reporters/json.mjs +3 -2
- package/bin/reporters/text.mjs +108 -61
- package/bin/{cache → state}/fix-attempts.mjs +3 -3
- package/bin/state/iterate-stall.mjs +74 -0
- package/bin/suggestions/parse.mjs +119 -0
- package/bin/suggestions/patch.mjs +52 -0
- package/bin/types/github.mjs +2 -0
- package/bin/types/iterate.mjs +2 -0
- package/bin/types/report.mjs +2 -0
- package/bin/types.mjs +3 -1
- package/package.json +3 -3
- package/plugin/skills/check/SKILL.md +15 -48
- package/plugin/skills/monitor/SKILL.md +11 -64
- package/plugin/skills/resolve/SKILL.md +10 -76
- package/bin/cache/file-cache.mjs +0 -79
- package/bin/cli.mjs +0 -286
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { getCurrentPrNumber } from "../github/client.mjs";
|
|
2
|
+
import { loadConfig } from "../config/load.mjs";
|
|
3
|
+
export async function runMonitor(opts) {
|
|
4
|
+
const config = loadConfig();
|
|
5
|
+
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
6
|
+
if (prNumber === null) {
|
|
7
|
+
throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
|
|
8
|
+
}
|
|
9
|
+
const { interval, maxTurns, expiresHours } = config.watch;
|
|
10
|
+
if (typeof interval !== "string" || !/^\d+[smhd]$/.test(interval)) {
|
|
11
|
+
throw new Error(`Invalid config: watch.interval must be a duration string like "4m" or "1h", got ${JSON.stringify(interval)}`);
|
|
12
|
+
}
|
|
13
|
+
if (!Number.isFinite(expiresHours) || expiresHours <= 0 || !Number.isInteger(expiresHours)) {
|
|
14
|
+
throw new Error(`Invalid config: watch.expiresHours must be a positive integer, got ${JSON.stringify(expiresHours)}`);
|
|
15
|
+
}
|
|
16
|
+
if (!Number.isFinite(maxTurns) || maxTurns <= 0 || !Number.isInteger(maxTurns)) {
|
|
17
|
+
throw new Error(`Invalid config: watch.maxTurns must be a positive integer, got ${JSON.stringify(maxTurns)}`);
|
|
18
|
+
}
|
|
19
|
+
const loopTag = `# pr-shepherd-loop:pr=${prNumber}`;
|
|
20
|
+
const loopPrompt = buildLoopPrompt(prNumber, loopTag, opts.readyDelaySuffix);
|
|
21
|
+
const loopArgs = `${interval} --max-turns ${maxTurns} --expires ${expiresHours}h`;
|
|
22
|
+
return { prNumber, loopTag, loopArgs, loopPrompt };
|
|
23
|
+
}
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Formatters
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
export function formatMonitorResult(result) {
|
|
28
|
+
const { prNumber, loopTag, loopArgs, loopPrompt } = result;
|
|
29
|
+
return [
|
|
30
|
+
`# PR #${prNumber} [MONITOR]`,
|
|
31
|
+
"",
|
|
32
|
+
`Loop tag: \`${loopTag}\``,
|
|
33
|
+
`Loop args: \`${loopArgs}\``,
|
|
34
|
+
"",
|
|
35
|
+
"## Loop prompt",
|
|
36
|
+
"",
|
|
37
|
+
loopPrompt,
|
|
38
|
+
"",
|
|
39
|
+
"## Instructions",
|
|
40
|
+
"",
|
|
41
|
+
`1. Run \`CronList\`. If any job's prompt contains \`${loopTag}\`, run the \`## Loop prompt\` body once inline (as if it were a cron tick) then stop — do not create a duplicate loop.`,
|
|
42
|
+
`2. Otherwise, invoke the \`/loop\` skill via the Skill tool. Build the \`args\` parameter as: only the value inside the backticks on the \`Loop args\` line above (the interval/flags string — not the \`Loop args:\` label), then a blank line, then the full \`## Loop prompt\` body.`,
|
|
43
|
+
].join("\n");
|
|
44
|
+
}
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Internal
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
function validateReadyDelaySuffix(readyDelaySuffix) {
|
|
49
|
+
if (readyDelaySuffix === undefined)
|
|
50
|
+
return undefined;
|
|
51
|
+
const trimmed = readyDelaySuffix.trim();
|
|
52
|
+
if (!/^\d+(?:m|min|minutes?|h|hours?)$/.test(trimmed)) {
|
|
53
|
+
throw new Error(`Invalid --ready-delay: ${readyDelaySuffix}. Expected a duration like 5m, 2h, 10m, or 1h.`);
|
|
54
|
+
}
|
|
55
|
+
return trimmed;
|
|
56
|
+
}
|
|
57
|
+
function buildLoopPrompt(prNumber, loopTag, readyDelaySuffix) {
|
|
58
|
+
const validatedDelay = validateReadyDelaySuffix(readyDelaySuffix);
|
|
59
|
+
const iterateCmd = validatedDelay
|
|
60
|
+
? `npx pr-shepherd iterate ${prNumber} --no-cache --ready-delay ${validatedDelay}`
|
|
61
|
+
: `npx pr-shepherd iterate ${prNumber} --no-cache`;
|
|
62
|
+
return [
|
|
63
|
+
loopTag,
|
|
64
|
+
"",
|
|
65
|
+
"**IMPORTANT — recurrence rules:**",
|
|
66
|
+
"- **Do NOT call `ScheduleWakeup` or `/loop`.** This session is fired by a recurring cron job. Either call creates a duplicate runner, causing concurrent git operations and `.git/index.lock` collisions.",
|
|
67
|
+
"- End the turn cleanly after completing the actions below. The cron job handles the next fire.",
|
|
68
|
+
"",
|
|
69
|
+
`**Self-dedup:** Run \`CronList\`. If more than one job contains \`${loopTag}\`, keep the lowest job ID and \`CronDelete\` the rest (ignore errors — a concurrent runner may have already deleted them).`,
|
|
70
|
+
"",
|
|
71
|
+
"Run in a single Bash call:",
|
|
72
|
+
` ${iterateCmd}`,
|
|
73
|
+
"",
|
|
74
|
+
`Exit codes 0–3 are all valid. If the command crashes (non-zero exit, no markdown output starting with \`# PR #${prNumber} [\`), log the first line of stderr and continue — do not cancel the loop. The next cron fire will retry.`,
|
|
75
|
+
"",
|
|
76
|
+
"The output is Markdown. The first line is an H1 heading of the form `# PR #<N> [<ACTION>]`. Every output ends with a `## Instructions` section — follow those numbered steps exactly.",
|
|
77
|
+
].join("\n");
|
|
78
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Ready-delay state machine for the shepherd iterate loop.
|
|
3
3
|
*
|
|
4
4
|
* When all READY conditions hold, shepherd writes a `ready-since.txt` marker
|
|
5
|
-
* to the
|
|
5
|
+
* to the state dir. The loop continues until the PR has been READY for
|
|
6
6
|
* `readyDelaySeconds` consecutively. Any not-READY result resets the timer.
|
|
7
7
|
*/
|
|
8
8
|
import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
|
|
@@ -47,8 +47,7 @@ export async function updateReadyDelay(prNumber, isReady, readyDelaySeconds, own
|
|
|
47
47
|
const elapsed = now - readySince;
|
|
48
48
|
const remaining = readyDelaySeconds - elapsed;
|
|
49
49
|
if (remaining <= 0) {
|
|
50
|
-
|
|
51
|
-
// until the PR drops out of READY state (which resets via safeUnlink above).
|
|
50
|
+
await safeUnlink(markerPath);
|
|
52
51
|
return { isReady: true, shouldCancel: true, remainingSeconds: 0 };
|
|
53
52
|
}
|
|
54
53
|
return { isReady: true, shouldCancel: false, remainingSeconds: remaining };
|
|
@@ -65,7 +64,7 @@ function readySincePath(pr, owner, repo) {
|
|
|
65
64
|
throw new Error(`Invalid path segment "${field}": ${value}`);
|
|
66
65
|
}
|
|
67
66
|
}
|
|
68
|
-
const base = process.env["
|
|
67
|
+
const base = process.env["PR_SHEPHERD_STATE_DIR"] ?? join(tmpdir(), "pr-shepherd-state");
|
|
69
68
|
return join(base, `${owner}-${repo}`, String(pr), "ready-since.txt");
|
|
70
69
|
}
|
|
71
70
|
async function safeUnlink(path) {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the numbered triage/fix/resolve instruction steps for the agent to follow.
|
|
3
|
+
* Steps are conditionally emitted based on what the fetch returned (mirrors
|
|
4
|
+
* `buildFixInstructions` in `commands/iterate/render.mts`).
|
|
5
|
+
*/
|
|
6
|
+
export function buildFetchInstructions(prNumber, result) {
|
|
7
|
+
const { actionableThreads, actionableComments, changesRequestedReviews, reviewSummaries, commitSuggestionsEnabled, } = result;
|
|
8
|
+
const total = actionableThreads.length +
|
|
9
|
+
actionableComments.length +
|
|
10
|
+
changesRequestedReviews.length +
|
|
11
|
+
reviewSummaries.length;
|
|
12
|
+
if (total === 0) {
|
|
13
|
+
return ["No actionable items — end this invocation."];
|
|
14
|
+
}
|
|
15
|
+
const hasCodeItems = actionableThreads.length > 0 ||
|
|
16
|
+
actionableComments.length > 0 ||
|
|
17
|
+
changesRequestedReviews.length > 0;
|
|
18
|
+
const hasSuggestions = commitSuggestionsEnabled && actionableThreads.some((t) => t.suggestion != null);
|
|
19
|
+
const instructions = [];
|
|
20
|
+
instructions.push(`Classify every item listed above into exactly one of: Fixed / Actionable / Not relevant / Outdated / Acknowledge. Do not silently skip any item. Bot-authored review summaries (authors whose name contains \`[bot]\` or matches \`copilot-pull-request-reviewer\`, \`gemini-code-assist\`) default to Acknowledge with reason "bot summary — no actionable content" unless the body calls out an unaddressed issue.`);
|
|
21
|
+
if (hasSuggestions) {
|
|
22
|
+
instructions.push(`For each Actionable thread marked \`[suggestion]\` in \`## Actionable Review Threads\` above: run \`npx pr-shepherd commit-suggestion ${prNumber} --thread-id <id> --message "<one-sentence headline>" --format=json\`, one thread at a time. On \`applied: true\` mark it Fixed — the CLI already resolved the thread, so exclude the ID from \`--resolve-thread-ids\`. On \`applied: false\` read \`reason\` and \`patch\`, then fall through to the manual fix step — do not retry the same command. Optionally pass \`--dry-run\` (omitting \`--message\`) if you want to inspect the unified diff before it mutates the working tree — the CLI validates with \`git apply --check\`, returns the patch and \`valid: true/false\`, and exits \`1\` on drift without committing or resolving the thread.`);
|
|
23
|
+
}
|
|
24
|
+
if (hasCodeItems) {
|
|
25
|
+
instructions.push(`Read and edit each file referenced under \`## Actionable Review Threads\`, \`## Actionable PR Comments\`, and \`## Pending CHANGES_REQUESTED reviews\` above. Reclassify each fixed item as Fixed. If an item is too complex to address, leave it as Actionable for the final report.`);
|
|
26
|
+
instructions.push(`Commit changed files: \`git add <files>\` (not \`git add -A\`) \`&& git commit -m "<descriptive message>"\`. If the fixes alter the PR's scope or intent, run \`gh pr edit ${prNumber} --title "<new title>" --body "<new body>"\` to reflect them. Then rebase and push: \`BASE_BRANCH=$(gh pr view ${prNumber} --json baseRefName --jq .baseRefName) && git fetch origin && git rebase "origin/$BASE_BRANCH" && git push --force-with-lease\`. Cancel stale in-progress runs: \`BRANCH=$(git rev-parse --abbrev-ref HEAD) && gh run list --branch "$BRANCH" --status in_progress --json databaseId --jq '.[].databaseId' | xargs -I{} gh run cancel {}\`.`);
|
|
27
|
+
}
|
|
28
|
+
const requireShaHint = hasCodeItems
|
|
29
|
+
? ` Include \`--require-sha $(git rev-parse HEAD)\` only when the commit-and-push step above ran.`
|
|
30
|
+
: "";
|
|
31
|
+
const dismissNote = changesRequestedReviews.length > 0
|
|
32
|
+
? ` For \`--dismiss-review-ids\`: \`--message\` is required with one specific sentence describing the fix or the reason for not acting (no boilerplate like "address review comments"); omit \`--message\` when not dismissing. Review-summary IDs (\`PRR_…\` from \`## Review summaries\`) go into \`--minimize-comment-ids\`, never \`--dismiss-review-ids\`.`
|
|
33
|
+
: reviewSummaries.length > 0
|
|
34
|
+
? ` Review-summary IDs (\`PRR_…\` from \`## Review summaries\`) go into \`--minimize-comment-ids\`.`
|
|
35
|
+
: "";
|
|
36
|
+
instructions.push(`Run \`npx pr-shepherd resolve ${prNumber} [--resolve-thread-ids <ids>] [--minimize-comment-ids <ids>] [--dismiss-review-ids <ids> --message "<reason>"]\` with only the non-empty flag subsets. Skip the command entirely if all three ID lists are empty.${requireShaHint}${dismissNote}`);
|
|
37
|
+
instructions.push(`Report: echo the CLI's mutation output, then one line per Acknowledged item: \`Acknowledged <id> (@<author>): <reason>\`. If any fetched item was neither resolved nor acknowledged, stop and escalate: "<N> item(s) fetched but not acted on or acknowledged — need human direction before closing".`);
|
|
38
|
+
return instructions;
|
|
39
|
+
}
|
package/bin/commands/resolve.mjs
CHANGED
|
@@ -18,6 +18,8 @@ import { fetchPrBatch } from "../github/batch.mjs";
|
|
|
18
18
|
import { getOutdatedThreads } from "../comments/outdated.mjs";
|
|
19
19
|
import { autoResolveOutdated, applyResolveOptions } from "../comments/resolve.mjs";
|
|
20
20
|
import { loadConfig } from "../config/load.mjs";
|
|
21
|
+
import { parseSuggestion } from "../suggestions/parse.mjs";
|
|
22
|
+
import { buildFetchInstructions } from "./resolve-instructions.mjs";
|
|
21
23
|
/**
|
|
22
24
|
* Fetch mode: auto-resolve outdated threads and return all active items for LLM triage.
|
|
23
25
|
*/
|
|
@@ -36,16 +38,45 @@ export async function runResolveFetch(opts) {
|
|
|
36
38
|
if (outdated.length > 0) {
|
|
37
39
|
const { errors } = await autoResolveOutdated(outdated.map((t) => t.id));
|
|
38
40
|
if (errors.length > 0) {
|
|
39
|
-
|
|
41
|
+
process.stderr.write(`pr-shepherd: auto-resolve outdated threads failed (continuing): ${errors.join(", ")}\n`);
|
|
40
42
|
}
|
|
41
43
|
}
|
|
42
44
|
const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated);
|
|
43
45
|
const cfg = loadConfig();
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
const actionableThreads = activeThreads.map(({ isResolved: _r, isOutdated: _o, ...rest }) => {
|
|
47
|
+
const thread = rest;
|
|
48
|
+
const suggestion = extractSuggestion(rest);
|
|
49
|
+
if (suggestion)
|
|
50
|
+
thread.suggestion = suggestion;
|
|
51
|
+
return thread;
|
|
52
|
+
});
|
|
53
|
+
const result = {
|
|
54
|
+
prNumber,
|
|
55
|
+
actionableThreads,
|
|
46
56
|
actionableComments: visibleComments,
|
|
47
57
|
changesRequestedReviews: data.changesRequestedReviews,
|
|
48
58
|
reviewSummaries: cfg.resolve.fetchReviewSummaries ? data.reviewSummaries : [],
|
|
59
|
+
commitSuggestionsEnabled: cfg.actions.commitSuggestions,
|
|
60
|
+
};
|
|
61
|
+
return { ...result, instructions: buildFetchInstructions(prNumber, result) };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Attach a parsed suggestion block to a thread if the comment body contains one
|
|
65
|
+
* and the thread has a resolvable line anchor. Threads without `path`/`line`
|
|
66
|
+
* (rare — usually file-level comments) can't accept a suggestion commit.
|
|
67
|
+
*/
|
|
68
|
+
function extractSuggestion(thread) {
|
|
69
|
+
if (!thread.path || thread.line === null)
|
|
70
|
+
return null;
|
|
71
|
+
const parsed = parseSuggestion(thread.body);
|
|
72
|
+
if (!parsed)
|
|
73
|
+
return null;
|
|
74
|
+
const startLine = thread.startLine ?? thread.line;
|
|
75
|
+
return {
|
|
76
|
+
startLine,
|
|
77
|
+
endLine: thread.line,
|
|
78
|
+
lines: parsed.lines,
|
|
79
|
+
author: thread.author,
|
|
49
80
|
};
|
|
50
81
|
}
|
|
51
82
|
/**
|
package/bin/commands/status.mjs
CHANGED
|
@@ -31,8 +31,15 @@ async function fetchSummary(pr, owner, repo) {
|
|
|
31
31
|
// If the response was truncated, fetch additional pages to get the full count.
|
|
32
32
|
if (p.reviewThreads.totalCount > p.reviewThreads.nodes.length) {
|
|
33
33
|
// Fetch additional pages backward until we have all threads.
|
|
34
|
+
const MAX_THREAD_PAGES = 10;
|
|
35
|
+
let pagesFetched = 0;
|
|
36
|
+
const totalCount = p.reviewThreads.totalCount;
|
|
34
37
|
let cursor = p.reviewThreads.pageInfo?.startCursor ?? null;
|
|
35
38
|
while (cursor !== null) {
|
|
39
|
+
if (++pagesFetched > MAX_THREAD_PAGES) {
|
|
40
|
+
process.stderr.write(`pr-shepherd: thread pagination cap reached for PR #${pr}: fetched ${allNodes.length} of ${totalCount} threads\n`);
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
36
43
|
// eslint-disable-next-line no-await-in-loop
|
|
37
44
|
const extra = await graphql(MULTI_PR_STATUS_QUERY_WITH_CURSOR, {
|
|
38
45
|
owner,
|
package/bin/comments/resolve.mjs
CHANGED
|
@@ -37,7 +37,7 @@ export async function applyResolveOptions(pr, repo, opts) {
|
|
|
37
37
|
};
|
|
38
38
|
await runBatched(opts.resolveThreadIds ?? [], (id) => resolveThread(id), result.resolvedThreads, result.errors);
|
|
39
39
|
await runBatched(opts.minimizeCommentIds ?? [], (id) => minimizeComment(id, "RESOLVED"), result.minimizedComments, result.errors);
|
|
40
|
-
await runBatched(opts.dismissReviewIds ?? [], (id) => dismissReview(id, opts.dismissMessage), result.dismissedReviews, result.errors);
|
|
40
|
+
await runBatched(opts.dismissReviewIds ?? [], (id) => dismissReview(id, opts.dismissMessage ?? ""), result.dismissedReviews, result.errors);
|
|
41
41
|
return result;
|
|
42
42
|
}
|
|
43
43
|
/**
|
package/bin/config/load.mjs
CHANGED
|
@@ -36,128 +36,32 @@ function deepMerge(base, override) {
|
|
|
36
36
|
}
|
|
37
37
|
return result;
|
|
38
38
|
}
|
|
39
|
-
// ---------------------------------------------------------------------------
|
|
40
|
-
// Compatibility shim — maps old RC keys to new ones and emits deprecation warnings
|
|
41
|
-
// ---------------------------------------------------------------------------
|
|
42
|
-
function applyCompat(raw) {
|
|
43
|
-
const out = { ...raw };
|
|
44
|
-
// Removed top-level sections — warn and strip.
|
|
45
|
-
if ("execution" in out) {
|
|
46
|
-
process.stderr.write(`pr-shepherd: config section "execution" (maxBufferMb, triageLogBufferMb) has been removed and has no effect.\n`);
|
|
47
|
-
delete out["execution"];
|
|
48
|
-
}
|
|
49
|
-
// Removed keys — warn and strip.
|
|
50
|
-
for (const gone of ["baseBranch", "minimizeBots", "cancelCiOnFailure", "autoMinimize"]) {
|
|
51
|
-
if (gone in out) {
|
|
52
|
-
process.stderr.write(`pr-shepherd: config key "${gone}" has been removed and has no effect.\n`);
|
|
53
|
-
delete out[gone];
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
// Renamed top-level section keys — iterate
|
|
57
|
-
const iterate = out["iterate"];
|
|
58
|
-
if (iterate && "maxFixAttempts" in iterate) {
|
|
59
|
-
process.stderr.write(`pr-shepherd: config key "iterate.maxFixAttempts" renamed to "iterate.fixAttemptsPerThread".\n`);
|
|
60
|
-
out["iterate"] = {
|
|
61
|
-
...iterate,
|
|
62
|
-
fixAttemptsPerThread: iterate["fixAttemptsPerThread"] ?? iterate["maxFixAttempts"],
|
|
63
|
-
};
|
|
64
|
-
delete out["iterate"]["maxFixAttempts"];
|
|
65
|
-
}
|
|
66
|
-
// Renamed watch keys
|
|
67
|
-
const watch = out["watch"];
|
|
68
|
-
if (watch) {
|
|
69
|
-
const watchOut = { ...watch };
|
|
70
|
-
if ("intervalDefault" in watch) {
|
|
71
|
-
process.stderr.write(`pr-shepherd: config key "watch.intervalDefault" renamed to "watch.interval".\n`);
|
|
72
|
-
watchOut["interval"] = watchOut["interval"] ?? watch["intervalDefault"];
|
|
73
|
-
delete watchOut["intervalDefault"];
|
|
74
|
-
}
|
|
75
|
-
if ("readyDelayMinutesDefault" in watch) {
|
|
76
|
-
process.stderr.write(`pr-shepherd: config key "watch.readyDelayMinutesDefault" renamed to "watch.readyDelayMinutes".\n`);
|
|
77
|
-
watchOut["readyDelayMinutes"] =
|
|
78
|
-
watchOut["readyDelayMinutes"] ?? watch["readyDelayMinutesDefault"];
|
|
79
|
-
delete watchOut["readyDelayMinutesDefault"];
|
|
80
|
-
}
|
|
81
|
-
if ("expiresHoursDefault" in watch) {
|
|
82
|
-
process.stderr.write(`pr-shepherd: config key "watch.expiresHoursDefault" renamed to "watch.expiresHours".\n`);
|
|
83
|
-
watchOut["expiresHours"] = watchOut["expiresHours"] ?? watch["expiresHoursDefault"];
|
|
84
|
-
delete watchOut["expiresHoursDefault"];
|
|
85
|
-
}
|
|
86
|
-
out["watch"] = watchOut;
|
|
87
|
-
}
|
|
88
|
-
// Renamed resolve keys (shaPollIntervalMs / shaPollMaxAttempts → shaPoll object)
|
|
89
|
-
const resolve = out["resolve"];
|
|
90
|
-
if (resolve) {
|
|
91
|
-
const resolveOut = { ...resolve };
|
|
92
|
-
const shaPollOut = {};
|
|
93
|
-
let shaPollChanged = false;
|
|
94
|
-
if ("shaPollIntervalMs" in resolve) {
|
|
95
|
-
process.stderr.write(`pr-shepherd: config key "resolve.shaPollIntervalMs" moved to "resolve.shaPoll.intervalMs".\n`);
|
|
96
|
-
shaPollOut["intervalMs"] =
|
|
97
|
-
resolve["shaPoll"]?.["intervalMs"] ??
|
|
98
|
-
resolve["shaPollIntervalMs"];
|
|
99
|
-
delete resolveOut["shaPollIntervalMs"];
|
|
100
|
-
shaPollChanged = true;
|
|
101
|
-
}
|
|
102
|
-
if ("shaPollMaxAttempts" in resolve) {
|
|
103
|
-
process.stderr.write(`pr-shepherd: config key "resolve.shaPollMaxAttempts" moved to "resolve.shaPoll.maxAttempts".\n`);
|
|
104
|
-
shaPollOut["maxAttempts"] =
|
|
105
|
-
resolve["shaPoll"]?.["maxAttempts"] ??
|
|
106
|
-
resolve["shaPollMaxAttempts"];
|
|
107
|
-
delete resolveOut["shaPollMaxAttempts"];
|
|
108
|
-
shaPollChanged = true;
|
|
109
|
-
}
|
|
110
|
-
if (shaPollChanged) {
|
|
111
|
-
resolveOut["shaPoll"] = {
|
|
112
|
-
...resolveOut["shaPoll"],
|
|
113
|
-
...shaPollOut,
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
out["resolve"] = resolveOut;
|
|
117
|
-
}
|
|
118
|
-
// Renamed checks keys
|
|
119
|
-
const checks = out["checks"];
|
|
120
|
-
if (checks) {
|
|
121
|
-
const checksOut = { ...checks };
|
|
122
|
-
if ("relevantEvents" in checks) {
|
|
123
|
-
process.stderr.write(`pr-shepherd: config key "checks.relevantEvents" renamed to "checks.ciTriggerEvents".\n`);
|
|
124
|
-
checksOut["ciTriggerEvents"] = checksOut["ciTriggerEvents"] ?? checks["relevantEvents"];
|
|
125
|
-
delete checksOut["relevantEvents"];
|
|
126
|
-
}
|
|
127
|
-
if ("logLinesKept" in checks) {
|
|
128
|
-
process.stderr.write(`pr-shepherd: config key "checks.logLinesKept" renamed to "checks.logMaxLines".\n`);
|
|
129
|
-
checksOut["logMaxLines"] = checksOut["logMaxLines"] ?? checks["logLinesKept"];
|
|
130
|
-
delete checksOut["logLinesKept"];
|
|
131
|
-
}
|
|
132
|
-
if ("logExcerptMaxChars" in checks) {
|
|
133
|
-
process.stderr.write(`pr-shepherd: config key "checks.logExcerptMaxChars" renamed to "checks.logMaxChars".\n`);
|
|
134
|
-
checksOut["logMaxChars"] = checksOut["logMaxChars"] ?? checks["logExcerptMaxChars"];
|
|
135
|
-
delete checksOut["logExcerptMaxChars"];
|
|
136
|
-
}
|
|
137
|
-
out["checks"] = checksOut;
|
|
138
|
-
}
|
|
139
|
-
return out;
|
|
140
|
-
}
|
|
141
39
|
const defaults = builtins;
|
|
142
|
-
|
|
40
|
+
const configCache = new Map();
|
|
143
41
|
export function loadConfig() {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
42
|
+
const cwd = process.cwd();
|
|
43
|
+
if (configCache.has(cwd))
|
|
44
|
+
return configCache.get(cwd);
|
|
45
|
+
const rcPath = findRcFile(cwd);
|
|
147
46
|
if (!rcPath) {
|
|
148
|
-
|
|
149
|
-
return
|
|
47
|
+
configCache.set(cwd, defaults);
|
|
48
|
+
return defaults;
|
|
150
49
|
}
|
|
151
50
|
try {
|
|
152
51
|
const raw = readFileSync(rcPath, "utf8");
|
|
153
52
|
const parsed = (parse(raw) ?? {});
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
return
|
|
53
|
+
const config = deepMerge(defaults, parsed);
|
|
54
|
+
configCache.set(cwd, config);
|
|
55
|
+
return config;
|
|
157
56
|
}
|
|
158
57
|
catch (err) {
|
|
159
58
|
process.stderr.write(`pr-shepherd: failed to parse ${rcPath}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
160
|
-
|
|
161
|
-
|
|
59
|
+
const fallback = { ...defaults };
|
|
60
|
+
configCache.set(cwd, fallback);
|
|
61
|
+
return fallback;
|
|
162
62
|
}
|
|
163
63
|
}
|
|
64
|
+
/** Reset the config cache — for use in tests that change directories. */
|
|
65
|
+
export function _resetConfigCache() {
|
|
66
|
+
configCache.clear();
|
|
67
|
+
}
|
package/bin/config.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
-
"cache": {
|
|
3
|
-
"ttlSeconds": 300
|
|
4
|
-
},
|
|
5
2
|
"iterate": {
|
|
6
3
|
"cooldownSeconds": 30,
|
|
7
|
-
"fixAttemptsPerThread": 3
|
|
4
|
+
"fixAttemptsPerThread": 3,
|
|
5
|
+
"stallTimeoutMinutes": 30,
|
|
6
|
+
"minimizeReviewSummaries": {
|
|
7
|
+
"bots": true,
|
|
8
|
+
"humans": true,
|
|
9
|
+
"approvals": false
|
|
10
|
+
}
|
|
8
11
|
},
|
|
9
12
|
"watch": {
|
|
10
13
|
"interval": "4m",
|
|
@@ -21,29 +24,14 @@
|
|
|
21
24
|
"fetchReviewSummaries": true
|
|
22
25
|
},
|
|
23
26
|
"checks": {
|
|
24
|
-
"ciTriggerEvents": ["pull_request", "pull_request_target"]
|
|
25
|
-
"timeoutPatterns": [
|
|
26
|
-
"cancel timeout",
|
|
27
|
-
"exceeded the maximum execution time",
|
|
28
|
-
"job was cancelled"
|
|
29
|
-
],
|
|
30
|
-
"infraPatterns": [
|
|
31
|
-
"runner error",
|
|
32
|
-
"service unavailable",
|
|
33
|
-
"ETIMEOUT",
|
|
34
|
-
"ECONNRESET",
|
|
35
|
-
"lost communication with the server",
|
|
36
|
-
"the hosted runner lost connection"
|
|
37
|
-
],
|
|
38
|
-
"logMaxLines": 50,
|
|
39
|
-
"logMaxChars": 3000
|
|
27
|
+
"ciTriggerEvents": ["pull_request", "pull_request_target"]
|
|
40
28
|
},
|
|
41
29
|
"mergeStatus": {
|
|
42
30
|
"blockingReviewerLogins": ["copilot"]
|
|
43
31
|
},
|
|
44
32
|
"actions": {
|
|
45
33
|
"autoResolveOutdated": true,
|
|
46
|
-
"
|
|
47
|
-
"
|
|
34
|
+
"autoMarkReady": true,
|
|
35
|
+
"commitSuggestions": true
|
|
48
36
|
}
|
|
49
37
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawReviewSummaryNodes, rawApprovedReviewNodes, rawCheckNodes) {
|
|
2
|
+
const reviewRequests = (raw.reviewRequests?.nodes ?? []).flatMap((n) => {
|
|
3
|
+
const login = n.requestedReviewer?.login ?? n.requestedReviewer?.name;
|
|
4
|
+
return login ? [{ login }] : [];
|
|
5
|
+
});
|
|
6
|
+
const latestReviews = (raw.latestReviews?.nodes ?? []).map((n) => ({
|
|
7
|
+
login: n.author?.login ?? "unknown",
|
|
8
|
+
state: n.state,
|
|
9
|
+
}));
|
|
10
|
+
const reviewThreads = rawThreadPages.map((t) => {
|
|
11
|
+
const comment = t.comments.nodes[0];
|
|
12
|
+
return {
|
|
13
|
+
id: t.id,
|
|
14
|
+
isResolved: t.isResolved,
|
|
15
|
+
isOutdated: t.isOutdated,
|
|
16
|
+
isMinimized: comment?.isMinimized ?? false,
|
|
17
|
+
path: comment?.path ?? null,
|
|
18
|
+
line: comment?.line ?? null,
|
|
19
|
+
startLine: comment?.startLine ?? null,
|
|
20
|
+
author: comment?.author?.login ?? "unknown",
|
|
21
|
+
body: comment?.body ?? "",
|
|
22
|
+
createdAtUnix: comment ? parseCreatedAt(comment.createdAt) : 0,
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
const comments = rawCommentNodes.map((c) => ({
|
|
26
|
+
id: c.id,
|
|
27
|
+
isMinimized: c.isMinimized,
|
|
28
|
+
author: c.author?.login ?? "unknown",
|
|
29
|
+
body: c.body,
|
|
30
|
+
createdAtUnix: parseCreatedAt(c.createdAt),
|
|
31
|
+
}));
|
|
32
|
+
const changesRequestedReviews = rawReviewNodes.map((r) => ({
|
|
33
|
+
id: r.id,
|
|
34
|
+
author: r.author?.login ?? "unknown",
|
|
35
|
+
body: r.body,
|
|
36
|
+
}));
|
|
37
|
+
const reviewSummaries = rawReviewSummaryNodes
|
|
38
|
+
.filter((r) => !r.isMinimized && r.body.trim() !== "")
|
|
39
|
+
.map((r) => ({
|
|
40
|
+
id: r.id,
|
|
41
|
+
author: r.author?.login ?? "unknown",
|
|
42
|
+
body: r.body,
|
|
43
|
+
}));
|
|
44
|
+
// APPROVED reviews often have empty bodies (clicking "Approve" without a comment), so
|
|
45
|
+
// we keep them — only the isMinimized filter applies. Monitor/iterate uses these IDs
|
|
46
|
+
// when the user opts in to minimizing approvals.
|
|
47
|
+
const approvedReviews = rawApprovedReviewNodes
|
|
48
|
+
.filter((r) => !r.isMinimized)
|
|
49
|
+
.map((r) => ({
|
|
50
|
+
id: r.id,
|
|
51
|
+
author: r.author?.login ?? "unknown",
|
|
52
|
+
body: r.body,
|
|
53
|
+
}));
|
|
54
|
+
const checks = rawCheckNodes.flatMap((node) => {
|
|
55
|
+
if (node.__typename === "CheckRun") {
|
|
56
|
+
const event = node.checkSuite?.workflowRun?.event ?? null;
|
|
57
|
+
const runId = extractRunId(node.detailsUrl);
|
|
58
|
+
const summary = extractCheckRunSummary(node.title, node.summary);
|
|
59
|
+
return [
|
|
60
|
+
{
|
|
61
|
+
name: node.name,
|
|
62
|
+
status: node.status,
|
|
63
|
+
conclusion: node.conclusion,
|
|
64
|
+
detailsUrl: node.detailsUrl ?? "",
|
|
65
|
+
event,
|
|
66
|
+
runId,
|
|
67
|
+
...(summary !== undefined && { summary }),
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
if (node.__typename === "StatusContext") {
|
|
72
|
+
const { status, conclusion } = mapStatusContextState(node.state);
|
|
73
|
+
const summary = node.description?.trim() || undefined;
|
|
74
|
+
return [
|
|
75
|
+
{
|
|
76
|
+
name: node.context,
|
|
77
|
+
status,
|
|
78
|
+
conclusion,
|
|
79
|
+
detailsUrl: node.targetUrl ?? "",
|
|
80
|
+
event: null,
|
|
81
|
+
runId: null,
|
|
82
|
+
...(summary !== undefined && { summary }),
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
return [];
|
|
87
|
+
});
|
|
88
|
+
return {
|
|
89
|
+
nodeId: raw.id,
|
|
90
|
+
number: raw.number,
|
|
91
|
+
state: raw.state,
|
|
92
|
+
isDraft: raw.isDraft,
|
|
93
|
+
mergeable: raw.mergeable,
|
|
94
|
+
mergeStateStatus: raw.mergeStateStatus,
|
|
95
|
+
reviewDecision: (raw.reviewDecision ?? null),
|
|
96
|
+
headRefOid: raw.headRefOid,
|
|
97
|
+
baseRefName: raw.baseRefName,
|
|
98
|
+
reviewRequests,
|
|
99
|
+
latestReviews,
|
|
100
|
+
reviewThreads,
|
|
101
|
+
comments,
|
|
102
|
+
changesRequestedReviews,
|
|
103
|
+
reviewSummaries,
|
|
104
|
+
approvedReviews,
|
|
105
|
+
checks,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function parseCreatedAt(iso) {
|
|
109
|
+
const ms = new Date(iso).getTime();
|
|
110
|
+
return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
|
|
111
|
+
}
|
|
112
|
+
function extractRunId(url) {
|
|
113
|
+
if (!url)
|
|
114
|
+
return null;
|
|
115
|
+
const m = /\/runs\/(\d+)/.exec(url);
|
|
116
|
+
return m ? (m[1] ?? null) : null;
|
|
117
|
+
}
|
|
118
|
+
function extractCheckRunSummary(title, summary) {
|
|
119
|
+
const t = title?.trim();
|
|
120
|
+
if (t)
|
|
121
|
+
return t;
|
|
122
|
+
const firstLine = summary
|
|
123
|
+
?.split("\n")
|
|
124
|
+
?.find((l) => l.trim() !== "")
|
|
125
|
+
?.trim();
|
|
126
|
+
return firstLine || undefined;
|
|
127
|
+
}
|
|
128
|
+
function mapStatusContextState(state) {
|
|
129
|
+
switch (state) {
|
|
130
|
+
case "SUCCESS":
|
|
131
|
+
return { status: "COMPLETED", conclusion: "SUCCESS" };
|
|
132
|
+
case "FAILURE":
|
|
133
|
+
case "ERROR":
|
|
134
|
+
return { status: "COMPLETED", conclusion: "FAILURE" };
|
|
135
|
+
case "PENDING":
|
|
136
|
+
case "EXPECTED":
|
|
137
|
+
default:
|
|
138
|
+
return { status: "IN_PROGRESS", conclusion: null };
|
|
139
|
+
}
|
|
140
|
+
}
|