mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
show/ship stop paying for diff mutants and for every move of dev: - scripts/process-track.mjs resolves the track from the current labels and the diff (show for unlabelled infra, ask for unlabelled product work) and checks the mechanical ship limits; outside them the pipeline comments and relabels track:ship -> track:show in the same round. - Validate on the review material is light on show/ship: a completed push run on the exact SHA is proof, a dispatch asks mutants=false. ask and the ci:mutants label keep the mutant dispatch. - show/ship skip the pre-review rebase when git merge-tree with dev is clean; the candidate is rebased once at merge and still passes Validate before the push to dev. The light merge waits for the push run of the candidate and dispatches only when none appears. - ship inside the limits merges after the light Validate without a model review; the issue gets a machine marker hp:ship-merge. - ship-review.yml + scripts/ship-review.mjs read the code of all ship tasks of a beta range in one model session and publish docs/reviews/SHIP-REVIEW-<tag>.md; both beta publication paths refuse a range with ship tasks the document does not cover or that carries a High. - show reviews judge correctness and AC; the spec review installs neither npm ci nor Chromium, the show review installs Chromium only when the issue names a smoke. Canon: PROCESS.md §5, §5.1, §10.4, new §11.7; REVIEWER.md, AUTHOR.md and AGENTS.md digests. Issue: #696 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
308 lines
23 KiB
JavaScript
308 lines
23 KiB
JavaScript
#!/usr/bin/env node
|
||
// Пакет задачи — производное представление, а не новый источник статуса (#496).
|
||
//
|
||
// Агент, берущий задачу, сегодня собирает одно и то же руками из четырёх мест:
|
||
// метки и комментарии issue, ветку и её положение относительно dev, документы
|
||
// ревью с их якорями, состояние Validate на вершине. Каждый раз — свои ходы,
|
||
// свои ошибки («метка встала раньше push'а», «ревьюер читал не тот SHA»).
|
||
// Скрипт делает эту сборку детерминированно и печатает один markdown-пакет:
|
||
//
|
||
// issue · статус и трек · что можно делать в этом статусе · решения владельца ·
|
||
// материал (ветка, SHA, база, Validate) · предыдущий вердикт · AC → свидетель ·
|
||
// непроверенное / следующий шаг
|
||
//
|
||
// Источник правды остаётся GitHub и git: пакет ничего не пишет и ничего не
|
||
// решает. Все чтения инъектируемы — `buildPacket(inputs)` чист и покрыт тестами.
|
||
//
|
||
// node scripts/task-packet.mjs --issue 437 [--repo Matysh/houseplan-card] [--json]
|
||
|
||
import { spawnSync } from 'node:child_process';
|
||
import { isMainModule } from './spawn-portable.mjs';
|
||
import { anchorTreeFrom, anchorVerdictFrom, verdictDeclaration } from './review-doc-guard.mjs';
|
||
import { classify } from './process-gate.mjs';
|
||
import { hasTrackLabel, trackFromLabels } from './process-track.mjs';
|
||
|
||
export const STATUS_LABELS = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready', 'S6-in-progress', 'S7-code-review', 'S8-merged'];
|
||
|
||
/** Что разрешено в статусе — по PROCESS.md, без домыслов. */
|
||
export function rightsFor(status, labels = [], { infrastructure = false, infrastructureHint = false } = {}) {
|
||
const blocked = labels.includes('blocked');
|
||
const exhausted = labels.includes('review-4');
|
||
const code = !infrastructure && ['S5-ready', 'S6-in-progress', 'S7-code-review'].includes(status);
|
||
const lines = [];
|
||
if (exhausted) lines.push('review-4: лимит циклов исчерпан — решение владельца (разделить, отклонить, арбитраж); дальше не двигать');
|
||
if (blocked) lines.push('blocked: работа стоит, ждём внешнего решения — коммиты по задаче гейт не пропустит');
|
||
lines.push(code
|
||
? 'продуктовый код трогать МОЖНО (правило №1)'
|
||
: infrastructure
|
||
? 'файлы класса A трогать НЕЛЬЗЯ; инфраструктурную реализацию МОЖНО вести сразу по issue (#562)'
|
||
: 'продуктовый код трогать НЕЛЬЗЯ: статус не S5/S6/S7 (правило №1)');
|
||
if (infrastructureHint) {
|
||
lines.push('метка infra — только подсказка, не доказательство и не право: до ветки проверь предполагаемые пути; без class A начинай сразу, при любом class A нужен продуктовый S-flow (#562)');
|
||
}
|
||
switch (status) {
|
||
case 'S1-new': lines.push('следующий шаг: аналитика (S2) — оценки и трек метками (по умолчанию track:show); на track:ship — строка «что меняется и чем проверить» под «## ТЗ», затем S5'); break;
|
||
case 'S2-analysis': lines.push('следующий шаг: track:show — до трёх AC под «## ТЗ», затем S5; track:ask — ТЗ (S3) с названным критерием §5'); break;
|
||
case 'S3-spec': lines.push('следующий шаг: ТЗ готово → push ветки → метка S4-spec-review (метку после push)'); break;
|
||
case 'S4-spec-review': lines.push('идёт ревью ТЗ: ждать вердикт (scripts/wait-verdict.mjs), не править материал'); break;
|
||
case 'S5-ready': lines.push('следующий шаг: ветка issue/NN-slug от dev, код по ТЗ, метка S6-in-progress'); break;
|
||
case 'S6-in-progress': lines.push('следующий шаг: gate:small + смоки по AC → push ветки → метка S7-code-review (метку после push)'); break;
|
||
case 'S7-code-review': lines.push('идёт код-ревью: ждать вердикт, ничего не пушить в ветку — вердикт привязан к SHA (#312)'); break;
|
||
case 'S8-merged': lines.push('код в dev, ждёт беты; ничего не делать; issue закроет владелец при выпуске'); break;
|
||
default:
|
||
lines.push(infrastructure
|
||
? 'инфраструктурный вход: реализовать и проверить → push ветки → S7-code-review; ТЗ и S1–S6 не нужны (#562)'
|
||
: infrastructureHint
|
||
? 'предварительный инфраструктурный вход: после проверки отсутствия class A реализовать → push ветки → S7-code-review; diff станет окончательным доказательством'
|
||
: 'статусной метки нет — продуктовая задача вне процесса; вход — первая S*-метка владельца');
|
||
}
|
||
return lines;
|
||
}
|
||
|
||
/** AC из текста ТЗ или тела issue: строки таблицы `| ACn |` и маркеры `ACn` в начале. */
|
||
export function extractAcceptanceCriteria(text) {
|
||
const out = new Map();
|
||
for (const raw of String(text ?? '').split('\n')) {
|
||
const line = raw.trim();
|
||
let m = line.match(/^\|\s*\**(AC\d+)\**\s*\|\s*([^|]*)\|/);
|
||
if (!m) m = line.match(/^(?:[-*]\s*)?\**(AC\d+)\**[.:)\s-]+(.*)$/);
|
||
if (!m) continue;
|
||
if (!out.has(m[1])) out.set(m[1], m[2].trim().slice(0, 160));
|
||
}
|
||
return [...out.entries()].map(([id, text]) => ({ id, text }));
|
||
}
|
||
|
||
/** Где в документе ревью упомянут AC: «проверен» или «без записи». */
|
||
export function evidenceFor(acs, reviewDocText) {
|
||
const text = String(reviewDocText ?? '');
|
||
return acs.map((ac) => {
|
||
const re = new RegExp(`(^|[^A-Z0-9])(${ac.id})(?![0-9])`, 'm');
|
||
const m = re.exec(text);
|
||
if (!m) return { ...ac, evidence: 'без записи в последнем документе ревью' };
|
||
const start = m.index + m[1].length;
|
||
const line = text.slice(start).split('\n')[0].replace(/^\W+/, '').slice(0, 140);
|
||
return { ...ac, evidence: line };
|
||
});
|
||
}
|
||
|
||
/** Решения владельца: его комментарии с заголовками/словами решения, свежие первыми. */
|
||
export function ownerDecisions(comments, owner) {
|
||
return (comments || [])
|
||
.filter((c) => c.author === owner && /решени|ответ|принима|утвержда|делаем|не делаем|owner|decision/i.test(String(c.body)))
|
||
.slice(-5)
|
||
.map((c) => ({ at: c.createdAt, head: String(c.body).trim().split('\n')[0].replace(/^#+\s*/, '').slice(0, 140), url: c.url }));
|
||
}
|
||
|
||
/** Последний вердикт этапа по комментариям (страховка) и по документу (запись конвейера). */
|
||
export function lastVerdict(comments, docs, stage) {
|
||
const marker = stage === 'spec' ? 'SPEC-REVIEW' : 'CODE-REVIEW';
|
||
const fromComments = (comments || []).map((c) => ({ ...c, line: verdictDeclaration(c.body) }))
|
||
.filter((c) => c.line && new RegExp(`${marker}|заход r\\d+`).test(c.body));
|
||
const latestComment = fromComments.at(-1) || null;
|
||
const numbered = (docs || [])
|
||
.map((d) => ({ ...d, round: Number((d.name.match(/-r(\d+)\.md$/) || [])[1]) }))
|
||
.filter((d) => d.name.startsWith(`${marker}-`) && Number.isFinite(d.round))
|
||
.sort((a, b) => b.round - a.round);
|
||
const latestDoc = numbered[0] || null;
|
||
return {
|
||
comment: latestComment ? { at: latestComment.createdAt, line: latestComment.line.trim(), url: latestComment.url } : null,
|
||
doc: latestDoc ? {
|
||
name: latestDoc.name, round: latestDoc.round,
|
||
tree: anchorTreeFrom(latestDoc.text), recorded: anchorVerdictFrom(latestDoc.text),
|
||
} : null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Дифф ветки как доказательство ускоренного инфраструктурного трека (#562).
|
||
* Документы ревью (`docs/reviews/**`, класс C) пишет конвейер и сама ветка
|
||
* ревью ТЗ — они не материал задачи и классификацию не двигают (#632): ветка
|
||
* продуктовой S6-задачи до первого кодового коммита содержит только
|
||
* SPEC-REVIEW и не должна выглядеть инфраструктурной.
|
||
*/
|
||
export function branchIsInfrastructure(changedFiles = []) {
|
||
const material = changedFiles.filter((name) => !name.startsWith('docs/reviews/'));
|
||
return material.length > 0 && !material.some((name) => classify(name) === 'A');
|
||
}
|
||
|
||
const PRE_CODE_STATUSES = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready'];
|
||
|
||
// Трек по меткам — одна функция на конвейер и пакет (#696): process-track.mjs.
|
||
export { hasTrackLabel, trackFromLabels };
|
||
|
||
/**
|
||
* Признаки продуктового S-flow (#632). Инфраструктурная задача входит в поток
|
||
* сразу на S7 и никогда не несёт S1–S5, ТЗ и ревью ТЗ; поэтому любой из этих
|
||
* признаков делает эвристику «дифф без класса A» неприменимой. S6/S7/S8 сами по
|
||
* себе признаком не являются: их носит и инфраструктурная задача после ревью.
|
||
* Метка `trivial` — признак сама по себе (r1 #632): короткий трек (PROCESS §5.1)
|
||
* идёт S2 → S5 без ТЗ и без ревью ТЗ, и в S6/S7 никакого другого следа потока у
|
||
* него нет. Ускоренный инфраструктурный вход понятия трека не имеет, поэтому
|
||
* `trivial` на нём не бывает; `infra` рядом с ней — тематическая метка.
|
||
*/
|
||
export function productFlowEvidence({ status = null, labels = [], issue = {}, specs = [], reviewDocs = [], comments = [] } = {}) {
|
||
const reasons = [];
|
||
if (PRE_CODE_STATUSES.includes(status)) reasons.push(`статус ${status}`);
|
||
if (labels.includes('trivial')) reasons.push('короткий трек trivial (ТЗ не пишется, §5.1)');
|
||
if (/^#{1,3}\s*ТЗ(?![\p{L}\p{N}_])/mu.test(String(issue?.body ?? ''))) reasons.push('раздел «## ТЗ» в теле issue');
|
||
if (specs.length) reasons.push('файл ТЗ в docs/specs');
|
||
if (reviewDocs.some((d) => String(d.name).startsWith('SPEC-REVIEW-'))) reasons.push('документ ревью ТЗ');
|
||
else if (comments.some((c) => verdictDeclaration(c.body) && /SPEC-REVIEW-\d+/.test(String(c.body)))) reasons.push('вердикт ревью ТЗ в комментариях');
|
||
return reasons;
|
||
}
|
||
|
||
export function buildPacket(inputs) {
|
||
const {
|
||
issue, labels = [], comments = [], owner = 'Matysh', branch = null, specs = [], reviewDocs = [], validate = null,
|
||
} = inputs;
|
||
const status = STATUS_LABELS.find((l) => labels.includes(l)) || null;
|
||
// Трек сначала определяется статусом и историей issue (#632): прошедшая
|
||
// S3/S4/S5 или несущая ТЗ задача — продуктовая, и её право на класс A в
|
||
// S5–S7 не отнимается пустым пока диффом.
|
||
const productFlow = productFlowEvidence({ status, labels, issue, specs, reviewDocs, comments });
|
||
// `infra` — тематическая метка и не даёт процессных прав. Ускоренный трек
|
||
// доказывается тем же механическим признаком, что process-gate: в реальном
|
||
// diff опубликованной ветки нет ни одного файла класса A — и только вне
|
||
// продуктового потока.
|
||
const infrastructure = branch?.infrastructure === true && productFlow.length === 0;
|
||
const infrastructureHint = branch == null && status == null && labels.includes('infra') && productFlow.length === 0;
|
||
// §5.1 (r1 #695): инфраструктурная задача без трековой метки идёт как
|
||
// `show`; явная метка владельца главнее. Маршрут при этом остаётся
|
||
// инфраструктурным — вход сразу на S7, без S1–S5.
|
||
const infraTrack = hasTrackLabel(labels) ? trackFromLabels(labels) : 'show';
|
||
const track = infrastructure
|
||
? `инфраструктурный · ${infraTrack}`
|
||
: infrastructureHint ? `инфраструктурный · ${infraTrack} (предварительно; подтвердить путями/diff)`
|
||
: trackFromLabels(labels);
|
||
const stage = status === 'S4-spec-review' || status === 'S3-spec' || status === 'S5-ready' ? 'spec' : 'code';
|
||
const verdict = lastVerdict(comments, reviewDocs, stage);
|
||
// ТЗ живёт в теле issue (#517); архивный файл — источник только у задач до
|
||
// перехода, у которых в теле AC нет. Порядок именно такой: тело правится и
|
||
// после создания файла, и тогда файл описывает не тот текст, что читает
|
||
// ревьюер.
|
||
const fromBody = extractAcceptanceCriteria(issue.body);
|
||
const acSource = fromBody.length || !specs.length
|
||
? issue.body : specs.map((s) => s.text).join('\n');
|
||
const acs = evidenceFor(extractAcceptanceCriteria(acSource), reviewDocs.length ? reviewDocs.at(-1).text : '');
|
||
const unverified = acs.filter((a) => a.evidence.startsWith('без записи'));
|
||
const packet = {
|
||
issue: { number: issue.number, title: issue.title, state: issue.state, url: issue.url },
|
||
status, track, labels, productFlow, rights: rightsFor(status, labels, { infrastructure, infrastructureHint }),
|
||
decisions: ownerDecisions(comments, owner),
|
||
material: branch ? {
|
||
branch: branch.name, tip: branch.tip, base: branch.base, ahead: branch.ahead, behind: branch.behind,
|
||
treeMatchesVerdict: verdict.doc?.tree ? branch.treeWithoutReviews === verdict.doc.tree : null,
|
||
validate: validate || { status: 'неизвестно' },
|
||
} : null,
|
||
verdict,
|
||
acceptance: acs,
|
||
unverified: unverified.map((a) => a.id),
|
||
};
|
||
return packet;
|
||
}
|
||
|
||
export function renderPacket(p) {
|
||
const L = [];
|
||
L.push(`# Пакет задачи #${p.issue.number} — ${p.issue.title}`);
|
||
L.push('');
|
||
L.push(`Статус: **${p.status || 'без S-метки'}** · трек: ${p.track} · метки: ${p.labels.join(', ') || '—'} · issue ${p.issue.state}`);
|
||
if (p.productFlow?.length) L.push(`Продуктовый поток: ${p.productFlow.join(', ')} — дифф без класса A трек не меняет (#632)`);
|
||
L.push('');
|
||
L.push('## Права и следующий шаг');
|
||
for (const r of p.rights) L.push(`- ${r}`);
|
||
L.push('');
|
||
L.push('## Решения владельца (свежие)');
|
||
if (!p.decisions.length) L.push('- не найдены (комментарии владельца со словами решения отсутствуют)');
|
||
for (const d of p.decisions) L.push(`- ${d.at?.slice(0, 16) || ''} — ${d.head}${d.url ? ` (${d.url})` : ''}`);
|
||
L.push('');
|
||
L.push('## Материал');
|
||
if (!p.material) L.push('- ветки issue/NN-* на origin нет — материал не запушен');
|
||
else {
|
||
const m = p.material;
|
||
L.push(`- ветка \`${m.branch}\`, вершина \`${m.tip.slice(0, 12)}\`, база dev \`${m.base.slice(0, 12)}\`: впереди ${m.ahead}, позади ${m.behind}${m.behind ? ' — перед S7 ребейз (rebase-on-dev.mjs при конфликте в бандле)' : ''}`);
|
||
L.push(`- Validate на вершине: ${m.validate.status}${m.validate.url ? ` (${m.validate.url})` : ''}`);
|
||
if (m.treeMatchesVerdict === true) L.push('- дерево вне docs/reviews совпадает с материалом последнего вердикта — повторный S7 применит его без модели (#499)');
|
||
if (m.treeMatchesVerdict === false) L.push('- дерево изменилось с последнего вердикта — будет полный разбор');
|
||
}
|
||
L.push('');
|
||
L.push('## Предыдущий вердикт');
|
||
if (p.verdict.comment) L.push(`- комментарий: ${p.verdict.comment.line}${p.verdict.comment.url ? ` (${p.verdict.comment.url})` : ''}`);
|
||
if (p.verdict.doc) L.push(`- документ: \`${p.verdict.doc.name}\`, дерево \`${p.verdict.doc.tree?.slice(0, 12) || '—'}\`, запись конвейера: ${p.verdict.doc.recorded ? `${p.verdict.doc.recorded.verdict} · High ${p.verdict.doc.recorded.high}` : 'нет (документ до #499)'}`);
|
||
if (!p.verdict.comment && !p.verdict.doc) L.push('- вердиктов этапа ещё не было');
|
||
L.push('');
|
||
L.push('## AC → свидетель (по последнему документу ревью)');
|
||
if (!p.acceptance.length) L.push('- AC не распознаны: ни таблицы `| ACn |`, ни строк `ACn:` в ТЗ/теле issue');
|
||
for (const a of p.acceptance) L.push(`- **${a.id}** ${a.text} → ${a.evidence}`);
|
||
L.push('');
|
||
L.push('## Непроверенное');
|
||
L.push(p.unverified.length ? `- ${p.unverified.join(', ')}: нет записи в последнем документе ревью — свидетель нужен до S7` : '- по документу ревью все распознанные AC имеют запись');
|
||
return `${L.join('\n')}\n`;
|
||
}
|
||
|
||
// ---- сбор входов: gh + git -------------------------------------------------
|
||
|
||
function sh(cmd, args, { cwd } = {}) {
|
||
const r = spawnSync(cmd, args, { encoding: 'utf8', cwd, maxBuffer: 64 * 1024 * 1024 });
|
||
if (r.error) throw r.error;
|
||
if (r.status !== 0) throw new Error(`${cmd} ${args.join(' ')} → ${(r.stderr || '').trim()}`);
|
||
return (r.stdout || '').trim();
|
||
}
|
||
|
||
export function collectInputs({ number, repo = 'Matysh/houseplan-card', cwd = process.cwd() }) {
|
||
const view = JSON.parse(sh('gh', ['issue', 'view', String(number), '--repo', repo, '--json', 'number,title,body,state,url,labels,comments']));
|
||
const labels = (view.labels || []).map((l) => l.name);
|
||
const comments = (view.comments || []).map((c) => ({ author: c.author?.login, body: c.body, createdAt: c.createdAt, url: c.url }));
|
||
const owner = repo.split('/')[0];
|
||
try { sh('git', ['fetch', '-q', 'origin', 'dev', `+refs/heads/issue/${number}-*:refs/remotes/origin/issue/${number}-*`], { cwd }); } catch { /* офлайн — читаем что есть */ }
|
||
const refs = sh('git', ['for-each-ref', '--sort=-committerdate', '--format=%(refname:lstrip=3)', `refs/remotes/origin/issue/${number}-*`], { cwd }).split('\n').filter(Boolean);
|
||
let branch = null; let reviewDocs = []; let specs = [];
|
||
if (refs[0]) {
|
||
const name = refs[0]; const ref = `origin/${name}`;
|
||
const tip = sh('git', ['rev-parse', ref], { cwd });
|
||
const base = sh('git', ['merge-base', 'origin/dev', ref], { cwd });
|
||
const ahead = Number(sh('git', ['rev-list', '--count', `origin/dev..${ref}`], { cwd }));
|
||
const behind = Number(sh('git', ['rev-list', '--count', `${ref}..origin/dev`], { cwd }));
|
||
const changedFiles = sh('git', ['diff', '--name-only', `${base}..${ref}`], { cwd }).split('\n').filter(Boolean);
|
||
const infrastructure = branchIsInfrastructure(changedFiles);
|
||
// Дерево без docs/reviews — для сравнения с якорем вердикта: git сам его не даёт,
|
||
// поэтому сравнение делается diff'ом при известном якоре (см. ниже).
|
||
const names = sh('git', ['ls-tree', '--name-only', `${ref}:docs/reviews`], { cwd }).split('\n').filter((n) => new RegExp(`-${number}-r\\d+\\.md$`).test(n));
|
||
reviewDocs = names.sort().map((n) => ({ name: n, text: sh('git', ['show', `${ref}:docs/reviews/${n}`], { cwd }) }));
|
||
const specNames = sh('git', ['ls-tree', '--name-only', `${ref}:docs/specs`], { cwd }).split('\n').filter((n) => new RegExp(`^${number}-.*\\.md$`).test(n));
|
||
specs = specNames.map((n) => ({ name: n, text: sh('git', ['show', `${ref}:docs/specs/${n}`], { cwd }) }));
|
||
const anchorTree = reviewDocs.length ? anchorTreeFrom(reviewDocs.at(-1).text) : null;
|
||
let treeWithoutReviews = null;
|
||
if (anchorTree) {
|
||
const same = spawnSync('git', ['diff', '--quiet', anchorTree, tip, '--', '.', ':!docs/reviews', ':!legacy/reviews'], { cwd });
|
||
treeWithoutReviews = same.status === 0 ? anchorTree : `differs-from-${anchorTree}`;
|
||
}
|
||
branch = { name, tip, base, ahead, behind, treeWithoutReviews, infrastructure };
|
||
}
|
||
let validate = null;
|
||
if (branch) {
|
||
try {
|
||
const runs = JSON.parse(sh('gh', ['run', 'list', '--repo', repo, '--workflow', 'validate.yml', '--commit', branch.tip, '--limit', '5', '--json', 'status,conclusion,url']));
|
||
const green = runs.find((r) => r.status === 'completed' && r.conclusion === 'success');
|
||
const running = runs.find((r) => r.status !== 'completed');
|
||
validate = green ? { status: 'зелёный', url: green.url } : running ? { status: 'идёт', url: running.url }
|
||
: runs[0] ? { status: `красный (${runs[0].conclusion})`, url: runs[0].url } : { status: 'прогона нет' };
|
||
} catch { validate = { status: 'неизвестно (gh run list недоступен)' }; }
|
||
}
|
||
return { issue: view, labels, comments, owner, branch, specs, reviewDocs, validate };
|
||
}
|
||
|
||
if (isMainModule(import.meta.url)) {
|
||
const argv = process.argv.slice(2);
|
||
const value = (name) => {
|
||
const eq = argv.find((a) => a.startsWith(`--${name}=`));
|
||
if (eq) return eq.slice(name.length + 3);
|
||
const at = argv.indexOf(`--${name}`);
|
||
return at >= 0 ? (argv[at + 1] || '') : '';
|
||
};
|
||
const number = Number(value('issue'));
|
||
if (!Number.isInteger(number) || number <= 0) { console.error('нужен --issue <номер>'); process.exit(2); }
|
||
const repo = value('repo') || 'Matysh/houseplan-card';
|
||
const packet = buildPacket(collectInputs({ number, repo }));
|
||
process.stdout.write(argv.includes('--json') ? `${JSON.stringify(packet, null, 2)}\n` : renderPacket(packet));
|
||
}
|