teqo-backup/scripts/lib/issues-panel.mjs
Francisco Solla ef2df24a80 feat(OPS116): painel aceita -ui-design.html com retrocompat -ui-draft.html (Closes #1059)
O painel `pnpm issues:tui` volta a enxergar o design UI depois do rename do
OPS114: `siblingPlanPaths` deriva `-ui-design.html` (vigente) e
`-ui-draft.html` (legado) e o novo `resolveUiDraft(planPath, exists)` — puro,
com a existência injetada pelo CLI — é dono da precedência (design vence,
draft é fallback, nenhum → design com `exists:false`). O `readPlans` sonda
design→draft no disco e o caminho REAL flui por `plansByNumber`/`buildIssueRow`
até o view model; o contrato `--json` segue `version: 1` com o shape
`{ path, exists }` e a chave histórica `uiDraft`. As superfícies do painel
trocam "rascunho UI" por "design UI".

Retrocompat é só de leitura — o acervo legado (30 `*-ui-draft.html`) não é
tocado. Unit dos dois specs cobre design-only, draft-only, ambos, nenhum, o
caminho real, body linkando o impl e o shape do JSON. Sem migration.
2026-09-16 01:14:21 -03:00

423 lines
16 KiB
JavaScript

/**
* 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/design 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-design.html`,
* `docs/plans/<slug>-ui-draft.html`). Given the intent path, derive the other
* three; the caller confirms existence on disk. `uiDesign` is the current
* name (OPS114) and `uiDraft` is the legacy name (OPS109) — the two are
* alternative names for the SAME artifact, never two artifacts. The
* historical `uiDraft` key stays for retrocompat; `resolveUiDraft` owns the
* precedence between them.
* @param {string | null} planPath
*/
export const siblingPlanPaths = (planPath) => {
const path = typeof planPath === 'string' ? planPath.trim() : ''
if (!/\.md$/.test(path)) return { impl: null, uiDesign: null, uiDraft: null }
const stem = path.replace(/\.md$/, '')
const base = stem.endsWith('-impl') ? stem.slice(0, -'-impl'.length) : stem
return {
impl: `${base}-impl.md`,
uiDesign: `${base}-ui-design.html`,
uiDraft: `${base}-ui-draft.html`,
}
}
/**
* Pick the design artifact of a plan: `<base>-ui-design.html` (current name,
* OPS114) wins over `<base>-ui-draft.html` (legacy, immutable acervo). Pure by
* contract — the caller injects the existence predicate, so the lib never
* touches disk. When neither file exists the verdict is the current name with
* `exists: false`: the panel teaches the live convention and the legacy
* fallback still applies on disk.
* @param {string | null} planPath
* @param {(path: string) => boolean} [exists]
* @returns {{ path: string | null, exists: boolean }}
*/
export const resolveUiDraft = (planPath, exists = () => false) => {
const { uiDesign, uiDraft } = siblingPlanPaths(planPath)
if (uiDesign && exists(uiDesign)) return { path: uiDesign, exists: true }
if (uiDraft && exists(uiDraft)) return { path: uiDraft, exists: true }
return { path: uiDesign, exists: false }
}
/** 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 design verdict — the CLI checks the
* sibling `.html` (current `-ui-design` first, legacy `-ui-draft` as fallback)
* and hands back the real `{ path, exists }`; the panel never reads its
* content. The legacy key name stays: it means “design UI, new or legacy”.
* @param {object} issue normalized GitHub issue
* @param {{ intention?: string|null, impl?: string|null, uiDraft?: { path: string|null, exists: boolean }|null, sessions?: Array<object>, doneIds?: Set<string>, knownIds?: Set<string>, repository?: string }} [context]
*/
export const buildIssueRow = (issue, context = {}) => {
const {
intention = null,
impl = null,
uiDraft = null,
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 designVerdict = uiDraft ?? resolveUiDraft(planPath)
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: designVerdict.path ?? null, exists: Boolean(designVerdict.exists) },
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?: { path: string|null, exists: boolean }|null }>, 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: 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. The
* `uiDraft` key is the historical name of the design artifact and carries the
* REAL path found on disk (current `-ui-design`, else legacy `-ui-draft`) —
* `version: 1` and the `{ path, exists }` shape are frozen.
* @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,
})),
})