Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- summary: |
Add PR title, body, and commit message to `fern automations upgrade --json` output.
The new `pr` field in the JSON output allows GitHub Actions consumers to use
CLI-generated PR formatting directly, eliminating duplicated presentation logic.
type: feat
7 changes: 6 additions & 1 deletion packages/cli/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2618,7 +2618,12 @@ function addAutomationsGenerateCommand(cli: Argv<GlobalCliOptions>, cliContext:
* }
* ],
* "skippedMajor": [{ "name": "...", "current": "0.28.0", "latest": "1.37.0" }],
* "alreadyUpToDate": [{ "name": "...", "version": "3.65.5" }]
* "alreadyUpToDate": [{ "name": "...", "version": "3.65.5" }],
* "pr": {
* "title": "chore(fern): upgrade CLI 4.66.0 → 4.96.0 and 1 generator",
* "body": "## Fern Upgrade\n...",
* "commitMessage": "chore: upgrade fern cli 4.66.0 -> 4.96.0, typescript-sdk 3.63.4 -> 3.65.5"
* }
* }
*
* Example GitHub Actions usage:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest";

import { getChangelogUrl } from "../executeAutomationsUpgrade.js";
import {
buildCommitMessage,
buildPrBody,
buildPrTitle,
getChangelogUrl,
getShortGeneratorName
} from "../executeAutomationsUpgrade.js";

describe("getChangelogUrl", () => {
it("derives typescript changelog URL from SDK generator name", () => {
Expand Down Expand Up @@ -88,10 +94,106 @@ describe("getChangelogUrl", () => {
});
});

describe("getShortGeneratorName", () => {
it("strips fernapi/fern- prefix", () => {
expect(getShortGeneratorName("fernapi/fern-typescript-sdk")).toBe("typescript-sdk");
});

it("strips prefix from variant names", () => {
expect(getShortGeneratorName("fernapi/fern-ruby-sdk-v2")).toBe("ruby-sdk-v2");
});

it("leaves non-fernapi names unchanged", () => {
expect(getShortGeneratorName("custom-org/my-generator")).toBe("custom-org/my-generator");
});
});

const CLI_UPGRADED = { from: "4.66.0", to: "5.7.3", upgraded: true };
const CLI_NOT_UPGRADED = { from: "5.7.3", to: "5.7.3", upgraded: false };

const GENERATOR_TS = {
name: "fernapi/fern-typescript-sdk",
group: "ts-sdk",
api: "api" as string | null,
from: "3.63.4",
to: "3.65.5",
changelog: "https://buildwithfern.com/learn/sdks/generators/typescript/changelog",
migrationsApplied: 1
};

const GENERATOR_GO = {
name: "fernapi/fern-go-sdk",
group: "go-sdk",
api: null as string | null,
from: "0.28.0",
to: "1.39.0",
changelog: "https://buildwithfern.com/learn/sdks/generators/go/changelog",
migrationsApplied: 0
};

describe("buildPrTitle", () => {
it("includes CLI and generator count", () => {
expect(buildPrTitle({ cli: CLI_UPGRADED, generators: [GENERATOR_TS, GENERATOR_GO] })).toBe(
"chore(fern): upgrade CLI 4.66.0 → 5.7.3 and 2 generators"
);
});

it("singular generator", () => {
expect(buildPrTitle({ cli: CLI_NOT_UPGRADED, generators: [GENERATOR_TS] })).toBe(
"chore(fern): upgrade 1 generator"
);
});

it("CLI only", () => {
expect(buildPrTitle({ cli: CLI_UPGRADED, generators: [] })).toBe("chore(fern): upgrade CLI 4.66.0 → 5.7.3");
});
});

describe("buildPrBody", () => {
it("includes CLI section and generator table", () => {
const body = buildPrBody({ cli: CLI_UPGRADED, generators: [GENERATOR_TS] });
expect(body).toContain("## Fern Upgrade");
expect(body).toContain("### CLI");
expect(body).toContain("`4.66.0` → `5.7.3`");
expect(body).toContain("### Generators");
expect(body).toContain("| fernapi/fern-typescript-sdk | 3.63.4 | 3.65.5 |");
expect(body).toContain("[View](https://buildwithfern.com/learn/sdks/generators/typescript/changelog)");
expect(body).toContain("fern-upgrade");
});

it("uses em-dash for missing changelog", () => {
const gen = { ...GENERATOR_TS, changelog: undefined };
const body = buildPrBody({ cli: CLI_NOT_UPGRADED, generators: [gen] });
expect(body).toContain("| — |");
});

it("omits CLI section when not upgraded", () => {
const body = buildPrBody({ cli: CLI_NOT_UPGRADED, generators: [GENERATOR_TS] });
expect(body).not.toContain("### CLI");
});

it("omits generator section when no generators", () => {
const body = buildPrBody({ cli: CLI_UPGRADED, generators: [] });
expect(body).not.toContain("### Generators");
});
});

describe("buildCommitMessage", () => {
it("includes cli and short generator names", () => {
expect(buildCommitMessage({ cli: CLI_UPGRADED, generators: [GENERATOR_TS] })).toBe(
"chore: upgrade fern cli 4.66.0 -> 5.7.3, typescript-sdk 3.63.4 -> 3.65.5"
);
});

it("generators only", () => {
expect(buildCommitMessage({ cli: CLI_NOT_UPGRADED, generators: [GENERATOR_TS, GENERATOR_GO] })).toBe(
"chore: upgrade fern typescript-sdk 3.63.4 -> 3.65.5, go-sdk 0.28.0 -> 1.39.0"
);
});
});

describe("AutomationsUpgradeResult schema", () => {
it("documents the expected JSON output shape", () => {
// This test documents the contract between CLI and GHA consumers.
// If the schema changes, this test should be updated alongside the GHA.
it("documents the expected JSON output shape including pr field", () => {
const exampleResult = {
cli: { from: "4.66.0", to: "4.96.0", upgraded: true },
generators: [
Expand All @@ -106,21 +208,24 @@ describe("AutomationsUpgradeResult schema", () => {
}
],
skippedMajor: [{ name: "fernapi/fern-ruby-sdk-v2", current: "0.3.0", latest: "1.0.0" }],
alreadyUpToDate: [{ name: "fernapi/fern-go-sdk", version: "1.37.0" }]
alreadyUpToDate: [{ name: "fernapi/fern-go-sdk", version: "1.37.0" }],
pr: {
title: "chore(fern): upgrade CLI 4.66.0 → 4.96.0 and 1 generator",
body: "## Fern Upgrade\n...",
commitMessage: "chore: upgrade fern cli 4.66.0 -> 4.96.0, typescript-sdk 3.63.4 -> 3.65.5"
}
};

// Verify all top-level keys exist
expect(exampleResult).toHaveProperty("cli");
expect(exampleResult).toHaveProperty("generators");
expect(exampleResult).toHaveProperty("skippedMajor");
expect(exampleResult).toHaveProperty("alreadyUpToDate");
expect(exampleResult).toHaveProperty("pr");

// Verify cli shape
expect(exampleResult.cli).toHaveProperty("from");
expect(exampleResult.cli).toHaveProperty("to");
expect(exampleResult.cli).toHaveProperty("upgraded");

// Verify generator entry shape
const gen = exampleResult.generators[0];
expect(gen).toBeDefined();
expect(gen).toHaveProperty("name");
Expand All @@ -131,18 +236,31 @@ describe("AutomationsUpgradeResult schema", () => {
expect(gen).toHaveProperty("changelog");
expect(gen).toHaveProperty("migrationsApplied");

// Verify skippedMajor entry shape
const skip = exampleResult.skippedMajor[0];
expect(skip).toBeDefined();
expect(skip).toHaveProperty("name");
expect(skip).toHaveProperty("current");
expect(skip).toHaveProperty("latest");

// Verify alreadyUpToDate entry shape
const upToDate = exampleResult.alreadyUpToDate[0];
expect(upToDate).toBeDefined();
expect(upToDate).toHaveProperty("name");
expect(upToDate).toHaveProperty("version");

expect(exampleResult.pr).toHaveProperty("title");
expect(exampleResult.pr).toHaveProperty("body");
expect(exampleResult.pr).toHaveProperty("commitMessage");
});

it("pr is null when nothing changed", () => {
const result = {
cli: { from: "5.7.3", to: "5.7.3", upgraded: false },
generators: [],
skippedMajor: [],
alreadyUpToDate: [{ name: "fernapi/fern-go-sdk", version: "1.37.0" }],
pr: null
};
expect(result.pr).toBeNull();
});

it("supports null api field for single-API projects", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,18 @@ interface AlreadyUpToDateEntry {
version: string;
}

interface PrSuggestion {
title: string;
body: string;
commitMessage: string;
}

export interface AutomationsUpgradeResult {
cli: CliUpgradeResult;
generators: GeneratorUpgradeEntry[];
skippedMajor: SkippedMajorEntry[];
alreadyUpToDate: AlreadyUpToDateEntry[];
pr: PrSuggestion | null;
}

/**
Expand Down Expand Up @@ -108,7 +115,12 @@ async function getCurrentCliVersion(cliContext: CliContext): Promise<string> {
* }
* ],
* "skippedMajor": [{ "name": "...", "current": "...", "latest": "..." }],
* "alreadyUpToDate": [{ "name": "...", "version": "..." }]
* "alreadyUpToDate": [{ "name": "...", "version": "..." }],
* "pr": {
* "title": "chore(fern): upgrade CLI 4.66.0 → 4.96.0 and 2 generators",
* "body": "## Fern Upgrade\n...",
* "commitMessage": "chore: upgrade fern cli 4.66.0 -> 4.96.0, ..."
* }
* }
*/
export async function executeAutomationsUpgrade({
Expand Down Expand Up @@ -155,15 +167,21 @@ export async function executeAutomationsUpgrade({
});

// 4. Build the structured result
const cliResult: CliUpgradeResult = {
from: cliVersionBefore,
to: cliVersionAfter,
upgraded: cliUpgraded
};

const hasChanges = cliUpgraded || generatorResults.generators.length > 0;
const pr = hasChanges ? buildPrSuggestion({ cli: cliResult, generators: generatorResults.generators }) : null;

const result: AutomationsUpgradeResult = {
cli: {
from: cliVersionBefore,
to: cliVersionAfter,
upgraded: cliUpgraded
},
cli: cliResult,
generators: generatorResults.generators,
skippedMajor: generatorResults.skippedMajor,
alreadyUpToDate: generatorResults.alreadyUpToDate
alreadyUpToDate: generatorResults.alreadyUpToDate,
pr
};

return result;
Expand Down Expand Up @@ -256,3 +274,111 @@ async function upgradeGeneratorsForAllWorkspaces({

return { generators, skippedMajor, alreadyUpToDate };
}

// ---------------------------------------------------------------------------
// PR formatting helpers
// ---------------------------------------------------------------------------

/**
* Strips the Docker org prefix from a generator name.
* "fernapi/fern-typescript-sdk" → "typescript-sdk"
*/
export function getShortGeneratorName(name: string): string {
return name.replace(/^fernapi\/fern-/, "");
}

/**
* Builds a suggested PR title from the upgrade results.
*/
export function buildPrTitle({
cli,
generators
}: {
cli: CliUpgradeResult;
generators: readonly GeneratorUpgradeEntry[];
}): string {
const parts: string[] = [];

if (cli.upgraded) {
parts.push(`CLI ${cli.from} → ${cli.to}`);
}

if (generators.length > 0) {
parts.push(`${generators.length} generator${generators.length === 1 ? "" : "s"}`);
}

return `chore(fern): upgrade ${parts.join(" and ")}`;
}

/**
* Builds a suggested PR body (markdown) from the upgrade results.
*/
export function buildPrBody({
cli,
generators
}: {
cli: CliUpgradeResult;
generators: readonly GeneratorUpgradeEntry[];
}): string {
const sections: string[] = ["## Fern Upgrade\n"];

if (cli.upgraded) {
sections.push(`### CLI\n- \`${cli.from}\` → \`${cli.to}\`\n`);
}

if (generators.length > 0) {
sections.push("### Generators");
sections.push("| Generator | From | To | Changelog |");
sections.push("|-----------|------|----|-----------|");

for (const g of generators) {
const link = g.changelog != null ? `[View](${g.changelog})` : "—";
sections.push(`| ${g.name} | ${g.from} | ${g.to} | ${link} |`);
}
sections.push("");
}

sections.push(
"---\n🤖 This PR was automatically created by " +
"[fern-upgrade](https://github.com/fern-api/actions/tree/main/upgrade)"
);

return sections.join("\n");
}

/**
* Builds a one-line commit message summarising all version changes.
*/
export function buildCommitMessage({
cli,
generators
}: {
cli: CliUpgradeResult;
generators: readonly GeneratorUpgradeEntry[];
}): string {
const parts: string[] = [];

if (cli.upgraded) {
parts.push(`cli ${cli.from} -> ${cli.to}`);
}

for (const g of generators) {
parts.push(`${getShortGeneratorName(g.name)} ${g.from} -> ${g.to}`);
}

return `chore: upgrade fern ${parts.join(", ")}`;
}

function buildPrSuggestion({
cli,
generators
}: {
cli: CliUpgradeResult;
generators: readonly GeneratorUpgradeEntry[];
}): PrSuggestion {
return {
title: buildPrTitle({ cli, generators }),
body: buildPrBody({ cli, generators }),
commitMessage: buildCommitMessage({ cli, generators })
};
}