diff --git a/src/commands/issue/merge.ts b/src/commands/issue/merge.ts index d280dacd2..bd1185c60 100644 --- a/src/commands/issue/merge.ts +++ b/src/commands/issue/merge.ts @@ -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 diff --git a/test/commands/issue/merge.func.test.ts b/test/commands/issue/merge.func.test.ts index 319a89d10..8c37dd455 100644 --- a/test/commands/issue/merge.func.test.ts +++ b/test/commands/issue/merge.func.test.ts @@ -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 @@ -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(); });