@ulysses-ai/create-workspace 0.23.2-beta.0 → 0.24.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.
Files changed (36) hide show
  1. package/package.json +2 -1
  2. package/template/CLAUDE.md.tmpl +2 -2
  3. package/template/_claude/hooks/_utils.mjs +43 -0
  4. package/template/_claude/hooks/session-start.mjs +10 -0
  5. package/template/_claude/rules/work-item-tracking.md +6 -3
  6. package/template/_claude/rules/workspace-structure.md +1 -1
  7. package/template/_claude/scripts/chat-record.mjs +43 -26
  8. package/template/_claude/scripts/classify-update.mjs +194 -32
  9. package/template/_claude/scripts/forges/github.mjs +51 -1
  10. package/template/_claude/scripts/forges/gitlab.mjs +69 -0
  11. package/template/_claude/scripts/forges/interface.mjs +19 -2
  12. package/template/_claude/scripts/maintenance-audit.mjs +325 -3
  13. package/template/_claude/scripts/merge-mode.mjs +22 -1
  14. package/template/_claude/scripts/migrate-sessions.mjs +212 -34
  15. package/template/_claude/scripts/release-leak-audit.mjs +480 -0
  16. package/template/_claude/scripts/task-pr.mjs +316 -72
  17. package/template/_claude/scripts/task-worktree.mjs +9 -1
  18. package/template/_claude/scripts/template-merge.mjs +21 -4
  19. package/template/_claude/scripts/template-modifications.mjs +152 -0
  20. package/template/_claude/scripts/trackers/github-issues.mjs +110 -1
  21. package/template/_claude/scripts/trackers/gitlab-issues.mjs +238 -5
  22. package/template/_claude/scripts/trackers/interface.mjs +47 -0
  23. package/template/_claude/skills/braindump/SKILL.md +1 -1
  24. package/template/_claude/skills/complete-work/SKILL.md +22 -12
  25. package/template/_claude/skills/handoff/SKILL.md +1 -1
  26. package/template/_claude/skills/maintenance/SKILL.md +8 -3
  27. package/template/_claude/skills/migrate-sessions/SKILL.md +20 -13
  28. package/template/_claude/skills/release/SKILL.md +20 -6
  29. package/template/_claude/skills/setup-tracker/SKILL.md +24 -1
  30. package/template/_claude/skills/start-work/SKILL.md +23 -5
  31. package/template/_claude/skills/workspace-update/SKILL.md +23 -15
  32. package/template/workspace.json.tmpl +2 -1
  33. package/lib/init.test.mjs +0 -75
  34. package/lib/payload.test.mjs +0 -210
  35. package/lib/scaffold.test.mjs +0 -79
  36. package/lib/upgrade.test.mjs +0 -547
@@ -45,6 +45,12 @@
45
45
  // "out": "<resolved output dir>",
46
46
  // "templateBase": "<resolved base dir, or null when none was staged>" }
47
47
  //
48
+ // A differs file registered in .claude/template-modifications.json carries
49
+ // its recorded reason (gh:194): merged/conflicted entries gain a `reason`
50
+ // field, and a noBase path becomes { path, reason } — unregistered entries
51
+ // keep the plain shapes above, so the skill can show WHY a file differs at
52
+ // each per-file decision.
53
+ //
48
54
  // Merged text — conflict markers `<<<<<<< local` / `>>>>>>> template`
49
55
  // included — lands ONLY in the output dir. Applying an approved result is
50
56
  // /workspace-update's job: copying the file into the workspace one
@@ -184,7 +190,16 @@ export function mergeTemplateFiles({ root, payload = null, baseline = null, out
184
190
  }
185
191
 
186
192
  const classification = classifyUpdate({ root: absRoot, payload: absPayload, baseline });
187
- const differsSet = new Set(classification.differs);
193
+ // differs entries are plain paths, or { file, reason } for paths with a
194
+ // registered modification (gh:194). Normalize to one shape and keep the
195
+ // reasons so they ride along on the merge output.
196
+ const differsFiles = classification.differs.map((e) => (typeof e === 'string' ? e : e.file));
197
+ const reasonByFile = new Map(
198
+ classification.differs
199
+ .filter((e) => typeof e === 'object' && typeof e.reason === 'string')
200
+ .map((e) => [e.file, e.reason]),
201
+ );
202
+ const differsSet = new Set(differsFiles);
188
203
  const baseDir = join(absPayload, TEMPLATE_BASE_DIR);
189
204
 
190
205
  const result = {
@@ -196,7 +211,7 @@ export function mergeTemplateFiles({ root, payload = null, baseline = null, out
196
211
  templateBase: existsSync(baseDir) ? baseDir : null,
197
212
  };
198
213
 
199
- let targets = classification.differs;
214
+ let targets = differsFiles;
200
215
  if (files !== null) {
201
216
  const wanted = files.split(',').map((s) => s.trim()).filter(Boolean);
202
217
  targets = [];
@@ -208,6 +223,7 @@ export function mergeTemplateFiles({ root, payload = null, baseline = null, out
208
223
 
209
224
  const { baseline: resolvedBaseline } = resolveBaseline({ root: absRoot, payload: absPayload, baseline });
210
225
  for (const rel of targets) {
226
+ const reason = reasonByFile.get(rel);
211
227
  const local = join(absRoot, rel);
212
228
  const template = join(absPayload, rel);
213
229
  const base = join(baseDir, rel);
@@ -216,12 +232,12 @@ export function mergeTemplateFiles({ root, payload = null, baseline = null, out
216
232
  continue;
217
233
  }
218
234
  if (!existsSync(base)) {
219
- result.noBase.push(rel);
235
+ result.noBase.push(reason === undefined ? rel : { path: rel, reason });
220
236
  continue;
221
237
  }
222
238
  const baselineHash = resolvedBaseline ? resolvedBaseline.files[rel] : undefined;
223
239
  if (baselineHash !== undefined && hashBytes(readFileSync(base)) !== baselineHash) {
224
- result.noBase.push(rel);
240
+ result.noBase.push(reason === undefined ? rel : { path: rel, reason });
225
241
  continue;
226
242
  }
227
243
  const merge = mergeTrio(local, base, template);
@@ -233,6 +249,7 @@ export function mergeTemplateFiles({ root, payload = null, baseline = null, out
233
249
  mkdirSync(dirname(outFile), { recursive: true });
234
250
  writeFileSync(outFile, merge.bytes);
235
251
  const entry = { path: rel, conflicts: merge.conflicts, out: outFile };
252
+ if (reason !== undefined) entry.reason = reason;
236
253
  if (merge.conflicts === 0) result.merged.push(entry);
237
254
  else result.conflicted.push(entry);
238
255
  }
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env node
2
+ // Reader for .claude/template-modifications.json — the workspace's registry
3
+ // of how it deliberately diverges from the template (gh:194).
4
+ //
5
+ // Shape (paths relative to .claude/; on read, backslashes become '/', and a
6
+ // leading "./" or ".claude/" on a key or entry is tolerated and stripped, so
7
+ // ".claude\rules\core.md", "./rules/core.md", and "rules/core.md" are the
8
+ // same key):
9
+ // {
10
+ // "localFiles": ["skills/my-skill/**", "rules/my-rule.md"],
11
+ // "modifications": { "rules/core.md": "kept our stricter lint gate" }
12
+ // }
13
+ //
14
+ // The registry covers .claude/ paths only — root files (CLAUDE.md,
15
+ // .mcp.json, .claudeignore) are handled by their own merge paths — so a key
16
+ // that escapes .claude/ ('../CLAUDE.md', an absolute path) names nothing it
17
+ // governs: it reports in `ignoredKeys` for the operator to fix instead of
18
+ // sitting silently inert, and a key that normalizes away ('.', '.claude/')
19
+ // is dropped.
20
+ //
21
+ // localFiles — files this workspace owns outright: never offered by
22
+ // /workspace-update, neither as an update nor as a removal. Semantics are
23
+ // unchanged from the workspace.json → workspace.localFiles array the field
24
+ // migrates from.
25
+ // modifications — deliberate edits to files the template still owns: they
26
+ // keep updating (three-way merge when both sides changed), and the
27
+ // recorded reason is shown whenever the file comes up for a decision, so
28
+ // "why does this file differ?" survives the person who made the edit.
29
+ //
30
+ // The file is workspace-owned: the template never ships or overwrites it.
31
+ // An upgrade payload carries no copy, and classify-update treats the path
32
+ // like the baseline — never as a template removal.
33
+ //
34
+ // Legacy fallback, for one release: workspaces that predate the file keep
35
+ // their data in workspace.json — `workspace.localFiles` (the old array) and
36
+ // `workspace.templateModifications` (an improvised path→reason map some
37
+ // workspaces adopted before the file existed). Both are read here so
38
+ // nothing breaks before /workspace-update migrates them; `legacyKeys` names
39
+ // the ones still present so the skill and the maintenance audit can offer
40
+ // the migration. Where the file and a legacy key both carry data, the file
41
+ // wins per modification path and the localFiles arrays union — a
42
+ // half-finished migration never loses an exclusion or hides a reason.
43
+
44
+ import { existsSync, readFileSync } from 'node:fs';
45
+ import { join, resolve } from 'node:path';
46
+
47
+ export const TEMPLATE_MODIFICATIONS_PATH = '.claude/template-modifications.json';
48
+
49
+ // workspace.json keys the file replaces. `localFiles` is the documented
50
+ // pre-gh:194 array; `templateModifications` is the improvised map.
51
+ export const LEGACY_KEYS = ['localFiles', 'templateModifications'];
52
+
53
+ // Normalize one registry path — a localFiles entry or a modifications key —
54
+ // to a .claude/-relative posix path: Windows backslashes become '/', repeated
55
+ // leading './' segments collapse, then leading '.claude/' prefixes strip.
56
+ // Returns '' when nothing usable remains ('.', '.claude/') and null when the
57
+ // path escapes .claude/ ('../CLAUDE.md', absolute paths, drive letters) —
58
+ // the caller reports those in ignoredKeys (gh:194).
59
+ function normalizeRel(value) {
60
+ const posix = value
61
+ .replace(/\\/g, '/')
62
+ .replace(/^(?:\.\/)+/, '')
63
+ .replace(/^(?:\.claude\/)+/, '');
64
+ if (posix === '' || posix === '.') return '';
65
+ if (posix === '..' || posix.startsWith('../')
66
+ || posix.startsWith('/') || /^[A-Za-z]:/.test(posix)) {
67
+ return null;
68
+ }
69
+ return posix;
70
+ }
71
+
72
+ // `ignored` collects the raw, as-written keys that escape .claude/.
73
+ function normalizeLocalFiles(entries, ignored) {
74
+ if (!Array.isArray(entries)) return [];
75
+ const out = [];
76
+ for (const entry of entries) {
77
+ if (typeof entry !== 'string' || entry.length === 0) continue;
78
+ const rel = normalizeRel(entry);
79
+ if (rel === null) { ignored.push(entry); continue; }
80
+ if (rel !== '') out.push(rel);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ function normalizeModifications(map, ignored) {
86
+ if (map === null || typeof map !== 'object' || Array.isArray(map)) return {};
87
+ const out = {};
88
+ for (const [key, reason] of Object.entries(map)) {
89
+ if (typeof reason !== 'string' || reason.length === 0) continue;
90
+ const rel = normalizeRel(key);
91
+ if (rel === null) { ignored.push(key); continue; }
92
+ if (rel !== '') out[rel] = reason;
93
+ }
94
+ return out;
95
+ }
96
+
97
+ /**
98
+ * Read the workspace's template-modification registry: the file first, the
99
+ * legacy workspace.json keys filling whatever it doesn't carry. Returns:
100
+ * localFiles — normalized .claude/-relative paths/globs, unioned
101
+ * modifications — { '.claude/-relative path': reason }, the file winning
102
+ * per path, keys sorted for deterministic output
103
+ * ignoredKeys — raw keys that escape .claude/ (they cover nothing the
104
+ * registry governs), sorted, from either field or source
105
+ * legacyKeys — LEGACY_KEYS still present in workspace.json (the
106
+ * migration is unfinished; /workspace-update offers it)
107
+ * parseError — message when the file exists but doesn't parse. Its
108
+ * content is then treated as absent — never guessed at —
109
+ * so the legacy keys alone apply and the callers surface
110
+ * the error.
111
+ */
112
+ export function readTemplateModifications(root) {
113
+ const absRoot = resolve(root);
114
+ let ws = null;
115
+ try {
116
+ const config = JSON.parse(readFileSync(join(absRoot, 'workspace.json'), 'utf8'));
117
+ if (config?.workspace && typeof config.workspace === 'object') ws = config.workspace;
118
+ } catch { /* no workspace.json (or unparseable — the audit reports that) */ }
119
+
120
+ const legacyKeys = ws !== null
121
+ ? LEGACY_KEYS.filter((k) => Object.prototype.hasOwnProperty.call(ws, k))
122
+ : [];
123
+ const ignored = [];
124
+ const localFiles = normalizeLocalFiles(ws?.localFiles, ignored);
125
+ let modifications = normalizeModifications(ws?.templateModifications, ignored);
126
+
127
+ let parseError = null;
128
+ const filePath = join(absRoot, TEMPLATE_MODIFICATIONS_PATH);
129
+ if (existsSync(filePath)) {
130
+ try {
131
+ const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
132
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
133
+ throw new Error('not a JSON object');
134
+ }
135
+ for (const entry of normalizeLocalFiles(parsed.localFiles, ignored)) {
136
+ if (!localFiles.includes(entry)) localFiles.push(entry);
137
+ }
138
+ modifications = { ...modifications, ...normalizeModifications(parsed.modifications, ignored) };
139
+ } catch (err) {
140
+ parseError = err instanceof Error ? err.message : String(err);
141
+ }
142
+ }
143
+
144
+ const sorted = Object.keys(modifications).sort();
145
+ return {
146
+ localFiles,
147
+ modifications: Object.fromEntries(sorted.map((k) => [k, modifications[k]])),
148
+ ignoredKeys: [...new Set(ignored)].sort(),
149
+ legacyKeys,
150
+ parseError,
151
+ };
152
+ }
@@ -4,7 +4,7 @@
4
4
 
5
5
  import '../../lib/require-node.mjs';
6
6
  import { spawnSync as nodeSpawnSync } from 'node:child_process';
7
- import { AlreadyAssignedError } from './interface.mjs';
7
+ import { AlreadyAssignedError, resolveEpicLabelPrefix } from './interface.mjs';
8
8
 
9
9
  const ISSUE_FIELDS = 'number,title,body,state,assignees,labels,milestone,url,createdAt,updatedAt';
10
10
 
@@ -17,6 +17,11 @@ const STANDARD_LABELS = [
17
17
  { name: 'P3', color: '0e8a16' },
18
18
  ];
19
19
 
20
+ // Epic labels share one colour so they read as a family in the UI;
21
+ // createEpic never rewrites a label that already exists, so a team that
22
+ // recolours theirs keeps their choice.
23
+ const EPIC_LABEL_COLOR = '5319e7';
24
+
20
25
  export function createGithubAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
21
26
  const repo = resolveRepo(config, spawnFn);
22
27
  let loginCache = null;
@@ -144,6 +149,105 @@ export function createGithubAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
144
149
  return normalizeMilestone(created);
145
150
  }
146
151
 
152
+ // ---- Epics --------------------------------------------------------------
153
+ // Label mode: an epic is a single label `epicLabelPrefix + name` on every
154
+ // issue in it, so an issue carries exactly one epic and `listEpicIssues`
155
+ // is a label query. GitHub has no native epic object — sub-issues could
156
+ // back a native mode one day, but until then `epics: "native"` fails
157
+ // loudly per call rather than degrading to labels, because a team that
158
+ // asked for native must find out, not silently get label semantics.
159
+ const epicLabelPrefix = resolveEpicLabelPrefix(config);
160
+
161
+ function requireLabelEpics(method) {
162
+ if (config?.epics === 'native') {
163
+ throw new Error(
164
+ `github-issues does not support epics: "native" (${method}) — GitHub has no epic object; `
165
+ + `sub-issues may back one later. Set workspace.tracker.epics to "label" (or remove it) `
166
+ + `to use ${epicLabelPrefix}* labels.`,
167
+ );
168
+ }
169
+ }
170
+
171
+ function isEpicLabel(name) {
172
+ return name.startsWith(epicLabelPrefix) && name !== epicLabelPrefix;
173
+ }
174
+
175
+ async function listEpics({ state } = {}) {
176
+ // `state` exists for signature parity with native mode — a label has no
177
+ // state, so every epic label is listed.
178
+ requireLabelEpics('listEpics');
179
+ // `--paginate` concatenates raw JSON pages into invalid JSON; the `--jq`
180
+ // filter runs per page and streams names one per line, so both the walk
181
+ // and the parse stay well-formed. (`gh label list --limit N` silently
182
+ // caps at N — gh:195.)
183
+ const stdout = gh(['api', '--paginate', `repos/${repo}/labels`, '--jq', '.[].name']);
184
+ return stdout.split('\n')
185
+ .map((name) => name.trim())
186
+ .filter(isEpicLabel)
187
+ .map((name) => ({ name: name.slice(epicLabelPrefix.length), native: false }));
188
+ }
189
+
190
+ // GitHub treats label names case-insensitively, so epic names match the
191
+ // same way — and the label's own casing wins, so every later write uses
192
+ // the canonical form.
193
+ async function getEpic(name) {
194
+ const wanted = String(name).toLowerCase();
195
+ return (await listEpics()).find((e) => e.name.toLowerCase() === wanted) ?? null;
196
+ }
197
+
198
+ async function requireEpic(name) {
199
+ const epic = await getEpic(name);
200
+ if (!epic) {
201
+ throw new Error(`Unknown epic "${name}" — create it with createEpic() first (assignment never mints epics).`);
202
+ }
203
+ return epic;
204
+ }
205
+
206
+ async function createEpic({ name, description = '' } = {}) {
207
+ requireLabelEpics('createEpic');
208
+ if (!name) throw new Error('createEpic: name is required');
209
+ const existing = await getEpic(name);
210
+ if (existing) return existing;
211
+ const args = ['label', 'create', `${epicLabelPrefix}${name}`, '--repo', repo, '--color', EPIC_LABEL_COLOR];
212
+ if (description) args.push('--description', description);
213
+ gh(args);
214
+ return { name, native: false };
215
+ }
216
+
217
+ // Exactly one epic per issue: stale epic labels (anything that is not the
218
+ // target) drop and the target adds only when absent — one edit carries
219
+ // both halves, and null strips every epic label.
220
+ async function setIssueEpic(issueId, name) {
221
+ requireLabelEpics('setIssueEpic');
222
+ const num = parseIssueNumber(issueId);
223
+ let toAdd = null;
224
+ if (name !== null && name !== undefined) {
225
+ toAdd = `${epicLabelPrefix}${(await requireEpic(name)).name}`; // canonical casing
226
+ }
227
+ const issue = await getIssue(issueId);
228
+ const current = issue.labels.filter(isEpicLabel);
229
+ const stale = toAdd === null ? current : current.filter((l) => l !== toAdd);
230
+ const needsAdd = toAdd !== null && !current.includes(toAdd);
231
+ if (stale.length === 0 && !needsAdd) return;
232
+ const args = ['issue', 'edit', String(num), '--repo', repo];
233
+ for (const label of stale) args.push('--remove-label', label);
234
+ if (needsAdd) args.push('--add-label', toAdd);
235
+ gh(args);
236
+ }
237
+
238
+ async function listEpicIssues(name, { state = 'open' } = {}) {
239
+ requireLabelEpics('listEpicIssues');
240
+ if (!['open', 'closed', 'all'].includes(state)) {
241
+ throw new Error(`listEpicIssues: state must be "open", "closed" or "all" — got "${state}"`);
242
+ }
243
+ const epic = await requireEpic(name);
244
+ const stdout = gh([
245
+ 'issue', 'list', '--repo', repo, '--label', `${epicLabelPrefix}${epic.name}`,
246
+ '--state', state, '--limit', '1000', '--json', ISSUE_FIELDS,
247
+ ]);
248
+ return JSON.parse(stdout).map(normalize);
249
+ }
250
+
147
251
  return {
148
252
  listAssignedToMe,
149
253
  listUnassigned,
@@ -155,6 +259,11 @@ export function createGithubAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
155
259
  issueRef,
156
260
  ensureLabels,
157
261
  ensureMilestone,
262
+ listEpics,
263
+ getEpic,
264
+ createEpic,
265
+ setIssueEpic,
266
+ listEpicIssues,
158
267
  get identity() { return `github-issues:${repo}`; },
159
268
  };
160
269
  }
@@ -9,7 +9,7 @@
9
9
 
10
10
  import '../../lib/require-node.mjs';
11
11
  import { spawnSync as nodeSpawnSync } from 'node:child_process';
12
- import { AlreadyAssignedError } from './interface.mjs';
12
+ import { AlreadyAssignedError, resolveEpicLabelPrefix } from './interface.mjs';
13
13
 
14
14
  const GITLAB_DEFAULT_HOST = 'gitlab.com';
15
15
 
@@ -22,6 +22,12 @@ const STANDARD_LABELS = [
22
22
  { name: 'P3', color: '0e8a16' },
23
23
  ];
24
24
 
25
+ // Epic labels share one colour so they read as a family in the UI;
26
+ // createEpic never rewrites a label that already exists (there is no
27
+ // --force here anyway), so a team that recolours theirs keeps their
28
+ // choice. Native epics ignore it — GitLab owns their appearance.
29
+ const EPIC_LABEL_COLOR = '5319e7';
30
+
25
31
  export function createGitlabAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
26
32
  const repo = resolveRepo(config, spawnFn);
27
33
  const host = typeof config?.host === 'string' && config.host ? config.host : GITLAB_DEFAULT_HOST;
@@ -39,7 +45,12 @@ export function createGitlabAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
39
45
  stdio: input !== undefined ? ['pipe', 'pipe', 'pipe'] : ['inherit', 'pipe', 'pipe'],
40
46
  });
41
47
  if (result.status !== 0) {
42
- throw new Error(`glab ${args.join(' ')} failed: ${(result.stderr || '').trim()}`);
48
+ const err = new Error(`glab ${args.join(' ')} failed: ${(result.stderr || '').trim()}`);
49
+ // Callers that classify failures (nativeEpicCall) must look at the
50
+ // stderr alone — the argv in the message can contain numbers that
51
+ // merely look like status codes (an epic iid of 404, say).
52
+ err.stderr = (result.stderr || '').trim();
53
+ throw err;
43
54
  }
44
55
  return result.stdout || '';
45
56
  }
@@ -134,13 +145,23 @@ export function createGitlabAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
134
145
  return issue;
135
146
  }
136
147
 
148
+ // `glab issue create` prints the new issue's URL: older GitLab/glab says
149
+ // /-/issues/N, newer says /-/work_items/N (issues became work items
150
+ // under the hood) — the number is the same iid either way. If neither
151
+ // shape appears the issue still WAS created (glab exited 0 and the
152
+ // server accepted it), so the error must say so and carry glab's output
153
+ // — otherwise the caller retries and duplicates the issue.
137
154
  async function createIssue({ title, body = '', labels = [], milestone = null }) {
138
155
  const args = ['issue', 'create', '--repo', repo, '--title', title, '--description', body, '--yes'];
139
156
  if (labels.length > 0) args.push('--label', labels.join(','));
140
157
  if (milestone) args.push('--milestone', milestone);
141
158
  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()}`);
159
+ const m = stdout.match(/\/-\/(?:issues|work_items)\/(\d+)/);
160
+ if (!m) {
161
+ throw new Error(
162
+ `glab issue create succeeded, but the new issue's number could not be parsed from its output `
163
+ + `— the issue WAS created; do NOT retry (a retry would create a duplicate). glab output: ${stdout.trim()}`);
164
+ }
144
165
  return getIssue(`gl:${m[1]}`);
145
166
  }
146
167
 
@@ -162,7 +183,11 @@ export function createGitlabAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
162
183
 
163
184
  // The canonical URL for an issue — what a cross-forge reference needs
164
185
  // (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.
186
+ // the same host/repo every other URL here is built from. `/-/issues/N`
187
+ // stays the link form even though GitLab now serves issues at work_items
188
+ // URLs — those links still resolve — and every method but createIssue
189
+ // takes `gl:N` ids rather than parsing glab's printed URLs, so this is
190
+ // the only other place the URL shape is chosen, deliberately.
166
191
  function issueUrl(issueId) {
167
192
  const num = parseIssueNumber(issueId);
168
193
  return `https://${host}/${repo}/-/issues/${num}`;
@@ -208,6 +233,209 @@ export function createGitlabAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
208
233
  return normalizeMilestone(created);
209
234
  }
210
235
 
236
+ // ---- Epics --------------------------------------------------------------
237
+ // Label mode (default) mirrors github-issues: one `epicLabelPrefix + name`
238
+ // label per epic, exactly one epic per issue. Native mode
239
+ // (workspace.tracker.epics: "native") uses GitLab's group epics instead —
240
+ // the group is the project path minus its last segment — and needs
241
+ // Premium/Ultimate: without it the group-epic endpoints answer 403 (404
242
+ // when the group itself is missing or out of reach). Either must surface
243
+ // as a clear pointer back to label mode, never a silent fallback — a
244
+ // native-configured team that quietly got labels would have epics living
245
+ // in two places. (This speaks the epics REST API, deprecated in favor of
246
+ // the work-item epics API as of GitLab 17.0, because glab has no
247
+ // high-level epic commands; the endpoints still serve.)
248
+ const nativeEpics = config?.epics === 'native';
249
+ const epicLabelPrefix = resolveEpicLabelPrefix(config);
250
+
251
+ // Epics live at the group level in native mode; a single-segment project
252
+ // path has no group to hang them on.
253
+ function epicGroup() {
254
+ const segments = repo.split('/');
255
+ if (segments.length < 2) {
256
+ throw new Error(`cannot derive a group for native epics from project "${repo}" — epics live at the group level`);
257
+ }
258
+ return segments.slice(0, -1).join('/');
259
+ }
260
+
261
+ // Epic iids are unique only within their group, so native epic paths name
262
+ // the epic's group — its numeric id when the entity carries one (the
263
+ // listing and an issue's embedded epic both do), the configured group
264
+ // path otherwise.
265
+ function epicGroupRef(groupId) {
266
+ return groupId != null ? String(groupId) : encodeURIComponent(epicGroup());
267
+ }
268
+
269
+ function isEpicLabel(name) {
270
+ return name.startsWith(epicLabelPrefix) && name !== epicLabelPrefix;
271
+ }
272
+
273
+ // Runs a native-epic call and translates glab's 403/404 into an
274
+ // actionable error pointing at label mode; any other failure passes
275
+ // through untouched. Only the stderr is classified — the argv baked into
276
+ // the message can contain numbers that merely look like status codes.
277
+ function nativeEpicCall(run) {
278
+ try {
279
+ return run();
280
+ } catch (e) {
281
+ const where = e.stderr ?? e.message;
282
+ if (!/\b40[34]\b|Forbidden|Not Found/i.test(where)) throw e;
283
+ throw new Error(
284
+ `native epics are unavailable for group "${epicGroup()}" on ${host} — typically no `
285
+ + `Premium/Ultimate licence, or the group is missing. Set workspace.tracker.epics to "label" `
286
+ + `(or remove it) to use ${epicLabelPrefix}* labels instead. Underlying error: ${e.message}`,
287
+ );
288
+ }
289
+ }
290
+
291
+ const nativeEpicState = (raw) => (raw.state === 'closed' ? 'closed' : 'open');
292
+
293
+ async function listEpics({ state = 'open' } = {}) {
294
+ if (nativeEpics) {
295
+ // Descendants and ancestors excluded: an iid collision with another
296
+ // group's epic must never be mistaken for this group's epic. Closed
297
+ // epics drop out of the default listing — a done epic is not a
298
+ // picker choice — but stay reachable via state: "all".
299
+ const epics = nativeEpicCall(() => apiListAll(
300
+ `groups/${encodeURIComponent(epicGroup())}/epics`,
301
+ ['include_descendant_groups=false', 'include_ancestor_groups=false'],
302
+ ));
303
+ return epics
304
+ .map((e) => ({
305
+ name: e.title, id: e.iid, native: true, url: e.web_url,
306
+ state: nativeEpicState(e), groupId: e.group_id,
307
+ }))
308
+ .filter((e) => state === 'all' || e.state === state);
309
+ }
310
+ // Label mode has no epic state; the option exists for signature parity.
311
+ const labels = await apiListAll(`projects/${encodeURIComponent(repo)}/labels`);
312
+ return labels
313
+ .map((l) => l.name)
314
+ .filter(isEpicLabel)
315
+ .map((name) => ({ name: name.slice(epicLabelPrefix.length), native: false }));
316
+ }
317
+
318
+ // GitLab treats label names — and epic titles for our purposes —
319
+ // case-insensitively, so epic names match the same way; the existing
320
+ // object's own casing wins, so every later write uses the canonical form.
321
+ async function getEpic(name) {
322
+ const wanted = String(name).toLowerCase();
323
+ return (await listEpics({ state: 'all' }))
324
+ .find((e) => e.name.toLowerCase() === wanted) ?? null;
325
+ }
326
+
327
+ async function requireEpic(name) {
328
+ const epic = await getEpic(name);
329
+ if (!epic) {
330
+ throw new Error(`Unknown epic "${name}" — create it with createEpic() first (assignment never mints epics).`);
331
+ }
332
+ return epic;
333
+ }
334
+
335
+ async function createEpic({ name, description = '' } = {}) {
336
+ if (!name) throw new Error('createEpic: name is required');
337
+ const existing = await getEpic(name);
338
+ if (existing) return existing;
339
+ if (nativeEpics) {
340
+ const args = ['api', `groups/${encodeURIComponent(epicGroup())}/epics`, '-X', 'POST', '-f', `title=${name}`];
341
+ if (description) args.push('-f', `description=${description}`);
342
+ args.push('--hostname', host);
343
+ const raw = JSON.parse(nativeEpicCall(() => glab(args)));
344
+ return {
345
+ name: raw.title, id: raw.iid, native: true, url: raw.web_url,
346
+ state: nativeEpicState(raw), groupId: raw.group_id,
347
+ };
348
+ }
349
+ const args = ['label', 'create', '--repo', repo, '--name', `${epicLabelPrefix}${name}`, '--color', `#${EPIC_LABEL_COLOR}`];
350
+ if (description) args.push('--description', description);
351
+ glab(args);
352
+ return { name, native: false };
353
+ }
354
+
355
+ // Native identity is group + iid together — the same iid under another
356
+ // group is a different epic. Without a group_id on one side, the iid is
357
+ // all there is to compare.
358
+ function sameNativeEpic(rawEpic, epic) {
359
+ if (!rawEpic || rawEpic.iid !== epic.id) return false;
360
+ if (rawEpic.group_id != null && epic.groupId != null) return rawEpic.group_id === epic.groupId;
361
+ return true;
362
+ }
363
+
364
+ // Exactly one epic per issue, both modes. Label mode writes one atomic
365
+ // PUT — add_labels/remove_labels change only the named labels, so a
366
+ // concurrent label edit on the same issue is not clobbered the way a
367
+ // read-modify-write of the full set would be; the read happens only for
368
+ // no-op detection and to learn which epic labels to drop. Native mode
369
+ // assigns through the epic-issue endpoints: assigning POSTs the issue's
370
+ // global id (GitLab unassigns its previous epic server-side), and
371
+ // unassigning DELETEs the association id, which only the epic's own
372
+ // issue list carries.
373
+ async function setIssueEpic(issueId, name) {
374
+ const num = parseIssueNumber(issueId);
375
+ const epic = name === null || name === undefined ? null : await requireEpic(name);
376
+ const raw = JSON.parse(glab(['issue', 'view', String(num), '--repo', repo, '-F', 'json']));
377
+
378
+ if (nativeEpics) {
379
+ if (epic) {
380
+ if (sameNativeEpic(raw.epic, epic)) return; // already there
381
+ nativeEpicCall(() => glab([
382
+ 'api', `groups/${epicGroupRef(epic.groupId)}/epics/${epic.id}/issues/${raw.id}`,
383
+ '-X', 'POST', '--hostname', host,
384
+ ]));
385
+ return;
386
+ }
387
+ // The current epic may belong to an ancestor group — its own group,
388
+ // not the tracker's, is where the association lives.
389
+ const current = raw.epic ?? (raw.epic_iid != null ? { iid: raw.epic_iid } : null);
390
+ if (!current) return;
391
+ const groupRef = epicGroupRef(current.group_id);
392
+ nativeEpicCall(() => {
393
+ const members = apiListAll(`groups/${groupRef}/epics/${current.iid}/issues`);
394
+ const association = members.find((i) => i.id === raw.id);
395
+ if (association) {
396
+ glab([
397
+ 'api', `groups/${groupRef}/epics/${current.iid}/issues/${association.epic_issue_id}`,
398
+ '-X', 'DELETE', '--hostname', host,
399
+ ]);
400
+ }
401
+ });
402
+ return;
403
+ }
404
+
405
+ const current = (raw.labels || []).map((l) => (typeof l === 'string' ? l : l.name));
406
+ const epicLabels = current.filter(isEpicLabel);
407
+ const toAdd = epic === null ? null : `${epicLabelPrefix}${epic.name}`; // canonical casing
408
+ const remove = toAdd === null ? epicLabels : epicLabels.filter((l) => l !== toAdd);
409
+ const add = toAdd !== null && !current.includes(toAdd) ? toAdd : null;
410
+ if (remove.length === 0 && add === null) return;
411
+ const args = ['api', `projects/${encodeURIComponent(repo)}/issues/${num}`, '-X', 'PUT'];
412
+ if (remove.length > 0) args.push('-f', `remove_labels=${remove.join(',')}`);
413
+ if (add !== null) args.push('-f', `add_labels=${add}`);
414
+ args.push('--hostname', host);
415
+ glab(args);
416
+ }
417
+
418
+ async function listEpicIssues(name, { state = 'open' } = {}) {
419
+ if (!['open', 'closed', 'all'].includes(state)) {
420
+ throw new Error(`listEpicIssues: state must be "open", "closed" or "all" — got "${state}"`);
421
+ }
422
+ const epic = await requireEpic(name);
423
+ // The API spells it "opened"; closed and all pass through as-is.
424
+ const gitlabState = state === 'open' ? 'opened' : state;
425
+ if (nativeEpics) {
426
+ // The epic-issue list endpoint has no state filter of its own.
427
+ const members = nativeEpicCall(() =>
428
+ apiListAll(`groups/${epicGroupRef(epic.groupId)}/epics/${epic.id}/issues`));
429
+ return members
430
+ .filter((i) => state === 'all' || i.state === gitlabState)
431
+ .map(normalize);
432
+ }
433
+ return apiListAll(
434
+ `projects/${encodeURIComponent(repo)}/issues`,
435
+ [`labels=${encodeURIComponent(`${epicLabelPrefix}${epic.name}`)}`, `state=${gitlabState}`],
436
+ ).map(normalize);
437
+ }
438
+
211
439
  return {
212
440
  listAssignedToMe,
213
441
  listUnassigned,
@@ -220,6 +448,11 @@ export function createGitlabAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
220
448
  issueUrl,
221
449
  ensureLabels,
222
450
  ensureMilestone,
451
+ listEpics,
452
+ getEpic,
453
+ createEpic,
454
+ setIssueEpic,
455
+ listEpicIssues,
223
456
  get identity() { return `gitlab-issues:${repo}`; },
224
457
  };
225
458
  }