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:
copilot-swe-agent[bot] 2026-03-24 00:59:49 +00:00
parent ad52e4e3d4
commit 76c375db18
2 changed files with 522 additions and 0 deletions

View File

@ -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

View File

@ -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原生表格
*
* 所有文档投射至目标文件夹 ID1fqWdLPaZkUZYt4OT_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);
});