feat(platform): board flow analysis from GitHub Projects V2 - #170
feat(platform): board flow analysis from GitHub Projects V2#170renatoguimaraescb wants to merge 4 commits into
Conversation
Adds an ingestion + metrics module for GitHub Projects boards: lead time, time per column, throughput, WIP aging, CFD and bottleneck signals. The engine deliberately measures PR-open-to-merge and says nothing about the queue in front of it; board data is the missing denominator. If AI shortens coding but total lead time does not move, the constraint is outside the code — that is the question this makes answerable. No snapshot collector, and that is the design decision worth reviewing. Projects V2 exposes ProjectV2ItemStatusChangedEvent on the content's timeline, carrying createdAt, previousStatus, status and wasAutomated. Transition history is therefore readable retroactively at second precision on the first sync — no accumulation period, no +/-24h detection window, no blind spot for two moves inside one interval. Verified against a live board before writing any code. Structure follows the Datadog integration: a client, a syncOrganization that never throws, idempotent upserts keyed by the provider's own node ids, and a slot in the existing 04:00 UTC cron. Metrics live in lib/queries as pure functions, unit-tested like cycle-time-flow.ts. Quality gates run before any metric is trusted. Un-gated board data produced a median lead time of a fraction of a day on a real board — flattering and false, caused by setup cards created and closed minutes apart. Six gates report severity, the measured value, affected items and the impact on the reading; metrics still compute, but never without the caveat. Nothing encodes a particular workflow. Column names are free text, mapped to lifecycle buckets by per-board config first and generic EN/PT name heuristics second; unmatched columns are reported, never silently treated as not-done. Fixtures are fictional. Honesty rules that shaped the code: - lead time falls back transitions -> closedAt -> updatedAt, and anything past the first rung is labelled approximate; updatedAt is never used to invent a lead time for work still in flight - P95 withheld below 20 observations, everything but the median below 10 - re-entering a column accumulates both visits instead of overwriting - removal from the board is an exit, never a completion - drafts have no timeline on the API, so they are excluded from duration metrics rather than counted as zero, with coverage reported - assignee concentration describes the board, never a person: no login is returned, only the share (Principle #2) Little's Law is returned beside observed lead time, not instead of it — a large divergence points at phantom WIP or a mis-mapped terminal column. No UI in this change: the collector and the gates are the foundation, and metrics over unvalidated data are worth nothing. Dashboard follows once the numbers are validated against a real board. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…y-run
Adds scripts/board-flow-dryrun.ts — reads a real board through the real
client and runs the real gates and metrics without touching a database.
Ran it against a 198-item board with 754 status events (~14 GraphQL
requests, 17s), which surfaced three defects the unit tests could not.
1. Testing is real work. `synthetic_items` matched test/teste on their
own and flagged three genuine items ("Permitir teste de cenários de
rebooking", "Teste não moderado nova UI mobile") while catching zero
placeholders — a 100% false-positive rate on that path. Unambiguous
markers (dummy, asdf, lorem) still fire alone; ambiguous words now
need a short lifetime to corroborate.
2. Import is not scaffolding. 91 of the 198 items were created in
same-minute batches and closed minutes later — all real work,
imported when the board was set up. Same distortion as a test card
(near-zero lead time, throughput spike in one artificial week) but a
different remedy: you delete a placeholder, you exclude an import
from duration analysis. Split into its own `mass_import` gate so the
finding names what actually happened.
3. Renamed columns were counted twice. Per-phase stats keyed on the raw
name, so "Ready for Deploy" and "Ready for deploy" — one column,
renamed — split into two rows with a misleadingly small n each
(11 and 24 instead of 35). Now keyed on the normalized name, labelled
with the most frequent spelling. Genuinely different names for the
same stage stay separate; merging those needs explicit statusConfig,
since guessing would be wrong elsewhere.
Also excludes the terminal column from time-per-phase: an item sits in
Done until archived, so its time there measured age since delivery and
dominated the ranking at a 39-day median.
Regression tests added for each, including the three real titles that
were wrongly flagged.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ntry The ingestion module had no way in. Adds the route, the read layer it needed, and the nav entry. Read layer (lib/queries/board-flow-data.ts) loads items and events and hands them to the existing pure functions, so every calculation stays unit-testable without a database. Reads paginate explicitly: PostgREST caps responses at the project's max-rows, and a board above that would come back silently truncated — the failure mode behind #121. The route degrades instead of erroring. A missing schema (migration 023 not applied) and an org with no board both render an unconfigured state, so the nav entry can ship before the migration lands rather than 500ing on a deployment that hasn't migrated. Section order is the spec's and it is deliberate: quality gates before any number, so the reader knows what the figures can carry before reading them. Then durations, time per column, WIP aging, throughput, CFD, stalled items, Little's Law. Two visualization decisions: - The CFD groups by lifecycle bucket, not by column. The live board has 17 columns; stacking that many bands is unreadable, while five buckets make accumulation obvious. Needed the resolved bucket per column, so summarizeBoard now returns `statusBuckets`. - Ran the product's categorical ramp through a palette validator. It passes colour-vision separation (worst adjacent pair ΔE 18.6, target 8) but four of five slots fall below 3:1 contrast against the page surface. That obligates relief, so every mark is paired with a visible label or rendered as a table — identity is never colour alone. Same reason the gates lead with an icon and the severity word, not a dot. Nav entry lives in tenantNavItems, which the sidebar and the mobile sheet share, so one entry covers both. Translations in en-US and pt-BR; es-ES falls back to en-US per the existing convention. Not verified: the page has not been rendered against real data. The schema is not applied anywhere yet and this machine has no Supabase credentials, so only the empty and unconfigured states are reachable locally. Build and types pass; visual confirmation is still owed. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
lucastribioliclickbus
left a comment
There was a problem hiding this comment.
Li a PR inteira. A decisão de arquitetura (timeline events em vez de snapshot + diff) está certa e bem defendida, os gates de qualidade são o ponto mais forte, e a doc em docs/integrations/github-projects.md é excelente.
Deixei 3 comentários inline, todos no caminho de escrita — que é exatamente o que os dois checkboxes abertos dizem que ainda não rodou contra Postgres. Os três corrompem números que a tela mostra como confiáveis:
sync.ts:427—history_availablemarcado por intenção de buscar, não por resultado (e diverge do dry-run que validou o board real).board-flow.ts:245—flowEfficiencyconta zero para item sem transições, contra a regra 1 do próprio arquivo.client.ts:276— conteúdo inacessível viraDRAFT_ISSUE, e o gate reporta a causa errada ao usuário.
Tenho mais uma dúzia de achados de severidade média/baixa (paginação por OFFSET sem ordenação determinística, ausência de AbortSignal.timeout que o client Datadog tem, client.ts sem testes, entre outros) — te passo por fora para não poluir a thread, e você decide o que entra nesta PR e o que vira issue.
|
Vou deixar uns achados aqui e vc vê se faz sentido.
|
Tipo
Feature — novo módulo de ingestão + métricas na plataforma.
Summary
Adiciona análise de fluxo de entrega a partir de GitHub Projects V2: lead time, tempo por coluna, throughput, WIP aging, CFD e sinais de gargalo — com portões de qualidade de dados na frente de tudo.
O motivo: o engine mede deliberadamente a janela PR-open → merge (
flow_efficiency.py) e não diz nada sobre a fila que vem antes. Se a IA encurta a fase de código mas o lead time total não se move, a restrição está fora do código. Esse é o denominador que faltava.Sem UI nesta PR. Coletor + portões são a fundação; métrica sobre dado não validado não vale nada. Dashboard vem depois de validar os números contra um board real.
A decisão de arquitetura que merece review
O desenho óbvio para "quanto tempo cada card ficou em cada coluna" é snapshot periódico + job de diff. Para Projects V2 isso é desnecessário.
A API expõe
ProjectV2ItemStatusChangedEventno timeline do conteúdo, comcreatedAt,previousStatus,statusewasAutomated. Validei contra um board real antes de escrever qualquer código:Consequências: histórico retroativo no primeiro sync (sem período de acumulação), precisão de segundo (sem janela de ±24h), CFD reconstruível para semanas passadas, e
wasAutomatedcomo sinal de movimentação automatizada melhor que heurística de timestamp.Único buraco:
DraftIssuenão éIssuee não temtimelineItems. Drafts ficam comhistory_available = falsee são excluídos de métricas de duração — nunca contados como zero.Como encaixa no que já existe
Espelha a integração Datadog:
client.ts+syncOrganization()que nunca lança + upsert idempotente pelos node ids do próprio provider + slot no cron diário que já roda às 04:00 UTC. Métricas emlib/queries/como funções puras, testadas comocycle-time-flow.ts.org_integrationsjá era genérica porprovider— só precisou do novo valor no enum.Portões de qualidade
Não são seção secundária. Em board real, dado sem portão produziu mediana de lead time de fração de dia — lisonjeiro e falso, causado por cards de setup criados e fechados minutos depois.
synthetic_itemsmass_importdone_not_closedclosedAtcomo fallbackbulk_movementwasAutomateddo GitHubfield_completenessassignee_concentrationhistory_coverageAgnóstico de organização
Nada codifica um workflow específico. Nomes de coluna são texto livre, mapeados para buckets de ciclo de vida por config explícita por board e, na ausência dela, por heurísticas genéricas de nome (EN/PT). Coluna sem match é reportada, nunca tratada silenciosamente como não-terminal. Fixtures dos testes são fictícias.
Regras de honestidade implementadas
transitions → closedAt → updatedAt; tudo além do primeiro degrau viraapproximate.updatedAtnunca inventa lead time para trabalho em andamento.assignee_concentrationdescreve o board, nunca uma pessoa: não retorna login, só o percentual (Princípio build(deps): Bump actions/checkout from 4 to 6 #2).Boas Práticas a Seguir
read:project), cifrado em repouso no padrão da migration 014syncOrganizationnão lança; gravalast_error)updatedAtmudouChecklist
board-flow,board-quality,github-projects-sync)npx tsc --noEmitlimponpm run lintsem erros e sem warnings nos arquivos novosnpx vitest run— 324 passed (27 arquivos)npm run buildverdescripts/board-flow-dryrun.ts— 198 itens, 754 eventos, ~14 requests, 17sValidação contra board real
scripts/board-flow-dryrun.tslê um board real pelo client real e roda gates e métricas reais, sem tocar banco. Rodado num board de 198 itens / 754 eventos (~14 requests GraphQL, 17s) — e achou três defeitos que os testes unitários não pegariam:test/testeisolados eram falso positivo. Flagravam 3 itens legítimos ("Permitir teste de cenários de rebooking") e zero placeholders — 100% de erro naquele caminho. Num board de engenharia, testar é o trabalho. Marcadores ambíguos agora exigem vida curta corroborando.Também: a coluna terminal saiu do tempo-por-fase —
Doneliderava com mediana de 39 dias, que é idade desde a entrega, não fluxo.O que ainda não foi validado: o caminho de escrita (migration + sync + upsert). Exige Docker/Supabase local, indisponível na máquina onde isto rodou.
Autoria Assistida
Status
🚧 Work in Progress — aberto como Draft.