guanghulab/scripts/sync-work-order-to-notion.js
2026-05-10 13:12:44 +08:00

355 lines
13 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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);
});