@dev-loops/core 1.0.0-rc.2 → 1.0.0-rc.3
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/package.json +4 -1
- package/src/claude/hook-decisions.mjs +14 -0
- package/src/config/config.mjs +615 -206
- package/src/config/extension-defaults.yaml +181 -423
- package/src/github/issue-ops.mjs +484 -0
- package/src/github/ownership-helpers.mjs +79 -0
- package/src/loop/bash-command-classify.mjs +35 -5
- package/src/loop/conductor-routing.mjs +1 -1
- package/src/loop/copilot-ci-status.mjs +59 -0
- package/src/loop/copilot-loop-state.mjs +9 -5
- package/src/loop/gate-carry-forward.mjs +1 -1
- package/src/loop/gate-fanin.mjs +2 -2
- package/src/loop/handoff-envelope.mjs +13 -14
- package/src/loop/pr-gate-coordination.mjs +10 -34
- package/src/loop/queue-board-sync.mjs +26 -9
- package/src/projects/resolve-project.mjs +14 -7
- package/src/tracker/adapter.mjs +127 -0
- package/src/tracker/github-adapter.mjs +150 -0
- package/src/tracker/index.mjs +50 -0
- package/src/tracker/noop-adapter.mjs +35 -0
|
@@ -5,6 +5,42 @@ const STATUS_CONTEXT_FAILURE_STATES = new Set(["FAILURE", "ERROR"]);
|
|
|
5
5
|
const STATUS_CONTEXT_PENDING_STATES = new Set(["PENDING", "EXPECTED"]);
|
|
6
6
|
const STATUS_CONTEXT_SUCCESS_STATES = new Set(["SUCCESS"]);
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Name of the server-side "Gate evidence" check dev-loops posts on its own
|
|
10
|
+
* pull requests (`.github/workflows/gate-evidence.yml`). Its conclusion is
|
|
11
|
+
* DERIVED from the loop's own progress (a clean current-head
|
|
12
|
+
* pre_approval_gate verdict), not an independent build/test signal — so the
|
|
13
|
+
* loop must exclude it, by this exact name only, when deriving the CI status
|
|
14
|
+
* that gates its own pre_approval step. Otherwise the loop could never post
|
|
15
|
+
* the very verdict that would turn this check green (#1358).
|
|
16
|
+
*/
|
|
17
|
+
export const LOOP_DERIVED_CI_CHECK_NAME = "gate-evidence";
|
|
18
|
+
|
|
19
|
+
function checkEntryName(entry) {
|
|
20
|
+
if (typeof entry?.name === "string" && entry.name.length > 0) return entry.name;
|
|
21
|
+
if (typeof entry?.context === "string" && entry.context.length > 0) return entry.context;
|
|
22
|
+
return "";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Split rollup/check-run entries into those matching `targetName` and the rest.
|
|
27
|
+
* Shared by both statusCheckRollup-shaped and check-runs-shaped payloads —
|
|
28
|
+
* both use `.name` (check-runs also use `.context` for legacy StatusContext).
|
|
29
|
+
*
|
|
30
|
+
* @param {Array<object>} entries
|
|
31
|
+
* @param {string} targetName
|
|
32
|
+
* @returns {{ matched: Array<object>, rest: Array<object> }}
|
|
33
|
+
*/
|
|
34
|
+
export function partitionEntriesByCheckName(entries, targetName) {
|
|
35
|
+
const list = Array.isArray(entries) ? entries : [];
|
|
36
|
+
const matched = [];
|
|
37
|
+
const rest = [];
|
|
38
|
+
for (const entry of list) {
|
|
39
|
+
(checkEntryName(entry) === targetName ? matched : rest).push(entry);
|
|
40
|
+
}
|
|
41
|
+
return { matched, rest };
|
|
42
|
+
}
|
|
43
|
+
|
|
8
44
|
function normalizeHeadScopedCiStatus(status) {
|
|
9
45
|
return VALID_HEAD_SCOPED_CI_STATUSES.has(status) ? status : "none";
|
|
10
46
|
}
|
|
@@ -253,3 +289,26 @@ export function normalizeHeadScopedCiContract({
|
|
|
253
289
|
|
|
254
290
|
return buildCiContract(overallStatus);
|
|
255
291
|
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Derive a loop-safe CI status from a PR `statusCheckRollup` snapshot: the
|
|
295
|
+
* `LOOP_DERIVED_CI_CHECK_NAME` entry (`gate-evidence`) is excluded from the
|
|
296
|
+
* status computation before it can block, and surfaced separately so a
|
|
297
|
+
* genuinely failing check right beside it can never be masked. Every reason
|
|
298
|
+
* gate-evidence can be red (missing draft_gate/pre_approval evidence,
|
|
299
|
+
* unresolved threads, a stale runner) is independently tracked elsewhere in
|
|
300
|
+
* the loop snapshot, so excluding it here loses no real signal: `status`
|
|
301
|
+
* stays a plain "success" (not an "unconfirmed" crediblyGreen) when it is the
|
|
302
|
+
* only excluded failure and everything else is green.
|
|
303
|
+
*
|
|
304
|
+
* @param {Array<object>} rollup
|
|
305
|
+
* @returns {{ status: "success"|"failure"|"pending"|"none", excludedFailureDetails: Array<string> }}
|
|
306
|
+
*/
|
|
307
|
+
export function deriveLoopCiStatusFromRollup(rollup) {
|
|
308
|
+
const { matched, rest } = partitionEntriesByCheckName(rollup, LOOP_DERIVED_CI_CHECK_NAME);
|
|
309
|
+
const status = normalizeStatusCheckRollupStatus(rest);
|
|
310
|
+
const excludedFailureDetails = matched.length > 0 && normalizeStatusCheckRollupStatus(matched) === "failure"
|
|
311
|
+
? [LOOP_DERIVED_CI_CHECK_NAME]
|
|
312
|
+
: [];
|
|
313
|
+
return { status, excludedFailureDetails };
|
|
314
|
+
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* becomes an explicit bounded input (agentFixStatus) rather than hidden orchestration behavior.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { deriveLoopCiStatusFromRollup } from "./copilot-ci-status.mjs";
|
|
16
16
|
|
|
17
17
|
/** Stable state name constants for the async Copilot review/fix loop. */
|
|
18
18
|
export const STATE = Object.freeze({
|
|
@@ -191,7 +191,7 @@ export function isCopilotRoundCapReached({ copilotReviewRoundCount, maxCopilotRo
|
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
export function normalizeCiStatus(rollup) {
|
|
194
|
-
return
|
|
194
|
+
return deriveLoopCiStatusFromRollup(rollup).status;
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
export function buildSnapshotFromPrFacts({
|
|
@@ -206,11 +206,15 @@ export function buildSnapshotFromPrFacts({
|
|
|
206
206
|
ciStatus,
|
|
207
207
|
lastCopilotRoundMaxSignal = null,
|
|
208
208
|
failureDetails = [],
|
|
209
|
-
excludedFailureDetails
|
|
209
|
+
excludedFailureDetails,
|
|
210
210
|
}) {
|
|
211
211
|
const prState = typeof prData?.state === "string" ? prData.state.toUpperCase() : "OPEN";
|
|
212
212
|
const prMerged = prState === "MERGED";
|
|
213
213
|
const prClosed = prState === "CLOSED";
|
|
214
|
+
// Default derivation excludes the loop's own gate-evidence check (#1358) so a
|
|
215
|
+
// caller that never threads an explicit ciStatus (e.g. gate-coordination
|
|
216
|
+
// detection) still never treats it as a blocking CI failure.
|
|
217
|
+
const rollupDerivation = deriveLoopCiStatusFromRollup(prData?.statusCheckRollup);
|
|
214
218
|
|
|
215
219
|
return normalizeSnapshot({
|
|
216
220
|
prExists: true,
|
|
@@ -225,9 +229,9 @@ export function buildSnapshotFromPrFacts({
|
|
|
225
229
|
actionableThreadCount,
|
|
226
230
|
copilotReviewRoundCount,
|
|
227
231
|
lastCopilotRoundMaxSignal,
|
|
228
|
-
ciStatus: ciStatus ??
|
|
232
|
+
ciStatus: ciStatus ?? rollupDerivation.status,
|
|
229
233
|
failureDetails,
|
|
230
|
-
excludedFailureDetails,
|
|
234
|
+
excludedFailureDetails: excludedFailureDetails ?? rollupDerivation.excludedFailureDetails,
|
|
231
235
|
});
|
|
232
236
|
}
|
|
233
237
|
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* caller records the carried verdict with provenance pointing at the PRIOR head's
|
|
18
18
|
* reviewer (that reviewer genuinely reviewed this angle's surface, which the delta
|
|
19
19
|
* did not touch), clearly marked as carried — see
|
|
20
|
-
* docs/gate-review-sub-loop-contract.md and write-gate-findings-log.mjs's
|
|
20
|
+
* skills/docs/gate-review-sub-loop-contract.md and write-gate-findings-log.mjs's
|
|
21
21
|
* `carriedFromHead` provenance field.
|
|
22
22
|
*
|
|
23
23
|
* The angle -> review-surface mapping is DERIVED from the single source of truth
|
package/src/loop/gate-fanin.mjs
CHANGED
|
@@ -30,7 +30,7 @@ const VALID_VERDICTS = new Set(["clean", "findings_present"]);
|
|
|
30
30
|
* depth). The flow MUST fail closed with this message and route the gate review
|
|
31
31
|
* to the conductor rather than silently degrading to a single-agent inline
|
|
32
32
|
* review (which requireFanoutProvenance is designed to reject). Documented as a
|
|
33
|
-
* contract in docs/gate-review-sub-loop-contract.md.
|
|
33
|
+
* contract in skills/docs/gate-review-sub-loop-contract.md.
|
|
34
34
|
*/
|
|
35
35
|
export const FANOUT_UNAVAILABLE_MESSAGE = "fan-out unavailable — route to conductor";
|
|
36
36
|
|
|
@@ -79,7 +79,7 @@ export function countDistinctReviewers(perAngle) {
|
|
|
79
79
|
* is well-formed and consistent. Shared by the write path (write-gate-findings-log)
|
|
80
80
|
* and the enforcement read path (buildPreMergeGateCheck) so both agree.
|
|
81
81
|
*
|
|
82
|
-
* Consistency rule (documented in docs/gate-review-sub-loop-contract.md):
|
|
82
|
+
* Consistency rule (documented in skills/docs/gate-review-sub-loop-contract.md):
|
|
83
83
|
* - `distinctReviewers` must be a non-negative integer.
|
|
84
84
|
* - `perAngle` must be an array, and non-empty when `distinctReviewers > 0`.
|
|
85
85
|
* - `distinctReviewers` must be <= the count of DISTINCT reviewer identities
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
import { normalizeRepoSlug } from "../github/repo-slug.mjs";
|
|
22
22
|
import { COPILOT_REVIEW_WAIT_TIMEOUT_MS } from "./policy-constants.mjs";
|
|
23
23
|
import { resolveEffectiveAsyncStartMode } from "./async-start-contract.mjs";
|
|
24
|
-
import { resolveHumanMergeOnly } from "../config/config.mjs";
|
|
24
|
+
import { resolveGateConfig, resolveHumanMergeOnly } from "../config/config.mjs";
|
|
25
25
|
|
|
26
26
|
// ---------------------------------------------------------------------------
|
|
27
27
|
// Constants
|
|
@@ -354,20 +354,19 @@ function applySpecSourceVariant(criteria, specSource) {
|
|
|
354
354
|
|
|
355
355
|
function deriveGateConfig(settings, subGate) {
|
|
356
356
|
const gateKey = subGate === "pre-approval" ? "preApproval" : subGate;
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
357
|
+
if (!settings?.gates?.[gateKey]) return undefined;
|
|
358
|
+
|
|
359
|
+
// Route through the canonical resolver rather than re-parsing
|
|
360
|
+
// gates.<gate>.angles by hand: resolveGateConfig already folds the unified
|
|
361
|
+
// angle-entry shape (mandatory/enabled per-entry, D3) into this same
|
|
362
|
+
// exclude-filtered angles + separate excludeAngles list the envelope
|
|
363
|
+
// contract has always shipped.
|
|
364
|
+
const resolved = resolveGateConfig(settings, gateKey);
|
|
364
365
|
return {
|
|
365
|
-
angles:
|
|
366
|
-
excludeAngles: excludeAngles.length > 0 ? excludeAngles : undefined,
|
|
367
|
-
blockCleanOnFindingSeverities:
|
|
368
|
-
|
|
369
|
-
: ["must-fix"],
|
|
370
|
-
requireCi: gateSettings.requireCi ?? true,
|
|
366
|
+
angles: resolved.angles ?? [],
|
|
367
|
+
excludeAngles: resolved.excludeAngles.length > 0 ? resolved.excludeAngles : undefined,
|
|
368
|
+
blockCleanOnFindingSeverities: resolved.blockCleanOnFindingSeverities,
|
|
369
|
+
requireCi: resolved.requireCi,
|
|
371
370
|
};
|
|
372
371
|
}
|
|
373
372
|
|
|
@@ -1380,41 +1380,17 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1380
1380
|
// exhausted and the current head is clean (zero unresolved threads + green CI)
|
|
1381
1381
|
// — including a POST-CAP head Copilot has not (and will not) re-review, since
|
|
1382
1382
|
// no further Copilot round is permitted. Re-requesting review is illegal here,
|
|
1383
|
-
// so this MUST NOT dead-end at READY_TO_REREQUEST_REVIEW
|
|
1384
|
-
//
|
|
1385
|
-
//
|
|
1386
|
-
//
|
|
1387
|
-
//
|
|
1383
|
+
// so this MUST NOT dead-end at READY_TO_REREQUEST_REVIEW — nor at a forced
|
|
1384
|
+
// rerequest for a post-convergence significant change (#1387): the cap makes
|
|
1385
|
+
// that rerequest impossible (request-copilot-review suppresses it), so a
|
|
1386
|
+
// significant change discovered here is reviewed by the pre_approval_gate
|
|
1387
|
+
// fan-out itself, on the post-cap head, same as any other clean fallback. It
|
|
1388
|
+
// routes to the pre_approval_gate, which reviews the post-cap head itself
|
|
1389
|
+
// (per #848). The CI guards below still hold (failing / credibly-green CI
|
|
1390
|
+
// blocks), and conflicts / blocked states are handled earlier, so
|
|
1391
|
+
// genuinely-blocked states still forbid pre_approval. Mirrors
|
|
1392
|
+
// LOW_SIGNAL_CONVERGED routing with round-cap reasoning.
|
|
1388
1393
|
if (effectiveLifecycleState === STATE.ROUND_CAP_CLEAN_FALLBACK) {
|
|
1389
|
-
if (roundCapNewCycleRequired) {
|
|
1390
|
-
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REREQUEST_COPILOT_REVIEW]);
|
|
1391
|
-
pushUnique(forbiddenActions, [
|
|
1392
|
-
PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
|
|
1393
|
-
PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
|
|
1394
|
-
PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
|
|
1395
|
-
PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
|
|
1396
|
-
PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
|
|
1397
|
-
]);
|
|
1398
|
-
return buildResult({
|
|
1399
|
-
repo: input.repo ?? null,
|
|
1400
|
-
pr: Number.isInteger(input.pr) ? input.pr : null,
|
|
1401
|
-
currentHeadSha,
|
|
1402
|
-
lifecycleState: STATE.READY_TO_REREQUEST_REVIEW,
|
|
1403
|
-
loopDisposition: DISPOSITION.ACTION_REQUIRED,
|
|
1404
|
-
gateBoundary: PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
|
|
1405
|
-
draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
|
|
1406
|
-
draftGate,
|
|
1407
|
-
preApprovalGate,
|
|
1408
|
-
allowedNextActions,
|
|
1409
|
-
forbiddenActions,
|
|
1410
|
-
nextAction: PR_CHECKPOINT_ACTION.REREQUEST_COPILOT_REVIEW,
|
|
1411
|
-
reason: `The previous Copilot cycle converged at the round cap (${copilotReviewRoundCount}/${maxCopilotRounds}), but significant post-convergence changes landed on the current head. Open a new cycle and re-request Copilot review before entering \`pre_approval_gate\`.`,
|
|
1412
|
-
mergeStateStatus,
|
|
1413
|
-
conflictFiles,
|
|
1414
|
-
refinementArtifact,
|
|
1415
|
-
copilotReviewRoundCount,
|
|
1416
|
-
});
|
|
1417
|
-
}
|
|
1418
1394
|
if (preApprovalRequireCi && (ciStatus === "failure" || ciStatus === "crediblyGreen")) {
|
|
1419
1395
|
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
|
|
1420
1396
|
pushUnique(forbiddenActions, postDraftForbidden);
|
|
@@ -172,7 +172,10 @@ function readDevloopsSettings(repoRoot) {
|
|
|
172
172
|
try {
|
|
173
173
|
const raw = readFileSync(base + ext, "utf8");
|
|
174
174
|
const settings = ext === ".json" ? JSON.parse(raw) : parseYaml(raw);
|
|
175
|
-
|
|
175
|
+
// `tracker` (issue #1408, the tracker-agnostic seam) is surfaced
|
|
176
|
+
// alongside `queue` so loadBoardConfig can prefer tracker.board over
|
|
177
|
+
// the deprecated queue.board without a second file read.
|
|
178
|
+
return { settings: settings?.queue ?? null, tracker: settings?.tracker ?? null };
|
|
176
179
|
} catch (err) {
|
|
177
180
|
if (err?.code === "ENOENT") {
|
|
178
181
|
// try next extension
|
|
@@ -184,21 +187,35 @@ function readDevloopsSettings(repoRoot) {
|
|
|
184
187
|
if (foundError) {
|
|
185
188
|
return { error: foundError.message };
|
|
186
189
|
}
|
|
187
|
-
return { settings: null };
|
|
190
|
+
return { settings: null, tracker: null };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Read a board selector ({number} or {title}) into the loadBoardConfig
|
|
194
|
+
* result shape, or null when neither is set. */
|
|
195
|
+
function boardSelector(board) {
|
|
196
|
+
if (!board || typeof board !== "object") return null;
|
|
197
|
+
if (typeof board.number === "number" && board.number > 0) {
|
|
198
|
+
return { enabled: true, projectNumber: board.number };
|
|
199
|
+
}
|
|
200
|
+
if (typeof board.title === "string" && board.title.trim().length > 0) {
|
|
201
|
+
return { enabled: true, boardTitle: board.title.trim() };
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
188
204
|
}
|
|
189
205
|
|
|
190
206
|
export function loadBoardConfig(repoRoot) {
|
|
191
|
-
const { settings: queue, error } = readDevloopsSettings(repoRoot);
|
|
207
|
+
const { settings: queue, tracker, error } = readDevloopsSettings(repoRoot);
|
|
192
208
|
if (error) {
|
|
193
209
|
return { enabled: false, reason: `config read/parse error: ${error}` };
|
|
194
210
|
}
|
|
211
|
+
// tracker.board (canonical) takes priority over the deprecated queue.board
|
|
212
|
+
// (issue #1408) — see resolveTrackerBoard in ../config/config.mjs for the
|
|
213
|
+
// equivalent resolution against the validated, loaded config.
|
|
214
|
+
const trackerBoard = boardSelector(tracker?.board);
|
|
215
|
+
if (trackerBoard) return trackerBoard;
|
|
195
216
|
if (!queue) return { enabled: false };
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
if (typeof queue.boardTitle === "string" && queue.boardTitle.trim().length > 0) {
|
|
200
|
-
return { enabled: true, boardTitle: queue.boardTitle.trim() };
|
|
201
|
-
}
|
|
217
|
+
const queueBoard = boardSelector(queue.board);
|
|
218
|
+
if (queueBoard) return queueBoard;
|
|
202
219
|
return { enabled: false };
|
|
203
220
|
}
|
|
204
221
|
|
|
@@ -5,6 +5,10 @@ import { parse as parseYaml } from "yaml";
|
|
|
5
5
|
// Read .devloops (and extension variants) queue settings, mirroring the
|
|
6
6
|
// resolution used by ensure-queue-board.mjs. Returns { project }, { title },
|
|
7
7
|
// and/or { olderThanDays } when configured; never throws on a missing/bad file.
|
|
8
|
+
//
|
|
9
|
+
// `tracker.board` (issue #1408, the tracker-agnostic seam) takes priority over
|
|
10
|
+
// the deprecated `queue.board` — same precedence as loadBoardConfig in
|
|
11
|
+
// ../loop/queue-board-sync.mjs and resolveTrackerBoard in ../config/config.mjs.
|
|
8
12
|
function resolveSettings(cwd) {
|
|
9
13
|
const basePath = path.join(cwd, ".devloops");
|
|
10
14
|
const extensions = ["", ".yaml", ".yml", ".json"];
|
|
@@ -13,13 +17,16 @@ function resolveSettings(cwd) {
|
|
|
13
17
|
const raw = readFileSync(basePath + ext, "utf-8");
|
|
14
18
|
const settings = ext === ".json" ? JSON.parse(raw) : parseYaml(raw);
|
|
15
19
|
const queue = settings?.queue;
|
|
16
|
-
if (!queue) return null;
|
|
17
20
|
const out = {};
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
const board = settings?.tracker?.board ?? queue?.board;
|
|
22
|
+
if (board && typeof board === "object") {
|
|
23
|
+
if (typeof board.number === "number" && Number.isInteger(board.number) && board.number > 0) {
|
|
24
|
+
out.project = board.number;
|
|
25
|
+
} else if (typeof board.title === "string" && board.title.trim().length > 0) {
|
|
26
|
+
out.title = board.title.trim();
|
|
27
|
+
}
|
|
22
28
|
}
|
|
29
|
+
if (!queue) return Object.keys(out).length > 0 ? out : null;
|
|
23
30
|
if (typeof queue.archiveOlderThanDays === "number" && Number.isInteger(queue.archiveOlderThanDays) && queue.archiveOlderThanDays > 0) {
|
|
24
31
|
out.olderThanDays = queue.archiveOlderThanDays;
|
|
25
32
|
}
|
|
@@ -132,7 +139,7 @@ function resolveProjectSelector(args) {
|
|
|
132
139
|
: null;
|
|
133
140
|
if (!projectRef && !projectTitle) {
|
|
134
141
|
throw Object.assign(
|
|
135
|
-
new Error("--project is required (or set queue.
|
|
142
|
+
new Error("--project is required (or set queue.board.number / queue.board.title in .devloops)"),
|
|
136
143
|
{ code: "INVALID_PROJECT" },
|
|
137
144
|
);
|
|
138
145
|
}
|
|
@@ -171,7 +178,7 @@ function findProject(projects, { projectRef, projectTitle }, owner) {
|
|
|
171
178
|
}
|
|
172
179
|
|
|
173
180
|
// Apply .devloops board settings when --project was not passed. Precedence:
|
|
174
|
-
// explicit --project flag > queue.
|
|
181
|
+
// explicit --project flag > queue.board.number/queue.board.title. Mutates args.
|
|
175
182
|
function applyDevloopsBoard(args, cwd) {
|
|
176
183
|
if (args.project === undefined) {
|
|
177
184
|
const settings = resolveSettings(cwd);
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracker adapter interface (issue #1408, the tracker-agnostic seam).
|
|
3
|
+
*
|
|
4
|
+
* Abstracts the work-item tracker (issues + optional board/queue) so the loop
|
|
5
|
+
* reads/writes issues and drives the queue/board through one generic seam.
|
|
6
|
+
* Mirrors the harness-adapter idiom exactly (`../harness/adapter.mjs`):
|
|
7
|
+
* `createTrackerAdapter(impl)` validates the Issues REQUIRED_METHODS and
|
|
8
|
+
* freezes the result; `resolveTrackerAdapter(config)` (see `./index.mjs`)
|
|
9
|
+
* picks a provider by config, with GitHub as the built-in default.
|
|
10
|
+
*
|
|
11
|
+
* Two capability groups (per the #1408 RFC):
|
|
12
|
+
* - Issues (REQUIRED): every provider must implement these — the spec of
|
|
13
|
+
* record a tracker-backed loop reads/writes.
|
|
14
|
+
* - Board (OPTIONAL): present only when the provider has a board/queue.
|
|
15
|
+
* Kept as a distinct, checkable capability (not folded into
|
|
16
|
+
* REQUIRED_METHODS) so a provider with no board — or a future
|
|
17
|
+
* composite/split adapter delegating board vs issues to different
|
|
18
|
+
* providers (see the #1408 hybrid-tracker design note) — is still a
|
|
19
|
+
* valid Tracker.
|
|
20
|
+
*
|
|
21
|
+
* @typedef {Object} TrackerIssue
|
|
22
|
+
* @property {string|number} id
|
|
23
|
+
* @property {string} title
|
|
24
|
+
* @property {string} body
|
|
25
|
+
* @property {string} url
|
|
26
|
+
* @property {string} state
|
|
27
|
+
* @property {string[]} assignees
|
|
28
|
+
*
|
|
29
|
+
* @typedef {Object} TrackerRef
|
|
30
|
+
* @property {string} repo
|
|
31
|
+
* @property {string|number} id
|
|
32
|
+
*
|
|
33
|
+
* @typedef {Object} TrackerAdapter
|
|
34
|
+
* @property {(urlOrRef: string) => TrackerRef} parseRef
|
|
35
|
+
* @property {(ref: TrackerRef) => Promise<TrackerIssue>} getIssue
|
|
36
|
+
* @property {(input: {repo: string, title: string, body: string}) => Promise<{id: string|number, url: string}>} createIssue
|
|
37
|
+
* @property {(ref: TrackerRef, edits: {title?: string, body?: string, assignees?: string[], milestone?: string}) => Promise<{edited: string[]}>} editIssue
|
|
38
|
+
* @property {(ref: TrackerRef, body: string) => Promise<{commentUrl: string}>} commentIssue
|
|
39
|
+
* @property {(filter: {repo: string, state?: string, labels?: string[], limit?: number}) => Promise<TrackerIssue[]>} listIssues
|
|
40
|
+
* Every returned object is Issue-shaped (same field names as getIssue), but
|
|
41
|
+
* a provider's underlying list call may not fetch per-item detail fields —
|
|
42
|
+
* the built-in github provider's `gh issue list` returns only
|
|
43
|
+
* id/title/state, so `body`/`url`/`assignees` are populated empty ("", [])
|
|
44
|
+
* rather than omitted, not truly fetched. Call getIssue for those fields.
|
|
45
|
+
* @property {(ref: TrackerRef) => Promise<{hasOpenLinkedPr: boolean, prNumber: number|null}|null>} detectLinkedPr
|
|
46
|
+
* @property {(cfg: object) => Promise<object>} [ensureBoard]
|
|
47
|
+
* @property {(board: object) => Promise<object[]>} [listQueueItems]
|
|
48
|
+
* @property {(board: object, issueId: string|number) => Promise<object>} [addQueueItem]
|
|
49
|
+
* @property {(board: object, item: object, logicalColumn: string) => Promise<void>} [setItemStatus]
|
|
50
|
+
* @property {(board: object, item: object, position: object) => Promise<void>} [reorderItem]
|
|
51
|
+
* @property {(board: object, filter: object) => Promise<void>} [archiveItems]
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/** Issues capability — REQUIRED on every tracker provider. */
|
|
55
|
+
export const REQUIRED_METHODS = Object.freeze([
|
|
56
|
+
"parseRef",
|
|
57
|
+
"getIssue",
|
|
58
|
+
"createIssue",
|
|
59
|
+
"editIssue",
|
|
60
|
+
"commentIssue",
|
|
61
|
+
"listIssues",
|
|
62
|
+
"detectLinkedPr",
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
/** Board capability — OPTIONAL; present only when the provider has a board. */
|
|
66
|
+
export const BOARD_METHODS = Object.freeze([
|
|
67
|
+
"ensureBoard",
|
|
68
|
+
"listQueueItems",
|
|
69
|
+
"addQueueItem",
|
|
70
|
+
"setItemStatus",
|
|
71
|
+
"reorderItem",
|
|
72
|
+
"archiveItems",
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Validate and freeze a tracker-adapter implementation. Requires the full
|
|
77
|
+
* Issues capability; Board methods are copied through (frozen) when present
|
|
78
|
+
* but are not required — a provider with no board is still a valid adapter.
|
|
79
|
+
*
|
|
80
|
+
* @param {Partial<TrackerAdapter>} impl
|
|
81
|
+
* @returns {TrackerAdapter}
|
|
82
|
+
*/
|
|
83
|
+
export function createTrackerAdapter(impl) {
|
|
84
|
+
if (!impl || typeof impl !== "object") {
|
|
85
|
+
throw new TypeError("createTrackerAdapter: impl must be an object");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const method of REQUIRED_METHODS) {
|
|
89
|
+
if (typeof impl[method] !== "function") {
|
|
90
|
+
throw new TypeError(`createTrackerAdapter: missing required method "${method}"`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const adapter = {};
|
|
95
|
+
for (const method of REQUIRED_METHODS) {
|
|
96
|
+
adapter[method] = impl[method];
|
|
97
|
+
}
|
|
98
|
+
for (const method of BOARD_METHODS) {
|
|
99
|
+
if (typeof impl[method] === "function") {
|
|
100
|
+
adapter[method] = impl[method];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return Object.freeze(adapter);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Type guard for a value implementing at least the required Issues capability.
|
|
109
|
+
* @param {*} value
|
|
110
|
+
* @returns {value is TrackerAdapter}
|
|
111
|
+
*/
|
|
112
|
+
export function isTrackerAdapter(value) {
|
|
113
|
+
if (!value || typeof value !== "object") {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
return REQUIRED_METHODS.every((method) => typeof value[method] === "function");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Whether an adapter also implements the optional Board capability in full.
|
|
121
|
+
* @param {*} value
|
|
122
|
+
* @returns {boolean}
|
|
123
|
+
*/
|
|
124
|
+
export function hasBoardCapability(value) {
|
|
125
|
+
if (!isTrackerAdapter(value)) return false;
|
|
126
|
+
return BOARD_METHODS.every((method) => typeof value[method] === "function");
|
|
127
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { createTrackerAdapter } from "./adapter.mjs";
|
|
2
|
+
import {
|
|
3
|
+
viewIssue,
|
|
4
|
+
createIssue as coreCreateIssue,
|
|
5
|
+
editIssue as coreEditIssue,
|
|
6
|
+
commentIssue as coreCommentIssue,
|
|
7
|
+
listIssues as coreListIssues,
|
|
8
|
+
detectLinkedIssuePr,
|
|
9
|
+
} from "../github/issue-ops.mjs";
|
|
10
|
+
import { main as moveQueueItemMain } from "../projects/move-queue-item.mjs";
|
|
11
|
+
import { main as listQueueItemsMain } from "../projects/list-queue-items.mjs";
|
|
12
|
+
import { DEFAULT_STATE_COLUMN_NAMES } from "../loop/queue-board-sync.mjs";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The v1 built-in GitHub tracker provider (issue #1408). A facade over the
|
|
16
|
+
* existing `gh issue` calls (now extracted to `../github/issue-ops.mjs`) and
|
|
17
|
+
* the GitHub Projects board tooling already in the repo — wiring, not a
|
|
18
|
+
* rewrite. Registered as the default provider by `./index.mjs`.
|
|
19
|
+
*
|
|
20
|
+
* Board capability is intentionally PARTIAL in this pass: only
|
|
21
|
+
* `listQueueItems`/`setItemStatus` are wired (the two board primitives
|
|
22
|
+
* already extracted to `../projects/*.mjs`). `ensureBoard`/`addQueueItem`/
|
|
23
|
+
* `reorderItem`/`archiveItems` still live only as `scripts/projects/*.mjs`
|
|
24
|
+
* CLI tools and are intentionally NOT duplicated into this adapter — no hot
|
|
25
|
+
* caller in this pass needs them through the seam, and `packages/core` must
|
|
26
|
+
* not import from repo-root `scripts/` (that would break `@dev-loops/core`
|
|
27
|
+
* when installed standalone). Extract them here too when a real caller needs
|
|
28
|
+
* board-writer access through the adapter (YAGNI).
|
|
29
|
+
*/
|
|
30
|
+
export function createGithubTrackerAdapter({ env = process.env, ghCommand = "gh", run } = {}) {
|
|
31
|
+
const deps = { env, ghCommand, ...(run ? { run } : {}) };
|
|
32
|
+
// detectLinkedIssuePr and the projects/*.mjs board primitives all name
|
|
33
|
+
// their DI param `runChild` (not `run`, unlike the other issue-ops
|
|
34
|
+
// functions) — pass the same injected runner under both names so a
|
|
35
|
+
// caller-supplied `run` reaches every dependency, not just issue-ops.
|
|
36
|
+
const runChildDeps = { env, ...(run ? { runChild: run } : {}) };
|
|
37
|
+
const linkedPrDeps = { ...runChildDeps, ghCommand };
|
|
38
|
+
|
|
39
|
+
function parseRef(urlOrRef) {
|
|
40
|
+
const trimmed = String(urlOrRef ?? "").trim();
|
|
41
|
+
// owner/repo#123
|
|
42
|
+
const hashMatch = /^([^/#\s]+\/[^/#\s]+)#(\d+)$/u.exec(trimmed);
|
|
43
|
+
if (hashMatch) {
|
|
44
|
+
return { repo: hashMatch[1], id: Number(hashMatch[2]) };
|
|
45
|
+
}
|
|
46
|
+
// Full GitHub issue URL: https://github.com/owner/repo/issues/123
|
|
47
|
+
const urlMatch = /^https?:\/\/github\.com\/([^/]+\/[^/]+)\/issues\/(\d+)(?:[/?#].*)?$/u.exec(trimmed);
|
|
48
|
+
if (urlMatch) {
|
|
49
|
+
return { repo: urlMatch[1], id: Number(urlMatch[2]) };
|
|
50
|
+
}
|
|
51
|
+
throw new Error(`parseRef: unrecognized issue reference "${urlOrRef}" (expected "owner/repo#123" or a github.com issue URL)`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function getIssue({ repo, id }) {
|
|
55
|
+
const { issue } = await viewIssue({ repo, issue: id, fields: "number,title,body,url,state,assignees" }, deps);
|
|
56
|
+
return {
|
|
57
|
+
id: issue.number,
|
|
58
|
+
title: issue.title ?? "",
|
|
59
|
+
body: issue.body ?? "",
|
|
60
|
+
url: issue.url ?? "",
|
|
61
|
+
state: typeof issue.state === "string" ? issue.state.toLowerCase() : "",
|
|
62
|
+
assignees: Array.isArray(issue.assignees)
|
|
63
|
+
? issue.assignees.map((a) => (typeof a?.login === "string" ? a.login : null)).filter((l) => l !== null)
|
|
64
|
+
: [],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function createIssue({ repo, title, body, milestone, labels, assignees }) {
|
|
69
|
+
const result = await coreCreateIssue({ repo, title, body, milestone, labels, assignees }, deps);
|
|
70
|
+
return { id: result.issueNumber, url: result.url };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function editIssue({ repo, id }, { title, body, assignees, milestone } = {}) {
|
|
74
|
+
// The tracker interface's flat `assignees` has no gh-native "replace"
|
|
75
|
+
// equivalent (`gh issue edit` only supports add/remove); this adapter
|
|
76
|
+
// treats it as an ADD list, matching the only current usage pattern in
|
|
77
|
+
// this repo (claiming an issue — see resolve-dev-loop-startup.mjs).
|
|
78
|
+
const result = await coreEditIssue({
|
|
79
|
+
repo,
|
|
80
|
+
issue: id,
|
|
81
|
+
title,
|
|
82
|
+
body,
|
|
83
|
+
addAssignees: assignees,
|
|
84
|
+
milestone,
|
|
85
|
+
}, deps);
|
|
86
|
+
return { edited: result.edited };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function commentIssue({ repo, id }, body) {
|
|
90
|
+
const result = await coreCommentIssue({ repo, issue: id, body }, deps);
|
|
91
|
+
return { commentUrl: result.commentUrl };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function listIssues({ repo, state, labels, limit }) {
|
|
95
|
+
const result = await coreListIssues({ repo, state, labels, limit }, deps);
|
|
96
|
+
// Normalize to the Tracker interface's Issue shape (same field names as
|
|
97
|
+
// getIssue), not the raw {number,title,state,labels} coreListIssues
|
|
98
|
+
// shape. `gh issue list` only returns number/title/state/labels — body/
|
|
99
|
+
// url/assignees are per-item fields `gh issue view` fetches, and this
|
|
100
|
+
// repo's list path never had them; fetching them here would be an N+1 gh
|
|
101
|
+
// call per listed issue. They are populated empty ("", []) rather than
|
|
102
|
+
// omitted, so every listIssues() result is still Issue-shaped (see
|
|
103
|
+
// TrackerAdapter.listIssues JSDoc in ./adapter.mjs).
|
|
104
|
+
return result.issues.map((issue) => ({
|
|
105
|
+
id: issue.number,
|
|
106
|
+
title: issue.title,
|
|
107
|
+
body: "",
|
|
108
|
+
url: "",
|
|
109
|
+
state: issue.state,
|
|
110
|
+
assignees: [],
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function detectLinkedPr({ repo, id }) {
|
|
115
|
+
const result = await detectLinkedIssuePr({ repo, issue: id }, linkedPrDeps);
|
|
116
|
+
return { hasOpenLinkedPr: result.hasOpenLinkedPr, prNumber: result.prNumber };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function listQueueItems(board) {
|
|
120
|
+
const result = await listQueueItemsMain({ repo: board.repo, project: board.project }, runChildDeps);
|
|
121
|
+
return result.items ?? [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// `board.columnNames` is the github provider's logical-column -> Status
|
|
125
|
+
// mapping — callers source it from the existing, already-load-bearing
|
|
126
|
+
// `queue.statusColumns` config (via `loadStateColumnMap` in
|
|
127
|
+
// `../loop/queue-board-sync.mjs`), not a tracker-owned config key; unset
|
|
128
|
+
// falls back to the provider's own defaults (DEFAULT_STATE_COLUMN_NAMES).
|
|
129
|
+
async function setItemStatus(board, item, logicalColumn) {
|
|
130
|
+
const columnNames = { ...DEFAULT_STATE_COLUMN_NAMES, ...(board.columnNames ?? {}) };
|
|
131
|
+
const toColumn = columnNames[logicalColumn];
|
|
132
|
+
if (!toColumn) {
|
|
133
|
+
throw new Error(`setItemStatus: no display column configured for logical column "${logicalColumn}"`);
|
|
134
|
+
}
|
|
135
|
+
const itemRef = String(item?.itemId ?? item?.number ?? item);
|
|
136
|
+
await moveQueueItemMain({ repo: board.repo, project: board.project, item: itemRef, toColumn }, runChildDeps);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return createTrackerAdapter({
|
|
140
|
+
parseRef,
|
|
141
|
+
getIssue,
|
|
142
|
+
createIssue,
|
|
143
|
+
editIssue,
|
|
144
|
+
commentIssue,
|
|
145
|
+
listIssues,
|
|
146
|
+
detectLinkedPr,
|
|
147
|
+
listQueueItems,
|
|
148
|
+
setItemStatus,
|
|
149
|
+
});
|
|
150
|
+
}
|