diff --git a/packages/core/src/config-manager.ts b/packages/core/src/config-manager.ts index b8da5a4..0db6e4b 100644 --- a/packages/core/src/config-manager.ts +++ b/packages/core/src/config-manager.ts @@ -23,6 +23,11 @@ export class ConfigManager { this.save(); } + public delete(key: K): void { + delete this.config[key]; + this.save(); + } + public exists(): boolean { return fs.existsSync(this.configPath); } diff --git a/packages/sisyphus/src/commands/actions/init.ts b/packages/sisyphus/src/commands/actions/init.ts index e710c3a..67b83c7 100644 --- a/packages/sisyphus/src/commands/actions/init.ts +++ b/packages/sisyphus/src/commands/actions/init.ts @@ -4,7 +4,7 @@ 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"; +import { createCiGenerator, GitHubCiGenerator, GitLabCiGenerator, WORKFLOW_CONFIGS, type WorkflowType } from "./CiGenerator"; const PROVIDER_LABELS = { bitbucket: "Bitbucket Pipelines", github: "GitHub Actions", gitlab: "GitLab CI" }; @@ -71,6 +71,19 @@ export class ActionsInitCommand extends BaseCommand { log.info(`\nAdd this to your ${color.bold(".gitlab-ci.yml")}:\n`); log.info(color.cyan(generator.getIncludeInstruction())); log.info(""); + log.info( + `${color.yellow("Note:")} Add a ${color.bold("GITLAB_TOKEN")} CI/CD variable (Personal Access Token with ${color.bold("api")} + ${color.bold("write_repository")} scopes)`, + ); + log.info(color.dim("Required for creating release PRs. Settings → CI/CD → Variables")); + log.info(""); + } + + if (generator instanceof GitHubCiGenerator) { + log.info( + `\n${color.yellow("Note:")} Enable ${color.bold("\"Allow GitHub Actions to create and approve pull requests\"")}`, + ); + log.info(color.dim("Settings → Actions → General → Workflow permissions")); + log.info(""); } log.info(color.dim("Commit and push these files to enable the workflows.")); diff --git a/packages/sisyphus/src/commands/actions/release-pr.ts b/packages/sisyphus/src/commands/actions/release-pr.ts index 573453d..58edaa6 100644 --- a/packages/sisyphus/src/commands/actions/release-pr.ts +++ b/packages/sisyphus/src/commands/actions/release-pr.ts @@ -27,7 +27,6 @@ export class ActionsReleasePrCommand extends BaseCommand { private async getProvider(): Promise { if (!this.provider) { this.provider = await createGitProvider(); - await this.provider.ensureAvailable(); } return this.provider; } @@ -40,7 +39,6 @@ export class ActionsReleasePrCommand extends BaseCommand { if (stones.length === 0) { log.info(color.dim("No pending stones found, skipping release PR")); - await Bun.$`git checkout ${baseBranch}`; return; } @@ -54,16 +52,13 @@ export class ActionsReleasePrCommand extends BaseCommand { 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; + } finally { + await this.restoreMainBranch(baseBranch); } } @@ -102,7 +97,9 @@ export class ActionsReleasePrCommand extends BaseCommand { s.start("Applying release changes..."); const changedFiles = await this.applyReleaseChanges(ctx, stones, packages); - await Bun.$`git add ${changedFiles}`; + for (const file of changedFiles) { + await Bun.$`git add ${file}`.nothrow(); + } const hasChanges = await Bun.$`git diff --cached --quiet`.nothrow(); if (hasChanges.exitCode !== 0) { @@ -313,9 +310,7 @@ export class ActionsReleasePrCommand extends BaseCommand { await provider.updatePr(prNumber, { body, title }); } - private async restoreMainBranch() { - const provider = await this.getProvider(); - const baseBranch = await provider.getDefaultBranch(); + private async restoreMainBranch(baseBranch: string) { try { await Bun.$`git checkout ${baseBranch}`.quiet(); } catch (error) { diff --git a/packages/sisyphus/src/commands/actions/templates/github/sis-create-stone.yml b/packages/sisyphus/src/commands/actions/templates/github/sis-create-stone.yml index 3a28820..9b23776 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,13 +1,19 @@ name: Sisyphus - Handle PR Merge on: + push: + branches: [main] pull_request: types: [closed] branches: [main] +concurrency: + group: sisyphus-${{ github.ref }} + cancel-in-progress: false + jobs: handle-merge: - if: github.event.pull_request.merged == true && !contains(github.event.pull_request.labels.*.name, 'sisyphus-release') + if: github.event_name == 'pull_request' && github.event.pull_request.merged == true && !contains(github.event.pull_request.labels.*.name, 'sisyphus-release') runs-on: ubuntu-latest permissions: contents: write @@ -54,3 +60,29 @@ jobs: run: bunx @r5n/sisyphus actions release-pr env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + handle-push: + if: github.event_name == 'push' && github.event.head_commit.committer.username != 'web-flow' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install + + - name: Configure git + run: | + git config user.name "r5n-bot[bot]" + git config user.email "r5n-bot[bot]@users.noreply.github.com" + + - name: 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 fc082ff..fc84413 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,15 +1,26 @@ handle-merge: stage: sisyphus - image: oven/bun:latest + image: oven/bun:alpine + before_script: + - apk add --no-cache git rules: - - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_STATE == "merged" && $CI_MERGE_REQUEST_LABELS !~ /sisyphus-release/ + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_COMMIT_TITLE =~ /^Merge branch/ && $CI_COMMIT_TITLE !~ /sisyphus\/release/ script: + - | + MR_IID=$(echo "$CI_COMMIT_DESCRIPTION" | grep -o '![0-9]*' | grep -o '[0-9]*' | head -1) + if [ -z "$MR_IID" ]; then + echo "No MR IID found in commit description, skipping" + exit 0 + fi + echo "Detected MR !$MR_IID" - git fetch --unshallow || true - git config user.name "r5n-bot[bot]" - git config user.email "r5n-bot[bot]@users.noreply.gitlab.com" - bun install - | - OUTPUT=$(bunx @r5n/sisyphus pr --url $CI_MERGE_REQUEST_PROJECT_URL/-/merge_requests/$CI_MERGE_REQUEST_IID --yes 2>&1) && EXIT_CODE=0 || EXIT_CODE=$? + GIT_TOKEN="${GITLAB_TOKEN:-$CI_JOB_TOKEN}" + GIT_USER=$([ -n "$GITLAB_TOKEN" ] && echo "oauth2" || echo "gitlab-ci-token") + OUTPUT=$(bunx @r5n/sisyphus pr --url $CI_PROJECT_URL/-/merge_requests/$MR_IID --yes 2>&1) && EXIT_CODE=0 || EXIT_CODE=$? echo "$OUTPUT" if echo "$OUTPUT" | grep -q "Skipping:"; then echo "Stone creation skipped" @@ -19,8 +30,20 @@ handle-merge: 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 + git diff --cached --quiet || git commit -m 'chore: add stone from MR !'"$MR_IID" + git push "https://${GIT_USER}:${GIT_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" HEAD:$CI_DEFAULT_BRANCH bunx @r5n/sisyphus actions release-pr - variables: - GITLAB_TOKEN: $GITLAB_TOKEN + +handle-push: + stage: sisyphus + image: oven/bun:alpine + before_script: + - apk add --no-cache git + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_COMMIT_TITLE !~ /^Merge branch/ + script: + - git fetch --unshallow || true + - git config user.name "r5n-bot[bot]" + - git config user.email "r5n-bot[bot]@users.noreply.gitlab.com" + - bun install + - 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 index b843d0c..fac10e8 100644 --- a/packages/sisyphus/src/commands/actions/templates/gitlab/sis-release.yml +++ b/packages/sisyphus/src/commands/actions/templates/gitlab/sis-release.yml @@ -1,14 +1,13 @@ release: stage: sisyphus - image: oven/bun:latest + image: oven/bun:alpine + before_script: + - apk add --no-cache git 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/ + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_COMMIT_TITLE =~ /^Merge branch.*sisyphus\/release/ script: - git fetch --unshallow || true - bun install - bunx @r5n/sisyphus roll --publish-only --yes - variables: - GITLAB_TOKEN: $GITLAB_TOKEN - NPM_TOKEN: $NPM_TOKEN diff --git a/packages/sisyphus/src/commands/init.ts b/packages/sisyphus/src/commands/init.ts index 887dd24..a342cc9 100644 --- a/packages/sisyphus/src/commands/init.ts +++ b/packages/sisyphus/src/commands/init.ts @@ -33,6 +33,7 @@ export class InitCommand extends BaseCommand { if (useDefault) { ctx.config.save(SISYPHUS_DEFAULT_CONFIG); + await this.setLastStoneToHead(ctx); this.displaySuccessNote(ctx.cli.version); return; } @@ -49,6 +50,7 @@ export class InitCommand extends BaseCommand { const mergedConfig = deepMerge({ ...currentConfig }, newConfig); ctx.config.save(mergedConfig); + await this.setLastStoneToHead(ctx); this.displaySuccessNote(ctx.cli.version); } @@ -187,6 +189,16 @@ export class InitCommand extends BaseCommand { } as InitFormValues; } + private async setLastStoneToHead(ctx: InitCtx) { + try { + const result = await Bun.$`git rev-parse HEAD`.quiet(); + const commit = result.stdout.toString().trim(); + if (commit) { + ctx.config.set("lastStone", { commit, date: new Date().toISOString() }); + } + } catch {} + } + private displaySuccessNote(version?: string) { note( `${color.magenta(`${color.bold("Sisyphus ")}${color.underline(color.italic(`v${version ?? "unknown"}`))}`)}${color.green(" is setup to roll stones.")} diff --git a/packages/sisyphus/src/commands/roll.ts b/packages/sisyphus/src/commands/roll.ts index 1ee942e..6bbffba 100644 --- a/packages/sisyphus/src/commands/roll.ts +++ b/packages/sisyphus/src/commands/roll.ts @@ -345,7 +345,6 @@ export class RollCommand extends BaseCommand { s.stop("Release created"); } - ctx.config.set("currentRelease", undefined); ctx.config.set("lastStone", { commit: await this.getCurrentCommit(), date: new Date().toISOString() }); note( @@ -360,7 +359,7 @@ export class RollCommand extends BaseCommand { } private createFallbackStone(packages: Package[]): Stone { - const message = `Release ${packages.map((p) => `${p.name}@${p.version}`).join(", ")}`; + const message = `Release ${packages.map((p) => `${p.name}@${p.newVersion ?? p.version}`).join(", ")}`; return Stone.create({ message }, 0); } } diff --git a/packages/sisyphus/src/domain/Commit.ts b/packages/sisyphus/src/domain/Commit.ts index ee5cb1f..ee2be56 100644 --- a/packages/sisyphus/src/domain/Commit.ts +++ b/packages/sisyphus/src/domain/Commit.ts @@ -120,21 +120,26 @@ export class Commit { } private static async fetchFromRange(range: string): Promise { - const result = await Bun.$`git log ${range} --pretty=format:"%H%x1f%s%x1f%an" --no-merges`.quiet(); + const format = "%x00%H%x1f%s%x1f%an%x1f%b%x00"; + const result = await Bun.$`git log ${range} --pretty=format:${format} --name-only --no-merges`.quiet(); const output = result.stdout.toString().trim(); if (!output) return []; + const segments = output.split("\x00").filter(Boolean); const commits: Commit[] = []; - for (const line of output.split("\n")) { - const parts = line.split(FIELD_SEPARATOR); - if (parts.length < 3) continue; - const [hash, subject, author] = parts; + for (let i = 0; i < segments.length; i += 2) { + const fields = segments[i]!.split(FIELD_SEPARATOR); + if (fields.length < 3) continue; + + const [hash, subject, author] = fields; if (!hash || !subject || !author) continue; - const commit = await Commit.hydrate(hash, subject, author); - commits.push(commit); + const body = fields.slice(3).join(FIELD_SEPARATOR).trim() || undefined; + const files = (segments[i + 1] ?? "").split("\n").filter(Boolean); + + commits.push(Commit.parse(hash, subject, author).withFiles(files).withBody(body)); } return commits; diff --git a/packages/sisyphus/src/providers/GitHubProvider.ts b/packages/sisyphus/src/providers/GitHubProvider.ts index a598d71..84c0c57 100644 --- a/packages/sisyphus/src/providers/GitHubProvider.ts +++ b/packages/sisyphus/src/providers/GitHubProvider.ts @@ -40,6 +40,10 @@ type GitHubCliPrResponse = { export class GitHubProvider extends GitProvider { readonly name = "github" as const; + private get apiPath(): string { + return `repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(this.repo)}`; + } + async ensureAvailable(): Promise { try { await Bun.$`which gh`.quiet(); @@ -83,14 +87,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}`; + await Bun.$`gh api ${this.apiPath}/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}`; + await Bun.$`gh api ${this.apiPath}/issues/${prNumber}/labels --method POST ${labelArgs}`; } return this.getPr(prNumber); @@ -106,7 +110,7 @@ export class GitHubProvider extends GitProvider { async getPr(number: number): Promise { try { - const result = await Bun.$`gh api repos/${this.owner}/${this.repo}/pulls/${number}`.quiet(); + const result = await Bun.$`gh api ${this.apiPath}/pulls/${number}`.quiet(); const data = JSON.parse(result.stdout.toString()); return this.mapRestApiResponse(data); @@ -130,7 +134,7 @@ export class GitHubProvider extends GitProvider { async getPrCommits(number: number): Promise { try { const result = - await Bun.$`gh api repos/${this.owner}/${this.repo}/pulls/${number}/commits --jq '.[].sha'`.quiet(); + await Bun.$`gh api ${this.apiPath}/pulls/${number}/commits --jq '.[].sha'`.quiet(); return result.stdout.toString().trim().split("\n").filter(Boolean); } catch { return []; @@ -140,7 +144,7 @@ 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(); + await Bun.$`gh api ${this.apiPath}/pulls/${number}/files --jq '.[].filename'`.quiet(); return result.stdout.toString().trim().split("\n").filter(Boolean); } catch { return []; diff --git a/packages/sisyphus/src/providers/GitLabProvider.ts b/packages/sisyphus/src/providers/GitLabProvider.ts index 0c49096..7983fe3 100644 --- a/packages/sisyphus/src/providers/GitLabProvider.ts +++ b/packages/sisyphus/src/providers/GitLabProvider.ts @@ -7,6 +7,7 @@ import { type FindPrOptions, GitProvider, type PullRequest, + type RemoteInfo, type UpdatePrOptions, } from "./GitProvider"; @@ -25,30 +26,28 @@ type GitLabMrResponse = { type GitLabChangesResponse = { changes: { new_path: string }[] }; +const DEFAULT_API_URL = "https://gitlab.com/api/v4"; + export class GitLabProvider extends GitProvider { readonly name = "gitlab" as const; - async ensureAvailable(): Promise { - try { - await Bun.$`which glab`.quiet(); - } catch { - throw new Exit( - "GitLab CLI (glab) is not installed", - "Install from https://gitlab.com/gitlab-org/cli and run: glab auth login", - ); - } + private authHeader: Record = {}; + private apiUrl: string; + private projectPath: string; - try { - await Bun.$`glab auth status`.quiet(); - } catch { - throw new Exit("GitLab CLI (glab) is not authenticated", "Run: glab auth login"); - } + constructor(info: RemoteInfo) { + super(info); + this.apiUrl = process.env.CI_API_V4_URL ?? DEFAULT_API_URL; + this.projectPath = encodeURIComponent(`${this.owner}/${this.repo}`); + } + + async ensureAvailable(): Promise { + this.authHeader = await this.resolveAuth(); } async getDefaultBranch(): Promise { try { - const result = await Bun.$`glab repo view --output json`.quiet(); - const data = JSON.parse(result.stdout.toString()); + const data = await this.api<{ default_branch: string }>(`/projects/${this.projectPath}`); return data.default_branch || DEFAULT_BRANCH; } catch { return DEFAULT_BRANCH; @@ -57,15 +56,15 @@ export class GitLabProvider extends GitProvider { 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 params = new URLSearchParams({ per_page: "1", state: "opened" }); + if (options.head) params.set("source_branch", options.head); + if (options.label) params.set("labels", options.label); - const result = await Bun.$`glab mr list --output json --per-page 1 ${optionalArgs}`.quiet(); - const mrs = JSON.parse(result.stdout.toString()); + const mrs = await this.api( + `/projects/${this.projectPath}/merge_requests?${params}`, + ); if (!mrs[0]) return null; - return this.mapMrResponse(mrs[0]); } catch { return null; @@ -73,27 +72,42 @@ export class GitLabProvider extends GitProvider { } async createPr(options: CreatePrOptions): Promise { - const labelArgs = options.labels?.flatMap((l) => ["--label", l]) ?? []; + const body: Record = { + description: options.body, + source_branch: options.head, + target_branch: options.base, + title: options.title, + }; - const result = - await Bun.$`glab mr create --source-branch ${options.head} --target-branch ${options.base} --title ${options.title} --description ${options.body} ${labelArgs} --yes`; + if (options.labels?.length) { + body.labels = options.labels.join(","); + } - const iid = this.extractMrNumber(result.stdout.toString()); - return this.getPr(iid); + const data = await this.api( + `/projects/${this.projectPath}/merge_requests`, + { body: JSON.stringify(body), method: "POST" }, + ); + + return this.mapMrResponse(data); } 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}`; + const body: Record = {}; + if (options.title) body.title = options.title; + if (options.body) body.description = options.body; + + await this.api( + `/projects/${this.projectPath}/merge_requests/${number}`, + { body: JSON.stringify(body), method: "PUT" }, + ); } 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())); + const data = await this.api( + `/projects/${this.projectPath}/merge_requests/${number}`, + ); + return this.mapMrResponse(data); } catch { throw new Exit(`Failed to fetch MR !${number}`, "Make sure the MR exists and you have access"); } @@ -101,8 +115,16 @@ export class GitLabProvider extends GitProvider { async getPrFromCurrentBranch(): Promise { try { - const result = await Bun.$`glab mr view --output json`.quiet(); - return this.mapMrResponse(JSON.parse(result.stdout.toString())); + const result = await Bun.$`git rev-parse --abbrev-ref HEAD`.quiet(); + const branch = result.stdout.toString().trim(); + + const params = new URLSearchParams({ per_page: "1", source_branch: branch, state: "opened" }); + const mrs = await this.api( + `/projects/${this.projectPath}/merge_requests?${params}`, + ); + + if (!mrs[0]) throw new Error("No MR found"); + return this.mapMrResponse(mrs[0]); } catch { throw new Exit("No MR found for current branch", "Make sure you have an open MR or provide a URL with --url"); } @@ -110,10 +132,10 @@ export class GitLabProvider extends GitProvider { 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); + const commits = await this.api<{ id: string }[]>( + `/projects/${this.projectPath}/merge_requests/${number}/commits`, + ); + return commits.map((c) => c.id); } catch { return []; } @@ -121,9 +143,9 @@ 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: GitLabChangesResponse = JSON.parse(result.stdout.toString()); + const data = await this.api( + `/projects/${this.projectPath}/merge_requests/${number}/changes`, + ); return data.changes?.map((c) => c.new_path) ?? []; } catch { return []; @@ -132,30 +154,69 @@ export class GitLabProvider extends GitProvider { 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(); + const body: Record = { name }; + if (options?.description) body.description = options.description; + if (options?.color) body.color = `#${options.color}`; + + await this.api(`/projects/${this.projectPath}/labels`, { + body: JSON.stringify(body), + method: "POST", + }); } catch {} } async createRelease(options: CreateReleaseOptions): Promise { - await Bun.$`glab release create ${options.tag} --name ${options.title} --notes ${options.notes}`; + await this.api(`/projects/${this.projectPath}/releases`, { + body: JSON.stringify({ + description: options.notes, + name: options.title, + tag_name: options.tag, + }), + method: "POST", + }); } async deleteRelease(tag: string): Promise { try { - await Bun.$`glab release delete ${tag} --yes`.quiet(); + await this.api(`/projects/${this.projectPath}/releases/${encodeURIComponent(tag)}`, { + method: "DELETE", + }); } catch {} } - private extractMrNumber(output: string): number { - const bangMatch = output.match(/!(\d+)/); - if (bangMatch?.[1]) return Number.parseInt(bangMatch[1], 10); + private async resolveAuth(): Promise> { + if (process.env.GITLAB_TOKEN) return { "PRIVATE-TOKEN": process.env.GITLAB_TOKEN }; + if (process.env.CI_JOB_TOKEN) return { "JOB-TOKEN": process.env.CI_JOB_TOKEN }; + + try { + const result = await Bun.$`glab auth status --show-token`.quiet(); + const output = result.stdout.toString() + result.stderr.toString(); + const match = output.match(/Token found:\s*(\S+)/); + if (match?.[1]) return { Authorization: `Bearer ${match[1]}` }; + } catch {} - const urlMatch = output.match(/merge_requests\/(\d+)/); - if (urlMatch?.[1]) return Number.parseInt(urlMatch[1], 10); + throw new Exit( + "GitLab token not found", + "Set GITLAB_TOKEN environment variable or install glab CLI and run: glab auth login", + ); + } + + private async api(path: string, options?: RequestInit): Promise { + const response = await fetch(`${this.apiUrl}${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + ...this.authHeader, + ...options?.headers, + }, + }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error(`GitLab API error: ${response.status} ${response.statusText}${body ? ` — ${body}` : ""}`); + } - throw new Exit("Failed to parse MR number from output", output); + return response.json() as Promise; } private mapMrResponse(data: GitLabMrResponse): PullRequest { diff --git a/packages/sisyphus/src/providers/index.ts b/packages/sisyphus/src/providers/index.ts index 9c798c2..39a7465 100644 --- a/packages/sisyphus/src/providers/index.ts +++ b/packages/sisyphus/src/providers/index.ts @@ -31,6 +31,12 @@ export async function createGitProvider() { throw new Exit("Could not detect git provider", "Make sure you have a git remote configured (origin)"); } + const provider = createProvider(info); + await provider.ensureAvailable(); + return provider; +} + +function createProvider(info: RemoteInfo) { switch (info.provider) { case "github": return new GitHubProvider(info); @@ -57,7 +63,7 @@ export async function detectProvider(): Promise { return info.provider; } -async function getRemoteUrl(): Promise { +export async function getRemoteUrl(): Promise { try { const result = await Bun.$`git remote get-url origin`.quiet(); return result.stdout.toString().trim() || null; @@ -66,7 +72,7 @@ async function getRemoteUrl(): Promise { } } -function parseRemoteUrl(url: string): RemoteInfo | null { +export function parseRemoteUrl(url: string): RemoteInfo | null { const patterns: [RegExp, Provider][] = [ [GITHUB_PATTERN, "github"], [GITLAB_PATTERN, "gitlab"], diff --git a/packages/sisyphus/src/services/ChangelogGenerator.ts b/packages/sisyphus/src/services/ChangelogGenerator.ts index a5e0ef1..a4d7897 100644 --- a/packages/sisyphus/src/services/ChangelogGenerator.ts +++ b/packages/sisyphus/src/services/ChangelogGenerator.ts @@ -167,14 +167,19 @@ export class ChangelogGenerator { const date = this.getDate(); const emoji = this.getEmoji(pkg); - return this.config.packageHeader.replace("{emoji}", emoji).replace("{version}", version).replace("{date}", date); + 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); + return this.config.rootHeader + .replace("{date}", () => date) + .replace("{packages}", () => packageList); } private formatRootStoneContent(stone: Stone): string { diff --git a/packages/sisyphus/src/services/CommitAnalyzer.ts b/packages/sisyphus/src/services/CommitAnalyzer.ts index 3d2b7dc..58d3aaa 100644 --- a/packages/sisyphus/src/services/CommitAnalyzer.ts +++ b/packages/sisyphus/src/services/CommitAnalyzer.ts @@ -69,6 +69,7 @@ export class CommitAnalyzer { private async getCommitsSinceLastRelease(): Promise { const lastStone = this.config.get("lastStone"); const lastCommit = lastStone?.commit || undefined; + if (!lastCommit) return []; return Commit.since(lastCommit); } diff --git a/packages/sisyphus/src/services/GitRemoteParser.ts b/packages/sisyphus/src/services/GitRemoteParser.ts index cb0e6da..57ca597 100644 --- a/packages/sisyphus/src/services/GitRemoteParser.ts +++ b/packages/sisyphus/src/services/GitRemoteParser.ts @@ -1,10 +1,4 @@ -const GITHUB_PATTERN = /github\.com[:/]([^/]+)\/([^/.]+)/; -const GITLAB_PATTERN = /gitlab\.com[:/]([^/]+)\/([^/.]+)/; -const BITBUCKET_PATTERN = /bitbucket\.org[:/]([^/]+)\/([^/.]+)/; - -type Provider = "github" | "gitlab" | "bitbucket"; - -type RemoteInfo = { provider: Provider; owner: string; repo: string; commitUrl: (hash: string) => string }; +import { getRemoteUrl, parseRemoteUrl, type Provider, type RemoteInfo } from "../providers"; const COMMIT_PATH: Record = { bitbucket: "commits", github: "commit", gitlab: "-/commit" }; const PROVIDER_DOMAIN: Record = { @@ -13,56 +7,34 @@ const PROVIDER_DOMAIN: Record = { gitlab: "gitlab.com", }; +type RemoteInfoWithCommitUrl = RemoteInfo & { commitUrl: (hash: string) => string }; + export class GitRemoteParser { - private cached: RemoteInfo | null | undefined = undefined; + private cached: RemoteInfoWithCommitUrl | null | undefined = undefined; - async getRemoteInfo(): Promise { + async getRemoteInfo(): Promise { if (this.cached !== undefined) return this.cached; - const url = await this.getRemoteUrl(); + const url = await getRemoteUrl(); if (!url) { this.cached = null; return null; } - this.cached = this.parseUrl(url); - return this.cached; - } - - private async getRemoteUrl(): Promise { - try { - const result = await Bun.$`git remote get-url origin`.quiet(); - return result.stdout.toString().trim() || null; - } catch { + const info = parseRemoteUrl(url); + if (!info) { + this.cached = null; return null; } - } - private parseUrl(url: string): RemoteInfo | null { - const patterns: [RegExp, Provider][] = [ - [GITHUB_PATTERN, "github"], - [GITLAB_PATTERN, "gitlab"], - [BITBUCKET_PATTERN, "bitbucket"], - ]; + const domain = PROVIDER_DOMAIN[info.provider]; + const commitPath = COMMIT_PATH[info.provider]; + const baseUrl = `https://${domain}/${info.owner}/${info.repo}`; - for (const [pattern, provider] of patterns) { - const match = url.match(pattern); - if (!match) continue; - - const [, owner, repo] = match; - if (owner && repo) { - return this.createRemoteInfo(provider, owner, repo); - } - } - - return null; - } - - private createRemoteInfo(provider: Provider, owner: string, repo: string): RemoteInfo { - const domain = PROVIDER_DOMAIN[provider]; - const commitPath = COMMIT_PATH[provider]; - const baseUrl = `https://${domain}/${owner}/${repo}`; - - return { commitUrl: (hash: string) => `${baseUrl}/${commitPath}/${hash}`, owner, provider, repo }; + this.cached = { + ...info, + commitUrl: (hash: string) => `${baseUrl}/${commitPath}/${hash}`, + }; + return this.cached; } } diff --git a/packages/sisyphus/src/services/PullRequestAnalyzer.ts b/packages/sisyphus/src/services/PullRequestAnalyzer.ts index 9da20c8..2b790f9 100644 --- a/packages/sisyphus/src/services/PullRequestAnalyzer.ts +++ b/packages/sisyphus/src/services/PullRequestAnalyzer.ts @@ -41,7 +41,6 @@ export class PullRequestAnalyzer { async analyze(url?: string): Promise { const provider = await this.getProvider(); - await provider.ensureAvailable(); const pr = url ? await this.fetchFromUrl(provider, url) : await this.fetchFromCurrentBranch(provider); @@ -85,7 +84,9 @@ export class PullRequestAnalyzer { pr: PullRequestInfo, ctx: AnalysisContext, ): Promise<{ commits: CommitInfo[]; affectedPackages: Set }> { - const files = ctx.url ? await this.fetchFilesFromApi(ctx.provider, ctx.url) : []; + const files = ctx.url + ? await this.fetchFilesFromApi(ctx.provider, ctx.url) + : await this.getFilesFromCommit(pr.mergeCommitSha); const affectedPackages = findAffectedPackages(files, ctx.packagePaths, ctx.isSinglePackage); const commit: CommitInfo = { @@ -144,7 +145,8 @@ export class PullRequestAnalyzer { private async getCommitParentCount(sha: string): Promise { try { - const result = await Bun.$`git rev-parse ${sha}^@ 2>/dev/null`.quiet(); + const result = await Bun.$`git rev-parse ${sha}^@`.quiet().nothrow(); + if (result.exitCode !== 0) return 1; const parents = result.stdout.toString().trim().split("\n").filter(Boolean); return parents.length; } catch { @@ -231,4 +233,14 @@ export class PullRequestAnalyzer { return null; } + + private async getFilesFromCommit(sha: string | null): Promise { + if (!sha) return []; + try { + const result = await Bun.$`git diff-tree --no-commit-id --name-only -r ${sha}`.quiet(); + return result.stdout.toString().trim().split("\n").filter(Boolean); + } catch { + return []; + } + } } diff --git a/packages/sisyphus/src/services/ReleaseOrchestrator.ts b/packages/sisyphus/src/services/ReleaseOrchestrator.ts index 2915881..78b85d9 100644 --- a/packages/sisyphus/src/services/ReleaseOrchestrator.ts +++ b/packages/sisyphus/src/services/ReleaseOrchestrator.ts @@ -311,8 +311,8 @@ export class ReleaseOrchestrator { try { await fn(); } catch (error) { - const stderr = this.getStderr(error); - throw new Exit(context, stderr); + const detail = this.getStderr(error) ?? (error instanceof Error ? error.message : undefined); + throw new Error(detail ? `${context}: ${detail}` : context); } } diff --git a/packages/sisyphus/src/services/StoneManager.ts b/packages/sisyphus/src/services/StoneManager.ts index 96b3dda..af75243 100644 --- a/packages/sisyphus/src/services/StoneManager.ts +++ b/packages/sisyphus/src/services/StoneManager.ts @@ -158,12 +158,12 @@ export class StoneManager { } async getAllTrackedCommitHashes(): Promise> { - const allStones = await this.listAllStones(); - const hashes = allStones.flatMap((stone) => stone.commits ?? []).map((commit) => commit.hash); + const pending = await this.list(); + const hashes = pending.flatMap((stone) => stone.commits ?? []).map((commit) => commit.hash); return new Set(hashes); } - private async listAllStones(): Promise { + async listAllStones(): Promise { const pending = await this.list(); const timestamps = await this.listReleasedTimestamps(); const released = await Promise.all(timestamps.map((t) => this.getReleasedStones(t)));