pr-shepherd 0.35.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +32 -12
- package/bin/checks/conclusions.d.mts +6 -0
- package/bin/checks/conclusions.mjs +9 -0
- package/bin/checks/triage.mjs +11 -3
- package/bin/cli/args.mjs +1 -0
- package/bin/cli/default-poll.mjs +1 -0
- package/bin/cli/fix-formatter.mjs +4 -2
- package/bin/cli/help-command-pages.d.mts +1 -1
- package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
- package/bin/cli/help-iterate-poll-pages.mjs +10 -5
- package/bin/cli/help-top-page.d.mts +1 -1
- package/bin/cli/help-top-page.mjs +2 -1
- package/bin/cli/help.d.mts +2 -2
- package/bin/cli/iterate-formatter.mjs +11 -2
- package/bin/cli/iterate-lean.mjs +1 -0
- package/bin/cli/poll-handler.mjs +7 -0
- package/bin/commands/check-annotations.d.mts +16 -3
- package/bin/commands/check-annotations.mjs +37 -1
- package/bin/commands/check.d.mts +1 -0
- package/bin/commands/check.mjs +35 -28
- package/bin/commands/commit-suggestion.mjs +11 -5
- package/bin/commands/iterate/fix-code.mjs +13 -6
- package/bin/commands/iterate/helpers.d.mts +1 -0
- package/bin/commands/iterate/helpers.mjs +15 -6
- package/bin/commands/iterate/index.mjs +5 -10
- package/bin/commands/iterate/render.mjs +9 -5
- package/bin/commands/iterate/stall.mjs +5 -0
- package/bin/commands/poll.d.mts +2 -0
- package/bin/commands/poll.mjs +51 -17
- package/bin/commands/ready-delay.mjs +3 -13
- package/bin/config/load.mjs +67 -25
- package/bin/github/batch-page-helpers.d.mts +45 -0
- package/bin/github/batch-page-helpers.mjs +63 -0
- package/bin/github/batch-page.d.mts +14 -0
- package/bin/github/batch-page.mjs +62 -0
- package/bin/github/batch-parse-suites.d.mts +4 -0
- package/bin/github/batch-parse-suites.mjs +25 -0
- package/bin/github/batch-parser-helpers.mjs +1 -0
- package/bin/github/batch-parsers-rules.d.mts +6 -0
- package/bin/github/batch-parsers-rules.mjs +122 -0
- package/bin/github/batch-parsers.mjs +6 -0
- package/bin/github/batch-raw-rules.d.mts +59 -0
- package/bin/github/batch-raw-rules.mjs +1 -0
- package/bin/github/batch-raw-types.d.mts +25 -11
- package/bin/github/batch.d.mts +2 -0
- package/bin/github/batch.mjs +14 -120
- package/bin/github/gql/batch-pr-page.gql +194 -0
- package/bin/github/gql/batch-pr.gql +79 -21
- package/bin/github/gql/commit-suggestion-thread.gql +40 -0
- package/bin/github/http.d.mts +2 -1
- package/bin/github/http.mjs +2 -1
- package/bin/github/pagination.d.mts +3 -2
- package/bin/github/pagination.mjs +3 -2
- package/bin/github/queries.d.mts +4 -0
- package/bin/github/queries.mjs +4 -0
- package/bin/github/rest-http.d.mts +6 -1
- package/bin/github/rest-http.mjs +25 -86
- package/bin/github/rest-text.d.mts +1 -0
- package/bin/github/rest-text.mjs +88 -0
- package/bin/github/suggestion-thread.d.mts +9 -0
- package/bin/github/suggestion-thread.mjs +45 -0
- package/bin/github/thread-comments.mjs +12 -8
- package/bin/merge-status/derive.mjs +2 -0
- package/bin/merge-status/requirements-format.d.mts +3 -0
- package/bin/merge-status/requirements-format.mjs +88 -0
- package/bin/merge-status/requirements.d.mts +2 -0
- package/bin/merge-status/requirements.mjs +51 -0
- package/bin/reporters/agent.mjs +0 -3
- package/bin/state/base.d.mts +9 -0
- package/bin/state/base.mjs +23 -0
- package/bin/state/bot-cr-seen.mjs +3 -13
- package/bin/state/fix-attempts.mjs +3 -13
- package/bin/state/iterate-stall.mjs +3 -13
- package/bin/state/seen-comments.mjs +6 -13
- package/bin/types/check-classification.d.mts +2 -2
- package/bin/types/github.d.mts +6 -7
- package/bin/types/iterate.d.mts +6 -7
- package/bin/types/merge-requirements.d.mts +82 -0
- package/bin/types/merge-requirements.mjs +2 -0
- package/bin/types/report.d.mts +5 -5
- package/bin/types.d.mts +1 -0
- package/bin/types.mjs +1 -0
- package/bin/util/path-segment.d.mts +2 -0
- package/bin/util/path-segment.mjs +2 -0
- package/bin/util/pool.d.mts +2 -0
- package/bin/util/pool.mjs +18 -0
- package/package.json +1 -1
- 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/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 {
|
|
10
|
+
import { attachAndMergeCheckAnnotations } 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";
|
|
@@ -38,7 +38,9 @@ export async function runCheck(opts) {
|
|
|
38
38
|
if (mergeStatus.state === "MERGED" || mergeStatus.state === "CLOSED") {
|
|
39
39
|
return buildTerminalReport(prNumber, repo, batchData, mergeStatus, mergeStatus.state);
|
|
40
40
|
}
|
|
41
|
-
const startupFailureChecks =
|
|
41
|
+
const startupFailureChecks = result.checkSuitesComplete
|
|
42
|
+
? []
|
|
43
|
+
: await fetchStartupFailureChecks(repo, batchData.headRefOid, prNumber);
|
|
42
44
|
const allChecks = mergeStartupFailureChecks(batchData.checks, startupFailureChecks);
|
|
43
45
|
const classifiedChecks = classifyChecks(allChecks);
|
|
44
46
|
const verdict = getCiVerdict(classifiedChecks);
|
|
@@ -47,6 +49,7 @@ export async function runCheck(opts) {
|
|
|
47
49
|
const inProgress = classifiedChecks.filter((c) => c.category === "in_progress");
|
|
48
50
|
const skipped = classifiedChecks.filter((c) => c.category === "skipped");
|
|
49
51
|
const filtered = classifiedChecks.filter((c) => c.category === "filtered");
|
|
52
|
+
const ignored = classifiedChecks.filter((c) => c.category === "ignored");
|
|
50
53
|
const triagedBase = failing.length > 0 && !opts.skipTriage ? await triageFailingChecks(failing, repo) : failing;
|
|
51
54
|
const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
|
|
52
55
|
const seenMap = await loadSeenMap(stateKey);
|
|
@@ -54,7 +57,8 @@ export async function runCheck(opts) {
|
|
|
54
57
|
const ruleSet = await loadRules(discoverRuleFiles(getEffectiveCwd()));
|
|
55
58
|
const classifyIndex = buildClassifyIndex(ruleSet, batchData);
|
|
56
59
|
const partition = partitionBatch(classifyIndex, batchData);
|
|
57
|
-
const
|
|
60
|
+
const merged = await attachAndMergeCheckAnnotations({ passing, failing: triagedBase, skipped, filtered, ignored }, seenMap, prNumber);
|
|
61
|
+
const ignoredAnnotated = merged.ignored.filter((c) => (c.annotations?.length ?? 0) > 0);
|
|
58
62
|
const minimizedCommentCandidates = batchData.comments.filter((c) => c.isMinimized && !partition.suppressedCommentIds.has(c.id));
|
|
59
63
|
const visibleCommentClassification = classifyVisibleComments(batchData.comments.filter((c) => !partition.suppressedCommentIds.has(c.id)), seenMap, config.iterate.minimizeComments, botUsernames);
|
|
60
64
|
const threadVisibility = classifyThreadVisibility(batchData.reviewThreads.filter((t) => !partition.suppressedThreadIds.has(t.id)), seenMap, botUsernames);
|
|
@@ -80,27 +84,29 @@ export async function runCheck(opts) {
|
|
|
80
84
|
}
|
|
81
85
|
const changesRequestedReviewVisibility = classifyChangesRequestedReviewsForDisplay(batchData.changesRequestedReviews.filter((r) => !partition.suppressedChangesRequestedIds.has(r.id)), seenMap, botUsernames);
|
|
82
86
|
const approvedReviewVisibility = classifyReviewsForDisplay(batchData.approvedReviews, seenMap);
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
87
|
+
if (opts.persistSeen !== false) {
|
|
88
|
+
await Promise.allSettled([
|
|
89
|
+
...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
|
|
90
|
+
...threadVisibility.toMarkSeen.map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
|
|
91
|
+
...visibleCommentClassification.toMarkSeen.map((c) => markSeen(stateKey, c.id, c.body)),
|
|
92
|
+
...[...firstLookSummaries, ...editedSummaries].map((r) => markSeen(stateKey, r.id, r.body)),
|
|
93
|
+
...changesRequestedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
|
|
94
|
+
...approvedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
|
|
95
|
+
...batchData.comments
|
|
96
|
+
.filter((c) => partition.suppressedCommentIds.has(c.id))
|
|
97
|
+
.map((c) => markSeen(stateKey, c.id, c.body)),
|
|
98
|
+
...batchData.reviewThreads
|
|
99
|
+
.filter((t) => partition.suppressedThreadIds.has(t.id))
|
|
100
|
+
.map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
|
|
101
|
+
...batchData.reviewSummaries
|
|
102
|
+
.filter((r) => partition.suppressedReviewSummaryIds.has(r.id))
|
|
103
|
+
.map((r) => markSeen(stateKey, r.id, r.body)),
|
|
104
|
+
...batchData.changesRequestedReviews
|
|
105
|
+
.filter((r) => partition.suppressedChangesRequestedIds.has(r.id))
|
|
106
|
+
.map((r) => markSeen(stateKey, r.id, r.body)),
|
|
107
|
+
]);
|
|
108
|
+
await markReviewInlineThreadMarkers(stateKey, batchData.reviewThreads);
|
|
109
|
+
}
|
|
104
110
|
const { threadIds: ruleAutoResolveThreadIds, commentIds: ruleAutoResolveCommentIds, reviewSummaryIds: ruleAutoResolveReviewSummaryIds, } = await remainingRuleAutoResolveIds(partition, opts.autoMinimizeSuppressed);
|
|
105
111
|
const changesRequestedReviews = changesRequestedReviewVisibility.visible;
|
|
106
112
|
const changesRequestedReviewCount = batchData.changesRequestedReviews.filter((r) => !partition.suppressedChangesRequestedIds.has(r.id)).length;
|
|
@@ -121,11 +127,12 @@ export async function runCheck(opts) {
|
|
|
121
127
|
baseBranch: batchData.baseRefName,
|
|
122
128
|
mergeStatus,
|
|
123
129
|
checks: {
|
|
124
|
-
passing,
|
|
125
|
-
failing:
|
|
130
|
+
passing: merged.passing,
|
|
131
|
+
failing: merged.failing,
|
|
126
132
|
inProgress: inProgress,
|
|
127
|
-
skipped,
|
|
128
|
-
filtered,
|
|
133
|
+
skipped: merged.skipped,
|
|
134
|
+
filtered: merged.filtered,
|
|
135
|
+
...(ignoredAnnotated.length > 0 && { ignored: ignoredAnnotated }),
|
|
129
136
|
filteredNames: verdict.filteredNames,
|
|
130
137
|
blockedByFilteredCheck,
|
|
131
138
|
...(verdict.ignoredNames.length > 0 && { ignoredNames: verdict.ignoredNames }),
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { execFile as execFileCb } from "node:child_process";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
|
-
import { resolve } from "node:path";
|
|
3
|
+
import { relative, resolve } from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { getRepoInfo, getCurrentPrNumber, getCurrentBranch } from "../github/client.mjs";
|
|
6
|
-
import {
|
|
6
|
+
import { fetchSuggestionThread } from "../github/suggestion-thread.mjs";
|
|
7
7
|
import { parseSuggestion, isCommittableSuggestion } from "../suggestions/parse.mjs";
|
|
8
8
|
import { buildUnifiedDiff } from "../suggestions/patch.mjs";
|
|
9
9
|
import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
@@ -28,7 +28,7 @@ export async function runCommitSuggestion(opts) {
|
|
|
28
28
|
cwd: getExecutionCwd(),
|
|
29
29
|
});
|
|
30
30
|
const localHeadSha = localHeadOut.trim();
|
|
31
|
-
const
|
|
31
|
+
const data = await fetchSuggestionThread(prNumber, repo, opts.threadId);
|
|
32
32
|
if (!data.headRepoWithOwner) {
|
|
33
33
|
throw new ShepherdError(`PR #${prNumber} head repository is unavailable (fork may have been deleted).`, EXIT.UNAVAILABLE);
|
|
34
34
|
}
|
|
@@ -40,7 +40,7 @@ export async function runCommitSuggestion(opts) {
|
|
|
40
40
|
throw new ShepherdError(`Local HEAD ${localHeadSha} does not match PR head ${data.headRefOid}. ` +
|
|
41
41
|
`Pull/rebase "${data.headRefName}" to the latest PR head and try again.`, EXIT.UNAVAILABLE);
|
|
42
42
|
}
|
|
43
|
-
const thread = data.
|
|
43
|
+
const thread = data.thread;
|
|
44
44
|
if (!thread) {
|
|
45
45
|
throw new ShepherdError(`Thread ${opts.threadId} not found on PR #${prNumber}.`, EXIT.UNAVAILABLE);
|
|
46
46
|
}
|
|
@@ -73,7 +73,13 @@ export async function runCommitSuggestion(opts) {
|
|
|
73
73
|
const startLine = thread.startLine ?? thread.line;
|
|
74
74
|
const endLine = thread.line;
|
|
75
75
|
const filePath = thread.path;
|
|
76
|
-
const
|
|
76
|
+
const cwd = getEffectiveCwd();
|
|
77
|
+
const resolvedPath = resolve(cwd, filePath);
|
|
78
|
+
const rel = relative(cwd, resolvedPath);
|
|
79
|
+
if (rel.startsWith("..") || rel === "") {
|
|
80
|
+
throw new ShepherdError(`Thread ${opts.threadId} path escapes the working tree.`, EXIT.UNAVAILABLE);
|
|
81
|
+
}
|
|
82
|
+
const originalContent = await readFile(resolvedPath, "utf8");
|
|
77
83
|
const patch = buildUnifiedDiff({
|
|
78
84
|
path: filePath,
|
|
79
85
|
originalContent,
|
|
@@ -8,7 +8,7 @@ import { buildResolveCommand } from "./classify.mjs";
|
|
|
8
8
|
import { buildFixInstructions } from "./render.mjs";
|
|
9
9
|
import { applyStallGuard } from "./stall.mjs";
|
|
10
10
|
import { tryCancelRun, buildAutoCancelRunIdsWithOptions, buildInProgressRunIds, buildRunProtection, } from "./helpers.mjs";
|
|
11
|
-
import { annotationMarkerBody } from "../check-annotations.mjs";
|
|
11
|
+
import { annotationMarkerBody, checksWithUnseenAnnotations } from "../check-annotations.mjs";
|
|
12
12
|
import { threadTranscriptBody } from "../../threads/transcript.mjs";
|
|
13
13
|
import { isHumanAuthor, isConfiguredBotAuthor } from "../../comments/authors.mjs";
|
|
14
14
|
import { loadConfig } from "../../config/load.mjs";
|
|
@@ -30,6 +30,7 @@ function nextFixAttempts(stored, headSha, threads) {
|
|
|
30
30
|
export async function handleFixCode(ctx) {
|
|
31
31
|
const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, botUsernames, ruleAutoResolveThreadIds, } = ctx;
|
|
32
32
|
const failingChecks = report.checks.failing;
|
|
33
|
+
const annotatedExtra = checksWithUnseenAnnotations(report).filter((c) => c.category !== "failing");
|
|
33
34
|
const { protectedRunIds, protectedRuns } = buildRunProtection([...failingChecks, ...report.checks.inProgress], opts.neverCancelRuns);
|
|
34
35
|
const stored = await readFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber });
|
|
35
36
|
const { threadAttempts, threadBodyHashes } = nextFixAttempts(stored, headSha, report.threads.actionable);
|
|
@@ -89,7 +90,11 @@ export async function handleFixCode(ctx) {
|
|
|
89
90
|
const threads = report.threads.actionable.map(toAgentThread);
|
|
90
91
|
const resolutionOnlyThreads = report.threads.resolutionOnly;
|
|
91
92
|
const actionableComments = report.comments.actionable.map(toAgentComment);
|
|
92
|
-
const
|
|
93
|
+
const failingAgentChecks = toAgentChecks(failingChecks);
|
|
94
|
+
const checks = [
|
|
95
|
+
...failingAgentChecks,
|
|
96
|
+
...toAgentChecks(annotatedExtra).map((c) => ({ ...c, annotationOnly: true })),
|
|
97
|
+
];
|
|
93
98
|
const { changesRequestedReviews } = report;
|
|
94
99
|
const hasConflicts = report.mergeStatus.status === "CONFLICTS";
|
|
95
100
|
const isBehind = report.mergeStatus.status === "BEHIND";
|
|
@@ -98,7 +103,8 @@ export async function handleFixCode(ctx) {
|
|
|
98
103
|
// summary-only iterations have no path to a push, so listing runs would prompt
|
|
99
104
|
// unnecessary cancellation.
|
|
100
105
|
const pushLikely = threads.length > 0 ||
|
|
101
|
-
|
|
106
|
+
failingAgentChecks.length > 0 ||
|
|
107
|
+
annotatedExtra.length > 0 ||
|
|
102
108
|
hasConflicts ||
|
|
103
109
|
changesRequestedReviews.length > 0 ||
|
|
104
110
|
actionableComments.length > 0;
|
|
@@ -110,13 +116,14 @@ export async function handleFixCode(ctx) {
|
|
|
110
116
|
: [];
|
|
111
117
|
const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
|
|
112
118
|
const allCommentIds = [...commentMinimizeIds, ...reviewSummaryIds];
|
|
113
|
-
const { resolveCommand, resolveOnlyCommand } = buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews,
|
|
119
|
+
const { resolveCommand, resolveOnlyCommand } = buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, failingAgentChecks, prNumber, botUsernames, ruleAutoResolveThreadIds);
|
|
114
120
|
// Safety: if the base branch is unknown, escalate when a push is plausible — the agent
|
|
115
121
|
// would need the correct base to rebase safely. This is a conservative guard, not a
|
|
116
122
|
// prediction that the agent *will* push. Intentionally broader than `pushLikely` above:
|
|
117
123
|
// resolution-only threads also need a known base in case the agent does push.
|
|
118
124
|
const pushIsPlausible = threads.length > 0 ||
|
|
119
|
-
|
|
125
|
+
failingAgentChecks.length > 0 ||
|
|
126
|
+
annotatedExtra.length > 0 ||
|
|
120
127
|
hasConflicts ||
|
|
121
128
|
changesRequestedReviews.length > 0 ||
|
|
122
129
|
actionableComments.length > 0 ||
|
|
@@ -166,7 +173,7 @@ export async function handleFixCode(ctx) {
|
|
|
166
173
|
cancelled,
|
|
167
174
|
};
|
|
168
175
|
const result = await applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, prospectiveResult, report, reviewSummaryIds);
|
|
169
|
-
if (result.action === "fix_code") {
|
|
176
|
+
if (result.action === "fix_code" && opts.persistSeen !== false) {
|
|
170
177
|
await Promise.allSettled(result.fix.checks.flatMap((ch) => (ch.annotations ?? []).map((a) => markSeen(stallKey, a.id, annotationMarkerBody(a)))));
|
|
171
178
|
}
|
|
172
179
|
return result;
|
|
@@ -11,3 +11,4 @@ export declare function buildActiveChecks(report: ShepherdReport): ActiveCheck[]
|
|
|
11
11
|
export declare function tryCancelRun(runId: string, owner: string, repo: string): Promise<string | null>;
|
|
12
12
|
export declare function getCurrentHeadSha(): Promise<string | null>;
|
|
13
13
|
export declare function buildWaitLog(base: IterateResultBase): string;
|
|
14
|
+
export declare function blockedCancelNote(base: IterateResultBase): string;
|
|
@@ -2,6 +2,7 @@ import { execFile as execFileCb } from "node:child_process";
|
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
3
|
import { rest } from "../../github/http.mjs";
|
|
4
4
|
import { getExecutionCwd } from "../../execution-context.mjs";
|
|
5
|
+
import { blockedReasonFromRequirements } from "../../merge-status/requirements-format.mjs";
|
|
5
6
|
const execFile = promisify(execFileCb);
|
|
6
7
|
export { buildAutoCancelRunIdsWithOptions, buildInProgressRunIds, buildRunProtection, } from "./reruns.mjs";
|
|
7
8
|
export function buildSummary(report) {
|
|
@@ -40,6 +41,7 @@ export function buildTerminalCancelResult(report) {
|
|
|
40
41
|
summary: buildSummary(report),
|
|
41
42
|
baseBranch: report.baseBranch,
|
|
42
43
|
branchProtection: report.branchProtection,
|
|
44
|
+
mergeRequirements: report.mergeStatus.mergeRequirements,
|
|
43
45
|
checks: buildRelevantChecks(report),
|
|
44
46
|
inProgressChecks: buildActiveChecks(report),
|
|
45
47
|
...buildSuppressedCheckFields(report),
|
|
@@ -134,14 +136,15 @@ export function buildWaitLog(base) {
|
|
|
134
136
|
.join(", ")}`);
|
|
135
137
|
}
|
|
136
138
|
switch (base.mergeStatus) {
|
|
137
|
-
case "BLOCKED":
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
else
|
|
139
|
+
case "BLOCKED": {
|
|
140
|
+
const fromRules = blockedReasonFromRequirements(base.mergeRequirements);
|
|
141
|
+
if (fromRules)
|
|
142
|
+
parts.push(fromRules);
|
|
143
|
+
else if (!base.blockingBotReviewInProgress) {
|
|
143
144
|
parts.push("awaiting human review or branch protection");
|
|
145
|
+
}
|
|
144
146
|
break;
|
|
147
|
+
}
|
|
145
148
|
case "BEHIND":
|
|
146
149
|
parts.push("branch is behind base");
|
|
147
150
|
break;
|
|
@@ -157,3 +160,9 @@ export function buildWaitLog(base) {
|
|
|
157
160
|
}
|
|
158
161
|
return parts.join(" — ");
|
|
159
162
|
}
|
|
163
|
+
export function blockedCancelNote(base) {
|
|
164
|
+
if (base.mergeStatus !== "BLOCKED")
|
|
165
|
+
return "has been ready for review";
|
|
166
|
+
const fromRules = blockedReasonFromRequirements(base.mergeRequirements);
|
|
167
|
+
return fromRules ? `is ${fromRules}` : "is awaiting human review or branch protection resolution";
|
|
168
|
+
}
|
|
@@ -5,13 +5,14 @@ import { graphql } from "../../github/http.mjs";
|
|
|
5
5
|
import { MARK_PR_READY_MUTATION } from "../../github/queries.mjs";
|
|
6
6
|
import { loadConfig } from "../../config/load.mjs";
|
|
7
7
|
import { EXIT, ShepherdError } from "../../exit-codes.mjs";
|
|
8
|
-
import { getCurrentHeadSha, buildSummary, buildRelevantChecks, buildActiveChecks, buildWaitLog, buildSuppressedCheckFields, buildTerminalCancelResult, } from "./helpers.mjs";
|
|
8
|
+
import { getCurrentHeadSha, buildSummary, buildRelevantChecks, buildActiveChecks, buildWaitLog, buildSuppressedCheckFields, buildTerminalCancelResult, blockedCancelNote, } from "./helpers.mjs";
|
|
9
9
|
import { classifyReviewSummaries } from "./classify.mjs";
|
|
10
10
|
import { applyStallGuard } from "./stall.mjs";
|
|
11
11
|
import { clearStallState } from "../../state/iterate-stall.mjs";
|
|
12
12
|
import { handleFixCode } from "./fix-code.mjs";
|
|
13
13
|
import { normalizeBotUsernames } from "../../comments/authors.mjs";
|
|
14
14
|
import { autoMinimizeComments } from "../../comments/resolve.mjs";
|
|
15
|
+
import { checksWithUnseenAnnotations } from "../check-annotations.mjs";
|
|
15
16
|
export async function runIterate(opts) {
|
|
16
17
|
const config = loadConfig();
|
|
17
18
|
const botUsernames = normalizeBotUsernames(config.botUsernames);
|
|
@@ -69,6 +70,7 @@ export async function runIterate(opts) {
|
|
|
69
70
|
report.comments.firstLook.length > 0 ||
|
|
70
71
|
report.changesRequestedReviews.length > 0 ||
|
|
71
72
|
report.checks.failing.length > 0 ||
|
|
73
|
+
checksWithUnseenAnnotations(report).length > 0 ||
|
|
72
74
|
report.mergeStatus.status === "CONFLICTS" ||
|
|
73
75
|
reviewSummaryIds.length > 0 ||
|
|
74
76
|
firstLookSummaries.length > 0 ||
|
|
@@ -90,6 +92,7 @@ export async function runIterate(opts) {
|
|
|
90
92
|
summary: buildSummary(report),
|
|
91
93
|
baseBranch: report.baseBranch,
|
|
92
94
|
branchProtection: report.branchProtection,
|
|
95
|
+
mergeRequirements: report.mergeStatus.mergeRequirements,
|
|
93
96
|
checks: buildRelevantChecks(report),
|
|
94
97
|
inProgressChecks: buildActiveChecks(report),
|
|
95
98
|
...buildSuppressedCheckFields(report),
|
|
@@ -97,15 +100,7 @@ export async function runIterate(opts) {
|
|
|
97
100
|
};
|
|
98
101
|
if (readyState.shouldCancel) {
|
|
99
102
|
await clearStallState(stallKey);
|
|
100
|
-
|
|
101
|
-
if (base.mergeStatus !== "BLOCKED")
|
|
102
|
-
cancelNote = "has been ready for review";
|
|
103
|
-
else if (base.reviewDecision === "REVIEW_REQUIRED")
|
|
104
|
-
cancelNote = "is awaiting human review";
|
|
105
|
-
else if (base.reviewDecision === "APPROVED")
|
|
106
|
-
cancelNote = "is awaiting additional approvals";
|
|
107
|
-
else
|
|
108
|
-
cancelNote = "is awaiting human review or branch protection resolution";
|
|
103
|
+
const cancelNote = blockedCancelNote(base);
|
|
109
104
|
return {
|
|
110
105
|
...base,
|
|
111
106
|
action: "cancel",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { renderShellCommand } from "../../cli/runner.mjs";
|
|
2
2
|
import { buildFailingCheckInstructions, buildCrStaleClause, buildBehindBaseHintInstruction, buildResolveCommandInstruction, } from "./check-instructions.mjs";
|
|
3
3
|
import { SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS, buildShepherdJournalInstruction, } from "../shepherd-journal.mjs";
|
|
4
|
+
import { isFailingAgentCheck } from "../../checks/conclusions.mjs";
|
|
4
5
|
import { buildCommitSuggestionInstruction } from "../commit-suggestion-instruction.mjs";
|
|
5
6
|
const FIX_INSTRUCTION_STOP = "Stop this iteration — if you pushed new commits, CI needs time before the next tick; otherwise stop before the next tick.";
|
|
6
7
|
/** Render a resolve command as a shell snippet. Appends `--require-sha "$HEAD_SHA"` when set. */
|
|
@@ -13,8 +14,11 @@ export function renderResolveCommand(rc) {
|
|
|
13
14
|
export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = [], resolveOnlyCommand, behindBaseHint = "", // iterate.behindBaseHint — see buildBehindBaseHintInstruction
|
|
14
15
|
isBehind = false) {
|
|
15
16
|
const instructions = [];
|
|
17
|
+
const failingChecks = checks.filter((c) => isFailingAgentCheck(c));
|
|
18
|
+
const hasAnnotations = checks.some((c) => (c.annotations?.length ?? 0) > 0);
|
|
16
19
|
const hasNonConflictHints = threads.length > 0 ||
|
|
17
|
-
|
|
20
|
+
failingChecks.length > 0 ||
|
|
21
|
+
hasAnnotations ||
|
|
18
22
|
changesRequestedReviews.length > 0 ||
|
|
19
23
|
actionableComments.length > 0;
|
|
20
24
|
// Leading decision or mandatory instruction depending on what actionable items exist.
|
|
@@ -24,9 +28,9 @@ isBehind = false) {
|
|
|
24
28
|
actionableSections.push("`## Review threads`");
|
|
25
29
|
if (actionableComments.length > 0)
|
|
26
30
|
actionableSections.push("`## Actionable comments`");
|
|
27
|
-
if (
|
|
31
|
+
if (failingChecks.length > 0)
|
|
28
32
|
actionableSections.push("`## Failing checks`");
|
|
29
|
-
if (
|
|
33
|
+
if (hasAnnotations) {
|
|
30
34
|
actionableSections.push("`## Check annotations`");
|
|
31
35
|
}
|
|
32
36
|
if (changesRequestedReviews.length > 0)
|
|
@@ -68,8 +72,8 @@ isBehind = false) {
|
|
|
68
72
|
if (resolutionOnlyThreads.length > 0) {
|
|
69
73
|
instructions.push(`Review the threads under \`## Review threads to resolve\`. Human-authored threads are replied to by the \`apply review:\` command shown below; Shepherd does not resolve them. Bot/non-human threads are included in \`--resolve-thread-ids\`.`);
|
|
70
74
|
}
|
|
71
|
-
instructions.push(...buildFailingCheckInstructions(
|
|
72
|
-
if (
|
|
75
|
+
instructions.push(...buildFailingCheckInstructions(failingChecks));
|
|
76
|
+
if (hasAnnotations) {
|
|
73
77
|
instructions.push(`For each item under \`## Check annotations\`: inspect the referenced file range and decide whether the annotation requires a code change. These annotations are surfaced once per PR and do not need any resolve/minimize mutation.`);
|
|
74
78
|
}
|
|
75
79
|
if (changesRequestedReviews.length > 0) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readStallState, writeStallState } from "../../state/iterate-stall.mjs";
|
|
2
2
|
import { toAgentThread, toAgentComment, toAgentStalledCheck } from "../../reporters/agent.mjs";
|
|
3
3
|
import { buildEscalateSuggestion, buildEscalateHumanMessage, formatDurationApprox, } from "./escalate.mjs";
|
|
4
|
+
import { checksWithUnseenAnnotations } from "../check-annotations.mjs";
|
|
4
5
|
function computeStallFingerprint(action, headSha, base, report, reviewSummaryIds) {
|
|
5
6
|
const checks = [
|
|
6
7
|
...report.checks.failing.map((f) => `failing:${f.name}:${f.conclusion}`),
|
|
@@ -13,6 +14,9 @@ function computeStallFingerprint(action, headSha, base, report, reviewSummaryIds
|
|
|
13
14
|
const reviews = report.changesRequestedReviews.map((r) => r.id).sort();
|
|
14
15
|
const summaries = [...reviewSummaryIds].sort();
|
|
15
16
|
const ruleAutoResolveSummaries = (report.ruleAutoResolveReviewSummaryIds ?? []).sort((a, b) => a.localeCompare(b));
|
|
17
|
+
const annotations = checksWithUnseenAnnotations(report)
|
|
18
|
+
.flatMap((c) => (c.annotations ?? []).map((a) => a.id))
|
|
19
|
+
.sort((a, b) => a.localeCompare(b));
|
|
16
20
|
return JSON.stringify({
|
|
17
21
|
action,
|
|
18
22
|
headSha,
|
|
@@ -28,6 +32,7 @@ function computeStallFingerprint(action, headSha, base, report, reviewSummaryIds
|
|
|
28
32
|
reviews,
|
|
29
33
|
summaries,
|
|
30
34
|
ruleAutoResolveSummaries,
|
|
35
|
+
annotations,
|
|
31
36
|
});
|
|
32
37
|
}
|
|
33
38
|
export async function applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, prospectiveResult, report, reviewSummaryIds) {
|
package/bin/commands/poll.d.mts
CHANGED
|
@@ -2,6 +2,8 @@ import type { IterateCommandOptions, IterateResult } from "../types.mts";
|
|
|
2
2
|
interface PollCommandOptions extends IterateCommandOptions {
|
|
3
3
|
intervalSeconds: number;
|
|
4
4
|
timeoutSeconds: number;
|
|
5
|
+
/** Settle window after first FIX_CODE before returning. Default 60. 0 disables. */
|
|
6
|
+
debounceSeconds?: number;
|
|
5
7
|
quietStatus?: boolean;
|
|
6
8
|
untilTerminal?: boolean;
|
|
7
9
|
}
|
package/bin/commands/poll.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { runIterate } from "./iterate/index.mjs";
|
|
2
2
|
import { sleep } from "../util/sleep.mjs";
|
|
3
|
+
const DEFAULT_POLL_DEBOUNCE_SECONDS = 60;
|
|
3
4
|
function writeTickProgress(tick, elapsedSeconds, sleepSeconds, verbose) {
|
|
4
5
|
if (verbose) {
|
|
5
6
|
process.stderr.write(`[poll tick ${tick} / +${elapsedSeconds}s] WAIT — sleeping ${sleepSeconds}s\n`);
|
|
@@ -54,11 +55,18 @@ function writeWaitProgress(opts) {
|
|
|
54
55
|
}
|
|
55
56
|
return signature;
|
|
56
57
|
}
|
|
58
|
+
function writeDebounceProgress(tick, elapsedMs, remainingMs) {
|
|
59
|
+
const elapsedSeconds = Math.round(elapsedMs / 1000);
|
|
60
|
+
const remainingSeconds = Math.round(remainingMs / 1000);
|
|
61
|
+
process.stderr.write(`[poll tick ${tick} / +${elapsedSeconds}s] FIX_CODE — debounce ${remainingSeconds}s remaining\n`);
|
|
62
|
+
}
|
|
57
63
|
/** @deprecated Hidden implementation for the legacy `poll` alias. */
|
|
58
64
|
export async function runPoll(opts) {
|
|
59
|
-
const { intervalSeconds, timeoutSeconds, quietStatus: quietStatusOpt, untilTerminal: untilTerminalOpt, ...iterateOpts } = opts;
|
|
65
|
+
const { intervalSeconds, timeoutSeconds, debounceSeconds: debounceSecondsOpt, quietStatus: quietStatusOpt, untilTerminal: untilTerminalOpt, ...iterateOpts } = opts;
|
|
60
66
|
const intervalMs = Math.min(intervalSeconds * 1000, MAX_TIMER_MS);
|
|
61
67
|
const timeoutMs = Math.min(timeoutSeconds * 1000, MAX_TIMER_MS);
|
|
68
|
+
const debounceSeconds = debounceSecondsOpt ?? DEFAULT_POLL_DEBOUNCE_SECONDS;
|
|
69
|
+
const debounceMs = Math.min(debounceSeconds * 1000, MAX_TIMER_MS);
|
|
62
70
|
const start = Date.now();
|
|
63
71
|
let tick = 0;
|
|
64
72
|
let lastResult;
|
|
@@ -73,27 +81,30 @@ export async function runPoll(opts) {
|
|
|
73
81
|
// terminal CANCEL/merged result. Pin the PR resolved by the first tick so later ticks target it
|
|
74
82
|
// directly and never re-run branch discovery.
|
|
75
83
|
let prNumber = opts.prNumber;
|
|
84
|
+
let debounceUntil = null;
|
|
76
85
|
while (true) {
|
|
77
86
|
tick += 1;
|
|
78
|
-
|
|
87
|
+
const pastDebounce = debounceUntil !== null && Date.now() >= debounceUntil;
|
|
88
|
+
lastResult = await runIterate({
|
|
89
|
+
...iterateOpts,
|
|
90
|
+
prNumber,
|
|
91
|
+
persistSeen: debounceSeconds === 0 || pastDebounce,
|
|
92
|
+
});
|
|
79
93
|
prNumber ??= lastResult.pr;
|
|
80
|
-
if (lastResult.action
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
const nextSleepMs = intervalMs;
|
|
92
|
-
if (lastResult.action === "wait") {
|
|
94
|
+
if (lastResult.action === "wait" && !pastDebounce) {
|
|
95
|
+
debounceUntil = null;
|
|
96
|
+
const elapsedMs = Date.now() - start;
|
|
97
|
+
if (!untilTerminal) {
|
|
98
|
+
const remainingMs = timeoutMs - elapsedMs;
|
|
99
|
+
if (remainingMs <= 0)
|
|
100
|
+
break;
|
|
101
|
+
if (remainingMs + TIMER_DRIFT_TOLERANCE_MS < intervalMs)
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
93
104
|
lastWaitSignature = writeWaitProgress({
|
|
94
105
|
tick,
|
|
95
106
|
elapsedMs,
|
|
96
|
-
sleepMs:
|
|
107
|
+
sleepMs: intervalMs,
|
|
97
108
|
result: lastResult,
|
|
98
109
|
quietStatus,
|
|
99
110
|
verbose,
|
|
@@ -101,8 +112,31 @@ export async function runPoll(opts) {
|
|
|
101
112
|
});
|
|
102
113
|
if (!quietStatus && !verbose)
|
|
103
114
|
dotsPrinted = true;
|
|
115
|
+
await sleep(intervalMs);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (untilTerminal && lastResult.action === "mark_ready" && !pastDebounce) {
|
|
119
|
+
debounceUntil = null;
|
|
120
|
+
await sleep(intervalMs);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (lastResult.action === "fix_code" && debounceSeconds > 0 && !pastDebounce) {
|
|
124
|
+
debounceUntil ??= Date.now() + debounceMs;
|
|
125
|
+
const remainingMs = debounceUntil - Date.now();
|
|
126
|
+
if (remainingMs > 0) {
|
|
127
|
+
if (dotsPrinted) {
|
|
128
|
+
process.stderr.write("\n");
|
|
129
|
+
dotsPrinted = false;
|
|
130
|
+
}
|
|
131
|
+
writeDebounceProgress(tick, Date.now() - start, remainingMs);
|
|
132
|
+
await sleep(Math.min(intervalMs, remainingMs));
|
|
133
|
+
}
|
|
134
|
+
// Always continue so a tick that *started* before debounceUntil but *finished*
|
|
135
|
+
// after it cannot return with persistSeen: false. The next loop iteration
|
|
136
|
+
// sees pastDebounce and persists.
|
|
137
|
+
continue;
|
|
104
138
|
}
|
|
105
|
-
|
|
139
|
+
break;
|
|
106
140
|
}
|
|
107
141
|
if (dotsPrinted)
|
|
108
142
|
process.stderr.write("\n");
|
|
@@ -6,9 +6,8 @@
|
|
|
6
6
|
* `readyDelaySeconds` consecutively. Any not-READY result resets the timer.
|
|
7
7
|
*/
|
|
8
8
|
import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import { resolveStateBase } from "../state/base.mjs";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
import { resolvePrStatePath } from "../state/base.mjs";
|
|
12
11
|
/**
|
|
13
12
|
* Update the ready-delay state machine and return the current decision.
|
|
14
13
|
*
|
|
@@ -57,16 +56,7 @@ export async function updateReadyDelay(prNumber, isReady, readyDelaySeconds, own
|
|
|
57
56
|
// Helpers
|
|
58
57
|
// ---------------------------------------------------------------------------
|
|
59
58
|
function readySincePath(pr, owner, repo) {
|
|
60
|
-
|
|
61
|
-
["owner", owner],
|
|
62
|
-
["repo", repo],
|
|
63
|
-
]) {
|
|
64
|
-
if (!SAFE_SEGMENT.test(value)) {
|
|
65
|
-
throw new Error(`Invalid path segment "${field}": ${value}`);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
const base = resolveStateBase();
|
|
69
|
-
return join(base, `${owner}-${repo}`, String(pr), "ready-since.txt");
|
|
59
|
+
return resolvePrStatePath({ owner, repo, pr }, "ready-since.txt");
|
|
70
60
|
}
|
|
71
61
|
async function safeUnlink(path) {
|
|
72
62
|
try {
|