diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts index 0f304dc21..13b8ef32a 100644 --- a/packages/core/src/__tests__/lifecycle-manager.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager.test.ts @@ -2365,7 +2365,66 @@ describe("reactions", () => { expect(sentMessage).toContain("Potential issue detected"); }); - it("dispatches CI failure details with check names and URLs on subsequent polls", async () => { + it("dispatches CI failure summary with failed step and log tail", async () => { + config.reactions = { + "ci-failed": { + auto: true, + action: "send-to-agent", + retries: 3, + escalateAfter: 3, + }, + }; + + const ciChecks = [ + { + name: "build", + status: "failed", + url: "https://github.com/org/repo/actions/runs/123/job/456", + conclusion: "FAILURE", + }, + ]; + const mockSCM = createMockSCM({ + getCISummary: vi.fn().mockResolvedValue("failing"), + getCIFailureSummary: vi.fn().mockResolvedValue({ + failedJobs: [ + { + name: "build", + failedStep: "Run pnpm test", + runUrl: "https://github.com/org/repo/actions/runs/123/job/456", + logTail: + "AssertionError: expected true to be false\n```\nProcess completed with exit code 1", + }, + ], + }), + enrichSessionsPRBatch: mockBatchEnrichment({ ciStatus: "failing", ciChecks }), + }); + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: mockSCM, + }); + + vi.mocked(mockSessionManager.send).mockResolvedValue(undefined); + + const lm = setupCheck("app-1", { + session: makeSession({ status: "pr_open", pr: makePR() }), + registry, + }); + + await lm.check("app-1"); + expect(mockSessionManager.send).toHaveBeenCalledTimes(1); + const sentMessage = vi.mocked(mockSessionManager.send).mock.calls[0]![1]; + expect(sentMessage).toContain("CI is failing on your PR."); + expect(sentMessage).toContain("Failed: build → Run pnpm test"); + expect(sentMessage).toContain("Failure URL: https://github.com/org/repo/actions/runs/123/job/456"); + expect(sentMessage).toContain("Log tail (last 3 lines):"); + expect(sentMessage).toContain("AssertionError: expected true to be false"); + expect(sentMessage).toContain("\u200B```"); + expect(sentMessage).toContain("Fix the issues and push again."); + expect(mockSCM.getCIFailureSummary).toHaveBeenCalledWith(makePR(), ciChecks); + }); + + it("falls back to check names and URLs when SCM lacks getCIFailureSummary", async () => { config.reactions = { "ci-failed": { auto: true, @@ -2419,6 +2478,7 @@ describe("reactions", () => { expect(lm.getStates().get("app-1")).toBe("ci_failed"); expect(mockSessionManager.send).toHaveBeenCalledTimes(1); const sentMessage = vi.mocked(mockSessionManager.send).mock.calls[0]![1]; + expect(sentMessage).toContain("CI checks are failing on your PR."); expect(sentMessage).toContain("lint"); expect(sentMessage).toContain("typecheck"); expect(sentMessage).toContain("https://github.com/org/repo/actions/runs/123"); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 17b77505b..37c59d3ce 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -669,8 +669,6 @@ function applyDefaultReactions(config: OrchestratorConfig): OrchestratorConfig { "ci-failed": { auto: true, action: "send-to-agent", - message: - "CI is failing on your PR. Investigate the failures, fix the issues, and push again.", retries: 2, escalateAfter: 2, }, diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index d8562fe4c..e4193ecbf 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -39,6 +39,8 @@ import { type ProjectConfig as _ProjectConfig, type PREnrichmentData, type CICheck, + type CIFailureSummary, + type PRInfo, type ReviewComment, type ReviewSummary, type ProcessProbeResult, @@ -109,6 +111,12 @@ const PERSISTENT_REACTION_KEYS = new Set(["ci-failed"]); * next real CI failure incident. */ const CI_PASSING_STABLE_THRESHOLD = 2; +type TransitionReaction = { + key: string; + result: ReactionResult | null; + messageEnriched?: boolean; +}; + type WorkspaceBranchProbe = | { kind: "branch"; branch: string } | { kind: "detached" } @@ -1644,7 +1652,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan session: Session, _oldStatus: SessionStatus, newStatus: SessionStatus, - transitionReaction?: { key: string; result: ReactionResult | null }, + transitionReaction?: TransitionReaction, ): Promise { const project = config.projects[session.projectId]; if (!project || !session.pr) return; @@ -1919,11 +1927,40 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan return lines.join("\n"); } - /** - * Format CI check failures into a human-readable message for the agent. - * Includes check names, statuses, and links for debugging. - */ - function formatCIFailureMessage(failedChecks: CICheck[]): string { + function isFailedCICheck(check: CICheck): boolean { + return check.status === "failed" || check.conclusion?.toUpperCase() === "FAILURE"; + } + + function formatCIFailureSummaryMessage(summary: CIFailureSummary): string { + const lines = ["CI is failing on your PR.", ""]; + + for (const job of summary.failedJobs) { + const failed = job.failedStep ? `${job.name} → ${job.failedStep}` : job.name; + lines.push(`Failed: ${failed}`); + lines.push(`Failure URL: ${job.runUrl}`); + + if (job.logTail) { + const lineCount = job.logTail.split(/\r?\n/).length; + const lineLabel = lineCount === 1 ? "line" : "lines"; + const escapedTail = escapeMarkdownCodeFenceClosers(job.logTail); + lines.push("", `Log tail (last ${lineCount} ${lineLabel}):`, "```", escapedTail, "```"); + } + + lines.push(""); + } + + lines.push("Fix the issues and push again."); + return lines.join("\n"); + } + + function escapeMarkdownCodeFenceClosers(logTail: string): string { + return logTail + .split(/\r?\n/) + .map((line) => (line.startsWith("```") ? `\u200B${line}` : line)) + .join("\n"); + } + + function formatCIFailureChecksFallback(failedChecks: CICheck[]): string { const lines = ["CI checks are failing on your PR. Here are the failed checks:", ""]; for (const check of failedChecks) { const status = check.conclusion ?? check.status; @@ -1934,11 +1971,60 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan return lines.join("\n"); } + /** + * Format CI failures into a human-readable message for the agent. + * Uses SCM-provided failed job/step/log details when available and falls + * back to check names/statuses/links for SCM plugins that do not implement it. + */ + async function formatCIFailureMessage( + scm: SCM, + pr: PRInfo, + failedChecks: CICheck[], + ): Promise { + if (scm.getCIFailureSummary) { + try { + const summary = await scm.getCIFailureSummary(pr, failedChecks); + if (summary?.failedJobs.length) { + return formatCIFailureSummaryMessage(summary); + } + } catch { + // Fall back to check names when summary enrichment fails. + } + } + + return formatCIFailureChecksFallback(failedChecks); + } + + async function getFailedCIChecks( + scm: SCM, + pr: PRInfo, + options: { allowFetch: boolean }, + ): Promise { + const prKey = `${pr.owner}/${pr.repo}#${pr.number}`; + const cachedEnrichment = prEnrichmentCache.get(prKey); + + let checks: CICheck[] | undefined = cachedEnrichment?.ciChecks; + if (checks === undefined && options.allowFetch) { + try { + checks = await scm.getCIChecks(pr); + } catch { + return null; + } + } + + const failedChecks = checks?.filter(isFailedCICheck) ?? []; + return failedChecks.length > 0 ? failedChecks : null; + } + + function makeCIFailureFingerprint(failedChecks: CICheck[]): string { + return makeFingerprint(failedChecks.map((c) => `${c.name}:${c.status}:${c.conclusion ?? ""}`)); + } + async function maybeDispatchCIFailureDetails( session: Session, _oldStatus: SessionStatus, newStatus: SessionStatus, - transitionReaction?: { key: string; result: ReactionResult | null }, + transitionReaction?: TransitionReaction, ): Promise { const project = config.projects[session.projectId]; if (!project || !session.pr) return; @@ -1974,32 +2060,10 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan return; } - // Fetch individual CI checks for failure details. - // Use batch enrichment data when available to avoid an extra REST call; - // fall back to getCIChecks() when the batch didn't run this cycle. - const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`; - const cachedEnrichment = prEnrichmentCache.get(prKey); + const failedChecks = await getFailedCIChecks(scm, session.pr, { allowFetch: true }); + if (!failedChecks) return; - let checks: CICheck[]; - if (cachedEnrichment?.ciChecks !== undefined) { - checks = cachedEnrichment.ciChecks; - } else { - try { - checks = await scm.getCIChecks(session.pr); - } catch { - // Failed to fetch checks — skip this cycle - return; - } - } - - const failedChecks = checks.filter( - (c) => c.status === "failed" || c.conclusion?.toUpperCase() === "FAILURE", - ); - if (failedChecks.length === 0) return; - - const ciFingerprint = makeFingerprint( - failedChecks.map((c) => `${c.name}:${c.status}:${c.conclusion ?? ""}`), - ); + const ciFingerprint = makeCIFailureFingerprint(failedChecks); const lastCIFingerprint = session.metadata["lastCIFailureFingerprint"] ?? ""; const lastCIDispatchHash = session.metadata["lastCIFailureDispatchHash"] ?? ""; @@ -2013,12 +2077,14 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan }); } - // If the transition reaction already sent a ci-failed reaction (now enriched - // with detailed check info from the batch cache), record the dispatch hash so - // subsequent polls don't re-send the same failure details. + // If the transition reaction already delivered an enriched agent message, + // or handled a non-agent action, record the dispatch hash so subsequent + // polls don't re-send the same failure details. if ( transitionReaction?.key === ciReactionKey && - transitionReaction.result?.success + transitionReaction.result?.success && + (transitionReaction.messageEnriched === true || + transitionReaction.result.action !== "send-to-agent") ) { updateSessionMetadata(session, { lastCIFailureDispatchHash: ciFingerprint, @@ -2039,7 +2105,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan reactionConfig.action && (reactionConfig.auto !== false || reactionConfig.action === "notify") ) { - const detailedMessage = formatCIFailureMessage(failedChecks); + const detailedMessage = await formatCIFailureMessage(scm, session.pr, failedChecks); try { if (reactionConfig.action === "send-to-agent") { @@ -2335,7 +2401,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan } const newStatus = assessment.status; const lifecycleChanged = session.metadata["lifecycle"] !== JSON.stringify(session.lifecycle); - let transitionReaction: { key: string; result: ReactionResult | null } | undefined; + let transitionReaction: TransitionReaction | undefined; const nextLifecycleEvidence = assessment.evidence; const nextDetectingAttempts = @@ -2473,18 +2539,27 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan if (reactionKey) { let reactionConfig = getReactionConfigForSession(session, reactionKey); + let messageEnriched = false; - // Enrich CI failure message with actual check details from batch cache - if (reactionKey === "ci-failed" && session.pr && reactionConfig) { - const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`; - const cachedData = prEnrichmentCache.get(prKey); - if (cachedData?.ciChecks) { - const failedChecks = cachedData.ciChecks.filter((c) => c.status === "failed"); - if (failedChecks.length > 0) { + // Enrich CI failure message with failed job/step/log details when + // batch check data is already available. If it is not, the + // post-transition CI dispatcher below fetches checks and sends the + // composed message without altering lifecycle state transitions. + if ( + reactionKey === "ci-failed" && + session.pr && + reactionConfig?.action === "send-to-agent" + ) { + const project = config.projects[session.projectId]; + const scm = project?.scm?.plugin ? registry.get("scm", project.scm.plugin) : null; + if (scm) { + const failedChecks = await getFailedCIChecks(scm, session.pr, { allowFetch: false }); + if (failedChecks) { reactionConfig = { ...reactionConfig, - message: formatCIFailureMessage(failedChecks), + message: await formatCIFailureMessage(scm, session.pr, failedChecks), }; + messageEnriched = true; } } } @@ -2493,7 +2568,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan // auto: false skips automated agent actions but still allows notifications if (reactionConfig.auto !== false || reactionConfig.action === "notify") { const reactionResult = await executeReaction(session, reactionKey, reactionConfig); - transitionReaction = { key: reactionKey, result: reactionResult }; + transitionReaction = { key: reactionKey, result: reactionResult, messageEnriched }; observer.recordOperation({ metric: "lifecycle_poll", operation: "lifecycle.transition.reaction", diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6525b5a2e..d759c7769 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -828,6 +828,9 @@ export interface SCM { /** Get individual CI check statuses */ getCIChecks(pr: PRInfo): Promise; + /** Get failed CI jobs/steps with a bounded failed-log tail, if supported. */ + getCIFailureSummary?(pr: PRInfo, failedChecks?: CICheck[]): Promise; + /** Get overall CI summary */ getCISummary(pr: PRInfo): Promise; @@ -1010,6 +1013,15 @@ export interface CICheck { completedAt?: Date; } +export interface CIFailureSummary { + failedJobs: Array<{ + name: string; + failedStep?: string; + runUrl: string; + logTail?: string; + }>; +} + export type CIStatus = "pending" | "passing" | "failing" | "none"; /** CI status constants */ diff --git a/packages/plugins/scm-github/src/index.ts b/packages/plugins/scm-github/src/index.ts index 5c2ed2f90..8d065db01 100644 --- a/packages/plugins/scm-github/src/index.ts +++ b/packages/plugins/scm-github/src/index.ts @@ -23,6 +23,7 @@ import { type PRState, type MergeMethod, type CICheck, + type CIFailureSummary, type CIStatus, type Review, type ReviewDecision, @@ -60,6 +61,8 @@ const BOT_AUTHORS = new Set([ "lgtm-com[bot]", ]); +const CI_FAILURE_LOG_TAIL_LINES = 120; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -167,6 +170,80 @@ function mapRawCheckStateToStatus(rawState: string | undefined): CICheck["status return "skipped"; } +function isFailedCheck(check: CICheck): boolean { + return check.status === "failed" || check.conclusion?.toUpperCase() === "FAILURE"; +} + +function isDecimalId(value: string): boolean { + return value.length > 0 && [...value].every((char) => char >= "0" && char <= "9"); +} + +function extractActionRunReference( + check: CICheck, +): { runId: string; jobId?: string; runUrl: string } | null { + if (!check.url) return null; + let pathParts: string[]; + try { + pathParts = new URL(check.url).pathname.split("/").filter(Boolean); + } catch { + return null; + } + + const actionsIndex = pathParts.findIndex( + (part, index) => part === "actions" && pathParts[index + 1] === "runs", + ); + const runId = actionsIndex >= 0 ? pathParts[actionsIndex + 2] : undefined; + if (!runId || !isDecimalId(runId)) return null; + + const jobIndex = pathParts.findIndex((part, index) => index > actionsIndex && part === "job"); + const jobId = jobIndex >= 0 ? pathParts[jobIndex + 1] : undefined; + + return { + runId, + ...(jobId && isDecimalId(jobId) ? { jobId } : {}), + runUrl: check.url, + }; +} + +function tailLines(text: string, maxLines: number): string | undefined { + const lines = text.split(/\r?\n/); + const tail = lines.slice(-maxLines).join("\n").trimEnd(); + return tail.length > 0 ? tail : undefined; +} + +function extractFailedStep(log: string): string | undefined { + let lastStep: string | undefined; + for (const line of log.split(/\r?\n/)) { + const parts = line.split("\t"); + const step = parts.length >= 3 ? parts[1]?.trim() : undefined; + if (step) lastStep = step; + } + return lastStep; +} + +async function getFailedJobLog( + pr: PRInfo, + runReference: { runId: string; jobId?: string }, +): Promise { + try { + return await gh([ + "run", + "view", + runReference.runId, + "--repo", + repoFlag(pr), + "--log-failed", + ...(runReference.jobId ? ["--job", runReference.jobId] : []), + ]); + } catch (err) { + if (!runReference.jobId) throw err; + return gh([ + "api", + `repos/${pr.owner}/${pr.repo}/actions/jobs/${runReference.jobId}/logs`, + ]); + } +} + async function getCIChecksFromStatusRollup(pr: PRInfo): Promise { const raw = await gh([ "pr", @@ -811,6 +888,46 @@ function createGitHubSCM(): SCM { }); }, + async getCIFailureSummary( + pr: PRInfo, + providedFailedChecks?: CICheck[], + ): Promise { + try { + const failedChecks = (providedFailedChecks ?? (await this.getCIChecks(pr))).filter( + isFailedCheck, + ); + if (failedChecks.length === 0) return null; + + const failedJobs: CIFailureSummary["failedJobs"] = []; + const seenRuns = new Set(); + + for (const check of failedChecks) { + const runReference = extractActionRunReference(check); + if (!runReference) continue; + + const seenKey = `${runReference.runId}:${runReference.jobId ?? ""}`; + if (seenRuns.has(seenKey)) continue; + seenRuns.add(seenKey); + + const log = await getFailedJobLog(pr, runReference); + + const failedJob: CIFailureSummary["failedJobs"][number] = { + name: check.name, + runUrl: runReference.runUrl, + }; + const failedStep = extractFailedStep(log); + if (failedStep) failedJob.failedStep = failedStep; + const logTail = tailLines(log, CI_FAILURE_LOG_TAIL_LINES); + if (logTail) failedJob.logTail = logTail; + failedJobs.push(failedJob); + } + + return failedJobs.length > 0 ? { failedJobs } : null; + } catch { + return null; + } + }, + async getCISummary(pr: PRInfo): Promise { let checks: CICheck[]; try { diff --git a/packages/plugins/scm-github/test/index.test.ts b/packages/plugins/scm-github/test/index.test.ts index f985fcea2..501ff4e09 100644 --- a/packages/plugins/scm-github/test/index.test.ts +++ b/packages/plugins/scm-github/test/index.test.ts @@ -647,6 +647,96 @@ describe("scm-github plugin", () => { }); }); + // ---- getCIFailureSummary ----------------------------------------------- + + describe("getCIFailureSummary", () => { + it("returns failed job step and caps the failed log tail", async () => { + const checks = [ + { + name: "build", + status: "failed" as const, + conclusion: "FAILURE", + url: "https://github.com/acme/repo/actions/runs/123/job/456", + }, + { + name: "lint", + status: "passed" as const, + conclusion: "SUCCESS", + url: "https://github.com/acme/repo/actions/runs/124/job/457", + }, + ]; + const logLines = Array.from( + { length: 125 }, + (_, index) => { + const step = index < 100 ? "Install dependencies" : "Run pnpm test"; + return `build\t${step}\t2026-05-12T00:00:00Z line ${index + 1}`; + }, + ); + mockGhRaw(logLines.join("\n")); + + const summary = await scm.getCIFailureSummary?.(pr, checks); + + expect(summary?.failedJobs).toHaveLength(1); + expect(summary?.failedJobs[0]).toEqual({ + name: "build", + failedStep: "Run pnpm test", + runUrl: "https://github.com/acme/repo/actions/runs/123/job/456", + logTail: logLines.slice(-120).join("\n"), + }); + expect(summary?.failedJobs[0]?.logTail?.split("\n")).toHaveLength(120); + expect(summary?.failedJobs[0]?.logTail?.split("\n")[0]).toContain("line 6"); + expect(summary?.failedJobs[0]?.logTail).toContain("line 125"); + expect(ghMock).toHaveBeenLastCalledWith( + expect.stringMatching(/(?:^|[\\/])gh(?:\.(?:exe|cmd|bat))?$/i), + ["run", "view", "123", "--repo", "acme/repo", "--log-failed", "--job", "456"], + expect.any(Object), + ); + expect(ghMock).toHaveBeenCalledTimes(1); + }); + + it("returns null when failed-log fetch fails", async () => { + const checks = [ + { + name: "build", + status: "failed" as const, + conclusion: "FAILURE", + url: "https://github.com/acme/repo/actions/runs/123/job/456", + }, + ]; + mockGhError("run view failed"); + + await expect(scm.getCIFailureSummary?.(pr, checks)).resolves.toBeNull(); + }); + + it("falls back to job logs API when gh run view cannot read logs yet", async () => { + const checks = [ + { + name: "lint", + status: "failed" as const, + conclusion: "FAILURE", + url: "https://github.com/acme/repo/actions/runs/123/job/456", + }, + ]; + mockGhError("run 123 is still in progress; logs will be available when it is complete"); + mockGhRaw("2026-05-14T23:40:42Z ##[error]Lint failed\nunused variable"); + + const summary = await scm.getCIFailureSummary?.(pr, checks); + + expect(summary?.failedJobs).toEqual([ + { + name: "lint", + runUrl: "https://github.com/acme/repo/actions/runs/123/job/456", + logTail: "2026-05-14T23:40:42Z ##[error]Lint failed\nunused variable", + }, + ]); + expect(ghMock).toHaveBeenLastCalledWith( + expect.stringMatching(/(?:^|[\\/])gh(?:\.(?:exe|cmd|bat))?$/i), + ["api", "repos/acme/repo/actions/jobs/456/logs"], + expect.any(Object), + ); + }); + }); + // ---- getCISummary ------------------------------------------------------ describe("getCISummary", () => {