pr-shepherd 0.46.5 → 0.46.6
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 +3 -0
- package/bin/api.d.mts +1 -1
- package/bin/checks/triage.mjs +12 -1
- package/bin/cli/body-truncate.d.mts +12 -0
- package/bin/cli/body-truncate.mjs +123 -0
- package/bin/cli/fix-formatter-extra.d.mts +2 -1
- package/bin/cli/fix-formatter-extra.mjs +18 -3
- package/bin/cli/fix-formatter.d.mts +3 -1
- package/bin/cli/fix-formatter.mjs +39 -13
- package/bin/cli/iterate-checks-formatter.mjs +8 -6
- package/bin/cli/iterate-formatter.mjs +10 -9
- package/bin/cli/iterate-lean.mjs +2 -0
- package/bin/cli/iterate-merge-formatter.d.mts +3 -1
- package/bin/cli/iterate-merge-formatter.mjs +15 -0
- package/bin/cli/list-formatters.d.mts +4 -3
- package/bin/cli/list-formatters.mjs +22 -14
- package/bin/commands/check-annotations.d.mts +15 -0
- package/bin/commands/check-annotations.mjs +23 -1
- package/bin/commands/check.d.mts +1 -0
- package/bin/commands/check.mjs +68 -32
- package/bin/commands/iterate/index.mjs +18 -5
- package/bin/commands/iterate/merge-state.d.mts +6 -1
- package/bin/commands/iterate/merge-state.mjs +42 -1
- package/bin/config/load.d.mts +10 -0
- package/bin/config/load.mjs +17 -1
- package/bin/config.json +4 -2
- package/bin/mcp/server.mjs +18 -6
- package/bin/types/iterate.d.mts +10 -0
- package/bin/types/merge-queue.d.mts +16 -0
- package/package.json +9 -6
- 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/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Autonomous PR CI monitor and review-comment resolver for agentic coding tools, including Claude Code and Codex.
|
|
4
4
|
|
|
5
|
+
**[jongleberry.com/pr-shepherd](https://jongleberry.com/pr-shepherd/)** — the why and the principles behind the design, for humans and agents.
|
|
6
|
+
|
|
5
7
|
## Why
|
|
6
8
|
|
|
7
9
|
An agent finishing a PR should think about code, not reconstruct GitHub state or invent a next-step policy each tick. Without Shepherd it fans out across GitHub MCP, `gh`, and GraphQL, then guesses what to do with the result.
|
|
@@ -88,6 +90,7 @@ This system is opinionated and works best with PRs that use required status chec
|
|
|
88
90
|
- Shepherd identifies its own latest reply only when that comment begins `<!-- pr-shepherd -->`, not from author equality. A marked viewer-authored thread can be resolved without another reply as a retry.
|
|
89
91
|
- Every review thread/comment/review summary is surfaced at least once, even if already outdated, resolved, or minimized; edited items re-surface through seen markers.
|
|
90
92
|
- Draft PRs can be marked ready automatically when clean; disable with `actions.autoMarkReady: false` or `--no-auto-mark-ready`.
|
|
93
|
+
- With `--merge`, actionable review threads/comments/reviews/summaries are held back (`WAIT`, with raw deferred-work counts) while a PR sits in the merge queue, since a Shepherd-initiated push would eject it; set `actions.workWhileQueued: true` to act on them immediately instead. Failing checks and merge conflicts are never deferred.
|
|
91
94
|
- The CLI never performs git mutations itself — it only emits commit/push instructions for the agent to run. Push access to the PR head is a usage precondition; GitHub viewer fields do not create a separate push-authorization handoff.
|
|
92
95
|
- Generated iterate mutations and automatic actions are capability-aware and omit unauthorized commands. Explicit `apply` operations honor the caller's intent and surface GitHub's result; semantic human-content protections still apply.
|
|
93
96
|
- `build_suggestion_patches` turns one or more ordered GitHub suggestion threads into checked patches and commit metadata, but never edits the working tree or git history. Local HEAD may be ahead when the live PR head is its ancestor.
|
package/bin/api.d.mts
CHANGED
|
@@ -8,7 +8,7 @@ export interface CreatePrShepherdOptions {
|
|
|
8
8
|
}
|
|
9
9
|
/** A positive PR number, GitHub pull-request URL, or owner/repo#number reference. */
|
|
10
10
|
export type PrReference = number | string;
|
|
11
|
-
export type IterateInput = Omit<IterateCommandOptions, "format" | "prNumber" | "targetRepository"> & {
|
|
11
|
+
export type IterateInput = Omit<IterateCommandOptions, "format" | "prNumber" | "targetRepository" | "persistSeen" | "deferQuotaWarning"> & {
|
|
12
12
|
pr?: PrReference;
|
|
13
13
|
};
|
|
14
14
|
export interface ReviewMutationsOperation {
|
package/bin/checks/triage.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/* eslint-disable max-lines */
|
|
2
2
|
import { restWithRateLimit, restText } from "../github/http.mjs";
|
|
3
3
|
import { loadDerived, storeDerived } from "../state/rest-cache.mjs";
|
|
4
|
+
import { loadConfig } from "../config/load.mjs";
|
|
4
5
|
const STARTUP_FAILURE_STATUS = "startup_failure";
|
|
5
6
|
const LOG_EXCERPT_CONTEXT_LINES = 16;
|
|
6
7
|
const LOG_EXCERPT_TAIL_LINES = 28;
|
|
@@ -181,10 +182,11 @@ async function fetchJobLogExcerpt(jobId, repo, stateKey, cacheable = false) {
|
|
|
181
182
|
}
|
|
182
183
|
}
|
|
183
184
|
function buildLogExcerpt(raw) {
|
|
185
|
+
const ignorePatterns = compileIgnoreLogLinePatterns();
|
|
184
186
|
const lines = raw
|
|
185
187
|
.split(/\r?\n/)
|
|
186
188
|
.map(cleanLogLine)
|
|
187
|
-
.filter((line) => line.trim() !== "");
|
|
189
|
+
.filter((line) => line.trim() !== "" && !isNoiseLine(line, ignorePatterns));
|
|
188
190
|
if (lines.length === 0)
|
|
189
191
|
return undefined;
|
|
190
192
|
const aggregateExcerpt = buildAggregateJobResultsExcerpt(lines);
|
|
@@ -276,6 +278,15 @@ function truncateAnchoredExcerpt(lines, anchorIndex) {
|
|
|
276
278
|
return text;
|
|
277
279
|
return truncateLogExcerpt(`${TRUNCATED_SUFFIX.trim()}\n${lines.slice(anchorIndex).join("\n")}`);
|
|
278
280
|
}
|
|
281
|
+
// User-configured via `checks.ignoreLogLines` (regex source strings) — empty by
|
|
282
|
+
// default. What counts as noise varies by CI toolchain, so Shepherd ships no
|
|
283
|
+
// built-in patterns; a project opts in via `.pr-shepherdrc.yml`.
|
|
284
|
+
function compileIgnoreLogLinePatterns() {
|
|
285
|
+
return loadConfig().checks.ignoreLogLines.map((pattern) => new RegExp(pattern));
|
|
286
|
+
}
|
|
287
|
+
function isNoiseLine(line, patterns) {
|
|
288
|
+
return patterns.some((re) => re.test(line));
|
|
289
|
+
}
|
|
279
290
|
function cleanLogLine(line) {
|
|
280
291
|
return line
|
|
281
292
|
.replace(/^\uFEFF/, "")
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const BODY_TRUNCATE_MAX_CHARS = 1200;
|
|
2
|
+
export declare const NESTED_BODY_TRUNCATE_MAX_CHARS = 600;
|
|
3
|
+
/**
|
|
4
|
+
* Truncates `body` to roughly `maxChars`, keeping a head and tail portion so an
|
|
5
|
+
* opening question and a trailing summary both survive. Never cuts inside a
|
|
6
|
+
* ``` or ~~~ fence: the cut points snap outward to the nearest fence-safe line
|
|
7
|
+
* boundary — an unbalanced fence here would swallow every section rendered
|
|
8
|
+
* after it in the same tick. A single line too long to fit a budget on its own
|
|
9
|
+
* (headEnd stays -1, or no line fits from the tail) falls back to a
|
|
10
|
+
* character-level slice of that line rather than dropping it entirely.
|
|
11
|
+
*/
|
|
12
|
+
export declare function truncateBody(body: string, maxChars: number, url?: string): string;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
const HEAD_FRACTION = 0.7;
|
|
2
|
+
export const BODY_TRUNCATE_MAX_CHARS = 1200;
|
|
3
|
+
export const NESTED_BODY_TRUNCATE_MAX_CHARS = 600;
|
|
4
|
+
function fenceOpener(trimmed) {
|
|
5
|
+
const char = trimmed[0];
|
|
6
|
+
if (char !== "`" && char !== "~")
|
|
7
|
+
return null;
|
|
8
|
+
let len = 0;
|
|
9
|
+
while (trimmed[len] === char)
|
|
10
|
+
len++;
|
|
11
|
+
return len >= 3 ? { char, len } : null;
|
|
12
|
+
}
|
|
13
|
+
function isFenceCloser(trimmed, fence) {
|
|
14
|
+
return trimmed.length >= fence.len && [...trimmed].every((c) => c === fence.char);
|
|
15
|
+
}
|
|
16
|
+
// Backtick and tilde fences close only against their own kind — a run of one
|
|
17
|
+
// character never closes a fence opened with the other (CommonMark semantics).
|
|
18
|
+
function fenceStatesAfterEachLine(lines) {
|
|
19
|
+
const states = [];
|
|
20
|
+
let fence = null;
|
|
21
|
+
for (const line of lines) {
|
|
22
|
+
const trimmed = line.trim();
|
|
23
|
+
if (fence) {
|
|
24
|
+
if (isFenceCloser(trimmed, fence))
|
|
25
|
+
fence = null;
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
fence = fenceOpener(trimmed);
|
|
29
|
+
}
|
|
30
|
+
states.push(fence !== null);
|
|
31
|
+
}
|
|
32
|
+
return states;
|
|
33
|
+
}
|
|
34
|
+
function cumulativeLengths(lines) {
|
|
35
|
+
const out = [];
|
|
36
|
+
let total = 0;
|
|
37
|
+
for (const line of lines) {
|
|
38
|
+
total += line.length + 1;
|
|
39
|
+
out.push(total);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/** Last line index keepable within `budget`, extended forward past any fence still open at that point. */
|
|
44
|
+
function findHeadEnd(states, cumLens, budget) {
|
|
45
|
+
let end = -1;
|
|
46
|
+
for (let i = 0; i < cumLens.length; i++) {
|
|
47
|
+
if (cumLens[i] > budget)
|
|
48
|
+
break;
|
|
49
|
+
end = i;
|
|
50
|
+
}
|
|
51
|
+
while (end >= 0 && end < states.length - 1 && states[end])
|
|
52
|
+
end++;
|
|
53
|
+
return end;
|
|
54
|
+
}
|
|
55
|
+
/** First line index keepable within `budget` counted from the end, extended backward past any fence open entering it. */
|
|
56
|
+
function findTailStart(states, cumLens, budget) {
|
|
57
|
+
const n = cumLens.length;
|
|
58
|
+
const total = cumLens[n - 1] ?? 0;
|
|
59
|
+
let start = n;
|
|
60
|
+
for (let j = n - 1; j >= 0; j--) {
|
|
61
|
+
const suffixLen = total - (j > 0 ? cumLens[j - 1] : 0);
|
|
62
|
+
if (suffixLen > budget)
|
|
63
|
+
break;
|
|
64
|
+
start = j;
|
|
65
|
+
}
|
|
66
|
+
while (start > 0 && states[start - 1])
|
|
67
|
+
start--;
|
|
68
|
+
return start;
|
|
69
|
+
}
|
|
70
|
+
const REVIEW_COMMENT_URL_RE = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/\d+#discussion_r(\d+)$/;
|
|
71
|
+
const ISSUE_COMMENT_URL_RE = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/(?:pull|issues)\/\d+#issuecomment-(\d+)$/;
|
|
72
|
+
// An agent can run this directly — no browser round-trip — to read the full body.
|
|
73
|
+
function commentViewCommand(url) {
|
|
74
|
+
const review = REVIEW_COMMENT_URL_RE.exec(url);
|
|
75
|
+
if (review) {
|
|
76
|
+
const [, owner, repo, id] = review;
|
|
77
|
+
return `gh api repos/${owner}/${repo}/pulls/comments/${id}`;
|
|
78
|
+
}
|
|
79
|
+
const issue = ISSUE_COMMENT_URL_RE.exec(url);
|
|
80
|
+
if (issue) {
|
|
81
|
+
const [, owner, repo, id] = issue;
|
|
82
|
+
return `gh api repos/${owner}/${repo}/issues/comments/${id}`;
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
function elisionMarker(charCount, url) {
|
|
87
|
+
const pointer = url ? (commentViewCommand(url) ?? url) : undefined;
|
|
88
|
+
return pointer
|
|
89
|
+
? `[…${charCount} chars elided — full text: ${pointer}]`
|
|
90
|
+
: `[…${charCount} chars elided]`;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Truncates `body` to roughly `maxChars`, keeping a head and tail portion so an
|
|
94
|
+
* opening question and a trailing summary both survive. Never cuts inside a
|
|
95
|
+
* ``` or ~~~ fence: the cut points snap outward to the nearest fence-safe line
|
|
96
|
+
* boundary — an unbalanced fence here would swallow every section rendered
|
|
97
|
+
* after it in the same tick. A single line too long to fit a budget on its own
|
|
98
|
+
* (headEnd stays -1, or no line fits from the tail) falls back to a
|
|
99
|
+
* character-level slice of that line rather than dropping it entirely.
|
|
100
|
+
*/
|
|
101
|
+
export function truncateBody(body, maxChars, url) {
|
|
102
|
+
if (body.length <= maxChars)
|
|
103
|
+
return body;
|
|
104
|
+
const lines = body.split("\n");
|
|
105
|
+
const states = fenceStatesAfterEachLine(lines);
|
|
106
|
+
const cumLens = cumulativeLengths(lines);
|
|
107
|
+
const headBudget = Math.ceil(maxChars * HEAD_FRACTION);
|
|
108
|
+
const tailBudget = maxChars - headBudget;
|
|
109
|
+
const headEnd = findHeadEnd(states, cumLens, headBudget);
|
|
110
|
+
const tailStart = findTailStart(states, cumLens, tailBudget);
|
|
111
|
+
if (tailStart <= headEnd + 1)
|
|
112
|
+
return body;
|
|
113
|
+
const head = headEnd >= 0 ? lines.slice(0, headEnd + 1).join("\n") : lines[0].slice(0, headBudget);
|
|
114
|
+
const tail = tailStart < lines.length
|
|
115
|
+
? lines.slice(tailStart).join("\n")
|
|
116
|
+
: tailBudget > 0
|
|
117
|
+
? lines[lines.length - 1].slice(-tailBudget)
|
|
118
|
+
: "";
|
|
119
|
+
const elidedChars = body.length - head.length - tail.length;
|
|
120
|
+
if (elidedChars <= 0)
|
|
121
|
+
return body;
|
|
122
|
+
return [head, elisionMarker(elidedChars, url), tail].join("\n\n");
|
|
123
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import type { CheckAnnotation, IterateResultFixCode } from "../types.mts";
|
|
2
|
-
|
|
2
|
+
/** Returns null when the annotation adds nothing beyond the check's own conclusion tag. */
|
|
3
|
+
export declare function renderCheckAnnotation(a: CheckAnnotation, logExcerpt?: string): string | null;
|
|
3
4
|
export declare function renderProtectedRun(run: IterateResultFixCode["fix"]["protectedRuns"][number]): string;
|
|
@@ -1,15 +1,30 @@
|
|
|
1
1
|
import { blockquote } from "./list-formatters.mjs";
|
|
2
|
-
|
|
2
|
+
// A bare "Process completed with exit code N." on its own carries nothing beyond
|
|
3
|
+
// the check's own [conclusion: FAILURE] tag, so the whole annotation is dropped.
|
|
4
|
+
const TRIVIAL_EXIT_CODE_RE = /^Process completed with exit code \d+\.?$/;
|
|
5
|
+
/** Returns null when the annotation adds nothing beyond the check's own conclusion tag. */
|
|
6
|
+
export function renderCheckAnnotation(a, logExcerpt) {
|
|
7
|
+
const hasRawDetails = a.rawDetails !== undefined && a.rawDetails.trim() !== "";
|
|
8
|
+
const hasTitle = a.title !== undefined && a.title.trim() !== "";
|
|
9
|
+
if (TRIVIAL_EXIT_CODE_RE.test(a.message.trim()) && !hasRawDetails && !hasTitle)
|
|
10
|
+
return null;
|
|
3
11
|
const loc = `${a.path}:${renderAnnotationRange(a)}`;
|
|
4
12
|
const link = a.blobUrl ? ` [↗](${a.blobUrl})` : "";
|
|
5
13
|
const title = a.title ? ` — ${a.title}` : "";
|
|
6
14
|
const lines = [`- \`${a.id}\`${link} \`${loc}\` [${a.level}]${title}`];
|
|
7
|
-
if (a.message.trim() !== "")
|
|
15
|
+
if (a.message.trim() !== "" && !duplicatesLog(a.message, logExcerpt)) {
|
|
8
16
|
lines.push(blockquote(a.message));
|
|
9
|
-
|
|
17
|
+
}
|
|
18
|
+
if (hasRawDetails && !duplicatesLog(a.rawDetails, logExcerpt)) {
|
|
10
19
|
lines.push(blockquote(a.rawDetails));
|
|
20
|
+
}
|
|
11
21
|
return lines.join("\n");
|
|
12
22
|
}
|
|
23
|
+
// The bullet's path:line + blob link already anchors this text; if the identical
|
|
24
|
+
// text is also in the check's log excerpt, only the blockquote body is redundant.
|
|
25
|
+
function duplicatesLog(text, logExcerpt) {
|
|
26
|
+
return logExcerpt !== undefined && logExcerpt.includes(text.trim());
|
|
27
|
+
}
|
|
13
28
|
export function renderProtectedRun(run) {
|
|
14
29
|
const label = run.workflowName
|
|
15
30
|
? `${run.workflowName} (${run.checkNames.join(", ")})`
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
import type { IterateResultFixCode } from "../types.mts";
|
|
2
|
-
export declare function formatFixCodeResult(header: string, result: IterateResultFixCode
|
|
2
|
+
export declare function formatFixCodeResult(header: string, result: IterateResultFixCode, opts?: {
|
|
3
|
+
verbose?: boolean;
|
|
4
|
+
}): string;
|
|
@@ -3,11 +3,14 @@ import { renderResolveCommand } from "../commands/iterate/render.mjs";
|
|
|
3
3
|
import { inlineCode, joinSections } from "../util/markdown.mjs";
|
|
4
4
|
import { renderSuggestionBlock, renderLineRange } from "./suggestion-renderer.mjs";
|
|
5
5
|
import { renderThreadBullet, renderReviewBullet, renderThreadResolutionStatusTag, renderAuthor, buildFirstLookBullets, renderThreadConversation, blockquote, } from "./list-formatters.mjs";
|
|
6
|
+
import { BODY_TRUNCATE_MAX_CHARS } from "./body-truncate.mjs";
|
|
6
7
|
import { numberInstructions } from "./iterate-instructions.mjs";
|
|
7
8
|
import { renderCheckAnnotation, renderProtectedRun } from "./fix-formatter-extra.mjs";
|
|
8
9
|
import { isFailingAgentCheck } from "../checks/conclusions.mjs";
|
|
9
10
|
import { renderMergeCommand } from "../commands/iterate/merge.mjs";
|
|
10
|
-
export function formatFixCodeResult(header, result) {
|
|
11
|
+
export function formatFixCodeResult(header, result, opts = {}) {
|
|
12
|
+
const verbose = opts.verbose ?? false;
|
|
13
|
+
const topCap = verbose ? undefined : BODY_TRUNCATE_MAX_CHARS;
|
|
11
14
|
const sections = [header];
|
|
12
15
|
const renderThreads = (heading, threads) => {
|
|
13
16
|
if (threads.length === 0)
|
|
@@ -21,7 +24,7 @@ export function formatFixCodeResult(header, result) {
|
|
|
21
24
|
const suggestionMarker = t.suggestion ? " [suggestion]" : "";
|
|
22
25
|
const editedMarker = t.edited ? " [edited since first look]" : "";
|
|
23
26
|
sections.push(`### ${heading} — ${loc} (${renderAuthor(t.author, t.authorType, t.authorAssociation, t.viewerDidAuthor)})${reviewMarker}${suggestionMarker}${editedMarker}`);
|
|
24
|
-
sections.push(renderThreadConversation(t));
|
|
27
|
+
sections.push(renderThreadConversation(t, verbose));
|
|
25
28
|
if (t.suggestion) {
|
|
26
29
|
sections.push(renderSuggestionBlock(t.suggestion, ""));
|
|
27
30
|
}
|
|
@@ -34,7 +37,7 @@ export function formatFixCodeResult(header, result) {
|
|
|
34
37
|
if (result.fix.resolutionOnlyThreads.length > 0) {
|
|
35
38
|
sections.push("## Review threads to resolve");
|
|
36
39
|
sections.push(result.fix.resolutionOnlyThreads
|
|
37
|
-
.map((t) => renderThreadBullet(t, { statusTag: renderThreadResolutionStatusTag(t) }))
|
|
40
|
+
.map((t) => renderThreadBullet(t, { statusTag: renderThreadResolutionStatusTag(t), verbose }))
|
|
38
41
|
.join("\n"));
|
|
39
42
|
}
|
|
40
43
|
if (result.fix.actionableComments.length > 0) {
|
|
@@ -44,7 +47,7 @@ export function formatFixCodeResult(header, result) {
|
|
|
44
47
|
const editedMarker = c.edited ? " [edited since first look]" : "";
|
|
45
48
|
const authorizationMarker = c.viewerCanMinimize === false ? " [viewer cannot minimize]" : "";
|
|
46
49
|
sections.push(`### ${heading} (${renderAuthor(c.author, c.authorType, c.authorAssociation)})${authorizationMarker}${editedMarker}`);
|
|
47
|
-
sections.push(blockquote(c.body));
|
|
50
|
+
sections.push(blockquote(c.body, topCap, c.url));
|
|
48
51
|
}
|
|
49
52
|
}
|
|
50
53
|
const failingChecks = result.fix.checks.filter((ch) => isFailingAgentCheck(ch));
|
|
@@ -87,10 +90,17 @@ export function formatFixCodeResult(header, result) {
|
|
|
87
90
|
});
|
|
88
91
|
sections.push(bullets.join("\n\n"));
|
|
89
92
|
}
|
|
90
|
-
const
|
|
91
|
-
|
|
93
|
+
const annotatedChecks = result.fix.checks
|
|
94
|
+
.map((ch) => ({
|
|
95
|
+
ch,
|
|
96
|
+
rendered: (ch.annotations ?? [])
|
|
97
|
+
.map((a) => renderCheckAnnotation(a, ch.logExcerpt))
|
|
98
|
+
.filter((s) => s !== null),
|
|
99
|
+
}))
|
|
100
|
+
.filter(({ rendered }) => rendered.length > 0);
|
|
101
|
+
if (annotatedChecks.length > 0) {
|
|
92
102
|
sections.push("## Check annotations");
|
|
93
|
-
for (const ch of
|
|
103
|
+
for (const { ch, rendered } of annotatedChecks) {
|
|
94
104
|
const workflowPrefix = ch.workflowName ? `${ch.workflowName} › ` : "";
|
|
95
105
|
const jobLabel = ch.jobName ? ch.jobName : ch.name;
|
|
96
106
|
const locator = ch.runId
|
|
@@ -99,25 +109,41 @@ export function formatFixCodeResult(header, result) {
|
|
|
99
109
|
? `external \`${ch.detailsUrl}\``
|
|
100
110
|
: "(no runId)";
|
|
101
111
|
sections.push(`### ${locator} — \`${workflowPrefix}${jobLabel}\``);
|
|
102
|
-
sections.push(
|
|
112
|
+
sections.push(rendered.join("\n\n"));
|
|
103
113
|
}
|
|
104
114
|
}
|
|
105
115
|
if (result.fix.changesRequestedReviews.length > 0) {
|
|
106
116
|
sections.push("## Changes-requested reviews");
|
|
107
|
-
|
|
117
|
+
for (const r of result.fix.changesRequestedReviews) {
|
|
118
|
+
// A stale bot CR that already had its full body surfaced on a prior tick stays a terse
|
|
119
|
+
// one-line reminder (renderReviewBullet's staleBotCr branch) — repeating the full body
|
|
120
|
+
// every tick would be noise, not new content. Every other review (a bot CR's first
|
|
121
|
+
// emission, or any human CR) gets the same H3 + blockquote shape as the sibling
|
|
122
|
+
// review-summary/approval sections below, so the agent actually has a body to read —
|
|
123
|
+
// the bare bullet this replaced only ever rendered the reviewId, never the body.
|
|
124
|
+
if (r.staleBotCr) {
|
|
125
|
+
sections.push(renderReviewBullet(r));
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const staleTag = r.staleReview
|
|
129
|
+
? " [stale — review is on an old commit, all threads resolved; ask reviewer to re-review or dismiss]"
|
|
130
|
+
: "";
|
|
131
|
+
sections.push(`### \`reviewId=${r.id}\` (${renderAuthor(r.author, r.authorType, r.authorAssociation)})${staleTag}`);
|
|
132
|
+
sections.push(r.body.trim() === "" ? "(no review body)" : blockquote(r.body, topCap));
|
|
133
|
+
}
|
|
108
134
|
}
|
|
109
135
|
if (result.fix.firstLookSummaries.length > 0) {
|
|
110
136
|
sections.push("## Review summaries (first look)");
|
|
111
137
|
for (const r of result.fix.firstLookSummaries) {
|
|
112
138
|
sections.push(`### \`reviewId=${r.id}\` (${renderAuthor(r.author, r.authorType, r.authorAssociation)})${r.viewerCanMinimize === false ? " [viewer cannot minimize]" : ""}`);
|
|
113
|
-
sections.push(r.body.trim() === "" ? "(no review body)" : blockquote(r.body));
|
|
139
|
+
sections.push(r.body.trim() === "" ? "(no review body)" : blockquote(r.body, topCap));
|
|
114
140
|
}
|
|
115
141
|
}
|
|
116
142
|
if (result.fix.editedSummaries.length > 0) {
|
|
117
143
|
sections.push("## Review summaries (edited since first look — already minimized; do not re-minimize)");
|
|
118
144
|
for (const r of result.fix.editedSummaries) {
|
|
119
145
|
sections.push(`### \`reviewId=${r.id}\` (${renderAuthor(r.author, r.authorType, r.authorAssociation)})${r.viewerCanMinimize === false ? " [viewer cannot minimize]" : ""}`);
|
|
120
|
-
sections.push(r.body.trim() === "" ? "(no review body)" : blockquote(r.body));
|
|
146
|
+
sections.push(r.body.trim() === "" ? "(no review body)" : blockquote(r.body, topCap));
|
|
121
147
|
}
|
|
122
148
|
}
|
|
123
149
|
const firstLookSummaryIds = new Set(result.fix.firstLookSummaries.map((r) => r.id));
|
|
@@ -130,14 +156,14 @@ export function formatFixCodeResult(header, result) {
|
|
|
130
156
|
sections.push("## Approvals (surfaced — not minimized)");
|
|
131
157
|
for (const r of result.fix.surfacedApprovals) {
|
|
132
158
|
sections.push(`### \`reviewId=${r.id}\` (${renderAuthor(r.author, r.authorType, r.authorAssociation)})${r.viewerCanMinimize === false ? " [viewer cannot minimize]" : ""}`);
|
|
133
|
-
sections.push(r.body.trim() === "" ? "(no review body)" : blockquote(r.body));
|
|
159
|
+
sections.push(r.body.trim() === "" ? "(no review body)" : blockquote(r.body, topCap));
|
|
134
160
|
}
|
|
135
161
|
}
|
|
136
162
|
const firstLookTotal = result.fix.firstLookThreads.length + result.fix.firstLookComments.length;
|
|
137
163
|
if (firstLookTotal > 0) {
|
|
138
164
|
sections.push(`## First-look items (${firstLookTotal}) — acknowledge status before acting`);
|
|
139
165
|
const resolutionOnlyIds = new Set(result.fix.resolutionOnlyThreads.map((t) => t.id));
|
|
140
|
-
sections.push(buildFirstLookBullets(result.fix.firstLookThreads, resolutionOnlyIds, result.fix.firstLookComments).join("\n"));
|
|
166
|
+
sections.push(buildFirstLookBullets(result.fix.firstLookThreads, resolutionOnlyIds, result.fix.firstLookComments, verbose).join("\n"));
|
|
141
167
|
}
|
|
142
168
|
if (result.fix.inProgressRunIds.length > 0) {
|
|
143
169
|
sections.push("## In-progress runs");
|
|
@@ -14,7 +14,7 @@ function formatRelevantCheck(check) {
|
|
|
14
14
|
const lines = [`- \`${workflow}${job}\` [conclusion: ${check.conclusion}]`];
|
|
15
15
|
appendCheckFields(lines, check);
|
|
16
16
|
appendLogExcerpt(lines, check.logExcerpt);
|
|
17
|
-
appendAnnotations(lines, check.annotations);
|
|
17
|
+
appendAnnotations(lines, check.annotations, check.logExcerpt);
|
|
18
18
|
return lines;
|
|
19
19
|
}
|
|
20
20
|
function appendCheckFields(lines, check) {
|
|
@@ -43,13 +43,15 @@ function appendLogExcerpt(lines, logExcerpt) {
|
|
|
43
43
|
for (const line of logExcerpt.split("\n"))
|
|
44
44
|
lines.push(` > ${line}`);
|
|
45
45
|
}
|
|
46
|
-
function appendAnnotations(lines, annotations) {
|
|
47
|
-
|
|
46
|
+
function appendAnnotations(lines, annotations, logExcerpt) {
|
|
47
|
+
const rendered = (annotations ?? [])
|
|
48
|
+
.map((a) => renderCheckAnnotation(a, logExcerpt))
|
|
49
|
+
.filter((s) => s !== null);
|
|
50
|
+
if (rendered.length === 0)
|
|
48
51
|
return;
|
|
49
52
|
lines.push(" - annotations:");
|
|
50
|
-
for (const
|
|
51
|
-
for (const line of
|
|
53
|
+
for (const text of rendered) {
|
|
54
|
+
for (const line of text.split("\n"))
|
|
52
55
|
lines.push(` ${line}`);
|
|
53
|
-
}
|
|
54
56
|
}
|
|
55
57
|
}
|
|
@@ -3,7 +3,7 @@ import { formatRelevantChecks } from "./iterate-checks-formatter.mjs";
|
|
|
3
3
|
import { joinSections } from "../util/markdown.mjs";
|
|
4
4
|
import { adaptIterateLog, buildSimpleIterateInstructions, numberInstructions, } from "./iterate-instructions.mjs";
|
|
5
5
|
import { formatMergeRequirementLines } from "../merge-status/requirements-format.mjs";
|
|
6
|
-
import { appendMergeQueueHeader, formatMergeAction } from "./iterate-merge-formatter.mjs";
|
|
6
|
+
import { appendMergeQueueHeader, formatDeferredWorkLine, formatMergeAction, } from "./iterate-merge-formatter.mjs";
|
|
7
7
|
import { formatApiUsage, formatQuotaWarning } from "./api-usage-formatter.mjs";
|
|
8
8
|
import { formatActivityLine } from "./iterate-activity-formatter.mjs";
|
|
9
9
|
/**
|
|
@@ -115,13 +115,13 @@ export function formatIterateResult(result, opts) {
|
|
|
115
115
|
const verboseChecks = verbose ? formatRelevantChecks(result.checks) : null;
|
|
116
116
|
const telemetrySections = [quotaWarning, apiUsage, verboseChecks];
|
|
117
117
|
switch (result.action) {
|
|
118
|
-
case "wait":
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
118
|
+
case "wait": {
|
|
119
|
+
const waitLines = [header, ...telemetrySections, adaptIterateLog(result.log)];
|
|
120
|
+
if (result.deferredWork)
|
|
121
|
+
waitLines.push(formatDeferredWorkLine(result.deferredWork));
|
|
122
|
+
waitLines.push(`## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result))}`);
|
|
123
|
+
return joinSections(waitLines);
|
|
124
|
+
}
|
|
125
125
|
case "mark_ready":
|
|
126
126
|
return joinSections([
|
|
127
127
|
header,
|
|
@@ -147,6 +147,7 @@ export function formatIterateResult(result, opts) {
|
|
|
147
147
|
const supersededStr = result.supersededNames.map((n) => "`" + n + "`").join(", ");
|
|
148
148
|
cancelHeaderLines.push(`**superseded** ${supersededStr}`);
|
|
149
149
|
}
|
|
150
|
+
appendMergeQueueHeader(cancelHeaderLines, result);
|
|
150
151
|
if (activityLine)
|
|
151
152
|
cancelHeaderLines.push(activityLine);
|
|
152
153
|
return joinSections([
|
|
@@ -166,6 +167,6 @@ export function formatIterateResult(result, opts) {
|
|
|
166
167
|
`## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result))}`,
|
|
167
168
|
]);
|
|
168
169
|
case "fix_code":
|
|
169
|
-
return formatFixCodeResult(joinSections([header, ...telemetrySections]), result);
|
|
170
|
+
return formatFixCodeResult(joinSections([header, ...telemetrySections]), result, { verbose });
|
|
170
171
|
}
|
|
171
172
|
}
|
package/bin/cli/iterate-lean.mjs
CHANGED
|
@@ -23,6 +23,7 @@ export function projectIterateLean(result, opts) {
|
|
|
23
23
|
status: result.status,
|
|
24
24
|
state: result.state,
|
|
25
25
|
mergeStateStatus: result.mergeStateStatus,
|
|
26
|
+
...(result.mergeStatus !== "CLEAN" && { mergeStatus: result.mergeStatus }), // mergeStateStatus alone can't always reconstruct this
|
|
26
27
|
...(readyDelaySuffix && { readyDelayOverride: readyDelaySuffix }),
|
|
27
28
|
...(result.mergeStatus === "BLOCKED" &&
|
|
28
29
|
result.reviewDecision !== null && { reviewDecision: result.reviewDecision }),
|
|
@@ -70,6 +71,7 @@ export function projectIterateLean(result, opts) {
|
|
|
70
71
|
case "wait":
|
|
71
72
|
return {
|
|
72
73
|
...base,
|
|
74
|
+
...(result.deferredWork && { deferredWork: result.deferredWork }),
|
|
73
75
|
log: adaptIterateLog(result.log),
|
|
74
76
|
instructions: simpleInstructions(result),
|
|
75
77
|
};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
import type { IterateResult, IterateResultMerge } from "../types.mts";
|
|
1
|
+
import type { IterateDeferredWork, IterateResult, IterateResultMerge } from "../types.mts";
|
|
2
|
+
/** One inline rollup line of the non-CI work held back while the PR sits in the merge queue. */
|
|
3
|
+
export declare function formatDeferredWorkLine(dw: IterateDeferredWork): string;
|
|
2
4
|
export declare function appendMergeQueueHeader(lines: string[], result: IterateResult): void;
|
|
3
5
|
export declare function formatMergeAction(header: string, result: IterateResultMerge): string;
|
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import { renderMergeCommand } from "../commands/iterate/merge.mjs";
|
|
2
2
|
import { inlineCode, joinSections } from "../util/markdown.mjs";
|
|
3
3
|
import { buildSimpleIterateInstructions, numberInstructions } from "./iterate-instructions.mjs";
|
|
4
|
+
/** One inline rollup line of the non-CI work held back while the PR sits in the merge queue. */
|
|
5
|
+
export function formatDeferredWorkLine(dw) {
|
|
6
|
+
const parts = [];
|
|
7
|
+
if (dw.threads > 0)
|
|
8
|
+
parts.push(`${dw.threads} thread${dw.threads === 1 ? "" : "s"}`);
|
|
9
|
+
if (dw.comments > 0)
|
|
10
|
+
parts.push(`${dw.comments} comment${dw.comments === 1 ? "" : "s"}`);
|
|
11
|
+
if (dw.changesRequestedReviews > 0) {
|
|
12
|
+
parts.push(`${dw.changesRequestedReviews} changes-requested review${dw.changesRequestedReviews === 1 ? "" : "s"}`);
|
|
13
|
+
}
|
|
14
|
+
if (dw.reviewSummaries > 0) {
|
|
15
|
+
parts.push(`${dw.reviewSummaries} review summar${dw.reviewSummaries === 1 ? "y" : "ies"}`);
|
|
16
|
+
}
|
|
17
|
+
return `**deferred (in merge queue)** ${parts.join(", ")}`;
|
|
18
|
+
}
|
|
4
19
|
export function appendMergeQueueHeader(lines, result) {
|
|
5
20
|
const queue = result.mergeQueue;
|
|
6
21
|
if (!queue)
|
|
@@ -35,9 +35,10 @@ export declare function renderThreadBullet(t: ThreadBulletInput, opts?: {
|
|
|
35
35
|
renderSuggestion?: boolean;
|
|
36
36
|
noBody?: boolean;
|
|
37
37
|
suppressEditedMarker?: boolean;
|
|
38
|
+
verbose?: boolean;
|
|
38
39
|
}): string;
|
|
39
|
-
export declare function renderThreadConversation(t: ThreadBulletInput): string;
|
|
40
|
-
export declare function blockquote(body: string): string;
|
|
40
|
+
export declare function renderThreadConversation(t: ThreadBulletInput, verbose?: boolean): string;
|
|
41
|
+
export declare function blockquote(body: string, maxChars?: number, url?: string): string;
|
|
41
42
|
export declare function renderCommentBullet(c: {
|
|
42
43
|
id: string;
|
|
43
44
|
url?: string;
|
|
@@ -74,5 +75,5 @@ export declare function renderReviewListSection(heading: string, items: {
|
|
|
74
75
|
* Threads that also appear in resolutionOnlyIds have their body suppressed
|
|
75
76
|
* (already shown in `## Review threads to resolve`).
|
|
76
77
|
*/
|
|
77
|
-
export declare function buildFirstLookBullets(firstLookThreads: FirstLookThread[], resolutionOnlyIds: Set<string>, firstLookComments: FirstLookComment[]): string[];
|
|
78
|
+
export declare function buildFirstLookBullets(firstLookThreads: FirstLookThread[], resolutionOnlyIds: Set<string>, firstLookComments: FirstLookComment[], verbose?: boolean): string[];
|
|
78
79
|
export {};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { renderLineRange, renderSuggestionBlock } from "./suggestion-renderer.mjs";
|
|
2
2
|
import { threadComments } from "../threads/transcript.mjs";
|
|
3
|
+
import { truncateBody, BODY_TRUNCATE_MAX_CHARS, NESTED_BODY_TRUNCATE_MAX_CHARS, } from "./body-truncate.mjs";
|
|
3
4
|
const BODY_PREVIEW_MAX = 100;
|
|
4
5
|
export function renderAuthor(author, authorType, authorAssociation, viewerDidAuthor) {
|
|
5
6
|
return [`@${author}`, authorType, authorAssociation, viewerDidAuthor ? "viewer-authored" : null]
|
|
@@ -41,48 +42,54 @@ export function renderThreadBullet(t, opts = {}) {
|
|
|
41
42
|
}
|
|
42
43
|
const parts = [bulletLine];
|
|
43
44
|
if (!opts.noBody) {
|
|
44
|
-
parts.push(renderThreadCommentBullets(t));
|
|
45
|
+
parts.push(renderThreadCommentBullets(t, opts.verbose));
|
|
45
46
|
}
|
|
46
47
|
if (t.suggestion && opts.renderSuggestion) {
|
|
47
48
|
parts.push(renderSuggestionBlock(t.suggestion));
|
|
48
49
|
}
|
|
49
50
|
return parts.join("\n");
|
|
50
51
|
}
|
|
51
|
-
export function renderThreadConversation(t) {
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
export function renderThreadConversation(t, verbose = false) {
|
|
53
|
+
const topCap = verbose ? undefined : BODY_TRUNCATE_MAX_CHARS;
|
|
54
|
+
if (!t.comments || t.comments.length === 0) {
|
|
55
|
+
return blockquote(t.body, topCap, t.url);
|
|
56
|
+
}
|
|
57
|
+
const nestedCap = verbose ? undefined : NESTED_BODY_TRUNCATE_MAX_CHARS;
|
|
54
58
|
return threadComments(t)
|
|
55
|
-
.map((c) => {
|
|
59
|
+
.map((c, i) => {
|
|
56
60
|
const heading = c.id
|
|
57
61
|
? c.url
|
|
58
62
|
? `#### [commentId=${c.id}](${c.url}) (${renderAuthor(c.author, c.authorType, c.authorAssociation, c.viewerDidAuthor)})`
|
|
59
63
|
: `#### \`commentId=${c.id}\` (${renderAuthor(c.author, c.authorType, c.authorAssociation, c.viewerDidAuthor)})`
|
|
60
64
|
: `#### (${renderAuthor(c.author, c.authorType, c.authorAssociation, c.viewerDidAuthor)})`;
|
|
61
|
-
return `${heading}\n\n${blockquote(c.body)}`;
|
|
65
|
+
return `${heading}\n\n${blockquote(c.body, i === 0 ? topCap : nestedCap, c.url)}`;
|
|
62
66
|
})
|
|
63
67
|
.join("\n\n");
|
|
64
68
|
}
|
|
65
|
-
export function blockquote(body) {
|
|
66
|
-
|
|
69
|
+
export function blockquote(body, maxChars, url) {
|
|
70
|
+
const text = maxChars === undefined ? body : truncateBody(body, maxChars, url);
|
|
71
|
+
return text
|
|
67
72
|
.replace(/\r\n/g, "\n")
|
|
68
73
|
.split("\n")
|
|
69
74
|
.map((line) => (line === "" ? ">" : `> ${line}`))
|
|
70
75
|
.join("\n");
|
|
71
76
|
}
|
|
72
|
-
function renderThreadCommentBullets(t) {
|
|
77
|
+
function renderThreadCommentBullets(t, verbose = false) {
|
|
78
|
+
const topCap = verbose ? undefined : BODY_TRUNCATE_MAX_CHARS;
|
|
79
|
+
const nestedCap = verbose ? undefined : NESTED_BODY_TRUNCATE_MAX_CHARS;
|
|
73
80
|
return threadComments(t)
|
|
74
|
-
.map((c) => {
|
|
81
|
+
.map((c, i) => {
|
|
75
82
|
const link = c.url ? ` [↗](${c.url})` : "";
|
|
76
83
|
const id = c.id ? `\`commentId=${c.id}\`` : "comment";
|
|
77
84
|
return [
|
|
78
85
|
` - ${id}${link} (${renderAuthor(c.author, c.authorType, c.authorAssociation, c.viewerDidAuthor)})`,
|
|
79
|
-
indentBlockquote(c.body, " "),
|
|
86
|
+
indentBlockquote(c.body, " ", i === 0 ? topCap : nestedCap, c.url),
|
|
80
87
|
].join("\n");
|
|
81
88
|
})
|
|
82
89
|
.join("\n");
|
|
83
90
|
}
|
|
84
|
-
function indentBlockquote(body, indent) {
|
|
85
|
-
return blockquote(body)
|
|
91
|
+
function indentBlockquote(body, indent, maxChars, url) {
|
|
92
|
+
return blockquote(body, maxChars, url)
|
|
86
93
|
.split("\n")
|
|
87
94
|
.map((line) => `${indent}${line}`)
|
|
88
95
|
.join("\n");
|
|
@@ -118,13 +125,14 @@ export function renderReviewListSection(heading, items) {
|
|
|
118
125
|
* Threads that also appear in resolutionOnlyIds have their body suppressed
|
|
119
126
|
* (already shown in `## Review threads to resolve`).
|
|
120
127
|
*/
|
|
121
|
-
export function buildFirstLookBullets(firstLookThreads, resolutionOnlyIds, firstLookComments) {
|
|
128
|
+
export function buildFirstLookBullets(firstLookThreads, resolutionOnlyIds, firstLookComments, verbose = false) {
|
|
122
129
|
const bullets = [];
|
|
123
130
|
for (const t of firstLookThreads) {
|
|
124
131
|
bullets.push(renderThreadBullet(t, {
|
|
125
132
|
statusTag: renderFirstLookStatusTag(t),
|
|
126
133
|
noBody: resolutionOnlyIds.has(t.id),
|
|
127
134
|
suppressEditedMarker: true,
|
|
135
|
+
verbose,
|
|
128
136
|
}));
|
|
129
137
|
}
|
|
130
138
|
for (const c of firstLookComments) {
|
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import { type AnnotationCacheOptions } from "../github/check-annotations.mts";
|
|
2
2
|
import type { CheckAnnotation, ClassifiedCheck, ShepherdReport, TriagedCheck } from "../types.mts";
|
|
3
3
|
export declare function checksWithActionableAnnotations(report: ShepherdReport): TriagedCheck[];
|
|
4
|
+
/**
|
|
5
|
+
* True when GitHub itself is already acting on this PR regardless of Shepherd (a failing
|
|
6
|
+
* check, an unseen check-run annotation, or a hard merge conflict) — the categories that
|
|
7
|
+
* `commands/iterate/index.mts` never defers while a PR is queued. Shared with `check.mts` so
|
|
8
|
+
* the seen-marker suppression gate there can't drift from the actual iterate dispatch
|
|
9
|
+
* decision (a queued PR with a failing check still renders review items via `fix_code`; their
|
|
10
|
+
* seen markers must not be suppressed just because the PR happens to be queued).
|
|
11
|
+
*/
|
|
12
|
+
export declare function hasCheckDrivenActionableWork(checks: {
|
|
13
|
+
failing: TriagedCheck[];
|
|
14
|
+
passing: ClassifiedCheck[];
|
|
15
|
+
skipped: ClassifiedCheck[];
|
|
16
|
+
filtered: ClassifiedCheck[];
|
|
17
|
+
ignored?: TriagedCheck[];
|
|
18
|
+
}, mergeStatusValue: string): boolean;
|
|
4
19
|
export declare function attachAndMergeCheckAnnotations(buckets: {
|
|
5
20
|
passing: ClassifiedCheck[];
|
|
6
21
|
failing: TriagedCheck[];
|
|
@@ -2,6 +2,9 @@ import { fetchCheckRunAnnotations, } from "../github/check-annotations.mjs";
|
|
|
2
2
|
function shouldFetchCheckAnnotations(check) {
|
|
3
3
|
return check.id != null && check.status === "COMPLETED" && check.hasAnnotations === true;
|
|
4
4
|
}
|
|
5
|
+
function hasActionableAnnotation(check) {
|
|
6
|
+
return check.conclusion !== "SUCCESS" && (check.annotations?.length ?? 0) > 0;
|
|
7
|
+
}
|
|
5
8
|
export function checksWithActionableAnnotations(report) {
|
|
6
9
|
return [
|
|
7
10
|
...report.checks.failing,
|
|
@@ -9,7 +12,26 @@ export function checksWithActionableAnnotations(report) {
|
|
|
9
12
|
...report.checks.skipped,
|
|
10
13
|
...report.checks.filtered,
|
|
11
14
|
...(report.checks.ignored ?? []),
|
|
12
|
-
].filter(
|
|
15
|
+
].filter(hasActionableAnnotation);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* True when GitHub itself is already acting on this PR regardless of Shepherd (a failing
|
|
19
|
+
* check, an unseen check-run annotation, or a hard merge conflict) — the categories that
|
|
20
|
+
* `commands/iterate/index.mts` never defers while a PR is queued. Shared with `check.mts` so
|
|
21
|
+
* the seen-marker suppression gate there can't drift from the actual iterate dispatch
|
|
22
|
+
* decision (a queued PR with a failing check still renders review items via `fix_code`; their
|
|
23
|
+
* seen markers must not be suppressed just because the PR happens to be queued).
|
|
24
|
+
*/
|
|
25
|
+
export function hasCheckDrivenActionableWork(checks, mergeStatusValue) {
|
|
26
|
+
return (checks.failing.length > 0 ||
|
|
27
|
+
[
|
|
28
|
+
...checks.failing,
|
|
29
|
+
...checks.passing,
|
|
30
|
+
...checks.skipped,
|
|
31
|
+
...checks.filtered,
|
|
32
|
+
...(checks.ignored ?? []),
|
|
33
|
+
].some(hasActionableAnnotation) ||
|
|
34
|
+
mergeStatusValue === "CONFLICTS");
|
|
13
35
|
}
|
|
14
36
|
export async function attachAndMergeCheckAnnotations(buckets, seenMap, prNumber, cacheOpts) {
|
|
15
37
|
const candidates = [
|
package/bin/commands/check.d.mts
CHANGED
package/bin/commands/check.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { deriveMergeStatus } from "../merge-status/derive.mjs";
|
|
|
7
7
|
import { loadConfig } from "../config/load.mjs";
|
|
8
8
|
import { classifyVisibleComments } from "../comments/visible-comments.mjs";
|
|
9
9
|
import { computeStatus } from "./check-status.mjs";
|
|
10
|
-
import { annotationMarkerBody, attachAndMergeCheckAnnotations } from "./check-annotations.mjs";
|
|
10
|
+
import { annotationMarkerBody, attachAndMergeCheckAnnotations, hasCheckDrivenActionableWork, } from "./check-annotations.mjs";
|
|
11
11
|
import { buildTerminalReport } from "./check-terminal-report.mjs";
|
|
12
12
|
import { isBlockedByFilteredCheck, refreshReadyMergeability, refreshUnknownMergeability, } from "./ready-mergeability.mjs";
|
|
13
13
|
import { loadSeenMap, markSeen, classifyItem } from "../state/seen-comments.mjs";
|
|
@@ -47,9 +47,16 @@ export async function runCheck(opts) {
|
|
|
47
47
|
const allChecks = mergeStartupFailureChecks(batchData.checks, startupFailureChecks);
|
|
48
48
|
const classifiedPrChecks = classifyChecks(allChecks);
|
|
49
49
|
const latestRemoval = batchData.latestMergeQueueRemoval;
|
|
50
|
+
// `timelineItems(last: 1, ...)` returns the single most recent removal regardless of age, so
|
|
51
|
+
// a PR removed from the queue once, long ago, and never re-added keeps returning that same
|
|
52
|
+
// historical event forever. When GitHub omits the removed queue commit for that old event
|
|
53
|
+
// (e.g. after the synthetic commit is garbage collected), freshness is unverifiable — treat
|
|
54
|
+
// it as stale/updated rather than as still current, so Shepherd doesn't escalate
|
|
55
|
+
// `merge-queue-removed` permanently on data it can no longer check. The raw removal fields
|
|
56
|
+
// still render in the merge-queue header regardless of this flag.
|
|
50
57
|
const headUpdatedAfterRemoval = Boolean(latestRemoval &&
|
|
51
|
-
latestRemoval.beforeCommitParentOids
|
|
52
|
-
|
|
58
|
+
(!latestRemoval.beforeCommitParentOids ||
|
|
59
|
+
!latestRemoval.beforeCommitParentOids.includes(batchData.headRefOid)));
|
|
53
60
|
const queueRawChecks = batchData.isInMergeQueue
|
|
54
61
|
? (batchData.mergeQueueChecks ?? [])
|
|
55
62
|
: latestRemoval && !headUpdatedAfterRemoval
|
|
@@ -118,6 +125,51 @@ export async function runCheck(opts) {
|
|
|
118
125
|
}
|
|
119
126
|
const changesRequestedReviewVisibility = classifyChangesRequestedReviewsForDisplay(batchData.changesRequestedReviews.filter((r) => !partition.suppressedChangesRequestedIds.has(r.id)), seenMap, botUsernames, batchData.viewerAuthorization?.viewerCanAdminister === true);
|
|
120
127
|
const approvedReviewVisibility = classifyReviewsForDisplay(batchData.approvedReviews, seenMap);
|
|
128
|
+
const changesRequestedReviews = changesRequestedReviewVisibility.visible;
|
|
129
|
+
const visibleChangesRequestedIds = new Set(changesRequestedReviews.map((review) => review.id));
|
|
130
|
+
const changesRequestedReviewCount = batchData.changesRequestedReviews.filter((review) => {
|
|
131
|
+
if (partition.suppressedChangesRequestedIds.has(review.id))
|
|
132
|
+
return false;
|
|
133
|
+
const isBot = !isHumanAuthor(review) || isConfiguredBotAuthor(review, botUsernames);
|
|
134
|
+
return (!isBot ||
|
|
135
|
+
batchData.viewerAuthorization?.viewerCanAdminister === true ||
|
|
136
|
+
visibleChangesRequestedIds.has(review.id));
|
|
137
|
+
}).length;
|
|
138
|
+
const approvedReviews = approvedReviewVisibility.visible;
|
|
139
|
+
let status = computeStatus(verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, mergeStatus, changesRequestedReviewCount);
|
|
140
|
+
// Resolve any pending mergeability refresh (and the resulting MERGED/CLOSED short-circuit)
|
|
141
|
+
// before deciding what to persist below — deferWhileQueued must see the same final,
|
|
142
|
+
// possibly-refreshed mergeStatus that commands/iterate/index.mts acts on, not the pre-refresh
|
|
143
|
+
// snapshot. A conflict newly discovered by this REST read is exactly the kind of check-driven
|
|
144
|
+
// signal that keeps a queued PR out of the deferral path.
|
|
145
|
+
if (status === "READY" && !didRefreshMergeability) {
|
|
146
|
+
const refreshed = await refreshReadyMergeability(prNumber, repo, batchData, verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, changesRequestedReviewCount);
|
|
147
|
+
batchData = refreshed.batchData;
|
|
148
|
+
mergeStatus = refreshed.mergeStatus;
|
|
149
|
+
status = refreshed.status;
|
|
150
|
+
if (mergeStatus.state === "MERGED" || mergeStatus.state === "CLOSED") {
|
|
151
|
+
return buildTerminalReport(prNumber, repo, batchData, mergeStatus, mergeStatus.state);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Mirrors the deferral gate in commands/iterate/index.mts exactly (via the shared
|
|
155
|
+
// hasCheckDrivenActionableWork helper, so the two can't drift): when this tick's non-CI
|
|
156
|
+
// actionable work (threads/comments/review summaries/changes-requested) will be held back
|
|
157
|
+
// because the PR is queued, none of it was actually shown to the agent this tick —
|
|
158
|
+
// persisting seen markers for it now would make it silently vanish from every later tick
|
|
159
|
+
// once it's no longer "new" or "edited", even after the PR leaves the queue. A queued PR
|
|
160
|
+
// that also has check-driven work (failing checks, actionable annotations, conflicts) is
|
|
161
|
+
// NOT deferred — index.mts still renders these items via fix_code — so this must stay false
|
|
162
|
+
// in that case too, or their seen markers would be suppressed while actually being shown.
|
|
163
|
+
const deferWhileQueued = opts.merge === true &&
|
|
164
|
+
batchData.isInMergeQueue === true &&
|
|
165
|
+
config.actions.workWhileQueued !== true &&
|
|
166
|
+
!hasCheckDrivenActionableWork({
|
|
167
|
+
failing: merged.failing,
|
|
168
|
+
passing: merged.passing,
|
|
169
|
+
skipped: merged.skipped,
|
|
170
|
+
filtered: merged.filtered,
|
|
171
|
+
ignored: merged.ignored,
|
|
172
|
+
}, mergeStatus.status);
|
|
121
173
|
if (opts.persistSeen !== false) {
|
|
122
174
|
const successfulAnnotations = [
|
|
123
175
|
...merged.passing,
|
|
@@ -127,19 +179,24 @@ export async function runCheck(opts) {
|
|
|
127
179
|
]
|
|
128
180
|
.filter((check) => check.conclusion === "SUCCESS")
|
|
129
181
|
.flatMap((check) => check.annotations ?? []);
|
|
182
|
+
const deferredMarkSeen = deferWhileQueued
|
|
183
|
+
? []
|
|
184
|
+
: [
|
|
185
|
+
...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
|
|
186
|
+
...threadVisibility.toMarkSeen.map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
|
|
187
|
+
...visibleCommentClassification.toMarkSeen.map((c) => markSeen(stateKey, c.id, c.body)),
|
|
188
|
+
...[...firstLookSummaries, ...editedSummaries].map((r) => markSeen(stateKey, r.id, r.body)),
|
|
189
|
+
...changesRequestedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
|
|
190
|
+
...approvedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
|
|
191
|
+
];
|
|
130
192
|
await Promise.allSettled([
|
|
131
193
|
...successfulAnnotations.map((a) => markSeen(stateKey, a.id, annotationMarkerBody(a))),
|
|
132
|
-
...
|
|
133
|
-
...threadVisibility.toMarkSeen.map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
|
|
134
|
-
...visibleCommentClassification.toMarkSeen.map((c) => markSeen(stateKey, c.id, c.body)),
|
|
135
|
-
...[...firstLookSummaries, ...editedSummaries].map((r) => markSeen(stateKey, r.id, r.body)),
|
|
136
|
-
...changesRequestedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
|
|
137
|
-
...approvedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
|
|
194
|
+
...deferredMarkSeen,
|
|
138
195
|
...batchData.comments
|
|
139
|
-
.filter((c) => partition.suppressedCommentIds.has(c.id))
|
|
196
|
+
.filter((c) => partition.suppressedCommentIds.has(c.id) && !deniedRuleAutoResolveCommentIds.has(c.id))
|
|
140
197
|
.map((c) => markSeen(stateKey, c.id, c.body)),
|
|
141
198
|
...batchData.reviewThreads
|
|
142
|
-
.filter((t) => partition.suppressedThreadIds.has(t.id))
|
|
199
|
+
.filter((t) => partition.suppressedThreadIds.has(t.id) && !deniedRuleAutoResolveThreadIds.has(t.id))
|
|
143
200
|
.map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
|
|
144
201
|
...batchData.reviewSummaries
|
|
145
202
|
.filter((r) => partition.suppressedReviewSummaryIds.has(r.id) &&
|
|
@@ -163,27 +220,6 @@ export async function runCheck(opts) {
|
|
|
163
220
|
...authorizedRuleAutoResolveThreadIds,
|
|
164
221
|
...[...deniedRuleAutoResolveThreadIds].filter((id) => visibleMutationThreadIds.has(id)),
|
|
165
222
|
];
|
|
166
|
-
const changesRequestedReviews = changesRequestedReviewVisibility.visible;
|
|
167
|
-
const visibleChangesRequestedIds = new Set(changesRequestedReviews.map((review) => review.id));
|
|
168
|
-
const changesRequestedReviewCount = batchData.changesRequestedReviews.filter((review) => {
|
|
169
|
-
if (partition.suppressedChangesRequestedIds.has(review.id))
|
|
170
|
-
return false;
|
|
171
|
-
const isBot = !isHumanAuthor(review) || isConfiguredBotAuthor(review, botUsernames);
|
|
172
|
-
return (!isBot ||
|
|
173
|
-
batchData.viewerAuthorization?.viewerCanAdminister === true ||
|
|
174
|
-
visibleChangesRequestedIds.has(review.id));
|
|
175
|
-
}).length;
|
|
176
|
-
const approvedReviews = approvedReviewVisibility.visible;
|
|
177
|
-
let status = computeStatus(verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, mergeStatus, changesRequestedReviewCount);
|
|
178
|
-
if (status === "READY" && !didRefreshMergeability) {
|
|
179
|
-
const refreshed = await refreshReadyMergeability(prNumber, repo, batchData, verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, changesRequestedReviewCount);
|
|
180
|
-
batchData = refreshed.batchData;
|
|
181
|
-
mergeStatus = refreshed.mergeStatus;
|
|
182
|
-
status = refreshed.status;
|
|
183
|
-
if (mergeStatus.state === "MERGED" || mergeStatus.state === "CLOSED") {
|
|
184
|
-
return buildTerminalReport(prNumber, repo, batchData, mergeStatus, mergeStatus.state);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
223
|
const blockedByFilteredCheck = isBlockedByFilteredCheck(mergeStatus, verdict);
|
|
188
224
|
const queueCommit = batchData.isInMergeQueue
|
|
189
225
|
? batchData.mergeQueueEntry?.headCommitOid
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/* eslint-disable max-lines */
|
|
1
2
|
import { runCheck } from "../check.mjs";
|
|
2
3
|
import { updateReadyDelay } from "../ready-delay.mjs";
|
|
3
4
|
import { getCurrentPrNumber } from "../../github/client.mjs";
|
|
@@ -10,7 +11,7 @@ import { clearStallState } from "../../state/iterate-stall.mjs";
|
|
|
10
11
|
import { handleFixCode } from "./fix-code.mjs";
|
|
11
12
|
import { normalizeBotUsernames } from "../../comments/authors.mjs";
|
|
12
13
|
import { autoMinimizeComments } from "../../comments/resolve.mjs";
|
|
13
|
-
import {
|
|
14
|
+
import { hasCheckDrivenActionableWork } from "../check-annotations.mjs";
|
|
14
15
|
import { buildReadyMergeResult, handleActiveMergeState } from "./merge-state.mjs";
|
|
15
16
|
import { buildIterateBase } from "./base.mjs";
|
|
16
17
|
import { markReadyIfAuthorized } from "./mark-ready.mjs";
|
|
@@ -73,9 +74,7 @@ async function runIterateCore(opts) {
|
|
|
73
74
|
(report.comments.minimizeIds?.length ?? 0) > 0 ||
|
|
74
75
|
report.comments.firstLook.length > 0 ||
|
|
75
76
|
report.changesRequestedReviews.length > 0 ||
|
|
76
|
-
report.checks.
|
|
77
|
-
checksWithActionableAnnotations(report).length > 0 ||
|
|
78
|
-
report.mergeStatus.status === "CONFLICTS" ||
|
|
77
|
+
hasCheckDrivenActionableWork(report.checks, report.mergeStatus.status) ||
|
|
79
78
|
reviewSummaryIds.length > 0 ||
|
|
80
79
|
firstLookSummaries.length > 0 ||
|
|
81
80
|
editedSummaries.length > 0 ||
|
|
@@ -85,7 +84,16 @@ async function runIterateCore(opts) {
|
|
|
85
84
|
const readyState = await updateReadyDelay(report.pr, isCleanReadyState, readyDelaySeconds, repoOwner, repoName);
|
|
86
85
|
const base = buildIterateBase(report, readyState);
|
|
87
86
|
const headSha = (await getCurrentHeadSha()) ?? "unknown";
|
|
88
|
-
|
|
87
|
+
// Checks (including merge-queue synthetic-commit checks) and hard conflicts are signals
|
|
88
|
+
// GitHub itself is already acting on — the queue will eject the PR for these regardless of
|
|
89
|
+
// what Shepherd does, so they always surface immediately. Only review threads/comments/
|
|
90
|
+
// changes-requested reviews/review summaries — the categories that would otherwise cause a
|
|
91
|
+
// Shepherd-initiated push while the PR sits safely in the queue — are eligible for deferral.
|
|
92
|
+
const checkDrivenActionableWork = hasCheckDrivenActionableWork(report.checks, report.mergeStatus.status);
|
|
93
|
+
const deferWhileQueued = opts.merge === true &&
|
|
94
|
+
report.mergeQueue?.inQueue === true &&
|
|
95
|
+
config.actions.workWhileQueued !== true;
|
|
96
|
+
if (hasActionableWork && !(deferWhileQueued && !checkDrivenActionableWork)) {
|
|
89
97
|
return handleFixCode({
|
|
90
98
|
base,
|
|
91
99
|
report,
|
|
@@ -110,6 +118,11 @@ async function runIterateCore(opts) {
|
|
|
110
118
|
base,
|
|
111
119
|
report,
|
|
112
120
|
stallKey,
|
|
121
|
+
reviewSummaryIds,
|
|
122
|
+
firstLookSummaries,
|
|
123
|
+
editedSummaries,
|
|
124
|
+
surfacedApprovals,
|
|
125
|
+
minimizeApprovals: config.iterate.minimizeApprovals,
|
|
113
126
|
});
|
|
114
127
|
if (mergeStateResult)
|
|
115
128
|
return mergeStateResult;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IterateResult, IterateResultBase, ShepherdReport } from "../../types.mts";
|
|
1
|
+
import type { IterateResult, IterateResultBase, Review, ShepherdReport } from "../../types.mts";
|
|
2
2
|
type StallKey = {
|
|
3
3
|
owner: string;
|
|
4
4
|
repo: string;
|
|
@@ -11,5 +11,10 @@ export declare function handleActiveMergeState(input: {
|
|
|
11
11
|
base: IterateResultBase;
|
|
12
12
|
report: ShepherdReport;
|
|
13
13
|
stallKey: StallKey;
|
|
14
|
+
reviewSummaryIds: string[];
|
|
15
|
+
firstLookSummaries: Review[];
|
|
16
|
+
editedSummaries: Review[];
|
|
17
|
+
surfacedApprovals: Review[];
|
|
18
|
+
minimizeApprovals: boolean;
|
|
14
19
|
}): Promise<IterateResult | null>;
|
|
15
20
|
export {};
|
|
@@ -19,14 +19,55 @@ export function buildReadyMergeResult(enabled, readyElapsed, base, report) {
|
|
|
19
19
|
}),
|
|
20
20
|
};
|
|
21
21
|
}
|
|
22
|
+
/** Raw counts of non-CI actionable work held back for one queued-PR wait tick. Omitted (all zero) when empty. */
|
|
23
|
+
function buildDeferredWork(input) {
|
|
24
|
+
const { report, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, minimizeApprovals, } = input;
|
|
25
|
+
// These buckets are not disjoint (e.g. an unresolved outdated thread is both
|
|
26
|
+
// `resolutionOnly` and `firstLook`; an eligible-to-minimize comment/summary is both
|
|
27
|
+
// `actionable`/`firstLook` and queued in `minimizeIds`/`reviewSummaryIds`) — dedupe by ID.
|
|
28
|
+
const threadIds = new Set([
|
|
29
|
+
...report.threads.actionable.map((t) => t.id),
|
|
30
|
+
...report.threads.resolutionOnly.map((t) => t.id),
|
|
31
|
+
...report.threads.firstLook.map((t) => t.id),
|
|
32
|
+
...(report.threads.ruleAutoResolveIds ?? []),
|
|
33
|
+
]);
|
|
34
|
+
const commentIds = new Set([
|
|
35
|
+
...report.comments.actionable.map((c) => c.id),
|
|
36
|
+
...(report.comments.minimizeIds ?? []),
|
|
37
|
+
...report.comments.firstLook.map((c) => c.id),
|
|
38
|
+
]);
|
|
39
|
+
const reviewSummaryIdSet = new Set([
|
|
40
|
+
...reviewSummaryIds,
|
|
41
|
+
...firstLookSummaries.map((r) => r.id),
|
|
42
|
+
...editedSummaries.map((r) => r.id),
|
|
43
|
+
...(minimizeApprovals ? surfacedApprovals.map((r) => r.id) : []),
|
|
44
|
+
]);
|
|
45
|
+
const deferredWork = {
|
|
46
|
+
threads: threadIds.size,
|
|
47
|
+
comments: commentIds.size,
|
|
48
|
+
changesRequestedReviews: report.changesRequestedReviews.length,
|
|
49
|
+
reviewSummaries: reviewSummaryIdSet.size,
|
|
50
|
+
};
|
|
51
|
+
const total = deferredWork.threads +
|
|
52
|
+
deferredWork.comments +
|
|
53
|
+
deferredWork.changesRequestedReviews +
|
|
54
|
+
deferredWork.reviewSummaries;
|
|
55
|
+
return total > 0 ? deferredWork : undefined;
|
|
56
|
+
}
|
|
22
57
|
export async function handleActiveMergeState(input) {
|
|
23
58
|
const { enabled, active, base, report, stallKey } = input;
|
|
59
|
+
const inQueue = report.mergeQueue?.inQueue === true;
|
|
24
60
|
if (enabled && active) {
|
|
25
61
|
await clearStallState(stallKey);
|
|
62
|
+
// Only the queued case ever holds back non-CI actionable work (see the `deferWhileQueued`
|
|
63
|
+
// gate in index.mts, which requires `inQueue === true`); an ordinary active auto-merge
|
|
64
|
+
// request with no queue membership never defers anything, so it never carries counts here.
|
|
65
|
+
const deferredWork = inQueue ? buildDeferredWork(input) : undefined;
|
|
26
66
|
return {
|
|
27
67
|
...base,
|
|
28
68
|
action: "wait",
|
|
29
|
-
|
|
69
|
+
...(deferredWork && { deferredWork }),
|
|
70
|
+
log: inQueue
|
|
30
71
|
? `WAIT: PR #${report.pr} is in the merge queue`
|
|
31
72
|
: `WAIT: PR #${report.pr} has auto-merge enabled`,
|
|
32
73
|
};
|
package/bin/config/load.d.mts
CHANGED
|
@@ -44,6 +44,8 @@ export interface PrShepherdConfig {
|
|
|
44
44
|
};
|
|
45
45
|
checks: {
|
|
46
46
|
ciTriggerEvents: string[];
|
|
47
|
+
/** Regex patterns matched against each raw log line; matching lines are dropped from log excerpts. Empty by default — no lines are stripped unless configured. */
|
|
48
|
+
ignoreLogLines: string[];
|
|
47
49
|
};
|
|
48
50
|
mergeStatus: {
|
|
49
51
|
blockingReviewerLogins: string[];
|
|
@@ -57,6 +59,14 @@ export interface PrShepherdConfig {
|
|
|
57
59
|
autoMarkReady: boolean;
|
|
58
60
|
/** Legacy-named patterns that keep matching Actions checks visible despite ignoreChecks. */
|
|
59
61
|
neverCancelRuns: string[];
|
|
62
|
+
/**
|
|
63
|
+
* When `false` (default), `iterate --merge` defers non-CI actionable work
|
|
64
|
+
* (review threads, comments, changes-requested reviews, review summaries)
|
|
65
|
+
* while the PR sits in the merge queue, since a Shepherd-initiated push
|
|
66
|
+
* would eject it. When `true`, restores pre-existing behavior: actionable
|
|
67
|
+
* work is handled immediately regardless of queue membership.
|
|
68
|
+
*/
|
|
69
|
+
workWhileQueued: boolean;
|
|
60
70
|
/** @deprecated Accepted for compatibility, ignored by the loader. */
|
|
61
71
|
autoResolveOutdated?: boolean;
|
|
62
72
|
/** @deprecated Accepted for compatibility, ignored by the loader. */
|
package/bin/config/load.mjs
CHANGED
|
@@ -86,6 +86,20 @@ function parseNeverCancelRuns(value) {
|
|
|
86
86
|
}
|
|
87
87
|
return value;
|
|
88
88
|
}
|
|
89
|
+
function parseIgnoreLogLines(value) {
|
|
90
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
|
|
91
|
+
throw new Error(`Invalid config: checks.ignoreLogLines must be an array of strings`);
|
|
92
|
+
}
|
|
93
|
+
for (const pattern of value) {
|
|
94
|
+
try {
|
|
95
|
+
new RegExp(pattern);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw new Error(`Invalid config: checks.ignoreLogLines contains an invalid regular expression: ${pattern}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
89
103
|
const SHEPHERD_OWNED_MERGE_FLAGS = [
|
|
90
104
|
"--repo",
|
|
91
105
|
"-R",
|
|
@@ -168,13 +182,14 @@ const KNOWN_NESTED_KEYS = {
|
|
|
168
182
|
]),
|
|
169
183
|
watch: new Set(["readyDelayMinutes", "graphqlQuotaWarnings"]),
|
|
170
184
|
resolve: new Set(["shaPoll"]),
|
|
171
|
-
checks: new Set(["ciTriggerEvents"]),
|
|
185
|
+
checks: new Set(["ciTriggerEvents", "ignoreLogLines"]),
|
|
172
186
|
mergeStatus: new Set(["blockingReviewerLogins"]),
|
|
173
187
|
merge: new Set(["commandArgs"]),
|
|
174
188
|
actions: new Set([
|
|
175
189
|
"autoMinimizeSuppressed",
|
|
176
190
|
"autoMarkReady",
|
|
177
191
|
"neverCancelRuns",
|
|
192
|
+
"workWhileQueued",
|
|
178
193
|
"autoResolveOutdated",
|
|
179
194
|
"commitSuggestions",
|
|
180
195
|
]),
|
|
@@ -261,6 +276,7 @@ export function loadConfig() {
|
|
|
261
276
|
config.merge.commandArgs = parseMergeCommandArgs(config.merge.commandArgs);
|
|
262
277
|
config.watch.graphqlQuotaWarnings = parseGraphqlQuotaWarnings(config.watch.graphqlQuotaWarnings);
|
|
263
278
|
config.iterate.minimizeComments = parseMinimizeCommentsPolicy(config.iterate.minimizeComments);
|
|
279
|
+
config.checks.ignoreLogLines = parseIgnoreLogLines(config.checks.ignoreLogLines);
|
|
264
280
|
configCache.set(cwd, config);
|
|
265
281
|
return config;
|
|
266
282
|
}
|
package/bin/config.json
CHANGED
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
38
|
"checks": {
|
|
39
|
-
"ciTriggerEvents": ["pull_request", "pull_request_target"]
|
|
39
|
+
"ciTriggerEvents": ["pull_request", "pull_request_target"],
|
|
40
|
+
"ignoreLogLines": []
|
|
40
41
|
},
|
|
41
42
|
"mergeStatus": {
|
|
42
43
|
"blockingReviewerLogins": ["copilot"]
|
|
@@ -47,6 +48,7 @@
|
|
|
47
48
|
"actions": {
|
|
48
49
|
"autoMinimizeSuppressed": true,
|
|
49
50
|
"autoMarkReady": true,
|
|
50
|
-
"neverCancelRuns": []
|
|
51
|
+
"neverCancelRuns": [],
|
|
52
|
+
"workWhileQueued": false
|
|
51
53
|
}
|
|
52
54
|
}
|
package/bin/mcp/server.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { z } from "zod";
|
|
|
5
5
|
import { createPrShepherd, PartialApplyError, PrShepherdValidationError, } from "../api.mjs";
|
|
6
6
|
import { isRepositoryQualifiedPrReference } from "../pr-reference.mjs";
|
|
7
7
|
import { formatJournalResult } from "../cli/journal-formatter.mjs";
|
|
8
|
-
import { formatCommitSuggestionResult, formatSuggestionPatchesResult, formatIterateResult, formatMarkFilesAsViewedResult, formatMutateResult, } from "../cli/formatters.mjs";
|
|
8
|
+
import { formatCommitSuggestionResult, formatSuggestionPatchesResult, formatIterateResult, formatMarkFilesAsViewedResult, formatMutateResult, projectIterateLean, } from "../cli/formatters.mjs";
|
|
9
9
|
import { formatCliError, serializeGitHubRequestErrorDetails } from "../cli/error-format.mjs";
|
|
10
10
|
import { errorToExitCode, EXIT } from "../exit-codes.mjs";
|
|
11
11
|
const QUALIFIED_PR_ERROR = "pr must be a GitHub pull-request URL or an owner/repo#number reference";
|
|
@@ -88,7 +88,13 @@ export function createPrShepherdMcpServer(options = {}) {
|
|
|
88
88
|
idempotentHint: false,
|
|
89
89
|
openWorldHint: true,
|
|
90
90
|
},
|
|
91
|
-
}, async (input) =>
|
|
91
|
+
}, async (input) => {
|
|
92
|
+
// One options object feeds both channels so JSON and Markdown cannot drift.
|
|
93
|
+
const opts = {
|
|
94
|
+
readyDelaySuffix: input.readyDelaySeconds === undefined ? undefined : `${input.readyDelaySeconds}s`,
|
|
95
|
+
};
|
|
96
|
+
return runTool(() => shepherd.iterate(requireRepositoryQualifiedPr(input)), (result) => formatIterateResult(result, opts), (result) => projectIterateLean(result, opts));
|
|
97
|
+
});
|
|
92
98
|
server.registerTool("apply", {
|
|
93
99
|
description: "Apply ordered review, journal, and file-view operations after prevalidation; explicit requests rely on GitHub's mutation response.",
|
|
94
100
|
inputSchema: applyInputSchema,
|
|
@@ -131,16 +137,22 @@ function readPackageVersion() {
|
|
|
131
137
|
const packageJson = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
132
138
|
return packageJson.version;
|
|
133
139
|
}
|
|
134
|
-
function toolResult(
|
|
140
|
+
function toolResult(structured, text) {
|
|
135
141
|
return {
|
|
136
142
|
content: [{ type: "text", text }],
|
|
137
|
-
structuredContent:
|
|
143
|
+
structuredContent: structured,
|
|
138
144
|
};
|
|
139
145
|
}
|
|
140
|
-
|
|
146
|
+
/**
|
|
147
|
+
* `project` mirrors the CLI's `--format=json` treatment of the same result. Tools
|
|
148
|
+
* whose CLI JSON is the raw result object (apply, build_suggestion_patch(es) — see
|
|
149
|
+
* handlers.mts and cli-parser.mts, which JSON.stringify the result directly) omit
|
|
150
|
+
* `project` and return the result unchanged, matching their own CLI JSON output.
|
|
151
|
+
*/
|
|
152
|
+
async function runTool(work, format, project) {
|
|
141
153
|
try {
|
|
142
154
|
const result = await work();
|
|
143
|
-
return toolResult(result, format(result));
|
|
155
|
+
return toolResult(project ? project(result) : result, format(result));
|
|
144
156
|
}
|
|
145
157
|
catch (error) {
|
|
146
158
|
return toolError(error);
|
package/bin/types/iterate.d.mts
CHANGED
|
@@ -48,6 +48,7 @@ export interface IterateResultBase {
|
|
|
48
48
|
interface IterateResultWait extends IterateResultBase {
|
|
49
49
|
action: "wait";
|
|
50
50
|
log: string;
|
|
51
|
+
deferredWork?: import("./merge-queue.mts").IterateDeferredWork;
|
|
51
52
|
}
|
|
52
53
|
export type CancelReason = "merged" | "closed" | "ready-delay-elapsed";
|
|
53
54
|
interface IterateResultCancel extends IterateResultBase {
|
|
@@ -135,9 +136,18 @@ export interface IterateCommandOptions extends GlobalOptions {
|
|
|
135
136
|
stallTimeoutSeconds?: number;
|
|
136
137
|
/** Legacy per-invocation no-op retained for API compatibility. */
|
|
137
138
|
neverCancelRuns?: string[];
|
|
139
|
+
/**
|
|
140
|
+
* Internal. `false` skips seen-marker writes (poll's discarded debounce ticks). Set
|
|
141
|
+
* only by `runPollCore`; excluded from the public `IterateInput` in api.mts.
|
|
142
|
+
*/
|
|
138
143
|
persistSeen?: boolean;
|
|
139
144
|
/** Shepherd through readiness and emit the exact merge/queue command when ready. */
|
|
140
145
|
merge?: boolean;
|
|
146
|
+
/**
|
|
147
|
+
* Internal. Defers attaching a quota warning until an until-terminal poll actually
|
|
148
|
+
* breaks. Set only by `runPollCore`; excluded from the public `IterateInput` in
|
|
149
|
+
* api.mts.
|
|
150
|
+
*/
|
|
141
151
|
deferQuotaWarning?: boolean;
|
|
142
152
|
}
|
|
143
153
|
export {};
|
|
@@ -11,3 +11,19 @@ export interface MergeQueueReport {
|
|
|
11
11
|
/** The current PR head is not a parent of the removed synthetic queue commit. */
|
|
12
12
|
headUpdatedAfterRemoval?: true;
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Raw counts of actionable work held back while the PR sits in the merge queue
|
|
16
|
+
* (`actions.workWhileQueued` is `false`, the default) — a Shepherd-initiated push
|
|
17
|
+
* or mutation right now would eject the PR. Omitted entirely once every count is
|
|
18
|
+
* zero. Not emitted for checks/annotations/conflicts: those always surface via
|
|
19
|
+
* `fix_code` immediately regardless of queue membership.
|
|
20
|
+
*/
|
|
21
|
+
export interface IterateDeferredWork {
|
|
22
|
+
/** Unique review threads across actionable, resolution-only, first-look, and rule-auto-resolve. */
|
|
23
|
+
threads: number;
|
|
24
|
+
/** Unique PR comments across actionable, minimize-queued, and first-look. */
|
|
25
|
+
comments: number;
|
|
26
|
+
changesRequestedReviews: number;
|
|
27
|
+
/** Unique review summaries across the minimize queue, first-look, edited, and (if opted in) surfaced approvals. */
|
|
28
|
+
reviewSummaries: number;
|
|
29
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pr-shepherd",
|
|
3
|
-
"version": "0.46.
|
|
3
|
+
"version": "0.46.6",
|
|
4
4
|
"description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"automation",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"github",
|
|
13
13
|
"pull-request"
|
|
14
14
|
],
|
|
15
|
-
"homepage": "https://
|
|
15
|
+
"homepage": "https://jongleberry.com/pr-shepherd/",
|
|
16
16
|
"bugs": {
|
|
17
17
|
"url": "https://github.com/jonathanong/pr-shepherd/issues"
|
|
18
18
|
},
|
|
@@ -67,12 +67,14 @@
|
|
|
67
67
|
"knip": "knip",
|
|
68
68
|
"knip:production": "knip --production",
|
|
69
69
|
"lint:dead-code": "npm run --silent knip && npm run --silent knip:production",
|
|
70
|
-
"lint": "oxlint src/ test-helpers/ fixtures/*.mts test-cases/ plugins/ .agents/plugins/ && npm run --silent lint:dead-code",
|
|
71
|
-
"format": "oxfmt src/ test-helpers/ fixtures/*.mts test-cases/*.mts plugins/ .agents/plugins/ docs/ README.md",
|
|
72
|
-
"format:check": "oxfmt --check src/ test-helpers/ fixtures/*.mts test-cases/*.mts plugins/ .agents/plugins/ docs/ README.md",
|
|
70
|
+
"lint": "oxlint src/ test-helpers/ fixtures/*.mts test-cases/ plugins/ .agents/plugins/ site/build.mjs site/serve.mjs site/lib/ && npm run --silent lint:dead-code",
|
|
71
|
+
"format": "oxfmt src/ test-helpers/ fixtures/*.mts test-cases/*.mts plugins/ .agents/plugins/ docs/ README.md site/build.mjs site/serve.mjs site/lib/",
|
|
72
|
+
"format:check": "oxfmt --check src/ test-helpers/ fixtures/*.mts test-cases/*.mts plugins/ .agents/plugins/ docs/ README.md site/build.mjs site/serve.mjs site/lib/",
|
|
73
73
|
"test": "vitest run",
|
|
74
74
|
"test:coverage": "vitest run --coverage && node scripts/strip-lcov-branches.mjs",
|
|
75
|
-
"test:watch": "vitest"
|
|
75
|
+
"test:watch": "vitest",
|
|
76
|
+
"site:build": "node site/build.mjs",
|
|
77
|
+
"site:serve": "node site/serve.mjs"
|
|
76
78
|
},
|
|
77
79
|
"dependencies": {
|
|
78
80
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
@@ -86,6 +88,7 @@
|
|
|
86
88
|
"@vitest/coverage-v8": "^4.1.4",
|
|
87
89
|
"husky": "^9.1.7",
|
|
88
90
|
"knip": "^6.14.1",
|
|
91
|
+
"marked": "^18.0.11",
|
|
89
92
|
"oxfmt": "^0.64.0",
|
|
90
93
|
"oxlint": "^1.60.0",
|
|
91
94
|
"typescript": "^7.0.2",
|