@skitterbyte/skitterspec-jira 0.0.0-stage → 1.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 +1089 -0
- package/README.md +42 -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 +554 -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 +61 -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 +692 -0
- package/assets/rules/spec-reports.md +557 -0
- package/assets/skills/no-spec/SKILL.md +246 -0
- package/assets/skills/spec/SKILL.md +714 -0
- package/assets/skills/spec-bug/SKILL.md +583 -0
- package/assets/skills/spec-cancel/SKILL.md +261 -0
- package/assets/skills/spec-complete/SKILL.md +448 -0
- package/assets/skills/spec-diff/SKILL.md +870 -0
- package/assets/skills/spec-hotfix/SKILL.md +605 -0
- package/assets/skills/spec-init/SKILL.md +178 -0
- package/assets/skills/spec-jira-setup/SKILL.md +220 -0
- package/assets/skills/spec-list/SKILL.md +173 -0
- package/assets/skills/spec-next/SKILL.md +676 -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 +112 -0
- package/assets/skills/spec-sync/SKILL.md +218 -0
- package/assets/skills/spec-to-main/SKILL.md +139 -0
- package/bin/skitterspec-jira.js +61 -0
- package/package.json +40 -4
- package/src/cli.js +5901 -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 +753 -0
- package/src/env/dev.js +63 -0
- package/src/env/hooks.js +226 -0
- package/src/env/hotfix.js +385 -0
- package/src/env/hotfixland.js +302 -0
- package/src/env/integrate.js +126 -0
- package/src/env/live.js +504 -0
- package/src/env/mainguard.js +299 -0
- package/src/env/provision.js +466 -0
- package/src/env/proxy.js +192 -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 +2577 -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 +1256 -0
- package/src/vendor/jira/commands.js +44 -0
- package/src/vendor/jira/config.js +296 -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 +197 -0
- package/src/vendor/provider-kit/src/args.js +68 -0
- package/src/vendor/provider-kit/src/config.js +198 -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 +44 -0
- package/src/vendor/provider-kit/src/identity.js +93 -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 +226 -0
- package/src/vendor/provider-kit/src/specs.js +166 -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 +306 -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 +989 -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
package/src/env/dev.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure planner for host dev-process supervision (`spec-env dev up|down`).
|
|
5
|
+
*
|
|
6
|
+
* Given a resolved spec and its allocated slot, `planDev` computes — for each
|
|
7
|
+
* configured `dev` process — its resolved port, the env var to inject, the log
|
|
8
|
+
* and pid file paths, the expanded health URL, and the canonical `frontPort` a
|
|
9
|
+
* later proxy (Phase 2) routes to it. No side effects: the CLI's `supervise.js`
|
|
10
|
+
* does the spawning/killing; this stays unit-testable with no processes.
|
|
11
|
+
*
|
|
12
|
+
* Port math: process `i` in the slot's block gets `portBase + slot*portsPerSpec
|
|
13
|
+
* + i` (reusing the same block the Docker stack draws from — see registry.js).
|
|
14
|
+
* So host dev servers get a reserved block even on a worktree-only spec.
|
|
15
|
+
*
|
|
16
|
+
* Log/pid files live beside the registry (default `.spec-env/`) and are keyed by
|
|
17
|
+
* the spec **folder** (not the bare slug) so a `feat-foo`/`bug-foo` pair can't
|
|
18
|
+
* collide — matching how the registry keys slots.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const path = require('node:path')
|
|
22
|
+
const { portOffset } = require('./registry.js')
|
|
23
|
+
const { expandTokens } = require('./resolve.js')
|
|
24
|
+
|
|
25
|
+
// The state dir (logs + pids) sits beside the registry file, e.g. `.spec-env`.
|
|
26
|
+
function stateDir(config) {
|
|
27
|
+
return path.posix.dirname(config.registry) || '.spec-env'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Plan the host dev processes for `spec` at `slot`.
|
|
32
|
+
*
|
|
33
|
+
* @returns {object} { slot, portOffset, procs: [{ name, command, port, portVar,
|
|
34
|
+
* env, frontPort, logFile, pidFile, health }] } — logFile/pidFile are paths
|
|
35
|
+
* relative to the primary checkout root (the CLI resolves them).
|
|
36
|
+
*/
|
|
37
|
+
function planDev(spec, slot, config) {
|
|
38
|
+
const base = portOffset(slot, config)
|
|
39
|
+
const dir = stateDir(config)
|
|
40
|
+
const procs = (config.dev || []).map((entry, i) => {
|
|
41
|
+
const port = base + i
|
|
42
|
+
const tokens = {
|
|
43
|
+
[entry.portVar]: String(port),
|
|
44
|
+
port: String(port),
|
|
45
|
+
slug: spec.slug,
|
|
46
|
+
name: entry.name,
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
name: entry.name,
|
|
50
|
+
command: expandTokens(entry.command, tokens),
|
|
51
|
+
port,
|
|
52
|
+
portVar: entry.portVar,
|
|
53
|
+
env: { [entry.portVar]: String(port) },
|
|
54
|
+
frontPort: typeof entry.frontPort === 'number' ? entry.frontPort : null,
|
|
55
|
+
logFile: `${dir}/logs/${spec.folder}-${entry.name}.log`,
|
|
56
|
+
pidFile: `${dir}/pids/${spec.folder}-${entry.name}.pid`,
|
|
57
|
+
health: entry.health ? expandTokens(entry.health, tokens) : null,
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
return { slot, portOffset: base, procs }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { planDev, stateDir }
|
package/src/env/hooks.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Register the review-gate hook in the project's Claude Code settings.
|
|
5
|
+
*
|
|
6
|
+
* `.claude/settings.json` rather than `settings.local.json`, and the difference
|
|
7
|
+
* is the point: the trusted-worktree entry is an absolute path and therefore
|
|
8
|
+
* one machine's business, while this is the project's policy — a phase that
|
|
9
|
+
* ended owes a verdict — and it should reach everyone who clones the repo. The
|
|
10
|
+
* command is written with `${CLAUDE_PROJECT_DIR}` so it stays true wherever the
|
|
11
|
+
* checkout lives, worktrees included.
|
|
12
|
+
*
|
|
13
|
+
* Conservative in the same way `trust.js` is: every existing key is preserved,
|
|
14
|
+
* a file it cannot parse is left exactly as it is, and re-running changes
|
|
15
|
+
* nothing once the entry is there. Callers own the reporting.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('node:fs')
|
|
19
|
+
const path = require('node:path')
|
|
20
|
+
|
|
21
|
+
// `.cjs`, and the extension is load-bearing. This file is copied INTO the target
|
|
22
|
+
// project, where that project's `package.json` decides how node parses a `.js` —
|
|
23
|
+
// so the CommonJS script shipped as `review-gate.js` died on its own first
|
|
24
|
+
// `require` in every `"type": "module"` project, printing a stack trace over
|
|
25
|
+
// every Bash tool call. `.cjs` settles the parse mode at the file, independently
|
|
26
|
+
// of the one file skitterspec does not control.
|
|
27
|
+
const HOOK_STEM = '.claude/hooks/review-gate'
|
|
28
|
+
const HOOK_SCRIPT = `${HOOK_STEM}.cjs`
|
|
29
|
+
const HOOK_COMMAND = `node "\${CLAUDE_PROJECT_DIR}/${HOOK_SCRIPT}"`
|
|
30
|
+
|
|
31
|
+
// Our script under ANY extension, so a registration naming the retired `.js` is
|
|
32
|
+
// recognised as ours and migrated rather than duplicated. The trailing lookahead
|
|
33
|
+
// is the boundary: without it `review-gate-extra.js` — somebody else's hook —
|
|
34
|
+
// matches on the stem and gets silently rewritten.
|
|
35
|
+
const HOOK_SCRIPT_RE = /[.]claude[/\\]hooks[/\\]review-gate(?:[.][A-Za-z0-9]+)?(?![\w.-])/
|
|
36
|
+
// Seconds. The engine call behind this is one git-free read of a small JSON
|
|
37
|
+
// file, so anything approaching this is a wedge rather than slow work — and a
|
|
38
|
+
// hook that times out fails OPEN, which is the answer we want for a wedge.
|
|
39
|
+
const HOOK_TIMEOUT = 10
|
|
40
|
+
const MATCHER = 'Bash'
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Every hook this package registers, as data.
|
|
44
|
+
*
|
|
45
|
+
* ONE LIST, so a second hook cannot arrive as a second copy of the merge logic
|
|
46
|
+
* below — which is where every property this file has earned would have to be
|
|
47
|
+
* re-derived and one of them forgotten. Each entry supplies only what differs:
|
|
48
|
+
* the script's stem, the tools it matches, and its timeout.
|
|
49
|
+
*
|
|
50
|
+
* The `re` for each is built from its stem with the same boundary lookahead the
|
|
51
|
+
* review gate's has, so `main-guard-extra.js` — somebody else's hook — is not
|
|
52
|
+
* silently rewritten as ours.
|
|
53
|
+
*/
|
|
54
|
+
function hookStemRe(stem) {
|
|
55
|
+
const name = stem.split('/').pop()
|
|
56
|
+
return new RegExp(`[.]claude[/\\\\]hooks[/\\\\]${name}(?:[.][A-Za-z0-9]+)?(?![\\w.-])`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const HOOKS = [
|
|
60
|
+
{
|
|
61
|
+
key: 'review-gate',
|
|
62
|
+
stem: HOOK_STEM,
|
|
63
|
+
matcher: MATCHER,
|
|
64
|
+
timeout: HOOK_TIMEOUT,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
// Refuses an Edit/Write in the primary checkout while it is on the base
|
|
68
|
+
// branch. A different matcher from the review gate's, because it guards a
|
|
69
|
+
// different moment: the first write, before any work exists.
|
|
70
|
+
key: 'main-guard',
|
|
71
|
+
stem: '.claude/hooks/main-guard',
|
|
72
|
+
matcher: 'Edit|Write|NotebookEdit|MultiEdit',
|
|
73
|
+
timeout: HOOK_TIMEOUT,
|
|
74
|
+
},
|
|
75
|
+
].map((h) => ({ ...h, script: `${h.stem}.cjs`, re: hookStemRe(h.stem) }))
|
|
76
|
+
|
|
77
|
+
function isObject(value) {
|
|
78
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function settingsPath(dir) {
|
|
82
|
+
return path.join(dir, '.claude', 'settings.json')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function writeSettings(file, settings) {
|
|
86
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
87
|
+
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function commandFor(script) {
|
|
91
|
+
return `node "\${CLAUDE_PROJECT_DIR}/${script}"`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function hookEntry(hook = HOOKS[0]) {
|
|
95
|
+
return {
|
|
96
|
+
matcher: hook.matcher,
|
|
97
|
+
hooks: [{ type: 'command', command: commandFor(hook.script), timeout: hook.timeout }],
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Is our hook already registered under `PreToolUse`? Pure.
|
|
103
|
+
*
|
|
104
|
+
* Matched on the SCRIPT PATH, not on the whole command string. An operator who
|
|
105
|
+
* added a timeout, wrapped the invocation, or changed the interpreter has
|
|
106
|
+
* registered our hook their way — re-adding a second copy beside theirs would
|
|
107
|
+
* run it twice and look like a bug in the gate.
|
|
108
|
+
*/
|
|
109
|
+
function alreadyRegistered(preToolUse, hook = HOOKS[0]) {
|
|
110
|
+
return registeredHooks(preToolUse, hook).length > 0
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Every hook object under `PreToolUse` whose command names our script, whatever
|
|
114
|
+
// extension it names it under. Pure.
|
|
115
|
+
function registeredHooks(preToolUse, hook = HOOKS[0]) {
|
|
116
|
+
if (!Array.isArray(preToolUse)) return []
|
|
117
|
+
const out = []
|
|
118
|
+
for (const group of preToolUse) {
|
|
119
|
+
if (!isObject(group) || !Array.isArray(group.hooks)) continue
|
|
120
|
+
for (const h of group.hooks) {
|
|
121
|
+
if (isObject(h) && typeof h.command === 'string' && hook.re.test(h.command)) out.push(h)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return out
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Ensure ONE hook is registered in an already-parsed settings object.
|
|
129
|
+
* Mutates `parsed` in place when it migrates a stale path; returns
|
|
130
|
+
* `{ changed, reason }` with the same vocabulary as the wrapper below.
|
|
131
|
+
*
|
|
132
|
+
* Split out so a second hook could be added without a second copy of this
|
|
133
|
+
* logic — which is where every property this file has earned (preserve unknown
|
|
134
|
+
* keys, match on the script PATH, migrate in place, stay idempotent) would have
|
|
135
|
+
* had to be re-derived, and one of them forgotten.
|
|
136
|
+
*/
|
|
137
|
+
function ensureOneHook(parsed, hook) {
|
|
138
|
+
const hooks = isObject(parsed.hooks) ? parsed.hooks : {}
|
|
139
|
+
const preToolUse = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : []
|
|
140
|
+
|
|
141
|
+
// Registered already — but possibly under a path that no longer exists, since
|
|
142
|
+
// the script's extension changed. Rewrite the path IN PLACE rather than
|
|
143
|
+
// replacing the command: an operator who wrapped our hook, added a flag or
|
|
144
|
+
// changed the interpreter has registered it their way, and the thing that
|
|
145
|
+
// moved is the file, not their command. Adding a fresh entry beside theirs
|
|
146
|
+
// would run the gate twice and read as a bug in the gate; leaving the old one
|
|
147
|
+
// alone would aim the harness at a file this same upgrade retires.
|
|
148
|
+
const registered = registeredHooks(preToolUse, hook)
|
|
149
|
+
if (registered.length) {
|
|
150
|
+
const stale = registered.filter((h) => !h.command.includes(hook.script))
|
|
151
|
+
if (!stale.length) return { changed: false, reason: 'present' }
|
|
152
|
+
for (const h of stale) h.command = h.command.replace(hook.re, hook.script)
|
|
153
|
+
return { changed: true, reason: 'migrated' }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
parsed.hooks = { ...hooks, PreToolUse: [...preToolUse, hookEntry(hook)] }
|
|
157
|
+
return { changed: true, reason: 'added' }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Ensure every hook this package ships is registered in `dir`'s project
|
|
162
|
+
* settings. Idempotent and non-destructive. Returns `{ changed, reason }`:
|
|
163
|
+
* - `created` — no settings file; one was written
|
|
164
|
+
* - `added` — merged into an existing file
|
|
165
|
+
* - `migrated` — registered under a retired path; the path was rewritten
|
|
166
|
+
* - `present` — already registered (no write)
|
|
167
|
+
* - `malformed` — the file exists but is not parseable JSON (left untouched)
|
|
168
|
+
*
|
|
169
|
+
* WITH SEVERAL HOOKS THE REASON IS THE STRONGEST ONE that applied, in the order
|
|
170
|
+
* added > migrated > present. A run that added one hook and found the other
|
|
171
|
+
* already there did change the file, and reporting `present` for it would tell
|
|
172
|
+
* an operator nothing happened when something did.
|
|
173
|
+
*/
|
|
174
|
+
function ensureHooks(dir) {
|
|
175
|
+
const file = settingsPath(dir)
|
|
176
|
+
|
|
177
|
+
let raw
|
|
178
|
+
try {
|
|
179
|
+
raw = fs.readFileSync(file, 'utf-8')
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (error.code === 'ENOENT') {
|
|
182
|
+
writeSettings(file, { hooks: { PreToolUse: HOOKS.map((h) => hookEntry(h)) } })
|
|
183
|
+
return { changed: true, reason: 'created' }
|
|
184
|
+
}
|
|
185
|
+
throw error
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let parsed
|
|
189
|
+
try {
|
|
190
|
+
parsed = JSON.parse(raw)
|
|
191
|
+
} catch {
|
|
192
|
+
// Never rewrite a settings file we could not read. It is the operator's
|
|
193
|
+
// config and everything else in it would be lost.
|
|
194
|
+
return { changed: false, reason: 'malformed' }
|
|
195
|
+
}
|
|
196
|
+
if (!isObject(parsed)) return { changed: false, reason: 'malformed' }
|
|
197
|
+
|
|
198
|
+
const reasons = HOOKS.map((h) => ensureOneHook(parsed, h))
|
|
199
|
+
const changed = reasons.some((r) => r.changed)
|
|
200
|
+
if (changed) writeSettings(file, parsed)
|
|
201
|
+
|
|
202
|
+
for (const rank of ['added', 'migrated', 'present']) {
|
|
203
|
+
if (reasons.some((r) => r.reason === rank)) return { changed, reason: rank }
|
|
204
|
+
}
|
|
205
|
+
return { changed, reason: 'present' }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// The name this shipped under when there was one hook. Kept as an alias rather
|
|
209
|
+
// than renamed away, because it is what an installed `init.js` from an older
|
|
210
|
+
// version calls — and a rename that breaks an upgrade path is not a rename, it
|
|
211
|
+
// is a removal.
|
|
212
|
+
const ensureReviewGateHook = ensureHooks
|
|
213
|
+
|
|
214
|
+
module.exports = {
|
|
215
|
+
ensureHooks,
|
|
216
|
+
ensureReviewGateHook,
|
|
217
|
+
alreadyRegistered,
|
|
218
|
+
registeredHooks,
|
|
219
|
+
hookEntry,
|
|
220
|
+
settingsPath,
|
|
221
|
+
HOOKS,
|
|
222
|
+
HOOK_STEM,
|
|
223
|
+
HOOK_SCRIPT,
|
|
224
|
+
HOOK_SCRIPT_RE,
|
|
225
|
+
HOOK_COMMAND,
|
|
226
|
+
}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure landing planner for `spec-env hotfix land`.
|
|
5
|
+
*
|
|
6
|
+
* A hotfix is built on an old release tag (its `Base version`), so it can't be
|
|
7
|
+
* fast-forwarded onto `main` like an ordinary spec (that's `integrate.js`).
|
|
8
|
+
* Instead `planHotfixLand` emits the exact side-effect-free, **never-pushing**
|
|
9
|
+
* commands to:
|
|
10
|
+
* 1. tag the hotfix branch head with the patch-bumped base tag (the prod deploy
|
|
11
|
+
* tag — the branch head already is baseRef + the fix);
|
|
12
|
+
* 2. for each extra target tag, cherry-pick the fix onto a throwaway worktree at
|
|
13
|
+
* that tag and re-tag it with its own patch bump (test/demo release lines);
|
|
14
|
+
* 3. cherry-pick the fix onto the base branch (main) for the next release.
|
|
15
|
+
*
|
|
16
|
+
* It performs no side effects — the caller (the CLI) probes git for `dirty` /
|
|
17
|
+
* `aheadOfBase` / `existingTags` / `mainRepoPath` and supplies them, keeping this
|
|
18
|
+
* deterministic and unit-testable with no live git. Conflict handling lives in the
|
|
19
|
+
* skill (run a cherry-pick, abort on non-zero exit, hand back), mirroring
|
|
20
|
+
* `integrate.js` — the planner never reasons about conflicts.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const path = require('node:path')
|
|
24
|
+
const { afterLandFor } = require('./integrate.js')
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse a semver-ish tag and return it with its PATCH bumped by one. Preserves any
|
|
28
|
+
* non-digit prefix (e.g. `v` → `v33.16.5`) and drops any pre-release/build suffix
|
|
29
|
+
* (`-rc1`, `+build`). Throws on a tag with no `MAJOR.MINOR.PATCH` core.
|
|
30
|
+
*/
|
|
31
|
+
function bumpPatch(tag) {
|
|
32
|
+
const m = /^(\D*)(\d+)\.(\d+)\.(\d+)(?:.*)$/.exec(String(tag || '').trim())
|
|
33
|
+
if (!m) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`hotfix: cannot bump a patch version from tag "${tag}" — need <prefix>MAJOR.MINOR.PATCH`,
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
const [, prefix, major, minor, patch] = m
|
|
39
|
+
return `${prefix}${major}.${minor}.${Number(patch) + 1}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A release tag's parts: `<prefix><major>.<minor>.<patch>[-<suffix>]`. A
|
|
43
|
+
// `hotfix.N` suffix is kept apart from every other one, because the two sort on
|
|
44
|
+
// opposite sides of their base (see compareTags). Null for anything else.
|
|
45
|
+
const TAG = /^(\D*)(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/
|
|
46
|
+
function parseTag(tag) {
|
|
47
|
+
const m = TAG.exec(String(tag || '').trim())
|
|
48
|
+
if (!m) return null
|
|
49
|
+
const [, prefix, major, minor, patch, suffix = null] = m
|
|
50
|
+
const hotfix = suffix && /^hotfix\.(\d+)$/.exec(suffix)
|
|
51
|
+
return {
|
|
52
|
+
prefix,
|
|
53
|
+
core: [Number(major), Number(minor), Number(patch)],
|
|
54
|
+
root: `${prefix}${major}.${minor}.${patch}`,
|
|
55
|
+
hotfix: hotfix ? Number(hotfix[1]) : null,
|
|
56
|
+
suffix: hotfix ? null : suffix,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Order two release tags: X.Y.Z-rc.N < X.Y.Z < X.Y.Z-hotfix.N < X.Y.(Z+1).
|
|
62
|
+
*
|
|
63
|
+
* This is skittership's rule and git's `version:refname` with a hotfix suffix
|
|
64
|
+
* configured to sort after its base, so the two tools never disagree about
|
|
65
|
+
* which tag is newer. A hotfix is a later build OF X.Y.Z; every other suffix is
|
|
66
|
+
* a pre-release BEFORE it, as in semver. Hotfix numbers compare numerically
|
|
67
|
+
* (`-hotfix.10` after `-hotfix.9`); other suffixes compare as strings. An
|
|
68
|
+
* unparseable tag sorts first, so it can never be mistaken for the newest.
|
|
69
|
+
*/
|
|
70
|
+
function compareTags(a, b) {
|
|
71
|
+
const x = parseTag(a)
|
|
72
|
+
const y = parseTag(b)
|
|
73
|
+
if (!x || !y) return (x ? 1 : 0) - (y ? 1 : 0)
|
|
74
|
+
for (let i = 0; i < 3; i++) {
|
|
75
|
+
if (x.core[i] !== y.core[i]) return x.core[i] - y.core[i]
|
|
76
|
+
}
|
|
77
|
+
// Rank within one core: pre-release (0) < the release itself (1) < hotfix (2).
|
|
78
|
+
const rank = (t) => (t.suffix ? 0 : t.hotfix === null ? 1 : 2)
|
|
79
|
+
if (rank(x) !== rank(y)) return rank(x) - rank(y)
|
|
80
|
+
if (x.hotfix !== null) return x.hotfix - y.hotfix
|
|
81
|
+
if (x.suffix) return x.suffix < y.suffix ? -1 : x.suffix > y.suffix ? 1 : 0
|
|
82
|
+
return 0
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The next `<root>-hotfix.<N>` for a base tag: N is one above the highest
|
|
87
|
+
* `<root>-hotfix.*` among `existingTags`, else 1. The root is the base with any
|
|
88
|
+
* hotfix suffix stripped, so a hotfix on `v36.4.0-hotfix.2` continues that
|
|
89
|
+
* line. A later `X.Y.(Z+1)` is neither a collision nor counted — it is another
|
|
90
|
+
* release line entirely. Same rule as skittership's release-hotfix.
|
|
91
|
+
*/
|
|
92
|
+
function nextHotfixTag(baseTag, existingTags) {
|
|
93
|
+
const base = parseTag(baseTag)
|
|
94
|
+
if (!base) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`hotfix: cannot derive a hotfix tag from "${baseTag}" — need <prefix>MAJOR.MINOR.PATCH`,
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
if (base.suffix) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`hotfix: base tag "${baseTag}" is a pre-release — a hotfix needs a released version`,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
let n = base.hotfix || 0
|
|
105
|
+
for (const t of existingTags || []) {
|
|
106
|
+
const p = parseTag(t)
|
|
107
|
+
if (p && p.root === base.root && p.hotfix !== null && p.hotfix > n) n = p.hotfix
|
|
108
|
+
}
|
|
109
|
+
return `${base.root}-hotfix.${n + 1}`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const BUMPS = ['patch', 'hotfix-suffix']
|
|
113
|
+
|
|
114
|
+
// The tag `hotfix.bump` asks for. An unknown strategy throws rather than
|
|
115
|
+
// falling back to `patch`: a typo there would otherwise cut the wrong release
|
|
116
|
+
// line and say nothing.
|
|
117
|
+
function nextTag(baseTag, existingTags, bump = 'patch') {
|
|
118
|
+
if (bump === 'patch') return bumpPatch(baseTag)
|
|
119
|
+
if (bump === 'hotfix-suffix') return nextHotfixTag(baseTag, existingTags)
|
|
120
|
+
throw new Error(`unknown hotfix.bump "${bump}" — use one of: ${BUMPS.join(', ')}`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// A filesystem/branch-safe slug derived from a tag (`v30.2.1` → `v30-2-1`).
|
|
124
|
+
function tagSlug(tag) {
|
|
125
|
+
return String(tag)
|
|
126
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
127
|
+
.replace(/^-+|-+$/g, '')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @param {object} spec resolved hotfix spec: { slug, branch, worktreePath, baseRef, ... }
|
|
132
|
+
* @param {object} config normalised env config (reads `config.hotfix`).
|
|
133
|
+
* @param {object} ctx { worktreeState:{dirty}, aheadOfBase, fixRange, mainRepoPath,
|
|
134
|
+
* base, extraTargets: string[], existingTags: string[],
|
|
135
|
+
* released?, push?: boolean, pushed?: boolean, remote?,
|
|
136
|
+
* fixCommits?: string[],
|
|
137
|
+
* carry?: { strategy: 'inplace'|'worktree'|'dirty', path,
|
|
138
|
+
* tmpPath?, commits: string[] } }
|
|
139
|
+
* @returns {object} { blocked, noop, reason, commands, afterLand, prodTag, targets, branch, base }
|
|
140
|
+
*/
|
|
141
|
+
function planHotfixLand(spec, config, ctx) {
|
|
142
|
+
const c = ctx || {}
|
|
143
|
+
const branch = spec.branch
|
|
144
|
+
const baseRef = spec.baseRef
|
|
145
|
+
const hotfixCfg = (config && config.hotfix) || { cherryPickMain: true, targets: [] }
|
|
146
|
+
const result = {
|
|
147
|
+
blocked: false,
|
|
148
|
+
noop: false,
|
|
149
|
+
reason: null,
|
|
150
|
+
commands: [],
|
|
151
|
+
steps: [],
|
|
152
|
+
afterLand: afterLandFor(null),
|
|
153
|
+
prodTag: null,
|
|
154
|
+
targets: [],
|
|
155
|
+
branch,
|
|
156
|
+
base: c.base,
|
|
157
|
+
}
|
|
158
|
+
const block = (reason) => ({ ...result, blocked: true, reason })
|
|
159
|
+
|
|
160
|
+
// A hotfix must carry the tag it forked from — everything below bumps from it.
|
|
161
|
+
if (!baseRef) {
|
|
162
|
+
return block('spec has no Base version — not a hotfix, or the header is missing')
|
|
163
|
+
}
|
|
164
|
+
// The completion edits (status flip, git mv) must be committed before landing.
|
|
165
|
+
if (c.worktreeState && c.worktreeState.dirty) {
|
|
166
|
+
return block('worktree has uncommitted changes — commit the completion first')
|
|
167
|
+
}
|
|
168
|
+
// Nothing on the branch beyond the base tag → nothing to land.
|
|
169
|
+
if (!c.aheadOfBase) {
|
|
170
|
+
return { ...result, noop: true, reason: `no commits on ${branch} beyond ${baseRef} — nothing to land` }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Track tags we'd create so we never plan a collision (with an existing tag, or
|
|
174
|
+
// between the prod tag and a target tag).
|
|
175
|
+
const taken = new Set(c.existingTags || [])
|
|
176
|
+
const fixRange = c.fixRange
|
|
177
|
+
// What a target cherry-picks: the fix commits by sha when the caller listed
|
|
178
|
+
// them (the version commit already left out), else the bare range. The
|
|
179
|
+
// range form is what this planner always emitted and what pure callers that
|
|
180
|
+
// list nothing still get.
|
|
181
|
+
const fixPick = Array.isArray(c.fixCommits) && c.fixCommits.length ? c.fixCommits : [fixRange]
|
|
182
|
+
const commands = []
|
|
183
|
+
const targets = []
|
|
184
|
+
// What the engine runs (hotfixland.js). `commands` stays beside it as the
|
|
185
|
+
// human-readable spelling of the same thing, for `--plan` and the tests.
|
|
186
|
+
const steps = []
|
|
187
|
+
const bump = hotfixCfg.bump || 'patch'
|
|
188
|
+
const releaseWith = String(hotfixCfg.releaseWith || '').trim()
|
|
189
|
+
|
|
190
|
+
// 1. Prod line: release the hotfix branch head (already baseRef + fix). No push.
|
|
191
|
+
// Three ways, decided here so the runner never has to: a release already
|
|
192
|
+
// cut on an earlier run and still at HEAD is reused, never re-cut; a
|
|
193
|
+
// project's `releaseWith` command cuts it and says which tag it made; or
|
|
194
|
+
// the built-in tagger tags HEAD with the `hotfix.bump` strategy's tag.
|
|
195
|
+
let prodTag = null
|
|
196
|
+
if (c.released && c.released.tag) {
|
|
197
|
+
prodTag = c.released.tag
|
|
198
|
+
taken.add(prodTag)
|
|
199
|
+
steps.push({ kind: 'release', via: 'recorded', tag: prodTag, release: c.released })
|
|
200
|
+
} else if (releaseWith) {
|
|
201
|
+
// The tag is the command's to choose and is unknown until it has run, so
|
|
202
|
+
// there is nothing to check a collision against here — the command refuses
|
|
203
|
+
// a tag that exists, and the runner refuses one a target also planned.
|
|
204
|
+
commands.push(`(cd ${spec.worktreePath} && ${releaseWith})`)
|
|
205
|
+
steps.push({ kind: 'release', via: 'command', command: releaseWith, cwd: spec.worktreePath })
|
|
206
|
+
} else {
|
|
207
|
+
prodTag = nextTag(baseRef, c.existingTags, bump)
|
|
208
|
+
if (taken.has(prodTag)) {
|
|
209
|
+
return block(`tag ${prodTag} already exists — bump the base version or delete the stale tag`)
|
|
210
|
+
}
|
|
211
|
+
taken.add(prodTag)
|
|
212
|
+
commands.push(`git -C ${spec.worktreePath} tag ${prodTag}`)
|
|
213
|
+
steps.push({
|
|
214
|
+
kind: 'release',
|
|
215
|
+
via: 'tag',
|
|
216
|
+
tag: prodTag,
|
|
217
|
+
cwd: spec.worktreePath,
|
|
218
|
+
baseTag: baseRef,
|
|
219
|
+
branch,
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
targets.push({ kind: 'prod', base: baseRef, tag: prodTag })
|
|
223
|
+
|
|
224
|
+
// 2. Publish the prod line: the branch first, then the tag, because the tag
|
|
225
|
+
// push is what starts the deploy. BEFORE anything is carried to main, so a
|
|
226
|
+
// declined or failed push never leaves main carrying a fix whose tag was
|
|
227
|
+
// never published. Three cases: the tag is already on the remote (a
|
|
228
|
+
// hand-run push, honoured on its positive signal); the operator consented
|
|
229
|
+
// (`--push`); or neither, and the run holds here with the commands printed.
|
|
230
|
+
const remote = c.remote || 'origin'
|
|
231
|
+
if (c.pushed) {
|
|
232
|
+
steps.push({ kind: 'push', via: 'remote', remote, branch, cwd: spec.worktreePath })
|
|
233
|
+
} else if (c.push) {
|
|
234
|
+
steps.push({ kind: 'push', via: 'push', remote, branch, cwd: spec.worktreePath })
|
|
235
|
+
} else {
|
|
236
|
+
steps.push({ kind: 'push', via: 'hold', remote, branch, cwd: spec.worktreePath })
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 3. Extra targets: cherry-pick the fix onto a throwaway worktree at each tag,
|
|
240
|
+
// re-tag with its own bump, then remove the worktree + temp branch (the
|
|
241
|
+
// commits survive under the new tag, so `-D` is safe for the throwaway branch).
|
|
242
|
+
// TAG-ONLY, deliberately: `releaseWith` is the prod line's. Running a
|
|
243
|
+
// project's release per target would need a hotfix/ branch and a clean
|
|
244
|
+
// tree for each one, and one failing half-way leaves a line half-released.
|
|
245
|
+
const wtRoot = path.dirname(spec.worktreePath)
|
|
246
|
+
for (const t of c.extraTargets || []) {
|
|
247
|
+
// Counted against `taken`, so two targets on one root get successive
|
|
248
|
+
// hotfix numbers rather than colliding on the same one.
|
|
249
|
+
const tag = nextTag(t, [...taken], bump)
|
|
250
|
+
if (taken.has(tag)) {
|
|
251
|
+
return block(`tag ${tag} (for target ${t}) already exists — resolve it before landing`)
|
|
252
|
+
}
|
|
253
|
+
taken.add(tag)
|
|
254
|
+
const slug = `${spec.slug}-onto-${tagSlug(t)}`
|
|
255
|
+
const tmpBranch = `hotfix/${slug}`
|
|
256
|
+
const tmpPath = path.join(wtRoot, slug)
|
|
257
|
+
commands.push(
|
|
258
|
+
`git worktree add ${tmpPath} -b ${tmpBranch} ${t}`,
|
|
259
|
+
`git -C ${tmpPath} cherry-pick ${fixPick.join(' ')}`,
|
|
260
|
+
`git -C ${tmpPath} tag ${tag}`,
|
|
261
|
+
`git worktree remove ${tmpPath}`,
|
|
262
|
+
`git branch -D ${tmpBranch}`,
|
|
263
|
+
)
|
|
264
|
+
const root = c.mainRepoPath
|
|
265
|
+
steps.push({
|
|
266
|
+
kind: 'target',
|
|
267
|
+
base: t,
|
|
268
|
+
tag,
|
|
269
|
+
worktreePath: tmpPath,
|
|
270
|
+
argvs: [
|
|
271
|
+
['-C', root, 'worktree', 'add', tmpPath, '-b', tmpBranch, t],
|
|
272
|
+
['-C', tmpPath, 'cherry-pick', ...fixPick],
|
|
273
|
+
['-C', tmpPath, 'tag', tag],
|
|
274
|
+
['-C', root, 'worktree', 'remove', tmpPath],
|
|
275
|
+
['-C', root, 'branch', '-D', tmpBranch],
|
|
276
|
+
],
|
|
277
|
+
})
|
|
278
|
+
targets.push({ kind: 'extra', base: t, tag, worktreePath: tmpPath })
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 4. Cherry-pick the fix onto the base branch (main) for the next release.
|
|
282
|
+
// Only then does the primary checkout gain anything, so only then do the
|
|
283
|
+
// after-land commands apply — a prod-only land leaves main untouched.
|
|
284
|
+
let afterLand = result.afterLand
|
|
285
|
+
if (hotfixCfg.cherryPickMain !== false) {
|
|
286
|
+
// THE CARRY IS A LIST OF COMMITS, NOT A RANGE. A releasing command leaves
|
|
287
|
+
// a version commit on the branch (package.json, CHANGELOG, RELEASES), and
|
|
288
|
+
// main's version line never moves on a hotfix — so the caller hands over
|
|
289
|
+
// `baseRef..branch` minus that commit and minus what `git cherry` says main
|
|
290
|
+
// already has. The spec's own completion commits stay in it: that is how
|
|
291
|
+
// the completed spec reaches main. Without a list (pure callers), the old
|
|
292
|
+
// range form, run in place.
|
|
293
|
+
const carry = c.carry || { strategy: 'inplace', path: c.mainRepoPath, commits: null }
|
|
294
|
+
const picks = carry.commits || [fixRange]
|
|
295
|
+
commands.push(
|
|
296
|
+
carry.strategy === 'worktree'
|
|
297
|
+
? `git worktree add ${carry.tmpPath} ${c.base} && git -C ${carry.tmpPath} cherry-pick ${picks.join(' ')}`
|
|
298
|
+
: `git -C ${carry.path} cherry-pick ${picks.join(' ')}`,
|
|
299
|
+
)
|
|
300
|
+
steps.push({
|
|
301
|
+
kind: 'carry',
|
|
302
|
+
base: c.base,
|
|
303
|
+
strategy: carry.strategy,
|
|
304
|
+
cwd: carry.path || c.mainRepoPath,
|
|
305
|
+
root: c.mainRepoPath,
|
|
306
|
+
tmpPath: carry.tmpPath || null,
|
|
307
|
+
commits: picks,
|
|
308
|
+
})
|
|
309
|
+
targets.push({ kind: 'main', base: c.base })
|
|
310
|
+
afterLand = afterLandFor(config, c.mainRepoPath)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return { ...result, commands, steps, afterLand, prodTag, targets, fixRange }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Plan `spec-env hotfix finish`: the teardown a hotfix waits for until its
|
|
318
|
+
* deploy is out.
|
|
319
|
+
*
|
|
320
|
+
* A hotfix branch is needed until the release it cut is confirmed — a failed
|
|
321
|
+
* deploy cuts the next `-hotfix.N` from the same branch — so `/spec-complete`
|
|
322
|
+
* lands and keeps the worktree, and this is the later step that removes it.
|
|
323
|
+
*
|
|
324
|
+
* THREE GUARDS, every one a positive signal, and every failing one named:
|
|
325
|
+
* - a clean tree — anything uncommitted would go with the worktree;
|
|
326
|
+
* - a release tag at HEAD — the land actually released what is here;
|
|
327
|
+
* - the branch on the remote at HEAD — a copy exists after `branch -D`.
|
|
328
|
+
* A remote that cannot be asked is "cannot tell", which refuses: deleting
|
|
329
|
+
* the only copy of a deployed fix is not the safe way to be wrong.
|
|
330
|
+
*
|
|
331
|
+
* DELIBERATELY NO "MERGED INTO MAIN" GUARD: the version commit never reaches
|
|
332
|
+
* main, so the branch is never an ancestor of it and `-d` would always refuse.
|
|
333
|
+
* And DELIBERATELY NO `--force` — the guards are the point of the verb, and
|
|
334
|
+
* `spec-env down --force` already exists for anyone who means to override them.
|
|
335
|
+
*
|
|
336
|
+
* state: { dirty, tagsAtHead: string[], head, remoteHead: string|null|undefined,
|
|
337
|
+
* remote, mainRepoPath, hasSlot }
|
|
338
|
+
* remoteHead: the branch's sha on the remote; null = not there;
|
|
339
|
+
* undefined = the remote could not be asked.
|
|
340
|
+
*/
|
|
341
|
+
function planHotfixFinish(spec, state) {
|
|
342
|
+
const s = state || {}
|
|
343
|
+
const remote = s.remote || 'origin'
|
|
344
|
+
const reasons = []
|
|
345
|
+
if (s.dirty) reasons.push('the worktree has uncommitted changes')
|
|
346
|
+
const releaseTag = (s.tagsAtHead || []).find((t) => parseTag(t) !== null) || null
|
|
347
|
+
if (!releaseTag) {
|
|
348
|
+
reasons.push('no release tag at HEAD — run `spec-env hotfix land` first')
|
|
349
|
+
}
|
|
350
|
+
if (s.remoteHead === undefined) {
|
|
351
|
+
reasons.push(`cannot tell whether ${spec.branch} is on ${remote} — the remote did not answer`)
|
|
352
|
+
} else if (s.remoteHead === null) {
|
|
353
|
+
reasons.push(`${spec.branch} is not on ${remote} — push it (\`hotfix land --push\`)`)
|
|
354
|
+
} else if (s.head && s.remoteHead !== s.head) {
|
|
355
|
+
reasons.push(`${remote}/${spec.branch} is not at HEAD — push the branch first`)
|
|
356
|
+
}
|
|
357
|
+
// A Docker stack has volumes and a slot that only `down` knows how to free.
|
|
358
|
+
if (s.hasSlot) {
|
|
359
|
+
reasons.push(`${spec.folder} holds a Docker stack — tear it down with \`spec-env down\``)
|
|
360
|
+
}
|
|
361
|
+
if (reasons.length) return { blocked: true, reasons, releaseTag, argvs: [] }
|
|
362
|
+
const root = s.mainRepoPath
|
|
363
|
+
return {
|
|
364
|
+
blocked: false,
|
|
365
|
+
reasons: [],
|
|
366
|
+
releaseTag,
|
|
367
|
+
argvs: [
|
|
368
|
+
['-C', root, 'worktree', 'remove', spec.worktreePath],
|
|
369
|
+
// -D, not -d: the branch is never merged into main (see above), and its
|
|
370
|
+
// commits are on the remote and under the release tag.
|
|
371
|
+
['-C', root, 'branch', '-D', spec.branch],
|
|
372
|
+
],
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
module.exports = {
|
|
377
|
+
bumpPatch,
|
|
378
|
+
compareTags,
|
|
379
|
+
nextHotfixTag,
|
|
380
|
+
nextTag,
|
|
381
|
+
BUMPS,
|
|
382
|
+
tagSlug,
|
|
383
|
+
planHotfixLand,
|
|
384
|
+
planHotfixFinish,
|
|
385
|
+
}
|