@erclx/canon 5.12.0 → 5.14.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 (74) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/api-design/REQUIREMENT.md +1 -1
  3. package/claude/skills/api-design/SKILL.md +4 -3
  4. package/claude/skills/api-design/references/adopted.md +1 -1
  5. package/claude/skills/auto-ship/REQUIREMENT.md +1 -0
  6. package/claude/skills/auto-ship/SKILL.md +2 -2
  7. package/claude/skills/build-in-slices/REQUIREMENT.md +45 -0
  8. package/claude/skills/build-in-slices/SKILL.md +94 -0
  9. package/claude/skills/build-in-slices/references/adopted.md +41 -0
  10. package/claude/skills/code-craft/REQUIREMENT.md +48 -0
  11. package/claude/skills/code-craft/SKILL.md +107 -0
  12. package/claude/skills/code-craft/references/adopted.md +52 -0
  13. package/claude/skills/codebase-layout/REQUIREMENT.md +1 -1
  14. package/claude/skills/deprecation-migration/REQUIREMENT.md +62 -0
  15. package/claude/skills/deprecation-migration/SKILL.md +115 -0
  16. package/claude/skills/deprecation-migration/references/adopted.md +50 -0
  17. package/claude/skills/git-followup/REQUIREMENT.md +1 -0
  18. package/claude/skills/git-followup/SKILL.md +3 -0
  19. package/claude/skills/git-pr/REQUIREMENT.md +1 -0
  20. package/claude/skills/git-pr/SKILL.md +1 -1
  21. package/claude/skills/git-pr/references/evidence.md +2 -2
  22. package/claude/skills/git-ship/REQUIREMENT.md +4 -0
  23. package/claude/skills/git-ship/SKILL.md +13 -2
  24. package/claude/skills/review-craft/REQUIREMENT.md +3 -0
  25. package/claude/skills/review-craft/SKILL.md +1 -1
  26. package/claude/skills/review-craft/references/adopted.md +8 -0
  27. package/claude/skills/review-craft/references/security.md +5 -0
  28. package/claude/skills/review-ui/REQUIREMENT.md +6 -2
  29. package/claude/skills/review-ui/SKILL.md +23 -4
  30. package/claude/skills/review-ui/references/driving.md +5 -3
  31. package/claude/skills/review-ui/references/post.md +3 -2
  32. package/claude/skills/role-worker/REQUIREMENT.md +1 -0
  33. package/claude/skills/role-worker/SKILL.md +4 -0
  34. package/claude/skills/test-first/REQUIREMENT.md +3 -0
  35. package/claude/skills/test-first/SKILL.md +2 -1
  36. package/docs/agents/commands.md +1 -1
  37. package/docs/agents/index.md +1 -0
  38. package/docs/agents/markdown-audit.md +1 -1
  39. package/docs/agents/pr-evidence.md +5 -0
  40. package/docs/agents/pr-frames.md +112 -0
  41. package/docs/agents/pr-preview.md +40 -12
  42. package/docs/workflow/skill-map.md +15 -12
  43. package/governance/rules/code/000-code.md +5 -1
  44. package/package.json +1 -1
  45. package/src/claude/cases/misc.ts +15 -0
  46. package/src/claude/skills-audit.ts +3 -0
  47. package/src/commands/pr.ts +559 -7
  48. package/src/commands/tooling.ts +39 -16
  49. package/src/context/architecture.ts +5 -2
  50. package/src/gate/boundaries.ts +509 -0
  51. package/src/gate/measures.ts +8 -19
  52. package/src/gate/stages.ts +12 -28
  53. package/src/pr/frames.ts +323 -0
  54. package/src/pr/preview.ts +66 -0
  55. package/src/tooling/manifest.ts +18 -3
  56. package/src/tooling/seeds.ts +37 -0
  57. package/src/tooling/verify-stack.ts +249 -0
  58. package/tooling/base/seeds/.cspell/tech-stack.txt +2 -0
  59. package/tooling/claude/manifest.toml +3 -3
  60. package/tooling/go/manifest.toml +3 -0
  61. package/tooling/go/reference.md +1 -0
  62. package/tooling/php/manifest.toml +3 -0
  63. package/tooling/php/reference.md +1 -0
  64. package/tooling/python/manifest.toml +3 -0
  65. package/tooling/python/reference.md +1 -1
  66. package/tooling/python/seeds/.cspell/tech-stack.txt +0 -1
  67. package/scripts/core/check-capability-seeding.sh +0 -134
  68. package/scripts/core/check-ignore-parity.sh +0 -141
  69. package/scripts/core/check-plugin-boundary.sh +0 -45
  70. package/scripts/core/check-seed-independence.sh +0 -62
  71. package/scripts/core/check-skill-paths.sh +0 -24
  72. package/scripts/core/list-seed-roots.sh +0 -18
  73. package/scripts/lib/tooling.sh +0 -37
  74. package/scripts/tooling/verify.sh +0 -184
@@ -36,6 +36,19 @@ import {
36
36
  readOwed,
37
37
  renderEvidenceBody,
38
38
  } from '@/pr/evidence'
39
+ import {
40
+ dropFrames,
41
+ FRAMES_BRANCH,
42
+ type FramesApi,
43
+ type FramesRefusal,
44
+ frameLink,
45
+ isFrameHead,
46
+ isFramePass,
47
+ pruneFrames,
48
+ pushFrame,
49
+ readFrame,
50
+ type TreeEntry,
51
+ } from '@/pr/frames'
39
52
  import { type HeadRefusal, resolveHead, resolveTip } from '@/pr/head'
40
53
  import {
41
54
  findLocalServer,
@@ -50,6 +63,8 @@ import { KEY_CHANGES } from '@/pr/paths'
50
63
  import {
51
64
  findDeployWorkflow,
52
65
  mintPreview,
66
+ type PreviewHeadReason,
67
+ readPreviewHead,
53
68
  type PreviewRefusal,
54
69
  type PreviewRunner,
55
70
  type RunRow,
@@ -114,8 +129,58 @@ const LOCAL_REFUSALS: Record<LocalRefusal | 'gh-failed', string> = {
114
129
  'gh could not read or edit the marked comment on this pull request.',
115
130
  }
116
131
 
132
+ interface FramesOptions extends ReadOptions {
133
+ readonly add?: string
134
+ readonly box?: string
135
+ readonly head?: string
136
+ readonly pass?: string
137
+ readonly drop?: boolean
138
+ readonly prune?: string
139
+ }
140
+
141
+ type FramesCommandRefusal =
142
+ | FramesRefusal
143
+ | 'bad-mode'
144
+ | 'no-number'
145
+ | 'bad-box'
146
+ | 'bad-head'
147
+ | 'bad-pass'
148
+ | 'bad-days'
149
+ | 'unreadable-frame'
150
+ | 'read-only'
151
+ | 'gh-missing'
152
+ | 'gh-failed'
153
+ | 'no-object-head'
154
+
155
+ const FRAMES_REFUSALS: Record<FramesCommandRefusal, string> = {
156
+ 'bad-mode':
157
+ 'Pass exactly one of --add, --drop, or --prune. --prune reads every pull request on the branch, so it takes no number.',
158
+ 'no-number':
159
+ '--add and --drop write for one pull request, so name its number.',
160
+ 'bad-box':
161
+ '--add takes --box <n>, a positive whole number naming the checklist box.',
162
+ 'bad-head':
163
+ '--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.',
164
+ 'bad-pass':
165
+ '--add takes --pass <stamp>, the instant the pass started in compact UTC such as 20261002T154450Z, so a second pass never replaces an earlier one.',
166
+ 'bad-days': '--prune takes a positive whole number of days.',
167
+ 'unreadable-frame':
168
+ 'The file named by --add is missing, empty, or not a PNG, so it would embed as a broken image. Nothing is pushed.',
169
+ 'read-only': `GitHub refused the write to ${FRAMES_BRANCH}. The token cannot write this repository, which is what a fork pull request's workflow token gets, so describe the frame in words instead.`,
170
+ 'gh-missing':
171
+ 'gh is not on the path, so the branch could not be read or written.',
172
+ 'gh-failed':
173
+ 'gh could not answer for this repository or pull request, so no link could be built.',
174
+ 'no-object-head':
175
+ 'The pull request object reported no head commit and no --head was passed, so the frame has no head to file under.',
176
+ 'unreadable-tip': `The ${FRAMES_BRANCH} branch could not be read, so nothing was written. A missing branch is not this, since an add creates it.`,
177
+ 'push-failed': `A write to ${FRAMES_BRANCH} failed partway. The ref did not move, so the branch is as it was.`,
178
+ 'ref-conflict': `Another writer kept moving ${FRAMES_BRANCH} through every attempt, so the add gave up. Re-run it.`,
179
+ }
180
+
117
181
  interface PreviewOptions extends ReadOptions {
118
182
  readonly timeout?: string
183
+ readonly check?: boolean
119
184
  }
120
185
 
121
186
  /** How long `canon pr preview` waits on the deploy run by default, in minutes. */
@@ -124,8 +189,13 @@ const PREVIEW_TIMEOUT_MINUTES = 15
124
189
  /** How often the deploy run is re-read while waiting on it. */
125
190
  const PREVIEW_POLL_MS = 15_000
126
191
 
127
- const PREVIEW_REFUSALS: Record<PreviewRefusal | 'bad-timeout', string> = {
192
+ const PREVIEW_REFUSALS: Record<
193
+ PreviewRefusal | 'bad-timeout' | 'check-timeout',
194
+ string
195
+ > = {
128
196
  'bad-timeout': '--timeout takes a positive number of minutes.',
197
+ 'check-timeout':
198
+ '--check only reads the deploy runs and waits on nothing, so it takes no --timeout.',
129
199
  'no-deploy':
130
200
  'No workflow under .github/workflows/ runs pages deploy and carries a workflow_dispatch trigger, so there is nothing to dispatch.',
131
201
  // biome-ignore lint/suspicious/noTemplateCurlyInString: a workflow expression, not a template
@@ -141,6 +211,17 @@ const PREVIEW_REFUSALS: Record<PreviewRefusal | 'bad-timeout', string> = {
141
211
  'The deploy run did not finish inside the bound. The preview may still land. Re-run with a longer --timeout or read the run.',
142
212
  }
143
213
 
214
+ /** What a reader does about each way `canon pr preview --check` answers. */
215
+ const PREVIEW_HEAD_NOTES: Record<PreviewHeadReason, string> = {
216
+ fresh: 'The newest successful deploy built the branch tip.',
217
+ stale:
218
+ 'The newest successful deploy built an earlier head than the branch tip. Mint again with canon pr preview before driving the preview.',
219
+ building:
220
+ 'A deploy of the branch tip is still running. Wait for it rather than minting again.',
221
+ 'no-build':
222
+ 'No successful deploy exists for this branch. Mint one with canon pr preview.',
223
+ }
224
+
144
225
  /** Why a head-sensitive read produced no answer about a commit. */
145
226
  type PullRefusal =
146
227
  | 'gh-missing'
@@ -528,8 +609,11 @@ export function register(program: Command): void {
528
609
  .option('--json', 'Add a machine-readable record on stdout')
529
610
  .option(
530
611
  '--timeout <minutes>',
531
- 'How long to wait on the deploy run',
532
- String(PREVIEW_TIMEOUT_MINUTES),
612
+ `How long to wait on the deploy run (default ${PREVIEW_TIMEOUT_MINUTES})`,
613
+ )
614
+ .option(
615
+ '--check',
616
+ 'Read whether the newest successful deploy built the branch tip, dispatching nothing',
533
617
  )
534
618
  .addHelpText(
535
619
  'after',
@@ -554,13 +638,22 @@ export function register(program: Command): void {
554
638
  ' run-failed the deploy run finished without succeeding',
555
639
  ' timeout the run did not finish inside --timeout minutes',
556
640
  '',
641
+ 'With --check nothing is dispatched or waited on. The newest successful',
642
+ 'workflow_dispatch run is compared with the branch tip read from the remote.',
643
+ 'Read `reason` on the JSON record, with `built`, `tip`, and `runId`:',
644
+ ' fresh the newest successful deploy built the tip',
645
+ ' stale the newest successful deploy built an earlier head',
646
+ ' building a deploy of the tip is still running, so wait',
647
+ ' no-build no deploy of this branch has succeeded',
648
+ '',
557
649
  'Exit codes:',
558
- ' 0 a preview was published',
559
- ' 1 refused, with the reason on stderr or in the JSON record',
650
+ ' 0 a preview was published, or --check read fresh',
651
+ ' 1 refused, or --check read anything but fresh',
560
652
  '',
561
653
  'Examples:',
562
654
  ' canon pr preview --json',
563
655
  ' canon pr preview 1341 --json --timeout 20',
656
+ ' canon pr preview 1341 --check --json',
564
657
  '',
565
658
  ].join('\n'),
566
659
  )
@@ -628,6 +721,82 @@ export function register(program: Command): void {
628
721
  ? await runLocalRemove(number, opts)
629
722
  : await runLocal(opts)
630
723
  })
724
+
725
+ pr.command('frames')
726
+ .description(
727
+ `Push a UI review frame to the never-merged ${FRAMES_BRANCH} branch and return its embed link, or drop frames from it`,
728
+ )
729
+ .argument(
730
+ '[number]',
731
+ 'Pull request the frame belongs to, for --add and --drop',
732
+ )
733
+ .helpOption('-h, --help', 'Show this help message')
734
+ .option('--root <path>', 'Repository to answer for, defaulting to the cwd')
735
+ .option('--json', 'Add a machine-readable record on stdout')
736
+ .option('--add <file>', 'Push this PNG as a frame')
737
+ .option('--box <n>', 'The checklist box the frame shows, with --add')
738
+ .option(
739
+ '--head <sha>',
740
+ "The head the frame was driven at, with --add, defaulting to the pull request's",
741
+ )
742
+ .option(
743
+ '--pass <stamp>',
744
+ 'The instant the pass started, in compact UTC such as 20261002T154450Z, with --add',
745
+ )
746
+ .option('--drop', "Rewrite the branch without this pull request's frames")
747
+ .option(
748
+ '--prune <days>',
749
+ 'Drop the frames of every pull request closed more than this many days ago',
750
+ )
751
+ .addHelpText(
752
+ 'after',
753
+ [
754
+ '',
755
+ `Writes only refs/heads/${FRAMES_BRANCH}, through the GitHub git data API,`,
756
+ 'so nothing is checked out and no other branch is touched. A frame lands',
757
+ 'at pr-<number>/<short-head>/<pass>/box-<n>.png, and `link` names that',
758
+ `path on ${FRAMES_BRANCH}, so it survives a rewrite that drops other pull`,
759
+ 'requests and resolves until its own pull request is dropped. The pass',
760
+ 'segment keeps a second pass at one head and box from replacing an image',
761
+ 'an earlier comment shows. The first --add creates the branch.',
762
+ '',
763
+ '--drop and --prune write one root commit and force-move the ref,',
764
+ 'since a delete commit would keep every image reachable through history.',
765
+ 'A rewrite that empties the branch deletes it. --prune keeps a pull',
766
+ 'request still open, reopened, closed exactly the given days ago, or one',
767
+ 'whose state could not be read, listing that last kind under `unread`.',
768
+ '',
769
+ 'Read `reason` on the JSON record:',
770
+ ' ok an add pushed, or a drop or prune finished',
771
+ ' bad-mode not exactly one of --add, --drop, --prune',
772
+ ' no-number --add or --drop named no pull request',
773
+ ' bad-box --add without a positive --box',
774
+ ' bad-head --head is not 7 to 40 lowercase hex characters',
775
+ ' bad-pass --add without a compact UTC --pass stamp',
776
+ ' bad-days --prune without a positive number of days',
777
+ ' unreadable-frame the --add file is missing, empty, or not a PNG',
778
+ ' read-only GitHub refused the write, as on a fork token',
779
+ ' unreadable-tip the branch could not be read',
780
+ ' push-failed a write failed and the ref did not move',
781
+ ' ref-conflict another writer kept moving the ref',
782
+ ' no-object-head no head to file the frame under',
783
+ ' gh-missing gh is not on the path',
784
+ ' gh-failed gh could not answer for the repository',
785
+ '',
786
+ 'Exit codes:',
787
+ ' 0 pushed, or a drop or prune finished, including one removing nothing',
788
+ ' 1 refused, with the reason on stderr or in the JSON record',
789
+ '',
790
+ 'Examples:',
791
+ ' canon pr frames 1341 --add frames/3.png --box 3 --pass 20261002T154450Z --json',
792
+ ' canon pr frames 1341 --drop --json',
793
+ ' canon pr frames --prune 30 --json',
794
+ '',
795
+ ].join('\n'),
796
+ )
797
+ .action(async (number: string | undefined, opts: FramesOptions) => {
798
+ process.exitCode = await runFrames(number, opts)
799
+ })
631
800
  }
632
801
 
633
802
  interface PullRequestRead {
@@ -1712,6 +1881,7 @@ async function runEvidence(
1712
1881
  base,
1713
1882
  head: identity.head,
1714
1883
  body,
1884
+ states,
1715
1885
  ...(commentId !== undefined && { commentId }),
1716
1886
  ...carried,
1717
1887
  })}\n`,
@@ -1773,7 +1943,7 @@ function ghPreviewRunner(root: string): PreviewRunner {
1773
1943
  '--limit',
1774
1944
  '20',
1775
1945
  '--json',
1776
- 'databaseId,status,conclusion',
1946
+ 'databaseId,status,conclusion,headSha,createdAt',
1777
1947
  ])
1778
1948
  if (out === null) return undefined
1779
1949
  try {
@@ -1789,7 +1959,7 @@ function ghPreviewRunner(root: string): PreviewRunner {
1789
1959
  'view',
1790
1960
  String(id),
1791
1961
  '--json',
1792
- 'databaseId,status,conclusion',
1962
+ 'databaseId,status,conclusion,headSha,createdAt',
1793
1963
  ]),
1794
1964
  )
1795
1965
  },
@@ -1810,6 +1980,15 @@ async function runPreview(
1810
1980
 
1811
1981
  intro('canon pr preview')
1812
1982
 
1983
+ if (opts.check === true && opts.timeout !== undefined) {
1984
+ return refuseWith(
1985
+ 'check-timeout',
1986
+ PREVIEW_REFUSALS['check-timeout'],
1987
+ emitJson,
1988
+ root,
1989
+ )
1990
+ }
1991
+
1813
1992
  const minutes = Number(opts.timeout ?? PREVIEW_TIMEOUT_MINUTES)
1814
1993
  if (!Number.isFinite(minutes) || minutes <= 0) {
1815
1994
  return refuseWith(
@@ -1838,6 +2017,10 @@ async function runPreview(
1838
2017
  }
1839
2018
  const { identity } = read
1840
2019
 
2020
+ if (opts.check === true) {
2021
+ return runPreviewCheck(root, emitJson, pick.path, identity)
2022
+ }
2023
+
1841
2024
  logStep('Deploy')
1842
2025
  logInfo(`${pick.path} on ${identity.branch}`)
1843
2026
 
@@ -1887,6 +2070,62 @@ async function runPreview(
1887
2070
  return 0
1888
2071
  }
1889
2072
 
2073
+ /**
2074
+ * Reads the built head against the tip. The tip comes from the remote rather
2075
+ * than `headRefOid`, which lags a push, so a build of the last head reads stale
2076
+ * the moment a newer commit lands.
2077
+ */
2078
+ async function runPreviewCheck(
2079
+ root: string,
2080
+ emitJson: boolean,
2081
+ workflow: string,
2082
+ identity: { readonly branch: string; readonly number?: number },
2083
+ ): Promise<number> {
2084
+ const resolved = await resolveTip(identity.branch, refReader(root))
2085
+ if (resolved.kind === 'refused') {
2086
+ return refuseWith(
2087
+ resolved.reason,
2088
+ PULL_REFUSALS[resolved.reason],
2089
+ emitJson,
2090
+ root,
2091
+ )
2092
+ }
2093
+
2094
+ const reading = await readPreviewHead(ghPreviewRunner(root), {
2095
+ workflow,
2096
+ branch: identity.branch,
2097
+ tip: resolved.tip,
2098
+ })
2099
+ if (reading.reason === 'gh-failed') {
2100
+ return refuseWith(
2101
+ 'gh-failed',
2102
+ PREVIEW_REFUSALS['gh-failed'],
2103
+ emitJson,
2104
+ root,
2105
+ )
2106
+ }
2107
+
2108
+ logStep('Preview head')
2109
+ logInfo(PREVIEW_HEAD_NOTES[reading.reason])
2110
+ outro()
2111
+
2112
+ if (emitJson) {
2113
+ process.stdout.write(
2114
+ `${JSON.stringify({
2115
+ root,
2116
+ ...(identity.number !== undefined && { number: identity.number }),
2117
+ reason: reading.reason,
2118
+ message: PREVIEW_HEAD_NOTES[reading.reason],
2119
+ workflow,
2120
+ tip: reading.tip,
2121
+ ...(reading.built !== undefined && { built: reading.built }),
2122
+ ...(reading.runId !== undefined && { runId: reading.runId }),
2123
+ })}\n`,
2124
+ )
2125
+ }
2126
+ return reading.reason === 'fresh' ? 0 : 1
2127
+ }
2128
+
1890
2129
  async function readText(path: string): Promise<string | undefined> {
1891
2130
  return readFile(path, 'utf8').catch(() => undefined)
1892
2131
  }
@@ -2098,6 +2337,319 @@ async function runLocalRemove(
2098
2337
  return finish('removed', `comment ${commentId}`)
2099
2338
  }
2100
2339
 
2340
+ const POSITIVE_WHOLE = /^[1-9]\d*$/
2341
+
2342
+ /** One `gh api` call, keeping the HTTP status of a failure so a refused write can be told from a missing ref. */
2343
+ type ApiCall =
2344
+ | { readonly ok: true; readonly data: unknown }
2345
+ | { readonly ok: false; readonly status: number | undefined }
2346
+
2347
+ type ApiCaller = (
2348
+ method: string,
2349
+ path: string,
2350
+ body?: unknown,
2351
+ ) => Promise<ApiCall>
2352
+
2353
+ /**
2354
+ * Runs `gh api` against the repository's own path, recording every write
2355
+ * GitHub answered 403 or 404, which is how a token without write access
2356
+ * reads, so a failed push can be reported as read-only rather than broken.
2357
+ */
2358
+ function ghApiRunner(root: string): {
2359
+ readonly call: ApiCaller
2360
+ readonly refusedWrites: number[]
2361
+ } {
2362
+ const refusedWrites: number[] = []
2363
+ const call: ApiCaller = async (method, path, body) => {
2364
+ const args = ['api', '-X', method, `repos/{owner}/{repo}/${path}`]
2365
+ if (body !== undefined) args.push('--input', '-')
2366
+ const result = await execa('gh', args, {
2367
+ cwd: root,
2368
+ timeout: GH_TIMEOUT_MS,
2369
+ env: gitEnv(),
2370
+ extendEnv: false,
2371
+ reject: false,
2372
+ ...(body !== undefined && { input: JSON.stringify(body) }),
2373
+ })
2374
+ if (result.exitCode !== 0) {
2375
+ const status = /HTTP (\d{3})/.exec(String(result.stderr))?.[1]
2376
+ const code = status === undefined ? undefined : Number(status)
2377
+ if (method !== 'GET' && (code === 403 || code === 404)) {
2378
+ refusedWrites.push(code)
2379
+ }
2380
+ return { ok: false, status: code }
2381
+ }
2382
+ const text = String(result.stdout).trim()
2383
+ try {
2384
+ return { ok: true, data: text === '' ? null : JSON.parse(text) }
2385
+ } catch {
2386
+ return { ok: false, status: undefined }
2387
+ }
2388
+ }
2389
+ return { call, refusedWrites }
2390
+ }
2391
+
2392
+ function shaOf(data: unknown): string | undefined {
2393
+ if (typeof data !== 'object' || data === null || !('sha' in data)) {
2394
+ return undefined
2395
+ }
2396
+ return typeof data.sha === 'string' ? data.sha : undefined
2397
+ }
2398
+
2399
+ function isTreeEntry(value: unknown): value is TreeEntry {
2400
+ if (typeof value !== 'object' || value === null) return false
2401
+ const entry = value as Record<string, unknown>
2402
+ return (
2403
+ typeof entry.path === 'string' &&
2404
+ typeof entry.mode === 'string' &&
2405
+ (entry.type === 'blob' || entry.type === 'tree') &&
2406
+ typeof entry.sha === 'string'
2407
+ )
2408
+ }
2409
+
2410
+ /**
2411
+ * The git data API behind `canon pr frames`. A 422 on a ref write is GitHub
2412
+ * refusing a move that is not a fast-forward, or a create or update on a ref
2413
+ * that already exists or no longer does, which is another writer's doing and
2414
+ * worth a re-read rather than a failure.
2415
+ */
2416
+ function ghFramesApi(call: ApiCaller): FramesApi {
2417
+ const ref = `git/refs/heads/${FRAMES_BRANCH}`
2418
+ const refWrite = (result: ApiCall) =>
2419
+ result.ok ? 'ok' : result.status === 422 ? 'conflict' : 'failed'
2420
+ return {
2421
+ async readTip() {
2422
+ const head = await call('GET', `git/ref/heads/${FRAMES_BRANCH}`)
2423
+ if (!head.ok) {
2424
+ return head.status === 404
2425
+ ? { kind: 'missing' }
2426
+ : { kind: 'unreadable' }
2427
+ }
2428
+ const commit = (head.data as { object?: { sha?: unknown } } | null)
2429
+ ?.object?.sha
2430
+ if (typeof commit !== 'string') return { kind: 'unreadable' }
2431
+ const read = await call('GET', `git/commits/${commit}`)
2432
+ const tree = read.ok
2433
+ ? (read.data as { tree?: { sha?: unknown } } | null)?.tree?.sha
2434
+ : undefined
2435
+ return typeof tree === 'string'
2436
+ ? { kind: 'found', commit, tree }
2437
+ : { kind: 'unreadable' }
2438
+ },
2439
+ async listTree(tree) {
2440
+ const read = await call('GET', `git/trees/${tree}`)
2441
+ if (!read.ok) return undefined
2442
+ const entries = (read.data as { tree?: unknown } | null)?.tree
2443
+ return Array.isArray(entries) && entries.every(isTreeEntry)
2444
+ ? entries
2445
+ : undefined
2446
+ },
2447
+ async createBlob(base64) {
2448
+ const made = await call('POST', 'git/blobs', {
2449
+ content: base64,
2450
+ encoding: 'base64',
2451
+ })
2452
+ return made.ok ? shaOf(made.data) : undefined
2453
+ },
2454
+ async createTree(entries, base) {
2455
+ const made = await call('POST', 'git/trees', {
2456
+ tree: entries.map(({ path, mode, type, sha }) => ({
2457
+ path,
2458
+ mode,
2459
+ type,
2460
+ sha,
2461
+ })),
2462
+ ...(base !== undefined && { base_tree: base }),
2463
+ })
2464
+ return made.ok ? shaOf(made.data) : undefined
2465
+ },
2466
+ async createCommit(message, tree, parents) {
2467
+ const made = await call('POST', 'git/commits', {
2468
+ message,
2469
+ tree,
2470
+ parents,
2471
+ })
2472
+ return made.ok ? shaOf(made.data) : undefined
2473
+ },
2474
+ async createRef(commit) {
2475
+ return refWrite(
2476
+ await call('POST', 'git/refs', {
2477
+ ref: `refs/heads/${FRAMES_BRANCH}`,
2478
+ sha: commit,
2479
+ }),
2480
+ )
2481
+ },
2482
+ async moveRef(commit, force) {
2483
+ return refWrite(await call('PATCH', ref, { sha: commit, force }))
2484
+ },
2485
+ async deleteRef() {
2486
+ const gone = await call('DELETE', ref)
2487
+ return gone.ok || gone.status === 422
2488
+ },
2489
+ async readPull(number) {
2490
+ const read = await call('GET', `pulls/${number}`)
2491
+ if (!read.ok) return undefined
2492
+ const row = read.data as { state?: unknown; closed_at?: unknown } | null
2493
+ if (row?.state !== 'open' && row?.state !== 'closed') return undefined
2494
+ return {
2495
+ state: row.state,
2496
+ ...(typeof row.closed_at === 'string' && { closedAt: row.closed_at }),
2497
+ }
2498
+ },
2499
+ }
2500
+ }
2501
+
2502
+ /** Validates the mode and its arguments before anything reads the network. */
2503
+ function framesArgumentRefusal(
2504
+ number: string | undefined,
2505
+ opts: FramesOptions,
2506
+ ): FramesCommandRefusal | undefined {
2507
+ const modes = [
2508
+ opts.add !== undefined,
2509
+ opts.drop === true,
2510
+ opts.prune !== undefined,
2511
+ ]
2512
+ if (modes.filter(Boolean).length !== 1) return 'bad-mode'
2513
+ if (opts.prune !== undefined) {
2514
+ if (number !== undefined) return 'bad-mode'
2515
+ return POSITIVE_WHOLE.test(opts.prune) ? undefined : 'bad-days'
2516
+ }
2517
+ if (number === undefined || !POSITIVE_WHOLE.test(number)) return 'no-number'
2518
+ if (
2519
+ opts.add !== undefined &&
2520
+ (opts.box === undefined || !POSITIVE_WHOLE.test(opts.box))
2521
+ ) {
2522
+ return 'bad-box'
2523
+ }
2524
+ if (opts.head !== undefined && !isFrameHead(opts.head)) return 'bad-head'
2525
+ if (
2526
+ opts.add !== undefined &&
2527
+ (opts.pass === undefined || !isFramePass(opts.pass))
2528
+ ) {
2529
+ return 'bad-pass'
2530
+ }
2531
+ return undefined
2532
+ }
2533
+
2534
+ async function readRepoName(root: string): Promise<string | undefined> {
2535
+ const row = await gh(root, ['repo', 'view', '--json', 'nameWithOwner'])
2536
+ if (row === null) return undefined
2537
+ try {
2538
+ const name = (JSON.parse(row) as { nameWithOwner?: unknown }).nameWithOwner
2539
+ return typeof name === 'string' ? name : undefined
2540
+ } catch {
2541
+ return undefined
2542
+ }
2543
+ }
2544
+
2545
+ async function runFrames(
2546
+ number: string | undefined,
2547
+ opts: FramesOptions,
2548
+ ): Promise<number> {
2549
+ const root = resolve(opts.root ?? process.cwd())
2550
+ const emitJson = opts.json ?? false
2551
+ const refuseFrames = (reason: FramesCommandRefusal) =>
2552
+ refuseWith(reason, FRAMES_REFUSALS[reason], emitJson, root)
2553
+
2554
+ intro('canon pr frames')
2555
+
2556
+ const invalid = framesArgumentRefusal(number, opts)
2557
+ if (invalid !== undefined) return refuseFrames(invalid)
2558
+
2559
+ const bytes =
2560
+ opts.add === undefined ? undefined : await readFrame(resolve(opts.add))
2561
+ if (opts.add !== undefined && bytes === undefined) {
2562
+ return refuseFrames('unreadable-frame')
2563
+ }
2564
+
2565
+ if (Bun.which('gh') === null) return refuseFrames('gh-missing')
2566
+ const runner = ghApiRunner(root)
2567
+ const api = ghFramesApi(runner.call)
2568
+ const refuseWrite = (reason: FramesRefusal) =>
2569
+ refuseFrames(
2570
+ reason === 'push-failed' && runner.refusedWrites.length > 0
2571
+ ? 'read-only'
2572
+ : reason,
2573
+ )
2574
+
2575
+ const finish = (
2576
+ record: Record<string, unknown>,
2577
+ lines: readonly string[],
2578
+ ): number => {
2579
+ logStep('Frames')
2580
+ for (const line of lines) logInfo(line)
2581
+ outro()
2582
+ if (emitJson) {
2583
+ process.stdout.write(
2584
+ `${JSON.stringify({ root, reason: 'ok', ...record })}\n`,
2585
+ )
2586
+ }
2587
+ return 0
2588
+ }
2589
+
2590
+ if (opts.prune !== undefined) {
2591
+ const days = Number(opts.prune)
2592
+ const result = await pruneFrames(api, days, new Date())
2593
+ if (result.kind === 'refused') return refuseWrite(result.reason)
2594
+ const { kind: _kind, ...rest } = result
2595
+ return finish({ days, ...rest }, [
2596
+ `removed ${plural(result.removed.length, 'pull request')}`,
2597
+ ...(result.unread.length > 0
2598
+ ? [`kept ${result.unread.map((n) => `#${n}`).join(', ')} unread`]
2599
+ : []),
2600
+ ])
2601
+ }
2602
+
2603
+ const pull = Number(number)
2604
+ if (opts.drop === true) {
2605
+ const result = await dropFrames(api, [pull])
2606
+ if (result.kind === 'refused') return refuseWrite(result.reason)
2607
+ const { kind: _kind, ...rest } = result
2608
+ return finish({ number: pull, ...rest }, [
2609
+ result.removed.length === 0
2610
+ ? `#${pull} holds no frames on ${FRAMES_BRANCH}`
2611
+ : `dropped the frames of #${pull}`,
2612
+ ])
2613
+ }
2614
+
2615
+ const repo = await readRepoName(root)
2616
+ if (repo === undefined) return refuseFrames('gh-failed')
2617
+
2618
+ let head = opts.head
2619
+ if (head === undefined) {
2620
+ const identity = await readIdentity(root, String(pull))
2621
+ if (identity.kind === 'refused') return refuseFrames('gh-failed')
2622
+ head = identity.identity.head
2623
+ }
2624
+ if (head === undefined || head === '') return refuseFrames('no-object-head')
2625
+ if (!isFrameHead(head)) return refuseFrames('bad-head')
2626
+
2627
+ const box = Number(opts.box)
2628
+ const pass = opts.pass ?? ''
2629
+ const result = await pushFrame(api, {
2630
+ number: pull,
2631
+ head,
2632
+ pass,
2633
+ box,
2634
+ bytes: bytes ?? new Uint8Array(),
2635
+ })
2636
+ if (result.kind === 'refused') return refuseWrite(result.reason)
2637
+ const link = frameLink(repo, result.path)
2638
+ return finish(
2639
+ {
2640
+ number: pull,
2641
+ box,
2642
+ head,
2643
+ pass,
2644
+ path: result.path,
2645
+ commit: result.commit,
2646
+ link,
2647
+ created: result.created,
2648
+ },
2649
+ [link],
2650
+ )
2651
+ }
2652
+
2101
2653
  /**
2102
2654
  * Frames a refusal on stderr in both modes and puts the record on stdout alone,
2103
2655
  * so an operator reading the terminal sees the reason rather than a command