@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 +15 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/git-pr/references/labels.md +6 -2
- package/claude/skills/git-pr-close/REQUIREMENT.md +29 -0
- package/claude/skills/git-pr-close/SKILL.md +55 -0
- package/claude/skills/git-pr-close/references/close.md +54 -0
- package/claude/skills/pr-evidence/SKILL.md +2 -2
- package/claude/skills/pr-evidence/references/evidence.md +3 -1
- package/claude/skills/role-worker/SKILL.md +2 -2
- package/docs/workflow/skill-map.md +1 -0
- package/package.json +1 -1
- package/src/claude/cases/git.ts +4 -0
- package/src/commands/labels.ts +152 -4
- package/src/commands/pr.ts +263 -2
- package/src/labels/apply.ts +10 -5
- package/src/labels/sync.ts +17 -0
- package/src/pr/close.ts +152 -0
- package/src/pr/health.ts +56 -0
- package/src/pr/local.ts +49 -8
- package/src/pr/rest.ts +10 -2
- package/src/sandbox/replay/run.ts +30 -5
- package/src/tooling/verify-stack.ts +4 -5
- package/tooling/base/reference.md +1 -0
- package/tooling/base/seeds/canon/config/pr-labels.toml +41 -0
package/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
|
|
|
@@ -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
|
-
|
|
112
|
+
canon labels sync
|
|
113
113
|
```
|
|
114
114
|
|
|
115
|
-
|
|
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
|
|
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
|
|
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
|
|
50
|
-
- Otherwise start the stack's `dev`
|
|
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
package/src/claude/cases/git.ts
CHANGED
|
@@ -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',
|
package/src/commands/labels.ts
CHANGED
|
@@ -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
|
package/src/commands/pr.ts
CHANGED
|
@@ -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<
|
|
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
|
|
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,
|
package/src/labels/apply.ts
CHANGED
|
@@ -20,20 +20,25 @@ export interface LabelDiff {
|
|
|
20
20
|
* A label the remote lacks is separated from the addable set rather than
|
|
21
21
|
* dropped, because the REST add endpoint creates any label it is handed and
|
|
22
22
|
* that write to the repository settings is not the map's call.
|
|
23
|
+
*
|
|
24
|
+
* Names match regardless of case, as GitHub holds them, so this agrees with
|
|
25
|
+
* `missingLabels` on which labels the remote carries.
|
|
23
26
|
*/
|
|
24
27
|
export function diffLabels(
|
|
25
28
|
earned: readonly string[],
|
|
26
29
|
present: readonly string[],
|
|
27
30
|
remote: readonly string[],
|
|
28
31
|
): LabelDiff {
|
|
29
|
-
const
|
|
30
|
-
|
|
32
|
+
const fold = (labels: readonly string[]): Set<string> =>
|
|
33
|
+
new Set(labels.map((label) => label.toLowerCase()))
|
|
34
|
+
const has = fold(present)
|
|
35
|
+
const carried = fold(remote)
|
|
31
36
|
|
|
32
|
-
const owed = earned.filter((label) => !has.has(label))
|
|
37
|
+
const owed = earned.filter((label) => !has.has(label.toLowerCase()))
|
|
33
38
|
|
|
34
39
|
return {
|
|
35
40
|
owed,
|
|
36
|
-
toAdd: owed.filter((label) => carried.has(label)),
|
|
37
|
-
missingOnRemote: owed.filter((label) => !carried.has(label)),
|
|
41
|
+
toAdd: owed.filter((label) => carried.has(label.toLowerCase())),
|
|
42
|
+
missingOnRemote: owed.filter((label) => !carried.has(label.toLowerCase())),
|
|
38
43
|
}
|
|
39
44
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The labels a map names that the remote does not carry yet, in map order.
|
|
3
|
+
*
|
|
4
|
+
* Only ever a subset of the map, so a label a person made by hand never
|
|
5
|
+
* appears and nothing here can become an edit or a deletion on the remote.
|
|
6
|
+
*
|
|
7
|
+
* GitHub holds label names unique regardless of case, so a remote `Docs`
|
|
8
|
+
* counts as carrying `docs`. Creating it would be refused, and every later
|
|
9
|
+
* run would report the same failure.
|
|
10
|
+
*/
|
|
11
|
+
export function missingLabels(
|
|
12
|
+
mapLabels: readonly string[],
|
|
13
|
+
remote: readonly string[],
|
|
14
|
+
): string[] {
|
|
15
|
+
const carried = new Set(remote.map((label) => label.toLowerCase()))
|
|
16
|
+
return mapLabels.filter((label) => !carried.has(label.toLowerCase()))
|
|
17
|
+
}
|
package/src/pr/close.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { scanPhaseLabels } from '@/labels/phase'
|
|
2
|
+
import { BAN_SETS } from '@/markdown/bans'
|
|
3
|
+
import { type DensityFinding, densityFindings } from '@/markdown/density'
|
|
4
|
+
import { bodyLines, scanBans } from '@/markdown/scan'
|
|
5
|
+
|
|
6
|
+
/** Why the verb did not close, in the order the refusals are read. */
|
|
7
|
+
export type CloseRefusal =
|
|
8
|
+
| 'no-reason'
|
|
9
|
+
| 'reason-unpublishable'
|
|
10
|
+
| 'gh-failed'
|
|
11
|
+
| 'pull-closed'
|
|
12
|
+
| 'head-mismatch'
|
|
13
|
+
| 'comment-failed'
|
|
14
|
+
| 'close-failed'
|
|
15
|
+
|
|
16
|
+
export interface ClosePull {
|
|
17
|
+
readonly isOpen: boolean
|
|
18
|
+
/** The head branch name, which the caller's expected head is checked against. */
|
|
19
|
+
readonly branch: string
|
|
20
|
+
/** Whether the head lives in another repository, whose branch this one cannot delete. */
|
|
21
|
+
readonly isFork: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Everything the verb reads or writes, injected so every refusal runs without
|
|
26
|
+
* a network. A read or write that failed returns undefined or false, never a
|
|
27
|
+
* guess.
|
|
28
|
+
*/
|
|
29
|
+
export interface CloseApi {
|
|
30
|
+
readPull(): Promise<ClosePull | undefined>
|
|
31
|
+
/** Posts the reason and answers the comment's URL. */
|
|
32
|
+
postComment(body: string): Promise<string | undefined>
|
|
33
|
+
patchClosed(): Promise<boolean>
|
|
34
|
+
deleteBranch(branch: string): Promise<boolean>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CloseOptions {
|
|
38
|
+
readonly number: number
|
|
39
|
+
readonly reason: string
|
|
40
|
+
/** The head branch the caller means, so a mistyped number closes nothing. */
|
|
41
|
+
readonly head: string
|
|
42
|
+
readonly isKeepBranch: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What the reason carries that a reader on the remote cannot use. */
|
|
46
|
+
export interface ReasonFindings {
|
|
47
|
+
readonly isClean: boolean
|
|
48
|
+
readonly bannedCharacters: readonly string[]
|
|
49
|
+
readonly phaseLabels: readonly string[]
|
|
50
|
+
readonly boardReferences: readonly string[]
|
|
51
|
+
readonly sessionLinks: readonly string[]
|
|
52
|
+
readonly density: readonly DensityFinding[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What happened to the head branch once the pull request closed. */
|
|
56
|
+
export type BranchOutcome = 'deleted' | 'kept' | 'fork' | 'delete-failed'
|
|
57
|
+
|
|
58
|
+
export interface CloseRecord {
|
|
59
|
+
readonly reason: 'closed' | CloseRefusal
|
|
60
|
+
readonly isOk: boolean
|
|
61
|
+
readonly number: number
|
|
62
|
+
readonly commentUrl?: string
|
|
63
|
+
readonly branch?: BranchOutcome
|
|
64
|
+
readonly findings?: ReasonFindings
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Runs the checks `canon labels scan` runs on a pull request body, plus
|
|
69
|
+
* density measured on the reason itself.
|
|
70
|
+
*
|
|
71
|
+
* The scan's own post density reads a body only when it opens on a known
|
|
72
|
+
* post heading, and a closing comment carries none, so an overloaded reason
|
|
73
|
+
* would pass it unmeasured.
|
|
74
|
+
*/
|
|
75
|
+
export function checkReason(reason: string): ReasonFindings {
|
|
76
|
+
const lines = bodyLines(reason)
|
|
77
|
+
const bannedCharacters = scanBans(lines, BAN_SETS).map(
|
|
78
|
+
(finding) => finding.term,
|
|
79
|
+
)
|
|
80
|
+
const { phaseLabels, boardReferences, sessionLinks } = scanPhaseLabels({
|
|
81
|
+
title: '',
|
|
82
|
+
body: reason,
|
|
83
|
+
headRefName: '',
|
|
84
|
+
})
|
|
85
|
+
const density = densityFindings(lines)
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
isClean:
|
|
89
|
+
bannedCharacters.length === 0 &&
|
|
90
|
+
phaseLabels.length === 0 &&
|
|
91
|
+
boardReferences.length === 0 &&
|
|
92
|
+
sessionLinks.length === 0 &&
|
|
93
|
+
density.length === 0,
|
|
94
|
+
bannedCharacters,
|
|
95
|
+
phaseLabels,
|
|
96
|
+
boardReferences,
|
|
97
|
+
sessionLinks,
|
|
98
|
+
density,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function refused(
|
|
103
|
+
reason: CloseRefusal,
|
|
104
|
+
number: number,
|
|
105
|
+
extra: Partial<CloseRecord> = {},
|
|
106
|
+
): CloseRecord {
|
|
107
|
+
return { reason, isOk: false, number, ...extra }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Closes one pull request unmerged with its reason posted first.
|
|
112
|
+
*
|
|
113
|
+
* The comment lands before the close, so a failure between the two leaves a
|
|
114
|
+
* comment on an open pull request rather than a closed one carrying no
|
|
115
|
+
* reason, which is the defect this verb exists to remove. The branch delete
|
|
116
|
+
* runs last and never fails the close, since the pull request is already
|
|
117
|
+
* closed with its reason by then.
|
|
118
|
+
*/
|
|
119
|
+
export async function close(
|
|
120
|
+
api: CloseApi,
|
|
121
|
+
opts: CloseOptions,
|
|
122
|
+
): Promise<CloseRecord> {
|
|
123
|
+
const { number } = opts
|
|
124
|
+
const reason = opts.reason.trim()
|
|
125
|
+
|
|
126
|
+
if (reason === '') return refused('no-reason', number)
|
|
127
|
+
const findings = checkReason(reason)
|
|
128
|
+
if (!findings.isClean) {
|
|
129
|
+
return refused('reason-unpublishable', number, { findings })
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const pull = await api.readPull()
|
|
133
|
+
if (pull === undefined) return refused('gh-failed', number)
|
|
134
|
+
if (!pull.isOpen) return refused('pull-closed', number)
|
|
135
|
+
if (pull.branch !== opts.head) return refused('head-mismatch', number)
|
|
136
|
+
|
|
137
|
+
const commentUrl = await api.postComment(reason)
|
|
138
|
+
if (commentUrl === undefined) return refused('comment-failed', number)
|
|
139
|
+
|
|
140
|
+
if (!(await api.patchClosed())) {
|
|
141
|
+
return refused('close-failed', number, { commentUrl })
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let branch: BranchOutcome
|
|
145
|
+
if (opts.isKeepBranch) branch = 'kept'
|
|
146
|
+
else if (pull.isFork) branch = 'fork'
|
|
147
|
+
else {
|
|
148
|
+
branch = (await api.deleteBranch(pull.branch)) ? 'deleted' : 'delete-failed'
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { reason: 'closed', isOk: true, number, commentUrl, branch }
|
|
152
|
+
}
|
package/src/pr/health.ts
ADDED
|
@@ -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
|
|
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
|
-
| {
|
|
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
|
|
20
|
-
* than a process is the unit, since one
|
|
21
|
-
* socket for its reload channel, and the
|
|
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
|
|
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?: {
|
|
19
|
-
|
|
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
|
-
|
|
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
|
|
99
|
-
|
|
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"]
|