Skip to content

Commit 16a1771

Browse files
authored
fix(home): give quick actions PR context and run them in auto (#2916)
1 parent 2f26cb1 commit 16a1771

3 files changed

Lines changed: 233 additions & 14 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { WorkflowAction } from "../workflow/schemas";
3+
import type { PrSnapshot } from "./prSnapshot";
4+
import type { HomeWorkstream } from "./schemas";
5+
import {
6+
buildQuickActionPrompt,
7+
buildSkillPrompt,
8+
buildWorkstreamContext,
9+
} from "./workstreamPrompt";
10+
11+
function makeAction(overrides: Partial<WorkflowAction> = {}): WorkflowAction {
12+
return {
13+
id: "a1",
14+
label: "Fix CI",
15+
skillId: "fix-ci",
16+
prompt: "Get the checks green.",
17+
...overrides,
18+
};
19+
}
20+
21+
function makePr(overrides: Partial<PrSnapshot> = {}): PrSnapshot {
22+
return {
23+
url: "https://github.com/posthog/code/pull/2910",
24+
number: 2910,
25+
title: "Add the thing",
26+
state: "open",
27+
ciStatus: "failing",
28+
reviewDecision: null,
29+
unresolvedThreads: 0,
30+
mergeable: true,
31+
isCurrentUserRequestedReviewer: false,
32+
isCurrentUserAuthor: true,
33+
author: "peter",
34+
lastUpdatedAt: 0,
35+
...overrides,
36+
};
37+
}
38+
39+
function makeWs(overrides: Partial<HomeWorkstream> = {}): HomeWorkstream {
40+
return {
41+
id: "ws_1",
42+
repoName: "code",
43+
repoFullPath: "PostHog/code",
44+
branch: "feat/the-thing",
45+
prUrl: null,
46+
pr: null,
47+
tasks: [],
48+
situations: [],
49+
primarySituation: null,
50+
lastActivityAt: 0,
51+
...overrides,
52+
};
53+
}
54+
55+
describe("buildSkillPrompt", () => {
56+
it.each([
57+
{
58+
name: "prefixes the skill command and keeps the body",
59+
action: makeAction({ skillId: "fix-ci", prompt: "Get it green." }),
60+
expected: "/fix-ci\n\nGet it green.",
61+
},
62+
{
63+
name: "emits just the command when there is no body",
64+
action: makeAction({ skillId: "fix-ci", prompt: " " }),
65+
expected: "/fix-ci",
66+
},
67+
{
68+
name: "sends the body alone when no skill is bound",
69+
action: makeAction({ skillId: "", prompt: "Do the work." }),
70+
expected: "Do the work.",
71+
},
72+
])("$name", ({ action, expected }) => {
73+
expect(buildSkillPrompt(action)).toBe(expected);
74+
});
75+
});
76+
77+
interface PromptCase {
78+
name: string;
79+
workstream: HomeWorkstream;
80+
contains: string[];
81+
notContains: string[];
82+
}
83+
84+
const contextCases: PromptCase[] = [
85+
{
86+
name: "includes repo, branch, and PR number/url/CI when a PR is present",
87+
workstream: makeWs({ pr: makePr() }),
88+
contains: [
89+
"- Repository: PostHog/code",
90+
"- Branch: feat/the-thing",
91+
"- Pull request #2910: Add the thing",
92+
"https://github.com/posthog/code/pull/2910",
93+
"CI: failing",
94+
],
95+
notContains: [],
96+
},
97+
{
98+
name: "includes review decision and unresolved threads when set",
99+
workstream: makeWs({
100+
pr: makePr({ reviewDecision: "changes_requested", unresolvedThreads: 3 }),
101+
}),
102+
contains: ["Review: changes_requested", "Unresolved review threads: 3"],
103+
notContains: [],
104+
},
105+
{
106+
name: "omits review decision and unresolved threads when unset",
107+
workstream: makeWs({ pr: makePr() }),
108+
contains: [],
109+
notContains: ["Review:", "Unresolved review threads"],
110+
},
111+
{
112+
name: "falls back to the bare PR url when there is no PR snapshot",
113+
workstream: makeWs({
114+
pr: null,
115+
prUrl: "https://github.com/posthog/code/pull/42",
116+
}),
117+
contains: ["- Pull request: https://github.com/posthog/code/pull/42"],
118+
notContains: [],
119+
},
120+
{
121+
name: "emits a branch-only block when there is no PR at all",
122+
workstream: makeWs({ pr: null, prUrl: null, branch: "wip" }),
123+
contains: ["- Branch: wip"],
124+
notContains: ["Pull request"],
125+
},
126+
];
127+
128+
describe("buildWorkstreamContext", () => {
129+
it.each(contextCases)("$name", ({ workstream, contains, notContains }) => {
130+
const context = buildWorkstreamContext(workstream);
131+
for (const text of contains) expect(context).toContain(text);
132+
for (const text of notContains) expect(context).not.toContain(text);
133+
});
134+
135+
// Exact-match case (asserts emptiness, not substrings), kept separate.
136+
it("returns an empty string when there is nothing to anchor to", () => {
137+
expect(
138+
buildWorkstreamContext(
139+
makeWs({ repoFullPath: null, branch: null, pr: null, prUrl: null }),
140+
),
141+
).toBe("");
142+
});
143+
});
144+
145+
const SKILL_PREFIX = "/fix-ci\n\nGet the checks green.";
146+
147+
const quickActionCases: PromptCase[] = [
148+
{
149+
name: "appends the workstream context after the skill prompt",
150+
workstream: makeWs({ pr: makePr() }),
151+
contains: [SKILL_PREFIX, "- Pull request #2910: Add the thing"],
152+
notContains: [],
153+
},
154+
{
155+
name: "is just the skill prompt when the workstream has no context",
156+
workstream: makeWs({
157+
repoFullPath: null,
158+
branch: null,
159+
pr: null,
160+
prUrl: null,
161+
}),
162+
contains: [SKILL_PREFIX],
163+
notContains: ["Context for this task"],
164+
},
165+
];
166+
167+
describe("buildQuickActionPrompt", () => {
168+
it.each(quickActionCases)(
169+
"$name",
170+
({ workstream, contains, notContains }) => {
171+
const prompt = buildQuickActionPrompt(makeAction(), workstream);
172+
expect(prompt.startsWith(SKILL_PREFIX)).toBe(true);
173+
for (const text of contains) expect(prompt).toContain(text);
174+
for (const text of notContains) expect(prompt).not.toContain(text);
175+
},
176+
);
177+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { WorkflowAction } from "../workflow/schemas";
2+
import type { HomeWorkstream } from "./schemas";
3+
4+
type SkillAction = Pick<WorkflowAction, "skillId" | "prompt">;
5+
6+
// The agent runs the bound skill when the prompt opens with `/<skill-id>`.
7+
export function buildSkillPrompt(action: SkillAction): string {
8+
const body = action.prompt.trim();
9+
const skillId = action.skillId.trim();
10+
if (!skillId) return body;
11+
const command = `/${skillId}`;
12+
return body ? `${command}\n\n${body}` : command;
13+
}
14+
15+
// Anchors a background run to the PR/branch it's meant to act on so it doesn't
16+
// have to ask the user which one.
17+
export function buildWorkstreamContext(workstream: HomeWorkstream): string {
18+
const lines: string[] = [];
19+
if (workstream.repoFullPath) {
20+
lines.push(`- Repository: ${workstream.repoFullPath}`);
21+
}
22+
if (workstream.branch) {
23+
lines.push(`- Branch: ${workstream.branch}`);
24+
}
25+
const pr = workstream.pr;
26+
if (pr) {
27+
lines.push(`- Pull request #${pr.number}: ${pr.title}`);
28+
lines.push(` ${pr.url}`);
29+
lines.push(` CI: ${pr.ciStatus}`);
30+
if (pr.reviewDecision) {
31+
lines.push(` Review: ${pr.reviewDecision}`);
32+
}
33+
if (pr.unresolvedThreads > 0) {
34+
lines.push(` Unresolved review threads: ${pr.unresolvedThreads}`);
35+
}
36+
} else if (workstream.prUrl) {
37+
lines.push(`- Pull request: ${workstream.prUrl}`);
38+
}
39+
if (lines.length === 0) return "";
40+
const header =
41+
"Context for this task (already known — don't ask the user for it):";
42+
return `\n\n${header}\n${lines.join("\n")}`;
43+
}
44+
45+
export function buildQuickActionPrompt(
46+
action: SkillAction,
47+
workstream: HomeWorkstream,
48+
): string {
49+
return `${buildSkillPrompt(action)}${buildWorkstreamContext(workstream)}`;
50+
}

packages/ui/src/features/home/hooks/useRunWorkstreamAction.ts

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { HomeSnapshot, HomeWorkstream } from "@posthog/core/home/schemas";
2+
import { buildQuickActionPrompt } from "@posthog/core/home/workstreamPrompt";
23
import {
34
REPORT_MODEL_RESOLVER,
45
type ReportModelResolver,
@@ -34,17 +35,6 @@ export interface RunWorkstreamAction {
3435
run: (action: BoundAction, workstream: HomeWorkstream) => void;
3536
}
3637

37-
// The agent runs the bound skill when the prompt starts with `/<skill-id>`, so
38-
// embed it directly; the descriptive prompt follows as the instruction. With no
39-
// skill bound, send the prompt on its own.
40-
function buildSkillPrompt(action: BoundAction): string {
41-
const body = action.prompt.trim();
42-
const skillId = action.skillId.trim();
43-
if (!skillId) return body;
44-
const command = `/${skillId}`;
45-
return body ? `${command}\n\n${body}` : command;
46-
}
47-
4838
/**
4939
* Runs a bound workflow action as a one-click cloud task: embeds the skill as a
5040
* `/<skill-id>` prefix and starts a cloud run on the workstream's repo + branch.
@@ -76,7 +66,7 @@ export function useRunWorkstreamAction(): RunWorkstreamAction {
7666

7767
const run = useCallback(
7868
(action: BoundAction, workstream: HomeWorkstream) => {
79-
const promptText = buildSkillPrompt(action);
69+
const promptText = buildQuickActionPrompt(action, workstream);
8070
// The GitHub integration map and cloud repo selector are keyed by the full
8171
// "org/repo" slug, so resolve from `repoFullPath`, not the bare `repoName`.
8272
const repo = workstream.repoFullPath?.toLowerCase() ?? null;
@@ -134,8 +124,8 @@ export function useRunWorkstreamAction(): RunWorkstreamAction {
134124
return;
135125
}
136126

137-
// `content` carries the skill prefix to the agent; `taskDescription`
138-
// is the clean prompt used for the task title and description.
127+
// `content` carries the skill prefix; `taskDescription` is the clean
128+
// title.
139129
const input: TaskCreationInput = {
140130
content: promptText,
141131
taskDescription: action.prompt.trim() || action.label,
@@ -145,6 +135,8 @@ export function useRunWorkstreamAction(): RunWorkstreamAction {
145135
githubUserIntegrationId,
146136
adapter,
147137
model,
138+
// Background run, so skip plan mode and let it act.
139+
executionMode: "auto",
148140
homeQuickActionLabel: action.label,
149141
};
150142

0 commit comments

Comments
 (0)