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