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 +45 -0
- package/bin/vp-tools.cjs +15 -0
- package/lib/intake/adapters/csv.cjs +81 -0
- package/lib/intake/adapters/excel-m365.cjs +328 -0
- package/lib/intake/adapters/google-sheets.cjs +161 -0
- package/lib/intake/channels.cjs +139 -0
- package/lib/intake/classifier.cjs +32 -0
- package/lib/intake/report.cjs +88 -0
- package/lib/intake/triage-ux.cjs +151 -0
- package/lib/intake/writeback.cjs +228 -0
- package/package.json +3 -2
- package/skills/vp-intake/SKILL.md +236 -0
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 };
|