@erclx/canon 5.33.0 → 5.34.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/REQUIREMENT.md +1 -2
- package/claude/skills/api-design/SKILL.md +2 -3
- package/claude/skills/api-design/references/adopted.md +1 -1
- package/claude/skills/canon-feedback/REQUIREMENT.md +1 -0
- package/claude/skills/canon-feedback/SKILL.md +10 -28
- package/claude/skills/canon-feedback/references/template.md +53 -0
- package/claude/skills/canvas-board/SKILL.md +4 -1
- package/claude/skills/canvas-board/references/canvas.md +26 -3
- package/claude/skills/code-craft/REQUIREMENT.md +1 -1
- package/claude/skills/code-craft/SKILL.md +0 -1
- package/claude/skills/create-rule/SKILL.md +1 -1
- package/claude/skills/create-rule/references/rule.md +3 -1
- package/claude/skills/create-skill/references/skill.md +1 -1
- package/claude/skills/git-issue/references/issue.md +4 -1
- package/claude/skills/review-craft/references/ui.md +1 -1
- package/claude/skills/review-ui/SKILL.md +7 -5
- package/claude/skills/review-ui/references/driving.md +8 -8
- package/claude/skills/review-ui/references/post.md +52 -16
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +9 -0
- package/claude/skills/target-setup/SKILL.md +10 -4
- package/claude/skills/target-setup/references/detect.md +19 -2
- package/claude/skills/test-craft/REQUIREMENT.md +2 -3
- package/claude/skills/test-craft/SKILL.md +15 -3
- package/docs/agents/canvas.md +5 -1
- package/docs/agents/commands.md +1 -1
- package/docs/agents/gov-detect.md +47 -0
- package/docs/agents/index.md +4 -1
- package/docs/agents/install-and-sync.md +4 -0
- package/docs/agents/pr-evidence-layout.md +70 -0
- package/docs/agents/pr-evidence.md +8 -9
- package/docs/agents/pr-frames.md +7 -2
- package/docs/agents/pr-lift.md +132 -0
- package/docs/agents/scripting.md +2 -1
- package/docs/target/projects.md +3 -3
- package/governance/detect.toml +201 -0
- package/governance/rules/code/000-code.md +1 -13
- package/governance/rules/code/010-testing.md +2 -30
- package/governance/rules/ui/400-ui.md +1 -1
- package/governance/rules/ui/480-canvas.md +13 -0
- package/package.json +1 -1
- package/scripts/core/regen-hero.ts +0 -6
- package/scripts/lib/ui.sh +0 -9
- package/src/canvas/arrange.ts +66 -0
- package/src/canvas/capture.ts +20 -6
- package/src/canvas/content.ts +39 -0
- package/src/canvas/fit.ts +238 -0
- package/src/canvas/tokens.ts +28 -10
- package/src/capture/frame-values.ts +2 -32
- package/src/commands/canvas.ts +179 -1
- package/src/commands/gov.ts +144 -1
- package/src/commands/pr.ts +376 -1
- package/src/feedback/body.ts +26 -5
- package/src/gate/measures.ts +58 -44
- package/src/gate/stages.ts +3 -4
- package/src/gov/detect.ts +301 -0
- package/src/gov/list.ts +23 -2
- package/src/gov/stacks.ts +9 -0
- package/src/pr/evidence.ts +218 -45
- package/src/pr/frames.ts +23 -3
- package/src/pr/lift.ts +418 -0
- package/src/pr/rest.ts +3 -0
- package/src/pr/review-scope.ts +76 -0
- package/src/sandbox/equivalence.ts +14 -7
package/src/commands/gov.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
govStackExists,
|
|
20
20
|
listGovStacks,
|
|
21
21
|
mergeExtraRules,
|
|
22
|
+
resolveExtraRules,
|
|
22
23
|
resolveRules,
|
|
23
24
|
} from '@/gov/stacks'
|
|
24
25
|
import {
|
|
@@ -51,6 +52,7 @@ import {
|
|
|
51
52
|
type TestOrderReport,
|
|
52
53
|
} from '@/gov/test-order'
|
|
53
54
|
import { recordStamp, runDomainSync } from '@/sync/engine'
|
|
55
|
+
import { detectRules } from '@/gov/detect'
|
|
54
56
|
import { writeChainStamp } from '@/sync/stamp'
|
|
55
57
|
import { resolveTarget } from '@/targets/validate'
|
|
56
58
|
import {
|
|
@@ -153,6 +155,11 @@ export function register(program: Command): void {
|
|
|
153
155
|
' canon gov install react',
|
|
154
156
|
' canon gov install node ../my-app',
|
|
155
157
|
' canon gov install astro --add 200-react,260-shadcn ../my-app',
|
|
158
|
+
' canon gov install --add tasks,plan ../my-app',
|
|
159
|
+
'',
|
|
160
|
+
'With --add and no stack, the named rules install alone. A first',
|
|
161
|
+
'positional naming a stack is read as the stack, so pass . or a path',
|
|
162
|
+
'carrying a slash to force a target that shares a stack name.',
|
|
156
163
|
'',
|
|
157
164
|
].join('\n'),
|
|
158
165
|
)
|
|
@@ -221,6 +228,31 @@ export function register(program: Command): void {
|
|
|
221
228
|
process.exitCode = runList(opts)
|
|
222
229
|
})
|
|
223
230
|
|
|
231
|
+
gov
|
|
232
|
+
.command('detect')
|
|
233
|
+
.description('Propose opt-in rules from the evidence a target holds')
|
|
234
|
+
.argument('[target]', 'Target directory', '.')
|
|
235
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
236
|
+
.option('--stack <stack>', 'Leave out every rule this stack resolves')
|
|
237
|
+
.option('--json', 'Emit machine-readable JSON')
|
|
238
|
+
.addHelpText(
|
|
239
|
+
'after',
|
|
240
|
+
[
|
|
241
|
+
'',
|
|
242
|
+
'Reads governance/detect.toml and matches each rule against the',
|
|
243
|
+
"target's manifests and git-listed paths to depth two. Pass the",
|
|
244
|
+
'proposals to canon gov install --add once confirmed.',
|
|
245
|
+
'',
|
|
246
|
+
'Examples:',
|
|
247
|
+
' canon gov detect --json',
|
|
248
|
+
' canon gov detect ../my-app --stack node --json',
|
|
249
|
+
'',
|
|
250
|
+
].join('\n'),
|
|
251
|
+
)
|
|
252
|
+
.action(async (target: string, opts: DetectOptions) => {
|
|
253
|
+
process.exitCode = await runDetect(target, opts)
|
|
254
|
+
})
|
|
255
|
+
|
|
224
256
|
gov
|
|
225
257
|
.command('test-order')
|
|
226
258
|
.description(
|
|
@@ -1070,6 +1102,7 @@ function runList(opts: ListOptions): number {
|
|
|
1070
1102
|
...(sections.stacks ? { stacks: catalog.stacks } : {}),
|
|
1071
1103
|
...(sections.rules ? { rules: catalog.rules } : {}),
|
|
1072
1104
|
unreferenced: catalog.unreferenced,
|
|
1105
|
+
staleDetect: catalog.staleDetect,
|
|
1073
1106
|
})}\n`,
|
|
1074
1107
|
)
|
|
1075
1108
|
return 0
|
|
@@ -1097,6 +1130,51 @@ function runList(opts: ListOptions): number {
|
|
|
1097
1130
|
return 0
|
|
1098
1131
|
}
|
|
1099
1132
|
|
|
1133
|
+
interface DetectOptions {
|
|
1134
|
+
readonly stack?: string
|
|
1135
|
+
readonly json?: boolean
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
async function runDetect(target: string, opts: DetectOptions): Promise<number> {
|
|
1139
|
+
const resolved = resolve(target)
|
|
1140
|
+
if (!existsSync(resolved)) {
|
|
1141
|
+
logError(`Target not found: ${target}`)
|
|
1142
|
+
return 1
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const result = await detectRules(PROJECT_ROOT, resolved, opts.stack)
|
|
1146
|
+
if (!result.ok) {
|
|
1147
|
+
logError(`Stack not found: ${result.missingStack}`)
|
|
1148
|
+
return 1
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
if (opts.json) {
|
|
1152
|
+
process.stdout.write(
|
|
1153
|
+
`${JSON.stringify({
|
|
1154
|
+
target: resolved,
|
|
1155
|
+
stack: opts.stack ?? null,
|
|
1156
|
+
proposals: result.proposals,
|
|
1157
|
+
})}\n`,
|
|
1158
|
+
)
|
|
1159
|
+
return 0
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
intro('canon gov detect')
|
|
1163
|
+
if (result.proposals.length === 0) {
|
|
1164
|
+
logInfo('No opt-in rule matched this target')
|
|
1165
|
+
} else {
|
|
1166
|
+
logStep('Proposed rules')
|
|
1167
|
+
for (const proposal of result.proposals) {
|
|
1168
|
+
const reasons = proposal.evidence
|
|
1169
|
+
.map((evidence) => `${evidence.match} in ${evidence.path}`)
|
|
1170
|
+
.join(', ')
|
|
1171
|
+
logInfo(`${proposal.rule}: ${reasons}`)
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
outro()
|
|
1175
|
+
return 0
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1100
1178
|
/**
|
|
1101
1179
|
* Silent on success so the consumed-copy stage that calls it stays quiet. It is
|
|
1102
1180
|
* the only work that stage does now. The installed set is readable on disk, so
|
|
@@ -1171,6 +1249,11 @@ async function runInstall(
|
|
|
1171
1249
|
): Promise<number> {
|
|
1172
1250
|
intro('canon gov install')
|
|
1173
1251
|
|
|
1252
|
+
const add = opts.add ?? ''
|
|
1253
|
+
if (add !== '' && isAddWithoutStack(stack, target)) {
|
|
1254
|
+
return installExtras(add, stack ?? target)
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1174
1257
|
const resolved = resolveTarget(target, PROJECT_ROOT)
|
|
1175
1258
|
if (typeof resolved === 'number') return resolved
|
|
1176
1259
|
|
|
@@ -1194,7 +1277,6 @@ async function runInstall(
|
|
|
1194
1277
|
return 1
|
|
1195
1278
|
}
|
|
1196
1279
|
|
|
1197
|
-
const add = opts.add ?? ''
|
|
1198
1280
|
const rules =
|
|
1199
1281
|
add === '' ? resolution.rules : mergeExtraRules(resolution.rules, add)
|
|
1200
1282
|
|
|
@@ -1247,6 +1329,67 @@ async function runInstall(
|
|
|
1247
1329
|
return 0
|
|
1248
1330
|
}
|
|
1249
1331
|
|
|
1332
|
+
/**
|
|
1333
|
+
* Whether `--add` runs with no stack under it. Commander fills the first
|
|
1334
|
+
* positional before the second, so `--add a,b ../my-app` arrives with the
|
|
1335
|
+
* target in `stack` and the default `.` in `target`. A first positional that
|
|
1336
|
+
* names a stack keeps today's meaning, so a folder named `node` reads as the
|
|
1337
|
+
* stack, and `.` or a path carrying a slash is how a caller forces the target.
|
|
1338
|
+
*/
|
|
1339
|
+
function isAddWithoutStack(stack: string | undefined, target: string): boolean {
|
|
1340
|
+
if (stack === undefined) return true
|
|
1341
|
+
if (target !== '.') return false
|
|
1342
|
+
if (stack === '.' || stack.includes('/')) return true
|
|
1343
|
+
return !govStackExists(PROJECT_ROOT, stack)
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* Installs `--add` names alone. The chain stamp is left as it was, since the
|
|
1348
|
+
* extras are not a stack, and overwriting the chain would cost a target the
|
|
1349
|
+
* missing-rule report its stack install recorded.
|
|
1350
|
+
*/
|
|
1351
|
+
async function installExtras(add: string, target: string): Promise<number> {
|
|
1352
|
+
const resolved = resolveTarget(target, PROJECT_ROOT)
|
|
1353
|
+
if (typeof resolved === 'number') return resolved
|
|
1354
|
+
|
|
1355
|
+
logStep('Resolving extras with no stack')
|
|
1356
|
+
|
|
1357
|
+
const { found, missing } = lookupRules(PROJECT_ROOT, resolveExtraRules(add))
|
|
1358
|
+
for (const rule of missing) logWarn(`${rule} (source not found, skipping)`)
|
|
1359
|
+
for (const source of found) logInfo(source.rule)
|
|
1360
|
+
|
|
1361
|
+
if (found.length === 0) {
|
|
1362
|
+
logError('No named rule resolved, so nothing was installed.')
|
|
1363
|
+
outro()
|
|
1364
|
+
return 1
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
const shouldInstall = await select({
|
|
1368
|
+
message: `Install ${found.length} rules to ${displayPath(target, CANON_RULES_REL)}?`,
|
|
1369
|
+
options: [
|
|
1370
|
+
{ value: true, label: 'Yes' },
|
|
1371
|
+
{ value: false, label: 'No' },
|
|
1372
|
+
],
|
|
1373
|
+
nonInteractiveDefault: true,
|
|
1374
|
+
})
|
|
1375
|
+
|
|
1376
|
+
if (!shouldInstall) {
|
|
1377
|
+
logWarn('Cancelled')
|
|
1378
|
+
outro()
|
|
1379
|
+
return 0
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
logStep('Installing rules')
|
|
1383
|
+
for (const rel of await installRules(found, resolved)) logAdd(rel)
|
|
1384
|
+
|
|
1385
|
+
await recordStamp(createGovAdapter(PROJECT_ROOT), resolved, new Date())
|
|
1386
|
+
|
|
1387
|
+
const { GREEN, NC } = palette(process.stderr)
|
|
1388
|
+
outro()
|
|
1389
|
+
process.stderr.write(`${GREEN}✓ Rules installed${NC}\n`)
|
|
1390
|
+
return 0
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1250
1393
|
/**
|
|
1251
1394
|
* Emits the rules payload the `gov-*` skills read. The output lands in the
|
|
1252
1395
|
* target's scratch directory rather than stdout because the file is the
|
package/src/commands/pr.ts
CHANGED
|
@@ -48,15 +48,27 @@ import {
|
|
|
48
48
|
FRAMES_BRANCH,
|
|
49
49
|
type FramesApi,
|
|
50
50
|
type FramesRefusal,
|
|
51
|
+
type FrameTheme,
|
|
51
52
|
frameLink,
|
|
52
53
|
isFrameHead,
|
|
53
54
|
isFramePass,
|
|
55
|
+
isFrameTheme,
|
|
54
56
|
pruneFrames,
|
|
55
57
|
pushFrame,
|
|
56
58
|
readFrame,
|
|
57
59
|
type TreeEntry,
|
|
58
60
|
} from '@/pr/frames'
|
|
59
61
|
import { type HeadRefusal, resolveHead, resolveTip } from '@/pr/head'
|
|
62
|
+
import {
|
|
63
|
+
DEFAULT_WAIT_SECONDS,
|
|
64
|
+
type EvidenceGate,
|
|
65
|
+
type GitRunner,
|
|
66
|
+
lift,
|
|
67
|
+
type LiftApi,
|
|
68
|
+
type LiftRefusal,
|
|
69
|
+
type PullState,
|
|
70
|
+
readBaseStanding,
|
|
71
|
+
} from '@/pr/lift'
|
|
60
72
|
import {
|
|
61
73
|
dropAddressLine,
|
|
62
74
|
findLocalServer,
|
|
@@ -82,6 +94,7 @@ import { type TickRefusal, tickBoxes } from '@/pr/tick'
|
|
|
82
94
|
import {
|
|
83
95
|
commentRowOf,
|
|
84
96
|
identityOf,
|
|
97
|
+
mergeStateOf,
|
|
85
98
|
type PullIdentity,
|
|
86
99
|
parseJsonLines,
|
|
87
100
|
type RestComment,
|
|
@@ -91,6 +104,7 @@ import {
|
|
|
91
104
|
selectBranchPull,
|
|
92
105
|
} from '@/pr/rest'
|
|
93
106
|
import { resolveReviewScope } from '@/pr/review-scope'
|
|
107
|
+
import { PROJECT_ROOT } from '@/roots/project'
|
|
94
108
|
import { intro, logInfo, logStep, logWarn, outro, plural } from '@/cli/ui'
|
|
95
109
|
|
|
96
110
|
const GH_TIMEOUT_MS = 30_000
|
|
@@ -117,6 +131,11 @@ interface EvidenceOptions extends ReadOptions {
|
|
|
117
131
|
readonly check?: boolean
|
|
118
132
|
}
|
|
119
133
|
|
|
134
|
+
interface LiftCommandOptions extends ReadOptions {
|
|
135
|
+
readonly check?: boolean
|
|
136
|
+
readonly wait?: string
|
|
137
|
+
}
|
|
138
|
+
|
|
120
139
|
interface LocalOptions extends ReadOptions {
|
|
121
140
|
readonly remove?: boolean
|
|
122
141
|
}
|
|
@@ -164,6 +183,7 @@ interface FramesOptions extends ReadOptions {
|
|
|
164
183
|
readonly box?: string
|
|
165
184
|
readonly head?: string
|
|
166
185
|
readonly pass?: string
|
|
186
|
+
readonly theme?: string
|
|
167
187
|
readonly drop?: boolean
|
|
168
188
|
readonly prune?: string
|
|
169
189
|
}
|
|
@@ -175,6 +195,7 @@ type FramesCommandRefusal =
|
|
|
175
195
|
| 'bad-box'
|
|
176
196
|
| 'bad-head'
|
|
177
197
|
| 'bad-pass'
|
|
198
|
+
| 'bad-theme'
|
|
178
199
|
| 'bad-days'
|
|
179
200
|
| 'unreadable-frame'
|
|
180
201
|
| 'read-only'
|
|
@@ -193,6 +214,8 @@ const FRAMES_REFUSALS: Record<FramesCommandRefusal, string> = {
|
|
|
193
214
|
'--head takes a commit sha, 7 to 40 lowercase hex characters. A branch name or a path would file the frame under a folder that names no commit.',
|
|
194
215
|
'bad-pass':
|
|
195
216
|
'--add takes --pass <stamp>, the instant the pass started in compact UTC such as 20261002T154450Z, so a second pass never replaces an earlier one.',
|
|
217
|
+
'bad-theme':
|
|
218
|
+
'--theme takes dark or light and goes with --add, so a second shot of one box never replaces the first.',
|
|
196
219
|
'bad-days': '--prune takes a positive whole number of days.',
|
|
197
220
|
'unreadable-frame':
|
|
198
221
|
'The file named by --add is missing, empty, or not a PNG, so it would embed as a broken image. Nothing is pushed.',
|
|
@@ -650,6 +673,62 @@ export function register(program: Command): void {
|
|
|
650
673
|
process.exitCode = await runEvidence(number, opts)
|
|
651
674
|
})
|
|
652
675
|
|
|
676
|
+
pr.command('lift')
|
|
677
|
+
.description(
|
|
678
|
+
"Lift a pull request's draft mark once its reviews, evidence, standing, and CI all read clear at the head",
|
|
679
|
+
)
|
|
680
|
+
.argument('[number]', 'Pull request to lift, defaulting to this branch')
|
|
681
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
682
|
+
.option('--root <path>', 'Repository to read, defaulting to the cwd')
|
|
683
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
684
|
+
.option('--check', 'Report every gate and write nothing')
|
|
685
|
+
.option(
|
|
686
|
+
'--wait <seconds>',
|
|
687
|
+
`Hold for a pending CI run up to this long, default ${DEFAULT_WAIT_SECONDS}`,
|
|
688
|
+
)
|
|
689
|
+
.addHelpText(
|
|
690
|
+
'after',
|
|
691
|
+
[
|
|
692
|
+
'',
|
|
693
|
+
'The verb reads the gate itself, so any session that closes a review can',
|
|
694
|
+
'run it and nobody has to vouch for who is asking. Gates, in the order',
|
|
695
|
+
'they are read:',
|
|
696
|
+
' review the newest `## Review` is closed at the head, off its marker',
|
|
697
|
+
' ui the newest `## UI review` is closed at the head, bound only',
|
|
698
|
+
' where `canon pr evidence --check` carries a checklist',
|
|
699
|
+
' evidence the evidence check reads settled',
|
|
700
|
+
' standing the head is fresh against the base, not stale or conflicted',
|
|
701
|
+
' ci every check run on the tip passed, waited on up to --wait',
|
|
702
|
+
'',
|
|
703
|
+
'Review state, UI state, evidence, and standing are read before the CI',
|
|
704
|
+
'wait, so a reviewer whose partner is still driving refuses at once. The',
|
|
705
|
+
'head and the reviews are read again before the mutation.',
|
|
706
|
+
'',
|
|
707
|
+
'Reasons:',
|
|
708
|
+
' lifted, already-ready, clear (--check)',
|
|
709
|
+
' pull-closed, review-missing, review-open, review-unmarked,',
|
|
710
|
+
' review-behind, ui-missing, ui-open, ui-unmarked, ui-behind,',
|
|
711
|
+
' evidence-owed, standing-stale, standing-conflict, standing-unknown,',
|
|
712
|
+
' ci-pending (--check), ci-failing, ci-conflicted, ci-timeout,',
|
|
713
|
+
' head-moved, gh-failed',
|
|
714
|
+
'',
|
|
715
|
+
'Every read is REST. The mutation is the one GraphQL call.',
|
|
716
|
+
'',
|
|
717
|
+
'Exit codes:',
|
|
718
|
+
' 0 lifted, already-ready, or every gate clear under --check',
|
|
719
|
+
' 1 refused, with the reason in the JSON record',
|
|
720
|
+
'',
|
|
721
|
+
'Examples:',
|
|
722
|
+
' canon pr lift 1341 --json',
|
|
723
|
+
' canon pr lift 1341 --check --json',
|
|
724
|
+
' canon pr lift --wait 600',
|
|
725
|
+
'',
|
|
726
|
+
].join('\n'),
|
|
727
|
+
)
|
|
728
|
+
.action(async (number: string | undefined, opts: LiftCommandOptions) => {
|
|
729
|
+
process.exitCode = await runLift(number, opts)
|
|
730
|
+
})
|
|
731
|
+
|
|
653
732
|
pr.command('preview')
|
|
654
733
|
.description(
|
|
655
734
|
"Deploy the pull request's branch to a Cloudflare Pages preview and report its address",
|
|
@@ -841,6 +920,10 @@ export function register(program: Command): void {
|
|
|
841
920
|
'--pass <stamp>',
|
|
842
921
|
'The instant the pass started, in compact UTC such as 20261002T154450Z, with --add',
|
|
843
922
|
)
|
|
923
|
+
.option(
|
|
924
|
+
'--theme <theme>',
|
|
925
|
+
'The theme the frame was shot at, dark or light, with --add, keeping two shots of one box apart',
|
|
926
|
+
)
|
|
844
927
|
.option('--drop', "Rewrite the branch without this pull request's frames")
|
|
845
928
|
.option(
|
|
846
929
|
'--prune <days>',
|
|
@@ -852,7 +935,8 @@ export function register(program: Command): void {
|
|
|
852
935
|
'',
|
|
853
936
|
`Writes only refs/heads/${FRAMES_BRANCH}, through the GitHub git data API,`,
|
|
854
937
|
'so nothing is checked out and no other branch is touched. A frame lands',
|
|
855
|
-
'at pr-<number>/<short-head>/<pass>/box-<n>.png,
|
|
938
|
+
'at pr-<number>/<short-head>/<pass>/box-<n>.png, or box-<n>-<theme>.png',
|
|
939
|
+
'with --theme, and `link` names that',
|
|
856
940
|
`path on ${FRAMES_BRANCH}, so it survives a rewrite that drops other pull`,
|
|
857
941
|
'requests and resolves until its own pull request is dropped. The pass',
|
|
858
942
|
'segment keeps a second pass at one head and box from replacing an image',
|
|
@@ -871,6 +955,7 @@ export function register(program: Command): void {
|
|
|
871
955
|
' bad-box --add without a positive --box',
|
|
872
956
|
' bad-head --head is not 7 to 40 lowercase hex characters',
|
|
873
957
|
' bad-pass --add without a compact UTC --pass stamp',
|
|
958
|
+
' bad-theme --theme is not dark or light, or no --add',
|
|
874
959
|
' bad-days --prune without a positive number of days',
|
|
875
960
|
' unreadable-frame the --add file is missing, empty, or not a PNG',
|
|
876
961
|
' read-only GitHub refused the write, as on a fork token',
|
|
@@ -2906,6 +2991,12 @@ function framesArgumentRefusal(
|
|
|
2906
2991
|
) {
|
|
2907
2992
|
return 'bad-pass'
|
|
2908
2993
|
}
|
|
2994
|
+
if (
|
|
2995
|
+
opts.theme !== undefined &&
|
|
2996
|
+
(opts.add === undefined || !isFrameTheme(opts.theme))
|
|
2997
|
+
) {
|
|
2998
|
+
return 'bad-theme'
|
|
2999
|
+
}
|
|
2909
3000
|
return undefined
|
|
2910
3001
|
}
|
|
2911
3002
|
|
|
@@ -3005,11 +3096,13 @@ async function runFrames(
|
|
|
3005
3096
|
|
|
3006
3097
|
const box = Number(opts.box)
|
|
3007
3098
|
const pass = opts.pass ?? ''
|
|
3099
|
+
const theme = opts.theme as FrameTheme | undefined
|
|
3008
3100
|
const result = await pushFrame(api, {
|
|
3009
3101
|
number: pull,
|
|
3010
3102
|
head,
|
|
3011
3103
|
pass,
|
|
3012
3104
|
box,
|
|
3105
|
+
theme,
|
|
3013
3106
|
bytes: bytes ?? new Uint8Array(),
|
|
3014
3107
|
})
|
|
3015
3108
|
if (result.kind === 'refused') return refuseWrite(result.reason)
|
|
@@ -3018,6 +3111,7 @@ async function runFrames(
|
|
|
3018
3111
|
{
|
|
3019
3112
|
number: pull,
|
|
3020
3113
|
box,
|
|
3114
|
+
...(theme === undefined ? {} : { theme }),
|
|
3021
3115
|
head,
|
|
3022
3116
|
pass,
|
|
3023
3117
|
path: result.path,
|
|
@@ -3029,6 +3123,287 @@ async function runFrames(
|
|
|
3029
3123
|
)
|
|
3030
3124
|
}
|
|
3031
3125
|
|
|
3126
|
+
const LIFT_REFUSALS: Record<LiftRefusal | 'bad-wait' | 'gh-missing', string> = {
|
|
3127
|
+
'bad-wait': '--wait takes a whole number of seconds, zero or more.',
|
|
3128
|
+
'gh-missing': PULL_REFUSALS['gh-missing'],
|
|
3129
|
+
'pull-closed': 'The pull request is closed, so there is no mark to lift.',
|
|
3130
|
+
'review-missing': 'The thread carries no code review, so none has closed.',
|
|
3131
|
+
'review-open':
|
|
3132
|
+
'The newest code review is open, so a finding still owes a fix.',
|
|
3133
|
+
'review-unmarked':
|
|
3134
|
+
'The newest code review carries no read-time marker, so the commit it covers is a GitHub stamp this gate does not trust.',
|
|
3135
|
+
'review-behind':
|
|
3136
|
+
'The newest code review closed on an earlier commit than the head.',
|
|
3137
|
+
'ui-missing':
|
|
3138
|
+
'The evidence comment carries a checklist and no UI review has been posted.',
|
|
3139
|
+
'ui-open': 'The newest UI review is open, so a box still owes a fix.',
|
|
3140
|
+
'ui-unmarked':
|
|
3141
|
+
'The newest UI review carries no head marker, so the commit it covers is a GitHub stamp this gate does not trust.',
|
|
3142
|
+
'ui-behind':
|
|
3143
|
+
'The newest UI review closed on an earlier commit than the head.',
|
|
3144
|
+
'evidence-owed':
|
|
3145
|
+
'The evidence check reads owed, so the ship chain left a step undone.',
|
|
3146
|
+
'standing-stale':
|
|
3147
|
+
'The base moved under a path this branch writes since it branched, so CI measured a tree that is gone.',
|
|
3148
|
+
'standing-conflict': 'The head does not merge cleanly into the base.',
|
|
3149
|
+
'standing-unknown':
|
|
3150
|
+
'The head or the base did not resolve, which a fork or a deleted head leaves, so its standing is unread and waits on a person.',
|
|
3151
|
+
'ci-pending': 'A check run on the tip has yet to conclude.',
|
|
3152
|
+
'ci-failing': 'A check run on the tip failed.',
|
|
3153
|
+
'ci-conflicted':
|
|
3154
|
+
'The branch conflicts with its base, so no run will start for the tip.',
|
|
3155
|
+
'ci-timeout': 'A check run was still pending when --wait ran out.',
|
|
3156
|
+
'head-moved':
|
|
3157
|
+
'A push landed during the wait, so the gates cleared a commit that is no longer the head.',
|
|
3158
|
+
'gh-failed':
|
|
3159
|
+
'A read or the mutation was refused, so nothing is reported as lifted.',
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
const READY_MUTATION =
|
|
3163
|
+
'mutation($id: ID!) { markPullRequestReadyForReview(input: {pullRequestId: $id}) { pullRequest { isDraft } } }'
|
|
3164
|
+
|
|
3165
|
+
function gitRunner(root: string): GitRunner {
|
|
3166
|
+
return async (args) => {
|
|
3167
|
+
const result = await execa('git', ['-C', root, ...args], {
|
|
3168
|
+
reject: false,
|
|
3169
|
+
timeout: GH_TIMEOUT_MS,
|
|
3170
|
+
env: gitEnv(),
|
|
3171
|
+
extendEnv: false,
|
|
3172
|
+
})
|
|
3173
|
+
return result.exitCode === 0 ? String(result.stdout) : undefined
|
|
3174
|
+
}
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
/**
|
|
3178
|
+
* Reads the evidence check through this checkout's own CLI rather than a
|
|
3179
|
+
* second copy of its logic, so the lift and a caller running
|
|
3180
|
+
* `canon pr evidence --check` read one answer.
|
|
3181
|
+
*/
|
|
3182
|
+
async function readEvidenceGate(
|
|
3183
|
+
root: string,
|
|
3184
|
+
number: string,
|
|
3185
|
+
): Promise<EvidenceGate | undefined> {
|
|
3186
|
+
const result = await execa(
|
|
3187
|
+
process.execPath,
|
|
3188
|
+
[
|
|
3189
|
+
join(PROJECT_ROOT, 'src', 'cli.ts'),
|
|
3190
|
+
'pr',
|
|
3191
|
+
'evidence',
|
|
3192
|
+
number,
|
|
3193
|
+
'--check',
|
|
3194
|
+
'--json',
|
|
3195
|
+
'--root',
|
|
3196
|
+
root,
|
|
3197
|
+
],
|
|
3198
|
+
{
|
|
3199
|
+
cwd: root,
|
|
3200
|
+
reject: false,
|
|
3201
|
+
timeout: GH_TIMEOUT_MS * 2,
|
|
3202
|
+
env: { ...gitEnv(), CANON_NON_INTERACTIVE: '1' },
|
|
3203
|
+
extendEnv: false,
|
|
3204
|
+
},
|
|
3205
|
+
)
|
|
3206
|
+
const last = String(result.stdout)
|
|
3207
|
+
.split('\n')
|
|
3208
|
+
.filter((line) => line.trim() !== '')
|
|
3209
|
+
.at(-1)
|
|
3210
|
+
if (last === undefined) return undefined
|
|
3211
|
+
|
|
3212
|
+
let record: { reason?: unknown; owed?: unknown; checklist?: unknown }
|
|
3213
|
+
try {
|
|
3214
|
+
record = JSON.parse(last)
|
|
3215
|
+
} catch {
|
|
3216
|
+
return undefined
|
|
3217
|
+
}
|
|
3218
|
+
if (record.reason !== 'settled' && record.reason !== 'owed') return undefined
|
|
3219
|
+
|
|
3220
|
+
return {
|
|
3221
|
+
isSettled: record.reason === 'settled',
|
|
3222
|
+
owed: Array.isArray(record.owed) ? record.owed.map(String) : [],
|
|
3223
|
+
hasChecklist:
|
|
3224
|
+
typeof record.checklist === 'string' && record.checklist !== '',
|
|
3225
|
+
}
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3228
|
+
function liftApi(root: string, number: string, baseRef: string): LiftApi {
|
|
3229
|
+
const git = gitRunner(root)
|
|
3230
|
+
|
|
3231
|
+
return {
|
|
3232
|
+
readPull: async (): Promise<PullState | undefined> => {
|
|
3233
|
+
const stdout = await gh(root, [
|
|
3234
|
+
'api',
|
|
3235
|
+
`repos/{owner}/{repo}/pulls/${number}`,
|
|
3236
|
+
])
|
|
3237
|
+
if (stdout === null) return undefined
|
|
3238
|
+
try {
|
|
3239
|
+
const row = JSON.parse(stdout) as RestPull
|
|
3240
|
+
if (row.node_id === undefined || row.head?.ref === undefined) {
|
|
3241
|
+
return undefined
|
|
3242
|
+
}
|
|
3243
|
+
return {
|
|
3244
|
+
isDraft: row.draft === true,
|
|
3245
|
+
isOpen: row.state === 'open',
|
|
3246
|
+
nodeId: row.node_id,
|
|
3247
|
+
branch: row.head.ref,
|
|
3248
|
+
head: row.head.sha ?? '',
|
|
3249
|
+
}
|
|
3250
|
+
} catch {
|
|
3251
|
+
return undefined
|
|
3252
|
+
}
|
|
3253
|
+
},
|
|
3254
|
+
readTip: async (branch) => {
|
|
3255
|
+
const resolved = await resolveTip(branch, refReader(root))
|
|
3256
|
+
return resolved.kind === 'read' ? resolved.tip : undefined
|
|
3257
|
+
},
|
|
3258
|
+
readReviews: async () => {
|
|
3259
|
+
const reviews = await listReviews(root, number)
|
|
3260
|
+
return reviews.kind === 'read' ? reviews.rows.map(reviewRowOf) : undefined
|
|
3261
|
+
},
|
|
3262
|
+
readEvidence: () => readEvidenceGate(root, number),
|
|
3263
|
+
readStanding: (head) =>
|
|
3264
|
+
readBaseStanding(git, Number(number), head, baseRef),
|
|
3265
|
+
readChecks: async (tip) => {
|
|
3266
|
+
const [listed, pulled] = await Promise.all([
|
|
3267
|
+
gh(root, [
|
|
3268
|
+
'api',
|
|
3269
|
+
`repos/{owner}/{repo}/commits/${tip}/check-runs?per_page=100`,
|
|
3270
|
+
]),
|
|
3271
|
+
gh(root, ['api', `repos/{owner}/{repo}/pulls/${number}`]),
|
|
3272
|
+
])
|
|
3273
|
+
if (listed === null) return undefined
|
|
3274
|
+
try {
|
|
3275
|
+
const mergeState =
|
|
3276
|
+
pulled === null
|
|
3277
|
+
? undefined
|
|
3278
|
+
: mergeStateOf((JSON.parse(pulled) as RestPull).mergeable_state)
|
|
3279
|
+
const reading = withMergeState(
|
|
3280
|
+
mergeState,
|
|
3281
|
+
collapseChecks(tip, JSON.parse(listed) as CheckRunListing),
|
|
3282
|
+
)
|
|
3283
|
+
return { state: reading.state, isConflicted: reading.conflicted }
|
|
3284
|
+
} catch {
|
|
3285
|
+
return undefined
|
|
3286
|
+
}
|
|
3287
|
+
},
|
|
3288
|
+
markReady: async (nodeId) => {
|
|
3289
|
+
const result = await execa(
|
|
3290
|
+
'gh',
|
|
3291
|
+
[
|
|
3292
|
+
'api',
|
|
3293
|
+
'graphql',
|
|
3294
|
+
'-f',
|
|
3295
|
+
`query=${READY_MUTATION}`,
|
|
3296
|
+
'-f',
|
|
3297
|
+
`id=${nodeId}`,
|
|
3298
|
+
],
|
|
3299
|
+
{
|
|
3300
|
+
cwd: root,
|
|
3301
|
+
reject: false,
|
|
3302
|
+
timeout: GH_TIMEOUT_MS,
|
|
3303
|
+
env: gitEnv(),
|
|
3304
|
+
extendEnv: false,
|
|
3305
|
+
},
|
|
3306
|
+
)
|
|
3307
|
+
if (result.exitCode !== 0) return false
|
|
3308
|
+
// The flag is read back rather than the exit trusted, since the
|
|
3309
|
+
// mutation can answer 200 with an errors array and no change.
|
|
3310
|
+
try {
|
|
3311
|
+
const body = JSON.parse(String(result.stdout)) as {
|
|
3312
|
+
data?: {
|
|
3313
|
+
markPullRequestReadyForReview?: {
|
|
3314
|
+
pullRequest?: { isDraft?: boolean }
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
}
|
|
3318
|
+
return (
|
|
3319
|
+
body.data?.markPullRequestReadyForReview?.pullRequest?.isDraft ===
|
|
3320
|
+
false
|
|
3321
|
+
)
|
|
3322
|
+
} catch {
|
|
3323
|
+
return false
|
|
3324
|
+
}
|
|
3325
|
+
},
|
|
3326
|
+
sleep: (ms) => new Promise((done) => setTimeout(done, ms)),
|
|
3327
|
+
now: () => Date.now(),
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3331
|
+
async function runLift(
|
|
3332
|
+
number: string | undefined,
|
|
3333
|
+
opts: LiftCommandOptions,
|
|
3334
|
+
): Promise<number> {
|
|
3335
|
+
const root = resolve(opts.root ?? process.cwd())
|
|
3336
|
+
const emitJson = opts.json ?? false
|
|
3337
|
+
|
|
3338
|
+
intro('canon pr lift')
|
|
3339
|
+
|
|
3340
|
+
let waitSeconds = DEFAULT_WAIT_SECONDS
|
|
3341
|
+
if (opts.wait !== undefined) {
|
|
3342
|
+
if (!/^\d+$/.test(opts.wait)) {
|
|
3343
|
+
return refuseWith('bad-wait', LIFT_REFUSALS['bad-wait'], emitJson, root)
|
|
3344
|
+
}
|
|
3345
|
+
waitSeconds = Number(opts.wait)
|
|
3346
|
+
}
|
|
3347
|
+
|
|
3348
|
+
if (Bun.which('gh') === null) {
|
|
3349
|
+
return refuseWith('gh-missing', LIFT_REFUSALS['gh-missing'], emitJson, root)
|
|
3350
|
+
}
|
|
3351
|
+
|
|
3352
|
+
const resolved = await resolvePullNumber(root, number)
|
|
3353
|
+
if (resolved.kind === 'refused') {
|
|
3354
|
+
return refuseWith(
|
|
3355
|
+
resolved.reason,
|
|
3356
|
+
PULL_REFUSALS[resolved.reason],
|
|
3357
|
+
emitJson,
|
|
3358
|
+
root,
|
|
3359
|
+
)
|
|
3360
|
+
}
|
|
3361
|
+
|
|
3362
|
+
// Read once up front for the base the standing is measured against. The
|
|
3363
|
+
// injected `readPull` re-reads the object on every gate that needs it.
|
|
3364
|
+
const initial = await readPull(root, resolved.number)
|
|
3365
|
+
if (initial.kind === 'refused') {
|
|
3366
|
+
return refuseWith(
|
|
3367
|
+
initial.reason,
|
|
3368
|
+
PULL_REFUSALS[initial.reason],
|
|
3369
|
+
emitJson,
|
|
3370
|
+
root,
|
|
3371
|
+
)
|
|
3372
|
+
}
|
|
3373
|
+
const base = initial.row.base?.ref
|
|
3374
|
+
if (base === undefined || base === '') {
|
|
3375
|
+
return refuseWith('gh-failed', LIFT_REFUSALS['gh-failed'], emitJson, root)
|
|
3376
|
+
}
|
|
3377
|
+
|
|
3378
|
+
const record = await lift(liftApi(root, resolved.number, `origin/${base}`), {
|
|
3379
|
+
isCheck: opts.check ?? false,
|
|
3380
|
+
waitSeconds,
|
|
3381
|
+
})
|
|
3382
|
+
|
|
3383
|
+
logStep(record.reason)
|
|
3384
|
+
logInfo(
|
|
3385
|
+
record.reason === 'lifted'
|
|
3386
|
+
? `#${resolved.number} is ready for review at ${record.head?.slice(0, 8)}`
|
|
3387
|
+
: record.reason === 'already-ready'
|
|
3388
|
+
? `#${resolved.number} is already ready for review`
|
|
3389
|
+
: record.reason === 'clear'
|
|
3390
|
+
? `every gate reads clear for #${resolved.number}`
|
|
3391
|
+
: LIFT_REFUSALS[record.reason],
|
|
3392
|
+
)
|
|
3393
|
+
for (const gate of record.gates) {
|
|
3394
|
+
if (!gate.isOk) logWarn(`${gate.gate}: ${gate.reason ?? 'not clear'}`)
|
|
3395
|
+
}
|
|
3396
|
+
outro()
|
|
3397
|
+
|
|
3398
|
+
if (emitJson) {
|
|
3399
|
+
process.stdout.write(
|
|
3400
|
+
`${JSON.stringify({ root, number: Number(resolved.number), ...record })}\n`,
|
|
3401
|
+
)
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
return record.isOk ? 0 : 1
|
|
3405
|
+
}
|
|
3406
|
+
|
|
3032
3407
|
/**
|
|
3033
3408
|
* Frames a refusal on stderr in both modes and puts the record on stdout alone,
|
|
3034
3409
|
* so an operator reading the terminal sees the reason rather than a command
|