diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 87e493bcb..00312806f 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -44,6 +44,7 @@ import { augmentProcessPathWithShellAndKnownCliDirs, setPathEnvValue } from "../ import { createAgentChatService } from "../../desktop/src/main/services/chat/agentChatService"; import { createOrchestrationService } from "../../desktop/src/main/services/orchestration/orchestrationService"; import type { createPrService } from "../../desktop/src/main/services/prs/prService"; +import { createPrPollingService } from "../../desktop/src/main/services/prs/prPollingService"; import { createPrSummaryService } from "../../desktop/src/main/services/prs/prSummaryService"; import { createQueueLandingService } from "../../desktop/src/main/services/prs/queueLandingService"; import { createCtoStateService } from "../../desktop/src/main/services/cto/ctoStateService"; @@ -70,7 +71,7 @@ import type { createSyncService } from "./services/sync/syncService"; import type { SharedSyncListener } from "./services/sync/sharedSyncListener"; import type { createSyncHostService, SyncRuntimeKind } from "./services/sync/syncHostService"; import { getSharedModelPickerStore } from "./services/modelPickerStore"; -import { createAutomationIngressService } from "../../desktop/src/main/services/automations/automationIngressService"; +import { createAutomationIngressService, createKvIngressCursorStore } from "../../desktop/src/main/services/automations/automationIngressService"; import { createAutomationSecretService } from "../../desktop/src/main/services/automations/automationSecretService"; import { createProjectSecretService } from "../../desktop/src/main/services/secrets/projectSecretService"; import type { createGithubService } from "../../desktop/src/main/services/github/githubService"; @@ -1067,22 +1068,28 @@ export async function createAdeRuntime(args: { }) : null; automationServiceRef = automationService; - const automationSecretService = automationFeatureEnabled - ? createAutomationSecretService({ - adeDir: paths.adeDir, - logger, - }) - : null; - const automationIngressService = automationFeatureEnabled && automationService && automationSecretService - ? createAutomationIngressService({ - logger, - automationService, - prService: headlessLinearServices.prService, - secretService: automationSecretService, - githubService: headlessLinearServices.githubService, - listRules: () => projectConfigService.get().effective.automations ?? [], - }) - : null; + const automationSecretService = createAutomationSecretService({ + adeDir: paths.adeDir, + logger, + }); + // The ingress runs even when the automations feature is unavailable: its + // GitHub relay poll feeds prService.ingestGithubWebhook, which is how + // webhook-driven PR state updates reach installed (non-source) runtimes. + // Automation rule dispatch stays gated on automationService being present. + const automationIngressService = createAutomationIngressService({ + logger, + automationService, + prService: headlessLinearServices.prService, + secretService: automationSecretService, + githubService: headlessLinearServices.githubService, + listRules: () => (automationService ? projectConfigService.get().effective.automations ?? [] : []), + ingressCursorStore: createKvIngressCursorStore(db), + }); + void automationIngressService.start().catch((error) => { + logger.warn("automations.ingress_start_failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); const configReloadService = createConfigReloadService({ paths: { sharedPath: adeProjectService.paths.sharedConfigPath, @@ -1148,6 +1155,32 @@ export async function createAdeRuntime(args: { aiIntegrationService, }); + // GitHub polling fallback. Runtime-bound desktop windows route PR reads to + // this daemon instead of the desktop main process, so the daemon must own + // the background polling loop that emits `prs-updated` — otherwise PR state + // only refreshes when a surface happens to issue a direct read. + const prPollingService = createPrPollingService({ + logger, + prService: headlessLinearServices.prService, + projectConfigService, + db, + onEvent: emitPrEvent, + onPullRequestsChanged: async ({ changedPrs, changes }) => { + if (changedPrs.length > 0) { + headlessLinearServices.prService.markHotRefresh(changedPrs.map((pr) => pr.id)); + } + for (const { pr, previousState, previousChecksStatus, previousReviewStatus } of changes) { + automationService?.onPullRequestChanged?.({ + pr, + previousState, + previousChecksStatus, + previousReviewStatus, + }); + } + }, + }); + prPollingService.start(); + const usageTrackingService = createUsageTrackingService({ logger, pollIntervalMs: 120_000, @@ -1304,6 +1337,7 @@ export async function createAdeRuntime(args: { clearTimeout(staleSessionReconcileTimer); } void configReloadService.dispose().catch(() => {}); + swallow(() => prPollingService.dispose()); swallow(() => automationIngressService?.dispose()); swallow(() => automationService?.dispose()); swallow(() => usageTrackingService.dispose()); diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index c49474c6d..d5c3485a4 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -146,7 +146,7 @@ import { createAutomationService } from "./services/automations/automationServic import { createAutomationPlannerService } from "./services/automations/automationPlannerService"; import { createAutomationSecretService } from "./services/automations/automationSecretService"; import { createProjectSecretService } from "./services/secrets/projectSecretService"; -import { createAutomationIngressService } from "./services/automations/automationIngressService"; +import { createAutomationIngressService, createKvIngressCursorStore } from "./services/automations/automationIngressService"; import { createReviewService } from "./services/review/reviewService"; import { createGithubPollingService } from "./services/automations/githubPollingService"; import type { AutomationAdeActionRegistry } from "./services/automations/automationService"; @@ -351,14 +351,16 @@ const devStabilityMode = process.env.ADE_STABILITY_MODE === "1" || !!process.env.VITE_DEV_SERVER_URL; const enableAllBackgroundTasks = process.env.ADE_ENABLE_ALL_BACKGROUND_TASKS === "1"; -// In dev stability mode, only enable essential background tasks by default. +// In startup stability mode, only enable essential background tasks by default. // Use ADE_ENABLE_ALL_BACKGROUND_TASKS=1 or individual flags to enable others. const defaultEnabledBackgroundTaskFlags = new Set([ "ADE_ENABLE_CONFIG_RELOAD", "ADE_ENABLE_USAGE_TRACKING", "ADE_ENABLE_HEAD_WATCHER", "ADE_ENABLE_PORT_ALLOCATION_RECOVERY", + "ADE_ENABLE_PR_POLLING", "ADE_ENABLE_SYNC_INIT", + "ADE_ENABLE_AUTOMATION_INGRESS", ]); function readString(source: Record | null | undefined, key: string): string | undefined { @@ -3187,16 +3189,18 @@ app.whenReady().then(async () => { prService, onEvent: (event) => emitProjectEvent(projectRoot, IPC.reviewEvent, event), }); - const automationIngressService = automationService - ? createAutomationIngressService({ - logger, - automationService, - prService, - secretService: automationSecretService, - githubService, - listRules: () => projectConfigService.get().effective.automations ?? [], - }) - : null; + // Constructed even when automations are unavailable (packaged builds): + // the relay poll feeds prService.ingestGithubWebhook for PR freshness, + // while automation rule dispatch stays gated on automationService. + const automationIngressService = createAutomationIngressService({ + logger, + automationService: automationService ?? null, + prService, + secretService: automationSecretService, + githubService, + listRules: () => (automationService ? projectConfigService.get().effective.automations ?? [] : []), + ingressCursorStore: createKvIngressCursorStore(db), + }); const githubPollingService = automationService ? createGithubPollingService({ @@ -3752,6 +3756,19 @@ app.whenReady().then(async () => { projectConfigService, usageTrackingService, }); + + scheduleBackgroundProjectTask( + "prs.polling_start", + () => prPollingService.start(), + (error) => { + logger.warn("prs.polling_start_failed", { + error: error instanceof Error ? error.message : String(error), + }); + }, + 0, + "ADE_ENABLE_PR_POLLING", + ); + if (automationIngressService) { scheduleBackgroundProjectTask( "automations.ingress_start", diff --git a/apps/desktop/src/main/services/automations/automationIngressService.test.ts b/apps/desktop/src/main/services/automations/automationIngressService.test.ts index 8de034599..1326650ea 100644 --- a/apps/desktop/src/main/services/automations/automationIngressService.test.ts +++ b/apps/desktop/src/main/services/automations/automationIngressService.test.ts @@ -227,6 +227,134 @@ describe("automationIngressService", () => { })); }); + it("still ingests relay PR webhooks when the automations feature is unavailable", async () => { + const cursors = new Map(); + const ingestGithubWebhook = vi.fn(async () => ({ + processed: true, + duplicate: false, + repoOwner: "arul28", + repoName: "ADE", + githubPrNumber: 687, + linkedPrIds: [], + reason: null, + })); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ + events: [ + { + cursor: "seq:3", + eventId: "delivery-3", + githubEvent: "pull_request", + summary: "GitHub pull_request · closed · arul28/ADE · #687", + createdAt: receivedAt, + payload: { + action: "closed", + repository: { full_name: "arul28/ADE" }, + pull_request: { number: 687, title: "Github Auth Checks Failed", merged: true }, + }, + }, + ], + nextCursor: "seq:3", + }), { headers: { "content-type": "application/json" } })); + + service = createAutomationIngressService({ + logger: makeLogger() as never, + automationService: null, + prService: { ingestGithubWebhook } as never, + secretService: { + getSecret: () => null, + } as never, + githubService: { + detectRepo: vi.fn(async () => ({ owner: "arul28", name: "ADE" })), + getAppUserTokenForRelay: vi.fn(async () => "ghu_app_user_token"), + }, + listRules: () => [], + ingressCursorStore: { + get: (source) => cursors.get(source) ?? null, + set: ({ source, cursor }) => { + cursors.set(source, cursor); + }, + }, + }); + + // start() must not bind the local automation webhook server in this mode. + await service.start(); + expect(fetchSpy).toHaveBeenCalledWith( + "https://ade-github-webhook-relay.arulsharma1028.workers.dev/github/repos/arul28/ADE/events", + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: "Bearer ghu_app_user_token", + }), + }), + ); + expect(ingestGithubWebhook).toHaveBeenCalledWith(expect.objectContaining({ + eventName: "pull_request", + deliveryId: "delivery-3", + payload: expect.objectContaining({ + pull_request: expect.objectContaining({ number: 687 }), + }), + })); + expect(cursors.get("github-relay")).toBe("seq:3"); + expect(service.getStatus()).toBeNull(); + expect(service.listRecentEvents()).toEqual([]); + }); + + it("refuses construction without cursor persistence when automations are unavailable", () => { + expect(() => createAutomationIngressService({ + logger: makeLogger() as never, + automationService: null, + prService: { ingestGithubWebhook: vi.fn() } as never, + secretService: { getSecret: () => null } as never, + listRules: () => [], + })).toThrowError(/ingressCursorStore/); + }); + + it("treats missing GitHub App authorization as quiet auth-pending, not a per-tick error", async () => { + const logger = makeLogger(); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const getAppUserTokenForRelay = vi.fn(async () => { + throw new Error("Authorize the ADE GitHub App with GitHub before using the hosted relay."); + }); + + service = createAutomationIngressService({ + logger: logger as never, + automationService: null, + prService: { ingestGithubWebhook: vi.fn() } as never, + secretService: { + getSecret: () => null, + } as never, + githubService: { + detectRepo: vi.fn(async () => ({ owner: "arul28", name: "ADE" })), + getAppUserTokenForRelay, + }, + listRules: () => [], + ingressCursorStore: { + get: () => null, + set: () => {}, + }, + }); + + await service.start(); + expect(getAppUserTokenForRelay).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledWith("automations.github_relay_auth_pending", expect.objectContaining({ + error: expect.stringContaining("Authorize the ADE GitHub App"), + })); + expect(logger.warn).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + + // Scheduled re-entry inside the cooldown window skips the token attempt. + await service.start(); + expect(getAppUserTokenForRelay).toHaveBeenCalledTimes(1); + + // Explicit pollNow (e.g. right after authorizing) bypasses the cooldown + // and the transition log fires only once. + await service.pollNow(); + expect(getAppUserTokenForRelay).toHaveBeenCalledTimes(2); + const authPendingLogs = (logger.info.mock.calls as unknown[][]) + .filter((call) => call[0] === "automations.github_relay_auth_pending"); + expect(authPendingLogs).toHaveLength(1); + expect(logger.warn).not.toHaveBeenCalled(); + }); + it("can read GitHub relay config from runtime environment variables", async () => { const previousApiBase = process.env.ADE_GITHUB_RELAY_API_BASE_URL; const previousProjectId = process.env.ADE_GITHUB_RELAY_REMOTE_PROJECT_ID; @@ -315,10 +443,12 @@ describe("automationIngressService", () => { await service.pollNow(); expect(fetchSpy).not.toHaveBeenCalled(); + // Missing authorization is an idle "disabled" state (quiet auth-pending + // cooldown), not a recurring error. expect(updates).toContainEqual(expect.objectContaining({ githubRelay: expect.objectContaining({ healthy: false, - status: "error", + status: "disabled", lastError: "Authorize the ADE GitHub App with GitHub before using the hosted relay.", }), })); diff --git a/apps/desktop/src/main/services/automations/automationIngressService.ts b/apps/desktop/src/main/services/automations/automationIngressService.ts index 3610e2500..75bf8d5a5 100644 --- a/apps/desktop/src/main/services/automations/automationIngressService.ts +++ b/apps/desktop/src/main/services/automations/automationIngressService.ts @@ -1,8 +1,9 @@ import { createHmac, timingSafeEqual } from "node:crypto"; import http from "node:http"; import { URL } from "node:url"; -import type { AutomationIngressEventRecord, AutomationIngressStatus, AutomationRule, AutomationTriggerType, GitHubRepoRef } from "../../../shared/types"; +import type { AutomationIngressEventRecord, AutomationIngressSource, AutomationIngressStatus, AutomationRule, AutomationTriggerType, GitHubRepoRef } from "../../../shared/types"; import type { Logger } from "../logging/logger"; +import type { AdeDb } from "../state/kvDb"; import type { createAutomationService } from "./automationService"; import type { AutomationSecretService } from "./automationSecretService"; import type { createPrService } from "../prs/prService"; @@ -14,9 +15,32 @@ import { shouldUseLegacyGitHubRelayProjectRoute, } from "../github/githubRelayConfig"; +export type AutomationIngressCursorStore = { + get(source: AutomationIngressSource): string | null; + set(args: { source: AutomationIngressSource; cursor: string | null }): void; +}; + +// Canonical kv-backed cursor store. The key template is a persistence +// contract — keep it defined once here so desktop and daemon wiring cannot +// drift apart (a drifted key silently resets the relay cursor). +export function createKvIngressCursorStore( + db: Pick, +): AutomationIngressCursorStore { + const key = (source: AutomationIngressSource) => `automations.ingress.cursor.${source}`; + return { + get: (source) => db.getJson(key(source)), + set: ({ source, cursor }) => db.setJson(key(source), cursor), + }; +} + type AutomationIngressServiceArgs = { logger: Logger; - automationService: ReturnType; + // Null when the automations feature is unavailable (packaged builds, + // installed daemons). The ingress still polls the GitHub relay and feeds + // prService.ingestGithubWebhook so PR state stays fresh; only automation + // rule dispatch, the local webhook server, and ingress status reporting + // require the automation service. + automationService: ReturnType | null; prService?: ReturnType | null; secretService: AutomationSecretService; githubService?: { @@ -24,6 +48,10 @@ type AutomationIngressServiceArgs = { getAppUserTokenForRelay: () => Promise; } | null; listRules: () => AutomationRule[]; + // Cursor persistence fallback. REQUIRED when automationService is null — + // without it the relay cursor would silently reset on every restart + // (enforced at construction). + ingressCursorStore?: AutomationIngressCursorStore | null; pollIntervalMs?: number; }; @@ -236,26 +264,51 @@ function mapGithubWebhookToTrigger(githubEvent: string, payload: Record | null = null; + // When the hosted relay needs a GitHub App user token that the user has not + // granted yet, that is an idle state, not an error: skip polling for a + // while and log the transition once instead of warning every tick. + let hostedAuthPendingUntilMs = 0; + let hostedAuthPendingLogged = false; const auditHostedRelayAuthTokenUse = createGitHubRelayAuthAuditLog( (event, metadata) => args.logger.info(event, metadata), ); const updateGithubRelayStatus = (patch: Partial) => { - args.automationService.updateIngressStatus({ + args.automationService?.updateIngressStatus({ githubRelay: patch, }); }; const updateLocalWebhookStatus = (patch: Partial) => { - args.automationService.updateIngressStatus({ + args.automationService?.updateIngressStatus({ localWebhook: patch, }); }; + const getIngressCursor = (source: AutomationIngressSource): string | null => { + if (args.automationService) return args.automationService.getIngressCursor(source); + return args.ingressCursorStore?.get(source) ?? null; + }; + + const setIngressCursor = (entry: { source: AutomationIngressSource; cursor: string | null }): void => { + if (args.automationService) { + args.automationService.setIngressCursor(entry); + return; + } + args.ingressCursorStore?.set(entry); + }; + const buildGithubRelayConfig = () => { return readGitHubRelayConfig((ref) => args.secretService.getSecret(ref)); }; @@ -296,6 +349,7 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg }); }); + if (!args.automationService) return null; const mapped = mapGithubWebhookToTrigger(githubEvent, payload); if (!mapped) { return await args.automationService.dispatchIngressTrigger({ @@ -327,6 +381,9 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg }; const dispatchLocalWebhook = async (automationId: string, payload: Record, rawBody: Buffer): Promise => { + if (!args.automationService) { + throw new Error("Automations are not available in this runtime."); + } const trigger = findTrigger(automationId, "webhook"); if (!trigger?.secretRef?.trim()) { throw new Error(`Automation '${automationId}' is missing webhook secretRef.`); @@ -426,6 +483,10 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg const pollGithubRelay = async () => { const config = buildGithubRelayConfig(); + const useLegacyProjectRoute = shouldUseLegacyGitHubRelayProjectRoute(config); + // Skip before the "polling" status write so the "disabled" + auth-error + // status reported at cooldown entry stays accurate for the whole window. + if (config.configured && !useLegacyProjectRoute && Date.now() < hostedAuthPendingUntilMs) return; updateGithubRelayStatus({ configured: config.configured, apiBaseUrl: config.apiBaseUrl, @@ -434,10 +495,9 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg }); if (!config.configured) return; try { - const cursor = args.automationService.getIngressCursor("github-relay"); + const cursor = getIngressCursor("github-relay"); const baseUrl = config.apiBaseUrl!.replace(/\/+$/, ""); const legacyAuthToken = gitHubRelayAuthorizationToken(config); - const useLegacyProjectRoute = shouldUseLegacyGitHubRelayProjectRoute(config); const repo = useLegacyProjectRoute ? null : await args.githubService?.detectRepo(); const eventsUrl = useLegacyProjectRoute ? new URL(`${baseUrl}/projects/${encodeURIComponent(config.remoteProjectId!)}/github/events`) @@ -456,13 +516,34 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg return; } if (cursor) eventsUrl.searchParams.set("after", cursor); - const githubAppUserToken = useLegacyProjectRoute ? null : await args.githubService?.getAppUserTokenForRelay(); + let githubAppUserToken: string | null = null; + if (!useLegacyProjectRoute) { + try { + githubAppUserToken = (await args.githubService?.getAppUserTokenForRelay()) ?? null; + } catch (error) { + hostedAuthPendingUntilMs = Date.now() + HOSTED_RELAY_AUTH_PENDING_RETRY_MS; + const message = error instanceof Error ? error.message : String(error); + if (!hostedAuthPendingLogged) { + hostedAuthPendingLogged = true; + args.logger.info("automations.github_relay_auth_pending", { error: message }); + } + updateGithubRelayStatus({ + healthy: false, + status: "disabled", + lastPolledAt: new Date().toISOString(), + lastError: message, + }); + return; + } + } const hostedAuth = useLegacyProjectRoute ? null : resolveHostedGitHubRelayAuthToken({ githubAppUserToken }); if (hostedAuth && !hostedAuth.ok) { throw new Error(hostedAuth.error); } + hostedAuthPendingUntilMs = 0; + hostedAuthPendingLogged = false; const authToken = useLegacyProjectRoute ? legacyAuthToken : hostedAuth?.token ?? null; if (!authToken) { throw new Error("GitHub auth is required for relay polling."); @@ -523,7 +604,7 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg error: error instanceof Error ? error.message : String(error), }); }); - await args.automationService.dispatchIngressTrigger({ + await args.automationService?.dispatchIngressTrigger({ source: "github-relay", eventKey: eventId, triggerType: "github-webhook", @@ -540,7 +621,7 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg : null; if (responseCursor) lastSeenCursor = responseCursor; if (lastSeenCursor && lastSeenCursor !== cursor) { - args.automationService.setIngressCursor({ source: "github-relay", cursor: lastSeenCursor }); + setIngressCursor({ source: "github-relay", cursor: lastSeenCursor }); } updateGithubRelayStatus({ healthy: true, @@ -573,7 +654,9 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg return { async start() { - if (!server) { + // The local webhook server exists to receive automation webhooks; in + // PR-freshness-only mode (no automation service) only the relay poll runs. + if (!server && args.automationService) { server = http.createServer((request, response) => { void handleWebhookRequest(request, response); }); @@ -603,14 +686,17 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg }, getStatus() { - return args.automationService.getIngressStatus(); + return args.automationService?.getIngressStatus() ?? null; }, listRecentEvents(limit = 20) { - return args.automationService.listIngressEvents(limit); + return args.automationService?.listIngressEvents(limit) ?? []; }, async pollNow() { + // Explicit polls (e.g. right after the user authorizes the GitHub App) + // bypass the auth-pending cooldown. + hostedAuthPendingUntilMs = 0; await pollGithubRelayOnce(); }, diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 263c2dfec..6947d3c5b 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -8119,8 +8119,8 @@ export function registerIpc({ }; ipcMain.handle(IPC.prsGetForLane, async (_event, arg: { laneId: string }): Promise => { - const ctx = getCtx(); - if (!ctx.prService) return null; + const ctx = ensurePrPolling(); + if (!ctx) return null; return ctx.prService.getForLane(arg.laneId); }); diff --git a/apps/desktop/src/main/services/prs/prAsync.test.ts b/apps/desktop/src/main/services/prs/prAsync.test.ts index 170d62146..06cc720a9 100644 --- a/apps/desktop/src/main/services/prs/prAsync.test.ts +++ b/apps/desktop/src/main/services/prs/prAsync.test.ts @@ -159,6 +159,48 @@ describe("prPollingService", () => { })); }); + it("throttles empty-cache discovery to a slow cadence and stays idle-cheap", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-24T12:00:00.000Z")); + vi.spyOn(Math, "random").mockReturnValue(0.5); + + const events: Array<{ type: string }> = []; + const prService = { + listAll: () => [], + discoverLanePullRequests: vi.fn(async () => []), + refresh: vi.fn(async () => []), + getHotRefreshDelayMs: () => null, + getHotRefreshPrIds: () => [], + } as any; + + const service = createPrPollingService({ + logger: createLogger() as any, + prService, + projectConfigService: { get: () => ({ effective: {} }) } as any, + onEvent: (event) => events.push(event), + }); + + service.start(); + await vi.advanceTimersByTimeAsync(12_000); + expect(prService.discoverLanePullRequests).toHaveBeenCalledTimes(1); + + // Nine more 60s ticks stay inside the 10-minute discovery window: no + // repeated forced snapshot fetches while the project has no tracked PRs. + for (let index = 0; index < 9; index += 1) { + await vi.advanceTimersByTimeAsync(60_000); + } + expect(prService.discoverLanePullRequests).toHaveBeenCalledTimes(1); + + // The tick that crosses the 10-minute mark discovers again. + await vi.advanceTimersByTimeAsync(60_000); + expect(prService.discoverLanePullRequests).toHaveBeenCalledTimes(2); + + // The empty path never runs a tracked-PR refresh and emits a single + // empty prs-updated event, not one per tick. + expect(prService.refresh).not.toHaveBeenCalled(); + expect(events.filter((event) => event.type === "prs-updated")).toHaveLength(1); + }); + it("keeps rate-limit backoff ahead of hot wakeups", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-24T12:00:00.000Z")); diff --git a/apps/desktop/src/main/services/prs/prPollingService.ts b/apps/desktop/src/main/services/prs/prPollingService.ts index 369127445..6bf4b016e 100644 --- a/apps/desktop/src/main/services/prs/prPollingService.ts +++ b/apps/desktop/src/main/services/prs/prPollingService.ts @@ -82,6 +82,9 @@ export function createPrPollingService({ const DEFAULT_INTERVAL_MS = 60_000; const MIN_INTERVAL_MS = 5_000; const MAX_INTERVAL_MS = 5 * 60_000; + const EMPTY_DISCOVERY_MIN_INTERVAL_MS = 10 * 60_000; + // Epoch (not "never") so the first tick after start still discovers. + let lastEmptyDiscoveryAtMs = 0; const readIntervalMs = (): number => { const seconds = projectConfigService.get().effective.github?.prPollingIntervalSeconds; @@ -167,10 +170,17 @@ export function createPrPollingService({ try { let existing = prService.listAll(); if (existing.length === 0) { - try { - existing = await prService.discoverLanePullRequests(); - } catch (error) { - logger.warn("prs.discovery_failed", { error: error instanceof Error ? error.message : String(error) }); + // Discovery force-refreshes the whole repo snapshot, which is far + // heavier than a tracked-PR delta poll. With zero tracked PRs (new + // users, non-PR projects) run it on a slow cadence instead of every + // tick — user-driven surfaces discover PRs on their own reads anyway. + if (Date.now() - lastEmptyDiscoveryAtMs >= EMPTY_DISCOVERY_MIN_INTERVAL_MS) { + lastEmptyDiscoveryAtMs = Date.now(); + try { + existing = await prService.discoverLanePullRequests(); + } catch (error) { + logger.warn("prs.discovery_failed", { error: error instanceof Error ? error.message : String(error) }); + } } } if (existing.length === 0) { diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts b/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts index 679034253..05bd44404 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts @@ -499,6 +499,7 @@ describe("selectLaneTabPrTag", () => { id: "mapped-pr", state: "closed", githubPrNumber: 123, + githubUrl: "https://github.com/arul28/ADE/pull/123", updatedAt: "2026-05-01T00:00:00.000Z", }); const githubPr = makeGitHubPr({ @@ -564,6 +565,25 @@ describe("selectLaneTabPrTag", () => { }); }); + it("keeps a terminal ADE row over a stale open GitHub snapshot for the same PR", () => { + const mappedPr = makePr({ id: "mapped-pr", state: "merged" }); + const githubPr = makeGitHubPr({ + id: "github-pr", + state: "open", + linkedPrId: "mapped-pr", + linkedLaneId: "lane-1", + title: "Stale open snapshot", + }); + + expect(selectLaneTabPrTag(makeLane(), [mappedPr], [githubPr])).toMatchObject({ + source: "ade", + id: "mapped-pr", + linkedPrId: "mapped-pr", + state: "merged", + title: "Show merged PR state", + }); + }); + it("keeps the ADE row when the GitHub match is not the same PR", () => { const mappedPr = makePr({ id: "mapped-pr", state: "open", githubPrNumber: 224 }); const githubPr = makeGitHubPr({ diff --git a/apps/desktop/src/renderer/components/lanes/lanePageModel.ts b/apps/desktop/src/renderer/components/lanes/lanePageModel.ts index 52d34fce3..2dd6c39e4 100644 --- a/apps/desktop/src/renderer/components/lanes/lanePageModel.ts +++ b/apps/desktop/src/renderer/components/lanes/lanePageModel.ts @@ -10,6 +10,7 @@ import type { } from "../../../shared/types"; import type { CreateLaneMode } from "./CreateLaneDialog"; import { mergeUnique } from "./laneUtils"; +import { isTerminalPrState } from "../../lib/prState"; type CreateLaneRequest = | { kind: "child"; args: { name: string; parentLaneId: string } } @@ -334,10 +335,6 @@ function mergeLaneTabPrTags(base: LaneTabPrTag, secondary: LaneTabPrTag | null): }; } -function isTerminalPrState(state: PrSummary["state"]): boolean { - return state === "merged" || state === "closed"; -} - function githubPrMatchesAdePr(pr: PrSummary, githubPr: GitHubPrListItem): boolean { return ( githubPr.linkedPrId === pr.id || @@ -365,8 +362,13 @@ function shouldPreferGithubPrTag( githubPr: GitHubPrListItem, ): boolean { const githubState = githubPr.isDraft ? "draft" : githubPr.state; - if (githubPrMatchesAdePr(pr, githubPr) && githubState !== pr.state) return true; - return isTerminalPrState(pr.state) && !isTerminalPrState(githubState); + if (!githubPrMatchesAdePr(pr, githubPr)) { + return isTerminalPrState(pr.state) && !isTerminalPrState(githubState); + } + if (isTerminalPrState(pr.state) && !isTerminalPrState(githubState)) { + return false; + } + return githubState !== pr.state; } export function selectLaneTabPrTag( diff --git a/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.test.tsx b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.test.tsx index 341dcc59e..330e8a95d 100644 --- a/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.test.tsx +++ b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.test.tsx @@ -232,9 +232,9 @@ describe("GitHubTab", () => { beforeEach(() => { mockUsePrs.mockReturnValue({ prs: [ - { id: "pr-open", checksStatus: "pending", reviewStatus: "requested", additions: 12, deletions: 3 }, - { id: "pr-merged", checksStatus: "passing", reviewStatus: "approved", additions: 5, deletions: 1 }, - { id: "pr-queue", checksStatus: "passing", reviewStatus: "approved", additions: 7, deletions: 2 }, + { id: "pr-open", state: "open", checksStatus: "pending", reviewStatus: "requested", additions: 12, deletions: 3 }, + { id: "pr-merged", state: "merged", checksStatus: "passing", reviewStatus: "approved", additions: 5, deletions: 1 }, + { id: "pr-queue", state: "open", checksStatus: "passing", reviewStatus: "approved", additions: 7, deletions: 2 }, ] satisfies Partial[], mergeContextByPrId: { "pr-queue": { groupType: "queue", groupId: "queue-group-1", members: [] }, @@ -963,6 +963,58 @@ describe("GitHubTab", () => { }); }); + it("places a linked PR in merged when local state is terminal and the GitHub snapshot is stale open", async () => { + const user = userEvent.setup(); + const staleOpenSnapshot: GitHubPrSnapshot = { + ...snapshot, + repoPullRequests: [ + makeGitHubPr({ + id: "repo-stale-open-merged", + githubPrNumber: 102, + githubUrl: "https://github.com/ade-dev/ade/pull/102", + title: "Stale open snapshot", + state: "open", + linkedPrId: "pr-merged", + linkedLaneId: "lane-merged", + linkedLaneName: "lane-merged", + }), + ], + externalPullRequests: [], + history: { + includeExternalClosed: false, + pageLimit: 0, + repoPullRequestsLoaded: 1, + repoPullRequestsMayHaveMore: false, + // Server-side totals still bucket the stale row under "open"; the + // badge counts must follow the reconciled state instead. + repoPullRequestCounts: { + open: 5, + merged: 3, + closed: 2, + }, + }, + }; + (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(staleOpenSnapshot); + + renderTab(); + + await waitFor(() => { + expect(window.ade.prs.getGitHubSnapshot).toHaveBeenCalledWith({ force: false }); + }); + expect(screen.queryByText("Stale open snapshot")).toBeNull(); + + // The reconciled row moves from open to merged in the badge totals too. + expect(within(screen.getByRole("button", { name: /^open/i })).getByText("4")).toBeTruthy(); + expect(within(screen.getByRole("button", { name: /^merged/i })).getByText("4")).toBeTruthy(); + expect(within(screen.getByRole("button", { name: /^closed/i })).getByText("2")).toBeTruthy(); + + await user.click(screen.getByRole("button", { name: /^merged/i })); + + await waitFor(() => { + expect(screen.getByText("Stale open snapshot")).toBeTruthy(); + }); + }); + it("shows linked and unmapped PRs together under the status tabs", async () => { const snapshotWithUnlinked: GitHubPrSnapshot = { ...snapshot, diff --git a/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx index ea95f7646..7b5244529 100644 --- a/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx +++ b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx @@ -29,6 +29,7 @@ import type { PrDetailRouteTab } from "../prsRouteState"; import { GitHubRepoSyncBar } from "../shared/GitHubRepoSyncBar"; import { GitHubPrSearchInput } from "../shared/GitHubPrSearchInput"; import { getGitHubSnapshotCoalesced } from "../../../lib/prReadCache"; +import { isTerminalPrState } from "../../../lib/prState"; const VIRTUALIZE_AT = 50; const LINKED_HYDRATION_LIMIT = 8; @@ -283,6 +284,22 @@ function upsertLaneSummary(lanes: LaneSummary[], lane: LaneSummary): LaneSummary return next; } +function isKnownPrState(value: unknown): value is PrSummary["state"] { + return value === "draft" || value === "open" || value === "merged" || value === "closed"; +} + +function reconcileLinkedPrState(item: GitHubPrListItem, linkedPr: PrSummary | null | undefined): GitHubPrListItem { + if (!isKnownPrState(linkedPr?.state)) return item; + if (!isTerminalPrState(linkedPr.state) || isTerminalPrState(item.state)) return item; + return { + ...item, + state: linkedPr.state, + isDraft: false, + title: linkedPr.title || item.title, + updatedAt: linkedPr.updatedAt || item.updatedAt, + }; +} + function matchesFilter(item: GitHubPrListItem, filter: GitHubFilter): boolean { if (filter === "open") return item.state === "open" || item.state === "draft"; return item.state === filter; @@ -955,26 +972,39 @@ export function GitHubTab({ () => (snapshot ? mergeGitHubListItems(snapshot) : []), [snapshot], ); + const displayedItems = React.useMemo( + () => allItems.map((item) => + reconcileLinkedPrState(item, item.linkedPrId ? prsByIdMap.get(item.linkedPrId) : null) + ), + [allItems, prsByIdMap], + ); const filteredItems = React.useMemo( - () => allItems + () => displayedItems .filter((item) => matchesFilter(item, filter) && matchesSearch(item)) .sort((a, b) => new Date(b.updatedAt || b.createdAt).getTime() - new Date(a.updatedAt || a.createdAt).getTime(), ), - [allItems, filter, matchesSearch], + [displayedItems, filter, matchesSearch], ); const hydrationItems = filteredItems.length > VIRTUALIZE_AT ? renderedHydrationItems : filteredItems; const filterCounts = React.useMemo(() => { - const listedCounts = countGitHubItemsByState(allItems); + const listedCounts = countGitHubItemsByState(displayedItems); const snapshotCounts = snapshot?.history?.repoPullRequestCounts; + // Snapshot totals were computed server-side from the raw snapshot, so a + // row that reconciliation moved between states (stale open → merged) + // must move in the badge totals too — otherwise the item changes tabs + // while the counts still bucket it under its stale state. + const rawCounts = countGitHubItemsByState(allItems); + const withReconcileDelta = (base: number | null | undefined, key: keyof GitHubFilterCounts, fallback: number): number => + base == null ? fallback : Math.max(0, base + listedCounts[key] - rawCounts[key]); return { - open: snapshotCounts?.open ?? listedCounts.open, - closed: snapshotCounts?.closed ?? listedCounts.closed, - merged: snapshotCounts?.merged ?? listedCounts.merged, + open: withReconcileDelta(snapshotCounts?.open, "open", listedCounts.open), + closed: withReconcileDelta(snapshotCounts?.closed, "closed", listedCounts.closed), + merged: withReconcileDelta(snapshotCounts?.merged, "merged", listedCounts.merged), }; - }, [allItems, snapshot?.history?.repoPullRequestCounts]); + }, [allItems, displayedItems, snapshot?.history?.repoPullRequestCounts]); const canLoadOlderHistory = filter !== "open" && Boolean(snapshot?.history?.repoPullRequestsMayHaveMore) @@ -991,7 +1021,7 @@ export function GitHubTab({ return; } - const linkedItem = allItems.find((item) => item.linkedPrId === selectedPrId); + const linkedItem = displayedItems.find((item) => item.linkedPrId === selectedPrId); if (!linkedItem) { pendingSelectedItemIdRef.current = null; return; @@ -1005,7 +1035,7 @@ export function GitHubTab({ } setSelectedItemId(linkedItem.id); hasInitializedSelectionRef.current = true; - }, [allItems, snapshot, selectedPrId, filter]); + }, [displayedItems, snapshot, selectedPrId, filter]); React.useEffect(() => { if (!snapshot) return; @@ -1031,10 +1061,10 @@ export function GitHubTab({ const selectedItem = React.useMemo( () => { - const item = allItems.find((candidate) => candidate.id === selectedItemId) ?? null; + const item = displayedItems.find((candidate) => candidate.id === selectedItemId) ?? null; return item && matchesFilter(item, filter) ? item : null; }, - [allItems, filter, selectedItemId], + [displayedItems, filter, selectedItemId], ); React.useEffect(() => { @@ -1256,7 +1286,7 @@ export function GitHubTab({ } const cachedSelectedItemId = selectedItemIdsByFilter[state] ?? null; const cachedSelectedItem = cachedSelectedItemId - ? allItems.find((item) => item.id === cachedSelectedItemId) ?? null + ? displayedItems.find((item) => item.id === cachedSelectedItemId) ?? null : null; const nextSelectedItemId = cachedSelectedItem && !matchesFilter(cachedSelectedItem, state) ? null @@ -1274,7 +1304,7 @@ export function GitHubTab({ onSelectPr(nextSelectedItem?.linkedPrId ?? null); } setLinkLaneId(""); - }, [allItems, filter, onSelectPr, selectedItemId, selectedItemIdsByFilter]); + }, [displayedItems, filter, onSelectPr, selectedItemId, selectedItemIdsByFilter]); const handleLink = React.useCallback(async () => { if (!selectedItem || !linkLaneId) return; diff --git a/apps/desktop/src/renderer/lib/prState.ts b/apps/desktop/src/renderer/lib/prState.ts new file mode 100644 index 000000000..4ba8c39d6 --- /dev/null +++ b/apps/desktop/src/renderer/lib/prState.ts @@ -0,0 +1,8 @@ +import type { PrSummary } from "../../shared/types"; + +// Terminal PR states can never advance on GitHub (merged is permanent; closed +// only leaves via an explicit reopen), so surfaces treat a terminal local +// state as authoritative over a stale non-terminal GitHub snapshot row. +export function isTerminalPrState(state: PrSummary["state"]): boolean { + return state === "merged" || state === "closed"; +} diff --git a/apps/ios/ADE/Views/Lanes/LaneHelpers.swift b/apps/ios/ADE/Views/Lanes/LaneHelpers.swift index 1aebfa788..13e18e064 100644 --- a/apps/ios/ADE/Views/Lanes/LaneHelpers.swift +++ b/apps/ios/ADE/Views/Lanes/LaneHelpers.swift @@ -475,8 +475,15 @@ private func selectTerminalGithubUpdate( private func shouldPreferGithubTag(_ pr: PullRequestListItem, _ githubPr: GitHubPrListItem) -> Bool { let githubState = githubPr.isDraft ? "draft" : githubPr.state - if githubPrMatchesAdePr(pr, githubPr), githubState != pr.state { return true } - return lanePrIsTerminalState(pr.state) && !lanePrIsTerminalState(githubState) + guard githubPrMatchesAdePr(pr, githubPr) else { + return lanePrIsTerminalState(pr.state) && !lanePrIsTerminalState(githubState) + } + // A terminal ADE state (merged/closed) can never be superseded by a stale + // non-terminal GitHub snapshot for the SAME PR, so keep the ADE row. + if lanePrIsTerminalState(pr.state), !lanePrIsTerminalState(githubState) { + return false + } + return githubState != pr.state } /// Resolve the single PR tag for a lane, preferring the ADE-mapped PR but diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 72586ed97..429272db9 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -6742,6 +6742,19 @@ final class ADETests: XCTestCase { XCTAssertEqual(terminalUpdate?.state, "merged") XCTAssertEqual(terminalUpdate?.prId, "pr-open") + // Inverse of the terminal-update case: the ADE row is already terminal + // (merged) while a stale GitHub snapshot still reports the SAME PR "open". + // The terminal ADE state must win — the stale non-terminal GitHub state + // must not override it. + let staleOpenSnapshot = selectLaneTabPrTag( + lane: lane, + pullRequests: [adePr(id: "pr-merged", number: 561, state: "merged")], + githubPrs: [githubPr(number: 561, state: "open", headBranch: "ade/mobile-audit-34b23435", linkedLaneId: nil, linkedPrId: nil)] + ) + XCTAssertEqual(staleOpenSnapshot?.source, .ade) + XCTAssertEqual(staleOpenSnapshot?.state, "merged") + XCTAssertEqual(staleOpenSnapshot?.prId, "pr-merged") + // A GitHub PR on a different branch must not tag this lane. let unrelated = selectLaneTabPrTag( lane: lane, diff --git a/apps/webhook-relay/src/relay.ts b/apps/webhook-relay/src/relay.ts index 3def8c454..905b1d239 100644 --- a/apps/webhook-relay/src/relay.ts +++ b/apps/webhook-relay/src/relay.ts @@ -27,12 +27,15 @@ type CursorRow = { type GitHubRepoAccessStatus = | { authorized: true; + repositoryId: number | null; } | { authorized: false; response: Response; }; +type GitHubRepoAccessLevel = "write" | "admin"; + type GitHubApiJsonResponse = { status: number; ok: boolean; @@ -297,6 +300,18 @@ function routeRepoEvents(pathname: string): { owner: string; name: string } | nu return null; } +function routeRepoWebhookAdmin(pathname: string): { owner: string; name: string; action: "heal" | "deliveries" } | null { + const parts = pathname.split("/").filter(Boolean); + if (parts.length === 6 && parts[0] === "github" && parts[1] === "repos" && parts[4] === "webhook") { + const owner = decodeURIComponent(parts[2] ?? "").trim(); + const name = decodeURIComponent(parts[3] ?? "").trim(); + if (owner && name && (parts[5] === "heal" || parts[5] === "deliveries")) { + return { owner, name, action: parts[5] }; + } + } + return null; +} + function routeRepoStatus(pathname: string): { projectId: string | null; owner: string; name: string } | null { const parts = pathname.split("/").filter(Boolean); if (parts.length === 7 && parts[0] === "projects" && parts[2] === "github" && parts[3] === "repos" && parts[6] === "status") { @@ -344,6 +359,7 @@ async function assertGitHubRepoAuthorized( request: Request, env: RelayEnv, repo: { owner: string; name: string }, + level: GitHubRepoAccessLevel = "write", ): Promise { const token = readBearerToken(request); if (!token) { @@ -360,20 +376,21 @@ async function assertGitHubRepoAuthorized( token, ); if (repoResponse.ok) { + const repositoryId = readRepositoryId(repoResponse.payload); const permissions = readNested(repoResponse.payload, "permissions"); if (permissions) { - if (repoPermissionsAllowWebhookRead(permissions)) return { authorized: true }; + if (repoPermissionsAllowAccess(permissions, level)) return { authorized: true, repositoryId }; return { authorized: false, - response: insufficientRepoPermissionResponse(), + response: insufficientRepoPermissionResponse(level), }; } - const fallback = await fetchAuthenticatedUserRepoPermission(apiBaseUrl, token, repo); - if (fallback === "authorized") return { authorized: true }; + const fallback = await fetchAuthenticatedUserRepoPermission(apiBaseUrl, token, repo, level); + if (fallback === "authorized") return { authorized: true, repositoryId }; return { authorized: false, - response: insufficientRepoPermissionResponse(), + response: insufficientRepoPermissionResponse(level), }; } @@ -402,28 +419,36 @@ async function fetchGitHubApiJson(apiBaseUrl: string, path: string, token: strin }; } -function repoPermissionsAllowWebhookRead(permissions: Record): boolean { +function readRepositoryId(payload: Record): number | null { + const raw = Number(payload.id); + return Number.isFinite(raw) ? Math.trunc(raw) : null; +} + +function repoPermissionsAllowAccess(permissions: Record, level: GitHubRepoAccessLevel): boolean { + if (level === "admin") return readBoolean(permissions, "admin") === true; return readBoolean(permissions, "admin") === true || readBoolean(permissions, "push") === true || readBoolean(permissions, "maintain") === true; } -function collaboratorPermissionAllowsWebhookRead(permission: string): boolean { +function collaboratorPermissionAllowsAccess(permission: string, level: GitHubRepoAccessLevel): boolean { const normalized = permission.trim().toLowerCase(); + if (level === "admin") return normalized === "admin"; return normalized === "admin" || normalized === "write" || normalized === "maintain"; } -function insufficientRepoPermissionResponse(): Response { - return json( - { ok: false, error: "GitHub token must have push/write, maintain, or admin access to read ADE webhook deliveries for this repository." }, - { status: 403 }, - ); +function insufficientRepoPermissionResponse(level: GitHubRepoAccessLevel): Response { + const error = level === "admin" + ? "GitHub token must have admin access to manage the ADE webhook for this repository." + : "GitHub token must have push/write, maintain, or admin access to read ADE webhook deliveries for this repository."; + return json({ ok: false, error }, { status: 403 }); } async function fetchAuthenticatedUserRepoPermission( apiBaseUrl: string, token: string, repo: { owner: string; name: string }, + level: GitHubRepoAccessLevel, ): Promise<"authorized" | "denied"> { const userResponse = await fetchGitHubApiJson(apiBaseUrl, "/user", token); const login = readString(userResponse.payload, "login"); @@ -437,7 +462,7 @@ async function fetchAuthenticatedUserRepoPermission( if (!permissionResponse.ok) return "denied"; const permission = readString(permissionResponse.payload, "permission"); - return permission && collaboratorPermissionAllowsWebhookRead(permission) ? "authorized" : "denied"; + return permission && collaboratorPermissionAllowsAccess(permission, level) ? "authorized" : "denied"; } function repositoryFullName(payload: Record): string | null { @@ -1090,6 +1115,137 @@ async function handleListRepoEvents(request: Request, env: RelayEnv, repo: { own }); } +function githubApiBaseUrl(env: RelayEnv): string { + return (env.GITHUB_API_BASE_URL?.trim() || "https://api.github.com").replace(/\/+$/, ""); +} + +async function createAppJwtOrErrorResponse(env: RelayEnv): Promise<{ jwt: string } | { response: Response }> { + const appId = env.GITHUB_APP_ID?.trim(); + const privateKey = env.GITHUB_APP_PRIVATE_KEY?.trim(); + if (!appId || !privateKey) { + return { response: json({ ok: false, error: "GitHub App credentials are not configured on the relay." }, { status: 503 }) }; + } + try { + return { jwt: await createGitHubAppJwt(appId, privateKey) }; + } catch (error) { + return { + response: json( + { ok: false, error: `GitHub App JWT could not be created: ${error instanceof Error ? error.message : String(error)}` }, + { status: 502 }, + ), + }; + } +} + +// Re-syncs the GitHub App's webhook secret to this worker's GITHUB_WEBHOOK_SECRET. +// This is the recovery path for signature-mismatch drift (secret rotated on one +// side only): it can only converge the pair onto the worker's current secret, +// never set an arbitrary value, so repeated calls are idempotent. +async function handleWebhookHeal(request: Request, env: RelayEnv, repo: { owner: string; name: string }): Promise { + if (request.method !== "POST") return text("method not allowed", 405); + const webhookSecret = String(env.GITHUB_WEBHOOK_SECRET ?? "").trim(); + if (!webhookSecret) return json({ ok: false, error: "GitHub webhook secret is not configured" }, { status: 503 }); + + const auth = await assertGitHubRepoAuthorized(request, env, repo, "admin"); + if (!auth.authorized) return auth.response; + + const appAuth = await createAppJwtOrErrorResponse(env); + if ("response" in appAuth) return appAuth.response; + + const appStatus = await fetchGitHubAppApiStatus(env, repo); + if (!appStatus.configured || !appStatus.installed) { + const error = appStatus.configured && !appStatus.installed && appStatus.error + ? appStatus.error + : "The ADE GitHub App is not installed on this repository."; + return json({ ok: false, error }, { status: 409 }); + } + + const response = await fetch(`${githubApiBaseUrl(env)}/app/hook/config`, { + method: "PATCH", + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${appAuth.jwt}`, + "content-type": "application/json", + "user-agent": "ADE GitHub Webhook Relay", + "x-github-api-version": "2022-11-28", + }, + body: JSON.stringify({ secret: webhookSecret }), + }); + const payload = (await response.json().catch(() => ({}))) as Record; + if (!response.ok) { + const message = (isRecord(payload) && readString(payload, "message")) + || `GitHub App webhook config update failed with HTTP ${response.status}.`; + return json({ ok: false, error: message }, { status: 502 }); + } + return json({ + ok: true, + healed: true, + webhookUrl: isRecord(payload) ? readString(payload, "url") || null : null, + contentType: isRecord(payload) ? readString(payload, "content_type") || null : null, + checkedAt: new Date().toISOString(), + }); +} + +// Proxies the GitHub App's webhook delivery log, filtered to the requested +// repository, so delivery failures (signature mismatch, timeouts) are +// observable without access to the GitHub App settings UI. +async function handleWebhookDeliveries(request: Request, env: RelayEnv, repo: { owner: string; name: string }): Promise { + if (request.method !== "GET") return text("method not allowed", 405); + + const auth = await assertGitHubRepoAuthorized(request, env, repo); + if (!auth.authorized) return auth.response; + + const appAuth = await createAppJwtOrErrorResponse(env); + if ("response" in appAuth) return appAuth.response; + + // `limit` bounds the per-repo result, not the GitHub fetch: the delivery log + // is app-wide, so always pull the max page and apply the caller's limit + // after the repo filter — otherwise busy sibling repos could starve the + // requested repo out of a small page. + const limit = Math.min(100, parseLimit(new URL(request.url))); + const response = await fetch(`${githubApiBaseUrl(env)}/app/hook/deliveries?per_page=100`, { + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${appAuth.jwt}`, + "user-agent": "ADE GitHub Webhook Relay", + "x-github-api-version": "2022-11-28", + }, + }); + const payload = (await response.json().catch(() => null)) as unknown; + if (!response.ok || !Array.isArray(payload)) { + const message = (isRecord(payload) && readString(payload, "message")) + || `GitHub App delivery log fetch failed with HTTP ${response.status}.`; + return json({ ok: false, error: message }, { status: 502 }); + } + + const deliveries = payload + .filter(isRecord) + .filter((item) => { + // Keep app-level deliveries (ping/meta have no repository) so webhook + // config issues stay visible alongside repo-scoped deliveries. When a + // delivery IS repo-scoped, fail closed: drop it unless it provably + // matches the repo the caller was authorized for. + if (item.repository_id == null) return true; + const repositoryId = Number(item.repository_id); + if (!Number.isFinite(repositoryId)) return false; + return auth.repositoryId != null && Math.trunc(repositoryId) === auth.repositoryId; + }) + .map((item) => ({ + id: Number.isFinite(Number(item.id)) ? Math.trunc(Number(item.id)) : null, + guid: readString(item, "guid") || null, + event: readString(item, "event") || null, + action: readString(item, "action") || null, + status: readString(item, "status") || null, + statusCode: Number.isFinite(Number(item.status_code)) ? Math.trunc(Number(item.status_code)) : null, + deliveredAt: readString(item, "delivered_at") || null, + redelivery: item.redelivery === true, + installationId: Number.isFinite(Number(item.installation_id)) ? Math.trunc(Number(item.installation_id)) : null, + })) + .slice(0, limit); + + return json({ ok: true, deliveries, checkedAt: new Date().toISOString() }); +} + async function handleRepoStatus(request: Request, env: RelayEnv, repo: { projectId: string | null; owner: string; name: string }): Promise { if (request.method !== "GET") return text("method not allowed", 405); if (repo.projectId) { @@ -1242,6 +1398,10 @@ export async function handleRequest(request: Request, env: RelayEnv): Promise { installationId: 123, })); }); + + it("requires a GitHub token to heal the webhook secret", async () => { + const env = makeEnv(); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const response = await handleRequest( + new Request("https://relay.example.com/github/repos/owner/repo/webhook/heal", { method: "POST" }), + env, + ); + + expect(response.status).toBe(401); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("refuses webhook heal for tokens without admin access", async () => { + const env = makeEnv(); + env.GITHUB_APP_ID = "4180227"; + env.GITHUB_APP_PRIVATE_KEY = await generateTestPrivateKeyPem(); + const fetchMock = stubRepoAccessWithPermissions({ admin: false, push: true, pull: true }); + vi.stubGlobal("fetch", fetchMock); + + const response = await handleRequest( + new Request("https://relay.example.com/github/repos/owner/repo/webhook/heal", { + method: "POST", + headers: githubAuthHeaders(), + }), + env, + ); + + expect(response.status).toBe(403); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("heals the GitHub App webhook secret to the worker's current secret", async () => { + const env = makeEnv(); + env.GITHUB_APP_ID = "4180227"; + env.GITHUB_APP_PRIVATE_KEY = await generateTestPrivateKeyPem(); + let patchedBody: unknown = null; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url === "https://api.github.com/repos/owner/repo") { + return new Response(JSON.stringify({ id: 4242, full_name: "owner/repo", permissions: { admin: true, push: true } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (url === "https://api.github.com/repos/owner/repo/installation") { + expect(String((init?.headers as Record)?.authorization)).toMatch(/^Bearer eyJ/); + return new Response(JSON.stringify({ id: 123, repository_selection: "selected" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (url === "https://api.github.com/app/hook/config") { + expect(init?.method).toBe("PATCH"); + expect(String((init?.headers as Record)?.authorization)).toMatch(/^Bearer eyJ/); + patchedBody = JSON.parse(String(init?.body)); + return new Response(JSON.stringify({ url: "https://relay.example.com/github/webhook", content_type: "json" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + throw new Error(`unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const response = await handleRequest( + new Request("https://relay.example.com/github/repos/owner/repo/webhook/heal", { + method: "POST", + headers: githubAuthHeaders(), + }), + env, + ); + + expect(response.status).toBe(200); + expect(patchedBody).toEqual({ secret: "github-secret" }); + expect(await response.json()).toEqual(expect.objectContaining({ + ok: true, + healed: true, + webhookUrl: "https://relay.example.com/github/webhook", + })); + }); + + it("refuses webhook heal when the app is not installed on the repository", async () => { + const env = makeEnv(); + env.GITHUB_APP_ID = "4180227"; + env.GITHUB_APP_PRIVATE_KEY = await generateTestPrivateKeyPem(); + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "https://api.github.com/repos/owner/repo") { + return new Response(JSON.stringify({ id: 4242, full_name: "owner/repo", permissions: { admin: true } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (url === "https://api.github.com/repos/owner/repo/installation") { + return new Response(JSON.stringify({ message: "Not Found" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + } + throw new Error(`unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const response = await handleRequest( + new Request("https://relay.example.com/github/repos/owner/repo/webhook/heal", { + method: "POST", + headers: githubAuthHeaders(), + }), + env, + ); + + expect(response.status).toBe(409); + const body = await response.json() as { ok: boolean }; + expect(body.ok).toBe(false); + }); + + it("lists webhook deliveries filtered to the requested repository", async () => { + const env = makeEnv(); + env.GITHUB_APP_ID = "4180227"; + env.GITHUB_APP_PRIVATE_KEY = await generateTestPrivateKeyPem(); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url === "https://api.github.com/repos/owner/repo") { + return new Response(JSON.stringify({ id: 4242, full_name: "owner/repo", permissions: { admin: false, push: true } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (url.startsWith("https://api.github.com/app/hook/deliveries")) { + // The GitHub fetch always pulls the max page; the caller's `limit` + // applies to the repo-filtered output instead. + expect(url).toBe("https://api.github.com/app/hook/deliveries?per_page=100"); + expect(String((init?.headers as Record)?.authorization)).toMatch(/^Bearer eyJ/); + return new Response(JSON.stringify([ + { id: 1, guid: "g-1", event: "pull_request", action: "closed", status: "Invalid HTTP Response: 401", status_code: 401, delivered_at: "2026-07-02T00:00:00Z", redelivery: false, repository_id: 4242, installation_id: 123 }, + { id: 2, guid: "g-2", event: "pull_request", action: "opened", status: "OK", status_code: 202, delivered_at: "2026-07-02T00:01:00Z", redelivery: false, repository_id: 999, installation_id: 456 }, + { id: 3, guid: "g-3", event: "ping", action: null, status: "OK", status_code: 202, delivered_at: "2026-06-30T00:00:00Z", redelivery: false, repository_id: null, installation_id: null }, + // Repo-scoped but with an unprovable repo id — must fail closed. + { id: 4, guid: "g-4", event: "pull_request", action: "closed", status: "OK", status_code: 202, delivered_at: "2026-07-02T00:02:00Z", redelivery: false, repository_id: "not-a-number", installation_id: 789 }, + ]), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + throw new Error(`unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const response = await handleRequest( + new Request("https://relay.example.com/github/repos/owner/repo/webhook/deliveries?limit=50", { + headers: githubAuthHeaders(), + }), + env, + ); + + expect(response.status).toBe(200); + const body = await response.json() as { ok: boolean; deliveries: Array<{ guid: string | null; statusCode: number | null }> }; + expect(body.ok).toBe(true); + expect(body.deliveries.map((delivery) => delivery.guid)).toEqual(["g-1", "g-3"]); + expect(body.deliveries[0]).toEqual(expect.objectContaining({ + event: "pull_request", + action: "closed", + statusCode: 401, + redelivery: false, + installationId: 123, + })); + + // `limit` bounds the post-filter result, not the GitHub fetch. + const limited = await handleRequest( + new Request("https://relay.example.com/github/repos/owner/repo/webhook/deliveries?limit=1", { + headers: githubAuthHeaders(), + }), + env, + ); + expect(limited.status).toBe(200); + const limitedBody = await limited.json() as { deliveries: Array<{ guid: string | null }> }; + expect(limitedBody.deliveries.map((delivery) => delivery.guid)).toEqual(["g-1"]); + }); }); diff --git a/docs/features/automations/README.md b/docs/features/automations/README.md index 60e5cce45..3e3bb8ebc 100644 --- a/docs/features/automations/README.md +++ b/docs/features/automations/README.md @@ -8,6 +8,8 @@ Automations never duplicate Linear issue intake — the CTO owns that. Automatio The automation rule engine, cron scheduler, file watcher, ingress endpoints (webhook listener, GitHub relay/polling, Linear relay), and built-in action runner all execute inside the ADE runtime (`ade serve`) that owns the project. For local project bindings the local runtime hosts them; for remote project bindings the remote runtime hosts them. The desktop renderer is a view: it edits rules, watches run history, and triggers manual fires through `window.ade.automations`, but it does not own scheduling, ingress, or dispatch state. +The ingress service has a reduced **PR-freshness-only mode**. In packaged builds and installed daemons where the automations feature is unavailable, the GitHub relay poll still runs so webhook-driven PR state updates reach `prService.ingestGithubWebhook`, while automation rule dispatch, the local webhook HTTP server, and ingress status/event reporting stay gated on the automations feature being enabled. See `automationIngressService.ts` in the source file map below. + Caveat: GitHub-polling and webhook ingress only work on a runtime that can reach the public internet (or your relay). A remote runtime behind a firewall may need the relay path even if the local desktop is internet-reachable. ## Source file map @@ -18,13 +20,13 @@ These services are loaded by the ADE runtime's project scope (and by the desktop - `automationService.ts` — main service. Rule CRUD, execution dispatch (`agent-session`, `built-in`), cron scheduling (via `node-cron`), file-change watching (via `chokidar`), queue management, run history, confidence scoring, billing codes, ingress cursor storage. - `automationPlannerService.ts` — natural-language rule authoring. `parseNaturalLanguage`, `validateDraft`, `saveDraft`, `simulate`. Runs a planner subprocess (Claude or Codex) to turn a free-text brief into an `AutomationRuleDraft`. -- `automationIngressService.ts` — HTTP webhook ingress (GitHub, custom webhooks) and polling-relay ingress (GitHub relay API). Signature verification for webhooks. `AutomationIngressEventRecord` is the normalized event shape. +- `automationIngressService.ts` — HTTP webhook ingress (GitHub, custom webhooks) and polling-relay ingress (GitHub relay API). Signature verification for webhooks. `AutomationIngressEventRecord` is the normalized event shape. Accepts `automationService: null` for the **PR-freshness-only mode** described under [Runtime ownership](#runtime-ownership): the relay poll still feeds `prService.ingestGithubWebhook`, but rule dispatch, the local webhook server, and ingress status/event reads are skipped. In that mode the relay cursor is persisted through an injected `ingressCursorStore` — `createKvIngressCursorStore(db)`, which reads/writes `automations.ingress.cursor.` in the kv table — instead of `automationService`'s cursor storage. A missing GitHub App user token puts the hosted relay poll into a quiet 5-minute auth-pending cooldown (relay status `disabled`, a single `automations.github_relay_auth_pending` info log) rather than warning every tick; `pollNow()` bypasses the cooldown. - `githubPollingService.ts` — direct GitHub REST polling for the origin repo plus `extraRepos`. Diffs per-poll snapshots of issues/PRs/comments to emit `github.issue_*` and `github.pr_*` trigger events without requiring a webhook or relay. Cursor format is `=|=` to support multi-repo state in a single stored string; see `readCursor`/`writeCursor` for the legacy-compat parser. - `automationSecretService.ts` — secret resolution for automation actions (env-ref style, same policy as CTO workers). Referenced as `${env:VAR}` in action config; resolved at execution time. ### GitHub relay and App -- `apps/webhook-relay/` — the hosted GitHub relay: a Cloudflare Worker (`src/index.ts` / `src/relay.ts`) plus D1 migrations. Receives ADE-GitHub-App webhooks, verifies the HMAC signature, stores deliveries idempotently by delivery id, and serves repo-scoped `/github/repos/:owner/:repo/status` and `/events` reads (monotonic `seq:` cursors). Legacy `/projects/:projectId/github/...` project-token routes remain for self-hosted deployments. See `apps/webhook-relay/README.md` for deploy/setup. +- `apps/webhook-relay/` — the hosted GitHub relay: a Cloudflare Worker (`src/index.ts` / `src/relay.ts`) plus D1 migrations. Receives ADE-GitHub-App webhooks, verifies the HMAC signature, stores deliveries idempotently by delivery id, and serves repo-scoped `/github/repos/:owner/:repo/status` and `/events` reads (monotonic `seq:` cursors). Two repo-scoped webhook-maintenance routes back drift recovery and diagnostics: `POST /github/repos/:owner/:repo/webhook/heal` (repo-**admin** gated) re-syncs the GitHub App's webhook secret to the Worker's own `GITHUB_WEBHOOK_SECRET` via `PATCH /app/hook/config` — the recovery path when a rotated secret causes signature-mismatch drift, and idempotent because it can only converge on the Worker's current secret; `GET /github/repos/:owner/:repo/webhook/deliveries` (push/write gated) proxies the GitHub App delivery log filtered to the caller's repository, failing closed (a repo-scoped delivery is dropped unless its `repository_id` matches the authorized repo; app-level ping/meta deliveries with no repository are kept). The shared `assertGitHubRepoAuthorized` gate now takes a `write | admin` access level and returns the `repositoryId` used for that filter. Legacy `/projects/:projectId/github/...` project-token routes remain for self-hosted deployments. See `apps/webhook-relay/README.md` for deploy/setup. - `apps/desktop/src/main/services/github/githubRelayConfig.ts` — resolves the relay base URL and auth mode. Defaults to the hosted Worker (`DEFAULT_GITHUB_RELAY_API_BASE_URL`) with `usesHostedDefault`; `fetchGitHubAppInstallationStatus` authenticates the hosted repo status route with a GitHub App user access token via `resolveHostedGitHubRelayAuthToken` (never the user's general GitHub token), falling back to the legacy project-token route only when `shouldUseLegacyGitHubRelayProjectRoute` (non-default base URL + project id + access token). Also exposes `createGitHubRelayAuthAuditLog`, a dedup wrapper that emits one `github.hosted_relay_auth_token_used` audit line per (event, route, repo, token source). - `apps/desktop/src/main/services/github/githubAppUserAuth.ts` — raw GitHub device-flow HTTP helpers: `startGitHubAppDeviceFlow`, `pollGitHubAppDeviceFlow`, and `refreshGitHubAppUserToken` against GitHub's OAuth device endpoints, plus the `ADE_GITHUB_APP_CLIENT_ID` constant and the `GitHubAppUserTokenRecord` shape. No storage or lifecycle logic — pure request/response mapping. - `apps/desktop/src/main/services/github/githubAppUserAuthService.ts` — `createGitHubAppUserAuthService`, the shared factory that owns the App user token store (`github.appUserToken.v1` in the credential store), device-auth session lifecycle (`startDeviceAuth` / `pollDeviceAuth` / `clearAuth`), single-flight refresh with an `authEpoch` guard so a clear can't re-persist an in-flight refresh, and `getValidTokenForRelay` (refreshes within a 2-minute skew). Consumed by both desktop `githubService` and the ade-cli headless services. @@ -131,7 +133,7 @@ Automations accept inbound events from four sources (`AutomationIngressSource`): - `local-webhook` — `automationIngressService` opens an HTTP endpoint. - `github-webhook` events verify HMAC-SHA256 via `safeCompareSignature` (timing-safe). Secret read from `automations.githubWebhook.secret`. - `webhook` events are custom inbound webhooks with optional shared-secret verification. -- `github-relay` — the default hosted path. A Cloudflare Worker (`apps/webhook-relay/`) receives GitHub App webhooks, verifies the GitHub HMAC signature, and writes each delivery into D1. ADE polls **repo-scoped** Worker routes — `GET /github/repos/:owner/:repo/status` for App-installation/webhook state and `GET /github/repos/:owner/:repo/events?after=` for new deliveries. Hosted relay reads use an expiring GitHub App user access token created through GitHub device flow, not the user's general ADE GitHub PAT/OAuth/`gh auth` token. The relay uses that app-limited token only to ask GitHub whether the authenticated user has push/write, maintain, or admin access, and rejects read-only public-repo callers with 403. The relay base URL defaults to `DEFAULT_GITHUB_RELAY_API_BASE_URL`. The legacy `automations.githubRelay.apiBaseUrl` + `remoteProjectId` + `accessToken` **project-token** routes (`/projects/:projectId/github/...`) remain for self-hosted relays — chosen only when a non-default base URL plus project id and access token are all set (`shouldUseLegacyGitHubRelayProjectRoute`) and do not require GitHub App user authorization. +- `github-relay` — the default hosted path. A Cloudflare Worker (`apps/webhook-relay/`) receives GitHub App webhooks, verifies the GitHub HMAC signature, and writes each delivery into D1. ADE polls **repo-scoped** Worker routes — `GET /github/repos/:owner/:repo/status` for App-installation/webhook state and `GET /github/repos/:owner/:repo/events?after=` for new deliveries. Hosted relay reads use an expiring GitHub App user access token created through GitHub device flow, not the user's general ADE GitHub PAT/OAuth/`gh auth` token. The relay uses that app-limited token only to ask GitHub whether the authenticated user has push/write, maintain, or admin access, and rejects read-only public-repo callers with 403. The same Worker also exposes two repo-scoped webhook-maintenance routes for drift recovery and diagnostics — `POST .../webhook/heal` (admin-gated re-sync of the App's webhook secret) and `GET .../webhook/deliveries` (push-gated, repo-filtered proxy of the App delivery log); see the source file map above. The relay base URL defaults to `DEFAULT_GITHUB_RELAY_API_BASE_URL`. The legacy `automations.githubRelay.apiBaseUrl` + `remoteProjectId` + `accessToken` **project-token** routes (`/projects/:projectId/github/...`) remain for self-hosted relays — chosen only when a non-default base URL plus project id and access token are all set (`shouldUseLegacyGitHubRelayProjectRoute`) and do not require GitHub App user authorization. - `linear-relay` — Linear event relay (shared with CTO intake; Linear triggers here are context-only). - `github-polling` — `githubPollingService` polls the GitHub REST API directly for the origin repo and any `extraRepos`, diffing per-poll snapshots to synthesize `github.issue_*` / `github.pr_*` events (opened / edited / labeled / closed / commented, and PR merged). No relay or webhook infra required. Cursor is a `=|=` string stored via `automationService.setIngressCursor({ source: "github-polling" })`; default interval is 30s. diff --git a/docs/features/pull-requests/README.md b/docs/features/pull-requests/README.md index bf3cb7d25..8866585e0 100644 --- a/docs/features/pull-requests/README.md +++ b/docs/features/pull-requests/README.md @@ -44,6 +44,22 @@ remote host. Status reads work exactly the same as local; the desktop window just sends every action through the SSH-tunneled JSON-RPC instead of the local socket. +Background PR polling lives in whichever process backs the window's +runtime. In packaged / installed builds the desktop window is +runtime-bound, so the ADE daemon owns the `prPollingService` instance +(created, started, and disposed in `apps/ade-cli/src/bootstrap.ts`) +whose ticks emit the PR events consumers render as `prs-updated`; the +daemon also starts the automation ingress relay poll there, which feeds +`prService.ingestGithubWebhook` for webhook-driven freshness (see +[automations](../automations/README.md#runtime-ownership)). Without +this the desktop main process no longer hosts the loop in production, +so PR state would only refresh when a surface issued a direct read. For +local-bound windows the desktop main process still owns its own +`prPollingService`: it is scheduled at project init as the +`prs.polling_start` background task (gated by `ADE_ENABLE_PR_POLLING`, +allowlisted in startup stability mode) and is also lazily started on +the first PR read through `ensurePrPolling` in `registerIpc.ts`. + ## Source file map Services. The canonical implementations run inside the runtime @@ -65,7 +81,7 @@ Service files (`apps/desktop/src/main/services/prs/`): | `prService.ts` | PR CRUD, GitHub sync, merge context, draft descriptions, check/review/comment hydration, cached detail snapshots (`listSnapshots`), commit snapshots (`getCommits`), integration proposals, merge-into-existing-lane adoption, merge bypass, post-merge cleanup, standalone PR branch cleanup (`cleanupBranch`), deployment listing, review-thread reply/resolve/react mutations for the timeline, the aggregate `getMobileSnapshot` that powers the iOS PRs tab, and `listOpenPullRequests` — a paginated `/repos/{owner}/{name}/pulls?state=open` fetch returning `BranchPullRequest[]` for the lane-creation branch picker. `getForLane(laneId)` resolves through `getDisplayRowForCurrentLaneBranch`: it returns the most recently updated PR whose head branch matches the lane's current branch ref, ranked open/draft → merged → closed, so a freshly merged PR still shows in lane-scoped UI instead of disappearing the moment GitHub flips the state. `getGitHubSnapshot` fetches repo PRs, backfills same-repo lane PR rows by branch, and performs a capped per-branch fallback (`head=:`) for active lane branches missing from the repo snapshot window so old merged/closed externally-created PRs can still badge lanes. On PR open, `publishLinearPrCardsForLane` combines the lane's own Linear references with `collectLinearPrIssueReferencesForLaneSessions(laneId)` — issues attached only to a chat/CLI session in the lane (via `laneService.listLinearIssuesForLaneSessions`, authoritative for sessions whose lane mirror never landed) — deduped via `dedupeLinearPrIssueReferences`, so a session-only issue still gets a PR attachment. When the optional live-status round-trip is enabled (`getLinearLiveStatusService`, gated by `ADE_LINEAR_LIVE_STATUS_ROUNDTRIP=1`) it also posts a PR-link comment back to each linked issue. See [Linear integration](../linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection). `computeStatus` / `getStatusByGithub` fetch the authoritative GitHub merge box over GraphQL (`mergeStateStatus`, `reviewDecision`, required/approving review counts, `viewerPermission` for bypass) and fold it into `PrStatus`; `getStatusByGithub` does the same for unmapped GitHub-tab PRs keyed only on `owner/repo#num` coords. `land` takes an editable commit title/body (`commit_title`/`commit_message`, `--subject`/`--body` on the admin retry; ignored for `rebase`) and an `expectedHeadSha` stale-head guard, and `updateBranch` brings a behind branch up to date via GitHub's `update-branch` API (`merge` strategy) or ADE's local lane rebase + force-with-lease push (`rebase` strategy, conflict-aware). Review-thread reply/resolve/react mutations work on unmapped GitHub-tab PRs through synthetic `gh:owner/repo#num` ids (`parseSyntheticGithubPrId` resolves the repo; `assertThreadBelongsToPr` still verifies thread ownership). Commit rows carry an avatar URL — the linked GitHub avatar when present, else a Gravatar identicon derived from the commit-author email. | | `prService.mobileSnapshot.test.ts` | Coverage for the mobile snapshot builder: stack chaining, capability gates, per-lane create eligibility, workflow-card aggregation | | `prService.mergeInto.test.ts` | Coverage for integration proposals that preview or adopt an existing merge target lane, including dirty-worktree handling and drift metadata. | -| `prPollingService.ts` | 60 s polling loop, fingerprint-based change detection, notification emission. Writes `last_polled_at` per PR so callers can run delta polls on the next tick | +| `prPollingService.ts` | 60 s polling loop, fingerprint-based change detection, notification emission. Writes `last_polled_at` per PR so callers can run delta polls on the next tick. The ADE daemon owns an instance (created + started + disposed in `apps/ade-cli/src/bootstrap.ts`) so background polling and PR events run for runtime-bound windows; the desktop main process still owns one for local-bound windows. When zero PRs are tracked yet, the forced full-snapshot `discoverLanePullRequests` fetch is throttled to a 10-minute cadence instead of running every tick — user-driven surfaces discover PRs on their own reads anyway | | `prSummaryService.ts` | AI PR summary generator; caches `PrAiSummary` per `(prId, headSha)` in `pull_request_ai_summaries` so pushes invalidate the cache | | `queueLandingService.ts` | Merge queue state machine (`ALLOWED_TRANSITIONS`), landing loop, auto-resolve on conflicts | | `integrationPlanning.ts` | `buildIntegrationPreflight` — validates source lanes for an integration proposal | @@ -83,7 +99,7 @@ Renderer components (`apps/desktop/src/renderer/components/prs/`): | `prsRouteState.ts` | URL ↔ page state mapping plus project-scoped last-route storage. When a project root is known, the PRs tab reads only that project's stored route and does not fall back to the legacy global route from another project. | | `CreatePrModal.tsx` | Draft/queue/integration PR creation with lane warnings, branch name validation, and optional initial values for single-PR handoffs from lane/chat surfaces. A `target: "primary"` handoff resolves the base branch from the primary lane (falling back to `main`). | | `tabs/NormalTab.tsx` | Normal PR list | -| `tabs/GitHubTab.tsx` | Repository PR browser with label filters, CI badges, review indicators, ADE-vs-unmanaged scope counts, and linked-lane context. State filter is one of `open` / `closed` / `merged` / `all`. The tab ignores legacy cross-repo `externalPullRequests` payloads; the "External" scope means repo PRs that are not managed by ADE. The "create lane from PR branch" affordance has been removed — open/closed PRs on branches without a lane no longer offer the preflight + create dialog (`prsPreflightCreateLaneFromPrBranch` / `prsCreateLaneFromPrBranch` IPC channels have been deleted), so creating a lane for an existing PR now goes through the standard lane creation flow. | +| `tabs/GitHubTab.tsx` | Repository PR browser with label filters, CI badges, review indicators, ADE-vs-unmanaged scope counts, and linked-lane context. State filter is one of `open` / `closed` / `merged` / `all`. The tab ignores legacy cross-repo `externalPullRequests` payloads; the "External" scope means repo PRs that are not managed by ADE. The "create lane from PR branch" affordance has been removed — open/closed PRs on branches without a lane no longer offer the preflight + create dialog (`prsPreflightCreateLaneFromPrBranch` / `prsCreateLaneFromPrBranch` IPC channels have been deleted), so creating a lane for an existing PR now goes through the standard lane creation flow. Snapshot rows are mapped through `reconcileLinkedPrState` (using `isTerminalPrState` from `renderer/lib/prState.ts`) into `displayedItems`, so a terminal ADE PR state (merged/closed) overrides a stale non-terminal GitHub row for the same linked PR across the list, filter counts, and selection (see [Terminal-state precedence](#terminal-state-precedence)). | | `tabs/QueueTab.tsx` | Merge queue UI showing queued stack members and their landing state. | | `tabs/IntegrationTab.tsx` | Integration (merge-plan) proposals and execution, including merge-into-lane selection, apply-and-resimulate, and adopted-lane cleanup messaging | | `tabs/RebaseTab.tsx` | Lane rebase needs (base + queue + PR target) and attention items. Hide/snooze controls only affect the lane rebase suggestion banner; still-behind needs remain actionable in the Rebase view. | @@ -247,6 +263,31 @@ app store via `useLaneColorById` / a `Map`); the rest of the row text inherits the lane color so a glance correlates a PR with its lane across the queue / GitHub / Workflows tabs. +### Terminal-state precedence + +A GitHub snapshot can lag ADE's own PR state — the repo-wide page window +is refreshed on a slower cadence than a merge/close the user just made. +When ADE holds an authoritative terminal state for a PR (`merged` or +`closed`) it wins over a stale non-terminal snapshot row for the same PR. +`isTerminalPrState` in `apps/desktop/src/renderer/lib/prState.ts` is the +shared rule: merged is permanent and closed only leaves via an explicit +reopen, so a terminal local state is never overwritten by an out-of-date +`open` / `draft` snapshot. Two renderer surfaces apply it: + +- **GitHub tab** — `GitHubTab.tsx` maps each snapshot row through + `reconcileLinkedPrState` into `displayedItems`. When a row's linked ADE + PR (`linkedPrId`) is terminal but the snapshot still shows it + non-terminal, the row is rewritten to the ADE state (clearing `isDraft` + and adopting the ADE title / `updatedAt`) before filtering, counting, + and selection all read `displayedItems`, so a just-merged PR is not + still counted or listed as open. +- **Lane PR tag** — `lanePageModel.ts` `shouldPreferGithubPrTag` uses the + same helper to decide whether a lane's GitHub-by-branch PR tag should + override the ADE PR tag. It never prefers the GitHub tag when the ADE + state is terminal and the GitHub state is not, so a lane badge does not + flip back to "open" after the PR merges; when both states are + comparable it still prefers the GitHub tag on a genuine state mismatch. + ## GitHub connectivity model `getStatus()` in `apps/desktop/src/main/services/github/githubService.ts` @@ -310,8 +351,11 @@ split for the banner copy: "not connected" / "cannot access ## Background polling -`prPollingService` runs at a 60 s default interval (clamped to -5 s–5 min, jittered ±10%). Each tick: +`prPollingService` runs inside the process that backs the window's +runtime — the ADE daemon for runtime-bound (packaged) windows, the +desktop main process for local-bound windows (see +[Where this runs](#where-this-runs)). It runs at a 60 s default interval +(clamped to 5 s–5 min, jittered ±10%). Each tick: 1. Pulls the current PR list via `prService`. 2. Computes a fingerprint per PR (excluding volatile timing fields: @@ -321,6 +365,14 @@ split for the banner copy: "not connected" / "cannot access 4. Emits `PrEventPayload` for state transitions (checks failing, review requested, changes requested, merge ready). +When `prService` reports zero tracked PRs, the tick can force a full +repo-snapshot discovery (`discoverLanePullRequests`). Because that is +far heavier than a tracked-PR delta poll, it is throttled to at most +once every 10 minutes for projects that have no PRs yet (new users, +non-PR projects) — user-driven surfaces still discover PRs on their own +reads. The throttle seeds from epoch, not "never", so the first tick +after start still discovers. + Notification titles are generic (not PR-specific) so they display well as system notifications. The event payload includes `prTitle`, `repoOwner`, `repoName`, `baseBranch`, `headBranch` so consumers can