Skip to content
Draft
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
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
/target
.env
.vercel
dist
node_modules
target
2 changes: 2 additions & 0 deletions bot/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
VITE_GITHUB_TOKEN=
VITE_CEREBRAS_API_KEY=
5 changes: 5 additions & 0 deletions bot/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
dist
.dev.vars
.env
.wrangler
122 changes: 122 additions & 0 deletions bot/api/webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import * as AI from "../lib/ai.js";
import * as Comment from "../lib/comment.js";
import * as GitHub from "../lib/github.js";

type Env = {
CEREBRAS_API_KEY?: string;
GITHUB_APP_ID: string;
GITHUB_PRIVATE_KEY: string;
GITHUB_WEBHOOK_SECRET: string;
};

export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== "POST") {
return Response.json({ error: "Method not allowed" }, { status: 405 });
}

const signature = request.headers.get("x-hub-signature-256");
const event = request.headers.get("x-github-event");
const body = await request.text();

if (
!(await GitHub.verifySignature({
payload: body,
signature,
secret: env.GITHUB_WEBHOOK_SECRET,
}))
) {
return Response.json({ error: "Invalid signature" }, { status: 401 });
}

if (event !== "pull_request") {
return Response.json({ ok: true, skipped: true });
}

const payload = JSON.parse(body);
const action: string = payload.action;

if (action !== "opened" && action !== "synchronize") {
return Response.json({ ok: true, skipped: true });
}

const pr = payload.pull_request;
const repo = payload.repository;
const installationId: number = payload.installation.id;
const owner: string = repo.owner.login;
const repoName: string = repo.name;
const prNumber: number = pr.number;
const headRef: string = pr.head.ref;
const headSha: string = pr.head.sha;
const fullRepo = `${owner}/${repoName}`;

try {
const octokit = await GitHub.createOctokit({
appId: env.GITHUB_APP_ID,
privateKey: env.GITHUB_PRIVATE_KEY,
installationId,
});

const [changelogFiles, existingComment] = await Promise.all([
GitHub.getChangelogFiles({ octokit, owner, repo: repoName, prNumber }),
GitHub.findBotComment({ octokit, owner, repo: repoName, prNumber }),
]);

let commentBody: string;

if (changelogFiles.length > 0) {
commentBody = Comment.found({
repo: fullRepo,
headRef,
changelogFile: changelogFiles[0],
});
} else {
let aiContent: string | null = null;

const changedPackages = await GitHub.getChangedPackages({
octokit,
owner,
repo: repoName,
ref: headSha,
prNumber,
});

const apiKey = env.CEREBRAS_API_KEY;
if (apiKey) {
const diff = await GitHub.getPRDiff({
octokit,
owner,
repo: repoName,
prNumber,
});
aiContent = await AI.generateChangelog({
apiKey,
diff,
packageNames: changedPackages,
});
}

commentBody = Comment.notFound({
repo: fullRepo,
headRef,
aiContent,
changedPackages,
});
}

await GitHub.upsertComment({
octokit,
owner,
repo: repoName,
prNumber,
body: commentBody,
existingCommentId: existingComment?.id ?? null,
});

return Response.json({ ok: true });
} catch (error) {
console.error("Error processing webhook:", error);
return Response.json({ error: "Internal server error" }, { status: 500 });
}
},
};
13 changes: 13 additions & 0 deletions bot/biome.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.14/schema.json",
"formatter": {
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 80
},
"linter": {
"rules": {
"recommended": true
}
}
}
40 changes: 40 additions & 0 deletions bot/lib/ai.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, test } from "vitest";
import * as AI from "./ai.js";

const sampleDiff = `diff --git a/src/lib.rs b/src/lib.rs
index abc1234..def5678 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -10,6 +10,10 @@ pub fn parse(input: &str) -> Result<()> {
let tokens = tokenize(input)?;
let ast = build_ast(tokens)?;
+ // Validate before processing
+ validate(&ast)?;
process(ast)
}
+
+fn validate(ast: &Ast) -> Result<()> {
+ Ok(())
+}`;

describe("AI.generateChangelog", () => {
test("generates a valid changelog entry", async () => {
const result = await AI.generateChangelog({
apiKey: import.meta.env.VITE_CEREBRAS_API_KEY,
diff: sampleDiff,
packageNames: ["changelogs"],
});
expect(result).not.toBeNull();
expect(result).toContain("---");
expect(result).toMatch(/changelogs:\s*(patch|minor|major)/);
});

test("returns null without apiKey", async () => {
const result = await AI.generateChangelog({
apiKey: undefined,
diff: sampleDiff,
packageNames: ["changelogs"],
});
expect(result).toBeNull();
});
});
74 changes: 74 additions & 0 deletions bot/lib/ai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
const cerebrasApiUrl = "https://api.cerebras.ai/v1/chat/completions";

export async function generateChangelog(
parameters: generateChangelog.Parameters,
): Promise<string | null> {
const { apiKey } = parameters;
if (!apiKey) return null;

const packages =
parameters.packageNames.length > 0
? parameters.packageNames.join(", ")
: "<package-name>";

const prompt = `Generate a changelog entry for this git diff.

Available packages: ${packages}

Respond with ONLY a markdown file in this exact format (no explanation):

---
<package-name>: patch
<another-package>: minor
---

Brief description of changes.

Rules:
- Replace <package-name> with actual package names from the list above
- Include ALL packages that were modified in the frontmatter
- Use "patch" for bug fixes, "minor" for features, "major" for breaking changes
- Keep the summary concise (1-3 sentences)
- Use past tense (e.g. "Added", "Fixed", "Removed")
- Code fences are allowed in the summary when helpful
- For breaking changes to public API, include a migration path. \`\`\`diff code fences are encouraged.

Git diff:
${parameters.diff}`;

try {
const response = await fetch(cerebrasApiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: "llama-3.3-70b",
messages: [{ role: "user", content: prompt }],
max_completion_tokens: 512,
temperature: 0.3,
}),
});

if (!response.ok) return null;

const data = (await response.json()) as {
choices: Array<{ message: { content: string } }>;
};
const content = data.choices?.[0]?.message?.content?.trim();
if (!content || !content.startsWith("---")) return null;

return content;
} catch {
return null;
}
}

export declare namespace generateChangelog {
type Parameters = {
apiKey: string | undefined;
diff: string;
packageNames: string[];
};
}
96 changes: 96 additions & 0 deletions bot/lib/comment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import * as Comment from "./comment.js";

beforeEach(() => {
let i = 0;
vi.spyOn(Math, "random").mockImplementation(() => [0.1, 0.2, 0.3][i++ % 3]);
});

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

describe("Comment.found", () => {
test("default", () => {
expect(
Comment.found({
repo: "wevm/viem",
headRef: "my-branch",
changelogFile: ".changelog/cool-cats-dance.md",
}),
).toMatchInlineSnapshot(`
"### ✅ Changelog found on PR.

[Edit changelog](https://github.com/wevm/viem/edit/my-branch/.changelog/cool-cats-dance.md)"
`);
});
});

describe("Comment.notFound", () => {
test("without ai", () => {
expect(
Comment.notFound({
repo: "wevm/viem",
headRef: "my-branch",
aiContent: null,
}),
).toMatchInlineSnapshot(`
"### ⚠️ Changelog not found.

A changelog entry is required before merging.

**[Add changelog](https://github.com/wevm/viem/new/my-branch?filename=.changelog/gentle-birds-bow.md&value=---%0A%3Cpackage-name%3E%3A%20patch%0A---%0A%0ABrief%20description%20of%20changes.)**"
`);
});

test("with ai content", () => {
const aiContent = `---
my-crate: patch
---

Fixed a bug.`;
expect(
Comment.notFound({
repo: "wevm/viem",
headRef: "my-branch",
aiContent,
}),
).toMatchInlineSnapshot(`
"### ⚠️ Changelog not found.

A changelog entry is required before merging. We've generated a suggested changelog based on your changes:

<details>
<summary>Preview</summary>

\`\`\`markdown
---
my-crate: patch
---

Fixed a bug.
\`\`\`

</details>

**[Add changelog](https://github.com/wevm/viem/new/my-branch?filename=.changelog/gentle-birds-bow.md&value=---%0Amy-crate%3A%20patch%0A---%0A%0AFixed%20a%20bug.)** to commit this to your branch."
`);
});

test("with changed packages", () => {
expect(
Comment.notFound({
repo: "wevm/viem",
headRef: "my-branch",
aiContent: null,
changedPackages: ["my-core", "my-utils"],
}),
).toMatchInlineSnapshot(`
"### ⚠️ Changelog not found.

A changelog entry is required before merging.

**[Add changelog](https://github.com/wevm/viem/new/my-branch?filename=.changelog/gentle-birds-bow.md&value=---%0Amy-core%3A%20patch%0Amy-utils%3A%20patch%0A---%0A%0ABrief%20description%20of%20changes.)**"
`);
});
});
Loading