feat(lifecycle): inject failed-job/step + log tail into CI-failure agent messages (closes #1807) (#1810)
* feat(core): add CI failure summary SCM contract Add an optional SCM getCIFailureSummary hook and shared CIFailureSummary shape so providers can return failed jobs, failed steps, run URLs, and bounded log tails without changing existing SCM implementations. * feat(scm-github): summarize failed CI logs Use failed GitHub check URLs to locate Actions run/job IDs, fetch gh run view --log-failed output, parse the step column from the gh log format, and cap each failed-job log tail at 120 lines. Return null when no failed run logs can be fetched so lifecycle can fall back cleanly. * feat(lifecycle): send actionable CI failure details Prefer SCM CI failure summaries when composing ci-failed agent messages, including failed job/step, run URL, and log tail. Fall back to check names/statuses for SCM plugins without getCIFailureSummary, and remove the generic default ci-failed text so default handling relies on the lifecycle-composed payload. State invariants preserved: this only changes ci-failed reaction payload composition and dispatch hashing. It does not change lifecycle status decisions, state-machine transitions, ci-failed persistence, retry/escalation thresholds, or stable-passing tracker reset semantics. * fix(lifecycle): harden CI failure summaries Address review feedback: escape log-tail lines that could close markdown fences, report the last failed-step column from gh failed logs, and pass already-known failed checks into getCIFailureSummary to avoid duplicate CI check fetches. State invariants preserved: lifecycle transition decisions, retry/escalation budgets, persistent ci-failed tracker behavior, and stable CI passing reset semantics remain unchanged. * refactor(lifecycle): centralize CI failure check lookup Deduplicate CI failure check lookup, filtering, and fingerprint generation across transition-time enrichment and follow-up CI-failure dispatch while preserving the existing fetch/no-fetch split. State invariants preserved: lifecycle transition decisions, reaction retry/escalation behavior, ci-failed tracker persistence, and stable-passing reset semantics are unchanged. * fix(scm-github): fetch in-progress failed job logs * fix(lifecycle): label CI failure URLs explicitly --------- Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com>
This commit is contained in:
parent
d44e708525
commit
3fb23cfb48
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<string> {
|
||||
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<CICheck[] | null> {
|
||||
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<void> {
|
||||
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>("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",
|
||||
|
|
|
|||
|
|
@ -828,6 +828,9 @@ export interface SCM {
|
|||
/** Get individual CI check statuses */
|
||||
getCIChecks(pr: PRInfo): Promise<CICheck[]>;
|
||||
|
||||
/** Get failed CI jobs/steps with a bounded failed-log tail, if supported. */
|
||||
getCIFailureSummary?(pr: PRInfo, failedChecks?: CICheck[]): Promise<CIFailureSummary | null>;
|
||||
|
||||
/** Get overall CI summary */
|
||||
getCISummary(pr: PRInfo): Promise<CIStatus>;
|
||||
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
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<CICheck[]> {
|
||||
const raw = await gh([
|
||||
"pr",
|
||||
|
|
@ -811,6 +888,46 @@ function createGitHubSCM(): SCM {
|
|||
});
|
||||
},
|
||||
|
||||
async getCIFailureSummary(
|
||||
pr: PRInfo,
|
||||
providedFailedChecks?: CICheck[],
|
||||
): Promise<CIFailureSummary | null> {
|
||||
try {
|
||||
const failedChecks = (providedFailedChecks ?? (await this.getCIChecks(pr))).filter(
|
||||
isFailedCheck,
|
||||
);
|
||||
if (failedChecks.length === 0) return null;
|
||||
|
||||
const failedJobs: CIFailureSummary["failedJobs"] = [];
|
||||
const seenRuns = new Set<string>();
|
||||
|
||||
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<CIStatus> {
|
||||
let checks: CICheck[];
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue