@kitman-sh/software-engineering 0.0.0-stage → 0.10.55

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.
@@ -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
+ })