Skip to content

Commit 5f4c6f4

Browse files
committed
feat(git): allow multiple PRs per task with an Other PRs submenu
A task now accumulates every PR created for it instead of keeping a single overwritten pr_url. The first-created PR stays primary; the rest appear in an "Other PRs" submenu on the PR badge dropdown showing each PR's number, a generated short summary, lifecycle state, and repo (when it differs). Clicking one promotes it to primary, optimistically so the switch is instant. Cloud TaskRun.output stays backwards and forwards compatible: writers maintain pr_url === pr_urls[0] plus an additive pr_summaries dict, all writes are fetch-merge-patch preserving foreign keys, and readers reconcile old-client pr_url overwrites by appending them at the end. Attach writes are serialized per session so concurrent PR creations can't clobber each other, and findPrUrls now extracts every PR URL in an output chunk rather than the first. Local accumulation only trusts attributable detections (cloud attribution, linked branch, dedicated worktree); PRs found on a shared folder's current branch no longer leak into other tasks' lists, and a data migration resets previously accumulated lists. Generated-By: PostHog Code Task-Id: 6a70b8ad-752c-451e-bdd6-a4bf178dfe38
1 parent 8dcfcb1 commit 5f4c6f4

45 files changed

Lines changed: 3398 additions & 114 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/agent/src/agent.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { buildPrOutput, mergePrUrls, readPrUrls } from "@posthog/shared";
12
import {
23
createAcpConnection,
34
type InProcessAcpConnection,
@@ -167,8 +168,13 @@ export class Agent {
167168
throw error;
168169
}
169170

171+
const freshOutput = await this.posthogAPI
172+
.getTaskRun(taskId, this.taskRunId)
173+
.then((run) => run.output)
174+
.catch(() => null);
175+
const urls = mergePrUrls(readPrUrls(freshOutput), [prUrl]);
170176
const updates: TaskRunUpdate = {
171-
output: { pr_url: prUrl },
177+
output: buildPrOutput(freshOutput, urls),
172178
};
173179
if (branchName) {
174180
updates.branch = branchName;

packages/agent/src/pr-url-detector.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { findPrUrl, wasCreatedRecently } from "./pr-url-detector";
2+
import { findPrUrl, findPrUrls, wasCreatedRecently } from "./pr-url-detector";
33

44
const PR_URL = "https://github.com/PostHog/posthog.com/pull/17764";
55

@@ -33,6 +33,25 @@ describe("findPrUrl", () => {
3333
});
3434
});
3535

36+
describe("findPrUrls", () => {
37+
const OTHER = "https://github.com/PostHog/posthog/pull/99";
38+
39+
it("finds every PR URL in one chunk, in order", () => {
40+
expect(findPrUrls(`Opened ${PR_URL} and ${OTHER} today`)).toEqual([
41+
PR_URL,
42+
OTHER,
43+
]);
44+
});
45+
46+
it("dedupes repeated mentions of the same PR", () => {
47+
expect(findPrUrls(`${PR_URL} again: ${PR_URL}`)).toEqual([PR_URL]);
48+
});
49+
50+
it("returns an empty array when there is no PR URL", () => {
51+
expect(findPrUrls("nothing here")).toEqual([]);
52+
});
53+
});
54+
3655
describe("wasCreatedRecently", () => {
3756
const now = new Date("2026-06-18T17:00:00Z").getTime();
3857
const maxAge = 15 * 60 * 1000;

packages/agent/src/pr-url-detector.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1-
const PR_URL_REGEX = /https:\/\/github\.com\/[^/\s"]+\/[^/\s"]+\/pull\/\d+/;
1+
const PR_URL_REGEX = /https:\/\/github\.com\/[^/\s"]+\/[^/\s"]+\/pull\/\d+/g;
22

33
// A fixed window (not "since run start") so a PR the agent merely views on a
44
// long run is too old to be mistaken for one it just created.
55
export const PR_CREATION_RECENCY_MS = 5 * 60 * 1000;
66

77
export function findPrUrl(text: string): string | null {
8-
return text.match(PR_URL_REGEX)?.[0] ?? null;
8+
return findPrUrls(text)[0] ?? null;
9+
}
10+
11+
export function findPrUrls(text: string): string[] {
12+
return [...new Set(text.match(PR_URL_REGEX) ?? [])];
913
}
1014

1115
// Fails closed on missing/invalid input so we never attribute on uncertainty.

packages/agent/src/server/agent-server.test.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1865,7 +1865,10 @@ describe("AgentServer HTTP Mode", () => {
18651865
): void;
18661866
fetchPrCreatedAt(url: string): Promise<string | null>;
18671867
detectedPrUrl: string | null;
1868-
posthogAPI: { updateTaskRun: ReturnType<typeof vi.fn> };
1868+
posthogAPI: {
1869+
getTaskRun: ReturnType<typeof vi.fn>;
1870+
updateTaskRun: ReturnType<typeof vi.fn>;
1871+
};
18691872
};
18701873

18711874
const justNow = () => new Date().toISOString();
@@ -1874,7 +1877,20 @@ describe("AgentServer HTTP Mode", () => {
18741877
const setup = (prCreatedAt: string | null): PrTestServer => {
18751878
const s = createServer() as unknown as PrTestServer;
18761879
s.fetchPrCreatedAt = vi.fn(async () => prCreatedAt);
1877-
s.posthogAPI = { updateTaskRun: vi.fn(async () => ({})) };
1880+
let storedOutput: Record<string, unknown> | null = null;
1881+
s.posthogAPI = {
1882+
getTaskRun: vi.fn(async () => ({ output: storedOutput })),
1883+
updateTaskRun: vi.fn(
1884+
async (
1885+
_taskId: string,
1886+
_runId: string,
1887+
updates: { output: Record<string, unknown> },
1888+
) => {
1889+
storedOutput = updates.output;
1890+
return {};
1891+
},
1892+
),
1893+
};
18781894
return s;
18791895
};
18801896

@@ -1885,7 +1901,7 @@ describe("AgentServer HTTP Mode", () => {
18851901
s.maybeAttachCreatedPr(payload, terminalUpdate(PR_URL));
18861902
await flush();
18871903
expect(s.posthogAPI.updateTaskRun).toHaveBeenCalledWith("t", "r", {
1888-
output: { pr_url: PR_URL },
1904+
output: { pr_url: PR_URL, pr_urls: [PR_URL] },
18891905
});
18901906
expect(s.detectedPrUrl).toBe(PR_URL);
18911907
});
@@ -1916,16 +1932,15 @@ describe("AgentServer HTTP Mode", () => {
19161932
expect(s.posthogAPI.updateTaskRun).toHaveBeenCalledTimes(1);
19171933
});
19181934

1919-
it("attributes the most recent PR when a run opens several, in detection order", async () => {
1920-
// output.pr_url holds one value; the latest PR the run created is the useful one.
1935+
it("accumulates every PR a run opens, keeping the first as primary", async () => {
19211936
const s = setup(justNow());
19221937
const second = "https://github.com/PostHog/posthog.com/pull/17765";
19231938
s.maybeAttachCreatedPr(payload, terminalUpdate(PR_URL));
19241939
s.maybeAttachCreatedPr(payload, terminalUpdate(second));
19251940
await flush();
19261941
expect(s.posthogAPI.updateTaskRun).toHaveBeenCalledTimes(2);
19271942
expect(s.posthogAPI.updateTaskRun).toHaveBeenLastCalledWith("t", "r", {
1928-
output: { pr_url: second },
1943+
output: { pr_url: PR_URL, pr_urls: [PR_URL, second] },
19291944
});
19301945
expect(s.detectedPrUrl).toBe(second);
19311946
});

packages/agent/src/server/agent-server.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { type ServerType, serve } from "@hono/node-server";
1717
import { execGh } from "@posthog/git/gh";
1818
import { getCurrentBranch } from "@posthog/git/queries";
19+
import { buildPrOutput, mergePrUrls, readPrUrls } from "@posthog/shared";
1920
import { unzipSync } from "fflate";
2021
import { Hono } from "hono";
2122
import { z } from "zod";
@@ -44,7 +45,7 @@ import type { PermissionMode } from "../execution-mode";
4445
import { DEFAULT_CODEX_MODEL, fetchGatewayModels } from "../gateway-models";
4546
import { HandoffCheckpointTracker } from "../handoff-checkpoint";
4647
import { PostHogAPIClient } from "../posthog-api";
47-
import { findPrUrl, wasCreatedRecently } from "../pr-url-detector";
48+
import { findPrUrls, wasCreatedRecently } from "../pr-url-detector";
4849
import {
4950
formatConversationForResume,
5051
type ResumeState,
@@ -3351,13 +3352,14 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions}
33513352
update: Record<string, unknown> | undefined,
33523353
): void {
33533354
if (!update) return;
3354-
const prUrl = findPrUrl(JSON.stringify(update));
3355-
if (!prUrl || this.evaluatedPrUrls.has(prUrl)) return;
3356-
this.evaluatedPrUrls.add(prUrl);
3357-
// Chain so attributions run in detection order; later PRs overwrite earlier ones.
3358-
this.prAttributionChain = this.prAttributionChain
3359-
.catch(() => {})
3360-
.then(() => this.attachPrIfCreatedThisRun(payload, prUrl));
3355+
for (const prUrl of findPrUrls(JSON.stringify(update))) {
3356+
if (this.evaluatedPrUrls.has(prUrl)) continue;
3357+
this.evaluatedPrUrls.add(prUrl);
3358+
// Chain so attributions run in detection order; later PRs append after earlier ones.
3359+
this.prAttributionChain = this.prAttributionChain
3360+
.catch(() => {})
3361+
.then(() => this.attachPrIfCreatedThisRun(payload, prUrl));
3362+
}
33613363
}
33623364

33633365
private async attachPrIfCreatedThisRun(
@@ -3385,8 +3387,13 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions}
33853387
this.detectedPrUrl = prUrl;
33863388

33873389
try {
3390+
const freshOutput = await this.posthogAPI
3391+
.getTaskRun(payload.task_id, payload.run_id)
3392+
.then((run) => run.output)
3393+
.catch(() => null);
3394+
const urls = mergePrUrls(readPrUrls(freshOutput), [prUrl]);
33883395
await this.posthogAPI.updateTaskRun(payload.task_id, payload.run_id, {
3389-
output: { pr_url: prUrl },
3396+
output: buildPrOutput(freshOutput, urls),
33903397
});
33913398
this.logger.debug("Attributed created PR to task run", {
33923399
taskId: payload.task_id,

packages/core/src/git-interaction/gitInteractionService.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ describe("GitInteractionService.runCreatePr", () => {
276276
expect(effects.attachPrUrlToTask).toHaveBeenCalledWith(
277277
"t",
278278
"https://example.test/pr/1",
279+
undefined,
279280
);
280281
if (result.outcome === "success") {
281282
expect(result.linkedBranchName).toBe("feature-x");

packages/core/src/git-interaction/gitInteractionService.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export interface GitInteractionEffects {
8282
markFirstPrShipped(): void;
8383
celebrate(): void;
8484
openExternalUrl(url: string): void;
85-
attachPrUrlToTask(taskId: string, prUrl: string): void;
85+
attachPrUrlToTask(taskId: string, prUrl: string, prTitle?: string): void;
8686
getConversationContext(taskId: string): string | undefined;
8787
logError(message: string, error: unknown): void;
8888
logWarn(message: string, context: Record<string, unknown>): void;
@@ -385,7 +385,11 @@ export class GitInteractionService {
385385

386386
if (result.prUrl) {
387387
this.effects.openExternalUrl(result.prUrl);
388-
this.effects.attachPrUrlToTask(input.taskId, result.prUrl);
388+
this.effects.attachPrUrlToTask(
389+
input.taskId,
390+
result.prUrl,
391+
input.prTitle.trim() || undefined,
392+
);
389393
}
390394

391395
return {

packages/core/src/git-pr/git-pr.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,40 @@ ${truncatedDiff || "(no diff available)"}${contextSection}`;
233233
};
234234
}
235235

236+
async generatePrShortSummary(
237+
conversationContext?: string,
238+
prTitle?: string,
239+
): Promise<{ summary: string }> {
240+
if (!conversationContext && !prTitle) return { summary: "" };
241+
242+
const system = `You generate ultra-short labels for pull requests. Given context about a PR, output a label of 15-20 characters that captures what the PR does.
243+
244+
Rules:
245+
- 15-20 characters total, never more than 24
246+
- Plain words, no punctuation, no quotes, no trailing period
247+
- Imperative mood ("Fix login loop" not "Fixed login loop")
248+
- Output only the label, nothing else`;
249+
250+
const parts: string[] = [];
251+
if (prTitle) parts.push(`PR title: ${prTitle}`);
252+
if (conversationContext) {
253+
parts.push(`Conversation context:\n${conversationContext}`);
254+
}
255+
256+
const response = await this.llm.prompt(
257+
[{ role: "user", content: parts.join("\n\n") }],
258+
{
259+
system,
260+
maxTokens: 30,
261+
model: HELPER_GATEWAY_MODEL,
262+
posthogProperties: { $ai_span_name: "pr_short_summary" },
263+
},
264+
);
265+
266+
const summary = response.content.trim().replace(/^["']|["']$/g, "");
267+
return { summary: summary.length > 24 ? summary.slice(0, 24) : summary };
268+
}
269+
236270
/**
237271
* Orchestrate branch -> commit -> push -> PR creation as a saga. Host git/gh
238272
* operations come through `host`; commit-message and PR-description generation

packages/core/src/git/router-schemas.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,7 @@ export const getPrDetailsByUrlOutput = z.object({
384384
merged: z.boolean(),
385385
draft: z.boolean(),
386386
headRefName: z.string().nullable(),
387+
title: z.string().nullable(),
387388
});
388389
export type PrDetailsByUrlOutput = z.infer<typeof getPrDetailsByUrlOutput>;
389390

@@ -496,6 +497,15 @@ export const generatePrTitleAndBodyOutput = z.object({
496497
body: z.string(),
497498
});
498499

500+
export const generatePrShortSummaryInput = z.object({
501+
conversationContext: z.string().optional(),
502+
prTitle: z.string().optional(),
503+
});
504+
505+
export const generatePrShortSummaryOutput = z.object({
506+
summary: z.string(),
507+
});
508+
499509
export const gitStateSnapshotSchema = z.object({
500510
changedFiles: z.array(changedFileSchema).optional(),
501511
diffStats: diffStatsSchema.optional(),

packages/core/src/sidebar/buildSidebarData.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { readPrUrls } from "@posthog/shared";
12
import type { Task, TaskRunStatus } from "@posthog/shared/domain-types";
23
import { getRepositoryInfo } from "./groupTasks";
34
import type { TaskData } from "./sidebarData.types";
@@ -150,9 +151,9 @@ export function deriveTaskData(
150151
taskLastViewedAt != null && lastActivityAt > taskLastViewedAt;
151152

152153
const cloudPrUrl =
153-
typeof task.latest_run?.output?.pr_url === "string"
154-
? task.latest_run.output.pr_url
155-
: ((session?.cloudOutput?.pr_url as string | undefined) ?? null);
154+
readPrUrls(task.latest_run?.output)[0] ??
155+
readPrUrls(session?.cloudOutput)[0] ??
156+
null;
156157

157158
const originProduct =
158159
task.origin_product ??

0 commit comments

Comments
 (0)