All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 4m8s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 19s
Test Suite / playwright-smoke (push) Successful in 13s
Keine feste Bug-zuerst-Policy: heuristic_v0 als Fallback, agent_v1-Slot vorbereitet, parent_action-Abhaengigkeiten und factors[] fuer spaetere KI-Steuerung. Co-authored-by: Cursor <cursoragent@cursor.com>
174 lines
5.2 KiB
Python
174 lines
5.2 KiB
Python
"""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
|