@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.
Files changed (64) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/api-design/REQUIREMENT.md +1 -2
  3. package/claude/skills/api-design/SKILL.md +2 -3
  4. package/claude/skills/api-design/references/adopted.md +1 -1
  5. package/claude/skills/canon-feedback/REQUIREMENT.md +1 -0
  6. package/claude/skills/canon-feedback/SKILL.md +10 -28
  7. package/claude/skills/canon-feedback/references/template.md +53 -0
  8. package/claude/skills/canvas-board/SKILL.md +4 -1
  9. package/claude/skills/canvas-board/references/canvas.md +26 -3
  10. package/claude/skills/code-craft/REQUIREMENT.md +1 -1
  11. package/claude/skills/code-craft/SKILL.md +0 -1
  12. package/claude/skills/create-rule/SKILL.md +1 -1
  13. package/claude/skills/create-rule/references/rule.md +3 -1
  14. package/claude/skills/create-skill/references/skill.md +1 -1
  15. package/claude/skills/git-issue/references/issue.md +4 -1
  16. package/claude/skills/review-craft/references/ui.md +1 -1
  17. package/claude/skills/review-ui/SKILL.md +7 -5
  18. package/claude/skills/review-ui/references/driving.md +8 -8
  19. package/claude/skills/review-ui/references/post.md +52 -16
  20. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +9 -0
  21. package/claude/skills/target-setup/SKILL.md +10 -4
  22. package/claude/skills/target-setup/references/detect.md +19 -2
  23. package/claude/skills/test-craft/REQUIREMENT.md +2 -3
  24. package/claude/skills/test-craft/SKILL.md +15 -3
  25. package/docs/agents/canvas.md +5 -1
  26. package/docs/agents/commands.md +1 -1
  27. package/docs/agents/gov-detect.md +47 -0
  28. package/docs/agents/index.md +4 -1
  29. package/docs/agents/install-and-sync.md +4 -0
  30. package/docs/agents/pr-evidence-layout.md +70 -0
  31. package/docs/agents/pr-evidence.md +8 -9
  32. package/docs/agents/pr-frames.md +7 -2
  33. package/docs/agents/pr-lift.md +132 -0
  34. package/docs/agents/scripting.md +2 -1
  35. package/docs/target/projects.md +3 -3
  36. package/governance/detect.toml +201 -0
  37. package/governance/rules/code/000-code.md +1 -13
  38. package/governance/rules/code/010-testing.md +2 -30
  39. package/governance/rules/ui/400-ui.md +1 -1
  40. package/governance/rules/ui/480-canvas.md +13 -0
  41. package/package.json +1 -1
  42. package/scripts/core/regen-hero.ts +0 -6
  43. package/scripts/lib/ui.sh +0 -9
  44. package/src/canvas/arrange.ts +66 -0
  45. package/src/canvas/capture.ts +20 -6
  46. package/src/canvas/content.ts +39 -0
  47. package/src/canvas/fit.ts +238 -0
  48. package/src/canvas/tokens.ts +28 -10
  49. package/src/capture/frame-values.ts +2 -32
  50. package/src/commands/canvas.ts +179 -1
  51. package/src/commands/gov.ts +144 -1
  52. package/src/commands/pr.ts +376 -1
  53. package/src/feedback/body.ts +26 -5
  54. package/src/gate/measures.ts +58 -44
  55. package/src/gate/stages.ts +3 -4
  56. package/src/gov/detect.ts +301 -0
  57. package/src/gov/list.ts +23 -2
  58. package/src/gov/stacks.ts +9 -0
  59. package/src/pr/evidence.ts +218 -45
  60. package/src/pr/frames.ts +23 -3
  61. package/src/pr/lift.ts +418 -0
  62. package/src/pr/rest.ts +3 -0
  63. package/src/pr/review-scope.ts +76 -0
  64. package/src/sandbox/equivalence.ts +14 -7
@@ -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
@@ -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, and `link` names that',
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