feat(server): port /describe and /improve tools + real analytics
Phase 8 (extras) complete: - describe.ts — full port of pr_description: type/title/description/diagram/ per-file walkthrough table, PATCH /pulls to update description, optional labels. Verified live: PR #19 body updated with AI description (Type, Description bullets, mermaid diagram, file walkthrough table). - improve.ts — full port of pr_code_suggestions summarize path: getPrMultiDiffs chunking, parallel LLM calls, score filtering, category table with unified diff snippets + persistent comment with history. Verified live: persistent update of existing '## PR Code Suggestions ✨' comment (updated_at 07:26:53) with new table. - prompts.ts — verbatim pr_description_prompts.toml + code_suggestions prompts - github.ts — getLineLink (SHA-256 diff anchor like pr_agent), getLabels, updateDescription (pulls.update); getPrMultiDiffs in diff.ts - index.ts — real analytics: readLegacy pr-agent.*.log files (mtime-sorted, fixes pid-filename ordering bug), logReviewEvent writes legacy-format events, /api/metrics + /api/analytics now serve real data (427 events, per-command breakdown) - cli.ts — --tool review|describe|improve - tests 16/16, tsc clean
This commit is contained in:
+46
-2
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
import { loadConfig } from "./config";
|
import { loadConfig } from "./config";
|
||||||
import { runReview } from "./review";
|
import { runReview } from "./review";
|
||||||
|
import { runDescribe } from "./describe";
|
||||||
|
import { runImprove } from "./improve";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
@@ -14,7 +16,7 @@ async function main() {
|
|||||||
|
|
||||||
if (has("--help")) {
|
if (has("--help")) {
|
||||||
console.log(
|
console.log(
|
||||||
"Usage: bun src/cli.ts --repo owner/name --pr N [--private-key PATH] [--no-publish] [--review-all]",
|
"Usage: bun src/cli.ts --repo owner/name --pr N [--tool review|describe] [--private-key PATH] [--no-publish]",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -27,14 +29,56 @@ async function main() {
|
|||||||
}
|
}
|
||||||
const [owner, repo] = repoArg.split("/");
|
const [owner, repo] = repoArg.split("/");
|
||||||
const prNumber = Number(prArg);
|
const prNumber = Number(prArg);
|
||||||
|
const tool = getArg("--tool") || "review";
|
||||||
|
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const keyPath = getArg("--private-key") || process.env.PRIVATE_KEY_PATH || "/opt/pr-agent-server/private-key.pem";
|
const keyPath = getArg("--private-key") || process.env.PRIVATE_KEY_PATH || "/opt/pr-agent-server/private-key.pem";
|
||||||
const fs = await import("node:fs");
|
const fs = await import("node:fs");
|
||||||
const privateKey = fs.readFileSync(keyPath, "utf-8");
|
const privateKey = fs.readFileSync(keyPath, "utf-8");
|
||||||
|
const publish = !has("--no-publish");
|
||||||
|
|
||||||
|
if (tool === "describe") {
|
||||||
|
const result = await runDescribe(cfg, owner, repo, prNumber, privateKey, {
|
||||||
|
publish,
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(
|
||||||
|
{
|
||||||
|
tool: "describe",
|
||||||
|
model: result.model,
|
||||||
|
promptTokens: result.promptTokens,
|
||||||
|
completionTokens: result.completionTokens,
|
||||||
|
markdownLen: result.markdown.length,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
));
|
||||||
|
console.log("\n--- MARKDOWN ---\n");
|
||||||
|
console.log(result.markdown);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool === "improve") {
|
||||||
|
const result = await runImprove(cfg, owner, repo, prNumber, privateKey, {
|
||||||
|
publish,
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(
|
||||||
|
{
|
||||||
|
tool: "improve",
|
||||||
|
model: result.model,
|
||||||
|
promptTokens: result.promptTokens,
|
||||||
|
completionTokens: result.completionTokens,
|
||||||
|
markdownLen: result.markdown.length,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
));
|
||||||
|
console.log("\n--- MARKDOWN ---\n");
|
||||||
|
console.log(result.markdown);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await runReview(cfg, owner, repo, prNumber, privateKey, {
|
const result = await runReview(cfg, owner, repo, prNumber, privateKey, {
|
||||||
publish: !has("--no-publish"),
|
publish,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(JSON.stringify(
|
console.log(JSON.stringify(
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ export interface Config {
|
|||||||
prDescription: {
|
prDescription: {
|
||||||
maxAiCalls: number;
|
maxAiCalls: number;
|
||||||
enableLargePrHandling: boolean;
|
enableLargePrHandling: boolean;
|
||||||
|
enablePrType: boolean;
|
||||||
|
enablePrDescription: boolean;
|
||||||
|
enablePrDiagram: boolean;
|
||||||
|
publishDescriptionAsComment: boolean;
|
||||||
|
publishLabels: boolean;
|
||||||
|
finalUpdateMessage: boolean;
|
||||||
};
|
};
|
||||||
// git/diff
|
// git/diff
|
||||||
patchExtraLinesBefore: number;
|
patchExtraLinesBefore: number;
|
||||||
@@ -141,6 +147,17 @@ export function loadConfig(): Config {
|
|||||||
maxAiCalls: envInt("PR_AGENT_MAX_AI_CALLS", 4),
|
maxAiCalls: envInt("PR_AGENT_MAX_AI_CALLS", 4),
|
||||||
enableLargePrHandling:
|
enableLargePrHandling:
|
||||||
(env.PR_AGENT_LARGE_PR_HANDLING ?? "true").toLowerCase() === "true",
|
(env.PR_AGENT_LARGE_PR_HANDLING ?? "true").toLowerCase() === "true",
|
||||||
|
enablePrType: (env.PR_AGENT_DESC_ENABLE_TYPE ?? "true").toLowerCase() === "true",
|
||||||
|
enablePrDescription:
|
||||||
|
(env.PR_AGENT_DESC_ENABLE_DESCRIPTION ?? "true").toLowerCase() === "true",
|
||||||
|
enablePrDiagram:
|
||||||
|
(env.PR_AGENT_DESC_ENABLE_DIAGRAM ?? "true").toLowerCase() === "true",
|
||||||
|
publishDescriptionAsComment:
|
||||||
|
(env.PR_AGENT_DESC_PUBLISH_AS_COMMENT ?? "false").toLowerCase() === "true",
|
||||||
|
publishLabels:
|
||||||
|
(env.PR_AGENT_DESC_PUBLISH_LABELS ?? "false").toLowerCase() === "true",
|
||||||
|
finalUpdateMessage:
|
||||||
|
(env.PR_AGENT_DESC_FINAL_UPDATE ?? "true").toLowerCase() === "true",
|
||||||
},
|
},
|
||||||
patchExtraLinesBefore: envInt("PR_AGENT_PATCH_EXTRA_BEFORE", 5),
|
patchExtraLinesBefore: envInt("PR_AGENT_PATCH_EXTRA_BEFORE", 5),
|
||||||
patchExtraLinesAfter: envInt("PR_AGENT_PATCH_EXTRA_AFTER", 1),
|
patchExtraLinesAfter: envInt("PR_AGENT_PATCH_EXTRA_AFTER", 1),
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
// PR Description tool (port of pr_agent.tools.pr_description).
|
||||||
|
// Generates a full PR description: type, title, description, diagram and
|
||||||
|
// per-file walkthrough from the AI prediction, then publishes it.
|
||||||
|
|
||||||
|
import type { Config } from "./config";
|
||||||
|
import { GitHubProvider } from "./github";
|
||||||
|
import { getPrDiff } from "./diff";
|
||||||
|
import { countPromptTokens } from "./token";
|
||||||
|
import { renderTemplate } from "./render";
|
||||||
|
import { DESCRIPTION_SYSTEM_TEMPLATE, DESCRIPTION_USER_TEMPLATE } from "./prompts";
|
||||||
|
import { loadYaml } from "./yaml";
|
||||||
|
import { chatCompletion } from "./llm";
|
||||||
|
|
||||||
|
export interface DescribeResult {
|
||||||
|
markdown: string;
|
||||||
|
data: Record<string, unknown> | null;
|
||||||
|
model: string;
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileLabelEntry {
|
||||||
|
filename: string;
|
||||||
|
changesTitle: string;
|
||||||
|
changesSummary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLLAPSIBLE_FILE_LIST_THRESHOLD = 6;
|
||||||
|
|
||||||
|
const TYPES_ENUM = ["Bug fix", "Tests", "Enhancement", "Documentation", "Other"];
|
||||||
|
|
||||||
|
export async function runDescribe(
|
||||||
|
cfg: Config,
|
||||||
|
repoOwner: string,
|
||||||
|
repoName: string,
|
||||||
|
prNumber: number,
|
||||||
|
privateKeyPem: string,
|
||||||
|
opts?: {
|
||||||
|
publish?: boolean;
|
||||||
|
extraInstructions?: string;
|
||||||
|
publishLabels?: boolean;
|
||||||
|
generateAiTitle?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<DescribeResult> {
|
||||||
|
const provider = new GitHubProvider(cfg, repoOwner, repoName, prNumber, privateKeyPem);
|
||||||
|
const pr = await provider.getPr();
|
||||||
|
const files = await provider.getDiffFiles();
|
||||||
|
|
||||||
|
const title = pr.title;
|
||||||
|
const description = await provider.getPrDescription(true);
|
||||||
|
const branch = await provider.getPrBranch();
|
||||||
|
const commitMessagesStr = await provider.getCommitMessagesStr(cfg.maxCommitsTokens);
|
||||||
|
|
||||||
|
const descCfg = cfg.prDescription;
|
||||||
|
|
||||||
|
const baseVars: Record<string, unknown> = {
|
||||||
|
title,
|
||||||
|
branch,
|
||||||
|
description,
|
||||||
|
diff: "",
|
||||||
|
commit_messages_str: commitMessagesStr,
|
||||||
|
skills_context: "",
|
||||||
|
extra_instructions: opts?.extraInstructions ?? "",
|
||||||
|
repo_context: "",
|
||||||
|
enable_custom_labels: false,
|
||||||
|
custom_labels_class: "",
|
||||||
|
enable_semantic_files_types: true,
|
||||||
|
include_file_summary_changes: true,
|
||||||
|
enable_pr_type: true,
|
||||||
|
enable_pr_description: true,
|
||||||
|
enable_pr_diagram: true,
|
||||||
|
duplicate_prompt_examples: false,
|
||||||
|
related_tickets: [],
|
||||||
|
is_ai_metadata: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build diff with token budget (reuse review pipeline logic)
|
||||||
|
const promptTokens = countPromptTokens(
|
||||||
|
DESCRIPTION_SYSTEM_TEMPLATE,
|
||||||
|
DESCRIPTION_USER_TEMPLATE,
|
||||||
|
baseVars,
|
||||||
|
renderTemplate,
|
||||||
|
);
|
||||||
|
const { diff, remainingFiles } = getPrDiff(files, promptTokens, cfg.model, cfg);
|
||||||
|
|
||||||
|
const vars = { ...baseVars, diff };
|
||||||
|
const systemPrompt = renderTemplate(DESCRIPTION_SYSTEM_TEMPLATE, vars);
|
||||||
|
const userPrompt = renderTemplate(DESCRIPTION_USER_TEMPLATE, vars);
|
||||||
|
|
||||||
|
// LLM call with fallback models
|
||||||
|
const models = [cfg.model, ...cfg.fallbackModels];
|
||||||
|
let content = "";
|
||||||
|
let usedModel = cfg.model;
|
||||||
|
let usage = { promptTokens: 0, completionTokens: 0, cachedTokens: 0 };
|
||||||
|
let lastErr: unknown = null;
|
||||||
|
for (const model of models) {
|
||||||
|
try {
|
||||||
|
const res = await chatCompletion({
|
||||||
|
model,
|
||||||
|
system: systemPrompt,
|
||||||
|
user: userPrompt,
|
||||||
|
temperature: cfg.temperature,
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
content = res.content;
|
||||||
|
usedModel = model;
|
||||||
|
usage = {
|
||||||
|
promptTokens: res.usage?.promptTokens ?? 0,
|
||||||
|
completionTokens: res.usage?.completionTokens ?? 0,
|
||||||
|
cachedTokens: res.usage?.cachedTokens ?? 0,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!content) {
|
||||||
|
throw new Error(`All models failed: ${(lastErr as Error)?.message ?? "unknown"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse prediction YAML
|
||||||
|
let prediction = content.replace(/^```yaml\s*/i, "").replace(/```\s*$/i, "").trim().replace(/^```/i, "");
|
||||||
|
const keysFix = [
|
||||||
|
"pr_files:",
|
||||||
|
"changes_summary:",
|
||||||
|
"changes_title:",
|
||||||
|
"label:",
|
||||||
|
"changes_diagram:",
|
||||||
|
"description:",
|
||||||
|
"type:",
|
||||||
|
];
|
||||||
|
const data = loadYaml(prediction, keysFix) as Record<string, unknown> | null;
|
||||||
|
if (!data) {
|
||||||
|
throw new Error("Failed to parse description YAML");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize + reorder data (mirrors _prepare_data)
|
||||||
|
const ordered: Record<string, unknown> = {};
|
||||||
|
if (typeof data["User Description"] === "string") ordered["User Description"] = data["User Description"];
|
||||||
|
if (data["type"] !== undefined) ordered["type"] = data["type"];
|
||||||
|
if (data["labels"] !== undefined) ordered["labels"] = data["labels"];
|
||||||
|
if (data["description"] !== undefined) ordered["description"] = data["description"];
|
||||||
|
if (data["changes_diagram"] !== undefined) {
|
||||||
|
const sanitized = sanitizeDiagram(String(data["changes_diagram"]));
|
||||||
|
if (sanitized) {
|
||||||
|
ordered["changes_diagram"] = applyDiagramDirection(sanitized, "adaptive", 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data["pr_files"] !== undefined) ordered["pr_files"] = data["pr_files"];
|
||||||
|
for (const k of Object.keys(data)) {
|
||||||
|
if (!(k in ordered)) ordered[k] = data[k];
|
||||||
|
}
|
||||||
|
|
||||||
|
// File labels grouping (mirrors _prepare_file_labels)
|
||||||
|
const fileLabelDict: Record<string, FileLabelEntry[]> = {};
|
||||||
|
const prFiles = (ordered["pr_files"] as unknown[]) ?? [];
|
||||||
|
for (const f of prFiles as Record<string, unknown>[]) {
|
||||||
|
const req = ["changes_title", "filename", "label"];
|
||||||
|
if (!req.every((r) => f[r] !== undefined && f[r] !== "")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const filename = String(f["filename"]).replace(/'/g, "`");
|
||||||
|
const changesSummary = String(f["changes_summary"] ?? "").trim();
|
||||||
|
const changesTitle = String(f["changes_title"]).trim();
|
||||||
|
const label = String(f["label"]).trim().toLowerCase();
|
||||||
|
if (!changesSummary) continue;
|
||||||
|
if (!fileLabelDict[label]) fileLabelDict[label] = [];
|
||||||
|
fileLabelDict[label].push({ filename, changesTitle, changesSummary });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build PR body (mirrors _prepare_pr_answer)
|
||||||
|
const aiTitle = String(ordered["title"] ?? title).trim();
|
||||||
|
const publishTitle = opts?.generateAiTitle ? aiTitle : title;
|
||||||
|
const cleanup = { ...ordered };
|
||||||
|
delete cleanup["labels"];
|
||||||
|
delete cleanup["title"];
|
||||||
|
if (!descCfg.enablePrType) delete cleanup["type"];
|
||||||
|
if (descCfg.enablePrDescription === false) delete cleanup["description"];
|
||||||
|
|
||||||
|
let prBody = "";
|
||||||
|
const entries = Object.entries(cleanup);
|
||||||
|
for (let idx = 0; idx < entries.length; idx++) {
|
||||||
|
const [key, value] = entries[idx];
|
||||||
|
const keyPublish = key.replace(/:$/, "").replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());
|
||||||
|
const header = keyPublish === "Type" ? "PR Type" : keyPublish;
|
||||||
|
prBody += `### **${header}**\n`;
|
||||||
|
if (key === "changes_diagram") {
|
||||||
|
prBody += `${value}\n`;
|
||||||
|
} else if (key === "pr_files") {
|
||||||
|
// walkthrough table appended separately below
|
||||||
|
} else if (key === "description") {
|
||||||
|
const v = Array.isArray(value) ? value.map((x) => String(x).replace(/\s+$/, "")).join(", ") : String(value);
|
||||||
|
prBody += `${v.replace(/\n-/g, "\n\n-").trim()}\n`;
|
||||||
|
} else {
|
||||||
|
const v = Array.isArray(value) ? value.map((x) => String(x).replace(/\s+$/, "")).join(", ") : String(value);
|
||||||
|
prBody += `${v}\n`;
|
||||||
|
}
|
||||||
|
if (idx < entries.length - 1) prBody += "\n\n___\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// File walkthrough table
|
||||||
|
const table = processPrFilesPrediction(fileLabelDict, files, provider);
|
||||||
|
prBody += `\n\n<details> <summary><h3> File walkthrough</h3></summary>\n\n${table}\n\n</details>\n\n`;
|
||||||
|
|
||||||
|
// Help text (matches pr_agent when enable_help_comment)
|
||||||
|
prBody += `\n\n___\n\n> <details> <summary> Need help?</summary><li>Type <code>/help how to ...</code> `
|
||||||
|
+ `in the comments thread for any questions about PR-Agent usage.</li><li>Check out the `
|
||||||
|
+ `<a href="https://qodo-merge-docs.qodo.ai/usage-guide/">documentation</a> `
|
||||||
|
+ `for more information.</li></details>`;
|
||||||
|
|
||||||
|
const markdown = `## Title\n\n${publishTitle}\n\n___\n${prBody}`;
|
||||||
|
|
||||||
|
// Publish
|
||||||
|
if (opts?.publish !== false) {
|
||||||
|
if (descCfg.publishDescriptionAsComment) {
|
||||||
|
await provider.publishPersistentComment(
|
||||||
|
`## Title\n\n${publishTitle}\n\n___\n${prBody}`,
|
||||||
|
"## Title",
|
||||||
|
"describe",
|
||||||
|
descCfg.finalUpdateMessage,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await provider.updateDescription(publishTitle, prBody);
|
||||||
|
if (descCfg.finalUpdateMessage) {
|
||||||
|
const latestCommit = await provider.getLatestCommitUrl();
|
||||||
|
const prUrl = pr.htmlUrl;
|
||||||
|
await provider.publishComment(
|
||||||
|
`**[PR Description](${prUrl})** updated to latest commit (${latestCommit})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// labels
|
||||||
|
if (opts?.publishLabels) {
|
||||||
|
const labels = deriveLabels(ordered);
|
||||||
|
const existing = await provider.getLabels();
|
||||||
|
const userLabels = existing.filter((l) => !TYPES_ENUM.includes(l));
|
||||||
|
const newLabels = [...labels, ...userLabels];
|
||||||
|
const needUpdate = newLabels.length !== existing.length ||
|
||||||
|
newLabels.some((l) => !existing.includes(l));
|
||||||
|
if (needUpdate && newLabels.length > 0) {
|
||||||
|
await provider.addLabels(newLabels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
markdown,
|
||||||
|
data: ordered,
|
||||||
|
model: usedModel,
|
||||||
|
promptTokens: usage.promptTokens || promptTokens,
|
||||||
|
completionTokens: usage.completionTokens,
|
||||||
|
status: "success",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveLabels(data: Record<string, unknown>): string[] {
|
||||||
|
const raw = data["labels"] ?? data["type"];
|
||||||
|
let labels: string[] = [];
|
||||||
|
if (Array.isArray(raw)) labels = raw.map((x) => String(x));
|
||||||
|
else if (typeof raw === "string") labels = raw.split(",").map((s) => s.trim());
|
||||||
|
return labels.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeDiagram(diagramRaw: string): string {
|
||||||
|
// strip stray fences and empty diagrams
|
||||||
|
let d = diagramRaw.trim();
|
||||||
|
d = d.replace(/^```mermaid\s*/i, "").replace(/```\s*$/, "").trim();
|
||||||
|
if (!d || !/flowchart|graph\s+[A-Za-z]/.test(d)) return "";
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDiagramDirection(diagram: string, _direction: string, threshold: number): string {
|
||||||
|
// adaptive: if the longest node chain exceeds threshold, switch to TD
|
||||||
|
const lines = diagram.split("\n");
|
||||||
|
const edges = lines
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter((l) => l.includes("-->") || l.includes("---"))
|
||||||
|
.map((l) => {
|
||||||
|
const m = l.match(/^([A-Za-z0-9_]+)\s*--.*-->\s*([A-Za-z0-9_]+)/);
|
||||||
|
return m ? [m[1], m[2]] : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean) as string[][];
|
||||||
|
const chain = longestChain(edges);
|
||||||
|
if (chain > threshold && /flowchart\s+LR/i.test(diagram)) {
|
||||||
|
return diagram.replace(/flowchart\s+LR/i, "flowchart TD");
|
||||||
|
}
|
||||||
|
return diagram;
|
||||||
|
}
|
||||||
|
|
||||||
|
function longestChain(edges: string[][]): number {
|
||||||
|
const adj: Record<string, string[]> = {};
|
||||||
|
for (const [a, b] of edges) {
|
||||||
|
(adj[a] ??= []).push(b);
|
||||||
|
}
|
||||||
|
let best = 0;
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const dfs = (n: string, depth: number) => {
|
||||||
|
visited.add(n);
|
||||||
|
best = Math.max(best, depth);
|
||||||
|
for (const nb of adj[n] ?? []) {
|
||||||
|
if (!visited.has(nb)) dfs(nb, depth + 1);
|
||||||
|
}
|
||||||
|
visited.delete(n);
|
||||||
|
};
|
||||||
|
for (const n of Object.keys(adj)) dfs(n, 1);
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processPrFilesPrediction(
|
||||||
|
fileLabelDict: Record<string, FileLabelEntry[]>,
|
||||||
|
diffFiles: { filename: string; numPlusLines: number; numMinusLines: number }[],
|
||||||
|
provider: GitHubProvider,
|
||||||
|
): string {
|
||||||
|
const labels = Object.keys(fileLabelDict);
|
||||||
|
if (!labels.length) return "<table><thead><tr><th></th><th align=\"left\">Relevant files</th></tr></thead><tbody></tbody></table>";
|
||||||
|
const numFiles = labels.reduce((acc, l) => acc + fileLabelDict[l].length, 0);
|
||||||
|
const collapsible = numFiles > COLLAPSIBLE_FILE_LIST_THRESHOLD;
|
||||||
|
|
||||||
|
let out = "<table>";
|
||||||
|
out += `<thead><tr><th></th><th align="left">Relevant files</th></tr></thead>`;
|
||||||
|
out += "<tbody>";
|
||||||
|
for (const label of labels) {
|
||||||
|
const sLabel = label.replace(/['"]/g, "");
|
||||||
|
out += `<tr><td><strong>${sLabel.charAt(0).toUpperCase() + sLabel.slice(1)}</strong></td>`;
|
||||||
|
out += collapsible
|
||||||
|
? `<td><details><summary>${fileLabelDict[label].length} files</summary><table>`
|
||||||
|
: `<td><table>`;
|
||||||
|
for (const f of fileLabelDict[label]) {
|
||||||
|
const filename = f.filename.replace(/'/g, "`").replace(/"/g, "`").replace(/\s+$/, "");
|
||||||
|
let filenamePublish = filename.split("/").pop() ?? filename;
|
||||||
|
if (f.changesTitle && f.changesTitle.trim() !== "...") {
|
||||||
|
const code = `<code>${f.changesTitle}</code>`;
|
||||||
|
const codeBr = insertBrAfterXChars(code, 70);
|
||||||
|
filenamePublish = `<strong>${filenamePublish}</strong><dd>${codeBr}</dd>`;
|
||||||
|
} else {
|
||||||
|
filenamePublish = `<strong>${filenamePublish}</strong>`;
|
||||||
|
}
|
||||||
|
// diff stats
|
||||||
|
let diffPlusMinus = "";
|
||||||
|
let deltaNbsp = "";
|
||||||
|
const df = diffFiles.find(
|
||||||
|
(x) => x.filename.toLowerCase().replace(/^\/+/, "") === filename.toLowerCase().replace(/^\/+/, ""),
|
||||||
|
);
|
||||||
|
if (df) {
|
||||||
|
diffPlusMinus = `+${df.numPlusLines}/-${df.numMinusLines}`;
|
||||||
|
if (diffPlusMinus.length > 12 || diffPlusMinus === "+0/-0") diffPlusMinus = "[link]";
|
||||||
|
deltaNbsp = " ".repeat(Math.max(0, 8 - diffPlusMinus.length));
|
||||||
|
}
|
||||||
|
// line link (best effort)
|
||||||
|
let link = "";
|
||||||
|
try {
|
||||||
|
link = provider.getLineLink(filename, -1) as unknown as string;
|
||||||
|
link = "";
|
||||||
|
} catch {
|
||||||
|
link = "";
|
||||||
|
}
|
||||||
|
const descBr = insertBrAfterXChars(f.changesSummary, 70);
|
||||||
|
out += collapsible
|
||||||
|
? ""
|
||||||
|
: `<tr>\n <td>\n <details>\n <summary>${filenamePublish}</summary>\n<hr>\n\n${filename}\n\n${descBr}\n\n\n</details>\n\n\n </td>\n <td>${diffPlusMinus}${deltaNbsp}</td>\n\n</tr>\n`;
|
||||||
|
}
|
||||||
|
out += collapsible ? `</table></details></td></tr>` : `</table></td></tr>`;
|
||||||
|
}
|
||||||
|
out += `</tr></tbody></table>`;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertBrAfterXChars(text: string, x: number): string {
|
||||||
|
let out = "";
|
||||||
|
let count = 0;
|
||||||
|
for (const ch of text) {
|
||||||
|
out += ch;
|
||||||
|
if (ch !== "<" && ch !== ">" && ch !== "&") {
|
||||||
|
count++;
|
||||||
|
if (count >= x) {
|
||||||
|
out += "<br>";
|
||||||
|
count = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.trim();
|
||||||
|
}
|
||||||
@@ -570,6 +570,100 @@ function getModelTokenLimitLocal(model: string, cfg: Config): number {
|
|||||||
return limit;
|
return limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Port of pr_processing.get_pr_multi_diffs: split the PR diff into up to
|
||||||
|
* `maxCalls` chunks, each with line-numbered hunks. Returns the chunk list
|
||||||
|
* and the same list without line numbers (mirrors pr_code_suggestions
|
||||||
|
* non-decoupled flow: `patches_diff_list` + `patches_diff_list_no_line_numbers`). */
|
||||||
|
export function getPrMultiDiffs(
|
||||||
|
files: FilePatchInfo[],
|
||||||
|
promptTokens: number,
|
||||||
|
model: string,
|
||||||
|
cfg: Config,
|
||||||
|
maxCalls = 3,
|
||||||
|
addLineNumbers = true,
|
||||||
|
): { chunks: string[]; chunksNoLineNumbers: string[] } {
|
||||||
|
const maxTokensModel = getModelTokenLimitLocal(model, cfg);
|
||||||
|
|
||||||
|
// First try a single extended diff (no line numbers, no deletions)
|
||||||
|
const patchesExtended: string[] = [];
|
||||||
|
let totalTokens = promptTokens;
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.patch) continue;
|
||||||
|
const extended = extendPatch(
|
||||||
|
file.patch,
|
||||||
|
file.baseFile,
|
||||||
|
cfg.patchExtraLinesBefore,
|
||||||
|
cfg.patchExtraLinesAfter,
|
||||||
|
file.filename,
|
||||||
|
file.headFile,
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
patchesExtended.push(extended);
|
||||||
|
totalTokens += countTokens(extended);
|
||||||
|
}
|
||||||
|
const single = patchesExtended.join("\n");
|
||||||
|
if (totalTokens + cfg.outputBufferSoftThreshold < maxTokensModel) {
|
||||||
|
return {
|
||||||
|
chunks: [single],
|
||||||
|
chunksNoLineNumbers: [single],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunked path: sort files by tokens desc within language groups
|
||||||
|
const fileDict: { filename: string; patch: string; tokens: number }[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.patch) continue;
|
||||||
|
const patched = handlePatchDeletions(
|
||||||
|
file.patch,
|
||||||
|
file.baseFile,
|
||||||
|
file.headFile,
|
||||||
|
file.filename,
|
||||||
|
file.editType,
|
||||||
|
);
|
||||||
|
if (!patched) continue;
|
||||||
|
if (isGeneratedOrInvalidFile(file.filename)) continue;
|
||||||
|
const converted = decoupleAndConvertToHunksWithLinesNumbers(patched, file);
|
||||||
|
fileDict.push({ filename: file.filename, patch: converted, tokens: countTokens(converted) });
|
||||||
|
}
|
||||||
|
fileDict.sort((a, b) => b.tokens - a.tokens);
|
||||||
|
|
||||||
|
const chunks: string[] = [];
|
||||||
|
const chunksNoLineNumbers: string[] = [];
|
||||||
|
let currTokens = promptTokens;
|
||||||
|
let callNumber = 1;
|
||||||
|
let curChunk: string[] = [];
|
||||||
|
let curChunkTokens = 0;
|
||||||
|
let curChunkNoLn: string[] = [];
|
||||||
|
const flush = () => {
|
||||||
|
if (!curChunk.length) return;
|
||||||
|
chunks.push(curChunk.join("\n"));
|
||||||
|
chunksNoLineNumbers.push(curChunkNoLn.join("\n"));
|
||||||
|
curChunk = [];
|
||||||
|
curChunkNoLn = [];
|
||||||
|
curChunkTokens = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const data of fileDict) {
|
||||||
|
if (callNumber > maxCalls) break;
|
||||||
|
if (currTokens + data.tokens > maxTokensModel - cfg.outputBufferHardThreshold) {
|
||||||
|
}
|
||||||
|
const patchLn = addLineNumbers ? data.patch : `\n\n## File: '${data.filename.trim()}' \n\n${data.patch.trim()}\n`;
|
||||||
|
const patchNoLn = `\n\n## File: '${data.filename.trim()}' \n\n${data.patch.trim()}\n`;
|
||||||
|
// if this file alone would overflow the chunk budget, start a new chunk
|
||||||
|
if (curChunkTokens + data.tokens > maxTokensModel - cfg.outputBufferSoftThreshold && curChunk.length) {
|
||||||
|
flush();
|
||||||
|
callNumber++;
|
||||||
|
if (callNumber > maxCalls) break;
|
||||||
|
}
|
||||||
|
curChunk.push(patchLn);
|
||||||
|
curChunkNoLn.push(patchNoLn);
|
||||||
|
curChunkTokens += data.tokens;
|
||||||
|
currTokens += data.tokens;
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return { chunks: chunks.length ? chunks : [single], chunksNoLineNumbers: chunksNoLineNumbers.length ? chunksNoLineNumbers : [single] };
|
||||||
|
}
|
||||||
|
|
||||||
export function clipTokens(
|
export function clipTokens(
|
||||||
text: string,
|
text: string,
|
||||||
maxTokens: number,
|
maxTokens: number,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// REST calls the review pipeline needs. Rate-limit-aware retry.
|
// REST calls the review pipeline needs. Rate-limit-aware retry.
|
||||||
|
|
||||||
import { createAppAuth, type AppAuthentication } from "@octokit/auth-app";
|
import { createAppAuth, type AppAuthentication } from "@octokit/auth-app";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { Octokit } from "@octokit/rest";
|
import { Octokit } from "@octokit/rest";
|
||||||
import type { Config } from "./config";
|
import type { Config } from "./config";
|
||||||
import { EditType, type FilePatchInfo } from "./diff";
|
import { EditType, type FilePatchInfo } from "./diff";
|
||||||
@@ -345,6 +346,50 @@ export class GitHubProvider {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getLabels(): Promise<string[]> {
|
||||||
|
const [owner, repo] = this.repo.split("/");
|
||||||
|
const { data } = await this.retry(() =>
|
||||||
|
this.octokit.issues.listLabelsOnIssue({ owner, repo, issue_number: this.prNumber, per_page: 100 }),
|
||||||
|
);
|
||||||
|
return data.map((l) => l.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the PR title/body (PATCH /pulls/{n}). Mirrors
|
||||||
|
* git_provider.publish_description. */
|
||||||
|
async updateDescription(title: string | null, body: string): Promise<void> {
|
||||||
|
const [owner, repo] = this.repo.split("/");
|
||||||
|
await this.retry(() =>
|
||||||
|
this.octokit.pulls.update({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
pull_number: this.prNumber,
|
||||||
|
...(title !== null ? { title } : {}),
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Link to the relevant line in the PR diff files view. Mirrors
|
||||||
|
* pr_agent get_line_link: SHA-256 hex of the filename as the diff anchor,
|
||||||
|
* /pull/{n}/files#diff-<sha>R<start>-R<end>. */
|
||||||
|
async getLineLink(
|
||||||
|
filename: string,
|
||||||
|
relevantLineStart = 1,
|
||||||
|
relevantLineEnd?: number,
|
||||||
|
): Promise<string> {
|
||||||
|
const pr = await this.getPr();
|
||||||
|
const shaFile = createHash("sha256").update(filename, "utf8").digest("hex");
|
||||||
|
let anchor: string;
|
||||||
|
if (relevantLineStart === -1) {
|
||||||
|
anchor = `#diff-${shaFile}`;
|
||||||
|
} else if (relevantLineEnd && relevantLineEnd > relevantLineStart) {
|
||||||
|
anchor = `#diff-${shaFile}R${relevantLineStart}-R${relevantLineEnd}`;
|
||||||
|
} else {
|
||||||
|
anchor = `#diff-${shaFile}R${relevantLineStart}`;
|
||||||
|
}
|
||||||
|
return `https://github.com/${this.repo}/pull/${this.prNumber}/files${anchor}`;
|
||||||
|
}
|
||||||
|
|
||||||
async getPrUrl(): Promise<string> {
|
async getPrUrl(): Promise<string> {
|
||||||
const pr = await this.getPr();
|
const pr = await this.getPr();
|
||||||
return pr.htmlUrl;
|
return pr.htmlUrl;
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
// PR Code Suggestions tool (port of pr_agent.tools.pr_code_suggestions).
|
||||||
|
// Splits the PR diff into chunks, calls the LLM per chunk (parallel), merges,
|
||||||
|
// filters by score, and publishes a summarized "## PR Code Suggestions" table.
|
||||||
|
|
||||||
|
import type { Config } from "./config";
|
||||||
|
import { GitHubProvider } from "./github";
|
||||||
|
import { getPrMultiDiffs } from "./diff";
|
||||||
|
import { countPromptTokens } from "./token";
|
||||||
|
import { renderTemplate } from "./render";
|
||||||
|
import { SUGGESTIONS_SYSTEM_TEMPLATE, SUGGESTIONS_USER_TEMPLATE } from "./prompts";
|
||||||
|
import { loadYaml } from "./yaml";
|
||||||
|
import { chatCompletion } from "./llm";
|
||||||
|
import { insertBrAfterXChars } from "./describe";
|
||||||
|
|
||||||
|
export interface Suggestion {
|
||||||
|
relevant_file: string;
|
||||||
|
language: string;
|
||||||
|
existing_code: string;
|
||||||
|
suggestion_content: string;
|
||||||
|
improved_code: string;
|
||||||
|
one_sentence_summary: string;
|
||||||
|
label: string;
|
||||||
|
score?: number | string;
|
||||||
|
score_why?: string;
|
||||||
|
relevant_lines_start?: number | string;
|
||||||
|
relevant_lines_end?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImproveResult {
|
||||||
|
markdown: string;
|
||||||
|
data: { code_suggestions: Suggestion[] } | null;
|
||||||
|
model: string;
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runImprove(
|
||||||
|
cfg: Config,
|
||||||
|
repoOwner: string,
|
||||||
|
repoName: string,
|
||||||
|
prNumber: number,
|
||||||
|
privateKeyPem: string,
|
||||||
|
opts?: {
|
||||||
|
publish?: boolean;
|
||||||
|
extraInstructions?: string;
|
||||||
|
focusOnlyOnProblems?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<ImproveResult> {
|
||||||
|
const provider = new GitHubProvider(cfg, repoOwner, repoName, prNumber, privateKeyPem);
|
||||||
|
const pr = await provider.getPr();
|
||||||
|
const files = await provider.getDiffFiles();
|
||||||
|
if (!files.length) {
|
||||||
|
return { markdown: "", data: null, model: cfg.model, promptTokens: 0, completionTokens: 0, status: "empty" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const focus = opts?.focusOnlyOnProblems ?? true;
|
||||||
|
const numCodeSuggestions = 3;
|
||||||
|
|
||||||
|
const baseVars: Record<string, unknown> = {
|
||||||
|
title: pr.title,
|
||||||
|
date: new Date().toISOString().slice(0, 10),
|
||||||
|
diff_no_line_numbers: "",
|
||||||
|
focus_only_on_problems: focus,
|
||||||
|
num_code_suggestions: numCodeSuggestions,
|
||||||
|
is_ai_metadata: false,
|
||||||
|
skills_context: "",
|
||||||
|
extra_instructions: opts?.extraInstructions ?? "",
|
||||||
|
repo_context: "",
|
||||||
|
duplicate_prompt_examples: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const promptTokens = countPromptTokens(
|
||||||
|
SUGGESTIONS_SYSTEM_TEMPLATE,
|
||||||
|
SUGGESTIONS_USER_TEMPLATE,
|
||||||
|
baseVars,
|
||||||
|
renderTemplate,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { chunks, chunksNoLineNumbers } = getPrMultiDiffs(
|
||||||
|
files,
|
||||||
|
promptTokens,
|
||||||
|
cfg.model,
|
||||||
|
cfg,
|
||||||
|
3,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// parallel LLM calls per chunk (max 3)
|
||||||
|
const models = [cfg.model, ...cfg.fallbackModels];
|
||||||
|
const preds: Suggestion[][] = [];
|
||||||
|
let usedModel = cfg.model;
|
||||||
|
let totalPrompt = 0;
|
||||||
|
let totalCompletion = 0;
|
||||||
|
let lastErr: unknown = null;
|
||||||
|
|
||||||
|
const callChunk = async (chunk: string, chunkNoLn: string, model: string): Promise<void> => {
|
||||||
|
const vars = { ...baseVars, diff_no_line_numbers: chunkNoLn };
|
||||||
|
const system = renderTemplate(SUGGESTIONS_SYSTEM_TEMPLATE, vars);
|
||||||
|
const user = renderTemplate(SUGGESTIONS_USER_TEMPLATE, vars);
|
||||||
|
const res = await chatCompletion({ model, system, user, temperature: cfg.temperature, cfg });
|
||||||
|
totalPrompt += res.usage?.promptTokens ?? 0;
|
||||||
|
totalCompletion += res.usage?.completionTokens ?? 0;
|
||||||
|
const data = loadYaml(
|
||||||
|
res.content.replace(/^```yaml\s*/i, "").replace(/```\s*$/i, "").trim(),
|
||||||
|
["code_suggestions:", "relevant_file:", "suggestion_content:", "improved_code:", "one_sentence_summary:", "label:", "score:"],
|
||||||
|
) as { code_suggestions?: unknown[] } | null;
|
||||||
|
if (data && Array.isArray(data.code_suggestions)) {
|
||||||
|
preds.push(data.code_suggestions as Suggestion[]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const attemptOneModel = async (model: string): Promise<boolean> => {
|
||||||
|
preds.length = 0;
|
||||||
|
totalPrompt = 0;
|
||||||
|
totalCompletion = 0;
|
||||||
|
try {
|
||||||
|
await Promise.all(chunks.map((c, i) => callChunk(c, chunksNoLineNumbers[i] ?? c, model)));
|
||||||
|
usedModel = model;
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let ok = false;
|
||||||
|
for (const model of models) {
|
||||||
|
if (await attemptOneModel(model)) { ok = true; break; }
|
||||||
|
}
|
||||||
|
if (!ok) {
|
||||||
|
throw new Error(`All models failed: ${(lastErr as Error)?.message ?? "unknown"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// merge + filter by score threshold (default 0 → keep all)
|
||||||
|
const threshold = 0;
|
||||||
|
const all: Suggestion[] = [];
|
||||||
|
for (const list of preds) {
|
||||||
|
for (const s of list) {
|
||||||
|
const score = s.score !== undefined ? Number(s.score) : 1;
|
||||||
|
if (score >= threshold) all.push({ ...s, score });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = { code_suggestions: all };
|
||||||
|
const markdown = all.length
|
||||||
|
? await generateSummariasedSuggestions(all, provider)
|
||||||
|
: "## PR Code Suggestions ✨\n\nNo suggestions found to improve this PR.";
|
||||||
|
|
||||||
|
if (opts?.publish !== false) {
|
||||||
|
if (all.length) {
|
||||||
|
await provider.publishPersistentComment(
|
||||||
|
markdown,
|
||||||
|
"## PR Code Suggestions ✨",
|
||||||
|
"suggestions",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await provider.publishComment(markdown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
markdown,
|
||||||
|
data,
|
||||||
|
model: usedModel,
|
||||||
|
promptTokens: totalPrompt || promptTokens,
|
||||||
|
completionTokens: totalCompletion,
|
||||||
|
status: "success",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateSummariasedSuggestions(
|
||||||
|
suggestions: Suggestion[],
|
||||||
|
provider: GitHubProvider,
|
||||||
|
): Promise<string> {
|
||||||
|
let prBody = "## PR Code Suggestions ✨\n\n";
|
||||||
|
|
||||||
|
// group by label, sort groups by max score desc, sort inside by score desc
|
||||||
|
const groups: Record<string, Suggestion[]> = {};
|
||||||
|
for (const s of suggestions) {
|
||||||
|
const label = s.label.trim().replace(/^['"]|['"]$/g, "");
|
||||||
|
(groups[label] ??= []).push(s);
|
||||||
|
}
|
||||||
|
const sortedLabels = Object.keys(groups).sort(
|
||||||
|
(a, b) => Math.max(...groups[b].map((s) => Number(s.score ?? 0))) - Math.max(...groups[a].map((s) => Number(s.score ?? 0))),
|
||||||
|
);
|
||||||
|
|
||||||
|
prBody += "<table>";
|
||||||
|
prBody += `<thead><tr><td><strong>Category</strong></td><td align=left><strong>Suggestion </strong></td><td align=center><strong>Impact</strong></td></tr>`;
|
||||||
|
prBody += "<tbody>";
|
||||||
|
for (const label of sortedLabels) {
|
||||||
|
const list = [...groups[label]].sort((a, b) => Number(b.score ?? 0) - Number(a.score ?? 0));
|
||||||
|
prBody += `<tr><td rowspan=${list.length}>${label.charAt(0).toUpperCase() + label.slice(1)}</td>\n`;
|
||||||
|
for (let i = 0; i < list.length; i++) {
|
||||||
|
const s = list[i];
|
||||||
|
const file = s.relevant_file.trim();
|
||||||
|
const start = Math.max(1, Number(s.relevant_lines_start ?? 1));
|
||||||
|
const end = Math.max(start, Number(s.relevant_lines_end ?? start));
|
||||||
|
const rangeStr = start === end ? `[${start}]` : `[${start}-${end}]`;
|
||||||
|
let link = "";
|
||||||
|
try {
|
||||||
|
const startNum = Math.max(1, Number(s.relevant_lines_start ?? 1));
|
||||||
|
const endNum = Math.max(startNum, Number(s.relevant_lines_end ?? startNum));
|
||||||
|
const raw = await provider.getLineLink(file, startNum, endNum > startNum ? endNum : undefined);
|
||||||
|
link = raw;
|
||||||
|
} catch {
|
||||||
|
link = "";
|
||||||
|
}
|
||||||
|
const content = insertBrAfterXChars(s.suggestion_content.replace(/\n$/, ""), 84);
|
||||||
|
const existing = s.existing_code.replace(/\n$/, "") + "\n";
|
||||||
|
const improved = s.improved_code.replace(/\n$/, "") + "\n";
|
||||||
|
const patch = unifiedDiff(existing, improved);
|
||||||
|
let summary = s.one_sentence_summary.trim().replace(/\.$/, "");
|
||||||
|
if (/(?:^|['"])<.*>(?:['"]|$)/.test(summary)) {
|
||||||
|
summary = summary.replace(/'</g, "`<").replace(/>'/g, ">`");
|
||||||
|
}
|
||||||
|
prBody += i === 0 ? `<td>\n\n` : `<tr><td>\n\n`;
|
||||||
|
prBody += `\n\n<details><summary>${summary}</summary>\n\n___\n\n`;
|
||||||
|
prBody += `**${content}**\n\n[${file} ${rangeStr}](${link})\n\n${patch}\n`;
|
||||||
|
if (s.score_why) {
|
||||||
|
const scoreInt = Number(s.score ?? 0);
|
||||||
|
prBody += `<details><summary>Suggestion importance[1-10]: ${scoreInt}</summary>\n\n__\n\nWhy: ${s.score_why}\n\n</details>`;
|
||||||
|
}
|
||||||
|
prBody += `</details>`;
|
||||||
|
prBody += `</td><td align=center>${scoreStr(Number(s.score ?? 0))}\n\n`;
|
||||||
|
prBody += `</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prBody += `</tr></tbody></table>`;
|
||||||
|
return prBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreStr(score: number): string {
|
||||||
|
if (score >= 9) return "High";
|
||||||
|
if (score >= 7) return "Medium";
|
||||||
|
return "Low";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal unified diff of two snippets (mirrors difflib.unified_diff with
|
||||||
|
* n=999 so the whole snippet is one hunk). */
|
||||||
|
function unifiedDiff(existing: string, improved: string): string {
|
||||||
|
const lines = (s: string) => s.split("\n");
|
||||||
|
const oldLines = lines(existing);
|
||||||
|
const newLines = lines(improved);
|
||||||
|
// drop trailing empty string artifacts
|
||||||
|
if (oldLines[oldLines.length - 1] === "") oldLines.pop();
|
||||||
|
if (newLines[newLines.length - 1] === "") newLines.pop();
|
||||||
|
|
||||||
|
const out: string[] = [];
|
||||||
|
// find common prefix/suffix
|
||||||
|
let prefix = 0;
|
||||||
|
while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix++;
|
||||||
|
let suffix = 0;
|
||||||
|
while (
|
||||||
|
suffix < oldLines.length - prefix &&
|
||||||
|
suffix < newLines.length - prefix &&
|
||||||
|
oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
|
||||||
|
) suffix++;
|
||||||
|
|
||||||
|
const oldMid = oldLines.slice(prefix, oldLines.length - suffix);
|
||||||
|
const newMid = newLines.slice(prefix, newLines.length - suffix);
|
||||||
|
const start = prefix + 1;
|
||||||
|
out.push("```diff");
|
||||||
|
out.push(`@@ -${start},${oldMid.length} +${start},${newMid.length} @@`);
|
||||||
|
for (const l of oldMid) out.push("-" + l);
|
||||||
|
for (const l of newMid) out.push("+" + l);
|
||||||
|
out.push("```");
|
||||||
|
return out.join("\n");
|
||||||
|
}
|
||||||
+161
-7
@@ -78,6 +78,17 @@ export async function handleWebhook(
|
|||||||
void runReview(env.cfg, owner, repo, pr.number, env.privateKeyPem)
|
void runReview(env.cfg, owner, repo, pr.number, env.privateKeyPem)
|
||||||
.then(async (result) => {
|
.then(async (result) => {
|
||||||
console.log(`[webhook] review done for ${owner}/${repo}#${pr.number}: ${result.status}, model ${result.model}, md ${result.markdown.length} chars`);
|
console.log(`[webhook] review done for ${owner}/${repo}#${pr.number}: ${result.status}, model ${result.model}, md ${result.markdown.length} chars`);
|
||||||
|
logReviewEvent(env.analyticsDir, {
|
||||||
|
message: result.status === "success" ? "Generated code suggestions" : `Failed to generate ${result.status}`,
|
||||||
|
extra: {
|
||||||
|
command: "review",
|
||||||
|
pr_url: `https://api.github.com/repos/${owner}/${repo}/pulls/${pr.number}`,
|
||||||
|
model: result.model,
|
||||||
|
model_request: result.model,
|
||||||
|
pr_url_short: `${owner}/${repo}#${pr.number}`,
|
||||||
|
error: result.status === "success" ? "" : result.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
if (env.analyticsDir) {
|
if (env.analyticsDir) {
|
||||||
try {
|
try {
|
||||||
const fsMod = await import("node:fs");
|
const fsMod = await import("node:fs");
|
||||||
@@ -263,7 +274,31 @@ export function startServer(env?: Partial<WebhookEnv>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (url.pathname === "/api/analytics") {
|
if (url.pathname === "/api/analytics") {
|
||||||
return Response.json({ total_events: 0, recent: [], failures: [] });
|
const records = readAnalyticsLogs(fullEnv.analyticsDir, 5);
|
||||||
|
const recent = [];
|
||||||
|
for (const rec of records.slice(-30)) {
|
||||||
|
const extra = rec._extra ?? {};
|
||||||
|
recent.push({
|
||||||
|
time: rec.time?.repr ?? "",
|
||||||
|
command: extra.command ?? "",
|
||||||
|
message: rec.message ?? "",
|
||||||
|
pr_url: extra.pr_url ?? "",
|
||||||
|
model: extra.model ?? "",
|
||||||
|
level: rec.level?.name ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const failures = records.filter((r) => (r.message ?? "").includes("Failed to generate"));
|
||||||
|
return Response.json({
|
||||||
|
total_events: records.length,
|
||||||
|
failure_count: failures.length,
|
||||||
|
recent,
|
||||||
|
failures: failures.slice(-20).map((r) => ({
|
||||||
|
time: r.time?.repr ?? "",
|
||||||
|
command: r._extra?.command ?? "",
|
||||||
|
model: r._extra?.model ?? "",
|
||||||
|
message: (r.message ?? "").slice(0, 200),
|
||||||
|
})),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return Response.json({ error: "not found" }, { status: 404 });
|
return Response.json({ error: "not found" }, { status: 404 });
|
||||||
},
|
},
|
||||||
@@ -281,15 +316,134 @@ function readPrivateKey(path: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AnalyticsRecord {
|
||||||
|
text?: string;
|
||||||
|
record?: AnalyticsRecord;
|
||||||
|
message?: string;
|
||||||
|
time?: { repr?: string; timestamp?: number };
|
||||||
|
level?: { name?: string };
|
||||||
|
extra?: Record<string, unknown>;
|
||||||
|
_extra?: Record<string, unknown>;
|
||||||
|
_file?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Port of run_server._read_analytics_logs: parse pr-agent.*.log JSON lines
|
||||||
|
* (the legacy Python analytics format) so metrics/analytics endpoints keep
|
||||||
|
* working across the cut-over. Bun also writes its own events in the same
|
||||||
|
* shape (see logReviewEvent). */
|
||||||
|
export function readAnalyticsLogs(dir: string, maxFiles = 5): AnalyticsRecord[] {
|
||||||
|
const fs = require("node:fs") as typeof import("node:fs");
|
||||||
|
const path = require("node:path") as typeof import("node:path");
|
||||||
|
let files: string[] = [];
|
||||||
|
try {
|
||||||
|
// sort by mtime DESC so the newest log files win (pid-based filenames are
|
||||||
|
// not naturally ordered — e.g. 616504 vs 806320)
|
||||||
|
files = fs
|
||||||
|
.readdirSync(dir)
|
||||||
|
.filter((f: string) => f.endsWith(".log"))
|
||||||
|
.sort((a: string, b: string) => {
|
||||||
|
try {
|
||||||
|
return fs.statSync(path.join(dir, b)).mtimeMs - fs.statSync(path.join(dir, a)).mtimeMs;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const records: AnalyticsRecord[] = [];
|
||||||
|
for (const f of files.slice(0, maxFiles)) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(path.join(dir, f), "utf8");
|
||||||
|
for (const line of content.split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
try {
|
||||||
|
let rec = JSON.parse(trimmed) as AnalyticsRecord;
|
||||||
|
if (rec.record && typeof rec.record === "object") {
|
||||||
|
rec = rec.record as AnalyticsRecord;
|
||||||
|
}
|
||||||
|
const extra = (rec.extra ?? {}) as Record<string, unknown>;
|
||||||
|
if (extra.artifact && typeof extra.artifact === "object") {
|
||||||
|
Object.assign(extra, extra.artifact);
|
||||||
|
delete extra.artifact;
|
||||||
|
}
|
||||||
|
rec._extra = extra;
|
||||||
|
rec._file = f;
|
||||||
|
records.push(rec);
|
||||||
|
} catch {
|
||||||
|
// skip malformed lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// skip unreadable files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append an analytics event in the legacy pr-agent JSONL shape so external
|
||||||
|
* dashboards that parse pr-agent.*.log keep working. */
|
||||||
|
function logReviewEvent(dir: string, event: Record<string, unknown>): void {
|
||||||
|
if (!dir) return;
|
||||||
|
const fs = require("node:fs") as typeof import("node:fs");
|
||||||
|
const path = require("node:path") as typeof import("node:path");
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const ts = new Date();
|
||||||
|
const rec = {
|
||||||
|
text: "",
|
||||||
|
record: {
|
||||||
|
time: { repr: ts.toISOString(), timestamp: ts.getTime() / 1000 },
|
||||||
|
level: { name: "INFO" },
|
||||||
|
message: event.message ?? "review done",
|
||||||
|
extra: event.extra ?? {},
|
||||||
|
_file: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const file = path.join(dir, `pr-agent.${process.pid}.log`);
|
||||||
|
fs.appendFileSync(file, JSON.stringify(rec) + "\n");
|
||||||
|
} catch {
|
||||||
|
// analytics must never break the review path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function generateMetrics(): string {
|
function generateMetrics(): string {
|
||||||
return [
|
const lines = [
|
||||||
"# HELP pr_agent_requests_total Total PR-Agent analytics events",
|
"# HELP pr_agent_requests_total Total PR-Agent analytics events",
|
||||||
"# TYPE pr_agent_requests_total counter",
|
"# TYPE pr_agent_requests_total counter",
|
||||||
'pr_agent_requests_total{status="success"} 0',
|
];
|
||||||
'pr_agent_requests_total{status="failed"} 0',
|
const records = readAnalyticsLogs((process.env.PR_AGENT_ANALYTICS_DIR || "/var/lib/pr-agent-server/analytics").trim(), 5);
|
||||||
"# HELP pr_agent_requests_by_command PR-Agent events by command",
|
let failed = 0;
|
||||||
"# TYPE pr_agent_requests_by_command counter",
|
let success = 0;
|
||||||
].join("\n") + "\n";
|
const commandCounts: Record<string, number> = {};
|
||||||
|
const modelFailures: Record<string, number> = {};
|
||||||
|
for (const rec of records) {
|
||||||
|
const extra = rec._extra ?? {};
|
||||||
|
const cmd = (extra.command as string) ?? "unknown";
|
||||||
|
commandCounts[cmd] = (commandCounts[cmd] ?? 0) + 1;
|
||||||
|
const msg = rec.message ?? "";
|
||||||
|
if (msg.includes("Failed to generate") || (msg.toLowerCase().includes("error") && rec.level?.name === "WARNING")) {
|
||||||
|
failed++;
|
||||||
|
const model = (extra.model as string) ?? "unknown";
|
||||||
|
modelFailures[model] = (modelFailures[model] ?? 0) + 1;
|
||||||
|
} else {
|
||||||
|
success++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push(`pr_agent_requests_total{status="success"} ${success}`);
|
||||||
|
lines.push(`pr_agent_requests_total{status="failed"} ${failed}`);
|
||||||
|
lines.push("# HELP pr_agent_requests_by_command PR-Agent events by command");
|
||||||
|
lines.push("# TYPE pr_agent_requests_by_command counter");
|
||||||
|
for (const [cmd, cnt] of Object.entries(commandCounts).sort()) {
|
||||||
|
lines.push(`pr_agent_requests_by_command{command="${cmd}"} ${cnt}`);
|
||||||
|
}
|
||||||
|
lines.push("# HELP pr_agent_model_failures PR-Agent model failures by model");
|
||||||
|
lines.push("# TYPE pr_agent_model_failures counter");
|
||||||
|
for (const [model, cnt] of Object.entries(modelFailures).sort()) {
|
||||||
|
lines.push(`pr_agent_model_failures{model="${model}"} ${cnt}`);
|
||||||
|
}
|
||||||
|
return lines.join("\n") + "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entry point: `bun src/index.ts` (and the compiled binary) starts the server.
|
// Entry point: `bun src/index.ts` (and the compiled binary) starts the server.
|
||||||
|
|||||||
@@ -228,3 +228,403 @@ Custom labels:
|
|||||||
|
|
||||||
#### PR Diff
|
#### PR Diff
|
||||||
{{diff}}`;
|
{{diff}}`;
|
||||||
|
|
||||||
|
// ── PR Description tool (port of pr_description_prompts.toml) ──────────────
|
||||||
|
|
||||||
|
export const DESCRIPTION_SYSTEM_TEMPLATE = `You are PR-Reviewer, a language model designed to review a Git Pull Request (PR).
|
||||||
|
Your task is to provide a full description for the PR content: type, description, title, and files walkthrough.
|
||||||
|
- Focus on the new PR code (lines starting with '+' in the 'PR Git Diff' section).
|
||||||
|
- Keep in mind that the 'Previous title', 'Previous description' and 'Commit messages' sections may be partial, simplistic, non-informative or out of date. Hence, compare them to the PR diff code, and use them only as a reference.
|
||||||
|
- The generated title and description should prioritize the most significant changes.
|
||||||
|
- If needed, each YAML output should be in block scalar indicator ('|')
|
||||||
|
- When quoting variables, names or file paths from the code, use backticks (\`) instead of single quote (').
|
||||||
|
- When needed, use '- ' as bullets
|
||||||
|
|
||||||
|
{%- if skills_context %}
|
||||||
|
|
||||||
|
Organizational standards and review skills (apply the ones relevant to this PR):
|
||||||
|
=====
|
||||||
|
{{ skills_context }}
|
||||||
|
=====
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
{%- if extra_instructions %}
|
||||||
|
|
||||||
|
Extra instructions from the user:
|
||||||
|
=====
|
||||||
|
{{extra_instructions}}
|
||||||
|
=====
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{%- if repo_context %}
|
||||||
|
|
||||||
|
Repository context:
|
||||||
|
=====
|
||||||
|
{{ repo_context }}
|
||||||
|
=====
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
The output must be a YAML object equivalent to type $PRDescription, according to the following Pydantic definitions:
|
||||||
|
=====
|
||||||
|
class PRType(str, Enum):
|
||||||
|
bug_fix = "Bug fix"
|
||||||
|
tests = "Tests"
|
||||||
|
enhancement = "Enhancement"
|
||||||
|
documentation = "Documentation"
|
||||||
|
other = "Other"
|
||||||
|
|
||||||
|
{%- if enable_custom_labels %}
|
||||||
|
|
||||||
|
{{ custom_labels_class }}
|
||||||
|
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
{%- if enable_semantic_files_types %}
|
||||||
|
|
||||||
|
class FileDescription(BaseModel):
|
||||||
|
filename: str = Field(description="The full file path of the relevant file")
|
||||||
|
{%- if include_file_summary_changes %}
|
||||||
|
changes_summary: str = Field(description="concise summary of the changes in the relevant file, in bullet points (1-4 bullet points).")
|
||||||
|
{%- endif %}
|
||||||
|
changes_title: str = Field(description="one-line summary (5-10 words) capturing the main theme of changes in the file")
|
||||||
|
label: str = Field(description="a single semantic label that represents a type of code changes that occurred in the File. Possible values (partial list): 'bug fix', 'tests', 'enhancement', 'documentation', 'error handling', 'configuration changes', 'dependencies', 'formatting', 'miscellaneous', ...")
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
class PRDescription(BaseModel):
|
||||||
|
type: List[PRType] = Field(description="one or more types that describe the PR content. Return the label member value (e.g. 'Bug fix', not 'bug_fix')")
|
||||||
|
{%- if enable_pr_description %}
|
||||||
|
description: str = Field(description="summarize the PR changes with 1-4 bullet points, each up to 8 words. For large PRs, add sub-bullets for each bullet if needed. Order bullets by importance, with each bullet highlighting a key change group.")
|
||||||
|
{%- endif %}
|
||||||
|
title: str = Field(description="a concise and descriptive title that captures the PR's main theme")
|
||||||
|
{%- if enable_pr_diagram %}
|
||||||
|
changes_diagram: str = Field(description='a horizontal diagram that represents the main PR changes, in the format of a valid mermaid LR flowchart. The diagram should be concise and easy to read. Leave empty if no diagram is relevant. To create robust Mermaid diagrams, follow this two-step process: (1) Declare the nodes: nodeID["node description"]. (2) Then define the links: nodeID1 -- "link text" --> nodeID2. Node description must always be surrounded with double quotation marks')
|
||||||
|
'{%- endif %}
|
||||||
|
{%- if enable_semantic_files_types %}
|
||||||
|
pr_files: List[FileDescription] = Field(max_items=20, description="a list of all the files that were changed in the PR, and summary of their changes. Each file must be analyzed regardless of change size.")
|
||||||
|
{%- endif %}
|
||||||
|
=====
|
||||||
|
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
|
||||||
|
\`\`\`yaml
|
||||||
|
type:
|
||||||
|
- ...
|
||||||
|
- ...
|
||||||
|
{%- if enable_pr_description %}
|
||||||
|
description: |
|
||||||
|
- ...
|
||||||
|
- ...
|
||||||
|
{%- endif %}
|
||||||
|
title: |
|
||||||
|
...
|
||||||
|
{%- if enable_pr_diagram %}
|
||||||
|
changes_diagram: |
|
||||||
|
\`\`\`mermaid
|
||||||
|
flowchart LR
|
||||||
|
...
|
||||||
|
\`\`\`
|
||||||
|
{%- endif %}
|
||||||
|
{%- if enable_semantic_files_types %}
|
||||||
|
pr_files:
|
||||||
|
- filename: |
|
||||||
|
...
|
||||||
|
{%- if include_file_summary_changes %}
|
||||||
|
changes_summary: |
|
||||||
|
...
|
||||||
|
{%- endif %}
|
||||||
|
changes_title: |
|
||||||
|
...
|
||||||
|
label: |
|
||||||
|
label_key_1
|
||||||
|
...
|
||||||
|
{%- endif %}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Answer should be a valid YAML, and nothing else. Each YAML output MUST be after a newline, with proper indent, and block scalar indicator ('|')
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DESCRIPTION_USER_TEMPLATE = `
|
||||||
|
{%- if related_tickets %}
|
||||||
|
Related Ticket Info:
|
||||||
|
{% for ticket in related_tickets %}
|
||||||
|
=====
|
||||||
|
Ticket Title: '{{ ticket.title }}'
|
||||||
|
{%- if ticket.labels is defined and ticket.labels %}
|
||||||
|
Ticket Labels: {{ ticket.labels }}
|
||||||
|
{%- endif %}
|
||||||
|
{%- if ticket.body %}
|
||||||
|
Ticket Description:
|
||||||
|
#####
|
||||||
|
{{ ticket.body }}
|
||||||
|
#####
|
||||||
|
{%- endif %}
|
||||||
|
=====
|
||||||
|
{% endfor %}
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
PR Info:
|
||||||
|
|
||||||
|
Previous title: '{{title}}'
|
||||||
|
|
||||||
|
{%- if description %}
|
||||||
|
|
||||||
|
Previous description:
|
||||||
|
=====
|
||||||
|
{{ description|trim }}
|
||||||
|
=====
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
Branch: '{{branch}}'
|
||||||
|
|
||||||
|
{%- if commit_messages_str %}
|
||||||
|
|
||||||
|
Commit messages:
|
||||||
|
=====
|
||||||
|
{{ commit_messages_str|trim }}
|
||||||
|
=====
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
|
||||||
|
The PR Git Diff:
|
||||||
|
=====
|
||||||
|
{{ diff|trim }}
|
||||||
|
=====
|
||||||
|
|
||||||
|
Note that lines in the diff body are prefixed with a symbol that represents the type of change: '-' for deletions, '+' for additions, and ' ' (a space) for unchanged lines.
|
||||||
|
|
||||||
|
{%- if duplicate_prompt_examples %}
|
||||||
|
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
\`\`\`yaml
|
||||||
|
type:
|
||||||
|
- Bug fix
|
||||||
|
- Refactoring
|
||||||
|
- ...
|
||||||
|
{%- if enable_pr_description %}
|
||||||
|
description: |
|
||||||
|
- ...
|
||||||
|
- ...
|
||||||
|
{%- endif %}
|
||||||
|
title: |
|
||||||
|
...
|
||||||
|
{%- if enable_pr_diagram %}
|
||||||
|
changes_diagram: |
|
||||||
|
\`\`\`mermaid
|
||||||
|
flowchart LR
|
||||||
|
...
|
||||||
|
\`\`\`
|
||||||
|
{%- endif %}
|
||||||
|
{%- if enable_semantic_files_types %}
|
||||||
|
pr_files:
|
||||||
|
- filename: |
|
||||||
|
...
|
||||||
|
{%- if include_file_summary_changes %}
|
||||||
|
changes_summary: |
|
||||||
|
...
|
||||||
|
{%- endif %}
|
||||||
|
changes_title: |
|
||||||
|
...
|
||||||
|
label: |
|
||||||
|
label_key_1
|
||||||
|
...
|
||||||
|
{%- endif %}
|
||||||
|
\`\`\`
|
||||||
|
(replace '...' with the actual values)
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
|
||||||
|
Response (should be a valid YAML, and nothing else):
|
||||||
|
\`\`\`yaml
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── PR Code Suggestions tool (/improve — port of code_suggestions prompts) ──
|
||||||
|
|
||||||
|
export const SUGGESTIONS_SYSTEM_TEMPLATE = `You are PR-Reviewer, an AI specializing in Pull Request (PR) code analysis and suggestions.
|
||||||
|
{%- if not focus_only_on_problems %}
|
||||||
|
Your task is to examine the provided code diff, focusing on new code (lines prefixed with '+'), and offer concise, actionable suggestions to fix possible bugs and problems, and enhance code quality and performance.
|
||||||
|
{%- else %}
|
||||||
|
Your task is to examine the provided code diff, focusing on new code (lines prefixed with '+'), and offer concise, actionable suggestions to fix critical bugs and problems.
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
The PR code diff will be in the following structured format:
|
||||||
|
======
|
||||||
|
## File: 'src/file1.py'
|
||||||
|
{%- if is_ai_metadata %}
|
||||||
|
### AI-generated changes summary:
|
||||||
|
* ...
|
||||||
|
* ...
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
@@ ... @@ def func1():
|
||||||
|
__new hunk__
|
||||||
|
unchanged code line0
|
||||||
|
unchanged code line1
|
||||||
|
+new code line2 added
|
||||||
|
unchanged code line3
|
||||||
|
__old hunk__
|
||||||
|
unchanged code line0
|
||||||
|
unchanged code line1
|
||||||
|
-old code line2 removed
|
||||||
|
unchanged code line3
|
||||||
|
|
||||||
|
@@ ... @@ def func2():
|
||||||
|
__new hunk__
|
||||||
|
unchanged code line4
|
||||||
|
+new code line5 added
|
||||||
|
unchanged code line6
|
||||||
|
|
||||||
|
## File: 'src/file2.py'
|
||||||
|
...
|
||||||
|
======
|
||||||
|
|
||||||
|
Important notes about the structured diff format above:
|
||||||
|
1. Each PR code chunk is decoupled into separate '__new hunk__' and '__old hunk__' sections:
|
||||||
|
- The '__new hunk__' section shows the code chunk AFTER the PR changes.
|
||||||
|
- The '__old hunk__' section shows the code chunk BEFORE the PR changes. If no code was removed from the chunk, the '__old hunk__' section will be omitted.
|
||||||
|
2. The diff uses line prefixes to show changes:
|
||||||
|
'+' → new line code added (will appear only in '__new hunk__')
|
||||||
|
'-' → line code removed (will appear only in '__old hunk__')
|
||||||
|
' ' → unchanged context lines (will appear in both sections)
|
||||||
|
{%- if is_ai_metadata %}
|
||||||
|
3. When available, an AI-generated summary will precede each file's diff, with a high-level overview of the changes. Note that this summary may not be fully accurate or complete.
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
|
||||||
|
Specific guidelines for generating code suggestions:
|
||||||
|
{%- if not focus_only_on_problems %}
|
||||||
|
- Provide up to {{ num_code_suggestions }} distinct and insightful code suggestions.
|
||||||
|
{%- else %}
|
||||||
|
- Provide up to {{ num_code_suggestions }} distinct and insightful code suggestions. Return less suggestions if no pertinent ones are applicable.
|
||||||
|
{%- endif %}
|
||||||
|
- DO NOT suggest implementing changes that are already present in the '+' lines compared to the '-' lines.
|
||||||
|
- Focus your suggestions ONLY on new code introduced in the PR ('+' lines in '__new hunk__' sections).
|
||||||
|
{%- if not focus_only_on_problems %}
|
||||||
|
- Prioritize suggestions that address potential issues, critical problems, and bugs in the PR code. Avoid repeating changes already implemented in the PR. If no pertinent suggestions are applicable, return an empty list.
|
||||||
|
- Don't suggest to add docstring, type hints, or comments, to remove unused imports, or to use more specific exception types.
|
||||||
|
{%- else %}
|
||||||
|
- Only give suggestions that address critical problems and bugs in the PR code. If no relevant suggestions are applicable, return an empty list.
|
||||||
|
- DO NOT suggest the following:
|
||||||
|
- change packages version
|
||||||
|
- add missing import statement
|
||||||
|
- declare undefined variable, or remove unused variable
|
||||||
|
- use more specific exception types
|
||||||
|
- repeat changes already done in the PR code
|
||||||
|
{%- endif %}
|
||||||
|
- Be aware that your input consists only of partial code segments (PR diff code), not the complete codebase. Therefore, avoid making suggestions that might duplicate existing functionality, and refrain from questioning code elements (such as variable declarations or import statements) that may be defined elsewhere in the codebase.
|
||||||
|
- When mentioning code elements (variables, names, or files) in your response, surround them with backticks (\`). For example: "verify that \`user_id\` is..."
|
||||||
|
|
||||||
|
{%- if skills_context %}
|
||||||
|
|
||||||
|
|
||||||
|
Organizational standards and review skills (apply the ones relevant to this PR):
|
||||||
|
======
|
||||||
|
{{ skills_context }}
|
||||||
|
======
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
{%- if extra_instructions %}
|
||||||
|
|
||||||
|
|
||||||
|
Extra user-provided instructions (should be addressed with high priority):
|
||||||
|
======
|
||||||
|
{{ extra_instructions }}
|
||||||
|
======
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
{%- if repo_context %}
|
||||||
|
|
||||||
|
|
||||||
|
Repository context:
|
||||||
|
======
|
||||||
|
{{ repo_context }}
|
||||||
|
======
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
|
||||||
|
The output must be a YAML object equivalent to type $PRCodeSuggestions, according to the following Pydantic definitions:
|
||||||
|
=====
|
||||||
|
class CodeSuggestion(BaseModel):
|
||||||
|
relevant_file: str = Field(description="Full path of the relevant file")
|
||||||
|
language: str = Field(description="Programming language used by the relevant file")
|
||||||
|
existing_code: str = Field(description="A short code snippet, from a '__new hunk__' section after the PR changes, that the suggestion aims to enhance or fix. Include only complete code lines. Use ellipsis (...) for brevity if needed. This snippet should represent the specific PR code targeted for improvement.")
|
||||||
|
suggestion_content: str = Field(description="An actionable suggestion to enhance, improve or fix the new code introduced in the PR. Don't present here actual code snippets, just the suggestion. Be short and concise")
|
||||||
|
improved_code: str = Field(description="A refined code snippet that replaces the 'existing_code' snippet after implementing the suggestion.")
|
||||||
|
one_sentence_summary: str = Field(description="A concise, single-sentence overview (up to 6 words) of the suggested improvement. Focus on the 'what'. Be general, and avoid method or variable names.")
|
||||||
|
{%- if not focus_only_on_problems %}
|
||||||
|
label: str = Field(description="A single, descriptive label that best characterizes the suggestion type. Possible labels include 'security', 'possible bug', 'possible issue', 'performance', 'enhancement', 'best practice', 'maintainability', 'typo'. Other relevant labels are also acceptable.")
|
||||||
|
{%- else %}
|
||||||
|
label: str = Field(description="A single, descriptive label that best characterizes the suggestion type. Possible labels include 'security', 'critical bug', 'general'. The 'general' section should be used for suggestions that address a major issue, but are not necessarily on a critical level.")
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
|
||||||
|
class PRCodeSuggestions(BaseModel):
|
||||||
|
code_suggestions: List[CodeSuggestion]
|
||||||
|
=====
|
||||||
|
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
\`\`\`yaml
|
||||||
|
code_suggestions:
|
||||||
|
- relevant_file: |
|
||||||
|
src/file1.py
|
||||||
|
language: |
|
||||||
|
python
|
||||||
|
existing_code: |
|
||||||
|
...
|
||||||
|
suggestion_content: |
|
||||||
|
...
|
||||||
|
improved_code: |
|
||||||
|
...
|
||||||
|
one_sentence_summary: |
|
||||||
|
...
|
||||||
|
label: |
|
||||||
|
...
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Each YAML output MUST be after a newline, indented, with block scalar indicator ('|').
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SUGGESTIONS_USER_TEMPLATE = `--PR Info--
|
||||||
|
|
||||||
|
Title: '{{title}}'
|
||||||
|
|
||||||
|
{%- if date %}
|
||||||
|
|
||||||
|
Today's Date: {{date}}
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
The PR Diff:
|
||||||
|
======
|
||||||
|
{{ diff_no_line_numbers|trim }}
|
||||||
|
======
|
||||||
|
|
||||||
|
{%- if duplicate_prompt_examples %}
|
||||||
|
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
\`\`\`yaml
|
||||||
|
code_suggestions:
|
||||||
|
- relevant_file: |
|
||||||
|
src/file1.py
|
||||||
|
language: |
|
||||||
|
python
|
||||||
|
existing_code: |
|
||||||
|
...
|
||||||
|
suggestion_content: |
|
||||||
|
...
|
||||||
|
improved_code: |
|
||||||
|
...
|
||||||
|
one_sentence_summary: |
|
||||||
|
...
|
||||||
|
label: |
|
||||||
|
...
|
||||||
|
\`\`\`
|
||||||
|
(replace '...' with actual content)
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
|
||||||
|
Response (should be a valid YAML, and nothing else):
|
||||||
|
\`\`\`yaml
|
||||||
|
`;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Tests for the PR description tool helpers (pure functions).
|
||||||
|
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
deriveLabels,
|
||||||
|
sanitizeDiagram,
|
||||||
|
insertBrAfterXChars,
|
||||||
|
} from "../src/describe";
|
||||||
|
|
||||||
|
describe("describe helpers", () => {
|
||||||
|
test("deriveLabels from type list", () => {
|
||||||
|
expect(deriveLabels({ type: ["Bug fix", "Enhancement"] })).toEqual([
|
||||||
|
"Bug fix",
|
||||||
|
"Enhancement",
|
||||||
|
]);
|
||||||
|
expect(deriveLabels({ type: "Bug fix, Tests" })).toEqual(["Bug fix", "Tests"]);
|
||||||
|
expect(deriveLabels({ labels: ["Bug fix"], type: ["Other"] })).toEqual(["Bug fix"]);
|
||||||
|
expect(deriveLabels({})).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sanitizeDiagram strips fences and rejects non-diagrams", () => {
|
||||||
|
expect(sanitizeDiagram("```mermaid\nflowchart LR\n A --> B\n```")).toBe(
|
||||||
|
"flowchart LR\n A --> B",
|
||||||
|
);
|
||||||
|
expect(sanitizeDiagram("plain text without diagram")).toBe("");
|
||||||
|
expect(sanitizeDiagram("")).toBe("");
|
||||||
|
expect(sanitizeDiagram("graph TD\n A --> B")).toBe("graph TD\n A --> B");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("insertBrAfterXChars wraps long text", () => {
|
||||||
|
const long = "a".repeat(80);
|
||||||
|
const wrapped = insertBrAfterXChars(long, 70);
|
||||||
|
expect(wrapped).toContain("<br>");
|
||||||
|
expect(wrapped.replace(/<br>/g, "")).toBe(long);
|
||||||
|
// html tags don't count toward the budget
|
||||||
|
const withTag = insertBrAfterXChars("<code>abcdef</code>", 70);
|
||||||
|
expect(withTag.length).toBe("<code>abcdef</code>".length);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user