@ulysses-ai/create-workspace 0.23.3-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.
- package/package.json +2 -1
- package/template/CLAUDE.md.tmpl +2 -2
- package/template/_claude/hooks/_utils.mjs +43 -0
- package/template/_claude/hooks/session-start.mjs +10 -0
- package/template/_claude/rules/work-item-tracking.md +6 -3
- package/template/_claude/rules/workspace-structure.md +1 -1
- package/template/_claude/scripts/chat-record.mjs +43 -26
- package/template/_claude/scripts/classify-update.mjs +194 -32
- package/template/_claude/scripts/forges/github.mjs +51 -1
- package/template/_claude/scripts/forges/gitlab.mjs +69 -0
- package/template/_claude/scripts/forges/interface.mjs +19 -2
- package/template/_claude/scripts/maintenance-audit.mjs +325 -3
- package/template/_claude/scripts/merge-mode.mjs +22 -1
- package/template/_claude/scripts/release-leak-audit.mjs +480 -0
- package/template/_claude/scripts/task-pr.mjs +316 -72
- package/template/_claude/scripts/task-worktree.mjs +9 -1
- package/template/_claude/scripts/template-merge.mjs +21 -4
- package/template/_claude/scripts/template-modifications.mjs +152 -0
- package/template/_claude/scripts/trackers/github-issues.mjs +110 -1
- package/template/_claude/scripts/trackers/gitlab-issues.mjs +221 -2
- package/template/_claude/scripts/trackers/interface.mjs +47 -0
- package/template/_claude/skills/braindump/SKILL.md +1 -1
- package/template/_claude/skills/complete-work/SKILL.md +22 -12
- package/template/_claude/skills/handoff/SKILL.md +1 -1
- package/template/_claude/skills/maintenance/SKILL.md +8 -3
- package/template/_claude/skills/release/SKILL.md +20 -6
- package/template/_claude/skills/setup-tracker/SKILL.md +24 -1
- package/template/_claude/skills/start-work/SKILL.md +23 -5
- package/template/_claude/skills/workspace-update/SKILL.md +23 -15
- package/template/workspace.json.tmpl +2 -1
- package/lib/init.test.mjs +0 -75
- package/lib/payload.test.mjs +0 -210
- package/lib/scaffold.test.mjs +0 -79
- 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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|
|
@@ -222,6 +233,209 @@ export function createGitlabAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
|
|
|
222
233
|
return normalizeMilestone(created);
|
|
223
234
|
}
|
|
224
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
|
+
|
|
225
439
|
return {
|
|
226
440
|
listAssignedToMe,
|
|
227
441
|
listUnassigned,
|
|
@@ -234,6 +448,11 @@ export function createGitlabAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
|
|
|
234
448
|
issueUrl,
|
|
235
449
|
ensureLabels,
|
|
236
450
|
ensureMilestone,
|
|
451
|
+
listEpics,
|
|
452
|
+
getEpic,
|
|
453
|
+
createEpic,
|
|
454
|
+
setIssueEpic,
|
|
455
|
+
listEpicIssues,
|
|
237
456
|
get identity() { return `gitlab-issues:${repo}`; },
|
|
238
457
|
};
|
|
239
458
|
}
|
|
@@ -8,6 +8,39 @@
|
|
|
8
8
|
// defaults to) this adapter's own repo, "owner/repo#N" otherwise —
|
|
9
9
|
// only the first form closes an issue in the PR's own repo, so a PR
|
|
10
10
|
// in any other repo must name the tracker's repo explicitly.
|
|
11
|
+
//
|
|
12
|
+
// Epic method contract (gh:195) — every adapter implements all five:
|
|
13
|
+
//
|
|
14
|
+
// listEpics({ state } = { state: 'open' }) → Epic[] — { name, id?,
|
|
15
|
+
// native, url?, state? }. `id`, `state` and `groupId` only exist in
|
|
16
|
+
// native mode; a closed GitLab epic is filtered out unless state is
|
|
17
|
+
// "all". Label mode has no epic state and ignores the option.
|
|
18
|
+
// getEpic(name) → the Epic, or null when absent. Names match
|
|
19
|
+
// case-insensitively — both forges treat labels that way — and the
|
|
20
|
+
// returned name is the label's own casing.
|
|
21
|
+
// createEpic({ name, description? }) → the Epic, creating only when no
|
|
22
|
+
// epic of that name exists in any casing (idempotent) — epics are
|
|
23
|
+
// created deliberately, never as a side effect of assigning an issue.
|
|
24
|
+
// setIssueEpic(issueId, name | null)
|
|
25
|
+
// An issue carries exactly one epic: assigning replaces any current
|
|
26
|
+
// epic, null removes it. An unknown name throws — a typo must never
|
|
27
|
+
// mint an epic.
|
|
28
|
+
// listEpicIssues(name, { state }) → Issue[], state "open" | "closed" |
|
|
29
|
+
// "all" (default open; anything else throws).
|
|
30
|
+
//
|
|
31
|
+
// Modes: label mode (default, both adapters) represents an epic as the
|
|
32
|
+
// label `epicLabelPrefix + name` — default prefix "epic:", configurable
|
|
33
|
+
// via workspace.tracker.epicLabelPrefix, which must end with a delimiter
|
|
34
|
+
// (resolveEpicLabelPrefix enforces that) — carried by every issue in the
|
|
35
|
+
// epic. Native mode (GitLab only, workspace.tracker.epics: "native") uses
|
|
36
|
+
// group epics and needs Premium/Ultimate; without it the group-epic
|
|
37
|
+
// endpoints answer 403/404, which must surface as a clear error pointing
|
|
38
|
+
// at label mode — never a silent fallback, or epics would silently live
|
|
39
|
+
// in two places. Epic iids are unique only within their group, so native
|
|
40
|
+
// epics are pinned to the tracker group's own (descendant and ancestor
|
|
41
|
+
// groups excluded) and matched by group and iid together. GitHub has no
|
|
42
|
+
// native epic object: `epics: "native"` throws from every epic method
|
|
43
|
+
// there (sub-issues may back one later).
|
|
11
44
|
|
|
12
45
|
import '../../lib/require-node.mjs';
|
|
13
46
|
import { createGithubAdapter } from './github-issues.mjs';
|
|
@@ -22,6 +55,20 @@ export class AlreadyAssignedError extends Error {
|
|
|
22
55
|
}
|
|
23
56
|
}
|
|
24
57
|
|
|
58
|
+
// Shared epic-label-prefix resolution (gh:195): `epicLabelPrefix` from the
|
|
59
|
+
// tracker config, defaulting to "epic:". A prefix must end with a delimiter
|
|
60
|
+
// (":", "-", "/") — a trailing alphanumeric would slice epic names at an
|
|
61
|
+
// arbitrary character and make label↔name mapping ambiguous.
|
|
62
|
+
export function resolveEpicLabelPrefix(config) {
|
|
63
|
+
const prefix = typeof config?.epicLabelPrefix === 'string' && config.epicLabelPrefix
|
|
64
|
+
? config.epicLabelPrefix
|
|
65
|
+
: 'epic:';
|
|
66
|
+
if (/[a-z0-9]$/i.test(prefix)) {
|
|
67
|
+
throw new Error(`tracker.epicLabelPrefix "${prefix}" must end with a delimiter such as ":" or "-"`);
|
|
68
|
+
}
|
|
69
|
+
return prefix;
|
|
70
|
+
}
|
|
71
|
+
|
|
25
72
|
export function createTracker(config, options = {}) {
|
|
26
73
|
if (!config || typeof config !== 'object') {
|
|
27
74
|
throw new Error('No tracker configured — pass workspace.json\'s workspace.tracker block.');
|