CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m46s
CI & Build / Python tests (push) Successful in 2m36s
CI & Build / Build & push image (push) Successful in 31s
The 1-2 hit tail: access, background, backup, backup_job, canonical_systems, design_stylesheet, forge_connections, moment_actions, notes, notifications, placement, planning, platforms, projects, record_batch, recurrence, report_check, retrieval_review, rule_scope, rule_versions, scheduler, shape_check, supersession, system_rulings, system_usage, text, trash, verification, version_pinning. Also corrected while reading: - backup.py said restore step 16 fills the System map; step 15 does. - version_pinning.py pointed at a design doc that is not in the repo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
336 lines
13 KiB
Python
336 lines
13 KiB
Python
"""Platforms — what a project is built on or ships as, and which projects are
|
|
which.
|
|
|
|
Membership is what makes "is this idea in family for that project?" a lookup
|
|
rather than a judgment: a family idea is for some platforms, and it reaches
|
|
every project that is a member of one of them.
|
|
|
|
Three ways a project becomes, or refuses to become, a member:
|
|
|
|
- **declared** — a person said so: at inception, or in the project's settings.
|
|
- **detected** — a marker file in a bound repo said so, found by the coverage
|
|
refresh that already walks the repo archive.
|
|
- **rejected** — a person said NO. Kept as a row, so the next refresh does not
|
|
detect it straight back.
|
|
|
|
The one invariant everything here protects: **detection only ever ADDS, and
|
|
only where nobody has answered.** It never overwrites a declared or rejected
|
|
row, and it never removes anything — not even a detected row whose marker has
|
|
since disappeared, because membership is what adoption rows hang off and a
|
|
platform flickering in and out with a repo's file tree would churn a ledger
|
|
of decisions nobody re-made.
|
|
|
|
The catalog itself is global, like the canonical area catalog, and for the
|
|
same reason writes to it are admin-only: a shared vocabulary anyone can extend
|
|
stops being shared. Reads are open to any signed-in user.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import fnmatch
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import select
|
|
|
|
from scribe.models import async_session
|
|
from scribe.models.family import Platform, ProjectPlatform
|
|
from scribe.services import access
|
|
from scribe.services.canonical_systems import canonical_slug
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# The states that make a project a member. `rejected` is an answer, not
|
|
# membership.
|
|
MEMBER_STATES = ("declared", "detected")
|
|
# What a person may set from a door. `detected` is the refresh's to write.
|
|
SETTABLE_STATES = ("declared", "rejected")
|
|
|
|
|
|
# --- the catalog -------------------------------------------------------------
|
|
|
|
async def list_platforms() -> list[Platform]:
|
|
"""The whole live catalog, in display order. Global — no owner filter."""
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Platform)
|
|
.where(Platform.deleted_at.is_(None))
|
|
.order_by(Platform.order_index.asc(), Platform.name.asc())
|
|
)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
def _clean_markers(markers) -> list[str]:
|
|
"""Markers as stored: stripped, non-empty strings, de-duplicated in order.
|
|
Anything else is refused by the caller before it gets here."""
|
|
seen: list[str] = []
|
|
for m in markers or []:
|
|
m = str(m).strip()
|
|
if m and m not in seen:
|
|
seen.append(m)
|
|
return seen
|
|
|
|
|
|
def validate_markers(markers) -> str | None:
|
|
"""The error a markers value would earn, or None. Pure."""
|
|
if markers is None:
|
|
return None
|
|
if not isinstance(markers, list) or not all(isinstance(m, str) for m in markers):
|
|
return "markers must be a list of glob patterns"
|
|
if any(m.strip().startswith("/") for m in markers):
|
|
return "markers are repo-relative — no leading slash"
|
|
return None
|
|
|
|
|
|
async def create_platform(
|
|
user_id: int, name: str, *, description: str | None = None,
|
|
markers: list[str] | None = None,
|
|
) -> Platform | dict | None:
|
|
"""Add a platform to the global catalog. Admin only.
|
|
|
|
Duplicate-gated on the slug, so "Android App" cannot be added beside
|
|
"Android app": the existing entry comes back instead of a second spelling
|
|
of it. None means not permitted, or no usable name.
|
|
"""
|
|
if not await access.is_instance_admin(user_id):
|
|
return None
|
|
slug = canonical_slug(name)
|
|
if not slug:
|
|
return None
|
|
error = validate_markers(markers)
|
|
if error:
|
|
raise ValueError(error)
|
|
async with async_session() as session:
|
|
existing = await session.scalar(
|
|
select(Platform).where(Platform.slug == slug, Platform.deleted_at.is_(None))
|
|
)
|
|
if existing is not None:
|
|
return {
|
|
"duplicate": True,
|
|
"existing_id": existing.id,
|
|
"message": (
|
|
f"'{existing.name}' (#{existing.id}) is already this platform — "
|
|
f"both names reduce to '{slug}'."
|
|
),
|
|
}
|
|
highest = await session.scalar(
|
|
select(Platform.order_index).order_by(Platform.order_index.desc()).limit(1)
|
|
)
|
|
entry = Platform(
|
|
name=" ".join(name.split()),
|
|
slug=slug,
|
|
description=description,
|
|
markers=_clean_markers(markers),
|
|
order_index=(highest or 0) + 1,
|
|
)
|
|
session.add(entry)
|
|
await session.commit()
|
|
await session.refresh(entry)
|
|
return entry
|
|
|
|
|
|
async def update_platform(user_id: int, platform_id: int, **fields: object) -> Platform | None:
|
|
"""Rename, re-describe, re-order or re-mark a catalog entry. Admin only.
|
|
|
|
A rename recomputes the slug — the display name and the match key must not
|
|
disagree. Changing the slug of a platform projects already belong to is
|
|
safe: membership is by id; only a backup carries the slug.
|
|
"""
|
|
if not await access.is_instance_admin(user_id):
|
|
return None
|
|
if "markers" in fields:
|
|
error = validate_markers(fields["markers"])
|
|
if error:
|
|
raise ValueError(error)
|
|
async with async_session() as session:
|
|
entry = await session.get(Platform, platform_id)
|
|
if entry is None or entry.deleted_at is not None:
|
|
return None
|
|
if fields.get("name"):
|
|
entry.name = " ".join(str(fields["name"]).split())
|
|
entry.slug = canonical_slug(entry.name)
|
|
if fields.get("description") is not None:
|
|
entry.description = fields["description"] or None
|
|
if fields.get("order_index") is not None:
|
|
entry.order_index = int(fields["order_index"])
|
|
if fields.get("markers") is not None:
|
|
entry.markers = _clean_markers(fields["markers"])
|
|
entry.updated_at = datetime.now(timezone.utc)
|
|
await session.commit()
|
|
await session.refresh(entry)
|
|
return entry
|
|
|
|
|
|
async def resolve_slugs(slugs: list[str]) -> tuple[dict[str, int], list[str]]:
|
|
"""{slug: id} for the slugs the live catalog knows, and the ones it does
|
|
not. Doors take slugs — readable, stable across installs, and the form an
|
|
inception record and a backup both carry."""
|
|
wanted = [s for s in dict.fromkeys(slugs or [])]
|
|
if not wanted:
|
|
return {}, []
|
|
async with async_session() as session:
|
|
rows = (await session.execute(
|
|
select(Platform.slug, Platform.id).where(
|
|
Platform.slug.in_(wanted), Platform.deleted_at.is_(None),
|
|
)
|
|
)).all()
|
|
known = {slug: pid for slug, pid in rows}
|
|
return known, [s for s in wanted if s not in known]
|
|
|
|
|
|
# --- detection (pure) --------------------------------------------------------
|
|
|
|
def marker_matches(marker: str, path: str) -> bool:
|
|
"""Whether one marker matches one repo-relative path.
|
|
|
|
A marker with no slash matches a file's BASENAME anywhere in the tree
|
|
(`go.mod`, `AndroidManifest.xml`, `vite.config.*`). A marker with a slash
|
|
matches the whole repo-relative path (`.github/workflows/*`), so a
|
|
directory-shaped marker cannot be satisfied by a same-named file
|
|
somewhere else.
|
|
"""
|
|
marker = marker.strip()
|
|
if not marker:
|
|
return False
|
|
if "/" in marker:
|
|
return fnmatch.fnmatchcase(path, marker)
|
|
return fnmatch.fnmatchcase(path.rsplit("/", 1)[-1], marker)
|
|
|
|
|
|
def detect(catalog: list, paths: list[str]) -> list[int]:
|
|
"""The ids of every catalog platform at least one of whose markers matches
|
|
at least one path. Pure, so it is testable against a fixture tree with no
|
|
forge and no database. A platform with no markers is never detected —
|
|
that is what declare-only means."""
|
|
hits: list[int] = []
|
|
for platform in catalog:
|
|
markers = list(getattr(platform, "markers", None) or [])
|
|
if markers and any(marker_matches(m, p) for m in markers for p in paths):
|
|
hits.append(platform.id)
|
|
return hits
|
|
|
|
|
|
# --- membership ----------------------------------------------------------------
|
|
|
|
async def project_platforms(user_id: int, project_id: int) -> list[dict] | None:
|
|
"""Every platform the project has an answer for, joined to the catalog.
|
|
None when the caller cannot read the project.
|
|
|
|
Rejected rows are included — a settings screen has to show "no" as well
|
|
as "yes", or it cannot let anyone change their mind.
|
|
"""
|
|
if not await access.can_read_project(user_id, project_id):
|
|
return None
|
|
async with async_session() as session:
|
|
rows = (await session.execute(
|
|
select(ProjectPlatform, Platform)
|
|
.join(Platform, Platform.id == ProjectPlatform.platform_id)
|
|
.where(ProjectPlatform.project_id == project_id, Platform.deleted_at.is_(None))
|
|
.order_by(Platform.order_index.asc(), Platform.name.asc())
|
|
)).all()
|
|
return [
|
|
{"id": p.id, "slug": p.slug, "name": p.name, "state": m.state}
|
|
for m, p in rows
|
|
]
|
|
|
|
|
|
def members(platforms: list[dict]) -> list[dict]:
|
|
"""The rows of `project_platforms` that are membership — the brief form
|
|
enter_project and get_project carry."""
|
|
return [
|
|
{"slug": p["slug"], "name": p["name"], "state": p["state"]}
|
|
for p in platforms if p["state"] in MEMBER_STATES
|
|
]
|
|
|
|
|
|
def validate_updates(updates) -> str | None:
|
|
"""The error a set of membership answers would earn, or None. Pure.
|
|
|
|
`updates` is {slug: "declared" | "rejected" | None}. None withdraws the
|
|
answer — the row goes, and detection may add the platform again later.
|
|
`detected` is not settable: it is what the refresh writes, and a person
|
|
claiming it would erase the difference between the two."""
|
|
if not isinstance(updates, dict):
|
|
return "platforms must be an object of {slug: state}"
|
|
for slug, state in updates.items():
|
|
if not isinstance(slug, str) or not slug:
|
|
return "each platform is named by its slug"
|
|
if state is not None and state not in SETTABLE_STATES:
|
|
return (
|
|
f"'{state}' is not a state a person sets — use one of "
|
|
f"{', '.join(SETTABLE_STATES)}, or null to withdraw the answer"
|
|
)
|
|
return None
|
|
|
|
|
|
async def set_project_platforms(
|
|
user_id: int, project_id: int, updates: dict[str, str | None],
|
|
) -> list[dict]:
|
|
"""Apply a person's answers about a project's platforms. Write-gated on
|
|
the project. Platforms not named are left exactly as they are.
|
|
|
|
Raises ValueError, naming the problem, on a malformed update, an unknown
|
|
slug, or no write access — before anything is written, so the update
|
|
applies whole or not at all.
|
|
"""
|
|
error = validate_updates(updates)
|
|
if error:
|
|
raise ValueError(error)
|
|
if not await access.can_write_project(user_id, project_id):
|
|
raise ValueError(f"project {project_id} not found or no write access")
|
|
known, unknown = await resolve_slugs(list(updates))
|
|
if unknown:
|
|
raise ValueError(f"unknown platform(s): {', '.join(unknown)} (list_platforms)")
|
|
async with async_session() as session:
|
|
existing = {
|
|
row.platform_id: row for row in (await session.execute(
|
|
select(ProjectPlatform).where(ProjectPlatform.project_id == project_id)
|
|
)).scalars().all()
|
|
}
|
|
for slug, state in updates.items():
|
|
pid = known[slug]
|
|
row = existing.get(pid)
|
|
if state is None:
|
|
if row is not None:
|
|
await session.delete(row)
|
|
elif row is None:
|
|
session.add(ProjectPlatform(project_id=project_id, platform_id=pid, state=state))
|
|
else:
|
|
row.state = state
|
|
await session.commit()
|
|
return await project_platforms(user_id, project_id) or []
|
|
|
|
|
|
async def record_detected(project_id: int, platform_ids: list[int]) -> list[int]:
|
|
"""Add `detected` membership for each platform the project has NO answer
|
|
for. Returns the ids actually added.
|
|
|
|
The refresh's writer, so it takes no user: it runs on the owner's behalf
|
|
inside a sync the owner's keyring authorised. It never updates or deletes
|
|
a row — see the module docstring for why that is the whole contract.
|
|
"""
|
|
if not platform_ids:
|
|
return []
|
|
async with async_session() as session:
|
|
answered = set((await session.execute(
|
|
select(ProjectPlatform.platform_id).where(
|
|
ProjectPlatform.project_id == project_id,
|
|
)
|
|
)).scalars().all())
|
|
added = [pid for pid in dict.fromkeys(platform_ids) if pid not in answered]
|
|
for pid in added:
|
|
session.add(ProjectPlatform(project_id=project_id, platform_id=pid, state="detected"))
|
|
await session.commit()
|
|
return added
|
|
|
|
|
|
async def detect_for_project(project_id: int, paths: list[str]) -> list[int]:
|
|
"""Run detection over a refresh's paths and record what is new. The
|
|
coverage refresh's single call — it must not be able to fail that
|
|
refresh, so the caller wraps it."""
|
|
hits = detect(await list_platforms(), paths)
|
|
added = await record_detected(project_id, hits)
|
|
if added:
|
|
logger.info("project %s: detected platform(s) %s", project_id, added)
|
|
return added
|
|
|