@_mustachio/ai-review-agent 0.1.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/README.md +213 -0
- package/action.yml +57 -0
- package/bin/cli.js +161 -0
- package/dist/default.txt +41 -0
- package/dist/index.js +32559 -0
- package/dist/index.js.map +1 -0
- package/dist/licenses.txt +583 -0
- package/dist/sourcemap-register.js +1 -0
- package/package.json +43 -0
- package/prompts/default.txt +41 -0
- package/src/core/engine.js +422 -0
- package/src/core/prompt.js +22 -0
- package/src/core/review.js +130 -0
- package/src/index.js +67 -0
- package/src/platforms/bitbucket.js +198 -0
- package/src/platforms/github.js +120 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const { execSync } = require('child_process');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_REVIEW = {
|
|
7
|
+
approve: false,
|
|
8
|
+
summary: 'Failed to parse AI review output.',
|
|
9
|
+
issues: [],
|
|
10
|
+
recommendation: 'Review manually.',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Run OpenCode and return the parsed review JSON.
|
|
15
|
+
* Writes prompt to a temp file and pipes via stdin to avoid CLI arg size limits.
|
|
16
|
+
*/
|
|
17
|
+
function runReview(prompt, id, { log = console.log } = {}) {
|
|
18
|
+
const tmpFile = path.join(os.tmpdir(), `ai-review-prompt-${id}.txt`);
|
|
19
|
+
fs.writeFileSync(tmpFile, prompt, 'utf-8');
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const stdout = execSync(
|
|
23
|
+
`cat "${tmpFile}" | opencode run --format json --title "PR Review #${id}"`,
|
|
24
|
+
{ encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] }
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
return parseReviewOutput(stdout, { log });
|
|
28
|
+
} finally {
|
|
29
|
+
try { fs.unlinkSync(tmpFile); } catch {}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse OpenCode JSON output to extract the review object.
|
|
35
|
+
*/
|
|
36
|
+
function parseReviewOutput(output, { log = console.log } = {}) {
|
|
37
|
+
const lines = output.split('\n').filter(Boolean);
|
|
38
|
+
const textParts = [];
|
|
39
|
+
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(line);
|
|
43
|
+
if (parsed.type === 'text' && parsed.part?.text) {
|
|
44
|
+
textParts.push(parsed.part.text);
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// Not valid JSON line, skip
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const text of textParts) {
|
|
52
|
+
const review = tryParseReview(text);
|
|
53
|
+
if (review) return review;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (textParts.length > 1) {
|
|
57
|
+
const combined = textParts.join('');
|
|
58
|
+
const review = tryParseReview(combined);
|
|
59
|
+
if (review) return review;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
log('Warning: Could not extract review JSON from OpenCode output');
|
|
63
|
+
log(`Raw text parts: ${JSON.stringify(textParts)}`);
|
|
64
|
+
return DEFAULT_REVIEW;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function tryParseReview(text) {
|
|
68
|
+
const trimmed = text.trim();
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const obj = JSON.parse(trimmed);
|
|
72
|
+
if (isValidReview(obj)) return obj;
|
|
73
|
+
} catch {}
|
|
74
|
+
|
|
75
|
+
const start = trimmed.indexOf('{');
|
|
76
|
+
if (start === -1) return null;
|
|
77
|
+
|
|
78
|
+
const jsonStr = trimmed.slice(start);
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const obj = JSON.parse(jsonStr);
|
|
82
|
+
if (isValidReview(obj)) return obj;
|
|
83
|
+
} catch {}
|
|
84
|
+
|
|
85
|
+
const repaired = repairTruncatedJson(jsonStr);
|
|
86
|
+
if (repaired) {
|
|
87
|
+
try {
|
|
88
|
+
const obj = JSON.parse(repaired);
|
|
89
|
+
if (isValidReview(obj)) return obj;
|
|
90
|
+
} catch {}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function repairTruncatedJson(json) {
|
|
97
|
+
let inString = false;
|
|
98
|
+
let escaped = false;
|
|
99
|
+
const stack = [];
|
|
100
|
+
|
|
101
|
+
for (let i = 0; i < json.length; i++) {
|
|
102
|
+
const ch = json[i];
|
|
103
|
+
if (escaped) { escaped = false; continue; }
|
|
104
|
+
if (ch === '\\' && inString) { escaped = true; continue; }
|
|
105
|
+
if (ch === '"') { inString = !inString; continue; }
|
|
106
|
+
if (inString) continue;
|
|
107
|
+
if (ch === '{') stack.push('}');
|
|
108
|
+
else if (ch === '[') stack.push(']');
|
|
109
|
+
else if (ch === '}' || ch === ']') stack.pop();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (stack.length === 0) return null;
|
|
113
|
+
|
|
114
|
+
let repaired = json;
|
|
115
|
+
if (inString) repaired += '"';
|
|
116
|
+
while (stack.length > 0) repaired += stack.pop();
|
|
117
|
+
|
|
118
|
+
return repaired;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isValidReview(obj) {
|
|
122
|
+
return (
|
|
123
|
+
obj &&
|
|
124
|
+
typeof obj === 'object' &&
|
|
125
|
+
typeof obj.approve === 'boolean' &&
|
|
126
|
+
typeof obj.summary === 'string'
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { runReview, parseReviewOutput };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Actions entry point.
|
|
3
|
+
* Thin wrapper that maps action.yml inputs to the CLI engine.
|
|
4
|
+
*/
|
|
5
|
+
const core = require('@actions/core');
|
|
6
|
+
const { runFullReview, shouldFailForThreshold, countBySeverity } = require('./core/engine');
|
|
7
|
+
const githubPlatform = require('./platforms/github');
|
|
8
|
+
|
|
9
|
+
async function run() {
|
|
10
|
+
try {
|
|
11
|
+
const apiKey = core.getInput('api-key');
|
|
12
|
+
if (apiKey) {
|
|
13
|
+
core.setSecret(apiKey);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const prMeta = await githubPlatform.getPrMetadata();
|
|
17
|
+
|
|
18
|
+
const review = await runFullReview({
|
|
19
|
+
baseBranch: prMeta.baseBranch,
|
|
20
|
+
prTitle: prMeta.prTitle,
|
|
21
|
+
prAuthor: prMeta.prAuthor,
|
|
22
|
+
prBody: prMeta.prBody,
|
|
23
|
+
prNumber: prMeta.prNumber,
|
|
24
|
+
promptPath: core.getInput('prompt') || undefined,
|
|
25
|
+
rulesPath: core.getInput('rules') || undefined,
|
|
26
|
+
excludePatterns: core.getInput('exclude-patterns'),
|
|
27
|
+
maxDiffSize: parseInt(core.getInput('max-diff-size') || '100000', 10),
|
|
28
|
+
opencodeVersion: core.getInput('opencode-version') || undefined,
|
|
29
|
+
opencodeConfig: core.getInput('opencode-config') || undefined,
|
|
30
|
+
apiKey: apiKey || undefined,
|
|
31
|
+
log: core.info,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Set outputs
|
|
35
|
+
core.setOutput('approved', String(review.approve));
|
|
36
|
+
core.setOutput('summary', review.summary);
|
|
37
|
+
core.setOutput('issues-count', String(review.issues.length));
|
|
38
|
+
core.setOutput('blocking-count', String(countBySeverity(review.issues, 'blocking')));
|
|
39
|
+
|
|
40
|
+
// Post review
|
|
41
|
+
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
42
|
+
if (!token) {
|
|
43
|
+
core.setFailed('GITHUB_TOKEN or GH_TOKEN is required to post reviews.');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
await githubPlatform.postReview(review, {
|
|
48
|
+
prNumber: prMeta.prNumber,
|
|
49
|
+
token,
|
|
50
|
+
postReview: core.getInput('post-review') !== 'false',
|
|
51
|
+
log: core.info,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Threshold check
|
|
55
|
+
const threshold = core.getInput('severity-threshold') || 'blocking';
|
|
56
|
+
const fail = shouldFailForThreshold(review, threshold);
|
|
57
|
+
if (fail) {
|
|
58
|
+
core.setFailed(fail);
|
|
59
|
+
} else {
|
|
60
|
+
core.info('AI review completed successfully.');
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
core.setFailed(`AI review failed: ${error.message}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
run();
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
const { formatComment, buildInlineComments } = require('../core/engine');
|
|
2
|
+
|
|
3
|
+
const BB_API = 'https://api.bitbucket.org/2.0';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Detect Bitbucket Pipelines environment.
|
|
7
|
+
*/
|
|
8
|
+
function detect() {
|
|
9
|
+
return !!process.env.BITBUCKET_BUILD_NUMBER;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get PR metadata. Bitbucket Pipelines only provides PR ID, branch, commit,
|
|
14
|
+
* workspace, and repo slug as env vars. Title/author/description must be
|
|
15
|
+
* fetched from the API.
|
|
16
|
+
*/
|
|
17
|
+
async function getPrMetadata(token) {
|
|
18
|
+
const prId = process.env.BITBUCKET_PR_ID;
|
|
19
|
+
if (!prId) throw new Error('BITBUCKET_PR_ID not set. Is this running on a PR pipeline?');
|
|
20
|
+
|
|
21
|
+
const workspace = process.env.BITBUCKET_WORKSPACE;
|
|
22
|
+
const repoSlug = process.env.BITBUCKET_REPO_SLUG;
|
|
23
|
+
|
|
24
|
+
if (!workspace || !repoSlug) {
|
|
25
|
+
throw new Error('BITBUCKET_WORKSPACE and BITBUCKET_REPO_SLUG must be set.');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Fetch PR details from API since env vars don't include title/author/description
|
|
29
|
+
const prData = await bbFetch(
|
|
30
|
+
`${BB_API}/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`,
|
|
31
|
+
{ headers: authHeaders(token) }
|
|
32
|
+
);
|
|
33
|
+
const pr = JSON.parse(prData);
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
prNumber: parseInt(prId, 10),
|
|
37
|
+
prTitle: pr.title || '',
|
|
38
|
+
prAuthor: pr.author?.display_name || pr.author?.nickname || '',
|
|
39
|
+
prBody: pr.description || '',
|
|
40
|
+
baseBranch: pr.destination?.branch?.name || 'main',
|
|
41
|
+
commitHash: process.env.BITBUCKET_COMMIT,
|
|
42
|
+
workspace,
|
|
43
|
+
repoSlug,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Post review results to Bitbucket PR.
|
|
49
|
+
*
|
|
50
|
+
* Bitbucket doesn't have GitHub's "request changes" concept.
|
|
51
|
+
* Instead we use:
|
|
52
|
+
* - Inline comments for specific issues
|
|
53
|
+
* - Summary comment for the overview
|
|
54
|
+
* - Build status (SUCCESSFUL/FAILED) to block/allow merge
|
|
55
|
+
* - Approve/unapprove as a secondary signal
|
|
56
|
+
*/
|
|
57
|
+
async function postReview(review, { prNumber, token, postReview: shouldPost = true, log = console.log }) {
|
|
58
|
+
const workspace = process.env.BITBUCKET_WORKSPACE;
|
|
59
|
+
const repoSlug = process.env.BITBUCKET_REPO_SLUG;
|
|
60
|
+
const commitHash = process.env.BITBUCKET_COMMIT;
|
|
61
|
+
|
|
62
|
+
if (!workspace || !repoSlug) {
|
|
63
|
+
throw new Error('BITBUCKET_WORKSPACE and BITBUCKET_REPO_SLUG must be set.');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const baseUrl = `${BB_API}/repositories/${workspace}/${repoSlug}/pullrequests/${prNumber}`;
|
|
67
|
+
const headers = authHeaders(token);
|
|
68
|
+
|
|
69
|
+
// Clean up previous AI review comments
|
|
70
|
+
log('Cleaning up previous AI review comments...');
|
|
71
|
+
await deleteStaleComments(baseUrl, headers, log);
|
|
72
|
+
|
|
73
|
+
// Post inline comments
|
|
74
|
+
const inlineComments = buildInlineComments(review.issues);
|
|
75
|
+
let inlinePosted = 0;
|
|
76
|
+
|
|
77
|
+
for (const comment of inlineComments) {
|
|
78
|
+
try {
|
|
79
|
+
await bbFetch(`${baseUrl}/comments`, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
82
|
+
body: JSON.stringify({
|
|
83
|
+
content: { raw: comment.body, markup: 'markdown' },
|
|
84
|
+
inline: {
|
|
85
|
+
path: comment.path,
|
|
86
|
+
to: comment.line,
|
|
87
|
+
...(comment.start_line ? { from: comment.start_line } : {}),
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
inlinePosted++;
|
|
92
|
+
} catch (err) {
|
|
93
|
+
log(`Warning: Failed to post inline comment on ${comment.path}:${comment.line}: ${err.message}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
log(`Posted ${inlinePosted} inline comment(s).`);
|
|
98
|
+
|
|
99
|
+
// Post summary comment
|
|
100
|
+
const summaryBody = formatComment(review);
|
|
101
|
+
await bbFetch(`${baseUrl}/comments`, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
104
|
+
body: JSON.stringify({
|
|
105
|
+
content: { raw: summaryBody, markup: 'markdown' },
|
|
106
|
+
}),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (!shouldPost) return;
|
|
110
|
+
|
|
111
|
+
// Set build status to block/allow merge (works with branch restrictions on Premium)
|
|
112
|
+
if (commitHash) {
|
|
113
|
+
const statusUrl = `${BB_API}/repositories/${workspace}/${repoSlug}/commit/${commitHash}/statuses/build`;
|
|
114
|
+
try {
|
|
115
|
+
await bbFetch(statusUrl, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
118
|
+
body: JSON.stringify({
|
|
119
|
+
state: review.approve ? 'SUCCESSFUL' : 'FAILED',
|
|
120
|
+
key: 'ai-code-review',
|
|
121
|
+
name: 'AI Code Review',
|
|
122
|
+
description: review.approve
|
|
123
|
+
? 'AI review passed — no blocking issues found.'
|
|
124
|
+
: `AI review found issues: ${review.summary}`.slice(0, 255),
|
|
125
|
+
url: `https://bitbucket.org/${workspace}/${repoSlug}/pull-requests/${prNumber}`,
|
|
126
|
+
}),
|
|
127
|
+
});
|
|
128
|
+
log(`Set build status: ${review.approve ? 'SUCCESSFUL' : 'FAILED'}`);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
log(`Warning: Could not set build status: ${err.message}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Approve/unapprove as secondary signal
|
|
135
|
+
if (review.approve) {
|
|
136
|
+
try {
|
|
137
|
+
await bbFetch(`${baseUrl}/approve`, {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers,
|
|
140
|
+
});
|
|
141
|
+
log('Approved PR.');
|
|
142
|
+
} catch (err) {
|
|
143
|
+
log(`Warning: Could not approve PR: ${err.message}`);
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
try {
|
|
147
|
+
await bbFetch(`${baseUrl}/approve`, {
|
|
148
|
+
method: 'DELETE',
|
|
149
|
+
headers,
|
|
150
|
+
});
|
|
151
|
+
} catch {
|
|
152
|
+
// May not have been approved previously — that's fine
|
|
153
|
+
}
|
|
154
|
+
log('PR not approved — issues found.');
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Delete previous AI review comments to prevent accumulation.
|
|
160
|
+
* Identifies bot comments by checking for our signature text.
|
|
161
|
+
*/
|
|
162
|
+
async function deleteStaleComments(baseUrl, headers, log) {
|
|
163
|
+
try {
|
|
164
|
+
const response = await bbFetch(`${baseUrl}/comments?pagelen=100`, { headers });
|
|
165
|
+
const data = JSON.parse(response);
|
|
166
|
+
|
|
167
|
+
for (const comment of (data.values || [])) {
|
|
168
|
+
if (comment.content?.raw?.includes('Generated by AI Code Review')) {
|
|
169
|
+
try {
|
|
170
|
+
await bbFetch(`${baseUrl}/comments/${comment.id}`, {
|
|
171
|
+
method: 'DELETE',
|
|
172
|
+
headers,
|
|
173
|
+
});
|
|
174
|
+
log(`Deleted stale comment #${comment.id}.`);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
log(`Warning: Could not delete comment #${comment.id}: ${err.message}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
} catch (err) {
|
|
181
|
+
log(`Warning: Could not list comments for cleanup: ${err.message}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function authHeaders(token) {
|
|
186
|
+
return { 'Authorization': `Bearer ${token}` };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function bbFetch(url, opts = {}) {
|
|
190
|
+
const response = await fetch(url, opts);
|
|
191
|
+
if (!response.ok) {
|
|
192
|
+
const body = await response.text();
|
|
193
|
+
throw new Error(`Bitbucket API ${response.status}: ${body}`);
|
|
194
|
+
}
|
|
195
|
+
return response.text();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = { detect, getPrMetadata, postReview };
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
const { formatComment, formatInlineIssuesAsList, buildInlineComments } = require('../core/engine');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Detect GitHub environment and extract PR metadata.
|
|
5
|
+
*/
|
|
6
|
+
function detect() {
|
|
7
|
+
return !!process.env.GITHUB_ACTIONS;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function getPrMetadata() {
|
|
11
|
+
// In GitHub Actions, the event payload is in a file
|
|
12
|
+
const eventPath = process.env.GITHUB_EVENT_PATH;
|
|
13
|
+
if (!eventPath) throw new Error('GITHUB_EVENT_PATH not set');
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const event = JSON.parse(fs.readFileSync(eventPath, 'utf-8'));
|
|
17
|
+
const pr = event.pull_request;
|
|
18
|
+
if (!pr) throw new Error('No pull_request in event payload');
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
prNumber: pr.number,
|
|
22
|
+
prTitle: pr.title,
|
|
23
|
+
prAuthor: pr.user.login,
|
|
24
|
+
prBody: pr.body || '',
|
|
25
|
+
baseBranch: pr.base.ref,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Post review results to GitHub PR.
|
|
31
|
+
*/
|
|
32
|
+
async function postReview(review, { prNumber, token, postReview: shouldPost = true, log = console.log }) {
|
|
33
|
+
const { Octokit } = require('@octokit/rest');
|
|
34
|
+
const octokit = new Octokit({ auth: token });
|
|
35
|
+
|
|
36
|
+
const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/');
|
|
37
|
+
|
|
38
|
+
// Dismiss stale reviews
|
|
39
|
+
log('Dismissing stale reviews...');
|
|
40
|
+
await dismissStaleReviews(octokit, owner, repo, prNumber, log);
|
|
41
|
+
|
|
42
|
+
if (!shouldPost) {
|
|
43
|
+
// Just post a comment
|
|
44
|
+
await octokit.issues.createComment({
|
|
45
|
+
owner,
|
|
46
|
+
repo,
|
|
47
|
+
issue_number: prNumber,
|
|
48
|
+
body: formatComment(review),
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Get head SHA
|
|
54
|
+
const { data: pr } = await octokit.pulls.get({ owner, repo, pull_number: prNumber });
|
|
55
|
+
const commitId = pr.head.sha;
|
|
56
|
+
|
|
57
|
+
const inlineComments = buildInlineComments(review.issues);
|
|
58
|
+
const summaryBody = formatComment(review);
|
|
59
|
+
const event = review.approve ? 'APPROVE' : 'REQUEST_CHANGES';
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
await octokit.pulls.createReview({
|
|
63
|
+
owner,
|
|
64
|
+
repo,
|
|
65
|
+
pull_number: prNumber,
|
|
66
|
+
commit_id: commitId,
|
|
67
|
+
event,
|
|
68
|
+
body: summaryBody,
|
|
69
|
+
comments: inlineComments,
|
|
70
|
+
});
|
|
71
|
+
log(`Posted review with ${inlineComments.length} inline comment(s).`);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
log(`Warning: Failed to post inline comments: ${error.message}`);
|
|
74
|
+
log('Falling back to summary-only review...');
|
|
75
|
+
|
|
76
|
+
await octokit.pulls.createReview({
|
|
77
|
+
owner,
|
|
78
|
+
repo,
|
|
79
|
+
pull_number: prNumber,
|
|
80
|
+
commit_id: commitId,
|
|
81
|
+
event,
|
|
82
|
+
body: summaryBody + formatInlineIssuesAsList(review.issues),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function dismissStaleReviews(octokit, owner, repo, prNumber, log) {
|
|
88
|
+
try {
|
|
89
|
+
const { data: reviews } = await octokit.pulls.listReviews({
|
|
90
|
+
owner,
|
|
91
|
+
repo,
|
|
92
|
+
pull_number: prNumber,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const botReviews = reviews.filter(
|
|
96
|
+
(r) =>
|
|
97
|
+
r.user?.login === 'github-actions[bot]' &&
|
|
98
|
+
(r.state === 'APPROVED' || r.state === 'CHANGES_REQUESTED')
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
for (const review of botReviews) {
|
|
102
|
+
try {
|
|
103
|
+
await octokit.pulls.dismissReview({
|
|
104
|
+
owner,
|
|
105
|
+
repo,
|
|
106
|
+
pull_number: prNumber,
|
|
107
|
+
review_id: review.id,
|
|
108
|
+
message: 'Superseded by new AI review.',
|
|
109
|
+
});
|
|
110
|
+
log(`Dismissed stale review #${review.id}.`);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
log(`Warning: Could not dismiss review #${review.id}: ${err.message}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} catch (err) {
|
|
116
|
+
log(`Warning: Could not list reviews for dismissal: ${err.message}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = { detect, getPrMetadata, postReview };
|