@erclx/canon 5.49.0 → 5.51.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 +4024 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/references/fan-in.md +20 -6
- package/claude/skills/canon-cli/SKILL.md +9 -7
- package/claude/skills/canvas-board/REQUIREMENT.md +4 -3
- package/claude/skills/canvas-board/SKILL.md +8 -9
- package/claude/skills/ci-workflow/REQUIREMENT.md +3 -3
- package/claude/skills/ci-workflow/SKILL.md +26 -1
- package/claude/skills/ci-workflow/references/workflows.md +28 -0
- package/claude/skills/context-fold/references/context.md +4 -3
- package/claude/skills/design-record/REQUIREMENT.md +33 -0
- package/claude/skills/design-record/SKILL.md +84 -0
- package/claude/skills/design-record/references/folder.md +69 -0
- package/claude/skills/design-taste/SKILL.md +3 -2
- package/claude/skills/git-followup/SKILL.md +1 -0
- package/claude/skills/git-pr/references/pr.md +1 -1
- package/claude/skills/markdown-craft/REQUIREMENT.md +1 -0
- package/claude/skills/markdown-craft/SKILL.md +6 -1
- package/claude/skills/markdown-craft/references/markdown.md +3 -3
- package/claude/skills/markdown-craft/references/tables.md +30 -0
- package/claude/skills/pick-by-eye/SKILL.md +10 -8
- package/claude/skills/plan-feature/SKILL.md +13 -1
- package/claude/skills/plan-feature/references/batches.md +1 -1
- package/claude/skills/session-worktree/SKILL.md +1 -1
- package/claude/skills/target-check/references/domains.md +8 -6
- package/docs/agents/index.md +2 -2
- package/docs/agents/markdown-audit.md +5 -2
- package/docs/agents/migrate.md +2 -0
- package/docs/agents/targets.md +8 -1
- package/docs/target/migrations.md +8 -1
- package/docs/target/projects.md +1 -1
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map.md +2 -1
- package/docs/workflow/visual-design-workflow.md +2 -1
- package/governance/detect.toml +1 -1
- package/governance/ledger/tooling/ci-workflow.md +6 -0
- package/governance/ledger/ui/design-reference.md +8 -12
- package/governance/rules/tooling/ci-workflow.md +4 -0
- package/governance/rules/ui/design-reference.md +4 -6
- package/package.json +2 -1
- package/scripts/ci/check-browsers.sh +13 -0
- package/scripts/ci/delete-previews.sh +34 -0
- package/scripts/ci/publish-check.sh +29 -0
- package/scripts/ci/refresh-frames-pr.sh +63 -0
- package/scripts/ci/report-baseline.sh +23 -0
- package/scripts/ci/stack-issues.sh +24 -0
- package/scripts/ci/verify-npm-token.sh +14 -0
- package/src/claude/cases/misc.ts +5 -0
- package/src/cli/help.ts +1 -0
- package/src/cli.ts +2 -0
- package/src/commands/changelog.ts +146 -0
- package/src/commands/claude.ts +26 -1
- package/src/commands/gate.ts +57 -16
- package/src/commands/hooks.ts +7 -5
- package/src/commands/markdown.ts +98 -19
- package/src/commands/migrate.ts +77 -0
- package/src/commands/worktrees.ts +273 -0
- package/src/context/audit.ts +6 -5
- package/src/design/budget.ts +1 -1
- package/src/design/components.ts +1 -1
- package/src/design/document.ts +1 -1
- package/src/design/regen.ts +1 -1
- package/src/design/render.ts +1 -1
- package/src/gate/hand-suite.ts +52 -0
- package/src/gate/measures.ts +9 -2
- package/src/gate/sequencer.ts +17 -8
- package/src/gov/detect.ts +28 -10
- package/src/hooks/post-merge.ts +45 -6
- package/src/markdown/gated.ts +5 -7
- package/src/markdown/scope.ts +60 -10
- package/src/markdown/structure.ts +1 -1
- package/src/markdown/tables.ts +150 -0
- package/src/migrate/status.ts +128 -0
- package/src/tasks/validate.ts +15 -12
- package/src/version/changelog.ts +156 -0
- package/src/worktrees/members.ts +540 -0
- package/tooling/base/configs/.husky/post-merge +7 -6
- package/tooling/claude/reference.md +1 -0
- package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +11 -1
- package/tooling/claude/seeds/CLAUDE.md +9 -0
- package/tooling/cloudflare/configs/.github/workflows/deploy.yml +10 -28
- package/tooling/cloudflare/configs/scripts/delete-previews.sh +36 -0
- package/tooling/cloudflare/reference.md +7 -4
- package/tooling/web/reference.md +5 -0
- package/tooling/web/seeds/canon/design/index.md +23 -0
- /package/claude/skills/{canvas-board → design-record}/references/design.md +0 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { compareVersions, parseVersion } from '@/version/compare'
|
|
2
|
+
|
|
3
|
+
export interface ChangelogEntry {
|
|
4
|
+
readonly version: string
|
|
5
|
+
/** The entry's heading line through its last line, trimmed. */
|
|
6
|
+
readonly markdown: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type ChangelogRange =
|
|
10
|
+
| { readonly kind: 'range'; readonly entries: readonly ChangelogEntry[] }
|
|
11
|
+
| { readonly kind: 'unknown-version'; readonly version: string }
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `release-please` writes each release as `## [x.y.z](compare-url) (date)`, so
|
|
15
|
+
* this is the one form the split reads. A heading in any other form is body
|
|
16
|
+
* text of the entry above it, and a change to that form in the release tool
|
|
17
|
+
* leaves the file reading as no entries at all.
|
|
18
|
+
*/
|
|
19
|
+
const HEADING = /^## \[([^\]]+)\]/
|
|
20
|
+
|
|
21
|
+
/** Entries in file order, which `release-please` keeps newest first. */
|
|
22
|
+
export function parseChangelog(text: string): ChangelogEntry[] {
|
|
23
|
+
const entries: ChangelogEntry[] = []
|
|
24
|
+
let current: { version: string; lines: string[] } | undefined
|
|
25
|
+
|
|
26
|
+
const close = () => {
|
|
27
|
+
if (current === undefined) return
|
|
28
|
+
entries.push({
|
|
29
|
+
version: current.version,
|
|
30
|
+
markdown: current.lines.join('\n').trim(),
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const line of text.split('\n')) {
|
|
35
|
+
const match = HEADING.exec(line)
|
|
36
|
+
if (match !== null) {
|
|
37
|
+
close()
|
|
38
|
+
current = { version: match[1] ?? '', lines: [line] }
|
|
39
|
+
} else {
|
|
40
|
+
current?.lines.push(line)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
close()
|
|
44
|
+
|
|
45
|
+
return entries
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The releases after `since` up to and including `running`, oldest first so a
|
|
50
|
+
* reader meets them in the order they shipped.
|
|
51
|
+
*
|
|
52
|
+
* A version the file never listed, or one that does not parse, or a `since`
|
|
53
|
+
* newer than `running`, names no range. Each reads as `unknown-version` rather
|
|
54
|
+
* than an empty success, since an empty range reads as a target with nothing
|
|
55
|
+
* to catch up on. `since` equal to `running` is the one empty range, because
|
|
56
|
+
* that target is current.
|
|
57
|
+
*/
|
|
58
|
+
export function entriesSince(
|
|
59
|
+
entries: readonly ChangelogEntry[],
|
|
60
|
+
since: string,
|
|
61
|
+
running: string,
|
|
62
|
+
): ChangelogRange {
|
|
63
|
+
const sinceParsed = parseVersion(since)
|
|
64
|
+
const runningParsed = parseVersion(running)
|
|
65
|
+
const listed = new Set(entries.map((entry) => entry.version))
|
|
66
|
+
|
|
67
|
+
if (sinceParsed === undefined || !listed.has(since)) {
|
|
68
|
+
return { kind: 'unknown-version', version: since }
|
|
69
|
+
}
|
|
70
|
+
if (runningParsed === undefined || !listed.has(running)) {
|
|
71
|
+
return { kind: 'unknown-version', version: running }
|
|
72
|
+
}
|
|
73
|
+
if (compareVersions(sinceParsed, runningParsed) > 0) {
|
|
74
|
+
return { kind: 'unknown-version', version: since }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const inRange = entries.filter((entry) => {
|
|
78
|
+
const parsed = parseVersion(entry.version)
|
|
79
|
+
return (
|
|
80
|
+
parsed !== undefined &&
|
|
81
|
+
compareVersions(parsed, sinceParsed) > 0 &&
|
|
82
|
+
compareVersions(parsed, runningParsed) <= 0
|
|
83
|
+
)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
return { kind: 'range', entries: inRange.toReversed() }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ChangelogInput {
|
|
90
|
+
/** The file's text, or undefined when it could not be read. */
|
|
91
|
+
readonly text: string | undefined
|
|
92
|
+
readonly running: string
|
|
93
|
+
/** An explicit start, which wins over the stamp. */
|
|
94
|
+
readonly since?: string
|
|
95
|
+
/** The version each stamped domain recorded. */
|
|
96
|
+
readonly stamped: readonly string[]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type ChangelogReport =
|
|
100
|
+
| {
|
|
101
|
+
readonly kind: 'range'
|
|
102
|
+
readonly since: string
|
|
103
|
+
readonly running: string
|
|
104
|
+
readonly entries: readonly ChangelogEntry[]
|
|
105
|
+
}
|
|
106
|
+
| {
|
|
107
|
+
readonly kind: 'refused'
|
|
108
|
+
readonly reason: 'no-stamp' | 'no-changelog' | 'unknown-version'
|
|
109
|
+
readonly version?: string
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Every input arrives as a value, so the refusals are decided here rather than
|
|
114
|
+
* where the file and the stamp are read. A file with no release heading refuses
|
|
115
|
+
* as `no-changelog`, since the entries it would range over are not there.
|
|
116
|
+
*/
|
|
117
|
+
export function resolveChangelog(input: ChangelogInput): ChangelogReport {
|
|
118
|
+
const since = input.since ?? oldestVersion(input.stamped)
|
|
119
|
+
if (since === undefined) return { kind: 'refused', reason: 'no-stamp' }
|
|
120
|
+
|
|
121
|
+
const entries = input.text === undefined ? [] : parseChangelog(input.text)
|
|
122
|
+
if (entries.length === 0) return { kind: 'refused', reason: 'no-changelog' }
|
|
123
|
+
|
|
124
|
+
const range = entriesSince(entries, since, input.running)
|
|
125
|
+
if (range.kind === 'unknown-version') {
|
|
126
|
+
return {
|
|
127
|
+
kind: 'refused',
|
|
128
|
+
reason: 'unknown-version',
|
|
129
|
+
version: range.version,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
kind: 'range',
|
|
135
|
+
since,
|
|
136
|
+
running: input.running,
|
|
137
|
+
entries: range.entries,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The oldest version that parses, or undefined when none does. */
|
|
142
|
+
export function oldestVersion(versions: readonly string[]): string | undefined {
|
|
143
|
+
let oldest:
|
|
144
|
+
| { raw: string; parsed: NonNullable<ReturnType<typeof parseVersion>> }
|
|
145
|
+
| undefined
|
|
146
|
+
|
|
147
|
+
for (const raw of versions) {
|
|
148
|
+
const parsed = parseVersion(raw)
|
|
149
|
+
if (parsed === undefined) continue
|
|
150
|
+
if (oldest === undefined || compareVersions(parsed, oldest.parsed) < 0) {
|
|
151
|
+
oldest = { raw, parsed }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return oldest?.raw
|
|
156
|
+
}
|
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import { execa } from 'execa'
|
|
2
|
+
import { gitEnv } from '@/git/env'
|
|
3
|
+
import {
|
|
4
|
+
type ReclaimOptions,
|
|
5
|
+
reclaimReport,
|
|
6
|
+
type Refusal,
|
|
7
|
+
type Unreadable,
|
|
8
|
+
type WorktreeVerdict,
|
|
9
|
+
} from '@/worktrees/reclaim'
|
|
10
|
+
import { removeReclaimable } from '@/worktrees/remove'
|
|
11
|
+
|
|
12
|
+
const GH_TIMEOUT_MS = 30_000
|
|
13
|
+
const GIT_TIMEOUT_MS = 60_000
|
|
14
|
+
|
|
15
|
+
/** Matches the reclaim's merged read, so an open pull request older than this is missed the same way. */
|
|
16
|
+
const OPEN_LIMIT = 200
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The fixed lead the integrator's body carries, stated in the auto-ship
|
|
20
|
+
* fan-in reference. Anything outside this one line is never read, so a branch
|
|
21
|
+
* named in prose is never a candidate.
|
|
22
|
+
*/
|
|
23
|
+
const MEMBER_LINE = /^[ \t]*(?:[-*][ \t]+)?Fan-in members:(.*)$/m
|
|
24
|
+
const BACKTICKED = /`([^`\s]+)`/g
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Why the whole run was refused before any member was judged. Every one of
|
|
28
|
+
* these deletes nothing at all, since each is an input the per-branch checks
|
|
29
|
+
* stand on or a body that names something no merge retires.
|
|
30
|
+
*/
|
|
31
|
+
export type MembersRefusal =
|
|
32
|
+
| Extract<
|
|
33
|
+
Unreadable,
|
|
34
|
+
'gh-missing' | 'gh-failed' | 'sessions-unreadable' | 'sessions-unchecked'
|
|
35
|
+
>
|
|
36
|
+
| 'not-merged'
|
|
37
|
+
| 'no-members'
|
|
38
|
+
| 'trunk-named'
|
|
39
|
+
| 'head-named'
|
|
40
|
+
| 'open-pull-request'
|
|
41
|
+
| 'head-unreadable'
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Why one half of one member was kept. The worktree reasons are the reclaim's
|
|
45
|
+
* own, since the same reading decided them.
|
|
46
|
+
*/
|
|
47
|
+
export type MemberRefusal =
|
|
48
|
+
| 'not-in-merged-head'
|
|
49
|
+
| 'remote-unreadable'
|
|
50
|
+
| 'worktree-kept'
|
|
51
|
+
| Exclude<Refusal, 'no-merged-pull-request'>
|
|
52
|
+
|
|
53
|
+
export type HalfState =
|
|
54
|
+
| 'deleted'
|
|
55
|
+
| 'would-delete'
|
|
56
|
+
| 'kept'
|
|
57
|
+
| 'absent'
|
|
58
|
+
| 'failed'
|
|
59
|
+
|
|
60
|
+
export interface Half {
|
|
61
|
+
readonly state: HalfState
|
|
62
|
+
/** Every reason the half was kept, empty for any other state. */
|
|
63
|
+
readonly reasons: readonly MemberRefusal[]
|
|
64
|
+
/** The git error on a failed half, null otherwise. */
|
|
65
|
+
readonly detail: string | null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface MemberOutcome {
|
|
69
|
+
readonly branch: string
|
|
70
|
+
readonly origin: Half
|
|
71
|
+
/** Null when no worktree holds the branch. */
|
|
72
|
+
readonly worktree: (Half & { readonly path: string }) | null
|
|
73
|
+
readonly local: Half
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type MembersReport =
|
|
77
|
+
| {
|
|
78
|
+
readonly kind: 'refused'
|
|
79
|
+
readonly reason: MembersRefusal
|
|
80
|
+
readonly detail: string
|
|
81
|
+
/** The branches the member line named, empty when no line was read. */
|
|
82
|
+
readonly members: readonly string[]
|
|
83
|
+
}
|
|
84
|
+
| { readonly kind: 'read'; readonly members: readonly MemberOutcome[] }
|
|
85
|
+
|
|
86
|
+
export interface CommandResult {
|
|
87
|
+
readonly exitCode: number
|
|
88
|
+
readonly stdout: string
|
|
89
|
+
readonly stderr: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type Runner = (cwd: string, args: readonly string[]) => Promise<CommandResult>
|
|
93
|
+
|
|
94
|
+
export interface MembersOptions {
|
|
95
|
+
/** Where the reclaim reading runs, which is what refuses the worktree the caller stands in. */
|
|
96
|
+
readonly cwd: string
|
|
97
|
+
/** The main worktree root, where every git write runs, for the reason `removeReclaimable` states. */
|
|
98
|
+
readonly root: string
|
|
99
|
+
readonly dryRun?: boolean
|
|
100
|
+
/** Null when `gh` is not on the path. */
|
|
101
|
+
readonly gh?: Runner | null
|
|
102
|
+
readonly git?: Runner
|
|
103
|
+
/** Overrides for the reclaim reading, past the merged read this module always supplies. */
|
|
104
|
+
readonly reclaim?: Omit<ReclaimOptions, 'cwd' | 'mergedPullRequests'>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function run(
|
|
108
|
+
command: string,
|
|
109
|
+
cwd: string,
|
|
110
|
+
args: readonly string[],
|
|
111
|
+
timeout: number,
|
|
112
|
+
): Promise<CommandResult> {
|
|
113
|
+
// A hook exports git's resolution variables, which beat both `-C` and the
|
|
114
|
+
// working directory `gh` resolves its repository through.
|
|
115
|
+
const result = await execa(command, [...args], {
|
|
116
|
+
cwd,
|
|
117
|
+
timeout,
|
|
118
|
+
env: gitEnv(),
|
|
119
|
+
extendEnv: false,
|
|
120
|
+
reject: false,
|
|
121
|
+
})
|
|
122
|
+
return {
|
|
123
|
+
exitCode: result.exitCode ?? 1,
|
|
124
|
+
stdout: result.stdout,
|
|
125
|
+
stderr: result.stderr.trim(),
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const runGit: Runner = (cwd, args) => run('git', cwd, args, GIT_TIMEOUT_MS)
|
|
130
|
+
const runGh: Runner = (cwd, args) => run('gh', cwd, args, GH_TIMEOUT_MS)
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Reads the branches an integrator's body names on its member line, in the
|
|
134
|
+
* order named. Only backticked names after the fixed lead count, and a body
|
|
135
|
+
* carrying no such line names nobody.
|
|
136
|
+
*/
|
|
137
|
+
export function parseMembers(body: string): string[] {
|
|
138
|
+
const line = MEMBER_LINE.exec(body)
|
|
139
|
+
if (line === null) return []
|
|
140
|
+
|
|
141
|
+
const names = [...(line[1] ?? '').matchAll(BACKTICKED)].map(
|
|
142
|
+
(match) => match[1] ?? '',
|
|
143
|
+
)
|
|
144
|
+
return [...new Set(names.filter((name) => name.length > 0))]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface PullRequestView {
|
|
148
|
+
readonly state: string
|
|
149
|
+
readonly body: string
|
|
150
|
+
readonly headRefName: string
|
|
151
|
+
readonly headRefOid: string
|
|
152
|
+
readonly baseRefName: string
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function isView(value: unknown): value is PullRequestView {
|
|
156
|
+
if (value === null || typeof value !== 'object') return false
|
|
157
|
+
const fields = value as Record<string, unknown>
|
|
158
|
+
return ['state', 'body', 'headRefName', 'headRefOid', 'baseRefName'].every(
|
|
159
|
+
(key) => typeof fields[key] === 'string',
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseJson(stdout: string): unknown {
|
|
164
|
+
try {
|
|
165
|
+
return JSON.parse(stdout)
|
|
166
|
+
} catch {
|
|
167
|
+
return undefined
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function refused(
|
|
172
|
+
reason: MembersRefusal,
|
|
173
|
+
detail: string,
|
|
174
|
+
members: readonly string[] = [],
|
|
175
|
+
): MembersReport {
|
|
176
|
+
return { kind: 'refused', reason, detail, members }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const kept = (...reasons: MemberRefusal[]): Half => ({
|
|
180
|
+
state: 'kept',
|
|
181
|
+
reasons,
|
|
182
|
+
detail: null,
|
|
183
|
+
})
|
|
184
|
+
const plain = (state: HalfState, detail: string | null = null): Half => ({
|
|
185
|
+
state,
|
|
186
|
+
reasons: [],
|
|
187
|
+
detail,
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
interface Plan {
|
|
191
|
+
readonly branch: string
|
|
192
|
+
/** The tip origin holds, null when origin has no such branch. */
|
|
193
|
+
readonly originTip: string | null
|
|
194
|
+
readonly origin: Half
|
|
195
|
+
readonly verdict: WorktreeVerdict | null
|
|
196
|
+
readonly localTip: string | null
|
|
197
|
+
readonly worktree: Half | null
|
|
198
|
+
readonly local: Half
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Retires the fan-in members an integrator's merged pull request names: each
|
|
203
|
+
* one's origin branch, its worktree, and its local branch.
|
|
204
|
+
*
|
|
205
|
+
* Merged here means reachable from the integrator's merged head, not merged
|
|
206
|
+
* into the trunk. The integrator squash merges, so no member is ever an
|
|
207
|
+
* ancestor of the trunk, but every member was merged into the integrator's
|
|
208
|
+
* branch before it shipped. A commit landing on a member after that merge is
|
|
209
|
+
* work the squash did not carry, and its tip stops being an ancestor.
|
|
210
|
+
*
|
|
211
|
+
* Origin and local are judged apart, since an unpushed local commit and a
|
|
212
|
+
* commit pushed from elsewhere each keep only their own half. The worktree
|
|
213
|
+
* takes the reclaim's reading with the members injected as its merged answer,
|
|
214
|
+
* so a held, dirty, or current worktree is refused by the same rules as
|
|
215
|
+
* everywhere else, and its local branch stays with it because git refuses to
|
|
216
|
+
* delete a branch a registered worktree holds.
|
|
217
|
+
*
|
|
218
|
+
* Every input is read before anything is deleted, and an unreadable one
|
|
219
|
+
* refuses the whole run, for the reason `reclaimReport` states.
|
|
220
|
+
*/
|
|
221
|
+
export async function retireMembers(
|
|
222
|
+
pullRequest: number,
|
|
223
|
+
opts: MembersOptions,
|
|
224
|
+
): Promise<MembersReport> {
|
|
225
|
+
const { cwd, root } = opts
|
|
226
|
+
const dryRun = opts.dryRun ?? false
|
|
227
|
+
const gh = opts.gh === undefined ? (Bun.which('gh') ? runGh : null) : opts.gh
|
|
228
|
+
const git = opts.git ?? runGit
|
|
229
|
+
|
|
230
|
+
if (gh === null) {
|
|
231
|
+
return refused(
|
|
232
|
+
'gh-missing',
|
|
233
|
+
'gh is not on the path, so the pull request could not be read.',
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const viewed = await gh(root, [
|
|
238
|
+
'pr',
|
|
239
|
+
'view',
|
|
240
|
+
String(pullRequest),
|
|
241
|
+
'--json',
|
|
242
|
+
'state,body,headRefName,headRefOid,baseRefName',
|
|
243
|
+
])
|
|
244
|
+
const view = viewed.exitCode === 0 ? parseJson(viewed.stdout) : undefined
|
|
245
|
+
if (!isView(view)) {
|
|
246
|
+
return refused(
|
|
247
|
+
'gh-failed',
|
|
248
|
+
viewed.stderr || `gh pr view ${pullRequest} returned no readable record.`,
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const members = parseMembers(view.body)
|
|
253
|
+
if (view.state !== 'MERGED') {
|
|
254
|
+
return refused(
|
|
255
|
+
'not-merged',
|
|
256
|
+
`#${pullRequest} is ${view.state.toLowerCase()}, so nothing it names has shipped.`,
|
|
257
|
+
members,
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
if (members.length === 0) {
|
|
261
|
+
return refused(
|
|
262
|
+
'no-members',
|
|
263
|
+
`#${pullRequest} carries no "Fan-in members:" line.`,
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (members.includes(view.baseRefName)) {
|
|
268
|
+
return refused(
|
|
269
|
+
'trunk-named',
|
|
270
|
+
`The member line names ${view.baseRefName}, the branch #${pullRequest} merged into.`,
|
|
271
|
+
members,
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
if (members.includes(view.headRefName)) {
|
|
275
|
+
return refused(
|
|
276
|
+
'head-named',
|
|
277
|
+
`The member line names ${view.headRefName}, the integrator's own branch.`,
|
|
278
|
+
members,
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const listed = await gh(root, [
|
|
283
|
+
'pr',
|
|
284
|
+
'list',
|
|
285
|
+
'--state',
|
|
286
|
+
'open',
|
|
287
|
+
'--limit',
|
|
288
|
+
String(OPEN_LIMIT),
|
|
289
|
+
'--json',
|
|
290
|
+
'headRefName,number',
|
|
291
|
+
])
|
|
292
|
+
const open = listed.exitCode === 0 ? parseJson(listed.stdout) : undefined
|
|
293
|
+
if (!Array.isArray(open)) {
|
|
294
|
+
return refused(
|
|
295
|
+
'gh-failed',
|
|
296
|
+
listed.stderr || 'gh pr list returned no readable record.',
|
|
297
|
+
members,
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
const openHeads = new Set(
|
|
301
|
+
open.flatMap((row: unknown) =>
|
|
302
|
+
row !== null &&
|
|
303
|
+
typeof row === 'object' &&
|
|
304
|
+
typeof (row as Record<string, unknown>).headRefName === 'string'
|
|
305
|
+
? [(row as Record<string, string>).headRefName]
|
|
306
|
+
: [],
|
|
307
|
+
),
|
|
308
|
+
)
|
|
309
|
+
const stillOpen = members.filter((branch) => openHeads.has(branch))
|
|
310
|
+
if (stillOpen.length > 0) {
|
|
311
|
+
return refused(
|
|
312
|
+
'open-pull-request',
|
|
313
|
+
`${stillOpen.join(', ')} has an open pull request of its own, so it is not a member this merge retires.`,
|
|
314
|
+
members,
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const reading = await reclaimReport({
|
|
319
|
+
...opts.reclaim,
|
|
320
|
+
cwd,
|
|
321
|
+
mergedPullRequests: async () => ({
|
|
322
|
+
kind: 'read',
|
|
323
|
+
merged: members.map((branch) => ({ branch, number: pullRequest })),
|
|
324
|
+
}),
|
|
325
|
+
})
|
|
326
|
+
if (reading.kind === 'unreadable') {
|
|
327
|
+
// The merged read is injected, so only the session refusals reach here.
|
|
328
|
+
const reason =
|
|
329
|
+
reading.reason === 'sessions-unchecked'
|
|
330
|
+
? 'sessions-unchecked'
|
|
331
|
+
: 'sessions-unreadable'
|
|
332
|
+
return refused(reason, reading.detail, members)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// With head-branch deletion on, the integrator's branch is gone from origin
|
|
336
|
+
// by now, and GitHub keeps this ref for every pull request it has opened.
|
|
337
|
+
const fetched = await git(root, [
|
|
338
|
+
'fetch',
|
|
339
|
+
'--quiet',
|
|
340
|
+
'--no-tags',
|
|
341
|
+
'origin',
|
|
342
|
+
`refs/pull/${pullRequest}/head`,
|
|
343
|
+
])
|
|
344
|
+
const fetchHead =
|
|
345
|
+
fetched.exitCode === 0
|
|
346
|
+
? await git(root, ['rev-parse', '--verify', '--quiet', 'FETCH_HEAD'])
|
|
347
|
+
: null
|
|
348
|
+
if (fetchHead === null || fetchHead.stdout.trim() !== view.headRefOid) {
|
|
349
|
+
return refused(
|
|
350
|
+
'head-unreadable',
|
|
351
|
+
fetched.stderr ||
|
|
352
|
+
`refs/pull/${pullRequest}/head does not resolve to ${view.headRefOid}, the head GitHub reports merged.`,
|
|
353
|
+
members,
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const merged = async (tip: string) =>
|
|
358
|
+
(await git(root, ['merge-base', '--is-ancestor', tip, view.headRefOid]))
|
|
359
|
+
.exitCode === 0
|
|
360
|
+
|
|
361
|
+
const plans: Plan[] = []
|
|
362
|
+
for (const branch of members) {
|
|
363
|
+
plans.push(
|
|
364
|
+
await judge(branch, {
|
|
365
|
+
git,
|
|
366
|
+
root,
|
|
367
|
+
merged,
|
|
368
|
+
dryRun,
|
|
369
|
+
verdict:
|
|
370
|
+
reading.worktrees.find((entry) => entry.branch === branch) ?? null,
|
|
371
|
+
}),
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (dryRun) {
|
|
376
|
+
return {
|
|
377
|
+
kind: 'read',
|
|
378
|
+
members: plans.map(({ branch, origin, verdict, worktree, local }) => ({
|
|
379
|
+
branch,
|
|
380
|
+
origin,
|
|
381
|
+
worktree:
|
|
382
|
+
verdict === null || worktree === null
|
|
383
|
+
? null
|
|
384
|
+
: { ...worktree, path: verdict.path },
|
|
385
|
+
local,
|
|
386
|
+
})),
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const outcomes: MemberOutcome[] = []
|
|
391
|
+
for (const plan of plans) outcomes.push(await act(plan, git, root))
|
|
392
|
+
return { kind: 'read', members: outcomes }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
interface JudgeContext {
|
|
396
|
+
readonly git: Runner
|
|
397
|
+
readonly root: string
|
|
398
|
+
readonly merged: (tip: string) => Promise<boolean>
|
|
399
|
+
readonly dryRun: boolean
|
|
400
|
+
readonly verdict: WorktreeVerdict | null
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Decides each half of one member from its own tip, deleting nothing. */
|
|
404
|
+
async function judge(branch: string, context: JudgeContext): Promise<Plan> {
|
|
405
|
+
const { git, root, merged, dryRun, verdict } = context
|
|
406
|
+
const go: Half = plain(dryRun ? 'would-delete' : 'deleted')
|
|
407
|
+
|
|
408
|
+
// Read from origin itself rather than the tracking ref, which holds only
|
|
409
|
+
// what the last fetch saw and misses a commit pushed since.
|
|
410
|
+
const remote = await git(root, [
|
|
411
|
+
'ls-remote',
|
|
412
|
+
'--heads',
|
|
413
|
+
'origin',
|
|
414
|
+
`refs/heads/${branch}`,
|
|
415
|
+
])
|
|
416
|
+
const originTip =
|
|
417
|
+
remote.exitCode === 0 ? (remote.stdout.trim().split(/\s/)[0] ?? '') : null
|
|
418
|
+
const origin =
|
|
419
|
+
originTip === null
|
|
420
|
+
? kept('remote-unreadable')
|
|
421
|
+
: originTip === ''
|
|
422
|
+
? plain('absent')
|
|
423
|
+
: (await merged(originTip))
|
|
424
|
+
? go
|
|
425
|
+
: kept('not-in-merged-head')
|
|
426
|
+
|
|
427
|
+
const head = await git(root, [
|
|
428
|
+
'rev-parse',
|
|
429
|
+
'--verify',
|
|
430
|
+
'--quiet',
|
|
431
|
+
`refs/heads/${branch}`,
|
|
432
|
+
])
|
|
433
|
+
const localTip = head.exitCode === 0 ? head.stdout.trim() : null
|
|
434
|
+
const localMerged = localTip !== null && (await merged(localTip))
|
|
435
|
+
|
|
436
|
+
const worktreeReasons: MemberRefusal[] = [
|
|
437
|
+
...(verdict?.refusals.filter(
|
|
438
|
+
(refusal): refusal is Exclude<Refusal, 'no-merged-pull-request'> =>
|
|
439
|
+
refusal !== 'no-merged-pull-request',
|
|
440
|
+
) ?? []),
|
|
441
|
+
...(localTip !== null && !localMerged
|
|
442
|
+
? (['not-in-merged-head'] as const)
|
|
443
|
+
: []),
|
|
444
|
+
]
|
|
445
|
+
const worktree =
|
|
446
|
+
verdict === null
|
|
447
|
+
? null
|
|
448
|
+
: worktreeReasons.length > 0
|
|
449
|
+
? kept(...worktreeReasons)
|
|
450
|
+
: go
|
|
451
|
+
|
|
452
|
+
const local =
|
|
453
|
+
localTip === null
|
|
454
|
+
? plain('absent')
|
|
455
|
+
: !localMerged
|
|
456
|
+
? kept('not-in-merged-head')
|
|
457
|
+
: worktree !== null && worktree.state === 'kept'
|
|
458
|
+
? kept('worktree-kept')
|
|
459
|
+
: go
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
branch,
|
|
463
|
+
originTip: originTip === '' ? null : originTip,
|
|
464
|
+
origin,
|
|
465
|
+
verdict,
|
|
466
|
+
localTip,
|
|
467
|
+
worktree,
|
|
468
|
+
local,
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Carries out one member's plan: origin first, then the worktree and its branch. */
|
|
473
|
+
async function act(
|
|
474
|
+
plan: Plan,
|
|
475
|
+
git: Runner,
|
|
476
|
+
root: string,
|
|
477
|
+
): Promise<MemberOutcome> {
|
|
478
|
+
const { branch, verdict } = plan
|
|
479
|
+
|
|
480
|
+
let origin = plan.origin
|
|
481
|
+
if (origin.state === 'deleted' && plan.originTip !== null) {
|
|
482
|
+
// The lease names the tip judged above, so a commit pushed between the
|
|
483
|
+
// judgment and the push keeps the branch rather than going with it.
|
|
484
|
+
const pushed = await git(root, [
|
|
485
|
+
'push',
|
|
486
|
+
'--quiet',
|
|
487
|
+
`--force-with-lease=refs/heads/${branch}:${plan.originTip}`,
|
|
488
|
+
'origin',
|
|
489
|
+
`:refs/heads/${branch}`,
|
|
490
|
+
])
|
|
491
|
+
if (pushed.exitCode !== 0) origin = plain('failed', pushed.stderr || null)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (verdict !== null && plan.worktree !== null) {
|
|
495
|
+
if (plan.worktree.state !== 'deleted') {
|
|
496
|
+
return {
|
|
497
|
+
branch,
|
|
498
|
+
origin,
|
|
499
|
+
worktree: { ...plan.worktree, path: verdict.path },
|
|
500
|
+
local: plan.local,
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const [removed] = (
|
|
505
|
+
await removeReclaimable([{ ...verdict, reclaimable: true }], {
|
|
506
|
+
cwd: root,
|
|
507
|
+
git,
|
|
508
|
+
})
|
|
509
|
+
).outcomes
|
|
510
|
+
const worktreeGone = removed !== undefined && removed.failedAt !== 'remove'
|
|
511
|
+
const worktree = worktreeGone
|
|
512
|
+
? plain('deleted')
|
|
513
|
+
: plain('failed', removed?.detail ?? null)
|
|
514
|
+
const local = !worktreeGone
|
|
515
|
+
? kept('worktree-kept')
|
|
516
|
+
: removed.failedAt === 'branch'
|
|
517
|
+
? plain('failed', removed.detail)
|
|
518
|
+
: plain('deleted')
|
|
519
|
+
return {
|
|
520
|
+
branch,
|
|
521
|
+
origin,
|
|
522
|
+
worktree: { ...worktree, path: verdict.path },
|
|
523
|
+
local,
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
let local = plan.local
|
|
528
|
+
if (local.state === 'deleted' && plan.localTip !== null) {
|
|
529
|
+
// The old value guards the delete the way the lease guards the push.
|
|
530
|
+
const deleted = await git(root, [
|
|
531
|
+
'update-ref',
|
|
532
|
+
'-d',
|
|
533
|
+
`refs/heads/${branch}`,
|
|
534
|
+
plan.localTip,
|
|
535
|
+
])
|
|
536
|
+
if (deleted.exitCode !== 0) local = plain('failed', deleted.stderr || null)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return { branch, origin, worktree: null, local }
|
|
540
|
+
}
|