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
5 changes: 3 additions & 2 deletions src/commands/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { resolveMilestoneId } from "../resolvers/milestone-resolver.js";
import { resolveProjectId } from "../resolvers/project-resolver.js";
import { resolveStatusId } from "../resolvers/status-resolver.js";
import { resolveTeamId } from "../resolvers/team-resolver.js";
import { resolveUserId } from "../resolvers/user-resolver.js";
import {
createIssueRelation,
deleteIssueRelation,
Expand Down Expand Up @@ -262,7 +263,7 @@ export function setupIssuesCommands(program: Command): void {
}

if (options.assignee) {
input.assigneeId = options.assignee;
input.assigneeId = await resolveUserId(ctx.sdk, options.assignee);
}

if (options.priority) {
Expand Down Expand Up @@ -435,7 +436,7 @@ export function setupIssuesCommands(program: Command): void {
}

if (options.assignee) {
input.assigneeId = options.assignee;
input.assigneeId = await resolveUserId(ctx.sdk, options.assignee);
}

if (options.project) {
Expand Down
37 changes: 37 additions & 0 deletions src/resolvers/user-resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { LinearSdkClient } from "../client/linear-client.js";
import { multipleMatchesError, notFoundError } from "../common/errors.js";
import { isUuid } from "../common/identifier.js";

export async function resolveUserId(
client: LinearSdkClient,
nameOrEmailOrId: string,
): Promise<string> {
if (isUuid(nameOrEmailOrId)) return nameOrEmailOrId;

// Try by display name first (case-insensitive)
const byName = await client.sdk.users({
filter: { displayName: { eqIgnoreCase: nameOrEmailOrId } },
first: 10,
});

if (byName.nodes.length === 1) return byName.nodes[0].id;

if (byName.nodes.length > 1) {
throw multipleMatchesError(
"User",
nameOrEmailOrId,
byName.nodes.map((u) => `${u.name} <${u.email}>`),
"Use email or UUID to disambiguate",
);
}

// Fall back to email (case-insensitive)
const byEmail = await client.sdk.users({
filter: { email: { eqIgnoreCase: nameOrEmailOrId } },
first: 1,
});

if (byEmail.nodes.length > 0) return byEmail.nodes[0].id;

throw notFoundError("User", nameOrEmailOrId);
}
203 changes: 203 additions & 0 deletions tests/unit/commands/issues.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
// tests/unit/commands/issues.test.ts
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";

// Mock all external dependencies before importing the module under test
vi.mock("../../../src/common/context.js", () => ({
createContext: vi.fn(() => ({
gql: { request: vi.fn() },
sdk: { sdk: {} },
})),
}));

vi.mock("../../../src/common/output.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../../src/common/output.js")>();
return {
...actual,
outputSuccess: vi.fn(),
};
});

vi.mock("../../../src/resolvers/user-resolver.js", () => ({
resolveUserId: vi.fn().mockResolvedValue("resolved-user-uuid"),
}));

vi.mock("../../../src/resolvers/team-resolver.js", () => ({
resolveTeamId: vi.fn().mockResolvedValue("resolved-team-uuid"),
}));

vi.mock("../../../src/resolvers/issue-resolver.js", () => ({
resolveIssueId: vi.fn().mockResolvedValue("resolved-issue-uuid"),
}));

vi.mock("../../../src/resolvers/project-resolver.js", () => ({
resolveProjectId: vi.fn().mockResolvedValue("resolved-project-uuid"),
}));

vi.mock("../../../src/resolvers/label-resolver.js", () => ({
resolveLabelIds: vi.fn().mockResolvedValue(["resolved-label-uuid"]),
}));

vi.mock("../../../src/resolvers/milestone-resolver.js", () => ({
resolveMilestoneId: vi.fn().mockResolvedValue("resolved-milestone-uuid"),
}));

vi.mock("../../../src/resolvers/cycle-resolver.js", () => ({
resolveCycleId: vi.fn().mockResolvedValue("resolved-cycle-uuid"),
}));

vi.mock("../../../src/resolvers/status-resolver.js", () => ({
resolveStatusId: vi.fn().mockResolvedValue("resolved-status-uuid"),
}));

vi.mock("../../../src/services/issue-service.js", () => ({
createIssue: vi.fn().mockResolvedValue({ id: "new-issue-id" }),
updateIssue: vi.fn().mockResolvedValue({ id: "updated-issue-id" }),
getIssue: vi.fn().mockResolvedValue({
id: "resolved-issue-uuid",
team: { id: "team-uuid", key: "ENG" },
project: { name: "My Project" },
labels: { nodes: [] },
}),
getIssueByIdentifier: vi.fn(),
listIssues: vi.fn().mockResolvedValue([]),
searchIssues: vi.fn().mockResolvedValue([]),
}));

vi.mock("../../../src/services/issue-relation-service.js", () => ({
createIssueRelation: vi.fn(),
deleteIssueRelation: vi.fn(),
findIssueRelation: vi.fn(),
}));

import { setupIssuesCommands } from "../../../src/commands/issues.js";
import { resolveUserId } from "../../../src/resolvers/user-resolver.js";
import {
createIssue,
updateIssue,
} from "../../../src/services/issue-service.js";

function createProgram(): Command {
const program = new Command();
program.option("--api-token <token>");
setupIssuesCommands(program);
return program;
}

describe("issues create --assignee", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
});

it("resolves assignee name to UUID before creating issue", async () => {
const program = createProgram();
await program.parseAsync([
"node",
"test",
"issues",
"create",
"Fix login bug",
"--team",
"ENG",
"--assignee",
"John Doe",
]);

expect(resolveUserId).toHaveBeenCalledWith(expect.anything(), "John Doe");
expect(createIssue).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ assigneeId: "resolved-user-uuid" }),
);
});

it("resolves assignee email to UUID before creating issue", async () => {
const program = createProgram();
await program.parseAsync([
"node",
"test",
"issues",
"create",
"Fix login bug",
"--team",
"ENG",
"--assignee",
"john@example.com",
]);

expect(resolveUserId).toHaveBeenCalledWith(
expect.anything(),
"john@example.com",
);
expect(createIssue).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ assigneeId: "resolved-user-uuid" }),
);
});

it("does not call resolveUserId when --assignee is omitted", async () => {
const program = createProgram();
await program.parseAsync([
"node",
"test",
"issues",
"create",
"Fix login bug",
"--team",
"ENG",
]);

expect(resolveUserId).not.toHaveBeenCalled();
expect(createIssue).toHaveBeenCalledWith(
expect.anything(),
expect.not.objectContaining({ assigneeId: expect.anything() }),
);
});
});

describe("issues update --assignee", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
});

it("resolves assignee name to UUID before updating issue", async () => {
const program = createProgram();
await program.parseAsync([
"node",
"test",
"issues",
"update",
"ENG-42",
"--assignee",
"Jane Smith",
]);

expect(resolveUserId).toHaveBeenCalledWith(expect.anything(), "Jane Smith");
expect(updateIssue).toHaveBeenCalledWith(
expect.anything(),
"resolved-issue-uuid",
expect.objectContaining({ assigneeId: "resolved-user-uuid" }),
);
});

it("does not call resolveUserId when --assignee is omitted", async () => {
const program = createProgram();
await program.parseAsync([
"node",
"test",
"issues",
"update",
"ENG-42",
"--title",
"New title",
]);

expect(resolveUserId).not.toHaveBeenCalled();
});
});
79 changes: 79 additions & 0 deletions tests/unit/resolvers/user-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// tests/unit/resolvers/user-resolver.test.ts
import { describe, expect, it, vi } from "vitest";
import type { LinearSdkClient } from "../../../src/client/linear-client.js";
import { resolveUserId } from "../../../src/resolvers/user-resolver.js";

interface MockUser {
id: string;
name?: string;
email?: string;
}

function mockSdkClient(...callResults: Array<{ nodes: MockUser[] }>) {
const users = vi.fn();
for (const result of callResults) {
users.mockResolvedValueOnce(result);
}
return { sdk: { users } } as unknown as LinearSdkClient;
}

describe("resolveUserId", () => {
it("returns UUID as-is without calling SDK", async () => {
const client = mockSdkClient();
const result = await resolveUserId(
client,
"550e8400-e29b-41d4-a716-446655440000",
);
expect(result).toBe("550e8400-e29b-41d4-a716-446655440000");
expect(client.sdk.users).not.toHaveBeenCalled();
});

it("resolves user by display name", async () => {
const client = mockSdkClient({
nodes: [
{ id: "user-uuid-1", name: "John Doe", email: "john@example.com" },
],
});
const result = await resolveUserId(client, "John Doe");
expect(result).toBe("user-uuid-1");
expect(client.sdk.users).toHaveBeenCalledWith({
filter: { displayName: { eqIgnoreCase: "John Doe" } },
first: 10,
});
});

it("falls back to email when name not found", async () => {
const client = mockSdkClient(
{ nodes: [] },
{
nodes: [{ id: "user-uuid-2", name: "Jane", email: "jane@example.com" }],
},
);
const result = await resolveUserId(client, "jane@example.com");
expect(result).toBe("user-uuid-2");
expect(client.sdk.users).toHaveBeenCalledTimes(2);
expect(client.sdk.users).toHaveBeenNthCalledWith(2, {
filter: { email: { eqIgnoreCase: "jane@example.com" } },
first: 1,
});
});

it("throws when user not found by name or email", async () => {
const client = mockSdkClient({ nodes: [] }, { nodes: [] });
await expect(resolveUserId(client, "Nobody")).rejects.toThrow(
'User "Nobody" not found',
);
});

it("throws when multiple users match by name", async () => {
const client = mockSdkClient({
nodes: [
{ id: "user-1", name: "Alex Smith", email: "alex1@example.com" },
{ id: "user-2", name: "Alex Smith", email: "alex2@example.com" },
],
});
await expect(resolveUserId(client, "Alex Smith")).rejects.toThrow(
'Multiple Users found matching "Alex Smith"',
);
});
});