teqo-backup/scripts/agent-status.mjs

223 lines
7.5 KiB
JavaScript

/**
* `pnpm agent:status` — read-only overview of the trackable-issue graph.
*
* Sections: overview counters (state/prio/kind), current claim queue (same
* ordering as `agent:claim --dry-run`, with `model:`), mermaid dependency
* graph of open issues, blockers with reasons, and consolidation suggestions
* (merge / break-down candidates). Never touches issues labeled
* `in-progress` — read-only over `gh issue list --state all`.
*/
import {
dieAgent,
issuesById,
labelNames,
parseFrontmatter,
priorityRank,
} from './lib/agent-forgejo.mjs'
import { githubApi as api } from './lib/github-api.mjs'
const die = dieAgent('status')
let issues
try {
issues = await api.listIssues({ state: 'all', limit: 200 })
} catch (error) {
die(`GitHub issue list failed: ${error.message}`)
}
const rows = issues
.map((issue) => {
const { meta } = parseFrontmatter(issue.body)
if (typeof meta.id !== 'string' || meta.id.length === 0) return null
const labels = labelNames(issue)
const stateLabel = labels.find((label) =>
['ready', 'in-progress', 'blocked', 'done', 'in-prod'].includes(label),
)
return {
issue,
meta,
labels,
state:
issue.state === 'CLOSED'
? (stateLabel ?? 'done')
: (stateLabel ?? (issue.state === 'OPEN' ? 'open' : 'done')),
priority: labels.find((label) => /^prio:P[0-3]$/.test(label))?.replace('prio:', '') ?? 'P2',
kind: labels.find((label) => label.startsWith('kind:'))?.replace('kind:', '') ?? 'feature',
needs: labels.filter((label) => label.startsWith('needs:')),
depends: Array.isArray(meta.depends) ? meta.depends : [],
model: typeof meta.model === 'string' ? meta.model : null,
}
})
.filter(Boolean)
const open = rows.filter((row) => row.issue.state === 'OPEN')
const doneIds = new Set(
rows
.filter(
(row) => row.issue.state === 'CLOSED' || row.state === 'done' || row.state === 'in-prod',
)
.map((row) => row.meta.id),
)
// --- Overview ----------------------------------------------------------------
console.log('## Overview\n')
const countBy = (fn) => {
const counts = new Map()
for (const row of rows) {
const key = fn(row)
counts.set(key, (counts.get(key) ?? 0) + 1)
}
return [...counts.entries()].sort((a, b) => String(a[0]).localeCompare(String(b[0])))
}
console.log(`Total rastreáveis: ${rows.length} (${open.length} abertas)`)
console.log(
'Por estado: ' +
countBy((row) => row.state)
.map(([state, count]) => `${state}=${count}`)
.join(' '),
)
console.log(
'Abertas por prio: ' +
countBy((row) => (row.issue.state === 'OPEN' ? row.priority : null))
.filter(([prio]) => prio !== null)
.map(([prio, count]) => `${prio}=${count}`)
.join(' '),
)
console.log(
'Abertas por kind: ' +
countBy((row) => (row.issue.state === 'OPEN' ? row.kind : null))
.filter(([kind]) => kind !== null)
.map(([kind, count]) => `${kind}=${count}`)
.join(' '),
)
const needsAttention = open.filter((row) => row.needs.length > 0)
if (needsAttention.length > 0) {
console.log('\nneeds:* abertas:')
for (const row of needsAttention) {
console.log(
` #${row.issue.number} ${row.meta.id} [${row.needs.join(', ')}] ${row.issue.title}`,
)
}
}
const recentDone = rows.filter((row) => row.state === 'done' || row.state === 'in-prod').slice(-5)
if (recentDone.length > 0) {
console.log('\nÚltimas concluídas:')
for (const row of recentDone) {
console.log(` #${row.issue.number} ${row.meta.id} [${row.state}] ${row.issue.title}`)
}
}
// --- Fila atual (mesma ordem do agent:claim --dry-run) ------------------------
console.log('\n## Fila atual (ordem do agent:claim)\n')
const byId = await issuesById()
const queue = open
.filter((row) => row.state === 'ready')
.map((row) => ({
row,
blockedBy: row.depends.filter((id) => byId.has(id) && !doneIds.has(id)),
}))
.filter((entry) => entry.blockedBy.length === 0)
.sort((a, b) => {
const rank = priorityRank(a.row.priority) - priorityRank(b.row.priority)
return rank !== 0 ? rank : a.row.issue.createdAt.localeCompare(b.row.issue.createdAt)
})
if (queue.length === 0) {
console.log('(vazia — nada `ready` desbloqueado)')
} else {
queue.forEach(({ row }, index) => {
const top = index === 0 ? ' ← topo' : ''
const model = row.model ? ` model:${row.model}` : ' model:—'
console.log(
` #${row.issue.number} ${row.meta.id} [${row.priority}]${model} ${row.issue.title}${top}`,
)
})
}
// --- Bloqueios ---------------------------------------------------------------
const blocked = open.filter(
(row) => row.state === 'blocked' || row.depends.some((id) => byId.has(id) && !doneIds.has(id)),
)
console.log('\n## Bloqueios\n')
if (blocked.length === 0) {
console.log('(nenhum)')
} else {
for (const row of blocked) {
const depBlockers = row.depends.filter((id) => byId.has(id) && !doneIds.has(id))
const reasons = []
if (depBlockers.length > 0) reasons.push(`dep aberta: ${depBlockers.join(', ')}`)
for (const need of row.needs) reasons.push(need)
if (row.labels.includes('requirements-changed')) reasons.push('requirements-changed')
if (row.state === 'blocked' && reasons.length === 0)
reasons.push('label blocked (motivo no body)')
console.log(
` #${row.issue.number} ${row.meta.id} [${row.priority}] ${row.issue.title} — ${reasons.join('; ')}`,
)
}
}
// --- Mermaid -----------------------------------------------------------------
console.log('\n## Grafo (abertas)\n')
console.log('```mermaid')
console.log('graph LR')
for (const row of open) {
const label = `${row.meta.id} ${row.state}/${row.priority}`
console.log(` ${row.meta.id}["${label}"]`)
}
for (const row of open) {
for (const dep of row.depends) {
if (byId.has(dep)) console.log(` ${dep} --> ${row.meta.id}`)
}
}
console.log('```')
// --- Sugestões de consolidação (nunca toca in-progress) -----------------------
console.log('\n## Sugestões de consolidação (só leitura — decisão é humana)\n')
const candidates = open.filter((row) => row.state !== 'in-progress')
const mergeCandidates = []
const byPrefix = new Map()
for (const row of candidates) {
// Domain proxy: the first two significant words of the title (after the
// "ID — " prefix). Items sharing domain + prio with no execution are the
// merge-review candidates; a bare same-track grouping is too noisy.
const words = row.issue.title
.replace(/^[A-Z0-9+]+\s*—\s*/, '')
.split(/[\s/—-]+/)
.filter(Boolean)
.slice(0, 2)
.join(' ')
.toLowerCase()
const key = `${words}/${row.priority}`
byPrefix.set(key, [...(byPrefix.get(key) ?? []), row])
}
for (const [key, group] of byPrefix) {
if (group.length >= 2 && group.length <= 6) {
mergeCandidates.push(
`mesmo domínio/prio (${key}), ${group.length} abertas sem execução: ${group.map((row) => row.meta.id).join(', ')} — avaliar merge`,
)
}
}
const dependentCount = new Map()
for (const row of candidates) {
for (const dep of row.depends) dependentCount.set(dep, (dependentCount.get(dep) ?? 0) + 1)
}
const breakdownCandidates = candidates.filter((row) => (dependentCount.get(row.meta.id) ?? 0) >= 3)
if (mergeCandidates.length === 0 && breakdownCandidates.length === 0) {
console.log('(nenhuma heurística disparou)')
} else {
for (const line of mergeCandidates) console.log(` merge? ${line}`)
for (const row of breakdownCandidates) {
console.log(
` break-down? #${row.issue.number} ${row.meta.id} bloqueia ${dependentCount.get(row.meta.id)} itens — avaliar fatiar (título guarda-chuva?)`,
)
}
}