Skip to content
Draft
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
20 changes: 20 additions & 0 deletions apps/code/src/main/services/git/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,3 +623,23 @@ export const createPrProgressPayload = z.object({
});

export type CreatePrProgressPayload = z.infer<typeof createPrProgressPayload>;

// PR work items: the current user's open PRs that need action. Each kind is a
// distinct reason a single PR is "waiting on you" (a PR can yield several).
export const prWorkItemKindSchema = z.enum(["review", "ci", "conflict"]);
export type PrWorkItemKind = z.infer<typeof prWorkItemKindSchema>;

export const prWorkItemSchema = z.object({
kind: prWorkItemKindSchema,
prNumber: z.number(),
title: z.string(),
url: z.string(),
headRefName: z.string(),
// Head commit SHA — lets dismissals be commit-scoped (a new push re-surfaces).
headSha: z.string(),
});

export type PrWorkItem = z.infer<typeof prWorkItemSchema>;

export const getPrWorkItemsInput = directoryPathInput;
export const getPrWorkItemsOutput = z.array(prWorkItemSchema);
175 changes: 174 additions & 1 deletion apps/code/src/main/services/git/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import type { IWorkspaceRepository } from "../../db/repositories/workspace-repos
import type { AgentService } from "../agent/service";
import type { LlmGatewayService } from "../llm-gateway/service";
import type { WorkspaceService } from "../workspace/service";
import { GitService, mapPrState } from "./service";
import { derivePrWorkItems, GitService, mapPrState } from "./service";

const stubWorkspaceRepo = {} as IWorkspaceRepository;

Expand Down Expand Up @@ -545,3 +545,176 @@ describe("GitService.resolveReviewThread", () => {
expect(result).toEqual({ success: false, isResolved: false });
});
});

describe("derivePrWorkItems", () => {
// `ghPr` mirrors a `gh pr list --json` row (uses `number`); `expected` is the
// derived work-item shape (uses `prNumber`).
const ghPr = {
number: 12,
title: "Add the thing",
url: "https://github.com/posthog/code/pull/12",
headRefName: "feat/thing",
headRefOid: "abc123",
};
const expected = {
prNumber: 12,
title: "Add the thing",
url: "https://github.com/posthog/code/pull/12",
headRefName: "feat/thing",
headSha: "abc123",
};

it("surfaces a review item for changes-requested", () => {
const items = derivePrWorkItems([
{ ...ghPr, reviewDecision: "CHANGES_REQUESTED" },
]);
expect(items).toEqual([{ ...expected, kind: "review" }]);
});

it("surfaces a ci item when a check fails (conclusion or state)", () => {
expect(
derivePrWorkItems([
{ ...ghPr, statusCheckRollup: [{ conclusion: "FAILURE" }] },
]),
).toEqual([{ ...expected, kind: "ci" }]);
expect(
derivePrWorkItems([{ ...ghPr, statusCheckRollup: [{ state: "ERROR" }] }]),
).toEqual([{ ...expected, kind: "ci" }]);
});

it("surfaces a conflict item when mergeable is CONFLICTING", () => {
const items = derivePrWorkItems([{ ...ghPr, mergeable: "CONFLICTING" }]);
expect(items).toEqual([{ ...expected, kind: "conflict" }]);
});

it("surfaces multiple items for one PR with multiple problems", () => {
const items = derivePrWorkItems([
{
...ghPr,
reviewDecision: "CHANGES_REQUESTED",
mergeable: "CONFLICTING",
statusCheckRollup: [{ conclusion: "FAILURE" }],
},
]);
expect(items.map((i) => i.kind)).toEqual(["review", "ci", "conflict"]);
});

it("yields nothing for a clean PR", () => {
expect(
derivePrWorkItems([
{
...ghPr,
reviewDecision: "APPROVED",
mergeable: "MERGEABLE",
statusCheckRollup: [{ conclusion: "SUCCESS" }, { state: "PENDING" }],
},
]),
).toEqual([]);
});

it("for a draft, surfaces only conflicts (skips review/ci)", () => {
expect(
derivePrWorkItems([
{
...ghPr,
isDraft: true,
reviewDecision: "CHANGES_REQUESTED",
mergeable: "CONFLICTING",
statusCheckRollup: [{ conclusion: "FAILURE" }],
},
]),
).toEqual([{ ...expected, kind: "conflict" }]);
});

it("yields nothing for a draft with no conflict", () => {
expect(
derivePrWorkItems([
{
...ghPr,
isDraft: true,
reviewDecision: "CHANGES_REQUESTED",
mergeable: "MERGEABLE",
statusCheckRollup: [{ conclusion: "FAILURE" }],
},
]),
).toEqual([]);
});
});

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

beforeEach(() => {
vi.clearAllMocks();
service = new GitService(
{} as LlmGatewayService,
{} as WorkspaceService,
{ getSessionEnvForTask: async () => ({}) } as unknown as AgentService,
stubWorkspaceRepo,
);
});

it("lists the user's open PRs and derives work items", async () => {
mockGetRemoteUrl.mockResolvedValue("https://github.com/posthog/code.git");
mockExecGh.mockResolvedValue({
exitCode: 0,
stdout: JSON.stringify([
{
number: 7,
title: "Fix login",
url: "https://github.com/posthog/code/pull/7",
headRefName: "fix/login",
headRefOid: "deadbeef",
reviewDecision: "CHANGES_REQUESTED",
mergeable: "MERGEABLE",
statusCheckRollup: [{ conclusion: "SUCCESS" }],
},
]),
});

const result = await service.getPrWorkItems("/repo");

const args = mockExecGh.mock.calls[0][0] as string[];
expect(args.slice(0, 6)).toEqual([
"pr",
"list",
"--author",
"@me",
"--state",
"open",
]);
expect(args).toContain("posthog/code");
expect(result).toEqual([
{
kind: "review",
prNumber: 7,
title: "Fix login",
url: "https://github.com/posthog/code/pull/7",
headRefName: "fix/login",
headSha: "deadbeef",
},
]);
});

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

const result = await service.getPrWorkItems("/repo");

expect(result).toEqual([]);
expect(mockExecGh).not.toHaveBeenCalled();
});

it("returns [] when gh exits non-zero (missing/unauthenticated)", async () => {
mockGetRemoteUrl.mockResolvedValue("https://github.com/posthog/code.git");
mockExecGh.mockResolvedValue({
exitCode: 1,
stdout: "",
stderr: "auth required",
});

const result = await service.getPrWorkItems("/repo");

expect(result).toEqual([]);
});
});
105 changes: 105 additions & 0 deletions apps/code/src/main/services/git/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import type {
PrReviewComment,
PrReviewThread,
PrStatusOutput,
PrWorkItem,
PublishOutput,
PullOutput,
PushOutput,
Expand Down Expand Up @@ -131,6 +132,68 @@ function toUnifiedDiffPatch(
return `diff --git a/${oldPath} b/${filename}\n--- ${fromPath}\n+++ ${toPath}\n${rawPatch}`;
}

/** Shape of a `gh pr list --json …` row consumed by `derivePrWorkItems`. */
interface GhPrListItem {
number: number;
title: string;
url: string;
headRefName: string;
headRefOid?: string;
mergeable?: string;
reviewDecision?: string;
isDraft?: boolean;
statusCheckRollup?: Array<{ state?: string; conclusion?: string }>;
}

// Check-run `conclusion` / status-context `state` values that count as a real
// failure worth a fix-it task. Ambiguous outcomes (cancelled, action_required,
// pending) are intentionally excluded so we don't nag about non-failures.
const FAILED_CHECK_CONCLUSIONS = new Set(["FAILURE", "TIMED_OUT"]);
const FAILED_CHECK_STATES = new Set(["FAILURE", "ERROR"]);

function hasFailingCheck(rollup: GhPrListItem["statusCheckRollup"]): boolean {
if (!rollup?.length) return false;
return rollup.some((check) => {
const conclusion = check.conclusion?.toUpperCase();
const state = check.state?.toUpperCase();
return (
(!!conclusion && FAILED_CHECK_CONCLUSIONS.has(conclusion)) ||
(!!state && FAILED_CHECK_STATES.has(state))
);
});
}

/**
* Derives 0..N work items from the current user's open PRs. A single PR can
* surface several (e.g. changes-requested *and* failing CI).
*
* Drafts surface only `conflict`: a merge conflict is the author's to resolve
* regardless of ready state and only rots, whereas changes-requested / failing
* CI on a draft is expected work-in-progress noise.
*/
export function derivePrWorkItems(prs: GhPrListItem[]): PrWorkItem[] {
const items: PrWorkItem[] = [];
for (const pr of prs) {
const base = {
prNumber: pr.number,
title: pr.title,
url: pr.url,
headRefName: pr.headRefName,
headSha: pr.headRefOid ?? "",
};
if (!pr.isDraft && pr.reviewDecision === "CHANGES_REQUESTED") {
items.push({ ...base, kind: "review" });
}
if (!pr.isDraft && hasFailingCheck(pr.statusCheckRollup)) {
items.push({ ...base, kind: "ci" });
}
if (pr.mergeable === "CONFLICTING") {
items.push({ ...base, kind: "conflict" });
}
}
return items;
}

@injectable()
export class GitService extends TypedEventEmitter<GitServiceEvents> {
private lastFetchTime = new Map<string, number>();
Expand Down Expand Up @@ -1023,6 +1086,48 @@ export class GitService extends TypedEventEmitter<GitServiceEvents> {
}
}

/**
* Surfaces the current user's open PRs in this repo that need action:
* - `review`: changes requested
* - `ci`: a failing check
* - `conflict`: merge conflicts
* Returns `[]` (no noise) when this isn't a GitHub repo, gh is missing/
* unauthenticated, or the call fails.
*/
public async getPrWorkItems(directoryPath: string): Promise<PrWorkItem[]> {
try {
const remoteUrl = await getRemoteUrl(directoryPath);
const parsed = remoteUrl ? parseGithubUrl(remoteUrl) : null;
if (!parsed) return [];

const result = await execGh(
[
"pr",
"list",
"--author",
"@me",
"--state",
"open",
"--limit",
"20",
"--repo",
`${parsed.owner}/${parsed.repo}`,
"--json",
"number,title,url,headRefName,headRefOid,mergeable,reviewDecision,statusCheckRollup,isDraft",
],
{ cwd: directoryPath },
);

if (result.exitCode !== 0) return [];

const prs = JSON.parse(result.stdout) as GhPrListItem[];
return derivePrWorkItems(prs);
} catch (error) {
log.warn("Failed to fetch PR work items", { directoryPath, error });
return [];
}
}

private async createPrViaGh(
directoryPath: string,
title?: string,
Expand Down
7 changes: 7 additions & 0 deletions apps/code/src/main/trpc/routers/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ import {
getPrTemplateOutput,
getPrUrlForBranchInput,
getPrUrlForBranchOutput,
getPrWorkItemsInput,
getPrWorkItemsOutput,
ghAuthTokenOutput,
ghStatusOutput,
gitStateSnapshotSchema,
Expand Down Expand Up @@ -339,6 +341,11 @@ export const gitRouter = router({
getService().getPrUrlForBranch(input.directoryPath, input.branchName),
),

getPrWorkItems: publicProcedure
.input(getPrWorkItemsInput)
.output(getPrWorkItemsOutput)
.query(({ input }) => getService().getPrWorkItems(input.directoryPath)),

createPr: publicProcedure
.input(createPrInput)
.output(createPrOutput)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export function SuggestedTaskCard({
e.stopPropagation();
onDismiss(task);
}}
className="flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-(--gray-9) hover:bg-(--gray-a3) hover:text-(--gray-12)"
className="flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-md bg-(--gray-3) text-(--gray-11) shadow-sm transition-colors hover:bg-(--gray-4) hover:text-(--gray-12)"
>
<X size={12} weight="bold" />
</button>
Expand Down
Loading
Loading