feat(observabilidade): CLI sessions-worklog-import com paginação
- Script CLI com args --discussion 31|32|33|all, --since-days N, --force - Paginação via MCP gateway (limit 100, tree_view false) - Output JSON-line progressivo + summary final - Testes: parseWorklogHtml tolerante (h2/h3/h4), idempotência upsert Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Importa comentários das discussões Desk #31/#32/#33 (worklogs, reflexões
|
||||
* e acções de melhoria) para a BD Observabilidade.
|
||||
*
|
||||
* Uso:
|
||||
* sessions-worklog-import.ts [--discussion 31|32|33|all] [--since-days N]
|
||||
* sessions-worklog-import.ts --discussion 31 --page-size 200
|
||||
*
|
||||
* Default: --discussion all --since-days 365
|
||||
*
|
||||
* Env obrigatório:
|
||||
* MCP_GATEWAY_TOKEN Bearer token do gateway MCP
|
||||
*/
|
||||
import { openSessionsDb } from '../services/sessions/db.js'
|
||||
import { DEFAULT_DB_PATH } from '../services/sessions/indexer.js'
|
||||
import { importWorklogDiscussion, type ImportResult } from '../services/sessions/worklog-import.js'
|
||||
|
||||
interface Args {
|
||||
discussion: 'all' | number
|
||||
sinceDays: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
const a: Args = { discussion: 'all', sinceDays: 365, pageSize: 500 }
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === '--discussion') {
|
||||
const v = argv[++i]
|
||||
a.discussion = v === 'all' ? 'all' : parseInt(v, 10)
|
||||
} else if (argv[i] === '--since-days') {
|
||||
a.sinceDays = parseInt(argv[++i], 10)
|
||||
} else if (argv[i] === '--page-size') {
|
||||
a.pageSize = parseInt(argv[++i], 10)
|
||||
}
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
if (!process.env.MCP_GATEWAY_TOKEN) {
|
||||
console.error('[worklog-import] MCP_GATEWAY_TOKEN não definido. Aborta.')
|
||||
process.exit(1)
|
||||
}
|
||||
const dbPath = process.env.OBSERVABILIDADE_DB ?? DEFAULT_DB_PATH
|
||||
const db = openSessionsDb(dbPath)
|
||||
|
||||
const discussions = args.discussion === 'all' ? [31, 32, 33] : [args.discussion as number]
|
||||
const sinceIso = new Date(Date.now() - args.sinceDays * 86400_000).toISOString()
|
||||
|
||||
console.error(
|
||||
`[worklog-import] db=${dbPath} discussions=${discussions.join(',')} since=${sinceIso} page_size=${args.pageSize}`,
|
||||
)
|
||||
|
||||
const results: ImportResult[] = []
|
||||
for (const d of discussions) {
|
||||
try {
|
||||
const r = await importWorklogDiscussion(db, d, { sinceIso, pageSize: args.pageSize })
|
||||
results.push(r)
|
||||
console.error(
|
||||
`[worklog-import] #${d}: fetched=${r.fetched} inserted=${r.imported} updated=${r.updated} skipped=${r.skipped} errors=${r.errors}`,
|
||||
)
|
||||
} catch (e) {
|
||||
console.error(`[worklog-import] falha #${d}:`, (e as Error).message)
|
||||
results.push({
|
||||
discussion_id: d,
|
||||
fetched: 0,
|
||||
imported: 0,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
errors: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
db: dbPath,
|
||||
since_iso: sinceIso,
|
||||
discussions: results,
|
||||
totals: {
|
||||
fetched: results.reduce((s, r) => s + r.fetched, 0),
|
||||
imported: results.reduce((s, r) => s + r.imported, 0),
|
||||
updated: results.reduce((s, r) => s + r.updated, 0),
|
||||
skipped: results.reduce((s, r) => s + r.skipped, 0),
|
||||
errors: results.reduce((s, r) => s + r.errors, 0),
|
||||
},
|
||||
total_in_db: db.countWorklogComments(),
|
||||
}
|
||||
console.log(JSON.stringify(summary))
|
||||
db.close()
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[worklog-import] falha fatal:', err)
|
||||
process.exit(2)
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mkdtempSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { openSessionsDb, type SessionsDb, type WorklogCommentRecord } from '../services/sessions/db.js'
|
||||
import { parseWorklogHtml } from '../services/sessions/worklog-import.js'
|
||||
import { detectActionsNeverExecuted, weekRange } from '../services/sessions/patterns.js'
|
||||
|
||||
const SAMPLE_H4 = `
|
||||
<h4>2026-04-15 10:30 - Refactor API sessions</h4>
|
||||
<p><strong>Projecto:</strong> DashDescomplicar</p>
|
||||
<p><strong>Tarefa:</strong> #2059 - Observabilidade Espelho</p>
|
||||
<p><strong>Duração:</strong> ~2h 15m</p>
|
||||
<h4>Trabalho Realizado</h4>
|
||||
<ul><li>Criar módulo worklog-import</li><li>Integrar detectores cruzados</li></ul>
|
||||
<h4>Ficheiros Modificados</h4>
|
||||
<ul><li><code>api/services/sessions/db.ts</code></li><li><code>api/scripts/sessions-worklog-import.ts</code></li></ul>
|
||||
<h4>Problemas / Soluções</h4>
|
||||
<ul><li>Parser HTML frágil → usar node-html-parser</li></ul>
|
||||
<h4>Padrões Detectados</h4>
|
||||
<ul><li>MCP gateway responde em SSE ou JSON</li></ul>
|
||||
<h4>Acções Sugeridas</h4>
|
||||
<ul><li>[Refactor] Extrair callMcpTool para módulo partilhado P2</li></ul>
|
||||
`
|
||||
|
||||
const SAMPLE_H2 = `
|
||||
<h2>2026-01-31 - Estratégia Stack</h2>
|
||||
<p><strong>Duração:</strong> ~2h</p>
|
||||
<h3>Trabalho Realizado</h3>
|
||||
<ul><li>Stack Mapeado - 15 sistemas</li></ul>
|
||||
<h3>Insights</h3>
|
||||
<ul><li>Posicionamento: Marketing alta performance</li></ul>
|
||||
`
|
||||
|
||||
const SAMPLE_D33 = `
|
||||
<ul>
|
||||
<li>[ ] [MCP] Corrigir bug desk-crm-v3 com tabelas de discussões</li>
|
||||
</ul>
|
||||
<p><strong>Origem:</strong> Sessão 2026-02-02</p>
|
||||
<p><strong>Prioridade:</strong> P1</p>
|
||||
`
|
||||
|
||||
describe('parseWorklogHtml', () => {
|
||||
it('extrai campos de comentário formato <h4>', () => {
|
||||
const parsed = parseWorklogHtml(SAMPLE_H4, { id: 100, discussion_id: 31, created_at: '' })
|
||||
expect(parsed.id).toBe(100)
|
||||
expect(parsed.title).toMatch(/2026-04-15/)
|
||||
expect(parsed.task_ref).toBe('#2059')
|
||||
expect(parsed.duration_sec).toBe(2 * 3600 + 15 * 60)
|
||||
expect(parsed.work_items.length).toBe(2)
|
||||
expect(parsed.files_modified.length).toBe(2)
|
||||
expect(parsed.patterns_text.length).toBe(1)
|
||||
expect(parsed.actions.length).toBe(1)
|
||||
expect(parsed.actions[0].tipo).toBe('Refactor')
|
||||
expect(parsed.actions[0].prioridade).toBe('P2')
|
||||
expect(parsed.created_at.startsWith('2026-04-15')).toBe(true)
|
||||
})
|
||||
|
||||
it('extrai campos de comentário formato <h2>/<h3> (legacy)', () => {
|
||||
const parsed = parseWorklogHtml(SAMPLE_H2, { id: 64, discussion_id: 31, created_at: '' })
|
||||
expect(parsed.title).toMatch(/2026-01-31/)
|
||||
expect(parsed.work_items.length).toBeGreaterThanOrEqual(1)
|
||||
expect(parsed.duration_sec).toBe(2 * 3600)
|
||||
expect(parsed.created_at.startsWith('2026-01-31')).toBe(true)
|
||||
})
|
||||
|
||||
it('extrai acções em formato discussão #33 (lista crua)', () => {
|
||||
const parsed = parseWorklogHtml(SAMPLE_D33, { id: 200, discussion_id: 33, created_at: '2026-02-02T00:00:00Z' })
|
||||
expect(parsed.actions.length).toBe(1)
|
||||
expect(parsed.actions[0].tipo).toBe('MCP')
|
||||
})
|
||||
})
|
||||
|
||||
describe('upsertWorklogComment idempotência', () => {
|
||||
let db: SessionsDb
|
||||
beforeEach(() => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'obs-wl-'))
|
||||
db = openSessionsDb(join(dir, 'sessions.db'))
|
||||
})
|
||||
|
||||
it('insert primeiro, update depois', () => {
|
||||
const base: WorklogCommentRecord = {
|
||||
id: 42,
|
||||
discussion_id: 31,
|
||||
created_at: '2026-04-15T10:30:00Z',
|
||||
staff_id: 25,
|
||||
title: 'Test',
|
||||
task_ref: '#100',
|
||||
duration_sec: 600,
|
||||
work_items: ['a'],
|
||||
files_modified: [],
|
||||
problems: [],
|
||||
patterns_text: [],
|
||||
actions: [],
|
||||
raw_html: '<h4>Test</h4>',
|
||||
imported_at: '2026-04-23T00:00:00Z',
|
||||
}
|
||||
const r1 = db.upsertWorklogComment(base)
|
||||
expect(r1.inserted).toBe(true)
|
||||
expect(db.countWorklogComments()).toBe(1)
|
||||
const r2 = db.upsertWorklogComment({ ...base, title: 'Updated' })
|
||||
expect(r2.inserted).toBe(false)
|
||||
expect(db.countWorklogComments()).toBe(1)
|
||||
const list = db.listWorklogComments({ discussion_id: 31 })
|
||||
expect(list[0].title).toBe('Updated')
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectActionsNeverExecuted', () => {
|
||||
let db: SessionsDb
|
||||
beforeEach(() => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'obs-act-'))
|
||||
db = openSessionsDb(join(dir, 'sessions.db'))
|
||||
})
|
||||
|
||||
it('sinaliza acções P1/P2 antigas sem execução', () => {
|
||||
const old = new Date('2026-03-01T00:00:00Z').toISOString()
|
||||
for (let i = 0; i < 4; i++) {
|
||||
db.upsertWorklogComment({
|
||||
id: 300 + i,
|
||||
discussion_id: 33,
|
||||
created_at: old,
|
||||
staff_id: 25,
|
||||
title: `Acção ${i}`,
|
||||
task_ref: `#${1000 + i}`,
|
||||
duration_sec: null,
|
||||
work_items: [],
|
||||
files_modified: [],
|
||||
problems: [],
|
||||
patterns_text: [],
|
||||
actions: [{ tipo: 'MCP', descricao: `Corrigir bug X${i}`, prioridade: i % 2 ? 'P1' : 'P2' }],
|
||||
raw_html: '',
|
||||
imported_at: '2026-04-23T00:00:00Z',
|
||||
})
|
||||
}
|
||||
const range = weekRange(new Date('2026-04-22T00:00:00Z'))
|
||||
const patterns = detectActionsNeverExecuted({
|
||||
db: db.rawDb(),
|
||||
weekStartIso: range.start.toISOString(),
|
||||
weekEndIso: range.end.toISOString(),
|
||||
})
|
||||
expect(patterns.length).toBe(1)
|
||||
expect(patterns[0].pattern_key).toBe('actions_never_executed')
|
||||
expect(patterns[0].affected_count).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('não sinaliza se acções recentes (<14 dias)', () => {
|
||||
const recent = new Date().toISOString()
|
||||
for (let i = 0; i < 5; i++) {
|
||||
db.upsertWorklogComment({
|
||||
id: 400 + i,
|
||||
discussion_id: 33,
|
||||
created_at: recent,
|
||||
staff_id: 25,
|
||||
title: null,
|
||||
task_ref: null,
|
||||
duration_sec: null,
|
||||
work_items: [],
|
||||
files_modified: [],
|
||||
problems: [],
|
||||
patterns_text: [],
|
||||
actions: [{ tipo: 'MCP', descricao: 'x', prioridade: 'P1' }],
|
||||
raw_html: '',
|
||||
imported_at: recent,
|
||||
})
|
||||
}
|
||||
const range = weekRange(new Date())
|
||||
const patterns = detectActionsNeverExecuted({
|
||||
db: db.rawDb(),
|
||||
weekStartIso: range.start.toISOString(),
|
||||
weekEndIso: range.end.toISOString(),
|
||||
})
|
||||
expect(patterns.length).toBe(0)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user