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 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 };