From c54819a7716f74b41abd64337c7b221e65cea26c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:22:47 +0000 Subject: [PATCH] feat(changelog): Preview PRs in every release workspace they land in Co-Authored-By: Burak Yigit Kaya <126780+BYK@users.noreply.github.com> --- .github/workflows/changelog-preview.yml | 72 +++++-- docs/src/content/docs/github-actions.md | 17 ++ docs/src/content/docs/workspaces.md | 9 +- .../changelog-preview-workflow.test.ts | 155 +++++++++++++ .../__tests__/changelog-workspaces.test.ts | 155 +++++++++++++ src/commands/changelog.ts | 203 ++++++++++++++---- .../__tests__/changelog-generate.test.ts | 76 ++++++- src/utils/changelog.ts | 74 ++++++- src/utils/git.ts | 86 +++++--- 9 files changed, 755 insertions(+), 92 deletions(-) create mode 100644 src/commands/__tests__/changelog-workspaces.test.ts diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 2bb477c78..60cb6ae83 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -165,20 +165,44 @@ jobs: BUMP_TYPE=$(echo "$RESULT" | jq -r '.bumpType // "none"') PR_SKIPPED=$(echo "$RESULT" | jq -r '.prSkipped // false') VERSIONING_POLICY=$(echo "$RESULT" | jq -r '.versioningPolicy // "auto"') + # Only set when .craft.yml defines release workspaces: one entry per + # workspace whose changelog lists this PR + WORKSPACES=$(echo "$RESULT" | jq -r 'if .workspaces then .workspaces | length else "" end') # Export for downstream steps (e.g., title enforcement) { echo "bump_type=${BUMP_TYPE}" echo "pr_skipped=${PR_SKIPPED}" echo "versioning_policy=${VERSIONING_POLICY}" + echo "workspace_count=${WORKSPACES}" } >> "$GITHUB_OUTPUT" - if [[ "$PR_SKIPPED" == "true" ]]; then + if [[ "$WORKSPACES" == "0" ]]; then + CHANGELOG="_This PR does not change any workspace, so it will not appear in any changelog._" + elif [[ "$PR_SKIPPED" == "true" ]]; then CHANGELOG="_This PR will not appear in the changelog._" elif [[ -z "$CHANGELOG" ]]; then CHANGELOG="_No changelog entries will be generated from this PR._" fi + bump_badge() { + case "$1" in + major) echo "🔴 **Major** (breaking changes)" ;; + minor) echo "🟡 **Minor** (new features)" ;; + patch) echo "🟢 **Patch** (bug fixes)" ;; + *) echo "⚪ **None** (no version bump detected)" ;; + esac + } + + bump_short() { + case "$1" in + major) echo "Major" ;; + minor) echo "Minor" ;; + patch) echo "Patch" ;; + *) echo "None" ;; + esac + } + # CalVer projects don't use semver bumps — skip the impact badge if [[ "$VERSIONING_POLICY" == "calver" ]]; then BUMP_BADGE="" @@ -186,24 +210,36 @@ jobs: SECTION_HEADING="Changelog Preview" STATUS_CONTEXT="Changelog Preview" else - case "$BUMP_TYPE" in - major) BUMP_BADGE="🔴 **Major** (breaking changes)" ;; - minor) BUMP_BADGE="🟡 **Minor** (new features)" ;; - patch) BUMP_BADGE="🟢 **Patch** (bug fixes)" ;; - *) BUMP_BADGE="⚪ **None** (no version bump detected)" ;; - esac - - case "$BUMP_TYPE" in - major) BUMP_SHORT="Major" ;; - minor) BUMP_SHORT="Minor" ;; - patch) BUMP_SHORT="Patch" ;; - *) BUMP_SHORT="None" ;; - esac - + BUMP_BADGE=$(bump_badge "$BUMP_TYPE") + BUMP_SHORT=$(bump_short "$BUMP_TYPE") SECTION_HEADING="Semver Impact of This PR" STATUS_CONTEXT="Changelog Preview / Semver Impact" fi + # With workspaces, list each workspace the PR lands in with its own bump + if [[ "$WORKSPACES" == "0" ]]; then + BUMP_BADGE="⚪ **None** (no workspace changed)" + BUMP_SHORT="No workspace changed" + elif [[ -n "$WORKSPACES" ]]; then + BUMP_BADGE="This PR lands in these workspaces:"$'\n' + BUMP_SHORT="" + while IFS=$'\t' read -r WS_NAME WS_BUMP WS_POLICY; do + if [[ "$WS_POLICY" == "calver" ]]; then + WS_BADGE="CalVer" + WS_SHORT="CalVer" + else + WS_BADGE=$(bump_badge "$WS_BUMP") + WS_SHORT=$(bump_short "$WS_BUMP") + fi + BUMP_BADGE+=$'\n'"- \`${WS_NAME}\`: ${WS_BADGE}" + BUMP_SHORT+="${BUMP_SHORT:+, }${WS_NAME}: ${WS_SHORT}" + done < <(echo "$RESULT" | jq -r '.workspaces[] | [.name, (.bumpType // "none"), (.versioningPolicy // "auto")] | @tsv') + # GitHub rejects commit status descriptions over 140 characters + if (( ${#BUMP_SHORT} > 140 )); then + BUMP_SHORT="${BUMP_SHORT:0:139}…" + fi + fi + # Determine mode: use status check mode when comment is false OR when running internally (no input) USE_COMMENT_MODE="${{ inputs.comment }}" if [[ "$USE_COMMENT_MODE" == "false" ]] || [[ -z "$USE_COMMENT_MODE" ]]; then @@ -308,12 +344,18 @@ jobs: BUMP_TYPE: ${{ steps.preview.outputs.bump_type }} PR_SKIPPED: ${{ steps.preview.outputs.pr_skipped }} VERSIONING_POLICY: ${{ steps.preview.outputs.versioning_policy }} + WORKSPACE_COUNT: ${{ steps.preview.outputs.workspace_count }} run: | if [[ "$PR_SKIPPED" == "true" ]]; then echo "PR is skip-changelog — skipping title enforcement" exit 0 fi + if [[ "$WORKSPACE_COUNT" == "0" ]]; then + echo "PR does not change any workspace — skipping title enforcement" + exit 0 + fi + if [[ "$VERSIONING_POLICY" == "manual" ]]; then echo "Manual versioning policy — skipping title enforcement" exit 0 diff --git a/docs/src/content/docs/github-actions.md b/docs/src/content/docs/github-actions.md index 14ba77519..1e2025010 100644 --- a/docs/src/content/docs/github-actions.md +++ b/docs/src/content/docs/github-actions.md @@ -442,6 +442,23 @@ options. Pass the `workspace` input to release one [release workspace](../workspaces/#github-actions) at a time. +The changelog preview needs no extra input for release workspaces. It lists +each workspace the pull request lands in, together with its version bump, and +shows the pull request's entry once: + +```markdown +## Semver Impact of This PR + +This PR lands in these workspaces: + +- `clients/python`: 🟡 **Minor** (new features) +- `clients/rust`: 🟡 **Minor** (new features) +``` + +A pull request lands in a workspace when its +[workspace-aware changelog](../workspaces/#workspace-aware-changelogs) would +list it. + ### Combining Both Workflows You can use both the changelog preview and release workflows together for a complete release flow: diff --git a/docs/src/content/docs/workspaces.md b/docs/src/content/docs/workspaces.md index 5047fb286..379bf9446 100644 --- a/docs/src/content/docs/workspaces.md +++ b/docs/src/content/docs/workspaces.md @@ -287,13 +287,18 @@ Publish request titles include the full workspace path, for example `publish: getsentry/toolkit/packages/cli@1.2.3`. A workflow cannot set both `path` and `workspace`. +The [changelog preview workflow](../github-actions/#changelog-preview) needs no +`workspace` input. It previews each pull request in every workspace whose +changelog would list it, using the same path rules as the release changelog, +and posts a single comment or status that names each of those workspaces with +its version bump. Running `craft changelog --pr ` without a workspace +does the same. + ## Known limitations - GitHub tracks a single "Latest" release per repository, so the badge can move between products. See the [GitHub target](../targets/github/#the-latest-badge-in-monorepos). -- The [changelog preview workflow](../github-actions/#changelog-preview) has no - `workspace` input yet. ## Alternative: one config per product diff --git a/src/__tests__/changelog-preview-workflow.test.ts b/src/__tests__/changelog-preview-workflow.test.ts index aabd6b7a0..26f417f62 100644 --- a/src/__tests__/changelog-preview-workflow.test.ts +++ b/src/__tests__/changelog-preview-workflow.test.ts @@ -102,3 +102,158 @@ printf '#!/usr/bin/env bash\\nprintf "2.35.0\\\\n"\\n' > "$3" } }, ); + +const preview = workflow.jobs.preview.steps.find( + step => step.name === 'Generate Changelog Preview', +)!; + +function runPreview(craftOutput: object, comment: 'true' | 'false') { + const directory = mkdtempSync(join(tmpdir(), 'craft-preview-')); + try { + const executable = (name: string, body: string) => { + const path = join(directory, name); + writeFileSync(path, '#!/usr/bin/env bash\nset -euo pipefail\n' + body); + chmodSync(path, 0o755); + }; + writeFileSync(join(directory, 'result.json'), JSON.stringify(craftOutput)); + executable('craft', `cat "${join(directory, 'result.json')}"\n`); + // Records each call; comment bodies are copied from their @file argument + executable( + 'gh', + ` +printf '%s\\n' "$*" >> "$CALLS" +for arg in "$@"; do + if [[ "$arg" == body=@* ]]; then cat "\${arg#body=@}" >> "$CALLS"; fi +done +`, + ); + const files = { + output: join(directory, 'output'), + summary: join(directory, 'summary'), + calls: join(directory, 'calls'), + }; + for (const file of Object.values(files)) { + writeFileSync(file, ''); + } + const result = spawnSync( + 'bash', + [ + '-eo', + 'pipefail', + '-c', + preview.run!.replace('${{ inputs.comment }}', comment), + ], + { + encoding: 'utf8', + env: { + ...process.env, + PATH: directory + ':' + process.env.PATH, + PR_NUMBER: '7', + HEAD_SHA: 'abc123', + GITHUB_REPOSITORY: 'getsentry/example', + GITHUB_SERVER_URL: 'https://github.com', + GITHUB_RUN_ID: '1', + GITHUB_OUTPUT: files.output, + GITHUB_STEP_SUMMARY: files.summary, + CALLS: files.calls, + }, + }, + ); + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + return { + output: readFileSync(files.output, 'utf8'), + summary: readFileSync(files.summary, 'utf8'), + calls: readFileSync(files.calls, 'utf8'), + }; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +test('lists each workspace the PR lands in with its bump in one comment', () => { + const { output, calls } = runPreview( + { + changelog: '### New Features\n\n> - Add option', + bumpType: 'minor', + versioningPolicy: 'auto', + prSkipped: false, + workspaces: [ + { name: 'clients/python', bumpType: 'minor', versioningPolicy: 'auto' }, + { name: 'clients/rust', bumpType: 'patch', versioningPolicy: 'auto' }, + { name: 'server', bumpType: 'minor', versioningPolicy: 'calver' }, + ], + }, + 'true', + ); + + expect(output).toContain('workspace_count=3\n'); + // One comment body, posted once + expect(calls.match(/^$/gm)).toHaveLength(1); + expect(calls).toContain( + 'This PR lands in these workspaces:\n\n' + + '- `clients/python`: 🟡 **Minor** (new features)\n' + + '- `clients/rust`: 🟢 **Patch** (bug fixes)\n' + + '- `server`: CalVer\n', + ); + expect(calls.match(/> - Add option/g)).toHaveLength(1); +}); + +test('names each workspace and bump in a single commit status', () => { + const { calls, summary } = runPreview( + { + changelog: '', + bumpType: 'minor', + versioningPolicy: 'auto', + prSkipped: true, + workspaces: [ + { name: 'clients/python', bumpType: 'minor', versioningPolicy: 'auto' }, + { name: 'clients/rust', bumpType: null, versioningPolicy: 'auto' }, + ], + }, + 'false', + ); + + const statuses = calls.split('\n').filter(line => line.includes('statuses')); + expect(statuses).toHaveLength(1); + expect(statuses[0]).toContain( + 'description=clients/python: Minor, clients/rust: None', + ); + expect(statuses[0]).toContain('context=Changelog Preview / Semver Impact'); + expect(summary).toContain('_This PR will not appear in the changelog._'); +}); + +test('says so when the PR lands in no workspace', () => { + const { output, summary, calls } = runPreview( + { + changelog: '', + bumpType: null, + versioningPolicy: 'auto', + prSkipped: false, + workspaces: [], + }, + 'false', + ); + + expect(output).toContain('workspace_count=0\n'); + expect(calls).toContain('description=No workspace changed'); + expect(summary).toContain('⚪ **None** (no workspace changed)'); + expect(summary).toContain('will not appear in any changelog'); +}); + +test('keeps the single-changelog preview without workspaces', () => { + const { output, summary, calls } = runPreview( + { + changelog: '### Bug Fixes\n\n> - Fix crash', + bumpType: 'patch', + versioningPolicy: 'auto', + prSkipped: false, + }, + 'false', + ); + + expect(output).toContain('workspace_count=\n'); + expect(calls).toContain('description=Patch'); + expect(summary).toContain('🟢 **Patch** (bug fixes)'); + expect(summary).toContain('> - Fix crash'); +}); diff --git a/src/commands/__tests__/changelog-workspaces.test.ts b/src/commands/__tests__/changelog-workspaces.test.ts new file mode 100644 index 000000000..b885b1f97 --- /dev/null +++ b/src/commands/__tests__/changelog-workspaces.test.ts @@ -0,0 +1,155 @@ +import { vi, beforeEach, afterEach, describe, it, expect } from 'vitest'; + +import type { ChangelogResult } from '../../utils/changelog'; + +vi.mock('../../logger'); + +const state = vi.hoisted(() => ({ + active: undefined as string | undefined, + selections: [] as (string | undefined)[], +})); + +vi.mock('../../config', () => ({ + findConfigFile: vi.fn(() => '/repo/.craft.yml'), + getActiveWorkspace: vi.fn(() => state.active), + setActiveWorkspace: vi.fn((name: string | undefined) => { + state.active = name; + state.selections.push(name); + }), + getWorkspaceNames: vi.fn(() => [ + 'clients/python', + 'clients/rust', + 'objectstore-server', + ]), + getGitTagPrefix: vi.fn(() => `${state.active}@`), + getVersioningPolicy: vi.fn(() => + state.active === 'objectstore-server' ? 'calver' : 'auto', + ), +})); + +vi.mock('../../utils/git', () => ({ + getGitClient: vi.fn().mockResolvedValue({}), + getLatestTag: vi.fn( + async (_git: unknown, prefix: string) => `${prefix}1.0.0`, + ), +})); + +const results: Record = { + 'clients/python': { + changelog: '### New Features\n> - Add option\n- Older python fix', + prEntry: '### New Features\n> - Add option', + bumpType: 'minor', + totalCommits: 2, + matchedCommitsWithSemver: 2, + prSkipped: false, + }, + 'clients/rust': { + changelog: '', + bumpType: null, + totalCommits: 0, + matchedCommitsWithSemver: 0, + prSkipped: false, + prOutsideWorkspace: true, + }, + 'objectstore-server': { + changelog: '### New Features\n> - Add option', + prEntry: '### New Features\n> - Add option', + bumpType: 'minor', + totalCommits: 1, + matchedCommitsWithSemver: 1, + prSkipped: false, + }, +}; + +vi.mock('../../utils/changelog', async importOriginal => ({ + BUMP_TYPES: (await importOriginal()) + .BUMP_TYPES, + generateChangesetFromGit: vi.fn(), + generateChangelogWithHighlight: vi.fn(async () => results[state.active!]), +})); + +describe('changelog --pr with release workspaces', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + state.active = undefined; + state.selections = []; + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('previews the PR in each workspace it lands in', async () => { + const { generateChangelogWithHighlight } = + await import('../../utils/changelog'); + const { changelogMain } = await import('../changelog'); + + await changelogMain({ pr: 7, format: 'json' }); + + // Each workspace's preview starts from that workspace's latest tag + expect(vi.mocked(generateChangelogWithHighlight).mock.calls).toEqual([ + [{}, 'clients/python@1.0.0', 7], + [{}, 'clients/rust@1.0.0', 7], + [{}, 'objectstore-server@1.0.0', 7], + ]); + // The selection is cleared afterwards + expect(state.selections.at(-1)).toBeUndefined(); + + expect(JSON.parse(consoleSpy.mock.calls[0][0] as string)).toEqual({ + changelog: '### New Features\n> - Add option', + bumpType: 'minor', + versioningPolicy: 'auto', + prSkipped: false, + workspaces: [ + { + name: 'clients/python', + bumpType: 'minor', + versioningPolicy: 'auto', + }, + { + name: 'objectstore-server', + bumpType: 'minor', + versioningPolicy: 'calver', + }, + ], + }); + }); + + it('reports no workspaces when the PR lands in none', async () => { + const { generateChangelogWithHighlight } = + await import('../../utils/changelog'); + const { changelogMain } = await import('../changelog'); + // Called once per workspace + for (let i = 0; i < 3; i++) { + vi.mocked(generateChangelogWithHighlight).mockResolvedValueOnce( + results['clients/rust'], + ); + } + + await changelogMain({ pr: 7, format: 'json' }); + + expect(JSON.parse(consoleSpy.mock.calls[0][0] as string)).toMatchObject({ + changelog: '', + bumpType: null, + prSkipped: false, + workspaces: [], + }); + }); + + it('uses the selected workspace alone when one is given', async () => { + const { generateChangelogWithHighlight } = + await import('../../utils/changelog'); + const { changelogMain } = await import('../changelog'); + state.active = 'clients/python'; + + await changelogMain({ pr: 7, format: 'json' }); + + expect(generateChangelogWithHighlight).toHaveBeenCalledTimes(1); + const output = JSON.parse(consoleSpy.mock.calls[0][0] as string); + expect(output.workspaces).toBeUndefined(); + expect(output.changelog).toContain('Older python fix'); + }); +}); diff --git a/src/commands/changelog.ts b/src/commands/changelog.ts index e026975bd..f6814797f 100644 --- a/src/commands/changelog.ts +++ b/src/commands/changelog.ts @@ -1,13 +1,20 @@ +import type { SimpleGit } from 'simple-git'; import { Argv, CommandBuilder } from 'yargs'; import { logger } from '../logger'; import { findConfigFile, + getActiveWorkspace, getGitTagPrefix, getVersioningPolicy, + getWorkspaceNames, + setActiveWorkspace, } from '../config'; import { getGitClient, getLatestTag } from '../utils/git'; import { + BUMP_TYPES, + type BumpType, + type ChangelogResult, generateChangesetFromGit, generateChangelogWithHighlight, } from '../utils/changelog'; @@ -50,42 +57,34 @@ export const builder: CommandBuilder = (yargs: Argv) => default: 'text', }); +/** The changelog preview of a PR for one workspace it lands in */ +interface WorkspacePreview { + name: string; + versioningPolicy: string; + result: ChangelogResult; +} + /** * Body of 'changelog' command */ export async function changelogMain(argv: ChangelogOptions): Promise { const git = await getGitClient(); - // Determine base revision for changelog generation - let since = argv.since; - if (!since) { - // Scope the latest-tag lookup to the configured tag prefix (if any) so - // monorepos with interleaved product tags (e.g. `cli@`, `mcp@`) resolve - // the correct base. Only read the prefix when a config file is present; - // the changelog command can run standalone without one. A broken or - // unreadable .craft.yml must not abort a standalone changelog run (which - // otherwise needs only git history), so fall back to no prefix on error. - let tagPrefix = ''; - try { - if (findConfigFile()) { - tagPrefix = getGitTagPrefix(); - } - } catch { - // If config can't be read, generate from the latest unprefixed tag. - logger.debug( - 'Could not read tag prefix from config; using latest tag overall', - ); - } - since = await getLatestTag(git, tagPrefix); - if (since) { - logger.debug(`Using latest tag as base revision: ${since}`); - } else { - logger.debug( - 'No tags found, generating changelog from beginning of history', - ); - } + // Without a selected workspace, preview a PR in every workspace it lands in. + const workspaces = argv.pr && !getActiveWorkspace() ? listWorkspaces() : []; + if (argv.pr && workspaces.length > 0) { + const previews = await previewInWorkspaces( + git, + workspaces, + argv.pr, + argv.since, + ); + printWorkspacePreviews(previews, argv.format); + return; } + const since = argv.since ?? (await getDefaultBase(git)); + // Generate changelog - use different function depending on whether PR is specified const result = argv.pr ? await generateChangelogWithHighlight(git, since, argv.pr) @@ -93,21 +92,11 @@ export async function changelogMain(argv: ChangelogOptions): Promise { // Output based on format if (argv.format === 'json') { - // Detect the versioning policy from .craft.yml so consumers (e.g. the - // changelog-preview workflow) can tailor their display accordingly. - let versioningPolicy = 'auto'; - try { - if (findConfigFile()) { - versioningPolicy = getVersioningPolicy(); - } - } catch { - // If config can't be read, default to 'auto' (semver behavior) - } - const output = { changelog: result.changelog || '', bumpType: result.bumpType, - versioningPolicy, + // Lets consumers (e.g. the changelog-preview workflow) tailor their display + versioningPolicy: readVersioningPolicy(), totalCommits: result.totalCommits, matchedCommitsWithSemver: result.matchedCommitsWithSemver, prSkipped: result.prSkipped ?? false, @@ -122,6 +111,140 @@ export async function changelogMain(argv: ChangelogOptions): Promise { } } +/** + * Returns the latest tag to generate the changelog from, or an empty string + * when there are no tags. + */ +async function getDefaultBase(git: SimpleGit): Promise { + // Scope the latest-tag lookup to the configured tag prefix (if any) so + // monorepos with interleaved product tags (e.g. `cli@`, `mcp@`) resolve + // the correct base. Only read the prefix when a config file is present; + // the changelog command can run standalone without one. A broken or + // unreadable .craft.yml must not abort a standalone changelog run (which + // otherwise needs only git history), so fall back to no prefix on error. + let tagPrefix = ''; + try { + if (findConfigFile()) { + tagPrefix = getGitTagPrefix(); + } + } catch { + // If config can't be read, generate from the latest unprefixed tag. + logger.debug( + 'Could not read tag prefix from config; using latest tag overall', + ); + } + const since = await getLatestTag(git, tagPrefix); + if (since) { + logger.debug(`Using latest tag as base revision: ${since}`); + } else { + logger.debug( + 'No tags found, generating changelog from beginning of history', + ); + } + return since; +} + +/** + * Reads the versioning policy from .craft.yml, defaulting to 'auto' (semver + * behavior) when there is no readable config. + */ +function readVersioningPolicy(): string { + try { + if (findConfigFile()) { + return getVersioningPolicy(); + } + } catch { + // Fall through to the default + } + return 'auto'; +} + +/** + * Lists the configured workspaces, or none when there is no readable config. + */ +function listWorkspaces(): string[] { + try { + return findConfigFile() ? getWorkspaceNames() : []; + } catch { + // An unreadable config falls back to the single-changelog preview, + // which reports the configuration error itself. + return []; + } +} + +/** + * Previews a PR in each workspace, keeping only the workspaces whose + * changelog would list it. + */ +async function previewInWorkspaces( + git: SimpleGit, + workspaces: string[], + pr: number, + since: string | undefined, +): Promise { + const previews: WorkspacePreview[] = []; + try { + for (const name of workspaces) { + setActiveWorkspace(name); + const result = await generateChangelogWithHighlight( + git, + since ?? (await getDefaultBase(git)), + pr, + ); + if (!result.prOutsideWorkspace) { + previews.push({ + name, + versioningPolicy: readVersioningPolicy(), + result, + }); + } + } + } finally { + setActiveWorkspace(undefined); + } + return previews; +} + +function printWorkspacePreviews( + previews: WorkspacePreview[], + format: OutputFormat | undefined, +): void { + if (format === 'json') { + const bumpTypes = previews + .map(({ result }) => result.bumpType) + .filter((bumpType): bumpType is BumpType => bumpType !== null) + .sort((a, b) => BUMP_TYPES.get(a)! - BUMP_TYPES.get(b)!); + const policies = new Set(previews.map(p => p.versioningPolicy)); + const output = { + // The PR's entry reads the same in every workspace, so show it once + changelog: previews.find(p => p.result.prEntry)?.result.prEntry ?? '', + bumpType: bumpTypes[0] ?? null, + versioningPolicy: policies.size === 1 ? [...policies][0] : 'auto', + prSkipped: previews.some(p => p.result.prSkipped), + workspaces: previews.map(({ name, versioningPolicy, result }) => ({ + name, + bumpType: result.bumpType, + versioningPolicy, + })), + }; + console.log(JSON.stringify(output, null, 2)); + return; + } + + if (previews.length === 0) { + console.log('This PR is not listed in any workspace changelog.'); + return; + } + for (const { name, result } of previews) { + console.log(`# ${name}\n`); + console.log( + result.prSkipped + ? 'This PR will not appear in the changelog.\n' + : `${result.changelog || 'No changelog entries found.'}\n`, + ); + } +} + export const handler = async (args: { [argName: string]: any; }): Promise => { diff --git a/src/utils/__tests__/changelog-generate.test.ts b/src/utils/__tests__/changelog-generate.test.ts index fb275d366..2882e8184 100644 --- a/src/utils/__tests__/changelog-generate.test.ts +++ b/src/utils/__tests__/changelog-generate.test.ts @@ -7,7 +7,7 @@ import { vi, type Mock, type MockedFunction } from 'vitest'; vi.mock('../githubApi.ts'); import { getGitHubClient } from '../githubApi'; vi.mock('../git'); -import { getChangesSince } from '../git'; +import { getChangesSince, getWorkspaceChangeFilter } from '../git'; vi.mock('fs', async importOriginal => { const actual = await importOriginal(); return { @@ -2277,7 +2277,7 @@ See incident report: https://example.com/incident/123`, describe('generateChangelogWithHighlight', () => { let mockGraphqlClient: Mock; let mockRestClient: { - pulls: { get: Mock }; + pulls: { get: Mock; listFiles: Mock }; issues: { listLabelsOnIssue: Mock }; }; const mockGetChangesSince = getChangesSince as MockedFunction< @@ -2293,7 +2293,7 @@ describe('generateChangelogWithHighlight', () => { clearReleaseConfigCache(); mockGraphqlClient = vi.fn(); mockRestClient = { - pulls: { get: vi.fn() }, + pulls: { get: vi.fn(), listFiles: vi.fn() }, issues: { listLabelsOnIssue: vi.fn() }, }; (getGitHubClient as MockedFunction).mockReturnValue( @@ -2802,4 +2802,74 @@ describe('generateChangelogWithHighlight', () => { expect(result.bumpType).toBe('patch'); }); }); + + describe('with a workspace selected', () => { + const mockFilter = getWorkspaceChangeFilter as MockedFunction< + typeof getWorkspaceChangeFilter + >; + + it('leaves out a PR that changes no workspace files', async () => { + setup({ + currentPR: { number: 5, title: 'feat(server): Add endpoint' }, + existingCommits: [], + }); + const belongsToWorkspace = vi.fn(() => false); + mockFilter.mockResolvedValue(belongsToWorkspace); + mockRestClient.pulls.listFiles.mockResolvedValueOnce({ + data: [{ filename: 'server/src/main.rs' }], + }); + + const result = await generateChangelogWithHighlight(dummyGit, '1.0.0', 5); + + expect(result).toMatchObject({ + changelog: '', + bumpType: null, + prOutsideWorkspace: true, + }); + expect(belongsToWorkspace).toHaveBeenCalledWith(['server/src/main.rs']); + expect(dummyGit.fetch).not.toHaveBeenCalled(); + }); + + it('checks every page of changed files, including renamed paths', async () => { + setup({ + currentPR: { number: 5, title: 'feat(py): Add client option' }, + existingCommits: [ + { hash: 'abc123', title: 'fix(py): Older fix (#4)', body: '' }, + ], + }); + const belongsToWorkspace = vi.fn(() => true); + mockFilter.mockResolvedValue(belongsToWorkspace); + const firstPage = Array.from({ length: 100 }, (_, i) => ({ + filename: `docs/${i}.md`, + })); + mockRestClient.pulls.listFiles + .mockResolvedValueOnce({ data: firstPage }) + .mockResolvedValueOnce({ + data: [ + { + filename: 'clients/python/new.py', + previous_filename: 'clients/rust/old.rs', + }, + ], + }); + + const result = await generateChangelogWithHighlight(dummyGit, '1.0.0', 5); + + expect(mockRestClient.pulls.listFiles).toHaveBeenCalledTimes(2); + expect(mockRestClient.pulls.listFiles).toHaveBeenLastCalledWith( + expect.objectContaining({ pull_number: 5, page: 2 }), + ); + expect(belongsToWorkspace).toHaveBeenCalledWith([ + ...firstPage.map(({ filename }) => filename), + 'clients/python/new.py', + 'clients/rust/old.rs', + ]); + expect(result.prOutsideWorkspace).toBeUndefined(); + expect(result.bumpType).toBe('minor'); + expect(result.changelog).toContain('Older fix'); + // The PR's own entry leaves out the rest of the changelog + expect(result.prEntry).toContain('Add client option'); + expect(result.prEntry).not.toContain('Older fix'); + }); + }); }); diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index da8583a57..40c73c9b7 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -12,7 +12,7 @@ import { getGlobalGitHubConfig, getChangelogConfig, } from '../config'; -import { getChangesSince } from './git'; +import { getChangesSince, getWorkspaceChangeFilter } from './git'; import { getGitHubClient } from './githubApi'; import { getVersion } from './version'; import type { AiSummaryConfig } from '../schemas/project_config'; @@ -74,6 +74,35 @@ async function fetchPRInfo(prNumber: number): Promise { }; } +/** + * Lists the files a PR changes, including the previous path of renamed files, + * so a move between workspaces counts for both sides. + */ +async function fetchPRFiles(prNumber: number): Promise { + const { repo, owner } = await getGlobalGitHubConfig(); + const github = getGitHubClient(); + const perPage = 100; + const files: string[] = []; + for (let page = 1; ; page++) { + const { data } = await github.pulls.listFiles({ + owner, + repo, + pull_number: prNumber, + per_page: perPage, + page, + }); + for (const file of data) { + files.push(file.filename); + if (file.previous_filename) { + files.push(file.previous_filename); + } + } + if (data.length < perPage) { + return files; + } + } +} + interface CommitAuthors { nodes: Array<{ user: { login: string } | null }>; } @@ -1592,6 +1621,17 @@ export interface ChangelogResult { matchedCommitsWithSemver: number; /** Whether the current PR was skipped (only set when using --pr flag) */ prSkipped?: boolean; + /** + * Whether the current PR changes no files in the active workspace, so it + * won't be listed in that workspace's changelog (only set when using --pr + * flag with a workspace selected) + */ + prOutsideWorkspace?: boolean; + /** + * The current PR's own entry, rendered without the rest of the changelog + * (only set when using --pr flag) + */ + prEntry?: string; } /** @@ -1712,7 +1752,25 @@ export async function generateChangelogWithHighlight( // Step 2: Calculate bump type for this specific PR (used for both skipped and non-skipped PRs) const prBumpType = getBumpTypeForPR(prInfo); - // Step 3: Check if PR should be skipped - bypass changelog generation but still return bump type + // Step 3: With a workspace selected, apply the same path rules as the + // workspace's release changelog. A PR outside the workspace neither appears + // in its changelog nor bumps its version. + const belongsToWorkspace = await getWorkspaceChangeFilter(); + if ( + belongsToWorkspace && + !belongsToWorkspace(await fetchPRFiles(currentPRNumber)) + ) { + return { + changelog: '', + bumpType: null, + totalCommits: 0, + matchedCommitsWithSemver: 0, + prSkipped: false, + prOutsideWorkspace: true, + }; + } + + // Step 3b: Check if PR should be skipped - bypass changelog generation but still return bump type if (shouldSkipCurrentPR(prInfo)) { return { changelog: '', @@ -1774,6 +1832,17 @@ export async function generateChangelogWithHighlight( true, ); + // The PR's entry on its own, for previews that span several changelogs + const prEntry = currentPRSurvived + ? await serializeChangelog( + categorizeCommits(filteredCommits.filter(c => c.highlight)).data, + MAX_LEFTOVERS, + true, + true, + false, + ) + : ''; + // Determine bump type: // - If current PR survived revert processing, use its specific bump type (PR 676 fix) // This shows "what is this PR's contribution to the version bump" @@ -1784,6 +1853,7 @@ export async function generateChangelogWithHighlight( return { changelog, prSkipped: false, + prEntry, bumpType, totalCommits: stats.totalCommits, matchedCommitsWithSemver: stats.matchedCommitsWithSemver, diff --git a/src/utils/git.ts b/src/utils/git.ts index 7c18d5771..9e30c7ccb 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -154,37 +154,13 @@ export async function getChangesSince( body: commit.body, pr: commit.message.match(PRExtractor)?.[0] || null, })); - if (!workspace || changes.length === 0) { + if (changes.length === 0) { return changes; } - - const root = getConfigFileDir() || process.cwd(); - const discovered = await discoverWorkspaces(root); - const ownedPaths = new Set(getWorkspaceNames()); - for (const pkg of discovered.packages) { - const relative = path - .relative(root, pkg.location) - .split(path.sep) - .join('/'); - if (relative === '' || relative === '..' || relative.startsWith('../')) { - throw new ConfigurationError( - `Workspace package is outside the repository: ${pkg.name}`, - ); - } - ownedPaths.add(relative); + const belongsToWorkspace = await getWorkspaceChangeFilter(); + if (!belongsToWorkspace) { + return changes; } - const orderedOwners = [...ownedPaths].sort( - (left, right) => right.length - left.length, - ); - const { includePaths, unownedPaths } = getChangelogConfig(); - const unownedPathsOwner = getUnownedPathsOwner(); - // Files outside every workspace belong to the configured owner. Without one, - // they are shared unless this workspace excludes them. - const includesUnowned = unownedPathsOwner - ? unownedPathsOwner === workspace - : unownedPaths !== 'exclude'; - const contains = (directory: string, file: string) => - file === directory || file.startsWith(`${directory}/`); // NUL-delimited filenames remain unambiguous even when they contain spaces // or newlines. Disabling renames includes both sides of a moved file. @@ -220,14 +196,64 @@ export async function getChangesSince( if (!files) { throw new Error(`Missing changelog paths for commit ${commit.hash}`); } - return files.some(file => { + return belongsToWorkspace(files); + }); +} + +/** + * Returns a predicate that tells whether a change touching the given + * repository-relative files belongs in the active workspace's changelog, or + * `undefined` when no workspace is selected. + * + * A file belongs to the workspace when it is under one of the workspace's + * `changelog.includePaths`, when the workspace is its closest owner (a + * workspace or discovered package directory), or when no workspace owns it + * and the `changelog.unownedPaths` rules give unowned files to this workspace. + */ +export async function getWorkspaceChangeFilter(): Promise< + ((files: string[]) => boolean) | undefined +> { + const workspace = getActiveWorkspace(); + if (!workspace) { + return undefined; + } + + const root = getConfigFileDir() || process.cwd(); + const discovered = await discoverWorkspaces(root); + const ownedPaths = new Set(getWorkspaceNames()); + for (const pkg of discovered.packages) { + const relative = path + .relative(root, pkg.location) + .split(path.sep) + .join('/'); + if (relative === '' || relative === '..' || relative.startsWith('../')) { + throw new ConfigurationError( + `Workspace package is outside the repository: ${pkg.name}`, + ); + } + ownedPaths.add(relative); + } + const orderedOwners = [...ownedPaths].sort( + (left, right) => right.length - left.length, + ); + const { includePaths, unownedPaths } = getChangelogConfig(); + const unownedPathsOwner = getUnownedPathsOwner(); + // Files outside every workspace belong to the configured owner. Without one, + // they are shared unless this workspace excludes them. + const includesUnowned = unownedPathsOwner + ? unownedPathsOwner === workspace + : unownedPaths !== 'exclude'; + const contains = (directory: string, file: string) => + file === directory || file.startsWith(`${directory}/`); + + return files => + files.some(file => { if (includePaths.some(directory => contains(directory, file))) { return true; } const owner = orderedOwners.find(directory => contains(directory, file)); return owner === undefined ? includesUnowned : owner === workspace; }); - }); } /**