dev-loops 0.2.7 → 0.4.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/developer.md +1 -0
- package/.claude/agents/fixer.md +1 -0
- package/.claude/agents/review.md +30 -0
- package/.claude/hooks/_run-context.mjs +9 -16
- package/.claude/skills/copilot-pr-followup/SKILL.md +62 -6
- package/.claude/skills/dev-loop/SKILL.md +6 -6
- package/.claude/skills/docs/anti-patterns.md +2 -0
- package/.claude/skills/docs/copilot-loop-operations.md +3 -3
- package/.claude/skills/docs/issue-intake-procedure.md +4 -0
- package/.claude/skills/docs/merge-preconditions.md +65 -1
- 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 +25 -5
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +69 -0
- package/README.md +8 -2
- package/agents/developer.agent.md +1 -0
- package/agents/fixer.agent.md +1 -0
- package/agents/review.agent.md +30 -0
- package/cli/index.mjs +60 -8
- package/extension/README.md +1 -1
- package/package.json +3 -3
- package/scripts/README.md +8 -7
- package/scripts/_core-helpers.mjs +1 -0
- package/scripts/claude/headless-dev-loop.mjs +53 -13
- package/scripts/claude/headless-info-smoke.mjs +45 -11
- package/scripts/github/build-adjacent-bundle.mjs +448 -0
- package/scripts/github/{create-draft-pr.mjs → create-pr.mjs} +28 -12
- package/scripts/github/detect-checkpoint-evidence.mjs +95 -4
- package/scripts/github/offer-human-handoff.mjs +147 -0
- package/scripts/github/post-gate-findings.mjs +392 -0
- package/scripts/github/probe-ci-status.mjs +468 -0
- package/scripts/github/reconcile-draft-gate.mjs +2 -2
- package/scripts/github/request-copilot-review.mjs +72 -11
- package/scripts/github/resolve-handoff-candidates.mjs +412 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +599 -17
- package/scripts/github/verify-fresh-review-context.mjs +12 -1
- package/scripts/github/write-gate-context.mjs +634 -0
- package/scripts/github/write-gate-findings-log.mjs +1 -1
- package/scripts/loop/_stale-runner-detection.mjs +1 -1
- package/scripts/loop/_worktree-path.mjs +27 -0
- package/scripts/loop/cleanup-worktree.mjs +175 -0
- package/scripts/loop/copilot-pr-handoff.mjs +1 -1
- package/scripts/loop/detect-change-scope.mjs +36 -11
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +30 -18
- package/scripts/loop/detect-stale-runner.mjs +3 -4
- package/scripts/loop/detect-tracker-first-loop-state.mjs +38 -11
- package/scripts/loop/ensure-worktree.mjs +219 -0
- package/scripts/loop/outer-loop.mjs +1 -1
- package/scripts/loop/pr-runner-coordination.mjs +1 -1
- 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 +5 -5
- package/scripts/loop/run-queue.mjs +112 -16
- package/scripts/loop/run-watch-cycle.mjs +75 -22
- package/scripts/projects/add-queue-item.mjs +80 -48
- package/scripts/projects/archive-done-items.mjs +136 -39
- package/scripts/projects/ensure-queue-board.mjs +67 -65
- package/scripts/projects/list-queue-items.mjs +59 -57
- package/scripts/projects/move-queue-item.mjs +125 -125
- package/scripts/projects/reorder-queue-item.mjs +67 -48
- package/scripts/projects/sync-item-status.mjs +199 -0
- package/skills/copilot-pr-followup/SKILL.md +62 -6
- package/skills/dev-loop/SKILL.md +2 -2
- package/skills/dev-loop/scripts/log-bash-exit-1.mjs +2 -2
- package/skills/dev-loop/scripts/phase-files.mjs +2 -2
- package/skills/docs/anti-patterns.md +2 -0
- package/skills/docs/copilot-loop-operations.md +3 -3
- package/skills/docs/issue-intake-procedure.md +4 -0
- package/skills/docs/merge-preconditions.md +65 -1
- package/skills/docs/stop-conditions.md +1 -0
- package/skills/docs/tracker-first-loop-state.md +6 -6
- package/skills/local-implementation/SKILL.md +25 -5
|
@@ -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
|
+
}
|