@ulysses-ai/create-workspace 0.15.0-beta.0 → 0.15.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/init.mjs +12 -25
- package/lib/scaffold.mjs +3 -2
- package/package.json +1 -1
- package/template/.claude/rules/memory-guidance.md +30 -0
- package/template/.claude/scripts/build-workspace-context.mjs +370 -23
- package/template/.claude/scripts/migrate-canonical-priority.mjs +108 -0
- package/template/.claude/skills/complete-work/SKILL.md +88 -0
- package/template/.claude/skills/maintenance/SKILL.md +79 -11
- package/template/.claude/skills/release/SKILL.md +3 -0
- package/template/.claude/skills/workspace-update/SKILL.md +7 -1
- package/template/workspace.json.tmpl +1 -0
- package/template/.claude/hooks/_bash-output-advisory.test.mjs +0 -88
- package/template/.claude/hooks/_utils.test.mjs +0 -99
- package/template/.claude/lib/freshness.test.mjs +0 -175
- package/template/.claude/lib/registry-check.test.mjs +0 -130
- package/template/.claude/lib/session-frontmatter.test.mjs +0 -242
- package/template/.claude/scripts/build-workspace-context.test.mjs +0 -633
- package/template/.claude/scripts/capture-context.test.mjs +0 -383
- package/template/.claude/scripts/generate-claude-local.test.mjs +0 -184
- package/template/.claude/scripts/migrate-claude-md-freshness-include.test.mjs +0 -54
- package/template/.claude/scripts/migrate-session-layout.test.mjs +0 -144
- package/template/.claude/scripts/migrate-to-workspace-context.test.mjs +0 -325
- package/template/.claude/scripts/sweep-references.test.mjs +0 -184
- package/template/.claude/scripts/sync-tasks.test.mjs +0 -350
- package/template/.claude/scripts/trackers/github-issues.test.mjs +0 -190
- package/template/.claude/scripts/trackers/interface.test.mjs +0 -40
|
@@ -1,350 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Unit tests for sync-tasks.mjs
|
|
3
|
-
// Run: node template/.claude/scripts/sync-tasks.test.mjs
|
|
4
|
-
import { toActiveForm } from './sync-tasks.mjs';
|
|
5
|
-
|
|
6
|
-
let failed = 0;
|
|
7
|
-
let passed = 0;
|
|
8
|
-
|
|
9
|
-
function assert(cond, msg) {
|
|
10
|
-
if (cond) { passed++; } else { failed++; console.error(` FAIL: ${msg}`); }
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function assertEq(actual, expected, msg) {
|
|
14
|
-
const a = JSON.stringify(actual);
|
|
15
|
-
const e = JSON.stringify(expected);
|
|
16
|
-
if (a === e) { passed++; } else {
|
|
17
|
-
failed++;
|
|
18
|
-
console.error(` FAIL: ${msg}\n expected: ${e}\n actual: ${a}`);
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
console.log('# toActiveForm');
|
|
23
|
-
assertEq(toActiveForm('Start work'), 'Starting work', 'simple verb');
|
|
24
|
-
assertEq(toActiveForm('Write fix and test'), 'Writing fix and test', 'e-drop');
|
|
25
|
-
assertEq(toActiveForm('Run the tests'), 'Running the tests', 'double consonant');
|
|
26
|
-
assertEq(toActiveForm('Identify race condition'), 'Identifying race condition', 'y-keep');
|
|
27
|
-
assertEq(toActiveForm('Complete work'), 'Completing work', 'e-drop on Complete');
|
|
28
|
-
assertEq(toActiveForm('Reproduce on iOS Safari'), 'Reproducing on iOS Safari', 'e-drop multi-word');
|
|
29
|
-
assertEq(toActiveForm('Fix bug'), 'Fixing bug', 'simple');
|
|
30
|
-
|
|
31
|
-
import { parseTasksSection } from './sync-tasks.mjs';
|
|
32
|
-
|
|
33
|
-
const SAMPLE_WITH_TASKS = `---
|
|
34
|
-
type: session-tracker
|
|
35
|
-
name: demo
|
|
36
|
-
---
|
|
37
|
-
|
|
38
|
-
# Work Session
|
|
39
|
-
|
|
40
|
-
## Tasks
|
|
41
|
-
|
|
42
|
-
> Linked: gh:42 — Auth timeout on mobile
|
|
43
|
-
|
|
44
|
-
- [x] Start work
|
|
45
|
-
- [x] Reproduce on iOS Safari
|
|
46
|
-
- [ ] Identify race condition
|
|
47
|
-
- [ ] Complete work
|
|
48
|
-
|
|
49
|
-
## Progress
|
|
50
|
-
|
|
51
|
-
(stuff)
|
|
52
|
-
`;
|
|
53
|
-
|
|
54
|
-
const SAMPLE_NO_TASKS = `---
|
|
55
|
-
type: session-tracker
|
|
56
|
-
name: demo
|
|
57
|
-
---
|
|
58
|
-
|
|
59
|
-
# Work Session
|
|
60
|
-
|
|
61
|
-
## Progress
|
|
62
|
-
|
|
63
|
-
(stuff)
|
|
64
|
-
`;
|
|
65
|
-
|
|
66
|
-
const SAMPLE_NO_LINK = `---
|
|
67
|
-
type: session-tracker
|
|
68
|
-
name: demo
|
|
69
|
-
---
|
|
70
|
-
|
|
71
|
-
## Tasks
|
|
72
|
-
|
|
73
|
-
- [x] Start work
|
|
74
|
-
- [ ] Complete work
|
|
75
|
-
`;
|
|
76
|
-
|
|
77
|
-
console.log('\n# parseTasksSection');
|
|
78
|
-
assertEq(
|
|
79
|
-
parseTasksSection(SAMPLE_WITH_TASKS).linked,
|
|
80
|
-
{ id: 'gh:42', title: 'Auth timeout on mobile' },
|
|
81
|
-
'parses linked blockquote'
|
|
82
|
-
);
|
|
83
|
-
assertEq(
|
|
84
|
-
parseTasksSection(SAMPLE_WITH_TASKS).todos.length,
|
|
85
|
-
4,
|
|
86
|
-
'parses 4 todos'
|
|
87
|
-
);
|
|
88
|
-
assertEq(
|
|
89
|
-
parseTasksSection(SAMPLE_WITH_TASKS).todos[0],
|
|
90
|
-
{ content: 'Start work', activeForm: 'Starting work', status: 'completed' },
|
|
91
|
-
'first todo completed'
|
|
92
|
-
);
|
|
93
|
-
assertEq(
|
|
94
|
-
parseTasksSection(SAMPLE_WITH_TASKS).todos[2],
|
|
95
|
-
{ content: 'Identify race condition', activeForm: 'Identifying race condition', status: 'pending' },
|
|
96
|
-
'pending todo'
|
|
97
|
-
);
|
|
98
|
-
assertEq(
|
|
99
|
-
parseTasksSection(SAMPLE_NO_TASKS),
|
|
100
|
-
{ linked: null, todos: [] },
|
|
101
|
-
'missing section returns empty'
|
|
102
|
-
);
|
|
103
|
-
assertEq(
|
|
104
|
-
parseTasksSection(SAMPLE_NO_LINK).linked,
|
|
105
|
-
null,
|
|
106
|
-
'no blockquote → linked: null'
|
|
107
|
-
);
|
|
108
|
-
assertEq(
|
|
109
|
-
parseTasksSection(SAMPLE_NO_LINK).todos.length,
|
|
110
|
-
2,
|
|
111
|
-
'no blockquote → still parses todos'
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
import { renderTasksSection, enforceBookends } from './sync-tasks.mjs';
|
|
115
|
-
|
|
116
|
-
console.log('\n# enforceBookends');
|
|
117
|
-
assertEq(
|
|
118
|
-
enforceBookends([]).map(t => t.content),
|
|
119
|
-
['Start work', 'Complete work'],
|
|
120
|
-
'empty list → bookends inserted'
|
|
121
|
-
);
|
|
122
|
-
assertEq(
|
|
123
|
-
enforceBookends([
|
|
124
|
-
{ content: 'Do thing', activeForm: 'Doing thing', status: 'pending' },
|
|
125
|
-
]).map(t => t.content),
|
|
126
|
-
['Start work', 'Do thing', 'Complete work'],
|
|
127
|
-
'middle task gets wrapped in bookends'
|
|
128
|
-
);
|
|
129
|
-
assertEq(
|
|
130
|
-
enforceBookends([
|
|
131
|
-
{ content: 'Complete work', activeForm: 'Completing work', status: 'pending' },
|
|
132
|
-
{ content: 'Do thing', activeForm: 'Doing thing', status: 'pending' },
|
|
133
|
-
{ content: 'Start work', activeForm: 'Starting work', status: 'completed' },
|
|
134
|
-
]).map(t => t.content),
|
|
135
|
-
['Start work', 'Do thing', 'Complete work'],
|
|
136
|
-
'misplaced bookends moved to ends'
|
|
137
|
-
);
|
|
138
|
-
assertEq(
|
|
139
|
-
enforceBookends([{ content: 'Start work', activeForm: 'Starting work', status: 'completed' }])[0].status,
|
|
140
|
-
'completed',
|
|
141
|
-
'preserves Start work status when present'
|
|
142
|
-
);
|
|
143
|
-
assertEq(
|
|
144
|
-
enforceBookends([])[0].status,
|
|
145
|
-
'completed',
|
|
146
|
-
'inserted Start work defaults to completed'
|
|
147
|
-
);
|
|
148
|
-
assertEq(
|
|
149
|
-
enforceBookends([])[1].status,
|
|
150
|
-
'pending',
|
|
151
|
-
'inserted Complete work defaults to pending'
|
|
152
|
-
);
|
|
153
|
-
|
|
154
|
-
console.log('\n# renderTasksSection');
|
|
155
|
-
assertEq(
|
|
156
|
-
renderTasksSection({
|
|
157
|
-
linked: null,
|
|
158
|
-
todos: [
|
|
159
|
-
{ content: 'Start work', activeForm: 'Starting work', status: 'completed' },
|
|
160
|
-
{ content: 'Do thing', activeForm: 'Doing thing', status: 'pending' },
|
|
161
|
-
{ content: 'Complete work', activeForm: 'Completing work', status: 'pending' },
|
|
162
|
-
],
|
|
163
|
-
}),
|
|
164
|
-
'## Tasks\n\n- [x] Start work\n- [ ] Do thing\n- [ ] Complete work\n',
|
|
165
|
-
'no link → no blockquote'
|
|
166
|
-
);
|
|
167
|
-
assertEq(
|
|
168
|
-
renderTasksSection({
|
|
169
|
-
linked: { id: 'gh:42', title: 'Auth timeout on mobile' },
|
|
170
|
-
todos: [
|
|
171
|
-
{ content: 'Start work', activeForm: 'Starting work', status: 'completed' },
|
|
172
|
-
{ content: 'Complete work', activeForm: 'Completing work', status: 'pending' },
|
|
173
|
-
],
|
|
174
|
-
}),
|
|
175
|
-
'## Tasks\n\n> Linked: gh:42 — Auth timeout on mobile\n\n- [x] Start work\n- [ ] Complete work\n',
|
|
176
|
-
'with link → blockquote rendered'
|
|
177
|
-
);
|
|
178
|
-
assertEq(
|
|
179
|
-
renderTasksSection({
|
|
180
|
-
linked: { id: 'gh:42', title: null },
|
|
181
|
-
todos: [
|
|
182
|
-
{ content: 'Start work', activeForm: 'Starting work', status: 'completed' },
|
|
183
|
-
{ content: 'Complete work', activeForm: 'Completing work', status: 'pending' },
|
|
184
|
-
],
|
|
185
|
-
}),
|
|
186
|
-
'## Tasks\n\n> Linked: gh:42\n\n- [x] Start work\n- [ ] Complete work\n',
|
|
187
|
-
'link with null title → bare ID'
|
|
188
|
-
);
|
|
189
|
-
|
|
190
|
-
import { writeTasksToSession } from './sync-tasks.mjs';
|
|
191
|
-
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'fs';
|
|
192
|
-
import { tmpdir } from 'os';
|
|
193
|
-
import { join } from 'path';
|
|
194
|
-
|
|
195
|
-
console.log('\n# writeTasksToSession');
|
|
196
|
-
|
|
197
|
-
function withTempSession(initialContent, fn) {
|
|
198
|
-
const dir = mkdtempSync(join(tmpdir(), 'sync-tasks-'));
|
|
199
|
-
const file = join(dir, 'session.md');
|
|
200
|
-
writeFileSync(file, initialContent);
|
|
201
|
-
try { fn(file); } finally { rmSync(dir, { recursive: true, force: true }); }
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
const FRESH_SESSION = `---
|
|
205
|
-
type: session-tracker
|
|
206
|
-
name: demo
|
|
207
|
-
workItem: gh:42
|
|
208
|
-
---
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
# Work Session: demo
|
|
212
|
-
|
|
213
|
-
description here.
|
|
214
|
-
|
|
215
|
-
## Progress
|
|
216
|
-
|
|
217
|
-
(Updated as the session progresses)
|
|
218
|
-
`;
|
|
219
|
-
|
|
220
|
-
withTempSession(FRESH_SESSION, (file) => {
|
|
221
|
-
writeTasksToSession(file, {
|
|
222
|
-
linked: { id: 'gh:42', title: 'Auth timeout on mobile' },
|
|
223
|
-
todos: [],
|
|
224
|
-
});
|
|
225
|
-
const after = readFileSync(file, 'utf-8');
|
|
226
|
-
assert(after.includes('## Tasks'), 'inserts ## Tasks section');
|
|
227
|
-
assert(after.includes('> Linked: gh:42 — Auth timeout on mobile'), 'inserts blockquote');
|
|
228
|
-
assert(after.includes('- [x] Start work'), 'inserts Start work bookend');
|
|
229
|
-
assert(after.includes('- [ ] Complete work'), 'inserts Complete work bookend');
|
|
230
|
-
assert(after.includes('## Progress'), 'preserves Progress heading');
|
|
231
|
-
assert(after.includes('(Updated as the session progresses)'), 'preserves Progress body');
|
|
232
|
-
assert(after.startsWith('---\ntype: session-tracker'), 'preserves frontmatter');
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
const SESSION_WITH_TASKS = `---
|
|
236
|
-
type: session-tracker
|
|
237
|
-
name: demo
|
|
238
|
-
---
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
# Work Session: demo
|
|
242
|
-
|
|
243
|
-
## Tasks
|
|
244
|
-
|
|
245
|
-
- [x] Start work
|
|
246
|
-
- [ ] Old task
|
|
247
|
-
- [ ] Complete work
|
|
248
|
-
|
|
249
|
-
## Progress
|
|
250
|
-
|
|
251
|
-
original progress text
|
|
252
|
-
`;
|
|
253
|
-
|
|
254
|
-
withTempSession(SESSION_WITH_TASKS, (file) => {
|
|
255
|
-
writeTasksToSession(file, {
|
|
256
|
-
linked: null,
|
|
257
|
-
todos: [
|
|
258
|
-
{ content: 'Start work', activeForm: 'Starting work', status: 'completed' },
|
|
259
|
-
{ content: 'New task', activeForm: 'Doing new task', status: 'in_progress' },
|
|
260
|
-
{ content: 'Complete work', activeForm: 'Completing work', status: 'pending' },
|
|
261
|
-
],
|
|
262
|
-
});
|
|
263
|
-
const after = readFileSync(file, 'utf-8');
|
|
264
|
-
assert(after.includes('- [-] New task'), 'replaced with new task (in_progress → [-])');
|
|
265
|
-
assert(!after.includes('- [ ] Old task'), 'old task removed');
|
|
266
|
-
assert(after.includes('original progress text'), 'preserves Progress body');
|
|
267
|
-
const taskHeadingCount = (after.match(/^## Tasks$/gm) || []).length;
|
|
268
|
-
assertEq(taskHeadingCount, 1, 'exactly one ## Tasks section after rewrite');
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
withTempSession(FRESH_SESSION, (file) => {
|
|
272
|
-
const input = {
|
|
273
|
-
linked: { id: 'gh:42', title: 'Auth timeout on mobile' },
|
|
274
|
-
todos: [
|
|
275
|
-
{ content: 'Reproduce', activeForm: 'Reproducing', status: 'completed' },
|
|
276
|
-
{ content: 'Fix it', activeForm: 'Fixing it', status: 'pending' },
|
|
277
|
-
],
|
|
278
|
-
};
|
|
279
|
-
writeTasksToSession(file, input);
|
|
280
|
-
const round = parseTasksSection(readFileSync(file, 'utf-8'));
|
|
281
|
-
assertEq(round.linked, input.linked, 'round-trip linked');
|
|
282
|
-
assertEq(round.todos.length, 4, 'round-trip todos length (with bookends)');
|
|
283
|
-
assertEq(round.todos[1].content, 'Reproduce', 'round-trip middle task content');
|
|
284
|
-
assertEq(round.todos[2].status, 'pending', 'round-trip middle task status');
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
console.log('\n# in_progress round-trip');
|
|
288
|
-
|
|
289
|
-
withTempSession(FRESH_SESSION, (file) => {
|
|
290
|
-
const input = {
|
|
291
|
-
linked: null,
|
|
292
|
-
todos: [
|
|
293
|
-
{ content: 'Doing thing', activeForm: 'Doing thing', status: 'in_progress' },
|
|
294
|
-
],
|
|
295
|
-
};
|
|
296
|
-
writeTasksToSession(file, input);
|
|
297
|
-
const after = readFileSync(file, 'utf-8');
|
|
298
|
-
assert(after.includes('- [-] Doing thing'), 'in_progress renders as [-]');
|
|
299
|
-
const round = parseTasksSection(after);
|
|
300
|
-
const middle = round.todos.find(t => t.content === 'Doing thing');
|
|
301
|
-
assertEq(middle.status, 'in_progress', 'in_progress round-trips losslessly');
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
import { execFileSync } from 'child_process';
|
|
305
|
-
import { fileURLToPath } from 'url';
|
|
306
|
-
import { dirname } from 'path';
|
|
307
|
-
|
|
308
|
-
const SCRIPT_PATH = join(dirname(fileURLToPath(import.meta.url)), 'sync-tasks.mjs');
|
|
309
|
-
|
|
310
|
-
console.log('\n# CLI');
|
|
311
|
-
|
|
312
|
-
withTempSession(FRESH_SESSION, (file) => {
|
|
313
|
-
const input = JSON.stringify({
|
|
314
|
-
todos: [
|
|
315
|
-
{ content: 'Start work', activeForm: 'Starting work', status: 'completed' },
|
|
316
|
-
{ content: 'Test thing', activeForm: 'Testing thing', status: 'in_progress' },
|
|
317
|
-
{ content: 'Complete work', activeForm: 'Completing work', status: 'pending' },
|
|
318
|
-
],
|
|
319
|
-
});
|
|
320
|
-
execFileSync('node', [SCRIPT_PATH, '--write', file], {
|
|
321
|
-
input,
|
|
322
|
-
encoding: 'utf-8',
|
|
323
|
-
});
|
|
324
|
-
const written = readFileSync(file, 'utf-8');
|
|
325
|
-
assert(written.includes('- [-] Test thing'), 'CLI --write rendered task (in_progress → [-])');
|
|
326
|
-
|
|
327
|
-
const out = execFileSync('node', [SCRIPT_PATH, '--read', file], {
|
|
328
|
-
encoding: 'utf-8',
|
|
329
|
-
});
|
|
330
|
-
const parsed = JSON.parse(out);
|
|
331
|
-
assertEq(parsed.todos.length, 3, 'CLI --read returns 3 todos');
|
|
332
|
-
assertEq(parsed.todos[1].content, 'Test thing', 'CLI --read content');
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
withTempSession('not a session file\n', (file) => {
|
|
336
|
-
let threw = false;
|
|
337
|
-
try {
|
|
338
|
-
execFileSync('node', [SCRIPT_PATH, '--read', file], {
|
|
339
|
-
encoding: 'utf-8',
|
|
340
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
341
|
-
});
|
|
342
|
-
} catch (e) {
|
|
343
|
-
threw = true;
|
|
344
|
-
assert(e.stderr.includes('Not a session') || e.stderr.includes('frontmatter'), 'errors on non-session file');
|
|
345
|
-
}
|
|
346
|
-
assert(threw, 'CLI throws on non-session file');
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
console.log(`\n${passed} passed, ${failed} failed`);
|
|
350
|
-
process.exit(failed > 0 ? 1 : 0);
|
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Tests for the GitHub Issues adapter. Uses an injected spawnFn to mock gh calls.
|
|
3
|
-
// Run: node .claude/scripts/trackers/github-issues.test.mjs
|
|
4
|
-
import { createTracker, AlreadyAssignedError } from './interface.mjs';
|
|
5
|
-
|
|
6
|
-
let failed = 0, passed = 0;
|
|
7
|
-
const ok = () => { passed++; };
|
|
8
|
-
const fail = (msg) => { failed++; console.error(` FAIL: ${msg}`); };
|
|
9
|
-
|
|
10
|
-
// Build a spawnFn that returns canned responses keyed by argv.
|
|
11
|
-
function buildSpawn(responses) {
|
|
12
|
-
const calls = [];
|
|
13
|
-
const fn = (cmd, args, options) => {
|
|
14
|
-
calls.push({ cmd, args, input: options?.input });
|
|
15
|
-
const key = args.join(' ');
|
|
16
|
-
const resp = responses[key];
|
|
17
|
-
if (!resp) {
|
|
18
|
-
return { status: 1, stdout: '', stderr: `no mock for: ${cmd} ${key}` };
|
|
19
|
-
}
|
|
20
|
-
return { status: 0, stdout: resp, stderr: '' };
|
|
21
|
-
};
|
|
22
|
-
fn.calls = calls;
|
|
23
|
-
return fn;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// listAssignedToMe normalizes JSON into Issue[].
|
|
27
|
-
{
|
|
28
|
-
const spawnFn = buildSpawn({
|
|
29
|
-
'api user --jq .login': 'alice\n',
|
|
30
|
-
'issue list --repo foo/bar --assignee alice --state open --json number,title,body,state,assignees,labels,milestone,url,createdAt,updatedAt':
|
|
31
|
-
JSON.stringify([{ number: 1, title: 'Fix bug', body: 'details', state: 'OPEN', assignees: [{ login: 'alice' }], labels: [{ name: 'bug' }], milestone: { title: 'v0.1' }, url: 'https://github.com/foo/bar/issues/1', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-02T00:00:00Z' }]),
|
|
32
|
-
});
|
|
33
|
-
const t = createTracker({ type: 'github-issues', repo: 'foo/bar' }, { spawnFn });
|
|
34
|
-
const issues = await t.listAssignedToMe();
|
|
35
|
-
if (issues.length === 1 && issues[0].id === 'gh:1' && issues[0].assignees[0] === 'alice'
|
|
36
|
-
&& issues[0].labels[0] === 'bug' && issues[0].milestone === 'v0.1') ok();
|
|
37
|
-
else fail(`listAssignedToMe normalization wrong: ${JSON.stringify(issues)}`);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// listUnassigned uses no:assignee search.
|
|
41
|
-
{
|
|
42
|
-
const spawnFn = buildSpawn({
|
|
43
|
-
'api user --jq .login': 'alice\n',
|
|
44
|
-
'issue list --repo foo/bar --search no:assignee --state open --json number,title,body,state,assignees,labels,milestone,url,createdAt,updatedAt':
|
|
45
|
-
JSON.stringify([{ number: 2, title: 'Open work', body: '', state: 'OPEN', assignees: [], labels: [], milestone: null, url: 'https://github.com/foo/bar/issues/2', createdAt: '2026-01-03T00:00:00Z', updatedAt: '2026-01-03T00:00:00Z' }]),
|
|
46
|
-
});
|
|
47
|
-
const t = createTracker({ type: 'github-issues', repo: 'foo/bar' }, { spawnFn });
|
|
48
|
-
const issues = await t.listUnassigned();
|
|
49
|
-
if (issues.length === 1 && issues[0].id === 'gh:2' && issues[0].assignees.length === 0) ok();
|
|
50
|
-
else fail(`listUnassigned wrong: ${JSON.stringify(issues)}`);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// claim throws AlreadyAssignedError when a different user is assigned.
|
|
54
|
-
{
|
|
55
|
-
const spawnFn = buildSpawn({
|
|
56
|
-
'api user --jq .login': 'alice\n',
|
|
57
|
-
'issue view 3 --repo foo/bar --json number,title,body,state,assignees,labels,milestone,url,createdAt,updatedAt':
|
|
58
|
-
JSON.stringify({ number: 3, title: 't', body: '', state: 'OPEN', assignees: [{ login: 'bob' }], labels: [], milestone: null, url: 'u', createdAt: 'd', updatedAt: 'd' }),
|
|
59
|
-
});
|
|
60
|
-
const t = createTracker({ type: 'github-issues', repo: 'foo/bar' }, { spawnFn });
|
|
61
|
-
try { await t.claim('gh:3'); fail('claim should have thrown'); }
|
|
62
|
-
catch (e) { if (e instanceof AlreadyAssignedError && e.assignees[0] === 'bob') ok(); else fail(`wrong error: ${e}`); }
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// claim is idempotent when already assigned to me.
|
|
66
|
-
{
|
|
67
|
-
const spawnFn = buildSpawn({
|
|
68
|
-
'api user --jq .login': 'alice\n',
|
|
69
|
-
'issue view 4 --repo foo/bar --json number,title,body,state,assignees,labels,milestone,url,createdAt,updatedAt':
|
|
70
|
-
JSON.stringify({ number: 4, title: 't', body: '', state: 'OPEN', assignees: [{ login: 'alice' }], labels: [], milestone: null, url: 'u', createdAt: 'd', updatedAt: 'd' }),
|
|
71
|
-
});
|
|
72
|
-
const t = createTracker({ type: 'github-issues', repo: 'foo/bar' }, { spawnFn });
|
|
73
|
-
const issue = await t.claim('gh:4');
|
|
74
|
-
const edited = spawnFn.calls.some(c => c.args.includes('edit') && c.args.includes('--add-assignee'));
|
|
75
|
-
if (issue.id === 'gh:4' && !edited) ok();
|
|
76
|
-
else fail(`claim should be no-op when already assigned: edited=${edited}`);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// claim assigns when unassigned.
|
|
80
|
-
{
|
|
81
|
-
const viewResponse = JSON.stringify({ number: 5, title: 't', body: '', state: 'OPEN', assignees: [], labels: [], milestone: null, url: 'u', createdAt: 'd', updatedAt: 'd' });
|
|
82
|
-
const viewAssigned = JSON.stringify({ number: 5, title: 't', body: '', state: 'OPEN', assignees: [{ login: 'alice' }], labels: [], milestone: null, url: 'u', createdAt: 'd', updatedAt: 'd' });
|
|
83
|
-
let viewCallCount = 0;
|
|
84
|
-
const spawnFn = (cmd, args, options) => {
|
|
85
|
-
const key = args.join(' ');
|
|
86
|
-
if (key === 'api user --jq .login') return { status: 0, stdout: 'alice\n', stderr: '' };
|
|
87
|
-
if (key === 'issue view 5 --repo foo/bar --json number,title,body,state,assignees,labels,milestone,url,createdAt,updatedAt') {
|
|
88
|
-
viewCallCount++;
|
|
89
|
-
return { status: 0, stdout: viewCallCount === 1 ? viewResponse : viewAssigned, stderr: '' };
|
|
90
|
-
}
|
|
91
|
-
if (key === 'issue edit 5 --repo foo/bar --add-assignee alice') return { status: 0, stdout: '', stderr: '' };
|
|
92
|
-
return { status: 1, stdout: '', stderr: `no mock: ${key}` };
|
|
93
|
-
};
|
|
94
|
-
const t = createTracker({ type: 'github-issues', repo: 'foo/bar' }, { spawnFn });
|
|
95
|
-
const issue = await t.claim('gh:5');
|
|
96
|
-
if (issue.assignees[0] === 'alice') ok();
|
|
97
|
-
else fail(`claim should assign: ${JSON.stringify(issue)}`);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// createIssue parses issue number from URL.
|
|
101
|
-
{
|
|
102
|
-
const spawnFn = (cmd, args, options) => {
|
|
103
|
-
const key = args.join(' ');
|
|
104
|
-
if (key.startsWith('issue create --repo foo/bar')) {
|
|
105
|
-
return { status: 0, stdout: 'https://github.com/foo/bar/issues/99\n', stderr: '' };
|
|
106
|
-
}
|
|
107
|
-
if (key === 'issue view 99 --repo foo/bar --json number,title,body,state,assignees,labels,milestone,url,createdAt,updatedAt') {
|
|
108
|
-
return { status: 0, stdout: JSON.stringify({ number: 99, title: 'New', body: 'b', state: 'OPEN', assignees: [], labels: [{ name: 'chore' }], milestone: null, url: 'https://github.com/foo/bar/issues/99', createdAt: 'd', updatedAt: 'd' }), stderr: '' };
|
|
109
|
-
}
|
|
110
|
-
return { status: 1, stdout: '', stderr: `no mock: ${key}` };
|
|
111
|
-
};
|
|
112
|
-
const t = createTracker({ type: 'github-issues', repo: 'foo/bar' }, { spawnFn });
|
|
113
|
-
const issue = await t.createIssue({ title: 'New', body: 'b', labels: ['chore'] });
|
|
114
|
-
if (issue.id === 'gh:99') ok();
|
|
115
|
-
else fail(`createIssue wrong: ${JSON.stringify(issue)}`);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// ensureLabels calls gh label create --force for each of the six standard labels.
|
|
119
|
-
{
|
|
120
|
-
const created = [];
|
|
121
|
-
const spawnFn = (cmd, args) => {
|
|
122
|
-
if (args[0] === 'label' && args[1] === 'create') {
|
|
123
|
-
created.push(args[2]);
|
|
124
|
-
return { status: 0, stdout: '', stderr: '' };
|
|
125
|
-
}
|
|
126
|
-
return { status: 1, stdout: '', stderr: 'no mock' };
|
|
127
|
-
};
|
|
128
|
-
const t = createTracker({ type: 'github-issues', repo: 'foo/bar' }, { spawnFn });
|
|
129
|
-
await t.ensureLabels();
|
|
130
|
-
const expected = ['bug', 'feat', 'chore', 'P1', 'P2', 'P3'];
|
|
131
|
-
if (JSON.stringify(created.sort()) === JSON.stringify(expected.sort())) ok();
|
|
132
|
-
else fail(`ensureLabels wrong: ${JSON.stringify(created)}`);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// gh failure surfaces stderr.
|
|
136
|
-
{
|
|
137
|
-
const spawnFn = () => ({ status: 1, stdout: '', stderr: 'gh is on fire' });
|
|
138
|
-
const t = createTracker({ type: 'github-issues', repo: 'foo/bar' }, { spawnFn });
|
|
139
|
-
try { await t.listUnassigned(); fail('should have thrown'); }
|
|
140
|
-
catch (e) { if (/gh is on fire/.test(e.message)) ok(); else fail(`wrong error: ${e.message}`); }
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// ensureMilestone returns existing milestone without POST when title matches.
|
|
144
|
-
{
|
|
145
|
-
const calls = [];
|
|
146
|
-
const spawnFn = (cmd, args) => {
|
|
147
|
-
calls.push(args.join(' '));
|
|
148
|
-
const key = args.join(' ');
|
|
149
|
-
if (key === 'api repos/foo/bar/milestones?state=all&per_page=100') {
|
|
150
|
-
return { status: 0, stdout: JSON.stringify([
|
|
151
|
-
{ number: 1, title: 'Backlog', description: 'Triage later', state: 'open', due_on: null, html_url: 'https://github.com/foo/bar/milestone/1' },
|
|
152
|
-
]), stderr: '' };
|
|
153
|
-
}
|
|
154
|
-
return { status: 1, stdout: '', stderr: `no mock: ${key}` };
|
|
155
|
-
};
|
|
156
|
-
const t = createTracker({ type: 'github-issues', repo: 'foo/bar' }, { spawnFn });
|
|
157
|
-
const ms = await t.ensureMilestone({ title: 'Backlog' });
|
|
158
|
-
const posted = calls.some(c => c.includes('-X POST'));
|
|
159
|
-
if (ms.title === 'Backlog' && ms.number === 1 && !posted) ok();
|
|
160
|
-
else fail(`ensureMilestone should return existing without POST: posted=${posted}, ms=${JSON.stringify(ms)}`);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// ensureMilestone creates when title does not exist.
|
|
164
|
-
{
|
|
165
|
-
const spawnFn = (cmd, args) => {
|
|
166
|
-
const key = args.join(' ');
|
|
167
|
-
if (key === 'api repos/foo/bar/milestones?state=all&per_page=100') {
|
|
168
|
-
return { status: 0, stdout: JSON.stringify([]), stderr: '' };
|
|
169
|
-
}
|
|
170
|
-
if (key.startsWith('api repos/foo/bar/milestones -X POST')) {
|
|
171
|
-
return { status: 0, stdout: JSON.stringify({ number: 2, title: 'v0.1', description: 'alpha', state: 'open', due_on: null, html_url: 'https://github.com/foo/bar/milestone/2' }), stderr: '' };
|
|
172
|
-
}
|
|
173
|
-
return { status: 1, stdout: '', stderr: `no mock: ${key}` };
|
|
174
|
-
};
|
|
175
|
-
const t = createTracker({ type: 'github-issues', repo: 'foo/bar' }, { spawnFn });
|
|
176
|
-
const ms = await t.ensureMilestone({ title: 'v0.1', description: 'alpha' });
|
|
177
|
-
if (ms.title === 'v0.1' && ms.number === 2 && ms.description === 'alpha') ok();
|
|
178
|
-
else fail(`ensureMilestone should create when absent: ${JSON.stringify(ms)}`);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// ensureMilestone rejects missing title.
|
|
182
|
-
{
|
|
183
|
-
const spawnFn = () => ({ status: 0, stdout: '[]', stderr: '' });
|
|
184
|
-
const t = createTracker({ type: 'github-issues', repo: 'foo/bar' }, { spawnFn });
|
|
185
|
-
try { await t.ensureMilestone({}); fail('should have thrown'); }
|
|
186
|
-
catch (e) { if (/title is required/.test(e.message)) ok(); else fail(`wrong error: ${e.message}`); }
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
console.log(`\n${passed} passed, ${failed} failed`);
|
|
190
|
-
process.exit(failed ? 1 : 0);
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Tests for the tracker factory and AlreadyAssignedError.
|
|
3
|
-
// Run: node .claude/scripts/trackers/interface.test.mjs
|
|
4
|
-
import { createTracker, AlreadyAssignedError } from './interface.mjs';
|
|
5
|
-
|
|
6
|
-
let failed = 0, passed = 0;
|
|
7
|
-
const ok = (msg) => { passed++; };
|
|
8
|
-
const fail = (msg) => { failed++; console.error(` FAIL: ${msg}`); };
|
|
9
|
-
|
|
10
|
-
// AlreadyAssignedError carries assignees and a code.
|
|
11
|
-
{
|
|
12
|
-
const err = new AlreadyAssignedError('gh:42', ['alice', 'bob']);
|
|
13
|
-
if (err.code === 'ALREADY_ASSIGNED' && JSON.stringify(err.assignees) === '["alice","bob"]'
|
|
14
|
-
&& err.message.includes('gh:42') && err.message.includes('alice')) ok();
|
|
15
|
-
else fail('AlreadyAssignedError should carry code and assignees');
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// createTracker rejects missing config.
|
|
19
|
-
{
|
|
20
|
-
try { createTracker(); fail('should throw on missing config'); }
|
|
21
|
-
catch (e) { if (/No tracker configured/.test(e.message)) ok(); else fail(`wrong error: ${e.message}`); }
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// createTracker rejects unknown type.
|
|
25
|
-
{
|
|
26
|
-
try { createTracker({ type: 'nope' }); fail('should throw on unknown type'); }
|
|
27
|
-
catch (e) { if (/Unknown tracker type/.test(e.message)) ok(); else fail(`wrong error: ${e.message}`); }
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// createTracker builds a github-issues adapter without calling gh at construction (lazy).
|
|
31
|
-
{
|
|
32
|
-
const fakeSpawn = () => { throw new Error('spawn should not run at construction'); };
|
|
33
|
-
// With repo: 'foo/bar' literal, the adapter should NOT shell out to resolve the remote.
|
|
34
|
-
const adapter = createTracker({ type: 'github-issues', repo: 'foo/bar' }, { spawnFn: fakeSpawn });
|
|
35
|
-
if (adapter.identity === 'github-issues:foo/bar') ok();
|
|
36
|
-
else fail(`unexpected identity: ${adapter.identity}`);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
console.log(`\n${passed} passed, ${failed} failed`);
|
|
40
|
-
process.exit(failed ? 1 : 0);
|