Improved workplan dependency management facilities

This commit is contained in:
2026-05-04 11:45:24 +02:00
parent 4d6164e81b
commit bfed370a6e
10 changed files with 380 additions and 29 deletions

View File

@@ -1,7 +1,7 @@
import uuid
from datetime import date
from sqlalchemy import Date, ForeignKey, String, Text
from sqlalchemy import Date, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -25,6 +25,8 @@ class Workstream(Base, TimestampMixin):
)
owner: Mapped[str | None] = mapped_column(String(100), nullable=True)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
planning_priority: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
planning_order: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
repo_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),

View File

@@ -1,6 +1,6 @@
import uuid
from sqlalchemy import ForeignKey, Text, UniqueConstraint
from sqlalchemy import CheckConstraint, ForeignKey, Index, String, Text, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -8,16 +8,36 @@ from api.models.base import Base, TimestampMixin, new_uuid
class WorkstreamDependency(Base, TimestampMixin):
"""Directed dependency edge: `from_workstream` depends on `to_workstream`.
"""Directed dependency edge: `from_workstream` depends on a workstream or task.
Semantics: `to_workstream` must reach a satisfactory state before
`from_workstream` can fully proceed. Hard deletes are intentional —
Semantics: the target must reach a satisfactory state before `from_workstream`
can fully proceed. Hard deletes are intentional —
removing an edge removes a constraint, not information.
"""
__tablename__ = "workstream_dependencies"
__table_args__ = (
UniqueConstraint("from_workstream_id", "to_workstream_id", name="uq_ws_dep_pair"),
CheckConstraint(
"(to_workstream_id IS NOT NULL AND to_task_id IS NULL) "
"OR (to_workstream_id IS NULL AND to_task_id IS NOT NULL)",
name="ck_ws_dep_exactly_one_target",
),
Index(
"uq_ws_dep_workstream_target",
"from_workstream_id",
"to_workstream_id",
"relationship_type",
unique=True,
postgresql_where=text("to_workstream_id IS NOT NULL"),
),
Index(
"uq_ws_dep_task_target",
"from_workstream_id",
"to_task_id",
"relationship_type",
unique=True,
postgresql_where=text("to_task_id IS NOT NULL"),
),
)
id: Mapped[uuid.UUID] = mapped_column(
@@ -29,17 +49,27 @@ class WorkstreamDependency(Base, TimestampMixin):
nullable=False,
index=True,
)
to_workstream_id: Mapped[uuid.UUID] = mapped_column(
to_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workstreams.id", ondelete="CASCADE"),
nullable=False,
nullable=True,
index=True,
)
to_task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tasks.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
relationship_type: Mapped[str] = mapped_column(
String(40), nullable=False, default="blocks", server_default="blocks", index=True
)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
from_workstream: Mapped["Workstream"] = relationship( # noqa: F821
"Workstream", foreign_keys=[from_workstream_id]
)
to_workstream: Mapped["Workstream"] = relationship( # noqa: F821
to_workstream: Mapped["Workstream | None"] = relationship( # noqa: F821
"Workstream", foreign_keys=[to_workstream_id]
)
to_task: Mapped["Task | None"] = relationship("Task", foreign_keys=[to_task_id]) # noqa: F821

View File

@@ -97,9 +97,13 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm
# Build a slug+title lookup for all workstreams referenced in deps
dep_ws_ids = set()
dep_task_ids = set()
for d in dep_rows:
dep_ws_ids.add(d.from_workstream_id)
dep_ws_ids.add(d.to_workstream_id)
if d.to_workstream_id:
dep_ws_ids.add(d.to_workstream_id)
if d.to_task_id:
dep_task_ids.add(d.to_task_id)
ws_lookup: dict = {w.id: w for w in open_ws}
extra_ids = dep_ws_ids - set(ws_lookup.keys())
if extra_ids:
@@ -108,22 +112,39 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm
)
for w in extra_rows.scalars():
ws_lookup[w.id] = w
task_lookup: dict = {}
if dep_task_ids:
task_rows = await session.execute(select(Task).where(Task.id.in_(dep_task_ids)))
task_lookup = {t.id: t for t in task_rows.scalars().all()}
# Index: workstream_id → (depends_on stubs, blocks stubs)
dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws}
for d in dep_rows:
from_id, to_id = d.from_workstream_id, d.to_workstream_id
if from_id in dep_index and to_id in ws_lookup:
from_id, to_id, task_id = d.from_workstream_id, d.to_workstream_id, d.to_task_id
if from_id in dep_index and to_id and to_id in ws_lookup:
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
dep_id=d.id,
target_type="workstream",
relationship_type=d.relationship_type,
workstream_id=to_id,
workstream_slug=ws_lookup[to_id].slug,
workstream_title=ws_lookup[to_id].title,
description=d.description,
))
if to_id in dep_index and from_id in ws_lookup:
if from_id in dep_index and task_id and task_id in task_lookup:
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
dep_id=d.id,
target_type="task",
relationship_type=d.relationship_type,
task_id=task_id,
task_title=task_lookup[task_id].title,
description=d.description,
))
if to_id and to_id in dep_index and from_id in ws_lookup:
dep_index[to_id]["blocks"].append(WorkstreamDepStub(
dep_id=d.id,
target_type="workstream",
relationship_type=d.relationship_type,
workstream_id=from_id,
workstream_slug=ws_lookup[from_id].slug,
workstream_title=ws_lookup[from_id].title,
@@ -142,7 +163,7 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm
"dependencies": [
{"workstation": ws_lookup[d.to_workstream_id].status}
for d in dep_rows
if d.from_workstream_id == w.id and d.to_workstream_id in ws_lookup
if d.from_workstream_id == w.id and d.to_workstream_id and d.to_workstream_id in ws_lookup
],
}
flow_result = flow_engine.evaluate(flow_obj, workstream_flow)

View File

@@ -5,6 +5,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.task import Task
from api.models.workstream import Workstream
from api.models.workstream_dependency import WorkstreamDependency
from api.schemas.workstream_dependency import WorkstreamDependencyCreate, WorkstreamDependencyRead
@@ -22,17 +23,27 @@ async def create_dependency(
body: WorkstreamDependencyCreate,
session: AsyncSession = Depends(get_session),
) -> WorkstreamDependency:
"""Record that workstream_id depends on body.to_workstream_id."""
"""Record that workstream_id depends on another workstream or a task."""
if await session.get(Workstream, workstream_id) is None:
raise HTTPException(status_code=404, detail="from workstream not found")
if await session.get(Workstream, body.to_workstream_id) is None:
raise HTTPException(status_code=404, detail="to workstream not found")
has_workstream_target = body.to_workstream_id is not None
has_task_target = body.to_task_id is not None
if has_workstream_target == has_task_target:
raise HTTPException(status_code=422, detail="provide exactly one dependency target")
if body.to_workstream_id and await session.get(Workstream, body.to_workstream_id) is None:
raise HTTPException(status_code=404, detail="target workstream not found")
if body.to_task_id and await session.get(Task, body.to_task_id) is None:
raise HTTPException(status_code=404, detail="target task not found")
if workstream_id == body.to_workstream_id:
raise HTTPException(status_code=422, detail="a workstream cannot depend on itself")
dep = WorkstreamDependency(
from_workstream_id=workstream_id,
to_workstream_id=body.to_workstream_id,
to_task_id=body.to_task_id,
relationship_type=body.relationship_type,
description=body.description,
)
session.add(dep)

View File

@@ -82,7 +82,11 @@ async def list_workstreams(
q = q.where(Workstream.owner == owner)
if slug:
q = q.where(Workstream.slug == slug)
q = q.order_by(Workstream.updated_at.desc())
q = q.order_by(
Workstream.planning_priority.asc().nullslast(),
Workstream.planning_order.asc().nullslast(),
Workstream.updated_at.desc(),
)
result = await session.execute(q)
return list(result.scalars().all())

View File

@@ -17,6 +17,8 @@ class WorkstreamCreate(BaseModel):
status: WorkstreamStatus = "active"
owner: str | None = None
due_date: date | None = None
planning_priority: str | None = None
planning_order: int | None = None
repo_id: uuid.UUID | None = None # GEMS primary: the owning repository
repo_goal_id: uuid.UUID | None = None
@@ -27,6 +29,8 @@ class WorkstreamUpdate(BaseModel):
status: WorkstreamStatus | None = None
owner: str | None = None
due_date: date | None = None
planning_priority: str | None = None
planning_order: int | None = None
repo_id: uuid.UUID | None = None
repo_goal_id: uuid.UUID | None = None
@@ -43,6 +47,8 @@ class WorkstreamRead(BaseModel):
status: WorkstreamStatus
owner: str | None = None
due_date: date | None = None
planning_priority: str | None = None
planning_order: int | None = None
created_at: datetime
updated_at: datetime

View File

@@ -5,7 +5,9 @@ from pydantic import BaseModel, ConfigDict
class WorkstreamDependencyCreate(BaseModel):
to_workstream_id: uuid.UUID
to_workstream_id: uuid.UUID | None = None
to_task_id: uuid.UUID | None = None
relationship_type: str = "blocks"
description: str | None = None
@@ -13,7 +15,9 @@ class WorkstreamDependencyRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
from_workstream_id: uuid.UUID
to_workstream_id: uuid.UUID
to_workstream_id: uuid.UUID | None = None
to_task_id: uuid.UUID | None = None
relationship_type: str
description: str | None = None
created_at: datetime
updated_at: datetime
@@ -22,7 +26,11 @@ class WorkstreamDependencyRead(BaseModel):
class WorkstreamDepStub(BaseModel):
"""Minimal projection of the other end of a dependency edge."""
dep_id: uuid.UUID
workstream_id: uuid.UUID
workstream_slug: str
workstream_title: str
target_type: str = "workstream"
relationship_type: str = "blocks"
workstream_id: uuid.UUID | None = None
workstream_slug: str | None = None
workstream_title: str | None = None
task_id: uuid.UUID | None = None
task_title: str | None = None
description: str | None = None

View File

@@ -419,6 +419,8 @@ def create_workstream(
owner: str | None = None,
due_date: str | None = None,
repo_id: str | None = None,
planning_priority: str | None = None,
planning_order: int | None = None,
) -> str:
"""Create a new workstream under a topic and emit a progress_event.
@@ -430,6 +432,8 @@ def create_workstream(
owner: optional owner name
due_date: optional ISO date string (YYYY-MM-DD)
repo_id: UUID of the owning repository (GEMS primary; strongly recommended per ADR-001)
planning_priority: optional planning priority (critical/high/medium/low or repo-local value)
planning_order: optional numeric ordering hint inside a repo/domain
"""
if not slug:
slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
@@ -442,6 +446,8 @@ def create_workstream(
"due_date": due_date,
"status": "active",
"repo_id": repo_id,
"planning_priority": planning_priority,
"planning_order": planning_order,
})
_post("/progress", {
"topic_id": topic_id,
@@ -830,21 +836,27 @@ def get_next_steps() -> str:
@mcp.tool()
def create_dependency(
from_workstream_id: str,
to_workstream_id: str,
to_workstream_id: str | None = None,
to_task_id: str | None = None,
relationship_type: str = "blocks",
description: str | None = None,
) -> str:
"""Record that one workstream depends on another.
"""Record that one workstream depends on another workstream or task.
Semantics: from_workstream cannot fully proceed until to_workstream reaches
a satisfactory state.
Semantics: from_workstream cannot fully proceed until the target reaches
a satisfactory state. Provide exactly one of to_workstream_id or to_task_id.
Args:
from_workstream_id: UUID of the workstream that has the dependency
to_workstream_id: UUID of the workstream it depends on
to_task_id: UUID of the task it depends on
relationship_type: blocks | starts_after | informs | soft_dependency
description: optional human-readable explanation of the dependency
"""
dep = _post(f"/workstreams/{from_workstream_id}/dependencies", {
"to_workstream_id": to_workstream_id,
"to_task_id": to_task_id,
"relationship_type": relationship_type,
"description": description,
})
return json.dumps(dep, indent=2)
@@ -862,7 +874,7 @@ def list_dependencies(workstream_id: str) -> str:
"""
edges = _get(f"/workstreams/{workstream_id}/dependencies")
depends_on = [e for e in edges if e["from_workstream_id"] == workstream_id]
blocks = [e for e in edges if e["to_workstream_id"] == workstream_id]
blocks = [e for e in edges if e.get("to_workstream_id") == workstream_id]
return json.dumps({"depends_on": depends_on, "blocks": blocks}, indent=2)

View File

@@ -0,0 +1,96 @@
"""add workstream planning metadata and typed dependency targets
Revision ID: s6n7o8p9q0r1
Revises: r5m6n7o8p9q0
Create Date: 2026-05-04
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = "s6n7o8p9q0r1"
down_revision = "r5m6n7o8p9q0"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("workstreams", sa.Column("planning_priority", sa.String(length=20), nullable=True))
op.add_column("workstreams", sa.Column("planning_order", sa.Integer(), nullable=True))
op.create_index("ix_workstreams_planning_priority", "workstreams", ["planning_priority"])
op.create_index("ix_workstreams_planning_order", "workstreams", ["planning_order"])
op.drop_constraint("uq_ws_dep_pair", "workstream_dependencies", type_="unique")
op.add_column("workstream_dependencies", sa.Column("to_task_id", UUID(as_uuid=True), nullable=True))
op.add_column(
"workstream_dependencies",
sa.Column("relationship_type", sa.String(length=40), nullable=False, server_default="blocks"),
)
op.alter_column(
"workstream_dependencies",
"to_workstream_id",
existing_type=UUID(as_uuid=True),
nullable=True,
)
op.create_foreign_key(
"fk_ws_dep_to_task_id",
"workstream_dependencies",
"tasks",
["to_task_id"],
["id"],
ondelete="CASCADE",
)
op.create_index("ix_workstream_dependencies_to_task_id", "workstream_dependencies", ["to_task_id"])
op.create_index(
"ix_workstream_dependencies_relationship_type",
"workstream_dependencies",
["relationship_type"],
)
op.create_check_constraint(
"ck_ws_dep_exactly_one_target",
"workstream_dependencies",
"(to_workstream_id IS NOT NULL AND to_task_id IS NULL) "
"OR (to_workstream_id IS NULL AND to_task_id IS NOT NULL)",
)
op.create_index(
"uq_ws_dep_workstream_target",
"workstream_dependencies",
["from_workstream_id", "to_workstream_id", "relationship_type"],
unique=True,
postgresql_where=sa.text("to_workstream_id IS NOT NULL"),
)
op.create_index(
"uq_ws_dep_task_target",
"workstream_dependencies",
["from_workstream_id", "to_task_id", "relationship_type"],
unique=True,
postgresql_where=sa.text("to_task_id IS NOT NULL"),
)
def downgrade() -> None:
op.drop_index("uq_ws_dep_task_target", table_name="workstream_dependencies")
op.drop_index("uq_ws_dep_workstream_target", table_name="workstream_dependencies")
op.drop_constraint("ck_ws_dep_exactly_one_target", "workstream_dependencies", type_="check")
op.drop_index("ix_workstream_dependencies_relationship_type", table_name="workstream_dependencies")
op.drop_index("ix_workstream_dependencies_to_task_id", table_name="workstream_dependencies")
op.drop_constraint("fk_ws_dep_to_task_id", "workstream_dependencies", type_="foreignkey")
op.alter_column(
"workstream_dependencies",
"to_workstream_id",
existing_type=UUID(as_uuid=True),
nullable=False,
)
op.drop_column("workstream_dependencies", "relationship_type")
op.drop_column("workstream_dependencies", "to_task_id")
op.create_unique_constraint(
"uq_ws_dep_pair",
"workstream_dependencies",
["from_workstream_id", "to_workstream_id"],
)
op.drop_index("ix_workstreams_planning_order", table_name="workstreams")
op.drop_index("ix_workstreams_planning_priority", table_name="workstreams")
op.drop_column("workstreams", "planning_order")
op.drop_column("workstreams", "planning_priority")

View File

@@ -22,6 +22,8 @@ Checks:
C-15 task-db-ahead WARN Yes DB task status is ahead of file — regression prevented; writeback syncs file
C-16 repo-behind-remote WARN No Local repo is behind remote tracking branch — --fix skipped to avoid clobbering remote progress
C-17 repo-ahead-push-failed WARN No Local repo has unpushed commits and push failed — writes skipped to prevent runaway divergence
C-19 workstream-planning-drift WARN Yes planning_priority/planning_order differs between file and DB
C-20 workstream-dependency-missing WARN Yes Workplan dependency frontmatter missing from DB graph
Usage:
python scripts/consistency_check.py --repo SLUG [--fix] [--no-writeback] [--json] [--api-base URL]
@@ -69,6 +71,7 @@ _ARCHIVED_WP_RE = re.compile(r"^\d{6}-(.+\.md)$")
VALID_WP_STATUSES = {"active", "completed", "archived"}
VALID_TASK_STATUSES = {"todo", "in_progress", "blocked", "done", "cancelled"}
VALID_TASK_PRIORITIES = {"low", "medium", "high", "critical"}
VALID_DEP_RELATIONSHIPS = {"blocks", "starts_after", "informs", "soft_dependency"}
# Workplan files use task-style vocabulary ("done"); the DB workstream API uses
# "completed". This map translates file values to DB values before comparison
@@ -214,6 +217,25 @@ def get_tasks_from_workplan(meta: dict, body: str) -> list[dict]:
return []
def _as_list(value: Any) -> list[str]:
if value is None:
return []
if isinstance(value, list):
return [str(item).strip().strip('"') for item in value if str(item).strip()]
if isinstance(value, str):
return [item.strip().strip('"') for item in value.split(",") if item.strip()]
return [str(value).strip().strip('"')]
def _as_int_or_none(value: Any) -> int | None:
if value in (None, "", "~", "null", "None", "none"):
return None
try:
return int(value)
except (TypeError, ValueError):
return None
# ---------------------------------------------------------------------------
# File update helpers
# ---------------------------------------------------------------------------
@@ -511,6 +533,22 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
if wp_file.parent == workplans_dir:
active_file_ws_ids.add(ws_id)
workplan_id_to_ws_id: dict[str, str] = {}
task_file_id_to_sh_id: dict[str, str] = {}
for _wp_file, meta, body in workplan_infos:
mapped_ws_id = str(meta.get("state_hub_workstream_id", "")).strip().strip('"')
wp_id = str(meta.get("id", "")).strip()
if wp_id and mapped_ws_id:
workplan_id_to_ws_id[wp_id] = mapped_ws_id
for task in get_tasks_from_workplan(meta, body):
if task.get("_parse_error"):
continue
task_file_id = str(task.get("id", "")).strip()
raw_sh = task.get("state_hub_task_id")
task_sh_id = "" if raw_sh is None else str(raw_sh).strip().strip('"')
if task_file_id and task_sh_id and task_sh_id not in ("~", "null", "None", "none"):
task_file_id_to_sh_id[task_file_id] = task_sh_id
# Per-workplan checks
for wp_file, meta, body in workplan_infos:
fname = workplan_display_path(repo_dir, wp_file)
@@ -618,6 +656,38 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
_fix_context={"ws_id": ws_id, "field": "title", "value": file_title},
)
planning_priority = str(meta.get("planning_priority", "")).strip() or None
if planning_priority != (ws.get("planning_priority") or None):
report.add(
severity="WARN", check_id="C-19",
message=(
f"Planning priority drift in '{ws.get('slug')}': "
f"file={planning_priority!r} db={ws.get('planning_priority')!r} (file wins)"
),
file_path=fname,
db_id=ws_id,
file_value=planning_priority,
db_value=ws.get("planning_priority"),
fixable=True,
_fix_context={"ws_id": ws_id, "field": "planning_priority", "value": planning_priority},
)
planning_order = _as_int_or_none(meta.get("planning_order"))
if planning_order != ws.get("planning_order"):
report.add(
severity="WARN", check_id="C-19",
message=(
f"Planning order drift in '{ws.get('slug')}': "
f"file={planning_order!r} db={ws.get('planning_order')!r} (file wins)"
),
file_path=fname,
db_id=ws_id,
file_value=planning_order,
db_value=ws.get("planning_order"),
fixable=True,
_fix_context={"ws_id": ws_id, "field": "planning_order", "value": planning_order},
)
# C-10, C-11, C-12: task-level checks
tasks = get_tasks_from_workplan(meta, body)
db_tasks = _api_get(api_base, "/tasks", {"workstream_id": ws_id})
@@ -626,6 +696,76 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
for t in db_tasks:
db_task_by_id[t["id"]] = t
existing_deps = _api_get(api_base, f"/workstreams/{ws_id}/dependencies") or []
existing_dep_keys = set()
if isinstance(existing_deps, list):
for dep in existing_deps:
if dep.get("from_workstream_id") != ws_id:
continue
rel = dep.get("relationship_type") or "blocks"
if dep.get("to_workstream_id"):
existing_dep_keys.add(("workstream", dep["to_workstream_id"], rel))
if dep.get("to_task_id"):
existing_dep_keys.add(("task", dep["to_task_id"], rel))
for target_wp_id in _as_list(meta.get("depends_on_workplans")):
target_ws_id = workplan_id_to_ws_id.get(target_wp_id)
if not target_ws_id:
report.add(
severity="WARN",
check_id="C-20",
message=f"Workplan dependency target '{target_wp_id}' is not linked to State Hub",
file_path=fname,
file_value=target_wp_id,
fixable=False,
)
continue
dep_key = ("workstream", target_ws_id, "blocks")
if dep_key not in existing_dep_keys:
report.add(
severity="WARN",
check_id="C-20",
message=f"Missing DB dependency edge: {ws_id[:8]}… depends on workplan {target_wp_id}",
file_path=fname,
db_id=ws_id,
file_value=target_wp_id,
fixable=True,
_fix_context={
"from_workstream_id": ws_id,
"to_workstream_id": target_ws_id,
"relationship_type": "blocks",
},
)
for target_task_id in _as_list(meta.get("depends_on_tasks")):
target_sh_id = task_file_id_to_sh_id.get(target_task_id)
if not target_sh_id:
report.add(
severity="WARN",
check_id="C-20",
message=f"Task dependency target '{target_task_id}' is not linked to State Hub",
file_path=fname,
file_value=target_task_id,
fixable=False,
)
continue
dep_key = ("task", target_sh_id, "starts_after")
if dep_key not in existing_dep_keys:
report.add(
severity="WARN",
check_id="C-20",
message=f"Missing DB dependency edge: {ws_id[:8]}… starts after task {target_task_id}",
file_path=fname,
db_id=ws_id,
file_value=target_task_id,
fixable=True,
_fix_context={
"from_workstream_id": ws_id,
"to_task_id": target_sh_id,
"relationship_type": "starts_after",
},
)
file_task_sh_ids: set[str] = set()
for task in tasks:
@@ -1180,7 +1320,7 @@ def fix_repo(
for issue in fixable:
ctx = issue._fix_context
try:
if issue.check_id in ("C-04", "C-05", "C-13"):
if issue.check_id in ("C-04", "C-05", "C-13", "C-19"):
ws_id = ctx["ws_id"]
result = _api_patch(api_base, f"/workstreams/{ws_id}",
{ctx["field"]: ctx["value"]})
@@ -1229,6 +1369,8 @@ def fix_repo(
"title": title or wp_id,
"status": status,
"owner": str(meta.get("owner", "")).strip() or None,
"planning_priority": str(meta.get("planning_priority", "")).strip() or None,
"planning_order": _as_int_or_none(meta.get("planning_order")),
})
if ws_data is None:
report.fixes_applied.append(
@@ -1284,6 +1426,25 @@ def fix_repo(
f"repo_id → {correct_repo_id[:8]}"
)
elif issue.check_id == "C-20":
from_workstream_id = ctx["from_workstream_id"]
body = {
"to_workstream_id": ctx.get("to_workstream_id"),
"to_task_id": ctx.get("to_task_id"),
"relationship_type": ctx["relationship_type"],
}
result = _api_post(api_base, f"/workstreams/{from_workstream_id}/dependencies", body)
if result is not None and "_error" not in result:
target = ctx.get("to_workstream_id") or ctx.get("to_task_id")
report.fixes_applied.append(
f"C-20 fixed: dependency {from_workstream_id[:8]}"
f"{ctx['relationship_type']}{target[:8]}"
)
elif result is not None:
report.fixes_applied.append(
f"C-20 FAILED: {result['_error']}"
)
elif issue.check_id == "C-10":
task_id = ctx["task_id"]
status = ctx["status"]