diff --git a/api/scripts/sessions-worklog-import.ts b/api/scripts/sessions-worklog-import.ts new file mode 100644 index 0000000..5e7472f --- /dev/null +++ b/api/scripts/sessions-worklog-import.ts @@ -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 { + 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) +}) diff --git a/api/tests/sessions-worklog-import.test.ts b/api/tests/sessions-worklog-import.test.ts new file mode 100644 index 0000000..84e4cf3 --- /dev/null +++ b/api/tests/sessions-worklog-import.test.ts @@ -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 = ` +

2026-04-15 10:30 - Refactor API sessions

+

Projecto: DashDescomplicar

+

Tarefa: #2059 - Observabilidade Espelho

+

Duração: ~2h 15m

+

Trabalho Realizado

+ +

Ficheiros Modificados

+ +

Problemas / Soluções

+ +

Padrões Detectados

+ +

Acções Sugeridas

+ +` + +const SAMPLE_H2 = ` +

2026-01-31 - Estratégia Stack

+

Duração: ~2h

+

Trabalho Realizado

+ +

Insights

+ +` + +const SAMPLE_D33 = ` + +

Origem: Sessão 2026-02-02

+

Prioridade: P1

+` + +describe('parseWorklogHtml', () => { + it('extrai campos de comentário formato

', () => { + 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

/

(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: '

Test

', + 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) + }) +})