Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
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
8 changes: 7 additions & 1 deletion packages/agent/src/agent.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { buildPrOutput, mergePrUrls, readPrUrls } from "@posthog/shared";
import {
createAcpConnection,
type InProcessAcpConnection,
Expand Down Expand Up @@ -167,8 +168,13 @@ export class Agent {
throw error;
}

const freshOutput = await this.posthogAPI
.getTaskRun(taskId, this.taskRunId)
.then((run) => run.output)
.catch(() => null);
const urls = mergePrUrls(readPrUrls(freshOutput), [prUrl]);
const updates: TaskRunUpdate = {
output: { pr_url: prUrl },
output: buildPrOutput(freshOutput, urls),
};
if (branchName) {
updates.branch = branchName;
Expand Down
39 changes: 38 additions & 1 deletion packages/agent/src/pr-url-detector.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { findPrUrl, wasCreatedRecently } from "./pr-url-detector";
import {
findPrUrl,
findPrUrls,
wasCreatedByLogin,
wasCreatedRecently,
} from "./pr-url-detector";

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

Expand Down Expand Up @@ -33,6 +38,38 @@ describe("findPrUrl", () => {
});
});

describe("findPrUrls", () => {
const OTHER = "https://github.com/PostHog/posthog/pull/99";

it("finds every PR URL in one chunk, in order", () => {
expect(findPrUrls(`Opened ${PR_URL} and ${OTHER} today`)).toEqual([
PR_URL,
OTHER,
]);
});

it("dedupes repeated mentions of the same PR", () => {
expect(findPrUrls(`${PR_URL} again: ${PR_URL}`)).toEqual([PR_URL]);
});

it("returns an empty array when there is no PR URL", () => {
expect(findPrUrls("nothing here")).toEqual([]);
});
});

describe("wasCreatedByLogin", () => {
it.each([
["run-owner", "run-owner", true],
["Run-Owner", "run-owner", true],
["someone-else", "run-owner", false],
[null, "run-owner", false],
["run-owner", null, false],
["", "", false],
] as const)("author=%s login=%s -> %s", (author, login, expected) => {
expect(wasCreatedByLogin(author, login)).toBe(expected);
});
});

describe("wasCreatedRecently", () => {
const now = new Date("2026-06-18T17:00:00Z").getTime();
const maxAge = 15 * 60 * 1000;
Expand Down
17 changes: 15 additions & 2 deletions packages/agent/src/pr-url-detector.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
const PR_URL_REGEX = /https:\/\/github\.com\/[^/\s"]+\/[^/\s"]+\/pull\/\d+/;
const PR_URL_REGEX = /https:\/\/github\.com\/[^/\s"]+\/[^/\s"]+\/pull\/\d+/g;

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

export function findPrUrl(text: string): string | null {
return text.match(PR_URL_REGEX)?.[0] ?? null;
return findPrUrls(text)[0] ?? null;
}

export function findPrUrls(text: string): string[] {
return [...new Set(text.match(PR_URL_REGEX) ?? [])];
}

// Fails closed on missing/invalid input so we never attribute on uncertainty.
export function wasCreatedByLogin(
author: string | null | undefined,
login: string | null | undefined,
): boolean {
if (!author || !login) return false;
return author.toLowerCase() === login.toLowerCase();
}

// Fails closed on missing/invalid input so we never attribute on uncertainty.
Expand Down
71 changes: 57 additions & 14 deletions packages/agent/src/server/agent-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1864,18 +1864,45 @@ describe("AgentServer HTTP Mode", () => {
p: JwtPayload,
u: Record<string, unknown> | undefined,
): void;
fetchPrCreatedAt(url: string): Promise<string | null>;
fetchPrAttribution(
url: string,
): Promise<{ createdAt: string | null; author: string | null }>;
fetchGhLogin(): Promise<string | null>;
detectedPrUrl: string | null;
posthogAPI: { updateTaskRun: ReturnType<typeof vi.fn> };
posthogAPI: {
getTaskRun: ReturnType<typeof vi.fn>;
updateTaskRun: ReturnType<typeof vi.fn>;
};
};

const justNow = () => new Date().toISOString();
const longAgo = "2020-01-01T00:00:00Z";
const GH_LOGIN = "run-owner";

const setup = (prCreatedAt: string | null): PrTestServer => {
const setup = (
prCreatedAt: string | null,
prAuthor: string | null = GH_LOGIN,
): PrTestServer => {
const s = createServer() as unknown as PrTestServer;
s.fetchPrCreatedAt = vi.fn(async () => prCreatedAt);
s.posthogAPI = { updateTaskRun: vi.fn(async () => ({})) };
s.fetchPrAttribution = vi.fn(async () => ({
createdAt: prCreatedAt,
author: prAuthor,
}));
s.fetchGhLogin = vi.fn(async () => GH_LOGIN);
let storedOutput: Record<string, unknown> | null = null;
s.posthogAPI = {
getTaskRun: vi.fn(async () => ({ output: storedOutput })),
updateTaskRun: vi.fn(
async (
_taskId: string,
_runId: string,
updates: { output: Record<string, unknown> },
) => {
storedOutput = updates.output;
return {};
},
),
};
return s;
};

Expand All @@ -1886,7 +1913,7 @@ describe("AgentServer HTTP Mode", () => {
s.maybeAttachCreatedPr(payload, terminalUpdate(PR_URL));
await flush();
expect(s.posthogAPI.updateTaskRun).toHaveBeenCalledWith("t", "r", {
output: { pr_url: PR_URL },
output: { pr_url: PR_URL, pr_urls: [PR_URL] },
});
expect(s.detectedPrUrl).toBe(PR_URL);
});
Expand All @@ -1903,7 +1930,7 @@ describe("AgentServer HTTP Mode", () => {
const s = setup(justNow());
s.maybeAttachCreatedPr(payload, { sessionUpdate: "agent_thought_chunk" });
await flush();
expect(s.fetchPrCreatedAt).not.toHaveBeenCalled();
expect(s.fetchPrAttribution).not.toHaveBeenCalled();
expect(s.posthogAPI.updateTaskRun).not.toHaveBeenCalled();
});

Expand All @@ -1913,20 +1940,19 @@ describe("AgentServer HTTP Mode", () => {
s.maybeAttachCreatedPr(payload, terminalUpdate(PR_URL));
s.maybeAttachCreatedPr(payload, terminalUpdate(PR_URL));
await flush();
expect(s.fetchPrCreatedAt).toHaveBeenCalledTimes(1);
expect(s.fetchPrAttribution).toHaveBeenCalledTimes(1);
expect(s.posthogAPI.updateTaskRun).toHaveBeenCalledTimes(1);
});

it("attributes the most recent PR when a run opens several, in detection order", async () => {
// output.pr_url holds one value; the latest PR the run created is the useful one.
it("accumulates every PR a run opens, keeping the first as primary", async () => {
const s = setup(justNow());
const second = "https://github.com/PostHog/posthog.com/pull/17765";
s.maybeAttachCreatedPr(payload, terminalUpdate(PR_URL));
s.maybeAttachCreatedPr(payload, terminalUpdate(second));
await flush();
expect(s.posthogAPI.updateTaskRun).toHaveBeenCalledTimes(2);
expect(s.posthogAPI.updateTaskRun).toHaveBeenLastCalledWith("t", "r", {
output: { pr_url: second },
output: { pr_url: PR_URL, pr_urls: [PR_URL, second] },
});
expect(s.detectedPrUrl).toBe(second);
});
Expand All @@ -1935,15 +1961,32 @@ describe("AgentServer HTTP Mode", () => {
const viewed = "https://github.com/PostHog/posthog.com/pull/1";
// The created PR reads as recent; the later, merely-viewed PR reads as old.
const s = setup(justNow());
s.fetchPrCreatedAt = vi.fn(async (url: string) =>
url === PR_URL ? justNow() : longAgo,
);
s.fetchPrAttribution = vi.fn(async (url: string) => ({
createdAt: url === PR_URL ? justNow() : longAgo,
author: GH_LOGIN,
}));
s.maybeAttachCreatedPr(payload, terminalUpdate(PR_URL));
s.maybeAttachCreatedPr(payload, terminalUpdate(viewed));
await flush();
expect(s.detectedPrUrl).toBe(PR_URL);
expect(s.posthogAPI.updateTaskRun).toHaveBeenCalledTimes(1);
});

it("does not attribute a fresh PR authored by someone else (merely viewed)", async () => {
const s = setup(justNow(), "someone-else");
s.maybeAttachCreatedPr(payload, terminalUpdate(PR_URL));
await flush();
expect(s.posthogAPI.updateTaskRun).not.toHaveBeenCalled();
expect(s.detectedPrUrl).toBeNull();
});

it("fails closed when the run's GitHub identity cannot be resolved", async () => {
const s = setup(justNow());
s.fetchGhLogin = vi.fn(async () => null);
s.maybeAttachCreatedPr(payload, terminalUpdate(PR_URL));
await flush();
expect(s.posthogAPI.updateTaskRun).not.toHaveBeenCalled();
});
});

describe("buildCloudSystemPrompt", () => {
Expand Down
98 changes: 74 additions & 24 deletions packages/agent/src/server/agent-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ import {
import { type ServerType, serve } from "@hono/node-server";
import { execGh } from "@posthog/git/gh";
import { getCurrentBranch } from "@posthog/git/queries";
import type { Adapter } from "@posthog/shared";
import {
type Adapter,
buildPrOutput,
mergePrUrls,
readPrUrls,
} from "@posthog/shared";
import { unzipSync } from "fflate";
import { Hono } from "hono";
import { z } from "zod";
Expand Down Expand Up @@ -45,7 +50,11 @@ import type { PermissionMode } from "../execution-mode";
import { DEFAULT_CODEX_MODEL, fetchGatewayModels } from "../gateway-models";
import { HandoffCheckpointTracker } from "../handoff-checkpoint";
import { PostHogAPIClient } from "../posthog-api";
import { findPrUrl, wasCreatedRecently } from "../pr-url-detector";
import {
findPrUrls,
wasCreatedByLogin,
wasCreatedRecently,
} from "../pr-url-detector";
import {
formatConversationForResume,
type ResumeState,
Expand Down Expand Up @@ -3355,13 +3364,14 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions}
update: Record<string, unknown> | undefined,
): void {
if (!update) return;
const prUrl = findPrUrl(JSON.stringify(update));
if (!prUrl || this.evaluatedPrUrls.has(prUrl)) return;
this.evaluatedPrUrls.add(prUrl);
// Chain so attributions run in detection order; later PRs overwrite earlier ones.
this.prAttributionChain = this.prAttributionChain
.catch(() => {})
.then(() => this.attachPrIfCreatedThisRun(payload, prUrl));
for (const prUrl of findPrUrls(JSON.stringify(update))) {
if (this.evaluatedPrUrls.has(prUrl)) continue;
this.evaluatedPrUrls.add(prUrl);
// Chain so attributions run in detection order; later PRs append after earlier ones.
this.prAttributionChain = this.prAttributionChain
.catch(() => {})
.then(() => this.attachPrIfCreatedThisRun(payload, prUrl));
}
}

private async attachPrIfCreatedThisRun(
Expand All @@ -3371,9 +3381,13 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions}
// Already the attributed PR (e.g. seeded from a Slack notification, or re-detected).
if (prUrl === this.detectedPrUrl) return;

let createdAt: string | null;
let attribution: { createdAt: string | null; author: string | null };
let ghLogin: string | null;
try {
createdAt = await this.fetchPrCreatedAt(prUrl);
[attribution, ghLogin] = await Promise.all([
this.fetchPrAttribution(prUrl),
this.fetchGhLogin(),
]);
} catch (err) {
this.logger.debug("PR attribution lookup failed", {
runId: payload.run_id,
Expand All @@ -3383,14 +3397,21 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions}
return;
}

// Only attribute PRs created during this run, not ones the agent merely viewed.
if (!wasCreatedRecently(createdAt, Date.now())) return;
// Only attribute PRs created during this run by this run's own GitHub
// identity — not ones the agent merely viewed.
if (!wasCreatedRecently(attribution.createdAt, Date.now())) return;
if (!wasCreatedByLogin(attribution.author, ghLogin)) return;

this.detectedPrUrl = prUrl;

try {
const freshOutput = await this.posthogAPI
.getTaskRun(payload.task_id, payload.run_id)
.then((run) => run.output)
.catch(() => null);
const urls = mergePrUrls(readPrUrls(freshOutput), [prUrl]);
await this.posthogAPI.updateTaskRun(payload.task_id, payload.run_id, {
output: { pr_url: prUrl },
output: buildPrOutput(freshOutput, urls),
});
this.logger.debug("Attributed created PR to task run", {
taskId: payload.task_id,
Expand All @@ -3407,21 +3428,50 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions}
}
}

private async fetchPrCreatedAt(prUrl: string): Promise<string | null> {
const res = await execGh(["pr", "view", prUrl, "--json", "createdAt"], {
cwd: this.config.repositoryPath,
timeoutMs: 10_000,
});
if (res.exitCode !== 0) return null;
private async fetchPrAttribution(
prUrl: string,
): Promise<{ createdAt: string | null; author: string | null }> {
const res = await execGh(
["pr", "view", prUrl, "--json", "createdAt,author"],
{
cwd: this.config.repositoryPath,
timeoutMs: 10_000,
},
);
if (res.exitCode !== 0) return { createdAt: null, author: null };
try {
return (
(JSON.parse(res.stdout) as { createdAt?: string }).createdAt ?? null
);
const data = JSON.parse(res.stdout) as {
createdAt?: string;
author?: { login?: string };
};
return {
createdAt: data.createdAt ?? null,
author: data.author?.login ?? null,
};
} catch {
return null;
return { createdAt: null, author: null };
}
}

private ghLoginPromise: Promise<string | null> | null = null;

private fetchGhLogin(): Promise<string | null> {
this.ghLoginPromise ??= execGh(["api", "user", "--jq", ".login"], {
cwd: this.config.repositoryPath,
timeoutMs: 10_000,
})
.then((res) => {
const login = res.exitCode === 0 ? res.stdout.trim() : "";
if (!login) this.ghLoginPromise = null;
return login || null;
})
.catch(() => {
this.ghLoginPromise = null;
return null;
});
return this.ghLoginPromise;
}

private async cleanupSession({
completeEventStream = false,
}: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ describe("GitInteractionService.runCreatePr", () => {
expect(effects.attachPrUrlToTask).toHaveBeenCalledWith(
"t",
"https://example.test/pr/1",
undefined,
);
if (result.outcome === "success") {
expect(result.linkedBranchName).toBe("feature-x");
Expand Down
Loading
Loading