Skip to content

Commit 3a0d2b4

Browse files
authored
chore(bot): use template for generated PRs (#2585)
* chore(bot): use template for generated PRs * fix(bot): tolerate PR template wording changes
1 parent 2932960 commit 3a0d2b4

5 files changed

Lines changed: 198 additions & 37 deletions

File tree

infra/emdash-bot/.flue/agents/investigate.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,11 @@ const screenshotSchema = v.object({
114114
description: v.optional(v.string()),
115115
});
116116

117+
const pullRequestSchema = v.object({
118+
title: v.pipe(v.string(), v.minLength(1), v.maxLength(256)),
119+
description: v.pipe(v.string(), v.minLength(10), v.maxLength(RESULT_SUMMARY_LIMIT)),
120+
});
121+
117122
const resultSchema = v.pipe(
118123
v.object({
119124
skipped: v.optional(v.boolean()),
@@ -137,6 +142,7 @@ const resultSchema = v.pipe(
137142
fixed: v.optional(v.boolean()),
138143
verdict: v.optional(v.picklist(["bug", "intended-behavior", "unclear"])),
139144
summary: v.pipe(v.string(), v.minLength(10), v.maxLength(RESULT_SUMMARY_LIMIT)),
145+
pullRequest: v.optional(pullRequestSchema),
140146
failureStage: v.optional(v.picklist(["workspace", "verification", "publication", "reporting"])),
141147
/** Reproduction screenshots pushed to bot/artifacts-<n>, rendered in the ask comment. */
142148
screenshots: v.optional(v.array(screenshotSchema)),
@@ -147,15 +153,26 @@ const resultSchema = v.pipe(
147153
(result.demonstration !== "none" && result.demonstratedReportedIssue === true),
148154
"reproduced=true requires demonstration != 'none' and demonstratedReportedIssue=true. If you demonstrated something other than the reported issue, or nothing, set reproduced=false and describe the finding in summary.",
149155
),
156+
v.check(
157+
(result) => result.fixed !== true || result.pullRequest !== undefined,
158+
"fixed=true requires pullRequest with a reviewer-facing title and description.",
159+
),
150160
);
151161

152-
const implementationResultSchema = v.object({
153-
skipped: v.optional(v.boolean()),
154-
implemented: v.boolean(),
155-
summary: v.pipe(v.string(), v.minLength(10), v.maxLength(RESULT_SUMMARY_LIMIT)),
156-
failureStage: v.optional(v.picklist(["workspace", "verification", "publication", "reporting"])),
157-
screenshots: v.optional(v.array(screenshotSchema)),
158-
});
162+
const implementationResultSchema = v.pipe(
163+
v.object({
164+
skipped: v.optional(v.boolean()),
165+
implemented: v.boolean(),
166+
summary: v.pipe(v.string(), v.minLength(10), v.maxLength(RESULT_SUMMARY_LIMIT)),
167+
pullRequest: v.optional(pullRequestSchema),
168+
failureStage: v.optional(v.picklist(["workspace", "verification", "publication", "reporting"])),
169+
screenshots: v.optional(v.array(screenshotSchema)),
170+
}),
171+
v.check(
172+
(result) => result.implemented !== true || result.pullRequest !== undefined,
173+
"implemented=true requires pullRequest with a reviewer-facing title and description.",
174+
),
175+
);
159176

160177
const publicationSchema = v.object({
161178
branch: v.string(),
@@ -468,7 +485,7 @@ export function Investigate({ id }: AgentProps) {
468485
defineTool({
469486
name: "report_implementation",
470487
description:
471-
"Report the implementation outcome. Set implemented=true only after publish_candidate succeeds. Include the commands run and any verification failures in the summary.",
488+
"Report the implementation outcome. Set implemented=true only after publish_candidate succeeds. Include the commands run and any verification failures in the summary. When implemented=true, provide pullRequest with a concise reviewer-facing title and description.",
472489
input: implementationResultSchema,
473490
output: reportedResultSchema,
474491
durable: true,
@@ -505,7 +522,7 @@ export function Investigate({ id }: AgentProps) {
505522
defineTool({
506523
name: "report_result",
507524
description:
508-
"Report the final structured investigation result to the issue orchestrator. reproduced=true means you demonstrated the defect the reporter described, in this checkout. The demonstration does NOT need to copy their exact steps: a failing unit test that exercises the same defect a UI report describes is a full reproduction of the issue -- report it as one, without hedging. It must be the same defect, though: an adjacent or latent bug you demonstrated, an out-of-repo infrastructure symptom, or a root cause from reading code alone is not a reproduction. Three distinct non-reproduced outcomes -- pick the honest one: rootCauseFound=true when you identified the reporter's defect but could not confirm it with a demonstration (environment limits, browser-only path) -- this is a first-class 'diagnosed' verdict; plain reproduced=false when you investigated and found nothing wrong or a different/adjacent issue (describe findings in summary); verdict='unclear' when the issue lacks the information an attempt would need -- say what is missing. Fill demonstration and demonstratedReportedIssue truthfully. If demonstration attempts are not converging after a couple of angles, stop and report the diagnosis with rootCauseFound rather than grinding.",
525+
"Report the final structured investigation result to the issue orchestrator. reproduced=true means you demonstrated the defect the reporter described, in this checkout. The demonstration does NOT need to copy their exact steps: a failing unit test that exercises the same defect a UI report describes is a full reproduction of the issue -- report it as one, without hedging. It must be the same defect, though: an adjacent or latent bug you demonstrated, an out-of-repo infrastructure symptom, or a root cause from reading code alone is not a reproduction. Three distinct non-reproduced outcomes -- pick the honest one: rootCauseFound=true when you identified the reporter's defect but could not confirm it with a demonstration (environment limits, browser-only path) -- this is a first-class 'diagnosed' verdict; plain reproduced=false when you investigated and found nothing wrong or a different/adjacent issue (describe findings in summary); verdict='unclear' when the issue lacks the information an attempt would need -- say what is missing. Fill demonstration and demonstratedReportedIssue truthfully. If demonstration attempts are not converging after a couple of angles, stop and report the diagnosis with rootCauseFound rather than grinding. When fixed=true, provide pullRequest with a concise reviewer-facing title and description.",
509526
input: resultSchema,
510527
output: reportedResultSchema,
511528
durable: true,

infra/emdash-bot/.flue/lib/comments.ts

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import pullRequestTemplate from "../../../../.github/PULL_REQUEST_TEMPLATE.md?raw";
12
import type { Kind, StateId } from "./machine.js";
23
import {
34
artifactsBranch,
@@ -188,24 +189,65 @@ export function renderPreviewReadyAsk(input: {
188189
.join("\n");
189190
}
190191

192+
export interface PullRequestCopy {
193+
readonly title: string;
194+
readonly description: string;
195+
}
196+
197+
const TYPE_SECTION_HEADING_RE = /^## Type of change\b/im;
198+
const BUG_FIX_CHECKBOX_RE = /^- \[ ] Bug fix\b(.*)$/im;
199+
const FEATURE_CHECKBOX_RE = /^- \[ ] Feature\b(.*)$/im;
200+
const AI_DISCLOSURE_CHECKBOX_RE = /^- \[ ] This PR includes AI-generated code\b.*$/im;
201+
202+
export function fillPullRequestTemplate(template: string, kind: Kind): string {
203+
const typeSectionStart = template.search(TYPE_SECTION_HEADING_RE);
204+
if (typeSectionStart === -1) throw new Error("pull request template is missing its type section");
205+
const typeCheckbox = kind === "bug" ? BUG_FIX_CHECKBOX_RE : FEATURE_CHECKBOX_RE;
206+
const typeLabel = kind === "bug" ? "Bug fix" : "Feature";
207+
const templateBody = template.slice(typeSectionStart);
208+
if (!typeCheckbox.test(templateBody)) {
209+
throw new Error(`pull request template is missing its ${typeLabel} checkbox`);
210+
}
211+
if (!AI_DISCLOSURE_CHECKBOX_RE.test(templateBody)) {
212+
throw new Error("pull request template is missing its AI disclosure checkbox");
213+
}
214+
return templateBody
215+
.replace(typeCheckbox, `- [x] ${typeLabel}$1`)
216+
.replace(
217+
AI_DISCLOSURE_CHECKBOX_RE,
218+
"- [x] This PR includes AI-generated code — model/tool: emdashbot + Kimi K2.7 Code",
219+
)
220+
.trim();
221+
}
222+
191223
/**
192224
* Body for the draft PR opened when the reporter confirms the change. References
193225
* the issue (so merging closes it), points at the preview the reporter just
194-
* verified, and flags that a maintainer must review before merge.
226+
* verified, and fills the repository's pull request template.
195227
*/
196-
export function renderDraftPrBody(issueNumber: number, previewPackage?: string): string {
228+
export function renderDraftPrBody(input: {
229+
issueNumber: number;
230+
kind: Kind;
231+
description: string;
232+
previewPackage?: string;
233+
}): string {
234+
const completedTemplate = fillPullRequestTemplate(pullRequestTemplate, input.kind);
197235
return [
198-
`Closes #${issueNumber}.`,
236+
"## What does this PR do?",
237+
"",
238+
input.description.trim(),
239+
"",
240+
`Closes #${input.issueNumber}.`,
199241
"",
200242
"A candidate change the reporter confirmed against their own site via the preview build:",
201243
"",
202244
"```bash",
203-
previewInstallCommand(issueNumber, previewPackage),
245+
previewInstallCommand(input.issueNumber, input.previewPackage),
204246
"```",
205247
"",
206-
"Review the candidate diff and its verification before merging.",
207-
"",
208248
"<sub>Opened automatically by emdashbot as a draft. A maintainer must review before merge.</sub>",
249+
"",
250+
completedTemplate,
209251
].join("\n");
210252
}
211253

infra/emdash-bot/.flue/lib/orchestrator.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { DurableObject } from "cloudflare:workers";
1010
import { Investigate } from "../agents/investigate.js";
1111
import { classifyComment, type ClassifierInput, type ClassifyResult } from "./classifier-client.js";
1212
import {
13+
type PullRequestCopy,
1314
type PreviewScreenshot,
1415
renderAgentComment,
1516
renderDraftPrBody,
@@ -164,6 +165,8 @@ export interface NormalizedEvent {
164165
readonly agentFailureStage?: string;
165166
/** Reproduction screenshots the fix run pushed, carried into the ask comment. */
166167
readonly agentScreenshots?: readonly PreviewScreenshot[];
168+
/** Reviewer-facing copy carried through preview confirmation into the draft PR. */
169+
readonly agentPullRequest?: PullRequestCopy;
167170
/**
168171
* Precomposed comment body that replaces the default `renderComment` output
169172
* for this transition. Used for the preview-ready ask, whose body needs data
@@ -194,6 +197,7 @@ export interface AgentResult {
194197
readonly implemented?: boolean;
195198
readonly verdict?: string;
196199
readonly summary?: string;
200+
readonly pullRequest?: PullRequestCopy;
197201
readonly failureStage?: string;
198202
readonly screenshots?: readonly PreviewScreenshot[];
199203
readonly [key: string]: unknown;
@@ -288,6 +292,7 @@ const STORAGE = {
288292
previewPollNextAt: "o:previewPollNextAt",
289293
previewNotes: "o:previewNotes",
290294
previewScreenshots: "o:previewScreenshots",
295+
candidatePullRequest: "o:candidatePullRequest",
291296
lastDiagnosis: "o:lastDiagnosis",
292297
deadlineWarningSentRunId: "o:deadlineWarningSentRunId",
293298
deadlineWarningRetryAt: "o:deadlineWarningRetryAt",
@@ -315,6 +320,16 @@ const INBOX_BATCH_LIMIT = 10;
315320
const CLASSIFIER_MAX_ATTEMPTS = 3;
316321
const CLASSIFIER_TEXT_LIMIT = 16_000;
317322

323+
function normalizePullRequestCopy(value: unknown): PullRequestCopy | undefined {
324+
if (!value || typeof value !== "object") return undefined;
325+
const { title, description } = value as { title?: unknown; description?: unknown };
326+
if (typeof title !== "string" || typeof description !== "string") return undefined;
327+
const normalizedTitle = title.trim().replaceAll(/\s+/g, " ");
328+
const normalizedDescription = description.trim();
329+
if (!normalizedTitle || !normalizedDescription) return undefined;
330+
return { title: normalizedTitle, description: normalizedDescription };
331+
}
332+
318333
interface CachedToken {
319334
token: string;
320335
/** Unix ms; tokens are valid ~1h, we expire 5m early. */
@@ -1025,6 +1040,7 @@ export class OrchestratorDO extends DurableObject<Env> {
10251040
const labels = await this.projectLabels();
10261041
const agentSummary =
10271042
typeof input.result?.summary === "string" ? input.result.summary : undefined;
1043+
const agentPullRequest = normalizePullRequestCopy(input.result.pullRequest);
10281044
const runStatus: Exclude<WorkCommentStatus, "running"> =
10291045
event === "agent.failed"
10301046
? input.result.failureStage === "timeout"
@@ -1054,6 +1070,7 @@ export class OrchestratorDO extends DurableObject<Env> {
10541070
settlesRunId: input.runId,
10551071
agentRunId: input.runId,
10561072
...(agentSummary ? { agentSummary } : {}),
1073+
...(agentPullRequest ? { agentPullRequest } : {}),
10571074
...(finalizedWorkComment ? { commentBodyOverride: "" } : {}),
10581075
...(failureStage ? { agentFailureStage: failureStage } : {}),
10591076
...(agentScreenshots ? { agentScreenshots } : {}),
@@ -2145,15 +2162,25 @@ export class OrchestratorDO extends DurableObject<Env> {
21452162
const token = await this.getInstallationToken(creds);
21462163
const headBranch = `bot/fix-${anchorNumber}`;
21472164
const kind = (await this.ctx.storage.get<Kind>(STORAGE.kind)) ?? "bug";
2165+
const pullRequestCopy = draft
2166+
? await this.ctx.storage.get<PullRequestCopy>(STORAGE.candidatePullRequest)
2167+
: undefined;
21482168
try {
21492169
const created =
21502170
(await getOpenPullRequest(token, repo, headBranch)) ??
21512171
(await createPullRequest(token, repo, {
21522172
headBranch,
21532173
baseBranch: "main",
2154-
title: renderPullRequestTitle(anchorNumber, kind),
2174+
title: pullRequestCopy?.title || renderPullRequestTitle(anchorNumber, kind),
21552175
body: draft
2156-
? renderDraftPrBody(anchorNumber, this.env.PREVIEW_PACKAGE)
2176+
? renderDraftPrBody({
2177+
issueNumber: anchorNumber,
2178+
kind,
2179+
description:
2180+
pullRequestCopy?.description ||
2181+
`Automated candidate change for issue #${anchorNumber}.`,
2182+
previewPackage: this.env.PREVIEW_PACKAGE,
2183+
})
21572184
: `Fixes #${anchorNumber}.\n\nAutomated PR opened by emdashbot.`,
21582185
draft,
21592186
}));
@@ -2487,13 +2514,23 @@ export class OrchestratorDO extends DurableObject<Env> {
24872514
input.agentScreenshots?.length
24882515
? transaction.put(STORAGE.previewScreenshots, input.agentScreenshots)
24892516
: transaction.delete(STORAGE.previewScreenshots),
2517+
transaction.put<PullRequestCopy>(
2518+
STORAGE.candidatePullRequest,
2519+
input.agentPullRequest ?? {
2520+
title: "",
2521+
description: input.agentSummary ?? "",
2522+
},
2523+
),
24902524
);
24912525
} else {
24922526
puts.push(
24932527
transaction.delete(STORAGE.previewBuildDeadline),
24942528
transaction.delete(STORAGE.previewPollNextAt),
24952529
transaction.delete(STORAGE.previewNotes),
24962530
transaction.delete(STORAGE.previewScreenshots),
2531+
...(decision.to === "awaiting_reporter"
2532+
? []
2533+
: [transaction.delete(STORAGE.candidatePullRequest)]),
24972534
);
24982535
}
24992536
const kindLabel = decision.addLabels.find(

infra/emdash-bot/tests/integration/orchestrator.test.ts

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1508,7 +1508,7 @@ describe("OrchestratorDO (workers-pool)", () => {
15081508
expect(await stub.getPendingSideEffectCount()).toBe(1);
15091509
});
15101510

1511-
test("draft PR titles distinguish bug fixes from directed implementations", async () => {
1511+
test("draft PRs use agent-authored copy with a legacy fallback", async () => {
15121512
const pullRequests: unknown[] = [];
15131513
let pullNumber = 100;
15141514
testEnv.GITHUB_APP_PRIVATE_KEY = "test-key-present";
@@ -1540,22 +1540,56 @@ describe("OrchestratorDO (workers-pool)", () => {
15401540
},
15411541
);
15421542

1543-
for (const [anchorNumber, kind] of [
1544-
[42, "bug"],
1545-
[43, "enhancement"],
1546-
] as const) {
1547-
const stub = testEnv.Orchestrator.getByName(uniqueIssueName());
1548-
await stub.debugSetTokenCache("cached-token", Date.now() + 60 * 60 * 1000);
1549-
await stub.debugPrimePreviewBuilding(anchorNumber, "Candidate notes.", kind);
1550-
await stub.event(
1551-
makeEvent({ event: "preview.ready", arg: null, actor: "system", anchorNumber }),
1552-
);
1553-
await stub.event(makeEvent({ event: "confirm", arg: null, actor: "reporter", anchorNumber }));
1554-
}
1543+
const customCopyStub = testEnv.Orchestrator.getByName(uniqueIssueName());
1544+
await customCopyStub.debugSetTokenCache("cached-token", Date.now() + 60 * 60 * 1000);
1545+
await customCopyStub.debugPrimeFixing(42);
1546+
await customCopyStub.debugSetStaleRun(
1547+
"implement-run",
1548+
Date.now(),
1549+
"investigate-42-implement-run",
1550+
"implement",
1551+
);
1552+
await customCopyStub.applyAgentResult({
1553+
runId: "implement-run",
1554+
result: {
1555+
implemented: true,
1556+
summary: "Keeps the selected locale when loading content.",
1557+
pullRequest: {
1558+
title: "fix(core): preserve the requested locale",
1559+
description: "Keeps the selected locale when loading content.",
1560+
},
1561+
},
1562+
pushed: true,
1563+
ok: true,
1564+
});
1565+
await customCopyStub.event(
1566+
makeEvent({ event: "preview.ready", arg: null, actor: "system", anchorNumber: 42 }),
1567+
);
1568+
await customCopyStub.event(
1569+
makeEvent({ event: "confirm", arg: null, actor: "reporter", anchorNumber: 42 }),
1570+
);
1571+
1572+
const legacyStub = testEnv.Orchestrator.getByName(uniqueIssueName());
1573+
await legacyStub.debugSetTokenCache("cached-token", Date.now() + 60 * 60 * 1000);
1574+
await legacyStub.debugPrimePreviewBuilding(43, "Candidate notes.", "enhancement");
1575+
await legacyStub.event(
1576+
makeEvent({ event: "preview.ready", arg: null, actor: "system", anchorNumber: 43 }),
1577+
);
1578+
await legacyStub.event(
1579+
makeEvent({ event: "confirm", arg: null, actor: "reporter", anchorNumber: 43 }),
1580+
);
15551581

15561582
expect(pullRequests).toMatchObject([
1557-
{ title: "Fix #42", draft: true },
1558-
{ title: "Implement #43", draft: true },
1583+
{
1584+
title: "fix(core): preserve the requested locale",
1585+
body: expect.stringContaining("Keeps the selected locale when loading content."),
1586+
draft: true,
1587+
},
1588+
{
1589+
title: "Implement #43",
1590+
body: expect.stringContaining("## What does this PR do?"),
1591+
draft: true,
1592+
},
15591593
]);
15601594
});
15611595

0 commit comments

Comments
 (0)