dev-loops 0.6.0 → 0.7.2
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 +8 -7
- package/.claude/agents/fixer.md +1 -0
- package/.claude/agents/refiner.md +2 -2
- package/.claude/agents/review.md +4 -3
- package/.claude/commands/{auto.md → loop-auto.md} +1 -1
- 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/{info.md → loop-info.md} +2 -2
- package/.claude/commands/loop-queue-status.md +24 -0
- package/.claude/commands/{start-spike.md → loop-start-spike.md} +2 -2
- package/.claude/commands/{start.md → loop-start.md} +1 -1
- package/.claude/commands/{status.md → loop-status.md} +1 -1
- package/.claude/hooks/_bash-command-classify.mjs +333 -29
- package/.claude/hooks/_hook-decisions.mjs +138 -15
- package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
- package/.claude/skills/copilot-pr-followup/SKILL.md +60 -34
- package/.claude/skills/dev-loop/SKILL.md +16 -12
- package/.claude/skills/docs/acceptance-criteria-verification.md +8 -3
- package/.claude/skills/docs/anti-patterns.md +5 -3
- package/.claude/skills/docs/artifact-authority-contract.md +30 -11
- package/.claude/skills/docs/confirmation-rules.md +1 -0
- package/.claude/skills/docs/contract-style-guide.md +37 -0
- package/.claude/skills/docs/copilot-loop-operations.md +19 -15
- package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
- package/.claude/skills/docs/debt-remediation-contract.md +1 -1
- package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
- package/.claude/skills/docs/issue-intake-procedure.md +26 -19
- 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 +30 -12
- package/.claude/skills/docs/plan-file-contract.md +1 -1
- package/.claude/skills/docs/pr-lifecycle-contract.md +44 -35
- package/.claude/skills/docs/public-dev-loop-contract.md +60 -44
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +106 -79
- package/.claude/skills/docs/spike-mode-contract.md +14 -7
- package/.claude/skills/docs/stop-conditions.md +30 -15
- package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
- package/.claude/skills/docs/ui-e2e-scoping-step.md +32 -0
- package/.claude/skills/docs/validation-policy.md +4 -5
- package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
- package/.claude/skills/local-implementation/SKILL.md +62 -145
- package/.claude/skills/loop-grill/SKILL.md +163 -0
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +101 -0
- package/README.md +9 -9
- package/agents/dev-loop.agent.md +11 -10
- package/agents/developer.agent.md +1 -1
- package/agents/docs.agent.md +1 -1
- package/agents/fixer.agent.md +2 -1
- package/agents/quality.agent.md +1 -1
- package/agents/refiner.agent.md +3 -3
- package/agents/review.agent.md +5 -4
- package/cli/index.mjs +35 -42
- package/extension/README.md +4 -4
- package/extension/checks.ts +1 -5
- package/extension/harness-types.ts +1 -0
- package/extension/index.ts +6 -0
- package/extension/pi-extension-adapter.ts +2 -0
- package/extension/presentation.ts +7 -19
- package/package.json +12 -12
- package/scripts/_core-helpers.mjs +2 -0
- package/scripts/claude/headless-dev-loop.mjs +1 -1
- package/scripts/docs/validate-rule-ownership.mjs +442 -0
- package/scripts/docs/validate-state-machine-conformance.mjs +1092 -0
- package/scripts/github/_review-thread-mutations.mjs +5 -2
- package/scripts/github/capture-review-threads.mjs +2 -9
- package/scripts/github/comment-issue.mjs +2 -9
- package/scripts/github/create-label.mjs +133 -0
- package/scripts/github/create-pr.mjs +119 -10
- package/scripts/github/detect-checkpoint-evidence.mjs +259 -18
- 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 +2 -9
- package/scripts/github/list-issues.mjs +2 -9
- package/scripts/github/list-review-threads.mjs +277 -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 +20 -5
- 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 +98 -26
- 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 +105 -15
- package/scripts/github/verify-briefing-prefixes.mjs +191 -0
- package/scripts/github/verify-fresh-review-context.mjs +226 -32
- package/scripts/github/view-pr.mjs +150 -0
- package/scripts/github/wait-pr-checks.mjs +171 -0
- package/scripts/github/write-gate-context.mjs +458 -65
- package/scripts/github/write-gate-findings-log.mjs +128 -2
- package/scripts/lib/jq-output.mjs +18 -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 +162 -14
- package/scripts/loop/debt-remediate.mjs +24 -12
- package/scripts/loop/detect-change-scope.mjs +38 -9
- package/scripts/loop/detect-copilot-loop-state.mjs +34 -8
- 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 +184 -69
- 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 +216 -10
- 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 +8 -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 +106 -0
- package/scripts/loop/steer-loop.mjs +29 -16
- package/scripts/loop/validate-pr-body-spec.mjs +223 -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 +443 -0
- package/scripts/projects/_resolve-project.mjs +1 -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 +59 -382
- package/scripts/projects/move-queue-item.mjs +21 -449
- package/scripts/projects/reconcile-queue.mjs +253 -0
- package/scripts/projects/reorder-queue-item.mjs +43 -68
- package/scripts/projects/resolve-active-board-item.mjs +108 -46
- 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 +4 -8
- 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 +16 -2
- package/skills/copilot-pr-followup/SKILL.md +60 -34
- package/skills/dev-loop/SKILL.md +11 -7
- package/skills/docs/acceptance-criteria-verification.md +8 -3
- package/skills/docs/anti-patterns.md +5 -3
- package/skills/docs/artifact-authority-contract.md +30 -11
- package/skills/docs/confirmation-rules.md +1 -0
- package/skills/docs/contract-style-guide.md +37 -0
- package/skills/docs/copilot-loop-operations.md +19 -15
- package/skills/docs/cross-harness-regression-contract.md +60 -0
- package/skills/docs/debt-remediation-contract.md +1 -1
- package/skills/docs/epic-tree-refinement-procedure.md +25 -22
- package/skills/docs/issue-intake-procedure.md +26 -19
- 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 +30 -12
- package/skills/docs/plan-file-contract.md +1 -1
- package/skills/docs/pr-lifecycle-contract.md +44 -35
- package/skills/docs/public-dev-loop-contract.md +60 -44
- package/skills/docs/required-rules.json +149 -0
- package/skills/docs/retrospective-checkpoint-contract.md +106 -79
- package/skills/docs/spike-mode-contract.md +14 -7
- package/skills/docs/stop-conditions.md +30 -15
- package/skills/docs/tracker-first-loop-state.md +13 -8
- package/skills/docs/ui-e2e-scoping-step.md +32 -0
- package/skills/docs/validation-policy.md +4 -5
- package/skills/docs/workflow-handoff-contract.md +39 -0
- package/skills/local-implementation/SKILL.md +62 -145
- package/skills/loop-grill/SKILL.md +165 -0
- package/.claude/commands/continue.md +0 -15
- package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
- 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
|
@@ -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
|
+
}
|
|
@@ -3,7 +3,7 @@ import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "
|
|
|
3
3
|
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
4
4
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
5
5
|
import { parseArgs } from "node:util";
|
|
6
|
-
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
6
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
7
7
|
|
|
8
8
|
const DEFAULT_TAIL_LINES = 200;
|
|
9
9
|
|
|
@@ -91,14 +91,7 @@ export function parseFetchCiLogsCliArgs(argv) {
|
|
|
91
91
|
options.tail = value;
|
|
92
92
|
continue;
|
|
93
93
|
}
|
|
94
|
-
if (token
|
|
95
|
-
options.jq = requireTokenValue(token, parseError);
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
if (token.name === "silent") {
|
|
99
|
-
options.silent = true;
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
94
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
102
95
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
103
96
|
}
|
|
104
97
|
if (options.repo === undefined || options.pr === undefined) {
|
|
@@ -3,7 +3,7 @@ import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "
|
|
|
3
3
|
import { requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
4
4
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
5
5
|
import { parseArgs } from "node:util";
|
|
6
|
-
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
6
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
7
7
|
|
|
8
8
|
const STATES = new Set(["open", "closed", "all"]);
|
|
9
9
|
|
|
@@ -93,14 +93,7 @@ export function parseListIssuesCliArgs(argv) {
|
|
|
93
93
|
options.limit = value;
|
|
94
94
|
continue;
|
|
95
95
|
}
|
|
96
|
-
if (token
|
|
97
|
-
options.jq = requireTokenValue(token, parseError);
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
if (token.name === "silent") {
|
|
101
|
-
options.silent = true;
|
|
102
|
-
continue;
|
|
103
|
-
}
|
|
96
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
104
97
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
105
98
|
}
|
|
106
99
|
if (options.repo === undefined) {
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
4
|
+
import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
|
|
5
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
6
|
+
import { authorMatchesFilter } from "./_review-thread-mutations.mjs";
|
|
7
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
8
|
+
|
|
9
|
+
// Dedicated query: unlike capture-review-threads.mjs's REVIEW_THREADS_QUERY
|
|
10
|
+
// (all comments, no path/line/isOutdated, no pagination past 100), this tool
|
|
11
|
+
// needs only the FIRST comment per thread (the reply-resolve target) plus the
|
|
12
|
+
// thread-level path/line/isOutdated fields, and must paginate past 100 threads.
|
|
13
|
+
export const LIST_REVIEW_THREADS_QUERY = [
|
|
14
|
+
"query($owner: String!, $name: String!, $pr: Int!, $after: String) {",
|
|
15
|
+
" repository(owner: $owner, name: $name) {",
|
|
16
|
+
" pullRequest(number: $pr) {",
|
|
17
|
+
" reviewThreads(first: 100, after: $after) {",
|
|
18
|
+
" pageInfo {",
|
|
19
|
+
" hasNextPage",
|
|
20
|
+
" endCursor",
|
|
21
|
+
" }",
|
|
22
|
+
" nodes {",
|
|
23
|
+
" id",
|
|
24
|
+
" isResolved",
|
|
25
|
+
" isOutdated",
|
|
26
|
+
" path",
|
|
27
|
+
" line",
|
|
28
|
+
" comments(first: 1) {",
|
|
29
|
+
" nodes {",
|
|
30
|
+
" databaseId",
|
|
31
|
+
" body",
|
|
32
|
+
" author {",
|
|
33
|
+
" login",
|
|
34
|
+
" }",
|
|
35
|
+
" }",
|
|
36
|
+
" }",
|
|
37
|
+
" }",
|
|
38
|
+
" }",
|
|
39
|
+
" }",
|
|
40
|
+
" }",
|
|
41
|
+
"}",
|
|
42
|
+
].join("\n");
|
|
43
|
+
|
|
44
|
+
// Bounded excerpt so a listing over many threads stays scannable; full body
|
|
45
|
+
// text is available from capture-review-threads.mjs when actually needed.
|
|
46
|
+
const BODY_EXCERPT_MAX_CHARS = 200;
|
|
47
|
+
|
|
48
|
+
const USAGE = `Usage: list-review-threads.mjs --repo <owner/name> --pr <number> [--unresolved-only] [--author <login>]
|
|
49
|
+
List review threads on a pull request with the thread id + first-comment
|
|
50
|
+
databaseId reply-resolve-review-thread.mjs needs, so a fixer pass can go
|
|
51
|
+
list -> reply-resolve without hand-written GraphQL.
|
|
52
|
+
Required:
|
|
53
|
+
--repo <owner/name> Repository slug (e.g. owner/repo)
|
|
54
|
+
--pr <number> Pull request number
|
|
55
|
+
Optional:
|
|
56
|
+
--unresolved-only Only include threads where isResolved is false
|
|
57
|
+
--author <login> Only include threads whose first comment author matches
|
|
58
|
+
(case-insensitive; "copilot" matches any Copilot review
|
|
59
|
+
login variant; "all" matches every author)
|
|
60
|
+
Output (stdout, JSON):
|
|
61
|
+
{ "ok": true, "repo": "owner/name", "pr": N, "threads": [
|
|
62
|
+
{ "threadId": "...", "commentId": N|null, "author": "..."|null,
|
|
63
|
+
"body": "...", "isResolved": bool, "isOutdated": bool,
|
|
64
|
+
"path": "..."|null, "line": N|null }, ...
|
|
65
|
+
] }
|
|
66
|
+
Error output (stderr, JSON):
|
|
67
|
+
Argument/usage errors:
|
|
68
|
+
{ "ok": false, "error": "...", "usage": "..." }
|
|
69
|
+
gh/runtime failures:
|
|
70
|
+
{ "ok": false, "error": "..." }
|
|
71
|
+
${JQ_OUTPUT_USAGE}
|
|
72
|
+
Exit codes:
|
|
73
|
+
0 Success
|
|
74
|
+
1 Argument error or gh failure
|
|
75
|
+
2 Invalid --jq filter`.trim();
|
|
76
|
+
|
|
77
|
+
const parseError = buildParseError(USAGE);
|
|
78
|
+
|
|
79
|
+
export function parseListReviewThreadsCliArgs(argv) {
|
|
80
|
+
const { tokens } = parseArgs({
|
|
81
|
+
args: [...argv],
|
|
82
|
+
options: {
|
|
83
|
+
help: { type: "boolean", short: "h" },
|
|
84
|
+
repo: { type: "string" },
|
|
85
|
+
pr: { type: "string" },
|
|
86
|
+
"unresolved-only": { type: "boolean" },
|
|
87
|
+
author: { type: "string" },
|
|
88
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
89
|
+
},
|
|
90
|
+
allowPositionals: true,
|
|
91
|
+
strict: false,
|
|
92
|
+
tokens: true,
|
|
93
|
+
});
|
|
94
|
+
const options = {
|
|
95
|
+
help: false,
|
|
96
|
+
repo: undefined,
|
|
97
|
+
pr: undefined,
|
|
98
|
+
unresolvedOnly: false,
|
|
99
|
+
author: undefined,
|
|
100
|
+
};
|
|
101
|
+
for (const token of tokens) {
|
|
102
|
+
if (token.kind === "positional") {
|
|
103
|
+
throw parseError(`Unknown argument: ${token.value}`);
|
|
104
|
+
}
|
|
105
|
+
if (token.kind !== "option") {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (token.name === "help") {
|
|
109
|
+
options.help = true;
|
|
110
|
+
return options;
|
|
111
|
+
}
|
|
112
|
+
if (token.name === "repo") {
|
|
113
|
+
options.repo = requireTokenValue(token, parseError).trim();
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (token.name === "pr") {
|
|
117
|
+
options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (token.name === "unresolved-only") {
|
|
121
|
+
options.unresolvedOnly = true;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (token.name === "author") {
|
|
125
|
+
options.author = requireTokenValue(token, parseError).trim();
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
129
|
+
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
130
|
+
}
|
|
131
|
+
if (options.repo === undefined || options.pr === undefined) {
|
|
132
|
+
throw parseError("Listing review threads requires both --repo <owner/name> and --pr <number>");
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
parseRepoSlug(options.repo);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw parseError(error instanceof Error ? error.message : String(error));
|
|
138
|
+
}
|
|
139
|
+
return options;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function buildQueryArgs({ owner, name, pr, after }) {
|
|
143
|
+
const args = [
|
|
144
|
+
"api",
|
|
145
|
+
"graphql",
|
|
146
|
+
"--field",
|
|
147
|
+
`owner=${owner}`,
|
|
148
|
+
"--field",
|
|
149
|
+
`name=${name}`,
|
|
150
|
+
"--field",
|
|
151
|
+
`pr=${pr}`,
|
|
152
|
+
"--field",
|
|
153
|
+
`query=${LIST_REVIEW_THREADS_QUERY}`,
|
|
154
|
+
];
|
|
155
|
+
if (typeof after === "string" && after.length > 0) {
|
|
156
|
+
args.push("--field", `after=${after}`);
|
|
157
|
+
}
|
|
158
|
+
return args;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function readThreadsConnection(payload) {
|
|
162
|
+
const connection = payload?.data?.repository?.pullRequest?.reviewThreads;
|
|
163
|
+
if (!connection || typeof connection !== "object") {
|
|
164
|
+
throw new Error("Invalid review-threads GraphQL payload: missing data.repository.pullRequest.reviewThreads");
|
|
165
|
+
}
|
|
166
|
+
const nodes = Array.isArray(connection.nodes) ? connection.nodes : [];
|
|
167
|
+
const pageInfo = connection.pageInfo ?? {};
|
|
168
|
+
return {
|
|
169
|
+
nodes,
|
|
170
|
+
hasNextPage: Boolean(pageInfo.hasNextPage),
|
|
171
|
+
endCursor: typeof pageInfo.endCursor === "string" ? pageInfo.endCursor : null,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function excerptBody(rawBody) {
|
|
176
|
+
const text = typeof rawBody === "string" ? rawBody.trim() : "";
|
|
177
|
+
if (text.length <= BODY_EXCERPT_MAX_CHARS) {
|
|
178
|
+
return text;
|
|
179
|
+
}
|
|
180
|
+
return `${text.slice(0, BODY_EXCERPT_MAX_CHARS)}…`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function normalizeThreadNode(node) {
|
|
184
|
+
const firstComment = node?.comments?.nodes?.[0] ?? null;
|
|
185
|
+
const commentId = Number.isFinite(firstComment?.databaseId) ? firstComment.databaseId : null;
|
|
186
|
+
const authorLogin = firstComment?.author?.login;
|
|
187
|
+
const author = typeof authorLogin === "string" && authorLogin.length > 0 ? authorLogin : null;
|
|
188
|
+
return {
|
|
189
|
+
threadId: typeof node?.id === "string" ? node.id : "",
|
|
190
|
+
commentId,
|
|
191
|
+
author,
|
|
192
|
+
body: excerptBody(firstComment?.body),
|
|
193
|
+
isResolved: Boolean(node?.isResolved),
|
|
194
|
+
isOutdated: Boolean(node?.isOutdated),
|
|
195
|
+
path: typeof node?.path === "string" ? node.path : null,
|
|
196
|
+
line: typeof node?.line === "number" ? node.line : null,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function fetchAllReviewThreads(
|
|
201
|
+
{ repo, pr },
|
|
202
|
+
{ env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {},
|
|
203
|
+
) {
|
|
204
|
+
const { owner, name } = parseRepoSlug(repo);
|
|
205
|
+
const threads = [];
|
|
206
|
+
let after = null;
|
|
207
|
+
while (true) {
|
|
208
|
+
const result = await runChild(ghCommand, buildQueryArgs({ owner, name, pr, after }), env);
|
|
209
|
+
if (result.code !== 0) {
|
|
210
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
211
|
+
throw new Error(`gh command failed: ${detail}`);
|
|
212
|
+
}
|
|
213
|
+
const payload = parseJsonText(result.stdout);
|
|
214
|
+
const { nodes, hasNextPage, endCursor } = readThreadsConnection(payload);
|
|
215
|
+
for (const node of nodes) {
|
|
216
|
+
threads.push(normalizeThreadNode(node));
|
|
217
|
+
}
|
|
218
|
+
if (!hasNextPage) {
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
if (!endCursor) {
|
|
222
|
+
throw new Error("Invalid review-threads GraphQL payload: pageInfo.hasNextPage is true but endCursor is missing");
|
|
223
|
+
}
|
|
224
|
+
after = endCursor;
|
|
225
|
+
}
|
|
226
|
+
return threads;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function filterThreads(threads, { unresolvedOnly = false, author = undefined } = {}) {
|
|
230
|
+
// "all" is a match-everything sentinel: bypass the author filter entirely so
|
|
231
|
+
// threads with a missing/ghost first-comment author (author null) still list.
|
|
232
|
+
// authorMatchesFilter deliberately rejects empty logins for its reply-resolve
|
|
233
|
+
// callers, so routing "all" through it would drop those threads.
|
|
234
|
+
const authorFilter = typeof author === "string" && author.trim().toLowerCase() === "all"
|
|
235
|
+
? undefined
|
|
236
|
+
: author;
|
|
237
|
+
return threads.filter((thread) => {
|
|
238
|
+
if (unresolvedOnly && thread.isResolved) {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
if (authorFilter !== undefined && !authorMatchesFilter(thread.author, authorFilter)) {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
return true;
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function runCli(
|
|
249
|
+
argv = process.argv.slice(2),
|
|
250
|
+
{
|
|
251
|
+
stdout = process.stdout,
|
|
252
|
+
stderr = process.stderr,
|
|
253
|
+
env = process.env,
|
|
254
|
+
ghCommand = "gh",
|
|
255
|
+
} = {},
|
|
256
|
+
) {
|
|
257
|
+
const options = parseListReviewThreadsCliArgs(argv);
|
|
258
|
+
if (options.help) {
|
|
259
|
+
stdout.write(`${USAGE}\n`);
|
|
260
|
+
return 0;
|
|
261
|
+
}
|
|
262
|
+
const allThreads = await fetchAllReviewThreads({ repo: options.repo, pr: options.pr }, { env, ghCommand });
|
|
263
|
+
const threads = filterThreads(allThreads, { unresolvedOnly: options.unresolvedOnly, author: options.author });
|
|
264
|
+
const result = { ok: true, repo: options.repo, pr: options.pr, threads };
|
|
265
|
+
return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
269
|
+
runCli().then((code) => {
|
|
270
|
+
if (typeof code === "number") {
|
|
271
|
+
process.exitCode = code;
|
|
272
|
+
}
|
|
273
|
+
}).catch((error) => {
|
|
274
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
275
|
+
process.exitCode = 1;
|
|
276
|
+
});
|
|
277
|
+
}
|