refactor: rename package fabledassistant -> scribe (code-only)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 1m14s

Renames src/fabledassistant -> src/scribe and all imports, plus the
default DB name and DB user/password (fabled -> scribe) in config +
compose. 952 refs / 154 files. Reverses the old 'internal name stays
fabledassistant' convention.

Code-only: live databases are still physically named 'fabledassistant'.
Deployed environments must set POSTGRES_DB / POSTGRES_USER (or rename the
DB) since the defaults now resolve to 'scribe'. Repo (FabledScribe), git
host (fabledsword), MCP (fabled-git) and the image name (fabledscribe)
are intentionally unchanged.

ruff check src/ clean locally; CI (typecheck + pytest) is the gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 15:48:35 -04:00
parent 1d4c206563
commit b255a0f90e
167 changed files with 1183 additions and 2368 deletions
+188
View File
@@ -0,0 +1,188 @@
"""Milestone management service."""
import logging
from datetime import datetime, timezone
from sqlalchemy import func, select
from scribe.models import async_session
from scribe.models.milestone import Milestone
from scribe.models.note import Note
logger = logging.getLogger(__name__)
async def create_milestone(
user_id: int,
project_id: int,
title: str,
description: str | None = None,
order_index: int = 0,
status: str = "active",
) -> Milestone:
async with async_session() as session:
milestone = Milestone(
user_id=user_id,
project_id=project_id,
title=title,
description=description,
status=status,
order_index=order_index,
)
session.add(milestone)
await session.commit()
await session.refresh(milestone)
return milestone
async def get_milestone(user_id: int, milestone_id: int) -> Milestone | None:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(
Milestone.id == milestone_id, Milestone.user_id == user_id,
Milestone.deleted_at.is_(None),
)
)
return result.scalars().first()
async def get_milestone_in_project(project_id: int, milestone_id: int) -> Milestone | None:
"""Fetch a milestone by id within a project, without a user_id ownership check.
Callers must verify project access separately before using this."""
async with async_session() as session:
result = await session.execute(
select(Milestone).where(
Milestone.id == milestone_id,
Milestone.project_id == project_id,
Milestone.deleted_at.is_(None),
)
)
return result.scalars().first()
async def get_milestone_by_title(user_id: int, project_id: int, title: str) -> Milestone | None:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(
Milestone.user_id == user_id,
Milestone.project_id == project_id,
func.lower(Milestone.title) == func.lower(title.strip()),
).limit(1)
)
return result.scalars().first()
async def find_milestone_by_title(user_id: int, title: str) -> Milestone | None:
"""Find a milestone by title across ALL projects for this user (case-insensitive)."""
async with async_session() as session:
result = await session.execute(
select(Milestone).where(
Milestone.user_id == user_id,
func.lower(Milestone.title) == func.lower(title.strip()),
).order_by(Milestone.id).limit(1)
)
return result.scalars().first()
async def get_or_create_milestone(user_id: int, project_id: int, title: str) -> Milestone:
milestone = await get_milestone_by_title(user_id, project_id, title)
if milestone:
return milestone
return await create_milestone(user_id, project_id, title=title)
async def list_milestones(
user_id: int, project_id: int, status: str | None = None
) -> list[Milestone]:
async with async_session() as session:
query = select(Milestone).where(
Milestone.user_id == user_id,
Milestone.project_id == project_id,
Milestone.deleted_at.is_(None),
)
if status:
query = query.where(Milestone.status == status)
query = query.order_by(Milestone.order_index.asc(), Milestone.created_at.asc())
result = await session.execute(query)
return list(result.scalars().all())
async def update_milestone(user_id: int, milestone_id: int, **fields: object) -> Milestone | None:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(
Milestone.id == milestone_id, Milestone.user_id == user_id,
Milestone.deleted_at.is_(None),
)
)
milestone = result.scalars().first()
if milestone is None:
return None
for key, value in fields.items():
if hasattr(milestone, key):
setattr(milestone, key, value)
milestone.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(milestone)
return milestone
async def delete_milestone(user_id: int, milestone_id: int) -> bool:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id)
)
milestone = result.scalars().first()
if milestone is None:
return False
await session.delete(milestone)
await session.commit()
return True
async def get_milestone_progress(milestone_id: int) -> dict:
"""Return task completion stats for a milestone."""
async with async_session() as session:
rows = await session.execute(
select(Note.status, func.count(Note.id))
.where(
Note.milestone_id == milestone_id, Note.status.isnot(None),
Note.deleted_at.is_(None),
)
.group_by(Note.status)
)
status_counts: dict[str, int] = {}
for status, count in rows.fetchall():
status_counts[status] = count
total = sum(status_counts.values())
cancelled = status_counts.get("cancelled", 0)
completed = status_counts.get("done", 0)
# Cancelled tasks are resolved work, not pending — exclude them from the
# percent-complete denominator so a milestone whose only open task was
# cancelled still reaches 100% (and auto-collapses) instead of stalling.
active_total = total - cancelled
pct = round(completed / active_total * 100, 1) if active_total > 0 else 0.0
return {
"total": total,
"completed": completed,
"pct": pct,
"status_counts": {
"todo": status_counts.get("todo", 0),
"in_progress": status_counts.get("in_progress", 0),
"done": status_counts.get("done", 0),
"cancelled": cancelled,
},
}
async def get_project_milestone_summary(user_id: int, project_id: int) -> list[dict]:
"""Return ordered list of milestones with their progress stats."""
milestones = await list_milestones(user_id, project_id)
result = []
for m in milestones:
progress = await get_milestone_progress(m.id)
entry = m.to_dict()
entry.update(progress)
result.append(entry)
return result