Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,101 @@ describe("automationIngressService", () => {
);
});

it("repairs repository stack state after an expired relay cursor is committed", async () => {
const setIngressCursor = vi.fn();
const reconcileGithubStacks = vi.fn(async () => []);
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({
events: [],
nextCursor: "seq:9",
cursorExpired: true,
hasMore: false,
}), { headers: { "content-type": "application/json" } }));

service = createAutomationIngressService({
logger: makeLogger() as never,
automationService: {
updateIngressStatus: vi.fn(),
dispatchIngressTrigger: vi.fn(),
getIngressCursor: () => "seq:2",
setIngressCursor,
getIngressStatus: () => ({}),
} as never,
prService: {
ingestGithubWebhook: vi.fn(),
reconcileGithubStacks,
} 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: () => [],
});

await service.pollNow();

expect(setIngressCursor).toHaveBeenCalledWith({
source: "github-relay",
cursor: "seq:9",
});
expect(reconcileGithubStacks).toHaveBeenCalledWith({
owner: "arul28",
name: "ADE",
});
expect(reconcileGithubStacks.mock.invocationCallOrder[0]).toBeLessThan(
setIngressCursor.mock.invocationCallOrder[0]!,
);
});

it("retries cursor-expiry stack repair before committing the replacement cursor", async () => {
const cursors = new Map<string, string | null>([["github-relay", "seq:2"]]);
const setIngressCursor = vi.fn(({ source, cursor }: { source: string; cursor: string | null }) => {
cursors.set(source, cursor);
});
const reconcileGithubStacks = vi.fn()
.mockRejectedValueOnce(new Error("stack list timed out"))
.mockResolvedValueOnce([]);
vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(JSON.stringify({
events: [],
nextCursor: "seq:9",
cursorExpired: true,
hasMore: false,
}), { headers: { "content-type": "application/json" } }));

service = createAutomationIngressService({
logger: makeLogger() as never,
automationService: {
updateIngressStatus: vi.fn(),
dispatchIngressTrigger: vi.fn(),
getIngressCursor: (source: string) => cursors.get(source) ?? null,
setIngressCursor,
getIngressStatus: () => ({}),
} as never,
prService: {
ingestGithubWebhook: vi.fn(),
reconcileGithubStacks,
} 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: () => [],
});

await service.pollNow();
expect(cursors.get("github-relay")).toBe("seq:2");
expect(setIngressCursor).not.toHaveBeenCalled();

await service.pollNow();
expect(reconcileGithubStacks).toHaveBeenCalledTimes(2);
expect(cursors.get("github-relay")).toBe("seq:9");
expect(setIngressCursor).toHaveBeenCalledWith({
source: "github-relay",
cursor: "seq:9",
});
});

it("skips a failing event and still advances the relay cursor (poison-event guard)", async () => {
const logger = makeLogger();
const setIngressCursor = vi.fn();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -941,8 +941,12 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg
? payload.nextCursor
: null;
if (responseCursor) pageLastCursor = responseCursor;
// Commit only after every event in this page has completed. A failed
// page is replayed from its previous durable cursor on the next drain.
if (payload.cursorExpired === true && repo) {
await args.prService?.reconcileGithubStacks(repo);
}
// Commit only after every event and any cursor-expiry repair in this
// page have completed. A failed page is replayed from its previous
// durable cursor on the next drain.
if (pageLastCursor && pageLastCursor !== pageCursor) {
setIngressCursor({ source: "github-relay", cursor: pageLastCursor });
for (const prId of pageIngestedPrIds) committedIngestedPrIds.add(prId);
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/main/services/github/adeReleaseFeed.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { GITHUB_REST_API_VERSION } from "./githubApiVersion";

export const ADE_RELEASE_REPO = { owner: "arul28", name: "ADE" } as const;

export type AdeLatestRelease = {
Expand Down Expand Up @@ -31,6 +33,7 @@ export async function fetchAdeLatestRelease(options?: {
const headers: Record<string, string> = {
accept: "application/vnd.github+json",
"user-agent": "ade-desktop",
"x-github-api-version": GITHUB_REST_API_VERSION,
};
const token = options?.token?.trim();
if (token) headers.authorization = `Bearer ${token}`;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const GITHUB_REST_API_VERSION = "2026-03-10";
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
createGitHubRelayAuthAuditLog,
type GitHubRelayAuthAuditLog,
} from "./githubRelayConfig";
import { GITHUB_REST_API_VERSION } from "./githubApiVersion";
import { asString } from "../shared/utils";

const GITHUB_APP_USER_TOKEN_KEY = "github.appUserToken.v1";
Expand Down Expand Up @@ -142,6 +143,7 @@ export function createGitHubAppUserAuthService(args: {
accept: "application/vnd.github+json",
authorization: `Bearer ${accessToken}`,
"user-agent": args.userAgent,
"x-github-api-version": GITHUB_REST_API_VERSION,
},
});
const payload = (await response.json().catch(() => ({}))) as Record<string, unknown>;
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/main/services/github/githubService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,14 @@ describe("githubService.apiRequest", () => {
expect(result.data).toEqual(payload);
expect(result.response).toBeDefined();
expect(result.response!.status).toBe(200);
expect(mockFetch).toHaveBeenCalledWith(
"https://api.github.com/repos/owner/repo",
expect.objectContaining({
headers: expect.objectContaining({
"x-github-api-version": "2026-03-10",
}),
}),
);
});

it("aborts and rejects when the response body never finishes", async () => {
Expand Down
8 changes: 6 additions & 2 deletions apps/desktop/src/main/services/github/githubService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type { SyncCredentialStore } from "../../../../../ade-cli/src/services/cr
import { mergePathEntries, resolveExecutableFromKnownLocations } from "../ai/cliExecutableResolver";
import { fetchGitHubAppInstallationStatus, type GitHubRelaySecretReader } from "./githubRelayConfig";
import { createGitHubAppUserAuthService } from "./githubAppUserAuthService";
import { GITHUB_REST_API_VERSION } from "./githubApiVersion";
import {
classifyGitHubAuthFailure,
GitHubRateLimitError,
Expand Down Expand Up @@ -801,7 +802,8 @@ export function createGithubService({
headers: {
accept: "application/vnd.github+json",
authorization: `Bearer ${token}`,
"user-agent": "ade-desktop"
"user-agent": "ade-desktop",
"x-github-api-version": GITHUB_REST_API_VERSION,
}
});

Expand Down Expand Up @@ -850,6 +852,7 @@ export function createGithubService({
accept: "application/vnd.github+json",
authorization: `Bearer ${token}`,
"user-agent": "ade-desktop",
"x-github-api-version": GITHUB_REST_API_VERSION,
},
},
);
Expand Down Expand Up @@ -1028,7 +1031,8 @@ export function createGithubService({
accept: args.accept?.trim() || "application/vnd.github+json",
authorization: `Bearer ${token}`,
"content-type": args.body != null ? "application/json" : "text/plain",
"user-agent": "ade-desktop"
"user-agent": "ade-desktop",
"x-github-api-version": GITHUB_REST_API_VERSION,
};

// For GET requests, send If-None-Match with cached ETag if available.
Expand Down
Loading