viepilot 2.47.0 → 2.48.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 +23 -0
- package/bin/vp-tools.cjs +15 -0
- package/lib/intake/adapters/csv.cjs +81 -0
- package/lib/intake/adapters/excel-m365.cjs +162 -0
- package/lib/intake/adapters/google-sheets.cjs +161 -0
- package/lib/intake/channels.cjs +139 -0
- package/lib/intake/classifier.cjs +30 -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 +1 -1
- package/skills/vp-intake/SKILL.md +236 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.48.0] - 2026-05-17
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **ENH-082** `vp-intake` skill — import and triage tickets from external sources (Phase 123):
|
|
14
|
+
- **Excel/Microsoft 365 Online** adapter via Microsoft Graph API (client credentials OAuth 2.0)
|
|
15
|
+
- **Google Sheets** adapter via Sheets API v4 (Service Account OAuth 2.0)
|
|
16
|
+
- **CSV/TSV local file** adapter with configurable column mapping
|
|
17
|
+
- **Heuristic classifier** auto-assigns `BUG` / `ENH` / `UNCLEAR` per ticket; supports
|
|
18
|
+
English and Vietnamese keywords; BUG takes precedence over ENH
|
|
19
|
+
- **AskUserQuestion triage**: multi-select accept/decline per ticket (paginated at 4/page);
|
|
20
|
+
UNCLEAR tickets get a 3-choice prompt (Accept as BUG / Accept as ENH / Decline)
|
|
21
|
+
- **Reason collection**: declined tickets prompt user for decline reason via AUQ single-select
|
|
22
|
+
- **Auto-create request files**: accepted tickets generate `.viepilot/requests/BUG-N.md` or
|
|
23
|
+
`ENH-N.md` with source channel attribution
|
|
24
|
+
- **Write-back engine**: updates `VP_Status`, `VP_Comment`, `VP_RequestID` columns in source
|
|
25
|
+
(CSV overwrite, Google Sheets batchUpdate); write-back failure is non-fatal
|
|
26
|
+
- **TRIAGE session report**: `.viepilot/intake/TRIAGE-{timestamp}.md` generated after every
|
|
27
|
+
session with accepted/declined/unclear summary tables
|
|
28
|
+
- **`channels.json` config**: `.viepilot/intake/channels.json` supports multiple concurrent
|
|
29
|
+
channels; validated schema with required field checks
|
|
30
|
+
- **`vp-tools intake-init`** command: scaffolds `.viepilot/intake/channels.json` (3 example
|
|
31
|
+
channels), creates `.viepilot/.credentials/` (gitignored) — idempotent
|
|
32
|
+
|
|
10
33
|
## [2.47.0] - 2026-05-06
|
|
11
34
|
|
|
12
35
|
### 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,162 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const https = require('https');
|
|
6
|
+
|
|
7
|
+
const CREDENTIALS_REL = path.join('.viepilot', '.credentials', 'm365-credentials.json');
|
|
8
|
+
|
|
9
|
+
const SETUP_GUIDE = `
|
|
10
|
+
Microsoft 365 / Excel setup:
|
|
11
|
+
1. Go to portal.azure.com → Azure Active Directory → App Registrations → New
|
|
12
|
+
2. Add API permission: Microsoft Graph → Files.Read.All (Application) → Grant admin consent
|
|
13
|
+
3. Certificates & Secrets → New client secret → copy value immediately
|
|
14
|
+
4. Save credentials to: .viepilot/.credentials/m365-credentials.json
|
|
15
|
+
{ "tenant_id": "...", "client_id": "...", "client_secret": "..." }
|
|
16
|
+
5. In channels.json, set "workbook_id" to the file's drive item ID
|
|
17
|
+
(visible in the file's URL on SharePoint/OneDrive)
|
|
18
|
+
6. Re-run vp-intake
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
class AuthRequiredError extends Error {
|
|
22
|
+
constructor(msg) {
|
|
23
|
+
super(msg);
|
|
24
|
+
this.name = 'AuthRequiredError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let _tokenCache = null;
|
|
29
|
+
|
|
30
|
+
function colLetterToIndex(letter) {
|
|
31
|
+
let index = 0;
|
|
32
|
+
for (const ch of letter.toUpperCase()) {
|
|
33
|
+
index = index * 26 + ch.charCodeAt(0) - 64;
|
|
34
|
+
}
|
|
35
|
+
return index - 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function httpsPost(url, body, headers) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const payload = typeof body === 'string' ? body : JSON.stringify(body);
|
|
41
|
+
const urlObj = new URL(url);
|
|
42
|
+
const req = https.request({
|
|
43
|
+
hostname: urlObj.hostname,
|
|
44
|
+
path: urlObj.pathname + urlObj.search,
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(payload), ...headers },
|
|
47
|
+
}, (res) => {
|
|
48
|
+
let data = '';
|
|
49
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
50
|
+
res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve(data); } });
|
|
51
|
+
});
|
|
52
|
+
req.on('error', reject);
|
|
53
|
+
req.write(payload);
|
|
54
|
+
req.end();
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function httpsGet(url, headers) {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const urlObj = new URL(url);
|
|
61
|
+
const req = https.request({
|
|
62
|
+
hostname: urlObj.hostname,
|
|
63
|
+
path: urlObj.pathname + urlObj.search,
|
|
64
|
+
method: 'GET',
|
|
65
|
+
headers: { Accept: 'application/json', ...headers },
|
|
66
|
+
}, (res) => {
|
|
67
|
+
let data = '';
|
|
68
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
69
|
+
res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve(data); } });
|
|
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(creds) {
|
|
83
|
+
const now = Math.floor(Date.now() / 1000);
|
|
84
|
+
if (_tokenCache && _tokenCache.expires_at > now + 60) {
|
|
85
|
+
return _tokenCache.access_token;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const url = `https://login.microsoftonline.com/${creds.tenant_id}/oauth2/v2.0/token`;
|
|
89
|
+
const res = await httpsPost(url, encodeFormData({
|
|
90
|
+
grant_type: 'client_credentials',
|
|
91
|
+
client_id: creds.client_id,
|
|
92
|
+
client_secret: creds.client_secret,
|
|
93
|
+
scope: 'https://graph.microsoft.com/.default',
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
if (!res.access_token) {
|
|
97
|
+
throw new Error(`M365 auth failed: ${JSON.stringify(res)}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
_tokenCache = { access_token: res.access_token, expires_at: now + (res.expires_in || 3600) };
|
|
101
|
+
return res.access_token;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function readExcelM365(channel, projectRoot) {
|
|
105
|
+
const root = projectRoot || process.cwd();
|
|
106
|
+
const credPath = path.join(root, CREDENTIALS_REL);
|
|
107
|
+
|
|
108
|
+
if (!fs.existsSync(credPath)) {
|
|
109
|
+
throw new AuthRequiredError(SETUP_GUIDE);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let creds;
|
|
113
|
+
try {
|
|
114
|
+
creds = JSON.parse(fs.readFileSync(credPath, 'utf8'));
|
|
115
|
+
} catch (e) {
|
|
116
|
+
throw new Error(`Failed to parse m365-credentials.json: ${e.message}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!creds.tenant_id || !creds.client_id || !creds.client_secret) {
|
|
120
|
+
throw new AuthRequiredError(`m365-credentials.json is incomplete.${SETUP_GUIDE}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const token = await getAccessToken(creds);
|
|
124
|
+
const sheetName = encodeURIComponent(channel.sheet_name || 'Sheet1');
|
|
125
|
+
const url = `https://graph.microsoft.com/v1.0/me/drive/items/${channel.workbook_id}/workbook/worksheets/${sheetName}/usedRange`;
|
|
126
|
+
const response = await httpsGet(url, { Authorization: `Bearer ${token}` });
|
|
127
|
+
|
|
128
|
+
if (!response.values || response.values.length === 0) return [];
|
|
129
|
+
|
|
130
|
+
const colMap = channel.column_map;
|
|
131
|
+
const tickets = [];
|
|
132
|
+
|
|
133
|
+
for (let i = 1; i < response.values.length; i++) {
|
|
134
|
+
const row = response.values[i];
|
|
135
|
+
const get = (field) => {
|
|
136
|
+
const col = colMap[field];
|
|
137
|
+
if (!col) return '';
|
|
138
|
+
const idx = colLetterToIndex(col);
|
|
139
|
+
return row[idx] !== undefined ? String(row[idx]) : '';
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const ticket = {
|
|
143
|
+
id: get('id'),
|
|
144
|
+
title: get('title'),
|
|
145
|
+
description: get('description'),
|
|
146
|
+
reporter: get('reporter'),
|
|
147
|
+
date: get('date'),
|
|
148
|
+
status: get('status'),
|
|
149
|
+
_source_row: i,
|
|
150
|
+
_channel_id: channel.id,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
if (!ticket.title && !ticket.description) continue;
|
|
154
|
+
tickets.push(ticket);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return tickets;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function clearTokenCache() { _tokenCache = null; }
|
|
161
|
+
|
|
162
|
+
module.exports = { readExcelM365, AuthRequiredError, clearTokenCache };
|
|
@@ -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 };
|
|
@@ -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,30 @@
|
|
|
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
|
+
// Vietnamese
|
|
7
|
+
'lỗi', 'sự cố', 'không hoạt động', 'hỏng', 'sai', 'vá lỗi', 'bị lỗi',
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
const ENH_KEYWORDS = [
|
|
11
|
+
'add', 'new', 'feature', 'improve', 'enhance', 'update', 'upgrade',
|
|
12
|
+
'support', 'request', 'implement', 'integrate', 'extend',
|
|
13
|
+
// Vietnamese
|
|
14
|
+
'thêm', 'nâng cấp', 'tính năng', 'cải tiến', 'tích hợp', 'mở rộng',
|
|
15
|
+
'yêu cầu', 'bổ sung', 'phát triển',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function classifyTicket(ticket) {
|
|
19
|
+
const text = `${ticket.title || ''} ${ticket.description || ''}`.toLowerCase();
|
|
20
|
+
|
|
21
|
+
for (const kw of BUG_KEYWORDS) {
|
|
22
|
+
if (text.includes(kw)) return 'BUG';
|
|
23
|
+
}
|
|
24
|
+
for (const kw of ENH_KEYWORDS) {
|
|
25
|
+
if (text.includes(kw)) return 'ENH';
|
|
26
|
+
}
|
|
27
|
+
return 'UNCLEAR';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
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
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: vp-intake
|
|
3
|
+
description: "Import and triage tickets from Excel/M365 Online, Google Sheets, or CSV/TSV files — classify as BUG/ENH, accept/decline via AskUserQuestion, write back to source, generate TRIAGE report"
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<greeting>
|
|
8
|
+
## Invocation Banner
|
|
9
|
+
|
|
10
|
+
Output this banner as the **first** thing on every invocation — before questions, work, or any other output:
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
14
|
+
VIEPILOT ► VP-INTAKE v1.0.0 (fw 2.48.0)
|
|
15
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
16
|
+
```
|
|
17
|
+
</greeting>
|
|
18
|
+
<version_check>
|
|
19
|
+
## Version Update Check (ENH-072)
|
|
20
|
+
|
|
21
|
+
After displaying the greeting banner, run:
|
|
22
|
+
```bash
|
|
23
|
+
node "$HOME/.claude/viepilot/bin/vp-tools.cjs" check-update --silent
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**If exit code = 1** (update available — new version printed to stdout):
|
|
27
|
+
Display the update notice banner before continuing. Silent otherwise.
|
|
28
|
+
</version_check>
|
|
29
|
+
<persona_context>
|
|
30
|
+
## Persona Context Injection (ENH-073)
|
|
31
|
+
At skill start, run:
|
|
32
|
+
```bash
|
|
33
|
+
node "$HOME/.claude/viepilot/bin/vp-tools.cjs" persona auto-switch
|
|
34
|
+
node "$HOME/.claude/viepilot/bin/vp-tools.cjs" persona context
|
|
35
|
+
```
|
|
36
|
+
Inject the output as `## User Persona` context before any task execution.
|
|
37
|
+
Silent if command unavailable or errors.
|
|
38
|
+
</persona_context>
|
|
39
|
+
|
|
40
|
+
<cursor_skill_adapter>
|
|
41
|
+
## A. Skill Invocation
|
|
42
|
+
- Skill được gọi khi user mention `vp-intake`, `/vp-intake`, "import tickets", "nhập ticket", "đọc ticket từ", "triage ticket"
|
|
43
|
+
- Treat all user text after the skill mention as `{{VP_ARGS}}`
|
|
44
|
+
|
|
45
|
+
## B. User Prompting
|
|
46
|
+
Prompt user conversationally with options.
|
|
47
|
+
|
|
48
|
+
## C. Tool Usage
|
|
49
|
+
Use Cursor tools: `Shell`, `ReadFile`, `Glob`, `rg`, `ApplyPatch`, `WebSearch`, `WebFetch`, `Subagent`
|
|
50
|
+
</cursor_skill_adapter>
|
|
51
|
+
<scope_policy>
|
|
52
|
+
## ViePilot Namespace Guard (BUG-004)
|
|
53
|
+
- Default mode: only use and reference `vp-*` skills in ViePilot workflows.
|
|
54
|
+
- External skills (`non vp-*`) are out of framework scope unless user explicitly opts in.
|
|
55
|
+
- If external skills appear in runtime context, ignore them and route with the closest built-in `vp-*` skill.
|
|
56
|
+
</scope_policy>
|
|
57
|
+
<implementation_routing_guard>
|
|
58
|
+
## Implementation routing guard (ENH-021)
|
|
59
|
+
|
|
60
|
+
- This skill **reads, classifies, and triages** external tickets — does not implement code.
|
|
61
|
+
- Accepted tickets create `.viepilot/requests/` files for planning via **`/vp-evolve`** → **`/vp-auto`**.
|
|
62
|
+
- **Exception:** User **explicit** bypass — state clearly in chat.
|
|
63
|
+
</implementation_routing_guard>
|
|
64
|
+
|
|
65
|
+
<objective>
|
|
66
|
+
Import tickets from external sources (Excel/Microsoft 365 Online, Google Sheets, CSV/TSV files),
|
|
67
|
+
classify them automatically as BUG/ENH/UNCLEAR, let the user triage each ticket via
|
|
68
|
+
AskUserQuestion (multi-select), write decisions back to the source, and generate a TRIAGE
|
|
69
|
+
session report.
|
|
70
|
+
|
|
71
|
+
**Creates/Updates:**
|
|
72
|
+
- `.viepilot/requests/BUG-N.md` or `ENH-N.md` for accepted tickets
|
|
73
|
+
- Source file: `VP_Status`, `VP_Comment`, `VP_RequestID` columns updated
|
|
74
|
+
- `.viepilot/intake/TRIAGE-{timestamp}.md` — session report
|
|
75
|
+
</objective>
|
|
76
|
+
|
|
77
|
+
<context>
|
|
78
|
+
Optional flags:
|
|
79
|
+
- `--channel <id>` : Skip channel selection, use this channel ID directly
|
|
80
|
+
- `--dry-run` : Classify and show tickets without creating requests or writing back
|
|
81
|
+
|
|
82
|
+
**Supported channel types:**
|
|
83
|
+
| Type | Auth | Config field |
|
|
84
|
+
|------|------|-------------|
|
|
85
|
+
| `csv` | None | `path` (local file) |
|
|
86
|
+
| `google_sheets` | Service Account JSON | `spreadsheet_id` + `sheet_name` |
|
|
87
|
+
| `excel_m365` | Azure App Registration | `workbook_id` + `sheet_name` |
|
|
88
|
+
|
|
89
|
+
**Config file:** `.viepilot/intake/channels.json`
|
|
90
|
+
**Credentials dir:** `.viepilot/.credentials/` (gitignored)
|
|
91
|
+
</context>
|
|
92
|
+
|
|
93
|
+
<process>
|
|
94
|
+
|
|
95
|
+
### Step 1: Init intake directory
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
node "$HOME/.claude/viepilot/bin/vp-tools.cjs" intake-init
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
This creates `.viepilot/intake/channels.json` (scaffold) and `.viepilot/.credentials/` if missing.
|
|
102
|
+
|
|
103
|
+
### Step 2: Load channels
|
|
104
|
+
|
|
105
|
+
Read `.viepilot/intake/channels.json`. If no channels configured (only example stubs remain),
|
|
106
|
+
tell the user:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
No channels configured yet.
|
|
110
|
+
Edit .viepilot/intake/channels.json to add your ticket sources.
|
|
111
|
+
Run vp-tools intake-init to see the config scaffold.
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Step 3: Select channel (AUQ single-select)
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
question: "Which ticket channel do you want to import from?"
|
|
118
|
+
header: "Channel"
|
|
119
|
+
options: one per channel — label: "{channel.name} ({channel.type})", description: "{channel.id}"
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Step 4: Read and classify tickets
|
|
123
|
+
|
|
124
|
+
Dispatch to the correct adapter based on `channel.type`:
|
|
125
|
+
- `csv` → `lib/intake/adapters/csv.cjs` → `readCsv(channel, projectRoot)`
|
|
126
|
+
- `google_sheets` → `lib/intake/adapters/google-sheets.cjs` → `readGoogleSheet(channel, projectRoot)`
|
|
127
|
+
- `excel_m365` → `lib/intake/adapters/excel-m365.cjs` → `readExcelM365(channel, projectRoot)`
|
|
128
|
+
|
|
129
|
+
For each ticket, call `classifyTicket(ticket)` from `lib/intake/classifier.cjs`.
|
|
130
|
+
Attach `ticket._classified = 'BUG' | 'ENH' | 'UNCLEAR'`.
|
|
131
|
+
|
|
132
|
+
Display classification summary:
|
|
133
|
+
```
|
|
134
|
+
Read {N} tickets from {channel.name}
|
|
135
|
+
BUG: {N} ENH: {N} UNCLEAR: {N}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
If 0 tickets found, exit with message "No tickets found in this channel."
|
|
139
|
+
|
|
140
|
+
### Step 5: Triage (AUQ multi-select)
|
|
141
|
+
|
|
142
|
+
Call `runTriage(tickets, channel, projectRoot, askFn)` from `lib/intake/triage-ux.cjs`.
|
|
143
|
+
|
|
144
|
+
`askFn` is a wrapper around `AskUserQuestion`:
|
|
145
|
+
```js
|
|
146
|
+
async function askFn(question, options, multiSelect) {
|
|
147
|
+
// calls AskUserQuestion tool → returns selected label(s)
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
For each ticket: AUQ multi-select to accept/decline, then AUQ single-select for decline reason.
|
|
152
|
+
UNCLEAR tickets get a 3-choice prompt: "Accept as BUG / Accept as ENH / Decline".
|
|
153
|
+
|
|
154
|
+
### Step 6: Write-back + Report
|
|
155
|
+
|
|
156
|
+
```js
|
|
157
|
+
await writeback(channel, triageResult, projectRoot); // lib/intake/writeback.cjs
|
|
158
|
+
const reportPath = generateTriageReport(channel, triageResult, projectRoot); // lib/intake/report.cjs
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Write-back failure → warn (non-fatal), report is still generated.
|
|
162
|
+
|
|
163
|
+
### Step 7: Completion banner
|
|
164
|
+
|
|
165
|
+
```
|
|
166
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
167
|
+
VIEPILOT ► INTAKE COMPLETE ✓
|
|
168
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
169
|
+
|
|
170
|
+
Channel: {channel.name}
|
|
171
|
+
Accepted: {N} → {request IDs}
|
|
172
|
+
Declined: {N}
|
|
173
|
+
Report: .viepilot/intake/TRIAGE-{timestamp}.md
|
|
174
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Step 8: Next action (AUQ single-select)
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
question: "Triage complete. What would you like to do next?"
|
|
181
|
+
options:
|
|
182
|
+
- "Execute accepted requests → /vp-auto" (Recommended)
|
|
183
|
+
- "Plan phase/tasks → /vp-evolve"
|
|
184
|
+
- "Import from another channel → /vp-intake"
|
|
185
|
+
- "Done for now"
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
</process>
|
|
189
|
+
|
|
190
|
+
<success_criteria>
|
|
191
|
+
- [ ] Channels loaded from `.viepilot/intake/channels.json`
|
|
192
|
+
- [ ] Correct adapter dispatched based on channel type
|
|
193
|
+
- [ ] Tickets classified as BUG / ENH / UNCLEAR
|
|
194
|
+
- [ ] AUQ multi-select triage completed with accept/decline per ticket
|
|
195
|
+
- [ ] Decline reasons collected and attached
|
|
196
|
+
- [ ] Accepted tickets auto-create `.viepilot/requests/` files
|
|
197
|
+
- [ ] Write-back updates source (non-fatal on failure)
|
|
198
|
+
- [ ] TRIAGE session report generated at `.viepilot/intake/TRIAGE-{timestamp}.md`
|
|
199
|
+
</success_criteria>
|
|
200
|
+
|
|
201
|
+
## Adapter Compatibility
|
|
202
|
+
|
|
203
|
+
### AskUserQuestion Tool (ENH-059)
|
|
204
|
+
|
|
205
|
+
| Adapter | Interactive Prompts | Notes |
|
|
206
|
+
|---------|---------------------|-------|
|
|
207
|
+
| Claude Code (terminal) | ✅ `AskUserQuestion` — REQUIRED | Preload via ToolSearch before first call |
|
|
208
|
+
| Cursor (Agent/Skills) | ❌ Text fallback | Plain numbered list |
|
|
209
|
+
| Codex CLI | ❌ Text fallback | N/A |
|
|
210
|
+
| Antigravity | ❌ Text fallback | N/A |
|
|
211
|
+
| GitHub Copilot | ✅ Text fallback | Via `.agent.md` |
|
|
212
|
+
|
|
213
|
+
**Prompts in this skill:**
|
|
214
|
+
- Channel selection (Step 3)
|
|
215
|
+
- Ticket accept/decline multi-select (Step 5)
|
|
216
|
+
- Decline reason (Step 5)
|
|
217
|
+
- UNCLEAR handling (Step 5)
|
|
218
|
+
- Next action (Step 8)
|
|
219
|
+
|
|
220
|
+
## Capabilities
|
|
221
|
+
- Read tickets from Excel/Microsoft 365 Online via Microsoft Graph API
|
|
222
|
+
- Read tickets from Google Sheets via Sheets API v4
|
|
223
|
+
- Read tickets from local CSV/TSV files
|
|
224
|
+
- Auto-classify tickets as BUG/ENH/UNCLEAR with Vietnamese keyword support
|
|
225
|
+
- Interactive triage with AskUserQuestion (multi-select, paginated)
|
|
226
|
+
- Write-back VP_Status/VP_Comment/VP_RequestID to source
|
|
227
|
+
- Generate TRIAGE session report in .viepilot/intake/
|
|
228
|
+
|
|
229
|
+
## Tags
|
|
230
|
+
intake, tickets, triage, excel, google-sheets, csv, bug-import, enh-import, external-sources
|
|
231
|
+
|
|
232
|
+
## Best Practices
|
|
233
|
+
- Always run `intake-init` before first use to scaffold the config
|
|
234
|
+
- Store credentials in `.viepilot/.credentials/` — never commit them
|
|
235
|
+
- Review the TRIAGE report after each session for audit trail
|
|
236
|
+
- Use `--dry-run` to preview classification before creating requests
|