also looking at this
feat(ci): let @buddy fix-ci find the run a comment cannot name
#1413
10 files
+697
-121
| @@ -1349,117 +1349,18 @@ cli | ||
| 1349 | 1349 | |
| 1350 | 1350 | try { |
| 1351 | 1351 | const provider = await providerFor(config, 'this command', logger) |
| 1352 | const { runFixCi } = await import('../src/ci') | |
| 1352 | 1353 | |
| 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, | |
| 1389 | 1357 | logger, |
| 1390 | 1358 | 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) } : {}), | |
| 1441 | 1361 | }) |
| 1442 | 1362 | |
| 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}`) | |
| 1463 | 1364 | } |
| 1464 | 1365 | catch (error) { |
| 1465 | 1366 | logger.error('fix-ci failed:', error) |
| @@ -1563,6 +1464,14 @@ cli | ||
| 1563 | 1464 | return 'Rebased.' |
| 1564 | 1465 | }, |
| 1565 | 1466 | 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 | }, | |
| 1566 | 1475 | remember: async (text, context) => { |
| 1567 | 1476 | const { addLearning, createLearning, loadLearnings, serializeLearnings, DEFAULT_LEARNINGS_FILE } |
| 1568 | 1477 | = await import('../src/ai') |