// scripts/notion-bridge.js // 铸渊 → Notion 数据桥(共用模块) // // 管道 A (syslog): node scripts/notion-bridge.js syslog // 管道 E (changes): node scripts/notion-bridge.js changes // // 必需环境变量: // NOTION_TOKEN GitHub Secret: NOTION_TOKEN // // 可选环境变量(有内置默认值): // SYSLOG_DB_ID 「GitHub SYSLOG 收件箱」database_id(默认已内置) // CHANGES_DB_ID 「GitHub 变更日志」database_id(默认已内置) // // 管道 E 额外环境变量(由 workflow 注入,见下方说明) 'use strict'; const https = require('https'); const fs = require('fs'); const path = require('path'); // ══════════════════════════════════════════════════════════ // 常量 // ══════════════════════════════════════════════════════════ const NOTION_VERSION = '2022-06-28'; const NOTION_API_HOSTNAME = 'api.notion.com'; const NOTION_RICH_TEXT_MAX = 2000; // Notion rich_text 单个 text object 内容上限 const NOTION_TITLE_MAX = 120; // Notion 标题属性建议截断长度 const UNKNOWN_COMMITTER = '未知'; // 提交者信息缺失时的默认值 // Notion 数据库 ID(霜砚已在 Notion 侧建好,ID 固定) const DEFAULT_SYSLOG_DB_ID = '330ab17507d542c9bbb96d0749b41197'; const DEFAULT_CHANGES_DB_ID = 'e740b77aa6bd4ac0a2e8a75f678fba98'; // ══════════════════════════════════════════════════════════ // Notion API 基础调用 // ══════════════════════════════════════════════════════════ /** * 向 Notion API 发起 HTTPS POST 请求 * @param {string} endpoint - e.g. '/v1/pages' * @param {object} body - JSON body * @param {string} token - Bearer token * @returns {Promise} - 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', chunk => data += chunk); 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}`)); } } catch (e) { reject(new Error(`Notion API parse error: ${data}`)); } }); }); req.on('error', reject); req.write(payload); req.end(); }); } // ══════════════════════════════════════════════════════════ // Notion 属性构建辅助函数 // ══════════════════════════════════════════════════════════ /** * 构建 Notion rich_text 属性值 * 自动将超过 NOTION_RICH_TEXT_MAX 的内容切分为多个 text object * @param {string} content * @returns {Array} */ 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: '' } }]; } /** * 构建 Notion title 属性值 * @param {string} content */ function titleProp(content) { return [{ type: 'text', text: { content: String(content || '').slice(0, NOTION_TITLE_MAX) } }]; } /** * 构建 Notion paragraph block(用于页面正文) * @param {string} content */ function paragraph(content) { return { object: 'block', type: 'paragraph', paragraph: { rich_text: richText(String(content || '').slice(0, NOTION_RICH_TEXT_MAX)) }, }; } /** * 构建 Notion heading_3 block */ function heading3(content) { return { object: 'block', type: 'heading_3', heading_3: { rich_text: [{ type: 'text', text: { content: String(content || '') } }] }, }; } // ══════════════════════════════════════════════════════════ // 管道 A · SYSLOG 收件箱同步到 Notion // ══════════════════════════════════════════════════════════ // // Notion「📥 GitHub SYSLOG 收件箱」数据库属性(霜砚已建): // 标题 title SYSLOG 文件名 // DEV编号 select 开发者编号,如 DEV-001 // 文件内容 rich_text SYSLOG 文件完整文本 // 接收时间 date 推送时间 // 处理状态 status 固定填「待处理」 // 来源路径 rich_text GitHub 中的文件路径 // commit_sha rich_text 对应的 Git commit SHA // 推送方 rich_text 固定填「铸渊」 /** * 将单条 syslog entry 写入 Notion 数据库 * @param {string} dbId - 「GitHub SYSLOG 收件箱」database_id * @param {string} fileContent - 文件原始内容(字符串) * @param {string} filePath - 相对路径(用于溯源) * @param {object} entry - 解析后的 JSON(或空对象) * @param {string} commitSha - Git commit SHA * @param {string} token - Notion token */ async function createSyslogRecord(dbId, fileContent, filePath, entry, commitSha, token) { const filename = path.basename(filePath); const title = entry.title || filename; const devId = entry.from || entry.dev_id || ''; const ts = entry.timestamp || new Date().toISOString(); const properties = { '标题': { title: titleProp(title) }, '文件内容': { rich_text: richText(fileContent) }, '来源路径': { rich_text: richText(filePath) }, '接收时间': { date: { start: ts } }, '处理状态': { status: { name: '待处理' } }, 'commit_sha': { rich_text: richText(commitSha || '') }, '推送方': { rich_text: richText('铸渊') }, }; // DEV编号 select 只在有值时设置(空值会导致 Notion API 报错) if (devId) { properties['DEV编号'] = { select: { name: devId } }; } const body = { parent: { database_id: dbId }, properties, }; return notionPost('/v1/pages', body, token); } /** * 递归扫描目录,返回所有匹配的文件路径 */ function scanDir(dir, extensions) { const results = []; if (!fs.existsSync(dir)) return results; const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { if (entry.name === '.gitkeep') continue; const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { results.push(...scanDir(fullPath, extensions)); } else if (extensions.some(ext => entry.name.endsWith(ext))) { results.push(fullPath); } } return results; } async function runPipelineA() { const token = process.env.NOTION_TOKEN; const dbId = process.env.SYSLOG_DB_ID || DEFAULT_SYSLOG_DB_ID; const commitSha = process.env.COMMIT_SHA || ''; if (!token) { console.log('⚠️ 管道A: 缺少 NOTION_TOKEN,跳过 Notion 同步'); process.exit(0); } const INBOX_DIR = 'syslog-inbox'; const files = scanDir(INBOX_DIR, ['.json', '.md', '.txt']); if (files.length === 0) { console.log('📭 syslog-inbox/ 无待处理条目,跳过 Notion 同步'); process.exit(0); } console.log(`📥 管道A: 发现 ${files.length} 条 syslog,开始同步到 Notion…`); let ok = 0, failed = 0; for (const fullPath of files) { const relPath = fullPath.replace(/\\/g, '/'); // normalize on Windows let raw, entry; try { raw = fs.readFileSync(fullPath, 'utf8'); entry = fullPath.endsWith('.json') ? JSON.parse(raw) : {}; } catch (e) { console.error(`❌ 读取 ${relPath} 失败: ${e.message}`); failed++; continue; } try { const page = await createSyslogRecord(dbId, raw, relPath, entry, commitSha, token); console.log(` ✅ ${relPath} → Notion page: ${page.id}`); ok++; } catch (e) { console.error(` ❌ ${relPath} → Notion 失败: ${e.message}`); failed++; } } console.log(`\n✅ 管道A Notion 同步完成 · 成功 ${ok} 条 · 失败 ${failed} 条`); if (failed > 0) process.exit(1); } // ══════════════════════════════════════════════════════════ // 管道 E · GitHub 变更日志同步到 Notion // ══════════════════════════════════════════════════════════ // // Notion「📋 GitHub 变更日志」数据库属性(霜砚已建): // 标题 title commit message 或 PR标题 // 提交者 select DEV编号或"铸渊""妈妈" // 变更类型 select Commit / PR opened / PR merged / PR closed // 变更文件 rich_text 变更的文件路径列表 // 提交时间 date Git提交时间 // commit_sha rich_text Git commit SHA // PR编号 rich_text PR号(PR事件) // 分支 rich_text 分支名 // 霜砚已读 checkbox 固定填 false // // 环境变量(由 workflow 注入): // EVENT_TYPE commit | pr // COMMIT_SHA 提交 SHA // COMMIT_MSG commit message(push 事件) // COMMITTER 提交者用户名 // COMMIT_TIMESTAMP 提交时间 ISO // CHANGED_FILES 换行符分隔的变更文件路径列表 // BRANCH 分支名 // PR_NUMBER PR 编号(pr 事件) // PR_TITLE PR 标题(pr 事件) // PR_ACTION opened | closed(pr 事件) // PR_MERGED true | false(pr 事件) async function runPipelineE() { const token = process.env.NOTION_TOKEN; const dbId = process.env.CHANGES_DB_ID || DEFAULT_CHANGES_DB_ID; if (!token) { console.log('⚠️ 管道E: 缺少 NOTION_TOKEN,跳过 Notion 同步'); process.exit(0); } const eventType = process.env.EVENT_TYPE || 'commit'; const now = process.env.COMMIT_TIMESTAMP || new Date().toISOString(); const commitSha = process.env.COMMIT_SHA || ''; const branch = process.env.BRANCH || ''; const changedFiles = (process.env.CHANGED_FILES || '').trim(); const committer = process.env.COMMITTER || UNKNOWN_COMMITTER; let title, changeType, prNumber; if (eventType === 'pr') { const action = process.env.PR_ACTION || 'opened'; const merged = process.env.PR_MERGED === 'true'; prNumber = process.env.PR_NUMBER || ''; const prTitle = process.env.PR_TITLE || '(无标题)'; if (merged) changeType = 'PR merged'; else if (action === 'closed') changeType = 'PR closed'; else changeType = 'PR opened'; title = `PR #${prNumber}: ${prTitle}`.slice(0, NOTION_TITLE_MAX); } else { const msg = process.env.COMMIT_MSG || '(无 commit message)'; title = msg.split('\n')[0].slice(0, NOTION_TITLE_MAX); changeType = 'Commit'; prNumber = ''; } console.log(`📡 管道E: 同步变更记录到 Notion: ${title}`); const properties = { '标题': { title: titleProp(title) }, '变更类型': { select: { name: changeType } }, '变更文件': { rich_text: richText(changedFiles) }, '提交时间': { date: { start: now } }, 'commit_sha': { rich_text: richText(commitSha) }, 'PR编号': { rich_text: richText(prNumber || '') }, '分支': { rich_text: richText(branch) }, '霜砚已读': { checkbox: false }, }; // 提交者 select 只在有值时设置 if (committer && committer !== UNKNOWN_COMMITTER) { properties['提交者'] = { select: { name: committer } }; } const body = { parent: { database_id: dbId }, properties, }; try { const page = await notionPost('/v1/pages', body, token); console.log(`✅ 管道E 变更记录已写入 Notion: ${page.id}`); } catch (e) { console.error(`❌ 管道E 写入 Notion 失败: ${e.message}`); process.exit(1); } } // ══════════════════════════════════════════════════════════ // 入口 // ══════════════════════════════════════════════════════════ const mode = process.argv[2]; if (mode === 'syslog') { runPipelineA().catch(e => { console.error(e); process.exit(1); }); } else if (mode === 'changes') { runPipelineE().catch(e => { console.error(e); process.exit(1); }); } else { console.error('用法: node scripts/notion-bridge.js [syslog|changes]'); process.exit(1); }