viepilot 2.45.6 → 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 +84 -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/workflows/brainstorm.md +64 -0
- package/workflows/crystallize.md +423 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,90 @@ 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
|
+
|
|
33
|
+
## [2.47.0] - 2026-05-06
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
- **ENH-081** Brownfield Scan Trace Log — `BROWNFIELD-TRACE.md` real-time trace artifact
|
|
37
|
+
(`workflows/crystallize.md`). Before any signal category scanning begins, crystallize now
|
|
38
|
+
writes `.viepilot/BROWNFIELD-TRACE.md` to disk with all 13 categories pre-populated as
|
|
39
|
+
`planned`. Full feature set:
|
|
40
|
+
- **Trace initialization + resume detection**: on session start, checks for existing trace;
|
|
41
|
+
if `Status: scan_complete` → AUQ offers skip re-scan or re-scan from scratch; if trace is
|
|
42
|
+
mid-scan (interrupted) → AUQ offers resume from the last completed signal category
|
|
43
|
+
- **Per-Category Update Protocol (ENH-081)**: on category start → row moves to `scanning`;
|
|
44
|
+
on completion → row moves to `done`/`assumed`/`skipped` with file count, signals found,
|
|
45
|
+
start time, and duration; `## Files Read Log` appended with `[x]` (read) / `[ ]` (not found)
|
|
46
|
+
per file probed
|
|
47
|
+
- **Coverage gate (ENH-081)**: before presenting Scan Report, counts remaining `planned` rows;
|
|
48
|
+
non-blocking warning if any categories were not executed; sets `Status: scan_complete` in
|
|
49
|
+
trace header after gate passes
|
|
50
|
+
- **Gap Filling Log (ENH-081)**: each user response during Step 0-B-ii gap-filling is
|
|
51
|
+
immediately appended to `## Gap Filling Log` — no batching
|
|
52
|
+
- **Step Completion tracking**: `## Step Completion` rows updated after Step 0-C (Brainstorm
|
|
53
|
+
Stub) and Step 0-D (UI Workspace, 3 cases: done / skipped / N/A)
|
|
54
|
+
|
|
55
|
+
## [2.46.1] - 2026-05-06
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
- **ENH-080** Upgrade re-scan (ENH-067 Step 0-B) now detects Signal Category 13 gap for
|
|
59
|
+
projects crystallized before v2.46.0:
|
|
60
|
+
- Delta computation table extended with `v2.46.0` row: detection field `ui_signals_imported`
|
|
61
|
+
absent from HANDOFF.json; trigger re-check prevents false-positive for backend-only projects
|
|
62
|
+
(no CSS, no Tailwind, no UI component files)
|
|
63
|
+
- Step 0-D now persists `ui_signals_imported: true/false` + `ui_signals_imported_at`/
|
|
64
|
+
`ui_signals_skipped_at` to `HANDOFF.json` after workspace generation or skip — marks gap
|
|
65
|
+
as "evaluated"; prevents re-asking on every crystallize run; `--upgrade` forces re-evaluation
|
|
66
|
+
- Patch mode handler added: re-runs Signal Cat 13 (Sub-scans A/B/C) + Step 0-D when UI gap
|
|
67
|
+
detected; AUQ gate preserved; no brainstorm supplement check (reads codebase directly)
|
|
68
|
+
|
|
69
|
+
## [2.46.0] - 2026-04-27
|
|
70
|
+
|
|
71
|
+
### Added
|
|
72
|
+
- **ENH-079** Signal Category 13 — UI/Design System Signals in brownfield scanner
|
|
73
|
+
(`workflows/crystallize.md`). When an existing project has a real UI layer, the
|
|
74
|
+
brownfield scan now reverse-engineers it into a complete `.viepilot/ui-direction/`
|
|
75
|
+
workspace automatically:
|
|
76
|
+
- Sub-scan A: CSS custom properties (`--color-*`, `--font-*`, `--spacing-*`, `--radius-*`),
|
|
77
|
+
Tailwind config (`theme.extend.colors`, `fontFamily`, `spacing`, `borderRadius`),
|
|
78
|
+
SCSS variables, design token JSON files → Design.MD v1 `design.md`
|
|
79
|
+
- Sub-scan B: Page/route inventory for 8 framework types (Next.js App Router, Next.js Pages
|
|
80
|
+
Router, React Router, Vue Router, Angular, Nuxt 3, SvelteKit, plain HTML) →
|
|
81
|
+
`index.html` hub + `pages/{slug}.html` stubs per discovered route
|
|
82
|
+
- Sub-scan C: Component inventory via file glob + UI library detection from `package.json`
|
|
83
|
+
(shadcn/ui, Material UI, Chakra UI, Ant Design, Headless UI, Bootstrap, Radix) →
|
|
84
|
+
`notes.md ## components_inventory`
|
|
85
|
+
- AUQ gate: "Generate ui-direction workspace?" before reverse-engineering (skippable)
|
|
86
|
+
- Scan Report `ui_signals` field: `triggered`, `workspace_generated`, `ui_tokens`,
|
|
87
|
+
`ui_pages`, `ui_components`
|
|
88
|
+
- `notes.md` front matter: `reverse_engineered: true` flag — distinguishes auto-generated
|
|
89
|
+
sessions from manually crafted brainstorm sessions
|
|
90
|
+
- `workflows/brainstorm.md` Step 3C: imports Signal 13 data when brownfield stub contains
|
|
91
|
+
`ui_signals`; handles 3 cases (workspace generated / data present / not triggered);
|
|
92
|
+
offers on-demand workspace generation via AUQ
|
|
93
|
+
|
|
10
94
|
## [2.45.6] - 2026-04-27
|
|
11
95
|
|
|
12
96
|
### Fixed
|
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 };
|