infra-kit 0.1.99 → 0.1.101
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/.omc/state/agent-replay-d367c3be-9c2a-48e7-bcea-b45861af568c.jsonl +2 -0
- package/.omc/state/agent-replay-f2846d8f-974c-486c-b16f-4bdaa28ca45f.jsonl +2 -0
- package/.omc/state/last-tool-error.json +7 -0
- package/.omc/state/subagent-tracking.json +7 -0
- package/.turbo/turbo-eslint-check.log +1 -4
- package/.turbo/turbo-eslint-fix.log +1 -0
- package/.turbo/turbo-prettier-check.log +1 -4
- package/.turbo/turbo-prettier-fix.log +1 -10
- package/.turbo/turbo-test.log +5 -11
- package/.turbo/turbo-ts-check.log +1 -4
- package/dist/cli.js +45 -36
- package/dist/cli.js.map +4 -4
- package/dist/mcp.js +42 -34
- package/dist/mcp.js.map +4 -4
- package/package.json +12 -12
- package/src/commands/doctor/doctor.ts +62 -12
- package/src/commands/env-clear/env-clear.ts +5 -10
- package/src/commands/env-list/env-list.ts +5 -10
- package/src/commands/env-load/env-load.ts +5 -10
- package/src/commands/env-status/env-status.ts +5 -10
- package/src/commands/gh-merge-dev/gh-merge-dev.ts +17 -18
- package/src/commands/gh-release-deliver/gh-release-deliver.ts +290 -89
- package/src/commands/gh-release-deploy-all/gh-release-deploy-all.ts +15 -14
- package/src/commands/gh-release-deploy-selected/gh-release-deploy-selected.ts +30 -23
- package/src/commands/gh-release-list/gh-release-list.ts +5 -10
- package/src/commands/release-create/release-create.ts +22 -18
- package/src/commands/release-desc-edit/index.ts +1 -0
- package/src/commands/release-desc-edit/release-desc-edit.ts +207 -0
- package/src/commands/version/version.ts +5 -10
- package/src/commands/worktrees-add/worktrees-add.ts +18 -14
- package/src/commands/worktrees-list/worktrees-list.ts +6 -11
- package/src/commands/worktrees-open/worktrees-open.ts +10 -6
- package/src/commands/worktrees-remove/worktrees-remove.ts +18 -14
- package/src/commands/worktrees-sync/worktrees-sync.ts +17 -12
- package/src/entry/cli.ts +16 -0
- package/src/integrations/gh/gh-release-prs/gh-release-prs.ts +21 -0
- package/src/integrations/gh/gh-release-prs/index.ts +1 -1
- package/src/integrations/gh/index.ts +1 -1
- package/src/integrations/jira/api.ts +8 -17
- package/src/integrations/jira/index.ts +2 -0
- package/src/lib/errors/__tests__/operation-error.test.ts +62 -0
- package/src/lib/errors/format-zx-error.ts +54 -0
- package/src/lib/errors/operation-error.ts +80 -0
- package/src/mcp/tools/index.ts +2 -0
- package/src/types.ts +56 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -7,23 +7,103 @@ import { $ } from 'zx'
|
|
|
7
7
|
import { getReleasePRsWithInfo } from 'src/integrations/gh'
|
|
8
8
|
import { deliverJiraRelease, loadJiraConfigOptional } from 'src/integrations/jira'
|
|
9
9
|
import { commandEcho } from 'src/lib/command-echo'
|
|
10
|
+
import { formatZxError } from 'src/lib/errors/format-zx-error'
|
|
11
|
+
import { OperationError } from 'src/lib/errors/operation-error'
|
|
10
12
|
import { logger } from 'src/lib/logger'
|
|
11
13
|
import { detectReleaseType, formatBranchChoices, getJiraDescriptions } from 'src/lib/release-utils'
|
|
12
14
|
import type { ReleaseType } from 'src/lib/release-utils'
|
|
13
|
-
import
|
|
15
|
+
import { defineMcpTool, textContent } from 'src/types'
|
|
16
|
+
import type { RequiredConfirmedOptionArg } from 'src/types'
|
|
14
17
|
|
|
15
18
|
interface GhReleaseDeliverArgs extends RequiredConfirmedOptionArg {
|
|
16
19
|
version: string
|
|
17
20
|
}
|
|
18
21
|
|
|
22
|
+
type PRState = 'OPEN' | 'MERGED' | 'CLOSED'
|
|
23
|
+
|
|
24
|
+
interface PRStatus {
|
|
25
|
+
number: number
|
|
26
|
+
state: PRState
|
|
27
|
+
title: string
|
|
28
|
+
}
|
|
29
|
+
|
|
19
30
|
/**
|
|
20
|
-
*
|
|
31
|
+
* Wrap a delivery step so its failure logs structured zx fields and surfaces
|
|
32
|
+
* an `OperationError` whose message names the actual step that failed —
|
|
33
|
+
* instead of the previous blanket "merging release branch into dev" message.
|
|
21
34
|
*/
|
|
22
|
-
|
|
23
|
-
|
|
35
|
+
const runStep = async <T>(operation: string, remediation: string, fn: () => Promise<T>): Promise<T> => {
|
|
36
|
+
try {
|
|
37
|
+
return await fn()
|
|
38
|
+
} catch (error) {
|
|
39
|
+
logger.error({ err: formatZxError(error) }, `❌ Failed to ${operation}`)
|
|
40
|
+
throw new OperationError(error, { operation, remediation })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
24
43
|
|
|
25
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Fetch the (most-recent) PR for the given head branch, across all states, so
|
|
46
|
+
* we can resume a partially-completed delivery: a PR merged on a prior attempt
|
|
47
|
+
* still appears here as `state: 'MERGED'`, letting the caller skip the merge.
|
|
48
|
+
*/
|
|
49
|
+
const fetchPRByHead = async (head: string): Promise<PRStatus | null> => {
|
|
50
|
+
const result = await $`gh pr list --head ${head} --state all --json number,state,title --limit 1`
|
|
51
|
+
const prs = JSON.parse(result.stdout) as PRStatus[]
|
|
52
|
+
|
|
53
|
+
return prs[0] ?? null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Find a MERGED RC PR (dev → main) whose title matches the given version, used
|
|
58
|
+
* to detect that a prior delivery run already merged the RC and we should skip
|
|
59
|
+
* the merge step on resume. Title-matched on purpose: an older MERGED RC PR
|
|
60
|
+
* from a different release must not short-circuit this version's flow.
|
|
61
|
+
*/
|
|
62
|
+
const fetchMergedRcPRForVersion = async (version: string): Promise<PRStatus | null> => {
|
|
63
|
+
const expectedTitle = `Release v${version} (RC)`
|
|
64
|
+
const result = await $`gh pr list --head dev --base main --state merged --json number,state,title --limit 20`
|
|
65
|
+
const prs = JSON.parse(result.stdout) as PRStatus[]
|
|
66
|
+
const match = prs.find((pr) => {
|
|
67
|
+
return pr.title === expectedTitle
|
|
68
|
+
})
|
|
26
69
|
|
|
70
|
+
return match ?? null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Find an open dev → main PR, if any. GitHub allows at most one open PR per
|
|
75
|
+
* head/base pair, so an existing open PR here is the RC PR for this release —
|
|
76
|
+
* even if its title was set by a previous (failed) delivery run. Adopting it
|
|
77
|
+
* is what makes the flow recoverable after a mid-run failure.
|
|
78
|
+
*/
|
|
79
|
+
const fetchOpenDevToMainPR = async (): Promise<PRStatus | null> => {
|
|
80
|
+
const result = await $`gh pr list --head dev --base main --state open --json number,state,title --limit 5`
|
|
81
|
+
const prs = JSON.parse(result.stdout) as PRStatus[]
|
|
82
|
+
|
|
83
|
+
return prs[0] ?? null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface ResolvedTarget {
|
|
87
|
+
selectedReleaseBranch: string
|
|
88
|
+
releasePrTitle: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const resolveTargetFromVersion = async (version: string): Promise<ResolvedTarget> => {
|
|
92
|
+
const selectedReleaseBranch = `release/v${version}`
|
|
93
|
+
const pr = await fetchPRByHead(selectedReleaseBranch)
|
|
94
|
+
|
|
95
|
+
if (!pr) {
|
|
96
|
+
logger.error(`❌ No PR found for branch ${selectedReleaseBranch}.`)
|
|
97
|
+
throw new OperationError(undefined, {
|
|
98
|
+
operation: `deliver release ${selectedReleaseBranch}`,
|
|
99
|
+
remediation: `confirm a PR exists ('gh pr list --head ${selectedReleaseBranch} --state all')`,
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { selectedReleaseBranch, releasePrTitle: pr.title }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const resolveTargetInteractively = async (): Promise<ResolvedTarget> => {
|
|
27
107
|
const releasePRsInfo = await getReleasePRsWithInfo()
|
|
28
108
|
|
|
29
109
|
const branches = releasePRsInfo.map((pr) => {
|
|
@@ -36,36 +116,199 @@ export const ghReleaseDeliver = async (args: GhReleaseDeliverArgs): Promise<Tool
|
|
|
36
116
|
}),
|
|
37
117
|
)
|
|
38
118
|
|
|
39
|
-
|
|
119
|
+
commandEcho.setInteractive()
|
|
40
120
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
121
|
+
const descriptions = await getJiraDescriptions()
|
|
122
|
+
|
|
123
|
+
const selectedReleaseBranch = await select({
|
|
124
|
+
message: '🌿 Select release branch',
|
|
125
|
+
choices: formatBranchChoices({ branches, descriptions, types: releaseTypes }),
|
|
126
|
+
})
|
|
45
127
|
|
|
46
|
-
|
|
128
|
+
const prInfo = releasePRsInfo.find((pr) => {
|
|
129
|
+
return pr.branch === selectedReleaseBranch
|
|
130
|
+
})
|
|
47
131
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
132
|
+
if (!prInfo) {
|
|
133
|
+
logger.error(`❌ Release branch ${selectedReleaseBranch} not found in open PRs.`)
|
|
134
|
+
throw new OperationError(undefined, {
|
|
135
|
+
operation: `deliver release ${selectedReleaseBranch}`,
|
|
136
|
+
remediation: `confirm an open PR exists for ${selectedReleaseBranch} ('gh pr list')`,
|
|
51
137
|
})
|
|
52
138
|
}
|
|
53
139
|
|
|
54
|
-
|
|
140
|
+
return { selectedReleaseBranch, releasePrTitle: prInfo.title }
|
|
141
|
+
}
|
|
55
142
|
|
|
56
|
-
|
|
143
|
+
interface MergeReleasePRArgs {
|
|
144
|
+
selectedReleaseBranch: string
|
|
145
|
+
releaseType: ReleaseType
|
|
146
|
+
}
|
|
57
147
|
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
148
|
+
const mergeReleasePR = async (args: MergeReleasePRArgs): Promise<void> => {
|
|
149
|
+
const { selectedReleaseBranch, releaseType } = args
|
|
150
|
+
const mergeTarget = releaseType === 'hotfix' ? 'main' : 'dev'
|
|
151
|
+
const releasePr = await fetchPRByHead(selectedReleaseBranch)
|
|
62
152
|
|
|
63
|
-
if (!
|
|
64
|
-
|
|
65
|
-
|
|
153
|
+
if (!releasePr) {
|
|
154
|
+
throw new OperationError(undefined, {
|
|
155
|
+
operation: `look up release PR for ${selectedReleaseBranch}`,
|
|
156
|
+
remediation: `verify the PR exists in GitHub`,
|
|
157
|
+
})
|
|
66
158
|
}
|
|
67
159
|
|
|
68
|
-
|
|
160
|
+
if (releasePr.state === 'MERGED') {
|
|
161
|
+
logger.info(`✓ Release PR ${selectedReleaseBranch} already merged — skipping`)
|
|
162
|
+
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (releasePr.state === 'CLOSED') {
|
|
167
|
+
throw new OperationError(undefined, {
|
|
168
|
+
operation: `merge release PR ${selectedReleaseBranch} into ${mergeTarget}`,
|
|
169
|
+
remediation: `the PR is closed without merge; reopen it or create a new release`,
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
await runStep(
|
|
174
|
+
`merge release PR ${selectedReleaseBranch} into ${mergeTarget}`,
|
|
175
|
+
`check 'gh pr view ${selectedReleaseBranch}' for mergeability and required reviews`,
|
|
176
|
+
async () => {
|
|
177
|
+
await $`gh pr merge ${selectedReleaseBranch} --squash --admin --delete-branch`
|
|
178
|
+
},
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const resolveRcPRNumber = async (selectedVersion: string): Promise<number> => {
|
|
183
|
+
const expectedTitle = `Release v${selectedVersion} (RC)`
|
|
184
|
+
const existingOpen = await fetchOpenDevToMainPR()
|
|
185
|
+
|
|
186
|
+
// Adopt any existing open dev→main PR. GitHub permits only one open PR per
|
|
187
|
+
// head/base pair, so a stale open RC PR (left behind by a prior failed run
|
|
188
|
+
// — the single most common cause of "Error merging release branch into
|
|
189
|
+
// dev") blocks `gh pr create`. Retitle it instead of fighting it.
|
|
190
|
+
if (existingOpen) {
|
|
191
|
+
const rcNumber = existingOpen.number
|
|
192
|
+
|
|
193
|
+
if (existingOpen.title !== expectedTitle) {
|
|
194
|
+
logger.info(
|
|
195
|
+
`Adopting open dev → main PR #${rcNumber} ("${existingOpen.title}") and retitling for v${selectedVersion}`,
|
|
196
|
+
)
|
|
197
|
+
await runStep(
|
|
198
|
+
`retitle dev → main PR #${rcNumber} to "${expectedTitle}"`,
|
|
199
|
+
`update manually: gh pr edit ${rcNumber} --title "${expectedTitle}"`,
|
|
200
|
+
async () => {
|
|
201
|
+
await $`gh pr edit ${rcNumber} --title ${expectedTitle}`
|
|
202
|
+
},
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return rcNumber
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
await runStep(
|
|
210
|
+
`create RC PR (dev → main) for v${selectedVersion}`,
|
|
211
|
+
`run 'gh pr create --base main --head dev' manually to surface the underlying error (e.g. no commits between dev and main)`,
|
|
212
|
+
async () => {
|
|
213
|
+
await $`gh pr create --base main --head dev --title ${expectedTitle} --body ""`
|
|
214
|
+
},
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
const created = await fetchOpenDevToMainPR()
|
|
218
|
+
|
|
219
|
+
if (!created) {
|
|
220
|
+
throw new OperationError(undefined, {
|
|
221
|
+
operation: `look up RC PR for v${selectedVersion}`,
|
|
222
|
+
remediation: `verify the RC PR was created ('gh pr list --head dev --base main')`,
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return created.number
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const ensureRcPRMerged = async (selectedVersion: string): Promise<void> => {
|
|
230
|
+
const alreadyMerged = await fetchMergedRcPRForVersion(selectedVersion)
|
|
231
|
+
|
|
232
|
+
if (alreadyMerged) {
|
|
233
|
+
logger.info(`✓ RC PR for v${selectedVersion} already merged into main — skipping`)
|
|
234
|
+
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const rcNumber = await resolveRcPRNumber(selectedVersion)
|
|
239
|
+
|
|
240
|
+
await runStep(
|
|
241
|
+
`merge RC PR #${rcNumber} (dev → main) for v${selectedVersion}`,
|
|
242
|
+
`check 'gh pr view ${rcNumber}' for mergeability and required reviews`,
|
|
243
|
+
async () => {
|
|
244
|
+
await $`gh pr merge ${rcNumber} --squash --admin`
|
|
245
|
+
},
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const dispatchDeployWorkflow = async (): Promise<void> => {
|
|
250
|
+
$.quiet = false
|
|
251
|
+
|
|
252
|
+
await runStep(
|
|
253
|
+
`dispatch deploy-all workflow on main`,
|
|
254
|
+
`check 'gh workflow list' and that you have permission to dispatch deploy-all.yml`,
|
|
255
|
+
async () => {
|
|
256
|
+
await $`gh workflow run deploy-all.yml --ref main -f environment=prod`
|
|
257
|
+
},
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
$.quiet = true
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const syncMainIntoDev = async (): Promise<void> => {
|
|
264
|
+
await runStep(
|
|
265
|
+
`sync main back into dev`,
|
|
266
|
+
`run manually: git switch main && git pull && git switch dev && git pull && git merge main --no-edit && git push`,
|
|
267
|
+
async () => {
|
|
268
|
+
await $`git switch main && git pull && git switch dev && git pull && git merge main --no-edit && git push`
|
|
269
|
+
},
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const deliverJiraReleaseSafely = async (selectedReleaseBranch: string): Promise<void> => {
|
|
274
|
+
const jiraConfig = await loadJiraConfigOptional()
|
|
275
|
+
|
|
276
|
+
if (!jiraConfig) {
|
|
277
|
+
logger.info('🔔 Jira is not configured, skipping Jira release delivery')
|
|
278
|
+
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
const versionName = selectedReleaseBranch.replace('release/', '')
|
|
284
|
+
|
|
285
|
+
await deliverJiraRelease({ versionName }, jiraConfig)
|
|
286
|
+
} catch (error) {
|
|
287
|
+
logger.error({ err: formatZxError(error) }, 'Failed to deliver Jira release (non-blocking)')
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Deliver a release branch to production. Each network/git step is run inside
|
|
293
|
+
* `runStep` so the surfaced error names the failing operation and includes the
|
|
294
|
+
* subprocess stderr. PR-merge steps are idempotent: if the release PR or RC PR
|
|
295
|
+
* is already MERGED, the step is skipped, so re-running after a mid-flight
|
|
296
|
+
* failure picks up where it stopped.
|
|
297
|
+
*/
|
|
298
|
+
export const ghReleaseDeliver = async (args: GhReleaseDeliverArgs) => {
|
|
299
|
+
const { version, confirmedCommand } = args
|
|
300
|
+
|
|
301
|
+
commandEcho.start('release-deliver')
|
|
302
|
+
|
|
303
|
+
const { selectedReleaseBranch, releasePrTitle } = version
|
|
304
|
+
? await resolveTargetFromVersion(version)
|
|
305
|
+
: await resolveTargetInteractively()
|
|
306
|
+
|
|
307
|
+
const selectedVersion = selectedReleaseBranch.replace('release/v', '')
|
|
308
|
+
|
|
309
|
+
commandEcho.addOption('--version', selectedVersion)
|
|
310
|
+
|
|
311
|
+
const releaseType: ReleaseType = detectReleaseType(releasePrTitle)
|
|
69
312
|
|
|
70
313
|
const answer = confirmedCommand
|
|
71
314
|
? true
|
|
@@ -85,85 +328,43 @@ export const ghReleaseDeliver = async (args: GhReleaseDeliverArgs): Promise<Tool
|
|
|
85
328
|
// Track --yes flag if confirmation was interactive (user confirmed)
|
|
86
329
|
commandEcho.addOption('--yes', true)
|
|
87
330
|
|
|
88
|
-
|
|
89
|
-
$.quiet = true
|
|
90
|
-
|
|
91
|
-
if (releaseType === 'hotfix') {
|
|
92
|
-
// Hotfix: merge directly into main, deploy, sync back to dev
|
|
93
|
-
await $`gh pr merge ${selectedReleaseBranch} --squash --admin --delete-branch`
|
|
94
|
-
|
|
95
|
-
$.quiet = false
|
|
96
|
-
|
|
97
|
-
await $`gh workflow run deploy-all.yml --ref main -f environment=prod`
|
|
98
|
-
|
|
99
|
-
$.quiet = true
|
|
331
|
+
$.quiet = true
|
|
100
332
|
|
|
101
|
-
|
|
102
|
-
await $`git switch main && git pull && git switch dev && git pull && git merge main --no-edit && git push`
|
|
103
|
-
} else {
|
|
104
|
-
// Regular: merge into dev, create RC PR to main, merge to main, deploy, sync
|
|
105
|
-
await $`gh pr merge ${selectedReleaseBranch} --squash --admin --delete-branch`
|
|
106
|
-
await $`gh pr create --base main --head dev --title "Release v${selectedVersion} (RC)" --body ""`
|
|
107
|
-
await $`gh pr merge dev --squash --admin`
|
|
108
|
-
|
|
109
|
-
$.quiet = false
|
|
110
|
-
|
|
111
|
-
await $`gh workflow run deploy-all.yml --ref main -f environment=prod`
|
|
112
|
-
|
|
113
|
-
$.quiet = true
|
|
114
|
-
|
|
115
|
-
// Sync main into dev
|
|
116
|
-
await $`git switch main && git pull && git switch dev && git pull && git merge main --no-edit && git push`
|
|
117
|
-
}
|
|
333
|
+
await mergeReleasePR({ selectedReleaseBranch, releaseType })
|
|
118
334
|
|
|
119
|
-
|
|
335
|
+
if (releaseType !== 'hotfix') {
|
|
336
|
+
await ensureRcPRMerged(selectedVersion)
|
|
337
|
+
}
|
|
120
338
|
|
|
121
|
-
|
|
122
|
-
|
|
339
|
+
await dispatchDeployWorkflow()
|
|
340
|
+
await syncMainIntoDev()
|
|
123
341
|
|
|
124
|
-
|
|
125
|
-
try {
|
|
126
|
-
const versionName = selectedReleaseBranch.replace('release/', '')
|
|
342
|
+
$.quiet = false
|
|
127
343
|
|
|
128
|
-
|
|
129
|
-
} catch (error) {
|
|
130
|
-
logger.error({ error }, 'Failed to deliver Jira release (non-blocking)')
|
|
131
|
-
}
|
|
132
|
-
} else {
|
|
133
|
-
logger.info('🔔 Jira is not configured, skipping Jira release delivery')
|
|
134
|
-
}
|
|
344
|
+
await deliverJiraReleaseSafely(selectedReleaseBranch)
|
|
135
345
|
|
|
136
|
-
|
|
346
|
+
logger.info(`Successfully delivered ${selectedReleaseBranch} to production!`)
|
|
137
347
|
|
|
138
|
-
|
|
348
|
+
commandEcho.print()
|
|
139
349
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
350
|
+
const structuredContent = {
|
|
351
|
+
releaseBranch: selectedReleaseBranch,
|
|
352
|
+
version: selectedVersion,
|
|
353
|
+
type: releaseType,
|
|
354
|
+
success: true,
|
|
355
|
+
}
|
|
146
356
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
type: 'text',
|
|
151
|
-
text: JSON.stringify(structuredContent, null, 2),
|
|
152
|
-
},
|
|
153
|
-
],
|
|
154
|
-
structuredContent,
|
|
155
|
-
}
|
|
156
|
-
} catch (error: unknown) {
|
|
157
|
-
logger.error({ error }, '❌ Error merging release branch into dev')
|
|
158
|
-
process.exit(1)
|
|
357
|
+
return {
|
|
358
|
+
content: textContent(JSON.stringify(structuredContent, null, 2)),
|
|
359
|
+
structuredContent,
|
|
159
360
|
}
|
|
160
361
|
}
|
|
161
362
|
|
|
162
363
|
// MCP Tool Registration
|
|
163
|
-
export const ghReleaseDeliverMcpTool = {
|
|
364
|
+
export const ghReleaseDeliverMcpTool = defineMcpTool({
|
|
164
365
|
name: 'gh-release-deliver',
|
|
165
366
|
description:
|
|
166
|
-
'Deliver a release to production. For hotfixes: squash-merges the release branch to main and dispatches the deploy-all workflow. For regular releases: squash-merges to dev, opens an RC PR, merges dev into main, dispatches the deploy-all workflow, then syncs main back to dev. Also releases the matching Jira fix version if Jira is configured. Dispatches the deploy workflow fire-and-forget — the tool returns once the workflow is accepted by GitHub, not when the deployment finishes. Irreversible production operation: the confirmation prompt is auto-skipped for MCP calls, so the caller is responsible for gating. "version" is required when invoked via MCP (the picker is unreachable without a TTY).',
|
|
367
|
+
'Deliver a release to production. For hotfixes: squash-merges the release branch to main and dispatches the deploy-all workflow. For regular releases: squash-merges to dev, opens an RC PR, merges dev into main, dispatches the deploy-all workflow, then syncs main back to dev. Also releases the matching Jira fix version if Jira is configured. Dispatches the deploy workflow fire-and-forget — the tool returns once the workflow is accepted by GitHub, not when the deployment finishes. PR-merge steps are idempotent: re-running after a partial failure skips PRs that are already merged. Irreversible production operation: the confirmation prompt is auto-skipped for MCP calls, so the caller is responsible for gating. "version" is required when invoked via MCP (the picker is unreachable without a TTY).',
|
|
167
368
|
inputSchema: {
|
|
168
369
|
version: z.string().describe('Release version to deliver to production (e.g., "1.2.5"). Required for MCP calls.'),
|
|
169
370
|
},
|
|
@@ -174,4 +375,4 @@ export const ghReleaseDeliverMcpTool = {
|
|
|
174
375
|
success: z.boolean().describe('Whether the delivery was successful'),
|
|
175
376
|
},
|
|
176
377
|
handler: ghReleaseDeliver,
|
|
177
|
-
}
|
|
378
|
+
})
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import select from '@inquirer/select'
|
|
2
|
-
import process from 'node:process'
|
|
3
2
|
import { z } from 'zod/v4'
|
|
4
3
|
import { $ } from 'zx'
|
|
5
4
|
|
|
6
5
|
import { getReleasePRsWithInfo } from 'src/integrations/gh'
|
|
7
6
|
import { commandEcho } from 'src/lib/command-echo'
|
|
7
|
+
import { OperationError } from 'src/lib/errors/operation-error'
|
|
8
8
|
import { getInfraKitConfig } from 'src/lib/infra-kit-config'
|
|
9
9
|
import { logger } from 'src/lib/logger'
|
|
10
10
|
import { detectReleaseType, formatBranchChoices, getJiraDescriptions } from 'src/lib/release-utils'
|
|
11
11
|
import type { ReleaseType } from 'src/lib/release-utils'
|
|
12
|
-
import
|
|
12
|
+
import { defineMcpTool, textContent } from 'src/types'
|
|
13
13
|
|
|
14
14
|
interface GhReleaseDeployAllArgs {
|
|
15
15
|
version: string
|
|
@@ -20,7 +20,7 @@ interface GhReleaseDeployAllArgs {
|
|
|
20
20
|
/**
|
|
21
21
|
* Deploy a release branch to an environment
|
|
22
22
|
*/
|
|
23
|
-
export const ghReleaseDeployAll = async (args: GhReleaseDeployAllArgs)
|
|
23
|
+
export const ghReleaseDeployAll = async (args: GhReleaseDeployAllArgs) => {
|
|
24
24
|
const { version, env, skipTerraform } = args
|
|
25
25
|
|
|
26
26
|
commandEcho.start('release-deploy-all')
|
|
@@ -79,8 +79,11 @@ export const ghReleaseDeployAll = async (args: GhReleaseDeployAllArgs): Promise<
|
|
|
79
79
|
commandEcho.addOption('--env', selectedEnv)
|
|
80
80
|
|
|
81
81
|
if (!environments.includes(selectedEnv)) {
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
throw new OperationError(undefined, {
|
|
83
|
+
operation: 'launch deploy-all workflow',
|
|
84
|
+
remediation: `pass one of: ${environments.join(', ')}`,
|
|
85
|
+
stderrExcerpt: `invalid environment: ${selectedEnv}`,
|
|
86
|
+
})
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
const shouldSkipTerraform = skipTerraform ?? false
|
|
@@ -113,22 +116,20 @@ export const ghReleaseDeployAll = async (args: GhReleaseDeployAllArgs): Promise<
|
|
|
113
116
|
}
|
|
114
117
|
|
|
115
118
|
return {
|
|
116
|
-
content:
|
|
117
|
-
{
|
|
118
|
-
type: 'text',
|
|
119
|
-
text: JSON.stringify(structuredContent, null, 2),
|
|
120
|
-
},
|
|
121
|
-
],
|
|
119
|
+
content: textContent(JSON.stringify(structuredContent, null, 2)),
|
|
122
120
|
structuredContent,
|
|
123
121
|
}
|
|
124
122
|
} catch (error: unknown) {
|
|
125
123
|
logger.error({ error }, '❌ Error launching workflow')
|
|
126
|
-
|
|
124
|
+
throw new OperationError(error, {
|
|
125
|
+
operation: 'launch deploy-all workflow',
|
|
126
|
+
remediation: "check 'gh workflow list' and that deploy-all.yml exists on the target ref",
|
|
127
|
+
})
|
|
127
128
|
}
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
// MCP Tool Registration
|
|
131
|
-
export const ghReleaseDeployAllMcpTool = {
|
|
132
|
+
export const ghReleaseDeployAllMcpTool = defineMcpTool({
|
|
132
133
|
name: 'gh-release-deploy-all',
|
|
133
134
|
description:
|
|
134
135
|
'Dispatch the deploy-all.yml GitHub Actions workflow to deploy every service from a release branch to the given environment. Fire-and-forget — returns once GitHub accepts the workflow_dispatch, NOT when the deployment finishes; watch the workflow run for completion status. Use gh-release-deploy-selected for a subset of services. Pass version="dev" to deploy from the dev branch instead of a release branch. Both "version" and "env" are required when invoked via MCP (interactive pickers are unavailable without a TTY).',
|
|
@@ -153,4 +154,4 @@ export const ghReleaseDeployAllMcpTool = {
|
|
|
153
154
|
success: z.boolean().describe('Whether the deployment was successful'),
|
|
154
155
|
},
|
|
155
156
|
handler: ghReleaseDeployAll,
|
|
156
|
-
}
|
|
157
|
+
})
|
|
@@ -2,19 +2,19 @@ import checkbox from '@inquirer/checkbox'
|
|
|
2
2
|
import select from '@inquirer/select'
|
|
3
3
|
import fs from 'node:fs/promises'
|
|
4
4
|
import { resolve } from 'node:path'
|
|
5
|
-
import process from 'node:process'
|
|
6
5
|
import yaml from 'yaml'
|
|
7
6
|
import { z } from 'zod/v4'
|
|
8
7
|
import { $ } from 'zx'
|
|
9
8
|
|
|
10
9
|
import { getReleasePRsWithInfo } from 'src/integrations/gh'
|
|
11
10
|
import { commandEcho } from 'src/lib/command-echo'
|
|
11
|
+
import { OperationError } from 'src/lib/errors/operation-error'
|
|
12
12
|
import { getProjectRoot } from 'src/lib/git-utils'
|
|
13
13
|
import { getInfraKitConfig } from 'src/lib/infra-kit-config'
|
|
14
14
|
import { logger } from 'src/lib/logger'
|
|
15
15
|
import { detectReleaseType, formatBranchChoices, getJiraDescriptions } from 'src/lib/release-utils'
|
|
16
16
|
import type { ReleaseType } from 'src/lib/release-utils'
|
|
17
|
-
import
|
|
17
|
+
import { defineMcpTool, textContent } from 'src/types'
|
|
18
18
|
|
|
19
19
|
interface GhReleaseDeploySelectedArgs {
|
|
20
20
|
version: string
|
|
@@ -27,7 +27,7 @@ interface GhReleaseDeploySelectedArgs {
|
|
|
27
27
|
* Deploy selected services from a release branch to an environment
|
|
28
28
|
*/
|
|
29
29
|
// eslint-disable-next-line sonarjs/cognitive-complexity
|
|
30
|
-
export const ghReleaseDeploySelected = async (args: GhReleaseDeploySelectedArgs)
|
|
30
|
+
export const ghReleaseDeploySelected = async (args: GhReleaseDeploySelectedArgs) => {
|
|
31
31
|
const { version, env, services, skipTerraform } = args
|
|
32
32
|
|
|
33
33
|
commandEcho.start('release-deploy-selected')
|
|
@@ -86,17 +86,22 @@ export const ghReleaseDeploySelected = async (args: GhReleaseDeploySelectedArgs)
|
|
|
86
86
|
commandEcho.addOption('--env', selectedEnv)
|
|
87
87
|
|
|
88
88
|
if (!environments.includes(selectedEnv)) {
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
throw new OperationError(undefined, {
|
|
90
|
+
operation: 'launch deploy-selected workflow',
|
|
91
|
+
remediation: `pass one of: ${environments.join(', ')}`,
|
|
92
|
+
stderrExcerpt: `invalid environment: ${selectedEnv}`,
|
|
93
|
+
})
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
// Parse available services from workflow file
|
|
94
97
|
const availableServices = await parseServicesFromWorkflow()
|
|
95
98
|
|
|
96
99
|
if (availableServices.length === 0) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
throw new OperationError(undefined, {
|
|
101
|
+
operation: 'launch deploy-selected workflow',
|
|
102
|
+
remediation: 'check .github/workflows/deploy-selected-services.yml for boolean service inputs',
|
|
103
|
+
stderrExcerpt: 'no services found in workflow file',
|
|
104
|
+
})
|
|
100
105
|
}
|
|
101
106
|
|
|
102
107
|
let selectedServices: string[] = []
|
|
@@ -120,8 +125,11 @@ export const ghReleaseDeploySelected = async (args: GhReleaseDeploySelectedArgs)
|
|
|
120
125
|
commandEcho.addOption('--services', selectedServices)
|
|
121
126
|
|
|
122
127
|
if (selectedServices.length === 0) {
|
|
123
|
-
|
|
124
|
-
|
|
128
|
+
throw new OperationError(undefined, {
|
|
129
|
+
operation: 'launch deploy-selected workflow',
|
|
130
|
+
remediation: `pass at least one service from: ${availableServices.join(', ')}`,
|
|
131
|
+
stderrExcerpt: 'no services selected',
|
|
132
|
+
})
|
|
125
133
|
}
|
|
126
134
|
|
|
127
135
|
// Validate all selected services
|
|
@@ -130,10 +138,11 @@ export const ghReleaseDeploySelected = async (args: GhReleaseDeploySelectedArgs)
|
|
|
130
138
|
})
|
|
131
139
|
|
|
132
140
|
if (invalidServices.length > 0) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
141
|
+
throw new OperationError(undefined, {
|
|
142
|
+
operation: 'launch deploy-selected workflow',
|
|
143
|
+
remediation: `pass services from: ${availableServices.join(', ')}`,
|
|
144
|
+
stderrExcerpt: `invalid services: ${invalidServices.join(', ')}`,
|
|
145
|
+
})
|
|
137
146
|
}
|
|
138
147
|
|
|
139
148
|
const shouldSkipTerraform = skipTerraform ?? false
|
|
@@ -171,17 +180,15 @@ export const ghReleaseDeploySelected = async (args: GhReleaseDeploySelectedArgs)
|
|
|
171
180
|
}
|
|
172
181
|
|
|
173
182
|
return {
|
|
174
|
-
content:
|
|
175
|
-
{
|
|
176
|
-
type: 'text',
|
|
177
|
-
text: JSON.stringify(structuredContent, null, 2),
|
|
178
|
-
},
|
|
179
|
-
],
|
|
183
|
+
content: textContent(JSON.stringify(structuredContent, null, 2)),
|
|
180
184
|
structuredContent,
|
|
181
185
|
}
|
|
182
186
|
} catch (error: unknown) {
|
|
183
187
|
logger.error({ error }, '❌ Error launching workflow')
|
|
184
|
-
|
|
188
|
+
throw new OperationError(error, {
|
|
189
|
+
operation: 'launch deploy-selected workflow',
|
|
190
|
+
remediation: "check 'gh workflow list' and that deploy-selected-services.yml exists on the target ref",
|
|
191
|
+
})
|
|
185
192
|
}
|
|
186
193
|
}
|
|
187
194
|
|
|
@@ -211,7 +218,7 @@ const parseServicesFromWorkflow = async (): Promise<string[]> => {
|
|
|
211
218
|
}
|
|
212
219
|
|
|
213
220
|
// MCP Tool Registration
|
|
214
|
-
export const ghReleaseDeploySelectedMcpTool = {
|
|
221
|
+
export const ghReleaseDeploySelectedMcpTool = defineMcpTool({
|
|
215
222
|
name: 'gh-release-deploy-selected',
|
|
216
223
|
description:
|
|
217
224
|
'Dispatch the deploy-selected-services.yml GitHub Actions workflow to deploy a chosen subset of services from a release branch to the given environment. Fire-and-forget — returns once GitHub accepts the workflow_dispatch, NOT when the deployment finishes; watch the workflow run for completion status. Service names are validated against the boolean inputs declared in the workflow. Use gh-release-deploy-all for every service. "version", "env", and "services" are all required when invoked via MCP (interactive pickers are unavailable without a TTY).',
|
|
@@ -242,4 +249,4 @@ export const ghReleaseDeploySelectedMcpTool = {
|
|
|
242
249
|
success: z.boolean().describe('Whether the deployment was successful'),
|
|
243
250
|
},
|
|
244
251
|
handler: ghReleaseDeploySelected,
|
|
245
|
-
}
|
|
252
|
+
})
|