mirror of
https://github.com/changesets/action
synced 2026-10-26 16:39:56 +08:00
Comment changeset status in PRs (#625)
* Initial implementation * Fix * fix * fix * Make it look nicer * Update docs * Rename to pr-status-comment * Remove outputs * Update path * update name * Checkout PR in a detached worktree (#626) Co-authored-by: bluwy <bjornlu.dev@gmail.com> * Move comment to separate job * Loosen event requirement * Update * Apply suggestions from code review Co-authored-by: Mateusz Burzyński <mateuszburzynski@gmail.com> * Use TLA * use using * Remove test log --------- Co-authored-by: Mateusz Burzyński <mateuszburzynski@gmail.com>
This commit is contained in:
co-authored by
Mateusz Burzyński
parent
34f64f6e2e
commit
8795eee5ee
@@ -0,0 +1,23 @@
|
||||
import * as core from "@actions/core";
|
||||
import * as github from "@actions/github";
|
||||
import { getCommentMessage } from "./message.ts";
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (err) {
|
||||
core.setFailed((err as Error).message);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const context = github.context.payload.pull_request;
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"This action should only be run on `pull_request_target` or `pull_request` events",
|
||||
);
|
||||
}
|
||||
|
||||
core.info("Creating comment message...");
|
||||
const commentBody = await getCommentMessage(context);
|
||||
core.setOutput("comment-body", commentBody);
|
||||
core.info("Done!");
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import * as github from "@actions/github";
|
||||
import getReleasePlan from "@changesets/get-release-plan";
|
||||
import type {
|
||||
ComprehensiveRelease,
|
||||
ReleasePlan,
|
||||
VersionType,
|
||||
} from "@changesets/types";
|
||||
import { markdownTable } from "markdown-table";
|
||||
import {
|
||||
getNewChangesetTemplateContent,
|
||||
getNewChangesetUrl,
|
||||
} from "./template.ts";
|
||||
import { getPullRequestWorktree } from "./worktree.ts";
|
||||
|
||||
type PullRequestContext = NonNullable<
|
||||
typeof github.context.payload.pull_request
|
||||
>;
|
||||
|
||||
export async function getCommentMessage(context: PullRequestContext) {
|
||||
await using worktree = await getPullRequestWorktree(context);
|
||||
|
||||
const releasePlan = await getReleasePlan(worktree.cwd, worktree.baseRef);
|
||||
const templateContent = await getNewChangesetTemplateContent(
|
||||
worktree.cwd,
|
||||
worktree.baseRef,
|
||||
context.title,
|
||||
);
|
||||
|
||||
const newChangesetUrl = getNewChangesetUrl(
|
||||
context.head.repo.html_url,
|
||||
context.head.ref,
|
||||
templateContent,
|
||||
);
|
||||
|
||||
if (releasePlan.changesets.length > 0) {
|
||||
return getApproveMessage(context.head.sha, newChangesetUrl, releasePlan);
|
||||
} else {
|
||||
return getAbsentMessage(context.head.sha, newChangesetUrl, releasePlan);
|
||||
}
|
||||
}
|
||||
|
||||
function getApproveMessage(
|
||||
commitSha: string,
|
||||
newChangesetUrl: string,
|
||||
releasePlan: ReleasePlan,
|
||||
) {
|
||||
return `\
|
||||
### 🦋 Changeset detected
|
||||
|
||||
Latest commit: ${commitSha}
|
||||
|
||||
**The changes in this PR will be included in the next version bump.**
|
||||
|
||||
${getReleasePlanMessage(releasePlan)}
|
||||
|
||||
Not sure what this means? [Click here to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).
|
||||
|
||||
[Click here if you're a maintainer who wants to add another changeset to this PR](${newChangesetUrl})`;
|
||||
}
|
||||
|
||||
function getAbsentMessage(
|
||||
commitSha: string,
|
||||
newChangesetUrl: string,
|
||||
releasePlan: ReleasePlan,
|
||||
) {
|
||||
return `\
|
||||
### ⚠️ No Changeset found
|
||||
|
||||
Latest commit: ${commitSha}
|
||||
|
||||
Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**
|
||||
|
||||
${getReleasePlanMessage(releasePlan)}
|
||||
|
||||
[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).
|
||||
|
||||
[Click here if you're a maintainer who wants to add a changeset to this PR](${newChangesetUrl})`;
|
||||
}
|
||||
|
||||
function getReleasePlanMessage(releasePlan: ReleasePlan) {
|
||||
const publishableReleases = releasePlan.releases.filter(
|
||||
(r) => r.type !== "none",
|
||||
) as (ComprehensiveRelease & { type: Exclude<VersionType, "none"> })[];
|
||||
|
||||
const table = markdownTable([
|
||||
["Name", "Type"],
|
||||
...publishableReleases.map((release) => {
|
||||
return [
|
||||
release.name,
|
||||
{
|
||||
major: "Major",
|
||||
minor: "Minor",
|
||||
patch: "Patch",
|
||||
}[release.type],
|
||||
];
|
||||
}),
|
||||
]);
|
||||
|
||||
let summary = "This PR includes ";
|
||||
if (releasePlan.changesets.length === 0) {
|
||||
summary += "no changesets";
|
||||
} else {
|
||||
summary += `changesets to release ${publishableReleases.length} package`;
|
||||
if (publishableReleases.length !== 1) {
|
||||
summary += "s";
|
||||
}
|
||||
}
|
||||
|
||||
return `\
|
||||
<details>
|
||||
<summary>${summary}</summary>
|
||||
|
||||
${
|
||||
publishableReleases.length > 0
|
||||
? table
|
||||
: "When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types"
|
||||
}
|
||||
|
||||
</details>`;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { getChangedPackagesSinceRef } from "@changesets/git";
|
||||
import { humanId } from "human-id";
|
||||
|
||||
export function getNewChangesetUrl(
|
||||
headRepoUrl: string,
|
||||
headRef: string,
|
||||
templateContent: string,
|
||||
) {
|
||||
const fileName = humanId({ separator: "-", capitalize: false });
|
||||
return `${headRepoUrl}/new/${headRef}?filename=.changeset/${fileName}.md&value=${encodeURIComponent(templateContent)}`;
|
||||
}
|
||||
|
||||
export async function getNewChangesetTemplateContent(
|
||||
cwd: string,
|
||||
baseRef: string,
|
||||
prTitle: string,
|
||||
) {
|
||||
const changedPackages = await getChangedPackagesSinceRef({
|
||||
cwd,
|
||||
ref: baseRef,
|
||||
});
|
||||
|
||||
return `\
|
||||
---
|
||||
${changedPackages.map((p) => `"${p.packageJson.name}": patch`).join("\n")}
|
||||
---
|
||||
|
||||
${prTitle}
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type * as github from "@actions/github";
|
||||
import getReleasePlan from "@changesets/get-release-plan";
|
||||
import { createFixture } from "fs-fixture";
|
||||
import { exec } from "tinyexec";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getPullRequestWorktree } from "./worktree.ts";
|
||||
|
||||
type PullRequestContext = NonNullable<
|
||||
typeof github.context.payload.pull_request
|
||||
>;
|
||||
|
||||
async function git(cwd: string, args: string[]) {
|
||||
const output = await exec("git", args, {
|
||||
nodeOptions: { cwd },
|
||||
throwOnError: true,
|
||||
});
|
||||
return output.stdout.trim();
|
||||
}
|
||||
|
||||
describe("getPullRequestWorktree", () => {
|
||||
it("fetches a PR branch into a detached worktree and keeps the main checkout untouched", async () => {
|
||||
// Local source repo
|
||||
await using sourceRepoFixture = await createFixture({
|
||||
".changeset/config.json": JSON.stringify({}),
|
||||
"package.json": JSON.stringify({
|
||||
name: "repo",
|
||||
private: true,
|
||||
workspaces: ["packages/*"],
|
||||
}),
|
||||
"packages/pkg-a/package.json": JSON.stringify({
|
||||
name: "pkg-a",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
});
|
||||
const sourceRepo = sourceRepoFixture.path;
|
||||
await git(sourceRepo, ["init", "-b", "main"]);
|
||||
await git(sourceRepo, ["config", "user.name", "Test User"]);
|
||||
await git(sourceRepo, ["config", "user.email", "test@example.com"]);
|
||||
await git(sourceRepo, ["add", "."]);
|
||||
await git(sourceRepo, ["commit", "-m", "base"]);
|
||||
|
||||
// Simulate remote bare git server
|
||||
await using originBareFixture = await createFixture();
|
||||
const originBare = originBareFixture.path;
|
||||
await git(originBare, ["clone", "--bare", sourceRepo, originBare]);
|
||||
|
||||
// Simulate checkout PR in github action
|
||||
await using checkoutRepoFixture = await createFixture();
|
||||
const checkoutRepo = checkoutRepoFixture.path;
|
||||
await git(checkoutRepo, [
|
||||
"clone",
|
||||
"--depth",
|
||||
"1",
|
||||
"--branch",
|
||||
"main",
|
||||
pathToFileURL(originBare).toString(),
|
||||
checkoutRepo,
|
||||
]);
|
||||
|
||||
// Simulate remote fork bare git server
|
||||
await using forkBareFixture = await createFixture();
|
||||
const forkBare = forkBareFixture.path;
|
||||
await git(forkBare, ["clone", "--bare", originBare, forkBare]);
|
||||
|
||||
await using forkRepoFixture = await createFixture();
|
||||
const forkRepo = forkRepoFixture.path;
|
||||
await git(forkRepo, [
|
||||
"clone",
|
||||
"--depth",
|
||||
"1",
|
||||
"--branch",
|
||||
"main",
|
||||
pathToFileURL(forkBare).toString(),
|
||||
forkRepo,
|
||||
]);
|
||||
await git(forkRepo, ["config", "user.name", "Test User"]);
|
||||
await git(forkRepo, ["config", "user.email", "test@example.com"]);
|
||||
await git(forkRepo, ["checkout", "-b", "feature"]);
|
||||
|
||||
await forkRepoFixture.mkdir("packages/pkg-a/src");
|
||||
await forkRepoFixture.writeFile(
|
||||
"packages/pkg-a/src/index.ts",
|
||||
"export const value = 1;\n",
|
||||
);
|
||||
await forkRepoFixture.writeFile(
|
||||
".changeset/add-pkg-a.md",
|
||||
`\
|
||||
---
|
||||
"pkg-a": patch
|
||||
---
|
||||
|
||||
Add pkg-a
|
||||
`,
|
||||
);
|
||||
|
||||
await git(forkRepo, ["add", "."]);
|
||||
await git(forkRepo, ["commit", "-m", "feature"]);
|
||||
await git(forkRepo, ["push", "origin", "feature"]);
|
||||
|
||||
// Run tests
|
||||
const originalHead = await git(checkoutRepo, ["rev-parse", "HEAD"]);
|
||||
const context = {
|
||||
number: 123,
|
||||
base: {
|
||||
ref: "main",
|
||||
repo: {
|
||||
clone_url: pathToFileURL(originBare).toString(),
|
||||
},
|
||||
},
|
||||
head: {
|
||||
ref: "feature",
|
||||
repo: {
|
||||
clone_url: pathToFileURL(forkBare).toString(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await using worktree = await getPullRequestWorktree(
|
||||
context satisfies PullRequestContext as PullRequestContext,
|
||||
checkoutRepo,
|
||||
);
|
||||
|
||||
const releasePlan = await getReleasePlan(worktree.cwd, worktree.baseRef);
|
||||
|
||||
const currentHead = await git(worktree.cwd, ["rev-parse", "HEAD"]);
|
||||
expect(currentHead).not.toBe(originalHead);
|
||||
|
||||
const currentBranch = await git(worktree.cwd, ["branch", "--show-current"]);
|
||||
expect(currentBranch).toBe("");
|
||||
|
||||
const releases = releasePlan.releases.map((release) => ({
|
||||
name: release.name,
|
||||
type: release.type,
|
||||
newVersion: release.newVersion,
|
||||
}));
|
||||
expect(releases).toEqual([
|
||||
{
|
||||
name: "pkg-a",
|
||||
type: "patch",
|
||||
newVersion: "1.0.1",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(await git(checkoutRepo, ["rev-parse", "HEAD"])).toBe(originalHead);
|
||||
expect(await git(checkoutRepo, ["branch", "--show-current"])).toBe("main");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type * as github from "@actions/github";
|
||||
import { isRepoShallow } from "@changesets/git";
|
||||
import { exec } from "tinyexec";
|
||||
|
||||
type PullRequestContext = NonNullable<
|
||||
typeof github.context.payload.pull_request
|
||||
>;
|
||||
|
||||
type WorktreeInfo = {
|
||||
baseRef: string;
|
||||
cwd: string;
|
||||
};
|
||||
|
||||
type TinyexecOptions = Parameters<typeof exec>[2];
|
||||
|
||||
function git(cwd: string, args: string[], opts: TinyexecOptions = {}) {
|
||||
return exec("git", args, {
|
||||
nodeOptions: { cwd, ...opts.nodeOptions },
|
||||
throwOnError: true,
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
interface Ref {
|
||||
fetchSource: string;
|
||||
local: string;
|
||||
remote: string;
|
||||
}
|
||||
|
||||
function getRefs(context: PullRequestContext): Record<"base" | "head", Ref> {
|
||||
const suffix = `${context.number}-${randomUUID()}`;
|
||||
return {
|
||||
base: {
|
||||
fetchSource: "origin",
|
||||
local: `refs/changesets-action-pr-status/base/${suffix}`,
|
||||
remote: `refs/heads/${context.base.ref}`,
|
||||
},
|
||||
head: {
|
||||
fetchSource: context.head.repo.clone_url,
|
||||
local: `refs/changesets-action-pr-status/head/${suffix}`,
|
||||
remote: `refs/heads/${context.head.ref}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function deepenRef(cwd: string, ref: Ref, deepenBy: number) {
|
||||
await git(cwd, [
|
||||
"fetch",
|
||||
"--no-tags",
|
||||
`--deepen=${deepenBy}`,
|
||||
ref.fetchSource,
|
||||
`${ref.remote}:${ref.local}`,
|
||||
]);
|
||||
}
|
||||
|
||||
async function ensureMergeBase(args: {
|
||||
cwd: string;
|
||||
refs: ReturnType<typeof getRefs>;
|
||||
deepenBy?: number;
|
||||
}) {
|
||||
const { cwd, refs, deepenBy = 50 } = args;
|
||||
|
||||
while (true) {
|
||||
const mergeBase = await git(cwd, ["merge-base", refs.base.local, "HEAD"], {
|
||||
throwOnError: false,
|
||||
});
|
||||
|
||||
if (mergeBase.exitCode === 0) {
|
||||
return mergeBase.stdout.trim();
|
||||
}
|
||||
|
||||
if (!(await isRepoShallow({ cwd }))) {
|
||||
throw new Error(
|
||||
`Failed to find merge base between "${refs.base.local}" and HEAD, and the repository is no longer shallow.`,
|
||||
);
|
||||
}
|
||||
|
||||
await deepenRef(cwd, refs.base, deepenBy);
|
||||
await deepenRef(cwd, refs.head, deepenBy);
|
||||
}
|
||||
}
|
||||
|
||||
async function mkdtemp(prefix: string) {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
|
||||
return {
|
||||
dir,
|
||||
async [Symbol.asyncDispose]() {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function tempRef(cwd: string, ref: Ref) {
|
||||
await git(cwd, [
|
||||
"fetch",
|
||||
"--no-tags",
|
||||
"--depth=1",
|
||||
ref.fetchSource,
|
||||
`${ref.remote}:${ref.local}`,
|
||||
]);
|
||||
return {
|
||||
async [Symbol.asyncDispose]() {
|
||||
await git(cwd, ["update-ref", "-d", ref.local], { throwOnError: false });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function tempWorktree(cwd: string, dir: string, ref: Ref) {
|
||||
await git(cwd, ["worktree", "add", "--detach", dir, ref.local]);
|
||||
|
||||
return {
|
||||
async [Symbol.asyncDispose]() {
|
||||
await git(cwd, ["worktree", "remove", "--force", dir], {
|
||||
throwOnError: false,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type WithAsyncDispose<T> = T & {
|
||||
[Symbol.asyncDispose](): Promise<void>;
|
||||
};
|
||||
|
||||
function moveDisposable<T extends object>(
|
||||
stack: AsyncDisposableStack,
|
||||
value: T,
|
||||
): WithAsyncDispose<T> {
|
||||
const moved = stack.move();
|
||||
return Object.assign(value, {
|
||||
async [Symbol.asyncDispose]() {
|
||||
await moved.disposeAsync();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPullRequestWorktree(
|
||||
context: PullRequestContext,
|
||||
cwd: string = process.cwd(),
|
||||
): Promise<WithAsyncDispose<WorktreeInfo>> {
|
||||
await using stack = new AsyncDisposableStack();
|
||||
const worktreeDir = stack.use(
|
||||
await mkdtemp("changesets-action-pr-status-"),
|
||||
).dir;
|
||||
|
||||
const refs = getRefs(context);
|
||||
|
||||
stack.use(await tempRef(cwd, refs.base));
|
||||
stack.use(await tempRef(cwd, refs.head));
|
||||
stack.use(await tempWorktree(cwd, worktreeDir, refs.head));
|
||||
|
||||
await ensureMergeBase({
|
||||
cwd: worktreeDir,
|
||||
refs,
|
||||
});
|
||||
|
||||
return moveDisposable(stack, {
|
||||
baseRef: refs.base.local,
|
||||
cwd: worktreeDir,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user