dev-loops 0.3.0 → 0.5.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/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +1 -1
- package/.claude/agents/refiner.md +1 -0
- package/.claude/hooks/_run-context.mjs +9 -16
- package/.claude/skills/copilot-pr-followup/SKILL.md +9 -7
- package/.claude/skills/dev-loop/SKILL.md +17 -7
- package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/.claude/skills/docs/anti-patterns.md +1 -1
- package/.claude/skills/docs/artifact-authority-contract.md +86 -31
- package/.claude/skills/docs/copilot-loop-operations.md +1 -1
- package/.claude/skills/docs/issue-intake-procedure.md +4 -0
- package/.claude/skills/docs/local-planning-flow.md +63 -0
- package/.claude/skills/docs/local-planning-worked-example.md +139 -0
- package/.claude/skills/docs/merge-preconditions.md +100 -1
- package/.claude/skills/docs/plan-file-contract.md +37 -0
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
- package/.claude/skills/docs/spike-mode-contract.md +237 -0
- package/.claude/skills/docs/stop-conditions.md +1 -0
- package/.claude/skills/docs/tracker-first-loop-state.md +6 -6
- package/.claude/skills/local-implementation/SKILL.md +8 -2
- package/CHANGELOG.md +69 -0
- package/README.md +9 -2
- package/agents/refiner.agent.md +1 -0
- package/cli/index.mjs +21 -2
- package/extension/README.md +1 -1
- package/package.json +6 -4
- package/scripts/README.md +2 -2
- package/scripts/github/capture-review-threads.mjs +20 -2
- package/scripts/github/offer-human-handoff.mjs +147 -0
- package/scripts/github/probe-ci-status.mjs +468 -0
- package/scripts/github/probe-copilot-review.mjs +69 -3
- package/scripts/github/request-copilot-review.mjs +3 -3
- package/scripts/github/resolve-handoff-candidates.mjs +412 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +20 -5
- package/scripts/lib/jq-output.mjs +297 -0
- package/scripts/loop/_stale-runner-detection.mjs +1 -1
- package/scripts/loop/_worktree-path.mjs +27 -0
- package/scripts/loop/check-retro-tooling.mjs +246 -0
- package/scripts/loop/cleanup-worktree.mjs +175 -0
- package/scripts/loop/copilot-pr-handoff.mjs +22 -4
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +35 -2
- package/scripts/loop/detect-stale-runner.mjs +3 -4
- package/scripts/loop/docs-grill-contract.mjs +70 -0
- package/scripts/loop/ensure-worktree.mjs +219 -0
- package/scripts/loop/info.mjs +21 -2
- package/scripts/loop/outer-loop.mjs +1 -1
- package/scripts/loop/pr-runner-coordination.mjs +21 -5
- package/scripts/loop/pre-flight-gate.mjs +10 -7
- package/scripts/loop/pre-push-main-guard.mjs +4 -4
- package/scripts/loop/provision-worktree.mjs +243 -0
- package/scripts/loop/resolve-dev-loop-startup.mjs +181 -10
- package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
- package/scripts/loop/run-queue.mjs +25 -1
- package/scripts/loop/run-watch-cycle.mjs +152 -25
- package/scripts/loop/slides-story-review-contract.mjs +123 -0
- package/scripts/pages/build-site.mjs +136 -0
- package/scripts/projects/add-queue-item.mjs +32 -7
- package/scripts/projects/archive-done-items.mjs +2 -1
- package/scripts/projects/ensure-queue-board.mjs +2 -1
- package/scripts/projects/list-queue-items.mjs +14 -3
- package/scripts/projects/move-queue-item.mjs +14 -3
- package/scripts/projects/reorder-queue-item.mjs +5 -4
- package/scripts/projects/sync-item-status.mjs +2 -1
- package/scripts/refine/_refine-helpers.mjs +20 -0
- package/scripts/refine/exit-spike.mjs +186 -0
- package/scripts/refine/promote-plan.mjs +387 -0
- package/scripts/refine/refine-plan-file.mjs +165 -0
- package/scripts/refine/validate-plan-file.mjs +64 -0
- package/scripts/refine/validate-spike-file.mjs +87 -0
- package/skills/copilot-pr-followup/SKILL.md +9 -7
- package/skills/dev-loop/SKILL.md +13 -3
- package/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/skills/docs/anti-patterns.md +1 -1
- package/skills/docs/artifact-authority-contract.md +86 -31
- package/skills/docs/copilot-loop-operations.md +1 -1
- package/skills/docs/issue-intake-procedure.md +4 -0
- package/skills/docs/local-planning-flow.md +63 -0
- package/skills/docs/local-planning-worked-example.md +139 -0
- package/skills/docs/merge-preconditions.md +100 -1
- package/skills/docs/plan-file-contract.md +37 -0
- package/skills/docs/retrospective-checkpoint-contract.md +55 -7
- package/skills/docs/spike-mode-contract.md +237 -0
- package/skills/docs/stop-conditions.md +1 -0
- package/skills/docs/tracker-first-loop-state.md +6 -6
- package/skills/local-implementation/SKILL.md +8 -2
|
@@ -3,6 +3,7 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
3
3
|
import { buildParseError, formatCliError, isCopilotLogin, isDirectCliRun, parseJsonText, parseReviewThreads } from "../_core-helpers.mjs";
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
5
|
import { parsePositiveInteger, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
6
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
6
7
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
7
8
|
import {
|
|
8
9
|
DEFAULT_POLL_INTERVAL_MS,
|
|
@@ -24,6 +25,10 @@ Required:
|
|
|
24
25
|
Output (stdout, JSON):
|
|
25
26
|
{ "ok": true, "status": "changed"|"timeout"|"idle", "repo": "...", "pr": N, "attempts": N,
|
|
26
27
|
"newComments": [...], "newReviews": [...], "newIssueComments": [...] }
|
|
28
|
+
Concise mode:
|
|
29
|
+
--concise, --summary Human-readable summary: status, attempts, new-activity
|
|
30
|
+
counts, AND the current round's new Copilot comment bodies.
|
|
31
|
+
${JQ_OUTPUT_USAGE}
|
|
27
32
|
Activity statuses:
|
|
28
33
|
changed Fresh Copilot review activity found (check newComments/newReviews/newIssueComments)
|
|
29
34
|
timeout Watch period elapsed with no fresh Copilot activity
|
|
@@ -91,7 +96,14 @@ function rejectRemovedFlag(token) {
|
|
|
91
96
|
export function parseWatchCliArgs(argv) {
|
|
92
97
|
const { tokens } = parseArgs({
|
|
93
98
|
args: [...argv],
|
|
94
|
-
options: {
|
|
99
|
+
options: {
|
|
100
|
+
help: { type: "boolean", short: "h" },
|
|
101
|
+
repo: { type: "string" },
|
|
102
|
+
pr: { type: "string" },
|
|
103
|
+
concise: { type: "boolean" },
|
|
104
|
+
summary: { type: "boolean" },
|
|
105
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
106
|
+
},
|
|
95
107
|
allowPositionals: true,
|
|
96
108
|
strict: false,
|
|
97
109
|
tokens: true,
|
|
@@ -102,6 +114,9 @@ export function parseWatchCliArgs(argv) {
|
|
|
102
114
|
pr: undefined,
|
|
103
115
|
pollIntervalMs: DEFAULT_POLL_INTERVAL_MS,
|
|
104
116
|
timeoutMs: COPILOT_REVIEW_WAIT_TIMEOUT_MS,
|
|
117
|
+
concise: false,
|
|
118
|
+
jq: undefined,
|
|
119
|
+
silent: false,
|
|
105
120
|
};
|
|
106
121
|
for (const token of tokens) {
|
|
107
122
|
if (token.kind === "positional") {
|
|
@@ -125,6 +140,18 @@ export function parseWatchCliArgs(argv) {
|
|
|
125
140
|
options.pr = parsePositiveInteger(requireTokenValue(token, parseError), "--pr", parseError);
|
|
126
141
|
continue;
|
|
127
142
|
}
|
|
143
|
+
if (token.name === "concise" || token.name === "summary") {
|
|
144
|
+
options.concise = true;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (token.name === "jq") {
|
|
148
|
+
options.jq = requireTokenValue(token, parseError);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (token.name === "silent") {
|
|
152
|
+
options.silent = true;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
128
155
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
129
156
|
}
|
|
130
157
|
if (options.repo === undefined || options.pr === undefined) {
|
|
@@ -311,6 +338,37 @@ export function buildPollDelayMs(watchStartedAtMs, timeoutMs, pollIntervalMs, at
|
|
|
311
338
|
const scheduledAtMs = watchStartedAtMs + Math.min(timeoutMs, attempt * pollIntervalMs);
|
|
312
339
|
return Math.max(0, scheduledAtMs - nowMs);
|
|
313
340
|
}
|
|
341
|
+
// Human-readable concise summary of a probe result, including the current
|
|
342
|
+
// round's new Copilot comment bodies (the field `loop info --pr` omits).
|
|
343
|
+
export function formatProbeConcise(result) {
|
|
344
|
+
const newComments = result.newComments ?? [];
|
|
345
|
+
const newReviews = result.newReviews ?? [];
|
|
346
|
+
const newIssueComments = result.newIssueComments ?? [];
|
|
347
|
+
const lines = [
|
|
348
|
+
`Copilot probe: PR #${result.pr} (${result.repo})`,
|
|
349
|
+
` status: ${result.status}`,
|
|
350
|
+
` attempts: ${result.attempts}`,
|
|
351
|
+
` new threadComments: ${newComments.length}`,
|
|
352
|
+
` new reviews: ${newReviews.length}`,
|
|
353
|
+
` new issueComments: ${newIssueComments.length}`,
|
|
354
|
+
];
|
|
355
|
+
const bodies = [
|
|
356
|
+
...newReviews.map((r) => ({ kind: "review", body: r.body })),
|
|
357
|
+
...newComments.map((c) => ({ kind: "threadComment", body: c.body })),
|
|
358
|
+
...newIssueComments.map((c) => ({ kind: "issueComment", body: c.body })),
|
|
359
|
+
].filter((entry) => typeof entry.body === "string" && entry.body.trim().length > 0);
|
|
360
|
+
if (bodies.length > 0) {
|
|
361
|
+
lines.push(" new Copilot comment bodies this round:");
|
|
362
|
+
for (const entry of bodies) {
|
|
363
|
+
const indented = entry.body.trim().split("\n").map((l) => ` ${l}`).join("\n");
|
|
364
|
+
lines.push(` [${entry.kind}]`);
|
|
365
|
+
lines.push(indented);
|
|
366
|
+
}
|
|
367
|
+
} else {
|
|
368
|
+
lines.push(" new Copilot comment bodies this round: (none)");
|
|
369
|
+
}
|
|
370
|
+
return lines.join("\n");
|
|
371
|
+
}
|
|
314
372
|
export async function runCli(
|
|
315
373
|
argv = process.argv.slice(2),
|
|
316
374
|
{
|
|
@@ -325,10 +383,18 @@ export async function runCli(
|
|
|
325
383
|
return;
|
|
326
384
|
}
|
|
327
385
|
const result = await watchCopilotReview(options, { env, ghCommand });
|
|
328
|
-
|
|
386
|
+
if (options.concise && options.jq === undefined && !options.silent) {
|
|
387
|
+
stdout.write(`${formatProbeConcise(result)}\n`);
|
|
388
|
+
return result.ok === false ? 1 : 0;
|
|
389
|
+
}
|
|
390
|
+
return emitResult(result, { jq: options.jq, silent: options.silent, stdout });
|
|
329
391
|
}
|
|
330
392
|
if (isDirectCliRun(import.meta.url)) {
|
|
331
|
-
runCli().
|
|
393
|
+
runCli().then((code) => {
|
|
394
|
+
if (typeof code === "number") {
|
|
395
|
+
process.exitCode = code;
|
|
396
|
+
}
|
|
397
|
+
}).catch((error) => {
|
|
332
398
|
process.stderr.write(`${formatCliError(error)}\n`);
|
|
333
399
|
process.exitCode = 1;
|
|
334
400
|
});
|
|
@@ -30,7 +30,7 @@ Optional:
|
|
|
30
30
|
the last Copilot review. Refused when the PR head
|
|
31
31
|
has not changed since the last review.
|
|
32
32
|
Debug:
|
|
33
|
-
|
|
33
|
+
DEVLOOPS_DEBUG=1 Emit stderr traces when best-effort same-head clean
|
|
34
34
|
convergence detection falls back to unsuppressed behavior
|
|
35
35
|
Output (stdout, JSON):
|
|
36
36
|
{ "ok": true, "status": "requested"|"already-requested"|"unavailable"|"suppressed_same_head_clean"|"blocked_by_copilot_comment"|"round_cap_reached"|"no_changes_since_last_review"|"suppressed_draft",
|
|
@@ -262,7 +262,7 @@ async function detectSameHeadCleanConvergence(options, runtime, priorReviewState
|
|
|
262
262
|
const interpretation = interpretLoopState(snapshot);
|
|
263
263
|
return interpretation.sameHeadCleanConverged;
|
|
264
264
|
} catch (error) {
|
|
265
|
-
if (runtime?.env?.
|
|
265
|
+
if (runtime?.env?.DEVLOOPS_DEBUG === "1") {
|
|
266
266
|
const detail = error instanceof Error ? error.message : String(error);
|
|
267
267
|
process.stderr.write(`[request-copilot-review] same-head clean-convergence detection unavailable: ${detail}\n`);
|
|
268
268
|
}
|
|
@@ -302,7 +302,7 @@ async function detectRoundCapAutoRerequestEligibility(options, runtime, priorRev
|
|
|
302
302
|
interpretation,
|
|
303
303
|
};
|
|
304
304
|
} catch (error) {
|
|
305
|
-
if (runtime?.env?.
|
|
305
|
+
if (runtime?.env?.DEVLOOPS_DEBUG === "1") {
|
|
306
306
|
const detail = error instanceof Error ? error.message : String(error);
|
|
307
307
|
process.stderr.write(`[request-copilot-review] round-cap auto-rerequest detection unavailable: ${detail}\n`);
|
|
308
308
|
}
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile as fsReadFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
6
|
+
import { parsePrNumber, runChild } from "../_cli-primitives.mjs";
|
|
7
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
8
|
+
import {
|
|
9
|
+
loadDevLoopConfig,
|
|
10
|
+
resolveHumanHandoffConfig,
|
|
11
|
+
} from "@dev-loops/core/config";
|
|
12
|
+
|
|
13
|
+
const USAGE = `Usage: resolve-handoff-candidates.mjs --repo <owner/name> --pr <number> [--changed-files <a,b,c>] [--pr-author <login>]
|
|
14
|
+
Resolve an ordered, deduped list of human-handoff reviewer/assignee candidates
|
|
15
|
+
for a PR, from the sources configured under \`approval.humanHandoff\` (#920).
|
|
16
|
+
This is the "offer" side: it surfaces candidates only — it never assigns anyone.
|
|
17
|
+
|
|
18
|
+
Sources (in priority order):
|
|
19
|
+
assignees Static configured list (approval.humanHandoff.assignees).
|
|
20
|
+
codeowners .github/CODEOWNERS / CODEOWNERS / docs/CODEOWNERS matched
|
|
21
|
+
against the PR's changed paths (last-match-wins per
|
|
22
|
+
CODEOWNERS semantics). Team handles (@org/team) are
|
|
23
|
+
included, flagged with isTeam:true.
|
|
24
|
+
recent-committers git log authors on the PR's changed paths (recent commits),
|
|
25
|
+
by GitHub login where derivable from the commit email,
|
|
26
|
+
excluding the PR author and bots.
|
|
27
|
+
|
|
28
|
+
Which sources run is controlled by approval.humanHandoff.candidatesFrom.
|
|
29
|
+
Disabled (default) => no-op: ok:true with an empty candidate list.
|
|
30
|
+
|
|
31
|
+
Required:
|
|
32
|
+
--repo <owner/name>
|
|
33
|
+
--pr <number>
|
|
34
|
+
Optional:
|
|
35
|
+
--changed-files <csv> Comma-separated changed paths. When omitted, derived
|
|
36
|
+
from \`gh pr view --json files\`.
|
|
37
|
+
--pr-author <login> PR author login (excluded from recent-committers).
|
|
38
|
+
When omitted, derived from \`gh pr view --json author\`.
|
|
39
|
+
Output (stdout, JSON):
|
|
40
|
+
{ "ok": true, "enabled": bool, "candidates": [{ "login", "source", "isTeam"?, "paths"? }],
|
|
41
|
+
"changedFiles": [...], "sources": [...], "warnings": [...] }
|
|
42
|
+
Exit codes:
|
|
43
|
+
0 Success (including disabled no-op and fail-soft per-source skips)
|
|
44
|
+
1 Argument error`.trim();
|
|
45
|
+
|
|
46
|
+
const parseError = buildParseError(USAGE);
|
|
47
|
+
|
|
48
|
+
function nextValue(args, i, flag) {
|
|
49
|
+
const value = args[i];
|
|
50
|
+
if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
|
|
51
|
+
throw parseError(`Missing value for ${flag}`);
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const RECENT_COMMITTERS_LIMIT = 50;
|
|
57
|
+
const CODEOWNERS_PATHS = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"];
|
|
58
|
+
// Bot author handles to exclude from recent-committers.
|
|
59
|
+
const BOT_PATTERN = /\[bot\]$|^(?:dependabot|github-actions|copilot|renovate)$/i;
|
|
60
|
+
|
|
61
|
+
export function parseResolveCandidatesCliArgs(argv) {
|
|
62
|
+
const args = [...argv];
|
|
63
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
64
|
+
return { help: true };
|
|
65
|
+
}
|
|
66
|
+
const options = { help: false, repo: undefined, pr: undefined, changedFiles: undefined, prAuthor: undefined };
|
|
67
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
68
|
+
const token = args[i];
|
|
69
|
+
if (token === "--repo") { options.repo = nextValue(args, ++i, "--repo"); continue; }
|
|
70
|
+
if (token === "--pr") { options.pr = parsePrNumber(nextValue(args, ++i, "--pr"), parseError); continue; }
|
|
71
|
+
if (token === "--changed-files") {
|
|
72
|
+
options.changedFiles = nextValue(args, ++i, "--changed-files")
|
|
73
|
+
.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (token === "--pr-author") { options.prAuthor = nextValue(args, ++i, "--pr-author").trim().replace(/^@/, ""); continue; }
|
|
77
|
+
throw parseError(`Unknown argument: ${token}`);
|
|
78
|
+
}
|
|
79
|
+
if (options.repo === undefined || options.pr === undefined) {
|
|
80
|
+
throw parseError("Resolving handoff candidates requires both --repo <owner/name> and --pr <number>");
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
parseRepoSlug(options.repo);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
throw parseError(error instanceof Error ? error.message : String(error));
|
|
86
|
+
}
|
|
87
|
+
return options;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// CODEOWNERS — parse + match
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Parse CODEOWNERS content into ordered { pattern, owners } rules.
|
|
96
|
+
* Comments (#...) and blank lines are skipped. Owners keep their leading `@`.
|
|
97
|
+
* @param {string} content
|
|
98
|
+
* @returns {{ pattern: string, owners: string[] }[]}
|
|
99
|
+
*/
|
|
100
|
+
export function parseCodeowners(content) {
|
|
101
|
+
const rules = [];
|
|
102
|
+
for (const rawLine of String(content).split("\n")) {
|
|
103
|
+
// A `#` starts a comment only at line start (after leading whitespace) or
|
|
104
|
+
// when preceded by whitespace; a mid-token `#` is part of the pattern/owner
|
|
105
|
+
// (gitignore/CODEOWNERS semantics).
|
|
106
|
+
const line = rawLine.replace(/(^|\s)#.*$/, "").trim();
|
|
107
|
+
if (line === "") continue;
|
|
108
|
+
const [pattern, ...owners] = line.split(/\s+/);
|
|
109
|
+
if (!pattern) continue;
|
|
110
|
+
rules.push({ pattern, owners: owners.filter((o) => o.length > 0) });
|
|
111
|
+
}
|
|
112
|
+
return rules;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Match a CODEOWNERS pattern against a repo-relative path (gitignore-ish).
|
|
117
|
+
* Leading `/` anchors to repo root; trailing `/` matches a directory subtree;
|
|
118
|
+
* `*` matches within a path segment, `**` across segments; a bare pattern with
|
|
119
|
+
* no slash matches the path's basename or any segment.
|
|
120
|
+
* @param {string} pattern
|
|
121
|
+
* @param {string} filePath
|
|
122
|
+
* @returns {boolean}
|
|
123
|
+
*/
|
|
124
|
+
export function codeownersMatch(pattern, filePath) {
|
|
125
|
+
const file = filePath.replace(/^\.?\//, "");
|
|
126
|
+
let pat = pattern;
|
|
127
|
+
const dirOnly = pat.endsWith("/");
|
|
128
|
+
if (dirOnly) pat = pat.slice(0, -1);
|
|
129
|
+
// Bare name (no slash, not anchored): match any path segment subtree.
|
|
130
|
+
const anchored = pat.startsWith("/");
|
|
131
|
+
if (anchored) pat = pat.slice(1);
|
|
132
|
+
|
|
133
|
+
const toRegex = (p) => {
|
|
134
|
+
let re = "";
|
|
135
|
+
for (let i = 0; i < p.length; i += 1) {
|
|
136
|
+
const c = p[i];
|
|
137
|
+
if (c === "*") {
|
|
138
|
+
if (p[i + 1] === "*") { re += ".*"; i += 1; }
|
|
139
|
+
else re += "[^/]*";
|
|
140
|
+
} else if ("\\^$.|?+()[]{}".includes(c)) {
|
|
141
|
+
re += `\\${c}`;
|
|
142
|
+
} else {
|
|
143
|
+
re += c;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return re;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// Bare-vs-anchored is decided on the NORMALIZED pattern: a trailing-only `/`
|
|
150
|
+
// (e.g. `build/`) normalizes to a bare token with no leading/internal slash,
|
|
151
|
+
// so it matches at any depth like gitignore. Only a leading or internal slash
|
|
152
|
+
// anchors the match.
|
|
153
|
+
if (!anchored && !pat.includes("/")) {
|
|
154
|
+
// Bare token: match a full segment anywhere, plus its subtree.
|
|
155
|
+
const seg = toRegex(pat);
|
|
156
|
+
return new RegExp(`(?:^|/)${seg}(?:/|$)`).test(file);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const body = toRegex(pat);
|
|
160
|
+
// Anchored (or contains slash): match from root, allow subtree under it.
|
|
161
|
+
const re = new RegExp(`^${body}(?:/|$)`);
|
|
162
|
+
if (re.test(file)) return true;
|
|
163
|
+
// Directory pattern also matches the directory's whole subtree.
|
|
164
|
+
if (dirOnly) return new RegExp(`^${body}/`).test(file);
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Resolve CODEOWNERS owners for a set of changed paths. Last-match-wins per
|
|
170
|
+
* CODEOWNERS semantics: the final matching rule for each path wins.
|
|
171
|
+
* @param {{ pattern: string, owners: string[] }[]} rules
|
|
172
|
+
* @param {string[]} changedFiles
|
|
173
|
+
* @returns {Map<string, Set<string>>} owner login (no `@`) -> set of paths
|
|
174
|
+
*/
|
|
175
|
+
export function ownersForPaths(rules, changedFiles) {
|
|
176
|
+
/** @type {Map<string, Set<string>>} */
|
|
177
|
+
const byOwner = new Map();
|
|
178
|
+
for (const file of changedFiles) {
|
|
179
|
+
let winner = null;
|
|
180
|
+
for (const rule of rules) {
|
|
181
|
+
if (codeownersMatch(rule.pattern, file)) winner = rule;
|
|
182
|
+
}
|
|
183
|
+
if (!winner || winner.owners.length === 0) continue;
|
|
184
|
+
for (const owner of winner.owners) {
|
|
185
|
+
const login = owner.replace(/^@/, "");
|
|
186
|
+
if (login === "") continue;
|
|
187
|
+
if (!byOwner.has(login)) byOwner.set(login, new Set());
|
|
188
|
+
byOwner.get(login).add(file);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return byOwner;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Source resolvers (each fail-soft: returns candidates + a warning on error)
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
async function resolveCodeownersSource(changedFiles, { repoRoot, readFile }) {
|
|
199
|
+
const warnings = [];
|
|
200
|
+
for (const rel of CODEOWNERS_PATHS) {
|
|
201
|
+
let content;
|
|
202
|
+
try {
|
|
203
|
+
content = await readFile(path.join(repoRoot, rel), "utf8");
|
|
204
|
+
} catch (error) {
|
|
205
|
+
// ENOENT => genuinely absent at this location: try the next path quietly.
|
|
206
|
+
// Any other error (EACCES / IO) is real: surface it rather than silently
|
|
207
|
+
// claiming "no CODEOWNERS file found".
|
|
208
|
+
if (error && error.code === "ENOENT") continue;
|
|
209
|
+
warnings.push(`codeowners: failed to read ${rel}: ${error instanceof Error ? error.message : String(error)}`);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const rules = parseCodeowners(content);
|
|
213
|
+
const byOwner = ownersForPaths(rules, changedFiles);
|
|
214
|
+
return {
|
|
215
|
+
candidates: [...byOwner.entries()].map(([login, paths]) => ({
|
|
216
|
+
login,
|
|
217
|
+
source: "codeowners",
|
|
218
|
+
isTeam: login.includes("/"),
|
|
219
|
+
paths: [...paths],
|
|
220
|
+
})),
|
|
221
|
+
warnings,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
// Fail-soft: no CODEOWNERS file anywhere -> no candidates, no abort. Keep any
|
|
225
|
+
// non-ENOENT read warnings so a permissions/IO failure is not masked.
|
|
226
|
+
return { candidates: [], warnings: [...warnings, "codeowners: no CODEOWNERS file found"] };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Map a git author email to a GitHub login. `<login>@users.noreply.github.com`
|
|
231
|
+
* (optionally `<id>+<login>@...`) carries the login directly; otherwise fall
|
|
232
|
+
* back to the local-part of the email as a best-effort handle.
|
|
233
|
+
*/
|
|
234
|
+
export function loginFromCommitEmail(email) {
|
|
235
|
+
const e = String(email).trim().toLowerCase();
|
|
236
|
+
const noreply = e.match(/^(?:\d+\+)?([^@]+)@users\.noreply\.github\.com$/);
|
|
237
|
+
if (noreply) return noreply[1];
|
|
238
|
+
const at = e.indexOf("@");
|
|
239
|
+
return at > 0 ? e.slice(0, at) : null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function resolveRecentCommittersSource(changedFiles, { repoRoot, prAuthor, run }) {
|
|
243
|
+
if (changedFiles.length === 0) {
|
|
244
|
+
return { candidates: [], warnings: [] };
|
|
245
|
+
}
|
|
246
|
+
let stdout;
|
|
247
|
+
try {
|
|
248
|
+
const result = await run("git", [
|
|
249
|
+
"-C", repoRoot,
|
|
250
|
+
"log", `-n${RECENT_COMMITTERS_LIMIT}`, "--no-merges", "--pretty=format:%ae",
|
|
251
|
+
"--", ...changedFiles,
|
|
252
|
+
]);
|
|
253
|
+
if (result.code !== 0) {
|
|
254
|
+
return { candidates: [], warnings: [`recent-committers: git log exited ${result.code}`] };
|
|
255
|
+
}
|
|
256
|
+
stdout = result.stdout;
|
|
257
|
+
} catch (error) {
|
|
258
|
+
return { candidates: [], warnings: [`recent-committers: ${error instanceof Error ? error.message : String(error)}`] };
|
|
259
|
+
}
|
|
260
|
+
const author = prAuthor ? prAuthor.toLowerCase() : null;
|
|
261
|
+
const seen = new Set();
|
|
262
|
+
const candidates = [];
|
|
263
|
+
for (const line of stdout.split("\n")) {
|
|
264
|
+
const email = line.trim();
|
|
265
|
+
if (email === "") continue;
|
|
266
|
+
const login = loginFromCommitEmail(email);
|
|
267
|
+
if (!login) continue;
|
|
268
|
+
const key = login.toLowerCase();
|
|
269
|
+
if (seen.has(key)) continue;
|
|
270
|
+
if (BOT_PATTERN.test(login)) continue;
|
|
271
|
+
if (author && key === author) continue;
|
|
272
|
+
seen.add(key);
|
|
273
|
+
candidates.push({ login, source: "recent-committers" });
|
|
274
|
+
}
|
|
275
|
+
return { candidates, warnings: [] };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// gh lookups for changed files / author (only when not supplied)
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
async function fetchPrFacts(repo, pr, { run, ghCommand }) {
|
|
283
|
+
const result = await run(ghCommand, [
|
|
284
|
+
"pr", "view", String(pr), "--repo", repo, "--json", "files,author",
|
|
285
|
+
]);
|
|
286
|
+
if (result.code !== 0) {
|
|
287
|
+
throw new Error(result.stderr.trim() || `gh pr view exited ${result.code}`);
|
|
288
|
+
}
|
|
289
|
+
const payload = JSON.parse(result.stdout);
|
|
290
|
+
const files = Array.isArray(payload?.files)
|
|
291
|
+
? payload.files.map((f) => (typeof f?.path === "string" ? f.path : "")).filter((p) => p.length > 0)
|
|
292
|
+
: [];
|
|
293
|
+
const author = typeof payload?.author?.login === "string" ? payload.author.login : null;
|
|
294
|
+
return { files, author };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// Orchestration
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Resolve handoff candidates. Pure-ish: all IO is injected.
|
|
303
|
+
* @param {{ repo: string, pr: number, changedFiles?: string[], prAuthor?: string|null }} input
|
|
304
|
+
* @param {object} deps
|
|
305
|
+
* @returns {Promise<object>}
|
|
306
|
+
*/
|
|
307
|
+
export async function resolveHandoffCandidates(input, deps = {}) {
|
|
308
|
+
const {
|
|
309
|
+
config,
|
|
310
|
+
repoRoot = process.cwd(),
|
|
311
|
+
ghCommand = "gh",
|
|
312
|
+
run = (cmd, args) => runChild(cmd, args),
|
|
313
|
+
readFile = fsReadFile,
|
|
314
|
+
} = deps;
|
|
315
|
+
|
|
316
|
+
const handoff = resolveHumanHandoffConfig(config);
|
|
317
|
+
const warnings = [];
|
|
318
|
+
|
|
319
|
+
if (!handoff.enabled) {
|
|
320
|
+
return {
|
|
321
|
+
ok: true,
|
|
322
|
+
enabled: false,
|
|
323
|
+
candidates: [],
|
|
324
|
+
changedFiles: input.changedFiles ?? [],
|
|
325
|
+
sources: [],
|
|
326
|
+
warnings: [],
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
let changedFiles = input.changedFiles;
|
|
331
|
+
let prAuthor = input.prAuthor ?? null;
|
|
332
|
+
if (changedFiles === undefined || prAuthor === null) {
|
|
333
|
+
try {
|
|
334
|
+
const facts = await fetchPrFacts(input.repo, input.pr, { run, ghCommand });
|
|
335
|
+
if (changedFiles === undefined) changedFiles = facts.files;
|
|
336
|
+
if (prAuthor === null) prAuthor = facts.author;
|
|
337
|
+
} catch (error) {
|
|
338
|
+
warnings.push(`pr-facts: ${error instanceof Error ? error.message : String(error)}`);
|
|
339
|
+
if (changedFiles === undefined) changedFiles = [];
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const ordered = [];
|
|
344
|
+
// 1. Static assignees — highest priority.
|
|
345
|
+
for (const login of handoff.assignees) {
|
|
346
|
+
ordered.push({ login: login.replace(/^@/, ""), source: "assignees", isTeam: login.includes("/") });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 2/3. Other sources, in the CANONICAL priority order
|
|
350
|
+
// (assignees > codeowners > recent-committers), regardless of the order they
|
|
351
|
+
// appear in candidatesFrom. candidatesFrom only selects WHICH sources run,
|
|
352
|
+
// not their relative rank.
|
|
353
|
+
const enabledSources = new Set(handoff.candidatesFrom);
|
|
354
|
+
if (enabledSources.has("codeowners")) {
|
|
355
|
+
const result = await resolveCodeownersSource(changedFiles, { repoRoot, readFile });
|
|
356
|
+
ordered.push(...result.candidates);
|
|
357
|
+
warnings.push(...result.warnings);
|
|
358
|
+
}
|
|
359
|
+
if (enabledSources.has("recent-committers")) {
|
|
360
|
+
const result = await resolveRecentCommittersSource(changedFiles, { repoRoot, prAuthor, run });
|
|
361
|
+
ordered.push(...result.candidates);
|
|
362
|
+
warnings.push(...result.warnings);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Dedup by login (case-insensitive), first occurrence wins (preserves the
|
|
366
|
+
// assignees > codeowners > recent-committers priority).
|
|
367
|
+
const seen = new Set();
|
|
368
|
+
const candidates = [];
|
|
369
|
+
for (const c of ordered) {
|
|
370
|
+
const key = c.login.toLowerCase();
|
|
371
|
+
if (seen.has(key)) continue;
|
|
372
|
+
seen.add(key);
|
|
373
|
+
candidates.push(c);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
ok: true,
|
|
378
|
+
enabled: true,
|
|
379
|
+
candidates,
|
|
380
|
+
changedFiles,
|
|
381
|
+
sources: handoff.candidatesFrom,
|
|
382
|
+
warnings,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
387
|
+
let options;
|
|
388
|
+
try {
|
|
389
|
+
options = parseResolveCandidatesCliArgs(argv);
|
|
390
|
+
} catch (error) {
|
|
391
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
392
|
+
return 1;
|
|
393
|
+
}
|
|
394
|
+
if (options.help) {
|
|
395
|
+
process.stdout.write(`${USAGE}\n`);
|
|
396
|
+
return 0;
|
|
397
|
+
}
|
|
398
|
+
const repoRoot = deps.repoRoot ?? process.cwd();
|
|
399
|
+
const { config } = deps.config !== undefined
|
|
400
|
+
? { config: deps.config }
|
|
401
|
+
: await loadDevLoopConfig({ repoRoot });
|
|
402
|
+
const result = await resolveHandoffCandidates(
|
|
403
|
+
{ repo: options.repo, pr: options.pr, changedFiles: options.changedFiles, prAuthor: options.prAuthor ?? null },
|
|
404
|
+
{ ...deps, config, repoRoot },
|
|
405
|
+
);
|
|
406
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
407
|
+
return 0;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
411
|
+
main().then((code) => { process.exitCode = code; });
|
|
412
|
+
}
|
|
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
|
|
|
3
3
|
import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
4
4
|
import { loadDevLoopConfig, resolveGateConfig, resolveRefinementConfig } from "@dev-loops/core/config";
|
|
5
5
|
import { parseArgs } from "node:util";
|
|
6
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
6
7
|
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
7
8
|
import { truncateText } from "@dev-loops/core/bash-exit-one";
|
|
8
9
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
@@ -105,9 +106,11 @@ exists on a different head SHA (the old comment is stale for the current head).
|
|
|
105
106
|
Error output (stderr, JSON):
|
|
106
107
|
{ "ok": false, "error": "...", "usage": "..." }
|
|
107
108
|
{ "ok": false, "error": "..." }
|
|
109
|
+
${JQ_OUTPUT_USAGE}
|
|
108
110
|
Exit codes:
|
|
109
111
|
0 Success
|
|
110
|
-
1 Argument error, gh failure, or contradictory gate evidence
|
|
112
|
+
1 Argument error, gh failure, or contradictory gate evidence
|
|
113
|
+
2 Invalid --jq filter`.trim();
|
|
111
114
|
const parseError = buildParseError(USAGE);
|
|
112
115
|
function rejectRemovedFlag(token) {
|
|
113
116
|
throw parseError(
|
|
@@ -258,6 +261,7 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
|
|
|
258
261
|
"findings-severity-counts": { type: "string" },
|
|
259
262
|
"execution-mode": { type: "string" },
|
|
260
263
|
"inline-reason": { type: "string" },
|
|
264
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
261
265
|
},
|
|
262
266
|
allowPositionals: true,
|
|
263
267
|
strict: false,
|
|
@@ -277,6 +281,8 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
|
|
|
277
281
|
findingsSeverityCounts: undefined,
|
|
278
282
|
executionMode: undefined,
|
|
279
283
|
inlineReason: undefined,
|
|
284
|
+
jq: undefined,
|
|
285
|
+
silent: false,
|
|
280
286
|
};
|
|
281
287
|
for (const token of tokens) {
|
|
282
288
|
if (token.kind === "positional") {
|
|
@@ -385,6 +391,14 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
|
|
|
385
391
|
options.inlineReason = smartTruncate(reason, MAX_GATE_COMMENT_EXCERPT_LENGTH);
|
|
386
392
|
continue;
|
|
387
393
|
}
|
|
394
|
+
if (token.name === "jq") {
|
|
395
|
+
options.jq = requireTokenValue(token, parseError);
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (token.name === "silent") {
|
|
399
|
+
options.silent = true;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
388
402
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
389
403
|
}
|
|
390
404
|
// Default execution mode to inline_single_agent when omitted. inlineReason is
|
|
@@ -1194,7 +1208,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
1194
1208
|
if (existing) {
|
|
1195
1209
|
const updated = await updateComment({ repo: options.repo, commentId: existing.commentId, body: desiredBody }, { env, ghCommand });
|
|
1196
1210
|
// Post-update verification: verify the updated comment is visible via direct API fetch by comment ID.
|
|
1197
|
-
// A run id is set (production context) — DEVLOOPS_RUN_ID
|
|
1211
|
+
// A run id is set (production context) — DEVLOOPS_RUN_ID.
|
|
1198
1212
|
let updateVerificationWarning = null;
|
|
1199
1213
|
if (envRunId) {
|
|
1200
1214
|
let verified = await verifyComment({ repo: options.repo, commentId: updated.commentId }, { env, ghCommand });
|
|
@@ -1229,7 +1243,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
1229
1243
|
// comment is not yet returned by paginated list endpoints. A direct fetch
|
|
1230
1244
|
// by comment ID confirms the comment is persisted, preventing the evidence
|
|
1231
1245
|
// checker from falsely reporting "missing" and triggering a duplicate post.
|
|
1232
|
-
// Only active when a run id is set (production context) — DEVLOOPS_RUN_ID
|
|
1246
|
+
// Only active when a run id is set (production context) — DEVLOOPS_RUN_ID.
|
|
1233
1247
|
let verified = true;
|
|
1234
1248
|
let verificationWarning = null;
|
|
1235
1249
|
if (envRunId) {
|
|
@@ -1286,10 +1300,11 @@ async function main() {
|
|
|
1286
1300
|
const result = await upsertCheckpointVerdict(options);
|
|
1287
1301
|
// Emit the inline-execution warning only on success so the JSON error
|
|
1288
1302
|
// envelope on stderr stays clean and machine-parseable on failures.
|
|
1289
|
-
|
|
1303
|
+
// Suppress it under --silent, which contracts to zero output (exit code only).
|
|
1304
|
+
if (inlineWarning && !options.silent) {
|
|
1290
1305
|
process.stderr.write(`${inlineWarning}\n`);
|
|
1291
1306
|
}
|
|
1292
|
-
process.
|
|
1307
|
+
process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent });
|
|
1293
1308
|
} catch (error) {
|
|
1294
1309
|
process.stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
|
|
1295
1310
|
process.exitCode = 1;
|