mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(OPS109): painel de issues e planos no terminal (via SSH)
This commit is contained in:
parent
9e00de36c9
commit
c2ee60dd6a
12 changed files with 2214 additions and 2 deletions
5
docs/changelog/2026-09-15-ops109.md
Normal file
5
docs/changelog/2026-09-15-ops109.md
Normal file
|
|
@ -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
|
||||
176
docs/plans/ops109-painel-issues-no-terminal-impl.md
Normal file
176
docs/plans/ops109-painel-issues-no-terminal-impl.md
Normal file
|
|
@ -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 (`<slug>-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 <data>)`, `**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<br/>state=all limit=200] --> VM
|
||||
SESS[agent-session list --json<br/>spawn do contrato OPS110] --> VM
|
||||
FS[docs/plans/*.md<br/>readFileSync] --> PARSER[parsePlanStatus<br/>parsePlanLinks]
|
||||
end
|
||||
VM[buildPanelViewModel<br/>puro: estado + ação + planos + sessão] --> ROWS[rows filtradas]
|
||||
PARSER --> VM
|
||||
ROWS --> TUI[TUI ANSI<br/>alternate screen + raw mode]
|
||||
TUI --> LIST[lista: estado·id·título·plano·prio<br/>filtro 1-4]
|
||||
TUI --> DETAIL[detalhe: resumo + planos<br/>+ ações w/s/o]
|
||||
DETAIL --> READER[leitor markdown<br/>render ANSI à mão]
|
||||
DETAIL -- w --> BROWSER[xdg-open/open<br/>fallback: imprime caminho]
|
||||
DETAIL -- s --> ATTACH[spawn agent-session attach --issue=N]
|
||||
LIST -- --json --> JSON[view model JSON<br/>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 `<slug>-impl.md` e `<slug>-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 <data>)`, `**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 `<slug>-impl.md` e `<slug>-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/<slug>.md\`](…)`), e a intenção registra a convenção irmã: `-impl.md`(com back-link`Intenção: docs/plans/<slug>.md`) e `-ui-draft.html`. `extractPlanPath`já casa o link real; o impl/rascunho se derivam do mesmo`<slug>`. 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/<data>-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).
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
720
scripts/issues-tui.mjs
Normal file
720
scripts/issues-tui.mjs
Normal file
|
|
@ -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))
|
||||
}
|
||||
131
scripts/lib/ansi.mjs
Normal file
131
scripts/lib/ansi.mjs
Normal file
|
|
@ -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)
|
||||
}
|
||||
391
scripts/lib/issues-panel.mjs
Normal file
391
scripts/lib/issues-panel.mjs
Normal file
|
|
@ -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/<slug>.md`,
|
||||
* `docs/plans/<slug>-impl.md`, `docs/plans/<slug>-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<object>, doneIds?: Set<string>, knownIds?: Set<string>, 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<object>, sessions?: Array<object>, plansByNumber?: Map<number, { intention?: string|null, impl?: string|null, uiDraft?: boolean }>, 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,
|
||||
})),
|
||||
})
|
||||
203
scripts/lib/markdown-ansi.mjs
Normal file
203
scripts/lib/markdown-ansi.mjs
Normal file
|
|
@ -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 : ['']
|
||||
}
|
||||
294
tests/unit/issuesPanel.unit.spec.ts
Normal file
294
tests/unit/issuesPanel.unit.spec.ts
Normal file
|
|
@ -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<string, unknown> = {}) => ({
|
||||
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<string>(),
|
||||
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)
|
||||
})
|
||||
})
|
||||
66
tests/unit/issuesPanelAnsi.unit.spec.ts
Normal file
66
tests/unit/issuesPanelAnsi.unit.spec.ts
Normal file
|
|
@ -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'))
|
||||
})
|
||||
})
|
||||
64
tests/unit/issuesPanelMarkdown.unit.spec.ts
Normal file
64
tests/unit/issuesPanelMarkdown.unit.spec.ts
Normal file
|
|
@ -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…'])
|
||||
})
|
||||
})
|
||||
161
tests/unit/issuesPanelRegression.unit.spec.ts
Normal file
161
tests/unit/issuesPanelRegression.unit.spec.ts
Normal file
|
|
@ -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<string, unknown> = {}) => ({
|
||||
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' })
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue