diff --git a/packages/core/src/abstract-cli.ts b/packages/core/src/abstract-cli.ts index 0bcbfda..4c15a7e 100644 --- a/packages/core/src/abstract-cli.ts +++ b/packages/core/src/abstract-cli.ts @@ -128,19 +128,22 @@ export abstract class AbstractCLI { private handleError(error: unknown) { if (this.metadata.onError?.(error)) return; - if (error instanceof Cancel) return; + if (error instanceof Cancel) { + process.exit(0); + } if (error instanceof Exit) { log.warn(color.yellow(error.message)); if (error.hint) log.info(color.dim(error.hint)); - return; + process.exit(0); } if (error instanceof Error) { log.error(color.red(error.message)); - return; + process.exit(1); } console.error(color.red("Error:"), error); + process.exit(1); } } diff --git a/packages/sisyphus/build.ts b/packages/sisyphus/build.ts index 084ebee..6d5b83c 100644 --- a/packages/sisyphus/build.ts +++ b/packages/sisyphus/build.ts @@ -1,3 +1,6 @@ +import { cp } from "node:fs/promises"; import { bunPackageBuilder } from "@r5n/tools/builder"; await bunPackageBuilder({ banner: "#!/usr/bin/env bun", maxSize: 150, packages: "bundle", target: "bun", type: "cli" }); + +await cp("src/commands/actions/templates", "dist/templates", { recursive: true }); diff --git a/packages/sisyphus/src/cli.ts b/packages/sisyphus/src/cli.ts index 36d64e3..92f98d7 100644 --- a/packages/sisyphus/src/cli.ts +++ b/packages/sisyphus/src/cli.ts @@ -1,6 +1,7 @@ import { AbstractCLI, ConfigManager } from "@r5n/cli-core"; import { version } from "../package.json"; import { + ActionsCommand, CheckCommand, InitCommand, MigrateCommand, @@ -27,6 +28,7 @@ class SisyphusCLI extends AbstractCLI { init() { this.registerCommands([ + new ActionsCommand(), new CheckCommand(), new InitCommand(), new MigrateCommand(), diff --git a/packages/sisyphus/src/commands/actions/CiGenerator.ts b/packages/sisyphus/src/commands/actions/CiGenerator.ts new file mode 100644 index 0000000..d2f5371 --- /dev/null +++ b/packages/sisyphus/src/commands/actions/CiGenerator.ts @@ -0,0 +1,106 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Provider } from "../../providers"; + +export type WorkflowType = "create-stone" | "release"; + +export type WorkflowFile = { path: string; content: string }; + +export type WorkflowConfig = { name: string; description: string }; + +export const WORKFLOW_CONFIGS: Record = { + "create-stone": { + description: "When a PR is merged, create stone and update release PR", + name: "Handle PR merge (stone + release PR)", + }, + release: { description: "When the release PR is merged, publish packages", name: "Publish release" }, +}; + +const TEMPLATES_DIR = join(dirname(fileURLToPath(import.meta.url)), "templates"); + +const TEMPLATE_FILENAMES: Record = { + "create-stone": "sis-create-stone.yml", + release: "sis-release.yml", +}; + +export abstract class CiGenerator { + abstract readonly provider: Provider; + protected abstract readonly outputDir: string; + + async generate(workflows: WorkflowType[]): Promise { + const files: WorkflowFile[] = []; + + for (const type of workflows) { + const content = await this.readTemplate(type); + const path = this.getOutputPath(type); + files.push({ content, path }); + } + + return files; + } + + getExistingFiles(workflows: WorkflowType[]): string[] { + return workflows.map((type) => this.getOutputPath(type)).filter((path) => existsSync(path)); + } + + protected async readTemplate(type: WorkflowType): Promise { + const templatePath = join(TEMPLATES_DIR, this.provider, TEMPLATE_FILENAMES[type]); + return readFile(templatePath, "utf-8"); + } + + protected abstract getOutputPath(type: WorkflowType): string; +} + +export class GitHubCiGenerator extends CiGenerator { + readonly provider = "github" as const; + protected readonly outputDir = ".github/workflows"; + + protected getOutputPath(type: WorkflowType): string { + return join(this.outputDir, TEMPLATE_FILENAMES[type]); + } +} + +const GITLAB_SISYPHUS_FILE = ".gitlab-ci-sisyphus.yml"; + +export class GitLabCiGenerator extends CiGenerator { + readonly provider = "gitlab" as const; + protected readonly outputDir = "."; + + async generate(workflows: WorkflowType[]): Promise { + const sections: string[] = []; + + sections.push("stages:\n - sisyphus\n"); + + for (const type of workflows) { + const content = await this.readTemplate(type); + sections.push(content); + } + + return [{ content: sections.join("\n"), path: GITLAB_SISYPHUS_FILE }]; + } + + getExistingFiles(_workflows: WorkflowType[]): string[] { + return existsSync(GITLAB_SISYPHUS_FILE) ? [GITLAB_SISYPHUS_FILE] : []; + } + + getIncludeInstruction(): string { + return `include:\n - local: '${GITLAB_SISYPHUS_FILE}'`; + } + + protected getOutputPath(_type: WorkflowType): string { + return GITLAB_SISYPHUS_FILE; + } +} + +export function createCiGenerator(provider: Provider): CiGenerator { + switch (provider) { + case "github": + return new GitHubCiGenerator(); + case "gitlab": + return new GitLabCiGenerator(); + default: + throw new Error(`CI generation is not supported for ${provider}`); + } +} diff --git a/packages/sisyphus/src/commands/actions/index.ts b/packages/sisyphus/src/commands/actions/index.ts new file mode 100644 index 0000000..ddb0c4f --- /dev/null +++ b/packages/sisyphus/src/commands/actions/index.ts @@ -0,0 +1,12 @@ +import { BaseCommand } from "../../base-command"; +import { ActionsInitCommand } from "./init"; +import { ActionsReleasePrCommand } from "./release-pr"; + +export class ActionsCommand extends BaseCommand { + name = "actions"; + description = "CI/CD integration"; + + init() { + this.registerSubcommands([new ActionsInitCommand(), new ActionsReleasePrCommand()]); + } +} diff --git a/packages/sisyphus/src/commands/actions/init.ts b/packages/sisyphus/src/commands/actions/init.ts new file mode 100644 index 0000000..e710c3a --- /dev/null +++ b/packages/sisyphus/src/commands/actions/init.ts @@ -0,0 +1,107 @@ +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { args, color, confirm, Exit, log, multiselect, note } from "@r5n/cli-core"; +import { BaseCommand, type Ctx } from "../../base-command"; +import { detectProvider } from "../../providers"; +import { createCiGenerator, GitLabCiGenerator, WORKFLOW_CONFIGS, type WorkflowType } from "./CiGenerator"; + +const PROVIDER_LABELS = { bitbucket: "Bitbucket Pipelines", github: "GitHub Actions", gitlab: "GitLab CI" }; + +const initArgs = args({ + all: { alias: "a", default: false, description: "Install all workflows", type: "boolean" }, + createStone: { default: false, description: "Install create-stone workflow", type: "boolean" }, + dryRun: { alias: "d", default: false, description: "Preview without writing files", type: "boolean" }, + release: { default: false, description: "Install release workflow", type: "boolean" }, + yes: { alias: "y", default: false, description: "Skip confirmation prompts", type: "boolean" }, +}); + +type InitCtx = Ctx; + +export class ActionsInitCommand extends BaseCommand { + name = "init"; + description = "Set up CI workflows for automated releases"; + args = initArgs; + prompts = true; + + async execute(ctx: InitCtx) { + const provider = await detectProvider(); + const generator = createCiGenerator(provider); + + log.info(`Detected ${color.bold(PROVIDER_LABELS[provider])} repository\n`); + + const selected = await this.selectWorkflows(ctx); + + if (selected.length === 0) { + log.info(color.dim("No workflows selected")); + return; + } + + const existing = generator.getExistingFiles(selected); + if (existing.length > 0 && !ctx.args.yes) { + if (!ctx.interactive) { + throw new Exit(`Workflows already exist: ${existing.join(", ")}`, "Use --yes to overwrite existing workflows"); + } + log.warn(`The following files already exist:\n${existing.map((f) => ` ${f}`).join("\n")}`); + const overwrite = await confirm({ initialValue: false, message: "Overwrite existing files?" }); + if (!overwrite) return; + } + + const files = await generator.generate(selected); + + if (ctx.args.dryRun) { + for (const file of files) { + log.info(`\n${color.bold(file.path)}:`); + log.info(color.dim(file.content)); + } + return; + } + + for (const file of files) { + const dir = dirname(file.path); + if (dir !== "." && !existsSync(dir)) { + await mkdir(dir, { recursive: true }); + } + await writeFile(file.path, file.content, "utf-8"); + } + + note(files.map((f) => ` ${color.green("+")} ${f.path}`).join("\n"), color.green("CI workflows created")); + + if (generator instanceof GitLabCiGenerator) { + log.info(`\nAdd this to your ${color.bold(".gitlab-ci.yml")}:\n`); + log.info(color.cyan(generator.getIncludeInstruction())); + log.info(""); + } + + log.info(color.dim("Commit and push these files to enable the workflows.")); + } + + private async selectWorkflows(ctx: InitCtx): Promise { + const fromFlags = this.getWorkflowsFromFlags(ctx); + if (fromFlags.length > 0) return fromFlags; + + if (!ctx.interactive) { + throw new Exit("No workflows specified", "Use --all or specify workflows with --createStone, --release"); + } + + return multiselect({ + initialValues: Object.keys(WORKFLOW_CONFIGS) as WorkflowType[], + message: "Select workflows to create", + options: Object.entries(WORKFLOW_CONFIGS).map(([key, config]) => ({ + hint: config.description, + label: config.name, + value: key as WorkflowType, + })), + required: false, + }); + } + + private getWorkflowsFromFlags(ctx: InitCtx): WorkflowType[] { + if (ctx.args.all) return Object.keys(WORKFLOW_CONFIGS) as WorkflowType[]; + + const selected: WorkflowType[] = []; + if (ctx.args.createStone) selected.push("create-stone"); + if (ctx.args.release) selected.push("release"); + return selected; + } +} diff --git a/packages/sisyphus/src/commands/actions/release-pr.ts b/packages/sisyphus/src/commands/actions/release-pr.ts new file mode 100644 index 0000000..573453d --- /dev/null +++ b/packages/sisyphus/src/commands/actions/release-pr.ts @@ -0,0 +1,325 @@ +import { args, color, Exit, log, spinner } from "@r5n/cli-core"; +import { BaseCommand, type Ctx } from "../../base-command"; +import { Package, Stone } from "../../domain"; +import { createGitProvider, type GitProvider } from "../../providers"; +import { ChangelogGenerator, CommitAnalyzer, PackageUpdater, StoneManager, WorkspaceScanner } from "../../services"; +import type { PackageRelease } from "../../types"; + +const RELEASE_BRANCH = "sisyphus/release"; +const RELEASE_LABEL = "sisyphus-release"; +const RELEASE_LABEL_DESCRIPTION = "Sisyphus release PR"; +const RELEASE_LABEL_COLOR = "6f42c1"; +const PR_TITLE_PREFIX = "chore(release):"; + +const releasePrArgs = args({ + dryRun: { alias: "d", default: false, description: "Preview without making changes", type: "boolean" }, +}); + +type ReleasePrCtx = Ctx; + +export class ActionsReleasePrCommand extends BaseCommand { + name = "release-pr"; + description = "Create or update a release PR from pending stones"; + args = releasePrArgs; + + private provider: GitProvider | null = null; + + private async getProvider(): Promise { + if (!this.provider) { + this.provider = await createGitProvider(); + await this.provider.ensureAvailable(); + } + return this.provider; + } + + async execute(ctx: ReleasePrCtx) { + const baseBranch = await this.checkoutReleaseBranch(); + + try { + const { stones, packages } = await this.collectReleaseData(ctx); + + if (stones.length === 0) { + log.info(color.dim("No pending stones found, skipping release PR")); + await Bun.$`git checkout ${baseBranch}`; + return; + } + + const prTitle = this.buildPrTitle(packages); + const prBody = this.buildPrBody(packages, stones); + + log.info(`${color.bold("Release PR:")} ${prTitle}`); + log.info(`${color.dim("Packages:")} ${packages.map((p) => p.name).join(", ")}`); + + if (ctx.args.dryRun) { + log.info(color.yellow("\n[dry-run] Would create/update release PR")); + log.info(color.dim("\nPR Body preview:")); + log.info(prBody); + await Bun.$`git checkout ${baseBranch}`; + return; + } + + await this.commitAndPushChanges(ctx, stones, packages); + await this.createOrUpdatePr(prTitle, prBody); + await Bun.$`git checkout ${baseBranch}`; + } catch (error) { + await this.restoreMainBranch(); + throw error; + } + } + + private async checkoutReleaseBranch(): Promise { + const provider = await this.getProvider(); + const baseBranch = await provider.getDefaultBranch(); + + await Bun.$`git fetch origin ${baseBranch}`; + await this.stashChanges(); + await Bun.$`git checkout -B ${RELEASE_BRANCH} origin/${baseBranch}`; + + return baseBranch; + } + + private async collectReleaseData(ctx: ReleasePrCtx): Promise<{ stones: Stone[]; packages: Package[] }> { + const manager = new StoneManager(ctx.config); + const generatedStones = await this.generateStonesFromCommits(ctx, manager, ctx.args.dryRun); + const pendingStones = await manager.list(); + const stones = ctx.args.dryRun ? [...pendingStones, ...generatedStones] : pendingStones; + + if (stones.length === 0) return { packages: [], stones: [] }; + + const { packages } = await WorkspaceScanner.scan({ single: ctx.config.get("single") }); + const mergedStone = Stone.mergeAll(stones); + const updatedPackages = Package.applyStone(mergedStone, packages); + + if (updatedPackages.length === 0) { + throw new Exit("No packages to update", "Stones don't reference any known packages"); + } + + return { packages: updatedPackages, stones }; + } + + private async commitAndPushChanges(ctx: ReleasePrCtx, stones: Stone[], packages: Package[]) { + const s = spinner(); + s.start("Applying release changes..."); + + const changedFiles = await this.applyReleaseChanges(ctx, stones, packages); + await Bun.$`git add ${changedFiles}`; + + const hasChanges = await Bun.$`git diff --cached --quiet`.nothrow(); + if (hasChanges.exitCode !== 0) { + await Bun.$`git commit -m ${`${PR_TITLE_PREFIX} prepare release`}`; + } + + await Bun.$`git push origin ${RELEASE_BRANCH} --force`; + s.stop("Release branch ready"); + } + + private async createOrUpdatePr(title: string, body: string) { + const s = spinner(); + const existingPr = await this.findExistingReleasePr(); + + if (existingPr) { + s.start("Updating PR..."); + await this.updatePr(existingPr.number, title, body); + s.stop(`PR #${existingPr.number} updated`); + log.info(`\n${color.green("Release PR updated:")} ${existingPr.url}`); + } else { + s.start("Creating PR..."); + const pr = await this.createPr(title, body); + s.stop(`PR #${pr.number} created`); + log.info(`\n${color.green("Release PR created:")} ${pr.url}`); + } + } + + private buildPrTitle(packages: Package[]): string { + const names = packages.map((p) => `${p.name}@${p.newVersion}`).join(", "); + return `${PR_TITLE_PREFIX} ${names}`; + } + + private buildPrBody(packages: Package[], stones: Stone[]): string { + const sections = [ + this.buildChangesSection(stones), + this.buildPackagesSection(packages), + this.buildStonesSection(stones), + this.buildFooter(), + ]; + return sections.join("\n"); + } + + private buildChangesSection(stones: Stone[]): string { + const lines = ["## Changes", ""]; + for (const s of stones) { + lines.push(`### ${s.message}`, ""); + if (s.commits && s.commits.length > 0) { + for (const commit of s.commits) { + lines.push(`- ${commit.message} (\`${commit.hash}\`)`); + } + } else if (s.description) { + lines.push(s.description); + } + lines.push(""); + } + return lines.join("\n"); + } + + private buildPackagesSection(packages: Package[]): string { + const lines = ["## Packages", ""]; + for (const pkg of packages) { + lines.push(`- \`${pkg.name}\` ${pkg.version} → ${pkg.newVersion}`); + } + lines.push(""); + return lines.join("\n"); + } + + private buildStonesSection(stones: Stone[]): string { + const lines = ["## Stones", ""]; + for (const s of stones) { + lines.push(`- \`${s.id}\`: ${s.message}`); + } + lines.push(""); + return lines.join("\n"); + } + + private buildFooter(): string { + return [ + "---", + "*This PR was automatically created by [Sisyphus](https://github.com/r5n-labs/clis).*", + "*Merging this PR will trigger the release workflow.*", + ].join("\n"); + } + + private async findExistingReleasePr(): Promise<{ number: number; url: string } | null> { + const provider = await this.getProvider(); + const pr = await provider.findPr({ head: RELEASE_BRANCH, label: RELEASE_LABEL }); + return pr ? { number: pr.number, url: pr.url } : null; + } + + private async generateStonesFromCommits(ctx: ReleasePrCtx, manager: StoneManager, dryRun: boolean): Promise { + const analyzer = new CommitAnalyzer(ctx.config); + const commitGroups = await analyzer.analyze({ single: ctx.config.get("single") }); + + if (commitGroups.length === 0) return []; + + const { packages } = await WorkspaceScanner.scan({ single: ctx.config.get("single") }); + const stones: Stone[] = []; + + for (const [index, group] of commitGroups.entries()) { + const stoneData = CommitAnalyzer.buildStoneData(group, packages); + + if (dryRun) { + log.info(`${color.dim("[dry-run] Would generate stone:")} ${group.message}`); + stones.push(Stone.create(stoneData, index)); + } else { + const stone = await manager.create(stoneData); + stones.push(stone); + log.info(`${color.dim("Generated stone:")} ${stone.id}`); + } + } + + return stones; + } + + private async findNewestCommitHash(stones: Stone[]): Promise { + const hashes = stones.flatMap((s) => s.commits ?? []).map((c) => c.hash); + if (hashes.length === 0) return null; + + const result = await Bun.$`git log -1 --format=%H ${hashes}`.quiet().nothrow(); + return result.stdout.toString().trim() || null; + } + + private async stashChanges() { + await Bun.$`git stash --include-untracked`.nothrow(); + } + + private async applyReleaseChanges(ctx: ReleasePrCtx, stones: Stone[], packages: Package[]): Promise { + const changedFiles: string[] = []; + + changedFiles.push(...(await this.updatePackages(packages))); + changedFiles.push(...(await this.generateChangelogs(ctx, stones, packages))); + changedFiles.push(...(await this.archiveStonesAndUpdateConfig(ctx, stones, packages))); + + return changedFiles; + } + + private async updatePackages(packages: Package[]): Promise { + const updater = new PackageUpdater(); + await updater.updateAll(packages); + return packages.map((p) => p.file); + } + + private async generateChangelogs(ctx: ReleasePrCtx, stones: Stone[], packages: Package[]): Promise { + const changelogConfig = ctx.config.get("changelog"); + if (!changelogConfig.generate) return []; + + const generator = new ChangelogGenerator(changelogConfig); + await generator.generate(stones, packages); + return [changelogConfig.filename, `**/${changelogConfig.filename}`]; + } + + private async archiveStonesAndUpdateConfig( + ctx: ReleasePrCtx, + stones: Stone[], + packages: Package[], + ): Promise { + const manager = new StoneManager(ctx.config); + const timestamp = await manager.archive(stones); + + const packageVersions = this.buildPackageVersions(packages); + ctx.config.set("currentRelease", { packages: packageVersions, stoneIds: stones.map((s) => s.id), timestamp }); + + await this.updateLastStone(ctx, stones); + + return [ctx.config.get("sisyphusDir")]; + } + + private buildPackageVersions(packages: Package[]): Record { + const versions: Record = {}; + for (const pkg of packages) { + if (pkg.newVersion) { + versions[pkg.name] = { newVersion: pkg.newVersion, oldVersion: pkg.version }; + } + } + return versions; + } + + private async updateLastStone(ctx: ReleasePrCtx, stones: Stone[]) { + const newestCommit = await this.findNewestCommitHash(stones); + if (newestCommit) { + ctx.config.set("lastStone", { commit: newestCommit, date: new Date().toISOString() }); + } + } + + private async createPr(title: string, body: string): Promise<{ number: number; url: string }> { + const provider = await this.getProvider(); + const baseBranch = await provider.getDefaultBranch(); + + await provider.ensureLabelExists(RELEASE_LABEL, { + color: RELEASE_LABEL_COLOR, + description: RELEASE_LABEL_DESCRIPTION, + }); + + const pr = await provider.createPr({ + base: baseBranch, + body, + head: RELEASE_BRANCH, + labels: [RELEASE_LABEL], + title, + }); + + return { number: pr.number, url: pr.url }; + } + + private async updatePr(prNumber: number, title: string, body: string) { + const provider = await this.getProvider(); + await provider.updatePr(prNumber, { body, title }); + } + + private async restoreMainBranch() { + const provider = await this.getProvider(); + const baseBranch = await provider.getDefaultBranch(); + try { + await Bun.$`git checkout ${baseBranch}`.quiet(); + } catch (error) { + log.warn(color.dim(`Failed to restore branch: ${error}`)); + } + } +} diff --git a/packages/sisyphus/src/commands/actions/templates/github/sis-create-stone.yml b/packages/sisyphus/src/commands/actions/templates/github/sis-create-stone.yml new file mode 100644 index 0000000..3a28820 --- /dev/null +++ b/packages/sisyphus/src/commands/actions/templates/github/sis-create-stone.yml @@ -0,0 +1,56 @@ +name: Sisyphus - Handle PR Merge + +on: + pull_request: + types: [closed] + branches: [main] + +jobs: + handle-merge: + if: github.event.pull_request.merged == true && !contains(github.event.pull_request.labels.*.name, 'sisyphus-release') + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install + + - name: Configure git + run: | + git config user.name "r5n-bot[bot]" + git config user.email "r5n-bot[bot]@users.noreply.github.com" + + - name: Create stone from PR + id: stone + run: | + OUTPUT=$(bunx @r5n/sisyphus pr --url ${{ github.event.pull_request.html_url }} --yes 2>&1) && EXIT_CODE=0 || EXIT_CODE=$? + echo "$OUTPUT" + if echo "$OUTPUT" | grep -q "Skipping:"; then + echo "skipped=true" >> $GITHUB_OUTPUT + else + echo "skipped=false" >> $GITHUB_OUTPUT + if [ $EXIT_CODE -ne 0 ]; then + exit $EXIT_CODE + fi + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Commit and push stone + if: steps.stone.outputs.skipped == 'false' + run: | + git add .sisyphus/stones/ + git diff --cached --quiet || (git commit -m "chore: add stone from PR #${{ github.event.pull_request.number }}" && git push) + + - name: Create or update release PR + if: steps.stone.outputs.skipped == 'false' + run: bunx @r5n/sisyphus actions release-pr + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/packages/sisyphus/src/commands/actions/templates/github/sis-release.yml b/packages/sisyphus/src/commands/actions/templates/github/sis-release.yml new file mode 100644 index 0000000..0b0146e --- /dev/null +++ b/packages/sisyphus/src/commands/actions/templates/github/sis-release.yml @@ -0,0 +1,30 @@ +name: Release + +on: + workflow_dispatch: + pull_request: + types: [closed] + branches: [main] + +jobs: + release: + if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'sisyphus-release')) + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install + + - name: Publish release + run: bunx @r5n/sisyphus roll --publish-only --yes + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/packages/sisyphus/src/commands/actions/templates/gitlab/sis-create-stone.yml b/packages/sisyphus/src/commands/actions/templates/gitlab/sis-create-stone.yml new file mode 100644 index 0000000..fc082ff --- /dev/null +++ b/packages/sisyphus/src/commands/actions/templates/gitlab/sis-create-stone.yml @@ -0,0 +1,26 @@ +handle-merge: + stage: sisyphus + image: oven/bun:latest + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_STATE == "merged" && $CI_MERGE_REQUEST_LABELS !~ /sisyphus-release/ + script: + - git fetch --unshallow || true + - git config user.name "r5n-bot[bot]" + - git config user.email "r5n-bot[bot]@users.noreply.gitlab.com" + - bun install + - | + OUTPUT=$(bunx @r5n/sisyphus pr --url $CI_MERGE_REQUEST_PROJECT_URL/-/merge_requests/$CI_MERGE_REQUEST_IID --yes 2>&1) && EXIT_CODE=0 || EXIT_CODE=$? + echo "$OUTPUT" + if echo "$OUTPUT" | grep -q "Skipping:"; then + echo "Stone creation skipped" + exit 0 + fi + if [ $EXIT_CODE -ne 0 ]; then + exit $EXIT_CODE + fi + git add .sisyphus/stones/ + git diff --cached --quiet || git commit -m 'chore: add stone from MR !'"$CI_MERGE_REQUEST_IID" + git push https://oauth2:$GITLAB_TOKEN@$CI_SERVER_HOST/$CI_PROJECT_PATH.git HEAD:$CI_DEFAULT_BRANCH + bunx @r5n/sisyphus actions release-pr + variables: + GITLAB_TOKEN: $GITLAB_TOKEN diff --git a/packages/sisyphus/src/commands/actions/templates/gitlab/sis-release.yml b/packages/sisyphus/src/commands/actions/templates/gitlab/sis-release.yml new file mode 100644 index 0000000..b843d0c --- /dev/null +++ b/packages/sisyphus/src/commands/actions/templates/gitlab/sis-release.yml @@ -0,0 +1,14 @@ +release: + stage: sisyphus + image: oven/bun:latest + rules: + - if: $CI_PIPELINE_SOURCE == "web" + when: manual + - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_STATE == "merged" && $CI_MERGE_REQUEST_LABELS =~ /sisyphus-release/ + script: + - git fetch --unshallow || true + - bun install + - bunx @r5n/sisyphus roll --publish-only --yes + variables: + GITLAB_TOKEN: $GITLAB_TOKEN + NPM_TOKEN: $NPM_TOKEN diff --git a/packages/sisyphus/src/commands/index.ts b/packages/sisyphus/src/commands/index.ts index 5672027..5eb81c7 100644 --- a/packages/sisyphus/src/commands/index.ts +++ b/packages/sisyphus/src/commands/index.ts @@ -1,3 +1,4 @@ +export * from "./actions"; export * from "./check"; export * from "./init"; export * from "./migrate"; diff --git a/packages/sisyphus/src/commands/init.ts b/packages/sisyphus/src/commands/init.ts index fa1189a..887dd24 100644 --- a/packages/sisyphus/src/commands/init.ts +++ b/packages/sisyphus/src/commands/init.ts @@ -8,9 +8,9 @@ const initArgs = args({ commitAuthor: { description: "Commit author name", type: "string" }, commitEmail: { description: "Commit author email", type: "string" }, commitMessage: { description: "Commit message template", type: "string" }, + createRelease: { description: "Create a release on git provider", type: "boolean" }, default: { alias: "d", default: false, description: "Reset to default config", type: "boolean" }, force: { alias: "f", default: false, description: "Overwrite existing config", type: "boolean" }, - github: { description: "Create GitHub releases", type: "boolean" }, npm: { description: "Publish to NPM on release", type: "boolean" }, push: { description: "Push commits and tags to remote on release", type: "boolean" }, rootChangelog: { description: "Generate combined changelog for monorepo", type: "boolean" }, @@ -59,7 +59,7 @@ export class InitCommand extends BaseCommand { const commit = defined({ author: v.commitAuthor, email: v.commitEmail, message: v.commitMessage }); const changelog = defined({ generate: v.changelog, root: v.rootChangelog }); - const release = defined({ github: v.github, npm: v.npm, push: v.push, tags: v.tags }); + const release = defined({ createRelease: v.createRelease, npm: v.npm, push: v.push, tags: v.tags }); return { ...defined({ single: v.single, tag: v.tag }), @@ -114,12 +114,12 @@ export class InitCommand extends BaseCommand { ), message: "Configure commit for release? Default commit message is `release: `", }), - github: () => + createRelease: () => confirm({ - active: "Yes, create a GitHub release", - inactive: "Skip GitHub release", - initialValue: currentConfig.release?.github ?? false, - message: "Create a release on GitHub when releasing packages?", + active: "Yes, create a release", + inactive: "Skip release creation", + initialValue: currentConfig.release?.createRelease ?? false, + message: "Create a release on git provider when releasing packages?", }), npm: () => confirm({ @@ -177,7 +177,7 @@ export class InitCommand extends BaseCommand { commitAuthor: values.commitAuthor, commitEmail: values.commitEmail, commitMessage: values.commitMessage, - github: values.github, + createRelease: values.createRelease, npm: values.npm, push: values.push, rootChangelog: values.rootChangelog, diff --git a/packages/sisyphus/src/commands/pr.ts b/packages/sisyphus/src/commands/pr.ts index a6d8ec0..4b5d497 100644 --- a/packages/sisyphus/src/commands/pr.ts +++ b/packages/sisyphus/src/commands/pr.ts @@ -32,6 +32,12 @@ export class PrCommand extends BaseCommand { this.displayPrSummary(result.pr); + const skipReason = this.shouldSkipPr(ctx, result.pr); + if (skipReason) { + log.info(color.dim(`Skipping: ${skipReason}`)); + return; + } + if (result.packages.size === 0) { throw new Exit("No packages affected by this PR", "The PR only modifies root files or files outside packages"); } @@ -89,6 +95,32 @@ export class PrCommand extends BaseCommand { } } + private shouldSkipPr(ctx: PrCtx, pr: PullRequestInfo): string | null { + const skip = ctx.config.get("pr").skip; + + for (const label of pr.labels) { + if (skip.labels.some((l) => l.toLowerCase() === label.toLowerCase())) { + return `label "${label}" is in skip list`; + } + } + + if (skip.authors.some((a) => a.toLowerCase() === pr.author.toLowerCase())) { + return `author "${pr.author}" is in skip list`; + } + + for (const pattern of skip.titlePatterns) { + try { + if (new RegExp(pattern, "i").test(pr.title)) { + return `title matches skip pattern "${pattern}"`; + } + } catch { + log.warn(color.yellow(`Invalid regex pattern in pr.skip.titlePatterns: "${pattern}"`)); + } + } + + return null; + } + private async determineBumpType(ctx: PrCtx, suggested: BumpType | null, labels: string[]): Promise { if (ctx.args.bump) { if (!isBumpType(ctx.args.bump)) { diff --git a/packages/sisyphus/src/commands/roll.ts b/packages/sisyphus/src/commands/roll.ts index bf3c287..1ee942e 100644 --- a/packages/sisyphus/src/commands/roll.ts +++ b/packages/sisyphus/src/commands/roll.ts @@ -1,16 +1,17 @@ import { args, color, confirm, Exit, log, note, spinner } from "@r5n/cli-core"; import { BaseCommand, type Ctx } from "../base-command"; import { CLI_BIN } from "../constants"; -import { BUMP_ORDER, type Package, Stone } from "../domain"; +import { Package, Stone } from "../domain"; import { ChangelogGenerator, ReleaseOrchestrator, StoneManager, WorkspaceScanner } from "../services"; const rollArgs = args({ changelog: { alias: "c", description: "Generate changelogs", type: "boolean" }, + createRelease: { alias: "r", description: "Create a release on git provider", type: "boolean" }, dryRun: { alias: "d", default: false, description: "Preview without making changes", type: "boolean" }, - github: { alias: "g", description: "Create GitHub releases", type: "boolean" }, noCommit: { default: false, description: "Skip creating release commit", type: "boolean" }, npm: { alias: "n", description: "Publish to NPM", type: "boolean" }, preview: { default: false, description: "Preview changelogs then prompt to delete", type: "boolean" }, + publishOnly: { default: false, description: "Publish from currentRelease (no file changes)", type: "boolean" }, push: { alias: "p", description: "Push commits and tags to remote", type: "boolean" }, tags: { alias: "t", description: "Create git tags", type: "boolean" }, yes: { alias: "y", default: false, description: "Skip confirmation prompts", type: "boolean" }, @@ -21,19 +22,26 @@ type RollCtx = Ctx; type RollOptions = { changelog: boolean; commit: boolean; + createRelease: boolean; dryRun: boolean; - github: boolean; npm: boolean; push: boolean; tags: boolean; }; +type PublishOnlyOptions = { createRelease: boolean; dryRun: boolean; npm: boolean; tags: boolean }; + export class RollCommand extends BaseCommand { name = "roll"; description = "Execute a release from pending stones"; args = rollArgs; async execute(ctx: RollCtx) { + if (ctx.args.publishOnly) { + await this.executePublishOnly(ctx); + return; + } + const options = this.resolveOptions(ctx); const manager = new StoneManager(ctx.config); @@ -45,8 +53,8 @@ export class RollCommand extends BaseCommand { const { packages } = await WorkspaceScanner.scan({ single: ctx.config.get("single") }); - const mergedStone = this.mergeStones(stones); - const updatedPackages = this.preparePackages(mergedStone, packages); + const mergedStone = Stone.mergeAll(stones); + const updatedPackages = Package.applyStone(mergedStone, packages); if (updatedPackages.length === 0) { throw new Exit("No packages to update", "Stones don't reference any known packages"); @@ -108,38 +116,14 @@ export class RollCommand extends BaseCommand { return { changelog: ctx.args.changelog ?? changelog.generate, commit, + createRelease: commit ? (ctx.args.createRelease ?? release.createRelease) : false, dryRun: ctx.args.dryRun, - github: commit ? (ctx.args.github ?? release.github) : false, npm: ctx.args.npm ?? release.npm, push: commit ? (ctx.args.push ?? release.push) : false, tags: commit ? (ctx.args.tags ?? release.tags) : false, }; } - private mergeStones(stones: Stone[]): Stone { - const [first, ...rest] = stones; - if (!first) throw new Exit("No stones to merge"); - if (rest.length === 0) return first; - - const messages = stones.map((s) => s.message).join("; "); - return Stone.merge(stones, messages).stone; - } - - private preparePackages(stone: Stone, packages: Map): Package[] { - const updated: Package[] = []; - - for (const bump of BUMP_ORDER) { - for (const name of stone.getPackages(bump)) { - const pkg = packages.get(name); - if (pkg) { - updated.push(pkg.withBump(bump, stone.tag)); - } - } - } - - return updated; - } - private printPreview(stone: Stone, packages: Package[], options: RollOptions) { const lines: string[] = []; @@ -161,7 +145,7 @@ export class RollCommand extends BaseCommand { lines.push(color.dim(`Git tags: ${options.tags ? "yes" : "no"}`)); lines.push(color.dim(`NPM publish: ${options.npm ? "yes" : "no"}`)); lines.push(color.dim(`Push to remote: ${options.push ? "yes" : "no"}`)); - lines.push(color.dim(`GitHub release: ${options.github ? "yes" : "no"}`)); + lines.push(color.dim(`Create release: ${options.createRelease ? "yes" : "no"}`)); log.step(lines.join("\n")); } @@ -217,10 +201,10 @@ export class RollCommand extends BaseCommand { s.stop("Pushed to remote"); } - if (options.github) { - s.start("Creating GitHub release..."); - await orchestrator.createGithubRelease(stone, packages); - s.stop("GitHub release created"); + if (options.createRelease) { + s.start("Creating release..."); + await orchestrator.createGitRelease(originalStones, packages); + s.stop("Release created"); } note( @@ -263,4 +247,120 @@ export class RollCommand extends BaseCommand { return ""; } } + + private async executePublishOnly(ctx: RollCtx) { + const currentRelease = ctx.config.get("currentRelease"); + + if (!currentRelease) { + throw new Exit("No currentRelease found in config", "Run `sis actions release-pr` first to prepare a release"); + } + + const packageEntries = Object.entries(currentRelease.packages); + if (packageEntries.length === 0) { + throw new Exit("No packages in currentRelease", "The release config appears to be empty"); + } + + const { packages: allPackages } = await WorkspaceScanner.scan({ single: ctx.config.get("single") }); + const packagesToPublish: Package[] = []; + + for (const [name, { oldVersion, newVersion }] of packageEntries) { + const pkg = allPackages.get(name); + if (pkg) { + packagesToPublish.push(pkg.withVersions(oldVersion, newVersion)); + } + } + + if (packagesToPublish.length === 0) { + throw new Exit("No matching packages found", "Packages in currentRelease don't exist in workspace"); + } + + log.info(color.bold("Publish-only mode")); + log.info(color.dim(`Timestamp: ${currentRelease.timestamp}`)); + log.info(color.dim(`Stones: ${currentRelease.stoneIds.join(", ")}`)); + log.info(""); + + log.info(color.bold("Packages to publish:")); + for (const pkg of packagesToPublish) { + log.info(` ${pkg.name}@${pkg.newVersion ?? pkg.version}`); + } + log.info(""); + + const release = ctx.config.get("release"); + const options: PublishOnlyOptions = { + createRelease: ctx.args.createRelease ?? release.createRelease, + dryRun: ctx.args.dryRun, + npm: ctx.args.npm ?? release.npm, + tags: ctx.args.tags ?? release.tags, + }; + + if (options.dryRun) { + log.info(color.yellow("[dry-run] Would publish packages")); + return; + } + + if (!ctx.args.yes && ctx.interactive) { + const confirmed = await confirm({ initialValue: true, message: "Proceed with publishing?" }); + if (!confirmed) return; + } + + await this.executePublish(ctx, packagesToPublish, currentRelease, options); + } + + private async executePublish( + ctx: RollCtx, + packages: Package[], + currentRelease: { stoneIds: string[]; timestamp: string }, + options: PublishOnlyOptions, + ) { + const orchestrator = new ReleaseOrchestrator(ctx.config, { + changelog: false, + createRelease: options.createRelease, + dryRun: options.dryRun, + npm: options.npm, + push: false, + tags: options.tags, + }); + const s = spinner(); + + try { + if (options.tags) { + s.start("Creating and pushing git tags..."); + await orchestrator.createGitTags(packages); + await orchestrator.pushTags(); + s.stop("Git tags pushed"); + } + + if (options.npm) { + s.start("Publishing to NPM..."); + await orchestrator.publishToNpm(packages); + s.stop("Published to NPM"); + } + + if (options.createRelease) { + s.start("Creating release..."); + const manager = new StoneManager(ctx.config); + const stones = await manager.getReleasedStones(currentRelease.timestamp); + const releaseStones = stones.length > 0 ? stones : [this.createFallbackStone(packages)]; + await orchestrator.createGitRelease(releaseStones, packages); + s.stop("Release created"); + } + + ctx.config.set("currentRelease", undefined); + ctx.config.set("lastStone", { commit: await this.getCurrentCommit(), date: new Date().toISOString() }); + + note( + `Published ${color.bold(String(packages.length))} package(s)\n` + + `Run ${color.green(`${CLI_BIN} check`)} to verify`, + color.green("Publish complete"), + ); + } catch (error) { + s.stop("Publish failed"); + throw error; + } + } + + private createFallbackStone(packages: Package[]): Stone { + const message = `Release ${packages.map((p) => `${p.name}@${p.version}`).join(", ")}`; + return Stone.create({ message }, 0); + } } diff --git a/packages/sisyphus/src/commands/version.ts b/packages/sisyphus/src/commands/version.ts index b50da09..01779df 100644 --- a/packages/sisyphus/src/commands/version.ts +++ b/packages/sisyphus/src/commands/version.ts @@ -2,7 +2,7 @@ import { args, color, confirm, Exit, log, multiselect, note, positionals, text } import { BaseCommand, type Ctx } from "../base-command"; import { CLI_BIN } from "../constants"; import { BUMP_COLORS, BumpType, nonEmpty, type Package, type StoneData } from "../domain"; -import { CommitAnalyzer, type CommitGroup, StoneManager, WorkspaceScanner } from "../services"; +import { CommitAnalyzer, StoneManager, WorkspaceScanner } from "../services"; import { findDependencyPackages } from "../utils"; // biome-ignore assist/source/useSortedKeys: message must come first @@ -11,7 +11,6 @@ const versionPositionals = positionals({ description: { description: "Stone description (optional details)" }, }); -// biome-ignore assist/source/useSortedKeys: cleaner order const versionArgs = args({ dryRun: { alias: "d", default: false, description: "Preview without writing files", type: "boolean" }, filter: { alias: "f", description: "Filter packages by name", type: "string" }, @@ -126,7 +125,7 @@ export class VersionCommand extends BaseCommand { let createdCount = 0; for (const group of commitGroups) { - const stoneData = this.buildStoneDataFromCommitGroup(group, packages, ctx.args.tag); + const stoneData = CommitAnalyzer.buildStoneData(group, packages, ctx.args.tag); if (ctx.args.dryRun) { this.logDryRunStone(stoneData); @@ -226,21 +225,6 @@ export class VersionCommand extends BaseCommand { }; } - private buildStoneDataFromCommitGroup(group: CommitGroup, packages: Map, tag?: string): StoneData { - const pkgNames = Array.from(group.packages); - const commits = group.commits.length > 0 ? group.commits : undefined; - const data: StoneData = { commits, message: group.message, tag }; - - if (group.bump === BumpType.Major) data.major = pkgNames; - else if (group.bump === BumpType.Minor) data.minor = pkgNames; - else data.patch = pkgNames; - - const deps = findDependencyPackages(pkgNames, packages); - if (deps.length > 0) data.dependency = deps; - - return data; - } - private async createStone(ctx: VersionCtx, data: StoneData, packages: Map) { const manager = new StoneManager(ctx.config); diff --git a/packages/sisyphus/src/constants.ts b/packages/sisyphus/src/constants.ts index 34a8f5d..b3c2d99 100644 --- a/packages/sisyphus/src/constants.ts +++ b/packages/sisyphus/src/constants.ts @@ -1,10 +1,11 @@ -import { OTHER_COMMIT_TYPE } from "./domain"; import type { SisyphusConfig } from "./types"; export const CLI_BIN = "sis"; export const BULLET_POINT = "🪨"; +export const OTHER_COMMIT_TYPE = "other"; + export const COMMIT_TYPE_ORDER: Record = { build: 23, chore: 25, @@ -25,6 +26,18 @@ export const COMMIT_TYPE_ORDER_FALLBACK = 50; export const DEFAULT_CONFIG_DIR = ".sisyphus"; export const DEFAULT_STONES_DIR = "stones"; +export const DEFAULT_RELEASED_DIR = "released"; + +export const SHORT_HASH_LENGTH = 7; +export const UNKNOWN_HASH = "unknown"; + +export const UNKNOWN_AUTHOR = "unknown"; +export const DEFAULT_VERSION = "0.0.0"; +export const DEFAULT_NPM_TAG = "latest"; +export const DEFAULT_BRANCH = "main"; + +export const STONE_ID_PAD_LENGTH = 4; +export const SHORT_UUID_LENGTH = 8; export const DEFAULT_CONFIG_FILE = "config.json"; export const DEFAULT_CHANGELOG_FILE = "CHANGELOG.md"; @@ -36,7 +49,9 @@ export const SISYPHUS_DEFAULT_CONFIG: SisyphusConfig = { append: true, filename: DEFAULT_CHANGELOG_FILE, generate: true, + packageHeader: "{emoji} {version} ({date})", root: false, + rootHeader: "{date} - {packages}", sections: { breaking: "Breaking changes", build: "Build", @@ -56,6 +71,8 @@ export const SISYPHUS_DEFAULT_CONFIG: SisyphusConfig = { commit: { author: "r5n-bot", message: "chore(release): {message}" }, + commits: { skip: { authors: ["r5n-bot[bot]"], messagePatterns: ["^chore\\(release\\):", "^chore: add stone"] } }, + ignore: [], lastStone: { commit: "", date: "" }, @@ -73,9 +90,14 @@ export const SISYPHUS_DEFAULT_CONFIG: SisyphusConfig = { feature: "minor", fix: "patch", }, + skip: { + authors: ["r5n-bot[bot]"], + labels: ["sisyphus-release", "skip-stone"], + titlePatterns: ["^chore\\(release\\):"], + }, }, - release: { github: false, npm: false, push: false, tags: false }, + release: { createRelease: false, npm: false, push: false, tags: false }, scripts: { post: {}, pre: {} }, diff --git a/packages/sisyphus/src/domain/Commit.ts b/packages/sisyphus/src/domain/Commit.ts index 9c53243..ee5cb1f 100644 --- a/packages/sisyphus/src/domain/Commit.ts +++ b/packages/sisyphus/src/domain/Commit.ts @@ -1,8 +1,8 @@ -const CONVENTIONAL_COMMIT_REGEX = /^(\w+)(?:\(([^)]+)\))?(!)?: (.+)$/; +import { OTHER_COMMIT_TYPE, SHORT_HASH_LENGTH } from "../constants"; -export const OTHER_COMMIT_TYPE = "other"; +const CONVENTIONAL_COMMIT_REGEX = /^(\w+)(?:\(([^)]+)\))?(!)?: (.+)$/; -const SHORT_HASH_LENGTH = 7; +const FIELD_SEPARATOR = "\x1f"; const KNOWN_COMMIT_TYPES = new Set([ "build", @@ -30,6 +30,7 @@ export type CommitInfo = { type CommitOptions = { hash: string; subject: string; + author: string; body?: string; type: string; scope?: string; @@ -41,6 +42,7 @@ type CommitOptions = { export class Commit { readonly hash: string; readonly subject: string; + readonly author: string; readonly body?: string; readonly type: string; readonly scope?: string; @@ -51,6 +53,7 @@ export class Commit { private constructor(options: CommitOptions) { this.hash = options.hash; this.subject = options.subject; + this.author = options.author; this.body = options.body; this.type = options.type; this.scope = options.scope; @@ -79,6 +82,35 @@ export class Commit { return []; } + static async fromHash(hash: string): Promise { + try { + const result = await Bun.$`git log -1 --pretty=format:"%H%x1f%s%x1f%an" ${hash}`.quiet(); + const output = result.stdout.toString().trim(); + if (!output) return null; + + const parts = output.split(FIELD_SEPARATOR); + if (parts.length < 3) return null; + const [fullHash, subject, author] = parts; + if (!fullHash || !subject || !author) return null; + + return Commit.hydrate(fullHash, subject, author); + } catch { + return null; + } + } + + static async fromMerge(mergeCommitSha: string): Promise { + try { + const result = await Bun.$`git rev-parse ${mergeCommitSha}^2`.quiet(); + const branchTip = result.stdout.toString().trim(); + if (!branchTip) return []; + + return Commit.fetchFromRange(`${mergeCommitSha}^1..${branchTip}`); + } catch { + return []; + } + } + private static async tryFetchFromRange(range: string): Promise { try { return await Commit.fetchFromRange(range); @@ -88,7 +120,7 @@ export class Commit { } private static async fetchFromRange(range: string): Promise { - const result = await Bun.$`git log ${range} --pretty=format:"%H%x1f%s" --no-merges`.quiet(); + const result = await Bun.$`git log ${range} --pretty=format:"%H%x1f%s%x1f%an" --no-merges`.quiet(); const output = result.stdout.toString().trim(); if (!output) return []; @@ -96,24 +128,23 @@ export class Commit { const commits: Commit[] = []; for (const line of output.split("\n")) { - const sepIndex = line.indexOf("\x1f"); - if (sepIndex === -1) continue; - const hash = line.slice(0, sepIndex); - const subject = line.slice(sepIndex + 1); - if (!hash || !subject) continue; + const parts = line.split(FIELD_SEPARATOR); + if (parts.length < 3) continue; + const [hash, subject, author] = parts; + if (!hash || !subject || !author) continue; - const commit = await Commit.hydrate(hash, subject); + const commit = await Commit.hydrate(hash, subject, author); commits.push(commit); } return commits; } - private static async hydrate(hash: string, subject: string): Promise { + private static async hydrate(hash: string, subject: string, author: string): Promise { const files = await Commit.getFiles(hash); const body = await Commit.getBody(hash); - return Commit.parse(hash, subject).withFiles(files).withBody(body); + return Commit.parse(hash, subject, author).withFiles(files).withBody(body); } private static async getFiles(hash: string): Promise { @@ -127,17 +158,17 @@ export class Commit { return body || undefined; } - static parse(hash: string, subject: string): Commit { + static parse(hash: string, subject: string, author: string): Commit { const match = subject.match(CONVENTIONAL_COMMIT_REGEX); if (match) { const [, type = "", scope, breaking, message = ""] = match; if (type && KNOWN_COMMIT_TYPES.has(type)) { - return new Commit({ breaking: !!breaking, files: [], hash, message, scope, subject, type }); + return new Commit({ author, breaking: !!breaking, files: [], hash, message, scope, subject, type }); } } - return new Commit({ breaking: false, files: [], hash, message: subject, subject, type: OTHER_COMMIT_TYPE }); + return new Commit({ author, breaking: false, files: [], hash, message: subject, subject, type: OTHER_COMMIT_TYPE }); } get shortHash(): string { @@ -170,6 +201,7 @@ export class Commit { private toOptions(): CommitOptions { return { + author: this.author, body: this.body, breaking: this.breaking, files: this.files, diff --git a/packages/sisyphus/src/domain/Package.ts b/packages/sisyphus/src/domain/Package.ts index fe4ce3b..cfaec3a 100644 --- a/packages/sisyphus/src/domain/Package.ts +++ b/packages/sisyphus/src/domain/Package.ts @@ -1,5 +1,7 @@ +import { DEFAULT_VERSION } from "../constants"; import { VersionCalculator } from "../services/VersionCalculator"; -import type { BumpType } from "./BumpType"; +import { BUMP_ORDER, type BumpType } from "./BumpType"; +import type { Stone } from "./Stone"; export type PackageJson = { name: string; @@ -16,6 +18,7 @@ export type PackageOptions = { dependencyOf?: readonly string[]; bump?: BumpType; tag?: string; + newVersion?: string; }; export class Package { @@ -25,6 +28,7 @@ export class Package { readonly dependencyOf: readonly string[]; readonly bump?: BumpType; readonly tag?: string; + private readonly _newVersion?: string; constructor(options: PackageOptions) { this.name = options.name; @@ -33,13 +37,30 @@ export class Package { this.dependencyOf = options.dependencyOf ?? []; this.bump = options.bump; this.tag = options.tag; + this._newVersion = options.newVersion; } static fromJson(json: PackageJson, file: string): Package { - return new Package({ file, name: json.name, version: json.version || "0.0.0" }); + return new Package({ file, name: json.name, version: json.version || DEFAULT_VERSION }); + } + + static applyStone(stone: Stone, packages: Map): Package[] { + const updated: Package[] = []; + + for (const bump of BUMP_ORDER) { + for (const name of stone.getPackages(bump)) { + const pkg = packages.get(name); + if (pkg) { + updated.push(pkg.withBump(bump, stone.tag)); + } + } + } + + return updated; } get newVersion(): string | undefined { + if (this._newVersion) return this._newVersion; if (!this.bump) return undefined; return VersionCalculator.bump(this.version, this.bump, this.tag); } @@ -54,7 +75,11 @@ export class Package { } withBump(bump: BumpType, tag?: string): Package { - return new Package({ ...this.toOptions(), bump, tag }); + return new Package({ ...this.toOptions(), bump, newVersion: undefined, tag }); + } + + withVersions(oldVersion: string, newVersion: string): Package { + return new Package({ ...this.toOptions(), newVersion, version: oldVersion }); } private toOptions(): PackageOptions { @@ -63,6 +88,7 @@ export class Package { dependencyOf: this.dependencyOf, file: this.file, name: this.name, + newVersion: this._newVersion, tag: this.tag, version: this.version, }; diff --git a/packages/sisyphus/src/domain/Stone.ts b/packages/sisyphus/src/domain/Stone.ts index 2c3e475..e183800 100644 --- a/packages/sisyphus/src/domain/Stone.ts +++ b/packages/sisyphus/src/domain/Stone.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { SHORT_UUID_LENGTH, STONE_ID_PAD_LENGTH } from "../constants"; import { BUMP_ORDER, BumpType, higherBump } from "./BumpType"; import type { CommitInfo } from "./Commit"; import { nonEmpty } from "./helpers"; @@ -19,6 +20,15 @@ export type StoneJson = StoneData & { id: string; commits?: readonly CommitInfo[ export type MergeResult = { stone: Stone; conflicts: readonly string[] }; +type StoneOptions = { + id: string; + message: string; + packages: Map; + tag?: string; + description?: string; + commits?: readonly CommitInfo[]; +}; + export class Stone { readonly id: string; readonly message: string; @@ -28,20 +38,13 @@ export class Stone { private readonly _packages: ReadonlyMap; - private constructor( - id: string, - message: string, - packages: Map, - tag?: string, - description?: string, - commits?: readonly CommitInfo[], - ) { - this.id = id; - this.message = message; - this.tag = tag; - this.description = description; - this.commits = commits; - this._packages = packages; + private constructor(options: StoneOptions) { + this.id = options.id; + this.message = options.message; + this.tag = options.tag; + this.description = options.description; + this.commits = options.commits; + this._packages = options.packages; } static create(data: StoneData, existingCount = 0): Stone { @@ -53,6 +56,15 @@ export class Stone { return Stone.fromData(json.id, json); } + static mergeAll(stones: Stone[]): Stone { + const [first, ...rest] = stones; + if (!first) throw new Error("No stones to merge"); + if (rest.length === 0) return first; + + const messages = stones.map((s) => s.message).join("; "); + return Stone.merge(stones, messages).stone; + } + static merge(stones: Stone[], message: string): MergeResult { const packageBumps = new Map(); const conflicts: string[] = []; @@ -73,14 +85,14 @@ export class Stone { const tags = [...new Set(stones.map((s) => s.tag).filter(Boolean))]; const id = `merged-${Date.now()}`; - const stone = new Stone( + const stone = new Stone({ + commits: commits.length > 0 ? commits : undefined, + description: descriptions || undefined, id, message, packages, - tags[0], - descriptions || undefined, - commits.length > 0 ? commits : undefined, - ); + tag: tags[0], + }); return { conflicts: [...new Set(conflicts)], stone }; } @@ -129,12 +141,19 @@ export class Stone { packages.set(BumpType.Dependency, data.dependency ?? []); packages.set(BumpType.Snapshot, data.snapshot ?? []); - return new Stone(id, data.message, packages, data.tag, data.description, data.commits); + return new Stone({ + commits: data.commits, + description: data.description, + id, + message: data.message, + packages, + tag: data.tag, + }); } private static generateId(existingCount: number): string { - const paddedNumber = String(existingCount + 1).padStart(4, "0"); - const shortUuid = randomUUID().slice(0, 8); + const paddedNumber = String(existingCount + 1).padStart(STONE_ID_PAD_LENGTH, "0"); + const shortUuid = randomUUID().slice(0, SHORT_UUID_LENGTH); return `${paddedNumber}-${shortUuid}`; } @@ -170,30 +189,45 @@ export class Stone { return this.allPackages.length === 0; } + affectsPackage(packageName: string): boolean { + return this.allPackages.includes(packageName); + } + getPackages(bump: BumpType): readonly string[] { return this._packages.get(bump) ?? []; } withMessage(message: string): Stone { - return new Stone(this.id, message, new Map(this._packages), this.tag, this.description, this.commits); + return new Stone({ ...this.toOptions(), message }); } withTag(tag: string | undefined): Stone { - return new Stone(this.id, this.message, new Map(this._packages), tag, this.description, this.commits); + return new Stone({ ...this.toOptions(), tag }); } withDescription(description: string | undefined): Stone { - return new Stone(this.id, this.message, new Map(this._packages), this.tag, description, this.commits); + return new Stone({ ...this.toOptions(), description }); } withCommits(commits: readonly CommitInfo[] | undefined): Stone { - return new Stone(this.id, this.message, new Map(this._packages), this.tag, this.description, commits); + return new Stone({ ...this.toOptions(), commits }); } withPackages(bump: BumpType, packages: readonly string[]): Stone { const newPackages = new Map(this._packages); newPackages.set(bump, packages); - return new Stone(this.id, this.message, newPackages, this.tag, this.description, this.commits); + return new Stone({ ...this.toOptions(), packages: newPackages }); + } + + private toOptions(): StoneOptions { + return { + commits: this.commits, + description: this.description, + id: this.id, + message: this.message, + packages: new Map(this._packages), + tag: this.tag, + }; } toJson(): StoneJson { diff --git a/packages/sisyphus/src/domain/index.ts b/packages/sisyphus/src/domain/index.ts index ff87f55..bff4755 100644 --- a/packages/sisyphus/src/domain/index.ts +++ b/packages/sisyphus/src/domain/index.ts @@ -1,3 +1,4 @@ +export { OTHER_COMMIT_TYPE } from "../constants"; export * from "./BumpType"; export * from "./Commit"; export * from "./helpers"; diff --git a/packages/sisyphus/src/providers/BitbucketProvider.ts b/packages/sisyphus/src/providers/BitbucketProvider.ts new file mode 100644 index 0000000..df72c6c --- /dev/null +++ b/packages/sisyphus/src/providers/BitbucketProvider.ts @@ -0,0 +1,66 @@ +import { Exit } from "@r5n/cli-core"; +import { + type CreateLabelOptions, + type CreatePrOptions, + type CreateReleaseOptions, + type FindPrOptions, + GitProvider, + type PullRequest, + type UpdatePrOptions, +} from "./GitProvider"; + +export class BitbucketProvider extends GitProvider { + readonly name = "bitbucket" as const; + + private notImplemented(): never { + throw new Exit("Bitbucket is not supported yet", "Bitbucket support is coming soon"); + } + + async ensureAvailable(): Promise { + this.notImplemented(); + } + + async getDefaultBranch(): Promise { + this.notImplemented(); + } + + async findPr(_options: FindPrOptions): Promise { + this.notImplemented(); + } + + async createPr(_options: CreatePrOptions): Promise { + this.notImplemented(); + } + + async updatePr(_number: number, _options: UpdatePrOptions): Promise { + this.notImplemented(); + } + + async getPr(_number: number): Promise { + this.notImplemented(); + } + + async getPrFromCurrentBranch(): Promise { + this.notImplemented(); + } + + async getPrCommits(_number: number): Promise { + this.notImplemented(); + } + + async getPrFiles(_number: number): Promise { + this.notImplemented(); + } + + async ensureLabelExists(_name: string, _options?: CreateLabelOptions): Promise { + this.notImplemented(); + } + + async createRelease(_options: CreateReleaseOptions): Promise { + this.notImplemented(); + } + + async deleteRelease(_tag: string): Promise { + this.notImplemented(); + } +} diff --git a/packages/sisyphus/src/providers/GitHubProvider.ts b/packages/sisyphus/src/providers/GitHubProvider.ts new file mode 100644 index 0000000..a598d71 --- /dev/null +++ b/packages/sisyphus/src/providers/GitHubProvider.ts @@ -0,0 +1,197 @@ +import { Exit } from "@r5n/cli-core"; +import { DEFAULT_BRANCH, UNKNOWN_AUTHOR } from "../constants"; +import { + type CreateLabelOptions, + type CreatePrOptions, + type CreateReleaseOptions, + type FindPrOptions, + GitProvider, + type PullRequest, + type UpdatePrOptions, +} from "./GitProvider"; + +type GitHubRestPrResponse = { + number: number; + title: string; + body: string | null; + html_url: string; + state: string; + merged: boolean; + merge_commit_sha: string | null; + user: { login: string } | null; + base: { ref: string } | null; + head: { ref: string } | null; + labels: { name: string }[] | null; +}; + +type GitHubCliPrResponse = { + number: number; + title: string; + body: string | null; + url: string; + state: string; + baseRefName: string; + headRefName: string; + author: { login: string } | null; + labels: { name: string }[] | null; + mergeCommit: { oid: string } | null; +}; + +export class GitHubProvider extends GitProvider { + readonly name = "github" as const; + + async ensureAvailable(): Promise { + try { + await Bun.$`which gh`.quiet(); + } catch { + throw new Exit("GitHub CLI (gh) is not installed", "Install from https://cli.github.com and run: gh auth login"); + } + + try { + await Bun.$`gh auth status`.quiet(); + } catch { + throw new Exit("GitHub CLI (gh) is not authenticated", "Run: gh auth login"); + } + } + + async getDefaultBranch(): Promise { + try { + const result = await Bun.$`gh repo view --json defaultBranchRef --jq .defaultBranchRef.name`.quiet(); + return result.stdout.toString().trim() || DEFAULT_BRANCH; + } catch { + return DEFAULT_BRANCH; + } + } + + async findPr(options: FindPrOptions): Promise { + try { + const optionalArgs: string[] = []; + if (options.head) optionalArgs.push("--head", options.head); + if (options.label) optionalArgs.push("--label", options.label); + + const result = + await Bun.$`gh pr list --json number,url,title,body,labels,author,headRefName,baseRefName,mergeCommit,state --limit 1 ${optionalArgs}`.quiet(); + const prs = JSON.parse(result.stdout.toString()); + + if (!prs[0]) return null; + + return this.mapPrResponse(prs[0]); + } catch { + return null; + } + } + + async createPr(options: CreatePrOptions): Promise { + const result = + await Bun.$`gh api repos/${this.owner}/${this.repo}/pulls --method POST -f head=${options.head} -f base=${options.base} -f title=${options.title} -f body=${options.body}`; + + const data = JSON.parse(result.stdout.toString()); + const prNumber = data.number as number; + + if (options.labels && options.labels.length > 0) { + const labelArgs = options.labels.flatMap((l) => ["-f", `labels[]=${l}`]); + await Bun.$`gh api repos/${this.owner}/${this.repo}/issues/${prNumber}/labels --method POST ${labelArgs}`; + } + + return this.getPr(prNumber); + } + + async updatePr(number: number, options: UpdatePrOptions): Promise { + const optionalArgs: string[] = []; + if (options.title) optionalArgs.push("--title", options.title); + if (options.body) optionalArgs.push("--body", options.body); + + await Bun.$`gh pr edit ${number} ${optionalArgs}`; + } + + async getPr(number: number): Promise { + try { + const result = await Bun.$`gh api repos/${this.owner}/${this.repo}/pulls/${number}`.quiet(); + const data = JSON.parse(result.stdout.toString()); + + return this.mapRestApiResponse(data); + } catch { + throw new Exit(`Failed to fetch PR #${number}`, "Make sure the PR exists and you have access"); + } + } + + async getPrFromCurrentBranch(): Promise { + try { + const result = + await Bun.$`gh pr view --json number,title,body,labels,author,headRefName,baseRefName,url,mergeCommit,state`.quiet(); + const data = JSON.parse(result.stdout.toString()); + + return this.mapPrResponse(data); + } catch { + throw new Exit("No PR found for current branch", "Make sure you have an open PR or provide a URL with --url"); + } + } + + async getPrCommits(number: number): Promise { + try { + const result = + await Bun.$`gh api repos/${this.owner}/${this.repo}/pulls/${number}/commits --jq '.[].sha'`.quiet(); + return result.stdout.toString().trim().split("\n").filter(Boolean); + } catch { + return []; + } + } + + async getPrFiles(number: number): Promise { + try { + const result = + await Bun.$`gh api repos/${this.owner}/${this.repo}/pulls/${number}/files --jq '.[].filename'`.quiet(); + return result.stdout.toString().trim().split("\n").filter(Boolean); + } catch { + return []; + } + } + + async ensureLabelExists(name: string, options?: CreateLabelOptions): Promise { + try { + const description = options?.description ?? ""; + const colorArgs = options?.color ? ["--color", options.color] : []; + await Bun.$`gh label create ${name} --description ${description} ${colorArgs} --force`.quiet(); + } catch {} + } + + async createRelease(options: CreateReleaseOptions): Promise { + await Bun.$`gh release create ${options.tag} --title ${options.title} --notes ${options.notes}`; + } + + async deleteRelease(tag: string): Promise { + try { + await Bun.$`gh release delete ${tag} --yes`.quiet(); + } catch {} + } + + private mapRestApiResponse(data: GitHubRestPrResponse): PullRequest { + return { + author: data.user?.login ?? UNKNOWN_AUTHOR, + baseBranch: data.base?.ref ?? DEFAULT_BRANCH, + body: data.body ?? "", + headBranch: data.head?.ref ?? "", + labels: data.labels?.map((l) => l.name) ?? [], + mergeCommitSha: data.merge_commit_sha, + merged: data.merged, + number: data.number, + title: data.title, + url: data.html_url, + }; + } + + private mapPrResponse(data: GitHubCliPrResponse): PullRequest { + return { + author: data.author?.login ?? UNKNOWN_AUTHOR, + baseBranch: data.baseRefName ?? DEFAULT_BRANCH, + body: data.body ?? "", + headBranch: data.headRefName ?? "", + labels: data.labels?.map((l) => l.name) ?? [], + mergeCommitSha: data.mergeCommit?.oid ?? null, + merged: data.state === "MERGED", + number: data.number, + title: data.title, + url: data.url, + }; + } +} diff --git a/packages/sisyphus/src/providers/GitLabProvider.ts b/packages/sisyphus/src/providers/GitLabProvider.ts new file mode 100644 index 0000000..0c49096 --- /dev/null +++ b/packages/sisyphus/src/providers/GitLabProvider.ts @@ -0,0 +1,175 @@ +import { Exit } from "@r5n/cli-core"; +import { DEFAULT_BRANCH, UNKNOWN_AUTHOR } from "../constants"; +import { + type CreateLabelOptions, + type CreatePrOptions, + type CreateReleaseOptions, + type FindPrOptions, + GitProvider, + type PullRequest, + type UpdatePrOptions, +} from "./GitProvider"; + +type GitLabMrResponse = { + iid: number; + title: string; + description: string | null; + web_url: string; + state: string; + target_branch: string; + source_branch: string; + merge_commit_sha: string | null; + author: { username: string } | null; + labels: string[]; +}; + +type GitLabChangesResponse = { changes: { new_path: string }[] }; + +export class GitLabProvider extends GitProvider { + readonly name = "gitlab" as const; + + async ensureAvailable(): Promise { + try { + await Bun.$`which glab`.quiet(); + } catch { + throw new Exit( + "GitLab CLI (glab) is not installed", + "Install from https://gitlab.com/gitlab-org/cli and run: glab auth login", + ); + } + + try { + await Bun.$`glab auth status`.quiet(); + } catch { + throw new Exit("GitLab CLI (glab) is not authenticated", "Run: glab auth login"); + } + } + + async getDefaultBranch(): Promise { + try { + const result = await Bun.$`glab repo view --output json`.quiet(); + const data = JSON.parse(result.stdout.toString()); + return data.default_branch || DEFAULT_BRANCH; + } catch { + return DEFAULT_BRANCH; + } + } + + async findPr(options: FindPrOptions): Promise { + try { + const optionalArgs: string[] = []; + if (options.head) optionalArgs.push("--source-branch", options.head); + if (options.label) optionalArgs.push("--label", options.label); + + const result = await Bun.$`glab mr list --output json --per-page 1 ${optionalArgs}`.quiet(); + const mrs = JSON.parse(result.stdout.toString()); + + if (!mrs[0]) return null; + + return this.mapMrResponse(mrs[0]); + } catch { + return null; + } + } + + async createPr(options: CreatePrOptions): Promise { + const labelArgs = options.labels?.flatMap((l) => ["--label", l]) ?? []; + + const result = + await Bun.$`glab mr create --source-branch ${options.head} --target-branch ${options.base} --title ${options.title} --description ${options.body} ${labelArgs} --yes`; + + const iid = this.extractMrNumber(result.stdout.toString()); + return this.getPr(iid); + } + + async updatePr(number: number, options: UpdatePrOptions): Promise { + const optionalArgs: string[] = []; + if (options.title) optionalArgs.push("--title", options.title); + if (options.body) optionalArgs.push("--description", options.body); + + await Bun.$`glab mr update ${number} ${optionalArgs}`; + } + + async getPr(number: number): Promise { + try { + const result = await Bun.$`glab mr view ${number} --output json`.quiet(); + return this.mapMrResponse(JSON.parse(result.stdout.toString())); + } catch { + throw new Exit(`Failed to fetch MR !${number}`, "Make sure the MR exists and you have access"); + } + } + + async getPrFromCurrentBranch(): Promise { + try { + const result = await Bun.$`glab mr view --output json`.quiet(); + return this.mapMrResponse(JSON.parse(result.stdout.toString())); + } catch { + throw new Exit("No MR found for current branch", "Make sure you have an open MR or provide a URL with --url"); + } + } + + async getPrCommits(number: number): Promise { + try { + const result = + await Bun.$`glab api projects/${this.owner}%2F${this.repo}/merge_requests/${number}/commits`.quiet(); + const commits = JSON.parse(result.stdout.toString()); + return commits.map((c: { id: string }) => c.id); + } catch { + return []; + } + } + + async getPrFiles(number: number): Promise { + try { + const result = + await Bun.$`glab api projects/${this.owner}%2F${this.repo}/merge_requests/${number}/changes`.quiet(); + const data: GitLabChangesResponse = JSON.parse(result.stdout.toString()); + return data.changes?.map((c) => c.new_path) ?? []; + } catch { + return []; + } + } + + async ensureLabelExists(name: string, options?: CreateLabelOptions): Promise { + try { + const description = options?.description ?? ""; + const colorArgs = options?.color ? ["--color", `#${options.color}`] : []; + await Bun.$`glab label create --name ${name} --description ${description} ${colorArgs}`.quiet(); + } catch {} + } + + async createRelease(options: CreateReleaseOptions): Promise { + await Bun.$`glab release create ${options.tag} --name ${options.title} --notes ${options.notes}`; + } + + async deleteRelease(tag: string): Promise { + try { + await Bun.$`glab release delete ${tag} --yes`.quiet(); + } catch {} + } + + private extractMrNumber(output: string): number { + const bangMatch = output.match(/!(\d+)/); + if (bangMatch?.[1]) return Number.parseInt(bangMatch[1], 10); + + const urlMatch = output.match(/merge_requests\/(\d+)/); + if (urlMatch?.[1]) return Number.parseInt(urlMatch[1], 10); + + throw new Exit("Failed to parse MR number from output", output); + } + + private mapMrResponse(data: GitLabMrResponse): PullRequest { + return { + author: data.author?.username ?? UNKNOWN_AUTHOR, + baseBranch: data.target_branch ?? DEFAULT_BRANCH, + body: data.description ?? "", + headBranch: data.source_branch ?? "", + labels: data.labels ?? [], + mergeCommitSha: data.merge_commit_sha, + merged: data.state === "merged", + number: data.iid, + title: data.title, + url: data.web_url, + }; + } +} diff --git a/packages/sisyphus/src/providers/GitProvider.ts b/packages/sisyphus/src/providers/GitProvider.ts new file mode 100644 index 0000000..564efab --- /dev/null +++ b/packages/sisyphus/src/providers/GitProvider.ts @@ -0,0 +1,59 @@ +export type MergeMethod = "merge" | "squash" | "rebase"; + +export type PullRequest = { + number: number; + url: string; + title: string; + body: string; + labels: string[]; + author: string; + headBranch: string; + baseBranch: string; + merged: boolean; + mergeCommitSha: string | null; +}; + +export type CreatePrOptions = { head: string; base: string; title: string; body: string; labels?: string[] }; + +export type UpdatePrOptions = { title?: string; body?: string }; + +export type FindPrOptions = { head?: string; label?: string }; + +export type CreateLabelOptions = { description?: string; color?: string }; + +export type CreateReleaseOptions = { tag: string; title: string; notes: string }; + +export type Provider = "github" | "gitlab" | "bitbucket"; + +export type RemoteInfo = { provider: Provider; owner: string; repo: string }; + +export type PrUrlInfo = { provider: Provider; owner: string; repo: string; number: number }; + +export abstract class GitProvider { + abstract readonly name: Provider; + + protected owner: string; + protected repo: string; + + constructor(info: RemoteInfo) { + this.owner = info.owner; + this.repo = info.repo; + } + + abstract ensureAvailable(): Promise; + + abstract getDefaultBranch(): Promise; + + abstract findPr(options: FindPrOptions): Promise; + abstract createPr(options: CreatePrOptions): Promise; + abstract updatePr(number: number, options: UpdatePrOptions): Promise; + abstract getPr(number: number): Promise; + abstract getPrFromCurrentBranch(): Promise; + abstract getPrCommits(number: number): Promise; + abstract getPrFiles(number: number): Promise; + + abstract ensureLabelExists(name: string, options?: CreateLabelOptions): Promise; + + abstract createRelease(options: CreateReleaseOptions): Promise; + abstract deleteRelease(tag: string): Promise; +} diff --git a/packages/sisyphus/src/providers/index.ts b/packages/sisyphus/src/providers/index.ts new file mode 100644 index 0000000..9c798c2 --- /dev/null +++ b/packages/sisyphus/src/providers/index.ts @@ -0,0 +1,107 @@ +import { Exit } from "@r5n/cli-core"; +import { BitbucketProvider } from "./BitbucketProvider"; +import { GitHubProvider } from "./GitHubProvider"; +import { GitLabProvider } from "./GitLabProvider"; +import type { Provider, PrUrlInfo, RemoteInfo } from "./GitProvider"; + +export { BitbucketProvider } from "./BitbucketProvider"; +export { GitHubProvider } from "./GitHubProvider"; +export { GitLabProvider } from "./GitLabProvider"; +export { + GitProvider, + type MergeMethod, + type Provider, + type PrUrlInfo, + type PullRequest, + type RemoteInfo, +} from "./GitProvider"; + +const GITHUB_PATTERN = /github\.com[:/]([^/]+)\/([^/.]+)/; +const GITLAB_PATTERN = /gitlab\.com[:/]([^/]+)\/([^/.]+)/; +const BITBUCKET_PATTERN = /bitbucket\.org[:/]([^/]+)\/([^/.]+)/; + +const GITHUB_PR_URL_PATTERN = /github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/; +const GITLAB_MR_URL_PATTERN = /gitlab\.com\/([^/]+)\/([^/]+)\/-\/merge_requests\/(\d+)/; +const BITBUCKET_PR_URL_PATTERN = /bitbucket\.org\/([^/]+)\/([^/]+)\/pull-requests\/(\d+)/; + +export async function createGitProvider() { + const info = await detectRemoteInfo(); + + if (!info) { + throw new Exit("Could not detect git provider", "Make sure you have a git remote configured (origin)"); + } + + switch (info.provider) { + case "github": + return new GitHubProvider(info); + case "gitlab": + return new GitLabProvider(info); + case "bitbucket": + return new BitbucketProvider(info); + default: + throw new Exit(`Unsupported provider: ${info.provider}`); + } +} + +async function detectRemoteInfo(): Promise { + const url = await getRemoteUrl(); + if (!url) return null; + return parseRemoteUrl(url); +} + +export async function detectProvider(): Promise { + const info = await detectRemoteInfo(); + if (!info) { + throw new Exit("Could not detect git provider", "Make sure you have a git remote configured (origin)"); + } + return info.provider; +} + +async function getRemoteUrl(): Promise { + try { + const result = await Bun.$`git remote get-url origin`.quiet(); + return result.stdout.toString().trim() || null; + } catch { + return null; + } +} + +function parseRemoteUrl(url: string): RemoteInfo | null { + const patterns: [RegExp, Provider][] = [ + [GITHUB_PATTERN, "github"], + [GITLAB_PATTERN, "gitlab"], + [BITBUCKET_PATTERN, "bitbucket"], + ]; + + for (const [pattern, provider] of patterns) { + const match = url.match(pattern); + if (!match) continue; + + const [, owner, repo] = match; + if (owner && repo) { + return { owner, provider, repo }; + } + } + + return null; +} + +export function parsePrUrl(url: string): PrUrlInfo | null { + const patterns: [RegExp, Provider][] = [ + [GITHUB_PR_URL_PATTERN, "github"], + [GITLAB_MR_URL_PATTERN, "gitlab"], + [BITBUCKET_PR_URL_PATTERN, "bitbucket"], + ]; + + for (const [pattern, provider] of patterns) { + const match = url.match(pattern); + if (!match) continue; + + const [, owner, repo, number] = match; + if (owner && repo && number) { + return { number: Number.parseInt(number, 10), owner, provider, repo }; + } + } + + return null; +} diff --git a/packages/sisyphus/src/services/ChangelogGenerator.ts b/packages/sisyphus/src/services/ChangelogGenerator.ts index 8aedf92..a5e0ef1 100644 --- a/packages/sisyphus/src/services/ChangelogGenerator.ts +++ b/packages/sisyphus/src/services/ChangelogGenerator.ts @@ -121,12 +121,10 @@ export class ChangelogGenerator { } private formatEntry(stones: Stone[], pkg: Package, bumpedDependencies: Package[]): string { - const version = pkg.newVersion ?? pkg.version; - const date = this.getDate(); - const emoji = this.getEmoji(pkg); + const header = this.formatPackageHeader(pkg); const hasDependencyChanges = bumpedDependencies.length > 0; - const lines = [`## ${emoji} ${version} (${date})`]; + const lines = [`## ${header}`]; for (const stone of stones) { lines.push("", `### ${BULLET_POINT} ${stone.message}`); @@ -144,9 +142,9 @@ export class ChangelogGenerator { } private formatRootEntry(stones: Stone[], packages: Package[]): string { - const date = this.getDate(); + const header = this.formatRootHeader(packages); - const lines = [`## ${date}`]; + const lines = [`## ${header}`]; lines.push("", "**Packages**"); for (const pkg of packages) { @@ -164,6 +162,21 @@ export class ChangelogGenerator { return lines.join("\n"); } + private formatPackageHeader(pkg: Package): string { + const version = pkg.newVersion ?? pkg.version; + const date = this.getDate(); + const emoji = this.getEmoji(pkg); + + return this.config.packageHeader.replace("{emoji}", emoji).replace("{version}", version).replace("{date}", date); + } + + private formatRootHeader(packages: Package[]): string { + const date = this.getDate(); + const packageList = packages.map((p) => `${p.name}@${p.newVersion ?? p.version}`).join(", "); + + return this.config.rootHeader.replace("{date}", date).replace("{packages}", packageList); + } + private formatRootStoneContent(stone: Stone): string { const parts: string[] = []; diff --git a/packages/sisyphus/src/services/CommitAnalyzer.ts b/packages/sisyphus/src/services/CommitAnalyzer.ts index 980519d..3d2b7dc 100644 --- a/packages/sisyphus/src/services/CommitAnalyzer.ts +++ b/packages/sisyphus/src/services/CommitAnalyzer.ts @@ -1,8 +1,9 @@ -import type { ConfigManager } from "@r5n/cli-core"; -import { COMMIT_TYPE_ORDER, COMMIT_TYPE_ORDER_FALLBACK } from "../constants"; -import { BumpType, Commit, type CommitInfo, OTHER_COMMIT_TYPE } from "../domain"; -import type { SisyphusConfig } from "../types"; -import { buildPackagePathMap, findAffectedPackages } from "../utils"; +import { type ConfigManager, color, log } from "@r5n/cli-core"; +import { COMMIT_TYPE_ORDER, COMMIT_TYPE_ORDER_FALLBACK, SISYPHUS_DEFAULT_CONFIG } from "../constants"; +import { BumpType, Commit, type CommitInfo, OTHER_COMMIT_TYPE, type Package, type StoneData } from "../domain"; +import type { CommitsSkipConfig, SisyphusConfig } from "../types"; +import { buildPackagePathMap, findAffectedPackages, findDependencyPackages } from "../utils"; +import { StoneManager } from "./StoneManager"; import { WorkspaceScanner } from "./WorkspaceScanner"; const COMMIT_TYPE_TO_BUMP: Record = { @@ -24,15 +25,43 @@ export type CommitGroup = { message: string; bump: BumpType; packages: Set) {} + private stoneManager: StoneManager; + + constructor(private config: ConfigManager) { + this.stoneManager = new StoneManager(config); + } async analyze(options: AnalyzeOptions = {}): Promise { - const commits = await this.getCommitsSinceLastRelease(); + let commits = await this.getCommitsSinceLastRelease(); + if (commits.length === 0) return []; + + const trackedHashes = await this.stoneManager.getAllTrackedCommitHashes(); + if (trackedHashes.size > 0) { + commits = commits.filter((c) => !trackedHashes.has(c.shortHash)); + if (commits.length === 0) return []; + } + + const skipConfig = this.config.get("commits")?.skip ?? SISYPHUS_DEFAULT_CONFIG.commits.skip; + commits = commits.filter((c) => !this.shouldSkipCommit(c, skipConfig)); if (commits.length === 0) return []; return this.groupByPackage(commits, options); } + private shouldSkipCommit(commit: Commit, skip: CommitsSkipConfig): boolean { + if (skip.authors.includes(commit.author)) return true; + + for (const pattern of skip.messagePatterns) { + try { + if (new RegExp(pattern, "i").test(commit.subject)) return true; + } catch { + log.warn(color.yellow(`Invalid regex pattern in commits.skip.messagePatterns: "${pattern}"`)); + } + } + + return false; + } + get commitCount(): Promise { return this.getCommitsSinceLastRelease().then((c) => c.length); } @@ -83,4 +112,18 @@ export class CommitAnalyzer { if (commit.breaking) return sections.breaking; return sections[commit.type as keyof typeof sections] ?? `${commit.type} updates`; } + + static buildStoneData(group: CommitGroup, packages: Map, tag?: string): StoneData { + const pkgNames = Array.from(group.packages); + const commits = group.commits.length > 0 ? group.commits : undefined; + const deps = findDependencyPackages(pkgNames, packages); + + return { + [group.bump]: pkgNames, + commits, + dependency: deps.length > 0 ? deps : undefined, + message: group.message, + tag, + }; + } } diff --git a/packages/sisyphus/src/services/GitRemoteParser.ts b/packages/sisyphus/src/services/GitRemoteParser.ts index 283fd26..cb0e6da 100644 --- a/packages/sisyphus/src/services/GitRemoteParser.ts +++ b/packages/sisyphus/src/services/GitRemoteParser.ts @@ -47,11 +47,11 @@ export class GitRemoteParser { for (const [pattern, provider] of patterns) { const match = url.match(pattern); - if (match) { - const [, owner, repo] = match; - if (owner && repo) { - return this.createRemoteInfo(provider, owner, repo); - } + if (!match) continue; + + const [, owner, repo] = match; + if (owner && repo) { + return this.createRemoteInfo(provider, owner, repo); } } diff --git a/packages/sisyphus/src/services/PullRequestAnalyzer.ts b/packages/sisyphus/src/services/PullRequestAnalyzer.ts index 201f74d..9da20c8 100644 --- a/packages/sisyphus/src/services/PullRequestAnalyzer.ts +++ b/packages/sisyphus/src/services/PullRequestAnalyzer.ts @@ -1,12 +1,18 @@ import type { ConfigManager } from "@r5n/cli-core"; import { Exit } from "@r5n/cli-core"; +import { OTHER_COMMIT_TYPE, SHORT_HASH_LENGTH, UNKNOWN_HASH } from "../constants"; import { BumpType, Commit, type CommitInfo } from "../domain"; +import { createGitProvider, type GitProvider, type MergeMethod, type PullRequest, parsePrUrl } from "../providers"; import type { SisyphusConfig } from "../types"; import { buildPackagePathMap, findAffectedPackages } from "../utils"; import { WorkspaceScanner } from "./WorkspaceScanner"; -const GITHUB_PR_URL_REGEX = /github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/; -const GITLAB_MR_URL_REGEX = /gitlab\.com\//; +type AnalysisContext = { + provider: GitProvider; + url: string | undefined; + packagePaths: Map; + isSinglePackage: boolean; +}; export type PullRequestInfo = { number: number; @@ -17,6 +23,8 @@ export type PullRequestInfo = { labels: string[]; author: string; url: string; + merged: boolean; + mergeCommitSha: string | null; }; export type PrAnalysisResult = { @@ -27,23 +35,92 @@ export type PrAnalysisResult = { }; export class PullRequestAnalyzer { + private provider: GitProvider | null = null; + constructor(private config: ConfigManager) {} async analyze(url?: string): Promise { - await this.ensureGhAvailable(); + const provider = await this.getProvider(); + await provider.ensureAvailable(); - const pr = url ? await this.fetchFromUrl(url) : await this.fetchFromCurrentBranch(); + const pr = url ? await this.fetchFromUrl(provider, url) : await this.fetchFromCurrentBranch(provider); + + const isSinglePackage = this.config.get("single"); + const { packages } = await WorkspaceScanner.scan({ single: isSinglePackage }); + const packagePaths = buildPackagePathMap(packages); + const ctx: AnalysisContext = { isSinglePackage, packagePaths, provider, url }; + + const { commits, affectedPackages } = pr.merged + ? await this.analyzeAfterMerge(pr, ctx) + : await this.analyzeBeforeMerge(pr, ctx); + + const suggestedBump = this.inferBumpFromLabels(pr.labels) ?? this.inferBumpFromTitle(pr.title); + + return { commits, packages: affectedPackages, pr, suggestedBump }; + } + + private async analyzeBeforeMerge( + pr: PullRequestInfo, + ctx: AnalysisContext, + ): Promise<{ commits: CommitInfo[]; affectedPackages: Set }> { const parsedCommits = await Commit.inRange(pr.baseBranch, pr.branch); + return this.processCommits(parsedCommits, ctx); + } - const { packages } = await WorkspaceScanner.scan({ single: this.config.get("single") }); - const packagePaths = buildPackagePathMap(packages); + private async analyzeAfterMerge( + pr: PullRequestInfo, + ctx: AnalysisContext, + ): Promise<{ commits: CommitInfo[]; affectedPackages: Set }> { + const mergeMethod = await this.detectMergeMethod(pr.mergeCommitSha); + if (mergeMethod === "squash") { + return this.analyzeSquashMerge(pr, ctx); + } + + return this.analyzeMergeCommit(pr, ctx); + } + + private async analyzeSquashMerge( + pr: PullRequestInfo, + ctx: AnalysisContext, + ): Promise<{ commits: CommitInfo[]; affectedPackages: Set }> { + const files = ctx.url ? await this.fetchFilesFromApi(ctx.provider, ctx.url) : []; + const affectedPackages = findAffectedPackages(files, ctx.packagePaths, ctx.isSinglePackage); + + const commit: CommitInfo = { + body: pr.body || undefined, + hash: pr.mergeCommitSha?.slice(0, SHORT_HASH_LENGTH) ?? UNKNOWN_HASH, + message: pr.title, + packages: [...affectedPackages], + subject: pr.title, + type: this.inferCommitType(pr.title), + }; + + return { affectedPackages, commits: [commit] }; + } + + private async analyzeMergeCommit( + pr: PullRequestInfo, + ctx: AnalysisContext, + ): Promise<{ commits: CommitInfo[]; affectedPackages: Set }> { + if (!pr.mergeCommitSha) { + return { affectedPackages: new Set(), commits: [] }; + } + + const parsedCommits = await Commit.fromMerge(pr.mergeCommitSha); + return this.processCommits(parsedCommits, ctx); + } + + private processCommits( + parsedCommits: Commit[], + ctx: AnalysisContext, + ): { commits: CommitInfo[]; affectedPackages: Set } { const commits: CommitInfo[] = []; const affectedPackages = new Set(); for (const commit of parsedCommits) { - const pkgs = findAffectedPackages(commit.files, packagePaths, false); + const pkgs = findAffectedPackages(commit.files, ctx.packagePaths, ctx.isSinglePackage); if (pkgs.size === 0) continue; commits.push(commit.toInfo([...pkgs])); @@ -53,60 +130,72 @@ export class PullRequestAnalyzer { } } - const suggestedBump = this.inferBumpFromLabels(pr.labels) ?? this.inferBumpFromTitle(pr.title); + return { affectedPackages, commits }; + } - return { commits, packages: affectedPackages, pr, suggestedBump }; + private async detectMergeMethod(mergeCommitSha: string | null): Promise { + if (!mergeCommitSha) return "squash"; + + const parentCount = await this.getCommitParentCount(mergeCommitSha); + + if (parentCount === 2) return "merge"; + return "squash"; } - async fetchFromCurrentBranch(): Promise { + private async getCommitParentCount(sha: string): Promise { try { - const result = await Bun.$`gh pr view --json number,title,body,labels,author,headRefName,baseRefName,url`.quiet(); - const data = JSON.parse(result.stdout.toString()); - - return { - author: data.author?.login ?? "unknown", - baseBranch: data.baseRefName, - body: data.body ?? "", - branch: data.headRefName, - labels: data.labels?.map((l: { name: string }) => l.name) ?? [], - number: data.number, - title: data.title, - url: data.url, - }; + const result = await Bun.$`git rev-parse ${sha}^@ 2>/dev/null`.quiet(); + const parents = result.stdout.toString().trim().split("\n").filter(Boolean); + return parents.length; } catch { - throw new Exit("No PR found for current branch", "Make sure you have an open PR or provide a URL with --url"); + return 1; } } - async fetchFromUrl(url: string): Promise { - if (GITLAB_MR_URL_REGEX.test(url)) { - throw new Exit("GitLab is not supported yet", "Only GitHub PRs are currently supported"); - } + private inferCommitType(title: string): string { + const match = title.match(/^(\w+)(?:\(.*?\))?!?:/); + return match?.[1] ?? OTHER_COMMIT_TYPE; + } - const match = url.match(GITHUB_PR_URL_REGEX); - if (!match) { - throw new Exit("Invalid PR URL", "Expected a GitHub PR URL like https://github.com/owner/repo/pull/123"); + private async getProvider(): Promise { + if (!this.provider) { + this.provider = await createGitProvider(); } + return this.provider; + } - const [, owner, repo, number] = match; + private async fetchFilesFromApi(provider: GitProvider, url: string): Promise { + const urlInfo = parsePrUrl(url); + if (!urlInfo) return []; - try { - const result = await Bun.$`gh api repos/${owner}/${repo}/pulls/${number}`.quiet(); - const data = JSON.parse(result.stdout.toString()); - - return { - author: data.user?.login ?? "unknown", - baseBranch: data.base?.ref ?? "main", - body: data.body ?? "", - branch: data.head?.ref ?? "", - labels: data.labels?.map((l: { name: string }) => l.name) ?? [], - number: data.number, - title: data.title, - url: data.html_url, - }; - } catch { - throw new Exit(`Failed to fetch PR #${number}`, "Make sure the PR exists and you have access"); + return provider.getPrFiles(urlInfo.number); + } + + private async fetchFromCurrentBranch(provider: GitProvider): Promise { + const pr = await provider.getPrFromCurrentBranch(); + return this.mapPullRequest(pr); + } + + private async fetchFromUrl(provider: GitProvider, url: string): Promise { + const urlInfo = parsePrUrl(url); + + if (!urlInfo) { + throw new Exit("Invalid PR URL", "Expected a PR/MR URL from GitHub, GitLab, or Bitbucket"); + } + + if (urlInfo.provider !== provider.name) { + throw new Exit( + `PR URL is from ${urlInfo.provider}, but repository is on ${provider.name}`, + "Make sure the PR URL matches the repository provider", + ); } + + const pr = await provider.getPr(urlInfo.number); + return this.mapPullRequest(pr); + } + + private mapPullRequest({ headBranch, ...pr }: PullRequest): PullRequestInfo { + return { ...pr, branch: headBranch }; } inferBumpFromLabels(labels: string[]): BumpType | null { @@ -142,18 +231,4 @@ export class PullRequestAnalyzer { return null; } - - private async ensureGhAvailable(): Promise { - try { - await Bun.$`which gh`.quiet(); - } catch { - throw new Exit("GitHub CLI (gh) is not installed", "Install from https://cli.github.com and run: gh auth login"); - } - - try { - await Bun.$`gh auth status`.quiet(); - } catch { - throw new Exit("GitHub CLI (gh) is not authenticated", "Run: gh auth login"); - } - } } diff --git a/packages/sisyphus/src/services/ReleaseOrchestrator.ts b/packages/sisyphus/src/services/ReleaseOrchestrator.ts index 333b66d..2915881 100644 --- a/packages/sisyphus/src/services/ReleaseOrchestrator.ts +++ b/packages/sisyphus/src/services/ReleaseOrchestrator.ts @@ -1,6 +1,8 @@ import { dirname, join } from "node:path"; -import { type ConfigManager, Exit } from "@r5n/cli-core"; +import { type ConfigManager, color, Exit, log } from "@r5n/cli-core"; +import { DEFAULT_NPM_TAG } from "../constants"; import type { CommitInfo, Package, Stone } from "../domain"; +import { createGitProvider, type GitProvider } from "../providers"; import type { SisyphusConfig } from "../types"; import { ChangelogGenerator } from "./ChangelogGenerator"; import { GitRemoteParser } from "./GitRemoteParser"; @@ -8,8 +10,8 @@ import { PackageUpdater } from "./PackageUpdater"; export type ReleaseOptions = { changelog: boolean; + createRelease: boolean; dryRun: boolean; - github: boolean; npm: boolean; push: boolean; tags: boolean; @@ -19,6 +21,7 @@ export class ReleaseOrchestrator { private packageUpdater = new PackageUpdater(); private changelogGenerator: ChangelogGenerator; private remoteParser = new GitRemoteParser(); + private provider: GitProvider | null = null; private commitUrlFn: ((hash: string) => string) | null = null; private options: ReleaseOptions; private createdTags: string[] = []; @@ -34,6 +37,13 @@ export class ReleaseOrchestrator { this.changelogGenerator = new ChangelogGenerator(this.config.get("changelog")); } + private async getProvider(): Promise { + if (!this.provider) { + this.provider = await createGitProvider(); + } + return this.provider; + } + private async initCommitLinks() { if (this.commitUrlFn !== null) return; const remoteInfo = await this.remoteParser.getRemoteInfo(); @@ -73,11 +83,20 @@ export class ReleaseOrchestrator { const allFiles = [...files, ...changelogFiles, sisyphusDir]; const message = this.formatCommitMessage(stone, packages); + const authorArg = this.getCommitAuthorArg(); + await this.run(() => Bun.$`git add -A -- ${allFiles}`.quiet(), "Failed to stage files"); - await this.run(() => Bun.$`git commit -m ${message}`.quiet(), "Failed to create commit"); + await this.run(() => Bun.$`git commit ${authorArg} -m ${message}`.quiet(), "Failed to create commit"); this.commitCreated = true; } + private getCommitAuthorArg(): string[] { + const { author, email } = this.config.get("commit"); + if (!author) return []; + const authorString = email ? `${author} <${email}>` : author; + return ["--author", authorString]; + } + private getChangelogFiles(packages: Package[]): string[] { const filename = this.config.get("changelog").filename; const files = packages.map((pkg) => join(dirname(pkg.file), filename)); @@ -93,10 +112,8 @@ export class ReleaseOrchestrator { if (this.options.dryRun) return; for (const pkg of packages) { - const newVersion = pkg.newVersion; - if (!newVersion) continue; - - const tagName = `${pkg.name}@${newVersion}`; + const version = pkg.newVersion ?? pkg.version; + const tagName = `${pkg.name}@${version}`; await this.run(() => Bun.$`git tag ${tagName}`.quiet(), `Failed to create tag ${tagName}`); this.createdTags.push(tagName); } @@ -116,59 +133,80 @@ export class ReleaseOrchestrator { await this.run(() => Bun.$`git push`.quiet(), "Failed to push commits"); if (this.createdTags.length > 0) { - await this.run(() => Bun.$`git push --tags`.quiet(), "Failed to push tags"); + await this.pushTags(); } this.pushedToRemote = true; } - async createGithubRelease(stone: Stone, packages: Package[]) { + async pushTags() { + if (this.options.dryRun) return; + if (this.createdTags.length === 0) return; + + await this.run(() => Bun.$`git push --tags`.quiet(), "Failed to push tags"); + } + + async createGitRelease(stones: Stone[], packages: Package[]) { if (this.options.dryRun) return; await this.initCommitLinks(); + const provider = await this.getProvider(); for (const pkg of packages) { - if (!pkg.newVersion) continue; - - const tagName = `${pkg.name}@${pkg.newVersion}`; - const title = `${pkg.name} v${pkg.newVersion}`; - const notes = this.formatReleaseNotes(stone, pkg); + const version = pkg.newVersion ?? pkg.version; + const tagName = `${pkg.name}@${version}`; + const title = `${pkg.name} v${version}`; + const relevantStones = this.filterStonesForPackage(stones, pkg.name); + const notes = this.formatReleaseNotes(relevantStones, pkg); await this.run( - () => Bun.$`gh release create ${tagName} --title ${title} --notes ${notes}`.quiet(), - `Failed to create GitHub release for ${tagName}`, + () => provider.createRelease({ notes, tag: tagName, title }), + `Failed to create release for ${tagName}`, ); this.createdReleases.push(tagName); } } - private formatReleaseNotes(stone: Stone, pkg: Package): string { + private filterStonesForPackage(stones: Stone[], packageName: string): Stone[] { + return stones.filter((s) => s.affectsPackage(packageName)); + } + + private formatReleaseNotes(stones: Stone[], pkg: Package): string { const lines: string[] = []; + const version = pkg.newVersion ?? pkg.version; - lines.push(`## ${stone.message}`); - lines.push(""); + lines.push(`\`${pkg.name}\` ${pkg.version} → ${version}`); - if (stone.description) { - lines.push(stone.description); - lines.push(""); - } + if (stones.length === 0) return lines.join("\n"); - lines.push(`**Package:** \`${pkg.name}\``); - lines.push(`**Version:** ${pkg.version} → ${pkg.newVersion}`); + lines.push(""); + lines.push("
"); + lines.push(`Stones (${stones.length})`); + lines.push(""); - const commits = this.filterCommitsForPackage(stone.commits, pkg.name); - if (commits.length > 0) { + for (const stone of stones) { + lines.push(`### ${stone.message}`); lines.push(""); - lines.push("
"); - lines.push(`Commits (${commits.length})`); - lines.push(""); - for (const commit of commits) { - lines.push(this.formatCommitLine(commit)); + if (stone.description) { + lines.push(stone.description); + lines.push(""); + } + const commits = this.filterCommitsForPackage(stone.commits, pkg.name); + if (commits.length > 0) { + lines.push("
"); + lines.push(`Commits (${commits.length})`); + lines.push(""); + for (const commit of commits) { + lines.push(this.formatCommitLine(commit)); + } + lines.push(""); + lines.push("
"); + lines.push(""); } - lines.push(""); - lines.push("
"); } + lines.push("
"); + return lines.join("\n"); } @@ -185,10 +223,11 @@ export class ReleaseOrchestrator { } async rollback() { - for (const release of this.createdReleases) { - try { - await Bun.$`gh release delete ${release} --yes`.quiet(); - } catch {} + if (this.createdReleases.length > 0) { + const provider = await this.getProvider(); + for (const release of this.createdReleases) { + await provider.deleteRelease(release); + } } this.createdReleases = []; @@ -196,21 +235,27 @@ export class ReleaseOrchestrator { for (const tag of this.createdTags) { try { await Bun.$`git push origin --delete ${tag}`.quiet(); - } catch {} + } catch (error) { + log.warn(color.dim(`Failed to delete remote tag "${tag}": ${error}`)); + } } } for (const tag of this.createdTags) { try { await Bun.$`git tag -d ${tag}`.quiet(); - } catch {} + } catch (error) { + log.warn(color.dim(`Failed to delete local tag "${tag}": ${error}`)); + } } this.createdTags = []; if (this.commitCreated) { try { await Bun.$`git reset HEAD~1`.quiet(); - } catch {} + } catch (error) { + log.warn(color.dim(`Failed to reset commit: ${error}`)); + } this.commitCreated = false; } @@ -219,10 +264,7 @@ export class ReleaseOrchestrator { } private async publishPackage(pkg: Package) { - const newVersion = pkg.newVersion; - if (!newVersion) return; - - const tag = this.config.get("tag") || "latest"; + const tag = this.config.get("tag") || DEFAULT_NPM_TAG; const pkgDir = dirname(pkg.file); await this.run(() => Bun.$`bun run build`.cwd(pkgDir).quiet(), `Failed to build ${pkg.name}`); @@ -234,7 +276,7 @@ export class ReleaseOrchestrator { private formatCommitMessage(stone: Stone, packages: Package[]): string { const template = this.config.get("commit").message; - const packageList = packages.map((pkg) => `- ${pkg.name}@${pkg.newVersion}`).join("\n"); + const packageList = packages.map((pkg) => `- ${pkg.name}@${pkg.newVersion ?? pkg.version}`).join("\n"); const subject = template .replace("{message}", () => stone.message) diff --git a/packages/sisyphus/src/services/StoneManager.ts b/packages/sisyphus/src/services/StoneManager.ts index e3dd0d7..96b3dda 100644 --- a/packages/sisyphus/src/services/StoneManager.ts +++ b/packages/sisyphus/src/services/StoneManager.ts @@ -1,17 +1,24 @@ import { existsSync } from "node:fs"; -import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises"; +import { mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import type { ConfigManager } from "@r5n/cli-core"; -import { DEFAULT_CONFIG_DIR, DEFAULT_STONES_DIR } from "../constants"; +import { type ConfigManager, color, log } from "@r5n/cli-core"; +import { DEFAULT_CONFIG_DIR, DEFAULT_RELEASED_DIR, DEFAULT_STONES_DIR } from "../constants"; import { Stone, type StoneData, type StoneJson } from "../domain"; import type { SisyphusConfig } from "../types"; export class StoneManager { constructor(private config: ConfigManager) {} + private get sisyphusDir(): string { + return this.config.get("sisyphusDir") || DEFAULT_CONFIG_DIR; + } + private get stonesPath(): string { - const cfg = this.config.getAll(); - return cfg.stonesPath || join(cfg.sisyphusDir || DEFAULT_CONFIG_DIR, DEFAULT_STONES_DIR); + return this.config.get("stonesPath") || join(this.sisyphusDir, DEFAULT_STONES_DIR); + } + + private get releasedPath(): string { + return join(this.sisyphusDir, DEFAULT_RELEASED_DIR); } async list(): Promise { @@ -100,10 +107,77 @@ export class StoneManager { return ids.length; } + async archive(stones: Stone[]): Promise { + if (stones.length === 0) return ""; + + const timestamp = this.createTimestamp(); + const archiveDir = join(this.releasedPath, timestamp); + + await mkdir(archiveDir, { recursive: true }); + + for (const stone of stones) { + const sourcePath = this.getFilePath(stone.id); + const destPath = join(archiveDir, `${stone.id}.json`); + + if (existsSync(sourcePath)) { + await rename(sourcePath, destPath); + this.removeFromConfigStones(stone.id); + } + } + + return timestamp; + } + + async getReleasedStones(timestamp: string): Promise { + const archiveDir = join(this.releasedPath, timestamp); + if (!existsSync(archiveDir)) return []; + + const files = await readdir(archiveDir); + const stones: Stone[] = []; + + for (const file of files) { + if (!file.endsWith(".json")) continue; + + try { + const content = await readFile(join(archiveDir, file), "utf-8"); + const json: StoneJson = JSON.parse(content); + stones.push(Stone.fromJson(json)); + } catch (error) { + log.warn(color.dim(`Failed to parse stone "${file}": ${error}`)); + } + } + + return stones; + } + + async listReleasedTimestamps(): Promise { + if (!existsSync(this.releasedPath)) return []; + + const entries = await readdir(this.releasedPath, { withFileTypes: true }); + return entries.filter((e) => e.isDirectory()).map((e) => e.name); + } + + async getAllTrackedCommitHashes(): Promise> { + const allStones = await this.listAllStones(); + const hashes = allStones.flatMap((stone) => stone.commits ?? []).map((commit) => commit.hash); + return new Set(hashes); + } + + private async listAllStones(): Promise { + const pending = await this.list(); + const timestamps = await this.listReleasedTimestamps(); + const released = await Promise.all(timestamps.map((t) => this.getReleasedStones(t))); + return [...pending, ...released.flat()]; + } + getFilePath(id: string): string { return join(this.stonesPath, `${id}.json`); } + private createTimestamp(): string { + return new Date().toISOString().replace(/[:.]/g, "-"); + } + private async ensureStorageExists(): Promise { if (!existsSync(this.stonesPath)) { await mkdir(this.stonesPath, { recursive: true }); diff --git a/packages/sisyphus/src/types.ts b/packages/sisyphus/src/types.ts index 21ced42..b3a7171 100644 --- a/packages/sisyphus/src/types.ts +++ b/packages/sisyphus/src/types.ts @@ -2,7 +2,11 @@ export type LastStone = { commit: string; date: string }; export type CommitConfig = { author: string; email?: string; message: string }; -export type ReleaseConfig = { github: boolean; npm: boolean; push: boolean; tags: boolean }; +export type ReleaseConfig = { createRelease: boolean; npm: boolean; push: boolean; tags: boolean }; + +export type PackageRelease = { oldVersion: string; newVersion: string }; + +export type CurrentRelease = { packages: Record; stoneIds: string[]; timestamp: string }; export type ChangelogSections = { breaking: string; @@ -24,7 +28,9 @@ export type ChangelogConfig = { append: boolean; filename: string; generate: boolean; + packageHeader: string; root: boolean; + rootHeader: string; sections: ChangelogSections; template?: string; }; @@ -33,12 +39,20 @@ export type ScriptsConfig = { pre: Record; post: Record; -export type PrConfig = { labelMapping: PrLabelMapping }; +export type PrSkipConfig = { labels: string[]; authors: string[]; titlePatterns: string[] }; + +export type PrConfig = { labelMapping: PrLabelMapping; skip: PrSkipConfig }; + +export type CommitsSkipConfig = { authors: string[]; messagePatterns: string[] }; + +export type CommitsConfig = { skip: CommitsSkipConfig }; export type SisyphusConfig = { $schema: string; changelog: ChangelogConfig; commit: CommitConfig; + commits: CommitsConfig; + currentRelease?: CurrentRelease; sisyphusDir: string; ignore: string[]; lastStone: LastStone;