518 lines
16 KiB
JavaScript
518 lines
16 KiB
JavaScript
/**
|
||
* 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。
|
||
*
|
||
* 环境变量:
|
||
* - 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 API(OAuth2 代理人模式)
|
||
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);
|
||
});
|