diff --git a/.github/workflows/issue-deduplicator.yml b/.github/workflows/issue-deduplicator.yml new file mode 100644 index 00000000000..6961f739787 --- /dev/null +++ b/.github/workflows/issue-deduplicator.yml @@ -0,0 +1,178 @@ +name: Issue Deduplicator + +on: + issues: + types: + - opened + - labeled + +permissions: + issues: write + models: read + +jobs: + find-similar: + name: Find similar issues + concurrency: + group: issue-deduplicator-${{ github.event.issue.number }} + cancel-in-progress: false + if: | + github.event.action == 'opened' || + (github.event.action == 'labeled' && github.event.label.name == 'codex-deduplicate') + runs-on: ubuntu-latest + + steps: + - name: Fetch issues and build prompt + id: build-prompt + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: | + set -eo pipefail + + # Fetch open AND closed so the AI can surface already-answered duplicates + gh issue list --repo "$REPO" \ + --json number,title,body,state \ + --limit 500 \ + --state all \ + --search "sort:created-desc" \ + | jq --arg cur "$ISSUE_NUMBER" '[ + .[] | select(.number != ($cur | tonumber)) | + { number, title, state, body: ((.body // "")[0:800]) } + ]' \ + > existing-issues.json + + gh issue view "$ISSUE_NUMBER" --repo "$REPO" \ + --json number,title,body \ + | jq '{ number, title, body: ((.body // "")[0:2000]) }' \ + > current-issue.json + + echo "issue_count=$(jq 'length' existing-issues.json)" + + # Build prompt file without heredocs (avoids YAML/bash indentation conflict) + { + printf 'New issue (JSON):\n' + cat current-issue.json + printf '\n\nExisting issues — open and closed (JSON array):\n' + cat existing-issues.json + printf '\n\nReturn a JSON object with this exact shape and nothing else:\n' + printf '{"issues": ["", ...], "reason": ""}\n' + printf '\nList only issue numbers (as strings) that describe the same underlying\n' + printf 'problem or request as the new issue. Closed issues are valid matches if\n' + printf 'they already answer or resolve it. At most five. Empty array if no\n' + printf 'convincing matches exist.\n' + } > dedup-prompt.txt + + - name: Run semantic duplicate detection + id: ai + uses: actions/ai-inference@b81b2afb8390ee6839b494a404766bef6493c7d9 # v1 + with: + model: openai/gpt-4o-mini + max-completion-tokens: 200 + system-prompt: > + You are a GitHub issue triage assistant. Identify duplicate issues + based on semantic similarity — same underlying problem, error, or + feature request — not just shared keywords. Closed issues are valid + matches if they already answer or resolve the new one. Respond only + with the requested JSON. No markdown, no explanation outside the JSON. + prompt-file: dedup-prompt.txt + + - name: Post duplicate comment + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + AI_RESPONSE: ${{ steps.ai.outputs.response }} + with: + script: | + const fs = require('fs'); + const { owner, repo } = context.repo; + const issue = context.payload.issue; + const BOT_MARKER = ""; + + // Always fetch existing bot comment first — needed for both paths + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: issue.number, per_page: 100, + }); + const existing = comments.find( + c => c.user?.type === 'Bot' && c.body?.includes(BOT_MARKER), + ); + + const raw = process.env.AI_RESPONSE ?? ''; + let parsed; + try { + const cleaned = raw.replace(/^```(?:json)?\s*/m, '').replace(/\s*```\s*$/m, '').trim(); + parsed = JSON.parse(cleaned); + } catch (err) { + core.warning(`AI response was not valid JSON: ${raw}`); + return; + } + + // Build a whitelist of numbers we actually fetched — guards against hallucinations + const fetched = JSON.parse(fs.readFileSync('existing-issues.json', 'utf8')); + const validNums = new Set(fetched.map(i => String(i.number))); + + if (!Array.isArray(parsed?.issues)) { + core.warning(`AI response has unexpected schema (issues is not an array): ${raw}`); + return; + } + + const currentNum = String(issue.number); + const matches = [...new Set( + parsed.issues + .map(String) + .filter(n => n !== currentNum && validNums.has(n)) + )].slice(0, 5); + + if (matches.length === 0) { + if (existing) { + await github.rest.issues.deleteComment({ + owner, repo, comment_id: existing.id, + }); + core.info(`Removed stale dedup comment on #${issue.number} — no matches on rescan.`); + } else { + core.info('No similar issues found.'); + } + return; + } + + // Annotate closed matches so reporters know they may already have an answer + const closedNums = new Set( + fetched.filter(i => i.state === 'closed').map(i => String(i.number)) + ); + const list = matches + .map(n => closedNums.has(n) ? `- #${n} _(closed)_` : `- #${n}`) + .join('\n'); + + const body = [ + BOT_MARKER, + 'Potential duplicates found — please check whether your issue is already covered:', + '', + list, + '', + '_Detected automatically via GitHub Models. Closed issues may already contain an answer. Matches may not be exact._', + ].join('\n'); + + if (existing) { + await github.rest.issues.updateComment({ + owner, repo, comment_id: existing.id, body, + }); + core.info(`Updated dedup comment on #${issue.number}.`); + } else { + await github.rest.issues.createComment({ + owner, repo, issue_number: issue.number, body, + }); + core.info(`Posted dedup comment on #${issue.number} with ${matches.length} match(es).`); + } + + - name: Remove codex-deduplicate label + if: github.event.action == 'labeled' && github.event.label.name == 'codex-deduplicate' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + # Only swallow 404 "label not found"; propagate auth/API/runner failures + output=$(gh issue edit "${{ github.event.issue.number }}" --remove-label "codex-deduplicate" 2>&1) && exit 0 || { + echo "$output" | grep -qi "could not remove label\|label.*not found\|Label.*not exist" && exit 0 + echo "$output" >&2 + exit 1 + } \ No newline at end of file