summaryrefslogtreecommitdiff
path: root/.claude/workflows/pr-babysit.js
blob: 8438d4d04a85afee108af180e3c44c197cbce950 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
export const meta = {
  name: 'pr-babysit',
  description: 'Drive a PR to green: a fast review lane (validate bot findings, fix, push without waiting on CI) overlapped with a CI-watch lane; code-writer fixes, code-verifier verification, at most one push per lane per cycle',
  whenToUse: 'After opening a PR, from a checkout of the PR branch. Default is a dry run (fixes left uncommitted, nothing posted); passing autoPush: true is the explicit authorization for pushes and PR comments.',
  phases: [{ title: 'Triage' }, { title: 'Fix' }, { title: 'Verify' }, { title: 'Push' }],
}

// args: { pr: number, maxCycles?: number, autoPush?: boolean (default false = dry run),
//          checkoutDir?: string (PR branch checkout; default: the session working dir) }
if (typeof args === 'string') { try { args = JSON.parse(args) } catch { /* not JSON: shape check below reports it */ } }
if (!args || !args.pr) {
  throw new Error('args must be { pr: number, maxCycles?, autoPush?, checkoutDir? }; run from the PR branch checkout or point checkoutDir at it')
}
args.pr = Number(args.pr)
if (!Number.isInteger(args.pr) || args.pr <= 0) {
  throw new Error('args.pr must be a positive integer PR number')
}
const checkoutDir = args.checkoutDir || '.'
if (typeof checkoutDir !== 'string' || checkoutDir.includes("'")) {
  throw new Error('checkoutDir must be a plain path string')
}
const IN_CHECKOUT = checkoutDir === '.' ? 'The working tree IS the PR checkout. '
  : `The PR branch checkout is at ${checkoutDir} - run every git/build/file command there, not in the session directory. `
const maxCycles = args.maxCycles ?? 3
if (!Number.isInteger(maxCycles) || maxCycles < 1) {
  throw new Error('maxCycles must be an integer >= 1')
}

const CI = {
  type: 'object', additionalProperties: false,
  required: ['status', 'infraRerun', 'realFailures'],
  properties: {
    status: { type: 'string', enum: ['green', 'red', 'running'] },
    infraRerun: { type: 'array', items: { type: 'string' } },
    realFailures: {
      type: 'array',
      items: {
        type: 'object', additionalProperties: false,
        required: ['check', 'firstError', 'files', 'rigSide'],
        properties: {
          check: { type: 'string' }, firstError: { type: 'string' },
          files: { type: 'array', items: { type: 'string' } },
          rigSide: { type: 'boolean' },
        },
      },
    },
  },
}
const REVIEWS = {
  type: 'object', additionalProperties: false,
  required: ['findings', 'replies', 'done'],
  properties: {
    findings: {
      type: 'array',
      items: {
        type: 'object', additionalProperties: false,
        required: ['source', 'commentId', 'file', 'line', 'claim', 'verdict', 'reason', 'fixHint'],
        properties: {
          source: { type: 'string' }, commentId: { type: 'integer' },
          file: { type: 'string' }, line: { type: 'integer' }, claim: { type: 'string' },
          verdict: { type: 'string', enum: ['valid', 'invalid', 'stale'] },
          reason: { type: 'string' }, fixHint: { type: 'string' },
        },
      },
    },
    replies: {
      type: 'array',
      items: {
        type: 'object', additionalProperties: false,
        required: ['commentId', 'body'],
        properties: { commentId: { type: 'integer' }, body: { type: 'string' } },
      },
    },
    done: { type: 'boolean' },
  },
}
const DEV = {
  type: 'object', additionalProperties: false,
  required: ['item', 'diffstat', 'buildOk', 'board', 'notes'],
  properties: {
    item: { type: 'string' }, diffstat: { type: 'string' }, buildOk: { type: 'boolean' },
    board: { type: 'string' }, notes: { type: 'string' },
  },
}
const CHECK = {
  type: 'object', additionalProperties: false,
  required: ['addresses', 'reason'],
  properties: { addresses: { type: 'boolean' }, reason: { type: 'string' } },
}
const OP = {
  type: 'object', additionalProperties: false,
  required: ['pass', 'detail'],
  properties: { pass: { type: 'boolean' }, detail: { type: 'string' } },
}
const SCOPE = {
  type: 'object', additionalProperties: false,
  required: ['files'],
  properties: { files: { type: 'array', items: { type: 'string' } } },
}
const OPIDS = {
  type: 'object', additionalProperties: false,
  required: ['pass', 'detail', 'doneIds'],
  properties: {
    pass: { type: 'boolean' }, detail: { type: 'string' },
    doneIds: { type: 'array', items: { type: 'integer' } },
  },
}

// Marking a review thread resolved has no REST endpoint — it needs the
// GraphQL resolveReviewThread mutation. Shared recipe handed to the posting
// agents so a fixed/refuted comment ends up both answered AND resolved.
const RESOLVE_RECIPE =
  'To resolve the review thread for an inline review comment (its integer databaseId is the commentId): ' +
  'get owner/repo via `gh repo view --json nameWithOwner -q .nameWithOwner`; find the thread node id with ' +
  '`gh api graphql -f query=\'query($o:String!,$r:String!,$p:Int!,$c:String){repository(owner:$o,name:$r){pullRequest(number:$p){reviewThreads(first:100,after:$c){pageInfo{hasNextPage endCursor}nodes{id isResolved comments(first:50){nodes{databaseId}}}}}}}\' -F o=OWNER -F r=REPO -F p=' + args.pr + '` ' +
  '(while hasNextPage is true and the comment is not found yet, re-run with -F c=<endCursor>), pick the thread whose comments contain that databaseId, then resolve it with ' +
  '`gh api graphql -f query=\'mutation($id:ID!){resolveReviewThread(input:{threadId:$id}){thread{isResolved}}}\' -F id=THREAD_ID`. ' +
  'Issue comments (the 404 fallback case) have no thread — do not try to resolve those.'

// Mechanical reply skeleton shared by the refuted-replies and fixed-resolve
// steps — kept in one place because the two copies drifted once already
// (the 404 fallback was missing from one of them).
const postReplyRecipe = (noun) =>
  `post a threaded reply to its inline comment via gh api repos/{owner}/{repo}/pulls/${args.pr}/comments/{commentId}/replies -f body=<body> ` +
  '(valid for inline review comments); if that 404s, the id is an issue comment — post a regular PR comment instead ' +
  `(gh pr comment ${args.pr} --body <quote the original point, then the ${noun}>) and skip resolving. ` +
  `After replying to an inline comment, mark its thread resolved. ${RESOLVE_RECIPE} `

const history = []
const repliedIds = new Set() // issue comments can't be thread-resolved, so they re-harvest every cycle — never reply twice

// Backoff between cycles that have nothing to do but wait. Degrades to a no-op
// rather than throwing if the workflow host has no timer.
const nap = (ms) => new Promise(res => { if (typeof setTimeout === 'function') setTimeout(res, ms); else res() })

// Canonicalize a repo-relative path for set/collision comparison: resolve ./..
// segments, unify separators; '' for anything that escapes the repo or uses
// characters no repo path does (also makes the path shell-safe to interpolate).
const canon = (p) => {
  const s = String(p).trim().replace(/\\/g, '/')
  // Absolute (CI-runner) paths: reject rather than corrupt into a bogus relative
  // path — the file-less group then routes through the scoper, which recovers the
  // real repo path and is existence-checked.
  if (s.startsWith('/')) return ''
  const out = []
  for (const seg of s.split('/')) {
    if (!seg || seg === '.') continue
    if (seg === '..') { if (out.pop() === undefined) return '' } else out.push(seg)
  }
  const c = out.join('/')
  return /^[A-Za-z0-9._+/-]+$/.test(c) ? c : ''
}

// Group actionable notes by top-level scope (plain JS — no model tokens).
const groupWork = (notes) => {
  const groups = new Map()
  for (const n of notes) {
    const key = (canon(n.scopeFile) || n.scopeFile).split('/').slice(0, 3).join('/')
    if (!groups.has(key)) groups.set(key, { key, files: new Set(), notes: [] })
    const g = groups.get(key)
    n.files.forEach(f => { const c = canon(f); if (c) g.files.add(c) })
    g.notes.push(n.text)
  }
  return [...groups.values()]
}

// Fix + verify one work list; returns { ok, fixes } — ok only if every group
// was scoped, fixed by a live worker, AND passed code-verifier verification.
const fixAndVerify = async (workIn) => {
  // code-writer's contract needs an explicit file set: a group whose notes named no
  // files (a CI failure whose log yielded no paths) is scoped by a dedicated agent
  // first; if that fails too, the group is withheld (ok=false → human review) rather
  // than dispatched with an invalid scope.
  const fileless = workIn.filter(w => w.files.size === 0)
  await parallel(fileless.map(w => () =>
    agent(
      `${IN_CHECKOUT}Determine which repo files must change to address these notes (read the code; if a note is a CI failure, read its CI log too):\n- ${w.notes.join('\n- ')}\n` +
      'files = repo-relative paths; empty only if genuinely undeterminable.',
      { label: `scope:${w.key}`, phase: 'Fix', model: 'sonnet', schema: SCOPE },
    ).then(s => s && s.files.forEach(f => { const c = canon(f); if (c) w.files.add(c) }))))
  // Scoped paths are model output: keep only what git ls-files confirms exists.
  // The check is executed (by a mechanical agent) and intersected here — a dead
  // checker drops every candidate, so unconfirmed groups fall through to withheld.
  const candidates = [...new Set(fileless.flatMap(w => [...w.files]))]
  if (candidates.length > 0) {
    const v = await agent(
      `${IN_CHECKOUT}Run exactly: git ls-files -- ${candidates.join(' ')}\nReturn files = the paths that command printed, verbatim — no additions, no substitutions.`,
      { label: 'scope:verify', phase: 'Fix', model: 'haiku', schema: SCOPE },
    )
    const exists = new Set((v ? v.files : []).map(canon))
    for (const w of fileless) for (const f of [...w.files])
      if (!exists.has(f)) { w.files.delete(f); log(`scope:${w.key}: dropped ${f} — not confirmed as a repo file`) }
  }
  const unscoped = workIn.filter(w => w.files.size === 0)
  for (const w of unscoped) log(`fix for ${w.key}: no file scope determinable — withheld for human review`)
  // Scoping can make groups overlap (two checks resolving to the same file); merge
  // intersecting groups (to closure) so two fixers never edit one file concurrently.
  const work = []
  for (let g of workIn.filter(w => w.files.size > 0)) {
    for (let i; (i = work.findIndex(m => [...g.files].some(f => m.files.has(f)))) >= 0;) {
      const [m] = work.splice(i, 1)
      g.files.forEach(f => m.files.add(f)); m.notes.push(...g.notes); m.key = `${m.key}+${g.key}`
      g = m
    }
    work.push(g)
  }
  // HIL rig rosters (test/hil/*.json) describe physical hardware the user owns:
  // never edit them autonomously — skipping/reshaping tests there papers over a
  // failing fixture. A failure that needs hardware swapped or re-cabled stays RED
  // for the user; roster edits happen only with the user's explicit approval.
  const withheld = []
  for (const w of work) {
    for (const f of [...w.files]) if (/^test\/hil\/[^/]+\.json$/.test(f)) {
      w.files.delete(f)
      log(`fix for ${w.key}: ${f} is a HIL rig config — edits need user approval, dropped from scope`)
    }
    if (w.files.size === 0) {
      withheld.push(w)
      log(`fix for ${w.key}: only a HIL rig config edit would address it — leaving red for the user`)
    }
  }
  for (const w of withheld) work.splice(work.indexOf(w), 1)
  const scopeOf = (w) => [...w.files].join(', ')
  const fixes = await pipeline(
    work,
    w => agent(
      `Fix the following issues on the PR branch. ${IN_CHECKOUT}\n` +
      'Constraint: never modify test/hil/*.json (HIL rig hardware config) — a failure that needs hardware swapped/changed stays red for the user.\n' +
      `Scope: ${scopeOf(w)}\nIssues:\n- ${w.notes.join('\n- ')}`,
      { label: `fix:${w.key}`, phase: 'Fix', agentType: 'code-writer', schema: DEV },
    ),
    (fix, w) => fix && agent(
      `${IN_CHECKOUT}Verify the uncommitted changes for ${scopeOf(w)} (use git diff -- <the files above>, and read any newly created untracked files directly) address these issues:\n- ${w.notes.join('\n- ')}\n` +
      'Return {"addresses": bool, "reason": string}.',
      { label: `check:${w.key}`, phase: 'Verify', agentType: 'code-verifier', schema: CHECK },
    ).then(v => ({ ...fix, addresses: !!(v && v.addresses), checkReason: v ? v.reason : 'verifier died' })),
  )
  const alive = fixes.filter(Boolean)
  if (alive.length < work.length) log(`${work.length - alive.length} fix group(s) lost to dead workers`)
  const unverified = alive.filter(f => f.addresses !== true)
  for (const f of unverified) log(`fix for ${f.item}: failed verification — ${f.checkReason}`)
  return { ok: unscoped.length === 0 && withheld.length === 0 && alive.length === work.length && unverified.length === 0, fixes: alive }
}

// Verification gates every push: never push unverified or partial edits.
const commitAndPush = async (cycle, what) => {
  const push = await agent(
    `${IN_CHECKOUT}On the PR branch: commit ALL working-tree changes as ONE commit (imperative message summarizing the cycle-${cycle} ${what} fixes for PR #${args.pr}, repo commit conventions), ` +
    "then push to the PR's remote branch. pass=true only if commit AND push succeeded; detail = pushed SHA.",
    { label: `push#${cycle}-${what}`, phase: 'Push', model: 'sonnet', schema: OP },
  )
  return push && push.pass ? push : null
}

for (let cycle = 1; cycle <= maxCycles; cycle++) {
  // Two independent lanes, launched together. The review lane never waits on
  // CI: it validates, fixes, and pushes while the CI lane is still watching.
  const ciPromise = agent(
    `Watch CI for PR #${args.pr} per your procedure; wait for pending checks.`,
    { label: `ci#${cycle}`, phase: 'Triage', agentType: 'pr-ci-watcher', schema: CI },
  ).catch(e => { log(`cycle ${cycle}: pr-ci-watcher errored — ${e && e.message}`); return null })
  // Every early return below leaves the loop while the CI lane is still
  // running: settle it first so no CI agent outlives the workflow.
  const stopWith = async (result) => { await ciPromise; return result }

  const r = await agent(
    `Validate the bot review findings on PR #${args.pr} per your procedure. ${IN_CHECKOUT}`,
    { label: `reviews#${cycle}`, phase: 'Triage', agentType: 'pr-review-validator', schema: REVIEWS },
  )
  if (!r) {
    history.push({ cycle, error: 'pr-review-validator died' })
    return await stopWith({ pass: false, cycles: cycle, history, reason: 'review-validator-died' })
  }
  const entry = { cycle, reviews: r }
  history.push(entry)
  // Outward reply/resolve attempts this cycle that did not fully complete; a green
  // PR must not terminate the loop while any remain, or the retry never happens.
  let pendingReplies = 0

  // Post drafted replies to REFUTED findings immediately. Outward-facing,
  // so gated on autoPush.
  const freshReplies = r.replies.filter(x => !repliedIds.has(x.commentId))
  if (freshReplies.length > 0 && args.autoPush === true) {
    const posted = await agent(
      `Reply to and resolve these refuted review comments on PR #${args.pr}. For each: ${postReplyRecipe('reply')}` +
      'If a thread already carries an identical reply of ours (a prior attempt that posted but failed to resolve), do not repost — just resolve it. ' +
      `Replies: ${JSON.stringify(freshReplies)}. pass=true only if every reply was posted and every inline thread resolved; detail = what went where. ` +
      'doneIds = the commentIds fully handled: reply posted (or already present) AND (thread resolved, or an issue comment with no thread to resolve).',
      { label: `replies#${cycle}`, phase: 'Push', model: 'sonnet', schema: OPIDS },
    )
    // Per-id accounting, matching the resolve path: only fully handled ids are marked
    // replied; a failed reply/resolve stays fresh and retries next cycle (the prompt's
    // already-present check keeps the retry from duplicating the reply).
    for (const id of (posted && posted.doneIds) || []) repliedIds.add(id)
    pendingReplies += freshReplies.filter(x => !repliedIds.has(x.commentId)).length
    if (!posted || !posted.pass) log(`cycle ${cycle}: refuted reply/resolve incomplete — ${posted ? posted.detail : 'agent died'}`)
  }

  // ---- review lane: fix + push without waiting for CI ----
  const validFindings = r.findings.filter(x => x.verdict === 'valid')
  let reviewPushed = false
  if (validFindings.length > 0) {
    const work = groupWork(validFindings.map(f => ({
      scopeFile: f.file, files: [f.file],
      text: `${f.file}:${f.line} [${f.source}] ${f.claim} — hint: ${f.fixHint}`,
    })))
    const { ok, fixes } = await fixAndVerify(work)
    entry.reviewFixes = fixes
    if (args.autoPush !== true) {
      log('autoPush not set: review-lane fixes left uncommitted (dry run)')
      return await stopWith({ pass: false, cycles: cycle, history, dryRun: true })
    }
    if (!ok) {
      log(`cycle ${cycle}: review-lane fixes left uncommitted for human review — not pushing unverified changes`)
      return await stopWith({ pass: false, cycles: cycle, history, reason: 'fix-verification-failed' })
    }
    const push = await commitAndPush(cycle, 'review')
    if (!push) {
      log(`cycle ${cycle}: review-lane push failed — stopping`)
      return await stopWith({ pass: false, cycles: cycle, history, reason: 'push-failed' })
    }
    reviewPushed = true
    const resolved = await agent(
      `The fixes for PR #${args.pr}'s valid review findings were just committed and pushed (${push.detail}). ` +
      `For each finding below: ${postReplyRecipe('fix note')}` +
      'Each reply states the finding is fixed in the pushed commit, with one line on the change. ' +
      `Findings: ${JSON.stringify(validFindings.map(f => ({ commentId: f.commentId, file: f.file, line: f.line, claim: f.claim, fixHint: f.fixHint })))}. ` +
      'pass=true only if every reply was posted and every thread resolved; detail = what went where. ' +
      'doneIds = the commentIds fully handled: reply posted AND (thread resolved, or an issue comment with no thread to resolve).',
      { label: `resolve#${cycle}`, phase: 'Push', model: 'sonnet', schema: OPIDS },
    )
    // Per-id accounting: a fully handled finding never re-replies (an issue comment
    // has no thread to resolve, so it re-harvests as stale next cycle and would get
    // a duplicate "fixed" note); an unfinished one stays out of repliedIds so its
    // reply/resolve is retried next cycle instead of silently abandoned.
    for (const id of (resolved && resolved.doneIds) || []) repliedIds.add(id)
    pendingReplies += validFindings.filter(f => !repliedIds.has(f.commentId)).length
    if (!resolved || !resolved.pass) log(`cycle ${cycle}: fixed reply/resolve incomplete — ${resolved ? resolved.detail : 'agent died'}`)
  }

  // ---- CI lane result ----
  const c = await ciPromise
  entry.ci = c
  if (!c) {
    log(`cycle ${cycle}: pr-ci-watcher died — re-arming`)
    continue
  }
  if (reviewPushed) {
    // The push restarted CI: this cycle's CI verdict is superseded. Re-arm;
    // next cycle's ci#N watches the fresh run.
    log(`cycle ${cycle}: review-lane push superseded the CI run — re-arming`)
    continue
  }
  const rigSide = c.realFailures.filter(rf => rf.rigSide)
  for (const rf of rigSide) log(`cycle ${cycle}: rig-side CI failure (not fixing): ${rf.check} — ${rf.firstError.slice(0, 120)}`)
  const fixable = c.realFailures.filter(rf => !rf.rigSide)
  if (fixable.length > 0) {
    const work = groupWork(fixable.map(rf => ({
      scopeFile: rf.files[0] || rf.check, files: rf.files,
      text: `CI ${rf.check}: ${rf.firstError}`,
    })))
    const { ok, fixes } = await fixAndVerify(work)
    entry.ciFixes = fixes
    if (args.autoPush !== true) {
      log('autoPush not set: CI-lane fixes left uncommitted (dry run)')
      return { pass: false, cycles: cycle, history, dryRun: true }
    }
    if (!ok) {
      log(`cycle ${cycle}: CI-lane fixes left uncommitted for human review — not pushing unverified changes`)
      return { pass: false, cycles: cycle, history, reason: 'fix-verification-failed' }
    }
    if (!(await commitAndPush(cycle, 'ci'))) {
      log(`cycle ${cycle}: CI-lane push failed — stopping`)
      return { pass: false, cycles: cycle, history, reason: 'push-failed' }
    }
    continue // pushed: fresh CI run next cycle
  }
  if (r.done && c.status === 'green') {
    if (pendingReplies > 0) {
      log(`cycle ${cycle}: PR green but ${pendingReplies} reply/resolve unfinished — re-arming to retry`)
      continue
    }
    log(`cycle ${cycle}: PR is green with no unresolved valid findings`)
    return { pass: true, cycles: cycle, history }
  }
  if (r.done && rigSide.length > 0 && fixable.length === 0 && c.infraRerun.length === 0 && c.status !== 'running') {
    log(`cycle ${cycle}: CI red only from rig-side failures — human/rig attention needed, nothing to fix in the PR`)
    return { pass: false, cycles: cycle, history, reason: 'ci-red-rig-side' }
  }
  if (c.status === 'running' || c.infraRerun.length > 0) {
    log(`cycle ${cycle}: CI still settling (${c.infraRerun.length} infra re-run(s)) — re-arming`)
    continue
  }
  if (!r.done) {
    // A bot has not reported for this head SHA yet. With CI already green there is
    // nothing else to wait on, so back off before re-arming or the cycle budget
    // burns on back-to-back re-harvests of the same unchanged PR.
    if (cycle < maxCycles) {
      log(`cycle ${cycle}: auto-review still pending — re-arming after a wait`)
      await nap(60000 * cycle) // no wait on the last cycle: nothing would re-check after it
    } else {
      log(`cycle ${cycle}: auto-review still pending — cycle budget exhausted`)
    }
    continue
  }
  log(`cycle ${cycle}: nothing actionable`)
  return { pass: false, cycles: cycle, history, reason: 'unactionable' }
}
return { pass: false, cycles: maxCycles, history, reason: 'maxCycles reached' }