guanghulab/scripts/sync-work-order-to-notion.js

355 lines
13 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
// ═══════════════════════════════════════════════
// 🔺 Sovereign: TCS-0002∞ | Root: SYS-GLW-0001
// 📜 Copyright: 国作登字-2026-A-00037559
// ═══════════════════════════════════════════════
// scripts/sync-work-order-to-notion.js
// 📋 铸渊工单 → Notion SYSLOG 同步器
//
// 架构说明:
// 工单是铸渊智能运维的核心记录单元
// 冰朔需要在 Notion 侧看到告警工单的完整上下文
// 复用已有的 SYSLOG 数据库(霜砚已在 Notion 侧建好)
// 不需要新建数据库,零额外配置成本
//
// 用法:
// node scripts/sync-work-order-to-notion.js --order-id <WO-xxx>
// node scripts/sync-work-order-to-notion.js --order-id "observer-23906843495"
// node scripts/sync-work-order-to-notion.js --all-failed (同步所有失败工单)
//
// 环境变量:
// NOTION_TOKEN GitHub Secret: NOTION_TOKEN (必须)
// SYSLOG_DB_ID 覆盖默认数据库 ID (可选)
// COMMIT_SHA 当前 commit SHA (可选,由 workflow 注入)
// RUN_ID 工作流运行 ID (可选,由 workflow 注入)
// WORKFLOW_NAME 工作流名称 (可选,由 workflow 注入)
//
// Notion「📥 GitHub SYSLOG 收件箱」数据库属性:
// 标题 title 工单ID + 任务名
// DEV编号 select "SYSTEM" (系统工单)
// 文件内容 rich_text 工单完整 JSON + 时间线
// 接收时间 date 工单创建时间
// 处理状态 status 待处理 / 人工干预
// 来源路径 rich_text data/work-orders/active.json
// commit_sha rich_text 工单关联的 commit SHA
// 推送方 rich_text 铸渊
// ═══════════════════════════════════════════════
'use strict';
const https = require('https');
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
// ── 常量 ─────────────────────────────────────
const NOTION_VERSION = '2022-06-28';
const NOTION_API_HOSTNAME = 'api.notion.com';
const NOTION_RICH_TEXT_MAX = 2000;
const NOTION_TITLE_MAX = 120;
const MAX_LOG_PREVIEW_LENGTH = 500;
// 复用 notion-bridge.js 已内置的数据库 ID
const DEFAULT_SYSLOG_DB_ID = '330ab17507d542c9bbb96d0749b41197';
// ── Notion API 基础请求 ──────────────────────
function notionPost(endpoint, body, token) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(body);
const opts = {
hostname: NOTION_API_HOSTNAME,
port: 443,
path: endpoint,
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json',
'Notion-Version': NOTION_VERSION,
'Content-Length': Buffer.byteLength(payload),
},
};
const req = https.request(opts, res => {
let data = '';
res.on('data', c => data += c);
res.on('end', () => {
try {
const parsed = JSON.parse(data);
if (res.statusCode >= 200 && res.statusCode < 300) resolve(parsed);
else reject(new Error(`Notion API ${res.statusCode}: ${parsed.message || data.slice(0, 200)}`));
} catch (e) {
reject(new Error(`Notion 解析失败: ${data.slice(0, 200)}`));
}
});
});
req.on('error', reject);
req.write(payload);
req.end();
});
}
// ── Notion 属性构建 ───────────────────────────
function richText(content) {
const str = String(content || '');
const chunks = [];
for (let i = 0; i < str.length; i += NOTION_RICH_TEXT_MAX) {
chunks.push({ type: 'text', text: { content: str.slice(i, i + NOTION_RICH_TEXT_MAX) } });
}
return chunks.length ? chunks : [{ type: 'text', text: { content: '' } }];
}
function titleProp(text) {
return { title: [{ type: 'text', text: { content: String(text || '').slice(0, NOTION_TITLE_MAX) } }] };
}
// ── 工单格式化 ────────────────────────────────
function formatOrderForNotion(order, runId, workflowName) {
// 构建时间线文本
const timelineText = (order.timeline || [])
.map(t => `[${t.timestamp}] ${t.actor}: ${t.message}`)
.join('\n');
// 构建部署日志文本
const logsText = (order.deploy_logs || [])
.map(l => `[${l.timestamp}] ${l.content}`)
.join('\n');
// 完整内容(传入 SYSLOG 文件内容字段)
const fullContent = [
'═══ 工单基本信息 ═══',
`工单ID: ${order.id}`,
`状态: ${order.status}`,
`任务: ${order.title}`,
`提交SHA: ${order.commit_sha || 'N/A'}`,
`分支: ${order.branch || 'main'}`,
`创建者: ${order.created_by || 'system'}`,
`创建时间: ${order.created_at}`,
`更新时间: ${order.updated_at}`,
`重试次数: ${order.retry_count || 0}/${order.max_retries || 3}`,
'',
'═══ 执行时间线 ═══',
timelineText || '(无时间线记录)',
'',
'═══ 部署日志 ═══',
logsText || '(无部署日志)',
'',
'═══ 观测信息 ═══',
`触发工作流: ${workflowName || '铸渊智能运维'}`,
`运行 ID: ${runId || 'N/A'}`,
`同步时间: ${new Date().toISOString()}`,
'',
'═══ 原始数据 ═══',
JSON.stringify(order, null, 2),
].join('\n');
return fullContent;
}
// ── 同步单条工单到 Notion ─────────────────────
async function syncOrderToNotion(order, token, dbId, options = {}) {
const { runId, workflowName, commitSha } = options;
const statusText = order.status === 'failed' || order.status === 'human-intervened'
? '人工干预'
: '待处理';
const notionTitle = `[${order.id}] ${(order.title || '').slice(0, 80)} · ${order.status}`;
const content = formatOrderForNotion(order, runId, workflowName);
const properties = {
'标题': titleProp(notionTitle),
'文件内容': { rich_text: richText(content) },
'接收时间': { date: { start: order.created_at || new Date().toISOString() } },
'来源路径': { rich_text: richText('data/work-orders/active.json') },
'推送方': { rich_text: richText('铸渊') },
};
// commit_sha 字段
if (commitSha || order.commit_sha) {
properties['commit_sha'] = { rich_text: richText(commitSha || order.commit_sha || '') };
}
// 处理状态SYSLOG 里是 status 字段)
try {
properties['处理状态'] = { status: { name: statusText } };
} catch (e) {
// 某些 Notion 数据库可能用 select 而非 status忽略此字段错误
console.log(` 处理状态字段跳过: ${e.message}`);
}
// DEV编号 select
properties['DEV编号'] = { select: { name: 'SYSTEM' } };
const body = {
parent: { database_id: dbId },
properties,
};
const page = await notionPost('/v1/pages', body, token);
return page;
}
// ── 读取工单数据 ──────────────────────────────
function loadOrders() {
const activeFile = path.join(ROOT, 'data/work-orders/active.json');
try {
return JSON.parse(fs.readFileSync(activeFile, 'utf8'));
} catch (e) {
console.error(`❌ 读取工单文件失败: ${e.message}`);
return { orders: [] };
}
}
// ── 解析命令行参数 ────────────────────────────
function parseArgs() {
const argv = process.argv.slice(2);
const params = {};
for (let i = 0; i < argv.length; i++) {
if (argv[i].startsWith('--')) {
params[argv[i].slice(2)] = argv[i + 1] || 'true';
i++;
}
}
return params;
}
// ── 主函数 ────────────────────────────────────
async function main() {
const token = process.env.NOTION_TOKEN;
const dbId = process.env.SYSLOG_DB_ID || DEFAULT_SYSLOG_DB_ID;
if (!token) {
console.error('❌ 缺少 NOTION_TOKEN 环境变量');
process.exit(1);
}
const args = parseArgs();
const orderId = args['order-id'];
const allFailed = args['all-failed'] === 'true';
const runId = args['run-id'] || process.env.RUN_ID || '';
const workflowName = args['workflow'] || process.env.WORKFLOW_NAME || '铸渊智能运维';
const commitSha = args['commit-sha'] || process.env.COMMIT_SHA || '';
if (!orderId && !allFailed) {
console.error('❌ 请提供 --order-id <ID> 或 --all-failed');
console.log('用法:');
console.log(' node scripts/sync-work-order-to-notion.js --order-id <WO-xxx>');
console.log(' node scripts/sync-work-order-to-notion.js --all-failed');
process.exit(1);
}
const data = loadOrders();
let targets = [];
if (allFailed) {
targets = data.orders.filter(o =>
o.status === 'failed' || o.status === 'human-intervened'
);
if (targets.length === 0) {
console.log('✅ 没有失败或人工干预工单,无需同步');
return;
}
console.log(`📋 找到 ${targets.length} 条失败/干预工单,准备同步到 Notion`);
} else {
// 支持 observer-{runId} 格式工单(观测系统生成)
if (orderId.startsWith('observer-')) {
// 构造虚拟工单,从部署日志文件读取信息
const logRunId = orderId.replace('observer-', '');
// 尝试直接找文件
const logsDir = path.join(ROOT, 'data/deploy-logs');
const files = fs.readdirSync(logsDir).filter(f => f.includes(logRunId));
if (files.length > 0) {
const logData = JSON.parse(fs.readFileSync(path.join(logsDir, files[0]), 'utf8'));
// 构造虚拟工单
const virtualOrder = {
id: orderId,
title: `部署观测告警 · ${logData.run?.workflow_name || workflowName}`,
status: 'human-intervened',
commit_sha: logData.run?.head_sha || commitSha,
branch: logData.run?.head_branch || 'main',
created_by: logData.run?.actor || 'system',
created_at: logData.run?.created_at || new Date().toISOString(),
updated_at: new Date().toISOString(),
retry_count: 0,
max_retries: 3,
timeline: [
{ timestamp: logData.run?.created_at, actor: 'system', message: '部署工作流执行' },
{ timestamp: logData._meta?.collected_at, actor: '副将', message: '观测系统采集日志' },
{ timestamp: new Date().toISOString(), actor: '铸渊', message: '人工干预·工单同步到Notion' },
],
deploy_logs: (logData.failed_jobs || []).map(j => ({
timestamp: j.completed_at,
content: `Job: ${j.job_name} · ${j.conclusion}\n${j.log_content?.slice(-MAX_LOG_PREVIEW_LENGTH) || ''}`,
})),
_observer_data: {
run_id: logRunId,
workflow: logData.run?.workflow_name,
conclusion: logData.run?.conclusion,
failed_jobs: (logData.failed_jobs || []).map(j => j.job_name),
analysis: logData.analysis,
},
};
targets = [virtualOrder];
} else {
// 找不到日志文件,构造最小虚拟工单
targets = [{
id: orderId,
title: `部署观测告警 · ${workflowName}`,
status: 'human-intervened',
commit_sha: commitSha,
branch: 'main',
created_by: 'system',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
retry_count: 3,
max_retries: 3,
timeline: [
{ timestamp: new Date().toISOString(), actor: '铸渊', message: '人工干预·工单同步到Notion' },
],
deploy_logs: [],
}];
}
} else {
// 标准工单格式
const order = data.orders.find(o => o.id === orderId);
if (!order) {
console.error(`❌ 工单 ${orderId} 未找到`);
process.exit(1);
}
targets = [order];
}
}
console.log(`📡 开始同步 ${targets.length} 条工单到 Notion SYSLOG 收件箱`);
console.log(` 数据库: ${dbId}`);
console.log('');
let ok = 0, failed = 0;
for (const order of targets) {
try {
console.log(` 📋 同步工单: ${order.id} · ${order.status}`);
const page = await syncOrderToNotion(order, token, dbId, {
runId, workflowName, commitSha,
});
console.log(` ✅ 已写入 Notion: ${page.id || page.url || '成功'}`);
ok++;
} catch (e) {
console.error(` ❌ 工单 ${order.id} 同步失败: ${e.message}`);
failed++;
}
}
console.log('');
console.log(`✅ Notion 同步完成 · 成功 ${ok} 条 · 失败 ${failed}`);
if (failed > 0) process.exit(1);
}
main().catch(e => {
console.error('❌ 同步脚本异常:', e.message);
process.exit(1);
});