Skip to content
Open
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
23 changes: 0 additions & 23 deletions apps/mobile/src/features/inbox/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import type {
SignalReportSignalsResponse,
SignalReportsQueryParams,
SignalReportsResponse,
SignalReportTask,
SuggestedReviewerWriteEntry,
} from "./types";

Expand Down Expand Up @@ -152,28 +151,6 @@ export async function getAvailableSuggestedReviewers(
return { results, count: results.length };
}

export async function getSignalReportTasks(
reportId: string,
): Promise<SignalReportTask[]> {
const baseUrl = getBaseUrl();
const projectId = getProjectId();

const response = await authedFetch(
`${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/tasks/`,
);

if (!response.ok) {
throw new HttpError(
response.status,
response.statusText,
"Failed to fetch signal report tasks",
);
}

const data = await response.json();
return data.results ?? [];
}

export async function getSignalReportArtefacts(
reportId: string,
): Promise<SignalReportArtefactsResponse> {
Expand Down
7 changes: 0 additions & 7 deletions apps/mobile/src/features/inbox/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,6 @@ export interface AvailableSuggestedReviewersResponse {
count: number;
}

export interface SignalReportTask {
id: string;
relationship: string;
task_id: string;
created_at: string;
}

export interface Signal {
signal_id: string;
content: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ describe("signed-commit tool handler", () => {
createSignedCommit.mockReset();
createSignedCommit.mockResolvedValue({
branch: "posthog-code/feature",
repository: "x/y",
commits: [
{ sha: "deadbeef", url: "https://github.com/x/y/commit/deadbeef" },
],
Expand Down
16 changes: 15 additions & 1 deletion packages/agent/src/adapters/signed-commit-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type SignedRewriteInput,
} from "@posthog/git/signed-commit";
import { z } from "zod";
import { reportCommitArtefacts } from "../signed-commit-artefacts";
import { qualifiedLocalToolName } from "./local-tools/registry";

/**
Expand Down Expand Up @@ -167,7 +168,20 @@ export function runSignedCommitTool(
): Promise<SignedCommitToolResult> {
return runSignedTool(
SIGNED_COMMIT_TOOL_NAME,
createSignedCommit,
async (c, a: SignedCommitInput) => {
const result = await createSignedCommit(c, a);
// The "commit hook": every pushed commit becomes a `commit` artefact on the signal
// reports this task is associated with. Best-effort and awaited inside the tool's
// try/catch-free success path — reportCommitArtefacts never throws, so a failed
// artefact post can't fail a commit that already landed. git_signed_rewrite is
// intentionally not hooked (it republishes existing history).
await reportCommitArtefacts({
taskId: c.taskId,
result,
message: a.message,
});
return result;
},
(r) => `Created ${r.commits.length} signed commit(s) on ${r.branch}`,
ctx,
args,
Expand Down
29 changes: 29 additions & 0 deletions packages/agent/src/posthog-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,35 @@ export class PostHogAPIClient {
return manifest.slice(-artifacts.length);
}

/** Signal reports the given task is associated with (via report task associations). */
async getSignalReportIdsForTask(taskId: string): Promise<string[]> {
const teamId = this.getTeamId();
const response = await this.apiRequest<{ results?: { id: string }[] }>(
`/api/projects/${teamId}/signals/reports/?task_id=${encodeURIComponent(taskId)}&limit=100`,
);
return (response.results ?? []).map((r) => r.id);
}

/**
* Append a log artefact to a signal report, attributed to `taskId` via the
* `X-PostHog-Task-Id` header (the server validates it against the token's team).
*/
async createSignalReportArtefact(
reportId: string,
taskId: string,
body: { artefact_type: string; content: Record<string, unknown> },
): Promise<void> {
const teamId = this.getTeamId();
await this.apiRequest(
`/api/projects/${teamId}/signals/reports/${reportId}/artefacts/`,
{
method: "POST",
body: JSON.stringify(body),
headers: { "X-PostHog-Task-Id": taskId },
},
);
}

/**
* Download artifact content by storage path
* Streams the file through the PostHog backend so the sandbox does not need
Expand Down
145 changes: 145 additions & 0 deletions packages/agent/src/signed-commit-artefacts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { reportCommitArtefacts } from "./signed-commit-artefacts";

const ENV = {
POSTHOG_API_URL: "https://us.posthog.com",
POSTHOG_PERSONAL_API_KEY: "pha_test",
POSTHOG_PROJECT_ID: "7",
};

// Point the env-file read at a path that never exists so only `env` is used.
const NO_ENV_FILE = "/nonexistent/agent-env";

const RESULT = {
branch: "posthog-code/fix-foo",
repository: "posthog/posthog",
commits: [
{ sha: "aaa111", url: "https://github.com/posthog/posthog/commit/aaa111" },
{ sha: "bbb222", url: "https://github.com/posthog/posthog/commit/bbb222" },
],
};

describe("reportCommitArtefacts", () => {
const fetchMock = vi.fn();

beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
});

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}

it("posts one commit artefact per commit per associated report, attributed via header", async () => {
fetchMock.mockImplementation(async (url: string | URL) => {
if (String(url).includes("/signals/reports/?")) {
return jsonResponse({
results: [{ id: "report-1" }, { id: "report-2" }],
});
}
return jsonResponse({ id: "artefact" });
});

await reportCommitArtefacts({
taskId: "task-1",
result: RESULT,
message: "fix: foo",
env: ENV,
envFilePath: NO_ENV_FILE,
});

const lookupCalls = fetchMock.mock.calls.filter(([url]) =>
String(url).includes("/signals/reports/?task_id=task-1"),
);
expect(lookupCalls).toHaveLength(1);

const postCalls = fetchMock.mock.calls.filter(([url]) =>
String(url).includes("/artefacts/"),
);
// 2 commits × 2 reports.
expect(postCalls).toHaveLength(4);
for (const [url, init] of postCalls) {
expect(String(url)).toMatch(
/\/api\/projects\/7\/signals\/reports\/report-[12]\/artefacts\/$/,
);
const headers = new Headers((init as RequestInit).headers);
expect(headers.get("X-PostHog-Task-Id")).toBe("task-1");
const body = JSON.parse(String((init as RequestInit).body));
expect(body.artefact_type).toBe("commit");
expect(body.content.repository).toBe("posthog/posthog");
expect(body.content.branch).toBe("posthog-code/fix-foo");
expect(["aaa111", "bbb222"]).toContain(body.content.commit_sha);
expect(body.content.message).toBe("fix: foo");
}
});

it("is a no-op without a task id", async () => {
await reportCommitArtefacts({
taskId: undefined,
result: RESULT,
message: "fix: foo",
env: ENV,
envFilePath: NO_ENV_FILE,
});
expect(fetchMock).not.toHaveBeenCalled();
});

it("is a no-op without sandbox PostHog credentials", async () => {
await reportCommitArtefacts({
taskId: "task-1",
result: RESULT,
message: "fix: foo",
env: {},
envFilePath: NO_ENV_FILE,
});
expect(fetchMock).not.toHaveBeenCalled();
});

it("never throws when the report lookup fails", async () => {
fetchMock.mockRejectedValue(new Error("network down"));
await expect(
reportCommitArtefacts({
taskId: "task-1",
result: RESULT,
message: "fix: foo",
env: ENV,
envFilePath: NO_ENV_FILE,
}),
).resolves.toBeUndefined();
});

it("keeps posting remaining artefacts when one post fails", async () => {
let postCount = 0;
fetchMock.mockImplementation(async (url: string | URL) => {
if (String(url).includes("/signals/reports/?")) {
return jsonResponse({ results: [{ id: "report-1" }] });
}
postCount += 1;
if (postCount === 1) {
return new Response("{}", { status: 500 });
}
return jsonResponse({ id: "artefact" });
});

await reportCommitArtefacts({
taskId: "task-1",
result: RESULT,
message: "fix: foo",
env: ENV,
envFilePath: NO_ENV_FILE,
});

// Both commits attempted despite the first failing.
expect(postCount).toBe(2);
});
});
111 changes: 111 additions & 0 deletions packages/agent/src/signed-commit-artefacts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { readFileSync } from "node:fs";
import type { SignedCommitResult } from "@posthog/git/signed-commit";
import { PostHogAPIClient } from "./posthog-api";
import { SANDBOX_ENV_FILE } from "./utils/github-token";

/**
* Best-effort "commit hook": after a successful signed-commit push, record one `commit`
* artefact per pushed commit on every signal report the task is associated with, so the
* report's work log shows exactly what landed. Attribution is deterministic — the artefact
* endpoint reads the `X-PostHog-Task-Id` header, never the model.
*
* Credentials come from the sandbox environment (`POSTHOG_API_URL` /
* `POSTHOG_PERSONAL_API_KEY` / `POSTHOG_PROJECT_ID`), preferring the live agentsh env file
* for the key so a mid-session token refresh is picked up — the same pattern as
* `resolveGithubToken`. Works identically from the Claude in-process server and the Codex
* stdio child (both inherit the sandbox env). Never throws: a failed artefact post must not
* fail the commit that already landed.
*/

interface SandboxPosthogApi {
apiUrl: string;
apiKey: string;
projectId: number;
}

function readSandboxEnvFile(envFilePath: string): Record<string, string> {
try {
const raw = readFileSync(envFilePath, "utf8");
const env: Record<string, string> = {};
for (const entry of raw.split("\0")) {
const eq = entry.indexOf("=");
if (eq > 0) {
env[entry.slice(0, eq)] = entry.slice(eq + 1);
}
}
return env;
} catch {
// No env file (local/desktop or test) — fall back to the process env only.
return {};
}
}

export function resolveSandboxPosthogApi(
env: Record<string, string | undefined> = process.env,
envFilePath: string = SANDBOX_ENV_FILE,
): SandboxPosthogApi | undefined {
const fileEnv = readSandboxEnvFile(envFilePath);
const apiUrl = fileEnv.POSTHOG_API_URL ?? env.POSTHOG_API_URL;
const apiKey =
fileEnv.POSTHOG_PERSONAL_API_KEY ?? env.POSTHOG_PERSONAL_API_KEY;
const projectId = Number(
fileEnv.POSTHOG_PROJECT_ID ?? env.POSTHOG_PROJECT_ID,
);
if (!apiUrl || !apiKey || !Number.isFinite(projectId) || projectId <= 0) {
return undefined;
}
return { apiUrl, apiKey, projectId };
}

export async function reportCommitArtefacts(opts: {
taskId: string | undefined;
result: SignedCommitResult;
/** Commit headline — the same for every chunk of a split payload. */
message: string;
env?: Record<string, string | undefined>;
envFilePath?: string;
}): Promise<void> {
const { taskId, result, message } = opts;
if (!taskId) {
return; // Local/desktop run — no task to attribute or associate through.
}
try {
const api = resolveSandboxPosthogApi(opts.env, opts.envFilePath);
if (!api) {
return; // No sandbox PostHog credentials — nothing to report to.
}
const client = new PostHogAPIClient({
apiUrl: api.apiUrl,
projectId: api.projectId,
getApiKey: () => api.apiKey,
});
const reportIds = await client.getSignalReportIdsForTask(taskId);
for (const reportId of reportIds) {
for (const commit of result.commits) {
try {
await client.createSignalReportArtefact(reportId, taskId, {
artefact_type: "commit",
content: {
repository: result.repository,
branch: result.branch,
commit_sha: commit.sha,
message,
},
});
} catch (err) {
warn(
`failed to record commit ${commit.sha} on report ${reportId}: ${err}`,
);
}
}
}
} catch (err) {
warn(`failed to record commit artefacts: ${err}`);
}
}

// stderr directly (not console) — this also runs inside the Codex stdio MCP child,
// where stdout is the protocol channel.
function warn(message: string): void {
process.stderr.write(`[signed-commit-artefacts] ${message}\n`);
}
Loading
Loading