@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,280 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { mkdir } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import type { Adapter, HandoffInput } from '../handoff.ts'
|
|
5
|
+
import { buildWorkItems } from '../handoff.ts'
|
|
6
|
+
import { readTextFile, writeAtomically } from '../store.ts'
|
|
7
|
+
import type {
|
|
8
|
+
ApplyResult,
|
|
9
|
+
Completion,
|
|
10
|
+
Confidence,
|
|
11
|
+
Requirement,
|
|
12
|
+
Throughput,
|
|
13
|
+
WorkItem,
|
|
14
|
+
} from '../types.ts'
|
|
15
|
+
|
|
16
|
+
// Everything in this file is written against the flow target's own parser:
|
|
17
|
+
// quartex/Nexus at c2464ac, plugins/stack/templates/tools/flow/src/
|
|
18
|
+
// capability.ts. That parser is an executable specification of what a
|
|
19
|
+
// capability file must look like, and each rule it enforces is cited at the
|
|
20
|
+
// line it comes from. scripts/__tests__/adapter-flow.test.ts asserts each rule
|
|
21
|
+
// independently, so if the target's grammar moves, the drift is visible here
|
|
22
|
+
// rather than surfacing as a parse error inside someone else's repo.
|
|
23
|
+
const CAP_DIR = join('docs', 'modernisation', 'capability-map')
|
|
24
|
+
const WORK = join('docs', 'WORK.md')
|
|
25
|
+
const FLOW_CLI = join('tools', 'flow', 'src', 'cli.ts')
|
|
26
|
+
const PROPOSED = /^## Proposed\s*$/m
|
|
27
|
+
|
|
28
|
+
// capability.ts:6. The store's three confidence kinds map onto the target's
|
|
29
|
+
// three vocabulary terms; `queued` becomes Speculative, which is what respec's
|
|
30
|
+
// own handoff does.
|
|
31
|
+
function confidenceFor(c: Confidence): string {
|
|
32
|
+
if (c.kind === 'confirmed') return 'Confirmed'
|
|
33
|
+
if (c.kind === 'inferred') return 'Inferred'
|
|
34
|
+
return 'Speculative'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// capability.ts:9. The target admits intended, poss-accidental and cruft; the
|
|
38
|
+
// store says intended or accidental-candidate. Emitting the store's spelling
|
|
39
|
+
// would produce a file the target refuses to parse.
|
|
40
|
+
function originFor(origin: Requirement['origin']): string {
|
|
41
|
+
return origin === 'accidental-candidate' ? 'poss-accidental' : 'intended'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const escapeRegex = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
45
|
+
// capability.ts:42. The pattern is derived per capability from its declared
|
|
46
|
+
// `ns`, so it also rejects an id that belongs to a different namespace.
|
|
47
|
+
const frIdPattern = (ns: string): RegExp => new RegExp(`^${escapeRegex(ns)}-\\d{3}$`)
|
|
48
|
+
|
|
49
|
+
// capability.ts:48 splits sections on a line beginning '## ', and every cell
|
|
50
|
+
// sits on one table row, so a newline anywhere in a value would both break the
|
|
51
|
+
// row and risk opening a section. Collapsed rather than escaped, because the
|
|
52
|
+
// target's grammar has no way to represent a multi-line cell at all.
|
|
53
|
+
function cell(text: string): string {
|
|
54
|
+
return text
|
|
55
|
+
.replace(/\s*\n\s*/g, ' ')
|
|
56
|
+
.replace(/\|/g, '\\|')
|
|
57
|
+
.trim()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function renderCapability(item: WorkItem, ns: string, reqs: Requirement[]): string {
|
|
61
|
+
const own = reqs.filter((r) => r.cap === item.key)
|
|
62
|
+
const lines = [
|
|
63
|
+
'---',
|
|
64
|
+
`cap: ${item.key}`,
|
|
65
|
+
`ns: ${ns}`,
|
|
66
|
+
`title: ${item.title}`,
|
|
67
|
+
// capability.ts:3. A capability handed over has not been started.
|
|
68
|
+
'status: todo',
|
|
69
|
+
'---',
|
|
70
|
+
'',
|
|
71
|
+
// capability.ts:44. All three sections are required and the target checks
|
|
72
|
+
// for their presence, not their content.
|
|
73
|
+
'## Functional requirements',
|
|
74
|
+
'',
|
|
75
|
+
'| id | requirement | actors | objects | rules | confidence | origin |',
|
|
76
|
+
'| --- | --- | --- | --- | --- | --- | --- |',
|
|
77
|
+
]
|
|
78
|
+
for (const r of own) {
|
|
79
|
+
// capability.ts:74. Exactly seven cells, in this order.
|
|
80
|
+
lines.push(
|
|
81
|
+
`| ${r.id} | ${cell(r.requirement)} | ${cell(r.actors)} | ${cell(r.objects)} | ${cell(r.rules)} | ${confidenceFor(r.confidence)} | ${originFor(r.origin)} |`,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
lines.push(
|
|
85
|
+
'',
|
|
86
|
+
'## Built',
|
|
87
|
+
'',
|
|
88
|
+
'(none)',
|
|
89
|
+
'',
|
|
90
|
+
'## Remaining',
|
|
91
|
+
'',
|
|
92
|
+
'All functional requirements.',
|
|
93
|
+
'',
|
|
94
|
+
)
|
|
95
|
+
return lines.join('\n')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function nsFor(input: HandoffInput, key: string): string {
|
|
99
|
+
return input.capabilities.find((c) => c.slug === key)?.ns ?? ''
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Refuses in plan(), before anything is written. The store's requirement ids
|
|
103
|
+
// are free text; the target derives a pattern from each capability's declared
|
|
104
|
+
// namespace and rejects anything else. Catching it here is the adapter earning
|
|
105
|
+
// its keep: the alternative is a half-written capability-map directory and a
|
|
106
|
+
// parse error in a repo this tool does not own.
|
|
107
|
+
function assertIdGrammar(items: WorkItem[], input: HandoffInput): void {
|
|
108
|
+
const bad: string[] = []
|
|
109
|
+
for (const item of items) {
|
|
110
|
+
const ns = nsFor(input, item.key)
|
|
111
|
+
const pattern = frIdPattern(ns)
|
|
112
|
+
for (const fr of item.frs) {
|
|
113
|
+
if (!pattern.test(fr)) bad.push(`${fr} (capability ${item.key}, ns ${ns})`)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (bad.length > 0) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`flow: ${bad.length} requirement id(s) do not match their capability's namespace pattern <ns>-NNN, which the flow target requires:\n ${bad.join('\n ')}`,
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function readIfPresent(path: string): Promise<string | null> {
|
|
124
|
+
try {
|
|
125
|
+
return await readTextFile(path)
|
|
126
|
+
} catch {
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The adapter's own lines are fenced by an HTML comment pair, and ONLY the
|
|
132
|
+
// region between them is ever rewritten.
|
|
133
|
+
//
|
|
134
|
+
// The previous version stripped every line under `## Proposed` matching
|
|
135
|
+
// `- [something]` before re-adding its own, on the assumption that such a line
|
|
136
|
+
// could only be adapter output. It cannot: `- [W07] Replace the auth provider`
|
|
137
|
+
// is exactly the notation the target's own WORK.md teaches, so a team keeping
|
|
138
|
+
// a shortlist there lost it on the first handoff. The fence makes ownership
|
|
139
|
+
// explicit rather than inferred from shape, which is the only way to edit a
|
|
140
|
+
// file somebody else writes in.
|
|
141
|
+
const FENCE_OPEN = '<!-- migrate:proposed -->'
|
|
142
|
+
const FENCE_CLOSE = '<!-- /migrate:proposed -->'
|
|
143
|
+
|
|
144
|
+
function updateWorkLedger(text: string, items: WorkItem[]): string {
|
|
145
|
+
const block = [
|
|
146
|
+
FENCE_OPEN,
|
|
147
|
+
...items.map((i) => `- [${i.key}] ${i.title} (${i.frs.length} FRs)`),
|
|
148
|
+
FENCE_CLOSE,
|
|
149
|
+
].join('\n')
|
|
150
|
+
|
|
151
|
+
const open = text.indexOf(FENCE_OPEN)
|
|
152
|
+
const close = text.indexOf(FENCE_CLOSE)
|
|
153
|
+
if (open !== -1 && close > open) {
|
|
154
|
+
return text.slice(0, open) + block + text.slice(close + FENCE_CLOSE.length)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let out = text
|
|
158
|
+
if (!PROPOSED.test(out)) {
|
|
159
|
+
out = `${out.replace(/\n*$/, '')}\n\n## Proposed\n`
|
|
160
|
+
}
|
|
161
|
+
const at = PROPOSED.exec(out)
|
|
162
|
+
if (!at) return out
|
|
163
|
+
// Inserted directly under the heading, ahead of whatever the team already
|
|
164
|
+
// keeps there. Nothing outside the fence is read, moved or removed, so a
|
|
165
|
+
// heading of any level below this point is simply none of the adapter's
|
|
166
|
+
// business: the earlier `\n## ` scan for a section end was both wrong (it
|
|
167
|
+
// missed `#` and `###`) and unnecessary once ownership is explicit.
|
|
168
|
+
const headEnd = at.index + at[0].length
|
|
169
|
+
return `${out.slice(0, headEnd)}\n\n${block}\n${out.slice(headEnd)}`
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
type FlowCoverage = { cap: string; coveredIds: string[] }
|
|
173
|
+
|
|
174
|
+
async function runFlow(
|
|
175
|
+
input: HandoffInput,
|
|
176
|
+
args: string[],
|
|
177
|
+
): Promise<{ code: number; out: string; err: string }> {
|
|
178
|
+
const proc = Bun.spawn(['bun', join(input.root, FLOW_CLI), ...args], {
|
|
179
|
+
cwd: input.root,
|
|
180
|
+
stdout: 'pipe',
|
|
181
|
+
stderr: 'pipe',
|
|
182
|
+
})
|
|
183
|
+
const [out, err] = await Promise.all([
|
|
184
|
+
new Response(proc.stdout).text(),
|
|
185
|
+
new Response(proc.stderr).text(),
|
|
186
|
+
])
|
|
187
|
+
await proc.exited
|
|
188
|
+
return { code: proc.exitCode ?? -1, out, err }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export const flow: Adapter = {
|
|
192
|
+
name: 'flow',
|
|
193
|
+
|
|
194
|
+
async plan(input: HandoffInput): Promise<WorkItem[]> {
|
|
195
|
+
const items = buildWorkItems(input.capabilities, input.requirements)
|
|
196
|
+
assertIdGrammar(items, input)
|
|
197
|
+
return items
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
async apply(items: WorkItem[], input: HandoffInput): Promise<ApplyResult> {
|
|
201
|
+
assertIdGrammar(items, input)
|
|
202
|
+
const src = input.config.source.path
|
|
203
|
+
await mkdir(join(input.root, CAP_DIR), { recursive: true })
|
|
204
|
+
|
|
205
|
+
const created: string[] = []
|
|
206
|
+
const updated: string[] = []
|
|
207
|
+
const unchanged: string[] = []
|
|
208
|
+
const refs: Record<string, string> = {}
|
|
209
|
+
|
|
210
|
+
for (const item of items) {
|
|
211
|
+
const rel = join(CAP_DIR, `${item.key}.md`)
|
|
212
|
+
const path = join(input.root, rel)
|
|
213
|
+
const next = renderCapability(item, nsFor(input, item.key), input.requirements)
|
|
214
|
+
const before = await readIfPresent(path)
|
|
215
|
+
if (before === null) created.push(item.key)
|
|
216
|
+
else if (before !== next) updated.push(item.key)
|
|
217
|
+
else unchanged.push(item.key)
|
|
218
|
+
if (before !== next) await writeAtomically(path, next, src)
|
|
219
|
+
refs[item.key] = rel
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const workPath = join(input.root, WORK)
|
|
223
|
+
const work = await readIfPresent(workPath)
|
|
224
|
+
let workChanged = false
|
|
225
|
+
if (work !== null) {
|
|
226
|
+
const nextWork = updateWorkLedger(work, items)
|
|
227
|
+
if (nextWork !== work) {
|
|
228
|
+
await writeAtomically(workPath, nextWork, src)
|
|
229
|
+
workChanged = true
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// A run that rewrote WORK.md has not left the target unchanged, whatever
|
|
233
|
+
// the capability files did. Reporting every item `unchanged` while a file
|
|
234
|
+
// was rewritten makes the idempotency claim untestable from the result.
|
|
235
|
+
if (workChanged && created.length === 0) {
|
|
236
|
+
for (const key of unchanged.splice(0, unchanged.length)) updated.push(key)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// The oracle. When the target carries its own flow CLI, the emission is
|
|
240
|
+
// validated by the parser that will actually read it, and its failure is
|
|
241
|
+
// reported verbatim rather than paraphrased. When it does not, that is
|
|
242
|
+
// said plainly: an unvalidated emission must not read as a checked one.
|
|
243
|
+
if (existsSync(join(input.root, FLOW_CLI))) {
|
|
244
|
+
const map = await runFlow(input, ['map'])
|
|
245
|
+
if (map.code !== 0) throw new Error(`flow map failed:\n${map.err || map.out}`)
|
|
246
|
+
const check = await runFlow(input, ['map', '--check'])
|
|
247
|
+
if (check.code !== 0) throw new Error(`flow map --check failed:\n${check.err || check.out}`)
|
|
248
|
+
} else {
|
|
249
|
+
process.stderr.write(
|
|
250
|
+
`flow: no ${FLOW_CLI} in the target, so the emitted capability files were not validated against the target's own parser\n`,
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return { created, updated, unchanged, refs }
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
async throughput(input: HandoffInput): Promise<Throughput> {
|
|
258
|
+
const basis =
|
|
259
|
+
'flow parity --json in the target, coveredIds (undated: the flow slice ledger holds the dates)'
|
|
260
|
+
if (!existsSync(join(input.root, FLOW_CLI))) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
`flow: no ${FLOW_CLI} in the target, so coverage cannot be read back; run \`flow parity\` there instead`,
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
const result = await runFlow(input, ['parity', '--json'])
|
|
266
|
+
if (result.code !== 0)
|
|
267
|
+
throw new Error(`flow parity --json failed:\n${result.err || result.out}`)
|
|
268
|
+
const coverage = JSON.parse(result.out) as FlowCoverage[]
|
|
269
|
+
const completions: Completion[] = []
|
|
270
|
+
for (const entry of coverage) {
|
|
271
|
+
// Every date this adapter could report would be invented: the target
|
|
272
|
+
// computes covered from merged slices plus a baseline and keeps the
|
|
273
|
+
// dates in a slice ledger. Undated is the honest answer, and forecast
|
|
274
|
+
// says so rather than projecting from nothing.
|
|
275
|
+
for (const fr of entry.coveredIds) completions.push({ fr, doneAt: null })
|
|
276
|
+
}
|
|
277
|
+
completions.sort((a, b) => a.fr.localeCompare(b.fr))
|
|
278
|
+
return { completions, basis }
|
|
279
|
+
},
|
|
280
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import type { Adapter, HandoffInput } from '../handoff.ts'
|
|
2
|
+
import { buildWorkItems } from '../handoff.ts'
|
|
3
|
+
import type { ApplyResult, Completion, Requirement, Throughput, WorkItem } from '../types.ts'
|
|
4
|
+
|
|
5
|
+
// The marker is the whole identity mechanism for an issue. It sits in the
|
|
6
|
+
// body, so one `issue list` resolves every requirement in a single call, and
|
|
7
|
+
// it keeps working after handoff.json is lost or was never committed. That is
|
|
8
|
+
// why `refs` records only the milestone: with one issue per requirement, a
|
|
9
|
+
// stored ref per issue would be N lookups to learn what one list already says,
|
|
10
|
+
// and it would be the copy that goes stale.
|
|
11
|
+
// Anchored to the START of the body, and paired with a closing fence.
|
|
12
|
+
//
|
|
13
|
+
// Unanchored, any issue that merely mentioned a marker was claimed as that
|
|
14
|
+
// requirement's issue and then overwritten wholesale: an issue whose body said
|
|
15
|
+
// "related to <!-- migrate:fr=UM-001 --> which is not this issue" lost a week
|
|
16
|
+
// of repro steps on the next handoff. The adapter's own generated body
|
|
17
|
+
// advertises the marker, so documenting the mechanism was enough to get
|
|
18
|
+
// hijacked. Anchoring means only a body this adapter wrote can be claimed.
|
|
19
|
+
const MARKER = /^<!-- migrate:fr=([^\s>]+) -->/
|
|
20
|
+
const BODY_END = '<!-- /migrate -->'
|
|
21
|
+
|
|
22
|
+
type GhIssue = {
|
|
23
|
+
number: number
|
|
24
|
+
title: string
|
|
25
|
+
body: string
|
|
26
|
+
state: string
|
|
27
|
+
closedAt: string | null
|
|
28
|
+
}
|
|
29
|
+
type GhMilestone = { number: number; title: string }
|
|
30
|
+
|
|
31
|
+
async function gh(input: HandoffInput, args: string[]): Promise<string> {
|
|
32
|
+
const proc = Bun.spawn([input.ghBin, ...args], {
|
|
33
|
+
cwd: input.root,
|
|
34
|
+
stdout: 'pipe',
|
|
35
|
+
stderr: 'pipe',
|
|
36
|
+
})
|
|
37
|
+
const [out, err] = await Promise.all([
|
|
38
|
+
new Response(proc.stdout).text(),
|
|
39
|
+
new Response(proc.stderr).text(),
|
|
40
|
+
])
|
|
41
|
+
await proc.exited
|
|
42
|
+
if (proc.exitCode !== 0) {
|
|
43
|
+
// The failure is reported with gh's own stderr rather than a paraphrase:
|
|
44
|
+
// an auth prompt, a rate limit and a missing repo all arrive here and the
|
|
45
|
+
// operator needs to tell them apart.
|
|
46
|
+
throw new Error(`gh ${args[0]} ${args[1] ?? ''} failed: ${err.trim() || out.trim()}`)
|
|
47
|
+
}
|
|
48
|
+
return out
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function issueTitle(req: Requirement): string {
|
|
52
|
+
return `${req.id} ${req.requirement}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function issueBody(req: Requirement, item: WorkItem): string {
|
|
56
|
+
return [
|
|
57
|
+
`<!-- migrate:fr=${req.id} -->`,
|
|
58
|
+
'',
|
|
59
|
+
`Capability: ${item.title} (${item.key})`,
|
|
60
|
+
'',
|
|
61
|
+
req.requirement,
|
|
62
|
+
'',
|
|
63
|
+
'| field | value |',
|
|
64
|
+
'| --- | --- |',
|
|
65
|
+
`| actors | ${req.actors} |`,
|
|
66
|
+
`| objects | ${req.objects} |`,
|
|
67
|
+
`| rules | ${req.rules} |`,
|
|
68
|
+
`| confidence | ${req.confidence.kind} |`,
|
|
69
|
+
`| origin | ${req.origin} |`,
|
|
70
|
+
'',
|
|
71
|
+
'Emitted by `migrate handoff --adapter github`. The marker above is how a',
|
|
72
|
+
're-run finds this issue again; removing it will produce a duplicate.',
|
|
73
|
+
'Everything above the line below is regenerated on each run; anything you',
|
|
74
|
+
'write beneath it is yours and is preserved.',
|
|
75
|
+
BODY_END,
|
|
76
|
+
].join('\n')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Everything after the closing fence belongs to whoever wrote it. A re-run
|
|
80
|
+
// regenerates the block above it and puts this back byte for byte; without it,
|
|
81
|
+
// any human annotation on a migration issue was reverted by the next handoff,
|
|
82
|
+
// which made the idempotency claim true only for issues nobody had touched.
|
|
83
|
+
// Nothing this function returns is ever generated by the adapter, so a second
|
|
84
|
+
// apply over an untouched issue reproduces the body exactly.
|
|
85
|
+
function humanTail(body: string): string {
|
|
86
|
+
const at = body.indexOf(BODY_END)
|
|
87
|
+
return at === -1 ? '' : body.slice(at + BODY_END.length)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function listMilestones(input: HandoffInput, slug: string): Promise<GhMilestone[]> {
|
|
91
|
+
const raw = await gh(input, ['api', `repos/${slug}/milestones?state=all`])
|
|
92
|
+
return JSON.parse(raw) as GhMilestone[]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Two capabilities can legitimately share a title; they can never share a
|
|
96
|
+
// slug. Keying the milestone on the title collapsed both onto one milestone
|
|
97
|
+
// and pointed both refs at it.
|
|
98
|
+
function milestoneTitle(item: WorkItem): string {
|
|
99
|
+
return `${item.title} (${item.key})`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const ISSUE_LIMIT = 500
|
|
103
|
+
|
|
104
|
+
async function listIssues(input: HandoffInput): Promise<GhIssue[]> {
|
|
105
|
+
const raw = await gh(input, [
|
|
106
|
+
'issue',
|
|
107
|
+
'list',
|
|
108
|
+
'--state',
|
|
109
|
+
'all',
|
|
110
|
+
'--limit',
|
|
111
|
+
String(ISSUE_LIMIT),
|
|
112
|
+
'--json',
|
|
113
|
+
'number,title,body,state,closedAt',
|
|
114
|
+
])
|
|
115
|
+
return JSON.parse(raw) as GhIssue[]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function repoSlug(input: HandoffInput): Promise<string> {
|
|
119
|
+
const raw = await gh(input, ['repo', 'view', '--json', 'nameWithOwner'])
|
|
120
|
+
return (JSON.parse(raw) as { nameWithOwner: string }).nameWithOwner
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function indexByMarker(issues: GhIssue[]): {
|
|
124
|
+
byFr: Map<string, GhIssue>
|
|
125
|
+
duplicates: string[]
|
|
126
|
+
} {
|
|
127
|
+
const byFr = new Map<string, GhIssue>()
|
|
128
|
+
const duplicates: string[] = []
|
|
129
|
+
for (const issue of issues) {
|
|
130
|
+
const m = MARKER.exec(issue.body ?? '')
|
|
131
|
+
const fr = m?.[1]
|
|
132
|
+
if (!fr) continue
|
|
133
|
+
// Two issues under one marker used to mean the last silently won, leaving
|
|
134
|
+
// the other to drift stale forever with no diagnostic.
|
|
135
|
+
if (byFr.has(fr)) duplicates.push(fr)
|
|
136
|
+
else byFr.set(fr, issue)
|
|
137
|
+
}
|
|
138
|
+
return { byFr, duplicates }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const github: Adapter = {
|
|
142
|
+
name: 'github',
|
|
143
|
+
|
|
144
|
+
async plan(input: HandoffInput): Promise<WorkItem[]> {
|
|
145
|
+
return buildWorkItems(input.capabilities, input.requirements)
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
async apply(items: WorkItem[], input: HandoffInput): Promise<ApplyResult> {
|
|
149
|
+
const slug = await repoSlug(input)
|
|
150
|
+
const milestones = await listMilestones(input, slug)
|
|
151
|
+
const byTitle = new Map(milestones.map((m) => [m.title, m]))
|
|
152
|
+
const issues = await listIssues(input)
|
|
153
|
+
if (issues.length >= ISSUE_LIMIT) {
|
|
154
|
+
// The listing is a hard window with no paging. Silently short, every
|
|
155
|
+
// requirement outside it would be re-filed as a duplicate on each run.
|
|
156
|
+
throw new Error(
|
|
157
|
+
`github: the repository returned ${issues.length} issues, at or above the ${ISSUE_LIMIT} listing limit, so existing migration issues may fall outside the window and be duplicated; narrow the repository or raise the limit before re-running`,
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
const { byFr, duplicates } = indexByMarker(issues)
|
|
161
|
+
for (const fr of duplicates) {
|
|
162
|
+
process.stderr.write(
|
|
163
|
+
`github: more than one issue carries the marker for ${fr}; only the first is updated, and the others will drift\n`,
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
const byId = new Map(input.requirements.map((r) => [r.id, r]))
|
|
167
|
+
|
|
168
|
+
const created: string[] = []
|
|
169
|
+
const updated: string[] = []
|
|
170
|
+
const unchanged: string[] = []
|
|
171
|
+
const refs: Record<string, string> = {}
|
|
172
|
+
|
|
173
|
+
for (const item of items) {
|
|
174
|
+
let milestoneCreated = false
|
|
175
|
+
let milestone = byTitle.get(milestoneTitle(item))
|
|
176
|
+
if (!milestone) {
|
|
177
|
+
const raw = await gh(input, [
|
|
178
|
+
'api',
|
|
179
|
+
`repos/${slug}/milestones`,
|
|
180
|
+
'-X',
|
|
181
|
+
'POST',
|
|
182
|
+
'-f',
|
|
183
|
+
`title=${milestoneTitle(item)}`,
|
|
184
|
+
'-f',
|
|
185
|
+
`description=${item.body.split('\n')[0] ?? ''}`,
|
|
186
|
+
])
|
|
187
|
+
milestone = JSON.parse(raw) as GhMilestone
|
|
188
|
+
byTitle.set(milestoneTitle(item), milestone)
|
|
189
|
+
milestoneCreated = true
|
|
190
|
+
}
|
|
191
|
+
refs[item.key] = `milestone:${milestone.number}`
|
|
192
|
+
|
|
193
|
+
let touched = false
|
|
194
|
+
for (const fr of item.frs) {
|
|
195
|
+
const req = byId.get(fr)
|
|
196
|
+
if (!req) continue
|
|
197
|
+
const existing = byFr.get(fr)
|
|
198
|
+
// The human tail is carried across, so an update replaces only the
|
|
199
|
+
// block this adapter owns.
|
|
200
|
+
const body = issueBody(req, item) + humanTail(existing?.body ?? '')
|
|
201
|
+
if (!existing) {
|
|
202
|
+
await gh(input, [
|
|
203
|
+
'issue',
|
|
204
|
+
'create',
|
|
205
|
+
'--title',
|
|
206
|
+
issueTitle(req),
|
|
207
|
+
'--body',
|
|
208
|
+
body,
|
|
209
|
+
'--milestone',
|
|
210
|
+
milestoneTitle(item),
|
|
211
|
+
])
|
|
212
|
+
touched = true
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
if (existing.body !== body) {
|
|
216
|
+
await gh(input, [
|
|
217
|
+
'issue',
|
|
218
|
+
'edit',
|
|
219
|
+
String(existing.number),
|
|
220
|
+
'--body',
|
|
221
|
+
body,
|
|
222
|
+
'--milestone',
|
|
223
|
+
milestoneTitle(item),
|
|
224
|
+
])
|
|
225
|
+
touched = true
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (milestoneCreated) created.push(item.key)
|
|
230
|
+
else if (touched) updated.push(item.key)
|
|
231
|
+
else unchanged.push(item.key)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { created, updated, unchanged, refs }
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
async throughput(input: HandoffInput): Promise<Throughput> {
|
|
238
|
+
const issues = await listIssues(input)
|
|
239
|
+
const known = new Set(input.requirements.map((r) => r.id))
|
|
240
|
+
const completions: Completion[] = []
|
|
241
|
+
// Deduped by requirement: `velocities` divides a completion count by
|
|
242
|
+
// elapsed days, so two closed issues under one marker would inflate the
|
|
243
|
+
// measured delivery rate even though coverage funnels through a Set and
|
|
244
|
+
// would not have noticed.
|
|
245
|
+
const seen = new Set<string>()
|
|
246
|
+
for (const issue of issues) {
|
|
247
|
+
if (issue.state.toUpperCase() !== 'CLOSED') continue
|
|
248
|
+
const fr = MARKER.exec(issue.body ?? '')?.[1]
|
|
249
|
+
if (fr && seen.has(fr)) continue
|
|
250
|
+
// An issue with no marker was filed by someone else and says nothing
|
|
251
|
+
// about this migration; one whose marker names a requirement the store
|
|
252
|
+
// does not have is reported by coverage rather than silently counted.
|
|
253
|
+
if (!fr || !known.has(fr)) continue
|
|
254
|
+
seen.add(fr)
|
|
255
|
+
completions.push({ fr, doneAt: issue.closedAt ? issue.closedAt.slice(0, 10) : null })
|
|
256
|
+
}
|
|
257
|
+
completions.sort((a, b) => a.fr.localeCompare(b.fr))
|
|
258
|
+
return { completions, basis: 'github issues closed, dated from closedAt' }
|
|
259
|
+
},
|
|
260
|
+
}
|