pr-shepherd 0.25.4 → 0.26.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 +23 -0
- package/bin/checks/classify.mjs +10 -2
- package/bin/classify/apply.mjs +110 -0
- package/bin/classify/loader.mjs +85 -0
- package/bin/classify/types.mjs +1 -0
- package/bin/cli/args.mjs +1 -0
- package/bin/cli/default-poll.mjs +1 -0
- package/bin/cli/fix-formatter.mjs +3 -4
- package/bin/cli/help-command-pages.mjs +20 -1
- package/bin/cli/help-top-page.mjs +3 -0
- package/bin/cli/iterate-formatter.mjs +42 -7
- package/bin/cli/iterate-instructions.mjs +3 -22
- package/bin/cli/iterate-lean.mjs +32 -9
- package/bin/cli/journal-handler.mjs +79 -0
- package/bin/cli/poll-handler.mjs +1 -0
- package/bin/cli-parser.mjs +5 -23
- package/bin/commands/check-terminal-report.mjs +1 -0
- package/bin/commands/check.mjs +36 -8
- package/bin/commands/iterate/classify.mjs +14 -5
- package/bin/commands/iterate/fix-code.mjs +2 -2
- package/bin/commands/iterate/helpers.mjs +15 -0
- package/bin/commands/iterate/index.mjs +8 -2
- package/bin/commands/iterate/render.mjs +15 -9
- package/bin/commands/iterate/stall.mjs +4 -0
- package/bin/commands/journal/index.mjs +26 -0
- package/bin/commands/journal/transform.mjs +112 -0
- package/bin/commands/poll.mjs +45 -3
- package/bin/commands/ready-mergeability.mjs +2 -2
- package/bin/commands/shepherd-journal.mjs +2 -2
- package/bin/config/load.mjs +7 -0
- package/bin/config.json +1 -0
- package/bin/github/activity.mjs +57 -0
- package/bin/github/batch-parsers.mjs +20 -34
- package/bin/github/branch-protection.mjs +12 -0
- package/bin/github/client.mjs +17 -1
- package/bin/github/gql/batch-pr.gql +8 -0
- package/bin/github/gql/get-pr-body.gql +8 -0
- package/bin/github/gql/update-pr-body.gql +7 -0
- package/bin/github/queries.mjs +4 -0
- package/bin/types/activity.mjs +1 -0
- package/bin/types/iterate.mjs +0 -1
- package/bin/types.mjs +1 -0
- package/package.json +12 -2
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +7 -22
- package/src/classify/types.mts +48 -0
package/bin/commands/poll.mjs
CHANGED
|
@@ -10,6 +10,37 @@ function writeTickProgress(tick, elapsedSeconds, sleepSeconds, verbose) {
|
|
|
10
10
|
process.stderr.write(".");
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
|
+
function waitSignature(result) {
|
|
14
|
+
const activity = result.activity ?? {
|
|
15
|
+
commitCount: 0,
|
|
16
|
+
reviewRoundCount: 0,
|
|
17
|
+
latestCommitCommittedAtUnix: null,
|
|
18
|
+
reviewItemsSinceLatestCommit: [],
|
|
19
|
+
};
|
|
20
|
+
return JSON.stringify({
|
|
21
|
+
status: result.status,
|
|
22
|
+
mergeStateStatus: result.mergeStateStatus,
|
|
23
|
+
reviewDecision: result.reviewDecision,
|
|
24
|
+
state: result.state,
|
|
25
|
+
active: (result.inProgressChecks ?? []).map((c) => [c.name, c.status, c.runId]),
|
|
26
|
+
commitCount: activity.commitCount,
|
|
27
|
+
latestCommitCommittedAtUnix: activity.latestCommitCommittedAtUnix,
|
|
28
|
+
reviewRoundCount: activity.reviewRoundCount,
|
|
29
|
+
reviewItemsSinceLatestCommit: activity.reviewItemsSinceLatestCommit.length,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function writeQuietStatus(tick, elapsedSeconds, sleepSeconds, result) {
|
|
33
|
+
const activeChecks = result.inProgressChecks ?? [];
|
|
34
|
+
const activeCheckText = activeChecks.map((c) => `${c.name} (${c.status})`).join(", ");
|
|
35
|
+
const active = activeChecks.length > 0 ? ` · active: ${activeCheckText}` : "";
|
|
36
|
+
const commitCount = result.activity?.commitCount ?? 0;
|
|
37
|
+
const reviewItems = result.activity?.reviewItemsSinceLatestCommit.length ?? 0;
|
|
38
|
+
const reviewRounds = result.activity?.reviewRoundCount ?? 0;
|
|
39
|
+
const commitSeg = commitCount > 0 ? ` · ${commitCount} commits` : "";
|
|
40
|
+
const reviewRoundSeg = reviewRounds > 0 ? ` · ${reviewRounds} review rounds` : "";
|
|
41
|
+
const reviewSeg = reviewItems > 0 ? ` · ${reviewItems} review items since latest commit` : "";
|
|
42
|
+
process.stderr.write(`[poll tick ${tick} / +${elapsedSeconds}s] WAIT ${result.status}/${result.mergeStateStatus}/${result.reviewDecision ?? "NO_REVIEW_DECISION"}${active}${commitSeg}${reviewRoundSeg}${reviewSeg} — sleeping ${sleepSeconds}s\n`);
|
|
43
|
+
}
|
|
13
44
|
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
14
45
|
export async function runPoll(opts) {
|
|
15
46
|
const { intervalSeconds, timeoutSeconds, ...iterateOpts } = opts;
|
|
@@ -19,7 +50,9 @@ export async function runPoll(opts) {
|
|
|
19
50
|
let tick = 0;
|
|
20
51
|
let lastResult;
|
|
21
52
|
const verbose = opts.verbose === true;
|
|
53
|
+
const quietStatus = opts.quietStatus === true;
|
|
22
54
|
let dotsPrinted = false;
|
|
55
|
+
let lastWaitSignature = null;
|
|
23
56
|
while (true) {
|
|
24
57
|
tick += 1;
|
|
25
58
|
lastResult = await runIterate(iterateOpts);
|
|
@@ -30,9 +63,18 @@ export async function runPoll(opts) {
|
|
|
30
63
|
if (remainingMs <= 0)
|
|
31
64
|
break;
|
|
32
65
|
const nextSleepMs = Math.min(intervalMs, remainingMs);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
66
|
+
if (quietStatus) {
|
|
67
|
+
const signature = waitSignature(lastResult);
|
|
68
|
+
if (signature !== lastWaitSignature) {
|
|
69
|
+
writeQuietStatus(tick, Math.round(elapsedMs / 1000), Math.round(nextSleepMs / 1000), lastResult);
|
|
70
|
+
}
|
|
71
|
+
lastWaitSignature = signature;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
writeTickProgress(tick, Math.round(elapsedMs / 1000), Math.round(nextSleepMs / 1000), verbose);
|
|
75
|
+
if (!verbose)
|
|
76
|
+
dotsPrinted = true;
|
|
77
|
+
}
|
|
36
78
|
await sleep(nextSleepMs);
|
|
37
79
|
}
|
|
38
80
|
if (dotsPrinted)
|
|
@@ -8,10 +8,10 @@ export async function refreshUnknownMergeability(prNumber, repo, batchData) {
|
|
|
8
8
|
}
|
|
9
9
|
return { batchData: await readMergeability(prNumber, repo, batchData), didRefresh: true };
|
|
10
10
|
}
|
|
11
|
-
export async function refreshReadyMergeability(prNumber, repo, batchData, verdict, unresolvedThreads, unresolvedComments) {
|
|
11
|
+
export async function refreshReadyMergeability(prNumber, repo, batchData, verdict, unresolvedThreads, unresolvedComments, changesRequestedCount) {
|
|
12
12
|
const refreshedBatchData = await readMergeability(prNumber, repo, batchData);
|
|
13
13
|
const mergeStatus = deriveMergeStatus(refreshedBatchData);
|
|
14
|
-
const status = computeStatus(verdict, unresolvedThreads, unresolvedComments, mergeStatus,
|
|
14
|
+
const status = computeStatus(verdict, unresolvedThreads, unresolvedComments, mergeStatus, changesRequestedCount);
|
|
15
15
|
return { batchData: refreshedBatchData, mergeStatus, status };
|
|
16
16
|
}
|
|
17
17
|
export function isBlockedByFilteredCheck(mergeStatus, verdict) {
|
|
@@ -4,9 +4,9 @@ export const SHEPHERD_JOURNAL_APPEND_HINT = "If this section already exists, app
|
|
|
4
4
|
export const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = "Review the bodies shown under `## Review summaries (first look)` — you are seeing these for the first time. Eligible non-human IDs, when present, are already included in `--minimize-comment-ids` in the resolve or resolve-only command above; if any warrants a Shepherd Journal note, append it before running resolve.";
|
|
5
5
|
export function buildShepherdJournalInstruction(prNumber, itemReferenceGuidance) {
|
|
6
6
|
return [
|
|
7
|
-
`For any large decisions or rejections you made this iteration,
|
|
7
|
+
`For any large decisions or rejections you made this iteration, run \`pr-shepherd journal ${prNumber} '- <decision>'\` to append an entry to the \`${SHEPHERD_JOURNAL_SECTION}\` section.`,
|
|
8
8
|
itemReferenceGuidance,
|
|
9
|
-
|
|
9
|
+
`The command is idempotent — re-running with the same text is a no-op.`,
|
|
10
10
|
].join(" ");
|
|
11
11
|
}
|
|
12
12
|
export const SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS = "For threads and comments, use the markdown link shown in its heading above; for reviews, reference the review ID.";
|
package/bin/config/load.mjs
CHANGED
|
@@ -51,6 +51,12 @@ function parseBotUsernames(value) {
|
|
|
51
51
|
}
|
|
52
52
|
return value;
|
|
53
53
|
}
|
|
54
|
+
function parseIgnoreChecks(value) {
|
|
55
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
|
|
56
|
+
throw new Error(`Invalid config: ignoreChecks must be an array of strings`);
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
54
60
|
const defaults = builtins;
|
|
55
61
|
const configCache = new Map();
|
|
56
62
|
export function loadConfig() {
|
|
@@ -67,6 +73,7 @@ export function loadConfig() {
|
|
|
67
73
|
const parsed = (parse(raw) ?? {});
|
|
68
74
|
const config = deepMerge(defaults, parsed);
|
|
69
75
|
config.botUsernames = parseBotUsernames(config.botUsernames);
|
|
76
|
+
config.ignoreChecks = parseIgnoreChecks(config.ignoreChecks);
|
|
70
77
|
config.iterate.minimizeComments = parseMinimizeCommentsPolicy(config.iterate.minimizeComments);
|
|
71
78
|
configCache.set(cwd, config);
|
|
72
79
|
return config;
|
package/bin/config.json
CHANGED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { parseCreatedAt } from "./batch-parser-helpers.mjs";
|
|
2
|
+
function reviewActivityItems(reviews, latestCommitCommittedAtUnix, kind) {
|
|
3
|
+
return reviews
|
|
4
|
+
.filter((r) => (r.createdAtUnix ?? 0) > latestCommitCommittedAtUnix)
|
|
5
|
+
.filter((r) => r.body.trim() !== "")
|
|
6
|
+
.map((r) => ({
|
|
7
|
+
kind,
|
|
8
|
+
id: r.id,
|
|
9
|
+
author: r.author,
|
|
10
|
+
authorType: r.authorType,
|
|
11
|
+
body: r.body,
|
|
12
|
+
createdAtUnix: r.createdAtUnix ?? 0,
|
|
13
|
+
}));
|
|
14
|
+
}
|
|
15
|
+
export function buildPrActivitySummary(raw, comments, reviewThreads, reviewSummaries, changesRequestedReviews, approvedReviews) {
|
|
16
|
+
const latestCommitCommittedAtUnix = raw.commits.nodes[0]?.commit.committedDate
|
|
17
|
+
? parseCreatedAt(raw.commits.nodes[0].commit.committedDate)
|
|
18
|
+
: null;
|
|
19
|
+
const reviewItemsSinceLatestCommit = latestCommitCommittedAtUnix === null
|
|
20
|
+
? []
|
|
21
|
+
: [
|
|
22
|
+
...comments
|
|
23
|
+
.filter((c) => c.createdAtUnix > latestCommitCommittedAtUnix)
|
|
24
|
+
.map((c) => ({
|
|
25
|
+
kind: "pr-comment",
|
|
26
|
+
id: c.id,
|
|
27
|
+
author: c.author,
|
|
28
|
+
authorType: c.authorType,
|
|
29
|
+
body: c.body,
|
|
30
|
+
url: c.url,
|
|
31
|
+
createdAtUnix: c.createdAtUnix,
|
|
32
|
+
})),
|
|
33
|
+
...reviewThreads.flatMap((t) => (t.comments ?? [])
|
|
34
|
+
.filter((c) => c.createdAtUnix > latestCommitCommittedAtUnix)
|
|
35
|
+
.map((c) => ({
|
|
36
|
+
kind: "review-thread-comment",
|
|
37
|
+
id: c.id,
|
|
38
|
+
author: c.author,
|
|
39
|
+
authorType: c.authorType,
|
|
40
|
+
body: c.body,
|
|
41
|
+
url: c.url,
|
|
42
|
+
createdAtUnix: c.createdAtUnix,
|
|
43
|
+
threadId: t.id,
|
|
44
|
+
path: t.path,
|
|
45
|
+
line: t.line,
|
|
46
|
+
}))),
|
|
47
|
+
...reviewActivityItems(reviewSummaries, latestCommitCommittedAtUnix, "review-summary"),
|
|
48
|
+
...reviewActivityItems(changesRequestedReviews, latestCommitCommittedAtUnix, "changes-requested-review"),
|
|
49
|
+
...reviewActivityItems(approvedReviews, latestCommitCommittedAtUnix, "approved-review"),
|
|
50
|
+
].sort((a, b) => a.createdAtUnix - b.createdAtUnix);
|
|
51
|
+
return {
|
|
52
|
+
commitCount: raw.commits.totalCount ?? raw.commits.nodes.length,
|
|
53
|
+
reviewRoundCount: raw.allReviews?.totalCount ?? 0,
|
|
54
|
+
latestCommitCommittedAtUnix,
|
|
55
|
+
reviewItemsSinceLatestCommit,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
import { mapAuthorType, parseCreatedAt, extractRunId, extractCheckRunSummary, mapStatusContextState, latestApprovedLogins, } from "./batch-parser-helpers.mjs";
|
|
2
|
+
import { buildPrActivitySummary } from "./activity.mjs";
|
|
3
|
+
import { parseBranchProtection } from "./branch-protection.mjs";
|
|
4
|
+
function parseReviewNode(r) {
|
|
5
|
+
return {
|
|
6
|
+
id: r.id,
|
|
7
|
+
author: r.author?.login ?? "unknown",
|
|
8
|
+
authorType: mapAuthorType(r.author?.__typename, r.author?.login),
|
|
9
|
+
body: r.body,
|
|
10
|
+
createdAtUnix: r.createdAt ? parseCreatedAt(r.createdAt) : 0,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
2
13
|
export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawReviewSummaryNodes, rawApprovedReviewNodes, rawCheckNodes) {
|
|
3
14
|
const reviewRequests = (raw.reviewRequests?.nodes ?? []).flatMap((n) => {
|
|
4
15
|
const login = n.requestedReviewer?.login ?? n.requestedReviewer?.name;
|
|
@@ -19,7 +30,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
19
30
|
authorType: mapAuthorType(c.author?.__typename, c.author?.login),
|
|
20
31
|
body: c.body,
|
|
21
32
|
url: c.url,
|
|
22
|
-
createdAtUnix: parseCreatedAt(c.createdAt),
|
|
33
|
+
createdAtUnix: c.createdAt ? parseCreatedAt(c.createdAt) : 0,
|
|
23
34
|
}));
|
|
24
35
|
return {
|
|
25
36
|
id: t.id,
|
|
@@ -34,7 +45,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
34
45
|
authorType: mapAuthorType(comment?.author?.__typename, comment?.author?.login),
|
|
35
46
|
body: comment?.body ?? "",
|
|
36
47
|
url: comment?.url ?? "",
|
|
37
|
-
createdAtUnix: comment ? parseCreatedAt(comment.createdAt) : 0,
|
|
48
|
+
createdAtUnix: comment?.createdAt ? parseCreatedAt(comment.createdAt) : 0,
|
|
38
49
|
comments,
|
|
39
50
|
};
|
|
40
51
|
});
|
|
@@ -45,35 +56,19 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
45
56
|
authorType: mapAuthorType(c.author?.__typename, c.author?.login),
|
|
46
57
|
body: c.body,
|
|
47
58
|
url: c.url,
|
|
48
|
-
createdAtUnix: parseCreatedAt(c.createdAt),
|
|
49
|
-
}));
|
|
50
|
-
const changesRequestedReviews = rawReviewNodes
|
|
51
|
-
.filter((r) => !crDone.has(r.author?.login ?? "unknown"))
|
|
52
|
-
.map((r) => ({
|
|
53
|
-
id: r.id,
|
|
54
|
-
author: r.author?.login ?? "unknown",
|
|
55
|
-
authorType: mapAuthorType(r.author?.__typename, r.author?.login),
|
|
56
|
-
body: r.body,
|
|
59
|
+
createdAtUnix: c.createdAt ? parseCreatedAt(c.createdAt) : 0,
|
|
57
60
|
}));
|
|
61
|
+
const allChangesRequestedReviews = rawReviewNodes.map((r) => parseReviewNode(r));
|
|
62
|
+
const changesRequestedReviews = allChangesRequestedReviews.filter((r) => !crDone.has(r.author));
|
|
58
63
|
const reviewSummaries = rawReviewSummaryNodes
|
|
59
64
|
.filter((r) => !r.isMinimized && r.body.trim() !== "")
|
|
60
|
-
.map((r) => (
|
|
61
|
-
id: r.id,
|
|
62
|
-
author: r.author?.login ?? "unknown",
|
|
63
|
-
authorType: mapAuthorType(r.author?.__typename, r.author?.login),
|
|
64
|
-
body: r.body,
|
|
65
|
-
}));
|
|
65
|
+
.map((r) => parseReviewNode(r));
|
|
66
66
|
// APPROVED reviews often have empty bodies (clicking "Approve" without a comment), so
|
|
67
67
|
// we keep them — only the isMinimized filter applies. Monitor/iterate uses these IDs
|
|
68
68
|
// when the user opts in to minimizing approvals.
|
|
69
69
|
const approvedReviews = rawApprovedReviewNodes
|
|
70
70
|
.filter((r) => !r.isMinimized)
|
|
71
|
-
.map((r) => (
|
|
72
|
-
id: r.id,
|
|
73
|
-
author: r.author?.login ?? "unknown",
|
|
74
|
-
authorType: mapAuthorType(r.author?.__typename, r.author?.login),
|
|
75
|
-
body: r.body,
|
|
76
|
-
}));
|
|
71
|
+
.map((r) => parseReviewNode(r));
|
|
77
72
|
const checks = rawCheckNodes.flatMap((node) => {
|
|
78
73
|
if (node.__typename === "CheckRun") {
|
|
79
74
|
const event = node.checkSuite?.workflowRun?.event ?? null;
|
|
@@ -125,16 +120,6 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
125
120
|
}
|
|
126
121
|
return [];
|
|
127
122
|
});
|
|
128
|
-
const rawProtection = raw.baseRef?.branchProtectionRule ?? null;
|
|
129
|
-
const branchProtection = rawProtection
|
|
130
|
-
? {
|
|
131
|
-
requiresApprovingReviews: rawProtection.requiresApprovingReviews,
|
|
132
|
-
requiredApprovingReviewCount: rawProtection.requiredApprovingReviewCount,
|
|
133
|
-
requiresConversationResolution: rawProtection.requiresConversationResolution,
|
|
134
|
-
requiresStatusChecks: rawProtection.requiresStatusChecks,
|
|
135
|
-
requiredStatusCheckContexts: rawProtection.requiredStatusCheckContexts ?? [],
|
|
136
|
-
}
|
|
137
|
-
: null;
|
|
138
123
|
return {
|
|
139
124
|
nodeId: raw.id,
|
|
140
125
|
number: raw.number,
|
|
@@ -155,6 +140,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
155
140
|
reviewSummaries,
|
|
156
141
|
approvedReviews,
|
|
157
142
|
checks,
|
|
158
|
-
branchProtection,
|
|
143
|
+
branchProtection: parseBranchProtection(raw),
|
|
144
|
+
activity: buildPrActivitySummary(raw, comments, reviewThreads, reviewSummaries, allChangesRequestedReviews, approvedReviews),
|
|
159
145
|
};
|
|
160
146
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function parseBranchProtection(raw) {
|
|
2
|
+
const rule = raw.baseRef?.branchProtectionRule ?? null;
|
|
3
|
+
return rule
|
|
4
|
+
? {
|
|
5
|
+
requiresApprovingReviews: rule.requiresApprovingReviews,
|
|
6
|
+
requiredApprovingReviewCount: rule.requiredApprovingReviewCount,
|
|
7
|
+
requiresConversationResolution: rule.requiresConversationResolution,
|
|
8
|
+
requiresStatusChecks: rule.requiresStatusChecks,
|
|
9
|
+
requiredStatusCheckContexts: rule.requiredStatusCheckContexts ?? [],
|
|
10
|
+
}
|
|
11
|
+
: null;
|
|
12
|
+
}
|
package/bin/github/client.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { execFile as execFileCb } from "node:child_process";
|
|
9
9
|
import { promisify } from "node:util";
|
|
10
10
|
import { graphql as httpGraphql, rest } from "./http.mjs";
|
|
11
|
-
import { PR_NUMBER_BY_BRANCH_QUERY, GET_PR_HEAD_SHA_QUERY } from "./queries.mjs";
|
|
11
|
+
import { PR_NUMBER_BY_BRANCH_QUERY, GET_PR_HEAD_SHA_QUERY, GET_PR_BODY_QUERY, UPDATE_PR_BODY_MUTATION, } from "./queries.mjs";
|
|
12
12
|
const execFile = promisify(execFileCb);
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
14
14
|
// GraphQL — thin re-exports so callers don't need to import http.mts directly
|
|
@@ -63,6 +63,22 @@ export async function getPrHeadSha(pr, owner, name) {
|
|
|
63
63
|
}
|
|
64
64
|
return sha;
|
|
65
65
|
}
|
|
66
|
+
/** Fetches the node ID and body text for a PR. GitHub returns null body for empty bodies — coerced to "". */
|
|
67
|
+
export async function getPullRequestBody(pr, owner, name) {
|
|
68
|
+
const result = await httpGraphql(GET_PR_BODY_QUERY, { owner, repo: name, pr });
|
|
69
|
+
const pullRequest = result.data.repository?.pullRequest;
|
|
70
|
+
if (!pullRequest) {
|
|
71
|
+
const detail = !result.data.repository
|
|
72
|
+
? "repository not found or access denied"
|
|
73
|
+
: "PR not found or access denied";
|
|
74
|
+
throw new Error(`Could not fetch body for ${owner}/${name} PR #${pr}: ${detail}`);
|
|
75
|
+
}
|
|
76
|
+
return { nodeId: pullRequest.id, body: pullRequest.body ?? "" };
|
|
77
|
+
}
|
|
78
|
+
/** Overwrites the PR body. */
|
|
79
|
+
export async function updatePullRequestBody(pullRequestId, body) {
|
|
80
|
+
await httpGraphql(UPDATE_PR_BODY_MUTATION, { pullRequestId, body });
|
|
81
|
+
}
|
|
66
82
|
/**
|
|
67
83
|
* Fetches `mergeable` and `mergeStateStatus` via the REST API.
|
|
68
84
|
*
|
|
@@ -131,6 +131,7 @@ query BatchPr(
|
|
|
131
131
|
login
|
|
132
132
|
}
|
|
133
133
|
body
|
|
134
|
+
createdAt
|
|
134
135
|
}
|
|
135
136
|
}
|
|
136
137
|
reviewSummaries: reviews(states: COMMENTED, last: 50, before: $reviewSummariesCursor) {
|
|
@@ -146,8 +147,12 @@ query BatchPr(
|
|
|
146
147
|
login
|
|
147
148
|
}
|
|
148
149
|
body
|
|
150
|
+
createdAt
|
|
149
151
|
}
|
|
150
152
|
}
|
|
153
|
+
allReviews: reviews(last: 1) {
|
|
154
|
+
totalCount
|
|
155
|
+
}
|
|
151
156
|
approvedReviews: reviews(states: APPROVED, last: 50, before: $approvedReviewsCursor) {
|
|
152
157
|
pageInfo {
|
|
153
158
|
hasPreviousPage
|
|
@@ -161,12 +166,15 @@ query BatchPr(
|
|
|
161
166
|
login
|
|
162
167
|
}
|
|
163
168
|
body
|
|
169
|
+
createdAt
|
|
164
170
|
}
|
|
165
171
|
}
|
|
166
172
|
commits(last: 1) {
|
|
173
|
+
totalCount
|
|
167
174
|
nodes {
|
|
168
175
|
commit {
|
|
169
176
|
oid
|
|
177
|
+
committedDate
|
|
170
178
|
statusCheckRollup {
|
|
171
179
|
contexts(first: 100, after: $checksCursor) {
|
|
172
180
|
pageInfo {
|
package/bin/github/queries.mjs
CHANGED
|
@@ -21,3 +21,7 @@ export const GET_PR_HEAD_SHA_QUERY = gql("get-pr-head-sha.gql");
|
|
|
21
21
|
export const PR_NUMBER_BY_BRANCH_QUERY = gql("pr-number-by-branch.gql");
|
|
22
22
|
/** Convert a draft PR to ready for review. */
|
|
23
23
|
export const MARK_PR_READY_MUTATION = gql("mark-pr-ready.gql");
|
|
24
|
+
/** Fetch the PR body and node ID. */
|
|
25
|
+
export const GET_PR_BODY_QUERY = gql("get-pr-body.gql");
|
|
26
|
+
/** Update the PR body. */
|
|
27
|
+
export const UPDATE_PR_BODY_MUTATION = gql("update-pr-body.gql");
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/bin/types/iterate.mjs
CHANGED
package/bin/types.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pr-shepherd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.1",
|
|
4
4
|
"description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jonathan Ong",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"bin/**",
|
|
13
|
+
"src/classify/types.mts",
|
|
13
14
|
"plugins/**",
|
|
14
15
|
"plugins/**/.codex-plugin/**",
|
|
15
16
|
".claude-plugin/**",
|
|
@@ -22,14 +23,23 @@
|
|
|
22
23
|
"node": ">=22.0.0"
|
|
23
24
|
},
|
|
24
25
|
"dependencies": {
|
|
26
|
+
"picomatch": "^4.0.4",
|
|
27
|
+
"tsx": "^4.22.4",
|
|
25
28
|
"yaml": "^2.7.0"
|
|
26
29
|
},
|
|
30
|
+
"exports": {
|
|
31
|
+
"./classify": {
|
|
32
|
+
"types": "./src/classify/types.mts",
|
|
33
|
+
"default": "./bin/classify/types.mjs"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
27
36
|
"devDependencies": {
|
|
28
37
|
"@types/node": "^25.6.0",
|
|
38
|
+
"@types/picomatch": "^4.0.3",
|
|
29
39
|
"@vitest/coverage-v8": "^4.1.4",
|
|
30
40
|
"husky": "^9.1.7",
|
|
31
41
|
"knip": "^6.14.1",
|
|
32
|
-
"oxfmt": "^0.
|
|
42
|
+
"oxfmt": "^0.51.0",
|
|
33
43
|
"oxlint": "^1.60.0",
|
|
34
44
|
"typescript": "^6.0.3",
|
|
35
45
|
"vitest": "^4.1.4"
|
|
@@ -14,30 +14,15 @@ Poll dispatcher for iterating a PR to completion.
|
|
|
14
14
|
|
|
15
15
|
## Steps
|
|
16
16
|
|
|
17
|
-
1. **Resolve PR number
|
|
18
|
-
- If `$ARGUMENTS` contains a PR number, use it.
|
|
19
|
-
- If `$ARGUMENTS` contains a GitHub PR URL, extract the number.
|
|
20
|
-
- Otherwise, infer: `gh pr view --json number --jq .number`
|
|
21
|
-
- If no PR found, report an error and stop.
|
|
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.
|
|
22
18
|
|
|
23
|
-
2. **Run `pr-shepherd
|
|
19
|
+
2. **Define the poll command once:** `pr-shepherd $N --interval 45s --timeout 4m`. Do not forward `$ARGUMENTS` as extra flags. Run `pr-shepherd --help` to inspect supported options.
|
|
24
20
|
|
|
25
|
-
|
|
26
|
-
pr-shepherd <N> --interval 45s --timeout 4m
|
|
27
|
-
```
|
|
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.
|
|
28
22
|
|
|
29
|
-
|
|
23
|
+
4. **Nonzero exit codes:** Treat a nonzero poll exit as PR state only when the output contains a matching `# PR #$N [ACTION]` heading. Exit `1` can also mean a command or validation failure; if there is no `[ACTION]` heading, surface the error and stop instead of looping.
|
|
30
24
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
- `[WAIT]`: call `pr-shepherd <N> --interval 45s --timeout 4m` again.
|
|
35
|
-
- `[MARK_READY]`: call `pr-shepherd <N> --interval 45s --timeout 4m` again.
|
|
36
|
-
- `[FIX_CODE]`: follow the output's `## Instructions`, then call `pr-shepherd <N> --interval 45s --timeout 4m` again.
|
|
37
|
-
|
|
38
|
-
Treat a nonzero poll exit code as PR state only when the output contains a matching `# PR #N [ACTION]` heading. Exit code `1` can also mean a command or validation failure; if there is no `[FIX_CODE]` heading, surface the error and stop instead of looping.
|
|
39
|
-
|
|
40
|
-
4. **Stop conditions (terminal states):**
|
|
41
|
-
- Stop when the CLI emits `[CANCEL]` (ready-delay completed, or PR merged/closed).
|
|
42
|
-
- Stop when the CLI emits `[ESCALATE]`, including `stall-timeout` for repeated unchanged CI failures or CI that never starts.
|
|
25
|
+
5. **Terminal states (stop):**
|
|
26
|
+
- `[CANCEL]` — ready-delay completed, or PR merged/closed.
|
|
27
|
+
- `[ESCALATE]` — needs human direction (includes `stall-timeout` for repeated unchanged CI failures or CI that never starts).
|
|
43
28
|
- **Do NOT merge the pull request** unless the human has explicitly requested or allowed it.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export type ClassifyItemKind =
|
|
2
|
+
| "review-thread"
|
|
3
|
+
| "pr-comment"
|
|
4
|
+
| "review-summary"
|
|
5
|
+
| "changes-requested";
|
|
6
|
+
|
|
7
|
+
export interface ClassifyItemBase {
|
|
8
|
+
readonly kind: ClassifyItemKind;
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly author: string;
|
|
11
|
+
readonly authorType: "User" | "Bot" | "Unknown";
|
|
12
|
+
readonly body: string;
|
|
13
|
+
readonly url?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ClassifyReviewThread extends ClassifyItemBase {
|
|
17
|
+
readonly kind: "review-thread";
|
|
18
|
+
readonly path?: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ClassifyPrComment extends ClassifyItemBase {
|
|
22
|
+
readonly kind: "pr-comment";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ClassifyReviewSummary extends ClassifyItemBase {
|
|
26
|
+
readonly kind: "review-summary";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ClassifyChangesRequested extends ClassifyItemBase {
|
|
30
|
+
readonly kind: "changes-requested";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ClassifyItem =
|
|
34
|
+
| ClassifyReviewThread
|
|
35
|
+
| ClassifyPrComment
|
|
36
|
+
| ClassifyReviewSummary
|
|
37
|
+
| ClassifyChangesRequested;
|
|
38
|
+
|
|
39
|
+
export interface ClassifyAction {
|
|
40
|
+
/** When true, routes the item's ID to the appropriate resolve/minimize GitHub mutation. Not supported for changes-requested reviews. */
|
|
41
|
+
readonly autoResolve?: boolean;
|
|
42
|
+
/** When true, hides the item from agent output (seen marker is still written). */
|
|
43
|
+
readonly suppress?: boolean;
|
|
44
|
+
/** Optional note recorded to the debug log when this rule fires. */
|
|
45
|
+
readonly reason?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type ClassifyRule = (item: ClassifyItem) => ClassifyAction | null | undefined;
|