Automate good first issue labeling in issue triage (#14498)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Engel Nyst
2026-05-22 17:54:32 +02:00
committed by GitHub
co-authored by openhands
parent 3515cb0858
commit d04c1bb31c
5 changed files with 1111 additions and 3 deletions
+237 -2
View File
@@ -15,10 +15,11 @@ on:
options:
- smoke-clone
- issue-check
- good-first-issue-check
- auto-close
default: smoke-clone
issue_number:
description: Existing issue number to analyze when mode is issue-check
description: Existing issue number to analyze for manual issue-check modes
required: false
type: number
close_after_days:
@@ -57,13 +58,19 @@ jobs:
issue-duplicate-check:
if: |
github.event_name == 'issues' ||
(github.event_name == 'issues' && github.event.action == 'opened') ||
(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
outputs:
should_comment: ${{ steps.parsed_result.outputs.should_comment }}
is_duplicate: ${{ steps.parsed_result.outputs.is_duplicate }}
auto_close_candidate: ${{ steps.parsed_result.outputs.auto_close_candidate }}
confidence: ${{ steps.parsed_result.outputs.confidence }}
classification: ${{ steps.parsed_result.outputs.classification }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -430,6 +437,234 @@ jobs:
await ensureCandidateLabelOnIssue();
}
issue-good-first-issue-check:
if: |
(
github.event_name == 'issues' ||
(github.event_name == 'workflow_dispatch' && inputs.mode == 'good-first-issue-check' && inputs.issue_number != null)
) &&
(needs.issue-duplicate-check.result == 'success' || needs.issue-duplicate-check.result == 'skipped')
needs:
- issue-duplicate-check
runs-on: ubuntu-latest
timeout-minutes: 35
concurrency:
group: issue-good-first-issue-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 good first issue 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: Determine whether good first issue auto-label check should run
id: precheck
uses: actions/github-script@v9
env:
ISSUE_NUMBER: ${{ github.event.issue.number || inputs.issue_number }}
DUPLICATE_SHOULD_COMMENT: ${{ needs.issue-duplicate-check.outputs.should_comment }}
DUPLICATE_CLASSIFICATION: ${{ needs.issue-duplicate-check.outputs.classification }}
with:
github-token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}
script: |
const issueNumber = Number(process.env.ISSUE_NUMBER);
const duplicateShouldComment = process.env.DUPLICATE_SHOULD_COMMENT === 'true';
const duplicateClassification = process.env.DUPLICATE_CLASSIFICATION || '';
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
)).filter(Boolean);
let skipReason = '';
if (issue.pull_request) {
skipReason = `#${issueNumber} is a pull request, not an issue.`;
} else if (issue.state !== 'open' || issue.locked) {
skipReason = `#${issueNumber} is not an open, unlocked issue.`;
} else if (labelNames.includes('good first issue')) {
skipReason = 'Issue is already labeled good first issue.';
} else if (labelNames.includes('duplicate-candidate')) {
skipReason = 'Issue is marked duplicate-candidate, so good first issue auto-labeling is blocked.';
} else if (
duplicateShouldComment ||
['duplicate', 'overlapping-scope'].includes(duplicateClassification)
) {
skipReason = `Duplicate check classified the issue as ${duplicateClassification || 'overlapping-scope'} and blocked good first issue auto-labeling.`;
}
core.setOutput('issue_url', issue.html_url || '');
core.setOutput('labels_json', JSON.stringify(labelNames));
core.setOutput('skip_reason', skipReason);
core.setOutput('should_run', skipReason ? 'false' : 'true');
- name: Summarize skipped good first issue check
if: steps.precheck.outputs.should_run != 'true'
run: |
{
echo "## Good first issue check"
echo
echo "- Issue: #${{ github.event.issue.number || inputs.issue_number }}"
echo "- Skipped: ${{ steps.precheck.outputs.skip_reason }}"
} >> "$GITHUB_STEP_SUMMARY"
- name: Run OpenHands good first issue conversation
if: steps.precheck.outputs.should_run == 'true'
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 }}/good-first-issue-check-result.json
REPOSITORY: ${{ github.repository }}
run: |
python scripts/issue_good_first_issue_check_openhands.py \
--repository "$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 good first issue check result
if: steps.precheck.outputs.should_run == 'true'
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 good first issue 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")
with output_path.open('a', encoding='utf-8') as fh:
fh.write(
f"should_apply_label={'true' if result.get('should_apply_label') else 'false'}\n"
)
fh.write(f"confidence={result.get('confidence', '')}\n")
fh.write(f"conversation_url={result.get('conversation_url', '')}\n")
write_multiline('summary', str(result.get('summary', '')).strip())
write_multiline(
'criteria_met_json',
json.dumps(result.get('criteria_met', []), ensure_ascii=False),
)
write_multiline(
'disqualifiers_json',
json.dumps(result.get('disqualifiers', []), ensure_ascii=False),
)
criteria_lines = [
f"- {item}" for item in result.get('criteria_met', [])
]
disqualifier_lines = [
f"- {item}" for item in result.get('disqualifiers', [])
]
summary_path.write_text(
"\n".join(
[
"## Good first issue check result",
"",
f"- Repository: {result.get('repository')}",
f"- Issue: #{result.get('issue_number')}",
f"- Should apply label: {result.get('should_apply_label')}",
f"- Confidence: {result.get('confidence')}",
f"- Conversation: {result.get('conversation_url')}",
"",
"### Summary",
result.get('summary', ''),
"",
"### Criteria met",
*(criteria_lines or ["- None"]),
"",
"### Disqualifiers",
*(disqualifier_lines or ["- None"]),
]
)
+ "\n",
encoding='utf-8',
)
PY
- name: Apply good first issue label
if: |
steps.precheck.outputs.should_run == 'true' &&
steps.parsed_result.outputs.should_apply_label == 'true'
uses: actions/github-script@v9
env:
ISSUE_NUMBER: ${{ github.event.issue.number || inputs.issue_number }}
with:
github-token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}
script: |
const issueNumber = Number(process.env.ISSUE_NUMBER);
const labelName = 'good first issue';
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
)).filter(Boolean);
if (labelNames.includes(labelName)) {
core.info(`Issue #${issueNumber} already has the ${labelName} label.`);
return;
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: [labelName],
});
- name: Summarize good first issue label action
if: steps.precheck.outputs.should_run == 'true'
run: |
if [ "${{ steps.parsed_result.outputs.should_apply_label }}" = "true" ]; then
echo "- Applied label: good first issue" >> "$GITHUB_STEP_SUMMARY"
else
echo "- Label not applied" >> "$GITHUB_STEP_SUMMARY"
fi
auto-close-duplicates:
if: |
github.event_name == 'schedule' ||