Skip to content

Commit 7849195

Browse files
committed
Sync auto-llm-issue-review.yml from .github repo
1 parent 1cd33d9 commit 7849195

File tree

1 file changed

+365
-0
lines changed

1 file changed

+365
-0
lines changed
Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
1+
name: "LLM Issue Review (Model Label Trigger)"
2+
3+
on:
4+
issues:
5+
types: [labeled]
6+
workflow_dispatch:
7+
inputs:
8+
issue_number:
9+
description: "Issue number"
10+
required: true
11+
type: number
12+
llm_provider:
13+
description: "LLM provider"
14+
required: false
15+
default: ""
16+
type: choice
17+
options:
18+
- ""
19+
- openai
20+
- gemini
21+
- anthropic
22+
llm_model:
23+
description: "Model name (provider-specific)"
24+
required: false
25+
default: ""
26+
type: string
27+
trigger_label:
28+
description: "Label to emulate (optional)"
29+
required: false
30+
default: ""
31+
type: string
32+
33+
permissions:
34+
contents: read
35+
issues: write
36+
37+
concurrency:
38+
group: llm-issue-review-${{ github.repository }}-${{ github.event.issue.number || github.event.inputs.issue_number }}
39+
cancel-in-progress: true
40+
41+
jobs:
42+
review:
43+
runs-on: ubuntu-latest
44+
steps:
45+
- name: Run LLM issue review and comment
46+
uses: actions/github-script@v7
47+
env:
48+
ISSUE_NUMBER: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.issue_number || github.event.issue.number }}
49+
TRIGGER_LABEL: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.trigger_label || github.event.label.name }}
50+
LLM_PROVIDER: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.llm_provider || '' }}
51+
LLM_MODEL: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.llm_model || '' }}
52+
OPENAI_BASE_URL: ${{ vars.OPENAI_BASE_URL || 'https://api.openai.com/v1' }}
53+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
54+
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
55+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
56+
with:
57+
github-token: ${{ secrets.GITHUB_TOKEN }}
58+
script: |
59+
const owner = context.repo.owner;
60+
const repo = context.repo.repo;
61+
62+
const issueNumber = Number(process.env.ISSUE_NUMBER || "0");
63+
if (!issueNumber) {
64+
core.setFailed("ISSUE_NUMBER is required (set inputs.issue_number for workflow_dispatch).");
65+
return;
66+
}
67+
68+
const triggerLabel = (process.env.TRIGGER_LABEL || "").trim();
69+
if (!triggerLabel) {
70+
core.info("No trigger label found; skipping.");
71+
return;
72+
}
73+
74+
function parseProviderModelFromLabel(label) {
75+
const raw = String(label || "").trim();
76+
if (!raw) return null;
77+
78+
// Explicit formats:
79+
// - llm:<provider>:<model>
80+
// - <provider>:<model> (provider in {openai, gemini, anthropic})
81+
let m = raw.match(/^llm:([^:]+):(.+)$/i);
82+
if (m) return { provider: m[1].toLowerCase(), model: m[2].trim(), raw };
83+
84+
m = raw.match(/^(openai|gemini|anthropic):(.+)$/i);
85+
if (m) return { provider: m[1].toLowerCase(), model: m[2].trim(), raw };
86+
87+
// Short formats based on common model prefixes
88+
if (/^gpt-/i.test(raw) || /^o\d/i.test(raw) || /^o1/i.test(raw)) {
89+
return { provider: "openai", model: raw, raw };
90+
}
91+
if (/^gemini/i.test(raw)) {
92+
const model = raw.toLowerCase() === "gemini3" ? "gemini-3" : raw;
93+
return { provider: "gemini", model, raw };
94+
}
95+
if (/^claude-/i.test(raw)) {
96+
return { provider: "anthropic", model: raw, raw };
97+
}
98+
99+
return null;
100+
}
101+
102+
const labelParsed = parseProviderModelFromLabel(triggerLabel);
103+
if (!labelParsed) {
104+
core.info(`Label '${triggerLabel}' does not look like a model label; skipping.`);
105+
return;
106+
}
107+
108+
const provider = (process.env.LLM_PROVIDER || labelParsed.provider || "").trim().toLowerCase();
109+
const model = (process.env.LLM_MODEL || labelParsed.model || "").trim();
110+
if (!provider || !model) {
111+
core.setFailed(`Unable to determine provider/model from label '${triggerLabel}'.`);
112+
return;
113+
}
114+
115+
const marker = `<!-- llm-issue-review:${triggerLabel} -->`;
116+
117+
// Skip if already commented for this label
118+
const { data: comments } = await github.rest.issues.listComments({
119+
owner,
120+
repo,
121+
issue_number: issueNumber,
122+
per_page: 100,
123+
});
124+
125+
if (comments.some(c => typeof c.body === "string" && c.body.includes(marker))) {
126+
core.info("A review comment for this label already exists; skipping.");
127+
return;
128+
}
129+
130+
const issueResp = await github.rest.issues.get({
131+
owner,
132+
repo,
133+
issue_number: issueNumber,
134+
});
135+
const issue = issueResp.data;
136+
137+
async function tryGetRepoFile(path) {
138+
try {
139+
const res = await github.rest.repos.getContent({
140+
owner,
141+
repo,
142+
path,
143+
});
144+
145+
if (!res?.data || Array.isArray(res.data) || res.data.type !== "file") return null;
146+
const b64 = res.data.content || "";
147+
const buf = Buffer.from(b64, "base64");
148+
const text = buf.toString("utf8");
149+
return text;
150+
} catch (e) {
151+
return null;
152+
}
153+
}
154+
155+
function extractLikelyPaths(text) {
156+
const body = String(text || "");
157+
const found = new Set();
158+
159+
// Backticked paths
160+
for (const m of body.matchAll(/`([^`]+)`/g)) {
161+
const p = (m[1] || "").trim();
162+
if (p.includes("/") && !p.startsWith("http")) found.add(p);
163+
}
164+
165+
// Loose paths (very heuristic)
166+
for (const m of body.matchAll(/(^|\\s)([\\w./-]+\\.[\\w]+)(\\s|$)/g)) {
167+
const p = (m[2] || "").trim();
168+
if (p.includes("/") && !p.startsWith("http")) found.add(p);
169+
}
170+
171+
return Array.from(found).slice(0, 5);
172+
}
173+
174+
const referencedPaths = extractLikelyPaths(issue.body || "");
175+
const fileSnippets = [];
176+
177+
for (const p of referencedPaths) {
178+
const content = await tryGetRepoFile(p);
179+
if (!content) continue;
180+
181+
const snippet = content.length > 6000 ? content.slice(0, 6000) + "\n...(truncated)..." : content;
182+
fileSnippets.push({ path: p, snippet });
183+
}
184+
185+
const automationTxt = await tryGetRepoFile("AUTOMATION.txt");
186+
187+
const systemPrompt = [
188+
"You are an expert software engineer.",
189+
"You are reviewing a GitHub issue and optionally some referenced code.",
190+
"Be specific, actionable, and concise.",
191+
"Prioritize correctness, security, maintainability, and tests.",
192+
"If information is missing, ask short clarifying questions.",
193+
].join(" ");
194+
195+
const promptParts = [];
196+
promptParts.push(`Repository: ${owner}/${repo}`);
197+
promptParts.push(`Issue #${issueNumber}: ${issue.title || ""}`);
198+
promptParts.push(`Trigger label: ${triggerLabel}`);
199+
promptParts.push("");
200+
promptParts.push("Issue body:");
201+
promptParts.push(issue.body || "(no body)");
202+
203+
if (automationTxt) {
204+
promptParts.push("");
205+
promptParts.push("AUTOMATION.txt (guidance):");
206+
promptParts.push(automationTxt.length > 4000 ? automationTxt.slice(0, 4000) + "\n...(truncated)..." : automationTxt);
207+
}
208+
209+
if (fileSnippets.length) {
210+
promptParts.push("");
211+
promptParts.push("Referenced file snippets:");
212+
for (const f of fileSnippets) {
213+
promptParts.push(`---\nFile: ${f.path}\n\n${f.snippet}`);
214+
}
215+
}
216+
217+
promptParts.push("");
218+
promptParts.push("Output format:");
219+
promptParts.push("- Short summary");
220+
promptParts.push("- Issues and risks (High/Medium/Low)");
221+
promptParts.push("- Proposed plan (next steps)");
222+
promptParts.push("- Suggested tests");
223+
224+
const userPrompt = promptParts.join("\n");
225+
226+
async function callOpenAI({ apiKey, baseUrl, model, messages }) {
227+
if (!apiKey) throw new Error("OPENAI_API_KEY is not set.");
228+
const url = `${baseUrl.replace(/\\/$/, "")}/chat/completions`;
229+
const payload = { model, messages };
230+
231+
const isGpt5ish = /gpt-?5/i.test(model) || /^o\\d/i.test(model) || /^o1/i.test(model);
232+
if (isGpt5ish) {
233+
payload.max_completion_tokens = 2048;
234+
} else {
235+
payload.max_tokens = 2048;
236+
payload.temperature = 0.2;
237+
}
238+
239+
const resp = await fetch(url, {
240+
method: "POST",
241+
headers: {
242+
"Authorization": `Bearer ${apiKey}`,
243+
"Content-Type": "application/json",
244+
},
245+
body: JSON.stringify(payload),
246+
});
247+
248+
if (!resp.ok) {
249+
const text = await resp.text();
250+
throw new Error(`OpenAI API error (${resp.status}): ${text}`);
251+
}
252+
253+
const data = await resp.json();
254+
const content = data?.choices?.[0]?.message?.content;
255+
if (!content) throw new Error("OpenAI API returned no content.");
256+
return content;
257+
}
258+
259+
async function callGemini({ apiKey, model, prompt }) {
260+
if (!apiKey) throw new Error("GEMINI_API_KEY is not set.");
261+
const geminiModel = model || "gemini-1.5-pro";
262+
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(geminiModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;
263+
const payload = {
264+
contents: [{ role: "user", parts: [{ text: prompt }] }],
265+
generationConfig: { temperature: 0.2, maxOutputTokens: 2048 },
266+
};
267+
268+
const resp = await fetch(url, {
269+
method: "POST",
270+
headers: { "Content-Type": "application/json" },
271+
body: JSON.stringify(payload),
272+
});
273+
274+
if (!resp.ok) {
275+
const text = await resp.text();
276+
throw new Error(`Gemini API error (${resp.status}): ${text}`);
277+
}
278+
279+
const data = await resp.json();
280+
const parts = data?.candidates?.[0]?.content?.parts || [];
281+
const text = parts.map(p => p.text || "").join("").trim();
282+
if (!text) throw new Error("Gemini API returned no content.");
283+
return text;
284+
}
285+
286+
async function callAnthropic({ apiKey, model, system, prompt }) {
287+
if (!apiKey) throw new Error("ANTHROPIC_API_KEY is not set.");
288+
const anthropicModel = model || "claude-3-5-sonnet-latest";
289+
const url = "https://api.anthropic.com/v1/messages";
290+
const payload = {
291+
model: anthropicModel,
292+
max_tokens: 2048,
293+
temperature: 0.2,
294+
system,
295+
messages: [{ role: "user", content: prompt }],
296+
};
297+
298+
const resp = await fetch(url, {
299+
method: "POST",
300+
headers: {
301+
"x-api-key": apiKey,
302+
"anthropic-version": "2023-06-01",
303+
"content-type": "application/json",
304+
},
305+
body: JSON.stringify(payload),
306+
});
307+
308+
if (!resp.ok) {
309+
const text = await resp.text();
310+
throw new Error(`Anthropic API error (${resp.status}): ${text}`);
311+
}
312+
313+
const data = await resp.json();
314+
const text = (data?.content || []).map(p => p.text || "").join("").trim();
315+
if (!text) throw new Error("Anthropic API returned no content.");
316+
return text;
317+
}
318+
319+
let reviewText = "";
320+
try {
321+
if (provider === "openai") {
322+
reviewText = await callOpenAI({
323+
apiKey: process.env.OPENAI_API_KEY,
324+
baseUrl: process.env.OPENAI_BASE_URL,
325+
model,
326+
messages: [
327+
{ role: "system", content: systemPrompt },
328+
{ role: "user", content: userPrompt },
329+
],
330+
});
331+
} else if (provider === "gemini") {
332+
reviewText = await callGemini({
333+
apiKey: process.env.GEMINI_API_KEY,
334+
model,
335+
prompt: `${systemPrompt}\n\n${userPrompt}`,
336+
});
337+
} else if (provider === "anthropic") {
338+
reviewText = await callAnthropic({
339+
apiKey: process.env.ANTHROPIC_API_KEY,
340+
model,
341+
system: systemPrompt,
342+
prompt: userPrompt,
343+
});
344+
} else {
345+
throw new Error(`Unsupported provider: ${provider}`);
346+
}
347+
} catch (e) {
348+
core.setFailed(e.message || String(e));
349+
return;
350+
}
351+
352+
const commentBody = [
353+
marker,
354+
`Provider: ${provider}`,
355+
`Model: ${model}`,
356+
"",
357+
reviewText,
358+
].join("\n");
359+
360+
await github.rest.issues.createComment({
361+
owner,
362+
repo,
363+
issue_number: issueNumber,
364+
body: commentBody,
365+
});

0 commit comments

Comments
 (0)