diff --git a/package-lock.json b/package-lock.json index 4d75da7..97327e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linearis", - "version": "2026.4.1", + "version": "2026.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linearis", - "version": "2026.4.1", + "version": "2026.4.2", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/src/commands/issues.ts b/src/commands/issues.ts index d1bac92..baccec4 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -45,6 +45,7 @@ interface CreateOptions { description?: string; assignee?: string; priority?: string; + estimate?: string; project?: string; team?: string; labels?: string; @@ -64,6 +65,8 @@ interface UpdateOptions { description?: string; status?: string; priority?: string; + estimate?: string; + clearEstimate?: boolean; assignee?: string; project?: string; labels?: string; @@ -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)", @@ -246,6 +252,7 @@ export function setupIssuesCommands(program: Command): void { .option("--project-milestone ", "set milestone (requires --project)") .option("--cycle ", "add to cycle (requires --team)") .option("--status ", "set status") + .option("--estimate ", "set estimate") .option("--parent-ticket ", "set parent issue") .option("--due-date ", "due date (YYYY-MM-DD)") .option("--blocks ", "this issue blocks ") @@ -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); } @@ -366,6 +377,8 @@ export function setupIssuesCommands(program: Command): void { .option("--clear-project-milestone", "clear project milestone") .option("--cycle ", "set cycle") .option("--clear-cycle", "clear cycle") + .option("--estimate ", "new estimate") + .option("--clear-estimate", "clear estimate") .option("--due-date ", "set due date (YYYY-MM-DD)") .option("--clear-due-date", "clear due date") .option("--blocks ", "add blocks relation") @@ -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"); } @@ -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); } diff --git a/tests/unit/commands/issues.test.ts b/tests/unit/commands/issues.test.ts index 91de5ed..8b5d0ef 100644 --- a/tests/unit/commands/issues.test.ts +++ b/tests/unit/commands/issues.test.ts @@ -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(); @@ -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(); diff --git a/tests/unit/services/issue-service.test.ts b/tests/unit/services/issue-service.test.ts index b5724f0..e169703 100644 --- a/tests/unit/services/issue-service.test.ts +++ b/tests/unit/services/issue-service.test.ts @@ -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) { @@ -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({