diff --git a/src/commands/github.ts b/src/commands/github.ts index 0f7a4e5..2bfadad 100644 --- a/src/commands/github.ts +++ b/src/commands/github.ts @@ -1,25 +1,20 @@ -import { promises as fsp } from "node:fs"; +import consola from "consola"; import type { Argv } from "mri"; import { resolve } from "pathe"; -import consola from "consola"; -import { underline, cyan } from "colorette"; -import { - getGithubChangelog, - resolveGithubToken, - syncGithubRelease, -} from "../github"; import { - ChangelogConfig, - loadChangelogConfig, - parseChangelogMarkdown, + loadChangelogConfig } from ".."; +import { pullRequest } from "./github/pull-request"; +import { release } from "./github/release"; + +const availableCommands = new Set(["release", "pull-request"]); export default async function githubMain(args: Argv) { const cwd = resolve(args.dir || ""); process.chdir(cwd); const [subCommand, ..._versions] = args._; - if (subCommand !== "release") { + if (!availableCommands.has(subCommand)) { consola.log( "Usage: changelogen gh release [all|versions...] [--dir] [--token]" ); @@ -30,7 +25,7 @@ export default async function githubMain(args: Argv) { if (config.repo?.provider !== "github") { consola.error( - "This command is only supported for github repository provider." + "These command are only supported for github repository provider." ); process.exit(1); } @@ -39,89 +34,9 @@ export default async function githubMain(args: Argv) { config.tokens.github = args.token; } - let changelogMd: string; - if (typeof config.output === "string") { - changelogMd = await fsp - .readFile(resolve(config.output), "utf8") - .catch(() => null); - } - if (!changelogMd) { - changelogMd = await getGithubChangelog(config).catch(() => null); - } - if (!changelogMd) { - consola.error(`Cannot resolve CHANGELOG.md`); - process.exit(1); - } - - const changelogReleases = parseChangelogMarkdown(changelogMd).releases; - - let versions = [..._versions].map((v) => v.replace(/^v/, "")); - if (versions[0] === "all") { - versions = changelogReleases.map((r) => r.version).sort(); - } else if (versions.length === 0) { - if (config.newVersion) { - versions = [config.newVersion]; - } else if (changelogReleases.length > 0) { - versions = [changelogReleases[0].version]; - } - } - - if (versions.length === 0) { - consola.error(`No versions specified to release!`); - process.exit(1); - } - - for (const version of versions) { - const release = changelogReleases.find((r) => r.version === version); - if (!release) { - consola.warn( - `No matching changelog entry found for ${version} in CHANGELOG.md. Skipping!` - ); - continue; - } - if (!release.body || !release.version) { - consola.warn( - `Changelog entry for ${version} in CHANGELOG.md is missing body or version. Skipping!` - ); - continue; - } - await githubRelease(config, { - version: release.version, - body: release.body, - }); - } -} - -export async function githubRelease( - config: ChangelogConfig, - release: { version: string; body: string } -) { - if (!config.tokens.github) { - config.tokens.github = await resolveGithubToken(config).catch( - () => undefined - ); - } - const result = await syncGithubRelease(config, release); - if (result.status === "manual") { - if (result.error) { - consola.error(result.error); - process.exitCode = 1; - } - const open = await import("open").then((r) => r.default); - await open(result.url) - .then(() => { - consola.info(`Followup in the browser to manually create the release.`); - }) - .catch(() => { - consola.info( - `Open this link to manually create a release: \n` + - underline(cyan(result.url)) + - "\n" - ); - }); - } else { - consola.success( - `Synced ${cyan(`v${release.version}`)} to Github releases!` - ); + if (subCommand === "release") { + await release(args, config); + } else if (subCommand === "pull-request") { + await pullRequest(args, config); } } diff --git a/src/commands/github/issue.ts b/src/commands/github/issue.ts new file mode 100644 index 0000000..7c29f2d --- /dev/null +++ b/src/commands/github/issue.ts @@ -0,0 +1,74 @@ +import { Argv } from "mri"; +import consola from "consola"; +import { ChangelogConfig } from "../../config"; +import { getGitDiff, parseCommits } from "../../git"; +import { bumpVersion } from "../../semver"; +import { generateMarkDown } from "../../markdown"; + +/** + * This command will: + * - Get the new version + * - Create a new issue with the changelog + */ +export async function issue(args: Argv, config: ChangelogConfig) { + // Get raw commits from the last release + const rawCommits = await getGitDiff(config.from, config.to); + + // Parse commits as conventional commits in order to get the new version + const commits = parseCommits(rawCommits, config).filter( + (c) => + config.types[c.type] && + !(c.type === "chore" && c.scope === "deps" && !c.isBreaking) + ); + + // Get the new version + const bumpOptions = _getBumpVersionOptions(args); + // TODO: create a new function to only get the new version (using semver.inc without bumping package.json) + const newVersion = await bumpVersion(commits, config, bumpOptions); + if (!newVersion) { + consola.error("Unable to bump version based on changes."); + process.exit(1); + } + config.newVersion = newVersion; + + // Generate changelog + // TODO: Add a template for the markdown issue body + const markdown = await generateMarkDown(commits, config); + + const [currentIssue] = await getGithubIssue(config); + + if (currentIssue) { + await updateGithubIssue(config, currentIssue, markdown); + } + + // TODO: Add a type for the issue body + const body = {} + + await createGithubIssue(config, body); +} + +// Duplicated from ./src/commands/default.ts. Can we create a shared function? +function _getBumpVersionOptions(args: Argv): BumpVersionOptions { + for (const type of [ + "major", + "premajor", + "minor", + "preminor", + "patch", + "prepatch", + "prerelease", + ] as const) { + const value = args[type]; + if (value) { + if (type.startsWith("pre")) { + return { + type, + preid: typeof value === "string" ? value : "", + }; + } + return { + type, + }; + } + } +} diff --git a/src/commands/github/pull-request.ts b/src/commands/github/pull-request.ts new file mode 100644 index 0000000..7460f76 --- /dev/null +++ b/src/commands/github/pull-request.ts @@ -0,0 +1,105 @@ +import { execa } from "execa"; +import { Argv } from "mri"; +import consola from "consola"; +import { ChangelogConfig } from "../../config"; +import { BumpVersionOptions, bumpVersion } from "../../semver"; +import { getGitDiff, parseCommits } from "../../git"; +import { generateMarkDown } from "../../markdown"; +import { createGithubPullRequest, getGithubPullRequest, updateGithubPullRequest } from "../../github"; + +/** + * This command will: + * - Bump version + * - Create a new branch + * - Commit the new version + * - Push the new branch + * - Create a pull request with changelog + */ +export async function pullRequest(args: Argv, config: ChangelogConfig) { + const filesToAdd = ["package.json"]; + + // Get raw commits from the last release + const rawCommits = await getGitDiff(config.from, config.to); + + // Parse commits as conventional commits in order to get the new version + const commits = parseCommits(rawCommits, config).filter( + (c) => + config.types[c.type] && + !(c.type === "chore" && c.scope === "deps" && !c.isBreaking) + ); + + // Get the new version + const bumpOptions = _getBumpVersionOptions(args); + const newVersion = await bumpVersion(commits, config, bumpOptions); + if (!newVersion) { + consola.error("Unable to bump version based on changes."); + process.exit(1); + } + config.newVersion = newVersion; + + // Create a new branch before committing + await execa("git", ["checkout", "-b", "v" + config.newVersion], { cwd: config.cwd }); + + // Add updated files to git + await execa("git", ["add", ...filesToAdd], { cwd: config.cwd }); + // Commit changes + const msg = config.templates.commitMessage.replaceAll( + "{{newVersion}}", + config.newVersion + ); + + // TODO: Add a way to configure username and email + + await execa("git", ["commit", "-m", msg], { cwd: config.cwd }); + + // Push branch and changes to remote + // TODO: Add a way to configure remote (useful for forks in dev mode) + await execa("git", ["push", "-u", "--force", "fork", "v" + config.newVersion], { cwd: config.cwd }); + + // Generate changelog + // TODO: Add a template for the markdown PR body + const markdown = await generateMarkDown(commits, config); + + const [currentPR] = await getGithubPullRequest(config); + + if (currentPR) { + await updateGithubPullRequest(config, currentPR.number, markdown); + } + + // TODO: Add a type for the body + const body = { + title: 'v' + config.newVersion, + head: 'v' + config.newVersion, + base: 'main', + body: markdown, + draft: true, + } + + await createGithubPullRequest(config, body); +} + +// Duplicated from ./src/commands/default.ts. Can we create a shared function? +function _getBumpVersionOptions(args: Argv): BumpVersionOptions { + for (const type of [ + "major", + "premajor", + "minor", + "preminor", + "patch", + "prepatch", + "prerelease", + ] as const) { + const value = args[type]; + if (value) { + if (type.startsWith("pre")) { + return { + type, + preid: typeof value === "string" ? value : "", + }; + } + return { + type, + }; + } + } +} diff --git a/src/commands/github/release.ts b/src/commands/github/release.ts new file mode 100644 index 0000000..5cef3db --- /dev/null +++ b/src/commands/github/release.ts @@ -0,0 +1,99 @@ +import { promises as fsp } from "node:fs"; +import consola from "consola"; +import type { Argv } from "mri"; +import { resolve } from "pathe"; +import { underline, cyan } from "colorette"; +import { getGithubChangelog, resolveGithubToken, syncGithubRelease } from "../../github"; +import { ChangelogConfig } from "../../config"; +import { parseChangelogMarkdown } from "../../markdown"; + +export async function release(args: Argv, config: ChangelogConfig) { + const [_, ..._versions] = args._; + + let changelogMd: string; + if (typeof config.output === "string") { + changelogMd = await fsp + .readFile(resolve(config.output), "utf8") + .catch(() => null); + } + if (!changelogMd) { + changelogMd = await getGithubChangelog(config).catch(() => null); + } + if (!changelogMd) { + consola.error(`Cannot resolve CHANGELOG.md`); + process.exit(1); + } + + const changelogReleases = parseChangelogMarkdown(changelogMd).releases; + + let versions = [..._versions].map((v) => v.replace(/^v/, "")); + if (versions[0] === "all") { + versions = changelogReleases.map((r) => r.version).sort(); + } else if (versions.length === 0) { + if (config.newVersion) { + versions = [config.newVersion]; + } else if (changelogReleases.length > 0) { + versions = [changelogReleases[0].version]; + } + } + + if (versions.length === 0) { + consola.error(`No versions specified to release!`); + process.exit(1); + } + + for (const version of versions) { + const release = changelogReleases.find((r) => r.version === version); + if (!release) { + consola.warn( + `No matching changelog entry found for ${version} in CHANGELOG.md. Skipping!` + ); + continue; + } + if (!release.body || !release.version) { + consola.warn( + `Changelog entry for ${version} in CHANGELOG.md is missing body or version. Skipping!` + ); + continue; + } + await githubRelease(config, { + version: release.version, + body: release.body, + }); + } +} + +// Why not create a utility in ./src/github.ts? +export async function githubRelease( + config: ChangelogConfig, + release: { version: string; body: string } +) { + if (!config.tokens.github) { + config.tokens.github = await resolveGithubToken(config).catch( + () => undefined + ); + } + const result = await syncGithubRelease(config, release); + if (result.status === "manual") { + if (result.error) { + consola.error(result.error); + process.exitCode = 1; + } + const open = await import("open").then((r) => r.default); + await open(result.url) + .then(() => { + consola.info(`Followup in the browser to manually create the release.`); + }) + .catch(() => { + consola.info( + `Open this link to manually create a release: \n` + + underline(cyan(result.url)) + + "\n" + ); + }); + } else { + consola.success( + `Synced ${cyan(`v${release.version}`)} to Github releases!` + ); + } +} diff --git a/src/github.ts b/src/github.ts index 0137d7e..3d6ce25 100644 --- a/src/github.ts +++ b/src/github.ts @@ -9,6 +9,14 @@ export interface GithubOptions { token: string; } +export interface GithubPullRequest { + title: string, + head: string, + base: string, + body: string, + draft?: boolean, +} + export interface GithubRelease { id?: string; tag_name: string; @@ -41,7 +49,30 @@ export async function getGithubChangelog(config: ChangelogConfig) { return await githubFetch( config, `https://raw.githubusercontent.com/${config.repo.repo}/main/CHANGELOG.md` - ); + ) as { number: number, [key: string]: any }[]; +} + +export async function getGithubPullRequest(config: ChangelogConfig) { + const owner = config.repo.repo.split("/")[0]; + return await githubFetch(config, `/repos/${config.repo.repo}/pulls?head=${owner}=${config.newVersion}`); +} + +export async function createGithubPullRequest(config: ChangelogConfig, body: GithubPullRequest){ + return await githubFetch(config, `/repos/${config.repo.repo}/pulls`, { + method: "POST", + body: { + ...body + } + }) +} + +export async function updateGithubPullRequest(config: ChangelogConfig, currentPR: string, body: GithubPullRequest["body"]){ + return await githubFetch(config, `/repos/${config.repo.repo}/pulls/${currentPR}`, { + method: "PATCH", + body: { + body + } + }) } export async function createGithubRelease(