ai-git-tools 2.1.6 → 2.1.8
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/README.md
CHANGED
|
@@ -350,9 +350,13 @@ ai-git-tools redmine-update --apply --from redmine-update.json
|
|
|
350
350
|
|
|
351
351
|
`--pr <number>` 是可選的補充 evidence,會加入 PR title、body、URL、merge state、commits 與 changed files;沒有 PR 仍可完整分析本機 Git diff。沒有 PR 時,工具不會自動推論已 merge、已部署或必須關閉 Issue。
|
|
352
352
|
|
|
353
|
-
|
|
353
|
+
執行過程會顯示目前階段,例如 Redmine 設定、Git evidence、PR 搜尋、每個 Issue 讀取、Copilot 分析與草稿產生進度。未指定 `--pr` 時,工具會依目前 branch 自動尋找對應的 GitHub PR;找不到時只省略 PR 資訊,不會影響本機 Git 分析。
|
|
354
354
|
|
|
355
|
-
|
|
355
|
+
notes 的 `### Git` 區塊會將 Branch 與 Base 連到對應的 GitHub branch;找到 PR 時也會顯示可點擊的 PR URL,找不到 PR 時不會顯示 PR 欄位。
|
|
356
|
+
|
|
357
|
+
Preview 會以色彩分開顯示每個 Issue 的開發內容、程式修改重點、API 變更、重要技術細節、修改檔案與實際 note。工具會從 Redmine API 找到 `已解決` 狀態並直接套用,不顯示互動式狀態選單。
|
|
358
|
+
|
|
359
|
+
原始 Issue description 會保留原始內容,供未來其它內容使用。AI 可產生多張流程圖,每張流程圖都有穩定的用途 ID,流程圖會放在 notes(公司 Redmine 已支援 notes Mermaid)。更新摘要會使用 Markdown 標題、項目符號與雙反引號標示程式路徑、API path、function 與 class。只有找到明確測試或驗證證據時才會加入驗證段落:
|
|
356
360
|
|
|
357
361
|
```text
|
|
358
362
|
{{mermaid
|
|
@@ -362,9 +366,9 @@ flowchart TD
|
|
|
362
366
|
}}
|
|
363
367
|
```
|
|
364
368
|
|
|
365
|
-
Issue
|
|
369
|
+
Issue 的基本責任分工如下:`description` 保存原始需求與未來其它內容,`status` 保存目前狀態,`notes` 保存 Git 開發紀錄與 Mermaid 流程圖。
|
|
366
370
|
|
|
367
|
-
當 preview
|
|
371
|
+
當 preview 套用時,工具會直接將狀態設為 `已解決`,並同步將完成百分比設為 `100%`、完成日期設為執行當天,依欄位名稱找到 `程式碼更版進度` custom field,勾選測試機選項 `測`。custom field ID 會從 Issue API 動態取得,不會寫死公司 Redmine 的 ID。
|
|
368
372
|
|
|
369
373
|
同一個 Issue 若已經有先前的 notes,後續再次執行更新仍會新增一筆新的 Redmine journal,不會因為已有 notes 而略過;只有 Issue 的需求內容或狀態在產生草稿後被其他人修改時,才會停下來要求重新確認。
|
|
370
374
|
|
package/package.json
CHANGED
|
@@ -4,9 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
import { readFileSync, writeFileSync } from 'fs';
|
|
6
6
|
import { loadConfig } from '../core/config-loader.js';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
collectGitEvidence,
|
|
9
|
+
collectPullRequestEvidence,
|
|
10
|
+
findCurrentPullRequestEvidence,
|
|
11
|
+
} from '../redmine/git-evidence.js';
|
|
8
12
|
import { buildRedmineConfig } from '../redmine/config.js';
|
|
9
13
|
import { RedmineClient } from '../redmine/redmine-client.js';
|
|
14
|
+
import { Logger } from '../utils/logger.js';
|
|
10
15
|
import {
|
|
11
16
|
applyRedmineDraft,
|
|
12
17
|
generateRedmineDraft,
|
|
@@ -15,6 +20,37 @@ import {
|
|
|
15
20
|
} from '../redmine/issue-sync.js';
|
|
16
21
|
import { formatRedminePreview as renderPreview } from '../redmine/redmine-formatters.js';
|
|
17
22
|
|
|
23
|
+
const logger = new Logger();
|
|
24
|
+
|
|
25
|
+
function countLines(value) {
|
|
26
|
+
return String(value || '').split('\n').filter(Boolean).length;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function printProgress(progress) {
|
|
30
|
+
switch (progress.phase) {
|
|
31
|
+
case 'read-issue':
|
|
32
|
+
logger.step(`[${progress.index}/${progress.total}] 讀取 Redmine Issue #${progress.issueId}...`);
|
|
33
|
+
break;
|
|
34
|
+
case 'issue-read':
|
|
35
|
+
logger.success(`Issue #${progress.issueId} 已取得:${progress.subject || '無標題'}`);
|
|
36
|
+
break;
|
|
37
|
+
case 'analyze-issue':
|
|
38
|
+
logger.step(`[${progress.index}/${progress.total}] 使用 Copilot 分析 Issue #${progress.issueId}...`);
|
|
39
|
+
break;
|
|
40
|
+
case 'issue-analyzed':
|
|
41
|
+
logger.success(`Issue #${progress.issueId} AI 分析完成`);
|
|
42
|
+
break;
|
|
43
|
+
case 'issue-complete':
|
|
44
|
+
logger.success(`Issue #${progress.issueId} 更新草稿完成`);
|
|
45
|
+
break;
|
|
46
|
+
case 'apply-issue':
|
|
47
|
+
logger.step(`[${progress.index}/${progress.total}] 更新 Redmine Issue #${progress.issueId}...`);
|
|
48
|
+
break;
|
|
49
|
+
default:
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
18
54
|
function printApplyResults(results) {
|
|
19
55
|
for (const result of results) {
|
|
20
56
|
if (result.applied) {
|
|
@@ -82,13 +118,23 @@ export function validateGeneratedDraft(draft = {}) {
|
|
|
82
118
|
*/
|
|
83
119
|
export async function redmineUpdateCommand(options = {}) {
|
|
84
120
|
validateRedmineOptions(options);
|
|
121
|
+
logger.header('Redmine Issue 更新分析');
|
|
85
122
|
const config = await loadConfig();
|
|
123
|
+
logger.step('檢查 Redmine 設定...');
|
|
86
124
|
const redmineConfig = buildRedmineConfig(config, process.env);
|
|
87
125
|
const client = new RedmineClient(redmineConfig);
|
|
126
|
+
logger.success('Redmine 設定已載入');
|
|
88
127
|
|
|
89
128
|
if (options.apply) {
|
|
129
|
+
logger.step(`讀取已審核草稿:${options.from}`);
|
|
90
130
|
const draft = parseDraft(readFileSync(options.from, 'utf-8'));
|
|
91
|
-
|
|
131
|
+
logger.success(`草稿已載入,共 ${draft.drafts.length} 個 Issue`);
|
|
132
|
+
const results = await applyRedmineDraft({
|
|
133
|
+
draft,
|
|
134
|
+
client,
|
|
135
|
+
force: options.force,
|
|
136
|
+
onProgress: printProgress,
|
|
137
|
+
});
|
|
92
138
|
printApplyResults(results);
|
|
93
139
|
if (hasApplyFailures(results)) {
|
|
94
140
|
throw new Error('部分 Redmine Issue 更新失敗,請查看上方結果並重新處理失敗項目');
|
|
@@ -96,8 +142,31 @@ export async function redmineUpdateCommand(options = {}) {
|
|
|
96
142
|
return results;
|
|
97
143
|
}
|
|
98
144
|
|
|
145
|
+
logger.step('收集 Git branch、Base、commits、修改檔案與 diff...');
|
|
99
146
|
const evidence = collectGitEvidence({ baseBranch: options.base });
|
|
100
|
-
|
|
147
|
+
logger.success(
|
|
148
|
+
`Git evidence 已取得:${countLines(evidence.commits)} 個 commits、${evidence.changedFiles.length} 個修改檔案;Base:${evidence.baseBranch}`
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
let pullRequest = null;
|
|
152
|
+
if (options.pr) {
|
|
153
|
+
logger.step(`讀取指定 GitHub PR #${options.pr}...`);
|
|
154
|
+
pullRequest = collectPullRequestEvidence(options.pr);
|
|
155
|
+
} else {
|
|
156
|
+
logger.step(`尋找 branch ${evidence.currentBranch} 對應的 GitHub PR...`);
|
|
157
|
+
try {
|
|
158
|
+
pullRequest = findCurrentPullRequestEvidence(evidence.currentBranch);
|
|
159
|
+
} catch {
|
|
160
|
+
pullRequest = null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (pullRequest) {
|
|
164
|
+
logger.success(`已找到 PR #${pullRequest.number}:${pullRequest.title || '無標題'}`);
|
|
165
|
+
console.log(`🔗 ${pullRequest.url}`);
|
|
166
|
+
} else {
|
|
167
|
+
logger.info('目前 branch 沒有找到對應的 GitHub PR,略過 PR 資訊');
|
|
168
|
+
}
|
|
169
|
+
|
|
101
170
|
const draft = await generateRedmineDraft({
|
|
102
171
|
issueIds: options.issue,
|
|
103
172
|
client,
|
|
@@ -105,9 +174,11 @@ export async function redmineUpdateCommand(options = {}) {
|
|
|
105
174
|
pullRequest,
|
|
106
175
|
model: config.ai.model,
|
|
107
176
|
maxRetries: config.ai.maxRetries,
|
|
177
|
+
onProgress: printProgress,
|
|
108
178
|
});
|
|
109
179
|
|
|
110
180
|
validateGeneratedDraft(draft);
|
|
181
|
+
logger.success(`已完成 ${draft.drafts.length} 個 Issue 的更新草稿分析`);
|
|
111
182
|
console.log(renderPreview(draft.drafts));
|
|
112
183
|
for (const failure of draft.failures) {
|
|
113
184
|
console.log(`❌ Issue #${failure.issueId} 無法產生更新:${failure.error}`);
|
|
@@ -184,3 +184,42 @@ export function collectPullRequestEvidence(pullRequestNumber, execImpl = execFil
|
|
|
184
184
|
changedFiles: pullRequest.files || [],
|
|
185
185
|
};
|
|
186
186
|
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 依目前 branch 找到對應的 GitHub Pull Request
|
|
190
|
+
* @param {string} branchName
|
|
191
|
+
* @param {Function} execImpl
|
|
192
|
+
* @returns {object|null}
|
|
193
|
+
*/
|
|
194
|
+
export function findCurrentPullRequestEvidence(branchName, execImpl = execFileSync) {
|
|
195
|
+
const raw = run(
|
|
196
|
+
'gh',
|
|
197
|
+
[
|
|
198
|
+
'pr',
|
|
199
|
+
'list',
|
|
200
|
+
'--head',
|
|
201
|
+
branchName,
|
|
202
|
+
'--state',
|
|
203
|
+
'all',
|
|
204
|
+
'--limit',
|
|
205
|
+
'1',
|
|
206
|
+
'--json',
|
|
207
|
+
'number,title,body,url,state,mergedAt,commits,files',
|
|
208
|
+
],
|
|
209
|
+
execImpl
|
|
210
|
+
);
|
|
211
|
+
const pullRequests = JSON.parse(raw);
|
|
212
|
+
if (!Array.isArray(pullRequests) || pullRequests.length === 0) return null;
|
|
213
|
+
|
|
214
|
+
const pullRequest = pullRequests[0];
|
|
215
|
+
return {
|
|
216
|
+
number: Number(pullRequest.number),
|
|
217
|
+
title: pullRequest.title || '',
|
|
218
|
+
body: pullRequest.body || '',
|
|
219
|
+
url: pullRequest.url || '',
|
|
220
|
+
state: pullRequest.state || '',
|
|
221
|
+
mergedAt: pullRequest.mergedAt || null,
|
|
222
|
+
commits: pullRequest.commits || [],
|
|
223
|
+
changedFiles: pullRequest.files || [],
|
|
224
|
+
};
|
|
225
|
+
}
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import { analyzeIssue as defaultAnalyzeIssue } from './issue-analyzer.js';
|
|
2
|
-
import { createHash } from 'crypto';
|
|
3
2
|
import {
|
|
4
3
|
formatRedmineNote,
|
|
5
|
-
formatRedmineDescription,
|
|
6
4
|
formatRedminePreview,
|
|
7
|
-
|
|
5
|
+
resolveIssueStatus,
|
|
8
6
|
} from './redmine-formatters.js';
|
|
9
7
|
|
|
10
8
|
function uniqueIssueIds(issueIds = []) {
|
|
11
9
|
return [...new Set(issueIds.map(id => Number(id)).filter(Number.isInteger))];
|
|
12
10
|
}
|
|
13
11
|
|
|
12
|
+
function getCurrentDate() {
|
|
13
|
+
const now = new Date();
|
|
14
|
+
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
15
|
+
const day = String(now.getDate()).padStart(2, '0');
|
|
16
|
+
return `${now.getFullYear()}-${month}-${day}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
14
19
|
/**
|
|
15
20
|
* 判斷 Redmine 狀態是否代表已解決
|
|
16
21
|
* @param {string} statusName
|
|
@@ -35,6 +40,7 @@ export function buildCompletionUpdate(issue, selectedStatus) {
|
|
|
35
40
|
if (!progressField) {
|
|
36
41
|
return {
|
|
37
42
|
doneRatio: 100,
|
|
43
|
+
dueDate: getCurrentDate(),
|
|
38
44
|
customFields: [],
|
|
39
45
|
warning: '找不到 custom field「程式碼更版進度」,已略過 checkbox 更新',
|
|
40
46
|
};
|
|
@@ -42,25 +48,11 @@ export function buildCompletionUpdate(issue, selectedStatus) {
|
|
|
42
48
|
|
|
43
49
|
return {
|
|
44
50
|
doneRatio: 100,
|
|
51
|
+
dueDate: getCurrentDate(),
|
|
45
52
|
customFields: [{ id: progressField.id, value: ['測'] }],
|
|
46
53
|
};
|
|
47
54
|
}
|
|
48
55
|
|
|
49
|
-
/**
|
|
50
|
-
* 建立單一 Issue 的同步識別
|
|
51
|
-
* @param {{issueId: number|string, evidence: object, pullRequest?: object|null}}
|
|
52
|
-
* @returns {string}
|
|
53
|
-
*/
|
|
54
|
-
export function createSyncId({ issueId, evidence = {}, pullRequest = null }) {
|
|
55
|
-
const identity = pullRequest?.number
|
|
56
|
-
? `pr-${pullRequest.url || pullRequest.number}`
|
|
57
|
-
: `${evidence.repository || 'local'}-${evidence.currentBranch || 'branch'}-${createHash('sha256')
|
|
58
|
-
.update(`${evidence.commits || ''}\n${evidence.diff || ''}`)
|
|
59
|
-
.digest('hex')
|
|
60
|
-
.slice(0, 12)}`;
|
|
61
|
-
return `redmine-sync:${issueId}:${identity}`;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
56
|
/**
|
|
65
57
|
* 產生一份待審核的 Redmine 更新草稿
|
|
66
58
|
* @param {object} options
|
|
@@ -72,27 +64,33 @@ export async function generateRedmineDraft({
|
|
|
72
64
|
evidence,
|
|
73
65
|
pullRequest = null,
|
|
74
66
|
analyzeIssueFn = defaultAnalyzeIssue,
|
|
75
|
-
selectStatusFn =
|
|
67
|
+
selectStatusFn = resolveIssueStatus,
|
|
76
68
|
model,
|
|
77
69
|
maxRetries,
|
|
70
|
+
onProgress = () => {},
|
|
78
71
|
}) {
|
|
79
72
|
const drafts = [];
|
|
80
73
|
const failures = [];
|
|
81
|
-
|
|
74
|
+
const uniqueIds = uniqueIssueIds(issueIds);
|
|
75
|
+
for (const [index, issueId] of uniqueIds.entries()) {
|
|
82
76
|
try {
|
|
77
|
+
onProgress({ phase: 'read-issue', index: index + 1, total: uniqueIds.length, issueId });
|
|
83
78
|
const issue = await client.getIssue(issueId);
|
|
79
|
+
onProgress({ phase: 'issue-read', index: index + 1, total: uniqueIds.length, issueId, subject: issue.subject });
|
|
80
|
+
onProgress({ phase: 'analyze-issue', index: index + 1, total: uniqueIds.length, issueId });
|
|
84
81
|
const analysis = await analyzeIssueFn({ issue, evidence: { ...evidence, pullRequest }, model, maxRetries });
|
|
85
|
-
|
|
82
|
+
onProgress({ phase: 'issue-analyzed', index: index + 1, total: uniqueIds.length, issueId });
|
|
83
|
+
const fallbackStatuses = issue.allowedStatuses?.length ? [] : await client.getIssueStatuses();
|
|
84
|
+
const selectedStatus = selectStatusFn === resolveIssueStatus
|
|
85
|
+
? resolveIssueStatus(issue, fallbackStatuses)
|
|
86
|
+
: await selectStatusFn(issue, { client });
|
|
87
|
+
onProgress({ phase: 'issue-complete', index: index + 1, total: uniqueIds.length, issueId });
|
|
86
88
|
const completion = buildCompletionUpdate(issue, selectedStatus);
|
|
87
|
-
const description = formatRedmineDescription(issue.description, analysis.flowcharts);
|
|
88
|
-
const syncId = createSyncId({ issueId, evidence, pullRequest });
|
|
89
89
|
const note = formatRedmineNote({
|
|
90
|
-
issueId,
|
|
91
90
|
issue,
|
|
92
91
|
analysis,
|
|
93
92
|
evidence,
|
|
94
93
|
pullRequest,
|
|
95
|
-
syncId,
|
|
96
94
|
});
|
|
97
95
|
|
|
98
96
|
drafts.push({
|
|
@@ -104,12 +102,11 @@ export async function generateRedmineDraft({
|
|
|
104
102
|
statusId: selectedStatus.statusId,
|
|
105
103
|
statusName: selectedStatus.statusName,
|
|
106
104
|
doneRatio: completion.doneRatio,
|
|
105
|
+
dueDate: completion.dueDate,
|
|
107
106
|
customFields: completion.customFields,
|
|
108
107
|
customFieldWarning: completion.warning || null,
|
|
109
|
-
description: description !== issue.description ? description : undefined,
|
|
110
108
|
analysis,
|
|
111
109
|
note,
|
|
112
|
-
syncId,
|
|
113
110
|
});
|
|
114
111
|
} catch (error) {
|
|
115
112
|
failures.push({ issueId, error: error.message });
|
|
@@ -131,14 +128,15 @@ export async function generateRedmineDraft({
|
|
|
131
128
|
* @param {{draft: object, client: object, force?: boolean}} options
|
|
132
129
|
* @returns {Promise<Array<object>>}
|
|
133
130
|
*/
|
|
134
|
-
export async function applyRedmineDraft({ draft, client, force = false }) {
|
|
131
|
+
export async function applyRedmineDraft({ draft, client, force = false, onProgress = () => {} }) {
|
|
135
132
|
if (!draft || !Array.isArray(draft.drafts)) {
|
|
136
133
|
throw new Error('無效的 Redmine draft');
|
|
137
134
|
}
|
|
138
135
|
|
|
139
136
|
const results = [];
|
|
140
|
-
for (const item of draft.drafts) {
|
|
137
|
+
for (const [index, item] of draft.drafts.entries()) {
|
|
141
138
|
try {
|
|
139
|
+
onProgress({ phase: 'apply-issue', index: index + 1, total: draft.drafts.length, issueId: item.issueId });
|
|
142
140
|
const current = await client.getIssue(item.issueId, { include: 'allowed_statuses,journals' });
|
|
143
141
|
|
|
144
142
|
const statusChanged = item.originalStatusId !== null && current.status?.id !== item.originalStatusId;
|
|
@@ -150,6 +148,7 @@ export async function applyRedmineDraft({ draft, client, force = false }) {
|
|
|
150
148
|
|
|
151
149
|
const update = { statusId: item.statusId, notes: item.note };
|
|
152
150
|
if (item.doneRatio !== undefined) update.doneRatio = item.doneRatio;
|
|
151
|
+
if (item.dueDate !== undefined) update.dueDate = item.dueDate;
|
|
153
152
|
if (item.description !== undefined) update.description = item.description;
|
|
154
153
|
if (item.customFields?.length) update.customFields = item.customFields;
|
|
155
154
|
|
|
@@ -131,13 +131,14 @@ export class RedmineClient {
|
|
|
131
131
|
|
|
132
132
|
/**
|
|
133
133
|
* @param {number|string} issueId
|
|
134
|
-
* @param {{statusId?: number, doneRatio?: number, description?: string, notes?: string, customFields?: Array<object>}} update
|
|
134
|
+
* @param {{statusId?: number, doneRatio?: number, dueDate?: string, description?: string, notes?: string, customFields?: Array<object>}} update
|
|
135
135
|
* @returns {Promise<object>}
|
|
136
136
|
*/
|
|
137
|
-
async updateIssue(issueId, { statusId, doneRatio, description, notes, customFields }) {
|
|
137
|
+
async updateIssue(issueId, { statusId, doneRatio, dueDate, description, notes, customFields }) {
|
|
138
138
|
const issue = {};
|
|
139
139
|
if (statusId !== undefined && statusId !== null) issue.status_id = statusId;
|
|
140
140
|
if (doneRatio !== undefined && doneRatio !== null) issue.done_ratio = doneRatio;
|
|
141
|
+
if (dueDate !== undefined && dueDate !== null) issue.due_date = dueDate;
|
|
141
142
|
if (description !== undefined) issue.description = description;
|
|
142
143
|
if (notes) issue.notes = notes;
|
|
143
144
|
if (Array.isArray(customFields) && customFields.length > 0) {
|
|
@@ -20,6 +20,21 @@ function formatBehaviorRules(rules = []) {
|
|
|
20
20
|
return `### 行為規則\n\n| 情境 | 行為 |\n|---|---|\n${rows.join('\n')}\n`;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
function formatFlowcharts(flowcharts = []) {
|
|
24
|
+
if (!Array.isArray(flowcharts) || flowcharts.length === 0) return '';
|
|
25
|
+
|
|
26
|
+
return flowcharts
|
|
27
|
+
.map((flowchart, index) => {
|
|
28
|
+
const mermaid = formatMermaid(flowchart?.source);
|
|
29
|
+
if (!mermaid) return '';
|
|
30
|
+
const id = normalizeFlowchartId(flowchart?.id, index);
|
|
31
|
+
const title = flowchart?.title?.trim() || '開發流程';
|
|
32
|
+
return `### 流程圖:${title}\n\n<!-- flowchart-id: ${id} -->\n${mermaid}\n`;
|
|
33
|
+
})
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.join('\n');
|
|
36
|
+
}
|
|
37
|
+
|
|
23
38
|
const MERMAID_START = '<!-- ai-git-tools:mermaid:start';
|
|
24
39
|
const MERMAID_END = '<!-- ai-git-tools:mermaid:end';
|
|
25
40
|
|
|
@@ -136,7 +151,7 @@ export function formatRedmineDescription(description = '', source = null) {
|
|
|
136
151
|
* @param {{issueId: number|string, analysis: object, evidence: object, pullRequest?: object|null, syncId?: string}} input
|
|
137
152
|
* @returns {string}
|
|
138
153
|
*/
|
|
139
|
-
export function formatRedmineNote({
|
|
154
|
+
export function formatRedmineNote({ issue = {}, analysis = {}, evidence = {}, pullRequest = null }) {
|
|
140
155
|
const sections = ['## 開發內容', ''];
|
|
141
156
|
const development = Array.isArray(analysis.developmentSummary)
|
|
142
157
|
? analysis.developmentSummary.join('\n\n')
|
|
@@ -156,6 +171,7 @@ export function formatRedmineNote({ issueId, issue = {}, analysis = {}, evidence
|
|
|
156
171
|
const unresolved = analysis.unresolvedItems?.length
|
|
157
172
|
? `### 待確認\n${analysis.unresolvedItems.map(item => `- [ ] ${item}`).join('\n')}\n`
|
|
158
173
|
: '';
|
|
174
|
+
const flowcharts = formatFlowcharts(analysis.flowcharts);
|
|
159
175
|
|
|
160
176
|
sections.push(
|
|
161
177
|
development,
|
|
@@ -165,22 +181,30 @@ export function formatRedmineNote({ issueId, issue = {}, analysis = {}, evidence
|
|
|
165
181
|
codeChanges,
|
|
166
182
|
apiChanges,
|
|
167
183
|
technicalDetails,
|
|
184
|
+
flowcharts,
|
|
168
185
|
verification
|
|
169
186
|
);
|
|
170
187
|
sections.push(unresolved);
|
|
171
188
|
|
|
172
|
-
if (evidence.currentBranch || evidence.baseBranch) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
''
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
189
|
+
if (evidence.currentBranch || evidence.baseBranch || pullRequest) {
|
|
190
|
+
const branch = evidence.currentBranch || '—';
|
|
191
|
+
const branchUrl = evidence.repository
|
|
192
|
+
? `https://github.com/${evidence.repository}/tree/${encodeURI(branch)}`
|
|
193
|
+
: '';
|
|
194
|
+
const gitLines = [
|
|
195
|
+
'### Git',
|
|
196
|
+
'',
|
|
197
|
+
`- Branch:${branchUrl ? `[${branch}](${branchUrl})` : `\`\`${branch}\`\``}`,
|
|
198
|
+
`- Base:${branchUrl ? `[${evidence.baseBranch || '—'}](https://github.com/${evidence.repository}/tree/${encodeURI(evidence.baseBranch || '')})` : evidence.baseBranch || '—'}`,
|
|
199
|
+
];
|
|
200
|
+
if (pullRequest) {
|
|
201
|
+
gitLines.push(
|
|
202
|
+
`- PR:${pullRequest.url ? `[${pullRequest.url}](${pullRequest.url})` : `#${pullRequest.number}`}`,
|
|
203
|
+
`- PR 狀態:${pullRequest.state || '—'}${pullRequest.mergedAt ? `(合併於 ${pullRequest.mergedAt})` : ''}`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
181
206
|
sections.push(
|
|
182
|
-
|
|
183
|
-
`PR 狀態:${pullRequest.state || '—'}${pullRequest.mergedAt ? `(合併於 ${pullRequest.mergedAt})` : ''}`,
|
|
207
|
+
gitLines.join('\n'),
|
|
184
208
|
''
|
|
185
209
|
);
|
|
186
210
|
}
|
|
@@ -189,7 +213,6 @@ export function formatRedmineNote({ issueId, issue = {}, analysis = {}, evidence
|
|
|
189
213
|
sections.push(`分析依據:${analysis.evidence.join('、')}`, '');
|
|
190
214
|
}
|
|
191
215
|
|
|
192
|
-
if (syncId) sections.push(`<!-- ${syncId} issue:${issueId} -->`);
|
|
193
216
|
return sections.filter((section, index) => section || index === 1).join('\n').trim();
|
|
194
217
|
}
|
|
195
218
|
|
|
@@ -240,6 +263,19 @@ export async function selectIssueStatus(issue, { client, prompt = inquirer.promp
|
|
|
240
263
|
return { statusId: answer.statusId, statusName: selected?.name || String(answer.statusId) };
|
|
241
264
|
}
|
|
242
265
|
|
|
266
|
+
/**
|
|
267
|
+
* 直接取得已解決狀態,不啟動互動式選單
|
|
268
|
+
* @param {object} issue
|
|
269
|
+
* @param {Array<object>} fallbackStatuses
|
|
270
|
+
* @returns {{statusId: number, statusName: string}}
|
|
271
|
+
*/
|
|
272
|
+
export function resolveIssueStatus(issue, fallbackStatuses = []) {
|
|
273
|
+
const statuses = getAvailableStatuses(issue, fallbackStatuses);
|
|
274
|
+
const resolved = statuses.find(status => /已解決|resolved/i.test(status.name || ''));
|
|
275
|
+
if (!resolved) throw new Error(`Issue #${issue.id} 找不到「已解決」狀態`);
|
|
276
|
+
return { statusId: resolved.id, statusName: resolved.name };
|
|
277
|
+
}
|
|
278
|
+
|
|
243
279
|
/**
|
|
244
280
|
* 產生有色 terminal preview
|
|
245
281
|
* @param {Array<object>} drafts
|
|
@@ -254,6 +290,7 @@ export function formatRedminePreview(drafts = []) {
|
|
|
254
290
|
`${chalk.dim('目前狀態:')} ${draft.currentStatusName || draft.originalStatusName || '—'}`,
|
|
255
291
|
`${chalk.green('更新狀態:')} ${draft.statusName || '未選擇'}`,
|
|
256
292
|
draft.doneRatio !== undefined ? `${chalk.green('完成百分比:')} ${draft.doneRatio}%` : '',
|
|
293
|
+
draft.dueDate ? `${chalk.green('完成日期:')} ${draft.dueDate}` : '',
|
|
257
294
|
draft.customFields?.length
|
|
258
295
|
? `${chalk.green('程式碼更版進度:')} ${draft.customFields.flatMap(field => field.value || []).join('、')}`
|
|
259
296
|
: '',
|