@ulysses-ai/create-workspace 0.22.0-beta.0 → 0.23.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/payload.mjs +170 -2
- package/lib/payload.test.mjs +158 -3
- package/lib/upgrade.mjs +148 -6
- package/lib/upgrade.test.mjs +319 -15
- package/package.json +1 -1
- package/template/_claude/rules/forge-operations.md +27 -6
- package/template/_claude/scripts/chat-record.mjs +51 -4
- package/template/_claude/scripts/classify-update.mjs +201 -21
- package/template/_claude/scripts/cleanup-work-session.mjs +64 -3
- package/template/_claude/scripts/forges/gitlab.mjs +450 -18
- package/template/_claude/scripts/forges/interface.mjs +39 -6
- package/template/_claude/scripts/merge-mode.mjs +96 -12
- package/template/_claude/scripts/migrate-sessions.mjs +232 -24
- package/template/_claude/scripts/task-pr.mjs +52 -13
- package/template/_claude/scripts/task-worktree.mjs +79 -10
- package/template/_claude/scripts/template-baseline.mjs +33 -9
- package/template/_claude/scripts/trackers/gitlab-issues.mjs +276 -0
- package/template/_claude/scripts/trackers/interface.mjs +3 -0
- package/template/_claude/skills/complete-work/SKILL.md +7 -4
- package/template/_claude/skills/migrate-sessions/SKILL.md +20 -4
- package/template/_claude/skills/release/SKILL.md +24 -8
- package/template/_claude/skills/setup-tracker/SKILL.md +46 -12
- package/template/_claude/skills/start-work/SKILL.md +15 -2
- package/template/_claude/skills/workspace-update/SKILL.md +34 -22
|
@@ -16,14 +16,15 @@
|
|
|
16
16
|
//
|
|
17
17
|
// Usage:
|
|
18
18
|
// node task-pr.mjs --create --root <launcher> --branch <branch>
|
|
19
|
-
// [--work-item gh:N] [--chat <name> | --repo <r> ...]
|
|
19
|
+
// [--work-item gh:N|gl:N] [--chat <name> | --repo <r> ...]
|
|
20
20
|
// --body-file <forge-repo>=<path> ... [--out <file>]
|
|
21
21
|
// [--force-with-lease]
|
|
22
22
|
// node task-pr.mjs --merge --root <launcher> --prs <json-from-create>
|
|
23
|
-
// [--work-item gh:N]
|
|
23
|
+
// [--work-item gh:N|gl:N]
|
|
24
24
|
//
|
|
25
25
|
// Every repo of the task resolves to a merge mode (mergeModeFor below):
|
|
26
|
-
// "forge" — its origin is
|
|
26
|
+
// "forge" — its origin is forge-hosted (github.com, gitlab.com, or the
|
|
27
|
+
// configured self-managed GitLab host) — or "local" — no
|
|
27
28
|
// origin at all, or an explicit "local" override in workspace.json
|
|
28
29
|
// (repos.{repo}.merge / workspace.merge), the escape hatch for a clone
|
|
29
30
|
// whose origin is a third-party upstream nobody here may push to. An
|
|
@@ -39,7 +40,9 @@
|
|
|
39
40
|
// body file is required per forge repo alone; one given for a local repo
|
|
40
41
|
// is ignored). The PR title is the linked issue's title when --work-item
|
|
41
42
|
// is given, else the branch's first commit subject; the body is the
|
|
42
|
-
// repo's body file with `Closes <ref>`
|
|
43
|
+
// repo's body file with a closing reference appended — `Closes <ref>`
|
|
44
|
+
// when the tracker's forge matches the PR's, else the issue's URL (a
|
|
45
|
+
// GitHub `owner/repo#N` reference cannot resolve on GitLab). Prints
|
|
43
46
|
// `{ prs, empty, pushed }` — prs holds forge entries (mode "forge") and
|
|
44
47
|
// local entries (mode "local") alike, every entry carrying its commit
|
|
45
48
|
// count — and, with --out, writes the same JSON to a file — a mid-run
|
|
@@ -77,7 +80,7 @@ import { resolve, dirname } from 'node:path';
|
|
|
77
80
|
import { spawnSync } from 'node:child_process';
|
|
78
81
|
import { fileURLToPath } from 'node:url';
|
|
79
82
|
import { taskWorktreePath, defaultBranchFor } from './task-worktree.mjs';
|
|
80
|
-
import { WORKSPACE_REPO, repoDirFor, readWorkspace, parseForgeRemote, mergeModeFor } from './merge-mode.mjs';
|
|
83
|
+
import { WORKSPACE_REPO, repoDirFor, readWorkspace, parseForgeRemote, forgeHosts, perRepoForge, mergeModeFor } from './merge-mode.mjs';
|
|
81
84
|
import { readRecord } from './chat-record.mjs';
|
|
82
85
|
import { createForge } from './forges/interface.mjs';
|
|
83
86
|
import { createTracker } from './trackers/interface.mjs';
|
|
@@ -97,6 +100,33 @@ function assertForgeEnabled(ws) {
|
|
|
97
100
|
}
|
|
98
101
|
}
|
|
99
102
|
|
|
103
|
+
// perRepoForge (from merge-mode.mjs, re-exported below) builds each repo's
|
|
104
|
+
// forge config; see its definition there for why the origin wins over the
|
|
105
|
+
// workspace-level type.
|
|
106
|
+
|
|
107
|
+
// Which forge an issues adapter's references resolve on — `owner/repo#N`
|
|
108
|
+
// from github-issues only closes on GitHub, `group/sub#N` from
|
|
109
|
+
// gitlab-issues only on GitLab.
|
|
110
|
+
const TRACKER_FORGE = { 'github-issues': 'github', 'gitlab-issues': 'gitlab' };
|
|
111
|
+
|
|
112
|
+
// The closing line for a PR body. Same forge as the tracker: the native
|
|
113
|
+
// reference (issueRef). Cross-forge — a GitHub tracker's issue referenced
|
|
114
|
+
// from a GitLab MR, or the reverse — cannot use it, since the target forge
|
|
115
|
+
// cannot resolve the reference; the line carries the issue's URL instead.
|
|
116
|
+
// As `Closes {url}` when the tracker mints canonical URLs (issueUrl —
|
|
117
|
+
// both forges close issues referenced by full URL); otherwise as a bare
|
|
118
|
+
// `Refs {url}` on the URL getIssue already returned, which references the
|
|
119
|
+
// issue without claiming close semantics the target forge may not honor.
|
|
120
|
+
function closingLineFor(tracker, issue, workItem, target, trackerConfig) {
|
|
121
|
+
const ref = tracker.issueRef(workItem, { fromRepo: `${target.owner}/${target.name}` });
|
|
122
|
+
const trackerForge = trackerConfig?.type ? TRACKER_FORGE[trackerConfig.type] : undefined;
|
|
123
|
+
if (!trackerForge || !target.forge || trackerForge === target.forge) {
|
|
124
|
+
return `Closes ${ref}`;
|
|
125
|
+
}
|
|
126
|
+
if (typeof tracker.issueUrl === 'function') return `Closes ${tracker.issueUrl(workItem)}`;
|
|
127
|
+
return issue?.url ? `Refs ${issue.url}` : `Closes ${ref}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
100
130
|
// Branch names become refs, refspecs, and (via the slug) paths; git's own
|
|
101
131
|
// format check is the authority, and its --branch variant also rejects
|
|
102
132
|
// names a later git call could mistake for an option. It needs no
|
|
@@ -236,7 +266,7 @@ async function createPrs(args, deps) {
|
|
|
236
266
|
// still complete with forge operations disabled.
|
|
237
267
|
if (forgeActive.length > 0) assertForgeEnabled(ws);
|
|
238
268
|
for (const t of forgeActive) {
|
|
239
|
-
Object.assign(t, parseForgeRemote(gitOut(deps.gitFn, t.worktree, ['remote', 'get-url', 'origin'])));
|
|
269
|
+
Object.assign(t, parseForgeRemote(gitOut(deps.gitFn, t.worktree, ['remote', 'get-url', 'origin']), { hosts: forgeHosts(ws) }));
|
|
240
270
|
}
|
|
241
271
|
for (const t of forgeActive) {
|
|
242
272
|
const bodyFile = args.bodyFiles.get(t.repo);
|
|
@@ -249,11 +279,12 @@ async function createPrs(args, deps) {
|
|
|
249
279
|
}
|
|
250
280
|
|
|
251
281
|
let issueTitle = null;
|
|
252
|
-
let
|
|
282
|
+
let closingLines = null;
|
|
253
283
|
if (args.workItem && ws.workspace?.tracker) {
|
|
254
284
|
const tracker = deps.trackerFactory(ws.workspace.tracker);
|
|
255
|
-
|
|
256
|
-
|
|
285
|
+
const issue = await tracker.getIssue(args.workItem);
|
|
286
|
+
issueTitle = issue.title;
|
|
287
|
+
closingLines = new Map(forgeActive.map((t) => [t.repo, closingLineFor(tracker, issue, args.workItem, t, ws.workspace.tracker)]));
|
|
257
288
|
}
|
|
258
289
|
|
|
259
290
|
// Local entries are recorded up front, before anything can fail: they are
|
|
@@ -273,7 +304,7 @@ async function createPrs(args, deps) {
|
|
|
273
304
|
}
|
|
274
305
|
|
|
275
306
|
for (const t of forgeActive) {
|
|
276
|
-
const forge = deps.forgeFactory(
|
|
307
|
+
const forge = deps.forgeFactory(perRepoForge(ws, t));
|
|
277
308
|
// Idempotency: a re-run must not open a second PR for a branch that
|
|
278
309
|
// already has one open against the same base. Reuse it as-is —
|
|
279
310
|
// re-titling or re-bodying an existing PR is a decision, not a
|
|
@@ -282,10 +313,11 @@ async function createPrs(args, deps) {
|
|
|
282
313
|
.find((p) => p.headRefName === args.branch && p.baseRefName === t.defaultBranch);
|
|
283
314
|
const title = issueTitle ?? firstCommitSubject(deps.gitFn, t, args.branch);
|
|
284
315
|
let body = readFileSync(args.bodyFiles.get(t.repo), 'utf8').replace(/\s*$/, '');
|
|
285
|
-
if (
|
|
316
|
+
if (closingLines) body = `${body}\n\n${closingLines.get(t.repo)}\n`;
|
|
286
317
|
const pr = existing ?? await forge.prCreate({ title, body, head: args.branch, base: t.defaultBranch });
|
|
287
318
|
prs.push({
|
|
288
319
|
repo: t.repo, mode: 'forge', owner: t.owner, name: t.name,
|
|
320
|
+
forge: t.forge, host: t.host,
|
|
289
321
|
number: pr.number, id: pr.id, url: pr.url, isWorkspace: t.isWorkspace, commits: t.commits,
|
|
290
322
|
});
|
|
291
323
|
}
|
|
@@ -375,7 +407,14 @@ async function mergePrs(args, deps) {
|
|
|
375
407
|
}
|
|
376
408
|
}
|
|
377
409
|
if (prs.some((p) => modeOf(p) === 'forge')) assertForgeEnabled(ws);
|
|
378
|
-
|
|
410
|
+
// Entries carry their repo's forge and host since GitLab support landed;
|
|
411
|
+
// older files predate the fields and fall back to the workspace block —
|
|
412
|
+
// the GitHub default those runs were built under.
|
|
413
|
+
const forgeFor = (p) => deps.forgeFactory({
|
|
414
|
+
...(ws.workspace?.forge ?? {}),
|
|
415
|
+
...(p.forge ? { type: p.forge, host: p.host } : {}),
|
|
416
|
+
repo: `${p.owner}/${p.name}`,
|
|
417
|
+
});
|
|
379
418
|
|
|
380
419
|
// State first, so a re-run knows what an earlier run already finished.
|
|
381
420
|
// A PR the forge reports MERGED is done; anything else is offered to
|
|
@@ -552,4 +591,4 @@ if (isMainModule(import.meta.url)) {
|
|
|
552
591
|
}
|
|
553
592
|
|
|
554
593
|
export { run, parseArgs };
|
|
555
|
-
export { parseForgeRemote, mergeModeFor } from './merge-mode.mjs';
|
|
594
|
+
export { parseForgeRemote, mergeModeFor, perRepoForge, forgeConfigForRepo } from './merge-mode.mjs';
|
|
@@ -23,19 +23,28 @@
|
|
|
23
23
|
// chat name it also consults the chat record, which is the only place
|
|
24
24
|
// open tasks are listed. /complete-work uses that to pick its flow.
|
|
25
25
|
//
|
|
26
|
+
// Long-lived lane chats sit at the launcher across BOTH lifecycles, so
|
|
27
|
+
// detection also matches the chat's session id against each old session's
|
|
28
|
+
// chatSessions frontmatter — a chat that drove a work-sessions/{name}/
|
|
29
|
+
// session by path is findable from the launcher too (gh:188). The id comes
|
|
30
|
+
// from --session-id, from the named chat record's sessionId, or from
|
|
31
|
+
// $CLAUDE_CODE_SESSION_ID, in that order. Tasks AND sessions at once
|
|
32
|
+
// report model "mixed" and let the operator pick.
|
|
33
|
+
//
|
|
26
34
|
// Usage:
|
|
27
35
|
// node task-worktree.mjs --root <dir> --create --repo <r> --branch <b> [--base <ref>]
|
|
28
36
|
// node task-worktree.mjs --root <dir> --remove --repo <r> --branch <b> [--force] [--delete-branch]
|
|
29
|
-
// node task-worktree.mjs --root <dir> --detect [--cwd <dir>] [--chat <name>]
|
|
37
|
+
// node task-worktree.mjs --root <dir> --detect [--cwd <dir>] [--chat <name>] [--session-id <id>]
|
|
30
38
|
|
|
31
39
|
import {
|
|
32
|
-
readFileSync, writeFileSync, existsSync, mkdirSync, statSync,
|
|
40
|
+
readFileSync, writeFileSync, existsSync, mkdirSync, statSync, readdirSync,
|
|
33
41
|
} from 'node:fs';
|
|
34
42
|
import { realpathSync } from 'node:fs';
|
|
35
43
|
import { join, resolve, relative, sep, isAbsolute, basename, dirname } from 'node:path';
|
|
36
44
|
import { spawnSync } from 'node:child_process';
|
|
37
45
|
import { fileURLToPath } from 'node:url';
|
|
38
46
|
import { readRecord } from './chat-record.mjs';
|
|
47
|
+
import { readSessionFields } from '../lib/session-frontmatter.mjs';
|
|
39
48
|
import { WORKSPACE_REPO, repoDirFor, mergeModeFor } from './merge-mode.mjs';
|
|
40
49
|
|
|
41
50
|
function isMainModule(metaUrl) {
|
|
@@ -192,9 +201,11 @@ function ensureWorkspaceExcluded(gitFn, rootDir) {
|
|
|
192
201
|
* Create (or return) the task worktree for {branch} in {repo}.
|
|
193
202
|
*
|
|
194
203
|
* Idempotent: a worktree already at the path on the same branch is a
|
|
195
|
-
* success — /start-work is not guaranteed to run exactly once per task
|
|
196
|
-
*
|
|
197
|
-
*
|
|
204
|
+
* success — /start-work is not guaranteed to run exactly once per task,
|
|
205
|
+
* and adopting another chat's task re-runs --create over the owner's
|
|
206
|
+
* existing worktree, which must be reused (created: false), never
|
|
207
|
+
* duplicated or failed. A path held by anything else is a collision and
|
|
208
|
+
* refuses rather than guessing. Three creation cases, in order:
|
|
198
209
|
* - the local branch exists (a prior remove kept it) → check it out,
|
|
199
210
|
* keeping its commits;
|
|
200
211
|
* - only refs/remotes/origin/{branch} exists → the task was started on
|
|
@@ -382,6 +393,58 @@ function matchingTasks(rootDir, chat, branch) {
|
|
|
382
393
|
return tasks.length > 0 ? tasks : null;
|
|
383
394
|
}
|
|
384
395
|
|
|
396
|
+
// The sessions a chat id has driven, from each tracker's chatSessions. A
|
|
397
|
+
// lane chat at the launcher works a session by path, so cwd can never say
|
|
398
|
+
// which — the id the SessionStart hook registered is the only link. All
|
|
399
|
+
// matches come back (a chat can hop across sessions over days); the caller
|
|
400
|
+
// asks the operator when there is more than one. Archived sessions sit at
|
|
401
|
+
// work-sessions/.archived/{name}/workspace/ — one level too deep for this
|
|
402
|
+
// scan, so they never surface.
|
|
403
|
+
function matchingSessions(rootReal, sessionId) {
|
|
404
|
+
if (!sessionId) return null;
|
|
405
|
+
const sessionsDir = sessionsDirFor(rootReal);
|
|
406
|
+
let names;
|
|
407
|
+
try {
|
|
408
|
+
names = readdirSync(sessionsDir).sort();
|
|
409
|
+
} catch {
|
|
410
|
+
return null; // no sessions dir — nothing to match
|
|
411
|
+
}
|
|
412
|
+
const out = [];
|
|
413
|
+
for (const name of names) {
|
|
414
|
+
const tracker = join(sessionsDir, name, 'workspace', 'session.md');
|
|
415
|
+
if (!existsSync(tracker)) continue;
|
|
416
|
+
let fields;
|
|
417
|
+
try {
|
|
418
|
+
fields = readSessionFields(tracker);
|
|
419
|
+
} catch {
|
|
420
|
+
continue; // an unparseable tracker is evidence about the tracker, not this chat
|
|
421
|
+
}
|
|
422
|
+
const chats = Array.isArray(fields.chatSessions) ? fields.chatSessions : [];
|
|
423
|
+
if (!chats.some((c) => c && c.id === sessionId)) continue;
|
|
424
|
+
out.push({
|
|
425
|
+
name,
|
|
426
|
+
branch: typeof fields.branch === 'string' ? fields.branch : null,
|
|
427
|
+
workItem: typeof fields.workItem === 'string' ? fields.workItem : null,
|
|
428
|
+
status: typeof fields.status === 'string' ? fields.status : null,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
return out.length > 0 ? out : null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Which session id detection matches sessions by: an explicit argument
|
|
435
|
+
// beats the named chat record's sessionId (the record IS this chat's id,
|
|
436
|
+
// keyed stably where a name is renameable), which beats the ambient
|
|
437
|
+
// $CLAUDE_CODE_SESSION_ID — the hook's id, present even when the hook's
|
|
438
|
+
// `Chat record:` line was compacted away.
|
|
439
|
+
function resolveSessionId(rootDir, chat, sessionId, env) {
|
|
440
|
+
if (sessionId) return sessionId;
|
|
441
|
+
if (chat) {
|
|
442
|
+
const rec = readRecord(rootDir, chat);
|
|
443
|
+
if (rec && rec.sessionId) return rec.sessionId;
|
|
444
|
+
}
|
|
445
|
+
return (env && env.CLAUDE_CODE_SESSION_ID) || null;
|
|
446
|
+
}
|
|
447
|
+
|
|
385
448
|
// One task worktree's detect payload, or null when {path} does not really
|
|
386
449
|
// hold a task: a stale plain directory under .claude/worktrees/ would
|
|
387
450
|
// otherwise detect through cwd and resolve to the repo around it — for the
|
|
@@ -408,10 +471,12 @@ function taskWorktreeInfo(gitFn, rootReal, repo, path, chat) {
|
|
|
408
471
|
* Tell a skill which lifecycle the current directory is under, in order:
|
|
409
472
|
* session (cwd in the old model's tree), task (cwd in a task worktree,
|
|
410
473
|
* enriched with the chat's matching tasks when {chat} is given), then —
|
|
411
|
-
* because
|
|
412
|
-
* task
|
|
474
|
+
* because lane chats run at the launcher, where cwd is just the root —
|
|
475
|
+
* task if the named chat's record has open tasks, session if the chat's
|
|
476
|
+
* session id appears in any tracker's chatSessions, "mixed" when both.
|
|
477
|
+
* Else none.
|
|
413
478
|
*/
|
|
414
|
-
function detectWorkModel(cwd, root, { chat = null, gitFn = spawnSync } = {}) {
|
|
479
|
+
function detectWorkModel(cwd, root, { chat = null, sessionId = null, gitFn = spawnSync, env = process.env } = {}) {
|
|
415
480
|
const rootReal = realPath(resolve(root));
|
|
416
481
|
const cwdReal = realPath(resolve(cwd));
|
|
417
482
|
|
|
@@ -441,7 +506,10 @@ function detectWorkModel(cwd, root, { chat = null, gitFn = spawnSync } = {}) {
|
|
|
441
506
|
}
|
|
442
507
|
|
|
443
508
|
const tasks = matchingTasks(rootReal, chat, null);
|
|
509
|
+
const sessions = matchingSessions(rootReal, resolveSessionId(rootReal, chat, sessionId, env));
|
|
510
|
+
if (tasks && sessions) return { model: 'mixed', tasks, sessions };
|
|
444
511
|
if (tasks) return { model: 'task', source: 'chat-record', tasks };
|
|
512
|
+
if (sessions) return { model: 'session', source: 'chat-sessions', sessions };
|
|
445
513
|
|
|
446
514
|
return { model: 'none' };
|
|
447
515
|
}
|
|
@@ -454,6 +522,7 @@ const VALUE_FLAGS = new Map([
|
|
|
454
522
|
['--base', 'base'],
|
|
455
523
|
['--cwd', 'cwd'],
|
|
456
524
|
['--chat', 'chat'],
|
|
525
|
+
['--session-id', 'sessionId'],
|
|
457
526
|
]);
|
|
458
527
|
|
|
459
528
|
// A repo name becomes a path segment under repos/ — one segment only, no
|
|
@@ -468,7 +537,7 @@ function isRepoSegment(repo) {
|
|
|
468
537
|
}
|
|
469
538
|
|
|
470
539
|
function parseArgs(argv) {
|
|
471
|
-
const args = { root: '.', mode: null, repo: null, branch: null, base: null, cwd: null, chat: null, force: false, deleteBranch: false };
|
|
540
|
+
const args = { root: '.', mode: null, repo: null, branch: null, base: null, cwd: null, chat: null, sessionId: null, force: false, deleteBranch: false };
|
|
472
541
|
const rest = argv.slice(2);
|
|
473
542
|
for (let i = 0; i < rest.length; i += 1) {
|
|
474
543
|
const a = rest[i];
|
|
@@ -513,7 +582,7 @@ function main() {
|
|
|
513
582
|
} else if (args.mode === 'remove') {
|
|
514
583
|
out = removeTaskWorktree(args.root, { repo: args.repo, branch: args.branch, force: args.force, deleteBranch: args.deleteBranch });
|
|
515
584
|
} else {
|
|
516
|
-
out = detectWorkModel(args.cwd || process.cwd(), args.root, { chat: args.chat });
|
|
585
|
+
out = detectWorkModel(args.cwd || process.cwd(), args.root, { chat: args.chat, sessionId: args.sessionId });
|
|
517
586
|
}
|
|
518
587
|
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
|
|
519
588
|
}
|
|
@@ -57,6 +57,14 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
57
57
|
|
|
58
58
|
export const BASELINE_PATH = '.claude/.template-baseline.json';
|
|
59
59
|
|
|
60
|
+
// Where --upgrade stages a RECONSTRUCTED baseline (built from the installed
|
|
61
|
+
// version's npm tarball) inside the payload. It never lands in the launcher:
|
|
62
|
+
// with a remote, /workspace-update classifies inside a task worktree that
|
|
63
|
+
// cannot see launcher-only files, and an untracked launcher baseline would
|
|
64
|
+
// also dirty the launcher against the incoming PR. The payload travels to
|
|
65
|
+
// the worktree by absolute path, so the baseline rides along.
|
|
66
|
+
export const RECONSTRUCTED_BASELINE_NAME = '.template-baseline.reconstructed.json';
|
|
67
|
+
|
|
60
68
|
// [source name, installed name] pairs for the verbatim-installed roots. The
|
|
61
69
|
// staged payload carries the live names; the template tree stores .claude/ and
|
|
62
70
|
// .mcp.json under the inert names _claude/ and _mcp.json, so scaffold passes
|
|
@@ -118,25 +126,38 @@ function* walkFiles(dir, prefix = '') {
|
|
|
118
126
|
}
|
|
119
127
|
|
|
120
128
|
/**
|
|
121
|
-
* Read
|
|
122
|
-
* { templateVersion, files } or null when absent
|
|
123
|
-
*
|
|
124
|
-
* the two-way behavior rather than
|
|
129
|
+
* Read a baseline JSON file at an explicit path. Returns
|
|
130
|
+
* { templateVersion, files, reconstructed } or null when absent or
|
|
131
|
+
* unparseable — a corrupt baseline is treated exactly like a missing one
|
|
132
|
+
* (gh:186) so classification falls back to the two-way behavior rather than
|
|
133
|
+
* failing the update, and reconstruction is not blocked by a broken file.
|
|
125
134
|
*/
|
|
126
|
-
export function
|
|
127
|
-
const path = join(resolve(root), BASELINE_PATH);
|
|
135
|
+
export function readBaselineFile(path) {
|
|
128
136
|
if (!existsSync(path)) return null;
|
|
129
137
|
try {
|
|
130
138
|
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
131
139
|
if (!parsed || typeof parsed !== 'object' || typeof parsed.files !== 'object' || parsed.files === null) {
|
|
132
140
|
return null;
|
|
133
141
|
}
|
|
134
|
-
return {
|
|
142
|
+
return {
|
|
143
|
+
templateVersion: typeof parsed.templateVersion === 'string' ? parsed.templateVersion : null,
|
|
144
|
+
files: parsed.files,
|
|
145
|
+
reconstructed: parsed.reconstructed === true,
|
|
146
|
+
};
|
|
135
147
|
} catch {
|
|
136
148
|
return null;
|
|
137
149
|
}
|
|
138
150
|
}
|
|
139
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Read the baseline at <root>/.claude/.template-baseline.json. Returns the
|
|
154
|
+
* parsed baseline or null when absent (workspaces older than the baseline's
|
|
155
|
+
* introduction) or unparseable.
|
|
156
|
+
*/
|
|
157
|
+
export function readBaseline(root) {
|
|
158
|
+
return readBaselineFile(join(resolve(root), BASELINE_PATH));
|
|
159
|
+
}
|
|
160
|
+
|
|
140
161
|
/**
|
|
141
162
|
* Build a baseline from a template source directory (a staged payload with
|
|
142
163
|
* live names, or the template tree via INERT_PAIRS). `version` overrides the
|
|
@@ -186,7 +207,10 @@ export function buildBaseline(sourceDir, { pairs = LIVE_PAIRS, version = null }
|
|
|
186
207
|
* they were for unapplied updates: a file whose workspace content still
|
|
187
208
|
* matches the old baseline while the payload ships something new (a declined
|
|
188
209
|
* `updated` batch) keeps the OLD entry, so the next update still offers the
|
|
189
|
-
* change instead of filing the file away as a local edit.
|
|
210
|
+
* change instead of filing the file away as a local edit. The previous
|
|
211
|
+
* baseline defaults to <root>/.claude/.template-baseline.json; pass
|
|
212
|
+
* opts.previous to override (the worktree flow reads the payload's
|
|
213
|
+
* reconstructed baseline when the root has none of its own).
|
|
190
214
|
*/
|
|
191
215
|
export function writeBaseline(root, sourceDir, opts = {}) {
|
|
192
216
|
const absRoot = resolve(root);
|
|
@@ -196,7 +220,7 @@ export function writeBaseline(root, sourceDir, opts = {}) {
|
|
|
196
220
|
`No verbatim template files found under ${resolve(sourceDir)} — refusing to write an empty baseline`,
|
|
197
221
|
);
|
|
198
222
|
}
|
|
199
|
-
const previous = readBaseline(absRoot);
|
|
223
|
+
const previous = 'previous' in opts ? opts.previous : readBaseline(absRoot);
|
|
200
224
|
if (previous) {
|
|
201
225
|
for (const rel of Object.keys(baseline.files)) {
|
|
202
226
|
const oldHash = previous.files[rel];
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// GitLab Issues adapter. Wraps the `glab` CLI via an injectable spawnFn
|
|
2
|
+
// (mirrors trackers/github-issues.mjs). Issue IDs are opaque strings of the
|
|
3
|
+
// form "gl:N" — adapters outside this file don't need to parse them;
|
|
4
|
+
// routing is handled by interface.mjs.
|
|
5
|
+
//
|
|
6
|
+
// The repo is a `group/sub/project` path (any depth); a self-managed
|
|
7
|
+
// instance is selected with `host` in the tracker config (GITLAB_HOST for
|
|
8
|
+
// glab, --hostname for `glab api`), defaulting to gitlab.com.
|
|
9
|
+
|
|
10
|
+
import '../../lib/require-node.mjs';
|
|
11
|
+
import { spawnSync as nodeSpawnSync } from 'node:child_process';
|
|
12
|
+
import { AlreadyAssignedError } from './interface.mjs';
|
|
13
|
+
|
|
14
|
+
const GITLAB_DEFAULT_HOST = 'gitlab.com';
|
|
15
|
+
|
|
16
|
+
const STANDARD_LABELS = [
|
|
17
|
+
{ name: 'bug', color: 'd73a4a' },
|
|
18
|
+
{ name: 'feat', color: 'a2eeef' },
|
|
19
|
+
{ name: 'chore', color: 'cfd3d7' },
|
|
20
|
+
{ name: 'P1', color: 'b60205' },
|
|
21
|
+
{ name: 'P2', color: 'fbca04' },
|
|
22
|
+
{ name: 'P3', color: '0e8a16' },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export function createGitlabAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
|
|
26
|
+
const repo = resolveRepo(config, spawnFn);
|
|
27
|
+
const host = typeof config?.host === 'string' && config.host ? config.host : GITLAB_DEFAULT_HOST;
|
|
28
|
+
let loginCache = null;
|
|
29
|
+
|
|
30
|
+
function glab(args, { input } = {}) {
|
|
31
|
+
// GITLAB_HOST always names the resolved instance — always, because an
|
|
32
|
+
// exported foreign value must not leak into a gitlab.com run — and
|
|
33
|
+
// aims the -R/--repo slugs at it.
|
|
34
|
+
const env = { ...process.env, GITLAB_HOST: host };
|
|
35
|
+
const result = spawnFn('glab', args, {
|
|
36
|
+
input,
|
|
37
|
+
encoding: 'utf-8',
|
|
38
|
+
env,
|
|
39
|
+
stdio: input !== undefined ? ['pipe', 'pipe', 'pipe'] : ['inherit', 'pipe', 'pipe'],
|
|
40
|
+
});
|
|
41
|
+
if (result.status !== 0) {
|
|
42
|
+
throw new Error(`glab ${args.join(' ')} failed: ${(result.stderr || '').trim()}`);
|
|
43
|
+
}
|
|
44
|
+
return result.stdout || '';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// `glab api` resolves its host from the CWD's git remote, not from
|
|
48
|
+
// GITLAB_HOST, so the resolved host is always passed explicitly.
|
|
49
|
+
function apiArgs(path) {
|
|
50
|
+
return ['api', path, '--hostname', host];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Paged walk over a `glab api` list endpoint — 100 at a time until a
|
|
54
|
+
// short page. The list subcommands fetch a single page and cannot
|
|
55
|
+
// express the server-side filters some of these calls need, so `glab
|
|
56
|
+
// api` does both jobs.
|
|
57
|
+
function apiListAll(path, params = []) {
|
|
58
|
+
const found = [];
|
|
59
|
+
for (let page = 1; ; page += 1) {
|
|
60
|
+
const query = [...params, 'per_page=100', `page=${page}`].join('&');
|
|
61
|
+
const batch = JSON.parse(glab(apiArgs(`${path}?${query}`)));
|
|
62
|
+
if (!Array.isArray(batch)) {
|
|
63
|
+
throw new Error(`glab api ${path} returned a non-list: ${JSON.stringify(batch).slice(0, 80)}`);
|
|
64
|
+
}
|
|
65
|
+
found.push(...batch);
|
|
66
|
+
if (batch.length < 100) break;
|
|
67
|
+
}
|
|
68
|
+
return found;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function currentUser() {
|
|
72
|
+
if (loginCache) return loginCache;
|
|
73
|
+
loginCache = JSON.parse(glab(apiArgs('user'))).username;
|
|
74
|
+
return loginCache;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalize(raw) {
|
|
78
|
+
return {
|
|
79
|
+
id: `gl:${raw.iid}`,
|
|
80
|
+
number: raw.iid,
|
|
81
|
+
title: raw.title,
|
|
82
|
+
body: raw.description || '',
|
|
83
|
+
state: (raw.state || '').toLowerCase() === 'closed' ? 'closed' : 'open',
|
|
84
|
+
assignees: (raw.assignees || []).map((a) => a.username),
|
|
85
|
+
labels: (raw.labels || []).map((l) => (typeof l === 'string' ? l : l.name)),
|
|
86
|
+
milestone: raw.milestone?.title ?? null,
|
|
87
|
+
url: raw.web_url,
|
|
88
|
+
createdAt: raw.created_at,
|
|
89
|
+
updatedAt: raw.updated_at,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseIssueNumber(issueId) {
|
|
94
|
+
const m = issueId.match(/^gl:(\d+)$/);
|
|
95
|
+
if (!m) throw new Error(`Not a GitLab issue ID: ${issueId}`);
|
|
96
|
+
return parseInt(m[1], 10);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function listAssignedToMe() {
|
|
100
|
+
const me = currentUser();
|
|
101
|
+
// `glab issue list` defaults to open issues; JSON output is -O here,
|
|
102
|
+
// -F on the single-entity commands.
|
|
103
|
+
const stdout = glab(['issue', 'list', '--repo', repo, '--assignee', me, '-O', 'json', '--per-page', '100']);
|
|
104
|
+
return JSON.parse(stdout).map(normalize);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function listUnassigned() {
|
|
108
|
+
// The API's server-side `assignee_id=None` filter, paged to the end —
|
|
109
|
+
// a client-side filter over one `issue list` page saw only the first
|
|
110
|
+
// hundred open issues.
|
|
111
|
+
return apiListAll(`projects/${encodeURIComponent(repo)}/issues`, ['state=opened', 'assignee_id=None'])
|
|
112
|
+
.map(normalize);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function getIssue(issueId) {
|
|
116
|
+
const num = parseIssueNumber(issueId);
|
|
117
|
+
const stdout = glab(['issue', 'view', String(num), '--repo', repo, '-F', 'json']);
|
|
118
|
+
return normalize(JSON.parse(stdout));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function claim(issueId) {
|
|
122
|
+
const me = currentUser();
|
|
123
|
+
const issue = await getIssue(issueId);
|
|
124
|
+
const others = issue.assignees.filter((a) => a !== me);
|
|
125
|
+
if (others.length > 0) {
|
|
126
|
+
throw new AlreadyAssignedError(issueId, others);
|
|
127
|
+
}
|
|
128
|
+
if (!issue.assignees.includes(me)) {
|
|
129
|
+
// --assignee replaces the assignee list; with no other assignees
|
|
130
|
+
// (checked above) that is exactly "assign me".
|
|
131
|
+
glab(['issue', 'update', String(issue.number), '--repo', repo, '--assignee', me]);
|
|
132
|
+
return getIssue(issueId);
|
|
133
|
+
}
|
|
134
|
+
return issue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function createIssue({ title, body = '', labels = [], milestone = null }) {
|
|
138
|
+
const args = ['issue', 'create', '--repo', repo, '--title', title, '--description', body, '--yes'];
|
|
139
|
+
if (labels.length > 0) args.push('--label', labels.join(','));
|
|
140
|
+
if (milestone) args.push('--milestone', milestone);
|
|
141
|
+
const stdout = glab(args);
|
|
142
|
+
const m = stdout.match(/\/-\/issues\/(\d+)/);
|
|
143
|
+
if (!m) throw new Error(`Could not parse issue number from: ${stdout.trim()}`);
|
|
144
|
+
return getIssue(`gl:${m[1]}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function comment(issueId, body) {
|
|
148
|
+
const num = parseIssueNumber(issueId);
|
|
149
|
+
// glab has no --body-file: the message travels as an argv element.
|
|
150
|
+
glab(['issue', 'note', String(num), '--repo', repo, '--message', body]);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// A closing reference for MR bodies: `#N` closes an issue in the MR's own
|
|
154
|
+
// project, `group/sub/project#N` reaches one living elsewhere — the same
|
|
155
|
+
// shapes github-issues renders, with GitLab's full-path reference for the
|
|
156
|
+
// cross-project case. No `fromRepo` means the MR is in this adapter's own
|
|
157
|
+
// project.
|
|
158
|
+
function issueRef(issueId, { fromRepo } = {}) {
|
|
159
|
+
const num = parseIssueNumber(issueId);
|
|
160
|
+
return fromRepo && fromRepo !== repo ? `${repo}#${num}` : `#${num}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The canonical URL for an issue — what a cross-forge reference needs
|
|
164
|
+
// (a `group/sub#N` reference cannot resolve on a GitHub PR), minted from
|
|
165
|
+
// the same host/repo every other URL here is built from.
|
|
166
|
+
function issueUrl(issueId) {
|
|
167
|
+
const num = parseIssueNumber(issueId);
|
|
168
|
+
return `https://${host}/${repo}/-/issues/${num}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function closeIssue(issueId, { comment: commentBody } = {}) {
|
|
172
|
+
const num = parseIssueNumber(issueId);
|
|
173
|
+
if (commentBody) {
|
|
174
|
+
glab(['issue', 'note', String(num), '--repo', repo, '--message', commentBody]);
|
|
175
|
+
}
|
|
176
|
+
glab(['issue', 'close', String(num), '--repo', repo]);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function ensureLabels() {
|
|
180
|
+
// No --force equivalent: labels that exist are left as they are, so
|
|
181
|
+
// this stays idempotent without rewriting a team's chosen colors.
|
|
182
|
+
const stdout = glab(['label', 'list', '--repo', repo, '-F', 'json', '--per-page', '100']);
|
|
183
|
+
const existing = new Set(JSON.parse(stdout).map((l) => l.name));
|
|
184
|
+
for (const { name, color } of STANDARD_LABELS) {
|
|
185
|
+
if (existing.has(name)) continue;
|
|
186
|
+
glab(['label', 'create', '--repo', repo, '--name', name, '--color', `#${color}`]);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// `glab milestone create` prints human text (it has no JSON output flag),
|
|
191
|
+
// and `milestone list` fetches a single page — so listing goes through
|
|
192
|
+
// the API paged to the end, and a create is verified by re-listing
|
|
193
|
+
// rather than by parsing what glab printed.
|
|
194
|
+
async function ensureMilestone({ title, description = '', dueOn = null } = {}) {
|
|
195
|
+
if (!title) throw new Error('ensureMilestone: title is required');
|
|
196
|
+
const findByTitle = () => apiListAll(`projects/${encodeURIComponent(repo)}/milestones`)
|
|
197
|
+
.find((m) => m.title === title);
|
|
198
|
+
const existing = findByTitle();
|
|
199
|
+
if (existing) return normalizeMilestone(existing);
|
|
200
|
+
const args = ['milestone', 'create', '--repo', repo, '--title', title];
|
|
201
|
+
if (description) args.push('--description', description);
|
|
202
|
+
if (dueOn) args.push('--due-date', dueOn);
|
|
203
|
+
glab(args);
|
|
204
|
+
const created = findByTitle();
|
|
205
|
+
if (!created) {
|
|
206
|
+
throw new Error(`milestone "${title}" was not in the milestone list after glab milestone create`);
|
|
207
|
+
}
|
|
208
|
+
return normalizeMilestone(created);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
listAssignedToMe,
|
|
213
|
+
listUnassigned,
|
|
214
|
+
getIssue,
|
|
215
|
+
claim,
|
|
216
|
+
createIssue,
|
|
217
|
+
comment,
|
|
218
|
+
closeIssue,
|
|
219
|
+
issueRef,
|
|
220
|
+
issueUrl,
|
|
221
|
+
ensureLabels,
|
|
222
|
+
ensureMilestone,
|
|
223
|
+
get identity() { return `gitlab-issues:${repo}`; },
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function normalizeMilestone(raw) {
|
|
228
|
+
return {
|
|
229
|
+
number: raw.id,
|
|
230
|
+
title: raw.title,
|
|
231
|
+
description: raw.description || '',
|
|
232
|
+
state: raw.state === 'closed' ? 'closed' : 'open', // GitLab says "active"
|
|
233
|
+
dueOn: raw.due_date || null,
|
|
234
|
+
url: raw.web_url,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Like github-issues.mjs's resolveRepo, kept local so the adapter stands
|
|
239
|
+
// alone: gitlab.com plus the configured self-managed host, scp-style and
|
|
240
|
+
// scheme URLs (an explicit non-default port stays in the host, so `host`
|
|
241
|
+
// can carry it — gitlab.example.com:8443), nested groups at any depth,
|
|
242
|
+
// optional .git suffix.
|
|
243
|
+
const DEFAULT_PORTS = { https: 443, http: 80, ssh: 22, git: 9418 };
|
|
244
|
+
const SCHEME_URL_RE = /^(?<scheme>https?|ssh|git):\/\/(?:[^@/]+@)?(?<host>[^:/]+)(?::(?<port>\d+))?\/(?<path>.+)$/;
|
|
245
|
+
|
|
246
|
+
function resolveRepo(config, spawnFn) {
|
|
247
|
+
if (config?.repo && config.repo !== 'auto') return config.repo;
|
|
248
|
+
const result = spawnFn('git', ['remote', 'get-url', 'origin'], { encoding: 'utf-8' });
|
|
249
|
+
if (result.status !== 0) {
|
|
250
|
+
throw new Error(`git remote get-url failed: ${(result.stderr || '').trim()}`);
|
|
251
|
+
}
|
|
252
|
+
const hosts = [GITLAB_DEFAULT_HOST, ...(config?.host ? [String(config.host).toLowerCase()] : [])];
|
|
253
|
+
const s = result.stdout.trim().replace(/\/+$/, '');
|
|
254
|
+
let host = null;
|
|
255
|
+
let path = null;
|
|
256
|
+
const scp = s.match(/^[^@/]+@([^:/]+):(.+)$/); // scp-style: git@host:path
|
|
257
|
+
if (scp) {
|
|
258
|
+
host = scp[1].toLowerCase();
|
|
259
|
+
path = scp[2];
|
|
260
|
+
} else {
|
|
261
|
+
const m = s.match(SCHEME_URL_RE);
|
|
262
|
+
if (m) {
|
|
263
|
+
const { scheme, host: h, port } = m.groups;
|
|
264
|
+
// A non-default port stays part of the host so it can match a
|
|
265
|
+
// configured host that carries one; a default port drops, so
|
|
266
|
+
// github.com:443-style URLs still resolve.
|
|
267
|
+
host = (port && Number(port) !== DEFAULT_PORTS[scheme] ? `${h}:${port}` : h).toLowerCase();
|
|
268
|
+
path = m.groups.path;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (host && path && hosts.includes(host)) {
|
|
272
|
+
const segments = path.replace(/\.git$/, '').split('/').filter(Boolean);
|
|
273
|
+
if (segments.length >= 2) return segments.join('/');
|
|
274
|
+
}
|
|
275
|
+
throw new Error(`Cannot parse GitLab remote: ${result.stdout.trim()}`);
|
|
276
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import '../../lib/require-node.mjs';
|
|
13
13
|
import { createGithubAdapter } from './github-issues.mjs';
|
|
14
|
+
import { createGitlabAdapter } from './gitlab-issues.mjs';
|
|
14
15
|
|
|
15
16
|
export class AlreadyAssignedError extends Error {
|
|
16
17
|
constructor(issueId, assignees) {
|
|
@@ -28,6 +29,8 @@ export function createTracker(config, options = {}) {
|
|
|
28
29
|
switch (config.type) {
|
|
29
30
|
case 'github-issues':
|
|
30
31
|
return createGithubAdapter(config, options);
|
|
32
|
+
case 'gitlab-issues':
|
|
33
|
+
return createGitlabAdapter(config, options);
|
|
31
34
|
default:
|
|
32
35
|
throw new Error(`Unknown tracker type: ${config.type}`);
|
|
33
36
|
}
|