diff --git a/.github/workflows/ai-triage.yml b/.github/workflows/ai-triage.yml new file mode 100644 index 000000000..8e2c32c19 --- /dev/null +++ b/.github/workflows/ai-triage.yml @@ -0,0 +1,208 @@ +name: AI triage (comment + optional PR) + +on: + issues: + types: [opened] + +permissions: + contents: write # needed if the PR job runs + pull-requests: write + issues: write + +jobs: + # 1) Always: post a first AI reply to new issues + ai_first_reply: + runs-on: ubuntu-latest + steps: + - name: Compose and post AI reply + uses: actions/github-script@v7 + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + with: + script: | + const issue = context.payload.issue; + const { owner, repo } = context.repo; + + // Pull a bit of repo context (README) to help the model + let readmeText = ''; + try { + const readme = await github.rest.repos.getReadme({ owner, repo }); + readmeText = Buffer.from(readme.data.content, 'base64').toString('utf8'); + // Keep the prompt lean + if (readmeText.length > 18000) readmeText = readmeText.slice(0, 18000) + "\n...[truncated]..."; + } catch (e) { + // repo may not have a README; ignore + } + + // Craft prompt for the OpenAI Responses API + const system = ` + You are an open-source triage assistant. + Goal: write a concise, actionable first reply for a new GitHub issue. + Style: factual, polite, link to existing docs or code where relevant in this repo, propose next steps. + If the issue lacks details, ask for the minimum set of specifics (versions, logs, repro steps). + Avoid making up repo facts; only use what’s in the issue and README excerpt. + `.trim(); + + const user = ` + Repository: ${owner}/${repo} + Issue #${issue.number}: ${issue.title} + + Issue body: + ${issue.body || '(no body provided)'} + + README excerpt (context): + ${readmeText || '(none)'} + `.trim(); + + // Call OpenAI Responses API (recommended by OpenAI docs) + // https://platform.openai.com/docs/api-reference + const res = await fetch('https://api.openai.com/v1/responses', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`, + }, + body: JSON.stringify({ + model: 'gpt-4o-mini', + input: [ + { role: 'system', content: system }, + { role: 'user', content: user } + ], + }), + }); + + if (!res.ok) { + const txt = await res.text(); + const fallback = `⚠️ AI reply failed (${res.status}). Raw error:\n\n${'```'}\n${txt}\n${'```'}`; + await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body: fallback }); + return; + } + + const data = await res.json(); + // Official SDKs expose "output_text"; on raw HTTP it may be present too per docs. + // Fallback to common shapes if needed. + const text = data.output_text + || data?.choices?.[0]?.message?.content + || 'Thanks for opening this issue — we will take a look!'; + + await github.rest.issues.createComment({ + owner, repo, issue_number: issue.number, body: text + }); + + # 2) Optional: create a PR when the issue is labeled auto-pr + ai_autofix_pr: + runs-on: ubuntu-latest + needs: ai_first_reply + if: contains(join(github.event.issue.labels.*.name, ','), 'auto-pr') + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Generate patch with OpenAI (unified diff) + id: genpatch + uses: actions/github-script@v7 + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + with: + result-encoding: string + script: | + const { owner, repo } = context.repo; + const issue = context.payload.issue; + + // Grab README to guide proposed changes a bit + let readmeText = ''; + try { + const readme = await github.rest.repos.getReadme({ owner, repo }); + readmeText = Buffer.from(readme.data.content, 'base64').toString('utf8'); + if (readmeText.length > 12000) readmeText = readmeText.slice(0, 12000) + "\n...[truncated]..."; + } catch {} + + // Ask for a STRICT unified diff with no code fences. + const system = ` + You are a code-change generator for Git patches. + Output ONLY a unified diff (git apply compatible), no backticks, no prose. + Keep changes minimal and safe. If unsure, return an empty diff. + `.trim(); + + const user = ` + Repository: ${owner}/${repo} + Issue #${issue.number}: ${issue.title} + + Issue body: + ${issue.body || '(no body)'} + + README excerpt: + ${readmeText || '(none)'} + + Task: + - Propose the smallest viable fix or improvement addressing the issue. + - Only modify files that almost certainly exist. + - If the request is a question, create either a docs tweak (README/CONTRIBUTING) or add comments. + - Output only a valid unified diff starting with "diff --git". + `.trim(); + + const res = await fetch('https://api.openai.com/v1/responses', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`, + }, + body: JSON.stringify({ + model: 'gpt-4o-mini', + input: [ + { role: 'system', content: system }, + { role: 'user', content: user } + ], + }), + }); + + if (!res.ok) { + const txt = await res.text(); + core.setFailed(`OpenAI error ${res.status}: ${txt}`); + } else { + const data = await res.json(); + const patch = (data.output_text || data?.choices?.[0]?.message?.content || '').trim(); + return patch; + } + - name: Apply patch (if any) + id: apply + shell: bash + run: | + set -euo pipefail + branch="ai/autofix-${{ github.event.issue.number }}" + patch="${{ steps.genpatch.outputs.result }}" + if [ -z "$patch" ]; then + echo "No patch produced; skipping PR." + echo "skip_pr=true" >> $GITHUB_OUTPUT + exit 0 + fi + + echo "Writing patch..." + cat > ai.patch <<'PATCH' +${{ steps.genpatch.outputs.result }} +PATCH + + echo "Applying patch..." + git config user.name "ai-triage-bot" + git config user.email "ai-triage-bot@users.noreply.github.com" + git checkout -b "$branch" + git apply --whitespace=fix ai.patch + git add -A + git commit -m "AI autofix for #${{ github.event.issue.number }}" + git push -u origin "$branch" + echo "skip_pr=false" >> $GITHUB_OUTPUT + - name: Open PR + if: steps.apply.outputs.skip_pr == 'false' + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const head = `ai/autofix-${{ github.event.issue.number }}`; + await github.rest.pulls.create({ + owner, repo, + head, + base: 'main', + title: `AI autofix for #${{ github.event.issue.number }}`, + body: `This PR was generated automatically from issue #${{ github.event.issue.number }}.\n\n> Label \`auto-pr\` triggered patch creation. Please review carefully.`, + maintainer_can_modify: true + });