diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml new file mode 100644 index 000000000..95d6bdcbe --- /dev/null +++ b/.github/workflows/changelog-preview.yml @@ -0,0 +1,105 @@ +name: Changelog Preview + +on: + # Allow this workflow to be called from other repositories + workflow_call: + inputs: + craft-version: + description: 'Version of Craft to use (tag or "latest")' + required: false + type: string + default: 'latest' + + # Also run on PRs in this repository (for dogfooding) + # Includes 'edited' and 'labeled' to update when PR title/description/labels change + pull_request: + types: [opened, synchronize, reopened, edited, labeled] + +permissions: + pull-requests: write + +jobs: + preview: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Install Craft using the shared install action + # TODO: Change to @v2 or @master after this PR is merged + - name: Install Craft + uses: getsentry/craft/install@pull/669/head + with: + craft-version: ${{ inputs.craft-version || 'latest' }} + + - name: Generate Changelog Preview + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CRAFT_LOG_LEVEL: Warn + run: | + PR_NUMBER="${{ github.event.pull_request.number }}" + + # Generate changelog with current PR injected and highlighted (JSON format) + echo "Running craft changelog --pr $PR_NUMBER --format json..." + RESULT=$(craft changelog --pr "$PR_NUMBER" --format json 2>/dev/null || echo '{"changelog":"","bumpType":null}') + + # Extract fields from JSON + CHANGELOG=$(echo "$RESULT" | jq -r '.changelog // ""') + BUMP_TYPE=$(echo "$RESULT" | jq -r '.bumpType // "none"') + + if [[ -z "$CHANGELOG" ]]; then + CHANGELOG="_No changelog entries will be generated from this PR._" + fi + + # Format bump type for display + 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 + + # Build comment body using a temp file (safer than heredoc) + COMMENT_FILE=$(mktemp) + cat > "$COMMENT_FILE" << CRAFT_CHANGELOG_COMMENT_END + + ## Suggested Version Bump + + ${BUMP_BADGE} + + ## 📋 Changelog Preview + + This is how your changes will appear in the changelog. + Entries from this PR are highlighted with a left border (blockquote style). + + --- + + ${CHANGELOG} + + --- + + 🤖 This preview updates automatically when you update the PR. + CRAFT_CHANGELOG_COMMENT_END + + # Find existing comment with our marker + COMMENT_ID=$(gh api \ + "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ + --jq '.[] | select(.body | contains("")) | .id' \ + | head -1) + + if [[ -n "$COMMENT_ID" ]]; then + echo "Updating existing comment $COMMENT_ID..." + gh api -X PATCH \ + "repos/$GITHUB_REPOSITORY/issues/comments/$COMMENT_ID" \ + -F body=@"$COMMENT_FILE" + else + echo "Creating new comment..." + gh api -X POST \ + "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ + -F body=@"$COMMENT_FILE" + fi + + rm -f "$COMMENT_FILE" diff --git a/README.md b/README.md index 8b5686c0a..7fc86b4e6 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ craft publish 1.2.3 - **Changelog Management** - Auto-generate changelogs from commits or validate manual entries - **Workspace Support** - Handle monorepos with NPM/Yarn workspaces - **CI Integration** - Wait for CI to pass, download artifacts, and publish +- **GitHub Actions** - Built-in actions for release preparation and changelog previews ## Configuration @@ -83,6 +84,78 @@ See the [configuration reference](https://getsentry.github.io/craft/configuratio See the [targets documentation](https://getsentry.github.io/craft/targets/) for configuration details. +## GitHub Actions + +Craft provides GitHub Actions for automating releases and previewing changelog entries. + +### Prepare Release Action + +Automates the `craft prepare` workflow in GitHub Actions: + +```yaml +name: Release +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (or "auto")' + required: false + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: getsentry/craft@v2 + with: + version: ${{ github.event.inputs.version }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +**Inputs:** + +| Input | Description | Default | +|-------|-------------|---------| +| `version` | Version to release (semver, "auto", "major", "minor", "patch") | Uses `versioning.policy` from config | +| `merge_target` | Target branch to merge into | Default branch | +| `force` | Force release even with blockers | `false` | +| `blocker_label` | Label that blocks releases | `release-blocker` | +| `publish_repo` | Repository for publish issues | `{owner}/publish` | + +**Outputs:** + +| Output | Description | +|--------|-------------| +| `version` | The resolved version being released | +| `branch` | The release branch name | +| `sha` | The commit SHA on the release branch | +| `changelog` | The changelog for this release | + +### Changelog Preview (Reusable Workflow) + +Posts a preview comment on PRs showing how they'll appear in the changelog: + +```yaml +name: Changelog Preview +on: + pull_request: + types: [opened, synchronize, reopened, edited, labeled] + +jobs: + changelog-preview: + uses: getsentry/craft/.github/workflows/changelog-preview.yml@v2 + secrets: inherit +``` + +The workflow will: +- Generate the upcoming changelog including the PR's changes +- Highlight entries from the PR using blockquote style (left border) +- Post a comment on the PR with the preview +- Automatically update when you update the PR (push, edit title/description, or change labels) + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines. diff --git a/action.yml b/action.yml index 31ebe3f00..e0aa73f7e 100644 --- a/action.yml +++ b/action.yml @@ -83,42 +83,8 @@ runs: echo "GIT_AUTHOR_NAME=${GIT_USER_NAME}" >> $GITHUB_ENV echo "EMAIL=${GIT_USER_EMAIL}" >> $GITHUB_ENV - - name: Download Craft from build artifact - id: artifact - if: github.repository == 'getsentry/craft' - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4 - continue-on-error: true - with: - name: ${{ github.sha }} - path: /tmp/craft-artifact - - - name: Install Craft from artifact - if: steps.artifact.outcome == 'success' - shell: bash - run: | - echo "Installing Craft from build artifact..." - sudo install -m 755 /tmp/craft-artifact/dist/craft /usr/local/bin/craft - - - name: Install Craft from release - if: steps.artifact.outcome != 'success' - shell: bash - run: | - # Try action ref first (e.g., v2, 2.15.0) - ACTION_REF="${{ github.action_ref }}" - CRAFT_URL="https://github.com/getsentry/craft/releases/download/${ACTION_REF}/craft" - - echo "Trying to download Craft from: ${CRAFT_URL}" - - # Fallback to latest if ref doesn't have a release - if ! curl -sfI "$CRAFT_URL" >/dev/null 2>&1; then - echo "Release not found for ref '${ACTION_REF}', falling back to latest..." - CRAFT_URL=$(curl -s "https://api.github.com/repos/getsentry/craft/releases/latest" \ - | jq -r '.assets[] | select(.name == "craft") | .browser_download_url') - fi - - echo "Installing Craft from: ${CRAFT_URL}" - sudo curl -sL -o /usr/local/bin/craft "$CRAFT_URL" - sudo chmod +x /usr/local/bin/craft + - name: Install Craft + uses: ./install - name: Craft Prepare id: craft diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index ee32859b0..9046114cb 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -23,6 +23,7 @@ export default defineConfig({ { label: 'Introduction', slug: '' }, { label: 'Installation', slug: 'getting-started' }, { label: 'Configuration', slug: 'configuration' }, + { label: 'GitHub Actions', slug: 'github-actions' }, ], }, { diff --git a/docs/src/content/docs/github-actions.md b/docs/src/content/docs/github-actions.md new file mode 100644 index 000000000..2cffed2b6 --- /dev/null +++ b/docs/src/content/docs/github-actions.md @@ -0,0 +1,250 @@ +--- +title: GitHub Actions +description: Automate releases and changelog previews with Craft GitHub Actions +--- + +Craft provides GitHub Actions for automating releases and previewing changelog entries in pull requests. + +For a real-world example of using Craft's GitHub Actions, see the [getsentry/publish](https://github.com/getsentry/publish) repository. + +## Prepare Release Action + +The main Craft action automates the `craft prepare` workflow in GitHub Actions. It creates a release branch, updates the changelog, and opens a publish request issue. + +### Basic Usage + +```yaml +name: Release +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (or "auto")' + required: false + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: getsentry/craft@v2 + with: + version: ${{ github.event.inputs.version }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +### Inputs + +| Input | Description | Default | +|-------|-------------|---------| +| `version` | Version to release. Can be a semver string (e.g., "1.2.3"), a bump type ("major", "minor", "patch"), or "auto" for automatic detection. | Uses `versioning.policy` from config | +| `merge_target` | Target branch to merge into. | Default branch | +| `force` | Force a release even when there are release-blockers. | `false` | +| `blocker_label` | Label that blocks releases. | `release-blocker` | +| `publish_repo` | Repository for publish issues (owner/repo format). | `{owner}/publish` | +| `git_user_name` | Git committer name. | GitHub actor | +| `git_user_email` | Git committer email. | Actor's noreply email | +| `path` | The path that Craft will run inside. | `.` | +| `craft_config_from_merge_target` | Use the craft config from the merge target branch. | `false` | + +### Outputs + +| Output | Description | +|--------|-------------| +| `version` | The resolved version being released | +| `branch` | The release branch name | +| `sha` | The commit SHA on the release branch | +| `previous_tag` | The tag before this release (for diff links) | +| `changelog` | The changelog for this release | + +### Auto-versioning Example + +When using auto-versioning, Craft analyzes conventional commits to determine the version bump: + +```yaml +name: Auto Release +on: + schedule: + - cron: '0 10 * * 1' # Every Monday at 10 AM + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: getsentry/craft@v2 + with: + version: auto + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +## Changelog Preview (Reusable Workflow) + +The changelog preview workflow posts a comment on pull requests showing how they will appear in the changelog. This helps contributors understand the impact of their changes. + +### Basic Usage + +Call the reusable workflow from your repository: + +```yaml +name: Changelog Preview +on: + pull_request: + types: [opened, synchronize, reopened, edited, labeled] + +jobs: + changelog-preview: + uses: getsentry/craft/.github/workflows/changelog-preview.yml@v2 + secrets: inherit +``` + +### Inputs + +| Input | Description | Default | +|-------|-------------|---------| +| `craft-version` | Version of Craft to use (tag or "latest") | `latest` | + +### Pinning a Specific Version + +```yaml +jobs: + changelog-preview: + uses: getsentry/craft/.github/workflows/changelog-preview.yml@v2 + with: + craft-version: "2.15.0" + secrets: inherit +``` + +### How It Works + +1. **Generates the changelog** - Runs `craft changelog --pr --format json` to generate the upcoming changelog with metadata +2. **Fetches PR info** - Gets PR title, body, labels, and base branch from GitHub API +3. **Categorizes the PR** - Matches the PR to changelog categories based on labels and commit patterns +4. **Suggests version bump** - Based on matched categories with semver fields (major/minor/patch) +5. **Highlights PR entries** - The current PR is rendered with blockquote style (displayed with a left border in GitHub) +6. **Posts a comment** - Creates or updates a comment on the PR with the changelog preview +7. **Auto-updates** - The comment is automatically updated when you update the PR (push commits, edit title/description, or change labels) + +### Example Comment + +The workflow posts a comment like this: + +```markdown +## Suggested Version Bump + +🟡 **Minor** (new features) + +## 📋 Changelog Preview + +This is how your changes will appear in the changelog. +Entries from this PR are highlighted with a left border (blockquote style). + +--- + +### New Features ✨ + +> - feat(api): Add new endpoint by @you in #123 + +- feat(core): Existing feature by @other in #100 + +### Bug Fixes 🐛 + +- fix(ui): Resolve crash by @other in #99 + +--- + +🤖 This preview updates automatically when you update the PR. +``` + +### PR Trigger Types + +The workflow supports these PR event types: +- `opened` - When a PR is created +- `synchronize` - When new commits are pushed +- `reopened` - When a closed PR is reopened +- `edited` - When the PR title or description is changed +- `labeled` - When labels are added or removed + +### Requirements + +- Use `secrets: inherit` to pass the GitHub token +- The repository should have a git history with tags for the changelog to be meaningful + +## Skipping Changelog Entries + +### Using Magic Words + +Use `#skip-changelog` in your commit message or PR body to exclude a commit from the changelog: + +``` +chore: Update dependencies + +#skip-changelog +``` + +### Using Labels + +You can configure labels to exclude PRs from the changelog. In your `.craft.yml`: + +```yaml +changelog: + categories: + - title: "New Features ✨" + labels: ["feature", "enhancement"] + - title: "Bug Fixes 🐛" + labels: ["bug", "fix"] + exclude: + labels: ["skip-changelog", "dependencies"] + authors: ["dependabot[bot]", "renovate[bot]"] +``` + +PRs with the `skip-changelog` label or from excluded authors will not appear in the changelog. + +## Tips + +### Combining Both Actions + +You can use both the changelog preview and release actions together for a complete release workflow. See the [getsentry/publish](https://github.com/getsentry/publish) repository for a real-world example. + +```yaml +# .github/workflows/changelog-preview.yml +name: Changelog Preview +on: + pull_request: + types: [opened, synchronize, reopened, edited, labeled] + +jobs: + changelog-preview: + uses: getsentry/craft/.github/workflows/changelog-preview.yml@v2 + secrets: inherit +``` + +```yaml +# .github/workflows/release.yml +name: Release +on: + workflow_dispatch: + inputs: + version: + description: 'Version (leave empty for auto)' + required: false + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: getsentry/craft@v2 + with: + version: ${{ github.event.inputs.version || 'auto' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` diff --git a/install/action.yml b/install/action.yml new file mode 100644 index 000000000..e84c4bfd4 --- /dev/null +++ b/install/action.yml @@ -0,0 +1,71 @@ +name: "Install Craft" +description: "Install Craft CLI (from build artifact for dogfooding, build from source, or from release)" + +inputs: + craft-version: + description: 'Version of Craft to install (tag or "latest"). Only used when installing from release.' + required: false + default: 'latest' + +runs: + using: "composite" + steps: + - name: Download Craft from build artifact + id: artifact + if: github.repository == 'getsentry/craft' + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4 + continue-on-error: true + with: + name: ${{ github.sha }} + path: /tmp/craft-artifact + + - name: Install Craft from artifact + if: steps.artifact.outcome == 'success' + shell: bash + run: | + echo "Installing Craft from build artifact..." + sudo install -m 755 /tmp/craft-artifact/dist/craft /usr/local/bin/craft + + # For getsentry/craft repo: build from source if no artifact available + - name: Setup Node.js (for building from source) + if: github.repository == 'getsentry/craft' && steps.artifact.outcome != 'success' + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Build Craft from source + id: build + if: github.repository == 'getsentry/craft' && steps.artifact.outcome != 'success' + shell: bash + run: | + echo "Building Craft from source..." + yarn install --frozen-lockfile + yarn build + sudo install -m 755 dist/craft /usr/local/bin/craft + + - name: Install Craft from release + if: steps.artifact.outcome != 'success' && steps.build.outcome != 'success' + shell: bash + env: + CRAFT_VERSION: ${{ inputs.craft-version }} + run: | + if [[ "$CRAFT_VERSION" == "latest" || -z "$CRAFT_VERSION" ]]; then + # Try action ref first (e.g., v2, 2.15.0) + ACTION_REF="${{ github.action_ref }}" + CRAFT_URL="https://github.com/getsentry/craft/releases/download/${ACTION_REF}/craft" + + echo "Trying to download Craft from: ${CRAFT_URL}" + + # Fallback to latest if ref doesn't have a release + if ! curl -sfI "$CRAFT_URL" >/dev/null 2>&1; then + echo "Release not found for ref '${ACTION_REF}', falling back to latest..." + CRAFT_URL=$(curl -s "https://api.github.com/repos/getsentry/craft/releases/latest" \ + | jq -r '.assets[] | select(.name == "craft") | .browser_download_url') + fi + else + CRAFT_URL="https://github.com/getsentry/craft/releases/download/${CRAFT_VERSION}/craft" + fi + + echo "Installing Craft from: ${CRAFT_URL}" + sudo curl -sL -o /usr/local/bin/craft "$CRAFT_URL" + sudo chmod +x /usr/local/bin/craft diff --git a/src/commands/changelog.ts b/src/commands/changelog.ts new file mode 100644 index 000000000..53cfed7b7 --- /dev/null +++ b/src/commands/changelog.ts @@ -0,0 +1,96 @@ +import { Argv, CommandBuilder } from 'yargs'; + +import { logger } from '../logger'; +import { getGitClient, getLatestTag } from '../utils/git'; +import { + generateChangesetFromGit, + generateChangelogWithHighlight, +} from '../utils/changelog'; +import { handleGlobalError } from '../utils/errors'; + +export const command = ['changelog']; +export const description = 'Generate changelog from git history'; + +/** Output format options */ +type OutputFormat = 'text' | 'json'; + +/** Command line options */ +interface ChangelogOptions { + /** Base revision to generate changelog from (defaults to latest tag) */ + since?: string; + /** PR number for the current (unmerged) PR */ + pr?: number; + /** Output format: text (default) or json */ + format?: OutputFormat; +} + +export const builder: CommandBuilder = (yargs: Argv) => + yargs + .option('since', { + alias: 's', + description: + 'Base revision (tag or SHA) to generate changelog from. Defaults to latest tag.', + type: 'string', + }) + .option('pr', { + description: + 'PR number for the current (unmerged) PR. The PR info will be fetched from GitHub API and the PR included in the changelog with highlighting.', + type: 'number', + }) + .option('format', { + alias: 'f', + description: 'Output format: text (default) or json', + type: 'string', + choices: ['text', 'json'] as const, + default: 'text', + }); + +/** + * 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) { + since = await getLatestTag(git); + if (since) { + logger.debug(`Using latest tag as base revision: ${since}`); + } else { + logger.debug('No tags found, generating changelog from beginning of history'); + } + } + + // Generate changelog - use different function depending on whether PR is specified + const result = argv.pr + ? await generateChangelogWithHighlight(git, since, argv.pr) + : await generateChangesetFromGit(git, since); + + // Output based on format + if (argv.format === 'json') { + const output = { + changelog: result.changelog || '', + bumpType: result.bumpType, + totalCommits: result.totalCommits, + matchedCommitsWithSemver: result.matchedCommitsWithSemver, + }; + console.log(JSON.stringify(output, null, 2)); + } else { + if (!result.changelog) { + console.log('No changelog entries found.'); + return; + } + console.log(result.changelog); + } +} + +export const handler = async (args: { + [argName: string]: any; +}): Promise => { + try { + return await changelogMain(args as ChangelogOptions); + } catch (e) { + handleGlobalError(e); + } +}; diff --git a/src/index.ts b/src/index.ts index bc3c25dd8..acb7efe40 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import * as publish from './commands/publish'; import * as targets from './commands/targets'; import * as config from './commands/config'; import * as artifacts from './commands/artifacts'; +import * as changelog from './commands/changelog'; function printVersion(): void { if (!process.argv.includes('-v') && !process.argv.includes('--version')) { @@ -84,6 +85,7 @@ async function main(): Promise { .command(targets) .command(config) .command(artifacts) + .command(changelog) .demandCommand() .version(getPackageVersion()) .alias('v', 'version') diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index 2d62bc047..55348cb32 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -13,6 +13,50 @@ import { getChangesSince } from './git'; import { getGitHubClient } from './githubApi'; import { getVersion } from './version'; +/** Information about the current (unmerged) PR to inject into changelog */ +export interface CurrentPRInfo { + number: number; + title: string; + body: string; + author: string; + labels: string[]; + /** Base branch ref (e.g., "master") for computing merge base */ + baseRef: string; +} + +/** + * Fetches PR details from GitHub API by PR number. + * + * @param prNumber The PR number to fetch + * @returns PR info + * @throws Error if PR cannot be fetched + */ +async function fetchPRInfo(prNumber: number): Promise { + const { repo, owner } = await getGlobalGitHubConfig(); + const github = getGitHubClient(); + + const { data: pr } = await github.pulls.get({ + owner, + repo, + pull_number: prNumber, + }); + + const { data: labels } = await github.issues.listLabelsOnIssue({ + owner, + repo, + issue_number: prNumber, + }); + + return { + number: prNumber, + title: pr.title, + body: pr.body ?? '', + author: pr.user?.login ?? '', + labels: labels.map(l => l.name), + baseRef: pr.base.ref, + }; +} + /** * Version bump types. */ @@ -272,6 +316,8 @@ interface PullRequest { hash: string; body: string; title: string; + /** Whether this entry should be highlighted in output */ + highlight?: boolean; } interface Commit { @@ -285,6 +331,25 @@ interface Commit { prBody?: string | null; labels: string[]; category: string | null; + /** Whether this entry should be highlighted in output */ + highlight?: boolean; +} + +/** + * Raw commit/PR info before categorization. + * This is the input to the categorization step. + */ +interface RawCommitInfo { + hash: string; + title: string; + body: string; + author?: string; + pr?: string; + prTitle?: string; + prBody?: string; + labels: string[]; + /** Whether this entry should be highlighted in output */ + highlight?: boolean; } /** @@ -632,11 +697,14 @@ interface ChangelogEntry { body?: string; /** Base URL for the repository, e.g. https://github.com/owner/repo */ repoUrl: string; + /** Whether this entry should be highlighted (rendered as blockquote) */ + highlight?: boolean; } /** * Formats a single changelog entry with consistent full markdown link format. * Format: `- Title by @author in [#123](pr-url)` or `- Title in [abcdef12](commit-url)` + * When highlight is true, the entry is prefixed with `> ` (blockquote). */ function formatChangelogEntry(entry: ChangelogEntry): string { let title = entry.title; @@ -679,6 +747,14 @@ function formatChangelogEntry(entry: ChangelogEntry): string { } } + // Apply blockquote highlighting if requested + if (entry.highlight) { + text = text + .split('\n') + .map(line => `> ${line}`) + .join('\n'); + } + return text; } @@ -697,6 +773,40 @@ export interface ChangelogResult { matchedCommitsWithSemver: number; } +/** + * Raw changelog data before serialization to markdown. + * This intermediate representation allows manipulation of entries + * before final formatting. + */ +export interface RawChangelogData { + /** Categories with their PR entries, keyed by category title */ + categories: Map; + /** Commits that didn't match any category */ + leftovers: Commit[]; + /** Release config for serialization */ + releaseConfig: NormalizedReleaseConfig | null; +} + +/** + * Statistics from changelog generation, used for auto-versioning. + */ +interface ChangelogStats { + /** The highest version bump type found */ + bumpType: BumpType | null; + /** Number of commits analyzed */ + totalCommits: number; + /** Number of commits that matched a category with a semver field */ + matchedCommitsWithSemver: number; +} + +/** + * Result from raw changelog generation, includes both data and stats. + */ +interface RawChangelogResult { + data: RawChangelogData; + stats: ChangelogStats; +} + // Memoization cache for generateChangesetFromGit // Caches promises to coalesce concurrent calls with the same arguments const changesetCache = new Map>(); @@ -733,15 +843,77 @@ export async function generateChangesetFromGit( return promise; } -async function generateChangesetFromGitImpl( +/** + * Generates a changelog preview for a PR, showing how it will appear in the changelog. + * This function: + * 1. Fetches PR info from GitHub API (including base branch) + * 2. Fetches all commit/PR info up to base branch + * 3. Adds the current PR to the list with highlight flag + * 4. Runs categorization on the combined list + * 5. Serializes to markdown + * + * @param git Local git client + * @param rev Base revision (tag or SHA) to generate changelog from + * @param currentPRNumber PR number to fetch from GitHub and include (highlighted) + * @returns The changelog result with formatted markdown + */ +export async function generateChangelogWithHighlight( git: SimpleGit, rev: string, - maxLeftovers: number + currentPRNumber: number ): Promise { - const rawConfig = readReleaseConfig(); - const releaseConfig = normalizeReleaseConfig(rawConfig); + // Step 1: Fetch PR info from GitHub + const prInfo = await fetchPRInfo(currentPRNumber); + + // Step 2: Fetch the base branch to get current state + await git.fetch('origin', prInfo.baseRef); + const baseRef = `origin/${prInfo.baseRef}`; + logger.debug(`Using PR base branch "${prInfo.baseRef}" for changelog`); + + // Step 3: Fetch raw commit info up to base branch + const rawCommits = await fetchRawCommitInfo(git, rev, baseRef); + + // Step 4: Add current PR to the list with highlight flag (at the beginning) + const currentPRCommit: RawCommitInfo = { + hash: '', + title: prInfo.title.trim(), + body: prInfo.body, + author: prInfo.author, + pr: String(prInfo.number), + prTitle: prInfo.title, + prBody: prInfo.body, + labels: prInfo.labels, + highlight: true, + }; + const allCommits = [currentPRCommit, ...rawCommits]; + + // Step 5: Run categorization on combined list + const { data: rawData, stats } = categorizeCommits(allCommits); + + // Step 6: Serialize to markdown + const changelog = await serializeChangelog(rawData, MAX_LEFTOVERS); + + return { + changelog, + ...stats, + }; +} - const gitCommits = (await getChangesSince(git, rev)).filter( +/** + * Fetches raw commit/PR info from git history and GitHub. + * This is the first step - just gathering data, no categorization. + * + * @param git Local git client + * @param rev Base revision (tag or SHA) to start from + * @param until Optional end revision (defaults to HEAD) + * @returns Array of raw commit info + */ +async function fetchRawCommitInfo( + git: SimpleGit, + rev: string, + until?: string +): Promise { + const gitCommits = (await getChangesSince(git, rev, until)).filter( ({ body }) => !body.includes(SKIP_CHANGELOG_MAGIC_WORD) ); @@ -749,39 +921,62 @@ async function generateChangesetFromGitImpl( gitCommits.map(({ hash }) => hash) ); - const categories = new Map(); - const commits: Record = {}; - const leftovers: Commit[] = []; - const missing: Commit[] = []; - - // Track bump type for auto-versioning (lower priority value = higher bump) - let bumpPriority: number | null = null; - let matchedCommitsWithSemver = 0; + const result: RawCommitInfo[] = []; for (const gitCommit of gitCommits) { - const hash = gitCommit.hash; + const githubCommit = githubCommits[gitCommit.hash]; - const githubCommit = githubCommits[hash]; + // Skip if PR body has skip marker if (githubCommit?.prBody?.includes(SKIP_CHANGELOG_MAGIC_WORD)) { continue; } - const labelsArray = githubCommit?.labels ?? []; - const labels = new Set(labelsArray); - const author = githubCommit?.author; + result.push({ + hash: gitCommit.hash, + title: gitCommit.title, + body: gitCommit.body, + author: githubCommit?.author, + pr: githubCommit?.pr ?? gitCommit.pr ?? undefined, + prTitle: githubCommit?.prTitle ?? undefined, + prBody: githubCommit?.prBody ?? undefined, + labels: githubCommit?.labels ?? [], + }); + } + + return result; +} - if (shouldExcludePR(labels, author, releaseConfig)) { +/** + * Categorizes raw commits into changelog structure. + * This is the second step - grouping by category and scope. + * + * @param rawCommits Array of raw commit info to categorize + * @returns Categorized changelog data and stats + */ +function categorizeCommits(rawCommits: RawCommitInfo[]): RawChangelogResult { + const rawConfig = readReleaseConfig(); + const releaseConfig = normalizeReleaseConfig(rawConfig); + + const categories = new Map(); + const leftovers: Commit[] = []; + const missing: RawCommitInfo[] = []; + + // Track bump type for auto-versioning (lower priority value = higher bump) + let bumpPriority: number | null = null; + let matchedCommitsWithSemver = 0; + + for (const raw of rawCommits) { + const labels = new Set(raw.labels); + + if (shouldExcludePR(labels, raw.author, releaseConfig)) { continue; } // Use PR title if available, otherwise use commit title for pattern matching - // Trim to handle any leading/trailing whitespace that could break pattern matching - const titleForMatching = ( - githubCommit?.prTitle ?? gitCommit.title - ).trim(); + const titleForMatching = (raw.prTitle ?? raw.title).trim(); const matchedCategory = matchCommitToCategory( labels, - author, + raw.author, titleForMatching, releaseConfig ); @@ -796,60 +991,54 @@ async function generateChangesetFromGitImpl( } } - const commit: Commit = { - author: author, - hash: hash, - title: gitCommit.title, - body: gitCommit.body, - hasPRinTitle: Boolean(gitCommit.pr), - // Use GitHub PR number, falling back to locally parsed PR from title - pr: githubCommit?.pr ?? gitCommit.pr ?? null, - prTitle: githubCommit?.prTitle ?? null, - prBody: githubCommit?.prBody ?? null, - labels: labelsArray, - category: categoryTitle, - }; - commits[hash] = commit; - - if (!githubCommit) { - missing.push(commit); + // Track commits not found on GitHub (for warning) + if (!raw.pr && raw.hash) { + missing.push(raw); } - if (!categoryTitle) { - leftovers.push(commit); + if (!categoryTitle || !raw.pr) { + // No category match or no PR - goes to leftovers + leftovers.push({ + author: raw.author, + hash: raw.hash, + title: raw.title, + body: raw.body, + hasPRinTitle: Boolean(raw.pr), + pr: raw.pr ?? null, + prTitle: raw.prTitle ?? null, + prBody: raw.prBody ?? null, + labels: raw.labels, + category: categoryTitle, + highlight: raw.highlight, + }); } else { - if (!commit.pr) { - leftovers.push(commit); - } else { - let category = categories.get(categoryTitle); - if (!category) { - category = { - title: categoryTitle, - scopeGroups: new Map(), - }; - categories.set(categoryTitle, category); - } - - // Extract and normalize scope from PR title - // Trim to handle any leading/trailing whitespace - const prTitle = (commit.prTitle ?? commit.title).trim(); - const scope = extractScope(prTitle); + // Has category and PR - add to category + let category = categories.get(categoryTitle); + if (!category) { + category = { + title: categoryTitle, + scopeGroups: new Map(), + }; + categories.set(categoryTitle, category); + } - // Get or create the scope group - let scopeGroup = category.scopeGroups.get(scope); - if (!scopeGroup) { - scopeGroup = []; - category.scopeGroups.set(scope, scopeGroup); - } + const prTitle = (raw.prTitle ?? raw.title).trim(); + const scope = extractScope(prTitle); - scopeGroup.push({ - author: commit.author, - number: commit.pr, - hash: commit.hash, - body: commit.prBody ?? '', - title: prTitle, - }); + let scopeGroup = category.scopeGroups.get(scope); + if (!scopeGroup) { + scopeGroup = []; + category.scopeGroups.set(scope, scopeGroup); } + + scopeGroup.push({ + author: raw.author, + number: raw.pr, + hash: raw.hash, + body: raw.prBody ?? '', + title: prTitle, + highlight: raw.highlight, + }); } } @@ -867,11 +1056,57 @@ async function generateChangesetFromGitImpl( if (missing.length > 0) { logger.warn( 'The following commits were not found on GitHub:', - missing.map(commit => `${commit.hash.slice(0, 8)} ${commit.title}`) + missing.map(c => `${c.hash.slice(0, 8)} ${c.title}`) ); } - const changelogSections = []; + return { + data: { + categories, + leftovers, + releaseConfig, + }, + stats: { + bumpType, + totalCommits: rawCommits.length, + matchedCommitsWithSemver, + }, + }; +} + +/** + * Generates raw changelog data from git history. + * Convenience function that fetches commits and categorizes them. + * + * @param git Local git client + * @param rev Base revision (tag or SHA) to generate changelog from + * @param until Optional end revision (defaults to HEAD) + * @returns Raw changelog data structure + */ +async function generateRawChangelog( + git: SimpleGit, + rev: string, + until?: string +): Promise { + const rawCommits = await fetchRawCommitInfo(git, rev, until); + return categorizeCommits(rawCommits); +} + +/** + * Serializes raw changelog data to markdown format. + * Entries with `highlight: true` are rendered as blockquotes. + * + * @param rawData The raw changelog data to serialize + * @param maxLeftovers Maximum number of leftover entries to include + * @returns Formatted markdown changelog string + */ +async function serializeChangelog( + rawData: RawChangelogData, + maxLeftovers: number +): Promise { + const { categories, leftovers, releaseConfig } = rawData; + + const changelogSections: string[] = []; const { repo, owner } = await getGlobalGitHubConfig(); const repoUrl = `https://github.com/${owner}/${repo}`; @@ -886,7 +1121,7 @@ async function generateChangesetFromGitImpl( // Sort categories by the order defined in release config const categoryOrder = - releaseConfig?.changelog.categories.map(c => c.title) ?? []; + releaseConfig?.changelog?.categories?.map(c => c.title) ?? []; const sortedCategories = [...categories.entries()].sort((a, b) => { const aIndex = categoryOrder.indexOf(a[1].title); const bIndex = categoryOrder.indexOf(b[1].title); @@ -935,6 +1170,7 @@ async function generateChangesetFromGitImpl( hash: pr.hash, body: pr.body, repoUrl, + highlight: pr.highlight, }) ); @@ -988,6 +1224,7 @@ async function generateChangesetFromGitImpl( : commit.body.includes(BODY_IN_CHANGELOG_MAGIC_WORD) ? commit.body : undefined, + highlight: commit.highlight, }) ) .join('\n') @@ -997,11 +1234,24 @@ async function generateChangesetFromGitImpl( } } + return changelogSections.join('\n\n'); +} + +/** + * Implementation of changelog generation that uses the new architecture. + * Generates raw data, then serializes to markdown. + */ +async function generateChangesetFromGitImpl( + git: SimpleGit, + rev: string, + maxLeftovers: number +): Promise { + const { data: rawData, stats } = await generateRawChangelog(git, rev); + const changelog = await serializeChangelog(rawData, maxLeftovers); + return { - changelog: changelogSections.join('\n\n'), - bumpType, - totalCommits: gitCommits.length, - matchedCommitsWithSemver, + changelog, + ...stats, }; } diff --git a/src/utils/git.ts b/src/utils/git.ts index 35ff34707..e74273dc2 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -55,10 +55,11 @@ export async function getLatestTag(git: SimpleGit): Promise { export async function getChangesSince( git: SimpleGit, - rev: string + rev: string, + until?: string ): Promise { const gitLogArgs: Options | LogOptions = { - to: 'HEAD', + to: until || 'HEAD', // The symmetric option defaults to true, giving us all the different commits // reachable from both `from` and `to` whereas what we are interested in is only the ones // reachable from `to` and _not_ from `from` so we get a "changelog" kind of list.