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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.49.0] - 2026-05-17
11
+
12
+ ### Added
13
+ - **ENH-083** `vp-intake` — SharePoint sharing link + xlsx direct download support (Phase 124):
14
+ - **SharePoint anonymous sharing link** adapter: URLs matching `/:x:/g/` or `/:x:/r/` are
15
+ resolved via the SharePoint WOPI viewer page — `FileGetUrl` (temp-auth token) is scraped
16
+ from the HTML and used to download the xlsx file without OAuth credentials
17
+ - **xlsx parser** using SheetJS (`xlsx` npm package): downloaded xlsx Buffer is parsed to
18
+ `rows[][]` matching the Graph API response format; falls back to first sheet when the
19
+ configured sheet name is not found
20
+ - **`sharing_url` field** in `channels.json` (`excel_m365` type): optional alternative to
21
+ `workbook_id` for SharePoint "Anyone with the link" files; no credentials required
22
+ - **Redirect-following HTTP client** (`httpsGetRaw`): browser User-Agent, follows 301/302/307
23
+ chains needed for SharePoint sharing link flow
24
+ - **Routing logic in `readExcelM365`**: `sharing_url` present → anonymous WOPI flow;
25
+ `workbook_id` present → Graph API (OAuth); neither present → `AuthRequiredError` with guide
26
+ - **BUG_KEYWORDS extended**: `performance`, `slow`, `timeout`, `hang`, `freeze`,
27
+ `unresponsive`, `chậm`, `treo`, `tắc`, `không phản hồi` — "performance issue" now correctly
28
+ classifies as BUG instead of UNCLEAR
29
+ - **19 new tests** (1897 total): keyword expansion (8 tests), `isSharingLink` detection (5
30
+ tests), routing logic (3 tests), `parseXlsxBuffer` (3 tests)
31
+
32
+ ## [2.48.0] - 2026-05-17
33
+
34
+ ### Added
35
+ - **ENH-082** `vp-intake` skill — import and triage tickets from external sources (Phase 123):
36
+ - **Excel/Microsoft 365 Online** adapter via Microsoft Graph API (client credentials OAuth 2.0)
37
+ - **Google Sheets** adapter via Sheets API v4 (Service Account OAuth 2.0)
38
+ - **CSV/TSV local file** adapter with configurable column mapping
39
+ - **Heuristic classifier** auto-assigns `BUG` / `ENH` / `UNCLEAR` per ticket; supports
40
+ English and Vietnamese keywords; BUG takes precedence over ENH
41
+ - **AskUserQuestion triage**: multi-select accept/decline per ticket (paginated at 4/page);
42
+ UNCLEAR tickets get a 3-choice prompt (Accept as BUG / Accept as ENH / Decline)
43
+ - **Reason collection**: declined tickets prompt user for decline reason via AUQ single-select
44
+ - **Auto-create request files**: accepted tickets generate `.viepilot/requests/BUG-N.md` or
45
+ `ENH-N.md` with source channel attribution
46
+ - **Write-back engine**: updates `VP_Status`, `VP_Comment`, `VP_RequestID` columns in source
47
+ (CSV overwrite, Google Sheets batchUpdate); write-back failure is non-fatal
48
+ - **TRIAGE session report**: `.viepilot/intake/TRIAGE-{timestamp}.md` generated after every
49
+ session with accepted/declined/unclear summary tables
50
+ - **`channels.json` config**: `.viepilot/intake/channels.json` supports multiple concurrent
51
+ channels; validated schema with required field checks
52
+ - **`vp-tools intake-init`** command: scaffolds `.viepilot/intake/channels.json` (3 example
53
+ channels), creates `.viepilot/.credentials/` (gitignored) — idempotent
54
+
10
55
  ## [2.47.0] - 2026-05-06
11
56
 
12
57
  ### Added
package/bin/vp-tools.cjs CHANGED
@@ -1288,6 +1288,21 @@ ${colors.cyan}Examples:${colors.reset}
1288
1288
  process.exit(0);
1289
1289
  },
1290
1290
 
1291
+ /**
1292
+ * Initialize .viepilot/intake/ directory with channels.json scaffold (ENH-082)
1293
+ */
1294
+ 'intake-init': (_args) => {
1295
+ const { initIntakeDir } = require('../lib/intake/channels.cjs');
1296
+ const projectCheck = validators.requireProjectRoot();
1297
+ validateArgs([projectCheck]);
1298
+ const projectRoot = projectCheck.value;
1299
+ const { channelsPath, credentialsDir } = initIntakeDir(projectRoot);
1300
+ console.log(formatSuccess('intake/ initialized'));
1301
+ console.log(` channels.json : ${channelsPath}`);
1302
+ console.log(` credentials : ${credentialsDir}`);
1303
+ console.log('\nEdit .viepilot/intake/channels.json to configure your ticket channels.');
1304
+ },
1305
+
1291
1306
  /**
1292
1307
  * Help
1293
1308
  */
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ function parseCsvLine(line, delimiter) {
7
+ const result = [];
8
+ let current = '';
9
+ let inQuotes = false;
10
+
11
+ for (let i = 0; i < line.length; i++) {
12
+ const ch = line[i];
13
+ if (ch === '"') {
14
+ if (inQuotes && line[i + 1] === '"') {
15
+ current += '"';
16
+ i++;
17
+ } else {
18
+ inQuotes = !inQuotes;
19
+ }
20
+ } else if (ch === delimiter && !inQuotes) {
21
+ result.push(current.trim());
22
+ current = '';
23
+ } else {
24
+ current += ch;
25
+ }
26
+ }
27
+ result.push(current.trim());
28
+ return result;
29
+ }
30
+
31
+ function applyColumnMap(headers, row, columnMap) {
32
+ const headerIndex = {};
33
+ headers.forEach((h, i) => { headerIndex[h.trim()] = i; });
34
+
35
+ const get = (fieldName) => {
36
+ const mapped = columnMap[fieldName];
37
+ if (!mapped) return '';
38
+ const idx = headerIndex[mapped];
39
+ return idx !== undefined ? (row[idx] || '') : '';
40
+ };
41
+
42
+ return {
43
+ id: get('id'),
44
+ title: get('title'),
45
+ description: get('description'),
46
+ reporter: get('reporter'),
47
+ date: get('date'),
48
+ status: get('status'),
49
+ };
50
+ }
51
+
52
+ async function readCsv(channel, projectRoot) {
53
+ const filePath = path.resolve(projectRoot || process.cwd(), channel.path);
54
+ if (!fs.existsSync(filePath)) {
55
+ throw new Error(`CSV file not found: ${filePath}`);
56
+ }
57
+
58
+ const ext = path.extname(filePath).toLowerCase();
59
+ const delimiter = ext === '.tsv' ? '\t' : ',';
60
+
61
+ const content = fs.readFileSync(filePath, 'utf8');
62
+ const lines = content.split(/\r?\n/).filter((l) => l.trim() !== '');
63
+
64
+ if (lines.length === 0) return [];
65
+
66
+ const headers = parseCsvLine(lines[0], delimiter);
67
+ const tickets = [];
68
+
69
+ for (let i = 1; i < lines.length; i++) {
70
+ const row = parseCsvLine(lines[i], delimiter);
71
+ const ticket = applyColumnMap(headers, row, channel.column_map);
72
+ if (!ticket.title && !ticket.description) continue;
73
+ ticket._source_row = i;
74
+ ticket._channel_id = channel.id;
75
+ tickets.push(ticket);
76
+ }
77
+
78
+ return tickets;
79
+ }
80
+
81
+ module.exports = { readCsv };
@@ -0,0 +1,328 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const https = require('https');
7
+
8
+ const CREDENTIALS_REL = path.join('.viepilot', '.credentials', 'm365-credentials.json');
9
+
10
+ const SETUP_GUIDE = `
11
+ Microsoft 365 / Excel setup:
12
+ 1. Go to portal.azure.com → Azure Active Directory → App Registrations → New
13
+ 2. Add API permission: Microsoft Graph → Files.Read.All (Application) → Grant admin consent
14
+ 3. Certificates & Secrets → New client secret → copy value immediately
15
+ 4. Save credentials to: .viepilot/.credentials/m365-credentials.json
16
+ { "tenant_id": "...", "client_id": "...", "client_secret": "..." }
17
+ 5. In channels.json, set "workbook_id" to the file's drive item ID
18
+ (visible in the file's URL on SharePoint/OneDrive)
19
+ 6. Re-run vp-intake
20
+
21
+ For SharePoint sharing links ("Anyone with the link"), use "sharing_url" in channels.json
22
+ instead of "workbook_id" — no credentials required.
23
+ `;
24
+
25
+ class AuthRequiredError extends Error {
26
+ constructor(msg) {
27
+ super(msg);
28
+ this.name = 'AuthRequiredError';
29
+ }
30
+ }
31
+
32
+ let _tokenCache = null;
33
+
34
+ function colLetterToIndex(letter) {
35
+ let index = 0;
36
+ for (const ch of letter.toUpperCase()) {
37
+ index = index * 26 + ch.charCodeAt(0) - 64;
38
+ }
39
+ return index - 1;
40
+ }
41
+
42
+ // ─── HTTP helpers ─────────────────────────────────────────────────────────────
43
+
44
+ function httpsPost(url, body, headers) {
45
+ return new Promise((resolve, reject) => {
46
+ const payload = typeof body === 'string' ? body : JSON.stringify(body);
47
+ const urlObj = new URL(url);
48
+ const req = https.request({
49
+ hostname: urlObj.hostname,
50
+ path: urlObj.pathname + urlObj.search,
51
+ method: 'POST',
52
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(payload), ...headers },
53
+ }, (res) => {
54
+ let data = '';
55
+ res.on('data', (chunk) => { data += chunk; });
56
+ res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve(data); } });
57
+ });
58
+ req.on('error', reject);
59
+ req.write(payload);
60
+ req.end();
61
+ });
62
+ }
63
+
64
+ function httpsGet(url, headers) {
65
+ return new Promise((resolve, reject) => {
66
+ const urlObj = new URL(url);
67
+ const req = https.request({
68
+ hostname: urlObj.hostname,
69
+ path: urlObj.pathname + urlObj.search,
70
+ method: 'GET',
71
+ headers: { Accept: 'application/json', ...headers },
72
+ }, (res) => {
73
+ let data = '';
74
+ res.on('data', (chunk) => { data += chunk; });
75
+ res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve(data); } });
76
+ });
77
+ req.on('error', reject);
78
+ req.end();
79
+ });
80
+ }
81
+
82
+ /** Follow redirects and return {status, headers, body} */
83
+ function httpsGetRaw(url, headers = {}, maxRedirects = 6) {
84
+ return new Promise((resolve, reject) => {
85
+ function doGet(currentUrl, remaining) {
86
+ const urlObj = new URL(currentUrl);
87
+ const req = https.request({
88
+ hostname: urlObj.hostname,
89
+ path: urlObj.pathname + urlObj.search,
90
+ method: 'GET',
91
+ headers: {
92
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
93
+ ...headers,
94
+ },
95
+ }, (res) => {
96
+ if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && remaining > 0) {
97
+ let nextUrl = res.headers.location;
98
+ if (!nextUrl.startsWith('http')) {
99
+ nextUrl = `${urlObj.protocol}//${urlObj.host}${nextUrl}`;
100
+ }
101
+ return doGet(nextUrl, remaining - 1);
102
+ }
103
+ const chunks = [];
104
+ res.on('data', (chunk) => chunks.push(chunk));
105
+ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }));
106
+ });
107
+ req.on('error', reject);
108
+ req.end();
109
+ }
110
+ doGet(url, maxRedirects);
111
+ });
112
+ }
113
+
114
+ function encodeFormData(obj) {
115
+ return Object.entries(obj)
116
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
117
+ .join('&');
118
+ }
119
+
120
+ // ─── Graph API (client credentials) ──────────────────────────────────────────
121
+
122
+ async function getAccessToken(creds) {
123
+ const now = Math.floor(Date.now() / 1000);
124
+ if (_tokenCache && _tokenCache.expires_at > now + 60) {
125
+ return _tokenCache.access_token;
126
+ }
127
+
128
+ const url = `https://login.microsoftonline.com/${creds.tenant_id}/oauth2/v2.0/token`;
129
+ const res = await httpsPost(url, encodeFormData({
130
+ grant_type: 'client_credentials',
131
+ client_id: creds.client_id,
132
+ client_secret: creds.client_secret,
133
+ scope: 'https://graph.microsoft.com/.default',
134
+ }));
135
+
136
+ if (!res.access_token) {
137
+ throw new Error(`M365 auth failed: ${JSON.stringify(res)}`);
138
+ }
139
+
140
+ _tokenCache = { access_token: res.access_token, expires_at: now + (res.expires_in || 3600) };
141
+ return res.access_token;
142
+ }
143
+
144
+ async function readViaGraphApi(channel, projectRoot) {
145
+ const root = projectRoot || process.cwd();
146
+ const credPath = path.join(root, CREDENTIALS_REL);
147
+
148
+ if (!fs.existsSync(credPath)) {
149
+ throw new AuthRequiredError(SETUP_GUIDE);
150
+ }
151
+
152
+ let creds;
153
+ try {
154
+ creds = JSON.parse(fs.readFileSync(credPath, 'utf8'));
155
+ } catch (e) {
156
+ throw new Error(`Failed to parse m365-credentials.json: ${e.message}`);
157
+ }
158
+
159
+ if (!creds.tenant_id || !creds.client_id || !creds.client_secret) {
160
+ throw new AuthRequiredError(`m365-credentials.json is incomplete.${SETUP_GUIDE}`);
161
+ }
162
+
163
+ const token = await getAccessToken(creds);
164
+ const sheetName = encodeURIComponent(channel.sheet_name || 'Sheet1');
165
+ const url = `https://graph.microsoft.com/v1.0/me/drive/items/${channel.workbook_id}/workbook/worksheets/${sheetName}/usedRange`;
166
+ const response = await httpsGet(url, { Authorization: `Bearer ${token}` });
167
+
168
+ if (!response.values || response.values.length === 0) return [];
169
+ return parseRowsWithColumnMap(response.values, channel.column_map);
170
+ }
171
+
172
+ // ─── SharePoint sharing link (anonymous WOPI) ─────────────────────────────────
173
+
174
+ /** Detect sharing link type: /:x:/g/ or /:x:/r/ paths */
175
+ function isSharingLink(url) {
176
+ return /\/:x:\/[gr]\//.test(url);
177
+ }
178
+
179
+ /**
180
+ * Resolve a SharePoint anonymous sharing link to a direct download URL.
181
+ * Technique: load WOPI viewer page → scrape FileGetUrl (temp auth token embedded in HTML).
182
+ */
183
+ async function resolveSharePointDownloadUrl(sharingUrl) {
184
+ // First request: follow redirects to the WOPI viewer page
185
+ const { status, body } = await httpsGetRaw(sharingUrl);
186
+ if (status !== 200) {
187
+ throw new Error(`SharePoint sharing link returned HTTP ${status}. The link may be expired or restricted.`);
188
+ }
189
+
190
+ const html = body.toString('utf8');
191
+
192
+ // Scrape FileGetUrl — SharePoint embeds a temp-auth download URL in the viewer HTML
193
+ const match = html.match(/"FileGetUrl"\s*:\s*"([^"]+)"/);
194
+ if (!match) {
195
+ throw new Error('Could not extract download URL from SharePoint viewer page. The file may require sign-in.');
196
+ }
197
+
198
+ // Unescape JSON unicode escapes (\\u0026 → &)
199
+ return match[1].replace(/\\u([\da-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
200
+ }
201
+
202
+ /** Download xlsx bytes from a URL (no auth, temp token already embedded) */
203
+ async function downloadXlsx(downloadUrl) {
204
+ const { status, body } = await httpsGetRaw(downloadUrl, {
205
+ Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,*/*',
206
+ });
207
+ if (status !== 200) {
208
+ throw new Error(`Failed to download xlsx: HTTP ${status}`);
209
+ }
210
+ return body; // Buffer
211
+ }
212
+
213
+ // ─── xlsx parser (SheetJS) ────────────────────────────────────────────────────
214
+
215
+ /**
216
+ * Parse xlsx Buffer → array of row arrays (like response.values from Graph API).
217
+ * Uses the `xlsx` npm package (SheetJS).
218
+ */
219
+ function parseXlsxBuffer(buffer, sheetName) {
220
+ let XLSX;
221
+ try {
222
+ XLSX = require('xlsx');
223
+ } catch {
224
+ throw new Error(
225
+ 'The "xlsx" package is required to parse xlsx files from sharing links.\n' +
226
+ 'Install it: npm install xlsx'
227
+ );
228
+ }
229
+
230
+ const workbook = XLSX.read(buffer, { type: 'buffer' });
231
+
232
+ let sheet;
233
+ if (sheetName && workbook.SheetNames.includes(sheetName)) {
234
+ sheet = workbook.Sheets[sheetName];
235
+ } else {
236
+ // Default to first sheet; warn if requested sheet not found
237
+ if (sheetName) {
238
+ process.stderr.write(`[vp-intake] Sheet "${sheetName}" not found. Using "${workbook.SheetNames[0]}" instead.\n`);
239
+ }
240
+ sheet = workbook.Sheets[workbook.SheetNames[0]];
241
+ }
242
+
243
+ if (!sheet) return [];
244
+
245
+ // Convert to array of arrays (raw values)
246
+ const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '' });
247
+ return rows;
248
+ }
249
+
250
+ async function readViaSharingLink(channel) {
251
+ const sharingUrl = channel.sharing_url;
252
+ const downloadUrl = await resolveSharePointDownloadUrl(sharingUrl);
253
+ const xlsxBuffer = await downloadXlsx(downloadUrl);
254
+ const rows = parseXlsxBuffer(xlsxBuffer, channel.sheet_name || null);
255
+
256
+ if (!rows || rows.length === 0) return [];
257
+ return parseRowsWithColumnMap(rows, channel.column_map);
258
+ }
259
+
260
+ // ─── Common row → ticket mapper ───────────────────────────────────────────────
261
+
262
+ function parseRowsWithColumnMap(values, colMap) {
263
+ const tickets = [];
264
+
265
+ for (let i = 1; i < values.length; i++) {
266
+ const row = values[i];
267
+ const get = (field) => {
268
+ const col = colMap[field];
269
+ if (!col) return '';
270
+ const idx = colLetterToIndex(col);
271
+ return row[idx] !== undefined ? String(row[idx]) : '';
272
+ };
273
+
274
+ const ticket = {
275
+ id: get('id'),
276
+ title: get('title'),
277
+ description: get('description'),
278
+ reporter: get('reporter'),
279
+ date: get('date'),
280
+ status: get('status'),
281
+ _source_row: i,
282
+ _channel_id: channel ? channel.id : undefined,
283
+ };
284
+
285
+ if (!ticket.title && !ticket.description) continue;
286
+ tickets.push(ticket);
287
+ }
288
+
289
+ return tickets;
290
+ }
291
+
292
+ // ─── Public entry point ───────────────────────────────────────────────────────
293
+
294
+ async function readExcelM365(channel, projectRoot) {
295
+ // Sharing link mode: no credentials required
296
+ if (channel.sharing_url) {
297
+ if (isSharingLink(channel.sharing_url)) {
298
+ return readViaSharingLink(channel);
299
+ }
300
+ throw new Error(
301
+ `sharing_url "${channel.sharing_url}" is not a SharePoint sharing link.\n` +
302
+ 'Sharing links look like: https://xxx.sharepoint.com/:x:/g/personal/xxx/EdXXX...\n' +
303
+ 'For direct document links (Doc.aspx?sourcedoc=...), use workbook_id + credentials instead.'
304
+ );
305
+ }
306
+
307
+ // Graph API mode: requires workbook_id + credentials
308
+ if (!channel.workbook_id) {
309
+ throw new AuthRequiredError(
310
+ 'excel_m365 channel requires either "sharing_url" (for SharePoint sharing links) or ' +
311
+ '"workbook_id" (for Graph API).' + SETUP_GUIDE
312
+ );
313
+ }
314
+
315
+ return readViaGraphApi(channel, projectRoot);
316
+ }
317
+
318
+ function clearTokenCache() { _tokenCache = null; }
319
+
320
+ module.exports = {
321
+ readExcelM365,
322
+ AuthRequiredError,
323
+ clearTokenCache,
324
+ // exported for testing
325
+ isSharingLink,
326
+ resolveSharePointDownloadUrl,
327
+ parseXlsxBuffer,
328
+ };
@@ -0,0 +1,161 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const https = require('https');
7
+
8
+ const CREDENTIALS_REL = path.join('.viepilot', '.credentials', 'google-service-account.json');
9
+
10
+ const SETUP_GUIDE = `
11
+ Google Sheets setup:
12
+ 1. Go to console.cloud.google.com → Create project → Enable Sheets API
13
+ 2. IAM → Service Accounts → Create → Download JSON key
14
+ 3. Save key to: .viepilot/.credentials/google-service-account.json
15
+ 4. Share your Google Sheet with the service account email (viewer access)
16
+ 5. Re-run vp-intake
17
+ `;
18
+
19
+ class AuthRequiredError extends Error {
20
+ constructor(msg) {
21
+ super(msg);
22
+ this.name = 'AuthRequiredError';
23
+ }
24
+ }
25
+
26
+ function colLetterToIndex(letter) {
27
+ let index = 0;
28
+ for (const ch of letter.toUpperCase()) {
29
+ index = index * 26 + ch.charCodeAt(0) - 64;
30
+ }
31
+ return index - 1;
32
+ }
33
+
34
+ function httpsPost(url, body, headers) {
35
+ return new Promise((resolve, reject) => {
36
+ const payload = typeof body === 'string' ? body : JSON.stringify(body);
37
+ const urlObj = new URL(url);
38
+ const req = https.request({
39
+ hostname: urlObj.hostname,
40
+ path: urlObj.pathname + urlObj.search,
41
+ method: 'POST',
42
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(payload), ...headers },
43
+ }, (res) => {
44
+ let data = '';
45
+ res.on('data', (chunk) => { data += chunk; });
46
+ res.on('end', () => {
47
+ try { resolve(JSON.parse(data)); } catch { resolve(data); }
48
+ });
49
+ });
50
+ req.on('error', reject);
51
+ req.write(payload);
52
+ req.end();
53
+ });
54
+ }
55
+
56
+ function httpsGet(url, headers) {
57
+ return new Promise((resolve, reject) => {
58
+ const urlObj = new URL(url);
59
+ const req = https.request({
60
+ hostname: urlObj.hostname,
61
+ path: urlObj.pathname + urlObj.search,
62
+ method: 'GET',
63
+ headers: { Accept: 'application/json', ...headers },
64
+ }, (res) => {
65
+ let data = '';
66
+ res.on('data', (chunk) => { data += chunk; });
67
+ res.on('end', () => {
68
+ try { resolve(JSON.parse(data)); } catch { resolve(data); }
69
+ });
70
+ });
71
+ req.on('error', reject);
72
+ req.end();
73
+ });
74
+ }
75
+
76
+ function encodeFormData(obj) {
77
+ return Object.entries(obj)
78
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
79
+ .join('&');
80
+ }
81
+
82
+ async function getAccessToken(keyJson, projectRoot) {
83
+ const now = Math.floor(Date.now() / 1000);
84
+ const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
85
+ const payload = Buffer.from(JSON.stringify({
86
+ iss: keyJson.client_email,
87
+ scope: 'https://www.googleapis.com/auth/spreadsheets.readonly',
88
+ aud: 'https://oauth2.googleapis.com/token',
89
+ exp: now + 3600,
90
+ iat: now,
91
+ })).toString('base64url');
92
+
93
+ const crypto = require('crypto');
94
+ const sign = crypto.createSign('RSA-SHA256');
95
+ sign.update(`${header}.${payload}`);
96
+ const signature = sign.sign(keyJson.private_key, 'base64url');
97
+ const jwt = `${header}.${payload}.${signature}`;
98
+
99
+ const tokenRes = await httpsPost(
100
+ 'https://oauth2.googleapis.com/token',
101
+ encodeFormData({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: jwt }),
102
+ );
103
+ if (!tokenRes.access_token) {
104
+ throw new Error(`Google auth failed: ${JSON.stringify(tokenRes)}`);
105
+ }
106
+ return tokenRes.access_token;
107
+ }
108
+
109
+ async function readGoogleSheet(channel, projectRoot) {
110
+ const root = projectRoot || process.cwd();
111
+ const credPath = path.join(root, CREDENTIALS_REL);
112
+
113
+ if (!fs.existsSync(credPath)) {
114
+ throw new AuthRequiredError(SETUP_GUIDE);
115
+ }
116
+
117
+ let keyJson;
118
+ try {
119
+ keyJson = JSON.parse(fs.readFileSync(credPath, 'utf8'));
120
+ } catch (e) {
121
+ throw new Error(`Failed to parse google-service-account.json: ${e.message}`);
122
+ }
123
+
124
+ const accessToken = await getAccessToken(keyJson, root);
125
+ const sheetName = encodeURIComponent(channel.sheet_name || 'Sheet1');
126
+ const url = `https://sheets.googleapis.com/v4/spreadsheets/${channel.spreadsheet_id}/values/${sheetName}!A:Z`;
127
+ const response = await httpsGet(url, { Authorization: `Bearer ${accessToken}` });
128
+
129
+ if (!response.values || response.values.length === 0) return [];
130
+
131
+ const colMap = channel.column_map;
132
+ const tickets = [];
133
+
134
+ for (let i = 1; i < response.values.length; i++) {
135
+ const row = response.values[i];
136
+ const get = (field) => {
137
+ const col = colMap[field];
138
+ if (!col) return '';
139
+ const idx = colLetterToIndex(col);
140
+ return row[idx] || '';
141
+ };
142
+
143
+ const ticket = {
144
+ id: get('id'),
145
+ title: get('title'),
146
+ description: get('description'),
147
+ reporter: get('reporter'),
148
+ date: get('date'),
149
+ status: get('status'),
150
+ _source_row: i,
151
+ _channel_id: channel.id,
152
+ };
153
+
154
+ if (!ticket.title && !ticket.description) continue;
155
+ tickets.push(ticket);
156
+ }
157
+
158
+ return tickets;
159
+ }
160
+
161
+ module.exports = { readGoogleSheet, AuthRequiredError };