From f6a6dc2732746e4faba510846bdc8b878afc2fd0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 07:06:49 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20GitHub=20=E2=86=92=20Notion=20data=20br?= =?UTF-8?q?idge=20(Pipeline=20A=20&=20E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/notion-bridge.js: exact Notion property names/types from spec, hardcoded DB IDs as defaults, recursive syslog scan, content chunking, named constants (NOTION_RICH_TEXT_MAX, NOTION_TITLE_MAX, UNKNOWN_COMMITTER) - bridge-syslog-to-notion.yml: trigger on syslog-inbox/** + syslog-processed/**, passes COMMIT_SHA for traceability - bridge-changes-to-notion.yml: fixed COMMITTER (author.name), removed bad CHANGED_FILES expr, added BRANCH env var, uses git diff as sole source Co-authored-by: qinfendebingshuo <207279273+qinfendebingshuo@users.noreply.github.com> --- .../workflows/bridge-changes-to-notion.yml | 62 ++-- .github/workflows/bridge-syslog-to-notion.yml | 17 +- scripts/notion-bridge.js | 294 +++++++++++------- 3 files changed, 227 insertions(+), 146 deletions(-) diff --git a/.github/workflows/bridge-changes-to-notion.yml b/.github/workflows/bridge-changes-to-notion.yml index 43601d7e..795ed26c 100644 --- a/.github/workflows/bridge-changes-to-notion.yml +++ b/.github/workflows/bridge-changes-to-notion.yml @@ -1,9 +1,12 @@ name: 铸渊 · Bridge E · GitHub Changes → Notion -# 管道 E:main 分支有 push 或 PR 创建/关闭 → 变更摘要同步到 Notion 变更日志数据库 -# 依赖 Secrets: -# NOTION_TOKEN Notion 集成 token(见仓库 Settings → Secrets → Actions) -# NOTION_CHANGES_DB_ID 「GitHub 变更日志」Notion 数据库 ID +# 管道 E:main 分支有 push 或 PR 创建/关闭 → 变更摘要同步到 Notion「📋 GitHub 变更日志」数据库 +# +# 依赖 Secrets(仓库 Settings → Secrets and variables → Actions): +# NOTION_TOKEN Notion 集成 token +# +# CHANGES_DB_ID 已内置默认值(e740b77aa6bd4ac0a2e8a75f678fba98) +# 如需覆盖,可在 Secrets 中添加 CHANGES_DB_ID on: push: @@ -30,37 +33,40 @@ jobs: with: node-version: '20' - # ── commit push 分支 ────────────────────────────────── + # ── commit push ────────────────────────────────────── - name: 📡 同步 commit 变更到 Notion if: github.event_name == 'push' env: - NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} - NOTION_CHANGES_DB_ID: ${{ secrets.NOTION_CHANGES_DB_ID }} - EVENT_TYPE: commit - COMMIT_SHA: ${{ github.sha }} - COMMIT_MSG: ${{ github.event.head_commit.message }} - COMMITTER: ${{ github.event.head_commit.author.username || github.actor }} - COMMIT_TIMESTAMP: ${{ github.event.head_commit.timestamp }} - CHANGED_FILES: ${{ join(github.event.commits[0].added, ',') }} + NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} + CHANGES_DB_ID: ${{ secrets.CHANGES_DB_ID }} + EVENT_TYPE: commit + COMMIT_SHA: ${{ github.sha }} + COMMIT_MSG: ${{ github.event.head_commit.message }} + COMMITTER: ${{ github.event.head_commit.author.name || github.actor }} + COMMIT_TIMESTAMP: ${{ github.event.head_commit.timestamp }} + BRANCH: ${{ github.ref_name }} run: | - # 从 git diff 提取完整变更文件列表(更完整) - CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null | tr '\n' ',' | sed 's/,$//') - export CHANGED_FILES="${CHANGED:-$CHANGED_FILES}" + # git diff 输出换行分隔的变更文件列表(包含新增、修改、删除) + # COMMIT_TIMESTAMP/COMMITTER fallback: GitHub 表达式 || 在空字符串时也会取右侧值 + CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || true) + export CHANGED_FILES="$CHANGED" node scripts/notion-bridge.js changes - # ── PR 分支 ─────────────────────────────────────────── + # ── PR 事件 ─────────────────────────────────────────── - name: 📡 同步 PR 变更到 Notion if: github.event_name == 'pull_request' env: - NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} - NOTION_CHANGES_DB_ID: ${{ secrets.NOTION_CHANGES_DB_ID }} - EVENT_TYPE: pr - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_TITLE: ${{ github.event.pull_request.title }} - PR_ACTION: ${{ github.event.action }} - PR_MERGED: ${{ github.event.pull_request.merged }} - COMMITTER: ${{ github.event.pull_request.user.login }} - COMMIT_TIMESTAMP: ${{ github.event.pull_request.updated_at }} - COMMIT_SHA: ${{ github.event.pull_request.head.sha }} - COMMIT_MSG: ${{ github.event.pull_request.title }} + NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} + CHANGES_DB_ID: ${{ secrets.CHANGES_DB_ID }} + EVENT_TYPE: pr + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_ACTION: ${{ github.event.action }} + PR_MERGED: ${{ github.event.pull_request.merged }} + COMMITTER: ${{ github.event.pull_request.user.login }} + COMMIT_TIMESTAMP: ${{ github.event.pull_request.updated_at }} + COMMIT_SHA: ${{ github.event.pull_request.head.sha }} + COMMIT_MSG: ${{ github.event.pull_request.title }} + BRANCH: ${{ github.event.pull_request.head.ref }} run: node scripts/notion-bridge.js changes + diff --git a/.github/workflows/bridge-syslog-to-notion.yml b/.github/workflows/bridge-syslog-to-notion.yml index ce94c18f..42e188f0 100644 --- a/.github/workflows/bridge-syslog-to-notion.yml +++ b/.github/workflows/bridge-syslog-to-notion.yml @@ -1,15 +1,19 @@ name: 铸渊 · Bridge A · SYSLOG → Notion -# 管道 A:syslog-inbox/ 有新文件 push → 同步到 Notion 数据库 -# 依赖 Secrets: -# NOTION_TOKEN Notion 集成 token(见仓库 Settings → Secrets → Actions) -# NOTION_SYSLOG_DB_ID 「GitHub SYSLOG 收件箱」Notion 数据库 ID +# 管道 A:syslog-inbox/ 有新文件 push → 同步到 Notion「📥 GitHub SYSLOG 收件箱」数据库 +# +# 依赖 Secrets(仓库 Settings → Secrets and variables → Actions): +# NOTION_TOKEN Notion 集成 token +# +# SYSLOG_DB_ID 已内置默认值(330ab17507d542c9bbb96d0749b41197) +# 如需覆盖,可在 Secrets 中添加 SYSLOG_DB_ID on: push: branches: [main] paths: - 'syslog-inbox/**' + - 'syslog-processed/**' workflow_dispatch: jobs: @@ -30,6 +34,7 @@ jobs: - name: 🔗 同步 syslog-inbox 到 Notion env: - NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} - NOTION_SYSLOG_DB_ID: ${{ secrets.NOTION_SYSLOG_DB_ID }} + NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} + SYSLOG_DB_ID: ${{ secrets.SYSLOG_DB_ID }} + COMMIT_SHA: ${{ github.sha }} run: node scripts/notion-bridge.js syslog diff --git a/scripts/notion-bridge.js b/scripts/notion-bridge.js index 6874f4ff..87a83b6f 100644 --- a/scripts/notion-bridge.js +++ b/scripts/notion-bridge.js @@ -5,11 +5,13 @@ // 管道 E (changes): node scripts/notion-bridge.js changes // // 必需环境变量: -// NOTION_TOKEN GitHub Secret: NOTION_TOKEN -// NOTION_SYSLOG_DB_ID 管道A · 「GitHub SYSLOG 收件箱」数据库 ID -// NOTION_CHANGES_DB_ID 管道E · 「GitHub 变更日志」数据库 ID +// NOTION_TOKEN GitHub Secret: NOTION_TOKEN // -// 所有其他上下文通过环境变量传入(见下方各管道章节) +// 可选环境变量(有内置默认值): +// SYSLOG_DB_ID 「GitHub SYSLOG 收件箱」database_id(默认已内置) +// CHANGES_DB_ID 「GitHub 变更日志」database_id(默认已内置) +// +// 管道 E 额外环境变量(由 workflow 注入,见下方说明) 'use strict'; @@ -18,13 +20,22 @@ const fs = require('fs'); const path = require('path'); // ══════════════════════════════════════════════════════════ -// Notion API 基础调用 +// 常量 // ══════════════════════════════════════════════════════════ -const NOTION_VERSION = '2022-06-28'; -const NOTION_API = 'api.notion.com'; -const NOTION_RICH_TEXT_MAX = 2000; // Notion rich_text content length limit per block -const NOTION_TITLE_MAX = 120; // Practical limit for Notion page title +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 请求 @@ -37,7 +48,7 @@ function notionPost(endpoint, body, token) { return new Promise((resolve, reject) => { const payload = JSON.stringify(body); const opts = { - hostname: NOTION_API, + hostname: NOTION_API_HOSTNAME, port: 443, path: endpoint, method: 'POST', @@ -72,25 +83,42 @@ function notionPost(endpoint, body, token) { }); } +// ══════════════════════════════════════════════════════════ +// Notion 属性构建辅助函数 +// ══════════════════════════════════════════════════════════ + /** - * 构建 Notion rich_text 块 + * 构建 Notion rich_text 属性值 + * 自动将超过 NOTION_RICH_TEXT_MAX 的内容切分为多个 text object * @param {string} content + * @returns {Array} */ function richText(content) { - const MAX = 2000; // Notion 单个 rich_text 内容上限 - return [{ type: 'text', text: { content: String(content || '').slice(0, MAX) } }]; + 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 paragraph block + * 构建 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) { - const MAX = 2000; - const text = String(content || '').slice(0, MAX); return { object: 'block', type: 'paragraph', - paragraph: { rich_text: richText(text) }, + paragraph: { rich_text: richText(String(content || '').slice(0, NOTION_RICH_TEXT_MAX)) }, }; } @@ -101,64 +129,94 @@ function heading3(content) { return { object: 'block', type: 'heading_3', - heading_3: { rich_text: richText(String(content || '')) }, + 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 {object} entry - syslog JSON 内容 - * @param {string} filename - 源文件名(用于溯源) - * @param {string} token - Notion token + * @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, entry, filename, token) { - const title = entry.title || filename; - const devId = entry.from || entry.dev_id || '未知'; - const ts = entry.timestamp || new Date().toISOString(); - const content = JSON.stringify(entry, null, 2); +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: { - // 「名称」是 Notion 数据库的默认标题列,始终存在 - '名称': { title: richText(title) }, - // 以下属性若不存在会被 Notion 忽略,不影响创建 - 'DEV编号': { rich_text: richText(devId) }, - '来源文件': { rich_text: richText(filename) }, - '接收时间': { date: { start: ts } }, - '处理状态': { select: { name: '待处理' } }, - }, - children: [ - heading3('📋 SYSLOG 原始内容'), - paragraph(content), - ], + properties, }; return notionPost('/v1/pages', body, token); } -async function runPipelineA() { - const token = process.env.NOTION_TOKEN; - const dbId = process.env.NOTION_SYSLOG_DB_ID; +/** + * 递归扫描目录,返回所有匹配的文件路径 + */ +function scanDir(dir, extensions) { + const results = []; + if (!fs.existsSync(dir)) return results; - if (!token || !dbId) { - console.log('⚠️ 管道A: 缺少 NOTION_TOKEN 或 NOTION_SYSLOG_DB_ID,跳过 Notion 同步'); + 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'; - if (!fs.existsSync(INBOX_DIR)) { - console.log('📭 syslog-inbox/ 目录不存在,跳过'); - process.exit(0); - } - - const files = fs.readdirSync(INBOX_DIR) - .filter(f => (f.endsWith('.json') || f.endsWith('.md') || f.endsWith('.txt')) && f !== '.gitkeep'); + const files = scanDir(INBOX_DIR, ['.json', '.md', '.txt']); if (files.length === 0) { console.log('📭 syslog-inbox/ 无待处理条目,跳过 Notion 同步'); @@ -169,25 +227,25 @@ async function runPipelineA() { let ok = 0, failed = 0; - for (const file of files) { - const fullPath = path.join(INBOX_DIR, file); - let entry; + for (const fullPath of files) { + const relPath = fullPath.replace(/\\/g, '/'); // normalize on Windows + let raw, entry; try { - const raw = fs.readFileSync(fullPath, 'utf8'); - entry = file.endsWith('.json') ? JSON.parse(raw) : { title: file, content: raw }; + raw = fs.readFileSync(fullPath, 'utf8'); + entry = fullPath.endsWith('.json') ? JSON.parse(raw) : {}; } catch (e) { - console.error(`❌ 读取 ${file} 失败: ${e.message}`); + console.error(`❌ 读取 ${relPath} 失败: ${e.message}`); failed++; continue; } try { - const page = await createSyslogRecord(dbId, entry, file, token); - console.log(` ✅ ${file} → Notion page: ${page.id}`); + const page = await createSyslogRecord(dbId, raw, relPath, entry, commitSha, token); + console.log(` ✅ ${relPath} → Notion page: ${page.id}`); ok++; } catch (e) { - console.error(` ❌ ${file} → Notion 失败: ${e.message}`); + console.error(` ❌ ${relPath} → Notion 失败: ${e.message}`); failed++; } } @@ -199,76 +257,88 @@ async function runPipelineA() { // ══════════════════════════════════════════════════════════ // 管道 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 事件) -/** - * 将单次 commit / PR 写入 Notion 变更日志数据库 - * 环境变量(由 workflow 注入): - * EVENT_TYPE commit | pr - * COMMIT_SHA 提交 SHA (commit) - * COMMIT_MSG commit message - * COMMITTER 提交者用户名 - * COMMIT_TIMESTAMP 提交时间 ISO - * CHANGED_FILES 逗号分隔的变更文件路径 - * 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.NOTION_CHANGES_DB_ID; + const dbId = process.env.CHANGES_DB_ID || DEFAULT_CHANGES_DB_ID; - if (!token || !dbId) { - console.log('⚠️ 管道E: 缺少 NOTION_TOKEN 或 NOTION_CHANGES_DB_ID,跳过 Notion 同步'); + 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 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, type, committer, changedFiles, description; + let title, changeType, prNumber; if (eventType === 'pr') { - const action = process.env.PR_ACTION || 'opened'; - const merged = process.env.PR_MERGED === 'true'; - const prNum = process.env.PR_NUMBER || ''; - const prTitle = process.env.PR_TITLE || '(无标题)'; - const finalAct = merged ? 'merged' : action; + 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 || '(无标题)'; - title = `PR #${prNum}: ${prTitle}`; - type = 'PR'; - committer = process.env.COMMITTER || '未知'; - changedFiles = process.env.CHANGED_FILES || ''; - description = `PR #${prNum} ${finalAct} by ${committer}`; + 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 sha = (process.env.COMMIT_SHA || '').slice(0, 7); const msg = process.env.COMMIT_MSG || '(无 commit message)'; - title = msg.split('\n')[0].slice(0, 120); // 取第一行 - type = 'commit'; - committer = process.env.COMMITTER || '未知'; - changedFiles = process.env.CHANGED_FILES || ''; - description = `${sha}: ${title}`; + 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: { - '名称': { title: richText(title) }, - '提交者': { rich_text: richText(committer) }, - '类型': { select: { name: type } }, - '时间': { date: { start: now } }, - '变更文件': { rich_text: richText(changedFiles) }, - }, - children: [ - heading3('📝 变更详情'), - paragraph(description), - ...(changedFiles ? [ - heading3('📁 变更文件列表'), - paragraph(changedFiles.split(',').join('\n')), - ] : []), - ], + properties, }; try {