@iceinvein/agent-skills 0.2.0 → 0.4.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/README.md +2 -2
- package/dist/cli/index.js +14 -10
- package/package.json +1 -1
- package/skills/index.json +4 -4
- package/skills/migrate/README.md +35 -23
- package/skills/migrate/SKILL.md +75 -15
- package/skills/migrate/bin/migrate.ts +90 -0
- package/skills/migrate/docs/architecture.md +61 -26
- package/skills/migrate/docs/reference.md +53 -8
- package/skills/migrate/fixtures/fake-gh.ts +113 -0
- package/skills/migrate/fixtures/flow-target/docs/WORK.md +12 -0
- package/skills/migrate/fixtures/flow-target/docs/modernisation/capability-map/.gitkeep +0 -0
- package/skills/migrate/fixtures/flow-target/tools/flow/src/cli.ts +156 -0
- package/skills/migrate/package.json +1 -1
- package/skills/migrate/references/phases/adjudicate.md +161 -0
- package/skills/migrate/references/phases/handoff.md +220 -0
- package/skills/migrate/references/phases/probe.md +2 -2
- package/skills/migrate/references/phases/queue.md +21 -14
- package/skills/migrate/references/run-ops.md +17 -13
- package/skills/migrate/scripts/__tests__/adapter-flow.test.ts +290 -0
- package/skills/migrate/scripts/__tests__/adapter-github.test.ts +232 -0
- package/skills/migrate/scripts/__tests__/adapter-markdown.test.ts +183 -0
- package/skills/migrate/scripts/__tests__/adjudicate.test.ts +332 -0
- package/skills/migrate/scripts/__tests__/assumptions.test.ts +179 -0
- package/skills/migrate/scripts/__tests__/coverage.test.ts +192 -0
- package/skills/migrate/scripts/__tests__/e2e-express.test.ts +167 -7
- package/skills/migrate/scripts/__tests__/e2e-webforms.test.ts +9 -4
- package/skills/migrate/scripts/__tests__/forecast.test.ts +280 -0
- package/skills/migrate/scripts/__tests__/gates-handoff.test.ts +309 -0
- package/skills/migrate/scripts/__tests__/handoff-cmd.test.ts +308 -0
- package/skills/migrate/scripts/__tests__/handoff-order.test.ts +156 -0
- package/skills/migrate/scripts/adapters/flow.ts +280 -0
- package/skills/migrate/scripts/adapters/github.ts +260 -0
- package/skills/migrate/scripts/adapters/markdown.ts +175 -0
- package/skills/migrate/scripts/adjudicate-cmd.ts +243 -0
- package/skills/migrate/scripts/assumptions.ts +188 -0
- package/skills/migrate/scripts/check.ts +119 -320
- package/skills/migrate/scripts/coverage-cmd.ts +86 -0
- package/skills/migrate/scripts/coverage.ts +151 -0
- package/skills/migrate/scripts/dates.ts +17 -0
- package/skills/migrate/scripts/forecast-cmd.ts +124 -0
- package/skills/migrate/scripts/forecast.ts +264 -0
- package/skills/migrate/scripts/gates/adjudication.ts +30 -0
- package/skills/migrate/scripts/gates/census.ts +107 -0
- package/skills/migrate/scripts/gates/citations.ts +11 -0
- package/skills/migrate/scripts/gates/context.ts +76 -0
- package/skills/migrate/scripts/gates/coverage.ts +22 -0
- package/skills/migrate/scripts/gates/deltas.ts +15 -0
- package/skills/migrate/scripts/gates/handoff.ts +145 -0
- package/skills/migrate/scripts/gates/leaks.ts +11 -0
- package/skills/migrate/scripts/gates/parity.ts +15 -0
- package/skills/migrate/scripts/gates/queue.ts +9 -0
- package/skills/migrate/scripts/gates/refs.ts +97 -0
- package/skills/migrate/scripts/gates/run-state.ts +67 -0
- package/skills/migrate/scripts/gates/source.ts +28 -0
- package/skills/migrate/scripts/handoff-cmd.ts +186 -0
- package/skills/migrate/scripts/handoff.ts +330 -0
- package/skills/migrate/scripts/paths.ts +4 -0
- package/skills/migrate/scripts/types.ts +43 -0
- package/skills/migrate/scripts/validate.ts +12 -0
- package/skills/migrate/skill.json +2 -2
- package/skills/migrate/templates/forecast-assumptions.md +59 -0
- package/skills/sluice/SKILL.md +20 -7
- package/skills/sluice/references/deep-channel.md +20 -0
- package/skills/sluice/references/finish.md +4 -2
- package/skills/sluice/references/meter.md +38 -0
- package/skills/sluice/scripts/run-stats.sh +236 -0
- package/skills/sluice/skill.json +4 -3
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
|
+
import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { flow } from '../adapters/flow.ts'
|
|
6
|
+
import type { Config } from '../config.ts'
|
|
7
|
+
import { buildWorkItems, type HandoffInput } from '../handoff.ts'
|
|
8
|
+
import type { Capability, Requirement } from '../types.ts'
|
|
9
|
+
|
|
10
|
+
const FIXTURE = join(import.meta.dir, '..', '..', 'fixtures', 'flow-target')
|
|
11
|
+
|
|
12
|
+
let root: string
|
|
13
|
+
|
|
14
|
+
const config = (): Config => ({
|
|
15
|
+
source: {
|
|
16
|
+
path: join(root, 'legacy'),
|
|
17
|
+
scope: 'x',
|
|
18
|
+
stack: 'unknown',
|
|
19
|
+
vcs: 'none',
|
|
20
|
+
basis: 'source-only',
|
|
21
|
+
},
|
|
22
|
+
target: {
|
|
23
|
+
name: 'target',
|
|
24
|
+
stack: 'unknown',
|
|
25
|
+
parity_test_path: 'tests/parity/{capability}/{fr_slug}.test.ts',
|
|
26
|
+
layout: {},
|
|
27
|
+
commands: {},
|
|
28
|
+
},
|
|
29
|
+
surfaces: ['routes'],
|
|
30
|
+
surfaceSingular: {},
|
|
31
|
+
closers: [],
|
|
32
|
+
handoff: { adapter: 'flow' },
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
function cap(slug: string, title: string, ns: string, elements: string[]): Capability {
|
|
36
|
+
return { slug, title, ns, elements }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function req(
|
|
40
|
+
id: string,
|
|
41
|
+
capSlug: string,
|
|
42
|
+
text: string,
|
|
43
|
+
over: Partial<Requirement> = {},
|
|
44
|
+
): Requirement {
|
|
45
|
+
return {
|
|
46
|
+
id,
|
|
47
|
+
cap: capSlug,
|
|
48
|
+
requirement: text,
|
|
49
|
+
actors: 'User',
|
|
50
|
+
objects: 'Thing',
|
|
51
|
+
rules: 'none',
|
|
52
|
+
origin: 'intended',
|
|
53
|
+
confidence: { kind: 'confirmed' },
|
|
54
|
+
citations: [],
|
|
55
|
+
parity: { kind: 'rubric', level: 'high' },
|
|
56
|
+
batch: 'b-1',
|
|
57
|
+
...over,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const CAPS = [
|
|
62
|
+
cap('billing', 'Billing', 'BI', ['el-b']),
|
|
63
|
+
cap('user-management', 'User management', 'UM', ['el-u']),
|
|
64
|
+
]
|
|
65
|
+
const REQS = [
|
|
66
|
+
req('UM-001', 'user-management', 'Authenticate a user'),
|
|
67
|
+
req('UM-002', 'user-management', 'Lock an account', {
|
|
68
|
+
confidence: { kind: 'inferred' },
|
|
69
|
+
origin: 'accidental-candidate',
|
|
70
|
+
}),
|
|
71
|
+
req('BI-001', 'billing', 'Raise an invoice'),
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
function input(caps = CAPS, reqs = REQS): HandoffInput {
|
|
75
|
+
return {
|
|
76
|
+
requirements: reqs,
|
|
77
|
+
capabilities: caps,
|
|
78
|
+
deltas: [],
|
|
79
|
+
config: config(),
|
|
80
|
+
root,
|
|
81
|
+
gitBin: 'git',
|
|
82
|
+
ghBin: 'gh',
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const capPath = (slug: string): string =>
|
|
87
|
+
join(root, 'docs', 'modernisation', 'capability-map', `${slug}.md`)
|
|
88
|
+
|
|
89
|
+
async function flowCli(args: string[]): Promise<{ code: number; out: string; err: string }> {
|
|
90
|
+
const proc = Bun.spawn(['bun', join(root, 'tools', 'flow', 'src', 'cli.ts'), ...args], {
|
|
91
|
+
cwd: root,
|
|
92
|
+
stdout: 'pipe',
|
|
93
|
+
stderr: 'pipe',
|
|
94
|
+
})
|
|
95
|
+
const [out, err] = await Promise.all([
|
|
96
|
+
new Response(proc.stdout).text(),
|
|
97
|
+
new Response(proc.stderr).text(),
|
|
98
|
+
])
|
|
99
|
+
await proc.exited
|
|
100
|
+
return { code: proc.exitCode ?? -1, out, err }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
beforeEach(async () => {
|
|
104
|
+
root = await mkdtemp(join(tmpdir(), 'migrate-flow-adapter-'))
|
|
105
|
+
await cp(FIXTURE, root, { recursive: true })
|
|
106
|
+
await Bun.write(join(root, 'legacy', 'app.js'), '// legacy\n')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
afterEach(async () => {
|
|
110
|
+
await rm(root, { recursive: true, force: true })
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('apply emits capability files the target parser accepts', async () => {
|
|
114
|
+
const result = await flow.apply(buildWorkItems(CAPS, REQS), input())
|
|
115
|
+
expect(result.created.sort()).toEqual(['billing', 'user-management'])
|
|
116
|
+
|
|
117
|
+
// The oracle: the target's own checker, run over what the adapter just wrote.
|
|
118
|
+
const check = await flowCli(['map', '--check'])
|
|
119
|
+
expect(check.err).toBe('')
|
|
120
|
+
expect(check.code).toBe(0)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// --- grammar conformance, one assertion per rule in the target's parser ---
|
|
124
|
+
// Each row cites quartex/Nexus at c2464ac,
|
|
125
|
+
// plugins/stack/templates/tools/flow/src/capability.ts.
|
|
126
|
+
|
|
127
|
+
test('capability.ts:43 every required frontmatter field is present', async () => {
|
|
128
|
+
await flow.apply(buildWorkItems(CAPS, REQS), input())
|
|
129
|
+
const text = await readFile(capPath('billing'), 'utf8')
|
|
130
|
+
for (const field of ['cap:', 'ns:', 'title:', 'status:']) {
|
|
131
|
+
expect(text).toContain(field)
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('capability.ts:3 status is one the target admits', async () => {
|
|
136
|
+
await flow.apply(buildWorkItems(CAPS, REQS), input())
|
|
137
|
+
const text = await readFile(capPath('billing'), 'utf8')
|
|
138
|
+
expect(text).toContain('status: todo')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('capability.ts:44 all three required sections are emitted', async () => {
|
|
142
|
+
await flow.apply(buildWorkItems(CAPS, REQS), input())
|
|
143
|
+
const text = await readFile(capPath('billing'), 'utf8')
|
|
144
|
+
expect(text).toContain('## Functional requirements')
|
|
145
|
+
expect(text).toContain('## Built')
|
|
146
|
+
expect(text).toContain('## Remaining')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('capability.ts:6 confidence is mapped into the target vocabulary', async () => {
|
|
150
|
+
await flow.apply(buildWorkItems(CAPS, REQS), input())
|
|
151
|
+
const text = await readFile(capPath('user-management'), 'utf8')
|
|
152
|
+
expect(text).toContain('| Confirmed |')
|
|
153
|
+
expect(text).toContain('| Inferred |')
|
|
154
|
+
expect(text).not.toContain('confirmed |')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('capability.ts:9 accidental-candidate is mapped to poss-accidental', async () => {
|
|
158
|
+
await flow.apply(buildWorkItems(CAPS, REQS), input())
|
|
159
|
+
const text = await readFile(capPath('user-management'), 'utf8')
|
|
160
|
+
expect(text).toContain('poss-accidental')
|
|
161
|
+
expect(text).not.toContain('accidental-candidate')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('capability.ts:74 every FR row has exactly seven cells', async () => {
|
|
165
|
+
await flow.apply(buildWorkItems(CAPS, REQS), input())
|
|
166
|
+
const text = await readFile(capPath('user-management'), 'utf8')
|
|
167
|
+
const rows = text
|
|
168
|
+
.split('\n')
|
|
169
|
+
.filter((l) => l.startsWith('| UM-'))
|
|
170
|
+
.map((l) => l.split('|').slice(1, -1))
|
|
171
|
+
expect(rows).toHaveLength(2)
|
|
172
|
+
for (const row of rows) expect(row).toHaveLength(7)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('capability.ts:48 no "## " line is emitted inside a table cell', async () => {
|
|
176
|
+
// A requirement whose text would open a section if it reached line start.
|
|
177
|
+
const sneaky = [req('BI-001', 'billing', 'Handle the\n## Built case')]
|
|
178
|
+
await flow.apply(
|
|
179
|
+
buildWorkItems([CAPS[0] as Capability], sneaky),
|
|
180
|
+
input([CAPS[0] as Capability], sneaky),
|
|
181
|
+
)
|
|
182
|
+
const check = await flowCli(['map', '--check'])
|
|
183
|
+
expect(check.code).toBe(0)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('capability.ts:42 an FR id that does not match its namespace refuses before writing', async () => {
|
|
187
|
+
const bad = [req('login-001', 'user-management', 'Authenticate a user')]
|
|
188
|
+
const caps = [CAPS[1] as Capability]
|
|
189
|
+
await expect(flow.plan(input(caps, bad))).rejects.toThrow(/login-001/)
|
|
190
|
+
// Nothing was written: the refusal happens in plan(), not part way through apply().
|
|
191
|
+
expect(await Bun.file(capPath('user-management')).exists()).toBe(false)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
// --- WORK.md ---
|
|
195
|
+
|
|
196
|
+
test('apply appends under an existing ## Proposed section and creates it when absent', async () => {
|
|
197
|
+
await flow.apply(buildWorkItems(CAPS, REQS), input())
|
|
198
|
+
const first = await readFile(join(root, 'docs', 'WORK.md'), 'utf8')
|
|
199
|
+
expect(first).toContain('## Proposed')
|
|
200
|
+
expect(first).toContain('- [billing] Billing (1 FRs)')
|
|
201
|
+
expect(first).toContain('- [user-management] User management (2 FRs)')
|
|
202
|
+
// The team's own sections survive.
|
|
203
|
+
expect(first).toContain('- [W01] Wire the deployment pipeline')
|
|
204
|
+
|
|
205
|
+
// Re-running does not stack a second copy of the same lines.
|
|
206
|
+
await flow.apply(buildWorkItems(CAPS, REQS), input())
|
|
207
|
+
const second = await readFile(join(root, 'docs', 'WORK.md'), 'utf8')
|
|
208
|
+
expect(second.match(/- \[billing\]/g)).toHaveLength(1)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
// --- throughput and degradation ---
|
|
212
|
+
|
|
213
|
+
test('throughput reads coveredIds from the target parity command, undated', async () => {
|
|
214
|
+
await flow.apply(buildWorkItems(CAPS, REQS), input())
|
|
215
|
+
// The target records delivery in the capability file's Built section.
|
|
216
|
+
const text = await readFile(capPath('billing'), 'utf8')
|
|
217
|
+
await writeFile(capPath('billing'), text.replace('## Built\n\n(none)', '## Built\n\nBI-001'))
|
|
218
|
+
|
|
219
|
+
const t = await flow.throughput?.(input())
|
|
220
|
+
expect(t?.completions).toEqual([{ fr: 'BI-001', doneAt: null }])
|
|
221
|
+
expect(t?.basis).toContain('flow parity')
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('a target with no flow CLI reports that the emission was not validated', async () => {
|
|
225
|
+
await rm(join(root, 'tools'), { recursive: true, force: true })
|
|
226
|
+
const result = await flow.apply(buildWorkItems(CAPS, REQS), input())
|
|
227
|
+
expect(result.created.sort()).toEqual(['billing', 'user-management'])
|
|
228
|
+
expect(result.refs['billing']).toContain('capability-map/billing.md')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('a capability file the target rejects fails apply with the target’s own message', async () => {
|
|
232
|
+
// The fixture CLI is the oracle; break its input and the adapter must
|
|
233
|
+
// surface the failure rather than reporting a clean emission.
|
|
234
|
+
await flow.apply(buildWorkItems(CAPS, REQS), input())
|
|
235
|
+
await writeFile(capPath('billing'), '---\ncap: billing\n---\n\nnothing else\n')
|
|
236
|
+
const check = await flowCli(['map', '--check'])
|
|
237
|
+
expect(check.code).toBe(1)
|
|
238
|
+
expect(check.err).toContain('missing field')
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test("the team's own lines under ## Proposed survive a handoff", async () => {
|
|
242
|
+
// The previous version stripped every `- [something]` line under the
|
|
243
|
+
// heading before adding its own, on the assumption that shape meant
|
|
244
|
+
// ownership. `- [W07]` is exactly the notation the target's own WORK.md
|
|
245
|
+
// teaches, so a team keeping a shortlist there lost it on the first run.
|
|
246
|
+
const workPath = join(root, 'docs', 'WORK.md')
|
|
247
|
+
await writeFile(
|
|
248
|
+
workPath,
|
|
249
|
+
[
|
|
250
|
+
'# Work',
|
|
251
|
+
'',
|
|
252
|
+
'## Proposed',
|
|
253
|
+
'',
|
|
254
|
+
'- [W07] Replace the auth provider',
|
|
255
|
+
'- [W08] Split the reporting service',
|
|
256
|
+
'',
|
|
257
|
+
'Notes: W07 is blocked until Q3.',
|
|
258
|
+
'',
|
|
259
|
+
'### Detail',
|
|
260
|
+
'',
|
|
261
|
+
'Some prose.',
|
|
262
|
+
'',
|
|
263
|
+
'# Appendix',
|
|
264
|
+
'',
|
|
265
|
+
'- [A1] An appendix item',
|
|
266
|
+
'',
|
|
267
|
+
].join('\n'),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
await flow.apply(buildWorkItems(CAPS, REQS), input())
|
|
271
|
+
const after = await readFile(workPath, 'utf8')
|
|
272
|
+
|
|
273
|
+
for (const kept of [
|
|
274
|
+
'- [W07] Replace the auth provider',
|
|
275
|
+
'- [W08] Split the reporting service',
|
|
276
|
+
'Notes: W07 is blocked until Q3.',
|
|
277
|
+
'### Detail',
|
|
278
|
+
'# Appendix',
|
|
279
|
+
'- [A1] An appendix item',
|
|
280
|
+
]) {
|
|
281
|
+
expect(after).toContain(kept)
|
|
282
|
+
}
|
|
283
|
+
expect(after).toContain('- [billing] Billing (1 FRs)')
|
|
284
|
+
|
|
285
|
+
// And a second run replaces only its own fenced block rather than stacking.
|
|
286
|
+
await flow.apply(buildWorkItems(CAPS, REQS), input())
|
|
287
|
+
const twice = await readFile(workPath, 'utf8')
|
|
288
|
+
expect(twice.match(/- \[billing\]/g)).toHaveLength(1)
|
|
289
|
+
expect(twice).toContain('- [W07] Replace the auth provider')
|
|
290
|
+
})
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { github } from '../adapters/github.ts'
|
|
6
|
+
import type { Config } from '../config.ts'
|
|
7
|
+
import { buildWorkItems, type HandoffInput } from '../handoff.ts'
|
|
8
|
+
import type { Capability, Requirement } from '../types.ts'
|
|
9
|
+
|
|
10
|
+
const FAKE_GH = join(import.meta.dir, '..', '..', 'fixtures', 'fake-gh.ts')
|
|
11
|
+
|
|
12
|
+
let root: string
|
|
13
|
+
let statePath: string
|
|
14
|
+
let logPath: string
|
|
15
|
+
|
|
16
|
+
const config = (): Config => ({
|
|
17
|
+
source: {
|
|
18
|
+
path: join(root, 'legacy'),
|
|
19
|
+
scope: 'x',
|
|
20
|
+
stack: 'unknown',
|
|
21
|
+
vcs: 'none',
|
|
22
|
+
basis: 'source-only',
|
|
23
|
+
},
|
|
24
|
+
target: {
|
|
25
|
+
name: 'target',
|
|
26
|
+
stack: 'unknown',
|
|
27
|
+
parity_test_path: 'tests/parity/{capability}/{fr_slug}.test.ts',
|
|
28
|
+
layout: {},
|
|
29
|
+
commands: {},
|
|
30
|
+
},
|
|
31
|
+
surfaces: ['routes'],
|
|
32
|
+
surfaceSingular: {},
|
|
33
|
+
closers: [],
|
|
34
|
+
handoff: { adapter: 'github' },
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
function cap(slug: string, title: string, elements: string[]): Capability {
|
|
38
|
+
return { slug, title, ns: slug.slice(0, 2).toUpperCase(), elements }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function req(id: string, capSlug: string, text: string, cites: string[] = []): Requirement {
|
|
42
|
+
return {
|
|
43
|
+
id,
|
|
44
|
+
cap: capSlug,
|
|
45
|
+
requirement: text,
|
|
46
|
+
actors: 'User',
|
|
47
|
+
objects: 'Thing',
|
|
48
|
+
rules: 'none',
|
|
49
|
+
origin: 'intended',
|
|
50
|
+
confidence: { kind: 'confirmed' },
|
|
51
|
+
citations: cites.map((c) => ({ kind: 'ledger' as const, id: c })),
|
|
52
|
+
parity: { kind: 'rubric', level: 'high' },
|
|
53
|
+
batch: 'b-1',
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const CAPS = [
|
|
58
|
+
cap('billing', 'Billing', ['el-b']),
|
|
59
|
+
cap('user-management', 'User management', ['el-u']),
|
|
60
|
+
]
|
|
61
|
+
const REQS = [
|
|
62
|
+
req('UM-001', 'user-management', 'Authenticate a user', ['el-b']),
|
|
63
|
+
req('UM-002', 'user-management', 'Lock an account'),
|
|
64
|
+
req('BI-001', 'billing', 'Raise an invoice'),
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
function input(reqs = REQS): HandoffInput {
|
|
68
|
+
return {
|
|
69
|
+
requirements: reqs,
|
|
70
|
+
capabilities: CAPS,
|
|
71
|
+
deltas: [],
|
|
72
|
+
config: config(),
|
|
73
|
+
root,
|
|
74
|
+
gitBin: 'git',
|
|
75
|
+
ghBin: FAKE_GH,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function log(): Promise<string[]> {
|
|
80
|
+
const text = await readFile(logPath, 'utf8')
|
|
81
|
+
return text.split('\n').filter((l) => l.length > 0)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
beforeEach(async () => {
|
|
85
|
+
root = await mkdtemp(join(tmpdir(), 'migrate-gh-adapter-'))
|
|
86
|
+
await Bun.write(join(root, 'legacy', 'app.js'), '// legacy\n')
|
|
87
|
+
// The fake gh keys its state and log off its working directory, which the
|
|
88
|
+
// adapter sets to the target root.
|
|
89
|
+
statePath = join(root, 'gh-state.json')
|
|
90
|
+
logPath = join(root, 'gh-log.txt')
|
|
91
|
+
await writeFile(logPath, '')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
afterEach(async () => {
|
|
95
|
+
await rm(root, { recursive: true, force: true })
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('apply creates one milestone per capability and one issue per requirement', async () => {
|
|
99
|
+
const result = await github.apply(buildWorkItems(CAPS, REQS), input())
|
|
100
|
+
|
|
101
|
+
expect(result.created.sort()).toEqual(['billing', 'user-management'])
|
|
102
|
+
// The ref is the milestone the capability's issues were filed under.
|
|
103
|
+
expect(result.refs['billing']).toBe('milestone:1')
|
|
104
|
+
|
|
105
|
+
const lines = await log()
|
|
106
|
+
expect(lines.filter((l) => l.includes('-X POST'))).toHaveLength(2)
|
|
107
|
+
expect(lines.filter((l) => l.startsWith('issue create'))).toHaveLength(3)
|
|
108
|
+
// Every issue body carries the marker that makes the next run idempotent.
|
|
109
|
+
const state = JSON.parse(await readFile(statePath, 'utf8'))
|
|
110
|
+
expect(state.issues).toHaveLength(3)
|
|
111
|
+
expect(state.issues[0].body).toContain('<!-- migrate:fr=')
|
|
112
|
+
// The milestone is keyed on the slug as well as the title: two capabilities
|
|
113
|
+
// can share a title and can never share a slug.
|
|
114
|
+
expect(state.issues.map((i: { milestone: string }) => i.milestone).sort()).toEqual([
|
|
115
|
+
'Billing (billing)',
|
|
116
|
+
'User management (user-management)',
|
|
117
|
+
'User management (user-management)',
|
|
118
|
+
])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('a second apply creates nothing and reports every item unchanged', async () => {
|
|
122
|
+
const items = buildWorkItems(CAPS, REQS)
|
|
123
|
+
await github.apply(items, input())
|
|
124
|
+
await writeFile(logPath, '')
|
|
125
|
+
|
|
126
|
+
const second = await github.apply(items, input())
|
|
127
|
+
expect(second.created).toEqual([])
|
|
128
|
+
expect(second.updated).toEqual([])
|
|
129
|
+
expect(second.unchanged.sort()).toEqual(['billing', 'user-management'])
|
|
130
|
+
|
|
131
|
+
const lines = await log()
|
|
132
|
+
expect(lines.filter((l) => l.startsWith('issue create'))).toHaveLength(0)
|
|
133
|
+
expect(lines.filter((l) => l.includes('-X POST'))).toHaveLength(0)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('an issue found only by its marker is edited rather than duplicated', async () => {
|
|
137
|
+
await github.apply(buildWorkItems(CAPS, REQS), input())
|
|
138
|
+
|
|
139
|
+
// The requirement's text changes, so its issue body must be brought up to
|
|
140
|
+
// date. The adapter has no stored ref for the issue: the marker in the body
|
|
141
|
+
// is the whole identity mechanism, which is what makes this safe after a
|
|
142
|
+
// lost handoff.json.
|
|
143
|
+
const edited = [
|
|
144
|
+
req('UM-001', 'user-management', 'Authenticate a user against stored credentials', ['el-b']),
|
|
145
|
+
req('UM-002', 'user-management', 'Lock an account'),
|
|
146
|
+
req('BI-001', 'billing', 'Raise an invoice'),
|
|
147
|
+
]
|
|
148
|
+
await writeFile(logPath, '')
|
|
149
|
+
const second = await github.apply(buildWorkItems(CAPS, edited), input(edited))
|
|
150
|
+
|
|
151
|
+
expect(second.updated).toEqual(['user-management'])
|
|
152
|
+
expect(second.unchanged).toEqual(['billing'])
|
|
153
|
+
const lines = await log()
|
|
154
|
+
expect(lines.filter((l) => l.startsWith('issue create'))).toHaveLength(0)
|
|
155
|
+
expect(lines.filter((l) => l.startsWith('issue edit'))).toHaveLength(1)
|
|
156
|
+
|
|
157
|
+
const state = JSON.parse(await readFile(statePath, 'utf8'))
|
|
158
|
+
expect(state.issues).toHaveLength(3)
|
|
159
|
+
const um1 = state.issues.filter((i: { body: string }) => i.body.includes('migrate:fr=UM-001'))
|
|
160
|
+
// Exactly one issue still carries the marker: the edit updated it in place
|
|
161
|
+
// rather than filing a second issue for the same requirement.
|
|
162
|
+
expect(um1).toHaveLength(1)
|
|
163
|
+
expect(um1[0].body).toContain('against stored credentials')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('throughput reads closed issues and dates them from closedAt', async () => {
|
|
167
|
+
await github.apply(buildWorkItems(CAPS, REQS), input())
|
|
168
|
+
|
|
169
|
+
// Found by marker, not by index: billing sorts first in dependency order, so
|
|
170
|
+
// issue 0 is BI-001.
|
|
171
|
+
const state = JSON.parse(await readFile(statePath, 'utf8'))
|
|
172
|
+
const target = state.issues.find((i: { body: string }) => i.body.includes('migrate:fr=UM-001'))
|
|
173
|
+
target.state = 'CLOSED'
|
|
174
|
+
target.closedAt = '2026-08-12T04:11:00Z'
|
|
175
|
+
await writeFile(statePath, JSON.stringify(state, null, 2))
|
|
176
|
+
|
|
177
|
+
const t = await github.throughput?.(input())
|
|
178
|
+
expect(t?.completions).toEqual([{ fr: 'UM-001', doneAt: '2026-08-12' }])
|
|
179
|
+
expect(t?.basis).toContain('closed')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('an issue with no marker is ignored rather than mistaken for a requirement', async () => {
|
|
183
|
+
await github.apply(buildWorkItems(CAPS, REQS), input())
|
|
184
|
+
const state = JSON.parse(await readFile(statePath, 'utf8'))
|
|
185
|
+
state.issues.push({
|
|
186
|
+
number: 500,
|
|
187
|
+
title: 'Someone else’s issue',
|
|
188
|
+
body: 'Nothing to do with the migration.',
|
|
189
|
+
state: 'CLOSED',
|
|
190
|
+
closedAt: '2026-08-01T00:00:00Z',
|
|
191
|
+
milestone: null,
|
|
192
|
+
})
|
|
193
|
+
await writeFile(statePath, JSON.stringify(state, null, 2))
|
|
194
|
+
|
|
195
|
+
const t = await github.throughput?.(input())
|
|
196
|
+
expect(t?.completions).toEqual([])
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('an issue that merely mentions a marker is not hijacked', async () => {
|
|
200
|
+
await github.apply(buildWorkItems(CAPS, REQS), input())
|
|
201
|
+
const state = JSON.parse(await readFile(statePath, 'utf8'))
|
|
202
|
+
const foreign = {
|
|
203
|
+
number: 900,
|
|
204
|
+
title: 'Investigate the flaky login test',
|
|
205
|
+
body: 'Related to the auth work at <!-- migrate:fr=UM-999 --> which is not this issue.\nA week of repro steps.',
|
|
206
|
+
state: 'OPEN',
|
|
207
|
+
closedAt: null,
|
|
208
|
+
milestone: null,
|
|
209
|
+
}
|
|
210
|
+
state.issues.push(foreign)
|
|
211
|
+
await writeFile(statePath, JSON.stringify(state, null, 2))
|
|
212
|
+
await writeFile(logPath, '')
|
|
213
|
+
|
|
214
|
+
await github.apply(buildWorkItems(CAPS, REQS), input())
|
|
215
|
+
const after = JSON.parse(await readFile(statePath, 'utf8'))
|
|
216
|
+
const kept = after.issues.find((i: { number: number }) => i.number === 900)
|
|
217
|
+
expect(kept.body).toBe(foreign.body)
|
|
218
|
+
expect(kept.milestone).toBeNull()
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('a human note below the fence survives a re-run', async () => {
|
|
222
|
+
await github.apply(buildWorkItems(CAPS, REQS), input())
|
|
223
|
+
const state = JSON.parse(await readFile(statePath, 'utf8'))
|
|
224
|
+
const target = state.issues.find((i: { body: string }) => i.body.includes('migrate:fr=UM-001'))
|
|
225
|
+
target.body = `${target.body}\n\n## Delivery notes\n\nSee PR #77.`
|
|
226
|
+
await writeFile(statePath, JSON.stringify(state, null, 2))
|
|
227
|
+
|
|
228
|
+
await github.apply(buildWorkItems(CAPS, REQS), input())
|
|
229
|
+
const after = JSON.parse(await readFile(statePath, 'utf8'))
|
|
230
|
+
const kept = after.issues.find((i: { body: string }) => i.body.includes('migrate:fr=UM-001'))
|
|
231
|
+
expect(kept.body).toContain('See PR #77.')
|
|
232
|
+
})
|