CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 32s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 33s
Milestone 333 step 5, and rule 27 — the counter had a tuning point from step 4 and no operator-facing one until now. The task said to reuse the snippet badge's classes rather than mint a parallel set, citing the eight duplicated CSS families the ledger already carries (#3207). `.usage-tag` lived in SnippetListView's SCOPED block, so "reuse" was not available: copying it into the rule pane would have been the ninth family, and importing it is not a thing a scoped block permits. So it was promoted rather than copied. Three pieces, each of which existed once and now exists once: - `components.css` gains `.usage-tag` / `.usage-dead`, geometry and colour only, with the scoped original deleted rather than left behind. - `UsageBadge.vue` holds the logic the two lists would otherwise duplicate — the >=3 dead-weight threshold, the empty-string-renders-nothing rule, the tooltip. - `types/usage.ts` holds `RecordUsage`, one client type over two tables. `SnippetUsage` becomes an alias, so no existing consumer changes. THE ADVICE IS A PROP, and that is the substance rather than the plumbing. The counts read identically for every kind; the remedy does not. A snippet offered and never opened should probably be rewritten or deleted — one action. A rule in the same position has TWO possible causes and the operator has to pick: its trigger may fire on the wrong work, in which case `when_to_apply` wants rewording, or it may genuinely not be wanted. Baking "delete it" into the component would give the wrong nudge half the time on the surface where being wrong is most expensive, since a deleted rule stops binding behaviour. The route zero-fills every row through `usage_for_rules`, one aggregate per page — per-row would be N+1 by construction. That matters more here than for snippets: every rule on every existing install predates `rule_usage_events`, so the zero-filled shape IS the common case for a while, and a route that attached the key only where it found events would leave the badge reading undefined on almost every row. `usage_for_rules` had no test at all — step 1 covered the write path and the zero shape and left the aggregate uncovered, which only became load-bearing when a list started rendering it. It now has an integration test over real Postgres, including that a rule with no events comes back zero-filled rather than absent. Recorded as snippet #3460, per the design system's own instruction that the component layer lives as snippets rather than as prose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TcCs1CcQ1ormdnzSshKqvN
211 lines
7.9 KiB
TypeScript
211 lines
7.9 KiB
TypeScript
import type { RecordUsage } from "@/types/usage";
|
|
|
|
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
|
|
|
/** One canonical location of a reusable thing. A snippet that unifies several
|
|
* one-offs carries several — one per call site. */
|
|
export interface SnippetLocation {
|
|
repo: string;
|
|
path: string;
|
|
symbol: string;
|
|
}
|
|
|
|
/** Structured fields parsed out of a snippet note (mirrors the backend
|
|
* `parse_snippet_fields` — see services/snippets.py). `repo`/`path`/`symbol`
|
|
* mirror the first location for back-compat; `locations` is the full list. */
|
|
export interface SnippetFields {
|
|
name: string;
|
|
when_to_use: string;
|
|
signature: string;
|
|
language: string;
|
|
repo: string;
|
|
path: string;
|
|
symbol: string;
|
|
locations: SnippetLocation[];
|
|
/** The snippets folded into this one by merge, oldest first. Read-only: merge
|
|
* is the only thing that adds to it, and an edit carries it forward.
|
|
*
|
|
* Each entry records what THAT source contributed — never what the survivor
|
|
* already had — which is what lets un-merge subtract exactly. An entry with
|
|
* no `locations`/`tags` predates that attribution and cannot be un-merged. */
|
|
merged_from: { id: number; locations?: SnippetLocation[]; tags?: string[] }[];
|
|
code: string;
|
|
}
|
|
|
|
/** A full snippet record: the note dict plus the parsed `snippet` sub-object,
|
|
* as returned by the backend `snippet_to_dict`. */
|
|
export interface Snippet {
|
|
id: number;
|
|
title: string;
|
|
body: string;
|
|
tags: string[];
|
|
note_type: string;
|
|
project_id: number | null;
|
|
permission?: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
snippet: SnippetFields;
|
|
systems?: { id: number; name: string }[];
|
|
/** Set when another user owns this record; `owner` is their username and
|
|
* `permission` your access level on it. */
|
|
shared?: boolean;
|
|
owner?: string | null;
|
|
}
|
|
|
|
/** Kept as a name because every consumer here says "snippet usage" — but it IS
|
|
* the shared shape, since rules answer the same question off their own table
|
|
* (milestone 333). The reasoning lives on `RecordUsage`; duplicating the four
|
|
* fields here is how the two drift. */
|
|
export type SnippetUsage = RecordUsage;
|
|
|
|
/** Result of the last drift check — does the recorded location and code still
|
|
* match source? The check runs agent-side (Scribe has no checkout); this is the
|
|
* remembered verdict. `current` is false once the snippet has been edited since
|
|
* the check, at which point the verdict describes code that's no longer there. */
|
|
export interface SnippetVerification {
|
|
status: "ok" | "missing" | "moved" | "changed" | "unverified";
|
|
current: boolean;
|
|
checked_at: string | null;
|
|
detail?: string | null;
|
|
path?: string | null;
|
|
needs_attention?: boolean;
|
|
}
|
|
|
|
/** Lightweight list item from the knowledge preview feed. Note: the `snippet`
|
|
* field here is a truncated *body preview* (the knowledge feed's naming), not
|
|
* the parsed fields above. */
|
|
export interface SnippetListItem {
|
|
id: number;
|
|
title: string;
|
|
tags: string[];
|
|
note_type: string;
|
|
snippet: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
/** Present only when the record belongs to someone else — a suggestion from
|
|
* them, not one of your own. Absent means it's yours. */
|
|
shared?: boolean;
|
|
owner?: string | null;
|
|
/** The recorded language, when one was given. Projected from the `data`
|
|
* mirror so lists can show it without parsing the body — and so a prior-art
|
|
* hit can be flagged as being in a DIFFERENT language than the file being
|
|
* written, which is a shape to adapt rather than code to paste. */
|
|
language?: string;
|
|
/** Always present from the backend, zero-filled for records with no events. */
|
|
usage?: SnippetUsage;
|
|
/** Present on the detail record; the list feed carries it when a check has
|
|
* been recorded. */
|
|
verification?: SnippetVerification;
|
|
}
|
|
|
|
/** Create/update payload — discrete fields the backend serializes into the
|
|
* note (title/body/tags). Empty strings are applied; omitted keys are left
|
|
* unchanged on update. */
|
|
export interface SnippetInput {
|
|
name: string;
|
|
code: string;
|
|
language?: string;
|
|
signature?: string;
|
|
when_to_use?: string;
|
|
locations?: SnippetLocation[];
|
|
tags?: string[];
|
|
project_id?: number | null;
|
|
system_ids?: number[];
|
|
/** Create only: record it even though a near-duplicate already exists. */
|
|
force?: boolean;
|
|
}
|
|
|
|
/** `repo`/`path`/`symbol` are the reverse lookup — "what already lives here?"
|
|
* They must all match the same recorded location; `path` also matches anything
|
|
* beneath it. Combinable with `q`. */
|
|
export async function listSnippets(
|
|
params: {
|
|
q?: string;
|
|
tag?: string;
|
|
projectId?: number | null;
|
|
repo?: string;
|
|
path?: string;
|
|
symbol?: string;
|
|
/** Drift check: "attention" | "ok" | "unverified" | "drifted" | a status. */
|
|
verification?: string;
|
|
} = {},
|
|
): Promise<{ snippets: SnippetListItem[]; total: number }> {
|
|
const qs = new URLSearchParams();
|
|
if (params.q) qs.set("q", params.q);
|
|
if (params.tag) qs.set("tag", params.tag);
|
|
if (params.projectId) qs.set("project_id", String(params.projectId));
|
|
if (params.repo) qs.set("repo", params.repo);
|
|
if (params.path) qs.set("path", params.path);
|
|
if (params.symbol) qs.set("symbol", params.symbol);
|
|
if (params.verification) qs.set("verification", params.verification);
|
|
const query = qs.toString();
|
|
return apiGet(`/api/snippets${query ? `?${query}` : ""}`);
|
|
}
|
|
|
|
export async function getSnippet(id: number): Promise<Snippet> {
|
|
return apiGet(`/api/snippets/${id}`);
|
|
}
|
|
|
|
export async function createSnippet(data: SnippetInput): Promise<Snippet> {
|
|
return apiPost("/api/snippets", data);
|
|
}
|
|
|
|
export async function updateSnippet(
|
|
id: number,
|
|
data: Partial<SnippetInput>,
|
|
): Promise<Snippet> {
|
|
return apiPatch(`/api/snippets/${id}`, data);
|
|
}
|
|
|
|
export async function deleteSnippet(id: number): Promise<void> {
|
|
return apiDelete(`/api/snippets/${id}`);
|
|
}
|
|
|
|
/** A set of snippets that resemble each other closely enough to be worth
|
|
* merging. Grouping is transitive, so a set can hold members that don't
|
|
* directly resemble each other — read it as a proposal, not a verdict. */
|
|
export interface DuplicateGroup {
|
|
note_ids: number[];
|
|
snippets: { id: number; title: string }[];
|
|
/** The strongest resemblance within the set — how confident the suggestion is. */
|
|
top_score: number;
|
|
}
|
|
|
|
/** Near-duplicates already in the record. The create gate prevents new ones and
|
|
* merge cures the ones you point it at; this is what finds them. */
|
|
export async function findDuplicateSnippets(
|
|
threshold?: number,
|
|
): Promise<{ groups: DuplicateGroup[]; threshold: number }> {
|
|
const qs = threshold ? `?threshold=${threshold}` : "";
|
|
return apiGet(`/api/snippets/duplicates${qs}`);
|
|
}
|
|
|
|
/** Record a drift-check verdict. The check itself runs where the code is — an
|
|
* agent with the working tree — since Scribe has no checkout. This stores what
|
|
* was found, and is how the UI clears a stale marker after a manual fix. */
|
|
export async function verifySnippet(
|
|
id: number,
|
|
verdict: { status: string; detail?: string; path?: string },
|
|
): Promise<Snippet> {
|
|
return apiPost(`/api/snippets/${id}/verify`, verdict);
|
|
}
|
|
|
|
/** Pull one source back out of a merged survivor: restores it and strips exactly
|
|
* what it contributed. Also repairs a half-undone merge — a source restored
|
|
* from the trash by hand leaves the survivor still claiming its call sites. */
|
|
export async function unmergeSnippet(
|
|
survivorId: number,
|
|
sourceId: number,
|
|
): Promise<{ survivor: Snippet; restored: Snippet | null }> {
|
|
return apiPost(`/api/snippets/${survivorId}/unmerge`, { source_id: sourceId });
|
|
}
|
|
|
|
/** Unify `sourceIds` into the canonical snippet `targetId`. Returns the merged
|
|
* survivor plus `merged_ids` — the sources actually folded in and trashed. */
|
|
export async function mergeSnippets(
|
|
targetId: number,
|
|
sourceIds: number[],
|
|
): Promise<Snippet & { merged_ids: number[] }> {
|
|
return apiPost(`/api/snippets/${targetId}/merge`, { source_ids: sourceIds });
|
|
}
|