feat(server,app): redesign git/GitHub refresh architecture with server authority

Centralizes refresh orchestration in WorkspaceGitService and GitHubService:
server coalesces, deduplicates, and throttles git/GitHub reads; clients peek
snapshots (cold reads fall through to a one-shot fetch) and never drive
refreshes. Introduces a two-TTL model (consumer vs internal), force-emit
bypass so fingerprint-matched refreshes still propagate, notifyGitMutation
wiring at ~13 action handlers, adaptive GitHub polling based on check state,
and checkout_status_update / prStatus push-to-cache wire messages. Fixes the
stale diff-stat bug and the inverted base-branch diff direction, and flips
next-action affordances promptly after commit / PR merge.
This commit is contained in:
Mohamed Boudra
2026-04-19 22:35:26 +07:00
parent 128d6668ae
commit 5a2708653c
50 changed files with 6320 additions and 1073 deletions
@@ -1,14 +1,22 @@
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
GitHubAuthenticationError,
GitHubCliMissingError,
GitHubCommandError,
computeGithubNextInterval,
createGitHubService,
resolveGitHubRepo,
type GitHubCommandRunner,
type GitHubCommandRunnerOptions,
type GitHubCurrentPullRequestStatus,
type GitHubReadOptions,
} from "./github-service.js";
import { CheckoutPrStatusResponseSchema } from "../shared/messages.js";
const EXPECTED_GITHUB_FAST_POLL_MS = 20_000;
const EXPECTED_GITHUB_SLOW_POLL_MS = 120_000;
const EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS = 300_000;
interface RunnerCall {
args: string[];
cwd: string;
@@ -99,6 +107,37 @@ function currentPullRequestJson(overrides: Record<string, unknown> = {}): string
});
}
function createCurrentPullRequestStatus(
overrides: Partial<GitHubCurrentPullRequestStatus> = {},
): GitHubCurrentPullRequestStatus {
return {
number: 42,
repoOwner: "acme",
repoName: "repo",
url: "https://github.com/acme/repo/pull/42",
title: "Update feature",
state: "open",
baseRefName: "main",
headRefName: "feature",
isMerged: false,
isDraft: false,
checks: [],
checksStatus: "none",
reviewDecision: null,
...overrides,
};
}
function recordCurrentPullRequestStatusReads(service: ReturnType<typeof createGitHubService>) {
const reads: GitHubReadOptions[] = [];
const getCurrentPullRequestStatus = service.getCurrentPullRequestStatus.bind(service);
service.getCurrentPullRequestStatus = vi.fn(async (options) => {
reads.push(options);
return getCurrentPullRequestStatus(options);
});
return reads;
}
function noPullRequestError(args: string[] = ["pr", "view"]): GitHubCommandError {
return new GitHubCommandError({
args,
@@ -122,6 +161,36 @@ function pullRequestJson(title: string): string {
]);
}
function issueJson(title: string): string {
return JSON.stringify([
{
number: 55,
title,
url: "https://github.com/acme/repo/issues/55",
state: "OPEN",
body: "issue body",
labels: [{ name: "bug" }],
updatedAt: "2026-04-18T12:00:00Z",
},
]);
}
function searchPullRequestJson(title: string): string {
return JSON.stringify([
{
number: 123,
title,
url: "https://github.com/acme/repo/pull/123",
state: "OPEN",
body: "pr body",
baseRefName: "main",
headRefName: "feature",
labels: [{ name: "enhancement" }],
updatedAt: "2026-04-18T13:00:00Z",
},
]);
}
function pullRequestTimelineJson(overrides: Record<string, unknown> = {}): string {
return JSON.stringify({
data: {
@@ -175,6 +244,212 @@ function pullRequestTimelineJson(overrides: Record<string, unknown> = {}): strin
}
describe("GitHubService", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("computes fast cadence for pending and slow cadence for stable PR states", () => {
const pendingStatus = createCurrentPullRequestStatus({ checksStatus: "pending" });
const runningCheckStatus = createCurrentPullRequestStatus({
checksStatus: "success",
checks: [{ name: "ci", status: "pending", url: null }],
});
const stableStatus = createCurrentPullRequestStatus({ checksStatus: "success" });
expect(computeGithubNextInterval(pendingStatus, 0)).toBe(EXPECTED_GITHUB_FAST_POLL_MS);
expect(computeGithubNextInterval(runningCheckStatus, 0)).toBe(EXPECTED_GITHUB_FAST_POLL_MS);
expect(computeGithubNextInterval(stableStatus, 0)).toBe(EXPECTED_GITHUB_SLOW_POLL_MS);
expect(computeGithubNextInterval(null, 0)).toBe(EXPECTED_GITHUB_SLOW_POLL_MS);
});
it("computes exponential error backoff up to the cap", () => {
const stableStatus = createCurrentPullRequestStatus({ checksStatus: "success" });
expect(computeGithubNextInterval(stableStatus, 1)).toBe(EXPECTED_GITHUB_SLOW_POLL_MS);
expect(computeGithubNextInterval(stableStatus, 2)).toBe(240_000);
expect(computeGithubNextInterval(stableStatus, 3)).toBe(EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS);
expect(computeGithubNextInterval(stableStatus, 4)).toBe(EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS);
});
it("polls PR status at fast cadence while checks are pending", async () => {
let now = 0;
const runner = createRunner([
currentPullRequestJson({
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "PENDING" }],
}),
currentPullRequestJson({
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "PENDING" }],
}),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => now,
});
const reads = recordCurrentPullRequestStatusReads(service);
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/repo",
headRef: "feature/fork",
});
await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" });
now = EXPECTED_GITHUB_FAST_POLL_MS;
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
expect(runner.calls).toHaveLength(2);
expect(reads.map((read) => read.reason)).toEqual([undefined, "self-heal-github"]);
subscription?.unsubscribe();
service.dispose?.();
});
it("polls PR status at slow cadence after stable checks", async () => {
let now = 0;
const runner = createRunner([
currentPullRequestJson({
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }],
}),
currentPullRequestJson({
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }],
}),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => now,
});
const reads = recordCurrentPullRequestStatusReads(service);
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/repo",
headRef: "feature/fork",
});
await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" });
now = EXPECTED_GITHUB_FAST_POLL_MS;
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
expect(runner.calls).toHaveLength(1);
now = EXPECTED_GITHUB_SLOW_POLL_MS;
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_SLOW_POLL_MS - EXPECTED_GITHUB_FAST_POLL_MS);
expect(runner.calls).toHaveLength(2);
expect(reads.map((read) => read.reason)).toEqual([undefined, "self-heal-github"]);
subscription?.unsubscribe();
service.dispose?.();
});
it("backs off consecutive poll errors and resets cadence after recovery", async () => {
let now = 0;
const runner = createScriptedRunner([
currentPullRequestJson({
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "PENDING" }],
}),
{ error: new Error("network down") },
{ error: new Error("network still down") },
currentPullRequestJson({
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }],
}),
currentPullRequestJson({
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }],
}),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => now,
});
const reads = recordCurrentPullRequestStatusReads(service);
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/repo",
headRef: "feature/fork",
});
await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" });
now = EXPECTED_GITHUB_FAST_POLL_MS;
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
now += EXPECTED_GITHUB_FAST_POLL_MS;
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
now += EXPECTED_GITHUB_FAST_POLL_MS * 2;
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS * 2);
now += EXPECTED_GITHUB_SLOW_POLL_MS;
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_SLOW_POLL_MS);
expect(runner.calls).toHaveLength(5);
expect(reads.map((read) => read.reason)).toEqual([
undefined,
"self-heal-github",
"self-heal-github",
"self-heal-github",
"self-heal-github",
]);
subscription?.unsubscribe();
service.dispose?.();
});
it("unsubscribe clears the adaptive GitHub poll timer", async () => {
let now = 0;
const runner = createRunner([
currentPullRequestJson({
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "PENDING" }],
}),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => now,
});
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/repo",
headRef: "feature/fork",
});
await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" });
subscription?.unsubscribe();
now = EXPECTED_GITHUB_FAST_POLL_MS;
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
expect(runner.calls).toHaveLength(1);
service.dispose?.();
});
it("dispose clears all adaptive GitHub poll timers", async () => {
let now = 0;
const runner = createRunner([
currentPullRequestJson({
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "PENDING" }],
}),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => now,
});
service.retainCurrentPullRequestStatusPoll?.({ cwd: "/repo", headRef: "feature/fork" });
await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" });
service.dispose?.();
now = EXPECTED_GITHUB_FAST_POLL_MS;
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
expect(runner.calls).toHaveLength(1);
});
it("fetches PR reviews and issue comments with one GraphQL call sorted chronologically", async () => {
const runner = createRunner([pullRequestTimelineJson()]);
const service = createGitHubService({
@@ -1216,4 +1491,230 @@ describe("GitHubService", () => {
stderr: "GraphQL: unavailable",
});
});
it("searches GitHub issues and PRs", async () => {
const runner = createRunner([issueJson("Issue title"), searchPullRequestJson("PR title")]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
await expect(
service.searchIssuesAndPrs({ cwd: "/repo", query: "cache", limit: 5 }),
).resolves.toEqual({
githubFeaturesEnabled: true,
items: [
{
kind: "pr",
number: 123,
title: "PR title",
url: "https://github.com/acme/repo/pull/123",
state: "OPEN",
body: "pr body",
labels: ["enhancement"],
baseRefName: "main",
headRefName: "feature",
updatedAt: "2026-04-18T13:00:00Z",
},
{
kind: "issue",
number: 55,
title: "Issue title",
url: "https://github.com/acme/repo/issues/55",
state: "OPEN",
body: "issue body",
labels: ["bug"],
baseRefName: null,
headRefName: null,
updatedAt: "2026-04-18T12:00:00Z",
},
],
});
expect(runner.calls).toEqual([
{
cwd: "/repo",
args: [
"issue",
"list",
"--search",
"cache",
"--json",
"number,title,url,state,body,labels,updatedAt",
"--limit",
"5",
],
},
{
cwd: "/repo",
args: [
"pr",
"list",
"--search",
"cache",
"--json",
"number,title,url,state,body,labels,baseRefName,headRefName,updatedAt",
"--limit",
"5",
],
},
]);
});
it("searches only GitHub PRs when the search kinds request excludes issues", async () => {
const runner = createRunner([searchPullRequestJson("PR title")]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
await expect(
service.searchIssuesAndPrs({
cwd: "/repo",
query: "cache",
limit: 5,
kinds: ["github-pr"],
}),
).resolves.toEqual({
githubFeaturesEnabled: true,
items: [
{
kind: "pr",
number: 123,
title: "PR title",
url: "https://github.com/acme/repo/pull/123",
state: "OPEN",
body: "pr body",
labels: ["enhancement"],
baseRefName: "main",
headRefName: "feature",
updatedAt: "2026-04-18T13:00:00Z",
},
],
});
expect(runner.calls).toEqual([
{
cwd: "/repo",
args: [
"pr",
"list",
"--search",
"cache",
"--json",
"number,title,url,state,body,labels,baseRefName,headRefName,updatedAt",
"--limit",
"5",
],
},
]);
});
it("reuses cached PR status without another gh call", async () => {
let now = 100;
const runner = createRunner([currentPullRequestJson()]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => now,
});
await expect(
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }),
).resolves.toMatchObject({ number: 42 });
now = 101;
await expect(
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }),
).resolves.toMatchObject({ number: 42 });
expect(runner.calls).toHaveLength(1);
});
it("bypasses the warm PR status cache for forced reads", async () => {
const runner = createRunner([
currentPullRequestJson({ number: 41, title: "First" }),
currentPullRequestJson({ number: 42, title: "Forced" }),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
await expect(
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }),
).resolves.toMatchObject({ number: 41 });
await expect(
service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
force: true,
reason: "test",
}),
).resolves.toMatchObject({ number: 42 });
expect(runner.calls).toHaveLength(2);
});
it("coalesces concurrent PR status callers", async () => {
const runner = createDeferredRunner();
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
const first = service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" });
const second = service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" });
await Promise.resolve();
expect(runner.calls).toHaveLength(1);
runner.resolveNext(currentPullRequestJson());
await expect(Promise.all([first, second])).resolves.toEqual([
expect.objectContaining({ number: 42 }),
expect.objectContaining({ number: 42 }),
]);
});
it("requires a reason for forced reads at runtime", async () => {
const service = createGitHubService({
runner: async () => ({ stdout: currentPullRequestJson(), stderr: "" }),
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
await expect(
service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
force: true,
} as never),
).rejects.toThrow("GitHubService forced read requires a reason");
});
it("type: force true requires a reason", () => {
// @ts-expect-error force: true requires reason
const invalid: GitHubReadOptions = { force: true };
const valid: GitHubReadOptions = { force: true, reason: "test" };
expect(invalid.force).toBe(true);
expect(valid.reason).toBe("test");
});
it("resolves GitHub repos from a WorkspaceGitService-owned remote URL", async () => {
const workspaceGitService = {
resolveRepoRemoteUrl: async (cwd: string) => {
expect(cwd).toBe("/repo");
return "git@github.com:getpaseo/paseo.git";
},
};
await expect(resolveGitHubRepo("/repo", { workspaceGitService })).resolves.toBe(
"getpaseo/paseo",
);
});
});
+369 -22
View File
@@ -1,9 +1,12 @@
import { z } from "zod";
import type { GitHubSearchKind } from "../shared/messages.js";
import { findExecutable } from "../utils/executable.js";
import { runGitCommand } from "../utils/run-git-command.js";
import { execCommand } from "../utils/spawn.js";
const DEFAULT_GITHUB_CACHE_TTL_MS = 30_000;
export const GITHUB_POLL_FAST_INTERVAL_MS = 20_000;
export const GITHUB_POLL_SLOW_INTERVAL_MS = 120_000;
export const GITHUB_POLL_ERROR_BACKOFF_CAP_MS = 300_000;
const GITHUB_ENV: NodeJS.ProcessEnv = {
...process.env,
GIT_TERMINAL_PROMPT: "0",
@@ -343,30 +346,63 @@ export interface GitHubPullRequestCreateResult {
number: number;
}
export interface ListGitHubPullRequestsOptions {
export type GitHubReadOptions =
| {
force?: false;
reason?: string;
}
| {
force: true;
reason: string;
};
export type ListGitHubPullRequestsOptions = {
cwd: string;
query?: string;
limit?: number;
}
} & GitHubReadOptions;
export interface ListGitHubIssuesOptions {
export type ListGitHubIssuesOptions = {
cwd: string;
query?: string;
limit?: number;
}
} & GitHubReadOptions;
export interface GetGitHubPullRequestOptions {
export type GetGitHubPullRequestOptions = {
cwd: string;
number: number;
}
} & GitHubReadOptions;
export interface GetGitHubPullRequestTimelineOptions {
export type GetGitHubPullRequestTimelineOptions = {
cwd: string;
prNumber: number;
repoOwner: string;
repoName: string;
} & GitHubReadOptions;
export interface GitHubSearchResult {
items: Array<{
kind: "issue" | "pr";
number: number;
title: string;
url: string;
state: string;
body: string | null;
labels: string[];
baseRefName?: string | null;
headRefName?: string | null;
updatedAt?: string;
}>;
githubFeaturesEnabled: boolean;
}
export type SearchGitHubIssuesAndPrsOptions = {
cwd: string;
query: string;
limit?: number;
kinds?: GitHubSearchKind[];
} & GitHubReadOptions;
export interface CreateGitHubPullRequestOptions {
cwd: string;
repo: string;
@@ -381,18 +417,28 @@ export interface GitHubService {
listIssues(options: ListGitHubIssuesOptions): Promise<GitHubIssueSummary[]>;
getPullRequest(options: GetGitHubPullRequestOptions): Promise<GitHubPullRequestSummary>;
getPullRequestHeadRef(options: GetGitHubPullRequestOptions): Promise<string>;
getCurrentPullRequestStatus(options: {
cwd: string;
headRef: string;
}): Promise<GitHubCurrentPullRequestStatus | null>;
getCurrentPullRequestStatus(
options: {
cwd: string;
headRef: string;
} & GitHubReadOptions,
): Promise<GitHubCurrentPullRequestStatus | null>;
getPullRequestTimeline(
options: GetGitHubPullRequestTimelineOptions,
): Promise<GitHubPullRequestTimeline>;
searchIssuesAndPrs(options: SearchGitHubIssuesAndPrsOptions): Promise<GitHubSearchResult>;
createPullRequest(
options: CreateGitHubPullRequestOptions,
): Promise<GitHubPullRequestCreateResult>;
isAuthenticated(options: { cwd: string }): Promise<boolean>;
isAuthenticated(options: { cwd: string } & GitHubReadOptions): Promise<boolean>;
retainCurrentPullRequestStatusPoll?(options: {
cwd: string;
headRef: string;
onStatus?: (status: GitHubCurrentPullRequestStatus | null) => void;
onError?: (error: unknown) => void;
}): { unsubscribe: () => void };
invalidate(options: { cwd: string }): void;
dispose?(): void;
}
export class GitHubCliMissingError extends Error {
@@ -453,6 +499,18 @@ type CurrentPullRequestStatusItem = z.infer<typeof CurrentPullRequestStatusSchem
interface InFlightCacheEntry {
cwd: string;
promise: Promise<unknown>;
force: boolean;
}
interface GitHubPollTarget {
cwd: string;
headRef: string;
retainCount: number;
timer: NodeJS.Timeout | null;
latestStatus: GitHubCurrentPullRequestStatus | null;
consecutiveErrors: number;
callbacks: Set<(status: GitHubCurrentPullRequestStatus | null) => void>;
errorCallbacks: Set<(error: unknown) => void>;
}
interface ResolvedPullRequestCandidate {
@@ -469,13 +527,20 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
};
const cache = new Map<string, CacheEntry>();
const inFlight = new Map<string, InFlightCacheEntry>();
const pollTargets = new Map<string, GitHubPollTarget>();
let api!: GitHubService;
async function cached<T>(params: {
cwd: string;
method: string;
args: unknown;
readOptions?: GitHubReadOptions;
load: () => Promise<T>;
}): Promise<T> {
if (params.readOptions?.force && !params.readOptions.reason) {
throw new Error("GitHubService forced read requires a reason");
}
const key = buildCacheKey({
cwd: params.cwd,
method: params.method,
@@ -483,12 +548,12 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
});
const cachedEntry = cache.get(key);
const now = deps.now();
if (cachedEntry && cachedEntry.expiresAt > now) {
if (!params.readOptions?.force && cachedEntry && cachedEntry.expiresAt > now) {
return cachedEntry.value as T;
}
const existing = inFlight.get(key);
if (existing) {
if (existing && (!params.readOptions?.force || existing.force)) {
return existing.promise as Promise<T>;
}
@@ -509,7 +574,11 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
inFlight.delete(key);
}
});
inFlight.set(key, { cwd: params.cwd, promise: request });
inFlight.set(key, {
cwd: params.cwd,
promise: request,
force: params.readOptions?.force === true,
});
return request;
}
@@ -529,12 +598,93 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
}
}
return {
function getPollTargetKey(options: { cwd: string; headRef: string }): string {
return buildCacheKey({
cwd: options.cwd,
method: "getCurrentPullRequestStatus",
args: { headRef: options.headRef },
});
}
function updatePollTargetAfterSuccess(options: {
cwd: string;
headRef: string;
status: GitHubCurrentPullRequestStatus | null;
notify: boolean;
}): void {
const target = pollTargets.get(getPollTargetKey(options));
if (!target) {
return;
}
target.latestStatus = options.status;
target.consecutiveErrors = 0;
if (options.notify) {
for (const callback of target.callbacks) {
callback(options.status);
}
}
scheduleGitHubPoll(target);
}
function scheduleGitHubPoll(target: GitHubPollTarget): void {
scheduleGitHubPollAfter(
target,
computeGithubNextInterval(target.latestStatus, target.consecutiveErrors),
);
}
function scheduleImmediateGitHubPoll(target: GitHubPollTarget): void {
scheduleGitHubPollAfter(target, 0);
}
function scheduleGitHubPollAfter(target: GitHubPollTarget, delayMs: number): void {
if (target.retainCount <= 0) {
return;
}
if (target.timer) {
clearTimeout(target.timer);
}
target.timer = setTimeout(() => {
target.timer = null;
void runGitHubPoll(target);
}, delayMs);
}
async function runGitHubPoll(target: GitHubPollTarget): Promise<void> {
try {
await api.getCurrentPullRequestStatus({
cwd: target.cwd,
headRef: target.headRef,
reason: "self-heal-github",
});
} catch (error) {
target.consecutiveErrors += 1;
for (const callback of target.errorCallbacks) {
callback(error);
}
scheduleGitHubPoll(target);
}
}
function closeGitHubPollTarget(target: GitHubPollTarget): void {
if (target.timer) {
clearTimeout(target.timer);
target.timer = null;
}
target.retainCount = 0;
target.callbacks.clear();
target.errorCallbacks.clear();
}
api = {
listPullRequests(options) {
return cached({
cwd: options.cwd,
method: "listPullRequests",
args: { query: options.query ?? "", limit: options.limit ?? 20 },
readOptions: options,
load: async () => {
const stdout = await run(
[
@@ -559,6 +709,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
cwd: options.cwd,
method: "listIssues",
args: { query: options.query ?? "", limit: options.limit ?? 20 },
readOptions: options,
load: async () => {
const stdout = await run(
[
@@ -583,6 +734,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
cwd: options.cwd,
method: "getPullRequest",
args: { number: options.number },
readOptions: options,
load: async () => {
const stdout = await run(
[
@@ -609,6 +761,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
cwd: options.cwd,
method: "getCurrentPullRequestStatus",
args: { headRef: options.headRef },
readOptions: options,
load: async () => {
return resolveCurrentPullRequestView({
cwd: options.cwd,
@@ -616,6 +769,14 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
run,
});
},
}).then((status) => {
updatePollTargetAfterSuccess({
cwd: options.cwd,
headRef: options.headRef,
status,
notify: options.reason === "self-heal-github",
});
return status;
});
},
@@ -624,6 +785,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
cwd: options.cwd,
method: "getPullRequestTimeline",
args: { prNumber: options.prNumber },
readOptions: options,
load: async () => {
try {
const stdout = await run(
@@ -660,6 +822,96 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
});
},
async searchIssuesAndPrs(options) {
if (options.force && !options.reason) {
throw new Error("GitHubService forced read requires a reason");
}
const kinds = options.kinds ?? ["github-issue", "github-pr"];
const shouldFetchIssues = kinds.includes("github-issue");
const shouldFetchPullRequests = kinds.includes("github-pr");
const readOptions: GitHubReadOptions = options.force
? { force: true, reason: options.reason }
: { force: false, reason: options.reason };
const [issuesResult, prsResult] = await Promise.allSettled([
shouldFetchIssues
? this.listIssues({
cwd: options.cwd,
query: options.query,
limit: options.limit,
...readOptions,
})
: Promise.resolve(null),
shouldFetchPullRequests
? this.listPullRequests({
cwd: options.cwd,
query: options.query,
limit: options.limit,
...readOptions,
})
: Promise.resolve(null),
]);
const items: GitHubSearchResult["items"] = [];
const requestedResults = [
shouldFetchIssues ? issuesResult : null,
shouldFetchPullRequests ? prsResult : null,
].filter((result) => result !== null);
if (
requestedResults.length > 0 &&
requestedResults.every(
(result) =>
result.status === "rejected" &&
(result.reason instanceof GitHubCliMissingError ||
result.reason instanceof GitHubAuthenticationError),
)
) {
return { items: [], githubFeaturesEnabled: false };
}
if (shouldFetchIssues && issuesResult.status === "fulfilled") {
for (const item of issuesResult.value ?? []) {
items.push({
kind: "issue",
number: item.number,
title: item.title,
url: item.url,
state: item.state,
body: item.body,
labels: item.labels,
baseRefName: null,
headRefName: null,
updatedAt: item.updatedAt,
});
}
}
if (shouldFetchPullRequests && prsResult.status === "fulfilled") {
for (const item of prsResult.value ?? []) {
items.push({
kind: "pr",
number: item.number,
title: item.title,
url: item.url,
state: item.state,
body: item.body,
labels: item.labels,
baseRefName: item.baseRefName,
headRefName: item.headRefName,
updatedAt: item.updatedAt,
});
}
}
items.sort((left, right) => {
const leftTime = parseOptionalTime(left.updatedAt ?? null);
const rightTime = parseOptionalTime(right.updatedAt ?? null);
return rightTime - leftTime;
});
return { items, githubFeaturesEnabled: true };
},
async createPullRequest(options) {
const args = [
"api",
@@ -689,6 +941,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
cwd: options.cwd,
method: "isAuthenticated",
args: {},
readOptions: options,
load: async () => {
try {
await run(["auth", "status"], { cwd: options.cwd });
@@ -706,6 +959,60 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
});
},
retainCurrentPullRequestStatusPoll(options) {
const key = getPollTargetKey(options);
let target = pollTargets.get(key);
if (!target) {
target = {
cwd: options.cwd,
headRef: options.headRef,
retainCount: 0,
timer: null,
latestStatus: null,
consecutiveErrors: 0,
callbacks: new Set(),
errorCallbacks: new Set(),
};
pollTargets.set(key, target);
}
const isNewlyRetained = target.retainCount === 0;
target.retainCount += 1;
if (options.onStatus) {
target.callbacks.add(options.onStatus);
}
if (options.onError) {
target.errorCallbacks.add(options.onError);
}
if (isNewlyRetained) {
scheduleImmediateGitHubPoll(target);
} else {
scheduleGitHubPoll(target);
}
let unsubscribed = false;
return {
unsubscribe: () => {
if (unsubscribed) {
return;
}
unsubscribed = true;
if (options.onStatus) {
target.callbacks.delete(options.onStatus);
}
if (options.onError) {
target.errorCallbacks.delete(options.onError);
}
target.retainCount -= 1;
if (target.retainCount > 0) {
return;
}
closeGitHubPollTarget(target);
pollTargets.delete(key);
},
};
},
invalidate(options) {
// Local checkout mutations that can alter the current PR identity or PR status
// must call this with the affected cwd before broadcasting fresh git state.
@@ -720,7 +1027,40 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
}
}
},
dispose() {
for (const target of pollTargets.values()) {
closeGitHubPollTarget(target);
}
pollTargets.clear();
},
};
return api;
}
export function computeGithubNextInterval(
status: GitHubCurrentPullRequestStatus | null,
consecutiveErrors: number,
): number {
const baseInterval = isGitHubStatusPending(status)
? GITHUB_POLL_FAST_INTERVAL_MS
: GITHUB_POLL_SLOW_INTERVAL_MS;
if (consecutiveErrors <= 1) {
return baseInterval;
}
return Math.min(baseInterval * 2 ** (consecutiveErrors - 1), GITHUB_POLL_ERROR_BACKOFF_CAP_MS);
}
function isGitHubStatusPending(status: GitHubCurrentPullRequestStatus | null): boolean {
if (!status) {
return false;
}
if (status.checksStatus === "pending") {
return true;
}
return status.checks.some((check) => check.status === "pending");
}
async function resolveGhPath(): Promise<string | null> {
@@ -1393,13 +1733,20 @@ function mapReviewDecision(value: unknown): PullRequestReviewDecision {
return null;
}
export async function resolveGitHubRepo(cwd: string): Promise<string | null> {
export interface GitHubRepoRemoteUrlResolver {
resolveRepoRemoteUrl(cwd: string, options?: GitHubReadOptions): Promise<string | null>;
}
export async function resolveGitHubRepo(
cwd: string,
options: { workspaceGitService: GitHubRepoRemoteUrlResolver; readOptions?: GitHubReadOptions },
): Promise<string | null> {
try {
const { stdout } = await runGitCommand(["config", "--get", "remote.origin.url"], {
const remoteUrl = await options.workspaceGitService.resolveRepoRemoteUrl(
cwd,
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
});
return parseGitHubRepoFromRemote(stdout.trim());
options.readOptions,
);
return parseGitHubRepoFromRemote(remoteUrl?.trim() ?? "");
} catch {
return null;
}