@erclx/canon 5.46.0 → 5.48.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/git-followup/SKILL.md +1 -0
- package/claude/skills/git-pr/REQUIREMENT.md +2 -2
- package/claude/skills/git-pr/SKILL.md +2 -1
- package/claude/skills/git-pr/references/closing-issues.md +2 -0
- package/claude/skills/git-pr/references/pr.md +58 -7
- package/claude/skills/review-address/SKILL.md +18 -6
- package/claude/skills/review-address/references/post-review-findings.md +4 -1
- package/claude/skills/review-address/references/rebase-only.md +14 -2
- package/claude/skills/review-pr/SKILL.md +9 -2
- package/claude/skills/review-pr/references/body-variants.md +29 -8
- package/claude/skills/review-pr/references/post.md +29 -10
- package/claude/skills/review-ui/REQUIREMENT.md +1 -1
- package/claude/skills/review-ui/SKILL.md +4 -3
- package/claude/skills/review-ui/references/post.md +16 -2
- package/claude/skills/review-ui/references/tick.md +7 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +4 -2
- package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
- package/claude/skills/role-reviewer/SKILL.md +4 -1
- package/claude/skills/role-worker/REQUIREMENT.md +1 -1
- package/claude/skills/role-worker/SKILL.md +1 -0
- package/claude/skills/task-board/references/decline.md +2 -2
- package/claude/skills/task-board/references/tasks.md +4 -2
- package/claude/skills/task-complete/SKILL.md +1 -0
- package/claude/skills/task-complete/references/archive.md +5 -3
- package/claude/skills/ui-checklist/SKILL.md +3 -3
- package/docs/agents/migrate.md +2 -2
- package/docs/target/migrations.md +4 -2
- package/docs/workflow/operating-model.md +2 -2
- package/docs/workflow/zshrc-aliases.md +3 -0
- package/package.json +1 -1
- package/scripts/core/bootstrap.sh +1 -0
- package/src/commands/claude.ts +4 -0
- package/src/commands/labels.ts +66 -1
- package/src/commands/migrate.ts +5 -4
- package/src/commands/pr.ts +405 -30
- package/src/commands/sessions.ts +25 -11
- package/src/commands/tasks.ts +11 -1
- package/src/labels/density.ts +363 -0
- package/src/migrate/session-defaults.ts +20 -5
- package/src/pr/evidence.ts +1 -1
- package/src/pr/lift.ts +19 -0
- package/src/pr/merge.ts +175 -0
- package/src/pr/rest.ts +3 -0
- package/src/pr/review-scope.ts +40 -1
- package/src/pr/tick.ts +109 -0
- package/src/sessions/live.ts +26 -0
- package/src/sessions/resolve.ts +62 -11
- package/src/tasks/archive.ts +32 -8
- package/src/tasks/closes.ts +9 -5
- package/src/tasks/validate.ts +4 -2
- package/tooling/base/seeds/.github/pull_request_template.md +1 -1
- package/tooling/claude/reference.md +3 -1
- package/tooling/claude/seeds/.claude/settings.json +1 -1
package/src/commands/pr.ts
CHANGED
|
@@ -63,6 +63,7 @@ import {
|
|
|
63
63
|
} from '@/pr/frames'
|
|
64
64
|
import { type HeadRefusal, resolveHead, resolveTip } from '@/pr/head'
|
|
65
65
|
import {
|
|
66
|
+
type ChecksGate,
|
|
66
67
|
DEFAULT_WAIT_SECONDS,
|
|
67
68
|
type EvidenceGate,
|
|
68
69
|
type GitRunner,
|
|
@@ -73,6 +74,13 @@ import {
|
|
|
73
74
|
readBaseStanding,
|
|
74
75
|
readChangedPaths,
|
|
75
76
|
} from '@/pr/lift'
|
|
77
|
+
import {
|
|
78
|
+
merge,
|
|
79
|
+
type MergeApi,
|
|
80
|
+
type MergeOutcome,
|
|
81
|
+
type MergeRefusal,
|
|
82
|
+
type MergeRequest,
|
|
83
|
+
} from '@/pr/merge'
|
|
76
84
|
import { machineListenerReader } from '@/listeners/machine'
|
|
77
85
|
import { loadPaintedPatterns } from '@/pr/painted'
|
|
78
86
|
import {
|
|
@@ -93,7 +101,15 @@ import {
|
|
|
93
101
|
servesChange,
|
|
94
102
|
type WorkflowFile,
|
|
95
103
|
} from '@/pr/preview'
|
|
96
|
-
import {
|
|
104
|
+
import { coversHead, resolveUiScope } from '@/pr/review-scope'
|
|
105
|
+
import {
|
|
106
|
+
countTicksAtHead,
|
|
107
|
+
readUiPassHead,
|
|
108
|
+
type TickRefusal,
|
|
109
|
+
tickBoxes,
|
|
110
|
+
type UiPassSummary,
|
|
111
|
+
writeUiPassLine,
|
|
112
|
+
} from '@/pr/tick'
|
|
97
113
|
import {
|
|
98
114
|
commentRowOf,
|
|
99
115
|
identityOf,
|
|
@@ -144,6 +160,10 @@ interface LiftCommandOptions extends ReadOptions {
|
|
|
144
160
|
readonly wait?: string
|
|
145
161
|
}
|
|
146
162
|
|
|
163
|
+
interface MergeCommandOptions extends ReadOptions {
|
|
164
|
+
readonly dryRun?: boolean
|
|
165
|
+
}
|
|
166
|
+
|
|
147
167
|
interface ReviewStateOptions extends ReadOptions {
|
|
148
168
|
readonly wait?: string
|
|
149
169
|
}
|
|
@@ -157,13 +177,23 @@ interface TickOptions extends ReadOptions {
|
|
|
157
177
|
readonly head?: string
|
|
158
178
|
}
|
|
159
179
|
|
|
160
|
-
type TickReason =
|
|
180
|
+
type TickReason =
|
|
181
|
+
| TickRefusal
|
|
182
|
+
| 'count-mismatch'
|
|
183
|
+
| 'stale-head'
|
|
184
|
+
| 'bad-boxes'
|
|
185
|
+
| 'no-comment'
|
|
186
|
+
| 'body-refused'
|
|
161
187
|
|
|
162
188
|
const TICK_REFUSALS: Record<TickReason | 'gh-failed', string> = {
|
|
189
|
+
'body-refused':
|
|
190
|
+
'The boxes are ticked on the evidence comment, but gh could not edit the pull request body, so its UI pass line still shows the earlier result. Re-run the tick to retry the body.',
|
|
163
191
|
'stale-head':
|
|
164
192
|
'The head named is not the branch tip on the remote, so a tick would claim a pass on a commit that is no longer the pull request. Re-drive the checklist at the tip.',
|
|
165
193
|
'bad-boxes':
|
|
166
194
|
'--boxes takes box numbers as a comma-separated list of positive whole numbers, such as 1,3, and --head takes the commit that was driven.',
|
|
195
|
+
'count-mismatch':
|
|
196
|
+
'The newest UI verdict at this head passed a different number of boxes than the ticks this call would leave at the head. Nothing was written. With fewer ticks than passed, re-run once with every box that passed. With more, a box ticked earlier at this head has since failed, which only the operator can untick.',
|
|
167
197
|
'no-comment': 'No comment on this pull request carries the evidence marker.',
|
|
168
198
|
'no-checklist': 'The evidence comment carries no checklist to tick.',
|
|
169
199
|
'no-box':
|
|
@@ -736,6 +766,11 @@ export function register(program: Command): void {
|
|
|
736
766
|
'comment. Together with --checklist the link and the checklist land in',
|
|
737
767
|
'one comment.',
|
|
738
768
|
'',
|
|
769
|
+
"A render at a new head also resets the pull request body's UI pass",
|
|
770
|
+
'line, the one `canon pr tick` writes under ## Testing, to unchecked',
|
|
771
|
+
'naming that head. It touches no other body text, leaves a line already',
|
|
772
|
+
'naming this head alone, and reports `uiPass` as reset or reset-refused.',
|
|
773
|
+
'',
|
|
739
774
|
'Both reasons carry what the marked comment already posted, each field',
|
|
740
775
|
'present only when that comment holds it:',
|
|
741
776
|
' preview the hosted **Preview:** address the comment carries',
|
|
@@ -816,7 +851,9 @@ export function register(program: Command): void {
|
|
|
816
851
|
' on an earlier commit still holds when it is an ancestor of the',
|
|
817
852
|
' head and no painted path changed since, and the record names',
|
|
818
853
|
' it as `carriedFrom`. A painted change refuses `ui-behind` and',
|
|
819
|
-
' lists the paths as `paintedPaths
|
|
854
|
+
' lists the paths as `paintedPaths`. A close whose count line',
|
|
855
|
+
' shows boxes not driven and none passed, failed, or needing',
|
|
856
|
+
' eyes refuses `ui-undriven`, carried or not',
|
|
820
857
|
' evidence the evidence check reads settled',
|
|
821
858
|
' standing the head is fresh against the base, not stale or conflicted',
|
|
822
859
|
' ci every check run on the tip passed, waited on up to --wait',
|
|
@@ -842,7 +879,7 @@ export function register(program: Command): void {
|
|
|
842
879
|
' lifted, already-ready, redrafted, clear (--check)',
|
|
843
880
|
' pull-closed, review-missing, review-open, review-unmarked,',
|
|
844
881
|
' review-behind, ui-missing, ui-open, ui-unmarked, ui-behind,',
|
|
845
|
-
' evidence-owed, standing-stale, standing-conflict, standing-unknown,',
|
|
882
|
+
' ui-undriven, evidence-owed, standing-stale, standing-conflict, standing-unknown,',
|
|
846
883
|
' ci-pending (--check), ci-failing, ci-conflicted, ci-timeout,',
|
|
847
884
|
' head-moved, gh-failed',
|
|
848
885
|
'',
|
|
@@ -863,6 +900,57 @@ export function register(program: Command): void {
|
|
|
863
900
|
process.exitCode = await runLift(number, opts)
|
|
864
901
|
})
|
|
865
902
|
|
|
903
|
+
pr.command('merge')
|
|
904
|
+
.description(
|
|
905
|
+
'Squash-merge a ready pull request with a message carrying only its Summary, Key changes, and Technical context',
|
|
906
|
+
)
|
|
907
|
+
.argument('[number]', 'Pull request to merge, defaulting to this branch')
|
|
908
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
909
|
+
.option('--root <path>', 'Repository to read, defaulting to the cwd')
|
|
910
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
911
|
+
.option('--dry-run', 'Print the title and description and merge nothing')
|
|
912
|
+
.addHelpText(
|
|
913
|
+
'after',
|
|
914
|
+
[
|
|
915
|
+
'',
|
|
916
|
+
'The operator runs this verb, and no skill calls it. A permissions deny',
|
|
917
|
+
'rule on `canon pr merge` and `gh pr merge` keeps an agent off it.',
|
|
918
|
+
'',
|
|
919
|
+
'The title is the pull request title plus ` (#<number>)`. The',
|
|
920
|
+
"description is the body's `## Summary`, `## Key changes`, and",
|
|
921
|
+
'`## Technical context`, in that order under their headings, with the',
|
|
922
|
+
'UI pass line dropped. Technical context is optional. Testing, the',
|
|
923
|
+
'reviewer requests, and every other section stay out of the trunk.',
|
|
924
|
+
'',
|
|
925
|
+
'Refusals are read before the one write, which sends the head it read',
|
|
926
|
+
'as `sha`, so a push after the read refuses rather than merging. The',
|
|
927
|
+
'verb reads the draft flag and not the reviews, since a ready pull',
|
|
928
|
+
'request already passed `canon pr lift`. It does not wait on checks or',
|
|
929
|
+
'delete the branch.',
|
|
930
|
+
'',
|
|
931
|
+
'Reasons:',
|
|
932
|
+
' merged, dry-run (--dry-run)',
|
|
933
|
+
' pull-closed, pull-draft, missing-summary, missing-key-changes,',
|
|
934
|
+
' ci-failing, ci-conflicted, ci-pending, head-moved, not-mergeable,',
|
|
935
|
+
' gh-failed',
|
|
936
|
+
'',
|
|
937
|
+
'The merge is the REST `PUT pulls/<number>/merge` with',
|
|
938
|
+
'`merge_method=squash`, never `gh pr merge`, which runs on GraphQL.',
|
|
939
|
+
'',
|
|
940
|
+
'Exit codes:',
|
|
941
|
+
' 0 merged, or every refusal clear under --dry-run',
|
|
942
|
+
' 1 refused, with the reason in the JSON record',
|
|
943
|
+
'',
|
|
944
|
+
'Examples:',
|
|
945
|
+
' canon pr merge 1341 --dry-run',
|
|
946
|
+
' canon pr merge 1341 --json',
|
|
947
|
+
'',
|
|
948
|
+
].join('\n'),
|
|
949
|
+
)
|
|
950
|
+
.action(async (number: string | undefined, opts: MergeCommandOptions) => {
|
|
951
|
+
process.exitCode = await runMerge(number, opts)
|
|
952
|
+
})
|
|
953
|
+
|
|
866
954
|
pr.command('preview')
|
|
867
955
|
.description(
|
|
868
956
|
"Deploy the pull request's branch to a Cloudflare Pages preview and report its address",
|
|
@@ -1024,8 +1112,27 @@ export function register(program: Command): void {
|
|
|
1024
1112
|
'it. A later render at another head clears a tick whose stamp names',
|
|
1025
1113
|
'a different commit.',
|
|
1026
1114
|
'',
|
|
1115
|
+
"It also keeps one marked line in the pull request body's ## Testing,",
|
|
1116
|
+
'such as `- [x] UI pass, 17 of 17 boxes passed at <short sha>, 1 taste',
|
|
1117
|
+
'box left to the operator`. The line is ticked only when every box a',
|
|
1118
|
+
'driver can run is ticked, counts taste boxes apart, and is the only',
|
|
1119
|
+
'body text this verb writes, so hand edits elsewhere survive.',
|
|
1120
|
+
'',
|
|
1121
|
+
'Before writing it reads the newest UI verdict. One that covers the tip',
|
|
1122
|
+
'and carries a count line must match the ticks this call leaves at the',
|
|
1123
|
+
'tip, so a re-drive passes every box that passed, since a new head',
|
|
1124
|
+
'clears the older ticks. Any other verdict leaves the check unrun.',
|
|
1125
|
+
'',
|
|
1027
1126
|
'Read `reason` on the JSON record:',
|
|
1028
|
-
' ticked the boxes were ticked
|
|
1127
|
+
' ticked the boxes were ticked and the body line written, with the',
|
|
1128
|
+
' tip in `head` and `countCheck` as matched or unchecked',
|
|
1129
|
+
' count-mismatch the verdict passed a different number of boxes than',
|
|
1130
|
+
' this call leaves ticked at the tip, nothing written,',
|
|
1131
|
+
' `passed` and `ticked` carry the two counts. Fewer ticks',
|
|
1132
|
+
' owe a re-run with every passed box, more ticks owe the',
|
|
1133
|
+
' operator an untick of a box that has since failed',
|
|
1134
|
+
' body-refused the comment is ticked but gh could not edit the body,',
|
|
1135
|
+
' exit 1 with `commentTicked`. Re-run to retry the body.',
|
|
1029
1136
|
' stale-head --head is not the remote branch tip, nothing written',
|
|
1030
1137
|
' taste-box a named box ends in (taste), nothing written',
|
|
1031
1138
|
' no-box a named number is past the checklist, nothing written',
|
|
@@ -2437,10 +2544,30 @@ async function runEvidence(
|
|
|
2437
2544
|
return 1
|
|
2438
2545
|
}
|
|
2439
2546
|
|
|
2547
|
+
// A UI pass line stamped at an earlier head describes a tree that is gone,
|
|
2548
|
+
// so a render at a new head puts it back to unchecked. A line already
|
|
2549
|
+
// naming this head is left alone, since each body edit reruns the label gate.
|
|
2550
|
+
const priorBody = identity.body ?? ''
|
|
2551
|
+
const stampedHead = readUiPassHead(priorBody)
|
|
2552
|
+
let uiPass: 'reset' | 'reset-refused' | undefined
|
|
2553
|
+
if (stampedHead !== undefined && !identity.head.startsWith(stampedHead)) {
|
|
2554
|
+
const reset = writeUiPassLine(priorBody, {
|
|
2555
|
+
kind: 'reset',
|
|
2556
|
+
head: identity.head,
|
|
2557
|
+
})
|
|
2558
|
+
uiPass = (await patchPullBody(root, identity.number, reset))
|
|
2559
|
+
? 'reset'
|
|
2560
|
+
: 'reset-refused'
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2440
2563
|
logStep('Scope')
|
|
2441
2564
|
logInfo(
|
|
2442
2565
|
`${plural(caseCount, 'case')} across ${plural(states.length, 'state')}`,
|
|
2443
2566
|
)
|
|
2567
|
+
if (uiPass === 'reset') logInfo('UI pass line reset to the new head')
|
|
2568
|
+
if (uiPass === 'reset-refused') {
|
|
2569
|
+
logWarn('gh could not edit the body, so its UI pass line names an old head')
|
|
2570
|
+
}
|
|
2444
2571
|
if (sized.unread.length > 0) {
|
|
2445
2572
|
logWarn(
|
|
2446
2573
|
`width unread for ${plural(sized.unread.length, 'image')}, rendered at 1000: ${sized.unread.join(', ')}`,
|
|
@@ -2469,6 +2596,7 @@ async function runEvidence(
|
|
|
2469
2596
|
body,
|
|
2470
2597
|
states,
|
|
2471
2598
|
...(sized.unread.length > 0 && { widthUnread: sized.unread }),
|
|
2599
|
+
...(uiPass !== undefined && { uiPass }),
|
|
2472
2600
|
...(commentId !== undefined && { commentId }),
|
|
2473
2601
|
...carried,
|
|
2474
2602
|
})}\n`,
|
|
@@ -2929,6 +3057,32 @@ async function runTick(
|
|
|
2929
3057
|
const result = tickBoxes(marked.body, numbers.map(Number), reading.tip)
|
|
2930
3058
|
if (result.kind === 'refused') return refuseTick(result.reason)
|
|
2931
3059
|
|
|
3060
|
+
// Read ahead of the write so a short list refuses before it leaves the
|
|
3061
|
+
// comment half ticked. A verdict that does not cover the tip, carries no
|
|
3062
|
+
// count line, or cannot be read leaves the check unrun and says so.
|
|
3063
|
+
const ticked = countTicksAtHead(
|
|
3064
|
+
readChecklistBoxes(findEvidenceChecklist([{ body: result.body }]) ?? ''),
|
|
3065
|
+
reading.tip,
|
|
3066
|
+
)
|
|
3067
|
+
const reviews = await listReviews(root, String(identity.number))
|
|
3068
|
+
const verdict = resolveUiScope({
|
|
3069
|
+
reviews: reviews.kind === 'read' ? reviews.rows.map(reviewRowOf) : [],
|
|
3070
|
+
})
|
|
3071
|
+
const isCounted =
|
|
3072
|
+
verdict.counts !== undefined &&
|
|
3073
|
+
verdict.commit !== undefined &&
|
|
3074
|
+
coversHead(verdict.commit, reading.tip)
|
|
3075
|
+
if (isCounted && verdict.counts.passed !== ticked) {
|
|
3076
|
+
return refuseWith(
|
|
3077
|
+
'count-mismatch',
|
|
3078
|
+
TICK_REFUSALS['count-mismatch'],
|
|
3079
|
+
emitJson,
|
|
3080
|
+
root,
|
|
3081
|
+
{ passed: verdict.counts.passed, ticked },
|
|
3082
|
+
)
|
|
3083
|
+
}
|
|
3084
|
+
const countCheck = isCounted ? 'matched' : 'unchecked'
|
|
3085
|
+
|
|
2932
3086
|
if (result.body !== marked.body) {
|
|
2933
3087
|
const patched = await gh(root, [
|
|
2934
3088
|
'api',
|
|
@@ -2941,9 +3095,47 @@ async function runTick(
|
|
|
2941
3095
|
if (patched === null) return refuseTick('gh-failed')
|
|
2942
3096
|
}
|
|
2943
3097
|
|
|
3098
|
+
const boxes = readChecklistBoxes(
|
|
3099
|
+
findEvidenceChecklist([{ body: result.body }]) ?? '',
|
|
3100
|
+
)
|
|
3101
|
+
const driven = boxes.filter((box) => !box.isTaste)
|
|
3102
|
+
const summary: UiPassSummary = {
|
|
3103
|
+
kind: 'passed',
|
|
3104
|
+
passed: countTicksAtHead(boxes, reading.tip),
|
|
3105
|
+
total: driven.length,
|
|
3106
|
+
taste: boxes.length - driven.length,
|
|
3107
|
+
head: reading.tip,
|
|
3108
|
+
}
|
|
3109
|
+
const priorBody = identity.body ?? ''
|
|
3110
|
+
const nextBody = writeUiPassLine(priorBody, summary)
|
|
3111
|
+
if (
|
|
3112
|
+
nextBody !== priorBody &&
|
|
3113
|
+
!(await patchPullBody(root, identity.number, nextBody))
|
|
3114
|
+
) {
|
|
3115
|
+
logStep('Partly ticked')
|
|
3116
|
+
logWarn(TICK_REFUSALS['body-refused'])
|
|
3117
|
+
outro()
|
|
3118
|
+
if (emitJson) {
|
|
3119
|
+
process.stdout.write(
|
|
3120
|
+
`${JSON.stringify({
|
|
3121
|
+
root,
|
|
3122
|
+
number: identity.number,
|
|
3123
|
+
reason: 'body-refused',
|
|
3124
|
+
message: TICK_REFUSALS['body-refused'],
|
|
3125
|
+
commentTicked: true,
|
|
3126
|
+
countCheck,
|
|
3127
|
+
head: reading.tip,
|
|
3128
|
+
boxes: numbers.map(Number),
|
|
3129
|
+
commentId,
|
|
3130
|
+
})}\n`,
|
|
3131
|
+
)
|
|
3132
|
+
}
|
|
3133
|
+
return 1
|
|
3134
|
+
}
|
|
3135
|
+
|
|
2944
3136
|
logStep('Ticked')
|
|
2945
3137
|
logInfo(
|
|
2946
|
-
`${plural(numbers.length, 'box')} at ${reading.tip.slice(0, 7)} on comment ${commentId}`,
|
|
3138
|
+
`${plural(numbers.length, 'box')} at ${reading.tip.slice(0, 7)} on comment ${commentId}, UI pass line on the body`,
|
|
2947
3139
|
)
|
|
2948
3140
|
outro()
|
|
2949
3141
|
if (emitJson) {
|
|
@@ -2952,6 +3144,7 @@ async function runTick(
|
|
|
2952
3144
|
root,
|
|
2953
3145
|
number: identity.number,
|
|
2954
3146
|
reason: 'ticked',
|
|
3147
|
+
countCheck,
|
|
2955
3148
|
head: reading.tip,
|
|
2956
3149
|
boxes: numbers.map(Number),
|
|
2957
3150
|
commentId,
|
|
@@ -2961,6 +3154,23 @@ async function runTick(
|
|
|
2961
3154
|
return 0
|
|
2962
3155
|
}
|
|
2963
3156
|
|
|
3157
|
+
/** Replaces a pull request's body, which `gh` takes through the REST pulls endpoint a cloud proxy serves. */
|
|
3158
|
+
async function patchPullBody(
|
|
3159
|
+
root: string,
|
|
3160
|
+
number: number,
|
|
3161
|
+
body: string,
|
|
3162
|
+
): Promise<boolean> {
|
|
3163
|
+
const patched = await gh(root, [
|
|
3164
|
+
'api',
|
|
3165
|
+
'-X',
|
|
3166
|
+
'PATCH',
|
|
3167
|
+
`repos/{owner}/{repo}/pulls/${number}`,
|
|
3168
|
+
'-f',
|
|
3169
|
+
`body=${body}`,
|
|
3170
|
+
])
|
|
3171
|
+
return patched !== null
|
|
3172
|
+
}
|
|
3173
|
+
|
|
2964
3174
|
/** One `gh api` call, keeping the HTTP status of a failure so a refused write can be told from a missing ref. */
|
|
2965
3175
|
type ApiCall =
|
|
2966
3176
|
| { readonly ok: true; readonly data: unknown }
|
|
@@ -3334,6 +3544,8 @@ const LIFT_REFUSALS: Record<LiftRefusal | 'bad-wait' | 'gh-missing', string> = {
|
|
|
3334
3544
|
'The newest UI review carries no head marker, so the commit it covers is a GitHub stamp this gate does not trust.',
|
|
3335
3545
|
'ui-behind':
|
|
3336
3546
|
'The newest UI review closed on an earlier commit than the head.',
|
|
3547
|
+
'ui-undriven':
|
|
3548
|
+
'The newest UI review closed having driven no box, so it looked at nothing. Re-drive the checklist on an address that answers.',
|
|
3337
3549
|
'evidence-owed':
|
|
3338
3550
|
'The evidence check reads owed, so the ship chain left a step undone.',
|
|
3339
3551
|
'standing-stale':
|
|
@@ -3455,6 +3667,34 @@ async function readEvidenceGate(
|
|
|
3455
3667
|
}
|
|
3456
3668
|
}
|
|
3457
3669
|
|
|
3670
|
+
async function readChecksGate(
|
|
3671
|
+
root: string,
|
|
3672
|
+
number: string,
|
|
3673
|
+
tip: string,
|
|
3674
|
+
): Promise<ChecksGate | undefined> {
|
|
3675
|
+
const [listed, pulled] = await Promise.all([
|
|
3676
|
+
gh(root, [
|
|
3677
|
+
'api',
|
|
3678
|
+
`repos/{owner}/{repo}/commits/${tip}/check-runs?per_page=100`,
|
|
3679
|
+
]),
|
|
3680
|
+
gh(root, ['api', `repos/{owner}/{repo}/pulls/${number}`]),
|
|
3681
|
+
])
|
|
3682
|
+
if (listed === null) return undefined
|
|
3683
|
+
try {
|
|
3684
|
+
const mergeState =
|
|
3685
|
+
pulled === null
|
|
3686
|
+
? undefined
|
|
3687
|
+
: mergeStateOf((JSON.parse(pulled) as RestPull).mergeable_state)
|
|
3688
|
+
const reading = withMergeState(
|
|
3689
|
+
mergeState,
|
|
3690
|
+
collapseChecks(tip, JSON.parse(listed) as CheckRunListing),
|
|
3691
|
+
)
|
|
3692
|
+
return { state: reading.state, isConflicted: reading.conflicted }
|
|
3693
|
+
} catch {
|
|
3694
|
+
return undefined
|
|
3695
|
+
}
|
|
3696
|
+
}
|
|
3697
|
+
|
|
3458
3698
|
function liftApi(root: string, number: string, baseRef: string): LiftApi {
|
|
3459
3699
|
const git = gitRunner(root)
|
|
3460
3700
|
|
|
@@ -3492,29 +3732,7 @@ function liftApi(root: string, number: string, baseRef: string): LiftApi {
|
|
|
3492
3732
|
readEvidence: () => readEvidenceGate(root, number),
|
|
3493
3733
|
readStanding: (head) =>
|
|
3494
3734
|
readBaseStanding(git, Number(number), head, baseRef),
|
|
3495
|
-
readChecks:
|
|
3496
|
-
const [listed, pulled] = await Promise.all([
|
|
3497
|
-
gh(root, [
|
|
3498
|
-
'api',
|
|
3499
|
-
`repos/{owner}/{repo}/commits/${tip}/check-runs?per_page=100`,
|
|
3500
|
-
]),
|
|
3501
|
-
gh(root, ['api', `repos/{owner}/{repo}/pulls/${number}`]),
|
|
3502
|
-
])
|
|
3503
|
-
if (listed === null) return undefined
|
|
3504
|
-
try {
|
|
3505
|
-
const mergeState =
|
|
3506
|
-
pulled === null
|
|
3507
|
-
? undefined
|
|
3508
|
-
: mergeStateOf((JSON.parse(pulled) as RestPull).mergeable_state)
|
|
3509
|
-
const reading = withMergeState(
|
|
3510
|
-
mergeState,
|
|
3511
|
-
collapseChecks(tip, JSON.parse(listed) as CheckRunListing),
|
|
3512
|
-
)
|
|
3513
|
-
return { state: reading.state, isConflicted: reading.conflicted }
|
|
3514
|
-
} catch {
|
|
3515
|
-
return undefined
|
|
3516
|
-
}
|
|
3517
|
-
},
|
|
3735
|
+
readChecks: (tip) => readChecksGate(root, number, tip),
|
|
3518
3736
|
markReady: (nodeId) =>
|
|
3519
3737
|
flipDraft(
|
|
3520
3738
|
root,
|
|
@@ -3616,6 +3834,160 @@ async function runLift(
|
|
|
3616
3834
|
return record.isOk ? 0 : 1
|
|
3617
3835
|
}
|
|
3618
3836
|
|
|
3837
|
+
const MERGE_REFUSALS: Record<MergeRefusal | 'gh-missing', string> = {
|
|
3838
|
+
'gh-missing': PULL_REFUSALS['gh-missing'],
|
|
3839
|
+
'pull-closed': 'The pull request is closed, so there is nothing to merge.',
|
|
3840
|
+
'pull-draft':
|
|
3841
|
+
'The pull request is a draft, so its reviews have not lifted the mark.',
|
|
3842
|
+
'missing-summary':
|
|
3843
|
+
'The body carries no `## Summary` with text under it, so the description has no change to state.',
|
|
3844
|
+
'missing-key-changes':
|
|
3845
|
+
'The body carries no `## Key changes` with text under it, so the description has no change to state.',
|
|
3846
|
+
'ci-failing': 'A check run on the head failed.',
|
|
3847
|
+
'ci-conflicted': 'The branch conflicts with its base.',
|
|
3848
|
+
'ci-pending':
|
|
3849
|
+
'A check run on the head has yet to conclude. Run the verb again once it does.',
|
|
3850
|
+
'head-moved':
|
|
3851
|
+
'The head moved after the read, so GitHub refused rather than merging unread commits.',
|
|
3852
|
+
'not-mergeable': 'GitHub answered that the pull request is not mergeable.',
|
|
3853
|
+
'gh-failed':
|
|
3854
|
+
'A read or the merge was refused, so nothing is reported as merged.',
|
|
3855
|
+
}
|
|
3856
|
+
|
|
3857
|
+
/**
|
|
3858
|
+
* The REST merge, never `gh pr merge`, which runs on the GraphQL a cloud
|
|
3859
|
+
* session's proxy refuses. gh reports the status only as the `(HTTP <code>)`
|
|
3860
|
+
* tail of its stderr, so 409 and 405 are read off it.
|
|
3861
|
+
*/
|
|
3862
|
+
async function putSquashMerge(
|
|
3863
|
+
root: string,
|
|
3864
|
+
number: string,
|
|
3865
|
+
request: MergeRequest,
|
|
3866
|
+
): Promise<MergeOutcome> {
|
|
3867
|
+
const result = await execa(
|
|
3868
|
+
'gh',
|
|
3869
|
+
[
|
|
3870
|
+
'api',
|
|
3871
|
+
'-X',
|
|
3872
|
+
'PUT',
|
|
3873
|
+
`repos/{owner}/{repo}/pulls/${number}/merge`,
|
|
3874
|
+
'-f',
|
|
3875
|
+
`commit_title=${request.title}`,
|
|
3876
|
+
'-f',
|
|
3877
|
+
`commit_message=${request.description}`,
|
|
3878
|
+
'-f',
|
|
3879
|
+
`sha=${request.sha}`,
|
|
3880
|
+
'-f',
|
|
3881
|
+
'merge_method=squash',
|
|
3882
|
+
],
|
|
3883
|
+
{
|
|
3884
|
+
cwd: root,
|
|
3885
|
+
reject: false,
|
|
3886
|
+
timeout: GH_TIMEOUT_MS,
|
|
3887
|
+
env: gitEnv(),
|
|
3888
|
+
extendEnv: false,
|
|
3889
|
+
},
|
|
3890
|
+
)
|
|
3891
|
+
if (result.exitCode !== 0) {
|
|
3892
|
+
const status = /\(HTTP (\d{3})\)/.exec(String(result.stderr))?.[1]
|
|
3893
|
+
if (status === '409') return { kind: 'head-moved' }
|
|
3894
|
+
if (status === '405') return { kind: 'not-mergeable' }
|
|
3895
|
+
return { kind: 'failed' }
|
|
3896
|
+
}
|
|
3897
|
+
try {
|
|
3898
|
+
const body = JSON.parse(String(result.stdout)) as {
|
|
3899
|
+
merged?: unknown
|
|
3900
|
+
sha?: unknown
|
|
3901
|
+
}
|
|
3902
|
+
return body.merged === true && typeof body.sha === 'string'
|
|
3903
|
+
? { kind: 'merged', sha: body.sha }
|
|
3904
|
+
: { kind: 'failed' }
|
|
3905
|
+
} catch {
|
|
3906
|
+
return { kind: 'failed' }
|
|
3907
|
+
}
|
|
3908
|
+
}
|
|
3909
|
+
|
|
3910
|
+
function mergeApi(root: string, number: string): MergeApi {
|
|
3911
|
+
return {
|
|
3912
|
+
readPull: async () => {
|
|
3913
|
+
const stdout = await gh(root, [
|
|
3914
|
+
'api',
|
|
3915
|
+
`repos/{owner}/{repo}/pulls/${number}`,
|
|
3916
|
+
])
|
|
3917
|
+
if (stdout === null) return undefined
|
|
3918
|
+
try {
|
|
3919
|
+
const row = JSON.parse(stdout) as RestPull
|
|
3920
|
+
if (row.head?.sha === undefined || typeof row.title !== 'string') {
|
|
3921
|
+
return undefined
|
|
3922
|
+
}
|
|
3923
|
+
return {
|
|
3924
|
+
isOpen: row.state === 'open',
|
|
3925
|
+
isDraft: row.draft === true,
|
|
3926
|
+
title: row.title,
|
|
3927
|
+
body: row.body ?? '',
|
|
3928
|
+
head: row.head.sha,
|
|
3929
|
+
}
|
|
3930
|
+
} catch {
|
|
3931
|
+
return undefined
|
|
3932
|
+
}
|
|
3933
|
+
},
|
|
3934
|
+
readChecks: (head) => readChecksGate(root, number, head),
|
|
3935
|
+
putMerge: (request) => putSquashMerge(root, number, request),
|
|
3936
|
+
}
|
|
3937
|
+
}
|
|
3938
|
+
|
|
3939
|
+
async function runMerge(
|
|
3940
|
+
number: string | undefined,
|
|
3941
|
+
opts: MergeCommandOptions,
|
|
3942
|
+
): Promise<number> {
|
|
3943
|
+
const root = resolve(opts.root ?? process.cwd())
|
|
3944
|
+
const emitJson = opts.json ?? false
|
|
3945
|
+
|
|
3946
|
+
intro('canon pr merge')
|
|
3947
|
+
|
|
3948
|
+
if (Bun.which('gh') === null) {
|
|
3949
|
+
return refuseWith(
|
|
3950
|
+
'gh-missing',
|
|
3951
|
+
MERGE_REFUSALS['gh-missing'],
|
|
3952
|
+
emitJson,
|
|
3953
|
+
root,
|
|
3954
|
+
)
|
|
3955
|
+
}
|
|
3956
|
+
|
|
3957
|
+
const resolved = await resolvePullNumber(root, number)
|
|
3958
|
+
if (resolved.kind === 'refused') {
|
|
3959
|
+
return refuseWith(
|
|
3960
|
+
resolved.reason,
|
|
3961
|
+
PULL_REFUSALS[resolved.reason],
|
|
3962
|
+
emitJson,
|
|
3963
|
+
root,
|
|
3964
|
+
)
|
|
3965
|
+
}
|
|
3966
|
+
|
|
3967
|
+
const record = await merge(mergeApi(root, resolved.number), {
|
|
3968
|
+
number: Number(resolved.number),
|
|
3969
|
+
isDryRun: opts.dryRun ?? false,
|
|
3970
|
+
})
|
|
3971
|
+
|
|
3972
|
+
logStep(record.reason)
|
|
3973
|
+
logInfo(
|
|
3974
|
+
record.reason === 'merged'
|
|
3975
|
+
? `#${record.number} squash-merged as ${record.sha?.slice(0, 8)}`
|
|
3976
|
+
: record.reason === 'dry-run'
|
|
3977
|
+
? `#${record.number} would merge with the message below`
|
|
3978
|
+
: MERGE_REFUSALS[record.reason],
|
|
3979
|
+
)
|
|
3980
|
+
outro()
|
|
3981
|
+
|
|
3982
|
+
if (emitJson) {
|
|
3983
|
+
process.stdout.write(`${JSON.stringify({ root, ...record })}\n`)
|
|
3984
|
+
} else if (record.reason === 'dry-run') {
|
|
3985
|
+
process.stdout.write(`${record.title}\n\n${record.description}\n`)
|
|
3986
|
+
}
|
|
3987
|
+
|
|
3988
|
+
return record.isOk ? 0 : 1
|
|
3989
|
+
}
|
|
3990
|
+
|
|
3619
3991
|
/**
|
|
3620
3992
|
* Frames a refusal on stderr in both modes and puts the record on stdout alone,
|
|
3621
3993
|
* so an operator reading the terminal sees the reason rather than a command
|
|
@@ -3630,13 +4002,16 @@ function refuseWith(
|
|
|
3630
4002
|
message: string,
|
|
3631
4003
|
emitJson: boolean,
|
|
3632
4004
|
root: string,
|
|
4005
|
+
extra: Record<string, unknown> = {},
|
|
3633
4006
|
): number {
|
|
3634
4007
|
logStep('Refused')
|
|
3635
4008
|
logWarn(message)
|
|
3636
4009
|
outro()
|
|
3637
4010
|
|
|
3638
4011
|
if (emitJson) {
|
|
3639
|
-
process.stdout.write(
|
|
4012
|
+
process.stdout.write(
|
|
4013
|
+
`${JSON.stringify({ root, reason, message, ...extra })}\n`,
|
|
4014
|
+
)
|
|
3640
4015
|
}
|
|
3641
4016
|
return 1
|
|
3642
4017
|
}
|
package/src/commands/sessions.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Command } from 'commander'
|
|
|
3
3
|
import { checkClaim, type ClaimReport } from '@/sessions/claim'
|
|
4
4
|
import {
|
|
5
5
|
callerIdentity,
|
|
6
|
+
confidenceOf,
|
|
6
7
|
repositoryOf,
|
|
7
8
|
type ResolvedSession,
|
|
8
9
|
resolveSessions,
|
|
@@ -134,15 +135,21 @@ export function register(program: Command): void {
|
|
|
134
135
|
'',
|
|
135
136
|
'The confidence field says how the liveness of a row was decided.',
|
|
136
137
|
'"confirmed" matched the running process against the start time the',
|
|
137
|
-
'record stamped. "unverified" means only that the
|
|
138
|
-
'cannot rule out a pid handed to an unrelated
|
|
139
|
-
'reported that way is a candidate list rather than
|
|
138
|
+
'record stamped, and reads "via": "pid". "unverified" means only that the',
|
|
139
|
+
'pid answers, which cannot rule out a pid handed to an unrelated',
|
|
140
|
+
'process, so a roster reported that way is a candidate list rather than',
|
|
141
|
+
'an identity. Where the start time cannot be read, a row whose messaging',
|
|
142
|
+
'socket accepts a connection is confirmed instead and reads "via":',
|
|
143
|
+
'"listener". The probe connects and closes without writing. Each row',
|
|
144
|
+
'carries its own "confidence", and the top-level field is the weakest of',
|
|
145
|
+
'the rows listed, so --branch is not weakened by a row it dropped.',
|
|
140
146
|
'',
|
|
141
147
|
'A reader in another pid namespace, such as a sandboxed shell, cannot',
|
|
142
148
|
'see the pid of a record stamped elsewhere. That row is judged on its',
|
|
143
149
|
'messaging socket instead: it stays on the roster as "unverified" with',
|
|
144
150
|
'"via": "socket", since a crashed session leaves its socket behind. A',
|
|
145
|
-
'foreign record whose socket
|
|
151
|
+
'foreign record whose socket accepts a connection is confirmed as',
|
|
152
|
+
'"listener". One whose socket is gone is left off the roster and',
|
|
146
153
|
'counted in "foreignDead", so an empty roster beside a count above zero',
|
|
147
154
|
'is one this reader could not check, not one whose sessions ended.',
|
|
148
155
|
'',
|
|
@@ -471,7 +478,7 @@ async function runList(opts: ListCommandOptions): Promise<number> {
|
|
|
471
478
|
: null
|
|
472
479
|
|
|
473
480
|
intro('canon sessions list')
|
|
474
|
-
reportConfidence(report)
|
|
481
|
+
reportConfidence(shown, report)
|
|
475
482
|
reportSessions(
|
|
476
483
|
shown,
|
|
477
484
|
opts.branch,
|
|
@@ -486,7 +493,8 @@ async function runList(opts: ListCommandOptions): Promise<number> {
|
|
|
486
493
|
process.stdout.write(
|
|
487
494
|
`${JSON.stringify({
|
|
488
495
|
dir: report.dir,
|
|
489
|
-
|
|
496
|
+
// Over the rows shown, so an unrelated row --branch dropped cannot weaken it.
|
|
497
|
+
confidence: confidenceOf(shown),
|
|
490
498
|
foreignDead: report.foreignDead,
|
|
491
499
|
branch: opts.branch ?? null,
|
|
492
500
|
repository,
|
|
@@ -528,6 +536,7 @@ function selfRefusal(own: Extract<SelfReport, { kind: 'unresolved' }>): string {
|
|
|
528
536
|
* addresses a session directly and the second opens by asking to be corrected.
|
|
529
537
|
*/
|
|
530
538
|
function reportConfidence(
|
|
539
|
+
shown: readonly ResolvedSession[],
|
|
531
540
|
report: Extract<SessionReport, { kind: 'resolved' }>,
|
|
532
541
|
): void {
|
|
533
542
|
logStep('Liveness')
|
|
@@ -535,23 +544,28 @@ function reportConfidence(
|
|
|
535
544
|
// A roster of none decided nothing, so the confirmed line would claim a check
|
|
536
545
|
// over rows that do not exist. The registry is never pruned and holds a record
|
|
537
546
|
// per session ever run, which is what makes the empty result worth stating.
|
|
538
|
-
if (
|
|
547
|
+
if (shown.length === 0) {
|
|
539
548
|
logInfo(
|
|
540
|
-
report.foreignDead > 0
|
|
549
|
+
report.foreignDead > 0 || report.sessions.length > 0
|
|
541
550
|
? 'No row to decide.'
|
|
542
551
|
: 'No row to decide. Every record belongs to a session that has ended.',
|
|
543
552
|
)
|
|
544
553
|
return
|
|
545
554
|
}
|
|
546
555
|
|
|
547
|
-
if (
|
|
556
|
+
if (confidenceOf(shown) === 'confirmed') {
|
|
557
|
+
const listened = shown.filter(
|
|
558
|
+
(session) => session.via === 'listener',
|
|
559
|
+
).length
|
|
548
560
|
logInfo(
|
|
549
|
-
|
|
561
|
+
listened === 0
|
|
562
|
+
? 'Every row matched a running process against the start time its record stamped.'
|
|
563
|
+
: `Every row is confirmed, ${listened} of them because their messaging socket accepted a connection and the rest by start time.`,
|
|
550
564
|
)
|
|
551
565
|
return
|
|
552
566
|
}
|
|
553
567
|
|
|
554
|
-
if (
|
|
568
|
+
if (shown.some((session) => session.via === 'socket')) {
|
|
555
569
|
logWarn(
|
|
556
570
|
'This reader sits in another pid namespace than some records, so those rows rest on a messaging socket existing alone. A crashed session leaves its socket behind, so treat the mapping as inferred and open by asking to be corrected.',
|
|
557
571
|
)
|