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:
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user