| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213 |
- /**
- * 3-Step AI Response Script for GitHub Issues
- *
- * Protocol:
- * - Step 1: Request files (no classification)
- * - Step 2: Response OR more files (mutually exclusive)
- * - Step 3: Final response required
- */
- module.exports = async ({ github, context, core, fs, path }) => {
- const apiUrl = process.env.OPENAI_URL;
- const apiKey = process.env.OPENAI_KEY;
- if (!apiUrl || !apiKey) {
- core.setFailed('OPENAI_URL and OPENAI_KEY must be set');
- return;
- }
- // Read system prompt and extract directory structure from AGENTS.md
- let systemPrompt;
- try {
- systemPrompt = fs.readFileSync('.github/prompts/issue-assistant.md', 'utf8');
- const agentsMd = fs.readFileSync('AGENTS.md', 'utf8');
- const structureMatch = agentsMd.match(/#{2,6}\s+Directory Structure[\s\S]*?```(?:\w+)?\s*\n([\s\S]*?)\n```/i);
- if (!structureMatch) {
- core.setFailed('Failed to extract Directory Structure from AGENTS.md.');
- return;
- }
- systemPrompt = systemPrompt.replace('{{DirectoryStructure}}', structureMatch[1].trim());
- } catch (error) {
- core.setFailed('Failed to read required files: ' + error.message);
- return;
- }
- const MAX_FILES = 10;
- const MAX_FILE_SIZE = 50000;
- const RATE_LIMIT_DELAY_MS = parseInt(process.env.RATE_LIMIT_DELAY_MS || '31000', 10);
- // Helper functions
- function isBinaryContent(content) {
- return content.includes('\0');
- }
- async function callOpenAI(messages) {
- const response = await fetch(apiUrl, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', 'api-key': apiKey },
- body: JSON.stringify({ messages, response_format: { type: "json_object" } })
- });
- if (!response.ok) {
- const errorText = await response.text();
- console.error('API error:', response.status, errorText);
- throw new Error('API error: ' + response.status + ' ' + errorText);
- }
- const data = await response.json();
- if (!data.choices?.[0]?.message?.content) {
- console.error('Invalid API response:', JSON.stringify(data));
- throw new Error('Invalid API response');
- }
- const content = data.choices[0].message.content.trim();
- console.log('OpenAI response:', content);
- return content;
- }
- function readFileContent(filePath) {
- try {
- const repoRoot = process.cwd();
- const fullPath = path.resolve(repoRoot, filePath);
- if (path.relative(repoRoot, fullPath).startsWith('..')) {
- return '[Access denied: ' + filePath + ']';
- }
- if (!fs.existsSync(fullPath)) {
- return '[File not found: ' + filePath + ']';
- }
- const stats = fs.statSync(fullPath);
- if (stats.isDirectory()) {
- return '[' + filePath + ' is a directory]';
- }
- if (stats.size > MAX_FILE_SIZE) {
- const fd = fs.openSync(fullPath, 'r');
- const buffer = Buffer.alloc(MAX_FILE_SIZE);
- const bytesRead = fs.readSync(fd, buffer, 0, MAX_FILE_SIZE, 0);
- fs.closeSync(fd);
- const { StringDecoder } = require('string_decoder');
- const decoder = new StringDecoder('utf8');
- const content = decoder.write(buffer.slice(0, bytesRead)) + decoder.end();
- return isBinaryContent(content) ? '[Binary file]' : content + '\n[Truncated]';
- }
- const content = fs.readFileSync(fullPath, 'utf8');
- return isBinaryContent(content) ? '[Binary file]' : content;
- } catch (error) {
- return '[Error: ' + (error.message || 'unknown') + ']';
- }
- }
- function extractClassification(parsed, raw) {
- if (parsed?.classification) {
- const c = parsed.classification.toLowerCase();
- return c;
- }
- const match = raw.match(/classification\s*:\s*([a-zA-Z0-9_-]+)/i);
- if (match) {
- const c = match[1].toLowerCase();
- return c;
- }
- return null;
- }
- function buildFileContents(files) {
- let msg = '';
- for (const f of files.slice(0, MAX_FILES)) {
- msg += '### `' + f + '`\n\n```\n' + readFileContent(f) + '\n```\n\n';
- }
- return msg;
- }
- // Process AI response, return { classification, response, files }
- function processResponse(raw) {
- let parsed;
- try {
- const match = raw.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```\s*$/);
- parsed = JSON.parse(match ? match[1].trim() : raw);
- } catch (e) {
- return { classification: extractClassification(null, raw), response: raw, files: [] };
- }
-
- const response = parsed.response && typeof parsed.response === 'string' && parsed.response.trim()
- ? parsed.response : null;
- const files = (parsed.requested_files || []).slice(0, MAX_FILES);
- const classification = response ? extractClassification(parsed, raw) : null;
-
- return { classification, response, files };
- }
- async function delay() {
- console.log('Waiting ' + (RATE_LIMIT_DELAY_MS / 1000) + 's...');
- await new Promise(r => setTimeout(r, RATE_LIMIT_DELAY_MS));
- }
- try {
- const issue = context.payload.issue;
- const messages = [{ role: 'system', content: systemPrompt }];
- // ========== Query 1: Analyze issue, request files ==========
- messages.push({ role: 'user', content:
- 'Analyze this issue and request relevant files.\n\n## Issue Details\n\n' +
- '**Title:** ' + (issue.title || '').substring(0, 500) + '\n\n' +
- '**Author:** @' + issue.user.login + '\n\n' +
- '**Labels:** ' + (issue.labels.map(l => l.name).join(', ') || '(None)') + '\n\n' +
- '**Body:**\n' + (issue.body || '').substring(0, 10000)
- });
- console.log('Query 1: Analyzing issue...');
- const r1 = await callOpenAI(messages);
- let result = processResponse(r1);
- // ========== Query 2: Provide files, get response or more files ==========
- if (!result.response) {
- await delay();
- messages.push({ role: 'assistant', content: r1 });
- messages.push({ role: 'user', content:
- 'Here are the requested files. Provide your response OR request more files.\n\n' +
- buildFileContents(result.files)
- });
- console.log('Query 2: Processing files...');
- const r2 = await callOpenAI(messages);
- result = processResponse(r2);
- // ========== Query 3: Provide more files, must respond ==========
- if (!result.response) {
- await delay();
- messages.push({ role: 'assistant', content: r2 });
- messages.push({ role: 'user', content:
- 'Here are the additional files. You must provide your final response now.\n\n' +
- buildFileContents(result.files)
- });
- console.log('Query 3: Final response...');
- const r3 = await callOpenAI(messages);
- result = processResponse(r3);
- }
- }
- // Fallback if no response
- if (!result.response) {
- result.response = 'Unable to process this issue. Please provide more details.';
- }
- // Post comment
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- body: result.response
- });
- // Add label
- if (result.classification) {
- try {
- await github.rest.issues.addLabels({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- labels: [result.classification]
- });
- } catch (e) {
- console.log('Failed to add label: ' + e.message);
- }
- }
- console.log('Completed successfully');
- } catch (error) {
- core.setFailed('Error: ' + error.message);
- }
- };
|