diff --git a/.github/workflows/on_issues_ai.yml b/.github/workflows/on_issues_ai.yml new file mode 100644 index 0000000000..f47d051e65 --- /dev/null +++ b/.github/workflows/on_issues_ai.yml @@ -0,0 +1,654 @@ +# yamllint disable rule:line-length +--- +name: AI issue triage and draft fix + +on: + issues: + types: [opened, reopened, labeled] + +concurrency: + group: ai-issue-${{ github.event.issue.number }} + cancel-in-progress: false + +env: + TRIAGE_MODEL: ${{ vars.OPENAI_TRIAGE_MODEL || 'gpt-5-mini' }} + MIN_CONFIDENCE: ${{ vars.AI_MIN_CONFIDENCE || '0.80' }} + +jobs: + detect_submitter: + if: >- + github.event.action != 'labeled' || + github.event.label.name == 'ai: fix-approved' + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + matched: ${{ steps.submitter.outputs.matched }} + addon: ${{ steps.submitter.outputs.addon }} + submitter: ${{ steps.submitter.outputs.submitter }} + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Detect mapped add-on submitter + id: submitter + env: + ISSUE_TITLE: ${{ github.event.issue.title }} + ISSUE_BODY: ${{ github.event.issue.body }} + run: bash .github/scripts/find_addon_submitter.sh + + triage: + if: >- + needs.detect_submitter.outputs.matched != 'true' && + ( + github.event.action == 'opened' || + github.event.action == 'reopened' || + ( + github.event.action == 'labeled' && + github.event.label.name == 'ai: fix-approved' + ) + ) + needs: detect_submitter + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + outputs: + addon: ${{ steps.result.outputs.addon }} + category: ${{ steps.result.outputs.category }} + confidence: ${{ steps.result.outputs.confidence }} + existing_addon: ${{ steps.result.outputs.existing_addon }} + risk: ${{ steps.result.outputs.risk }} + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Ensure AI labels exist + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + gh label create "ai: triaged" --repo "$REPO" --color "1d76db" --description "Issue classified by AI" --force + gh label create "ai: answered" --repo "$REPO" --color "0e8a16" --description "Question answered automatically" --force + gh label create "ai: needs-info" --repo "$REPO" --color "fbca04" --description "AI requested essential information" --force + gh label create "ai: fix-proposed" --repo "$REPO" --color "c5def5" --description "AI recommends repository analysis" --force + gh label create "ai: fix-approved" --repo "$REPO" --color "5319e7" --description "Maintainer approved an automated fix attempt" --force + gh label create "ai: fixing" --repo "$REPO" --color "0052cc" --description "Automated fix attempt is running" --force + gh label create "ai: pr-created" --repo "$REPO" --color "0e8a16" --description "Automated draft pull request created" --force + gh label create "ai: maintainer-review" --repo "$REPO" --color "d93f0b" --description "Maintainer review is required" --force + gh label create "ai: new-addon-request" --repo "$REPO" --color "ededed" --description "New add-on request; never implemented automatically" --force + gh label create "ai-generated" --repo "$REPO" --color "bfdadc" --description "Changes generated with AI assistance" --force + + - name: Triage issue with structured output + id: result + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + ISSUE_AUTHOR: ${{ github.event.issue.user.login }} + ISSUE_BODY: ${{ github.event.issue.body }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_TITLE: ${{ github.event.issue.title }} + run: | + set -euo pipefail + test -n "$OPENAI_API_KEY" + + issue_body="${ISSUE_BODY:0:20000}" + addon_catalog="$( + find . -mindepth 2 -maxdepth 2 -type f \ + \( -name config.yaml -o -name config.json \) -printf '%h\n' | + sed 's#^./##' | sort -u | + jq -Rsc 'split("\n") | map(select(length > 0))' + )" + + jq -n \ + --arg model "$TRIAGE_MODEL" \ + --arg instructions "$(cat .github/ai/triage-prompt.md)" \ + --arg author "$ISSUE_AUTHOR" \ + --arg body "$issue_body" \ + --arg number "$ISSUE_NUMBER" \ + --arg title "$ISSUE_TITLE" \ + --argjson addon_catalog "$addon_catalog" \ + --slurpfile schema .github/ai/triage-schema.json \ + '{ + model: $model, + store: false, + max_output_tokens: 1200, + reasoning: {effort: "low"}, + instructions: $instructions, + input: ( + "Repository issue #" + $number + "\n" + + "Author: " + $author + "\n" + + "Title: " + $title + "\n\n" + + "Existing add-on directories:\n" + + ($addon_catalog | tojson) + "\n\n" + + "Body:\n" + $body + ), + text: { + format: { + type: "json_schema", + name: "issue_triage", + strict: true, + schema: $schema[0] + } + } + }' > "$RUNNER_TEMP/openai-request.json" + + curl --fail-with-body --retry 3 --max-time 120 \ + -H "Authorization: Bearer $OPENAI_API_KEY" \ + -H "Content-Type: application/json" \ + https://api.openai.com/v1/responses \ + --data-binary "@$RUNNER_TEMP/openai-request.json" \ + > "$RUNNER_TEMP/openai-response.json" + + jq -r ' + [ + .output[]? | + select(.type == "message") | + .content[]? | + select(.type == "output_text") | + .text + ] | join("") + ' "$RUNNER_TEMP/openai-response.json" > "$RUNNER_TEMP/triage.json" + + jq -e ' + (.category | type == "string") and + (.confidence | type == "number") and + (.risk | type == "string") and + (.summary | type == "string") and + (.response | type == "string") and + (.missing_information | type == "array") + ' "$RUNNER_TEMP/triage.json" > /dev/null + + category="$(jq -r '.category' "$RUNNER_TEMP/triage.json")" + addon="$(jq -r '.addon // ""' "$RUNNER_TEMP/triage.json")" + existing_addon=false + if [[ -n "$addon" && "$addon" != */* && "$addon" != "." && "$addon" != ".." ]] && + [[ -f "$addon/config.yaml" || -f "$addon/config.json" ]]; then + existing_addon=true + fi + + echo "addon=$addon" >> "$GITHUB_OUTPUT" + echo "category=$category" >> "$GITHUB_OUTPUT" + echo "confidence=$(jq -r '.confidence' "$RUNNER_TEMP/triage.json")" >> "$GITHUB_OUTPUT" + echo "existing_addon=$existing_addon" >> "$GITHUB_OUTPUT" + echo "risk=$(jq -r '.risk' "$RUNNER_TEMP/triage.json")" >> "$GITHUB_OUTPUT" + + - name: Publish triage result + if: github.event.action == 'opened' || github.event.action == 'reopened' + env: + EXISTING_ADDON: ${{ steps.result.outputs.existing_addon }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_ACTION: ${{ github.event.action }} + ISSUE_AUTHOR: ${{ github.event.issue.user.login }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_UPDATED_AT: ${{ github.event.issue.updated_at }} + REPO: ${{ github.repository }} + REPOSITORY_OWNER: ${{ github.repository_owner }} + run: | + set -euo pipefail + + marker="" + existing="$( + gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json comments | + jq --arg marker "$marker" \ + '[.comments[] | select(.body | contains($marker))] | length' + )" + [[ "$existing" -eq 0 ]] || exit 0 + + category="$(jq -r '.category' "$RUNNER_TEMP/triage.json")" + confidence="$(jq -r '.confidence' "$RUNNER_TEMP/triage.json")" + risk="$(jq -r '.risk' "$RUNNER_TEMP/triage.json")" + summary="$(jq -r '.summary' "$RUNNER_TEMP/triage.json")" + response="$(jq -r '.response' "$RUNNER_TEMP/triage.json")" + + confident=false + if awk -v confidence="$confidence" -v minimum="$MIN_CONFIDENCE" \ + 'BEGIN { exit !(confidence >= minimum) }'; then + confident=true + fi + + labels=("ai: triaged") + heading="### AI triage" + body="$response" + + if [[ "$confident" != true ]]; then + labels+=("ai: maintainer-review") + body="**Low-confidence assessment (${confidence}):** ${summary} + + A maintainer should review this issue before any automated action." + else + case "$category" in + question) + if [[ "$(jq -r '.safe_to_answer_automatically' "$RUNNER_TEMP/triage.json")" == true ]]; then + labels+=("ai: answered") + else + labels+=("ai: maintainer-review") + fi + ;; + missing_information) + heading="### Additional information required" + labels+=("ai: needs-info") + missing="$( + jq -r ' + if (.missing_information | length) == 0 then + "" + else + "\n\n**Needed:**\n" + + (.missing_information | map("- " + .) | join("\n")) + end + ' "$RUNNER_TEMP/triage.json" + )" + body="${response}${missing}" + ;; + bug) + labels+=("bug" "ai: fix-proposed") + body="**Assessment:** ${summary} + + **Estimated risk:** ${risk} + + ${response}" + if [[ "$ISSUE_AUTHOR" == "$REPOSITORY_OWNER" ]]; then + labels+=("ai: fixing") + body="${body} + + Repository analysis and a draft fix attempt will start automatically because the issue was opened by the repository owner." + else + body="${body} + + A maintainer can approve repository analysis and an automated draft fix attempt by adding the \`ai: fix-approved\` label." + fi + ;; + improvement) + labels+=("enhancement") + if [[ "$EXISTING_ADDON" == true ]]; then + labels+=("ai: fix-proposed" "ai: fixing") + body="**Assessment:** ${summary} + + **Estimated risk:** ${risk} + + ${response} + + This targets an existing add-on, so repository analysis and a validated draft fix attempt will start automatically." + else + labels+=("ai: maintainer-review") + body="${response} + + The referenced add-on directory could not be verified, so no automated implementation will start." + fi + ;; + new_addon_request) + labels+=("enhancement" "ai: new-addon-request" "ai: maintainer-review") + body="${response} + + New add-on requests are never accepted or implemented automatically by this workflow. A maintainer must review the proposal manually." + ;; + unsupported | spam) + labels+=("ai: maintainer-review") + ;; + esac + fi + + body="${marker} + ${heading} + + ${body} + + Automated classification using \`${TRIAGE_MODEL}\`; confidence ${confidence}." + + for label in "${labels[@]}"; do + if ! gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-label "$label"; then + if [[ "$label" == "bug" || "$label" == "enhancement" ]]; then + echo "Optional repository label '$label' does not exist; continuing." >&2 + else + exit 1 + fi + fi + done + gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body "$body" + + codex_fix: + if: >- + always() && + needs.detect_submitter.result == 'success' && + needs.detect_submitter.outputs.matched != 'true' && + needs.triage.result == 'success' && + fromJSON(needs.triage.outputs.confidence) >= + fromJSON(vars.AI_MIN_CONFIDENCE || '0.80') && + ( + ( + needs.triage.outputs.category == 'improvement' && + needs.triage.outputs.existing_addon == 'true' + ) || + ( + needs.triage.outputs.category == 'bug' && + ( + ( + github.event.action == 'labeled' && + github.event.label.name == 'ai: fix-approved' + ) || + ( + (github.event.action == 'opened' || github.event.action == 'reopened') && + github.event.issue.user.login == github.repository_owner + ) + ) + ) + ) + needs: [detect_submitter, triage] + runs-on: ubuntu-latest + permissions: + contents: read + issues: read + pull-requests: read + outputs: + addon: ${{ steps.prepare.outputs.addon }} + branch: ${{ steps.prepare.outputs.branch }} + category: ${{ steps.prepare.outputs.category }} + existing_pr: ${{ steps.prepare.outputs.existing_pr }} + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Check for an existing pull request + id: prepare + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + REPO: ${{ github.repository }} + TRIAGE_ADDON: ${{ needs.triage.outputs.addon }} + TRIAGE_CATEGORY: ${{ needs.triage.outputs.category }} + run: | + set -euo pipefail + branch="ai/issue-${ISSUE_NUMBER}" + existing_pr="$( + gh pr list --repo "$REPO" --state open --head "$branch" \ + --json url --jq '.[0].url // ""' + )" + echo "addon=$TRIAGE_ADDON" >> "$GITHUB_OUTPUT" + echo "branch=$branch" >> "$GITHUB_OUTPUT" + echo "category=$TRIAGE_CATEGORY" >> "$GITHUB_OUTPUT" + echo "existing_pr=$existing_pr" >> "$GITHUB_OUTPUT" + + - name: Build isolated issue context + if: steps.prepare.outputs.existing_pr == '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + REPO: ${{ github.repository }} + TRIAGE_ADDON: ${{ needs.triage.outputs.addon }} + TRIAGE_CATEGORY: ${{ needs.triage.outputs.category }} + TRIAGE_RISK: ${{ needs.triage.outputs.risk }} + run: | + set -euo pipefail + gh issue view "$ISSUE_NUMBER" --repo "$REPO" \ + --json number,title,body,author,comments,labels,url | + jq \ + --arg addon "$TRIAGE_ADDON" \ + --arg category "$TRIAGE_CATEGORY" \ + --arg risk "$TRIAGE_RISK" \ + '. + {automation_triage: {addon: $addon, category: $category, risk: $risk}}' \ + > ai-issue-context.json + cp .github/ai/fix-prompt.md codex-prompt.md + + - name: Run Codex + id: codex + if: steps.prepare.outputs.existing_pr == '' + uses: openai/codex-action@v1 + with: + openai-api-key: ${{ secrets.OPENAI_API_KEY }} + prompt-file: codex-prompt.md + output-file: codex-result.md + sandbox: workspace-write + safety-strategy: drop-sudo + allow-users: "*" + model: ${{ vars.OPENAI_FIX_MODEL }} + effort: high + + - name: Package proposed patch + if: steps.prepare.outputs.existing_pr == '' + run: | + set -euo pipefail + test -f codex-result.md + cp codex-result.md "$RUNNER_TEMP/codex-result.md" + rm -f ai-issue-context.json codex-prompt.md codex-result.md + git add -A + git diff --cached --binary --full-index > "$RUNNER_TEMP/ai.patch" + git reset + cp "$RUNNER_TEMP/ai.patch" ai.patch + cp "$RUNNER_TEMP/codex-result.md" codex-result.md + + - name: Upload proposed patch + if: steps.prepare.outputs.existing_pr == '' + uses: actions/upload-artifact@v4 + with: + name: ai-issue-${{ github.event.issue.number }}-${{ github.run_id }} + path: | + ai.patch + codex-result.md + if-no-files-found: error + retention-days: 3 + + report_codex_failure: + if: always() && needs.codex_fix.result == 'failure' + needs: codex_fix + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Report failed automated analysis + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + REPO: ${{ github.repository }} + RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + for label in "ai: fix-approved" "ai: fix-proposed" "ai: fixing"; do + gh issue edit "$ISSUE_NUMBER" --repo "$REPO" \ + --remove-label "$label" || true + done + gh issue edit "$ISSUE_NUMBER" --repo "$REPO" \ + --add-label "ai: maintainer-review" + gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body \ + "### Automated analysis failed + + The automated repository analysis did not complete, so no pull request was created. A maintainer should review the issue and the [workflow run](${RUN_URL})." + + publish_fix: + if: >- + always() && + needs.codex_fix.result == 'success' && + needs.codex_fix.outputs.existing_pr == '' + needs: codex_fix + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Download proposed patch + uses: actions/download-artifact@v4 + with: + name: ai-issue-${{ github.event.issue.number }}-${{ github.run_id }} + + - name: Apply patch + run: | + set -euo pipefail + if [[ -s ai.patch ]]; then + git apply --index --3way ai.patch + fi + rm -f ai.patch codex-result.md + + - name: Restore Codex report + uses: actions/download-artifact@v4 + with: + name: ai-issue-${{ github.event.issue.number }}-${{ github.run_id }} + path: ${{ runner.temp }}/ai-result + + - name: Ensure publication labels exist + env: + GH_TOKEN: ${{ secrets.AI_PR_TOKEN || secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + gh label create "ai: maintainer-review" --repo "$REPO" --color "d93f0b" --description "Maintainer review is required" --force + gh label create "ai: pr-created" --repo "$REPO" --color "0e8a16" --description "Automated draft pull request created" --force + gh label create "ai-generated" --repo "$REPO" --color "bfdadc" --description "Changes generated with AI assistance" --force + + - name: Validate proposed patch + id: validation + env: + AI_EXPECTED_ADDON: ${{ needs.codex_fix.outputs.addon }} + AI_MAX_CHANGED_FILES: ${{ vars.AI_MAX_CHANGED_FILES || '25' }} + AI_MAX_CHANGED_LINES: ${{ vars.AI_MAX_CHANGED_LINES || '2000' }} + AI_REQUEST_CATEGORY: ${{ needs.codex_fix.outputs.category }} + run: | + set -o pipefail + bash .github/scripts/validate_ai_patch.sh origin/master 2>&1 | + tee "$RUNNER_TEMP/ai-validation.log" + + - name: Report validation failure + if: failure() && steps.validation.outcome == 'failure' + env: + GH_TOKEN: ${{ secrets.AI_PR_TOKEN || secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + REPO: ${{ github.repository }} + RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + report="$(cat "$RUNNER_TEMP/ai-result/codex-result.md" 2>/dev/null || true)" + validation="$(tail -n 80 "$RUNNER_TEMP/ai-validation.log" 2>/dev/null || true)" + body="### Automated fix blocked by validation + + ${report} + +
+ Validation output + + \`\`\`text + ${validation} + \`\`\` +
+ + [Open the workflow run](${RUN_URL})" + for label in "ai: fix-approved" "ai: fix-proposed" "ai: fixing"; do + gh issue edit "$ISSUE_NUMBER" --repo "$REPO" \ + --remove-label "$label" || true + done + gh issue edit "$ISSUE_NUMBER" --repo "$REPO" \ + --add-label "ai: maintainer-review" + gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body "$body" + + - name: Report no safe change + if: steps.validation.outputs.has_changes == 'false' + env: + GH_TOKEN: ${{ secrets.AI_PR_TOKEN || secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + report="$(cat "$RUNNER_TEMP/ai-result/codex-result.md")" + body="### Automated repository analysis + + ${report} + + No draft pull request was created because Codex produced no repository change." + for label in "ai: fix-approved" "ai: fix-proposed" "ai: fixing"; do + gh issue edit "$ISSUE_NUMBER" --repo "$REPO" \ + --remove-label "$label" || true + done + gh issue edit "$ISSUE_NUMBER" --repo "$REPO" \ + --add-label "ai: maintainer-review" + gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body "$body" + + - name: Commit and push validated patch + if: steps.validation.outputs.has_changes == 'true' + env: + BRANCH: ${{ needs.codex_fix.outputs.branch }} + GH_TOKEN: ${{ secrets.AI_PR_TOKEN || secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch origin "$BRANCH:refs/remotes/origin/$BRANCH" || true + git checkout -B "$BRANCH" + git add -A + git commit -m "fix: address issue #${ISSUE_NUMBER}" + gh auth setup-git + git push --force-with-lease --set-upstream origin "$BRANCH" + + - name: Create draft pull request + id: pr + if: steps.validation.outputs.has_changes == 'true' + env: + BRANCH: ${{ needs.codex_fix.outputs.branch }} + GH_TOKEN: ${{ secrets.AI_PR_TOKEN || secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + report="$(cat "$RUNNER_TEMP/ai-result/codex-result.md")" + cat > "$RUNNER_TEMP/pr-body.md" <> "$GITHUB_OUTPUT" + + pr_number="${pr_url##*/}" + gh pr edit "$pr_number" --repo "$REPO" --add-label "ai-generated" + + - name: Update issue with analysis and pull request + if: steps.validation.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ secrets.AI_PR_TOKEN || secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + PR_URL: ${{ steps.pr.outputs.url }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + report="$(cat "$RUNNER_TEMP/ai-result/codex-result.md")" + body="### Automated fix prepared + + ${report} + + **Draft pull request:** ${PR_URL} + + The pull request remains in draft pending human review and CI." + for label in "ai: fix-approved" "ai: fix-proposed" "ai: fixing"; do + gh issue edit "$ISSUE_NUMBER" --repo "$REPO" \ + --remove-label "$label" || true + done + gh issue edit "$ISSUE_NUMBER" --repo "$REPO" \ + --add-label "ai: pr-created" + gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body "$body"