workflow-ai 1.0.55 → 1.0.57
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/configs/pipeline.yaml +18 -8
- package/package.json +1 -1
- package/src/runner.mjs +23 -4
- package/src/scripts/archive-plan-tickets.js +102 -0
- package/src/scripts/check-anomalies.js +161 -0
- package/src/scripts/check-conditions.js +251 -0
- package/src/scripts/check-plan-decomposed.js +179 -0
- package/src/scripts/check-plan-templates.js +295 -0
- package/src/scripts/move-ticket.js +260 -0
- package/src/scripts/move-to-ready.js +115 -0
- package/src/scripts/move-to-review.js +142 -0
- package/src/scripts/pick-next-task.js +723 -0
- package/templates/ticket-template.md +3 -0
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* pick-next-task.js - Скрипт для выбора следующего тикета из директории ready/
|
|
5
|
+
*
|
|
6
|
+
* Использование:
|
|
7
|
+
* node pick-next-task.js
|
|
8
|
+
*
|
|
9
|
+
* Выводит результат в формате:
|
|
10
|
+
* ---RESULT---
|
|
11
|
+
* status: found
|
|
12
|
+
* ticket_id: IMPL-001
|
|
13
|
+
* ---RESULT---
|
|
14
|
+
*
|
|
15
|
+
* или если задач нет:
|
|
16
|
+
* ---RESULT---
|
|
17
|
+
* status: empty
|
|
18
|
+
* ---RESULT---
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from 'fs';
|
|
22
|
+
import path from 'path';
|
|
23
|
+
import { findProjectRoot } from 'workflow-ai/lib/find-root.mjs';
|
|
24
|
+
import { parseFrontmatter, printResult, normalizePlanId, extractPlanId, getLastReviewStatus, serializeFrontmatter, loadTicketMovementRules, checkAndClosePlan } from 'workflow-ai/lib/utils.mjs';
|
|
25
|
+
import { createLogger } from 'workflow-ai/lib/logger.mjs';
|
|
26
|
+
|
|
27
|
+
const logger = createLogger();
|
|
28
|
+
|
|
29
|
+
// Корень проекта
|
|
30
|
+
const PROJECT_DIR = findProjectRoot();
|
|
31
|
+
// Базовая директория workflow
|
|
32
|
+
const WORKFLOW_DIR = path.join(PROJECT_DIR, '.workflow');
|
|
33
|
+
const TICKETS_DIR = path.join(WORKFLOW_DIR, 'tickets');
|
|
34
|
+
const READY_DIR = path.join(TICKETS_DIR, 'ready');
|
|
35
|
+
const DONE_DIR = path.join(TICKETS_DIR, 'done');
|
|
36
|
+
const IN_PROGRESS_DIR = path.join(TICKETS_DIR, 'in-progress');
|
|
37
|
+
const BLOCKED_DIR = path.join(TICKETS_DIR, 'blocked');
|
|
38
|
+
const REVIEW_DIR = path.join(TICKETS_DIR, 'review');
|
|
39
|
+
const ARCHIVE_DIR = path.join(TICKETS_DIR, 'archive');
|
|
40
|
+
const BACKLOG_DIR = path.join(TICKETS_DIR, 'backlog');
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Проверяет условие (condition) тикета
|
|
45
|
+
*/
|
|
46
|
+
function checkCondition(condition) {
|
|
47
|
+
const { type, value } = condition;
|
|
48
|
+
|
|
49
|
+
switch (type) {
|
|
50
|
+
case 'file_exists':
|
|
51
|
+
const filePath = path.join(PROJECT_DIR, value);
|
|
52
|
+
return fs.existsSync(filePath);
|
|
53
|
+
|
|
54
|
+
case 'file_not_exists':
|
|
55
|
+
const filePath2 = path.join(PROJECT_DIR, value);
|
|
56
|
+
return !fs.existsSync(filePath2);
|
|
57
|
+
|
|
58
|
+
case 'tasks_completed':
|
|
59
|
+
// Проверяет, что указанные задачи выполнены (находятся в done/)
|
|
60
|
+
if (!value || (Array.isArray(value) && value.length === 0)) return true;
|
|
61
|
+
const ids = Array.isArray(value) ? value : [value];
|
|
62
|
+
return ids.every(taskId => {
|
|
63
|
+
const donePath = path.join(DONE_DIR, `${taskId}.md`);
|
|
64
|
+
const archivePath = path.join(ARCHIVE_DIR, `${taskId}.md`);
|
|
65
|
+
return fs.existsSync(donePath) || fs.existsSync(archivePath);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
case 'date_after':
|
|
69
|
+
return new Date() > new Date(value);
|
|
70
|
+
|
|
71
|
+
case 'date_before':
|
|
72
|
+
return new Date() < new Date(value);
|
|
73
|
+
|
|
74
|
+
case 'manual_approval':
|
|
75
|
+
// Для ручного подтверждения всегда возвращаем false
|
|
76
|
+
// Требуется явное одобрение
|
|
77
|
+
return false;
|
|
78
|
+
|
|
79
|
+
default:
|
|
80
|
+
logger.warn(`Unknown condition type: ${type}`);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Парсит секцию "## Ревью" тикета и возвращает все записи ревью.
|
|
87
|
+
* @param {string} content - Содержимое тикета
|
|
88
|
+
* @returns {Array<{date: string, status: string, comment: string}>}
|
|
89
|
+
*/
|
|
90
|
+
function parseReviewSection(content) {
|
|
91
|
+
if (!content) return [];
|
|
92
|
+
|
|
93
|
+
const headerIdx = content.search(/^##\s*Ревью\s*$/m);
|
|
94
|
+
if (headerIdx === -1) return [];
|
|
95
|
+
|
|
96
|
+
const bodyStart = content.indexOf('\n', headerIdx);
|
|
97
|
+
if (bodyStart === -1) return [];
|
|
98
|
+
|
|
99
|
+
const nextH2 = content.indexOf('\n## ', bodyStart);
|
|
100
|
+
const reviewSection = (nextH2 === -1
|
|
101
|
+
? content.slice(bodyStart + 1)
|
|
102
|
+
: content.slice(bodyStart + 1, nextH2)).trim();
|
|
103
|
+
|
|
104
|
+
const reviews = [];
|
|
105
|
+
|
|
106
|
+
const tableRows = reviewSection.split('\n').filter(line => line.trim().startsWith('|'));
|
|
107
|
+
if (tableRows.length >= 2) {
|
|
108
|
+
const dataRows = tableRows.slice(2).filter(row => {
|
|
109
|
+
const cells = row.split('|').map(c => c.trim()).filter(c => c);
|
|
110
|
+
return cells.length >= 2;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
for (const row of dataRows) {
|
|
114
|
+
const cells = row.split('|').map(c => c.trim()).filter(c => c);
|
|
115
|
+
const date = cells[0] || '';
|
|
116
|
+
const statusRaw = cells[1]?.toLowerCase() || '';
|
|
117
|
+
const comment = cells[2] || '';
|
|
118
|
+
let status = null;
|
|
119
|
+
if (statusRaw.includes('passed')) status = 'passed';
|
|
120
|
+
else if (statusRaw.includes('failed')) status = 'failed';
|
|
121
|
+
else if (statusRaw.includes('skipped')) status = 'skipped';
|
|
122
|
+
|
|
123
|
+
if (status) {
|
|
124
|
+
reviews.push({ date, status, comment });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const listItems = reviewSection.split('\n').filter(line => line.trim().match(/^[-*]\s/));
|
|
130
|
+
for (const item of listItems) {
|
|
131
|
+
const trimmed = item.trim();
|
|
132
|
+
const dateMatch = trimmed.match(/^[-*]\s*(\d{4}-\d{2}-\d{2})/);
|
|
133
|
+
const statusMatch = trimmed.match(/:\s*(passed|failed|skipped)\b/i);
|
|
134
|
+
if (dateMatch && statusMatch) {
|
|
135
|
+
reviews.push({
|
|
136
|
+
date: dateMatch[1],
|
|
137
|
+
status: statusMatch[1].toLowerCase(),
|
|
138
|
+
comment: trimmed.replace(/^[-*]\s*\d{4}-\d{2}-\d{2}:\s*(passed|failed|skipped)\b/i, '').trim()
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return reviews;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Вычисляет метрики ревью-итераций для всех тикетов
|
|
148
|
+
* @returns {object} Метрики: iterations, avgTimeToFirstPassed, failedVsPassed
|
|
149
|
+
*/
|
|
150
|
+
function calculateReviewMetrics() {
|
|
151
|
+
const allDirs = [BACKLOG_DIR, READY_DIR, IN_PROGRESS_DIR, BLOCKED_DIR, REVIEW_DIR, DONE_DIR, ARCHIVE_DIR];
|
|
152
|
+
const ticketMetrics = {};
|
|
153
|
+
let totalFailed = 0;
|
|
154
|
+
let totalPassed = 0;
|
|
155
|
+
let firstPassedTimes = [];
|
|
156
|
+
|
|
157
|
+
for (const dir of allDirs) {
|
|
158
|
+
if (!fs.existsSync(dir)) continue;
|
|
159
|
+
|
|
160
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.md') && f !== '.gitkeep.md');
|
|
161
|
+
|
|
162
|
+
for (const file of files) {
|
|
163
|
+
const filePath = path.join(dir, file);
|
|
164
|
+
try {
|
|
165
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
166
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
167
|
+
const ticketId = frontmatter.id || file.replace('.md', '');
|
|
168
|
+
|
|
169
|
+
const reviews = parseReviewSection(content);
|
|
170
|
+
if (reviews.length === 0) continue;
|
|
171
|
+
|
|
172
|
+
ticketMetrics[ticketId] = reviews.length;
|
|
173
|
+
|
|
174
|
+
for (const review of reviews) {
|
|
175
|
+
if (review.status === 'failed') totalFailed++;
|
|
176
|
+
else if (review.status === 'passed') totalPassed++;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const firstPassed = reviews.find(r => r.status === 'passed');
|
|
180
|
+
if (firstPassed && firstPassed.date) {
|
|
181
|
+
const ticketCreated = new Date(frontmatter.created_at || '1970-01-01');
|
|
182
|
+
const passedDate = new Date(firstPassed.date);
|
|
183
|
+
const daysToPass = Math.floor((passedDate - ticketCreated) / (1000 * 60 * 60 * 24));
|
|
184
|
+
if (daysToPass >= 0) {
|
|
185
|
+
firstPassedTimes.push(daysToPass);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
} catch (e) {
|
|
189
|
+
// Skip errors
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const avgTimeToFirstPassed = firstPassedTimes.length > 0
|
|
195
|
+
? Math.round(firstPassedTimes.reduce((a, b) => a + b, 0) / firstPassedTimes.length)
|
|
196
|
+
: null;
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
iterations_per_ticket: ticketMetrics,
|
|
200
|
+
total_failed: totalFailed,
|
|
201
|
+
total_passed: totalPassed,
|
|
202
|
+
avg_time_to_first_passed_days: avgTimeToFirstPassed,
|
|
203
|
+
tickets_with_reviews: Object.keys(ticketMetrics).length
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Проверяет зависимости тикета
|
|
209
|
+
*/
|
|
210
|
+
function checkDependencies(dependencies) {
|
|
211
|
+
if (!dependencies || dependencies.length === 0) {
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return dependencies.every(depId => {
|
|
216
|
+
const donePath = path.join(DONE_DIR, `${depId}.md`);
|
|
217
|
+
const archivePath = path.join(ARCHIVE_DIR, `${depId}.md`);
|
|
218
|
+
return fs.existsSync(donePath) || fs.existsSync(archivePath);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Авто-коррекция тикетов на основе статуса ревью.
|
|
224
|
+
* Сканирует все директории и перемещает тикеты по правилам из конфига.
|
|
225
|
+
*
|
|
226
|
+
* @param {object} config - Конфигурация правил перемещения
|
|
227
|
+
* @returns {object} Результат: { moved: Array<{id, from, to, reason}> }
|
|
228
|
+
*/
|
|
229
|
+
function autoCorrectTickets(config) {
|
|
230
|
+
const moved = [];
|
|
231
|
+
|
|
232
|
+
const dirMap = {
|
|
233
|
+
backlog: BACKLOG_DIR,
|
|
234
|
+
ready: READY_DIR,
|
|
235
|
+
in_progress: IN_PROGRESS_DIR,
|
|
236
|
+
blocked: BLOCKED_DIR,
|
|
237
|
+
review: REVIEW_DIR,
|
|
238
|
+
done: DONE_DIR,
|
|
239
|
+
archive: ARCHIVE_DIR
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Перемещает тикет из одной директории в другую
|
|
244
|
+
*/
|
|
245
|
+
function moveTicket(ticketId, fromDir, toDir, reason) {
|
|
246
|
+
const fromPath = path.join(fromDir, `${ticketId}.md`);
|
|
247
|
+
const toPath = path.join(toDir, `${ticketId}.md`);
|
|
248
|
+
|
|
249
|
+
if (!fs.existsSync(fromPath)) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
const content = fs.readFileSync(fromPath, 'utf8');
|
|
255
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
256
|
+
|
|
257
|
+
frontmatter.updated_at = new Date().toISOString();
|
|
258
|
+
|
|
259
|
+
if (toDir === DONE_DIR && !frontmatter.completed_at) {
|
|
260
|
+
frontmatter.completed_at = new Date().toISOString();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const newContent = serializeFrontmatter(frontmatter) + body;
|
|
264
|
+
fs.writeFileSync(toPath, newContent, 'utf8');
|
|
265
|
+
|
|
266
|
+
fs.unlinkSync(fromPath);
|
|
267
|
+
|
|
268
|
+
console.log(`[AUTO-CORRECT] ${ticketId}: ${path.basename(fromDir)} → ${path.basename(toDir)} (${reason})`);
|
|
269
|
+
|
|
270
|
+
moved.push({
|
|
271
|
+
id: ticketId,
|
|
272
|
+
from: path.basename(fromDir),
|
|
273
|
+
to: path.basename(toDir),
|
|
274
|
+
reason
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
return true;
|
|
278
|
+
} catch (e) {
|
|
279
|
+
logger.error(`Failed to move ticket ${ticketId}: ${e.message}`);
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Обрабатывает тикеты в указанной директории
|
|
286
|
+
*/
|
|
287
|
+
function processDirectory(dir, rules, dirName) {
|
|
288
|
+
if (!fs.existsSync(dir)) return;
|
|
289
|
+
|
|
290
|
+
const files = fs.readdirSync(dir)
|
|
291
|
+
.filter(f => f.endsWith('.md') && f !== '.gitkeep.md');
|
|
292
|
+
|
|
293
|
+
for (const file of files) {
|
|
294
|
+
const filePath = path.join(dir, file);
|
|
295
|
+
try {
|
|
296
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
297
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
298
|
+
const ticketId = frontmatter.id || file.replace('.md', '');
|
|
299
|
+
|
|
300
|
+
const reviewStatus = getLastReviewStatus(content);
|
|
301
|
+
|
|
302
|
+
for (const rule of rules) {
|
|
303
|
+
const ruleCondition = rule.condition;
|
|
304
|
+
let shouldMove = false;
|
|
305
|
+
|
|
306
|
+
if (ruleCondition === null) {
|
|
307
|
+
shouldMove = reviewStatus === null;
|
|
308
|
+
} else {
|
|
309
|
+
shouldMove = reviewStatus === ruleCondition;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (shouldMove) {
|
|
313
|
+
const targetDirName = rule.to_dir;
|
|
314
|
+
const targetDir = dirMap[targetDirName];
|
|
315
|
+
if (targetDir) {
|
|
316
|
+
moveTicket(ticketId, dir, targetDir, rule.reason);
|
|
317
|
+
}
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
} catch (e) {
|
|
322
|
+
logger.warn(`Failed to process ticket ${file}: ${e.message}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (!config || !config.rules) {
|
|
328
|
+
logger.error('Ticket movement rules config not loaded');
|
|
329
|
+
return { moved };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const rulesConfig = config.rules;
|
|
333
|
+
|
|
334
|
+
for (const [dirName, rules] of Object.entries(rulesConfig)) {
|
|
335
|
+
const dir = dirMap[dirName];
|
|
336
|
+
if (dir) {
|
|
337
|
+
processDirectory(dir, rules, dirName);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return { moved };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Считывает все тикеты из директории ready/
|
|
346
|
+
*/
|
|
347
|
+
function readReadyTickets() {
|
|
348
|
+
if (!fs.existsSync(READY_DIR)) {
|
|
349
|
+
return [];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const files = fs.readdirSync(READY_DIR)
|
|
353
|
+
.filter(f => f.endsWith('.md') && f !== '.gitkeep.md');
|
|
354
|
+
|
|
355
|
+
const tickets = [];
|
|
356
|
+
|
|
357
|
+
for (const file of files) {
|
|
358
|
+
const filePath = path.join(READY_DIR, file);
|
|
359
|
+
try {
|
|
360
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
361
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
362
|
+
|
|
363
|
+
tickets.push({
|
|
364
|
+
id: frontmatter.id || file.replace('.md', ''),
|
|
365
|
+
frontmatter,
|
|
366
|
+
filePath
|
|
367
|
+
});
|
|
368
|
+
} catch (e) {
|
|
369
|
+
console.error(`[WARN] Failed to read ticket ${file}: ${e.message}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return tickets;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Считывает все тикеты из директории review/
|
|
378
|
+
*/
|
|
379
|
+
function readReviewTickets() {
|
|
380
|
+
if (!fs.existsSync(path.join(TICKETS_DIR, 'review'))) {
|
|
381
|
+
return [];
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const files = fs.readdirSync(path.join(TICKETS_DIR, 'review'))
|
|
385
|
+
.filter(f => f.endsWith('.md') && f !== '.gitkeep.md');
|
|
386
|
+
|
|
387
|
+
const tickets = [];
|
|
388
|
+
|
|
389
|
+
for (const file of files) {
|
|
390
|
+
const filePath = path.join(TICKETS_DIR, 'review', file);
|
|
391
|
+
try {
|
|
392
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
393
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
394
|
+
|
|
395
|
+
tickets.push({
|
|
396
|
+
id: frontmatter.id || file.replace('.md', ''),
|
|
397
|
+
frontmatter,
|
|
398
|
+
filePath
|
|
399
|
+
});
|
|
400
|
+
} catch (e) {
|
|
401
|
+
console.error(`[WARN] Failed to read ticket ${file}: ${e.message}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return tickets;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Проверяет, заполнен ли раздел результатов (Summary) в тикете
|
|
410
|
+
*/
|
|
411
|
+
function hasFilledResult(body) {
|
|
412
|
+
const resultSectionRegex = /^##\s*(Результат выполнения|Result)\s*$/m;
|
|
413
|
+
const sectionStart = body.search(resultSectionRegex);
|
|
414
|
+
|
|
415
|
+
if (sectionStart === -1) {
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const nextSectionRegex = /^##\s+/gm;
|
|
420
|
+
nextSectionRegex.lastIndex = sectionStart + 1;
|
|
421
|
+
const nextSectionMatch = nextSectionRegex.exec(body);
|
|
422
|
+
const sectionEnd = nextSectionMatch ? nextSectionMatch.index : body.length;
|
|
423
|
+
|
|
424
|
+
const sectionContent = body.substring(sectionStart, sectionEnd);
|
|
425
|
+
|
|
426
|
+
const summaryRegex = /^###\s*(Summary|Что сделано)\s*$/m;
|
|
427
|
+
const summaryStart = sectionContent.search(summaryRegex);
|
|
428
|
+
|
|
429
|
+
if (summaryStart === -1) {
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const nextSubsectionRegex = /^###\s+/gm;
|
|
434
|
+
nextSubsectionRegex.lastIndex = summaryStart + 1;
|
|
435
|
+
const nextSubsectionMatch = nextSubsectionRegex.exec(sectionContent);
|
|
436
|
+
const summaryEnd = nextSubsectionMatch ? nextSubsectionMatch.index : sectionContent.length;
|
|
437
|
+
|
|
438
|
+
const summaryContent = sectionContent.substring(summaryStart, summaryEnd);
|
|
439
|
+
const withoutComments = summaryContent.replace(/<!--[\s\S]*?-->/g, '').trim();
|
|
440
|
+
|
|
441
|
+
return withoutComments.length > 0;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Находит завершённые тикеты в in-progress/ (с заполненным Summary)
|
|
446
|
+
* Возвращает массив id тикетов
|
|
447
|
+
*/
|
|
448
|
+
function findCompletedInProgress() {
|
|
449
|
+
if (!fs.existsSync(IN_PROGRESS_DIR)) {
|
|
450
|
+
return [];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const files = fs.readdirSync(IN_PROGRESS_DIR)
|
|
454
|
+
.filter(f => f.endsWith('.md') && f !== '.gitkeep.md');
|
|
455
|
+
|
|
456
|
+
const completed = [];
|
|
457
|
+
|
|
458
|
+
for (const file of files) {
|
|
459
|
+
const filePath = path.join(IN_PROGRESS_DIR, file);
|
|
460
|
+
try {
|
|
461
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
462
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
463
|
+
|
|
464
|
+
if (!hasFilledResult(body)) {
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
completed.push({
|
|
469
|
+
id: frontmatter.id || file.replace('.md', ''),
|
|
470
|
+
frontmatter,
|
|
471
|
+
filePath
|
|
472
|
+
});
|
|
473
|
+
} catch (e) {
|
|
474
|
+
console.error(`[WARN] Failed to read in-progress ticket ${file}: ${e.message}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return completed;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Выбирает следующий тикет для выполнения
|
|
483
|
+
*/
|
|
484
|
+
function filterByPlan(tickets, planId) {
|
|
485
|
+
if (!planId) return tickets;
|
|
486
|
+
return tickets.filter(t => normalizePlanId(t.frontmatter.parent_plan) === planId);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function pickNextTicket(planId) {
|
|
490
|
+
const tickets = filterByPlan(readReadyTickets(), planId);
|
|
491
|
+
|
|
492
|
+
if (tickets.length === 0) {
|
|
493
|
+
// Если ready/ пуст, проверяем review/ — нужно завершить ревью
|
|
494
|
+
let reviewTickets = filterByPlan(readReviewTickets(), planId);
|
|
495
|
+
|
|
496
|
+
if (reviewTickets.length === 0) {
|
|
497
|
+
// Нет тикетов ни в ready/, ни в review/ — проверяем in-progress/
|
|
498
|
+
// на завершённые тикеты (с заполненным Summary)
|
|
499
|
+
const completedInProgress = filterByPlan(findCompletedInProgress(), planId);
|
|
500
|
+
if (completedInProgress.length > 0) {
|
|
501
|
+
const first = completedInProgress[0];
|
|
502
|
+
logger.info(`Found completed ticket in in-progress/: ${first.id}`);
|
|
503
|
+
return {
|
|
504
|
+
status: 'completed_in_progress',
|
|
505
|
+
ticket_id: first.id
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (reviewTickets.length > 0) {
|
|
511
|
+
return {
|
|
512
|
+
status: 'in_review',
|
|
513
|
+
ticket_id: reviewTickets[0].id,
|
|
514
|
+
priority: reviewTickets[0].frontmatter.priority,
|
|
515
|
+
title: reviewTickets[0].frontmatter.title,
|
|
516
|
+
type: reviewTickets[0].frontmatter.type
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
return { status: 'empty', reason: 'No tickets in ready/' };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Фильтрация по условиям и зависимостям
|
|
523
|
+
const eligibleTickets = tickets.filter(ticket => {
|
|
524
|
+
const { frontmatter } = ticket;
|
|
525
|
+
|
|
526
|
+
// Пропускаем тикеты, требующие ручного выполнения
|
|
527
|
+
if (frontmatter.type === 'human') {
|
|
528
|
+
logger.info(`Skipping ticket ${ticket.id}: type is 'human' (requires manual execution)`);
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Проверка условий
|
|
533
|
+
const conditions = frontmatter.conditions || [];
|
|
534
|
+
const conditionsMet = conditions.every(checkCondition);
|
|
535
|
+
if (!conditionsMet) {
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Проверка зависимостей
|
|
540
|
+
const dependencies = frontmatter.dependencies || [];
|
|
541
|
+
const depsMet = checkDependencies(dependencies);
|
|
542
|
+
if (!depsMet) {
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return true;
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
if (eligibleTickets.length === 0) {
|
|
550
|
+
return {
|
|
551
|
+
status: 'empty',
|
|
552
|
+
reason: 'No eligible tickets (conditions/dependencies not met)'
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Сортировка по приоритету (меньше = важнее), затем по created_at
|
|
557
|
+
eligibleTickets.sort((a, b) => {
|
|
558
|
+
const priorityA = a.frontmatter.priority || 999;
|
|
559
|
+
const priorityB = b.frontmatter.priority || 999;
|
|
560
|
+
|
|
561
|
+
if (priorityA !== priorityB) {
|
|
562
|
+
return priorityA - priorityB;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// При равном приоритете - по дате создания (старые первые)
|
|
566
|
+
const dateA = new Date(a.frontmatter.created_at || '9999-12-31');
|
|
567
|
+
const dateB = new Date(b.frontmatter.created_at || '9999-12-31');
|
|
568
|
+
return dateA - dateB;
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
const selected = eligibleTickets[0];
|
|
572
|
+
|
|
573
|
+
return {
|
|
574
|
+
status: 'found',
|
|
575
|
+
ticket_id: selected.id,
|
|
576
|
+
priority: selected.frontmatter.priority,
|
|
577
|
+
title: selected.frontmatter.title,
|
|
578
|
+
type: selected.frontmatter.type
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Архивирует все done-тикеты, принадлежащие архивным планам (plans/archive/).
|
|
584
|
+
* Сканирует все планы в plans/archive/, находит их тикеты в done/ и перемещает в archive/.
|
|
585
|
+
*/
|
|
586
|
+
function archiveTicketsOfArchivedPlans() {
|
|
587
|
+
const archivedPlansDir = path.join(WORKFLOW_DIR, 'plans', 'archive');
|
|
588
|
+
if (!fs.existsSync(archivedPlansDir)) return { archived: [] };
|
|
589
|
+
|
|
590
|
+
// Собираем ID всех архивных планов
|
|
591
|
+
const archivedPlanIds = new Set();
|
|
592
|
+
const planFiles = fs.readdirSync(archivedPlansDir).filter(f => f.endsWith('.md'));
|
|
593
|
+
for (const file of planFiles) {
|
|
594
|
+
const id = normalizePlanId(file);
|
|
595
|
+
if (id) archivedPlanIds.add(id);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (archivedPlanIds.size === 0) return { archived: [] };
|
|
599
|
+
|
|
600
|
+
if (!fs.existsSync(DONE_DIR)) return { archived: [] };
|
|
601
|
+
|
|
602
|
+
if (!fs.existsSync(ARCHIVE_DIR)) {
|
|
603
|
+
fs.mkdirSync(ARCHIVE_DIR, { recursive: true });
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const archived = [];
|
|
607
|
+
const files = fs.readdirSync(DONE_DIR).filter(f => f.endsWith('.md') && f !== '.gitkeep.md');
|
|
608
|
+
|
|
609
|
+
for (const file of files) {
|
|
610
|
+
const filePath = path.join(DONE_DIR, file);
|
|
611
|
+
try {
|
|
612
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
613
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
614
|
+
const ticketPlanId = normalizePlanId(frontmatter.parent_plan);
|
|
615
|
+
|
|
616
|
+
if (!ticketPlanId || !archivedPlanIds.has(ticketPlanId)) continue;
|
|
617
|
+
|
|
618
|
+
const ticketId = frontmatter.id || file.replace('.md', '');
|
|
619
|
+
|
|
620
|
+
frontmatter.updated_at = new Date().toISOString();
|
|
621
|
+
frontmatter.archived_at = new Date().toISOString();
|
|
622
|
+
|
|
623
|
+
const destPath = path.join(ARCHIVE_DIR, file);
|
|
624
|
+
fs.writeFileSync(destPath, serializeFrontmatter(frontmatter) + body, 'utf8');
|
|
625
|
+
fs.unlinkSync(filePath);
|
|
626
|
+
|
|
627
|
+
archived.push(ticketId);
|
|
628
|
+
logger.info(`[ARCHIVE] ${ticketId}: done → archive (plan ${ticketPlanId} is archived)`);
|
|
629
|
+
} catch (e) {
|
|
630
|
+
logger.warn(`Failed to archive ticket ${file}: ${e.message}`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
return { archived };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Main entry point
|
|
638
|
+
async function main() {
|
|
639
|
+
const planId = extractPlanId();
|
|
640
|
+
|
|
641
|
+
if (planId) {
|
|
642
|
+
logger.info(`Filtering by plan_id: ${planId}`);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const configPath = path.join(WORKFLOW_DIR, 'config', 'ticket-movement-rules.yaml');
|
|
646
|
+
let movementConfig = null;
|
|
647
|
+
try {
|
|
648
|
+
movementConfig = loadTicketMovementRules(configPath);
|
|
649
|
+
logger.info('Loaded ticket movement rules from config');
|
|
650
|
+
} catch (e) {
|
|
651
|
+
logger.warn(`Failed to load ticket movement config: ${e.message}`);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
logger.info('Running auto-correction...');
|
|
655
|
+
const correctionResult = autoCorrectTickets(movementConfig);
|
|
656
|
+
if (correctionResult.moved.length > 0) {
|
|
657
|
+
logger.info(`Auto-corrected ${correctionResult.moved.length} ticket(s)`);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Архивируем done-тикеты архивных планов
|
|
661
|
+
const archiveResult = archiveTicketsOfArchivedPlans();
|
|
662
|
+
if (archiveResult.archived.length > 0) {
|
|
663
|
+
logger.info(`Archived ${archiveResult.archived.length} ticket(s) from archived plans: ${archiveResult.archived.join(', ')}`);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (planId) {
|
|
667
|
+
const closeResult = checkAndClosePlan(WORKFLOW_DIR, planId);
|
|
668
|
+
if (closeResult.closed) {
|
|
669
|
+
logger.info(`Plan ${planId} closed: all ${closeResult.total} tickets done`);
|
|
670
|
+
} else if (closeResult.total > 0) {
|
|
671
|
+
logger.info(`Plan ${planId} progress: ${closeResult.done}/${closeResult.total} tickets done`);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
logger.info(`Scanning ready/ directory: ${READY_DIR}`);
|
|
676
|
+
|
|
677
|
+
const result = pickNextTicket(planId);
|
|
678
|
+
|
|
679
|
+
if (result.status === 'found') {
|
|
680
|
+
logger.info(`Selected ticket: ${result.ticket_id} (${result.title})`);
|
|
681
|
+
logger.info(`Priority: ${result.priority}, Type: ${result.type}`);
|
|
682
|
+
} else {
|
|
683
|
+
logger.info(result.reason);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
logger.info('Calculating review metrics...');
|
|
687
|
+
const reviewMetrics = calculateReviewMetrics();
|
|
688
|
+
logger.info(`Found ${reviewMetrics.tickets_with_reviews} tickets with reviews`);
|
|
689
|
+
logger.info(`Total failed: ${reviewMetrics.total_failed}, passed: ${reviewMetrics.total_passed}`);
|
|
690
|
+
|
|
691
|
+
const metricsDir = path.join(WORKFLOW_DIR, 'metrics');
|
|
692
|
+
if (!fs.existsSync(metricsDir)) {
|
|
693
|
+
fs.mkdirSync(metricsDir, { recursive: true });
|
|
694
|
+
}
|
|
695
|
+
const metricsFile = path.join(metricsDir, 'review-metrics.json');
|
|
696
|
+
fs.writeFileSync(metricsFile, JSON.stringify(reviewMetrics, null, 2), 'utf8');
|
|
697
|
+
logger.info(`Metrics saved to ${metricsFile}`);
|
|
698
|
+
|
|
699
|
+
const finalResult = {
|
|
700
|
+
...result,
|
|
701
|
+
auto_corrected: correctionResult.moved.length,
|
|
702
|
+
moved_tickets: correctionResult.moved.map(m => m.id).join(','),
|
|
703
|
+
review_metrics: {
|
|
704
|
+
tickets_with_reviews: reviewMetrics.tickets_with_reviews,
|
|
705
|
+
total_failed: reviewMetrics.total_failed,
|
|
706
|
+
total_passed: reviewMetrics.total_passed,
|
|
707
|
+
avg_time_to_first_passed_days: reviewMetrics.avg_time_to_first_passed_days,
|
|
708
|
+
iterations_per_ticket: reviewMetrics.iterations_per_ticket
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
printResult(finalResult);
|
|
713
|
+
|
|
714
|
+
if (result.status === 'empty') {
|
|
715
|
+
process.exit(0);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
main().catch(e => {
|
|
720
|
+
logger.error(e.message);
|
|
721
|
+
printResult({ status: 'error', error: e.message });
|
|
722
|
+
process.exit(1);
|
|
723
|
+
});
|