mirror of
https://github.com/openai/codex.git
synced 2026-03-05 21:45:28 +03:00
402 lines
15 KiB
YAML
402 lines
15 KiB
YAML
name: Issue Deduplicator
|
|
|
|
on:
|
|
issues:
|
|
types:
|
|
- opened
|
|
- labeled
|
|
|
|
jobs:
|
|
gather-duplicates-all:
|
|
name: Identify potential duplicates (all issues)
|
|
# Prevent runs on forks (requires OpenAI API key, wastes Actions minutes)
|
|
if: github.repository == 'openai/codex' && (github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-deduplicate'))
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
outputs:
|
|
issues_json: ${{ steps.normalize-all.outputs.issues_json }}
|
|
reason: ${{ steps.normalize-all.outputs.reason }}
|
|
has_matches: ${{ steps.normalize-all.outputs.has_matches }}
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
|
|
- name: Prepare Codex inputs
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
REPO: ${{ github.repository }}
|
|
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
|
run: |
|
|
set -eo pipefail
|
|
|
|
CURRENT_ISSUE_FILE=codex-current-issue.json
|
|
EXISTING_ALL_FILE=codex-existing-issues-all.json
|
|
|
|
gh issue list --repo "$REPO" \
|
|
--json number,title,body,createdAt,updatedAt,state,labels \
|
|
--limit 1000 \
|
|
--state all \
|
|
--search "sort:created-desc" \
|
|
| jq '[.[] | {
|
|
number,
|
|
title,
|
|
body: ((.body // "")[0:4000]),
|
|
createdAt,
|
|
updatedAt,
|
|
state,
|
|
labels: ((.labels // []) | map(.name))
|
|
}]' \
|
|
> "$EXISTING_ALL_FILE"
|
|
|
|
gh issue view "$ISSUE_NUMBER" \
|
|
--repo "$REPO" \
|
|
--json number,title,body \
|
|
| jq '{number, title, body: ((.body // "")[0:4000])}' \
|
|
> "$CURRENT_ISSUE_FILE"
|
|
|
|
echo "Prepared duplicate detection input files."
|
|
echo "all_issue_count=$(jq 'length' "$EXISTING_ALL_FILE")"
|
|
|
|
# Prompt instructions are intentionally inline in this workflow. The old
|
|
# .github/prompts/issue-deduplicator.txt file is obsolete and removed.
|
|
- id: codex-all
|
|
name: Find duplicates (pass 1, all issues)
|
|
uses: openai/codex-action@main
|
|
with:
|
|
openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }}
|
|
allow-users: "*"
|
|
prompt: |
|
|
You are an assistant that triages new GitHub issues by identifying potential duplicates.
|
|
|
|
You will receive the following JSON files located in the current working directory:
|
|
- `codex-current-issue.json`: JSON object describing the newly created issue (fields: number, title, body).
|
|
- `codex-existing-issues-all.json`: JSON array of recent issues with states, timestamps, and labels.
|
|
|
|
Instructions:
|
|
- Compare the current issue against the existing issues to find up to five that appear to describe the same underlying problem or request.
|
|
- Prioritize concrete overlap in symptoms, reproduction details, error signatures, and user intent.
|
|
- Prefer active unresolved issues when confidence is similar.
|
|
- Closed issues can still be valid duplicates if they clearly match.
|
|
- Return fewer matches rather than speculative ones.
|
|
- If confidence is low, return an empty list.
|
|
- Include at most five issue numbers.
|
|
- After analysis, provide a short reason for your decision.
|
|
|
|
output-schema: |
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"issues": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "string"
|
|
}
|
|
},
|
|
"reason": { "type": "string" }
|
|
},
|
|
"required": ["issues", "reason"],
|
|
"additionalProperties": false
|
|
}
|
|
|
|
- id: normalize-all
|
|
name: Normalize pass 1 output
|
|
env:
|
|
CODEX_OUTPUT: ${{ steps.codex-all.outputs.final-message }}
|
|
CURRENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
|
run: |
|
|
set -eo pipefail
|
|
|
|
raw=${CODEX_OUTPUT//$'\r'/}
|
|
parsed=false
|
|
issues='[]'
|
|
reason=''
|
|
|
|
if [ -n "$raw" ] && printf '%s' "$raw" | jq -e 'type == "object" and (.issues | type == "array")' >/dev/null 2>&1; then
|
|
parsed=true
|
|
issues=$(printf '%s' "$raw" | jq -c '[.issues[] | tostring]')
|
|
reason=$(printf '%s' "$raw" | jq -r '.reason // ""')
|
|
else
|
|
reason='Pass 1 output was empty or invalid JSON.'
|
|
fi
|
|
|
|
filtered=$(jq -cn --argjson issues "$issues" --arg current "$CURRENT_ISSUE_NUMBER" '[
|
|
$issues[]
|
|
| tostring
|
|
| select(. != $current)
|
|
] | reduce .[] as $issue ([]; if index($issue) then . else . + [$issue] end) | .[:5]')
|
|
|
|
has_matches=false
|
|
if [ "$(jq 'length' <<< "$filtered")" -gt 0 ]; then
|
|
has_matches=true
|
|
fi
|
|
|
|
echo "Pass 1 parsed: $parsed"
|
|
echo "Pass 1 matches after filtering: $(jq 'length' <<< "$filtered")"
|
|
echo "Pass 1 reason: $reason"
|
|
|
|
{
|
|
echo "issues_json=$filtered"
|
|
echo "reason<<EOF"
|
|
echo "$reason"
|
|
echo "EOF"
|
|
echo "has_matches=$has_matches"
|
|
} >> "$GITHUB_OUTPUT"
|
|
|
|
gather-duplicates-open:
|
|
name: Identify potential duplicates (open issues fallback)
|
|
# Pass 1 may drop sudo on the runner, so run the fallback in a fresh job.
|
|
needs: gather-duplicates-all
|
|
if: ${{ needs.gather-duplicates-all.result == 'success' && needs.gather-duplicates-all.outputs.has_matches != 'true' }}
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
outputs:
|
|
issues_json: ${{ steps.normalize-open.outputs.issues_json }}
|
|
reason: ${{ steps.normalize-open.outputs.reason }}
|
|
has_matches: ${{ steps.normalize-open.outputs.has_matches }}
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
|
|
- name: Prepare Codex inputs
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
REPO: ${{ github.repository }}
|
|
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
|
run: |
|
|
set -eo pipefail
|
|
|
|
CURRENT_ISSUE_FILE=codex-current-issue.json
|
|
EXISTING_OPEN_FILE=codex-existing-issues-open.json
|
|
|
|
gh issue list --repo "$REPO" \
|
|
--json number,title,body,createdAt,updatedAt,state,labels \
|
|
--limit 1000 \
|
|
--state open \
|
|
--search "sort:created-desc" \
|
|
| jq '[.[] | {
|
|
number,
|
|
title,
|
|
body: ((.body // "")[0:4000]),
|
|
createdAt,
|
|
updatedAt,
|
|
state,
|
|
labels: ((.labels // []) | map(.name))
|
|
}]' \
|
|
> "$EXISTING_OPEN_FILE"
|
|
|
|
gh issue view "$ISSUE_NUMBER" \
|
|
--repo "$REPO" \
|
|
--json number,title,body \
|
|
| jq '{number, title, body: ((.body // "")[0:4000])}' \
|
|
> "$CURRENT_ISSUE_FILE"
|
|
|
|
echo "Prepared fallback duplicate detection input files."
|
|
echo "open_issue_count=$(jq 'length' "$EXISTING_OPEN_FILE")"
|
|
|
|
- id: codex-open
|
|
name: Find duplicates (pass 2, open issues)
|
|
uses: openai/codex-action@main
|
|
with:
|
|
openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }}
|
|
allow-users: "*"
|
|
prompt: |
|
|
You are an assistant that triages new GitHub issues by identifying potential duplicates.
|
|
|
|
This is a fallback pass because a broad search did not find convincing matches.
|
|
|
|
You will receive the following JSON files located in the current working directory:
|
|
- `codex-current-issue.json`: JSON object describing the newly created issue (fields: number, title, body).
|
|
- `codex-existing-issues-open.json`: JSON array of open issues only.
|
|
|
|
Instructions:
|
|
- Search only these active unresolved issues for duplicates of the current issue.
|
|
- Prioritize concrete overlap in symptoms, reproduction details, error signatures, and user intent.
|
|
- Prefer fewer, higher-confidence matches.
|
|
- If confidence is low, return an empty list.
|
|
- Include at most five issue numbers.
|
|
- After analysis, provide a short reason for your decision.
|
|
|
|
output-schema: |
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"issues": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "string"
|
|
}
|
|
},
|
|
"reason": { "type": "string" }
|
|
},
|
|
"required": ["issues", "reason"],
|
|
"additionalProperties": false
|
|
}
|
|
|
|
- id: normalize-open
|
|
name: Normalize pass 2 output
|
|
env:
|
|
CODEX_OUTPUT: ${{ steps.codex-open.outputs.final-message }}
|
|
CURRENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
|
run: |
|
|
set -eo pipefail
|
|
|
|
raw=${CODEX_OUTPUT//$'\r'/}
|
|
parsed=false
|
|
issues='[]'
|
|
reason=''
|
|
|
|
if [ -n "$raw" ] && printf '%s' "$raw" | jq -e 'type == "object" and (.issues | type == "array")' >/dev/null 2>&1; then
|
|
parsed=true
|
|
issues=$(printf '%s' "$raw" | jq -c '[.issues[] | tostring]')
|
|
reason=$(printf '%s' "$raw" | jq -r '.reason // ""')
|
|
else
|
|
reason='Pass 2 output was empty or invalid JSON.'
|
|
fi
|
|
|
|
filtered=$(jq -cn --argjson issues "$issues" --arg current "$CURRENT_ISSUE_NUMBER" '[
|
|
$issues[]
|
|
| tostring
|
|
| select(. != $current)
|
|
] | reduce .[] as $issue ([]; if index($issue) then . else . + [$issue] end) | .[:5]')
|
|
|
|
has_matches=false
|
|
if [ "$(jq 'length' <<< "$filtered")" -gt 0 ]; then
|
|
has_matches=true
|
|
fi
|
|
|
|
echo "Pass 2 parsed: $parsed"
|
|
echo "Pass 2 matches after filtering: $(jq 'length' <<< "$filtered")"
|
|
echo "Pass 2 reason: $reason"
|
|
|
|
{
|
|
echo "issues_json=$filtered"
|
|
echo "reason<<EOF"
|
|
echo "$reason"
|
|
echo "EOF"
|
|
echo "has_matches=$has_matches"
|
|
} >> "$GITHUB_OUTPUT"
|
|
|
|
select-final:
|
|
name: Select final duplicate set
|
|
needs:
|
|
- gather-duplicates-all
|
|
- gather-duplicates-open
|
|
if: ${{ always() && needs.gather-duplicates-all.result == 'success' && (needs.gather-duplicates-open.result == 'success' || needs.gather-duplicates-open.result == 'skipped') }}
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
outputs:
|
|
codex_output: ${{ steps.select-final.outputs.codex_output }}
|
|
steps:
|
|
- id: select-final
|
|
name: Select final duplicate set
|
|
env:
|
|
PASS1_ISSUES: ${{ needs.gather-duplicates-all.outputs.issues_json }}
|
|
PASS1_REASON: ${{ needs.gather-duplicates-all.outputs.reason }}
|
|
PASS2_ISSUES: ${{ needs.gather-duplicates-open.outputs.issues_json }}
|
|
PASS2_REASON: ${{ needs.gather-duplicates-open.outputs.reason }}
|
|
PASS1_HAS_MATCHES: ${{ needs.gather-duplicates-all.outputs.has_matches }}
|
|
PASS2_HAS_MATCHES: ${{ needs.gather-duplicates-open.outputs.has_matches }}
|
|
run: |
|
|
set -eo pipefail
|
|
|
|
selected_issues='[]'
|
|
selected_reason='No plausible duplicates found.'
|
|
selected_pass='none'
|
|
|
|
if [ "$PASS1_HAS_MATCHES" = "true" ]; then
|
|
selected_issues=${PASS1_ISSUES:-'[]'}
|
|
selected_reason=${PASS1_REASON:-'Pass 1 found duplicates.'}
|
|
selected_pass='all'
|
|
fi
|
|
|
|
if [ "$PASS2_HAS_MATCHES" = "true" ]; then
|
|
selected_issues=${PASS2_ISSUES:-'[]'}
|
|
selected_reason=${PASS2_REASON:-'Pass 2 found duplicates.'}
|
|
selected_pass='open-fallback'
|
|
fi
|
|
|
|
final_json=$(jq -cn \
|
|
--argjson issues "$selected_issues" \
|
|
--arg reason "$selected_reason" \
|
|
--arg pass "$selected_pass" \
|
|
'{issues: $issues, reason: $reason, pass: $pass}')
|
|
|
|
echo "Final pass used: $selected_pass"
|
|
echo "Final duplicate count: $(jq '.issues | length' <<< "$final_json")"
|
|
echo "Final reason: $(jq -r '.reason' <<< "$final_json")"
|
|
|
|
{
|
|
echo "codex_output<<EOF"
|
|
echo "$final_json"
|
|
echo "EOF"
|
|
} >> "$GITHUB_OUTPUT"
|
|
|
|
comment-on-issue:
|
|
name: Comment with potential duplicates
|
|
needs: select-final
|
|
if: ${{ always() && needs.select-final.result == 'success' }}
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
issues: write
|
|
steps:
|
|
- name: Comment on issue
|
|
uses: actions/github-script@v8
|
|
env:
|
|
CODEX_OUTPUT: ${{ needs.select-final.outputs.codex_output }}
|
|
with:
|
|
github-token: ${{ github.token }}
|
|
script: |
|
|
const raw = process.env.CODEX_OUTPUT ?? '';
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch (error) {
|
|
core.info(`Codex output was not valid JSON. Raw output: ${raw}`);
|
|
core.info(`Parse error: ${error.message}`);
|
|
return;
|
|
}
|
|
|
|
const issues = Array.isArray(parsed?.issues) ? parsed.issues : [];
|
|
const currentIssueNumber = String(context.payload.issue.number);
|
|
const passUsed = typeof parsed?.pass === 'string' ? parsed.pass : 'unknown';
|
|
const reason = typeof parsed?.reason === 'string' ? parsed.reason : '';
|
|
|
|
console.log(`Current issue number: ${currentIssueNumber}`);
|
|
console.log(`Pass used: ${passUsed}`);
|
|
if (reason) {
|
|
console.log(`Reason: ${reason}`);
|
|
}
|
|
console.log(issues);
|
|
|
|
const filteredIssues = [...new Set(issues.map((value) => String(value)))].filter((value) => value !== currentIssueNumber).slice(0, 5);
|
|
|
|
if (filteredIssues.length === 0) {
|
|
core.info('Codex reported no potential duplicates.');
|
|
return;
|
|
}
|
|
|
|
const lines = [
|
|
'Potential duplicates detected. Please review them and close your issue if it is a duplicate.',
|
|
'',
|
|
...filteredIssues.map((value) => `- #${String(value)}`),
|
|
'',
|
|
'*Powered by [Codex Action](https://github.com/openai/codex-action)*'];
|
|
|
|
await github.rest.issues.createComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: context.payload.issue.number,
|
|
body: lines.join("\n"),
|
|
});
|
|
|
|
- name: Remove codex-deduplicate label
|
|
if: ${{ always() && github.event.action == 'labeled' && github.event.label.name == 'codex-deduplicate' }}
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
GH_REPO: ${{ github.repository }}
|
|
run: |
|
|
gh issue edit "${{ github.event.issue.number }}" --remove-label codex-deduplicate || true
|
|
echo "Attempted to remove label: codex-deduplicate"
|