@relipa/ai-flow-kit 0.0.3 → 0.0.4-beta.2
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/AIFLOW.md +27 -1
- package/QUICK_START.md +20 -6
- package/README.md +27 -0
- package/bin/aiflow.js +5 -0
- package/custom/templates/laravel.md +15 -90
- package/custom/templates/nestjs.md +72 -0
- package/custom/templates/nextjs.md +14 -89
- package/custom/templates/nodejs-express.md +73 -0
- package/custom/templates/python-django.md +71 -0
- package/custom/templates/python-fastapi.md +54 -0
- package/custom/templates/reactjs.md +492 -567
- package/custom/templates/shared/gate-workflow.md +75 -0
- package/custom/templates/spring-boot.md +523 -598
- package/custom/templates/tools/copilot.md +8 -0
- package/custom/templates/tools/cursor.md +8 -0
- package/custom/templates/tools/gemini.md +8 -0
- package/custom/templates/tools/generic.md +12 -0
- package/custom/templates/vue-nuxt.md +14 -89
- package/docs/ai-integration.md +53 -0
- package/docs/developer-overview.md +126 -0
- package/package.json +1 -1
- package/scripts/hooks/session-start.js +2 -1
- package/scripts/init.js +460 -453
- package/scripts/prompt.js +12 -2
- package/scripts/use.js +594 -570
package/scripts/use.js
CHANGED
|
@@ -1,570 +1,594 @@
|
|
|
1
|
-
const fs = require('fs-extra');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const chalk = require('chalk');
|
|
4
|
-
const https = require('https');
|
|
5
|
-
const { select, input } = require('@inquirer/prompts');
|
|
6
|
-
|
|
7
|
-
const PROJECT_DIR = process.cwd();
|
|
8
|
-
const AIFLOW_DIR = path.join(PROJECT_DIR, '.aiflow');
|
|
9
|
-
const STATE_FILE = path.join(AIFLOW_DIR, 'state.json');
|
|
10
|
-
const CONTEXT_DIR = path.join(PROJECT_DIR, '.claude', 'context');
|
|
11
|
-
|
|
12
|
-
// ──────────────────────────────────────────────────────────────
|
|
13
|
-
// Entry point
|
|
14
|
-
// ──────────────────────────────────────────────────────────────
|
|
15
|
-
|
|
16
|
-
module.exports = async function use(target, options = {}) {
|
|
17
|
-
if (!(await fs.pathExists(STATE_FILE))) {
|
|
18
|
-
console.log(chalk.red('Project is not initialized. Run `aiflow init` first.'));
|
|
19
|
-
return;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// ── Manual entry ──
|
|
23
|
-
if (options.manual) {
|
|
24
|
-
return await manualContext();
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// ── Load from local file ──
|
|
28
|
-
if (options.file) {
|
|
29
|
-
return await loadFromFile(options.file, options);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// ── No target → version switcher ──
|
|
33
|
-
if (!target) {
|
|
34
|
-
return await switchVersion();
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// ── Detect target type and dispatch ──
|
|
38
|
-
if (isVersion(target)) {
|
|
39
|
-
return await switchVersion(target);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
if (isTicketId(target)) {
|
|
43
|
-
const adapter = await resolveTicketAdapter();
|
|
44
|
-
if (adapter === 'jira') return await loadFromJira(target, options);
|
|
45
|
-
return await loadFromBacklog(target, options);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
if (isBacklogUrl(target)) {
|
|
49
|
-
const id = extractBacklogId(target);
|
|
50
|
-
return await loadFromBacklog(id, options);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// ── Fallback: try Backlog then manual ──
|
|
54
|
-
console.log(chalk.yellow(`Cannot detect format for: ${target}`));
|
|
55
|
-
console.log(chalk.gray('Supported: PROJ-33, APP-123, version number, --manual'));
|
|
56
|
-
return await manualContext();
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
// ──────────────────────────────────────────────────────────────
|
|
60
|
-
// Detectors
|
|
61
|
-
// ──────────────────────────────────────────────────────────────
|
|
62
|
-
|
|
63
|
-
function isVersion(s) {
|
|
64
|
-
return /^\d+\.\d+\.\d+$/.test(s);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Both Backlog and Jira use the same PROJECT-NUMBER format. */
|
|
68
|
-
function isTicketId(s) {
|
|
69
|
-
return /^[A-Z][A-Z0-9_]+-\d+$/.test(s);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Determine which adapter to use for a ticket ID based on configured adapters
|
|
74
|
-
* in state.json. Falls back to backlog if both or neither are configured.
|
|
75
|
-
*/
|
|
76
|
-
async function resolveTicketAdapter() {
|
|
77
|
-
try {
|
|
78
|
-
if (await fs.pathExists(STATE_FILE)) {
|
|
79
|
-
const state = await fs.readJson(STATE_FILE);
|
|
80
|
-
const adapters = state.adapters || [];
|
|
81
|
-
if (adapters.includes('jira') && !adapters.includes('backlog')) {
|
|
82
|
-
return 'jira';
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
} catch (_) { /* ignore */ }
|
|
86
|
-
return 'backlog';
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function isBacklogUrl(s) {
|
|
90
|
-
return s.includes('backlog.com/view/') || s.includes('backlogtool.com/view/');
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function extractBacklogId(url) {
|
|
94
|
-
const match = url.match(/\/view\/([A-Z][A-Z0-9_]+-\d+)/);
|
|
95
|
-
return match ? match[1] : null;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// ──────────────────────────────────────────────────────────────
|
|
99
|
-
// Version switcher (original behavior)
|
|
100
|
-
// ──────────────────────────────────────────────────────────────
|
|
101
|
-
|
|
102
|
-
async function switchVersion(versionReq) {
|
|
103
|
-
const versionsDir = path.join(AIFLOW_DIR, 'versions');
|
|
104
|
-
if (!(await fs.pathExists(versionsDir))) {
|
|
105
|
-
console.log(chalk.red('No versions directory found.'));
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const available = await fs.readdir(versionsDir);
|
|
110
|
-
if (!available.length) {
|
|
111
|
-
console.log(chalk.red('No installed versions found.'));
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
let targetVersion = versionReq;
|
|
116
|
-
if (!targetVersion) {
|
|
117
|
-
targetVersion = await select({
|
|
118
|
-
message: 'Select version to use:',
|
|
119
|
-
choices: available.map(v => ({ name: `v${v}`, value: v }))
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (!available.includes(targetVersion)) {
|
|
124
|
-
console.log(chalk.red(`Version v${targetVersion} is not installed.`));
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
console.log(chalk.blue(`Switching to v${targetVersion}...`));
|
|
129
|
-
const versionDir = path.join(versionsDir, targetVersion);
|
|
130
|
-
|
|
131
|
-
const claudeDir = path.join(PROJECT_DIR, '.claude');
|
|
132
|
-
await fs.emptyDir(claudeDir);
|
|
133
|
-
await fs.copy(path.join(versionDir, 'skills'), path.join(claudeDir, 'skills'), { overwrite: true });
|
|
134
|
-
|
|
135
|
-
const rulesDir = path.join(PROJECT_DIR, '.rules');
|
|
136
|
-
await fs.emptyDir(rulesDir);
|
|
137
|
-
await fs.copy(path.join(versionDir, 'rules'), rulesDir, { overwrite: true });
|
|
138
|
-
|
|
139
|
-
const state = await fs.readJson(STATE_FILE);
|
|
140
|
-
state.current_version = targetVersion;
|
|
141
|
-
await fs.writeJson(STATE_FILE, state);
|
|
142
|
-
|
|
143
|
-
console.log(chalk.green(`✓ Switched to v${targetVersion}.`));
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// ──────────────────────────────────────────────────────────────
|
|
147
|
-
// Backlog context loader
|
|
148
|
-
// ──────────────────────────────────────────────────────────────
|
|
149
|
-
|
|
150
|
-
async function loadFromBacklog(issueKey, options = {}) {
|
|
151
|
-
const creds = await loadCredentials();
|
|
152
|
-
const apiKey = process.env.BACKLOG_API_KEY || creds.BACKLOG_API_KEY;
|
|
153
|
-
const spaceKey = process.env.BACKLOG_SPACE_KEY || creds.BACKLOG_SPACE_KEY
|
|
154
|
-
|| process.env.BACKLOG_DOMAIN || creds.BACKLOG_DOMAIN;
|
|
155
|
-
|
|
156
|
-
if (!apiKey || !spaceKey) {
|
|
157
|
-
console.log(chalk.yellow('⚠ Backlog credentials not set.'));
|
|
158
|
-
console.log(chalk.gray('Run: aiflow init --adapter backlog'));
|
|
159
|
-
console.log(chalk.gray('Or set environment variables:'));
|
|
160
|
-
console.log(chalk.gray(' BACKLOG_API_KEY=your-api-key'));
|
|
161
|
-
console.log(chalk.gray(' BACKLOG_SPACE_KEY=your-space (e.g. mycompany)'));
|
|
162
|
-
console.log(chalk.gray('\nFalling back to manual entry...\n'));
|
|
163
|
-
return await manualContext(issueKey);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// spaceKey can be full domain (mycompany.backlog.com) or just the space (mycompany)
|
|
167
|
-
const domain = spaceKey.includes('.') ? spaceKey : `${spaceKey}.backlog.com`;
|
|
168
|
-
|
|
169
|
-
console.log(chalk.blue(`Fetching context from Backlog: ${issueKey}...`));
|
|
170
|
-
|
|
171
|
-
try {
|
|
172
|
-
// Default: description only. Comments loaded only when explicitly requested.
|
|
173
|
-
const loadComments = options.withComments || options.commentsLast != null || options.commentsFrom != null;
|
|
174
|
-
const [issue, rawComments] = await Promise.all([
|
|
175
|
-
fetchBacklogIssue(domain, apiKey, issueKey),
|
|
176
|
-
loadComments
|
|
177
|
-
? fetchBacklogComments(domain, apiKey, issueKey)
|
|
178
|
-
: Promise.resolve([]),
|
|
179
|
-
]);
|
|
180
|
-
|
|
181
|
-
const comments = filterComments(rawComments, options);
|
|
182
|
-
const context = buildContextFromBacklog(issue, comments, issueKey, domain);
|
|
183
|
-
await saveContext(context, options.save);
|
|
184
|
-
printContextSummary(context);
|
|
185
|
-
suggestNextStep(context);
|
|
186
|
-
} catch (err) {
|
|
187
|
-
console.log(chalk.yellow(`⚠ Could not fetch from Backlog: ${err.message}`));
|
|
188
|
-
console.log(chalk.gray('Falling back to manual entry...\n'));
|
|
189
|
-
await manualContext(issueKey);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Filter comments array based on CLI options:
|
|
195
|
-
* --no-comments → empty array (already handled upstream, but safe here too)
|
|
196
|
-
* --comments-last N → last N comments
|
|
197
|
-
* --comments-from N → comments starting at index N (0-based)
|
|
198
|
-
* (default) → all comments
|
|
199
|
-
*/
|
|
200
|
-
function filterComments(comments, options = {}) {
|
|
201
|
-
if (!comments || !comments.length) return [];
|
|
202
|
-
|
|
203
|
-
if (options.commentsLast != null) {
|
|
204
|
-
const n = Math.max(1, options.commentsLast);
|
|
205
|
-
return comments.slice(-n);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
if (options.commentsFrom != null) {
|
|
209
|
-
const from = Math.max(0, options.commentsFrom);
|
|
210
|
-
return comments.slice(from);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
return comments;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Generic Backlog GET helper
|
|
218
|
-
*/
|
|
219
|
-
function backlogGet(url) {
|
|
220
|
-
return new Promise((resolve, reject) => {
|
|
221
|
-
https.get(url, (res) => {
|
|
222
|
-
let data = '';
|
|
223
|
-
res.on('data', chunk => data += chunk);
|
|
224
|
-
res.on('end', () => {
|
|
225
|
-
if (res.statusCode !== 200) {
|
|
226
|
-
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
try {
|
|
230
|
-
resolve(JSON.parse(data));
|
|
231
|
-
} catch (e) {
|
|
232
|
-
reject(new Error('Invalid JSON response from Backlog'));
|
|
233
|
-
}
|
|
234
|
-
});
|
|
235
|
-
}).on('error', reject);
|
|
236
|
-
});
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* Fetch issue detail from Backlog REST API
|
|
241
|
-
*/
|
|
242
|
-
function fetchBacklogIssue(domain, apiKey, issueKey) {
|
|
243
|
-
return backlogGet(`https://${domain}/api/v2/issues/${issueKey}?apiKey=${apiKey}`);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Fetch ALL comments of an issue from Backlog REST API (paginated, max 100/page).
|
|
248
|
-
*/
|
|
249
|
-
async function fetchBacklogComments(domain, apiKey, issueKey) {
|
|
250
|
-
const all = [];
|
|
251
|
-
let offset = 0;
|
|
252
|
-
try {
|
|
253
|
-
while (true) {
|
|
254
|
-
const batch = await backlogGet(
|
|
255
|
-
`https://${domain}/api/v2/issues/${issueKey}/comments?apiKey=${apiKey}&count=100&offset=${offset}&order=asc`
|
|
256
|
-
);
|
|
257
|
-
all.push(...batch);
|
|
258
|
-
if (batch.length < 100) break;
|
|
259
|
-
offset += 100;
|
|
260
|
-
}
|
|
261
|
-
} catch (_) {
|
|
262
|
-
// comments are optional — don't fail the whole fetch
|
|
263
|
-
}
|
|
264
|
-
return all;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Transform Backlog API response + comments to internal context format
|
|
269
|
-
*/
|
|
270
|
-
function buildContextFromBacklog(issue, comments, issueKey, domain) {
|
|
271
|
-
const type = detectTaskType(issue);
|
|
272
|
-
|
|
273
|
-
// Format comments into readable text for AI context
|
|
274
|
-
const formattedComments = (comments || [])
|
|
275
|
-
.filter(c => c.content && c.content.trim())
|
|
276
|
-
.map(c => {
|
|
277
|
-
const author = c.createdUser?.name || 'Unknown';
|
|
278
|
-
const date = c.created ? new Date(c.created).toLocaleDateString('vi-VN') : '';
|
|
279
|
-
return `[${author} - ${date}]\n${c.content.trim()}`;
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
const allText = [issue.description || '', ...formattedComments.map(c => c)].join('\n');
|
|
283
|
-
|
|
284
|
-
return {
|
|
285
|
-
taskId: issueKey,
|
|
286
|
-
taskType: type,
|
|
287
|
-
title: issue.summary || '',
|
|
288
|
-
description: issue.description || '',
|
|
289
|
-
status: issue.status?.name || 'Unknown',
|
|
290
|
-
priority: issue.priority?.name || 'Unknown',
|
|
291
|
-
assignee: issue.assignee?.name || 'Unassigned',
|
|
292
|
-
|
|
293
|
-
// Comments from all participants
|
|
294
|
-
comments: formattedComments,
|
|
295
|
-
commentCount: formattedComments.length,
|
|
296
|
-
|
|
297
|
-
// Parse checklist-style acceptance criteria from description
|
|
298
|
-
acceptanceCriteria: extractCriteria(issue.description || ''),
|
|
299
|
-
|
|
300
|
-
// Related files mentioned anywhere in ticket
|
|
301
|
-
context: {
|
|
302
|
-
files: extractFiles(allText),
|
|
303
|
-
mentionedServices: [],
|
|
304
|
-
relatedTickets: extractRelatedTickets(allText),
|
|
305
|
-
},
|
|
306
|
-
|
|
307
|
-
metadata: {
|
|
308
|
-
created: issue.created,
|
|
309
|
-
updated: issue.updated,
|
|
310
|
-
loadedFrom: 'backlog',
|
|
311
|
-
adapter: 'backlog',
|
|
312
|
-
sourceUrl: `https://${domain}/view/${issueKey}`,
|
|
313
|
-
}
|
|
314
|
-
};
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// ──────────────────────────────────────────────────────────────
|
|
318
|
-
// Jira context loader
|
|
319
|
-
// ──────────────────────────────────────────────────────────────
|
|
320
|
-
|
|
321
|
-
async function loadFromJira(issueKey, options = {}) {
|
|
322
|
-
const creds = await loadCredentials();
|
|
323
|
-
const apiToken = process.env.JIRA_API_TOKEN || creds.JIRA_API_TOKEN;
|
|
324
|
-
const email = process.env.JIRA_EMAIL || creds.JIRA_EMAIL;
|
|
325
|
-
const domain = process.env.JIRA_DOMAIN || creds.JIRA_DOMAIN;
|
|
326
|
-
|
|
327
|
-
if (!apiToken || !email || !domain) {
|
|
328
|
-
console.log(chalk.yellow('⚠ Jira credentials not set.'));
|
|
329
|
-
console.log(chalk.gray('Run: aiflow init --adapter jira'));
|
|
330
|
-
console.log(chalk.gray('Or set environment variables: JIRA_API_TOKEN, JIRA_EMAIL, JIRA_DOMAIN'));
|
|
331
|
-
console.log(chalk.gray('\nFalling back to manual entry...\n'));
|
|
332
|
-
return await manualContext(issueKey);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
console.log(chalk.blue(`Fetching context from Jira: ${issueKey}...`));
|
|
336
|
-
|
|
337
|
-
try {
|
|
338
|
-
const issue = await fetchJiraIssue(domain, email, apiToken, issueKey);
|
|
339
|
-
const context = buildContextFromJira(issue, issueKey, domain);
|
|
340
|
-
await saveContext(context, options.save);
|
|
341
|
-
printContextSummary(context);
|
|
342
|
-
suggestNextStep(context);
|
|
343
|
-
} catch (err) {
|
|
344
|
-
console.log(chalk.yellow(`⚠ Could not fetch from Jira: ${err.message}`));
|
|
345
|
-
console.log(chalk.gray('Falling back to manual entry...\n'));
|
|
346
|
-
await manualContext(issueKey);
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
function fetchJiraIssue(domain, email, apiToken, issueKey) {
|
|
351
|
-
return new Promise((resolve, reject) => {
|
|
352
|
-
const auth = Buffer.from(`${email}:${apiToken}`).toString('base64');
|
|
353
|
-
const url = `https://${domain}.atlassian.net/rest/api/3/issue/${issueKey}`;
|
|
354
|
-
const options = {
|
|
355
|
-
headers: {
|
|
356
|
-
'Authorization': `Basic ${auth}`,
|
|
357
|
-
'Accept': 'application/json'
|
|
358
|
-
}
|
|
359
|
-
};
|
|
360
|
-
|
|
361
|
-
https.get(url, options, (res) => {
|
|
362
|
-
let data = '';
|
|
363
|
-
res.on('data', chunk => data += chunk);
|
|
364
|
-
res.on('end', () => {
|
|
365
|
-
if (res.statusCode !== 200) {
|
|
366
|
-
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
|
|
367
|
-
return;
|
|
368
|
-
}
|
|
369
|
-
try {
|
|
370
|
-
resolve(JSON.parse(data));
|
|
371
|
-
} catch (e) {
|
|
372
|
-
reject(new Error('Invalid JSON response from Jira'));
|
|
373
|
-
}
|
|
374
|
-
});
|
|
375
|
-
}).on('error', reject);
|
|
376
|
-
});
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
function buildContextFromJira(issue, issueKey, domain) {
|
|
380
|
-
const fields = issue.fields || {};
|
|
381
|
-
const type = detectTaskTypeFromString(fields.issuetype?.name || '');
|
|
382
|
-
|
|
383
|
-
return {
|
|
384
|
-
taskId: issueKey,
|
|
385
|
-
taskType: type,
|
|
386
|
-
title: fields.summary || '',
|
|
387
|
-
description: fields.description?.content?.map(block =>
|
|
388
|
-
block.content?.map(c => c.text).join('') || ''
|
|
389
|
-
).join('\n') || '',
|
|
390
|
-
status: fields.status?.name || 'Unknown',
|
|
391
|
-
priority: fields.priority?.name || 'Unknown',
|
|
392
|
-
assignee: fields.assignee?.displayName || 'Unassigned',
|
|
393
|
-
acceptanceCriteria: extractCriteria(fields.description?.toString() || ''),
|
|
394
|
-
context: {
|
|
395
|
-
files: [],
|
|
396
|
-
relatedTickets: []
|
|
397
|
-
},
|
|
398
|
-
metadata: {
|
|
399
|
-
created: fields.created,
|
|
400
|
-
updated: fields.updated,
|
|
401
|
-
loadedFrom: 'jira',
|
|
402
|
-
adapter: 'jira',
|
|
403
|
-
sourceUrl: `https://${domain}.atlassian.net/browse/${issueKey}`
|
|
404
|
-
}
|
|
405
|
-
};
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
// ──────────────────────────────────────────────────────────────
|
|
409
|
-
// Manual context entry
|
|
410
|
-
// ──────────────────────────────────────────────────────────────
|
|
411
|
-
|
|
412
|
-
async function manualContext(prefillId = '') {
|
|
413
|
-
console.log(chalk.cyan('\nManual Context Entry\n'));
|
|
414
|
-
|
|
415
|
-
const taskId = await input({
|
|
416
|
-
message: 'Ticket ID (e.g. PROJ-33):',
|
|
417
|
-
default: prefillId
|
|
418
|
-
});
|
|
419
|
-
|
|
420
|
-
const title = await input({ message: 'Title:' });
|
|
421
|
-
const description = await input({ message: 'Description (brief):' });
|
|
422
|
-
const taskType = await select({
|
|
423
|
-
message: 'Task type:',
|
|
424
|
-
choices: [
|
|
425
|
-
{ name: '🐛 Bug Fix', value: 'bug-fix' },
|
|
426
|
-
{ name: '✨ Feature', value: 'feature' },
|
|
427
|
-
{ name: '🔍 Investigation', value: 'investigation' },
|
|
428
|
-
{ name: '♻️ Refactor', value: 'refactor' },
|
|
429
|
-
{ name: '📊 Impact Analysis', value: 'impact-analysis' }
|
|
430
|
-
]
|
|
431
|
-
});
|
|
432
|
-
|
|
433
|
-
const context = {
|
|
434
|
-
taskId,
|
|
435
|
-
taskType,
|
|
436
|
-
title,
|
|
437
|
-
description,
|
|
438
|
-
status: 'In Progress',
|
|
439
|
-
acceptanceCriteria: [],
|
|
440
|
-
context: { files: [], relatedTickets: [] },
|
|
441
|
-
metadata: {
|
|
442
|
-
created: new Date().toISOString(),
|
|
443
|
-
loadedFrom: 'manual',
|
|
444
|
-
adapter: 'manual'
|
|
445
|
-
}
|
|
446
|
-
};
|
|
447
|
-
|
|
448
|
-
await saveContext(context);
|
|
449
|
-
printContextSummary(context);
|
|
450
|
-
suggestNextStep(context);
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
// ──────────────────────────────────────────────────────────────
|
|
454
|
-
// Load from JSON file
|
|
455
|
-
// ──────────────────────────────────────────────────────────────
|
|
456
|
-
|
|
457
|
-
async function loadFromFile(filePath, options = {}) {
|
|
458
|
-
if (!(await fs.pathExists(filePath))) {
|
|
459
|
-
console.log(chalk.red(`File not found: ${filePath}`));
|
|
460
|
-
return;
|
|
461
|
-
}
|
|
462
|
-
const context = await fs.readJson(filePath);
|
|
463
|
-
await saveContext(context, options.save);
|
|
464
|
-
printContextSummary(context);
|
|
465
|
-
suggestNextStep(context);
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
// ──────────────────────────────────────────────────────────────
|
|
469
|
-
// Credentials loader (reads .aiflow/credentials.json as fallback)
|
|
470
|
-
// ──────────────────────────────────────────────────────────────
|
|
471
|
-
|
|
472
|
-
async function loadCredentials() {
|
|
473
|
-
const credsFile = path.join(PROJECT_DIR, '.aiflow', 'credentials.json');
|
|
474
|
-
if (await fs.pathExists(credsFile)) {
|
|
475
|
-
return await fs.readJson(credsFile).catch(() => ({}));
|
|
476
|
-
}
|
|
477
|
-
return {};
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
// ──────────────────────────────────────────────────────────────
|
|
481
|
-
// Context persistence
|
|
482
|
-
// ──────────────────────────────────────────────────────────────
|
|
483
|
-
|
|
484
|
-
async function saveContext(context, saveName) {
|
|
485
|
-
await fs.ensureDir(CONTEXT_DIR);
|
|
486
|
-
await fs.ensureDir(path.join(CONTEXT_DIR, 'history'));
|
|
487
|
-
|
|
488
|
-
// Save as current
|
|
489
|
-
await fs.writeJson(path.join(CONTEXT_DIR, 'current.json'), context, { spaces: 2 });
|
|
490
|
-
|
|
491
|
-
// Save to history
|
|
492
|
-
const histFile = path.join(CONTEXT_DIR, 'history', `${context.taskId || 'manual'}.json`);
|
|
493
|
-
await fs.writeJson(histFile, context, { spaces: 2 });
|
|
494
|
-
|
|
495
|
-
// Save named snapshot if requested
|
|
496
|
-
if (saveName) {
|
|
497
|
-
const namedFile = path.join(CONTEXT_DIR, 'history', `${saveName}.json`);
|
|
498
|
-
await fs.writeJson(namedFile, context, { spaces: 2 });
|
|
499
|
-
console.log(chalk.gray(` Context also saved as: ${saveName}`));
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
// Update state
|
|
503
|
-
if (await fs.pathExists(STATE_FILE)) {
|
|
504
|
-
const state = await fs.readJson(STATE_FILE);
|
|
505
|
-
state.current_context = context.taskId;
|
|
506
|
-
state.current_context_type = context.taskType;
|
|
507
|
-
await fs.writeJson(STATE_FILE, state);
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
// ──────────────────────────────────────────────────────────────
|
|
512
|
-
// Helpers
|
|
513
|
-
// ──────────────────────────────────────────────────────────────
|
|
514
|
-
|
|
515
|
-
function detectTaskType(issue) {
|
|
516
|
-
const name = issue.issueType?.name || issue.type?.name || '';
|
|
517
|
-
const summary = issue.summary || '';
|
|
518
|
-
return detectTaskTypeFromString(name || summary);
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
function detectTaskTypeFromString(text) {
|
|
522
|
-
const lower = text.toLowerCase();
|
|
523
|
-
if (['bug', 'defect', 'バグ'].some(k => lower.includes(k))) return 'bug-fix';
|
|
524
|
-
if (['task', 'story', 'feature', 'タスク'].some(k => lower.includes(k))) return 'feature';
|
|
525
|
-
if (['research', 'investigation', '調査'].some(k => lower.includes(k))) return 'investigation';
|
|
526
|
-
return 'bug-fix'; // default for unknown
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
function extractCriteria(text) {
|
|
530
|
-
const lines = text.split('\n');
|
|
531
|
-
return lines
|
|
532
|
-
.filter(l => l.trim().match(/^[-*•]|^\d+\./))
|
|
533
|
-
.map(l => l.replace(/^[-*•\d.]+\s*/, '').trim())
|
|
534
|
-
.filter(l => l.length > 0)
|
|
535
|
-
.slice(0, 10);
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
function extractFiles(text) {
|
|
539
|
-
const matches = text.match(/[\w/]+\.(php|js|ts|vue|jsx|tsx|css|html)/g) || [];
|
|
540
|
-
return [...new Set(matches)];
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
function extractRelatedTickets(text) {
|
|
544
|
-
const matches = text.match(/[A-Z][A-Z0-9_]+-\d+/g) || [];
|
|
545
|
-
return [...new Set(matches)];
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
function printContextSummary(context) {
|
|
549
|
-
console.log(chalk.green('\n✓ Context loaded\n'));
|
|
550
|
-
console.log(` ${chalk.white('Ticket:')} ${context.taskId}`);
|
|
551
|
-
console.log(` ${chalk.white('Type:')} ${context.taskType}`);
|
|
552
|
-
console.log(` ${chalk.white('Title:')} ${context.title.substring(0, 70)}`);
|
|
553
|
-
console.log(` ${chalk.white('Status:')} ${context.status}`);
|
|
554
|
-
console.log(` ${chalk.white('Assignee:')} ${context.assignee}`);
|
|
555
|
-
if (context.description) {
|
|
556
|
-
console.log(` ${chalk.white('Desc:')} ${context.description.substring(0, 80)}...`);
|
|
557
|
-
}
|
|
558
|
-
if (context.acceptanceCriteria?.length) {
|
|
559
|
-
console.log(` ${chalk.white('Criteria:')} ${context.acceptanceCriteria.length} item(s)`);
|
|
560
|
-
}
|
|
561
|
-
if (context.commentCount > 0) {
|
|
562
|
-
console.log(` ${chalk.white('Comments:')} ${context.commentCount} comment(s) loaded`);
|
|
563
|
-
}
|
|
564
|
-
console.log();
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
function suggestNextStep(context) {
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
const https = require('https');
|
|
5
|
+
const { select, input } = require('@inquirer/prompts');
|
|
6
|
+
|
|
7
|
+
const PROJECT_DIR = process.cwd();
|
|
8
|
+
const AIFLOW_DIR = path.join(PROJECT_DIR, '.aiflow');
|
|
9
|
+
const STATE_FILE = path.join(AIFLOW_DIR, 'state.json');
|
|
10
|
+
const CONTEXT_DIR = path.join(PROJECT_DIR, '.claude', 'context');
|
|
11
|
+
|
|
12
|
+
// ──────────────────────────────────────────────────────────────
|
|
13
|
+
// Entry point
|
|
14
|
+
// ──────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
module.exports = async function use(target, options = {}) {
|
|
17
|
+
if (!(await fs.pathExists(STATE_FILE))) {
|
|
18
|
+
console.log(chalk.red('Project is not initialized. Run `aiflow init` first.'));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ── Manual entry ──
|
|
23
|
+
if (options.manual) {
|
|
24
|
+
return await manualContext();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── Load from local file ──
|
|
28
|
+
if (options.file) {
|
|
29
|
+
return await loadFromFile(options.file, options);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── No target → version switcher ──
|
|
33
|
+
if (!target) {
|
|
34
|
+
return await switchVersion();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Detect target type and dispatch ──
|
|
38
|
+
if (isVersion(target)) {
|
|
39
|
+
return await switchVersion(target);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (isTicketId(target)) {
|
|
43
|
+
const adapter = await resolveTicketAdapter();
|
|
44
|
+
if (adapter === 'jira') return await loadFromJira(target, options);
|
|
45
|
+
return await loadFromBacklog(target, options);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (isBacklogUrl(target)) {
|
|
49
|
+
const id = extractBacklogId(target);
|
|
50
|
+
return await loadFromBacklog(id, options);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Fallback: try Backlog then manual ──
|
|
54
|
+
console.log(chalk.yellow(`Cannot detect format for: ${target}`));
|
|
55
|
+
console.log(chalk.gray('Supported: PROJ-33, APP-123, version number, --manual'));
|
|
56
|
+
return await manualContext();
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// ──────────────────────────────────────────────────────────────
|
|
60
|
+
// Detectors
|
|
61
|
+
// ──────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
function isVersion(s) {
|
|
64
|
+
return /^\d+\.\d+\.\d+$/.test(s);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Both Backlog and Jira use the same PROJECT-NUMBER format. */
|
|
68
|
+
function isTicketId(s) {
|
|
69
|
+
return /^[A-Z][A-Z0-9_]+-\d+$/.test(s);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Determine which adapter to use for a ticket ID based on configured adapters
|
|
74
|
+
* in state.json. Falls back to backlog if both or neither are configured.
|
|
75
|
+
*/
|
|
76
|
+
async function resolveTicketAdapter() {
|
|
77
|
+
try {
|
|
78
|
+
if (await fs.pathExists(STATE_FILE)) {
|
|
79
|
+
const state = await fs.readJson(STATE_FILE);
|
|
80
|
+
const adapters = state.adapters || [];
|
|
81
|
+
if (adapters.includes('jira') && !adapters.includes('backlog')) {
|
|
82
|
+
return 'jira';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch (_) { /* ignore */ }
|
|
86
|
+
return 'backlog';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isBacklogUrl(s) {
|
|
90
|
+
return s.includes('backlog.com/view/') || s.includes('backlogtool.com/view/');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function extractBacklogId(url) {
|
|
94
|
+
const match = url.match(/\/view\/([A-Z][A-Z0-9_]+-\d+)/);
|
|
95
|
+
return match ? match[1] : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ──────────────────────────────────────────────────────────────
|
|
99
|
+
// Version switcher (original behavior)
|
|
100
|
+
// ──────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
async function switchVersion(versionReq) {
|
|
103
|
+
const versionsDir = path.join(AIFLOW_DIR, 'versions');
|
|
104
|
+
if (!(await fs.pathExists(versionsDir))) {
|
|
105
|
+
console.log(chalk.red('No versions directory found.'));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const available = await fs.readdir(versionsDir);
|
|
110
|
+
if (!available.length) {
|
|
111
|
+
console.log(chalk.red('No installed versions found.'));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let targetVersion = versionReq;
|
|
116
|
+
if (!targetVersion) {
|
|
117
|
+
targetVersion = await select({
|
|
118
|
+
message: 'Select version to use:',
|
|
119
|
+
choices: available.map(v => ({ name: `v${v}`, value: v }))
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!available.includes(targetVersion)) {
|
|
124
|
+
console.log(chalk.red(`Version v${targetVersion} is not installed.`));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
console.log(chalk.blue(`Switching to v${targetVersion}...`));
|
|
129
|
+
const versionDir = path.join(versionsDir, targetVersion);
|
|
130
|
+
|
|
131
|
+
const claudeDir = path.join(PROJECT_DIR, '.claude');
|
|
132
|
+
await fs.emptyDir(claudeDir);
|
|
133
|
+
await fs.copy(path.join(versionDir, 'skills'), path.join(claudeDir, 'skills'), { overwrite: true });
|
|
134
|
+
|
|
135
|
+
const rulesDir = path.join(PROJECT_DIR, '.rules');
|
|
136
|
+
await fs.emptyDir(rulesDir);
|
|
137
|
+
await fs.copy(path.join(versionDir, 'rules'), rulesDir, { overwrite: true });
|
|
138
|
+
|
|
139
|
+
const state = await fs.readJson(STATE_FILE);
|
|
140
|
+
state.current_version = targetVersion;
|
|
141
|
+
await fs.writeJson(STATE_FILE, state);
|
|
142
|
+
|
|
143
|
+
console.log(chalk.green(`✓ Switched to v${targetVersion}.`));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ──────────────────────────────────────────────────────────────
|
|
147
|
+
// Backlog context loader
|
|
148
|
+
// ──────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
async function loadFromBacklog(issueKey, options = {}) {
|
|
151
|
+
const creds = await loadCredentials();
|
|
152
|
+
const apiKey = process.env.BACKLOG_API_KEY || creds.BACKLOG_API_KEY;
|
|
153
|
+
const spaceKey = process.env.BACKLOG_SPACE_KEY || creds.BACKLOG_SPACE_KEY
|
|
154
|
+
|| process.env.BACKLOG_DOMAIN || creds.BACKLOG_DOMAIN;
|
|
155
|
+
|
|
156
|
+
if (!apiKey || !spaceKey) {
|
|
157
|
+
console.log(chalk.yellow('⚠ Backlog credentials not set.'));
|
|
158
|
+
console.log(chalk.gray('Run: aiflow init --adapter backlog'));
|
|
159
|
+
console.log(chalk.gray('Or set environment variables:'));
|
|
160
|
+
console.log(chalk.gray(' BACKLOG_API_KEY=your-api-key'));
|
|
161
|
+
console.log(chalk.gray(' BACKLOG_SPACE_KEY=your-space (e.g. mycompany)'));
|
|
162
|
+
console.log(chalk.gray('\nFalling back to manual entry...\n'));
|
|
163
|
+
return await manualContext(issueKey);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// spaceKey can be full domain (mycompany.backlog.com) or just the space (mycompany)
|
|
167
|
+
const domain = spaceKey.includes('.') ? spaceKey : `${spaceKey}.backlog.com`;
|
|
168
|
+
|
|
169
|
+
console.log(chalk.blue(`Fetching context from Backlog: ${issueKey}...`));
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
// Default: description only. Comments loaded only when explicitly requested.
|
|
173
|
+
const loadComments = options.withComments || options.commentsLast != null || options.commentsFrom != null;
|
|
174
|
+
const [issue, rawComments] = await Promise.all([
|
|
175
|
+
fetchBacklogIssue(domain, apiKey, issueKey),
|
|
176
|
+
loadComments
|
|
177
|
+
? fetchBacklogComments(domain, apiKey, issueKey)
|
|
178
|
+
: Promise.resolve([]),
|
|
179
|
+
]);
|
|
180
|
+
|
|
181
|
+
const comments = filterComments(rawComments, options);
|
|
182
|
+
const context = buildContextFromBacklog(issue, comments, issueKey, domain);
|
|
183
|
+
await saveContext(context, options.save);
|
|
184
|
+
printContextSummary(context);
|
|
185
|
+
await suggestNextStep(context);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
console.log(chalk.yellow(`⚠ Could not fetch from Backlog: ${err.message}`));
|
|
188
|
+
console.log(chalk.gray('Falling back to manual entry...\n'));
|
|
189
|
+
await manualContext(issueKey);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Filter comments array based on CLI options:
|
|
195
|
+
* --no-comments → empty array (already handled upstream, but safe here too)
|
|
196
|
+
* --comments-last N → last N comments
|
|
197
|
+
* --comments-from N → comments starting at index N (0-based)
|
|
198
|
+
* (default) → all comments
|
|
199
|
+
*/
|
|
200
|
+
function filterComments(comments, options = {}) {
|
|
201
|
+
if (!comments || !comments.length) return [];
|
|
202
|
+
|
|
203
|
+
if (options.commentsLast != null) {
|
|
204
|
+
const n = Math.max(1, options.commentsLast);
|
|
205
|
+
return comments.slice(-n);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (options.commentsFrom != null) {
|
|
209
|
+
const from = Math.max(0, options.commentsFrom);
|
|
210
|
+
return comments.slice(from);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return comments;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Generic Backlog GET helper
|
|
218
|
+
*/
|
|
219
|
+
function backlogGet(url) {
|
|
220
|
+
return new Promise((resolve, reject) => {
|
|
221
|
+
https.get(url, (res) => {
|
|
222
|
+
let data = '';
|
|
223
|
+
res.on('data', chunk => data += chunk);
|
|
224
|
+
res.on('end', () => {
|
|
225
|
+
if (res.statusCode !== 200) {
|
|
226
|
+
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
resolve(JSON.parse(data));
|
|
231
|
+
} catch (e) {
|
|
232
|
+
reject(new Error('Invalid JSON response from Backlog'));
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}).on('error', reject);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Fetch issue detail from Backlog REST API
|
|
241
|
+
*/
|
|
242
|
+
function fetchBacklogIssue(domain, apiKey, issueKey) {
|
|
243
|
+
return backlogGet(`https://${domain}/api/v2/issues/${issueKey}?apiKey=${apiKey}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Fetch ALL comments of an issue from Backlog REST API (paginated, max 100/page).
|
|
248
|
+
*/
|
|
249
|
+
async function fetchBacklogComments(domain, apiKey, issueKey) {
|
|
250
|
+
const all = [];
|
|
251
|
+
let offset = 0;
|
|
252
|
+
try {
|
|
253
|
+
while (true) {
|
|
254
|
+
const batch = await backlogGet(
|
|
255
|
+
`https://${domain}/api/v2/issues/${issueKey}/comments?apiKey=${apiKey}&count=100&offset=${offset}&order=asc`
|
|
256
|
+
);
|
|
257
|
+
all.push(...batch);
|
|
258
|
+
if (batch.length < 100) break;
|
|
259
|
+
offset += 100;
|
|
260
|
+
}
|
|
261
|
+
} catch (_) {
|
|
262
|
+
// comments are optional — don't fail the whole fetch
|
|
263
|
+
}
|
|
264
|
+
return all;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Transform Backlog API response + comments to internal context format
|
|
269
|
+
*/
|
|
270
|
+
function buildContextFromBacklog(issue, comments, issueKey, domain) {
|
|
271
|
+
const type = detectTaskType(issue);
|
|
272
|
+
|
|
273
|
+
// Format comments into readable text for AI context
|
|
274
|
+
const formattedComments = (comments || [])
|
|
275
|
+
.filter(c => c.content && c.content.trim())
|
|
276
|
+
.map(c => {
|
|
277
|
+
const author = c.createdUser?.name || 'Unknown';
|
|
278
|
+
const date = c.created ? new Date(c.created).toLocaleDateString('vi-VN') : '';
|
|
279
|
+
return `[${author} - ${date}]\n${c.content.trim()}`;
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const allText = [issue.description || '', ...formattedComments.map(c => c)].join('\n');
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
taskId: issueKey,
|
|
286
|
+
taskType: type,
|
|
287
|
+
title: issue.summary || '',
|
|
288
|
+
description: issue.description || '',
|
|
289
|
+
status: issue.status?.name || 'Unknown',
|
|
290
|
+
priority: issue.priority?.name || 'Unknown',
|
|
291
|
+
assignee: issue.assignee?.name || 'Unassigned',
|
|
292
|
+
|
|
293
|
+
// Comments from all participants
|
|
294
|
+
comments: formattedComments,
|
|
295
|
+
commentCount: formattedComments.length,
|
|
296
|
+
|
|
297
|
+
// Parse checklist-style acceptance criteria from description
|
|
298
|
+
acceptanceCriteria: extractCriteria(issue.description || ''),
|
|
299
|
+
|
|
300
|
+
// Related files mentioned anywhere in ticket
|
|
301
|
+
context: {
|
|
302
|
+
files: extractFiles(allText),
|
|
303
|
+
mentionedServices: [],
|
|
304
|
+
relatedTickets: extractRelatedTickets(allText),
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
metadata: {
|
|
308
|
+
created: issue.created,
|
|
309
|
+
updated: issue.updated,
|
|
310
|
+
loadedFrom: 'backlog',
|
|
311
|
+
adapter: 'backlog',
|
|
312
|
+
sourceUrl: `https://${domain}/view/${issueKey}`,
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ──────────────────────────────────────────────────────────────
|
|
318
|
+
// Jira context loader
|
|
319
|
+
// ──────────────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
async function loadFromJira(issueKey, options = {}) {
|
|
322
|
+
const creds = await loadCredentials();
|
|
323
|
+
const apiToken = process.env.JIRA_API_TOKEN || creds.JIRA_API_TOKEN;
|
|
324
|
+
const email = process.env.JIRA_EMAIL || creds.JIRA_EMAIL;
|
|
325
|
+
const domain = process.env.JIRA_DOMAIN || creds.JIRA_DOMAIN;
|
|
326
|
+
|
|
327
|
+
if (!apiToken || !email || !domain) {
|
|
328
|
+
console.log(chalk.yellow('⚠ Jira credentials not set.'));
|
|
329
|
+
console.log(chalk.gray('Run: aiflow init --adapter jira'));
|
|
330
|
+
console.log(chalk.gray('Or set environment variables: JIRA_API_TOKEN, JIRA_EMAIL, JIRA_DOMAIN'));
|
|
331
|
+
console.log(chalk.gray('\nFalling back to manual entry...\n'));
|
|
332
|
+
return await manualContext(issueKey);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
console.log(chalk.blue(`Fetching context from Jira: ${issueKey}...`));
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
const issue = await fetchJiraIssue(domain, email, apiToken, issueKey);
|
|
339
|
+
const context = buildContextFromJira(issue, issueKey, domain);
|
|
340
|
+
await saveContext(context, options.save);
|
|
341
|
+
printContextSummary(context);
|
|
342
|
+
await suggestNextStep(context);
|
|
343
|
+
} catch (err) {
|
|
344
|
+
console.log(chalk.yellow(`⚠ Could not fetch from Jira: ${err.message}`));
|
|
345
|
+
console.log(chalk.gray('Falling back to manual entry...\n'));
|
|
346
|
+
await manualContext(issueKey);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function fetchJiraIssue(domain, email, apiToken, issueKey) {
|
|
351
|
+
return new Promise((resolve, reject) => {
|
|
352
|
+
const auth = Buffer.from(`${email}:${apiToken}`).toString('base64');
|
|
353
|
+
const url = `https://${domain}.atlassian.net/rest/api/3/issue/${issueKey}`;
|
|
354
|
+
const options = {
|
|
355
|
+
headers: {
|
|
356
|
+
'Authorization': `Basic ${auth}`,
|
|
357
|
+
'Accept': 'application/json'
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
https.get(url, options, (res) => {
|
|
362
|
+
let data = '';
|
|
363
|
+
res.on('data', chunk => data += chunk);
|
|
364
|
+
res.on('end', () => {
|
|
365
|
+
if (res.statusCode !== 200) {
|
|
366
|
+
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
resolve(JSON.parse(data));
|
|
371
|
+
} catch (e) {
|
|
372
|
+
reject(new Error('Invalid JSON response from Jira'));
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
}).on('error', reject);
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function buildContextFromJira(issue, issueKey, domain) {
|
|
380
|
+
const fields = issue.fields || {};
|
|
381
|
+
const type = detectTaskTypeFromString(fields.issuetype?.name || '');
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
taskId: issueKey,
|
|
385
|
+
taskType: type,
|
|
386
|
+
title: fields.summary || '',
|
|
387
|
+
description: fields.description?.content?.map(block =>
|
|
388
|
+
block.content?.map(c => c.text).join('') || ''
|
|
389
|
+
).join('\n') || '',
|
|
390
|
+
status: fields.status?.name || 'Unknown',
|
|
391
|
+
priority: fields.priority?.name || 'Unknown',
|
|
392
|
+
assignee: fields.assignee?.displayName || 'Unassigned',
|
|
393
|
+
acceptanceCriteria: extractCriteria(fields.description?.toString() || ''),
|
|
394
|
+
context: {
|
|
395
|
+
files: [],
|
|
396
|
+
relatedTickets: []
|
|
397
|
+
},
|
|
398
|
+
metadata: {
|
|
399
|
+
created: fields.created,
|
|
400
|
+
updated: fields.updated,
|
|
401
|
+
loadedFrom: 'jira',
|
|
402
|
+
adapter: 'jira',
|
|
403
|
+
sourceUrl: `https://${domain}.atlassian.net/browse/${issueKey}`
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ──────────────────────────────────────────────────────────────
|
|
409
|
+
// Manual context entry
|
|
410
|
+
// ──────────────────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
async function manualContext(prefillId = '') {
|
|
413
|
+
console.log(chalk.cyan('\nManual Context Entry\n'));
|
|
414
|
+
|
|
415
|
+
const taskId = await input({
|
|
416
|
+
message: 'Ticket ID (e.g. PROJ-33):',
|
|
417
|
+
default: prefillId
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const title = await input({ message: 'Title:' });
|
|
421
|
+
const description = await input({ message: 'Description (brief):' });
|
|
422
|
+
const taskType = await select({
|
|
423
|
+
message: 'Task type:',
|
|
424
|
+
choices: [
|
|
425
|
+
{ name: '🐛 Bug Fix', value: 'bug-fix' },
|
|
426
|
+
{ name: '✨ Feature', value: 'feature' },
|
|
427
|
+
{ name: '🔍 Investigation', value: 'investigation' },
|
|
428
|
+
{ name: '♻️ Refactor', value: 'refactor' },
|
|
429
|
+
{ name: '📊 Impact Analysis', value: 'impact-analysis' }
|
|
430
|
+
]
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const context = {
|
|
434
|
+
taskId,
|
|
435
|
+
taskType,
|
|
436
|
+
title,
|
|
437
|
+
description,
|
|
438
|
+
status: 'In Progress',
|
|
439
|
+
acceptanceCriteria: [],
|
|
440
|
+
context: { files: [], relatedTickets: [] },
|
|
441
|
+
metadata: {
|
|
442
|
+
created: new Date().toISOString(),
|
|
443
|
+
loadedFrom: 'manual',
|
|
444
|
+
adapter: 'manual'
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
await saveContext(context);
|
|
449
|
+
printContextSummary(context);
|
|
450
|
+
await suggestNextStep(context);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ──────────────────────────────────────────────────────────────
|
|
454
|
+
// Load from JSON file
|
|
455
|
+
// ──────────────────────────────────────────────────────────────
|
|
456
|
+
|
|
457
|
+
async function loadFromFile(filePath, options = {}) {
|
|
458
|
+
if (!(await fs.pathExists(filePath))) {
|
|
459
|
+
console.log(chalk.red(`File not found: ${filePath}`));
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const context = await fs.readJson(filePath);
|
|
463
|
+
await saveContext(context, options.save);
|
|
464
|
+
printContextSummary(context);
|
|
465
|
+
await suggestNextStep(context);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ──────────────────────────────────────────────────────────────
|
|
469
|
+
// Credentials loader (reads .aiflow/credentials.json as fallback)
|
|
470
|
+
// ──────────────────────────────────────────────────────────────
|
|
471
|
+
|
|
472
|
+
async function loadCredentials() {
|
|
473
|
+
const credsFile = path.join(PROJECT_DIR, '.aiflow', 'credentials.json');
|
|
474
|
+
if (await fs.pathExists(credsFile)) {
|
|
475
|
+
return await fs.readJson(credsFile).catch(() => ({}));
|
|
476
|
+
}
|
|
477
|
+
return {};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ──────────────────────────────────────────────────────────────
|
|
481
|
+
// Context persistence
|
|
482
|
+
// ──────────────────────────────────────────────────────────────
|
|
483
|
+
|
|
484
|
+
async function saveContext(context, saveName) {
|
|
485
|
+
await fs.ensureDir(CONTEXT_DIR);
|
|
486
|
+
await fs.ensureDir(path.join(CONTEXT_DIR, 'history'));
|
|
487
|
+
|
|
488
|
+
// Save as current
|
|
489
|
+
await fs.writeJson(path.join(CONTEXT_DIR, 'current.json'), context, { spaces: 2 });
|
|
490
|
+
|
|
491
|
+
// Save to history
|
|
492
|
+
const histFile = path.join(CONTEXT_DIR, 'history', `${context.taskId || 'manual'}.json`);
|
|
493
|
+
await fs.writeJson(histFile, context, { spaces: 2 });
|
|
494
|
+
|
|
495
|
+
// Save named snapshot if requested
|
|
496
|
+
if (saveName) {
|
|
497
|
+
const namedFile = path.join(CONTEXT_DIR, 'history', `${saveName}.json`);
|
|
498
|
+
await fs.writeJson(namedFile, context, { spaces: 2 });
|
|
499
|
+
console.log(chalk.gray(` Context also saved as: ${saveName}`));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Update state
|
|
503
|
+
if (await fs.pathExists(STATE_FILE)) {
|
|
504
|
+
const state = await fs.readJson(STATE_FILE);
|
|
505
|
+
state.current_context = context.taskId;
|
|
506
|
+
state.current_context_type = context.taskType;
|
|
507
|
+
await fs.writeJson(STATE_FILE, state);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ──────────────────────────────────────────────────────────────
|
|
512
|
+
// Helpers
|
|
513
|
+
// ──────────────────────────────────────────────────────────────
|
|
514
|
+
|
|
515
|
+
function detectTaskType(issue) {
|
|
516
|
+
const name = issue.issueType?.name || issue.type?.name || '';
|
|
517
|
+
const summary = issue.summary || '';
|
|
518
|
+
return detectTaskTypeFromString(name || summary);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function detectTaskTypeFromString(text) {
|
|
522
|
+
const lower = text.toLowerCase();
|
|
523
|
+
if (['bug', 'defect', 'バグ'].some(k => lower.includes(k))) return 'bug-fix';
|
|
524
|
+
if (['task', 'story', 'feature', 'タスク'].some(k => lower.includes(k))) return 'feature';
|
|
525
|
+
if (['research', 'investigation', '調査'].some(k => lower.includes(k))) return 'investigation';
|
|
526
|
+
return 'bug-fix'; // default for unknown
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function extractCriteria(text) {
|
|
530
|
+
const lines = text.split('\n');
|
|
531
|
+
return lines
|
|
532
|
+
.filter(l => l.trim().match(/^[-*•]|^\d+\./))
|
|
533
|
+
.map(l => l.replace(/^[-*•\d.]+\s*/, '').trim())
|
|
534
|
+
.filter(l => l.length > 0)
|
|
535
|
+
.slice(0, 10);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function extractFiles(text) {
|
|
539
|
+
const matches = text.match(/[\w/]+\.(php|js|ts|vue|jsx|tsx|css|html)/g) || [];
|
|
540
|
+
return [...new Set(matches)];
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function extractRelatedTickets(text) {
|
|
544
|
+
const matches = text.match(/[A-Z][A-Z0-9_]+-\d+/g) || [];
|
|
545
|
+
return [...new Set(matches)];
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function printContextSummary(context) {
|
|
549
|
+
console.log(chalk.green('\n✓ Context loaded\n'));
|
|
550
|
+
console.log(` ${chalk.white('Ticket:')} ${context.taskId}`);
|
|
551
|
+
console.log(` ${chalk.white('Type:')} ${context.taskType}`);
|
|
552
|
+
console.log(` ${chalk.white('Title:')} ${context.title.substring(0, 70)}`);
|
|
553
|
+
console.log(` ${chalk.white('Status:')} ${context.status}`);
|
|
554
|
+
console.log(` ${chalk.white('Assignee:')} ${context.assignee}`);
|
|
555
|
+
if (context.description) {
|
|
556
|
+
console.log(` ${chalk.white('Desc:')} ${context.description.substring(0, 80)}...`);
|
|
557
|
+
}
|
|
558
|
+
if (context.acceptanceCriteria?.length) {
|
|
559
|
+
console.log(` ${chalk.white('Criteria:')} ${context.acceptanceCriteria.length} item(s)`);
|
|
560
|
+
}
|
|
561
|
+
if (context.commentCount > 0) {
|
|
562
|
+
console.log(` ${chalk.white('Comments:')} ${context.commentCount} comment(s) loaded`);
|
|
563
|
+
}
|
|
564
|
+
console.log();
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async function suggestNextStep(context) {
|
|
568
|
+
let aiTools = ['claude']; // Default
|
|
569
|
+
try {
|
|
570
|
+
if (await fs.pathExists(STATE_FILE)) {
|
|
571
|
+
const state = await fs.readJson(STATE_FILE);
|
|
572
|
+
if (state.aiTools && state.aiTools.length) {
|
|
573
|
+
aiTools = state.aiTools;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
} catch (_) {}
|
|
577
|
+
|
|
578
|
+
console.log(chalk.cyan('\nNext Steps:'));
|
|
579
|
+
|
|
580
|
+
if (aiTools.includes('claude')) {
|
|
581
|
+
console.log(` ${chalk.white('1. Claude:')} Run ${chalk.bold.green('claude start')} in terminal. ${chalk.gray('(Quickest way to start)')}`);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (aiTools.includes('cursor')) {
|
|
585
|
+
console.log(` ${chalk.white('2. Cursor:')} Open Cursor Chat ${chalk.gray('(Ctrl+L)')}.`);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (aiTools.includes('gemini')) {
|
|
589
|
+
console.log(` ${chalk.white('3. Gemini:')} Open Gemini and load project context.`);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
console.log(`\n ${chalk.yellow('Note:')} If the AI does not start automatically, type ${chalk.bold.green('"Gate 1"')} or ${chalk.bold.green('"Analyze ticket"')} to begin.`);
|
|
593
|
+
console.log();
|
|
594
|
+
}
|