diff --git a/packages/release/src/commands/standing-pr-command.ts b/packages/release/src/commands/standing-pr-command.ts index df780617..7e0490f7 100644 --- a/packages/release/src/commands/standing-pr-command.ts +++ b/packages/release/src/commands/standing-pr-command.ts @@ -114,7 +114,7 @@ export function createStandingPRCommand(): Command { try { const result = await runStandingPRMerge(options, { publish: opts.publish }); - emitResult(result, { json: opts.json, output: opts.output, changed: result != null }); + emitResult(result, { json: opts.json, output: opts.output, changed: result.action === 'merged' }); } catch (err) { emitError(err, { json: opts.json, output: opts.output }); console.error(err instanceof Error ? err.message : String(err)); diff --git a/packages/release/src/index.ts b/packages/release/src/index.ts index 409f32b2..8248b1c8 100644 --- a/packages/release/src/index.ts +++ b/packages/release/src/index.ts @@ -25,7 +25,12 @@ export { runGate } from './gate/gate.js'; export type { PreviewOptions } from './preview/preview.js'; export { runPreview } from './preview/preview.js'; export { resolveScopeToTarget, runRelease } from './release.js'; -export type { StandingPRManifest, StandingPROptions, StandingPRResult } from './standing-pr/standing-pr.js'; +export type { + StandingPRManifest, + StandingPRMergeResult, + StandingPROptions, + StandingPRResult, +} from './standing-pr/standing-pr.js'; export { publishFromManifest, runStandingPRMerge, diff --git a/packages/release/src/standing-pr/standing-pr.ts b/packages/release/src/standing-pr/standing-pr.ts index e41f2e61..32ae9da5 100644 --- a/packages/release/src/standing-pr/standing-pr.ts +++ b/packages/release/src/standing-pr/standing-pr.ts @@ -1,15 +1,7 @@ import * as fs from 'node:fs'; import { type CIConfig, DEFAULT_DEMOTE_SCOPES, loadConfig as loadReleaseKitConfig } from '@releasekit/config'; import type { VersionOutput, VersionPackageUpdate } from '@releasekit/core'; -import { - deriveReleaseChannel, - error, - info, - markerData, - shouldMatchPackageTargets, - success, - warn, -} from '@releasekit/core'; +import { deriveReleaseChannel, info, markerData, shouldMatchPackageTargets, success, warn } from '@releasekit/core'; import { type Forge, type ForgeComment, @@ -127,6 +119,13 @@ export interface StandingPRResult { versionOutput?: VersionOutput; } +export interface StandingPRMergeResult { + action: 'merged' | 'noop'; + prNumber?: number; + prUrl?: string; + releaseOutput?: ReleaseOutput; +} + /** Old manifests live in open PRs: fields added after v1 must stay optional and be tolerated when absent. */ export interface StandingPRManifest { schemaVersion: 1 | 2; @@ -1777,7 +1776,7 @@ export async function runStandingPRPublish( export async function runStandingPRMerge( options: StandingPROptions, flags: { publish: boolean }, -): Promise { +): Promise { const cwd = options.projectDir; const releaseKitConfig = loadReleaseKitConfig({ cwd, configPath: options.config }); @@ -1788,8 +1787,9 @@ export async function runStandingPRMerge( const githubContext = getGitHubContext(); if (!githubContext?.token) { - error('No GitHub context (GITHUB_REPOSITORY or GITHUB_TOKEN) available'); - return null; + throw new Error( + 'No GitHub context (GITHUB_REPOSITORY or GITHUB_TOKEN) available, so the standing PR cannot be merged.', + ); } const forge = forgeFor(githubContext); @@ -1797,7 +1797,7 @@ export async function runStandingPRMerge( const pr = await forge.findStandingPR(branch); if (!pr) { info(`No open standing PR found for branch '${branch}'`); - return null; + return { action: 'noop' }; } info(`Merging standing PR #${pr.number} via ${mergeMethod}...`); @@ -1819,9 +1819,10 @@ export async function runStandingPRMerge( await deleteReleaseBranch(branch, cwd); } + const merged: StandingPRMergeResult = { action: 'merged', prNumber: pr.number, prUrl: pr.url }; if (!flags.publish) { - return null; + return merged; } - return publishFromManifest(pr.number, options); + return { ...merged, releaseOutput: await publishFromManifest(pr.number, options) }; } diff --git a/packages/release/test/unit/standing-pr.spec.ts b/packages/release/test/unit/standing-pr.spec.ts index 440bdc1e..b1c2df49 100644 --- a/packages/release/test/unit/standing-pr.spec.ts +++ b/packages/release/test/unit/standing-pr.spec.ts @@ -1,7 +1,8 @@ -import type { VersionPackageUpdate } from '@releasekit/core'; +import { EXIT_CODES, type VersionPackageUpdate } from '@releasekit/core'; import { createFakeForge, type FakeForge, type FakeForgeSeed } from '@releasekit/forge'; import { FakeGit, type FakeGitSeed, type GitLogOptions } from '@releasekit/git'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createStandingPRCommand } from '../../src/commands/standing-pr-command.js'; import { renderFailureReport, renderResolvedReport } from '../../src/failure-report/failure-report.js'; import { renderNotesRegion } from '../../src/standing-pr/notes-region.js'; import type { StandingPRManifest } from '../../src/standing-pr/standing-pr.js'; @@ -3130,27 +3131,56 @@ describe('runStandingPRMerge', () => { process.env = { ...originalEnv }; }); - it('should return null when no GitHub context available', async () => { - delete process.env.GITHUB_REPOSITORY; - delete process.env.GITHUB_TOKEN; + async function runMergeCommand() { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + try { + await createStandingPRCommand().parseAsync(['merge', '--project-dir', '/test', '--json'], { from: 'user' }); + return { envelope: JSON.parse(logSpy.mock.calls[0]?.[0] as string), exitCode: exitSpy.mock.calls[0]?.[0] }; + } finally { + logSpy.mockRestore(); + errSpy.mockRestore(); + exitSpy.mockRestore(); + } + } - const result = await runStandingPRMerge( - { projectDir: '/test', verbose: false, quiet: false, json: false }, - { publish: false }, - ); + it('should report changed:true with the merged PR when merging without --publish', async () => { + await mockForge({ standingPR: openStandingPR(42) }); - expect(result).toBeNull(); + const { envelope, exitCode } = await runMergeCommand(); + + expect(exitCode).toBeUndefined(); + expect(envelope).toMatchObject({ + status: 'success', + changed: true, + data: { action: 'merged', prNumber: 42, prUrl: 'https://github.com/owner/repo/pull/42' }, + }); }); - it('should return null when no open standing PR found', async () => { + it('should report changed:false with a noop result when no open standing PR is found', async () => { const forge = await mockForge({ standingPR: null }); - const result = await runStandingPRMerge( - { projectDir: '/test', verbose: false, quiet: false, json: false }, - { publish: false }, - ); + const { envelope, exitCode } = await runMergeCommand(); - expect(result).toBeNull(); + expect(exitCode).toBeUndefined(); + expect(envelope).toMatchObject({ status: 'success', changed: false, data: { action: 'noop' } }); + expect(forge.mergedPullRequests).toHaveLength(0); + }); + + it('should fail with a general error when no GitHub context is available', async () => { + delete process.env.GITHUB_REPOSITORY; + delete process.env.GITHUB_TOKEN; + const forge = await mockForge({ standingPR: openStandingPR(42) }); + + const { envelope, exitCode } = await runMergeCommand(); + + expect(exitCode).toBe(EXIT_CODES.GENERAL_ERROR); + expect(envelope).toMatchObject({ + status: 'error', + changed: false, + errors: [{ code: 'GENERAL_ERROR', message: expect.stringContaining('No GitHub context') }], + }); expect(forge.mergedPullRequests).toHaveLength(0); }); @@ -3212,17 +3242,6 @@ describe('runStandingPRMerge', () => { ).rejects.toThrow('Network error'); }); - it('should return null without publishing when publish flag is false', async () => { - await mockForge({ standingPR: openStandingPR(42) }); - - const result = await runStandingPRMerge( - { projectDir: '/test', verbose: false, quiet: false, json: false }, - { publish: false }, - ); - - expect(result).toBeNull(); - }); - it('should call publishFromManifest when publish flag is true and manifest exists', async () => { await mockForge({ standingPR: openStandingPR(42), @@ -3244,7 +3263,11 @@ describe('runStandingPRMerge', () => { { publish: true }, ); - expect(result).not.toBeNull(); + expect(result).toMatchObject({ + action: 'merged', + prNumber: 42, + releaseOutput: { publishOutput: { publishSucceeded: true } }, + }); expect(vi.mocked(runPublishStep)).toHaveBeenCalledWith( expect.objectContaining({ updates: baseManifest.versionOutput.updates }), expect.objectContaining({ skipGitCommit: true }),