dev-loops 0.5.0 → 0.7.1
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/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +2 -1
- package/.claude/agents/review.md +2 -1
- package/.claude/commands/loop-auto.md +7 -0
- package/.claude/commands/loop-continue.md +15 -0
- package/.claude/commands/loop-enqueue.md +22 -0
- package/.claude/commands/loop-grill.md +17 -0
- package/.claude/commands/loop-info.md +7 -0
- package/.claude/commands/loop-queue-status.md +24 -0
- package/.claude/commands/loop-start-spike.md +16 -0
- package/.claude/commands/loop-start.md +7 -0
- package/.claude/commands/loop-status.md +6 -0
- package/.claude/hooks/_bash-command-classify.mjs +333 -29
- package/.claude/hooks/_hook-decisions.mjs +138 -15
- package/.claude/hooks/_run-context.mjs +11 -4
- package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
- package/.claude/skills/copilot-pr-followup/SKILL.md +24 -12
- package/.claude/skills/dev-loop/SKILL.md +19 -10
- package/.claude/skills/docs/acceptance-criteria-verification.md +6 -2
- package/.claude/skills/docs/anti-patterns.md +2 -1
- package/.claude/skills/docs/artifact-authority-contract.md +22 -4
- package/.claude/skills/docs/copilot-loop-operations.md +1 -1
- package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
- package/.claude/skills/docs/issue-intake-procedure.md +1 -0
- package/.claude/skills/docs/local-planning-flow.md +1 -1
- package/.claude/skills/docs/local-planning-worked-example.md +1 -1
- package/.claude/skills/docs/merge-preconditions.md +17 -1
- package/.claude/skills/docs/plan-file-contract.md +1 -1
- package/.claude/skills/docs/public-dev-loop-contract.md +1 -1
- package/.claude/skills/docs/release-runbook.md +45 -0
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +90 -76
- package/.claude/skills/docs/ui-e2e-scoping-step.md +134 -0
- package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
- package/.claude/skills/local-implementation/SKILL.md +26 -15
- package/.claude/skills/loop-grill/SKILL.md +163 -0
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +125 -0
- package/README.md +29 -10
- package/agents/dev-loop.agent.md +11 -3
- package/agents/developer.agent.md +1 -1
- package/agents/docs.agent.md +1 -1
- package/agents/fixer.agent.md +1 -1
- package/agents/quality.agent.md +1 -1
- package/agents/refiner.agent.md +1 -1
- package/agents/review.agent.md +3 -2
- package/cli/index.mjs +35 -40
- package/extension/README.md +4 -4
- package/extension/checks.ts +1 -5
- package/extension/harness-types.ts +1 -0
- package/extension/index.ts +16 -1
- package/extension/pi-extension-adapter.ts +2 -0
- package/extension/presentation.ts +16 -13
- package/lib/dev-loops-core.mjs +141 -0
- package/package.json +10 -8
- package/scripts/claude/generate-claude-assets.mjs +15 -1
- package/scripts/github/capture-review-threads.mjs +2 -9
- package/scripts/github/comment-issue.mjs +174 -0
- package/scripts/github/create-label.mjs +133 -0
- package/scripts/github/detect-checkpoint-evidence.mjs +170 -14
- package/scripts/github/detect-linked-issue-pr.mjs +22 -6
- package/scripts/github/edit-pr.mjs +259 -0
- package/scripts/github/fetch-ci-logs.mjs +208 -0
- package/scripts/github/list-issues.mjs +184 -0
- package/scripts/github/manage-sub-issues.mjs +46 -10
- package/scripts/github/offer-human-handoff.mjs +8 -5
- package/scripts/github/post-gate-findings.mjs +14 -2
- package/scripts/github/probe-ci-status.mjs +8 -2
- package/scripts/github/probe-copilot-review.mjs +21 -14
- package/scripts/github/ready-for-review.mjs +26 -8
- package/scripts/github/reconcile-draft-gate.mjs +7 -3
- package/scripts/github/reply-resolve-review-thread.mjs +16 -5
- package/scripts/github/reply-resolve-review-threads.mjs +69 -7
- package/scripts/github/request-copilot-review.mjs +8 -2
- package/scripts/github/resolve-handoff-candidates.mjs +7 -3
- package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
- package/scripts/github/stage-reviewer-draft.mjs +10 -2
- package/scripts/github/tick-verified-checkboxes.mjs +202 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +31 -10
- package/scripts/github/verify-fresh-review-context.mjs +150 -31
- package/scripts/github/view-pr.mjs +150 -0
- package/scripts/github/write-gate-context.mjs +248 -61
- package/scripts/github/write-gate-findings-log.mjs +75 -1
- package/scripts/lib/jq-output.mjs +18 -0
- package/scripts/loop/_handoff-contract.mjs +1 -0
- package/scripts/loop/_post-convergence-change.mjs +211 -0
- package/scripts/loop/_pr-runner-coordination.mjs +70 -0
- package/scripts/loop/_repo-root-resolver.mjs +47 -0
- package/scripts/loop/build-handoff-envelope.mjs +14 -4
- package/scripts/loop/check-retro-tooling.mjs +14 -3
- package/scripts/loop/checkpoint-contract.mjs +7 -4
- package/scripts/loop/cleanup-worktree.mjs +12 -3
- package/scripts/loop/conductor-monitor.mjs +12 -18
- package/scripts/loop/copilot-pr-handoff.mjs +135 -14
- package/scripts/loop/debt-remediate.mjs +24 -12
- package/scripts/loop/detect-change-scope.mjs +36 -7
- package/scripts/loop/detect-copilot-loop-state.mjs +11 -5
- package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
- package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
- package/scripts/loop/detect-internal-only-pr.mjs +8 -2
- package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +179 -66
- package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
- package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
- package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
- package/scripts/loop/ensure-worktree.mjs +8 -3
- package/scripts/loop/info.mjs +12 -4
- package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
- package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
- package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
- package/scripts/loop/inspect-run-viewer.mjs +67 -4
- package/scripts/loop/inspect-run.mjs +8 -2
- package/scripts/loop/outer-loop.mjs +8 -4
- package/scripts/loop/pr-runner-coordination.mjs +2 -9
- package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
- package/scripts/loop/pre-flight-gate.mjs +9 -9
- package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
- package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
- package/scripts/loop/provision-worktree.mjs +74 -3
- package/scripts/loop/resolve-dev-loop-startup.mjs +76 -7
- package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
- package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
- package/scripts/loop/run-conductor-cycle.mjs +13 -2
- package/scripts/loop/run-queue.mjs +18 -9
- package/scripts/loop/run-refinement-audit.mjs +8 -9
- package/scripts/loop/run-watch-cycle.mjs +2 -9
- package/scripts/loop/sanctioned-commands.mjs +104 -0
- package/scripts/loop/steer-loop.mjs +29 -16
- package/scripts/loop/validate-pr-body-spec.mjs +207 -0
- package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
- package/scripts/pages/build-site.mjs +59 -8
- package/scripts/pages/build-state-atlas.mjs +441 -0
- package/scripts/projects/_resolve-project.mjs +148 -0
- package/scripts/projects/add-queue-item.mjs +60 -54
- package/scripts/projects/archive-done-items.mjs +49 -84
- package/scripts/projects/ensure-queue-board.mjs +10 -36
- package/scripts/projects/list-queue-items.mjs +116 -65
- package/scripts/projects/move-queue-item.mjs +28 -49
- package/scripts/projects/reconcile-queue.mjs +253 -0
- package/scripts/projects/reorder-queue-item.mjs +41 -47
- package/scripts/projects/resolve-active-board-item.mjs +255 -0
- package/scripts/projects/sync-item-status.mjs +15 -10
- package/scripts/refine/_refine-helpers.mjs +21 -5
- package/scripts/refine/exit-spike.mjs +18 -8
- package/scripts/refine/promote-plan.mjs +22 -16
- package/scripts/refine/prose-linkage-detector.mjs +1 -1
- package/scripts/refine/refine-plan-file.mjs +13 -4
- package/scripts/refine/refinement-completeness-checker.mjs +1 -1
- package/scripts/refine/scaffold-spike-file.mjs +179 -0
- package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
- package/scripts/refine/tree-integrity-validator.mjs +1 -1
- package/scripts/refine/validate-plan-file.mjs +1 -1
- package/scripts/refine/validate-spike-file.mjs +1 -1
- package/scripts/refine/verify.mjs +11 -6
- package/scripts/release/assert-core-dependency-version.mjs +123 -0
- package/scripts/release/extract-changelog-section.mjs +125 -0
- package/skills/copilot-pr-followup/SKILL.md +24 -12
- package/skills/dev-loop/SKILL.md +22 -6
- package/skills/docs/acceptance-criteria-verification.md +6 -2
- package/skills/docs/anti-patterns.md +2 -1
- package/skills/docs/artifact-authority-contract.md +22 -4
- package/skills/docs/copilot-loop-operations.md +1 -1
- package/skills/docs/cross-harness-regression-contract.md +60 -0
- package/skills/docs/issue-intake-procedure.md +1 -0
- package/skills/docs/local-planning-flow.md +1 -1
- package/skills/docs/local-planning-worked-example.md +1 -1
- package/skills/docs/merge-preconditions.md +17 -1
- package/skills/docs/plan-file-contract.md +1 -1
- package/skills/docs/public-dev-loop-contract.md +1 -1
- package/skills/docs/release-runbook.md +45 -0
- package/skills/docs/retrospective-checkpoint-contract.md +90 -76
- package/skills/docs/ui-e2e-scoping-step.md +134 -0
- package/skills/docs/workflow-handoff-contract.md +39 -0
- package/skills/local-implementation/SKILL.md +26 -15
- package/skills/loop-grill/SKILL.md +165 -0
- package/scripts/loop/conductor.mjs +0 -233
- package/scripts/loop/detect-stale-runner.mjs +0 -265
- package/scripts/loop/pre-push-main-guard.mjs +0 -117
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
3
|
import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
4
|
-
import { parseIssueNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
4
|
+
import { parseIssueNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
|
|
5
5
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
6
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
6
7
|
export const LINKED_ISSUE_PR_QUERY = [
|
|
7
8
|
"query($owner:String!, $name:String!, $issue:Int!, $after:String) {",
|
|
8
9
|
" repository(owner:$owner, name:$name) {",
|
|
@@ -28,6 +29,7 @@ export const LINKED_ISSUE_PR_QUERY = [
|
|
|
28
29
|
" }",
|
|
29
30
|
" ... on CrossReferencedEvent {",
|
|
30
31
|
" createdAt",
|
|
32
|
+
" willCloseTarget",
|
|
31
33
|
" source {",
|
|
32
34
|
" __typename",
|
|
33
35
|
" ... on PullRequest {",
|
|
@@ -73,12 +75,18 @@ Error output (stderr, JSON):
|
|
|
73
75
|
Argument/usage errors:
|
|
74
76
|
{ "ok": false, "error": "...", "usage": "..." }
|
|
75
77
|
gh/runtime failures:
|
|
76
|
-
{ "ok": false, "error": "..." }
|
|
78
|
+
{ "ok": false, "error": "..." }
|
|
79
|
+
${JQ_OUTPUT_USAGE}`.trim();
|
|
77
80
|
const parseError = buildParseError(USAGE);
|
|
78
81
|
export function parseDetectLinkedIssuePrCliArgs(argv) {
|
|
79
82
|
const { tokens } = parseArgs({
|
|
80
83
|
args: [...argv],
|
|
81
|
-
options: {
|
|
84
|
+
options: {
|
|
85
|
+
help: { type: "boolean", short: "h" },
|
|
86
|
+
repo: { type: "string" },
|
|
87
|
+
issue: { type: "string" },
|
|
88
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
89
|
+
},
|
|
82
90
|
allowPositionals: true,
|
|
83
91
|
strict: false,
|
|
84
92
|
tokens: true,
|
|
@@ -107,6 +115,7 @@ export function parseDetectLinkedIssuePrCliArgs(argv) {
|
|
|
107
115
|
options.issue = parseIssueNumber(requireTokenValue(token, parseError), parseError);
|
|
108
116
|
continue;
|
|
109
117
|
}
|
|
118
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
110
119
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
111
120
|
}
|
|
112
121
|
if (options.repo === undefined || options.issue === undefined) {
|
|
@@ -162,6 +171,13 @@ function normalizeLinkedPrNode(node) {
|
|
|
162
171
|
};
|
|
163
172
|
}
|
|
164
173
|
if (node.__typename === "CrossReferencedEvent") {
|
|
174
|
+
// Only a cross-reference that will CLOSE this issue owns its board status.
|
|
175
|
+
// A bare body-mention (willCloseTarget:false, e.g. "part of #X") must not
|
|
176
|
+
// create board-ownership linkage — otherwise every sibling a PR mentions
|
|
177
|
+
// gets dragged to In Progress and the resolver fails closed (#1130).
|
|
178
|
+
if (node.willCloseTarget !== true) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
165
181
|
return {
|
|
166
182
|
eventType: "CROSS_REFERENCED_EVENT",
|
|
167
183
|
eventCreatedAt: node.createdAt,
|
|
@@ -253,7 +269,7 @@ export function selectLinkedIssuePr(candidates) {
|
|
|
253
269
|
});
|
|
254
270
|
return sorted[0] ?? null;
|
|
255
271
|
}
|
|
256
|
-
export async function detectLinkedIssuePr({ repo, issue }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
272
|
+
export async function detectLinkedIssuePr({ repo, issue }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
|
|
257
273
|
const { owner, name } = parseRepoSlug(repo);
|
|
258
274
|
const candidates = [];
|
|
259
275
|
const closedUnmergedCandidates = [];
|
|
@@ -322,7 +338,7 @@ export async function detectLinkedIssuePr({ repo, issue }, { env = process.env,
|
|
|
322
338
|
}
|
|
323
339
|
export async function runCli(
|
|
324
340
|
argv = process.argv.slice(2),
|
|
325
|
-
{ stdout = process.stdout, env = process.env, ghCommand = "gh" } = {},
|
|
341
|
+
{ stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh" } = {},
|
|
326
342
|
) {
|
|
327
343
|
const options = parseDetectLinkedIssuePrCliArgs(argv);
|
|
328
344
|
if (options.help) {
|
|
@@ -333,7 +349,7 @@ export async function runCli(
|
|
|
333
349
|
{ repo: options.repo, issue: options.issue },
|
|
334
350
|
{ env, ghCommand },
|
|
335
351
|
);
|
|
336
|
-
|
|
352
|
+
process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
|
|
337
353
|
}
|
|
338
354
|
if (isDirectCliRun(import.meta.url)) {
|
|
339
355
|
runCli().catch((error) => {
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
5
|
+
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
6
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
7
|
+
import { parseArgs } from "node:util";
|
|
8
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
9
|
+
|
|
10
|
+
const USAGE = `Usage: edit-pr.mjs --repo <owner/name> --pr <number> [--title <t>] [--body <b> | --body-file <path>] [--add-assignee <u>] [--remove-assignee <u>] [--milestone <m>]
|
|
11
|
+
Edit PR title/body/assignees/milestone. Thin wrapper over \`gh pr edit\` — use this
|
|
12
|
+
instead of an agent-level raw \`gh pr edit\` so the loop's internal-tooling record
|
|
13
|
+
stays clean (siblings: view-pr.mjs, comment-issue.mjs; #1057).
|
|
14
|
+
Required:
|
|
15
|
+
--repo <owner/name> Repository slug (e.g. owner/repo)
|
|
16
|
+
--pr <number> Pull request number
|
|
17
|
+
At least one edit:
|
|
18
|
+
--title <t> New PR title
|
|
19
|
+
--body <b> New PR body as a single argument
|
|
20
|
+
--body-file <path> Read the new body from a file (- reads stdin)
|
|
21
|
+
--add-assignee <u> Assignee to add (repeatable)
|
|
22
|
+
--remove-assignee <u> Assignee to remove (repeatable)
|
|
23
|
+
--milestone <m> Milestone to set (empty string clears it)
|
|
24
|
+
(--title/--body/--body-file reject empty or
|
|
25
|
+
whitespace-only values; use --milestone "" only
|
|
26
|
+
to clear the milestone)
|
|
27
|
+
Output (stdout, JSON):
|
|
28
|
+
{ "ok": true, "repo": "owner/repo", "pr": 17, "edited": ["title", "body", ...] }
|
|
29
|
+
Error output (stderr, JSON):
|
|
30
|
+
{ "ok": false, "error": "...", "usage"?: "..." }
|
|
31
|
+
${JQ_OUTPUT_USAGE}
|
|
32
|
+
Exit codes:
|
|
33
|
+
0 Success
|
|
34
|
+
1 Argument error or gh failure
|
|
35
|
+
2 Invalid --jq filter`.trim();
|
|
36
|
+
const parseError = buildParseError(USAGE);
|
|
37
|
+
|
|
38
|
+
export function parseEditPrCliArgs(argv) {
|
|
39
|
+
const { tokens } = parseArgs({
|
|
40
|
+
args: [...argv],
|
|
41
|
+
options: {
|
|
42
|
+
help: { type: "boolean", short: "h" },
|
|
43
|
+
repo: { type: "string" },
|
|
44
|
+
pr: { type: "string" },
|
|
45
|
+
title: { type: "string" },
|
|
46
|
+
body: { type: "string" },
|
|
47
|
+
"body-file": { type: "string" },
|
|
48
|
+
"add-assignee": { type: "string", multiple: true },
|
|
49
|
+
"remove-assignee": { type: "string", multiple: true },
|
|
50
|
+
milestone: { type: "string" },
|
|
51
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
52
|
+
},
|
|
53
|
+
allowPositionals: true,
|
|
54
|
+
strict: false,
|
|
55
|
+
tokens: true,
|
|
56
|
+
});
|
|
57
|
+
const options = {
|
|
58
|
+
help: false,
|
|
59
|
+
repo: undefined,
|
|
60
|
+
pr: undefined,
|
|
61
|
+
title: undefined,
|
|
62
|
+
body: undefined,
|
|
63
|
+
bodyFile: undefined,
|
|
64
|
+
addAssignees: [],
|
|
65
|
+
removeAssignees: [],
|
|
66
|
+
milestone: undefined,
|
|
67
|
+
jq: undefined,
|
|
68
|
+
silent: false,
|
|
69
|
+
};
|
|
70
|
+
for (const token of tokens) {
|
|
71
|
+
if (token.kind === "positional") {
|
|
72
|
+
throw parseError(`Unknown argument: ${token.value}`);
|
|
73
|
+
}
|
|
74
|
+
if (token.kind !== "option") {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (token.name === "help") {
|
|
78
|
+
options.help = true;
|
|
79
|
+
return options;
|
|
80
|
+
}
|
|
81
|
+
if (token.name === "repo") {
|
|
82
|
+
options.repo = requireTokenValue(token, parseError).trim();
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (token.name === "pr") {
|
|
86
|
+
options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (token.name === "title") {
|
|
90
|
+
const title = requireTokenValue(token, parseError);
|
|
91
|
+
if (title.trim().length === 0) {
|
|
92
|
+
throw parseError("--title must not be empty or whitespace-only");
|
|
93
|
+
}
|
|
94
|
+
options.title = title;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (token.name === "body") {
|
|
98
|
+
const body = requireTokenValue(token, parseError);
|
|
99
|
+
if (body.trim().length === 0) {
|
|
100
|
+
throw parseError("--body must not be empty or whitespace-only");
|
|
101
|
+
}
|
|
102
|
+
options.body = body;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (token.name === "body-file") {
|
|
106
|
+
const rawPath = requireTokenValue(token, parseError).trim();
|
|
107
|
+
if (rawPath.length === 0) {
|
|
108
|
+
throw parseError("--body-file must be a non-empty path");
|
|
109
|
+
}
|
|
110
|
+
options.bodyFile = rawPath;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (token.name === "add-assignee") {
|
|
114
|
+
const u = requireTokenValue(token, parseError).trim();
|
|
115
|
+
if (u.length === 0) throw parseError("--add-assignee must be a non-empty login");
|
|
116
|
+
options.addAssignees.push(u);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (token.name === "remove-assignee") {
|
|
120
|
+
const u = requireTokenValue(token, parseError).trim();
|
|
121
|
+
if (u.length === 0) throw parseError("--remove-assignee must be a non-empty login");
|
|
122
|
+
options.removeAssignees.push(u);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (token.name === "milestone") {
|
|
126
|
+
// Read the raw token value: an empty string is a valid milestone value
|
|
127
|
+
// (`gh pr edit --milestone ""` clears it), so this deliberately does NOT
|
|
128
|
+
// go through requireTokenValue (which rejects empty). Guard only a truly
|
|
129
|
+
// missing value (`--milestone` with no following token). A whitespace-only
|
|
130
|
+
// value is neither a clear nor a real milestone name — fail closed rather
|
|
131
|
+
// than forwarding it to gh for a less actionable error.
|
|
132
|
+
if (typeof token.value !== "string") {
|
|
133
|
+
throw parseError("--milestone requires a value (use an empty string to clear)");
|
|
134
|
+
}
|
|
135
|
+
if (token.value.length > 0 && token.value.trim().length === 0) {
|
|
136
|
+
throw parseError('--milestone must be a milestone name or "" to clear (whitespace-only is not allowed)');
|
|
137
|
+
}
|
|
138
|
+
options.milestone = token.value;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
142
|
+
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
143
|
+
}
|
|
144
|
+
if (options.repo === undefined || options.pr === undefined) {
|
|
145
|
+
throw parseError("Editing a PR requires both --repo <owner/name> and --pr <number>");
|
|
146
|
+
}
|
|
147
|
+
if (options.body !== undefined && options.bodyFile !== undefined) {
|
|
148
|
+
throw parseError("--body and --body-file are mutually exclusive; pass only one");
|
|
149
|
+
}
|
|
150
|
+
const hasEdit =
|
|
151
|
+
options.title !== undefined ||
|
|
152
|
+
options.body !== undefined ||
|
|
153
|
+
options.bodyFile !== undefined ||
|
|
154
|
+
options.addAssignees.length > 0 ||
|
|
155
|
+
options.removeAssignees.length > 0 ||
|
|
156
|
+
options.milestone !== undefined;
|
|
157
|
+
if (!hasEdit) {
|
|
158
|
+
throw parseError("Editing a PR requires at least one of --title/--body/--body-file/--add-assignee/--remove-assignee/--milestone");
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
parseRepoSlug(options.repo);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
throw parseError(error instanceof Error ? error.message : String(error));
|
|
164
|
+
}
|
|
165
|
+
return options;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function resolveBody(options) {
|
|
169
|
+
if (options.bodyFile === undefined) return options.body;
|
|
170
|
+
// Stdin (fd 0): the fs/promises readFile does NOT accept an integer fd, so read
|
|
171
|
+
// it synchronously via the callback-style API (which does). A real path stays on
|
|
172
|
+
// the async promise read.
|
|
173
|
+
const body =
|
|
174
|
+
options.bodyFile === "-" ? readFileSync(0, "utf8") : await readFile(options.bodyFile, "utf8");
|
|
175
|
+
// Fail closed on an empty / whitespace-only file so a blank --body-file cannot
|
|
176
|
+
// silently clear the PR body (USAGE promises --body/--title reject empties).
|
|
177
|
+
if (body.trim().length === 0) {
|
|
178
|
+
throw new Error(`--body-file ${options.bodyFile} is empty`);
|
|
179
|
+
}
|
|
180
|
+
return body;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Build the `gh pr edit` args and the parallel `edited` list (which fields were
|
|
184
|
+
// touched) so callers get a stable summary without re-reading the PR.
|
|
185
|
+
async function buildEditArgs(options) {
|
|
186
|
+
const args = ["pr", "edit", String(options.pr), "--repo", options.repo];
|
|
187
|
+
const edited = [];
|
|
188
|
+
if (options.title !== undefined) {
|
|
189
|
+
args.push("--title", options.title);
|
|
190
|
+
edited.push("title");
|
|
191
|
+
}
|
|
192
|
+
// resolveBody still runs for validation (reads the file, throws on empty /
|
|
193
|
+
// whitespace-only). A REAL --body-file path is handed straight to gh so large
|
|
194
|
+
// bodies avoid command-length limits. But `--body-file -` (stdin) was already
|
|
195
|
+
// consumed by resolveBody reading fd 0; re-emitting `--body-file -` makes gh
|
|
196
|
+
// re-read an exhausted stdin and clear the body, so pass the resolved string
|
|
197
|
+
// inline via --body instead.
|
|
198
|
+
const body = await resolveBody(options);
|
|
199
|
+
if (body !== undefined) {
|
|
200
|
+
if (options.bodyFile !== undefined && options.bodyFile !== "-") {
|
|
201
|
+
args.push("--body-file", options.bodyFile);
|
|
202
|
+
} else {
|
|
203
|
+
args.push("--body", body);
|
|
204
|
+
}
|
|
205
|
+
edited.push("body");
|
|
206
|
+
}
|
|
207
|
+
for (const u of options.addAssignees) {
|
|
208
|
+
args.push("--add-assignee", u);
|
|
209
|
+
}
|
|
210
|
+
if (options.addAssignees.length > 0) edited.push("add-assignee");
|
|
211
|
+
for (const u of options.removeAssignees) {
|
|
212
|
+
args.push("--remove-assignee", u);
|
|
213
|
+
}
|
|
214
|
+
if (options.removeAssignees.length > 0) edited.push("remove-assignee");
|
|
215
|
+
if (options.milestone !== undefined) {
|
|
216
|
+
args.push("--milestone", options.milestone);
|
|
217
|
+
edited.push("milestone");
|
|
218
|
+
}
|
|
219
|
+
return { args, edited };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function editPr(options, { env = process.env, ghCommand = "gh", run = runChild } = {}) {
|
|
223
|
+
const { args, edited } = await buildEditArgs(options);
|
|
224
|
+
const result = await run(ghCommand, args, env);
|
|
225
|
+
if (result.code !== 0) {
|
|
226
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
227
|
+
throw new Error(`gh pr edit failed: ${detail}`);
|
|
228
|
+
}
|
|
229
|
+
return { ok: true, repo: options.repo, pr: options.pr, edited };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function runCli(
|
|
233
|
+
argv = process.argv.slice(2),
|
|
234
|
+
{ stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh", run = runChild } = {},
|
|
235
|
+
) {
|
|
236
|
+
let options;
|
|
237
|
+
try {
|
|
238
|
+
options = parseEditPrCliArgs(argv);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
stderr.write(`${formatCliError(error)}\n`);
|
|
241
|
+
return 1;
|
|
242
|
+
}
|
|
243
|
+
if (options.help) {
|
|
244
|
+
stdout.write(`${USAGE}\n`);
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
let result;
|
|
248
|
+
try {
|
|
249
|
+
result = await editPr(options, { env, ghCommand, run });
|
|
250
|
+
} catch (error) {
|
|
251
|
+
stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
|
|
252
|
+
return 1;
|
|
253
|
+
}
|
|
254
|
+
return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
258
|
+
runCli().then((code) => { process.exitCode = code; });
|
|
259
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
3
|
+
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
4
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_TAIL_LINES = 200;
|
|
9
|
+
|
|
10
|
+
const USAGE = `Usage: fetch-ci-logs.mjs --repo <owner/name> --pr <number> [--failed-only] [--tail <n>]
|
|
11
|
+
Fetch the GitHub Actions CI run log tail for a PR's current head SHA. Thin wrapper
|
|
12
|
+
over \`gh run list\` + \`gh run view --log\` — use this instead of an agent-level raw
|
|
13
|
+
\`gh run view\` so the loop's internal-tooling record stays clean (#993). Complements
|
|
14
|
+
probe-ci-status.mjs, which reports failed-check NAMES; this returns the LOG tail.
|
|
15
|
+
(Siblings: list-issues.mjs, comment-issue.mjs.)
|
|
16
|
+
Required:
|
|
17
|
+
--repo <owner/name> Repository slug (e.g. owner/repo)
|
|
18
|
+
--pr <number> Pull request number
|
|
19
|
+
Optional:
|
|
20
|
+
--failed-only Only include runs whose conclusion is failure
|
|
21
|
+
(default: all Actions runs for the head SHA)
|
|
22
|
+
--tail <n> Lines of log tail to return per run (default 200)
|
|
23
|
+
Output (stdout, JSON):
|
|
24
|
+
{ "ok": true, "repo": "owner/repo", "pr": 17, "headSha": "abc123",
|
|
25
|
+
"runs": [{ "runId": 42, "name": "ci", "conclusion": "failure", "logTail": "..." }] }
|
|
26
|
+
Notes:
|
|
27
|
+
Actions-only (gh run is GitHub Actions). CircleCI / external commit-status logs
|
|
28
|
+
are NOT covered — use the provider's UI for those (probe-ci-status names the check).
|
|
29
|
+
Error output (stderr, JSON):
|
|
30
|
+
{ "ok": false, "error": "...", "usage"?: "..." }
|
|
31
|
+
${JQ_OUTPUT_USAGE}
|
|
32
|
+
Exit codes:
|
|
33
|
+
0 Success
|
|
34
|
+
1 Argument error or gh failure
|
|
35
|
+
2 Invalid --jq filter`.trim();
|
|
36
|
+
const parseError = buildParseError(USAGE);
|
|
37
|
+
|
|
38
|
+
export function parseFetchCiLogsCliArgs(argv) {
|
|
39
|
+
const { tokens } = parseArgs({
|
|
40
|
+
args: [...argv],
|
|
41
|
+
options: {
|
|
42
|
+
help: { type: "boolean", short: "h" },
|
|
43
|
+
repo: { type: "string" },
|
|
44
|
+
pr: { type: "string" },
|
|
45
|
+
"failed-only": { type: "boolean" },
|
|
46
|
+
tail: { type: "string" },
|
|
47
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
48
|
+
},
|
|
49
|
+
allowPositionals: true,
|
|
50
|
+
strict: false,
|
|
51
|
+
tokens: true,
|
|
52
|
+
});
|
|
53
|
+
const options = {
|
|
54
|
+
help: false,
|
|
55
|
+
repo: undefined,
|
|
56
|
+
pr: undefined,
|
|
57
|
+
failedOnly: false,
|
|
58
|
+
tail: DEFAULT_TAIL_LINES,
|
|
59
|
+
jq: undefined,
|
|
60
|
+
silent: false,
|
|
61
|
+
};
|
|
62
|
+
for (const token of tokens) {
|
|
63
|
+
if (token.kind === "positional") {
|
|
64
|
+
throw parseError(`Unknown argument: ${token.value}`);
|
|
65
|
+
}
|
|
66
|
+
if (token.kind !== "option") {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (token.name === "help") {
|
|
70
|
+
options.help = true;
|
|
71
|
+
return options;
|
|
72
|
+
}
|
|
73
|
+
if (token.name === "repo") {
|
|
74
|
+
options.repo = requireTokenValue(token, parseError).trim();
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (token.name === "pr") {
|
|
78
|
+
options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (token.name === "failed-only") {
|
|
82
|
+
options.failedOnly = true;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (token.name === "tail") {
|
|
86
|
+
const raw = requireTokenValue(token, parseError);
|
|
87
|
+
const value = Number(raw);
|
|
88
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
89
|
+
throw parseError(`--tail must be a positive integer, got "${raw}"`);
|
|
90
|
+
}
|
|
91
|
+
options.tail = value;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
95
|
+
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
96
|
+
}
|
|
97
|
+
if (options.repo === undefined || options.pr === undefined) {
|
|
98
|
+
throw parseError("Fetching CI logs requires both --repo <owner/name> and --pr <number>");
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
parseRepoSlug(options.repo);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
throw parseError(error instanceof Error ? error.message : String(error));
|
|
104
|
+
}
|
|
105
|
+
return options;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function ghJson(run, ghCommand, args, env, label) {
|
|
109
|
+
const result = await run(ghCommand, args, env);
|
|
110
|
+
if (result.code !== 0) {
|
|
111
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
112
|
+
throw new Error(`${label} failed: ${detail}`);
|
|
113
|
+
}
|
|
114
|
+
return parseJsonText(result.stdout, { label });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function tailLines(text, n) {
|
|
118
|
+
const lines = String(text).replace(/\r?\n$/u, "").split(/\r?\n/u);
|
|
119
|
+
return lines.slice(Math.max(0, lines.length - n)).join("\n");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function fetchCiLogs(options, { env = process.env, ghCommand = "gh", run = runChild } = {}) {
|
|
123
|
+
// 1. Resolve the PR's current head SHA — logs must be scoped to the head being
|
|
124
|
+
// evaluated, not a stale push.
|
|
125
|
+
const pr = await ghJson(
|
|
126
|
+
run,
|
|
127
|
+
ghCommand,
|
|
128
|
+
["pr", "view", String(options.pr), "--repo", options.repo, "--json", "headRefOid"],
|
|
129
|
+
env,
|
|
130
|
+
"gh pr view",
|
|
131
|
+
);
|
|
132
|
+
const headSha = typeof pr.headRefOid === "string" ? pr.headRefOid.trim() : "";
|
|
133
|
+
if (headSha.length === 0) {
|
|
134
|
+
throw new Error("gh pr view did not return headRefOid");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 2. List Actions runs for that exact commit.
|
|
138
|
+
const runs = await ghJson(
|
|
139
|
+
run,
|
|
140
|
+
ghCommand,
|
|
141
|
+
["run", "list", "--repo", options.repo, "--commit", headSha, "--json", "databaseId,name,conclusion,status"],
|
|
142
|
+
env,
|
|
143
|
+
"gh run list",
|
|
144
|
+
);
|
|
145
|
+
if (!Array.isArray(runs)) {
|
|
146
|
+
throw new Error("gh run list did not return a JSON array");
|
|
147
|
+
}
|
|
148
|
+
const selected = options.failedOnly
|
|
149
|
+
? runs.filter((r) => String(r?.conclusion).toLowerCase() === "failure")
|
|
150
|
+
: runs;
|
|
151
|
+
|
|
152
|
+
// 3. Fetch each run's log tail. --log-failed restricts to failed steps when the
|
|
153
|
+
// run failed; for non-failed runs it returns nothing, so fall back to --log.
|
|
154
|
+
const out = [];
|
|
155
|
+
for (const r of selected) {
|
|
156
|
+
const runId = Number.isInteger(r?.databaseId) ? r.databaseId : null;
|
|
157
|
+
if (runId === null) continue;
|
|
158
|
+
const conclusion = typeof r?.conclusion === "string" ? r.conclusion.toLowerCase() : null;
|
|
159
|
+
const logFlag = conclusion === "failure" ? "--log-failed" : "--log";
|
|
160
|
+
const logResult = await run(
|
|
161
|
+
ghCommand,
|
|
162
|
+
["run", "view", String(runId), "--repo", options.repo, logFlag],
|
|
163
|
+
env,
|
|
164
|
+
);
|
|
165
|
+
// A log fetch failure for one run shouldn't abort the others (logs expire);
|
|
166
|
+
// record an empty tail with a note rather than throwing.
|
|
167
|
+
const logTail =
|
|
168
|
+
logResult.code === 0
|
|
169
|
+
? tailLines(logResult.stdout, options.tail)
|
|
170
|
+
: `<log unavailable: ${logResult.stderr.trim() || `exit ${logResult.code}`}>`;
|
|
171
|
+
out.push({
|
|
172
|
+
runId,
|
|
173
|
+
name: typeof r?.name === "string" ? r.name : null,
|
|
174
|
+
conclusion,
|
|
175
|
+
logTail,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
return { ok: true, repo: options.repo, pr: options.pr, headSha, runs: out };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function runCli(
|
|
182
|
+
argv = process.argv.slice(2),
|
|
183
|
+
{ stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh", run = runChild } = {},
|
|
184
|
+
) {
|
|
185
|
+
let options;
|
|
186
|
+
try {
|
|
187
|
+
options = parseFetchCiLogsCliArgs(argv);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
stderr.write(`${formatCliError(error)}\n`);
|
|
190
|
+
return 1;
|
|
191
|
+
}
|
|
192
|
+
if (options.help) {
|
|
193
|
+
stdout.write(`${USAGE}\n`);
|
|
194
|
+
return 0;
|
|
195
|
+
}
|
|
196
|
+
let result;
|
|
197
|
+
try {
|
|
198
|
+
result = await fetchCiLogs(options, { env, ghCommand, run });
|
|
199
|
+
} catch (error) {
|
|
200
|
+
stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
|
|
201
|
+
return 1;
|
|
202
|
+
}
|
|
203
|
+
return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
207
|
+
runCli().then((code) => { process.exitCode = code; });
|
|
208
|
+
}
|