@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.
@@ -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 a forge-hosted owner/name — or "local" — no
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>` appended. Prints
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 issueRefs = null;
282
+ let closingLines = null;
253
283
  if (args.workItem && ws.workspace?.tracker) {
254
284
  const tracker = deps.trackerFactory(ws.workspace.tracker);
255
- issueTitle = (await tracker.getIssue(args.workItem)).title;
256
- issueRefs = new Map(forgeActive.map((t) => [t.repo, tracker.issueRef(args.workItem, { fromRepo: `${t.owner}/${t.name}` })]));
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({ ...(ws.workspace?.forge ?? {}), repo: `${t.owner}/${t.name}` });
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 (issueRefs) body = `${body}\n\nCloses ${issueRefs.get(t.repo)}\n`;
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
- const forgeFor = (p) => deps.forgeFactory({ ...(ws.workspace?.forge ?? {}), repo: `${p.owner}/${p.name}` });
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. A
196
- * path held by anything else is a collision and refuses rather than
197
- * guessing. Three creation cases, in order:
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 task chats run at the launcher, where cwd is just the root —
412
- * task again if the named chat's record has open tasks. Else none.
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 the baseline at <root>/.claude/.template-baseline.json. Returns
122
- * { templateVersion, files } or null when absent (workspaces older than the
123
- * baseline's introduction) or unparseable — classification then falls back to
124
- * the two-way behavior rather than failing the update.
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 readBaseline(root) {
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 { templateVersion: typeof parsed.templateVersion === 'string' ? parsed.templateVersion : null, files: parsed.files };
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
  }