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' ||
+6
View File
@@ -497,3 +497,9 @@ Called by `workspace.get_llm()` in the SDK to retrieve LLM config with the API k
- `openhands/sdk/llm/llm.py`: `LLM.api_key` accepts `SecretSource` (including `LookupSecret`)
- `openhands/workspace/cloud/workspace.py`: `get_llm()` and `get_secrets()` return LookupSecret-backed objects
- Tests: `tests/sdk/llm/test_llm_secret_source_api_key.py`, `tests/workspace/test_cloud_workspace_sdk_settings.py`
### Issue Triage Automation
- `.github/workflows/issue-duplicate-checker.yml` now has a second job that auto-applies `good first issue` after the duplicate check completes.
- The duplicate check is used only as a veto/guardrail for `good first issue` automation: duplicate or overlapping-scope issues should not be auto-labeled.
- The OpenHands classifier logic for newcomer suitability lives in `scripts/issue_good_first_issue_check_openhands.py`, with focused unit coverage in `tests/unit/test_issue_good_first_issue_check_openhands.py`.
+9 -1
View File
@@ -10,7 +10,15 @@ These are the procedures and guidelines on how issues are triaged in this repo b
* **Critical**: Affecting all users or potential security issues.
## Difficulty
* Issues good for newcomers may be tagged with **good first issue**.
* Issues good for newcomers may be tagged with **good first issue** by maintainers or by the automated triage workflow when the issue clearly meets the criteria below.
* The `welcome-good-first-issue` workflow only posts a welcome comment after the label is present; it does not decide whether the label should be applied.
* The automated triage workflow should be conservative and should not auto-apply **good first issue** to issues that look like duplicates or overlapping-scope reports.
* Use **good first issue** only when all of the following are true:
* The work is narrow in scope and should be solved as a single issue rather than a multi-step project.
* The bug, request, or expected outcome is already clear enough that a contributor should not need major discovery work before starting.
* The likely fix stays within a well-bounded area of the repo and does not require deep architecture knowledge, cross-repo coordination, enterprise-only context, migrations, or infrastructure work. Exclude enterprise/ directory from these.
* The validation path is straightforward: the PR author should be able to give clear **How to Test** steps, and if the change is user-facing, screenshots or video evidence should be practical to provide.
* Avoid **good first issue** for broad design discussions, umbrella tracking issues, items missing reproduction steps or acceptance criteria, and changes that are likely to require significant maintainer guidance.
## Not Enough Information
* User is asked to provide more information (logs, how to reproduce, etc.) when the issue is not clear.
@@ -0,0 +1,568 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
OPENHANDS_BASE_URL = os.environ.get('OPENHANDS_BASE_URL', 'https://app.all-hands.dev')
REPOSITORY_PATTERN = re.compile(r'^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$')
GITHUB_API_BASE_URL = os.environ.get('GITHUB_API_BASE_URL', 'https://api.github.com')
FAILED_EXECUTION_STATUSES = {
'error',
'errored',
'failed',
'stopped',
}
SUCCESSFUL_TERMINAL_EXECUTION_STATUSES = {
'completed',
'finished',
}
TERMINAL_EXECUTION_STATUSES = (
FAILED_EXECUTION_STATUSES | SUCCESSFUL_TERMINAL_EXECUTION_STATUSES
)
EVENT_SEARCH_LIMIT = 1000
EVENT_SEARCH_LIMIT_HIT_MESSAGE = (
f'Event search returned at least {EVENT_SEARCH_LIMIT} events; results may be '
'incomplete'
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
'Start an OpenHands Cloud conversation that decides whether a '
'GitHub issue should be labeled good first issue.'
)
)
parser.add_argument(
'--repository', required=True, help='Repository in owner/repo form'
)
parser.add_argument(
'--issue-number', required=True, type=int, help='Issue number to inspect'
)
parser.add_argument(
'--output',
default='good-first-issue-check-result.json',
help='Path where the JSON result should be written',
)
parser.add_argument(
'--poll-interval-seconds',
default=5,
type=int,
help='Polling interval while waiting for the conversation to finish',
)
parser.add_argument(
'--max-wait-seconds',
default=900,
type=int,
help=(
'Maximum time to wait per polling phase; if a start task must be awaited '
'first, the total runtime can approach twice this value'
),
)
return parser.parse_args()
def github_headers() -> dict[str, str]:
headers = {
'Accept': 'application/vnd.github+json',
'User-Agent': 'openhands-good-first-issue-check',
'X-GitHub-Api-Version': '2022-11-28',
}
github_token = os.environ.get('GITHUB_TOKEN')
if github_token:
headers['Authorization'] = f'Bearer {github_token}'
return headers
def openhands_headers() -> dict[str, str]:
api_key = os.environ.get('OPENHANDS_API_KEY')
if not api_key:
raise RuntimeError('OPENHANDS_API_KEY environment variable is required')
return {
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json',
}
def request_json(
base_url: str,
path: str,
*,
method: str = 'GET',
headers: dict[str, str] | None = None,
body: dict[str, Any] | None = None,
) -> Any:
data = json.dumps(body).encode('utf-8') if body is not None else None
request = urllib.request.Request(
f'{base_url}{path}',
data=data,
headers=headers or {},
method=method,
)
try:
with urllib.request.urlopen(request, timeout=60) as response:
return json.load(response)
except urllib.error.HTTPError as exc:
error_body = exc.read().decode('utf-8', errors='replace')
raise RuntimeError(
f'{method} {base_url}{path} failed with HTTP {exc.code}: {error_body}'
) from exc
except json.JSONDecodeError as exc:
raise RuntimeError(
f'Failed to parse JSON from {method} {base_url}{path}: {exc}'
) from exc
except urllib.error.URLError as exc:
raise RuntimeError(f'{method} {base_url}{path} failed: {exc}') from exc
def fetch_issue(repository: str, issue_number: int) -> dict[str, Any]:
if not REPOSITORY_PATTERN.fullmatch(repository):
raise ValueError(f'Invalid repository format: {repository}')
return request_json(
GITHUB_API_BASE_URL,
f'/repos/{repository}/issues/{issue_number}',
headers=github_headers(),
)
def escape_json_text(value: str | None) -> str:
return json.dumps(value or '', ensure_ascii=False)
def extract_label_names(issue: dict[str, Any]) -> list[str]:
labels = issue.get('labels')
if not isinstance(labels, list):
return []
label_names: list[str] = []
for label in labels:
if isinstance(label, str):
candidate = label.strip()
elif isinstance(label, dict):
candidate = str(label.get('name') or '').strip()
else:
candidate = ''
if candidate:
label_names.append(candidate)
return sorted(set(label_names), key=str.lower)
def build_prompt(repository: str, issue: dict[str, Any]) -> str:
issue_number = issue['number']
issue_title = issue.get('title', '')
issue_body = issue.get('body') or ''
issue_url = issue.get('html_url', '')
issue_labels = extract_label_names(issue)
issue_title_json = escape_json_text(issue_title)
issue_body_json = escape_json_text(issue_body)
issue_labels_json = json.dumps(issue_labels, ensure_ascii=False)
return '\n'.join(
[
'You are deciding whether a GitHub issue should be labeled `good first issue` for newcomers to this repository.',
'',
'Be conservative. This label is auto-applied by automation, so false positives are more harmful than false negatives.',
'',
f'Repository: {repository}',
f'Issue number: #{issue_number}',
f'Issue URL: {issue_url}',
f'Issue labels (JSON array): {issue_labels_json}',
f'Issue title (JSON-escaped string): {issue_title_json}',
f'Issue body (JSON-escaped string): {issue_body_json}',
'',
'Use the repository, issue details, labels, and any code/documentation context you inspect to decide whether this issue is truly appropriate for a newcomer.',
'',
'All of the following must be true to apply `good first issue`:',
'1. The work is narrow in scope and should be solved as a single issue rather than a multi-step project.',
'2. The bug, request, or expected outcome is already clear enough that a contributor should not need major discovery work before starting.',
'3. The likely fix stays within a well-bounded area of the repo and does not require deep architecture knowledge, cross-repo coordination, enterprise-only context, migrations, security-sensitive changes, or infrastructure work.',
'4. The validation path is straightforward: the PR author should be able to give clear How to Test steps, and if the change is user-facing, screenshots or video evidence should be practical to provide.',
'5. A reviewer should be able to evaluate the change with the normal PR review workflow, without special credentials, paid services, or hard-to-reproduce environments.',
'',
'Do NOT apply `good first issue` for broad design discussions, umbrella tracking issues, items missing reproduction steps or acceptance criteria, or changes likely to require significant maintainer guidance.',
'Do NOT treat “not a duplicate” as positive evidence on its own. Only use the criteria above.',
'Do not post comments, do not modify files, and do not change repository state.',
'Return exactly one JSON object and nothing else. Do not wrap it in markdown fences.',
'',
'Return schema:',
'{',
f' "issue_number": {issue_number},',
' "should_apply_label": true or false,',
' "confidence": "high" | "medium" | "low",',
' "summary": "short explanation",',
' "criteria_met": ["criterion that is satisfied"],',
' "disqualifiers": ["reason the issue should not be auto-labeled"]',
'}',
'',
'Rules:',
'- `should_apply_label` must be true only when all required criteria are satisfied.',
'- If any material uncertainty remains, return `should_apply_label: false`.',
'- `should_apply_label` must be false unless confidence is `high`.',
'- `criteria_met` and `disqualifiers` should each contain at most 5 short strings.',
'- If the issue is missing enough detail to judge newcomer suitability confidently, return `should_apply_label: false`.',
'- If the issue appears enterprise-only, security-sensitive, infrastructure-heavy, or broadly architectural, return `should_apply_label: false`.',
]
)
def start_conversation(
prompt: str, repository: str, issue_number: int
) -> dict[str, Any]:
body = {
'title': f'Good first issue check #{issue_number}',
'selected_repository': repository,
'initial_message': {
'content': [
{
'type': 'text',
'text': prompt,
}
]
},
}
return request_json(
OPENHANDS_BASE_URL,
'/api/v1/app-conversations',
method='POST',
headers=openhands_headers(),
body=body,
)
def extract_first_item(payload: Any) -> dict[str, Any] | None:
if isinstance(payload, list):
first_item = payload[0] if payload else None
return first_item if isinstance(first_item, dict) else None
if not isinstance(payload, dict):
return None
items = payload.get('items')
if isinstance(items, list):
first_item = items[0] if items else None
return first_item if isinstance(first_item, dict) else None
return payload
def poll_start_task(
start_task_id: str, poll_interval_seconds: int, max_wait_seconds: int
) -> dict[str, Any]:
deadline = time.time() + max_wait_seconds
while time.time() < deadline:
payload = request_json(
OPENHANDS_BASE_URL,
f'/api/v1/app-conversations/start-tasks?ids={urllib.parse.quote(start_task_id)}',
headers={'Authorization': openhands_headers()['Authorization']},
)
item = extract_first_item(payload)
if item is None:
time.sleep(poll_interval_seconds)
continue
status = item.get('status')
if status == 'READY' and item.get('app_conversation_id'):
return item
if status in {'ERROR', 'FAILED'}:
raise RuntimeError(f'OpenHands start task failed: {json.dumps(item)}')
time.sleep(poll_interval_seconds)
raise TimeoutError(
f'Timed out waiting for start task {start_task_id} to become ready'
)
def poll_conversation(
app_conversation_id: str, poll_interval_seconds: int, max_wait_seconds: int
) -> dict[str, Any]:
deadline = time.time() + max_wait_seconds
while time.time() < deadline:
payload = request_json(
OPENHANDS_BASE_URL,
f'/api/v1/app-conversations?ids={urllib.parse.quote(app_conversation_id)}',
headers={'Authorization': openhands_headers()['Authorization']},
)
item = extract_first_item(payload)
if item is None:
time.sleep(poll_interval_seconds)
continue
execution_status = str(item.get('execution_status', '')).lower()
if execution_status in FAILED_EXECUTION_STATUSES:
raise RuntimeError(
'OpenHands conversation ended with '
f'{execution_status}: {json.dumps(item)}'
)
if execution_status in SUCCESSFUL_TERMINAL_EXECUTION_STATUSES:
return item
time.sleep(poll_interval_seconds)
raise TimeoutError(
f'Timed out waiting for conversation {app_conversation_id} to finish running'
)
def validate_event_search_results(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
if len(events) >= EVENT_SEARCH_LIMIT:
raise RuntimeError(EVENT_SEARCH_LIMIT_HIT_MESSAGE)
return events
def fetch_app_server_events(app_conversation_id: str) -> list[dict[str, Any]]:
payload = request_json(
OPENHANDS_BASE_URL,
f'/api/v1/conversation/{urllib.parse.quote(app_conversation_id)}/events/search?limit={EVENT_SEARCH_LIMIT}',
headers={'Authorization': openhands_headers()['Authorization']},
)
if isinstance(payload, dict):
items = payload.get('items')
return validate_event_search_results(items) if isinstance(items, list) else []
if isinstance(payload, list):
return validate_event_search_results(payload)
return []
def fetch_agent_server_events(
app_conversation_id: str, agent_server_url: str, session_api_key: str
) -> list[dict[str, Any]]:
payload = request_json(
agent_server_url,
f'/api/conversations/{urllib.parse.quote(app_conversation_id)}/events/search?limit={EVENT_SEARCH_LIMIT}',
headers={'X-Session-API-Key': session_api_key},
)
if isinstance(payload, dict):
items = payload.get('items')
return validate_event_search_results(items) if isinstance(items, list) else []
if isinstance(payload, list):
return validate_event_search_results(payload)
return []
def fetch_agent_server_final_response(
app_conversation_id: str, agent_server_url: str, session_api_key: str
) -> str:
payload = request_json(
agent_server_url,
f'/api/conversations/{urllib.parse.quote(app_conversation_id)}/agent_final_response',
headers={'X-Session-API-Key': session_api_key},
)
if not isinstance(payload, dict):
return ''
return str(payload.get('response') or '').strip()
def extract_agent_server_url(conversation_url: str) -> str | None:
marker = '/api/conversations/'
if marker not in conversation_url:
return None
return conversation_url.rsplit(marker, 1)[0]
def extract_last_agent_text(events: list[dict[str, Any]]) -> str:
agent_events = [
event
for event in events
if event.get('kind') == 'MessageEvent' and event.get('source') == 'agent'
]
if not agent_events:
raise RuntimeError(
'No assistant text message was found in the conversation events'
)
llm_message = agent_events[-1].get('llm_message')
if not isinstance(llm_message, dict):
raise RuntimeError('Last agent message has no llm_message field')
content = llm_message.get('content')
if not isinstance(content, list):
raise RuntimeError('Last agent message content is not a list')
text_parts: list[str] = []
for part in content:
if not isinstance(part, dict):
continue
if part.get('type') == 'text' and part.get('text'):
text_parts.append(str(part['text']))
if not text_parts:
raise RuntimeError('Last agent message contains no text content')
return ''.join(text_parts).strip()
def parse_agent_json(text: str) -> dict[str, Any]:
cleaned = text.strip()
try:
return json.loads(cleaned)
except json.JSONDecodeError:
decoder = json.JSONDecoder()
for start, character in enumerate(cleaned):
if character != '{':
continue
try:
candidate, end = decoder.raw_decode(cleaned[start:])
except json.JSONDecodeError:
continue
trailing = cleaned[start + end :].strip()
if trailing not in {'', '```'}:
continue
if isinstance(candidate, dict):
return candidate
raise ValueError('No valid JSON object found in the agent response')
def as_bool(value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {'true', '1', 'yes'}
if isinstance(value, (int, float)):
return bool(value)
return False
def normalize_string_list(value: Any) -> list[str]:
if not isinstance(value, list):
return []
normalized: list[str] = []
for item in value:
text = str(item).strip() if item is not None else ''
if text:
normalized.append(text)
if len(normalized) == 5:
break
return normalized
def normalize_result(result: dict[str, Any]) -> dict[str, Any]:
normalized = dict(result)
normalized['should_apply_label'] = as_bool(normalized.get('should_apply_label'))
confidence = str(normalized.get('confidence') or 'low').strip().lower()
if confidence not in {'high', 'medium', 'low'}:
confidence = 'low'
normalized['confidence'] = confidence
normalized['summary'] = str(normalized.get('summary') or '').strip()
normalized['criteria_met'] = normalize_string_list(normalized.get('criteria_met'))
normalized['disqualifiers'] = normalize_string_list(normalized.get('disqualifiers'))
if confidence != 'high':
normalized['should_apply_label'] = False
if normalized['disqualifiers']:
normalized['should_apply_label'] = False
return normalized
def main() -> int:
args = parse_args()
issue = fetch_issue(args.repository, args.issue_number)
if issue.get('pull_request'):
raise RuntimeError(f'#{args.issue_number} is a pull request, not an issue')
prompt = build_prompt(args.repository, issue)
start_task = start_conversation(prompt, args.repository, args.issue_number)
app_conversation_id = start_task.get('app_conversation_id')
conversation_url = ''
if not app_conversation_id:
task_id = start_task.get('id')
if not task_id:
raise RuntimeError(f'Missing id in start task response: {start_task}')
ready_task = poll_start_task(
task_id,
args.poll_interval_seconds,
args.max_wait_seconds,
)
app_conversation_id = ready_task.get('app_conversation_id')
if not app_conversation_id:
raise RuntimeError(f'Missing app_conversation_id in response: {ready_task}')
conversation = poll_conversation(
app_conversation_id,
args.poll_interval_seconds,
args.max_wait_seconds,
)
conversation_url = (
conversation.get('conversation_url')
or f'{OPENHANDS_BASE_URL}/conversations/{app_conversation_id}'
)
session_api_key_value = conversation.get('session_api_key')
if session_api_key_value and not isinstance(session_api_key_value, str):
raise RuntimeError(
'session_api_key had unexpected type in the OpenHands conversation: '
f'{type(session_api_key_value).__name__}'
)
session_api_key = session_api_key_value or ''
agent_server_url = extract_agent_server_url(conversation_url)
agent_text = ''
if agent_server_url and session_api_key:
try:
agent_text = fetch_agent_server_final_response(
app_conversation_id,
agent_server_url,
session_api_key,
)
except RuntimeError:
agent_text = ''
if not agent_text:
events = fetch_app_server_events(app_conversation_id)
try:
agent_text = extract_last_agent_text(events)
except RuntimeError as exc:
if not session_api_key:
raise RuntimeError(
'App server events did not contain assistant text and '
'session_api_key was missing from the OpenHands conversation'
) from exc
if not agent_server_url:
raise RuntimeError(
'App server events did not contain assistant text and cannot '
'extract agent server URL from conversation URL: '
f'{conversation_url}'
) from exc
events = fetch_agent_server_events(
app_conversation_id,
agent_server_url,
session_api_key,
)
agent_text = extract_last_agent_text(events)
result = normalize_result(parse_agent_json(agent_text))
result['issue_number'] = args.issue_number
result['repository'] = args.repository
result['app_conversation_id'] = app_conversation_id
result['conversation_url'] = conversation_url
result['agent_response'] = agent_text
output_path = Path(args.output)
try:
output_path.write_text(json.dumps(result, indent=2, ensure_ascii=False) + '\n')
except OSError as exc:
raise RuntimeError(f'Failed to write output to {output_path}: {exc}') from exc
print(
json.dumps(
{
'issue_number': result.get('issue_number'),
'should_apply_label': result.get('should_apply_label'),
'confidence': result.get('confidence'),
'conversation_url': result.get('conversation_url'),
'output': str(output_path),
},
ensure_ascii=False,
)
)
return 0
if __name__ == '__main__':
try:
raise SystemExit(main())
except Exception as exc: # pragma: no cover - CLI boundary
print(f'Error: {exc}', file=sys.stderr)
raise SystemExit(1)
@@ -0,0 +1,291 @@
from __future__ import annotations
import argparse
import importlib.util
import itertools
import json
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
MODULE_COUNTER = itertools.count()
def load_module(script_name: str):
path = ROOT / 'scripts' / script_name
module_name = f'test_{path.stem}_{next(MODULE_COUNTER)}'
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
raise AssertionError(f'Unable to load module from {path}')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def make_agent_message(text: str) -> dict:
return {
'kind': 'MessageEvent',
'source': 'agent',
'llm_message': {'content': [{'type': 'text', 'text': text}]},
}
def test_extract_label_names_normalizes_issue_labels():
module = load_module('issue_good_first_issue_check_openhands.py')
label_names = module.extract_label_names(
{
'labels': [
{'name': 'bug'},
'good first issue',
{'name': 'Bug'},
{'name': ''},
None,
]
}
)
assert label_names == ['Bug', 'bug', 'good first issue']
def test_build_prompt_includes_issue_labels_and_criteria():
module = load_module('issue_good_first_issue_check_openhands.py')
prompt = module.build_prompt(
'OpenHands/OpenHands',
{
'number': 123,
'title': 'Clarify triage behavior',
'body': 'The issue needs a clear automated path.',
'html_url': 'https://github.com/OpenHands/OpenHands/issues/123',
'labels': [{'name': 'bug'}, {'name': 'frontend'}],
},
)
assert 'Issue labels (JSON array): ["bug", "frontend"]' in prompt
assert 'Do NOT treat “not a duplicate” as positive evidence on its own.' in prompt
assert 'All of the following must be true to apply `good first issue`:' in prompt
def test_normalize_result_requires_high_confidence_and_no_disqualifiers():
module = load_module('issue_good_first_issue_check_openhands.py')
normalized = module.normalize_result(
{
'should_apply_label': True,
'confidence': 'medium',
'summary': 'Looks promising',
'criteria_met': ['narrow scope', 'clear request'],
'disqualifiers': [],
}
)
assert normalized['should_apply_label'] is False
assert normalized['confidence'] == 'medium'
normalized = module.normalize_result(
{
'should_apply_label': True,
'confidence': 'high',
'summary': 'Looks promising',
'criteria_met': ['narrow scope', 'clear request'],
'disqualifiers': ['needs enterprise context'],
}
)
assert normalized['should_apply_label'] is False
assert normalized['disqualifiers'] == ['needs enterprise context']
def test_normalize_result_limits_string_lists():
module = load_module('issue_good_first_issue_check_openhands.py')
normalized = module.normalize_result(
{
'should_apply_label': True,
'confidence': 'high',
'summary': 'Looks promising',
'criteria_met': ['1', '2', '3', '4', '5', '6'],
'disqualifiers': [None, '', 'too broad', 42, ''],
}
)
assert normalized['criteria_met'] == ['1', '2', '3', '4', '5']
assert normalized['disqualifiers'] == ['too broad', '42']
assert normalized['should_apply_label'] is False
def test_good_first_issue_main_writes_result_from_final_response(monkeypatch, tmp_path):
module = load_module('issue_good_first_issue_check_openhands.py')
output_path = tmp_path / 'result.json'
monkeypatch.setattr(
module,
'parse_args',
lambda: argparse.Namespace(
repository='OpenHands/OpenHands',
issue_number=123,
output=str(output_path),
poll_interval_seconds=1,
max_wait_seconds=10,
),
)
monkeypatch.setattr(
module,
'fetch_issue',
lambda repository, issue_number: {
'number': issue_number,
'title': 'Issue title',
'body': 'Issue body',
'html_url': f'https://github.com/{repository}/issues/{issue_number}',
'labels': [{'name': 'bug'}],
},
)
monkeypatch.setattr(
module,
'start_conversation',
lambda *args, **kwargs: {'app_conversation_id': 'conv-123'},
)
monkeypatch.setattr(
module,
'poll_conversation',
lambda app_conversation_id, poll_interval_seconds, max_wait_seconds: {
'conversation_url': 'https://runtime.example/api/conversations/conv-123',
'session_api_key': 'session-key',
},
)
monkeypatch.setattr(
module,
'fetch_agent_server_final_response',
lambda app_conversation_id, agent_server_url, session_api_key: json.dumps(
{
'should_apply_label': True,
'confidence': 'high',
'summary': 'Narrow, clear, and easy to validate.',
'criteria_met': ['narrow scope', 'clear expected outcome'],
'disqualifiers': [],
}
),
)
monkeypatch.setattr(
module,
'fetch_app_server_events',
lambda app_conversation_id: pytest.fail(
'fetch_app_server_events should not run'
),
)
monkeypatch.setattr(
module,
'fetch_agent_server_events',
lambda *args, **kwargs: pytest.fail('fetch_agent_server_events should not run'),
)
assert module.main() == 0
result = json.loads(output_path.read_text())
assert result['should_apply_label'] is True
assert result['confidence'] == 'high'
assert result['criteria_met'] == ['narrow scope', 'clear expected outcome']
assert result['repository'] == 'OpenHands/OpenHands'
def test_good_first_issue_main_falls_back_to_app_server_events(monkeypatch, tmp_path):
module = load_module('issue_good_first_issue_check_openhands.py')
output_path = tmp_path / 'result.json'
monkeypatch.setattr(
module,
'parse_args',
lambda: argparse.Namespace(
repository='OpenHands/OpenHands',
issue_number=123,
output=str(output_path),
poll_interval_seconds=1,
max_wait_seconds=10,
),
)
monkeypatch.setattr(
module,
'fetch_issue',
lambda repository, issue_number: {
'number': issue_number,
'title': 'Issue title',
'body': 'Issue body',
'html_url': f'https://github.com/{repository}/issues/{issue_number}',
'labels': [{'name': 'enhancement'}],
},
)
monkeypatch.setattr(
module,
'start_conversation',
lambda *args, **kwargs: {'app_conversation_id': 'conv-123'},
)
monkeypatch.setattr(
module,
'poll_conversation',
lambda app_conversation_id, poll_interval_seconds, max_wait_seconds: {
'conversation_url': 'https://runtime.example/api/conversations/conv-123',
'session_api_key': 'session-key',
},
)
monkeypatch.setattr(
module,
'fetch_agent_server_final_response',
lambda *args, **kwargs: '',
)
monkeypatch.setattr(
module,
'fetch_app_server_events',
lambda app_conversation_id: [
make_agent_message(
json.dumps(
{
'should_apply_label': False,
'confidence': 'medium',
'summary': 'Needs more discovery before a newcomer can pick it up.',
'criteria_met': ['clear user-facing outcome'],
'disqualifiers': ['scope is still too broad'],
}
)
)
],
)
monkeypatch.setattr(
module,
'fetch_agent_server_events',
lambda *args, **kwargs: pytest.fail('fetch_agent_server_events should not run'),
)
assert module.main() == 0
result = json.loads(output_path.read_text())
assert result['should_apply_label'] is False
assert result['disqualifiers'] == ['scope is still too broad']
def test_good_first_issue_main_rejects_pull_requests(monkeypatch, tmp_path):
module = load_module('issue_good_first_issue_check_openhands.py')
monkeypatch.setattr(
module,
'parse_args',
lambda: argparse.Namespace(
repository='OpenHands/OpenHands',
issue_number=123,
output=str(tmp_path / 'result.json'),
poll_interval_seconds=1,
max_wait_seconds=10,
),
)
monkeypatch.setattr(
module,
'fetch_issue',
lambda repository, issue_number: {
'number': issue_number,
'pull_request': {'url': 'https://example.test/pr/123'},
},
)
with pytest.raises(RuntimeError, match='is a pull request, not an issue'):
module.main()