@erclx/canon 5.65.0 → 5.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [5.66.0](https://github.com/erclx/canon/compare/v5.65.0...v5.66.0) (2026-10-25)
4
+
5
+
6
+ ### Features
7
+
8
+ * **labels:** seed a starter label map and create its labels ([#2715](https://github.com/erclx/canon/issues/2715)) ([82a11b1](https://github.com/erclx/canon/commit/82a11b13870960926f0844519a76e039afd0ed5c))
9
+ * **pr:** close a pull request with a required reason ([#2718](https://github.com/erclx/canon/issues/2718)) ([963f784](https://github.com/erclx/canon/commit/963f784e46e25e3c42ea1f6027a7b04860ec544e))
10
+ * **pr:** probe declared health paths before linking the local preview ([#2712](https://github.com/erclx/canon/issues/2712)) ([a16c3f9](https://github.com/erclx/canon/commit/a16c3f966211f5bd9b8af9f82720d42251ac41b9))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **sandbox:** retry the replay checkout copy on a vanished file ([#2720](https://github.com/erclx/canon/issues/2720)) ([64d964b](https://github.com/erclx/canon/commit/64d964bdae0e26f79ab5c0ddc0c1f0e80bc20e6b))
16
+ * **tooling:** install Go for the go stack verify job ([#2717](https://github.com/erclx/canon/issues/2717)) ([e87d433](https://github.com/erclx/canon/commit/e87d433bdbf64945165923d1b6f7294e2d873b7b))
17
+
3
18
  ## [5.65.0](https://github.com/erclx/canon/compare/v5.64.0...v5.65.0) (2026-10-25)
4
19
 
5
20
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
4
- "version": "5.65.0",
4
+ "version": "5.66.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -109,10 +109,14 @@ Labelling runs whenever this skill runs and at no other time, recomputing over t
109
109
  Warn and continue rather than creating it, naming the command in the warning:
110
110
 
111
111
  ```bash
112
- gh label create <name> --description "<text>"
112
+ canon labels sync
113
113
  ```
114
114
 
115
- Creating a label writes to the repository settings from a run invoked to open a pull request, and a label created from a map typo is harder to notice than a warning.
115
+ It creates every label the map names that the remote lacks, and never edits or deletes one.
116
+
117
+ - An installed `canon` predating the verb takes `gh label create <name>` per label instead
118
+ - Creating a label writes to the repository settings, and a label created from a map typo is harder to notice than a warning
119
+ - So the operator runs the sync on purpose after reading the map, and a run invoked to open a pull request never does
116
120
 
117
121
  The refusal names the label it rejected and applies none of the set, so an unseen warning leaves the run reading like one that labelled. Surface it beside the result line rather than letting the pull request URL stand alone.
118
122
 
@@ -0,0 +1,29 @@
1
+ ---
2
+ name: git-pr-close
3
+ description: What closing a pull request unmerged is for, the gap it closes, and how it splits from merging and from the verb it calls
4
+ ---
5
+
6
+ # Git PR close requirement
7
+
8
+ ## Gap
9
+
10
+ Without this skill, a session asked to close a pull request improvises a bare close. Most hand-closed pull requests then carry no reason, so a reader who finds one later cannot tell a rejected idea from a superseded one or a pause. No `git-*` skill covered the act, so the request had nowhere to route.
11
+
12
+ ## Must
13
+
14
+ - Every close goes through `canon pr close`, which refuses without a reason and posts it before the close
15
+ - The reason follows the shape `references/close.md` fixes, opening on what happened and ending on what replaces it or how to reopen
16
+ - A close of another branch's pull request names that branch through `--head`, so a wrong number refuses rather than closing someone else's work
17
+
18
+ ## Must not
19
+
20
+ - Merge, mark ready, or reopen a pull request
21
+ - Guess a number from the newest pull request in view
22
+ - Close with no reason, or post the reason any way other than through the verb
23
+ - Exist only to be typed by name. It earns its place when a session routes "close this PR" or a superseded batch here unprompted.
24
+
25
+ ## Out of scope
26
+
27
+ - Writing the gate on the reason, which the verb owns
28
+ - Merging, which the operator runs through `canon pr merge`
29
+ - Closing an issue, which `git-issue` covers
@@ -0,0 +1,55 @@
1
+ ---
2
+ name: git-pr-close
3
+ description: Closes an open pull request without merging it, posting a short reason first and deleting its head branch, through `canon pr close`. Use when asked to "close this PR", "close #N", "supersede #N with #M", "abandon this pull request", or "drop this PR". Do NOT use to merge, which the operator does, or to close an issue, which `git-issue` covers.
4
+ metadata:
5
+ family: after-pr
6
+ ---
7
+
8
+ # Git PR close
9
+
10
+ Close one pull request unmerged with its reason on the thread, so a later reader finds why without asking.
11
+
12
+ Read `${CLAUDE_SKILL_DIR}/references/close.md` before writing the reason, for its shape and the two forms it takes.
13
+
14
+ ## Guards
15
+
16
+ - Take the number the caller named. With none named, "this pull request" means the current branch's, read from the `number` of `canon pr head --json`.
17
+ - Never guess a number from the newest pull request in view, since a wrong number closes another branch's work
18
+ - If the caller gave no reason and none follows from the conversation, stop: `❌ No reason to post. Say why this pull request is closing.`
19
+
20
+ ## Step 1: write the reason
21
+
22
+ Write the reason to `.canon/tmp/pr/close/close-<number>.md` in its own call, in the shape `close.md` fixes.
23
+
24
+ ## Step 2: close
25
+
26
+ Run the verb. It refuses unless the pull request's head is the branch `--head` names, defaulting to the current branch, so a wrong number writes nothing.
27
+
28
+ ```bash
29
+ canon pr close <number> --reason-file .canon/tmp/pr/close/close-<number>.md --json
30
+ ```
31
+
32
+ - Pass `--head <branch>` only when closing a branch this checkout is not on, taking the branch from the caller, such as a dispatch naming it or the batch a replacement merged
33
+ - Never read `--head` off the pull request being closed, since a branch read from the same number agrees with any number and the check proves nothing
34
+ - Pass `--keep-branch` only when the caller says the branch stays
35
+ - The verb posts the reason, closes, then deletes the branch, and refuses before any write on a reason it cannot publish
36
+
37
+ ## Step 3: read the record
38
+
39
+ Branch on `reason` rather than the exit, since a shell function wrapping `canon` can flatten it.
40
+
41
+ | `reason` | Route |
42
+ | ---------------------- | ---------------------------------------------------------------------------- |
43
+ | `closed` | Report the comment URL, and name the branch when `branch` is `delete-failed` |
44
+ | `reason-unpublishable` | Rewrite the sentence each finding names, then run Step 2 again |
45
+ | `head-mismatch` | Stop and report it, since the number names another branch's pull request |
46
+ | `close-failed` | Report that the comment posted and the pull request is still open |
47
+ | Any other refusal | Report the `message` and stop |
48
+
49
+ ## Output
50
+
51
+ One line on a close:
52
+
53
+ ```plaintext
54
+ ✅ Closed #<number>: <comment url>
55
+ ```
@@ -0,0 +1,54 @@
1
+ ---
2
+ title: Closing comment reference
3
+ description: The shape of the comment a pull request carries when it closes unmerged, its two forms, and the limits the verb checks
4
+ ---
5
+
6
+ # Closing comment reference
7
+
8
+ ## Scope
9
+
10
+ Governs the comment posted when a pull request closes without merging.
11
+
12
+ Does not govern:
13
+
14
+ - A pull request body or title: `${CLAUDE_SKILL_DIR}/../git-pr/references/pr.md`
15
+ - A review reply, which `review-address` writes
16
+
17
+ ## Shape
18
+
19
+ A reader who finds the closed pull request later learns why it closed and where the work went, in two sentences at most.
20
+
21
+ - Open on what happened: `Superseded by #<number>` when another pull request carries the work, or `Closing unmerged.` otherwise
22
+ - Say why in one sentence, naming the decision rather than retelling the discussion that reached it
23
+ - End on what replaces it, or how to reopen when the close is a pause
24
+ - Keep each paragraph under 350 characters, and two paragraphs at most
25
+
26
+ ### Superseded
27
+
28
+ Another pull request carries the work, so the comment links it and says nothing is left behind.
29
+
30
+ ```markdown
31
+ Superseded by #<replacement>, which combines this batch with the five other context rebuilds and carries every reviewed change from this branch. Nothing here is left to carry over.
32
+ ```
33
+
34
+ ### Closed for a decision
35
+
36
+ Nothing replaces the work, so the comment names the decision that ended it.
37
+
38
+ ```markdown
39
+ Closing unmerged. Base's starter label map should stay stack-neutral rather than list every stack's configs, so each project adds rows for its own stack.
40
+ ```
41
+
42
+ ## What the verb refuses
43
+
44
+ `canon pr close` checks the reason before it reads the pull request, and refuses `reason-unpublishable` with the findings in its record.
45
+
46
+ - A banned character, a phase label, a board reference, or a session link, the checks `canon labels scan` runs on a body
47
+ - A paragraph or a run of paragraphs past the density limits, measured on the reason itself
48
+ - The scan measures density only on a post opening on a known heading, so the verb measures it here
49
+
50
+ ## Sources
51
+
52
+ - [Zephyr pull request lifecycle policy](https://docs.zephyrproject.org/latest/contribute/pr_lifecycle_policy.html)
53
+ - "Close with explicit rationale and a reopen path."
54
+ - For a superseded one: "Link the replacement pull request." and "Add a short summary comment preserving context."
@@ -43,11 +43,11 @@ Collect only the lines below whose condition holds, and nothing when none does.
43
43
  | A checklist posted alone | `📋 Posted the UI checklist to <number>, on its own.` |
44
44
  | The preview step returned an address | `🔗 Preview: <url>` |
45
45
  | The evidence step posted the local link | `🖥️ Local preview: <url>` |
46
- | The local step refused and evidence or a checklist posted | `🖥️ No local preview: <reason>` |
46
+ | The local step refused and evidence or a checklist posted | `🖥️ No local preview: <reason>: <message>` |
47
47
  | A git refusal or a preview refusal the reference reports | `⚠️ <reason>: <message>` |
48
48
 
49
49
  ### The no-local-preview line
50
50
 
51
- It fires when the local step refused as `no-server` or `no-listener-reader`, since that pull request changes a rendered surface and a reviewer could have used the link. A later run only carries a link forward, so it cannot add one the comment never had.
51
+ It fires when the local step refused under any reason the reference holds, `unhealthy` included, since that pull request changes a rendered surface and a reviewer could have used the link. The `message` names what to fix, such as the failing health path. A later run only carries a link forward, so it cannot add one the comment never had.
52
52
 
53
53
  To add the link later, start the server and run this skill again, adding `--checklist` to the evidence call when one is still owed.
@@ -35,7 +35,9 @@ canon pr local --json
35
35
  The verb reports the lowest port serving an HTML page whose process runs inside this worktree, never one in another worktree or the main checkout, which shows a different branch. `canon pr local --help` states the contract. Read `reason` on the record rather than the exit code.
36
36
 
37
37
  - `ok`: hold the record's `url` for the evidence step below.
38
- - `no-server` or `no-listener-reader`: hold the reason for the closing report and pass no link. Never start a server here, since the ship chain cannot own one. A worker starts one under `role-worker`.
38
+ - `no-server`, `no-listener-reader`, `unhealthy`, or `invalid-config`: hold the reason and the record's `message` for the closing report, and pass no link.
39
+ - `unhealthy` means a page answered but a health path declared in `canon/config/local.toml` failed. `invalid-config` means that file cannot be read, so nothing was checked.
40
+ - Never start a server here, since the ship chain cannot own one. A worker starts one under `role-worker`.
39
41
  - Anything else, including no record or an older binary's unknown-subcommand error: move on silently, as a skip rather than a stop.
40
42
 
41
43
  ### Where the link lands
@@ -46,8 +46,8 @@ Open the task's `## Findings` line for an override with the bold source-and-date
46
46
 
47
47
  A painted pull request needs a server in this worktree for `canon pr local` to find, and this session is the one with a lifetime to own it. `auto-ship` Step 5 decides whether the diff is painted, so this body restates no file test.
48
48
 
49
- - Run `canon pr local --json` first and start nothing when `reason` reads `ok`
50
- - Otherwise start the stack's `dev` script as a background command from the worktree root before the pull request step. `dev` follows the worktree, so a `review-address` fix shows on the same link.
49
+ - Run `canon pr local --json` first. Start nothing on `ok`, `unhealthy`, or `invalid-config`, and carry a refusal's `message` in the announcement
50
+ - Otherwise start the stack's `dev` in the background from the worktree root before the pull request step, so a `review-address` fix shows on the same link.
51
51
  - Wait up to 60 seconds for the verb to read `ok`, then ship either way. A stack with no `dev` script gets no start, and `pr-evidence` reports `no-server`.
52
52
  - Never detach it with `nohup`, `setsid`, or `disown`, since the owning session is the stop. `canon worktrees reclaim` ends any listener left in a worktree it removes.
53
53
 
@@ -80,6 +80,7 @@ Groups run in the order a project meets them. Every name `canon claude skills li
80
80
  | `canon:git-followup` | A small edit on a branch with an open PR |
81
81
  | `canon:pr-evidence` | A new head needs its screenshots re-posted |
82
82
  | `canon:git-split` | A branch carries unrelated commits |
83
+ | `canon:git-pr-close` | A pull request closes without merging |
83
84
  | `canon:git-issue` | Something belongs on the tracker |
84
85
  | `canon:git-worktree` | After a merge, to reclaim worktrees |
85
86
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.65.0",
4
+ "version": "5.66.0",
5
5
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -22,6 +22,10 @@ export const GIT_CASES: readonly SkillCase[] = [
22
22
  prompt: 'Turn this bug into a GitHub issue and file it.',
23
23
  expect: 'git-issue',
24
24
  },
25
+ {
26
+ prompt: 'Close #42 without merging, #57 supersedes it.',
27
+ expect: 'git-pr-close',
28
+ },
25
29
  {
26
30
  prompt: 'Write the title and body for this pull request.',
27
31
  expect: 'git-pr',
@@ -14,9 +14,10 @@ import {
14
14
  } from '@/labels/density'
15
15
  import { resolveScanInput } from '@/labels/event'
16
16
  import { checkTitleFormat, type TitleFormatIssue } from '@/labels/format'
17
- import { MAP_REL } from '@/labels/map'
17
+ import { MAP_REL, readLabelMap } from '@/labels/map'
18
18
  import { isReleasePullRequest, scanPhaseLabels } from '@/labels/phase'
19
19
  import { checkSections } from '@/labels/sections'
20
+ import { missingLabels } from '@/labels/sync'
20
21
  import { BAN_SETS } from '@/markdown/bans'
21
22
  import { bodyLines, scanBans } from '@/markdown/scan'
22
23
  import { intro, logInfo, logStep, logWarn, outro, plural } from '@/cli/ui'
@@ -33,6 +34,12 @@ interface ApplyOptions extends AuditOptions {
33
34
 
34
35
  type ApplyRefusal = 'bad-number' | 'no-branch' | 'gh-failed' | 'head-mismatch'
35
36
 
37
+ interface SyncOptions {
38
+ readonly root?: string
39
+ readonly check?: boolean
40
+ readonly json?: boolean
41
+ }
42
+
36
43
  const GH_TIMEOUT_MS = 30_000
37
44
 
38
45
  interface ScanOptions {
@@ -187,6 +194,48 @@ export function register(program: Command): void {
187
194
  process.exitCode = await runApply(number, opts)
188
195
  })
189
196
 
197
+ labels
198
+ .command('sync')
199
+ .description(
200
+ 'Create on the remote each label the map names that it lacks, never editing or deleting one',
201
+ )
202
+ .helpOption('-h, --help', 'Show this help message')
203
+ .option('--root <path>', 'Repository to read, defaulting to the cwd')
204
+ .option('--check', 'Report the labels missing and write nothing')
205
+ .option('--json', 'Add a machine-readable record on stdout')
206
+ .addHelpText(
207
+ 'after',
208
+ [
209
+ '',
210
+ `Reads the label names under [domains] in ${MAP_REL}, reads the remote's`,
211
+ 'labels over REST, and creates each one the remote lacks. A label already',
212
+ 'on the remote is left as it is, and one the map does not name is never',
213
+ 'touched. A project declaring no map creates nothing and exits 0.',
214
+ '',
215
+ 'Run it once after installing a map and again after adding a row, so',
216
+ '`canon labels apply` finds every label it earns.',
217
+ '',
218
+ 'Record: labels, present, created, missing, failed',
219
+ '',
220
+ 'Exit codes:',
221
+ ' 0 the remote carries every label the map names after the run',
222
+ ' 1 refused, with the reason on stderr or in the JSON record',
223
+ ' 2 labels are still missing, being any under --check, or a failed',
224
+ ' create otherwise',
225
+ '',
226
+ 'Reasons:',
227
+ ' gh-failed the remote labels could not be read, so nothing was written',
228
+ '',
229
+ 'Examples:',
230
+ ' canon labels sync',
231
+ ' canon labels sync --check --json',
232
+ '',
233
+ ].join('\n'),
234
+ )
235
+ .action(async (opts: SyncOptions) => {
236
+ process.exitCode = await runSync(opts)
237
+ })
238
+
190
239
  labels
191
240
  .command('scan')
192
241
  .description(
@@ -544,9 +593,7 @@ async function runApply(number: string, opts: ApplyOptions): Promise<number> {
544
593
  }
545
594
 
546
595
  for (const label of diff.missingOnRemote) {
547
- logWarn(
548
- `${label} is not on the remote. Create it with: gh label create ${label}`,
549
- )
596
+ logWarn(`${label} is not on the remote. Create it with: canon labels sync`)
550
597
  }
551
598
  for (const label of failed) logWarn(`Label apply failed: ${label}`)
552
599
 
@@ -577,6 +624,107 @@ async function runApply(number: string, opts: ApplyOptions): Promise<number> {
577
624
  return owed.length === 0 ? 0 : 2
578
625
  }
579
626
 
627
+ async function runSync(opts: SyncOptions): Promise<number> {
628
+ const root = resolve(opts.root ?? process.cwd())
629
+ const emitJson = opts.json ?? false
630
+ const isCheck = opts.check ?? false
631
+
632
+ intro('canon labels sync')
633
+
634
+ const map = readLabelMap(root)
635
+
636
+ if (map.kind === 'refused' && map.reason === 'no-map') {
637
+ logStep('Labels')
638
+ logInfo(REFUSALS['no-map'])
639
+ outro()
640
+ if (emitJson) {
641
+ process.stdout.write(
642
+ `${JSON.stringify({
643
+ reason: 'no-map',
644
+ labels: [],
645
+ present: [],
646
+ created: [],
647
+ missing: [],
648
+ failed: [],
649
+ })}\n`,
650
+ )
651
+ }
652
+ return 0
653
+ }
654
+
655
+ const refuse = (reason: LabelAuditRefusal | 'gh-failed'): number => {
656
+ const message =
657
+ reason === 'gh-failed'
658
+ ? 'gh could not read the remote labels, so nothing was written.'
659
+ : REFUSALS[reason]
660
+ logStep('Refused')
661
+ logWarn(message)
662
+ outro()
663
+ if (emitJson) {
664
+ process.stdout.write(`${JSON.stringify({ root, reason, message })}\n`)
665
+ }
666
+ return 1
667
+ }
668
+
669
+ if (map.kind === 'refused') return refuse(map.reason)
670
+
671
+ const remoteRaw = await gh(root, [
672
+ 'api',
673
+ '--paginate',
674
+ 'repos/{owner}/{repo}/labels',
675
+ '--jq',
676
+ '.[].name',
677
+ ])
678
+ if (remoteRaw === null) return refuse('gh-failed')
679
+
680
+ const labels = map.domains.map((row) => row.label)
681
+ const present = nonEmptyLines(remoteRaw)
682
+ const toCreate = missingLabels(labels, present)
683
+
684
+ const created: string[] = []
685
+ const failed: string[] = []
686
+
687
+ if (!isCheck) {
688
+ for (const label of toCreate) {
689
+ const wrote = await gh(root, [
690
+ 'api',
691
+ '-X',
692
+ 'POST',
693
+ 'repos/{owner}/{repo}/labels',
694
+ '-f',
695
+ `name=${label}`,
696
+ '--silent',
697
+ ])
698
+ if (wrote === null) failed.push(label)
699
+ else created.push(label)
700
+ }
701
+ }
702
+
703
+ const missing = toCreate.filter((label) => !created.includes(label))
704
+
705
+ logStep('Labels')
706
+ logInfo(labels.join(', '))
707
+
708
+ if (isCheck) {
709
+ logStep(missing.length === 0 ? 'Nothing missing' : 'Missing')
710
+ for (const label of missing) logWarn(label)
711
+ } else {
712
+ logStep('Created')
713
+ logInfo(created.length === 0 ? 'no label was created' : created.join(', '))
714
+ }
715
+ for (const label of failed) logWarn(`Label create failed: ${label}`)
716
+
717
+ outro()
718
+
719
+ if (emitJson) {
720
+ process.stdout.write(
721
+ `${JSON.stringify({ labels, present, created, missing, failed })}\n`,
722
+ )
723
+ }
724
+
725
+ return missing.length === 0 ? 0 : 2
726
+ }
727
+
580
728
  /**
581
729
  * Reads `--closes` as the issues the body must close. Undefined when the flag
582
730
  * is absent, which skips the check, and a message when the value is neither
@@ -74,6 +74,7 @@ import {
74
74
  readBaseStanding,
75
75
  readChangedPaths,
76
76
  } from '@/pr/lift'
77
+ import { close, type CloseApi, type CloseRefusal } from '@/pr/close'
77
78
  import {
78
79
  merge,
79
80
  type MergeApi,
@@ -92,6 +93,7 @@ import {
92
93
  } from '@/pr/drive-copy'
93
94
  import { machineListenerReader } from '@/listeners/machine'
94
95
  import { loadPaintedPatterns } from '@/pr/painted'
96
+ import { LOCAL_CONFIG_REL, readHealthPaths } from '@/pr/health'
95
97
  import {
96
98
  dropAddressLine,
97
99
  maskLocalAddress,
@@ -174,6 +176,12 @@ interface MergeCommandOptions extends ReadOptions {
174
176
  readonly dryRun?: boolean
175
177
  }
176
178
 
179
+ interface CloseCommandOptions extends ReadOptions {
180
+ readonly reasonFile?: string
181
+ readonly head?: string
182
+ readonly keepBranch?: boolean
183
+ }
184
+
177
185
  interface ReviewStateOptions extends ReadOptions {
178
186
  readonly wait?: string
179
187
  }
@@ -244,9 +252,15 @@ const AMBIGUOUS_PULL =
244
252
  /** How long the probe waits on a listener before reading it as not a server. */
245
253
  const LOCAL_PROBE_TIMEOUT_MS = 2_000
246
254
 
247
- const LOCAL_REFUSALS: Record<LocalRefusal | 'gh-failed', string> = {
255
+ const LOCAL_REFUSALS: Record<
256
+ LocalRefusal | 'invalid-config' | 'gh-failed',
257
+ string
258
+ > = {
248
259
  'no-server':
249
260
  'Nothing listening inside this worktree served an HTML page. Start the dev server here and re-run to get a link.',
261
+ unhealthy:
262
+ 'A page answered inside this worktree, but a health path it declares failed. Start what the page reaches and re-run to get a link.',
263
+ 'invalid-config': `${LOCAL_CONFIG_REL} cannot be read, so no health path was checked and no link is given. Fix or delete it and re-run.`,
250
264
  'no-listener-reader':
251
265
  'This machine offers neither lsof nor /proc, so no listening socket could be read. Nothing is posted.',
252
266
  'gh-failed':
@@ -991,6 +1005,60 @@ export function register(program: Command): void {
991
1005
  process.exitCode = await runMerge(number, opts)
992
1006
  })
993
1007
 
1008
+ pr.command('close')
1009
+ .description(
1010
+ 'Close a pull request unmerged, posting the reason first and deleting its head branch',
1011
+ )
1012
+ .argument('<number>', 'Pull request to close')
1013
+ .helpOption('-h, --help', 'Show this help message')
1014
+ .option('--reason-file <path>', 'Closing comment to post, required')
1015
+ .option(
1016
+ '--head <branch>',
1017
+ 'Head branch the pull request must carry, defaulting to the current branch',
1018
+ )
1019
+ .option('--keep-branch', 'Leave the head branch in place')
1020
+ .option('--root <path>', 'Repository to read, defaulting to the cwd')
1021
+ .option('--json', 'Add a machine-readable record on stdout')
1022
+ .addHelpText(
1023
+ 'after',
1024
+ [
1025
+ '',
1026
+ 'The reason is required, so a pull request this verb closes always says',
1027
+ 'why. It is checked before anything is read: banned characters, phase',
1028
+ 'labels, board references, session links, and density measured on the',
1029
+ 'reason itself. A finding refuses reason-unpublishable with the findings',
1030
+ 'in the record.',
1031
+ '',
1032
+ 'Then the pull request is read and must be open with the head --head',
1033
+ 'names. The comment posts first, then the close, then the branch',
1034
+ 'delete, so a failure part way leaves a comment on an open pull request',
1035
+ 'rather than a close with no reason. A failed delete never fails the',
1036
+ 'close, and a head in another repository is never deleted.',
1037
+ '',
1038
+ 'Record: number, commentUrl, branch (deleted, kept, fork, delete-failed),',
1039
+ 'findings on a reason-unpublishable refusal',
1040
+ '',
1041
+ 'Reasons:',
1042
+ ' closed',
1043
+ ' no-reason, reason-unreadable, reason-unpublishable, no-head,',
1044
+ ' gh-failed, pull-closed, head-mismatch, comment-failed, close-failed',
1045
+ '',
1046
+ 'Every call is REST, never `gh pr close`, which runs on GraphQL.',
1047
+ '',
1048
+ 'Exit codes:',
1049
+ ' 0 closed',
1050
+ ' 1 refused, with the reason in the JSON record',
1051
+ '',
1052
+ 'Examples:',
1053
+ ' canon pr close 1341 --reason-file reason.md --json',
1054
+ ' canon pr close 1341 --reason-file reason.md --head feat/old --keep-branch',
1055
+ '',
1056
+ ].join('\n'),
1057
+ )
1058
+ .action(async (number: string, opts: CloseCommandOptions) => {
1059
+ process.exitCode = await runClose(number, opts)
1060
+ })
1061
+
994
1062
  pr.command('preview')
995
1063
  .description(
996
1064
  "Deploy the pull request's branch to a Cloudflare Pages preview and report its address",
@@ -1090,6 +1158,13 @@ export function register(program: Command): void {
1090
1158
  'port serving an HTML page is reported, which skips a test runner or a',
1091
1159
  'reload socket answering in the same tree.',
1092
1160
  '',
1161
+ 'A project can declare paths the page must also serve, in',
1162
+ 'canon/config/local.toml as health = ["/api/health"]. Each is requested',
1163
+ "on the page's own port, redirects followed, and only a final 2xx",
1164
+ 'passes. A page failing one is skipped for the next page port, and the',
1165
+ "refusal names the lowest page port's first failing path. An absent",
1166
+ 'file or an empty list checks the page alone.',
1167
+ '',
1093
1168
  'A process in a sibling worktree, in the main checkout seen from a',
1094
1169
  'linked worktree, or in a linked worktree seen from the main checkout',
1095
1170
  'is never reported, since its server shows another branch. Sockets are',
@@ -1112,6 +1187,11 @@ export function register(program: Command): void {
1112
1187
  ' ok a page answered, with its address in `url`',
1113
1188
  ' no-server nothing inside this worktree served a page',
1114
1189
  ' no-listener-reader neither lsof nor /proc is available',
1190
+ ' unhealthy a page answered but a declared health path did',
1191
+ ' not, with `port`, `path`, and `status`, which',
1192
+ ' is null on a timeout',
1193
+ ' invalid-config canon/config/local.toml is not valid TOML, or',
1194
+ ' health is not a list of paths starting with /',
1115
1195
  ' removed --remove dropped the line',
1116
1196
  ' no-comment --remove found no marked comment, a no-op',
1117
1197
  ' no-line --remove found no address line, a no-op',
@@ -3005,6 +3085,19 @@ function machineLocalRunner(): LocalRunner {
3005
3085
  return false
3006
3086
  }
3007
3087
  },
3088
+ // The final status after redirects, so a login bounce ending on a 3xx or
3089
+ // a 4xx fails rather than reading as the backend answering.
3090
+ async check(port, path) {
3091
+ try {
3092
+ const response = await fetch(`http://localhost:${port}${path}`, {
3093
+ signal: AbortSignal.timeout(LOCAL_PROBE_TIMEOUT_MS),
3094
+ })
3095
+ await response.body?.cancel()
3096
+ return response.status
3097
+ } catch {
3098
+ return null
3099
+ }
3100
+ },
3008
3101
  }
3009
3102
  }
3010
3103
 
@@ -3025,7 +3118,35 @@ async function runLocal(opts: LocalOptions): Promise<number> {
3025
3118
  intro('canon pr local')
3026
3119
 
3027
3120
  const worktree = await resolveWorktreeRoot(root)
3028
- const result = await findLocalServer(worktree, machineLocalRunner())
3121
+ const declaration = readHealthPaths(worktree)
3122
+ if (declaration.kind === 'invalid') {
3123
+ return refuseWith(
3124
+ 'invalid-config',
3125
+ `${declaration.reason}. ${LOCAL_REFUSALS['invalid-config']}`,
3126
+ emitJson,
3127
+ root,
3128
+ )
3129
+ }
3130
+
3131
+ const result = await findLocalServer(
3132
+ worktree,
3133
+ machineLocalRunner(),
3134
+ declaration.kind === 'paths' ? declaration.paths : [],
3135
+ )
3136
+ if (result.kind === 'refused' && result.reason === 'unhealthy') {
3137
+ const { port, path, status } = result
3138
+ const answer =
3139
+ status === null
3140
+ ? `gave no answer within ${LOCAL_PROBE_TIMEOUT_MS / 1000} seconds`
3141
+ : `answered ${status}`
3142
+ return refuseWith(
3143
+ 'unhealthy',
3144
+ `${LOCAL_REFUSALS.unhealthy} ${path} on http://localhost:${port} ${answer}.`,
3145
+ emitJson,
3146
+ root,
3147
+ { port, path, status },
3148
+ )
3149
+ }
3029
3150
  if (result.kind === 'refused') {
3030
3151
  return refuseWith(
3031
3152
  result.reason,
@@ -4394,6 +4515,146 @@ async function runDriveCopy(
4394
4515
  return 0
4395
4516
  }
4396
4517
 
4518
+ type CloseCommandRefusal = 'reason-unreadable' | 'no-head'
4519
+
4520
+ const CLOSE_REFUSALS: Record<CloseRefusal | CloseCommandRefusal, string> = {
4521
+ 'no-reason':
4522
+ 'No closing reason was given. Write it to a file and pass --reason-file.',
4523
+ 'reason-unreadable': 'The --reason-file could not be read.',
4524
+ 'reason-unpublishable':
4525
+ 'The reason carries text a reader on the remote cannot use or that breaks the density limits. The findings are in the record.',
4526
+ 'no-head':
4527
+ 'This checkout is on no branch, so pass --head with the branch the pull request carries.',
4528
+ 'gh-failed': 'gh could not read the pull request, so nothing was written.',
4529
+ 'pull-closed': 'The pull request is already closed.',
4530
+ 'head-mismatch':
4531
+ 'The pull request carries another head branch than the one expected. Nothing was written to it.',
4532
+ 'comment-failed':
4533
+ 'The closing comment did not post, so the pull request was left open.',
4534
+ 'close-failed':
4535
+ 'The comment posted but the close was refused, so the pull request is still open. Close it by hand or re-run.',
4536
+ }
4537
+
4538
+ function closeApi(root: string, number: string): CloseApi {
4539
+ return {
4540
+ readPull: async () => {
4541
+ const stdout = await gh(root, [
4542
+ 'api',
4543
+ `repos/{owner}/{repo}/pulls/${number}`,
4544
+ ])
4545
+ if (stdout === null) return undefined
4546
+ try {
4547
+ const row = JSON.parse(stdout) as RestPull
4548
+ if (typeof row.head?.ref !== 'string') return undefined
4549
+ const headRepo = row.head.repo?.full_name
4550
+ return {
4551
+ isOpen: row.state === 'open',
4552
+ branch: row.head.ref,
4553
+ isFork:
4554
+ headRepo === undefined || headRepo !== row.base?.repo?.full_name,
4555
+ }
4556
+ } catch {
4557
+ return undefined
4558
+ }
4559
+ },
4560
+ postComment: async (body) => {
4561
+ const url = await gh(root, [
4562
+ 'api',
4563
+ '-X',
4564
+ 'POST',
4565
+ `repos/{owner}/{repo}/issues/${number}/comments`,
4566
+ '-f',
4567
+ `body=${body}`,
4568
+ '--jq',
4569
+ '.html_url',
4570
+ ])
4571
+ return url === null || url.trim() === '' ? undefined : url.trim()
4572
+ },
4573
+ patchClosed: async () =>
4574
+ (await gh(root, [
4575
+ 'api',
4576
+ '-X',
4577
+ 'PATCH',
4578
+ `repos/{owner}/{repo}/pulls/${number}`,
4579
+ '-f',
4580
+ 'state=closed',
4581
+ '--silent',
4582
+ ])) !== null,
4583
+ deleteBranch: async (branch) =>
4584
+ (await gh(root, [
4585
+ 'api',
4586
+ '-X',
4587
+ 'DELETE',
4588
+ `repos/{owner}/{repo}/git/refs/heads/${encodeURIComponent(branch)}`,
4589
+ '--silent',
4590
+ ])) !== null,
4591
+ }
4592
+ }
4593
+
4594
+ async function runClose(
4595
+ number: string,
4596
+ opts: CloseCommandOptions,
4597
+ ): Promise<number> {
4598
+ const root = resolve(opts.root ?? process.cwd())
4599
+ const emitJson = opts.json ?? false
4600
+
4601
+ intro('canon pr close')
4602
+
4603
+ const refuseClose = (
4604
+ reason: CloseRefusal | CloseCommandRefusal,
4605
+ extra: Record<string, unknown> = {},
4606
+ ): number =>
4607
+ refuseWith(reason, CLOSE_REFUSALS[reason], emitJson, root, {
4608
+ number: Number(number),
4609
+ ...extra,
4610
+ })
4611
+
4612
+ if (opts.reasonFile === undefined) return refuseClose('no-reason')
4613
+ let reason: string
4614
+ try {
4615
+ reason = await readFile(resolve(root, opts.reasonFile), 'utf8')
4616
+ } catch {
4617
+ logWarn(`Could not read ${opts.reasonFile}`)
4618
+ return refuseClose('reason-unreadable')
4619
+ }
4620
+
4621
+ let head = opts.head
4622
+ if (head === undefined) {
4623
+ const branch = await $`git -C ${root} symbolic-ref --short -q HEAD`
4624
+ .env(gitEnv())
4625
+ .quiet()
4626
+ .nothrow()
4627
+ head = branch.exitCode === 0 ? branch.text().trim() : ''
4628
+ if (head === '') return refuseClose('no-head')
4629
+ }
4630
+
4631
+ const record = await close(closeApi(root, number), {
4632
+ number: Number(number),
4633
+ reason,
4634
+ head,
4635
+ isKeepBranch: opts.keepBranch ?? false,
4636
+ })
4637
+
4638
+ if (!record.isOk) {
4639
+ return refuseClose(record.reason as CloseRefusal, {
4640
+ ...(record.commentUrl !== undefined && { commentUrl: record.commentUrl }),
4641
+ ...(record.findings !== undefined && { findings: record.findings }),
4642
+ })
4643
+ }
4644
+
4645
+ logStep('Closed')
4646
+ logInfo(`#${record.number} closed, branch ${record.branch}`)
4647
+ if (record.branch === 'delete-failed') {
4648
+ logWarn(`The branch ${head} could not be deleted. Delete it by hand.`)
4649
+ }
4650
+ outro()
4651
+
4652
+ if (emitJson) {
4653
+ process.stdout.write(`${JSON.stringify({ root, ...record })}\n`)
4654
+ }
4655
+ return 0
4656
+ }
4657
+
4397
4658
  function refuseWith(
4398
4659
  reason: string,
4399
4660
  message: string,
@@ -20,20 +20,25 @@ export interface LabelDiff {
20
20
  * A label the remote lacks is separated from the addable set rather than
21
21
  * dropped, because the REST add endpoint creates any label it is handed and
22
22
  * that write to the repository settings is not the map's call.
23
+ *
24
+ * Names match regardless of case, as GitHub holds them, so this agrees with
25
+ * `missingLabels` on which labels the remote carries.
23
26
  */
24
27
  export function diffLabels(
25
28
  earned: readonly string[],
26
29
  present: readonly string[],
27
30
  remote: readonly string[],
28
31
  ): LabelDiff {
29
- const has = new Set(present)
30
- const carried = new Set(remote)
32
+ const fold = (labels: readonly string[]): Set<string> =>
33
+ new Set(labels.map((label) => label.toLowerCase()))
34
+ const has = fold(present)
35
+ const carried = fold(remote)
31
36
 
32
- const owed = earned.filter((label) => !has.has(label))
37
+ const owed = earned.filter((label) => !has.has(label.toLowerCase()))
33
38
 
34
39
  return {
35
40
  owed,
36
- toAdd: owed.filter((label) => carried.has(label)),
37
- missingOnRemote: owed.filter((label) => !carried.has(label)),
41
+ toAdd: owed.filter((label) => carried.has(label.toLowerCase())),
42
+ missingOnRemote: owed.filter((label) => !carried.has(label.toLowerCase())),
38
43
  }
39
44
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The labels a map names that the remote does not carry yet, in map order.
3
+ *
4
+ * Only ever a subset of the map, so a label a person made by hand never
5
+ * appears and nothing here can become an edit or a deletion on the remote.
6
+ *
7
+ * GitHub holds label names unique regardless of case, so a remote `Docs`
8
+ * counts as carrying `docs`. Creating it would be refused, and every later
9
+ * run would report the same failure.
10
+ */
11
+ export function missingLabels(
12
+ mapLabels: readonly string[],
13
+ remote: readonly string[],
14
+ ): string[] {
15
+ const carried = new Set(remote.map((label) => label.toLowerCase()))
16
+ return mapLabels.filter((label) => !carried.has(label.toLowerCase()))
17
+ }
@@ -0,0 +1,152 @@
1
+ import { scanPhaseLabels } from '@/labels/phase'
2
+ import { BAN_SETS } from '@/markdown/bans'
3
+ import { type DensityFinding, densityFindings } from '@/markdown/density'
4
+ import { bodyLines, scanBans } from '@/markdown/scan'
5
+
6
+ /** Why the verb did not close, in the order the refusals are read. */
7
+ export type CloseRefusal =
8
+ | 'no-reason'
9
+ | 'reason-unpublishable'
10
+ | 'gh-failed'
11
+ | 'pull-closed'
12
+ | 'head-mismatch'
13
+ | 'comment-failed'
14
+ | 'close-failed'
15
+
16
+ export interface ClosePull {
17
+ readonly isOpen: boolean
18
+ /** The head branch name, which the caller's expected head is checked against. */
19
+ readonly branch: string
20
+ /** Whether the head lives in another repository, whose branch this one cannot delete. */
21
+ readonly isFork: boolean
22
+ }
23
+
24
+ /**
25
+ * Everything the verb reads or writes, injected so every refusal runs without
26
+ * a network. A read or write that failed returns undefined or false, never a
27
+ * guess.
28
+ */
29
+ export interface CloseApi {
30
+ readPull(): Promise<ClosePull | undefined>
31
+ /** Posts the reason and answers the comment's URL. */
32
+ postComment(body: string): Promise<string | undefined>
33
+ patchClosed(): Promise<boolean>
34
+ deleteBranch(branch: string): Promise<boolean>
35
+ }
36
+
37
+ export interface CloseOptions {
38
+ readonly number: number
39
+ readonly reason: string
40
+ /** The head branch the caller means, so a mistyped number closes nothing. */
41
+ readonly head: string
42
+ readonly isKeepBranch: boolean
43
+ }
44
+
45
+ /** What the reason carries that a reader on the remote cannot use. */
46
+ export interface ReasonFindings {
47
+ readonly isClean: boolean
48
+ readonly bannedCharacters: readonly string[]
49
+ readonly phaseLabels: readonly string[]
50
+ readonly boardReferences: readonly string[]
51
+ readonly sessionLinks: readonly string[]
52
+ readonly density: readonly DensityFinding[]
53
+ }
54
+
55
+ /** What happened to the head branch once the pull request closed. */
56
+ export type BranchOutcome = 'deleted' | 'kept' | 'fork' | 'delete-failed'
57
+
58
+ export interface CloseRecord {
59
+ readonly reason: 'closed' | CloseRefusal
60
+ readonly isOk: boolean
61
+ readonly number: number
62
+ readonly commentUrl?: string
63
+ readonly branch?: BranchOutcome
64
+ readonly findings?: ReasonFindings
65
+ }
66
+
67
+ /**
68
+ * Runs the checks `canon labels scan` runs on a pull request body, plus
69
+ * density measured on the reason itself.
70
+ *
71
+ * The scan's own post density reads a body only when it opens on a known
72
+ * post heading, and a closing comment carries none, so an overloaded reason
73
+ * would pass it unmeasured.
74
+ */
75
+ export function checkReason(reason: string): ReasonFindings {
76
+ const lines = bodyLines(reason)
77
+ const bannedCharacters = scanBans(lines, BAN_SETS).map(
78
+ (finding) => finding.term,
79
+ )
80
+ const { phaseLabels, boardReferences, sessionLinks } = scanPhaseLabels({
81
+ title: '',
82
+ body: reason,
83
+ headRefName: '',
84
+ })
85
+ const density = densityFindings(lines)
86
+
87
+ return {
88
+ isClean:
89
+ bannedCharacters.length === 0 &&
90
+ phaseLabels.length === 0 &&
91
+ boardReferences.length === 0 &&
92
+ sessionLinks.length === 0 &&
93
+ density.length === 0,
94
+ bannedCharacters,
95
+ phaseLabels,
96
+ boardReferences,
97
+ sessionLinks,
98
+ density,
99
+ }
100
+ }
101
+
102
+ function refused(
103
+ reason: CloseRefusal,
104
+ number: number,
105
+ extra: Partial<CloseRecord> = {},
106
+ ): CloseRecord {
107
+ return { reason, isOk: false, number, ...extra }
108
+ }
109
+
110
+ /**
111
+ * Closes one pull request unmerged with its reason posted first.
112
+ *
113
+ * The comment lands before the close, so a failure between the two leaves a
114
+ * comment on an open pull request rather than a closed one carrying no
115
+ * reason, which is the defect this verb exists to remove. The branch delete
116
+ * runs last and never fails the close, since the pull request is already
117
+ * closed with its reason by then.
118
+ */
119
+ export async function close(
120
+ api: CloseApi,
121
+ opts: CloseOptions,
122
+ ): Promise<CloseRecord> {
123
+ const { number } = opts
124
+ const reason = opts.reason.trim()
125
+
126
+ if (reason === '') return refused('no-reason', number)
127
+ const findings = checkReason(reason)
128
+ if (!findings.isClean) {
129
+ return refused('reason-unpublishable', number, { findings })
130
+ }
131
+
132
+ const pull = await api.readPull()
133
+ if (pull === undefined) return refused('gh-failed', number)
134
+ if (!pull.isOpen) return refused('pull-closed', number)
135
+ if (pull.branch !== opts.head) return refused('head-mismatch', number)
136
+
137
+ const commentUrl = await api.postComment(reason)
138
+ if (commentUrl === undefined) return refused('comment-failed', number)
139
+
140
+ if (!(await api.patchClosed())) {
141
+ return refused('close-failed', number, { commentUrl })
142
+ }
143
+
144
+ let branch: BranchOutcome
145
+ if (opts.isKeepBranch) branch = 'kept'
146
+ else if (pull.isFork) branch = 'fork'
147
+ else {
148
+ branch = (await api.deleteBranch(pull.branch)) ? 'deleted' : 'delete-failed'
149
+ }
150
+
151
+ return { reason: 'closed', isOk: true, number, commentUrl, branch }
152
+ }
@@ -0,0 +1,56 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ /** Where a project declares what `canon pr local` checks beyond the page. */
5
+ export const LOCAL_CONFIG_REL = join('canon', 'config', 'local.toml')
6
+
7
+ /**
8
+ * The health paths a project declared. `none` is an absent file or an empty
9
+ * `health = []`. `invalid` is a declaration that exists and cannot be used,
10
+ * which refuses rather than falling back to the page-only probe, since that
11
+ * fallback is the failure the declaration exists to catch.
12
+ */
13
+ export type HealthDeclaration =
14
+ | { readonly kind: 'paths'; readonly paths: readonly string[] }
15
+ | { readonly kind: 'none' }
16
+ | { readonly kind: 'invalid'; readonly reason: string }
17
+
18
+ /**
19
+ * A path on the page's own host. A second leading slash would read as a
20
+ * protocol-relative host, so it is refused to keep the probe on localhost.
21
+ */
22
+ function isLocalPath(entry: unknown): entry is string {
23
+ return (
24
+ typeof entry === 'string' &&
25
+ entry.startsWith('/') &&
26
+ !entry.startsWith('//') &&
27
+ !entry.startsWith('/\\')
28
+ )
29
+ }
30
+
31
+ export function readHealthPaths(root: string): HealthDeclaration {
32
+ const path = join(root, LOCAL_CONFIG_REL)
33
+ if (!existsSync(path)) return { kind: 'none' }
34
+
35
+ let parsed: Record<string, unknown>
36
+ try {
37
+ parsed = Bun.TOML.parse(readFileSync(path, 'utf8')) as Record<
38
+ string,
39
+ unknown
40
+ >
41
+ } catch {
42
+ return { kind: 'invalid', reason: `${LOCAL_CONFIG_REL} is not valid TOML` }
43
+ }
44
+
45
+ const declared = parsed.health
46
+ if (!Array.isArray(declared) || !declared.every(isLocalPath)) {
47
+ return {
48
+ kind: 'invalid',
49
+ reason: `${LOCAL_CONFIG_REL} needs health as a list of paths, each starting with a single /`,
50
+ }
51
+ }
52
+
53
+ return declared.length === 0
54
+ ? { kind: 'none' }
55
+ : { kind: 'paths', paths: declared }
56
+ }
package/src/pr/local.ts CHANGED
@@ -2,27 +2,62 @@ import { belongsTo } from '@/listeners/belongs'
2
2
  import type { ListenerReader } from '@/listeners/machine'
3
3
  import { readAddresses } from '@/pr/evidence'
4
4
 
5
- export type LocalRefusal = 'no-server' | 'no-listener-reader'
5
+ export type LocalRefusal = 'no-server' | 'no-listener-reader' | 'unhealthy'
6
6
 
7
- /** The listener reads a detection makes plus an HTML probe, injected so it can be driven without binding a socket. */
7
+ /** The listener reads a detection makes plus two HTTP probes, injected so it can be driven without binding a socket. */
8
8
  export interface LocalRunner extends ListenerReader {
9
9
  /** Whether the port serves an HTML page at its root inside the runner's timeout. */
10
10
  probe(port: number): Promise<boolean>
11
+ /** The final status a path on the port answers after redirects, or null when nothing answers inside the timeout. */
12
+ check(port: number, path: string): Promise<number | null>
11
13
  }
12
14
 
13
15
  export type LocalResult =
14
16
  | { readonly kind: 'found'; readonly port: number; readonly url: string }
15
- | { readonly kind: 'refused'; readonly reason: LocalRefusal }
17
+ | {
18
+ readonly kind: 'refused'
19
+ readonly reason: Exclude<LocalRefusal, 'unhealthy'>
20
+ }
21
+ | {
22
+ readonly kind: 'refused'
23
+ readonly reason: 'unhealthy'
24
+ readonly port: number
25
+ readonly path: string
26
+ readonly status: number | null
27
+ }
28
+
29
+ interface HealthFailure {
30
+ readonly path: string
31
+ readonly status: number | null
32
+ }
33
+
34
+ async function firstFailingPath(
35
+ port: number,
36
+ healthPaths: readonly string[],
37
+ runner: LocalRunner,
38
+ ): Promise<HealthFailure | undefined> {
39
+ for (const path of healthPaths) {
40
+ const status = await runner.check(port, path)
41
+ if (status === null || status < 200 || status >= 300) {
42
+ return { path, status }
43
+ }
44
+ }
45
+ return undefined
46
+ }
16
47
 
17
48
  /**
18
49
  * Finds the server this worktree is running: the lowest port whose listening
19
- * process runs inside the worktree and answers an HTTP probe. A port rather
20
- * than a process is the unit, since one dev server often holds a second
21
- * socket for its reload channel, and the lower one is the page by convention.
50
+ * process runs inside the worktree, answers an HTTP probe, and passes every
51
+ * declared health path. A port rather than a process is the unit, since one
52
+ * dev server often holds a second socket for its reload channel, and the
53
+ * lower one is the page by convention. Health is read on the page's own port,
54
+ * since that is the address a reviewer drives, and a page whose backend fails
55
+ * refuses as `unhealthy` naming the lowest page port's first failing path.
22
56
  */
23
57
  export async function findLocalServer(
24
58
  worktreeRoot: string,
25
59
  runner: LocalRunner,
60
+ healthPaths: readonly string[] = [],
26
61
  ): Promise<LocalResult> {
27
62
  const listeners = await runner.listListeners()
28
63
  if (listeners === undefined) {
@@ -37,12 +72,18 @@ export async function findLocalServer(
37
72
  if (cwd !== undefined && belongsTo(cwd, worktreeRoot)) ports.add(port)
38
73
  }
39
74
 
75
+ let unhealthy: (HealthFailure & { readonly port: number }) | undefined
40
76
  for (const port of [...ports].sort((a, b) => a - b)) {
41
- if (await runner.probe(port)) {
77
+ if (!(await runner.probe(port))) continue
78
+ const failure = await firstFailingPath(port, healthPaths, runner)
79
+ if (failure === undefined) {
42
80
  return { kind: 'found', port, url: `http://localhost:${port}` }
43
81
  }
82
+ unhealthy ??= { port, ...failure }
44
83
  }
45
- return { kind: 'refused', reason: 'no-server' }
84
+ return unhealthy === undefined
85
+ ? { kind: 'refused', reason: 'no-server' }
86
+ : { kind: 'refused', reason: 'unhealthy', ...unhealthy }
46
87
  }
47
88
 
48
89
  /**
package/src/pr/rest.ts CHANGED
@@ -15,8 +15,16 @@ export interface RestPull {
15
15
  readonly number?: number
16
16
  readonly title?: string
17
17
  readonly body?: string | null
18
- readonly head?: { readonly ref?: string; readonly sha?: string }
19
- readonly base?: { readonly ref?: string }
18
+ readonly head?: {
19
+ readonly ref?: string
20
+ readonly sha?: string
21
+ /** Null when the fork the head lived in was deleted. */
22
+ readonly repo?: { readonly full_name?: string } | null
23
+ }
24
+ readonly base?: {
25
+ readonly ref?: string
26
+ readonly repo?: { readonly full_name?: string }
27
+ }
20
28
  readonly mergeable_state?: string
21
29
  readonly state?: string
22
30
  readonly draft?: boolean
@@ -81,6 +81,35 @@ function isCopied(rel: string): boolean {
81
81
  return rel.split('/').at(-1) !== 'node_modules' && rel !== '.claude/worktrees'
82
82
  }
83
83
 
84
+ /**
85
+ * Git's background maintenance creates and removes files such as
86
+ * `.git/objects/maintenance.lock` while a copy walks the checkout, and a file
87
+ * listed then gone throws ENOENT. A short bound covers every transient name
88
+ * git uses, where a filter on `*.lock` would cover one.
89
+ */
90
+ const COPY_ATTEMPTS = 3
91
+
92
+ export function copyCheckout(
93
+ source: ReplaySource,
94
+ tree: string,
95
+ copy: typeof cpSync = cpSync,
96
+ ): void {
97
+ for (let attempt = 1; ; attempt++) {
98
+ try {
99
+ copy(source.path, tree, {
100
+ recursive: true,
101
+ verbatimSymlinks: true,
102
+ filter: (from) => isCopied(relative(source.path, from)),
103
+ })
104
+ return
105
+ } catch (error) {
106
+ const vanished = (error as NodeJS.ErrnoException).code === 'ENOENT'
107
+ if (!vanished || attempt === COPY_ATTEMPTS) throw error
108
+ rmSync(tree, { recursive: true, force: true })
109
+ }
110
+ }
111
+ }
112
+
84
113
  /**
85
114
  * Copies a source into `tree`. A snapshot's files lose their `.fixture`
86
115
  * suffix and its recorded expectation stays behind, and a checkout copies whole rather than cloning, since a clone
@@ -88,11 +117,7 @@ function isCopied(rel: string): boolean {
88
117
  */
89
118
  export function copySource(source: ReplaySource, tree: string): void {
90
119
  if (source.kind === 'checkout') {
91
- cpSync(source.path, tree, {
92
- recursive: true,
93
- verbatimSymlinks: true,
94
- filter: (from) => isCopied(relative(source.path, from)),
95
- })
120
+ copyCheckout(source, tree)
96
121
  return
97
122
  }
98
123
 
@@ -95,19 +95,18 @@ async function run({
95
95
  logStep(`Scaffolding ${stack}`)
96
96
  // Manifests carry shell syntax (`&&`, `cd`, quoted globs), so the command
97
97
  // runs under a shell rather than as a split argv.
98
- const scaffold = await shell(
99
- manifest.scaffold.replaceAll('{{name}}', name),
100
- runsDir,
101
- )
98
+ const scaffoldCommand = manifest.scaffold.replaceAll('{{name}}', name)
99
+ const scaffold = await shell(scaffoldCommand, runsDir)
102
100
  if (scaffold.failed) {
103
101
  logWarn('Scaffold failed')
102
+ process.stderr.write(`$ ${scaffoldCommand}\n`)
104
103
  writeTail(scaffold.all)
105
- state.isFailed = true
106
104
  }
107
105
 
108
106
  if (!existsSync(state.dir)) {
109
107
  return refuse(`Scaffold did not produce ${state.dir}`)
110
108
  }
109
+ record(state, 'Scaffold', !scaffold.failed)
111
110
 
112
111
  await shell(
113
112
  'git init -q && git add . && git commit -m "chore: verify scaffold" -q --no-verify --allow-empty',
@@ -39,6 +39,7 @@ Seeds live in `tooling/base/seeds/`. Sync drops each once on first install and n
39
39
  - `.github/workflows/verify.yml` runs on pull requests targeting `main` and on `workflow_dispatch`. A multi-root project adds its own jobs here.
40
40
  - `.github/pull_request_template.md` has `## Summary`, `## Key changes`, `## Technical context`, and `## Testing`. A project adds its own checklist lines.
41
41
  - `canon/context/development/` holds the `setup` and `ci` entries under a folder index. Extend them with project-specific commands and workflows, and keep canonical rationale here.
42
+ - `canon/config/pr-labels.toml` is the starter pull request label map, covering what the toolkit installs. A project adds a row per surface of its own.
42
43
 
43
44
  ## Tool pairing
44
45
 
@@ -0,0 +1,41 @@
1
+ # Pull request domain labels.
2
+ #
3
+ # `git-pr` matches the paths a branch changed against the prefixes below and
4
+ # applies every label that matches. The rows here cover what the toolkit
5
+ # installs. Add a row per surface of your own, such as `app = ["src/"]`, and
6
+ # `canon labels audit` names any changed path no row reaches.
7
+ #
8
+ # Each label has to exist on the remote before a pull request can carry it.
9
+ # Run `canon labels sync` once to create the ones the remote lacks, and again
10
+ # after adding a row.
11
+ #
12
+ # The matching rule and the `[declined]` table are described in the `git-pr`
13
+ # skill's labels reference.
14
+
15
+ [domains]
16
+ tooling = [
17
+ ".github/",
18
+ ".husky/",
19
+ "scripts/",
20
+ ".gitignore",
21
+ ".lintstagedrc",
22
+ ".oxfmtrc.json",
23
+ ".prettierignore",
24
+ "commitlint.config.js",
25
+ "package.json",
26
+ "bun.lock",
27
+ "canon/config/",
28
+ ]
29
+ claude = [".claude/", "CLAUDE.md"]
30
+ docs = [
31
+ "README.md",
32
+ "docs/",
33
+ "canon/REQUIREMENTS.md",
34
+ "canon/DESIGN.md",
35
+ "canon/context/",
36
+ ]
37
+
38
+ # Paths deliberately left unlabelled, keyed by the reason.
39
+ [declined]
40
+ # Release automation opens its own pull request and applies its own label.
41
+ release-managed = ["CHANGELOG.md", ".release-please-manifest.json"]