pr-shepherd 0.57.0 → 0.58.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/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +150 -297
- package/bin/classify/standard/codex-notices.d.mts +3 -2
- package/bin/classify/standard/codex-notices.mjs +4 -6
- package/bin/classify/standard/gemini-notices.d.mts +1 -1
- package/bin/classify/standard/gemini-notices.mjs +2 -2
- package/bin/classify/standard/index.d.mts +4 -1
- package/bin/classify/standard/index.mjs +4 -5
- package/bin/classify/standard/kilo-no-issues.d.mts +1 -1
- package/bin/classify/standard/kilo-no-issues.mjs +2 -2
- package/bin/classify/standard/sourcery-notices.d.mts +1 -1
- package/bin/classify/standard/sourcery-notices.mjs +2 -2
- package/bin/commands/iterate/index.mjs +31 -12
- package/bin/github/captured-proxy-agent.d.mts +3 -0
- package/bin/github/captured-proxy-agent.mjs +8 -0
- package/bin/github/github-fetch.mjs +19 -6
- package/bin/github/legacy-dispatcher.d.mts +6 -0
- package/bin/github/legacy-dispatcher.mjs +44 -0
- package/bin/github/poll-summary-readiness.d.mts +6 -1
- package/bin/github/poll-summary-readiness.mjs +9 -3
- package/bin/github/poll-summary-review.mjs +13 -10
- package/package.json +3 -3
- package/plugins/pr-shepherd/.claude-plugin/plugin.json +15 -0
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/.codex.mcp.json +1 -1
- package/plugins/pr-shepherd/.mcp.json +1 -1
- package/plugins/pr-shepherd/skills/mark-files-as-viewed/SKILL.md +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +1 -1
- package/plugins/pr-shepherd/skills/reduce-pr-noise/SKILL.md +1 -1
- package/plugins/pr-shepherd/skills/reduce-pr-noise/references/classifiers.md +2 -2
- package/bin/classify/standard/coderabbit-review-summary.d.mts +0 -8
- package/bin/classify/standard/coderabbit-review-summary.mjs +0 -37
- package/bin/classify/standard/codex-review-summary.d.mts +0 -7
- package/bin/classify/standard/codex-review-summary.mjs +0 -20
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isBotItem } from "./author.mjs";
|
|
2
|
-
/** Gemini Code Assist daily-quota and review-limit notices. A heading means a real review. */
|
|
2
|
+
/** Gemini Code Assist daily-quota and review-limit notices on conversation comments. A heading means a real review. */
|
|
3
3
|
const rule = (item) => {
|
|
4
|
-
if (!isBotItem(item, ["gemini-code-assist"], ["pr-comment"
|
|
4
|
+
if (!isBotItem(item, ["gemini-code-assist"], ["pr-comment"]))
|
|
5
5
|
return null;
|
|
6
6
|
if (/^#{1,6}\s/m.test(item.body))
|
|
7
7
|
return null;
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { ClassifyRule } from "../types.mts";
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Bundled classification rules that `classify.standard` can enable by name.
|
|
4
|
+
* Each rule matches conversation comments (`pr-comment`) only.
|
|
5
|
+
*/
|
|
3
6
|
export declare const STANDARD_RULES: Readonly<Record<string, ClassifyRule>>;
|
|
4
7
|
export declare const STANDARD_RULE_NAMES: readonly string[];
|
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
import coderabbitNotices from "./coderabbit-notices.mjs";
|
|
2
|
-
import coderabbitReviewSummary from "./coderabbit-review-summary.mjs";
|
|
3
2
|
import codecovReport from "./codecov-report.mjs";
|
|
4
3
|
import codexNotices from "./codex-notices.mjs";
|
|
5
|
-
import codexReviewSummary from "./codex-review-summary.mjs";
|
|
6
4
|
import dependabotNotices from "./dependabot-notices.mjs";
|
|
7
5
|
import geminiNotices from "./gemini-notices.mjs";
|
|
8
6
|
import kiloNoIssues from "./kilo-no-issues.mjs";
|
|
9
7
|
import sourceryNotices from "./sourcery-notices.mjs";
|
|
10
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Bundled classification rules that `classify.standard` can enable by name.
|
|
10
|
+
* Each rule matches conversation comments (`pr-comment`) only.
|
|
11
|
+
*/
|
|
11
12
|
export const STANDARD_RULES = {
|
|
12
13
|
"coderabbit-notices": coderabbitNotices,
|
|
13
|
-
"coderabbit-review-summary": coderabbitReviewSummary,
|
|
14
14
|
"codecov-report": codecovReport,
|
|
15
15
|
"codex-notices": codexNotices,
|
|
16
|
-
"codex-review-summary": codexReviewSummary,
|
|
17
16
|
"dependabot-notices": dependabotNotices,
|
|
18
17
|
"gemini-notices": geminiNotices,
|
|
19
18
|
"kilo-no-issues": kiloNoIssues,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { ClassifyRule } from "../types.mts";
|
|
2
|
-
/** Kilo Code
|
|
2
|
+
/** Kilo Code conversation comment whose status line reads "No Issues Found" and that carries no findings. */
|
|
3
3
|
declare const rule: ClassifyRule;
|
|
4
4
|
export default rule;
|
|
@@ -2,9 +2,9 @@ import { isBotItem } from "./author.mjs";
|
|
|
2
2
|
const STATUS_LINE = /^[^\S\n]*(?:\*\*)?Status:?(?:\*\*)?:?[^\S\n]*(?:\p{Extended_Pictographic}️?[^\S\n]*)?No Issues Found\b/imu;
|
|
3
3
|
/** Headings a no-issues report carries; any other heading means real feedback. */
|
|
4
4
|
const REPORT_HEADING = /^(?:Code Review Summary|Files Reviewed\b.*)$/i;
|
|
5
|
-
/** Kilo Code
|
|
5
|
+
/** Kilo Code conversation comment whose status line reads "No Issues Found" and that carries no findings. */
|
|
6
6
|
const rule = (item) => {
|
|
7
|
-
if (!isBotItem(item, ["kilo-code-bot"], ["pr-comment"
|
|
7
|
+
if (!isBotItem(item, ["kilo-code-bot"], ["pr-comment"]))
|
|
8
8
|
return null;
|
|
9
9
|
if (!STATUS_LINE.test(item.body))
|
|
10
10
|
return null;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ClassifyRule } from "../types.mts";
|
|
2
2
|
/**
|
|
3
|
-
* Sourcery's private-repo upsell and weekly rate-limit notices. A real
|
|
3
|
+
* Sourcery's private-repo upsell and weekly rate-limit notices on conversation comments. A real review opens
|
|
4
4
|
* with a Markdown heading ("Reviewer's Guide", "Summary by Sourcery"); the notices carry none,
|
|
5
5
|
* so a heading means genuine feedback that merely quotes the phrase.
|
|
6
6
|
*/
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { isBotItem } from "./author.mjs";
|
|
2
2
|
/**
|
|
3
|
-
* Sourcery's private-repo upsell and weekly rate-limit notices. A real
|
|
3
|
+
* Sourcery's private-repo upsell and weekly rate-limit notices on conversation comments. A real review opens
|
|
4
4
|
* with a Markdown heading ("Reviewer's Guide", "Summary by Sourcery"); the notices carry none,
|
|
5
5
|
* so a heading means genuine feedback that merely quotes the phrase.
|
|
6
6
|
*/
|
|
7
7
|
const rule = (item) => {
|
|
8
|
-
if (!isBotItem(item, ["sourcery-ai"], ["pr-comment"
|
|
8
|
+
if (!isBotItem(item, ["sourcery-ai"], ["pr-comment"]))
|
|
9
9
|
return null;
|
|
10
10
|
if (/^#{1,6}\s/m.test(item.body))
|
|
11
11
|
return null;
|
|
@@ -259,16 +259,24 @@ async function runIterateCore(opts) {
|
|
|
259
259
|
if (markReadyResult)
|
|
260
260
|
return markReadyResult;
|
|
261
261
|
if (readyState.shouldCancel && !report.mergeStatus.isDraft) {
|
|
262
|
-
const
|
|
262
|
+
const receipt = receiptCurrent
|
|
263
|
+
? "written"
|
|
264
|
+
: await recordReadyReceipt(receiptKey, report, checkContext);
|
|
263
265
|
// Aggregate --stack routing trusts only a layer's receipt, so an unwritten
|
|
264
266
|
// one keeps the elapsed marker and retries next tick. A one-PR receipt
|
|
265
267
|
// only lets a rerun skip the wait, so its failure never changes the action.
|
|
266
|
-
|
|
268
|
+
// A layer GitHub reports BLOCKED gets no receipt, so aggregate routing
|
|
269
|
+
// still holds it, but without --merge its one-PR session ends like a
|
|
270
|
+
// single PR's instead of waiting for a stall-timeout.
|
|
271
|
+
const stackLayer = report.mergeStatus.mergeRequirements?.stack !== undefined;
|
|
272
|
+
if (stackLayer && (receipt === "failed" || (receipt === "blocked" && opts.merge))) {
|
|
267
273
|
const receiptWait = {
|
|
268
274
|
...base,
|
|
269
275
|
action: "wait",
|
|
270
276
|
shouldCancel: false,
|
|
271
|
-
log:
|
|
277
|
+
log: receipt === "blocked"
|
|
278
|
+
? `WAIT: PR #${base.pr} reached ready-delay but GitHub reports BLOCKED, so its stack readiness receipt was not certified`
|
|
279
|
+
: `WAIT: PR #${base.pr} reached ready-delay but its stack readiness receipt could not be persisted`,
|
|
272
280
|
};
|
|
273
281
|
return annotateBlockedWait(await applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, receiptWait, reportForWork, reviewSummaryIds, opts.stallDeadlineSink), blockerGate);
|
|
274
282
|
}
|
|
@@ -305,6 +313,11 @@ async function runIterateCore(opts) {
|
|
|
305
313
|
};
|
|
306
314
|
return annotateBlockedWait(await applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, wait, reportForWork, reviewSummaryIds, opts.stallDeadlineSink), blockerGate);
|
|
307
315
|
}
|
|
316
|
+
/**
|
|
317
|
+
* Persist a READY receipt from a fresh compact snapshot. `blocked` means the
|
|
318
|
+
* snapshot is ready except for GitHub's BLOCKED state; `failed` covers missing,
|
|
319
|
+
* stale, or not-ready evidence and write errors.
|
|
320
|
+
*/
|
|
308
321
|
async function recordReadyReceipt(key, report, context) {
|
|
309
322
|
if (report.status !== "READY" ||
|
|
310
323
|
report.mergeStatus.state !== "OPEN" ||
|
|
@@ -312,7 +325,7 @@ async function recordReadyReceipt(key, report, context) {
|
|
|
312
325
|
!report.headSha ||
|
|
313
326
|
!report.baseRefOid ||
|
|
314
327
|
transportReadinessGaps(report, report.mergeStatus.mergeStateStatus).length > 0)
|
|
315
|
-
return
|
|
328
|
+
return "failed";
|
|
316
329
|
try {
|
|
317
330
|
const raw = await receiptSummaryForReport(key, report, context);
|
|
318
331
|
if (fingerprintRawSummaryPr(raw) === null ||
|
|
@@ -320,16 +333,22 @@ async function recordReadyReceipt(key, report, context) {
|
|
|
320
333
|
raw.isDraft ||
|
|
321
334
|
raw.headRefOid !== report.headSha ||
|
|
322
335
|
raw.baseRefOid !== report.baseRefOid)
|
|
323
|
-
return
|
|
336
|
+
return "failed";
|
|
324
337
|
// Summarize hydrates annotation totals onto the snapshot before the stored hash.
|
|
325
338
|
const summary = await summarizePollSummaryPr(raw, { owner: key.owner, name: key.repo }, { stackPrNumber: report.pr });
|
|
326
339
|
if (annotationProbeUnavailable(raw))
|
|
327
|
-
return
|
|
340
|
+
return "failed";
|
|
328
341
|
const fingerprint = fingerprintRawSummaryPr(raw);
|
|
329
342
|
if (fingerprint === null)
|
|
330
|
-
return
|
|
331
|
-
|
|
332
|
-
|
|
343
|
+
return "failed";
|
|
344
|
+
const checks = summary.checks ?? {};
|
|
345
|
+
const review = summary.review ?? {};
|
|
346
|
+
if (!isCurrentSummaryReady(raw, checks, review)) {
|
|
347
|
+
const onlyBlocked = raw.mergeStateStatus === "BLOCKED" &&
|
|
348
|
+
!raw.isInMergeQueue &&
|
|
349
|
+
isCurrentSummaryReady(raw, checks, review, { allowBlocked: true });
|
|
350
|
+
return onlyBlocked ? "blocked" : "failed";
|
|
351
|
+
}
|
|
333
352
|
const removalEvent = currentQueueRemovalEvent(raw);
|
|
334
353
|
// Bind recovery to the exact evidence the one-PR check accepted. A new ejection
|
|
335
354
|
// between that check and this fresh snapshot must get its own acknowledgment.
|
|
@@ -341,7 +360,7 @@ async function recordReadyReceipt(key, report, context) {
|
|
|
341
360
|
removalEvent.beforeCommit?.oid !== accepted.beforeCommitOid ||
|
|
342
361
|
Math.floor(Date.parse(removalEvent.createdAt) / 1000) !== accepted.createdAtUnix ||
|
|
343
362
|
removalEvent.reason !== accepted.reason)
|
|
344
|
-
return
|
|
363
|
+
return "failed";
|
|
345
364
|
}
|
|
346
365
|
await writeReadyReceipt({
|
|
347
366
|
version: 1,
|
|
@@ -356,13 +375,13 @@ async function recordReadyReceipt(key, report, context) {
|
|
|
356
375
|
}),
|
|
357
376
|
recordedAtUnix: Math.floor(Date.now() / 1000),
|
|
358
377
|
});
|
|
359
|
-
return
|
|
378
|
+
return "written";
|
|
360
379
|
}
|
|
361
380
|
catch {
|
|
362
381
|
// The receipt is supplementary evidence. A transient summary fetch or
|
|
363
382
|
// state-directory failure must not turn a completed one-PR poll into a
|
|
364
383
|
// different Shepherd action.
|
|
365
|
-
return
|
|
384
|
+
return "failed";
|
|
366
385
|
}
|
|
367
386
|
}
|
|
368
387
|
/** Clear a stale READY receipt; return whether a current receipt remains. */
|
|
@@ -1,19 +1,32 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { createCapturedProxyAgent } from "./captured-proxy-agent.mjs";
|
|
3
|
+
import { loadUndiciKeepingLegacyDispatcher } from "./legacy-dispatcher.mjs";
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
2
5
|
let proxyAgent;
|
|
3
6
|
let proxyConfiguration;
|
|
7
|
+
let undiciModule;
|
|
8
|
+
function loadUndici() {
|
|
9
|
+
undiciModule ??= loadUndiciKeepingLegacyDispatcher(() => require("undici"));
|
|
10
|
+
return undiciModule;
|
|
11
|
+
}
|
|
4
12
|
/** Attach proxy routing to GitHub requests without changing the host's dispatcher. */
|
|
5
|
-
export function githubFetch(input, init) {
|
|
13
|
+
export async function githubFetch(input, init) {
|
|
6
14
|
const httpProxy = process.env["http_proxy"] ?? process.env["HTTP_PROXY"] ?? "";
|
|
7
15
|
const httpsProxy = process.env["https_proxy"] ?? process.env["HTTPS_PROXY"] ?? "";
|
|
8
16
|
if (!httpProxy && !httpsProxy)
|
|
9
17
|
return fetch(input, init);
|
|
18
|
+
const undici = loadUndici();
|
|
10
19
|
const configuration = JSON.stringify([httpProxy, httpsProxy]);
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
20
|
+
let agent = proxyAgent;
|
|
21
|
+
if (!agent || configuration !== proxyConfiguration) {
|
|
22
|
+
const previous = agent;
|
|
23
|
+
// Undici 8 sends plain HTTP to the proxy as an absolute-form request.
|
|
24
|
+
// Tunnel every request so HTTP stays on CONNECT, matching Undici 7.
|
|
25
|
+
agent = createCapturedProxyAgent(undici, httpProxy, httpsProxy);
|
|
26
|
+
proxyAgent = agent;
|
|
14
27
|
proxyConfiguration = configuration;
|
|
15
28
|
// close waits for in-flight bodies before releasing the previous pool.
|
|
16
29
|
void previous?.close().catch(() => { });
|
|
17
30
|
}
|
|
18
|
-
return fetch(input, { ...init, dispatcher:
|
|
31
|
+
return undici.fetch(input, { ...init, dispatcher: agent });
|
|
19
32
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load Undici and put the host's legacy dispatcher back when this load replaced
|
|
3
|
+
* it. `load` must be synchronous so a concurrent setGlobalDispatcher() cannot
|
|
4
|
+
* land in the slot and then be overwritten by the restore.
|
|
5
|
+
*/
|
|
6
|
+
export declare function loadUndiciKeepingLegacyDispatcher<T>(load: () => T, slot?: symbol): T;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const legacyDispatcherSlot = Symbol.for("undici.globalDispatcher.1");
|
|
2
|
+
/**
|
|
3
|
+
* Undici 8's import installs a default agent and copies it onto the legacy
|
|
4
|
+
* dispatcher slot that Node 22 and 24's fetch still reads. Put the host's
|
|
5
|
+
* value back afterwards. The current slot stays writable, so a later
|
|
6
|
+
* setGlobalDispatcher() can replace it and mirror that change onto fetch.
|
|
7
|
+
*/
|
|
8
|
+
function restoreLegacyDispatcher(previous, slot) {
|
|
9
|
+
if (previous) {
|
|
10
|
+
Object.defineProperty(globalThis, slot, previous);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const installed = Object.getOwnPropertyDescriptor(globalThis, slot);
|
|
14
|
+
// Undici defines this property as non-configurable, so absence can only be
|
|
15
|
+
// represented by clearing the value.
|
|
16
|
+
if (!installed?.writable)
|
|
17
|
+
return;
|
|
18
|
+
Object.defineProperty(globalThis, slot, {
|
|
19
|
+
value: undefined,
|
|
20
|
+
writable: true,
|
|
21
|
+
enumerable: false,
|
|
22
|
+
configurable: false,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Load Undici and put the host's legacy dispatcher back when this load replaced
|
|
27
|
+
* it. `load` must be synchronous so a concurrent setGlobalDispatcher() cannot
|
|
28
|
+
* land in the slot and then be overwritten by the restore.
|
|
29
|
+
*/
|
|
30
|
+
export function loadUndiciKeepingLegacyDispatcher(load, slot = legacyDispatcherSlot) {
|
|
31
|
+
const previous = Object.getOwnPropertyDescriptor(globalThis, slot);
|
|
32
|
+
const previousValue = previous?.value;
|
|
33
|
+
try {
|
|
34
|
+
const loaded = load();
|
|
35
|
+
if (Object.getOwnPropertyDescriptor(globalThis, slot)?.value !== previousValue) {
|
|
36
|
+
restoreLegacyDispatcher(previous, slot);
|
|
37
|
+
}
|
|
38
|
+
return loaded;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
restoreLegacyDispatcher(previous, slot);
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { PollSummaryChecks, PollSummaryReview } from "../types.mts";
|
|
2
2
|
import type { RawSummaryPr } from "./poll-summary-raw.mts";
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* Fresh compact evidence required before a READY receipt can be used.
|
|
5
|
+
* `allowBlocked` asks whether GitHub's BLOCKED state is the only gap, so a
|
|
6
|
+
* caller can tell a blocker it cannot clear from missing or stale evidence.
|
|
7
|
+
*/
|
|
4
8
|
export declare function isCurrentSummaryReady(raw: RawSummaryPr, checks: PollSummaryChecks, review: PollSummaryReview, options?: {
|
|
5
9
|
allowQueuedProgress?: boolean;
|
|
10
|
+
allowBlocked?: boolean;
|
|
6
11
|
}): boolean;
|
|
@@ -2,8 +2,15 @@ import { summarizePollSummaryChecks } from "./poll-summary-checks.mjs";
|
|
|
2
2
|
import { parseBranchRules } from "./batch-parsers-rules.mjs";
|
|
3
3
|
import { rulesComplete } from "./fingerprint-fields.mjs";
|
|
4
4
|
import { isKnownMergeStateStatus, transportReadinessGaps } from "./transport-evidence.mjs";
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* Fresh compact evidence required before a READY receipt can be used.
|
|
7
|
+
* `allowBlocked` asks whether GitHub's BLOCKED state is the only gap, so a
|
|
8
|
+
* caller can tell a blocker it cannot clear from missing or stale evidence.
|
|
9
|
+
*/
|
|
6
10
|
export function isCurrentSummaryReady(raw, checks, review, options = {}) {
|
|
11
|
+
const blockedStates = options.allowBlocked
|
|
12
|
+
? ["DIRTY", "BEHIND", "UNKNOWN", "HAS_HOOKS"]
|
|
13
|
+
: ["DIRTY", "BEHIND", "UNKNOWN", "BLOCKED", "HAS_HOOKS"];
|
|
7
14
|
const queued = options.allowQueuedProgress === true && raw.isInMergeQueue;
|
|
8
15
|
// Merge-group checks may still be running after the PR earned its receipt;
|
|
9
16
|
// source-commit checks must remain complete. A fresh pending source check is
|
|
@@ -29,8 +36,7 @@ export function isCurrentSummaryReady(raw, checks, review, options = {}) {
|
|
|
29
36
|
raw.mergeable !== "CONFLICTING" &&
|
|
30
37
|
raw.mergeStateStatus !== "DIRTY" &&
|
|
31
38
|
(queued || raw.mergeable === "MERGEABLE") &&
|
|
32
|
-
(queued ||
|
|
33
|
-
!["DIRTY", "BEHIND", "UNKNOWN", "BLOCKED", "HAS_HOOKS"].includes(raw.mergeStateStatus)) &&
|
|
39
|
+
(queued || !blockedStates.includes(raw.mergeStateStatus)) &&
|
|
34
40
|
(checks.failing ?? 0) === 0 &&
|
|
35
41
|
(sourceChecks.failing ?? 0) === 0 &&
|
|
36
42
|
(sourceChecks.inProgress ?? 0) === 0 &&
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { normalizeBotUsernames } from "../comments/authors.mjs";
|
|
2
2
|
import { applyRules } from "../classify/apply.mjs";
|
|
3
|
+
import { normalizeAuthorType } from "../comments/authors.mjs";
|
|
3
4
|
import { loadClassifyRules } from "../classify/loader.mjs";
|
|
4
5
|
import { loadConfig } from "../config/load.mjs";
|
|
5
6
|
import { getEffectiveCwd } from "../execution-context.mjs";
|
|
6
7
|
import { classifyItem } from "../state/seen-comments.mjs";
|
|
7
8
|
import { mutationWasDenied } from "../state/seen-comments.mjs";
|
|
8
9
|
import { canGenerateGithubMutation } from "./mutation-policy.mjs";
|
|
10
|
+
import { threadEndedByShepherd } from "../comments/marker.mjs";
|
|
9
11
|
const THREAD_COMMENT_SEPARATOR = "\n\n--- thread comment ---\n\n";
|
|
10
12
|
export async function summarizePollSummaryReview(raw, seen, viewerCanAdminister) {
|
|
11
13
|
const config = loadConfig();
|
|
@@ -55,7 +57,11 @@ export async function summarizePollSummaryReview(raw, seen, viewerCanAdminister)
|
|
|
55
57
|
const transcript = thread.comments.nodes
|
|
56
58
|
.map((comment) => comment.body)
|
|
57
59
|
.join(THREAD_COMMENT_SEPARATOR);
|
|
58
|
-
|
|
60
|
+
const completeResolved = thread.isResolved === true && !truncated;
|
|
61
|
+
if (completeResolved &&
|
|
62
|
+
threadEndedByShepherd({ body: root?.body ?? "", comments: thread.comments.nodes }))
|
|
63
|
+
continue;
|
|
64
|
+
if (isSuppressed(rules, "review-thread", root, thread.id, thread.path ?? root?.path ?? null, root?.body ?? "", completeResolved))
|
|
59
65
|
continue;
|
|
60
66
|
const repeatable = !mutationWasDenied(thread.id, transcript, seen) &&
|
|
61
67
|
(root?.viewerDidAuthor === true ||
|
|
@@ -75,23 +81,20 @@ export async function summarizePollSummaryReview(raw, seen, viewerCanAdminister)
|
|
|
75
81
|
...(incomplete && { incomplete: true }),
|
|
76
82
|
};
|
|
77
83
|
}
|
|
78
|
-
function isSuppressed(rules, kind, raw, id = raw?.id ?? "unknown", path, body = raw?.body ?? "") {
|
|
84
|
+
function isSuppressed(rules, kind, raw, id = raw?.id ?? "unknown", path, body = raw?.body ?? "", autoResolveSatisfied = false) {
|
|
79
85
|
const item = {
|
|
80
86
|
kind,
|
|
81
87
|
id,
|
|
82
88
|
author: raw?.author?.login ?? "unknown",
|
|
83
|
-
authorType: raw?.author?.__typename
|
|
84
|
-
|
|
85
|
-
: raw?.author?.__typename === "User"
|
|
86
|
-
? "User"
|
|
87
|
-
: "Unknown",
|
|
88
|
-
...(raw?.authorAssociation && { authorAssociation: raw.authorAssociation }),
|
|
89
|
+
authorType: normalizeAuthorType(raw?.author?.__typename, raw?.author?.login),
|
|
90
|
+
...(raw?.authorAssociation !== undefined && { authorAssociation: raw.authorAssociation }),
|
|
89
91
|
body,
|
|
90
|
-
...(raw?.url
|
|
92
|
+
...(kind === "review-thread" ? { url: raw?.url ?? "" } : raw?.url ? { url: raw.url } : {}),
|
|
91
93
|
...(kind === "review-thread" && { path }),
|
|
92
94
|
};
|
|
93
95
|
const action = applyRules(rules, item);
|
|
94
96
|
// An auto-resolve match is hidden only once the full check minimizes or resolves it, or
|
|
95
97
|
// surfaces a denied mutation once; until then it stays actionable so that check runs.
|
|
96
|
-
return action.suppress === true &&
|
|
98
|
+
return (action.suppress === true &&
|
|
99
|
+
(action.autoResolve !== true || kind === "changes-requested" || autoResolveSatisfied));
|
|
97
100
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pr-shepherd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.58.0",
|
|
4
4
|
"description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"automation",
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"dependencies": {
|
|
80
80
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
81
81
|
"picomatch": "^4.0.4",
|
|
82
|
-
"undici": "^
|
|
82
|
+
"undici": "^8.11.2",
|
|
83
83
|
"yaml": "^2.7.0",
|
|
84
84
|
"zod": "^4.4.3"
|
|
85
85
|
},
|
|
@@ -96,6 +96,6 @@
|
|
|
96
96
|
"vitest": "^5.0.0"
|
|
97
97
|
},
|
|
98
98
|
"engines": {
|
|
99
|
-
"node": ">=22.
|
|
99
|
+
"node": ">=22.19.0"
|
|
100
100
|
}
|
|
101
101
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pr-shepherd",
|
|
3
|
+
"description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
|
|
4
|
+
"version": "0.58.0",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Jonathan Ong",
|
|
7
|
+
"email": "jonathanrichardong@gmail.com"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/jonathanong/pr-shepherd",
|
|
10
|
+
"repository": "https://github.com/jonathanong/pr-shepherd",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"keywords": ["github", "pull-request", "ci", "code-review", "automation"],
|
|
13
|
+
"skills": "./skills/",
|
|
14
|
+
"mcpServers": "./.mcp.json"
|
|
15
|
+
}
|
|
@@ -3,7 +3,7 @@ name: mark-files-as-viewed
|
|
|
3
3
|
description: "Mark selected changed pull-request files as viewed with pr-shepherd (MCP or CLI)."
|
|
4
4
|
user-invocable: true
|
|
5
5
|
argument-hint: "[PR number or URL] [files|tests|--tests|--match REGEX]"
|
|
6
|
-
allowed-tools:
|
|
6
|
+
allowed-tools: mcp__plugin_pr-shepherd_pr-shepherd, Bash(npx pr-shepherd:*), Bash(pnpm exec pr-shepherd:*), Bash(pnpm dlx pr-shepherd:*), Bash(bunx pr-shepherd:*), Bash(pr-shepherd:*)
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# mark-files-as-viewed
|
|
@@ -3,7 +3,7 @@ name: pr-shepherd
|
|
|
3
3
|
description: 'Create or iterate a GitHub pull request with pr-shepherd (MCP or CLI). Use for requests like "make a PR and use pr-shepherd", "iterate PR #123", or "run pr-shepherd until this PR is ready".'
|
|
4
4
|
user-invocable: true
|
|
5
5
|
argument-hint: "[PR number or URL | --stack PR] [--merge]"
|
|
6
|
-
allowed-tools:
|
|
6
|
+
allowed-tools: mcp__plugin_pr-shepherd_pr-shepherd, Bash(npx pr-shepherd:*), Bash(pnpm exec pr-shepherd:*), Bash(pnpm dlx pr-shepherd:*), Bash(bunx pr-shepherd:*), Bash(pr-shepherd:*)
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# pr-shepherd
|
|
@@ -3,7 +3,7 @@ name: reduce-pr-noise
|
|
|
3
3
|
description: Reduce repetitive pr-shepherd output by configuring bot-comment classification rules or existing noise settings. Use for requests to silence a specific bot notice, quiet polling, trim CI logs, or tune comment visibility; use pr-shepherd for PR iteration itself.
|
|
4
4
|
user-invocable: true
|
|
5
5
|
argument-hint: "[noisy bot comment or output]"
|
|
6
|
-
allowed-tools:
|
|
6
|
+
allowed-tools: Bash(npx pr-shepherd:*), Bash(pnpm exec pr-shepherd:*), Bash(pnpm dlx pr-shepherd:*), Bash(bunx pr-shepherd:*), Bash(pr-shepherd:*), Edit(**/.pr-shepherdrc.yml), Edit(~/.pr-shepherdrc.yml)
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# Reduce pr-shepherd noise
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Use a classification rule when the unwanted item has a recognizable author and message pattern. Inspect a representative item first so the rule does not hide other feedback from the same bot. Prefer an existing settings control for polling- or output-wide behavior.
|
|
4
4
|
|
|
5
|
-
Check the bundled rules first. Under `classify.standard` in `.pr-shepherdrc.yml`, `all: true` enables every rule, and a named boolean such as `codecov-report: false` overrides `all`. They cover vendor bot notices: `coderabbit-notices`, `
|
|
5
|
+
Check the bundled rules first. Under `classify.standard` in `.pr-shepherdrc.yml`, `all: true` enables every rule, and a named boolean such as `codecov-report: false` overrides `all`. They cover vendor bot notices on conversation comments only: `coderabbit-notices`, `codecov-report`, `codex-notices`, `dependabot-notices`, `gemini-notices`, `kilo-no-issues`, and `sourcery-notices`. They do not match review summaries or inline review comments. Write a custom rule only when no bundled rule fits.
|
|
6
6
|
|
|
7
7
|
Place an `.mts` file in `.pr-shepherd/classification/` in the project. Shepherd uses the first classification directory on the working directory's ancestor chain; it stops at the home directory if reached, otherwise at the filesystem root. A home-level rule directory is not discovered when the project is outside the home tree. Rule directories do not merge. Files ending in `.ts`, `.mts`, `.mjs`, or `.js` load, except names beginning with `_` or `.`. An `.mts` rule can use erasable TypeScript syntax and `import type`, without transpilation-only features such as enums.
|
|
8
8
|
|
|
@@ -12,7 +12,7 @@ Each file default-exports a `ClassifyRule` from `pr-shepherd/classify`. A rule r
|
|
|
12
12
|
import type { ClassifyRule } from "pr-shepherd/classify";
|
|
13
13
|
|
|
14
14
|
const rule: ClassifyRule = (item) => {
|
|
15
|
-
if (item.kind !== "pr-comment"
|
|
15
|
+
if (item.kind !== "pr-comment") return null;
|
|
16
16
|
if (item.author.toLowerCase() !== "gemini-code-assist") return null;
|
|
17
17
|
if (!/^You have reached your daily quota limit\b/i.test(item.body)) return null;
|
|
18
18
|
return { suppress: true };
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import type { ClassifyRule } from "../types.mts";
|
|
2
|
-
/**
|
|
3
|
-
* CodeRabbit review summaries that only count and re-prompt their inline threads. The threads
|
|
4
|
-
* carry the findings, so the summary adds nothing. Any outside-diff, nitpick, duplicate or
|
|
5
|
-
* other body-only section keeps the summary visible.
|
|
6
|
-
*/
|
|
7
|
-
declare const rule: ClassifyRule;
|
|
8
|
-
export default rule;
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import { isBotItem } from "./author.mjs";
|
|
2
|
-
const HEADING = /^\*\*Actionable comments posted: \d+\*\*\n/;
|
|
3
|
-
const PROMPT_BLOCK = /\n<details>\n<summary>🤖 Prompt to fix review comments<\/summary>\n\n```\n([\s\S]*?)\n```\n\n<\/details>\n/;
|
|
4
|
-
const REVIEW_INFO_BLOCK = /\n<details>\n<summary>ℹ️ Review info<\/summary>\n[\s\S]*<\/details>\n/;
|
|
5
|
-
const AUTOFIX_BLOCK = /\n<!-- autofix_checkbox_start -->[\s\S]*?<!-- autofix_checkbox_end -->\n/;
|
|
6
|
-
/** Sections that carry findings only in the summary body, never in an inline thread. */
|
|
7
|
-
const BODY_ONLY_FINDINGS = /\[!CAUTION\]|Outside diff range comments|Nitpick comments|Duplicate comments|Additional comments|Outside diff comments:/;
|
|
8
|
-
/**
|
|
9
|
-
* CodeRabbit review summaries that only count and re-prompt their inline threads. The threads
|
|
10
|
-
* carry the findings, so the summary adds nothing. Any outside-diff, nitpick, duplicate or
|
|
11
|
-
* other body-only section keeps the summary visible.
|
|
12
|
-
*/
|
|
13
|
-
const rule = (item) => {
|
|
14
|
-
if (!isBotItem(item, ["coderabbitai"], ["review-summary"]))
|
|
15
|
-
return null;
|
|
16
|
-
const body = `${item.body.trim()}\n`;
|
|
17
|
-
if (!HEADING.test(body) || BODY_ONLY_FINDINGS.test(body))
|
|
18
|
-
return null;
|
|
19
|
-
const prompt = body.match(PROMPT_BLOCK)?.[1];
|
|
20
|
-
if (prompt !== undefined && !prompt.includes("\nInline comments:\n"))
|
|
21
|
-
return null;
|
|
22
|
-
const rest = body
|
|
23
|
-
.replace(HEADING, "\n")
|
|
24
|
-
.replace(AUTOFIX_BLOCK, "\n")
|
|
25
|
-
.replace(PROMPT_BLOCK, "\n")
|
|
26
|
-
.replace(REVIEW_INFO_BLOCK, "\n")
|
|
27
|
-
.replace(/<!--[\s\S]*?-->/g, "")
|
|
28
|
-
.replace(/^---$/gm, "");
|
|
29
|
-
if (rest.trim() !== "")
|
|
30
|
-
return null;
|
|
31
|
-
return {
|
|
32
|
-
autoResolve: true,
|
|
33
|
-
suppress: true,
|
|
34
|
-
reason: "CodeRabbit review summary that only repeats its inline threads",
|
|
35
|
-
};
|
|
36
|
-
};
|
|
37
|
-
export default rule;
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import type { ClassifyRule } from "../types.mts";
|
|
2
|
-
/**
|
|
3
|
-
* The Codex review summary that only introduces its inline threads. A summary that carries a
|
|
4
|
-
* finding in its own body (for example, a permalink to a line outside the diff) stays visible.
|
|
5
|
-
*/
|
|
6
|
-
declare const rule: ClassifyRule;
|
|
7
|
-
export default rule;
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { isBotItem } from "./author.mjs";
|
|
2
|
-
const ABOUT_BLOCK = /\n\n<details> <summary>ℹ️ About Codex in GitHub<\/summary>[\s\S]*?<\/details>$/;
|
|
3
|
-
const ENVELOPE = /^### 💡 Codex Review\n\nHere are some automated review suggestions for this pull request\.\n\n\*\*Reviewed commit:\*\* `[a-f0-9]+`$/;
|
|
4
|
-
/**
|
|
5
|
-
* The Codex review summary that only introduces its inline threads. A summary that carries a
|
|
6
|
-
* finding in its own body (for example, a permalink to a line outside the diff) stays visible.
|
|
7
|
-
*/
|
|
8
|
-
const rule = (item) => {
|
|
9
|
-
if (!isBotItem(item, ["chatgpt-codex-connector"], ["review-summary"]))
|
|
10
|
-
return null;
|
|
11
|
-
const content = item.body.trim().replace(ABOUT_BLOCK, "").trim();
|
|
12
|
-
if (!ENVELOPE.test(content))
|
|
13
|
-
return null;
|
|
14
|
-
return {
|
|
15
|
-
autoResolve: true,
|
|
16
|
-
suppress: true,
|
|
17
|
-
reason: "Codex review summary that only introduces its inline threads",
|
|
18
|
-
};
|
|
19
|
-
};
|
|
20
|
-
export default rule;
|