From cc017e15bcc6aa3e3b36c1d8ea02d35486a0fd8c Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Wed, 11 Mar 2026 15:27:39 +0100 Subject: [PATCH 01/38] feat(sisyphus): add actions command for GitHub Actions integration Add `sis actions init` and `sis actions release-pr` commands for automating releases via GitHub Actions. Includes PR skip configuration and shared logic refactoring (Stone.mergeAll, Package.applyStone). --- packages/sisyphus/src/cli.ts | 2 + .../sisyphus/src/commands/actions/index.ts | 12 + .../sisyphus/src/commands/actions/init.ts | 140 +++++++++++ .../src/commands/actions/release-pr.ts | 233 ++++++++++++++++++ .../actions/workflows/sis-create-stone.yml | 36 +++ .../actions/workflows/sis-release-pr.yml | 28 +++ .../actions/workflows/sis-release.yml | 29 +++ packages/sisyphus/src/commands/index.ts | 1 + packages/sisyphus/src/commands/pr.ts | 28 +++ packages/sisyphus/src/commands/roll.ts | 30 +-- packages/sisyphus/src/constants.ts | 5 + packages/sisyphus/src/domain/Commit.ts | 15 ++ packages/sisyphus/src/domain/Package.ts | 18 +- packages/sisyphus/src/domain/Stone.ts | 9 + .../src/services/PullRequestAnalyzer.ts | 28 ++- packages/sisyphus/src/types.ts | 4 +- 16 files changed, 588 insertions(+), 30 deletions(-) create mode 100644 packages/sisyphus/src/commands/actions/index.ts create mode 100644 packages/sisyphus/src/commands/actions/init.ts create mode 100644 packages/sisyphus/src/commands/actions/release-pr.ts create mode 100644 packages/sisyphus/src/commands/actions/workflows/sis-create-stone.yml create mode 100644 packages/sisyphus/src/commands/actions/workflows/sis-release-pr.yml create mode 100644 packages/sisyphus/src/commands/actions/workflows/sis-release.yml 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/index.ts b/packages/sisyphus/src/commands/actions/index.ts new file mode 100644 index 0000000..2378690 --- /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 = "GitHub Actions 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..e9e60d4 --- /dev/null +++ b/packages/sisyphus/src/commands/actions/init.ts @@ -0,0 +1,140 @@ +import { existsSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { args, color, confirm, Exit, log, multiselect, note } from "@r5n/cli-core"; +import { BaseCommand, type Ctx } from "../../base-command"; + +const WORKFLOWS_DIR = ".github/workflows"; +const TEMPLATES_DIR = join(dirname(fileURLToPath(import.meta.url)), "workflows"); + +type WorkflowType = "create-stone" | "release-pr" | "release"; + +type WorkflowConfig = { name: string; description: string; filename: string }; + +const WORKFLOWS: Record = { + "create-stone": { + description: "When a PR is merged, automatically create a stone", + filename: "sis-create-stone.yml", + name: "Auto-create stone from PR", + }, + release: { + description: "When the release PR is merged, run the release", + filename: "sis-release.yml", + name: "Auto-release on PR merge", + }, + "release-pr": { + description: "When stones are added, create or update a release PR", + filename: "sis-release-pr.yml", + name: "Create/update release PR", + }, +}; + +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" }, + releasePr: { default: false, description: "Install release-pr 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 GitHub Actions workflows"; + args = initArgs; + prompts = true; + + async execute(ctx: InitCtx) { + const selected = await this.selectWorkflows(ctx); + + if (selected.length === 0) { + log.info(color.dim("No workflows selected")); + return; + } + + const existing = this.findExistingWorkflows(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 workflows already exist:\n${existing.map((f) => ` ${f}`).join("\n")}`); + const overwrite = await confirm({ initialValue: false, message: "Overwrite existing workflows?" }); + if (!overwrite) return; + } + + if (ctx.args.dryRun) { + await this.previewWorkflows(selected); + return; + } + + await this.createWorkflows(selected); + + note( + selected.map((w) => ` ${color.green("+")} ${WORKFLOWS[w].filename}`).join("\n"), + color.green("Workflows created"), + ); + + log.info(color.dim("\nCommit 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.args.all || !ctx.interactive) { + return Object.keys(WORKFLOWS) as WorkflowType[]; + } + + return multiselect({ + initialValues: Object.keys(WORKFLOWS) as WorkflowType[], + message: "Select workflows to create", + options: Object.entries(WORKFLOWS).map(([key, config]) => ({ + hint: config.description, + label: config.name, + value: key as WorkflowType, + })), + required: false, + }); + } + + private getWorkflowsFromFlags(ctx: InitCtx): WorkflowType[] { + const selected: WorkflowType[] = []; + if (ctx.args.createStone) selected.push("create-stone"); + if (ctx.args.releasePr) selected.push("release-pr"); + if (ctx.args.release) selected.push("release"); + return selected; + } + + private findExistingWorkflows(selected: WorkflowType[]): string[] { + return selected.map((w) => WORKFLOWS[w].filename).filter((filename) => existsSync(join(WORKFLOWS_DIR, filename))); + } + + private async readTemplate(filename: string): Promise { + return readFile(join(TEMPLATES_DIR, filename), "utf-8"); + } + + private async previewWorkflows(selected: WorkflowType[]) { + for (const workflow of selected) { + const config = WORKFLOWS[workflow]; + const content = await this.readTemplate(config.filename); + log.info(`\n${color.bold(config.filename)}:`); + log.info(color.dim(content)); + } + } + + private async createWorkflows(selected: WorkflowType[]) { + if (!existsSync(WORKFLOWS_DIR)) { + await mkdir(WORKFLOWS_DIR, { recursive: true }); + } + + for (const workflow of selected) { + const config = WORKFLOWS[workflow]; + const content = await this.readTemplate(config.filename); + const filepath = join(WORKFLOWS_DIR, config.filename); + await writeFile(filepath, content, "utf-8"); + } + } +} 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..28ad405 --- /dev/null +++ b/packages/sisyphus/src/commands/actions/release-pr.ts @@ -0,0 +1,233 @@ +import { args, color, Exit, log, spinner } from "@r5n/cli-core"; +import { BaseCommand, type Ctx } from "../../base-command"; +import { Package, Stone } from "../../domain"; +import { ChangelogGenerator, PackageUpdater, StoneManager, WorkspaceScanner } from "../../services"; + +const RELEASE_BRANCH = "sisyphus/release"; +const RELEASE_LABEL = "sisyphus-release"; +const PR_TITLE_PREFIX = "chore(release):"; +const DEFAULT_BASE_BRANCH = "main"; + +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; + + async execute(ctx: ReleasePrCtx) { + const manager = new StoneManager(ctx.config); + const stones = await manager.list(); + + if (stones.length === 0) { + log.info(color.dim("No pending stones found, skipping release PR")); + return; + } + + 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"); + } + + const prTitle = this.buildPrTitle(updatedPackages); + const prBody = this.buildPrBody(mergedStone, updatedPackages, stones); + + log.info(`${color.bold("Release PR:")} ${prTitle}`); + log.info(`${color.dim("Packages:")} ${updatedPackages.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); + return; + } + + const s = spinner(); + + const existingPr = await this.findExistingReleasePr(); + + try { + if (existingPr) { + s.start("Updating release branch..."); + await this.updateReleaseBranch(ctx, stones, updatedPackages); + s.stop("Release branch updated"); + + s.start("Updating PR..."); + await this.updatePr(existingPr.number, prTitle, prBody); + s.stop(`PR #${existingPr.number} updated`); + + log.info(`\n${color.green("Release PR updated:")} ${existingPr.url}`); + } else { + s.start("Creating release branch..."); + await this.createReleaseBranch(ctx, stones, updatedPackages); + s.stop("Release branch created"); + + s.start("Creating PR..."); + const pr = await this.createPr(prTitle, prBody); + s.stop(`PR #${pr.number} created`); + + log.info(`\n${color.green("Release PR created:")} ${pr.url}`); + } + } catch (error) { + s.stop("Failed"); + await this.restoreMainBranch(); + throw error; + } + } + + private async restoreMainBranch() { + const baseBranch = await this.getDefaultBranch(); + try { + await Bun.$`git checkout ${baseBranch}`.quiet(); + } catch {} + } + + private buildPrTitle(packages: Package[]): string { + const names = packages.map((p) => `${p.name}@${p.newVersion}`).join(", "); + return `${PR_TITLE_PREFIX} ${names}`; + } + + private buildPrBody(stone: Stone, packages: Package[], stones: Stone[]): string { + const lines: string[] = []; + + lines.push("## Release Summary"); + lines.push(""); + lines.push(`**Message:** ${stone.message}`); + if (stone.description) { + lines.push(""); + lines.push(stone.description); + } + lines.push(""); + + lines.push("## Packages"); + lines.push(""); + for (const pkg of packages) { + lines.push(`- \`${pkg.name}\` ${pkg.version} → ${pkg.newVersion}`); + } + lines.push(""); + + if (stones.length > 1) { + lines.push("## Stones"); + lines.push(""); + for (const s of stones) { + lines.push(`- **${s.id}**: ${s.message}`); + } + lines.push(""); + } + + lines.push("---"); + lines.push("*This PR was automatically created by [Sisyphus](https://github.com/r5n-labs/clis).*"); + lines.push("*Merging this PR will trigger the release workflow.*"); + + return lines.join("\n"); + } + + private async findExistingReleasePr(): Promise<{ number: number; url: string } | null> { + try { + const result = + await Bun.$`gh pr list --head ${RELEASE_BRANCH} --label ${RELEASE_LABEL} --json number,url --limit 1`.quiet(); + const prs = JSON.parse(result.stdout.toString()); + return prs[0] ?? null; + } catch { + return null; + } + } + + private async createReleaseBranch(ctx: ReleasePrCtx, stones: Stone[], packages: Package[]) { + const baseBranch = await this.getDefaultBranch(); + + await Bun.$`git checkout -B ${RELEASE_BRANCH} origin/${baseBranch}`.quiet(); + + await this.applyReleaseChanges(ctx, stones, packages); + + await Bun.$`git add -A`.quiet(); + await Bun.$`git commit -m ${`${PR_TITLE_PREFIX} prepare release`}`.quiet(); + await Bun.$`git push -u origin ${RELEASE_BRANCH} --force`.quiet(); + + await Bun.$`git checkout ${baseBranch}`.quiet(); + } + + private async updateReleaseBranch(ctx: ReleasePrCtx, stones: Stone[], packages: Package[]) { + const baseBranch = await this.getDefaultBranch(); + + await Bun.$`git fetch origin ${baseBranch}`.quiet(); + await Bun.$`git checkout ${RELEASE_BRANCH}`.quiet(); + await Bun.$`git reset --hard origin/${baseBranch}`.quiet(); + + await this.applyReleaseChanges(ctx, stones, packages); + + await Bun.$`git add -A`.quiet(); + + const hasChanges = await Bun.$`git diff --cached --quiet`.nothrow().quiet(); + if (hasChanges.exitCode !== 0) { + await Bun.$`git commit -m ${`${PR_TITLE_PREFIX} prepare release`}`.quiet(); + } + + await Bun.$`git push origin ${RELEASE_BRANCH} --force`.quiet(); + + await Bun.$`git checkout ${baseBranch}`.quiet(); + } + + private async applyReleaseChanges(ctx: ReleasePrCtx, stones: Stone[], packages: Package[]) { + const changelogConfig = ctx.config.get("changelog"); + const generator = new ChangelogGenerator(changelogConfig); + const updater = new PackageUpdater(); + + await updater.updateAll(packages); + + if (changelogConfig.generate) { + await generator.generate(stones, packages); + } + + const manager = new StoneManager(ctx.config); + for (const stone of stones) { + await manager.delete(stone.id); + } + } + + private async createPr(title: string, body: string): Promise<{ number: number; url: string }> { + const baseBranch = await this.getDefaultBranch(); + + await this.ensureLabelExists(); + + const result = + await Bun.$`gh pr create --head ${RELEASE_BRANCH} --base ${baseBranch} --title ${title} --body ${body} --label ${RELEASE_LABEL}`.quiet(); + + const url = result.stdout.toString().trim(); + const number = this.extractPrNumber(url); + + return { number, url }; + } + + private extractPrNumber(url: string): number { + const match = url.match(/\/pull\/(\d+)$/); + if (!match?.[1]) throw new Exit("Failed to parse PR number from URL", url); + return Number.parseInt(match[1], 10); + } + + private async ensureLabelExists() { + try { + await Bun.$`gh label create ${RELEASE_LABEL} --description "Sisyphus release PR" --color 6f42c1 --force`.quiet(); + } catch {} + } + + private async updatePr(prNumber: number, title: string, body: string) { + await Bun.$`gh pr edit ${prNumber} --title ${title} --body ${body}`.quiet(); + } + + private async getDefaultBranch(): Promise { + try { + const result = await Bun.$`gh repo view --json defaultBranchRef --jq .defaultBranchRef.name`.quiet(); + return result.stdout.toString().trim() || DEFAULT_BASE_BRANCH; + } catch { + return DEFAULT_BASE_BRANCH; + } + } +} diff --git a/packages/sisyphus/src/commands/actions/workflows/sis-create-stone.yml b/packages/sisyphus/src/commands/actions/workflows/sis-create-stone.yml new file mode 100644 index 0000000..8aba06c --- /dev/null +++ b/packages/sisyphus/src/commands/actions/workflows/sis-create-stone.yml @@ -0,0 +1,36 @@ +name: Create Stone from PR + +on: + pull_request: + types: [closed] + branches: [main] + +jobs: + create-stone: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: read + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install + + - name: Create stone from PR + run: bunx @r5n/sisyphus pr --url ${{ github.event.pull_request.html_url }} --yes + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Commit and push stone + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add .sisyphus/stones/ + git diff --cached --quiet || git commit -m "chore: add stone from PR #${{ github.event.pull_request.number }}" + git push diff --git a/packages/sisyphus/src/commands/actions/workflows/sis-release-pr.yml b/packages/sisyphus/src/commands/actions/workflows/sis-release-pr.yml new file mode 100644 index 0000000..1e141b8 --- /dev/null +++ b/packages/sisyphus/src/commands/actions/workflows/sis-release-pr.yml @@ -0,0 +1,28 @@ +name: Create Release PR + +on: + push: + branches: [main] + paths: + - '.sisyphus/stones/**' + +jobs: + release-pr: + 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: Create or update release PR + run: bunx @r5n/sisyphus actions release-pr + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/packages/sisyphus/src/commands/actions/workflows/sis-release.yml b/packages/sisyphus/src/commands/actions/workflows/sis-release.yml new file mode 100644 index 0000000..507b082 --- /dev/null +++ b/packages/sisyphus/src/commands/actions/workflows/sis-release.yml @@ -0,0 +1,29 @@ +name: Release + +on: + pull_request: + types: [closed] + branches: [main] + +jobs: + release: + if: 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: Run release + run: bunx @r5n/sisyphus roll --yes + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.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/pr.ts b/packages/sisyphus/src/commands/pr.ts index a6d8ec0..47f9c53 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,28 @@ 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) { + if (new RegExp(pattern, "i").test(pr.title)) { + return `title matches skip pattern "${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..a2714e8 100644 --- a/packages/sisyphus/src/commands/roll.ts +++ b/packages/sisyphus/src/commands/roll.ts @@ -1,7 +1,7 @@ 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({ @@ -45,8 +45,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"); @@ -116,30 +116,6 @@ export class RollCommand extends BaseCommand { }; } - 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[] = []; diff --git a/packages/sisyphus/src/constants.ts b/packages/sisyphus/src/constants.ts index 34a8f5d..51be585 100644 --- a/packages/sisyphus/src/constants.ts +++ b/packages/sisyphus/src/constants.ts @@ -73,6 +73,11 @@ export const SISYPHUS_DEFAULT_CONFIG: SisyphusConfig = { feature: "minor", fix: "patch", }, + skip: { + authors: ["github-actions[bot]"], + labels: ["sisyphus-release", "skip-stone"], + titlePatterns: ["^chore\\(release\\):"], + }, }, release: { github: false, npm: false, push: false, tags: false }, diff --git a/packages/sisyphus/src/domain/Commit.ts b/packages/sisyphus/src/domain/Commit.ts index 9c53243..5e545a0 100644 --- a/packages/sisyphus/src/domain/Commit.ts +++ b/packages/sisyphus/src/domain/Commit.ts @@ -79,6 +79,21 @@ export class Commit { return []; } + static async fromHash(hash: string): Promise { + try { + const result = await Bun.$`git log -1 --pretty=format:"%H|%s" ${hash}`.quiet(); + const output = result.stdout.toString().trim(); + if (!output) return null; + + const [fullHash, subject] = output.split("|"); + if (!fullHash || !subject) return null; + + return Commit.hydrate(fullHash, subject); + } catch { + return null; + } + } + private static async tryFetchFromRange(range: string): Promise { try { return await Commit.fetchFromRange(range); diff --git a/packages/sisyphus/src/domain/Package.ts b/packages/sisyphus/src/domain/Package.ts index fe4ce3b..a5b0594 100644 --- a/packages/sisyphus/src/domain/Package.ts +++ b/packages/sisyphus/src/domain/Package.ts @@ -1,5 +1,6 @@ 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; @@ -39,6 +40,21 @@ export class Package { return new Package({ file, name: json.name, version: json.version || "0.0.0" }); } + 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.bump) return undefined; return VersionCalculator.bump(this.version, this.bump, this.tag); diff --git a/packages/sisyphus/src/domain/Stone.ts b/packages/sisyphus/src/domain/Stone.ts index 2c3e475..ae65286 100644 --- a/packages/sisyphus/src/domain/Stone.ts +++ b/packages/sisyphus/src/domain/Stone.ts @@ -53,6 +53,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[] = []; diff --git a/packages/sisyphus/src/services/PullRequestAnalyzer.ts b/packages/sisyphus/src/services/PullRequestAnalyzer.ts index 201f74d..4940732 100644 --- a/packages/sisyphus/src/services/PullRequestAnalyzer.ts +++ b/packages/sisyphus/src/services/PullRequestAnalyzer.ts @@ -34,7 +34,11 @@ export class PullRequestAnalyzer { const pr = url ? await this.fetchFromUrl(url) : await this.fetchFromCurrentBranch(); - const parsedCommits = await Commit.inRange(pr.baseBranch, pr.branch); + let parsedCommits = await Commit.inRange(pr.baseBranch, pr.branch); + + if (parsedCommits.length === 0 && url) { + parsedCommits = await this.fetchCommitsFromApi(url); + } const { packages } = await WorkspaceScanner.scan({ single: this.config.get("single") }); const packagePaths = buildPackagePathMap(packages); @@ -58,6 +62,28 @@ export class PullRequestAnalyzer { return { commits, packages: affectedPackages, pr, suggestedBump }; } + private async fetchCommitsFromApi(url: string): Promise { + const match = url.match(GITHUB_PR_URL_REGEX); + if (!match) return []; + + const [, owner, repo, number] = match; + + try { + const result = await Bun.$`gh api repos/${owner}/${repo}/pulls/${number}/commits --jq '.[].sha'`.quiet(); + const hashes = result.stdout.toString().trim().split("\n").filter(Boolean); + + const commits: Commit[] = []; + for (const hash of hashes) { + const commit = await Commit.fromHash(hash); + if (commit) commits.push(commit); + } + + return commits; + } catch { + return []; + } + } + async fetchFromCurrentBranch(): Promise { try { const result = await Bun.$`gh pr view --json number,title,body,labels,author,headRefName,baseRefName,url`.quiet(); diff --git a/packages/sisyphus/src/types.ts b/packages/sisyphus/src/types.ts index 21ced42..b260360 100644 --- a/packages/sisyphus/src/types.ts +++ b/packages/sisyphus/src/types.ts @@ -33,7 +33,9 @@ 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 SisyphusConfig = { $schema: string; From c0a6d5b492b68b4bff23c1abf2ba4e8f5502fd9b Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Thu, 12 Mar 2026 11:54:22 +0100 Subject: [PATCH 02/38] refactor(sisyphus): add GitProvider abstraction and rename github to createRelease Introduce provider abstraction layer to support GitHub, GitLab, and Bitbucket. Currently only GitHub is implemented; GitLab and Bitbucket are stubbed with "not supported yet" errors. - Add GitProvider abstract class with PR, label, and release operations - Add GitHubProvider implementation using gh CLI - Add factory function to detect provider from git remote - Rename --github flag to --createRelease for provider-agnostic naming - Update config, types, and init prompts accordingly --- .../src/commands/actions/release-pr.ts | 92 +++++----- packages/sisyphus/src/commands/init.ts | 16 +- packages/sisyphus/src/commands/roll.ts | 16 +- packages/sisyphus/src/constants.ts | 2 +- .../src/providers/BitbucketProvider.ts | 62 +++++++ .../sisyphus/src/providers/GitHubProvider.ts | 164 ++++++++++++++++++ .../sisyphus/src/providers/GitLabProvider.ts | 62 +++++++ .../sisyphus/src/providers/GitProvider.ts | 54 ++++++ packages/sisyphus/src/providers/index.ts | 90 ++++++++++ .../src/services/PullRequestAnalyzer.ts | 112 +++++------- .../src/services/ReleaseOrchestrator.ts | 27 ++- packages/sisyphus/src/types.ts | 2 +- 12 files changed, 557 insertions(+), 142 deletions(-) create mode 100644 packages/sisyphus/src/providers/BitbucketProvider.ts create mode 100644 packages/sisyphus/src/providers/GitHubProvider.ts create mode 100644 packages/sisyphus/src/providers/GitLabProvider.ts create mode 100644 packages/sisyphus/src/providers/GitProvider.ts create mode 100644 packages/sisyphus/src/providers/index.ts diff --git a/packages/sisyphus/src/commands/actions/release-pr.ts b/packages/sisyphus/src/commands/actions/release-pr.ts index 28ad405..93b8cb0 100644 --- a/packages/sisyphus/src/commands/actions/release-pr.ts +++ b/packages/sisyphus/src/commands/actions/release-pr.ts @@ -1,12 +1,14 @@ 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, PackageUpdater, StoneManager, WorkspaceScanner } from "../../services"; 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 DEFAULT_BASE_BRANCH = "main"; const releasePrArgs = args({ dryRun: { alias: "d", default: false, description: "Preview without making changes", type: "boolean" }, @@ -19,6 +21,16 @@ export class ActionsReleasePrCommand extends BaseCommand { 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 manager = new StoneManager(ctx.config); const stones = await manager.list(); @@ -82,13 +94,6 @@ export class ActionsReleasePrCommand extends BaseCommand { } } - private async restoreMainBranch() { - const baseBranch = await this.getDefaultBranch(); - try { - await Bun.$`git checkout ${baseBranch}`.quiet(); - } catch {} - } - private buildPrTitle(packages: Package[]): string { const names = packages.map((p) => `${p.name}@${p.newVersion}`).join(", "); return `${PR_TITLE_PREFIX} ${names}`; @@ -130,18 +135,14 @@ export class ActionsReleasePrCommand extends BaseCommand { } private async findExistingReleasePr(): Promise<{ number: number; url: string } | null> { - try { - const result = - await Bun.$`gh pr list --head ${RELEASE_BRANCH} --label ${RELEASE_LABEL} --json number,url --limit 1`.quiet(); - const prs = JSON.parse(result.stdout.toString()); - return prs[0] ?? null; - } catch { - return 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 createReleaseBranch(ctx: ReleasePrCtx, stones: Stone[], packages: Package[]) { - const baseBranch = await this.getDefaultBranch(); + const provider = await this.getProvider(); + const baseBranch = await provider.getDefaultBranch(); await Bun.$`git checkout -B ${RELEASE_BRANCH} origin/${baseBranch}`.quiet(); @@ -155,7 +156,8 @@ export class ActionsReleasePrCommand extends BaseCommand { } private async updateReleaseBranch(ctx: ReleasePrCtx, stones: Stone[], packages: Package[]) { - const baseBranch = await this.getDefaultBranch(); + const provider = await this.getProvider(); + const baseBranch = await provider.getDefaultBranch(); await Bun.$`git fetch origin ${baseBranch}`.quiet(); await Bun.$`git checkout ${RELEASE_BRANCH}`.quiet(); @@ -193,41 +195,35 @@ export class ActionsReleasePrCommand extends BaseCommand { } private async createPr(title: string, body: string): Promise<{ number: number; url: string }> { - const baseBranch = await this.getDefaultBranch(); - - await this.ensureLabelExists(); - - const result = - await Bun.$`gh pr create --head ${RELEASE_BRANCH} --base ${baseBranch} --title ${title} --body ${body} --label ${RELEASE_LABEL}`.quiet(); - - const url = result.stdout.toString().trim(); - const number = this.extractPrNumber(url); - - return { number, url }; - } - - private extractPrNumber(url: string): number { - const match = url.match(/\/pull\/(\d+)$/); - if (!match?.[1]) throw new Exit("Failed to parse PR number from URL", url); - return Number.parseInt(match[1], 10); - } - - private async ensureLabelExists() { - try { - await Bun.$`gh label create ${RELEASE_LABEL} --description "Sisyphus release PR" --color 6f42c1 --force`.quiet(); - } catch {} + 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) { - await Bun.$`gh pr edit ${prNumber} --title ${title} --body ${body}`.quiet(); + const provider = await this.getProvider(); + await provider.updatePr(prNumber, { body, title }); } - private async getDefaultBranch(): Promise { + private async restoreMainBranch() { + const provider = await this.getProvider(); + const baseBranch = await provider.getDefaultBranch(); try { - const result = await Bun.$`gh repo view --json defaultBranchRef --jq .defaultBranchRef.name`.quiet(); - return result.stdout.toString().trim() || DEFAULT_BASE_BRANCH; - } catch { - return DEFAULT_BASE_BRANCH; - } + await Bun.$`git checkout ${baseBranch}`.quiet(); + } catch {} } } 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/roll.ts b/packages/sisyphus/src/commands/roll.ts index a2714e8..066ca57 100644 --- a/packages/sisyphus/src/commands/roll.ts +++ b/packages/sisyphus/src/commands/roll.ts @@ -6,8 +6,8 @@ import { ChangelogGenerator, ReleaseOrchestrator, StoneManager, WorkspaceScanner 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" }, @@ -21,8 +21,8 @@ type RollCtx = Ctx; type RollOptions = { changelog: boolean; commit: boolean; + createRelease: boolean; dryRun: boolean; - github: boolean; npm: boolean; push: boolean; tags: boolean; @@ -108,8 +108,8 @@ 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, @@ -137,7 +137,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")); } @@ -193,10 +193,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(stone, packages); + s.stop("Release created"); } note( diff --git a/packages/sisyphus/src/constants.ts b/packages/sisyphus/src/constants.ts index 51be585..134d285 100644 --- a/packages/sisyphus/src/constants.ts +++ b/packages/sisyphus/src/constants.ts @@ -80,7 +80,7 @@ export const SISYPHUS_DEFAULT_CONFIG: SisyphusConfig = { }, }, - 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/providers/BitbucketProvider.ts b/packages/sisyphus/src/providers/BitbucketProvider.ts new file mode 100644 index 0000000..0d29865 --- /dev/null +++ b/packages/sisyphus/src/providers/BitbucketProvider.ts @@ -0,0 +1,62 @@ +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 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..d96bc5c --- /dev/null +++ b/packages/sisyphus/src/providers/GitHubProvider.ts @@ -0,0 +1,164 @@ +import { Exit } from "@r5n/cli-core"; +import { + type CreateLabelOptions, + type CreatePrOptions, + type CreateReleaseOptions, + type FindPrOptions, + GitProvider, + type PullRequest, + type UpdatePrOptions, +} from "./GitProvider"; + +const DEFAULT_BRANCH = "main"; + +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 --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 labelArgs = options.labels?.flatMap((l) => ["--label", l]) ?? []; + + const result = + await Bun.$`gh pr create --head ${options.head} --base ${options.base} --title ${options.title} --body ${options.body} ${labelArgs}`.quiet(); + + const url = result.stdout.toString().trim(); + const number = this.extractPrNumber(url); + + return this.getPr(number); + } + + 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}`.quiet(); + } + + 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 { + author: data.user?.login ?? "unknown", + baseBranch: data.base?.ref ?? DEFAULT_BRANCH, + body: data.body ?? "", + headBranch: 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"); + } + } + + async getPrFromCurrentBranch(): 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 ?? "", + headBranch: data.headRefName, + labels: data.labels?.map((l: { name: string }) => l.name) ?? [], + number: data.number, + title: data.title, + url: data.url, + }; + } 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 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}`.quiet(); + } + + async deleteRelease(tag: string): Promise { + try { + await Bun.$`gh release delete ${tag} --yes`.quiet(); + } catch {} + } + + private extractPrNumber(url: string): number { + const match = url.match(/\/pull\/(\d+)$/); + if (!match?.[1]) throw new Exit("Failed to parse PR number from URL", url); + return Number.parseInt(match[1], 10); + } + + private mapPrResponse(data: Record): PullRequest { + return { + author: (data.author as { login?: string })?.login ?? "unknown", + baseBranch: data.baseRefName as string, + body: (data.body as string) ?? "", + headBranch: data.headRefName as string, + labels: ((data.labels as { name: string }[]) ?? []).map((l) => l.name), + number: data.number as number, + title: data.title as string, + url: data.url as string, + }; + } +} diff --git a/packages/sisyphus/src/providers/GitLabProvider.ts b/packages/sisyphus/src/providers/GitLabProvider.ts new file mode 100644 index 0000000..1c4ddba --- /dev/null +++ b/packages/sisyphus/src/providers/GitLabProvider.ts @@ -0,0 +1,62 @@ +import { Exit } from "@r5n/cli-core"; +import { + type CreateLabelOptions, + type CreatePrOptions, + type CreateReleaseOptions, + type FindPrOptions, + GitProvider, + type PullRequest, + type UpdatePrOptions, +} from "./GitProvider"; + +export class GitLabProvider extends GitProvider { + readonly name = "gitlab" as const; + + private notImplemented(): never { + throw new Exit("GitLab is not supported yet", "GitLab 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 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/GitProvider.ts b/packages/sisyphus/src/providers/GitProvider.ts new file mode 100644 index 0000000..8b1d640 --- /dev/null +++ b/packages/sisyphus/src/providers/GitProvider.ts @@ -0,0 +1,54 @@ +export type PullRequest = { + number: number; + url: string; + title: string; + body: string; + labels: string[]; + author: string; + headBranch: string; + baseBranch: string; +}; + +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 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..d055194 --- /dev/null +++ b/packages/sisyphus/src/providers/index.ts @@ -0,0 +1,90 @@ +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 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); + } +} + +async function detectRemoteInfo(): Promise { + const url = await getRemoteUrl(); + if (!url) return null; + return parseRemoteUrl(url); +} + +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) { + 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) { + 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/PullRequestAnalyzer.ts b/packages/sisyphus/src/services/PullRequestAnalyzer.ts index 4940732..9aef5f4 100644 --- a/packages/sisyphus/src/services/PullRequestAnalyzer.ts +++ b/packages/sisyphus/src/services/PullRequestAnalyzer.ts @@ -1,13 +1,11 @@ import type { ConfigManager } from "@r5n/cli-core"; import { Exit } from "@r5n/cli-core"; import { BumpType, Commit, type CommitInfo } from "../domain"; +import { createGitProvider, type GitProvider, 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\//; - export type PullRequestInfo = { number: number; title: string; @@ -27,17 +25,20 @@ 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); let parsedCommits = await Commit.inRange(pr.baseBranch, pr.branch); if (parsedCommits.length === 0 && url) { - parsedCommits = await this.fetchCommitsFromApi(url); + parsedCommits = await this.fetchCommitsFromApi(provider, url); } const { packages } = await WorkspaceScanner.scan({ single: this.config.get("single") }); @@ -62,15 +63,19 @@ export class PullRequestAnalyzer { return { commits, packages: affectedPackages, pr, suggestedBump }; } - private async fetchCommitsFromApi(url: string): Promise { - const match = url.match(GITHUB_PR_URL_REGEX); - if (!match) return []; + private async getProvider(): Promise { + if (!this.provider) { + this.provider = await createGitProvider(); + } + return this.provider; + } - const [, owner, repo, number] = match; + private async fetchCommitsFromApi(provider: GitProvider, url: string): Promise { + const urlInfo = parsePrUrl(url); + if (!urlInfo) return []; try { - const result = await Bun.$`gh api repos/${owner}/${repo}/pulls/${number}/commits --jq '.[].sha'`.quiet(); - const hashes = result.stdout.toString().trim().split("\n").filter(Boolean); + const hashes = await provider.getPrCommits(urlInfo.number); const commits: Commit[] = []; for (const hash of hashes) { @@ -84,55 +89,40 @@ export class PullRequestAnalyzer { } } - async fetchFromCurrentBranch(): 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, - }; - } catch { - throw new Exit("No PR found for current branch", "Make sure you have an open PR or provide a URL with --url"); - } + private async fetchFromCurrentBranch(provider: GitProvider): Promise { + const pr = await provider.getPrFromCurrentBranch(); + return this.mapPullRequest(pr); } - 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 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"); } - 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"); + 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 [, owner, repo, number] = match; + const pr = await provider.getPr(urlInfo.number); + return this.mapPullRequest(pr); + } - 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"); - } + private mapPullRequest(pr: PullRequest): PullRequestInfo { + return { + author: pr.author, + baseBranch: pr.baseBranch, + body: pr.body, + branch: pr.headBranch, + labels: pr.labels, + number: pr.number, + title: pr.title, + url: pr.url, + }; } inferBumpFromLabels(labels: string[]): BumpType | null { @@ -168,18 +158,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..13bc35e 100644 --- a/packages/sisyphus/src/services/ReleaseOrchestrator.ts +++ b/packages/sisyphus/src/services/ReleaseOrchestrator.ts @@ -1,6 +1,7 @@ import { dirname, join } from "node:path"; import { type ConfigManager, Exit } from "@r5n/cli-core"; 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 +9,8 @@ import { PackageUpdater } from "./PackageUpdater"; export type ReleaseOptions = { changelog: boolean; + createRelease: boolean; dryRun: boolean; - github: boolean; npm: boolean; push: boolean; tags: boolean; @@ -19,6 +20,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 +36,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(); @@ -122,10 +131,11 @@ export class ReleaseOrchestrator { this.pushedToRemote = true; } - async createGithubRelease(stone: Stone, packages: Package[]) { + async createGitRelease(stone: 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; @@ -135,8 +145,8 @@ export class ReleaseOrchestrator { const notes = this.formatReleaseNotes(stone, 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); } @@ -185,10 +195,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 = []; diff --git a/packages/sisyphus/src/types.ts b/packages/sisyphus/src/types.ts index b260360..4875d7e 100644 --- a/packages/sisyphus/src/types.ts +++ b/packages/sisyphus/src/types.ts @@ -2,7 +2,7 @@ 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 ChangelogSections = { breaking: string; From 3d18eb6806c337ab12ea4f50d57d61d1ed37a7fb Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Wed, 11 Mar 2026 18:58:12 +0100 Subject: [PATCH 03/38] feat(sisyphus): implement GitLabProvider for glab CLI integration --- .../sisyphus/src/providers/GitLabProvider.ts | 132 +++++++++++++++--- 1 file changed, 109 insertions(+), 23 deletions(-) diff --git a/packages/sisyphus/src/providers/GitLabProvider.ts b/packages/sisyphus/src/providers/GitLabProvider.ts index 1c4ddba..093e89d 100644 --- a/packages/sisyphus/src/providers/GitLabProvider.ts +++ b/packages/sisyphus/src/providers/GitLabProvider.ts @@ -9,54 +9,140 @@ import { type UpdatePrOptions, } from "./GitProvider"; +const DEFAULT_BRANCH = "main"; + export class GitLabProvider extends GitProvider { readonly name = "gitlab" as const; - private notImplemented(): never { - throw new Exit("GitLab is not supported yet", "GitLab support is coming soon"); - } - async ensureAvailable(): Promise { - this.notImplemented(); + 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 { - this.notImplemented(); + 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 { - this.notImplemented(); + 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 { - this.notImplemented(); + 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`.quiet(); + + const iid = this.extractMrNumber(result.stdout.toString()); + return this.getPr(iid); } - async updatePr(_number: number, _options: UpdatePrOptions): Promise { - this.notImplemented(); + 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}`.quiet(); } - async getPr(_number: number): Promise { - this.notImplemented(); + 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 { - this.notImplemented(); + 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 getPrCommits(_number: number): Promise { - this.notImplemented(); + 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 ensureLabelExists(_name: string, _options?: CreateLabelOptions): Promise { - this.notImplemented(); + async createRelease(options: CreateReleaseOptions): Promise { + await Bun.$`glab release create ${options.tag} --name ${options.title} --notes ${options.notes}`.quiet(); } - async createRelease(_options: CreateReleaseOptions): Promise { - this.notImplemented(); + 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); } - async deleteRelease(_tag: string): Promise { - this.notImplemented(); + private mapMrResponse(data: Record): PullRequest { + return { + author: (data.author as { username?: string })?.username ?? "unknown", + baseBranch: (data.target_branch as string) ?? DEFAULT_BRANCH, + body: (data.description as string) ?? "", + headBranch: (data.source_branch as string) ?? "", + labels: (data.labels as string[]) ?? [], + number: data.iid as number, + title: data.title as string, + url: data.web_url as string, + }; } } From a979a0dc01b1c79b190f91665b67c2ecc86ec133 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Wed, 11 Mar 2026 19:55:17 +0100 Subject: [PATCH 04/38] feat(sisyphus): add GitLab CI support to actions init command --- packages/sisyphus/build.ts | 3 + .../src/commands/actions/CiGenerator.ts | 111 ++++++++++++++++++ .../sisyphus/src/commands/actions/init.ts | 109 ++++++----------- .../github}/sis-create-stone.yml | 0 .../github}/sis-release-pr.yml | 0 .../github}/sis-release.yml | 0 .../templates/gitlab/sis-create-stone.yml | 13 ++ .../templates/gitlab/sis-release-pr.yml | 10 ++ .../actions/templates/gitlab/sis-release.yml | 8 ++ packages/sisyphus/src/providers/index.ts | 8 ++ 10 files changed, 191 insertions(+), 71 deletions(-) create mode 100644 packages/sisyphus/src/commands/actions/CiGenerator.ts rename packages/sisyphus/src/commands/actions/{workflows => templates/github}/sis-create-stone.yml (100%) rename packages/sisyphus/src/commands/actions/{workflows => templates/github}/sis-release-pr.yml (100%) rename packages/sisyphus/src/commands/actions/{workflows => templates/github}/sis-release.yml (100%) create mode 100644 packages/sisyphus/src/commands/actions/templates/gitlab/sis-create-stone.yml create mode 100644 packages/sisyphus/src/commands/actions/templates/gitlab/sis-release-pr.yml create mode 100644 packages/sisyphus/src/commands/actions/templates/gitlab/sis-release.yml 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/commands/actions/CiGenerator.ts b/packages/sisyphus/src/commands/actions/CiGenerator.ts new file mode 100644 index 0000000..5b185ac --- /dev/null +++ b/packages/sisyphus/src/commands/actions/CiGenerator.ts @@ -0,0 +1,111 @@ +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-pr" | "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, automatically create a stone", + name: "Auto-create stone from PR", + }, + release: { description: "When the release PR is merged, run the release", name: "Auto-release on PR merge" }, + "release-pr": { + description: "When stones are added, create or update a release PR", + name: "Create/update release PR", + }, +}; + +const TEMPLATES_DIR = join(dirname(fileURLToPath(import.meta.url)), "templates"); + +const TEMPLATE_FILENAMES: Record = { + "create-stone": "sis-create-stone.yml", + release: "sis-release.yml", + "release-pr": "sis-release-pr.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(); + case "bitbucket": + throw new Error("Bitbucket CI generation is not supported yet"); + } +} diff --git a/packages/sisyphus/src/commands/actions/init.ts b/packages/sisyphus/src/commands/actions/init.ts index e9e60d4..7580341 100644 --- a/packages/sisyphus/src/commands/actions/init.ts +++ b/packages/sisyphus/src/commands/actions/init.ts @@ -1,34 +1,12 @@ import { existsSync } from "node:fs"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +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 WORKFLOWS_DIR = ".github/workflows"; -const TEMPLATES_DIR = join(dirname(fileURLToPath(import.meta.url)), "workflows"); - -type WorkflowType = "create-stone" | "release-pr" | "release"; - -type WorkflowConfig = { name: string; description: string; filename: string }; - -const WORKFLOWS: Record = { - "create-stone": { - description: "When a PR is merged, automatically create a stone", - filename: "sis-create-stone.yml", - name: "Auto-create stone from PR", - }, - release: { - description: "When the release PR is merged, run the release", - filename: "sis-release.yml", - name: "Auto-release on PR merge", - }, - "release-pr": { - description: "When stones are added, create or update a release PR", - filename: "sis-release-pr.yml", - name: "Create/update release PR", - }, -}; +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" }, @@ -43,11 +21,16 @@ type InitCtx = Ctx; export class ActionsInitCommand extends BaseCommand { name = "init"; - description = "Set up GitHub Actions workflows"; + 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) { @@ -55,29 +38,43 @@ export class ActionsInitCommand extends BaseCommand { return; } - const existing = this.findExistingWorkflows(selected); + 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 workflows already exist:\n${existing.map((f) => ` ${f}`).join("\n")}`); - const overwrite = await confirm({ initialValue: false, message: "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) { - await this.previewWorkflows(selected); + for (const file of files) { + log.info(`\n${color.bold(file.path)}:`); + log.info(color.dim(file.content)); + } return; } - await this.createWorkflows(selected); + 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")); - note( - selected.map((w) => ` ${color.green("+")} ${WORKFLOWS[w].filename}`).join("\n"), - color.green("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("\nCommit and push these files to enable the workflows.")); + log.info(color.dim("Commit and push these files to enable the workflows.")); } private async selectWorkflows(ctx: InitCtx): Promise { @@ -85,13 +82,13 @@ export class ActionsInitCommand extends BaseCommand { if (fromFlags.length > 0) return fromFlags; if (ctx.args.all || !ctx.interactive) { - return Object.keys(WORKFLOWS) as WorkflowType[]; + return Object.keys(WORKFLOW_CONFIGS) as WorkflowType[]; } return multiselect({ - initialValues: Object.keys(WORKFLOWS) as WorkflowType[], + initialValues: Object.keys(WORKFLOW_CONFIGS) as WorkflowType[], message: "Select workflows to create", - options: Object.entries(WORKFLOWS).map(([key, config]) => ({ + options: Object.entries(WORKFLOW_CONFIGS).map(([key, config]) => ({ hint: config.description, label: config.name, value: key as WorkflowType, @@ -107,34 +104,4 @@ export class ActionsInitCommand extends BaseCommand { if (ctx.args.release) selected.push("release"); return selected; } - - private findExistingWorkflows(selected: WorkflowType[]): string[] { - return selected.map((w) => WORKFLOWS[w].filename).filter((filename) => existsSync(join(WORKFLOWS_DIR, filename))); - } - - private async readTemplate(filename: string): Promise { - return readFile(join(TEMPLATES_DIR, filename), "utf-8"); - } - - private async previewWorkflows(selected: WorkflowType[]) { - for (const workflow of selected) { - const config = WORKFLOWS[workflow]; - const content = await this.readTemplate(config.filename); - log.info(`\n${color.bold(config.filename)}:`); - log.info(color.dim(content)); - } - } - - private async createWorkflows(selected: WorkflowType[]) { - if (!existsSync(WORKFLOWS_DIR)) { - await mkdir(WORKFLOWS_DIR, { recursive: true }); - } - - for (const workflow of selected) { - const config = WORKFLOWS[workflow]; - const content = await this.readTemplate(config.filename); - const filepath = join(WORKFLOWS_DIR, config.filename); - await writeFile(filepath, content, "utf-8"); - } - } } diff --git a/packages/sisyphus/src/commands/actions/workflows/sis-create-stone.yml b/packages/sisyphus/src/commands/actions/templates/github/sis-create-stone.yml similarity index 100% rename from packages/sisyphus/src/commands/actions/workflows/sis-create-stone.yml rename to packages/sisyphus/src/commands/actions/templates/github/sis-create-stone.yml diff --git a/packages/sisyphus/src/commands/actions/workflows/sis-release-pr.yml b/packages/sisyphus/src/commands/actions/templates/github/sis-release-pr.yml similarity index 100% rename from packages/sisyphus/src/commands/actions/workflows/sis-release-pr.yml rename to packages/sisyphus/src/commands/actions/templates/github/sis-release-pr.yml diff --git a/packages/sisyphus/src/commands/actions/workflows/sis-release.yml b/packages/sisyphus/src/commands/actions/templates/github/sis-release.yml similarity index 100% rename from packages/sisyphus/src/commands/actions/workflows/sis-release.yml rename to packages/sisyphus/src/commands/actions/templates/github/sis-release.yml 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..c054dda --- /dev/null +++ b/packages/sisyphus/src/commands/actions/templates/gitlab/sis-create-stone.yml @@ -0,0 +1,13 @@ +create-stone: + stage: sisyphus + image: oven/bun:latest + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_STATE == "merged" + script: + - bun install + - bunx @r5n/sisyphus pr --url $CI_MERGE_REQUEST_PROJECT_URL/-/merge_requests/$CI_MERGE_REQUEST_IID --yes + - git config user.name "gitlab-ci[bot]" + - git config user.email "gitlab-ci[bot]@users.noreply.gitlab.com" + - 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 diff --git a/packages/sisyphus/src/commands/actions/templates/gitlab/sis-release-pr.yml b/packages/sisyphus/src/commands/actions/templates/gitlab/sis-release-pr.yml new file mode 100644 index 0000000..f7e65d8 --- /dev/null +++ b/packages/sisyphus/src/commands/actions/templates/gitlab/sis-release-pr.yml @@ -0,0 +1,10 @@ +release-pr: + stage: sisyphus + image: oven/bun:latest + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + changes: + - .sisyphus/stones/**/* + script: + - bun install + - bunx @r5n/sisyphus actions release-pr 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..a95e2a3 --- /dev/null +++ b/packages/sisyphus/src/commands/actions/templates/gitlab/sis-release.yml @@ -0,0 +1,8 @@ +release: + 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: + - bun install + - bunx @r5n/sisyphus roll --yes diff --git a/packages/sisyphus/src/providers/index.ts b/packages/sisyphus/src/providers/index.ts index d055194..e91ac55 100644 --- a/packages/sisyphus/src/providers/index.ts +++ b/packages/sisyphus/src/providers/index.ts @@ -40,6 +40,14 @@ async function detectRemoteInfo(): Promise { 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(); From 55ba5da36fba38ea52a0990c6641b76afaff009c Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Wed, 11 Mar 2026 20:20:36 +0100 Subject: [PATCH 05/38] fix(sisyphus): include root files when analyzing PRs in single-package repos --- packages/sisyphus/src/services/PullRequestAnalyzer.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/sisyphus/src/services/PullRequestAnalyzer.ts b/packages/sisyphus/src/services/PullRequestAnalyzer.ts index 9aef5f4..7381275 100644 --- a/packages/sisyphus/src/services/PullRequestAnalyzer.ts +++ b/packages/sisyphus/src/services/PullRequestAnalyzer.ts @@ -41,14 +41,15 @@ export class PullRequestAnalyzer { parsedCommits = await this.fetchCommitsFromApi(provider, url); } - const { packages } = await WorkspaceScanner.scan({ single: this.config.get("single") }); + const isSinglePackage = this.config.get("single"); + const { packages } = await WorkspaceScanner.scan({ single: isSinglePackage }); const packagePaths = buildPackagePathMap(packages); const commits: CommitInfo[] = []; const affectedPackages = new Set(); for (const commit of parsedCommits) { - const pkgs = findAffectedPackages(commit.files, packagePaths, false); + const pkgs = findAffectedPackages(commit.files, packagePaths, isSinglePackage); if (pkgs.size === 0) continue; commits.push(commit.toInfo([...pkgs])); From db970db0f7ef8206e1c059aa62e932f864c6455b Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Thu, 12 Mar 2026 12:11:50 +0100 Subject: [PATCH 06/38] fix(sisyphus): use consistent field separator in Commit.fromHash --- packages/sisyphus/src/domain/Commit.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/sisyphus/src/domain/Commit.ts b/packages/sisyphus/src/domain/Commit.ts index 5e545a0..c02af09 100644 --- a/packages/sisyphus/src/domain/Commit.ts +++ b/packages/sisyphus/src/domain/Commit.ts @@ -3,6 +3,7 @@ const CONVENTIONAL_COMMIT_REGEX = /^(\w+)(?:\(([^)]+)\))?(!)?: (.+)$/; export const OTHER_COMMIT_TYPE = "other"; const SHORT_HASH_LENGTH = 7; +const FIELD_SEPARATOR = "\x1f"; const KNOWN_COMMIT_TYPES = new Set([ "build", @@ -81,11 +82,11 @@ export class Commit { static async fromHash(hash: string): Promise { try { - const result = await Bun.$`git log -1 --pretty=format:"%H|%s" ${hash}`.quiet(); + const result = await Bun.$`git log -1 --pretty=format:"%H%x1f%s" ${hash}`.quiet(); const output = result.stdout.toString().trim(); if (!output) return null; - const [fullHash, subject] = output.split("|"); + const [fullHash, subject] = output.split(FIELD_SEPARATOR); if (!fullHash || !subject) return null; return Commit.hydrate(fullHash, subject); @@ -111,7 +112,7 @@ export class Commit { const commits: Commit[] = []; for (const line of output.split("\n")) { - const sepIndex = line.indexOf("\x1f"); + const sepIndex = line.indexOf(FIELD_SEPARATOR); if (sepIndex === -1) continue; const hash = line.slice(0, sepIndex); const subject = line.slice(sepIndex + 1); From 999cf03fbce611a5fda07bba2c85b32763fe39c0 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Thu, 12 Mar 2026 13:49:46 +0100 Subject: [PATCH 07/38] feat(sisyphus): add two-stage release flow with stone archiving and publish-only mode --- .../src/commands/actions/release-pr.ts | 11 +- .../actions/templates/github/sis-release.yml | 4 +- .../actions/templates/gitlab/sis-release.yml | 2 +- packages/sisyphus/src/commands/roll.ts | 123 ++++++++++++++++++ packages/sisyphus/src/constants.ts | 1 + packages/sisyphus/src/domain/Package.ts | 4 + .../sisyphus/src/services/StoneManager.ts | 60 ++++++++- packages/sisyphus/src/types.ts | 3 + 8 files changed, 199 insertions(+), 9 deletions(-) diff --git a/packages/sisyphus/src/commands/actions/release-pr.ts b/packages/sisyphus/src/commands/actions/release-pr.ts index 93b8cb0..b1b8e08 100644 --- a/packages/sisyphus/src/commands/actions/release-pr.ts +++ b/packages/sisyphus/src/commands/actions/release-pr.ts @@ -189,9 +189,16 @@ export class ActionsReleasePrCommand extends BaseCommand { } const manager = new StoneManager(ctx.config); - for (const stone of stones) { - await manager.delete(stone.id); + const timestamp = await manager.archive(stones); + + const packageVersions: Record = {}; + for (const pkg of packages) { + if (pkg.newVersion) { + packageVersions[pkg.name] = pkg.newVersion; + } } + + ctx.config.set("currentRelease", { packages: packageVersions, stoneIds: stones.map((s) => s.id), timestamp }); } private async createPr(title: string, body: string): Promise<{ number: number; url: string }> { diff --git a/packages/sisyphus/src/commands/actions/templates/github/sis-release.yml b/packages/sisyphus/src/commands/actions/templates/github/sis-release.yml index 507b082..9d5dbcc 100644 --- a/packages/sisyphus/src/commands/actions/templates/github/sis-release.yml +++ b/packages/sisyphus/src/commands/actions/templates/github/sis-release.yml @@ -22,8 +22,8 @@ jobs: - name: Install dependencies run: bun install - - name: Run release - run: bunx @r5n/sisyphus roll --yes + - 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-release.yml b/packages/sisyphus/src/commands/actions/templates/gitlab/sis-release.yml index a95e2a3..e8b9070 100644 --- a/packages/sisyphus/src/commands/actions/templates/gitlab/sis-release.yml +++ b/packages/sisyphus/src/commands/actions/templates/gitlab/sis-release.yml @@ -5,4 +5,4 @@ release: - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_STATE == "merged" && $CI_MERGE_REQUEST_LABELS =~ /sisyphus-release/ script: - bun install - - bunx @r5n/sisyphus roll --yes + - bunx @r5n/sisyphus roll --publish-only --yes diff --git a/packages/sisyphus/src/commands/roll.ts b/packages/sisyphus/src/commands/roll.ts index 066ca57..672f604 100644 --- a/packages/sisyphus/src/commands/roll.ts +++ b/packages/sisyphus/src/commands/roll.ts @@ -11,6 +11,7 @@ const rollArgs = args({ 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" }, @@ -28,12 +29,19 @@ type RollOptions = { 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); @@ -239,4 +247,119 @@ 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, version] of packageEntries) { + const pkg = allPackages.get(name); + if (pkg) { + packagesToPublish.push(pkg.withVersion(version)); + } + } + + 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.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 git tags..."); + await orchestrator.createGitTags(packages); + s.stop("Git tags created"); + } + + 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 mergedStone = stones.length > 0 ? Stone.mergeAll(stones) : this.createFallbackStone(packages); + await orchestrator.createGitRelease(mergedStone, 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/constants.ts b/packages/sisyphus/src/constants.ts index 134d285..5aa40fd 100644 --- a/packages/sisyphus/src/constants.ts +++ b/packages/sisyphus/src/constants.ts @@ -25,6 +25,7 @@ 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 DEFAULT_CONFIG_FILE = "config.json"; export const DEFAULT_CHANGELOG_FILE = "CHANGELOG.md"; diff --git a/packages/sisyphus/src/domain/Package.ts b/packages/sisyphus/src/domain/Package.ts index a5b0594..80c94cc 100644 --- a/packages/sisyphus/src/domain/Package.ts +++ b/packages/sisyphus/src/domain/Package.ts @@ -73,6 +73,10 @@ export class Package { return new Package({ ...this.toOptions(), bump, tag }); } + withVersion(version: string): Package { + return new Package({ ...this.toOptions(), version }); + } + private toOptions(): PackageOptions { return { bump: this.bump, diff --git a/packages/sisyphus/src/services/StoneManager.ts b/packages/sisyphus/src/services/StoneManager.ts index e3dd0d7..a5dbc9d 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 { 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,55 @@ 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 {} + } + + return stones; + } + 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 4875d7e..07e2a05 100644 --- a/packages/sisyphus/src/types.ts +++ b/packages/sisyphus/src/types.ts @@ -4,6 +4,8 @@ export type CommitConfig = { author: string; email?: string; message: string }; export type ReleaseConfig = { createRelease: boolean; npm: boolean; push: boolean; tags: boolean }; +export type CurrentRelease = { packages: Record; stoneIds: string[]; timestamp: string }; + export type ChangelogSections = { breaking: string; build: string; @@ -41,6 +43,7 @@ export type SisyphusConfig = { $schema: string; changelog: ChangelogConfig; commit: CommitConfig; + currentRelease?: CurrentRelease; sisyphusDir: string; ignore: string[]; lastStone: LastStone; From 681f60e3c64616b5db5196ba364358d47bff5d9c Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Thu, 12 Mar 2026 15:32:39 +0100 Subject: [PATCH 08/38] refactor(sisyphus): merge create-stone and release-pr workflows to avoid PAT requirement --- .../src/commands/actions/CiGenerator.ts | 13 +++---- .../sisyphus/src/commands/actions/index.ts | 2 +- .../sisyphus/src/commands/actions/init.ts | 2 -- .../templates/github/sis-create-stone.yml | 35 ++++++++++++++----- .../templates/github/sis-release-pr.yml | 28 --------------- .../templates/gitlab/sis-create-stone.yml | 17 ++++++--- .../templates/gitlab/sis-release-pr.yml | 10 ------ 7 files changed, 44 insertions(+), 63 deletions(-) delete mode 100644 packages/sisyphus/src/commands/actions/templates/github/sis-release-pr.yml delete mode 100644 packages/sisyphus/src/commands/actions/templates/gitlab/sis-release-pr.yml diff --git a/packages/sisyphus/src/commands/actions/CiGenerator.ts b/packages/sisyphus/src/commands/actions/CiGenerator.ts index 5b185ac..d2fba16 100644 --- a/packages/sisyphus/src/commands/actions/CiGenerator.ts +++ b/packages/sisyphus/src/commands/actions/CiGenerator.ts @@ -4,7 +4,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import type { Provider } from "../../providers"; -export type WorkflowType = "create-stone" | "release-pr" | "release"; +export type WorkflowType = "create-stone" | "release"; export type WorkflowFile = { path: string; content: string }; @@ -12,14 +12,10 @@ export type WorkflowConfig = { name: string; description: string }; export const WORKFLOW_CONFIGS: Record = { "create-stone": { - description: "When a PR is merged, automatically create a stone", - name: "Auto-create stone from PR", - }, - release: { description: "When the release PR is merged, run the release", name: "Auto-release on PR merge" }, - "release-pr": { - description: "When stones are added, create or update a release PR", - name: "Create/update release PR", + 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"); @@ -27,7 +23,6 @@ const TEMPLATES_DIR = join(dirname(fileURLToPath(import.meta.url)), "templates") const TEMPLATE_FILENAMES: Record = { "create-stone": "sis-create-stone.yml", release: "sis-release.yml", - "release-pr": "sis-release-pr.yml", }; export abstract class CiGenerator { diff --git a/packages/sisyphus/src/commands/actions/index.ts b/packages/sisyphus/src/commands/actions/index.ts index 2378690..ddb0c4f 100644 --- a/packages/sisyphus/src/commands/actions/index.ts +++ b/packages/sisyphus/src/commands/actions/index.ts @@ -4,7 +4,7 @@ import { ActionsReleasePrCommand } from "./release-pr"; export class ActionsCommand extends BaseCommand { name = "actions"; - description = "GitHub Actions integration"; + 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 index 7580341..fef930b 100644 --- a/packages/sisyphus/src/commands/actions/init.ts +++ b/packages/sisyphus/src/commands/actions/init.ts @@ -13,7 +13,6 @@ const initArgs = args({ 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" }, - releasePr: { default: false, description: "Install release-pr workflow", type: "boolean" }, yes: { alias: "y", default: false, description: "Skip confirmation prompts", type: "boolean" }, }); @@ -100,7 +99,6 @@ export class ActionsInitCommand extends BaseCommand { private getWorkflowsFromFlags(ctx: InitCtx): WorkflowType[] { const selected: WorkflowType[] = []; if (ctx.args.createStone) selected.push("create-stone"); - if (ctx.args.releasePr) selected.push("release-pr"); if (ctx.args.release) selected.push("release"); return selected; } 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 index 8aba06c..3a43177 100644 --- a/packages/sisyphus/src/commands/actions/templates/github/sis-create-stone.yml +++ b/packages/sisyphus/src/commands/actions/templates/github/sis-create-stone.yml @@ -1,4 +1,4 @@ -name: Create Stone from PR +name: Sisyphus - Handle PR Merge on: pull_request: @@ -6,12 +6,12 @@ on: branches: [main] jobs: - create-stone: - if: github.event.pull_request.merged == true + 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: read + pull-requests: write steps: - uses: actions/checkout@v4 with: @@ -22,15 +22,32 @@ jobs: - name: Install dependencies run: bun install + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + - name: Create stone from PR - run: bunx @r5n/sisyphus pr --url ${{ github.event.pull_request.html_url }} --yes + id: stone + run: | + OUTPUT=$(bunx @r5n/sisyphus pr --url ${{ github.event.pull_request.html_url }} --yes 2>&1) || true + echo "$OUTPUT" + if echo "$OUTPUT" | grep -q "Skipping:"; then + echo "skipped=true" >> $GITHUB_OUTPUT + else + echo "skipped=false" >> $GITHUB_OUTPUT + fi env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Commit and push stone + if: steps.stone.outputs.skipped == 'false' run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" git add .sisyphus/stones/ - git diff --cached --quiet || git commit -m "chore: add stone from PR #${{ github.event.pull_request.number }}" - git push + 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-pr.yml b/packages/sisyphus/src/commands/actions/templates/github/sis-release-pr.yml deleted file mode 100644 index 1e141b8..0000000 --- a/packages/sisyphus/src/commands/actions/templates/github/sis-release-pr.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Create Release PR - -on: - push: - branches: [main] - paths: - - '.sisyphus/stones/**' - -jobs: - release-pr: - 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: Create or update release PR - run: bunx @r5n/sisyphus actions release-pr - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_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 index c054dda..3946ed0 100644 --- a/packages/sisyphus/src/commands/actions/templates/gitlab/sis-create-stone.yml +++ b/packages/sisyphus/src/commands/actions/templates/gitlab/sis-create-stone.yml @@ -1,13 +1,22 @@ -create-stone: +handle-merge: stage: sisyphus image: oven/bun:latest rules: - - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_STATE == "merged" + - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_STATE == "merged" && $CI_MERGE_REQUEST_LABELS !~ /sisyphus-release/ script: - - bun install - - bunx @r5n/sisyphus pr --url $CI_MERGE_REQUEST_PROJECT_URL/-/merge_requests/$CI_MERGE_REQUEST_IID --yes - git config user.name "gitlab-ci[bot]" - git config user.email "gitlab-ci[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) || true + echo "$OUTPUT" + if echo "$OUTPUT" | grep -q "Skipping:"; then + echo "Stone creation skipped" + exit 0 + 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-pr.yml b/packages/sisyphus/src/commands/actions/templates/gitlab/sis-release-pr.yml deleted file mode 100644 index f7e65d8..0000000 --- a/packages/sisyphus/src/commands/actions/templates/gitlab/sis-release-pr.yml +++ /dev/null @@ -1,10 +0,0 @@ -release-pr: - stage: sisyphus - image: oven/bun:latest - rules: - - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - changes: - - .sisyphus/stones/**/* - script: - - bun install - - bunx @r5n/sisyphus actions release-pr From d714285ef24923bee22ff6b8b7117ca4c793edae Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Thu, 12 Mar 2026 15:52:17 +0100 Subject: [PATCH 09/38] fix(sisyphus): remove .quiet() from git providers shell commands to surface errors --- packages/sisyphus/src/providers/GitHubProvider.ts | 6 +++--- packages/sisyphus/src/providers/GitLabProvider.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/sisyphus/src/providers/GitHubProvider.ts b/packages/sisyphus/src/providers/GitHubProvider.ts index d96bc5c..4b17a63 100644 --- a/packages/sisyphus/src/providers/GitHubProvider.ts +++ b/packages/sisyphus/src/providers/GitHubProvider.ts @@ -59,7 +59,7 @@ export class GitHubProvider extends GitProvider { const labelArgs = options.labels?.flatMap((l) => ["--label", l]) ?? []; const result = - await Bun.$`gh pr create --head ${options.head} --base ${options.base} --title ${options.title} --body ${options.body} ${labelArgs}`.quiet(); + await Bun.$`gh pr create --head ${options.head} --base ${options.base} --title ${options.title} --body ${options.body} ${labelArgs}`; const url = result.stdout.toString().trim(); const number = this.extractPrNumber(url); @@ -72,7 +72,7 @@ export class GitHubProvider extends GitProvider { if (options.title) optionalArgs.push("--title", options.title); if (options.body) optionalArgs.push("--body", options.body); - await Bun.$`gh pr edit ${number} ${optionalArgs}`.quiet(); + await Bun.$`gh pr edit ${number} ${optionalArgs}`; } async getPr(number: number): Promise { @@ -134,7 +134,7 @@ export class GitHubProvider extends GitProvider { } async createRelease(options: CreateReleaseOptions): Promise { - await Bun.$`gh release create ${options.tag} --title ${options.title} --notes ${options.notes}`.quiet(); + await Bun.$`gh release create ${options.tag} --title ${options.title} --notes ${options.notes}`; } async deleteRelease(tag: string): Promise { diff --git a/packages/sisyphus/src/providers/GitLabProvider.ts b/packages/sisyphus/src/providers/GitLabProvider.ts index 093e89d..e205221 100644 --- a/packages/sisyphus/src/providers/GitLabProvider.ts +++ b/packages/sisyphus/src/providers/GitLabProvider.ts @@ -62,7 +62,7 @@ export class GitLabProvider extends GitProvider { 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`.quiet(); + 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); @@ -73,7 +73,7 @@ export class GitLabProvider extends GitProvider { if (options.title) optionalArgs.push("--title", options.title); if (options.body) optionalArgs.push("--description", options.body); - await Bun.$`glab mr update ${number} ${optionalArgs}`.quiet(); + await Bun.$`glab mr update ${number} ${optionalArgs}`; } async getPr(number: number): Promise { @@ -114,7 +114,7 @@ export class GitLabProvider extends GitProvider { } async createRelease(options: CreateReleaseOptions): Promise { - await Bun.$`glab release create ${options.tag} --name ${options.title} --notes ${options.notes}`.quiet(); + await Bun.$`glab release create ${options.tag} --name ${options.title} --notes ${options.notes}`; } async deleteRelease(tag: string): Promise { From ab43eb6857afb5ab8e56f88a13f05d482cf9a733 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Thu, 12 Mar 2026 16:01:01 +0100 Subject: [PATCH 10/38] fix(sisyphus): properly propagate errors in workflow templates while allowing skips --- .../commands/actions/templates/github/sis-create-stone.yml | 5 ++++- .../commands/actions/templates/gitlab/sis-create-stone.yml | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) 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 index 3a43177..578a339 100644 --- a/packages/sisyphus/src/commands/actions/templates/github/sis-create-stone.yml +++ b/packages/sisyphus/src/commands/actions/templates/github/sis-create-stone.yml @@ -30,12 +30,15 @@ jobs: - name: Create stone from PR id: stone run: | - OUTPUT=$(bunx @r5n/sisyphus pr --url ${{ github.event.pull_request.html_url }} --yes 2>&1) || true + 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 }} 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 index 3946ed0..60f45c8 100644 --- a/packages/sisyphus/src/commands/actions/templates/gitlab/sis-create-stone.yml +++ b/packages/sisyphus/src/commands/actions/templates/gitlab/sis-create-stone.yml @@ -8,12 +8,15 @@ handle-merge: - git config user.email "gitlab-ci[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) || true + 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 From 3d2564dea9542f88fe417afff87847e20f052168 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Thu, 12 Mar 2026 16:07:37 +0100 Subject: [PATCH 11/38] fix(cli-core): exit with code 1 on errors --- packages/core/src/abstract-cli.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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); } } From face1fe5b3ff6375a489144ad0cd3736f2f0625b Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Thu, 12 Mar 2026 17:26:13 +0100 Subject: [PATCH 12/38] fix(sisyphus): support publish-only mode in ReleaseOrchestrator Use pkg.version as fallback when pkg.newVersion is undefined (packages created via withVersion() don't have bump set). Add pushTags() method to push tags before creating GitHub releases in publish-only flow. --- packages/sisyphus/src/commands/roll.ts | 5 ++-- .../src/services/ReleaseOrchestrator.ts | 30 ++++++++++--------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/sisyphus/src/commands/roll.ts b/packages/sisyphus/src/commands/roll.ts index 672f604..d274c53 100644 --- a/packages/sisyphus/src/commands/roll.ts +++ b/packages/sisyphus/src/commands/roll.ts @@ -324,9 +324,10 @@ export class RollCommand extends BaseCommand { try { if (options.tags) { - s.start("Creating git tags..."); + s.start("Creating and pushing git tags..."); await orchestrator.createGitTags(packages); - s.stop("Git tags created"); + await orchestrator.pushTags(); + s.stop("Git tags pushed"); } if (options.npm) { diff --git a/packages/sisyphus/src/services/ReleaseOrchestrator.ts b/packages/sisyphus/src/services/ReleaseOrchestrator.ts index 13bc35e..d453312 100644 --- a/packages/sisyphus/src/services/ReleaseOrchestrator.ts +++ b/packages/sisyphus/src/services/ReleaseOrchestrator.ts @@ -102,10 +102,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); } @@ -125,12 +123,19 @@ 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 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(stone: Stone, packages: Package[]) { if (this.options.dryRun) return; @@ -138,10 +143,9 @@ export class ReleaseOrchestrator { 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 version = pkg.newVersion ?? pkg.version; + const tagName = `${pkg.name}@${version}`; + const title = `${pkg.name} v${version}`; const notes = this.formatReleaseNotes(stone, pkg); await this.run( @@ -163,8 +167,9 @@ export class ReleaseOrchestrator { lines.push(""); } + const version = pkg.newVersion ?? pkg.version; lines.push(`**Package:** \`${pkg.name}\``); - lines.push(`**Version:** ${pkg.version} → ${pkg.newVersion}`); + lines.push(`**Version:** ${pkg.version} → ${version}`); const commits = this.filterCommitsForPackage(stone.commits, pkg.name); if (commits.length > 0) { @@ -230,9 +235,6 @@ export class ReleaseOrchestrator { } private async publishPackage(pkg: Package) { - const newVersion = pkg.newVersion; - if (!newVersion) return; - const tag = this.config.get("tag") || "latest"; const pkgDir = dirname(pkg.file); @@ -245,7 +247,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) From 0eb059391642b6538ea958337c2644f7b6f76580 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Fri, 13 Mar 2026 13:30:55 +0100 Subject: [PATCH 13/38] fix(sisyphus): use REST API for PR creation to avoid GraphQL permission issues gh pr create uses GraphQL which fails with GITHUB_TOKEN in Actions. The REST API works reliably with standard token permissions. --- .../sisyphus/src/providers/GitHubProvider.ts | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/sisyphus/src/providers/GitHubProvider.ts b/packages/sisyphus/src/providers/GitHubProvider.ts index 4b17a63..3fadf83 100644 --- a/packages/sisyphus/src/providers/GitHubProvider.ts +++ b/packages/sisyphus/src/providers/GitHubProvider.ts @@ -56,15 +56,18 @@ export class GitHubProvider extends GitProvider { } async createPr(options: CreatePrOptions): Promise { - const labelArgs = options.labels?.flatMap((l) => ["--label", l]) ?? []; - const result = - await Bun.$`gh pr create --head ${options.head} --base ${options.base} --title ${options.title} --body ${options.body} ${labelArgs}`; + 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}`.quiet(); + + const data = JSON.parse(result.stdout.toString()); + const prNumber = data.number as number; - const url = result.stdout.toString().trim(); - const number = this.extractPrNumber(url); + 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}`.quiet(); + } - return this.getPr(number); + return this.getPr(prNumber); } async updatePr(number: number, options: UpdatePrOptions): Promise { @@ -143,12 +146,6 @@ export class GitHubProvider extends GitProvider { } catch {} } - private extractPrNumber(url: string): number { - const match = url.match(/\/pull\/(\d+)$/); - if (!match?.[1]) throw new Exit("Failed to parse PR number from URL", url); - return Number.parseInt(match[1], 10); - } - private mapPrResponse(data: Record): PullRequest { return { author: (data.author as { login?: string })?.login ?? "unknown", From a70a45694ca572467f45ddf9113212c009bb10df Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Fri, 13 Mar 2026 14:05:36 +0100 Subject: [PATCH 14/38] fix(sisyphus): separate --all and --yes flags in actions init --all selects all workflows, --yes only skips confirmation prompts. Non-interactive mode now requires explicit workflow selection. --- packages/sisyphus/src/commands/actions/init.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/sisyphus/src/commands/actions/init.ts b/packages/sisyphus/src/commands/actions/init.ts index fef930b..e710c3a 100644 --- a/packages/sisyphus/src/commands/actions/init.ts +++ b/packages/sisyphus/src/commands/actions/init.ts @@ -80,8 +80,8 @@ export class ActionsInitCommand extends BaseCommand { const fromFlags = this.getWorkflowsFromFlags(ctx); if (fromFlags.length > 0) return fromFlags; - if (ctx.args.all || !ctx.interactive) { - return Object.keys(WORKFLOW_CONFIGS) as WorkflowType[]; + if (!ctx.interactive) { + throw new Exit("No workflows specified", "Use --all or specify workflows with --createStone, --release"); } return multiselect({ @@ -97,6 +97,8 @@ export class ActionsInitCommand extends BaseCommand { } 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"); From 2ca1ea25898801c6b157606cf9ed0dc98a2b23ab Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Fri, 13 Mar 2026 14:17:17 +0100 Subject: [PATCH 15/38] fix(sisyphus): show shell errors for critical git and GitHub API operations Remove .quiet() from git and gh commands in release-pr and createPr so errors are visible when operations fail. --- .../src/commands/actions/release-pr.ts | 26 +++++++++---------- .../sisyphus/src/providers/GitHubProvider.ts | 4 +-- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/sisyphus/src/commands/actions/release-pr.ts b/packages/sisyphus/src/commands/actions/release-pr.ts index b1b8e08..05cfc50 100644 --- a/packages/sisyphus/src/commands/actions/release-pr.ts +++ b/packages/sisyphus/src/commands/actions/release-pr.ts @@ -144,37 +144,37 @@ export class ActionsReleasePrCommand extends BaseCommand { const provider = await this.getProvider(); const baseBranch = await provider.getDefaultBranch(); - await Bun.$`git checkout -B ${RELEASE_BRANCH} origin/${baseBranch}`.quiet(); + await Bun.$`git checkout -B ${RELEASE_BRANCH} origin/${baseBranch}`; await this.applyReleaseChanges(ctx, stones, packages); - await Bun.$`git add -A`.quiet(); - await Bun.$`git commit -m ${`${PR_TITLE_PREFIX} prepare release`}`.quiet(); - await Bun.$`git push -u origin ${RELEASE_BRANCH} --force`.quiet(); + await Bun.$`git add -A`; + await Bun.$`git commit -m ${`${PR_TITLE_PREFIX} prepare release`}`; + await Bun.$`git push -u origin ${RELEASE_BRANCH} --force`; - await Bun.$`git checkout ${baseBranch}`.quiet(); + await Bun.$`git checkout ${baseBranch}`; } private async updateReleaseBranch(ctx: ReleasePrCtx, stones: Stone[], packages: Package[]) { const provider = await this.getProvider(); const baseBranch = await provider.getDefaultBranch(); - await Bun.$`git fetch origin ${baseBranch}`.quiet(); - await Bun.$`git checkout ${RELEASE_BRANCH}`.quiet(); - await Bun.$`git reset --hard origin/${baseBranch}`.quiet(); + await Bun.$`git fetch origin ${baseBranch}`; + await Bun.$`git checkout ${RELEASE_BRANCH}`; + await Bun.$`git reset --hard origin/${baseBranch}`; await this.applyReleaseChanges(ctx, stones, packages); - await Bun.$`git add -A`.quiet(); + await Bun.$`git add -A`; - const hasChanges = await Bun.$`git diff --cached --quiet`.nothrow().quiet(); + const hasChanges = await Bun.$`git diff --cached --quiet`.nothrow(); if (hasChanges.exitCode !== 0) { - await Bun.$`git commit -m ${`${PR_TITLE_PREFIX} prepare release`}`.quiet(); + await Bun.$`git commit -m ${`${PR_TITLE_PREFIX} prepare release`}`; } - await Bun.$`git push origin ${RELEASE_BRANCH} --force`.quiet(); + await Bun.$`git push origin ${RELEASE_BRANCH} --force`; - await Bun.$`git checkout ${baseBranch}`.quiet(); + await Bun.$`git checkout ${baseBranch}`; } private async applyReleaseChanges(ctx: ReleasePrCtx, stones: Stone[], packages: Package[]) { diff --git a/packages/sisyphus/src/providers/GitHubProvider.ts b/packages/sisyphus/src/providers/GitHubProvider.ts index 3fadf83..a46e874 100644 --- a/packages/sisyphus/src/providers/GitHubProvider.ts +++ b/packages/sisyphus/src/providers/GitHubProvider.ts @@ -57,14 +57,14 @@ export class GitHubProvider extends GitProvider { 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}`.quiet(); + 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}`.quiet(); + await Bun.$`gh api repos/${this.owner}/${this.repo}/issues/${prNumber}/labels --method POST ${labelArgs}`; } return this.getPr(prNumber); From a248b836d9971ef9f3a36b830164c554a0a235ec Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Fri, 13 Mar 2026 14:23:18 +0100 Subject: [PATCH 16/38] fix(sisyphus): stash changes before switching branches in release-pr Prevents failures when bun.lock or other untracked files exist. --- packages/sisyphus/src/commands/actions/release-pr.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/sisyphus/src/commands/actions/release-pr.ts b/packages/sisyphus/src/commands/actions/release-pr.ts index 05cfc50..3ea549e 100644 --- a/packages/sisyphus/src/commands/actions/release-pr.ts +++ b/packages/sisyphus/src/commands/actions/release-pr.ts @@ -144,6 +144,7 @@ export class ActionsReleasePrCommand extends BaseCommand { const provider = await this.getProvider(); const baseBranch = await provider.getDefaultBranch(); + await this.stashChanges(); await Bun.$`git checkout -B ${RELEASE_BRANCH} origin/${baseBranch}`; await this.applyReleaseChanges(ctx, stones, packages); @@ -160,8 +161,8 @@ export class ActionsReleasePrCommand extends BaseCommand { const baseBranch = await provider.getDefaultBranch(); await Bun.$`git fetch origin ${baseBranch}`; - await Bun.$`git checkout ${RELEASE_BRANCH}`; - await Bun.$`git reset --hard origin/${baseBranch}`; + await this.stashChanges(); + await Bun.$`git checkout -B ${RELEASE_BRANCH} origin/${baseBranch}`; await this.applyReleaseChanges(ctx, stones, packages); @@ -177,6 +178,10 @@ export class ActionsReleasePrCommand extends BaseCommand { await Bun.$`git checkout ${baseBranch}`; } + private async stashChanges() { + await Bun.$`git stash --include-untracked`.nothrow(); + } + private async applyReleaseChanges(ctx: ReleasePrCtx, stones: Stone[], packages: Package[]) { const changelogConfig = ctx.config.get("changelog"); const generator = new ChangelogGenerator(changelogConfig); From f61cbcfb9d4dc3ae96848c0affc731197534c678 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Fri, 13 Mar 2026 14:28:42 +0100 Subject: [PATCH 17/38] fix(sisyphus): only stage release-related files in release-pr --- .../src/commands/actions/release-pr.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/sisyphus/src/commands/actions/release-pr.ts b/packages/sisyphus/src/commands/actions/release-pr.ts index 3ea549e..f9ede80 100644 --- a/packages/sisyphus/src/commands/actions/release-pr.ts +++ b/packages/sisyphus/src/commands/actions/release-pr.ts @@ -147,9 +147,9 @@ export class ActionsReleasePrCommand extends BaseCommand { await this.stashChanges(); await Bun.$`git checkout -B ${RELEASE_BRANCH} origin/${baseBranch}`; - await this.applyReleaseChanges(ctx, stones, packages); + const changedFiles = await this.applyReleaseChanges(ctx, stones, packages); - await Bun.$`git add -A`; + await Bun.$`git add ${changedFiles}`; await Bun.$`git commit -m ${`${PR_TITLE_PREFIX} prepare release`}`; await Bun.$`git push -u origin ${RELEASE_BRANCH} --force`; @@ -164,9 +164,9 @@ export class ActionsReleasePrCommand extends BaseCommand { await this.stashChanges(); await Bun.$`git checkout -B ${RELEASE_BRANCH} origin/${baseBranch}`; - await this.applyReleaseChanges(ctx, stones, packages); + const changedFiles = await this.applyReleaseChanges(ctx, stones, packages); - await Bun.$`git add -A`; + await Bun.$`git add ${changedFiles}`; const hasChanges = await Bun.$`git diff --cached --quiet`.nothrow(); if (hasChanges.exitCode !== 0) { @@ -182,15 +182,20 @@ export class ActionsReleasePrCommand extends BaseCommand { await Bun.$`git stash --include-untracked`.nothrow(); } - private async applyReleaseChanges(ctx: ReleasePrCtx, stones: Stone[], packages: Package[]) { + private async applyReleaseChanges(ctx: ReleasePrCtx, stones: Stone[], packages: Package[]): Promise { const changelogConfig = ctx.config.get("changelog"); const generator = new ChangelogGenerator(changelogConfig); const updater = new PackageUpdater(); + const sisyphusDir = ctx.config.get("sisyphusDir"); + + const changedFiles: string[] = []; await updater.updateAll(packages); + changedFiles.push(...packages.map((p) => p.file)); if (changelogConfig.generate) { await generator.generate(stones, packages); + changedFiles.push(changelogConfig.filename, `**/${changelogConfig.filename}`); } const manager = new StoneManager(ctx.config); @@ -204,6 +209,10 @@ export class ActionsReleasePrCommand extends BaseCommand { } ctx.config.set("currentRelease", { packages: packageVersions, stoneIds: stones.map((s) => s.id), timestamp }); + + changedFiles.push(sisyphusDir); + + return changedFiles; } private async createPr(title: string, body: string): Promise<{ number: number; url: string }> { From af12fa34f39e60c174babd71a5e01817309fbba5 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Fri, 13 Mar 2026 15:30:56 +0100 Subject: [PATCH 18/38] feat(sisyphus): add workflow_dispatch to release workflow template --- .../src/commands/actions/templates/github/sis-release.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/sisyphus/src/commands/actions/templates/github/sis-release.yml b/packages/sisyphus/src/commands/actions/templates/github/sis-release.yml index 9d5dbcc..0b0146e 100644 --- a/packages/sisyphus/src/commands/actions/templates/github/sis-release.yml +++ b/packages/sisyphus/src/commands/actions/templates/github/sis-release.yml @@ -1,13 +1,14 @@ name: Release on: + workflow_dispatch: pull_request: types: [closed] branches: [main] jobs: release: - if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'sisyphus-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 From ae9aa4ad5beb90edcee6a4d9e5c3db8438f8fb21 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Fri, 13 Mar 2026 16:41:06 +0100 Subject: [PATCH 19/38] fix(sisyphus): store old version in currentRelease for release notes --- packages/sisyphus/src/commands/actions/release-pr.ts | 5 +++-- packages/sisyphus/src/commands/roll.ts | 4 ++-- packages/sisyphus/src/domain/Package.ts | 9 +++++++++ packages/sisyphus/src/types.ts | 4 +++- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/sisyphus/src/commands/actions/release-pr.ts b/packages/sisyphus/src/commands/actions/release-pr.ts index f9ede80..f5270d0 100644 --- a/packages/sisyphus/src/commands/actions/release-pr.ts +++ b/packages/sisyphus/src/commands/actions/release-pr.ts @@ -3,6 +3,7 @@ import { BaseCommand, type Ctx } from "../../base-command"; import { Package, Stone } from "../../domain"; import { createGitProvider, type GitProvider } from "../../providers"; import { ChangelogGenerator, PackageUpdater, StoneManager, WorkspaceScanner } from "../../services"; +import type { PackageRelease } from "../../types"; const RELEASE_BRANCH = "sisyphus/release"; const RELEASE_LABEL = "sisyphus-release"; @@ -201,10 +202,10 @@ export class ActionsReleasePrCommand extends BaseCommand { const manager = new StoneManager(ctx.config); const timestamp = await manager.archive(stones); - const packageVersions: Record = {}; + const packageVersions: Record = {}; for (const pkg of packages) { if (pkg.newVersion) { - packageVersions[pkg.name] = pkg.newVersion; + packageVersions[pkg.name] = { newVersion: pkg.newVersion, oldVersion: pkg.version }; } } diff --git a/packages/sisyphus/src/commands/roll.ts b/packages/sisyphus/src/commands/roll.ts index d274c53..3bd57cd 100644 --- a/packages/sisyphus/src/commands/roll.ts +++ b/packages/sisyphus/src/commands/roll.ts @@ -263,10 +263,10 @@ export class RollCommand extends BaseCommand { const { packages: allPackages } = await WorkspaceScanner.scan({ single: ctx.config.get("single") }); const packagesToPublish: Package[] = []; - for (const [name, version] of packageEntries) { + for (const [name, { oldVersion, newVersion }] of packageEntries) { const pkg = allPackages.get(name); if (pkg) { - packagesToPublish.push(pkg.withVersion(version)); + packagesToPublish.push(pkg.withVersions(oldVersion, newVersion)); } } diff --git a/packages/sisyphus/src/domain/Package.ts b/packages/sisyphus/src/domain/Package.ts index 80c94cc..9e69e1f 100644 --- a/packages/sisyphus/src/domain/Package.ts +++ b/packages/sisyphus/src/domain/Package.ts @@ -17,6 +17,7 @@ export type PackageOptions = { dependencyOf?: readonly string[]; bump?: BumpType; tag?: string; + newVersion?: string; }; export class Package { @@ -26,6 +27,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; @@ -34,6 +36,7 @@ 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 { @@ -56,6 +59,7 @@ export class Package { } get newVersion(): string | undefined { + if (this._newVersion) return this._newVersion; if (!this.bump) return undefined; return VersionCalculator.bump(this.version, this.bump, this.tag); } @@ -77,12 +81,17 @@ export class Package { return new Package({ ...this.toOptions(), version }); } + withVersions(oldVersion: string, newVersion: string): Package { + return new Package({ ...this.toOptions(), newVersion, version: oldVersion }); + } + private toOptions(): PackageOptions { return { bump: this.bump, dependencyOf: this.dependencyOf, file: this.file, name: this.name, + newVersion: this._newVersion, tag: this.tag, version: this.version, }; diff --git a/packages/sisyphus/src/types.ts b/packages/sisyphus/src/types.ts index 07e2a05..b3b089b 100644 --- a/packages/sisyphus/src/types.ts +++ b/packages/sisyphus/src/types.ts @@ -4,7 +4,9 @@ export type CommitConfig = { author: string; email?: string; message: string }; export type ReleaseConfig = { createRelease: boolean; npm: boolean; push: boolean; tags: boolean }; -export type CurrentRelease = { packages: Record; stoneIds: string[]; timestamp: string }; +export type PackageRelease = { oldVersion: string; newVersion: string }; + +export type CurrentRelease = { packages: Record; stoneIds: string[]; timestamp: string }; export type ChangelogSections = { breaking: string; From b844179ca5e93d2667b3695e774fdbf99a5c009e Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Fri, 13 Mar 2026 16:51:48 +0100 Subject: [PATCH 20/38] fix(sisyphus): show new version in publish-only log output --- packages/sisyphus/src/commands/roll.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sisyphus/src/commands/roll.ts b/packages/sisyphus/src/commands/roll.ts index 3bd57cd..be3ed43 100644 --- a/packages/sisyphus/src/commands/roll.ts +++ b/packages/sisyphus/src/commands/roll.ts @@ -281,7 +281,7 @@ export class RollCommand extends BaseCommand { log.info(color.bold("Packages to publish:")); for (const pkg of packagesToPublish) { - log.info(` ${pkg.name}@${pkg.version}`); + log.info(` ${pkg.name}@${pkg.newVersion ?? pkg.version}`); } log.info(""); From 620c76c8d14f9ecfc28bd955da8dccd2e9b898d3 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Mon, 16 Mar 2026 14:10:14 +0100 Subject: [PATCH 21/38] fix(sisyphus): improve GitLab workflow templates Add git fetch --unshallow for full history, manual trigger support, and fix skip logic to avoid running unnecessary commands. --- .../actions/templates/gitlab/sis-create-stone.yml | 9 +++++---- .../commands/actions/templates/gitlab/sis-release.yml | 6 ++++++ 2 files changed, 11 insertions(+), 4 deletions(-) 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 index 60f45c8..c6ae0a6 100644 --- a/packages/sisyphus/src/commands/actions/templates/gitlab/sis-create-stone.yml +++ b/packages/sisyphus/src/commands/actions/templates/gitlab/sis-create-stone.yml @@ -4,6 +4,7 @@ handle-merge: 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 "gitlab-ci[bot]" - git config user.email "gitlab-ci[bot]@users.noreply.gitlab.com" - bun install @@ -17,9 +18,9 @@ handle-merge: 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 + 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 index e8b9070..b843d0c 100644 --- a/packages/sisyphus/src/commands/actions/templates/gitlab/sis-release.yml +++ b/packages/sisyphus/src/commands/actions/templates/gitlab/sis-release.yml @@ -2,7 +2,13 @@ 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 From 234a387ece254d284f81d28a3cf060ecf0baea20 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Mon, 16 Mar 2026 15:35:32 +0100 Subject: [PATCH 22/38] feat(sisyphus): explicitly detect and handle squash vs merge PRs When analyzing a merged PR, detect the merge method by checking if the merge commit has 1 parent (squash) or 2 parents (merge). For squash merges, create a synthetic commit from the PR title and fetch files from the API. For merge commits, extract the original commits from the merge ancestry. --- packages/sisyphus/src/constants.ts | 6 +- packages/sisyphus/src/domain/Commit.ts | 17 ++- packages/sisyphus/src/domain/index.ts | 1 + .../src/providers/BitbucketProvider.ts | 4 + .../sisyphus/src/providers/GitHubProvider.ts | 22 ++- .../sisyphus/src/providers/GitLabProvider.ts | 14 ++ .../sisyphus/src/providers/GitProvider.ts | 5 + packages/sisyphus/src/providers/index.ts | 9 +- .../src/services/PullRequestAnalyzer.ts | 131 ++++++++++++++---- 9 files changed, 178 insertions(+), 31 deletions(-) diff --git a/packages/sisyphus/src/constants.ts b/packages/sisyphus/src/constants.ts index 5aa40fd..e8b4341 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, @@ -27,6 +28,9 @@ 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 DEFAULT_CONFIG_FILE = "config.json"; export const DEFAULT_CHANGELOG_FILE = "CHANGELOG.md"; diff --git a/packages/sisyphus/src/domain/Commit.ts b/packages/sisyphus/src/domain/Commit.ts index c02af09..3e27894 100644 --- a/packages/sisyphus/src/domain/Commit.ts +++ b/packages/sisyphus/src/domain/Commit.ts @@ -1,8 +1,7 @@ -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([ @@ -95,6 +94,18 @@ export class Commit { } } + 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); 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 index 0d29865..df72c6c 100644 --- a/packages/sisyphus/src/providers/BitbucketProvider.ts +++ b/packages/sisyphus/src/providers/BitbucketProvider.ts @@ -48,6 +48,10 @@ export class BitbucketProvider extends GitProvider { this.notImplemented(); } + async getPrFiles(_number: number): Promise { + this.notImplemented(); + } + async ensureLabelExists(_name: string, _options?: CreateLabelOptions): Promise { this.notImplemented(); } diff --git a/packages/sisyphus/src/providers/GitHubProvider.ts b/packages/sisyphus/src/providers/GitHubProvider.ts index a46e874..4a4413d 100644 --- a/packages/sisyphus/src/providers/GitHubProvider.ts +++ b/packages/sisyphus/src/providers/GitHubProvider.ts @@ -44,7 +44,7 @@ export class GitHubProvider extends GitProvider { if (options.label) optionalArgs.push("--label", options.label); const result = - await Bun.$`gh pr list --json number,url,title,body,labels,author,headRefName,baseRefName --limit 1 ${optionalArgs}`.quiet(); + 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; @@ -89,6 +89,8 @@ export class GitHubProvider extends GitProvider { body: data.body ?? "", headBranch: data.head?.ref ?? "", labels: data.labels?.map((l: { name: string }) => l.name) ?? [], + mergeCommitSha: data.merge_commit_sha ?? null, + merged: data.merged ?? false, number: data.number, title: data.title, url: data.html_url, @@ -100,7 +102,8 @@ export class GitHubProvider extends GitProvider { async getPrFromCurrentBranch(): Promise { try { - const result = await Bun.$`gh pr view --json number,title,body,labels,author,headRefName,baseRefName,url`.quiet(); + 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 { @@ -109,6 +112,8 @@ export class GitHubProvider extends GitProvider { body: data.body ?? "", headBranch: data.headRefName, labels: data.labels?.map((l: { name: string }) => l.name) ?? [], + mergeCommitSha: data.mergeCommit?.oid ?? null, + merged: data.state === "MERGED", number: data.number, title: data.title, url: data.url, @@ -128,6 +133,16 @@ export class GitHubProvider extends GitProvider { } } + 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 ?? ""; @@ -147,12 +162,15 @@ export class GitHubProvider extends GitProvider { } private mapPrResponse(data: Record): PullRequest { + const mergeCommit = data.mergeCommit as { oid?: string } | undefined; return { author: (data.author as { login?: string })?.login ?? "unknown", baseBranch: data.baseRefName as string, body: (data.body as string) ?? "", headBranch: data.headRefName as string, labels: ((data.labels as { name: string }[]) ?? []).map((l) => l.name), + mergeCommitSha: mergeCommit?.oid ?? null, + merged: data.state === "MERGED", number: data.number as number, title: data.title as string, url: data.url as string, diff --git a/packages/sisyphus/src/providers/GitLabProvider.ts b/packages/sisyphus/src/providers/GitLabProvider.ts index e205221..c215262 100644 --- a/packages/sisyphus/src/providers/GitLabProvider.ts +++ b/packages/sisyphus/src/providers/GitLabProvider.ts @@ -105,6 +105,18 @@ export class GitLabProvider extends GitProvider { } } + 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 = JSON.parse(result.stdout.toString()); + const changes = data.changes as { new_path: string }[]; + return changes?.map((c) => c.new_path) ?? []; + } catch { + return []; + } + } + async ensureLabelExists(name: string, options?: CreateLabelOptions): Promise { try { const description = options?.description ?? ""; @@ -140,6 +152,8 @@ export class GitLabProvider extends GitProvider { body: (data.description as string) ?? "", headBranch: (data.source_branch as string) ?? "", labels: (data.labels as string[]) ?? [], + mergeCommitSha: (data.merge_commit_sha as string) ?? null, + merged: data.state === "merged", number: data.iid as number, title: data.title as string, url: data.web_url as string, diff --git a/packages/sisyphus/src/providers/GitProvider.ts b/packages/sisyphus/src/providers/GitProvider.ts index 8b1d640..564efab 100644 --- a/packages/sisyphus/src/providers/GitProvider.ts +++ b/packages/sisyphus/src/providers/GitProvider.ts @@ -1,3 +1,5 @@ +export type MergeMethod = "merge" | "squash" | "rebase"; + export type PullRequest = { number: number; url: string; @@ -7,6 +9,8 @@ export type PullRequest = { author: string; headBranch: string; baseBranch: string; + merged: boolean; + mergeCommitSha: string | null; }; export type CreatePrOptions = { head: string; base: string; title: string; body: string; labels?: string[] }; @@ -46,6 +50,7 @@ export abstract class GitProvider { 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; diff --git a/packages/sisyphus/src/providers/index.ts b/packages/sisyphus/src/providers/index.ts index e91ac55..4313dba 100644 --- a/packages/sisyphus/src/providers/index.ts +++ b/packages/sisyphus/src/providers/index.ts @@ -7,7 +7,14 @@ import type { Provider, PrUrlInfo, RemoteInfo } from "./GitProvider"; export { BitbucketProvider } from "./BitbucketProvider"; export { GitHubProvider } from "./GitHubProvider"; export { GitLabProvider } from "./GitLabProvider"; -export { GitProvider, type Provider, type PrUrlInfo, type PullRequest, type RemoteInfo } from "./GitProvider"; +export { + GitProvider, + type MergeMethod, + type Provider, + type PrUrlInfo, + type PullRequest, + type RemoteInfo, +} from "./GitProvider"; const GITHUB_PATTERN = /github\.com[:/]([^/]+)\/([^/.]+)/; const GITLAB_PATTERN = /gitlab\.com[:/]([^/]+)\/([^/.]+)/; diff --git a/packages/sisyphus/src/services/PullRequestAnalyzer.ts b/packages/sisyphus/src/services/PullRequestAnalyzer.ts index 7381275..726e3b3 100644 --- a/packages/sisyphus/src/services/PullRequestAnalyzer.ts +++ b/packages/sisyphus/src/services/PullRequestAnalyzer.ts @@ -1,11 +1,19 @@ import type { ConfigManager } from "@r5n/cli-core"; import { Exit } from "@r5n/cli-core"; +import { SHORT_HASH_LENGTH, UNKNOWN_HASH } from "../constants"; import { BumpType, Commit, type CommitInfo } from "../domain"; -import { createGitProvider, type GitProvider, type PullRequest, parsePrUrl } from "../providers"; +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"; +type AnalysisContext = { + provider: GitProvider; + url: string | undefined; + packagePaths: Map; + isSinglePackage: boolean; +}; + export type PullRequestInfo = { number: number; title: string; @@ -15,6 +23,8 @@ export type PullRequestInfo = { labels: string[]; author: string; url: string; + merged: boolean; + mergeCommitSha: string | null; }; export type PrAnalysisResult = { @@ -35,21 +45,82 @@ export class PullRequestAnalyzer { const pr = url ? await this.fetchFromUrl(provider, url) : await this.fetchFromCurrentBranch(provider); - let parsedCommits = await Commit.inRange(pr.baseBranch, pr.branch); - - if (parsedCommits.length === 0 && url) { - parsedCommits = await this.fetchCommitsFromApi(provider, url); - } - 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); + } + + 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, isSinglePackage); + const pkgs = findAffectedPackages(commit.files, ctx.packagePaths, ctx.isSinglePackage); if (pkgs.size === 0) continue; commits.push(commit.toInfo([...pkgs])); @@ -59,9 +130,31 @@ 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"; + } + + private async getCommitParentCount(sha: string): Promise { + try { + 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 { + return 1; + } + } + + private inferCommitType(title: string): string { + const match = title.match(/^(\w+)(?:\(.*?\))?!?:/); + return match?.[1] ?? "other"; } private async getProvider(): Promise { @@ -71,23 +164,11 @@ export class PullRequestAnalyzer { return this.provider; } - private async fetchCommitsFromApi(provider: GitProvider, url: string): Promise { + private async fetchFilesFromApi(provider: GitProvider, url: string): Promise { const urlInfo = parsePrUrl(url); if (!urlInfo) return []; - try { - const hashes = await provider.getPrCommits(urlInfo.number); - - const commits: Commit[] = []; - for (const hash of hashes) { - const commit = await Commit.fromHash(hash); - if (commit) commits.push(commit); - } - - return commits; - } catch { - return []; - } + return provider.getPrFiles(urlInfo.number); } private async fetchFromCurrentBranch(provider: GitProvider): Promise { @@ -120,6 +201,8 @@ export class PullRequestAnalyzer { body: pr.body, branch: pr.headBranch, labels: pr.labels, + mergeCommitSha: pr.mergeCommitSha, + merged: pr.merged, number: pr.number, title: pr.title, url: pr.url, From c991d7b5434f4c973bb758dc1a33646f321a338e Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Mon, 16 Mar 2026 17:52:28 +0100 Subject: [PATCH 23/38] refactor(sisyphus): improve release PR body format Show each stone as a separate section with heading and description. Always show Stones section regardless of count. --- .../src/commands/actions/release-pr.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/sisyphus/src/commands/actions/release-pr.ts b/packages/sisyphus/src/commands/actions/release-pr.ts index f5270d0..de74654 100644 --- a/packages/sisyphus/src/commands/actions/release-pr.ts +++ b/packages/sisyphus/src/commands/actions/release-pr.ts @@ -50,7 +50,7 @@ export class ActionsReleasePrCommand extends BaseCommand { } const prTitle = this.buildPrTitle(updatedPackages); - const prBody = this.buildPrBody(mergedStone, updatedPackages, stones); + const prBody = this.buildPrBody(updatedPackages, stones); log.info(`${color.bold("Release PR:")} ${prTitle}`); log.info(`${color.dim("Packages:")} ${updatedPackages.map((p) => p.name).join(", ")}`); @@ -100,17 +100,19 @@ export class ActionsReleasePrCommand extends BaseCommand { return `${PR_TITLE_PREFIX} ${names}`; } - private buildPrBody(stone: Stone, packages: Package[], stones: Stone[]): string { + private buildPrBody(packages: Package[], stones: Stone[]): string { const lines: string[] = []; - lines.push("## Release Summary"); + lines.push("## Changes"); lines.push(""); - lines.push(`**Message:** ${stone.message}`); - if (stone.description) { + for (const s of stones) { + lines.push(`### ${s.message}`); + if (s.description) { + lines.push(""); + lines.push(s.description); + } lines.push(""); - lines.push(stone.description); } - lines.push(""); lines.push("## Packages"); lines.push(""); @@ -119,14 +121,12 @@ export class ActionsReleasePrCommand extends BaseCommand { } lines.push(""); - if (stones.length > 1) { - lines.push("## Stones"); - lines.push(""); - for (const s of stones) { - lines.push(`- **${s.id}**: ${s.message}`); - } - lines.push(""); + lines.push("## Stones"); + lines.push(""); + for (const s of stones) { + lines.push(`- \`${s.id}\`: ${s.message}`); } + lines.push(""); lines.push("---"); lines.push("*This PR was automatically created by [Sisyphus](https://github.com/r5n-labs/clis).*"); From 3e05410c2c06f5cf702767af42508a4291544e93 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Mon, 16 Mar 2026 18:09:30 +0100 Subject: [PATCH 24/38] fix(sisyphus): filter release notes by package and use collapsible format Show only relevant stones in each package's GitHub release notes. Wrap stones in a collapsible section with nested commits. --- packages/sisyphus/src/commands/roll.ts | 6 +- packages/sisyphus/src/domain/Stone.ts | 4 ++ .../src/services/ReleaseOrchestrator.ts | 55 ++++++++++++------- 3 files changed, 41 insertions(+), 24 deletions(-) diff --git a/packages/sisyphus/src/commands/roll.ts b/packages/sisyphus/src/commands/roll.ts index be3ed43..1ee942e 100644 --- a/packages/sisyphus/src/commands/roll.ts +++ b/packages/sisyphus/src/commands/roll.ts @@ -203,7 +203,7 @@ export class RollCommand extends BaseCommand { if (options.createRelease) { s.start("Creating release..."); - await orchestrator.createGitRelease(stone, packages); + await orchestrator.createGitRelease(originalStones, packages); s.stop("Release created"); } @@ -340,8 +340,8 @@ export class RollCommand extends BaseCommand { s.start("Creating release..."); const manager = new StoneManager(ctx.config); const stones = await manager.getReleasedStones(currentRelease.timestamp); - const mergedStone = stones.length > 0 ? Stone.mergeAll(stones) : this.createFallbackStone(packages); - await orchestrator.createGitRelease(mergedStone, packages); + const releaseStones = stones.length > 0 ? stones : [this.createFallbackStone(packages)]; + await orchestrator.createGitRelease(releaseStones, packages); s.stop("Release created"); } diff --git a/packages/sisyphus/src/domain/Stone.ts b/packages/sisyphus/src/domain/Stone.ts index ae65286..359e3fa 100644 --- a/packages/sisyphus/src/domain/Stone.ts +++ b/packages/sisyphus/src/domain/Stone.ts @@ -179,6 +179,10 @@ 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) ?? []; } diff --git a/packages/sisyphus/src/services/ReleaseOrchestrator.ts b/packages/sisyphus/src/services/ReleaseOrchestrator.ts index d453312..f296181 100644 --- a/packages/sisyphus/src/services/ReleaseOrchestrator.ts +++ b/packages/sisyphus/src/services/ReleaseOrchestrator.ts @@ -136,7 +136,7 @@ export class ReleaseOrchestrator { await this.run(() => Bun.$`git push --tags`.quiet(), "Failed to push tags"); } - async createGitRelease(stone: Stone, packages: Package[]) { + async createGitRelease(stones: Stone[], packages: Package[]) { if (this.options.dryRun) return; await this.initCommitLinks(); @@ -146,7 +146,8 @@ export class ReleaseOrchestrator { const version = pkg.newVersion ?? pkg.version; const tagName = `${pkg.name}@${version}`; const title = `${pkg.name} v${version}`; - const notes = this.formatReleaseNotes(stone, pkg); + const relevantStones = this.filterStonesForPackage(stones, pkg.name); + const notes = this.formatReleaseNotes(relevantStones, pkg); await this.run( () => provider.createRelease({ notes, tag: tagName, title }), @@ -156,34 +157,46 @@ export class ReleaseOrchestrator { } } - 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"); - const version = pkg.newVersion ?? pkg.version; - lines.push(`**Package:** \`${pkg.name}\``); - lines.push(`**Version:** ${pkg.version} → ${version}`); + 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"); } From 52ee71d3c4c40387e14062c70f0e5eaf95a0ae8e Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Mon, 16 Mar 2026 18:42:52 +0100 Subject: [PATCH 25/38] feat(sisyphus): add configurable changelog headers Add packageHeader and rootHeader config options for changelog headers. - packageHeader: "{emoji} {version} ({date})" with {emoji}, {version}, {date} - rootHeader: "{date} - {packages}" with {date}, {packages} placeholders --- packages/sisyphus/src/constants.ts | 2 ++ .../src/services/ChangelogGenerator.ts | 25 ++++++++++++++----- packages/sisyphus/src/types.ts | 2 ++ 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/sisyphus/src/constants.ts b/packages/sisyphus/src/constants.ts index e8b4341..ee8a3d1 100644 --- a/packages/sisyphus/src/constants.ts +++ b/packages/sisyphus/src/constants.ts @@ -41,7 +41,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", 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/types.ts b/packages/sisyphus/src/types.ts index b3b089b..52efb7d 100644 --- a/packages/sisyphus/src/types.ts +++ b/packages/sisyphus/src/types.ts @@ -28,7 +28,9 @@ export type ChangelogConfig = { append: boolean; filename: string; generate: boolean; + packageHeader: string; root: boolean; + rootHeader: string; sections: ChangelogSections; template?: string; }; From 699cb41b491f8ab4252f9083fb07522e80304a21 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Mon, 16 Mar 2026 18:55:26 +0100 Subject: [PATCH 26/38] fix(sisyphus): use commit.author config for release commits Apply configured author and email to git commits using --author flag. --- packages/sisyphus/src/services/ReleaseOrchestrator.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/sisyphus/src/services/ReleaseOrchestrator.ts b/packages/sisyphus/src/services/ReleaseOrchestrator.ts index f296181..a04cb77 100644 --- a/packages/sisyphus/src/services/ReleaseOrchestrator.ts +++ b/packages/sisyphus/src/services/ReleaseOrchestrator.ts @@ -82,11 +82,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)); From a03b93f96a21688d90f99d61f6dbde97328c4372 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Wed, 18 Mar 2026 16:00:20 +0100 Subject: [PATCH 27/38] feat(sisyphus): exclude PR commits from --fromCommits by default --- .../sisyphus/src/services/CommitAnalyzer.ts | 15 ++++++++++++-- .../sisyphus/src/services/StoneManager.ts | 20 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/sisyphus/src/services/CommitAnalyzer.ts b/packages/sisyphus/src/services/CommitAnalyzer.ts index 980519d..d231c95 100644 --- a/packages/sisyphus/src/services/CommitAnalyzer.ts +++ b/packages/sisyphus/src/services/CommitAnalyzer.ts @@ -3,6 +3,7 @@ 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 { StoneManager } from "./StoneManager"; import { WorkspaceScanner } from "./WorkspaceScanner"; const COMMIT_TYPE_TO_BUMP: Record = { @@ -24,12 +25,22 @@ 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 []; + } + return this.groupByPackage(commits, options); } diff --git a/packages/sisyphus/src/services/StoneManager.ts b/packages/sisyphus/src/services/StoneManager.ts index a5dbc9d..77d7bdc 100644 --- a/packages/sisyphus/src/services/StoneManager.ts +++ b/packages/sisyphus/src/services/StoneManager.ts @@ -148,6 +148,26 @@ export class StoneManager { 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`); } From 310d0f1463a1758ec2c9e8797cb3b7ac2bbd9472 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Wed, 18 Mar 2026 16:01:13 +0100 Subject: [PATCH 28/38] fix: handle invalid regex patterns and simplify Package version methods --- packages/sisyphus/src/commands/pr.ts | 8 ++++++-- packages/sisyphus/src/domain/Package.ts | 6 +----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/sisyphus/src/commands/pr.ts b/packages/sisyphus/src/commands/pr.ts index 47f9c53..4b5d497 100644 --- a/packages/sisyphus/src/commands/pr.ts +++ b/packages/sisyphus/src/commands/pr.ts @@ -109,8 +109,12 @@ export class PrCommand extends BaseCommand { } for (const pattern of skip.titlePatterns) { - if (new RegExp(pattern, "i").test(pr.title)) { - return `title matches skip pattern "${pattern}"`; + 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}"`)); } } diff --git a/packages/sisyphus/src/domain/Package.ts b/packages/sisyphus/src/domain/Package.ts index 9e69e1f..22367d7 100644 --- a/packages/sisyphus/src/domain/Package.ts +++ b/packages/sisyphus/src/domain/Package.ts @@ -74,11 +74,7 @@ export class Package { } withBump(bump: BumpType, tag?: string): Package { - return new Package({ ...this.toOptions(), bump, tag }); - } - - withVersion(version: string): Package { - return new Package({ ...this.toOptions(), version }); + return new Package({ ...this.toOptions(), bump, newVersion: undefined, tag }); } withVersions(oldVersion: string, newVersion: string): Package { From 6d804a46e8358d9a4348727a3b86c6d58a8b32d8 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Wed, 18 Mar 2026 16:30:18 +0100 Subject: [PATCH 29/38] feat(sisyphus): add commit filtering to skip bot and release commits Add support for filtering commits during changelog analysis based on author names and message patterns. This prevents automated commits (bots, release commits) from appearing in changelogs. --- packages/sisyphus/src/constants.ts | 7 ++++ packages/sisyphus/src/domain/Commit.ts | 37 +++++++++++-------- .../sisyphus/src/services/CommitAnalyzer.ts | 24 ++++++++++-- packages/sisyphus/src/types.ts | 5 +++ 4 files changed, 54 insertions(+), 19 deletions(-) diff --git a/packages/sisyphus/src/constants.ts b/packages/sisyphus/src/constants.ts index ee8a3d1..79cb80c 100644 --- a/packages/sisyphus/src/constants.ts +++ b/packages/sisyphus/src/constants.ts @@ -63,6 +63,13 @@ export const SISYPHUS_DEFAULT_CONFIG: SisyphusConfig = { commit: { author: "r5n-bot", message: "chore(release): {message}" }, + commits: { + skip: { + authors: ["github-actions[bot]", "r5n-bot"], + messagePatterns: ["^chore\\(release\\):", "^chore: add stone"], + }, + }, + ignore: [], lastStone: { commit: "", date: "" }, diff --git a/packages/sisyphus/src/domain/Commit.ts b/packages/sisyphus/src/domain/Commit.ts index 3e27894..ee5cb1f 100644 --- a/packages/sisyphus/src/domain/Commit.ts +++ b/packages/sisyphus/src/domain/Commit.ts @@ -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; @@ -81,14 +84,16 @@ export class Commit { static async fromHash(hash: string): Promise { try { - const result = await Bun.$`git log -1 --pretty=format:"%H%x1f%s" ${hash}`.quiet(); + 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 [fullHash, subject] = output.split(FIELD_SEPARATOR); - if (!fullHash || !subject) 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); + return Commit.hydrate(fullHash, subject, author); } catch { return null; } @@ -115,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 []; @@ -123,24 +128,23 @@ export class Commit { const commits: Commit[] = []; for (const line of output.split("\n")) { - const sepIndex = line.indexOf(FIELD_SEPARATOR); - 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 { @@ -154,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 { @@ -197,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/services/CommitAnalyzer.ts b/packages/sisyphus/src/services/CommitAnalyzer.ts index d231c95..bb27782 100644 --- a/packages/sisyphus/src/services/CommitAnalyzer.ts +++ b/packages/sisyphus/src/services/CommitAnalyzer.ts @@ -1,7 +1,7 @@ -import type { ConfigManager } from "@r5n/cli-core"; -import { COMMIT_TYPE_ORDER, COMMIT_TYPE_ORDER_FALLBACK } from "../constants"; +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 } from "../domain"; -import type { SisyphusConfig } from "../types"; +import type { CommitsSkipConfig, SisyphusConfig } from "../types"; import { buildPackagePathMap, findAffectedPackages } from "../utils"; import { StoneManager } from "./StoneManager"; import { WorkspaceScanner } from "./WorkspaceScanner"; @@ -41,9 +41,27 @@ export class CommitAnalyzer { 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); } diff --git a/packages/sisyphus/src/types.ts b/packages/sisyphus/src/types.ts index 52efb7d..b3a7171 100644 --- a/packages/sisyphus/src/types.ts +++ b/packages/sisyphus/src/types.ts @@ -43,10 +43,15 @@ export type PrSkipConfig = { labels: string[]; authors: string[]; titlePatterns: 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[]; From 096dcf9ec3ed34a484286e1d894fa8095a4cb766 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Wed, 18 Mar 2026 16:47:02 +0100 Subject: [PATCH 30/38] feat(sisyphus): auto-generate stones from commits in release-pr flow The release-pr command now automatically analyzes conventional commits and generates stones before creating the release PR. Extracted buildStoneData to CommitAnalyzer as a shared static method. --- .../src/commands/actions/release-pr.ts | 20 ++++++++++++++++++- packages/sisyphus/src/commands/version.ts | 19 ++---------------- .../sisyphus/src/services/CommitAnalyzer.ts | 19 ++++++++++++++++-- 3 files changed, 38 insertions(+), 20 deletions(-) diff --git a/packages/sisyphus/src/commands/actions/release-pr.ts b/packages/sisyphus/src/commands/actions/release-pr.ts index de74654..16ef1db 100644 --- a/packages/sisyphus/src/commands/actions/release-pr.ts +++ b/packages/sisyphus/src/commands/actions/release-pr.ts @@ -2,7 +2,7 @@ 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, PackageUpdater, StoneManager, WorkspaceScanner } from "../../services"; +import { ChangelogGenerator, CommitAnalyzer, PackageUpdater, StoneManager, WorkspaceScanner } from "../../services"; import type { PackageRelease } from "../../types"; const RELEASE_BRANCH = "sisyphus/release"; @@ -34,6 +34,9 @@ export class ActionsReleasePrCommand extends BaseCommand { async execute(ctx: ReleasePrCtx) { const manager = new StoneManager(ctx.config); + + await this.generateStonesFromCommits(ctx, manager); + const stones = await manager.list(); if (stones.length === 0) { @@ -179,6 +182,21 @@ export class ActionsReleasePrCommand extends BaseCommand { await Bun.$`git checkout ${baseBranch}`; } + private async generateStonesFromCommits(ctx: ReleasePrCtx, manager: StoneManager): 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") }); + + for (const group of commitGroups) { + const stoneData = CommitAnalyzer.buildStoneData(group, packages); + const stone = await manager.create(stoneData); + log.info(`${color.dim("Generated stone:")} ${stone.id}`); + } + } + private async stashChanges() { await Bun.$`git stash --include-untracked`.nothrow(); } diff --git a/packages/sisyphus/src/commands/version.ts b/packages/sisyphus/src/commands/version.ts index b50da09..a47c806 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 @@ -126,7 +126,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 +226,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/services/CommitAnalyzer.ts b/packages/sisyphus/src/services/CommitAnalyzer.ts index bb27782..349d200 100644 --- a/packages/sisyphus/src/services/CommitAnalyzer.ts +++ b/packages/sisyphus/src/services/CommitAnalyzer.ts @@ -1,8 +1,8 @@ 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 } from "../domain"; +import { BumpType, Commit, type CommitInfo, OTHER_COMMIT_TYPE, type Package, type StoneData } from "../domain"; import type { CommitsSkipConfig, SisyphusConfig } from "../types"; -import { buildPackagePathMap, findAffectedPackages } from "../utils"; +import { buildPackagePathMap, findAffectedPackages, findDependencyPackages } from "../utils"; import { StoneManager } from "./StoneManager"; import { WorkspaceScanner } from "./WorkspaceScanner"; @@ -112,4 +112,19 @@ 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 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; + } } From 3f41d6791fb1941e28f6c74d2f5be32483ee27c3 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Wed, 18 Mar 2026 16:59:40 +0100 Subject: [PATCH 31/38] fix(sisyphus): show commit details in release PR body The PR body now displays individual commits under each stone section instead of only showing the description. --- packages/sisyphus/src/commands/actions/release-pr.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/sisyphus/src/commands/actions/release-pr.ts b/packages/sisyphus/src/commands/actions/release-pr.ts index 16ef1db..a2fefa0 100644 --- a/packages/sisyphus/src/commands/actions/release-pr.ts +++ b/packages/sisyphus/src/commands/actions/release-pr.ts @@ -110,8 +110,12 @@ export class ActionsReleasePrCommand extends BaseCommand { lines.push(""); for (const s of stones) { lines.push(`### ${s.message}`); - if (s.description) { - lines.push(""); + lines.push(""); + 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(""); From b39114f7d56d751bf76812b4766e8a72def096c7 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Wed, 18 Mar 2026 17:30:51 +0100 Subject: [PATCH 32/38] fix(sisyphus): update lastStone after generating stones from commits Prevents duplicate stone generation by tracking the newest processed commit. Also respects dry-run mode when generating stones. --- .../src/commands/actions/release-pr.ts | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/sisyphus/src/commands/actions/release-pr.ts b/packages/sisyphus/src/commands/actions/release-pr.ts index a2fefa0..4204400 100644 --- a/packages/sisyphus/src/commands/actions/release-pr.ts +++ b/packages/sisyphus/src/commands/actions/release-pr.ts @@ -193,12 +193,34 @@ export class ActionsReleasePrCommand extends BaseCommand { if (commitGroups.length === 0) return; const { packages } = await WorkspaceScanner.scan({ single: ctx.config.get("single") }); + const createdStones: Stone[] = []; for (const group of commitGroups) { const stoneData = CommitAnalyzer.buildStoneData(group, packages); - const stone = await manager.create(stoneData); - log.info(`${color.dim("Generated stone:")} ${stone.id}`); + + if (ctx.args.dryRun) { + log.info(`${color.dim("[dry-run] Would generate stone:")} ${group.message}`); + } else { + const stone = await manager.create(stoneData); + createdStones.push(stone); + log.info(`${color.dim("Generated stone:")} ${stone.id}`); + } } + + if (!ctx.args.dryRun && createdStones.length > 0) { + const newestCommit = await this.findNewestCommitHash(createdStones); + if (newestCommit) { + ctx.config.set("lastStone", { commit: newestCommit, date: new Date().toISOString() }); + } + } + } + + 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() { From 85fcf51301eccc6b805bde6de59fe7c60c68e263 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Wed, 18 Mar 2026 17:46:19 +0100 Subject: [PATCH 33/38] fix(sisyphus): set lastStone from all released stones, not just generated ones Moves lastStone update to applyReleaseChanges so it considers all stones being released (both PR-created and commit-generated). --- packages/sisyphus/src/commands/actions/release-pr.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/sisyphus/src/commands/actions/release-pr.ts b/packages/sisyphus/src/commands/actions/release-pr.ts index 4204400..8bb0163 100644 --- a/packages/sisyphus/src/commands/actions/release-pr.ts +++ b/packages/sisyphus/src/commands/actions/release-pr.ts @@ -206,13 +206,6 @@ export class ActionsReleasePrCommand extends BaseCommand { log.info(`${color.dim("Generated stone:")} ${stone.id}`); } } - - if (!ctx.args.dryRun && createdStones.length > 0) { - const newestCommit = await this.findNewestCommitHash(createdStones); - if (newestCommit) { - ctx.config.set("lastStone", { commit: newestCommit, date: new Date().toISOString() }); - } - } } private async findNewestCommitHash(stones: Stone[]): Promise { @@ -255,6 +248,11 @@ export class ActionsReleasePrCommand extends BaseCommand { ctx.config.set("currentRelease", { packages: packageVersions, stoneIds: stones.map((s) => s.id), timestamp }); + const newestCommit = await this.findNewestCommitHash(stones); + if (newestCommit) { + ctx.config.set("lastStone", { commit: newestCommit, date: new Date().toISOString() }); + } + changedFiles.push(sisyphusDir); return changedFiles; From 56f4ca24b1738b2bc0b755d346870576445dcf7e Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Wed, 18 Mar 2026 19:49:48 +0100 Subject: [PATCH 34/38] fix(sisyphus): generate stones on release branch in release-pr flow Stones were being generated before checkout, causing them to be lost. Decompose execute method for readability. --- .../src/commands/actions/release-pr.ts | 169 +++++++++--------- packages/sisyphus/src/commands/version.ts | 1 - 2 files changed, 83 insertions(+), 87 deletions(-) diff --git a/packages/sisyphus/src/commands/actions/release-pr.ts b/packages/sisyphus/src/commands/actions/release-pr.ts index 8bb0163..926a790 100644 --- a/packages/sisyphus/src/commands/actions/release-pr.ts +++ b/packages/sisyphus/src/commands/actions/release-pr.ts @@ -33,16 +33,58 @@ export class ActionsReleasePrCommand extends BaseCommand { } async execute(ctx: ReleasePrCtx) { - const manager = new StoneManager(ctx.config); + 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; + } - await this.generateStonesFromCommits(ctx, manager); + const prTitle = this.buildPrTitle(packages); + const prBody = this.buildPrBody(packages, stones); - const stones = await manager.list(); + log.info(`${color.bold("Release PR:")} ${prTitle}`); + log.info(`${color.dim("Packages:")} ${packages.map((p) => p.name).join(", ")}`); - if (stones.length === 0) { - log.info(color.dim("No pending stones found, skipping release PR")); - return; + 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); @@ -52,49 +94,39 @@ export class ActionsReleasePrCommand extends BaseCommand { throw new Exit("No packages to update", "Stones don't reference any known packages"); } - const prTitle = this.buildPrTitle(updatedPackages); - const prBody = this.buildPrBody(updatedPackages, stones); - - log.info(`${color.bold("Release PR:")} ${prTitle}`); - log.info(`${color.dim("Packages:")} ${updatedPackages.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); - return; - } + return { packages: updatedPackages, stones }; + } + private async commitAndPushChanges(ctx: ReleasePrCtx, stones: Stone[], packages: Package[]) { const s = spinner(); + s.start("Applying release changes..."); - const existingPr = await this.findExistingReleasePr(); - - try { - if (existingPr) { - s.start("Updating release branch..."); - await this.updateReleaseBranch(ctx, stones, updatedPackages); - s.stop("Release branch updated"); + const changedFiles = await this.applyReleaseChanges(ctx, stones, packages); + await Bun.$`git add ${changedFiles}`; - s.start("Updating PR..."); - await this.updatePr(existingPr.number, prTitle, prBody); - s.stop(`PR #${existingPr.number} updated`); + const hasChanges = await Bun.$`git diff --cached --quiet`.nothrow(); + if (hasChanges.exitCode !== 0) { + await Bun.$`git commit -m ${`${PR_TITLE_PREFIX} prepare release`}`; + } - log.info(`\n${color.green("Release PR updated:")} ${existingPr.url}`); - } else { - s.start("Creating release branch..."); - await this.createReleaseBranch(ctx, stones, updatedPackages); - s.stop("Release branch created"); + await Bun.$`git push origin ${RELEASE_BRANCH} --force`; + s.stop("Release branch ready"); + } - s.start("Creating PR..."); - const pr = await this.createPr(prTitle, prBody); - s.stop(`PR #${pr.number} created`); + private async createOrUpdatePr(title: string, body: string) { + const s = spinner(); + const existingPr = await this.findExistingReleasePr(); - log.info(`\n${color.green("Release PR created:")} ${pr.url}`); - } - } catch (error) { - s.stop("Failed"); - await this.restoreMainBranch(); - throw error; + 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}`); } } @@ -148,64 +180,29 @@ export class ActionsReleasePrCommand extends BaseCommand { return pr ? { number: pr.number, url: pr.url } : null; } - private async createReleaseBranch(ctx: ReleasePrCtx, stones: Stone[], packages: Package[]) { - const provider = await this.getProvider(); - const baseBranch = await provider.getDefaultBranch(); - - await this.stashChanges(); - await Bun.$`git checkout -B ${RELEASE_BRANCH} origin/${baseBranch}`; - - const changedFiles = await this.applyReleaseChanges(ctx, stones, packages); - - await Bun.$`git add ${changedFiles}`; - await Bun.$`git commit -m ${`${PR_TITLE_PREFIX} prepare release`}`; - await Bun.$`git push -u origin ${RELEASE_BRANCH} --force`; - - await Bun.$`git checkout ${baseBranch}`; - } - - private async updateReleaseBranch(ctx: ReleasePrCtx, stones: Stone[], packages: Package[]) { - 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}`; - - 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`; - - await Bun.$`git checkout ${baseBranch}`; - } - - private async generateStonesFromCommits(ctx: ReleasePrCtx, manager: StoneManager): Promise { + 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; + if (commitGroups.length === 0) return []; const { packages } = await WorkspaceScanner.scan({ single: ctx.config.get("single") }); - const createdStones: Stone[] = []; + const stones: Stone[] = []; - for (const group of commitGroups) { + for (const [index, group] of commitGroups.entries()) { const stoneData = CommitAnalyzer.buildStoneData(group, packages); - if (ctx.args.dryRun) { + 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); - createdStones.push(stone); + stones.push(stone); log.info(`${color.dim("Generated stone:")} ${stone.id}`); } } + + return stones; } private async findNewestCommitHash(stones: Stone[]): Promise { diff --git a/packages/sisyphus/src/commands/version.ts b/packages/sisyphus/src/commands/version.ts index a47c806..01779df 100644 --- a/packages/sisyphus/src/commands/version.ts +++ b/packages/sisyphus/src/commands/version.ts @@ -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" }, From 1e239e4717080bcf216671b3f80290dba0a1fb1b Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Thu, 19 Mar 2026 14:25:44 +0100 Subject: [PATCH 35/38] fix(sisyphus): add logging to error recovery catch blocks --- .../sisyphus/src/commands/actions/release-pr.ts | 4 +++- .../sisyphus/src/services/ReleaseOrchestrator.ts | 14 ++++++++++---- packages/sisyphus/src/services/StoneManager.ts | 6 ++++-- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/sisyphus/src/commands/actions/release-pr.ts b/packages/sisyphus/src/commands/actions/release-pr.ts index 926a790..706bbf4 100644 --- a/packages/sisyphus/src/commands/actions/release-pr.ts +++ b/packages/sisyphus/src/commands/actions/release-pr.ts @@ -285,6 +285,8 @@ export class ActionsReleasePrCommand extends BaseCommand { const baseBranch = await provider.getDefaultBranch(); try { await Bun.$`git checkout ${baseBranch}`.quiet(); - } catch {} + } catch (error) { + log.warn(color.dim(`Failed to restore branch: ${error}`)); + } } } diff --git a/packages/sisyphus/src/services/ReleaseOrchestrator.ts b/packages/sisyphus/src/services/ReleaseOrchestrator.ts index a04cb77..cd2a339 100644 --- a/packages/sisyphus/src/services/ReleaseOrchestrator.ts +++ b/packages/sisyphus/src/services/ReleaseOrchestrator.ts @@ -1,5 +1,5 @@ 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 type { CommitInfo, Package, Stone } from "../domain"; import { createGitProvider, type GitProvider } from "../providers"; import type { SisyphusConfig } from "../types"; @@ -234,21 +234,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; } diff --git a/packages/sisyphus/src/services/StoneManager.ts b/packages/sisyphus/src/services/StoneManager.ts index 77d7bdc..96b3dda 100644 --- a/packages/sisyphus/src/services/StoneManager.ts +++ b/packages/sisyphus/src/services/StoneManager.ts @@ -1,7 +1,7 @@ import { existsSync } from "node:fs"; import { mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import type { ConfigManager } from "@r5n/cli-core"; +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"; @@ -142,7 +142,9 @@ export class StoneManager { const content = await readFile(join(archiveDir, file), "utf-8"); const json: StoneJson = JSON.parse(content); stones.push(Stone.fromJson(json)); - } catch {} + } catch (error) { + log.warn(color.dim(`Failed to parse stone "${file}": ${error}`)); + } } return stones; From bc8505a7c3d588993d739b8dec6c5433207a2bba Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Thu, 19 Mar 2026 14:30:21 +0100 Subject: [PATCH 36/38] chore(sisyphus): use r5n-bot[bot] for CI commit author --- .../actions/templates/github/sis-create-stone.yml | 4 ++-- .../actions/templates/gitlab/sis-create-stone.yml | 4 ++-- packages/sisyphus/src/constants.ts | 9 ++------- 3 files changed, 6 insertions(+), 11 deletions(-) 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 index 578a339..3a28820 100644 --- a/packages/sisyphus/src/commands/actions/templates/github/sis-create-stone.yml +++ b/packages/sisyphus/src/commands/actions/templates/github/sis-create-stone.yml @@ -24,8 +24,8 @@ jobs: - name: Configure git run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" + 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 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 index c6ae0a6..fc082ff 100644 --- a/packages/sisyphus/src/commands/actions/templates/gitlab/sis-create-stone.yml +++ b/packages/sisyphus/src/commands/actions/templates/gitlab/sis-create-stone.yml @@ -5,8 +5,8 @@ handle-merge: - 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 "gitlab-ci[bot]" - - git config user.email "gitlab-ci[bot]@users.noreply.gitlab.com" + - 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=$? diff --git a/packages/sisyphus/src/constants.ts b/packages/sisyphus/src/constants.ts index 79cb80c..7de61dd 100644 --- a/packages/sisyphus/src/constants.ts +++ b/packages/sisyphus/src/constants.ts @@ -63,12 +63,7 @@ export const SISYPHUS_DEFAULT_CONFIG: SisyphusConfig = { commit: { author: "r5n-bot", message: "chore(release): {message}" }, - commits: { - skip: { - authors: ["github-actions[bot]", "r5n-bot"], - messagePatterns: ["^chore\\(release\\):", "^chore: add stone"], - }, - }, + commits: { skip: { authors: ["r5n-bot[bot]"], messagePatterns: ["^chore\\(release\\):", "^chore: add stone"] } }, ignore: [], @@ -88,7 +83,7 @@ export const SISYPHUS_DEFAULT_CONFIG: SisyphusConfig = { fix: "patch", }, skip: { - authors: ["github-actions[bot]"], + authors: ["r5n-bot[bot]"], labels: ["sisyphus-release", "skip-stone"], titlePatterns: ["^chore\\(release\\):"], }, From 7e7e653b8f011dec1b4bd3f9651a483660215c82 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Thu, 19 Mar 2026 15:01:11 +0100 Subject: [PATCH 37/38] refactor(sisyphus): feedback Use early continue in loops, computed properties, destructuring spread, and default cases in switch statements. --- .../src/commands/actions/CiGenerator.ts | 4 ++-- packages/sisyphus/src/providers/index.ts | 22 ++++++++++--------- .../sisyphus/src/services/CommitAnalyzer.ts | 15 ++++++------- .../sisyphus/src/services/GitRemoteParser.ts | 10 ++++----- .../src/services/PullRequestAnalyzer.ts | 15 ++----------- 5 files changed, 28 insertions(+), 38 deletions(-) diff --git a/packages/sisyphus/src/commands/actions/CiGenerator.ts b/packages/sisyphus/src/commands/actions/CiGenerator.ts index d2fba16..d2f5371 100644 --- a/packages/sisyphus/src/commands/actions/CiGenerator.ts +++ b/packages/sisyphus/src/commands/actions/CiGenerator.ts @@ -100,7 +100,7 @@ export function createCiGenerator(provider: Provider): CiGenerator { return new GitHubCiGenerator(); case "gitlab": return new GitLabCiGenerator(); - case "bitbucket": - throw new Error("Bitbucket CI generation is not supported yet"); + default: + throw new Error(`CI generation is not supported for ${provider}`); } } diff --git a/packages/sisyphus/src/providers/index.ts b/packages/sisyphus/src/providers/index.ts index 4313dba..9c798c2 100644 --- a/packages/sisyphus/src/providers/index.ts +++ b/packages/sisyphus/src/providers/index.ts @@ -38,6 +38,8 @@ export async function createGitProvider() { return new GitLabProvider(info); case "bitbucket": return new BitbucketProvider(info); + default: + throw new Exit(`Unsupported provider: ${info.provider}`); } } @@ -73,11 +75,11 @@ function parseRemoteUrl(url: string): RemoteInfo | null { for (const [pattern, provider] of patterns) { const match = url.match(pattern); - if (match) { - const [, owner, repo] = match; - if (owner && repo) { - return { owner, provider, repo }; - } + if (!match) continue; + + const [, owner, repo] = match; + if (owner && repo) { + return { owner, provider, repo }; } } @@ -93,11 +95,11 @@ export function parsePrUrl(url: string): PrUrlInfo | null { for (const [pattern, provider] of patterns) { const match = url.match(pattern); - if (match) { - const [, owner, repo, number] = match; - if (owner && repo && number) { - return { number: Number.parseInt(number, 10), owner, provider, repo }; - } + if (!match) continue; + + const [, owner, repo, number] = match; + if (owner && repo && number) { + return { number: Number.parseInt(number, 10), owner, provider, repo }; } } diff --git a/packages/sisyphus/src/services/CommitAnalyzer.ts b/packages/sisyphus/src/services/CommitAnalyzer.ts index 349d200..3d2b7dc 100644 --- a/packages/sisyphus/src/services/CommitAnalyzer.ts +++ b/packages/sisyphus/src/services/CommitAnalyzer.ts @@ -116,15 +116,14 @@ export class CommitAnalyzer { 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 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; + 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 726e3b3..26822b9 100644 --- a/packages/sisyphus/src/services/PullRequestAnalyzer.ts +++ b/packages/sisyphus/src/services/PullRequestAnalyzer.ts @@ -194,19 +194,8 @@ export class PullRequestAnalyzer { return this.mapPullRequest(pr); } - private mapPullRequest(pr: PullRequest): PullRequestInfo { - return { - author: pr.author, - baseBranch: pr.baseBranch, - body: pr.body, - branch: pr.headBranch, - labels: pr.labels, - mergeCommitSha: pr.mergeCommitSha, - merged: pr.merged, - number: pr.number, - title: pr.title, - url: pr.url, - }; + private mapPullRequest({ headBranch, ...pr }: PullRequest): PullRequestInfo { + return { ...pr, branch: headBranch }; } inferBumpFromLabels(labels: string[]): BumpType | null { From 87c309308815c8458776ea547e81120a2d5cb8c1 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Thu, 19 Mar 2026 16:56:07 +0100 Subject: [PATCH 38/38] refactor(sisyphus): extract constants and improve type safety - Replace hardcoded values with named constants (UNKNOWN_AUTHOR, DEFAULT_VERSION, DEFAULT_NPM_TAG, DEFAULT_BRANCH, etc.) - Refactor Stone constructor from 6 positional args to options object - Decompose long methods in release-pr.ts (buildPrBody, applyReleaseChanges) - Add typed API response interfaces for GitHub and GitLab providers - Remove inline type assertions in favor of proper type definitions --- .../src/commands/actions/release-pr.ts | 97 +++++++++++++------ packages/sisyphus/src/constants.ts | 8 ++ packages/sisyphus/src/domain/Package.ts | 3 +- packages/sisyphus/src/domain/Stone.ts | 75 ++++++++------ .../sisyphus/src/providers/GitHubProvider.ts | 90 ++++++++++------- .../sisyphus/src/providers/GitLabProvider.ts | 41 +++++--- .../src/services/PullRequestAnalyzer.ts | 4 +- .../src/services/ReleaseOrchestrator.ts | 3 +- 8 files changed, 208 insertions(+), 113 deletions(-) diff --git a/packages/sisyphus/src/commands/actions/release-pr.ts b/packages/sisyphus/src/commands/actions/release-pr.ts index 706bbf4..573453d 100644 --- a/packages/sisyphus/src/commands/actions/release-pr.ts +++ b/packages/sisyphus/src/commands/actions/release-pr.ts @@ -136,13 +136,19 @@ export class ActionsReleasePrCommand extends BaseCommand { } private buildPrBody(packages: Package[], stones: Stone[]): string { - const lines: string[] = []; + const sections = [ + this.buildChangesSection(stones), + this.buildPackagesSection(packages), + this.buildStonesSection(stones), + this.buildFooter(), + ]; + return sections.join("\n"); + } - lines.push("## Changes"); - lines.push(""); + private buildChangesSection(stones: Stone[]): string { + const lines = ["## Changes", ""]; for (const s of stones) { - lines.push(`### ${s.message}`); - lines.push(""); + lines.push(`### ${s.message}`, ""); if (s.commits && s.commits.length > 0) { for (const commit of s.commits) { lines.push(`- ${commit.message} (\`${commit.hash}\`)`); @@ -152,28 +158,35 @@ export class ActionsReleasePrCommand extends BaseCommand { } lines.push(""); } + return lines.join("\n"); + } - lines.push("## Packages"); - lines.push(""); + 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"); + } - lines.push("## Stones"); - lines.push(""); + private buildStonesSection(stones: Stone[]): string { + const lines = ["## Stones", ""]; for (const s of stones) { lines.push(`- \`${s.id}\`: ${s.message}`); } lines.push(""); - - lines.push("---"); - lines.push("*This PR was automatically created by [Sisyphus](https://github.com/r5n-labs/clis).*"); - lines.push("*Merging this PR will trigger the release workflow.*"); - 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 }); @@ -218,41 +231,61 @@ export class ActionsReleasePrCommand extends BaseCommand { } private async applyReleaseChanges(ctx: ReleasePrCtx, stones: Stone[], packages: Package[]): Promise { - const changelogConfig = ctx.config.get("changelog"); - const generator = new ChangelogGenerator(changelogConfig); - const updater = new PackageUpdater(); - const sisyphusDir = ctx.config.get("sisyphusDir"); - 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); - changedFiles.push(...packages.map((p) => p.file)); + return packages.map((p) => p.file); + } - if (changelogConfig.generate) { - await generator.generate(stones, packages); - changedFiles.push(changelogConfig.filename, `**/${changelogConfig.filename}`); - } + 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: Record = {}; + 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) { - packageVersions[pkg.name] = { newVersion: pkg.newVersion, oldVersion: pkg.version }; + versions[pkg.name] = { newVersion: pkg.newVersion, oldVersion: pkg.version }; } } + return versions; + } - ctx.config.set("currentRelease", { packages: packageVersions, stoneIds: stones.map((s) => s.id), timestamp }); - + 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() }); } - - changedFiles.push(sisyphusDir); - - return changedFiles; } private async createPr(title: string, body: string): Promise<{ number: number; url: string }> { diff --git a/packages/sisyphus/src/constants.ts b/packages/sisyphus/src/constants.ts index 7de61dd..b3c2d99 100644 --- a/packages/sisyphus/src/constants.ts +++ b/packages/sisyphus/src/constants.ts @@ -31,6 +31,14 @@ 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"; diff --git a/packages/sisyphus/src/domain/Package.ts b/packages/sisyphus/src/domain/Package.ts index 22367d7..cfaec3a 100644 --- a/packages/sisyphus/src/domain/Package.ts +++ b/packages/sisyphus/src/domain/Package.ts @@ -1,3 +1,4 @@ +import { DEFAULT_VERSION } from "../constants"; import { VersionCalculator } from "../services/VersionCalculator"; import { BUMP_ORDER, type BumpType } from "./BumpType"; import type { Stone } from "./Stone"; @@ -40,7 +41,7 @@ export class Package { } 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[] { diff --git a/packages/sisyphus/src/domain/Stone.ts b/packages/sisyphus/src/domain/Stone.ts index 359e3fa..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 { @@ -82,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 }; } @@ -138,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}`; } @@ -188,25 +198,36 @@ export class Stone { } 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/providers/GitHubProvider.ts b/packages/sisyphus/src/providers/GitHubProvider.ts index 4a4413d..a598d71 100644 --- a/packages/sisyphus/src/providers/GitHubProvider.ts +++ b/packages/sisyphus/src/providers/GitHubProvider.ts @@ -1,4 +1,5 @@ import { Exit } from "@r5n/cli-core"; +import { DEFAULT_BRANCH, UNKNOWN_AUTHOR } from "../constants"; import { type CreateLabelOptions, type CreatePrOptions, @@ -9,7 +10,32 @@ import { type UpdatePrOptions, } from "./GitProvider"; -const DEFAULT_BRANCH = "main"; +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; @@ -83,18 +109,7 @@ export class GitHubProvider extends GitProvider { const result = await Bun.$`gh api repos/${this.owner}/${this.repo}/pulls/${number}`.quiet(); const data = JSON.parse(result.stdout.toString()); - return { - author: data.user?.login ?? "unknown", - baseBranch: data.base?.ref ?? DEFAULT_BRANCH, - body: data.body ?? "", - headBranch: data.head?.ref ?? "", - labels: data.labels?.map((l: { name: string }) => l.name) ?? [], - mergeCommitSha: data.merge_commit_sha ?? null, - merged: data.merged ?? false, - number: data.number, - title: data.title, - url: data.html_url, - }; + return this.mapRestApiResponse(data); } catch { throw new Exit(`Failed to fetch PR #${number}`, "Make sure the PR exists and you have access"); } @@ -106,18 +121,7 @@ export class GitHubProvider extends GitProvider { 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 { - author: data.author?.login ?? "unknown", - baseBranch: data.baseRefName, - body: data.body ?? "", - headBranch: data.headRefName, - labels: data.labels?.map((l: { name: string }) => l.name) ?? [], - mergeCommitSha: data.mergeCommit?.oid ?? null, - merged: data.state === "MERGED", - number: data.number, - title: data.title, - url: data.url, - }; + 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"); } @@ -161,19 +165,33 @@ export class GitHubProvider extends GitProvider { } catch {} } - private mapPrResponse(data: Record): PullRequest { - const mergeCommit = data.mergeCommit as { oid?: string } | undefined; + private mapRestApiResponse(data: GitHubRestPrResponse): PullRequest { return { - author: (data.author as { login?: string })?.login ?? "unknown", - baseBranch: data.baseRefName as string, - body: (data.body as string) ?? "", - headBranch: data.headRefName as string, - labels: ((data.labels as { name: string }[]) ?? []).map((l) => l.name), - mergeCommitSha: mergeCommit?.oid ?? null, + 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 as number, - title: data.title as string, - url: data.url as string, + 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 index c215262..0c49096 100644 --- a/packages/sisyphus/src/providers/GitLabProvider.ts +++ b/packages/sisyphus/src/providers/GitLabProvider.ts @@ -1,4 +1,5 @@ import { Exit } from "@r5n/cli-core"; +import { DEFAULT_BRANCH, UNKNOWN_AUTHOR } from "../constants"; import { type CreateLabelOptions, type CreatePrOptions, @@ -9,7 +10,20 @@ import { type UpdatePrOptions, } from "./GitProvider"; -const DEFAULT_BRANCH = "main"; +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; @@ -109,9 +123,8 @@ export class GitLabProvider extends GitProvider { try { const result = await Bun.$`glab api projects/${this.owner}%2F${this.repo}/merge_requests/${number}/changes`.quiet(); - const data = JSON.parse(result.stdout.toString()); - const changes = data.changes as { new_path: string }[]; - return changes?.map((c) => c.new_path) ?? []; + const data: GitLabChangesResponse = JSON.parse(result.stdout.toString()); + return data.changes?.map((c) => c.new_path) ?? []; } catch { return []; } @@ -145,18 +158,18 @@ export class GitLabProvider extends GitProvider { throw new Exit("Failed to parse MR number from output", output); } - private mapMrResponse(data: Record): PullRequest { + private mapMrResponse(data: GitLabMrResponse): PullRequest { return { - author: (data.author as { username?: string })?.username ?? "unknown", - baseBranch: (data.target_branch as string) ?? DEFAULT_BRANCH, - body: (data.description as string) ?? "", - headBranch: (data.source_branch as string) ?? "", - labels: (data.labels as string[]) ?? [], - mergeCommitSha: (data.merge_commit_sha as string) ?? null, + 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 as number, - title: data.title as string, - url: data.web_url as string, + number: data.iid, + title: data.title, + url: data.web_url, }; } } diff --git a/packages/sisyphus/src/services/PullRequestAnalyzer.ts b/packages/sisyphus/src/services/PullRequestAnalyzer.ts index 26822b9..9da20c8 100644 --- a/packages/sisyphus/src/services/PullRequestAnalyzer.ts +++ b/packages/sisyphus/src/services/PullRequestAnalyzer.ts @@ -1,6 +1,6 @@ import type { ConfigManager } from "@r5n/cli-core"; import { Exit } from "@r5n/cli-core"; -import { SHORT_HASH_LENGTH, UNKNOWN_HASH } from "../constants"; +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"; @@ -154,7 +154,7 @@ export class PullRequestAnalyzer { private inferCommitType(title: string): string { const match = title.match(/^(\w+)(?:\(.*?\))?!?:/); - return match?.[1] ?? "other"; + return match?.[1] ?? OTHER_COMMIT_TYPE; } private async getProvider(): Promise { diff --git a/packages/sisyphus/src/services/ReleaseOrchestrator.ts b/packages/sisyphus/src/services/ReleaseOrchestrator.ts index cd2a339..2915881 100644 --- a/packages/sisyphus/src/services/ReleaseOrchestrator.ts +++ b/packages/sisyphus/src/services/ReleaseOrchestrator.ts @@ -1,5 +1,6 @@ import { dirname, join } from "node:path"; 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"; @@ -263,7 +264,7 @@ export class ReleaseOrchestrator { } private async publishPackage(pkg: Package) { - 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}`);