The author allocator could select an ineligible issue for two independent reasons, both fixed here. Defect 1 — candidate truncation before ranking. The loader fetched the complete open inventory via api_get_all, then sliced it to `limit` items before constructing any WorkCandidate. Because every status:ready issue ties at priority 20 and the tie breaks on lowest number, the slice — not the ranking — decided the winner, so the same query returned different answers at different `limit` values. Candidate construction now consumes the full listing; `limit` bounds the reported skip list only, and any such truncation is reported explicitly rather than silently. Defect 2 — dependency state inferred from body substrings. Only "blocked on #" and "downstream of #" were recognized, so the repository's canonical `Depends: #N, #N` field never set dependency_unmet and dependency-blocked issues were emitted as eligible. The new allocator_dependencies module parses the canonical declaration into structured references and resolves each against live issue state: open means unmet, closed means met, and unavailable evidence fails closed. The complete open listing proves openness without extra calls; anything absent from it is confirmed by a cached targeted lookup instead of being assumed closed. The legacy "blocked on #" marker still parses. A failed listing now marks the inventory incomplete and the tool fails closed instead of ranking a partial set. Selection already advanced past a skipped candidate; with dependencies resolved correctly that fall-through now actually engages, and is covered by a regression. Tests: 35 new cases across parser, resolver, loader, and an MCP-level gitea_allocate_next_work regression, including limit-invariant selection over a 73-candidate inventory and a guard proving pre-ranking truncation would change the winner. No issue number is special-cased.
151 lines
5.0 KiB
Python
151 lines
5.0 KiB
Python
"""Canonical dependency parsing and live-state resolution for the allocator (#758).
|
|
|
|
The work allocator previously inferred dependency state from two lowercase
|
|
substrings (``"blocked on #"`` / ``"downstream of #"``). The repository's
|
|
canonical declaration form is a ``Depends:`` field inside the issue body's
|
|
linkage line, for example::
|
|
|
|
* Parent: #631 · Depends: #633, #634 · Related: #630, #434
|
|
|
|
That form matched neither substring, so dependency-blocked issues were emitted
|
|
as eligible candidates. This module replaces substring inference with:
|
|
|
|
1. structured parsing of ``Depends:`` declarations into issue references, and
|
|
2. resolution of each reference against **live issue state**, never body text.
|
|
|
|
Both halves fail closed: a reference whose state cannot be established makes
|
|
the owning candidate ineligible rather than assignable.
|
|
|
|
No issue number is special-cased here (#758 AC4/AC14); the parser is driven
|
|
entirely by the declaration syntax.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Callable, Iterable
|
|
|
|
# Live issue states, as reported by Gitea.
|
|
DEP_STATE_OPEN = "open"
|
|
DEP_STATE_CLOSED = "closed"
|
|
|
|
# "Depends:" / "Depends on:" introduces the declaration. "Dependencies" does
|
|
# not match: after "depend" it continues with "e", not "s".
|
|
_DEPENDS_KEYWORD = re.compile(r"depends(?:\s+on)?\s*:?\s*", re.IGNORECASE)
|
|
|
|
# Immediately after the keyword, consume only the contiguous run of issue
|
|
# references. Anchoring the run this way means the declaration ends naturally
|
|
# at the next separator ("·", newline) or sibling field ("Related:"), without
|
|
# needing to enumerate separators.
|
|
_DEP_RUN = re.compile(
|
|
r"\s*(#\d+(?:\s*(?:,|and|&)\s*#\d+)*)",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
# Legacy marker retained so previously-recognized bodies keep working.
|
|
_LEGACY_BLOCKED = re.compile(r"blocked\s+on\s+#(\d+)", re.IGNORECASE)
|
|
|
|
_ISSUE_REF = re.compile(r"#(\d+)")
|
|
|
|
|
|
def parse_dependency_refs(body: str | None) -> tuple[int, ...]:
|
|
"""Extract declared dependency issue numbers from an issue *body*.
|
|
|
|
Recognizes the canonical ``Depends: #N, #N`` field (including the
|
|
``Depends on`` spelling) plus the legacy ``blocked on #N`` marker.
|
|
Returns references in first-seen order with duplicates removed. Malformed
|
|
or absent declarations yield an empty tuple rather than raising.
|
|
"""
|
|
if not body:
|
|
return ()
|
|
|
|
refs: list[int] = []
|
|
|
|
def _add(value: str) -> None:
|
|
number = int(value)
|
|
if number > 0 and number not in refs:
|
|
refs.append(number)
|
|
|
|
for match in _DEPENDS_KEYWORD.finditer(body):
|
|
run = _DEP_RUN.match(body, match.end())
|
|
if not run:
|
|
continue
|
|
for ref in _ISSUE_REF.findall(run.group(1)):
|
|
_add(ref)
|
|
|
|
for ref in _LEGACY_BLOCKED.findall(body):
|
|
_add(ref)
|
|
|
|
return tuple(refs)
|
|
|
|
|
|
def resolve_dependency_state(
|
|
refs: Iterable[int],
|
|
state_lookup: Callable[[int], str | None],
|
|
*,
|
|
subject: str = "candidate",
|
|
) -> dict:
|
|
"""Resolve declared *refs* against live issue state.
|
|
|
|
*state_lookup* maps an issue number to its live state string, or to
|
|
``None`` when that evidence could not be obtained. A reference is:
|
|
|
|
* **met** when live state is ``closed``;
|
|
* **unmet** when live state is any other live value (``open``, etc.);
|
|
* **unavailable** when state is ``None`` or the lookup raises.
|
|
|
|
Unmet *and* unavailable both mark the candidate ineligible (#758 AC6/AC7):
|
|
allocation must never assume a dependency is satisfied.
|
|
"""
|
|
unmet: list[int] = []
|
|
unavailable: list[int] = []
|
|
met: list[int] = []
|
|
|
|
for ref in refs:
|
|
try:
|
|
number = int(ref)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
try:
|
|
state = state_lookup(number)
|
|
except Exception: # noqa: BLE001 — unavailable evidence fails closed
|
|
state = None
|
|
normalized = (str(state).strip().lower() if state is not None else "") or None
|
|
if normalized is None:
|
|
unavailable.append(number)
|
|
elif normalized == DEP_STATE_CLOSED:
|
|
met.append(number)
|
|
else:
|
|
unmet.append(number)
|
|
|
|
reason: str | None = None
|
|
if unmet and unavailable:
|
|
reason = (
|
|
f"{subject} has unresolved dependencies "
|
|
f"{_fmt(unmet)} and unverifiable dependencies {_fmt(unavailable)} "
|
|
"(fail closed)"
|
|
)
|
|
elif unmet:
|
|
reason = (
|
|
f"{subject} depends on unresolved issue(s) {_fmt(unmet)}; "
|
|
"they are not closed"
|
|
)
|
|
elif unavailable:
|
|
reason = (
|
|
f"{subject} dependency evidence unavailable for {_fmt(unavailable)} "
|
|
"(fail closed)"
|
|
)
|
|
|
|
return {
|
|
"refs": tuple(int(x) for x in refs),
|
|
"met": tuple(met),
|
|
"unmet": tuple(unmet),
|
|
"unavailable": tuple(unavailable),
|
|
"dependency_unmet": bool(unmet or unavailable),
|
|
"reason": reason,
|
|
}
|
|
|
|
|
|
def _fmt(numbers: Iterable[int]) -> str:
|
|
return ", ".join(f"#{n}" for n in numbers)
|