Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -60,19 +60,22 @@ def apply_entry(
args = ['issue', 'create', '--repo', repo, '--title', entry['title'], '--body', body]
for label in labels:
args += ['--label', label]
issue_type = entry.get('issue_type')
result = None
# Resolved before the issue is created, never retried after it: a
# failed `gh issue create --type` has already created the issue, so a
# second attempt without the type is a duplicate rather than a repair.
wanted_type = entry.get('issue_type')
issue_type = (
_github.match_issue_type(wanted_type, _github.existing_issue_types(repo))
if wanted_type
else None
)
if wanted_type and not issue_type:
_summary.write_step_summary(
f'Dropped issue type "{wanted_type}", which this repo does not have.'
)
if issue_type:
result = _github.gh(*args, '--type', issue_type, check=False)
if result.returncode != 0:
_summary.write_step_summary(
f'`gh issue create --type {issue_type}` failed ({result.stderr.strip()}); '
'retrying without --type.'
)
result = None
if result is None:
result = _github.gh(*args)
return result.stdout.strip()
args += ['--type', issue_type]
return _github.gh(*args).stdout.strip()
else:
target = entry.get('target_issue', default_target)
_github.gh('issue', 'comment', str(target), '--repo', repo, '--body', body)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,34 @@ def drop_inapplicable_fields(entry: Any) -> tuple[Any, list[str]]:
return {k: v for k, v in entry.items() if k not in dropped}, dropped


def coerce_target_issue(entry: Any) -> Any:
"""Turn a `target_issue` the model wrote as text into the integer it means.

Issues are written `#44` everywhere a person sees them, and the model
returns that string often enough to matter: the schema wants an integer, so
the whole envelope was rejected and a usable body was thrown away for a `#`.
Anything that is not a plain issue reference is left exactly as it is, for
the schema to reject on its own terms.
"""
if not isinstance(entry, dict) or not isinstance(entry.get('target_issue'), str):
return entry
text = entry['target_issue'].strip().removeprefix('#')
if not text.isdigit():
return entry
return {**entry, 'target_issue': int(text)}


def normalise_envelope(envelope: Any) -> tuple[Any, list[str]]:
"""Drop inapplicable fields from the envelope and each `also` entry."""
if not isinstance(envelope, dict):
return envelope, []
cleaned, dropped = drop_inapplicable_fields(envelope)
cleaned, dropped = drop_inapplicable_fields(coerce_target_issue(envelope))
notes = [f'envelope: {f}' for f in dropped]
also = cleaned.get('also')
if isinstance(also, list):
entries: list[Any] = []
for i, entry in enumerate(also):
entry, entry_dropped = drop_inapplicable_fields(entry)
entry, entry_dropped = drop_inapplicable_fields(coerce_target_issue(entry))
notes += [f'envelope.also[{i}]: {f}' for f in entry_dropped]
entries.append(entry)
cleaned = {**cleaned, 'also': entries}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,38 @@ def existing_labels(repo: str) -> set[str]:
def filter_labels(labels: list[str], available: set[str]) -> list[str]:
"""Drop labels that don't already exist in the repo (never auto-create)."""
return [label for label in labels if label in available]


def existing_issue_types(repo: str) -> set[str]:
"""Return the issue type names enabled for `repo`, which may be none.

Issue types come from the owning organisation, so a repo can have none at
all: a personal fork returns `null` here, and so does any repo whose org
has not enabled them.
"""
owner, _, name = repo.partition('/')
query = (
'query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) '
'{ issueTypes(first: 50) { nodes { name isEnabled } } } }'
)
data = (
gh_json(
'api', 'graphql', '-f', f'query={query}', '-F', f'owner={owner}', '-F', f'name={name}'
)
or {}
)
repository = (data.get('data') or {}).get('repository') or {}
types = repository.get('issueTypes') or {}
return {node['name'] for node in types.get('nodes') or [] if node.get('isEnabled')}


def match_issue_type(issue_type: str | None, available: set[str]) -> str | None:
"""Resolve `issue_type` to the repo's own spelling, or `None` if it has no such type.

The model is asked for a lowercase name, and GitHub's are capitalised, so
the match ignores case and the repo's spelling is what gets passed on.
"""
if not issue_type:
return None
folded = issue_type.casefold()
return next((name for name in sorted(available) if name.casefold() == folded), None)
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from __future__ import annotations

import json
import urllib.error
import urllib.request
from typing import Any

Expand All @@ -30,9 +31,9 @@ def call_openrouter(
"""POST the prompt to OpenRouter with the envelope schema, return the parsed JSON.

Uses urllib rather than requests so the script has no third-party
dependencies at all. urlopen raises HTTPError (a subclass of OSError) on a
non-2xx response, which main() treats the same as any other OpenRouter
failure: fall back to the plain body.
dependencies at all. A non-2xx response is raised as an error carrying
OpenRouter's own explanation, which main() treats the same as any other
OpenRouter failure: fall back to the plain body.
"""
payload = {
'model': model,
Expand All @@ -55,8 +56,38 @@ def call_openrouter(
headers={'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'},
method='POST',
)
# S310: the URL is a literal https endpoint, not caller-controlled.
with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310
body = json.loads(response.read().decode())
try:
# S310: the URL is a literal https endpoint, not caller-controlled.
with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310
body = json.loads(response.read().decode())
except urllib.error.HTTPError as exc:
# `str(exc)` is only ever "HTTP Error 400: Bad Request", which says
# nothing about which of the model, the key or the schema OpenRouter
# objected to. The reason is in the response body, and reading it is
# the difference between a glance and an afternoon.
raise RuntimeError(f'{exc} - {_error_detail(exc)}') from exc
content = body['choices'][0]['message']['content']
return json.loads(content)


def _error_detail(exc: urllib.error.HTTPError) -> str:
"""OpenRouter's own explanation of a non-2xx, as far as it can be read.

The body is JSON in the ordinary case and can be anything at all when a
proxy answers instead, so nothing here is allowed to raise: an unreadable
explanation must not replace the status code that came with it.
"""
try:
raw = exc.read().decode(errors='replace').strip()
except Exception: # noqa: BLE001 - any read failure means no detail, not a crash.
return 'no response body'
if not raw:
return 'empty response body'
try:
parsed = json.loads(raw)
except ValueError:
return raw[:500]
error = parsed.get('error') if isinstance(parsed, dict) else None
if isinstance(error, dict) and error.get('message'):
return str(error['message'])[:500]
return raw[:500]
Loading