Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export default tseslint.config(
"**/node_modules/**",
"**/.next/**",
"**/coverage/**",
"packages/web/dist-server/**",
"packages/web/next-env.d.ts",
"packages/web/next.config.js",
"packages/web/postcss.config.mjs",
Expand Down Expand Up @@ -71,6 +72,26 @@ export default tseslint.config(
},
},

// Plugin packages and CLI run in Node.js — declare standard Node.js globals
{
files: ["packages/plugins/**/*.ts", "packages/cli/**/*.ts"],
languageOptions: {
globals: {
console: "readonly",
process: "readonly",
setTimeout: "readonly",
clearTimeout: "readonly",
setInterval: "readonly",
clearInterval: "readonly",
URL: "readonly",
URLSearchParams: "readonly",
Buffer: "readonly",
__dirname: "readonly",
__filename: "readonly",
},
},
},

// CLI package uses console.log/error for user output
{
files: ["packages/cli/**/*.ts"],
Expand Down
62 changes: 53 additions & 9 deletions packages/web/src/__tests__/components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,30 @@ describe("SessionCard", () => {
expect(onMerge).toHaveBeenCalledWith(42);
});

it("shows CI failing alert", () => {
it("does not show ask-to-fix when pr.ciStatus is not failing (session status ignored)", () => {
// Button visibility is driven by pr.ciStatus (GitHub data), not session.status.
// Even when session.status is ci_failed, if pr.ciStatus isn't failing yet
// (e.g. enrichment hasn't run), the button should NOT show.
const pr = makePR({
state: "open",
ciStatus: "none", // PR enrichment not yet loaded
ciChecks: [],
reviewDecision: "none",
mergeability: {
mergeable: false,
ciPassing: false,
approved: false,
noConflicts: true,
blockers: ["Data not loaded"],
},
});
const session = makeSession({ status: "ci_failed", activity: "idle", pr });
render(<SessionCard session={session} />);
// No button until pr.ciStatus is updated to "failing" via SSE snapshot patch
expect(screen.queryByText("ask to fix")).not.toBeInTheDocument();
});

it("shows CI failing alert with count when checks are available", () => {
const pr = makePR({
state: "open",
ciStatus: "failing",
Expand All @@ -291,13 +314,13 @@ describe("SessionCard", () => {
expect(screen.getByText("1 CI check failing")).toBeInTheDocument();
});

it("shows CI status unknown when ciStatus is failing but no failed checks", () => {
// This happens when GitHub API fails - getCISummary returns "failing"
// but getCIChecks returns empty array
it("shows CI failing with ask-to-fix even when no check details are available", () => {
// pr.ciStatus="failing" is the authoritative GitHub signal — show the action
// button even when ciChecks is empty (API inconsistency or enrichment lag).
const pr = makePR({
state: "open",
ciStatus: "failing",
ciChecks: [], // Empty - API failed to fetch checks
ciChecks: [], // Empty - API failed to fetch individual checks
reviewDecision: "none",
mergeability: {
mergeable: false,
Expand All @@ -307,13 +330,34 @@ describe("SessionCard", () => {
blockers: ["CI is failing"],
},
});
const session = makeSession({ status: "ci_failed", activity: "idle", pr });
const session = makeSession({ status: "working", activity: "active", pr });
render(<SessionCard session={session} />);
expect(screen.getByText("CI unknown")).toBeInTheDocument();
// pr.ciStatus is the GitHub signal — show "CI failing" with action button
expect(screen.getByText("CI failing")).toBeInTheDocument();
expect(screen.getByText("ask to fix")).toBeInTheDocument();
// Should NOT show "0 CI check failing"
expect(screen.queryByText(/0.*CI check.*failing/i)).not.toBeInTheDocument();
// Should NOT show "ask to fix" action for unknown CI
expect(screen.queryByText("ask to fix")).not.toBeInTheDocument();
});

it("shows ask-to-fix based on pr.ciStatus regardless of session status", () => {
// Button is shown when pr.ciStatus=failing even when session is "working"
// (agent already handling it) — human override remains available while CI is red.
const pr = makePR({
state: "open",
ciStatus: "failing",
ciChecks: [{ name: "test", status: "failed" }],
reviewDecision: "approved",
mergeability: {
mergeable: false,
ciPassing: false,
approved: true,
noConflicts: true,
blockers: [],
},
});
const session = makeSession({ status: "working", activity: "active", pr });
render(<SessionCard session={session} />);
expect(screen.getByText("ask to fix")).toBeInTheDocument();
});

it("shows changes requested alert", () => {
Expand Down
35 changes: 31 additions & 4 deletions packages/web/src/app/api/events/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getServices } from "@/lib/services";
import { sessionToDashboard } from "@/lib/serialize";
import { getServices, getSCM } from "@/lib/services";
import { sessionToDashboard, enrichSessionPR, resolveProject } from "@/lib/serialize";
import { getAttentionLevel } from "@/lib/types";
import { filterWorkerSessions } from "@/lib/project-utils";
import {
Expand Down Expand Up @@ -71,14 +71,26 @@ export async function GET(request: Request): Promise<Response> {
}

try {
const { config, sessionManager } = await getServices();
const { config, registry, sessionManager } = await getServices();
const requestedProjectId =
projectFilter && projectFilter !== "all" && config.projects[projectFilter]
? projectFilter
: undefined;
const sessions = await sessionManager.list(requestedProjectId);
const workerSessions = filterWorkerSessions(sessions, projectFilter, config.projects);
const dashboardSessions = workerSessions.map(sessionToDashboard);

// Enrich PR CI data from cache only — no GitHub API calls, just cache lookups.
// This keeps the snapshot current with the latest CI status without adding latency.
for (let i = 0; i < workerSessions.length; i++) {
const core = workerSessions[i];
if (!core?.pr) continue;
const project = resolveProject(core, config.projects);
const scm = getSCM(registry, project);
if (!scm) continue;
await enrichSessionPR(dashboardSessions[i], scm, core.pr, { cacheOnly: true });
}

const projectObserver = ensureObserver(config);

const initialEvent = {
Expand All @@ -91,6 +103,8 @@ export async function GET(request: Request): Promise<Response> {
activity: s.activity,
attentionLevel: getAttentionLevel(s),
lastActivityAt: s.lastActivityAt,
prCiStatus: s.pr?.ciStatus ?? null,
prCiChecks: s.pr?.ciChecks ?? null,
})),
};
controller.enqueue(encoder.encode(`data: ${JSON.stringify(initialEvent)}\n\n`));
Expand Down Expand Up @@ -130,14 +144,25 @@ export async function GET(request: Request): Promise<Response> {
void (async () => {
let dashboardSessions;
try {
const { config, sessionManager } = await getServices();
const { config, registry, sessionManager } = await getServices();
const requestedProjectId =
projectFilter && projectFilter !== "all" && config.projects[projectFilter]
? projectFilter
: undefined;
const sessions = await sessionManager.list(requestedProjectId);
const workerSessions = filterWorkerSessions(sessions, projectFilter, config.projects);
dashboardSessions = workerSessions.map(sessionToDashboard);

// Enrich PR CI data from cache only — no GitHub API calls, just cache lookups.
for (let i = 0; i < workerSessions.length; i++) {
const core = workerSessions[i];
if (!core?.pr) continue;
const project = resolveProject(core, config.projects);
const scm = getSCM(registry, project);
if (!scm) continue;
await enrichSessionPR(dashboardSessions[i], scm, core.pr, { cacheOnly: true });
}

const projectObserver = ensureObserver(config);

if (projectObserver && observerProjectId) {
Expand Down Expand Up @@ -165,6 +190,8 @@ export async function GET(request: Request): Promise<Response> {
activity: s.activity,
attentionLevel: getAttentionLevel(s),
lastActivityAt: s.lastActivityAt,
prCiStatus: s.pr?.ciStatus ?? null,
prCiChecks: s.pr?.ciChecks ?? null,
})),
};
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
Expand Down
17 changes: 14 additions & 3 deletions packages/web/src/components/SessionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -619,16 +619,27 @@ function getAlerts(session: DashboardSession): Alert[] {

const alerts: Alert[] = [];

// Button visibility is driven solely by GitHub CI data (pr.ciStatus), not by
// session lifecycle status. pr.ciStatus is kept current via SSE snapshot patches
// (cache-only enrichment on every 5s poll), so the button reflects the actual
// GitHub state without waiting for the 15s+ stale full-refresh cycle.
if (pr.ciStatus === CI_STATUS.FAILING) {
const failedCheck = pr.ciChecks.find((c) => c.status === "failed");
const failCount = pr.ciChecks.filter((c) => c.status === "failed").length;
if (failCount === 0) {
// GitHub reports CI as failing but individual check details aren't available
// (API inconsistency or enrichment lag). Still offer the action button since
// pr.ciStatus is the authoritative GitHub signal that CI is red.
alerts.push({
key: "ci-unknown",
label: "CI unknown",
key: "ci-fail",
label: "CI failing",
className: "",
color: "var(--color-alert-ci-unknown)",
color: "var(--color-alert-ci)",
borderColor: "var(--color-alert-ci)",
url: pr.url + "/checks",
actionLabel: "ask to fix",
actionMessage: `Please fix the failing CI checks on ${pr.url}`,
actionClassName: "bg-[var(--color-alert-ci-bg)] text-white hover:brightness-110",
});
} else {
alerts.push({
Expand Down
26 changes: 25 additions & 1 deletion packages/web/src/hooks/useSessionEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,24 @@ function reducer(state: State, action: Action): State {
const next = state.sessions.map((s) => {
const patch = patchMap.get(s.id);
if (!patch) return s;

// Check if any field we care about has changed
const prCiStatusChanged =
patch.prCiStatus !== undefined &&
patch.prCiStatus !== null &&
s.pr !== null &&
s.pr?.ciStatus !== patch.prCiStatus;
const prCiChecksChanged =
patch.prCiChecks !== undefined &&
patch.prCiChecks !== null &&
s.pr !== null;

if (
s.status === patch.status &&
s.activity === patch.activity &&
s.lastActivityAt === patch.lastActivityAt
s.lastActivityAt === patch.lastActivityAt &&
!prCiStatusChanged &&
!prCiChecksChanged
) {
return s;
}
Expand All @@ -38,6 +52,16 @@ function reducer(state: State, action: Action): State {
status: patch.status,
activity: patch.activity,
lastActivityAt: patch.lastActivityAt,
// Patch PR CI data when the snapshot includes fresh cache data
...(s.pr && patch.prCiStatus !== null && patch.prCiStatus !== undefined
? {
pr: {
...s.pr,
ciStatus: patch.prCiStatus,
ciChecks: patch.prCiChecks ?? s.pr.ciChecks,
},
}
: {}),
};
});
return changed ? { ...state, sessions: next } : state;
Expand Down
4 changes: 4 additions & 0 deletions packages/web/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ export interface SSESnapshotEvent {
activity: ActivityState | null;
attentionLevel: AttentionLevel;
lastActivityAt: string;
/** Current CI status from GitHub — included when cached PR data is available */
prCiStatus?: DashboardPR["ciStatus"] | null;
/** Current CI checks from GitHub — included when cached PR data is available */
prCiChecks?: DashboardPR["ciChecks"] | null;
}>;
}

Expand Down
Loading