From 8391b53e006d98998de18f56d341347d84b848d4 Mon Sep 17 00:00:00 2001 From: Renato Guimaraes Date: Sun, 23 Aug 2026 13:13:59 -0300 Subject: [PATCH 1/4] feat(platform): board flow analysis from GitHub Projects V2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an ingestion + metrics module for GitHub Projects boards: lead time, time per column, throughput, WIP aging, CFD and bottleneck signals. The engine deliberately measures PR-open-to-merge and says nothing about the queue in front of it; board data is the missing denominator. If AI shortens coding but total lead time does not move, the constraint is outside the code — that is the question this makes answerable. No snapshot collector, and that is the design decision worth reviewing. Projects V2 exposes ProjectV2ItemStatusChangedEvent on the content's timeline, carrying createdAt, previousStatus, status and wasAutomated. Transition history is therefore readable retroactively at second precision on the first sync — no accumulation period, no +/-24h detection window, no blind spot for two moves inside one interval. Verified against a live board before writing any code. Structure follows the Datadog integration: a client, a syncOrganization that never throws, idempotent upserts keyed by the provider's own node ids, and a slot in the existing 04:00 UTC cron. Metrics live in lib/queries as pure functions, unit-tested like cycle-time-flow.ts. Quality gates run before any metric is trusted. Un-gated board data produced a median lead time of a fraction of a day on a real board — flattering and false, caused by setup cards created and closed minutes apart. Six gates report severity, the measured value, affected items and the impact on the reading; metrics still compute, but never without the caveat. Nothing encodes a particular workflow. Column names are free text, mapped to lifecycle buckets by per-board config first and generic EN/PT name heuristics second; unmatched columns are reported, never silently treated as not-done. Fixtures are fictional. Honesty rules that shaped the code: - lead time falls back transitions -> closedAt -> updatedAt, and anything past the first rung is labelled approximate; updatedAt is never used to invent a lead time for work still in flight - P95 withheld below 20 observations, everything but the median below 10 - re-entering a column accumulates both visits instead of overwriting - removal from the board is an exit, never a completion - drafts have no timeline on the API, so they are excluded from duration metrics rather than counted as zero, with coverage reported - assignee concentration describes the board, never a person: no login is returned, only the share (Principle #2) Little's Law is returned beside observed lead time, not instead of it — a large divergence points at phantom WIP or a mis-mapped terminal column. No UI in this change: the collector and the gates are the foundation, and metrics over unvalidated data are worth nothing. Dashboard follows once the numbers are validated against a real board. Co-authored-by: Claude Opus 5 (1M context) --- docs/integrations/github-projects.md | 221 ++++++ .../integrations/github-projects/client.ts | 558 ++++++++++++++ .../lib/integrations/github-projects/sync.ts | 536 ++++++++++++++ platform/lib/queries/board-flow.ts | 693 ++++++++++++++++++ platform/lib/queries/board-quality.ts | 380 ++++++++++ .../app/api/cron/sync-integrations/route.ts | 41 ++ platform/src/types/board-flow.ts | 252 +++++++ .../migrations/023_github_projects.sql | 146 ++++ platform/tests/board-flow.test.ts | 598 +++++++++++++++ platform/tests/board-quality.test.ts | 328 +++++++++ platform/tests/github-projects-sync.test.ts | 75 ++ 11 files changed, 3828 insertions(+) create mode 100644 docs/integrations/github-projects.md create mode 100644 platform/lib/integrations/github-projects/client.ts create mode 100644 platform/lib/integrations/github-projects/sync.ts create mode 100644 platform/lib/queries/board-flow.ts create mode 100644 platform/lib/queries/board-quality.ts create mode 100644 platform/src/types/board-flow.ts create mode 100644 platform/supabase/migrations/023_github_projects.sql create mode 100644 platform/tests/board-flow.test.ts create mode 100644 platform/tests/board-quality.test.ts create mode 100644 platform/tests/github-projects-sync.test.ts diff --git a/docs/integrations/github-projects.md b/docs/integrations/github-projects.md new file mode 100644 index 0000000..0cc2dbf --- /dev/null +++ b/docs/integrations/github-projects.md @@ -0,0 +1,221 @@ +# GitHub Projects Integration + +Iris reads a GitHub Projects V2 board to measure delivery flow: lead time, +time in each column, throughput, WIP aging and bottlenecks. + +It exists because the engine deliberately measures a narrow window. Cycle time +in `flow_efficiency.py` runs from PR open to merge and says nothing about the +queue in front of it. If AI shortens the coding phase but total lead time does +not move, the constraint sits outside the code — and only board data can show +that. + +This page covers what is read, what is computed, and what the data cannot +support. + +--- + +## The API fact that shapes everything + +The intuitive design for "how long did each card sit in each column" is a +periodic snapshot of the board plus a diffing job. For Projects V2 that design +is unnecessary. + +The GraphQL API exposes `ProjectV2ItemStatusChangedEvent` on the timeline of +the item's issue or pull request: + +```graphql +... on ProjectV2ItemStatusChangedEvent { + createdAt # when + previousStatus # from + status # to + wasAutomated # automation vs. human + project { id } # which board +} +``` + +Alongside it, `AddedToProjectV2Event` and `RemovedFromProjectV2Event` mark board +entry and exit. + +Consequences: + +- **History is retroactive.** The first sync backfills a board's full past. No + accumulation period before the first number is available. +- **Precision is exact**, to the second — not bounded by a collection interval. +- **Cumulative flow diagrams are reconstructable** for any past week. +- `wasAutomated` distinguishes workflow automation from a person moving a card, + which is a better bulk-movement signal than inferring it from timestamps. + +Verify it yourself on any issue that has moved columns: + +```bash +gh api graphql -f query=' +query { + repository(owner:"OWNER", name:"REPO") { + issue(number: NUM) { + timelineItems(first:100, itemTypes:[PROJECT_V2_ITEM_STATUS_CHANGED_EVENT]) { + nodes { + ... on ProjectV2ItemStatusChangedEvent { + createdAt previousStatus status wasAutomated + } + } + } + } + } +}' +``` + +### The one gap: draft items + +`DraftIssue` is not an Issue and has no `timelineItems` field, so drafts carry +current status but no history. They are marked `history_available = false` and +excluded from every duration metric — never counted as zero. The +`history_coverage` gate reports how much of the board that leaves out. + +--- + +## Setup + +### 1. Token + +A token with `read:project` on the board's owner (plus `repo` to read private +repository content). Stored encrypted at rest with `pgp_sym_encrypt`, keyed by +the deployment's `INTEGRATIONS_ENCRYPTION_KEY`, exactly like the Datadog +credentials. + +### 2. Configure the boards + +One `org_integrations` row with `provider = 'github_projects'`, whose `config` +lists the boards to sync: + +```json +{ + "boards": [ + { + "owner": "acme-inc", + "ownerType": "organization", + "number": 42, + "teamSlug": "platform", + "statusConfig": { + "backlog": ["Backlog", "Icebox"], + "discovery": ["Discovery", "Refinement"], + "queue": ["Ready for Development", "Ready for Deploy"], + "active": ["In Progress", "Code Review", "Validation"], + "done": ["Done", "Cancelled"] + } + } + ] +} +``` + +- `number` is the project number from its URL. +- `teamSlug` is a free-form grouping label. Iris has no team entity of its own + and never interprets this string — it groups by it. +- `statusConfig` is optional. When absent, columns are classified by generic + name heuristics (see below). A malformed board entry is skipped, not fatal. + +### 3. Sync + +The existing daily cron (`/api/cron/sync-integrations`, 04:00 UTC) picks the +provider up automatically. The first run backfills; later runs only re-read the +timeline of items whose board `updatedAt` moved, so the daily cost tracks board +activity rather than board size. + +--- + +## Column classification + +Column names are free text on every board, so nothing is hardcoded to a +particular workflow. Each column maps to a lifecycle bucket, from explicit +`statusConfig` first and generic name patterns second: + +| Bucket | Meaning | Matched by (when unconfigured) | +|---|---|---| +| `backlog` | Not started | backlog, icebox, inbox, triage, todo, new | +| `discovery` | Being defined | discovery, refinement, grooming, spec, design, analysis | +| `queue` | Waiting, not being worked | ready, awaiting, waiting, blocked, pending, hold | +| `active` | Work in progress | progress, doing, dev, review, test, qa, validation, deploy, monitoring | +| `done` | Terminal | done, closed, complete, shipped, delivered, cancelled | + +Patterns cover English and Portuguese vocabularies. Anything unmatched is +reported in `unmappedStatuses` rather than silently treated as not-done — a +column quietly assumed non-terminal distorts lead time for every item ending +there. + +**Why `queue` exists beyond the obvious four.** Boards routinely have columns +that are neither backlog nor work in flight ("Ready for Deploy", "Blocked"). +Counting them as `active` inflates flow efficiency, whose whole purpose is +exposing invisible waiting; counting them as `backlog` corrupts backlog growth. +They get their own bucket: waiting, but not backlog. + +--- + +## Quality gates + +Gates run **before** any metric is trusted, and they are not a footnote. On a +real board, un-gated data produced a median lead time of a fraction of a day — +flattering and false, caused by a batch of setup cards created and closed +minutes apart. + +| Gate | Detects | Why it matters | +|---|---|---| +| `synthetic_items` | Test-looking titles; same-minute bursts that also died within minutes | Scaffolding lands in the fast tail and collapses the median | +| `done_not_closed` | Terminal column with an open issue | Makes `closedAt` unusable as the lead-time fallback | +| `bulk_movement` | 5+ items moved within 2 minutes; GitHub's `wasAutomated` | Records board maintenance, not flow | +| `field_completeness` | Fill rate of priority / size / iteration / assignee | Decides which cuts are trustworthy | +| `assignee_concentration` | Share held by the most-assigned account | Board may be a personal list, not group work | +| `history_coverage` | Share of items with real history | The honest ceiling on duration analysis | + +Each gate returns a severity, the measured value, the affected items and a +plain statement of the impact on the reading. Metrics are still computed when a +gate fires — they are just never shown without the caveat. + +A note on `assignee_concentration`: it describes the **board**, never a person. +No login is returned, only the share. Iris does not rank or score individuals +(see `docs/PRINCIPLES.md`), and nothing per-person is derived from it. + +--- + +## Metrics + +Computed by `platform/lib/queries/board-flow.ts` — pure functions, no I/O. + +| Metric | Definition | +|---|---| +| Lead time | Board entry → first arrival in a terminal column | +| Cycle time | First entry into an `active` column → terminal | +| Time per phase | Sum of intervals per column, accumulating re-entries, with per-column `n` | +| Flow efficiency | Active time ÷ lead time | +| Throughput | Items reaching terminal, per ISO week | +| Inflow / outflow | Arrivals vs. departures per week, plus the cumulative delta | +| WIP, aging WIP | Non-terminal items; age per column, median and max | +| Little's Law | WIP ÷ throughput, reported *beside* observed lead time | +| CFD | Item count per column at the end of each week | +| Percentiles | P50 / P70 / P85 / P95, with a sample guard | + +### Honesty rules + +- **Fallback ladder for lead time.** Transitions (exact) → `closedAt` → + `item.updatedAt`. Anything past the first rung sets `leadTimeSource`, and the + item is marked `approximate`. `updatedAt` is only used for items the board + already considers finished; for open work it would invent a lead time. +- **Sample guards.** Below 20 observations P95 is withheld; below 10, only the + median plus the raw distribution. `suppressed` names what was withheld. +- **Re-entry accumulates.** A card going back a column produces a second visit; + per-column time sums both passes. +- **Removal is not completion.** `RemovedFromProjectV2Event` closes the current + visit and marks the item off-board. Throughput never counts it. +- **Little's Law is a check, not a headline.** Predicted and observed lead time + are returned together; a large divergence usually means phantom WIP or a + mis-mapped terminal column. + +--- + +## Not in scope + +- **No writes.** Read-only; Iris never moves a card or edits a field. +- **No reconstruction before the first event.** If the API has no history for + an item, the answer is "not measurable", never a heuristic estimate. +- **No individual ranking.** Permanent, per Principle #2. +- **Only the Status field has history.** Other fields (priority, size, + iteration) are captured as current values; the API exposes no comparable + change event for them. diff --git a/platform/lib/integrations/github-projects/client.ts b/platform/lib/integrations/github-projects/client.ts new file mode 100644 index 0000000..153a500 --- /dev/null +++ b/platform/lib/integrations/github-projects/client.ts @@ -0,0 +1,558 @@ +/** + * GitHub Projects V2 GraphQL client. + * + * Two reads, both validated against the live API: + * + * 1. `fetchProjectItems` — the board's current state: every item with its + * Status/Iteration/Priority/Size field values and its content (Issue, + * PullRequest or DraftIssue). + * + * 2. `fetchStatusHistory` — the transition history, from + * `ProjectV2ItemStatusChangedEvent` on the *content's* timeline. This is + * what makes phase durations computable retroactively; see the header of + * migration 023 for why there is no snapshot mechanism here. + * + * Two API facts shape this file: + * + * - History hangs off the Issue/PullRequest, not off the board item. Drafts + * have no timeline at all, so they yield no history — never a zero. + * - An issue's timeline carries events from *every* project it belongs to, so + * each event must be filtered by `project.id`. Skipping that filter mixes + * other teams' boards into this board's numbers. + */ + +const GITHUB_GRAPHQL_URL = "https://api.github.com/graphql"; + +/** Items per page. Kept modest because `fieldValues` multiplies node cost. */ +const ITEMS_PAGE_SIZE = 50; +/** Content ids per history request. */ +const HISTORY_BATCH_SIZE = 20; +/** Timeline events per page. */ +const TIMELINE_PAGE_SIZE = 100; +/** Safety valve against a pathological board pinning the sync forever. */ +const MAX_ITEM_PAGES = 200; +const MAX_TIMELINE_PAGES = 20; + +export interface GitHubProjectsCredentials { + /** Token with `read:project` (plus `repo` for private repo content). */ + token: string; +} + +export interface BoardRef { + ownerLogin: string; + ownerType: "organization" | "user"; + /** The project number as it appears in the URL. */ + number: number; +} + +export type ProjectContentType = "ISSUE" | "PULL_REQUEST" | "DRAFT_ISSUE"; + +export interface RawProjectItem { + /** Board item node id ("PVTI_..."). */ + itemId: string; + /** Content node id ("I_..."), absent for drafts — they have no timeline. */ + contentId: string | null; + contentType: ProjectContentType; + contentRepo: string | null; + contentNumber: number | null; + title: string; + /** OPEN | CLOSED | MERGED; null for drafts. */ + contentState: string | null; + createdAt: string | null; + closedAt: string | null; + itemUpdatedAt: string | null; + status: string | null; + iteration: string | null; + priority: string | null; + size: string | null; + assignees: string[]; + labels: string[]; +} + +export interface RawBoard { + projectId: string; + title: string; + items: RawProjectItem[]; +} + +export type StatusEventKind = "ADDED" | "STATUS_CHANGED" | "REMOVED"; + +export interface RawStatusEvent { + /** Timeline event node id — the idempotency key for persistence. */ + eventId: string; + contentId: string; + kind: StatusEventKind; + /** "" when GitHub reports no prior column; null on ADDED/REMOVED. */ + previousStatus: string | null; + status: string | null; + occurredAt: string; + wasAutomated: boolean; + actorLogin: string | null; +} + +export interface StatusHistory { + eventsByContentId: Map; + /** Content ids whose timeline had more pages than we walked. */ + truncatedContentIds: Set; +} + +export class GitHubProjectsError extends Error {} + +// --------------------------------------------------------------------------- +// Transport +// --------------------------------------------------------------------------- + +/** + * POST a GraphQL document. GitHub answers 200 with an `errors` array for + * query-level problems, so a non-throwing fetch is not a success. + * + * `NOT_FOUND` on individual `nodes` entries is tolerated by the caller (a + * deleted issue), which is why partial data is returned alongside errors + * rather than raising unconditionally. + */ +async function graphql( + creds: GitHubProjectsCredentials, + query: string, + variables: Record, +): Promise { + let response: Response; + try { + response = await fetch(GITHUB_GRAPHQL_URL, { + method: "POST", + headers: { + Authorization: `bearer ${creds.token}`, + "Content-Type": "application/json", + Accept: "application/vnd.github+json", + "User-Agent": "iris-github-projects", + }, + body: JSON.stringify({ query, variables }), + }); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new GitHubProjectsError(`GitHub GraphQL request failed: ${detail}`); + } + + if (response.status === 401 || response.status === 403) { + throw new GitHubProjectsError( + `GitHub rejected the token (HTTP ${response.status}). ` + + "The integration needs `read:project` on the owner.", + ); + } + if (response.status === 429 || response.status >= 500) { + throw new GitHubProjectsError( + `GitHub GraphQL unavailable (HTTP ${response.status}).`, + ); + } + if (!response.ok) { + throw new GitHubProjectsError(`GitHub GraphQL HTTP ${response.status}.`); + } + + const body = (await response.json()) as { + data?: T; + errors?: Array<{ message: string; type?: string }>; + }; + + if (body.errors?.length) { + // Missing nodes are expected (deleted content); anything else is fatal. + const fatal = body.errors.filter((e) => e.type !== "NOT_FOUND"); + if (fatal.length > 0 || !body.data) { + throw new GitHubProjectsError( + `GitHub GraphQL error: ${body.errors.map((e) => e.message).join("; ")}`, + ); + } + } + + if (!body.data) { + throw new GitHubProjectsError("GitHub GraphQL returned no data."); + } + return body.data; +} + +// --------------------------------------------------------------------------- +// 1. Board state +// --------------------------------------------------------------------------- + +const ITEM_FIELDS = ` + id + type + updatedAt + fieldValues(first: 20) { + nodes { + __typename + ... on ProjectV2ItemFieldSingleSelectValue { + name + field { ... on ProjectV2SingleSelectField { name } } + } + ... on ProjectV2ItemFieldIterationValue { + title + field { ... on ProjectV2IterationField { name } } + } + ... on ProjectV2ItemFieldTextValue { + text + field { ... on ProjectV2Field { name } } + } + } + } + content { + __typename + ... on Issue { + id number title state createdAt closedAt + repository { nameWithOwner } + assignees(first: 10) { nodes { login } } + labels(first: 20) { nodes { name } } + } + ... on PullRequest { + id number title state createdAt closedAt + repository { nameWithOwner } + assignees(first: 10) { nodes { login } } + labels(first: 20) { nodes { name } } + } + ... on DraftIssue { + id title createdAt updatedAt + assignees(first: 10) { nodes { login } } + } + } +`; + +function itemsQuery(ownerType: "organization" | "user"): string { + return ` + query ProjectItems($login: String!, $number: Int!, $cursor: String) { + ${ownerType}(login: $login) { + projectV2(number: $number) { + id + title + items(first: ${ITEMS_PAGE_SIZE}, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { ${ITEM_FIELDS} } + } + } + } + } + `; +} + +interface RawFieldValue { + __typename: string; + name?: string; + title?: string; + text?: string; + field?: { name?: string }; +} + +interface RawItemNode { + id: string; + type: string; + updatedAt: string | null; + fieldValues: { nodes: Array }; + content: { + __typename: string; + id?: string; + number?: number; + title?: string; + state?: string; + createdAt?: string; + closedAt?: string | null; + updatedAt?: string; + repository?: { nameWithOwner?: string }; + assignees?: { nodes: Array<{ login: string }> }; + labels?: { nodes: Array<{ name: string }> }; + } | null; +} + +/** Field names are per-board free text; match case-insensitively. */ +function readField( + values: Array, + fieldName: string, +): string | null { + const wanted = fieldName.toLowerCase(); + for (const v of values) { + if (!v?.field?.name) continue; + if (v.field.name.toLowerCase() !== wanted) continue; + return v.name ?? v.title ?? v.text ?? null; + } + return null; +} + +function toContentType(typename: string | undefined): ProjectContentType { + if (typename === "Issue") return "ISSUE"; + if (typename === "PullRequest") return "PULL_REQUEST"; + return "DRAFT_ISSUE"; +} + +function parseItem(node: RawItemNode): RawProjectItem { + const content = node.content; + const values = node.fieldValues?.nodes ?? []; + const contentType = toContentType(content?.__typename); + const isDraft = contentType === "DRAFT_ISSUE"; + + return { + itemId: node.id, + // Drafts get no contentId: without one, the history fetch skips them + // instead of asking for a timeline that cannot exist. + contentId: isDraft ? null : (content?.id ?? null), + contentType, + contentRepo: content?.repository?.nameWithOwner ?? null, + contentNumber: content?.number ?? null, + // The Title *field* wins over content title only when content is absent; + // a draft's title lives on the content itself. + title: content?.title ?? readField(values, "Title") ?? "(untitled)", + contentState: isDraft ? null : (content?.state ?? null), + createdAt: content?.createdAt ?? null, + closedAt: content?.closedAt ?? null, + itemUpdatedAt: node.updatedAt ?? null, + status: readField(values, "Status"), + iteration: readField(values, "Iteration"), + priority: readField(values, "Priority"), + size: readField(values, "Size") ?? readField(values, "Estimate"), + assignees: (content?.assignees?.nodes ?? []).map((a) => a.login), + labels: (content?.labels?.nodes ?? []).map((l) => l.name), + }; +} + +interface ProjectPayload { + projectV2: { + id: string; + title: string; + items: { + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + nodes: Array; + }; + } | null; +} + +interface ProjectItemsResponse { + organization?: ProjectPayload | null; + user?: ProjectPayload | null; +} + +/** Fetch every item on a board, paginating until exhausted. */ +export async function fetchProjectItems( + creds: GitHubProjectsCredentials, + board: BoardRef, +): Promise { + const query = itemsQuery(board.ownerType); + const items: RawProjectItem[] = []; + let cursor: string | null = null; + let projectId = ""; + let title = ""; + + for (let page = 0; page < MAX_ITEM_PAGES; page++) { + const data: ProjectItemsResponse = await graphql( + creds, + query, + { login: board.ownerLogin, number: board.number, cursor }, + ); + + const project = (data.organization ?? data.user)?.projectV2; + if (!project) { + throw new GitHubProjectsError( + `Project ${board.ownerLogin}/${board.number} not found ` + + "(wrong number, or the token cannot see it).", + ); + } + + projectId = project.id; + title = project.title; + for (const node of project.items.nodes) { + if (node) items.push(parseItem(node)); + } + + if (!project.items.pageInfo.hasNextPage) break; + cursor = project.items.pageInfo.endCursor; + } + + return { projectId, title, items }; +} + +// --------------------------------------------------------------------------- +// 2. Status history +// --------------------------------------------------------------------------- + +const TIMELINE_EVENT_FIELDS = ` + __typename + ... on ProjectV2ItemStatusChangedEvent { + id createdAt previousStatus status wasAutomated + actor { login } + project { id } + } + ... on AddedToProjectV2Event { + id createdAt wasAutomated + actor { login } + project { id } + } + ... on RemovedFromProjectV2Event { + id createdAt wasAutomated + actor { login } + project { id } + } +`; + +const TIMELINE_ITEM_TYPES = + "[PROJECT_V2_ITEM_STATUS_CHANGED_EVENT, ADDED_TO_PROJECT_V2_EVENT, " + + "REMOVED_FROM_PROJECT_V2_EVENT]"; + +const HISTORY_QUERY = ` + query ItemHistory($ids: [ID!]!, $cursor: String) { + nodes(ids: $ids) { + __typename + ... on Issue { + id + timelineItems(first: ${TIMELINE_PAGE_SIZE}, after: $cursor, itemTypes: ${TIMELINE_ITEM_TYPES}) { + pageInfo { hasNextPage endCursor } + nodes { ${TIMELINE_EVENT_FIELDS} } + } + } + ... on PullRequest { + id + timelineItems(first: ${TIMELINE_PAGE_SIZE}, after: $cursor, itemTypes: ${TIMELINE_ITEM_TYPES}) { + pageInfo { hasNextPage endCursor } + nodes { ${TIMELINE_EVENT_FIELDS} } + } + } + } + } +`; + +interface RawTimelineEvent { + __typename: string; + id?: string; + createdAt?: string; + previousStatus?: string | null; + status?: string | null; + wasAutomated?: boolean; + actor?: { login?: string } | null; + project?: { id?: string } | null; +} + +interface RawHistoryNode { + __typename: string; + id?: string; + timelineItems?: { + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + nodes: Array; + }; +} + +function eventKind(typename: string): StatusEventKind | null { + if (typename === "ProjectV2ItemStatusChangedEvent") return "STATUS_CHANGED"; + if (typename === "AddedToProjectV2Event") return "ADDED"; + if (typename === "RemovedFromProjectV2Event") return "REMOVED"; + return null; +} + +/** + * Convert a timeline node into events belonging to `projectId`. + * + * The project filter is the important part: an issue tracked on several boards + * emits status events for all of them onto the same timeline. + */ +function parseEvents( + contentId: string, + nodes: Array, + projectId: string, +): RawStatusEvent[] { + const out: RawStatusEvent[] = []; + for (const node of nodes) { + if (!node?.id || !node.createdAt) continue; + if (node.project?.id !== projectId) continue; + + const kind = eventKind(node.__typename); + if (!kind) continue; + + out.push({ + eventId: node.id, + contentId, + kind, + previousStatus: + kind === "STATUS_CHANGED" ? (node.previousStatus ?? "") : null, + status: kind === "STATUS_CHANGED" ? (node.status ?? null) : null, + occurredAt: node.createdAt, + wasAutomated: node.wasAutomated ?? false, + actorLogin: node.actor?.login ?? null, + }); + } + return out; +} + +/** + * Fetch status history for the given content ids, batched. + * + * Items are fetched in batches sharing one timeline cursor. When any node in a + * batch reports more pages, that node is re-walked on its own so a single + * long-lived issue cannot silently truncate its batch mates. + */ +export async function fetchStatusHistory( + creds: GitHubProjectsCredentials, + contentIds: string[], + projectId: string, +): Promise { + const eventsByContentId = new Map(); + const truncatedContentIds = new Set(); + + const append = (contentId: string, events: RawStatusEvent[]) => { + const existing = eventsByContentId.get(contentId); + if (existing) existing.push(...events); + else eventsByContentId.set(contentId, [...events]); + }; + + for (let i = 0; i < contentIds.length; i += HISTORY_BATCH_SIZE) { + const batch = contentIds.slice(i, i + HISTORY_BATCH_SIZE); + const data = await graphql<{ nodes: Array }>( + creds, + HISTORY_QUERY, + { ids: batch, cursor: null }, + ); + + for (const node of data.nodes ?? []) { + // Null node = content deleted since the board read. Not an error. + if (!node?.id || !node.timelineItems) continue; + + append( + node.id, + parseEvents(node.id, node.timelineItems.nodes, projectId), + ); + + if (node.timelineItems.pageInfo.hasNextPage) { + const remaining = await fetchRemainingTimeline( + creds, + node.id, + node.timelineItems.pageInfo.endCursor, + projectId, + ); + append(node.id, remaining.events); + if (remaining.truncated) truncatedContentIds.add(node.id); + } + } + } + + return { eventsByContentId, truncatedContentIds }; +} + +async function fetchRemainingTimeline( + creds: GitHubProjectsCredentials, + contentId: string, + startCursor: string | null, + projectId: string, +): Promise<{ events: RawStatusEvent[]; truncated: boolean }> { + const events: RawStatusEvent[] = []; + let cursor = startCursor; + + for (let page = 0; page < MAX_TIMELINE_PAGES; page++) { + const data = await graphql<{ nodes: Array }>( + creds, + HISTORY_QUERY, + { ids: [contentId], cursor }, + ); + const node = (data.nodes ?? [])[0]; + if (!node?.timelineItems) return { events, truncated: false }; + + events.push(...parseEvents(contentId, node.timelineItems.nodes, projectId)); + + if (!node.timelineItems.pageInfo.hasNextPage) { + return { events, truncated: false }; + } + cursor = node.timelineItems.pageInfo.endCursor; + } + + return { events, truncated: true }; +} diff --git a/platform/lib/integrations/github-projects/sync.ts b/platform/lib/integrations/github-projects/sync.ts new file mode 100644 index 0000000..5e7468c --- /dev/null +++ b/platform/lib/integrations/github-projects/sync.ts @@ -0,0 +1,536 @@ +/** + * GitHub Projects daily sync. + * + * Pulls board state into `project_items` and transition history into + * `project_status_events`. Idempotent by `provider_item_id` and + * `provider_event_id`, so repeat runs converge instead of duplicating. + * + * History is fetched only for items that can have new history: a board item + * whose `updatedAt` has not moved since the last sync cannot have gained a + * status event. That check is what keeps the daily cost proportional to board + * activity rather than to board size — the first sync backfills everything, + * every later sync touches the few items that actually moved. + */ + +import type { SupabaseClient } from "@supabase/supabase-js"; + +import { + fetchProjectItems, + fetchStatusHistory, + type BoardRef, + type GitHubProjectsCredentials, + type RawProjectItem, + type RawStatusEvent, +} from "./client"; + +import { logger } from "@/lib/debug"; +import { decryptCredentials } from "@/lib/encryption"; + +const PROVIDER = "github_projects" as const; +/** Rows per upsert statement. */ +const UPSERT_CHUNK = 500; + +/** + * One board to sync, as stored in `org_integrations.config.boards`. + * + * `statusConfig` is optional: when absent, classification falls back to the + * name heuristics in `lib/queries/board-flow.ts`. Column vocabularies are + * per-organization, so nothing here assumes a particular workflow. + */ +export interface BoardConfig { + owner: string; + ownerType?: "organization" | "user"; + number: number; + /** Free-form grouping label (team, squad, tribe, product — adopter's call). */ + teamSlug?: string; + statusConfig?: Record; +} + +export interface SyncOptions { + /** Inject a clock for testing. */ + now?: () => Date; + /** Re-fetch history for every item, ignoring `item_updated_at`. */ + forceFullHistory?: boolean; +} + +export interface BoardSyncResult { + boardId: string; + title: string; + itemsUpserted: number; + eventsUpserted: number; + /** Items whose history was fetched this run. */ + historyFetched: number; + /** Items that can never have history (drafts). */ + itemsWithoutHistory: number; +} + +export interface SyncResult { + organizationId: string; + boards: BoardSyncResult[]; +} + +export interface SyncFailure { + organizationId: string; + error: string; +} + +/** + * Sync every board configured for one org. Updates `last_sync_at` on success + * and `last_error` on failure; never throws — the cron route inspects the + * returned value. + */ +export async function syncOrganization( + supabase: SupabaseClient, + organizationId: string, + opts: SyncOptions = {}, +): Promise { + const now = (opts.now ?? (() => new Date()))(); + + try { + const { data: integration, error: loadErr } = await supabase + .from("org_integrations") + .select("id, credentials_encrypted, status, config") + .eq("organization_id", organizationId) + .eq("provider", PROVIDER) + .maybeSingle(); + + if (loadErr) throw new Error(`load integration: ${loadErr.message}`); + if (!integration) throw new Error("integration row not found"); + if (integration.status === "disconnected") { + throw new Error("integration is disconnected"); + } + if (!integration.credentials_encrypted) { + throw new Error("integration has no credentials (disconnected?)"); + } + + const creds = await decryptCredentials( + integration.credentials_encrypted, + ); + if (!creds?.token) + throw new Error("integration credentials carry no token"); + + const boards = readBoardConfig(integration.config); + if (boards.length === 0) { + throw new Error("integration config lists no boards"); + } + + const repoLookup = await loadRepoLookup(supabase, organizationId); + + const results: BoardSyncResult[] = []; + for (const board of boards) { + results.push( + await syncBoard( + supabase, + organizationId, + creds, + board, + repoLookup, + now, + opts, + ), + ); + } + + await supabase + .from("org_integrations") + .update({ + last_sync_at: now.toISOString(), + last_error: null, + status: "active", + }) + .eq("organization_id", organizationId) + .eq("provider", PROVIDER); + + return { organizationId, boards: results }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error("github-projects sync failed", { + organizationId, + error: message, + }); + await supabase + .from("org_integrations") + .update({ last_error: truncate(message, 1000), status: "error" }) + .eq("organization_id", organizationId) + .eq("provider", PROVIDER); + return { organizationId, error: message }; + } +} + +// --------------------------------------------------------------------------- +// Per-board sync +// --------------------------------------------------------------------------- + +async function syncBoard( + supabase: SupabaseClient, + organizationId: string, + creds: GitHubProjectsCredentials, + config: BoardConfig, + repoLookup: Map, + now: Date, + opts: SyncOptions, +): Promise { + const ref: BoardRef = { + ownerLogin: config.owner, + ownerType: config.ownerType ?? "organization", + number: config.number, + }; + + const raw = await fetchProjectItems(creds, ref); + + const { data: boardRow, error: boardErr } = await supabase + .from("project_boards") + .upsert( + { + organization_id: organizationId, + provider_project_id: raw.projectId, + owner_login: ref.ownerLogin, + owner_type: ref.ownerType, + number: ref.number, + title: raw.title, + team_slug: config.teamSlug ?? null, + status_config: config.statusConfig ?? {}, + last_synced_at: now.toISOString(), + }, + { onConflict: "organization_id,provider_project_id" }, + ) + .select("id") + .single(); + + if (boardErr || !boardRow) { + throw new Error(`upsert board: ${boardErr?.message ?? "no row returned"}`); + } + const boardId = boardRow.id as string; + + // Which items already carry history, and at what updatedAt. Drives the + // incremental decision below. + const known = await loadKnownItems(supabase, boardId); + + const itemsUpserted = await upsertItems( + supabase, + boardId, + raw.items, + repoLookup, + now, + ); + + const needHistory = raw.items.filter((item) => + needsHistoryFetch(item, known, opts.forceFullHistory ?? false), + ); + const contentIds = needHistory + .map((i) => i.contentId) + .filter((id): id is string => id !== null); + + let eventsUpserted = 0; + if (contentIds.length > 0) { + const history = await fetchStatusHistory(creds, contentIds, raw.projectId); + + // Events are keyed by content id; rows need our item uuid. The board read + // is the only place that knows both, so bridge through it. + const uuidByProviderItemId = await loadItemUuids(supabase, boardId); + const uuidByContentId = new Map(); + for (const item of raw.items) { + if (!item.contentId) continue; + const uuid = uuidByProviderItemId.get(item.itemId); + if (uuid) uuidByContentId.set(item.contentId, uuid); + } + + eventsUpserted = await upsertEvents( + supabase, + history.eventsByContentId, + uuidByContentId, + ); + + await markHistoryAvailable( + supabase, + boardId, + needHistory, + history.truncatedContentIds, + ); + } + + return { + boardId, + title: raw.title, + itemsUpserted, + eventsUpserted, + historyFetched: contentIds.length, + itemsWithoutHistory: raw.items.filter((i) => i.contentId === null).length, + }; +} + +interface KnownItem { + itemUpdatedAt: string | null; + historyAvailable: boolean; +} + +async function loadKnownItems( + supabase: SupabaseClient, + boardId: string, +): Promise> { + const out = new Map(); + // Paginate: a large board would otherwise hit PostgREST's max-rows cap and + // silently look like "no items known", re-fetching every timeline daily. + const pageSize = 1000; + for (let from = 0; ; from += pageSize) { + const { data, error } = await supabase + .from("project_items") + .select("provider_item_id, item_updated_at, history_available") + .eq("board_id", boardId) + .range(from, from + pageSize - 1); + + if (error) throw new Error(`load known items: ${error.message}`); + for (const row of data ?? []) { + out.set(row.provider_item_id, { + itemUpdatedAt: row.item_updated_at, + historyAvailable: row.history_available, + }); + } + if (!data || data.length < pageSize) break; + } + return out; +} + +/** + * An item needs its timeline read when it is new, when the board item changed + * since we last saw it, or when a previous run never managed to record history. + * Drafts are excluded up front — they have no timeline to read. + */ +function needsHistoryFetch( + item: RawProjectItem, + known: Map, + force: boolean, +): boolean { + if (item.contentId === null) return false; + if (force) return true; + + const previous = known.get(item.itemId); + if (!previous) return true; + if (!previous.historyAvailable) return true; + if (previous.itemUpdatedAt !== item.itemUpdatedAt) return true; + return false; +} + +async function upsertItems( + supabase: SupabaseClient, + boardId: string, + items: RawProjectItem[], + repoLookup: Map, + now: Date, +): Promise { + const rows = dedupeBy( + items.map((item) => ({ + board_id: boardId, + provider_item_id: item.itemId, + content_type: item.contentType, + content_repo: item.contentRepo, + content_number: item.contentNumber, + repository_id: resolveRepositoryId(item.contentRepo, repoLookup), + title: item.title, + current_status: item.status, + content_state: item.contentState, + source_created_at: item.createdAt, + source_closed_at: item.closedAt, + item_updated_at: item.itemUpdatedAt, + assignees: item.assignees, + labels: item.labels, + iteration: item.iteration, + priority: item.priority, + size: item.size, + fetched_at: now.toISOString(), + })), + (r) => r.provider_item_id, + ); + + let total = 0; + for (const chunk of chunked(rows, UPSERT_CHUNK)) { + const { error, count } = await supabase + .from("project_items") + .upsert(chunk, { + onConflict: "board_id,provider_item_id", + // history_available is owned by the history step, not by this one. + ignoreDuplicates: false, + count: "exact", + }); + if (error) throw new Error(`upsert items: ${error.message}`); + total += count ?? chunk.length; + } + return total; +} + +/** Map `provider_item_id` → `project_items.id` for one board. */ +async function loadItemUuids( + supabase: SupabaseClient, + boardId: string, +): Promise> { + const out = new Map(); + const pageSize = 1000; + for (let from = 0; ; from += pageSize) { + const { data, error } = await supabase + .from("project_items") + .select("id, provider_item_id") + .eq("board_id", boardId) + .range(from, from + pageSize - 1); + + if (error) throw new Error(`load item ids: ${error.message}`); + for (const row of data ?? []) out.set(row.provider_item_id, row.id); + if (!data || data.length < pageSize) break; + } + return out; +} + +async function upsertEvents( + supabase: SupabaseClient, + eventsByContentId: Map, + uuidByContentId: Map, +): Promise { + const rows: Array> = []; + for (const [contentId, events] of eventsByContentId) { + const itemId = uuidByContentId.get(contentId); + if (!itemId) continue; + for (const e of events) { + rows.push({ + item_id: itemId, + event_kind: e.kind, + previous_status: e.previousStatus, + status: e.status, + occurred_at: e.occurredAt, + was_automated: e.wasAutomated, + actor_login: e.actorLogin, + provider_event_id: e.eventId, + }); + } + } + + const deduped = dedupeBy(rows, (r) => r.provider_event_id as string); + + let total = 0; + for (const chunk of chunked(deduped, UPSERT_CHUNK)) { + const { error, count } = await supabase + .from("project_status_events") + .upsert(chunk, { onConflict: "provider_event_id", count: "exact" }); + if (error) throw new Error(`upsert status events: ${error.message}`); + total += count ?? chunk.length; + } + return total; +} + +async function markHistoryAvailable( + supabase: SupabaseClient, + boardId: string, + fetched: RawProjectItem[], + truncatedContentIds: Set, +): Promise { + const truncated = fetched + .filter((i) => i.contentId && truncatedContentIds.has(i.contentId)) + .map((i) => i.itemId); + const complete = fetched + .filter((i) => !i.contentId || !truncatedContentIds.has(i.contentId)) + .map((i) => i.itemId); + + for (const [ids, isTruncated] of [ + [complete, false], + [truncated, true], + ] as const) { + for (const chunk of chunked(ids, UPSERT_CHUNK)) { + if (chunk.length === 0) continue; + const { error } = await supabase + .from("project_items") + .update({ history_available: true, history_truncated: isTruncated }) + .eq("board_id", boardId) + .in("provider_item_id", chunk); + if (error) throw new Error(`mark history: ${error.message}`); + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +export function readBoardConfig(config: unknown): BoardConfig[] { + if (!config || typeof config !== "object") return []; + const boards = (config as { boards?: unknown }).boards; + if (!Array.isArray(boards)) return []; + + const out: BoardConfig[] = []; + for (const entry of boards) { + if (!entry || typeof entry !== "object") continue; + const e = entry as Record; + const owner = typeof e.owner === "string" ? e.owner : null; + const number = typeof e.number === "number" ? e.number : null; + if (!owner || number === null) continue; + + out.push({ + owner, + number, + ownerType: e.ownerType === "user" ? "user" : "organization", + teamSlug: typeof e.teamSlug === "string" ? e.teamSlug : undefined, + statusConfig: + e.statusConfig && typeof e.statusConfig === "object" + ? (e.statusConfig as Record) + : undefined, + }); + } + return out; +} + +/** + * Map bare repo name → repositories.id. Board content carries + * "owner/repo" while Iris stores the bare name, so both sides are + * normalized to the bare, lowercased name. + */ +async function loadRepoLookup( + supabase: SupabaseClient, + organizationId: string, +): Promise> { + const out = new Map(); + const pageSize = 1000; + for (let from = 0; ; from += pageSize) { + const { data, error } = await supabase + .from("repositories") + .select("id, name") + .eq("organization_id", organizationId) + .range(from, from + pageSize - 1); + + if (error) throw new Error(`load repositories: ${error.message}`); + for (const row of data ?? []) { + out.set(bareName(row.name), row.id); + } + if (!data || data.length < pageSize) break; + } + return out; +} + +function resolveRepositoryId( + contentRepo: string | null, + lookup: Map, +): string | null { + if (!contentRepo) return null; + return lookup.get(bareName(contentRepo)) ?? null; +} + +function bareName(repo: string): string { + const parts = repo.split("/"); + return (parts[parts.length - 1] ?? repo).trim().toLowerCase(); +} + +/** + * Postgres rejects an upsert whose rows repeat the conflict key, so dedupe + * before sending. Last occurrence wins. + */ +function dedupeBy(rows: T[], key: (row: T) => string): T[] { + const byKey = new Map(); + for (const row of rows) byKey.set(key(row), row); + return [...byKey.values()]; +} + +function chunked(rows: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < rows.length; i += size) out.push(rows.slice(i, i + size)); + return out; +} + +function truncate(text: string, max: number): string { + return text.length <= max ? text : `${text.slice(0, max - 1)}…`; +} diff --git a/platform/lib/queries/board-flow.ts b/platform/lib/queries/board-flow.ts new file mode 100644 index 0000000..6b946b2 --- /dev/null +++ b/platform/lib/queries/board-flow.ts @@ -0,0 +1,693 @@ +/** + * Board flow metrics — pure functions over persisted items and status events. + * + * No I/O, no Supabase client: fully unit-testable, same convention as + * `cycle-time-flow.ts`. + * + * Two rules run through everything here: + * + * 1. **A duration is only reported when the data supports it.** Items without + * transition history (drafts) are excluded from duration metrics rather than + * counted as zero, and a lead time derived from `closedAt` or `updatedAt` + * carries `leadTimeSource` so the UI can mark it approximate. + * + * 2. **Nothing assumes a workflow.** Column names are free text per board. + * They are mapped onto lifecycle buckets by explicit per-board config, and + * only fall back to generic name heuristics. Columns that match nothing are + * reported in `unmappedStatuses` instead of being quietly treated as + * not-done. + */ + +import type { + AgingColumn, + BoardFlowSummary, + BoardItemInput, + CfdPoint, + CoverageStats, + FlowBalance, + ItemFlow, + LeadTimeSource, + LifecycleBucket, + LittlesLawCheck, + PercentileSet, + PhaseStat, + PhaseVisit, + StalledItem, + StatusClassification, + StatusConfig, + StatusEventInput, + WeeklyCount, +} from "@/types/board-flow"; + +const HOUR_MS = 3_600_000; +const WEEK_HOURS = 168; + +/** Below this many observations, only the median and the raw strip are shown. */ +export const MIN_SAMPLE_PERCENTILES = 10; +/** Below this many observations, P95 is withheld. */ +export const MIN_SAMPLE_P95 = 20; +/** Items sitting still longer than this are listed as stalled. */ +export const STALLED_THRESHOLD_HOURS = 168; + +// --------------------------------------------------------------------------- +// Status classification +// --------------------------------------------------------------------------- + +/** + * Generic column-name heuristics, tried in order. First match wins, which is + * why queue precedes active: "Ready for Development" is a queue, not + * development. Patterns cover English and Portuguese column vocabularies; + * anything else needs explicit per-board config, and says so via `unmapped`. + */ +const HEURISTICS: Array<[LifecycleBucket, RegExp]> = [ + [ + "done", + /\b(done|closed|complete|completed|shipped|released|delivered|finished|cancel|conclu|finalizad|entregue|encerrad)/i, + ], + [ + "discovery", + /\b(discovery|refinement|grooming|spec|design|analysis|research|scoping|descobert|refinament|analise|análise|especifica)/i, + ], + [ + "queue", + /\b(ready|awaiting|waiting|blocked|queue|pending|on hold|hold|aguardando|bloquead|fila|pendente)/i, + ], + [ + "active", + /\b(progress|doing|wip|dev|implement|review|test|qa|validat|verif|staging|deploy|monitor|homolog|desenvolv|revis|teste|valida)/i, + ], + [ + "backlog", + /\b(backlog|icebox|inbox|triage|todo|to do|new|proposed|idea|novo|ideia|triagem|entrada)/i, + ], +]; + +/** + * Resolve every column seen on the board to a bucket. + * + * Explicit config always wins over heuristics — an organization whose "Review" + * column means something unusual can say so without patching code. + */ +export function classifyStatuses( + config: StatusConfig, + seenStatuses: Iterable, +): StatusClassification { + const byStatus = new Map(); + + for (const [bucket, names] of Object.entries(config)) { + if (!Array.isArray(names)) continue; + for (const name of names) { + if (typeof name !== "string") continue; + byStatus.set(name.trim().toLowerCase(), bucket as LifecycleBucket); + } + } + + const unmapped: string[] = []; + for (const status of seenStatuses) { + const key = status.trim().toLowerCase(); + if (!key || byStatus.has(key)) continue; + + const hit = HEURISTICS.find(([, pattern]) => pattern.test(key)); + if (hit) byStatus.set(key, hit[0]); + else unmapped.push(status); + } + + return { byStatus, unmapped: [...new Set(unmapped)] }; +} + +function bucketOf( + status: string | null, + classification: StatusClassification, +): LifecycleBucket | null { + if (!status) return null; + return classification.byStatus.get(status.trim().toLowerCase()) ?? null; +} + +// --------------------------------------------------------------------------- +// Per-item flow +// --------------------------------------------------------------------------- + +/** + * Reconstruct one item's journey from its status events. + * + * Re-entry accumulates: each visit to a column is its own `PhaseVisit`, and + * `hoursByStatus` sums them. A `REMOVED` event closes the current visit and + * marks the item as off-board — an exit that is deliberately not a completion. + */ +export function buildItemFlow( + item: BoardItemInput, + events: StatusEventInput[], + classification: StatusClassification, + now: Date, +): ItemFlow { + const ordered = [...events].sort( + (a, b) => Date.parse(a.occurredAt) - Date.parse(b.occurredAt), + ); + + const visits: PhaseVisit[] = []; + let enteredBoardAt: string | null = null; + let removedFromBoard = false; + let open: { status: string; enteredAt: string } | null = null; + + const closeOpen = (at: string) => { + if (!open) return; + visits.push({ + status: open.status, + bucket: bucketOf(open.status, classification), + enteredAt: open.enteredAt, + exitedAt: at, + hours: hoursBetween(open.enteredAt, at), + }); + open = null; + }; + + for (const event of ordered) { + if (enteredBoardAt === null && event.kind !== "REMOVED") { + enteredBoardAt = event.occurredAt; + } + + if (event.kind === "STATUS_CHANGED") { + closeOpen(event.occurredAt); + if (event.status) { + open = { status: event.status, enteredAt: event.occurredAt }; + } + // Re-added after removal: the item is back on the board. + removedFromBoard = false; + } else if (event.kind === "REMOVED") { + closeOpen(event.occurredAt); + removedFromBoard = true; + } + } + + // A still-open visit runs to now. Left open (exitedAt null) so callers can + // tell "currently here" from "left at this time". + if (open) { + visits.push({ + status: open.status, + bucket: bucketOf(open.status, classification), + enteredAt: open.enteredAt, + exitedAt: null, + hours: hoursBetween(open.enteredAt, now.toISOString()), + }); + } + + if (enteredBoardAt === null) enteredBoardAt = item.sourceCreatedAt; + + const hoursByStatus: Record = {}; + const passesByStatus: Record = {}; + for (const visit of visits) { + hoursByStatus[visit.status] = + (hoursByStatus[visit.status] ?? 0) + visit.hours; + passesByStatus[visit.status] = (passesByStatus[visit.status] ?? 0) + 1; + } + + const firstDone = visits.find((v) => v.bucket === "done") ?? null; + const terminalAt = firstDone?.enteredAt ?? null; + const isTerminal = terminalAt !== null; + + // Active time excludes the terminal column and every queue — the whole point + // of flow efficiency is separating work from waiting. + const activeHours = visits + .filter((v) => v.bucket === "active") + .reduce((sum, v) => sum + v.hours, 0); + + const { leadTimeHours, leadTimeSource } = resolveLeadTime( + item, + enteredBoardAt, + terminalAt, + ); + + const firstActive = visits.find((v) => v.bucket === "active") ?? null; + const cycleTimeHours = + firstActive && terminalAt + ? hoursBetween(firstActive.enteredAt, terminalAt) + : null; + + const currentVisit = visits.length > 0 ? visits[visits.length - 1] : null; + const lastMoveAt = currentVisit?.enteredAt ?? enteredBoardAt; + + return { + itemId: item.id, + title: item.title, + enteredBoardAt, + visits, + hoursByStatus, + passesByStatus, + terminalAt, + isTerminal, + removedFromBoard, + currentStatus: item.currentStatus, + assignees: item.assignees, + leadTimeHours, + leadTimeSource, + cycleTimeHours, + activeHours, + flowEfficiency: + leadTimeHours !== null && leadTimeHours > 0 + ? activeHours / leadTimeHours + : null, + ageHours: + !isTerminal && enteredBoardAt + ? hoursBetween(enteredBoardAt, now.toISOString()) + : null, + hoursInCurrentStatus: + !isTerminal && lastMoveAt + ? hoursBetween(lastMoveAt, now.toISOString()) + : null, + approximate: leadTimeSource !== null && leadTimeSource !== "transitions", + }; +} + +/** + * Lead time, with the fallback ladder from the spec. + * + * Transitions are exact. `closedAt` is a decent proxy but says nothing about + * when the board considered the work done. `updatedAt` is a last resort — any + * edit moves it — and only ever used for items that have no better signal. + * Anything but the first rung sets `leadTimeSource` so the number is labelled. + */ +function resolveLeadTime( + item: BoardItemInput, + enteredBoardAt: string | null, + terminalAt: string | null, +): { leadTimeHours: number | null; leadTimeSource: LeadTimeSource | null } { + const start = enteredBoardAt ?? item.sourceCreatedAt; + if (!start) return { leadTimeHours: null, leadTimeSource: null }; + + if (item.historyAvailable && terminalAt) { + return { + leadTimeHours: hoursBetween(start, terminalAt), + leadTimeSource: "transitions", + }; + } + if (item.sourceClosedAt) { + return { + leadTimeHours: hoursBetween(start, item.sourceClosedAt), + leadTimeSource: "closed_at", + }; + } + // Only for items the board already considers finished; an open item has no + // lead time yet, and `updatedAt` would invent one. + if (terminalAt && item.itemUpdatedAt) { + return { + leadTimeHours: hoursBetween(start, item.itemUpdatedAt), + leadTimeSource: "item_updated_at", + }; + } + return { leadTimeHours: null, leadTimeSource: null }; +} + +// --------------------------------------------------------------------------- +// Board aggregation +// --------------------------------------------------------------------------- + +export interface SummarizeOptions { + boardId: string; + title: string; + teamSlug?: string | null; + statusConfig?: StatusConfig; + now?: Date; +} + +export function summarizeBoard( + items: BoardItemInput[], + events: StatusEventInput[], + opts: SummarizeOptions, +): BoardFlowSummary { + const now = opts.now ?? new Date(); + + const seen = new Set(); + for (const item of items) + if (item.currentStatus) seen.add(item.currentStatus); + for (const event of events) { + if (event.status) seen.add(event.status); + if (event.previousStatus) seen.add(event.previousStatus); + } + const classification = classifyStatuses(opts.statusConfig ?? {}, seen); + + const eventsByItem = new Map(); + for (const event of events) { + const list = eventsByItem.get(event.itemId); + if (list) list.push(event); + else eventsByItem.set(event.itemId, [event]); + } + + const flows = items.map((item) => + buildItemFlow(item, eventsByItem.get(item.id) ?? [], classification, now), + ); + + const wipFlows = flows.filter((f) => !f.isTerminal && !f.removedFromBoard); + + return { + boardId: opts.boardId, + title: opts.title, + teamSlug: opts.teamSlug ?? null, + coverage: computeCoverage(items, flows), + leadTime: percentiles(flows.map((f) => f.leadTimeHours).filter(isNumber)), + cycleTime: percentiles(flows.map((f) => f.cycleTimeHours).filter(isNumber)), + phases: computePhaseStats(flows, classification), + flowEfficiencyMedian: median( + flows.map((f) => f.flowEfficiency).filter(isNumber), + ), + throughput: weeklyCounts(flows.map((f) => f.terminalAt).filter(isString)), + balance: computeBalance(flows), + wip: wipFlows.length, + aging: computeAging(wipFlows, classification), + stalled: computeStalled(wipFlows), + cfd: computeCfd(flows, now), + littlesLaw: computeLittlesLaw(flows, wipFlows.length), + unmappedStatuses: classification.unmapped, + }; +} + +function computeCoverage( + items: BoardItemInput[], + flows: ItemFlow[], +): CoverageStats { + const withHistory = items.filter((i) => i.historyAvailable).length; + return { + totalItems: items.length, + itemsWithHistory: withHistory, + itemsApproximated: flows.filter((f) => f.approximate).length, + historyCoveragePct: + items.length === 0 ? 0 : round((withHistory / items.length) * 100, 1), + }; +} + +function computePhaseStats( + flows: ItemFlow[], + classification: StatusClassification, +): PhaseStat[] { + const perStatus = new Map< + string, + { hours: number[]; total: number; reentered: number } + >(); + + for (const flow of flows) { + for (const [status, hours] of Object.entries(flow.hoursByStatus)) { + const entry = perStatus.get(status) ?? { + hours: [], + total: 0, + reentered: 0, + }; + entry.hours.push(hours); + entry.total += hours; + if ((flow.passesByStatus[status] ?? 0) > 1) entry.reentered += 1; + perStatus.set(status, entry); + } + } + + return [...perStatus.entries()] + .map(([status, entry]) => ({ + status, + bucket: bucketOf(status, classification), + n: entry.hours.length, + medianHours: median(entry.hours), + totalHours: round(entry.total, 2), + reentered: entry.reentered, + })) + .sort((a, b) => b.totalHours - a.totalHours); +} + +function computeBalance(flows: ItemFlow[]): FlowBalance[] { + const inflow = countByWeek( + flows.map((f) => f.enteredBoardAt).filter(isString), + ); + const outflow = countByWeek(flows.map((f) => f.terminalAt).filter(isString)); + + const weeks = [...new Set([...inflow.keys(), ...outflow.keys()])].sort(); + let cumulative = 0; + return weeks.map((week) => { + const inCount = inflow.get(week) ?? 0; + const outCount = outflow.get(week) ?? 0; + cumulative += inCount - outCount; + return { + week, + inflow: inCount, + outflow: outCount, + cumulativeDelta: cumulative, + }; + }); +} + +function computeAging( + wipFlows: ItemFlow[], + classification: StatusClassification, +): AgingColumn[] { + const perStatus = new Map(); + for (const flow of wipFlows) { + if (flow.ageHours === null) continue; + const key = flow.currentStatus ?? "(no status)"; + const list = perStatus.get(key); + if (list) list.push(flow.ageHours); + else perStatus.set(key, [flow.ageHours]); + } + + return [...perStatus.entries()] + .map(([status, ages]) => ({ + status, + bucket: bucketOf(status, classification), + count: ages.length, + medianAgeHours: median(ages), + maxAgeHours: ages.length ? round(Math.max(...ages), 2) : null, + })) + .sort((a, b) => (b.maxAgeHours ?? 0) - (a.maxAgeHours ?? 0)); +} + +/** + * Items that have not moved in a while, worst first. The spec calls this the + * most actionable output of the whole analysis, so it is not truncated here — + * presentation decides how many to show. + */ +function computeStalled(wipFlows: ItemFlow[]): StalledItem[] { + return wipFlows + .filter( + (f) => + f.hoursInCurrentStatus !== null && + f.hoursInCurrentStatus >= STALLED_THRESHOLD_HOURS, + ) + .map((f) => ({ + itemId: f.itemId, + title: f.title, + currentStatus: f.currentStatus, + hoursSinceLastMove: round(f.hoursInCurrentStatus ?? 0, 2), + totalAgeHours: round(f.ageHours ?? 0, 2), + assignees: f.assignees, + })) + .sort((a, b) => b.hoursSinceLastMove - a.hoursSinceLastMove); +} + +/** + * Cumulative flow: for each ISO week, how many items sat in each column at the + * end of that week. Reconstructed from the event stream — the reason a CFD is + * possible at all without historical snapshots. + */ +function computeCfd(flows: ItemFlow[], now: Date): CfdPoint[] { + const starts = flows + .map((f) => f.enteredBoardAt) + .filter(isString) + .map((iso) => Date.parse(iso)) + .filter((ms) => Number.isFinite(ms)); + + if (starts.length === 0) return []; + + const sample = (ms: number): CfdPoint => { + const counts: Record = {}; + for (const flow of flows) { + const status = statusAt(flow, ms); + if (!status) continue; + counts[status] = (counts[status] ?? 0) + 1; + } + return { week: isoWeekKey(new Date(ms)), counts }; + }; + + const points: CfdPoint[] = []; + const end = now.getTime(); + let cursor = weekEnd(Math.min(...starts)); + while (cursor < end) { + points.push(sample(cursor)); + cursor += WEEK_HOURS * HOUR_MS; + } + // The week in progress is sampled at `now` rather than at its future end, + // so the last point reflects the board as it stands instead of stopping at + // the previous Sunday. + points.push(sample(end)); + return points; +} + +/** Which column an item was in at instant `ms`, or null if not on the board. */ +function statusAt(flow: ItemFlow, ms: number): string | null { + let status: string | null = null; + for (const visit of flow.visits) { + const entered = Date.parse(visit.enteredAt); + if (entered > ms) break; + const exited = visit.exitedAt ? Date.parse(visit.exitedAt) : Infinity; + status = ms < exited ? visit.status : null; + } + return status; +} + +/** + * Little's Law as a consistency check, not as a metric to report on its own. + * A large gap between predicted and observed lead time usually means phantom + * WIP or a mis-mapped terminal column — which is why both are returned. + */ +function computeLittlesLaw(flows: ItemFlow[], wip: number): LittlesLawCheck { + const throughput = weeklyCounts( + flows.map((f) => f.terminalAt).filter(isString), + ); + const meanPerWeek = + throughput.length === 0 + ? null + : throughput.reduce((sum, w) => sum + w.count, 0) / throughput.length; + + const observed = median(flows.map((f) => f.leadTimeHours).filter(isNumber)); + const predicted = + meanPerWeek && meanPerWeek > 0 ? (wip / meanPerWeek) * WEEK_HOURS : null; + + return { + wip, + throughputPerWeek: meanPerWeek === null ? null : round(meanPerWeek, 2), + predictedLeadTimeHours: predicted === null ? null : round(predicted, 2), + observedLeadTimeHours: observed, + divergenceRatio: + predicted !== null && observed !== null && observed > 0 + ? round(Math.abs(predicted - observed) / observed, 3) + : null, + }; +} + +// --------------------------------------------------------------------------- +// Statistics helpers +// --------------------------------------------------------------------------- + +/** + * Percentiles with the sample guard applied. Small samples get the median and + * the raw values; P95 needs a real sample behind it or it is withheld. + */ +export function percentiles(values: number[]): PercentileSet { + const sorted = [...values].sort((a, b) => a - b); + const n = sorted.length; + const suppressed: string[] = []; + + if (n === 0) { + return { + n: 0, + p50: null, + p70: null, + p85: null, + p95: null, + suppressed: ["p50", "p70", "p85", "p95"], + raw: [], + }; + } + + const p50 = quantile(sorted, 0.5); + if (n < MIN_SAMPLE_PERCENTILES) { + suppressed.push("p70", "p85", "p95"); + return { + n, + p50, + p70: null, + p85: null, + p95: null, + suppressed, + raw: sorted.map((v) => round(v, 2)), + }; + } + + const p95 = n >= MIN_SAMPLE_P95 ? quantile(sorted, 0.95) : null; + if (p95 === null) suppressed.push("p95"); + + return { + n, + p50, + p70: quantile(sorted, 0.7), + p85: quantile(sorted, 0.85), + p95, + suppressed, + raw: sorted.map((v) => round(v, 2)), + }; +} + +/** Linear-interpolation quantile over an already-sorted array. */ +function quantile(sorted: number[], q: number): number | null { + if (sorted.length === 0) return null; + if (sorted.length === 1) return round(sorted[0], 2); + const pos = (sorted.length - 1) * q; + const lower = Math.floor(pos); + const upper = Math.ceil(pos); + if (lower === upper) return round(sorted[lower], 2); + return round( + sorted[lower] + (pos - lower) * (sorted[upper] - sorted[lower]), + 2, + ); +} + +export function median(values: number[]): number | null { + return quantile( + [...values].sort((a, b) => a - b), + 0.5, + ); +} + +function weeklyCounts(timestamps: string[]): WeeklyCount[] { + return [...countByWeek(timestamps).entries()] + .map(([week, count]) => ({ week, count })) + .sort((a, b) => a.week.localeCompare(b.week)); +} + +function countByWeek(timestamps: string[]): Map { + const out = new Map(); + for (const iso of timestamps) { + const ms = Date.parse(iso); + if (!Number.isFinite(ms)) continue; + const key = isoWeekKey(new Date(ms)); + out.set(key, (out.get(key) ?? 0) + 1); + } + return out; +} + +/** ISO-8601 week key, e.g. "2026-W34". Thursday decides the week's year. */ +export function isoWeekKey(date: Date): string { + const d = new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()), + ); + // Shift to the Thursday of the current ISO week. + const day = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - day); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const week = Math.ceil( + ((d.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7, + ); + return `${d.getUTCFullYear()}-W${String(week).padStart(2, "0")}`; +} + +/** End of the ISO week (Sunday 23:59:59.999 UTC) containing `ms`. */ +function weekEnd(ms: number): number { + const d = new Date(ms); + const day = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + (7 - day)); + d.setUTCHours(23, 59, 59, 999); + return d.getTime(); +} + +function hoursBetween(fromIso: string, toIso: string): number { + const from = Date.parse(fromIso); + const to = Date.parse(toIso); + if (!Number.isFinite(from) || !Number.isFinite(to)) return 0; + return round(Math.max(0, (to - from) / HOUR_MS), 2); +} + +function round(value: number, digits: number): number { + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} + +function isNumber(value: number | null): value is number { + return value !== null && Number.isFinite(value); +} + +function isString(value: string | null): value is string { + return typeof value === "string" && value.length > 0; +} diff --git a/platform/lib/queries/board-quality.ts b/platform/lib/queries/board-quality.ts new file mode 100644 index 0000000..bccffc9 --- /dev/null +++ b/platform/lib/queries/board-quality.ts @@ -0,0 +1,380 @@ +/** + * Data-quality gates for board flow analysis. + * + * These run *before* any metric is trusted, and they are not a footnote. On a + * real board, board-setup noise — a batch of test cards created and closed + * minutes apart — dragged the median lead time down to a fraction of a day. + * The number was flattering and completely false. + * + * Every gate returns a measured value, a severity, the items responsible, and + * a plain statement of what it does to the reading. Metrics are still computed + * when a gate fires; they are just never shown without the caveat attached. + * + * Pure functions, no I/O. + */ + +import type { + BoardItemInput, + GateSeverity, + LifecycleBucket, + QualityGate, + QualityReport, + StatusClassification, + StatusEventInput, +} from "@/types/board-flow"; + +/** + * Titles that look like board scaffolding rather than work. Deliberately + * conservative: a false positive here silently drops real work from the + * numbers, which is worse than a missed test card. + */ +const SYNTHETIC_TITLE_RE = + /\b(test|teste|testando|exemplo|example|dummy|sample|placeholder|lorem|foo|bar|baz|asdf|xxx|tbd)\b/i; + +/** A burst this size created within one minute reads as scaffolding. */ +export const SYNTHETIC_BURST_MIN_ITEMS = 5; +/** ...and closed this fast, it never represented real flow. */ +export const SYNTHETIC_BURST_MAX_LIFETIME_MINUTES = 30; + +/** This many status events inside the window reads as a board tidy-up. */ +export const BULK_MOVE_MIN_ITEMS = 5; +export const BULK_MOVE_WINDOW_MINUTES = 2; + +/** Fraction of items carrying a field, below which cuts by it are unreliable. */ +export const FIELD_COMPLETENESS_WARN_PCT = 70; +export const FIELD_COMPLETENESS_CRITICAL_PCT = 40; + +/** Share of assignments held by one person, above which the board is skewed. */ +export const ASSIGNEE_CONCENTRATION_WARN_PCT = 50; +export const ASSIGNEE_CONCENTRATION_CRITICAL_PCT = 75; + +export const DONE_NOT_CLOSED_WARN_PCT = 10; +export const DONE_NOT_CLOSED_CRITICAL_PCT = 30; + +export const HISTORY_COVERAGE_WARN_PCT = 90; +export const HISTORY_COVERAGE_CRITICAL_PCT = 60; + +const MINUTE_MS = 60_000; + +export function evaluateQuality( + items: BoardItemInput[], + events: StatusEventInput[], + classification: StatusClassification, +): QualityReport { + const gates: QualityGate[] = [ + syntheticItemsGate(items), + doneNotClosedGate(items, classification), + bulkMovementGate(events), + fieldCompletenessGate(items), + assigneeConcentrationGate(items), + historyCoverageGate(items), + ]; + + const overall = worstSeverity(gates.map((g) => g.severity)); + return { + gates, + overall, + degraded: gates.some((g) => g.severity === "critical"), + }; +} + +// --------------------------------------------------------------------------- +// Gates +// --------------------------------------------------------------------------- + +/** + * Synthetic items: obvious test titles, plus same-minute creation bursts with + * a very short lifetime. Both patterns come from setting a board up, and both + * land in the fast tail of the lead-time distribution where they do the most + * damage to the median. + */ +function syntheticItemsGate(items: BoardItemInput[]): QualityGate { + const flagged = new Set(); + + for (const item of items) { + if (SYNTHETIC_TITLE_RE.test(item.title)) flagged.add(item.id); + } + + const byMinute = new Map(); + for (const item of items) { + if (!item.sourceCreatedAt) continue; + const ms = Date.parse(item.sourceCreatedAt); + if (!Number.isFinite(ms)) continue; + const key = String(Math.floor(ms / MINUTE_MS)); + const bucket = byMinute.get(key); + if (bucket) bucket.push(item); + else byMinute.set(key, [item]); + } + + for (const burst of byMinute.values()) { + if (burst.length < SYNTHETIC_BURST_MIN_ITEMS) continue; + const shortLived = burst.filter((item) => { + if (!item.sourceCreatedAt || !item.sourceClosedAt) return false; + const lifetime = + Date.parse(item.sourceClosedAt) - Date.parse(item.sourceCreatedAt); + return ( + Number.isFinite(lifetime) && + lifetime >= 0 && + lifetime <= SYNTHETIC_BURST_MAX_LIFETIME_MINUTES * MINUTE_MS + ); + }); + // Only a burst that *also* died young is scaffolding; a big planning + // session legitimately creates many cards at once. + if (shortLived.length >= SYNTHETIC_BURST_MIN_ITEMS) { + for (const item of shortLived) flagged.add(item.id); + } + } + + const pct = percentOf(flagged.size, items.length); + return { + id: "synthetic_items", + severity: flagged.size === 0 ? "ok" : pct >= 5 ? "critical" : "warning", + value: pct, + unit: "percent", + affectedItemIds: [...flagged], + summary: + flagged.size === 0 + ? "No synthetic or test-looking items detected." + : `${flagged.size} item(s) (${pct}%) look like board scaffolding rather than real work. ` + + "They cluster in the fast tail and pull the lead-time median down; exclude them before reading any duration.", + }; +} + +/** + * Items parked in a terminal column while their issue is still open. When this + * is common, `closedAt` stops being a usable completion marker — which matters + * because it is the first fallback for lead time. + */ +function doneNotClosedGate( + items: BoardItemInput[], + classification: StatusClassification, +): QualityGate { + const done = items.filter( + (item) => bucketOf(item.currentStatus, classification) === "done", + ); + const mismatched = done.filter((item) => item.contentState === "OPEN"); + const pct = percentOf(mismatched.length, done.length); + + return { + id: "done_not_closed", + severity: severityFromPct( + pct, + DONE_NOT_CLOSED_WARN_PCT, + DONE_NOT_CLOSED_CRITICAL_PCT, + ), + value: pct, + unit: "percent", + affectedItemIds: mismatched.map((i) => i.id), + summary: + mismatched.length === 0 + ? "Every item in a terminal column has its issue closed." + : `${mismatched.length} of ${done.length} items in a terminal column still have an open issue (${pct}%). ` + + "`closedAt` is therefore unreliable as a completion marker, and any lead time falling back to it is an estimate.", + }; +} + +/** + * Bulk movement: many status changes inside a couple of minutes. That is + * someone tidying the board, not work flowing, and it compresses whatever + * phase the items were sitting in. + * + * `wasAutomated` comes straight from GitHub, so automation-driven cascades are + * identified rather than guessed at from identical timestamps. + */ +function bulkMovementGate(events: StatusEventInput[]): QualityGate { + const moves = events + .filter((e) => e.kind === "STATUS_CHANGED") + .map((e) => ({ ...e, ms: Date.parse(e.occurredAt) })) + .filter((e) => Number.isFinite(e.ms)) + .sort((a, b) => a.ms - b.ms); + + const windowMs = BULK_MOVE_WINDOW_MINUTES * MINUTE_MS; + const flagged = new Set(); + let automated = 0; + + let start = 0; + for (let end = 0; end < moves.length; end++) { + while (moves[end].ms - moves[start].ms > windowMs) start++; + const window = moves.slice(start, end + 1); + const distinctItems = new Set(window.map((m) => m.itemId)); + if (distinctItems.size >= BULK_MOVE_MIN_ITEMS) { + for (const id of distinctItems) flagged.add(id); + } + } + for (const move of moves) if (move.wasAutomated) automated++; + + const pct = percentOf(flagged.size, new Set(moves.map((m) => m.itemId)).size); + return { + id: "bulk_movement", + severity: flagged.size === 0 ? "ok" : pct >= 25 ? "critical" : "warning", + value: pct, + unit: "percent", + affectedItemIds: [...flagged], + summary: + flagged.size === 0 + ? "No bulk column moves detected." + : `${flagged.size} item(s) (${pct}% of items with transitions) moved in bursts of ` + + `${BULK_MOVE_MIN_ITEMS}+ within ${BULK_MOVE_WINDOW_MINUTES} minute(s)` + + (automated > 0 + ? `, ${automated} of the moves flagged as automated by GitHub` + : "") + + ". Those transitions record board maintenance, not flow, and shorten the phases they pass through.", + }; +} + +/** + * How much of the board is actually filled in. This does not degrade the core + * durations — it decides which *cuts* (by priority, size, iteration, owner) can + * be trusted at all. + */ +function fieldCompletenessGate(items: BoardItemInput[]): QualityGate { + const total = items.length; + const filled = { + priority: items.filter((i) => nonEmpty(i.priority)).length, + size: items.filter((i) => nonEmpty(i.size)).length, + iteration: items.filter((i) => nonEmpty(i.iteration)).length, + assignee: items.filter((i) => i.assignees.length > 0).length, + }; + + const pcts = Object.entries(filled).map( + ([field, count]) => [field, percentOf(count, total)] as const, + ); + const worst = pcts.reduce( + (acc, entry) => (entry[1] < acc[1] ? entry : acc), + pcts[0] ?? (["none", 100] as const), + ); + + return { + id: "field_completeness", + severity: severityFromPct( + worst[1], + FIELD_COMPLETENESS_WARN_PCT, + FIELD_COMPLETENESS_CRITICAL_PCT, + /* lowerIsWorse */ true, + ), + value: worst[1], + unit: "percent", + affectedItemIds: [], + summary: + total === 0 + ? "No items to assess." + : `Field completeness — ${pcts.map(([f, p]) => `${f} ${p}%`).join(", ")}. ` + + `Cuts by ${worst[0]} rest on ${worst[1]}% of items and should not be read as representative below that.`, + }; +} + +/** + * Concentration of assignments. This is a property of the *board* — when one + * account holds most of the cards, the board is a personal list rather than a + * record of how the group works. + * + * It is explicitly not a productivity signal. Iris never ranks or scores + * individuals (see docs/PRINCIPLES.md), and no per-person output is derived + * from this gate: the login is not returned, only the share. + */ +function assigneeConcentrationGate(items: BoardItemInput[]): QualityGate { + const counts = new Map(); + let assignments = 0; + for (const item of items) { + for (const login of item.assignees) { + counts.set(login, (counts.get(login) ?? 0) + 1); + assignments++; + } + } + + const top = Math.max(0, ...counts.values()); + const pct = percentOf(top, assignments); + const unassigned = items.filter((i) => i.assignees.length === 0).length; + const unassignedPct = percentOf(unassigned, items.length); + + return { + id: "assignee_concentration", + severity: severityFromPct( + pct, + ASSIGNEE_CONCENTRATION_WARN_PCT, + ASSIGNEE_CONCENTRATION_CRITICAL_PCT, + ), + value: pct, + unit: "percent", + affectedItemIds: [], + summary: + assignments === 0 + ? "No items carry an assignee, so the board says nothing about how work is distributed." + : `The most-assigned account holds ${pct}% of all assignments, and ${unassignedPct}% of items have no owner. ` + + "High concentration together with many unowned items means the board does not represent real workload distribution. " + + "This describes the board, never a person's output.", + }; +} + +/** + * Share of items with real transition history. Drafts can never have it (no + * timeline exists for them), so this is the honest ceiling on how much of the + * board supports duration analysis at all. + */ +function historyCoverageGate(items: BoardItemInput[]): QualityGate { + const withHistory = items.filter((i) => i.historyAvailable).length; + const drafts = items.filter((i) => i.contentType === "DRAFT_ISSUE").length; + const pct = percentOf(withHistory, items.length); + + return { + id: "history_coverage", + severity: severityFromPct( + pct, + HISTORY_COVERAGE_WARN_PCT, + HISTORY_COVERAGE_CRITICAL_PCT, + /* lowerIsWorse */ true, + ), + value: pct, + unit: "percent", + affectedItemIds: items.filter((i) => !i.historyAvailable).map((i) => i.id), + summary: + `${withHistory} of ${items.length} items (${pct}%) carry transition history` + + (drafts > 0 + ? `; ${drafts} are draft items, which have no timeline on the API and can never contribute phase durations` + : "") + + ". Phase and lead-time figures describe only the items with history, not the whole board.", + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function bucketOf( + status: string | null, + classification: StatusClassification, +): LifecycleBucket | null { + if (!status) return null; + return classification.byStatus.get(status.trim().toLowerCase()) ?? null; +} + +function nonEmpty(value: string | null): boolean { + return typeof value === "string" && value.trim().length > 0; +} + +function percentOf(part: number, total: number): number { + if (total <= 0) return 0; + return Math.round((part / total) * 1000) / 10; +} + +function severityFromPct( + pct: number, + warnAt: number, + criticalAt: number, + lowerIsWorse = false, +): GateSeverity { + if (lowerIsWorse) { + if (pct <= criticalAt) return "critical"; + if (pct <= warnAt) return "warning"; + return "ok"; + } + if (pct >= criticalAt) return "critical"; + if (pct >= warnAt) return "warning"; + return "ok"; +} + +function worstSeverity(severities: GateSeverity[]): GateSeverity { + if (severities.includes("critical")) return "critical"; + if (severities.includes("warning")) return "warning"; + return "ok"; +} diff --git a/platform/src/app/api/cron/sync-integrations/route.ts b/platform/src/app/api/cron/sync-integrations/route.ts index f764b22..52c6382 100644 --- a/platform/src/app/api/cron/sync-integrations/route.ts +++ b/platform/src/app/api/cron/sync-integrations/route.ts @@ -5,6 +5,7 @@ import { rematchUnlinkedDeployments, syncOrganization, } from "@/lib/integrations/datadog/sync"; +import { syncOrganization as syncGitHubProjects } from "@/lib/integrations/github-projects/sync"; import { supabaseAdmin } from "@/lib/supabase"; // The cron loops sequentially across active integrations; allow it to @@ -15,6 +16,7 @@ export const dynamic = "force-dynamic"; interface PerOrgOutcome { organizationId: string; + provider: string; ok: boolean; deploymentsUpserted?: number; commitsUpserted?: number; @@ -22,6 +24,14 @@ interface PerOrgOutcome { unmatchedDeployments?: number; /** Rows whose `repository_id` flipped from null to a repo this run. */ rematched?: number; + /** GitHub Projects: one entry per synced board. */ + boards?: Array<{ + title: string; + itemsUpserted: number; + eventsUpserted: number; + historyFetched: number; + itemsWithoutHistory: number; + }>; error?: string; } @@ -50,6 +60,35 @@ export async function GET(request: NextRequest) { const outcomes: PerOrgOutcome[] = []; for (const integration of integrations ?? []) { + if (integration.provider === "github_projects") { + const result = await syncGitHubProjects( + supabaseAdmin, + integration.organization_id, + ); + outcomes.push( + "error" in result + ? { + organizationId: integration.organization_id, + provider: integration.provider, + ok: false, + error: result.error, + } + : { + organizationId: integration.organization_id, + provider: integration.provider, + ok: true, + boards: result.boards.map((b) => ({ + title: b.title, + itemsUpserted: b.itemsUpserted, + eventsUpserted: b.eventsUpserted, + historyFetched: b.historyFetched, + itemsWithoutHistory: b.itemsWithoutHistory, + })), + }, + ); + continue; + } + if (integration.provider !== "datadog") continue; const result = await syncOrganization( @@ -60,6 +99,7 @@ export async function GET(request: NextRequest) { if ("error" in result) { outcomes.push({ organizationId: integration.organization_id, + provider: integration.provider, ok: false, error: result.error, }); @@ -87,6 +127,7 @@ export async function GET(request: NextRequest) { outcomes.push({ organizationId: integration.organization_id, + provider: integration.provider, ok: true, deploymentsUpserted: result.deploymentsUpserted, commitsUpserted: result.commitsUpserted, diff --git a/platform/src/types/board-flow.ts b/platform/src/types/board-flow.ts new file mode 100644 index 0000000..033789c --- /dev/null +++ b/platform/src/types/board-flow.ts @@ -0,0 +1,252 @@ +/** + * Types for board flow analysis (GitHub Projects V2). + * + * Nothing here encodes a particular workflow. Column names are free text on + * every board, so a board's columns are mapped onto lifecycle buckets either + * explicitly (per-board `status_config`) or by generic name heuristics. + */ + +/** + * Lifecycle buckets a column can map to. + * + * `queue` is the one addition to the obvious four. Boards routinely have + * columns that are neither backlog nor work-in-flight — "Ready for X", + * "Awaiting Y", "Blocked". Folding those into `active` inflates flow + * efficiency (the metric whose entire job is exposing invisible waiting), and + * folding them into `backlog` corrupts backlog-growth. They get their own + * bucket: counted as waiting, not as backlog. + */ +export type LifecycleBucket = + "backlog" | "discovery" | "active" | "queue" | "done"; + +/** Per-board column mapping. Keys are bucket names, values are column names. */ +export type StatusConfig = Partial>; + +export interface StatusClassification { + /** Lowercased column name → bucket. */ + byStatus: Map; + /** + * Columns seen on the board that matched neither the explicit config nor any + * heuristic. Surfaced as a warning: a column silently treated as "not done" + * skews lead time for every item that ends there. + */ + unmapped: string[]; +} + +export interface BoardItemInput { + id: string; + title: string; + contentType: "ISSUE" | "PULL_REQUEST" | "DRAFT_ISSUE"; + currentStatus: string | null; + /** OPEN | CLOSED | MERGED; null for drafts. */ + contentState: string | null; + sourceCreatedAt: string | null; + sourceClosedAt: string | null; + itemUpdatedAt: string | null; + assignees: string[]; + labels: string[]; + iteration: string | null; + priority: string | null; + size: string | null; + /** False for drafts and for items whose history fetch never succeeded. */ + historyAvailable: boolean; +} + +export interface StatusEventInput { + itemId: string; + kind: "ADDED" | "STATUS_CHANGED" | "REMOVED"; + previousStatus: string | null; + status: string | null; + occurredAt: string; + wasAutomated: boolean; +} + +/** + * Where a lead time number came from. Anything other than `transitions` is an + * approximation and must be labelled as such wherever it is displayed. + */ +export type LeadTimeSource = "transitions" | "closed_at" | "item_updated_at"; + +export interface PhaseVisit { + status: string; + bucket: LifecycleBucket | null; + enteredAt: string; + /** Null while the item still sits in this column. */ + exitedAt: string | null; + hours: number; +} + +export interface ItemFlow { + itemId: string; + title: string; + enteredBoardAt: string | null; + /** Chronological, one entry per visit — re-entering a column adds a visit. */ + visits: PhaseVisit[]; + /** Accumulated hours per column, summing every visit. */ + hoursByStatus: Record; + /** Visit count per column. > 1 means the item came back. */ + passesByStatus: Record; + terminalAt: string | null; + isTerminal: boolean; + /** Taken off the board. An exit, never a completion. */ + removedFromBoard: boolean; + currentStatus: string | null; + assignees: string[]; + leadTimeHours: number | null; + leadTimeSource: LeadTimeSource | null; + cycleTimeHours: number | null; + activeHours: number; + /** activeHours / leadTimeHours; null when lead time is unknown or zero. */ + flowEfficiency: number | null; + /** Age of a non-terminal item since it entered the board. */ + ageHours: number | null; + hoursInCurrentStatus: number | null; + /** True when any displayed duration for this item rests on a fallback. */ + approximate: boolean; +} + +/** + * Percentiles with an explicit sample guard. `suppressed` names the + * percentiles withheld because the sample was too small to support them — + * displaying P95 over six observations is worse than displaying nothing. + */ +export interface PercentileSet { + n: number; + p50: number | null; + p70: number | null; + p85: number | null; + p95: number | null; + suppressed: string[]; + /** Raw sorted observations, so a small sample can be shown as a strip plot. */ + raw: number[]; +} + +export interface PhaseStat { + status: string; + bucket: LifecycleBucket | null; + /** Items that passed through this column at least once. */ + n: number; + medianHours: number | null; + totalHours: number; + /** Items that entered this column more than once. */ + reentered: number; +} + +export interface WeeklyCount { + /** ISO week key, e.g. "2026-W34". */ + week: string; + count: number; +} + +export interface FlowBalance { + week: string; + inflow: number; + outflow: number; + /** Running sum of (inflow - outflow) up to and including this week. */ + cumulativeDelta: number; +} + +export interface CfdPoint { + week: string; + /** Item count per column at the end of that week. */ + counts: Record; +} + +export interface AgingColumn { + status: string; + bucket: LifecycleBucket | null; + count: number; + medianAgeHours: number | null; + maxAgeHours: number | null; +} + +export interface StalledItem { + itemId: string; + title: string; + currentStatus: string | null; + /** Hours since the last status transition (or board entry). */ + hoursSinceLastMove: number; + /** Hours since the item entered the board. */ + totalAgeHours: number; + assignees: string[]; +} + +export interface LittlesLawCheck { + wip: number; + /** Mean items reaching a terminal column per week. */ + throughputPerWeek: number | null; + /** WIP / throughput, expressed in hours. Null when throughput is zero. */ + predictedLeadTimeHours: number | null; + observedLeadTimeHours: number | null; + /** |predicted - observed| / observed. Large values mean the model is off. */ + divergenceRatio: number | null; +} + +export interface CoverageStats { + totalItems: number; + /** Items with real transition history (excludes drafts). */ + itemsWithHistory: number; + /** Items counted with a fallback lead time. */ + itemsApproximated: number; + historyCoveragePct: number; +} + +export interface BoardFlowSummary { + boardId: string; + title: string; + teamSlug: string | null; + coverage: CoverageStats; + leadTime: PercentileSet; + cycleTime: PercentileSet; + phases: PhaseStat[]; + flowEfficiencyMedian: number | null; + throughput: WeeklyCount[]; + balance: FlowBalance[]; + wip: number; + aging: AgingColumn[]; + stalled: StalledItem[]; + cfd: CfdPoint[]; + littlesLaw: LittlesLawCheck; + unmappedStatuses: string[]; +} + +// --------------------------------------------------------------------------- +// Quality gates +// --------------------------------------------------------------------------- + +export type GateSeverity = "ok" | "warning" | "critical"; + +export type GateId = + | "synthetic_items" + | "done_not_closed" + | "bulk_movement" + | "field_completeness" + | "assignee_concentration" + | "history_coverage"; + +export interface QualityGate { + id: GateId; + severity: GateSeverity; + /** The measured value the severity was decided on. */ + value: number; + /** Unit of `value`, for display. */ + unit: "percent" | "count"; + affectedItemIds: string[]; + /** + * Plain-language statement of what this does to the reading. Written in + * English as the neutral base, same convention as the engine's narrative + * output; the UI layer localizes by `id`. + */ + summary: string; +} + +export interface QualityReport { + gates: QualityGate[]; + /** Worst severity across gates — drives how loudly the UI hedges. */ + overall: GateSeverity; + /** + * True when at least one critical gate fired. Metrics should still be + * computed and shown, but never without the warning attached. + */ + degraded: boolean; +} diff --git a/platform/supabase/migrations/023_github_projects.sql b/platform/supabase/migrations/023_github_projects.sql new file mode 100644 index 0000000..26505d2 --- /dev/null +++ b/platform/supabase/migrations/023_github_projects.sql @@ -0,0 +1,146 @@ +-- 023_github_projects.sql +-- Board-level flow analysis from GitHub Projects V2. +-- +-- Why there is no snapshot table here +-- ----------------------------------- +-- The obvious design for "how long did each card sit in each column" is a +-- periodic snapshot of the board plus a diffing job. That design is wrong for +-- Projects V2: the GraphQL API exposes `ProjectV2ItemStatusChangedEvent` on the +-- issue timeline, carrying `createdAt`, `previousStatus`, `status` and +-- `wasAutomated`. The full transition history is therefore readable +-- retroactively, at second precision, on the first sync — no accumulation +-- period, no +/-24h detection window, and no blind spot for two transitions +-- inside one collection interval. +-- +-- So we persist the *events* the API already knows about, and derive phase +-- durations with window functions over `occurred_at`. Re-entry into a column +-- is a separate row, which is what makes accumulated per-phase time correct +-- instead of last-write-wins. +-- +-- The one thing this cannot cover: `DraftIssue` has no `timelineItems` field at +-- all (it is not an Issue), so drafts carry current status but no history. +-- `project_items.history_available` marks them, and duration metrics exclude +-- them rather than guessing. + +ALTER TYPE integration_provider ADD VALUE IF NOT EXISTS 'github_projects'; + +-- --------------------------------------------------------------------------- +-- Boards +-- --------------------------------------------------------------------------- + +CREATE TABLE project_boards ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + -- GitHub's global node id for the project, e.g. "PVT_kwDOA...". + provider_project_id TEXT NOT NULL, + -- Owner login + the human-facing project number, as used in the URL. + owner_login TEXT NOT NULL, + owner_type TEXT NOT NULL DEFAULT 'organization' + CHECK (owner_type IN ('organization', 'user')), + number INTEGER NOT NULL, + title TEXT NOT NULL, + -- Optional grouping label. Iris has no team entity of its own; a board maps + -- to whatever unit the adopter organizes by (team, squad, tribe, product). + -- Analysis groups by this string and never interprets it. + team_slug TEXT, + -- Maps this board's column names onto the four lifecycle buckets: + -- {"backlog": [...], "discovery": [...], "active": [...], "done": [...]} + -- Empty means "classify by the built-in name heuristics". Column names are + -- free text per board, so any hardcoded vocabulary would be wrong for + -- somebody; unmatched columns are reported, never silently dropped. + status_config JSONB NOT NULL DEFAULT '{}'::jsonb, + last_synced_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (organization_id, provider_project_id) +); + +CREATE INDEX idx_project_boards_org ON project_boards(organization_id); +CREATE INDEX idx_project_boards_team + ON project_boards(organization_id, team_slug) + WHERE team_slug IS NOT NULL; + +CREATE TRIGGER update_project_boards_updated_at BEFORE UPDATE ON project_boards + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- --------------------------------------------------------------------------- +-- Items +-- --------------------------------------------------------------------------- + +CREATE TABLE project_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + board_id UUID NOT NULL REFERENCES project_boards(id) ON DELETE CASCADE, + -- GitHub's global node id for the board item ("PVTI_..."). Idempotency key. + provider_item_id TEXT NOT NULL, + content_type TEXT NOT NULL + CHECK (content_type IN ('ISSUE', 'PULL_REQUEST', 'DRAFT_ISSUE')), + -- NULL for DRAFT_ISSUE: a draft lives only on the board, so it has no + -- repository and no number. Code reading these must not assume an issue. + content_repo TEXT, + content_number INTEGER, + -- Best-effort link to a tracked Iris repo, when content_repo resolves. + repository_id UUID REFERENCES repositories(id) ON DELETE SET NULL, + title TEXT NOT NULL, + -- Current Status field value. NULL when the board has no Status field or + -- the item was never assigned one. + current_status TEXT, + -- OPEN | CLOSED | MERGED for issues/PRs; NULL for drafts. + content_state TEXT, + source_created_at TIMESTAMPTZ, + source_closed_at TIMESTAMPTZ, + -- The board item's own updatedAt. Drives incremental re-sync: an item whose + -- updatedAt has not moved cannot have new transitions. + item_updated_at TIMESTAMPTZ, + assignees TEXT[] NOT NULL DEFAULT '{}', + labels TEXT[] NOT NULL DEFAULT '{}', + iteration TEXT, + priority TEXT, + size TEXT, + -- FALSE for drafts (no timeline) and for items whose history fetch failed. + -- Duration metrics must filter on this instead of treating absence as zero. + history_available BOOLEAN NOT NULL DEFAULT FALSE, + -- TRUE when the timeline had more status events than we paginated through. + history_truncated BOOLEAN NOT NULL DEFAULT FALSE, + fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (board_id, provider_item_id) +); + +CREATE INDEX idx_project_items_board ON project_items(board_id); +CREATE INDEX idx_project_items_board_status + ON project_items(board_id, current_status); +CREATE INDEX idx_project_items_repo + ON project_items(repository_id) + WHERE repository_id IS NOT NULL; + +-- --------------------------------------------------------------------------- +-- Status events +-- --------------------------------------------------------------------------- + +CREATE TABLE project_status_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + item_id UUID NOT NULL REFERENCES project_items(id) ON DELETE CASCADE, + -- ADDED: item entered the board (AddedToProjectV2Event). + -- STATUS_CHANGED: column move (ProjectV2ItemStatusChangedEvent). + -- REMOVED: taken off the board (RemovedFromProjectV2Event) — an exit, and + -- explicitly NOT a completion. Throughput must not count it. + event_kind TEXT NOT NULL + CHECK (event_kind IN ('ADDED', 'STATUS_CHANGED', 'REMOVED')), + -- Empty string when GitHub reports the item had no prior status (first + -- assignment). Kept verbatim rather than normalized to NULL so the + -- distinction between "no previous column" and "unknown" survives. + previous_status TEXT, + status TEXT, + occurred_at TIMESTAMPTZ NOT NULL, + -- GitHub's own flag for automation-driven moves (workflow rules, auto-add). + -- A better signal than guessing from identical timestamps. + was_automated BOOLEAN NOT NULL DEFAULT FALSE, + actor_login TEXT, + -- Global node id of the timeline event. Idempotency key: re-syncing the + -- same timeline re-upserts the same rows instead of duplicating history. + provider_event_id TEXT NOT NULL UNIQUE, + fetched_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Phase derivation always reads one item's events in chronological order. +CREATE INDEX idx_project_status_events_item_time + ON project_status_events(item_id, occurred_at); diff --git a/platform/tests/board-flow.test.ts b/platform/tests/board-flow.test.ts new file mode 100644 index 0000000..c5179c3 --- /dev/null +++ b/platform/tests/board-flow.test.ts @@ -0,0 +1,598 @@ +import { describe, expect, it } from "vitest"; + +import { + MIN_SAMPLE_P95, + MIN_SAMPLE_PERCENTILES, + buildItemFlow, + classifyStatuses, + isoWeekKey, + percentiles, + summarizeBoard, +} from "@/lib/queries/board-flow"; +import type { BoardItemInput, StatusEventInput } from "@/types/board-flow"; + +// --------------------------------------------------------------------------- +// Fixtures +// +// Fictional board with a generic column vocabulary. Nothing here mirrors a +// particular organization's workflow — the point is that the module works off +// whatever column names it is handed. +// --------------------------------------------------------------------------- + +const NOW = new Date("2026-03-20T00:00:00Z"); + +function item(overrides: Partial = {}): BoardItemInput { + return { + id: "item-1", + title: "Add rate limiting to the public API", + contentType: "ISSUE", + currentStatus: "In Progress", + contentState: "OPEN", + sourceCreatedAt: "2026-03-01T09:00:00Z", + sourceClosedAt: null, + itemUpdatedAt: "2026-03-05T09:00:00Z", + assignees: ["dev-a"], + labels: [], + iteration: "Sprint 7", + priority: "P2", + size: "M", + historyAvailable: true, + ...overrides, + }; +} + +function event(overrides: Partial = {}): StatusEventInput { + return { + itemId: "item-1", + kind: "STATUS_CHANGED", + previousStatus: "", + status: "Backlog", + occurredAt: "2026-03-01T09:00:00Z", + wasAutomated: false, + ...overrides, + }; +} + +const CLASSIFICATION = classifyStatuses({}, [ + "Backlog", + "Discovery", + "Ready for Development", + "In Progress", + "Code Review", + "Done", +]); + +// --------------------------------------------------------------------------- +// classifyStatuses +// --------------------------------------------------------------------------- + +describe("classifyStatuses", () => { + it("maps generic column vocabularies without configuration", () => { + const c = classifyStatuses({}, [ + "Backlog", + "Discovery", + "In Progress", + "Code Review", + "Done", + ]); + expect(c.byStatus.get("backlog")).toBe("backlog"); + expect(c.byStatus.get("discovery")).toBe("discovery"); + expect(c.byStatus.get("in progress")).toBe("active"); + expect(c.byStatus.get("code review")).toBe("active"); + expect(c.byStatus.get("done")).toBe("done"); + expect(c.unmapped).toEqual([]); + }); + + it("treats a 'Ready for ...' column as a queue, not as active work", () => { + // Folding queues into active would inflate flow efficiency, whose entire + // job is exposing waiting. + const c = classifyStatuses({}, [ + "Ready for Development", + "Ready for Deploy", + ]); + expect(c.byStatus.get("ready for development")).toBe("queue"); + expect(c.byStatus.get("ready for deploy")).toBe("queue"); + }); + + it("lets explicit per-board config override the heuristics", () => { + const c = classifyStatuses({ active: ["Parking Lot"] }, ["Parking Lot"]); + expect(c.byStatus.get("parking lot")).toBe("active"); + expect(c.unmapped).toEqual([]); + }); + + // Edge case 8: custom columns matching no configured state. + it("reports unrecognised columns instead of silently swallowing them", () => { + const c = classifyStatuses({}, ["Backlog", "Zephyr", "Quadrant Two"]); + expect(c.unmapped).toEqual(["Zephyr", "Quadrant Two"]); + expect(c.byStatus.has("zephyr")).toBe(false); + }); + + it("is case- and whitespace-insensitive", () => { + const c = classifyStatuses({ done: [" SHIPPED "] }, ["shipped"]); + expect(c.byStatus.get("shipped")).toBe("done"); + }); +}); + +// --------------------------------------------------------------------------- +// buildItemFlow +// --------------------------------------------------------------------------- + +describe("buildItemFlow", () => { + // Edge case 1: first run, nothing recorded yet. + it("never invents a duration for an item with no events", () => { + const flow = buildItemFlow(item(), [], CLASSIFICATION, NOW); + + expect(flow.visits).toEqual([]); + expect(flow.leadTimeHours).toBeNull(); + expect(flow.leadTimeSource).toBeNull(); + expect(flow.cycleTimeHours).toBeNull(); + expect(flow.activeHours).toBe(0); + expect(flow.isTerminal).toBe(false); + // Board entry falls back to the issue's creation date, which is real data. + expect(flow.enteredBoardAt).toBe("2026-03-01T09:00:00Z"); + }); + + it("computes exact per-phase durations from transitions", () => { + const events: StatusEventInput[] = [ + event({ kind: "ADDED", previousStatus: null, status: null }), + event({ status: "Backlog" }), + event({ + previousStatus: "Backlog", + status: "In Progress", + occurredAt: "2026-03-03T09:00:00Z", + }), + event({ + previousStatus: "In Progress", + status: "Done", + occurredAt: "2026-03-05T09:00:00Z", + }), + ]; + + const flow = buildItemFlow( + item({ currentStatus: "Done", contentState: "CLOSED" }), + events, + CLASSIFICATION, + NOW, + ); + + expect(flow.hoursByStatus["Backlog"]).toBe(48); + expect(flow.hoursByStatus["In Progress"]).toBe(48); + expect(flow.leadTimeSource).toBe("transitions"); + expect(flow.leadTimeHours).toBe(96); + expect(flow.terminalAt).toBe("2026-03-05T09:00:00Z"); + expect(flow.isTerminal).toBe(true); + expect(flow.activeHours).toBe(48); + expect(flow.cycleTimeHours).toBe(48); + expect(flow.flowEfficiency).toBe(0.5); + expect(flow.approximate).toBe(false); + }); + + // Edge case 7: an item that goes backwards must accumulate both visits. + it("accumulates time across re-entry into the same column", () => { + const events: StatusEventInput[] = [ + event({ status: "In Progress" }), + event({ + previousStatus: "In Progress", + status: "Backlog", + occurredAt: "2026-03-02T09:00:00Z", + }), + event({ + previousStatus: "Backlog", + status: "In Progress", + occurredAt: "2026-03-04T09:00:00Z", + }), + event({ + previousStatus: "In Progress", + status: "Done", + occurredAt: "2026-03-05T09:00:00Z", + }), + ]; + + const flow = buildItemFlow(item(), events, CLASSIFICATION, NOW); + + // 24h on the first pass + 24h on the second — not overwritten. + expect(flow.hoursByStatus["In Progress"]).toBe(48); + expect(flow.passesByStatus["In Progress"]).toBe(2); + expect(flow.visits.filter((v) => v.status === "In Progress")).toHaveLength( + 2, + ); + }); + + it("falls back to closedAt and marks the result approximate", () => { + const flow = buildItemFlow( + item({ + historyAvailable: false, + sourceClosedAt: "2026-03-04T09:00:00Z", + currentStatus: "Done", + contentState: "CLOSED", + }), + [], + CLASSIFICATION, + NOW, + ); + + expect(flow.leadTimeSource).toBe("closed_at"); + expect(flow.leadTimeHours).toBe(72); + expect(flow.approximate).toBe(true); + }); + + it("does not use updatedAt as a lead time for an item that never finished", () => { + // updatedAt moves on any edit; treating it as completion would fabricate + // a lead time for work still in flight. + const flow = buildItemFlow( + item({ historyAvailable: false, itemUpdatedAt: "2026-03-10T09:00:00Z" }), + [], + CLASSIFICATION, + NOW, + ); + + expect(flow.leadTimeHours).toBeNull(); + expect(flow.leadTimeSource).toBeNull(); + }); + + // Edge case 5: a draft has no timeline at all. + it("yields no phase durations for a draft item", () => { + const flow = buildItemFlow( + item({ + contentType: "DRAFT_ISSUE", + contentState: null, + sourceClosedAt: null, + historyAvailable: false, + }), + [], + CLASSIFICATION, + NOW, + ); + + expect(flow.visits).toEqual([]); + expect(flow.leadTimeHours).toBeNull(); + expect(flow.activeHours).toBe(0); + }); + + // Edge case 4: created == updated, untouched for weeks. + it("ages an untouched in-flight item from its board entry", () => { + const flow = buildItemFlow( + item({ + sourceCreatedAt: "2026-02-27T00:00:00Z", + itemUpdatedAt: "2026-02-27T00:00:00Z", + }), + [event({ status: "In Progress", occurredAt: "2026-02-27T00:00:00Z" })], + CLASSIFICATION, + NOW, + ); + + expect(flow.isTerminal).toBe(false); + expect(flow.ageHours).toBe(21 * 24); + expect(flow.hoursInCurrentStatus).toBe(21 * 24); + }); + + it("treats removal from the board as an exit, never as completion", () => { + const events: StatusEventInput[] = [ + event({ status: "In Progress" }), + event({ + kind: "REMOVED", + previousStatus: null, + status: null, + occurredAt: "2026-03-02T09:00:00Z", + }), + ]; + + const flow = buildItemFlow(item(), events, CLASSIFICATION, NOW); + + expect(flow.removedFromBoard).toBe(true); + expect(flow.isTerminal).toBe(false); + expect(flow.terminalAt).toBeNull(); + // The open visit is closed at removal, not left running to now. + expect(flow.hoursByStatus["In Progress"]).toBe(24); + }); + + it("orders events defensively rather than trusting input order", () => { + const events: StatusEventInput[] = [ + event({ + previousStatus: "Backlog", + status: "Done", + occurredAt: "2026-03-05T09:00:00Z", + }), + event({ status: "Backlog", occurredAt: "2026-03-01T09:00:00Z" }), + ]; + + const flow = buildItemFlow(item(), events, CLASSIFICATION, NOW); + expect(flow.hoursByStatus["Backlog"]).toBe(96); + expect(flow.terminalAt).toBe("2026-03-05T09:00:00Z"); + }); +}); + +// --------------------------------------------------------------------------- +// percentiles +// --------------------------------------------------------------------------- + +describe("percentiles", () => { + it("returns nothing for an empty sample", () => { + const p = percentiles([]); + expect(p.n).toBe(0); + expect(p.p50).toBeNull(); + expect(p.suppressed).toContain("p50"); + }); + + it("gives only the median and the raw strip below the small-sample floor", () => { + const p = percentiles([1, 2, 3, 4, 5, 6]); + expect(p.n).toBe(6); + expect(p.p50).toBe(3.5); + expect(p.p95).toBeNull(); + expect(p.p85).toBeNull(); + expect(p.suppressed).toEqual(["p70", "p85", "p95"]); + expect(p.raw).toEqual([1, 2, 3, 4, 5, 6]); + }); + + it("withholds P95 until the sample can support it", () => { + const sample = Array.from({ length: MIN_SAMPLE_P95 - 1 }, (_, i) => i + 1); + const p = percentiles(sample); + expect(p.n).toBeGreaterThanOrEqual(MIN_SAMPLE_PERCENTILES); + expect(p.p85).not.toBeNull(); + expect(p.p95).toBeNull(); + expect(p.suppressed).toEqual(["p95"]); + }); + + it("reports every percentile once the sample is large enough", () => { + const sample = Array.from({ length: MIN_SAMPLE_P95 }, (_, i) => i + 1); + const p = percentiles(sample); + expect(p.p50).not.toBeNull(); + expect(p.p95).not.toBeNull(); + expect(p.suppressed).toEqual([]); + }); +}); + +describe("isoWeekKey", () => { + it("keys by ISO week, with Thursday deciding the year", () => { + expect(isoWeekKey(new Date("2026-03-18T12:00:00Z"))).toBe("2026-W12"); + // 1 Jan 2027 is a Friday, so it belongs to the last ISO week of 2026. + expect(isoWeekKey(new Date("2027-01-01T00:00:00Z"))).toBe("2026-W53"); + }); +}); + +// --------------------------------------------------------------------------- +// summarizeBoard +// --------------------------------------------------------------------------- + +describe("summarizeBoard", () => { + it("summarises a small board end to end", () => { + const items: BoardItemInput[] = [ + item({ id: "a", currentStatus: "Done", contentState: "CLOSED" }), + item({ id: "b", currentStatus: "In Progress" }), + item({ + id: "c", + contentType: "DRAFT_ISSUE", + contentState: null, + currentStatus: "Backlog", + historyAvailable: false, + }), + ]; + + const events: StatusEventInput[] = [ + event({ itemId: "a", status: "Backlog" }), + event({ + itemId: "a", + previousStatus: "Backlog", + status: "In Progress", + occurredAt: "2026-03-02T09:00:00Z", + }), + event({ + itemId: "a", + previousStatus: "In Progress", + status: "Done", + occurredAt: "2026-03-04T09:00:00Z", + }), + event({ + itemId: "b", + status: "In Progress", + occurredAt: "2026-03-10T09:00:00Z", + }), + ]; + + const summary = summarizeBoard(items, events, { + boardId: "board-1", + title: "Team Alpha", + teamSlug: "team-alpha", + now: NOW, + }); + + expect(summary.coverage.totalItems).toBe(3); + expect(summary.coverage.itemsWithHistory).toBe(2); + expect(summary.coverage.historyCoveragePct).toBe(66.7); + + // Only the finished item has a lead time; the draft contributes nothing. + expect(summary.leadTime.n).toBe(1); + expect(summary.leadTime.p50).toBe(72); + + // WIP excludes the terminal item; the draft counts (it is on the board). + expect(summary.wip).toBe(2); + expect(summary.throughput).toEqual([{ week: "2026-W10", count: 1 }]); + expect(summary.unmappedStatuses).toEqual([]); + }); + + it("reports phase stats with a per-column sample size", () => { + const items = [ + item({ id: "a", currentStatus: "Done" }), + item({ id: "b", currentStatus: "Done" }), + ]; + const events: StatusEventInput[] = [ + event({ itemId: "a", status: "In Progress" }), + event({ + itemId: "a", + previousStatus: "In Progress", + status: "Done", + occurredAt: "2026-03-02T09:00:00Z", + }), + event({ itemId: "b", status: "In Progress" }), + event({ + itemId: "b", + previousStatus: "In Progress", + status: "Done", + occurredAt: "2026-03-04T09:00:00Z", + }), + ]; + + const summary = summarizeBoard(items, events, { + boardId: "board-1", + title: "Team Alpha", + now: NOW, + }); + + const inProgress = summary.phases.find((p) => p.status === "In Progress"); + expect(inProgress?.n).toBe(2); + expect(inProgress?.medianHours).toBe(48); + expect(inProgress?.bucket).toBe("active"); + expect(inProgress?.reentered).toBe(0); + }); + + it("tracks inflow against outflow and the cumulative backlog delta", () => { + const items = [ + item({ id: "a", currentStatus: "Done" }), + item({ id: "b", currentStatus: "Backlog" }), + item({ id: "c", currentStatus: "Backlog" }), + ]; + const events: StatusEventInput[] = [ + event({ + itemId: "a", + status: "Backlog", + occurredAt: "2026-03-02T09:00:00Z", + }), + event({ + itemId: "a", + previousStatus: "Backlog", + status: "Done", + occurredAt: "2026-03-03T09:00:00Z", + }), + event({ + itemId: "b", + status: "Backlog", + occurredAt: "2026-03-04T09:00:00Z", + }), + event({ + itemId: "c", + status: "Backlog", + occurredAt: "2026-03-05T09:00:00Z", + }), + ]; + + const summary = summarizeBoard(items, events, { + boardId: "board-1", + title: "Team Alpha", + now: NOW, + }); + + // Three arrived, one left, in the same ISO week. + expect(summary.balance).toEqual([ + { week: "2026-W10", inflow: 3, outflow: 1, cumulativeDelta: 2 }, + ]); + }); + + it("lists stalled items worst-first and never counts terminal ones", () => { + const items = [ + item({ id: "stuck", currentStatus: "Code Review" }), + item({ id: "fresh", currentStatus: "In Progress" }), + item({ id: "shipped", currentStatus: "Done" }), + ]; + const events: StatusEventInput[] = [ + event({ + itemId: "stuck", + status: "Code Review", + occurredAt: "2026-02-01T00:00:00Z", + }), + event({ + itemId: "fresh", + status: "In Progress", + occurredAt: "2026-03-19T00:00:00Z", + }), + event({ + itemId: "shipped", + status: "Done", + occurredAt: "2026-01-01T00:00:00Z", + }), + ]; + + const summary = summarizeBoard(items, events, { + boardId: "board-1", + title: "Team Alpha", + now: NOW, + }); + + expect(summary.stalled.map((s) => s.itemId)).toEqual(["stuck"]); + expect(summary.stalled[0].currentStatus).toBe("Code Review"); + expect(summary.stalled[0].assignees).toEqual(["dev-a"]); + }); + + it("builds a cumulative flow series from the event stream", () => { + const items = [item({ id: "a", currentStatus: "Done" })]; + const events: StatusEventInput[] = [ + event({ + itemId: "a", + status: "Backlog", + occurredAt: "2026-03-02T09:00:00Z", + }), + event({ + itemId: "a", + previousStatus: "Backlog", + status: "Done", + occurredAt: "2026-03-16T09:00:00Z", + }), + ]; + + const summary = summarizeBoard(items, events, { + boardId: "board-1", + title: "Team Alpha", + now: NOW, + }); + + expect(summary.cfd.length).toBeGreaterThan(1); + expect(summary.cfd[0].counts).toEqual({ Backlog: 1 }); + expect(summary.cfd[summary.cfd.length - 1].counts).toEqual({ Done: 1 }); + }); + + it("returns Little's Law alongside the observed lead time, not instead of it", () => { + const items = [ + item({ id: "a", currentStatus: "Done" }), + item({ id: "b", currentStatus: "In Progress" }), + ]; + const events: StatusEventInput[] = [ + event({ itemId: "a", status: "Backlog" }), + event({ + itemId: "a", + previousStatus: "Backlog", + status: "Done", + occurredAt: "2026-03-03T09:00:00Z", + }), + event({ + itemId: "b", + status: "In Progress", + occurredAt: "2026-03-02T09:00:00Z", + }), + ]; + + const summary = summarizeBoard(items, events, { + boardId: "board-1", + title: "Team Alpha", + now: NOW, + }); + + expect(summary.littlesLaw.wip).toBe(1); + expect(summary.littlesLaw.throughputPerWeek).toBe(1); + expect(summary.littlesLaw.predictedLeadTimeHours).toBe(168); + expect(summary.littlesLaw.observedLeadTimeHours).toBe(48); + expect(summary.littlesLaw.divergenceRatio).toBe(2.5); + }); + + it("handles an empty board without throwing", () => { + const summary = summarizeBoard([], [], { + boardId: "board-1", + title: "Team Alpha", + now: NOW, + }); + + expect(summary.coverage.totalItems).toBe(0); + expect(summary.leadTime.n).toBe(0); + expect(summary.wip).toBe(0); + expect(summary.cfd).toEqual([]); + expect(summary.littlesLaw.throughputPerWeek).toBeNull(); + }); +}); diff --git a/platform/tests/board-quality.test.ts b/platform/tests/board-quality.test.ts new file mode 100644 index 0000000..c3f25d2 --- /dev/null +++ b/platform/tests/board-quality.test.ts @@ -0,0 +1,328 @@ +import { describe, expect, it } from "vitest"; + +import { classifyStatuses } from "@/lib/queries/board-flow"; +import { + BULK_MOVE_MIN_ITEMS, + SYNTHETIC_BURST_MIN_ITEMS, + evaluateQuality, +} from "@/lib/queries/board-quality"; +import type { BoardItemInput, StatusEventInput } from "@/types/board-flow"; + +// Fictional board, generic column names — see board-flow.test.ts. +const CLASSIFICATION = classifyStatuses({}, [ + "Backlog", + "In Progress", + "Code Review", + "Done", +]); + +function item(overrides: Partial = {}): BoardItemInput { + return { + id: "item-1", + title: "Add rate limiting to the public API", + contentType: "ISSUE", + currentStatus: "In Progress", + contentState: "OPEN", + sourceCreatedAt: "2026-03-01T09:00:00Z", + sourceClosedAt: null, + itemUpdatedAt: "2026-03-05T09:00:00Z", + assignees: ["dev-a"], + labels: [], + iteration: "Sprint 7", + priority: "P2", + size: "M", + historyAvailable: true, + ...overrides, + }; +} + +function gate(items: BoardItemInput[], events: StatusEventInput[] = []) { + const report = evaluateQuality(items, events, CLASSIFICATION); + return (id: string) => report.gates.find((g) => g.id === id)!; +} + +// --------------------------------------------------------------------------- +// Synthetic items — edge case 3 +// --------------------------------------------------------------------------- + +describe("synthetic_items gate", () => { + it("stays quiet on a board of real work", () => { + const g = gate([item(), item({ id: "b", title: "Fix checkout timeout" })]); + expect(g("synthetic_items").severity).toBe("ok"); + expect(g("synthetic_items").value).toBe(0); + }); + + it("flags test-looking titles", () => { + const g = gate([ + item(), + item({ id: "b", title: "teste de fluxo" }), + item({ id: "c", title: "dummy card" }), + ]); + const result = g("synthetic_items"); + expect(result.affectedItemIds.sort()).toEqual(["b", "c"]); + expect(result.severity).toBe("critical"); + }); + + it("flags a same-minute burst that also died within minutes", () => { + // Nine cards created together and closed 25 minutes later: board setup, + // not delivery. Left in, they collapse the lead-time median. + const burst = Array.from({ length: 9 }, (_, i) => + item({ + id: `burst-${i}`, + title: `Card ${i}`, + sourceCreatedAt: "2026-03-01T10:00:00Z", + sourceClosedAt: "2026-03-01T10:25:00Z", + currentStatus: "Done", + contentState: "CLOSED", + }), + ); + + const result = gate([...burst, item({ id: "real" })])("synthetic_items"); + expect(result.affectedItemIds).toHaveLength(9); + expect(result.severity).toBe("critical"); + expect(result.summary).toContain("scaffolding"); + }); + + it("does not flag a planning session that creates many cards at once", () => { + // Same burst shape, but the cards live on — that is grooming, not setup. + const burst = Array.from( + { length: SYNTHETIC_BURST_MIN_ITEMS + 4 }, + (_, i) => + item({ + id: `plan-${i}`, + title: `Story ${i}`, + sourceCreatedAt: "2026-03-01T10:00:00Z", + sourceClosedAt: null, + }), + ); + + expect(gate(burst)("synthetic_items").severity).toBe("ok"); + }); +}); + +// --------------------------------------------------------------------------- +// Done but not closed — edge case 2 +// --------------------------------------------------------------------------- + +describe("done_not_closed gate", () => { + it("passes when every terminal item is closed", () => { + const g = gate([ + item({ currentStatus: "Done", contentState: "CLOSED" }), + item({ id: "b", currentStatus: "Done", contentState: "CLOSED" }), + ]); + expect(g("done_not_closed").severity).toBe("ok"); + }); + + it("fires when a Done item still has an open issue", () => { + const g = gate([ + item({ id: "a", currentStatus: "Done", contentState: "OPEN" }), + item({ id: "b", currentStatus: "Done", contentState: "CLOSED" }), + ]); + const result = g("done_not_closed"); + expect(result.value).toBe(50); + expect(result.severity).toBe("critical"); + expect(result.affectedItemIds).toEqual(["a"]); + expect(result.summary).toContain("closedAt"); + }); + + it("ignores non-terminal items entirely", () => { + const g = gate([ + item({ currentStatus: "In Progress", contentState: "OPEN" }), + ]); + expect(g("done_not_closed").value).toBe(0); + expect(g("done_not_closed").severity).toBe("ok"); + }); +}); + +// --------------------------------------------------------------------------- +// Bulk movement — edge case 6 +// --------------------------------------------------------------------------- + +describe("bulk_movement gate", () => { + it("stays quiet when moves are spread out", () => { + const events: StatusEventInput[] = Array.from({ length: 6 }, (_, i) => ({ + itemId: `item-${i}`, + kind: "STATUS_CHANGED", + previousStatus: "Backlog", + status: "In Progress", + occurredAt: new Date(Date.UTC(2026, 2, 1 + i, 9)).toISOString(), + wasAutomated: false, + })); + + const g = gate([], events); + expect(g("bulk_movement").severity).toBe("ok"); + }); + + it("flags many items moved inside the same couple of minutes", () => { + const events: StatusEventInput[] = Array.from( + { length: BULK_MOVE_MIN_ITEMS + 3 }, + (_, i) => ({ + itemId: `item-${i}`, + kind: "STATUS_CHANGED" as const, + previousStatus: "Backlog", + status: "Done", + occurredAt: new Date(Date.UTC(2026, 2, 1, 9, 0, i * 5)).toISOString(), + wasAutomated: false, + }), + ); + + const result = gate([], events)("bulk_movement"); + expect(result.affectedItemIds).toHaveLength(BULK_MOVE_MIN_ITEMS + 3); + expect(result.severity).toBe("critical"); + expect(result.summary).toContain("board maintenance"); + }); + + it("credits GitHub's own automation flag in the summary", () => { + const events: StatusEventInput[] = Array.from( + { length: BULK_MOVE_MIN_ITEMS }, + (_, i) => ({ + itemId: `item-${i}`, + kind: "STATUS_CHANGED" as const, + previousStatus: "Code Review", + status: "Done", + occurredAt: new Date(Date.UTC(2026, 2, 1, 9, 0, i)).toISOString(), + wasAutomated: true, + }), + ); + + expect(gate([], events)("bulk_movement").summary).toContain("automated"); + }); +}); + +// --------------------------------------------------------------------------- +// Field completeness +// --------------------------------------------------------------------------- + +describe("field_completeness gate", () => { + it("reports the weakest field and what it limits", () => { + const items = [ + item({ id: "a", priority: null, size: null }), + item({ id: "b", priority: null, size: "M" }), + ]; + const result = gate(items)("field_completeness"); + + expect(result.value).toBe(0); + expect(result.severity).toBe("critical"); + expect(result.summary).toContain("priority 0%"); + }); + + it("passes a well-filled board", () => { + expect( + gate([item(), item({ id: "b" })])("field_completeness").severity, + ).toBe("ok"); + }); + + it("does not crash on an empty board", () => { + expect(gate([])("field_completeness").summary).toContain("No items"); + }); +}); + +// --------------------------------------------------------------------------- +// Assignee concentration +// --------------------------------------------------------------------------- + +describe("assignee_concentration gate", () => { + it("reads concentration as a board property, not a person's output", () => { + const items = [ + item({ id: "a", assignees: ["dev-a"] }), + item({ id: "b", assignees: ["dev-a"] }), + item({ id: "c", assignees: ["dev-a"] }), + item({ id: "d", assignees: [] }), + ]; + const result = gate(items)("assignee_concentration"); + + expect(result.value).toBe(100); + expect(result.severity).toBe("critical"); + // No login is ever returned — only the share. + expect(result.summary).not.toContain("dev-a"); + expect(result.summary).toContain("never a person"); + expect(result.affectedItemIds).toEqual([]); + }); + + it("says so plainly when nothing is assigned", () => { + const result = gate([item({ assignees: [] })])("assignee_concentration"); + expect(result.summary).toContain("No items carry an assignee"); + }); + + it("passes when work is spread across people", () => { + const items = [ + item({ id: "a", assignees: ["dev-a"] }), + item({ id: "b", assignees: ["dev-b"] }), + item({ id: "c", assignees: ["dev-c"] }), + item({ id: "d", assignees: ["dev-d"] }), + ]; + expect(gate(items)("assignee_concentration").severity).toBe("ok"); + }); +}); + +// --------------------------------------------------------------------------- +// History coverage — edge cases 1 and 5 +// --------------------------------------------------------------------------- + +describe("history_coverage gate", () => { + it("is critical on a first run where nothing has history yet", () => { + const items = [ + item({ id: "a", historyAvailable: false }), + item({ id: "b", historyAvailable: false }), + ]; + const result = gate(items)("history_coverage"); + + expect(result.value).toBe(0); + expect(result.severity).toBe("critical"); + expect(result.affectedItemIds.sort()).toEqual(["a", "b"]); + }); + + it("explains that drafts can never contribute durations", () => { + const items = [ + item({ id: "a" }), + item({ + id: "b", + contentType: "DRAFT_ISSUE", + contentState: null, + historyAvailable: false, + }), + ]; + const result = gate(items)("history_coverage"); + + expect(result.value).toBe(50); + expect(result.summary).toContain("draft"); + expect(result.summary).toContain("no timeline"); + }); + + it("passes when the whole board carries history", () => { + expect(gate([item(), item({ id: "b" })])("history_coverage").severity).toBe( + "ok", + ); + }); +}); + +// --------------------------------------------------------------------------- +// Report roll-up +// --------------------------------------------------------------------------- + +describe("evaluateQuality", () => { + it("rolls the worst gate up and marks the report degraded", () => { + const report = evaluateQuality( + [item({ currentStatus: "Done", contentState: "OPEN" })], + [], + CLASSIFICATION, + ); + + expect(report.overall).toBe("critical"); + expect(report.degraded).toBe(true); + expect(report.gates).toHaveLength(6); + }); + + it("reports a clean board as not degraded", () => { + const items = [ + item({ id: "a", assignees: ["dev-a"] }), + item({ id: "b", assignees: ["dev-b"] }), + item({ id: "c", assignees: ["dev-c"] }), + item({ id: "d", assignees: ["dev-d"] }), + ]; + const report = evaluateQuality(items, [], CLASSIFICATION); + + expect(report.degraded).toBe(false); + expect(report.overall).toBe("ok"); + }); +}); diff --git a/platform/tests/github-projects-sync.test.ts b/platform/tests/github-projects-sync.test.ts new file mode 100644 index 0000000..76dce10 --- /dev/null +++ b/platform/tests/github-projects-sync.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; + +import { readBoardConfig } from "@/lib/integrations/github-projects/sync"; + +/** + * `org_integrations.config` is operator-edited JSON, so the parser is the + * boundary where a typo has to degrade into "skip this board" rather than into + * a crashed cron run. + */ +describe("readBoardConfig", () => { + it("reads a complete board entry", () => { + const boards = readBoardConfig({ + boards: [ + { + owner: "acme-inc", + number: 12, + ownerType: "user", + teamSlug: "platform", + statusConfig: { done: ["Shipped"] }, + }, + ], + }); + + expect(boards).toEqual([ + { + owner: "acme-inc", + number: 12, + ownerType: "user", + teamSlug: "platform", + statusConfig: { done: ["Shipped"] }, + }, + ]); + }); + + it("defaults ownerType to organization and leaves the rest undefined", () => { + const [board] = readBoardConfig({ + boards: [{ owner: "acme-inc", number: 3 }], + }); + + expect(board.ownerType).toBe("organization"); + expect(board.teamSlug).toBeUndefined(); + // No statusConfig means "classify by the generic name heuristics". + expect(board.statusConfig).toBeUndefined(); + }); + + it("skips entries missing owner or number instead of throwing", () => { + const boards = readBoardConfig({ + boards: [ + { owner: "acme-inc" }, + { number: 7 }, + { owner: "acme-inc", number: "7" }, + null, + "not-an-object", + { owner: "acme-inc", number: 9 }, + ], + }); + + expect(boards).toHaveLength(1); + expect(boards[0]).toMatchObject({ owner: "acme-inc", number: 9 }); + }); + + it("returns nothing for malformed or empty config", () => { + expect(readBoardConfig(null)).toEqual([]); + expect(readBoardConfig({})).toEqual([]); + expect(readBoardConfig({ boards: "nope" })).toEqual([]); + expect(readBoardConfig({ boards: [] })).toEqual([]); + }); + + it("treats an unknown ownerType as an organization rather than guessing", () => { + const [board] = readBoardConfig({ + boards: [{ owner: "acme-inc", number: 1, ownerType: "team" }], + }); + expect(board.ownerType).toBe("organization"); + }); +}); From 78fc47af70d84559f866eee5f1f1cc3318c875da Mon Sep 17 00:00:00 2001 From: Renato Guimaraes Date: Mon, 24 Aug 2026 13:07:38 -0300 Subject: [PATCH 2/4] fix(platform): correct three board-flow findings from a live-board dry-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds scripts/board-flow-dryrun.ts — reads a real board through the real client and runs the real gates and metrics without touching a database. Ran it against a 198-item board with 754 status events (~14 GraphQL requests, 17s), which surfaced three defects the unit tests could not. 1. Testing is real work. `synthetic_items` matched test/teste on their own and flagged three genuine items ("Permitir teste de cenários de rebooking", "Teste não moderado nova UI mobile") while catching zero placeholders — a 100% false-positive rate on that path. Unambiguous markers (dummy, asdf, lorem) still fire alone; ambiguous words now need a short lifetime to corroborate. 2. Import is not scaffolding. 91 of the 198 items were created in same-minute batches and closed minutes later — all real work, imported when the board was set up. Same distortion as a test card (near-zero lead time, throughput spike in one artificial week) but a different remedy: you delete a placeholder, you exclude an import from duration analysis. Split into its own `mass_import` gate so the finding names what actually happened. 3. Renamed columns were counted twice. Per-phase stats keyed on the raw name, so "Ready for Deploy" and "Ready for deploy" — one column, renamed — split into two rows with a misleadingly small n each (11 and 24 instead of 35). Now keyed on the normalized name, labelled with the most frequent spelling. Genuinely different names for the same stage stay separate; merging those needs explicit statusConfig, since guessing would be wrong elsewhere. Also excludes the terminal column from time-per-phase: an item sits in Done until archived, so its time there measured age since delivery and dominated the ranking at a 39-day median. Regression tests added for each, including the three real titles that were wrongly flagged. Co-authored-by: Claude Opus 5 (1M context) --- docs/integrations/github-projects.md | 41 +++- platform/lib/queries/board-flow.ts | 54 +++++- platform/lib/queries/board-quality.ts | 117 ++++++++--- platform/scripts/board-flow-dryrun.ts | 267 ++++++++++++++++++++++++++ platform/src/types/board-flow.ts | 1 + platform/tests/board-flow.test.ts | 75 ++++++++ platform/tests/board-quality.test.ts | 78 +++++++- 7 files changed, 584 insertions(+), 49 deletions(-) create mode 100644 platform/scripts/board-flow-dryrun.ts diff --git a/docs/integrations/github-projects.md b/docs/integrations/github-projects.md index 0cc2dbf..6e2055c 100644 --- a/docs/integrations/github-projects.md +++ b/docs/integrations/github-projects.md @@ -158,13 +158,26 @@ minutes apart. | Gate | Detects | Why it matters | |---|---|---| -| `synthetic_items` | Test-looking titles; same-minute bursts that also died within minutes | Scaffolding lands in the fast tail and collapses the median | +| `synthetic_items` | Placeholder titles (`dummy`, `asdf`, `lorem`); ambiguous ones only with a short lifetime | Scaffolding lands in the fast tail and collapses the median | +| `mass_import` | Same-minute creation batches also closed within minutes | Board import/backfill, not flow: near-zero lead time and a fake throughput spike | | `done_not_closed` | Terminal column with an open issue | Makes `closedAt` unusable as the lead-time fallback | | `bulk_movement` | 5+ items moved within 2 minutes; GitHub's `wasAutomated` | Records board maintenance, not flow | | `field_completeness` | Fill rate of priority / size / iteration / assignee | Decides which cuts are trustworthy | | `assignee_concentration` | Share held by the most-assigned account | Board may be a personal list, not group work | | `history_coverage` | Share of items with real history | The honest ceiling on duration analysis | +### Two lessons from running this on a live board + +**Testing is real work.** An earlier `synthetic_items` matched `test`/`teste` on +their own. On a 198-item board it flagged three genuine items ("Permitir teste +de cenários de rebooking") and caught zero placeholders. Ambiguous words now +require a short lifetime to corroborate; only unambiguous markers fire alone. + +**Import is not scaffolding.** 91 of those 198 items were created in +same-minute batches and closed minutes later — all real work, imported when the +board was set up. Same distortion, different remedy: you delete a placeholder, +you exclude an import from duration analysis. Hence two separate gates. + Each gate returns a severity, the measured value, the affected items and a plain statement of the impact on the reading. Metrics are still computed when a gate fires — they are just never shown without the caveat. @@ -207,6 +220,32 @@ Computed by `platform/lib/queries/board-flow.ts` — pure functions, no I/O. - **Little's Law is a check, not a headline.** Predicted and observed lead time are returned together; a large divergence usually means phantom WIP or a mis-mapped terminal column. +- **Renamed columns are one column.** Per-phase stats key on the normalized + name, because renaming leaves the old spelling on historical events — + otherwise "Ready for Deploy" and "Ready for deploy" split into two rows with a + misleadingly small `n` each. The most frequent spelling becomes the label. + Genuinely different names for the same stage ("Ready for dev" vs "Ready for + Development") stay separate; merging those needs explicit `statusConfig`, + since guessing would be wrong elsewhere. +- **The terminal column is excluded from time-per-phase.** An item sits in Done + until archived, so time there measures age since delivery, not flow. + +--- + +## Validating before you trust it + +`platform/scripts/board-flow-dryrun.ts` reads a real board through the real +client, runs the real gates and metrics, and prints the result — touching no +database. Use it to check the column classification and the gates against a +board before applying the migration or reading a dashboard. + +```bash +npx tsx scripts/board-flow-dryrun.ts [--user] +``` + +Token comes from `$GITHUB_TOKEN` or `gh auth token`. On a 198-item board with +754 status events it made ~14 GraphQL requests in about 17 seconds, which is +also the honest way to size the daily sync cost for a given board. --- diff --git a/platform/lib/queries/board-flow.ts b/platform/lib/queries/board-flow.ts index 6b946b2..5c6ab40 100644 --- a/platform/lib/queries/board-flow.ts +++ b/platform/lib/queries/board-flow.ts @@ -374,33 +374,54 @@ function computeCoverage( }; } +interface PhaseAccumulator { + /** Every spelling seen for this column, with how often each appeared. */ + labels: Map; + hours: number[]; + total: number; + reentered: number; +} + +/** + * Per-column time, keyed by *normalized* column name. + * + * Normalizing matters on real boards: renaming a column leaves the old + * spelling on historical events, so "Ready for Deploy" and "Ready for deploy" + * are one column whose stats would otherwise split in two, each with a + * misleadingly small `n`. The most frequent spelling becomes the label. + */ function computePhaseStats( flows: ItemFlow[], classification: StatusClassification, ): PhaseStat[] { - const perStatus = new Map< - string, - { hours: number[]; total: number; reentered: number } - >(); + const perStatus = new Map(); for (const flow of flows) { for (const [status, hours] of Object.entries(flow.hoursByStatus)) { - const entry = perStatus.get(status) ?? { + // A terminal column has no meaningful duration: the item sits in "Done" + // until somebody archives it, so time there measures age since delivery, + // not flow. Left in, it dominates the ranking. + if (bucketOf(status, classification) === "done") continue; + + const key = status.trim().toLowerCase(); + const entry = perStatus.get(key) ?? { + labels: new Map(), hours: [], total: 0, reentered: 0, }; + entry.labels.set(status, (entry.labels.get(status) ?? 0) + 1); entry.hours.push(hours); entry.total += hours; if ((flow.passesByStatus[status] ?? 0) > 1) entry.reentered += 1; - perStatus.set(status, entry); + perStatus.set(key, entry); } } return [...perStatus.entries()] - .map(([status, entry]) => ({ - status, - bucket: bucketOf(status, classification), + .map(([key, entry]) => ({ + status: mostFrequentLabel(entry.labels, key), + bucket: bucketOf(key, classification), n: entry.hours.length, medianHours: median(entry.hours), totalHours: round(entry.total, 2), @@ -409,6 +430,21 @@ function computePhaseStats( .sort((a, b) => b.totalHours - a.totalHours); } +function mostFrequentLabel( + labels: Map, + fallback: string, +): string { + let best = fallback; + let bestCount = -1; + for (const [label, count] of labels) { + if (count > bestCount) { + best = label; + bestCount = count; + } + } + return best; +} + function computeBalance(flows: ItemFlow[]): FlowBalance[] { const inflow = countByWeek( flows.map((f) => f.enteredBoardAt).filter(isString), diff --git a/platform/lib/queries/board-quality.ts b/platform/lib/queries/board-quality.ts index bccffc9..153ec50 100644 --- a/platform/lib/queries/board-quality.ts +++ b/platform/lib/queries/board-quality.ts @@ -24,17 +24,30 @@ import type { } from "@/types/board-flow"; /** - * Titles that look like board scaffolding rather than work. Deliberately - * conservative: a false positive here silently drops real work from the - * numbers, which is worse than a missed test card. + * Titles that can only be scaffolding. These fire on their own. + * + * Nothing here doubles as real engineering vocabulary — that distinction was + * learned the hard way. An earlier version also matched `test`/`teste` on their + * own and flagged three real items on a live board ("Teste não moderado nova + * UI mobile", "Permitir teste de cenários de rebooking") while catching zero + * actual test cards. On an engineering board, testing *is* the work. + */ +const UNAMBIGUOUS_SYNTHETIC_RE = + /\b(dummy|lorem|asdf|qwerty|placeholder|foo|bar|baz|tbd|xxx+|test card|card de teste|delete me|ignore me)\b/i; + +/** + * Titles that *might* be scaffolding. These only count when corroborated by a + * very short lifetime, so a real item about testing is never dropped. */ -const SYNTHETIC_TITLE_RE = - /\b(test|teste|testando|exemplo|example|dummy|sample|placeholder|lorem|foo|bar|baz|asdf|xxx|tbd)\b/i; +const WEAK_SYNTHETIC_RE = + /\b(test|teste|testing|testando|sample|example|exemplo)\b/i; -/** A burst this size created within one minute reads as scaffolding. */ +/** A burst this size created within one minute is treated as one event. */ export const SYNTHETIC_BURST_MIN_ITEMS = 5; -/** ...and closed this fast, it never represented real flow. */ +/** Lifetime under which an item never represented real flow. */ export const SYNTHETIC_BURST_MAX_LIFETIME_MINUTES = 30; +/** Share of the board created-and-closed in bulk that becomes critical. */ +export const MASS_IMPORT_CRITICAL_PCT = 25; /** This many status events inside the window reads as a board tidy-up. */ export const BULK_MOVE_MIN_ITEMS = 5; @@ -63,6 +76,7 @@ export function evaluateQuality( ): QualityReport { const gates: QualityGate[] = [ syntheticItemsGate(items), + massImportGate(items), doneNotClosedGate(items, classification), bulkMovementGate(events), fieldCompletenessGate(items), @@ -83,18 +97,53 @@ export function evaluateQuality( // --------------------------------------------------------------------------- /** - * Synthetic items: obvious test titles, plus same-minute creation bursts with - * a very short lifetime. Both patterns come from setting a board up, and both - * land in the fast tail of the lead-time distribution where they do the most - * damage to the median. + * Synthetic items: placeholder cards left behind from setting a board up. + * + * Unambiguous markers fire on their own. Ambiguous ones (`test`, `sample`) + * require a very short lifetime to corroborate, because on an engineering board + * testing is real work — matching those words alone produced only false + * positives on a live board. */ function syntheticItemsGate(items: BoardItemInput[]): QualityGate { const flagged = new Set(); for (const item of items) { - if (SYNTHETIC_TITLE_RE.test(item.title)) flagged.add(item.id); + if (UNAMBIGUOUS_SYNTHETIC_RE.test(item.title)) { + flagged.add(item.id); + continue; + } + if (WEAK_SYNTHETIC_RE.test(item.title) && isShortLived(item)) { + flagged.add(item.id); + } } + const pct = percentOf(flagged.size, items.length); + return { + id: "synthetic_items", + severity: flagged.size === 0 ? "ok" : pct >= 5 ? "critical" : "warning", + value: pct, + unit: "percent", + affectedItemIds: [...flagged], + summary: + flagged.size === 0 + ? "No placeholder or scaffolding items detected." + : `${flagged.size} item(s) (${pct}%) look like placeholder cards rather than real work. ` + + "They cluster in the fast tail and pull the lead-time median down; exclude them before reading any duration.", + }; +} + +/** + * Bulk creation that was also bulk-completed: items created within the same + * minute and closed minutes later. + * + * This is a separate finding from synthetic items, and the distinction came + * from real data. On a live board, 91 of 198 items matched this pattern — + * every one of them real work, imported when the board was set up. The + * distortion is severe (a zero-day lead time for half the board, plus a + * throughput spike in one artificial week) but the fix is different: you drop + * these from duration analysis, you do not delete them. + */ +function massImportGate(items: BoardItemInput[]): QualityGate { const byMinute = new Map(); for (const item of items) { if (!item.sourceCreatedAt) continue; @@ -106,20 +155,12 @@ function syntheticItemsGate(items: BoardItemInput[]): QualityGate { else byMinute.set(key, [item]); } + const flagged = new Set(); for (const burst of byMinute.values()) { if (burst.length < SYNTHETIC_BURST_MIN_ITEMS) continue; - const shortLived = burst.filter((item) => { - if (!item.sourceCreatedAt || !item.sourceClosedAt) return false; - const lifetime = - Date.parse(item.sourceClosedAt) - Date.parse(item.sourceCreatedAt); - return ( - Number.isFinite(lifetime) && - lifetime >= 0 && - lifetime <= SYNTHETIC_BURST_MAX_LIFETIME_MINUTES * MINUTE_MS - ); - }); - // Only a burst that *also* died young is scaffolding; a big planning - // session legitimately creates many cards at once. + const shortLived = burst.filter(isShortLived); + // A planning session legitimately creates many cards at once; only a burst + // that was also *completed* immediately looks like an import. if (shortLived.length >= SYNTHETIC_BURST_MIN_ITEMS) { for (const item of shortLived) flagged.add(item.id); } @@ -127,19 +168,37 @@ function syntheticItemsGate(items: BoardItemInput[]): QualityGate { const pct = percentOf(flagged.size, items.length); return { - id: "synthetic_items", - severity: flagged.size === 0 ? "ok" : pct >= 5 ? "critical" : "warning", + id: "mass_import", + severity: + flagged.size === 0 + ? "ok" + : pct >= MASS_IMPORT_CRITICAL_PCT + ? "critical" + : "warning", value: pct, unit: "percent", affectedItemIds: [...flagged], summary: flagged.size === 0 - ? "No synthetic or test-looking items detected." - : `${flagged.size} item(s) (${pct}%) look like board scaffolding rather than real work. ` + - "They cluster in the fast tail and pull the lead-time median down; exclude them before reading any duration.", + ? "No bulk create-and-close pattern detected." + : `${flagged.size} item(s) (${pct}%) were created in same-minute batches and closed within ` + + `${SYNTHETIC_BURST_MAX_LIFETIME_MINUTES} minutes — the signature of a board import or backfill, ` + + "not of work flowing. They carry a near-zero lead time and concentrate throughput into one " + + "artificial week; exclude them from duration and throughput readings.", }; } +function isShortLived(item: BoardItemInput): boolean { + if (!item.sourceCreatedAt || !item.sourceClosedAt) return false; + const lifetime = + Date.parse(item.sourceClosedAt) - Date.parse(item.sourceCreatedAt); + return ( + Number.isFinite(lifetime) && + lifetime >= 0 && + lifetime <= SYNTHETIC_BURST_MAX_LIFETIME_MINUTES * MINUTE_MS + ); +} + /** * Items parked in a terminal column while their issue is still open. When this * is common, `closedAt` stops being a usable completion marker — which matters diff --git a/platform/scripts/board-flow-dryrun.ts b/platform/scripts/board-flow-dryrun.ts new file mode 100644 index 0000000..d90d776 --- /dev/null +++ b/platform/scripts/board-flow-dryrun.ts @@ -0,0 +1,267 @@ +/** + * Dry-run the GitHub Projects board flow analysis against a live board. + * + * Reads the board through the real client, runs the real quality gates and the + * real metrics, and prints the result. Touches no database — which is the point: + * it validates the API queries, the column classification and the honesty rules + * against a real board before anyone applies a migration or wires a dashboard. + * + * Usage: + * npx tsx scripts/board-flow-dryrun.ts [--user] + * + * Token: $GITHUB_TOKEN, or falls back to `gh auth token`. Needs `read:project` + * (plus `repo` to read private repository content). + */ + +import { execFileSync } from "node:child_process"; + +import { + fetchProjectItems, + fetchStatusHistory, + type RawProjectItem, + type RawStatusEvent, +} from "../lib/integrations/github-projects/client"; +import { classifyStatuses, summarizeBoard } from "../lib/queries/board-flow"; +import { evaluateQuality } from "../lib/queries/board-quality"; +import type { BoardItemInput, StatusEventInput } from "../src/types/board-flow"; + +function resolveToken(): string { + if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN; + try { + return execFileSync("gh", ["auth", "token"], { encoding: "utf8" }).trim(); + } catch { + throw new Error( + "No token: set GITHUB_TOKEN or authenticate the GitHub CLI (`gh auth login`).", + ); + } +} + +/** + * Map the client's raw shapes onto the metric inputs. + * + * In production this translation happens on the read side (the metrics read + * persisted rows, not the client), so it lives here rather than in the library. + */ +function toItemInput( + raw: RawProjectItem, + historyAvailable: boolean, +): BoardItemInput { + return { + id: raw.itemId, + title: raw.title, + contentType: raw.contentType, + currentStatus: raw.status, + contentState: raw.contentState, + sourceCreatedAt: raw.createdAt, + sourceClosedAt: raw.closedAt, + itemUpdatedAt: raw.itemUpdatedAt, + assignees: raw.assignees, + labels: raw.labels, + iteration: raw.iteration, + priority: raw.priority, + size: raw.size, + historyAvailable, + }; +} + +function toEventInputs( + itemId: string, + events: RawStatusEvent[], +): StatusEventInput[] { + return events.map((e) => ({ + itemId, + kind: e.kind, + previousStatus: e.previousStatus, + status: e.status, + occurredAt: e.occurredAt, + wasAutomated: e.wasAutomated, + })); +} + +function days(hours: number | null): string { + return hours === null ? "—" : `${(hours / 24).toFixed(2)}d`; +} + +async function main() { + const [owner, numberArg, ...flags] = process.argv.slice(2); + if (!owner || !numberArg) { + console.error( + "Usage: npx tsx scripts/board-flow-dryrun.ts [--user]", + ); + process.exit(1); + } + + const board = { + ownerLogin: owner, + ownerType: flags.includes("--user") + ? ("user" as const) + : ("organization" as const), + number: Number(numberArg), + }; + const creds = { token: resolveToken() }; + + const startedAt = Date.now(); + console.log(`\nReading ${owner}/${board.number} ...`); + + const raw = await fetchProjectItems(creds, board); + const itemPages = Math.ceil(raw.items.length / 50); + console.log( + ` ${raw.items.length} items on "${raw.title}" (~${itemPages} page(s))`, + ); + + const withContent = raw.items.filter((i) => i.contentId !== null); + const drafts = raw.items.length - withContent.length; + + const history = await fetchStatusHistory( + creds, + withContent.map((i) => i.contentId!), + raw.projectId, + ); + const historyBatches = Math.ceil(withContent.length / 20); + const totalEvents = [...history.eventsByContentId.values()].reduce( + (sum, e) => sum + e.length, + 0, + ); + console.log( + ` ${totalEvents} status events across ${history.eventsByContentId.size} items ` + + `(~${historyBatches} batch(es)); ${drafts} draft(s) without history`, + ); + console.log( + ` ~${itemPages + historyBatches} GraphQL requests, ${((Date.now() - startedAt) / 1000).toFixed(1)}s\n`, + ); + + // Build metric inputs. An item has history when the fetch returned events for + // its content id; drafts never do. + const items: BoardItemInput[] = []; + const events: StatusEventInput[] = []; + for (const rawItem of raw.items) { + const itemEvents = rawItem.contentId + ? (history.eventsByContentId.get(rawItem.contentId) ?? []) + : []; + items.push(toItemInput(rawItem, itemEvents.length > 0)); + events.push(...toEventInputs(rawItem.itemId, itemEvents)); + } + + const seen = new Set(); + for (const i of items) if (i.currentStatus) seen.add(i.currentStatus); + for (const e of events) { + if (e.status) seen.add(e.status); + if (e.previousStatus) seen.add(e.previousStatus); + } + const classification = classifyStatuses({}, seen); + + console.log("COLUMN CLASSIFICATION (no config — pure heuristics)"); + for (const [status, bucket] of [...classification.byStatus].sort()) { + console.log(` ${bucket.padEnd(10)} ${status}`); + } + if (classification.unmapped.length > 0) { + console.log(` UNMAPPED: ${classification.unmapped.join(", ")}`); + } + + const quality = evaluateQuality(items, events, classification); + console.log(`\nQUALITY GATES — overall: ${quality.overall.toUpperCase()}`); + for (const gate of quality.gates) { + const mark = + gate.severity === "ok" + ? "ok " + : gate.severity === "warning" + ? "WARN" + : "CRIT"; + console.log( + ` [${mark}] ${gate.id} = ${gate.value}${gate.unit === "percent" ? "%" : ""}`, + ); + console.log(` ${gate.summary}`); + } + + const summary = summarizeBoard(items, events, { + boardId: raw.projectId, + title: raw.title, + now: new Date(), + }); + + console.log(`\nCOVERAGE`); + console.log( + ` ${summary.coverage.itemsWithHistory}/${summary.coverage.totalItems} items with history ` + + `(${summary.coverage.historyCoveragePct}%), ${summary.coverage.itemsApproximated} approximated`, + ); + + console.log(`\nLEAD TIME (n=${summary.leadTime.n})`); + console.log( + ` p50 ${days(summary.leadTime.p50)} p70 ${days(summary.leadTime.p70)} ` + + `p85 ${days(summary.leadTime.p85)} p95 ${days(summary.leadTime.p95)}` + + (summary.leadTime.suppressed.length + ? ` [suppressed: ${summary.leadTime.suppressed.join(", ")}]` + : ""), + ); + console.log(`CYCLE TIME (n=${summary.cycleTime.n})`); + console.log( + ` p50 ${days(summary.cycleTime.p50)} p85 ${days(summary.cycleTime.p85)}`, + ); + console.log( + `FLOW EFFICIENCY ${summary.flowEfficiencyMedian === null ? "—" : `${(summary.flowEfficiencyMedian * 100).toFixed(1)}%`}`, + ); + + console.log(`\nTIME PER COLUMN (median, n)`); + for (const phase of summary.phases) { + console.log( + ` ${(phase.bucket ?? "?").padEnd(10)} ${phase.status.padEnd(24)} ` + + `${days(phase.medianHours).padStart(8)} n=${phase.n}` + + (phase.reentered > 0 ? ` (${phase.reentered} re-entered)` : ""), + ); + } + + console.log(`\nWIP ${summary.wip} — aging by column`); + for (const col of summary.aging) { + console.log( + ` ${col.status.padEnd(24)} count=${String(col.count).padStart(3)} ` + + `median ${days(col.medianAgeHours).padStart(8)} max ${days(col.maxAgeHours).padStart(8)}`, + ); + } + + console.log( + `\nSTALLED (no move in 7+ days) — ${summary.stalled.length} item(s)`, + ); + for (const item of summary.stalled.slice(0, 10)) { + console.log( + ` ${days(item.hoursSinceLastMove).padStart(8)} in ${(item.currentStatus ?? "?").padEnd(22)} ` + + `age ${days(item.totalAgeHours).padStart(8)} ${item.title.slice(0, 46)}`, + ); + } + + console.log(`\nTHROUGHPUT (per ISO week)`); + for (const week of summary.throughput.slice(-8)) { + console.log( + ` ${week.week} ${"#".repeat(Math.min(week.count, 40))} ${week.count}`, + ); + } + + console.log(`\nFLOW BALANCE (last 8 weeks)`); + for (const week of summary.balance.slice(-8)) { + console.log( + ` ${week.week} in ${String(week.inflow).padStart(3)} out ${String(week.outflow).padStart(3)} ` + + `cumulative ${week.cumulativeDelta > 0 ? "+" : ""}${week.cumulativeDelta}`, + ); + } + + const ll = summary.littlesLaw; + console.log(`\nLITTLE'S LAW`); + console.log( + ` wip=${ll.wip} throughput=${ll.throughputPerWeek}/week ` + + `predicted ${days(ll.predictedLeadTimeHours)} observed ${days(ll.observedLeadTimeHours)} ` + + `divergence ${ll.divergenceRatio ?? "—"}`, + ); + + console.log(`\nCFD — ${summary.cfd.length} weekly points`); + const last = summary.cfd[summary.cfd.length - 1]; + if (last) { + console.log(` latest (${last.week}): ${JSON.stringify(last.counts)}`); + } + console.log(); +} + +main().catch((err) => { + console.error( + `\nFAILED: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(1); +}); diff --git a/platform/src/types/board-flow.ts b/platform/src/types/board-flow.ts index 033789c..8b4c30c 100644 --- a/platform/src/types/board-flow.ts +++ b/platform/src/types/board-flow.ts @@ -218,6 +218,7 @@ export type GateSeverity = "ok" | "warning" | "critical"; export type GateId = | "synthetic_items" + | "mass_import" | "done_not_closed" | "bulk_movement" | "field_completeness" diff --git a/platform/tests/board-flow.test.ts b/platform/tests/board-flow.test.ts index c5179c3..d8c7195 100644 --- a/platform/tests/board-flow.test.ts +++ b/platform/tests/board-flow.test.ts @@ -445,6 +445,81 @@ describe("summarizeBoard", () => { expect(inProgress?.reentered).toBe(0); }); + /** + * From a live board: a renamed column leaves the old spelling on historical + * events, so "Ready for Deploy" and "Ready for deploy" arrived as two rows + * with a misleadingly small `n` each. + */ + it("merges spellings of a renamed column into one phase", () => { + const items = [ + item({ id: "a", currentStatus: "Done" }), + item({ id: "b", currentStatus: "Done" }), + item({ id: "c", currentStatus: "Done" }), + ]; + const events: StatusEventInput[] = [ + // Two items moved through the old spelling, one through the new. + event({ itemId: "a", status: "Ready for deploy" }), + event({ + itemId: "a", + previousStatus: "Ready for deploy", + status: "Done", + occurredAt: "2026-03-02T09:00:00Z", + }), + event({ itemId: "b", status: "Ready for deploy" }), + event({ + itemId: "b", + previousStatus: "Ready for deploy", + status: "Done", + occurredAt: "2026-03-02T09:00:00Z", + }), + event({ itemId: "c", status: "Ready for Deploy" }), + event({ + itemId: "c", + previousStatus: "Ready for Deploy", + status: "Done", + occurredAt: "2026-03-03T09:00:00Z", + }), + ]; + + const summary = summarizeBoard(items, events, { + boardId: "board-1", + title: "Team Alpha", + now: NOW, + }); + + const queues = summary.phases.filter((p) => p.bucket === "queue"); + expect(queues).toHaveLength(1); + expect(queues[0].n).toBe(3); + // The most frequent spelling wins the label. + expect(queues[0].status).toBe("Ready for deploy"); + }); + + it("excludes the terminal column from time-per-phase", () => { + // An item sits in Done until archived, so its time there measures age + // since delivery. Left in, it dominates the ranking. + const items = [item({ id: "a", currentStatus: "Done" })]; + const events: StatusEventInput[] = [ + event({ itemId: "a", status: "In Progress" }), + event({ + itemId: "a", + previousStatus: "In Progress", + status: "Done", + occurredAt: "2026-03-02T09:00:00Z", + }), + ]; + + const summary = summarizeBoard(items, events, { + boardId: "board-1", + title: "Team Alpha", + now: NOW, + }); + + expect(summary.phases.map((p) => p.status)).toEqual(["In Progress"]); + // The raw per-item accumulation still records it; only the board-level + // phase ranking drops it. + expect(summary.leadTime.p50).toBe(24); + }); + it("tracks inflow against outflow and the cumulative backlog delta", () => { const items = [ item({ id: "a", currentStatus: "Done" }), diff --git a/platform/tests/board-quality.test.ts b/platform/tests/board-quality.test.ts index c3f25d2..ea8538f 100644 --- a/platform/tests/board-quality.test.ts +++ b/platform/tests/board-quality.test.ts @@ -52,19 +52,58 @@ describe("synthetic_items gate", () => { expect(g("synthetic_items").value).toBe(0); }); - it("flags test-looking titles", () => { + it("flags unambiguous placeholder titles on their own", () => { const g = gate([ item(), - item({ id: "b", title: "teste de fluxo" }), - item({ id: "c", title: "dummy card" }), + item({ id: "b", title: "dummy card" }), + item({ id: "c", title: "asdf" }), ]); const result = g("synthetic_items"); expect(result.affectedItemIds.sort()).toEqual(["b", "c"]); expect(result.severity).toBe("critical"); }); - it("flags a same-minute burst that also died within minutes", () => { - // Nine cards created together and closed 25 minutes later: board setup, + /** + * Regression guard from a live board: an earlier version matched `test` and + * `teste` on their own, flagged three real engineering items, and caught zero + * actual placeholder cards. On an engineering board, testing is the work. + */ + it("does not flag real work that merely mentions testing", () => { + const items = [ + item({ id: "a", title: "Teste não moderado nova UI mobile + cupons" }), + item({ id: "b", title: "Permitir teste de cenários de rebooking" }), + item({ id: "c", title: "Finalizar teste de pagamento combinado" }), + item({ id: "d", title: "Add integration tests to the booking service" }), + ]; + + const result = gate(items)("synthetic_items"); + expect(result.affectedItemIds).toEqual([]); + expect(result.severity).toBe("ok"); + }); + + it("flags an ambiguous title only when a short lifetime corroborates it", () => { + const longLived = item({ id: "long", title: "teste de carga" }); + const shortLived = item({ + id: "short", + title: "teste de carga", + sourceCreatedAt: "2026-03-01T10:00:00Z", + sourceClosedAt: "2026-03-01T10:02:00Z", + }); + + expect(gate([longLived])("synthetic_items").affectedItemIds).toEqual([]); + expect(gate([shortLived])("synthetic_items").affectedItemIds).toEqual([ + "short", + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Mass import — edge case 3 +// --------------------------------------------------------------------------- + +describe("mass_import gate", () => { + it("flags a same-minute burst that was also closed within minutes", () => { + // Nine cards created together and closed 25 minutes later: a board import, // not delivery. Left in, they collapse the lead-time median. const burst = Array.from({ length: 9 }, (_, i) => item({ @@ -77,14 +116,14 @@ describe("synthetic_items gate", () => { }), ); - const result = gate([...burst, item({ id: "real" })])("synthetic_items"); + const result = gate([...burst, item({ id: "real" })])("mass_import"); expect(result.affectedItemIds).toHaveLength(9); expect(result.severity).toBe("critical"); - expect(result.summary).toContain("scaffolding"); + expect(result.summary).toContain("import or backfill"); }); it("does not flag a planning session that creates many cards at once", () => { - // Same burst shape, but the cards live on — that is grooming, not setup. + // Same burst shape, but the cards live on — that is grooming, not import. const burst = Array.from( { length: SYNTHETIC_BURST_MIN_ITEMS + 4 }, (_, i) => @@ -96,7 +135,26 @@ describe("synthetic_items gate", () => { }), ); - expect(gate(burst)("synthetic_items").severity).toBe("ok"); + expect(gate(burst)("mass_import").severity).toBe("ok"); + }); + + it("keeps imported items separate from placeholder items", () => { + // The two findings call for different actions: you delete a placeholder, + // you exclude an import from duration analysis. + const burst = Array.from({ length: SYNTHETIC_BURST_MIN_ITEMS }, (_, i) => + item({ + id: `imported-${i}`, + title: `BUSV-${i}: real delivered work`, + sourceCreatedAt: "2026-03-01T10:00:00Z", + sourceClosedAt: "2026-03-01T10:05:00Z", + }), + ); + + const g = gate(burst); + expect(g("mass_import").affectedItemIds).toHaveLength( + SYNTHETIC_BURST_MIN_ITEMS, + ); + expect(g("synthetic_items").affectedItemIds).toEqual([]); }); }); @@ -310,7 +368,7 @@ describe("evaluateQuality", () => { expect(report.overall).toBe("critical"); expect(report.degraded).toBe(true); - expect(report.gates).toHaveLength(6); + expect(report.gates).toHaveLength(7); }); it("reports a clean board as not degraded", () => { From 62b746ec0bf2695badfaa721a6f8b78d06eb374a Mon Sep 17 00:00:00 2001 From: Renato Guimaraes Date: Mon, 24 Aug 2026 13:07:56 -0300 Subject: [PATCH 3/4] docs(platform): correct the quality-gate header to the real finding Co-authored-by: Claude Opus 5 (1M context) --- platform/lib/queries/board-quality.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/platform/lib/queries/board-quality.ts b/platform/lib/queries/board-quality.ts index 153ec50..4e6abbd 100644 --- a/platform/lib/queries/board-quality.ts +++ b/platform/lib/queries/board-quality.ts @@ -2,9 +2,9 @@ * Data-quality gates for board flow analysis. * * These run *before* any metric is trusted, and they are not a footnote. On a - * real board, board-setup noise — a batch of test cards created and closed - * minutes apart — dragged the median lead time down to a fraction of a day. - * The number was flattering and completely false. + * real 198-item board, 91 items had been created in same-minute batches and + * closed minutes later — a board import — which dragged the median lead time + * to zero. The number was flattering and completely false. * * Every gate returns a measured value, a severity, the items responsible, and * a plain statement of what it does to the reading. Metrics are still computed From cbaefcace5fc5d2720a0adf0e85037d71f25a3b9 Mon Sep 17 00:00:00 2001 From: Renato Guimaraes Date: Mon, 24 Aug 2026 15:04:28 -0300 Subject: [PATCH 4/4] feat(platform): surface board flow at /[tenant]/flow with a sidebar entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ingestion module had no way in. Adds the route, the read layer it needed, and the nav entry. Read layer (lib/queries/board-flow-data.ts) loads items and events and hands them to the existing pure functions, so every calculation stays unit-testable without a database. Reads paginate explicitly: PostgREST caps responses at the project's max-rows, and a board above that would come back silently truncated — the failure mode behind #121. The route degrades instead of erroring. A missing schema (migration 023 not applied) and an org with no board both render an unconfigured state, so the nav entry can ship before the migration lands rather than 500ing on a deployment that hasn't migrated. Section order is the spec's and it is deliberate: quality gates before any number, so the reader knows what the figures can carry before reading them. Then durations, time per column, WIP aging, throughput, CFD, stalled items, Little's Law. Two visualization decisions: - The CFD groups by lifecycle bucket, not by column. The live board has 17 columns; stacking that many bands is unreadable, while five buckets make accumulation obvious. Needed the resolved bucket per column, so summarizeBoard now returns `statusBuckets`. - Ran the product's categorical ramp through a palette validator. It passes colour-vision separation (worst adjacent pair ΔE 18.6, target 8) but four of five slots fall below 3:1 contrast against the page surface. That obligates relief, so every mark is paired with a visible label or rendered as a table — identity is never colour alone. Same reason the gates lead with an icon and the severity word, not a dot. Nav entry lives in tenantNavItems, which the sidebar and the mobile sheet share, so one entry covers both. Translations in en-US and pt-BR; es-ES falls back to en-US per the existing convention. Not verified: the page has not been rendered against real data. The schema is not applied anywhere yet and this machine has no Supabase credentials, so only the empty and unconfigured states are reachable locally. Build and types pass; visual confirmation is still owed. Co-authored-by: Claude Opus 5 (1M context) --- docs/integrations/github-projects.md | 16 + platform/CLAUDE.md | 1 + platform/lib/queries/board-flow-data.ts | 219 +++++ platform/lib/queries/board-flow.ts | 17 + platform/lib/translations.ts | 181 +++++ .../src/app/[tenant]/flow/flow-sections.tsx | 758 ++++++++++++++++++ platform/src/app/[tenant]/flow/flow-view.tsx | 111 +++ platform/src/app/[tenant]/flow/loading.tsx | 25 + platform/src/app/[tenant]/flow/page.tsx | 80 ++ .../src/components/tenant/TenantNavList.tsx | 89 +- platform/src/types/board-flow.ts | 8 + platform/tests/board-flow.test.ts | 41 + 12 files changed, 1505 insertions(+), 41 deletions(-) create mode 100644 platform/lib/queries/board-flow-data.ts create mode 100644 platform/src/app/[tenant]/flow/flow-sections.tsx create mode 100644 platform/src/app/[tenant]/flow/flow-view.tsx create mode 100644 platform/src/app/[tenant]/flow/loading.tsx create mode 100644 platform/src/app/[tenant]/flow/page.tsx diff --git a/docs/integrations/github-projects.md b/docs/integrations/github-projects.md index 6e2055c..b14813c 100644 --- a/docs/integrations/github-projects.md +++ b/docs/integrations/github-projects.md @@ -205,6 +205,22 @@ Computed by `platform/lib/queries/board-flow.ts` — pure functions, no I/O. | CFD | Item count per column at the end of each week | | Percentiles | P50 / P70 / P85 / P95, with a sample guard | +### Where it surfaces + +`/[tenant]/flow`, reachable from the sidebar as **Delivery Flow**. Section order +is deliberate — quality gates first, so the reader knows what the figures can +carry before reading them; then durations, time per column, WIP aging, +throughput, the CFD, stalled items, and the Little's Law check. + +Two presentation notes worth keeping if the page is reworked: + +- The CFD groups by **lifecycle bucket**, not by column. A real board carries a + dozen-plus columns and stacking that many bands is unreadable; five buckets + make accumulation obvious. +- The product's categorical ramp passes colour-vision separation but sits below + 3:1 against the page surface, so every mark is paired with a visible label or + rendered as a table. Identity is never carried by colour alone. + ### Honesty rules - **Fallback ladder for lead time.** Transitions (exact) → `closedAt` → diff --git a/platform/CLAUDE.md b/platform/CLAUDE.md index fb0aac7..d5f3126 100644 --- a/platform/CLAUDE.md +++ b/platform/CLAUDE.md @@ -34,6 +34,7 @@ Multi-tenant Next.js application: public marketing site + authenticated engineer - `/[tenant]/repos/[repoName]` — Repo detail with charts - `/[tenant]/compare` — Cross-repo comparison - `/[tenant]/ai-exposure` — AI shadow detection +- `/[tenant]/flow` — Delivery flow from a GitHub Projects board (lead time, time per column, throughput, WIP aging, CFD); quality gates render before any metric - `/[tenant]/audit-log` — Org audit log (feature flag `auditLog`, enabled by default) - `/[tenant]/team` — Team management - `/[tenant]/settings` — Organization settings diff --git a/platform/lib/queries/board-flow-data.ts b/platform/lib/queries/board-flow-data.ts new file mode 100644 index 0000000..a6ee3c5 --- /dev/null +++ b/platform/lib/queries/board-flow-data.ts @@ -0,0 +1,219 @@ +/** + * Read side for board flow analysis. + * + * Loads persisted board items and status events, then hands them to the pure + * functions in `board-flow.ts` / `board-quality.ts`. The split matters: every + * calculation stays unit-testable without a database, and this file only does + * I/O and shape translation. + * + * Reads paginate explicitly. PostgREST caps every response at the project's + * "Max rows" (default 1000), and a board with more items than that would come + * back silently truncated — the same failure mode that produced the "repos with + * metrics show 0 runs" bug (#121). + */ + +import type { SupabaseClient } from "@supabase/supabase-js"; + +import { classifyStatuses, summarizeBoard } from "./board-flow"; +import { evaluateQuality } from "./board-quality"; + +import type { + BoardFlowSummary, + BoardItemInput, + QualityReport, + StatusConfig, + StatusEventInput, +} from "@/types/board-flow"; + +const PAGE_SIZE = 1000; + +export interface BoardRow { + id: string; + title: string; + ownerLogin: string; + number: number; + teamSlug: string | null; + statusConfig: StatusConfig; + lastSyncedAt: string | null; +} + +export interface BoardFlowResult { + board: BoardRow; + summary: BoardFlowSummary; + quality: QualityReport; +} + +/** + * Postgres error codes meaning "the tables aren't there yet" — migration 023 + * not applied. Callers render an unconfigured state instead of an error page, + * so the nav entry never 500s on a deployment that hasn't migrated. + */ +const MISSING_TABLE_CODES = new Set(["42P01", "PGRST205"]); + +function isMissingTable(error: { code?: string } | null): boolean { + return !!error?.code && MISSING_TABLE_CODES.has(error.code); +} + +/** + * Boards configured for an org, newest sync first. Returns `null` when the + * schema is absent (integration never deployed), `[]` when it exists but the + * org has no boards. + */ +export async function getOrgBoards( + supabase: SupabaseClient, + organizationId: string, +): Promise { + const { data, error } = await supabase + .from("project_boards") + .select( + "id, title, owner_login, number, team_slug, status_config, last_synced_at", + ) + .eq("organization_id", organizationId) + .order("title"); + + if (error) { + if (isMissingTable(error)) return null; + throw new Error(`load boards: ${error.message}`); + } + + return (data ?? []).map((row) => ({ + id: row.id, + title: row.title, + ownerLogin: row.owner_login, + number: row.number, + teamSlug: row.team_slug, + statusConfig: (row.status_config ?? {}) as StatusConfig, + lastSyncedAt: row.last_synced_at, + })); +} + +/** Full flow analysis for one board. */ +export async function getBoardFlow( + supabase: SupabaseClient, + board: BoardRow, + now?: Date, +): Promise { + const items = await loadItems(supabase, board.id); + const events = await loadEvents( + supabase, + items.map((i) => i.id), + ); + + const classificationInput = new Set(); + for (const item of items) { + if (item.currentStatus) classificationInput.add(item.currentStatus); + } + for (const event of events) { + if (event.status) classificationInput.add(event.status); + if (event.previousStatus) classificationInput.add(event.previousStatus); + } + + const summary = summarizeBoard(items, events, { + boardId: board.id, + title: board.title, + teamSlug: board.teamSlug, + statusConfig: board.statusConfig, + now, + }); + + // The gates need the same column classification the summary used, so both + // sides agree on which column is terminal. + const classification = classifyStatuses( + board.statusConfig, + classificationInput, + ); + const quality = evaluateQuality(items, events, classification); + + return { board, summary, quality }; +} + +async function loadItems( + supabase: SupabaseClient, + boardId: string, +): Promise { + const out: BoardItemInput[] = []; + + for (let from = 0; ; from += PAGE_SIZE) { + const { data, error } = await supabase + .from("project_items") + .select( + "id, title, content_type, current_status, content_state, source_created_at, source_closed_at, item_updated_at, assignees, labels, iteration, priority, size, history_available", + ) + .eq("board_id", boardId) + .range(from, from + PAGE_SIZE - 1); + + if (error) throw new Error(`load board items: ${error.message}`); + + for (const row of data ?? []) { + out.push({ + id: row.id, + title: row.title, + contentType: row.content_type, + currentStatus: row.current_status, + contentState: row.content_state, + sourceCreatedAt: row.source_created_at, + sourceClosedAt: row.source_closed_at, + itemUpdatedAt: row.item_updated_at, + assignees: row.assignees ?? [], + labels: row.labels ?? [], + iteration: row.iteration, + priority: row.priority, + size: row.size, + historyAvailable: row.history_available, + }); + } + + if (!data || data.length < PAGE_SIZE) break; + } + + return out; +} + +/** + * Status events for the given items. + * + * Item ids are chunked into the `.in()` filter so the URL stays within limits + * on a large board, and each chunk paginates independently — one item can carry + * dozens of transitions, so row count is not bounded by item count. + */ +async function loadEvents( + supabase: SupabaseClient, + itemIds: string[], +): Promise { + if (itemIds.length === 0) return []; + + const ID_CHUNK = 200; + const out: StatusEventInput[] = []; + + for (let i = 0; i < itemIds.length; i += ID_CHUNK) { + const chunk = itemIds.slice(i, i + ID_CHUNK); + + for (let from = 0; ; from += PAGE_SIZE) { + const { data, error } = await supabase + .from("project_status_events") + .select( + "item_id, event_kind, previous_status, status, occurred_at, was_automated", + ) + .in("item_id", chunk) + .order("occurred_at") + .range(from, from + PAGE_SIZE - 1); + + if (error) throw new Error(`load status events: ${error.message}`); + + for (const row of data ?? []) { + out.push({ + itemId: row.item_id, + kind: row.event_kind, + previousStatus: row.previous_status, + status: row.status, + occurredAt: row.occurred_at, + wasAutomated: row.was_automated, + }); + } + + if (!data || data.length < PAGE_SIZE) break; + } + } + + return out; +} diff --git a/platform/lib/queries/board-flow.ts b/platform/lib/queries/board-flow.ts index 5c6ab40..660a62a 100644 --- a/platform/lib/queries/board-flow.ts +++ b/platform/lib/queries/board-flow.ts @@ -357,9 +357,26 @@ export function summarizeBoard( cfd: computeCfd(flows, now), littlesLaw: computeLittlesLaw(flows, wipFlows.length), unmappedStatuses: classification.unmapped, + statusBuckets: buildStatusBuckets(seen, classification), }; } +/** + * Bucket per column, keyed by the column's own spelling (not normalized) so a + * caller holding a raw status string can look it up directly. + */ +function buildStatusBuckets( + seen: Set, + classification: StatusClassification, +): Record { + const out: Record = {}; + for (const status of seen) { + const bucket = bucketOf(status, classification); + if (bucket) out[status] = bucket; + } + return out; +} + function computeCoverage( items: BoardItemInput[], flows: ItemFlow[], diff --git a/platform/lib/translations.ts b/platform/lib/translations.ts index 0dcb52b..cd714a1 100644 --- a/platform/lib/translations.ts +++ b/platform/lib/translations.ts @@ -15,6 +15,7 @@ export const translations = { repositories: "Repositories", compare: "Compare", aiExposure: "AI Exposure", + boardFlow: "Delivery Flow", team: "Team", auditLog: "Audit Log", profile: "Profile", @@ -767,6 +768,95 @@ export const translations = { cascadesSubtitle: "Fix cascade trigger rate — Human vs AI", }, }, + boardFlow: { + title: "Delivery Flow", + subtitle: + "How work moves across a GitHub Projects board — lead time, time per column, and where it waits. Read the data-quality gates first; they decide how much the numbers below can carry.", + notConfigured: + "No board connected. Configure a GitHub Projects integration to measure delivery flow beyond the code window.", + empty: + "This board has no items yet, or the first sync hasn't run. Flow metrics appear once the sync has read the board's history.", + boardLabel: "Board", + lastSynced: "Last synced", + neverSynced: "never", + quality: { + title: "Data quality", + subtitle: + "These run before the metrics. A failing gate does not hide the numbers — it tells you what they can and cannot support.", + degraded: + "At least one critical gate fired. Read every figure below with that caveat attached.", + clean: "All gates passed. The figures below rest on clean board data.", + affected: "{count} item(s) affected", + }, + leadTime: { + title: "Lead time", + subtitle: "Board entry to a terminal column", + cycleTitle: "Cycle time", + cycleSubtitle: "First active column to terminal", + flowEfficiency: "Flow efficiency", + flowEfficiencyHint: "Active time as a share of total lead time", + suppressed: "{list} withheld — sample too small", + sample: "n={count}", + approximate: "{count} approximated from a fallback", + }, + phases: { + title: "Time per column", + subtitle: + "Median hours per column, accumulating re-entries. The terminal column is excluded — time there measures age since delivery, not flow.", + reentered: "{count} re-entered", + empty: "No transition history yet.", + }, + aging: { + title: "Work in progress", + subtitle: "Age of items that haven't reached a terminal column", + count: "items", + median: "median", + max: "oldest", + empty: "Nothing in flight.", + }, + throughput: { + title: "Throughput and flow balance", + subtitle: + "Items reaching a terminal column per ISO week, against what arrived. A rising cumulative delta means the board is filling faster than it empties.", + inflow: "Arrived", + outflow: "Completed", + cumulative: "Cumulative delta", + empty: "Not enough weeks of history yet.", + }, + cfd: { + title: "Cumulative flow", + subtitle: + "Items per column at the end of each week, reconstructed from the transition history. Widening bands mark where work accumulates.", + empty: "Not enough history to draw a flow diagram.", + }, + stalled: { + title: "Stalled items", + subtitle: + "Longest without a column change. This is the most actionable list on the page.", + sinceMove: "Since last move", + totalAge: "Total age", + column: "Column", + item: "Item", + empty: "Nothing has been sitting still for a week or more.", + }, + littlesLaw: { + title: "Little's Law check", + subtitle: + "WIP divided by throughput, shown beside the observed lead time. A wide gap usually means phantom WIP or a mis-mapped terminal column — not a slower team.", + predicted: "Predicted", + observed: "Observed", + wip: "WIP", + throughput: "Throughput/week", + divergence: "Divergence", + }, + coverage: { + title: "Coverage", + withHistory: "{count} of {total} items carry transition history", + unmapped: + "Unrecognised columns: {list}. They are treated as non-terminal, which skews the lead time of anything ending there — map them in the board's status config.", + }, + units: { days: "d", hours: "h" }, + }, aiExposure: { title: "Shadow AI Exposure", subtitle: @@ -1664,6 +1754,7 @@ export const translations = { repositories: "Repositórios", compare: "Comparar", aiExposure: "Exposição a IA", + boardFlow: "Fluxo de Entrega", team: "Equipe", auditLog: "Log de Auditoria", profile: "Perfil", @@ -2188,6 +2279,96 @@ export const translations = { "Taxa de gatilho de cascata de correção — Humano vs IA", }, }, + boardFlow: { + title: "Fluxo de Entrega", + subtitle: + "Como o trabalho se move por um board do GitHub Projects — lead time, tempo por coluna e onde ele espera. Leia primeiro os portões de qualidade: eles definem quanto os números abaixo aguentam.", + notConfigured: + "Nenhum board conectado. Configure uma integração com o GitHub Projects para medir o fluxo de entrega além da janela de código.", + empty: + "Este board ainda não tem itens, ou a primeira sincronização não rodou. As métricas aparecem quando o sync tiver lido o histórico do board.", + boardLabel: "Board", + lastSynced: "Última sincronização", + neverSynced: "nunca", + quality: { + title: "Qualidade dos dados", + subtitle: + "Rodam antes das métricas. Um portão que falha não esconde os números — ele diz o que eles sustentam e o que não sustentam.", + degraded: + "Ao menos um portão crítico disparou. Leia cada número abaixo com essa ressalva.", + clean: + "Todos os portões passaram. Os números abaixo se apoiam em dados limpos.", + affected: "{count} item(ns) afetado(s)", + }, + leadTime: { + title: "Lead time", + subtitle: "Entrada no board até uma coluna terminal", + cycleTitle: "Cycle time", + cycleSubtitle: "Primeira coluna ativa até o terminal", + flowEfficiency: "Eficiência de fluxo", + flowEfficiencyHint: "Tempo ativo como fração do lead time total", + suppressed: "{list} suprimido(s) — amostra pequena", + sample: "n={count}", + approximate: "{count} aproximado(s) por fallback", + }, + phases: { + title: "Tempo por coluna", + subtitle: + "Mediana de horas por coluna, acumulando reentradas. A coluna terminal fica de fora — o tempo ali mede idade desde a entrega, não fluxo.", + reentered: "{count} reentraram", + empty: "Ainda sem histórico de transições.", + }, + aging: { + title: "Trabalho em andamento", + subtitle: "Idade dos itens que não chegaram a uma coluna terminal", + count: "itens", + median: "mediana", + max: "mais antigo", + empty: "Nada em andamento.", + }, + throughput: { + title: "Vazão e balanço de fluxo", + subtitle: + "Itens que chegaram a uma coluna terminal por semana ISO, contra o que entrou. Delta cumulativo subindo significa que o board enche mais rápido do que esvazia.", + inflow: "Entraram", + outflow: "Concluídos", + cumulative: "Delta cumulativo", + empty: "Ainda sem semanas suficientes de histórico.", + }, + cfd: { + title: "Fluxo cumulativo", + subtitle: + "Itens por coluna ao fim de cada semana, reconstruído do histórico de transições. Faixas que engordam marcam onde o trabalho acumula.", + empty: "Histórico insuficiente para desenhar o diagrama.", + }, + stalled: { + title: "Itens parados", + subtitle: + "Mais tempo sem trocar de coluna. Esta é a lista mais acionável da página.", + sinceMove: "Desde o último movimento", + totalAge: "Idade total", + column: "Coluna", + item: "Item", + empty: "Nada parado por uma semana ou mais.", + }, + littlesLaw: { + title: "Verificação da Lei de Little", + subtitle: + "WIP dividido pela vazão, ao lado do lead time observado. Uma diferença grande costuma indicar WIP fantasma ou coluna terminal mal mapeada — não um time mais lento.", + predicted: "Previsto", + observed: "Observado", + wip: "WIP", + throughput: "Vazão/semana", + divergence: "Divergência", + }, + coverage: { + title: "Cobertura", + withHistory: "{count} de {total} itens têm histórico de transições", + unmapped: + "Colunas não reconhecidas: {list}. São tratadas como não-terminais, o que distorce o lead time de tudo que termina nelas — mapeie-as na configuração de status do board.", + }, + units: { days: "d", hours: "h" }, + }, aiExposure: { title: "Exposição a Shadow AI", subtitle: diff --git a/platform/src/app/[tenant]/flow/flow-sections.tsx b/platform/src/app/[tenant]/flow/flow-sections.tsx new file mode 100644 index 0000000..344a4d3 --- /dev/null +++ b/platform/src/app/[tenant]/flow/flow-sections.tsx @@ -0,0 +1,758 @@ +"use client"; + +import { AlertTriangle, CheckCircle2, CircleAlert } from "lucide-react"; +import { + Area, + AreaChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useTranslation } from "@/hooks/useTranslation"; +import { cn } from "@/lib/utils"; +import type { + AgingColumn, + BoardFlowSummary, + CfdPoint, + FlowBalance, + GateSeverity, + LifecycleBucket, + PercentileSet, + PhaseStat, + QualityReport, + StalledItem, +} from "@/types/board-flow"; + +/** + * Fixed bucket order and colour, from the product's validated categorical + * ramp. Assigned by lifecycle stage and never cycled, so a bucket keeps its + * colour when another one is absent from a board. + * + * The ramp passes CVD separation but sits below 3:1 against the surface, so + * every mark here is paired with a visible label or a table — identity is never + * carried by colour alone. + */ +const BUCKET_ORDER: LifecycleBucket[] = [ + "backlog", + "discovery", + "queue", + "active", + "done", +]; + +const BUCKET_COLOR: Record = { + backlog: "var(--color-cat-6)", + discovery: "var(--color-cat-3)", + queue: "var(--color-cat-4)", + active: "var(--color-cat-1)", + done: "var(--color-cat-5)", +}; + +const BUCKET_BG: Record = { + backlog: "bg-[var(--color-cat-6)]", + discovery: "bg-[var(--color-cat-3)]", + queue: "bg-[var(--color-cat-4)]", + active: "bg-[var(--color-cat-1)]", + done: "bg-[var(--color-cat-5)]", +}; + +/** Hours → a compact human duration. Days once it passes two of them. */ +export function formatDuration(hours: number | null): string { + if (hours === null) return "—"; + if (hours < 48) + return `${hours < 10 ? hours.toFixed(1) : Math.round(hours)}h`; + return `${(hours / 24).toFixed(hours / 24 < 10 ? 1 : 0)}d`; +} + +// --------------------------------------------------------------------------- +// Quality gates — first on the page, by design +// --------------------------------------------------------------------------- + +const GATE_ICON: Record< + GateSeverity, + React.ComponentType<{ className?: string }> +> = { + ok: CheckCircle2, + warning: CircleAlert, + critical: AlertTriangle, +}; + +const GATE_TONE: Record = { + ok: "text-signal-green", + warning: "text-signal-yellow", + critical: "text-signal-red", +}; + +export function QualityGates({ report }: { report: QualityReport }) { + const { t } = useTranslation(); + + return ( + + + {t("boardFlow.quality.title")} +

+ {t("boardFlow.quality.subtitle")} +

+
+ +

+ {report.degraded + ? t("boardFlow.quality.degraded") + : t("boardFlow.quality.clean")} +

+ +
    + {report.gates.map((gate) => { + const Icon = GATE_ICON[gate.severity]; + return ( +
  • + {/* Icon + text carry the state; colour only reinforces it. */} + +
    +

    + {gate.severity} + {" · "} + {gate.id} + {" · "} + {gate.value} + {gate.unit === "percent" ? "%" : ""} + {gate.affectedItemIds.length > 0 && ( + + {t("boardFlow.quality.affected", { + count: gate.affectedItemIds.length, + })} + + )} +

    +

    + {gate.summary} +

    +
    +
  • + ); + })} +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Lead time / cycle time / flow efficiency +// --------------------------------------------------------------------------- + +function PercentileBlock({ + title, + subtitle, + set, +}: { + title: string; + subtitle: string; + set: PercentileSet; +}) { + const { t } = useTranslation(); + + return ( +
+
+

{title}

+

{subtitle}

+
+
+ {(["p50", "p70", "p85", "p95"] as const).map((key) => ( +
+

{key}

+

+ {formatDuration(set[key])} +

+
+ ))} +
+

+ {t("boardFlow.leadTime.sample", { count: set.n })} + {set.suppressed.length > 0 && ( + <> + {" · "} + {t("boardFlow.leadTime.suppressed", { + list: set.suppressed.join(", "), + })} + + )} +

+
+ ); +} + +export function DurationSummary({ summary }: { summary: BoardFlowSummary }) { + const { t } = useTranslation(); + const efficiency = summary.flowEfficiencyMedian; + + return ( + + + {t("boardFlow.leadTime.title")} + + + + +
+
+

+ {t("boardFlow.leadTime.flowEfficiency")} +

+

+ {t("boardFlow.leadTime.flowEfficiencyHint")} +

+
+

+ {efficiency === null ? "—" : `${(efficiency * 100).toFixed(0)}%`} +

+ {summary.coverage.itemsApproximated > 0 && ( +

+ {t("boardFlow.leadTime.approximate", { + count: summary.coverage.itemsApproximated, + })} +

+ )} +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Time per column +// --------------------------------------------------------------------------- + +export function PhaseBars({ phases }: { phases: PhaseStat[] }) { + const { t } = useTranslation(); + + if (phases.length === 0) { + return ( + + + {t("boardFlow.phases.title")} + + +

+ {t("boardFlow.phases.empty")} +

+
+
+ ); + } + + const max = Math.max(...phases.map((p) => p.medianHours ?? 0), 1); + + return ( + + + {t("boardFlow.phases.title")} +

+ {t("boardFlow.phases.subtitle")} +

+
+ + {phases.map((phase) => { + const width = ((phase.medianHours ?? 0) / max) * 100; + return ( +
+
+ {phase.status} + + {formatDuration(phase.medianHours)} + + {t("boardFlow.leadTime.sample", { count: phase.n })} + {phase.reentered > 0 && + ` · ${t("boardFlow.phases.reentered", { count: phase.reentered })}`} + + +
+ {/* 4px rounded data-end, anchored to the baseline. */} +
+
+
+
+ ); + })} + p.bucket)} /> + + + ); +} + +function BucketLegend({ buckets }: { buckets: Array }) { + const present = BUCKET_ORDER.filter((b) => buckets.includes(b)); + if (present.length < 2) return null; + + return ( +
+ {present.map((bucket) => ( + + + {bucket} + + ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// WIP aging +// --------------------------------------------------------------------------- + +export function AgingTable({ aging }: { aging: AgingColumn[] }) { + const { t } = useTranslation(); + + return ( + + + {t("boardFlow.aging.title")} +

+ {t("boardFlow.aging.subtitle")} +

+
+ + {aging.length === 0 ? ( +

+ {t("boardFlow.aging.empty")} +

+ ) : ( +
+ + + + + + + + + + + {aging.map((col) => ( + + + + + + + ))} + +
+ {t("boardFlow.stalled.column")} + + {t("boardFlow.aging.count")} + + {t("boardFlow.aging.median")} + + {t("boardFlow.aging.max")} +
{col.status} + {col.count} + + {formatDuration(col.medianAgeHours)} + + {formatDuration(col.maxAgeHours)} +
+
+ )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Throughput and flow balance +// +// Inflow and outflow share a unit, so they share one scale. The cumulative +// delta is a different measure entirely and gets its own column rather than a +// second y-axis. +// --------------------------------------------------------------------------- + +export function ThroughputTable({ balance }: { balance: FlowBalance[] }) { + const { t } = useTranslation(); + const recent = balance.slice(-12); + const max = Math.max(...recent.flatMap((w) => [w.inflow, w.outflow]), 1); + + return ( + + + {t("boardFlow.throughput.title")} +

+ {t("boardFlow.throughput.subtitle")} +

+
+ + {recent.length === 0 ? ( +

+ {t("boardFlow.throughput.empty")} +

+ ) : ( +
+ + + + + + + + + + + {recent.map((week) => ( + + + + + + + ))} + +
ISO + {t("boardFlow.throughput.inflow")} + + {t("boardFlow.throughput.outflow")} + + {t("boardFlow.throughput.cumulative")} +
{week.week} + + + + + {week.cumulativeDelta > 0 ? "+" : ""} + {week.cumulativeDelta} +
+
+ )} +
+
+ ); +} + +/** Bar with the number beside it — the value is never colour-only. */ +function MiniBar({ + value, + max, + className, +}: { + value: number; + max: number; + className: string; +}) { + return ( + + + + + {value} + + ); +} + +// --------------------------------------------------------------------------- +// Cumulative flow diagram +// --------------------------------------------------------------------------- + +interface CfdRow { + week: string; + backlog: number; + discovery: number; + queue: number; + active: number; + done: number; +} + +/** + * Stacked area by lifecycle bucket rather than by column. + * + * A real board carries a dozen-plus columns; stacking that many bands is + * unreadable, while five buckets make accumulation obvious at a glance. + */ +export function CfdChart({ + cfd, + statusBuckets, +}: { + cfd: CfdPoint[]; + statusBuckets: Record; +}) { + const { t } = useTranslation(); + + if (cfd.length < 2) { + return ( + + + {t("boardFlow.cfd.title")} + + +

+ {t("boardFlow.cfd.empty")} +

+
+
+ ); + } + + const rows: CfdRow[] = cfd.map((point) => { + const row: CfdRow = { + week: point.week, + backlog: 0, + discovery: 0, + queue: 0, + active: 0, + done: 0, + }; + for (const [status, count] of Object.entries(point.counts)) { + const bucket = statusBuckets[status]; + if (bucket) row[bucket] += count; + } + return row; + }); + + return ( + + + {t("boardFlow.cfd.title")} +

+ {t("boardFlow.cfd.subtitle")} +

+
+ + + + + + + + + {BUCKET_ORDER.map((bucket) => ( + + ))} + + + +
+ ); +} + +// --------------------------------------------------------------------------- +// Stalled items — the most actionable output on the page +// --------------------------------------------------------------------------- + +export function StalledTable({ stalled }: { stalled: StalledItem[] }) { + const { t } = useTranslation(); + + return ( + + + {t("boardFlow.stalled.title")} +

+ {t("boardFlow.stalled.subtitle")} +

+
+ + {stalled.length === 0 ? ( +

+ {t("boardFlow.stalled.empty")} +

+ ) : ( +
+ + + + + + + + + + + {stalled.slice(0, 25).map((item) => ( + + + + + + + ))} + +
+ {t("boardFlow.stalled.sinceMove")} + + {t("boardFlow.stalled.column")} + + {t("boardFlow.stalled.totalAge")} + + {t("boardFlow.stalled.item")} +
+ {formatDuration(item.hoursSinceLastMove)} + + {item.currentStatus ?? "—"} + + {formatDuration(item.totalAgeHours)} + {item.title}
+
+ )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Little's Law + coverage +// --------------------------------------------------------------------------- + +export function LittlesLawCard({ summary }: { summary: BoardFlowSummary }) { + const { t } = useTranslation(); + const ll = summary.littlesLaw; + + return ( + + + {t("boardFlow.littlesLaw.title")} +

+ {t("boardFlow.littlesLaw.subtitle")} +

+
+ + {[ + { label: t("boardFlow.littlesLaw.wip"), value: String(ll.wip) }, + { + label: t("boardFlow.littlesLaw.throughput"), + value: + ll.throughputPerWeek === null + ? "—" + : String(ll.throughputPerWeek), + }, + { + label: t("boardFlow.littlesLaw.predicted"), + value: formatDuration(ll.predictedLeadTimeHours), + }, + { + label: t("boardFlow.littlesLaw.observed"), + value: formatDuration(ll.observedLeadTimeHours), + }, + { + label: t("boardFlow.littlesLaw.divergence"), + value: ll.divergenceRatio === null ? "—" : `${ll.divergenceRatio}x`, + }, + ].map((cell) => ( +
+

+ {cell.label} +

+

{cell.value}

+
+ ))} +
+
+ ); +} + +export function CoverageNote({ summary }: { summary: BoardFlowSummary }) { + const { t } = useTranslation(); + + return ( +
+

+ {t("boardFlow.coverage.withHistory", { + count: summary.coverage.itemsWithHistory, + total: summary.coverage.totalItems, + })} +

+ {summary.unmappedStatuses.length > 0 && ( +

+ {t("boardFlow.coverage.unmapped", { + list: summary.unmappedStatuses.join(", "), + })} +

+ )} +
+ ); +} diff --git a/platform/src/app/[tenant]/flow/flow-view.tsx b/platform/src/app/[tenant]/flow/flow-view.tsx new file mode 100644 index 0000000..7016409 --- /dev/null +++ b/platform/src/app/[tenant]/flow/flow-view.tsx @@ -0,0 +1,111 @@ +"use client"; + +import Link from "next/link"; + +import { + AgingTable, + CfdChart, + CoverageNote, + DurationSummary, + LittlesLawCard, + PhaseBars, + QualityGates, + StalledTable, + ThroughputTable, +} from "./flow-sections"; + +import { useTranslation } from "@/hooks/useTranslation"; +import { cn } from "@/lib/utils"; +import type { BoardFlowSummary, QualityReport } from "@/types/board-flow"; + +interface FlowViewProps { + orgSlug: string; + boards: Array<{ id: string; title: string }>; + selectedBoardId: string; + lastSyncedAt: string | null; + summary: BoardFlowSummary; + quality: QualityReport; +} + +/** + * Section order is deliberate: quality gates come before any number, so the + * reader knows what the figures can carry before reading them. + */ +export function FlowView({ + orgSlug, + boards, + selectedBoardId, + lastSyncedAt, + summary, + quality, +}: FlowViewProps) { + const { t } = useTranslation(); + const isEmpty = summary.coverage.totalItems === 0; + + return ( +
+
+
+

{t("boardFlow.title")}

+

+ {t("boardFlow.subtitle")} +

+
+

+ {t("boardFlow.lastSynced")}:{" "} + + {lastSyncedAt + ? new Date(lastSyncedAt) + .toISOString() + .slice(0, 16) + .replace("T", " ") + : t("boardFlow.neverSynced")} + +

+
+ + {boards.length > 1 && ( +
+ {boards.map((board) => { + const isActive = board.id === selectedBoardId; + return ( + + {board.title} + + ); + })} +
+ )} + + {isEmpty ? ( +
+ {t("boardFlow.empty")} +
+ ) : ( + <> + + + + + + + + + + + )} +
+ ); +} diff --git a/platform/src/app/[tenant]/flow/loading.tsx b/platform/src/app/[tenant]/flow/loading.tsx new file mode 100644 index 0000000..16e226f --- /dev/null +++ b/platform/src/app/[tenant]/flow/loading.tsx @@ -0,0 +1,25 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function BoardFlowLoading() { + return ( +
+
+ + +
+ + {/* Quality gates come first on the real page, so they lead here too. */} + + + + +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ + +
+ ); +} diff --git a/platform/src/app/[tenant]/flow/page.tsx b/platform/src/app/[tenant]/flow/page.tsx new file mode 100644 index 0000000..d5a659f --- /dev/null +++ b/platform/src/app/[tenant]/flow/page.tsx @@ -0,0 +1,80 @@ +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; + +import { getServerSession } from "next-auth/next"; + +import { FlowView } from "./flow-view"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { authOptions } from "@/lib/auth"; +import { getBoardFlow, getOrgBoards } from "@/lib/queries/board-flow-data"; +import { getServerTranslation } from "@/lib/server-translation"; +import { supabaseAdmin } from "@/lib/supabase"; + +export default async function BoardFlowPage({ + params, + searchParams, +}: { + params: Promise<{ tenant: string }>; + searchParams: Promise<{ board?: string }>; +}) { + const session = await getServerSession(authOptions); + if (!session?.user) redirect("/auth/signin"); + + const { tenant } = await params; + const { board: boardParam } = await searchParams; + const { t } = await getServerTranslation(); + + const { data: org } = await supabaseAdmin + .from("organizations") + .select("id, name") + .eq("slug", tenant) + .single(); + + if (!org) notFound(); + + const boards = await getOrgBoards(supabaseAdmin, org.id); + + // `null` means the schema isn't deployed yet (migration 023 not applied); + // `[]` means no board is configured. Both are "nothing to show", not errors — + // the nav entry must never 500 on a deployment that hasn't migrated. + if (boards === null || boards.length === 0) { + return ( +
+
+

{t("boardFlow.title")}

+

+ {t("boardFlow.subtitle")} +

+
+ + +

+ {t("boardFlow.notConfigured")} +

+ +
+
+
+ ); + } + + const selected = boards.find((b) => b.id === boardParam) ?? boards[0]; + const { summary, quality } = await getBoardFlow(supabaseAdmin, selected); + + return ( + ({ id: b.id, title: b.title }))} + selectedBoardId={selected.id} + lastSyncedAt={selected.lastSyncedAt} + summary={summary} + quality={quality} + /> + ); +} diff --git a/platform/src/components/tenant/TenantNavList.tsx b/platform/src/components/tenant/TenantNavList.tsx index 575c5b7..aac328a 100644 --- a/platform/src/components/tenant/TenantNavList.tsx +++ b/platform/src/components/tenant/TenantNavList.tsx @@ -1,25 +1,26 @@ -'use client'; +"use client"; -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; +import Link from "next/link"; +import { usePathname } from "next/navigation"; import { LayoutDashboard, GitBranch, ArrowLeftRight, Eye, + KanbanSquare, Users, User, ScrollText, Settings, -} from 'lucide-react'; +} from "lucide-react"; -import { useTenant } from './TenantProvider'; +import { useTenant } from "./TenantProvider"; -import { useTranslation } from '@/hooks/useTranslation'; -import type { FeatureKey } from '@/lib/features'; -import { useFeatureFlags } from '@/lib/features/client'; -import { cn } from '@/lib/utils'; +import { useTranslation } from "@/hooks/useTranslation"; +import type { FeatureKey } from "@/lib/features"; +import { useFeatureFlags } from "@/lib/features/client"; +import { cn } from "@/lib/utils"; export interface TenantNavItem { translationKey: string; @@ -31,53 +32,59 @@ export interface TenantNavItem { export const tenantNavItems: TenantNavItem[] = [ { - translationKey: 'navigation.dashboard', - href: '/dashboard', + translationKey: "navigation.dashboard", + href: "/dashboard", icon: LayoutDashboard, - roles: ['owner', 'admin', 'member'], + roles: ["owner", "admin", "member"], }, { - translationKey: 'navigation.repositories', - href: '/repos', + translationKey: "navigation.repositories", + href: "/repos", icon: GitBranch, - roles: ['owner', 'admin', 'member'], + roles: ["owner", "admin", "member"], }, { - translationKey: 'navigation.compare', - href: '/compare', + translationKey: "navigation.compare", + href: "/compare", icon: ArrowLeftRight, - roles: ['owner', 'admin', 'member'], + roles: ["owner", "admin", "member"], }, { - translationKey: 'navigation.aiExposure', - href: '/ai-exposure', + translationKey: "navigation.aiExposure", + href: "/ai-exposure", icon: Eye, - roles: ['owner', 'admin', 'member'], + roles: ["owner", "admin", "member"], }, { - translationKey: 'navigation.team', - href: '/team', + translationKey: "navigation.boardFlow", + href: "/flow", + icon: KanbanSquare, + roles: ["owner", "admin", "member"], + }, + { + translationKey: "navigation.team", + href: "/team", icon: Users, - roles: ['owner', 'admin'], + roles: ["owner", "admin"], }, { - translationKey: 'navigation.settings', - href: '/settings', + translationKey: "navigation.settings", + href: "/settings", icon: Settings, - roles: ['owner', 'admin'], + roles: ["owner", "admin"], }, { - translationKey: 'navigation.auditLog', - href: '/audit-log', + translationKey: "navigation.auditLog", + href: "/audit-log", icon: ScrollText, - roles: ['owner', 'admin'], - featureKey: 'auditLog', + roles: ["owner", "admin"], + featureKey: "auditLog", }, { - translationKey: 'navigation.profile', - href: '/profile', + translationKey: "navigation.profile", + href: "/profile", icon: User, - roles: ['owner', 'admin', 'member'], + roles: ["owner", "admin", "member"], }, ]; @@ -103,7 +110,7 @@ export function TenantNavList({ onItemClick, className }: TenantNavListProps) { }); return ( -