@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,530 @@
1
+ import {
2
+ agentWork,
3
+ approveChangeRequest,
4
+ ask,
5
+ call,
6
+ type CalledLoop,
7
+ comment,
8
+ Continuing,
9
+ defineLoop,
10
+ mergeChangeRequest,
11
+ pipelineMoved,
12
+ pipelineStatus,
13
+ reportPipeline,
14
+ rerunPipeline,
15
+ reviewThreads,
16
+ syncBase,
17
+ verify,
18
+ waitFor,
19
+ writeBack,
20
+ isBlocking,
21
+ type BaseSync,
22
+ type MergedFinding,
23
+ type PipelineStatus,
24
+ type ReviewThreads
25
+ } from '@kitman-sh/sdk/loop'
26
+ import * as Effect from 'effect/Effect'
27
+ import * as Schema from 'effect/Schema'
28
+ import review, { ReviewInput, ReviewOutput } from './review'
29
+ import { ShepherdOutput } from './shepherd-output'
30
+
31
+ /** Review, by its schemas: Build calls this Loop and Review calls Build, so Review's definition may not be ready yet. */
32
+ const reviewLoop = () => ({ id: 'review', input: ReviewInput, output: ReviewOutput })
33
+
34
+ /** The model time one fix or round of replies may take before a person looks. */
35
+ const stepBudget = '45 minutes'
36
+
37
+ const Input = Schema.Struct({
38
+ /** The open ChangeRequest to see through to merge; the Run works on its branch. */
39
+ changeRequestId: Schema.String,
40
+ taskId: Schema.optional(Schema.String),
41
+ /** Merges once it is approved, green and mergeable, without asking. */
42
+ merge: Schema.optional(Schema.Boolean),
43
+ /** The target branch, for merging it in on a conflict, when Kitman does not say the ChangeRequest's own. */
44
+ branch: Schema.optional(Schema.String),
45
+ /** The ChangeRequest's branch and target, as Kitman puts the Run on it. */
46
+ continuing: Schema.optional(Continuing),
47
+ /** Rounds of fixing a failed pipeline before it stops as needs you; 3 when left out. */
48
+ maxFixRounds: Schema.optional(Schema.Int),
49
+ /** Times it looks at the ChangeRequest before asking whether to go on; 20 when left out. */
50
+ maxCycles: Schema.optional(Schema.Int)
51
+ })
52
+
53
+ export { ShepherdOutput }
54
+
55
+ const Output = ShepherdOutput
56
+
57
+ type Output = typeof Output.Type
58
+
59
+ const CiFix = Schema.Struct({
60
+ /** fixed: a commit fixes the branch's own failure; pre_existing: it fails on the target branch too; flaky: it passed on its rerun. */
61
+ verdict: Schema.Literals(['fixed', 'pre_existing', 'flaky']),
62
+ summary: Schema.String
63
+ })
64
+
65
+ const Replies = Schema.Struct({
66
+ replies: Schema.Array(
67
+ Schema.Struct({
68
+ threadId: Schema.String,
69
+ /** What changed, why not, or the answer to a question; posted on the thread. */
70
+ reply: Schema.String,
71
+ /** Whether a commit changed the code for it. */
72
+ changed: Schema.Boolean
73
+ })
74
+ )
75
+ })
76
+
77
+ const failedJobs = (pipeline: PipelineStatus) =>
78
+ pipeline.jobs.flatMap(job =>
79
+ job.status === 'failed'
80
+ ? [`### ${job.name}${job.url === undefined ? '' : ` (${job.url})`}\n\n${job.logTail ?? 'No log was served.'}`]
81
+ : []
82
+ )
83
+
84
+ const ciFixPrompt = (pipeline: PipelineStatus, target: string) =>
85
+ [
86
+ `The pipeline at ${pipeline.head} failed. These jobs failed, with the end of each log:`,
87
+ ...failedJobs(pipeline),
88
+ 'Run the failing job once more here to rule out a flake; if it passes, change nothing and answer flaky.',
89
+ `Then run it on the target branch (a worktree of \`origin/${target}\`, which Kitman fetched just before this Step; do not fetch). If it fails there too, the branch did not cause it: change nothing and answer pre_existing.`,
90
+ "Otherwise it is this branch's own failure: write or fix a test that shows it first, then fix it, and answer fixed. Kitman pushes what you leave."
91
+ ].join('\n\n')
92
+
93
+ const threadLine = (thread: ReviewThreads['threads'][number]) =>
94
+ [
95
+ `### Thread ${thread.id}${thread.path === undefined ? '' : ` on ${thread.path}${thread.line === undefined ? '' : `:${thread.line}`}`}`,
96
+ ...thread.comments.map(entry => `${entry.author ?? 'someone'}: ${entry.body}`)
97
+ ].join('\n')
98
+
99
+ const ReviewFix = Schema.Struct({ summary: Schema.String })
100
+
101
+ const reviewFixPrompt = (findings: readonly MergedFinding[]) =>
102
+ [
103
+ 'A review of the files the catch-up merge changed found these. Fix each blocker and major finding, with a test that shows it where one can. Kitman pushes what you leave.',
104
+ ...findings.map(finding => `- ${finding.id} (${finding.severity}): ${finding.detail}`)
105
+ ].join('\n')
106
+
107
+ type CatchUp = NonNullable<Extract<BaseSync, { readonly status: 'merged' }>['catchUp']>
108
+
109
+ /**
110
+ * What a catch-up merge leaves to approve: nothing new, so the approval carries; only the files it settled or changed
111
+ * after the merge, to review; or a change to the branch's own diff elsewhere, which only a fresh approval covers.
112
+ */
113
+ const catchUpVerdict = (
114
+ catchUp: CatchUp
115
+ ):
116
+ | { readonly kind: 'carry' }
117
+ | { readonly kind: 'review'; readonly files: readonly string[] }
118
+ | { readonly kind: 'changed'; readonly why: string } => {
119
+ if (catchUp.truncated) return { kind: 'changed', why: 'it changed more files than the Runner compares' }
120
+ const files = [...new Set([...catchUp.conflicted, ...catchUp.rewritten])]
121
+ const elsewhere = catchUp.changed.filter(file => !files.includes(file))
122
+
123
+ if (elsewhere.length > 0) return { kind: 'changed', why: `the branch's own diff changed in ${elsewhere.join(', ')}` }
124
+
125
+ return files.length === 0 ? { kind: 'carry' } : { kind: 'review', files }
126
+ }
127
+
128
+ const repliesPrompt = (threads: ReviewThreads['threads']) =>
129
+ [
130
+ 'Reviewers left these comments on the ChangeRequest. For each thread, apply what you agree with and give the reply to post on it: what you changed, or why you did not. A comment that asks a question gets an answer and no change.',
131
+ "Never mark a thread resolved: whether a disputed point is settled is the reviewer's call.",
132
+ ...threads.map(threadLine)
133
+ ].join('\n\n')
134
+
135
+ /** A thread's key changes when someone adds to it, so a new comment on an answered thread is answered again. */
136
+ const threadKey = (thread: ReviewThreads['threads'][number]) => `${thread.id}:${thread.comments.length}`
137
+
138
+ /**
139
+ * Takes an open ChangeRequest to merge: it waits on its Events with no model time between them, fixes a pipeline the
140
+ * branch broke (after ruling out a flake and the target branch), answers each new review thread on the thread, merges
141
+ * the target branch in on a conflict, and merges once approved, green and mergeable when told to or when a person
142
+ * says so. It stops as needs you, with why, instead of spinning.
143
+ */
144
+ export default defineLoop({
145
+ id: 'shepherd',
146
+ version: '1.1.0',
147
+ title: 'Shepherd',
148
+ input: Input,
149
+ output: Output,
150
+ agents: { implementer: 'implementer' },
151
+ checkout: 'write',
152
+ capabilities: [
153
+ 'agent_work',
154
+ 'read_change_request',
155
+ 'post_comment',
156
+ 'merge_change_request',
157
+ 'approve_change_request',
158
+ 'call',
159
+ 'write_back',
160
+ 'ask',
161
+ 'verify'
162
+ ],
163
+ calls: (): readonly CalledLoop[] => [review],
164
+ run: input => {
165
+ const changeRequestId = input.changeRequestId
166
+ // The ChangeRequest's own target: an offer's `branch` may be the offering Run's branch, which is this one.
167
+ const target = input.continuing?.targetBranch ?? input.branch ?? 'HEAD'
168
+ const answered = new Set<string>()
169
+ const reranHeads = new Set<string>()
170
+ const ranOnRunner = new Set<string>()
171
+ let fixRounds = 0
172
+ let replies = 0
173
+ let stalledAt: string | undefined
174
+ let stalls = 0
175
+
176
+ /** The files a catch-up merge settled or changed, still to review at the head it pushed or a later one. */
177
+ let unreviewed:
178
+ | {
179
+ readonly files: readonly string[]
180
+ readonly synced: string
181
+ /** The base sync whose merged-from head's approval covers the rest of the change. */
182
+ readonly sync: string
183
+ readonly earlier: readonly MergedFinding[]
184
+ }
185
+ | undefined
186
+
187
+ /** Why an approval of the head is still needed after a catch-up merge, for a person who is left to give it. */
188
+ let notCarried: string | undefined
189
+
190
+ const done = (status: Output['status'], reason: string): Output => ({ status, reason, fixRounds, replies })
191
+
192
+ /** Runs the repository's pipeline on a Runner at `head` and reports it; the reason when it could not run. */
193
+ const runOnRunner = (step: 'run' | 'rerun', cycle: number, head: string, checks: readonly string[]) =>
194
+ Effect.gen(function* () {
195
+ const verification = `pipeline-${step}:${cycle}`
196
+
197
+ const failed = yield* verify(verification, { revision: head, checks }).pipe(
198
+ Effect.as(undefined),
199
+ Effect.catchTag('StepFailed', error => Effect.succeed(error.reason))
200
+ )
201
+
202
+ if (failed === undefined)
203
+ yield* reportPipeline(`pipeline-${step === 'run' ? 'report' : 'rereport'}:${cycle}`, {
204
+ changeRequestId,
205
+ verification
206
+ })
207
+
208
+ return failed
209
+ })
210
+
211
+ return Effect.gen(function* () {
212
+ for (let cycle = 1; ; cycle++) {
213
+ if (cycle > (input.maxCycles ?? 20)) {
214
+ const goOn = yield* ask(`continue:${cycle}`, {
215
+ prompt: `Shepherd has looked at this ChangeRequest ${cycle - 1} times without landing it. Keep going?`,
216
+ options: ['Keep going', 'Stop'],
217
+ recommended: 'Stop',
218
+ recommendationReason: `${cycle - 1} looks have not landed it, so something needs a person rather than another look.`,
219
+ default: 'Stop',
220
+ timeout: '1 hour'
221
+ })
222
+
223
+ if (goOn === 'Stop') return done('needs_you', `Stopped after ${cycle - 1} looks without landing it`)
224
+ }
225
+
226
+ const pipeline = yield* pipelineStatus(`pipeline:${cycle}`, { changeRequestId })
227
+
228
+ // The forge runs no pipeline for this repository: its pipeline runs on a Runner and is reported to Kitman.
229
+ if (pipeline.status === 'none' && pipeline.runnerChecks !== undefined) {
230
+ if (ranOnRunner.has(pipeline.head))
231
+ return done('needs_you', `The pipeline ran on the Runner at ${pipeline.head}, but no result was recorded`)
232
+ ranOnRunner.add(pipeline.head)
233
+
234
+ const failed = yield* runOnRunner('run', cycle, pipeline.head, pipeline.runnerChecks)
235
+
236
+ if (failed !== undefined) return done('needs_you', `The pipeline could not run on the Runner: ${failed}`)
237
+
238
+ continue
239
+ }
240
+
241
+ if (pipeline.status === 'none' || pipeline.status === 'running') {
242
+ yield* pipelineMoved(`wait:${cycle}`, changeRequestId, pipeline)
243
+
244
+ continue
245
+ }
246
+
247
+ if (pipeline.status === 'failed' || pipeline.status === 'canceled') {
248
+ if (stalledAt === pipeline.head) stalls++
249
+ else stalls = 0
250
+
251
+ if (stalls >= 2)
252
+ return done('needs_you', `Two fixes pushed nothing new; the pipeline at ${pipeline.head} still fails`)
253
+
254
+ if (fixRounds >= (input.maxFixRounds ?? 3))
255
+ return done('needs_you', `The pipeline still fails after ${fixRounds} fix rounds`)
256
+ fixRounds++
257
+ stalledAt = pipeline.head
258
+
259
+ const fixed = yield* agentWork(`ci-fix:${cycle}`, {
260
+ agent: 'implementer',
261
+ prompt: ciFixPrompt(pipeline, target),
262
+ output: CiFix,
263
+ timeout: stepBudget
264
+ })
265
+
266
+ if (fixed.verdict === 'pre_existing')
267
+ return done('needs_you', `The failure is pre-existing on the target branch: ${fixed.summary}`)
268
+
269
+ if (fixed.verdict === 'flaky') {
270
+ if (pipeline.source === 'reported') {
271
+ // Only a pipeline that runs on the Runner can be run again; nothing on the forge can run a report.
272
+ if (pipeline.runnerChecks === undefined)
273
+ return done('needs_you', `The failing job passed when run again, outside the forge: ${fixed.summary}`)
274
+
275
+ if (reranHeads.has(pipeline.head))
276
+ return done('needs_you', `The failing job failed again when run again on the Runner: ${fixed.summary}`)
277
+ reranHeads.add(pipeline.head)
278
+
279
+ const failed = yield* runOnRunner('rerun', cycle, pipeline.head, pipeline.runnerChecks)
280
+
281
+ if (failed !== undefined) return done('needs_you', `The pipeline could not run on the Runner: ${failed}`)
282
+
283
+ continue
284
+ }
285
+
286
+ if (reranHeads.has(pipeline.head))
287
+ return done('needs_you', `The failing job failed again when run again on the forge: ${fixed.summary}`)
288
+ reranHeads.add(pipeline.head)
289
+ yield* rerunPipeline(`rerun:${cycle}`, { changeRequestId })
290
+ // Until the forge reports the rerun, a read still shows the failure it reran.
291
+ yield* pipelineMoved(`rerun-wait:${cycle}`, changeRequestId, pipeline).pipe(
292
+ Effect.catchTag('WaitTimedOut', () => Effect.void)
293
+ )
294
+ }
295
+
296
+ continue
297
+ }
298
+
299
+ const threads = (yield* reviewThreads(`threads:${cycle}`, { changeRequestId })).threads.filter(
300
+ thread => !answered.has(threadKey(thread))
301
+ )
302
+
303
+ if (threads.length > 0) {
304
+ const answers = yield* agentWork(`comments:${cycle}`, {
305
+ agent: 'implementer',
306
+ prompt: repliesPrompt(threads),
307
+ output: Replies,
308
+ timeout: stepBudget
309
+ })
310
+
311
+ for (const answer of answers.replies) {
312
+ yield* comment(`reply:${cycle}:${answer.threadId}`, {
313
+ changeRequestId,
314
+ threadId: answer.threadId,
315
+ body: answer.reply
316
+ })
317
+ replies++
318
+ }
319
+
320
+ for (const thread of threads) answered.add(`${thread.id}:${thread.comments.length + 1}`)
321
+
322
+ if (answers.replies.some(answer => answer.changed)) continue
323
+ }
324
+
325
+ // A review comment posted since this look began goes round again, to be read and answered.
326
+ const commented = {
327
+ event: 'change_request.comment',
328
+ filter: { changeRequestId },
329
+ ...(pipeline.readAt === undefined ? undefined : { since: pipeline.readAt })
330
+ }
331
+
332
+ const mergeable = yield* waitFor(`mergeable:${cycle}`, {
333
+ event: 'change_request.mergeable',
334
+ filter: { changeRequestId },
335
+ timeout: '1 hour',
336
+ or: [commented]
337
+ })
338
+
339
+ if (mergeable.type === commented.event) continue
340
+
341
+ if (mergeable.payload.mergeable === false) {
342
+ const synced = yield* syncBase(`sync:${cycle}`, { agent: 'implementer', target })
343
+
344
+ if (synced.status === 'conflict')
345
+ return done('needs_you', `Merging ${target} in conflicts on ${synced.files.join(', ')}: ${synced.reason}`)
346
+
347
+ if (synced.status === 'merged') {
348
+ const caught = synced.catchUp
349
+
350
+ const verdict =
351
+ caught === undefined
352
+ ? ({ kind: 'changed', why: 'its Runner did not report what it did to the branch' } as const)
353
+ : catchUpVerdict(caught)
354
+
355
+ if (verdict.kind === 'changed') {
356
+ unreviewed = undefined
357
+ notCarried = `merging ${target} in changed it: ${verdict.why}`
358
+ } else if (caught !== undefined && unreviewed !== undefined) {
359
+ const files = [...new Set([...unreviewed.files, ...(verdict.kind === 'review' ? verdict.files : [])])]
360
+
361
+ // With no fix since the files' merge, the review need not cover this merge's target commits; otherwise
362
+ // `since` keeps covering the fixes, and this merge with them.
363
+ unreviewed =
364
+ caught.from === unreviewed.synced
365
+ ? { ...unreviewed, files, synced: caught.head }
366
+ : { ...unreviewed, files }
367
+ } else if (caught !== undefined && verdict.kind === 'review') {
368
+ notCarried = undefined
369
+ unreviewed = { files: verdict.files, synced: caught.head, sync: `sync:${cycle}`, earlier: [] }
370
+ } else if (caught !== undefined) {
371
+ notCarried = undefined
372
+
373
+ const carried = yield* approveChangeRequest(`carry:${cycle}`, {
374
+ changeRequestId,
375
+ head: caught.head,
376
+ summary: `Merging ${target} in left the branch's own diff as it was approved.`,
377
+ carries: `sync:${cycle}`
378
+ }).pipe(
379
+ Effect.catchTag('StepFailed', error =>
380
+ Effect.succeed({ approved: false as const, reason: error.reason })
381
+ )
382
+ )
383
+
384
+ if (!carried.approved) notCarried = `the approval was not carried: ${carried.reason}`
385
+ }
386
+ }
387
+
388
+ continue
389
+ }
390
+
391
+ let reviewedHere = false
392
+
393
+ if (unreviewed !== undefined) {
394
+ const files = unreviewed
395
+ const head = pipeline.head
396
+
397
+ const reviewed = yield* call(`review:${cycle}`, reviewLoop(), {
398
+ base: `origin/${target}`,
399
+ head,
400
+ files: files.files,
401
+ ...(head === files.synced ? undefined : { since: files.synced }),
402
+ ...(files.earlier.length === 0 ? undefined : { earlier: files.earlier })
403
+ })
404
+
405
+ if (reviewed.blocking) {
406
+ const blocking = reviewed.findings.filter(isBlocking)
407
+
408
+ if (fixRounds >= (input.maxFixRounds ?? 3))
409
+ return done(
410
+ 'needs_you',
411
+ `The review of the catch-up merge's files still finds ${blocking.map(finding => finding.id).join(', ')} after ${fixRounds} fix rounds`
412
+ )
413
+ fixRounds++
414
+
415
+ yield* agentWork(`review-fix:${cycle}`, {
416
+ agent: 'implementer',
417
+ prompt: reviewFixPrompt(blocking),
418
+ output: ReviewFix,
419
+ timeout: stepBudget
420
+ })
421
+ unreviewed = { ...files, earlier: reviewed.findings }
422
+
423
+ continue
424
+ }
425
+
426
+ const approval = yield* approveChangeRequest(`approve:${cycle}`, {
427
+ changeRequestId,
428
+ head,
429
+ summary: `A review of the files merging ${target} in changed (${files.files.join(', ')}) passed with nothing blocking.`,
430
+ review: `review:${cycle}`,
431
+ carries: files.sync
432
+ }).pipe(
433
+ Effect.catchTag('StepFailed', error => Effect.succeed({ approved: false as const, reason: error.reason }))
434
+ )
435
+
436
+ unreviewed = undefined
437
+
438
+ if (approval.approved) reviewedHere = true
439
+ else
440
+ notCarried = `merging ${target} in, whose files' review passed but did not approve it: ${approval.reason}`
441
+ }
442
+
443
+ // A newer head's finished pipeline goes round again, to gate that head and wait for its own approval. A
444
+ // reported pipeline has no forge run to finish on a push, so an approval of a newer head goes round instead.
445
+ const moved =
446
+ pipeline.readAt === undefined
447
+ ? []
448
+ : [
449
+ { event: 'change_request.pipeline', filter: { changeRequestId }, since: pipeline.readAt },
450
+ ...(pipeline.source === 'reported'
451
+ ? [
452
+ {
453
+ event: 'change_request.approval',
454
+ filter: { changeRequestId, approved: true },
455
+ since: pipeline.readAt
456
+ }
457
+ ]
458
+ : [])
459
+ ]
460
+
461
+ const approved = reviewedHere
462
+ ? undefined
463
+ : yield* waitFor(`approved:${cycle}`, {
464
+ event: 'change_request.approval',
465
+ // An approval of an earlier commit does not approve the one whose pipeline passed.
466
+ filter: { changeRequestId, approved: true, head: pipeline.head },
467
+ timeout: '7 days',
468
+ or: [commented, ...moved]
469
+ })
470
+
471
+ if (approved !== undefined && approved.type !== 'change_request.approval') continue
472
+
473
+ // The approval of a newer head woke a wait on a reported pipeline: that head needs its own pipeline first.
474
+ if (pipeline.source === 'reported' && approved !== undefined && approved.payload.head !== pipeline.head)
475
+ continue
476
+
477
+ const merge =
478
+ input.merge === true
479
+ ? 'Merge'
480
+ : yield* ask(`merge:${cycle}`, {
481
+ prompt: 'The ChangeRequest is approved, green and mergeable. Merge now?',
482
+ options: [
483
+ { value: 'Merge', description: 'Merges the head whose pipeline passed.' },
484
+ { value: 'Wait', description: 'Leaves the merge to a person.' }
485
+ ],
486
+ recommended: 'Merge',
487
+ recommendationReason: 'It is approved, green and mergeable, which is everything a merge waits for.',
488
+ default: 'Wait',
489
+ timeout: '1 hour'
490
+ })
491
+
492
+ if (merge === 'Wait') return done('ready', 'Approved, green and mergeable; left for a person to merge')
493
+
494
+ // Only the head whose pipeline passed is merged: one pushed while waiting goes round again.
495
+ const merged = yield* mergeChangeRequest(`merge-change-request:${cycle}`, {
496
+ changeRequestId,
497
+ head: pipeline.head
498
+ })
499
+
500
+ if (merged.status !== 'merged') continue
501
+ yield* writeBack('write-back', { milestone: 'merged' })
502
+
503
+ return done('merged', `Merged at ${merged.revision}`)
504
+ }
505
+ }).pipe(
506
+ // Kitman fails a Step that runs past its time budget with this reason.
507
+ Effect.catchIf(
508
+ error => error._tag === 'StepFailed' && error.reason.includes('time budget'),
509
+ error => Effect.succeed(done('needs_you', `Step "${error.step}" ran past its ${stepBudget} budget`))
510
+ ),
511
+ Effect.catchTags({
512
+ WaitTimedOut: timedOut =>
513
+ Effect.succeed(
514
+ done(
515
+ 'needs_you',
516
+ timedOut.event === 'change_request.approval' && notCarried !== undefined
517
+ ? `Nobody approved the head after ${notCarried}`
518
+ : `Nothing happened on the ChangeRequest while waiting for ${timedOut.event}`
519
+ )
520
+ ),
521
+ ChangeRequestClosed: ended =>
522
+ Effect.succeed(
523
+ ended.merged
524
+ ? done('merged', 'Someone else merged the ChangeRequest')
525
+ : done('needs_you', 'The ChangeRequest was closed')
526
+ )
527
+ })
528
+ )
529
+ }
530
+ })