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
443 changes: 384 additions & 59 deletions infra/emdash-bot/.flue/agents/investigate.ts

Large diffs are not rendered by default.

18 changes: 12 additions & 6 deletions infra/emdash-bot/.flue/cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
import { Sandbox as BaseSandbox } from "@cloudflare/sandbox";

import {
gateGithubRequest,
githubAuthHeader,
inspectGithubRequest,
PUSH_CAPABILITY_HEADER,
verifyPushCapability,
} from "./lib/github-proxy.js";
Expand Down Expand Up @@ -62,22 +62,28 @@ async function handleAuthenticatedGithub(request: Request, env: Env): Promise<Re
}

const forwarded = new Request(request);
const capability = forwarded.headers.get(PUSH_CAPABILITY_HEADER);
const issueNumber = await verifyPushCapability(
forwarded.headers.get(PUSH_CAPABILITY_HEADER),
capability,
env.GITHUB_WEBHOOK_SECRET,
owner,
repo,
);
forwarded.headers.delete(PUSH_CAPABILITY_HEADER);
const denial = await gateGithubRequest(forwarded, url, owner, repo, issueNumber ?? undefined);
if (denial) {
const gate = await inspectGithubRequest(forwarded, url, owner, repo, issueNumber ?? undefined);
if (!gate.allowed) {
console.warn("[sandbox/outbound] denying", {
method: request.method,
host: url.host,
path: url.pathname,
reason: denial,
stage: gate.stage,
reason: gate.reason,
capabilityPresent: capability !== null,
capabilityValid: issueNumber !== null,
...(gate.refs ? { refs: gate.refs } : {}),
...(gate.parseError ? { parseError: gate.parseError } : {}),
});
return new Response(`forbidden: ${denial}`, { status: 403 });
return new Response(`forbidden: ${gate.reason}`, { status: 403 });
}

console.log("[sandbox/outbound] allow", {
Expand Down
110 changes: 110 additions & 0 deletions infra/emdash-bot/.flue/lib/candidate-publisher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
export type GitTreeMode = "100644" | "100755" | "120000";

export interface CandidateChange {
readonly path: string;
readonly mode: GitTreeMode;
/** Null deletes the path from the candidate tree. */
readonly content: Uint8Array | null;
}

export interface CandidateSnapshot {
readonly baseCommitSha: string;
readonly treeSha: string;
readonly changes: readonly CandidateChange[];
}

export interface CandidateGitHub {
getBranchSha(branch: string): Promise<string | null>;
getCommit(sha: string): Promise<{ treeSha: string; message: string }>;
createBlob(content: Uint8Array): Promise<string>;
createTree(baseTreeSha: string, entries: readonly GitTreeEntry[]): Promise<string>;
createCommit(message: string, treeSha: string, parentSha: string): Promise<string>;
createBranch(branch: string, commitSha: string): Promise<void>;
updateBranch(branch: string, commitSha: string): Promise<void>;
}

export interface GitTreeEntry {
readonly path: string;
readonly mode: GitTreeMode;
readonly type: "blob";
readonly sha: string | null;
}

export interface CandidatePublication {
readonly branch: string;
readonly commitSha: string;
readonly files: string[];
}

export interface PublishCandidateInput {
readonly branch: string;
readonly runId: string;
readonly commitMessage: string;
readonly expectedPreviousSha: string | null;
readonly snapshot: CandidateSnapshot;
}

export function requireCandidatePublication(
claimed: boolean,
publication: CandidatePublication | null,
): void {
if (claimed && !publication) {
throw new Error("publish_candidate must complete before reporting a published change");
}
}

export async function publishCandidate(
input: PublishCandidateInput,
github: CandidateGitHub,
): Promise<CandidatePublication> {
if (input.snapshot.changes.length === 0) throw new Error("candidate has no changes to publish");
const files = input.snapshot.changes.map((change) => change.path);
const runMarker = `EmDash-Run: ${input.runId}`;
const liveBefore = await github.getBranchSha(input.branch);
if (liveBefore !== input.expectedPreviousSha) {
if (liveBefore) {
const liveCommit = await github.getCommit(liveBefore);
if (liveCommit.message.includes(runMarker)) {
return { branch: input.branch, commitSha: liveBefore, files };
Comment on lines +65 to +68

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The idempotency branch returns the live commit as soon as its message contains the run marker, without checking whether that commit's tree matches the current snapshot. If a single run calls publish_candidate more than once after making additional edits, the second snapshot will have a different treeSha than the commit already on the branch, but this branch silently returns the stale commit. The agent then reports implemented: true / fixed: true against a publication that does not contain the verified candidate tree.

Suggested change
if (liveBefore) {
const liveCommit = await github.getCommit(liveBefore);
if (liveCommit.message.includes(runMarker)) {
return { branch: input.branch, commitSha: liveBefore, files };
if (liveBefore) {
const liveCommit = await github.getCommit(liveBefore);
if (liveCommit.message.includes(runMarker)) {
if (liveCommit.treeSha !== input.snapshot.treeSha) {
throw new Error(
"candidate branch already has a commit from this run with a different tree",
);
}
return { branch: input.branch, commitSha: liveBefore, files };
}
}

}
}
throw new Error(
`candidate branch changed since this run started (expected ${input.expectedPreviousSha ?? "absent"}, found ${liveBefore ?? "absent"})`,
);
}

const baseCommit = await github.getCommit(input.snapshot.baseCommitSha);
const entries: GitTreeEntry[] = [];
for (const change of input.snapshot.changes) {
entries.push({
path: change.path,
mode: change.mode,
type: "blob",
sha: change.content === null ? null : await github.createBlob(change.content),
});
}
const treeSha = await github.createTree(baseCommit.treeSha, entries);
if (treeSha !== input.snapshot.treeSha) {
throw new Error(
`GitHub created tree ${treeSha}, which does not match the verified candidate ${input.snapshot.treeSha}`,
);
}
const message = `${input.commitMessage.trim()}\n\n${runMarker}`;
const parentSha = input.expectedPreviousSha ?? input.snapshot.baseCommitSha;
const commitSha = await github.createCommit(message, treeSha, parentSha);

const liveAtUpdate = await github.getBranchSha(input.branch);
if (liveAtUpdate !== input.expectedPreviousSha) {
throw new Error("candidate branch changed while the publication was being prepared");
}
if (liveAtUpdate === null) await github.createBranch(input.branch, commitSha);
else await github.updateBranch(input.branch, commitSha);

const publishedSha = await github.getBranchSha(input.branch);
if (publishedSha !== commitSha) {
throw new Error(
`candidate branch verification failed (expected ${commitSha}, found ${publishedSha ?? "absent"})`,
);
}
return { branch: input.branch, commitSha, files };
}
51 changes: 32 additions & 19 deletions infra/emdash-bot/.flue/lib/comments.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { StateId } from "./machine.js";
import type { Kind, StateId } from "./machine.js";
import { artifactsBranch, fixBranch, previewInstallCommand } from "./preview.js";
import type { Decision } from "./router.js";

Expand Down Expand Up @@ -39,11 +39,11 @@ export function renderReadonlyReply(state: StateId | null): string {
case "needs_info":
return "I need more to go on -- see my last comment for what's missing.";
case "fixing":
return "Building a candidate fix.";
return "Building a candidate change.";
case "preview_building":
return "Building a preview so you can try the fix.";
return "Building a preview so you can try the change.";
case "awaiting_reporter":
return "Try the preview from my last comment. Reply `@emdashbot confirm` if it's fixed, or describe what's still wrong.";
return "Try the preview from my last comment. Reply `@emdashbot confirm` if it works, or describe what needs to change.";
default: {
const _exhaustive: never = state;
return `State: \`${String(_exhaustive)}\`.`;
Expand All @@ -62,10 +62,19 @@ export function renderAgentComment(
decision: Extract<Decision, { kind: "transition" }>,
anchorNumber: number,
agentSummary?: string,
failure?: { runId?: string; failureStage?: string },
previewPackage = "emdash",
): string {
const summary = agentSummary?.trim();
if (!decision.event.startsWith("agent.")) return "";
if (!summary) return "";
if (decision.event === "agent.failed") {
const details = [
failure?.failureStage ? `Failed stage: \`${failure.failureStage}\`` : "",
failure?.runId ? `Run: \`${failure.runId}\`` : "",
].filter(Boolean);
return details.length > 0 ? `${summary}\n\n${details.join(" · ")}` : summary;
}

switch (decision.event) {
case "agent.fix_ready":
Comment thread
ascorbic marked this conversation as resolved.
Expand All @@ -80,7 +89,7 @@ export function renderAgentComment(
"Try it:",
"",
"```sh",
`pnpm add https://pkg.pr.new/emdash-cms/emdash@bot/fix-${anchorNumber}`,
previewInstallCommand(anchorNumber, previewPackage),
"```",
"",
"Reply `@emdashbot confirm` if it works and I'll open the PR, or `@emdashbot revise <feedback>` to push changes.",
Expand Down Expand Up @@ -118,7 +127,7 @@ function mdEscape(text: string): string {
}

/**
* Compose the ask comment posted when a candidate fix's preview has published.
* Compose the ask comment posted when a candidate change's preview has published.
* The reporter verifies the change against their own site via the pkg.pr.new
* install command, then replies to confirm or reject.
*
Expand All @@ -132,6 +141,7 @@ export function renderPreviewReadyAsk(input: {
owner: string;
repo: string;
issueNumber: number;
previewPackage?: string;
at: string;
notes?: string | null;
screenshots?: readonly PreviewScreenshot[];
Expand All @@ -144,24 +154,24 @@ export function renderPreviewReadyAsk(input: {
`![${mdEscape(shot.description ?? shot.filename)}](https://raw.githubusercontent.com/${input.owner}/${input.repo}/${artifactsBranch(input.issueNumber)}/.bot-artifacts/${shot.filename})`,
);
const reporterAsk = input.reporterLogin
? `@${input.reporterLogin} could you try this and reply here with whether it resolves the issue? A simple "yes, fixed" or "no, still broken" is enough.`
: "Could the reporter please try this and reply with whether it resolves the issue?";
? `@${input.reporterLogin} could you try this and reply here with whether it works as requested? A simple "yes" or "no" is enough.`
: "Could the reporter please try this and reply with whether it works as requested?";
return [
`<!-- bot-ask: ${input.at} -->`,
"The investigation reproduced this issue and pushed a candidate fix.",
"A candidate change is ready to preview.",
"",
input.notes?.trim() ?? "",
"",
"Try the fix against your own site:",
"Try the change against your own site:",
"",
"```bash",
previewInstallCommand(input.issueNumber),
previewInstallCommand(input.issueNumber, input.previewPackage),
"```",
"",
...(shots.length > 0 ? ["**Screenshots:**", "", shots.join("\n\n"), ""] : []),
reporterAsk,
"",
"<sub>Maintainers can act on the reporter's behalf: `@emdashbot confirm` to accept the fix and open a draft PR, or `@emdashbot reject` (with details) to reap the branch and revise.</sub>",
"<sub>Maintainers can act on the reporter's behalf: `@emdashbot confirm` to accept the change and open a draft PR, or `@emdashbot reject` (with details) to reap the branch and revise.</sub>",
"",
`Fix branch: \`${fixBranch(input.issueNumber)}\` · Artifacts branch: \`${artifactsBranch(input.issueNumber)}\``,
]
Expand All @@ -170,23 +180,26 @@ export function renderPreviewReadyAsk(input: {
}

/**
* Body for the draft PR opened when the reporter confirms the fix. References
* Body for the draft PR opened when the reporter confirms the change. References
* the issue (so merging closes it), points at the preview the reporter just
* verified, and flags that a maintainer must review before merge. The fix run
* left a regression test on the branch; the reviewer confirms it on the diff.
* verified, and flags that a maintainer must review before merge.
*/
export function renderDraftPrBody(issueNumber: number): string {
export function renderDraftPrBody(issueNumber: number, previewPackage?: string): string {
return [
`Closes #${issueNumber}.`,
"",
"A candidate fix the reporter confirmed against their own site via the preview build:",
"A candidate change the reporter confirmed against their own site via the preview build:",
"",
"```bash",
previewInstallCommand(issueNumber),
previewInstallCommand(issueNumber, previewPackage),
"```",
"",
"The fix run left a regression test on the branch -- confirm it covers the reported case on review.",
"Review the candidate diff and its verification before merging.",
"",
"<sub>Opened automatically by emdashbot as a draft. A maintainer must review before merge.</sub>",
].join("\n");
}

export function renderPullRequestTitle(issueNumber: number, kind: Kind): string {
return `${kind === "bug" ? "Fix" : "Implement"} #${issueNumber}`;
}
Loading
Loading