@theihtisham/review-agent 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +352 -0
- package/__tests__/config.test.ts +99 -0
- package/__tests__/diff-parser.test.ts +137 -0
- package/__tests__/fixtures/mock-data.ts +120 -0
- package/__tests__/llm-client.test.ts +166 -0
- package/__tests__/rate-limiter.test.ts +95 -0
- package/__tests__/security-utils.test.ts +152 -0
- package/__tests__/security.test.ts +138 -0
- package/action.yml +65 -0
- package/dist/index.js +55824 -0
- package/dist/sourcemap-register.js +1 -0
- package/package.json +46 -0
- package/src/config.ts +201 -0
- package/src/conventions.ts +288 -0
- package/src/github.ts +180 -0
- package/src/llm-client.ts +210 -0
- package/src/main.ts +106 -0
- package/src/reviewer.ts +161 -0
- package/src/reviewers/security.ts +205 -0
- package/src/types.ts +114 -0
- package/src/utils/diff-parser.ts +177 -0
- package/src/utils/rate-limiter.ts +72 -0
- package/src/utils/security.ts +125 -0
- package/tsconfig.json +34 -0
- package/vitest.config.ts +16 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import * as core from "@actions/core";
|
|
2
|
+
import { FileDiff, ReviewAgentConfig, ReviewComment, Severity } from "../types";
|
|
3
|
+
import { getChangedLines } from "../utils/diff-parser";
|
|
4
|
+
|
|
5
|
+
interface SecurityPattern {
|
|
6
|
+
name: string;
|
|
7
|
+
pattern: RegExp;
|
|
8
|
+
message: string;
|
|
9
|
+
severity: Severity;
|
|
10
|
+
owasp?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const SECURITY_PATTERNS: SecurityPattern[] = [
|
|
14
|
+
{
|
|
15
|
+
name: "sql-injection",
|
|
16
|
+
pattern:
|
|
17
|
+
/(?:query|execute|raw)\s*\(\s*(?:['"`]|`[^`]*`|\+|f["']|template)/i,
|
|
18
|
+
message:
|
|
19
|
+
"Potential SQL injection: avoid string concatenation or interpolation in queries. Use parameterized queries instead.",
|
|
20
|
+
severity: "critical",
|
|
21
|
+
owasp: "A03:2021-Injection",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: "sql-string-concat",
|
|
25
|
+
pattern:
|
|
26
|
+
/['"`]SELECT\s+.*['"`]\s*\+/i,
|
|
27
|
+
message:
|
|
28
|
+
"SQL query built with string concatenation. Use parameterized queries to prevent SQL injection.",
|
|
29
|
+
severity: "critical",
|
|
30
|
+
owasp: "A03:2021-Injection",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "eval-usage",
|
|
34
|
+
pattern: /(?:eval|Function)\s*\(/,
|
|
35
|
+
message:
|
|
36
|
+
'Avoid eval() and Function() constructors — they enable arbitrary code execution. Use safer alternatives.',
|
|
37
|
+
severity: "critical",
|
|
38
|
+
owasp: "A03:2021-Injection",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "hardcoded-secret",
|
|
42
|
+
pattern:
|
|
43
|
+
/(?:password|passwd|secret|api[_-]?key|apikey|token|auth)\s*[:=]\s*['"][^'"]{6,}['"]/i,
|
|
44
|
+
message:
|
|
45
|
+
"Hardcoded secret detected. Move this to an environment variable or secret manager.",
|
|
46
|
+
severity: "critical",
|
|
47
|
+
owasp: "A07:2021-Identification and Authentication Failures",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "unsafe-innerhtml",
|
|
51
|
+
pattern: /\.innerHTML\s*=/,
|
|
52
|
+
message:
|
|
53
|
+
"Direct innerHTML assignment can lead to XSS. Use textContent or a sanitization library.",
|
|
54
|
+
severity: "critical",
|
|
55
|
+
owasp: "A03:2021-Injection",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "dangerouslySetInnerHTML",
|
|
59
|
+
pattern: /dangerouslySetInnerHTML/,
|
|
60
|
+
message:
|
|
61
|
+
"dangerouslySetInnerHTML bypasses React's XSS protection. Ensure the content is sanitized.",
|
|
62
|
+
severity: "warning",
|
|
63
|
+
owasp: "A03:2021-Injection",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "unsafe-redirect",
|
|
67
|
+
pattern:
|
|
68
|
+
/(?:redirect|res\.redirect|location\.href|location\.replace)\s*\(\s*(?:req\.|request\.|params|query)/i,
|
|
69
|
+
message:
|
|
70
|
+
"Potential open redirect. Validate and whitelist redirect targets.",
|
|
71
|
+
severity: "critical",
|
|
72
|
+
owasp: "A01:2021-Broken Access Control",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: "cors-wildcard",
|
|
76
|
+
pattern: /Access-Control-Allow-Origin['"]\s*:\s*['"]\*['"]/,
|
|
77
|
+
message:
|
|
78
|
+
"CORS wildcard origin allows any site to access this resource. Restrict to known origins.",
|
|
79
|
+
severity: "warning",
|
|
80
|
+
owasp: "A05:2021-Security Misconfiguration",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: "disabled-auth",
|
|
84
|
+
pattern: /(?:@Public|@AllowAnonymous|skipAuth|auth:\s*false|requireAuth:\s*false)/i,
|
|
85
|
+
message:
|
|
86
|
+
"Authentication/authorization check is disabled. Ensure this endpoint is intentionally public.",
|
|
87
|
+
severity: "warning",
|
|
88
|
+
owasp: "A07:2021-Identification and Authentication Failures",
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: "weak-crypto",
|
|
92
|
+
pattern:
|
|
93
|
+
/(?:md5|sha1|des|rc4|bcrypt\s*\(\s*\d{1,2}\s*,)/i,
|
|
94
|
+
message:
|
|
95
|
+
"Weak cryptographic algorithm detected. Use SHA-256+, bcrypt(12+), or argon2id.",
|
|
96
|
+
severity: "warning",
|
|
97
|
+
owasp: "A02:2021-Cryptographic Failures",
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: "console-log-sensitive",
|
|
101
|
+
pattern:
|
|
102
|
+
/console\.(log|info|debug)\(.*(?:password|token|secret|api[_-]?key|credential)/i,
|
|
103
|
+
message:
|
|
104
|
+
"Avoid logging sensitive data. This could expose secrets in log output.",
|
|
105
|
+
severity: "critical",
|
|
106
|
+
owasp: "A09:2021-Security Logging and Monitoring Failures",
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: "no-https",
|
|
110
|
+
pattern: /fetch\s*\(\s*['"]http:\/\//,
|
|
111
|
+
message:
|
|
112
|
+
"Use HTTPS instead of HTTP for external requests to prevent MITM attacks.",
|
|
113
|
+
severity: "warning",
|
|
114
|
+
owasp: "A02:2021-Cryptographic Failures",
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: "exec-spawn",
|
|
118
|
+
pattern: /(?:exec|execSync|spawn|spawnSync)\s*\(\s*(?:['"`]|\+|`)/,
|
|
119
|
+
message:
|
|
120
|
+
"Potential command injection via exec/spawn. Validate and sanitize all inputs, prefer execFile with args array.",
|
|
121
|
+
severity: "critical",
|
|
122
|
+
owasp: "A03:2021-Injection",
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: "unhandled-errors",
|
|
126
|
+
pattern: /catch\s*\(\s*\w+\s*\)\s*\{\s*\}/,
|
|
127
|
+
message:
|
|
128
|
+
"Empty catch block silently swallows errors. At minimum, log the error.",
|
|
129
|
+
severity: "warning",
|
|
130
|
+
owasp: "A09:2021-Security Logging and Monitoring Failures",
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: "todo-security",
|
|
134
|
+
pattern: /(?:TODO|FIXME|HACK|XXX).*(?:security|auth|password|encrypt)/i,
|
|
135
|
+
message:
|
|
136
|
+
"Security-related TODO comment found. Address before merging.",
|
|
137
|
+
severity: "warning",
|
|
138
|
+
owasp: "A09:2021-Security Logging and Monitoring Failures",
|
|
139
|
+
},
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
export function scanForSecurityIssues(
|
|
143
|
+
diff: FileDiff,
|
|
144
|
+
config: ReviewAgentConfig
|
|
145
|
+
): ReviewComment[] {
|
|
146
|
+
const comments: ReviewComment[] = [];
|
|
147
|
+
const changedLines = getChangedLines(diff.patch);
|
|
148
|
+
|
|
149
|
+
for (const [lineNum, lineContent] of changedLines) {
|
|
150
|
+
for (const pattern of SECURITY_PATTERNS) {
|
|
151
|
+
if (pattern.pattern.test(lineContent)) {
|
|
152
|
+
if (!severityMeetsThreshold(pattern.severity, config.review.severity)) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const body = pattern.owasp
|
|
157
|
+
? `${pattern.message}\n\n**OWASP:** ${pattern.owasp}`
|
|
158
|
+
: pattern.message;
|
|
159
|
+
|
|
160
|
+
comments.push({
|
|
161
|
+
path: diff.filename,
|
|
162
|
+
line: lineNum,
|
|
163
|
+
side: "RIGHT",
|
|
164
|
+
severity: pattern.severity,
|
|
165
|
+
category: "security",
|
|
166
|
+
body: body,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Apply custom rules
|
|
173
|
+
for (const rule of config.rules) {
|
|
174
|
+
if (rule.category !== "security") continue;
|
|
175
|
+
try {
|
|
176
|
+
const regex = new RegExp(rule.pattern, "gi");
|
|
177
|
+
for (const [lineNum, lineContent] of changedLines) {
|
|
178
|
+
if (regex.test(lineContent)) {
|
|
179
|
+
comments.push({
|
|
180
|
+
path: diff.filename,
|
|
181
|
+
line: lineNum,
|
|
182
|
+
side: "RIGHT",
|
|
183
|
+
severity: rule.severity,
|
|
184
|
+
category: "security",
|
|
185
|
+
body: rule.message,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} catch (err) {
|
|
190
|
+
core.warning(
|
|
191
|
+
`Invalid custom rule pattern "${rule.pattern}": ${err instanceof Error ? err.message : String(err)}`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return comments;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function severityMeetsThreshold(
|
|
200
|
+
severity: Severity,
|
|
201
|
+
threshold: Severity
|
|
202
|
+
): boolean {
|
|
203
|
+
const order: Record<Severity, number> = { critical: 0, warning: 1, info: 2 };
|
|
204
|
+
return order[severity] <= order[threshold];
|
|
205
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
export type Severity = "critical" | "warning" | "info";
|
|
2
|
+
|
|
3
|
+
export type ReviewCategory =
|
|
4
|
+
| "bug"
|
|
5
|
+
| "security"
|
|
6
|
+
| "performance"
|
|
7
|
+
| "style"
|
|
8
|
+
| "convention";
|
|
9
|
+
|
|
10
|
+
export type LLMProvider = "openai" | "anthropic" | "ollama";
|
|
11
|
+
|
|
12
|
+
export type ReviewType = "approve" | "request-changes" | "comment";
|
|
13
|
+
|
|
14
|
+
export interface ReviewComment {
|
|
15
|
+
path: string;
|
|
16
|
+
line: number;
|
|
17
|
+
side: "LEFT" | "RIGHT";
|
|
18
|
+
body: string;
|
|
19
|
+
severity: Severity;
|
|
20
|
+
category: ReviewCategory;
|
|
21
|
+
startLine?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface FileDiff {
|
|
25
|
+
filename: string;
|
|
26
|
+
patch: string;
|
|
27
|
+
additions: number;
|
|
28
|
+
deletions: number;
|
|
29
|
+
changeType: string;
|
|
30
|
+
content?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ReviewResult {
|
|
34
|
+
comments: ReviewComment[];
|
|
35
|
+
score: number;
|
|
36
|
+
summary: string;
|
|
37
|
+
breakdown: Record<ReviewCategory, number>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RepoConvention {
|
|
41
|
+
language: string;
|
|
42
|
+
patterns: string[];
|
|
43
|
+
namingStyle: string;
|
|
44
|
+
examples: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ReviewAgentConfig {
|
|
48
|
+
llm: {
|
|
49
|
+
provider: LLMProvider;
|
|
50
|
+
apiKey: string;
|
|
51
|
+
model: string;
|
|
52
|
+
baseUrl: string;
|
|
53
|
+
};
|
|
54
|
+
review: {
|
|
55
|
+
severity: Severity;
|
|
56
|
+
maxComments: number;
|
|
57
|
+
reviewType: ReviewType;
|
|
58
|
+
languageHints: string[];
|
|
59
|
+
learnConventions: boolean;
|
|
60
|
+
};
|
|
61
|
+
ignore: {
|
|
62
|
+
paths: string[];
|
|
63
|
+
extensions: string[];
|
|
64
|
+
};
|
|
65
|
+
rules: CustomRule[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface CustomRule {
|
|
69
|
+
name: string;
|
|
70
|
+
pattern: string;
|
|
71
|
+
message: string;
|
|
72
|
+
severity: Severity;
|
|
73
|
+
category: ReviewCategory;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ActionInputs {
|
|
77
|
+
githubToken: string;
|
|
78
|
+
llmProvider: LLMProvider;
|
|
79
|
+
llmApiKey: string;
|
|
80
|
+
llmModel: string;
|
|
81
|
+
llmBaseUrl: string;
|
|
82
|
+
configPath: string;
|
|
83
|
+
severity: Severity;
|
|
84
|
+
maxComments: number;
|
|
85
|
+
reviewType: ReviewType;
|
|
86
|
+
languageHints: string[];
|
|
87
|
+
learnConventions: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface GitHubPRContext {
|
|
91
|
+
owner: string;
|
|
92
|
+
repo: string;
|
|
93
|
+
pullNumber: number;
|
|
94
|
+
commitId: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface LLMReviewRequest {
|
|
98
|
+
file: FileDiff;
|
|
99
|
+
conventions: RepoConvention[];
|
|
100
|
+
config: ReviewAgentConfig;
|
|
101
|
+
existingCode?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface LLMReviewResponse {
|
|
105
|
+
comments: Array<{
|
|
106
|
+
line: number;
|
|
107
|
+
endLine?: number;
|
|
108
|
+
severity: Severity;
|
|
109
|
+
category: ReviewCategory;
|
|
110
|
+
message: string;
|
|
111
|
+
}>;
|
|
112
|
+
score: number;
|
|
113
|
+
summary: string;
|
|
114
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import * as minimatch from "minimatch";
|
|
2
|
+
import { FileDiff, ReviewAgentConfig } from "../types";
|
|
3
|
+
|
|
4
|
+
interface ParsedHunk {
|
|
5
|
+
oldStart: number;
|
|
6
|
+
oldLines: number;
|
|
7
|
+
newStart: number;
|
|
8
|
+
newLines: number;
|
|
9
|
+
lines: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function parsePatch(patch: string): ParsedHunk[] {
|
|
13
|
+
if (!patch) return [];
|
|
14
|
+
|
|
15
|
+
const hunks: ParsedHunk[] = [];
|
|
16
|
+
const lines = patch.split("\n");
|
|
17
|
+
let currentHunk: ParsedHunk | null = null;
|
|
18
|
+
|
|
19
|
+
for (const line of lines) {
|
|
20
|
+
const hunkMatch = line.match(
|
|
21
|
+
/^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/
|
|
22
|
+
);
|
|
23
|
+
if (hunkMatch) {
|
|
24
|
+
if (currentHunk) {
|
|
25
|
+
hunks.push(currentHunk);
|
|
26
|
+
}
|
|
27
|
+
currentHunk = {
|
|
28
|
+
oldStart: parseInt(hunkMatch[1], 10),
|
|
29
|
+
oldLines: parseInt(hunkMatch[2] || "1", 10),
|
|
30
|
+
newStart: parseInt(hunkMatch[3], 10),
|
|
31
|
+
newLines: parseInt(hunkMatch[4] || "1", 10),
|
|
32
|
+
lines: [],
|
|
33
|
+
};
|
|
34
|
+
} else if (currentHunk) {
|
|
35
|
+
currentHunk.lines.push(line);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (currentHunk) {
|
|
40
|
+
hunks.push(currentHunk);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return hunks;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getChangedLines(patch: string): Map<number, string> {
|
|
47
|
+
const changedLines = new Map<number, string>();
|
|
48
|
+
const hunks = parsePatch(patch);
|
|
49
|
+
|
|
50
|
+
for (const hunk of hunks) {
|
|
51
|
+
let currentLine = hunk.newStart;
|
|
52
|
+
|
|
53
|
+
for (const line of hunk.lines) {
|
|
54
|
+
if (line.startsWith("+")) {
|
|
55
|
+
changedLines.set(currentLine, line.substring(1));
|
|
56
|
+
currentLine++;
|
|
57
|
+
} else if (line.startsWith("-")) {
|
|
58
|
+
// Removed line - do not advance new line counter
|
|
59
|
+
} else {
|
|
60
|
+
currentLine++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return changedLines;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function shouldIgnoreFile(
|
|
69
|
+
filename: string,
|
|
70
|
+
config: ReviewAgentConfig
|
|
71
|
+
): boolean {
|
|
72
|
+
const ext = filename.substring(filename.lastIndexOf("."));
|
|
73
|
+
if (config.ignore.extensions.includes(ext.toLowerCase())) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const pattern of config.ignore.paths) {
|
|
78
|
+
if (minimatch.minimatch(filename, pattern)) {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const generatedPatterns = [
|
|
84
|
+
/\.generated\./,
|
|
85
|
+
/\.min\./,
|
|
86
|
+
/\/__generated__\//,
|
|
87
|
+
/\.pb\.go$/,
|
|
88
|
+
/\.graphql\.generated\./,
|
|
89
|
+
];
|
|
90
|
+
for (const pattern of generatedPatterns) {
|
|
91
|
+
if (pattern.test(filename)) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function getFileLanguage(filename: string): string {
|
|
100
|
+
const ext = filename.substring(filename.lastIndexOf(".")).toLowerCase();
|
|
101
|
+
const langMap: Record<string, string> = {
|
|
102
|
+
".ts": "typescript",
|
|
103
|
+
".tsx": "typescript",
|
|
104
|
+
".js": "javascript",
|
|
105
|
+
".jsx": "javascript",
|
|
106
|
+
".py": "python",
|
|
107
|
+
".rb": "ruby",
|
|
108
|
+
".go": "go",
|
|
109
|
+
".rs": "rust",
|
|
110
|
+
".java": "java",
|
|
111
|
+
".kt": "kotlin",
|
|
112
|
+
".cs": "csharp",
|
|
113
|
+
".cpp": "cpp",
|
|
114
|
+
".c": "c",
|
|
115
|
+
".h": "c",
|
|
116
|
+
".hpp": "cpp",
|
|
117
|
+
".php": "php",
|
|
118
|
+
".swift": "swift",
|
|
119
|
+
".scala": "scala",
|
|
120
|
+
".sh": "bash",
|
|
121
|
+
".bash": "bash",
|
|
122
|
+
".zsh": "zsh",
|
|
123
|
+
".ps1": "powershell",
|
|
124
|
+
".sql": "sql",
|
|
125
|
+
".html": "html",
|
|
126
|
+
".css": "css",
|
|
127
|
+
".scss": "scss",
|
|
128
|
+
".less": "less",
|
|
129
|
+
".vue": "vue",
|
|
130
|
+
".svelte": "svelte",
|
|
131
|
+
".dart": "dart",
|
|
132
|
+
".lua": "lua",
|
|
133
|
+
".r": "r",
|
|
134
|
+
".R": "r",
|
|
135
|
+
".m": "objc",
|
|
136
|
+
".mm": "objc",
|
|
137
|
+
".ex": "elixir",
|
|
138
|
+
".exs": "elixir",
|
|
139
|
+
".erl": "erlang",
|
|
140
|
+
".hs": "haskell",
|
|
141
|
+
".ml": "ocaml",
|
|
142
|
+
".clj": "clojure",
|
|
143
|
+
".lisp": "lisp",
|
|
144
|
+
".tf": "terraform",
|
|
145
|
+
".yaml": "yaml",
|
|
146
|
+
".yml": "yaml",
|
|
147
|
+
".json": "json",
|
|
148
|
+
".xml": "xml",
|
|
149
|
+
".toml": "toml",
|
|
150
|
+
".dockerfile": "dockerfile",
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
if (filename.endsWith("Dockerfile")) return "dockerfile";
|
|
154
|
+
if (filename.endsWith("Makefile")) return "makefile";
|
|
155
|
+
|
|
156
|
+
return langMap[ext] || "unknown";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function formatDiffForReview(diff: FileDiff): string {
|
|
160
|
+
const lines: string[] = [];
|
|
161
|
+
const hunks = parsePatch(diff.patch);
|
|
162
|
+
|
|
163
|
+
lines.push(`File: ${diff.filename} (${diff.changeType}, +${diff.additions}/-${diff.deletions})`);
|
|
164
|
+
lines.push("---");
|
|
165
|
+
|
|
166
|
+
for (const hunk of hunks) {
|
|
167
|
+
lines.push(
|
|
168
|
+
`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`
|
|
169
|
+
);
|
|
170
|
+
for (const line of hunk.lines) {
|
|
171
|
+
lines.push(line);
|
|
172
|
+
}
|
|
173
|
+
lines.push("");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return lines.join("\n");
|
|
177
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export class RateLimiter {
|
|
2
|
+
private queue: Array<() => void> = [];
|
|
3
|
+
private activeCount = 0;
|
|
4
|
+
private lastCallTime = 0;
|
|
5
|
+
|
|
6
|
+
constructor(
|
|
7
|
+
private maxConcurrent: number = 3,
|
|
8
|
+
private minIntervalMs: number = 500
|
|
9
|
+
) {}
|
|
10
|
+
|
|
11
|
+
async acquire(): Promise<void> {
|
|
12
|
+
if (this.activeCount >= this.maxConcurrent) {
|
|
13
|
+
await new Promise<void>((resolve) => {
|
|
14
|
+
this.queue.push(resolve);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
this.activeCount++;
|
|
19
|
+
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
const elapsed = now - this.lastCallTime;
|
|
22
|
+
const remaining = this.minIntervalMs - elapsed;
|
|
23
|
+
|
|
24
|
+
if (remaining > 0) {
|
|
25
|
+
await new Promise<void>((resolve) => setTimeout(resolve, remaining));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
this.lastCallTime = Date.now();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
release(): void {
|
|
32
|
+
this.activeCount--;
|
|
33
|
+
const next = this.queue.shift();
|
|
34
|
+
if (next) {
|
|
35
|
+
next();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get pendingCount(): number {
|
|
40
|
+
return this.queue.length;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get activeRequests(): number {
|
|
44
|
+
return this.activeCount;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class RetryHandler {
|
|
49
|
+
static async withRetry<T>(
|
|
50
|
+
fn: () => Promise<T>,
|
|
51
|
+
maxRetries: number = 3,
|
|
52
|
+
baseDelayMs: number = 1000
|
|
53
|
+
): Promise<T> {
|
|
54
|
+
let lastError: Error | undefined;
|
|
55
|
+
|
|
56
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
57
|
+
try {
|
|
58
|
+
return await fn();
|
|
59
|
+
} catch (err) {
|
|
60
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
61
|
+
|
|
62
|
+
if (attempt < maxRetries) {
|
|
63
|
+
const jitter = Math.random() * 500;
|
|
64
|
+
const delay = baseDelayMs * Math.pow(2, attempt) + jitter;
|
|
65
|
+
await new Promise<void>((resolve) => setTimeout(resolve, delay));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
throw lastError;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
export function sanitizeForLog(input: string): string {
|
|
2
|
+
// Remove potential secrets: API keys, tokens, passwords
|
|
3
|
+
let sanitized = input;
|
|
4
|
+
|
|
5
|
+
const secretPatterns = [
|
|
6
|
+
/(?:api[_-]?key|apikey|token|secret|password|auth)["\s]*[:=]["\s]*[^\s"',;}\]]{8,}/gi,
|
|
7
|
+
/sk-[a-zA-Z0-9]{20,}/g,
|
|
8
|
+
/sk_live_[a-zA-Z0-9]{24,}/g,
|
|
9
|
+
/ghp_[a-zA-Z0-9]{36}/g,
|
|
10
|
+
/gho_[a-zA-Z0-9]{36}/g,
|
|
11
|
+
/ghu_[a-zA-Z0-9]{36}/g,
|
|
12
|
+
/ghs_[a-zA-Z0-9]{36}/g,
|
|
13
|
+
/github_pat_[a-zA-Z0-9_]{22,}/g,
|
|
14
|
+
/AKIA[0-9A-Z]{16}/g,
|
|
15
|
+
/AIza[0-9A-Za-z_-]{35}/g,
|
|
16
|
+
/eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*/g,
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
for (const pattern of secretPatterns) {
|
|
20
|
+
sanitized = sanitized.replace(pattern, "[REDACTED]");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return sanitized;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function truncateString(input: string, maxLength: number): string {
|
|
27
|
+
if (input.length <= maxLength) return input;
|
|
28
|
+
return input.substring(0, maxLength) + "... (truncated)";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function validateApiKey(apiKey: string, provider: string): void {
|
|
32
|
+
if (!apiKey && provider !== "ollama") {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`API key is required for provider "${provider}". Use the llm-api-key input or switch to ollama for local inference.`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (apiKey && apiKey.length < 8) {
|
|
39
|
+
throw new Error("API key appears to be too short. Please check your secret configuration.");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function formatCommentBody(
|
|
44
|
+
message: string,
|
|
45
|
+
severity: string,
|
|
46
|
+
category: string
|
|
47
|
+
): string {
|
|
48
|
+
const severityEmoji: Record<string, string> = {
|
|
49
|
+
critical: "🔴",
|
|
50
|
+
warning: "🟡",
|
|
51
|
+
info: "🔵",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const categoryLabel: Record<string, string> = {
|
|
55
|
+
bug: "Bug",
|
|
56
|
+
security: "Security",
|
|
57
|
+
performance: "Performance",
|
|
58
|
+
style: "Style",
|
|
59
|
+
convention: "Convention",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const emoji = severityEmoji[severity] || "⚪";
|
|
63
|
+
const label = categoryLabel[category] || category;
|
|
64
|
+
|
|
65
|
+
return `${emoji} **[${label}]** (${severity})\n\n${message}\n\n---\n*Powered by [ReviewAgent](https://github.com/reviewagent/review-agent)*`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function buildSummaryComment(
|
|
69
|
+
score: number,
|
|
70
|
+
breakdown: Record<string, number>,
|
|
71
|
+
summary: string,
|
|
72
|
+
filesReviewed: number,
|
|
73
|
+
commentsPosted: number
|
|
74
|
+
): string {
|
|
75
|
+
const scoreColor =
|
|
76
|
+
score >= 80 ? "🟢" : score >= 60 ? "🟡" : score >= 40 ? "🟠" : "🔴";
|
|
77
|
+
const scoreLabel =
|
|
78
|
+
score >= 80
|
|
79
|
+
? "Great"
|
|
80
|
+
: score >= 60
|
|
81
|
+
? "Good"
|
|
82
|
+
: score >= 40
|
|
83
|
+
? "Needs Work"
|
|
84
|
+
: "Poor";
|
|
85
|
+
|
|
86
|
+
const breakdownRows = Object.entries(breakdown)
|
|
87
|
+
.filter(([, count]) => count > 0)
|
|
88
|
+
.map(([category, count]) => {
|
|
89
|
+
const emoji: Record<string, string> = {
|
|
90
|
+
bug: "🐛",
|
|
91
|
+
security: "🔒",
|
|
92
|
+
performance: "⚡",
|
|
93
|
+
style: "🎨",
|
|
94
|
+
convention: "📐",
|
|
95
|
+
};
|
|
96
|
+
return `| ${emoji[category] || "•"} ${capitalize(category)} | ${count} |`;
|
|
97
|
+
})
|
|
98
|
+
.join("\n");
|
|
99
|
+
|
|
100
|
+
const breakdownTable = breakdownRows
|
|
101
|
+
? `| Category | Count |\n|----------|-------|\n${breakdownRows}\n`
|
|
102
|
+
: "No issues found. Clean code! \n";
|
|
103
|
+
|
|
104
|
+
return `## ${scoreColor} ReviewAgent Code Review Summary
|
|
105
|
+
|
|
106
|
+
**Score: ${score}/100** — ${scoreLabel}
|
|
107
|
+
|
|
108
|
+
${summary}
|
|
109
|
+
|
|
110
|
+
### Breakdown
|
|
111
|
+
|
|
112
|
+
${breakdownTable}
|
|
113
|
+
|
|
114
|
+
### Stats
|
|
115
|
+
- **Files reviewed:** ${filesReviewed}
|
|
116
|
+
- **Comments posted:** ${commentsPosted}
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
*ReviewAgent — AI-powered code review. Configure via \`.reviewagent.yml\`*`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function capitalize(s: string): string {
|
|
124
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
125
|
+
}
|