-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathreview_pr.js
More file actions
152 lines (138 loc) · 5.89 KB
/
Copy pathreview_pr.js
File metadata and controls
152 lines (138 loc) · 5.89 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
import core from "@actions/core";
import github from "@actions/github";
import fetch from "node-fetch";
import { fileURLToPath } from "url";
import path from "path";
import fs from "fs/promises";
const {
GITHUB_TOKEN,
OPENAI_API_KEY,
MODEL = "gpt-4o-mini",
MAX_TOKENS = "15000",
TIME_BUDGET_SECONDS = "120",
MAX_FILES = "30",
MAX_LINES = "2000",
LINE_TRIM_PER_FILE = "300",
RISKY_EXTS = "js,ts,tsx,jsx,py,go,rb,php,java,kt,cs,rs,swift,c,cc,cpp,h,sql,sh,ps1,yml,yaml,json,html,htm,css,scss,vue,mdx,tf,tfvars,hcl",
OPENAI_BASE_URL = "https://api.openai.com/v1",
OPENAI_ORG = ""
} = process.env;
const riskySet = new Set(RISKY_EXTS.split(",").map(s => s.trim().toLowerCase()));
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const started = Date.now();
const deadline = started + Number(TIME_BUDGET_SECONDS) * 1000;
const abortIfTimeUp = () => { if (Date.now() > deadline) throw new Error("TIME_BUDGET_EXCEEDED"); };
const octokit = github.getOctokit(GITHUB_TOKEN);
const { context } = github;
const owner = context.repo.owner;
const repo = context.repo.repo;
const COMMENT_TAG = "<!-- secure-code-review-bot -->";
async function getPRNumber() {
const n = context.payload.pull_request?.number;
if (!n) throw new Error("This action must run on pull_request events.");
return n;
}
function isBinaryOrLarge(f) { return f.status === "removed" || f.patch == null; }
function hasRiskyExt(filename) { const ext = filename.toLowerCase().split(".").pop(); return riskySet.has(ext); }
function trimPatchLines(patch, limit) {
const lines = patch.split("\n");
if (lines.length <= limit) return patch;
const header = lines[0].startsWith("@@") ? lines[0] : "";
return [header, ...lines.slice(0, limit)].join("\n");
}
async function listChangedFiles(prNumber, capFiles, capLines, perFileCap) {
let page = 1, per_page = 100, files = [], totalLines = 0;
for (;;) {
abortIfTimeUp();
const { data } = await octokit.rest.pulls.listFiles({ owner, repo, pull_number: prNumber, per_page, page });
if (data.length === 0) break;
for (const f of data) {
if (files.length >= capFiles) break;
if (isBinaryOrLarge(f) || !hasRiskyExt(f.filename)) continue;
let patch = (f.patch || "")
.split("\n")
.filter(l => l.startsWith("@@") || l.startsWith("+") || l.startsWith("-"))
.join("\n");
if (!patch.trim()) continue;
patch = trimPatchLines(patch, perFileCap);
const patchLines = patch.split("\n").length;
if (totalLines + patchLines > capLines) {
const remaining = Math.max(0, capLines - totalLines);
if (remaining === 0) break;
patch = trimPatchLines(patch, remaining);
}
totalLines += patch.split("\n").length;
files.push({ filename: f.filename, status: f.status, additions: f.additions, deletions: f.deletions, patch });
if (files.length >= capFiles || totalLines >= capLines) break;
}
if (data.length < per_page || files.length >= capFiles || totalLines >= capLines) break;
page++;
}
return { files, totalLines };
}
async function loadPrompt() {
const promptPath = path.join(__dirname, "prompt.txt");
return fs.readFile(promptPath, "utf8");
}
function buildUserMessage(files) {
let body = "Unified diffs (changed hunks only). Each block begins with '=== FILE ==='.\n";
for (const f of files) {
body += `\n=== FILE: ${f.filename} (${f.status}, +${f.additions}/-${f.deletions}) ===\n`;
body += f.patch + "\n";
}
return body;
}
async function callOpenAI(systemPrompt, userContent) {
abortIfTimeUp();
const resp = await fetch(`${OPENAI_BASE_URL}/chat/completions`, {
method: "POST",
headers: {
"Authorization": `Bearer ${OPENAI_API_KEY}`,
"Content-Type": "application/json",
...(OPENAI_ORG ? { "OpenAI-Organization": OPENAI_ORG } : {})
},
body: JSON.stringify({
model: MODEL,
max_tokens: Number(MAX_TOKENS),
temperature: 0.2,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userContent }
]
})
});
if (resp.status === 429) throw new Error("OPENAI_INSUFFICIENT_QUOTA");
if (!resp.ok) throw new Error(`OpenAI HTTP ${resp.status}: ${await resp.text()}`);
const data = await resp.json();
return data.choices?.[0]?.message?.content?.trim() || "";
}
async function upsertPRComment(prNumber, content) {
abortIfTimeUp();
const header = `## 🔐 Secure Code Review (AI)\n${COMMENT_TAG}`;
const body = `${header}\n\n${content}\n\n---\n_Models can make mistakes. Verify before merging._`;
const { data: comments } = await octokit.rest.issues.listComments({ owner, repo, issue_number: prNumber, per_page: 100 });
const mine = comments.find(c => c.body?.includes(COMMENT_TAG));
if (mine) await octokit.rest.issues.updateComment({ owner, repo, comment_id: mine.id, body });
else await octokit.rest.issues.createComment({ owner, repo, issue_number: prNumber, body });
}
async function run() {
try {
const prNumber = await getPRNumber();
const { files, totalLines } = await listChangedFiles(prNumber, Number(MAX_FILES), Number(MAX_LINES), Number(LINE_TRIM_PER_FILE));
if (files.length === 0) { await upsertPRComment(prNumber, "No eligible code changes."); return; }
const systemPrompt = await loadPrompt();
const userMessage = buildUserMessage(files);
const analysis = await callOpenAI(systemPrompt, userMessage);
await upsertPRComment(prNumber, analysis || "No output from model.");
} catch (err) {
const prNumber = context.payload.pull_request?.number;
if (String(err).includes("OPENAI_INSUFFICIENT_QUOTA") && prNumber) {
await upsertPRComment(prNumber, "⚠️ Could not complete review: **API quota exceeded**. Add billing or use another provider.");
} else if (prNumber) {
await upsertPRComment(prNumber, `⚠️ Could not complete review: \`${String(err)}\``);
}
core.setFailed(String(err));
}
}
run();