@erclx/canon 5.33.0 → 5.35.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/README.md +3 -5
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/REQUIREMENT.md +2 -3
- package/claude/skills/api-design/SKILL.md +2 -3
- package/claude/skills/api-design/references/adopted.md +1 -1
- package/claude/skills/auto-ship/references/draft-mark.md +1 -1
- package/claude/skills/canon-cli/SKILL.md +10 -11
- package/claude/skills/canon-feedback/REQUIREMENT.md +1 -0
- package/claude/skills/canon-feedback/SKILL.md +10 -28
- package/claude/skills/canon-feedback/references/template.md +53 -0
- package/claude/skills/canvas-board/SKILL.md +4 -1
- package/claude/skills/canvas-board/references/canvas.md +26 -3
- package/claude/skills/code-craft/REQUIREMENT.md +1 -1
- package/claude/skills/code-craft/SKILL.md +0 -1
- package/claude/skills/create-rule/SKILL.md +1 -1
- package/claude/skills/create-rule/references/rule.md +3 -1
- package/claude/skills/create-skill/SKILL.md +2 -1
- package/claude/skills/create-skill/references/skill-paths.md +12 -15
- package/{standards/standard.md → claude/skills/create-skill/references/skill-reference.md} +50 -45
- package/claude/skills/create-skill/references/skill.md +10 -4
- package/claude/skills/draft-and-pick/SKILL.md +1 -1
- package/claude/skills/git-commit/references/versioning.md +13 -13
- package/claude/skills/git-issue/references/issue.md +4 -1
- package/claude/skills/git-pr/SKILL.md +1 -1
- package/claude/skills/git-pr/references/publish.md +1 -1
- package/claude/skills/review-address/REQUIREMENT.md +1 -1
- package/claude/skills/review-address/SKILL.md +2 -2
- package/claude/skills/review-craft/references/ui.md +1 -1
- package/claude/skills/review-pr/SKILL.md +3 -3
- package/claude/skills/review-pr/references/post.md +1 -1
- package/claude/skills/review-ui/REQUIREMENT.md +1 -1
- package/claude/skills/review-ui/SKILL.md +8 -6
- package/claude/skills/review-ui/references/driving.md +8 -8
- package/claude/skills/review-ui/references/post.md +52 -16
- package/claude/skills/role-orchestrator/REQUIREMENT.md +2 -2
- package/claude/skills/role-orchestrator/SKILL.md +11 -12
- package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +3 -1
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +11 -8
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +10 -4
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +11 -2
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +11 -12
- package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +12 -15
- package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +6 -4
- package/claude/skills/role-orchestrator/scripts/poll.ts +50 -1
- package/claude/skills/role-reviewer/REQUIREMENT.md +17 -7
- package/claude/skills/role-reviewer/SKILL.md +17 -10
- package/claude/skills/role-worker/REQUIREMENT.md +1 -1
- package/claude/skills/role-worker/SKILL.md +6 -6
- package/claude/skills/session-compact/references/handoff-note.md +1 -1
- package/claude/skills/target-setup/SKILL.md +12 -6
- package/claude/skills/target-setup/references/detect.md +22 -5
- package/claude/skills/test-craft/REQUIREMENT.md +2 -3
- package/claude/skills/test-craft/SKILL.md +15 -3
- package/docs/agents/audits.md +1 -1
- package/docs/agents/canvas.md +5 -1
- package/docs/agents/commands.md +3 -5
- package/docs/agents/counts.md +5 -6
- package/docs/agents/drift-surfaces.md +15 -19
- package/docs/agents/gov-detect.md +47 -0
- package/docs/agents/index.md +6 -4
- package/docs/agents/install-and-sync.md +11 -31
- package/docs/agents/markdown-audit.md +10 -12
- package/docs/agents/migrate.md +1 -1
- package/docs/agents/pr-evidence-layout.md +70 -0
- package/docs/agents/pr-evidence.md +8 -9
- package/docs/agents/pr-frames.md +7 -2
- package/docs/agents/pr-lift.md +132 -0
- package/docs/agents/records-stale.md +1 -1
- package/docs/agents/records-validate.md +11 -23
- package/docs/agents/records.md +1 -1
- package/docs/agents/routing.md +1 -1
- package/docs/agents/rule-citations.md +11 -14
- package/docs/agents/scripting.md +4 -21
- package/docs/agents/state-scoped-risk.md +1 -1
- package/docs/agents/tasks-validate.md +1 -1
- package/docs/agents/teach.md +1 -1
- package/docs/target/index.md +1 -0
- package/docs/target/migrations.md +4 -0
- package/docs/target/projects.md +11 -11
- package/docs/target/scenarios.md +5 -6
- package/docs/target/standards-moved.md +32 -0
- package/docs/target/sync.md +5 -5
- package/docs/workflow/operating-model.md +16 -18
- package/docs/workflow/skill-map.md +1 -1
- package/governance/detect.toml +197 -0
- package/governance/renames.toml +0 -1
- package/governance/rules/code/000-code.md +1 -13
- package/governance/rules/code/010-testing.md +2 -30
- package/governance/rules/ui/400-ui.md +1 -1
- package/governance/rules/ui/480-canvas.md +13 -0
- package/governance/stacks/base.toml +15 -8
- package/governance/stacks/go.toml +2 -0
- package/governance/stacks/node.toml +1 -1
- package/governance/stacks/php.toml +2 -0
- package/governance/stacks/python.toml +1 -1
- package/package.json +1 -4
- package/scripts/core/install-check.sh +2 -3
- package/scripts/core/regen-hero.ts +0 -6
- package/scripts/lib/ui.sh +0 -9
- package/src/audits/catalog.ts +0 -9
- package/src/canvas/arrange.ts +66 -0
- package/src/canvas/capture.ts +20 -6
- package/src/canvas/content.ts +39 -0
- package/src/canvas/fit.ts +238 -0
- package/src/canvas/tokens.ts +28 -10
- package/src/capture/frame-values.ts +2 -32
- package/src/claude/routing.ts +1 -1
- package/src/claude/skills-reach.ts +4 -6
- package/src/cli/help.ts +3 -4
- package/src/cli.ts +0 -2
- package/src/commands/canvas.ts +179 -1
- package/src/commands/gov.ts +149 -10
- package/src/commands/init.ts +1 -1
- package/src/commands/labels.ts +4 -44
- package/src/commands/migrate.ts +1 -1
- package/src/commands/pr.ts +376 -1
- package/src/commands/records.ts +4 -20
- package/src/commands/tooling.ts +2 -4
- package/src/counts/catalogs.ts +0 -6
- package/src/feedback/body.ts +26 -5
- package/src/gate/boundaries.ts +2 -2
- package/src/gate/measures.ts +64 -72
- package/src/gate/sequencer.ts +51 -2
- package/src/gate/stages.ts +3 -22
- package/src/gov/citations.ts +11 -43
- package/src/gov/detect.ts +301 -0
- package/src/gov/list.ts +23 -2
- package/src/gov/stacks.ts +9 -0
- package/src/markdown/bans.ts +1 -1
- package/src/migrate/scratch-evidence.ts +2 -2
- package/src/pr/evidence.ts +218 -45
- package/src/pr/frames.ts +23 -3
- package/src/pr/lift.ts +418 -0
- package/src/pr/rest.ts +3 -0
- package/src/pr/review-scope.ts +76 -0
- package/src/records/validate.ts +6 -194
- package/src/sandbox/equivalence.ts +14 -7
- package/src/sandbox/provision.ts +0 -5
- package/src/secrets/shipped.ts +1 -2
- package/src/serve/static.ts +1 -2
- package/src/shipped/references.ts +29 -80
- package/src/sync/check.ts +18 -14
- package/src/tooling/inject.ts +6 -37
- package/src/tooling/read.ts +2 -3
- package/src/tooling/scan.ts +4 -17
- package/src/tooling/subfolder.ts +2 -66
- package/tooling/base/configs/scripts/verify.json +1 -1
- package/tooling/base/configs/scripts/verify.ts +1 -12
- package/tooling/base/manifest.toml +1 -2
- package/tooling/base/reference.md +10 -16
- package/tooling/base/seeds/.github/workflows/verify.yml +0 -3
- package/tooling/base/seeds/.lintstagedrc +1 -2
- package/tooling/base/seeds/canon/context/ci.md +0 -1
- package/tooling/base/seeds/canon/context/development.md +1 -1
- package/tooling/claude/reference.md +1 -1
- package/tooling/claude/seeds/.claude/hooks/review-post-scan.sh +13 -1
- package/tooling/go/reference.md +1 -1
- package/tooling/php/reference.md +1 -1
- package/tooling/python/reference.md +2 -2
- package/tooling/web/configs/.github/workflows/verify.yml +0 -3
- package/tooling/web/manifest.toml +0 -1
- package/tooling/web/reference.md +2 -4
- package/docs/agents/standards-audit.md +0 -40
- package/governance/rules/tooling/765-spelling.md +0 -19
- package/governance/stacks/astro.toml +0 -2
- package/governance/stacks/nextjs.toml +0 -2
- package/governance/stacks/node-server.toml +0 -2
- package/governance/stacks/python-fastapi.toml +0 -2
- package/governance/stacks/react.toml +0 -2
- package/src/commands/standards.ts +0 -278
- package/src/labels/spelling.ts +0 -120
- package/src/standards/audit.ts +0 -146
- package/src/standards/list.ts +0 -33
- package/src/standards/read.ts +0 -177
- package/src/standards/scope.ts +0 -56
- package/standards/index.md +0 -10
- package/tooling/base/configs/.vscode/extensions.json +0 -9
- package/tooling/base/configs/.vscode/settings.json +0 -3
- package/tooling/base/seeds/.cspell/project-terms.txt +0 -0
- package/tooling/base/seeds/.cspell/tech-stack.txt +0 -25
- package/tooling/base/seeds/cspell.json +0 -20
- package/tooling/go/seeds/.cspell/tech-stack.txt +0 -16
- package/tooling/nextjs/seeds/.cspell/tech-stack.txt +0 -3
- package/tooling/php/seeds/.cspell/tech-stack.txt +0 -5
- package/tooling/python/seeds/.cspell/tech-stack.txt +0 -18
- package/tooling/vite-react/seeds/.cspell/project-terms.txt +0 -1
- package/tooling/vite-react/seeds/.cspell/tech-stack.txt +0 -2
- package/tooling/web/configs/.vscode/extensions.json +0 -13
- package/tooling/web/configs/.vscode/settings.json +0 -10
- package/tooling/web/seeds/.cspell/tech-stack.txt +0 -21
package/src/pr/lift.ts
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import type { ChecksState } from '@/targets/pulls'
|
|
2
|
+
import {
|
|
3
|
+
coversHead,
|
|
4
|
+
type ReviewRow,
|
|
5
|
+
resolveReviewScope,
|
|
6
|
+
resolveUiScope,
|
|
7
|
+
} from '@/pr/review-scope'
|
|
8
|
+
|
|
9
|
+
/** How long `--wait` holds for a pending CI run, in seconds, when none is named. */
|
|
10
|
+
export const DEFAULT_WAIT_SECONDS = 1200
|
|
11
|
+
|
|
12
|
+
/** The gap between two reads of the check runs while the verb waits on them. */
|
|
13
|
+
export const CHECKS_POLL_MS = 30_000
|
|
14
|
+
|
|
15
|
+
/** Why the verb did not lift, in the order the gates are read. */
|
|
16
|
+
export type LiftRefusal =
|
|
17
|
+
| 'pull-closed'
|
|
18
|
+
| 'review-missing'
|
|
19
|
+
| 'review-open'
|
|
20
|
+
| 'review-unmarked'
|
|
21
|
+
| 'review-behind'
|
|
22
|
+
| 'ui-missing'
|
|
23
|
+
| 'ui-open'
|
|
24
|
+
| 'ui-unmarked'
|
|
25
|
+
| 'ui-behind'
|
|
26
|
+
| 'evidence-owed'
|
|
27
|
+
| 'standing-stale'
|
|
28
|
+
| 'standing-conflict'
|
|
29
|
+
| 'standing-unknown'
|
|
30
|
+
| 'ci-pending'
|
|
31
|
+
| 'ci-failing'
|
|
32
|
+
| 'ci-conflicted'
|
|
33
|
+
| 'ci-timeout'
|
|
34
|
+
| 'head-moved'
|
|
35
|
+
| 'gh-failed'
|
|
36
|
+
|
|
37
|
+
/** The gates a pull request clears, named so a record can report each one. */
|
|
38
|
+
export type GateName = 'review' | 'ui' | 'evidence' | 'standing' | 'ci'
|
|
39
|
+
|
|
40
|
+
export interface GateResult {
|
|
41
|
+
readonly gate: GateName
|
|
42
|
+
readonly isOk: boolean
|
|
43
|
+
/** Absent when the gate passed. */
|
|
44
|
+
readonly reason?: LiftRefusal
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** How the head sits against the base the pull request targets. */
|
|
48
|
+
export type Standing = 'fresh' | 'stale' | 'conflict' | 'unknown'
|
|
49
|
+
|
|
50
|
+
export interface PullState {
|
|
51
|
+
readonly isDraft: boolean
|
|
52
|
+
readonly isOpen: boolean
|
|
53
|
+
/** The id the ready-for-review mutation takes. */
|
|
54
|
+
readonly nodeId: string
|
|
55
|
+
readonly branch: string
|
|
56
|
+
/** The head the pull request object reports, which lags the tip. */
|
|
57
|
+
readonly head: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What the evidence check says about the comment the ship chain posts. */
|
|
61
|
+
export interface EvidenceGate {
|
|
62
|
+
readonly isSettled: boolean
|
|
63
|
+
readonly owed: readonly string[]
|
|
64
|
+
/** Whether the marked comment carries a visual checklist, which binds the UI gate. */
|
|
65
|
+
readonly hasChecklist: boolean
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ChecksGate {
|
|
69
|
+
readonly state: ChecksState
|
|
70
|
+
readonly isConflicted: boolean
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Everything the verb reads or writes, injected so the gates and the wait run
|
|
75
|
+
* without a network. A read that failed returns undefined, and the verb
|
|
76
|
+
* refuses `gh-failed` rather than reading absence as a pass.
|
|
77
|
+
*/
|
|
78
|
+
export interface LiftApi {
|
|
79
|
+
readPull(): Promise<PullState | undefined>
|
|
80
|
+
/** The branch tip from the remote, which is the authority for the head. */
|
|
81
|
+
readTip(branch: string): Promise<string | undefined>
|
|
82
|
+
readReviews(): Promise<readonly ReviewRow[] | undefined>
|
|
83
|
+
readEvidence(): Promise<EvidenceGate | undefined>
|
|
84
|
+
readStanding(head: string): Promise<Standing>
|
|
85
|
+
readChecks(tip: string): Promise<ChecksGate | undefined>
|
|
86
|
+
/** The one GraphQL call the verb makes. */
|
|
87
|
+
markReady(nodeId: string): Promise<boolean>
|
|
88
|
+
sleep(ms: number): Promise<void>
|
|
89
|
+
now(): number
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface LiftOptions {
|
|
93
|
+
readonly isCheck: boolean
|
|
94
|
+
readonly waitSeconds: number
|
|
95
|
+
readonly pollMs?: number
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface LiftRecord {
|
|
99
|
+
readonly reason: 'lifted' | 'already-ready' | 'clear' | LiftRefusal
|
|
100
|
+
readonly isLifted: boolean
|
|
101
|
+
/** Whether the caller can treat the pull request as ready afterwards. */
|
|
102
|
+
readonly isOk: boolean
|
|
103
|
+
readonly head?: string
|
|
104
|
+
readonly gates: readonly GateResult[]
|
|
105
|
+
readonly waitedSeconds: number
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function pass(gate: GateName): GateResult {
|
|
109
|
+
return { gate, isOk: true }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function fail(gate: GateName, reason: LiftRefusal): GateResult {
|
|
113
|
+
return { gate, isOk: false, reason }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The code review gate. A pass with no marker is refused rather than lifted,
|
|
118
|
+
* since the commit it covers is a stamp GitHub wrote at submission, which the
|
|
119
|
+
* marker exists to replace.
|
|
120
|
+
*/
|
|
121
|
+
function reviewGate(reviews: readonly ReviewRow[], head: string): GateResult {
|
|
122
|
+
const scope = resolveReviewScope({ reviews })
|
|
123
|
+
if (scope.state === 'none') return fail('review', 'review-missing')
|
|
124
|
+
if (scope.state === 'open') return fail('review', 'review-open')
|
|
125
|
+
if (scope.source !== 'marker') return fail('review', 'review-unmarked')
|
|
126
|
+
if (scope.commit === undefined || !coversHead(scope.commit, head)) {
|
|
127
|
+
return fail('review', 'review-behind')
|
|
128
|
+
}
|
|
129
|
+
return pass('review')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Binds only where the evidence record carries a checklist to drive. */
|
|
133
|
+
function uiGate(
|
|
134
|
+
reviews: readonly ReviewRow[],
|
|
135
|
+
head: string,
|
|
136
|
+
hasChecklist: boolean,
|
|
137
|
+
): GateResult {
|
|
138
|
+
if (!hasChecklist) return pass('ui')
|
|
139
|
+
|
|
140
|
+
const scope = resolveUiScope({ reviews })
|
|
141
|
+
if (scope.state === 'none') return fail('ui', 'ui-missing')
|
|
142
|
+
if (scope.state === 'open') return fail('ui', 'ui-open')
|
|
143
|
+
if (scope.source !== 'marker') return fail('ui', 'ui-unmarked')
|
|
144
|
+
if (scope.commit === undefined || !coversHead(scope.commit, head)) {
|
|
145
|
+
return fail('ui', 'ui-behind')
|
|
146
|
+
}
|
|
147
|
+
return pass('ui')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function standingGate(standing: Standing): GateResult {
|
|
151
|
+
if (standing === 'fresh') return pass('standing')
|
|
152
|
+
return fail('standing', `standing-${standing}`)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
type CiOutcome =
|
|
156
|
+
| { readonly kind: 'passing'; readonly waited: number }
|
|
157
|
+
| {
|
|
158
|
+
readonly kind: 'refused'
|
|
159
|
+
readonly reason: LiftRefusal
|
|
160
|
+
readonly waited: number
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Reads the check runs until they conclude, the deadline passes, or a run can
|
|
165
|
+
* no longer start. A conflicted branch gets no merge ref, so no run ever
|
|
166
|
+
* starts and waiting on it is the one wait that cannot end.
|
|
167
|
+
*/
|
|
168
|
+
async function waitForChecks(
|
|
169
|
+
api: LiftApi,
|
|
170
|
+
tip: string,
|
|
171
|
+
waitSeconds: number,
|
|
172
|
+
pollMs: number,
|
|
173
|
+
): Promise<CiOutcome> {
|
|
174
|
+
const started = api.now()
|
|
175
|
+
const deadline = started + waitSeconds * 1000
|
|
176
|
+
|
|
177
|
+
for (;;) {
|
|
178
|
+
const reading = await api.readChecks(tip)
|
|
179
|
+
const waited = Math.round((api.now() - started) / 1000)
|
|
180
|
+
if (reading === undefined) {
|
|
181
|
+
return { kind: 'refused', reason: 'gh-failed', waited }
|
|
182
|
+
}
|
|
183
|
+
if (reading.state === 'passing') return { kind: 'passing', waited }
|
|
184
|
+
if (reading.state === 'failing') {
|
|
185
|
+
return { kind: 'refused', reason: 'ci-failing', waited }
|
|
186
|
+
}
|
|
187
|
+
if (reading.isConflicted) {
|
|
188
|
+
return { kind: 'refused', reason: 'ci-conflicted', waited }
|
|
189
|
+
}
|
|
190
|
+
if (api.now() + pollMs > deadline) {
|
|
191
|
+
return { kind: 'refused', reason: 'ci-timeout', waited }
|
|
192
|
+
}
|
|
193
|
+
await api.sleep(pollMs)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function refused(
|
|
198
|
+
reason: LiftRefusal,
|
|
199
|
+
gates: readonly GateResult[],
|
|
200
|
+
head: string | undefined,
|
|
201
|
+
waitedSeconds = 0,
|
|
202
|
+
): LiftRecord {
|
|
203
|
+
return {
|
|
204
|
+
reason,
|
|
205
|
+
isLifted: false,
|
|
206
|
+
isOk: false,
|
|
207
|
+
...(head !== undefined && { head }),
|
|
208
|
+
gates,
|
|
209
|
+
waitedSeconds,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Lifts the draft mark when every gate reads clear at the head.
|
|
215
|
+
*
|
|
216
|
+
* The reads that need no wait all run before the CI wait, so a reviewer whose
|
|
217
|
+
* partner is still driving refuses at once rather than holding a turn through
|
|
218
|
+
* a CI run. The head and the reviews are read again after the wait and before
|
|
219
|
+
* the mutation, since a push during the wait moves the head off the commit
|
|
220
|
+
* every earlier gate cleared.
|
|
221
|
+
*/
|
|
222
|
+
export async function lift(
|
|
223
|
+
api: LiftApi,
|
|
224
|
+
opts: LiftOptions,
|
|
225
|
+
): Promise<LiftRecord> {
|
|
226
|
+
const pollMs = opts.pollMs ?? CHECKS_POLL_MS
|
|
227
|
+
|
|
228
|
+
const pull = await api.readPull()
|
|
229
|
+
if (pull === undefined) return refused('gh-failed', [], undefined)
|
|
230
|
+
if (!pull.isOpen) return refused('pull-closed', [], pull.head)
|
|
231
|
+
if (!pull.isDraft) {
|
|
232
|
+
return {
|
|
233
|
+
reason: 'already-ready',
|
|
234
|
+
isLifted: false,
|
|
235
|
+
isOk: true,
|
|
236
|
+
head: pull.head,
|
|
237
|
+
gates: [],
|
|
238
|
+
waitedSeconds: 0,
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// The tip, not the object's head, since the object lags the ref by up to a
|
|
243
|
+
// minute after a push and a review marker names the commit it read.
|
|
244
|
+
const tip = await api.readTip(pull.branch)
|
|
245
|
+
if (tip === undefined) return refused('gh-failed', [], pull.head)
|
|
246
|
+
|
|
247
|
+
const [reviews, evidence, standing] = await Promise.all([
|
|
248
|
+
api.readReviews(),
|
|
249
|
+
api.readEvidence(),
|
|
250
|
+
api.readStanding(tip),
|
|
251
|
+
])
|
|
252
|
+
if (reviews === undefined || evidence === undefined) {
|
|
253
|
+
return refused('gh-failed', [], tip)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const gates: GateResult[] = [
|
|
257
|
+
reviewGate(reviews, tip),
|
|
258
|
+
uiGate(reviews, tip, evidence.hasChecklist),
|
|
259
|
+
evidence.isSettled ? pass('evidence') : fail('evidence', 'evidence-owed'),
|
|
260
|
+
standingGate(standing),
|
|
261
|
+
]
|
|
262
|
+
const blocked = gates.find((gate) => !gate.isOk)
|
|
263
|
+
|
|
264
|
+
if (opts.isCheck) {
|
|
265
|
+
const reading = await api.readChecks(tip)
|
|
266
|
+
if (reading === undefined) {
|
|
267
|
+
return refused('gh-failed', gates, tip)
|
|
268
|
+
}
|
|
269
|
+
const ci =
|
|
270
|
+
reading.state === 'passing'
|
|
271
|
+
? pass('ci')
|
|
272
|
+
: reading.state === 'failing'
|
|
273
|
+
? fail('ci', 'ci-failing')
|
|
274
|
+
: reading.isConflicted
|
|
275
|
+
? fail('ci', 'ci-conflicted')
|
|
276
|
+
: fail('ci', 'ci-pending')
|
|
277
|
+
const all = [...gates, ci]
|
|
278
|
+
const first = all.find((gate) => !gate.isOk)
|
|
279
|
+
if (first?.reason !== undefined) return refused(first.reason, all, tip)
|
|
280
|
+
return {
|
|
281
|
+
reason: 'clear',
|
|
282
|
+
isLifted: false,
|
|
283
|
+
isOk: true,
|
|
284
|
+
head: tip,
|
|
285
|
+
gates: all,
|
|
286
|
+
waitedSeconds: 0,
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (blocked?.reason !== undefined) return refused(blocked.reason, gates, tip)
|
|
291
|
+
|
|
292
|
+
const ci = await waitForChecks(api, tip, opts.waitSeconds, pollMs)
|
|
293
|
+
if (ci.kind === 'refused') {
|
|
294
|
+
return refused(ci.reason, [...gates, fail('ci', ci.reason)], tip, ci.waited)
|
|
295
|
+
}
|
|
296
|
+
const cleared = [...gates, pass('ci')]
|
|
297
|
+
|
|
298
|
+
// The re-read: whatever the gates cleared above was cleared at `tip`.
|
|
299
|
+
const [nowPull, nowTip, nowReviews] = await Promise.all([
|
|
300
|
+
api.readPull(),
|
|
301
|
+
api.readTip(pull.branch),
|
|
302
|
+
api.readReviews(),
|
|
303
|
+
])
|
|
304
|
+
if (
|
|
305
|
+
nowPull === undefined ||
|
|
306
|
+
nowTip === undefined ||
|
|
307
|
+
nowReviews === undefined
|
|
308
|
+
) {
|
|
309
|
+
return refused('gh-failed', cleared, tip, ci.waited)
|
|
310
|
+
}
|
|
311
|
+
if (!nowPull.isDraft) {
|
|
312
|
+
return {
|
|
313
|
+
reason: 'already-ready',
|
|
314
|
+
isLifted: false,
|
|
315
|
+
isOk: true,
|
|
316
|
+
head: nowTip,
|
|
317
|
+
gates: cleared,
|
|
318
|
+
waitedSeconds: ci.waited,
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (nowTip !== tip) return refused('head-moved', cleared, nowTip, ci.waited)
|
|
322
|
+
|
|
323
|
+
const reread = [
|
|
324
|
+
reviewGate(nowReviews, nowTip),
|
|
325
|
+
uiGate(nowReviews, nowTip, evidence.hasChecklist),
|
|
326
|
+
].find((gate) => !gate.isOk)
|
|
327
|
+
if (reread?.reason !== undefined) {
|
|
328
|
+
return refused(reread.reason, cleared, nowTip, ci.waited)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Standing is read again too, since a sibling merging during the wait can
|
|
332
|
+
// leave a branch that read fresh before it stale.
|
|
333
|
+
const laterStanding = standingGate(await api.readStanding(nowTip))
|
|
334
|
+
if (laterStanding.reason !== undefined) {
|
|
335
|
+
return refused(laterStanding.reason, cleared, nowTip, ci.waited)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const isMarked = await api.markReady(pull.nodeId)
|
|
339
|
+
if (!isMarked) return refused('gh-failed', cleared, nowTip, ci.waited)
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
reason: 'lifted',
|
|
343
|
+
isLifted: true,
|
|
344
|
+
isOk: true,
|
|
345
|
+
head: nowTip,
|
|
346
|
+
gates: cleared,
|
|
347
|
+
waitedSeconds: ci.waited,
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** One git invocation, answering its stdout or undefined when it failed. */
|
|
352
|
+
export type GitRunner = (args: readonly string[]) => Promise<string | undefined>
|
|
353
|
+
|
|
354
|
+
function nonEmptyLines(output: string): string[] {
|
|
355
|
+
return output.split('\n').filter((line) => line !== '')
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Reads where a head stands against the base, ported from the orchestrator
|
|
360
|
+
* poll's own read so the lift and the poll answer one question the same way.
|
|
361
|
+
*
|
|
362
|
+
* merge-tree answers locally, because `mergeable_state` reads unknown while
|
|
363
|
+
* GitHub computes it, which is exactly when a verb asks. Only a change to a
|
|
364
|
+
* path the branch writes makes it stale, since re-testing after every merge to
|
|
365
|
+
* the base is a merge queue's cost without its batching. A head or base that
|
|
366
|
+
* does not resolve, which a fork or a deleted head leaves, reads `unknown`.
|
|
367
|
+
*/
|
|
368
|
+
export async function readBaseStanding(
|
|
369
|
+
git: GitRunner,
|
|
370
|
+
number: number,
|
|
371
|
+
head: string,
|
|
372
|
+
ref: string,
|
|
373
|
+
): Promise<Standing> {
|
|
374
|
+
// The base is fetched as well as the head. A tracking ref is only as current
|
|
375
|
+
// as the last fetch, so a sibling merging since then would read as fresh.
|
|
376
|
+
const baseBranch = ref.startsWith('origin/')
|
|
377
|
+
? ref.slice('origin/'.length)
|
|
378
|
+
: ref
|
|
379
|
+
await git(['fetch', '-q', 'origin', baseBranch])
|
|
380
|
+
await git(['fetch', '-q', 'origin', `pull/${number}/head`])
|
|
381
|
+
|
|
382
|
+
const hasHead =
|
|
383
|
+
(await git(['cat-file', '-e', `${head}^{commit}`])) !== undefined
|
|
384
|
+
const hasRef = (await git(['rev-parse', '--verify', '-q', ref])) !== undefined
|
|
385
|
+
if (!hasHead || !hasRef) return 'unknown'
|
|
386
|
+
|
|
387
|
+
const merged = await git(['merge-tree', '--write-tree', ref, head])
|
|
388
|
+
if (merged === undefined) return 'conflict'
|
|
389
|
+
|
|
390
|
+
const forkPoint = (await git(['merge-base', ref, head]))?.trim()
|
|
391
|
+
if (forkPoint === undefined || forkPoint === '') return 'unknown'
|
|
392
|
+
|
|
393
|
+
const written = await git([
|
|
394
|
+
'diff',
|
|
395
|
+
'--name-only',
|
|
396
|
+
'--no-renames',
|
|
397
|
+
forkPoint,
|
|
398
|
+
head,
|
|
399
|
+
])
|
|
400
|
+
if (written === undefined) return 'unknown'
|
|
401
|
+
|
|
402
|
+
const paths = nonEmptyLines(written)
|
|
403
|
+
if (paths.length === 0) return 'fresh'
|
|
404
|
+
|
|
405
|
+
const moved = await git([
|
|
406
|
+
'--literal-pathspecs',
|
|
407
|
+
'diff',
|
|
408
|
+
'--name-only',
|
|
409
|
+
'--no-renames',
|
|
410
|
+
forkPoint,
|
|
411
|
+
ref,
|
|
412
|
+
'--',
|
|
413
|
+
...paths,
|
|
414
|
+
])
|
|
415
|
+
if (moved === undefined) return 'unknown'
|
|
416
|
+
|
|
417
|
+
return nonEmptyLines(moved).length > 0 ? 'stale' : 'fresh'
|
|
418
|
+
}
|
package/src/pr/rest.ts
CHANGED
|
@@ -17,6 +17,9 @@ export interface RestPull {
|
|
|
17
17
|
readonly head?: { readonly ref?: string; readonly sha?: string }
|
|
18
18
|
readonly base?: { readonly ref?: string }
|
|
19
19
|
readonly mergeable_state?: string
|
|
20
|
+
readonly state?: string
|
|
21
|
+
readonly draft?: boolean
|
|
22
|
+
readonly node_id?: string
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
/** One row of `repos/{owner}/{repo}/pulls/<n>/reviews`. */
|
package/src/pr/review-scope.ts
CHANGED
|
@@ -131,6 +131,82 @@ function markerOf(
|
|
|
131
131
|
return { commit, readAt }
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/** The two headings `review-ui` posts a verdict under. */
|
|
135
|
+
export const UI_HEADINGS = ['## UI review', '## UI review closed'] as const
|
|
136
|
+
|
|
137
|
+
export type UiHeading = (typeof UI_HEADINGS)[number]
|
|
138
|
+
|
|
139
|
+
export interface UiScope {
|
|
140
|
+
readonly heading?: UiHeading
|
|
141
|
+
readonly state: ReviewState
|
|
142
|
+
/** The head the verdict covered, a full or abbreviated sha. */
|
|
143
|
+
readonly commit?: string
|
|
144
|
+
/** `marker` is the verdict's own record, `fallback` is GitHub's stamp. */
|
|
145
|
+
readonly source: ScopeSource
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The marker `review-ui` writes as the last line of every verdict. It names the
|
|
150
|
+
* head the pass drove, since the submission stamp moves onto a commit pushed
|
|
151
|
+
* while the pass was driving.
|
|
152
|
+
*/
|
|
153
|
+
const UI_MARKER = /^<!--\s*review-ui:\s*head=([0-9a-f]{7,40})\s*-->$/
|
|
154
|
+
|
|
155
|
+
function lastLine(body: string): string | undefined {
|
|
156
|
+
const lines = body.split('\n')
|
|
157
|
+
|
|
158
|
+
let index = lines.length - 1
|
|
159
|
+
while (index >= 0 && (lines[index] ?? '').trim() === '') index -= 1
|
|
160
|
+
if (index < 0) return undefined
|
|
161
|
+
|
|
162
|
+
return (lines[index] ?? '').trim()
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Resolves the newest UI verdict and the head it covered.
|
|
167
|
+
*
|
|
168
|
+
* Read off `reviews` like the code review family, since `review-ui` posts each
|
|
169
|
+
* verdict as a pull request review. The first line is matched for equality so
|
|
170
|
+
* `## UI review closed` is never taken for the open heading by prefix.
|
|
171
|
+
*/
|
|
172
|
+
export function resolveUiScope(listing: ReviewListing): UiScope {
|
|
173
|
+
let newest: { heading: UiHeading; row: ReviewRow } | undefined
|
|
174
|
+
|
|
175
|
+
for (const row of listing.reviews ?? []) {
|
|
176
|
+
const first = ((row.body ?? '').split('\n')[0] ?? '').replace(/\r$/, '')
|
|
177
|
+
const heading = UI_HEADINGS.find((candidate) => candidate === first)
|
|
178
|
+
if (heading !== undefined) newest = { heading, row }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (newest === undefined) return { state: 'none', source: 'none' }
|
|
182
|
+
|
|
183
|
+
const { heading, row } = newest
|
|
184
|
+
const state: ReviewState = heading === '## UI review' ? 'open' : 'closed'
|
|
185
|
+
const line = lastLine(row.body ?? '')
|
|
186
|
+
const match = line === undefined ? null : UI_MARKER.exec(line)
|
|
187
|
+
const marked = match?.[1]
|
|
188
|
+
|
|
189
|
+
if (marked !== undefined) {
|
|
190
|
+
return { heading, state, commit: marked, source: 'marker' }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const oid = row.commit?.oid
|
|
194
|
+
return {
|
|
195
|
+
heading,
|
|
196
|
+
state,
|
|
197
|
+
...(oid === undefined || oid === '' ? {} : { commit: oid }),
|
|
198
|
+
source: 'fallback',
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Whether a covered commit names the head. A marker may carry a short sha, so
|
|
204
|
+
* coverage is a prefix match on the head.
|
|
205
|
+
*/
|
|
206
|
+
export function coversHead(commit: string, head: string): boolean {
|
|
207
|
+
return commit !== '' && head !== '' && head.startsWith(commit)
|
|
208
|
+
}
|
|
209
|
+
|
|
134
210
|
/**
|
|
135
211
|
* Resolves what the newest review pass covered.
|
|
136
212
|
*
|