@skitterbyte/skitterspec-jira 0.0.0-stage → 2.0.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/MIGRATION.md +1266 -0
- package/README.md +44 -2
- package/assets/claude-md-section.md +85 -0
- package/assets/commands/allow-main.md +33 -0
- package/assets/commands/spec-connect.md +13 -0
- package/assets/commands/spec-live.md +14 -0
- package/assets/commands/spec-remote-review.md +37 -0
- package/assets/commands/spec-skip.md +24 -0
- package/assets/core/SETUP.md +192 -0
- package/assets/core/env.config.json.example +56 -0
- package/assets/core/env.config.md +606 -0
- package/assets/core/gating.config.json.example +4 -0
- package/assets/core/gating.config.md +81 -0
- package/assets/core/jira.config.json.example +18 -0
- package/assets/core/jira.config.md +88 -0
- package/assets/hooks/main-guard.cjs +162 -0
- package/assets/hooks/review-gate.cjs +146 -0
- package/assets/review/page.html +3384 -0
- package/assets/rules/commit-trailers.md +96 -0
- package/assets/rules/negative-checks.md +69 -0
- package/assets/rules/offered-unblocks.md +100 -0
- package/assets/rules/spec-planning.md +702 -0
- package/assets/rules/spec-reports.md +572 -0
- package/assets/skills/no-spec/SKILL.md +249 -0
- package/assets/skills/spec/SKILL.md +714 -0
- package/assets/skills/spec-bug/SKILL.md +585 -0
- package/assets/skills/spec-cancel/SKILL.md +261 -0
- package/assets/skills/spec-complete/SKILL.md +451 -0
- package/assets/skills/spec-diff/SKILL.md +870 -0
- package/assets/skills/spec-hotfix/SKILL.md +607 -0
- package/assets/skills/spec-init/SKILL.md +178 -0
- package/assets/skills/spec-jira-setup/SKILL.md +221 -0
- package/assets/skills/spec-list/SKILL.md +173 -0
- package/assets/skills/spec-next/SKILL.md +678 -0
- package/assets/skills/spec-push/SKILL.md +306 -0
- package/assets/skills/spec-review/SKILL.md +256 -0
- package/assets/skills/spec-reviewed/SKILL.md +284 -0
- package/assets/skills/spec-start/SKILL.md +496 -0
- package/assets/skills/spec-status/SKILL.md +114 -0
- package/assets/skills/spec-sync/SKILL.md +220 -0
- package/assets/skills/spec-to-main/SKILL.md +141 -0
- package/bin/skitterspec-jira.js +68 -0
- package/package.json +40 -4
- package/src/cli.js +5967 -0
- package/src/deprecate.js +138 -0
- package/src/env/bootstrap.js +188 -0
- package/src/env/building.js +143 -0
- package/src/env/classify.js +176 -0
- package/src/env/commitcmd.js +108 -0
- package/src/env/config.js +850 -0
- package/src/env/dev.js +63 -0
- package/src/env/glob.js +34 -0
- package/src/env/hooks.js +226 -0
- package/src/env/hotfix.js +413 -0
- package/src/env/hotfixland.js +302 -0
- package/src/env/integrate.js +167 -0
- package/src/env/live.js +479 -0
- package/src/env/mainguard.js +299 -0
- package/src/env/provision.js +466 -0
- package/src/env/proxy.js +225 -0
- package/src/env/prune.js +119 -0
- package/src/env/registry.js +159 -0
- package/src/env/render.js +17 -0
- package/src/env/resolve.js +716 -0
- package/src/env/review.js +2622 -0
- package/src/env/reviewers/coderabbit.js +228 -0
- package/src/env/reviewers.js +517 -0
- package/src/env/serve.js +1044 -0
- package/src/env/supervise.js +157 -0
- package/src/env/teardown.js +248 -0
- package/src/env/trust.js +87 -0
- package/src/gating.js +155 -0
- package/src/init.js +1150 -0
- package/src/lines-diff.js +114 -0
- package/src/prompts.js +129 -0
- package/src/vendor/jira/adf.js +414 -0
- package/src/vendor/jira/api.js +295 -0
- package/src/vendor/jira/cli-sync.js +1252 -0
- package/src/vendor/jira/commands.js +44 -0
- package/src/vendor/jira/config.js +286 -0
- package/src/vendor/jira/credentials.js +108 -0
- package/src/vendor/jira/dialect.js +45 -0
- package/src/vendor/jira/mcp.js +129 -0
- package/src/vendor/provider-kit/index.js +41 -0
- package/src/vendor/provider-kit/src/adapter.js +23 -0
- package/src/vendor/provider-kit/src/apply.js +207 -0
- package/src/vendor/provider-kit/src/args.js +68 -0
- package/src/vendor/provider-kit/src/assignment.js +277 -0
- package/src/vendor/provider-kit/src/config.js +251 -0
- package/src/vendor/provider-kit/src/credentials.js +404 -0
- package/src/vendor/provider-kit/src/doctor.js +35 -0
- package/src/vendor/provider-kit/src/http.js +63 -0
- package/src/vendor/provider-kit/src/identity.js +93 -0
- package/src/vendor/provider-kit/src/list-assignee.js +71 -0
- package/src/vendor/provider-kit/src/list-rows.js +35 -0
- package/src/vendor/provider-kit/src/md-compare.js +39 -0
- package/src/vendor/provider-kit/src/md-tables.js +94 -0
- package/src/vendor/provider-kit/src/prompt.js +50 -0
- package/src/vendor/provider-kit/src/released.js +219 -0
- package/src/vendor/provider-kit/src/specs.js +248 -0
- package/src/vendor/provider-kit/src/transport.js +22 -0
- package/src/vendor/sync-core/index.js +92 -0
- package/src/vendor/sync-core/src/base.js +81 -0
- package/src/vendor/sync-core/src/compare.js +338 -0
- package/src/vendor/sync-core/src/dialect.js +51 -0
- package/src/vendor/sync-core/src/fields.js +36 -0
- package/src/vendor/sync-core/src/legacy.js +100 -0
- package/src/vendor/sync-core/src/normalize.js +971 -0
- package/src/vendor/sync-core/src/push.js +85 -0
- package/src/vendor/sync-core/src/retarget.js +278 -0
- package/src/vendor/sync-core/src/task-block.js +308 -0
- package/src/vendor/sync-core/src/verify.js +67 -0
- package/src/vendor/sync-core/src/write.js +191 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Run a hotfix land's steps — the engine half of `spec-env hotfix land`.
|
|
5
|
+
*
|
|
6
|
+
* `planHotfixLand` (hotfix.js) decides WHAT the land is; this module does it,
|
|
7
|
+
* the way `bootstrap.js` does a worktree's steps. It used to be a printed plan
|
|
8
|
+
* the skill ran by hand, and that stopped being possible the moment the release
|
|
9
|
+
* could be a project's own command: the prod tag is only known once that
|
|
10
|
+
* command has run and said what it cut, so no plan printed beforehand can name
|
|
11
|
+
* it.
|
|
12
|
+
*
|
|
13
|
+
* The run stops at the first step that fails, and the release is reported to
|
|
14
|
+
* `io.record` the moment it succeeds — so a land that stops later (a refused
|
|
15
|
+
* push, a conflicted cherry-pick) can be re-run without cutting a second
|
|
16
|
+
* release. skittership's own command refuses a second one anyway, since HEAD is
|
|
17
|
+
* then already tagged; the record is what lets the re-run carry on instead of
|
|
18
|
+
* stopping there.
|
|
19
|
+
*
|
|
20
|
+
* Every side effect goes through `io.spawn`, so tests drive it with a fake.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const { spawnSync } = require('node:child_process')
|
|
24
|
+
|
|
25
|
+
// The fields skittership's `release-hotfix.cjs --json` prints, and their types.
|
|
26
|
+
// `versionCommit` is null only on its `--dry-run`, which skitterspec never
|
|
27
|
+
// passes — so a null here is accepted, and means "no version commit to leave
|
|
28
|
+
// behind", exactly as the built-in tagger's.
|
|
29
|
+
const RELEASE_FIELDS = {
|
|
30
|
+
tag: 'string',
|
|
31
|
+
version: 'string',
|
|
32
|
+
baseTag: 'string',
|
|
33
|
+
branch: 'string',
|
|
34
|
+
versionCommit: 'string|null',
|
|
35
|
+
pushed: 'boolean',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse a release command's stdout STRICTLY: the whole of it, trimmed, must be
|
|
40
|
+
* one JSON object carrying every field above. Anything else — a package
|
|
41
|
+
* manager's run banner, a stray log line, a missing field — is a refusal rather
|
|
42
|
+
* than a best guess, because a guessed tag is a deploy of the wrong thing.
|
|
43
|
+
*/
|
|
44
|
+
function parseRelease(stdout) {
|
|
45
|
+
const text = String(stdout || '').trim()
|
|
46
|
+
if (!text) return { ok: false, reason: 'printed nothing on stdout — expected one JSON line' }
|
|
47
|
+
let obj
|
|
48
|
+
try {
|
|
49
|
+
obj = JSON.parse(text)
|
|
50
|
+
} catch {
|
|
51
|
+
const first = text.split('\n')[0].slice(0, 120)
|
|
52
|
+
return { ok: false, reason: `stdout is not one JSON object (starts: ${first})` }
|
|
53
|
+
}
|
|
54
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
55
|
+
return { ok: false, reason: 'stdout is JSON but not an object' }
|
|
56
|
+
}
|
|
57
|
+
for (const [key, type] of Object.entries(RELEASE_FIELDS)) {
|
|
58
|
+
const v = obj[key]
|
|
59
|
+
const fits = type
|
|
60
|
+
.split('|')
|
|
61
|
+
.some((t) => (t === 'null' ? v === null : typeof v === t && (t !== 'string' || v.length)))
|
|
62
|
+
if (!fits) return { ok: false, reason: `JSON field "${key}" is missing or not ${type}` }
|
|
63
|
+
}
|
|
64
|
+
return { ok: true, release: obj }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One line describing a step, for `--plan` and the failure report. */
|
|
68
|
+
function describeStep(step) {
|
|
69
|
+
switch (step.kind) {
|
|
70
|
+
case 'release':
|
|
71
|
+
if (step.via === 'command') return `release: ${step.command}`
|
|
72
|
+
if (step.via === 'recorded') return `release: ${step.tag} (cut earlier — not re-run)`
|
|
73
|
+
return `release: git tag ${step.tag}`
|
|
74
|
+
case 'push':
|
|
75
|
+
if (step.via === 'remote') return `push: ${step.tag || 'the tag'} is already on ${step.remote}`
|
|
76
|
+
if (step.via === 'hold') return `push: held — no consent to push to ${step.remote}`
|
|
77
|
+
return `push: ${step.branch}, then ${step.tag || 'the tag'}, to ${step.remote}`
|
|
78
|
+
case 'target':
|
|
79
|
+
return `target: ${step.base} -> ${step.tag} (cherry-pick, tag)`
|
|
80
|
+
case 'carry': {
|
|
81
|
+
const n = Array.isArray(step.commits) ? step.commits.length : 0
|
|
82
|
+
const what = n === 1 ? '1 commit' : `${n} commits`
|
|
83
|
+
if (step.strategy === 'worktree') return `carry: cherry-pick ${what} onto ${step.base} (throwaway worktree)`
|
|
84
|
+
return `carry: cherry-pick ${what} onto ${step.base}`
|
|
85
|
+
}
|
|
86
|
+
default:
|
|
87
|
+
return `unknown step ${step.kind}`
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The last non-blank line a command wrote to stderr — skittership's refusals
|
|
92
|
+
// are one `✖ release-hotfix: <reason>` line there, and that is what to relay.
|
|
93
|
+
function lastLine(text) {
|
|
94
|
+
const lines = String(text || '')
|
|
95
|
+
.split('\n')
|
|
96
|
+
.map((l) => l.trim())
|
|
97
|
+
.filter(Boolean)
|
|
98
|
+
return lines.length ? lines[lines.length - 1] : null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function why(res) {
|
|
102
|
+
if (res.error) return res.error.message
|
|
103
|
+
return res.status === null ? `killed by ${res.signal || 'a signal'}` : `exit ${res.status}`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function runRelease(step, ctx) {
|
|
107
|
+
if (step.via === 'recorded') {
|
|
108
|
+
return { outcome: 'skipped', line: describeStep(step), release: step.release }
|
|
109
|
+
}
|
|
110
|
+
if (step.via === 'tag') {
|
|
111
|
+
const res = ctx.spawn('git', ['-C', step.cwd, 'tag', step.tag], {
|
|
112
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
113
|
+
encoding: 'utf8',
|
|
114
|
+
})
|
|
115
|
+
if (res.error || res.status !== 0) {
|
|
116
|
+
return { outcome: 'failed', line: `${describeStep(step)} — ${lastLine(res.stderr) || why(res)}` }
|
|
117
|
+
}
|
|
118
|
+
const release = {
|
|
119
|
+
tag: step.tag,
|
|
120
|
+
version: step.tag.replace(/^\D*/, ''),
|
|
121
|
+
baseTag: step.baseTag,
|
|
122
|
+
branch: step.branch,
|
|
123
|
+
versionCommit: null,
|
|
124
|
+
pushed: false,
|
|
125
|
+
}
|
|
126
|
+
return { outcome: 'released', line: `tagged ${step.tag}`, release }
|
|
127
|
+
}
|
|
128
|
+
// A project's own command, through the shell. stdout is captured whole for
|
|
129
|
+
// the JSON; stderr is captured so a refusal can be relayed as its own line,
|
|
130
|
+
// and is passed on to ours so its progress output is not lost.
|
|
131
|
+
const res = ctx.spawn(step.command, {
|
|
132
|
+
cwd: step.cwd,
|
|
133
|
+
shell: true,
|
|
134
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
135
|
+
encoding: 'utf8',
|
|
136
|
+
})
|
|
137
|
+
if (res.stderr) ctx.childStderr(res.stderr)
|
|
138
|
+
if (res.error || res.status !== 0) {
|
|
139
|
+
return { outcome: 'refused', line: `release refused — ${lastLine(res.stderr) || why(res)}` }
|
|
140
|
+
}
|
|
141
|
+
const parsed = parseRelease(res.stdout)
|
|
142
|
+
if (!parsed.ok) return { outcome: 'refused', line: `release refused — ${parsed.reason}` }
|
|
143
|
+
return { outcome: 'released', line: `released ${parsed.release.tag}`, release: parsed.release }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Publish the release. The tag is the release's, known only once it has run,
|
|
147
|
+
// so the argvs are built here rather than in the plan. `hold` is not a failure:
|
|
148
|
+
// it is the operator not having said yes, and it stops the run so nothing after
|
|
149
|
+
// it is carried before the deploy tag is public.
|
|
150
|
+
function runPush(step, release, ctx) {
|
|
151
|
+
const s = { ...step, tag: release ? release.tag : null }
|
|
152
|
+
if (s.via === 'remote') return { outcome: 'skipped', line: describeStep(s) }
|
|
153
|
+
if (s.via === 'hold') return { outcome: 'held', line: describeStep(s) }
|
|
154
|
+
if (!release) return { outcome: 'failed', line: `${describeStep(s)} — no release to push` }
|
|
155
|
+
for (const ref of [s.branch, s.tag]) {
|
|
156
|
+
const argv = ['-C', s.cwd, 'push', s.remote, ref]
|
|
157
|
+
const res = ctx.spawn('git', argv, { stdio: ['ignore', ctx.childStdout, 'pipe'], encoding: 'utf8' })
|
|
158
|
+
if (res.error || res.status !== 0) {
|
|
159
|
+
return { outcome: 'failed', line: `push ${ref} to ${s.remote} — ${lastLine(res.stderr) || why(res)}` }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { outcome: 'pushed', line: `pushed ${s.branch}, then ${s.tag}, to ${s.remote}` }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Carry the fix onto the base branch, one commit at a time so a conflict can
|
|
167
|
+
* name the commit that caused it.
|
|
168
|
+
*
|
|
169
|
+
* Three strategies, chosen by the caller from where the base branch is:
|
|
170
|
+
* inplace — checked out and clean: cherry-pick there.
|
|
171
|
+
* worktree — not checked out anywhere: a throwaway worktree on it, removed
|
|
172
|
+
* afterwards, so the branch moves without anyone's checkout moving.
|
|
173
|
+
* dirty — checked out with uncommitted work: refuse, and print the command
|
|
174
|
+
* to run by hand once it is clean. Somebody's work is in that tree.
|
|
175
|
+
* On a conflict the cherry-pick is aborted, which leaves the base branch where
|
|
176
|
+
* it was before that commit, and the hotfix branch and its record untouched —
|
|
177
|
+
* so a re-run tries the carry again.
|
|
178
|
+
*/
|
|
179
|
+
function runCarry(step, release, ctx) {
|
|
180
|
+
const version = release && release.versionCommit
|
|
181
|
+
// The version commit cannot be in a list made before the release cut it,
|
|
182
|
+
// but a re-run lists the branch after — so it is left out here as well.
|
|
183
|
+
const commits = (step.commits || []).filter((sha) => !version || sha !== version)
|
|
184
|
+
if (!commits.length) {
|
|
185
|
+
return { outcome: 'skipped', line: `carry: nothing to carry — ${step.base} already has the fix`, carried: 0 }
|
|
186
|
+
}
|
|
187
|
+
if (step.strategy === 'dirty') {
|
|
188
|
+
return {
|
|
189
|
+
outcome: 'refused',
|
|
190
|
+
line:
|
|
191
|
+
`carry: ${step.cwd} has uncommitted changes on ${step.base} — commit or stash them, ` +
|
|
192
|
+
`then run: git -C ${step.cwd} cherry-pick ${commits.join(' ')}`,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const git = (argv) =>
|
|
196
|
+
ctx.spawn('git', argv, { stdio: ['ignore', ctx.childStdout, 'pipe'], encoding: 'utf8' })
|
|
197
|
+
let cwd = step.cwd
|
|
198
|
+
if (step.strategy === 'worktree') {
|
|
199
|
+
cwd = step.tmpPath
|
|
200
|
+
const res = git(['-C', step.root, 'worktree', 'add', cwd, step.base])
|
|
201
|
+
if (res.error || res.status !== 0) {
|
|
202
|
+
return { outcome: 'failed', line: `carry: worktree add ${cwd} — ${lastLine(res.stderr) || why(res)}` }
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const cleanup = () => {
|
|
206
|
+
if (step.strategy === 'worktree') git(['-C', step.root, 'worktree', 'remove', '--force', cwd])
|
|
207
|
+
}
|
|
208
|
+
for (const sha of commits) {
|
|
209
|
+
const res = git(['-C', cwd, 'cherry-pick', sha])
|
|
210
|
+
if (res.error || res.status !== 0) {
|
|
211
|
+
git(['-C', cwd, 'cherry-pick', '--abort'])
|
|
212
|
+
cleanup()
|
|
213
|
+
return {
|
|
214
|
+
outcome: 'failed',
|
|
215
|
+
line:
|
|
216
|
+
`carry: cherry-pick ${sha} onto ${step.base} conflicted — aborted; ` +
|
|
217
|
+
`${lastLine(res.stderr) || why(res)}`,
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
cleanup()
|
|
222
|
+
const what = commits.length === 1 ? '1 commit' : `${commits.length} commits`
|
|
223
|
+
return { outcome: 'ran', line: `carried ${what} onto ${step.base}`, carried: commits.length }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// A step that is a list of git argvs run in order — a target's worktree,
|
|
227
|
+
// cherry-pick, tag and cleanup, or the carry's cherry-pick.
|
|
228
|
+
function runArgvs(step, ctx) {
|
|
229
|
+
for (const argv of step.argvs) {
|
|
230
|
+
const res = ctx.spawn('git', argv, { stdio: ['ignore', ctx.childStdout, 'pipe'], encoding: 'utf8' })
|
|
231
|
+
if (res.error || res.status !== 0) {
|
|
232
|
+
return {
|
|
233
|
+
outcome: 'failed',
|
|
234
|
+
line: `${describeStep(step)} — git ${argv.join(' ')}: ${lastLine(res.stderr) || why(res)}`,
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return { outcome: 'ran', line: describeStep(step) }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Run `steps` in order, stopping at the first that fails or refuses.
|
|
243
|
+
*
|
|
244
|
+
* io: { spawn?, write?, record?, childStdout?, childStderr? }
|
|
245
|
+
* - `write(line)` receives every report line as it happens
|
|
246
|
+
* - `record(release)` is called once, as soon as a release succeeds
|
|
247
|
+
*
|
|
248
|
+
* Returns { ok, held, results, failedAt, release } — `release` is what the
|
|
249
|
+
* release step cut (or found recorded), or null if it never got that far.
|
|
250
|
+
* `held` is a run that stopped at the push for want of consent: not a failure,
|
|
251
|
+
* and nothing after it ran.
|
|
252
|
+
*/
|
|
253
|
+
function runHotfixLand(steps, io = {}) {
|
|
254
|
+
const ctx = {
|
|
255
|
+
spawn: io.spawn || spawnSync,
|
|
256
|
+
childStdout: io.childStdout || 'inherit',
|
|
257
|
+
childStderr: io.childStderr || ((t) => process.stderr.write(t)),
|
|
258
|
+
}
|
|
259
|
+
const write = io.write || (() => {})
|
|
260
|
+
const results = []
|
|
261
|
+
let release = null
|
|
262
|
+
let carried = null
|
|
263
|
+
for (let i = 0; i < steps.length; i++) {
|
|
264
|
+
const step = steps[i]
|
|
265
|
+
let r
|
|
266
|
+
try {
|
|
267
|
+
if (step.kind === 'release') r = runRelease(step, ctx)
|
|
268
|
+
else if (step.kind === 'push') r = runPush(step, release, ctx)
|
|
269
|
+
else if (step.kind === 'carry') r = runCarry(step, release, ctx)
|
|
270
|
+
else if (step.kind === 'target') {
|
|
271
|
+
// A project's release command can cut a tag the built-in tagger also
|
|
272
|
+
// planned for a target on the same root. Refuse rather than collide.
|
|
273
|
+
if (step.kind === 'target' && release && release.tag === step.tag) {
|
|
274
|
+
r = { outcome: 'refused', line: `${describeStep(step)} — the release already cut ${step.tag}` }
|
|
275
|
+
} else r = runArgvs(step, ctx)
|
|
276
|
+
} else r = { outcome: 'failed', line: describeStep(step) }
|
|
277
|
+
} catch (err) {
|
|
278
|
+
r = { outcome: 'failed', line: `${describeStep(step)} — ${err.message}` }
|
|
279
|
+
}
|
|
280
|
+
results.push({ step, outcome: r.outcome, line: r.line })
|
|
281
|
+
if (typeof r.carried === 'number') carried = r.carried
|
|
282
|
+
if (r.release) {
|
|
283
|
+
release = r.release
|
|
284
|
+
if (r.outcome === 'released' && io.record) io.record(release)
|
|
285
|
+
}
|
|
286
|
+
if (r.outcome === 'pushed' && release) {
|
|
287
|
+
release = { ...release, pushed: true }
|
|
288
|
+
if (io.record) io.record(release)
|
|
289
|
+
}
|
|
290
|
+
if (r.outcome === 'held') {
|
|
291
|
+
if (r.line) write(r.line)
|
|
292
|
+
return { ok: true, held: true, results, failedAt: null, release, carried }
|
|
293
|
+
}
|
|
294
|
+
if (r.outcome === 'failed' || r.outcome === 'refused') {
|
|
295
|
+
return { ok: false, results, failedAt: i + 1, release, carried }
|
|
296
|
+
}
|
|
297
|
+
if (r.line) write(r.line)
|
|
298
|
+
}
|
|
299
|
+
return { ok: true, held: false, results, failedAt: null, release, carried }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
module.exports = { parseRelease, runHotfixLand, describeStep, RELEASE_FIELDS }
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { globToRegExp } = require('./glob.js')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Pure integrate planner for `spec-env integrate`.
|
|
7
|
+
*
|
|
8
|
+
* `planIntegrate` decides whether a spec's worktree branch can be landed onto the
|
|
9
|
+
* base branch and, if so, emits the exact commands the `/spec-complete` skill runs
|
|
10
|
+
* (rebase the branch onto base in the worktree, then fast-forward base to it in the
|
|
11
|
+
* primary checkout). It performs no side effects: the caller (the CLI) queries git
|
|
12
|
+
* for `dirty`/`aheadOfBase`/`mainRepoPath` and supplies them, keeping this
|
|
13
|
+
* deterministic and unit-testable with no live git.
|
|
14
|
+
*
|
|
15
|
+
* Strategy is rebase + fast-forward (linear history). Conflict handling lives in
|
|
16
|
+
* the skill: it runs the rebase and, on a non-zero exit, `git rebase --abort` and
|
|
17
|
+
* hands back — so the planner never needs to reason about conflicts.
|
|
18
|
+
*
|
|
19
|
+
* @param {object} spec resolved spec: { branch, worktreePath, folder, ... }
|
|
20
|
+
* @param {object} config normalised env config (reads `afterLand`).
|
|
21
|
+
* In CHECKOUT mode there is no worktree: the branch is already checked out in the
|
|
22
|
+
* primary checkout, so landing is a rebase in place, a switch to base, and the
|
|
23
|
+
* fast-forward. `planIntegrateCheckout` below covers that; the shape of what it
|
|
24
|
+
* returns is identical so callers need no second code path.
|
|
25
|
+
*
|
|
26
|
+
* @param {object} ctx { worktreeState: { dirty }, base, aheadOfBase, mainRepoPath,
|
|
27
|
+
* changedFiles } — `changedFiles` is what the land brings
|
|
28
|
+
* in (`base...branch`), or null when it could not be read
|
|
29
|
+
* @returns {object} { blocked, noop, reason, commands, afterLand, base, branch }
|
|
30
|
+
*/
|
|
31
|
+
function planIntegrate(spec, config, ctx) {
|
|
32
|
+
const { worktreeState = {}, base, aheadOfBase, mainRepoPath } = ctx || {}
|
|
33
|
+
const branch = spec.branch
|
|
34
|
+
const result = {
|
|
35
|
+
blocked: false, noop: false, reason: null, commands: [], afterLand: noAfterLand(), base, branch,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The completion edits must be committed first — never rebase a dirty tree.
|
|
39
|
+
if (worktreeState.dirty) {
|
|
40
|
+
return { ...result, blocked: true, reason: 'worktree has uncommitted changes — commit the completion first' }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Nothing on the branch that isn't already on base → already landed. The
|
|
44
|
+
// after-land commands still apply: the land may have happened on another
|
|
45
|
+
// machine or in an earlier session, leaving them pending in THIS checkout.
|
|
46
|
+
// That earlier land's range is unknown here, so the range is null.
|
|
47
|
+
if (!aheadOfBase) {
|
|
48
|
+
return { ...result, noop: true, afterLand: afterLandFor(config, mainRepoPath, null) }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
...result,
|
|
53
|
+
commands: [
|
|
54
|
+
`git -C ${spec.worktreePath} rebase ${base}`,
|
|
55
|
+
`git -C ${mainRepoPath} merge --ff-only ${branch}`,
|
|
56
|
+
],
|
|
57
|
+
afterLand: afterLandFor(config, mainRepoPath, rangeOf(ctx)),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Pure integrate planner for CHECKOUT mode.
|
|
63
|
+
*
|
|
64
|
+
* The branch lives in the primary checkout, so `git -C <worktree> rebase` — the
|
|
65
|
+
* worktree-mode plan — has nothing to address. Landing is three steps in one
|
|
66
|
+
* repo: rebase onto base, switch to base, fast-forward.
|
|
67
|
+
*
|
|
68
|
+
* The switch is what makes this safe to repeat. Leaving the checkout on the spec
|
|
69
|
+
* branch after landing would mean the next `spec-env up` refuses ("standing on
|
|
70
|
+
* another spec's branch") for a spec that is finished, and the operator would
|
|
71
|
+
* have to know to switch back by hand.
|
|
72
|
+
*
|
|
73
|
+
* ctx: { dirty, base, aheadOfBase, checkoutPath, onBranch, changedFiles }
|
|
74
|
+
*/
|
|
75
|
+
function planIntegrateCheckout(spec, config, ctx) {
|
|
76
|
+
const { dirty, base, aheadOfBase, checkoutPath, onBranch } = ctx || {}
|
|
77
|
+
const branch = spec.branch
|
|
78
|
+
const result = {
|
|
79
|
+
blocked: false, noop: false, reason: null, commands: [], afterLand: noAfterLand(), base, branch,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// "Already landed" is answered FIRST, before any refusal. A landed spec needs
|
|
83
|
+
// no action wherever the checkout happens to be standing — and after a
|
|
84
|
+
// successful land it is standing on base, so asking "not on the branch" here
|
|
85
|
+
// would refuse the very spec this just finished landing. /spec-complete calls
|
|
86
|
+
// integrate again on exactly that state.
|
|
87
|
+
if (!aheadOfBase) {
|
|
88
|
+
return { ...result, noop: true, afterLand: afterLandFor(config, checkoutPath, null) }
|
|
89
|
+
}
|
|
90
|
+
if (dirty) {
|
|
91
|
+
return {
|
|
92
|
+
...result,
|
|
93
|
+
blocked: true,
|
|
94
|
+
reason: 'the checkout has uncommitted changes — commit the completion first',
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// There IS something to land, so where you are standing now matters: the
|
|
98
|
+
// branch is the checkout in this mode, and landing from elsewhere would
|
|
99
|
+
// rebase and fast-forward a branch the operator is not looking at.
|
|
100
|
+
if (onBranch === false) {
|
|
101
|
+
return {
|
|
102
|
+
...result,
|
|
103
|
+
blocked: true,
|
|
104
|
+
reason: `the checkout is not on ${branch} — switch to it before landing`,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
...result,
|
|
110
|
+
commands: [
|
|
111
|
+
`git -C ${checkoutPath} rebase ${base}`,
|
|
112
|
+
`git -C ${checkoutPath} switch ${base}`,
|
|
113
|
+
`git -C ${checkoutPath} merge --ff-only ${branch}`,
|
|
114
|
+
],
|
|
115
|
+
afterLand: afterLandFor(config, checkoutPath, rangeOf(ctx)),
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The after-land half of a plan: `config.afterLand`, to run in `cwd` (the
|
|
120
|
+
// primary checkout, standing on base) once the land commands have succeeded.
|
|
121
|
+
// A blocked plan carries the empty form — nothing landed, so nothing follows.
|
|
122
|
+
//
|
|
123
|
+
// `range` is the files the land changes: a list (possibly empty) when known,
|
|
124
|
+
// null when it is not. Each entry gets a decision — `always` (no `when`),
|
|
125
|
+
// `matched` (with the first path that hit), `skipped`, or `unknown` — and
|
|
126
|
+
// `commands` is every entry but the skipped ones, in config order.
|
|
127
|
+
//
|
|
128
|
+
// AN UNKNOWN RANGE RUNS THE CONDITIONAL ENTRIES. A land made elsewhere, or a
|
|
129
|
+
// diff git would not answer, says nothing about what changed, and only one
|
|
130
|
+
// reading is harmless: a redundant generate costs seconds, a skipped one leaves
|
|
131
|
+
// the base red with nothing pointing at why (`.claude/rules/negative-checks.md`
|
|
132
|
+
// rule 4). An EMPTY known range is not unknown — a land whose commits touch no
|
|
133
|
+
// files is healthy, and it skips them.
|
|
134
|
+
function afterLandFor(config, cwd, range = null) {
|
|
135
|
+
const raw = (config && Array.isArray(config.afterLand)) ? config.afterLand : []
|
|
136
|
+
const entries = raw.map((e) => decide(asEntry(e), range))
|
|
137
|
+
const commands = entries.filter((e) => e.decision !== 'skipped').map((e) => e.run)
|
|
138
|
+
return { cwd: entries.length ? cwd : null, commands, entries, range }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function decide(entry, range) {
|
|
142
|
+
if (!entry.when) return { ...entry, decision: 'always', matched: null }
|
|
143
|
+
if (range === null) return { ...entry, decision: 'unknown', matched: null }
|
|
144
|
+
const res = entry.when.map(globToRegExp)
|
|
145
|
+
const matched = range.find((f) => res.some((r) => r.test(f)))
|
|
146
|
+
return matched === undefined
|
|
147
|
+
? { ...entry, decision: 'skipped', matched: null }
|
|
148
|
+
: { ...entry, decision: 'matched', matched }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Normalised config holds `{ run, when }`; a pure caller may still hand over
|
|
152
|
+
// the plain string an entry is written as.
|
|
153
|
+
function asEntry(entry) {
|
|
154
|
+
return typeof entry === 'string' ? { run: entry, when: null } : { run: entry.run, when: entry.when || null }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// `changedFiles` from a planner's ctx, with anything that is not a list read as
|
|
158
|
+
// unknown — a caller that did not supply it said nothing about the range.
|
|
159
|
+
function rangeOf(ctx) {
|
|
160
|
+
return Array.isArray(ctx && ctx.changedFiles) ? ctx.changedFiles : null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function noAfterLand() {
|
|
164
|
+
return { cwd: null, commands: [], entries: [], range: null }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = { planIntegrate, planIntegrateCheckout, afterLandFor, rangeOf }
|