infra-kit 0.1.97 → 0.1.99
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/.eslintcache +1 -1
- package/.turbo/turbo-eslint-check.log +1 -1
- package/.turbo/turbo-prettier-check.log +1 -1
- package/.turbo/turbo-test.log +7 -7
- package/.turbo/turbo-ts-check.log +1 -1
- package/dist/cli.js +73 -37
- package/dist/cli.js.map +4 -4
- package/dist/mcp.js +30 -26
- package/dist/mcp.js.map +4 -4
- package/package.json +1 -1
- package/src/commands/config/config.ts +125 -0
- package/src/commands/config/index.ts +1 -0
- package/src/commands/doctor/doctor.ts +27 -18
- package/src/commands/init/init.ts +65 -1
- package/src/commands/release-create/release-create.ts +226 -95
- package/src/commands/worktrees-add/worktrees-add.ts +16 -12
- package/src/entry/cli.ts +35 -27
- package/src/lib/__tests__/infra-kit-config.test.ts +53 -1
- package/src/lib/infra-kit-config/index.ts +2 -2
- package/src/lib/infra-kit-config/infra-kit-config.ts +190 -37
- package/src/lib/version-utils/__tests__/next-version.test.ts +217 -0
- package/src/lib/version-utils/index.ts +13 -0
- package/src/lib/version-utils/load-existing-versions.ts +67 -0
- package/src/lib/version-utils/next-version.ts +187 -0
- package/src/mcp/tools/index.ts +0 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/src/commands/release-create-batch/index.ts +0 -1
- package/src/commands/release-create-batch/release-create-batch.ts +0 -198
|
@@ -4,13 +4,14 @@ import path from 'node:path'
|
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
5
|
|
|
6
6
|
// Import AFTER the mock is declared so the module picks up the mocked dep.
|
|
7
|
-
import { getProjectRoot } from 'src/lib/git-utils'
|
|
7
|
+
import { getProjectRoot, getRepoName } from 'src/lib/git-utils'
|
|
8
8
|
|
|
9
9
|
import { getInfraKitConfig, resetInfraKitConfigCache } from '../infra-kit-config'
|
|
10
10
|
|
|
11
11
|
vi.mock('src/lib/git-utils', () => {
|
|
12
12
|
return {
|
|
13
13
|
getProjectRoot: vi.fn(),
|
|
14
|
+
getRepoName: vi.fn(),
|
|
14
15
|
}
|
|
15
16
|
})
|
|
16
17
|
|
|
@@ -35,11 +36,18 @@ const withTmpRepo = async (fn: (tmp: string) => Promise<void>): Promise<void> =>
|
|
|
35
36
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'infra-kit-config-test-'))
|
|
36
37
|
|
|
37
38
|
vi.mocked(getProjectRoot).mockResolvedValue(tmp)
|
|
39
|
+
vi.mocked(getRepoName).mockResolvedValue(path.basename(tmp))
|
|
40
|
+
// Point os.homedir() at the tmp dir so user-scope override layers
|
|
41
|
+
// (~/.infra-kit/config.yml, ~/.infra-kit/projects/<repo>/infra-kit.yml)
|
|
42
|
+
// can't leak the developer's real config into the test.
|
|
43
|
+
const homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp)
|
|
44
|
+
|
|
38
45
|
resetInfraKitConfigCache()
|
|
39
46
|
|
|
40
47
|
try {
|
|
41
48
|
await fn(tmp)
|
|
42
49
|
} finally {
|
|
50
|
+
homedirSpy.mockRestore()
|
|
43
51
|
resetInfraKitConfigCache()
|
|
44
52
|
fs.rmSync(tmp, { recursive: true, force: true })
|
|
45
53
|
}
|
|
@@ -103,6 +111,50 @@ taskManager:
|
|
|
103
111
|
})
|
|
104
112
|
})
|
|
105
113
|
|
|
114
|
+
it('accepts a worktrees prompt-defaults block', async () => {
|
|
115
|
+
await withTmpRepo(async (tmp) => {
|
|
116
|
+
fs.writeFileSync(
|
|
117
|
+
path.join(tmp, 'infra-kit.yml'),
|
|
118
|
+
`environments: [dev]
|
|
119
|
+
envManagement:
|
|
120
|
+
provider: doppler
|
|
121
|
+
config:
|
|
122
|
+
name: p
|
|
123
|
+
worktrees:
|
|
124
|
+
openInGithubDesktop: false
|
|
125
|
+
openInCmux: true
|
|
126
|
+
`,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
const cfg = await getInfraKitConfig()
|
|
130
|
+
|
|
131
|
+
expect(cfg.worktrees?.openInGithubDesktop).toBe(false)
|
|
132
|
+
expect(cfg.worktrees?.openInCmux).toBe(true)
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('lets the user-global config layer supply a worktrees block when the project omits it', async () => {
|
|
137
|
+
await withTmpRepo(async (tmp) => {
|
|
138
|
+
fs.writeFileSync(path.join(tmp, 'infra-kit.yml'), VALID_YML)
|
|
139
|
+
|
|
140
|
+
const userGlobalDir = path.join(tmp, '.infra-kit')
|
|
141
|
+
|
|
142
|
+
fs.mkdirSync(userGlobalDir, { recursive: true })
|
|
143
|
+
fs.writeFileSync(
|
|
144
|
+
path.join(userGlobalDir, 'config.yml'),
|
|
145
|
+
`worktrees:
|
|
146
|
+
openInGithubDesktop: false
|
|
147
|
+
openInCmux: true
|
|
148
|
+
`,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
const cfg = await getInfraKitConfig()
|
|
152
|
+
|
|
153
|
+
expect(cfg.worktrees?.openInGithubDesktop).toBe(false)
|
|
154
|
+
expect(cfg.worktrees?.openInCmux).toBe(true)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
106
158
|
it('rejects ide.cursor mode=workspace without workspaceConfigPath', async () => {
|
|
107
159
|
await withTmpRepo(async (tmp) => {
|
|
108
160
|
fs.writeFileSync(
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { getInfraKitConfig, resetInfraKitConfigCache } from './infra-kit-config'
|
|
2
|
-
export type { InfraKitConfig } from './infra-kit-config'
|
|
1
|
+
export { getInfraKitConfig, getInfraKitConfigPaths, resetInfraKitConfigCache } from './infra-kit-config'
|
|
2
|
+
export type { InfraKitConfig, InfraKitConfigPaths } from './infra-kit-config'
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import fs from 'node:fs/promises'
|
|
2
|
+
import os from 'node:os'
|
|
2
3
|
import path from 'node:path'
|
|
3
4
|
import yaml from 'yaml'
|
|
4
5
|
import { z } from 'zod/v4'
|
|
5
6
|
|
|
6
|
-
import { getProjectRoot } from 'src/lib/git-utils'
|
|
7
|
+
import { getProjectRoot, getRepoName } from 'src/lib/git-utils'
|
|
7
8
|
|
|
8
9
|
const INFRA_KIT_CONFIG_FILE = 'infra-kit.yml'
|
|
9
|
-
|
|
10
|
+
|
|
11
|
+
const USER_CONFIG_DIR_NAME = '.infra-kit'
|
|
12
|
+
const USER_GLOBAL_CONFIG_FILE = 'config.yml'
|
|
13
|
+
const USER_PROJECTS_DIR = 'projects'
|
|
10
14
|
|
|
11
15
|
// envManagement
|
|
12
16
|
const dopplerEnvManagementSchema = z.object({
|
|
@@ -52,88 +56,162 @@ const jiraTaskManagerSchema = z.object({
|
|
|
52
56
|
|
|
53
57
|
const taskManagerSchema = z.discriminatedUnion('provider', [jiraTaskManagerSchema])
|
|
54
58
|
|
|
59
|
+
// worktrees prompt defaults
|
|
60
|
+
const worktreesConfigSchema = z.object({
|
|
61
|
+
openInGithubDesktop: z.boolean().optional(),
|
|
62
|
+
openInCmux: z.boolean().optional(),
|
|
63
|
+
})
|
|
64
|
+
|
|
55
65
|
const infraKitConfigSchema = z.object({
|
|
56
66
|
environments: z.array(z.string().min(1)).min(1),
|
|
57
67
|
envManagement: envManagementSchema,
|
|
58
68
|
ide: ideSchema.optional(),
|
|
59
69
|
taskManager: taskManagerSchema.optional(),
|
|
70
|
+
worktrees: worktreesConfigSchema.optional(),
|
|
60
71
|
})
|
|
61
72
|
|
|
62
|
-
const
|
|
73
|
+
const infraKitOverrideConfigSchema = infraKitConfigSchema.partial()
|
|
63
74
|
|
|
64
75
|
export type InfraKitConfig = z.infer<typeof infraKitConfigSchema>
|
|
65
76
|
|
|
77
|
+
export interface InfraKitConfigPaths {
|
|
78
|
+
/** Committed project config (required). */
|
|
79
|
+
main: string
|
|
80
|
+
/** User-scope global overrides applied to every project. */
|
|
81
|
+
userGlobal: string
|
|
82
|
+
/** User-scope per-project overrides — `<userProjectsDir>/<projectName>/infra-kit.yml`. */
|
|
83
|
+
userProject: string
|
|
84
|
+
/** Repo basename (`path.basename(projectRoot)`) used to namespace the user-project file. */
|
|
85
|
+
projectName: string
|
|
86
|
+
}
|
|
87
|
+
|
|
66
88
|
interface CacheEntry {
|
|
67
|
-
|
|
68
|
-
localMtimeMs: number | null
|
|
89
|
+
mtimes: Record<keyof Omit<InfraKitConfigPaths, 'projectName'>, number | null>
|
|
69
90
|
value: InfraKitConfig
|
|
70
91
|
}
|
|
71
92
|
|
|
72
93
|
let cached: CacheEntry | null = null
|
|
73
94
|
|
|
74
95
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
96
|
+
* Resolve every file path that participates in the config merge chain. Always
|
|
97
|
+
* returns paths even for files that don't yet exist, so callers can use them
|
|
98
|
+
* for "where would my override go?" prompts.
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* const paths = await getInfraKitConfigPaths()
|
|
102
|
+
* // {
|
|
103
|
+
* // main: '/Users/arthur/projects/api/infra-kit.yml',
|
|
104
|
+
* // userGlobal: '/Users/arthur/.infra-kit/config.yml',
|
|
105
|
+
* // userProject: '/Users/arthur/.infra-kit/projects/api/infra-kit.yml',
|
|
106
|
+
* // projectName: 'api',
|
|
107
|
+
* // }
|
|
80
108
|
*/
|
|
81
|
-
export const
|
|
109
|
+
export const getInfraKitConfigPaths = async (): Promise<InfraKitConfigPaths> => {
|
|
82
110
|
const projectRoot = await getProjectRoot()
|
|
83
|
-
const
|
|
84
|
-
const
|
|
111
|
+
const projectName = await getRepoName()
|
|
112
|
+
const userConfigDir = path.join(os.homedir(), USER_CONFIG_DIR_NAME)
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
main: path.join(projectRoot, INFRA_KIT_CONFIG_FILE),
|
|
116
|
+
userGlobal: path.join(userConfigDir, USER_GLOBAL_CONFIG_FILE),
|
|
117
|
+
userProject: path.join(userConfigDir, USER_PROJECTS_DIR, projectName, INFRA_KIT_CONFIG_FILE),
|
|
118
|
+
projectName,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Read and validate `infra-kit.yml`, with optional override layers shallow-merged
|
|
124
|
+
* on top in this order (later wins):
|
|
125
|
+
* 1. project `infra-kit.yml` — committed source of truth
|
|
126
|
+
* 2. `~/.infra-kit/config.yml` — user-global defaults
|
|
127
|
+
* 3. `~/.infra-kit/projects/<repo-name>/infra-kit.yml` — user-scope per-project overrides
|
|
128
|
+
*
|
|
129
|
+
* Top-level keys (entire capability sections like `ide`, `envManagement`)
|
|
130
|
+
* replace wholesale. Results are cached per file mtimes so the long-running
|
|
131
|
+
* MCP server picks up edits without a restart.
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* // infra-kit.yml: { environments: ['dev'], envManagement: { provider: 'doppler', config: { name: 'p' } } }
|
|
135
|
+
* // ~/.infra-kit/config.yml: { ide: { provider: 'cursor', config: { mode: 'windows' } } }
|
|
136
|
+
* const cfg = await getInfraKitConfig()
|
|
137
|
+
* // => { environments: ['dev'], envManagement: {...}, ide: { provider: 'cursor', config: { mode: 'windows' } } }
|
|
138
|
+
*/
|
|
139
|
+
export const getInfraKitConfig = async (): Promise<InfraKitConfig> => {
|
|
140
|
+
const paths = await getInfraKitConfigPaths()
|
|
85
141
|
|
|
86
142
|
let mainStat: Awaited<ReturnType<typeof fs.stat>>
|
|
87
143
|
|
|
88
144
|
try {
|
|
89
|
-
mainStat = await fs.stat(
|
|
145
|
+
mainStat = await fs.stat(paths.main)
|
|
90
146
|
} catch {
|
|
91
147
|
cached = null
|
|
92
|
-
throw new Error(`infra-kit.yml not found at ${
|
|
148
|
+
throw new Error(`infra-kit.yml not found at ${paths.main}`)
|
|
93
149
|
}
|
|
94
150
|
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
151
|
+
const [userGlobalStat, userProjectStat] = await Promise.all([
|
|
152
|
+
statIfExists(paths.userGlobal),
|
|
153
|
+
statIfExists(paths.userProject),
|
|
154
|
+
])
|
|
98
155
|
|
|
99
|
-
|
|
100
|
-
|
|
156
|
+
const mtimes = {
|
|
157
|
+
main: Number(mainStat.mtimeMs),
|
|
158
|
+
userGlobal: userGlobalStat ? Number(userGlobalStat.mtimeMs) : null,
|
|
159
|
+
userProject: userProjectStat ? Number(userProjectStat.mtimeMs) : null,
|
|
101
160
|
}
|
|
102
161
|
|
|
103
|
-
|
|
104
|
-
|
|
162
|
+
if (cached && shallowEqual(cached.mtimes, mtimes)) {
|
|
163
|
+
return cached.value
|
|
164
|
+
}
|
|
105
165
|
|
|
106
|
-
|
|
166
|
+
const layers: ConfigLayer[] = [
|
|
167
|
+
{ label: 'infra-kit.yml', path: paths.main, required: true },
|
|
168
|
+
{ label: '~/.infra-kit/config.yml', path: paths.userGlobal, required: false },
|
|
169
|
+
{
|
|
170
|
+
label: `~/.infra-kit/projects/${paths.projectName}/infra-kit.yml`,
|
|
171
|
+
path: paths.userProject,
|
|
172
|
+
required: false,
|
|
173
|
+
},
|
|
174
|
+
]
|
|
107
175
|
|
|
108
|
-
|
|
109
|
-
const localRaw = await fs.readFile(localPath, 'utf-8')
|
|
110
|
-
const localParsedRaw = yaml.parse(localRaw) ?? {}
|
|
176
|
+
let merged: Record<string, unknown> = {}
|
|
111
177
|
|
|
112
|
-
|
|
178
|
+
for (const layer of layers) {
|
|
179
|
+
const data = await loadLayer(layer)
|
|
113
180
|
|
|
114
|
-
if (
|
|
115
|
-
throw new Error(`Invalid infra-kit.local.yml at ${localPath}: ${z.prettifyError(localResult.error)}`)
|
|
116
|
-
}
|
|
181
|
+
if (data === null) continue
|
|
117
182
|
|
|
118
|
-
merged = { ...
|
|
183
|
+
merged = { ...merged, ...data }
|
|
119
184
|
}
|
|
120
185
|
|
|
121
|
-
const
|
|
186
|
+
const finalResult = infraKitConfigSchema.safeParse(merged)
|
|
122
187
|
|
|
123
|
-
if (!
|
|
124
|
-
throw new Error(`Invalid infra-kit
|
|
188
|
+
if (!finalResult.success) {
|
|
189
|
+
throw new Error(`Invalid merged infra-kit config: ${z.prettifyError(finalResult.error)}`)
|
|
125
190
|
}
|
|
126
191
|
|
|
127
|
-
cached = {
|
|
192
|
+
cached = { mtimes, value: finalResult.data }
|
|
128
193
|
|
|
129
|
-
return
|
|
194
|
+
return finalResult.data
|
|
130
195
|
}
|
|
131
196
|
|
|
132
|
-
/**
|
|
197
|
+
/**
|
|
198
|
+
* For tests — drops the in-memory cache so the next read hits disk.
|
|
199
|
+
*
|
|
200
|
+
* @example
|
|
201
|
+
* resetInfraKitConfigCache()
|
|
202
|
+
* await getInfraKitConfig() // re-reads files even if mtimes look unchanged
|
|
203
|
+
*/
|
|
133
204
|
export const resetInfraKitConfigCache = (): void => {
|
|
134
205
|
cached = null
|
|
135
206
|
}
|
|
136
207
|
|
|
208
|
+
/**
|
|
209
|
+
* `fs.stat` that returns `null` instead of throwing on ENOENT. Used so the
|
|
210
|
+
* resolver can probe optional files in the merge chain without try/catch noise.
|
|
211
|
+
*
|
|
212
|
+
* @example
|
|
213
|
+
* const stat = await statIfExists('/does/not/exist') // => null
|
|
214
|
+
*/
|
|
137
215
|
const statIfExists = async (filePath: string): Promise<Awaited<ReturnType<typeof fs.stat>> | null> => {
|
|
138
216
|
try {
|
|
139
217
|
return await fs.stat(filePath)
|
|
@@ -141,3 +219,78 @@ const statIfExists = async (filePath: string): Promise<Awaited<ReturnType<typeof
|
|
|
141
219
|
return null
|
|
142
220
|
}
|
|
143
221
|
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* `fs.readFile` that returns `null` instead of throwing on ENOENT.
|
|
225
|
+
*
|
|
226
|
+
* @example
|
|
227
|
+
* const raw = await readIfExists('/missing.yml') // => null
|
|
228
|
+
* const raw = await readIfExists('/exists.yml') // => 'environments: [dev]\n'
|
|
229
|
+
*/
|
|
230
|
+
const readIfExists = async (filePath: string): Promise<string | null> => {
|
|
231
|
+
try {
|
|
232
|
+
return await fs.readFile(filePath, 'utf-8')
|
|
233
|
+
} catch {
|
|
234
|
+
return null
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Reference-equality comparison of every key in two flat records. Used to
|
|
240
|
+
* cheaply detect whether the cached mtime fingerprint still matches.
|
|
241
|
+
*
|
|
242
|
+
* @example
|
|
243
|
+
* shallowEqual({ a: 1, b: 2 }, { a: 1, b: 2 }) // => true
|
|
244
|
+
* shallowEqual({ a: 1 }, { a: 1, b: 2 }) // => false
|
|
245
|
+
* shallowEqual({ a: 1 }, { a: 2 }) // => false
|
|
246
|
+
*/
|
|
247
|
+
const shallowEqual = <T extends Record<string, unknown>>(a: T, b: T): boolean => {
|
|
248
|
+
const keys = Object.keys(a)
|
|
249
|
+
|
|
250
|
+
if (keys.length !== Object.keys(b).length) return false
|
|
251
|
+
|
|
252
|
+
return keys.every((k) => {
|
|
253
|
+
return a[k] === b[k]
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
interface ConfigLayer {
|
|
258
|
+
label: string
|
|
259
|
+
path: string
|
|
260
|
+
required: boolean
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Read a single layer of the merge chain: parse the YAML if the file exists
|
|
265
|
+
* and validate it against the override schema. Returns `null` if an optional
|
|
266
|
+
* layer is missing; throws if the layer is required or invalid.
|
|
267
|
+
*
|
|
268
|
+
* @example
|
|
269
|
+
* await loadLayer({ label: '~/.infra-kit/config.yml', path: '/missing.yml', required: false })
|
|
270
|
+
* // => null
|
|
271
|
+
*
|
|
272
|
+
* @example
|
|
273
|
+
* // /home/me/.infra-kit/config.yml: 'ide:\n provider: cursor\n config: { mode: windows }'
|
|
274
|
+
* await loadLayer({ label: '~/.infra-kit/config.yml', path: '/home/me/.infra-kit/config.yml', required: false })
|
|
275
|
+
* // => { ide: { provider: 'cursor', config: { mode: 'windows' } } }
|
|
276
|
+
*/
|
|
277
|
+
const loadLayer = async (layer: ConfigLayer): Promise<Record<string, unknown> | null> => {
|
|
278
|
+
const raw = await readIfExists(layer.path)
|
|
279
|
+
|
|
280
|
+
if (raw === null) {
|
|
281
|
+
if (layer.required) {
|
|
282
|
+
throw new Error(`${layer.label} not found at ${layer.path}`)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return null
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const parsedRaw = yaml.parse(raw) ?? {}
|
|
289
|
+
const result = infraKitOverrideConfigSchema.safeParse(parsedRaw)
|
|
290
|
+
|
|
291
|
+
if (!result.success) {
|
|
292
|
+
throw new Error(`Invalid ${layer.label} at ${layer.path}: ${z.prettifyError(result.error)}`)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return result.data as Record<string, unknown>
|
|
296
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
NoPriorVersionsError,
|
|
5
|
+
collectKnownVersions,
|
|
6
|
+
computeNextVersion,
|
|
7
|
+
parseReleaseSpec,
|
|
8
|
+
resolveReleaseEntries,
|
|
9
|
+
} from '../next-version'
|
|
10
|
+
|
|
11
|
+
describe('collectKnownVersions', () => {
|
|
12
|
+
it('parses remote branch refs and Jira version names and dedupes', () => {
|
|
13
|
+
const known = collectKnownVersions({
|
|
14
|
+
remoteBranches: ['release/v1.62.0', 'refs/heads/release/v1.63.0', 'release/v1.62.0'],
|
|
15
|
+
jiraVersions: ['v1.63.0', 'v1.64.5'],
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
expect(known).toEqual([
|
|
19
|
+
[1, 62, 0],
|
|
20
|
+
[1, 63, 0],
|
|
21
|
+
[1, 64, 5],
|
|
22
|
+
])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('ignores non-semver inputs', () => {
|
|
26
|
+
const known = collectKnownVersions({
|
|
27
|
+
remoteBranches: ['release/v1.62.0', 'release/v-bogus', 'main'],
|
|
28
|
+
jiraVersions: ['v1.63.0', 'random-name'],
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
expect(known).toEqual([
|
|
32
|
+
[1, 62, 0],
|
|
33
|
+
[1, 63, 0],
|
|
34
|
+
])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('returns [] when sources are empty or undefined', () => {
|
|
38
|
+
expect(collectKnownVersions({})).toEqual([])
|
|
39
|
+
expect(collectKnownVersions({ remoteBranches: [], jiraVersions: [] })).toEqual([])
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('computeNextVersion', () => {
|
|
44
|
+
it('regular bumps minor and resets patch', () => {
|
|
45
|
+
const known = collectKnownVersions({ remoteBranches: ['release/v1.63.5', 'release/v1.62.0'] })
|
|
46
|
+
|
|
47
|
+
expect(computeNextVersion(known, 'regular')).toBe('1.64.0')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('hotfix bumps patch on the highest minor (any patch)', () => {
|
|
51
|
+
const known = collectKnownVersions({
|
|
52
|
+
remoteBranches: ['release/v1.63.5', 'release/v1.63.0', 'release/v1.62.0'],
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
expect(computeNextVersion(known, 'hotfix')).toBe('1.63.6')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('hotfix uses the highest minor even when patch chain has gaps', () => {
|
|
59
|
+
const known = collectKnownVersions({
|
|
60
|
+
remoteBranches: ['release/v1.55.10', 'release/v1.55.20', 'release/v1.55.30'],
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
expect(computeNextVersion(known, 'hotfix')).toBe('1.55.31')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('throws NoPriorVersionsError when there are no known versions', () => {
|
|
67
|
+
expect(() => {
|
|
68
|
+
return computeNextVersion([], 'regular')
|
|
69
|
+
}).toThrow(NoPriorVersionsError)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('parseReleaseSpec', () => {
|
|
74
|
+
it('parses bare version as regular with no description', () => {
|
|
75
|
+
expect(parseReleaseSpec('1.2.5')).toEqual({ version: '1.2.5', type: 'regular' })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('parses version:type', () => {
|
|
79
|
+
expect(parseReleaseSpec('1.2.5:hotfix')).toEqual({ version: '1.2.5', type: 'hotfix' })
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('parses version:type:description', () => {
|
|
83
|
+
expect(parseReleaseSpec('1.2.5:regular:Holiday backend')).toEqual({
|
|
84
|
+
version: '1.2.5',
|
|
85
|
+
type: 'regular',
|
|
86
|
+
description: 'Holiday backend',
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('preserves colons inside the description', () => {
|
|
91
|
+
expect(parseReleaseSpec('1.2.5:regular:Fixes: A and B')).toEqual({
|
|
92
|
+
version: '1.2.5',
|
|
93
|
+
type: 'regular',
|
|
94
|
+
description: 'Fixes: A and B',
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('accepts the literal "next" token', () => {
|
|
99
|
+
expect(parseReleaseSpec('next:hotfix')).toEqual({ version: 'next', type: 'hotfix' })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('lowercases the type', () => {
|
|
103
|
+
expect(parseReleaseSpec('1.2.5:HOTFIX')).toEqual({ version: '1.2.5', type: 'hotfix' })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('drops empty description', () => {
|
|
107
|
+
expect(parseReleaseSpec('1.2.5:regular:')).toEqual({ version: '1.2.5', type: 'regular' })
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('throws on unknown type', () => {
|
|
111
|
+
expect(() => {
|
|
112
|
+
return parseReleaseSpec('1.2.5:major')
|
|
113
|
+
}).toThrow(/Invalid release type/)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('throws on empty spec', () => {
|
|
117
|
+
expect(() => {
|
|
118
|
+
return parseReleaseSpec(' ')
|
|
119
|
+
}).toThrow(/empty/)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('resolveReleaseEntries', () => {
|
|
124
|
+
const known = collectKnownVersions({ remoteBranches: ['release/v1.63.0'] })
|
|
125
|
+
|
|
126
|
+
it('passes through explicit semver entries unchanged', () => {
|
|
127
|
+
expect(
|
|
128
|
+
resolveReleaseEntries(
|
|
129
|
+
[
|
|
130
|
+
{ version: '1.70.0', type: 'regular' },
|
|
131
|
+
{ version: 'v1.70.1', type: 'hotfix' },
|
|
132
|
+
],
|
|
133
|
+
known,
|
|
134
|
+
),
|
|
135
|
+
).toEqual([
|
|
136
|
+
{ version: '1.70.0', type: 'regular' },
|
|
137
|
+
{ version: '1.70.1', type: 'hotfix' },
|
|
138
|
+
])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('resolves a single "next" using the entry type', () => {
|
|
142
|
+
expect(resolveReleaseEntries([{ version: 'next', type: 'regular' }], known)).toEqual([
|
|
143
|
+
{ version: '1.64.0', type: 'regular' },
|
|
144
|
+
])
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('advances sequential "next" tokens of the same type', () => {
|
|
148
|
+
expect(
|
|
149
|
+
resolveReleaseEntries(
|
|
150
|
+
[
|
|
151
|
+
{ version: 'next', type: 'regular' },
|
|
152
|
+
{ version: 'next', type: 'regular' },
|
|
153
|
+
],
|
|
154
|
+
known,
|
|
155
|
+
),
|
|
156
|
+
).toEqual([
|
|
157
|
+
{ version: '1.64.0', type: 'regular' },
|
|
158
|
+
{ version: '1.65.0', type: 'regular' },
|
|
159
|
+
])
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('advances sequential "next" tokens across mixed types', () => {
|
|
163
|
+
expect(
|
|
164
|
+
resolveReleaseEntries(
|
|
165
|
+
[
|
|
166
|
+
{ version: 'next', type: 'regular' },
|
|
167
|
+
{ version: 'next', type: 'hotfix' },
|
|
168
|
+
],
|
|
169
|
+
known,
|
|
170
|
+
),
|
|
171
|
+
).toEqual([
|
|
172
|
+
{ version: '1.64.0', type: 'regular' },
|
|
173
|
+
{ version: '1.64.1', type: 'hotfix' },
|
|
174
|
+
])
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('mixes literals and "next", advancing the running max', () => {
|
|
178
|
+
expect(
|
|
179
|
+
resolveReleaseEntries(
|
|
180
|
+
[
|
|
181
|
+
{ version: 'next', type: 'regular' },
|
|
182
|
+
{ version: '1.70.0', type: 'regular' },
|
|
183
|
+
{ version: 'next', type: 'regular' },
|
|
184
|
+
],
|
|
185
|
+
known,
|
|
186
|
+
),
|
|
187
|
+
).toEqual([
|
|
188
|
+
{ version: '1.64.0', type: 'regular' },
|
|
189
|
+
{ version: '1.70.0', type: 'regular' },
|
|
190
|
+
{ version: '1.71.0', type: 'regular' },
|
|
191
|
+
])
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('preserves description through resolution', () => {
|
|
195
|
+
expect(resolveReleaseEntries([{ version: 'next', type: 'regular', description: 'Holiday' }], known)).toEqual([
|
|
196
|
+
{ version: '1.64.0', type: 'regular', description: 'Holiday' },
|
|
197
|
+
])
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('accepts case-insensitive "next"', () => {
|
|
201
|
+
expect(resolveReleaseEntries([{ version: 'NEXT', type: 'regular' }], known)).toEqual([
|
|
202
|
+
{ version: '1.64.0', type: 'regular' },
|
|
203
|
+
])
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('throws on invalid version', () => {
|
|
207
|
+
expect(() => {
|
|
208
|
+
return resolveReleaseEntries([{ version: 'nope', type: 'regular' }], known)
|
|
209
|
+
}).toThrow(/Invalid version/)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('throws NoPriorVersionsError when "next" with no known versions', () => {
|
|
213
|
+
expect(() => {
|
|
214
|
+
return resolveReleaseEntries([{ version: 'next', type: 'regular' }], [])
|
|
215
|
+
}).toThrow(NoPriorVersionsError)
|
|
216
|
+
})
|
|
217
|
+
})
|
|
@@ -1 +1,14 @@
|
|
|
1
|
+
export { loadExistingVersions } from './load-existing-versions'
|
|
2
|
+
export {
|
|
3
|
+
collectKnownVersions,
|
|
4
|
+
computeNextVersion,
|
|
5
|
+
type ExistingVersionsSources,
|
|
6
|
+
hasNextToken,
|
|
7
|
+
NEXT_TOKEN,
|
|
8
|
+
NoPriorVersionsError,
|
|
9
|
+
parseReleaseSpec,
|
|
10
|
+
type ReleaseEntry,
|
|
11
|
+
resolveReleaseEntries,
|
|
12
|
+
type SemVer,
|
|
13
|
+
} from './next-version'
|
|
1
14
|
export { parseVersion, sortVersions } from './version-utils'
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { $ } from 'zx'
|
|
2
|
+
|
|
3
|
+
import { getProjectVersions, loadJiraConfigOptional } from 'src/integrations/jira'
|
|
4
|
+
import { logger } from 'src/lib/logger'
|
|
5
|
+
|
|
6
|
+
import { collectKnownVersions } from './next-version'
|
|
7
|
+
import type { SemVer } from './next-version'
|
|
8
|
+
|
|
9
|
+
const parseRemoteRefs = async (): Promise<string[]> => {
|
|
10
|
+
const previousQuiet = $.quiet
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
$.quiet = true
|
|
14
|
+
const result = await $`git ls-remote --heads origin 'release/v*'`
|
|
15
|
+
const lines = result.stdout.split('\n')
|
|
16
|
+
|
|
17
|
+
return lines
|
|
18
|
+
.map((line) => {
|
|
19
|
+
const tab = line.indexOf('\t')
|
|
20
|
+
|
|
21
|
+
if (tab === -1) return ''
|
|
22
|
+
|
|
23
|
+
return line.slice(tab + 1).replace(/^refs\/heads\//, '')
|
|
24
|
+
})
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
} finally {
|
|
27
|
+
$.quiet = previousQuiet
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const fetchJiraVersionNames = async (): Promise<string[]> => {
|
|
32
|
+
const config = await loadJiraConfigOptional()
|
|
33
|
+
|
|
34
|
+
if (!config) return []
|
|
35
|
+
|
|
36
|
+
const versions = await getProjectVersions(config)
|
|
37
|
+
|
|
38
|
+
return versions.map((v) => {
|
|
39
|
+
return v.name
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Load known release versions from the union of:
|
|
45
|
+
* - remote release branches (`release/v*` on origin)
|
|
46
|
+
* - Jira fix versions (when configured)
|
|
47
|
+
*
|
|
48
|
+
* Each source is queried in parallel; if either fails, we log a warning
|
|
49
|
+
* and continue with the other so a transient outage doesn't block release
|
|
50
|
+
* creation.
|
|
51
|
+
*/
|
|
52
|
+
export const loadExistingVersions = async (): Promise<SemVer[]> => {
|
|
53
|
+
const [branchesResult, jiraResult] = await Promise.allSettled([parseRemoteRefs(), fetchJiraVersionNames()])
|
|
54
|
+
|
|
55
|
+
if (branchesResult.status === 'rejected') {
|
|
56
|
+
logger.warn({ error: branchesResult.reason }, 'Failed to list remote release branches; continuing without them')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (jiraResult.status === 'rejected') {
|
|
60
|
+
logger.warn({ error: jiraResult.reason }, 'Failed to fetch Jira versions; continuing without them')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return collectKnownVersions({
|
|
64
|
+
remoteBranches: branchesResult.status === 'fulfilled' ? branchesResult.value : [],
|
|
65
|
+
jiraVersions: jiraResult.status === 'fulfilled' ? jiraResult.value : [],
|
|
66
|
+
})
|
|
67
|
+
}
|