@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.
- package/package.json +7 -1
- package/src/analysis/diff-analyzer.mjs +31 -5
- package/src/claude/hook-decisions.mjs +14 -0
- package/src/cli/primitives.mjs +10 -2
- package/src/cli/retry-wrapper.mjs +14 -6
- package/src/config/config.mjs +1125 -240
- package/src/config/extension-defaults.yaml +217 -426
- package/src/github/copilot-helpers.mjs +139 -18
- package/src/github/issue-ops.mjs +556 -0
- package/src/github/ownership-helpers.mjs +79 -0
- package/src/github/review-threads.mjs +44 -3
- package/src/loop/bash-command-classify.mjs +35 -5
- package/src/loop/conductor-routing.mjs +1 -1
- package/src/loop/copilot-ci-status.mjs +76 -0
- package/src/loop/copilot-loop-iterations.mjs +1 -2
- package/src/loop/copilot-loop-state.mjs +9 -5
- package/src/loop/default-branch-guard.mjs +380 -0
- package/src/loop/gate-carry-forward.mjs +29 -2
- package/src/loop/gate-fanin.mjs +481 -31
- package/src/loop/handoff-envelope.mjs +43 -23
- package/src/loop/main-checkout-ff.mjs +58 -0
- package/src/loop/pr-gate-coordination.mjs +204 -47
- package/src/loop/pr-title-markers.mjs +76 -15
- package/src/loop/queue-board-sync.mjs +26 -9
- package/src/loop/reviewer-loop-state.mjs +2 -2
- package/src/loop/ui-e2e-scoping.mjs +2 -0
- package/src/loop/ui-review-drive.mjs +23 -0
- package/src/loop/ui-review-provision.mjs +36 -0
- 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
|
@@ -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
|
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
+
}
|