pr-shepherd 0.20.0 → 0.22.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 +11 -9
- package/bin/checks/triage.mjs +1 -0
- package/bin/checks/triage.test-support.mjs +1 -2
- package/bin/cli/default-poll.mjs +32 -0
- package/bin/cli/fix-formatter.mjs +36 -8
- package/bin/cli/help-command-pages.mjs +3 -4
- package/bin/cli/help-top-page.mjs +5 -5
- package/bin/cli/iterate-lean.mjs +4 -0
- package/bin/cli/iterate-lean.test-support.mjs +0 -2
- package/bin/cli/list-formatters.mjs +53 -4
- package/bin/cli/validate-default-args.mjs +32 -0
- package/bin/cli-parser.clean.test-support.mjs +0 -1
- package/bin/cli-parser.commit-suggestion.test-support.mjs +1 -2
- package/bin/cli-parser.iterate-fix.test-support.mjs +1 -2
- package/bin/cli-parser.iterate.test-support.mjs +1 -2
- package/bin/cli-parser.mjs +7 -7
- package/bin/cli-parser.test-support.mjs +1 -2
- package/bin/commands/check-annotations.mjs +40 -0
- package/bin/commands/check.mjs +11 -7
- package/bin/commands/check.test-support.mjs +10 -4
- package/bin/commands/clean.test-support.mjs +0 -1
- package/bin/commands/commit-suggestion.apply.test-support.mjs +1 -2
- package/bin/commands/commit-suggestion.test-support.mjs +1 -2
- package/bin/commands/iterate/escalate.mjs +16 -2
- package/bin/commands/iterate/fix-code.mjs +9 -2
- package/bin/commands/iterate/helpers.mjs +1 -0
- package/bin/commands/iterate/render.mjs +6 -0
- package/bin/commands/iterate/stall.mjs +43 -2
- package/bin/commands/iterate-stall.test-support.mjs +0 -1
- package/bin/commands/iterate-test-support.mjs +1 -1
- package/bin/commands/iterate.fix-code-in-progress.test-support.mjs +8 -3
- package/bin/commands/poll.mjs +12 -3
- package/bin/commands/resolve.mjs +5 -4
- package/bin/commands/resolve.test-support.mjs +2 -2
- package/bin/commands/shepherd-journal.test-support.mjs +0 -2
- package/bin/comments/resolve.test-support.mjs +1 -2
- package/bin/config.json +1 -1
- package/bin/github/batch-parsers.mjs +30 -3
- package/bin/github/batch-parsers.test-support.mjs +1 -2
- package/bin/github/batch.mjs +2 -0
- package/bin/github/batch.test-support.mjs +1 -2
- package/bin/github/check-annotations.mjs +75 -0
- package/bin/github/client.test-support.mjs +55 -0
- package/bin/github/gql/batch-pr.gql +15 -2
- package/bin/github/gql/check-run-annotations.gql +32 -0
- package/bin/github/gql/review-thread-comments.gql +27 -0
- package/bin/github/http.test-support.mjs +1 -2
- package/bin/github/queries.mjs +4 -0
- package/bin/github/thread-comments.mjs +34 -0
- package/bin/reporters/agent.mjs +26 -0
- package/bin/state/seen-comments.test-support.mjs +19 -0
- package/bin/suggestions/patch.test-support.mjs +0 -2
- package/bin/threads/transcript.mjs +31 -0
- package/bin/types/agent-thread.mjs +1 -0
- package/bin/types/check-annotations.mjs +1 -0
- package/bin/types/review-thread.mjs +1 -0
- package/bin/types.mjs +3 -0
- package/package.json +1 -1
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +9 -9
- package/bin/cli/default-iterate.mjs +0 -50
package/README.md
CHANGED
|
@@ -22,10 +22,10 @@ Example Workflow:
|
|
|
22
22
|
|
|
23
23
|
The CLI emits unified recheck instructions. Generated commands call `pr-shepherd` directly.
|
|
24
24
|
|
|
25
|
-
At a high level, the skill invokes `pr-shepherd
|
|
25
|
+
At a high level, the skill invokes `pr-shepherd <PR>` (which polls by default), providing actionable feedback directly to the agent:
|
|
26
26
|
|
|
27
27
|
```text
|
|
28
|
-
> pr-shepherd
|
|
28
|
+
> pr-shepherd 123
|
|
29
29
|
|
|
30
30
|
# PR #123 [FIX_CODE]
|
|
31
31
|
|
|
@@ -34,7 +34,9 @@ At a high level, the skill invokes `pr-shepherd poll <PR>`, which provides actio
|
|
|
34
34
|
|
|
35
35
|
## Review threads
|
|
36
36
|
|
|
37
|
-
### `PRRT_kwDOSGizTs58XB1L` — `src/commands/iterate/index.mts:42` (@alice)
|
|
37
|
+
### `threadId=PRRT_kwDOSGizTs58XB1L` — `src/commands/iterate/index.mts:42` (@alice)
|
|
38
|
+
|
|
39
|
+
#### `commentId=PRRC_kwDOSGizTs58XB1M` (@alice)
|
|
38
40
|
|
|
39
41
|
> The variable name is misleading.
|
|
40
42
|
>
|
|
@@ -61,7 +63,7 @@ _(schematic — actual steps depend on PR state)_
|
|
|
61
63
|
4. Rebase and push: `git fetch origin && git rebase origin/main && git push --force-with-lease` — capture `HEAD_SHA=$(git rev-parse HEAD)`.
|
|
62
64
|
5. Run the `resolve:` command above, substituting `"$HEAD_SHA"`.
|
|
63
65
|
6. Add or update a `## Shepherd Journal` section in the PR description for any large decisions made, appending under the existing heading if it already exists.
|
|
64
|
-
7. CI needs time to run on the new push. Run `pr-shepherd
|
|
66
|
+
7. CI needs time to run on the new push. Run `pr-shepherd 123` again to continue until Shepherd returns `[CANCEL]` or `[ESCALATE]`.
|
|
65
67
|
```
|
|
66
68
|
|
|
67
69
|
On every iteration, a command is returned to instruct the agent exactly what to do. No guessing, no thinking, as few agentic turns as possible:
|
|
@@ -97,7 +99,7 @@ Some other workflow improvements:
|
|
|
97
99
|
|
|
98
100
|
Recommendations:
|
|
99
101
|
|
|
100
|
-
- Run `pr-shepherd` on all your PRs before you go to sleep so that you wake up to reviewable PRs. Keep an active goal cycling `pr-shepherd
|
|
102
|
+
- Run `pr-shepherd` on all your PRs before you go to sleep so that you wake up to reviewable PRs. Keep an active goal cycling `pr-shepherd <PR>` until Shepherd emits `[CANCEL]` for ready-delay completion or merged/closed, or `[ESCALATE]` (including `stall-timeout` for repeated unchanged CI failures or CI that never starts).
|
|
101
103
|
- Instruct your agents to write comments in a single review (comment, changes requested, or approved). This allows the review's comments/threads to be minimized or resolved together, keeping your pull request history clean. If you write inline comments outside of a review, each comment would still show up in the pull request history and take up space.
|
|
102
104
|
- Avoid sticky comments as they will continue to be hidden. Instead, just make a new comment, especially on reviews. If you really want sticky comments, instruct your agent to unhide/unminimize them when updating them.
|
|
103
105
|
- Avoid having automation edit comments, reviews, or threads in place because updated items get minimized. Instead, always make a new review, comment, thread, etc.
|
|
@@ -114,7 +116,7 @@ Recommendations:
|
|
|
114
116
|
|
|
115
117
|
### Iterate a PR to completion
|
|
116
118
|
|
|
117
|
-
Poll dispatcher — checks CI and review comments, fixes issues, and marks the PR ready for review when clean. Each non-terminal tick emits an `## Instructions` section; the skill follows it, then invokes `pr-shepherd
|
|
119
|
+
Poll dispatcher — checks CI and review comments, fixes issues, and marks the PR ready for review when clean. Each non-terminal tick emits an `## Instructions` section; the skill follows it, then invokes `pr-shepherd <PR>` again until `[CANCEL]` or `[ESCALATE]`.
|
|
118
120
|
|
|
119
121
|
Claude Code (via `pr-shepherd` skill):
|
|
120
122
|
|
|
@@ -128,9 +130,9 @@ Codex or direct CLI:
|
|
|
128
130
|
|
|
129
131
|
```sh
|
|
130
132
|
pr-shepherd 42
|
|
133
|
+
pr-shepherd 42 --interval 45s --timeout 4m
|
|
131
134
|
pr-shepherd 42 --ready-delay 15m
|
|
132
|
-
pr-shepherd
|
|
133
|
-
pr-shepherd iterate 42 # legacy-compatible spelling
|
|
135
|
+
pr-shepherd iterate 42 # single tick
|
|
134
136
|
```
|
|
135
137
|
|
|
136
138
|
## Iterate decision loop
|
|
@@ -197,7 +199,7 @@ Iterate a PR from Codex:
|
|
|
197
199
|
pr-shepherd iterate 42
|
|
198
200
|
```
|
|
199
201
|
|
|
200
|
-
Or ask Codex to use the `pr-shepherd` skill, for example: `run pr-shepherd until this PR is ready`. Follow the output's `## Instructions`. Continue until Shepherd emits `[CANCEL]` or `[ESCALATE]` (including `stall-timeout` for repeated unchanged CI failures). `pr-shepherd iterate 42` remains supported for existing workflows.
|
|
202
|
+
Or ask Codex to use the `pr-shepherd` skill, for example: `run pr-shepherd until this PR is ready`. Follow the output's `## Instructions`. Continue until Shepherd emits `[CANCEL]` or `[ESCALATE]` (including `stall-timeout` for repeated unchanged CI failures or CI that never starts). `pr-shepherd iterate 42` remains supported for existing workflows.
|
|
201
203
|
|
|
202
204
|
## Configuration
|
|
203
205
|
|
package/bin/checks/triage.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
1
|
+
import { vi, beforeEach } from "vitest";
|
|
3
2
|
// ---------------------------------------------------------------------------
|
|
4
3
|
// Stub fetch globally so http.mts uses our mock.
|
|
5
4
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { parsePrNumber } from "./args.mjs";
|
|
2
|
+
import { validateDefaultArgs } from "./validate-default-args.mjs";
|
|
3
|
+
import { USAGE } from "./help.mjs";
|
|
4
|
+
const DEFAULT_POLL_FLAGS_WITH_VALUES = new Set([
|
|
5
|
+
"--format",
|
|
6
|
+
"--ready-delay",
|
|
7
|
+
"--stall-timeout",
|
|
8
|
+
"--interval",
|
|
9
|
+
"--timeout",
|
|
10
|
+
]);
|
|
11
|
+
const DEFAULT_POLL_BOOLEAN_FLAGS = new Set([
|
|
12
|
+
"--verbose",
|
|
13
|
+
"--no-auto-mark-ready",
|
|
14
|
+
"--no-auto-cancel-actionable",
|
|
15
|
+
]);
|
|
16
|
+
export function isDefaultPollInvocation(subcommand) {
|
|
17
|
+
if (subcommand === "--help" || subcommand === "-h")
|
|
18
|
+
return false;
|
|
19
|
+
return (subcommand === undefined || parsePrNumber(subcommand) !== null || isDefaultPollFlag(subcommand));
|
|
20
|
+
}
|
|
21
|
+
export function validateDefaultPollArgs(args) {
|
|
22
|
+
return validateDefaultArgs(args, DEFAULT_POLL_FLAGS_WITH_VALUES, DEFAULT_POLL_BOOLEAN_FLAGS, writeDefaultUsageError);
|
|
23
|
+
}
|
|
24
|
+
function isDefaultPollFlag(arg) {
|
|
25
|
+
const name = arg.split("=", 1)[0];
|
|
26
|
+
return DEFAULT_POLL_FLAGS_WITH_VALUES.has(name) || DEFAULT_POLL_BOOLEAN_FLAGS.has(arg);
|
|
27
|
+
}
|
|
28
|
+
function writeDefaultUsageError(arg) {
|
|
29
|
+
process.stderr.write(`Unknown subcommand: ${arg}\n`);
|
|
30
|
+
process.stderr.write(`${USAGE.top}\n`);
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { renderResolveCommand } from "../commands/iterate/render.mjs";
|
|
2
2
|
import { joinSections } from "../util/markdown.mjs";
|
|
3
3
|
import { renderSuggestionBlock, renderLineRange } from "./suggestion-renderer.mjs";
|
|
4
|
-
import { renderThreadBullet, renderReviewBullet, renderThreadResolutionStatusTag, renderAuthor, buildFirstLookBullets, } from "./list-formatters.mjs";
|
|
4
|
+
import { renderThreadBullet, renderReviewBullet, renderThreadResolutionStatusTag, renderAuthor, buildFirstLookBullets, renderThreadConversation, blockquote, } from "./list-formatters.mjs";
|
|
5
5
|
import { adaptFixCodeInstructions, numberInstructions } from "./iterate-instructions.mjs";
|
|
6
6
|
export function formatFixCodeResult(header, result, opts) {
|
|
7
7
|
const readyDelaySuffix = opts?.readyDelaySuffix;
|
|
@@ -14,7 +14,7 @@ export function formatFixCodeResult(header, result, opts) {
|
|
|
14
14
|
const heading = t.url ? `[threadId=${t.id}](${t.url})` : `\`threadId=${t.id}\``;
|
|
15
15
|
const suggestionMarker = t.suggestion ? " [suggestion]" : "";
|
|
16
16
|
sections.push(`### ${heading} — ${loc} (${renderAuthor(t.author, t.authorType)})${suggestionMarker}`);
|
|
17
|
-
sections.push(
|
|
17
|
+
sections.push(renderThreadConversation(t));
|
|
18
18
|
if (t.suggestion) {
|
|
19
19
|
sections.push(renderSuggestionBlock(t.suggestion, ""));
|
|
20
20
|
}
|
|
@@ -57,6 +57,21 @@ export function formatFixCodeResult(header, result, opts) {
|
|
|
57
57
|
});
|
|
58
58
|
sections.push(bullets.join("\n\n"));
|
|
59
59
|
}
|
|
60
|
+
const checksWithAnnotations = result.fix.checks.filter((ch) => (ch.annotations?.length ?? 0) > 0);
|
|
61
|
+
if (checksWithAnnotations.length > 0) {
|
|
62
|
+
sections.push("## Check annotations");
|
|
63
|
+
for (const ch of checksWithAnnotations) {
|
|
64
|
+
const workflowPrefix = ch.workflowName ? `${ch.workflowName} › ` : "";
|
|
65
|
+
const jobLabel = ch.jobName ? ch.jobName : ch.name;
|
|
66
|
+
const locator = ch.runId
|
|
67
|
+
? `\`${ch.runId}\``
|
|
68
|
+
: ch.detailsUrl
|
|
69
|
+
? `external \`${ch.detailsUrl}\``
|
|
70
|
+
: "(no runId)";
|
|
71
|
+
sections.push(`### ${locator} — \`${workflowPrefix}${jobLabel}\``);
|
|
72
|
+
sections.push(ch.annotations.map(renderCheckAnnotation).join("\n\n"));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
60
75
|
if (result.fix.changesRequestedReviews.length > 0) {
|
|
61
76
|
sections.push("## Changes-requested reviews");
|
|
62
77
|
sections.push(result.fix.changesRequestedReviews.map((r) => renderReviewBullet(r)).join("\n"));
|
|
@@ -112,10 +127,23 @@ export function formatFixCodeResult(header, result, opts) {
|
|
|
112
127
|
sections.push(numberInstructions(adaptFixCodeInstructions(result.fix.instructions, result.pr, readyDelaySuffix)));
|
|
113
128
|
return joinSections(sections);
|
|
114
129
|
}
|
|
115
|
-
function
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
130
|
+
function renderCheckAnnotation(a) {
|
|
131
|
+
const loc = `${a.path}:${renderAnnotationRange(a)}`;
|
|
132
|
+
const link = a.blobUrl ? ` [↗](${a.blobUrl})` : "";
|
|
133
|
+
const title = a.title ? ` — ${a.title}` : "";
|
|
134
|
+
const lines = [`- \`${a.id}\`${link} \`${loc}\` [${a.level}]${title}`];
|
|
135
|
+
if (a.message.trim() !== "")
|
|
136
|
+
lines.push(blockquote(a.message));
|
|
137
|
+
if (a.rawDetails !== undefined && a.rawDetails.trim() !== "")
|
|
138
|
+
lines.push(blockquote(a.rawDetails));
|
|
139
|
+
return lines.join("\n");
|
|
140
|
+
}
|
|
141
|
+
function renderAnnotationRange(a) {
|
|
142
|
+
if (a.startLine === null && a.endLine === null)
|
|
143
|
+
return "?";
|
|
144
|
+
const start = a.startLine ?? a.endLine;
|
|
145
|
+
const end = a.endLine ?? a.startLine;
|
|
146
|
+
if (start === end)
|
|
147
|
+
return String(start);
|
|
148
|
+
return `${start}-${end}`;
|
|
121
149
|
}
|
|
@@ -51,11 +51,10 @@ Exit codes:
|
|
|
51
51
|
1 validation, lookup, precondition, or suggestion parsing failure`,
|
|
52
52
|
iterate: `pr-shepherd iterate
|
|
53
53
|
|
|
54
|
-
Run one iterate tick for a pull request.
|
|
54
|
+
Run one iterate tick for a pull request. The no-subcommand form polls; use this subcommand for a single tick.
|
|
55
55
|
The output contains one action and an action-specific ## Instructions section.
|
|
56
56
|
|
|
57
57
|
Usage:
|
|
58
|
-
pr-shepherd [PR] [iterate-flags]
|
|
59
58
|
pr-shepherd iterate [PR] [iterate-flags]
|
|
60
59
|
|
|
61
60
|
Iterate flags:
|
|
@@ -98,11 +97,11 @@ Forwarded iterate flags:
|
|
|
98
97
|
--no-auto-mark-ready Do not convert draft PRs to ready for review.
|
|
99
98
|
--no-auto-cancel-actionable Do not cancel in-progress runs before actionable fixes.
|
|
100
99
|
--format text|json Output Markdown text or JSON. Default: text.
|
|
101
|
-
--verbose Include verbose iterate fields and
|
|
100
|
+
--verbose Include verbose iterate fields and detailed per-tick lines.
|
|
102
101
|
--help, -h Print this help and exit before GitHub, git, config, or log I/O.
|
|
103
102
|
|
|
104
103
|
Durations accept seconds, minutes, or hours: 30s, 2m, 1h, or bare seconds.
|
|
105
|
-
WAIT
|
|
104
|
+
Each WAIT tick writes a single dot to stderr; --verbose emits the detailed per-tick line.
|
|
106
105
|
|
|
107
106
|
Exit codes:
|
|
108
107
|
0 WAIT timeout or MARK_READY
|
|
@@ -5,8 +5,8 @@ Autonomous PR CI monitor and review-comment resolver for agentic coding tools.
|
|
|
5
5
|
Usage:
|
|
6
6
|
pr-shepherd --version | -v
|
|
7
7
|
pr-shepherd --help | -h
|
|
8
|
-
pr-shepherd [PR] [flags]
|
|
9
|
-
pr-shepherd iterate [PR] [flags]
|
|
8
|
+
pr-shepherd [PR] [poll-flags] [iterate-flags]
|
|
9
|
+
pr-shepherd iterate [PR] [iterate-flags]
|
|
10
10
|
pr-shepherd poll [PR] [poll-flags] [iterate-flags]
|
|
11
11
|
pr-shepherd resolve [PR] [resolve-flags]
|
|
12
12
|
pr-shepherd commit-suggestion [PR] --thread-id ID --message MSG [flags]
|
|
@@ -14,8 +14,8 @@ Usage:
|
|
|
14
14
|
pr-shepherd log-file [--format text|json]
|
|
15
15
|
|
|
16
16
|
Commands:
|
|
17
|
-
[PR]
|
|
18
|
-
iterate
|
|
17
|
+
[PR] Poll until non-WAIT or timeout. This is the default command.
|
|
18
|
+
iterate Run one iterate tick (single-tick alias).
|
|
19
19
|
poll Re-run iterate while the action is WAIT, then print the final tick.
|
|
20
20
|
resolve Fetch actionable review items, or resolve/minimize/dismiss IDs.
|
|
21
21
|
commit-suggestion Convert one GitHub suggestion thread into a patch and commit instructions.
|
|
@@ -28,7 +28,7 @@ PR argument:
|
|
|
28
28
|
|
|
29
29
|
Common flags:
|
|
30
30
|
--format text|json Output Markdown text or JSON. Default: text.
|
|
31
|
-
--verbose Include verbose iterate fields and poll
|
|
31
|
+
--verbose Include verbose iterate fields and detailed poll-tick lines.
|
|
32
32
|
--help, -h Print help and exit before any GitHub, git, config, or log I/O.
|
|
33
33
|
|
|
34
34
|
Iterate flags:
|
package/bin/cli/iterate-lean.mjs
CHANGED
|
@@ -111,6 +111,10 @@ export function projectIterateLean(result, opts) {
|
|
|
111
111
|
...(result.escalate.changesRequestedReviews.length > 0 && {
|
|
112
112
|
changesRequestedReviews: result.escalate.changesRequestedReviews,
|
|
113
113
|
}),
|
|
114
|
+
...(result.escalate.stalledChecks &&
|
|
115
|
+
result.escalate.stalledChecks.length > 0 && {
|
|
116
|
+
stalledChecks: result.escalate.stalledChecks,
|
|
117
|
+
}),
|
|
114
118
|
...(result.escalate.thrashHistory &&
|
|
115
119
|
result.escalate.thrashHistory.length > 0 && {
|
|
116
120
|
thrashHistory: result.escalate.thrashHistory,
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
// @ts-nocheck
|
|
2
|
-
import { describe, it, expect } from "vitest";
|
|
3
1
|
import { projectIterateLean, projectIterateVerbose } from "./iterate-lean.mjs";
|
|
4
2
|
import { makeIterateResult } from "../cli-parser.iterate-fixtures.mjs";
|
|
5
3
|
export { makeIterateResult, projectIterateLean, projectIterateVerbose };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { renderLineRange, renderSuggestionBlock } from "./suggestion-renderer.mjs";
|
|
2
|
+
import { threadComments } from "../threads/transcript.mjs";
|
|
2
3
|
const BODY_PREVIEW_MAX = 100;
|
|
3
4
|
export function renderAuthor(author, authorType) {
|
|
4
5
|
return authorType ? `@${author} · ${authorType}` : `@${author}`;
|
|
@@ -27,12 +28,60 @@ export function renderThreadBullet(t, opts = {}) {
|
|
|
27
28
|
: "`(no location)`";
|
|
28
29
|
const suggestionMarker = t.suggestion ? " [suggestion]" : "";
|
|
29
30
|
const statusSuffix = opts.statusTag ? ` ${opts.statusTag}` : "";
|
|
30
|
-
const
|
|
31
|
-
|
|
31
|
+
const bulletLine = `- \`threadId=${t.id}\`${link} ${loc} (${renderAuthor(t.author, t.authorType)})${suggestionMarker}${statusSuffix}`;
|
|
32
|
+
if (!opts.noBody && (!t.comments || t.comments.length === 0)) {
|
|
33
|
+
const legacyLine = `${bulletLine}: ${renderBodyPreview(t.body)}`;
|
|
34
|
+
return t.suggestion && opts.renderSuggestion
|
|
35
|
+
? `${legacyLine}\n${renderSuggestionBlock(t.suggestion)}`
|
|
36
|
+
: legacyLine;
|
|
37
|
+
}
|
|
38
|
+
const parts = [bulletLine];
|
|
39
|
+
if (!opts.noBody) {
|
|
40
|
+
parts.push(renderThreadCommentBullets(t));
|
|
41
|
+
}
|
|
32
42
|
if (t.suggestion && opts.renderSuggestion) {
|
|
33
|
-
|
|
43
|
+
parts.push(renderSuggestionBlock(t.suggestion));
|
|
34
44
|
}
|
|
35
|
-
return
|
|
45
|
+
return parts.join("\n");
|
|
46
|
+
}
|
|
47
|
+
export function renderThreadConversation(t) {
|
|
48
|
+
if (!t.comments || t.comments.length === 0)
|
|
49
|
+
return blockquote(t.body);
|
|
50
|
+
return threadComments(t)
|
|
51
|
+
.map((c) => {
|
|
52
|
+
const heading = c.id
|
|
53
|
+
? c.url
|
|
54
|
+
? `#### [commentId=${c.id}](${c.url}) (${renderAuthor(c.author, c.authorType)})`
|
|
55
|
+
: `#### \`commentId=${c.id}\` (${renderAuthor(c.author, c.authorType)})`
|
|
56
|
+
: `#### (${renderAuthor(c.author, c.authorType)})`;
|
|
57
|
+
return `${heading}\n\n${blockquote(c.body)}`;
|
|
58
|
+
})
|
|
59
|
+
.join("\n\n");
|
|
60
|
+
}
|
|
61
|
+
export function blockquote(body) {
|
|
62
|
+
return body
|
|
63
|
+
.replace(/\r\n/g, "\n")
|
|
64
|
+
.split("\n")
|
|
65
|
+
.map((line) => (line === "" ? ">" : `> ${line}`))
|
|
66
|
+
.join("\n");
|
|
67
|
+
}
|
|
68
|
+
function renderThreadCommentBullets(t) {
|
|
69
|
+
return threadComments(t)
|
|
70
|
+
.map((c) => {
|
|
71
|
+
const link = c.url ? ` [↗](${c.url})` : "";
|
|
72
|
+
const id = c.id ? `\`commentId=${c.id}\`` : "comment";
|
|
73
|
+
return [
|
|
74
|
+
` - ${id}${link} (${renderAuthor(c.author, c.authorType)})`,
|
|
75
|
+
indentBlockquote(c.body, " "),
|
|
76
|
+
].join("\n");
|
|
77
|
+
})
|
|
78
|
+
.join("\n");
|
|
79
|
+
}
|
|
80
|
+
function indentBlockquote(body, indent) {
|
|
81
|
+
return blockquote(body)
|
|
82
|
+
.split("\n")
|
|
83
|
+
.map((line) => `${indent}${line}`)
|
|
84
|
+
.join("\n");
|
|
36
85
|
}
|
|
37
86
|
export function renderCommentBullet(c, opts = {}) {
|
|
38
87
|
const link = c.url ? ` [↗](${c.url})` : "";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { parsePrNumber } from "./args.mjs";
|
|
2
|
+
/**
|
|
3
|
+
* Validate args for default-dispatch paths (e.g. `pr-shepherd [PR] [flags]`).
|
|
4
|
+
* Returns false when an unexpected token is found; calls onError with the
|
|
5
|
+
* offending arg so callers can print their own usage message.
|
|
6
|
+
*/
|
|
7
|
+
export function validateDefaultArgs(args, flagsWithValues, booleanFlags, onError) {
|
|
8
|
+
let sawPr = false;
|
|
9
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
10
|
+
const arg = args[i];
|
|
11
|
+
if (flagsWithValues.has(arg)) {
|
|
12
|
+
if (i + 1 >= args.length || args[i + 1].startsWith("--")) {
|
|
13
|
+
onError(arg);
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
i += 1;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const inlineFlag = arg.split("=", 1)[0];
|
|
20
|
+
if (flagsWithValues.has(inlineFlag))
|
|
21
|
+
continue;
|
|
22
|
+
if (booleanFlags.has(arg))
|
|
23
|
+
continue;
|
|
24
|
+
if (parsePrNumber(arg) !== null && !sawPr) {
|
|
25
|
+
sawPr = true;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
onError(arg);
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
1
|
+
import { vi, beforeEach, afterEach } from "vitest";
|
|
3
2
|
vi.mock("./commands/check.mts", () => ({ runCheck: vi.fn() }));
|
|
4
3
|
vi.mock("./commands/resolve.mts", () => ({
|
|
5
4
|
runResolveFetch: vi.fn(),
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
1
|
+
import { vi, beforeEach, afterEach } from "vitest";
|
|
3
2
|
vi.mock("./commands/check.mts", () => ({ runCheck: vi.fn() }));
|
|
4
3
|
vi.mock("./commands/resolve.mts", () => ({
|
|
5
4
|
runResolveFetch: vi.fn(),
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
1
|
+
import { vi, beforeEach, afterEach } from "vitest";
|
|
3
2
|
vi.mock("./commands/check.mts", () => ({ runCheck: vi.fn() }));
|
|
4
3
|
vi.mock("./commands/resolve.mts", () => ({
|
|
5
4
|
runResolveFetch: vi.fn(),
|
package/bin/cli-parser.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Usage:
|
|
5
5
|
* pr-shepherd --version
|
|
6
|
-
* pr-shepherd [PR] [--format text|json] [--ready-delay Nm]
|
|
6
|
+
* pr-shepherd [PR] [--interval 45s] [--timeout 4m] [--format text|json] [--ready-delay Nm]
|
|
7
7
|
* [--stall-timeout <duration>] [--no-auto-mark-ready]
|
|
8
8
|
* [--no-auto-cancel-actionable]
|
|
9
9
|
* pr-shepherd resolve [PR] [--fetch] [--resolve-thread-ids A,B] [--minimize-comment-ids X,Y]
|
|
@@ -22,7 +22,7 @@ import { readFileSync } from "node:fs";
|
|
|
22
22
|
import { runResolveFetch, runResolveMutate } from "./commands/resolve.mjs";
|
|
23
23
|
import { runLogFile } from "./commands/log-file.mjs";
|
|
24
24
|
import { parseCommonArgs, getFlag, hasFlag, parseList } from "./cli/args.mjs";
|
|
25
|
-
import {
|
|
25
|
+
import { isDefaultPollInvocation, validateDefaultPollArgs } from "./cli/default-poll.mjs";
|
|
26
26
|
import { USAGE, maybePrintHelp } from "./cli/help.mjs";
|
|
27
27
|
import { formatFetchResult, formatMutateResult } from "./cli/formatters.mjs";
|
|
28
28
|
import { handleClean, handleCommitSuggestion, handleIterate } from "./cli/handlers.mjs";
|
|
@@ -49,8 +49,8 @@ export async function main(argv) {
|
|
|
49
49
|
}
|
|
50
50
|
// Short-circuit all --help/-h before any I/O or logging.
|
|
51
51
|
if (hasFlag(args, "--help") || hasFlag(args, "-h")) {
|
|
52
|
-
if (
|
|
53
|
-
process.stdout.write(`${USAGE.
|
|
52
|
+
if (isDefaultPollInvocation(subcommand)) {
|
|
53
|
+
process.stdout.write(`${USAGE.poll}\n`);
|
|
54
54
|
}
|
|
55
55
|
else {
|
|
56
56
|
const key = subcommand != null && subcommand in USAGE
|
|
@@ -62,10 +62,10 @@ export async function main(argv) {
|
|
|
62
62
|
}
|
|
63
63
|
// Initialize the per-worktree log and install a stdout tee.
|
|
64
64
|
await setupLog(argv);
|
|
65
|
-
if (
|
|
66
|
-
if (!
|
|
65
|
+
if (isDefaultPollInvocation(subcommand)) {
|
|
66
|
+
if (!validateDefaultPollArgs(args))
|
|
67
67
|
return;
|
|
68
|
-
await
|
|
68
|
+
await handlePoll(args);
|
|
69
69
|
return;
|
|
70
70
|
}
|
|
71
71
|
switch (subcommand) {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
// @ts-nocheck
|
|
2
1
|
import { readFileSync } from "node:fs";
|
|
3
|
-
import {
|
|
2
|
+
import { vi, beforeEach, afterEach } from "vitest";
|
|
4
3
|
vi.mock("./commands/resolve.mts", () => ({
|
|
5
4
|
runResolveFetch: vi.fn(),
|
|
6
5
|
runResolveMutate: vi.fn(),
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { fetchCheckRunAnnotations } from "../github/check-annotations.mjs";
|
|
2
|
+
export async function attachUnseenCheckAnnotations(checks, seenMap, prNumber) {
|
|
3
|
+
const checksWithAnnotations = [];
|
|
4
|
+
for (const check of checks) {
|
|
5
|
+
// eslint-disable-next-line no-await-in-loop
|
|
6
|
+
checksWithAnnotations.push(await attachForCheck(check, seenMap, prNumber));
|
|
7
|
+
}
|
|
8
|
+
return checksWithAnnotations;
|
|
9
|
+
}
|
|
10
|
+
async function attachForCheck(check, seenMap, prNumber) {
|
|
11
|
+
if (check.id == null)
|
|
12
|
+
return check;
|
|
13
|
+
let annotations;
|
|
14
|
+
try {
|
|
15
|
+
annotations = await fetchCheckRunAnnotations(check.id);
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
19
|
+
process.stderr.write(`pr-shepherd: annotation fetch failed for PR #${prNumber} check "${check.name}" (ignored): ${msg}\n`);
|
|
20
|
+
return check;
|
|
21
|
+
}
|
|
22
|
+
const unseen = annotations.filter((a) => !seenMap.has(a.id));
|
|
23
|
+
if (unseen.length === 0)
|
|
24
|
+
return check;
|
|
25
|
+
return { ...check, annotations: unseen };
|
|
26
|
+
}
|
|
27
|
+
export function annotationMarkerBody(a) {
|
|
28
|
+
return JSON.stringify({
|
|
29
|
+
path: a.path,
|
|
30
|
+
startLine: a.startLine,
|
|
31
|
+
endLine: a.endLine,
|
|
32
|
+
startColumn: a.startColumn,
|
|
33
|
+
endColumn: a.endColumn,
|
|
34
|
+
level: a.level,
|
|
35
|
+
title: a.title,
|
|
36
|
+
message: a.message,
|
|
37
|
+
rawDetails: a.rawDetails,
|
|
38
|
+
blobUrl: a.blobUrl,
|
|
39
|
+
});
|
|
40
|
+
}
|
package/bin/commands/check.mjs
CHANGED
|
@@ -9,9 +9,11 @@ import { deriveMergeStatus } from "../merge-status/derive.mjs";
|
|
|
9
9
|
import { loadConfig } from "../config/load.mjs";
|
|
10
10
|
import { classifyVisibleComments } from "../comments/visible-comments.mjs";
|
|
11
11
|
import { computeStatus } from "./check-status.mjs";
|
|
12
|
+
import { attachUnseenCheckAnnotations } from "./check-annotations.mjs";
|
|
12
13
|
import { buildTerminalReport } from "./check-terminal-report.mjs";
|
|
13
14
|
import { isBlockedByFilteredCheck, refreshReadyMergeability, refreshUnknownMergeability, } from "./ready-mergeability.mjs";
|
|
14
15
|
import { loadSeenMap, markSeen, classifyItem } from "../state/seen-comments.mjs";
|
|
16
|
+
import { threadTranscriptBody } from "../threads/transcript.mjs";
|
|
15
17
|
export async function runCheck(opts) {
|
|
16
18
|
const repo = await getRepoInfo();
|
|
17
19
|
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
@@ -38,15 +40,18 @@ export async function runCheck(opts) {
|
|
|
38
40
|
const inProgress = classifiedChecks.filter((c) => c.category === "in_progress");
|
|
39
41
|
const skipped = classifiedChecks.filter((c) => c.category === "skipped");
|
|
40
42
|
const filtered = classifiedChecks.filter((c) => c.category === "filtered");
|
|
41
|
-
const
|
|
43
|
+
const triagedBase = failing.length > 0 && !opts.skipTriage ? await triageFailingChecks(failing, repo) : failing;
|
|
42
44
|
const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
|
|
45
|
+
const seenMap = await loadSeenMap(stateKey);
|
|
46
|
+
const triaged = await attachUnseenCheckAnnotations(triagedBase, seenMap, prNumber);
|
|
43
47
|
const unresolvedThreads = batchData.reviewThreads.filter((t) => !t.isResolved);
|
|
44
48
|
const outdated = getOutdatedThreads(unresolvedThreads);
|
|
45
49
|
let autoResolved = [];
|
|
46
50
|
let autoResolveErrors = [];
|
|
47
51
|
if (opts.autoResolve && outdated.length > 0) {
|
|
48
52
|
const { resolved: resolvedIds, errors } = await autoResolveOutdated(outdated.map((t) => t.id));
|
|
49
|
-
|
|
53
|
+
const resolvedIdsSet = new Set(resolvedIds); // ⚡ Bolt optimization: O(1) lookup
|
|
54
|
+
autoResolved = outdated.filter((t) => resolvedIdsSet.has(t.id));
|
|
50
55
|
autoResolveErrors = errors;
|
|
51
56
|
}
|
|
52
57
|
const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated && !t.isMinimized);
|
|
@@ -54,12 +59,11 @@ export async function runCheck(opts) {
|
|
|
54
59
|
const resolvedCandidates = batchData.reviewThreads.filter((t) => t.isResolved && !t.isOutdated);
|
|
55
60
|
const minimizedThreadCandidates = batchData.reviewThreads.filter((t) => t.isMinimized && !t.isResolved && !t.isOutdated);
|
|
56
61
|
const minimizedCommentCandidates = batchData.comments.filter((c) => c.isMinimized);
|
|
57
|
-
const seenMap = await loadSeenMap(stateKey);
|
|
58
62
|
const visibleCommentClassification = classifyVisibleComments(batchData.comments, seenMap, config.iterate.minimizeComments);
|
|
59
63
|
const autoResolvedIds = new Set(autoResolved.map((t) => t.id));
|
|
60
64
|
const firstLookThreads = [
|
|
61
65
|
...outdatedCandidates.flatMap((t) => {
|
|
62
|
-
const cls = classifyItem(t.id, t
|
|
66
|
+
const cls = classifyItem(t.id, threadTranscriptBody(t), seenMap);
|
|
63
67
|
if (cls === "unchanged")
|
|
64
68
|
return [];
|
|
65
69
|
const base = {
|
|
@@ -70,14 +74,14 @@ export async function runCheck(opts) {
|
|
|
70
74
|
return cls === "edited" ? [{ ...base, edited: true }] : [base];
|
|
71
75
|
}),
|
|
72
76
|
...resolvedCandidates.flatMap((t) => {
|
|
73
|
-
const cls = classifyItem(t.id, t
|
|
77
|
+
const cls = classifyItem(t.id, threadTranscriptBody(t), seenMap);
|
|
74
78
|
if (cls === "unchanged")
|
|
75
79
|
return [];
|
|
76
80
|
const base = { ...t, firstLookStatus: "resolved" };
|
|
77
81
|
return cls === "edited" ? [{ ...base, edited: true }] : [base];
|
|
78
82
|
}),
|
|
79
83
|
...minimizedThreadCandidates.flatMap((t) => {
|
|
80
|
-
const cls = classifyItem(t.id, t
|
|
84
|
+
const cls = classifyItem(t.id, threadTranscriptBody(t), seenMap);
|
|
81
85
|
if (cls === "unchanged")
|
|
82
86
|
return [];
|
|
83
87
|
const base = { ...t, firstLookStatus: "minimized" };
|
|
@@ -104,7 +108,7 @@ export async function runCheck(opts) {
|
|
|
104
108
|
seenSummaries.push(r);
|
|
105
109
|
}
|
|
106
110
|
await Promise.allSettled([
|
|
107
|
-
...firstLookThreads.map((t) => markSeen(stateKey, t.id, t
|
|
111
|
+
...firstLookThreads.map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
|
|
108
112
|
...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
|
|
109
113
|
...visibleCommentClassification.toMarkSeen.map((c) => markSeen(stateKey, c.id, c.body)),
|
|
110
114
|
...[...firstLookSummaries, ...editedSummaries].map((r) => markSeen(stateKey, r.id, r.body)),
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
1
|
+
import { vi, beforeEach } from "vitest";
|
|
3
2
|
vi.mock("../github/batch.mts", () => ({ fetchPrBatch: vi.fn() }));
|
|
4
3
|
vi.mock("../github/client.mts", () => ({
|
|
5
4
|
getRepoInfo: vi.fn().mockResolvedValue({ owner: "owner", name: "repo" }),
|
|
@@ -10,6 +9,9 @@ vi.mock("../checks/triage.mts", () => ({
|
|
|
10
9
|
triageFailingChecks: vi.fn((checks) => Promise.resolve(checks)),
|
|
11
10
|
fetchStartupFailureChecks: vi.fn().mockResolvedValue([]),
|
|
12
11
|
}));
|
|
12
|
+
vi.mock("../github/check-annotations.mts", () => ({
|
|
13
|
+
fetchCheckRunAnnotations: vi.fn().mockResolvedValue([]),
|
|
14
|
+
}));
|
|
13
15
|
vi.mock("../comments/resolve.mts", () => ({
|
|
14
16
|
autoResolveOutdated: vi.fn().mockResolvedValue({ resolved: [], errors: [] }),
|
|
15
17
|
}));
|
|
@@ -27,6 +29,7 @@ import { runCheck } from "./check.mjs";
|
|
|
27
29
|
import { fetchPrBatch } from "../github/batch.mjs";
|
|
28
30
|
import { getCurrentPrNumber, getMergeableState } from "../github/client.mjs";
|
|
29
31
|
import { fetchStartupFailureChecks, triageFailingChecks } from "../checks/triage.mjs";
|
|
32
|
+
import { fetchCheckRunAnnotations } from "../github/check-annotations.mjs";
|
|
30
33
|
import { loadSeenMap, markSeen, hashBody } from "../state/seen-comments.mjs";
|
|
31
34
|
import { autoResolveOutdated } from "../comments/resolve.mjs";
|
|
32
35
|
const mockFetchPrBatch = vi.mocked(fetchPrBatch);
|
|
@@ -34,6 +37,7 @@ const mockGetCurrentPrNumber = vi.mocked(getCurrentPrNumber);
|
|
|
34
37
|
const mockGetMergeableState = vi.mocked(getMergeableState);
|
|
35
38
|
const mockTriageFailingChecks = vi.mocked(triageFailingChecks);
|
|
36
39
|
const mockFetchStartupFailureChecks = vi.mocked(fetchStartupFailureChecks);
|
|
40
|
+
const mockFetchCheckRunAnnotations = vi.mocked(fetchCheckRunAnnotations);
|
|
37
41
|
const mockLoadSeenMap = vi.mocked(loadSeenMap);
|
|
38
42
|
const mockMarkSeen = vi.mocked(markSeen);
|
|
39
43
|
const mockAutoResolveOutdated = vi.mocked(autoResolveOutdated);
|
|
@@ -42,7 +46,7 @@ function defaultConfig() {
|
|
|
42
46
|
return {
|
|
43
47
|
iterate: {
|
|
44
48
|
fixAttemptsPerThread: 3,
|
|
45
|
-
stallTimeoutMinutes:
|
|
49
|
+
stallTimeoutMinutes: 60,
|
|
46
50
|
minimizeApprovals: false,
|
|
47
51
|
minimizeComments: "all",
|
|
48
52
|
},
|
|
@@ -64,6 +68,7 @@ function defaultConfig() {
|
|
|
64
68
|
}
|
|
65
69
|
function makeCheck(overrides = {}) {
|
|
66
70
|
return {
|
|
71
|
+
id: null,
|
|
67
72
|
name: "tests",
|
|
68
73
|
status: "COMPLETED",
|
|
69
74
|
conclusion: "SUCCESS",
|
|
@@ -136,6 +141,7 @@ export function registerHooks() {
|
|
|
136
141
|
mockFetchPrBatch.mockResolvedValue({ data: makeBatchData() });
|
|
137
142
|
mockGetMergeableState.mockResolvedValue({ mergeable: "MERGEABLE", mergeStateStatus: "CLEAN" });
|
|
138
143
|
mockFetchStartupFailureChecks.mockResolvedValue([]);
|
|
144
|
+
mockFetchCheckRunAnnotations.mockResolvedValue([]);
|
|
139
145
|
});
|
|
140
146
|
}
|
|
141
|
-
export { BASE_OPTS, autoResolveOutdated, defaultConfig, fetchPrBatch, fetchStartupFailureChecks, getCurrentPrNumber, getMergeableState, hashBody, loadSeenMap, makeBatchData, makeCheck, makeComment, makeThread, markSeen, mockAutoResolveOutdated, mockFetchPrBatch, mockFetchStartupFailureChecks, mockGetCurrentPrNumber, mockGetMergeableState, mockLoadConfig, mockLoadSeenMap, mockMarkSeen, mockTriageFailingChecks, runCheck, triageFailingChecks, };
|
|
147
|
+
export { BASE_OPTS, autoResolveOutdated, defaultConfig, fetchPrBatch, fetchStartupFailureChecks, fetchCheckRunAnnotations, getCurrentPrNumber, getMergeableState, hashBody, loadSeenMap, makeBatchData, makeCheck, makeComment, makeThread, markSeen, mockAutoResolveOutdated, mockFetchPrBatch, mockFetchStartupFailureChecks, mockFetchCheckRunAnnotations, mockGetCurrentPrNumber, mockGetMergeableState, mockLoadConfig, mockLoadSeenMap, mockMarkSeen, mockTriageFailingChecks, runCheck, triageFailingChecks, };
|