@relipa/ai-flow-kit 0.2.0-beta.0 → 0.2.0-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/bin/aiflow.js +64 -4
- package/custom/rules/output-language.md +36 -0
- package/custom/skills/automation-testing/SKILL.md +1 -1
- package/custom/skills/ba-skills/skill-ba-build-business-rules-v1.md +2 -0
- package/custom/skills/ba-skills/skill-ba-initial-analysis-template-v1.md +7 -5
- package/custom/skills/ba-skills/skill-ba-initial-analysis-v1.md +8 -1
- package/custom/skills/ba-skills/skill-ba-mermaid-flowchart-v1.md +2 -0
- package/custom/skills/ba-skills/skill-ba-qna-template-v1.md +6 -5
- package/custom/skills/ba-skills/skill-ba-qna-v1.md +19 -5
- package/custom/skills/ba-skills/skill-ba-ui-prototype-v1.md +2 -0
- package/custom/skills/ba-skills/skill-ba-write-uc-spec-v1.md +2 -0
- package/custom/skills/coverage-check/SKILL.md +2 -0
- package/custom/skills/create-system-requirement/SKILL.md +115 -33
- package/custom/skills/deploy-model/SKILL.md +2 -0
- package/custom/skills/design-experiment/SKILL.md +2 -0
- package/custom/skills/evaluate-model/SKILL.md +2 -0
- package/custom/skills/evidence-aggregation/SKILL.md +2 -0
- package/custom/skills/execute-flow/SKILL.md +2 -0
- package/custom/skills/explore-data/SKILL.md +2 -0
- package/custom/skills/frame-ml-problem/SKILL.md +2 -0
- package/custom/skills/gate-review/SKILL.md +2 -0
- package/custom/skills/generate-spec/SKILL.md +2 -0
- package/custom/skills/generate-test-report/SKILL.md +2 -0
- package/custom/skills/generate-testcase/SKILL.md +2 -0
- package/custom/skills/impact-analysis/SKILL.md +2 -0
- package/custom/skills/improve-algorithm/SKILL.md +2 -0
- package/custom/skills/ingest-data/SKILL.md +117 -3
- package/custom/skills/log-bug/SKILL.md +2 -0
- package/custom/skills/pr-impact-analysis/SKILL.md +2 -0
- package/custom/skills/read-study-requirement/SKILL.md +13 -7
- package/custom/skills/report-customer/SKILL.md +6 -2
- package/custom/skills/retest-orchestration/SKILL.md +2 -0
- package/custom/skills/review-plan/SKILL.md +2 -0
- package/custom/skills/script-sync/SKILL.md +1 -1
- package/custom/skills/test-analysis/SKILL.md +2 -0
- package/custom/skills/test-skills/rules/qa-writing-standards.md +2 -2
- package/custom/skills/train-model/SKILL.md +2 -0
- package/custom/templates/shared/create-spec-workflow.md +93 -31
- package/custom/templates/shared/create-testcase-workflow.md +32 -1
- package/custom/templates/shared/gate-workflow.md +15 -0
- package/docs/common/CHANGELOG.md +23 -0
- package/package.json +1 -1
- package/scripts/create-score-excel.js +4 -111
- package/scripts/init.js +24 -1
- package/scripts/link-resolver.js +101 -4
- package/scripts/task.js +25 -8
- package/scripts/ticket-writer.js +247 -0
- package/scripts/use.js +141 -0
package/scripts/link-resolver.js
CHANGED
|
@@ -3,9 +3,13 @@ const https = require('https');
|
|
|
3
3
|
const chalk = require('chalk');
|
|
4
4
|
|
|
5
5
|
const BACKLOG_RE = /https?:\/\/([\w.-]+\.backlog(?:tool)?\.com)\/view\/([A-Z][A-Z0-9_]+-\d+)(?:#comment-(\d+))?/i;
|
|
6
|
-
// Backlog Document permalink — "Copy link" on a Document page gives /alias/document/{id}
|
|
7
|
-
//
|
|
8
|
-
|
|
6
|
+
// Backlog Document permalink — "Copy link" on a Document page gives /alias/document/{id},
|
|
7
|
+
// where {id} is Nulab's hex document ID (e.g. 0193b335c62173de9547bab5dd0b5324), not numeric.
|
|
8
|
+
// Newer "Share link" format adds a project-key + /e/ prefix: /document/{PROJECT_KEY}/e/{id}.
|
|
9
|
+
// Confirmed live against a real space (2026-08-06): GET /api/v2/documents/{id} returns the
|
|
10
|
+
// body as `plain` (not `plainText`/`content`) — see fetchBacklogDocument below.
|
|
11
|
+
// Comment fetch (fetchBacklogDocumentComment) is still unverified — adjust its field names if it 404s or returns empty content.
|
|
12
|
+
const BACKLOG_DOCUMENT_RE = /https?:\/\/([\w.-]+\.backlog(?:tool)?\.com)\/(?:alias\/)?document\/(?:[A-Z][A-Z0-9_]*\/e\/)?([0-9a-f]+)(?:#comment-(\d+))?/i;
|
|
9
13
|
// Backlog Wiki permalink — "Copy link" on a Wiki page gives /alias/wiki/{id}.
|
|
10
14
|
const BACKLOG_WIKI_RE = /https?:\/\/([\w.-]+\.backlog(?:tool)?\.com)\/alias\/wiki\/(\d+)/i;
|
|
11
15
|
const JIRA_RE = /https?:\/\/([\w.-]+\.atlassian\.net)\/browse\/([A-Z][A-Z0-9_]+-\d+)/i;
|
|
@@ -78,6 +82,41 @@ function httpsGet(url, headers) {
|
|
|
78
82
|
});
|
|
79
83
|
}
|
|
80
84
|
|
|
85
|
+
// Generic POST — `bodyType` picks the wire format:
|
|
86
|
+
// 'form' → application/x-www-form-urlencoded (Backlog's write API)
|
|
87
|
+
// 'json' → application/json (Jira's write API)
|
|
88
|
+
function httpsPost(url, payload, { headers = {}, bodyType = 'json' } = {}) {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const body = bodyType === 'form'
|
|
91
|
+
? new URLSearchParams(payload).toString()
|
|
92
|
+
: JSON.stringify(payload);
|
|
93
|
+
const reqHeaders = {
|
|
94
|
+
...headers,
|
|
95
|
+
'Content-Type': bodyType === 'form' ? 'application/x-www-form-urlencoded' : 'application/json',
|
|
96
|
+
'Content-Length': Buffer.byteLength(body),
|
|
97
|
+
};
|
|
98
|
+
const { hostname, pathname, search } = new URL(url);
|
|
99
|
+
const req = https.request(
|
|
100
|
+
{ hostname, path: pathname + search, method: 'POST', headers: reqHeaders },
|
|
101
|
+
(res) => {
|
|
102
|
+
let data = '';
|
|
103
|
+
res.on('data', chunk => (data += chunk));
|
|
104
|
+
res.on('end', () => {
|
|
105
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
106
|
+
reject(new Error(`HTTP ${res.statusCode}: ${data.substring(0, 500)}`));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
try { resolve(data ? JSON.parse(data) : {}); }
|
|
110
|
+
catch (e) { reject(new Error('Invalid JSON from API')); }
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
);
|
|
114
|
+
req.on('error', reject);
|
|
115
|
+
req.write(body);
|
|
116
|
+
req.end();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
81
120
|
async function fetchBacklogTicket(domain, apiKey, ticketId) {
|
|
82
121
|
const issue = await httpsGet(`https://${domain}/api/v2/issues/${ticketId}?apiKey=${apiKey}`);
|
|
83
122
|
return {
|
|
@@ -115,7 +154,7 @@ async function fetchBacklogDocument(domain, apiKey, documentId) {
|
|
|
115
154
|
documentId: String(documentId),
|
|
116
155
|
title: doc.title || doc.name || '',
|
|
117
156
|
tag: (doc.tags || []).map(t => t.name).join(', '),
|
|
118
|
-
content: (doc.plainText || doc.content || '').substring(0, 4000),
|
|
157
|
+
content: (doc.plain || doc.plainText || doc.content || '').substring(0, 4000),
|
|
119
158
|
};
|
|
120
159
|
}
|
|
121
160
|
|
|
@@ -148,6 +187,36 @@ async function fetchBacklogWiki(domain, apiKey, wikiId) {
|
|
|
148
187
|
};
|
|
149
188
|
}
|
|
150
189
|
|
|
190
|
+
// ── Backlog WRITE — issue creation (PM Workflow_v1.0.md "Vấn đề 5") ──────────
|
|
191
|
+
|
|
192
|
+
async function fetchBacklogIssueTypes(domain, apiKey, projectIdOrKey) {
|
|
193
|
+
return httpsGet(`https://${domain}/api/v2/projects/${projectIdOrKey}/issueTypes?apiKey=${apiKey}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function fetchBacklogPriorities(domain, apiKey) {
|
|
197
|
+
return httpsGet(`https://${domain}/api/v2/priorities?apiKey=${apiKey}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Creates a Backlog issue. Requires an apiKey with WRITE permission — distinct from the
|
|
202
|
+
* read-only key used elsewhere in this file for `ak fetch-links`/`ak use`.
|
|
203
|
+
* `projectId`, `summary`, `issueTypeId`, `priorityId` are mandatory per Backlog's API.
|
|
204
|
+
*/
|
|
205
|
+
async function createBacklogIssue(domain, apiKeyWrite, { projectId, summary, description, issueTypeId, priorityId }) {
|
|
206
|
+
const issue = await httpsPost(
|
|
207
|
+
`https://${domain}/api/v2/issues?apiKey=${apiKeyWrite}`,
|
|
208
|
+
{
|
|
209
|
+
projectId: String(projectId),
|
|
210
|
+
summary,
|
|
211
|
+
issueTypeId: String(issueTypeId),
|
|
212
|
+
priorityId: String(priorityId),
|
|
213
|
+
description: description || '',
|
|
214
|
+
},
|
|
215
|
+
{ bodyType: 'form' }
|
|
216
|
+
);
|
|
217
|
+
return { ticketId: issue.issueKey, url: `https://${domain}/view/${issue.issueKey}`, raw: issue };
|
|
218
|
+
}
|
|
219
|
+
|
|
151
220
|
function jiraAuth(email, token) {
|
|
152
221
|
return `Basic ${Buffer.from(`${email}:${token}`).toString('base64')}`;
|
|
153
222
|
}
|
|
@@ -191,6 +260,32 @@ async function fetchJiraComment(domain, email, token, ticketId, commentId) {
|
|
|
191
260
|
};
|
|
192
261
|
}
|
|
193
262
|
|
|
263
|
+
// ── Jira WRITE — issue creation (PM Workflow_v1.0.md "Vấn đề 5") ────────────
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Creates a Jira issue. `apiTokenWrite`/`emailWrite` are distinct from the read-only
|
|
267
|
+
* credentials used elsewhere in this file — same "separate WRITE key" policy as Backlog.
|
|
268
|
+
*/
|
|
269
|
+
async function createJiraIssue(domain, emailWrite, apiTokenWrite, { projectKey, summary, description, issueTypeName }) {
|
|
270
|
+
const issue = await httpsPost(
|
|
271
|
+
`https://${domain}/rest/api/3/issue`,
|
|
272
|
+
{
|
|
273
|
+
fields: {
|
|
274
|
+
project: { key: projectKey },
|
|
275
|
+
summary,
|
|
276
|
+
issuetype: { name: issueTypeName || 'Task' },
|
|
277
|
+
description: {
|
|
278
|
+
type: 'doc',
|
|
279
|
+
version: 1,
|
|
280
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: description || '' }] }],
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
{ headers: { Authorization: jiraAuth(emailWrite, apiTokenWrite), Accept: 'application/json' }, bodyType: 'json' }
|
|
285
|
+
);
|
|
286
|
+
return { ticketId: issue.key, url: `https://${domain}/browse/${issue.key}`, raw: issue };
|
|
287
|
+
}
|
|
288
|
+
|
|
194
289
|
function linkDedupeKey(classified) {
|
|
195
290
|
if (classified.kind === 'document') return 'doc:' + classified.documentId + (classified.commentId ? '#' + classified.commentId : '');
|
|
196
291
|
if (classified.kind === 'wiki') return 'wiki:' + classified.wikiId;
|
|
@@ -270,4 +365,6 @@ module.exports = {
|
|
|
270
365
|
fetchBacklogTicket, fetchBacklogComment,
|
|
271
366
|
fetchBacklogDocument, fetchBacklogDocumentComment, fetchBacklogWiki,
|
|
272
367
|
fetchJiraTicket, fetchJiraComment,
|
|
368
|
+
fetchBacklogIssueTypes, fetchBacklogPriorities, createBacklogIssue,
|
|
369
|
+
createJiraIssue,
|
|
273
370
|
};
|
package/scripts/task.js
CHANGED
|
@@ -345,8 +345,9 @@ async function nextGate(taskId) {
|
|
|
345
345
|
const isGenDoc = taskType === 'gen-doc';
|
|
346
346
|
const isSystemRequirement = taskType === 'create-system-requirement';
|
|
347
347
|
const isIngestData = taskType === 'ingest-data';
|
|
348
|
-
const isTwoGateDoc = isGenDoc || isSystemRequirement
|
|
349
|
-
const
|
|
348
|
+
const isTwoGateDoc = isGenDoc || isSystemRequirement;
|
|
349
|
+
const isDocFlow = isTwoGateDoc || isIngestData;
|
|
350
|
+
const maxGate = isTwoGateDoc ? 2 : isIngestData ? 3 : 5;
|
|
350
351
|
|
|
351
352
|
const taskState = {
|
|
352
353
|
...existing,
|
|
@@ -371,7 +372,7 @@ async function nextGate(taskId) {
|
|
|
371
372
|
console.log(chalk.green(`✓ Gate ${currentGate} approved for ${resolvedId}.`));
|
|
372
373
|
console.log(chalk.gray(` Summary saved to: .aiflow/tasks/${resolvedId}/task-summary.md`));
|
|
373
374
|
|
|
374
|
-
if (
|
|
375
|
+
if (isDocFlow && currentGate >= maxGate) {
|
|
375
376
|
console.log(chalk.green('\n ✅ Document workflow complete. Task is done.'));
|
|
376
377
|
} else {
|
|
377
378
|
const nextLabel = gateLabel(nextGateNum, taskType);
|
|
@@ -380,7 +381,7 @@ async function nextGate(taskId) {
|
|
|
380
381
|
console.log(chalk.gray(` 1. Open a NEW chatbox or terminal session.`));
|
|
381
382
|
console.log(chalk.gray(` 2. Run: aiflow task resume ${resolvedId} (to load context).`));
|
|
382
383
|
console.log(chalk.gray(` 3. Type "start" or "continue from the current plan".`));
|
|
383
|
-
if (!
|
|
384
|
+
if (!isDocFlow) {
|
|
384
385
|
console.log(chalk.yellow(` (Note: Gate 3 progress is saved via [x] checkboxes in the Gate 2 plan doc (AK-Docs/04.Coding/02.Plans/).`));
|
|
385
386
|
console.log(chalk.yellow(` The AI will automatically resume the exact task you left off.)`));
|
|
386
387
|
}
|
|
@@ -485,7 +486,10 @@ async function detectCurrentGate(taskId) {
|
|
|
485
486
|
|
|
486
487
|
async function generateMarkdownSummary(taskState, taskType = null) {
|
|
487
488
|
taskType = taskType || taskState.taskType || 'feature';
|
|
488
|
-
const maxGate = taskType === 'testing' ? 4
|
|
489
|
+
const maxGate = taskType === 'testing' ? 4
|
|
490
|
+
: taskType === 'ingest-data' ? 3
|
|
491
|
+
: (taskType === 'gen-doc' || taskType === 'create-system-requirement') ? 2
|
|
492
|
+
: 5;
|
|
489
493
|
const lines = [];
|
|
490
494
|
lines.push(`# Task Summary: ${taskState.taskId}`);
|
|
491
495
|
lines.push(`**Title:** ${taskState.title}`);
|
|
@@ -531,7 +535,8 @@ function gateLabel(n, taskType = null) {
|
|
|
531
535
|
if (taskType === 'ingest-data') {
|
|
532
536
|
if (n === 1) return 'Fetch, Classify, Draft';
|
|
533
537
|
if (n === 2) return 'Branch + Merge Request';
|
|
534
|
-
if (n
|
|
538
|
+
if (n === 3) return 'Sinh Task & Tạo Ticket';
|
|
539
|
+
if (n >= 4) return 'Done';
|
|
535
540
|
}
|
|
536
541
|
const labels = {
|
|
537
542
|
1: 'AI Analyze Requirement',
|
|
@@ -599,15 +604,19 @@ module.exports.createOrActivateTaskState = async function createOrActivateTaskSt
|
|
|
599
604
|
};
|
|
600
605
|
|
|
601
606
|
/**
|
|
602
|
-
* Called by `aiflow gate <N> <start|approved>` to keep task-state.json in sync
|
|
607
|
+
* Called by `aiflow gate <N> <start|approved|skip>` to keep task-state.json in sync
|
|
603
608
|
* with the actual gate progress — without requiring the user to run `task next`.
|
|
604
609
|
*
|
|
605
610
|
* - 'start' : advances currentGate to N (if N is higher than stored value)
|
|
606
611
|
* - 'approved': records approval timestamp + advances currentGate to N+1
|
|
612
|
+
* - 'skip' : records a skip (gate legitimately produced no deliverable, e.g.
|
|
613
|
+
* ingest-data Gate 3 deciding no task is needed) + advances currentGate
|
|
614
|
+
* to N+1, same as 'approved' — every "is this task done" check only ever
|
|
615
|
+
* inspects `currentGate`, so a skipped gate still lets the task complete.
|
|
607
616
|
*
|
|
608
617
|
* Silently no-ops when task-state.json does not exist yet (task not saved).
|
|
609
618
|
*/
|
|
610
|
-
module.exports.updateTaskGateState = async function updateTaskGateState(ticketId, gateNum, action) {
|
|
619
|
+
module.exports.updateTaskGateState = async function updateTaskGateState(ticketId, gateNum, action, options = {}) {
|
|
611
620
|
if (!ticketId) return;
|
|
612
621
|
const taskDir = path.join(TASKS_DIR, ticketId);
|
|
613
622
|
const statePath = path.join(taskDir, 'task-state.json');
|
|
@@ -633,6 +642,14 @@ module.exports.updateTaskGateState = async function updateTaskGateState(ticketId
|
|
|
633
642
|
if (gateNum >= (existing.currentGate || 1)) {
|
|
634
643
|
updated.currentGate = gateNum + 1;
|
|
635
644
|
}
|
|
645
|
+
} else if (action === 'skip') {
|
|
646
|
+
const skippedGates = { ...(existing.skippedGates || {}) };
|
|
647
|
+
skippedGates[String(gateNum)] = { at: now, reason: options.reason || '' };
|
|
648
|
+
updated.skippedGates = skippedGates;
|
|
649
|
+
// Advance currentGate to next gate (only if not already ahead) — same as 'approved'
|
|
650
|
+
if (gateNum >= (existing.currentGate || 1)) {
|
|
651
|
+
updated.currentGate = gateNum + 1;
|
|
652
|
+
}
|
|
636
653
|
}
|
|
637
654
|
|
|
638
655
|
await fs.writeJson(statePath, updated, { spaces: 2 });
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Vấn đề 5 (docs/internal/PM Workflow_v1.0.md) — turns an AI-proposed, PM-approved task list
|
|
3
|
+
// (born out of `ingest-data` Gate 3) into real tickets on Backlog/Jira.
|
|
4
|
+
//
|
|
5
|
+
// Read access (fetch-links, listing projects/issueTypes/priorities) reuses the existing
|
|
6
|
+
// read-only credentials already configured for `ak use`/`ak fetch-links`. Creating a ticket is a
|
|
7
|
+
// WRITE action and requires a SEPARATE credential (`BACKLOG_API_KEY_WRITE` / `JIRA_API_TOKEN_WRITE`)
|
|
8
|
+
// — same "distinct WRITE key" policy the PM Workflow doc calls for, kept apart from the read key so
|
|
9
|
+
// a compromised/misconfigured read key can never create tickets.
|
|
10
|
+
|
|
11
|
+
const fs = require('fs-extra');
|
|
12
|
+
const chalk = require('chalk');
|
|
13
|
+
const useCommand = require('./use');
|
|
14
|
+
const linkResolver = require('./link-resolver');
|
|
15
|
+
|
|
16
|
+
const {
|
|
17
|
+
loadCredentials,
|
|
18
|
+
resolveBacklogCredentials,
|
|
19
|
+
resolveJiraCredentials,
|
|
20
|
+
saveMcpCredentials,
|
|
21
|
+
} = useCommand;
|
|
22
|
+
|
|
23
|
+
const {
|
|
24
|
+
fetchBacklogIssueTypes,
|
|
25
|
+
fetchBacklogPriorities,
|
|
26
|
+
createBacklogIssue,
|
|
27
|
+
createJiraIssue,
|
|
28
|
+
} = linkResolver;
|
|
29
|
+
|
|
30
|
+
// Only these keys can be written via `ak credentials set` — dedicated adapter credentials
|
|
31
|
+
// (BACKLOG_API_KEY, JIRA_API_TOKEN, ...) still go through `ak init --adapter` on purpose.
|
|
32
|
+
const SETTABLE_CREDENTIAL_KEYS = [
|
|
33
|
+
'BACKLOG_API_KEY_WRITE',
|
|
34
|
+
'JIRA_API_TOKEN_WRITE',
|
|
35
|
+
'JIRA_EMAIL_WRITE',
|
|
36
|
+
'BACKLOG_DEFAULT_PROJECT_ID',
|
|
37
|
+
'BACKLOG_DEFAULT_PROJECT_KEY',
|
|
38
|
+
'JIRA_DEFAULT_PROJECT_KEY',
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
function jiraFullDomain(domain) {
|
|
42
|
+
return domain.includes('.') ? domain : `${domain}.atlassian.net`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function missingCredResult(target, field, message) {
|
|
46
|
+
return { ok: false, error: 'missing-write-credentials', target, field, message };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function resolveBacklogWrite(creds) {
|
|
50
|
+
const read = resolveBacklogCredentials(creds);
|
|
51
|
+
if (!read) {
|
|
52
|
+
return missingCredResult(
|
|
53
|
+
'backlog', 'BACKLOG_API_KEY / BACKLOG_SPACE_KEY',
|
|
54
|
+
'Chưa cấu hình credentials đọc Backlog. Chạy `ak init --adapter backlog` trước.'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const apiKeyWrite = process.env.BACKLOG_API_KEY_WRITE || creds.BACKLOG_API_KEY_WRITE;
|
|
58
|
+
if (!apiKeyWrite) {
|
|
59
|
+
return missingCredResult(
|
|
60
|
+
'backlog', 'BACKLOG_API_KEY_WRITE',
|
|
61
|
+
'Cần API key Backlog có quyền tạo issue (khác key đọc hiện tại). Cung cấp API key này, mình sẽ lưu lại và tạo ticket ngay.'
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return { ok: true, domain: read.domain, apiKeyRead: read.apiKey, apiKeyWrite };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function resolveJiraWrite(creds) {
|
|
68
|
+
const read = resolveJiraCredentials(creds);
|
|
69
|
+
if (!read) {
|
|
70
|
+
return missingCredResult(
|
|
71
|
+
'jira', 'JIRA_API_TOKEN / JIRA_EMAIL / JIRA_DOMAIN',
|
|
72
|
+
'Chưa cấu hình credentials đọc Jira. Chạy `ak init --adapter jira` trước.'
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const apiTokenWrite = process.env.JIRA_API_TOKEN_WRITE || creds.JIRA_API_TOKEN_WRITE;
|
|
76
|
+
if (!apiTokenWrite) {
|
|
77
|
+
return missingCredResult(
|
|
78
|
+
'jira', 'JIRA_API_TOKEN_WRITE',
|
|
79
|
+
'Cần Jira API token có quyền tạo issue (khác token đọc hiện tại). Cung cấp token này, mình sẽ lưu lại và tạo ticket ngay.'
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
const emailWrite = process.env.JIRA_EMAIL_WRITE || creds.JIRA_EMAIL_WRITE || read.email;
|
|
83
|
+
return { ok: true, domain: jiraFullDomain(read.domain), emailWrite, apiTokenWrite };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function pickBacklogDefaults(domain, apiKeyRead, projectId) {
|
|
87
|
+
const [issueTypes, priorities] = await Promise.all([
|
|
88
|
+
fetchBacklogIssueTypes(domain, apiKeyRead, projectId),
|
|
89
|
+
fetchBacklogPriorities(domain, apiKeyRead),
|
|
90
|
+
]);
|
|
91
|
+
if (!issueTypes || !issueTypes.length) throw new Error(`Project ${projectId} không có issueType nào — không thể tạo issue.`);
|
|
92
|
+
const defaultIssueTypeId = issueTypes[0].id;
|
|
93
|
+
const normal = (priorities || []).find((p) => /normal|trung bình/i.test(p.name));
|
|
94
|
+
const defaultPriorityId = (normal || (priorities || [])[0] || {}).id;
|
|
95
|
+
if (!defaultPriorityId) throw new Error('Không lấy được danh sách priority từ Backlog.');
|
|
96
|
+
return { defaultIssueTypeId, defaultPriorityId };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `ak tasks create-tickets <file.json> [--json]`
|
|
101
|
+
*
|
|
102
|
+
* Input file shape:
|
|
103
|
+
* {
|
|
104
|
+
* "target": "backlog" | "jira",
|
|
105
|
+
* "projectId": "12345", // optional — Backlog only, overrides BACKLOG_DEFAULT_PROJECT_ID
|
|
106
|
+
* "projectKey": "PROJ", // optional — Jira only, overrides JIRA_DEFAULT_PROJECT_KEY
|
|
107
|
+
* "tasks": [
|
|
108
|
+
* { "type": "spec|coding|test|other", "title": "...", "description": "..." }
|
|
109
|
+
* ]
|
|
110
|
+
* }
|
|
111
|
+
*
|
|
112
|
+
* `description` is expected to already be fully composed by the AI per the ticket template in
|
|
113
|
+
* PM Workflow_v1.0.md §6.1 (Nội dung task + Nguồn tham chiếu) — this command posts it as-is.
|
|
114
|
+
*/
|
|
115
|
+
async function createTicketsCommand(inputFile, options = {}) {
|
|
116
|
+
const emit = (obj) => {
|
|
117
|
+
if (options.json) { process.stdout.write(JSON.stringify(obj)); return; }
|
|
118
|
+
if (obj.ok === false) {
|
|
119
|
+
console.log(chalk.red(`✗ ${obj.message || obj.error}`));
|
|
120
|
+
} else {
|
|
121
|
+
console.log(chalk.green(`✓ ${obj.results.filter(r => r.ok).length}/${obj.results.length} ticket(s) created on ${obj.target}.`));
|
|
122
|
+
for (const r of obj.results) {
|
|
123
|
+
console.log(r.ok ? ` ${chalk.green('✓')} ${r.ticketId} ${chalk.gray(r.title)}` : ` ${chalk.red('✗')} ${chalk.gray(r.title)} — ${r.error}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
console.log(JSON.stringify(obj));
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
let input;
|
|
130
|
+
try {
|
|
131
|
+
input = await fs.readJson(inputFile);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
emit({ ok: false, error: 'invalid-input-file', message: `Không đọc được ${inputFile}: ${err.message}` });
|
|
134
|
+
process.exitCode = 1;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const target = input.target;
|
|
139
|
+
const tasks = input.tasks || [];
|
|
140
|
+
if (!['backlog', 'jira'].includes(target)) {
|
|
141
|
+
emit({ ok: false, error: 'invalid-target', message: `target phải là "backlog" hoặc "jira", nhận được: ${target}` });
|
|
142
|
+
process.exitCode = 1;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (!tasks.length) {
|
|
146
|
+
emit({ ok: false, error: 'empty-tasks', message: 'File input không có task nào trong "tasks".' });
|
|
147
|
+
process.exitCode = 1;
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const creds = await loadCredentials();
|
|
152
|
+
|
|
153
|
+
if (target === 'backlog') {
|
|
154
|
+
const write = await resolveBacklogWrite(creds);
|
|
155
|
+
if (!write.ok) { emit(write); process.exitCode = 2; return; }
|
|
156
|
+
|
|
157
|
+
const projectId = input.projectId || creds.BACKLOG_DEFAULT_PROJECT_ID;
|
|
158
|
+
if (!projectId) {
|
|
159
|
+
emit({
|
|
160
|
+
ok: false, error: 'missing-project',
|
|
161
|
+
message: 'Chưa xác định projectId đích trên Backlog. Chạy `ak backlog-projects` để xem danh sách, rồi `ak backlog-set-default-project <id> <key>`.',
|
|
162
|
+
});
|
|
163
|
+
process.exitCode = 2;
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let defaults;
|
|
168
|
+
try {
|
|
169
|
+
defaults = await pickBacklogDefaults(write.domain, write.apiKeyRead, projectId);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
emit({ ok: false, error: 'project-lookup-failed', message: err.message });
|
|
172
|
+
process.exitCode = 1;
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const results = [];
|
|
177
|
+
for (const task of tasks) {
|
|
178
|
+
try {
|
|
179
|
+
const created = await createBacklogIssue(write.domain, write.apiKeyWrite, {
|
|
180
|
+
projectId,
|
|
181
|
+
summary: task.title,
|
|
182
|
+
description: task.description,
|
|
183
|
+
issueTypeId: task.issueTypeId || defaults.defaultIssueTypeId,
|
|
184
|
+
priorityId: task.priorityId || defaults.defaultPriorityId,
|
|
185
|
+
});
|
|
186
|
+
results.push({ type: task.type, title: task.title, ok: true, ticketId: created.ticketId, url: created.url });
|
|
187
|
+
} catch (err) {
|
|
188
|
+
results.push({ type: task.type, title: task.title, ok: false, error: err.message });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
emit({ ok: true, target, projectId, results });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// target === 'jira'
|
|
196
|
+
const write = await resolveJiraWrite(creds);
|
|
197
|
+
if (!write.ok) { emit(write); process.exitCode = 2; return; }
|
|
198
|
+
|
|
199
|
+
const projectKey = input.projectKey || creds.JIRA_DEFAULT_PROJECT_KEY;
|
|
200
|
+
if (!projectKey) {
|
|
201
|
+
emit({
|
|
202
|
+
ok: false, error: 'missing-project',
|
|
203
|
+
message: 'Chưa xác định project đích trên Jira. Chạy `ak jira-projects` để xem danh sách, rồi `ak jira-set-default-project <key>`.',
|
|
204
|
+
});
|
|
205
|
+
process.exitCode = 2;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const results = [];
|
|
210
|
+
for (const task of tasks) {
|
|
211
|
+
try {
|
|
212
|
+
const created = await createJiraIssue(write.domain, write.emailWrite, write.apiTokenWrite, {
|
|
213
|
+
projectKey,
|
|
214
|
+
summary: task.title,
|
|
215
|
+
description: task.description,
|
|
216
|
+
issueTypeName: task.issueType || 'Task',
|
|
217
|
+
});
|
|
218
|
+
results.push({ type: task.type, title: task.title, ok: true, ticketId: created.ticketId, url: created.url });
|
|
219
|
+
} catch (err) {
|
|
220
|
+
results.push({ type: task.type, title: task.title, ok: false, error: err.message });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
emit({ ok: true, target, projectKey, results });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* `ak credentials set <KEY> <VALUE>` — lets the AI persist a WRITE key the user pasted in chat
|
|
228
|
+
* (after being asked, per the missing-write-credentials error above) without a bespoke command
|
|
229
|
+
* per key. Restricted to an allowlist — dedicated adapter read-credentials still go through
|
|
230
|
+
* `ak init --adapter`, this is only for the pieces that flow introduced.
|
|
231
|
+
*/
|
|
232
|
+
async function setCredentialCommand(key, value) {
|
|
233
|
+
if (!SETTABLE_CREDENTIAL_KEYS.includes(key)) {
|
|
234
|
+
console.log(chalk.red(`✗ Unknown credential key: ${key}.`));
|
|
235
|
+
console.log(chalk.gray(` Allowed: ${SETTABLE_CREDENTIAL_KEYS.join(', ')}`));
|
|
236
|
+
process.exitCode = 1;
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
await saveMcpCredentials({ [key]: value });
|
|
240
|
+
console.log(chalk.green(`✓ Saved ${key} to ~/.aiflow/credentials.json`));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
module.exports = {
|
|
244
|
+
createTicketsCommand,
|
|
245
|
+
setCredentialCommand,
|
|
246
|
+
SETTABLE_CREDENTIAL_KEYS,
|
|
247
|
+
};
|
package/scripts/use.js
CHANGED
|
@@ -360,6 +360,14 @@ function backlogGet(url) {
|
|
|
360
360
|
});
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Fetch the list of projects in the Backlog space — used to let the user pick a default
|
|
365
|
+
* project for tasks created manually (no ticket to derive a project from).
|
|
366
|
+
*/
|
|
367
|
+
function fetchBacklogProjects(domain, apiKey) {
|
|
368
|
+
return backlogGet(`https://${domain}/api/v2/projects?apiKey=${apiKey}`);
|
|
369
|
+
}
|
|
370
|
+
|
|
363
371
|
/**
|
|
364
372
|
* Fetch issue detail from Backlog REST API
|
|
365
373
|
*/
|
|
@@ -584,6 +592,64 @@ function fetchJiraIssue(domain, email, apiToken, issueKey) {
|
|
|
584
592
|
});
|
|
585
593
|
}
|
|
586
594
|
|
|
595
|
+
/**
|
|
596
|
+
* `ak jira-projects [--json]` — lists the projects visible to the configured Jira account, so the
|
|
597
|
+
* user can pick a default project for tasks created from Meeting Minutes/QnA (see Vấn đề 5.1 in
|
|
598
|
+
* PM Workflow_v1.0.md). Mirrors `listBacklogProjectsCommand` above.
|
|
599
|
+
*/
|
|
600
|
+
function fetchJiraProjects(domain, email, apiToken) {
|
|
601
|
+
return new Promise((resolve, reject) => {
|
|
602
|
+
const auth = Buffer.from(`${email}:${apiToken}`).toString("base64");
|
|
603
|
+
const url = `https://${domain}.atlassian.net/rest/api/3/project`;
|
|
604
|
+
https
|
|
605
|
+
.get(url, { headers: { Authorization: `Basic ${auth}`, Accept: "application/json" } }, (res) => {
|
|
606
|
+
let data = "";
|
|
607
|
+
res.on("data", (chunk) => (data += chunk));
|
|
608
|
+
res.on("end", () => {
|
|
609
|
+
if (res.statusCode !== 200) {
|
|
610
|
+
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
try { resolve(JSON.parse(data)); }
|
|
614
|
+
catch (e) { reject(new Error("Invalid JSON response from Jira")); }
|
|
615
|
+
});
|
|
616
|
+
})
|
|
617
|
+
.on("error", reject);
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async function listJiraProjectsCommand(options = {}) {
|
|
622
|
+
const creds = await loadCredentials();
|
|
623
|
+
const resolved = resolveJiraCredentials(creds);
|
|
624
|
+
if (!resolved) {
|
|
625
|
+
const msg = "Jira credentials not set. Run `aiflow init --adapter jira` first.";
|
|
626
|
+
if (options.json) { process.stdout.write(JSON.stringify({ ok: false, error: msg })); return; }
|
|
627
|
+
console.log(chalk.yellow(`⚠ ${msg}`));
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
try {
|
|
631
|
+
const projects = await fetchJiraProjects(resolved.domain, resolved.email, resolved.apiToken);
|
|
632
|
+
const list = projects.map((p) => ({ id: p.id, key: p.key, name: p.name }));
|
|
633
|
+
if (options.json) {
|
|
634
|
+
process.stdout.write(JSON.stringify({ ok: true, projects: list, defaultProjectKey: creds.JIRA_DEFAULT_PROJECT_KEY ?? null }));
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
console.log(chalk.cyan(`\nJira projects (${list.length}):`));
|
|
638
|
+
for (const p of list) {
|
|
639
|
+
const isDefault = p.key === creds.JIRA_DEFAULT_PROJECT_KEY;
|
|
640
|
+
console.log(` ${isDefault ? chalk.green("●") : " "} ${p.key} ${chalk.gray(p.name)} ${chalk.gray(`(id: ${p.id})`)}`);
|
|
641
|
+
}
|
|
642
|
+
} catch (err) {
|
|
643
|
+
if (options.json) { process.stdout.write(JSON.stringify({ ok: false, error: err.message })); return; }
|
|
644
|
+
console.log(chalk.red(`✗ Could not fetch Jira projects: ${err.message}`));
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function setDefaultJiraProjectCommand(key) {
|
|
649
|
+
await saveMcpCredentials({ JIRA_DEFAULT_PROJECT_KEY: key });
|
|
650
|
+
console.log(chalk.green(`✓ Default Jira project set to ${key}.`));
|
|
651
|
+
}
|
|
652
|
+
|
|
587
653
|
/**
|
|
588
654
|
* Fetch ALL comments of an issue from Jira REST API.
|
|
589
655
|
*/
|
|
@@ -787,11 +853,20 @@ async function manualContext(prefillId = "", options = {}) {
|
|
|
787
853
|
// Task type — pre-select existing value if available
|
|
788
854
|
const taskType = await promptForTaskType(existing.taskType, options.taskType);
|
|
789
855
|
|
|
856
|
+
// Stamp the default Backlog project (`ak backlog-set-default-project`), if one was ever set —
|
|
857
|
+
// manual tasks have no ticket to derive a project from otherwise. Kept for later use: syncing a
|
|
858
|
+
// locally-created task up to Backlog as a new issue needs to know which project it belongs to.
|
|
859
|
+
const creds = await loadCredentials();
|
|
860
|
+
const projectId = existing.projectId ?? creds.BACKLOG_DEFAULT_PROJECT_ID ?? null;
|
|
861
|
+
const projectKey = existing.projectKey ?? creds.BACKLOG_DEFAULT_PROJECT_KEY ?? null;
|
|
862
|
+
|
|
790
863
|
const context = {
|
|
791
864
|
taskId,
|
|
792
865
|
taskType,
|
|
793
866
|
title,
|
|
794
867
|
description,
|
|
868
|
+
projectId,
|
|
869
|
+
projectKey,
|
|
795
870
|
status: existing.status || "In Progress",
|
|
796
871
|
mode: existing.mode || "auto",
|
|
797
872
|
acceptanceCriteria: existing.acceptanceCriteria || [],
|
|
@@ -967,6 +1042,63 @@ async function loadCredentials() {
|
|
|
967
1042
|
return {};
|
|
968
1043
|
}
|
|
969
1044
|
|
|
1045
|
+
/** Always the global file (`~/.aiflow/credentials.json`) — same path `loadCredentials` reads
|
|
1046
|
+
* first — so a value written here is picked up by every later `loadCredentials()` call. */
|
|
1047
|
+
function globalCredentialsPath() {
|
|
1048
|
+
return path.join(os.homedir(), ".aiflow", "credentials.json");
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/** Merges `patch` into the saved `.mcp` credentials block, creating the file if needed. */
|
|
1052
|
+
async function saveMcpCredentials(patch) {
|
|
1053
|
+
const file = globalCredentialsPath();
|
|
1054
|
+
await fs.ensureDir(path.dirname(file));
|
|
1055
|
+
const data = (await fs.pathExists(file)) ? await fs.readJson(file).catch(() => ({})) : {};
|
|
1056
|
+
data.mcp = { ...(data.mcp || {}), ...patch };
|
|
1057
|
+
await fs.writeJson(file, data, { spaces: 2 });
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* `ak backlog-projects [--json]` — lists the projects in the configured Backlog space, so the
|
|
1062
|
+
* user (or a headless caller like the VS Code extension) can pick one as the default project for
|
|
1063
|
+
* manually-created tasks (see `manualContext` below, which stamps that default onto new tasks).
|
|
1064
|
+
*/
|
|
1065
|
+
async function listBacklogProjectsCommand(options = {}) {
|
|
1066
|
+
const creds = await loadCredentials();
|
|
1067
|
+
const resolved = resolveBacklogCredentials(creds);
|
|
1068
|
+
if (!resolved) {
|
|
1069
|
+
const msg = "Backlog credentials not set. Run `aiflow init --adapter backlog` first.";
|
|
1070
|
+
if (options.json) { process.stdout.write(JSON.stringify({ ok: false, error: msg })); return; }
|
|
1071
|
+
console.log(chalk.yellow(`⚠ ${msg}`));
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
try {
|
|
1075
|
+
const projects = await fetchBacklogProjects(resolved.domain, resolved.apiKey);
|
|
1076
|
+
const list = projects.map((p) => ({ id: p.id, projectKey: p.projectKey, name: p.name }));
|
|
1077
|
+
if (options.json) {
|
|
1078
|
+
process.stdout.write(JSON.stringify({ ok: true, projects: list, defaultProjectId: creds.BACKLOG_DEFAULT_PROJECT_ID ?? null }));
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
console.log(chalk.cyan(`\nBacklog projects (${list.length}):`));
|
|
1082
|
+
for (const p of list) {
|
|
1083
|
+
const isDefault = String(p.id) === String(creds.BACKLOG_DEFAULT_PROJECT_ID);
|
|
1084
|
+
console.log(` ${isDefault ? chalk.green("●") : " "} ${p.projectKey} ${chalk.gray(p.name)} ${chalk.gray(`(id: ${p.id})`)}`);
|
|
1085
|
+
}
|
|
1086
|
+
} catch (err) {
|
|
1087
|
+
if (options.json) { process.stdout.write(JSON.stringify({ ok: false, error: err.message })); return; }
|
|
1088
|
+
console.log(chalk.red(`✗ Could not fetch Backlog projects: ${err.message}`));
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* `ak backlog-set-default-project <id> <key>` — saves the chosen default project, so future
|
|
1094
|
+
* manually-created tasks (`ak use --manual`) get stamped with it automatically. Non-interactive —
|
|
1095
|
+
* safe to spawn without a TTY (see New-ex-architecture_v1.0.md §1.1 for why that distinction matters).
|
|
1096
|
+
*/
|
|
1097
|
+
async function setDefaultBacklogProjectCommand(id, key) {
|
|
1098
|
+
await saveMcpCredentials({ BACKLOG_DEFAULT_PROJECT_ID: id, BACKLOG_DEFAULT_PROJECT_KEY: key });
|
|
1099
|
+
console.log(chalk.green(`✓ Default Backlog project set to ${key} (id: ${id}).`));
|
|
1100
|
+
}
|
|
1101
|
+
|
|
970
1102
|
// ──────────────────────────────────────────────────────────────
|
|
971
1103
|
// Context persistence
|
|
972
1104
|
// ──────────────────────────────────────────────────────────────
|
|
@@ -1353,3 +1485,12 @@ module.exports.detectTaskTypeFromString = detectTaskTypeFromString;
|
|
|
1353
1485
|
module.exports.manualContext = manualContext;
|
|
1354
1486
|
module.exports.promptForTaskType = promptForTaskType;
|
|
1355
1487
|
module.exports.previewTarget = previewTarget;
|
|
1488
|
+
module.exports.resolveBacklogCredentials = resolveBacklogCredentials;
|
|
1489
|
+
module.exports.fetchBacklogProjects = fetchBacklogProjects;
|
|
1490
|
+
module.exports.listBacklogProjectsCommand = listBacklogProjectsCommand;
|
|
1491
|
+
module.exports.setDefaultBacklogProjectCommand = setDefaultBacklogProjectCommand;
|
|
1492
|
+
module.exports.resolveJiraCredentials = resolveJiraCredentials;
|
|
1493
|
+
module.exports.fetchJiraProjects = fetchJiraProjects;
|
|
1494
|
+
module.exports.listJiraProjectsCommand = listJiraProjectsCommand;
|
|
1495
|
+
module.exports.setDefaultJiraProjectCommand = setDefaultJiraProjectCommand;
|
|
1496
|
+
module.exports.saveMcpCredentials = saveMcpCredentials;
|