diff --git a/alembic/versions/0113_system_path_patterns.py b/alembic/versions/0113_system_path_patterns.py new file mode 100644 index 0000000..e63e3c9 --- /dev/null +++ b/alembic/versions/0113_system_path_patterns.py @@ -0,0 +1,34 @@ +"""system_path_patterns — a System names the files that are its area +(milestone 444 step 3, #4756) + +Revision ID: 0113 +Revises: 0112 +Create Date: 2026-10-02 + +A JSONB list of globs relative to the repo root. NOT NULL with a `[]` +default, so an existing System reads as "no paths yet" rather than as NULL. No +backfill: which files are which area is a judgment, and the charters that name +directories in prose are not a mapping anyone confirmed. +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "0113" +down_revision = "0112" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "systems", + sa.Column( + "path_patterns", postgresql.JSONB(), nullable=False, + server_default=sa.text("'[]'::jsonb"), + ), + ) + + +def downgrade() -> None: + op.drop_column("systems", "path_patterns") diff --git a/alembic/versions/0114_system_usage_events.py b/alembic/versions/0114_system_usage_events.py new file mode 100644 index 0000000..86734a8 --- /dev/null +++ b/alembic/versions/0114_system_usage_events.py @@ -0,0 +1,45 @@ +"""system_usage_events — were an area's rulings read once its files were +touched? (milestone 444 step 4, #4757) + +Revision ID: 0114 +Revises: 0113 +Create Date: 2026-10-03 + +The usage twin for Systems, beside note_usage_events and rule_usage_events and +separate from both for the same reason they are separate from each other: a +System id restores through its own map. FK-free like its siblings; no CHECK on +`event`, like its siblings. +""" +import sqlalchemy as sa +from alembic import op + +revision = "0114" +down_revision = "0113" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "system_usage_events", + sa.Column("id", sa.BigInteger(), primary_key=True), + sa.Column( + "created_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.text("now()"), + ), + sa.Column("user_id", sa.BigInteger(), nullable=True), + sa.Column("system_id", sa.BigInteger(), nullable=False), + sa.Column("event", sa.Text(), nullable=False), + sa.Column("source", sa.Text(), nullable=False), + sa.Column("project_id", sa.BigInteger(), nullable=True), + ) + op.create_index( + "ix_system_usage_system_event", "system_usage_events", ["system_id", "event"] + ) + op.create_index("ix_system_usage_created_at", "system_usage_events", ["created_at"]) + + +def downgrade() -> None: + op.drop_index("ix_system_usage_created_at", table_name="system_usage_events") + op.drop_index("ix_system_usage_system_event", table_name="system_usage_events") + op.drop_table("system_usage_events") diff --git a/frontend/src/api/systems.ts b/frontend/src/api/systems.ts index 24d3637..7d19ed1 100644 --- a/frontend/src/api/systems.ts +++ b/frontend/src/api/systems.ts @@ -14,6 +14,12 @@ export interface System { color: string | null; status: "active" | "archived"; order_index: number; + /** + * The files that are this area, as globs relative to the repo root: `*` + * within one directory, `**` across any depth, a plain directory covering + * everything under it. Empty means the area has not named its files. + */ + path_patterns: string[]; open_issue_count: number; created_at: string | null; updated_at: string | null; @@ -39,7 +45,13 @@ export interface CreatedSystem extends System { export async function createSystem( projectId: number, - data: { name: string; description?: string; color?: string; canonical_id?: number }, + data: { + name: string; + description?: string; + color?: string; + canonical_id?: number; + path_patterns?: string[]; + }, ): Promise { return apiPost(`/api/projects/${projectId}/systems`, data); } @@ -53,6 +65,7 @@ export async function updateSystem( color: string | null; status: "active" | "archived"; order_index: number; + path_patterns: string[]; }>, ): Promise { return apiPatch(`/api/projects/${projectId}/systems/${systemId}`, data); diff --git a/frontend/src/components/SystemsSection.vue b/frontend/src/components/SystemsSection.vue index d3411f7..f69fffd 100644 --- a/frontend/src/components/SystemsSection.vue +++ b/frontend/src/components/SystemsSection.vue @@ -28,6 +28,7 @@ const newDescription = ref(""); // feature two matchers to keep in step, which is the exact drift the catalog // exists to end. The server still applies an exact hit on submit. const newCanonicalId = ref(null); +const newPaths = ref(""); const creating = ref(false); // An `overlap` the server offered after a create — an offer, never applied. const suggestion = ref<{ systemId: number; match: CanonicalMatch } | null>(null); @@ -37,6 +38,7 @@ const editingId = ref(null); const editName = ref(""); const editDescription = ref(""); const editCanonicalId = ref(null); +const editPaths = ref(""); const savingEdit = ref(false); // Mapping review @@ -55,6 +57,16 @@ const visibleSystems = computed(() => const proposals = computed(() => canon.proposalsByProject[props.projectId] ?? []); +// The files field is one pattern per line. The server is the one that +// validates and tidies them, so this only splits — a second validator here +// would be a second answer to "is this pattern acceptable". +function parsePaths(text: string): string[] { + return text + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + function areaName(system: System): string | null { return canon.byId(system.canonical_id)?.name ?? null; } @@ -89,6 +101,7 @@ function openCreate() { newName.value = ""; newDescription.value = ""; newCanonicalId.value = null; + newPaths.value = ""; } function cancelCreate() { @@ -96,6 +109,7 @@ function cancelCreate() { newName.value = ""; newDescription.value = ""; newCanonicalId.value = null; + newPaths.value = ""; } async function submitCreate() { @@ -107,6 +121,7 @@ async function submitCreate() { name, description: newDescription.value.trim() || undefined, canonical_id: newCanonicalId.value ?? undefined, + path_patterns: parsePaths(newPaths.value), }); cancelCreate(); if (created.canonical_suggestion) { @@ -157,6 +172,7 @@ function startEdit(system: System) { editName.value = system.name; editDescription.value = system.description; editCanonicalId.value = system.canonical_id; + editPaths.value = system.path_patterns.join("\n"); } function cancelEdit() { @@ -171,6 +187,7 @@ async function submitEdit(system: System) { await store.updateSystem(props.projectId, system.id, { name, description: editDescription.value.trim(), + path_patterns: parsePaths(editPaths.value), }); // The mapping is a separate write with its own validation — one column, // one writer (services/canonical_systems.set_system_canonical). @@ -180,8 +197,9 @@ async function submitEdit(system: System) { } editingId.value = null; toast.show("System updated"); - } catch { - toast.show("Failed to update system", "error"); + } catch (e) { + // A refused pattern comes back as a 400 naming it — say which. + toast.show(apiErrorMessage(e, "Failed to update system"), "error"); } finally { savingEdit.value = false; } @@ -337,6 +355,20 @@ async function confirmDelete() { Files this system under an area shared by every project. Your name stays as you typed it. +

{{ system.description }}

+
    +
  • {{ pattern }}
  • +