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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 29 additions & 4 deletions src/commands/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ interface CreateOptions {
description?: string;
assignee?: string;
priority?: string;
estimate?: string;
project?: string;
team?: string;
labels?: string;
Expand All @@ -64,6 +65,8 @@ interface UpdateOptions {
description?: string;
status?: string;
priority?: string;
estimate?: string;
clearEstimate?: boolean;
assignee?: string;
project?: string;
labels?: string;
Expand All @@ -90,10 +93,13 @@ export const ISSUES_META: DomainMeta = {
context: [
"an issue belongs to exactly one team. it has a status (e.g. backlog,",
"todo, in progress, done — configurable per team), a priority (1-4),",
"and can be assigned to a user. issues can have labels, a due date,",
"belong to a project, be part of a cycle (sprint), and reference a",
"project milestone. parent-child relationships and issue relations",
"(blocks, blocked-by, relates-to, duplicate-of) are supported.",
"and can be assigned to a user. issues can have estimates; valid values",
"are integers whose meaning depends on the team's estimation scale",
"(fibonacci, exponential, linear, or t-shirt sizes mapped to integers).",
"issues can have labels, a due date, belong to a project, be part of a",
"cycle (sprint), and reference a project milestone. parent-child",
"relationships and issue relations (blocks, blocked-by, relates-to,",
"duplicate-of) are supported.",
].join("\n"),
arguments: {
issue: "issue identifier (UUID or ABC-123)",
Expand Down Expand Up @@ -246,6 +252,7 @@ export function setupIssuesCommands(program: Command): void {
.option("--project-milestone <ms>", "set milestone (requires --project)")
.option("--cycle <cycle>", "add to cycle (requires --team)")
.option("--status <status>", "set status")
.option("--estimate <n>", "set estimate")
.option("--parent-ticket <issue>", "set parent issue")
.option("--due-date <date>", "due date (YYYY-MM-DD)")
.option("--blocks <issue>", "this issue blocks <issue>")
Expand Down Expand Up @@ -285,6 +292,10 @@ export function setupIssuesCommands(program: Command): void {
input.priority = parseInt(options.priority, 10);
}

if (options.estimate !== undefined) {
input.estimate = parseInt(options.estimate, 10);
}

if (options.project) {
input.projectId = await resolveProjectId(ctx.sdk, options.project);
}
Expand Down Expand Up @@ -366,6 +377,8 @@ export function setupIssuesCommands(program: Command): void {
.option("--clear-project-milestone", "clear project milestone")
.option("--cycle <cycle>", "set cycle")
.option("--clear-cycle", "clear cycle")
.option("--estimate <n>", "new estimate")
.option("--clear-estimate", "clear estimate")
.option("--due-date <date>", "set due date (YYYY-MM-DD)")
.option("--clear-due-date", "clear due date")
.option("--blocks <issue>", "add blocks relation")
Expand All @@ -392,6 +405,12 @@ export function setupIssuesCommands(program: Command): void {
);
}

if (options.estimate !== undefined && options.clearEstimate) {
throw new Error(
"Cannot use --estimate and --clear-estimate together",
);
}

if (options.cycle && options.clearCycle) {
throw new Error("Cannot use --cycle and --clear-cycle together");
}
Expand Down Expand Up @@ -462,6 +481,12 @@ export function setupIssuesCommands(program: Command): void {
input.priority = parseInt(options.priority, 10);
}

if (options.clearEstimate) {
input.estimate = null;
} else if (options.estimate !== undefined) {
input.estimate = parseInt(options.estimate, 10);
}

if (options.assignee) {
input.assigneeId = await resolveUserId(ctx.sdk, options.assignee);
}
Expand Down
145 changes: 145 additions & 0 deletions tests/unit/commands/issues.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,73 @@ describe("issues create --assignee", () => {
});
});

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

it("passes estimate as integer to createIssue", async () => {
const program = createProgram();
await program.parseAsync([
"node",
"test",
"issues",
"create",
"Estimate test",
"--team",
"ENG",
"--estimate",
"5",
]);

expect(createIssue).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ estimate: 5 }),
);
});

it("passes estimate 0 through to createIssue", async () => {
const program = createProgram();
await program.parseAsync([
"node",
"test",
"issues",
"create",
"Zero estimate",
"--team",
"ENG",
"--estimate",
"0",
]);

expect(createIssue).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ estimate: 0 }),
);
});

it("does not set estimate when --estimate is omitted", async () => {
const program = createProgram();
await program.parseAsync([
"node",
"test",
"issues",
"create",
"No estimate",
"--team",
"ENG",
]);

expect(createIssue).toHaveBeenCalledWith(
expect.anything(),
expect.not.objectContaining({ estimate: expect.anything() }),
);
});
});

describe("issues create --due-date", () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -224,6 +291,84 @@ describe("issues create --due-date", () => {
});
});

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

it("passes estimate as integer to updateIssue", async () => {
const program = createProgram();
await program.parseAsync([
"node",
"test",
"issues",
"update",
"ENG-42",
"--estimate",
"8",
]);

expect(updateIssue).toHaveBeenCalledWith(
expect.anything(),
"resolved-issue-uuid",
expect.objectContaining({ estimate: 8 }),
);
});

it("clears estimate with --clear-estimate", async () => {
const program = createProgram();
await program.parseAsync([
"node",
"test",
"issues",
"update",
"ENG-42",
"--clear-estimate",
]);

expect(updateIssue).toHaveBeenCalledWith(
expect.anything(),
"resolved-issue-uuid",
expect.objectContaining({ estimate: null }),
);
});

it("rejects --estimate and --clear-estimate together", async () => {
const program = createProgram();
await program.parseAsync([
"node",
"test",
"issues",
"update",
"ENG-42",
"--estimate",
"5",
"--clear-estimate",
]);

expect(process.exit).toHaveBeenCalledWith(1);
});

it("rejects --estimate 0 and --clear-estimate together", async () => {
const program = createProgram();
await program.parseAsync([
"node",
"test",
"issues",
"update",
"ENG-42",
"--estimate",
"0",
"--clear-estimate",
]);

expect(process.exit).toHaveBeenCalledWith(1);
});
});

describe("issues update --due-date", () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down
77 changes: 77 additions & 0 deletions tests/unit/services/issue-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
import { describe, expect, it, vi } from "vitest";
import type { GraphQLClient } from "../../../src/client/graphql-client.js";
import {
createIssue,
getIssue,
getIssueByIdentifier,
listIssues,
searchIssues,
updateIssue,
} from "../../../src/services/issue-service.js";

function mockGqlClient(response: Record<string, unknown>) {
Expand Down Expand Up @@ -125,6 +127,81 @@ describe("getIssueByIdentifier", () => {
});
});

describe("createIssue", () => {
it("creates issue and returns result", async () => {
const client = mockGqlClient({
issueCreate: {
success: true,
issue: { id: "new-id", identifier: "ENG-1", title: "New", estimate: 5 },
},
});
const result = await createIssue(client, {
title: "New",
teamId: "team-uuid",
estimate: 5,
});
expect(result.id).toBe("new-id");
expect(client.request).toHaveBeenCalledWith(expect.anything(), {
input: { title: "New", teamId: "team-uuid", estimate: 5 },
});
});

it("throws when creation fails", async () => {
const client = mockGqlClient({
issueCreate: { success: false, issue: null },
});
await expect(
createIssue(client, { title: "Fail", teamId: "team-uuid" }),
).rejects.toThrow("Failed to create issue");
});
});

describe("updateIssue", () => {
it("updates issue and returns result", async () => {
const client = mockGqlClient({
issueUpdate: {
success: true,
issue: {
id: "issue-id",
identifier: "ENG-1",
title: "Updated",
estimate: 8,
},
},
});
const result = await updateIssue(client, "issue-id", { estimate: 8 });
expect(result.id).toBe("issue-id");
expect(client.request).toHaveBeenCalledWith(expect.anything(), {
id: "issue-id",
input: { estimate: 8 },
});
});

it("clears estimate with null", async () => {
const client = mockGqlClient({
issueUpdate: {
success: true,
issue: { id: "issue-id", identifier: "ENG-1", title: "Cleared" },
},
});
const result = await updateIssue(client, "issue-id", { estimate: null });
expect(result.id).toBe("issue-id");
expect(client.request).toHaveBeenCalledWith(expect.anything(), {
id: "issue-id",
input: { estimate: null },
});
});

it("throws when update fails", async () => {
const client = mockGqlClient({
issueUpdate: { success: false, issue: null },
});
await expect(
updateIssue(client, "issue-id", { title: "Fail" }),
).rejects.toThrow("Failed to update issue");
});
});

describe("searchIssues", () => {
it("returns search results", async () => {
const client = mockGqlClient({
Expand Down
Loading