Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
122 changes: 122 additions & 0 deletions .github/scripts/design-review.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// get diff (pr diff or git diff depending on trigger)
// filter to design-relevant files
// build Claude prompt with guidelines + diff
// call Claude API
// post review comment to GitHub

const Anthropic = require("@anthropic-ai/sdk");
const { getDiff } = require("./get-diff");
const { filterFiles } = require("./filter-files");
const { buildPrompt } = require("./prompt");
const { postComment } = require("./post-comment");

if (!process.env.GITHUB_TOKEN) {
console.error("ERROR: GITHUB_TOKEN environment variable is not set.");
process.exit(1);
}

if (!process.env.GITHUB_REPOSITORY) {
console.error("ERROR: GITHUB_REPOSITORY environment variable is not set.");
process.exit(1);
}
Comment thread
amaan-bhati marked this conversation as resolved.

/**
* Filter the full unified diff down to only hunks belonging to relevantFiles.
* Prevents the agent from seeing or commenting on non-design files.
*
* @param {string} fullDiff - raw unified diff
* @param {string[]} relevantFiles - file paths that passed filterFiles()
* @returns {string} filtered diff containing only relevant file sections
*/
function filterDiffToRelevantFiles(fullDiff, relevantFiles) {
const relevantSet = new Set(relevantFiles);
const sections = fullDiff.split(/^(?=diff --git )/m);
return sections
.filter((section) => {
const match = section.match(/^diff --git a\/(.+) b\/.+/);
return match && relevantSet.has(match[1]);
})
.join("");
}

async function run() {
console.log("=== Keploy Design Review Agent ===");
console.log(`Trigger: ${process.env.GITHUB_EVENT_NAME}`);

// Step 1: Get the diff
console.log("Fetching diff...");
const { diff, changedFiles } = await getDiff();

if (!diff || diff.trim().length === 0) {
console.log("No diff found. Nothing to review.");
await postComment(
"## Keploy Design Review\n\nNo changes detected in this diff. Nothing to review."
);
return;
}

console.log(`Total changed files: ${changedFiles.length}`);

// Step 2: Filter to design-relevant files
const relevantFiles = filterFiles(changedFiles);
console.log(`Design-relevant files: ${relevantFiles.length}`);

if (relevantFiles.length === 0) {
console.log("No design-relevant files changed. Skipping review.");
await postComment(
"## Keploy Design Review\n\n✅ No design-relevant files changed in this diff. Nothing to review."
);
return;
}

// Step 3: Check API key — only required if we actually have files to review.
// This prevents failures on fork PRs where secrets are unavailable but
// there are no design files to review anyway (already handled above).
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
if (!ANTHROPIC_API_KEY) {
console.error("ERROR: ANTHROPIC_API_KEY environment variable is not set.");
process.exit(1);
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

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

If ANTHROPIC_API_KEY is missing, the script exits with code 1, which will fail checks on fork PRs (where secrets are unavailable) even though GITHUB_TOKEN can still be used to post a clear “could not run” comment. Consider posting a non-failing comment explaining that the design review was skipped due to missing credentials, and exit 0.

Suggested change
console.error("ERROR: ANTHROPIC_API_KEY environment variable is not set.");
process.exit(1);
console.log(
"ANTHROPIC_API_KEY is not set. Skipping design review and posting a comment so maintainers can rerun after configuring credentials."
);
await postComment(
"## Keploy Design Review\n\n⚪ Design review was skipped because `ANTHROPIC_API_KEY` is not available in this workflow run. This commonly happens on fork pull requests where secrets are not exposed. If you need a review, ask a maintainer to rerun the workflow with the required credentials."
);
return;

Copilot uses AI. Check for mistakes.
}

// Step 4: Trim the diff to only the hunks for relevant files
const filteredDiff = filterDiffToRelevantFiles(diff, relevantFiles);

// Step 5: Build the prompt
console.log("Building review prompt...");
const { system, user } = buildPrompt(filteredDiff, relevantFiles);

Comment on lines +72 to +106
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

You compute relevantFiles, but still pass the full unified diff into buildPrompt(...). This means the agent can flag issues in non-relevant files (including its own .github/scripts/*) whenever at least one relevant file exists. Filter the diff down to hunks for relevantFiles (or fetch per-file diffs) before building the prompt.

Copilot uses AI. Check for mistakes.
// Step 6: Call Claude API
console.log("Calling Claude API...");
const client = new Anthropic({ apiKey: ANTHROPIC_API_KEY });

const message = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 4096,
messages: [
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

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

The model name is hard-coded (claude-sonnet-4-6). To avoid workflow breakage when models are renamed/deprecated, make this configurable via an env var (with a sane default) and include the model name in error output when requests fail.

Copilot uses AI. Check for mistakes.
{
role: "user",
content: user,
},
],
system,
});

const reviewText = message.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("\n");

console.log("Review generated. Posting comment...");
console.log("--- Review Preview ---");
console.log(reviewText.slice(0, 500) + (reviewText.length > 500 ? "..." : ""));
console.log("---------------------");

// Step 7: Post the comment
await postComment(reviewText);
console.log("=== Design review complete ===");
}

run().catch((err) => {
console.error("Design review agent failed:", err.message);
process.exit(1);
});
45 changes: 45 additions & 0 deletions .github/scripts/filter-files.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// filters a list of changed file paths to only those relevant for design review

const ALLOWED_EXTENSIONS = [
".css",
".scss",
".sass",
".mdx",
Comment thread
amaan-bhati marked this conversation as resolved.
".md",
".tsx",
".jsx",
".js",
".ts",
];

const IGNORED_PATHS = [
"node_modules/",
"build/",
".docusaurus/",
".github/",
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"DESIGN_GUIDELINES.md",
];
Comment on lines +16 to +25
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

filterFiles() currently allows any .js/.ts/.md files, including CI/scripts under .github/. That will cause the design agent to review non-UI automation code (and potentially comment on its own implementation). Add .github/ (and possibly DESIGN_GUIDELINES.md) to IGNORED_PATHS, or restrict allowed paths to actual site sources like src/, docs/, and versioned_docs/.

Copilot uses AI. Check for mistakes.

// Only review actual site source directories
const ALLOWED_PATHS = ["src/", "docs/", "versioned_docs/", "blog/", "static/"];

/**
* @param {string[]} files array of file paths from the diff
* @returns {string[]} filtered file paths
*/
function filterFiles(files) {
return files.filter((file) => {
const isIgnored = IGNORED_PATHS.some((p) => file.includes(p));
if (isIgnored) return false;

const hasAllowedExt = ALLOWED_EXTENSIONS.some((ext) => file.endsWith(ext));
if (!hasAllowedExt) return false;

return ALLOWED_PATHS.some((p) => file.startsWith(p));
});
}

module.exports = { filterFiles };
96 changes: 96 additions & 0 deletions .github/scripts/get-diff.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// returns the unified diff string and list of changed files for the current GitHub event (PR or push)

const { execSync } = require("child_process");
const https = require("https");

const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY; // "owner/repo"
const PR_NUMBER = process.env.PR_NUMBER;
const GITHUB_EVENT_NAME = process.env.GITHUB_EVENT_NAME;
const GITHUB_SHA = process.env.GITHUB_SHA;

// fetch the pr diff from the GitHub API, using the "diff" media type to get a unified diff string, returns raw unified diff string
function fetchPRDiff() {
return new Promise((resolve, reject) => {
const [owner, repo] = GITHUB_REPOSITORY.split("/");
const options = {
hostname: "api.github.com",
path: `/repos/${owner}/${repo}/pulls/${PR_NUMBER}`,
headers: {
Authorization: `Bearer ${GITHUB_TOKEN}`,
Accept: "application/vnd.github.v3.diff",
"User-Agent": "keploy-design-review-agent",
},
};

https
.get(options, (res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
if (res.statusCode < 200 || res.statusCode >= 300) {
reject(
new Error(
`GitHub API returned ${res.statusCode} fetching PR diff. ` +
`Check GITHUB_TOKEN permissions and that PR_NUMBER=${PR_NUMBER} is valid. ` +
`Response: ${data.trim().slice(0, 300)}`
)
);
return;
}
resolve(data);
});
})
.on("error", reject);
});
}

// get diff for a push event using git. Compares HEAD to its parent (HEAD~1).
function getCommitDiff() {
try {
const diff = execSync("git diff HEAD~1 HEAD", {
encoding: "utf8",
maxBuffer: 10 * 1024 * 1024, // 10MB
});
return diff;
} catch {
// first commit edge case, diff against empty tree
const diff = execSync(
"git diff 4b825dc642cb6eb9a060e54bf8d69288fbee4904 HEAD",
{ encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }
);
return diff;
}
}

// for manual workflow_dispatch: diff the last commit. this is a best practice to get some diff for manual runs, but may not be perfect depending on the repo state.

function getManualDiff() {
return getCommitDiff();
}

// main export function that returns the diff and list of changed files based on the GitHub event type (pull_request or push)

// diff : raw unified diff string
// changedFiles: array of file paths that changed (extracted from diff headers)

async function getDiff() {
let diff = "";

if (GITHUB_EVENT_NAME === "pull_request") {
diff = await fetchPRDiff();
} else if (GITHUB_EVENT_NAME === "push") {
diff = getCommitDiff();
} else {
// workflow_dispatch or any other trigger
diff = getManualDiff();
}
Comment thread
amaan-bhati marked this conversation as resolved.

// Extract unique file names from diff headers: "diff --git a/foo b/foo"
const fileMatches = [...diff.matchAll(/^diff --git a\/(.+) b\/.+$/gm)];
const changedFiles = [...new Set(fileMatches.map((m) => m[1]))];

return { diff, changedFiles };
}

module.exports = { getDiff };
124 changes: 124 additions & 0 deletions .github/scripts/post-comment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// posts the design review results as a github comment
// on a pr: posts a pr review comment
Comment thread
amaan-bhati marked this conversation as resolved.
Outdated
// on a push: posts a commit comment
// on a manual workflow_dispatch: posts a commit comment (best effort to get some comment for manual runs, but may not be perfect depending on repo state)

const https = require("https");

const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY;
const PR_NUMBER = process.env.PR_NUMBER;
const GITHUB_EVENT_NAME = process.env.GITHUB_EVENT_NAME;
const GITHUB_SHA = process.env.GITHUB_SHA;

function githubRequest(method, path, body) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(body);
const options = {
hostname: "api.github.com",
path,
method,
headers: {
Authorization: `Bearer ${GITHUB_TOKEN}`,
Accept: "application/vnd.github.v3+json",
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
"User-Agent": "keploy-design-review-agent",
},
};

const req = https.request(options, (res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
// 204 No Content (DELETE) returns empty body — safe parse
resolve(data.length > 0 ? JSON.parse(data) : {});
} else {
reject(
new Error(
`GitHub API error ${res.statusCode}: ${data}`
)
);
}
});
});

req.on("error", reject);
req.write(payload);
req.end();
});
}

/**
* Find and delete any previous design review comment on the PR
* so we don't accumulate stale comments.
*/
async function deletePreviousReviewComment(owner, repo) {
const listPath = `/repos/${owner}/${repo}/issues/${PR_NUMBER}/comments`;

const comments = await new Promise((resolve, reject) => {
const options = {
hostname: "api.github.com",
path: listPath,
method: "GET",
headers: {
Authorization: `Bearer ${GITHUB_TOKEN}`,
Accept: "application/vnd.github.v3+json",
"User-Agent": "keploy-design-review-agent",
},
};

https
.get(options, (res) => {
let data = "";
res.on("data", (c) => (data += c));
res.on("end", () => resolve(JSON.parse(data)));
})
.on("error", reject);
});

Comment on lines +56 to +132
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

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

deletePreviousReviewComment calls the PR comments API without pagination; GitHub returns only the first page by default, so an older “Keploy Design Review” comment may not be found/deleted on large PRs. Consider adding ?per_page=100 and iterating through Link headers (or looping pages) until the matching bot comment is found/removed.

Suggested change
/**
* Find and delete any previous design review comment on the PR
* so we don't accumulate stale comments.
*/
async function deletePreviousReviewComment(owner, repo) {
const listPath = `/repos/${owner}/${repo}/issues/${PR_NUMBER}/comments`;
const comments = await new Promise((resolve, reject) => {
const options = {
hostname: "api.github.com",
path: listPath,
method: "GET",
headers: {
Authorization: `Bearer ${GITHUB_TOKEN}`,
Accept: "application/vnd.github.v3+json",
"User-Agent": "keploy-design-review-agent",
},
};
https
.get(options, (res) => {
let data = "";
res.on("data", (c) => (data += c));
res.on("end", () => resolve(JSON.parse(data)));
})
.on("error", reject);
});
function parseNextLink(linkHeader) {
if (!linkHeader) {
return null;
}
const links = linkHeader.split(",");
for (const link of links) {
const match = link.match(/<([^>]+)>;\s*rel="([^"]+)"/);
if (match && match[2] === "next") {
const nextUrl = new URL(match[1]);
return `${nextUrl.pathname}${nextUrl.search}`;
}
}
return null;
}
async function fetchAllIssueComments(owner, repo) {
const comments = [];
let nextPath = `/repos/${owner}/${repo}/issues/${PR_NUMBER}/comments?per_page=100`;
while (nextPath) {
const pageComments = await new Promise((resolve, reject) => {
const options = {
hostname: "api.github.com",
path: nextPath,
method: "GET",
headers: {
Authorization: `Bearer ${GITHUB_TOKEN}`,
Accept: "application/vnd.github.v3+json",
"User-Agent": "keploy-design-review-agent",
},
};
https
.get(options, (res) => {
let data = "";
res.on("data", (c) => (data += c));
res.on("end", () => {
if (res.statusCode < 200 || res.statusCode >= 300) {
reject(
new Error(
`GitHub API error ${res.statusCode} while listing PR comments. Please verify the repository, PR number, and token permissions, then retry. Response: ${data}`
)
);
return;
}
let parsedComments;
try {
parsedComments = data.length > 0 ? JSON.parse(data) : [];
} catch (error) {
reject(
new Error(
`GitHub API returned invalid JSON while listing PR comments. Please retry the workflow, and if the issue persists inspect the API response body. Response: ${data}`
)
);
return;
}
nextPath = parseNextLink(res.headers.link);
resolve(Array.isArray(parsedComments) ? parsedComments : []);
});
})
.on("error", reject);
});
comments.push(...pageComments);
}
return comments;
}
/**
* Find and delete any previous design review comment on the PR
* so we don't accumulate stale comments.
*/
async function deletePreviousReviewComment(owner, repo) {
const comments = await fetchAllIssueComments(owner, repo);

Copilot uses AI. Check for mistakes.
const botComments = comments.filter(
(c) =>
c.user.type === "Bot" &&
c.body.includes("Keploy Design Review")
);
Comment on lines +133 to +137
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

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

The deletion filter matches any Bot comment containing “Keploy Design Review”, which could delete comments from other bots (or future automation) that happen to include that phrase. Consider narrowing this to the specific bot identity (e.g., c.user.login === 'github-actions[bot]' and/or a unique HTML comment marker in the body) before deleting.

Copilot uses AI. Check for mistakes.

for (const comment of botComments) {
await githubRequest(
"DELETE",
`/repos/${owner}/${repo}/issues/comments/${comment.id}`,
{}
).catch(() => {}); // ignore delete errors
}
}

/**
* @param {string} reviewBody - the Markdown review text from Claude
*/
async function postComment(reviewBody) {
const [owner, repo] = GITHUB_REPOSITORY.split("/");

if (GITHUB_EVENT_NAME === "pull_request" && PR_NUMBER) {
// Clean up previous bot comment first
await deletePreviousReviewComment(owner, repo);

// Post fresh PR comment
await githubRequest(
"POST",
`/repos/${owner}/${repo}/issues/${PR_NUMBER}/comments`,
{ body: reviewBody }
);
console.log(`Design review posted to PR #${PR_NUMBER}`);
} else {
// Post as commit comment (push or manual trigger)
await githubRequest(
"POST",
`/repos/${owner}/${repo}/commits/${GITHUB_SHA}/comments`,
{ body: reviewBody }
);
console.log(`Design review posted to commit ${GITHUB_SHA}`);
}
}

module.exports = { postComment };
Loading
Loading