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
|
@@ -9,9 +9,13 @@ import {
|
|
|
9
9
|
summarizeGateReviewCommentMarkers,
|
|
10
10
|
summarizeGateReviewComments,
|
|
11
11
|
} from "../_core-helpers.mjs";
|
|
12
|
+
import { access } from "node:fs/promises";
|
|
13
|
+
import path from "node:path";
|
|
12
14
|
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
13
15
|
import { fetchGithubReviewThreadsPayload } from "./capture-review-threads.mjs";
|
|
14
16
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
17
|
+
import { loadDevLoopConfig, resolveGateConfig, resolveRequireFanoutEvidence } from "@dev-loops/core/config";
|
|
18
|
+
import { buildLogPath } from "./write-gate-findings-log.mjs";
|
|
15
19
|
import { ensureAsyncRunnerOwnership } from "../loop/_pr-runner-coordination.mjs";
|
|
16
20
|
import { detectStaleRunner } from "../loop/_stale-runner-detection.mjs";
|
|
17
21
|
const USAGE = `Usage: detect-checkpoint-evidence.mjs --repo <owner/name> --pr <number>
|
|
@@ -200,6 +204,8 @@ function emptyGateMarkerSummary() {
|
|
|
200
204
|
verdict: null,
|
|
201
205
|
findingsSummary: null,
|
|
202
206
|
nextAction: null,
|
|
207
|
+
executionMode: null,
|
|
208
|
+
inlineReason: null,
|
|
203
209
|
contractComplete: false,
|
|
204
210
|
commentId: null,
|
|
205
211
|
commentUrl: null,
|
|
@@ -216,13 +222,15 @@ function normalizeGateMarkerSummary(summary) {
|
|
|
216
222
|
verdict: summary.verdict,
|
|
217
223
|
findingsSummary: summary.findingsSummary,
|
|
218
224
|
nextAction: summary.nextAction,
|
|
225
|
+
executionMode: summary.executionMode ?? null,
|
|
226
|
+
inlineReason: summary.inlineReason ?? null,
|
|
219
227
|
contractComplete: summary.contractComplete === true,
|
|
220
228
|
commentId: summary.commentId,
|
|
221
229
|
commentUrl: summary.commentUrl,
|
|
222
230
|
updatedAt: summary.updatedAt,
|
|
223
231
|
};
|
|
224
232
|
}
|
|
225
|
-
export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, staleRunnerCheck = null) {
|
|
233
|
+
export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, staleRunnerCheck = null, fanoutEnforcement = null) {
|
|
226
234
|
const failures = [];
|
|
227
235
|
if (!(evidence.draftGate.visible && evidence.draftGate.verdict === "clean")) {
|
|
228
236
|
failures.push("missing visible clean draft_gate comment");
|
|
@@ -236,6 +244,24 @@ export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, s
|
|
|
236
244
|
)) {
|
|
237
245
|
failures.push("missing visible clean current-head pre_approval_gate comment");
|
|
238
246
|
}
|
|
247
|
+
// Fail-closed fan-out evidence enforcement (gates.requireFanoutEvidence, ON by
|
|
248
|
+
// default / opt-out). When disabled or config-unavailable, fanoutEnforcement is
|
|
249
|
+
// { required: false, gates: [] } so the `.required` guard skips this block.
|
|
250
|
+
if (fanoutEnforcement && fanoutEnforcement.required) {
|
|
251
|
+
for (const gate of fanoutEnforcement.gates) {
|
|
252
|
+
if (gate.executionMode !== "fanout_fanin") {
|
|
253
|
+
failures.push(
|
|
254
|
+
`${gate.name}: requireFanoutEvidence is enabled but executionMode is "${gate.executionMode ?? "unset"}" (expected "fanout_fanin"); inline gate verdicts are not accepted`,
|
|
255
|
+
);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (!gate.ledgerExists) {
|
|
259
|
+
failures.push(
|
|
260
|
+
`${gate.name}: requireFanoutEvidence is enabled but no findings-log ledger exists for the reviewed head (${gate.ledgerPath})`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
239
265
|
if (typeof unresolvedThreadCount === "number" && unresolvedThreadCount !== 0) {
|
|
240
266
|
if (unresolvedThreadCount === -1) {
|
|
241
267
|
failures.push("could not fetch review thread state from GitHub API; re-run gate evidence check when API connectivity is restored");
|
|
@@ -253,6 +279,52 @@ export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, s
|
|
|
253
279
|
failures,
|
|
254
280
|
};
|
|
255
281
|
}
|
|
282
|
+
async function ledgerExists(fullPath) {
|
|
283
|
+
try {
|
|
284
|
+
await access(fullPath);
|
|
285
|
+
return true;
|
|
286
|
+
} catch {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Build the fan-out evidence enforcement descriptor.
|
|
292
|
+
*
|
|
293
|
+
* Enforcement is ON by default (opt-out via gates.requireFanoutEvidence: false).
|
|
294
|
+
* Returns { required: false } when enforcement is disabled OR when config is
|
|
295
|
+
* unavailable (config == null — null or undefined — after a failed load) — config-unavailable must
|
|
296
|
+
* fail open and never enable enforcement. When enabled, records per-required-gate
|
|
297
|
+
* executionMode and whether the deterministic findings-log ledger exists for the
|
|
298
|
+
* reviewed head SHA so the pre-merge check can fail closed on inline verdicts or
|
|
299
|
+
* missing ledgers.
|
|
300
|
+
*/
|
|
301
|
+
async function buildFanoutEnforcement({ repo, pr, currentHeadSha, draftGateMarker, preApprovalGateMarker, config, cwd }) {
|
|
302
|
+
// Fail open when config could not be loaded/validated. `== null` covers both
|
|
303
|
+
// null and undefined; the loader only ever yields null on failure, but the
|
|
304
|
+
// loose check defensively treats an absent config as unavailable.
|
|
305
|
+
if (config == null || !resolveRequireFanoutEvidence(config)) {
|
|
306
|
+
return { required: false, gates: [] };
|
|
307
|
+
}
|
|
308
|
+
const draftRequired = resolveGateConfig(config, "draft").required;
|
|
309
|
+
const preApprovalRequired = resolveGateConfig(config, "preApproval").required;
|
|
310
|
+
const gateSpecs = [
|
|
311
|
+
{ name: "draft_gate", marker: draftGateMarker, required: draftRequired },
|
|
312
|
+
{ name: "pre_approval_gate", marker: preApprovalGateMarker, required: preApprovalRequired },
|
|
313
|
+
].filter((spec) => spec.required && spec.marker.visible);
|
|
314
|
+
const gates = [];
|
|
315
|
+
for (const spec of gateSpecs) {
|
|
316
|
+
const headSha = spec.marker.headSha ?? currentHeadSha;
|
|
317
|
+
const ledgerPath = buildLogPath({ repo, pr, gate: spec.name, headSha, tmpRoot: "tmp" });
|
|
318
|
+
const fullPath = path.resolve(cwd, ledgerPath);
|
|
319
|
+
gates.push({
|
|
320
|
+
name: spec.name,
|
|
321
|
+
executionMode: spec.marker.executionMode ?? null,
|
|
322
|
+
ledgerPath,
|
|
323
|
+
ledgerExists: await ledgerExists(fullPath),
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return { required: true, gates };
|
|
327
|
+
}
|
|
256
328
|
export async function detectCheckpointEvidence(options, { env = process.env, ghCommand = "gh", cwd = process.cwd() } = {}) {
|
|
257
329
|
const runnerOwnership = await ensureAsyncRunnerOwnership({
|
|
258
330
|
repo: options.repo,
|
|
@@ -299,6 +371,24 @@ export async function detectCheckpointEvidence(options, { env = process.env, ghC
|
|
|
299
371
|
const allComments = [...commentsPayload, ...prReviews];
|
|
300
372
|
const commentSummary = summarizeGateReviewComments(allComments);
|
|
301
373
|
const markerSummary = summarizeGateReviewCommentMarkers(allComments, { headSha: currentHeadSha });
|
|
374
|
+
const draftGateMarker = normalizeGateMarkerSummary(markerSummary.draft_gate);
|
|
375
|
+
const preApprovalGateMarker = normalizeGateMarkerSummary(markerSummary.pre_approval_gate);
|
|
376
|
+
// loadDevLoopConfig never throws: it returns { config, warnings, errors }.
|
|
377
|
+
// A non-empty errors array means the config could not be loaded/validated, so
|
|
378
|
+
// treat it as config-unavailable and leave fan-out enforcement disabled
|
|
379
|
+
// (preserves default behavior). Other gate checks remain unaffected.
|
|
380
|
+
let config = null;
|
|
381
|
+
const { config: loadedConfig, errors: configErrors } = await loadDevLoopConfig({ repoRoot: cwd });
|
|
382
|
+
config = Array.isArray(configErrors) && configErrors.length > 0 ? null : loadedConfig;
|
|
383
|
+
const fanoutEnforcement = await buildFanoutEnforcement({
|
|
384
|
+
repo: options.repo,
|
|
385
|
+
pr: options.pr,
|
|
386
|
+
currentHeadSha,
|
|
387
|
+
draftGateMarker,
|
|
388
|
+
preApprovalGateMarker,
|
|
389
|
+
config,
|
|
390
|
+
cwd,
|
|
391
|
+
});
|
|
302
392
|
return {
|
|
303
393
|
ok: true,
|
|
304
394
|
repo: options.repo,
|
|
@@ -306,9 +396,10 @@ export async function detectCheckpointEvidence(options, { env = process.env, ghC
|
|
|
306
396
|
currentHeadSha,
|
|
307
397
|
draftGate: normalizeGateSummary(commentSummary.draft_gate),
|
|
308
398
|
preApprovalGate: normalizeGateSummary(commentSummary.pre_approval_gate),
|
|
309
|
-
draftGateMarker
|
|
310
|
-
preApprovalGateMarker
|
|
399
|
+
draftGateMarker,
|
|
400
|
+
preApprovalGateMarker,
|
|
311
401
|
draftGateSatisfied: commentSummary.draft_gate?.verdict === "clean" && typeof commentSummary.draft_gate?.headSha === "string",
|
|
402
|
+
fanoutEnforcement,
|
|
312
403
|
...(runnerOwnership.status !== "skipped_no_async_run_id" ? { runnerOwnership } : {}),
|
|
313
404
|
staleRunner: {
|
|
314
405
|
status: staleRunnerDetection.status,
|
|
@@ -351,7 +442,7 @@ async function main() {
|
|
|
351
442
|
? [`exit signal recorded for run ${result.staleRunner.activeRun?.runId}: refuse to merge`]
|
|
352
443
|
: [],
|
|
353
444
|
};
|
|
354
|
-
const preMergeGateCheck = buildPreMergeGateCheck(result, unresolvedThreadCount, staleRunnerCheck);
|
|
445
|
+
const preMergeGateCheck = buildPreMergeGateCheck(result, unresolvedThreadCount, staleRunnerCheck, result.fanoutEnforcement);
|
|
355
446
|
const output = { ...result, preMergeGateCheck, staleRunnerCheck };
|
|
356
447
|
if (!preMergeGateCheck.ok) {
|
|
357
448
|
process.stderr.write(`${JSON.stringify({
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
3
|
+
import { parsePrNumber, runChild } from "../_cli-primitives.mjs";
|
|
4
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
5
|
+
import { loadDevLoopConfig } from "@dev-loops/core/config";
|
|
6
|
+
import { resolveHandoffCandidates } from "./resolve-handoff-candidates.mjs";
|
|
7
|
+
|
|
8
|
+
const USAGE = `Usage: offer-human-handoff.mjs --repo <owner/name> --pr <number> [--assign <login>...] [--request-review <login>...] [--changed-files <csv>] [--pr-author <login>]
|
|
9
|
+
Human-handoff offer at the pre-approval / merge-handoff boundary (#920, Request B
|
|
10
|
+
of #910). OFFER-only: without --assign/--request-review it resolves and prints
|
|
11
|
+
the candidate list (the "offer"); the operator confirms who takes it. With
|
|
12
|
+
--assign / --request-review it performs the confirmed action via \`gh pr edit\`.
|
|
13
|
+
|
|
14
|
+
This pairs with autonomy.humanMergeOnly: when human-merge is enforced, the offer
|
|
15
|
+
names who should take the merge. No-op when approval.humanHandoff is disabled
|
|
16
|
+
(default) — prints an empty offer.
|
|
17
|
+
|
|
18
|
+
Required:
|
|
19
|
+
--repo <owner/name>
|
|
20
|
+
--pr <number>
|
|
21
|
+
Optional:
|
|
22
|
+
--assign <login> Add a confirmed assignee (repeatable). Performs
|
|
23
|
+
\`gh pr edit --add-assignee\`.
|
|
24
|
+
--request-review <login> Request review from a confirmed reviewer (repeatable).
|
|
25
|
+
Performs \`gh pr edit --add-reviewer\`.
|
|
26
|
+
--changed-files <csv> Forwarded to the candidate resolver.
|
|
27
|
+
--pr-author <login> Forwarded to the candidate resolver.
|
|
28
|
+
Output (stdout, JSON):
|
|
29
|
+
Offer mode: { "ok": true, "mode": "offer", "enabled", "candidates": [...], ... }
|
|
30
|
+
Apply mode: { "ok": true, "mode": "apply", "assigned": [...], "requestedReview": [...] }
|
|
31
|
+
Exit codes:
|
|
32
|
+
0 Success (including disabled no-op offer)
|
|
33
|
+
1 Argument error or gh failure`.trim();
|
|
34
|
+
|
|
35
|
+
const parseError = buildParseError(USAGE);
|
|
36
|
+
|
|
37
|
+
function nextValue(args, i, flag) {
|
|
38
|
+
const value = args[i];
|
|
39
|
+
if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
|
|
40
|
+
throw parseError(`Missing value for ${flag}`);
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A login flag value with the leading `@` stripped and trimmed. Reject an empty
|
|
46
|
+
// result (e.g. `--assign @`) so a blank login can never reach `gh pr edit
|
|
47
|
+
// --add-assignee ""`.
|
|
48
|
+
function loginValue(args, i, flag) {
|
|
49
|
+
const login = nextValue(args, i, flag).trim().replace(/^@/, "").trim();
|
|
50
|
+
if (login === "") throw parseError(`${flag} requires a non-empty login`);
|
|
51
|
+
return login;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parseOfferCliArgs(argv) {
|
|
55
|
+
const args = [...argv];
|
|
56
|
+
if (args.includes("--help") || args.includes("-h")) return { help: true };
|
|
57
|
+
const options = {
|
|
58
|
+
help: false, repo: undefined, pr: undefined,
|
|
59
|
+
assign: [], requestReview: [], changedFiles: undefined, prAuthor: undefined,
|
|
60
|
+
};
|
|
61
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
62
|
+
const token = args[i];
|
|
63
|
+
if (token === "--repo") { options.repo = nextValue(args, ++i, "--repo"); continue; }
|
|
64
|
+
if (token === "--pr") { options.pr = parsePrNumber(nextValue(args, ++i, "--pr"), parseError); continue; }
|
|
65
|
+
if (token === "--assign") { options.assign.push(loginValue(args, ++i, "--assign")); continue; }
|
|
66
|
+
if (token === "--request-review") { options.requestReview.push(loginValue(args, ++i, "--request-review")); continue; }
|
|
67
|
+
if (token === "--changed-files") {
|
|
68
|
+
options.changedFiles = nextValue(args, ++i, "--changed-files")
|
|
69
|
+
.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (token === "--pr-author") { options.prAuthor = nextValue(args, ++i, "--pr-author").trim().replace(/^@/, ""); continue; }
|
|
73
|
+
throw parseError(`Unknown argument: ${token}`);
|
|
74
|
+
}
|
|
75
|
+
if (options.repo === undefined || options.pr === undefined) {
|
|
76
|
+
throw parseError("Offering human handoff requires both --repo <owner/name> and --pr <number>");
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
parseRepoSlug(options.repo);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
throw parseError(error instanceof Error ? error.message : String(error));
|
|
82
|
+
}
|
|
83
|
+
return options;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Perform the confirmed assign / request-review actions via `gh pr edit`.
|
|
88
|
+
* OFFER-only contract: this is reached only when the operator passed
|
|
89
|
+
* --assign / --request-review (the confirmation). It never auto-assigns.
|
|
90
|
+
* @returns {Promise<{ ok: boolean, mode: "apply", assigned: string[], requestedReview: string[] }>}
|
|
91
|
+
*/
|
|
92
|
+
export async function applyHandoff({ repo, pr, assign, requestReview }, { run = (cmd, args) => runChild(cmd, args), ghCommand = "gh" } = {}) {
|
|
93
|
+
const ghArgs = ["pr", "edit", String(pr), "--repo", repo];
|
|
94
|
+
for (const login of assign) ghArgs.push("--add-assignee", login);
|
|
95
|
+
for (const login of requestReview) ghArgs.push("--add-reviewer", login);
|
|
96
|
+
const result = await run(ghCommand, ghArgs);
|
|
97
|
+
if (result.code !== 0) {
|
|
98
|
+
throw new Error(result.stderr.trim() || `gh pr edit exited ${result.code}`);
|
|
99
|
+
}
|
|
100
|
+
return { ok: true, mode: "apply", assigned: assign, requestedReview: requestReview };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
104
|
+
let options;
|
|
105
|
+
try {
|
|
106
|
+
options = parseOfferCliArgs(argv);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
109
|
+
return 1;
|
|
110
|
+
}
|
|
111
|
+
if (options.help) {
|
|
112
|
+
process.stdout.write(`${USAGE}\n`);
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const run = deps.run ?? ((cmd, args) => runChild(cmd, args));
|
|
117
|
+
const ghCommand = deps.ghCommand ?? "gh";
|
|
118
|
+
|
|
119
|
+
// Apply mode: operator confirmed an assignee/reviewer.
|
|
120
|
+
if (options.assign.length > 0 || options.requestReview.length > 0) {
|
|
121
|
+
try {
|
|
122
|
+
const applied = await applyHandoff(
|
|
123
|
+
{ repo: options.repo, pr: options.pr, assign: options.assign, requestReview: options.requestReview },
|
|
124
|
+
{ run, ghCommand },
|
|
125
|
+
);
|
|
126
|
+
process.stdout.write(`${JSON.stringify(applied)}\n`);
|
|
127
|
+
return 0;
|
|
128
|
+
} catch (error) {
|
|
129
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
130
|
+
return 1;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Offer mode: resolve + print candidates, assign nobody.
|
|
135
|
+
const repoRoot = deps.repoRoot ?? process.cwd();
|
|
136
|
+
const config = deps.config !== undefined ? deps.config : (await loadDevLoopConfig({ repoRoot })).config;
|
|
137
|
+
const offer = await resolveHandoffCandidates(
|
|
138
|
+
{ repo: options.repo, pr: options.pr, changedFiles: options.changedFiles, prAuthor: options.prAuthor ?? null },
|
|
139
|
+
{ ...deps, config, repoRoot, run, ghCommand },
|
|
140
|
+
);
|
|
141
|
+
process.stdout.write(`${JSON.stringify({ ...offer, mode: "offer" })}\n`);
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
146
|
+
main().then((code) => { process.exitCode = code; });
|
|
147
|
+
}
|