From 50e67c0988d91866b06c9967fae8c2f54f88efe9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 12:38:57 +0300 Subject: [PATCH] =?UTF-8?q?process.yml:=20=D1=81=D1=87=D1=91=D1=82=20?= =?UTF-8?q?=D1=80=D0=B0=D1=83=D0=BD=D0=B4=D0=BE=D0=B2=20=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D1=8C=D1=8E=20=D0=BF=D0=B5=D1=80=D0=B5=D1=87=D0=B8=D1=81=D0=BB?= =?UTF-8?q?=D1=8F=D0=B5=D1=82=20docs/reviews=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20Git=20Trees=20API=20(#621)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit У `contents` API потолок 1 000 записей с молчаливой обрезкой; каталог подошёл к нему (986 файлов). Guard теперь спускается по дереву commit → docs → reviews и трактует `truncated` как отказ листинга (счёт по файлам отключается, страховка по комментариям остаётся). Предупреждение о потолке снято. Тесты: фикстура на 2 400+ имён со своими документами в хвосте; свидетель на проводке workflow. Issue: #621 User-Visible: no --- .github/workflows/process.yml | 29 +++++++++++++++++++++-------- test/review-doc-guard.test.mjs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index 1813ba7c..3eb81d2a 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -162,15 +162,28 @@ jobs: done target="${branch:-dev}" names=$(mktemp); docs=$(mktemp -d) - if ! gh api "repos/$REPO/contents/docs/reviews?ref=$target" --jq '.[].name' \ - > "$names" 2>/dev/null; then + # #621: каталог перечисляется Git Trees API, а не `contents`. + # У `contents` потолок 1 000 записей, после которого ответ молча + # обрезается; docs/reviews подошёл к нему (986 файлов на 23.09, + # +≈60 в неделю), и счёт по файлам начал бы занижаться — то есть + # повторять номер захода и класть документ поверх предыдущего. + # Дерево спускается по одному уровню: commit → root → docs → + # reviews; лимит дерева — 100 000 записей, флаг `truncated` здесь + # трактуется как отказ, а не как частичный список. + tree_names() { + local sha entry + sha=$(gh api "repos/$REPO/commits/$1" --jq '.commit.tree.sha') || return 1 + for entry in docs reviews; do + sha=$(gh api "repos/$REPO/git/trees/$sha" \ + --jq ".tree[] | select(.type == \"tree\" and .path == \"$entry\") | .sha") || return 1 + [ -n "$sha" ] || return 1 + done + gh api "repos/$REPO/git/trees/$sha" \ + --jq 'if .truncated then error("truncated") else .tree[] | select(.type == "blob") | .path end' + } + if ! tree_names "$target" > "$names" 2>/dev/null; then : > "$names" - echo "::warning::список docs/reviews на $target не получен — счёт по файлам отключён" - fi - # Каталог перечисляется одним ответом до 1000 записей; за этой - # границей ответ молча обрежется, и счёт по файлам занизится. - if [ "$(grep -c . "$names")" -ge 1000 ]; then - echo "::warning::в docs/reviews не меньше 1000 файлов — листинг contents обрезается, счёт по файлам ненадёжен" + echo "::warning::дерево docs/reviews на $target не получено — счёт по файлам отключён" fi # Тела нужны только своим документам этапа: их единицы. for name in $(grep -E "^${marker}-${NUM}-r[0-9]+\\.md$" "$names" || true); do diff --git a/test/review-doc-guard.test.mjs b/test/review-doc-guard.test.mjs index a1004a98..ec751789 100644 --- a/test/review-doc-guard.test.mjs +++ b/test/review-doc-guard.test.mjs @@ -932,3 +932,37 @@ test('ревью: модель работает job-scoped токеном, а н assert.doesNotMatch(model.slice(0, model.indexOf('steps:')), /id-token: write/, 'OIDC этой стадии больше не выдаётся'); }); + +// #621: каталог docs/reviews перевалил за 1 000 файлов, а `contents` API +// перечисляет не больше 1 000 записей и обрезает молча. Занижённый счёт по +// файлам — это повтор номера захода и документ поверх предыдущего (#454). +// Счёт обязан не зависеть от размера каталога, а листинг в workflow — идти +// через Git Trees API, где потолок 100 000 и обрезка объявлена флагом. +test('счёт раундов не зависит от числа файлов в каталоге (#621 AC1)', () => { + const names = []; + for (let i = 1; i <= 1_200; i += 1) names.push(`CODE-REVIEW-${i}-r1.md`, `SPEC-REVIEW-${i}-r1.md`); + // Свои документы — в хвосте списка, за пределами первой тысячи: обрезанный + // листинг их бы не увидел. + names.push('CODE-REVIEW-621-r2.md', 'CODE-REVIEW-621-r3.md'); + assert.ok(names.length > 1_000); + const { rounds, skipped } = reviewRoundsFromFiles(names, 'CODE-REVIEW', '621'); + assert.deepEqual(rounds, [1, 2, 3]); + assert.deepEqual(skipped, []); + assert.equal(attemptFromRounds(rounds), 4); +}); + +test('guard перечисляет docs/reviews деревом, а не contents, и без предупреждения о потолке (#621 AC2)', () => { + const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); + const guard = workflow.slice(workflow.indexOf('\n guard:\n'), workflow.indexOf('\n prepare:\n')); + assert.doesNotMatch(guard, /contents\/docs\/reviews\?ref=/, 'листинг каталога через contents API снят'); + assert.doesNotMatch(guard, /1000 файлов/, 'предупреждение о потолке удалено вместе с зависимостью'); + assert.match(guard, /gh api "repos\/\$REPO\/commits\/\$1" --jq '\.commit\.tree\.sha'/, 'дерево берётся от коммита ветки задачи'); + assert.match(guard, /for entry in docs reviews; do/, 'спуск по уровням docs → reviews'); + assert.match(guard, /gh api "repos\/\$REPO\/git\/trees\/\$sha"/, 'листинг — Git Trees API'); + assert.match(guard, /if \.truncated then error\("truncated"\)/, 'обрезанное дерево — отказ, не частичный список'); + assert.match(guard, /if ! tree_names "\$target" > "\$names" 2>\/dev\/null; then\n\s+: > "\$names"/, + 'отказ листинга отключает счёт по файлам, страховка по комментариям остаётся'); + // Тела своих документов по-прежнему читаются поштучно: у одиночного файла + // потолка нет. + assert.match(guard, /contents\/docs\/reviews\/\$name\?ref=\$target/); +});