"""Sprint commit ranking — pluggable rankers (heuristic now, agent later).""" from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Protocol from steering.eval_context import SteeringEvalContext COMMITTABLE_STATUSES = frozenset({"new", "triaged", "accepted"}) PLANABLE_CYCLE_STATUSES = frozenset({"planned", "active"}) OPEN_ACTION = frozenset({"open", "ready", "in_progress", "blocked", "review_required"}) _DONE_ACTION = frozenset({"done", "discarded"}) @dataclass class SprintCommitCandidate: item: dict[str, Any] dependency_refs: list[dict[str, Any]] = field(default_factory=list) dependency_blocked: bool = False dependency_reason: str | None = None @dataclass class RankedSprintCommitCandidate: candidate: SprintCommitCandidate rank: int score: float reason_code: str summary: str factors: list[dict[str, Any]] ranker_key: str class SprintCommitRanker(Protocol): key: str def rank( self, ctx: SteeringEvalContext, *, candidates: list[SprintCommitCandidate], read_models: dict[str, Any], target_work_cycle_id: str, ) -> list[RankedSprintCommitCandidate]: ... _RANKERS: dict[str, SprintCommitRanker] = {} def register_sprint_commit_ranker(ranker: SprintCommitRanker) -> None: _RANKERS[ranker.key] = ranker def resolve_sprint_commit_ranker(ctx: SteeringEvalContext) -> SprintCommitRanker: """ Ranker-Auswahl — nicht hardcodiert. Zukünftig: Governance/Method-Metadata (`proposal_rankers.sprint_commit=agent_v1`) oder Actor-Slot mit auditiertem Agent-Scoring. Default: heuristic_v0 (Fallback). """ configured = ( (ctx.operating_context.get("proposal_rankers") or {}).get("sprint_commit") or (ctx.operating_context.get("method_capabilities") or {}).get( "sprint_commit_ranker" ) ) if configured and configured in _RANKERS: return _RANKERS[configured] return _RANKERS["heuristic_v0"] def _action_by_id(actions: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: return {str(a["id"]): a for a in actions if a.get("id")} def build_sprint_commit_candidates( ctx: SteeringEvalContext, *, target_work_cycle_id: str, ) -> list[SprintCommitCandidate]: """Kandidaten + Abhängigkeitskontext — keine feste Bug/Story-Reihenfolge.""" vocabulary = ctx.backlog_vocabulary convertible = frozenset( vocabulary.get("convertible_kinds") or ["story", "bug", "issue"] ) actions_by_id = _action_by_id(ctx.actions) candidates: list[SprintCommitCandidate] = [] for item in ctx.backlog_items: if item.get("status") not in COMMITTABLE_STATUSES: continue if item.get("item_kind") == "epic": continue if (item.get("item_kind") or "story") not in convertible: continue refs: list[dict[str, Any]] = [] blocked = False block_reason: str | None = None parent_action_id = item.get("parent_action_id") if parent_action_id: parent = actions_by_id.get(str(parent_action_id)) refs.append( { "ref_type": "parent_action", "ref_id": str(parent_action_id), "status": parent.get("status") if parent else "missing", "title": parent.get("title") if parent else None, } ) if not parent: blocked = True block_reason = "missing_parent_action" elif parent.get("status") not in _DONE_ACTION: in_target_sprint = ( parent.get("work_cycle_id") and str(parent["work_cycle_id"]) == str(target_work_cycle_id) ) if not in_target_sprint: blocked = True block_reason = "waiting_on_feature_action" parent_backlog_id = item.get("parent_backlog_id") if parent_backlog_id: refs.append( { "ref_type": "parent_epic", "ref_id": str(parent_backlog_id), } ) candidates.append( SprintCommitCandidate( item=item, dependency_refs=refs, dependency_blocked=blocked, dependency_reason=block_reason, ) ) return candidates def resolve_target_work_cycle(ctx: SteeringEvalContext) -> dict[str, Any] | None: cycles = [ c for c in ctx.work_cycles if c.get("status") in PLANABLE_CYCLE_STATUSES ] if not cycles: return None if ctx.horizon.work_cycle_id: match = next( (c for c in cycles if str(c["id"]) == str(ctx.horizon.work_cycle_id)), None, ) if match: return match planned = sorted( [c for c in cycles if c.get("status") == "planned"], key=lambda c: (c.get("sort_order", 0), str(c["id"])), ) if planned: return planned[0] active = sorted( [c for c in cycles if c.get("status") == "active"], key=lambda c: (c.get("sort_order", 0), str(c["id"])), ) return active[0] if active else None