@erclx/canon 5.76.0 → 5.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/references/ui-checklist.md +9 -1
- package/claude/skills/ci-workflow/REQUIREMENT.md +8 -2
- package/claude/skills/ci-workflow/SKILL.md +3 -0
- package/claude/skills/code-craft/REQUIREMENT.md +1 -1
- package/claude/skills/code-craft/SKILL.md +7 -0
- package/claude/skills/code-craft/references/adopted.md +8 -0
- package/claude/skills/pr-evidence/SKILL.md +6 -1
- package/claude/skills/pr-evidence/references/evidence.md +1 -0
- package/claude/skills/pr-evidence/references/reel.md +9 -1
- package/claude/skills/review-craft/references/ui.md +13 -1
- package/claude/skills/review-pr/references/body-variants.md +2 -0
- package/claude/skills/review-pr/references/close-out.md +2 -0
- package/claude/skills/review-pr/references/post.md +1 -0
- package/claude/skills/review-ui/REQUIREMENT.md +1 -0
- package/claude/skills/review-ui/SKILL.md +2 -2
- package/claude/skills/review-ui/references/driving.md +32 -4
- package/claude/skills/review-ui/references/post.md +6 -2
- package/claude/skills/role-orchestrator/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-output.md +24 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +3 -2
- package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +35 -4
- package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
- package/claude/skills/role-worker/SKILL.md +1 -0
- package/claude/skills/test-craft/SKILL.md +1 -0
- package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
- package/claude/skills/ui-checklist/SKILL.md +2 -1
- package/claude/skills/ui-craft/references/capture.md +16 -0
- package/claude/skills/ui-film/REQUIREMENT.md +2 -2
- package/claude/skills/ui-film/SKILL.md +6 -1
- package/claude/skills/ui-film/references/compose-craft.md +68 -11
- package/claude/skills/ui-film/references/config.md +2 -2
- package/claude/skills/ui-film/references/handoff.md +1 -1
- package/claude/skills/ui-film/references/harness.md +71 -0
- package/claude/skills/ui-film/references/take-craft.md +42 -17
- package/docs/agents/targets.md +3 -1
- package/docs/workflow/operating-model.md +6 -5
- package/docs/workflow/visual-design-workflow.md +2 -1
- package/governance/ledger/skills/code-craft/SKILL.md +9 -0
- package/governance/ledger/skills/test-craft/SKILL.md +1 -0
- package/governance/ledger/skills/ui-craft/capture.md +18 -5
- package/package.json +5 -4
- package/src/commands/pr.ts +117 -31
- package/src/commands/take.ts +12 -1
- package/src/commands/targets.ts +11 -1
- package/src/design/components.ts +5 -107
- package/src/design/favicon.ts +21 -17
- package/src/design/render.ts +9 -27
- package/src/design/template.ts +42 -0
- package/src/design/templates/favicon.svg +17 -0
- package/src/design/templates/hand-drawn-figure.css +54 -0
- package/src/design/templates/preview.html +27 -0
- package/src/design/templates/scrollbar.css +29 -0
- package/src/design/templates/status.css +24 -0
- package/src/gate/embedded.ts +400 -0
- package/src/gate/stages.ts +8 -0
- package/src/pr/evidence.ts +43 -4
- package/src/pr/frames.ts +1 -6
- package/src/pr/lift.ts +77 -9
- package/src/pr/rest.ts +2 -0
- package/src/pr/review-scope.ts +28 -0
- package/src/pr/review-state.ts +5 -5
- package/src/pr/waiver.ts +45 -0
- package/src/take/camera.ts +337 -208
- package/src/take/film.ts +7 -1
- package/src/take/log.ts +43 -2
- package/src/take/perform.ts +85 -13
- package/src/take/plan.ts +26 -5
- package/src/take/reel.ts +30 -5
- package/src/take/screencast.ts +110 -3
- package/src/take/stepper.ts +24 -4
- package/src/targets/registry.ts +99 -14
- package/src/targets/resolve.ts +3 -1
- package/tooling/astro/configs/card.config.mjs +4 -0
- package/tooling/astro/configs/scripts/check-card-exclusion.sh +7 -2
- package/tooling/astro/reference.md +2 -1
- package/tooling/web/configs/.github/workflows/readme-screenshot.yml +3 -1
- package/tooling/web/configs/.github/workflows/verify.yml +5 -2
- package/tooling/web/configs/scripts/install-playwright-deps.sh +52 -0
- package/tooling/web/reference.md +5 -0
- package/src/commands/fixtures/gh.sh +0 -64
- package/src/take/fixtures/busy.html +0 -107
- package/src/take/fixtures/fade.html +0 -49
- package/src/take/fixtures/fetch.html +0 -38
- package/src/take/fixtures/input.html +0 -138
- package/src/take/zoom/NOTICE.md +0 -45
- package/src/take/zoom/math-utils.ts +0 -69
- package/src/take/zoom/region-utils.ts +0 -55
- package/src/take/zoom/spring.ts +0 -133
package/src/pr/evidence.ts
CHANGED
|
@@ -31,6 +31,9 @@ const LOCAL_SKIP_PREFIX = '**No local preview:**'
|
|
|
31
31
|
/** How the line naming why no reel was filmed opens. */
|
|
32
32
|
const REEL_SKIP_PREFIX = '**No reel:**'
|
|
33
33
|
|
|
34
|
+
/** How the line naming the painted paths changed with no capture opens. */
|
|
35
|
+
const UNCAPTURED_PREFIX = '**Uncaptured:**'
|
|
36
|
+
|
|
34
37
|
/** The width a reel is filmed at, which it asks for in the comment. */
|
|
35
38
|
const REEL_WIDTH = 960
|
|
36
39
|
|
|
@@ -59,8 +62,11 @@ const BOX_LINE = /^(\s*[-*+] \[)([ xX])(\] )(.*)$/
|
|
|
59
62
|
/** The trailing segment a tick carries, naming the commit the box passed at. Matched only at the end of a line, so a box whose own text says `passed at` is left alone. */
|
|
60
63
|
const STAMP = / · passed at `([0-9a-f]{7,40})`$/
|
|
61
64
|
|
|
62
|
-
/**
|
|
63
|
-
const
|
|
65
|
+
/** The marker a box a person has to judge by eye carries among its trailing markers. */
|
|
66
|
+
const TASTE_MARKER = '(taste)'
|
|
67
|
+
|
|
68
|
+
/** The run of parenthesized markers that ends a box, such as `(taste) (local only)`, in any order. */
|
|
69
|
+
const TRAILING_MARKERS = /(?:\s*\([^()]*\))+$/
|
|
64
70
|
|
|
65
71
|
/** A `#issuecomment-<id>` suffix, which is the REST comment id `gh pr view` never returns directly. */
|
|
66
72
|
const ISSUE_COMMENT_ID = /#issuecomment-(\d+)$/
|
|
@@ -476,6 +482,22 @@ function skipLines(prefix: string, reason: string | undefined): string[] {
|
|
|
476
482
|
return text === '' ? [] : [`${prefix} ${escapeCell(text)}`, '']
|
|
477
483
|
}
|
|
478
484
|
|
|
485
|
+
/**
|
|
486
|
+
* The uncaptured line, or nothing once the list is empty or a reel filmed at
|
|
487
|
+
* this head shows the change. A reel at an earlier head leaves it, since a
|
|
488
|
+
* later push may have added the path.
|
|
489
|
+
*/
|
|
490
|
+
function uncapturedLines(
|
|
491
|
+
uncaptured: readonly string[],
|
|
492
|
+
head: string,
|
|
493
|
+
reel: EvidenceReel | undefined,
|
|
494
|
+
): string[] {
|
|
495
|
+
if (uncaptured.length === 0) return []
|
|
496
|
+
if (reel !== undefined && head.startsWith(reel.head)) return []
|
|
497
|
+
const paths = uncaptured.map((path) => `\`${path}\``).join(', ')
|
|
498
|
+
return [`${UNCAPTURED_PREFIX} ${paths}`, '']
|
|
499
|
+
}
|
|
500
|
+
|
|
479
501
|
export function evidenceMarker(head: string): string {
|
|
480
502
|
return `${MARKER_PREFIX} head=${head} -->`
|
|
481
503
|
}
|
|
@@ -511,6 +533,10 @@ export function evidenceMarker(head: string): string {
|
|
|
511
533
|
* A skip names why the local address or the reel is missing, on its own line
|
|
512
534
|
* where that address or reel would sit. Each is dropped whenever its address
|
|
513
535
|
* or reel renders, carried or passed, so a skip never outlives its cause.
|
|
536
|
+
*
|
|
537
|
+
* The uncaptured line names each painted path changed with no capture, under
|
|
538
|
+
* the reel, and is computed fresh on every render rather than carried, so it
|
|
539
|
+
* drops on the first render after a capture or a reel at the head lands.
|
|
514
540
|
*/
|
|
515
541
|
export function renderEvidenceBody(
|
|
516
542
|
states: readonly EvidenceState[],
|
|
@@ -522,6 +548,7 @@ export function renderEvidenceBody(
|
|
|
522
548
|
local?: string,
|
|
523
549
|
reel?: EvidenceReel,
|
|
524
550
|
skips: EvidenceSkips = {},
|
|
551
|
+
uncaptured: readonly string[] = [],
|
|
525
552
|
): string {
|
|
526
553
|
const segments = [
|
|
527
554
|
...(preview === undefined ? [] : [`${PREVIEW_PREFIX} ${preview}`]),
|
|
@@ -535,6 +562,7 @@ export function renderEvidenceBody(
|
|
|
535
562
|
...(reel === undefined
|
|
536
563
|
? skipLines(REEL_SKIP_PREFIX, skips.reel)
|
|
537
564
|
: [reelLine(reel), '']),
|
|
565
|
+
...uncapturedLines(uncaptured, head, reel),
|
|
538
566
|
]
|
|
539
567
|
const closing =
|
|
540
568
|
checklist === undefined
|
|
@@ -859,7 +887,7 @@ export interface ChecklistBox {
|
|
|
859
887
|
readonly isTicked: boolean
|
|
860
888
|
/** The short sha a tick names, absent on an empty box or a tick nothing stamped. */
|
|
861
889
|
readonly stamp?: string
|
|
862
|
-
/** Whether the box
|
|
890
|
+
/** Whether the box's trailing marker run holds `(taste)`, which no driver ticks. */
|
|
863
891
|
readonly isTaste: boolean
|
|
864
892
|
}
|
|
865
893
|
|
|
@@ -906,12 +934,23 @@ export function readChecklistBoxes(checklist: string): ChecklistBox[] {
|
|
|
906
934
|
text: parsed.text,
|
|
907
935
|
isTicked: parsed.isTicked,
|
|
908
936
|
...(parsed.stamp !== undefined && { stamp: parsed.stamp }),
|
|
909
|
-
isTaste: parsed.text
|
|
937
|
+
isTaste: hasTasteMarker(parsed.text),
|
|
910
938
|
})
|
|
911
939
|
})
|
|
912
940
|
return boxes
|
|
913
941
|
}
|
|
914
942
|
|
|
943
|
+
function hasTasteMarker(text: string): boolean {
|
|
944
|
+
const run = TRAILING_MARKERS.exec(text)?.[0] ?? ''
|
|
945
|
+
const markers: readonly string[] = run.match(/\([^()]*\)/g) ?? []
|
|
946
|
+
return markers.includes(TASTE_MARKER)
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/** The boxes a driver can run, being every box but a taste one. */
|
|
950
|
+
export function countDrivable(boxes: readonly ChecklistBox[]): number {
|
|
951
|
+
return boxes.filter((box) => !box.isTaste).length
|
|
952
|
+
}
|
|
953
|
+
|
|
915
954
|
/** The head the marked comment's trailing marker names. */
|
|
916
955
|
export function findEvidenceHead(
|
|
917
956
|
comments: readonly EvidenceComment[],
|
package/src/pr/frames.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { REEL_MAX_BYTES } from '@/take/reel'
|
|
2
3
|
|
|
3
4
|
/** The branch holding review frames. It never merges into a branch anyone checks out. */
|
|
4
5
|
export const FRAMES_BRANCH = 'canon/frames'
|
|
@@ -13,12 +14,6 @@ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
|
|
13
14
|
const RIFF = [0x52, 0x49, 0x46, 0x46]
|
|
14
15
|
const WEBP = [0x57, 0x45, 0x42, 0x50]
|
|
15
16
|
|
|
16
|
-
/**
|
|
17
|
-
* The largest reel an add takes, GitHub's cap on an image in a comment, so a
|
|
18
|
-
* reel that would not render is refused before it reaches the branch.
|
|
19
|
-
*/
|
|
20
|
-
export const REEL_MAX_BYTES = 10_000_000
|
|
21
|
-
|
|
22
17
|
const SHORT_HEAD_LENGTH = 7
|
|
23
18
|
|
|
24
19
|
const HEAD_PATTERN = /^[0-9a-f]{7,40}$/
|
package/src/pr/lift.ts
CHANGED
|
@@ -4,9 +4,11 @@ import {
|
|
|
4
4
|
type ReviewRow,
|
|
5
5
|
resolveReviewScope,
|
|
6
6
|
resolveUiScope,
|
|
7
|
+
standingPasses,
|
|
7
8
|
type UiCounts,
|
|
8
9
|
} from '@/pr/review-scope'
|
|
9
10
|
import { readCarry } from '@/pr/carry'
|
|
11
|
+
import { readWaivers } from '@/pr/waiver'
|
|
10
12
|
import { DEFAULT_PAINTED, isPainted, type PaintedPatterns } from '@/pr/painted'
|
|
11
13
|
|
|
12
14
|
/** How long `--wait` holds for a pending CI run, in seconds, when none is named. */
|
|
@@ -28,6 +30,7 @@ export type LiftRefusal =
|
|
|
28
30
|
| 'ui-behind'
|
|
29
31
|
| 'ui-undriven'
|
|
30
32
|
| 'evidence-owed'
|
|
33
|
+
| 'painted-uncaptured'
|
|
31
34
|
| 'standing-stale'
|
|
32
35
|
| 'standing-conflict'
|
|
33
36
|
| 'standing-unknown'
|
|
@@ -52,6 +55,10 @@ export interface GateResult {
|
|
|
52
55
|
readonly paintedPaths?: readonly string[]
|
|
53
56
|
/** Every path a box can observe that kept a UI close from carrying forward. */
|
|
54
57
|
readonly drivenPaths?: readonly string[]
|
|
58
|
+
/** The painted paths changed with no capture, reel at the head, or waiver, sorted. */
|
|
59
|
+
readonly uncaptured?: readonly string[]
|
|
60
|
+
/** The uncaptured paths a review waived, sorted. */
|
|
61
|
+
readonly waived?: readonly string[]
|
|
55
62
|
}
|
|
56
63
|
|
|
57
64
|
/** How an earlier commit sits against the head, and what changed since. */
|
|
@@ -78,8 +85,35 @@ export interface PullState {
|
|
|
78
85
|
export interface EvidenceGate {
|
|
79
86
|
readonly isSettled: boolean
|
|
80
87
|
readonly owed: readonly string[]
|
|
81
|
-
/** Whether the marked comment
|
|
82
|
-
readonly
|
|
88
|
+
/** Whether the marked comment's checklist holds a box a driver can run, which binds the UI gate. */
|
|
89
|
+
readonly hasDrivableBox: boolean
|
|
90
|
+
/**
|
|
91
|
+
* The painted paths changed while no evidence image did, absent where the
|
|
92
|
+
* base keeps no captures, which reads as unchecked and passes.
|
|
93
|
+
*/
|
|
94
|
+
readonly uncaptured?: readonly string[]
|
|
95
|
+
/** The head the marked comment's reel was filmed at, when it embeds one. */
|
|
96
|
+
readonly reelHead?: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Whether an evidence record binds the UI family. A checklist binds unless
|
|
101
|
+
* the record counts zero drivable boxes, so a missing or unreadable count
|
|
102
|
+
* fails closed rather than reading as unbound.
|
|
103
|
+
*/
|
|
104
|
+
export function readsDrivableBox(record: {
|
|
105
|
+
readonly checklist?: unknown
|
|
106
|
+
readonly drivable?: unknown
|
|
107
|
+
}): boolean {
|
|
108
|
+
if (typeof record.checklist !== 'string' || record.checklist === '') {
|
|
109
|
+
return false
|
|
110
|
+
}
|
|
111
|
+
const { drivable } = record
|
|
112
|
+
return !(
|
|
113
|
+
typeof drivable === 'number' &&
|
|
114
|
+
Number.isInteger(drivable) &&
|
|
115
|
+
drivable === 0
|
|
116
|
+
)
|
|
83
117
|
}
|
|
84
118
|
|
|
85
119
|
export interface ChecksGate {
|
|
@@ -219,13 +253,14 @@ export async function uiGate(
|
|
|
219
253
|
painted: PaintedPatterns,
|
|
220
254
|
reviews: readonly ReviewRow[],
|
|
221
255
|
head: string,
|
|
222
|
-
|
|
256
|
+
hasDrivableBox: boolean,
|
|
223
257
|
): Promise<GateResult> {
|
|
224
|
-
if (!hasChecklist) return pass('ui')
|
|
225
|
-
|
|
226
258
|
const scope = resolveUiScope({ reviews })
|
|
227
|
-
|
|
259
|
+
// An open verdict holds the gate even once no drivable box remains, so
|
|
260
|
+
// dropping the checklist or marking a failed box taste never clears it.
|
|
228
261
|
if (scope.state === 'open') return fail('ui', 'ui-open')
|
|
262
|
+
if (!hasDrivableBox) return pass('ui')
|
|
263
|
+
if (scope.state === 'none') return fail('ui', 'ui-missing')
|
|
229
264
|
if (scope.source !== 'marker') return fail('ui', 'ui-unmarked')
|
|
230
265
|
if (scope.commit === undefined) return fail('ui', 'ui-behind')
|
|
231
266
|
// Ahead of the head test so a close carried across commits is refused too.
|
|
@@ -254,6 +289,39 @@ export async function uiGate(
|
|
|
254
289
|
}
|
|
255
290
|
}
|
|
256
291
|
|
|
292
|
+
/**
|
|
293
|
+
* The evidence gate. An owed step refuses first. Then every painted path the
|
|
294
|
+
* pull request changed with no capture refuses, unless a reel filmed at the
|
|
295
|
+
* head shows it or a standing pass waived it by the path as the list spells it.
|
|
296
|
+
* A reel at an earlier head clears nothing, since a later push may add the
|
|
297
|
+
* path, and for the same reason a waiver counts only while the newest pass of
|
|
298
|
+
* its family restates it.
|
|
299
|
+
*/
|
|
300
|
+
export function evidenceGate(
|
|
301
|
+
evidence: EvidenceGate,
|
|
302
|
+
reviews: readonly ReviewRow[],
|
|
303
|
+
head: string,
|
|
304
|
+
): GateResult {
|
|
305
|
+
if (!evidence.isSettled) return fail('evidence', 'evidence-owed')
|
|
306
|
+
|
|
307
|
+
const isFilmed =
|
|
308
|
+
evidence.reelHead !== undefined && coversHead(evidence.reelHead, head)
|
|
309
|
+
const listed = isFilmed ? [] : (evidence.uncaptured ?? [])
|
|
310
|
+
const named = new Set(
|
|
311
|
+
readWaivers(standingPasses(reviews)).map((waiver) => waiver.path),
|
|
312
|
+
)
|
|
313
|
+
const waived = listed.filter((path) => named.has(path)).toSorted()
|
|
314
|
+
const uncaptured = listed.filter((path) => !named.has(path)).toSorted()
|
|
315
|
+
|
|
316
|
+
const lists = {
|
|
317
|
+
...(uncaptured.length > 0 && { uncaptured }),
|
|
318
|
+
...(waived.length > 0 && { waived }),
|
|
319
|
+
}
|
|
320
|
+
return uncaptured.length > 0
|
|
321
|
+
? { ...fail('evidence', 'painted-uncaptured'), ...lists }
|
|
322
|
+
: { ...pass('evidence'), ...lists }
|
|
323
|
+
}
|
|
324
|
+
|
|
257
325
|
function standingGate(standing: Standing): GateResult {
|
|
258
326
|
if (standing === 'fresh') return pass('standing')
|
|
259
327
|
return fail('standing', `standing-${standing}`)
|
|
@@ -409,8 +477,8 @@ export async function lift(
|
|
|
409
477
|
|
|
410
478
|
const gates: GateResult[] = [
|
|
411
479
|
reviewGate(reviews, tip),
|
|
412
|
-
await uiGate(api, painted, reviews, tip, evidence.
|
|
413
|
-
|
|
480
|
+
await uiGate(api, painted, reviews, tip, evidence.hasDrivableBox),
|
|
481
|
+
evidenceGate(evidence, reviews, tip),
|
|
414
482
|
standingGate(standing),
|
|
415
483
|
]
|
|
416
484
|
if (!pull.isDraft) return settleReady(api, opts, pull, tip, gates)
|
|
@@ -481,7 +549,7 @@ export async function lift(
|
|
|
481
549
|
|
|
482
550
|
const reread = [
|
|
483
551
|
reviewGate(nowReviews, nowTip),
|
|
484
|
-
await uiGate(api, painted, nowReviews, nowTip, evidence.
|
|
552
|
+
await uiGate(api, painted, nowReviews, nowTip, evidence.hasDrivableBox),
|
|
485
553
|
].find((gate) => !gate.isOk)
|
|
486
554
|
if (reread?.reason !== undefined) {
|
|
487
555
|
return refused(reread.reason, cleared, nowTip, ci.waited)
|
package/src/pr/rest.ts
CHANGED
|
@@ -39,6 +39,7 @@ export interface RestReview {
|
|
|
39
39
|
readonly body?: string | null
|
|
40
40
|
readonly commit_id?: string | null
|
|
41
41
|
readonly submitted_at?: string | null
|
|
42
|
+
readonly state?: string | null
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
/** One row of `repos/{owner}/{repo}/issues/<n>/comments`. */
|
|
@@ -117,6 +118,7 @@ export function reviewRowOf(review: RestReview): ReviewRow {
|
|
|
117
118
|
? null
|
|
118
119
|
: { oid: review.commit_id },
|
|
119
120
|
submittedAt: review.submitted_at ?? null,
|
|
121
|
+
...(typeof review.state === 'string' && { state: review.state }),
|
|
120
122
|
}
|
|
121
123
|
}
|
|
122
124
|
|
package/src/pr/review-scope.ts
CHANGED
|
@@ -47,6 +47,8 @@ export interface ReviewRow {
|
|
|
47
47
|
readonly body?: string
|
|
48
48
|
readonly commit?: { readonly oid?: string } | null
|
|
49
49
|
readonly submittedAt?: string | null
|
|
50
|
+
/** GitHub's review state, `DISMISSED` once the operator withdrew the pass. */
|
|
51
|
+
readonly state?: string | null
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
/**
|
|
@@ -238,6 +240,32 @@ export function resolveUiScope(listing: ReviewListing): UiScope {
|
|
|
238
240
|
}
|
|
239
241
|
}
|
|
240
242
|
|
|
243
|
+
/**
|
|
244
|
+
* The newest code pass and the newest UI pass, each only when its own marker
|
|
245
|
+
* closes it, skipping any pass the operator dismissed. A body with no pass
|
|
246
|
+
* heading never stands, and neither does an older pass a newer one replaced,
|
|
247
|
+
* so a pass speaks only for the head it read.
|
|
248
|
+
*/
|
|
249
|
+
export function standingPasses(reviews: readonly ReviewRow[]): ReviewRow[] {
|
|
250
|
+
let code: ReviewRow | undefined
|
|
251
|
+
let ui: ReviewRow | undefined
|
|
252
|
+
for (const row of reviews) {
|
|
253
|
+
if (row.state === 'DISMISSED') continue
|
|
254
|
+
const body = row.body ?? ''
|
|
255
|
+
if (headingOf(body) !== undefined) code = row
|
|
256
|
+
else if (resolveUiScope({ reviews: [row] }).state !== 'none') ui = row
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const passes: ReviewRow[] = []
|
|
260
|
+
if (code !== undefined && markerOf(code.body ?? '') !== undefined) {
|
|
261
|
+
passes.push(code)
|
|
262
|
+
}
|
|
263
|
+
if (ui !== undefined && UI_MARKER.test(lastLine(ui.body ?? '') ?? '')) {
|
|
264
|
+
passes.push(ui)
|
|
265
|
+
}
|
|
266
|
+
return passes
|
|
267
|
+
}
|
|
268
|
+
|
|
241
269
|
/**
|
|
242
270
|
* Whether a covered commit names the head. A marker may carry a short sha, so
|
|
243
271
|
* coverage is a prefix match on the head.
|
package/src/pr/review-state.ts
CHANGED
|
@@ -94,7 +94,7 @@ function familyOf(gate: GateResult, commit: string | undefined, tip: string) {
|
|
|
94
94
|
*/
|
|
95
95
|
async function readAt(
|
|
96
96
|
api: ReviewStateApi,
|
|
97
|
-
|
|
97
|
+
hasDrivableBox: boolean,
|
|
98
98
|
painted: PaintedPatterns,
|
|
99
99
|
): Promise<ReviewStateRead> {
|
|
100
100
|
const [tip, reviews] = await Promise.all([api.readTip(), api.readReviews()])
|
|
@@ -105,7 +105,7 @@ async function readAt(
|
|
|
105
105
|
const listing: { reviews: readonly ReviewRow[] } = { reviews }
|
|
106
106
|
const scope = resolveReviewScope(listing)
|
|
107
107
|
const uiScope = resolveUiScope(listing)
|
|
108
|
-
const ui = await uiGate(api, painted, reviews, tip,
|
|
108
|
+
const ui = await uiGate(api, painted, reviews, tip, hasDrivableBox)
|
|
109
109
|
if (ui.reason === 'gh-failed') return { kind: 'refused', reason: 'gh-failed' }
|
|
110
110
|
|
|
111
111
|
return {
|
|
@@ -117,7 +117,7 @@ async function readAt(
|
|
|
117
117
|
ui: {
|
|
118
118
|
...uiScope,
|
|
119
119
|
...familyOf(ui, uiScope.commit, tip),
|
|
120
|
-
isBound:
|
|
120
|
+
isBound: hasDrivableBox || uiScope.state === 'open',
|
|
121
121
|
...(ui.carriedFrom !== undefined && { carriedFrom: ui.carriedFrom }),
|
|
122
122
|
...(ui.paintedPaths !== undefined && { paintedPaths: ui.paintedPaths }),
|
|
123
123
|
...(ui.drivenPaths !== undefined && { drivenPaths: ui.drivenPaths }),
|
|
@@ -132,7 +132,7 @@ export async function readReviewState(
|
|
|
132
132
|
): Promise<ReviewStateRead> {
|
|
133
133
|
const evidence = await api.readEvidence()
|
|
134
134
|
if (evidence === undefined) return { kind: 'refused', reason: 'gh-failed' }
|
|
135
|
-
return readAt(api, evidence.
|
|
135
|
+
return readAt(api, evidence.hasDrivableBox, painted)
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
/**
|
|
@@ -158,7 +158,7 @@ export async function waitForReviews(
|
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
for (;;) {
|
|
161
|
-
const read = await readAt(api, evidence.
|
|
161
|
+
const read = await readAt(api, evidence.hasDrivableBox, painted)
|
|
162
162
|
if (read.kind === 'refused') {
|
|
163
163
|
return { ...read, waitedSeconds: waited() }
|
|
164
164
|
}
|
package/src/pr/waiver.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ReviewRow } from '@/pr/review-scope'
|
|
2
|
+
|
|
3
|
+
/** A painted path a review accepted as owing no capture, and why. */
|
|
4
|
+
export interface Waiver {
|
|
5
|
+
readonly path: string
|
|
6
|
+
readonly reason: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The line `review-pr` writes in a pass body to waive one path. The path is
|
|
11
|
+
* kept exactly as spelled, so a waiver matches an uncaptured path only when
|
|
12
|
+
* both spell it alike.
|
|
13
|
+
*/
|
|
14
|
+
const WAIVER_LINE = /^- \*\*Waived capture:\*\* `([^`]+)`:\s*(\S.*)$/
|
|
15
|
+
|
|
16
|
+
const FENCE = /^\s*(```|~~~)/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Every waiver in the review bodies, in order. A line inside a fenced block is
|
|
20
|
+
* a quoted sample rather than a waiver, so it never counts.
|
|
21
|
+
*
|
|
22
|
+
* Review bodies alone are read, never the pull request body or an issue
|
|
23
|
+
* comment, since every session posts as one account and a review is the one
|
|
24
|
+
* surface a worker never writes.
|
|
25
|
+
*/
|
|
26
|
+
export function readWaivers(reviews: readonly ReviewRow[]): Waiver[] {
|
|
27
|
+
const waivers: Waiver[] = []
|
|
28
|
+
for (const review of reviews) {
|
|
29
|
+
let fence: string | undefined
|
|
30
|
+
for (const line of (review.body ?? '').split('\n')) {
|
|
31
|
+
const opener = FENCE.exec(line)?.[1]
|
|
32
|
+
if (opener !== undefined) {
|
|
33
|
+
if (fence === undefined) fence = opener
|
|
34
|
+
else if (opener === fence) fence = undefined
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
if (fence !== undefined) continue
|
|
38
|
+
const match = WAIVER_LINE.exec(line.trimEnd())
|
|
39
|
+
if (match?.[1] !== undefined && match[2] !== undefined) {
|
|
40
|
+
waivers.push({ path: match[1], reason: match[2] })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return waivers
|
|
45
|
+
}
|