diff --git a/docs/changelog/2026-09-15-ops109.md b/docs/changelog/2026-09-15-ops109.md new file mode 100644 index 00000000..15ac705c --- /dev/null +++ b/docs/changelog/2026-09-15-ops109.md @@ -0,0 +1,5 @@ +feat(OPS109): painel de issues e planos no terminal (via SSH) + +TUI read-only (`pnpm issues:tui`) que lista todas as issues rastreáveis com estado, prioridade, kind e ação pendente; filtra por estado (em andamento / aguardando / concluídas) sem sair do painel; abre o detalhe de uma issue com o plano de intenção e o de implementação — cada um com status legível (`aprovado` vs `aguardando aprovação`) e link; renderiza o markdown do plano no terminal (títulos, listas, tabelas, código, negrito, citação) com rolagem; abre o rascunho UI no browser da máquina com fallback para impressão do caminho em SSH sem display; e anexa à sessão do agente via `agent:session attach` (OPS110) — sem nunca iniciar um run novo. Modo não-interativo (`pnpm issues:tui --json`) expõe o view model versionado para teste/script. Sem migration, sem deps novas, sem alterações em collections. `knip.json` limpo — zero exports mortos. + +Related #1020 diff --git a/docs/plans/ops109-painel-issues-no-terminal-impl.md b/docs/plans/ops109-painel-issues-no-terminal-impl.md new file mode 100644 index 00000000..6672f247 --- /dev/null +++ b/docs/plans/ops109-painel-issues-no-terminal-impl.md @@ -0,0 +1,176 @@ +# Impl: OPS109 — Painel de issues e planos no terminal (via SSH) + +Status: aprovado (gate humano 2026-09-15) +Atualizado em: 2026-09-15 +Issue: #1020 +Intenção: docs/plans/ops109-painel-issues-no-terminal.md +Appetite restante: entregue + +## Leitura da intenção + +- **Outcome:** o mantenedor abre uma TUI no terminal (mesa ou SSH de laptop/celular), vê numa lista as issues com estado/prioridade/kind e a ação que esperam dele, filtra por estado (em andamento / aguardando / concluídas), seleciona uma issue e lê o plano de intenção e o de implementação renderizados — cada um com link e status legível ("aprovado" vs "aguardando aprovação") — além de abrir o rascunho UI no browser da máquina e anexar à sessão do agente daquela issue, tudo read-only e sem sair do terminal. +- **O que NÃO negociar:** + - Read-only de ponta a ponta sobre o tracker e os planos (não claima, não relabela, não fecha, não comenta, não edita plano). Abrir browser/anexar sessão são navegação local, não escrita. + - **A fonte é a mesma camada de leitura de Issues usada por `pnpm issue` / `pnpm agent:status`** — `scripts/lib/github-api.mjs` (`listIssues`) + `scripts/lib/agent-forgejo.mjs` (`parseFrontmatter`, `labelNames`, `priorityRank`). Sem cache paralelo, sem segunda fonte de verdade. + - **A sessão do agente consome o contrato versionado do OPS110** (`pnpm agent:session list --json` = `{version, server, sessions:[{…state, status}]}` e `resolveSessionRef`); não iniciar run novo, não twinar o mecanismo de sessão. + - "Nenhuma sessão ativa" e "sem plano / impl não criado" são **estados de primeira classe** — nunca célula vazia, nunca falha silenciosa. + - Sem browser/display (SSH puro): **imprime o caminho** do `.html` para abrir manualmente; nunca crasha nem falha em silêncio. + - Token ausente / rede fora = **mensagem clara, nunca stack trace**. + - Terminal estreito (celular, ~46 col) continua utilizável. + - Não duplicar `pnpm issue` / `pnpm agent:status`; não substituí-los. +- **O que reavaliar (hipóteses da intenção):** + - "Nenhuma lib TUI instalada hoje" — **confirmado e mais severo**: `chalk`, `picocolors`, `marked`, `marked-terminal`, `markdown-it`, `cli-table3`, `boxen`, `ora`, `ink`, `blessed`, `string-width`, `strip-ansi`, `wrap-ansi` estão **todas ausentes** de `node_modules`. `react-markdown`/`remark-gfm` existem mas são browser/React — inúteis aqui. O plano assume ANSI/markdown à mão (decisão 1). + - `extractPlanPath` (`scripts/lib/agent-pool-prompt.mjs:12`) casa **o primeiro** `docs/plans/*.md` do body — mas o body de uma Issue costuma linkar o **plano de intenção**, não o `-impl.md`. O impl é descoberto por convenção de arquivo irmão (`-impl.md`) + back-link `Intenção:`, não por varredura cega do body (decisão 4). + - `loadStates` é **privado** dentro do CLI `scripts/agent-session.mjs:342` (não exportado pelo lib). Consumir via spawn do CLI é o contrato versionado; a alternativa de importar exige extrair `loadStates` para o lib (decisão 2). + - `listIssues` **não pagina** (`scripts/lib/github-api.mjs:213`; `per_page: limit` + `page`, um GET). Todos os callers usam `limit: 200` — o mesmo teto deste CLI (decisão 6). + - A linha `Status:` dos planos é **suja**: variantes `aprovado (gate humano )`, `**entregue** (negrito)`, `entregue (2026-07-29)`, `plano — registrado (blocked até plano em main)`, `engenharia pronta, mesclada…`, `Fases 1–4 implementadas…`. O parser precisa normalizar negrito/backticks/sufixos e classificar por prefixo, preservando o valor cru visível (decisão 3). + - Node 24 com type-stripping nativo importa `src/lib/slug.ts` de `.mjs` sem loader (já exercido por `scripts/lib/agent-session.mjs:21`), mas o painel não precisa disso — lógica pura em `.mjs`. + +## Abordagem recomendada + +```mermaid +flowchart LR + subgraph Leitura read-only + GH[github-api.listIssues
state=all limit=200] --> VM + SESS[agent-session list --json
spawn do contrato OPS110] --> VM + FS[docs/plans/*.md
readFileSync] --> PARSER[parsePlanStatus
parsePlanLinks] + end + VM[buildPanelViewModel
puro: estado + ação + planos + sessão] --> ROWS[rows filtradas] + PARSER --> VM + ROWS --> TUI[TUI ANSI
alternate screen + raw mode] + TUI --> LIST[lista: estado·id·título·plano·prio
filtro 1-4] + TUI --> DETAIL[detalhe: resumo + planos
+ ações w/s/o] + DETAIL --> READER[leitor markdown
render ANSI à mão] + DETAIL -- w --> BROWSER[xdg-open/open
fallback: imprime caminho] + DETAIL -- s --> ATTACH[spawn agent-session attach --issue=N] + LIST -- --json --> JSON[view model JSON
não-interativo p/ CI/teste] +``` + +**Opções consideradas:** A) CLI novo `scripts/issues-tui.mjs` + libs puras em `scripts/lib/issues-panel.mjs`, TUI ANSI à mão, **zero dependência nova**; B) mesmo CLI mas com `marked` + `marked-terminal` + `cli-table3`/`picocolors`; C) `ink` (React no terminal) ou `blessed`. +**Recomendação:** **A** — o subconjunto de render exigido (headings, listas, tabelas simples, código, negrito, citação) cabe em ~150 linhas de ANSI à mão; zero-dep evita tocar `pnpm-lock`, mantém `knip` honesto (dep nova precisa ser realmente usada), não encarece `pnpm install`/CI e preserva o contrato de "roda em qualquer terminal SSH". O appetite ~1–2 dias não paga o custo de integrar/estilizar uma lib de markdown inteira para um subconjunto. +**Rejeitadas:** + +- **B** (`marked` + `marked-terminal` + `cli-table3`/`picocolors`) — quatro deps só para um subconjunto; `marked-terminal` ainda arrasta `string-width`/`cli-table3`/`ansi-escapes` e traz um modelo de impressão imperativo difícil de truncar a 46 colunas; toda dep nova passa por auditoria de supply-chain e precisa de uso real sob `knip` (deps não usadas são block). +- **C** (`ink`/`blessed`) — React/layout engine inteiro para 5 telas; `blessed` está em manutenção irregular e `ink` exige JSX + reconciler, muito peso e superfície para uma ferramenta read-only. Gatilho de revisitação registrado na decisão 1. + +### Componentes / mudanças + +- **`scripts/lib/issues-panel.mjs`** (novo, puro, testável): a única fonte de derivação do painel. + - `parsePlanStatus(markdown)` — extrai a linha `Status:` (aceita `**negrito**`, backticks, sufixos entre parênteses) e devolve `{ raw, normalized }`. + - `classifyPlanStatus(raw)` — `aprovado*` → `'aprovado'`; `rascunho|registrado|blocked|plano — registrado` → `'aguardando'`; `em execução|executado|entregue|implementado|pronta|produção` → `'andou'`; desconhecido → `'desconhecido'` (fail-safe: mostra o cru, não inventa). + - `parsePlanLinks(body, planPath?)` — a partir do path do plano de intenção, deriva `-impl.md` e `-ui-draft.html` por convenção de arquivo irmão, confirma a existência via `readFileSync`/`existsSync` injetado. + - `buildPanelViewModel({ issues, sessions, plans })` — junta Issue + labels (`labelNames`/`priorityRank` de `agent-forgejo.mjs`), plano intenção/impl (status/classificação) e sessão OPS110 (`resolveSessionRef` de `agent-session.mjs`) num shape estável testável. + - `issueAction(vm)` — a ação pendente do operador ("aguardando aprovação" / "em andamento" / "travada"), derivada de labels + status dos planos. + - `filterByState(rows, key)` — `all | in-progress | waiting | done`. + - `truncateToWidth(text, width)` — truncamento ANSI-safe com `…` para terminal estreito (largura baseline ~46). + - Reusa **sem twin**: `labelNames`, `priorityRank`, `parseFrontmatter` (agent-forgejo), `extractPlanPath` (agent-pool-prompt), `resolveSessionRef` (agent-session). +- **`scripts/lib/ansi.mjs`** (novo, puro): helpers mínimos — `style`/`bold`/`dim`/`inverse`, códigos de cursor, `enterAlternateScreen`/`leaveAlternateScreen`, `stripAnsi`, `visibleWidth`. Sem dependência; só constante + string. +- **`scripts/lib/markdown-ansi.mjs`** (novo, puro): `renderMarkdown(md, { width })` — subconjunto: `#`..`######` (heading), listas `-`/`1.`, tabelas `|…|`, blocos ` ``` ` (dim/recuado), `**negrito**`/`*itálico*`, citação `> `. Retorna `string[]` de linhas para o leitor rolar. Sem parser completo — linhas desconhecidas passam cruas. +- **`scripts/issues-tui.mjs`** (novo, I/O fina): o CLI. Lê env (`GITHUB_TOKEN` via `github-api`), spawna `node scripts/agent-session.mjs list --json`, lê os `docs/plans/*.md` dos itens selecionados, monta o view model com o lib puro e desenha a TUI (alternate screen + raw mode). Subcomandos: `--json` (imprime o view model sem TUI — contrato não-interativo para teste/CI) e `--help`. Abre browser (`xdg-open`/`open`/`wslview`) e sessão (`spawnSync(node scripts/agent-session.mjs attach --issue=N …)`), com fallback. +- **`package.json`**: novo script `"issues:tui": "node scripts/issues-tui.mjs"` no mesmo padrão de `"issue"`/`"agent:status"`/`"agent:session"`. +- **Migration:** **sem migration** — nenhum schema/collection/global muda; `push:false` intocado. +- **Access / Consent:** não se aplica — tooling de terminal, sem acesso a banco, sem PII, sem `Consent`. Não toca `src/`. Nenhum helper de access novo. +- **UI:** **Impeccable C** (fluxo novo, ferramenta de terminal; sem UI de produto). Rascunho UI existente em `docs/plans/ops109-painel-issues-no-terminal-ui-draft.html` — **contrato visual, não código**: 5 cenas (lista 100col com filtro 1–4; detalhe com planos+ações `w`/`s`/`o`; leitor markdown com `Status:` no header e `%`; estados críticos sem token/vazio/sem ações; celular ~46col). O craft é a fidelidade ao rascunho via ANSI (cores funcionais de estado, acento de seleção). Sem ciclo shape→craft→critique→polish de browser — a verificação é aberta no próprio terminal. + +### Dados → forma (se aplicável) + +- **Forma:** **store completo + view model puro**. A camada pura (`issues-panel.mjs`) recebe os três insumos (`issues`, `sessions`, `plans`) e devolve um único shape — `{ rows: [{ number, id, title, state, priority, kind, depends, action, plan: { intention: {path, status, classification}, impl: {…} }, uiDraft: {path, exists}, session: {status, sessionID, branch, dir} | null, githubUrl }] }`. O CLI só faz I/O (fetch/spawn/read) e render. +- **Por quê:** o aceite de produto é todo leitura/derivação; concentrar a derivação num módulo puro torna testável por unit (`// @vitest-environment node`, import direto do `.mjs`) e mantém a camada de I/O fina — o padrão dominante pedido na intenção/diretrizes. +- **Rejeitadas:** A) derivar direto no CLI (I/O + lógica entrelaçados → intestável sem spawn/HTTP); B) derivar no render (acoplado ao terminal, impossível de exercitar em `--json`); C) cachear o view model em disco (segunda fonte que envelhece — anti-goal explícito). + +## Decisões de engenharia + +**1. Dependências: zero-dep hand-rolled vs libs.** +Opções: A) zero-dep — TUI ANSI à mão (`scripts/lib/ansi.mjs`) + render markdown ANSI à mão (`scripts/lib/markdown-ansi.mjs`); B) adicionar `marked` + `marked-terminal` + `cli-table3` + `picocolors`; C) adicionar `ink`/`blessed`. +Recomendação: **A** — o subconjunto pedido (headings, listas, tabelas, código, negrito, citação) é pequeno e estável; zero-dep não toca lockfile, mantém `knip` sem `ignoreDependencies` novo (deps não usadas são block), não infla `pnpm install`/CI e preserva "roda em qualquer terminal SSH". Node 24 ESM puro já é a convenção dos `scripts/*.mjs`. +Rejeitadas: B porque quatro deps (mais transitivas) para um subconjunto, com `marked-terminal` importando `string-width`/`cli-table3`/`ansi-escapes` e um modelo de impressão difícil de truncar a 46 col; C porque `ink` (JSX+reconciler) e `blessed` (peso/manutenção) são um framework inteiro para 5 telas read-only. +**Gatilho de revisitação:** se o painel passar a precisar de render semântico rico (syntax highlight, tabelas com quebra de célula, âncoras navegáveis) ou de mais de ~5 telas com estado complexo, reabrir e migrar para `marked`+`marked-terminal` (B) — a fronteira pura (`markdown-ansi.mjs`) isola a troca a um módulo. + +**2. Onde vive a lógica: lib pura vs inline; e o `loadStates` do OPS110.** +Opções: A) lógica de derivação em `scripts/lib/issues-panel.mjs` (puro/testável) e I/O fino no CLI `scripts/issues-tui.mjs`; B) tudo inline no CLI; C) lib pura **e** importar o estado de sessão lendo o diretório `~/.local/state/teqo/agent-sessions/*.json` diretamente. +Recomendação: **A + consumir o contrato via spawn** — o CLI spawna `node scripts/agent-session.mjs list --json` (o contrato versionado que `docs/AGENT-OPS.md:71,73` já declara como "o contrato do OPS109") e passa o JSON para `buildPanelViewModel`, que usa `resolveSessionRef` importado de `scripts/lib/agent-session.mjs`. Isso **não duplica** a leitura do diretório de estado nem reimplementa `deriveSessionStatus`/health-por-HTTP: o painel não fala HTTP com o servidor de sessões. +Rejeitadas: B porque entrelaça I/O e derivação e mata o `--json` testável; C porque reler o diretório é exatamente o **twin** que a engineering-standards proíbe — `loadStates` é privado de propósito (`scripts/agent-session.mjs:342`) e duplicá-lo faria o painel driftar da derivação de status/health do OPS110. Se algum dia importar em vez de spawnar for necessário, o movimento correto é **extrair `loadStates` para `scripts/lib/agent-session.mjs`** (edit the owner) e reusar — registrado como gatilho. + +**3. Parser do `Status:` e classificação de produto.** +Opções: A) novo `parsePlanStatus` que normaliza `**negrito**`/backticks/sufixos e classifica por prefixo, com valor **cru sempre visível**; B) regex literal só nas strings conhecidas; C) regex de `Status:` já existente em algum lib (não existe — confirmado: nenhum parser de `Status:` em `scripts/lib/*`). +Recomendação: **A** — a variedade real (193 `aprovado`, 192 `rascunho`, 72 `registrado`, 21 `em execução`/`entregue`, sufixos `(gate humano )`, `**entregue**`, `plano — registrado (blocked até plano em main)`, e até frases como `Fases 1–4 implementadas…`) exige normalização. `classifyPlanStatus` mapeia por prefixo segundo a leitura de produto assumida (`aprovado*`=aprovado; `rascunho|registrado|blocked`=aguardando; `em execução|executado|entregue|implementado`=andou); desconhecido → `desconhecido` (fail-safe: exibe o cru, nunca inventa "aprovado"). +Rejeitadas: B porque a lista de literais mudaria a cada plano novo e classificaria erroneamente `**entregue**`/`entregue (data)`; C porque não existe e criar duas regex seria o twin. +Nota: o header do leitor **sempre** mostra o `Status:` cru (rascunho UI cena 3: `Status: aprovado`), com a classificação como rótulo ao lado. + +**4. Descoberta de plano intenção / impl / rascunho UI a partir do body + convenções.** +Opções: A) `extractPlanPath(body)` (reuso) para a intenção; derivar `-impl.md` e `-ui-draft.html` como irmãos por convenção e confirmar existência no disco; B) varrer todos os `docs/plans/*.md` procurando back-links `Issue: #N`/`Intenção:` (varredura do repo); C) assumir que o body lista os dois planos. +Recomendação: **A** — o body linka o plano de intenção (`Plano: [\`docs/plans/.md\`](…)`), e a intenção registra a convenção irmã: `-impl.md`(com back-link`Intenção: docs/plans/.md`) e `-ui-draft.html`. `extractPlanPath`já casa o link real; o impl/rascunho se derivam do mesmo``. Quando o impl **não existe**, o estado é "impl não criado" (primeira classe) e o link do GitHub é a saída. Existência verificada com `existsSync` (a camada de I/O passa os paths existentes ao view model puro). +Rejeitadas: B porque varrer 777 planos por request é caro e ambíguo (um plano pode referenciar várias Issues); C porque o body só carrega o link da intenção e assumir o impl faria o painel mentir quando ele não existe. + +**5. Contrato do comando e como o painel abre markdown/detalhe.** +Opções: A) `pnpm issues:tui` interativo (alternate screen + raw mode) **e** `pnpm issues:tui --json` não-interativo imprimindo o view model; navegação em telas full-screen com `enter`/`esc`/`w`/`s`/`o`/`1-4`/`r`/`q`; B) só interativo, sem `--json`; C) páginas de texto impressas (sem alternate screen, `less`-like) re-executando o CLI. +Recomendação: **A** — o rascunho UI cita `pnpm issues:tui` como nome do comando (cena 4: `GITHUB_TOKEN=… pnpm issues:tui`), então o nome fica. `--json` é o contrato **não-interativo** que permite testar o view model de ponta a ponta por spawn (sem TTY) e serve de escape para resposta scriptável; o interativo usa alternate screen (`\x1b[?1049h`) + raw mode (`process.stdin.setRawMode(true)`), restore garantido em `finally` (inclusive em `SIGINT`/erro) para nunca deixar o terminal em raw. +Rejeitadas: B porque um TUI sem contrato testável força testar via spawn de TTY (frágil) e não dá saída scriptável; C porque o `less`-like re-executaria o fetch a cada página (custo/token) e não tem estado de seleção. +Nota: o comando **não** substitui `pnpm issue`/`pnpm agent:status` — é um terceiro ponto de leitura, read-only, sobre a mesma camada. + +**6. Paginação do `listIssues` (limit 100, sem loop).** +Opções: A) uma chamada `listIssues({ state: 'all', limit: 100 })` e **estado explícito** quando a resposta bate no teto; B) loop de paginação novo até esgotar; C) elevar o limite além de 100. +Recomendação: **A** — `scripts/lib/github-api.mjs` não pagina e `per_page` do GitHub é clampado em 100; o painel usa 100 como teto efetivo (`--limit` clampado) e, se `rows.length >= 100`, mostra um aviso ("mostrando as 100 mais recentes") em vez de fingir completude. Paginação real é uma mudança no **dono** (`github-api.mjs`), fora do appetite e sem evidência de necessidade hoje. _(Ajustado na execução: a hipótese original de 200 colidia com o teto de `per_page` do GitHub — 200 nunca chegava e o aviso nunca dispararia.)_ +Rejeitadas: B porque adiciona paginação ao painel em vez do dono (twin) e não há caso que estoure 100 ainda; C porque `per_page` acima de 100 é ignorado pelo GitHub e elevaria custo sem resolver o teto. + +**7. Abrir browser e sessão com fallback SSH.** +Opções: A) `spawnSync` de `xdg-open`/`open`/`wslview` para o `.html`, e `spawnSync(node scripts/agent-session.mjs attach --issue=N …)` para a sessão — ambos com fallback textual; B) só imprimir o caminho/comando (nunca tentar spawnar); C) `chromium.launch()` do Playwright (como os scripts de relatório). +Recomendação: **A** — humano está na máquina do repo (decisão assumida na intenção) e quer a ação de um toque. Browser: se `DISPLAY`/`WAYLAND_DISPLAY` ausentes e nenhum opener existir, **imprime o path absoluto** (nunca falha em silêncio). Sessão: reusa o CLI do OPS110 (sem reimplementar attach); o painel antes consulta o `list --json` — sem `session` para a Issue, mostra "nenhuma sessão ativa" e **não inicia run** (abrir-sessão só anexa). +Rejeitadas: B porque o gate humano pediu explicitamente "abrir no browser" (a ação existe); C porque Playwright headless não é "o browser padrão da máquina" e um `.html` de draft de UI deve abrir no browser do usuário. +Nota: o attach do painel é síncrono (`stdio: 'inherit'`) e, ao sair do TUI do agente, o painel retoma — o run do agente **não** é encerrado (OPS110). + +## Fases verificáveis + +1. **Tracer / leitura+derivação puros (quota ~40% do appetite)** — `scripts/lib/issues-panel.mjs` + `scripts/lib/markdown-ansi.mjs` + `scripts/lib/ansi.mjs` com os helpers puros e os unit tests correspondentes; `scripts/issues-tui.mjs --json` que já exercita o pipeline real (fetch GitHub + spawn `agent-session list --json` + leitura de planos) imprimindo o view model. Prova: `pnpm issues:tui --json` devolve rows com estado/ação/planos/sessão; unit verde. +2. **UI (quota ~45%)** — TUI interativa: lista (filtro 1–4, navegação `↑↓`/`enter`), detalhe (planos + ações `w`/`s`/`o`), leitor markdown rolável (`↑↓`/`pgup`/`pgdn`/`esc`), estados críticos (sem token, vazio, sem plano/impl, sem sessão, sem browser/display), truncamento a terminal estreito. Fidelidade ao rascunho UI. Prova: aberta no terminal (mesa e via SSH), com os cenários do rascunho reproduzidos à mão. +3. **Gates (quota ~15%)** — `pnpm gate:fast` (lint + typecheck + unit); `pnpm knip` (nenhuma dep nova a declarar, nenhum export morto); `pnpm gate:ci` conforme o repo; push via `pnpm push`. Registro: entrada em `docs/changelog/-ops109.md`. + +## Rabbit holes / Não escopo (engenharia) + +- **Escrever no tracker/planos** — read-only é invariante; claim/relabel/close/comentar/editar plano mora nos comandos existentes. +- **Iniciar run novo** — o painel só anexa (attach) a um run existente via CLI do OPS110; nunca `agent:session start`. +- **Servidor web / ttyd / wetty / app mobile / tmux obrigatório** — o contrato é o terminal SSH puro. +- **Notificações / watch / daemon / burndown** — fora de escopo por produto. +- **Reimplementar `loadStates`/health HTTP do OPS110** — twin proibido; consome-se `list --json`. Se um dia importar for necessário, extrair `loadStates` para o lib (edit the owner). +- **Paginação do `listIssues`** — mudança de dono (`github-api.mjs`), sem evidência de necessidade; o painel clampa no teto de `per_page` do GitHub (100) e avisa quando a página enche. +- **Filtro de busca (`/`) na lista** — está no rodapé do rascunho UI, mas **não** está no aceite de produto; fica como polish barato na Fase 2 se sobrar appetite, sem virar requisito (gatilho: humano pedir). +- **Renomear `pnpm issue`/`agent:status`** — não substituir; o painel é um terceiro ponto de leitura. +- **`push:false`/migration** — nenhum schema muda; nada de migration. + +## Débts do /simplify (triage) + +- **Já resolvido no simplify (não reabrir):** scroll de um frame por linha no `paint` (sem `\n` final); overflow das linhas largas a 80/96 col e da linha estreita a 46 (limiar 96 + `padToWidth` sempre); capacidade do corpo estreito contando 2 linhas por item (`visibleWindow` puro, testado); leitor markdown em largura fixa 80 (agora largura dinâmica + truncamento); inverso da linha selecionada apagado pelo reset interno (`stripAnsi` antes do inverse); `--limit` acima do teto de 100 do GitHub (clamp + `truncated` honesto); classificação de `plano — registrado`; twin de frontmatter (`parseFrontmatter` de `agent-forgejo`); flags manuais (`parseEqualsFlags` + `dieWithLabel`); `sessionWarning` invisível na TUI; `wrap` local duplicado (`wrapInline`); `readKey` descartando teclas em lote e travando no EOF (`createKeyReader` com fila); ui-draft lido só para `Boolean` (`existsSync`); `planLabel`/`toJsonPayload` com branch morto; docs do ANSI e do reader; `DEFAULT_READER_WIDTH` não exportado. +- **Adiado com gatilho:** extrair `renderList`/`renderDetail`/`renderReader`/`paint` da CLI para lib pura testável e um smoke do `--json`/`--limit` — **gatilho:** nova regressão de layout ou 2º consumidor do render. (A matemática de janela já ficou pura/testada em `visibleWindow`; o restante foi verificado no tmux a 100 e 46 colunas.) +- **Explicitamente fora:** teste automatizado de TTY (a verificação ficou no tmux manual, custo/benefício fora do appetite); I/O no entrypoint é o desenho — `scripts/issues-tui.mjs` é a fronteira de I/O, os `scripts/lib/*.mjs` do painel são puros. + +## Riscos e mitigação + +- **Terminal em raw mode travado após crash** — restaurar (`stty`/`\x1b[?1049l` + `setRawMode(false)`) num `finally` e em handlers de `SIGINT`/`SIGTERM`/`uncaughtException`; testar matando o processo no meio. +- **Render ANSI quebrado em SSH/terminal estreito** — sem `string-width`, larguras calculadas por `visibleWidth` próprio (strip de sequências) e truncamento com `…`; a matemática de janela é unit-testada (`visibleWindow`) e o layout foi verificado no tmux a 100 e 46 colunas. Sem emoji/double-width décor — só o essencial. +- **`marked`-less render distorce tabelas/aninhamentos** — o subconjunto é deliberadamente conservador; linhas fora do subconjunto passam cruas (nunca somem). Gatilho de revisitação na decisão 1. +- **`GITHUB_TOKEN` ausente/401/rede fora** — capturar e renderizar mensagem clara (tela de erro da cena 4), nunca stack trace; `r` tenta de novo. O `github-api` já retry-a GET 5xx e lança em 4xx/sem token. +- **`agent-session list --json` indisponível (OPS110 ausente/CLI fora)** — o painel trata como "sem sessões" com aviso discreto; a Issue nunca quebra por causa disso (sessão é enriquecimento, não requisito de lista). +- **Teto de 100 issues** — `per_page` do GitHub é clampado em 100; aviso explícito "mostrando as 100 mais recentes" quando a página enche (decisão 6). +- **Custo/token da camada de leitura** — uma chamada `listIssues` por carga (não por navegação); planos lidos do disco local, não do GitHub. `r` recarrega por intenção do humano. +- **Deriva do contrato OPS110** — o `list --json` é versionado (`version` no payload); o painel pode avisar se `version` mudar, sem quebrar. Não reler o diretório de estado (decisão 2). +- **`knip` acusando exports não usados dos libs novos** — cada helper puro é exercitado por unit/spec importando o `.mjs` (o padrão do repo, ex.: `agentSession.unit.spec.ts`), o que satisfaz o `entry`/uso. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto: lista com estado/prio/kind/ação; filtro por estado; detalhe com intenção+impl (link + status legível); "sem plano"/"impl não criado" explícitos com link GitHub; leitor markdown renderizado com rolagem/volta; rascunho UI no browser com fallback de path; sessão ativa mostrada e anexável, "nenhuma sessão ativa" caso contrário (sem iniciar run); terminal estreito utilizável; token/rede = mensagem clara; read-only de ponta a ponta sobre a camada existente. +- [ ] Invariantes AGENTS/engineering-standards: identificadores em inglês (strings de UI/labels em pt); **edit the owner, don't twin** (reusa `github-api.mjs`, `agent-forgejo.mjs`, `agent-pool-prompt.mjs`, `agent-session.mjs`; consome o contrato OPS110 em vez de reler o dir de estado); nenhuma dep nova; sem migration/`push`; sem PII/DB; comandos bare no CI. +- [ ] Testes de domínio previstos: unit em `tests/unit/*.unit.spec.ts` (`// @vitest-environment node`, import direto dos `.mjs`) cobrindo `parsePlanStatus`/`classifyPlanStatus` (incluindo `**entregue**`, `aprovado (gate humano …)`, `blocked`, desconhecido), `parsePlanLinks` (impl/rascunho irmãos, ausência), `buildPanelViewModel` (shape, ação, "sem plano"/"impl não criado"/"nenhuma sessão ativa", teto de 200), `filterByState`, `markdown-ansi` (headings/listas/tabelas/código/negrito/citação passam; linhas cruas preservadas) e `truncateToWidth`/`visibleWidth`. Sem int test (sem access/write paths). + +## Autoscore de decision-quality + +**Score: 4.5 / 5.** + +| Critério | Nota | Justificativa | +| ------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Decisões caras registradas (dependência, contrato, fronteira) | 1.0 | Decisões 1 (zero-dep vs libs, com gatilho), 5 (nome `pnpm issues:tui` + `--json` como contrato não-interativo), 2 (fronteira lib pura vs I/O; consumir `list --json` em vez de reler o dir) e 7 (browser/attach + fallback) são exatamente as reversões caras; cada uma com Opções/Recomendação/Rejeitadas. | +| Alternativas rejeitadas com motivo | 1.0 | Toda decisão (1–7) traz rejeitadas com porquê concreto (knip/supply-chain, twin proibido, teto de 200, raw-mode, paginação fora do dono). | +| Barato empurrado para Não escopo/gatilho | 0.9 | Busca `/`, paginação, syntax highlight e extração de `loadStates` ficam com gatilho de revisitação explícito em vez de virar escopo; o appetite é respeitado. | +| Depth check de reuso (sem twin) | 1.0 | Reusa `github-api.listIssues`, `agent-forgejo` (labels/frontmatter/priority), `agent-pool-prompt.extractPlanPath`, `agent-session.resolveSessionRef` e o contrato `list --json`; nomeia explicitamente o twin proibido (reler o dir de estado) e o dono correto (`edit the owner`). | +| Testabilidade / tracer cedo | 0.6 | Fase 1 já entrega o pipeline real via `--json` (tracer bullet) e toda a derivação é pura/testável; perde meio ponto porque o TUI interativo em si só se verifica à mão (sem teste automatizado de TTY, aceitável para o appetite). | + +**Total: 4.5/5** (≥4 — pronto para o gate humano do impl plan). diff --git a/docs/plans/ops109-painel-issues-no-terminal.md b/docs/plans/ops109-painel-issues-no-terminal.md index 4f9e68a8..5568f25e 100644 --- a/docs/plans/ops109-painel-issues-no-terminal.md +++ b/docs/plans/ops109-painel-issues-no-terminal.md @@ -1,6 +1,6 @@ # OPS109 — Painel de issues e planos no terminal (via SSH) -Status: rascunho +Status: aprovado (gate humano 2026-09-15) Atualizado em: 2026-09-15 Issue: #1020 Priority: P2 diff --git a/package.json b/package.json index a64946d5..72ef1e37 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,8 @@ "agent:session": "node scripts/agent-session.mjs", "worktree": "node scripts/worktree.mjs", "unblock:check": "node scripts/auto-unblock.mjs --check", - "issue": "node scripts/issue.mjs" + "issue": "node scripts/issue.mjs", + "issues:tui": "node scripts/issues-tui.mjs" }, "dependencies": { "@ai-sdk/deepseek": "3.0.19", diff --git a/scripts/issues-tui.mjs b/scripts/issues-tui.mjs new file mode 100644 index 00000000..a593b1d8 --- /dev/null +++ b/scripts/issues-tui.mjs @@ -0,0 +1,720 @@ +#!/usr/bin/env node +/** + * OPS109 — `pnpm issues:tui`: painel read-only de issues e planos no terminal. + * + * pnpm issues:tui TUI interativa (alternate screen + raw mode) + * pnpm issues:tui --json imprime o view model (contrato não-interativo) + * pnpm issues:tui --help ajuda + * + * A fonte dos dados é a camada de leitura existente: `github-api.listIssues` + * (Issues + labels), `agent-session list --json` (contrato do OPS110) e os + * `docs/plans/*.md` no disco. A derivação mora em `scripts/lib/issues-panel.mjs` + * (puro/testável); aqui fica só o I/O (fetch, spawn, disco) e o render. + * + * Guardrails: read-only sobre tracker e planos; não inicia run (só anexa a + * sessão existente via `agent:session attach`); sem token/rede vira mensagem + * clara, nunca stack trace; sair nunca deixa o terminal em raw mode. + * + * Teclas na lista: ↑↓ mover · enter abrir · 1-4 filtrar · r recarregar · q sair + * No detalhe: esc voltar · enter abrir plano · w rascunho UI · s sessão · o GitHub + * No leitor: ↑↓ rolar · pgup/pgdn página · esc voltar + */ + +import { spawnSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +import { + bold, + clearLine, + dim, + enterAlternateScreen, + hideCursor, + leaveAlternateScreen, + moveTo, + padToWidth, + SGR, + showCursor, + stripAnsi, + style, + terminalSize, + tone, + truncateToWidth, + visibleWidth, +} from './lib/ansi.mjs' +import { dieWithLabel, parseEqualsFlags } from './lib/cli.mjs' +import { githubApi as api } from './lib/github-api.mjs' +import { + buildPanelViewModel, + filterByState, + planLabel, + toJsonPayload, + visibleWindow, +} from './lib/issues-panel.mjs' +import { renderMarkdown, wrapInline } from './lib/markdown-ansi.mjs' + +const die = dieWithLabel('issues:tui') + +/** GitHub caps `per_page` at 100 — the panel never promises more than that. */ +const MAX_LIMIT = 100 + +const USAGE = `Uso: pnpm issues:tui [--json] [--limit=N] [--help] + + (sem flags) TUI interativa (↑↓ mover · enter abrir · 1-4 filtrar · r recarregar · q sair) + --json imprime o view model das issues (não-interativo, para teste/script) + --limit=N teto de issues a ler (default 100, o teto de per_page do GitHub) + --help esta ajuda + +Read-only sobre o tracker e os planos; a sessão do agente é consumida via +\`pnpm agent:session list --json\` (OPS110). Exige GITHUB_TOKEN no ambiente.` + +const FILTERS = ['all', 'in-progress', 'waiting', 'done'] +const FILTER_LABELS = { + all: 'todas', + 'in-progress': 'em andamento', + waiting: 'aguardando', + done: 'concluídas', +} + +const STATE_MARK = { + 'in-progress': { mark: '●', tone: 'wait', label: 'em andamento' }, + ready: { mark: '○', tone: 'neutral', label: 'pronta' }, + blocked: { mark: '■', tone: 'bad', label: 'travada' }, + done: { mark: '✓', tone: 'ok', label: 'concluída' }, + 'in-prod': { mark: '✓', tone: 'ok', label: 'em produção' }, + open: { mark: '○', tone: 'neutral', label: 'aberta' }, +} + +const stateCell = (state) => { + const entry = STATE_MARK[state] ?? STATE_MARK.open + return tone(entry.tone, `${entry.mark} ${entry.label}`) +} + +const classificationTone = (classification) => + ({ aprovado: 'ok', andou: 'ok', aguardando: 'wait', desconhecido: 'neutral' })[classification] ?? + 'neutral' + +// --- I/O --------------------------------------------------------------------- + +/** + * Read the plan files for one row from disk (repo root = cwd). Missing files + * come back null — “sem plano” / “impl não criado” are first-class states. + * The UI draft only needs its existence verdict (content never rendered). + */ +const readPlans = (row) => { + const readIfExists = (path) => { + if (!path) return null + const absolute = resolve(process.cwd(), path) + try { + return readFileSync(absolute, 'utf8') + } catch { + return null + } + } + const uiDraftPath = row.uiDraft?.path + return { + intention: readIfExists(row.plan.intention?.path), + impl: readIfExists(row.plan.impl?.path), + uiDraft: Boolean(uiDraftPath) && existsSync(resolve(process.cwd(), uiDraftPath)), + } +} + +/** Sessions via the OPS110 contract. Failure degrades to “no sessions”. */ +const loadSessions = () => { + const result = spawnSync(process.execPath, ['scripts/agent-session.mjs', 'list', '--json'], { + cwd: process.cwd(), + encoding: 'utf8', + timeout: 15_000, + }) + if (result.status !== 0 || !result.stdout) { + return { + sessions: [], + warning: 'estado das sessões indisponível (OPS110) — mostrando sem sessões.', + } + } + try { + const payload = JSON.parse(result.stdout) + const sessions = (payload.sessions ?? []).map((session) => ({ + ...session, + status: session.status ?? 'unknown', + })) + return { sessions, warning: null } + } catch { + return { sessions: [], warning: 'estado das sessões ilegível — mostrando sem sessões.' } + } +} + +/** + * Load the full view model. Network/token failures raise a typed error the + * caller renders as the cena-4 error screen. + */ +const loadViewModel = async ({ limit, withPlans = true }) => { + if (!process.env.GITHUB_TOKEN) { + const error = new Error('GITHUB_TOKEN não definido no ambiente.') + error.code = 'NO_TOKEN' + throw error + } + const issues = await api.listIssues({ state: 'all', limit }) + const { sessions, warning: sessionWarning } = loadSessions() + const base = buildPanelViewModel({ issues, sessions, limit }) + if (withPlans) { + const plansByNumber = new Map() + for (const row of base.rows) plansByNumber.set(row.number, readPlans(row)) + return { + ...buildPanelViewModel({ issues, sessions, plansByNumber, limit }), + sessionWarning, + } + } + return { ...base, sessionWarning } +} + +// --- Render ------------------------------------------------------------------ + +/** + * Paint the frame: header, filter chips, rows (with the cursor inverted) and + * the contextual footer. Narrow terminals (<96 cols, the wide row's minimum) + * get the phone layout — 2 lines per row — and every line is truncated to the + * width so nothing wraps or overflows on an 80-col SSH window. + */ +const renderList = (viewModel, { width, height, filter, cursor, message, error }) => { + const narrow = width < 96 + const lines = [] + const counts = viewModel.counts + const push = (line) => lines.push(truncateToWidth(line, width)) + + if (error) { + lines.push(bold('teqo · issues')) + lines.push('') + push(tone('bad', `erro: ${error}`)) + push('Nada foi alterado. Defina o token e rode de novo:') + push(style(SGR.cyan, 'GITHUB_TOKEN=… pnpm issues:tui')) + lines.push('') + lines.push(dim('r tentar de novo · q sair')) + return lines + } + + lines.push( + narrow + ? bold('teqo · issues') + : truncateToWidth( + `${bold('teqo · issues')} ${dim('|')} repo fsolla/teqo ${dim(`| ${viewModel.rows.length} rastreáveis`)}`, + width, + ), + ) + + const chips = FILTERS.map((key, index) => { + const active = key === filter + if (narrow) { + const short = { all: 'todas', 'in-progress': 'andam.', waiting: 'aguard.', done: 'feitas' }[ + key + ] + const label = `${index + 1} ${short}` + return active ? style(SGR.inverse, label) : dim(label) + } + const label = `${index + 1} ${FILTER_LABELS[key]} (${counts[key]})` + return active ? style(SGR.inverse, label) : dim(label) + }) + push(chips.join(narrow ? ' ' : ' ')) + + if (viewModel.truncated) { + push(dim(`mostrando as ${viewModel.limit} mais recentes`)) + } + if (viewModel.sessionWarning) { + push(dim(viewModel.sessionWarning)) + } + + const rows = filterByState(viewModel.rows, filter) + if (rows.length === 0) { + lines.push('') + push(dim(`Nenhuma issue em “${FILTER_LABELS[filter]}” no momento.`)) + push(dim('1-4 troca o filtro · q sai')) + } + + const reserved = 3 + const bodyHeight = Math.max(1, height - lines.length - reserved) + const rowHeight = narrow ? 2 : 1 + const capacity = Math.max(1, Math.floor(bodyHeight / rowHeight)) + const { start, end } = visibleWindow({ count: rows.length, cursor, capacity }) + const visible = rows.slice(start, end) + + if (!narrow && rows.length > 0) { + lines.push( + style( + SGR.bold, + `${padToWidth('estado', 20)}${padToWidth('id', 10)}${padToWidth('título', 40)}${padToWidth('plano', 22)}prio`, + ), + ) + } + + visible.forEach((row, index) => { + const absolute = start + index + const selected = absolute === cursor + const rowTitle = `${row.code ?? `#${row.number}`} ${row.title}` + if (narrow) { + const sessionMark = row.session ? ' ◆' : '' + const head = `${stateCell(row.state)} · ${row.code ?? `#${row.number}`} · ${row.priority}${sessionMark}` + const detail = rowTitle + lines.push( + selected ? style(SGR.inverse, padToWidth(stripAnsi(head), width)) : padToWidth(head, width), + ) + lines.push( + selected + ? style(SGR.inverse, padToWidth(truncateToWidth(detail, width), width)) + : dim(padToWidth(truncateToWidth(detail, width), width)), + ) + return + } + const actionTone = + row.action === 'aguardando aprovação' + ? 'wait' + : row.action === 'em andamento' + ? 'accent' + : row.action.startsWith('travada') + ? 'bad' + : row.action === 'concluída' + ? 'ok' + : 'neutral' + const sessionMark = + row.session && (row.session.status === 'working' || row.session.status === 'idle') + ? tone('ok', ' ◆') + : '' + const cell = `${padToWidth(stateCell(row.state), 20)}${padToWidth(row.code ?? `#${row.number}`, 10)}${padToWidth(truncateToWidth(row.title, 39), 40)}${padToWidth(tone(actionTone, truncateToWidth(planLabel(row), 20)), 22)}${row.priority}${sessionMark}` + lines.push( + selected ? style(SGR.inverse, padToWidth(stripAnsi(cell), width)) : padToWidth(cell, width), + ) + }) + + while (lines.length < height - 1) lines.push('') + lines.push( + message + ? tone('ok', truncateToWidth(message, width)) + : dim( + truncateToWidth('↑↓ navegar · enter abrir · 1-4 filtrar · r recarregar · q sair', width), + ), + ) + return lines.slice(0, height) +} + +/** Detail screen: meta, summary, both plans with status, and the actions. */ +const renderDetail = (row, { width, height, message }) => { + const lines = [] + const push = (line) => lines.push(truncateToWidth(line, width)) + const header = `${row.code ?? ''} — ${row.title}`.trim() + push(bold(header)) + push( + `Issue #${row.number} · prio ${row.priority} · kind ${row.kind} · depends ${row.depends.join(', ') || '—'} · ${row.url}`, + ) + const stateEntry = STATE_MARK[row.state] ?? STATE_MARK.open + push(tone(stateEntry.tone, `${stateEntry.mark} ${stateEntry.label} · ação: ${row.action}`)) + + lines.push('') + lines.push(dim('resumo')) + const summary = row.summary || '(sem resumo no body — veja o GitHub)' + for (const piece of wrapInline(summary, width)) push(piece) + + lines.push('') + lines.push(dim('planos')) + const planLine = (label, plan, fallback) => { + if (!plan || !plan.path || !plan.exists) { + return `${tone('neutral', `○ ${label}`)} · ${dim(fallback)} · ${dim(`GitHub: ${row.url}`)}` + } + const classification = plan.classification + const statusLabel = + classification === 'aprovado' + ? tone('ok', `✓ aprovado`) + : classification === 'aguardando' + ? tone('wait', `◌ aguardando aprovação`) + : classification === 'andou' + ? tone('ok', `✓ ${plan.status?.raw ?? 'andou'}`) + : tone('neutral', `? ${plan.status?.raw ?? 'status desconhecido'}`) + return `${statusLabel} · ${plan.path} · ${dim('enter abre')}` + } + push(planLine('intenção', row.plan.intention, 'sem plano')) + push(planLine('implementação', row.plan.impl, 'impl não criado')) + + lines.push('') + lines.push(dim('ações')) + push( + row.uiDraft.exists + ? `${style(SGR.cyan, 'w')} abrir rascunho UI no browser · ${row.uiDraft.path}` + : dim('w rascunho UI: não tem (classe A)'), + ) + if (row.session) { + push( + `${style(SGR.cyan, 's')} abrir sessão do agente · ${tone('ok', `run ${row.session.status}`)} · ${row.session.branch}`, + ) + } else { + push( + `${style(SGR.cyan, 's')} ${dim('sessão do agente: nenhuma ativa (o painel não inicia run)')}`, + ) + } + push(`${style(SGR.cyan, 'o')} abrir a issue no GitHub`) + + while (lines.length < height - 1) lines.push('') + lines.push( + message + ? tone('ok', truncateToWidth(message, width)) + : dim( + truncateToWidth( + 'esc voltar · enter abrir plano · w rascunho UI · s sessão · o GitHub', + width, + ), + ), + ) + return lines.slice(0, height) +} + +/** Reader screen: rendered markdown with a scroll offset and a progress %. */ +const renderReader = (plan, { width, height, scroll, total }) => { + const lines = [] + const status = plan.status?.raw ?? plan.classification + const progress = total <= 0 ? 100 : Math.min(100, Math.round(((scroll + height) / total) * 100)) + const progressLabel = `${progress}%` + const titleWidth = Math.max(10, width - visibleWidth(status) - visibleWidth(progressLabel) - 6) + const title = truncateToWidth(plan.path, titleWidth) + lines.push( + truncateToWidth( + `${bold(title)} ${tone(classificationTone(plan.classification), status)} ${dim(progressLabel)}`, + width, + ), + ) + for (const line of plan.lines.slice(scroll, scroll + Math.max(1, height - 2))) { + lines.push(truncateToWidth(line, width)) + } + while (lines.length < height - 1) lines.push('') + lines.push(dim(truncateToWidth('↑↓ rolar · pgup/pgdn página · esc voltar à issue', width))) + return lines.slice(0, height) +} + +/** + * Draw one frame. No newline after the last row: writing one would scroll the + * alternate screen by a line every frame (the frame's first row would drift). + */ +const paint = (lines, { width, height }) => { + const frame = [] + for (let row = 0; row < height; row += 1) { + frame.push(`${padToWidth(lines[row] ?? '', width)}${clearLine()}`) + } + process.stdout.write(`${hideCursor()}${moveTo(1, 1)}${frame.join('\n')}`) +} + +// --- Interactions ------------------------------------------------------------ + +/** Open a local file in the machine's default browser, or print the path. */ +const openInBrowser = (path) => { + const absolute = resolve(process.cwd(), path) + const hasDisplay = Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY) + const opener = + process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open' + if (hasDisplay) { + const result = spawnSync(opener, [absolute], { stdio: 'ignore' }) + if (result.status === 0) return { opened: true, message: `abrindo ${path} no browser` } + } + return { opened: false, message: `sem browser/display — abra manualmente: ${absolute}` } +} + +/** Attach to the existing agent session. Never starts a run (OPS110 contract). */ +const attachSession = (row) => { + if (!row.session) return { message: 'nenhuma sessão ativa — o painel não inicia run.' } + const result = spawnSync( + process.execPath, + [ + 'scripts/agent-session.mjs', + 'attach', + `--issue=${row.number}`, + `--session=${row.session.sessionID}`, + ], + { cwd: process.cwd(), stdio: 'inherit' }, + ) + return { + message: + result.status === 0 + ? 'sessão encerrada — de volta ao painel' + : 'não consegui anexar à sessão', + } +} + +const openGithub = (row) => { + const result = openInBrowser(row.url) + return { message: result.opened ? `abrindo ${row.url}` : `abra manualmente: ${row.url}` } +} + +/** Known multi-byte key sequences, longest first so `esc` never wins early. */ +const KEY_SEQUENCES = [ + ['\u001b[5~', 'pgup'], + ['\u001b[6~', 'pgdn'], + ['\u001b[A', 'up'], + ['\u001b[B', 'down'], + ['\u001b', 'esc'], + ['\r', 'enter'], + ['\n', 'enter'], + ['\u007f', 'backspace'], + ['\b', 'backspace'], +].sort((left, right) => right[0].length - left[0].length) + +/** + * Key-press reader for raw-mode stdin. Buffers every token of a chunk (a + * fast key repeat can deliver several in one `data` event), resolves `q` on + * EOF so the loop never hangs, and normalizes to `up`/`down`/`enter`/`esc`/ + * `pgup`/`pgdn`/`backspace` or the literal character. + * @param {NodeJS.ReadStream} stdin + */ +const createKeyReader = (stdin) => { + const queue = [] + let waiting = null + let ended = false + + const resolveNext = () => { + if (waiting && queue.length > 0) { + const done = waiting + waiting = null + done(queue.shift()) + } + } + + const onData = (buffer) => { + const text = buffer.toString('utf8') + let index = 0 + while (index < text.length) { + const sequence = KEY_SEQUENCES.find(([token]) => text.startsWith(token, index)) + if (sequence) { + queue.push(sequence[1]) + index += sequence[0].length + continue + } + queue.push(text[index]) + index += 1 + } + resolveNext() + } + + const onEnd = () => { + ended = true + if (waiting) { + const done = waiting + waiting = null + done('q') + } + } + + stdin.on('data', onData) + stdin.once('end', onEnd) + stdin.once('close', onEnd) + + return { + next: () => + new Promise((done) => { + if (queue.length > 0) { + done(queue.shift()) + return + } + if (ended) { + done('q') + return + } + waiting = done + }), + dispose: () => { + stdin.removeListener('data', onData) + stdin.removeListener('end', onEnd) + stdin.removeListener('close', onEnd) + }, + } +} + +/** + * Drive the interactive TUI until `q`. Raw mode and the alternate screen are + * restored in `finally` and on fatal signals — a crash must never leave the + * terminal in raw mode. + */ +const runTui = async ({ limit }) => { + const stdin = process.stdin + const stdout = process.stdout + const isTty = Boolean(stdin.isTTY && stdout.isTTY) + + let viewModel = null + let error = null + let message = null + const reload = async () => { + try { + viewModel = await loadViewModel({ limit }) + error = null + } catch (caught) { + viewModel = null + error = + caught?.code === 'NO_TOKEN' ? caught.message : `falha ao ler as issues: ${caught.message}` + } + } + await reload() + + const state = { + screen: 'list', + filter: 'all', + cursor: 0, + row: null, + plan: null, + scroll: 0, + } + + const keys = createKeyReader(stdin) + const cleanup = () => { + keys.dispose() + if (isTty && stdin.setRawMode) stdin.setRawMode(false) + stdin.pause() + if (isTty) stdout.write(`${showCursor()}${leaveAlternateScreen()}`) + } + + const onFatal = (error) => { + if (error?.stack) process.stderr.write(`${error.stack}\n`) + cleanup() + process.exit(1) + } + process.once('SIGINT', onFatal) + process.once('SIGTERM', onFatal) + process.once('uncaughtException', onFatal) + + if (isTty) { + if (stdin.setRawMode) stdin.setRawMode(true) + stdin.resume() + stdout.write(enterAlternateScreen()) + } + + try { + let running = true + while (running) { + const { columns, rows } = terminalSize({ columns: stdout.columns, rows: stdout.rows }) + const width = Math.max(40, columns) + const height = Math.max(10, rows) + + const visibleRows = viewModel ? filterByState(viewModel.rows, state.filter) : [] + state.cursor = Math.max(0, Math.min(state.cursor, Math.max(0, visibleRows.length - 1))) + + let lines + if (state.screen === 'reader' && state.plan) { + lines = renderReader(state.plan, { + width, + height, + scroll: state.scroll, + total: state.plan.lines.length, + }) + } else if (state.screen === 'detail' && state.row) { + lines = renderDetail(state.row, { width, height, message }) + } else { + lines = renderList(viewModel ?? { rows: [], counts: {}, truncated: false, limit }, { + width, + height, + filter: state.filter, + cursor: state.cursor, + message, + error, + }) + } + if (isTty) paint(lines, { width, height }) + else stdout.write(`${lines.map((line) => `${line}\n`).join('')}\n`) + + message = null + if (!isTty) { + running = false + continue + } + + const key = await keys.next() + + if (state.screen === 'reader') { + const maxScroll = Math.max(0, state.plan.lines.length - (height - 2)) + if (key === 'up') state.scroll = Math.max(0, state.scroll - 1) + else if (key === 'down') state.scroll = Math.min(maxScroll, state.scroll + 1) + else if (key === 'pgup') state.scroll = Math.max(0, state.scroll - (height - 2)) + else if (key === 'pgdn') state.scroll = Math.min(maxScroll, state.scroll + (height - 2)) + else if (key === 'esc' || key === 'q') state.screen = 'detail' + continue + } + + if (state.screen === 'detail') { + if (key === 'esc') state.screen = 'list' + else if (key === 'o') message = openGithub(state.row).message + else if (key === 'w') { + if (!state.row.uiDraft.exists) message = 'rascunho UI: não tem (classe A)' + else message = openInBrowser(state.row.uiDraft.path).message + } else if (key === 's') { + message = attachSession(state.row).message + await reload() + state.row = + viewModel?.rows.find((entry) => entry.number === state.row.number) ?? state.row + } else if (key === 'enter') { + const openPlan = async (plan) => { + if (!plan || !plan.exists) { + message = plan?.path ? `plano não encontrado: ${plan.path}` : 'sem plano para abrir' + return + } + const markdown = readFileSync(resolve(process.cwd(), plan.path), 'utf8') + state.plan = { + ...plan, + lines: renderMarkdown(markdown, { width: Math.max(20, width - 2) }), + } + state.scroll = 0 + state.screen = 'reader' + } + // enter opens the impl when present, else the intention (UI draft order). + await openPlan( + state.row.plan.impl?.exists ? state.row.plan.impl : state.row.plan.intention, + ) + } else if (key === 'q') running = false + continue + } + + // list screen + if (key === 'q') running = false + else if (key === 'up') state.cursor = Math.max(0, state.cursor - 1) + else if (key === 'down') state.cursor = Math.min(visibleRows.length - 1, state.cursor + 1) + else if (['1', '2', '3', '4'].includes(key)) { + state.filter = FILTERS[Number(key) - 1] + state.cursor = 0 + } else if (key === 'r') { + await reload() + state.cursor = 0 + message = 'recarregado' + } else if (key === 'enter' && visibleRows[state.cursor]) { + state.row = visibleRows[state.cursor] + state.screen = 'detail' + } + } + } finally { + cleanup() + } +} + +// --- Entry ------------------------------------------------------------------- + +const { flags, positional } = parseEqualsFlags(process.argv.slice(2)) +if (positional.length > 0) { + die(`argumento posicional inesperado: ${positional.join(' ')} (veja pnpm issues:tui --help)`) +} + +if (flags.help) { + console.log(USAGE) + process.exit(0) +} + +const rawLimit = flags.limit ?? String(MAX_LIMIT) +if (typeof rawLimit !== 'string' || !/^\d+$/.test(rawLimit) || Number(rawLimit) < 1) { + die(`--limit inválido: ${flags.limit === true ? '(sem valor)' : rawLimit}`) +} +const limit = Math.min(Number(rawLimit), MAX_LIMIT) + +try { + if (flags.json) { + const viewModel = await loadViewModel({ limit, withPlans: true }) + console.log(JSON.stringify(toJsonPayload(viewModel), null, 2)) + if (viewModel.sessionWarning) console.error(`[issues:tui] ${viewModel.sessionWarning}`) + } else { + await runTui({ limit }) + } +} catch (error) { + if (error?.code === 'NO_TOKEN') { + die( + 'GITHUB_TOKEN não definido no ambiente.\nNada foi alterado. Defina o token e rode de novo:\n\n GITHUB_TOKEN=… pnpm issues:tui', + ) + } + die(error?.message ?? String(error)) +} diff --git a/scripts/lib/ansi.mjs b/scripts/lib/ansi.mjs new file mode 100644 index 00000000..d6a1f683 --- /dev/null +++ b/scripts/lib/ansi.mjs @@ -0,0 +1,131 @@ +/** + * OPS109 — helpers ANSI mínimos para o painel de issues (`pnpm issues:tui`). + * + * Puro by contract: só constantes de escape + operações de string (zero + * dependência, zero I/O). O painel roda num terminal SSH comum, então o + * conjunto é deliberadamente pequeno e conservador — cores funcionais de + * estado, negrito/dim e o controle de tela alternada. Nada de emoji ou + * glifos double-width: a largura é calculada por `visibleWidth`, que ignora + * as sequências CSI/SGR. + */ + +const ESC = '\u001b[' +const RESET = `${ESC}0m` + +/** SGR codes used by the panel. Names map to the UI draft's functional roles. */ +export const SGR = { + reset: RESET, + bold: `${ESC}1m`, + dim: `${ESC}2m`, + italic: `${ESC}3m`, + inverse: `${ESC}7m`, + red: `${ESC}31m`, + green: `${ESC}32m`, + yellow: `${ESC}33m`, + cyan: `${ESC}36m`, + gray: `${ESC}90m`, +} + +/** + * Runs of CSI escape sequences: `ESC [ … final-byte`. Covers SGR + * (`\u001b[0m`) and cursor moves (`\u001b[2J`), so width math never counts + * control bytes. Deliberately not a full terminal parser (no OSC/links). + */ +const CSI_PATTERN = /\u001b\[[0-9;?]*[ -/]*[@-~]/g + +/** + * Strip every CSI sequence — for measuring and for re-wrapping already + * styled text. + * @param {string} text + */ +export const stripAnsi = (text) => String(text ?? '').replace(CSI_PATTERN, '') + +/** + * Printable width in terminal cells. Conservative: counts code points, not + * grapheme clusters or East-Asian double-width (the panel avoids those), so + * the value equals “how many characters the eye sees”. + * @param {string} text + */ +export const visibleWidth = (text) => [...stripAnsi(text)].length + +/** + * Truncate to `width` visible columns, appending an ellipsis when it had to + * cut. The result is unstyled plain text (escape sequences are stripped): + * callers that need color apply it around the truncated value. + * @param {string} text + * @param {number} width + */ +export const truncateToWidth = (text, width) => { + const source = String(text ?? '') + const plain = stripAnsi(source) + const chars = [...plain] + const limit = Math.max(0, Math.trunc(width)) + if (chars.length <= limit) return source + if (limit === 0) return '' + return `${chars.slice(0, Math.max(0, limit - 1)).join('')}…` +} + +/** + * Pad a styled string with spaces up to `width` visible columns. When the + * text is longer it is truncated with `truncateToWidth`; padding happens + * AFTER the text so a background color covers the whole cell. + * @param {string} text + * @param {number} width + */ +export const padToWidth = (text, width) => { + const target = Math.max(0, Math.trunc(width)) + const clipped = visibleWidth(text) > target ? truncateToWidth(text, target) : String(text ?? '') + return `${clipped}${' '.repeat(Math.max(0, target - visibleWidth(clipped)))}` +} + +/** Enter the alternate screen buffer (restores the shell on leave). */ +export const enterAlternateScreen = () => `${ESC}?1049h` + +/** Leave the alternate screen buffer. */ +export const leaveAlternateScreen = () => `${ESC}?1049l` + +/** Hide the hardware cursor (during full redraws). */ +export const hideCursor = () => `${ESC}?25l` + +/** Show the hardware cursor. */ +export const showCursor = () => `${ESC}?25h` + +/** Move the cursor to a 1-based row/column (no clearing). */ +export const moveTo = (row, column) => `${ESC}${row};${column}H` + +/** Clear the whole screen and home the cursor. */ +export const clearScreen = () => `${ESC}2J${ESC}H` + +/** Erase from the cursor to the end of the current line. */ +export const clearLine = () => `${ESC}K` + +/** Current terminal size, with conservative defaults when not a TTY. */ +export const terminalSize = ({ columns, rows } = {}) => ({ + columns: Number.isInteger(columns) && columns > 0 ? columns : 80, + rows: Number.isInteger(rows) && rows > 0 ? rows : 24, +}) + +/** Wrap `text` in an SGR code and reset at the end (no-op on empty text). */ +export const style = (code, text) => (text ? `${code}${text}${RESET}` : '') + +/** Bold and dim shorthands over `style`. */ +export const bold = (text) => style(SGR.bold, text) +export const dim = (text) => style(SGR.dim, text) + +/** + * Severity → color for functional state reading (matches the UI draft): + * green = done/approved, yellow = waiting, red = blocked/error, blue/gray = + * neutral. Unknown states degrade to gray. + * @param {'ok'|'wait'|'bad'|'neutral'|string} tone + * @param {string} text + */ +export const tone = (toneName, text) => { + const code = { + ok: SGR.green, + wait: SGR.yellow, + bad: SGR.red, + accent: SGR.cyan, + neutral: SGR.gray, + }[toneName] + return style(code ?? SGR.gray, text) +} diff --git a/scripts/lib/issues-panel.mjs b/scripts/lib/issues-panel.mjs new file mode 100644 index 00000000..bdd3e656 --- /dev/null +++ b/scripts/lib/issues-panel.mjs @@ -0,0 +1,391 @@ +/** + * OPS109 — derivação pura do painel de issues (`pnpm issues:tui`). + * + * Todo o conhecimento do painel mora aqui: estado/ação da Issue, leitura do + * `Status:` dos planos, descoberta do plano de intenção/impl/rascunho UI e o + * casamento com a sessão do agente do OPS110. Puro por contrato — recebe os + * insumos (issues normalizadas do `github-api`, sessões do + * `agent-session list --json` e os planos já lidos do disco) e devolve o view + * model; o CLI só faz I/O e render. Reusa a camada existente sem twin: + * `labelNames`/`priorityRank`/`parseFrontmatter` (agent-forgejo), + * `extractPlanPath` (agent-pool-prompt) e `resolveSessionRef` (agent-session). + */ + +import { labelNames, parseFrontmatter, priorityRank } from './agent-forgejo.mjs' +import { extractPlanPath } from './agent-pool-prompt.mjs' +import { resolveSessionRef } from './agent-session.mjs' + +/** Issue state labels the panel reads (GitHub tracker labels). */ +const ISSUE_STATES = ['ready', 'in-progress', 'blocked', 'done', 'in-prod'] + +/** Filter keys of the list, mirroring the UI draft's `1-4` shortcut. */ +const FILTER_KEYS = ['all', 'in-progress', 'waiting', 'done'] + +/** Plan status → product classification (intent doc's assumed reading). */ +const CLASSIFICATIONS = [ + { kind: 'aprovado', prefixes: ['aprovado', 'aprovada'] }, + { + kind: 'andou', + prefixes: [ + 'em execução', + 'executado', + 'executada', + 'entregue', + 'implementado', + 'pronta', + 'pronto', + 'produção', + 'em produção', + ], + }, + { + kind: 'aguardando', + prefixes: ['rascunho', 'registrado', 'blocked', 'bloqueado', 'aguardando', 'pendente'], + }, +] + +/** + * Extract the `Status:` line of a plan, tolerating the real-world mess: + * `**bold**`, backticks, an em dash separator and parenthetical suffixes + * (`aprovado (gate humano 2026-09-15)`). Returns the raw value (always shown) + * plus a normalized lowercase form used for classification. + * @param {string} markdown + * @returns {{ raw: string, normalized: string } | null} + */ +export const parsePlanStatus = (markdown) => { + for (const line of String(markdown ?? '').split('\n')) { + const match = /^[\s>*-]*\*{0,2}Status:?\*{0,2}\s*(.+)$/i.exec(line.trim()) + if (!match) continue + const raw = match[1] + .replace(/`/g, '') + .replace(/\*{1,2}/g, '') + .replace(/\s+$/g, '') + .trim() + if (!raw) continue + return { raw, normalized: raw.toLowerCase() } + } + return null +} + +/** + * Classify a raw `Status:` value into the product reading. Unknown values + * fail safe to `desconhecido` — the panel shows the raw line and never + * invents “aprovado”. The messy real-world `plano — registrado (…)` variant + * is normalized by dropping the leading `plano` label before matching. + * @param {{ raw: string, normalized: string } | string | null} status + * @returns {'aprovado' | 'andou' | 'aguardando' | 'desconhecido'} + */ +export const classifyPlanStatus = (status) => { + const normalized = (typeof status === 'string' ? status : (status?.normalized ?? '')) + .toLowerCase() + .replace(/^plano\s*[—–-]\s*/u, '') + if (!normalized) return 'desconhecido' + const hit = CLASSIFICATIONS.find(({ prefixes }) => + prefixes.some((prefix) => normalized.startsWith(prefix)), + ) + return hit ? hit.kind : 'desconhecido' +} + +/** + * Sibling-file conventions of the plans (`docs/plans/.md`, + * `docs/plans/-impl.md`, `docs/plans/-ui-draft.html`). Given the + * intent path, derive the other two; the caller confirms existence on disk. + * @param {string | null} planPath + */ +export const siblingPlanPaths = (planPath) => { + const path = typeof planPath === 'string' ? planPath.trim() : '' + if (!/\.md$/.test(path)) return { impl: null, uiDraft: null } + const stem = path.replace(/\.md$/, '') + const base = stem.endsWith('-impl') ? stem.slice(0, -'-impl'.length) : stem + return { impl: `${base}-impl.md`, uiDraft: `${base}-ui-draft.html` } +} + +/** Which issue state label the panel shows (closed wins as `done`). */ +export const issueState = (issue) => { + const labels = labelNames(issue) + if (issue.state === 'CLOSED') { + return labels.includes('in-prod') ? 'in-prod' : 'done' + } + const stateLabel = labels.find((label) => ISSUE_STATES.includes(label)) + return stateLabel ?? 'open' +} + +/** Priority label → `P0..P3`, defaulting to P2 like the other CLIs. */ +const issuePriority = (issue) => + labelNames(issue) + .find((label) => /^prio:P[0-3]$/.test(label)) + ?.replace('prio:', '') ?? 'P2' + +/** Kind label → `feature` when absent. */ +const issueKind = (issue) => + labelNames(issue) + .find((label) => label.startsWith('kind:')) + ?.replace('kind:', '') ?? 'feature' + +/** Frontmatter `id` of an issue, or null (delegates to the shared parser). */ +export const issueCode = (issue) => { + const { meta } = parseFrontmatter(issue.body) + return typeof meta.id === 'string' && meta.id.length > 0 ? meta.id : null +} + +/** Frontmatter `depends` of an issue (ids), via the shared parser. */ +export const issueDepends = (issue) => { + const { meta } = parseFrontmatter(issue.body) + return Array.isArray(meta.depends) ? meta.depends : [] +} + +/** Body without the frontmatter block — the human summary. */ +export const issueSummary = (issue) => { + const { rest } = parseFrontmatter(issue.body) + return rest + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !/^Plano:/.test(line) && !/^#/.test(line)) + .slice(0, 3) + .join(' ') +} + +/** GitHub URL of the issue (the escape hatch when a plan is missing). */ +const issueUrl = (issue, repository = 'fsolla/teqo') => + `https://github.com/${repository}/issues/${issue.number}` + +/** + * The pending action of an issue, in operator terms. Priority: blocked dep + * wins (it cannot move), then a plan awaiting approval, then the execution + * label, else the plain state. + * @param {{ state: string, plan: { intention: { classification?: string } | null, impl: { classification?: string } | null }, blockedBy: string[] }} view + */ +export const issueAction = (view) => { + if (view.blockedBy.length > 0) return `travada (depends ${view.blockedBy.join(', ')})` + if (view.state === 'blocked') return 'travada' + if (view.state === 'in-progress') return 'em andamento' + const waiting = [view.plan.intention, view.plan.impl].some( + (plan) => plan && plan.classification === 'aguardando', + ) + if (waiting) return 'aguardando aprovação' + if (view.state === 'done' || view.state === 'in-prod') return 'concluída' + if (view.state === 'ready') return 'pronta' + return 'aberta' +} + +/** Short plan cell for the list column (UI draft's `plano`). */ +export const planLabel = (view) => { + const { intention, impl } = view.plan + if (impl?.exists) return `impl ${impl.status?.raw ?? impl.classification}` + if (intention?.exists) return `intenção: ${intention.status?.raw ?? intention.classification}` + if (impl?.path && !impl.exists) return 'impl não criado' + return 'sem plano' +} + +const planView = (kind, path, markdown) => { + if (!path || markdown === null || markdown === undefined) { + return { kind, path, exists: false, status: null, classification: path ? 'não criado' : 'none' } + } + const status = parsePlanStatus(markdown) + const classification = status ? classifyPlanStatus(status) : 'desconhecido' + return { + kind, + path, + exists: true, + status: status ? { raw: status.raw, classification } : null, + classification, + } +} + +/** + * Build one row of the panel from an issue and its already-read artifacts. + * `intention`/`impl` carry the file contents, with `null`/missing meaning + * “file does not exist”; `uiDraft` is the existence verdict (the CLI checks + * the sibling `.html`; the panel never reads its content). + * @param {object} issue normalized GitHub issue + * @param {{ intention?: string|null, impl?: string|null, uiDraft?: boolean, sessions?: Array, doneIds?: Set, knownIds?: Set, repository?: string }} [context] + */ +export const buildIssueRow = (issue, context = {}) => { + const { + intention = null, + impl = null, + uiDraft = false, + sessions = [], + doneIds = new Set(), + knownIds = new Set(), + repository, + } = context + const state = issueState(issue) + const planPath = extractPlanPath(issue.body) ?? null + const siblings = siblingPlanPaths(planPath) + const intentionPath = planPath ?? siblings.impl?.replace(/-impl\.md$/, '.md') ?? null + const implPath = planPath && /-impl\.md$/.test(planPath) ? planPath : siblings.impl + const uiDraftPath = siblings.uiDraft + + const plan = { + intention: planView('intention', intentionPath, intention), + impl: planView('impl', implPath, impl), + } + const depends = issueDepends(issue) + const blockedBy = depends.filter((id) => knownIds.has(id) && !doneIds.has(id)) + const session = resolveSessionRef({ states: sessions, issue: issue.number }) + + const view = { + number: issue.number, + code: issueCode(issue), + title: issue.title, + state, + priority: issuePriority(issue), + kind: issueKind(issue), + depends, + blockedBy, + summary: issueSummary(issue), + url: issueUrl(issue, repository), + plan, + uiDraft: { path: uiDraftPath, exists: Boolean(uiDraft) }, + session: session + ? { + status: session.status ?? 'unknown', + sessionID: session.sessionID, + branch: session.branch, + dir: session.dir, + issue: session.issue ?? null, + } + : null, + action: '', + } + view.action = issueAction(view) + return view +} + +/** + * Filter rows by the panel's filter key. Unknown keys behave like `all` + * (fail-open for reading, never hides data silently). + */ +export const filterByState = (rows, key) => { + const list = Array.isArray(rows) ? rows : [] + if (!FILTER_KEYS.includes(key) || key === 'all') return [...list] + if (key === 'in-progress') return list.filter((row) => row.state === 'in-progress') + if (key === 'waiting') return list.filter((row) => row.action === 'aguardando aprovação') + return list.filter((row) => row.state === 'done' || row.state === 'in-prod') +} + +/** Counts shown on the filter chips. */ +export const filterCounts = (rows) => ({ + all: rows.length, + 'in-progress': filterByState(rows, 'in-progress').length, + waiting: filterByState(rows, 'waiting').length, + done: filterByState(rows, 'done').length, +}) + +/** + * Sort panel rows: open work first (in-progress, waiting, ready), then + * blocked, then done; priority ascending inside each band, then issue number. + */ +export const sortRows = (rows) => { + const band = (state) => { + if (state === 'in-progress') return 0 + if (state === 'ready') return 1 + if (state === 'blocked' || state === 'open') return 2 + return 3 + } + return [...rows].sort((left, right) => { + const byBand = band(left.state) - band(right.state) + if (byBand !== 0) return byBand + const byPriority = priorityRank(left.priority) - priorityRank(right.priority) + if (byPriority !== 0) return byPriority + return left.number - right.number + }) +} + +/** + * Scroll window of the list: which slice of `count` rows fits the current + * cursor into `capacity` rows, keeping the cursor roughly centered. Pure so + * the narrow/wide math is unit-tested instead of discovered on a pty. + * @param {{ count: number, cursor: number, capacity: number }} input + */ +export const visibleWindow = ({ count, cursor, capacity }) => { + const size = Math.max(1, Math.trunc(capacity)) + if (count <= size) return { start: 0, end: Math.max(0, count) } + const clamped = Math.max(0, Math.min(cursor, count - 1)) + const start = Math.max(0, Math.min(clamped - Math.floor(size / 2), count - size)) + return { start, end: start + size } +} + +/** + * Assemble the full panel view model. Pure: the caller (CLI) does fetch, + * spawn and disk reads, then hands the raw inputs here. `limit` is the + * effective page size (GitHub caps `per_page` at 100 — the CLI clamps it). + * @param {{ issues: Array, sessions?: Array, plansByNumber?: Map, repository?: string, limit?: number }} input + */ +export const buildPanelViewModel = ({ + issues = [], + sessions = [], + plansByNumber = new Map(), + repository, + limit = 100, +} = {}) => { + const trackable = issues.filter((issue) => issueCode(issue)) + const knownIds = new Set(trackable.map((issue) => issueCode(issue))) + const doneIds = new Set( + trackable + .filter( + (issue) => issue.state === 'CLOSED' || ['done', 'in-prod'].includes(issueState(issue)), + ) + .map((issue) => issueCode(issue)), + ) + const rows = sortRows( + trackable.map((issue) => { + const plans = plansByNumber.get(issue.number) ?? {} + return buildIssueRow(issue, { + intention: plans.intention ?? null, + impl: plans.impl ?? null, + uiDraft: Boolean(plans.uiDraft), + sessions, + doneIds, + knownIds, + repository, + }) + }), + ) + return { + rows, + counts: filterCounts(rows), + truncated: rows.length >= limit, + limit, + } +} + +/** + * Render one issue as JSON-safe line for `--json` (already plain objects). + * Kept as a helper so the CLI contract is explicit about the shape. + * @param {object} viewModel + */ +export const toJsonPayload = (viewModel) => ({ + version: 1, + truncated: viewModel.truncated, + counts: viewModel.counts, + rows: viewModel.rows.map((row) => ({ + number: row.number, + code: row.code, + title: row.title, + state: row.state, + priority: row.priority, + kind: row.kind, + action: row.action, + depends: row.depends, + blockedBy: row.blockedBy, + url: row.url, + plan: { + intention: { + path: row.plan.intention.path, + exists: row.plan.intention.exists, + classification: row.plan.intention.classification, + status: row.plan.intention.status?.raw ?? null, + }, + impl: { + path: row.plan.impl.path, + exists: row.plan.impl.exists, + classification: row.plan.impl.classification, + status: row.plan.impl.status?.raw ?? null, + }, + }, + uiDraft: { path: row.uiDraft.path, exists: row.uiDraft.exists }, + session: row.session, + })), +}) diff --git a/scripts/lib/markdown-ansi.mjs b/scripts/lib/markdown-ansi.mjs new file mode 100644 index 00000000..4f06a223 --- /dev/null +++ b/scripts/lib/markdown-ansi.mjs @@ -0,0 +1,203 @@ +/** + * OPS109 — render de markdown para ANSI (subconjunto deliberado). + * + * Puro: recebe o markdown e devolve `string[]` (uma linha renderizada por + * item), sem escrever no terminal — o leitor rola o array. Cobre o que o + * aceite pede: títulos `#`–`######`, listas `-`/`1.`, tabelas `|…|`, blocos + * ``` ``` ```, `**negrito**`/`*itálico*`, código inline e citação `>`. + * + * O que fica de fora passa **cru** (nunca some): a regra é nunca perder + * conteúdo em troca de estética. Zero dependência — `react-markdown`/`remark` + * são de browser e não servem num terminal. + */ + +import { bold, dim, padToWidth, SGR, style, truncateToWidth, visibleWidth } from './ansi.mjs' + +/** Fallback reading width when the caller has no terminal measurement. */ +const DEFAULT_READER_WIDTH = 80 + +/** + * Inline spans: `**bold**`, `*italic*`, `` `code` `` and `[text](url)`. + * Order matters — bold before italic so `**` wins. Unmatched markers stay + * literal (each regex only matches paired runs). The callback form keeps `$` + * sequences in the content from being reinterpreted by `String.replace`. + * @param {string} line + */ +export const renderInline = (line) => { + let output = String(line ?? '') + output = output.replace(/\*\*([^*\n]+)\*\*/g, (_match, text) => bold(text)) + output = output.replace(/__([^_\n]+)__/g, (_match, text) => bold(text)) + output = output.replace(/`([^`\n]+)`/g, (_match, text) => style(SGR.cyan, text)) + output = output.replace(/\*([^*\n]+)\*/g, (_match, text) => style(SGR.italic, text)) + output = output.replace( + /\[([^\]\n]+)\]\(([^)\n]+)\)/g, + (_match, text, url) => `${text} ${dim(`(${url})`)}`, + ) + return output +} + +const HEADING = /^(#{1,6})\s+(.*)$/ +const LIST_ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/ +const QUOTE = /^>\s?(.*)$/ +const FENCE = /^```(.*)$/ +const TABLE_ROW = /^\s*\|(.+)\|\s*$/ +const TABLE_DIVIDER = /^\s*\|?[\s:|-]+\|?\s*$/ + +const headingStyle = (depth, text) => { + const rendered = renderInline(text) + if (depth <= 1) return bold(rendered.toUpperCase()) + return bold(rendered) +} + +/** + * Expand tab stops for stable width math (tab = 2 at the panel's density). + * @param {string} text + */ +const expandTabs = (text) => String(text ?? '').replace(/\t/g, ' ') + +/** + * Render a markdown table block. Cells are padded to the widest visible cell + * and truncated to the reader width; the divider row is emitted in dim gray. + * @param {string[]} rows + * @param {number} width + */ +const renderTable = (rows, width) => { + const parsed = rows.map((row) => + row + .trim() + .replace(/^\||\|$/g, '') + .split('|') + .map((cell) => cell.trim()), + ) + const dividerIndex = rows.findIndex((row) => TABLE_DIVIDER.test(row)) + const columnCount = Math.max(...parsed.map((cells) => cells.length)) + const columnWidths = Array.from({ length: columnCount }, (_, index) => + Math.max(...parsed.map((cells) => visibleWidth(cells[index] ?? ''))), + ) + const lines = [] + for (const [rowIndex, cells] of parsed.entries()) { + if (rowIndex === dividerIndex) { + const rule = columnWidths.map((columnWidth) => '-'.repeat(columnWidth)).join('--') + lines.push(dim(truncateToWidth(rule, width))) + continue + } + const renderedCells = Array.from({ length: columnCount }, (_, index) => + padToWidth(renderInline(cells[index] ?? ''), columnWidths[index]), + ) + lines.push(truncateToWidth(renderedCells.join(' ').replace(/\s+$/, ''), width)) + } + return lines +} + +/** + * Render markdown into an array of terminal lines. + * @param {string} markdown + * @param {{ width?: number }} [options] + * @returns {string[]} + */ +export const renderMarkdown = (markdown, { width = DEFAULT_READER_WIDTH } = {}) => { + const lines = String(markdown ?? '').split('\n') + const output = [] + let inFence = false + let tableBuffer = [] + + const flushTable = () => { + if (tableBuffer.length === 0) return + for (const line of renderTable(tableBuffer, width)) output.push(line) + tableBuffer = [] + } + + for (const rawLine of lines) { + const line = expandTabs(rawLine) + + const fence = FENCE.exec(line) + if (fence) { + flushTable() + if (inFence) { + output.push(dim('```')) + inFence = false + } else { + output.push(dim(`\`\`\`${fence[1]}`.trimEnd())) + inFence = true + } + continue + } + if (inFence) { + output.push(dim(truncateToWidth(line, width))) + continue + } + + if (TABLE_ROW.test(line)) { + tableBuffer.push(line) + continue + } + if (tableBuffer.length > 0) flushTable() + + if (line.trim() === '') { + output.push('') + continue + } + + const heading = HEADING.exec(line) + if (heading) { + output.push(truncateToWidth(headingStyle(heading[1].length, heading[2]), width)) + continue + } + + const quote = QUOTE.exec(line) + if (quote) { + output.push( + truncateToWidth(`${dim('│')} ${style(SGR.italic, renderInline(quote[1]))}`, width), + ) + continue + } + + const list = LIST_ITEM.exec(line) + if (list) { + const [, indent, marker, content] = list + const bullet = /^\d/.test(marker) ? marker : '•' + const prefix = `${indent}${bullet} ` + const wrapped = wrapInline(renderInline(content), Math.max(8, width - visibleWidth(prefix))) + wrapped.forEach((piece, index) => + output.push( + index === 0 ? `${prefix}${piece}` : `${' '.repeat(visibleWidth(prefix))}${piece}`, + ), + ) + continue + } + + const wrapped = wrapInline(renderInline(line), width) + for (const piece of wrapped) output.push(piece) + } + + if (inFence) output.push(dim('```')) + flushTable() + return output +} + +/** + * Word-wrap a styled string preserving its escape sequences. Splits on + * spaces (visible), never breaks inside a style run mid-word unless the word + * alone exceeds the width. + * @param {string} text + * @param {number} width + */ +export const wrapInline = (text, width) => { + const plain = String(text ?? '') + if (width <= 0) return [plain] + if (visibleWidth(plain) <= width) return [plain] + const words = plain.split(' ') + const lines = [] + let current = '' + for (const word of words) { + const candidate = current ? `${current} ${word}` : word + if (visibleWidth(candidate) <= width) { + current = candidate + continue + } + if (current) lines.push(current) + current = visibleWidth(word) > width ? truncateToWidth(word, width) : word + } + if (current) lines.push(current) + return lines.length > 0 ? lines : [''] +} diff --git a/tests/unit/issuesPanel.unit.spec.ts b/tests/unit/issuesPanel.unit.spec.ts new file mode 100644 index 00000000..1aade858 --- /dev/null +++ b/tests/unit/issuesPanel.unit.spec.ts @@ -0,0 +1,294 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { + buildIssueRow, + buildPanelViewModel, + classifyPlanStatus, + filterByState, + filterCounts, + issueAction, + issueCode, + issueDepends, + issueState, + issueSummary, + parsePlanStatus, + planLabel, + siblingPlanPaths, + sortRows, + toJsonPayload, +} from '../../scripts/lib/issues-panel.mjs' + +const issue = (over: Record = {}) => ({ + number: 1020, + title: 'OPS109 — Painel de issues e planos no terminal (via SSH)', + body: [ + '---', + 'id: OPS109', + 'depends: [OPS110]', + 'priority: P2', + '---', + 'Plano: [`docs/plans/ops109-painel-issues-no-terminal.md`](docs/plans/ops109-painel-issues-no-terminal.md)', + '', + 'Ferramenta local para visualizar o andamento.', + ].join('\n'), + state: 'OPEN', + createdAt: '2026-09-15T10:00:00.000Z', + labels: [ + { name: 'in-progress', color: '' }, + { name: 'prio:P2', color: '' }, + { name: 'kind:feature', color: '' }, + ], + ...over, +}) + +describe('parsePlanStatus (OPS109)', () => { + it('reads the raw and normalized Status line', () => { + expect(parsePlanStatus('# T\n\nStatus: aprovado\n')).toEqual({ + raw: 'aprovado', + normalized: 'aprovado', + }) + }) + + it('strips bold, backticks and parenthetical suffixes', () => { + expect(parsePlanStatus('Status: **entregue** (2026-07-29)')?.raw).toBe('entregue (2026-07-29)') + expect(parsePlanStatus('Status: `aprovado (gate humano 2026-09-10)`')?.raw).toBe( + 'aprovado (gate humano 2026-09-10)', + ) + }) + + it('returns null when there is no Status line', () => { + expect(parsePlanStatus('# só título')).toBeNull() + expect(parsePlanStatus('')).toBeNull() + }) +}) + +describe('classifyPlanStatus (OPS109 — leitura de produto)', () => { + it.each([ + ['aprovado', 'aprovado'], + ['aprovado (gate humano 2026-09-10)', 'aprovado'], + ['rascunho', 'aguardando'], + ['registrado', 'aguardando'], + ['blocked', 'aguardando'], + ['em execução', 'andou'], + ['executado', 'andou'], + ['entregue', 'andou'], + ['entregue (2026-07-29)', 'andou'], + ['plano — registrado (blocked até plano em main)', 'aguardando'], + ['qualquer coisa nova', 'desconhecido'], + ])('%s → %s', (raw, expected) => { + expect(classifyPlanStatus({ raw, normalized: raw.toLowerCase() })).toBe(expected) + }) + + it('fails safe to desconhecido when there is no status', () => { + expect(classifyPlanStatus(null)).toBe('desconhecido') + expect(classifyPlanStatus({ raw: '', normalized: '' })).toBe('desconhecido') + }) +}) + +describe('siblingPlanPaths (OPS109)', () => { + it('derives impl and ui-draft from the intent path', () => { + expect(siblingPlanPaths('docs/plans/ops109-x.md')).toEqual({ + impl: 'docs/plans/ops109-x-impl.md', + uiDraft: 'docs/plans/ops109-x-ui-draft.html', + }) + }) + + it('does not double-suffix when given the impl path', () => { + expect(siblingPlanPaths('docs/plans/ops109-x-impl.md').impl).toBe('docs/plans/ops109-x-impl.md') + }) + + it('returns nulls for a non-markdown path', () => { + expect(siblingPlanPaths(null)).toEqual({ impl: null, uiDraft: null }) + expect(siblingPlanPaths('docs/plans/x.html')).toEqual({ impl: null, uiDraft: null }) + }) +}) + +describe('issue field derivation (OPS109)', () => { + it('reads the frontmatter id and depends', () => { + expect(issueCode(issue())).toBe('OPS109') + expect(issueDepends(issue())).toEqual(['OPS110']) + }) + + it('derives state/priority/kind from labels', () => { + expect(issueState(issue())).toBe('in-progress') + expect(issueState(issue({ state: 'CLOSED' }))).toBe('done') + expect(issueState(issue({ labels: [{ name: 'ready', color: '' }] }))).toBe('ready') + }) + + it('summarizes the body after the frontmatter and plan link', () => { + expect(issueSummary(issue())).toBe('Ferramenta local para visualizar o andamento.') + }) + + it('returns null id and empty depends for an id-less body', () => { + const bare = issue({ body: 'sem frontmatter' }) + expect(issueCode(bare)).toBeNull() + expect(issueDepends(bare)).toEqual([]) + }) +}) + +describe('buildIssueRow (OPS109)', () => { + const deps = { + doneIds: new Set(), + knownIds: new Set(['OPS110']), + } + + it('marks a plan present with the classification', () => { + const row = buildIssueRow(issue(), { + ...deps, + intention: 'Status: aprovado\n', + impl: 'Status: em execução\n', + }) + expect(row.plan.intention).toMatchObject({ + path: 'docs/plans/ops109-painel-issues-no-terminal.md', + exists: true, + classification: 'aprovado', + }) + expect(row.plan.impl).toMatchObject({ + path: 'docs/plans/ops109-painel-issues-no-terminal-impl.md', + exists: true, + classification: 'andou', + }) + expect(row.uiDraft).toEqual({ + path: 'docs/plans/ops109-painel-issues-no-terminal-ui-draft.html', + exists: false, + }) + }) + + it('explains missing intention and impl as first-class states', () => { + const row = buildIssueRow(issue(), { ...deps, intention: null, impl: null }) + expect(row.plan.intention).toMatchObject({ exists: false, classification: 'não criado' }) + expect(row.plan.impl).toMatchObject({ exists: false, classification: 'não criado' }) + expect(planLabel({ plan: { intention: null, impl: null } })).toBe('sem plano') + }) + + it('reports sem plano when the body has no plan link', () => { + const row = buildIssueRow(issue({ body: '---\nid: OPS999\n---\nsem plano' }), deps) + expect(row.plan.intention.path).toBeNull() + expect(row.plan.intention.exists).toBe(false) + }) + + it('ties the session by issue number and falls back to none', () => { + const sessions = [ + { sessionID: 'ses_abc', issue: 1020, branch: 'OPS109-x', dir: '/tmp/x', status: 'working' }, + { sessionID: 'ses_def', issue: 9999, branch: 'OPS999-y', dir: '/tmp/y', status: 'idle' }, + ] + const withSession = buildIssueRow(issue(), { ...deps, sessions }) + expect(withSession.session).toMatchObject({ status: 'working', sessionID: 'ses_abc' }) + + const withoutSession = buildIssueRow(issue({ number: 4242 }), { ...deps, sessions }) + expect(withoutSession.session).toBeNull() + }) +}) + +describe('issueAction (OPS109)', () => { + const base = { + state: 'ready', + plan: { intention: null, impl: null }, + blockedBy: [], + } + + it('prefers the blocked dependency reason', () => { + expect(issueAction({ ...base, blockedBy: ['OPS111'] })).toBe('travada (depends OPS111)') + }) + + it('reads in-progress, waiting, done and ready', () => { + expect(issueAction({ ...base, state: 'in-progress' })).toBe('em andamento') + expect( + issueAction({ + ...base, + plan: { intention: { classification: 'aguardando' }, impl: null }, + }), + ).toBe('aguardando aprovação') + expect(issueAction({ ...base, state: 'done' })).toBe('concluída') + expect(issueAction({ ...base, state: 'ready' })).toBe('pronta') + }) + + it('does not treat em execução as waiting', () => { + expect( + issueAction({ + ...base, + state: 'blocked', + plan: { intention: { classification: 'andou' }, impl: { classification: 'andou' } }, + }), + ).toBe('travada') + }) +}) + +describe('filterByState / sortRows / filterCounts (OPS109)', () => { + const rows: { number: number; code: string; state: string; priority: string; action: string }[] = + [ + { number: 1, code: 'A', state: 'in-progress', priority: 'P1', action: 'em andamento' }, + { number: 2, code: 'B', state: 'ready', priority: 'P0', action: 'aguardando aprovação' }, + { number: 3, code: 'C', state: 'done', priority: 'P2', action: 'concluída' }, + { number: 4, code: 'D', state: 'blocked', priority: 'P2', action: 'travada' }, + ] + + it('filters by the four keys and fails open on unknown', () => { + expect(filterByState(rows, 'all').length).toBe(4) + expect(filterByState(rows, 'in-progress').map((row) => row.number)).toEqual([1]) + expect(filterByState(rows, 'waiting').map((row) => row.number)).toEqual([2]) + expect(filterByState(rows, 'done').map((row) => row.number)).toEqual([3]) + expect(filterByState(rows, 'nonsense').length).toBe(4) + }) + + it('counts each band', () => { + expect(filterCounts(rows)).toEqual({ all: 4, 'in-progress': 1, waiting: 1, done: 1 }) + }) + + it('orders open work before blocked before done', () => { + expect(sortRows(rows).map((row) => row.number)).toEqual([1, 2, 4, 3]) + }) +}) + +describe('buildPanelViewModel / toJsonPayload (OPS109)', () => { + const issues = [ + issue(), + issue({ + number: 1019, + state: 'CLOSED', + labels: [ + { name: 'done', color: '' }, + { name: 'in-prod', color: '' }, + { name: 'prio:P2', color: '' }, + ], + body: '---\nid: OPS110\n---\nPlano: [`docs/plans/ops110.md`](docs/plans/ops110.md)', + }), + ] + const plansByNumber = new Map([ + [ + 1020, + { + intention: 'Status: aprovado\n', + impl: 'Status: em execução\n', + uiDraft: true, + }, + ], + ]) + + it('assembles rows, counts and the truncation flag', () => { + const model = buildPanelViewModel({ issues, sessions: [], plansByNumber, limit: 200 }) + expect(model.rows.map((row) => row.code)).toEqual(['OPS109', 'OPS110']) + expect(model.truncated).toBe(false) + expect(model.counts.all).toBe(2) + expect(model.counts['in-progress']).toBe(1) + }) + + it('flags truncation when the page fills the limit', () => { + const model = buildPanelViewModel({ issues, limit: 2 }) + expect(model.truncated).toBe(true) + }) + + it('produces a JSON-safe payload with both plans', () => { + const model = buildPanelViewModel({ issues, plansByNumber, limit: 200 }) + const payload = toJsonPayload(model) + expect(payload.version).toBe(1) + const row = payload.rows.find((entry: { code?: string }) => entry.code === 'OPS109') + expect(row.plan.intention).toMatchObject({ classification: 'aprovado', status: 'aprovado' }) + expect(row.plan.impl).toMatchObject({ classification: 'andou', status: 'em execução' }) + expect(row.uiDraft.exists).toBe(true) + expect(row.session).toBeNull() + expect(JSON.parse(JSON.stringify(payload))).toEqual(payload) + }) +}) diff --git a/tests/unit/issuesPanelAnsi.unit.spec.ts b/tests/unit/issuesPanelAnsi.unit.spec.ts new file mode 100644 index 00000000..c7faae78 --- /dev/null +++ b/tests/unit/issuesPanelAnsi.unit.spec.ts @@ -0,0 +1,66 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { + clearScreen, + enterAlternateScreen, + leaveAlternateScreen, + padToWidth, + SGR, + stripAnsi, + style, + terminalSize, + tone, + truncateToWidth, + visibleWidth, +} from '../../scripts/lib/ansi.mjs' + +describe('ansi (OPS109)', () => { + it('strips SGR sequences when measuring', () => { + const styled = style(SGR.bold, 'olá') + expect(stripAnsi(styled)).toBe('olá') + expect(visibleWidth(styled)).toBe(3) + expect(visibleWidth('olá')).toBe(3) + }) + + it('keeps plain text untouched', () => { + expect(stripAnsi('sem escape')).toBe('sem escape') + }) + + it('truncates with an ellipsis at the visible limit', () => { + expect(truncateToWidth('abcdef', 4)).toBe('abc…') + expect(truncateToWidth('abc', 4)).toBe('abc') + expect(truncateToWidth('abc', 0)).toBe('') + }) + + it('measures width ignoring escapes when truncating', () => { + const styled = `${SGR.bold}abcdef${SGR.reset}` + expect(stripAnsi(truncateToWidth(styled, 3))).toBe('ab…') + }) + + it('pads to the target width after the text', () => { + expect(padToWidth('ab', 4)).toBe('ab ') + expect(stripAnsi(padToWidth(`${SGR.bold}ab`, 4))).toBe('ab ') + expect(visibleWidth(padToWidth('abcdef', 4))).toBe(4) + }) + + it('emits the alternate-screen enter/leave pair and clear', () => { + expect(enterAlternateScreen()).toBe('\u001b[?1049h') + expect(leaveAlternateScreen()).toBe('\u001b[?1049l') + expect(clearScreen()).toBe('\u001b[2J\u001b[H') + }) + + it('falls back to 80x24 when the size is unknown', () => { + expect(terminalSize({})).toEqual({ columns: 80, rows: 24 }) + expect(terminalSize({ columns: 0, rows: Number.NaN })).toEqual({ columns: 80, rows: 24 }) + expect(terminalSize({ columns: 46, rows: 20 })).toEqual({ columns: 46, rows: 20 }) + }) + + it('styles known tones and degrades unknown ones to gray', () => { + expect(tone('ok', 'x')).toBe(style(SGR.green, 'x')) + expect(tone('wait', 'x')).toBe(style(SGR.yellow, 'x')) + expect(tone('bad', 'x')).toBe(style(SGR.red, 'x')) + expect(tone('whatever', 'x')).toBe(style(SGR.gray, 'x')) + }) +}) diff --git a/tests/unit/issuesPanelMarkdown.unit.spec.ts b/tests/unit/issuesPanelMarkdown.unit.spec.ts new file mode 100644 index 00000000..c81862c7 --- /dev/null +++ b/tests/unit/issuesPanelMarkdown.unit.spec.ts @@ -0,0 +1,64 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { stripAnsi, visibleWidth } from '../../scripts/lib/ansi.mjs' +import { renderInline, renderMarkdown, wrapInline } from '../../scripts/lib/markdown-ansi.mjs' + +const plain = (lines: string[]) => lines.map((line) => stripAnsi(line)) + +describe('markdown-ansi (OPS109)', () => { + it('renders headings with hierarchy (h1 uppercase, h2 bold)', () => { + const lines = renderMarkdown('# Título\n\n## Seção\n\n### Sub') + expect(plain(lines)[0]).toBe('TÍTULO') + expect(plain(lines)[2]).toBe('Seção') + expect(plain(lines)[4]).toBe('Sub') + }) + + it('renders inline bold, code, italic and links', () => { + expect(plain([renderInline('com **negrito** aqui')])).toEqual(['com negrito aqui']) + expect(plain([renderInline('use `pnpm gate` agora')])).toEqual(['use pnpm gate agora']) + expect(plain([renderInline('um *itálico* só')])).toEqual(['um itálico só']) + expect(plain([renderInline('veja [plano](docs/x.md)')])).toEqual(['veja plano (docs/x.md)']) + }) + + it('renders unordered and ordered lists with wrapping indent', () => { + const lines = plain(renderMarkdown('- um\n- dois\n\n1. primeiro\n2. segundo')) + expect(lines).toEqual(['• um', '• dois', '', '1. primeiro', '2. segundo']) + }) + + it('renders tables with aligned columns and a dim divider', () => { + const lines = renderMarkdown('| A | B |\n|---|---|\n| x | y |') + expect(plain(lines)).toEqual(['A B', '--------', 'x y']) + }) + + it('renders fenced code dimmed and preserves content', () => { + const lines = plain(renderMarkdown('```text\nfluxo → outcome\n```')) + expect(lines).toEqual(['```text', 'fluxo → outcome', '```']) + }) + + it('renders blockquotes with a left bar', () => { + expect(plain(renderMarkdown('> citação de exemplo'))).toEqual(['│ citação de exemplo']) + }) + + it('passes unknown lines through untouched (never loses content)', () => { + expect(plain(renderMarkdown('linha || esquisita ***'))).toEqual(['linha || esquisita ***']) + }) + + it('closes an unterminated fence at the end', () => { + const lines = plain(renderMarkdown('```\naberto')) + expect(lines[0]).toBe('```') + expect(lines[1]).toBe('aberto') + expect(lines[2]).toBe('```') + }) + + it('wraps long lines to the reader width without breaking words', () => { + const lines = renderMarkdown('palavra '.repeat(12).trim(), { width: 20 }) + expect(lines.length).toBeGreaterThan(1) + for (const line of lines) expect(visibleWidth(line)).toBeLessThanOrEqual(20) + }) + + it('truncates a single word longer than the width', () => { + expect(wrapInline('x'.repeat(50), 10)).toEqual(['xxxxxxxxx…']) + }) +}) diff --git a/tests/unit/issuesPanelRegression.unit.spec.ts b/tests/unit/issuesPanelRegression.unit.spec.ts new file mode 100644 index 00000000..17ff64b9 --- /dev/null +++ b/tests/unit/issuesPanelRegression.unit.spec.ts @@ -0,0 +1,161 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { + buildPanelViewModel, + issueAction, + issueState, + planLabel, + toJsonPayload, + visibleWindow, +} from '../../scripts/lib/issues-panel.mjs' + +const issue = (over: Record = {}) => ({ + number: 999, + title: 'Test', + body: '---\nid: TEST999\n---\nPlano: [`docs/plans/test999.md`](docs/plans/test999.md)\n', + state: 'OPEN', + createdAt: '2026-09-15T10:00:00.000Z', + labels: [{ name: 'ready', color: '' }], + ...over, +}) + +describe('planLabel (OPS109)', () => { + it('says sem plano when both plans are null', () => { + expect(planLabel({ plan: { intention: null, impl: null } })).toBe('sem plano') + }) + + it('says sem plano when intention path is null and impl path is null', () => { + expect( + planLabel({ + plan: { + intention: { path: null, exists: false, classification: 'none' }, + impl: { path: null, exists: false, classification: 'none' }, + }, + }), + ).toBe('sem plano') + }) + + it('shows the intention status when the intention exists but impl is missing', () => { + expect( + planLabel({ + plan: { + intention: { + path: 'docs/plans/test.md', + exists: true, + classification: 'aprovado', + status: { raw: 'aprovado', classification: 'aprovado' }, + }, + impl: { + path: 'docs/plans/test-impl.md', + exists: false, + classification: 'não criado', + status: null, + }, + }, + }), + ).toBe('intenção: aprovado') + }) + + it('prefers the impl when it exists', () => { + expect( + planLabel({ + plan: { + intention: { + path: 'docs/plans/test.md', + exists: true, + classification: 'aprovado', + status: { raw: 'aprovado', classification: 'aprovado' }, + }, + impl: { + path: 'docs/plans/test-impl.md', + exists: true, + classification: 'andou', + status: { raw: 'em execução', classification: 'andou' }, + }, + }, + }), + ).toBe('impl em execução') + }) +}) + +describe('issueAction (OPS109 — blocked wins)', () => { + it('says travada when blocked label is present even if plan is aguardando', () => { + expect( + issueAction({ + state: 'blocked', + plan: { intention: { classification: 'aguardando' }, impl: null }, + blockedBy: [], + }), + ).toBe('travada') + }) +}) + +describe('issueState (OPS109 — closed issues)', () => { + it('returns done for closed issues even when the label still says in-progress', () => { + expect( + issueState(issue({ state: 'CLOSED', labels: [{ name: 'in-progress', color: '' }] })), + ).toBe('done') + }) + + it('returns in-prod for closed in-prod issues', () => { + expect(issueState(issue({ state: 'CLOSED', labels: [{ name: 'in-prod', color: '' }] }))).toBe( + 'in-prod', + ) + }) +}) + +describe('visibleWindow (OPS109 — list scroll math)', () => { + it('returns the whole list when it fits', () => { + expect(visibleWindow({ count: 7, cursor: 3, capacity: 10 })).toEqual({ start: 0, end: 7 }) + }) + + it('keeps the cursor centered in a long list', () => { + expect(visibleWindow({ count: 100, cursor: 50, capacity: 10 })).toEqual({ + start: 45, + end: 55, + }) + }) + + it('clamps at the top and the bottom', () => { + expect(visibleWindow({ count: 100, cursor: 0, capacity: 10 })).toEqual({ start: 0, end: 10 }) + expect(visibleWindow({ count: 100, cursor: 99, capacity: 10 })).toEqual({ + start: 90, + end: 100, + }) + }) + + it('never slices the cursor out at the window edges', () => { + const { start, end } = visibleWindow({ count: 23, cursor: 22, capacity: 4 }) + expect(start).toBeLessThanOrEqual(22) + expect(end).toBeGreaterThan(22) + expect(end - start).toBe(4) + }) + + it('degrades to a single row for an empty or zero capacity', () => { + expect(visibleWindow({ count: 0, cursor: 0, capacity: 0 })).toEqual({ start: 0, end: 0 }) + expect(visibleWindow({ count: 5, cursor: 2, capacity: 0 })).toEqual({ start: 2, end: 3 }) + }) +}) + +describe('toJsonPayload / buildPanelViewModel (OPS109 — no regressions)', () => { + it('marks a missing impl as não criado in the JSON contract', () => { + const model = buildPanelViewModel({ + issues: [ + issue({ + number: 999, + body: '---\nid: TEST999\n---\nPlano: [`docs/plans/test999.md`](docs/plans/test999.md)', + }), + ], + plansByNumber: new Map([ + [999, { intention: 'Status: aprovado\n', impl: null, uiDraft: false }], + ]), + limit: 200, + }) + const payload = toJsonPayload(model) + const row = payload.rows[0] + expect(row.plan.intention).toMatchObject({ classification: 'aprovado', status: 'aprovado' }) + expect(row.plan.impl).toMatchObject({ exists: false, classification: 'não criado' }) + }) +})