sync identified tasks of existing workplans on projection reset
Hub tasks now carry record_id, so reset_repository_projection can create missing derived tasks, update title/status, and cancel open tasks the forge no longer derives. Rows with no record_id are left alone. Assistant: grok Assistant-Session: 01a04996-76e8-7f53-b971-1885cfbed436
This commit is contained in:
parent
bb1b8f4c95
commit
3204baf8fd
2 changed files with 210 additions and 13 deletions
|
|
@ -577,6 +577,9 @@ class ResetOutcome:
|
|||
refused: list[dict[str, Any]] = field(default_factory=list)
|
||||
# Identifiers freed from rows retired before retirement released them.
|
||||
released: list[str] = field(default_factory=list)
|
||||
created_tasks: list[str] = field(default_factory=list)
|
||||
updated_tasks: list[str] = field(default_factory=list)
|
||||
cancelled_tasks: list[str] = field(default_factory=list)
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
|
|
@ -591,11 +594,17 @@ class ResetOutcome:
|
|||
"retired": len(self.retired),
|
||||
"refused": len(self.refused),
|
||||
"released": len(self.released),
|
||||
"created_tasks": len(self.created_tasks),
|
||||
"updated_tasks": len(self.updated_tasks),
|
||||
"cancelled_tasks": len(self.cancelled_tasks),
|
||||
},
|
||||
"created": self.created,
|
||||
"updated": self.updated,
|
||||
"retired": self.retired,
|
||||
"released": self.released,
|
||||
"created_tasks": self.created_tasks,
|
||||
"updated_tasks": self.updated_tasks,
|
||||
"cancelled_tasks": self.cancelled_tasks,
|
||||
"refused": self.refused,
|
||||
"notes": self.notes,
|
||||
}
|
||||
|
|
@ -626,6 +635,89 @@ def _tombstone_slug(slug: str, when: datetime) -> str:
|
|||
RETIRE_REASON = "no longer derived from the forge"
|
||||
|
||||
|
||||
def _coerce_task_status(raw: str | None) -> Any:
|
||||
from api.models.task import TaskStatus
|
||||
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return TaskStatus(str(raw).strip().lower())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _task_status_value(status: Any) -> str:
|
||||
return str(getattr(status, "value", status) or "")
|
||||
|
||||
|
||||
def _sync_existing_workplan_tasks(
|
||||
session: Any,
|
||||
row: Any,
|
||||
derived_wp: DerivedWorkplan,
|
||||
hub_tasks: list[Any],
|
||||
outcome: ResetOutcome,
|
||||
) -> None:
|
||||
"""Match identified tasks of an existing workplan by record_id.
|
||||
|
||||
Rows with no ``record_id`` predate the backfill and cannot be qualified
|
||||
from the file alone (CUST-WP-0068-T09). They are not cancelled or
|
||||
overwritten.
|
||||
"""
|
||||
from api.models.task import Task, TaskPriority, TaskStatus
|
||||
|
||||
want = {
|
||||
t.record_id.strip().lower(): t
|
||||
for t in derived_wp.tasks
|
||||
if t.record_id and t.record_id.strip()
|
||||
}
|
||||
matched: dict[str, Any] = {}
|
||||
stale: list[Any] = []
|
||||
for ht in hub_tasks:
|
||||
rid = (ht.record_id or "").strip().lower()
|
||||
if not rid:
|
||||
continue
|
||||
if rid in want:
|
||||
matched[rid] = ht
|
||||
else:
|
||||
stale.append(ht)
|
||||
|
||||
for key, dt in want.items():
|
||||
ht = matched.get(key)
|
||||
if ht is None:
|
||||
kwargs: dict[str, Any] = {
|
||||
"id": uuid.UUID(dt.uuid),
|
||||
"workplan_id": row.id,
|
||||
"record_id": dt.record_id,
|
||||
"title": (dt.title or dt.record_id),
|
||||
}
|
||||
st = _coerce_task_status(dt.status)
|
||||
if st is not None:
|
||||
kwargs["status"] = st
|
||||
if dt.priority:
|
||||
try:
|
||||
kwargs["priority"] = TaskPriority(dt.priority.strip().lower())
|
||||
except ValueError:
|
||||
pass
|
||||
session.add(Task(**kwargs))
|
||||
outcome.created_tasks.append(dt.record_id)
|
||||
continue
|
||||
changed = False
|
||||
if dt.title and dt.title.strip() and ht.title != dt.title.strip():
|
||||
ht.title = dt.title.strip()
|
||||
changed = True
|
||||
st = _coerce_task_status(dt.status)
|
||||
if st is not None and ht.status != st:
|
||||
ht.status = st
|
||||
changed = True
|
||||
if changed:
|
||||
outcome.updated_tasks.append(dt.record_id)
|
||||
|
||||
for ht in stale:
|
||||
if _task_status_value(ht.status) in {"wait", "todo", "progress"}:
|
||||
ht.status = TaskStatus.cancel
|
||||
outcome.cancelled_tasks.append(ht.record_id or str(ht.id))
|
||||
|
||||
|
||||
async def reset_repository_projection(
|
||||
session: Any,
|
||||
repo_slug: str,
|
||||
|
|
@ -646,10 +738,10 @@ async def reset_repository_projection(
|
|||
file was removed deliberately — or that someone pointed this at the wrong
|
||||
branch. The caller must say which.
|
||||
|
||||
Scope: workplans, and the tasks of workplans being created. Tasks of
|
||||
*existing* workplans are left alone, because hub task rows carry no
|
||||
canonical identifier and can only be matched by title — renaming a heading
|
||||
would otherwise destroy and recreate its record (`T06`).
|
||||
Scope: workplans, and their tasks. Tasks of existing workplans are matched
|
||||
by ``record_id`` (STATE-WP-0083-T06); rows with no ``record_id`` are left
|
||||
alone. A derived task the hub lacks is created; an identified hub task the
|
||||
forge no longer derives is cancelled if it is still open.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
|
@ -932,6 +1024,17 @@ async def reset_repository_projection(
|
|||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
|
||||
existing_ids = [matched[k].id for k, _w in want.items() if k in matched]
|
||||
tasks_by_wp: dict[Any, list[Any]] = {}
|
||||
if existing_ids:
|
||||
loaded = list(
|
||||
(
|
||||
await session.execute(select(Task).where(Task.workplan_id.in_(existing_ids)))
|
||||
).scalars()
|
||||
)
|
||||
for task_row in loaded:
|
||||
tasks_by_wp.setdefault(task_row.workplan_id, []).append(task_row)
|
||||
|
||||
for key, w in want.items():
|
||||
row = matched.get(key)
|
||||
if row is None:
|
||||
|
|
@ -995,6 +1098,9 @@ async def reset_repository_projection(
|
|||
changed = True
|
||||
if changed:
|
||||
outcome.updated.append(w.record_id)
|
||||
_sync_existing_workplan_tasks(
|
||||
session, row, w, tasks_by_wp.get(row.id, []), outcome
|
||||
)
|
||||
|
||||
for r in stale:
|
||||
outcome.retired.append(r.slug or str(r.id))
|
||||
|
|
@ -1002,12 +1108,16 @@ async def reset_repository_projection(
|
|||
r.projection_retired_reason = RETIRE_REASON
|
||||
r.slug = _tombstone_slug(r.slug or str(r.id), now)
|
||||
|
||||
if outcome.created or outcome.updated or outcome.retired or outcome.released:
|
||||
if (
|
||||
outcome.created
|
||||
or outcome.updated
|
||||
or outcome.retired
|
||||
or outcome.released
|
||||
or outcome.created_tasks
|
||||
or outcome.updated_tasks
|
||||
or outcome.cancelled_tasks
|
||||
):
|
||||
outcome.status = "applied"
|
||||
outcome.notes.append(
|
||||
"Tasks of existing workplans were not touched; hub tasks carry no "
|
||||
"canonical identifier (STATE-WP-0083-T06)."
|
||||
)
|
||||
return outcome
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -218,11 +218,12 @@ class _Row:
|
|||
class _FakeSession:
|
||||
"""Stands in for AsyncSession: enough to prove intent without a database."""
|
||||
|
||||
def __init__(self, repo, rows, foreign=None, slug_clash=None):
|
||||
def __init__(self, repo, rows, foreign=None, slug_clash=None, task_rows=None):
|
||||
self._repo = repo
|
||||
self.rows = list(rows)
|
||||
self._foreign = list(foreign or [])
|
||||
self._slug_clash = list(slug_clash or [])
|
||||
self._task_rows = task_rows
|
||||
self.added = []
|
||||
self.deleted = []
|
||||
self.committed = False
|
||||
|
|
@ -231,16 +232,24 @@ class _FakeSession:
|
|||
async def execute(self, *_a, **_k):
|
||||
self._calls += 1
|
||||
repo, rows = self._repo, self.rows
|
||||
# 1st call resolves the repo, 2nd loads its workplans, 3rd is the
|
||||
# foreign-identifier lookup.
|
||||
# 1 resolves the repo, 2 loads its workplans, 3 is the identifier
|
||||
# lookup, 4 the slug lookup.
|
||||
# lookup, 4 the slug lookup. On the update-only path (no creates)
|
||||
# the 3rd call is the existing-task load instead.
|
||||
update_only = (
|
||||
self._task_rows is not None
|
||||
and not self._foreign
|
||||
and not self._slug_clash
|
||||
)
|
||||
if self._calls == 2:
|
||||
payload = rows
|
||||
elif update_only and self._calls == 3:
|
||||
payload = self._task_rows
|
||||
elif self._calls == 3:
|
||||
payload = self._foreign
|
||||
elif self._calls == 4:
|
||||
payload = self._slug_clash
|
||||
elif self._calls == 5:
|
||||
payload = self._task_rows or []
|
||||
else:
|
||||
payload = []
|
||||
|
||||
|
|
@ -1030,3 +1039,81 @@ class TestTitleSyncBehaviour:
|
|||
session = _FakeSession(repo=_Repo(), rows=[row])
|
||||
await fp.reset_repository_projection(session, "demo", derived=d)
|
||||
assert row.title == "Real Title"
|
||||
|
||||
|
||||
class _TaskRow:
|
||||
def __init__(self, record_id, status="todo", title="t", workplan_id=None):
|
||||
import uuid as _u
|
||||
self.id = _u.uuid4()
|
||||
self.record_id = record_id
|
||||
self.status = status
|
||||
self.title = title
|
||||
self.priority = "medium"
|
||||
self.workplan_id = workplan_id
|
||||
|
||||
|
||||
class TestExistingWorkplanTasks:
|
||||
"""Tasks of an existing workplan are matched by record_id (CUST-WP-0068-T09)."""
|
||||
|
||||
def _derived(self, *tasks):
|
||||
dts = [
|
||||
fp.DerivedTask(
|
||||
record_id=tid,
|
||||
uuid=fp.derived_record_uuid(tid),
|
||||
title=title,
|
||||
status=st,
|
||||
priority="medium",
|
||||
)
|
||||
for tid, title, st in tasks
|
||||
]
|
||||
return fp.DerivedProjection(
|
||||
repo_slug="demo",
|
||||
commit="c0ffee",
|
||||
workplans=[
|
||||
fp.DerivedWorkplan(
|
||||
record_id="DEMO-WP-0001",
|
||||
uuid=fp.derived_record_uuid("DEMO-WP-0001"),
|
||||
title="DEMO-WP-0001",
|
||||
status="active",
|
||||
relative_path="workplans/a.md",
|
||||
archived=False,
|
||||
tasks=dts,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_a_missing_identified_task(self):
|
||||
row = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md")
|
||||
session = _FakeSession(repo=_Repo(), rows=[row], task_rows=[])
|
||||
out = await fp.reset_repository_projection(
|
||||
session, "demo",
|
||||
derived=self._derived(("DEMO-WP-0001-T01", "Do it", "todo")),
|
||||
)
|
||||
assert out.created_tasks == ["DEMO-WP-0001-T01"]
|
||||
assert session.added
|
||||
assert session.added[0].record_id == "DEMO-WP-0001-T01"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancels_an_open_task_the_file_no_longer_derives(self):
|
||||
row = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md")
|
||||
stale = _TaskRow("DEMO-WP-0001-T09", status="todo", workplan_id=row.id)
|
||||
session = _FakeSession(repo=_Repo(), rows=[row], task_rows=[stale])
|
||||
out = await fp.reset_repository_projection(
|
||||
session, "demo",
|
||||
derived=self._derived(("DEMO-WP-0001-T01", "Do it", "todo")),
|
||||
)
|
||||
assert stale.status.value == "cancel" or stale.status == "cancel" or str(stale.status).endswith("cancel")
|
||||
assert "DEMO-WP-0001-T09" in out.cancelled_tasks
|
||||
assert session.deleted == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_leaves_unidentified_tasks_alone(self):
|
||||
row = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md")
|
||||
orphan = _TaskRow(None, status="todo", title="legacy", workplan_id=row.id)
|
||||
session = _FakeSession(repo=_Repo(), rows=[row], task_rows=[orphan])
|
||||
await fp.reset_repository_projection(
|
||||
session, "demo",
|
||||
derived=self._derived(("DEMO-WP-0001-T01", "Do it", "todo")),
|
||||
)
|
||||
assert orphan.status == "todo"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue