@erclx/canon 5.64.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 +25 -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/claude/skills/test-craft/references/e2e.md +1 -1
- package/docs/workflow/skill-map.md +1 -0
- package/governance/ledger/skills/codebase-layout/SKILL.md +64 -0
- package/governance/ledger/skills/codebase-layout/frontend.md +62 -0
- package/governance/ledger/skills/codebase-layout/tests.md +26 -0
- package/governance/ledger/skills/review-craft/SKILL.md +61 -0
- package/governance/ledger/skills/review-craft/security.md +34 -0
- package/governance/ledger/skills/teach-craft/SKILL.md +33 -0
- package/governance/ledger/skills/teach-craft/lesson-craft.md +47 -0
- package/governance/ledger/skills/teach-craft/pedagogy.md +77 -0
- package/governance/ledger/skills/test-craft/SKILL.md +67 -0
- package/governance/ledger/skills/test-craft/component.md +49 -0
- package/governance/ledger/skills/test-craft/e2e.md +55 -0
- package/governance/ledger/skills/test-craft/unit.md +54 -0
- package/package.json +3 -3
- package/src/claude/cases/git.ts +4 -0
- package/src/claude/skills-audit.ts +7 -0
- package/src/commands/labels.ts +152 -4
- package/src/commands/pr.ts +263 -2
- package/src/commands/sandbox.ts +247 -2
- package/src/gate/measures.ts +3 -0
- package/src/gate/stages.ts +25 -0
- package/src/labels/apply.ts +10 -5
- package/src/labels/sync.ts +17 -0
- package/src/migrate/records.ts +6 -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/expected.ts +137 -0
- package/src/sandbox/replay/run.ts +348 -0
- package/src/sandbox/replay/verdict.ts +102 -0
- package/src/tooling/verify-stack.ts +4 -5
- package/tooling/astro/configs/playwright.config.ts +2 -2
- package/tooling/base/reference.md +1 -0
- package/tooling/base/seeds/canon/config/pr-labels.toml +41 -0
- package/tooling/nextjs/configs/playwright.config.ts +2 -2
- package/tooling/vite-react/configs/playwright.config.ts +2 -2
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,
|