pr-shepherd 0.8.1 → 0.10.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 +123 -21
- package/bin/checks/classify.mjs +2 -1
- package/bin/checks/triage.mjs +63 -61
- package/bin/cli/args.mjs +9 -3
- package/bin/cli/fence.mjs +4 -0
- package/bin/cli/fix-formatter.mjs +45 -19
- package/bin/cli/formatters.mjs +61 -44
- package/bin/cli/handlers.mjs +5 -4
- package/bin/cli/iterate-formatter.mjs +51 -45
- package/bin/cli/iterate-lean.mjs +95 -0
- package/bin/cli/list-formatters.mjs +32 -0
- package/bin/cli/suggestion-renderer.mjs +31 -0
- package/bin/cli-parser.iterate-fixtures.mjs +6 -5
- package/bin/cli-parser.mjs +27 -3
- package/bin/commands/check-status.mjs +7 -4
- package/bin/commands/check.mjs +39 -3
- package/bin/commands/commit-suggestion.mjs +12 -10
- package/bin/commands/iterate/classify.mjs +10 -54
- package/bin/commands/iterate/escalate.mjs +5 -14
- package/bin/commands/iterate/fix-code.mjs +15 -16
- package/bin/commands/iterate/helpers.mjs +8 -5
- package/bin/commands/iterate/index.mjs +18 -12
- package/bin/commands/iterate/render.mjs +28 -12
- package/bin/commands/iterate/stall.mjs +1 -1
- package/bin/commands/log-file.mjs +7 -0
- package/bin/commands/monitor.mjs +7 -4
- package/bin/commands/ready-delay.mjs +2 -2
- package/bin/commands/resolve-instructions.mjs +14 -5
- package/bin/commands/resolve.mjs +38 -37
- package/bin/commands/status.mjs +41 -29
- package/bin/comments/resolve.mjs +75 -65
- package/bin/config.json +4 -7
- package/bin/github/batch-parsers.mjs +4 -0
- package/bin/github/client.mjs +11 -32
- package/bin/github/gql/batch-pr.gql +6 -0
- package/bin/github/gql/get-pr-head-sha.gql +7 -0
- package/bin/github/http.mjs +166 -11
- package/bin/github/queries.mjs +7 -11
- package/bin/log/log-file.mjs +88 -0
- package/bin/log/session.mjs +100 -0
- package/bin/log/setup.mjs +54 -0
- package/bin/reporters/agent.mjs +21 -13
- package/bin/reporters/check-instructions.mjs +9 -14
- package/bin/reporters/text.mjs +93 -105
- package/bin/state/base.mjs +5 -0
- package/bin/state/fix-attempts.mjs +2 -2
- package/bin/state/iterate-stall.mjs +2 -2
- package/bin/state/seen-comments.mjs +76 -0
- package/bin/suggestions/extract.mjs +15 -0
- package/bin/suggestions/parse.mjs +1 -26
- package/bin/types/report.mjs +0 -1
- package/bin/util/markdown.mjs +7 -0
- package/bin/util/worktree.mjs +23 -0
- package/package.json +2 -2
- package/bin/commands/iterate/steps.mjs +0 -31
- package/bin/github/gql/dismiss-review.gql +0 -7
- package/bin/github/gql/minimize-comment.gql +0 -7
- package/bin/github/gql/multi-pr-status.gql +0 -32
- package/bin/github/gql/resolve-thread.gql +0 -7
package/README.md
CHANGED
|
@@ -10,28 +10,130 @@ Example Workflow:
|
|
|
10
10
|
3. Accept the plan
|
|
11
11
|
4. Switch to Auto Mode
|
|
12
12
|
5. Prompt: `make a PR, then run /pr-shepherd:monitor`
|
|
13
|
-
6.
|
|
14
|
-
7.
|
|
13
|
+
6. Agent makes a draft PR
|
|
14
|
+
7. PR has passing CI -> draft is marked Ready for Review
|
|
15
|
+
8. Review bots begin providing reviews
|
|
16
|
+
9. Agent automatically classifies and fixes review comments based on the Plan
|
|
17
|
+
10. Human reviews PR with a well-documented PR title and description, passing CI, and no open threads/comments/reviews
|
|
15
18
|
|
|
16
|
-
##
|
|
19
|
+
## How it works
|
|
17
20
|
|
|
18
|
-
|
|
21
|
+
`pr-shepherd` optimizes token management, rate limits, and agentic orchestration by moving **ALL** deterministic logic and prompts to code via a CLI tool, enshrining what would be a large skill or command prompt (of which the agent would inevitably make mistakes) into the code and returning a clear, actionable prompt.
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
- **Lower context usage per iteration** — classification lives in the CLI; the agent receives one decision per tick and never sees raw GraphQL payloads or resolved threads
|
|
22
|
-
- **Prompt-cache friendly** — the 4-minute default tick is tuned to Claude's 5-minute prompt-cache TTL (tunable via `watch.interval`)
|
|
23
|
-
- **Reduced GitHub rate-limit exposure** — one batched GraphQL read per tick; loop-state files (fix-attempts, stall detection, ready-delay timer) are kept in `$TMPDIR/pr-shepherd-state/`
|
|
24
|
-
- **No MCP surface** — skills call the CLI via `npx`; no long-lived MCP server, no extra auth boundary, smaller reasoning surface
|
|
25
|
-
- **Skills over subagents** — skill prompts inject into the main conversation rather than spawning a subagent that reloads CLAUDE.md every turn
|
|
26
|
-
- **Safe to interrupt** — all state lives in the PR on GitHub; the cron loop self-terminates when the PR is merged, closed, or settles after ready-delay
|
|
23
|
+
At a high level, to start the monitor, the skill/command invokes a CLI that returns a prompt to be ingested by the agent _(schematic — paraphrased for brevity; actual output is more detailed)_:
|
|
27
24
|
|
|
28
|
-
|
|
25
|
+
```
|
|
26
|
+
/pr-shepherd:monitor
|
|
27
|
+
|
|
28
|
+
> npx pr-shepherd monitor 123
|
|
29
|
+
|
|
30
|
+
# PR #123 [MONITOR]
|
|
31
|
+
|
|
32
|
+
Loop tag: `# pr-shepherd-loop:pr=123`
|
|
33
|
+
Loop args: `4m --max-turns 50 --expires 8h`
|
|
34
|
+
|
|
35
|
+
## Loop prompt
|
|
36
|
+
|
|
37
|
+
# pr-shepherd-loop:pr=123
|
|
38
|
+
|
|
39
|
+
**IMPORTANT — recurrence rules:** Do not call ScheduleWakeup or /loop. End the turn
|
|
40
|
+
after completing the actions below. The cron job handles the next fire.
|
|
41
|
+
|
|
42
|
+
Run in a single Bash call:
|
|
43
|
+
npx pr-shepherd iterate 123
|
|
44
|
+
|
|
45
|
+
…(self-dedup guidance, error-handling instructions)…
|
|
46
|
+
|
|
47
|
+
## Instructions
|
|
48
|
+
|
|
49
|
+
1. Run `CronList`. If any job's prompt contains the loop tag, run the ## Loop prompt inline then stop.
|
|
50
|
+
2. Otherwise, invoke the /loop skill with Loop args and the full ## Loop prompt body.
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Each iteration calls `npx pr-shepherd iterate <PR>`, which provides actionable feedback directly to the agent:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
> npx pr-shepherd iterate 123
|
|
57
|
+
|
|
58
|
+
# PR #123 [FIX_CODE]
|
|
59
|
+
|
|
60
|
+
**status** `UNRESOLVED_COMMENTS` · **merge** `BLOCKED` · **state** `OPEN` · **repo** `owner/repo`
|
|
61
|
+
**summary** 3 passing
|
|
62
|
+
|
|
63
|
+
## Review threads
|
|
64
|
+
|
|
65
|
+
### `PRRT_kwDOSGizTs58XB1L` — `src/commands/iterate.mts:42` (@alice)
|
|
66
|
+
|
|
67
|
+
> The variable name is misleading.
|
|
68
|
+
>
|
|
69
|
+
> Consider renaming `x` to `remainingSeconds` so readers don't have to
|
|
70
|
+
> trace back to the declaration to understand its meaning.
|
|
71
|
+
|
|
72
|
+
## Failing checks
|
|
29
73
|
|
|
30
|
-
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
-
|
|
34
|
-
|
|
74
|
+
- `24697658766` — `CI › lint / typecheck / test (22.x)`
|
|
75
|
+
> npx oxfmt
|
|
76
|
+
|
|
77
|
+
## Post-fix push
|
|
78
|
+
|
|
79
|
+
- base: `main`
|
|
80
|
+
- resolve: `npx pr-shepherd resolve 123 --resolve-thread-ids PRRT_kwDOSGizTs58XB1L --minimize-comment-ids IC_kwDOSGizTs7_ajT8,IC_kwDOSGizTs7_ajT9 --dismiss-review-ids PRR_kwDOSGizTs58XB1R --message "$DISMISS_MESSAGE" --require-sha "$HEAD_SHA"`
|
|
81
|
+
|
|
82
|
+
## Instructions
|
|
83
|
+
|
|
84
|
+
_(schematic — actual steps depend on PR state)_
|
|
85
|
+
|
|
86
|
+
1. Apply code fixes for each file referenced under `## Review threads`.
|
|
87
|
+
2. For each failing check: examine the log tail to decide — rerun if transient, fix code if real.
|
|
88
|
+
3. Commit changed files.
|
|
89
|
+
4. Rebase and push: `git fetch origin && git rebase origin/main && git push --force-with-lease` — capture `HEAD_SHA=$(git rev-parse HEAD)`.
|
|
90
|
+
5. Run the `resolve:` command above, substituting `"$HEAD_SHA"`.
|
|
91
|
+
6. Add a `## Shepherd Journal` entry to the PR description for any large decisions made.
|
|
92
|
+
7. Stop this iteration.
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
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:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
npx pr-shepherd resolve 123 --resolve-thread-ids PRRT_kwDOSGizTs58XB1L --minimize-comment-ids IC_kwDOSGizTs7_ajT8,IC_kwDOSGizTs7_ajT9 --dismiss-review-ids PRR_kwDOSGizTs58XB1R --message "$DISMISS_MESSAGE" --require-sha "$HEAD_SHA"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Workflow
|
|
102
|
+
|
|
103
|
+
This system makes opinionated decisions, which may or may not work for your team's workflow.
|
|
104
|
+
|
|
105
|
+
- The following PR branch protection rules are expected:
|
|
106
|
+
- There are required status checks
|
|
107
|
+
- All inline comments are resolved
|
|
108
|
+
- **ALL** comments/threads/reviews will be hidden by default except for PR approvals. The only option here is to hide PR approvals as well.
|
|
109
|
+
- The primary reason is to optimize tokens by avoiding re-fetching comments and re-adding them to the agent's context.
|
|
110
|
+
- This also ties hand-in-hand with requiring all inline comments to be resolved.
|
|
111
|
+
- We also want to avoid storing state as comments can be unresolved/minimized/hidden.
|
|
112
|
+
- `pr-shepherd` keeps the PR title and description up to date, including a journal of decisions with links to comments/threads/reviews (that would be hidden at this point).
|
|
113
|
+
- This may break your workflow if your PR titles and descriptions are restricted to a specific format.
|
|
114
|
+
- `pr-shepherd` does **NOT** reply to inline comments when resolving them. Doing so would require agentic loops and more tokens. Instead, it updates the PR title & description once per loop with only the relevant information.
|
|
115
|
+
- Branches are currently kept up-to-date with `git push --force-with-lease`. Please make a PR for making `merge <default branch>` an option.
|
|
116
|
+
- Branches are currently only rebased when 1) pushing a commit on a branch that is out of date or 2) there are merge conflicts. It does not continuously rebase the branch (use a merge queue for that).
|
|
117
|
+
- To optimize AI code reviewer tokens, create your pull requests initially as drafts and instruct your AI code reviewers to only code review PRs that are ready for review. `pr-shepherd` will automatically mark PRs as ready for review when all CI passes (can be disabled). If you have no intention of marking your PR as ready for review, then don't run `pr-shepherd`.
|
|
118
|
+
|
|
119
|
+
Some other workflow improvements:
|
|
120
|
+
|
|
121
|
+
- `pr-shepherd` knows whether a GitHub Copilot code review is in progress
|
|
122
|
+
- `pr-shepherd` waits 10 minutes (configurable) until after all comments are hidden and CI passes before exiting. The primary reason is to wait for any lingering automated code reviews that do not provide status updates via the GitHub GraphQL API.
|
|
123
|
+
- The agent is instructed to cancel failed CI runs and, when a failure looks transient (e.g. network timeout, runner setup crash), re-run them via `gh run rerun <id> --failed`. The primary reason is to minimize CI costs.
|
|
124
|
+
- `pr-shepherd` supports "commit suggestions" by converting them into a diff, applying them, and then committing them with attribution. This avoids a file read & write. One commit is always made per suggestion to avoid any merge conflicts — in these cases, the agent will resolve the comment manually.
|
|
125
|
+
|
|
126
|
+
Recommendations:
|
|
127
|
+
|
|
128
|
+
- Run `pr-shepherd` on all your PRs before you go to sleep so that you wake up to reviewable PRs. As it uses `/loop`, it will continue working when your rate limit window is reset. There is a default timeout of 8 hours and 50 loops before exiting automatically.
|
|
129
|
+
|
|
130
|
+
## Design Principles
|
|
131
|
+
|
|
132
|
+
- **Reduced agent context and turns** — logic lives in the CLI, not the prompt. The relevant context is provided automatically to the agent, reducing tool calls.
|
|
133
|
+
- **Reduced GitHub rate-limit exposure** — GraphQL requests are batched when possible
|
|
134
|
+
- **Minimal state** — `pr-shepherd` stores minimal state in `$PR_SHEPHERD_STATE_DIR` (default `$TMPDIR/pr-shepherd-state/`), not in the repository
|
|
135
|
+
- **Classifications and decisions still happen at the agent level** — `pr-shepherd`'s goal is to provide sufficient context to make informed decisions and provide clear actionable steps without writing unreliable code-level heuristics
|
|
136
|
+
- **Configurable** — `pr-shepherd` is configurable via `.pr-shepherdrc.yml`, which is only possible with a light prompt that simply invokes the CLI which returns the prompt.
|
|
35
137
|
|
|
36
138
|
## Usage
|
|
37
139
|
|
|
@@ -72,9 +174,9 @@ GitHub has seen the push before resolving).
|
|
|
72
174
|
|
|
73
175
|
See [docs/skills.md](docs/skills.md) for full argument reference.
|
|
74
176
|
|
|
75
|
-
##
|
|
177
|
+
## Iterate decision loop
|
|
76
178
|
|
|
77
|
-
On each tick (4-minute default, tunable via `watch.interval`): fetch PR state in one GraphQL batch → classify CI, comments, and merge status → take one action (
|
|
179
|
+
On each tick (4-minute default, tunable via `watch.interval`): fetch PR state in one GraphQL batch → classify CI, comments, and merge status → take one action (`fix_code`, `mark_ready`, `cancel`, `escalate`, `wait`, or `cooldown`). See [docs/iterate-flow.md](docs/iterate-flow.md) for the decision table and [docs/flow.md](docs/flow.md) for the end-to-end flow diagram.
|
|
78
180
|
|
|
79
181
|
## Install
|
|
80
182
|
|
|
@@ -126,10 +228,10 @@ checks:
|
|
|
126
228
|
- pull_request_target
|
|
127
229
|
- merge_group # add for merge-queue repos
|
|
128
230
|
actions:
|
|
129
|
-
|
|
231
|
+
autoMarkReady: false # disable to stay draft until you manually promote
|
|
130
232
|
```
|
|
131
233
|
|
|
132
|
-
Environment variables: `GH_TOKEN` / `GITHUB_TOKEN` (auth; falls back to `gh auth token`), `PR_SHEPHERD_STATE_DIR` (override loop-state base dir).
|
|
234
|
+
Environment variables: `GH_TOKEN` / `GITHUB_TOKEN` (auth; falls back to `gh auth token`), `PR_SHEPHERD_STATE_DIR` (override loop-state and log base dir), `PR_SHEPHERD_LOG_DISABLED=1` (disable the per-worktree debug log).
|
|
133
235
|
|
|
134
236
|
See [docs/configuration.md](docs/configuration.md) for full semantics and deprecated-key migration.
|
|
135
237
|
|
package/bin/checks/classify.mjs
CHANGED
|
@@ -49,6 +49,7 @@ export function getCiVerdict(classified) {
|
|
|
49
49
|
// When there are no relevant checks (e.g. docs-only PR where all checks are filtered/skipped),
|
|
50
50
|
// treat as allPassed rather than blocking — there's nothing to fail.
|
|
51
51
|
const allPassed = !anyInProgress && !anyFailing;
|
|
52
|
+
const hasChecks = relevant.length > 0;
|
|
52
53
|
const filteredNames = classified.filter((c) => c.category === "filtered").map((c) => c.name);
|
|
53
|
-
return { allPassed, anyInProgress, anyFailing, filteredNames };
|
|
54
|
+
return { allPassed, hasChecks, anyInProgress, anyFailing, filteredNames };
|
|
54
55
|
}
|
package/bin/checks/triage.mjs
CHANGED
|
@@ -1,64 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* own conclusion field — no log-content classification:
|
|
4
|
-
* - timeout: conclusion is TIMED_OUT.
|
|
5
|
-
* - cancelled: conclusion is CANCELLED, STARTUP_FAILURE, or STALE.
|
|
6
|
-
* - actionable: everything else (FAILURE, ACTION_REQUIRED, …).
|
|
7
|
-
*
|
|
8
|
-
* Exception: checks with runId === null are always classified as "actionable"
|
|
9
|
-
* regardless of conclusion, so they surface in fix_code where the monitor
|
|
10
|
-
* escalates to the user (no run to rerun/inspect).
|
|
11
|
-
*
|
|
12
|
-
* For all checks with a non-null runId, the jobs API is called once per runId
|
|
13
|
-
* (results are cached across checks that share a run) to fetch workflow name
|
|
14
|
-
* and, for actionable checks, the first failed step name. No log fetching is done.
|
|
15
|
-
*
|
|
16
|
-
* Note on infrastructure-killed FAILURE runs: GitHub reports these as
|
|
17
|
-
* conclusion === "FAILURE", so they classify as "actionable". The jobs API
|
|
18
|
-
* surfaces their failedStep (e.g. "Set up job") which gives the agent a
|
|
19
|
-
* GitHub-native signal to distinguish runner setup deaths from real test
|
|
20
|
-
* failures — without any log-pattern analysis at the CLI level.
|
|
21
|
-
*/
|
|
22
|
-
import { rest } from "../github/http.mjs";
|
|
23
|
-
// ---------------------------------------------------------------------------
|
|
24
|
-
// Public API
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
|
-
/**
|
|
27
|
-
* Triage each failing check: classify by GitHub conclusion and call the jobs
|
|
28
|
-
* API for each check with a non-null runId to fetch workflow name and (for
|
|
29
|
-
* actionable failures) the name of the first failed step.
|
|
30
|
-
*
|
|
31
|
-
* Jobs responses are cached by runId so checks that share a run (e.g. matrix
|
|
32
|
-
* builds or multiple required steps in one workflow) make only one API call.
|
|
33
|
-
*/
|
|
34
|
-
export function triageFailingChecks(failingChecks, repo) {
|
|
1
|
+
import { rest, restText } from "../github/http.mjs";
|
|
2
|
+
export function triageFailingChecks(failingChecks, repo, logTailLines, logTailChars = 200) {
|
|
35
3
|
const jobsCache = new Map();
|
|
36
|
-
return Promise.all(failingChecks.map((c) => triageCheck(c, repo, jobsCache)));
|
|
4
|
+
return Promise.all(failingChecks.map((c) => triageCheck(c, repo, jobsCache, logTailLines, logTailChars)));
|
|
37
5
|
}
|
|
38
|
-
|
|
39
|
-
// Internal
|
|
40
|
-
// ---------------------------------------------------------------------------
|
|
41
|
-
async function triageCheck(check, repo, jobsCache) {
|
|
42
|
-
const failureKind = check.runId === null ? "actionable" : classifyConclusion(check.conclusion);
|
|
6
|
+
async function triageCheck(check, repo, jobsCache, logTailLines, logTailChars) {
|
|
43
7
|
if (check.runId === null) {
|
|
44
|
-
return { ...check
|
|
8
|
+
return { ...check };
|
|
45
9
|
}
|
|
46
10
|
const jobs = await fetchJobs(check.runId, repo, jobsCache);
|
|
47
|
-
const jobInfo = jobs ? pickJobInfo(jobs, check.name
|
|
11
|
+
const jobInfo = jobs ? pickJobInfo(jobs, check.name) : undefined;
|
|
12
|
+
const logTail = jobInfo?.jobId !== undefined && logTailLines > 0
|
|
13
|
+
? await fetchLogTail(jobInfo.jobId, repo, logTailLines, logTailChars, jobInfo.failedStep)
|
|
14
|
+
: undefined;
|
|
48
15
|
return {
|
|
49
16
|
...check,
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
...(
|
|
17
|
+
...(jobInfo?.workflowName !== undefined && { workflowName: jobInfo.workflowName }),
|
|
18
|
+
...(jobInfo?.jobName !== undefined && { jobName: jobInfo.jobName }),
|
|
19
|
+
...(jobInfo?.failedStep !== undefined && { failedStep: jobInfo.failedStep }),
|
|
20
|
+
...(logTail !== undefined && { logTail }),
|
|
53
21
|
};
|
|
54
22
|
}
|
|
55
|
-
function classifyConclusion(c) {
|
|
56
|
-
if (c === "TIMED_OUT")
|
|
57
|
-
return "timeout";
|
|
58
|
-
if (c === "CANCELLED" || c === "STARTUP_FAILURE" || c === "STALE")
|
|
59
|
-
return "cancelled";
|
|
60
|
-
return "actionable";
|
|
61
|
-
}
|
|
62
23
|
function fetchJobs(runId, repo, cache) {
|
|
63
24
|
const cached = cache.get(runId);
|
|
64
25
|
if (cached)
|
|
@@ -90,17 +51,58 @@ async function fetchJobsUncached(runId, repo) {
|
|
|
90
51
|
}
|
|
91
52
|
return allJobs;
|
|
92
53
|
}
|
|
93
|
-
function pickJobInfo(jobs, checkName
|
|
94
|
-
// Match by name. For matrix jobs sharing a check name, prefer a failing one.
|
|
95
|
-
// Fall back to prefix matching for matrix jobs whose workflow-API name includes
|
|
96
|
-
// a suffix like "(ubuntu)" while checkName is just the base name.
|
|
54
|
+
function pickJobInfo(jobs, checkName) {
|
|
97
55
|
const exactMatches = jobs.filter((j) => j.name === checkName);
|
|
98
56
|
const matchedJobs = exactMatches.length > 0 ? exactMatches : jobs.filter((j) => j.name.startsWith(checkName));
|
|
99
|
-
const job = matchedJobs.find((j) => j.conclusion === "failure") ??
|
|
57
|
+
const job = matchedJobs.find((j) => j.conclusion === "failure") ??
|
|
58
|
+
matchedJobs.find((j) => j.conclusion !== null && j.conclusion !== "success") ??
|
|
59
|
+
matchedJobs[0];
|
|
100
60
|
if (!job)
|
|
101
61
|
return undefined;
|
|
102
|
-
const failedStep =
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
62
|
+
const failedStep = job.steps?.find((s) => s.conclusion !== null &&
|
|
63
|
+
s.conclusion !== "success" &&
|
|
64
|
+
s.conclusion !== "skipped" &&
|
|
65
|
+
s.conclusion !== "neutral")?.name;
|
|
66
|
+
return {
|
|
67
|
+
workflowName: job.workflow_name,
|
|
68
|
+
jobName: job.name,
|
|
69
|
+
failedStep,
|
|
70
|
+
jobId: job.id,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
async function fetchLogTail(jobId, repo, logTailLines, logTailChars, failedStepName) {
|
|
74
|
+
const { owner, name } = repo;
|
|
75
|
+
try {
|
|
76
|
+
const text = await restText(`/repos/${owner}/${name}/actions/jobs/${jobId}/logs`);
|
|
77
|
+
const allLines = text.split("\n");
|
|
78
|
+
const stepLines = failedStepName ? extractStepLines(allLines, failedStepName) : null;
|
|
79
|
+
const lines = stepLines ?? allLines;
|
|
80
|
+
const tail = lines.length <= logTailLines ? lines.join("\n") : lines.slice(-logTailLines).join("\n");
|
|
81
|
+
return tail.length <= logTailChars ? tail : tail.slice(-logTailChars);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Extract the lines inside the ##[group]..##[endgroup] section for the named step.
|
|
88
|
+
// Returns null when no matching group is found so the caller falls back to the full log.
|
|
89
|
+
function extractStepLines(lines, stepName) {
|
|
90
|
+
const lowerStep = stepName.toLowerCase();
|
|
91
|
+
let inStep = false;
|
|
92
|
+
const result = [];
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
const content = line.replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+/, "");
|
|
95
|
+
if (!inStep) {
|
|
96
|
+
if (content.startsWith("##[group]") && content.slice(9).toLowerCase().includes(lowerStep)) {
|
|
97
|
+
inStep = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else if (content.startsWith("##[endgroup]")) {
|
|
101
|
+
inStep = false;
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
result.push(line);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return result.length > 0 ? result : null;
|
|
106
108
|
}
|
package/bin/cli/args.mjs
CHANGED
|
@@ -26,6 +26,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
26
26
|
"--no-auto-mark-ready",
|
|
27
27
|
"--no-auto-cancel-actionable",
|
|
28
28
|
"--dry-run",
|
|
29
|
+
"--verbose",
|
|
29
30
|
]);
|
|
30
31
|
// ---------------------------------------------------------------------------
|
|
31
32
|
// Strict integer parsing
|
|
@@ -44,18 +45,23 @@ export function parseCommonArgs(args) {
|
|
|
44
45
|
tokens: true,
|
|
45
46
|
options: {
|
|
46
47
|
format: { type: "string" },
|
|
48
|
+
verbose: { type: "boolean" },
|
|
47
49
|
},
|
|
48
50
|
});
|
|
49
51
|
const format = (values.format ?? "text");
|
|
52
|
+
const verbose = values.verbose === true;
|
|
50
53
|
// Build the set of arg indices consumed by global flags so we can strip
|
|
51
54
|
// them from `extra`. Subcommand-specific flags are left untouched.
|
|
52
55
|
const consumedIndices = new Set();
|
|
53
56
|
for (const tok of tokens ?? []) {
|
|
54
|
-
if (tok.kind === "option" && tok.name === "format") {
|
|
57
|
+
if (tok.kind === "option" && (tok.name === "format" || tok.name === "verbose")) {
|
|
55
58
|
consumedIndices.add(tok.index);
|
|
56
59
|
// When the value is a separate arg (--flag value, not --flag=value),
|
|
57
60
|
// inlineValue is false and the value occupies tok.index + 1.
|
|
58
|
-
if (
|
|
61
|
+
if (tok.name === "format" &&
|
|
62
|
+
"inlineValue" in tok &&
|
|
63
|
+
tok.inlineValue === false &&
|
|
64
|
+
tok.value != null) {
|
|
59
65
|
consumedIndices.add(tok.index + 1);
|
|
60
66
|
}
|
|
61
67
|
}
|
|
@@ -97,7 +103,7 @@ export function parseCommonArgs(args) {
|
|
|
97
103
|
const extra = args.filter((_, i) => !consumedIndices.has(i));
|
|
98
104
|
return {
|
|
99
105
|
prNumber,
|
|
100
|
-
global: { format },
|
|
106
|
+
global: { format, verbose },
|
|
101
107
|
extra,
|
|
102
108
|
};
|
|
103
109
|
}
|
|
@@ -1,61 +1,87 @@
|
|
|
1
1
|
import { renderResolveCommand } from "../commands/iterate.mjs";
|
|
2
|
+
import { safeFence } from "./fence.mjs";
|
|
3
|
+
import { joinSections } from "../util/markdown.mjs";
|
|
4
|
+
import { renderSuggestionBlock, renderLineRange } from "./suggestion-renderer.mjs";
|
|
5
|
+
import { renderThreadBullet, renderCommentBullet, renderReviewBullet, renderFirstLookStatusTag, } from "./list-formatters.mjs";
|
|
2
6
|
export function formatFixCodeResult(header, result) {
|
|
3
7
|
const sections = [header];
|
|
4
8
|
if (result.fix.threads.length > 0) {
|
|
5
9
|
sections.push("## Review threads");
|
|
6
10
|
for (const t of result.fix.threads) {
|
|
7
|
-
const
|
|
8
|
-
|
|
11
|
+
const lineLabel = renderLineRange(t.startLine, t.line);
|
|
12
|
+
const loc = t.path ? `\`${t.path}:${lineLabel}\`` : "(no location)";
|
|
13
|
+
const heading = t.url ? `[threadId=${t.id}](${t.url})` : `\`threadId=${t.id}\``;
|
|
14
|
+
const suggestionMarker = t.suggestion ? " [suggestion]" : "";
|
|
15
|
+
sections.push(`### ${heading} — ${loc} (@${t.author})${suggestionMarker}`);
|
|
9
16
|
sections.push(blockquote(t.body));
|
|
17
|
+
if (t.suggestion) {
|
|
18
|
+
sections.push(renderSuggestionBlock(t.suggestion, ""));
|
|
19
|
+
}
|
|
10
20
|
}
|
|
11
21
|
}
|
|
12
22
|
if (result.fix.actionableComments.length > 0) {
|
|
13
23
|
sections.push("## Actionable comments");
|
|
14
24
|
for (const c of result.fix.actionableComments) {
|
|
15
|
-
|
|
25
|
+
const heading = c.url ? `[commentId=${c.id}](${c.url})` : `\`commentId=${c.id}\``;
|
|
26
|
+
sections.push(`### ${heading} (@${c.author})`);
|
|
16
27
|
sections.push(blockquote(c.body));
|
|
17
28
|
}
|
|
18
29
|
}
|
|
19
30
|
if (result.fix.checks.length > 0) {
|
|
20
31
|
sections.push("## Failing checks");
|
|
21
32
|
const bullets = result.fix.checks.map((ch) => {
|
|
22
|
-
const
|
|
33
|
+
const workflowPrefix = ch.workflowName ? `${ch.workflowName} › ` : "";
|
|
34
|
+
const jobLabel = ch.jobName ? ch.jobName : ch.name;
|
|
23
35
|
const locator = ch.runId
|
|
24
36
|
? `\`${ch.runId}\``
|
|
25
37
|
: ch.detailsUrl
|
|
26
38
|
? `external \`${ch.detailsUrl}\``
|
|
27
39
|
: "(no runId)";
|
|
28
|
-
const lines = [`- ${locator} — \`${
|
|
40
|
+
const lines = [`- ${locator} — \`${workflowPrefix}${jobLabel}\``];
|
|
29
41
|
if (ch.failedStep)
|
|
30
42
|
lines.push(` > ${ch.failedStep}`);
|
|
31
43
|
if (ch.summary)
|
|
32
44
|
lines.push(` > ${ch.summary}`);
|
|
45
|
+
if (ch.logTail) {
|
|
46
|
+
const fence = safeFence(ch.logTail);
|
|
47
|
+
lines.push(` ${fence}`);
|
|
48
|
+
lines.push(ch.logTail.replace(/^/gm, " ").trimEnd());
|
|
49
|
+
lines.push(` ${fence}`);
|
|
50
|
+
}
|
|
33
51
|
return lines.join("\n");
|
|
34
52
|
});
|
|
35
53
|
sections.push(bullets.join("\n\n"));
|
|
36
54
|
}
|
|
37
55
|
if (result.fix.changesRequestedReviews.length > 0) {
|
|
38
56
|
sections.push("## Changes-requested reviews");
|
|
39
|
-
sections.push(result.fix.changesRequestedReviews.map((r) =>
|
|
40
|
-
}
|
|
41
|
-
if (result.fix.noiseCommentIds.length > 0) {
|
|
42
|
-
sections.push("## Noise (minimize only)");
|
|
43
|
-
sections.push(result.fix.noiseCommentIds.map((id) => `\`${id}\``).join(", "));
|
|
57
|
+
sections.push(result.fix.changesRequestedReviews.map((r) => renderReviewBullet(r)).join("\n"));
|
|
44
58
|
}
|
|
45
59
|
if (result.fix.reviewSummaryIds.length > 0) {
|
|
46
60
|
sections.push("## Review summaries (minimize only)");
|
|
47
|
-
sections.push(result.fix.reviewSummaryIds.map((id) =>
|
|
61
|
+
sections.push(result.fix.reviewSummaryIds.map((id) => `- \`${id}\``).join("\n"));
|
|
62
|
+
}
|
|
63
|
+
if (result.fix.surfacedApprovals.length > 0) {
|
|
64
|
+
sections.push("## Approvals (surfaced — not minimized)");
|
|
65
|
+
for (const r of result.fix.surfacedApprovals) {
|
|
66
|
+
sections.push(`### \`reviewId=${r.id}\` (@${r.author})`);
|
|
67
|
+
sections.push(r.body.trim() === "" ? "(no review body)" : blockquote(r.body));
|
|
68
|
+
}
|
|
48
69
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
70
|
+
const firstLookTotal = result.fix.firstLookThreads.length + result.fix.firstLookComments.length;
|
|
71
|
+
if (firstLookTotal > 0) {
|
|
72
|
+
sections.push(`## First-look items (${firstLookTotal}) — already closed on GitHub; acknowledge only`);
|
|
73
|
+
const bullets = [];
|
|
74
|
+
for (const t of result.fix.firstLookThreads) {
|
|
75
|
+
bullets.push(renderThreadBullet(t, { statusTag: renderFirstLookStatusTag(t) }));
|
|
76
|
+
}
|
|
77
|
+
for (const c of result.fix.firstLookComments) {
|
|
78
|
+
bullets.push(renderCommentBullet(c, { statusTag: "[status: minimized]" }));
|
|
54
79
|
}
|
|
80
|
+
sections.push(bullets.join("\n"));
|
|
55
81
|
}
|
|
56
82
|
if (result.cancelled.length > 0) {
|
|
57
83
|
sections.push("## Cancelled runs");
|
|
58
|
-
sections.push(result.cancelled.map((id) =>
|
|
84
|
+
sections.push(result.cancelled.map((id) => `- \`${id}\``).join("\n"));
|
|
59
85
|
}
|
|
60
86
|
sections.push("## Post-fix push");
|
|
61
87
|
const postFixLines = [`- base: \`${result.baseBranch}\``];
|
|
@@ -65,9 +91,9 @@ export function formatFixCodeResult(header, result) {
|
|
|
65
91
|
sections.push(postFixLines.join("\n"));
|
|
66
92
|
sections.push("## Instructions");
|
|
67
93
|
sections.push(result.fix.instructions.map((inst, i) => `${i + 1}. ${inst}`).join("\n"));
|
|
68
|
-
return sections
|
|
94
|
+
return joinSections(sections);
|
|
69
95
|
}
|
|
70
|
-
|
|
96
|
+
function blockquote(body) {
|
|
71
97
|
return body
|
|
72
98
|
.replace(/\r\n/g, "\n")
|
|
73
99
|
.split("\n")
|