@erclx/canon 5.65.0 → 5.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/app-drive/REQUIREMENT.md +35 -0
  4. package/claude/skills/app-drive/SKILL.md +112 -0
  5. package/claude/skills/app-drive/references/config.md +38 -0
  6. package/claude/skills/app-drive/references/drive-record.md +70 -0
  7. package/claude/skills/app-drive/references/isolate.md +46 -0
  8. package/claude/skills/canon-cli/SKILL.md +2 -0
  9. package/claude/skills/create-skill/references/skill.md +1 -1
  10. package/claude/skills/git-pr/references/labels.md +6 -2
  11. package/claude/skills/git-pr-close/REQUIREMENT.md +29 -0
  12. package/claude/skills/git-pr-close/SKILL.md +55 -0
  13. package/claude/skills/git-pr-close/references/close.md +54 -0
  14. package/claude/skills/plan-feature/references/plan.md +1 -1
  15. package/claude/skills/plan-intake/references/intake.md +12 -2
  16. package/claude/skills/pr-evidence/SKILL.md +2 -2
  17. package/claude/skills/pr-evidence/references/evidence.md +3 -1
  18. package/claude/skills/review-ui/SKILL.md +5 -4
  19. package/claude/skills/review-ui/references/drive-copy.md +4 -0
  20. package/claude/skills/review-ui/references/driving.md +1 -1
  21. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +15 -2
  22. package/claude/skills/role-planner/SKILL.md +2 -2
  23. package/claude/skills/role-worker/SKILL.md +6 -8
  24. package/claude/skills/session-map/REQUIREMENT.md +1 -1
  25. package/claude/skills/session-map/SKILL.md +8 -1
  26. package/claude/skills/session-map/references/session.md +19 -5
  27. package/claude/skills/ui-record/REQUIREMENT.md +34 -0
  28. package/claude/skills/ui-record/SKILL.md +103 -0
  29. package/claude/skills/ui-record/references/config.md +40 -0
  30. package/claude/skills/ui-record/references/handoff.md +48 -0
  31. package/claude/skills/ui-record/references/take-craft.md +92 -0
  32. package/docs/agents/records.md +1 -0
  33. package/docs/workflow/parallel-features.md +1 -1
  34. package/docs/workflow/skill-map-on-request.md +1 -0
  35. package/docs/workflow/skill-map.md +2 -0
  36. package/docs/workflow/visual-design-workflow.md +2 -1
  37. package/package.json +1 -1
  38. package/src/audits/catalog.ts +5 -1
  39. package/src/browser/agent-cli.ts +68 -0
  40. package/src/claude/cases/git.ts +4 -0
  41. package/src/claude/cases/workflow.ts +20 -0
  42. package/src/cli/help.ts +3 -0
  43. package/src/cli.ts +4 -0
  44. package/src/commands/browser.ts +102 -0
  45. package/src/commands/intake.ts +181 -1
  46. package/src/commands/labels.ts +152 -4
  47. package/src/commands/pr.ts +278 -8
  48. package/src/commands/records.ts +18 -1
  49. package/src/commands/take.ts +266 -0
  50. package/src/context/requirements.ts +1 -11
  51. package/src/git/status.ts +73 -0
  52. package/src/intake/drive-check.ts +355 -0
  53. package/src/intake/folder.ts +1 -1
  54. package/src/labels/apply.ts +10 -5
  55. package/src/labels/sync.ts +17 -0
  56. package/src/markdown/scan.ts +10 -0
  57. package/src/pr/close.ts +152 -0
  58. package/src/pr/drive-copy.ts +97 -84
  59. package/src/pr/health.ts +56 -0
  60. package/src/pr/local.ts +49 -8
  61. package/src/pr/rest.ts +10 -2
  62. package/src/records/session-map.ts +75 -0
  63. package/src/records/validate.ts +64 -8
  64. package/src/sandbox/replay/run.ts +30 -5
  65. package/src/take/camera.ts +309 -0
  66. package/src/take/encode.ts +170 -0
  67. package/src/take/film.ts +206 -0
  68. package/src/take/fixtures/fade.html +49 -0
  69. package/src/take/fixtures/fetch.html +38 -0
  70. package/src/take/log.ts +109 -0
  71. package/src/take/perform.ts +200 -0
  72. package/src/take/plan.ts +193 -0
  73. package/src/take/screencast.ts +94 -0
  74. package/src/take/shell.ts +287 -0
  75. package/src/take/stepper.ts +252 -0
  76. package/src/take/zoom/NOTICE.md +45 -0
  77. package/src/take/zoom/math-utils.ts +69 -0
  78. package/src/take/zoom/region-utils.ts +55 -0
  79. package/src/take/zoom/spring.ts +133 -0
  80. package/src/tasks/archive.ts +1 -3
  81. package/src/tooling/verify-stack.ts +4 -5
  82. package/tooling/base/reference.md +1 -0
  83. package/tooling/base/seeds/canon/config/pr-labels.toml +41 -0
  84. package/claude/skills/review-ui/scripts/pw.sh +0 -60
@@ -0,0 +1,355 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
3
+ import { basename, isAbsolute, join, normalize } from 'node:path'
4
+ import {
5
+ changedSince,
6
+ readWorktreeStatus,
7
+ type StatusEntry,
8
+ } from '@/git/status'
9
+ import { extractOrdinal, type IntakeRefused, openFolder } from '@/intake/folder'
10
+ import { requirementsRel } from '@/context/requirements'
11
+ import { bodyLines } from '@/markdown/scan'
12
+
13
+ /** The record a drive leaves in its intake folder, which this check reads. */
14
+ export const DRIVE_RECORD = 'drive.json'
15
+
16
+ /** The one folder inside an intake folder that evidence may live in. */
17
+ export const EVIDENCE_DIR = 'evidence'
18
+
19
+ const SCRATCH = join('.canon', 'tmp', 'app-drive')
20
+
21
+ /**
22
+ * The refusals this check adds to the folder lookup's own, which reach a
23
+ * caller unchanged as an `IntakeRefused`.
24
+ */
25
+ export const DRIVE_REFUSALS = [
26
+ 'bad-input',
27
+ 'no-record',
28
+ 'bad-record',
29
+ 'no-requirements',
30
+ 'no-baseline',
31
+ 'unreadable-tree',
32
+ ] as const
33
+
34
+ export type DriveRefusal = (typeof DRIVE_REFUSALS)[number]
35
+
36
+ export interface DriveRefused {
37
+ readonly ok: false
38
+ readonly reason: DriveRefusal
39
+ readonly message: string
40
+ readonly detail: readonly string[]
41
+ }
42
+
43
+ export interface BaselineRecorded {
44
+ readonly ok: true
45
+ readonly slug: string
46
+ readonly path: string
47
+ readonly entries: number
48
+ }
49
+
50
+ /** Each list names what failed, and an empty list is a clean axis. */
51
+ export interface DriveFindings {
52
+ /** Goals the record carries no entry for. */
53
+ readonly unmapped: readonly string[]
54
+ /** Goals marked `not-driven` with no reason given. */
55
+ readonly unreasoned: readonly string[]
56
+ /** Entries naming a goal the requirements record does not hold. */
57
+ readonly unknown: readonly string[]
58
+ /** Paths in the main checkout that differ from the baseline. */
59
+ readonly changed: readonly string[]
60
+ /** Evidence paths that resolve outside the folder's `evidence/`. */
61
+ readonly strayEvidence: readonly string[]
62
+ /** Evidence paths inside `evidence/` that hold no file. */
63
+ readonly missingEvidence: readonly string[]
64
+ /** The drive's worktree, when the record names one still on disk. */
65
+ readonly worktreeLeft: readonly string[]
66
+ }
67
+
68
+ export interface DriveChecked {
69
+ readonly ok: true
70
+ readonly slug: string
71
+ readonly reason: 'clean' | 'failed'
72
+ readonly goals: number
73
+ readonly findings: DriveFindings
74
+ }
75
+
76
+ export type BaselineOutcome = BaselineRecorded | DriveRefused
77
+ export type CheckOutcome = DriveChecked | DriveRefused | IntakeRefused
78
+
79
+ type GoalStatus = 'driven' | 'not-driven'
80
+
81
+ interface DriveGoal {
82
+ readonly goal: string
83
+ readonly status: GoalStatus
84
+ readonly reason?: string
85
+ }
86
+
87
+ interface DriveRecord {
88
+ readonly goals: readonly DriveGoal[]
89
+ readonly evidence: readonly string[]
90
+ readonly worktree?: string
91
+ }
92
+
93
+ function refuse(
94
+ reason: DriveRefusal,
95
+ message: string,
96
+ detail: readonly string[] = [],
97
+ ): DriveRefused {
98
+ return { ok: false, reason, message, detail }
99
+ }
100
+
101
+ /**
102
+ * A goal as both sides compare it: a leading bold label and a trailing period
103
+ * dropped, and whitespace collapsed. Requirements records carry no goal ids,
104
+ * so the text is the identity, and stripping the label lets a drive record
105
+ * paste the bullet whole or name the sentence alone.
106
+ */
107
+ function normalizeGoal(text: string): string {
108
+ return text
109
+ .replace(/\s+/g, ' ')
110
+ .trim()
111
+ .replace(/^\*\*[^*]+\*\*\s*/, '')
112
+ .replace(/\.$/, '')
113
+ .trim()
114
+ }
115
+
116
+ /**
117
+ * Every top-level bullet under `## Goals`, through any H3 grouping, up to the
118
+ * next H2. A wrapped bullet's indented continuation joins it, and a nested
119
+ * bullet is detail of its parent rather than a goal of its own.
120
+ */
121
+ export function readGoals(source: string): string[] {
122
+ const goals: string[] = []
123
+ let inGoals = false
124
+ let current: string | undefined
125
+ let appending = false
126
+
127
+ const close = () => {
128
+ if (current !== undefined) goals.push(normalizeGoal(current))
129
+ current = undefined
130
+ appending = false
131
+ }
132
+
133
+ for (const line of bodyLines(source)) {
134
+ if (line.fenced) continue
135
+ const text = line.text
136
+
137
+ if (/^#{1,2}\s/.test(text)) {
138
+ close()
139
+ inGoals = /^##\s+Goals\s*$/.test(text)
140
+ continue
141
+ }
142
+ if (!inGoals) continue
143
+
144
+ const bullet = /^[-*]\s+(.*)$/.exec(text)
145
+ if (bullet) {
146
+ close()
147
+ current = bullet[1]
148
+ appending = true
149
+ } else if (/^\s+[-*]\s/.test(text) || text.trim() === '') {
150
+ appending = false
151
+ } else if (appending && /^\s+\S/.test(text)) {
152
+ current = `${current} ${text.trim()}`
153
+ } else if (/^#{3,}\s/.test(text)) {
154
+ appending = false
155
+ }
156
+ }
157
+ close()
158
+
159
+ return goals
160
+ }
161
+
162
+ function parseRecord(raw: string): DriveRecord | string {
163
+ let parsed: unknown
164
+ try {
165
+ parsed = JSON.parse(raw)
166
+ } catch {
167
+ return `${DRIVE_RECORD} does not parse as JSON.`
168
+ }
169
+ if (typeof parsed !== 'object' || parsed === null) {
170
+ return `${DRIVE_RECORD} is not an object.`
171
+ }
172
+
173
+ const { goals, evidence, worktree } = parsed as Record<string, unknown>
174
+ if (!Array.isArray(goals)) {
175
+ return `${DRIVE_RECORD} carries no "goals" list.`
176
+ }
177
+
178
+ const entries: DriveGoal[] = []
179
+ for (const [index, entry] of goals.entries()) {
180
+ const { goal, status, reason } = (entry ?? {}) as Record<string, unknown>
181
+ if (typeof goal !== 'string' || goal.trim() === '') {
182
+ return `${DRIVE_RECORD} goals[${index}] carries no "goal" text.`
183
+ }
184
+ if (status !== 'driven' && status !== 'not-driven') {
185
+ return `${DRIVE_RECORD} goals[${index}] has a "status" other than "driven" or "not-driven".`
186
+ }
187
+ entries.push({
188
+ goal,
189
+ status,
190
+ ...(typeof reason === 'string' && { reason }),
191
+ })
192
+ }
193
+
194
+ if (
195
+ evidence !== undefined &&
196
+ (!Array.isArray(evidence) ||
197
+ !evidence.every((item): item is string => typeof item === 'string'))
198
+ ) {
199
+ return `${DRIVE_RECORD} carries an "evidence" field that is not a list of paths.`
200
+ }
201
+ if (worktree !== undefined && typeof worktree !== 'string') {
202
+ return `${DRIVE_RECORD} carries a "worktree" that is not a path.`
203
+ }
204
+
205
+ return {
206
+ goals: entries,
207
+ evidence: evidence ?? [],
208
+ ...(worktree !== undefined && { worktree }),
209
+ }
210
+ }
211
+
212
+ function isEvidencePath(path: string): boolean {
213
+ if (isAbsolute(path)) return false
214
+ const segments = normalize(path).split(/[\\/]/)
215
+ return (
216
+ segments[0] === EVIDENCE_DIR &&
217
+ segments.length > 1 &&
218
+ !segments.includes('..')
219
+ )
220
+ }
221
+
222
+ /**
223
+ * The scratch key for a topic, with any ordinal dropped, so a baseline taken
224
+ * before the intake folder is numbered and a check naming the numbered folder
225
+ * meet at one file. Undefined for a slug that could leave scratch.
226
+ */
227
+ function bareSlug(slug: string): string | undefined {
228
+ if (!/^[a-z0-9][a-z0-9.-]*$/i.test(slug) || slug.includes('..')) {
229
+ return undefined
230
+ }
231
+ const ordinal = extractOrdinal(slug)
232
+ return ordinal === '' ? slug : slug.slice(ordinal.length + 1)
233
+ }
234
+
235
+ function baselinePath(root: string, bare: string): string {
236
+ return join(root, SCRATCH, bare, 'baseline.json')
237
+ }
238
+
239
+ function badSlug(slug: string): DriveRefused {
240
+ return refuse(
241
+ 'bad-input',
242
+ `Not an intake slug: ${slug}. Pass the folder's topic, as in app-drive-1-0.`,
243
+ )
244
+ }
245
+
246
+ /**
247
+ * Stores the main checkout's status before a drive, so the check can tell a
248
+ * write the drive made from one already in the tree. Scratch rather than the
249
+ * intake folder, since the baseline is safe to lose once the check passes and
250
+ * the folder holds findings alone. A second call replaces the first.
251
+ */
252
+ export async function recordBaseline(
253
+ root: string,
254
+ slug: string,
255
+ ): Promise<BaselineOutcome> {
256
+ const bare = bareSlug(slug)
257
+ if (bare === undefined) return badSlug(slug)
258
+
259
+ const status = await readWorktreeStatus(root)
260
+ if (status === undefined) {
261
+ return refuse('unreadable-tree', `git cannot read the status of ${root}.`)
262
+ }
263
+
264
+ const path = baselinePath(root, bare)
265
+ await mkdir(join(path, '..'), { recursive: true })
266
+ await writeFile(path, `${JSON.stringify(status)}\n`)
267
+
268
+ return { ok: true, slug: bare, path, entries: status.length }
269
+ }
270
+
271
+ /**
272
+ * Checks a finished drive against its intake folder's `drive.json`: every
273
+ * goal mapped or skipped with a reason, the main checkout unchanged since the
274
+ * baseline, the evidence inside the folder's `evidence/`, and the drive's
275
+ * worktree gone. It reports what failed and never reverts a changed path,
276
+ * since only the operator knows which of them to keep.
277
+ */
278
+ export async function checkDrive(
279
+ root: string,
280
+ slug: string,
281
+ ): Promise<CheckOutcome> {
282
+ const bare = bareSlug(slug)
283
+ if (bare === undefined) return badSlug(slug)
284
+
285
+ const folder = await openFolder(root, slug)
286
+ if (typeof folder !== 'string') return folder
287
+
288
+ const recordPath = join(folder, DRIVE_RECORD)
289
+ if (!existsSync(recordPath)) {
290
+ return refuse(
291
+ 'no-record',
292
+ `No ${DRIVE_RECORD} in ${basename(folder)}, so no drive was recorded.`,
293
+ )
294
+ }
295
+ const record = parseRecord(await readFile(recordPath, 'utf8'))
296
+ if (typeof record === 'string') return refuse('bad-record', record)
297
+
298
+ const requirements = join(root, requirementsRel(root))
299
+ if (!existsSync(requirements)) {
300
+ return refuse(
301
+ 'no-requirements',
302
+ `No requirements record at ${requirementsRel(root)}, so no goal can be checked.`,
303
+ )
304
+ }
305
+ const goals = readGoals(await readFile(requirements, 'utf8'))
306
+
307
+ let baseline: readonly StatusEntry[]
308
+ try {
309
+ baseline = JSON.parse(await readFile(baselinePath(root, bare), 'utf8'))
310
+ } catch {
311
+ return refuse(
312
+ 'no-baseline',
313
+ `No baseline for ${bare}. Run canon intake drive-check ${bare} --baseline before the drive.`,
314
+ )
315
+ }
316
+ const current = await readWorktreeStatus(root)
317
+ if (current === undefined) {
318
+ return refuse('unreadable-tree', `git cannot read the status of ${root}.`)
319
+ }
320
+
321
+ const entries = new Map(
322
+ record.goals.map((entry) => [normalizeGoal(entry.goal), entry]),
323
+ )
324
+ const known = new Set(goals)
325
+ const unreasoned = goals.filter((goal) => {
326
+ const entry = entries.get(goal)
327
+ return entry?.status === 'not-driven' && !entry.reason?.trim()
328
+ })
329
+
330
+ const findings: DriveFindings = {
331
+ unmapped: goals.filter((goal) => !entries.has(goal)),
332
+ unreasoned,
333
+ unknown: record.goals
334
+ .filter((entry) => !known.has(normalizeGoal(entry.goal)))
335
+ .map((entry) => entry.goal),
336
+ changed: changedSince(baseline, current),
337
+ strayEvidence: record.evidence.filter((path) => !isEvidencePath(path)),
338
+ missingEvidence: record.evidence.filter(
339
+ (path) => isEvidencePath(path) && !existsSync(join(folder, path)),
340
+ ),
341
+ worktreeLeft:
342
+ record.worktree !== undefined && existsSync(record.worktree)
343
+ ? [record.worktree]
344
+ : [],
345
+ }
346
+ const failed = Object.values(findings).some((list) => list.length > 0)
347
+
348
+ return {
349
+ ok: true,
350
+ slug: basename(folder),
351
+ reason: failed ? 'failed' : 'clean',
352
+ goals: goals.length,
353
+ findings,
354
+ }
355
+ }
@@ -142,7 +142,7 @@ export function extractOrdinal(name: string): string {
142
142
  return /^\d{2,}-/.exec(name)?.[0].slice(0, -1) ?? ''
143
143
  }
144
144
 
145
- async function openFolder(
145
+ export async function openFolder(
146
146
  root: string,
147
147
  slug: string,
148
148
  ): Promise<string | IntakeRefused> {
@@ -20,20 +20,25 @@ export interface LabelDiff {
20
20
  * A label the remote lacks is separated from the addable set rather than
21
21
  * dropped, because the REST add endpoint creates any label it is handed and
22
22
  * that write to the repository settings is not the map's call.
23
+ *
24
+ * Names match regardless of case, as GitHub holds them, so this agrees with
25
+ * `missingLabels` on which labels the remote carries.
23
26
  */
24
27
  export function diffLabels(
25
28
  earned: readonly string[],
26
29
  present: readonly string[],
27
30
  remote: readonly string[],
28
31
  ): LabelDiff {
29
- const has = new Set(present)
30
- const carried = new Set(remote)
32
+ const fold = (labels: readonly string[]): Set<string> =>
33
+ new Set(labels.map((label) => label.toLowerCase()))
34
+ const has = fold(present)
35
+ const carried = fold(remote)
31
36
 
32
- const owed = earned.filter((label) => !has.has(label))
37
+ const owed = earned.filter((label) => !has.has(label.toLowerCase()))
33
38
 
34
39
  return {
35
40
  owed,
36
- toAdd: owed.filter((label) => carried.has(label)),
37
- missingOnRemote: owed.filter((label) => !carried.has(label)),
41
+ toAdd: owed.filter((label) => carried.has(label.toLowerCase())),
42
+ missingOnRemote: owed.filter((label) => !carried.has(label.toLowerCase())),
38
43
  }
39
44
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The labels a map names that the remote does not carry yet, in map order.
3
+ *
4
+ * Only ever a subset of the map, so a label a person made by hand never
5
+ * appears and nothing here can become an edit or a deletion on the remote.
6
+ *
7
+ * GitHub holds label names unique regardless of case, so a remote `Docs`
8
+ * counts as carrying `docs`. Creating it would be refused, and every later
9
+ * run would report the same failure.
10
+ */
11
+ export function missingLabels(
12
+ mapLabels: readonly string[],
13
+ remote: readonly string[],
14
+ ): string[] {
15
+ const carried = new Set(remote.map((label) => label.toLowerCase()))
16
+ return mapLabels.filter((label) => !carried.has(label.toLowerCase()))
17
+ }
@@ -122,6 +122,16 @@ export function bodyLines(source: string): BodyLine[] {
122
122
  )
123
123
  }
124
124
 
125
+ /**
126
+ * Counts whitespace-delimited tokens, the unit a record's word cap is stated
127
+ * in. A code span counts as the tokens it holds, so a line citing a long path
128
+ * pays for it the way a reader does. A caller gating on the count hands in
129
+ * prose with fenced lines already dropped.
130
+ */
131
+ export function wordCount(text: string): number {
132
+ return text.match(/\S+/g)?.length ?? 0
133
+ }
134
+
125
135
  /**
126
136
  * Drops every fenced block from raw text, frontmatter included as content.
127
137
  *
@@ -0,0 +1,152 @@
1
+ import { scanPhaseLabels } from '@/labels/phase'
2
+ import { BAN_SETS } from '@/markdown/bans'
3
+ import { type DensityFinding, densityFindings } from '@/markdown/density'
4
+ import { bodyLines, scanBans } from '@/markdown/scan'
5
+
6
+ /** Why the verb did not close, in the order the refusals are read. */
7
+ export type CloseRefusal =
8
+ | 'no-reason'
9
+ | 'reason-unpublishable'
10
+ | 'gh-failed'
11
+ | 'pull-closed'
12
+ | 'head-mismatch'
13
+ | 'comment-failed'
14
+ | 'close-failed'
15
+
16
+ export interface ClosePull {
17
+ readonly isOpen: boolean
18
+ /** The head branch name, which the caller's expected head is checked against. */
19
+ readonly branch: string
20
+ /** Whether the head lives in another repository, whose branch this one cannot delete. */
21
+ readonly isFork: boolean
22
+ }
23
+
24
+ /**
25
+ * Everything the verb reads or writes, injected so every refusal runs without
26
+ * a network. A read or write that failed returns undefined or false, never a
27
+ * guess.
28
+ */
29
+ export interface CloseApi {
30
+ readPull(): Promise<ClosePull | undefined>
31
+ /** Posts the reason and answers the comment's URL. */
32
+ postComment(body: string): Promise<string | undefined>
33
+ patchClosed(): Promise<boolean>
34
+ deleteBranch(branch: string): Promise<boolean>
35
+ }
36
+
37
+ export interface CloseOptions {
38
+ readonly number: number
39
+ readonly reason: string
40
+ /** The head branch the caller means, so a mistyped number closes nothing. */
41
+ readonly head: string
42
+ readonly isKeepBranch: boolean
43
+ }
44
+
45
+ /** What the reason carries that a reader on the remote cannot use. */
46
+ export interface ReasonFindings {
47
+ readonly isClean: boolean
48
+ readonly bannedCharacters: readonly string[]
49
+ readonly phaseLabels: readonly string[]
50
+ readonly boardReferences: readonly string[]
51
+ readonly sessionLinks: readonly string[]
52
+ readonly density: readonly DensityFinding[]
53
+ }
54
+
55
+ /** What happened to the head branch once the pull request closed. */
56
+ export type BranchOutcome = 'deleted' | 'kept' | 'fork' | 'delete-failed'
57
+
58
+ export interface CloseRecord {
59
+ readonly reason: 'closed' | CloseRefusal
60
+ readonly isOk: boolean
61
+ readonly number: number
62
+ readonly commentUrl?: string
63
+ readonly branch?: BranchOutcome
64
+ readonly findings?: ReasonFindings
65
+ }
66
+
67
+ /**
68
+ * Runs the checks `canon labels scan` runs on a pull request body, plus
69
+ * density measured on the reason itself.
70
+ *
71
+ * The scan's own post density reads a body only when it opens on a known
72
+ * post heading, and a closing comment carries none, so an overloaded reason
73
+ * would pass it unmeasured.
74
+ */
75
+ export function checkReason(reason: string): ReasonFindings {
76
+ const lines = bodyLines(reason)
77
+ const bannedCharacters = scanBans(lines, BAN_SETS).map(
78
+ (finding) => finding.term,
79
+ )
80
+ const { phaseLabels, boardReferences, sessionLinks } = scanPhaseLabels({
81
+ title: '',
82
+ body: reason,
83
+ headRefName: '',
84
+ })
85
+ const density = densityFindings(lines)
86
+
87
+ return {
88
+ isClean:
89
+ bannedCharacters.length === 0 &&
90
+ phaseLabels.length === 0 &&
91
+ boardReferences.length === 0 &&
92
+ sessionLinks.length === 0 &&
93
+ density.length === 0,
94
+ bannedCharacters,
95
+ phaseLabels,
96
+ boardReferences,
97
+ sessionLinks,
98
+ density,
99
+ }
100
+ }
101
+
102
+ function refused(
103
+ reason: CloseRefusal,
104
+ number: number,
105
+ extra: Partial<CloseRecord> = {},
106
+ ): CloseRecord {
107
+ return { reason, isOk: false, number, ...extra }
108
+ }
109
+
110
+ /**
111
+ * Closes one pull request unmerged with its reason posted first.
112
+ *
113
+ * The comment lands before the close, so a failure between the two leaves a
114
+ * comment on an open pull request rather than a closed one carrying no
115
+ * reason, which is the defect this verb exists to remove. The branch delete
116
+ * runs last and never fails the close, since the pull request is already
117
+ * closed with its reason by then.
118
+ */
119
+ export async function close(
120
+ api: CloseApi,
121
+ opts: CloseOptions,
122
+ ): Promise<CloseRecord> {
123
+ const { number } = opts
124
+ const reason = opts.reason.trim()
125
+
126
+ if (reason === '') return refused('no-reason', number)
127
+ const findings = checkReason(reason)
128
+ if (!findings.isClean) {
129
+ return refused('reason-unpublishable', number, { findings })
130
+ }
131
+
132
+ const pull = await api.readPull()
133
+ if (pull === undefined) return refused('gh-failed', number)
134
+ if (!pull.isOpen) return refused('pull-closed', number)
135
+ if (pull.branch !== opts.head) return refused('head-mismatch', number)
136
+
137
+ const commentUrl = await api.postComment(reason)
138
+ if (commentUrl === undefined) return refused('comment-failed', number)
139
+
140
+ if (!(await api.patchClosed())) {
141
+ return refused('close-failed', number, { commentUrl })
142
+ }
143
+
144
+ let branch: BranchOutcome
145
+ if (opts.isKeepBranch) branch = 'kept'
146
+ else if (pull.isFork) branch = 'fork'
147
+ else {
148
+ branch = (await api.deleteBranch(pull.branch)) ? 'deleted' : 'delete-failed'
149
+ }
150
+
151
+ return { reason: 'closed', isOk: true, number, commentUrl, branch }
152
+ }