mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 16:08:23 +08:00
Automate good first issue labeling in issue triage (#14498)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
@@ -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' ||
|
||||
|
||||
Reference in New Issue
Block a user