Files
Gitea-Tools/allocator_dependencies.py
T
sysadmin ad59053cd7 fix: rank complete allocator inventory and resolve declared dependencies (Closes #758)
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.
2026-07-19 14:12:04 -04:00

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)