Skip to content

refactor(milestones): CQRS consolidation - 9 tools → 2 tools #13

@polaz

Description

@polaz

Summary

Consolidate 9 milestone tools into 2 CQRS-aligned tools for better MCP client compatibility and cleaner read/write separation.

Current State (9 tools)

Tool Type Description
list_milestones READ List milestones with filtering
get_milestone READ Get milestone details
get_milestone_issue READ List issues in milestone
get_milestone_merge_requests READ List MRs in milestone
get_milestone_burndown_events READ Get burndown chart data (Premium)
create_milestone WRITE Create new milestone
edit_milestone WRITE Modify milestone
delete_milestone WRITE Remove milestone
promote_milestone WRITE Promote to group level

Target State (2 tools)

browse_milestones (Query)

Consolidates all READ operations:

{
  action: "list" | "get" | "issues" | "merge_requests" | "burndown",
  
  // Common - scope selection
  scope: "project" | "group",
  projectId?: string,
  groupId?: string,
  
  // For "list" action
  state?: "active" | "closed" | "all",
  title?: string,
  search?: string,
  include_parent_milestones?: boolean,
  include_ancestors?: boolean,
  updated_before?: string,
  updated_after?: string,
  
  // For "get", "issues", "merge_requests", "burndown"
  milestoneId?: number,
  
  // Pagination for list/issues/merge_requests
  per_page?: number,
  page?: number
}

Read-only mode: Always allowed

Tier requirements:

  • burndown action requires Premium tier

manage_milestone (Command)

Consolidates all WRITE operations:

{
  action: "create" | "update" | "delete" | "promote",
  
  // Common - scope selection
  scope: "project" | "group",
  projectId?: string,
  groupId?: string,
  
  // For "update", "delete", "promote"
  milestoneId?: number,
  
  // For "create" and "update"
  title?: string,
  description?: string,
  start_date?: string,        // YYYY-MM-DD
  due_date?: string,          // YYYY-MM-DD
  state_event?: "close" | "activate"
}

Read-only mode: Blocked entirely

Implementation Tasks

  • Create new browse_milestones handler with action dispatch
  • Create new manage_milestone handler with action dispatch
  • Update Zod schemas with flat object pattern (see Schema Pattern below)
  • Handle both project and group milestone APIs
  • Gate burndown action on Premium tier
  • Update registry to export new tool names
  • Update read-only tools list (only browse_milestones)
  • Remove old handlers after migration
  • Update unit tests
  • Update integration tests

Schema Pattern (AI-Compatible)

CRITICAL: Per #29, do NOT use z.discriminatedUnion() - it generates oneOf at JSON Schema root level which is incompatible with Claude API.

Use flat z.object() with .refine() for conditional validation:

// browse_milestones - READ operations
const BrowseMilestonesSchema = z.object({
  action: z.enum(["list", "get", "issues", "merge_requests", "burndown"]),
  scope: z.enum(["project", "group"]),
  projectId: z.string().optional(),
  groupId: z.string().optional(),
  // list-specific
  state: z.enum(["active", "closed", "all"]).optional(),
  title: z.string().optional(),
  search: z.string().optional(),
  include_parent_milestones: z.boolean().optional(),
  include_ancestors: z.boolean().optional(),
  updated_before: z.string().optional(),
  updated_after: z.string().optional(),
  // get/issues/merge_requests/burndown specific
  milestoneId: z.number().optional(),
  // pagination
  per_page: z.number().optional(),
  page: z.number().optional(),
}).refine(
  (data) => data.scope !== "project" || data.projectId !== undefined,
  { message: "projectId is required when scope is 'project'", path: ["projectId"] }
).refine(
  (data) => data.scope !== "group" || data.groupId !== undefined,
  { message: "groupId is required when scope is 'group'", path: ["groupId"] }
).refine(
  (data) => data.action === "list" || data.milestoneId !== undefined,
  { message: "milestoneId is required for get/issues/merge_requests/burndown actions", path: ["milestoneId"] }
);

// manage_milestone - WRITE operations
const ManageMilestoneSchema = z.object({
  action: z.enum(["create", "update", "delete", "promote"]),
  scope: z.enum(["project", "group"]),
  projectId: z.string().optional(),
  groupId: z.string().optional(),
  milestoneId: z.number().optional(),
  title: z.string().optional(),
  description: z.string().optional(),
  start_date: z.string().optional(),
  due_date: z.string().optional(),
  state_event: z.enum(["close", "activate"]).optional(),
}).refine(
  (data) => data.scope !== "project" || data.projectId !== undefined,
  { message: "projectId is required when scope is 'project'", path: ["projectId"] }
).refine(
  (data) => data.scope !== "group" || data.groupId !== undefined,
  { message: "groupId is required when scope is 'group'", path: ["groupId"] }
).refine(
  (data) => data.action === "create" || data.milestoneId !== undefined,
  { message: "milestoneId is required for update/delete/promote actions", path: ["milestoneId"] }
).refine(
  (data) => data.action !== "create" || data.title !== undefined,
  { message: "title is required for 'create' action", path: ["title"] }
);

Breaking Changes

Old Tool Migration Path
list_milestones browse_milestones with action: "list"
get_milestone browse_milestones with action: "get"
get_milestone_issue browse_milestones with action: "issues"
get_milestone_merge_requests browse_milestones with action: "merge_requests"
get_milestone_burndown_events browse_milestones with action: "burndown"
create_milestone manage_milestone with action: "create"
edit_milestone manage_milestone with action: "update"
delete_milestone manage_milestone with action: "delete"
promote_milestone manage_milestone with action: "promote"

Feature Flag

Existing USE_MILESTONE flag continues to control entire milestones entity visibility.

Testing Requirements

  • Unit tests for flat schema validation with refine
  • Test project-level milestones
  • Test group-level milestones
  • Test issues/MRs listing in milestone
  • Test burndown (Premium tier only)
  • Test promote operation
  • Test read-only mode blocks manage_milestone entirely
  • Integration tests for all operations
  • Verify JSON Schema output has NO oneOf/allOf/anyOf at top level

Acceptance Criteria

  • Total tools reduced from 9 to 2
  • All existing functionality preserved
  • Both project and group milestones supported
  • Burndown requires Premium tier
  • Promote operation works correctly
  • Read-only mode correctly gates write operations
  • Clear error messages for invalid action/parameter combinations
  • JSON Schema compatible with Claude API (no oneOf/allOf/anyOf at root)

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    refactorCode restructuring without behavior change

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions