Skip to content
Open
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
143 changes: 143 additions & 0 deletions .github/scripts/design-review.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// 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");

// Model is configurable via the CLAUDE_MODEL repo variable so it can be updated
// without a code change if the model is renamed or a newer version is preferred.
const CLAUDE_MODEL = process.env.CLAUDE_MODEL || "claude-sonnet-4-6";

if (!process.env.GITHUB_TOKEN) {
console.error(
"ERROR: GITHUB_TOKEN is not set. This is automatically provided by GitHub Actions — " +
"ensure the workflow step has not overridden it and that the job has 'issues: write' permission."
);
process.exit(1);
}

if (!process.env.GITHUB_REPOSITORY) {
console.error(
"ERROR: GITHUB_REPOSITORY is not set. This is automatically provided by GitHub Actions " +
"as 'owner/repo'. Ensure it is passed via the 'env:' block in the workflow step: " +
"GITHUB_REPOSITORY: ${{ github.repository }}"
);
process.exit(1);
}
Comment on lines +17 to +32
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.

These error logs don’t give a clear next step. Consider including actionable guidance (e.g., which workflow/env should set GITHUB_TOKEN / GITHUB_REPOSITORY, and how to fix it) so failures are easier to diagnose when the action runs.

Copilot uses AI. Check for mistakes.

/**
* 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}`);
console.log(`Model: ${CLAUDE_MODEL}`);

// 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.
// Exit cleanly (exit 0) so the check doesn't fail and block the PR.
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
if (!ANTHROPIC_API_KEY) {
console.log("ANTHROPIC_API_KEY not available — skipping review.");
await postComment(
"## Keploy Design Review\n\n" +
"⚪ Design review was skipped because `ANTHROPIC_API_KEY` is not available in this workflow run.\n\n" +
"Possible causes: the secret is not configured in repo Settings → Secrets → Actions, " +
"it is restricted to a specific environment that this workflow cannot access, " +
"or the workflow trigger was changed from `pull_request_target` to `pull_request` " +
"which may not expose secrets. A maintainer can verify and rerun once resolved."
);
return;
}

// 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 (model: ${CLAUDE_MODEL})...`);
const client = new Anthropic({ apiKey: ANTHROPIC_API_KEY });

let message;
try {
message = await client.messages.create({
model: CLAUDE_MODEL,
max_tokens: 4096,
messages: [{ role: "user", content: user }],
system,
});
} catch (err) {
throw new Error(
`Claude API request failed (model: ${CLAUDE_MODEL}): ${err.message}`
);
}

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);
});
46 changes: 46 additions & 0 deletions .github/scripts/filter-files.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// filters a list of changed file paths to only those relevant for design review

const ALLOWED_EXTENSIONS = [
".css",
".scss",
".sass",
".mdx",
Comment on lines +3 to +7
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.

ALLOWED_EXTENSIONS doesn’t include .svg, even though SVGs are text diffs and can be design-relevant (the repo already has static/keploy-logo.svg). Adding .svg here would better align with the workflow’s intent to review static/** changes and reduce “nothing to review” runs for SVG-only updates.

Copilot uses AI. Check for mistakes.
".md",
".tsx",
".jsx",
".js",
".ts",
".svg", // SVGs are text diffs and design-relevant (e.g. static/keploy-logo.svg)
];

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 };
102 changes: 102 additions & 0 deletions .github/scripts/get-diff.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// 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" ||
GITHUB_EVENT_NAME === "pull_request_target"
) {
// Both events carry PR_NUMBER and use the GitHub API diff endpoint.
// pull_request_target is used so secrets are available on fork PRs
// while still reviewing the actual PR changes via the API.
diff = await fetchPRDiff();
} else if (GITHUB_EVENT_NAME === "push") {
diff = getCommitDiff();
} else {
// workflow_dispatch or any other trigger
diff = getManualDiff();
}
Comment on lines +77 to +93
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.

getDiff() only treats pull_request as a PR event. This workflow runs on pull_request_target, so the code will fall through to getManualDiff() and end up reviewing the last commit on the base branch instead of the PR diff. Handle pull_request_target the same way as pull_request (fetch diff via GitHub API using PR_NUMBER).

Copilot uses AI. Check for mistakes.

// 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 };
9 changes: 9 additions & 0 deletions .github/scripts/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "keploy-design-review-agent",
"version": "1.0.0",
"description": "AI-powered design review agent for Keploy Docs",
"private": true,
"dependencies": {
"@anthropic-ai/sdk": "0.32.1"
}
}
Loading
Loading