workflow-ai 1.0.25 → 1.0.26
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/package.json +1 -1
- package/src/lib/logger.mjs +73 -0
- package/src/lib/utils.mjs +107 -10
- package/src/runner.mjs +17 -16
- package/src/scripts/pick-next-task.js +219 -126
- package/src/skills/decompose-gaps/SKILL.md +1 -6
- package/src/skills/decompose-plan/SKILL.md +1 -8
package/package.json
CHANGED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { findProjectRoot } from './find-root.mjs';
|
|
4
|
+
|
|
5
|
+
const LEVELS = {
|
|
6
|
+
DEBUG: -1,
|
|
7
|
+
INFO: 0,
|
|
8
|
+
WARN: 1,
|
|
9
|
+
ERROR: 2
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const COLORS = {
|
|
13
|
+
DEBUG: '\x1b[90m',
|
|
14
|
+
INFO: '\x1b[36m',
|
|
15
|
+
WARN: '\x1b[33m',
|
|
16
|
+
ERROR: '\x1b[31m',
|
|
17
|
+
RESET: '\x1b[0m'
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function formatTimestamp() {
|
|
21
|
+
const now = new Date();
|
|
22
|
+
return now.toISOString().replace('T', ' ').substring(0, 19);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function ensureLogDir(logFilePath) {
|
|
26
|
+
const logDir = path.dirname(logFilePath);
|
|
27
|
+
if (!fs.existsSync(logDir)) {
|
|
28
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function createLogger(logFilePath = null, consoleLevel = LEVELS.INFO) {
|
|
33
|
+
const projectRoot = findProjectRoot();
|
|
34
|
+
const defaultLogPath = path.join(projectRoot, '.workflow/logs/pipeline.log');
|
|
35
|
+
const logPath = logFilePath || defaultLogPath;
|
|
36
|
+
|
|
37
|
+
ensureLogDir(logPath);
|
|
38
|
+
|
|
39
|
+
function writeToFile(formatted) {
|
|
40
|
+
fs.appendFileSync(logPath, formatted + '\n', 'utf8');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function writeToConsole(formatted, level) {
|
|
44
|
+
if (LEVELS[level] < consoleLevel) return;
|
|
45
|
+
|
|
46
|
+
const color = COLORS[level];
|
|
47
|
+
const reset = COLORS.RESET;
|
|
48
|
+
|
|
49
|
+
if (level === 'ERROR') {
|
|
50
|
+
console.error(`${color}${formatted}${reset}`);
|
|
51
|
+
} else if (level === 'WARN') {
|
|
52
|
+
console.warn(`${color}${formatted}${reset}`);
|
|
53
|
+
} else {
|
|
54
|
+
console.log(`${color}${formatted}${reset}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function log(level, message) {
|
|
59
|
+
const timestamp = formatTimestamp();
|
|
60
|
+
const formatted = `[${timestamp}] [${level}] ${message}`;
|
|
61
|
+
writeToFile(formatted);
|
|
62
|
+
writeToConsole(formatted, level);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
debug: (message) => log('DEBUG', message),
|
|
67
|
+
info: (message) => log('INFO', message),
|
|
68
|
+
warn: (message) => log('WARN', message),
|
|
69
|
+
error: (message) => log('ERROR', message)
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export { createLogger, LEVELS };
|
package/src/lib/utils.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import YAML from 'js-yaml';
|
|
2
2
|
import { fileURLToPath } from 'url';
|
|
3
3
|
import path from 'path';
|
|
4
|
+
import fs from 'fs';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Парсит YAML frontmatter из markdown-файла.
|
|
@@ -116,17 +117,26 @@ export function getPackageRoot() {
|
|
|
116
117
|
export function getLastReviewStatus(content) {
|
|
117
118
|
if (!content) return null;
|
|
118
119
|
|
|
119
|
-
// Находим
|
|
120
|
-
const
|
|
121
|
-
|
|
120
|
+
// Находим последний заголовок H2 "## Ревью" (только строки начинающиеся с "## ")
|
|
121
|
+
const lines = content.split('\n');
|
|
122
|
+
let lastHeaderLineIndex = -1;
|
|
123
|
+
|
|
124
|
+
for (let i = 0; i < lines.length; i++) {
|
|
125
|
+
if (lines[i].startsWith('## ') && lines[i].includes('Ревью')) {
|
|
126
|
+
lastHeaderLineIndex = i;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
122
129
|
|
|
123
|
-
|
|
124
|
-
if (bodyStart === -1) return null;
|
|
130
|
+
if (lastHeaderLineIndex === -1) return null;
|
|
125
131
|
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
132
|
+
// Собираем содержимое после заголовка до следующего H2 заголовка
|
|
133
|
+
const reviewLines = [];
|
|
134
|
+
for (let i = lastHeaderLineIndex + 1; i < lines.length; i++) {
|
|
135
|
+
if (lines[i].startsWith('## ')) break; // следующий H2 заголовок
|
|
136
|
+
reviewLines.push(lines[i]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const reviewSection = reviewLines.join('\n').trim();
|
|
130
140
|
if (!reviewSection) return null;
|
|
131
141
|
|
|
132
142
|
// Пробуем распарсить табличный формат
|
|
@@ -154,9 +164,96 @@ export function getLastReviewStatus(content) {
|
|
|
154
164
|
if (listItems.length > 0) {
|
|
155
165
|
// Последний элемент списка = самое свежее ревью (записи ведутся хронологически)
|
|
156
166
|
const latestItem = listItems[listItems.length - 1].trim();
|
|
157
|
-
const statusMatch = latestItem.match(/:\s*(passed|failed)\b/i);
|
|
167
|
+
const statusMatch = latestItem.match(/:\s*(passed|failed|skipped)\b/i);
|
|
158
168
|
if (statusMatch) return statusMatch[1].toLowerCase();
|
|
159
169
|
}
|
|
160
170
|
|
|
161
171
|
return null;
|
|
162
172
|
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Загружает конфигурацию правил перемещения тикетов.
|
|
176
|
+
*
|
|
177
|
+
* @param {string} configPath - Путь к конфигурационному файлу
|
|
178
|
+
* @returns {object} Объект конфигурации с правилами
|
|
179
|
+
*/
|
|
180
|
+
export function loadTicketMovementRules(configPath) {
|
|
181
|
+
if (!fs.existsSync(configPath)) {
|
|
182
|
+
throw new Error(`Config file not found: ${configPath}`);
|
|
183
|
+
}
|
|
184
|
+
const content = fs.readFileSync(configPath, 'utf8');
|
|
185
|
+
return YAML.load(content);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Проверяет все тикеты плана и закрывает его если все выполнены.
|
|
190
|
+
*
|
|
191
|
+
* @param {string} workflowDir - Путь к директории .workflow/
|
|
192
|
+
* @param {string} planId - Нормализованный ID плана (например "PLAN-002")
|
|
193
|
+
* @returns {{ closed: boolean, reason: string, total: number, done: number }}
|
|
194
|
+
*/
|
|
195
|
+
export function checkAndClosePlan(workflowDir, planId) {
|
|
196
|
+
if (!workflowDir || !planId) {
|
|
197
|
+
return { closed: false, reason: 'Missing workflowDir or planId', total: 0, done: 0 };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const ticketsDir = path.join(workflowDir, 'tickets');
|
|
201
|
+
const allDirNames = ['backlog', 'ready', 'in-progress', 'blocked', 'review', 'done'];
|
|
202
|
+
const allTickets = [];
|
|
203
|
+
|
|
204
|
+
for (const dirName of allDirNames) {
|
|
205
|
+
const dir = path.join(ticketsDir, dirName);
|
|
206
|
+
if (!fs.existsSync(dir)) continue;
|
|
207
|
+
|
|
208
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.md') && f !== '.gitkeep.md');
|
|
209
|
+
for (const file of files) {
|
|
210
|
+
try {
|
|
211
|
+
const content = fs.readFileSync(path.join(dir, file), 'utf8');
|
|
212
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
213
|
+
if (normalizePlanId(frontmatter.parent_plan) === planId) {
|
|
214
|
+
allTickets.push({ id: frontmatter.id || file.replace('.md', ''), dir: dirName });
|
|
215
|
+
}
|
|
216
|
+
} catch (_) { /* skip malformed */ }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const total = allTickets.length;
|
|
221
|
+
const done = allTickets.filter(t => t.dir === 'done').length;
|
|
222
|
+
|
|
223
|
+
if (total === 0) {
|
|
224
|
+
return { closed: false, reason: 'No tickets found for plan', total, done };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (done < total) {
|
|
228
|
+
return { closed: false, reason: `${done}/${total} tickets done`, total, done };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const plansDir = path.join(workflowDir, 'plans', 'current');
|
|
232
|
+
if (!fs.existsSync(plansDir)) {
|
|
233
|
+
return { closed: false, reason: 'Plans directory not found', total, done };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const planFile = fs.readdirSync(plansDir)
|
|
237
|
+
.filter(f => f.endsWith('.md'))
|
|
238
|
+
.find(f => normalizePlanId(f) === planId);
|
|
239
|
+
|
|
240
|
+
if (!planFile) {
|
|
241
|
+
return { closed: false, reason: 'Plan file not found', total, done };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const planPath = path.join(plansDir, planFile);
|
|
245
|
+
const planContent = fs.readFileSync(planPath, 'utf8');
|
|
246
|
+
const { frontmatter, body } = parseFrontmatter(planContent);
|
|
247
|
+
|
|
248
|
+
if (frontmatter.status === 'completed') {
|
|
249
|
+
return { closed: false, reason: 'Plan already completed', total, done };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
frontmatter.status = 'completed';
|
|
253
|
+
frontmatter.completed_at = new Date().toISOString();
|
|
254
|
+
frontmatter.updated_at = new Date().toISOString();
|
|
255
|
+
|
|
256
|
+
fs.writeFileSync(planPath, serializeFrontmatter(frontmatter) + body, 'utf8');
|
|
257
|
+
|
|
258
|
+
return { closed: true, reason: 'All tickets done', total, done };
|
|
259
|
+
}
|
package/src/runner.mjs
CHANGED
|
@@ -8,16 +8,18 @@ import yaml from 'js-yaml';
|
|
|
8
8
|
import { findProjectRoot } from './lib/find-root.mjs';
|
|
9
9
|
|
|
10
10
|
// ============================================================================
|
|
11
|
-
// Logger — система логирования с уровнями INFO/WARN/ERROR
|
|
11
|
+
// Logger — система логирования с уровнями DEBUG/INFO/WARN/ERROR
|
|
12
12
|
// ============================================================================
|
|
13
13
|
class Logger {
|
|
14
14
|
static LEVELS = {
|
|
15
|
+
DEBUG: -1,
|
|
15
16
|
INFO: 0,
|
|
16
17
|
WARN: 1,
|
|
17
18
|
ERROR: 2
|
|
18
19
|
};
|
|
19
20
|
|
|
20
21
|
static COLORS = {
|
|
22
|
+
DEBUG: '\x1b[90m', // gray
|
|
21
23
|
INFO: '\x1b[36m', // cyan
|
|
22
24
|
WARN: '\x1b[33m', // yellow
|
|
23
25
|
ERROR: '\x1b[31m', // red
|
|
@@ -28,6 +30,7 @@ class Logger {
|
|
|
28
30
|
this.logFilePath = logFilePath;
|
|
29
31
|
this.consoleLevel = consoleLevel;
|
|
30
32
|
this.stats = {
|
|
33
|
+
debug: 0,
|
|
31
34
|
info: 0,
|
|
32
35
|
warn: 0,
|
|
33
36
|
error: 0,
|
|
@@ -53,17 +56,6 @@ class Logger {
|
|
|
53
56
|
}
|
|
54
57
|
}
|
|
55
58
|
|
|
56
|
-
/**
|
|
57
|
-
* Создаёт директорию для логов если она не существует
|
|
58
|
-
*/
|
|
59
|
-
_ensureLogDirectory() {
|
|
60
|
-
const logDir = path.dirname(this.logFilePath);
|
|
61
|
-
if (!fs.existsSync(logDir)) {
|
|
62
|
-
fs.mkdirSync(logDir, { recursive: true });
|
|
63
|
-
console.log(`[Logger] Created log directory: ${logDir}`);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
59
|
/**
|
|
68
60
|
* Открывает файл для записи (append mode)
|
|
69
61
|
*/
|
|
@@ -142,7 +134,8 @@ class Logger {
|
|
|
142
134
|
this._writeToConsole(formattedMessage, level);
|
|
143
135
|
|
|
144
136
|
// Обновляем статистику
|
|
145
|
-
if (level === '
|
|
137
|
+
if (level === 'DEBUG') this.stats.debug++;
|
|
138
|
+
else if (level === 'INFO') this.stats.info++;
|
|
146
139
|
else if (level === 'WARN') this.stats.warn++;
|
|
147
140
|
else if (level === 'ERROR') this.stats.error++;
|
|
148
141
|
}
|
|
@@ -168,6 +161,13 @@ class Logger {
|
|
|
168
161
|
this._log('ERROR', stage, message);
|
|
169
162
|
}
|
|
170
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Логгирует DEBUG сообщение
|
|
166
|
+
*/
|
|
167
|
+
debug(message, stage) {
|
|
168
|
+
this._log('DEBUG', stage, message);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
171
|
/**
|
|
172
172
|
* Логгирует старт stage
|
|
173
173
|
*/
|
|
@@ -242,9 +242,10 @@ class Logger {
|
|
|
242
242
|
'┌─────────────────────────────────────────────────────────┐',
|
|
243
243
|
'│ LOG STATISTICS │',
|
|
244
244
|
'├─────────────────────────────────────────────────────────┤',
|
|
245
|
-
`│
|
|
246
|
-
`│
|
|
247
|
-
`│
|
|
245
|
+
`│ DEBUG messages: ${String(this.stats.debug).padEnd(34)}│`,
|
|
246
|
+
`│ INFO messages: ${String(this.stats.info).padEnd(34)}│`,
|
|
247
|
+
`│ WARN messages: ${String(this.stats.warn).padEnd(34)}│`,
|
|
248
|
+
`│ ERROR messages: ${String(this.stats.error).padEnd(34)}│`,
|
|
248
249
|
'├─────────────────────────────────────────────────────────┤',
|
|
249
250
|
'│ STAGE STATISTICS │',
|
|
250
251
|
'├─────────────────────────────────────────────────────────┤',
|
|
@@ -21,7 +21,10 @@
|
|
|
21
21
|
import fs from 'fs';
|
|
22
22
|
import path from 'path';
|
|
23
23
|
import { findProjectRoot } from '../lib/find-root.mjs';
|
|
24
|
-
import { parseFrontmatter, printResult, normalizePlanId, extractPlanId, getLastReviewStatus, serializeFrontmatter } from '../lib/utils.mjs';
|
|
24
|
+
import { parseFrontmatter, printResult, normalizePlanId, extractPlanId, getLastReviewStatus, serializeFrontmatter, loadTicketMovementRules, checkAndClosePlan } from '../lib/utils.mjs';
|
|
25
|
+
import { createLogger } from '../lib/logger.mjs';
|
|
26
|
+
|
|
27
|
+
const logger = createLogger();
|
|
25
28
|
|
|
26
29
|
// Корень проекта
|
|
27
30
|
const PROJECT_DIR = findProjectRoot();
|
|
@@ -72,12 +75,133 @@ function checkCondition(condition) {
|
|
|
72
75
|
return false;
|
|
73
76
|
|
|
74
77
|
default:
|
|
75
|
-
|
|
76
|
-
console.error(`[WARN] Unknown condition type: ${type}`);
|
|
78
|
+
logger.warn(`Unknown condition type: ${type}`);
|
|
77
79
|
return true;
|
|
78
80
|
}
|
|
79
81
|
}
|
|
80
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Парсит секцию "## Ревью" тикета и возвращает все записи ревью.
|
|
85
|
+
* @param {string} content - Содержимое тикета
|
|
86
|
+
* @returns {Array<{date: string, status: string, comment: string}>}
|
|
87
|
+
*/
|
|
88
|
+
function parseReviewSection(content) {
|
|
89
|
+
if (!content) return [];
|
|
90
|
+
|
|
91
|
+
const headerIdx = content.search(/^##\s*Ревью\s*$/m);
|
|
92
|
+
if (headerIdx === -1) return [];
|
|
93
|
+
|
|
94
|
+
const bodyStart = content.indexOf('\n', headerIdx);
|
|
95
|
+
if (bodyStart === -1) return [];
|
|
96
|
+
|
|
97
|
+
const nextH2 = content.indexOf('\n## ', bodyStart);
|
|
98
|
+
const reviewSection = (nextH2 === -1
|
|
99
|
+
? content.slice(bodyStart + 1)
|
|
100
|
+
: content.slice(bodyStart + 1, nextH2)).trim();
|
|
101
|
+
|
|
102
|
+
const reviews = [];
|
|
103
|
+
|
|
104
|
+
const tableRows = reviewSection.split('\n').filter(line => line.trim().startsWith('|'));
|
|
105
|
+
if (tableRows.length >= 2) {
|
|
106
|
+
const dataRows = tableRows.slice(2).filter(row => {
|
|
107
|
+
const cells = row.split('|').map(c => c.trim()).filter(c => c);
|
|
108
|
+
return cells.length >= 2;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
for (const row of dataRows) {
|
|
112
|
+
const cells = row.split('|').map(c => c.trim()).filter(c => c);
|
|
113
|
+
const date = cells[0] || '';
|
|
114
|
+
const statusRaw = cells[1]?.toLowerCase() || '';
|
|
115
|
+
const comment = cells[2] || '';
|
|
116
|
+
let status = null;
|
|
117
|
+
if (statusRaw.includes('passed')) status = 'passed';
|
|
118
|
+
else if (statusRaw.includes('failed')) status = 'failed';
|
|
119
|
+
else if (statusRaw.includes('skipped')) status = 'skipped';
|
|
120
|
+
|
|
121
|
+
if (status) {
|
|
122
|
+
reviews.push({ date, status, comment });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const listItems = reviewSection.split('\n').filter(line => line.trim().match(/^[-*]\s/));
|
|
128
|
+
for (const item of listItems) {
|
|
129
|
+
const trimmed = item.trim();
|
|
130
|
+
const dateMatch = trimmed.match(/^[-*]\s*(\d{4}-\d{2}-\d{2})/);
|
|
131
|
+
const statusMatch = trimmed.match(/:\s*(passed|failed|skipped)\b/i);
|
|
132
|
+
if (dateMatch && statusMatch) {
|
|
133
|
+
reviews.push({
|
|
134
|
+
date: dateMatch[1],
|
|
135
|
+
status: statusMatch[1].toLowerCase(),
|
|
136
|
+
comment: trimmed.replace(/^[-*]\s*\d{4}-\d{2}-\d{2}:\s*(passed|failed|skipped)\b/i, '').trim()
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return reviews;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Вычисляет метрики ревью-итераций для всех тикетов
|
|
146
|
+
* @returns {object} Метрики: iterations, avgTimeToFirstPassed, failedVsPassed
|
|
147
|
+
*/
|
|
148
|
+
function calculateReviewMetrics() {
|
|
149
|
+
const allDirs = [BACKLOG_DIR, READY_DIR, IN_PROGRESS_DIR, BLOCKED_DIR, REVIEW_DIR, DONE_DIR];
|
|
150
|
+
const ticketMetrics = {};
|
|
151
|
+
let totalFailed = 0;
|
|
152
|
+
let totalPassed = 0;
|
|
153
|
+
let firstPassedTimes = [];
|
|
154
|
+
|
|
155
|
+
for (const dir of allDirs) {
|
|
156
|
+
if (!fs.existsSync(dir)) continue;
|
|
157
|
+
|
|
158
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.md') && f !== '.gitkeep.md');
|
|
159
|
+
|
|
160
|
+
for (const file of files) {
|
|
161
|
+
const filePath = path.join(dir, file);
|
|
162
|
+
try {
|
|
163
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
164
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
165
|
+
const ticketId = frontmatter.id || file.replace('.md', '');
|
|
166
|
+
|
|
167
|
+
const reviews = parseReviewSection(content);
|
|
168
|
+
if (reviews.length === 0) continue;
|
|
169
|
+
|
|
170
|
+
ticketMetrics[ticketId] = reviews.length;
|
|
171
|
+
|
|
172
|
+
for (const review of reviews) {
|
|
173
|
+
if (review.status === 'failed') totalFailed++;
|
|
174
|
+
else if (review.status === 'passed') totalPassed++;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const firstPassed = reviews.find(r => r.status === 'passed');
|
|
178
|
+
if (firstPassed && firstPassed.date) {
|
|
179
|
+
const ticketCreated = new Date(frontmatter.created_at || '1970-01-01');
|
|
180
|
+
const passedDate = new Date(firstPassed.date);
|
|
181
|
+
const daysToPass = Math.floor((passedDate - ticketCreated) / (1000 * 60 * 60 * 24));
|
|
182
|
+
if (daysToPass >= 0) {
|
|
183
|
+
firstPassedTimes.push(daysToPass);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
} catch (e) {
|
|
187
|
+
// Skip errors
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const avgTimeToFirstPassed = firstPassedTimes.length > 0
|
|
193
|
+
? Math.round(firstPassedTimes.reduce((a, b) => a + b, 0) / firstPassedTimes.length)
|
|
194
|
+
: null;
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
iterations_per_ticket: ticketMetrics,
|
|
198
|
+
total_failed: totalFailed,
|
|
199
|
+
total_passed: totalPassed,
|
|
200
|
+
avg_time_to_first_passed_days: avgTimeToFirstPassed,
|
|
201
|
+
tickets_with_reviews: Object.keys(ticketMetrics).length
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
81
205
|
/**
|
|
82
206
|
* Проверяет зависимости тикета
|
|
83
207
|
*/
|
|
@@ -94,19 +218,23 @@ function checkDependencies(dependencies) {
|
|
|
94
218
|
|
|
95
219
|
/**
|
|
96
220
|
* Авто-коррекция тикетов на основе статуса ревью.
|
|
97
|
-
* Сканирует все директории и перемещает тикеты по
|
|
98
|
-
* - blocked → done (если review = passed)
|
|
99
|
-
* - blocked → backlog (если review = failed, НЕ при null)
|
|
100
|
-
* - done → backlog (если review = failed)
|
|
101
|
-
* - review → done (если review = passed)
|
|
102
|
-
* - in-progress → done (если review = passed)
|
|
103
|
-
* - ready → done (если review = passed)
|
|
221
|
+
* Сканирует все директории и перемещает тикеты по правилам из конфига.
|
|
104
222
|
*
|
|
223
|
+
* @param {object} config - Конфигурация правил перемещения
|
|
105
224
|
* @returns {object} Результат: { moved: Array<{id, from, to, reason}> }
|
|
106
225
|
*/
|
|
107
|
-
function autoCorrectTickets() {
|
|
226
|
+
function autoCorrectTickets(config) {
|
|
108
227
|
const moved = [];
|
|
109
228
|
|
|
229
|
+
const dirMap = {
|
|
230
|
+
backlog: BACKLOG_DIR,
|
|
231
|
+
ready: READY_DIR,
|
|
232
|
+
in_progress: IN_PROGRESS_DIR,
|
|
233
|
+
blocked: BLOCKED_DIR,
|
|
234
|
+
review: REVIEW_DIR,
|
|
235
|
+
done: DONE_DIR
|
|
236
|
+
};
|
|
237
|
+
|
|
110
238
|
/**
|
|
111
239
|
* Перемещает тикет из одной директории в другую
|
|
112
240
|
*/
|
|
@@ -119,23 +247,18 @@ function autoCorrectTickets() {
|
|
|
119
247
|
}
|
|
120
248
|
|
|
121
249
|
try {
|
|
122
|
-
// Читаем содержимое
|
|
123
250
|
const content = fs.readFileSync(fromPath, 'utf8');
|
|
124
251
|
const { frontmatter, body } = parseFrontmatter(content);
|
|
125
252
|
|
|
126
|
-
// Обновляем updated_at
|
|
127
253
|
frontmatter.updated_at = new Date().toISOString();
|
|
128
254
|
|
|
129
|
-
// Если перемещаем в done — ставим completed_at
|
|
130
255
|
if (toDir === DONE_DIR && !frontmatter.completed_at) {
|
|
131
256
|
frontmatter.completed_at = new Date().toISOString();
|
|
132
257
|
}
|
|
133
258
|
|
|
134
|
-
// Сериализуем и записываем в новую директорию
|
|
135
259
|
const newContent = serializeFrontmatter(frontmatter) + body;
|
|
136
260
|
fs.writeFileSync(toPath, newContent, 'utf8');
|
|
137
261
|
|
|
138
|
-
// Удаляем старый файл
|
|
139
262
|
fs.unlinkSync(fromPath);
|
|
140
263
|
|
|
141
264
|
console.log(`[AUTO-CORRECT] ${ticketId}: ${path.basename(fromDir)} → ${path.basename(toDir)} (${reason})`);
|
|
@@ -149,7 +272,7 @@ function autoCorrectTickets() {
|
|
|
149
272
|
|
|
150
273
|
return true;
|
|
151
274
|
} catch (e) {
|
|
152
|
-
|
|
275
|
+
logger.error(`Failed to move ticket ${ticketId}: ${e.message}`);
|
|
153
276
|
return false;
|
|
154
277
|
}
|
|
155
278
|
}
|
|
@@ -157,7 +280,7 @@ function autoCorrectTickets() {
|
|
|
157
280
|
/**
|
|
158
281
|
* Обрабатывает тикеты в указанной директории
|
|
159
282
|
*/
|
|
160
|
-
function processDirectory(dir, rules) {
|
|
283
|
+
function processDirectory(dir, rules, dirName) {
|
|
161
284
|
if (!fs.existsSync(dir)) return;
|
|
162
285
|
|
|
163
286
|
const files = fs.readdirSync(dir)
|
|
@@ -170,112 +293,46 @@ function autoCorrectTickets() {
|
|
|
170
293
|
const { frontmatter } = parseFrontmatter(content);
|
|
171
294
|
const ticketId = frontmatter.id || file.replace('.md', '');
|
|
172
295
|
|
|
173
|
-
// Получаем статус ревью
|
|
174
296
|
const reviewStatus = getLastReviewStatus(content);
|
|
175
297
|
|
|
176
|
-
// Применяем правила
|
|
177
298
|
for (const rule of rules) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
299
|
+
const ruleCondition = rule.condition;
|
|
300
|
+
let shouldMove = false;
|
|
301
|
+
|
|
302
|
+
if (ruleCondition === null) {
|
|
303
|
+
shouldMove = reviewStatus === null;
|
|
304
|
+
} else {
|
|
305
|
+
shouldMove = reviewStatus === ruleCondition;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (shouldMove) {
|
|
309
|
+
const targetDirName = rule.to_dir;
|
|
310
|
+
const targetDir = dirMap[targetDirName];
|
|
311
|
+
if (targetDir) {
|
|
312
|
+
moveTicket(ticketId, dir, targetDir, rule.reason);
|
|
313
|
+
}
|
|
314
|
+
break;
|
|
181
315
|
}
|
|
182
316
|
}
|
|
183
317
|
} catch (e) {
|
|
184
|
-
|
|
318
|
+
logger.warn(`Failed to process ticket ${file}: ${e.message}`);
|
|
185
319
|
}
|
|
186
320
|
}
|
|
187
321
|
}
|
|
188
322
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
{
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
]);
|
|
202
|
-
|
|
203
|
-
// Правила для blocked/
|
|
204
|
-
processDirectory(BLOCKED_DIR, [
|
|
205
|
-
{
|
|
206
|
-
condition: (status) => status === 'passed',
|
|
207
|
-
toDir: DONE_DIR,
|
|
208
|
-
reason: 'review passed'
|
|
209
|
-
},
|
|
210
|
-
{
|
|
211
|
-
condition: (status) => status === 'skipped',
|
|
212
|
-
toDir: DONE_DIR,
|
|
213
|
-
reason: 'review skipped'
|
|
214
|
-
},
|
|
215
|
-
{
|
|
216
|
-
condition: (status) => status === 'failed',
|
|
217
|
-
toDir: BACKLOG_DIR,
|
|
218
|
-
reason: 'review failed'
|
|
219
|
-
}
|
|
220
|
-
// null (нет ревью) — не перемещаем
|
|
221
|
-
]);
|
|
222
|
-
|
|
223
|
-
// Правила для done/
|
|
224
|
-
processDirectory(DONE_DIR, [
|
|
225
|
-
{
|
|
226
|
-
condition: (status) => status === 'failed',
|
|
227
|
-
toDir: BACKLOG_DIR,
|
|
228
|
-
reason: 'review failed'
|
|
229
|
-
},
|
|
230
|
-
{
|
|
231
|
-
condition: (status) => status === null,
|
|
232
|
-
toDir: BACKLOG_DIR,
|
|
233
|
-
reason: 'no review'
|
|
234
|
-
}
|
|
235
|
-
// passed или skipped — не перемещаем
|
|
236
|
-
]);
|
|
237
|
-
|
|
238
|
-
// Правила для review/
|
|
239
|
-
processDirectory(REVIEW_DIR, [
|
|
240
|
-
{
|
|
241
|
-
condition: (status) => status === 'passed',
|
|
242
|
-
toDir: DONE_DIR,
|
|
243
|
-
reason: 'review passed'
|
|
244
|
-
},
|
|
245
|
-
{
|
|
246
|
-
condition: (status) => status === 'skipped',
|
|
247
|
-
toDir: DONE_DIR,
|
|
248
|
-
reason: 'review skipped'
|
|
249
|
-
}
|
|
250
|
-
]);
|
|
251
|
-
|
|
252
|
-
// Правила для in-progress/
|
|
253
|
-
processDirectory(IN_PROGRESS_DIR, [
|
|
254
|
-
{
|
|
255
|
-
condition: (status) => status === 'passed',
|
|
256
|
-
toDir: DONE_DIR,
|
|
257
|
-
reason: 'review passed'
|
|
258
|
-
},
|
|
259
|
-
{
|
|
260
|
-
condition: (status) => status === 'skipped',
|
|
261
|
-
toDir: DONE_DIR,
|
|
262
|
-
reason: 'review skipped'
|
|
263
|
-
}
|
|
264
|
-
]);
|
|
265
|
-
|
|
266
|
-
// Правила для ready/
|
|
267
|
-
processDirectory(READY_DIR, [
|
|
268
|
-
{
|
|
269
|
-
condition: (status) => status === 'passed',
|
|
270
|
-
toDir: DONE_DIR,
|
|
271
|
-
reason: 'review passed'
|
|
272
|
-
},
|
|
273
|
-
{
|
|
274
|
-
condition: (status) => status === 'skipped',
|
|
275
|
-
toDir: DONE_DIR,
|
|
276
|
-
reason: 'review skipped'
|
|
323
|
+
if (!config || !config.rules) {
|
|
324
|
+
logger.error('Ticket movement rules config not loaded');
|
|
325
|
+
return { moved };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const rulesConfig = config.rules;
|
|
329
|
+
|
|
330
|
+
for (const [dirName, rules] of Object.entries(rulesConfig)) {
|
|
331
|
+
const dir = dirMap[dirName];
|
|
332
|
+
if (dir) {
|
|
333
|
+
processDirectory(dir, rules, dirName);
|
|
277
334
|
}
|
|
278
|
-
|
|
335
|
+
}
|
|
279
336
|
|
|
280
337
|
return { moved };
|
|
281
338
|
}
|
|
@@ -438,7 +495,7 @@ function pickNextTicket(planId) {
|
|
|
438
495
|
const completedInProgress = filterByPlan(findCompletedInProgress(), planId);
|
|
439
496
|
if (completedInProgress.length > 0) {
|
|
440
497
|
const first = completedInProgress[0];
|
|
441
|
-
|
|
498
|
+
logger.info(`Found completed ticket in in-progress/: ${first.id}`);
|
|
442
499
|
return {
|
|
443
500
|
status: 'completed_in_progress',
|
|
444
501
|
ticket_id: first.id
|
|
@@ -517,32 +574,68 @@ async function main() {
|
|
|
517
574
|
const planId = extractPlanId();
|
|
518
575
|
|
|
519
576
|
if (planId) {
|
|
520
|
-
|
|
577
|
+
logger.info(`Filtering by plan_id: ${planId}`);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const configPath = path.join(WORKFLOW_DIR, 'config', 'ticket-movement-rules.yaml');
|
|
581
|
+
let movementConfig = null;
|
|
582
|
+
try {
|
|
583
|
+
movementConfig = loadTicketMovementRules(configPath);
|
|
584
|
+
logger.info('Loaded ticket movement rules from config');
|
|
585
|
+
} catch (e) {
|
|
586
|
+
logger.warn(`Failed to load ticket movement config: ${e.message}`);
|
|
521
587
|
}
|
|
522
588
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
const correctionResult = autoCorrectTickets();
|
|
589
|
+
logger.info('Running auto-correction...');
|
|
590
|
+
const correctionResult = autoCorrectTickets(movementConfig);
|
|
526
591
|
if (correctionResult.moved.length > 0) {
|
|
527
|
-
|
|
592
|
+
logger.info(`Auto-corrected ${correctionResult.moved.length} ticket(s)`);
|
|
528
593
|
}
|
|
529
594
|
|
|
530
|
-
|
|
595
|
+
if (planId) {
|
|
596
|
+
const closeResult = checkAndClosePlan(WORKFLOW_DIR, planId);
|
|
597
|
+
if (closeResult.closed) {
|
|
598
|
+
logger.info(`Plan ${planId} closed: all ${closeResult.total} tickets done`);
|
|
599
|
+
} else if (closeResult.total > 0) {
|
|
600
|
+
logger.info(`Plan ${planId} progress: ${closeResult.done}/${closeResult.total} tickets done`);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
logger.info(`Scanning ready/ directory: ${READY_DIR}`);
|
|
531
605
|
|
|
532
606
|
const result = pickNextTicket(planId);
|
|
533
607
|
|
|
534
608
|
if (result.status === 'found') {
|
|
535
|
-
|
|
536
|
-
|
|
609
|
+
logger.info(`Selected ticket: ${result.ticket_id} (${result.title})`);
|
|
610
|
+
logger.info(`Priority: ${result.priority}, Type: ${result.type}`);
|
|
537
611
|
} else {
|
|
538
|
-
|
|
612
|
+
logger.info(result.reason);
|
|
539
613
|
}
|
|
540
614
|
|
|
541
|
-
|
|
615
|
+
logger.info('Calculating review metrics...');
|
|
616
|
+
const reviewMetrics = calculateReviewMetrics();
|
|
617
|
+
logger.info(`Found ${reviewMetrics.tickets_with_reviews} tickets with reviews`);
|
|
618
|
+
logger.info(`Total failed: ${reviewMetrics.total_failed}, passed: ${reviewMetrics.total_passed}`);
|
|
619
|
+
|
|
620
|
+
const metricsDir = path.join(WORKFLOW_DIR, 'metrics');
|
|
621
|
+
if (!fs.existsSync(metricsDir)) {
|
|
622
|
+
fs.mkdirSync(metricsDir, { recursive: true });
|
|
623
|
+
}
|
|
624
|
+
const metricsFile = path.join(metricsDir, 'review-metrics.json');
|
|
625
|
+
fs.writeFileSync(metricsFile, JSON.stringify(reviewMetrics, null, 2), 'utf8');
|
|
626
|
+
logger.info(`Metrics saved to ${metricsFile}`);
|
|
627
|
+
|
|
542
628
|
const finalResult = {
|
|
543
629
|
...result,
|
|
544
630
|
auto_corrected: correctionResult.moved.length,
|
|
545
|
-
moved_tickets: correctionResult.moved.map(m => m.id).join(',')
|
|
631
|
+
moved_tickets: correctionResult.moved.map(m => m.id).join(','),
|
|
632
|
+
review_metrics: {
|
|
633
|
+
tickets_with_reviews: reviewMetrics.tickets_with_reviews,
|
|
634
|
+
total_failed: reviewMetrics.total_failed,
|
|
635
|
+
total_passed: reviewMetrics.total_passed,
|
|
636
|
+
avg_time_to_first_passed_days: reviewMetrics.avg_time_to_first_passed_days,
|
|
637
|
+
iterations_per_ticket: reviewMetrics.iterations_per_ticket
|
|
638
|
+
}
|
|
546
639
|
};
|
|
547
640
|
|
|
548
641
|
printResult(finalResult);
|
|
@@ -553,7 +646,7 @@ async function main() {
|
|
|
553
646
|
}
|
|
554
647
|
|
|
555
648
|
main().catch(e => {
|
|
556
|
-
|
|
649
|
+
logger.error(e.message);
|
|
557
650
|
printResult({ status: 'error', error: e.message });
|
|
558
651
|
process.exit(1);
|
|
559
652
|
});
|
|
@@ -63,12 +63,7 @@ description: Декомпозировать недочёты (gaps) из ана
|
|
|
63
63
|
|
|
64
64
|
### 5. Определить тип каждого тикета
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|-----|---------|-------------------|
|
|
68
|
-
| Исправление | FIX | Результат не соответствует ожиданиям |
|
|
69
|
-
| Реализация | IMPL | Задача была пропущена |
|
|
70
|
-
| Ревью | REVIEW | Нужна дополнительная проверка |
|
|
71
|
-
| Документация | DOCS | Не хватает документации |
|
|
66
|
+
Прочитай актуальные типы задач из `.workflow/config/config.yaml` (секция `task_types`). Используй **только** префиксы, определённые в конфиге. Не используй типы, которых нет в конфиге.
|
|
72
67
|
|
|
73
68
|
### 6. Назначить приоритеты
|
|
74
69
|
|
|
@@ -39,14 +39,7 @@ description: Декомпозировать план на исполняемые
|
|
|
39
39
|
|
|
40
40
|
### 3. Определить тип каждого тикета
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|-----|---------|----------|
|
|
44
|
-
| Архитектура | ARCH | Создание планов, архитектуры |
|
|
45
|
-
| Реализация | IMPL | Написание кода |
|
|
46
|
-
| Исправление | FIX | Багфиксы |
|
|
47
|
-
| Ревью | REVIEW | Проверка кода/документации |
|
|
48
|
-
| Документация | DOCS | Написание документации |
|
|
49
|
-
| Администрирование | ADMIN | Настройка, конфигурация |
|
|
42
|
+
Прочитай актуальные типы задач из `.workflow/config/config.yaml` (секция `task_types`). Используй **только** префиксы, определённые в конфиге. Не используй типы, которых нет в конфиге.
|
|
50
43
|
|
|
51
44
|
### 4. Определить зависимости
|
|
52
45
|
|