@dev-loops/core 1.0.0-rc.2 → 1.0.0-rc.4

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.
Files changed (33) hide show
  1. package/package.json +7 -1
  2. package/src/analysis/diff-analyzer.mjs +31 -5
  3. package/src/claude/hook-decisions.mjs +14 -0
  4. package/src/cli/primitives.mjs +10 -2
  5. package/src/cli/retry-wrapper.mjs +14 -6
  6. package/src/config/config.mjs +1125 -240
  7. package/src/config/extension-defaults.yaml +217 -426
  8. package/src/github/copilot-helpers.mjs +139 -18
  9. package/src/github/issue-ops.mjs +556 -0
  10. package/src/github/ownership-helpers.mjs +79 -0
  11. package/src/github/review-threads.mjs +44 -3
  12. package/src/loop/bash-command-classify.mjs +35 -5
  13. package/src/loop/conductor-routing.mjs +1 -1
  14. package/src/loop/copilot-ci-status.mjs +76 -0
  15. package/src/loop/copilot-loop-iterations.mjs +1 -2
  16. package/src/loop/copilot-loop-state.mjs +9 -5
  17. package/src/loop/default-branch-guard.mjs +380 -0
  18. package/src/loop/gate-carry-forward.mjs +29 -2
  19. package/src/loop/gate-fanin.mjs +481 -31
  20. package/src/loop/handoff-envelope.mjs +43 -23
  21. package/src/loop/main-checkout-ff.mjs +58 -0
  22. package/src/loop/pr-gate-coordination.mjs +204 -47
  23. package/src/loop/pr-title-markers.mjs +76 -15
  24. package/src/loop/queue-board-sync.mjs +26 -9
  25. package/src/loop/reviewer-loop-state.mjs +2 -2
  26. package/src/loop/ui-e2e-scoping.mjs +2 -0
  27. package/src/loop/ui-review-drive.mjs +23 -0
  28. package/src/loop/ui-review-provision.mjs +36 -0
  29. package/src/projects/resolve-project.mjs +14 -7
  30. package/src/tracker/adapter.mjs +127 -0
  31. package/src/tracker/github-adapter.mjs +150 -0
  32. package/src/tracker/index.mjs +50 -0
  33. package/src/tracker/noop-adapter.mjs +35 -0
@@ -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
 
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Deterministic state machine and bounded planning/merge contracts for reviewer-side PR loops.
3
3
  */
4
+ import { SUBMITTED_REVIEW_STATES } from "../github/copilot-helpers.mjs";
4
5
 
5
6
  export const REVIEWER_STATE = Object.freeze({
6
7
  WAITING_FOR_REVIEW_REQUEST: "waiting_for_review_request",
@@ -105,7 +106,6 @@ const VALID_LOCAL_RUN_STATUSES = new Set(["none", "running", "completed", "faile
105
106
  const VALID_LOCAL_MERGE_STATUSES = new Set(["none", "ready", "failed"]);
106
107
  const VALID_DRAFT_NOTIFICATION_STATUSES = new Set(["none", "notified"]);
107
108
  const VALID_SUBMISSION_STATUSES = new Set(["none", "submitted", "failed"]);
108
- const VALID_SUBMITTED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"]);
109
109
 
110
110
  const SUPPORTED_REVIEW_ANGLES = Object.freeze([
111
111
  "correctness",
@@ -143,7 +143,7 @@ function normalizeSubmittedReviewState(value) {
143
143
  }
144
144
 
145
145
  const normalized = value.trim().toUpperCase();
146
- return VALID_SUBMITTED_REVIEW_STATES.has(normalized) ? normalized : null;
146
+ return SUBMITTED_REVIEW_STATES.has(normalized) ? normalized : null;
147
147
  }
148
148
 
149
149
  /**
@@ -38,8 +38,10 @@ export const VIEWER_SOURCE_PATHS = Object.freeze([
38
38
  export const REGISTERED_ARTIFACT_PATHS = Object.freeze([
39
39
  "docs/presentations/introducing-dev-loops.html",
40
40
  "docs/presentations/dev-loops-deep-dive.html",
41
+ "docs/presentations/how-dev-loops-decided-itself.html",
41
42
  "docs/articles/introducing-dev-loops.html",
42
43
  "docs/articles/dev-loops-deep-dive.html",
44
+ "docs/articles/how-dev-loops-decided-itself.html",
43
45
  ]);
44
46
 
45
47
  export const VIEWER_ARTIFACT_ID = "inspect-run-viewer";
@@ -44,6 +44,19 @@ export function isErrorResponseStatus(status) {
44
44
  return typeof status === "number" && (status < 200 || status >= 400);
45
45
  }
46
46
 
47
+ /** The one owner of the request-abort carve-out: a request the browser itself
48
+ * aborted carries no defect signal. Navigating away cancels in-flight asset
49
+ * requests, so these appear on every multi-step flow. Matched per engine:
50
+ * WebKit reports "cancelled", Chromium "net::ERR_ABORTED", Firefox
51
+ * "NS_BINDING_ABORTED". Matching is case-insensitive and substring-based because
52
+ * engines wrap the token in longer text. A genuine DNS/connection/TLS failure
53
+ * carries a different token and is still classified must-fix. */
54
+ export function isAbortedRequestFailure(failure) {
55
+ if (typeof failure !== "string") return false;
56
+ const f = failure.toLowerCase();
57
+ return f.includes("cancelled") || f.includes("canceled") || f.includes("err_aborted") || f.includes("ns_binding_aborted");
58
+ }
59
+
47
60
  /** Bound the stack text carried onto a page-error failure so a runaway stack
48
61
  * (or a synthetic error with a huge stack) can't bloat the feed envelope. Keeps
49
62
  * the head — the top frames, where the throwing file:line sits. Exported so the
@@ -157,6 +170,16 @@ export function classifyFailures({
157
170
  }
158
171
 
159
172
  for (const f of requestFailures) {
173
+ // A request the BROWSER aborted is not evidence of a defect: navigating away
174
+ // cancels every asset request still in flight, so a flow with more than one
175
+ // `goto` manufactures one of these per unfinished image/font on the page it
176
+ // left. Measured on sofatutor 2026-08-05: a clean two-goto admin2 walk
177
+ // produced 13, all "cancelled", all classified must-fix — and since
178
+ // `ok: failures.length === 0`, they failed an otherwise passing drive and
179
+ // would have been posted as findings against the PR. This is the request-abort
180
+ // counterpart of the 3xx carve-out in isErrorResponseStatus: a real
181
+ // server/network fault still arrives with its own failure text and is kept.
182
+ if (isAbortedRequestFailure(f.failure)) continue;
160
183
  failures.push({
161
184
  kind: "request-failed",
162
185
  severity: MUST_FIX,
@@ -37,6 +37,10 @@ const MUST_FIX = "must-fix";
37
37
  * @param {object} seams - Injected IO (all required except clock/log defaults).
38
38
  * @param {(a:{repoRoot:string,pr:number,branch?:string})=>Promise<{path:string,created:boolean,reused:boolean}>} seams.ensureWorktree
39
39
  * @param {(a:{worktreePath:string,repoRoot:string})=>{ok:boolean,message?:string,mainWorktreePath?:string|null}} seams.assertNotPrimary
40
+ * @param {(a:{worktreePath:string,repoRoot:string,pr:number})=>Promise<{ok:boolean,sha?:string|null,detail?:string}>} [seams.pinPrHead] -
41
+ * Pin the worktree to the PR head and report the resolved SHA. Optional only
42
+ * for back-compat: omitting it leaves the ref UNVERIFIED and is logged as such.
43
+ * Runs after the primary-checkout guard, never before.
40
44
  * @param {(a:{repoRoot:string,worktreePath:string})=>Promise<{changed:boolean,detail:string}>} seams.detectDepDelta
41
45
  * @param {(a:{worktreePath:string})=>Promise<{ok:boolean,detail:string}>} seams.installDeps
42
46
  * @param {(worktreePath:string)=>Promise<object|null>} seams.resolveRunRecipe
@@ -58,6 +62,7 @@ export async function provisionAndBoot(
58
62
  {
59
63
  ensureWorktree,
60
64
  assertNotPrimary,
65
+ pinPrHead,
61
66
  detectDepDelta,
62
67
  installDeps,
63
68
  resolveRunRecipe,
@@ -111,6 +116,36 @@ export async function provisionAndBoot(
111
116
  );
112
117
  }
113
118
 
119
+ // 2b. Pin the worktree to the PR head, AFTER the primary-checkout guard above
120
+ // (this checks out a ref, so it must never run in the primary checkout).
121
+ // ensureWorktree resolves `branch` against what already exists, so the
122
+ // default `pr-<n>` lands wherever that name happens to point — the base
123
+ // branch when no remote carries it, and a same-named remote branch that
124
+ // is not this PR's head when one does. A fork PR's head branch is on no
125
+ // candidate remote at all. Reviewing the wrong commit still reports
126
+ // ok:true, so pin the head explicitly and record the resolved SHA.
127
+ let headSha = null;
128
+ if (pinPrHead) {
129
+ const pin = await pinPrHead({ worktreePath, repoRoot, pr });
130
+ if (!pin?.ok) {
131
+ return stop(
132
+ `cannot pin PR head: ${pin?.detail ?? "unknown failure"}`,
133
+ {
134
+ kind: "pr-head-unpinned",
135
+ severity: MUST_FIX,
136
+ message: `the worktree could not be pinned to PR #${pr}'s head, so it cannot be reviewed as that PR: ${pin?.detail ?? "unknown failure"}`,
137
+ },
138
+ { worktreePath },
139
+ );
140
+ }
141
+ headSha = pin.sha ?? null;
142
+ record(`worktree pinned to PR head ${headSha ?? "(sha unknown)"}${pin.detail ? ` (${pin.detail})` : ""}`);
143
+ } else {
144
+ // Never silent: a caller without the seam gets an unverified ref, which is
145
+ // exactly the failure mode this step exists to close.
146
+ record("WARNING: PR head not pinned (no pinPrHead seam) — worktree ref is UNVERIFIED");
147
+ }
148
+
114
149
  // 3. Install only the dependency-lock delta vs. the primary checkout. No delta
115
150
  // => deps are shared; installing anything would be a blind re-install.
116
151
  const delta = await detectDepDelta({ repoRoot, worktreePath });
@@ -256,6 +291,7 @@ export async function provisionAndBoot(
256
291
  worktreePath,
257
292
  created: wt.created,
258
293
  reused: wt.reused,
294
+ headSha,
259
295
  depInstall,
260
296
  migrations,
261
297
  boot: bootResult,
@@ -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 tracker.board — or the deprecated queue.board — number / 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
+ }
@@ -0,0 +1,50 @@
1
+ export { createTrackerAdapter, isTrackerAdapter, hasBoardCapability, REQUIRED_METHODS, BOARD_METHODS } from "./adapter.mjs";
2
+ export { createGithubTrackerAdapter } from "./github-adapter.mjs";
3
+ export { createNoopTrackerAdapter } from "./noop-adapter.mjs";
4
+
5
+ import { createGithubTrackerAdapter } from "./github-adapter.mjs";
6
+
7
+ /** Built-in provider registry — GitHub is the only baked-in provider in v1
8
+ * (issue #1408); an external provider registers here post-1.0 (or a consumer
9
+ * passes its own adapter directly, bypassing this registry entirely). */
10
+ const BUILTIN_PROVIDERS = Object.freeze({
11
+ github: createGithubTrackerAdapter,
12
+ });
13
+
14
+ /**
15
+ * Resolve the tracker adapter for the given effective dev-loop config.
16
+ *
17
+ * Config-driven with NO global/singleton state (#1408 design constraint): a
18
+ * future multi-tracker or per-capability layer just calls this again with a
19
+ * different scoped config, and it stays additive — this function never reads
20
+ * outside its `config` argument.
21
+ *
22
+ * `config?.tracker?.provider` selects a provider FROM THE REGISTERED
23
+ * `providers` map (default `"github"`, the only one registered out of the
24
+ * box) — the registry is extensible, not built-in-only: a consumer passes
25
+ * `{ providers: { ...builtins, jira: createJiraAdapter } }` to register an
26
+ * external provider (post-1.0 consumer concern). An unknown provider (not in
27
+ * whatever `providers` was actually passed) fails closed rather than
28
+ * silently falling back to GitHub. `config?.tracker?.plugin` is reserved for
29
+ * a consumer's own module-loading resolver in front of this — not
30
+ * implemented in this pass (non-goal, #1408).
31
+ *
32
+ * @param {import("../config/config.mjs").DevLoopConfig|null|undefined} config
33
+ * @param {{ env?: NodeJS.ProcessEnv, ghCommand?: string, providers?: Record<string, Function> }} [deps]
34
+ * @returns {import("./adapter.mjs").TrackerAdapter}
35
+ */
36
+ export function resolveTrackerAdapter(config, { env, ghCommand, providers = BUILTIN_PROVIDERS } = {}) {
37
+ const provider = config?.tracker?.provider?.trim() || "github";
38
+ const factory = providers[provider];
39
+ if (typeof factory !== "function") {
40
+ throw new Error(
41
+ `Unknown tracker.provider "${provider}" — no adapter is registered for it. ` +
42
+ `Registered: ${Object.keys(providers).join(", ")} ("github" is the built-in default; ` +
43
+ `any others listed here were registered by the caller). ` +
44
+ `An external provider is a post-1.0 consumer concern: register it by passing ` +
45
+ `{ providers: { ...builtins, "${provider}": createYourAdapter } } to resolveTrackerAdapter ` +
46
+ `(setting tracker.provider in .devloops alone does not register one).`,
47
+ );
48
+ }
49
+ return factory({ ...(env !== undefined ? { env } : {}), ...(ghCommand !== undefined ? { ghCommand } : {}) });
50
+ }
@@ -0,0 +1,35 @@
1
+ import { createTrackerAdapter } from "./adapter.mjs";
2
+
3
+ /**
4
+ * Create a minimal, in-memory tracker adapter for tests. Every Issues method
5
+ * is a deterministic stub; Board methods are included so tests can also
6
+ * exercise the optional capability without a real GitHub Projects board.
7
+ *
8
+ * @param {Partial<import("./adapter.mjs").TrackerAdapter>} [overrides]
9
+ * @returns {import("./adapter.mjs").TrackerAdapter}
10
+ */
11
+ export function createNoopTrackerAdapter(overrides = {}) {
12
+ return createTrackerAdapter({
13
+ parseRef: (urlOrRef) => ({ repo: "", id: String(urlOrRef) }),
14
+ getIssue: async (ref) => ({
15
+ id: ref?.id ?? "",
16
+ title: "",
17
+ body: "",
18
+ url: "",
19
+ state: "open",
20
+ assignees: [],
21
+ }),
22
+ createIssue: async () => ({ id: "0", url: "" }),
23
+ editIssue: async () => ({ edited: [] }),
24
+ commentIssue: async () => ({ commentUrl: "" }),
25
+ listIssues: async () => [],
26
+ detectLinkedPr: async () => null,
27
+ ensureBoard: async () => ({}),
28
+ listQueueItems: async () => [],
29
+ addQueueItem: async () => ({}),
30
+ setItemStatus: async () => {},
31
+ reorderItem: async () => {},
32
+ archiveItems: async () => {},
33
+ ...overrides,
34
+ });
35
+ }