Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/plenty-donuts-repeat.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions pr-status/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 6 additions & 2 deletions src/pr-status/index.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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!");
}
17 changes: 11 additions & 6 deletions src/pr-status/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
VersionType,
} from "@changesets/types";
import { markdownTable } from "markdown-table";
import { summarizeReleasePlan } from "./summary.ts";
import {
getNewChangesetTemplateContent,
getNewChangesetUrl,
Expand All @@ -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);
Expand All @@ -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(
Expand Down
271 changes: 271 additions & 0 deletions src/pr-status/summary.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
31 changes: 31 additions & 0 deletions src/pr-status/summary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { ReleasePlan, VersionType } from "@changesets/types";

type PublishableRelease = {
name: string;
type: Exclude<VersionType, "none">;
oldVersion: string;
newVersion: string;
changesets: string[];
};

const bumpOrder = ["major", "minor", "patch"] as const;

export function summarizeReleasePlan(releasePlan: ReleasePlan) {
const releases = releasePlan.releases.flatMap<PublishableRelease>(
({ 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,
};
}