ReviewOS

also looking at this

stacks/buddy

feat(ci): let @buddy fix-ci find the run a comment cannot name

#1413
Merged glennmichael123 wants to merge feat/fix-ci-from-comment into main
10 files +697 -121
bin/cli.tsmodified+15-106
Changes to bin/cli.ts
@@ -1349,117 +1349,18 @@ cli
13491349
13501350 try {
13511351 const provider = await providerFor(config, 'this command', logger)
1352 const { runFixCi } = await import('../src/ci')
13521353
1353 assertSupports(provider, 'ciLogs', 'getWorkflowRunLogs', 'reading CI logs')
1354 const runId = options.runId ? Number.parseInt(options.runId, 10) : undefined
1355 const log = runId ? await provider.getWorkflowRunLogs(runId) : null
1356
1357 if (!log) {
1358 logger.warn('⚠️ Could not read the run logs; nothing to diagnose')
1359 return
1360 }
1361
1362 const { attemptFix, failsOnBaseBranch } = await import('../src/ci')
1363 const { parseFixAttempts, upsertFixAttempts } = await import('../src/ci/attempts')
1364 const { createAiClient } = await import('../src/ai')
1365
1366 // The guard in attemptFix can only stop a loop if it is told how many
1367 // attempts came before, and a CI job remembers nothing between runs.
1368 const prNumber = options.pr ? Number.parseInt(options.pr, 10) : undefined
1369 let pullRequest: PullRequest | undefined
1370 if (prNumber) {
1371 const open = await provider.getPullRequests('open')
1372 pullRequest = open.find(candidate => candidate.number === prNumber)
1373 }
1374 const priorAttempts = parseFixAttempts(pullRequest?.body)?.attempts ?? 0
1375 const baseBranch = config.repository?.baseBranch ?? 'main'
1376
1377 // Both of these are capability-gated rather than assumed: a provider
1378 // that cannot read a run history still gets the full diagnosis, it just
1379 // cannot tell an inherited failure from a new one.
1380 const canReadRuns = supports(provider, 'ciRuns', 'listWorkflowRuns')
1381 const canRerun = supports(provider, 'ciRuns', 'rerunWorkflowRun')
1382
1383 const outcome = await attemptFix({
1384 log,
1385 priorAttempts,
1386 workspace: process.cwd(),
1387 baseBranch,
1388 ai: createAiClient(config, logger),
1354 const status = await runFixCi({
1355 config,
1356 provider,
13891357 logger,
13901358 dryRun: Boolean(options.dryRun),
1391 ...(canReadRuns
1392 ? {
1393 checkBase: (kind: FailureKind) => failsOnBaseBranch(
1394 {
1395 listWorkflowRuns: (branch, listOptions) => provider.listWorkflowRuns!(branch, listOptions),
1396 getWorkflowRunLogs: id => provider.getWorkflowRunLogs!(id),
1397 },
1398 baseBranch,
1399 kind,
1400 logger,
1401 ),
1402 }
1403 : {}),
1404 ...(canRerun && runId ? { rerun: () => provider.rerunWorkflowRun!(runId) } : {}),
1405 regenerateLockfile: async () => {
1406 const { regenerateLockFile, detectRequiredPackageManagers, getAllLockFilePaths } = await import('../src/utils/lock-file')
1407 const { commitAndPush } = await import('../src/utils/git')
1408
1409 // Detection keys off the manifests a lockfile is derived from, so a
1410 // repository with several ecosystems regenerates each of them.
1411 const managers = detectRequiredPackageManagers(['package.json', 'composer.json'])
1412 let regenerated = false
1413
1414 for (const manager of managers) {
1415 const result = await regenerateLockFile(manager, process.cwd())
1416 if (result.success)
1417 regenerated = true
1418 else
1419 logger.warn(`⚠️ ${result.message}`)
1420 }
1421
1422 if (!regenerated)
1423 return { regenerated: false, pushed: false }
1424
1425 // Rewriting the file in a workspace that is about to be thrown away
1426 // repairs nothing. The job checked this branch out with credentials,
1427 // so the commit is a local one rather than a branch recreation.
1428 try {
1429 const pushed = await commitAndPush(
1430 getAllLockFilePaths(),
1431 'fix(deps): regenerate the lock file\n\nThe lock file had drifted from the manifest, which failed CI.',
1432 process.cwd(),
1433 )
1434 return { regenerated: true, pushed }
1435 }
1436 catch (error) {
1437 logger.warn(`⚠️ Could not push the regenerated lock file: ${error instanceof Error ? error.message : String(error)}`)
1438 return { regenerated: true, pushed: false }
1439 }
1440 },
1359 ...(options.runId ? { runId: Number.parseInt(options.runId, 10) } : {}),
1360 ...(options.pr ? { prNumber: Number.parseInt(options.pr, 10) } : {}),
14411361 })
14421362
1443 logger.info(`\n${outcome.report}\n`)
1444
1445 if (prNumber && !options.dryRun) {
1446 await provider.createComment(prNumber, outcome.report)
1447
1448 // Only a real attempt counts. Recording the ones the guard already
1449 // refused would march the counter up without anything being tried.
1450 if (outcome.action !== 'skipped' && pullRequest) {
1451 try {
1452 await provider.updatePullRequest(prNumber, {
1453 body: upsertFixAttempts(pullRequest.body, priorAttempts + 1),
1454 })
1455 }
1456 catch (error) {
1457 logger.warn(`Could not record the fix attempt on PR #${prNumber}: ${error instanceof Error ? error.message : String(error)}`)
1458 }
1459 }
1460 }
1461
1462 logger.success(`✅ ${outcome.action}${outcome.fixed ? ' (fixed)' : ''}`)
1363 logger.success(`✅ ${status}`)
14631364 }
14641365 catch (error) {
14651366 logger.error('fix-ci failed:', error)
@@ -1563,6 +1464,14 @@ cli
15631464 return 'Rebased.'
15641465 },
15651466 merge: async () => await new Buddy(config).mergeEligiblePullRequests(false),
1467 // `analysisOnly` because this job checks out the default branch, not
1468 // the pull request's. A lock-file rewrite or an agent edit here would
1469 // change the wrong tree; re-running a flake is an API call and still
1470 // works. The fix-ci job, which does check out the branch, repairs.
1471 fixCi: async (prNumber) => {
1472 const { runFixCi } = await import('../src/ci')
1473 return await runFixCi({ config, provider, prNumber, analysisOnly: true, logger })
1474 },
15661475 remember: async (text, context) => {
15671476 const { addLearning, createLearning, loadLearnings, serializeLearnings, DEFAULT_LEARNINGS_FILE }
15681477 = await import('../src/ai')