pr-shepherd 0.32.4 → 0.33.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 +2 -2
- package/bin/cli/help-command-pages.mjs +6 -0
- package/bin/cli/journal-handler.mjs +36 -4
- package/bin/commands/iterate/check-instructions.mjs +39 -5
- package/bin/commands/iterate/classify.mjs +16 -9
- package/bin/commands/iterate/fix-code.mjs +4 -1
- package/bin/commands/iterate/index.mjs +16 -1
- package/bin/commands/iterate/render.mjs +9 -23
- package/bin/commands/mark-files-as-viewed.mjs +1 -3
- package/bin/comments/resolve.mjs +3 -1
- package/bin/config.json +2 -1
- package/bin/github/batch-response.mjs +16 -0
- package/bin/github/batch.mjs +12 -22
- package/bin/github/errors.mjs +2 -0
- package/bin/github/graphql-http.mjs +45 -13
- package/bin/github/graphql-response.mjs +42 -0
- package/package.json +1 -1
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
package/README.md
CHANGED
|
@@ -190,7 +190,7 @@ actions:
|
|
|
190
190
|
|
|
191
191
|
Environment variables:
|
|
192
192
|
|
|
193
|
-
- `GH_TOKEN` / `GITHUB_TOKEN` / `GITHUB_PERSONAL_ACCESS_TOKEN` for auth; `gh auth token` is used as a fallback.
|
|
193
|
+
- `GH_TOKEN` / `GITHUB_TOKEN` / `GITHUB_PERSONAL_ACCESS_TOKEN` for auth; `gh auth token` is used as a fallback. See [GitHub authentication and token access](docs/authentication.md) for required PAT permissions.
|
|
194
194
|
- `PR_SHEPHERD_STATE_DIR` to override state and log location.
|
|
195
195
|
- `PR_SHEPHERD_LOG_DISABLED=1` to disable per-worktree debug logging.
|
|
196
196
|
|
|
@@ -221,7 +221,7 @@ Ready-to-use examples for common patterns are in [`examples/classification/`](ex
|
|
|
221
221
|
## Requirements
|
|
222
222
|
|
|
223
223
|
- Node.js >= 22.18.0, Bun, or Deno
|
|
224
|
-
- A GitHub token or authenticated `gh` CLI
|
|
224
|
+
- A GitHub token or authenticated `gh` CLI with the [required repository access](docs/authentication.md). A classic PAT needs the `repo` scope for complete operation.
|
|
225
225
|
- `git`
|
|
226
226
|
|
|
227
227
|
## Docs
|
|
@@ -169,12 +169,18 @@ Creates the section at the end if absent. Idempotent — duplicate items are ski
|
|
|
169
169
|
|
|
170
170
|
Usage:
|
|
171
171
|
pr-shepherd journal [PR] <item> [--dry-run] [--format text|json]
|
|
172
|
+
pr-shepherd journal [PR] --file <path> [--dry-run] [--format text|json]
|
|
173
|
+
pr-shepherd journal [PR] --file - [--dry-run] [--format text|json]
|
|
172
174
|
|
|
173
175
|
PR PR number or GitHub pull request URL. Defaults to current branch PR.
|
|
174
176
|
item Markdown list item: must start with "- " followed by non-whitespace text.
|
|
175
177
|
Example: '- Rejected suggestion: kept existing pattern for consistency.'
|
|
178
|
+
Provide it as a positional argument, or via --file to avoid shell-escaping
|
|
179
|
+
backticks and multi-line Markdown. Exactly one of the two is required.
|
|
176
180
|
|
|
177
181
|
Flags:
|
|
182
|
+
--file <path> Read the entry from a file instead of a positional argument.
|
|
183
|
+
Pass --file - to read from stdin.
|
|
178
184
|
--dry-run Preview the new PR body without writing it to GitHub.
|
|
179
185
|
--format text|json Output format. Default: text.
|
|
180
186
|
--help, -h Print this help and exit before any GitHub I/O.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
1
2
|
import { runJournal } from "../commands/journal/index.mjs";
|
|
2
|
-
import { parsePrNumber } from "./args.mjs";
|
|
3
|
+
import { getFlag, parsePrNumber } from "./args.mjs";
|
|
3
4
|
import { USAGE } from "./help.mjs";
|
|
4
5
|
export async function handleJournal(args) {
|
|
5
6
|
for (const a of args) {
|
|
@@ -7,13 +8,29 @@ export async function handleJournal(args) {
|
|
|
7
8
|
continue;
|
|
8
9
|
if (a === "--dry-run" || a === "--format" || a.startsWith("--format="))
|
|
9
10
|
continue;
|
|
11
|
+
if (a === "--file" || a.startsWith("--file="))
|
|
12
|
+
continue;
|
|
10
13
|
process.stderr.write(`pr-shepherd: journal: unknown flag: "${a}"\n`);
|
|
11
14
|
process.exitCode = 1;
|
|
12
15
|
return;
|
|
13
16
|
}
|
|
14
17
|
const { prNumber, extra } = parseJournalArgs(args);
|
|
15
|
-
const
|
|
16
|
-
if (
|
|
18
|
+
const filePath = getFlag(args, "--file");
|
|
19
|
+
if (filePath !== null && extra[0]) {
|
|
20
|
+
process.stderr.write(`pr-shepherd: journal: provide the entry as a positional argument or via --file, not both\n`);
|
|
21
|
+
process.exitCode = 1;
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
let rawItem;
|
|
25
|
+
try {
|
|
26
|
+
rawItem = filePath !== null ? await readItemSource(filePath) : extra[0];
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
process.stderr.write(`pr-shepherd: journal: ${String(e)}\n`);
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (rawItem === undefined) {
|
|
17
34
|
process.stderr.write(`${USAGE.journal}\n`);
|
|
18
35
|
process.exitCode = 1;
|
|
19
36
|
return;
|
|
@@ -35,11 +52,26 @@ export async function handleJournal(args) {
|
|
|
35
52
|
process.exitCode = 1;
|
|
36
53
|
}
|
|
37
54
|
}
|
|
55
|
+
/** Reads the journal entry from a file, or from stdin when `filePath` is `-`. */
|
|
56
|
+
async function readItemSource(filePath) {
|
|
57
|
+
if (filePath === "-")
|
|
58
|
+
return readStdin();
|
|
59
|
+
return readFile(filePath, "utf8");
|
|
60
|
+
}
|
|
61
|
+
async function readStdin() {
|
|
62
|
+
const chunks = [];
|
|
63
|
+
for await (const chunk of process.stdin) {
|
|
64
|
+
chunks.push(chunk);
|
|
65
|
+
}
|
|
66
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
67
|
+
}
|
|
38
68
|
function parseJournalArgs(args) {
|
|
39
69
|
const flagConsumedIndices = new Set();
|
|
40
70
|
for (let i = 0; i < args.length; i++) {
|
|
41
71
|
const a = args[i];
|
|
42
|
-
if (a === "--format"
|
|
72
|
+
if ((a === "--format" || a === "--file") &&
|
|
73
|
+
i + 1 < args.length &&
|
|
74
|
+
!args[i + 1].startsWith("--")) {
|
|
43
75
|
flagConsumedIndices.add(i);
|
|
44
76
|
flagConsumedIndices.add(i + 1);
|
|
45
77
|
}
|
|
@@ -8,6 +8,40 @@ export function buildCrStaleClause(reviews) {
|
|
|
8
8
|
: "";
|
|
9
9
|
return bot + human;
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Build the optional behind-base push hint. Empty unless the branch is actually behind its base
|
|
13
|
+
* and the user configured a non-blank `iterate.behindBaseHint` — the CLI never prescribes
|
|
14
|
+
* rebase/merge mechanics itself (see "Keep skills and loop prompts minimal" in CLAUDE.md); this
|
|
15
|
+
* only echoes back the caller's own configured pointer. `hint` is trimmed and type-checked at the
|
|
16
|
+
* point of use (rather than at config load) so a malformed rc file value (non-string, or
|
|
17
|
+
* whitespace-only) degrades to "no hint" instead of rendering garbage into agent-facing text or
|
|
18
|
+
* discarding the rest of the user's config.
|
|
19
|
+
*/
|
|
20
|
+
export function buildBehindBaseHintInstruction(baseBranch, hint, isBehind) {
|
|
21
|
+
const trimmedHint = typeof hint === "string" ? hint.trim() : "";
|
|
22
|
+
if (!isBehind || trimmedHint === "")
|
|
23
|
+
return [];
|
|
24
|
+
return [`The branch is behind \`origin/${baseBranch}\` — ${trimmedHint} before pushing.`];
|
|
25
|
+
}
|
|
26
|
+
/** Build the `Run the resolve: command` instruction, including its optional substitution hint. */
|
|
27
|
+
export function buildResolveCommandInstruction(resolveCommand) {
|
|
28
|
+
if (!resolveCommand.hasMutations)
|
|
29
|
+
return [];
|
|
30
|
+
const instructions = [];
|
|
31
|
+
if ((resolveCommand.replyThreadIds?.length ?? 0) > 0) {
|
|
32
|
+
instructions.push(`Before running the \`resolve:\` command, remove any thread from \`--reply-thread-ids\` if the latest visible comment in that thread is your own prior Shepherd reply. Do not reply to your own comments.`);
|
|
33
|
+
}
|
|
34
|
+
const substituteParts = [];
|
|
35
|
+
if (resolveCommand.requiresHeadSha) {
|
|
36
|
+
substituteParts.push(`\`$HEAD_SHA\` with the pushed commit SHA (or \`$(git rev-parse HEAD)\` if you did not push)`);
|
|
37
|
+
}
|
|
38
|
+
if (resolveCommand.requiresDismissMessage) {
|
|
39
|
+
substituteParts.push(`\`$DISMISS_MESSAGE\` with a one-sentence reply/description of what you changed`);
|
|
40
|
+
}
|
|
41
|
+
const substituteHint = substituteParts.length > 0 ? `, substituting ${substituteParts.join(" and ")}` : "";
|
|
42
|
+
instructions.push(`Run the \`resolve:\` command shown above${substituteHint}.`);
|
|
43
|
+
return instructions;
|
|
44
|
+
}
|
|
11
45
|
export function buildFailingCheckInstructions(checks) {
|
|
12
46
|
if (checks.length === 0)
|
|
13
47
|
return [];
|
|
@@ -18,19 +52,19 @@ export function buildFailingCheckInstructions(checks) {
|
|
|
18
52
|
const hasBare = checks.some((c) => !c.runId && !c.detailsUrl);
|
|
19
53
|
const parts = [];
|
|
20
54
|
if (hasRunId) {
|
|
21
|
-
parts.push("read any included log excerpt first; fetch the full log with `gh run view <runId> --log-failed`
|
|
55
|
+
parts.push("read any included log excerpt first; fetch the full log with `gh run view <runId> --log-failed` if insufficient; rerun with `gh run rerun <runId> --failed` for transient infra failures, or apply a code fix for real test/build failures; if API/log output lacks detail, open the run URL in the GitHub UI");
|
|
22
56
|
}
|
|
23
57
|
if (hasCancelled) {
|
|
24
|
-
parts.push("for `[conclusion: CANCELLED]` entries
|
|
58
|
+
parts.push("for `[conclusion: CANCELLED]` entries (not concurrency-superseded — see `**superseded**`): rerun with `gh run rerun <runId>` unless already pushing new commits this tick, in which case the fresh run supersedes it; don't treat as resolved — distinct from `## Cancelled runs`");
|
|
25
59
|
}
|
|
26
60
|
if (hasStartupFailure) {
|
|
27
|
-
parts.push("for `[conclusion: STARTUP_FAILURE]` entries: inspect with `gh run view <runId
|
|
61
|
+
parts.push("for `[conclusion: STARTUP_FAILURE]` entries: inspect with `gh run view <runId>`, rerun with `gh run rerun <runId>` if warranted");
|
|
28
62
|
}
|
|
29
63
|
if (hasExternal) {
|
|
30
|
-
parts.push("for `external` entries
|
|
64
|
+
parts.push("for `external` entries: open the URL to inspect the failure");
|
|
31
65
|
}
|
|
32
66
|
if (hasBare) {
|
|
33
|
-
parts.push("for `(no runId)` entries: no log or URL
|
|
67
|
+
parts.push("for `(no runId)` entries: no log or URL available — escalate to a human");
|
|
34
68
|
}
|
|
35
69
|
return [`For each failing check under \`## Failing checks\`: ${parts.join("; ")}.`];
|
|
36
70
|
}
|
|
@@ -14,16 +14,21 @@ function dedupeIds(ids) {
|
|
|
14
14
|
}
|
|
15
15
|
export function classifyReviewSummaries(summaries, approvals, minimizeApprovals, minimizeComments = "all", botUsernames = new Set(), unresolvedThreads = [], ruleAutoResolveIds = []) {
|
|
16
16
|
const blockedReviewIds = new Set(unresolvedThreads.flatMap((t) => (t.reviewId !== undefined ? [t.reviewId] : [])));
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
// First-look
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
//
|
|
17
|
+
const eligible = (r) => shouldMinimizeAuthor(r.authorType, minimizeComments, r.author, botUsernames) &&
|
|
18
|
+
!blockedReviewIds.has(r.id);
|
|
19
|
+
// First-look summaries still need one tick to surface their body to the agent,
|
|
20
|
+
// so their minimize IDs ride in the agent-facing resolve command. Seen summaries
|
|
21
|
+
// (already surfaced in a prior tick) have no new content to show — the CLI
|
|
22
|
+
// self-minimizes them in-process (selfMinimizeIds) instead of routing a
|
|
23
|
+
// cosmetic-only mutation through fix_code (issue #313). Edited summaries are
|
|
24
|
+
// excluded entirely: they are already minimized server-side (body changed after
|
|
25
|
+
// minimize was applied).
|
|
26
|
+
const minimizeIds = summaries.firstLook.filter(eligible).map((r) => r.id);
|
|
27
|
+
const selfMinimizeIds = summaries.seen.filter(eligible).map((r) => r.id);
|
|
28
|
+
// Rule-matched summaries are already suppressed from agent output; bypass normal
|
|
29
|
+
// policy gates. Keep the two sets disjoint.
|
|
25
30
|
for (const id of ruleAutoResolveIds) {
|
|
26
|
-
if (!minimizeIds.includes(id))
|
|
31
|
+
if (!minimizeIds.includes(id) && !selfMinimizeIds.includes(id))
|
|
27
32
|
minimizeIds.push(id);
|
|
28
33
|
}
|
|
29
34
|
if (minimizeApprovals) {
|
|
@@ -36,6 +41,7 @@ export function classifyReviewSummaries(summaries, approvals, minimizeApprovals,
|
|
|
36
41
|
}
|
|
37
42
|
return {
|
|
38
43
|
minimizeIds,
|
|
44
|
+
selfMinimizeIds,
|
|
39
45
|
firstLookSummaries: summaries.firstLook,
|
|
40
46
|
editedSummaries: summaries.edited,
|
|
41
47
|
surfacedApprovals,
|
|
@@ -43,6 +49,7 @@ export function classifyReviewSummaries(summaries, approvals, minimizeApprovals,
|
|
|
43
49
|
}
|
|
44
50
|
return {
|
|
45
51
|
minimizeIds,
|
|
52
|
+
selfMinimizeIds,
|
|
46
53
|
firstLookSummaries: summaries.firstLook,
|
|
47
54
|
editedSummaries: summaries.edited,
|
|
48
55
|
surfacedApprovals: approvals,
|
|
@@ -11,6 +11,7 @@ import { tryCancelRun, buildAutoCancelRunIdsWithOptions, buildInProgressRunIds,
|
|
|
11
11
|
import { annotationMarkerBody } from "../check-annotations.mjs";
|
|
12
12
|
import { threadTranscriptBody } from "../../threads/transcript.mjs";
|
|
13
13
|
import { isHumanAuthor, isConfiguredBotAuthor } from "../../comments/authors.mjs";
|
|
14
|
+
import { loadConfig } from "../../config/load.mjs";
|
|
14
15
|
function nextFixAttempts(stored, headSha, threads) {
|
|
15
16
|
const threadAttempts = stored ? { ...stored.threadAttempts } : {};
|
|
16
17
|
const threadBodyHashes = stored?.threadBodyHashes
|
|
@@ -91,6 +92,8 @@ export async function handleFixCode(ctx) {
|
|
|
91
92
|
const checks = toAgentChecks(failingChecks);
|
|
92
93
|
const { changesRequestedReviews } = report;
|
|
93
94
|
const hasConflicts = report.mergeStatus.status === "CONFLICTS";
|
|
95
|
+
const isBehind = report.mergeStatus.status === "BEHIND";
|
|
96
|
+
const { behindBaseHint } = loadConfig().iterate;
|
|
94
97
|
// Only surface in-progress runs when a push is plausible — resolution-only and
|
|
95
98
|
// summary-only iterations have no path to a push, so listing runs would prompt
|
|
96
99
|
// unnecessary cancellation.
|
|
@@ -137,7 +140,7 @@ export async function handleFixCode(ctx) {
|
|
|
137
140
|
}
|
|
138
141
|
const firstLookThreads = report.threads.firstLook;
|
|
139
142
|
const firstLookComments = report.comments.firstLook;
|
|
140
|
-
const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, resolveOnlyCommand);
|
|
143
|
+
const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, resolveOnlyCommand, behindBaseHint, isBehind);
|
|
141
144
|
const prospectiveResult = {
|
|
142
145
|
...base,
|
|
143
146
|
baseBranch: baseLookup.branch,
|
|
@@ -10,6 +10,7 @@ import { applyStallGuard } from "./stall.mjs";
|
|
|
10
10
|
import { clearStallState } from "../../state/iterate-stall.mjs";
|
|
11
11
|
import { handleFixCode } from "./fix-code.mjs";
|
|
12
12
|
import { normalizeBotUsernames } from "../../comments/authors.mjs";
|
|
13
|
+
import { autoMinimizeComments } from "../../comments/resolve.mjs";
|
|
13
14
|
export async function runIterate(opts) {
|
|
14
15
|
const config = loadConfig();
|
|
15
16
|
const botUsernames = normalizeBotUsernames(config.botUsernames);
|
|
@@ -35,11 +36,25 @@ export async function runIterate(opts) {
|
|
|
35
36
|
await clearStallState(stallKey);
|
|
36
37
|
return buildTerminalCancelResult(report);
|
|
37
38
|
}
|
|
38
|
-
const { minimizeIds
|
|
39
|
+
const { minimizeIds, selfMinimizeIds, firstLookSummaries, editedSummaries, surfacedApprovals } = classifyReviewSummaries({
|
|
39
40
|
firstLook: report.firstLookSummaries,
|
|
40
41
|
seen: report.reviewSummaries,
|
|
41
42
|
edited: report.editedSummaries,
|
|
42
43
|
}, report.approvedReviews, config.iterate.minimizeApprovals, config.iterate.minimizeComments, botUsernames, [...report.threads.actionable, ...report.threads.resolutionOnly], report.ruleAutoResolveReviewSummaryIds);
|
|
44
|
+
// Already-seen review summaries have no new content to surface — minimize them
|
|
45
|
+
// in-process so they never register as agent-facing actionable work (#313).
|
|
46
|
+
// GitHub can still return a null/error/rate-limit result per ID without
|
|
47
|
+
// throwing (autoMinimizeComments reports this via `errors`, not a rejection);
|
|
48
|
+
// any ID it did not confirm minimized falls back into the agent-facing set so
|
|
49
|
+
// the resolve command remains a working fallback instead of silently dropping it.
|
|
50
|
+
let reviewSummaryIds = minimizeIds;
|
|
51
|
+
if (selfMinimizeIds.length > 0) {
|
|
52
|
+
const { minimized } = await autoMinimizeComments(selfMinimizeIds);
|
|
53
|
+
const minimizedIds = new Set(minimized);
|
|
54
|
+
const unminimized = selfMinimizeIds.filter((id) => !minimizedIds.has(id));
|
|
55
|
+
if (unminimized.length > 0)
|
|
56
|
+
reviewSummaryIds = [...reviewSummaryIds, ...unminimized];
|
|
57
|
+
}
|
|
43
58
|
const hasActionableWork = report.threads.actionable.length > 0 ||
|
|
44
59
|
report.threads.resolutionOnly.length > 0 ||
|
|
45
60
|
report.threads.firstLook.length > 0 ||
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { renderShellCommand } from "../../cli/runner.mjs";
|
|
2
|
-
import { buildFailingCheckInstructions, buildCrStaleClause } from "./check-instructions.mjs";
|
|
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
4
|
import { buildCommitSuggestionInstruction } from "../commit-suggestion-instruction.mjs";
|
|
5
5
|
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.";
|
|
@@ -10,8 +10,8 @@ export function renderResolveCommand(rc) {
|
|
|
10
10
|
parts.push("--require-sha", "$HEAD_SHA");
|
|
11
11
|
return renderShellCommand(parts);
|
|
12
12
|
}
|
|
13
|
-
export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews,
|
|
14
|
-
|
|
13
|
+
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
|
+
isBehind = false) {
|
|
15
15
|
const instructions = [];
|
|
16
16
|
const hasNonConflictHints = threads.length > 0 ||
|
|
17
17
|
checks.length > 0 ||
|
|
@@ -47,6 +47,7 @@ resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], f
|
|
|
47
47
|
else if (hasConflicts) {
|
|
48
48
|
instructions.push(`The branch has merge conflicts that must be resolved before merging (see \`**branch**\` above). Resolve them and push.`);
|
|
49
49
|
}
|
|
50
|
+
instructions.push(...buildBehindBaseHintInstruction(baseBranch, behindBaseHint, isBehind));
|
|
50
51
|
if (inProgressRunIds.length > 0) {
|
|
51
52
|
instructions.push(`If you decide to push new commits: cancel each in-progress run listed under \`## In-progress runs\` before applying code fixes (e.g. \`gh run cancel <id>\`). Runs may complete between the tick and your action; treat cancellation errors on already-finished runs as non-fatal. Skip this step if you are only resolving threads without pushing — the existing runs remain relevant.`);
|
|
52
53
|
}
|
|
@@ -54,15 +55,13 @@ resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], f
|
|
|
54
55
|
if (hasSuggestions)
|
|
55
56
|
instructions.push(buildCommitSuggestionInstruction(prNumber, "## Review threads", false));
|
|
56
57
|
if (threads.length > 0 || actionableComments.length > 0) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (actionableComments.length > 0)
|
|
61
|
-
fixSections.push("`## Actionable comments`");
|
|
58
|
+
// Actionable comments carry no file/line location (unlike threads), so "referenced above"
|
|
59
|
+
// is only accurate when threads are present.
|
|
60
|
+
const filesRef = threads.length > 0 ? "each file referenced above" : "the relevant files";
|
|
62
61
|
const suggestionFallback = hasSuggestions
|
|
63
62
|
? ` When applying a \`[suggestion]\` thread manually (e.g. after a failed \`commit-suggestion\` run), replace the exact line range shown in the heading (\`path:startLine-endLine\`) with the replacement shown in its \`Replaces lines …\` block verbatim — an empty replacement deletes those lines, a single blank line replaces the range with one blank line.`
|
|
64
63
|
: "";
|
|
65
|
-
instructions.push(`Apply code fixes: read and edit
|
|
64
|
+
instructions.push(`Apply code fixes: read and edit ${filesRef}.${suggestionFallback}`);
|
|
66
65
|
}
|
|
67
66
|
if (resolutionOnlyThreads.length > 0) {
|
|
68
67
|
instructions.push(`Review the threads under \`## Review threads to resolve\`. Human-authored threads are replied to by the \`resolve:\` command shown below; Shepherd does not resolve them. Bot/non-human threads are included in \`--resolve-thread-ids\`.`);
|
|
@@ -79,20 +78,7 @@ resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], f
|
|
|
79
78
|
}
|
|
80
79
|
if (resolveOnlyCommand?.hasMutations)
|
|
81
80
|
instructions.push(`Run the \`resolve-only:\` command shown above — no substitutions needed.`);
|
|
82
|
-
|
|
83
|
-
if ((resolveCommand.replyThreadIds?.length ?? 0) > 0) {
|
|
84
|
-
instructions.push(`Before running the \`resolve:\` command, remove any thread from \`--reply-thread-ids\` if the latest visible comment in that thread is your own prior Shepherd reply. Do not reply to your own comments.`);
|
|
85
|
-
}
|
|
86
|
-
const substituteParts = [];
|
|
87
|
-
if (resolveCommand.requiresHeadSha) {
|
|
88
|
-
substituteParts.push(`\`$HEAD_SHA\` with the pushed commit SHA (or \`$(git rev-parse HEAD)\` if you did not push)`);
|
|
89
|
-
}
|
|
90
|
-
if (resolveCommand.requiresDismissMessage) {
|
|
91
|
-
substituteParts.push(`\`$DISMISS_MESSAGE\` with a one-sentence reply/description of what you changed`);
|
|
92
|
-
}
|
|
93
|
-
const substituteHint = substituteParts.length > 0 ? `, substituting ${substituteParts.join(" and ")}` : "";
|
|
94
|
-
instructions.push(`Run the \`resolve:\` command shown above${substituteHint}.`);
|
|
95
|
-
}
|
|
81
|
+
instructions.push(...buildResolveCommandInstruction(resolveCommand));
|
|
96
82
|
if (cancelledCount > 0) {
|
|
97
83
|
instructions.push(`Do not re-run \`gh run cancel\` on the IDs listed under \`## Cancelled runs\` — those runs were already cancelled by the CLI before this turn.`);
|
|
98
84
|
}
|
|
@@ -153,9 +153,7 @@ async function bulkMarkFilesAsViewedChunk(pullRequestId, paths, result, hasPendi
|
|
|
153
153
|
let suppressCurrentChunkErrors = false;
|
|
154
154
|
let rateLimitStop;
|
|
155
155
|
try {
|
|
156
|
-
const resp = await graphqlWithRateLimit(buildBulkMutation(paths), {
|
|
157
|
-
pullRequestId,
|
|
158
|
-
});
|
|
156
|
+
const resp = await graphqlWithRateLimit(buildBulkMutation(paths), { pullRequestId }, { allowPartialData: true });
|
|
159
157
|
data = resp.data;
|
|
160
158
|
graphQlErrors = (resp.errors ?? []);
|
|
161
159
|
const messages = graphQlErrors.map((e) => e.message);
|
package/bin/comments/resolve.mjs
CHANGED
|
@@ -128,7 +128,9 @@ async function bulkApplyChunk(resolveIds, replyIds, minimizeIds, dismissIds, dis
|
|
|
128
128
|
let rateLimitStop;
|
|
129
129
|
let suppressCurrentChunkErrors = false;
|
|
130
130
|
try {
|
|
131
|
-
const resp = await graphqlWithRateLimit(doc, {}
|
|
131
|
+
const resp = await graphqlWithRateLimit(doc, {}, {
|
|
132
|
+
allowPartialData: true,
|
|
133
|
+
});
|
|
132
134
|
data = resp.data;
|
|
133
135
|
graphQlErrors = (resp.errors ?? []);
|
|
134
136
|
const graphQlErrorMessages = graphQlErrors.map((e) => e.message);
|
package/bin/config.json
CHANGED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { GitHubRequestError } from "./errors.mjs";
|
|
2
|
+
export function requireRawPr(response, pr, repo) {
|
|
3
|
+
if (!response?.repository) {
|
|
4
|
+
throw new GitHubRequestError(`GitHub GraphQL response did not include repository ${repo.owner}/${repo.name} (not found or access denied)`, { status: 200 });
|
|
5
|
+
}
|
|
6
|
+
if (!response.repository.pullRequest)
|
|
7
|
+
throw new Error(`PR #${pr} not found`);
|
|
8
|
+
return response.repository.pullRequest;
|
|
9
|
+
}
|
|
10
|
+
export function requireContextNodes(nodes) {
|
|
11
|
+
const nullIndex = nodes.findIndex((node) => node === null);
|
|
12
|
+
if (nullIndex !== -1) {
|
|
13
|
+
throw new GitHubRequestError(`Malformed GitHub GraphQL response: null check context at repository.pullRequest.commits.nodes.0.commit.statusCheckRollup.contexts.nodes.${nullIndex}`, { status: 200 });
|
|
14
|
+
}
|
|
15
|
+
return nodes;
|
|
16
|
+
}
|
package/bin/github/batch.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { paginateForward, paginateBackward } from "./pagination.mjs";
|
|
|
3
3
|
import { hydrateThreadCommentPages } from "./thread-comments.mjs";
|
|
4
4
|
import { BATCH_PR_QUERY } from "./queries.mjs";
|
|
5
5
|
import { parseRawPr } from "./batch-parsers.mjs";
|
|
6
|
+
import { requireContextNodes, requireRawPr } from "./batch-response.mjs";
|
|
6
7
|
/**
|
|
7
8
|
* Fetch all PR data needed for a `shepherd check` in one (or a few, if paginating) GraphQL requests.
|
|
8
9
|
*/
|
|
@@ -13,10 +14,7 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
|
|
|
13
14
|
repo: repo.name,
|
|
14
15
|
pr,
|
|
15
16
|
});
|
|
16
|
-
const raw = result.data
|
|
17
|
-
if (!raw) {
|
|
18
|
-
throw new Error(`PR #${pr} not found`);
|
|
19
|
-
}
|
|
17
|
+
const raw = requireRawPr(result.data, pr, repo);
|
|
20
18
|
// Paginate reviewThreads backward if the first page is incomplete.
|
|
21
19
|
let rawThreadPages = raw.reviewThreads.nodes;
|
|
22
20
|
if (raw.reviewThreads.pageInfo.hasPreviousPage && raw.reviewThreads.pageInfo.startCursor) {
|
|
@@ -29,9 +27,7 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
|
|
|
29
27
|
pr,
|
|
30
28
|
...(cursor ? { threadsCursor: cursor } : {}),
|
|
31
29
|
});
|
|
32
|
-
const pr2 = res.data
|
|
33
|
-
if (!pr2)
|
|
34
|
-
throw new Error(`PR #${pr} not found`);
|
|
30
|
+
const pr2 = requireRawPr(res.data, pr, repo);
|
|
35
31
|
return pr2.reviewThreads;
|
|
36
32
|
}, raw.reviewThreads.pageInfo.startCursor);
|
|
37
33
|
// extra contains pages before the first page.
|
|
@@ -48,9 +44,7 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
|
|
|
48
44
|
pr,
|
|
49
45
|
...(cursor ? { commentsCursor: cursor } : {}),
|
|
50
46
|
});
|
|
51
|
-
const pr2 = res.data
|
|
52
|
-
if (!pr2)
|
|
53
|
-
throw new Error(`PR #${pr} not found`);
|
|
47
|
+
const pr2 = requireRawPr(res.data, pr, repo);
|
|
54
48
|
return pr2.comments;
|
|
55
49
|
}, raw.comments.pageInfo.startCursor);
|
|
56
50
|
rawCommentNodes = [...extra, ...rawCommentNodes];
|
|
@@ -66,9 +60,7 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
|
|
|
66
60
|
pr,
|
|
67
61
|
...(cursor ? { changesRequestedCursor: cursor } : {}),
|
|
68
62
|
});
|
|
69
|
-
const pr2 = res.data
|
|
70
|
-
if (!pr2)
|
|
71
|
-
throw new Error(`PR #${pr} not found`);
|
|
63
|
+
const pr2 = requireRawPr(res.data, pr, repo);
|
|
72
64
|
return pr2.changesRequestedReviews;
|
|
73
65
|
}, raw.changesRequestedReviews.pageInfo.startCursor);
|
|
74
66
|
rawReviewNodes = [...extra, ...rawReviewNodes];
|
|
@@ -83,9 +75,7 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
|
|
|
83
75
|
pr,
|
|
84
76
|
...(cursor ? { reviewSummariesCursor: cursor } : {}),
|
|
85
77
|
});
|
|
86
|
-
const pr2 = res.data
|
|
87
|
-
if (!pr2)
|
|
88
|
-
throw new Error(`PR #${pr} not found`);
|
|
78
|
+
const pr2 = requireRawPr(res.data, pr, repo);
|
|
89
79
|
return pr2.reviewSummaries;
|
|
90
80
|
}, raw.reviewSummaries.pageInfo.startCursor);
|
|
91
81
|
rawReviewSummaryNodes = [...extra, ...rawReviewSummaryNodes];
|
|
@@ -103,15 +93,13 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
|
|
|
103
93
|
pr,
|
|
104
94
|
...(cursor ? { approvedReviewsCursor: cursor } : {}),
|
|
105
95
|
});
|
|
106
|
-
const pr2 = res.data
|
|
107
|
-
if (!pr2)
|
|
108
|
-
throw new Error(`PR #${pr} not found`);
|
|
96
|
+
const pr2 = requireRawPr(res.data, pr, repo);
|
|
109
97
|
return pr2.approvedReviews;
|
|
110
98
|
}, raw.approvedReviews.pageInfo.startCursor);
|
|
111
99
|
rawApprovedReviewNodes = [...extra, ...rawApprovedReviewNodes];
|
|
112
100
|
}
|
|
113
101
|
// Paginate check contexts forward if the first page is incomplete.
|
|
114
|
-
let rawCheckNodes = raw.commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? [];
|
|
102
|
+
let rawCheckNodes = requireContextNodes(raw.commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? []);
|
|
115
103
|
const checksPageInfo = raw.commits.nodes[0]?.commit.statusCheckRollup?.contexts.pageInfo;
|
|
116
104
|
const firstOid = raw.commits.nodes[0]?.commit.oid;
|
|
117
105
|
if (checksPageInfo?.hasNextPage && checksPageInfo.endCursor) {
|
|
@@ -125,7 +113,7 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
|
|
|
125
113
|
pr,
|
|
126
114
|
...(cursor ? { checksCursor: cursor } : {}),
|
|
127
115
|
});
|
|
128
|
-
const pr2 = res.data
|
|
116
|
+
const pr2 = requireRawPr(res.data, pr, repo);
|
|
129
117
|
if (!pr2?.commits.nodes[0]?.commit.statusCheckRollup) {
|
|
130
118
|
throw new Error(`Check-context pagination interrupted: statusCheckRollup disappeared on page ${pageCount + 2} (possible force-push race). Retry after the push stabilizes.`);
|
|
131
119
|
}
|
|
@@ -135,7 +123,9 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
|
|
|
135
123
|
}
|
|
136
124
|
pageCount++;
|
|
137
125
|
const ctxs = pr2.commits.nodes[0]?.commit.statusCheckRollup?.contexts;
|
|
138
|
-
|
|
126
|
+
if (!ctxs)
|
|
127
|
+
return { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] };
|
|
128
|
+
return { ...ctxs, nodes: requireContextNodes(ctxs.nodes) };
|
|
139
129
|
}, checksPageInfo.endCursor);
|
|
140
130
|
rawCheckNodes = [...rawCheckNodes, ...extra];
|
|
141
131
|
}
|
package/bin/github/errors.mjs
CHANGED
|
@@ -2,11 +2,13 @@ export class GitHubRequestError extends Error {
|
|
|
2
2
|
status;
|
|
3
3
|
rateLimit;
|
|
4
4
|
retryAfterSeconds;
|
|
5
|
+
graphqlErrors;
|
|
5
6
|
constructor(message, opts) {
|
|
6
7
|
super(message);
|
|
7
8
|
this.name = "GitHubRequestError";
|
|
8
9
|
this.status = opts.status;
|
|
9
10
|
this.rateLimit = opts.rateLimit;
|
|
10
11
|
this.retryAfterSeconds = opts.retryAfterSeconds;
|
|
12
|
+
this.graphqlErrors = opts.graphqlErrors;
|
|
11
13
|
}
|
|
12
14
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { appendEntry, nextEntry } from "../log/log-file.mjs";
|
|
2
2
|
import { formatRequestEntry, formatResponseEntry } from "../log/session.mjs";
|
|
3
3
|
import { GitHubRequestError } from "./errors.mjs";
|
|
4
|
+
import { formatGraphQlErrors, parseGraphQlPayload } from "./graphql-response.mjs";
|
|
4
5
|
import { makeHeaders } from "./http-auth.mjs";
|
|
5
6
|
import { requestWithTokenRetry } from "./http-request.mjs";
|
|
6
7
|
import { parseRateLimit, parseRetryAfter, redactToken, sanitizeBody, } from "./http-utils.mjs";
|
|
7
8
|
const BASE_URL = "https://api.github.com";
|
|
8
|
-
async function graphqlInner(query, vars) {
|
|
9
|
+
async function graphqlInner(query, vars, opts) {
|
|
9
10
|
const url = `${BASE_URL}/graphql`;
|
|
10
11
|
const n = nextEntry();
|
|
11
12
|
appendEntry(formatRequestEntry({
|
|
@@ -38,7 +39,28 @@ async function graphqlInner(query, vars) {
|
|
|
38
39
|
}));
|
|
39
40
|
throw new GitHubRequestError(`GitHub GraphQL request failed: ${res.status} ${sanitizeBody(body)}`, { status: res.status, rateLimit: rateLimit ?? undefined, retryAfterSeconds });
|
|
40
41
|
}
|
|
41
|
-
|
|
42
|
+
let parsed;
|
|
43
|
+
try {
|
|
44
|
+
parsed = await res.json();
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
const detail = err instanceof Error ? `: ${err.message}` : "";
|
|
48
|
+
appendEntry(formatResponseEntry({
|
|
49
|
+
n,
|
|
50
|
+
kind: "GraphQL",
|
|
51
|
+
method: "POST",
|
|
52
|
+
url,
|
|
53
|
+
status: res.status,
|
|
54
|
+
durationMs,
|
|
55
|
+
textBody: `Invalid JSON response${detail}`,
|
|
56
|
+
attempt: attempt > 1 ? attempt : undefined,
|
|
57
|
+
}));
|
|
58
|
+
throw new GitHubRequestError(`GitHub GraphQL response was not valid JSON${detail}`, {
|
|
59
|
+
status: res.status,
|
|
60
|
+
rateLimit: rateLimit ?? undefined,
|
|
61
|
+
retryAfterSeconds,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
42
64
|
appendEntry(formatResponseEntry({
|
|
43
65
|
n,
|
|
44
66
|
kind: "GraphQL",
|
|
@@ -49,25 +71,35 @@ async function graphqlInner(query, vars) {
|
|
|
49
71
|
body: parsed,
|
|
50
72
|
attempt: attempt > 1 ? attempt : undefined,
|
|
51
73
|
}));
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
74
|
+
const payload = parseGraphQlPayload(parsed, res.status, rateLimit, retryAfterSeconds);
|
|
75
|
+
if (payload.data == null) {
|
|
76
|
+
const detail = formatGraphQlErrors(payload.errors);
|
|
77
|
+
throw new GitHubRequestError(`GitHub GraphQL error (no data)${detail ? `: ${detail}` : ""}`, {
|
|
78
|
+
status: res.status,
|
|
79
|
+
rateLimit: rateLimit ?? undefined,
|
|
80
|
+
retryAfterSeconds,
|
|
81
|
+
graphqlErrors: payload.errors,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
if (payload.errors?.length && !opts.allowPartialData) {
|
|
85
|
+
throw new GitHubRequestError(`GitHub GraphQL error: ${formatGraphQlErrors(payload.errors)}`, {
|
|
55
86
|
status: res.status,
|
|
56
87
|
rateLimit: rateLimit ?? undefined,
|
|
57
88
|
retryAfterSeconds,
|
|
89
|
+
graphqlErrors: payload.errors,
|
|
58
90
|
});
|
|
59
91
|
}
|
|
60
|
-
if (
|
|
61
|
-
const messages =
|
|
92
|
+
if (payload.errors?.length) {
|
|
93
|
+
const messages = payload.errors.map((e) => e.message).join("; ");
|
|
62
94
|
process.stderr.write(`pr-shepherd: GraphQL non-fatal errors: ${messages}\n`);
|
|
63
95
|
}
|
|
64
|
-
return { data:
|
|
96
|
+
return { data: payload.data, rateLimit, retryAfterSeconds, errors: payload.errors };
|
|
65
97
|
}
|
|
66
|
-
export async function graphql(query, vars = {}) {
|
|
67
|
-
const { data } = await graphqlInner(query, vars);
|
|
68
|
-
return { data };
|
|
98
|
+
export async function graphql(query, vars = {}, opts = {}) {
|
|
99
|
+
const { data, errors } = await graphqlInner(query, vars, opts);
|
|
100
|
+
return { data, errors };
|
|
69
101
|
}
|
|
70
|
-
export async function graphqlWithRateLimit(query, vars = {}) {
|
|
71
|
-
const { data, rateLimit, retryAfterSeconds, errors } = await graphqlInner(query, vars);
|
|
102
|
+
export async function graphqlWithRateLimit(query, vars = {}, opts = {}) {
|
|
103
|
+
const { data, rateLimit, retryAfterSeconds, errors } = await graphqlInner(query, vars, opts);
|
|
72
104
|
return { data, rateLimit: rateLimit ?? undefined, retryAfterSeconds, errors };
|
|
73
105
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { GitHubRequestError } from "./errors.mjs";
|
|
2
|
+
export function parseGraphQlPayload(parsed, status, rateLimit, retryAfterSeconds) {
|
|
3
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
4
|
+
throw malformedGraphQlResponse("expected a JSON object", status, rateLimit, retryAfterSeconds);
|
|
5
|
+
}
|
|
6
|
+
const record = parsed;
|
|
7
|
+
let errors;
|
|
8
|
+
if (record["errors"] !== undefined) {
|
|
9
|
+
if (!Array.isArray(record["errors"]) ||
|
|
10
|
+
!record["errors"].every((error) => typeof error === "object" &&
|
|
11
|
+
error !== null &&
|
|
12
|
+
typeof error["message"] === "string")) {
|
|
13
|
+
throw malformedGraphQlResponse("errors field is not an array of GraphQL errors", status, rateLimit, retryAfterSeconds);
|
|
14
|
+
}
|
|
15
|
+
errors = record["errors"];
|
|
16
|
+
}
|
|
17
|
+
if (!("data" in record)) {
|
|
18
|
+
if (errors?.length)
|
|
19
|
+
return { data: null, errors };
|
|
20
|
+
throw malformedGraphQlResponse("missing data field", status, rateLimit, retryAfterSeconds);
|
|
21
|
+
}
|
|
22
|
+
if (record["data"] !== null &&
|
|
23
|
+
(typeof record["data"] !== "object" || Array.isArray(record["data"]))) {
|
|
24
|
+
throw malformedGraphQlResponse("data field is not an object or null", status, rateLimit, retryAfterSeconds);
|
|
25
|
+
}
|
|
26
|
+
return { data: record["data"] ?? null, errors };
|
|
27
|
+
}
|
|
28
|
+
export function formatGraphQlErrors(errors) {
|
|
29
|
+
return (errors ?? [])
|
|
30
|
+
.map((error) => {
|
|
31
|
+
const path = Array.isArray(error.path) ? error.path.map(String).join(".") : "";
|
|
32
|
+
return path ? `${error.message} (path: ${path})` : error.message;
|
|
33
|
+
})
|
|
34
|
+
.join("; ");
|
|
35
|
+
}
|
|
36
|
+
function malformedGraphQlResponse(detail, status, rateLimit, retryAfterSeconds) {
|
|
37
|
+
return new GitHubRequestError(`Malformed GitHub GraphQL response: ${detail}`, {
|
|
38
|
+
status,
|
|
39
|
+
rateLimit: rateLimit ?? undefined,
|
|
40
|
+
retryAfterSeconds,
|
|
41
|
+
});
|
|
42
|
+
}
|
package/package.json
CHANGED