fix: exclude epic and child-only containers from allocator selection (Closes #844)
Epics and parent issues whose body delegates implementation to children are skipped before ranking with structured reason epic_or_child_only_container. Title-only "epic" mentions without body/label evidence remain eligible. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
"""Allocator epic / child-only container pre-rank exclusion (#844).
|
||||
|
||||
Covers:
|
||||
* Issue #631-shaped child-only epic is excluded before ranking.
|
||||
* Implementable child issues remain eligible and can be selected.
|
||||
* Ordinary issues that merely mention "epic" in title/body are not excluded.
|
||||
* Excluded containers never receive assignments or workflow leases.
|
||||
* Structured skip reason ``epic_or_child_only_container`` is reported.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from allocator_service import (
|
||||
OUTCOME_ASSIGNED,
|
||||
OUTCOME_PREVIEW,
|
||||
SKIP_EPIC_OR_CHILD_ONLY_CONTAINER,
|
||||
WorkCandidate,
|
||||
allocate_next_work,
|
||||
classify_epic_or_child_only_container,
|
||||
)
|
||||
from control_plane_db import ControlPlaneDB
|
||||
|
||||
REMOTE = "prgs"
|
||||
ORG = "Scaled-Tech-Consulting"
|
||||
REPO = "Gitea-Tools"
|
||||
|
||||
# Minimal body mirroring issue #631 authoritative scope language.
|
||||
_EPIC_631_BODY = """
|
||||
## Scope (umbrella)
|
||||
|
||||
This epic owns the **product roadmap and linkage** for the Web Console.
|
||||
Implementation is delivered via child issues only.
|
||||
|
||||
## Explicit non-goals
|
||||
|
||||
* Do not implement product features in this epic issue itself.
|
||||
* No product feature implementation is claimed complete solely on this epic.
|
||||
"""
|
||||
|
||||
_CHILD_BODY = """
|
||||
## Problem
|
||||
|
||||
Operators need a workflow-event timeline model for Phase 1.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Timeline model API exists
|
||||
"""
|
||||
|
||||
|
||||
def _issue(
|
||||
number: int,
|
||||
*,
|
||||
title: str = "",
|
||||
body: str = "",
|
||||
labels: tuple[str, ...] = ("status:ready", "type:feature"),
|
||||
priority: int = 20,
|
||||
) -> WorkCandidate:
|
||||
return WorkCandidate(
|
||||
kind="issue",
|
||||
number=number,
|
||||
state="open",
|
||||
labels=labels,
|
||||
title=title or f"issue {number}",
|
||||
body=body,
|
||||
priority=priority,
|
||||
)
|
||||
|
||||
|
||||
class ClassifyEpicContainerTest(unittest.TestCase):
|
||||
def test_631_shaped_body_and_title_is_container(self) -> None:
|
||||
c = _issue(
|
||||
631,
|
||||
title="Epic: MCP Control Plane Web Console",
|
||||
body=_EPIC_631_BODY,
|
||||
)
|
||||
is_c, detail = classify_epic_or_child_only_container(c)
|
||||
self.assertTrue(is_c)
|
||||
self.assertIsNotNone(detail)
|
||||
self.assertIn("body_marker", detail or "")
|
||||
|
||||
def test_body_markers_without_epic_title(self) -> None:
|
||||
c = _issue(
|
||||
900,
|
||||
title="Control plane roadmap tracker",
|
||||
body="Implementation is delivered via child issues only.",
|
||||
)
|
||||
is_c, _ = classify_epic_or_child_only_container(c)
|
||||
self.assertTrue(is_c)
|
||||
|
||||
def test_epic_label_alone_is_container(self) -> None:
|
||||
c = _issue(
|
||||
901,
|
||||
title="Roadmap linkage",
|
||||
body="Track children.",
|
||||
labels=("status:ready", "type:epic"),
|
||||
)
|
||||
is_c, detail = classify_epic_or_child_only_container(c)
|
||||
self.assertTrue(is_c)
|
||||
self.assertIn("type:epic", detail or "")
|
||||
|
||||
def test_title_epic_prefix_alone_not_container(self) -> None:
|
||||
"""Title-only 'Epic:' without body scope evidence stays eligible (#844)."""
|
||||
c = _issue(
|
||||
902,
|
||||
title="Epic: something mentioned only in title",
|
||||
body="Implement a concrete fix for the allocator skip list.",
|
||||
)
|
||||
is_c, detail = classify_epic_or_child_only_container(c)
|
||||
self.assertFalse(is_c)
|
||||
self.assertIsNone(detail)
|
||||
|
||||
def test_incidental_epic_word_not_container(self) -> None:
|
||||
c = _issue(
|
||||
903,
|
||||
title="Document epic handoff conventions",
|
||||
body=(
|
||||
"Update the docs so implementable issues that mention an epic "
|
||||
"remain independently executable."
|
||||
),
|
||||
)
|
||||
is_c, _ = classify_epic_or_child_only_container(c)
|
||||
self.assertFalse(is_c)
|
||||
|
||||
def test_prs_never_classified(self) -> None:
|
||||
pr = WorkCandidate(
|
||||
kind="pr",
|
||||
number=10,
|
||||
state="open",
|
||||
title="Epic: fake",
|
||||
body="Implementation is delivered via child issues only.",
|
||||
head_sha="a" * 40,
|
||||
priority=5,
|
||||
)
|
||||
is_c, _ = classify_epic_or_child_only_container(pr)
|
||||
self.assertFalse(is_c)
|
||||
|
||||
|
||||
class AllocateEpicContainerExclusionTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
|
||||
|
||||
def _alloc(self, candidates, **kwargs):
|
||||
defaults = dict(
|
||||
session_id="sess-844",
|
||||
role="author",
|
||||
remote=REMOTE,
|
||||
org=ORG,
|
||||
repo=REPO,
|
||||
profile_name="prgs-author",
|
||||
username="jcwalker3",
|
||||
claims={},
|
||||
apply=False,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return allocate_next_work(self.db, candidates=candidates, **defaults)
|
||||
|
||||
def test_631_shaped_epic_excluded_child_selected(self) -> None:
|
||||
epic = _issue(
|
||||
631,
|
||||
title="Epic: MCP Control Plane Web Console",
|
||||
body=_EPIC_631_BODY,
|
||||
)
|
||||
child = _issue(
|
||||
637,
|
||||
title="Web Console: Workflow-event timeline model (Phase 1)",
|
||||
body=_CHILD_BODY,
|
||||
)
|
||||
res = self._alloc([epic, child], apply=False)
|
||||
self.assertTrue(res["success"], res)
|
||||
self.assertEqual(res["outcome"], OUTCOME_PREVIEW)
|
||||
self.assertEqual(res["selected"]["number"], 637)
|
||||
skipped = {s["number"]: s for s in res["skipped"]}
|
||||
self.assertIn(631, skipped)
|
||||
self.assertEqual(
|
||||
skipped[631]["reason_code"], SKIP_EPIC_OR_CHILD_ONLY_CONTAINER
|
||||
)
|
||||
self.assertIn(SKIP_EPIC_OR_CHILD_ONLY_CONTAINER, skipped[631]["reason"])
|
||||
|
||||
def test_container_cannot_receive_assignment_or_lease(self) -> None:
|
||||
epic = _issue(
|
||||
631,
|
||||
title="Epic: MCP Control Plane Web Console",
|
||||
body=_EPIC_631_BODY,
|
||||
)
|
||||
res = self._alloc([epic], apply=True)
|
||||
self.assertTrue(res["success"], res)
|
||||
# Only container present → no safe work; never assigned_work.
|
||||
self.assertNotEqual(res["outcome"], OUTCOME_ASSIGNED)
|
||||
self.assertIsNone(res.get("assignment"))
|
||||
self.assertIsNone(res.get("selected"))
|
||||
skipped = {s["number"]: s for s in res["skipped"]}
|
||||
self.assertEqual(
|
||||
skipped[631]["reason_code"], SKIP_EPIC_OR_CHILD_ONLY_CONTAINER
|
||||
)
|
||||
# No lease row for the epic.
|
||||
leases = self.db.list_active_leases(
|
||||
remote=REMOTE, org=ORG, repo=REPO
|
||||
) if hasattr(self.db, "list_active_leases") else []
|
||||
# Prefer generic inventory if available.
|
||||
if not leases and hasattr(self.db, "list_leases"):
|
||||
leases = self.db.list_leases(remote=REMOTE, org=ORG, repo=REPO)
|
||||
for lease in leases or []:
|
||||
work_number = lease.get("work_number") if isinstance(lease, dict) else None
|
||||
self.assertNotEqual(work_number, 631)
|
||||
|
||||
def test_incidental_epic_title_remains_eligible(self) -> None:
|
||||
ordinary = _issue(
|
||||
700,
|
||||
title="Document epic handoff conventions",
|
||||
body="Write runbook text about epic vs child issues.",
|
||||
)
|
||||
res = self._alloc([ordinary], apply=False)
|
||||
self.assertTrue(res["success"], res)
|
||||
self.assertEqual(res["selected"]["number"], 700)
|
||||
self.assertEqual(res["skipped"], [])
|
||||
|
||||
def test_apply_selects_child_not_epic(self) -> None:
|
||||
epic = _issue(
|
||||
631,
|
||||
title="Epic: MCP Control Plane Web Console",
|
||||
body=_EPIC_631_BODY,
|
||||
)
|
||||
child = _issue(
|
||||
637,
|
||||
title="Web Console: Workflow-event timeline model (Phase 1)",
|
||||
body=_CHILD_BODY,
|
||||
)
|
||||
res = self._alloc([epic, child], apply=True)
|
||||
self.assertTrue(res["success"], res)
|
||||
self.assertEqual(res["outcome"], OUTCOME_ASSIGNED)
|
||||
self.assertEqual(res["selected"]["number"], 637)
|
||||
self.assertEqual(res["assignment"]["work_number"], 637)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user