ai-git-tools 2.1.7 → 2.1.9

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,6 +350,10 @@ 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
+ 執行過程會顯示目前階段,例如 Redmine 設定、Git evidence、PR 搜尋、每個 Issue 讀取、Copilot 分析與草稿產生進度。未指定 `--pr` 時,工具會依目前 branch 自動尋找對應的 GitHub PR;找不到時只省略 PR 資訊,不會影響本機 Git 分析。
354
+
355
+ notes 的 `### Git` 區塊會將 Branch 與 Base 連到對應的 GitHub branch;找到 PR 時也會顯示可點擊的 PR URL,找不到 PR 時不會顯示 PR 欄位。
356
+
353
357
  Preview 會以色彩分開顯示每個 Issue 的開發內容、程式修改重點、API 變更、重要技術細節、修改檔案與實際 note。工具會從 Redmine API 找到 `已解決` 狀態並直接套用,不顯示互動式狀態選單。
354
358
 
355
359
  原始 Issue description 會保留原始內容,供未來其它內容使用。AI 可產生多張流程圖,每張流程圖都有穩定的用途 ID,流程圖會放在 notes(公司 Redmine 已支援 notes Mermaid)。更新摘要會使用 Markdown 標題、項目符號與雙反引號標示程式路徑、API path、function 與 class。只有找到明確測試或驗證證據時才會加入驗證段落:
package/bin/cli.js CHANGED
@@ -99,7 +99,7 @@ registerCommand(program, 'redmine-update', '分析並更新 Redmine Issue 的開
99
99
  { flags: '--output <file>', description: '保存 preview 草稿 JSON' },
100
100
  { flags: '--apply', description: '套用已審核的草稿' },
101
101
  { flags: '--from <file>', description: '指定要套用的 preview 草稿 JSON' },
102
- { flags: '--force', description: '強制略過衝突與重複同步檢查' },
102
+ { flags: '--force', description: '強制略過 Issue 狀態或內容衝突檢查' },
103
103
  ], redmineUpdateCommand);
104
104
 
105
105
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-git-tools",
3
- "version": "2.1.7",
3
+ "version": "2.1.9",
4
4
  "description": "AI-powered Git automation tools for commit messages and PR generation",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -4,9 +4,14 @@
4
4
 
5
5
  import { readFileSync, writeFileSync } from 'fs';
6
6
  import { loadConfig } from '../core/config-loader.js';
7
- import { collectGitEvidence, collectPullRequestEvidence } from '../redmine/git-evidence.js';
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
- const results = await applyRedmineDraft({ draft, client, force: options.force });
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
- const pullRequest = options.pr ? collectPullRequestEvidence(options.pr) : null;
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,5 +1,4 @@
1
1
  import { analyzeIssue as defaultAnalyzeIssue } from './issue-analyzer.js';
2
- import { createHash } from 'crypto';
3
2
  import {
4
3
  formatRedmineNote,
5
4
  formatRedminePreview,
@@ -54,21 +53,6 @@ export function buildCompletionUpdate(issue, selectedStatus) {
54
53
  };
55
54
  }
56
55
 
57
- /**
58
- * 建立單一 Issue 的同步識別
59
- * @param {{issueId: number|string, evidence: object, pullRequest?: object|null}}
60
- * @returns {string}
61
- */
62
- export function createSyncId({ issueId, evidence = {}, pullRequest = null }) {
63
- const identity = pullRequest?.number
64
- ? `pr-${pullRequest.url || pullRequest.number}`
65
- : `${evidence.repository || 'local'}-${evidence.currentBranch || 'branch'}-${createHash('sha256')
66
- .update(`${evidence.commits || ''}\n${evidence.diff || ''}`)
67
- .digest('hex')
68
- .slice(0, 12)}`;
69
- return `redmine-sync:${issueId}:${identity}`;
70
- }
71
-
72
56
  /**
73
57
  * 產生一份待審核的 Redmine 更新草稿
74
58
  * @param {object} options
@@ -83,26 +67,30 @@ export async function generateRedmineDraft({
83
67
  selectStatusFn = resolveIssueStatus,
84
68
  model,
85
69
  maxRetries,
70
+ onProgress = () => {},
86
71
  }) {
87
72
  const drafts = [];
88
73
  const failures = [];
89
- for (const issueId of uniqueIssueIds(issueIds)) {
74
+ const uniqueIds = uniqueIssueIds(issueIds);
75
+ for (const [index, issueId] of uniqueIds.entries()) {
90
76
  try {
77
+ onProgress({ phase: 'read-issue', index: index + 1, total: uniqueIds.length, issueId });
91
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 });
92
81
  const analysis = await analyzeIssueFn({ issue, evidence: { ...evidence, pullRequest }, model, maxRetries });
82
+ onProgress({ phase: 'issue-analyzed', index: index + 1, total: uniqueIds.length, issueId });
93
83
  const fallbackStatuses = issue.allowedStatuses?.length ? [] : await client.getIssueStatuses();
94
84
  const selectedStatus = selectStatusFn === resolveIssueStatus
95
85
  ? resolveIssueStatus(issue, fallbackStatuses)
96
86
  : await selectStatusFn(issue, { client });
87
+ onProgress({ phase: 'issue-complete', index: index + 1, total: uniqueIds.length, issueId });
97
88
  const completion = buildCompletionUpdate(issue, selectedStatus);
98
- const syncId = createSyncId({ issueId, evidence, pullRequest });
99
89
  const note = formatRedmineNote({
100
- issueId,
101
90
  issue,
102
91
  analysis,
103
92
  evidence,
104
93
  pullRequest,
105
- syncId,
106
94
  });
107
95
 
108
96
  drafts.push({
@@ -119,7 +107,6 @@ export async function generateRedmineDraft({
119
107
  customFieldWarning: completion.warning || null,
120
108
  analysis,
121
109
  note,
122
- syncId,
123
110
  });
124
111
  } catch (error) {
125
112
  failures.push({ issueId, error: error.message });
@@ -141,15 +128,16 @@ export async function generateRedmineDraft({
141
128
  * @param {{draft: object, client: object, force?: boolean}} options
142
129
  * @returns {Promise<Array<object>>}
143
130
  */
144
- export async function applyRedmineDraft({ draft, client, force = false }) {
131
+ export async function applyRedmineDraft({ draft, client, force = false, onProgress = () => {} }) {
145
132
  if (!draft || !Array.isArray(draft.drafts)) {
146
133
  throw new Error('無效的 Redmine draft');
147
134
  }
148
135
 
149
136
  const results = [];
150
- for (const item of draft.drafts) {
137
+ for (const [index, item] of draft.drafts.entries()) {
151
138
  try {
152
- const current = await client.getIssue(item.issueId, { include: 'allowed_statuses,journals' });
139
+ onProgress({ phase: 'apply-issue', index: index + 1, total: draft.drafts.length, issueId: item.issueId });
140
+ const current = await client.getIssue(item.issueId, { include: 'allowed_statuses' });
153
141
 
154
142
  const statusChanged = item.originalStatusId !== null && current.status?.id !== item.originalStatusId;
155
143
  const descriptionChanged = current.description !== item.originalDescription;
@@ -148,10 +148,10 @@ export function formatRedmineDescription(description = '', source = null) {
148
148
 
149
149
  /**
150
150
  * 格式化 Redmine Issue note
151
- * @param {{issueId: number|string, analysis: object, evidence: object, pullRequest?: object|null, syncId?: string}} input
151
+ * @param {{issue?: object, analysis?: object, evidence?: object, pullRequest?: object|null}} input
152
152
  * @returns {string}
153
153
  */
154
- export function formatRedmineNote({ issueId, issue = {}, analysis = {}, evidence = {}, pullRequest = null, syncId }) {
154
+ export function formatRedmineNote({ issue = {}, analysis = {}, evidence = {}, pullRequest = null }) {
155
155
  const sections = ['## 開發內容', ''];
156
156
  const development = Array.isArray(analysis.developmentSummary)
157
157
  ? analysis.developmentSummary.join('\n\n')
@@ -188,19 +188,22 @@ export function formatRedmineNote({ issueId, issue = {}, analysis = {}, evidence
188
188
 
189
189
  if (evidence.currentBranch || evidence.baseBranch || pullRequest) {
190
190
  const branch = evidence.currentBranch || '—';
191
- const branchUrl = evidence.repository
191
+ const branchUrl = evidence.repository && branch !== '—'
192
192
  ? `https://github.com/${evidence.repository}/tree/${encodeURI(branch)}`
193
193
  : '';
194
+ const base = evidence.baseBranch || '—';
195
+ const baseUrl = evidence.repository && base !== '—'
196
+ ? `https://github.com/${evidence.repository}/tree/${encodeURI(base)}`
197
+ : '';
194
198
  const gitLines = [
195
199
  '### Git',
196
200
  '',
197
201
  `- Branch:${branchUrl ? `[${branch}](${branchUrl})` : `\`\`${branch}\`\``}`,
198
- `- Base:${evidence.baseBranch || '—'}`,
202
+ `- Base:${baseUrl ? `[${base}](${baseUrl})` : base}`,
199
203
  ];
200
204
  if (pullRequest) {
201
205
  gitLines.push(
202
- `- PR:${pullRequest.url ? `[${pullRequest.url}](${pullRequest.url})` : `#${pullRequest.number}`}`,
203
- `- PR 狀態:${pullRequest.state || '—'}${pullRequest.mergedAt ? `(合併於 ${pullRequest.mergedAt})` : ''}`
206
+ `- PR:${pullRequest.url ? `[${pullRequest.url}](${pullRequest.url})` : `#${pullRequest.number}`}`
204
207
  );
205
208
  }
206
209
  sections.push(
@@ -213,7 +216,6 @@ export function formatRedmineNote({ issueId, issue = {}, analysis = {}, evidence
213
216
  sections.push(`分析依據:${analysis.evidence.join('、')}`, '');
214
217
  }
215
218
 
216
- if (syncId) sections.push(`<!-- ${syncId} issue:${issueId} -->`);
217
219
  return sections.filter((section, index) => section || index === 1).join('\n').trim();
218
220
  }
219
221