From 76c375db18e3bbfb7d3864a6ae29bc7eaaa6fded Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 00:59:49 +0000 Subject: [PATCH] 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 --- .github/workflows/tcs-semantic-landing.yml | 41 ++ scripts/tcs-semantic-landing.js | 481 +++++++++++++++++++++ 2 files changed, 522 insertions(+) create mode 100644 .github/workflows/tcs-semantic-landing.yml create mode 100644 scripts/tcs-semantic-landing.js diff --git a/.github/workflows/tcs-semantic-landing.yml b/.github/workflows/tcs-semantic-landing.yml new file mode 100644 index 00000000..8d6ef25b --- /dev/null +++ b/.github/workflows/tcs-semantic-landing.yml @@ -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 diff --git a/scripts/tcs-semantic-landing.js b/scripts/tcs-semantic-landing.js new file mode 100644 index 00000000..ab51589d --- /dev/null +++ b/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); +});