@rt-tools/agent-kit 0.12.0 → 0.14.0
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/assets/checks/board-runs.github.mjs +132 -0
- package/assets/checks/board.github.mjs +43 -41
- package/assets/checks/check-board.github.mjs +73 -8
- package/assets/checks/check-schema-drift.mjs +28 -5
- package/assets/checks/rt-kit-checks.config.mjs +12 -0
- package/assets/checks/task-new.github.mjs +15 -3
- package/assets/commands/feedback.md +8 -0
- package/assets/defaults/project.sh +50 -5
- package/assets/hooks/browser-guard-no-other-drivers.sh +1 -1
- package/assets/hooks/claim-guard.sh +34 -0
- package/assets/hooks/dev-server-guard.sh +1 -1
- package/assets/hooks/dispatch.sh +48 -15
- package/assets/hooks/git-guard-delivery-folder.sh +99 -0
- package/assets/hooks/git-guard-delivery-signature.sh +10 -4
- package/assets/hooks/git-guard-delivery.sh +21 -69
- package/assets/hooks/git-guard-push-tests.sh +2 -2
- package/assets/hooks/hook-input.sh +62 -0
- package/assets/hooks/override-write-guard.sh +107 -0
- package/assets/hooks/rerun-guard.sh +1 -1
- package/assets/hooks/rule-source-guard.sh +126 -0
- package/assets/hooks/task-flow-guard.sh +18 -0
- package/assets/hooks/turn-exit-guard.sh +33 -1
- package/assets/hooks/waiting-turn-guard.sh +39 -3
- package/assets/patterns/git-workflow-pr.azure.md +4 -4
- package/assets/patterns/git-workflow-pr.github.md +4 -4
- package/assets/patterns/git-workflow-pr.gitlab.md +4 -4
- package/assets/patterns/task-flow-archive.md +23 -21
- package/assets/patterns/task-flow-close.md +98 -96
- package/assets/patterns/task-flow-resume.md +5 -3
- package/assets/pitfalls/agent-kit.md +80 -0
- package/assets/pitfalls/git-workflow.github.md +8 -0
- package/assets/rules/deploy-flow.azure.md +15 -7
- package/assets/rules/deploy-flow.github.md +15 -6
- package/assets/rules/deploy-flow.gitlab.md +15 -7
- package/assets/rules/git-workflow.github.md +10 -5
- package/assets/rules/task-flow.md +44 -26
- package/assets/rules/turn-conduct.md +33 -0
- package/assets/skills/agent-kit.md +32 -77
- package/assets/templates/proposal.md +16 -1
- package/lib/proposals.d.ts +5 -1
- package/lib/proposals.d.ts.map +1 -1
- package/lib/proposals.js +74 -5
- package/lib/proposals.js.map +1 -1
- package/lib/shipment.d.ts.map +1 -1
- package/lib/shipment.fixture.d.ts +39 -0
- package/lib/shipment.fixture.d.ts.map +1 -0
- package/lib/shipment.fixture.js +99 -0
- package/lib/shipment.fixture.js.map +1 -0
- package/lib/shipment.js +59 -7
- package/lib/shipment.js.map +1 -1
- package/package.json +1 -1
- package/rt-tools-agent-kit-0.14.0.tgz +0 -0
- package/rt-tools-agent-kit-0.12.0.tgz +0 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Состояние прогонов и выкатки у хостинга: что встало на вершине, чем кончилось и на сколько
|
|
3
|
+
* прод отстал от главной ветки.
|
|
4
|
+
*
|
|
5
|
+
* Отдельным файлом, а не внутри работы с очередью: у очереди свой предмет — задачи, колонки и
|
|
6
|
+
* заявки, — а здесь спрашивают конвейер. Вместе они переросли предел длины файла, и делить их
|
|
7
|
+
* по предмету дешевле, чем по числу строк: правку прогонов и правку очереди делают разные
|
|
8
|
+
* работы.
|
|
9
|
+
*
|
|
10
|
+
* Нет сети или нет токена — вызовы бросают `OfflineError`, как и остальная работа с хостингом:
|
|
11
|
+
* невозможность спросить расхождением не считается.
|
|
12
|
+
*/
|
|
13
|
+
import { OWNER, REPO, gh } from './board.mjs';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Сколько прогонов завелось на этой вершине.
|
|
17
|
+
*
|
|
18
|
+
* Спрашивается вершина, а не ветка: прогон промежуточного коммита о состоянии вершины не
|
|
19
|
+
* говорит ничего, а список прогонов ветки отдаёт их вперемешку.
|
|
20
|
+
*/
|
|
21
|
+
export function runsOnHead(sha, options) {
|
|
22
|
+
const answer = gh(['api', `repos/${OWNER}/${REPO}/actions/runs?head_sha=${sha}&per_page=1`, '--jq', '.total_count'], options);
|
|
23
|
+
return Number(String(answer).trim());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Коммит последней успешной выкатки и то, на сколько от него ушла главная ветка.
|
|
28
|
+
*
|
|
29
|
+
* Спрашивается сама выкатка, а не последний прогон главной ветки. Там, где выкатку запускают
|
|
30
|
+
* рукой, слияние прода не двигает вовсе, и прогон главной о нём не говорит ничего: прод
|
|
31
|
+
* отставал на 476 коммитов, а сверка молчала. Судится только завершённая успехом выкатка —
|
|
32
|
+
* идущая ещё может кончиться чем угодно.
|
|
33
|
+
*
|
|
34
|
+
* Возвращает `null`, если выкаток не было ни одной: это не расхождение, а нечего сравнивать.
|
|
35
|
+
*/
|
|
36
|
+
export function deployLag(workflow, mainBranch, options) {
|
|
37
|
+
const runs = gh(
|
|
38
|
+
[
|
|
39
|
+
'api',
|
|
40
|
+
`repos/${OWNER}/${REPO}/actions/workflows/${encodeURIComponent(workflow)}/runs` +
|
|
41
|
+
'?status=success&per_page=1',
|
|
42
|
+
'--jq',
|
|
43
|
+
'[.workflow_runs[] | {sha: .head_sha, at: .created_at}] | first // empty',
|
|
44
|
+
],
|
|
45
|
+
options
|
|
46
|
+
);
|
|
47
|
+
const last = String(runs).trim();
|
|
48
|
+
if (!last) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const run = JSON.parse(last);
|
|
53
|
+
const behind = gh(
|
|
54
|
+
['api', `repos/${OWNER}/${REPO}/compare/${run.sha}...${encodeURIComponent(mainBranch)}`, '--jq', '.ahead_by'],
|
|
55
|
+
options
|
|
56
|
+
);
|
|
57
|
+
return { sha: run.sha, at: run.at, behind: Number(String(behind).trim()) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Чем кончились прогоны на этой вершине: `success`, если все завершились успехом, `running`,
|
|
62
|
+
* если хоть один ещё идёт, `failure` — если хоть один упал. Прогонов нет вовсе — `none`.
|
|
63
|
+
*
|
|
64
|
+
* Цвет спрашивается отдельно от факта: факт отвечает на вопрос «событие дошло», цвет — на
|
|
65
|
+
* вопрос «работу можно отдавать». Второй вопрос задаётся там, где готовое стоит черновиком.
|
|
66
|
+
*/
|
|
67
|
+
export function verdictOnHead(sha, options) {
|
|
68
|
+
const answer = gh(
|
|
69
|
+
[
|
|
70
|
+
'api',
|
|
71
|
+
`repos/${OWNER}/${REPO}/actions/runs?head_sha=${sha}&per_page=20`,
|
|
72
|
+
'--jq',
|
|
73
|
+
'[.workflow_runs[] | {status, conclusion}] | if length == 0 then "none"' +
|
|
74
|
+
' elif any(.status != "completed") then "running"' +
|
|
75
|
+
' elif any(.conclusion != "success") then "failure"' +
|
|
76
|
+
' else "success" end',
|
|
77
|
+
],
|
|
78
|
+
options
|
|
79
|
+
);
|
|
80
|
+
return String(answer).trim();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Когда вершина легла в ветку — по времени коммита у хостинга, а не по местным часам ветки. */
|
|
84
|
+
export function headCommittedAt(sha, options) {
|
|
85
|
+
const answer = gh(['api', `repos/${OWNER}/${REPO}/commits/${sha}`, '--jq', '.commit.committer.date'], options);
|
|
86
|
+
return Date.parse(String(answer).trim());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Прогоны вершины, вытесненные из очереди конвейера.
|
|
91
|
+
*
|
|
92
|
+
* Группа очереди бережёт идущий прогон и не бережёт ждущего: хостинг держит в группе один
|
|
93
|
+
* ждущий, и следующий встающий вытесняет прежний. Вытесненный завершается отменой и в списке
|
|
94
|
+
* неотличим от упавшего, хотя ветку не проверял ни строчкой.
|
|
95
|
+
*
|
|
96
|
+
* Отличает их число заданий. Отмена — общее слово для двух случаев: у прогона, остановленного
|
|
97
|
+
* на ходу, задания есть и журналы у них читаются; у вытесненного из очереди их ноль, потому что
|
|
98
|
+
* он не начинался. Замером по семи отменённым прогонам дерева: шесть с нулём заданий и один
|
|
99
|
+
* остановленный на ходу с одним.
|
|
100
|
+
*
|
|
101
|
+
* Число заданий спрашивается отдельным вызовом и только у отменённых: спрошенное у каждого
|
|
102
|
+
* прогона стоило бы вызова на прогон при каждой сверке.
|
|
103
|
+
*
|
|
104
|
+
* Зелёный прогон на той же вершине снимает ответ целиком — вытесненный за ним уже перезапущен,
|
|
105
|
+
* и говорить о нём нечего.
|
|
106
|
+
*/
|
|
107
|
+
export function evictedOnHead(sha, options) {
|
|
108
|
+
const answer = gh(
|
|
109
|
+
[
|
|
110
|
+
'api',
|
|
111
|
+
`repos/${OWNER}/${REPO}/actions/runs?head_sha=${sha}&per_page=20`,
|
|
112
|
+
'--jq',
|
|
113
|
+
'[.workflow_runs[] | {id, status, conclusion}] | tojson',
|
|
114
|
+
],
|
|
115
|
+
options
|
|
116
|
+
);
|
|
117
|
+
const runs = JSON.parse(String(answer).trim() || '[]');
|
|
118
|
+
if (runs.some((run) => run.status === 'completed' && run.conclusion === 'success')) {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return runs
|
|
123
|
+
.filter((run) => run.status === 'completed' && run.conclusion === 'cancelled')
|
|
124
|
+
.filter((run) => jobCount(run.id, options) === 0)
|
|
125
|
+
.map((run) => run.id);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Сколько заданий завелось у прогона. Ноль означает, что он не начинался вовсе. */
|
|
129
|
+
function jobCount(id, options) {
|
|
130
|
+
const answer = gh(['api', `repos/${OWNER}/${REPO}/actions/runs/${id}/jobs?per_page=1`, '--jq', '.total_count'], options);
|
|
131
|
+
return Number(String(answer).trim());
|
|
132
|
+
}
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* функции возвращают `null`, командный режим печатает `{"offline":true}`.
|
|
20
20
|
*/
|
|
21
21
|
import { execFileSync } from 'node:child_process';
|
|
22
|
-
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
22
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
23
23
|
import { homedir } from 'node:os';
|
|
24
24
|
import { join } from 'node:path';
|
|
25
25
|
|
|
@@ -256,46 +256,6 @@ export function fetchOpenPulls(options) {
|
|
|
256
256
|
);
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
-
/**
|
|
260
|
-
* Сколько прогонов завелось на этой вершине.
|
|
261
|
-
*
|
|
262
|
-
* Спрашивается вершина, а не ветка: прогон промежуточного коммита о состоянии вершины не
|
|
263
|
-
* говорит ничего, а список прогонов ветки отдаёт их вперемешку.
|
|
264
|
-
*/
|
|
265
|
-
export function runsOnHead(sha, options) {
|
|
266
|
-
const answer = gh(['api', `repos/${OWNER}/${REPO}/actions/runs?head_sha=${sha}&per_page=1`, '--jq', '.total_count'], options);
|
|
267
|
-
return Number(String(answer).trim());
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* Чем кончились прогоны на этой вершине: `success`, если все завершились успехом, `running`,
|
|
272
|
-
* если хоть один ещё идёт, `failure` — если хоть один упал. Прогонов нет вовсе — `none`.
|
|
273
|
-
*
|
|
274
|
-
* Цвет спрашивается отдельно от факта: факт отвечает на вопрос «событие дошло», цвет — на
|
|
275
|
-
* вопрос «работу можно отдавать». Второй вопрос задаётся там, где готовое стоит черновиком.
|
|
276
|
-
*/
|
|
277
|
-
export function verdictOnHead(sha, options) {
|
|
278
|
-
const answer = gh(
|
|
279
|
-
[
|
|
280
|
-
'api',
|
|
281
|
-
`repos/${OWNER}/${REPO}/actions/runs?head_sha=${sha}&per_page=20`,
|
|
282
|
-
'--jq',
|
|
283
|
-
'[.workflow_runs[] | {status, conclusion}] | if length == 0 then "none"' +
|
|
284
|
-
' elif any(.status != "completed") then "running"' +
|
|
285
|
-
' elif any(.conclusion != "success") then "failure"' +
|
|
286
|
-
' else "success" end',
|
|
287
|
-
],
|
|
288
|
-
options
|
|
289
|
-
);
|
|
290
|
-
return String(answer).trim();
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/** Когда вершина легла в ветку — по времени коммита у хостинга, а не по местным часам ветки. */
|
|
294
|
-
export function headCommittedAt(sha, options) {
|
|
295
|
-
const answer = gh(['api', `repos/${OWNER}/${REPO}/commits/${sha}`, '--jq', '.commit.committer.date'], options);
|
|
296
|
-
return Date.parse(String(answer).trim());
|
|
297
|
-
}
|
|
298
|
-
|
|
299
259
|
/** `[<КЛЮЧ>-<номер>]` в начале заголовка — единственная форма номера в названиях */
|
|
300
260
|
export const TITLE_NUMBER = new RegExp(`^\\[${TASK_KEY}-(\\d+)\\]\\s+\\S`);
|
|
301
261
|
/** `<КЛЮЧ>-<номер>-<slug>` — имя ветки, отведённой под задачу */
|
|
@@ -327,6 +287,48 @@ export function numberFromTaskDir(name) {
|
|
|
327
287
|
* Вглубь спускаемся ровно на один уровень: в имени ветки одна косая, а всё, что глубже, папкой
|
|
328
288
|
* задачи уже не будет — зато туда попал бы архив, если дерево держит его внутри.
|
|
329
289
|
*/
|
|
290
|
+
/** Шапка раскладки: по ней разложенную копию узнаёт и гард места правки. */
|
|
291
|
+
const STAMP = /^<!-- rt-kit v[^\n]*-->\n/m;
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Снять с копий образца шапку раскладки.
|
|
295
|
+
*
|
|
296
|
+
* Образец разложен пакетом и шапку несёт по праву: его кладёт и обновляет раскладка. Копия под
|
|
297
|
+
* задачу — уже текст проекта, тем же доводом, каким пакет кладёт без шапки черновик компаньона:
|
|
298
|
+
* с первой правки сверять в ней нечего.
|
|
299
|
+
*
|
|
300
|
+
* Оставленная в копии, шапка отбивает первую же правку разбора просьбы — то есть первое движение
|
|
301
|
+
* любой работы, — и отказ уводит править образец пакета вместо копии под задачу. Снималась она
|
|
302
|
+
* тремя строками руками, каждой работой заново.
|
|
303
|
+
*
|
|
304
|
+
* Возвращает имена файлов, с которых шапка снята: по ним сценарий и судит, что снятие работает,
|
|
305
|
+
* а вызывающий — что папка собрана.
|
|
306
|
+
*/
|
|
307
|
+
export function unstampFolder(folder) {
|
|
308
|
+
const cleaned = [];
|
|
309
|
+
|
|
310
|
+
if (!existsSync(folder)) {
|
|
311
|
+
return cleaned;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
for (const name of readdirSync(folder)) {
|
|
315
|
+
if (!name.endsWith('.md')) {
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const path = join(folder, name);
|
|
320
|
+
const before = readFileSync(path, 'utf8');
|
|
321
|
+
const after = before.replace(STAMP, '');
|
|
322
|
+
|
|
323
|
+
if (after !== before) {
|
|
324
|
+
writeFileSync(path, after);
|
|
325
|
+
cleaned.push(name);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return cleaned;
|
|
330
|
+
}
|
|
331
|
+
|
|
330
332
|
export function taskDirs(dir = join(ROOT, CONFIG.tasksDir), prefix = '') {
|
|
331
333
|
if (!existsSync(dir)) {
|
|
332
334
|
return [];
|
|
@@ -43,13 +43,11 @@ import {
|
|
|
43
43
|
fetchIssues,
|
|
44
44
|
fetchOpenPulls,
|
|
45
45
|
gh,
|
|
46
|
-
headCommittedAt,
|
|
47
46
|
numberFromTaskDir,
|
|
48
47
|
numberFromTitle,
|
|
49
|
-
runsOnHead,
|
|
50
48
|
taskDirs,
|
|
51
|
-
verdictOnHead,
|
|
52
49
|
} from './board.mjs';
|
|
50
|
+
import { deployLag, evictedOnHead, headCommittedAt, runsOnHead, verdictOnHead } from './board-runs.mjs';
|
|
53
51
|
import { CONFIG, ROOT } from './rt-kit-checks.config.mjs';
|
|
54
52
|
|
|
55
53
|
const IN_REVIEW = STATUS_OPTIONS[IN_REVIEW_STATUS].name;
|
|
@@ -67,6 +65,12 @@ const RUN_GRACE_MINUTES = 10;
|
|
|
67
65
|
*/
|
|
68
66
|
const PIPELINE = CONFIG.pushGate?.pipelineFile ?? '';
|
|
69
67
|
const HAS_PIPELINE = PIPELINE !== '' && existsSync(join(ROOT, PIPELINE));
|
|
68
|
+
/**
|
|
69
|
+
* Рабочий поток выкатки и ветка, с которой прод сравнивают. Не назвав потока, дерево сверки
|
|
70
|
+
* прода не получает — и сверка говорит об этом вслух: молчание читалось бы как «прод сошёлся».
|
|
71
|
+
*/
|
|
72
|
+
const DEPLOY_WORKFLOW = CONFIG.deploy?.workflow ?? '';
|
|
73
|
+
const MAIN_BRANCH = CONFIG.deploy?.mainBranch ?? 'main';
|
|
70
74
|
|
|
71
75
|
const problems = [];
|
|
72
76
|
const report = (message) => problems.push(message);
|
|
@@ -136,6 +140,10 @@ function folderInBranch(branch, options) {
|
|
|
136
140
|
* промежутке значила бы «подожди», а не «чини».
|
|
137
141
|
*/
|
|
138
142
|
function checkHeadRun(pull, options) {
|
|
143
|
+
if (checkEvicted(pull, options)) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
139
147
|
if (runsOnHead(pull.headRefOid, options) > 0) {
|
|
140
148
|
checkReadyDraft(pull, options);
|
|
141
149
|
return;
|
|
@@ -153,6 +161,36 @@ function checkHeadRun(pull, options) {
|
|
|
153
161
|
);
|
|
154
162
|
}
|
|
155
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Прогон вершины, вытесненный из очереди конвейера.
|
|
166
|
+
*
|
|
167
|
+
* Группа очереди бережёт идущий прогон и не бережёт ждущего: хостинг держит в группе один
|
|
168
|
+
* ждущий, и следующий встающий вытесняет прежний. Ветка за таким прогоном не проверялась ни
|
|
169
|
+
* строчкой, а по очереди работ выглядит проверенной — прогон на вершине есть, и сверка считает
|
|
170
|
+
* именно факт.
|
|
171
|
+
*
|
|
172
|
+
* Судится раньше отсутствия прогона и раньше цвета: иначе одна вершина получает две строки об
|
|
173
|
+
* одном. Отвечает `true`, когда строка сказана, и остальные проверки вершины пропускаются.
|
|
174
|
+
*
|
|
175
|
+
* Строка называет обе команды и в том порядке, в каком их зовут. Перезапуск отбивает гард, пока
|
|
176
|
+
* за тот же ход не читался журнал этого задания, и порядок в строке выполняет требование сам:
|
|
177
|
+
* исполнитель зовёт написанное и не упирается в отказ на втором шаге.
|
|
178
|
+
*/
|
|
179
|
+
function checkEvicted(pull, options) {
|
|
180
|
+
const evicted = evictedOnHead(pull.headRefOid, options);
|
|
181
|
+
if (evicted.length === 0) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const run = evicted[0];
|
|
186
|
+
report(
|
|
187
|
+
`PR #${pull.number}: прогон ${run} на вершине ${pull.headRefOid.slice(0, 8)} вытеснен из очереди конвейера — ` +
|
|
188
|
+
`заданий у него ноль, ветка не проверялась, а в списке он выглядит упавшим; ` +
|
|
189
|
+
`прочитай прогон и перезапусти его (gh run view ${run} && gh run rerun ${run})`
|
|
190
|
+
);
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
|
|
156
194
|
/**
|
|
157
195
|
* Готовая работа, оставленная черновиком.
|
|
158
196
|
*
|
|
@@ -252,10 +290,11 @@ try {
|
|
|
252
290
|
claimed.set(titleNumber, pull.number);
|
|
253
291
|
}
|
|
254
292
|
|
|
255
|
-
// Папка задачи, лежащая в ветке открытого PR, —
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
// тем же PR
|
|
293
|
+
// Папка задачи, лежащая в ветке открытого PR, — расхождение с первой минуты заявки:
|
|
294
|
+
// уборка стоит до её открытия, и открытие с лежащей папкой отбивает гард поставки.
|
|
295
|
+
// Дошедшая сюда папка означает обход — либо заявку, открытую мимо гарда. Сказанная
|
|
296
|
+
// после слияния, эта строка уже не чинится тем же PR: работа перешла дальше, и на
|
|
297
|
+
// разбор заводится вторая задача.
|
|
259
298
|
if (HAS_PIPELINE && pull.headRefOid) {
|
|
260
299
|
checkHeadRun(pull, options);
|
|
261
300
|
}
|
|
@@ -266,7 +305,7 @@ try {
|
|
|
266
305
|
const folder = folderInBranch(pull.headRefName, options);
|
|
267
306
|
if (folder !== null) {
|
|
268
307
|
report(
|
|
269
|
-
`PR #${pull.number}: ветка везёт папку задачи «${folder}/» —
|
|
308
|
+
`PR #${pull.number}: ветка везёт папку задачи «${folder}/» — заявка открывается после уборки. Разбери её этим же PR или поставь в тело строку «Task-folder-skip: <причина>»`
|
|
270
309
|
);
|
|
271
310
|
}
|
|
272
311
|
}
|
|
@@ -312,7 +351,33 @@ try {
|
|
|
312
351
|
}
|
|
313
352
|
}
|
|
314
353
|
|
|
354
|
+
// Прод сверяется с главной веткой по последней успешной выкатке. Задача уходит из очереди
|
|
355
|
+
// слиянием, но слияние — ещё не прод: там, где выкатку запускают рукой, между ними может лечь
|
|
356
|
+
// сколько угодно коммитов, и заметить это неоткуда.
|
|
357
|
+
if (!offline && DEPLOY_WORKFLOW) {
|
|
358
|
+
try {
|
|
359
|
+
const lag = deployLag(DEPLOY_WORKFLOW, MAIN_BRANCH, { token: botToken() ?? undefined });
|
|
360
|
+
if (lag === null) {
|
|
361
|
+
report(`выкаток по «${DEPLOY_WORKFLOW}» не было ни одной — сравнить прод не с чем`);
|
|
362
|
+
} else if (lag.behind > 0) {
|
|
363
|
+
report(
|
|
364
|
+
`прод отстал от «${MAIN_BRANCH}» на ${lag.behind} коммитов: последняя выкатка — ${lag.sha.slice(0, 8)} от ${String(lag.at).slice(0, 10)}`
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
} catch (error) {
|
|
368
|
+
if (error instanceof OfflineError) {
|
|
369
|
+
console.log(`check-board: прод не сверялся — ${error.message}`);
|
|
370
|
+
} else {
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
315
376
|
// Непроверенное называется вслух: молчание о прогонах читалось бы как «прогоны на месте».
|
|
377
|
+
if (!offline && !DEPLOY_WORKFLOW) {
|
|
378
|
+
console.log('check-board: прод с главной веткой не сверялся — рабочий поток выкатки в настройке дерева не назван');
|
|
379
|
+
}
|
|
380
|
+
|
|
316
381
|
if (!offline && !HAS_PIPELINE) {
|
|
317
382
|
console.log('check-board: прогоны на вершинах не спрашивались — файла конвейера в дереве нет');
|
|
318
383
|
}
|
|
@@ -111,6 +111,15 @@ async function withServiceClient(serviceUrl, run) {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
async function main() {
|
|
114
|
+
// Незаданное имя спрашивается отдельно от несуществующего файла. Склеенное с корнем, пустое
|
|
115
|
+
// имя даёт сам корень — а он есть всегда, и проверка шла дальше, будто схема на месте.
|
|
116
|
+
// Дерево без хранилища так и говорит: имени нет, сверять нечего.
|
|
117
|
+
if (!CONFIG.schemaFile) {
|
|
118
|
+
console.log('check-schema-drift: имя файла схемы не задано — сверять нечего');
|
|
119
|
+
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
114
123
|
if (!existsSync(join(ROOT, CONFIG.schemaFile))) {
|
|
115
124
|
console.log('check-schema-drift: схемы нет — сверять нечего');
|
|
116
125
|
|
|
@@ -162,9 +171,14 @@ async function main() {
|
|
|
162
171
|
return 1;
|
|
163
172
|
}
|
|
164
173
|
if (diff.status !== 0) {
|
|
165
|
-
|
|
174
|
+
// Ни «сошлось», ни «разошлось» — сама команда сравнения не отработала. Пропуск
|
|
175
|
+
// здесь неотличим от сошедшихся миграций, и именно им проверка молчала о пустом
|
|
176
|
+
// имени схемы: с ним она звала сравнение без обязательного довода, а отказ
|
|
177
|
+
// читался зелёным гейтом.
|
|
178
|
+
console.error('check-schema-drift: сравнение не отработало\n');
|
|
179
|
+
console.error(`${diff.stdout ?? ''}${diff.stderr ?? ''}`);
|
|
166
180
|
|
|
167
|
-
return
|
|
181
|
+
return 1;
|
|
168
182
|
}
|
|
169
183
|
|
|
170
184
|
console.log('check-schema-drift: миграции и схема сошлись');
|
|
@@ -179,8 +193,17 @@ async function main() {
|
|
|
179
193
|
main().then(
|
|
180
194
|
(code) => process.exit(code),
|
|
181
195
|
(error) => {
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
196
|
+
// Сломанная обвязка — не пропуск, а отказ. «Проверять негде» проверка говорит сама и
|
|
197
|
+
// раньше: нет схемы, нет адреса, адрес боевой, сервер не отвечает — всё это законные
|
|
198
|
+
// выходы нулём, и каждый назван своей строкой. Сюда доходит то, чего она не предвидела,
|
|
199
|
+
// и молчаливый ноль здесь означает «гейт зелен, потому что сверять не получилось».
|
|
200
|
+
// Отличить его от «сверено и сошлось» нечем: за таким нулём проверка простояла
|
|
201
|
+
// выключенной, пока её не позвали руками.
|
|
202
|
+
console.error(`check-schema-drift: проверка не отработала — ${error?.message ?? error}\n`);
|
|
203
|
+
console.error(
|
|
204
|
+
'Это отказ самой проверки, а не расхождение схемы. Почини обвязку: недостающий пакет ставится в корень,\n' +
|
|
205
|
+
'пустое имя схемы или каталога миграций задаётся в настройке проверок дерева.'
|
|
206
|
+
);
|
|
207
|
+
process.exit(1);
|
|
185
208
|
}
|
|
186
209
|
);
|
|
@@ -143,6 +143,18 @@ const DEFAULTS = {
|
|
|
143
143
|
stepPattern: '^\\s*-\\s*name:\\s*(.+?)\\s*$',
|
|
144
144
|
steps: {},
|
|
145
145
|
},
|
|
146
|
+
/**
|
|
147
|
+
* Выкатка. `workflow` — файл рабочего потока, которым прод выкатывают; пусто — сверка
|
|
148
|
+
* прода молчит вслух. `mainBranch` — ветка, с которой прод сравнивают.
|
|
149
|
+
*
|
|
150
|
+
* Спрашивается именно последняя успешная выкатка, а не последний прогон главной ветки:
|
|
151
|
+
* там, где выкатку запускают рукой, прогон главной о проде не говорит ничего, и прод,
|
|
152
|
+
* отставший на сотни коммитов, не виден ничему.
|
|
153
|
+
*/
|
|
154
|
+
deploy: {
|
|
155
|
+
workflow: '',
|
|
156
|
+
mainBranch: 'main',
|
|
157
|
+
},
|
|
146
158
|
/** Очередь работ: владелец, репозиторий, борда и учётная запись машинной работы. */
|
|
147
159
|
board: {
|
|
148
160
|
owner: '',
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* Тело читается со стандартного ввода. Автор и исполнитель — учётная запись бота,
|
|
18
18
|
* та же, от которой идут коммиты; `--assignee` перекрывает исполнителя.
|
|
19
19
|
*/
|
|
20
|
-
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { cpSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
21
21
|
import { dirname, join, resolve } from 'node:path';
|
|
22
22
|
import { fileURLToPath } from 'node:url';
|
|
23
23
|
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
graphql,
|
|
39
39
|
numberFromTitle,
|
|
40
40
|
taskState,
|
|
41
|
+
unstampFolder,
|
|
41
42
|
} from './board.mjs';
|
|
42
43
|
|
|
43
44
|
function parseArgs(argv) {
|
|
@@ -161,6 +162,7 @@ const branch = args.slug ? `${TASK_KEY}-${number}-${args.slug}` : `${TASK_KEY}-$
|
|
|
161
162
|
* `docs/tasks/_draft-<slug>`. Оставленный черновиком, он остаётся вне истории, а следующий
|
|
162
163
|
* заход его не находит: хук запуска ищет папку по имени ветки.
|
|
163
164
|
*/
|
|
165
|
+
|
|
164
166
|
function adoptDraft() {
|
|
165
167
|
if (!args.slug) {
|
|
166
168
|
console.log(`\nПапка задачи: --slug не задан, переименовать черновик нечем.`);
|
|
@@ -174,10 +176,20 @@ function adoptDraft() {
|
|
|
174
176
|
console.log(`\nПапка задачи уже на месте: docs/tasks/${branch}/`);
|
|
175
177
|
} else if (existsSync(draft)) {
|
|
176
178
|
renameSync(draft, target);
|
|
179
|
+
// Черновик тоже собирают с образца, и шапка в нём та же: снимается она и здесь.
|
|
180
|
+
unstampFolder(target);
|
|
177
181
|
console.log(`\nПапка задачи: docs/tasks/_draft-${args.slug}/ → docs/tasks/${branch}/`);
|
|
178
182
|
} else {
|
|
179
|
-
|
|
180
|
-
|
|
183
|
+
const template = join(root, 'docs/tasks/_template');
|
|
184
|
+
|
|
185
|
+
if (!existsSync(template)) {
|
|
186
|
+
console.log(`\nПапки задачи нет, и собрать её не с чего: образца ${'docs/tasks/_template'} в дереве не лежит`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
cpSync(template, target, { recursive: true });
|
|
191
|
+
unstampFolder(target);
|
|
192
|
+
console.log(`\nПапка задачи собрана с образца: docs/tasks/${branch}/`);
|
|
181
193
|
}
|
|
182
194
|
|
|
183
195
|
// Шапку замысла читает гард: по ней он находит договорённость о продукте. Номер в ней
|
|
@@ -70,11 +70,19 @@ cp .claude/rt-kit/templates/proposal.md \
|
|
|
70
70
|
|
|
71
71
|
- **место:** раздел «<заголовок>», в конец
|
|
72
72
|
- **повод:** что в этой работе пошло не так без этого правила
|
|
73
|
+
- **ближайшее:** «<точная строка ресурса, к которой это ближе всего>» — <чего она не покрывает>
|
|
73
74
|
|
|
74
75
|
> Готовый текст правки — ровно то, что вставить, в стиле соседних правил: по-русски,
|
|
75
76
|
> утверждением, без воды.
|
|
76
77
|
```
|
|
77
78
|
|
|
79
|
+
**Строка «ближайшее» обязательна, и она единственная, которую проверяет машина.** Цитата ищется
|
|
80
|
+
в ресурсе, и ненайденная отбивает блок: либо ресурс не читали, либо утверждение с тех пор
|
|
81
|
+
переписано. Ближайшего нет вовсе — так и пишется: «нет». Написана она затем, чтобы ресурс был
|
|
82
|
+
прочитан: разбор кончается предложением дописать статью в тот же ресурс, который промах уже
|
|
83
|
+
описывал, и вторая статья о том же дороже, чем её отсутствие. Отбитый блок остаётся лежать с
|
|
84
|
+
отметкой и причиной — видно, что разбор был, и видно, почему он не стал правкой.
|
|
85
|
+
|
|
78
86
|
Повод пишется от случая, а не от желания: «здесь было неудобно» правилом не становится. Слово
|
|
79
87
|
пользователя пересказывается его смыслом, а не твоими выводами о том, как надо было бы.
|
|
80
88
|
|
|
@@ -277,11 +277,50 @@ rt_shell_paths_default() {
|
|
|
277
277
|
}')"
|
|
278
278
|
fi
|
|
279
279
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
280
|
+
# Пути берутся только у тех кусков команды, которые пишут. Прежде брались у всей строки
|
|
281
|
+
# целиком, и команда чтения, сцепленная с записью, отдавала свои пути как цели записи:
|
|
282
|
+
# `python3 <<PY … PY` рядом с `grep -n … projects/…` отбивался за правку кода, которой в нём
|
|
283
|
+
# не было. Отбитий, пришедшихся не на правку файла, набиралось большинство, и цену платил
|
|
284
|
+
# тот, кто просто читал соседний файл в той же строке.
|
|
285
|
+
#
|
|
286
|
+
# Кусок — строка верхнего уровня, а внутри неё `;`, `&&` и `||`. Тело heredoc от своей
|
|
287
|
+
# команды не отрывается: оно едет вместе с ней одним куском, потому что путь записи
|
|
288
|
+
# интерпретатора стоит именно там.
|
|
289
|
+
printf '%s' "$text" | awk '
|
|
290
|
+
function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s }
|
|
291
|
+
function flush( n, i, part) {
|
|
292
|
+
if (chunk == "") { return }
|
|
293
|
+
if (tag != "") { part = chunk; gsub(/\n/, " ", part); print part; chunk = ""; return }
|
|
294
|
+
n = split(chunk, parts, /;|&&|\|\|/)
|
|
295
|
+
for (i = 1; i <= n; i++) { print parts[i] }
|
|
296
|
+
chunk = ""
|
|
297
|
+
}
|
|
298
|
+
tag != "" {
|
|
299
|
+
chunk = chunk "\n" $0
|
|
300
|
+
if (trim($0) == tag) { flush(); tag = "" }
|
|
301
|
+
next
|
|
302
|
+
}
|
|
303
|
+
{
|
|
304
|
+
chunk = $0
|
|
305
|
+
if (match($0, /<<-?[ \t]*[A-Za-z_][A-Za-z0-9_]*/)) {
|
|
306
|
+
t = substr($0, RSTART, RLENGTH)
|
|
307
|
+
sub(/^<<-?[ \t]*/, "", t)
|
|
308
|
+
tag = t
|
|
309
|
+
next
|
|
310
|
+
}
|
|
311
|
+
flush()
|
|
312
|
+
}
|
|
313
|
+
END { if (chunk != "") { part = chunk; gsub(/\n/, " ", part); print part } }
|
|
314
|
+
' \
|
|
315
|
+
| while IFS= read -r piece; do
|
|
316
|
+
[ -z "$piece" ] && continue
|
|
317
|
+
rt_shell_writes "$piece" || continue
|
|
318
|
+
printf '%s' "$piece" \
|
|
319
|
+
| tr "(),;=" ' ' \
|
|
320
|
+
| tr '[:space:]' '\n' \
|
|
321
|
+
| grep -E '^[A-Za-z0-9_@.-]*/[A-Za-z0-9_@./-]+$' \
|
|
322
|
+
| sed 's|^\./||'
|
|
323
|
+
done \
|
|
285
324
|
| sort -u
|
|
286
325
|
}
|
|
287
326
|
|
|
@@ -420,8 +459,14 @@ rt_lint_for() { rt_lint_for_default "$@"; }
|
|
|
420
459
|
rt_task_branch_ok() { rt_task_branch_ok_default "$@"; }
|
|
421
460
|
rt_reinvented_in() { rt_reinvented_in_default "$@"; }
|
|
422
461
|
rt_is_app_code() { rt_is_app_code_default "$@"; }
|
|
462
|
+
# Каталог источников пакета правил в этом дереве, от корня. Пусто — дерево пакета не везёт, и
|
|
463
|
+
# адрес правки у него один: надстройка. Дерево, которое пакет и разрабатывает, называет каталог
|
|
464
|
+
# сам — иначе гард места правки посылал бы его в надстройку вместо источника.
|
|
465
|
+
rt_kit_sources_dir_default() { printf ''; }
|
|
466
|
+
|
|
423
467
|
rt_shell_writes() { rt_shell_writes_default "$@"; }
|
|
424
468
|
rt_shell_paths() { rt_shell_paths_default "$@"; }
|
|
469
|
+
rt_kit_sources_dir() { rt_kit_sources_dir_default "$@"; }
|
|
425
470
|
rt_qa_decorative() { rt_qa_decorative_default "$@"; }
|
|
426
471
|
rt_task_state() { rt_task_state_default "$@"; }
|
|
427
472
|
rt_pull_state() { rt_pull_state_default "$@"; }
|
|
@@ -80,7 +80,7 @@ esac
|
|
|
80
80
|
# помечают движок в прогоне сквозных спек, — такой образец отбил бы сам прогон в день, когда
|
|
81
81
|
# появился. Поэтому якорь на границе команды и требование похожего на исполняемый файл слова,
|
|
82
82
|
# а не значения флага.
|
|
83
|
-
printf '%s' "$cmd" | grep -qE
|
|
83
|
+
printf '%s' "$cmd" | grep -qE "${RT_CMD_BOUND}(/[^[:space:]]*/)?(google-chrome|chromium)([[:space:]]|\$)" \
|
|
84
84
|
&& deny "Прямой запуск бинарника браузера обходит закреплённый профиль."
|
|
85
85
|
|
|
86
86
|
printf '%s' "$cmd" | grep -qF 'Google Chrome.app/Contents/MacOS' \
|