@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.
- package/CHANGELOG.md +26 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/REQUIREMENT.md +35 -0
- package/claude/skills/app-drive/SKILL.md +112 -0
- package/claude/skills/app-drive/references/config.md +38 -0
- package/claude/skills/app-drive/references/drive-record.md +70 -0
- package/claude/skills/app-drive/references/isolate.md +46 -0
- package/claude/skills/canon-cli/SKILL.md +2 -0
- package/claude/skills/create-skill/references/skill.md +1 -1
- package/claude/skills/git-pr/references/labels.md +6 -2
- package/claude/skills/git-pr-close/REQUIREMENT.md +29 -0
- package/claude/skills/git-pr-close/SKILL.md +55 -0
- package/claude/skills/git-pr-close/references/close.md +54 -0
- package/claude/skills/plan-feature/references/plan.md +1 -1
- package/claude/skills/plan-intake/references/intake.md +12 -2
- package/claude/skills/pr-evidence/SKILL.md +2 -2
- package/claude/skills/pr-evidence/references/evidence.md +3 -1
- package/claude/skills/review-ui/SKILL.md +5 -4
- package/claude/skills/review-ui/references/drive-copy.md +4 -0
- package/claude/skills/review-ui/references/driving.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +15 -2
- package/claude/skills/role-planner/SKILL.md +2 -2
- package/claude/skills/role-worker/SKILL.md +6 -8
- package/claude/skills/session-map/REQUIREMENT.md +1 -1
- package/claude/skills/session-map/SKILL.md +8 -1
- package/claude/skills/session-map/references/session.md +19 -5
- package/claude/skills/ui-record/REQUIREMENT.md +34 -0
- package/claude/skills/ui-record/SKILL.md +103 -0
- package/claude/skills/ui-record/references/config.md +40 -0
- package/claude/skills/ui-record/references/handoff.md +48 -0
- package/claude/skills/ui-record/references/take-craft.md +92 -0
- package/docs/agents/records.md +1 -0
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map-on-request.md +1 -0
- package/docs/workflow/skill-map.md +2 -0
- package/docs/workflow/visual-design-workflow.md +2 -1
- package/package.json +1 -1
- package/src/audits/catalog.ts +5 -1
- package/src/browser/agent-cli.ts +68 -0
- package/src/claude/cases/git.ts +4 -0
- package/src/claude/cases/workflow.ts +20 -0
- package/src/cli/help.ts +3 -0
- package/src/cli.ts +4 -0
- package/src/commands/browser.ts +102 -0
- package/src/commands/intake.ts +181 -1
- package/src/commands/labels.ts +152 -4
- package/src/commands/pr.ts +278 -8
- package/src/commands/records.ts +18 -1
- package/src/commands/take.ts +266 -0
- package/src/context/requirements.ts +1 -11
- package/src/git/status.ts +73 -0
- package/src/intake/drive-check.ts +355 -0
- package/src/intake/folder.ts +1 -1
- package/src/labels/apply.ts +10 -5
- package/src/labels/sync.ts +17 -0
- package/src/markdown/scan.ts +10 -0
- package/src/pr/close.ts +152 -0
- package/src/pr/drive-copy.ts +97 -84
- package/src/pr/health.ts +56 -0
- package/src/pr/local.ts +49 -8
- package/src/pr/rest.ts +10 -2
- package/src/records/session-map.ts +75 -0
- package/src/records/validate.ts +64 -8
- package/src/sandbox/replay/run.ts +30 -5
- package/src/take/camera.ts +309 -0
- package/src/take/encode.ts +170 -0
- package/src/take/film.ts +206 -0
- package/src/take/fixtures/fade.html +49 -0
- package/src/take/fixtures/fetch.html +38 -0
- package/src/take/log.ts +109 -0
- package/src/take/perform.ts +200 -0
- package/src/take/plan.ts +193 -0
- package/src/take/screencast.ts +94 -0
- package/src/take/shell.ts +287 -0
- package/src/take/stepper.ts +252 -0
- package/src/take/zoom/NOTICE.md +45 -0
- package/src/take/zoom/math-utils.ts +69 -0
- package/src/take/zoom/region-utils.ts +55 -0
- package/src/take/zoom/spring.ts +133 -0
- package/src/tasks/archive.ts +1 -3
- package/src/tooling/verify-stack.ts +4 -5
- package/tooling/base/reference.md +1 -0
- package/tooling/base/seeds/canon/config/pr-labels.toml +41 -0
- 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
|
+
}
|
package/src/intake/folder.ts
CHANGED
|
@@ -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> {
|
package/src/labels/apply.ts
CHANGED
|
@@ -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
|
|
30
|
-
|
|
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
|
+
}
|
package/src/markdown/scan.ts
CHANGED
|
@@ -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
|
*
|
package/src/pr/close.ts
ADDED
|
@@ -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
|
+
}
|