diff --git a/.changeset/plenty-donuts-repeat.md b/.changeset/plenty-donuts-repeat.md new file mode 100644 index 00000000..73142ae8 --- /dev/null +++ b/.changeset/plenty-donuts-repeat.md @@ -0,0 +1,5 @@ +--- +"@changesets/action": minor +--- + +Add `has-changesets`, `max-bump`, and `releases` outputs to the `pr-status` sub-action, exposing the release plan as structured data. diff --git a/pr-status/action.yml b/pr-status/action.yml index c7dcf75e..0179c985 100644 --- a/pr-status/action.yml +++ b/pr-status/action.yml @@ -7,6 +7,14 @@ inputs: {} outputs: comment-body: description: The generated comment body to present the changesets status in PRs. + has-changesets: + description: A boolean about whether the pull request contains changesets. + max-bump: + description: > + The highest semver bump across the released packages. One of `major`, `minor`, `patch`, or `none`. + releases: + description: > + A JSON array to present the packages that will be released, each with the ids of the changesets that bumped it. The format is `[{"name": "@xx/xx", "type": "minor", "oldVersion": "1.1.0", "newVersion": "1.2.0", "changesets": ["tidy-pandas-shake"]}]` branding: icon: package color: blue diff --git a/src/pr-status/index.ts b/src/pr-status/index.ts index e1c779a7..166a63d7 100644 --- a/src/pr-status/index.ts +++ b/src/pr-status/index.ts @@ -1,6 +1,6 @@ import * as core from "@actions/core"; import * as github from "@actions/github"; -import { getCommentMessage } from "./message.ts"; +import { getPullRequestStatus } from "./message.ts"; try { await main(); @@ -17,7 +17,11 @@ async function main() { } core.info("Creating comment message..."); - const commentBody = await getCommentMessage(context); + const { commentBody, hasChangesets, maxBump, releases } = + await getPullRequestStatus(context); core.setOutput("comment-body", commentBody); + core.setOutput("has-changesets", String(hasChangesets)); + core.setOutput("max-bump", maxBump); + core.setOutput("releases", JSON.stringify(releases)); core.info("Done!"); } diff --git a/src/pr-status/message.ts b/src/pr-status/message.ts index 6e01b581..fe097bc2 100644 --- a/src/pr-status/message.ts +++ b/src/pr-status/message.ts @@ -6,6 +6,7 @@ import type { VersionType, } from "@changesets/types"; import { markdownTable } from "markdown-table"; +import { summarizeReleasePlan } from "./summary.ts"; import { getNewChangesetTemplateContent, getNewChangesetUrl, @@ -16,7 +17,7 @@ type PullRequestContext = NonNullable< typeof github.context.payload.pull_request >; -export async function getCommentMessage(context: PullRequestContext) { +export async function getPullRequestStatus(context: PullRequestContext) { await using worktree = await getPullRequestWorktree(context); const releasePlan = await getReleasePlan(worktree.cwd, worktree.baseRef); @@ -32,11 +33,15 @@ export async function getCommentMessage(context: PullRequestContext) { templateContent, ); - if (releasePlan.changesets.length > 0) { - return getApproveMessage(context.head.sha, newChangesetUrl, releasePlan); - } else { - return getAbsentMessage(context.head.sha, newChangesetUrl, releasePlan); - } + const commentBody = + releasePlan.changesets.length > 0 + ? getApproveMessage(context.head.sha, newChangesetUrl, releasePlan) + : getAbsentMessage(context.head.sha, newChangesetUrl, releasePlan); + + const { hasChangesets, maxBump, releases } = + summarizeReleasePlan(releasePlan); + + return { commentBody, hasChangesets, maxBump, releases }; } function getApproveMessage( diff --git a/src/pr-status/summary.test.ts b/src/pr-status/summary.test.ts new file mode 100644 index 00000000..46c4a0aa --- /dev/null +++ b/src/pr-status/summary.test.ts @@ -0,0 +1,271 @@ +import type { + ComprehensiveRelease, + NewChangeset, + PreState, + ReleasePlan, +} from "@changesets/types"; +import { describe, expect, it } from "vitest"; +import { summarizeReleasePlan } from "./summary.ts"; + +function release( + name: string, + type: ComprehensiveRelease["type"], + oldVersion: string, + newVersion: string, + changesets: string[] = [], +): ComprehensiveRelease { + return { name, type, oldVersion, newVersion, changesets }; +} + +function changeset( + id: string, + releases: NewChangeset["releases"] = [], +): NewChangeset { + return { id, summary: "", releases }; +} + +function releasePlan( + releases: ComprehensiveRelease[], + changesets: NewChangeset[] = [], + preState?: PreState, +): ReleasePlan { + return { changesets, releases, preState }; +} + +describe("summarizeReleasePlan", () => { + it("reports the highest bump across the released packages", () => { + const summary = summarizeReleasePlan( + releasePlan([ + release("pkg-a", "patch", "1.0.0", "1.0.1"), + release("pkg-b", "major", "2.3.0", "3.0.0"), + release("pkg-c", "minor", "0.4.0", "0.5.0"), + ]), + ); + + expect(summary.maxBump).toBe("major"); + }); + + it("reports minor when no package is released as major", () => { + const summary = summarizeReleasePlan( + releasePlan([ + release("pkg-a", "patch", "1.0.0", "1.0.1"), + release("pkg-b", "minor", "2.3.0", "2.4.0"), + ]), + ); + + expect(summary.maxBump).toBe("minor"); + }); + + it("carries the name, type and versions of each released package", () => { + const summary = summarizeReleasePlan( + releasePlan([release("pkg-a", "minor", "1.1.0", "1.2.0")]), + ); + + expect(summary.releases).toEqual([ + { + name: "pkg-a", + type: "minor", + oldVersion: "1.1.0", + newVersion: "1.2.0", + changesets: [], + }, + ]); + }); + + it("carries the ids of the changesets that bumped each package", () => { + const summary = summarizeReleasePlan( + releasePlan( + [ + release("pkg-a", "major", "2.3.0", "3.0.0", [ + "tidy-pandas-shake", + "lucky-moons-wave", + ]), + release("pkg-b", "patch", "1.0.0", "1.0.1", ["brave-otters-sing"]), + ], + [ + changeset("tidy-pandas-shake", [{ name: "pkg-a", type: "major" }]), + changeset("lucky-moons-wave", [{ name: "pkg-a", type: "patch" }]), + changeset("brave-otters-sing", [{ name: "pkg-b", type: "patch" }]), + ], + ), + ); + + expect(summary.releases).toEqual([ + { + name: "pkg-a", + type: "major", + oldVersion: "2.3.0", + newVersion: "3.0.0", + changesets: ["tidy-pandas-shake", "lucky-moons-wave"], + }, + { + name: "pkg-b", + type: "patch", + oldVersion: "1.0.0", + newVersion: "1.0.1", + changesets: ["brave-otters-sing"], + }, + ]); + }); + + it("summarizes several changesets spanning several packages", () => { + const summary = summarizeReleasePlan( + releasePlan( + [ + release("pkg-a", "major", "2.3.0", "3.0.0", [ + "tidy-pandas-shake", + "lucky-moons-wave", + ]), + release("pkg-b", "minor", "1.4.0", "1.5.0", [ + "tidy-pandas-shake", + "brave-otters-sing", + ]), + release("pkg-c", "patch", "0.2.1", "0.2.2", ["brave-otters-sing"]), + release("pkg-d", "none", "5.0.0", "5.0.0"), + ], + [ + changeset("tidy-pandas-shake", [ + { name: "pkg-a", type: "major" }, + { name: "pkg-b", type: "minor" }, + ]), + changeset("brave-otters-sing", [ + { name: "pkg-b", type: "patch" }, + { name: "pkg-c", type: "patch" }, + ]), + changeset("lucky-moons-wave", [{ name: "pkg-a", type: "patch" }]), + ], + ), + ); + + expect(summary.hasChangesets).toBe(true); + expect(summary.maxBump).toBe("major"); + expect(summary.releases).toEqual([ + { + name: "pkg-a", + type: "major", + oldVersion: "2.3.0", + newVersion: "3.0.0", + changesets: ["tidy-pandas-shake", "lucky-moons-wave"], + }, + { + name: "pkg-b", + type: "minor", + oldVersion: "1.4.0", + newVersion: "1.5.0", + changesets: ["tidy-pandas-shake", "brave-otters-sing"], + }, + { + name: "pkg-c", + type: "patch", + oldVersion: "0.2.1", + newVersion: "0.2.2", + changesets: ["brave-otters-sing"], + }, + ]); + }); + + it("carries prerelease versions", () => { + const summary = summarizeReleasePlan( + releasePlan( + [ + release("pkg-a", "minor", "1.1.0-next.3", "1.2.0-next.0", [ + "tidy-pandas-shake", + ]), + release("pkg-b", "patch", "0.5.2", "0.5.3-next.0", [ + "brave-otters-sing", + ]), + ], + [ + changeset("tidy-pandas-shake", [{ name: "pkg-a", type: "minor" }]), + changeset("brave-otters-sing", [{ name: "pkg-b", type: "patch" }]), + ], + { mode: "pre", tag: "next", changesets: ["earlier-frogs-jump"] }, + ), + ); + + expect(summary.maxBump).toBe("minor"); + expect(summary.releases).toEqual([ + { + name: "pkg-a", + type: "minor", + oldVersion: "1.1.0-next.3", + newVersion: "1.2.0-next.0", + changesets: ["tidy-pandas-shake"], + }, + { + name: "pkg-b", + type: "patch", + oldVersion: "0.5.2", + newVersion: "0.5.3-next.0", + changesets: ["brave-otters-sing"], + }, + ]); + }); + + it("omits packages that are not released", () => { + const summary = summarizeReleasePlan( + releasePlan([ + release("pkg-a", "none", "1.0.0", "1.0.0"), + release("pkg-b", "patch", "2.0.0", "2.0.1"), + ]), + ); + + expect(summary.maxBump).toBe("patch"); + expect(summary.releases).toEqual([ + { + name: "pkg-b", + type: "patch", + oldVersion: "2.0.0", + newVersion: "2.0.1", + changesets: [], + }, + ]); + }); + + it("reports none when there is nothing to release", () => { + const summary = summarizeReleasePlan(releasePlan([])); + + expect(summary.maxBump).toBe("none"); + expect(summary.releases).toEqual([]); + }); + + it("reports none when every package is unreleased", () => { + const summary = summarizeReleasePlan( + releasePlan([release("pkg-a", "none", "1.0.0", "1.0.0")]), + ); + + expect(summary.maxBump).toBe("none"); + expect(summary.releases).toEqual([]); + }); + + it("reports no changesets when the pull request has none", () => { + const summary = summarizeReleasePlan( + releasePlan([release("pkg-a", "patch", "1.0.0", "1.0.1")]), + ); + + expect(summary.hasChangesets).toBe(false); + }); + + it("reports changesets when the pull request has them", () => { + const summary = summarizeReleasePlan( + releasePlan( + [release("pkg-a", "patch", "1.0.0", "1.0.1")], + [changeset("tidy-pandas-shake", [{ name: "pkg-a", type: "patch" }])], + ), + ); + + expect(summary.hasChangesets).toBe(true); + }); + + it("separates an empty changeset from a missing one", () => { + const empty = summarizeReleasePlan( + releasePlan([], [changeset("lucky-moons-wave")]), + ); + const missing = summarizeReleasePlan(releasePlan([])); + + expect(empty.hasChangesets).toBe(true); + expect(missing.hasChangesets).toBe(false); + expect(empty.maxBump).toBe(missing.maxBump); + expect(empty.releases).toEqual(missing.releases); + }); +}); diff --git a/src/pr-status/summary.ts b/src/pr-status/summary.ts new file mode 100644 index 00000000..dba86ffa --- /dev/null +++ b/src/pr-status/summary.ts @@ -0,0 +1,31 @@ +import type { ReleasePlan, VersionType } from "@changesets/types"; + +type PublishableRelease = { + name: string; + type: Exclude; + oldVersion: string; + newVersion: string; + changesets: string[]; +}; + +const bumpOrder = ["major", "minor", "patch"] as const; + +export function summarizeReleasePlan(releasePlan: ReleasePlan) { + const releases = releasePlan.releases.flatMap( + ({ name, type, oldVersion, newVersion, changesets }) => + type === "none" + ? [] + : [{ name, type, oldVersion, newVersion, changesets }], + ); + + const maxBump = + bumpOrder.find((bump) => + releases.some((release) => release.type === bump), + ) ?? "none"; + + return { + hasChangesets: releasePlan.changesets.length > 0, + maxBump, + releases, + }; +}