feat(observabilidade): tabela worklog_comments + parser HTML + importer MCP

- Schema worklog_comments (id, discussion, parent, datas, staff, campos parseados em JSON)
- Parser HTML tolerante (h2/h3/h4) extrai title, task_ref, duration, work_items,
  files_modified, problems, patterns_text, actions
- Módulo worklog-import com paginação MCP get_discussion_comments
- Helper mcp-client.ts partilhado (gateway MCP JSON-RPC + SSE)
- Dep runtime: node-html-parser

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 03:07:09 +01:00
parent 86770b1570
commit 11f9833aac
5 changed files with 685 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
/**
* Cliente HTTP mínimo para o gateway MCP (JSON-RPC 2.0 sobre HTTP).
*
* Suporta resposta em JSON puro ou SSE (text/event-stream). Partilhado entre
* os scripts de Observabilidade (patterns + worklog import).
*/
export interface MCPToolCallResult {
content?: Array<{ type: string; text: string }>
isError?: boolean
}
export async function callMcpTool(
tool: string,
args: Record<string, unknown>,
): Promise<MCPToolCallResult> {
const url = process.env.MCP_GATEWAY_URL ?? 'https://gateway.descomplicar.pt/v1/desk-crm/mcp'
const token = process.env.MCP_GATEWAY_TOKEN
if (!token) throw new Error('MCP_GATEWAY_TOKEN não definido')
const body = {
jsonrpc: '2.0',
id: Date.now(),
method: 'tools/call',
params: { name: tool, arguments: args },
}
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
},
body: JSON.stringify(body),
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
throw new Error(`MCP gateway ${res.status}: ${txt.slice(0, 300)}`)
}
const raw = await res.text()
let payload: string | null = null
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim()
if (!trimmed) continue
if (trimmed.startsWith('data: ')) {
payload = trimmed.slice(6)
break
}
if (trimmed.startsWith('{')) {
payload = trimmed
break
}
}
if (!payload) throw new Error(`MCP resposta sem payload JSON: ${raw.slice(0, 200)}`)
const parsed = JSON.parse(payload) as { error?: unknown; result?: MCPToolCallResult }
if (parsed.error) throw new Error(`MCP error: ${JSON.stringify(parsed.error)}`)
const result = parsed.result as MCPToolCallResult | undefined
if (result?.isError) {
const txt = result.content?.map((c) => c.text).join('\n') ?? ''
throw new Error(`MCP tool ${tool} devolveu isError: ${txt.slice(0, 300)}`)
}
return result ?? {}
}
/** Extrai o primeiro bloco de texto JSON-encoded do resultado MCP. */
export function extractMcpJsonPayload<T = unknown>(r: MCPToolCallResult): T {
const text = r.content?.find((c) => c.type === 'text')?.text
if (!text) throw new Error('MCP result sem content text')
return JSON.parse(text) as T
}