@rt-tools/agent-kit 0.17.0 → 0.19.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-epics.github.mjs +123 -0
- package/assets/checks/board-gh.github.mjs +121 -0
- package/assets/checks/board.github.mjs +77 -73
- package/assets/checks/check-board.github.mjs +39 -3
- package/assets/checks/check-hook-scope.mjs +126 -0
- package/assets/checks/check-profile-drift.mjs +195 -0
- package/assets/checks/check-specs.mjs +1 -1
- package/assets/checks/rt-kit-checks.config.mjs +13 -0
- package/assets/checks/spec-anchors.mjs +2 -2
- package/assets/defaults/project.sh +21 -117
- package/assets/defaults/shell.sh +139 -0
- package/assets/hooks/browser-guard-no-other-drivers.sh +41 -2
- package/assets/hooks/claim-guard.sh +22 -1
- package/assets/hooks/docs-guard.sh +1 -1
- package/assets/hooks/exam-guard.sh +85 -12
- package/assets/hooks/git-guard-delivery-conflict.sh +85 -0
- package/assets/hooks/git-guard-delivery-signature.sh +14 -5
- package/assets/hooks/git-guard-delivery.sh +15 -2
- package/assets/hooks/grill-gate.sh +63 -6
- package/assets/hooks/proposal-guard.sh +11 -4
- package/assets/hooks/rule-source-guard.sh +8 -13
- package/assets/hooks/task-context-load.sh +22 -0
- package/assets/hooks/task-flow-context.sh +9 -0
- package/assets/hooks/task-flow-draft-guard.sh +17 -0
- package/assets/hooks/turn-exit-guard.sh +45 -87
- package/assets/hooks/waiting-turn-guard.sh +65 -2
- package/assets/hooks/work-start-guard.sh +166 -0
- package/assets/hooks/write-targets.sh +24 -0
- package/assets/laws/verifiability.md +28 -0
- package/assets/patterns/browser-verification-measure.md +49 -1
- package/assets/patterns/browser-verification-stand.md +10 -2
- package/assets/patterns/doc-style-write.md +21 -1
- package/assets/patterns/git-workflow-commit.github.md +32 -3
- package/assets/patterns/git-workflow-docker.md +8 -0
- package/assets/patterns/git-workflow-freshness.md +87 -0
- package/assets/patterns/git-workflow-merge.md +23 -0
- package/assets/patterns/git-workflow-pr.github.md +6 -0
- package/assets/patterns/git-workflow-restart.md +18 -0
- package/assets/patterns/git-workflow-stack.md +30 -0
- package/assets/patterns/lib-layers-move.md +4 -0
- package/assets/patterns/spec-driven-domain.md +13 -1
- package/assets/patterns/spec-driven-rule.md +5 -4
- package/assets/patterns/spec-driven-sweep.md +1 -1
- package/assets/patterns/status-report-table.github.md +1 -1
- package/assets/patterns/task-flow-archive.md +12 -2
- package/assets/patterns/task-flow-close.md +31 -24
- package/assets/patterns/task-flow-handoff.md +14 -2
- package/assets/patterns/task-flow-resume.md +18 -3
- package/assets/patterns/task-flow-start.md +23 -23
- package/assets/patterns/testing-e2e.md +23 -0
- package/assets/patterns/turn-entry-map.md +1 -1
- package/assets/pitfalls/agent-kit.md +4 -6
- package/assets/pitfalls/doc-style.md +10 -0
- package/assets/pitfalls/git-workflow.github.md +20 -0
- package/assets/pitfalls/spec-driven.md +6 -0
- package/assets/pitfalls/task-flow.md +20 -0
- package/assets/pitfalls/testing.md +6 -0
- package/assets/pitfalls/turn-conduct.md +35 -0
- package/assets/rules/browser-verification.md +19 -0
- package/assets/rules/deploy-flow.github.md +9 -0
- package/assets/rules/doc-style.md +6 -0
- package/assets/rules/git-workflow.azure.md +17 -2
- package/assets/rules/git-workflow.github.md +89 -90
- package/assets/rules/git-workflow.gitlab.md +12 -4
- package/assets/rules/lists.md +5 -0
- package/assets/rules/observability.needs-app.md +4 -0
- package/assets/rules/reuse-first.md +6 -0
- package/assets/rules/spec-driven.md +29 -25
- package/assets/rules/task-flow.md +52 -46
- package/assets/rules/testing.md +27 -1
- package/assets/rules/turn-conduct.md +46 -43
- package/assets/skills/agent-kit.md +9 -0
- package/package.json +1 -1
- package/rt-tools-agent-kit-0.19.0.tgz +0 -0
- package/rt-tools-agent-kit-0.17.0.tgz +0 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Связь задачи с эпиком. Живёт своим файлом: сверка очереди работ и без неё стоит у предела
|
|
3
|
+
* длины, а читают эти две проверки порознь.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
import { TASK_KEY } from './board.mjs';
|
|
9
|
+
import { CONFIG, ROOT } from './rt-kit-checks.config.mjs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Метка карточки эпика. Не названа — связь не судится: отличить карточку эпика от обычной
|
|
13
|
+
* задачи станет нечем.
|
|
14
|
+
*/
|
|
15
|
+
const EPIC_LABEL = CONFIG.board?.epicLabel ?? '';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Строки состава эпика — те, что стоят в таблице с колонкой «Задача».
|
|
19
|
+
*
|
|
20
|
+
* Замысел эпика держит и другие таблицы: источники находок, состав семей, счёт пунктов. Номера,
|
|
21
|
+
* взятые из всего текста и даже из всех таблиц, делали бы задачей эпика всё, что он упомянул, —
|
|
22
|
+
* прошлый эпик, из находок которого он вырос, разбор, задачу соседнего дерева.
|
|
23
|
+
*/
|
|
24
|
+
function planRows(plan) {
|
|
25
|
+
const rows = [];
|
|
26
|
+
let inside = false;
|
|
27
|
+
for (const line of plan.split('\n')) {
|
|
28
|
+
const isRow = line.trimStart().startsWith('|');
|
|
29
|
+
if (!isRow) {
|
|
30
|
+
inside = false;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (!inside) {
|
|
34
|
+
// Граница слова здесь не годится: `\b` знает только латиницу, и с кириллицей она не
|
|
35
|
+
// совпадает никогда — проверка молчала бы на любом замысле.
|
|
36
|
+
inside = /\|[^|]*Задача/.test(line);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
rows.push(line);
|
|
40
|
+
}
|
|
41
|
+
return rows.join('\n');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Связь задачи с эпиком, прочитанная в обе стороны.
|
|
46
|
+
*
|
|
47
|
+
* Задача, заведённая под эпик, называет его в теле, а замысел эпика называет её со своей
|
|
48
|
+
* стороны. Односторонняя привязка выглядит целой ровно так же, как двусторонняя: читатель
|
|
49
|
+
* приходит то от замысла эпика, то от его карточки, и вторая сторона существует только для одного из
|
|
50
|
+
* них. Держалась она подражанием — пока тело писали с образца соседней задачи того же эпика,
|
|
51
|
+
* строка ехала вместе с формой, а задача, заведённая посреди работы находкой, писалась не с
|
|
52
|
+
* образца.
|
|
53
|
+
*
|
|
54
|
+
* Путь к замыслу берётся из тела карточки эпика: называть его она обязана и так, а настройка
|
|
55
|
+
* каталога завела бы второй источник правды. Путём считается написание с каталогом — голое имя
|
|
56
|
+
* файла в теле встречается прозой и уводило бы проверку на первое же упоминание. Замысла нет на
|
|
57
|
+
* диске — это своё расхождение: карточка ссылается в пустоту.
|
|
58
|
+
*
|
|
59
|
+
* Судится только открытое, как и вся остальная сверка: закрытая задача эпика — история, и
|
|
60
|
+
* строку о ней нечем закрыть.
|
|
61
|
+
*/
|
|
62
|
+
export function checkEpicLinks(open, report) {
|
|
63
|
+
// Метка не названа — карточку эпика отличить от обычной задачи нечем, и проверка молчит.
|
|
64
|
+
// Молчит именно так, а не «эпиков нет»: дерево без эпиков и дерево, не назвавшее метки,
|
|
65
|
+
// здесь неразличимы.
|
|
66
|
+
if (!EPIC_LABEL) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const byNumber = new Map(open.map((issue) => [issue.number, issue]));
|
|
71
|
+
const epics = open.filter((issue) => (issue.labels ?? []).some((label) => label.name === EPIC_LABEL));
|
|
72
|
+
const listedBy = new Map();
|
|
73
|
+
// Эпики, состав которых прочитать не вышло. Их задачи обратной стороной не судятся: о
|
|
74
|
+
// непрочитанном замысле уже сказано своей строкой, и четыре строки «задачи нет в замысле»
|
|
75
|
+
// рядом с ней говорят о том же промахе ещё раз, называя виноватыми чужие задачи.
|
|
76
|
+
const unreadable = new Set();
|
|
77
|
+
|
|
78
|
+
for (const epic of epics) {
|
|
79
|
+
const planPath = String(epic.body ?? '').match(/(?:^|[\s(`])([\w.-]+(?:\/[\w.-]+)+\.md)/)?.[1] ?? null;
|
|
80
|
+
if (planPath === null) {
|
|
81
|
+
report(`#${epic.number}: карточка эпика не называет путь к замыслу — состав эпика читать негде`);
|
|
82
|
+
unreadable.add(epic.number);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (!existsSync(join(ROOT, planPath))) {
|
|
86
|
+
report(`#${epic.number}: замысла эпика «${planPath}» нет на диске — карточка ссылается в пустоту`);
|
|
87
|
+
unreadable.add(epic.number);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const plan = readFileSync(join(ROOT, planPath), 'utf8');
|
|
92
|
+
const mentions = planRows(plan).matchAll(new RegExp(`(?:#|${TASK_KEY}-)(\\d+)`, 'g'));
|
|
93
|
+
const numbers = new Set([...mentions].map((match) => Number(match[1])));
|
|
94
|
+
for (const number of numbers) {
|
|
95
|
+
if (number === epic.number || !byNumber.has(number)) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
listedBy.set(number, epic.number);
|
|
99
|
+
const body = String(byNumber.get(number).body ?? '');
|
|
100
|
+
if (!new RegExp(`(?:#|${TASK_KEY}-)${epic.number}\\b`).test(body)) {
|
|
101
|
+
report(
|
|
102
|
+
`#${number}: замысел эпика #${epic.number} задачу называет, а её тело эпика — нет. Допиши строку «Задача эпика #${epic.number}, замысел — ${planPath}»`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Обратная сторона: тело назвало эпик, а замысел эпика этой задачи не знает. Читается
|
|
109
|
+
// это как задача под эпиком, но «взять следующую» её не отдаст никогда.
|
|
110
|
+
const epicNumbers = new Set(epics.map((issue) => issue.number));
|
|
111
|
+
for (const issue of open) {
|
|
112
|
+
if (epicNumbers.has(issue.number)) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const named = String(issue.body ?? '').match(new RegExp(`эпика?\\s+(?:#|${TASK_KEY}-)(\\d+)`, 'i'))?.[1];
|
|
116
|
+
if (named === undefined || !epicNumbers.has(Number(named)) || unreadable.has(Number(named))) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (listedBy.get(issue.number) !== Number(named)) {
|
|
120
|
+
report(`#${issue.number}: тело называет эпик #${named}, а в его замысле задачи нет — «взять следующую» её не отдаст`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Вызов клиента хостинга: как его находят, чем подписывают и что считается временным отказом.
|
|
4
|
+
*
|
|
5
|
+
* Отделено от работы с очередью работ потому, что предмет здесь другой — не задача и не колонка,
|
|
6
|
+
* а сам вызов: где взять исполняемый файл, каким токеном его подписать, что читать как «сети
|
|
7
|
+
* нет» и что повторить. Вместе с очередью это переросло предел длины файла.
|
|
8
|
+
*/
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
|
|
13
|
+
import { CONFIG } from './rt-kit-checks.config.mjs';
|
|
14
|
+
|
|
15
|
+
const BOARD = CONFIG.board ?? {};
|
|
16
|
+
const BOT_TOKEN_FILE = BOARD.tokenPath ? BOARD.tokenPath.replace(/^~/, homedir()) : '';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `gh` у владельца подменён обёрткой менеджера паролей, и вызов по имени уходит в неё.
|
|
20
|
+
* Поэтому сначала пробуется настоящий исполняемый файл, и только потом имя из PATH.
|
|
21
|
+
*/
|
|
22
|
+
function ghBinary() {
|
|
23
|
+
if (process.env.GH_BIN) {
|
|
24
|
+
return process.env.GH_BIN;
|
|
25
|
+
}
|
|
26
|
+
const homebrew = '/opt/homebrew/bin/gh';
|
|
27
|
+
return existsSync(homebrew) ? homebrew : 'gh';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Токен машинной записи лежит вне репозитория и в вывод не попадает. Его отсутствие — не отказ:
|
|
32
|
+
* дерево, не назвавшее токена в `board.tokenPath`, работает с очередью учётной записью, под
|
|
33
|
+
* которой залогинен клиент хостинга.
|
|
34
|
+
*/
|
|
35
|
+
export function botToken() {
|
|
36
|
+
if (!existsSync(BOT_TOKEN_FILE)) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const token = readFileSync(BOT_TOKEN_FILE, 'utf8').trim();
|
|
40
|
+
return token.length > 0 ? token : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class OfflineError extends Error {}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Отказ сети от отказа по существу отличается только текстом: `gh` на оба отвечает
|
|
47
|
+
* ненулевым кодом. Сюда попадает то, после чего проверять нечем, — а не то, что
|
|
48
|
+
* проверено и оказалось не так.
|
|
49
|
+
*/
|
|
50
|
+
function isOffline(stderr) {
|
|
51
|
+
return /dial tcp|no such host|network is unreachable|timeout|TLS handshake|connection refused|Bad credentials|authentication|not logged/i.test(
|
|
52
|
+
stderr
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Отказ, который проходит сам: хостинг ответил, но не смог.
|
|
58
|
+
*
|
|
59
|
+
* Отличается от отказа по существу тем, что повтор его снимает: пятисотые коды, шлюз и его
|
|
60
|
+
* таймаут. Отказ по праву, по несуществующей записи и по незнакомой колонке сюда не попадают —
|
|
61
|
+
* они не пройдут и на третий раз, а ждать заставят.
|
|
62
|
+
*/
|
|
63
|
+
function isUnavailable(stderr) {
|
|
64
|
+
return /HTTP 50[0234]\b|Bad Gateway|Service Unavailable|Gateway Time-?out|Server Error/i.test(stderr);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Сколько раз пробовать вызов, который отбит недоступностью хостинга. */
|
|
68
|
+
const TRIES = 3;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Пауза перед следующей попыткой, вдвое длиннее прежней. Синхронная: вызов хостинга здесь тоже
|
|
72
|
+
* синхронный, и уводить его в обещание пришлось бы вместе со всеми, кто его зовёт.
|
|
73
|
+
*
|
|
74
|
+
* Число берётся из окружения ради проб: своей паузы им не нужно, а ждать три секунды на каждый
|
|
75
|
+
* сценарий они не обязаны.
|
|
76
|
+
*/
|
|
77
|
+
const PAUSE_MS = Number(process.env.RT_GH_RETRY_MS ?? 1000) || 0;
|
|
78
|
+
|
|
79
|
+
function pause(ms) {
|
|
80
|
+
if (ms > 0) {
|
|
81
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function gh(args, { token } = {}) {
|
|
86
|
+
const env = { ...process.env };
|
|
87
|
+
if (token) {
|
|
88
|
+
env.GH_TOKEN = token;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let waited = PAUSE_MS;
|
|
92
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
93
|
+
try {
|
|
94
|
+
return execFileSync(ghBinary(), args, { env, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
95
|
+
} catch (error) {
|
|
96
|
+
const stderr = String(error.stderr ?? error.message ?? '');
|
|
97
|
+
if (error.code === 'ENOENT' || isOffline(stderr)) {
|
|
98
|
+
throw new OfflineError(stderr.trim() || 'gh недоступен');
|
|
99
|
+
}
|
|
100
|
+
// Правило требует двигать колонку тем же движением, что и работу, а хостинг отвечал
|
|
101
|
+
// недоступностью час подряд: без повтора исполнитель либо крутит вызов руками, либо
|
|
102
|
+
// оставляет колонку отставшей — и находит это сверка уже после открытия заявки.
|
|
103
|
+
if (isUnavailable(stderr) && attempt < TRIES) {
|
|
104
|
+
pause(waited);
|
|
105
|
+
waited *= 2;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const failure = new Error(stderr.trim() || `gh ${args[0]} завершился с ошибкой`);
|
|
109
|
+
failure.stderr = stderr;
|
|
110
|
+
throw failure;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function ghJson(args, options) {
|
|
116
|
+
return JSON.parse(gh(args, options));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function graphql(query, options) {
|
|
120
|
+
return ghJson(['api', 'graphql', '-f', `query=${query}`], options);
|
|
121
|
+
}
|
|
@@ -18,13 +18,16 @@
|
|
|
18
18
|
* Нет сети или нет токена — это не расхождение, а невозможность проверить:
|
|
19
19
|
* функции возвращают `null`, командный режим печатает `{"offline":true}`.
|
|
20
20
|
*/
|
|
21
|
-
import { execFileSync } from 'node:child_process';
|
|
22
21
|
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
23
|
-
import { homedir } from 'node:os';
|
|
24
22
|
import { join } from 'node:path';
|
|
25
23
|
|
|
24
|
+
import { OfflineError, botToken, gh, ghJson, graphql } from './board-gh.mjs';
|
|
26
25
|
import { CONFIG, ROOT } from './rt-kit-checks.config.mjs';
|
|
27
26
|
|
|
27
|
+
// Наружу клиент уезжает отсюда прежним именем: его зовут у борды три соседних модуля и команды
|
|
28
|
+
// дерева, и переписывать их ради переезда объявления значило бы платить за деление файла дважды.
|
|
29
|
+
export { OfflineError, botToken, gh, ghJson, graphql };
|
|
30
|
+
|
|
28
31
|
/**
|
|
29
32
|
* Адрес борды и её колонки живут в `.claude/rt-kit/checks.json`: идентификаторы проекта, поля
|
|
30
33
|
* и вариантов выдаёт сам GitHub при заведении борды, и угадать их нельзя. Пустое значение
|
|
@@ -69,72 +72,6 @@ if (PROJECT_ID && !TASK_KEY) {
|
|
|
69
72
|
}
|
|
70
73
|
/** Кого запрашивают на разбор: без ревьювера PR не попадает во входящие владельца. */
|
|
71
74
|
export const REVIEWER = BOARD.reviewer ?? '';
|
|
72
|
-
const BOT_TOKEN_FILE = BOARD.tokenPath ? BOARD.tokenPath.replace(/^~/, homedir()) : '';
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* `gh` у владельца подменён обёрткой менеджера паролей, и вызов по имени уходит в неё.
|
|
76
|
-
* Поэтому сначала пробуется настоящий исполняемый файл, и только потом имя из PATH.
|
|
77
|
-
*/
|
|
78
|
-
function ghBinary() {
|
|
79
|
-
if (process.env.GH_BIN) {
|
|
80
|
-
return process.env.GH_BIN;
|
|
81
|
-
}
|
|
82
|
-
const homebrew = '/opt/homebrew/bin/gh';
|
|
83
|
-
return existsSync(homebrew) ? homebrew : 'gh';
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Токен машинной записи лежит вне репозитория и в вывод не попадает. Его отсутствие — не отказ:
|
|
88
|
-
* дерево, не назвавшее токена в `board.tokenPath`, работает с очередью учётной записью, под
|
|
89
|
-
* которой залогинен клиент хостинга.
|
|
90
|
-
*/
|
|
91
|
-
export function botToken() {
|
|
92
|
-
if (!existsSync(BOT_TOKEN_FILE)) {
|
|
93
|
-
return null;
|
|
94
|
-
}
|
|
95
|
-
const token = readFileSync(BOT_TOKEN_FILE, 'utf8').trim();
|
|
96
|
-
return token.length > 0 ? token : null;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export class OfflineError extends Error {}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Отказ сети от отказа по существу отличается только текстом: `gh` на оба отвечает
|
|
103
|
-
* ненулевым кодом. Сюда попадает то, после чего проверять нечем, — а не то, что
|
|
104
|
-
* проверено и оказалось не так.
|
|
105
|
-
*/
|
|
106
|
-
function isOffline(stderr) {
|
|
107
|
-
return /dial tcp|no such host|network is unreachable|timeout|TLS handshake|connection refused|Bad credentials|authentication|not logged/i.test(
|
|
108
|
-
stderr
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export function gh(args, { token } = {}) {
|
|
113
|
-
const env = { ...process.env };
|
|
114
|
-
if (token) {
|
|
115
|
-
env.GH_TOKEN = token;
|
|
116
|
-
}
|
|
117
|
-
try {
|
|
118
|
-
return execFileSync(ghBinary(), args, { env, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
119
|
-
} catch (error) {
|
|
120
|
-
const stderr = String(error.stderr ?? error.message ?? '');
|
|
121
|
-
if (error.code === 'ENOENT' || isOffline(stderr)) {
|
|
122
|
-
throw new OfflineError(stderr.trim() || 'gh недоступен');
|
|
123
|
-
}
|
|
124
|
-
const failure = new Error(stderr.trim() || `gh ${args[0]} завершился с ошибкой`);
|
|
125
|
-
failure.stderr = stderr;
|
|
126
|
-
throw failure;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export function ghJson(args, options) {
|
|
131
|
-
return JSON.parse(gh(args, options));
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export function graphql(query, options) {
|
|
135
|
-
return ghJson(['api', 'graphql', '-f', `query=${query}`], options);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
75
|
/**
|
|
139
76
|
* Тикеты, стоящие на борде, и элементы борды, тикетами не являющиеся. У каждого тикета —
|
|
140
77
|
* его элемент борды и колонка: переставить задачу можно только по идентификатору элемента,
|
|
@@ -189,7 +126,12 @@ export function moveTask(number, status, options) {
|
|
|
189
126
|
}
|
|
190
127
|
|
|
191
128
|
export function fetchIssues(state, options) {
|
|
192
|
-
|
|
129
|
+
// Тело берётся вместе со списком, а не поштучным вызовом на задачу: связь с эпиком читается
|
|
130
|
+
// как раз в нём, а четыреста вызовов вида «покажи одну задачу» стоили бы дороже всей сверки.
|
|
131
|
+
return ghJson(
|
|
132
|
+
['issue', 'list', '--state', state, '--limit', '400', '--json', 'number,title,state,assignees,labels,body'],
|
|
133
|
+
options,
|
|
134
|
+
);
|
|
193
135
|
}
|
|
194
136
|
|
|
195
137
|
export function fetchIssue(number, options) {
|
|
@@ -237,8 +179,7 @@ export function pullState(ref, options) {
|
|
|
237
179
|
author: pull.author?.login ?? null,
|
|
238
180
|
reviewers,
|
|
239
181
|
reviewed: reviewers.filter((login) => login !== (pull.author?.login ?? null)).length > 0,
|
|
240
|
-
// Конфликт приезжает
|
|
241
|
-
// хостинг считает сливаемость заново после каждой правки главной ветки. Судится только
|
|
182
|
+
// Конфликт приезжает чужим слиянием, без единого действия автора заявки. Судится только
|
|
242
183
|
// прямое «конфликтует»: `UNKNOWN` означает, что хостинг ещё считает, и читать его как
|
|
243
184
|
// конфликт значило бы отбивать работу на каждой свежей вершине.
|
|
244
185
|
conflicting: pull.mergeable === 'CONFLICTING',
|
|
@@ -246,9 +187,30 @@ export function pullState(ref, options) {
|
|
|
246
187
|
}
|
|
247
188
|
|
|
248
189
|
/**
|
|
249
|
-
*
|
|
250
|
-
*
|
|
190
|
+
* На сколько коммитов ветка заявки позади главной. Гард судит основание в минуту открытия, а
|
|
191
|
+
* заявка стоит днями: влитого за это время не видит ни он, ни зелёный прогон. Сравнить нечем —
|
|
192
|
+
* ноль: сверка без доступа отбивала бы работу вместо промаха; без сети летит отказ, как везде.
|
|
251
193
|
*/
|
|
194
|
+
export function behindMain(branch, mainBranch, options) {
|
|
195
|
+
if (!OWNER || !REPO || !branch || !mainBranch) {
|
|
196
|
+
return 0;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
const behind = gh(
|
|
200
|
+
['api', `repos/${OWNER}/${REPO}/compare/${encodeURIComponent(mainBranch)}...${encodeURIComponent(branch)}`, '--jq', '.behind_by'],
|
|
201
|
+
options
|
|
202
|
+
);
|
|
203
|
+
return Number(String(behind).trim()) || 0;
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (error instanceof OfflineError) {
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
return 0;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Вершина берётся вместе с остальным: по ней судят и папку задачи, и прогон, а спросить её
|
|
213
|
+
* потом значило бы второй вызов на каждый PR. */
|
|
252
214
|
export function fetchOpenPulls(options) {
|
|
253
215
|
return ghJson(
|
|
254
216
|
['pr', 'list', '--state', 'open', '--limit', '200', '--json', 'number,title,headRefName,headRefOid,isDraft,body,mergeable'],
|
|
@@ -256,6 +218,32 @@ export function fetchOpenPulls(options) {
|
|
|
256
218
|
);
|
|
257
219
|
}
|
|
258
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Свои открытые заявки, помеченные конфликтующими. Спрашивается это в минуту, когда берётся
|
|
223
|
+
* новая работа: пока отданное конфликтует, влить его человек не может, и каждая следующая
|
|
224
|
+
* заявка прибавляет к очереди ещё одну, которую придётся догонять.
|
|
225
|
+
*
|
|
226
|
+
* Свои — значит открытые машинной записью дерева. Дерево, её не назвавшее, не спрашивается
|
|
227
|
+
* вовсе: `@me` отвечал бы учётной записью, под которой залогинен клиент хостинга, а это чаще
|
|
228
|
+
* всего владелец, и его заявки исполнителю не чинить.
|
|
229
|
+
*
|
|
230
|
+
* Судится только прямое `CONFLICTING`. `UNKNOWN` означает, что хостинг ещё считает сливаемость
|
|
231
|
+
* — он пересчитывает её после каждой правки главной ветки, — и читать его как конфликт значило
|
|
232
|
+
* бы отбивать работу на каждой свежей вершине.
|
|
233
|
+
*/
|
|
234
|
+
export function conflictingPulls(options) {
|
|
235
|
+
if (!BOT) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
const pulls = ghJson(['pr', 'list', '--author', BOT, '--state', 'open', '--limit', '100', '--json', 'number,headRefName,mergeable'], options);
|
|
239
|
+
if (!Array.isArray(pulls)) {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
return pulls
|
|
243
|
+
.filter((pull) => pull.mergeable === 'CONFLICTING')
|
|
244
|
+
.map((pull) => ({ number: pull.number ?? null, branch: pull.headRefName ?? '' }));
|
|
245
|
+
}
|
|
246
|
+
|
|
259
247
|
/** `[<КЛЮЧ>-<номер>]` в начале заголовка — единственная форма номера в названиях */
|
|
260
248
|
export const TITLE_NUMBER = new RegExp(`^\\[${TASK_KEY}-(\\d+)\\]\\s+\\S`);
|
|
261
249
|
/** `<КЛЮЧ>-<номер>-<slug>` — имя ветки, отведённой под задачу */
|
|
@@ -447,6 +435,22 @@ if (isEntryPoint && process.argv[2] === 'pr') {
|
|
|
447
435
|
}
|
|
448
436
|
}
|
|
449
437
|
|
|
438
|
+
// Свои конфликтующие заявки одной строкой JSON: `{"conflicting":[{"number":…,"branch":…}]}`.
|
|
439
|
+
// Зовёт её гард поставки перед тем, как пустить взятие новой работы. Дерево без машинной записи
|
|
440
|
+
// отвечает пустым списком: спрашивать не о ком.
|
|
441
|
+
if (isEntryPoint && process.argv[2] === 'conflicts') {
|
|
442
|
+
try {
|
|
443
|
+
process.stdout.write(`${JSON.stringify({ conflicting: conflictingPulls() ?? [] })}\n`);
|
|
444
|
+
} catch (error) {
|
|
445
|
+
if (error instanceof OfflineError) {
|
|
446
|
+
process.stdout.write('{"offline":true}\n');
|
|
447
|
+
} else {
|
|
448
|
+
process.stdout.write(`${JSON.stringify({ error: String(error.message ?? error) })}\n`);
|
|
449
|
+
process.exit(1);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
450
454
|
// Перевод колонки правит борду. Токен машинной записи здесь необязателен: не назвавшее его
|
|
451
455
|
// дерево правит борду учётной записью, под которой залогинен клиент хостинга. Требование
|
|
452
456
|
// токена держало бы очередь работ у дерева, машинной записи не заводившего, и у дерева, чью
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
fetchBoard,
|
|
43
43
|
fetchIssues,
|
|
44
44
|
fetchOpenPulls,
|
|
45
|
+
behindMain,
|
|
45
46
|
gh,
|
|
46
47
|
ghJson,
|
|
47
48
|
numberFromTaskDir,
|
|
@@ -50,6 +51,7 @@ import {
|
|
|
50
51
|
} from './board.mjs';
|
|
51
52
|
import { onlyIgnoredPaths } from './board-paths.mjs';
|
|
52
53
|
import { HAS_PIPELINE, deployLag, evictedOnHead, headCommittedAt, runsOnHead, verdictOnHead } from './board-runs.mjs';
|
|
54
|
+
import { checkEpicLinks } from './board-epics.mjs';
|
|
53
55
|
import { similarTitles } from './board-titles.mjs';
|
|
54
56
|
import { CONFIG, ROOT } from './rt-kit-checks.config.mjs';
|
|
55
57
|
|
|
@@ -70,6 +72,18 @@ const RUN_GRACE_MINUTES = 10;
|
|
|
70
72
|
* Рабочий поток выкатки и ветка, с которой прод сравнивают. Не назвав потока, дерево сверки
|
|
71
73
|
* прода не получает — и сверка говорит об этом вслух: молчание читалось бы как «прод сошёлся».
|
|
72
74
|
*/
|
|
75
|
+
/**
|
|
76
|
+
* Метки, которыми в очереди работ помечен груз — присланные деревьями разборы происшествий и
|
|
77
|
+
* предложения. Задачами они не являются: заголовок у них без номера, исполнителя нет, на борде
|
|
78
|
+
* их нет, — и сверка печатала на каждую по три строки, среди которых настоящее расхождение уже
|
|
79
|
+
* не читалось. Заводить такие записи в очередь перестали, а заведённые прежним порядком оттуда
|
|
80
|
+
* никуда не денутся: закрывать их — решение владельца, а не сверки.
|
|
81
|
+
*
|
|
82
|
+
* Имя метки называет дерево: у каждого оно своё, а выдуманное умолчание не совпало бы ни с чем
|
|
83
|
+
* и молча выключило бы отсев. Дерево, метки не назвавшее, судится как прежде.
|
|
84
|
+
*/
|
|
85
|
+
const CARGO_LABELS = new Set(CONFIG.board?.cargoLabels ?? []);
|
|
86
|
+
|
|
73
87
|
const DEPLOY_WORKFLOW = CONFIG.deploy?.workflow ?? '';
|
|
74
88
|
const MAIN_BRANCH = CONFIG.deploy?.mainBranch ?? 'main';
|
|
75
89
|
|
|
@@ -265,7 +279,7 @@ function checkConflicting(pull) {
|
|
|
265
279
|
);
|
|
266
280
|
}
|
|
267
281
|
|
|
268
|
-
let checked = { issues: 0, pulls: 0 };
|
|
282
|
+
let checked = { issues: 0, pulls: 0, cargo: 0 };
|
|
269
283
|
|
|
270
284
|
// Черновики судятся по диску и потому проверяются всегда: связи для этого не нужно.
|
|
271
285
|
checkDrafts();
|
|
@@ -275,9 +289,13 @@ try {
|
|
|
275
289
|
const options = { token: botToken() ?? undefined };
|
|
276
290
|
const board = fetchBoard(options);
|
|
277
291
|
const issues = fetchIssues('all', options);
|
|
278
|
-
const
|
|
292
|
+
const allOpen = issues.filter((issue) => issue.state === 'OPEN');
|
|
293
|
+
// Отсев идёт один раз и до всех проверок задачи: помеченная запись не задача целиком, а не
|
|
294
|
+
// наполовину, — её не судят ни заголовком, ни исполнителем, ни бордой, ни сводкой совпавших
|
|
295
|
+
// заголовков, ни связью с эпиком.
|
|
296
|
+
const open = CARGO_LABELS.size === 0 ? allOpen : allOpen.filter((issue) => !(issue.labels ?? []).some((label) => CARGO_LABELS.has(label.name)));
|
|
279
297
|
const pulls = fetchOpenPulls(options);
|
|
280
|
-
checked = { issues: issues.length, pulls: pulls.length };
|
|
298
|
+
checked = { issues: issues.length, pulls: pulls.length, cargo: allOpen.length - open.length };
|
|
281
299
|
|
|
282
300
|
for (const item of board.foreign) {
|
|
283
301
|
report(`борда: ${item} — на борде стоят задачи, а не PR о них`);
|
|
@@ -298,6 +316,7 @@ try {
|
|
|
298
316
|
}
|
|
299
317
|
|
|
300
318
|
similarTitles(open);
|
|
319
|
+
checkEpicLinks(open, report);
|
|
301
320
|
|
|
302
321
|
const openNumbers = new Set(open.map((issue) => issue.number));
|
|
303
322
|
const claimed = new Map();
|
|
@@ -330,6 +349,17 @@ try {
|
|
|
330
349
|
|
|
331
350
|
checkConflicting(pull);
|
|
332
351
|
|
|
352
|
+
// Отставание заявки от главной ветки: гард судит основание один раз, в минуту открытия,
|
|
353
|
+
// а заявка стоит днями. Влитая с отставанием, она везёт в главную сочетание, которого не
|
|
354
|
+
// проверял никто, — и по странице заявки этого не видно: прогон на ней зелёный.
|
|
355
|
+
const behind = behindMain(pull.headRefName, MAIN_BRANCH, options);
|
|
356
|
+
if (behind > 0) {
|
|
357
|
+
report(
|
|
358
|
+
`PR #${pull.number}: ветка отстала от «${MAIN_BRANCH}» на ${behind} коммитов — прогон шёл от основания, ` +
|
|
359
|
+
`которого в главной ветке уже нет. Влей главную, пересмотри набор проверок по тому, что ветка везёт теперь, и прогони заново`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
333
363
|
if (!FOLDER_SKIP.test(String(pull.body ?? '')) && pull.headRefName) {
|
|
334
364
|
const folder = folderInBranch(pull.headRefName, options);
|
|
335
365
|
if (folder !== null) {
|
|
@@ -407,6 +437,12 @@ if (!offline && !DEPLOY_WORKFLOW) {
|
|
|
407
437
|
console.log('check-board: прод с главной веткой не сверялся — рабочий поток выкатки в настройке дерева не назван');
|
|
408
438
|
}
|
|
409
439
|
|
|
440
|
+
// Отсеянное называется числом: молчаливый отсев неотличим от сломанной сверки — метка,
|
|
441
|
+
// названная с опечаткой, выключила бы проверку целиком и не сказала бы об этом.
|
|
442
|
+
if (!offline && checked.cargo > 0) {
|
|
443
|
+
console.log(`check-board: записей груза в очереди ${checked.cargo} — задачами они не судятся`);
|
|
444
|
+
}
|
|
445
|
+
|
|
410
446
|
if (!offline && !HAS_PIPELINE) {
|
|
411
447
|
console.log('check-board: прогоны на вершинах не спрашивались — файла конвейера в дереве нет');
|
|
412
448
|
}
|