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.
@@ -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
- const INFRA_KIT_LOCAL_CONFIG_FILE = 'infra-kit.local.yml'
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 infraKitLocalConfigSchema = infraKitConfigSchema.partial()
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
- mainMtimeMs: number
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
- * Read and validate `infra-kit.yml`, with optional `infra-kit.local.yml` overrides
76
- * shallow-merged on top (per-developer, gitignored). Top-level keys (entire
77
- * capability sections like `ide`, `envManagement`) replace wholesale. Results are
78
- * cached per file mtimes so the long-running MCP server picks up edits without a
79
- * restart.
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 getInfraKitConfig = async (): Promise<InfraKitConfig> => {
109
+ export const getInfraKitConfigPaths = async (): Promise<InfraKitConfigPaths> => {
82
110
  const projectRoot = await getProjectRoot()
83
- const mainPath = path.join(projectRoot, INFRA_KIT_CONFIG_FILE)
84
- const localPath = path.join(projectRoot, INFRA_KIT_LOCAL_CONFIG_FILE)
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(mainPath)
145
+ mainStat = await fs.stat(paths.main)
90
146
  } catch {
91
147
  cached = null
92
- throw new Error(`infra-kit.yml not found at ${mainPath}`)
148
+ throw new Error(`infra-kit.yml not found at ${paths.main}`)
93
149
  }
94
150
 
95
- const localStat = await statIfExists(localPath)
96
- const mainMtimeMs = Number(mainStat.mtimeMs)
97
- const localMtimeMs = localStat ? Number(localStat.mtimeMs) : null
151
+ const [userGlobalStat, userProjectStat] = await Promise.all([
152
+ statIfExists(paths.userGlobal),
153
+ statIfExists(paths.userProject),
154
+ ])
98
155
 
99
- if (cached && cached.mainMtimeMs === mainMtimeMs && cached.localMtimeMs === localMtimeMs) {
100
- return cached.value
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
- const mainRaw = await fs.readFile(mainPath, 'utf-8')
104
- const mainParsed = yaml.parse(mainRaw)
162
+ if (cached && shallowEqual(cached.mtimes, mtimes)) {
163
+ return cached.value
164
+ }
105
165
 
106
- let merged: unknown = mainParsed
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
- if (localStat) {
109
- const localRaw = await fs.readFile(localPath, 'utf-8')
110
- const localParsedRaw = yaml.parse(localRaw) ?? {}
176
+ let merged: Record<string, unknown> = {}
111
177
 
112
- const localResult = infraKitLocalConfigSchema.safeParse(localParsedRaw)
178
+ for (const layer of layers) {
179
+ const data = await loadLayer(layer)
113
180
 
114
- if (!localResult.success) {
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 = { ...(mainParsed as object), ...localResult.data }
183
+ merged = { ...merged, ...data }
119
184
  }
120
185
 
121
- const result = infraKitConfigSchema.safeParse(merged)
186
+ const finalResult = infraKitConfigSchema.safeParse(merged)
122
187
 
123
- if (!result.success) {
124
- throw new Error(`Invalid infra-kit.yml at ${mainPath}: ${z.prettifyError(result.error)}`)
188
+ if (!finalResult.success) {
189
+ throw new Error(`Invalid merged infra-kit config: ${z.prettifyError(finalResult.error)}`)
125
190
  }
126
191
 
127
- cached = { mainMtimeMs, localMtimeMs, value: result.data }
192
+ cached = { mtimes, value: finalResult.data }
128
193
 
129
- return result.data
194
+ return finalResult.data
130
195
  }
131
196
 
132
- /** For tests — drops the in-memory cache. */
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
+ }