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
10 changes: 10 additions & 0 deletions apps/code/src/main/services/git/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,16 @@ export const prStatusOutput = z.object({
export type PrStatusInput = z.infer<typeof prStatusInput>;
export type PrStatusOutput = z.infer<typeof prStatusOutput>;

// Look up the PR for an arbitrary branch (not necessarily the current one).
export const getPrUrlForBranchInput = z.object({
directoryPath: z.string(),
branchName: z.string(),
});
export const getPrUrlForBranchOutput = z.string().nullable();

export type GetPrUrlForBranchInput = z.infer<typeof getPrUrlForBranchInput>;
export type GetPrUrlForBranchOutput = z.infer<typeof getPrUrlForBranchOutput>;

// Create PR operation
export const createPrInput = z.object({
directoryPath: z.string(),
Expand Down
83 changes: 83 additions & 0 deletions apps/code/src/main/services/git/service.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const mockExecGh = vi.hoisted(() => vi.fn());
const mockGetRemoteUrl = vi.hoisted(() => vi.fn());

vi.mock("@posthog/git/gh", () => ({
execGh: mockExecGh,
}));

vi.mock("@posthog/git/queries", async () => {
const actual = await vi.importActual<object>("@posthog/git/queries");
return { ...actual, getRemoteUrl: mockGetRemoteUrl };
});

vi.mock("../../utils/logger.js", () => ({
logger: {
scope: () => ({
Expand Down Expand Up @@ -185,3 +191,80 @@ describe("GitService.getGhAuthToken", () => {
});
});
});

describe("GitService.getPrUrlForBranch", () => {
let service: GitService;

beforeEach(() => {
vi.clearAllMocks();
service = new GitService({} as LlmGatewayService);
});

it("returns the PR URL for a branch via gh pr list", async () => {
mockGetRemoteUrl.mockResolvedValue("https://github.com/posthog/code.git");
mockExecGh.mockResolvedValue({
exitCode: 0,
stdout: JSON.stringify([
{ url: "https://github.com/posthog/code/pull/42" },
]),
});

const result = await service.getPrUrlForBranch("/repo", "feat/x");

expect(mockExecGh).toHaveBeenCalledWith([
"pr",
"list",
"--head",
"feat/x",
"--state",
"all",
"--json",
"url",
"--limit",
"1",
"--repo",
"posthog/code",
]);
expect(result).toBe("https://github.com/posthog/code/pull/42");
});

it("returns null when no PR exists for the branch", async () => {
mockGetRemoteUrl.mockResolvedValue("https://github.com/posthog/code.git");
mockExecGh.mockResolvedValue({ exitCode: 0, stdout: "[]" });

const result = await service.getPrUrlForBranch("/repo", "feat/no-pr");

expect(result).toBeNull();
});

it("returns null for a non-GitHub remote", async () => {
mockGetRemoteUrl.mockResolvedValue("https://gitlab.com/foo/bar.git");

const result = await service.getPrUrlForBranch("/repo", "feat/x");

expect(result).toBeNull();
expect(mockExecGh).not.toHaveBeenCalled();
});

it("returns null when the repo has no remote", async () => {
mockGetRemoteUrl.mockResolvedValue(null);

const result = await service.getPrUrlForBranch("/repo", "feat/x");

expect(result).toBeNull();
expect(mockExecGh).not.toHaveBeenCalled();
});

it("returns null when gh command fails", async () => {
mockGetRemoteUrl.mockResolvedValue("https://github.com/posthog/code.git");
mockExecGh.mockResolvedValue({
exitCode: 1,
stdout: "",
stderr: "auth required",
});

const result = await service.getPrUrlForBranch("/repo", "feat/x");

expect(result).toBeNull();
});
});
48 changes: 48 additions & 0 deletions apps/code/src/main/services/git/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,54 @@ export class GitService extends TypedEventEmitter<GitServiceEvents> {
}
}

/**
* Look up the PR URL for any branch name (not just the currently checked-out
* one). Uses `gh pr list --head` rather than `gh pr view` so the lookup works
* regardless of which branch the working tree is on.
*/
public async getPrUrlForBranch(
directoryPath: string,
branchName: string,
): Promise<string | null> {
try {
const remoteUrl = await getRemoteUrl(directoryPath);
if (!remoteUrl) return null;

const parsed = parseGitHubUrl(remoteUrl);
if (!parsed) return null;

const repoSlug = `${parsed.organization}/${parsed.repository}`;
const result = await execGh([
"pr",
"list",
"--head",
branchName,
"--state",
"all",
"--json",
"url",
"--limit",
"1",
"--repo",
repoSlug,
]);

if (result.exitCode !== 0) {
log.warn("Failed to list PRs for branch", {
branchName,
error: result.stderr || result.error,
});
return null;
}

const data = JSON.parse(result.stdout) as Array<{ url?: string }>;
return data[0]?.url ?? null;
} catch (error) {
log.warn("Failed to resolve PR URL for branch", { branchName, error });
return null;
}
}

private async createPrViaGh(
directoryPath: string,
title?: string,
Expand Down
9 changes: 9 additions & 0 deletions apps/code/src/main/trpc/routers/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ import {
getPrReviewCommentsOutput,
getPrTemplateInput,
getPrTemplateOutput,
getPrUrlForBranchInput,
getPrUrlForBranchOutput,
ghAuthTokenOutput,
ghStatusOutput,
gitStateSnapshotSchema,
Expand Down Expand Up @@ -297,6 +299,13 @@ export const gitRouter = router({
.output(prStatusOutput)
.query(({ input }) => getService().getPrStatus(input.directoryPath)),

getPrUrlForBranch: publicProcedure
.input(getPrUrlForBranchInput)
.output(getPrUrlForBranchOutput)
.query(({ input }) =>
getService().getPrUrlForBranch(input.directoryPath, input.branchName),
),

createPr: publicProcedure
.input(createPrInput)
.output(createPrOutput)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useWorkspace } from "@features/workspace/hooks/useWorkspace";
import { useTRPC } from "@renderer/trpc/client";
import { useQuery } from "@tanstack/react-query";

/**
* Resolves the PR URL for a local task's linked branch by looking it up via
* `gh pr list --head`. Returns `null` when the task has no linked branch, no
* folder path, or the branch has no associated PR on GitHub.
*/
export function useLinkedBranchPrUrl(taskId: string): string | null {
const workspace = useWorkspace(taskId);
const linkedBranch = workspace?.linkedBranch ?? null;
const folderPath = workspace?.folderPath ?? null;

const trpc = useTRPC();
const { data } = useQuery(
trpc.git.getPrUrlForBranch.queryOptions(
{
directoryPath: folderPath as string,
branchName: linkedBranch as string,
},
{
enabled: !!folderPath && !!linkedBranch,
staleTime: 60_000,
refetchInterval: 5 * 60_000,
retry: 1,
Comment on lines +24 to +26
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unnecessary polling once URL is found

refetchInterval: 5 * 60_000 keeps firing network requests even after a PR URL has been successfully resolved. Since a PR URL is stable once it exists, you could stop polling after the first successful (non-null) result to avoid the extra gh pr list calls.

Suggested change
staleTime: 60_000,
refetchInterval: 5 * 60_000,
retry: 1,
enabled: !!folderPath && !!linkedBranch,
staleTime: 60_000,
refetchInterval: (query) =>
query.state.data ? false : 5 * 60_000,
retry: 1,
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/features/git-interaction/hooks/useLinkedBranchPrUrl.ts
Line: 24-26

Comment:
**Unnecessary polling once URL is found**

`refetchInterval: 5 * 60_000` keeps firing network requests even after a PR URL has been successfully resolved. Since a PR URL is stable once it exists, you could stop polling after the first successful (non-null) result to avoid the extra `gh pr list` calls.

```suggestion
        enabled: !!folderPath && !!linkedBranch,
        staleTime: 60_000,
        refetchInterval: (query) =>
          query.state.data ? false : 5 * 60_000,
        retry: 1,
```

How can I resolve this? If you propose a fix, please make it concise.

},
),
);

return data ?? null;
}
Loading