Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/release/src/commands/standing-pr-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
7 changes: 6 additions & 1 deletion packages/release/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
31 changes: 16 additions & 15 deletions packages/release/src/standing-pr/standing-pr.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1777,7 +1776,7 @@ export async function runStandingPRPublish(
export async function runStandingPRMerge(
options: StandingPROptions,
flags: { publish: boolean },
): Promise<ReleaseOutput | null> {
): Promise<StandingPRMergeResult> {
const cwd = options.projectDir;

const releaseKitConfig = loadReleaseKitConfig({ cwd, configPath: options.config });
Expand All @@ -1788,16 +1787,17 @@ 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);

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}...`);
Expand All @@ -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) };
}
77 changes: 50 additions & 27 deletions packages/release/test/unit/standing-pr.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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),
Expand All @@ -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 }),
Expand Down
Loading