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
2 changes: 1 addition & 1 deletion apps/code/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
<script type="module" src="/src/renderer/main.tsx"></script>
</body>

</html>
</html>
10 changes: 10 additions & 0 deletions apps/code/src/shared/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
export const BILLING_FLAG = "posthog-code-billing";
export const EXPERIMENT_SUGGESTIONS_FLAG =
"posthog-code-experiment-suggestions";
export const SELF_DRIVING_SETUP_TASK_FLAG =
"posthog-code-self-driving-setup-task";
export const SYNC_CLOUD_TASKS_FLAG = "posthog-code-sync-cloud-tasks";
export const HOME_TAB_FLAG = "posthog-code-home-tab";
export const DISCOVERY_RUN_FLAG = "posthog-code-discovery-run";
export const BRANCH_PREFIX = "posthog-code/";
export const DATA_DIR = ".posthog-code";
export const WORKTREES_DIR = ".posthog-code/worktrees";
export const LEGACY_DATA_DIRS = [
".twig",
Expand Down
46 changes: 23 additions & 23 deletions docs/workflow-architecture.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# Home workflow architecture

The Home tab's data layer workstream grouping, PR polling, situation
classification, and workflow-config persistence runs **server-side in
The Home tab's data layer workstream grouping, PR polling, situation
classification, and workflow-config persistence runs **server-side in
PostHog**, in the `tasks` product (`products/tasks/` in the posthog repo). The
Electron app is a thin authenticated client.

The Electron app and the PostHog `tasks` product share wire shapes and
classification logic if you change either, update both sides and this doc.
classification logic if you change either, update both sides and this doc.

---

Expand All @@ -19,10 +19,10 @@ wants available when work lands in each situation.

Three concerns sit on top of that:

1. **Storage** the per-user bindings JSON (`CodeWorkflowConfig`).
2. **PR / signal polling** CI status, review decision, threads, mergeability
1. **Storage** the per-user bindings JSON (`CodeWorkflowConfig`).
2. **PR / signal polling** CI status, review decision, threads, mergeability
for each tracked PR (`CodePrSnapshot`).
3. **Grouping + classification** group a user's tasks into workstreams and
3. **Grouping + classification** group a user's tasks into workstreams and
compute the situations each is in (`CodeWorkstream`).

All three live in PostHog now.
Expand All @@ -32,30 +32,30 @@ All three live in PostHog now.
## 2. Server (PostHog `products/tasks/backend/`)

**Pure logic** (ported 1:1 from the original TypeScript; unit-tested without
Django) `code_workstreams/`:
Django) `code_workstreams/`:

- `situations.py` situation ids, priority order, attention set.
- `classify.py` `classify(input) → set[SituationId]`, `pick_primary_situation`.
- `grouping.py` `build_workstreams(tasks, pr_by_task, now)`: groups tasks
- `situations.py` situation ids, priority order, attention set.
- `classify.py` `classify(input) → set[SituationId]`, `pick_primary_situation`.
- `grouping.py` `build_workstreams(tasks, pr_by_task, now)`: groups tasks
(PR URL → repo+branch → path), extracts the active-agent set, classifies, and
buckets into needs-attention / in-progress.
- `default_workflow.py`, `validation.py` default bindings + save validation.
- `default_workflow.py`, `validation.py` default bindings + save validation.

**Models** (`models.py`):

- `CodeWorkflowConfig` per `(team, user)` bindings + monotonic `version`.
- `CodePrSnapshot` per `(team, pr_url)` polled GitHub state (shared across a
- `CodeWorkflowConfig` per `(team, user)` bindings + monotonic `version`.
- `CodePrSnapshot` per `(team, pr_url)` polled GitHub state (shared across a
team's users).
- `CodeWorkstream` per `(team, user, key)` grouped + classified workstream;
- `CodeWorkstream` per `(team, user, key)` grouped + classified workstream;
the API reads these rows directly.

**Temporal worker** (`temporal/code_workstreams/`, task queue
`TASKS_TASK_QUEUE`):

- `evaluate-code-workstreams` (dispatcher) a Temporal **Schedule** every 3 min
- `evaluate-code-workstreams` (dispatcher) a Temporal **Schedule** every 3 min
enumerates teams with recent code activity and fans out one child workflow per
team (bounded concurrency).
- `evaluate-team-code-workstreams` per team: `load_team_pr_urls` →
- `evaluate-team-code-workstreams` per team: `load_team_pr_urls` →
`poll_team_pull_requests` (GitHub GraphQL via the team integration, rate-limit
aware, heartbeated) → `rebuild_team_workstreams` (group + classify + upsert
`CodeWorkstream`, prune stale).
Expand All @@ -81,7 +81,7 @@ Responses use the exact camelCase wire shapes the Electron app validates.

## 3. Electron app (`apps/code/`)

Thin authenticated clients over the REST API no local persistence, no `gh`
Thin authenticated clients over the REST API no local persistence, no `gh`
polling, no client-side classification:

| Concern | File |
Expand All @@ -97,7 +97,7 @@ Delivery for v1 is **REST + client poll**: `HomeService` polls
`GET /code_home/` and emits `home.onSnapshotUpdated`; `WorkflowService` calls the
config endpoints and emits `workflow.onChanged`. Both subscriptions write back
into the TanStack Query cache. A realtime push channel (SSE) is a future
enhancement the tRPC subscription contract wouldn't change.
enhancement the tRPC subscription contract wouldn't change.

`WorkflowService.get()` surfaces network/load failures rather than masking them:
the config endpoint is the only source of truth, so when it can't be reached the
Expand All @@ -107,10 +107,10 @@ canvas shows an offline/error state with a retry instead of fabricating a config

## 4. What is intentionally NOT here yet

- **`unresolvedThreads` for PRs the user doesn't author** only the M3 reviewer
- **`unresolvedThreads` for PRs the user doesn't author** only the M3 reviewer
flow needs it; `is_current_user_requested_reviewer` defaults false.
- **Realtime push (SSE)** v1 is client poll; the worker keeps server data fresh.
- **Snooze / mute / viewed** (`home_attention_state`) M4, not migrated yet.
- **`auto`-trigger actions** deliberately omitted.
- **Continue-as-new batching in the dispatcher** current active-team counts
- **Realtime push (SSE)** v1 is client poll; the worker keeps server data fresh.
- **Snooze / mute / viewed** (`home_attention_state`) M4, not migrated yet.
- **`auto`-trigger actions** deliberately omitted.
- **Continue-as-new batching in the dispatcher** current active-team counts
fan out in one pass (capped + logged); page with continue-as-new at larger scale.
2 changes: 1 addition & 1 deletion packages/agent/src/adapters/claude/claude-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1112,7 +1112,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
const newAbortController = new AbortController();
const { sessionId: _drop, ...rest } = prev.queryOptions;

// parseMcpServers yields only http/sse/stdio carry over any in-process
// parseMcpServers yields only http/sse/stdio carry over any in-process
// ("sdk") server so the local-tools server (signed commits) survives.
const preservedInProcess = Object.fromEntries(
Object.entries(prev.queryOptions.mcpServers ?? {}).filter(
Expand Down
27 changes: 26 additions & 1 deletion packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
AvailableSuggestedReviewersResponse,
DismissalArtefact,
PriorityJudgmentArtefact,
RepoSelectionArtefact,
SandboxEnvironment,
SandboxEnvironmentInput,
Signal,
Expand Down Expand Up @@ -324,6 +325,7 @@ type AnyArtefact =
| PriorityJudgmentArtefact
| ActionabilityJudgmentArtefact
| SignalFindingArtefact
| RepoSelectionArtefact
| SuggestedReviewersArtefact
| DismissalArtefact;

Expand Down Expand Up @@ -434,6 +436,26 @@ function normalizeSignalFindingArtefact(
};
}

function normalizeRepoSelectionArtefact(
value: Record<string, unknown>,
): RepoSelectionArtefact | null {
const id = optionalString(value.id);
if (!id) return null;

const contentValue = isObjectRecord(value.content) ? value.content : null;
if (!contentValue) return null;

return {
id,
type: "repo_selection",
created_at: optionalString(value.created_at) ?? new Date(0).toISOString(),
content: {
repository: optionalString(contentValue.repository),
reason: optionalString(contentValue.reason) ?? "",
},
};
}

function normalizeDismissalArtefact(
value: Record<string, unknown>,
): DismissalArtefact | null {
Expand Down Expand Up @@ -482,6 +504,9 @@ function normalizeSignalReportArtefact(value: unknown): AnyArtefact | null {
if (dispatchType === "priority_judgment") {
return normalizePriorityJudgmentArtefact(value);
}
if (dispatchType === "repo_selection") {
return normalizeRepoSelectionArtefact(value);
}
if (dispatchType === "dismissal") {
return normalizeDismissalArtefact(value);
}
Expand Down Expand Up @@ -1088,7 +1113,7 @@ export class PostHogAPIClient {
return all;
}

async getTask(taskId: string) {
async getTask(taskId: string): Promise<Task> {
const teamId = await this.getTeamId();
const data = await this.api.get(`/api/projects/{project_id}/tasks/{id}/`, {
path: { project_id: teamId.toString(), id: taskId },
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/git/router-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,23 @@ export const getPrChangedFilesInput = z.object({
});
export const getPrChangedFilesOutput = z.array(changedFileSchema);

// getPrDiffStatsBatch schemas
export const prDiffStatsSchema = z.object({
additions: z.number(),
deletions: z.number(),
changedFiles: z.number(),
});
export type PrDiffStats = z.infer<typeof prDiffStatsSchema>;

export const getPrDiffStatsBatchInput = z.object({
prUrls: z.array(z.string()),
});
export const getPrDiffStatsBatchOutput = z.record(
z.string(),
prDiffStatsSchema,
);

// getPrDetailsByUrl schemas
export const getPrDetailsByUrlInput = z.object({
prUrl: z.string(),
});
Expand Down
49 changes: 49 additions & 0 deletions packages/core/src/inbox/artefacts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { SuggestedReviewer } from "@posthog/shared/types";
import { describe, expect, it } from "vitest";
import {
extractSuggestedReviewers,
reviewerInitials,
suggestedReviewerDisplayName,
} from "./artefacts";

describe("artefacts", () => {
it("extracts suggested reviewers from artefacts", () => {
const reviewers: SuggestedReviewer[] = [
{
github_login: "benw",
github_name: "Ben W.",
relevant_commits: [],
user: null,
},
];

expect(
extractSuggestedReviewers([
{ type: "priority_judgment", content: {} },
{ type: "suggested_reviewers", content: reviewers },
]),
).toEqual(reviewers);
});

it("prefers user names for display", () => {
expect(
suggestedReviewerDisplayName({
github_login: "benw",
github_name: "Ben W.",
relevant_commits: [],
user: {
id: 1,
uuid: "uuid-1",
email: "ben@posthog.com",
first_name: "Ben",
last_name: "W.",
},
}),
).toBe("Ben W.");
});

it("derives reviewer initials from names and emails", () => {
expect(reviewerInitials("Ben W.", null)).toBe("BW");
expect(reviewerInitials("", "ben@posthog.com")).toBe("BE");
});
});
88 changes: 88 additions & 0 deletions packages/core/src/inbox/artefacts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type {
RepoSelectionArtefact,
SuggestedReviewer,
} from "@posthog/shared/types";

function hasRepositoryContent(
content: unknown,
): content is RepoSelectionArtefact["content"] {
return (
typeof content === "object" &&
content !== null &&
"repository" in content &&
typeof content.repository === "string"
);
}

export function extractRepoSelectionRepository(
results: { type: string; content: unknown }[] | undefined,
): string | null {
const artefact = results?.find(
(entry): entry is RepoSelectionArtefact =>
entry.type === "repo_selection" && hasRepositoryContent(entry.content),
);
return artefact?.content.repository ?? null;
}

export function suggestedReviewerDisplayName(
reviewer: SuggestedReviewer,
): string {
if (reviewer.user) {
const name =
`${reviewer.user.first_name} ${reviewer.user.last_name}`.trim();
if (name) return name;
if (reviewer.user.email) return reviewer.user.email;
}
return reviewer.github_name ?? reviewer.github_login;
}

export function extractSuggestedReviewers(
results: { type: string; content: unknown }[] | undefined,
): SuggestedReviewer[] {
const artefact = results?.find(
(
entry,
): entry is { type: "suggested_reviewers"; content: SuggestedReviewer[] } =>
entry.type === "suggested_reviewers" && Array.isArray(entry.content),
);
return artefact?.content ?? [];
}

const AVATAR_PALETTE = [
"bg-(--orange-9) text-white",
"bg-(--blue-9) text-white",
"bg-(--purple-9) text-white",
"bg-(--green-9) text-white",
"bg-(--pink-9) text-white",
"bg-(--teal-9) text-white",
] as const;

export function reviewerAvatarToneClass(seed: string): string {
let hash = 0;
for (let i = 0; i < seed.length; i += 1) {
hash = (hash + seed.charCodeAt(i) * (i + 1)) % 9973;
}
return AVATAR_PALETTE[hash % AVATAR_PALETTE.length];
}

export function reviewerInitials(
name: string | null | undefined,
email: string | null | undefined,
): string {
const trimmedName = name?.trim() ?? "";
if (trimmedName) {
const parts = trimmedName.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return `${parts[0][0] ?? ""}${parts[parts.length - 1][0] ?? ""}`.toUpperCase();
}
return trimmedName.slice(0, 2).toUpperCase();
}

const trimmedEmail = email?.trim() ?? "";
if (trimmedEmail) {
const local = trimmedEmail.split("@")[0] ?? trimmedEmail;
return local.slice(0, 2).toUpperCase();
}

return "??";
}
Loading
Loading