issue-ai-response.js 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. /**
  2. * 3-Step AI Response Script for GitHub Issues
  3. *
  4. * Protocol:
  5. * - Step 1: Request files (no classification)
  6. * - Step 2: Response OR more files (mutually exclusive)
  7. * - Step 3: Final response required
  8. */
  9. module.exports = async ({ github, context, core, fs, path }) => {
  10. const apiUrl = process.env.OPENAI_URL;
  11. const apiKey = process.env.OPENAI_KEY;
  12. if (!apiUrl || !apiKey) {
  13. core.setFailed('OPENAI_URL and OPENAI_KEY must be set');
  14. return;
  15. }
  16. // Read system prompt and extract directory structure from AGENTS.md
  17. let systemPrompt;
  18. try {
  19. systemPrompt = fs.readFileSync('.github/prompts/issue-assistant.md', 'utf8');
  20. const agentsMd = fs.readFileSync('AGENTS.md', 'utf8');
  21. const structureMatch = agentsMd.match(/#{2,6}\s+Directory Structure[\s\S]*?```(?:\w+)?\s*\n([\s\S]*?)\n```/i);
  22. if (!structureMatch) {
  23. core.setFailed('Failed to extract Directory Structure from AGENTS.md.');
  24. return;
  25. }
  26. systemPrompt = systemPrompt.replace('{{DirectoryStructure}}', structureMatch[1].trim());
  27. } catch (error) {
  28. core.setFailed('Failed to read required files: ' + error.message);
  29. return;
  30. }
  31. const MAX_FILES = 10;
  32. const MAX_FILE_SIZE = 50000;
  33. const RATE_LIMIT_DELAY_MS = parseInt(process.env.RATE_LIMIT_DELAY_MS || '31000', 10);
  34. // Helper functions
  35. function isBinaryContent(content) {
  36. return content.includes('\0');
  37. }
  38. async function callOpenAI(messages) {
  39. const response = await fetch(apiUrl, {
  40. method: 'POST',
  41. headers: { 'Content-Type': 'application/json', 'api-key': apiKey },
  42. body: JSON.stringify({ messages, response_format: { type: "json_object" } })
  43. });
  44. if (!response.ok) {
  45. const errorText = await response.text();
  46. console.error('API error:', response.status, errorText);
  47. throw new Error('API error: ' + response.status + ' ' + errorText);
  48. }
  49. const data = await response.json();
  50. if (!data.choices?.[0]?.message?.content) {
  51. console.error('Invalid API response:', JSON.stringify(data));
  52. throw new Error('Invalid API response');
  53. }
  54. const content = data.choices[0].message.content.trim();
  55. console.log('OpenAI response:', content);
  56. return content;
  57. }
  58. function readFileContent(filePath) {
  59. try {
  60. const repoRoot = process.cwd();
  61. const fullPath = path.resolve(repoRoot, filePath);
  62. if (path.relative(repoRoot, fullPath).startsWith('..')) {
  63. return '[Access denied: ' + filePath + ']';
  64. }
  65. if (!fs.existsSync(fullPath)) {
  66. return '[File not found: ' + filePath + ']';
  67. }
  68. const stats = fs.statSync(fullPath);
  69. if (stats.isDirectory()) {
  70. return '[' + filePath + ' is a directory]';
  71. }
  72. if (stats.size > MAX_FILE_SIZE) {
  73. const fd = fs.openSync(fullPath, 'r');
  74. const buffer = Buffer.alloc(MAX_FILE_SIZE);
  75. const bytesRead = fs.readSync(fd, buffer, 0, MAX_FILE_SIZE, 0);
  76. fs.closeSync(fd);
  77. const { StringDecoder } = require('string_decoder');
  78. const decoder = new StringDecoder('utf8');
  79. const content = decoder.write(buffer.slice(0, bytesRead)) + decoder.end();
  80. return isBinaryContent(content) ? '[Binary file]' : content + '\n[Truncated]';
  81. }
  82. const content = fs.readFileSync(fullPath, 'utf8');
  83. return isBinaryContent(content) ? '[Binary file]' : content;
  84. } catch (error) {
  85. return '[Error: ' + (error.message || 'unknown') + ']';
  86. }
  87. }
  88. function extractClassification(parsed, raw) {
  89. if (parsed?.classification) {
  90. const c = parsed.classification.toLowerCase();
  91. return c;
  92. }
  93. const match = raw.match(/classification\s*:\s*([a-zA-Z0-9_-]+)/i);
  94. if (match) {
  95. const c = match[1].toLowerCase();
  96. return c;
  97. }
  98. return null;
  99. }
  100. function buildFileContents(files) {
  101. let msg = '';
  102. for (const f of files.slice(0, MAX_FILES)) {
  103. msg += '### `' + f + '`\n\n```\n' + readFileContent(f) + '\n```\n\n';
  104. }
  105. return msg;
  106. }
  107. // Process AI response, return { classification, response, files }
  108. function processResponse(raw) {
  109. let parsed;
  110. try {
  111. const match = raw.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```\s*$/);
  112. parsed = JSON.parse(match ? match[1].trim() : raw);
  113. } catch (e) {
  114. return { classification: extractClassification(null, raw), response: raw, files: [] };
  115. }
  116. const response = parsed.response && typeof parsed.response === 'string' && parsed.response.trim()
  117. ? parsed.response : null;
  118. const files = (parsed.requested_files || []).slice(0, MAX_FILES);
  119. const classification = response ? extractClassification(parsed, raw) : null;
  120. return { classification, response, files };
  121. }
  122. async function delay() {
  123. console.log('Waiting ' + (RATE_LIMIT_DELAY_MS / 1000) + 's...');
  124. await new Promise(r => setTimeout(r, RATE_LIMIT_DELAY_MS));
  125. }
  126. try {
  127. const issue = context.payload.issue;
  128. const messages = [{ role: 'system', content: systemPrompt }];
  129. // ========== Query 1: Analyze issue, request files ==========
  130. messages.push({ role: 'user', content:
  131. 'Analyze this issue and request relevant files.\n\n## Issue Details\n\n' +
  132. '**Title:** ' + (issue.title || '').substring(0, 500) + '\n\n' +
  133. '**Author:** @' + issue.user.login + '\n\n' +
  134. '**Labels:** ' + (issue.labels.map(l => l.name).join(', ') || '(None)') + '\n\n' +
  135. '**Body:**\n' + (issue.body || '').substring(0, 10000)
  136. });
  137. console.log('Query 1: Analyzing issue...');
  138. const r1 = await callOpenAI(messages);
  139. let result = processResponse(r1);
  140. // ========== Query 2: Provide files, get response or more files ==========
  141. if (!result.response) {
  142. await delay();
  143. messages.push({ role: 'assistant', content: r1 });
  144. messages.push({ role: 'user', content:
  145. 'Here are the requested files. Provide your response OR request more files.\n\n' +
  146. buildFileContents(result.files)
  147. });
  148. console.log('Query 2: Processing files...');
  149. const r2 = await callOpenAI(messages);
  150. result = processResponse(r2);
  151. // ========== Query 3: Provide more files, must respond ==========
  152. if (!result.response) {
  153. await delay();
  154. messages.push({ role: 'assistant', content: r2 });
  155. messages.push({ role: 'user', content:
  156. 'Here are the additional files. You must provide your final response now.\n\n' +
  157. buildFileContents(result.files)
  158. });
  159. console.log('Query 3: Final response...');
  160. const r3 = await callOpenAI(messages);
  161. result = processResponse(r3);
  162. }
  163. }
  164. // Fallback if no response
  165. if (!result.response) {
  166. result.response = 'Unable to process this issue. Please provide more details.';
  167. }
  168. // Post comment
  169. await github.rest.issues.createComment({
  170. owner: context.repo.owner,
  171. repo: context.repo.repo,
  172. issue_number: issue.number,
  173. body: result.response
  174. });
  175. // Add label
  176. if (result.classification) {
  177. try {
  178. await github.rest.issues.addLabels({
  179. owner: context.repo.owner,
  180. repo: context.repo.repo,
  181. issue_number: issue.number,
  182. labels: [result.classification]
  183. });
  184. } catch (e) {
  185. console.log('Failed to add label: ' + e.message);
  186. }
  187. }
  188. console.log('Completed successfully');
  189. } catch (error) {
  190. core.setFailed('Error: ' + error.message);
  191. }
  192. };