viepilot 2.47.0 → 2.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const CHANNELS_REL = path.join('.viepilot', 'intake', 'channels.json');
7
+
8
+ const REQUIRED_CHANNEL_FIELDS = ['id', 'type', 'name'];
9
+ const REQUIRED_COLUMN_MAP_FIELDS = ['title', 'description'];
10
+ const VALID_TYPES = ['csv', 'google_sheets', 'excel_m365'];
11
+
12
+ const CHANNELS_SCAFFOLD = {
13
+ channels: [
14
+ {
15
+ id: 'csv-example',
16
+ type: 'csv',
17
+ name: 'Local CSV Export',
18
+ path: './reports/tickets.csv',
19
+ column_map: {
20
+ id: 'ticket_id',
21
+ title: 'summary',
22
+ description: 'details',
23
+ reporter: 'author',
24
+ date: 'created_at',
25
+ status: 'status',
26
+ },
27
+ },
28
+ {
29
+ id: 'gsheet-example',
30
+ type: 'google_sheets',
31
+ name: 'Google Sheet Tickets',
32
+ spreadsheet_id: 'REPLACE_WITH_SPREADSHEET_ID',
33
+ sheet_name: 'Sheet1',
34
+ column_map: {
35
+ id: 'A',
36
+ title: 'B',
37
+ description: 'C',
38
+ reporter: 'D',
39
+ date: 'E',
40
+ status: 'F',
41
+ },
42
+ },
43
+ {
44
+ id: 'm365-example',
45
+ type: 'excel_m365',
46
+ name: 'M365 Excel Tickets',
47
+ workbook_id: 'REPLACE_WITH_WORKBOOK_ID',
48
+ sheet_name: 'Tickets',
49
+ column_map: {
50
+ id: 'A',
51
+ title: 'B',
52
+ description: 'C',
53
+ reporter: 'D',
54
+ date: 'E',
55
+ status: 'F',
56
+ },
57
+ },
58
+ ],
59
+ };
60
+
61
+ function loadChannels(projectRoot) {
62
+ const channelsPath = path.join(projectRoot, CHANNELS_REL);
63
+ if (!fs.existsSync(channelsPath)) {
64
+ return { channels: [] };
65
+ }
66
+ try {
67
+ return JSON.parse(fs.readFileSync(channelsPath, 'utf8'));
68
+ } catch (e) {
69
+ throw new Error(`Failed to parse channels.json: ${e.message}`);
70
+ }
71
+ }
72
+
73
+ function validateChannel(channel) {
74
+ for (const field of REQUIRED_CHANNEL_FIELDS) {
75
+ if (!channel[field]) {
76
+ throw new Error(`Channel missing required field: "${field}"`);
77
+ }
78
+ }
79
+ if (!VALID_TYPES.includes(channel.type)) {
80
+ throw new Error(`Invalid channel type "${channel.type}". Must be one of: ${VALID_TYPES.join(', ')}`);
81
+ }
82
+ if (!channel.column_map) {
83
+ throw new Error(`Channel "${channel.id}" missing "column_map"`);
84
+ }
85
+ for (const field of REQUIRED_COLUMN_MAP_FIELDS) {
86
+ if (!channel.column_map[field]) {
87
+ throw new Error(`Channel "${channel.id}" column_map missing required field: "${field}"`);
88
+ }
89
+ }
90
+ if (channel.type === 'csv' && !channel.path) {
91
+ throw new Error(`CSV channel "${channel.id}" must have a "path" field`);
92
+ }
93
+ if (channel.type === 'google_sheets' && !channel.spreadsheet_id) {
94
+ throw new Error(`Google Sheets channel "${channel.id}" must have a "spreadsheet_id" field`);
95
+ }
96
+ if (channel.type === 'excel_m365' && !channel.workbook_id) {
97
+ throw new Error(`Excel/M365 channel "${channel.id}" must have a "workbook_id" field`);
98
+ }
99
+ return true;
100
+ }
101
+
102
+ function getChannelById(id, channels) {
103
+ return (channels || []).find((c) => c.id === id) || null;
104
+ }
105
+
106
+ function initIntakeDir(projectRoot) {
107
+ const intakeDir = path.join(projectRoot, '.viepilot', 'intake');
108
+ const channelsPath = path.join(intakeDir, 'channels.json');
109
+ const credentialsDir = path.join(projectRoot, '.viepilot', '.credentials');
110
+ const gitkeepPath = path.join(credentialsDir, '.gitkeep');
111
+ const gitignorePath = path.join(projectRoot, '.gitignore');
112
+
113
+ if (!fs.existsSync(intakeDir)) {
114
+ fs.mkdirSync(intakeDir, { recursive: true });
115
+ }
116
+
117
+ if (!fs.existsSync(channelsPath)) {
118
+ fs.writeFileSync(channelsPath, JSON.stringify(CHANNELS_SCAFFOLD, null, 2) + '\n', 'utf8');
119
+ }
120
+
121
+ if (!fs.existsSync(credentialsDir)) {
122
+ fs.mkdirSync(credentialsDir, { recursive: true });
123
+ }
124
+ if (!fs.existsSync(gitkeepPath)) {
125
+ fs.writeFileSync(gitkeepPath, '', 'utf8');
126
+ }
127
+
128
+ if (fs.existsSync(gitignorePath)) {
129
+ const existing = fs.readFileSync(gitignorePath, 'utf8');
130
+ const entry = '.viepilot/.credentials/';
131
+ if (!existing.includes(entry)) {
132
+ fs.appendFileSync(gitignorePath, `\n# ViePilot OAuth credentials (ENH-082)\n${entry}\n`, 'utf8');
133
+ }
134
+ }
135
+
136
+ return { intakeDir, channelsPath, credentialsDir };
137
+ }
138
+
139
+ module.exports = { loadChannels, validateChannel, getChannelById, initIntakeDir, CHANNELS_SCAFFOLD };
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ const BUG_KEYWORDS = [
4
+ 'bug', 'error', 'fail', 'crash', 'broken', 'fix', 'exception', 'regression',
5
+ 'wrong', 'incorrect', 'unexpected', 'null', 'undefined', '500', '404',
6
+ 'performance', 'slow', 'timeout', 'hang', 'freeze', 'unresponsive',
7
+ // Vietnamese
8
+ 'lỗi', 'sự cố', 'không hoạt động', 'hỏng', 'sai', 'vá lỗi', 'bị lỗi',
9
+ 'chậm', 'treo', 'tắc', 'không phản hồi',
10
+ ];
11
+
12
+ const ENH_KEYWORDS = [
13
+ 'add', 'new', 'feature', 'improve', 'enhance', 'update', 'upgrade',
14
+ 'support', 'request', 'implement', 'integrate', 'extend',
15
+ // Vietnamese
16
+ 'thêm', 'nâng cấp', 'tính năng', 'cải tiến', 'tích hợp', 'mở rộng',
17
+ 'yêu cầu', 'bổ sung', 'phát triển',
18
+ ];
19
+
20
+ function classifyTicket(ticket) {
21
+ const text = `${ticket.title || ''} ${ticket.description || ''}`.toLowerCase();
22
+
23
+ for (const kw of BUG_KEYWORDS) {
24
+ if (text.includes(kw)) return 'BUG';
25
+ }
26
+ for (const kw of ENH_KEYWORDS) {
27
+ if (text.includes(kw)) return 'ENH';
28
+ }
29
+ return 'UNCLEAR';
30
+ }
31
+
32
+ module.exports = { classifyTicket };
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ function truncate(str, max) {
7
+ if (!str) return '';
8
+ return str.length > max ? str.slice(0, max - 1) + '…' : str;
9
+ }
10
+
11
+ function generateTriageReport(channel, triageResult, projectRoot) {
12
+ const root = projectRoot || process.cwd();
13
+ const now = new Date();
14
+ const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
15
+ const tsDisplay = now.toISOString();
16
+
17
+ const accepted = triageResult.accepted || [];
18
+ const declined = triageResult.declined || [];
19
+ const unclear = triageResult.unclear || [];
20
+ const total = accepted.length + declined.length + unclear.length;
21
+
22
+ const acceptedRows = accepted.map(({ ticket, request_id }) =>
23
+ `| ${ticket.id || ticket._source_row} | ${truncate(ticket.title, 50)} | ${ticket._classified || '?'} | ${request_id} |`
24
+ ).join('\n') || '| — | — | — | — |';
25
+
26
+ const declinedRows = declined.map(({ ticket, reason }) =>
27
+ `| ${ticket.id || ticket._source_row} | ${truncate(ticket.title, 50)} | ${truncate(reason, 60)} |`
28
+ ).join('\n') || '| — | — | — |';
29
+
30
+ const unclearRows = unclear.map(({ ticket }) =>
31
+ `| ${ticket.id || ticket._source_row} | ${truncate(ticket.title, 50)} |`
32
+ ).join('\n') || '| — | — |';
33
+
34
+ const pkgVersion = (() => {
35
+ try {
36
+ return require(path.join(root, 'package.json')).version;
37
+ } catch {
38
+ return 'unknown';
39
+ }
40
+ })();
41
+
42
+ const report = `# Triage Report — ${channel.name}
43
+
44
+ **Session**: ${tsDisplay}
45
+ **Channel**: ${channel.name} (${channel.type})
46
+ **Total Tickets**: ${total}
47
+
48
+ ## Summary
49
+
50
+ | Decision | Count |
51
+ |----------|-------|
52
+ | Accepted | ${accepted.length} |
53
+ | Declined | ${declined.length} |
54
+ | Unclear | ${unclear.length} |
55
+
56
+ ## Accepted Tickets
57
+
58
+ | Source ID | Title | Type | Request Created |
59
+ |-----------|-------|------|-----------------|
60
+ ${acceptedRows}
61
+
62
+ ## Declined Tickets
63
+
64
+ | Source ID | Title | Reason |
65
+ |-----------|-------|--------|
66
+ ${declinedRows}
67
+
68
+ ## Unclear Tickets (not actioned)
69
+
70
+ | Source ID | Title |
71
+ |-----------|-------|
72
+ ${unclearRows}
73
+
74
+ ---
75
+ *Generated by ViePilot vp-intake v${pkgVersion}*
76
+ `;
77
+
78
+ const intakeDir = path.join(root, '.viepilot', 'intake');
79
+ if (!fs.existsSync(intakeDir)) {
80
+ fs.mkdirSync(intakeDir, { recursive: true });
81
+ }
82
+
83
+ const reportPath = path.join(intakeDir, `TRIAGE-${ts}.md`);
84
+ fs.writeFileSync(reportPath, report, 'utf8');
85
+ return reportPath;
86
+ }
87
+
88
+ module.exports = { generateTriageReport };
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const DECLINE_REASONS = [
7
+ 'Duplicate — already tracked',
8
+ 'Out of scope for this project',
9
+ "Won't fix — low value",
10
+ 'Deferred to future milestone',
11
+ ];
12
+
13
+ function nextRequestNumber(type, projectRoot) {
14
+ const prefix = type === 'BUG' ? 'BUG-' : 'ENH-';
15
+ const reqDir = path.join(projectRoot, '.viepilot', 'requests');
16
+ if (!fs.existsSync(reqDir)) return 1;
17
+
18
+ const existing = fs.readdirSync(reqDir)
19
+ .filter((f) => f.startsWith(prefix) && f.endsWith('.md'))
20
+ .map((f) => parseInt(f.replace(prefix, '').replace('.md', ''), 10))
21
+ .filter((n) => !isNaN(n));
22
+
23
+ return existing.length ? Math.max(...existing) + 1 : 1;
24
+ }
25
+
26
+ function createRequestFile(ticket, type, channel, projectRoot) {
27
+ const n = nextRequestNumber(type, projectRoot);
28
+ const id = `${type}-${String(n).padStart(3, '0')}`;
29
+ const now = new Date().toISOString().split('T')[0];
30
+ const typeLabel = type === 'BUG' ? 'Bug' : 'Enhancement';
31
+
32
+ const content = `# ${typeLabel}: ${ticket.title}
33
+
34
+ ## Meta
35
+ - **ID**: ${id}
36
+ - **Type**: ${typeLabel}
37
+ - **Status**: new
38
+ - **Priority**: medium
39
+ - **Created**: ${now}
40
+ - **Reporter**: ${ticket.reporter || 'External source'}
41
+ - **Source**: ${channel.name} — ticket #${ticket.id}
42
+ - **Assignee**: AI
43
+
44
+ ## Summary
45
+ ${ticket.description || ticket.title}
46
+
47
+ ## Acceptance Criteria
48
+ - [ ] (derived from description — fill in before planning)
49
+ `;
50
+
51
+ const filePath = path.join(projectRoot, '.viepilot', 'requests', `${id}.md`);
52
+ fs.writeFileSync(filePath, content, 'utf8');
53
+ return { id, filePath };
54
+ }
55
+
56
+ function truncate(str, max) {
57
+ if (!str) return '';
58
+ return str.length > max ? str.slice(0, max - 1) + '…' : str;
59
+ }
60
+
61
+ async function runTriage(tickets, channel, projectRoot, askUserQuestionFn) {
62
+ const accepted = [];
63
+ const declined = [];
64
+ const unclear = [];
65
+
66
+ const bugTickets = tickets.filter((t) => t._classified === 'BUG');
67
+ const enhTickets = tickets.filter((t) => t._classified === 'ENH');
68
+ const unclearTickets = tickets.filter((t) => t._classified === 'UNCLEAR');
69
+
70
+ console.log(`\n BUG: ${bugTickets.length} ENH: ${enhTickets.length} UNCLEAR: ${unclearTickets.length}\n`);
71
+
72
+ const PAGE_SIZE = 4;
73
+
74
+ async function triagePage(pageTickets, labelPrefix) {
75
+ for (let offset = 0; offset < pageTickets.length; offset += PAGE_SIZE) {
76
+ const page = pageTickets.slice(offset, offset + PAGE_SIZE);
77
+ const pageNum = Math.floor(offset / PAGE_SIZE) + 1;
78
+ const totalPages = Math.ceil(pageTickets.length / PAGE_SIZE);
79
+
80
+ const question = totalPages > 1
81
+ ? `${labelPrefix} — Page ${pageNum}/${totalPages}: select tickets to ACCEPT`
82
+ : `${labelPrefix}: select tickets to ACCEPT`;
83
+
84
+ const options = page.map((t) => ({
85
+ label: `[${t._classified}] #${t.id || t._source_row} — ${truncate(t.title, 55)}`,
86
+ description: truncate(t.description, 100),
87
+ }));
88
+
89
+ const answer = await askUserQuestionFn(question, options, true);
90
+ const selectedLabels = Array.isArray(answer) ? answer : (answer ? [answer] : []);
91
+
92
+ for (const t of page) {
93
+ const label = `[${t._classified}] #${t.id || t._source_row} — ${truncate(t.title, 55)}`;
94
+ if (selectedLabels.includes(label)) {
95
+ accepted.push(t);
96
+ } else {
97
+ declined.push(t);
98
+ }
99
+ }
100
+ }
101
+ }
102
+
103
+ if (bugTickets.length > 0) await triagePage(bugTickets, 'BUG tickets');
104
+ if (enhTickets.length > 0) await triagePage(enhTickets, 'ENH tickets');
105
+
106
+ for (const t of unclearTickets) {
107
+ const answer = await askUserQuestionFn(
108
+ `UNCLEAR ticket #${t.id || t._source_row} — "${truncate(t.title, 60)}": how to handle?`,
109
+ [
110
+ { label: 'Accept as BUG', description: 'Create a BUG request' },
111
+ { label: 'Accept as ENH', description: 'Create an ENH request' },
112
+ { label: 'Decline', description: 'Skip this ticket' },
113
+ ],
114
+ false,
115
+ );
116
+ if (answer === 'Accept as BUG') {
117
+ t._classified = 'BUG';
118
+ accepted.push(t);
119
+ } else if (answer === 'Accept as ENH') {
120
+ t._classified = 'ENH';
121
+ accepted.push(t);
122
+ } else {
123
+ declined.push(t);
124
+ }
125
+ }
126
+
127
+ const acceptedWithIds = [];
128
+ for (const t of accepted) {
129
+ const { id } = createRequestFile(t, t._classified, channel, projectRoot);
130
+ acceptedWithIds.push({ ticket: t, request_id: id });
131
+ console.log(` ✓ Created ${id} — ${t.title}`);
132
+ }
133
+
134
+ const declinedWithReasons = [];
135
+ for (const t of declined) {
136
+ const reason = await askUserQuestionFn(
137
+ `Reason for declining #${t.id || t._source_row} — "${truncate(t.title, 50)}"?`,
138
+ DECLINE_REASONS.map((r) => ({ label: r, description: '' })),
139
+ false,
140
+ );
141
+ declinedWithReasons.push({ ticket: t, reason: reason || 'No reason provided' });
142
+ }
143
+
144
+ return {
145
+ accepted: acceptedWithIds,
146
+ declined: declinedWithReasons,
147
+ unclear,
148
+ };
149
+ }
150
+
151
+ module.exports = { runTriage, nextRequestNumber };
@@ -0,0 +1,228 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const https = require('https');
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // CSV write-back
9
+ // ---------------------------------------------------------------------------
10
+
11
+ function parseCsvLine(line, delimiter) {
12
+ const result = [];
13
+ let current = '';
14
+ let inQuotes = false;
15
+ for (let i = 0; i < line.length; i++) {
16
+ const ch = line[i];
17
+ if (ch === '"') {
18
+ if (inQuotes && line[i + 1] === '"') { current += '"'; i++; }
19
+ else inQuotes = !inQuotes;
20
+ } else if (ch === delimiter && !inQuotes) {
21
+ result.push(current); current = '';
22
+ } else {
23
+ current += ch;
24
+ }
25
+ }
26
+ result.push(current);
27
+ return result;
28
+ }
29
+
30
+ function csvCell(value) {
31
+ const s = String(value || '');
32
+ if (s.includes(',') || s.includes('"') || s.includes('\n')) {
33
+ return `"${s.replace(/"/g, '""')}"`;
34
+ }
35
+ return s;
36
+ }
37
+
38
+ function buildTicketDecisions(triageResult) {
39
+ const decisions = {};
40
+ for (const { ticket, request_id } of triageResult.accepted) {
41
+ decisions[ticket._source_row] = { vp_status: 'accepted', vp_comment: '', vp_request_id: request_id };
42
+ }
43
+ for (const { ticket, reason } of triageResult.declined) {
44
+ decisions[ticket._source_row] = { vp_status: 'declined', vp_comment: reason, vp_request_id: '' };
45
+ }
46
+ for (const { ticket } of (triageResult.unclear || [])) {
47
+ decisions[ticket._source_row] = { vp_status: 'unclear', vp_comment: '', vp_request_id: '' };
48
+ }
49
+ return decisions;
50
+ }
51
+
52
+ async function writebackCsv(channel, triageResult, projectRoot) {
53
+ const filePath = path.resolve(projectRoot || process.cwd(), channel.path);
54
+ if (!fs.existsSync(filePath)) {
55
+ return { success: false, error: `CSV file not found: ${filePath}` };
56
+ }
57
+
58
+ try {
59
+ const ext = path.extname(filePath).toLowerCase();
60
+ const delimiter = ext === '.tsv' ? '\t' : ',';
61
+ const content = fs.readFileSync(filePath, 'utf8');
62
+ const lines = content.split(/\r?\n/);
63
+ if (lines.length === 0) return { success: true };
64
+
65
+ let headers = parseCsvLine(lines[0], delimiter);
66
+ const VP_COLS = ['VP_Status', 'VP_Comment', 'VP_RequestID'];
67
+ const existingVpIdx = VP_COLS.map((col) => headers.indexOf(col));
68
+ const hasVpCols = existingVpIdx.every((i) => i >= 0);
69
+
70
+ if (!hasVpCols) {
71
+ headers = [...headers, ...VP_COLS];
72
+ }
73
+
74
+ const decisions = buildTicketDecisions(triageResult);
75
+ const outputLines = [headers.map(csvCell).join(delimiter)];
76
+
77
+ for (let i = 1; i < lines.length; i++) {
78
+ if (!lines[i].trim()) { outputLines.push(''); continue; }
79
+ const row = parseCsvLine(lines[i], delimiter);
80
+ const d = decisions[i];
81
+ if (d) {
82
+ while (row.length < headers.length) row.push('');
83
+ const statusIdx = headers.indexOf('VP_Status');
84
+ const commentIdx = headers.indexOf('VP_Comment');
85
+ const reqIdx = headers.indexOf('VP_RequestID');
86
+ if (statusIdx >= 0) row[statusIdx] = d.vp_status;
87
+ if (commentIdx >= 0) row[commentIdx] = d.vp_comment;
88
+ if (reqIdx >= 0) row[reqIdx] = d.vp_request_id;
89
+ }
90
+ outputLines.push(row.map(csvCell).join(delimiter));
91
+ }
92
+
93
+ fs.writeFileSync(filePath, outputLines.join('\n'), 'utf8');
94
+ return { success: true };
95
+ } catch (e) {
96
+ return { success: false, error: e.message };
97
+ }
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Google Sheets write-back (batchUpdate)
102
+ // ---------------------------------------------------------------------------
103
+
104
+ function httpsRequest(method, url, body, headers) {
105
+ return new Promise((resolve, reject) => {
106
+ const urlObj = new URL(url);
107
+ const payload = body ? JSON.stringify(body) : '';
108
+ const req = https.request({
109
+ hostname: urlObj.hostname,
110
+ path: urlObj.pathname + urlObj.search,
111
+ method,
112
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload), ...headers },
113
+ }, (res) => {
114
+ let data = '';
115
+ res.on('data', (chunk) => { data += chunk; });
116
+ res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve(data); } });
117
+ });
118
+ req.on('error', reject);
119
+ if (payload) req.write(payload);
120
+ req.end();
121
+ });
122
+ }
123
+
124
+ function colLetterToIndex(letter) {
125
+ let index = 0;
126
+ for (const ch of letter.toUpperCase()) index = index * 26 + ch.charCodeAt(0) - 64;
127
+ return index - 1;
128
+ }
129
+
130
+ function indexToColLetter(idx) {
131
+ let letter = '';
132
+ idx++;
133
+ while (idx > 0) {
134
+ const rem = (idx - 1) % 26;
135
+ letter = String.fromCharCode(65 + rem) + letter;
136
+ idx = Math.floor((idx - 1) / 26);
137
+ }
138
+ return letter;
139
+ }
140
+
141
+ async function getGoogleAccessToken(projectRoot) {
142
+ const credPath = path.join(projectRoot, '.viepilot', '.credentials', 'google-service-account.json');
143
+ if (!fs.existsSync(credPath)) return null;
144
+ const keyJson = JSON.parse(fs.readFileSync(credPath, 'utf8'));
145
+ const now = Math.floor(Date.now() / 1000);
146
+ const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
147
+ const payload = Buffer.from(JSON.stringify({
148
+ iss: keyJson.client_email,
149
+ scope: 'https://www.googleapis.com/auth/spreadsheets',
150
+ aud: 'https://oauth2.googleapis.com/token',
151
+ exp: now + 3600, iat: now,
152
+ })).toString('base64url');
153
+ const crypto = require('crypto');
154
+ const sign = crypto.createSign('RSA-SHA256');
155
+ sign.update(`${header}.${payload}`);
156
+ const sig = sign.sign(keyJson.private_key, 'base64url');
157
+ const jwt = `${header}.${payload}.${sig}`;
158
+ const body = `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=${jwt}`;
159
+ const res = await httpsRequest('POST', 'https://oauth2.googleapis.com/token', null, {
160
+ 'Content-Type': 'application/x-www-form-urlencoded',
161
+ 'Content-Length': Buffer.byteLength(body),
162
+ });
163
+ return res.access_token || null;
164
+ }
165
+
166
+ async function writebackGoogleSheets(channel, triageResult, projectRoot) {
167
+ try {
168
+ const root = projectRoot || process.cwd();
169
+ const token = await getGoogleAccessToken(root);
170
+ if (!token) return { success: false, error: 'No Google credentials for write-back' };
171
+
172
+ const decisions = buildTicketDecisions(triageResult);
173
+ const allTickets = [
174
+ ...triageResult.accepted.map((a) => a.ticket),
175
+ ...triageResult.declined.map((d) => d.ticket),
176
+ ];
177
+ if (allTickets.length === 0) return { success: true };
178
+
179
+ const colMap = channel.column_map;
180
+ const lastCol = colLetterToIndex(Object.values(colMap).sort().pop() || 'F');
181
+ const vpStatusCol = indexToColLetter(lastCol + 1);
182
+ const vpCommentCol = indexToColLetter(lastCol + 2);
183
+ const vpReqCol = indexToColLetter(lastCol + 3);
184
+
185
+ const valueRanges = [];
186
+ for (const ticket of allTickets) {
187
+ const d = decisions[ticket._source_row];
188
+ if (!d) continue;
189
+ const rowNum = ticket._source_row + 1;
190
+ const sheetName = channel.sheet_name || 'Sheet1';
191
+ valueRanges.push({ range: `${sheetName}!${vpStatusCol}${rowNum}`, values: [[d.vp_status]] });
192
+ valueRanges.push({ range: `${sheetName}!${vpCommentCol}${rowNum}`, values: [[d.vp_comment]] });
193
+ valueRanges.push({ range: `${sheetName}!${vpReqCol}${rowNum}`, values: [[d.vp_request_id]] });
194
+ }
195
+
196
+ const url = `https://sheets.googleapis.com/v4/spreadsheets/${channel.spreadsheet_id}/values:batchUpdate`;
197
+ await httpsRequest('POST', url, { valueInputOption: 'RAW', data: valueRanges }, { Authorization: `Bearer ${token}` });
198
+ return { success: true };
199
+ } catch (e) {
200
+ return { success: false, error: e.message };
201
+ }
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Main writeback dispatcher
206
+ // ---------------------------------------------------------------------------
207
+
208
+ async function writeback(channel, triageResult, projectRoot) {
209
+ const root = projectRoot || process.cwd();
210
+ let result;
211
+
212
+ if (channel.type === 'csv') {
213
+ result = await writebackCsv(channel, triageResult, root);
214
+ } else if (channel.type === 'google_sheets') {
215
+ result = await writebackGoogleSheets(channel, triageResult, root);
216
+ } else if (channel.type === 'excel_m365') {
217
+ result = { success: false, error: 'Excel/M365 write-back requires Graph API — see task 123.4' };
218
+ } else {
219
+ result = { success: false, error: `Unknown channel type: ${channel.type}` };
220
+ }
221
+
222
+ if (!result.success) {
223
+ console.warn(` ⚠ Write-back warning: ${result.error} — triage report is the source of truth`);
224
+ }
225
+ return result;
226
+ }
227
+
228
+ module.exports = { writeback };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viepilot",
3
- "version": "2.47.0",
3
+ "version": "2.49.0",
4
4
  "description": "**Autonomous Vibe Coding Framework / Bộ khung phát triển tự động có kiểm soát**",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -74,7 +74,8 @@
74
74
  },
75
75
  "dependencies": {
76
76
  "docx": "^9.0.0",
77
- "pptxgenjs": "^3.12.0"
77
+ "pptxgenjs": "^3.12.0",
78
+ "xlsx": "^0.18.5"
78
79
  },
79
80
  "optionalDependencies": {
80
81
  "@googleapis/slides": "^1.0.0"