From 809c217904dab64acdc786c1e1ec9e885d367478 Mon Sep 17 00:00:00 2001 From: Hugh Wimberly Date: Wed, 8 Apr 2026 00:04:58 -0700 Subject: [PATCH 1/2] feat(issues): add --estimate and --clear-estimate to issues create/update Wire up estimate support in the command layer. The GraphQL fragments already include the estimate field, so `issues read` returns it without changes. This adds: - `--estimate ` on `issues create` to set story points - `--estimate ` on `issues update` to change story points - `--clear-estimate` on `issues update` to remove the estimate - Mutual exclusion validation for --estimate + --clear-estimate Includes command-layer and service-layer tests for all estimate paths, plus previously missing createIssue/updateIssue service tests. Refs #26 Supersedes #41 --- src/commands/issues.ts | 22 +++++ tests/unit/commands/issues.test.ts | 109 ++++++++++++++++++++++ tests/unit/services/issue-service.test.ts | 77 +++++++++++++++ 3 files changed, 208 insertions(+) diff --git a/src/commands/issues.ts b/src/commands/issues.ts index dadab50..667b3e2 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -41,6 +41,7 @@ interface CreateOptions { description?: string; assignee?: string; priority?: string; + estimate?: string; project?: string; team?: string; labels?: string; @@ -59,6 +60,8 @@ interface UpdateOptions { description?: string; status?: string; priority?: string; + estimate?: string; + clearEstimate?: boolean; assignee?: string; project?: string; labels?: string; @@ -239,6 +242,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 ", "story points estimate") .option("--parent-ticket ", "set parent issue") .option("--blocks ", "this issue blocks ") .option("--blocked-by ", "this issue is blocked by ") @@ -277,6 +281,10 @@ export function setupIssuesCommands(program: Command): void { input.priority = parseInt(options.priority, 10); } + if (options.estimate) { + input.estimate = parseInt(options.estimate, 10); + } + if (options.project) { input.projectId = await resolveProjectId(ctx.sdk, options.project); } @@ -354,6 +362,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 (story points)") + .option("--clear-estimate", "clear estimate") .option("--blocks ", "add blocks relation") .option("--blocked-by ", "add blocked-by relation") .option("--relates-to ", "add relates-to relation") @@ -378,6 +388,12 @@ export function setupIssuesCommands(program: Command): void { ); } + if (options.estimate && 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"); } @@ -442,6 +458,12 @@ export function setupIssuesCommands(program: Command): void { input.priority = parseInt(options.priority, 10); } + if (options.clearEstimate) { + input.estimate = null; + } else if (options.estimate) { + 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 9085056..4bff4f7 100644 --- a/tests/unit/commands/issues.test.ts +++ b/tests/unit/commands/issues.test.ts @@ -158,6 +158,115 @@ 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("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 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); + }); +}); + describe("issues update --assignee", () => { 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({ From 9312e69eb7837fc9440faa87e7c19c46f0685517 Mon Sep 17 00:00:00 2001 From: Hugh Wimberly Date: Wed, 8 Apr 2026 09:49:00 -0700 Subject: [PATCH 2/2] fix(issues): handle --estimate 0 and use scale-neutral descriptions Address review feedback: - Use `options.estimate !== undefined` instead of truthiness checks so that `--estimate 0` is not silently dropped (Linear scales include 0) - Change option descriptions from "story points" to scale-neutral wording, since teams may use fibonacci, exponential, linear, or t-shirt sizing - Add estimate context to ISSUES_META so agents understand the field - Add tests for --estimate 0 and --estimate 0 --clear-estimate --- src/commands/issues.ts | 20 +++++++++-------- tests/unit/commands/issues.test.ts | 36 ++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 667b3e2..d72e7c0 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -86,10 +86,12 @@ 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, 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, 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)", @@ -242,7 +244,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 ", "story points estimate") + .option("--estimate ", "set estimate") .option("--parent-ticket ", "set parent issue") .option("--blocks ", "this issue blocks ") .option("--blocked-by ", "this issue is blocked by ") @@ -281,7 +283,7 @@ export function setupIssuesCommands(program: Command): void { input.priority = parseInt(options.priority, 10); } - if (options.estimate) { + if (options.estimate !== undefined) { input.estimate = parseInt(options.estimate, 10); } @@ -362,7 +364,7 @@ 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 (story points)") + .option("--estimate ", "new estimate") .option("--clear-estimate", "clear estimate") .option("--blocks ", "add blocks relation") .option("--blocked-by ", "add blocked-by relation") @@ -388,7 +390,7 @@ export function setupIssuesCommands(program: Command): void { ); } - if (options.estimate && options.clearEstimate) { + if (options.estimate !== undefined && options.clearEstimate) { throw new Error( "Cannot use --estimate and --clear-estimate together", ); @@ -460,7 +462,7 @@ export function setupIssuesCommands(program: Command): void { if (options.clearEstimate) { input.estimate = null; - } else if (options.estimate) { + } else if (options.estimate !== undefined) { input.estimate = parseInt(options.estimate, 10); } diff --git a/tests/unit/commands/issues.test.ts b/tests/unit/commands/issues.test.ts index 4bff4f7..cf4e28c 100644 --- a/tests/unit/commands/issues.test.ts +++ b/tests/unit/commands/issues.test.ts @@ -186,6 +186,26 @@ describe("issues create --estimate", () => { ); }); + 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([ @@ -265,6 +285,22 @@ describe("issues update --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 --assignee", () => {