feat: add TCS semantic landing workflow and script for [TCS-MEMO] and [TCS-TABLE] issue processing
Co-authored-by: qinfendebingshuo <207279273+qinfendebingshuo@users.noreply.github.com> Agent-Logs-Url: https://github.com/qinfendebingshuo/guanghulab/sessions/122c9353-e45b-46f7-aa46-9e50d0432f5c
This commit is contained in:
parent
ad52e4e3d4
commit
76c375db18
|
|
@ -0,0 +1,41 @@
|
|||
# ═══════════════════════════════════════════════
|
||||
# 🔺 Sovereign: TCS-0002∞ | Root: SYS-GLW-0001
|
||||
# 📜 Copyright: 国作登字-2026-A-00037559
|
||||
# ═══════════════════════════════════════════════
|
||||
name: 🛰️ TCS 语义直连落盘 · Semantic Landing
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
jobs:
|
||||
semantic-landing:
|
||||
name: 📡 语义落盘 → Google Drive
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
contains(github.event.issue.title, '[TCS-MEMO]') ||
|
||||
contains(github.event.issue.title, '[TCS-TABLE]')
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: 🔧 安装依赖
|
||||
run: npm install googleapis
|
||||
|
||||
- name: 🛰️ 执行语义落盘
|
||||
env:
|
||||
GOOGLE_DRIVE_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_DRIVE_SERVICE_ACCOUNT }}
|
||||
DRIVE_LANDING_FOLDER_ID: '1fqWdLPaZkUZYt4OT_h3fJQHnd-q5QN3G'
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: node scripts/tcs-semantic-landing.js
|
||||
|
|
@ -0,0 +1,481 @@
|
|||
/**
|
||||
* scripts/tcs-semantic-landing.js
|
||||
*
|
||||
* 功能:语义直连落盘 (Semantic Direct Landing)
|
||||
*
|
||||
* 监听含 [TCS-MEMO] 或 [TCS-TABLE] 标题的 Issue,执行以下动作:
|
||||
* - [TCS-MEMO]:将 Issue Body 创建/更新为 Google Docs(原生文档)
|
||||
* - [TCS-TABLE]:将 Issue Body 解析为 CSV/JSON,写入 Google Sheets(原生表格)
|
||||
*
|
||||
* 所有文档投射至目标文件夹 ID:1fqWdLPaZkUZYt4OT_h3fJQHnd-q5QN3G
|
||||
* 完成后在 Issue 下回复文档/表格直连 ID。
|
||||
*
|
||||
* 环境变量:
|
||||
* - GOOGLE_DRIVE_SERVICE_ACCOUNT: Service Account JSON 密钥
|
||||
* - DRIVE_LANDING_FOLDER_ID: 目标 Drive 文件夹 ID
|
||||
* - GITHUB_TOKEN: GitHub API 令牌
|
||||
* - ISSUE_NUMBER: Issue 编号
|
||||
* - ISSUE_TITLE: Issue 标题
|
||||
* - ISSUE_BODY: Issue 正文内容
|
||||
* - GITHUB_REPOSITORY: owner/repo
|
||||
*
|
||||
* 守护: PER-ZY001 铸渊
|
||||
* 系统: SYS-GLW-0001
|
||||
* 主权: TCS-0002∞
|
||||
*/
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const https = require('https');
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// 常量
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
const TAG_MEMO = '[TCS-MEMO]';
|
||||
const TAG_TABLE = '[TCS-TABLE]';
|
||||
const LANDING_SUBFOLDER = 'tcs-semantic-landing';
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// Google API 认证
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
function buildAuth(serviceAccountJson) {
|
||||
const credentials = JSON.parse(serviceAccountJson);
|
||||
return new google.auth.GoogleAuth({
|
||||
credentials,
|
||||
scopes: [
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
'https://www.googleapis.com/auth/documents',
|
||||
'https://www.googleapis.com/auth/spreadsheets'
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// Drive 辅助函数
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
function escapeQuery(str) {
|
||||
return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 Drive 中查找或创建子文件夹
|
||||
*/
|
||||
async function getOrCreateFolder(drive, parentId, folderName) {
|
||||
const safeName = escapeQuery(folderName);
|
||||
const query = `name='${safeName}' and '${parentId}' in parents and mimeType='application/vnd.google-apps.folder' and trashed=false`;
|
||||
const res = await drive.files.list({
|
||||
q: query,
|
||||
fields: 'files(id, name)',
|
||||
pageSize: 1
|
||||
});
|
||||
|
||||
if (res.data.files && res.data.files.length > 0) {
|
||||
return res.data.files[0].id;
|
||||
}
|
||||
|
||||
const createRes = await drive.files.create({
|
||||
requestBody: {
|
||||
name: folderName,
|
||||
mimeType: 'application/vnd.google-apps.folder',
|
||||
parents: [parentId]
|
||||
},
|
||||
fields: 'id'
|
||||
});
|
||||
console.log(`[semantic-landing] Created folder: ${folderName}`);
|
||||
return createRes.data.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 Drive 文件夹中查找同名文件
|
||||
*/
|
||||
async function findFileByName(drive, folderId, fileName, mimeType) {
|
||||
const safeName = escapeQuery(fileName);
|
||||
let query = `name='${safeName}' and '${folderId}' in parents and trashed=false`;
|
||||
if (mimeType) {
|
||||
query += ` and mimeType='${mimeType}'`;
|
||||
}
|
||||
const res = await drive.files.list({
|
||||
q: query,
|
||||
fields: 'files(id, name)',
|
||||
pageSize: 1
|
||||
});
|
||||
|
||||
if (res.data.files && res.data.files.length > 0) {
|
||||
return res.data.files[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// [TCS-MEMO] 处理:创建/更新 Google Docs
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
async function handleMemo(auth, drive, folderId, issueTitle, issueBody, issueNumber) {
|
||||
const docs = google.docs({ version: 'v1', auth });
|
||||
const docTitle = `TCS-MEMO #${issueNumber}: ${issueTitle.replace(TAG_MEMO, '').trim()}`;
|
||||
const content = issueBody || '(empty)';
|
||||
|
||||
// 检查是否已存在同名文档
|
||||
const existingFile = await findFileByName(
|
||||
drive, folderId, docTitle, 'application/vnd.google-apps.document'
|
||||
);
|
||||
|
||||
let docId;
|
||||
|
||||
if (existingFile) {
|
||||
// 更新:先清空再写入
|
||||
docId = existingFile.id;
|
||||
console.log(`[semantic-landing] Updating existing doc: ${docId}`);
|
||||
|
||||
// 获取当前文档内容长度
|
||||
const docData = await docs.documents.get({ documentId: docId });
|
||||
const endIndex = docData.data.body.content
|
||||
.reduce((max, el) => Math.max(max, el.endIndex || 0), 1);
|
||||
|
||||
const requests = [];
|
||||
if (endIndex > 2) {
|
||||
requests.push({
|
||||
deleteContentRange: {
|
||||
range: { startIndex: 1, endIndex: endIndex - 1 }
|
||||
}
|
||||
});
|
||||
}
|
||||
requests.push({
|
||||
insertText: {
|
||||
location: { index: 1 },
|
||||
text: content
|
||||
}
|
||||
});
|
||||
|
||||
await docs.documents.batchUpdate({
|
||||
documentId: docId,
|
||||
requestBody: { requests }
|
||||
});
|
||||
console.log(`[semantic-landing] Doc updated: ${docId}`);
|
||||
} else {
|
||||
// 创建新 Google Doc
|
||||
const createRes = await drive.files.create({
|
||||
requestBody: {
|
||||
name: docTitle,
|
||||
mimeType: 'application/vnd.google-apps.document',
|
||||
parents: [folderId]
|
||||
},
|
||||
fields: 'id'
|
||||
});
|
||||
docId = createRes.data.id;
|
||||
console.log(`[semantic-landing] Doc created: ${docId}`);
|
||||
|
||||
// 写入内容
|
||||
await docs.documents.batchUpdate({
|
||||
documentId: docId,
|
||||
requestBody: {
|
||||
requests: [{
|
||||
insertText: {
|
||||
location: { index: 1 },
|
||||
text: content
|
||||
}
|
||||
}]
|
||||
}
|
||||
});
|
||||
console.log(`[semantic-landing] Content written to doc: ${docId}`);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'Google Docs',
|
||||
id: docId,
|
||||
url: `https://docs.google.com/document/d/${docId}/edit`,
|
||||
action: existingFile ? 'updated' : 'created'
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// [TCS-TABLE] 处理:创建/更新 Google Sheets
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 从 Issue Body 解析表格数据
|
||||
* 支持格式:
|
||||
* 1. JSON 数组: [["a","b"],["c","d"]]
|
||||
* 2. CSV 文本块 (用 ```csv 包裹或纯 CSV)
|
||||
* 3. Markdown 表格: | col1 | col2 |
|
||||
*/
|
||||
function parseTableData(body) {
|
||||
if (!body || !body.trim()) {
|
||||
return [['(empty)']];
|
||||
}
|
||||
|
||||
// 1. 尝试 JSON 解析
|
||||
const jsonMatch = body.match(/```(?:json)?\s*\n([\s\S]*?)\n```/);
|
||||
if (jsonMatch) {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonMatch[1].trim());
|
||||
if (Array.isArray(parsed)) {
|
||||
if (parsed.length > 0 && Array.isArray(parsed[0])) {
|
||||
return parsed; // 二维数组
|
||||
}
|
||||
if (parsed.length > 0 && typeof parsed[0] === 'object') {
|
||||
// 对象数组 → 表头 + 行
|
||||
const headers = Object.keys(parsed[0]);
|
||||
const rows = parsed.map(obj => headers.map(h => String(obj[h] ?? '')));
|
||||
return [headers, ...rows];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 不是 JSON,继续其他解析
|
||||
}
|
||||
}
|
||||
|
||||
// 也尝试直接从 body 解析 JSON(不在代码块中)
|
||||
const trimmed = body.trim();
|
||||
if (trimmed.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (Array.isArray(parsed)) {
|
||||
if (parsed.length > 0 && Array.isArray(parsed[0])) {
|
||||
return parsed;
|
||||
}
|
||||
if (parsed.length > 0 && typeof parsed[0] === 'object') {
|
||||
const headers = Object.keys(parsed[0]);
|
||||
const rows = parsed.map(obj => headers.map(h => String(obj[h] ?? '')));
|
||||
return [headers, ...rows];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 继续其他解析
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 尝试 CSV 解析(代码块内或纯文本)
|
||||
const csvMatch = body.match(/```(?:csv)?\s*\n([\s\S]*?)\n```/);
|
||||
const csvContent = csvMatch ? csvMatch[1].trim() : null;
|
||||
|
||||
if (csvContent) {
|
||||
return parseCSV(csvContent);
|
||||
}
|
||||
|
||||
// 3. 尝试 Markdown 表格解析
|
||||
const mdTableMatch = body.match(/(\|[^\n]+\|\s*\n\|[-\s|:]+\|\s*\n(?:\|[^\n]+\|\s*\n?)*)/);
|
||||
if (mdTableMatch) {
|
||||
return parseMarkdownTable(mdTableMatch[1]);
|
||||
}
|
||||
|
||||
// 4. 检查整个 body 是否为 CSV 格式(包含逗号分隔的多行)
|
||||
const lines = trimmed.split('\n').filter(l => l.trim());
|
||||
if (lines.length >= 2 && lines.every(l => l.includes(','))) {
|
||||
return parseCSV(trimmed);
|
||||
}
|
||||
|
||||
// 5. 降级:整个 body 作为单格数据
|
||||
return [[trimmed]];
|
||||
}
|
||||
|
||||
function parseCSV(text) {
|
||||
return text
|
||||
.split('\n')
|
||||
.filter(line => line.trim())
|
||||
.map(line => line.split(',').map(cell => cell.trim()));
|
||||
}
|
||||
|
||||
function parseMarkdownTable(text) {
|
||||
const lines = text.split('\n').filter(l => l.trim());
|
||||
const rows = [];
|
||||
|
||||
for (const line of lines) {
|
||||
// 跳过分隔行 |---|---|
|
||||
if (/^\|[\s\-:|]+\|$/.test(line.trim())) continue;
|
||||
const cells = line
|
||||
.split('|')
|
||||
.slice(1, -1) // 去掉首尾空元素
|
||||
.map(c => c.trim());
|
||||
if (cells.length > 0) {
|
||||
rows.push(cells);
|
||||
}
|
||||
}
|
||||
|
||||
return rows.length > 0 ? rows : [['(empty)']];
|
||||
}
|
||||
|
||||
async function handleTable(auth, drive, folderId, issueTitle, issueBody, issueNumber) {
|
||||
const sheets = google.sheets({ version: 'v4', auth });
|
||||
const sheetTitle = `TCS-TABLE #${issueNumber}: ${issueTitle.replace(TAG_TABLE, '').trim()}`;
|
||||
const tableData = parseTableData(issueBody);
|
||||
|
||||
// 检查是否已存在同名表格
|
||||
const existingFile = await findFileByName(
|
||||
drive, folderId, sheetTitle, 'application/vnd.google-apps.spreadsheet'
|
||||
);
|
||||
|
||||
let spreadsheetId;
|
||||
|
||||
if (existingFile) {
|
||||
spreadsheetId = existingFile.id;
|
||||
console.log(`[semantic-landing] Updating existing sheet: ${spreadsheetId}`);
|
||||
|
||||
// 清空并写入
|
||||
await sheets.spreadsheets.values.clear({
|
||||
spreadsheetId,
|
||||
range: 'Sheet1'
|
||||
});
|
||||
|
||||
await sheets.spreadsheets.values.update({
|
||||
spreadsheetId,
|
||||
range: 'Sheet1!A1',
|
||||
valueInputOption: 'USER_ENTERED',
|
||||
requestBody: {
|
||||
values: tableData
|
||||
}
|
||||
});
|
||||
console.log(`[semantic-landing] Sheet updated: ${spreadsheetId}`);
|
||||
} else {
|
||||
// 创建新 Spreadsheet
|
||||
const createRes = await drive.files.create({
|
||||
requestBody: {
|
||||
name: sheetTitle,
|
||||
mimeType: 'application/vnd.google-apps.spreadsheet',
|
||||
parents: [folderId]
|
||||
},
|
||||
fields: 'id'
|
||||
});
|
||||
spreadsheetId = createRes.data.id;
|
||||
console.log(`[semantic-landing] Sheet created: ${spreadsheetId}`);
|
||||
|
||||
// 写入数据
|
||||
await sheets.spreadsheets.values.update({
|
||||
spreadsheetId,
|
||||
range: 'Sheet1!A1',
|
||||
valueInputOption: 'USER_ENTERED',
|
||||
requestBody: {
|
||||
values: tableData
|
||||
}
|
||||
});
|
||||
console.log(`[semantic-landing] Data written to sheet: ${spreadsheetId}`);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'Google Sheets',
|
||||
id: spreadsheetId,
|
||||
url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit`,
|
||||
action: existingFile ? 'updated' : 'created'
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// GitHub Issue 回复
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
function postIssueComment(token, repo, issueNumber, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const [owner, repoName] = repo.split('/');
|
||||
const data = JSON.stringify({ body });
|
||||
const options = {
|
||||
hostname: 'api.github.com',
|
||||
path: `/repos/${owner}/${repoName}/issues/${issueNumber}/comments`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'zhuyuan-semantic-landing',
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(data)
|
||||
}
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let body = '';
|
||||
res.on('data', chunk => body += chunk);
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(JSON.parse(body));
|
||||
} else {
|
||||
reject(new Error(`GitHub API ${res.statusCode}: ${body}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// 主流程
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
async function main() {
|
||||
const serviceAccountJson = process.env.GOOGLE_DRIVE_SERVICE_ACCOUNT;
|
||||
const rootFolderId = process.env.DRIVE_LANDING_FOLDER_ID;
|
||||
const githubToken = process.env.GITHUB_TOKEN;
|
||||
const issueNumber = process.env.ISSUE_NUMBER;
|
||||
const issueTitle = process.env.ISSUE_TITLE || '';
|
||||
const issueBody = process.env.ISSUE_BODY || '';
|
||||
const repo = process.env.GITHUB_REPOSITORY;
|
||||
|
||||
// 验证必需环境变量
|
||||
if (!serviceAccountJson) {
|
||||
console.error('[semantic-landing] GOOGLE_DRIVE_SERVICE_ACCOUNT not set');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!rootFolderId) {
|
||||
console.error('[semantic-landing] DRIVE_LANDING_FOLDER_ID not set');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!githubToken || !issueNumber || !repo) {
|
||||
console.error('[semantic-landing] Missing GITHUB_TOKEN, ISSUE_NUMBER, or GITHUB_REPOSITORY');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 判断类型
|
||||
const isMemo = issueTitle.includes(TAG_MEMO);
|
||||
const isTable = issueTitle.includes(TAG_TABLE);
|
||||
|
||||
if (!isMemo && !isTable) {
|
||||
console.log('[semantic-landing] Issue title does not contain [TCS-MEMO] or [TCS-TABLE], skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[semantic-landing] Processing Issue #${issueNumber}: ${issueTitle}`);
|
||||
console.log(`[semantic-landing] Type: ${isMemo ? 'MEMO (Google Docs)' : 'TABLE (Google Sheets)'}`);
|
||||
|
||||
// 初始化 Google API
|
||||
const auth = buildAuth(serviceAccountJson);
|
||||
const drive = google.drive({ version: 'v3', auth });
|
||||
|
||||
// 确保子文件夹存在
|
||||
const landingFolderId = await getOrCreateFolder(drive, rootFolderId, LANDING_SUBFOLDER);
|
||||
|
||||
let result;
|
||||
if (isMemo) {
|
||||
result = await handleMemo(auth, drive, landingFolderId, issueTitle, issueBody, issueNumber);
|
||||
} else {
|
||||
result = await handleTable(auth, drive, landingFolderId, issueTitle, issueBody, issueNumber);
|
||||
}
|
||||
|
||||
console.log(`[semantic-landing] ${result.action}: ${result.type} → ${result.id}`);
|
||||
|
||||
// 回复 Issue
|
||||
const now = new Date().toISOString();
|
||||
const comment = [
|
||||
'## 📡 语义落盘完成 · Semantic Landing Complete',
|
||||
'',
|
||||
`> **类型 (Type)**: ${result.type}`,
|
||||
`> **动作 (Action)**: ${result.action}`,
|
||||
`> **文档 ID (Doc ID)**: \`${result.id}\``,
|
||||
`> **直连 (Direct Link)**: [打开文档](${result.url})`,
|
||||
`> **目标文件夹**: \`tcs-semantic-landing/\``,
|
||||
`> **完成时间**: ${now}`,
|
||||
'',
|
||||
'✅ 格点已对齐,主控人格可通过 Doc ID 进行读取核验。',
|
||||
'',
|
||||
'> *—— 铸渊(ICE-GL-ZY001)· 代码守护人格体*'
|
||||
].join('\n');
|
||||
|
||||
await postIssueComment(githubToken, repo, issueNumber, comment);
|
||||
console.log(`[semantic-landing] Feedback posted to Issue #${issueNumber}`);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('[semantic-landing] Fatal error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Reference in New Issue