feat: GitHub → Notion data bridge (Pipeline A & E)

- 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>
This commit is contained in:
copilot-swe-agent[bot] 2026-03-06 07:06:49 +00:00
parent 248ef71aaf
commit f6a6dc2732
3 changed files with 227 additions and 146 deletions

View File

@ -1,9 +1,12 @@
name: 铸渊 · Bridge E · GitHub Changes → Notion
# 管道 Emain 分支有 push 或 PR 创建/关闭 → 变更摘要同步到 Notion 变更日志数据库
# 依赖 Secrets
# NOTION_TOKEN Notion 集成 token见仓库 Settings → Secrets → Actions
# NOTION_CHANGES_DB_ID 「GitHub 变更日志」Notion 数据库 ID
# 管道 Emain 分支有 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

View File

@ -1,15 +1,19 @@
name: 铸渊 · Bridge A · SYSLOG → Notion
# 管道 Asyslog-inbox/ 有新文件 push → 同步到 Notion 数据库
# 依赖 Secrets
# NOTION_TOKEN Notion 集成 token见仓库 Settings → Secrets → Actions
# NOTION_SYSLOG_DB_ID 「GitHub SYSLOG 收件箱」Notion 数据库 ID
# 管道 Asyslog-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

View File

@ -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 messagepush 事件)
// COMMITTER 提交者用户名
// COMMIT_TIMESTAMP 提交时间 ISO
// CHANGED_FILES 换行符分隔的变更文件路径列表
// BRANCH 分支名
// PR_NUMBER PR 编号pr 事件)
// PR_TITLE PR 标题pr 事件)
// PR_ACTION opened | closedpr 事件)
// PR_MERGED true | falsepr 事件)
/**
* 将单次 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 {