@kitman-sh/software-engineering 0.0.0-stage → 0.10.54
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/INSTRUCTIONS.md +3 -0
- package/agents/implementer.md +41 -0
- package/agents/investigator.md +20 -0
- package/agents/planner.md +27 -0
- package/agents/releaser.md +22 -0
- package/agents/researcher.md +19 -0
- package/agents/reviewer.md +37 -0
- package/agents/writer.md +17 -0
- package/dist/bundle.js +29301 -0
- package/dist/declarations.json +3126 -0
- package/dist/setup/2a237548a69b0cd53ec3be7f687cfa8c2128335b3a655d34e185f2abce6a3c37.json +1 -0
- package/kitman.config.ts +813 -0
- package/package.json +30 -4
- package/src/build.ts +542 -0
- package/src/change-request-body.ts +95 -0
- package/src/errand.ts +142 -0
- package/src/file-tasks.ts +30 -0
- package/src/firewatch.ts +232 -0
- package/src/hillclimb.ts +407 -0
- package/src/hindsight.ts +242 -0
- package/src/index.ts +49 -0
- package/src/plan.ts +634 -0
- package/src/project-update.ts +88 -0
- package/src/release.ts +406 -0
- package/src/research.ts +225 -0
- package/src/review.ts +397 -0
- package/src/shepherd-output.ts +10 -0
- package/src/shepherd.ts +530 -0
- package/src/sweep.ts +385 -0
- package/src/tidy.ts +286 -0
- package/tsconfig.json +6 -0
- package/README.md +0 -3
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { agentWork, defineLoop, draftUpdate, projectActivity, type ProjectActivityResult } from '@kitman-sh/sdk/loop'
|
|
2
|
+
import * as Effect from 'effect/Effect'
|
|
3
|
+
import * as Schema from 'effect/Schema'
|
|
4
|
+
|
|
5
|
+
const Health = Schema.Literals(['on_track', 'at_risk', 'off_track'])
|
|
6
|
+
|
|
7
|
+
const Input = Schema.Struct({ projectId: Schema.String })
|
|
8
|
+
|
|
9
|
+
const Output = Schema.Struct({
|
|
10
|
+
/** Left out when nothing happened since the last Update, so there was nothing to draft. */
|
|
11
|
+
draftId: Schema.optional(Schema.String),
|
|
12
|
+
health: Schema.optional(Health)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const Written = Schema.Struct({
|
|
16
|
+
/** How the Project is going, judged from its Runs and Task changes. */
|
|
17
|
+
health: Health,
|
|
18
|
+
/** The Update for people to read: what moved, what is stuck and what comes next. */
|
|
19
|
+
note: Schema.String,
|
|
20
|
+
/** Why the health differs from the last Update's, naming the Runs or Tasks that moved it. */
|
|
21
|
+
healthReason: Schema.optional(Schema.String)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const label = { on_track: 'on track', at_risk: 'at risk', off_track: 'off track' } as const
|
|
25
|
+
|
|
26
|
+
const day = (iso: string) =>
|
|
27
|
+
new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })
|
|
28
|
+
|
|
29
|
+
const prompt = (read: ProjectActivityResult) =>
|
|
30
|
+
[
|
|
31
|
+
`Write a Project Update for ${read.project.name} from what happened ${read.since === undefined ? 'so far' : `since its last Update on ${day(read.since)}`}.`,
|
|
32
|
+
'```json',
|
|
33
|
+
JSON.stringify({ health: read.health, runs: read.runs, taskChanges: read.taskChanges }, null, 2),
|
|
34
|
+
'```',
|
|
35
|
+
'Say in a few short paragraphs what moved, what is stuck and what comes next, naming Tasks by key. Suggest a health: on_track, at_risk or off_track.',
|
|
36
|
+
...(read.health === undefined
|
|
37
|
+
? []
|
|
38
|
+
: [
|
|
39
|
+
`The last Update said ${read.health}. If your health differs, give in healthReason one sentence on why, naming the Runs or Tasks that moved it.`
|
|
40
|
+
]),
|
|
41
|
+
'A person reviews and posts it, so claim nothing the Runs and Task changes do not show.'
|
|
42
|
+
].join('\n')
|
|
43
|
+
|
|
44
|
+
/** The note, opening with why the health moved when it did. */
|
|
45
|
+
const noteOf = (read: ProjectActivityResult, written: typeof Written.Type) => {
|
|
46
|
+
if (read.health === undefined || read.health === written.health) return written.note
|
|
47
|
+
|
|
48
|
+
const moved = `Health moved from ${label[read.health]} to ${label[written.health]}`
|
|
49
|
+
|
|
50
|
+
return `${written.healthReason === undefined ? `${moved}.` : `${moved}: ${written.healthReason}`}\n\n${written.note}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Reads a Project's Runs and Task changes since its latest Update, as the person who asked, and drafts the next Update
|
|
55
|
+
* into that person's Inbox, replacing their unposted draft. It never posts one: only a person can. With nothing new it
|
|
56
|
+
* drafts nothing. A Team that wants one every week schedules it in its own Kit with the Project's id as input.
|
|
57
|
+
*/
|
|
58
|
+
export default defineLoop({
|
|
59
|
+
id: 'project-update',
|
|
60
|
+
version: '2.0.1',
|
|
61
|
+
title: 'Project update',
|
|
62
|
+
input: Input,
|
|
63
|
+
output: Output,
|
|
64
|
+
agents: { writer: 'writer' },
|
|
65
|
+
checkout: 'none',
|
|
66
|
+
capabilities: ['read_project', 'agent_work', 'draft_update'],
|
|
67
|
+
run: ({ projectId }) =>
|
|
68
|
+
Effect.gen(function* () {
|
|
69
|
+
const read = yield* projectActivity('read', { projectId })
|
|
70
|
+
|
|
71
|
+
if (read.runs.length === 0 && read.taskChanges.length === 0) return {}
|
|
72
|
+
|
|
73
|
+
const written = yield* agentWork('write', { agent: 'writer', prompt: prompt(read), output: Written })
|
|
74
|
+
|
|
75
|
+
const draft = yield* draftUpdate('draft', {
|
|
76
|
+
projectId,
|
|
77
|
+
health: written.health,
|
|
78
|
+
note: noteOf(read, written),
|
|
79
|
+
writtenFrom: {
|
|
80
|
+
runs: read.runs.length,
|
|
81
|
+
taskChanges: read.taskChanges.length,
|
|
82
|
+
...(read.since === undefined ? undefined : { since: read.since })
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
return { draftId: draft.id, health: written.health }
|
|
87
|
+
})
|
|
88
|
+
})
|
package/src/release.ts
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import {
|
|
2
|
+
agentWork,
|
|
3
|
+
artifact,
|
|
4
|
+
ask,
|
|
5
|
+
claim,
|
|
6
|
+
defineLoop,
|
|
7
|
+
mergeChangeRequest,
|
|
8
|
+
pipelineMoved,
|
|
9
|
+
pipelineStatus,
|
|
10
|
+
release,
|
|
11
|
+
rerunPipeline,
|
|
12
|
+
verify,
|
|
13
|
+
waitFor
|
|
14
|
+
} from '@kitman-sh/sdk/loop'
|
|
15
|
+
import * as Effect from 'effect/Effect'
|
|
16
|
+
import * as Schema from 'effect/Schema'
|
|
17
|
+
|
|
18
|
+
const Input = Schema.Struct({
|
|
19
|
+
/** The tag to release; the drafting agent proposes one when left out. */
|
|
20
|
+
tag: Schema.optional(Schema.String),
|
|
21
|
+
/** The revision to tag; the Run's base branch head when left out. */
|
|
22
|
+
revision: Schema.optional(Schema.String),
|
|
23
|
+
/** A release ChangeRequest to wait for, merge, and tag the merge of. */
|
|
24
|
+
changeRequestId: Schema.optional(Schema.String),
|
|
25
|
+
/** What the release deploys to; one Release deploys to it at a time. The deploy Event type when left out. */
|
|
26
|
+
deployTarget: Schema.optional(Schema.String),
|
|
27
|
+
/** The Event type the deploy reports; `deploy` when left out. Its payload names the `tag` and a `status`. */
|
|
28
|
+
deployEvent: Schema.optional(Schema.String),
|
|
29
|
+
/** How long to wait for the deploy; 60 when left out. */
|
|
30
|
+
deployTimeoutMinutes: Schema.optional(Schema.Int)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const Drafted = Schema.Struct({
|
|
34
|
+
/** The next version's tag. */
|
|
35
|
+
tag: Schema.String,
|
|
36
|
+
/** The latest release tag before it; left out when there is none. */
|
|
37
|
+
previousTag: Schema.optional(Schema.String),
|
|
38
|
+
title: Schema.String,
|
|
39
|
+
notes: Schema.String,
|
|
40
|
+
/** The ChangeRequests the notes cover. */
|
|
41
|
+
changeRequests: Schema.Array(Schema.String)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
type Drafted = typeof Drafted.Type
|
|
45
|
+
|
|
46
|
+
const Output = Schema.Struct({
|
|
47
|
+
status: Schema.Literals([
|
|
48
|
+
'gate_failed',
|
|
49
|
+
'not_mergeable',
|
|
50
|
+
'rejected',
|
|
51
|
+
'deployed',
|
|
52
|
+
'rolled_back',
|
|
53
|
+
'deploy_failed',
|
|
54
|
+
'timed_out'
|
|
55
|
+
]),
|
|
56
|
+
/** Why it stopped, or what happened, in a line. */
|
|
57
|
+
reason: Schema.String,
|
|
58
|
+
tag: Schema.optional(Schema.String),
|
|
59
|
+
notesArtifactId: Schema.optional(Schema.String),
|
|
60
|
+
revision: Schema.optional(Schema.String),
|
|
61
|
+
url: Schema.optional(Schema.String)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
type Output = typeof Output.Type
|
|
65
|
+
|
|
66
|
+
const draftPrompt = (tag: string | undefined, changeRequestId: string | undefined) =>
|
|
67
|
+
[
|
|
68
|
+
'Draft the release notes for the ChangeRequests merged since the last release tag on the base branch, including those with no linked Task.',
|
|
69
|
+
...(changeRequestId === undefined ? [] : [`The release ChangeRequest ${changeRequestId} is part of it.`]),
|
|
70
|
+
'Group them by what a user of the software notices, and name each ChangeRequest you cover.',
|
|
71
|
+
tag === undefined
|
|
72
|
+
? 'Propose the next version tag, following the scheme earlier tags use. With no earlier tag, propose v0.1.0 and say so in the notes.'
|
|
73
|
+
: `The release is tagged ${tag}.`,
|
|
74
|
+
'Give the latest earlier release tag as previousTag, or leave it out when there is none.'
|
|
75
|
+
].join('\n\n')
|
|
76
|
+
|
|
77
|
+
const redraftPrompt = (drafted: Drafted, edit: string) =>
|
|
78
|
+
[
|
|
79
|
+
'Redraft these release notes as asked, keeping the tag and everything not mentioned.',
|
|
80
|
+
`Asked: ${edit}`,
|
|
81
|
+
'```json',
|
|
82
|
+
JSON.stringify(drafted, null, 2),
|
|
83
|
+
'```'
|
|
84
|
+
].join('\n\n')
|
|
85
|
+
|
|
86
|
+
const approval = ['Approve', 'Edit notes', 'Reject'] as const
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Waits at no model cost until the release ChangeRequest is green, approved and mergeable, and gives the head that
|
|
90
|
+
* passed; or why it stopped. `round` names a gate run again after the head moved.
|
|
91
|
+
*/
|
|
92
|
+
const readyToMerge = (changeRequestId: string, round = 1) =>
|
|
93
|
+
Effect.gen(function* () {
|
|
94
|
+
// A newer head whose pipeline finishes while approval is awaited is gated afresh, up to 10 times.
|
|
95
|
+
for (let pass = 1; ; pass++) {
|
|
96
|
+
const at = (name: string) => {
|
|
97
|
+
const named = pass === 1 ? name : `${name}:moved:${pass}`
|
|
98
|
+
|
|
99
|
+
return round === 1 ? named : `${named}:${round}`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let read = yield* pipelineStatus(at('gate'), { changeRequestId })
|
|
103
|
+
|
|
104
|
+
// Read again after each pipeline Event, on whichever head is newest, so the gate judges what the forge says now.
|
|
105
|
+
for (let look = 1; look <= 10 && (read.status === 'running' || read.status === 'none'); look++) {
|
|
106
|
+
const again = (name: string) => at(look === 1 ? name : `${name}:${look}`)
|
|
107
|
+
|
|
108
|
+
yield* pipelineMoved(again('gate:pipeline'), changeRequestId, read)
|
|
109
|
+
read = yield* pipelineStatus(again('gate:after'), { changeRequestId })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (read.status !== 'success') {
|
|
113
|
+
const failing = read.jobs.filter(job => job.status === 'failed').map(job => job.name)
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
status: 'gate_failed',
|
|
117
|
+
reason: `The pipeline at ${read.head} is ${read.status}${failing.length === 0 ? '' : `: ${failing.join(', ')}`}`,
|
|
118
|
+
revision: read.head
|
|
119
|
+
} satisfies Output
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const approved = yield* waitFor(at('approved'), {
|
|
123
|
+
event: 'change_request.approval',
|
|
124
|
+
// An approval of an earlier commit does not approve the one whose pipeline passed.
|
|
125
|
+
filter: { changeRequestId, approved: true, head: read.head },
|
|
126
|
+
timeout: '7 days',
|
|
127
|
+
or:
|
|
128
|
+
read.readAt === undefined
|
|
129
|
+
? []
|
|
130
|
+
: [{ event: 'change_request.pipeline', filter: { changeRequestId }, since: read.readAt }]
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
if (approved.type !== 'change_request.approval') {
|
|
134
|
+
if (pass >= 10)
|
|
135
|
+
return {
|
|
136
|
+
status: 'not_mergeable',
|
|
137
|
+
reason: `ChangeRequest ${changeRequestId} kept moving while it awaited approval`
|
|
138
|
+
} satisfies Output
|
|
139
|
+
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const mergeable = yield* waitFor(at('mergeable'), {
|
|
144
|
+
event: 'change_request.mergeable',
|
|
145
|
+
filter: { changeRequestId },
|
|
146
|
+
timeout: '1 hour'
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
if (mergeable.payload.mergeable === false)
|
|
150
|
+
return {
|
|
151
|
+
status: 'not_mergeable',
|
|
152
|
+
reason: `ChangeRequest ${changeRequestId} does not merge cleanly; bring it up to date first`
|
|
153
|
+
} satisfies Output
|
|
154
|
+
|
|
155
|
+
return { passed: read.head }
|
|
156
|
+
}
|
|
157
|
+
}).pipe(
|
|
158
|
+
Effect.catchTags({
|
|
159
|
+
WaitTimedOut: timedOut =>
|
|
160
|
+
Effect.succeed({
|
|
161
|
+
status: 'not_mergeable',
|
|
162
|
+
reason: `Nothing happened on ChangeRequest ${changeRequestId} while waiting for ${timedOut.event}`
|
|
163
|
+
} satisfies Output),
|
|
164
|
+
ChangeRequestClosed: closed =>
|
|
165
|
+
Effect.succeed({
|
|
166
|
+
status: 'not_mergeable',
|
|
167
|
+
reason: `ChangeRequest ${changeRequestId} was ${closed.merged ? 'merged' : 'closed'} before Kitman released it`
|
|
168
|
+
} satisfies Output)
|
|
169
|
+
})
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
const recovery = (previous: string | undefined) =>
|
|
173
|
+
previous === undefined
|
|
174
|
+
? (['Retry the deploy', 'Leave it'] as const)
|
|
175
|
+
: (['Retry the deploy', `Roll back to ${previous}`, 'Leave it'] as const)
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Proves the release before anyone is asked: the approved checks pass on the revision to tag, or a release
|
|
179
|
+
* ChangeRequest is green, approved and mergeable. It then asks once, with no default, whether to release the drafted
|
|
180
|
+
* notes, which can be edited in place. On approval it merges the ChangeRequest when there is one, tags and publishes
|
|
181
|
+
* once (a tag already released at the same revision counts as done; one at another is never moved), holds the deploy
|
|
182
|
+
* target so two Releases never deploy over each other, and waits for the deploy. A failed or late deploy asks whether
|
|
183
|
+
* to retry (it runs the tag's failed pipeline again and waits), roll back to the previous tag (it runs that
|
|
184
|
+
* tag's pipeline again in full and waits for its deploy), or leave it, which it does by default.
|
|
185
|
+
*/
|
|
186
|
+
export default defineLoop({
|
|
187
|
+
id: 'release',
|
|
188
|
+
version: '2.0.0',
|
|
189
|
+
title: 'Release',
|
|
190
|
+
input: Input,
|
|
191
|
+
output: Output,
|
|
192
|
+
agents: { releaser: 'releaser' },
|
|
193
|
+
checkout: 'read',
|
|
194
|
+
capabilities: [
|
|
195
|
+
'agent_work',
|
|
196
|
+
'ask',
|
|
197
|
+
'verify',
|
|
198
|
+
'claim',
|
|
199
|
+
'publish_release',
|
|
200
|
+
'read_change_request',
|
|
201
|
+
'merge_change_request'
|
|
202
|
+
],
|
|
203
|
+
run: input =>
|
|
204
|
+
Effect.gen(function* () {
|
|
205
|
+
const changeRequestId = input.changeRequestId
|
|
206
|
+
// The commit the gate proved, and the one tagged or merged: never the branch head read again later.
|
|
207
|
+
let gated: string
|
|
208
|
+
|
|
209
|
+
if (changeRequestId === undefined) {
|
|
210
|
+
const gate = yield* verify('gate', input.revision === undefined ? {} : { revision: input.revision })
|
|
211
|
+
|
|
212
|
+
if (!gate.passed) {
|
|
213
|
+
const failing = gate.checks.filter(check => check.status !== 'passed').map(check => check.name)
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
status: 'gate_failed',
|
|
217
|
+
reason: `The checks fail at ${gate.revision}: ${failing.join(', ')}`,
|
|
218
|
+
revision: gate.revision
|
|
219
|
+
} satisfies Output
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
gated = gate.revision
|
|
223
|
+
} else {
|
|
224
|
+
const ready = yield* readyToMerge(changeRequestId)
|
|
225
|
+
|
|
226
|
+
if (!('passed' in ready)) return ready
|
|
227
|
+
gated = ready.passed
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let drafted = yield* agentWork('draft-notes', {
|
|
231
|
+
agent: 'releaser',
|
|
232
|
+
prompt: draftPrompt(input.tag, changeRequestId),
|
|
233
|
+
output: Drafted
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
const tag = input.tag ?? drafted.tag
|
|
237
|
+
|
|
238
|
+
// The question has no default: a release goes out only when someone says so.
|
|
239
|
+
for (let round = 1; ; round++) {
|
|
240
|
+
const answer = yield* ask(round === 1 ? 'approve' : `approve:${round}`, {
|
|
241
|
+
prompt: `Release ${tag} with these notes?\n\n${drafted.notes}`,
|
|
242
|
+
options: [
|
|
243
|
+
{ value: 'Approve', description: 'Tags, publishes and deploys it.' },
|
|
244
|
+
{ value: 'Edit notes', description: 'Say what to change; the notes are redrafted.' },
|
|
245
|
+
{ value: 'Reject', description: 'Keeps the draft and releases nothing.' }
|
|
246
|
+
] satisfies { readonly value: (typeof approval)[number]; readonly description: string }[],
|
|
247
|
+
recommended: 'Approve',
|
|
248
|
+
recommendationReason:
|
|
249
|
+
'The release is proved: its checks pass, or its ChangeRequest is green, approved and mergeable.',
|
|
250
|
+
default: null
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
if (answer === 'Approve') break
|
|
254
|
+
|
|
255
|
+
if (answer === 'Reject') {
|
|
256
|
+
const why = yield* ask('reject-why', {
|
|
257
|
+
prompt: `Why not release ${tag}? The draft is kept.`,
|
|
258
|
+
freeText: true,
|
|
259
|
+
informationOnly: true,
|
|
260
|
+
recommendationReason: 'Only you know why you rejected it.',
|
|
261
|
+
default: 'No reason given',
|
|
262
|
+
timeout: '1 day'
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
const kept = yield* artifact('notes', {
|
|
266
|
+
kind: 'release_notes',
|
|
267
|
+
title: drafted.title,
|
|
268
|
+
text: drafted.notes,
|
|
269
|
+
data: { tag, changeRequests: drafted.changeRequests }
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
return { status: 'rejected', reason: why, tag, notesArtifactId: kept.artifactId } satisfies Output
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const edit = yield* ask(`edit:${round}`, {
|
|
276
|
+
prompt: 'What should change in the notes?',
|
|
277
|
+
freeText: true,
|
|
278
|
+
informationOnly: true,
|
|
279
|
+
recommendationReason: 'You chose to edit them, so only you know what should change.',
|
|
280
|
+
default: '',
|
|
281
|
+
timeout: '1 day'
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
if (edit.trim() === '') continue
|
|
285
|
+
|
|
286
|
+
drafted = yield* agentWork(`redraft:${round}`, {
|
|
287
|
+
agent: 'releaser',
|
|
288
|
+
prompt: redraftPrompt(drafted, edit),
|
|
289
|
+
output: Drafted
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const notes = yield* artifact('notes', {
|
|
294
|
+
kind: 'release_notes',
|
|
295
|
+
title: drafted.title,
|
|
296
|
+
text: drafted.notes,
|
|
297
|
+
data: { tag, changeRequests: drafted.changeRequests }
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
let revision = gated
|
|
301
|
+
|
|
302
|
+
if (changeRequestId !== undefined) {
|
|
303
|
+
// A head pushed while waiting for approval has passed nothing: it goes through the gate before it merges.
|
|
304
|
+
for (let round = 1; ; round++) {
|
|
305
|
+
const merged = yield* mergeChangeRequest(round === 1 ? 'merge' : `merge:${round}`, {
|
|
306
|
+
changeRequestId,
|
|
307
|
+
head: gated
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
if (merged.status === 'conflict')
|
|
311
|
+
return {
|
|
312
|
+
status: 'not_mergeable',
|
|
313
|
+
reason: `ChangeRequest ${changeRequestId} no longer merges cleanly`,
|
|
314
|
+
tag,
|
|
315
|
+
notesArtifactId: notes.artifactId
|
|
316
|
+
} satisfies Output
|
|
317
|
+
|
|
318
|
+
if (merged.status === 'merged') {
|
|
319
|
+
revision = merged.revision
|
|
320
|
+
break
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (round >= 3)
|
|
324
|
+
return {
|
|
325
|
+
status: 'not_mergeable',
|
|
326
|
+
reason: `ChangeRequest ${changeRequestId} kept moving while it was gated; it is at ${merged.head}`,
|
|
327
|
+
tag,
|
|
328
|
+
notesArtifactId: notes.artifactId
|
|
329
|
+
} satisfies Output
|
|
330
|
+
|
|
331
|
+
const ready = yield* readyToMerge(changeRequestId, round + 1)
|
|
332
|
+
|
|
333
|
+
if (!('passed' in ready)) return { ...ready, tag, notesArtifactId: notes.artifactId }
|
|
334
|
+
gated = ready.passed
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const deployEvent = input.deployEvent ?? 'deploy'
|
|
339
|
+
|
|
340
|
+
// Held until the Run completes, so the next Release deploys after this one is settled.
|
|
341
|
+
yield* claim('deploy-target', { resource: `deploy:${input.deployTarget ?? deployEvent}` })
|
|
342
|
+
|
|
343
|
+
const released = yield* release('tag', {
|
|
344
|
+
tag,
|
|
345
|
+
revision,
|
|
346
|
+
title: drafted.title,
|
|
347
|
+
notes: drafted.notes
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
const deployed = (step: string, deployTag: string) =>
|
|
351
|
+
waitFor(step, {
|
|
352
|
+
event: deployEvent,
|
|
353
|
+
filter: { tag: deployTag },
|
|
354
|
+
timeout: `${input.deployTimeoutMinutes ?? 60} minutes`
|
|
355
|
+
}).pipe(
|
|
356
|
+
Effect.map(deploy =>
|
|
357
|
+
deploy.payload.status === 'failed' ? ('deploy_failed' as const) : ('deployed' as const)
|
|
358
|
+
),
|
|
359
|
+
Effect.catchTag('WaitTimedOut', () => Effect.succeed('timed_out' as const))
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
const settled = { tag, notesArtifactId: notes.artifactId, revision: released.revision, url: released.url }
|
|
363
|
+
let status = yield* deployed('deploy', tag)
|
|
364
|
+
|
|
365
|
+
for (let round = 1; status !== 'deployed'; round++) {
|
|
366
|
+
const options = recovery(drafted.previousTag)
|
|
367
|
+
|
|
368
|
+
const answer = yield* ask(`recover:${round}`, {
|
|
369
|
+
prompt: `${tag} ${status === 'timed_out' ? 'did not report a deploy in time' : 'failed to deploy'}. What now?`,
|
|
370
|
+
options,
|
|
371
|
+
recommended: 'Retry the deploy',
|
|
372
|
+
recommendationReason:
|
|
373
|
+
'A failed or late deploy is often passing, and a retry runs only the failed jobs of the same tag.',
|
|
374
|
+
default: 'Leave it',
|
|
375
|
+
timeout: '1 hour'
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
if (answer === 'Leave it')
|
|
379
|
+
return { status, reason: `${tag} at ${released.revision} was left undeployed`, ...settled } satisfies Output
|
|
380
|
+
|
|
381
|
+
if (answer === 'Retry the deploy') {
|
|
382
|
+
yield* rerunPipeline(`deploy:rerun-${round}`, { ref: tag })
|
|
383
|
+
status = yield* deployed(`deploy:retry-${round}`, tag)
|
|
384
|
+
continue
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const previous = drafted.previousTag ?? tag
|
|
388
|
+
|
|
389
|
+
yield* rerunPipeline(`deploy:redeploy-${round}`, { ref: previous, all: true })
|
|
390
|
+
const back = yield* deployed(`deploy:rollback-${round}`, previous)
|
|
391
|
+
|
|
392
|
+
if (back === 'deployed')
|
|
393
|
+
return { status: 'rolled_back', reason: `Rolled back to ${previous}`, ...settled } satisfies Output
|
|
394
|
+
status = back
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
yield* artifact('report', {
|
|
398
|
+
kind: 'release_report',
|
|
399
|
+
title: `${tag} deployed`,
|
|
400
|
+
text: `${tag} at ${released.revision} deployed. ${released.url}`,
|
|
401
|
+
data: { tag, status, revision: released.revision, url: released.url }
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
return { status: 'deployed', reason: `${tag} deployed`, ...settled } satisfies Output
|
|
405
|
+
})
|
|
406
|
+
})
|