guanghulab/scripts/tcs-semantic-landing.js

518 lines
16 KiB
JavaScript
Raw Normal View History

/**
* 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
*
* 环境变量
* - GDRIVE_CLIENT_ID: OAuth 客户端 ID
* - GDRIVE_CLIENT_SECRET: OAuth 客户端密钥
* - GDRIVE_REFRESH_TOKEN: 长效刷新令牌
* - 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 认证OAuth2 代理人模式)
// ═══════════════════════════════════════════════
function buildAuth() {
const { getOAuth2Client } = require('./grid-db/drive-auth');
return getOAuth2Client();
}
// ═══════════════════════════════════════════════
// 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}`);
// 获取当前文档内容长度
// Google Docs body content elements each have an endIndex; we need the max
// to know where existing content ends. Start at 1 because index 0 is reserved
// by Docs for the document root and content starts at index 1.
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. Check if body looks like CSV (consistent comma-separated columns across lines)
const lines = trimmed.split('\n').filter(l => l.trim());
if (lines.length >= 2 && lines.every(l => l.includes(','))) {
const colCounts = lines.map(l => parseCSVLine(l).length);
const firstCount = colCounts[0];
if (firstCount >= 2 && colCounts.every(c => c === firstCount)) {
return parseCSV(trimmed);
}
}
// 5. 降级:整个 body 作为单格数据
return [[trimmed]];
}
/**
* Parse a single CSV line, handling quoted values containing commas.
*/
function parseCSVLine(line) {
const cells = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuotes) {
if (ch === '"' && line[i + 1] === '"') {
current += '"';
i++; // skip escaped quote
} else if (ch === '"') {
inQuotes = false;
} else {
current += ch;
}
} else {
if (ch === '"') {
inQuotes = true;
} else if (ch === ',') {
cells.push(current.trim());
current = '';
} else {
current += ch;
}
}
}
cells.push(current.trim());
return cells;
}
function parseCSV(text) {
return text
.split('\n')
.filter(line => line.trim())
.map(line => parseCSVLine(line));
}
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 responseBody = '';
res.on('data', chunk => responseBody += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
resolve(JSON.parse(responseBody));
} catch (e) {
resolve({ raw: responseBody });
}
} else {
reject(new Error(`GitHub API ${res.statusCode}: ${responseBody}`));
}
});
});
req.on('error', reject);
req.write(data);
req.end();
});
}
// ═══════════════════════════════════════════════
// 主流程
// ═══════════════════════════════════════════════
async function main() {
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 (!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 APIOAuth2 代理人模式)
const auth = buildAuth();
const drive = google.drive({ version: 'v3', auth });
console.log('[semantic-landing] ✅ OAuth2 credentials configured');
// 确保子文件夹存在
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);
});