diff --git a/docs/integrations/github-projects.md b/docs/integrations/github-projects.md new file mode 100644 index 0000000..b14813c --- /dev/null +++ b/docs/integrations/github-projects.md @@ -0,0 +1,276 @@ +# 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` | 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. + +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 | + +### 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` → + `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. +- **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. + +--- + +## 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/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/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-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 new file mode 100644 index 0000000..660a62a --- /dev/null +++ b/platform/lib/queries/board-flow.ts @@ -0,0 +1,746 @@ +/** + * 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, + 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[], +): 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), + }; +} + +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(); + + for (const flow of flows) { + for (const [status, hours] of Object.entries(flow.hoursByStatus)) { + // 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(key, entry); + } + } + + return [...perStatus.entries()] + .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), + reentered: entry.reentered, + })) + .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), + ); + 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..4e6abbd --- /dev/null +++ b/platform/lib/queries/board-quality.ts @@ -0,0 +1,439 @@ +/** + * Data-quality gates for board flow analysis. + * + * These run *before* any metric is trusted, and they are not a footnote. On a + * 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 + * 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 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 WEAK_SYNTHETIC_RE = + /\b(test|teste|testing|testando|sample|example|exemplo)\b/i; + +/** A burst this size created within one minute is treated as one event. */ +export const SYNTHETIC_BURST_MIN_ITEMS = 5; +/** 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; +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), + massImportGate(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: 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 (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; + 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]); + } + + const flagged = new Set(); + for (const burst of byMinute.values()) { + if (burst.length < SYNTHETIC_BURST_MIN_ITEMS) continue; + 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); + } + } + + const pct = percentOf(flagged.size, items.length); + return { + 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 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 + * 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/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/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/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/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/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 ( -