Skip to content
Merged
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
17 changes: 14 additions & 3 deletions src/commands/issue/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,14 +285,25 @@ export const mergeCommand = buildCommand({
async *func(this: SentryContext, flags: MergeFlags, ...args: string[]) {
const { cwd } = this;

if (args.length < 2) {
// Accept "sentry issue merge A --into B" as a valid 2-issue merge.
// --into already designates which issue is the merge target, so passing
// it as a positional too would be redundant. Append it to args so the
// rest of the pipeline sees 2+ issues and orderForMerge puts it first.
const effectiveArgs =
args.length === 1 && flags.into ? [...args, flags.into] : args;

if (effectiveArgs.length < 2) {
const hint =
args.length === 1
? "Tip: you can also write: sentry issue merge CLI-K9 --into CLI-15H"
: "Example: sentry issue merge CLI-K9 CLI-15H CLI-15N";
throw new ValidationError(
`'sentry ${COMMAND_PATH}' needs at least 2 issue IDs (got ${args.length}).\n\n` +
"Example: sentry issue merge CLI-K9 CLI-15H CLI-15N"
hint
);
}

const { org, issues } = await resolveAllIssues(args, cwd);
const { org, issues } = await resolveAllIssues(effectiveArgs, cwd);
const ordered = await orderForMerge(issues, flags.into, cwd);
const groupIds = ordered.map((i) => i.id);
// `--into` is a preference, not a guarantee — track it so we can warn
Expand Down
73 changes: 72 additions & 1 deletion test/commands/issue/merge.func.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,19 @@ describe("mergeCommand.func()", () => {
mergeSpy.mockRestore();
});

test("rejects when fewer than 2 issues are provided", async () => {
test("rejects when fewer than 2 issues are provided (0 positionals, no --into)", async () => {
const { context } = createMockContext();
const func = await mergeCommand.loader();
const err = await func
.call(context, { json: false })
.catch((e: Error) => e);

expect(err).toBeInstanceOf(Error);
expect(err.message).toContain("needs at least 2 issue IDs");
expect(mergeSpy).not.toHaveBeenCalled();
});

test("rejects when 1 positional is given without --into (shows --into hint)", async () => {
const { context } = createMockContext();
const func = await mergeCommand.loader();
const err = await func
Expand All @@ -79,6 +91,65 @@ describe("mergeCommand.func()", () => {

expect(err).toBeInstanceOf(Error);
expect(err.message).toContain("needs at least 2 issue IDs");
expect(err.message).toContain("--into");
expect(mergeSpy).not.toHaveBeenCalled();
});

test("rejects when 0 positionals + --into (still needs at least 1 positional)", async () => {
const { context } = createMockContext();
const func = await mergeCommand.loader();
const err = await func
.call(context, { json: false, into: "CLI-B" })
.catch((e: Error) => e);

expect(err).toBeInstanceOf(Error);
expect(err.message).toContain("needs at least 2 issue IDs");
expect(mergeSpy).not.toHaveBeenCalled();
});

test("accepts 1 positional + --into as a valid 2-issue merge (CLI-1AE fix)", async () => {
resolveIssueSpy.mockImplementation(({ issueArg }: { issueArg: string }) =>
Promise.resolve({
org: "test-org",
issue: makeMockIssue({
shortId: issueArg,
id: issueArg.replace("CLI-", "10"),
}),
})
);
// Sentry honors the --into preference in this case
mergeSpy.mockResolvedValue({ parent: "10B", children: ["10A"] });

const { context } = createMockContext();
const func = await mergeCommand.loader();
// The user's actual pattern that triggered CLI-1AE:
// sentry issue merge CLI-A --into CLI-B
await func.call(context, { json: false, into: "CLI-B" }, "CLI-A");

expect(mergeSpy).toHaveBeenCalledTimes(1);
const callArgs = mergeSpy.mock.calls[0] as [string, string[]];
expect(callArgs[0]).toBe("test-org");
// CLI-B (--into target) moved to front as preferred parent
expect(callArgs[1][0]).toBe("10B");
expect(new Set(callArgs[1])).toEqual(new Set(["10A", "10B"]));
});

test("1 positional + --into same issue triggers dedupe guard", async () => {
// sentry issue merge CLI-A --into CLI-A — both resolve to the same group
resolveIssueSpy.mockImplementation(() =>
Promise.resolve({
org: "test-org",
issue: makeMockIssue({ shortId: "CLI-A", id: "100" }),
})
);

const { context } = createMockContext();
const func = await mergeCommand.loader();
const err = await func
.call(context, { json: false, into: "CLI-A" }, "CLI-A")
.catch((e: Error) => e);

expect(err.message).toContain("at least 2 distinct issues");
expect(mergeSpy).not.toHaveBeenCalled();
});

Expand Down
Loading