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
1 change: 1 addition & 0 deletions apps/server/src/git/Layers/GitHubCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ const makeGitHubCli = Effect.sync(() => {
runProcess("gh", input.args, {
cwd: input.cwd,
timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
...(input.stdin !== undefined ? { stdin: input.stdin } : {}),
}),
catch: (error) => normalizeGitHubCliError("execute", error),
});
Expand Down
66 changes: 54 additions & 12 deletions apps/server/src/git/Layers/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
sanitizeBranchFragment,
sanitizeFeatureBranchName,
} from "@t3tools/shared/git";
import { createTtlCache } from "@t3tools/shared/cache";

import { GitCommandError, GitManagerError } from "../Errors.ts";
import { GitManager, type GitManagerShape } from "../Services/GitManager.ts";
Expand Down Expand Up @@ -361,6 +362,13 @@ function toPullRequestHeadRemoteInfo(pr: {
};
}

// Cache PR lookups for 60s — the PR state for a branch rarely changes between polls.
const latestPrCache = createTtlCache<PullRequestInfo | null>(60_000);
// Cache repository clone URLs for 5 minutes — repo clone URLs are essentially static.
const repoCloneUrlCache = createTtlCache<{ sshUrl: string; url: string }>(300_000);
// Cache default branch for 15 minutes — essentially never changes for a given repo.
const defaultBranchCache = createTtlCache<string | null>(900_000);

export const makeGitManager = Effect.gen(function* () {
const gitCore = yield* GitCore;
const gitHubCli = yield* GitHubCli;
Expand All @@ -377,10 +385,16 @@ export const makeGitManager = Effect.gen(function* () {
return;
}

const cloneUrls = yield* gitHubCli.getRepositoryCloneUrls({
cwd,
repository: repositoryNameWithOwner,
});
const cachedCloneUrls = repoCloneUrlCache.get(repositoryNameWithOwner);
const cloneUrls = cachedCloneUrls
? cachedCloneUrls
: yield* gitHubCli
.getRepositoryCloneUrls({ cwd, repository: repositoryNameWithOwner })
.pipe(
Effect.tap((urls) =>
Effect.sync(() => repoCloneUrlCache.set(repositoryNameWithOwner, urls)),
),
);
const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url");
const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url;
const preferredRemoteName =
Expand Down Expand Up @@ -424,10 +438,16 @@ export const makeGitManager = Effect.gen(function* () {
return;
}

const cloneUrls = yield* gitHubCli.getRepositoryCloneUrls({
cwd,
repository: repositoryNameWithOwner,
});
const cachedCloneUrls = repoCloneUrlCache.get(repositoryNameWithOwner);
const cloneUrls = cachedCloneUrls
? cachedCloneUrls
: yield* gitHubCli
.getRepositoryCloneUrls({ cwd, repository: repositoryNameWithOwner })
.pipe(
Effect.tap((urls) =>
Effect.sync(() => repoCloneUrlCache.set(repositoryNameWithOwner, urls)),
),
);
const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url");
const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url;
const preferredRemoteName =
Expand Down Expand Up @@ -587,7 +607,10 @@ export const makeGitManager = Effect.gen(function* () {
return null;
});

const findLatestPr = (cwd: string, details: { branch: string; upstreamRef: string | null }) =>
const findLatestPrUncached = (
cwd: string,
details: { branch: string; upstreamRef: string | null },
) =>
Effect.gen(function* () {
const headContext = yield* resolveBranchHeadContext(cwd, details);
const parsedByNumber = new Map<number, PullRequestInfo>();
Expand Down Expand Up @@ -640,6 +663,17 @@ export const makeGitManager = Effect.gen(function* () {
return parsed[0] ?? null;
});

const findLatestPr = (cwd: string, details: { branch: string; upstreamRef: string | null }) =>
Effect.gen(function* () {
const cacheKey = `${cwd}::${details.branch}`;
const cached = latestPrCache.get(cacheKey);
if (cached !== undefined) return cached;

const result = yield* findLatestPrUncached(cwd, details);
latestPrCache.set(cacheKey, result);
return result;
});

const resolveBaseBranch = (
cwd: string,
branch: string,
Expand All @@ -657,9 +691,14 @@ export const makeGitManager = Effect.gen(function* () {
}
}

const defaultFromGh = yield* gitHubCli
.getDefaultBranch({ cwd })
.pipe(Effect.catch(() => Effect.succeed(null)));
const cachedDefault = defaultBranchCache.get(cwd);
if (cachedDefault !== undefined) {
return cachedDefault ?? "main";
}
const defaultFromGh = yield* gitHubCli.getDefaultBranch({ cwd }).pipe(
Effect.tap((result) => Effect.sync(() => defaultBranchCache.set(cwd, result))),
Effect.catch(() => Effect.succeed(null)),
);
if (defaultFromGh) {
return defaultFromGh;
}
Expand Down Expand Up @@ -761,6 +800,9 @@ export const makeGitManager = Effect.gen(function* () {
upstreamRef: details.upstreamRef,
});

// Invalidate the PR cache since we're about to look up / create a PR
latestPrCache.invalidate(`${cwd}::${branch}`);

const existing = yield* findOpenPr(cwd, headContext.headSelectors);
if (existing) {
return {
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/git/Services/GitHubCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export interface GitHubCliShape {
readonly cwd: string;
readonly args: ReadonlyArray<string>;
readonly timeoutMs?: number;
readonly stdin?: string;
}) => Effect.Effect<ProcessRunResult, GitHubCliError>;

/**
Expand Down
147 changes: 130 additions & 17 deletions apps/server/src/wsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,22 @@ import { expandHomePath } from "./os-jank.ts";
import { makeServerPushBus } from "./wsServer/pushBus.ts";
import { makeServerReadiness } from "./wsServer/readiness.ts";
import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson";
import { createTtlCache } from "@t3tools/shared/cache";

// Cache PR head SHA for 2 minutes — avoids re-fetching on each comment publish.
const prHeadShaCache = createTtlCache<string>(120_000);

// Cache review request GitHub results for 60s — collapses duplicate polls from multiple tabs.
interface CachedReviewRequestPr {
url: string;
number: number;
title: string;
body: string;
repository: { nameWithOwner: string };
author: { login: string };
labels: readonly { name: string }[];
}
const reviewRequestGhCache = createTtlCache<readonly CachedReviewRequestPr[]>(60_000);

/**
* ServerShape - Service API for server lifecycle control.
Expand Down Expand Up @@ -1152,23 +1168,81 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
}
const [, owner, repo, prNumber] = prUrlMatch;

// Get the PR head SHA from GitHub API instead of the local worktree
// (worktree git link may be broken if the clone was removed).
const headSha = yield* gitHubCli
// Get the PR head SHA (cached for 2 minutes to avoid repeated API calls
// when publishing multiple comments in the same review session).
const prKey = `${owner}/${repo}#${prNumber}`;
const cachedSha = prHeadShaCache.get(prKey);
const headSha = cachedSha
? cachedSha
: yield* gitHubCli
.execute({
cwd: body.cwd,
args: ["api", `repos/${owner}/${repo}/pulls/${prNumber}`, "--jq", ".head.sha"],
timeoutMs: 15_000,
})
.pipe(
Effect.map((r) => r.stdout.trim()),
Effect.tap((sha) => Effect.sync(() => prHeadShaCache.set(prKey, sha))),
Effect.catch(() =>
// Fallback: try local git rev-parse
git.resolveRef(body.cwd, "HEAD").pipe(Effect.catch(() => Effect.succeed("HEAD"))),
),
);

// Batch-submit all comments as a single pending review (1 API call
// instead of N individual comment calls).
const reviewPayload = JSON.stringify({
commit_id: headSha,
event: "COMMENT",
comments: comments.map((c) => ({
path: c.file,
line: c.startLine,
body: c.body,
})),
});
const prUrl = `https://github.com/${owner}/${repo}/pull/${prNumber}`;

const batchResult = yield* gitHubCli
.execute({
cwd: body.cwd,
args: ["api", `repos/${owner}/${repo}/pulls/${prNumber}`, "--jq", ".head.sha"],
timeoutMs: 15_000,
args: [
"api",
`repos/${owner}/${repo}/pulls/${prNumber}/reviews`,
"-X",
"POST",
"--input",
"-",
// gh reads JSON from stdin when --input is "-"
],
timeoutMs: 30_000,
stdin: reviewPayload,
})
.pipe(
Effect.map((r) => r.stdout.trim()),
Effect.catch(() =>
// Fallback: try local git rev-parse
git.resolveRef(body.cwd, "HEAD").pipe(Effect.catch(() => Effect.succeed("HEAD"))),
),
Effect.map((r) => {
try {
const json = JSON.parse(r.stdout) as { html_url?: string };
return { ok: true as const, url: json.html_url ?? prUrl };
} catch {
return { ok: true as const, url: prUrl };
}
}),
Effect.catch(() => Effect.succeed({ ok: false as const, url: prUrl })),
);

// Create individual PR review comments, marking each as published on success.
if (batchResult.ok) {
const now = new Date().toISOString();
for (const comment of comments) {
yield* reviewCommentRepo
.update({ id: comment.id, publishedAt: now, publishedUrl: batchResult.url })
.pipe(Effect.ignore);
}
return {
published: comments.length,
url: prUrl,
};
}

// Fallback: publish comments individually if batch fails.
let published = 0;
const now = new Date().toISOString();
for (const comment of comments) {
Expand Down Expand Up @@ -1203,7 +1277,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
Effect.catch(() => Effect.succeed(null as string | null)),
);
if (ghUrl !== null) {
// Mark comment as published with the GitHub URL
yield* reviewCommentRepo
.update({ id: comment.id, publishedAt: now, publishedUrl: ghUrl })
.pipe(Effect.ignore);
Expand All @@ -1214,15 +1287,22 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
return {
published,
failed: comments.length - published,
url: `https://github.com/${owner}/${repo}/pull/${prNumber}`,
url: prUrl,
};
}

case WS_METHODS.reviewRequestList: {
// Fetch current review requests from GitHub and sync to DB
const ghResults = yield* gitHubCli
.listReviewRequests({ limit: 30 })
.pipe(Effect.catch(() => Effect.succeed([] as const)));
// Fetch current review requests from GitHub, using a 60s server-side
// cache to collapse duplicate polls from multiple browser tabs.
const cachedGhResults = reviewRequestGhCache.get("review-requests");
const ghResults = cachedGhResults
? cachedGhResults
: yield* gitHubCli.listReviewRequests({ limit: 30 }).pipe(
Effect.tap((results) =>
Effect.sync(() => reviewRequestGhCache.set("review-requests", results)),
),
Effect.catch(() => Effect.succeed([] as const)),
);

// Upsert each GitHub result into the DB
for (const pr of ghResults) {
Expand Down Expand Up @@ -1276,6 +1356,39 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
return {};
}

case WS_METHODS.reviewRequestSubmit: {
const body = stripRequestTag(request.body);

// Parse owner/repo/number from PR URL
const prUrlMatch = body.prUrl.match(/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/);
if (!prUrlMatch) {
return yield* new RouteRequestError({
message: "Invalid PR URL format. Expected: https://github.com/owner/repo/pull/123",
});
}
const [, owner, repo, prNumber] = prUrlMatch;

// Submit the review via GitHub API
yield* gitHubCli.execute({
cwd: process.cwd(),
args: [
"api",
`repos/${owner}/${repo}/pulls/${prNumber}/reviews`,
"-X",
"POST",
"-f",
`event=${body.event}`,
"-f",
`body=${body.body ?? ""}`,
],
timeoutMs: 15_000,
});

// Dismiss the review request after successful submission
yield* reviewRequestRepo.updateStatus({ id: body.id, status: "dismissed" });
return {};
}

default: {
const _exhaustiveCheck: never = request.body;
return yield* new RouteRequestError({
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/chat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { memo } from "react";
import GitActionsControl from "../GitActionsControl";
import { JiraActionsControl } from "../JiraActionsControl";
import { ReviewActionsControl } from "./ReviewActionsControl";
import { DiffIcon } from "lucide-react";
import { Badge } from "../ui/badge";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
Expand Down Expand Up @@ -107,6 +108,7 @@ export const ChatHeader = memo(function ChatHeader({
/>
)}
{activeProjectName && <GitActionsControl gitCwd={gitCwd} activeThreadId={activeThreadId} />}
<ReviewActionsControl threadId={activeThreadId} />
<JiraActionsControl
threadId={activeThreadId}
linkedJiraTicket={linkedJiraTicket}
Expand Down
Loading
Loading