feat(process): risk by changed hunks decides ship and informs show (#707)

The ship limits count lines and files but not what was touched: a
12-line pointerdown handler passed them like a typo and merged unread.
The track rule also lived twice - the guard computed the cycle limit in
bash while process-track.mjs computed the track, and the two disagreed
on multiple track labels. The packet still told authors to rebase
show/ship branches that merge cleanly.

- scripts/change-risk.mjs: one pure classifier over `git diff -U0` from
  the merge base. Class A lines only; comments, blank lines and pure
  renames give no risk; deletions do. Area and token rules per class
  (geometry, touch, migration, devices, perf, ux, visual render/ui),
  evidence as path:line, five per class.
- process-track.mjs: owner confirmation is a comment line
  "Трек: <x> — решение владельца" by the repo owner (latest wins, only
  for the current track); several track labels read as the strictest
  with a warning; cycleLimit, guardLimit and rebaseBeforeReview are the
  single source. `stage` makes the whole S7 track decision in one call:
  ship with risk and no confirmation is raised to show with evidence,
  a confirmed ship keeps merging without the model and records the risk
  for the batch review; show/ask get a risk note for the reviewer.
- _process.yml: the guard asks process-track.mjs for the limit and keeps
  no track logic; the track step calls the script once and only
  executes its raise flag and comment file; risk_note reaches the
  Review prompt, ship_risk reaches the hp:ship-merge comment (marker
  line unchanged).
- task-packet.mjs: track basis, limit and rebase policy; next step
  without the stale rebase line; risk with its consequence per track;
  required checks with reasons (ci:golden only on render risk);
  changelog and visual evidence - from the same exports.
- ship-review.mjs: the batch brief prints the risk line of a ship merge.
- Canon: PROCESS.md §5, §5.1, §10.4, §11.7, both digests, AGENTS.md.
- Registry anchors that watched the moved code are moved, not dropped.

Issue: #707
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-10-01 00:03:35 +00:00
committed by claude[bot]
parent a49f7095ce
commit 1d51beade1
15 changed files with 1750 additions and 161 deletions
+302 -35
View File
@@ -1,42 +1,65 @@
#!/usr/bin/env node
/**
* Трек задачи и рамки `ship` (PROCESS.md §5, #695/#696).
* Трек задачи, его основание, лимит циклов и рамки `ship` (PROCESS.md §5,
* #695/#696/#707) — одно правило на конвейер и пакет задачи.
*
* node scripts/process-track.mjs stage --stage=code|spec --labels="a,b" --branch=<имя> \
* --base=origin/dev --head=HEAD --comments=<json> --owner=<login> --out=<dir> [--run-url=<url>]
* node scripts/process-track.mjs limit --labels="a,b" [--files=<список путей>]
* node scripts/process-track.mjs resolve --labels="a,b" --base=<ref> --head=<ref>
* node scripts/process-track.mjs ship-limits --base=<ref> --head=<ref>
*
* `resolve` печатает `track=ship|show|ask`, `mutants=true|false` и
* `full=true|false` — то, что конвейер ревью читает, решая, сколько стоит
* заход: мутантов в разработке нет ни на одном треке (#709) — `mutants`
* всегда `false`, весь реестр проверяет только ночной прогон; полный
* набор (смоки, golden, perf) на ветке задачи — только меткам `ci:full` и
* `ci:golden` (#697). Инфраструктурная задача без трековой
* метки — `show` (§5.1); признак инфраструктуры механический, как в §1: в
* диффе ни одного файла класса A.
* `stage` — шаг «Трек задачи и рамки ship» конвейера: ОДИН вызов решает трек,
* его основание, рамки ship, риск по изменённым участкам (#707), повышение
* ship → show, заметку риска для промпта ревью и строку риска для комментария
* слияния ship. Печатает `track=`, `mutants=`, `full=`, `ship=`, `raise=`,
* `basis=`, `risk=`; те же поля и многострочные `risk_note`/`ship_risk` пишет в
* `$GITHUB_OUTPUT`; при `raise=true` кладёт тело комментария в `<out>/raise.md`.
* Bash шага только исполняет: логики трека в нём нет.
*
* `ship-limits` печатает `ship=true|false` и по строке `violation=…` на каждое
* нарушение рамок: дифф `src/**` не больше 30 строк, без новых файлов в
* `src/**`, без i18n, без полей конфига и без Python. Рамки механические
* намеренно: по ним конвейер сливает задачу без ревью модели, и решать их
* «на глаз» некому.
* `limit` — job `guard`: трек и лимит циклов по меткам и списку файлов из
* compare API; ответ на 300 файлов и больше инфраструктуру не доказывает.
*
* Мутантов в разработке нет ни на одном треке (#709) — `mutants` всегда
* `false`. Полный набор (смоки, golden, perf) на ветке задачи — только меткам
* `ci:full` и `ci:golden` (#697): риск, включая `visual`, его не заказывает.
* Рамки ship механические намеренно: по ним конвейер сливает задачу без ревью
* модели, и решать их «на глаз» некому.
*/
import { spawnSync } from 'node:child_process';
import { appendFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { isMainModule } from './spawn-portable.mjs';
import { classify } from './process-gate.mjs';
import { classify } from './change-classes.mjs';
import { classifyRisk, emptyRisk, riskClassLine, RISK_CLASSES, RAISING_CLASSES } from './change-risk.mjs';
// Классификатор риска живёт рядом (ТЗ #707 §10 п.5); пакет и конвейер берут его отсюда.
export { classifyRisk, emptyRisk, riskClassLine, RISK_CLASSES, RAISING_CLASSES };
export const SHIP_SRC_LINE_LIMIT = 30;
/** Потолок ответа compare API: полный список файлов при нём не доказан. */
export const COMPARE_FILES_CAP = 300;
/** Не больше стольких строк заметки риска в промпте ревью. */
export const RISK_NOTE_LINE_LIMIT = 25;
/** Трековые метки от строжайшей к мягкой. */
const STRICTEST_FIRST = ['ask', 'show', 'ship'];
/** Явные трековые метки задачи, строжайшая первой. */
export const explicitTracks = (labels = []) => STRICTEST_FIRST.filter((track) => labels.includes(`track:${track}`));
/**
* Трек по меткам: `track:*` главнее прежних меток; `trivial` и `small`
* читаются как `show` (§5.1); задача без трековой метки — `ask`. Метки трека
* не доказывают продуктовый поток: инфраструктурной задаче владелец тоже может
* Трек по меткам: `track:*` главнее прежних меток, из нескольких `track:*`
* действует строжайшая (`ask` > `show` > `ship`) — это дефект разметки, и
* решать его в пользу дешёвого трека нельзя (#707). `trivial` и `small`
* читаются как `show` (§5.1); задача без трековой метки — `ask`. Метки трека не
* доказывают продуктовый поток: инфраструктурной задаче владелец тоже может
* поставить `track:*`, чтобы задать цену конвейера.
*/
export function trackFromLabels(labels = []) {
if (labels.includes('track:ship')) return 'ship';
if (labels.includes('track:show')) return 'show';
if (labels.includes('track:ask')) return 'ask';
const [strictest] = explicitTracks(labels);
if (strictest) return strictest;
if (labels.includes('trivial') || labels.includes('small')) return 'show';
return 'ask';
}
@@ -46,21 +69,121 @@ export const hasTrackLabel = (labels = []) => ['track:ship', 'track:show', 'trac
.some((label) => labels.includes(label));
/**
* Трек, по которому конвейер оценивает заход. Явная метка решает всё; без неё
* инфраструктурная задача (ни одного файла класса A в диффе) — `show`, прочие —
* `ask`. Мутанты проверяют тесты, а не продукт: в разработке их не гоняют ни
* локально, ни в CI (#709, решение владельца 2026-09-29) — только ночной полный
* реестр (`mutation-gate.yml`, #513). Поле остаётся для совместимости выхода.
* Полный набор — по меткам `ci:full` и `ci:golden` на любом треке (#697).
* Трек по меткам и признаку инфраструктуры: явная метка решает всё; без неё
* инфраструктурная задача (§1) — `show`, прочие — `ask` (§5.1). Одна функция на
* guard, шаг трека и пакет задачи.
*/
export function resolveTrack({ labels = [], files = [] } = {}) {
const infrastructure = files.length > 0 && files.every((file) => classify(file) !== 'A');
const track = hasTrackLabel(labels) ? trackFromLabels(labels) : (infrastructure ? 'show' : 'ask');
export const labelTrack = ({ labels = [], infrastructure = false } = {}) => (hasTrackLabel(labels) ? trackFromLabels(labels) : (infrastructure ? 'show' : 'ask'));
/**
* Трек, по которому конвейер оценивает заход. Признак инфраструктуры
* механический, как в §1: в диффе ни одного файла класса A; обрезанный список
* (`filesCapped`) его не доказывает. Мутанты проверяют тесты, а не продукт: в
* разработке их не гоняют ни локально, ни в CI (#709, решение владельца
* 2026-09-29) — только ночной полный реестр (`mutation-gate.yml`, #513). Поле
* остаётся для совместимости выхода. Полный набор — по меткам `ci:full` и
* `ci:golden` на любом треке (#697).
*/
export function resolveTrack({ labels = [], files = [], filesCapped = false } = {}) {
const infrastructure = !filesCapped && files.length > 0 && files.every((file) => classify(file) !== 'A');
const track = labelTrack({ labels, infrastructure });
const mutants = false;
const full = labels.includes('ci:full') || labels.includes('ci:golden');
return { track, mutants, full, infrastructure };
}
/** Лимит циклов код-ревью (§4, §5): `ask` — 4, `show`/`ship` — 2. */
export const cycleLimit = (track) => (track === 'ask' ? 4 : 2);
/**
* Трек и лимит для job `guard`: пути — из compare API, у которого потолок
* ответа 300 файлов; такой ответ инфраструктуру не доказывает — остаётся `ask`/4.
*/
export function guardLimit({ labels = [], files = [] } = {}) {
const { track, infrastructure } = resolveTrack({ labels, files, filesCapped: files.length >= COMPARE_FILES_CAP });
return { track, limit: cycleLimit(track), infrastructure };
}
/**
* Ребейз до ревью (§10.4, #257, #696): `ask` приводится к `dev` всегда;
* `show`/`ship` — только если слияние с `dev` не чистое (`mergeClean !== true`:
* неизвестная чистота — тоже ребейз, как у шага «Привести ветку к dev»).
*/
export const rebaseBeforeReview = (track, mergeClean) => track === 'ask' || mergeClean !== true;
/**
* Строка подтверждения трека владельцем (§5, #707): в начале строки
* комментария, тире любое из «—», «–», «-», слова без учёта регистра. Агент её не
* пишет никогда: агенты и конвейер действуют от учётной записи владельца, и по
* автору события их не отличить (ТЗ #707 §10 п.1).
*/
export const OWNER_TRACK_LINE = /^[ \t]*трек:[ \t]*(ship|show|ask)[ \t]*[—–-][ \t]*решение владельца/gimu;
/** Комментарии из `gh issue view --json comments` (или массива) в общий вид. */
export function normalizeComments(data) {
const list = Array.isArray(data) ? data : data?.comments;
if (!Array.isArray(list)) return null;
return list.map((c) => ({
author: typeof c?.author === 'string' ? c.author : (c?.author?.login ?? null),
body: String(c?.body ?? ''),
createdAt: c?.createdAt ?? null,
url: c?.url ?? null,
}));
}
/** Самая поздняя строка владельца `Трек: <x> — решение владельца` по времени комментария. */
export function ownerTrackLine(comments, owner) {
if (!Array.isArray(comments) || !owner) return null;
const who = String(owner).toLowerCase();
const sorted = comments.map((c, i) => ({ c, i })).sort((a, b) => {
const x = String(a.c?.createdAt ?? ''); const y = String(b.c?.createdAt ?? '');
return x < y ? -1 : x > y ? 1 : a.i - b.i;
});
let latest = null;
for (const { c } of sorted) {
const author = typeof c?.author === 'string' ? c.author : c?.author?.login;
if (String(author ?? '').toLowerCase() !== who) continue;
for (const m of String(c?.body ?? '').matchAll(OWNER_TRACK_LINE)) {
latest = { track: m[1].toLowerCase(), at: c.createdAt ?? null, url: c.url ?? null };
}
}
return latest;
}
/**
* Трек и его основание (К2 #707). Метка без строки владельца — предложение
* (аналитика, агента, конвейера); подтверждает самая поздняя строка владельца,
* и только если её трек равен текущему. Комментарии недоступны (`null`) —
* «происхождение не установлено», подтверждения нет. Несколько трековых меток —
* строжайшая и предупреждение в основании.
*/
export function trackOrigin({ labels = [], comments = [], owner = '', infrastructure = false } = {}) {
const explicit = explicitTracks(labels);
const track = labelTrack({ labels, infrastructure });
const warning = explicit.length > 1
? `несколько трековых меток (${explicit.map((t) => `track:${t}`).join(', ')}) — дефект разметки, действует строжайшая track:${track}`
: null;
let origin; let basis; let confirmed = false; let confirmedAt = null;
if (explicit.length) {
if (!Array.isArray(comments) || !owner) {
origin = 'unknown'; basis = 'метка, происхождение не установлено (комментарии недоступны)';
} else {
const line = ownerTrackLine(comments, owner);
if (line && line.track === track) {
origin = 'owner'; confirmed = true; confirmedAt = line.at;
basis = `метка, подтверждённая владельцем (${String(line.at ?? '').slice(0, 10) || 'дата не записана'})`;
} else {
origin = 'proposal'; basis = 'метка без подтверждения — предложение';
}
}
} else if (labels.includes('small') || labels.includes('trivial')) {
origin = 'legacy'; basis = `прежняя метка ${labels.includes('small') ? 'small' : 'trivial'} → show (§5.1)`;
} else {
origin = 'default'; basis = infrastructure ? 'метки нет: инфраструктура → show' : 'метки нет: продукт → ask';
}
return { track, basis, origin, confirmed, confirmedAt, warning, explicit };
}
const I18N = [/^src\/i18n\//, /^custom_components\/[^/]+\/translations\//];
const CONFIG = [/^src\/types\.ts$/, /^src\/config-[^/]+\.ts$/];
const PYTHON = /\.py$/;
@@ -107,15 +230,117 @@ export function parseNameStatus(text = '') {
});
}
/**
* Заметка риска для промпта ревью `show`/`ask` (К3 #707), не длиннее
* `RISK_NOTE_LINE_LIMIT` строк; пустой риск — пустая строка. Риск — вопрос
* ревьюеру, маршрут не меняется: автоматического show → ask нет.
*/
export function riskNote({ track, confirmed = false, risk, labels = [] } = {}) {
if (!risk?.classes?.length) return '';
const lines = [];
const raising = risk.raising || [];
if (raising.length && (track === 'show' || track === 'ask')) {
if (track === 'show' && !confirmed) {
lines.push('**Риск по изменённым участкам (#707).** Трек show держится на «решать нечего» (PROCESS.md §5). По каждому классу ниже назови документ или AC, где поведение уже зафиксировано; не нашёл — Medium «решать есть что — нужен track:ask» с названным критерием §5.');
} else if (track === 'show') {
lines.push('**Риск по изменённым участкам (#707).** Трек show подтверждён владельцем. По каждому классу ниже назови документ или AC, где поведение уже зафиксировано; трек не повышать: не нашёл — вопрос владельцу в комментарии, вариант по умолчанию «повысить до ask».');
} else {
lines.push('**Риск по изменённым участкам (#707).** Трек ask: сверь, что каждый класс ниже покрыт AC ТЗ.');
}
for (const cls of raising) lines.push(`- ${riskClassLine(risk, cls)}`);
}
if (risk.visual?.render && !labels.includes('ci:golden')) {
lines.push('Визуальный риск в пути отрисовки плана без ci:golden — если задача меняет вид, нужен ci:golden (PROCESS.md §8); иначе запиши в «чего не проверял».');
lines.push(`- ${riskClassLine(risk, 'visual')}`);
}
return lines.slice(0, RISK_NOTE_LINE_LIMIT).join('\n');
}
/**
* Строка риска для комментария слияния ship (`hp:ship-merge`): человеку и
* машинная `<!-- hp:ship-risk classes=… -->` для пакетного ревью (§11.7).
* Без риска — пустая строка.
*/
export function shipRiskText({ risk, confirmed = false } = {}) {
if (!risk?.classes?.length) return '';
const head = confirmed && risk.raising?.length
? 'Риск по участкам (трек подтверждён владельцем, не повышен)'
: 'Риск по участкам (visual ship не повышает)';
return `${head}: ${risk.classes.map((cls) => riskClassLine(risk, cls)).join(' · ')}\n<!-- hp:ship-risk classes=${risk.classes.join(',')} -->`;
}
/** Комментарий повышения ship → show: рамки и риск одним комментарием. */
export function raiseComment({ violations = [], risk = null, runUrl = '' } = {}) {
const parts = ['**Трек повышен: `track:ship` → `track:show`.**'];
if (violations.length) parts.push(`Правка выходит за механические рамки ship (PROCESS.md §5): ${violations.join('; ')}.`);
if (risk?.raising?.length) {
parts.push([
'Изменённые участки несут риск, который ship без ревью не пропускает (PROCESS.md §5, #707):',
...risk.raising.map((cls) => `- ${riskClassLine(risk, cls)}`),
].join('\n'));
}
parts.push('Слияние без ревью модели для неё закрыто. Этот заход идёт по треку show: лёгкий Validate и ревью модели «корректность и AC».');
parts.push(violations.length
? 'Понизить трек обратно может только владелец.'
: 'Понизить трек может только владелец; подтвердить ship — строкой `Трек: ship — решение владельца` в комментарии и снова `S7-code-review`.');
if (runUrl) parts.push(`[Прогон](${runUrl}).`);
return `${parts.join('\n\n')}\n`;
}
/**
* Решение шага трека на S7 (К3 #707) — чистая функция над метками, диффом и
* комментариями. Этап `spec`, отсутствие ветки и инфраструктурный дифф дают
* пустой риск и прежнее поведение.
*/
export function decideTrack({
stage = 'code', branch = '', labels = [], files = [], numstat = [], nameStatus = [], diff = '',
comments = [], owner = '', runUrl = '',
} = {}) {
const base = resolveTrack({ labels, files });
const code = stage === 'code' && Boolean(branch);
const risk = code ? classifyRisk(diff) : emptyRisk();
const origin = trackOrigin({ labels, comments, owner, infrastructure: base.infrastructure });
let { track } = base;
let ship = false; let raise = false; let comment = ''; let violations = [];
if (track === 'ship' && code) {
violations = shipLimitViolations({ numstat, nameStatus });
const riskRaises = risk.raising.length > 0 && !origin.confirmed;
if (violations.length || riskRaises) {
raise = true; track = 'show';
comment = raiseComment({ violations, risk, runUrl });
} else {
ship = true;
}
}
const confirmed = origin.confirmed && track === origin.track;
const reasons = [violations.length && 'рамки ship', raise && risk.raising.length && `риск ${risk.raising.join(', ')}`].filter(Boolean);
const basis = raise ? `повышен конвейером с ship (${reasons.join(' и ')}); было: ${origin.basis}` : origin.basis;
return {
track, mutants: base.mutants, full: base.full, infrastructure: base.infrastructure, ship, raise, comment, violations,
risk, confirmed, basis, warning: origin.warning,
note: code && !ship ? riskNote({ track, confirmed, risk, labels }) : '',
shipRisk: ship ? shipRiskText({ risk, confirmed: origin.confirmed }) : '',
};
}
function git(args) {
const r = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
const r = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${(r.stderr || '').trim()}`);
return r.stdout;
}
/** Комментарии из файла `gh issue view --json comments`; пустой или битый файл — недоступны. */
export function readComments(path) {
if (!path || !existsSync(path)) return null;
const text = readFileSync(path, 'utf8').trim();
if (!text) return null;
try { return normalizeComments(JSON.parse(text)); } catch { return null; }
}
if (isMainModule(import.meta.url)) {
const [command, ...rest] = process.argv.slice(2);
const value = (name) => rest.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3) ?? '';
const labels = value('labels').split(',').map((s) => s.trim()).filter(Boolean);
const emit = (lines) => {
for (const line of lines) console.log(line);
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`);
@@ -124,8 +349,50 @@ if (isMainModule(import.meta.url)) {
const base = value('base');
const head = value('head') || 'HEAD';
const range = base ? `${base}...${head}` : null;
if (command === 'resolve') {
const labels = value('labels').split(',').map((s) => s.trim()).filter(Boolean);
if (command === 'stage') {
const branch = value('branch');
const stage = value('stage') || 'code';
const out = value('out');
if (!out) throw new Error('--out is required');
const diffRange = branch ? `${base || 'origin/dev'}...${head}` : null;
const code = stage === 'code' && diffRange;
const decision = decideTrack({
stage, branch, labels,
files: diffRange ? git(['diff', '--name-only', diffRange]).split('\n').filter(Boolean) : [],
numstat: code ? parseNumstat(git(['diff', '--numstat', diffRange])) : [],
nameStatus: code ? parseNameStatus(git(['diff', '--name-status', diffRange])) : [],
diff: code ? git(['-c', 'core.quotePath=false', 'diff', '--unified=0', '-M', '--no-color', '--no-ext-diff', '--no-textconv', diffRange]) : '',
comments: readComments(value('comments')),
owner: value('owner'),
runUrl: value('run-url'),
});
mkdirSync(out, { recursive: true });
if (decision.raise) writeFileSync(join(out, 'raise.md'), decision.comment);
const basis = `${decision.basis}${decision.warning ? ` · внимание: ${decision.warning}` : ''}`;
const lines = [
`track=${decision.track}`, `mutants=${decision.mutants}`, `full=${decision.full}`, `ship=${decision.ship}`,
`raise=${decision.raise}`, `basis=${basis}`, `risk=${decision.risk.classes.join(',')}`,
];
for (const line of lines) console.log(line);
if (decision.violations.length) console.log(`violations=${decision.violations.join('; ')}`);
if (decision.note) console.log(`risk_note:\n${decision.note}`);
if (process.env.GITHUB_OUTPUT) {
const block = (name, text) => {
if (!text) return '';
const delimiter = `HP_TRACK_${randomUUID()}`;
return `${name}<<${delimiter}\n${text}\n${delimiter}\n`;
};
appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n${block('risk_note', decision.note)}${block('ship_risk', decision.shipRisk)}`);
}
if (process.env.GITHUB_STEP_SUMMARY) {
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `- трек **${decision.track}** · основание: ${basis} · риск по участкам: ${decision.risk.classes.join(', ') || 'нет'} · полный набор: ${decision.full} · слияние без модели: ${decision.ship}${decision.raise ? ' · повышен ship → show' : ''}\n`);
}
} else if (command === 'limit') {
const path = value('files');
const files = path && existsSync(path) ? readFileSync(path, 'utf8').split('\n').map((s) => s.trim()).filter(Boolean) : [];
const { track, limit, infrastructure } = guardLimit({ labels, files });
for (const line of [`track=${track}`, `limit=${limit}`, `infrastructure=${infrastructure}`]) console.log(line);
} else if (command === 'resolve') {
const files = range ? git(['diff', '--name-only', range]).split('\n').filter(Boolean) : [];
const { track, mutants, full } = resolveTrack({ labels, files });
emit([`track=${track}`, `mutants=${mutants}`, `full=${full}`]);
@@ -137,7 +404,7 @@ if (isMainModule(import.meta.url)) {
});
emit([`ship=${violations.length === 0}`, `violations=${violations.join('; ')}`]);
} else {
throw new Error('usage: process-track.mjs resolve --labels=a,b [--base=<ref> --head=<ref>] | ship-limits --base=<ref> [--head=<ref>]');
throw new Error('usage: process-track.mjs stage --stage=code|spec --labels=a,b --branch=<b> --base=<ref> --out=<dir> [--head --comments --owner --run-url] | limit --labels=a,b [--files=<file>] | resolve --labels=a,b [--base=<ref> --head=<ref>] | ship-limits --base=<ref> [--head=<ref>]');
}
} catch (error) {
console.error(`::error::${error.message}`);