Add issue duplicate automation workflow (#14444)

Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
🐾 smolpaws
2026-05-16 17:34:23 +02:00
committed by GitHub
co-authored by Engel Nyst openhands
parent 68083f275b
commit e3d9abfd01
5 changed files with 3717 additions and 0 deletions
@@ -0,0 +1,468 @@
---
name: Issue Duplicate Check via OpenHands Cloud
on:
issues:
types: [opened]
schedule:
- cron: 0 9 * * *
workflow_dispatch:
inputs:
mode:
description: Which workflow path to run
required: true
type: choice
options:
- smoke-clone
- issue-check
- auto-close
default: smoke-clone
issue_number:
description: Existing issue number to analyze when mode is issue-check
required: false
type: number
close_after_days:
description: Days to wait before auto-closing duplicate candidates in auto-close mode
required: false
type: number
default: 3
permissions:
contents: read
issues: write
jobs:
smoke-clone:
if: github.event_name == 'workflow_dispatch' && inputs.mode == 'smoke-clone'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Clone repository
run: |
git clone --depth 1 "https://github.com/${{ github.repository }}.git" /tmp/repo-clone
echo "Repository HEAD: $(git -C /tmp/repo-clone rev-parse --short HEAD)"
- name: Summarize smoke test
run: |
{
echo "## Smoke clone completed"
echo
echo "- Repository cloned to /tmp/repo-clone"
} >> "$GITHUB_STEP_SUMMARY"
issue-duplicate-check:
if: |
github.event_name == 'issues' ||
(github.event_name == 'workflow_dispatch' && inputs.mode == 'issue-check' && inputs.issue_number != null)
runs-on: ubuntu-latest
timeout-minutes: 35
concurrency:
group: issue-duplicate-check-${{ github.repository }}-${{ github.event.issue.number || inputs.issue_number }}
cancel-in-progress: false
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Validate duplicate check inputs
env:
OPENHANDS_API_KEY: ${{ secrets.OPENHANDS_API_KEY }}
ISSUE_NUMBER: ${{ github.event.issue.number || inputs.issue_number }}
run: |
if [ -z "$OPENHANDS_API_KEY" ]; then
echo "Error: OPENHANDS_API_KEY secret is required"
exit 1
fi
if [ -z "$ISSUE_NUMBER" ]; then
echo "Error: ISSUE_NUMBER is required"
exit 1
fi
- name: Run OpenHands duplicate check conversation
id: run_check
env:
OPENHANDS_API_KEY: ${{ secrets.OPENHANDS_API_KEY }}
GITHUB_TOKEN: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}
ISSUE_NUMBER: ${{ github.event.issue.number || inputs.issue_number }}
OUTPUT_PATH: ${{ runner.temp }}/issue-duplicate-check-result.json
run: |
python scripts/issue_duplicate_check_openhands.py \
--repository "${{ github.repository }}" \
--issue-number "$ISSUE_NUMBER" \
--output "$OUTPUT_PATH"
test -f "$OUTPUT_PATH" || {
echo "Error: Output file not created"
exit 1
}
echo "result_path=$OUTPUT_PATH" >> "$GITHUB_OUTPUT"
- name: Parse duplicate check result
id: parsed_result
env:
RESULT_PATH: ${{ steps.run_check.outputs.result_path }}
run: |
python - <<'PY'
import json
import os
import sys
from pathlib import Path
try:
result = json.loads(Path(os.environ['RESULT_PATH']).read_text())
except (FileNotFoundError, json.JSONDecodeError) as exc:
print(
f"Error: Failed to read duplicate check result: {exc}",
file=sys.stderr,
)
raise SystemExit(1) from exc
output_path = Path(os.environ['GITHUB_OUTPUT'])
summary_path = Path(os.environ['GITHUB_STEP_SUMMARY'])
def write_multiline(name: str, value: str) -> None:
delimiter = f"EOF_{os.urandom(8).hex()}"
with output_path.open('a', encoding='utf-8') as fh:
fh.write(f"{name}<<{delimiter}\n{value}\n{delimiter}\n")
canonical_issue_number = result.get('canonical_issue_number')
with output_path.open('a', encoding='utf-8') as fh:
fh.write(f"should_comment={'true' if result.get('should_comment') else 'false'}\n")
fh.write(f"is_duplicate={'true' if result.get('is_duplicate') else 'false'}\n")
fh.write(
f"auto_close_candidate={'true' if result.get('auto_close_candidate') else 'false'}\n"
)
fh.write(f"confidence={result.get('confidence', '')}\n")
fh.write(f"classification={result.get('classification', '')}\n")
fh.write(
f"canonical_issue_number={canonical_issue_number if canonical_issue_number is not None else ''}\n"
)
fh.write(f"conversation_url={result.get('conversation_url', '')}\n")
fh.write(f"app_conversation_id={result.get('app_conversation_id', '')}\n")
write_multiline('summary', str(result.get('summary', '')).strip())
write_multiline(
'candidate_issues_json',
json.dumps(result.get('candidate_issues', []), ensure_ascii=False),
)
candidate_lines = []
for candidate in result.get('candidate_issues', []):
candidate_lines.append(
f"- #{candidate.get('number')}: {candidate.get('title')} ({candidate.get('url')}) — {candidate.get('similarity_reason', '')}"
)
summary_path.write_text(
"\n".join(
[
"## Duplicate check result",
"",
f"- Repository: {result.get('repository')}",
f"- Issue: #{result.get('issue_number')}",
f"- Should comment: {result.get('should_comment')}",
f"- Exact duplicate: {result.get('is_duplicate')}",
f"- Auto-close candidate: {result.get('auto_close_candidate')}",
f"- Classification: {result.get('classification')}",
f"- Confidence: {result.get('confidence')}",
f"- Canonical issue: {canonical_issue_number}",
f"- Conversation: {result.get('conversation_url')}",
"",
"### Summary",
result.get('summary', ''),
"",
"### Candidate issues",
*(candidate_lines or ["- None"]),
]
)
+ "\n",
encoding='utf-8',
)
PY
- name: Post duplicate overlap notice
if: steps.parsed_result.outputs.should_comment == 'true'
uses: actions/github-script@v7
env:
ISSUE_NUMBER: ${{ github.event.issue.number || inputs.issue_number }}
SUMMARY: ${{ steps.parsed_result.outputs.summary }}
CANDIDATE_ISSUES_JSON: ${{ steps.parsed_result.outputs.candidate_issues_json }}
CLASSIFICATION: ${{ steps.parsed_result.outputs.classification }}
AUTO_CLOSE_CANDIDATE: ${{ steps.parsed_result.outputs.auto_close_candidate }}
CANONICAL_ISSUE_NUMBER: ${{ steps.parsed_result.outputs.canonical_issue_number }}
CLOSE_AFTER_DAYS: ${{ inputs.close_after_days || '3' }}
with:
github-token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}
script: |
const issueNumber = Number(process.env.ISSUE_NUMBER);
const summary = (process.env.SUMMARY || '').trim();
const classification = process.env.CLASSIFICATION || 'no-match';
const autoClose = process.env.AUTO_CLOSE_CANDIDATE === 'true';
const closeAfterDays = process.env.CLOSE_AFTER_DAYS || '3';
let candidates = [];
try {
candidates = JSON.parse(process.env.CANDIDATE_ISSUES_JSON || '[]');
} catch (error) {
core.setFailed(`Invalid candidate JSON: ${error.message}`);
return;
}
if (!Array.isArray(candidates)) {
core.setFailed('CANDIDATE_ISSUES_JSON is not an array');
return;
}
if (candidates.length === 0) {
core.setFailed(`No candidate issues were returned for issue #${issueNumber}.`);
return;
}
const canonicalIssueRaw = process.env.CANONICAL_ISSUE_NUMBER || candidates[0].number;
const canonicalIssueNumber = canonicalIssueRaw ? Number(canonicalIssueRaw) : Number.NaN;
const candidateLabel = 'duplicate-candidate';
function parseDuplicateCheckMarker(body) {
if (!body) {
return null;
}
const match = body.match(/<!-- openhands-duplicate-check canonical=(\d+) auto-close=(true|false) -->/);
if (!match) {
return null;
}
return {
canonicalIssueNumber: Number(match[1]),
autoClose: match[2] === 'true',
};
}
async function ensureCanonicalIssueIsOpenIssue() {
let canonicalIssue;
try {
({ data: canonicalIssue } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: canonicalIssueNumber,
}));
} catch (error) {
if (error.status === 404) {
core.setFailed(`Canonical issue #${canonicalIssueNumber} does not exist.`);
return false;
}
throw error;
}
if (canonicalIssue.pull_request) {
core.setFailed(`Canonical issue #${canonicalIssueNumber} is a pull request, not an issue.`);
return false;
}
if (canonicalIssue.state !== 'open' || canonicalIssue.locked) {
core.setFailed(`Canonical issue #${canonicalIssueNumber} must be an open, unlocked issue.`);
return false;
}
return true;
}
async function ensureCandidateLabelOnIssue() {
try {
await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: candidateLabel,
});
} catch (error) {
if (error.status !== 404) {
throw error;
}
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: candidateLabel,
color: 'FBCA04',
description: 'Potential duplicate awaiting auto-close or maintainer review',
});
}
const { data: issue } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
});
const labelNames = (issue.labels || []).map((label) => (
typeof label === 'string' ? label : label.name
));
if (!labelNames.includes(candidateLabel)) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: [candidateLabel],
});
}
}
async function removeCandidateLabelFromIssue() {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
name: candidateLabel,
});
} catch (error) {
if (error.status !== 404) {
throw error;
}
}
}
if (!Number.isInteger(canonicalIssueNumber) || canonicalIssueNumber <= 0) {
core.setFailed(`No canonical issue number was returned for issue #${issueNumber}.`);
return;
}
if (!(await ensureCanonicalIssueIsOpenIssue())) {
return;
}
const marker = `<!-- openhands-duplicate-check canonical=${canonicalIssueNumber} auto-close=${autoClose ? 'true' : 'false'} -->`;
const header = candidates.length === 1
? 'Found 1 possible duplicate issue:'
: `Found ${candidates.length} possible duplicate issues:`;
const candidateLines = candidates.map((candidate, index) => (
`${index + 1}. [#${candidate.number}](${candidate.url}) — ${candidate.title}`
));
const sections = [];
if (summary) {
sections.push(summary, '');
}
sections.push(header, '', ...candidateLines);
if (classification === 'overlapping-scope') {
sections.push(
'',
'These may not be exact duplicates, but the scope appears to overlap enough that keeping discussion in one place may be more useful.'
);
}
if (autoClose) {
sections.push(
'',
`This issue will be automatically closed as a duplicate in ${closeAfterDays} days.`,
'',
'- If your issue is a duplicate, please close it and 👍 the existing issue instead',
'- To prevent auto-closure, add a comment or 👎 this comment'
);
}
sections.push(
'',
marker,
'_This comment was created by an AI assistant (OpenHands) on behalf of the repository maintainer._'
);
const body = sections.join('\n').trim();
const MAX_COMMENT_PAGES = 50;
let allComments = [];
let page = 1;
while (page <= MAX_COMMENT_PAGES) {
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
per_page: 100,
page,
});
if (!comments || comments.length === 0) {
break;
}
allComments = allComments.concat(comments);
if (comments.length < 100) {
break;
}
page += 1;
}
if (page > MAX_COMMENT_PAGES) {
core.setFailed(
`Stopped loading comments for issue #${issueNumber} after ${MAX_COMMENT_PAGES} pages.`
);
return;
}
const existing = allComments.find((comment) => comment.body && comment.body.includes('<!-- openhands-duplicate-check '));
if (existing) {
const existingMarker = parseDuplicateCheckMarker(existing.body);
if (existingMarker) {
if (existingMarker.autoClose) {
await ensureCandidateLabelOnIssue();
} else {
await removeCandidateLabelFromIssue();
}
if (
existingMarker.canonicalIssueNumber !== canonicalIssueNumber ||
existingMarker.autoClose !== autoClose
) {
core.setFailed(
`Duplicate check comment already exists on issue #${issueNumber} with different canonical/auto-close metadata; manual reconciliation is required.`
);
return;
}
} else {
core.warning(
`Duplicate check comment already exists on issue #${issueNumber} but its marker could not be parsed; leaving label state unchanged.`
);
}
core.info(`Duplicate check comment already exists on issue #${issueNumber}; skipping.`);
return;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body,
});
if (autoClose) {
await ensureCandidateLabelOnIssue();
}
auto-close-duplicates:
if: |
github.event_name == 'schedule' ||
(github.event_name == 'workflow_dispatch' && inputs.mode == 'auto-close')
runs-on: ubuntu-latest
timeout-minutes: 20
concurrency:
group: auto-close-duplicates-${{ github.repository }}
cancel-in-progress: false
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Auto-close aged duplicate candidates
env:
GITHUB_TOKEN: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}
CLOSE_AFTER_DAYS: ${{ inputs.close_after_days || '3' }}
run: |
python scripts/auto_close_duplicate_issues.py \
--repository "${{ github.repository }}" \
--close-after-days "$CLOSE_AFTER_DAYS" | tee "$RUNNER_TEMP/auto-close-summary.json"
status=${PIPESTATUS[0]}
if [ "$status" -ne 0 ]; then
echo "::error::Auto-close script failed with exit code $status"
exit "$status"
fi
- name: Summarize auto-close run
run: |
{
echo "## Auto-close duplicate candidates"
echo
cat "$RUNNER_TEMP/auto-close-summary.json"
} >> "$GITHUB_STEP_SUMMARY"
@@ -0,0 +1,64 @@
---
name: Remove duplicate candidate label on activity
on:
issue_comment:
types: [created]
permissions:
issues: write
concurrency:
group: remove-duplicate-${{ github.repository }}-${{ github.event.issue.number }}
cancel-in-progress: false
jobs:
remove-duplicate-candidate:
if: |
github.event.issue.state == 'open' &&
github.event.issue.pull_request == null &&
contains(github.event.issue.labels.*.name, 'duplicate-candidate') &&
github.event.comment.user.type != 'Bot' &&
!startsWith(github.event.comment.body || '', '<!-- openhands-duplicate-check') &&
!startsWith(github.event.comment.body || '', '<!-- openhands-duplicate-veto')
runs-on: ubuntu-latest
steps:
- name: Remove duplicate-candidate label
uses: actions/github-script@v7
with:
github-token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}
script: |
const issueNumber = context.issue.number;
const commenter = context.payload.comment.user.login || '';
const normalizedCommenter = commenter.toLowerCase();
if (
normalizedCommenter.endsWith('[bot]') ||
normalizedCommenter === 'all-hands-bot'
) {
core.info(
`Skipping duplicate-candidate label removal for bot comment from ${commenter || 'unknown'}`
);
return;
}
core.info(
`Removing duplicate-candidate label from issue #${issueNumber} after comment from ${commenter}`
);
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
name: 'duplicate-candidate',
});
} catch (error) {
if (error.status === 404) {
core.info(
`duplicate-candidate label was already removed from issue #${issueNumber}`
);
return;
}
throw error;
}