@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
package/src/shepherd.ts
ADDED
|
@@ -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
|
+
})
|