ai-git-tools 2.1.14 → 2.1.16
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 +31 -10
- package/bin/cli.js +9 -2
- package/package.json +1 -1
- package/src/commands/init.js +1 -1
- package/src/commands/redmine-subtasks.js +35 -5
- package/src/core/ai-client.js +2 -8
- package/src/core/config-loader.js +2 -2
- package/src/pr-modules/ai/code-analyzer.js +1 -1
- package/src/redmine/issue-analyzer.js +1 -1
- package/src/redmine/redmine-client.js +3 -2
- package/src/redmine/subtask-analyzer.js +767 -133
- package/src/redmine/subtask-formatters.js +346 -3
- package/src/redmine/subtask-sync.js +211 -29
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
analyzeSubtasks as defaultAnalyzeSubtasks,
|
|
3
|
+
MIN_MAX_SUBTASKS,
|
|
4
|
+
MAX_MAX_SUBTASKS,
|
|
5
|
+
normalizeFrontendAnalysis,
|
|
6
|
+
validateFrontendAnalysis,
|
|
7
|
+
} from './subtask-analyzer.js';
|
|
2
8
|
import {
|
|
3
9
|
formatSubtaskContent,
|
|
4
10
|
getManagedSubtaskKey,
|
|
@@ -17,10 +23,33 @@ function sameValue(left, right) {
|
|
|
17
23
|
return (left ?? null) === (right ?? null);
|
|
18
24
|
}
|
|
19
25
|
|
|
26
|
+
function normalizeRelations(relations = []) {
|
|
27
|
+
return (Array.isArray(relations) ? relations : [])
|
|
28
|
+
.map(relation => ({
|
|
29
|
+
id: relation?.id ?? null,
|
|
30
|
+
issueId: relation?.issue_id ?? relation?.issueId ?? null,
|
|
31
|
+
issueToId: relation?.issue_to_id ?? relation?.issueToId ?? null,
|
|
32
|
+
relationType: relation?.relation_type ?? relation?.relationType ?? null,
|
|
33
|
+
}))
|
|
34
|
+
.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
|
|
35
|
+
}
|
|
36
|
+
|
|
20
37
|
function getParentSnapshot(parent) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
38
|
+
return {
|
|
39
|
+
id: parent.id,
|
|
40
|
+
subject: parent.subject || '',
|
|
41
|
+
description: parent.description || '',
|
|
42
|
+
projectId: getProjectId(parent),
|
|
43
|
+
trackerId: getTrackerId(parent),
|
|
44
|
+
children: Array.isArray(parent.children)
|
|
45
|
+
? parent.children.map(child => ({
|
|
46
|
+
id: child.id,
|
|
47
|
+
subject: child.subject || '',
|
|
48
|
+
description: child.description || '',
|
|
49
|
+
}))
|
|
50
|
+
: [],
|
|
51
|
+
relations: normalizeRelations(parent.relations),
|
|
52
|
+
};
|
|
24
53
|
}
|
|
25
54
|
|
|
26
55
|
function assertParentMetadata(parent) {
|
|
@@ -34,11 +63,26 @@ function sanitizeError(error, client) {
|
|
|
34
63
|
return secret ? String(error?.message || error).replaceAll(secret, '[REDACTED]') : String(error?.message || error);
|
|
35
64
|
}
|
|
36
65
|
|
|
66
|
+
function assertDraftDoesNotContainPrivateFields(value, path = 'draft') {
|
|
67
|
+
if (!value || typeof value !== 'object') return;
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
value.forEach((item, index) => assertDraftDoesNotContainPrivateFields(item, `${path}[${index}]`));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
for (const [key, item] of Object.entries(value)) {
|
|
73
|
+
if (/raw|secret|token|password|private|credential|api[-_]?key/i.test(key)) {
|
|
74
|
+
throw new Error(`Frontend 子任務 draft 含有禁止保存的欄位:${path}.${key}`);
|
|
75
|
+
}
|
|
76
|
+
assertDraftDoesNotContainPrivateFields(item, `${path}.${key}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
37
80
|
function hasParentConflict(expected, current) {
|
|
38
81
|
return expected.subject !== current.subject
|
|
39
82
|
|| expected.description !== current.description
|
|
40
83
|
|| !sameValue(getProjectId(expected), getProjectId(current))
|
|
41
|
-
|| !sameValue(getTrackerId(expected), getTrackerId(current))
|
|
84
|
+
|| !sameValue(getTrackerId(expected), getTrackerId(current))
|
|
85
|
+
|| JSON.stringify(normalizeRelations(expected.relations)) !== JSON.stringify(normalizeRelations(current.relations));
|
|
42
86
|
}
|
|
43
87
|
|
|
44
88
|
function getExistingChildTitles(children = []) {
|
|
@@ -49,17 +93,39 @@ function getExistingManagedKeys(children = []) {
|
|
|
49
93
|
return new Set(children.map(child => getManagedSubtaskKey(child.description)).filter(Boolean));
|
|
50
94
|
}
|
|
51
95
|
|
|
96
|
+
function getReplaceableChildren(parent, children, parentIssueId) {
|
|
97
|
+
const childrenById = new Map(children.map(child => [String(child.id), child]));
|
|
98
|
+
const managedPrefix = buildManagedSubtaskKey(parentIssueId, '');
|
|
99
|
+
return (Array.isArray(parent?.children) ? parent.children : [])
|
|
100
|
+
.map(child => childrenById.get(String(child?.id)))
|
|
101
|
+
.filter(child => getManagedSubtaskKey(child?.description)?.startsWith(managedPrefix));
|
|
102
|
+
}
|
|
103
|
+
|
|
52
104
|
async function loadChildDetails(client, children = []) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
105
|
+
const unavailableChildIds = [];
|
|
106
|
+
if (typeof client?.getIssue !== 'function') {
|
|
107
|
+
return {
|
|
108
|
+
children,
|
|
109
|
+
unavailableChildIds: children
|
|
110
|
+
.filter(child => !child.description)
|
|
111
|
+
.map(child => child.id ?? 'unknown'),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const detailedChildren = await Promise.all(children.map(async child => {
|
|
115
|
+
if (child.description) return child;
|
|
116
|
+
if (child.id === undefined || child.id === null) {
|
|
117
|
+
unavailableChildIds.push('unknown');
|
|
118
|
+
return child;
|
|
119
|
+
}
|
|
56
120
|
try {
|
|
57
|
-
const detail = await client.getIssue(child.id
|
|
121
|
+
const detail = await client.getIssue(child.id);
|
|
58
122
|
return { ...child, description: detail.description || '' };
|
|
59
123
|
} catch {
|
|
124
|
+
unavailableChildIds.push(child.id);
|
|
60
125
|
return child;
|
|
61
126
|
}
|
|
62
127
|
}));
|
|
128
|
+
return { children: detailedChildren, unavailableChildIds };
|
|
63
129
|
}
|
|
64
130
|
|
|
65
131
|
/**
|
|
@@ -72,6 +138,7 @@ export async function generateSubtaskDraft({
|
|
|
72
138
|
client,
|
|
73
139
|
analyzeSubtasksFn = defaultAnalyzeSubtasks,
|
|
74
140
|
maxSubtasks = 8,
|
|
141
|
+
repositoryContext = {},
|
|
75
142
|
model,
|
|
76
143
|
maxRetries,
|
|
77
144
|
onProgress = () => {},
|
|
@@ -80,36 +147,74 @@ export async function generateSubtaskDraft({
|
|
|
80
147
|
const parent = await client.getIssue(parentIssueId, { include: 'children,relations' });
|
|
81
148
|
assertParentMetadata(parent);
|
|
82
149
|
onProgress({ phase: 'parent-read', parentIssueId, subject: parent.subject });
|
|
150
|
+
onProgress({ phase: 'scope-analysis', parentIssueId });
|
|
83
151
|
onProgress({ phase: 'analyze-subtasks', parentIssueId });
|
|
84
152
|
const analysis = await analyzeSubtasksFn({
|
|
85
153
|
issue: parent,
|
|
86
154
|
maxSubtasks,
|
|
155
|
+
repositoryContext,
|
|
87
156
|
model,
|
|
88
157
|
maxRetries,
|
|
89
158
|
});
|
|
159
|
+
if (analysis.schemaVersion !== 2) {
|
|
160
|
+
throw new Error('Frontend 子任務分析必須回傳 schemaVersion 2,請重新產生 preview');
|
|
161
|
+
}
|
|
162
|
+
validateFrontendAnalysis(analysis, parentIssueId, maxSubtasks);
|
|
90
163
|
onProgress({ phase: 'subtasks-analyzed', parentIssueId, count: analysis.subtasks.length });
|
|
91
164
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
165
|
+
if (analysis.scopeDecision?.status === 'none') {
|
|
166
|
+
throw new Error(`Issue #${parentIssueId} 沒有可建立的 Frontend 子任務`);
|
|
167
|
+
}
|
|
168
|
+
if (analysis.scopeDecision?.status === 'unclear') {
|
|
169
|
+
throw new Error(`Issue #${parentIssueId} 的 Frontend scope 不明,請先確認 unresolved items`);
|
|
170
|
+
}
|
|
171
|
+
if (!analysis.subtasks.length) {
|
|
172
|
+
throw new Error(`Issue #${parentIssueId} 沒有產生有效的 Frontend 子任務`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const subtasks = analysis.subtasks.map((subtask, index) => {
|
|
176
|
+
onProgress({
|
|
177
|
+
phase: 'screen-enrichment',
|
|
178
|
+
index: index + 1,
|
|
179
|
+
total: analysis.subtasks.length,
|
|
180
|
+
parentIssueId,
|
|
181
|
+
title: subtask.title,
|
|
182
|
+
});
|
|
183
|
+
return {
|
|
184
|
+
...subtask,
|
|
185
|
+
title: subtask.title,
|
|
186
|
+
content: formatSubtaskContent({
|
|
187
|
+
subtask,
|
|
188
|
+
parentIssueId: parent.id,
|
|
189
|
+
scopeDecision: analysis.scopeDecision,
|
|
190
|
+
evidence: analysis.evidence,
|
|
191
|
+
}),
|
|
192
|
+
};
|
|
193
|
+
});
|
|
97
194
|
|
|
98
195
|
return {
|
|
99
|
-
version:
|
|
196
|
+
version: 2,
|
|
197
|
+
schemaVersion: 2,
|
|
100
198
|
generatedAt: new Date().toISOString(),
|
|
199
|
+
analysisScope: analysis.analysisScope,
|
|
200
|
+
maxSubtasks,
|
|
201
|
+
isIndivisible: Boolean(analysis.isIndivisible),
|
|
202
|
+
indivisibleReason: analysis.indivisibleReason || '',
|
|
203
|
+
scopeDecision: analysis.scopeDecision,
|
|
204
|
+
evidence: analysis.evidence || [],
|
|
205
|
+
unresolvedItems: analysis.unresolvedItems || [],
|
|
101
206
|
parent: getParentSnapshot(parent),
|
|
102
207
|
subtasks,
|
|
103
|
-
unresolvedItems: analysis.unresolvedItems || [],
|
|
104
208
|
};
|
|
209
|
+
|
|
105
210
|
}
|
|
106
211
|
|
|
107
212
|
/**
|
|
108
213
|
* 套用已審核的子任務 draft
|
|
109
|
-
* @param {{draft: object, client: object, force?: boolean, onProgress?: Function}} options
|
|
214
|
+
* @param {{draft: object, client: object, force?: boolean, replace?: boolean, onProgress?: Function}} options
|
|
110
215
|
* @returns {Promise<Array<object>>}
|
|
111
216
|
*/
|
|
112
|
-
export async function applySubtaskDraft({ draft, client, force = false, onProgress = () => {} }) {
|
|
217
|
+
export async function applySubtaskDraft({ draft, client, force = false, replace = false, onProgress = () => {} }) {
|
|
113
218
|
validateSubtaskDraft(draft);
|
|
114
219
|
const parentIssueId = draft.parent.id;
|
|
115
220
|
const current = await client.getIssue(parentIssueId, { include: 'children,relations' });
|
|
@@ -118,25 +223,73 @@ export async function applySubtaskDraft({ draft, client, force = false, onProgre
|
|
|
118
223
|
}
|
|
119
224
|
assertParentMetadata(draft.parent);
|
|
120
225
|
|
|
121
|
-
const
|
|
226
|
+
const childDetails = await loadChildDetails(
|
|
122
227
|
client,
|
|
123
228
|
Array.isArray(current.children) ? current.children : []
|
|
124
229
|
);
|
|
230
|
+
if (childDetails.unavailableChildIds.length > 0) {
|
|
231
|
+
return [{
|
|
232
|
+
parentIssueId,
|
|
233
|
+
blocked: true,
|
|
234
|
+
reason: 'child-detail-unavailable',
|
|
235
|
+
childIds: childDetails.unavailableChildIds,
|
|
236
|
+
}];
|
|
237
|
+
}
|
|
238
|
+
const children = childDetails.children;
|
|
239
|
+
const replaceableChildren = replace
|
|
240
|
+
? getReplaceableChildren(draft.parent, children, parentIssueId)
|
|
241
|
+
: [];
|
|
242
|
+
const replacedChildIds = new Set();
|
|
125
243
|
const managedKeys = getExistingManagedKeys(children);
|
|
126
244
|
const titles = getExistingChildTitles(children);
|
|
127
245
|
const results = [];
|
|
128
246
|
for (const [index, subtask] of draft.subtasks.entries()) {
|
|
247
|
+
const subtaskId = subtask.id || subtask.key;
|
|
248
|
+
const managedKey = buildManagedSubtaskKey(parentIssueId, subtaskId);
|
|
249
|
+
const replacement = replace
|
|
250
|
+
? replaceableChildren.find(child => !replacedChildIds.has(String(child.id))
|
|
251
|
+
&& getManagedSubtaskKey(child.description) === managedKey)
|
|
252
|
+
|| replaceableChildren.find(child => !replacedChildIds.has(String(child.id)))
|
|
253
|
+
: null;
|
|
129
254
|
onProgress({
|
|
130
|
-
phase: 'create-child',
|
|
255
|
+
phase: replacement ? 'replace-child' : 'create-child',
|
|
131
256
|
index: index + 1,
|
|
132
257
|
total: draft.subtasks.length,
|
|
133
258
|
parentIssueId,
|
|
134
|
-
key:
|
|
259
|
+
key: subtaskId,
|
|
135
260
|
title: subtask.title,
|
|
136
261
|
});
|
|
137
|
-
|
|
262
|
+
if (replacement) {
|
|
263
|
+
replacedChildIds.add(String(replacement.id));
|
|
264
|
+
try {
|
|
265
|
+
await client.updateIssue(replacement.id, {
|
|
266
|
+
subject: subtask.title,
|
|
267
|
+
description: subtask.content,
|
|
268
|
+
});
|
|
269
|
+
results.push({
|
|
270
|
+
...(draft.version === 2 ? { id: subtaskId } : { key: subtaskId }),
|
|
271
|
+
title: subtask.title,
|
|
272
|
+
updated: true,
|
|
273
|
+
childId: replacement.id,
|
|
274
|
+
});
|
|
275
|
+
} catch (error) {
|
|
276
|
+
results.push({
|
|
277
|
+
...(draft.version === 2 ? { id: subtaskId } : { key: subtaskId }),
|
|
278
|
+
title: subtask.title,
|
|
279
|
+
updated: false,
|
|
280
|
+
childId: replacement.id,
|
|
281
|
+
error: sanitizeError(error, client),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
138
286
|
if (managedKeys.has(managedKey) || titles.has(String(subtask.title).trim().toLocaleLowerCase())) {
|
|
139
|
-
results.push({
|
|
287
|
+
results.push({
|
|
288
|
+
...(draft.version === 2 ? { id: subtaskId } : { key: subtaskId }),
|
|
289
|
+
title: subtask.title,
|
|
290
|
+
skipped: true,
|
|
291
|
+
reason: 'duplicate',
|
|
292
|
+
});
|
|
140
293
|
continue;
|
|
141
294
|
}
|
|
142
295
|
|
|
@@ -149,7 +302,7 @@ export async function applySubtaskDraft({ draft, client, force = false, onProgre
|
|
|
149
302
|
parentIssueId,
|
|
150
303
|
});
|
|
151
304
|
results.push({
|
|
152
|
-
key:
|
|
305
|
+
...(draft.version === 2 ? { id: subtaskId } : { key: subtaskId }),
|
|
153
306
|
title: subtask.title,
|
|
154
307
|
created: true,
|
|
155
308
|
childId: child?.id ?? child?.issue?.id ?? null,
|
|
@@ -158,7 +311,7 @@ export async function applySubtaskDraft({ draft, client, force = false, onProgre
|
|
|
158
311
|
titles.add(String(subtask.title).trim().toLocaleLowerCase());
|
|
159
312
|
} catch (error) {
|
|
160
313
|
results.push({
|
|
161
|
-
key:
|
|
314
|
+
...(draft.version === 2 ? { id: subtaskId } : { key: subtaskId }),
|
|
162
315
|
title: subtask.title,
|
|
163
316
|
created: false,
|
|
164
317
|
error: sanitizeError(error, client),
|
|
@@ -173,17 +326,46 @@ export async function applySubtaskDraft({ draft, client, force = false, onProgre
|
|
|
173
326
|
* @param {object} draft
|
|
174
327
|
*/
|
|
175
328
|
export function validateSubtaskDraft(draft = {}) {
|
|
176
|
-
|
|
177
|
-
|
|
329
|
+
assertDraftDoesNotContainPrivateFields(draft);
|
|
330
|
+
if (draft.version !== 2 || draft.schemaVersion !== 2) {
|
|
331
|
+
throw new Error('Frontend 子任務 draft v1 不受支援,請重新產生 schemaVersion 2 preview');
|
|
332
|
+
}
|
|
333
|
+
if (draft.analysisScope !== 'frontend') {
|
|
334
|
+
throw new Error('Frontend 子任務 draft 的 analysisScope 必須是 frontend');
|
|
335
|
+
}
|
|
336
|
+
const maxSubtasks = draft.maxSubtasks === undefined ? MAX_MAX_SUBTASKS : Number(draft.maxSubtasks);
|
|
337
|
+
if (!Number.isInteger(maxSubtasks) || maxSubtasks < MIN_MAX_SUBTASKS || maxSubtasks > MAX_MAX_SUBTASKS) {
|
|
338
|
+
throw new Error(`Frontend 子任務 draft 的 maxSubtasks 必須介於 ${MIN_MAX_SUBTASKS} 到 ${MAX_MAX_SUBTASKS} 之間`);
|
|
339
|
+
}
|
|
340
|
+
if (!draft.scopeDecision || !['full', 'partial'].includes(draft.scopeDecision.status)) {
|
|
341
|
+
throw new Error('Frontend 子任務 draft 缺少可套用的 scopeDecision');
|
|
342
|
+
}
|
|
343
|
+
if (!draft.parent || !Array.isArray(draft.subtasks) || draft.subtasks.length === 0) {
|
|
344
|
+
throw new Error('Frontend 子任務 draft 格式不受支援');
|
|
178
345
|
}
|
|
179
346
|
if (draft.parent.id === undefined || draft.parent.id === null) {
|
|
180
347
|
throw new Error('子任務 draft 缺少 parent Issue ID');
|
|
181
348
|
}
|
|
349
|
+
const ids = new Set();
|
|
182
350
|
for (const subtask of draft.subtasks) {
|
|
183
|
-
if (!subtask?.
|
|
184
|
-
throw new Error('子任務 draft 缺少
|
|
351
|
+
if (!subtask?.id || !subtask.title?.trim() || !subtask.content?.trim()) {
|
|
352
|
+
throw new Error('Frontend 子任務 draft 缺少 id、title 或 content');
|
|
185
353
|
}
|
|
354
|
+
if (ids.has(subtask.id)) throw new Error(`Frontend 子任務 draft ID 重複:${subtask.id}`);
|
|
355
|
+
ids.add(subtask.id);
|
|
186
356
|
}
|
|
357
|
+
const normalizedAnalysis = normalizeFrontendAnalysis({
|
|
358
|
+
schemaVersion: draft.schemaVersion,
|
|
359
|
+
issueId: draft.parent.id,
|
|
360
|
+
analysisScope: draft.analysisScope,
|
|
361
|
+
isIndivisible: Boolean(draft.isIndivisible),
|
|
362
|
+
indivisibleReason: draft.indivisibleReason || '',
|
|
363
|
+
scopeDecision: draft.scopeDecision,
|
|
364
|
+
evidence: Array.isArray(draft.evidence) ? draft.evidence : [],
|
|
365
|
+
unresolvedItems: Array.isArray(draft.unresolvedItems) ? draft.unresolvedItems : [],
|
|
366
|
+
subtasks: draft.subtasks,
|
|
367
|
+
});
|
|
368
|
+
validateFrontendAnalysis(normalizedAnalysis, draft.parent.id, maxSubtasks);
|
|
187
369
|
}
|
|
188
370
|
|
|
189
371
|
/**
|