@rt-tools/agent-kit 0.12.0 → 0.13.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 +87 -0
- package/assets/checks/board.github.mjs +0 -40
- package/assets/checks/check-board.github.mjs +39 -8
- package/assets/checks/check-schema-drift.mjs +28 -5
- package/assets/checks/rt-kit-checks.config.mjs +12 -0
- 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/dev-server-guard.sh +1 -1
- 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 +23 -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 +17 -0
- 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/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 +6 -1
- package/assets/rules/task-flow.md +41 -25
- package/assets/rules/turn-conduct.md +4 -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.13.0.tgz +0 -0
- package/rt-tools-agent-kit-0.12.0.tgz +0 -0
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
}
|
|
@@ -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>` — имя ветки, отведённой под задачу */
|
|
@@ -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, 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);
|
|
@@ -252,10 +256,11 @@ try {
|
|
|
252
256
|
claimed.set(titleNumber, pull.number);
|
|
253
257
|
}
|
|
254
258
|
|
|
255
|
-
// Папка задачи, лежащая в ветке открытого PR, —
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
// тем же PR
|
|
259
|
+
// Папка задачи, лежащая в ветке открытого PR, — расхождение с первой минуты заявки:
|
|
260
|
+
// уборка стоит до её открытия, и открытие с лежащей папкой отбивает гард поставки.
|
|
261
|
+
// Дошедшая сюда папка означает обход — либо заявку, открытую мимо гарда. Сказанная
|
|
262
|
+
// после слияния, эта строка уже не чинится тем же PR: работа перешла дальше, и на
|
|
263
|
+
// разбор заводится вторая задача.
|
|
259
264
|
if (HAS_PIPELINE && pull.headRefOid) {
|
|
260
265
|
checkHeadRun(pull, options);
|
|
261
266
|
}
|
|
@@ -266,7 +271,7 @@ try {
|
|
|
266
271
|
const folder = folderInBranch(pull.headRefName, options);
|
|
267
272
|
if (folder !== null) {
|
|
268
273
|
report(
|
|
269
|
-
`PR #${pull.number}: ветка везёт папку задачи «${folder}/» —
|
|
274
|
+
`PR #${pull.number}: ветка везёт папку задачи «${folder}/» — заявка открывается после уборки. Разбери её этим же PR или поставь в тело строку «Task-folder-skip: <причина>»`
|
|
270
275
|
);
|
|
271
276
|
}
|
|
272
277
|
}
|
|
@@ -312,7 +317,33 @@ try {
|
|
|
312
317
|
}
|
|
313
318
|
}
|
|
314
319
|
|
|
320
|
+
// Прод сверяется с главной веткой по последней успешной выкатке. Задача уходит из очереди
|
|
321
|
+
// слиянием, но слияние — ещё не прод: там, где выкатку запускают рукой, между ними может лечь
|
|
322
|
+
// сколько угодно коммитов, и заметить это неоткуда.
|
|
323
|
+
if (!offline && DEPLOY_WORKFLOW) {
|
|
324
|
+
try {
|
|
325
|
+
const lag = deployLag(DEPLOY_WORKFLOW, MAIN_BRANCH, { token: botToken() ?? undefined });
|
|
326
|
+
if (lag === null) {
|
|
327
|
+
report(`выкаток по «${DEPLOY_WORKFLOW}» не было ни одной — сравнить прод не с чем`);
|
|
328
|
+
} else if (lag.behind > 0) {
|
|
329
|
+
report(
|
|
330
|
+
`прод отстал от «${MAIN_BRANCH}» на ${lag.behind} коммитов: последняя выкатка — ${lag.sha.slice(0, 8)} от ${String(lag.at).slice(0, 10)}`
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
} catch (error) {
|
|
334
|
+
if (error instanceof OfflineError) {
|
|
335
|
+
console.log(`check-board: прод не сверялся — ${error.message}`);
|
|
336
|
+
} else {
|
|
337
|
+
throw error;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
315
342
|
// Непроверенное называется вслух: молчание о прогонах читалось бы как «прогоны на месте».
|
|
343
|
+
if (!offline && !DEPLOY_WORKFLOW) {
|
|
344
|
+
console.log('check-board: прод с главной веткой не сверялся — рабочий поток выкатки в настройке дерева не назван');
|
|
345
|
+
}
|
|
346
|
+
|
|
316
347
|
if (!offline && !HAS_PIPELINE) {
|
|
317
348
|
console.log('check-board: прогоны на вершинах не спрашивались — файла конвейера в дереве нет');
|
|
318
349
|
}
|
|
@@ -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: '',
|
|
@@ -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' \
|
|
@@ -100,7 +100,7 @@ RUNNER='((npx|pnpm|yarn|bun|npm)([[:space:]]+(exec|run|dlx))?[[:space:]]+)?'
|
|
|
100
100
|
|
|
101
101
|
# Начало вызова: начало строки или разделитель команд. Кавычку в границы вносить нельзя — тогда
|
|
102
102
|
# поиск по тексту и снятие процесса по шаблону читаются как запуск.
|
|
103
|
-
BOUND=
|
|
103
|
+
BOUND="$RT_CMD_BOUND"
|
|
104
104
|
|
|
105
105
|
printf '%s' "$cmd" | grep -qE "${BOUND}${RUNNER}(nx|ng)[[:space:]]+(run[[:space:]]+[^[:space:]]*:)?(serve|dev)" \
|
|
106
106
|
&& deny "Запуск ещё одного сервера разработки через каркас."
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Условия поставки о папке задачи. НЕ гард: объявления `rt-hook:` у него нет, к событиям агента
|
|
3
|
+
# он не подключается. Его источает гард поставки — тем же приёмом, каким источает хвост отказа и
|
|
4
|
+
# запись наблюдений.
|
|
5
|
+
#
|
|
6
|
+
# Зачем он отдельным файлом. Условий о папке два, и живут они в разных местах вызова: одно на
|
|
7
|
+
# открытии заявки, другое на слиянии. Вместе с разбором команды, подписью, основанием и очередью
|
|
8
|
+
# работ они переросли предел длины файла, а делить гард по событиям нельзя — он одно целое,
|
|
9
|
+
# и разбор команды у него общий. Делится он по предмету: папка задачи — свой предмет, у неё свой
|
|
10
|
+
# обход и своя запись в архиве.
|
|
11
|
+
#
|
|
12
|
+
# ЧТО ЗДЕСЬ. Три вызова, и все три зовутся из гарда поставки, где уже объявлены `deny`, `fault`
|
|
13
|
+
# и переменные профиля: `tasks_dir`, `archive_dir`, `main_branch`, `folder_skip_re`, `root`.
|
|
14
|
+
# Своих у помощника нет — он не читает ни ввода, ни настроек.
|
|
15
|
+
|
|
16
|
+
# Есть ли папка задачи в ветке. Смотрим содержимое ветки, а не рабочее дерево: если папку
|
|
17
|
+
# удалили, но не закоммитили, проверка прошла бы, а папка всё равно уехала бы в главную. Имя
|
|
18
|
+
# ветки подставляем целиком, вместе с косой: у ветки вида `chore/312-slug` папка лежит во
|
|
19
|
+
# вложенном каталоге.
|
|
20
|
+
rt_folder_in_branch() {
|
|
21
|
+
git ls-tree -d --name-only HEAD -- "$1" 2>/dev/null | head -1
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# Была ли папка задачи в этой ветке вообще. Спрашивается у ветки, которая её удалила: иначе
|
|
25
|
+
# требование записи в архиве цеплялось бы к работе, у которой папки и не было.
|
|
26
|
+
rt_folder_was_in_branch() {
|
|
27
|
+
_base="$1"
|
|
28
|
+
_folder="$2"
|
|
29
|
+
_had="$(git ls-tree -d --name-only "$_base" -- "$_folder" 2>/dev/null | head -1)"
|
|
30
|
+
[ -z "$_had" ] && _had="$(git log "$_base..HEAD" --diff-filter=A --name-only --pretty=format: -- "$_folder" 2>/dev/null | head -1)"
|
|
31
|
+
[ -n "$_had" ]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Условие открытия заявки: папка разобрана, и запись в архиве от ветки прибыла. Отказ копится
|
|
35
|
+
# вместе с прочими условиями поставки и печатается разом.
|
|
36
|
+
#
|
|
37
|
+
# Разбор стоит здесь, а не на слиянии. Прежде было наоборот: считалось, что папка нужна на диске
|
|
38
|
+
# всё время разбора, потому что правку по замечаниям без замысла отбивает гард хода работы.
|
|
39
|
+
# Владелец же вливает, как только видит зелёное, и закрывающему коммиту места не остаётся —
|
|
40
|
+
# трижды подряд папка уехала в главную неразобранной, а гард слияния этого не видел вовсе:
|
|
41
|
+
# кнопку нажимает человек на хостинге, и до него гард не достаёт. Замысел после уборки гард хода
|
|
42
|
+
# работы берёт из истории ветки, а не с диска.
|
|
43
|
+
rt_delivery_open_folder() {
|
|
44
|
+
[ -n "$tasks_dir" ] || return 0
|
|
45
|
+
printf '%s' "$cmd" | grep -qiE "$folder_skip_re" && return 0
|
|
46
|
+
|
|
47
|
+
_folder="$tasks_dir/$branch"
|
|
48
|
+
_lying="$(rt_folder_in_branch "$_folder")"
|
|
49
|
+
if [ -n "$_lying" ]; then
|
|
50
|
+
fault "в ветке лежит папка задачи «${_lying}» — заявка открывается после уборки, а не до неё. Перенеси в «${archive_dir:-архив}» то, что объясняет принятые решения, остальное удали, закоммить последним коммитом и повтори. Если работа вливается частями, поставь в команду комментарий «# Task-folder-skip: <причина>»."
|
|
51
|
+
return 0
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
[ -n "$archive_dir" ] || return 0
|
|
55
|
+
_base="$(git merge-base "$main_branch" HEAD 2>/dev/null)"
|
|
56
|
+
[ -z "$_base" ] && return 0
|
|
57
|
+
rt_folder_was_in_branch "$_base" "$_folder" || return 0
|
|
58
|
+
|
|
59
|
+
_gained="$(git diff --name-only --diff-filter=A "$_base" HEAD -- "$archive_dir" 2>/dev/null | head -1)"
|
|
60
|
+
[ -z "$_gained" ] \
|
|
61
|
+
&& fault "папку задачи удалили, но в «${archive_dir}» ветка ничего не добавила. Удалить проще, чем разобрать, — и вместе с папкой пропадает разбор просьбы, единственная запись слов владельца. Перенеси то, что объясняет принятые решения, одним файлом с понятным именем и повтори."
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Условие слияния: тот же предмет вторым рубежом. Он ловит слияние, идущее командой, — то, до
|
|
65
|
+
# которого гард достаёт. Отсюда выходят только отказом или молчанием: слияние решается целиком.
|
|
66
|
+
rt_delivery_merge_folder() {
|
|
67
|
+
[ -n "$tasks_dir" ] || exit 0 # ведения работы папкой в дереве нет
|
|
68
|
+
|
|
69
|
+
_branch="$(git branch --show-current 2>/dev/null)"
|
|
70
|
+
[ -z "$_branch" ] && exit 0
|
|
71
|
+
rt_task_branch_ok "$_branch" || exit 0 # за беззадачной веткой папки не стоит
|
|
72
|
+
|
|
73
|
+
_folder="$tasks_dir/$_branch"
|
|
74
|
+
|
|
75
|
+
# Сначала ищем обход в самой команде — это работает и без сети. Если читать только
|
|
76
|
+
# тело заявки, то без сети гард отбил бы слияние, причина которого в этом теле и написана.
|
|
77
|
+
printf '%s' "$cmd" | grep -qiE "$folder_skip_re" && exit 0
|
|
78
|
+
|
|
79
|
+
_number="$(printf '%s' "$cmd" | sed -nE 's/.*(pr|mr)[[:space:]]+(merge|update)[[:space:]]+([0-9]+).*/\3/p' | head -1)"
|
|
80
|
+
if [ -n "$_number" ] && rt_needs rt_report_body git-guard-delivery; then
|
|
81
|
+
_body="$(cd "$root" && rt_report_body "$_number" 2>/dev/null)"
|
|
82
|
+
[ -n "$_body" ] && printf '%s' "$_body" | grep -qiE "$folder_skip_re" && exit 0
|
|
83
|
+
fi
|
|
84
|
+
|
|
85
|
+
_lying="$(rt_folder_in_branch "$_folder")"
|
|
86
|
+
[ -n "$_lying" ] \
|
|
87
|
+
&& deny "BLOCKED: в ветке осталась папка задачи «${_lying}» — она уедет в главную. Разобрать её потом будет некому: работа перейдёт к следующей задаче, а эта заявка закроется. Перенеси в «${archive_dir:-архив}» то, что объясняет принятые решения, остальное удали и повтори. Если работа вливается частями, поставь в тело заявки строку «Task-folder-skip: <причина>»."
|
|
88
|
+
|
|
89
|
+
[ -n "$archive_dir" ] || exit 0
|
|
90
|
+
_base="$(git merge-base "$main_branch" HEAD 2>/dev/null)"
|
|
91
|
+
[ -z "$_base" ] && exit 0
|
|
92
|
+
rt_folder_was_in_branch "$_base" "$_folder" || exit 0
|
|
93
|
+
|
|
94
|
+
_gained="$(git diff --name-only --diff-filter=A "$_base" HEAD -- "$archive_dir" 2>/dev/null | head -1)"
|
|
95
|
+
[ -z "$_gained" ] \
|
|
96
|
+
&& deny "BLOCKED: папку задачи удалили, но в «${archive_dir}» ветка ничего не добавила. Удалить проще, чем разобрать, — и вместе с папкой пропадает разбор просьбы, единственная запись слов владельца. Перенеси то, что объясняет принятые решения, одним файлом с понятным именем и повтори."
|
|
97
|
+
|
|
98
|
+
exit 0
|
|
99
|
+
}
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
# среди них самая отдельная — своя точка проверки, свои условия молчания, свой отказ.
|
|
12
12
|
|
|
13
13
|
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/utf8.sh" 2>/dev/null || true
|
|
14
|
+
# Начало вызова помощник берёт оттуда же, откуда все гарды. Источается он и здесь, хотя зовущий
|
|
15
|
+
# гард это уже сделал: пустой образец превратил бы признак в поиск слова по всей строке, то есть
|
|
16
|
+
# в отказ на каждом упоминании команды.
|
|
17
|
+
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/hook-input.sh" 2>/dev/null || true
|
|
14
18
|
|
|
15
19
|
rt_delivery_signature() {
|
|
16
20
|
# --- подпись машинного коммита ------------------------------------------------------------
|
|
@@ -33,13 +37,15 @@ rt_delivery_signature() {
|
|
|
33
37
|
# логина разошлись бы молча. Исполнителем задачи он тоже не бывает — в дереве, где хостинг
|
|
34
38
|
# ограничил машинную запись, исполнителем ставят человека, а коммит остаётся машинным.
|
|
35
39
|
#
|
|
36
|
-
# Вызов пуша узнаётся по двум признакам сразу —
|
|
37
|
-
#
|
|
38
|
-
#
|
|
40
|
+
# Вызов пуша узнаётся по двум признакам сразу — начало вызова перед словом `git` и слово `push`
|
|
41
|
+
# отдельным словом. Одной подстрокой «git push» его не поймать: помощник учётных данных и
|
|
42
|
+
# заголовок запроса ставятся ключами `-c` между ними. Начало вызова считает присваивания
|
|
43
|
+
# переменных частью команды: пуш подписанного коммита набирают с подстановкой токена, и признак
|
|
44
|
+
# без них пропускал ровно тот вызов, ради которого этот помощник и заведён.
|
|
39
45
|
# Пробный пуш не отправляет ничего, и подпись у него не спрашивают. Отложенная правка — тоже:
|
|
40
46
|
# `git stash push` кладёт правку в тайник этой же машины, поэтому тайник вырезается из строки, а
|
|
41
47
|
# признак считается по остатку.
|
|
42
|
-
if printf '%s' "$cmd" | grep -qE
|
|
48
|
+
if printf '%s' "$cmd" | grep -qE "${RT_CMD_BOUND}git([[:space:]]|\$)" \
|
|
43
49
|
&& printf '%s' "$cmd" | sed -E 's/git[[:space:]]+stash[[:space:]]+push/git stash/g' | grep -qE '(^|[[:space:]])push([[:space:]]|$)' \
|
|
44
50
|
&& ! printf '%s' "$cmd" | grep -q -- '--dry-run'; then
|
|
45
51
|
# Два условия молчания. Дерево, не назвавшее почты машинной записи, требования не получает:
|