@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.
@@ -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 { normalizeStatusCheckRollupContract } from "./copilot-ci-status.mjs";
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 normalizeStatusCheckRollupContract(rollup).overallStatus;
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 ?? normalizeCiStatus(prData?.statusCheckRollup),
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
@@ -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
- const gateSettings = settings?.gates?.[gateKey];
358
- if (!gateSettings) return undefined;
359
-
360
- const angles = Array.isArray(gateSettings.angles) ? [...gateSettings.angles] : [];
361
- const excludeAngles = Array.isArray(gateSettings.excludeAngles) ? [...gateSettings.excludeAngles] : [];
362
- const filteredAngles = angles.filter((a) => !excludeAngles.includes(a));
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: filteredAngles,
366
- excludeAngles: excludeAngles.length > 0 ? excludeAngles : undefined,
367
- blockCleanOnFindingSeverities: Array.isArray(gateSettings.blockCleanOnFindingSeverities)
368
- ? [...gateSettings.blockCleanOnFindingSeverities]
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. It routes to the
1384
- // pre_approval_gate, which reviews the post-cap head itself (per #848). The CI
1385
- // guards below still hold (failing / credibly-green CI blocks), and conflicts /
1386
- // blocked states are handled earlier, so genuinely-blocked states still forbid
1387
- // pre_approval. Mirrors LOW_SIGNAL_CONVERGED routing with round-cap reasoning.
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
- return { settings: settings?.queue ?? null };
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
- if (typeof queue.projectNumber === "number" && queue.projectNumber > 0) {
197
- return { enabled: true, projectNumber: queue.projectNumber };
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
- if (typeof queue.projectNumber === "number" && Number.isInteger(queue.projectNumber) && queue.projectNumber > 0) {
19
- out.project = queue.projectNumber;
20
- } else if (typeof queue.boardTitle === "string" && queue.boardTitle.trim().length > 0) {
21
- out.title = queue.boardTitle.trim();
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.projectNumber / queue.boardTitle in .devloops)"),
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.projectNumber/boardTitle. Mutates args.
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
+ }