mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
fix(release): fetch draft-notes PR summaries in batches (#20602)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The release workflow drafts the next stable's release notes when a beta publishes (`draft_stable_notes`) > - The draft nests each referenced PR's summary under its commit subject, and it fetches each summary with its own `gh pr view` call > - A beta range now references more than 1,500 PRs, so these serial calls do not finish inside the job's 10-minute timeout > - This pull request fetches the summaries in batches of 50 per GraphQL query > - The benefit is that the job finishes in under a minute and pushes the notes branch again ## Linked Issues or Issue Description **What happened?** The `draft_stable_notes` job of [run 42763789753](https://github.com/paperclipai/paperclip/actions/runs/42763789753) (beta `2026.1110.0-beta.0`) reached its 10-minute timeout. GitHub cancelled it during "Draft stable notes from the published beta", while a `gh` process was still running. The beta itself published and its smoke tests passed. The `release-notes/v2026.1110.0-beta.0` branch was not pushed. **Expected behavior** The job writes the notes draft and pushes the branch for every beta, whatever the size of the commit range. **Steps to reproduce** 1. Publish a beta whose range from the last stable has many PR-referencing commits. This range has 1,586 commits, and 1,583 different PRs. 2. `draft-stable-notes.sh` calls `gh pr view <n>` once for each commit, one after another. 3. The job reaches `timeout-minutes: 10` before the loop ends. **Paperclip version or commit** `09e24dd12c36251dd12af2d73dbc715ce990f922` (beta `2026.1110.0-beta.0`). This was the first beta to run the job after #12124 added the PR summaries; earlier betas skipped it. ## What Changed - `scripts/draft-stable-notes.sh`: - Collects the unique PR numbers first. Fetches their bodies 50 per `gh api graphql` query (aliased `pullRequest(number:)` fields), into a temporary cache that `enrich_pr` reads. - Parses the raw response with `jq`, not with `gh --jq`. When any number in a batch is not a PR (for example, an issue reference), gh exits non-zero and does not apply its `--jq` filter. The other PRs in the batch still have usable data. - The output format does not change. A missing `gh` or `jq`, a failed batch, a cache directory that `mktemp` cannot create, or `DRAFT_NOTES_SKIP_PR_ENRICHMENT=1` still gives bare subjects. - No code comments are added (per `CONTRIBUTING.md`); this description holds the explanation. - `scripts/draft-stable-notes.test.mjs`: - The enrichment tests now use a fake `gh` that answers GraphQL like GitHub. - New tests: batching (120 PRs make 3 queries, and a repeated PR is fetched once); a batch with a non-PR number; a failed batch; a failed `mktemp`; prose-only and empty bodies; the opt-out. - Range tests now run with enrichment disabled, so an authenticated `gh` on the test machine cannot reach GitHub. - `package.json`: adds the test file to `test:release-registry`. These tests did not run in CI before. ## Verification - `node --test scripts/draft-stable-notes.test.mjs`: 17 pass. They also pass with macOS bash 3.2 first on `PATH`. - `node scripts/ci-profile/node-test.mjs scripts/draft-stable-notes.test.mjs scripts/release-lib.test.mjs`: 31 pass. - Red check: the new tests against the old script fail 5 cases (all batching and enrichment cases). The `mktemp` test fails against an unguarded `mktemp` call. - Live run on the real range: `./scripts/draft-stable-notes.sh 2026.1110.0-beta.0` finished in 47 seconds. It wrote 4,596 excerpt lines for 1,583 PRs. - Parity: for 40 randomly sampled PRs from that range, the new excerpts are byte-identical to the old per-PR `gh pr view` output. - `shellcheck`: no new findings. The two remaining notices were there before this change. ## Risks - Low. The change touches only the post-publish draft job, and every failure still gives the bare-subject draft. - `jq` is now needed for the PR summaries. GitHub's Ubuntu runners include it. Without `jq`, the draft has no summaries, the same as without `gh`. - I did not change `release.yml`. The job already checks out `master`, so after this merges, re-running the failed job of run 42763789753 should push the missing notes branch with the fixed script. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) via Claude Code, with tool use (shell, file editing, test runs, read-only GitHub API calls). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d2832a5a0b
commit
8148fc0b1e
4 files changed
+201
-53
No files matched your search
+1
-1
@@ -61,7 +61,7 @@
|
|||||||
"smoke:notion-generic-live": "node scripts/smoke/notion-generic-live.mjs",
|
"smoke:notion-generic-live": "node scripts/smoke/notion-generic-live.mjs",
|
||||||
"smoke:posthog-live": "node scripts/smoke/posthog-live.mjs",
|
"smoke:posthog-live": "node scripts/smoke/posthog-live.mjs",
|
||||||
"smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs",
|
"smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs",
|
||||||
"test:release-registry": "node scripts/ci-profile/node-test.mjs scripts/ci-profile/reporters.test.mjs scripts/ci/retry-reporter.test.mjs scripts/__tests__/runner-vitest-package-lanes.test.mjs scripts/__tests__/canary-startup-playwright.test.mjs scripts/__tests__/cursor-public-install-sandbox.test.mjs scripts/__tests__/grok-public-install-sandbox.test.mjs scripts/__tests__/public-install-closure.test.mjs scripts/__tests__/public-install-registry.test.mjs scripts/__tests__/prefetch-child.test.mjs scripts/__tests__/stage-bundled-packages.test.mjs scripts/__tests__/pack-staged-packages.test.mjs scripts/__tests__/retain-runner-qualification-packages.test.mjs scripts/__tests__/runner-release-crate-cache.test.mjs scripts/__tests__/runner-debug-crate-cache.test.mjs scripts/__tests__/agent-build-cache.test.mjs scripts/__tests__/agent-preflight.test.mjs scripts/__tests__/vitest-worker-isolation.test.mjs scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/release-registry-versions.test.mjs scripts/link-plugin-dev-sdk.test.js scripts/acpx-patch-packaging.test.mjs scripts/service-onboard-smoke.test.mjs scripts/docker-onboard-smoke.test.mjs scripts/preview-artifacts.test.mjs scripts/cloud-migrator-artifacts.test.mjs scripts/typecheck-test-programs.test.mjs scripts/__tests__/local-verify.test.mjs scripts/test-sandbox-providers.test.mjs",
|
"test:release-registry": "node scripts/ci-profile/node-test.mjs scripts/ci-profile/reporters.test.mjs scripts/ci/retry-reporter.test.mjs scripts/__tests__/runner-vitest-package-lanes.test.mjs scripts/__tests__/canary-startup-playwright.test.mjs scripts/__tests__/cursor-public-install-sandbox.test.mjs scripts/__tests__/grok-public-install-sandbox.test.mjs scripts/__tests__/public-install-closure.test.mjs scripts/__tests__/public-install-registry.test.mjs scripts/__tests__/prefetch-child.test.mjs scripts/__tests__/stage-bundled-packages.test.mjs scripts/__tests__/pack-staged-packages.test.mjs scripts/__tests__/retain-runner-qualification-packages.test.mjs scripts/__tests__/runner-release-crate-cache.test.mjs scripts/__tests__/runner-debug-crate-cache.test.mjs scripts/__tests__/agent-build-cache.test.mjs scripts/__tests__/agent-preflight.test.mjs scripts/__tests__/vitest-worker-isolation.test.mjs scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/draft-stable-notes.test.mjs scripts/release-registry-versions.test.mjs scripts/link-plugin-dev-sdk.test.js scripts/acpx-patch-packaging.test.mjs scripts/service-onboard-smoke.test.mjs scripts/docker-onboard-smoke.test.mjs scripts/preview-artifacts.test.mjs scripts/cloud-migrator-artifacts.test.mjs scripts/typecheck-test-programs.test.mjs scripts/__tests__/local-verify.test.mjs scripts/test-sandbox-providers.test.mjs",
|
||||||
"storybook-visual:baseline": "node scripts/storybook-visual-baseline.mjs",
|
"storybook-visual:baseline": "node scripts/storybook-visual-baseline.mjs",
|
||||||
"test:storybook-visual": "node scripts/storybook-visual-baseline.mjs download && node scripts/storybook-visual-baseline.mjs verify && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts",
|
"test:storybook-visual": "node scripts/storybook-visual-baseline.mjs download && node scripts/storybook-visual-baseline.mjs verify && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts",
|
||||||
"test:storybook-visual:update": "node scripts/storybook-visual-baseline.mjs download && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts --update-snapshots && node scripts/storybook-visual-baseline.mjs pack",
|
"test:storybook-visual:update": "node scripts/storybook-visual-baseline.mjs download && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts --update-snapshots && node scripts/storybook-visual-baseline.mjs pack",
|
||||||
|
|||||||
@@ -129,10 +129,47 @@ subjects="$(git -C "$repo_dir" log --no-merges --format='%s' "$range")"
|
|||||||
# "What Changed" bullets, falls back to the first prose lines. Degrades
|
# "What Changed" bullets, falls back to the first prose lines. Degrades
|
||||||
# silently when gh or the network is unavailable (tests, offline runs);
|
# silently when gh or the network is unavailable (tests, offline runs);
|
||||||
# set DRAFT_NOTES_SKIP_PR_ENRICHMENT=1 to disable explicitly.
|
# set DRAFT_NOTES_SKIP_PR_ENRICHMENT=1 to disable explicitly.
|
||||||
|
PR_BATCH_SIZE=50
|
||||||
|
pr_cache=""
|
||||||
|
|
||||||
|
fetch_pr_batch() {
|
||||||
|
local query="query(\$owner: String!, \$name: String!) { repository(owner: \$owner, name: \$name) {" pr_num num encoded
|
||||||
|
for pr_num in "$@"; do
|
||||||
|
query+=" pr${pr_num}: pullRequest(number: ${pr_num}) { body }"
|
||||||
|
done
|
||||||
|
query+=" } }"
|
||||||
|
(cd "$repo_dir" && gh api graphql -F owner='{owner}' -F name='{repo}' -f query="$query" 2>/dev/null) \
|
||||||
|
> "$pr_cache/batch.json" || true
|
||||||
|
while IFS=$'\t' read -r num encoded; do
|
||||||
|
[[ "$num" =~ ^[0-9]+$ ]] || continue
|
||||||
|
printf '%s' "$encoded" | base64 -d > "$pr_cache/$num" 2>/dev/null || rm -f "$pr_cache/$num"
|
||||||
|
done < <(jq -r '.data.repository // {} | to_entries[]
|
||||||
|
| select((.value | type) == "object" and (.value.body | type) == "string")
|
||||||
|
| "\(.key | ltrimstr("pr"))\t\(.value.body | @base64)"' "$pr_cache/batch.json" 2>/dev/null || true)
|
||||||
|
}
|
||||||
|
|
||||||
|
prefetch_pr_bodies() {
|
||||||
|
local pr_nums=() pr_num
|
||||||
|
[ "${DRAFT_NOTES_SKIP_PR_ENRICHMENT:-0}" = "1" ] && return 0
|
||||||
|
command -v gh >/dev/null 2>&1 || return 0
|
||||||
|
command -v jq >/dev/null 2>&1 || return 0
|
||||||
|
while IFS= read -r pr_num; do
|
||||||
|
[ -n "$pr_num" ] && pr_nums+=("$pr_num")
|
||||||
|
done < <(printf '%s\n' "$subjects" | grep -oE '\(#[0-9]+\)$' | tr -dc '0-9\n' | sort -un || true)
|
||||||
|
[ "${#pr_nums[@]}" -gt 0 ] || return 0
|
||||||
|
pr_cache="$(mktemp -d)" || { pr_cache=""; return 0; }
|
||||||
|
trap 'rm -rf "$pr_cache"' EXIT
|
||||||
|
local start=0
|
||||||
|
while [ "$start" -lt "${#pr_nums[@]}" ]; do
|
||||||
|
fetch_pr_batch "${pr_nums[@]:$start:$PR_BATCH_SIZE}"
|
||||||
|
start=$((start + PR_BATCH_SIZE))
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
enrich_pr() {
|
enrich_pr() {
|
||||||
local pr_num="$1" pr_body excerpt
|
local pr_num="$1" pr_body excerpt
|
||||||
[ "${DRAFT_NOTES_SKIP_PR_ENRICHMENT:-0}" = "1" ] && return 0
|
[ -n "$pr_cache" ] && [ -s "$pr_cache/$pr_num" ] || return 0
|
||||||
pr_body="$(cd "$repo_dir" && gh pr view "$pr_num" --json body --jq .body 2>/dev/null || true)"
|
pr_body="$(cat "$pr_cache/$pr_num")"
|
||||||
[ -n "$pr_body" ] || return 0
|
[ -n "$pr_body" ] || return 0
|
||||||
if printf '%s\n' "$pr_body" | grep -q '^## What Changed'; then
|
if printf '%s\n' "$pr_body" | grep -q '^## What Changed'; then
|
||||||
excerpt="$(printf '%s\n' "$pr_body" | sed -n '/^## What Changed/,/^## /p' | grep -E '^- ' | head -3 || true)"
|
excerpt="$(printf '%s\n' "$pr_body" | sed -n '/^## What Changed/,/^## /p' | grep -E '^- ' | head -3 || true)"
|
||||||
@@ -163,6 +200,8 @@ section() {
|
|||||||
printf '\n'
|
printf '\n'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prefetch_pr_bodies
|
||||||
|
|
||||||
conventional='^(feat|fix)(\([^)]*\))?!?: '
|
conventional='^(feat|fix)(\([^)]*\))?!?: '
|
||||||
mkdir -p "$(dirname "$out_file")"
|
mkdir -p "$(dirname "$out_file")"
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync, spawnSync } from "node:child_process";
|
||||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
const repoRoot = new URL("..", import.meta.url).pathname.replace(/\/$/, "");
|
const repoRoot = new URL("..", import.meta.url).pathname.replace(/\/$/, "");
|
||||||
const script = join(repoRoot, "scripts", "draft-stable-notes.sh");
|
const script = join(repoRoot, "scripts", "draft-stable-notes.sh");
|
||||||
|
const hasJq = spawnSync("jq", ["--version"]).status === 0;
|
||||||
|
const needsJq = { skip: !hasJq && "jq is not installed" };
|
||||||
|
const offlineEnv = { ...process.env, DRAFT_NOTES_SKIP_PR_ENRICHMENT: "1" };
|
||||||
|
|
||||||
function git(dir, ...args) {
|
function git(dir, ...args) {
|
||||||
return execFileSync("git", ["-C", dir, ...args], {
|
return execFileSync("git", ["-C", dir, ...args], {
|
||||||
@@ -31,16 +34,60 @@ function makeFixtureRepo() {
|
|||||||
return dir;
|
return dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
function runDraft(dir, betaVersion, extraArgs = []) {
|
function runDraft(dir, betaVersion, extraArgs = [], env = offlineEnv) {
|
||||||
const out = join(dir, "draft.md");
|
const out = join(dir, "draft.md");
|
||||||
const stdout = execFileSync(
|
const stdout = execFileSync(
|
||||||
"bash",
|
"bash",
|
||||||
[script, betaVersion, "--repo-dir", dir, "--out", out, ...extraArgs],
|
[script, betaVersion, "--repo-dir", dir, "--out", out, ...extraArgs],
|
||||||
{ encoding: "utf8" }
|
{ encoding: "utf8", env }
|
||||||
);
|
);
|
||||||
return { stdout, body: readFileSync(out, "utf8") };
|
return { stdout, body: readFileSync(out, "utf8") };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function installFakeGh(dir, bodies, { unavailable = false, failBatchContaining = null } = {}) {
|
||||||
|
const binDir = join(dir, "fake-bin");
|
||||||
|
mkdirSync(binDir, { recursive: true });
|
||||||
|
const bodiesFile = join(dir, "fake-gh-bodies.json");
|
||||||
|
const logFile = join(dir, "fake-gh-calls.log");
|
||||||
|
writeFileSync(bodiesFile, JSON.stringify(bodies));
|
||||||
|
writeFileSync(
|
||||||
|
join(binDir, "gh"),
|
||||||
|
`#!/usr/bin/env node
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
fs.appendFileSync(${JSON.stringify(logFile)}, JSON.stringify(args) + "\\n");
|
||||||
|
if (${JSON.stringify(unavailable)} || args[0] !== "api" || args[1] !== "graphql") process.exit(1);
|
||||||
|
const query = (args.find((arg) => arg.startsWith("query=")) || "").slice("query=".length);
|
||||||
|
const numbers = [...query.matchAll(/pullRequest\\(number: (\\d+)\\)/g)].map((match) => match[1]);
|
||||||
|
if (numbers.includes(${JSON.stringify(String(failBatchContaining))})) process.exit(1);
|
||||||
|
const bodies = JSON.parse(fs.readFileSync(${JSON.stringify(bodiesFile)}, "utf8"));
|
||||||
|
const repository = {};
|
||||||
|
const errors = [];
|
||||||
|
for (const number of numbers) {
|
||||||
|
if (Object.hasOwn(bodies, number)) {
|
||||||
|
repository["pr" + number] = { body: bodies[number] };
|
||||||
|
} else {
|
||||||
|
repository["pr" + number] = null;
|
||||||
|
errors.push({ message: "Could not resolve to a PullRequest with the number of " + number + "." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.stdout.write(JSON.stringify(errors.length ? { data: { repository }, errors } : { data: { repository } }));
|
||||||
|
process.exit(errors.length ? 1 : 0);
|
||||||
|
`,
|
||||||
|
{ mode: 0o755 }
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
env: { ...process.env, PATH: `${binDir}:${process.env.PATH}` },
|
||||||
|
calls: () =>
|
||||||
|
existsSync(logFile)
|
||||||
|
? readFileSync(logFile, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line))
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const whatChanged = (...bullets) =>
|
||||||
|
["## Thinking Path", "", "## What Changed", "", ...bullets.map((b) => `- ${b}`), "", "## Risks", ""].join("\n");
|
||||||
|
|
||||||
test("drafts grouped notes from the newest stable tag to the beta source", () => {
|
test("drafts grouped notes from the newest stable tag to the beta source", () => {
|
||||||
const dir = makeFixtureRepo();
|
const dir = makeFixtureRepo();
|
||||||
commit(dir, "feat: ancient work (#1)");
|
commit(dir, "feat: ancient work (#1)");
|
||||||
@@ -129,6 +176,7 @@ test("writes to releases/beta/v<version>.md inside the repo by default", () => {
|
|||||||
|
|
||||||
execFileSync("bash", [script, "2026.100.0-beta.0", "--repo-dir", dir], {
|
execFileSync("bash", [script, "2026.100.0-beta.0", "--repo-dir", dir], {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
|
env: offlineEnv,
|
||||||
});
|
});
|
||||||
const body = readFileSync(
|
const body = readFileSync(
|
||||||
join(dir, "releases", "beta", "v2026.100.0-beta.0.md"),
|
join(dir, "releases", "beta", "v2026.100.0-beta.0.md"),
|
||||||
@@ -137,76 +185,137 @@ test("writes to releases/beta/v<version>.md inside the repo by default", () => {
|
|||||||
assert.match(body, /- feat: default path \(#1\)/);
|
assert.match(body, /- feat: default path \(#1\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("nests PR summaries under subjects when gh can serve them", () => {
|
test("nests PR summaries under subjects when gh can serve them", needsJq, () => {
|
||||||
const dir = makeFixtureRepo();
|
const dir = makeFixtureRepo();
|
||||||
commit(dir, "feat: enriched work (#42)");
|
commit(dir, "feat: enriched work (#42)");
|
||||||
git(dir, "tag", "beta/v2026.100.0-beta.0");
|
git(dir, "tag", "beta/v2026.100.0-beta.0");
|
||||||
|
const gh = installFakeGh(dir, { 42: whatChanged("Adds the enriched thing", "Covers it with tests") });
|
||||||
|
|
||||||
const binDir = join(dir, "fake-bin");
|
const { body } = runDraft(dir, "2026.100.0-beta.0", [], gh.env);
|
||||||
execFileSync("mkdir", ["-p", binDir]);
|
|
||||||
writeFileSync(
|
|
||||||
join(binDir, "gh"),
|
|
||||||
`#!/usr/bin/env bash
|
|
||||||
echo "## Thinking Path"
|
|
||||||
echo ""
|
|
||||||
echo "## What Changed"
|
|
||||||
echo ""
|
|
||||||
echo "- Adds the enriched thing"
|
|
||||||
echo "- Covers it with tests"
|
|
||||||
echo ""
|
|
||||||
echo "## Risks"
|
|
||||||
`,
|
|
||||||
{ mode: 0o755 }
|
|
||||||
);
|
|
||||||
|
|
||||||
const out = join(dir, "draft.md");
|
|
||||||
execFileSync(
|
|
||||||
"bash",
|
|
||||||
[script, "2026.100.0-beta.0", "--repo-dir", dir, "--out", out],
|
|
||||||
{ encoding: "utf8", env: { ...process.env, PATH: `${binDir}:${process.env.PATH}` } }
|
|
||||||
);
|
|
||||||
const body = readFileSync(out, "utf8");
|
|
||||||
assert.match(body, /- feat: enriched work \(#42\)\n > - Adds the enriched thing\n > - Covers it with tests/);
|
assert.match(body, /- feat: enriched work \(#42\)\n > - Adds the enriched thing\n > - Covers it with tests/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("survives a PR body whose What Changed section has no bullets", () => {
|
test("falls back to the first prose lines when a PR body has no What Changed section", needsJq, () => {
|
||||||
|
const dir = makeFixtureRepo();
|
||||||
|
commit(dir, "fix: prose body (#8)");
|
||||||
|
git(dir, "tag", "beta/v2026.100.0-beta.0");
|
||||||
|
const gh = installFakeGh(dir, {
|
||||||
|
8: "<!-- template comment -->\r\n# Title\r\n\r\nFirst prose line.\r\nSecond prose line.\r\nThird line.\r\n",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { body } = runDraft(dir, "2026.100.0-beta.0", [], gh.env);
|
||||||
|
|
||||||
|
assert.match(body, /- fix: prose body \(#8\)\n > First prose line\.\r\n > Second prose line\.\r\n\n/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("survives a PR body whose What Changed section has no bullets", needsJq, () => {
|
||||||
const dir = makeFixtureRepo();
|
const dir = makeFixtureRepo();
|
||||||
commit(dir, "feat: sparse body (#9)");
|
commit(dir, "feat: sparse body (#9)");
|
||||||
|
commit(dir, "feat: empty body (#10)");
|
||||||
git(dir, "tag", "beta/v2026.100.0-beta.0");
|
git(dir, "tag", "beta/v2026.100.0-beta.0");
|
||||||
|
const gh = installFakeGh(dir, { 9: "## What Changed\n\n## Risks\n", 10: null });
|
||||||
|
|
||||||
const binDir = join(dir, "fake-bin");
|
const { body } = runDraft(dir, "2026.100.0-beta.0", [], gh.env);
|
||||||
execFileSync("mkdir", ["-p", binDir]);
|
|
||||||
writeFileSync(
|
|
||||||
join(binDir, "gh"),
|
|
||||||
`#!/usr/bin/env bash
|
|
||||||
echo "## What Changed"
|
|
||||||
echo ""
|
|
||||||
echo "## Risks"
|
|
||||||
`,
|
|
||||||
{ mode: 0o755 }
|
|
||||||
);
|
|
||||||
|
|
||||||
const out = join(dir, "draft.md");
|
assert.match(body, /- feat: empty body \(#10\)\n- feat: sparse body \(#9\)\n\n/);
|
||||||
execFileSync(
|
});
|
||||||
"bash",
|
|
||||||
[script, "2026.100.0-beta.0", "--repo-dir", dir, "--out", out],
|
test("fetches PR bodies in batched GraphQL queries, not one lookup per PR", needsJq, () => {
|
||||||
{ encoding: "utf8", env: { ...process.env, PATH: `${binDir}:${process.env.PATH}` } }
|
const dir = makeFixtureRepo();
|
||||||
);
|
const bodies = {};
|
||||||
const body = readFileSync(out, "utf8");
|
for (let pr = 1000; pr < 1120; pr += 1) {
|
||||||
assert.match(body, /- feat: sparse body \(#9\)\n/);
|
commit(dir, `feat: change ${pr} (#${pr})`);
|
||||||
|
bodies[pr] = whatChanged(`Summary of ${pr}`);
|
||||||
|
}
|
||||||
|
commit(dir, "fix: follow-up to change 1000 (#1000)");
|
||||||
|
git(dir, "tag", "beta/v2026.100.0-beta.0");
|
||||||
|
const gh = installFakeGh(dir, bodies);
|
||||||
|
|
||||||
|
const { body } = runDraft(dir, "2026.100.0-beta.0", [], gh.env);
|
||||||
|
|
||||||
|
const calls = gh.calls();
|
||||||
|
assert.equal(calls.length, 3);
|
||||||
|
for (const call of calls) assert.deepEqual(call.slice(0, 2), ["api", "graphql"]);
|
||||||
|
for (let pr = 1000; pr < 1120; pr += 1) {
|
||||||
|
assert.match(body, new RegExp(`- feat: change ${pr} \\(#${pr}\\)\\n > - Summary of ${pr}\\n`));
|
||||||
|
}
|
||||||
|
assert.match(body, /- fix: follow-up to change 1000 \(#1000\)\n > - Summary of 1000\n/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the resolvable PRs of a batch that also references a non-PR number", needsJq, () => {
|
||||||
|
const dir = makeFixtureRepo();
|
||||||
|
commit(dir, "feat: real PR (#5)");
|
||||||
|
commit(dir, "docs: closes an issue, not a PR (#6)");
|
||||||
|
git(dir, "tag", "beta/v2026.100.0-beta.0");
|
||||||
|
const gh = installFakeGh(dir, { 5: whatChanged("Real summary") });
|
||||||
|
|
||||||
|
const { body } = runDraft(dir, "2026.100.0-beta.0", [], gh.env);
|
||||||
|
|
||||||
|
assert.equal(gh.calls().length, 1);
|
||||||
|
assert.match(body, /- feat: real PR \(#5\)\n > - Real summary\n/);
|
||||||
|
assert.match(body, /- docs: closes an issue, not a PR \(#6\)\n\n/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed batch drops only its own excerpts", needsJq, () => {
|
||||||
|
const dir = makeFixtureRepo();
|
||||||
|
const bodies = {};
|
||||||
|
for (let pr = 1; pr <= 60; pr += 1) {
|
||||||
|
commit(dir, `fix: change ${pr} (#${pr})`);
|
||||||
|
bodies[pr] = whatChanged(`Summary of ${pr}`);
|
||||||
|
}
|
||||||
|
git(dir, "tag", "beta/v2026.100.0-beta.0");
|
||||||
|
const gh = installFakeGh(dir, bodies, { failBatchContaining: 55 });
|
||||||
|
|
||||||
|
const { body } = runDraft(dir, "2026.100.0-beta.0", [], gh.env);
|
||||||
|
|
||||||
|
assert.equal(gh.calls().length, 2);
|
||||||
|
assert.match(body, /- fix: change 50 \(#50\)\n > - Summary of 50\n/);
|
||||||
|
assert.match(body, /- fix: change 51 \(#51\)\n- fix: change 50 \(#50\)/);
|
||||||
|
assert.doesNotMatch(body, /Summary of 5[1-9]|Summary of 60/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("drafts without summaries when the PR cache cannot be created", needsJq, () => {
|
||||||
|
const dir = makeFixtureRepo();
|
||||||
|
commit(dir, "feat: plain work (#7)");
|
||||||
|
git(dir, "tag", "beta/v2026.100.0-beta.0");
|
||||||
|
const gh = installFakeGh(dir, { 7: whatChanged("Should not appear") });
|
||||||
|
writeFileSync(join(dir, "fake-bin", "mktemp"), "#!/usr/bin/env bash\nexit 1\n", { mode: 0o755 });
|
||||||
|
|
||||||
|
const { body } = runDraft(dir, "2026.100.0-beta.0", [], gh.env);
|
||||||
|
|
||||||
|
assert.equal(gh.calls().length, 0);
|
||||||
|
assert.match(body, /- feat: plain work \(#7\)\n/);
|
||||||
|
assert.doesNotMatch(body, /Should not appear/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("skeleton stays clean when enrichment is unavailable", () => {
|
test("skeleton stays clean when enrichment is unavailable", () => {
|
||||||
const dir = makeFixtureRepo();
|
const dir = makeFixtureRepo();
|
||||||
commit(dir, "feat: plain work (#7)");
|
commit(dir, "feat: plain work (#7)");
|
||||||
git(dir, "tag", "beta/v2026.100.0-beta.0");
|
git(dir, "tag", "beta/v2026.100.0-beta.0");
|
||||||
|
const gh = installFakeGh(dir, {}, { unavailable: true });
|
||||||
|
|
||||||
const { body } = runDraft(dir, "2026.100.0-beta.0");
|
const { body } = runDraft(dir, "2026.100.0-beta.0", [], gh.env);
|
||||||
|
|
||||||
assert.match(body, /- feat: plain work \(#7\)\n/);
|
assert.match(body, /- feat: plain work \(#7\)\n/);
|
||||||
assert.doesNotMatch(body, / > /);
|
assert.doesNotMatch(body, / > /);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("does not call gh when enrichment is disabled", () => {
|
||||||
|
const dir = makeFixtureRepo();
|
||||||
|
commit(dir, "feat: plain work (#7)");
|
||||||
|
git(dir, "tag", "beta/v2026.100.0-beta.0");
|
||||||
|
const gh = installFakeGh(dir, { 7: whatChanged("Should not appear") });
|
||||||
|
|
||||||
|
const { body } = runDraft(dir, "2026.100.0-beta.0", [], {
|
||||||
|
...gh.env,
|
||||||
|
DRAFT_NOTES_SKIP_PR_ENRICHMENT: "1",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(gh.calls().length, 0);
|
||||||
|
assert.doesNotMatch(body, /Should not appear/);
|
||||||
|
});
|
||||||
|
|
||||||
test("rejects a malformed beta version", () => {
|
test("rejects a malformed beta version", () => {
|
||||||
const dir = makeFixtureRepo();
|
const dir = makeFixtureRepo();
|
||||||
assert.throws(() =>
|
assert.throws(() =>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"package.json": "e670c9718e4b0039768aad204085b1063508997a",
|
"package.json": "88c3caa4d83b43e4c3aa473d8a64da053d17662c",
|
||||||
"packages/plugins/sdk/package.json": "b7a4936e2803fd0d3b32dd7f17fc1ea86609ead0",
|
"packages/plugins/sdk/package.json": "b7a4936e2803fd0d3b32dd7f17fc1ea86609ead0",
|
||||||
"packages/plugins/sdk/src/bundlers.ts": "883bf3d9f2bd42877cfc0c3707cd882a6f6248d0",
|
"packages/plugins/sdk/src/bundlers.ts": "883bf3d9f2bd42877cfc0c3707cd882a6f6248d0",
|
||||||
"packages/plugins/sdk/src/define-plugin.ts": "eb6530c006722d22f55355ec810745f1524b874a",
|
"packages/plugins/sdk/src/define-plugin.ts": "eb6530c006722d22f55355ec810745f1524b874a",
|
||||||
|
|||||||
Reference in new issue
Block a user