@erclx/canon 5.13.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/SKILL.md +3 -2
- 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 +3 -0
- package/claude/skills/git-ship/SKILL.md +12 -1
- package/claude/skills/review-ui/REQUIREMENT.md +5 -2
- package/claude/skills/review-ui/SKILL.md +12 -4
- package/claude/skills/review-ui/references/driving.md +5 -3
- package/claude/skills/review-ui/references/post.md +3 -2
- 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/workflow/skill-map.md +15 -14
- package/package.json +1 -1
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/skills-audit.ts +1 -0
- package/src/commands/pr.ts +452 -0
- 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/tooling/manifest.ts +2 -3
- package/src/tooling/seeds.ts +37 -0
- package/tooling/claude/manifest.toml +3 -3
- 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
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: The pull request frame store
|
|
3
|
+
description: How canon pr frames pushes a UI review frame to a never-merged branch through the GitHub API and returns a branch-path link, which ref it writes, the pass segment that keeps an earlier image fixed, the refusal reasons it names, and how --drop and --prune rewrite the branch so a dropped frame leaves history
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# The pull request frame store
|
|
7
|
+
|
|
8
|
+
`canon pr frames` gives a review comment an image it can embed. `review-ui`
|
|
9
|
+
drives a pull request's checklist and captures a frame per box, and a frame
|
|
10
|
+
on the reviewer's disk resolves for nobody else. The verb pushes one frame to
|
|
11
|
+
the `canon-frames` branch and returns a link to its path there.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
canon pr frames 1341 --add frames/3.png --box 3 --head <sha> --pass 20261002T154450Z --json
|
|
15
|
+
canon pr frames 1341 --drop --json
|
|
16
|
+
canon pr frames --prune 30 --json
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## What it writes
|
|
20
|
+
|
|
21
|
+
The verb writes `refs/heads/canon-frames` and no other ref. Every call goes
|
|
22
|
+
through the GitHub git data API, being blobs, trees, commits, and that one ref,
|
|
23
|
+
so nothing is checked out, no worktree is entered, and neither `main` nor any
|
|
24
|
+
branch a pull request holds is touched. The branch never merges.
|
|
25
|
+
|
|
26
|
+
A frame lands at `pr-<number>/<short-head>/<pass>/box-<n>.png`. `--head` names
|
|
27
|
+
the commit the pass drove, 7 to 40 lowercase hex characters, and defaults to
|
|
28
|
+
the head the pull request object reports. `--pass` is the instant the pass
|
|
29
|
+
started, in compact UTC as `date -u +%Y%m%dT%H%M%SZ` prints it, which
|
|
30
|
+
`review-ui` records once at its first step. A second pass at the same head and
|
|
31
|
+
box therefore writes a new file rather than replacing the image an earlier
|
|
32
|
+
comment shows, which matters when a preview is minted again at one head.
|
|
33
|
+
|
|
34
|
+
`link` uses the same `blob/<ref>/<path>?raw=true` form as the evidence comment,
|
|
35
|
+
naming the `canon-frames` branch rather than a commit. Every drop rewrites the
|
|
36
|
+
branch, and a link pinned to a commit would stop resolving for every other pull
|
|
37
|
+
request's frames once GitHub collects the commit it names. A path link lives
|
|
38
|
+
exactly as long as its file, so it survives every rewrite that drops another
|
|
39
|
+
pull request and ends only when its own is dropped.
|
|
40
|
+
|
|
41
|
+
The first `--add` creates the branch with a root commit holding the
|
|
42
|
+
frame, so a project needs no setup. An add only fast-forwards the ref. When
|
|
43
|
+
another writer moved it between the read and the move, the add rebuilds on the
|
|
44
|
+
new tip, up to three times, before refusing as `ref-conflict`.
|
|
45
|
+
|
|
46
|
+
Only a frame of the address the pass drove belongs here. The branch is as
|
|
47
|
+
public as the repository, so a frame of a private or pre-release state would
|
|
48
|
+
be published with it.
|
|
49
|
+
|
|
50
|
+
## What the record carries
|
|
51
|
+
|
|
52
|
+
`reason` on the record is what a caller branches on, not the exit code:
|
|
53
|
+
|
|
54
|
+
| Reason | What it means |
|
|
55
|
+
| ------------------ | -------------------------------------------------------------------------------- |
|
|
56
|
+
| `ok` | An add pushed, or a drop or prune finished, including one that removed nothing. |
|
|
57
|
+
| `bad-mode` | Not exactly one of `--add`, `--drop`, `--prune`, or a number with `--prune`. |
|
|
58
|
+
| `no-number` | `--add` or `--drop` named no pull request. |
|
|
59
|
+
| `bad-box` | `--add` carried no positive `--box`. |
|
|
60
|
+
| `bad-head` | `--head`, or the head the pull request reported, is not a 7 to 40 character sha. |
|
|
61
|
+
| `bad-pass` | `--add` carried no `--pass` stamp in compact UTC. |
|
|
62
|
+
| `bad-days` | `--prune` carried no positive number of days. |
|
|
63
|
+
| `unreadable-frame` | The `--add` file is missing, empty, or not a PNG. Nothing is called. |
|
|
64
|
+
| `read-only` | GitHub refused a write with 403 or 404, which is what a fork's token gets. |
|
|
65
|
+
| `unreadable-tip` | The branch exists and could not be read. |
|
|
66
|
+
| `push-failed` | A write failed partway and the ref did not move. |
|
|
67
|
+
| `ref-conflict` | Another writer kept moving the ref through every attempt. |
|
|
68
|
+
| `no-object-head` | No `--head` and the pull request reported none. |
|
|
69
|
+
| `gh-missing` | `gh` is not on the path. |
|
|
70
|
+
| `gh-failed` | `gh` could not name the repository or read the pull request. |
|
|
71
|
+
|
|
72
|
+
An add's `ok` record carries `link`, `commit`, `path`, `box`, `head`, `pass`,
|
|
73
|
+
and `created`, which is true when the add made the branch. A drop's carries
|
|
74
|
+
`removed`, the pull requests whose folders went, plus `commit` for the rewrite
|
|
75
|
+
or `deleted` when it emptied the branch. A prune adds `kept` and `unread`.
|
|
76
|
+
|
|
77
|
+
`review-ui` reads every reason but `ok` as a cue to describe the frame in words
|
|
78
|
+
and name the reason, so a refused push never fails the pass.
|
|
79
|
+
|
|
80
|
+
## Dropping frames
|
|
81
|
+
|
|
82
|
+
A plain delete commit would keep every image reachable through history, so
|
|
83
|
+
`--drop` and `--prune` write one root commit holding everything else and
|
|
84
|
+
force-move the ref to it. A rewrite that leaves the branch empty deletes it,
|
|
85
|
+
and the next add recreates it. Dropping a pull request with no frames, or on a
|
|
86
|
+
missing branch, reports `ok` with nothing removed, so a scheduled run never
|
|
87
|
+
fails on an empty branch.
|
|
88
|
+
|
|
89
|
+
`--prune <days>` reads every `pr-<n>/` folder, asks GitHub for each pull
|
|
90
|
+
request's state, and drops every one reading closed with a close date more
|
|
91
|
+
than that many days ago, in a single rewrite. It keeps one still open, one
|
|
92
|
+
reopened after closing, one closed exactly the given days ago, and one whose
|
|
93
|
+
state could not be read, listing that last kind under `unread`.
|
|
94
|
+
|
|
95
|
+
This repository runs `--prune 30` daily from
|
|
96
|
+
`.github/workflows/pr-frames-cleanup.yml`. A frame's file stays on the branch
|
|
97
|
+
until its own pull request has been closed for 30 days, so a link in a review
|
|
98
|
+
comment keeps resolving for about a month after its pull request closes,
|
|
99
|
+
whatever other pull requests the branch drops in between. A target project
|
|
100
|
+
carries no such workflow yet and keeps its frames until someone runs `--drop`.
|
|
101
|
+
|
|
102
|
+
## What a rewrite does not do
|
|
103
|
+
|
|
104
|
+
A force-move leaves the old commits unreachable without purging them. GitHub
|
|
105
|
+
serves an unreachable commit by sha until it collects it, so a dropped frame
|
|
106
|
+
stays reachable to anyone holding a sha from an earlier record, and a frame
|
|
107
|
+
holding sensitive content needs a purge request to GitHub. Its path link stops
|
|
108
|
+
resolving at the drop, since the branch no longer carries the file.
|
|
109
|
+
|
|
110
|
+
A drop racing an add can lose the add's frame, since GitHub's ref update has no
|
|
111
|
+
compare-and-swap for a force-move. The drop builds from the tip it reads, and
|
|
112
|
+
the window is the length of one rewrite.
|
|
@@ -38,20 +38,21 @@ This page is the corpus the coverage claim is measured against: every name `cano
|
|
|
38
38
|
|
|
39
39
|
## Build the feature
|
|
40
40
|
|
|
41
|
-
| Skill
|
|
42
|
-
|
|
|
43
|
-
| `canon:session-worktree`
|
|
44
|
-
| `canon:auto-ship`
|
|
45
|
-
| `canon:project-commands`
|
|
46
|
-
| `canon:test-first`
|
|
47
|
-
| `canon:test-craft`
|
|
48
|
-
| `canon:build-in-slices`
|
|
49
|
-
| `canon:api-design`
|
|
50
|
-
| `canon:
|
|
51
|
-
| `canon:
|
|
52
|
-
| `canon:
|
|
53
|
-
| `canon:
|
|
54
|
-
| `canon:
|
|
41
|
+
| Skill | When to use |
|
|
42
|
+
| ----------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
|
43
|
+
| `canon:session-worktree` | At the plan-to-execute boundary, to get an isolated tree and branch, or to route a refused main-root write |
|
|
44
|
+
| `canon:auto-ship` | After plan approval, to chain implement, verify, review, draft PR |
|
|
45
|
+
| `canon:project-commands` | When the project's own command needs running |
|
|
46
|
+
| `canon:test-first` | Before implementing a planned change, to run its test red, green, then refactor |
|
|
47
|
+
| `canon:test-craft` | When writing or changing any test, to pick its layer and filter what it asserts |
|
|
48
|
+
| `canon:build-in-slices` | While building a plan, to commit each tested slice and note out-of-scope defects rather than fix them |
|
|
49
|
+
| `canon:api-design` | When changing what a caller sees, being an export, a flag, an output shape, or a retried write |
|
|
50
|
+
| `canon:deprecation-migration` | When deleting or replacing code something may still call, to migrate its callers and prove none remain |
|
|
51
|
+
| `canon:code-craft` | Before writing a function, class, or module, to pick the shape the next change needs |
|
|
52
|
+
| `canon:review-craft` | When reviewing a change, for what to look for and how much evidence a finding needs |
|
|
53
|
+
| `canon:systematic-debugging` | When a test fails or a bug surfaces, to force root cause first |
|
|
54
|
+
| `canon:design-taste` | When drafting or judging an interface, or when output reads generic, to settle which decision comes first |
|
|
55
|
+
| `canon:ui-checklist` | After a UI change, to write what to look at and name what ships untested |
|
|
55
56
|
|
|
56
57
|
## Check the work before it leaves the branch
|
|
57
58
|
|
package/package.json
CHANGED
package/src/claude/cases/misc.ts
CHANGED
|
@@ -84,6 +84,11 @@ export const MISC_CASES: readonly SkillCase[] = [
|
|
|
84
84
|
"I've got the plan approved and it touches the parser, the command, and the docs. How should I build it so I don't end up with one giant diff?",
|
|
85
85
|
expect: 'build-in-slices',
|
|
86
86
|
},
|
|
87
|
+
{
|
|
88
|
+
prompt:
|
|
89
|
+
'The old parseDate helper was replaced months ago. Can I just delete it, or could a few files still be calling it?',
|
|
90
|
+
expect: 'deprecation-migration',
|
|
91
|
+
},
|
|
87
92
|
{
|
|
88
93
|
prompt: 'Does our github about text still match what the readme says?',
|
|
89
94
|
expect: 'repo-metadata',
|
|
@@ -39,6 +39,7 @@ export const PRACTICE_SKILLS: readonly string[] = [
|
|
|
39
39
|
join(SHIPPED_CORPUS, 'api-design'),
|
|
40
40
|
join(SHIPPED_CORPUS, 'code-craft'),
|
|
41
41
|
join(SHIPPED_CORPUS, 'build-in-slices'),
|
|
42
|
+
join(SHIPPED_CORPUS, 'deprecation-migration'),
|
|
42
43
|
]
|
|
43
44
|
|
|
44
45
|
/** The closing H2s a practice skill carries, matched exactly outside fences. */
|
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,
|
|
@@ -116,6 +129,55 @@ const LOCAL_REFUSALS: Record<LocalRefusal | 'gh-failed', string> = {
|
|
|
116
129
|
'gh could not read or edit the marked comment on this pull request.',
|
|
117
130
|
}
|
|
118
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
|
+
|
|
119
181
|
interface PreviewOptions extends ReadOptions {
|
|
120
182
|
readonly timeout?: string
|
|
121
183
|
readonly check?: boolean
|
|
@@ -659,6 +721,82 @@ export function register(program: Command): void {
|
|
|
659
721
|
? await runLocalRemove(number, opts)
|
|
660
722
|
: await runLocal(opts)
|
|
661
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
|
+
})
|
|
662
800
|
}
|
|
663
801
|
|
|
664
802
|
interface PullRequestRead {
|
|
@@ -1743,6 +1881,7 @@ async function runEvidence(
|
|
|
1743
1881
|
base,
|
|
1744
1882
|
head: identity.head,
|
|
1745
1883
|
body,
|
|
1884
|
+
states,
|
|
1746
1885
|
...(commentId !== undefined && { commentId }),
|
|
1747
1886
|
...carried,
|
|
1748
1887
|
})}\n`,
|
|
@@ -2198,6 +2337,319 @@ async function runLocalRemove(
|
|
|
2198
2337
|
return finish('removed', `comment ${commentId}`)
|
|
2199
2338
|
}
|
|
2200
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
|
+
|
|
2201
2653
|
/**
|
|
2202
2654
|
* Frames a refusal on stderr in both modes and puts the record on stdout alone,
|
|
2203
2655
|
* so an operator reading the terminal sees the reason rather than a command
|