diff --git a/CHANGELOG.md b/CHANGELOG.md index e9be527..9e1187a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `milestones list|get|create|update|delete` command group for managing Linear project milestones. +- `--milestone` / `-m` on `issues list`, `issues create`, and `issues update` to filter or assign issues by project milestone. +- Issue text and JSON output now includes milestone information when available. + ## [1.9.0] - 2026-05-21 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 81bd4c7..751d995 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ team: CEN project: my-project # optional — used when --project flag is omitted ``` -When set, commands with `--project` (`issues list`, `issues create`, `issues update`, `search`, `deps`) will use this default. Explicit `--project` flags always override it. +When set, commands with `--project` (`issues list`, `issues create`, `issues update`, `milestones`, `search`, `deps`) will use this default. Explicit `--project` flags always override it. ### Authentication Modes @@ -109,6 +109,7 @@ linear issues get CEN-123 --format minimal --output json - `issues list`, `issues get` - `cycles list`, `cycles get`, `cycles analyze` - `projects list`, `projects get` +- `milestones list`, `milestones get`, `milestones create`, `milestones update` - `teams list`, `teams get`, `teams labels`, `teams states` - `users list`, `users get`, `users me` - `search` (all search operations) @@ -131,6 +132,9 @@ linear issues list --priority 1 --format full # Get issues in specific cycle linear issues list --cycle 65 --format full +# Get issues in a project milestone +linear issues list --project "Q3 Launch" --milestone Beta --format full + # Filter by assignee linear issues list --assignee me --format full @@ -166,9 +170,24 @@ linear issues create "Implement feature" \ --assignee me \ --estimate 5 \ --cycle 65 \ + --project "Q3 Launch" \ + --milestone Beta \ --labels "backend,security" ``` +#### Milestone Operations +```bash +# List milestones for a project +linear milestones list --project "Q3 Launch" + +# Create and update milestones +linear milestones create Beta --project "Q3 Launch" --target-date 2026-08-01 +linear milestones update Beta --project "Q3 Launch" --name "Private beta" + +# Assign an issue to a milestone +linear issues update CEN-123 --project "Q3 Launch" --milestone Beta +``` + #### Working with Team Context - Cycle numbers (65, 66) require team context from `linear init` - Issue identifiers (CEN-123) work without team context diff --git a/README.md b/README.md index 081cc91..0425576 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ linear auth login - [Search](#search) - [Dependencies](#dependencies) - [Projects](#projects) + - [Milestones](#milestones) - [Cycles](#cycles) - [Teams](#teams) - [Labels](#labels) @@ -474,6 +475,9 @@ linear issues list --project "Q1 Release" # Filter by project name or UUID linear search "auth" --project "Q1 Release" # Works with search too linear deps --team ENG --project "Q1 Release" # Works with deps too +# Filter by project milestone +linear issues list --project "Q1 Release" --milestone "Beta" + # Filter by state (comma-separated) linear issues list --state "Backlog,Todo,In Progress" --team ENG @@ -504,6 +508,7 @@ linear issues create "Implement OAuth2 login" \ --assignee me \ --estimate 5 \ --cycle current \ + --milestone "Beta" \ --labels "backend,security" \ --due 2026-03-31 @@ -520,6 +525,7 @@ linear issues update ENG-123 \ --state Done \ --assignee alice \ --priority 1 \ + --milestone "Beta" \ --labels "urgent,hotfix" \ --due 2026-02-15 @@ -642,6 +648,18 @@ linear projects create "Q1 Release" --team ENG linear projects update PROJECT-ID --state completed ``` +### Milestones + +Project milestones group issues inside a Linear project: + +```bash +linear milestones list --project "Q1 Release" +linear milestones get "Beta" --project "Q1 Release" +linear milestones create "Beta" --project "Q1 Release" --target-date 2026-08-01 +linear milestones update "Beta" --project "Q1 Release" --name "Private beta" +linear milestones delete MILESTONE-ID --project "Q1 Release" +``` + ### Cycles ```bash diff --git a/internal/cli/dependencies.go b/internal/cli/dependencies.go index 32b7bba..8351ab5 100644 --- a/internal/cli/dependencies.go +++ b/internal/cli/dependencies.go @@ -1,8 +1,8 @@ package cli import ( - "github.com/joa23/linear-cli/pkg/linear" "github.com/joa23/linear-cli/internal/service" + "github.com/joa23/linear-cli/pkg/linear" ) // Dependencies holds all injectable dependencies for CLI commands @@ -15,6 +15,7 @@ type Dependencies struct { Issues service.IssueServiceInterface Cycles service.CycleServiceInterface Projects service.ProjectServiceInterface + Milestones service.MilestoneServiceInterface Search service.SearchServiceInterface Teams service.TeamServiceInterface Users service.UserServiceInterface @@ -33,6 +34,7 @@ func NewDependencies(client *linear.Client) *Dependencies { Issues: services.Issues, Cycles: services.Cycles, Projects: services.Projects, + Milestones: services.Milestones, Search: services.Search, Teams: services.Teams, Users: services.Users, diff --git a/internal/cli/issues.go b/internal/cli/issues.go index c201b1b..51e1a1c 100644 --- a/internal/cli/issues.go +++ b/internal/cli/issues.go @@ -46,6 +46,7 @@ func newIssuesListCmd() *cobra.Command { priority string assignee string cycle string + milestone string labels string excludeLabels string sortBy string @@ -86,7 +87,7 @@ TIP: Use --format full for detailed output, --format minimal for concise output. --priority 1 \ --assignee johannes.zillmann@centrum-ai.com \ --cycle 65 \ - --project "My Project" \ + --milestone "Beta" \ --labels "customer,bug" \ --limit 50 \ --format full @@ -163,6 +164,9 @@ TIP: Use --format full for detailed output, --format minimal for concise output. if cycle != "" { filters.CycleID = cycle } + if milestone != "" { + filters.MilestoneID = milestone + } if project != "" { filters.ProjectID = project } @@ -205,6 +209,7 @@ TIP: Use --format full for detailed output, --format minimal for concise output. cmd.Flags().StringVar(&priority, "priority", "", "Filter by priority: 0-4 or none/urgent/high/normal/low") cmd.Flags().StringVarP(&assignee, "assignee", "a", "", "Filter by assignee (email or 'me')") cmd.Flags().StringVarP(&cycle, "cycle", "c", "", "Filter by cycle (number, 'current', or 'next')") + cmd.Flags().StringVarP(&milestone, "milestone", "m", "", "Filter by project milestone (name or UUID)") cmd.Flags().StringVarP(&labels, "labels", "l", "", "Filter by labels (comma-separated)") cmd.Flags().StringVarP(&excludeLabels, "exclude-labels", "L", "", "Exclude issues with these labels (comma-separated)") cmd.Flags().StringVarP(&sortBy, "sort", "s", "", "Sort by: created, updated") @@ -289,6 +294,7 @@ func newIssuesCreateCmd() *cobra.Command { labels string cycle string project string + milestone string assignee string dueDate string parent string @@ -322,6 +328,7 @@ TIP: Run 'linear init' first to set default team.`, --assignee stefan@centrum-ai.com \ --estimate 5 \ --cycle 65 \ + --milestone "Beta" \ --labels "backend,security" \ --blocked-by CEN-99 \ --depends-on CEN-98,CEN-97 \ @@ -414,6 +421,9 @@ TIP: Run 'linear init' first to set default team.`, if project != "" { input.ProjectID = project } + if milestone != "" { + input.ProjectMilestoneID = milestone + } if assignee != "" { input.AssigneeID = assignee } @@ -449,6 +459,7 @@ TIP: Run 'linear init' first to set default team.`, cmd.Flags().StringVarP(&labels, "labels", "l", "", "Comma-separated label names/IDs") cmd.Flags().StringVarP(&cycle, "cycle", "c", "", "Cycle number or name (e.g., 'current', 'next')") cmd.Flags().StringVarP(&project, "project", "P", "", ProjectFlagDescription) + cmd.Flags().StringVarP(&milestone, "milestone", "m", "", "Project milestone name or UUID") cmd.Flags().StringVarP(&assignee, "assignee", "a", "", "Assignee name or email (use 'me' for yourself)") cmd.Flags().StringVar(&dueDate, "due", "", "Due date YYYY-MM-DD") cmd.Flags().StringVar(&parent, "parent", "", "Parent issue ID (for sub-issues)") @@ -472,6 +483,7 @@ func newIssuesUpdateCmd() *cobra.Command { removeLabels string cycle string project string + milestone string assignee string dueDate string parent string @@ -523,7 +535,7 @@ LABEL MODES: return err } -// Get team from flag or config (for cycle resolution) + // Get team from flag or config (for cycle resolution) if team == "" { team = GetDefaultTeam() } @@ -534,6 +546,7 @@ LABEL MODES: priority != "" || estimate != "" || labels != "" || addLabels != "" || removeLabels != "" || cycle != "" || project != "" || assignee != "" || + milestone != "" || dueDate != "" || parent != "" || dependsOn != "" || blockedBy != "" || len(attachFiles) > 0 @@ -604,6 +617,9 @@ LABEL MODES: if project != "" { input.ProjectID = &project } + if milestone != "" { + input.ProjectMilestoneID = &milestone + } if assignee != "" { input.AssigneeID = &assignee } @@ -644,6 +660,7 @@ LABEL MODES: cmd.Flags().StringVar(&removeLabels, "remove-labels", "", "Remove specific labels without affecting others (comma-separated)") cmd.Flags().StringVarP(&cycle, "cycle", "c", "", "Update cycle number or name") cmd.Flags().StringVarP(&project, "project", "P", "", ProjectFlagDescription) + cmd.Flags().StringVarP(&milestone, "milestone", "m", "", "Update project milestone name or UUID") cmd.Flags().StringVarP(&assignee, "assignee", "a", "", "Update assignee name or email (use 'me' for yourself)") cmd.Flags().StringVar(&dueDate, "due", "", "Update due date YYYY-MM-DD") cmd.Flags().StringVar(&parent, "parent", "", "Update parent issue") diff --git a/internal/cli/milestones.go b/internal/cli/milestones.go new file mode 100644 index 0000000..17da35a --- /dev/null +++ b/internal/cli/milestones.go @@ -0,0 +1,339 @@ +package cli + +import ( + "fmt" + "time" + + "github.com/joa23/linear-cli/internal/format" + "github.com/joa23/linear-cli/internal/service" + "github.com/spf13/cobra" +) + +func newMilestonesCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "milestones", + Aliases: []string{"milestone", "m"}, + Short: "Manage Linear project milestones", + Long: "List, view, create, update, and delete Linear project milestones.", + } + + cmd.AddCommand( + newMilestonesListCmd(), + newMilestonesGetCmd(), + newMilestonesCreateCmd(), + newMilestonesUpdateCmd(), + newMilestonesDeleteCmd(), + ) + + return cmd +} + +func newMilestonesListCmd() *cobra.Command { + var project, teamID, formatStr, outputType string + var limit int + + cmd := &cobra.Command{ + Use: "list", + Short: "List milestones for a project", + Example: ` # List milestones for the default project + linear milestones list + + # List milestones for a specific project + linear milestones list --project "Q3 Launch" + + # Output as JSON + linear milestones list --project "Q3 Launch" --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + deps, err := getDeps(cmd) + if err != nil { + return err + } + + if project == "" { + project = GetDefaultProject() + } + if teamID == "" { + teamID = GetDefaultTeam() + } + + limit, err := validateAndNormalizeLimit(limit) + if err != nil { + return err + } + verbosity, err := format.ParseVerbosity(formatStr) + if err != nil { + return err + } + output, err := format.ParseOutputType(outputType) + if err != nil { + return err + } + + result, err := deps.Milestones.List(&service.MilestoneListInput{ + ProjectID: project, + TeamID: teamID, + Limit: limit, + }, verbosity, output) + if err != nil { + return fmt.Errorf("failed to list milestones: %w", err) + } + + fmt.Println(result) + return nil + }, + } + + cmd.Flags().StringVarP(&project, "project", "P", "", ProjectFlagDescription) + cmd.Flags().StringVarP(&teamID, "team", "t", "", TeamFlagDescription) + cmd.Flags().IntVarP(&limit, "limit", "n", 50, "Number of milestones to return") + cmd.Flags().StringVarP(&formatStr, "format", "f", "compact", "Verbosity: minimal|compact|detailed|full") + cmd.Flags().StringVarP(&outputType, "output", "o", "text", "Output: text|json") + + return cmd +} + +func newMilestonesGetCmd() *cobra.Command { + var project, teamID, formatStr, outputType string + + cmd := &cobra.Command{ + Use: "get ", + Short: "Get milestone details", + Example: ` # Get by UUID + linear milestones get + + # Get by name within a project + linear milestones get "Beta" --project "Q3 Launch"`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + deps, err := getDeps(cmd) + if err != nil { + return err + } + if project == "" { + project = GetDefaultProject() + } + if teamID == "" { + teamID = GetDefaultTeam() + } + + verbosity, err := format.ParseVerbosity(formatStr) + if err != nil { + return err + } + output, err := format.ParseOutputType(outputType) + if err != nil { + return err + } + + result, err := deps.Milestones.Get(args[0], project, teamID, verbosity, output) + if err != nil { + return fmt.Errorf("failed to get milestone: %w", err) + } + + fmt.Println(result) + return nil + }, + } + + cmd.Flags().StringVarP(&project, "project", "P", "", ProjectFlagDescription) + cmd.Flags().StringVarP(&teamID, "team", "t", "", TeamFlagDescription) + cmd.Flags().StringVarP(&formatStr, "format", "f", "full", "Verbosity: minimal|compact|detailed|full") + cmd.Flags().StringVarP(&outputType, "output", "o", "text", "Output: text|json") + + return cmd +} + +func newMilestonesCreateCmd() *cobra.Command { + var project, teamID, description, targetDate, formatStr, outputType string + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a milestone", + Example: ` # Create milestone in default project + linear milestones create "Beta" + + # Create with target date and description + linear milestones create "Launch" --project "Q3 Launch" --target-date 2026-08-01 --description "Public launch"`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + deps, err := getDeps(cmd) + if err != nil { + return err + } + if project == "" { + project = GetDefaultProject() + } + if teamID == "" { + teamID = GetDefaultTeam() + } + + desc, err := getDescriptionFromFlagOrStdin(description) + if err != nil { + return fmt.Errorf("failed to read description: %w", err) + } + if err := validateDateOnly(targetDate, "--target-date"); err != nil { + return err + } + + verbosity, err := format.ParseVerbosity(formatStr) + if err != nil { + return err + } + output, err := format.ParseOutputType(outputType) + if err != nil { + return err + } + + result, err := deps.Milestones.Create(&service.CreateMilestoneInput{ + Name: args[0], + Description: desc, + ProjectID: project, + TeamID: teamID, + TargetDate: targetDate, + }, verbosity, output) + if err != nil { + return fmt.Errorf("failed to create milestone: %w", err) + } + + fmt.Println(result) + return nil + }, + } + + cmd.Flags().StringVarP(&project, "project", "P", "", ProjectFlagDescription) + cmd.Flags().StringVarP(&teamID, "team", "t", "", TeamFlagDescription) + cmd.Flags().StringVarP(&description, "description", "d", "", "Milestone description (or - for stdin)") + cmd.Flags().StringVar(&targetDate, "target-date", "", "Target date YYYY-MM-DD") + cmd.Flags().StringVarP(&formatStr, "format", "f", "full", "Verbosity: minimal|compact|detailed|full") + cmd.Flags().StringVarP(&outputType, "output", "o", "text", "Output: text|json") + + return cmd +} + +func newMilestonesUpdateCmd() *cobra.Command { + var name, project, teamID, description, targetDate, formatStr, outputType string + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a milestone", + Example: ` # Rename milestone + linear milestones update "Beta" --project "Q3 Launch" --name "Private beta" + + # Update target date + linear milestones update --target-date 2026-08-15`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + deps, err := getDeps(cmd) + if err != nil { + return err + } + if project == "" { + project = GetDefaultProject() + } + if teamID == "" { + teamID = GetDefaultTeam() + } + + hasFlags := name != "" || description != "" || targetDate != "" + if !hasFlags { + return fmt.Errorf("no updates specified. Use flags like --name, --description, --target-date") + } + + desc, err := getDescriptionFromFlagOrStdin(description) + if err != nil { + return fmt.Errorf("failed to read description: %w", err) + } + if err := validateDateOnly(targetDate, "--target-date"); err != nil { + return err + } + + verbosity, err := format.ParseVerbosity(formatStr) + if err != nil { + return err + } + output, err := format.ParseOutputType(outputType) + if err != nil { + return err + } + + input := &service.UpdateMilestoneInput{TeamID: teamID, LookupProjectID: project} + if name != "" { + input.Name = &name + } + if desc != "" { + input.Description = &desc + } + if targetDate != "" { + input.TargetDate = &targetDate + } + + result, err := deps.Milestones.Update(args[0], input, verbosity, output) + if err != nil { + return fmt.Errorf("failed to update milestone: %w", err) + } + + fmt.Println(result) + return nil + }, + } + + cmd.Flags().StringVarP(&name, "name", "n", "", "Update milestone name") + cmd.Flags().StringVarP(&project, "project", "P", "", ProjectFlagDescription) + cmd.Flags().StringVarP(&teamID, "team", "t", "", TeamFlagDescription) + cmd.Flags().StringVarP(&description, "description", "d", "", "Update description (or - for stdin)") + cmd.Flags().StringVar(&targetDate, "target-date", "", "Update target date YYYY-MM-DD") + cmd.Flags().StringVarP(&formatStr, "format", "f", "full", "Verbosity: minimal|compact|detailed|full") + cmd.Flags().StringVarP(&outputType, "output", "o", "text", "Output: text|json") + + return cmd +} + +func newMilestonesDeleteCmd() *cobra.Command { + var project, teamID string + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a milestone", + Example: ` # Delete by UUID + linear milestones delete + + # Delete by name within a project + linear milestones delete "Beta" --project "Q3 Launch"`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + deps, err := getDeps(cmd) + if err != nil { + return err + } + if project == "" { + project = GetDefaultProject() + } + if teamID == "" { + teamID = GetDefaultTeam() + } + + result, err := deps.Milestones.Delete(args[0], project, teamID) + if err != nil { + return fmt.Errorf("failed to delete milestone: %w", err) + } + + fmt.Println(result) + return nil + }, + } + + cmd.Flags().StringVarP(&project, "project", "P", "", ProjectFlagDescription) + cmd.Flags().StringVarP(&teamID, "team", "t", "", TeamFlagDescription) + + return cmd +} + +func validateDateOnly(value string, flagName string) error { + if value == "" { + return nil + } + if _, err := time.Parse("2006-01-02", value); err != nil { + return fmt.Errorf("invalid %s %q: expected YYYY-MM-DD", flagName, value) + } + return nil +} diff --git a/internal/cli/root.go b/internal/cli/root.go index f93256b..8fa56d2 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -81,7 +81,8 @@ Issues (alias: i): Issue flags: -t team, -d description, -s state, -p priority (0-4), -e estimate, -l labels, -c cycle, -P project, -a assignee, - --parent, --blocked-by, --depends-on, --attach, --due, --title + -m milestone, --parent, --blocked-by, --depends-on, + --attach, --due, --title Comment/Reply flags: -b body, --attach (inline image embed) Projects (alias: p): @@ -92,6 +93,13 @@ Projects (alias: p): Project flags: -t team, -d description, -s state, -l lead, -n name +Milestones (alias: m): + m list --project List project milestones + m get Get milestone details + m create [flags] Create milestone + m update [flags] Update milestone + m delete Delete milestone + Cycles (alias: c): c list [--team ] List cycles c get Get cycle details @@ -161,6 +169,7 @@ Configuration: // Resources newIssuesCmd(), newProjectsCmd(), + newMilestonesCmd(), newCyclesCmd(), newTeamsCmd(), newUsersCmd(), diff --git a/internal/format/format.go b/internal/format/format.go index 7eaa602..c229849 100644 --- a/internal/format/format.go +++ b/internal/format/format.go @@ -46,7 +46,7 @@ type Pagination struct { TotalCount int // Total items HasNextPage bool // More results exist // Deprecated: Use offset-based pagination instead - EndCursor string // Cursor for cursor-based pagination + EndCursor string // Cursor for cursor-based pagination } // Formatter formats Linear resources as ASCII text or JSON @@ -99,6 +99,18 @@ func (f *Formatter) RenderProjectList(projects []core.Project, verbosity Verbosi return renderer.RenderProjectList(projects, verbosity, page) } +// RenderMilestone renders a single project milestone with the specified verbosity and output type. +func (f *Formatter) RenderMilestone(milestone *core.ProjectMilestone, verbosity Verbosity, outputType OutputType) string { + renderer := f.factory.GetRenderer(outputType) + return renderer.RenderMilestone(milestone, verbosity) +} + +// RenderMilestoneList renders project milestones with the specified verbosity and output type. +func (f *Formatter) RenderMilestoneList(milestones []core.ProjectMilestone, verbosity Verbosity, outputType OutputType) string { + renderer := f.factory.GetRenderer(outputType) + return renderer.RenderMilestoneList(milestones, verbosity) +} + // RenderTeam renders a single team with the specified verbosity and output type func (f *Formatter) RenderTeam(team *core.Team, verbosity Verbosity, outputType OutputType) string { renderer := f.factory.GetRenderer(outputType) diff --git a/internal/format/format_test.go b/internal/format/format_test.go index 39bfc7f..9e78a86 100644 --- a/internal/format/format_test.go +++ b/internal/format/format_test.go @@ -493,6 +493,102 @@ func TestFormatter_CycleList(t *testing.T) { }) } +func TestFormatter_Milestone(t *testing.T) { + f := New() + + milestone := &core.ProjectMilestone{ + ID: "milestone-1", + Name: "Beta", + Description: "Public beta launch", + TargetDate: "2026-08-01", + Status: "next", + Progress: 56.25, + Project: &core.Project{ID: "project-1", Name: "Q3 Launch"}, + } + + t.Run("nil milestone returns empty string", func(t *testing.T) { + result := f.RenderMilestone(nil, VerbosityCompact, OutputText) + if result != "" { + t.Error("nil milestone should return empty string") + } + }) + + t.Run("text compact contains name status progress and project", func(t *testing.T) { + result := f.RenderMilestone(milestone, VerbosityCompact, OutputText) + if !strings.Contains(result, "Beta") { + t.Error("should contain milestone name") + } + if !strings.Contains(result, "next") { + t.Error("should contain status") + } + if !strings.Contains(result, "56%") { + t.Error("should render progress as a percentage without double-scaling") + } + if !strings.Contains(result, "Q3 Launch") { + t.Error("should contain project name") + } + }) + + t.Run("text full contains description", func(t *testing.T) { + result := f.RenderMilestone(milestone, VerbosityFull, OutputText) + if !strings.Contains(result, "Public beta launch") { + t.Error("should contain description") + } + if !strings.Contains(result, "Progress: 56.") { + t.Error("should render progress with one decimal") + } + if strings.Contains(result, "5625") { + t.Error("progress should not be multiplied by 100 a second time") + } + }) + + t.Run("JSON contains milestone fields", func(t *testing.T) { + result := f.RenderMilestone(milestone, VerbosityFull, OutputJSON) + if !strings.Contains(result, `"name": "Beta"`) { + t.Error("should contain name field") + } + if !strings.Contains(result, `"status": "next"`) { + t.Error("should contain status field") + } + if !strings.Contains(result, `"progress": 56.25`) { + t.Error("should contain raw progress value") + } + }) +} + +func TestFormatter_MilestoneList(t *testing.T) { + f := New() + + milestones := []core.ProjectMilestone{ + {Name: "Alpha", Status: "done", Progress: 100}, + {Name: "Beta", Status: "next", Progress: 0}, + } + + t.Run("list with milestones", func(t *testing.T) { + result := f.RenderMilestoneList(milestones, VerbosityCompact, OutputText) + if !strings.Contains(result, "MILESTONES (2)") { + t.Error("should contain count header") + } + if !strings.Contains(result, "Alpha") { + t.Error("should contain first milestone") + } + }) + + t.Run("empty list text", func(t *testing.T) { + result := f.RenderMilestoneList([]core.ProjectMilestone{}, VerbosityCompact, OutputText) + if result != "No milestones found." { + t.Errorf("expected 'No milestones found.', got '%s'", result) + } + }) + + t.Run("empty list JSON", func(t *testing.T) { + result := f.RenderMilestoneList([]core.ProjectMilestone{}, VerbosityCompact, OutputJSON) + if result != "[]" { + t.Errorf("expected '[]', got '%s'", result) + } + }) +} + func TestFormatter_ProjectList(t *testing.T) { f := New() diff --git a/internal/format/issue.go b/internal/format/issue.go index 3cc037f..d88db80 100644 --- a/internal/format/issue.go +++ b/internal/format/issue.go @@ -192,6 +192,10 @@ func writeIssueBody(b *strings.Builder, issue *core.Issue) { b.WriteString(fmtSprintf("Project: %s\n", issue.Project.Name)) } + if issue.ProjectMilestone != nil { + b.WriteString(fmtSprintf("Milestone: %s\n", issue.ProjectMilestone.Name)) + } + if issue.Cycle != nil { b.WriteString(fmtSprintf("Cycle: %s (#%d)\n", issue.Cycle.Name, issue.Cycle.Number)) } diff --git a/internal/format/json_dtos.go b/internal/format/json_dtos.go index 288c60c..37e8240 100644 --- a/internal/format/json_dtos.go +++ b/internal/format/json_dtos.go @@ -13,41 +13,43 @@ type IssueMinimalDTO struct { // IssueCompactDTO contains key metadata (~150 tokens) type IssueCompactDTO struct { - Identifier string `json:"identifier"` - Title string `json:"title"` - State string `json:"state"` - Priority *int `json:"priority"` - Assignee *string `json:"assignee"` - Delegate *string `json:"delegate,omitempty"` // OAuth app delegate - Estimate *float64 `json:"estimate"` - DueDate *string `json:"dueDate"` - CycleNumber *int `json:"cycleNumber"` - ProjectName *string `json:"projectName"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + Identifier string `json:"identifier"` + Title string `json:"title"` + State string `json:"state"` + Priority *int `json:"priority"` + Assignee *string `json:"assignee"` + Delegate *string `json:"delegate,omitempty"` // OAuth app delegate + Estimate *float64 `json:"estimate"` + DueDate *string `json:"dueDate"` + CycleNumber *int `json:"cycleNumber"` + ProjectName *string `json:"projectName"` + MilestoneName *string `json:"milestoneName"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } // issueBaseFields contains the shared fields between IssueDetailedDTO and IssueFullDTO. type issueBaseFields struct { - Identifier string `json:"identifier"` - Title string `json:"title"` - Description string `json:"description"` - State *StateDTO `json:"state"` - Priority *int `json:"priority"` - Assignee *UserDTO `json:"assignee"` - Delegate *UserDTO `json:"delegate,omitempty"` - Creator *UserDTO `json:"creator"` - Estimate *float64 `json:"estimate"` - DueDate *string `json:"dueDate"` - Labels []LabelDTO `json:"labels"` - Project *ProjectRefDTO `json:"project"` - Cycle *CycleRefDTO `json:"cycle"` - Parent *IssueRefDTO `json:"parent"` - Children []IssueRefDTO `json:"children"` - Attachments []AttachmentDTO `json:"attachments"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - URL string `json:"url"` + Identifier string `json:"identifier"` + Title string `json:"title"` + Description string `json:"description"` + State *StateDTO `json:"state"` + Priority *int `json:"priority"` + Assignee *UserDTO `json:"assignee"` + Delegate *UserDTO `json:"delegate,omitempty"` + Creator *UserDTO `json:"creator"` + Estimate *float64 `json:"estimate"` + DueDate *string `json:"dueDate"` + Labels []LabelDTO `json:"labels"` + Project *ProjectRefDTO `json:"project"` + Milestone *MilestoneRefDTO `json:"milestone"` + Cycle *CycleRefDTO `json:"cycle"` + Parent *IssueRefDTO `json:"parent"` + Children []IssueRefDTO `json:"children"` + Attachments []AttachmentDTO `json:"attachments"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + URL string `json:"url"` } // IssueFullDTO contains complete issue details (~500 tokens) @@ -93,47 +95,62 @@ type CycleCompactDTO struct { // CycleFullDTO contains complete cycle details type CycleFullDTO struct { - Number int `json:"number"` - Name string `json:"name"` - Status string `json:"status"` - StartsAt string `json:"startsAt"` - EndsAt string `json:"endsAt"` - Progress float64 `json:"progress"` - Description string `json:"description"` - Team *TeamDTO `json:"team"` - ScopeHistory []int `json:"scopeHistory"` - CompletedScopeHistory []int `json:"completedScopeHistory"` - InProgressScopeHistory []int `json:"inProgressScopeHistory"` - IssueCountHistory []int `json:"issueCountHistory"` - CompletedIssueCountHistory []int `json:"completedIssueCountHistory"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + Number int `json:"number"` + Name string `json:"name"` + Status string `json:"status"` + StartsAt string `json:"startsAt"` + EndsAt string `json:"endsAt"` + Progress float64 `json:"progress"` + Description string `json:"description"` + Team *TeamDTO `json:"team"` + ScopeHistory []int `json:"scopeHistory"` + CompletedScopeHistory []int `json:"completedScopeHistory"` + InProgressScopeHistory []int `json:"inProgressScopeHistory"` + IssueCountHistory []int `json:"issueCountHistory"` + CompletedIssueCountHistory []int `json:"completedIssueCountHistory"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } // --- Project DTOs --- // ProjectDTO represents a project in JSON format type ProjectDTO struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - State string `json:"state"` - Content string `json:"content"` - Issues []IssueRefDTO `json:"issues"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + State string `json:"state"` + Content string `json:"content"` + Issues []IssueRefDTO `json:"issues"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +// MilestoneDTO represents a project milestone in JSON format. +type MilestoneDTO struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + TargetDate string `json:"targetDate"` + Status string `json:"status"` + Progress float64 `json:"progress"` + Project *ProjectRefDTO `json:"project"` + Issues []IssueRefDTO `json:"issues"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + ArchivedAt *string `json:"archivedAt,omitempty"` } // --- Team DTOs --- // TeamDTO represents a team in JSON format type TeamDTO struct { - ID string `json:"id"` - Key string `json:"key"` - Name string `json:"name"` - Description string `json:"description"` - IssueEstimationType string `json:"issueEstimationType"` - EstimateScale *EstimateScale `json:"estimateScale"` + ID string `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + IssueEstimationType string `json:"issueEstimationType"` + EstimateScale *EstimateScale `json:"estimateScale"` } // EstimateScale represents the estimation scale for a team @@ -146,14 +163,14 @@ type EstimateScale struct { // UserDTO represents a user in JSON format type UserDTO struct { - ID string `json:"id"` - Name string `json:"name"` - DisplayName string `json:"displayName"` - Email string `json:"email"` - Active bool `json:"active"` - Admin bool `json:"admin"` - Teams []TeamRef `json:"teams"` - CreatedAt string `json:"createdAt"` + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"displayName"` + Email string `json:"email"` + Active bool `json:"active"` + Admin bool `json:"admin"` + Teams []TeamRef `json:"teams"` + CreatedAt string `json:"createdAt"` } // TeamRef is a minimal team reference @@ -166,13 +183,13 @@ type TeamRef struct { // CommentDTO represents a comment in JSON format type CommentDTO struct { - ID string `json:"id"` - Body string `json:"body"` - User *UserDTO `json:"user"` - Issue *IssueRefDTO `json:"issue"` + ID string `json:"id"` + Body string `json:"body"` + User *UserDTO `json:"user"` + Issue *IssueRefDTO `json:"issue"` Parent *CommentRefDTO `json:"parent"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } // --- Reference DTOs (nested objects) --- @@ -195,6 +212,12 @@ type ProjectRefDTO struct { Name string `json:"name"` } +// MilestoneRefDTO is a minimal project milestone reference. +type MilestoneRefDTO struct { + ID string `json:"id"` + Name string `json:"name"` +} + // CycleRefDTO is a minimal cycle reference type CycleRefDTO struct { ID string `json:"id"` @@ -267,6 +290,11 @@ func IssueToCompactDTO(issue *core.Issue) IssueCompactDTO { dto.ProjectName = &name } + if issue.ProjectMilestone != nil { + name := issue.ProjectMilestone.Name + dto.MilestoneName = &name + } + return dto } @@ -329,6 +357,13 @@ func populateIssueBase(issue *core.Issue) issueBaseFields { } } + if issue.ProjectMilestone != nil { + base.Milestone = &MilestoneRefDTO{ + ID: issue.ProjectMilestone.ID, + Name: issue.ProjectMilestone.Name, + } + } + if issue.Cycle != nil { base.Cycle = &CycleRefDTO{ ID: issue.Cycle.ID, @@ -396,8 +431,8 @@ func IssueToDetailedDTO(issue *core.Issue) IssueDetailedDTO { dto.Comments = make([]CommentSummaryDTO, len(issue.Comments.Nodes)) for i, comment := range issue.Comments.Nodes { dto.Comments[i] = CommentSummaryDTO{ - ID: comment.ID, - Body: truncate(cleanDescription(comment.Body), 200), + ID: comment.ID, + Body: truncate(cleanDescription(comment.Body), 200), User: &UserDTO{ ID: comment.User.ID, Name: comment.User.Name, @@ -497,6 +532,41 @@ func ProjectToDTO(project *core.Project) ProjectDTO { return dto } +// MilestoneToDTO converts a project milestone to DTO. +func MilestoneToDTO(milestone *core.ProjectMilestone) MilestoneDTO { + dto := MilestoneDTO{ + ID: milestone.ID, + Name: milestone.Name, + Description: milestone.Description, + TargetDate: milestone.TargetDate, + Status: milestone.Status, + Progress: milestone.Progress, + CreatedAt: milestone.CreatedAt, + UpdatedAt: milestone.UpdatedAt, + ArchivedAt: milestone.ArchivedAt, + } + + if milestone.Project != nil { + dto.Project = &ProjectRefDTO{ + ID: milestone.Project.ID, + Name: milestone.Project.Name, + } + } + + if milestone.Issues != nil && len(milestone.Issues.Nodes) > 0 { + dto.Issues = make([]IssueRefDTO, len(milestone.Issues.Nodes)) + for i, issue := range milestone.Issues.Nodes { + dto.Issues[i] = IssueRefDTO{ + Identifier: issue.Identifier, + Title: issue.Title, + State: issue.State.Name, + } + } + } + + return dto +} + // TeamToDTO converts a team to DTO func TeamToDTO(team *core.Team) TeamDTO { dto := TeamDTO{ diff --git a/internal/format/json_renderer.go b/internal/format/json_renderer.go index 4151c68..2c6c2b3 100644 --- a/internal/format/json_renderer.go +++ b/internal/format/json_renderer.go @@ -129,6 +129,30 @@ func (r *JSONRenderer) RenderProjectList(projects []core.Project, verbosity Verb return r.marshal(dtos) } +// --- Milestone Rendering --- + +func (r *JSONRenderer) RenderMilestone(milestone *core.ProjectMilestone, verbosity Verbosity) string { + if milestone == nil { + return r.renderError("Milestone is nil") + } + + dto := MilestoneToDTO(milestone) + return r.marshal(dto) +} + +func (r *JSONRenderer) RenderMilestoneList(milestones []core.ProjectMilestone, verbosity Verbosity) string { + if len(milestones) == 0 { + return "[]" + } + + dtos := make([]MilestoneDTO, len(milestones)) + for i, milestone := range milestones { + dtos[i] = MilestoneToDTO(&milestone) + } + + return r.marshal(dtos) +} + // --- Team Rendering --- func (r *JSONRenderer) RenderTeam(team *core.Team, verbosity Verbosity) string { diff --git a/internal/format/renderer.go b/internal/format/renderer.go index bb020e4..b540a3c 100644 --- a/internal/format/renderer.go +++ b/internal/format/renderer.go @@ -9,6 +9,7 @@ type Renderer interface { RenderIssue(issue *core.Issue, verbosity Verbosity) string RenderCycle(cycle *core.Cycle, verbosity Verbosity) string RenderProject(project *core.Project, verbosity Verbosity) string + RenderMilestone(milestone *core.ProjectMilestone, verbosity Verbosity) string RenderTeam(team *core.Team, verbosity Verbosity) string RenderUser(user *core.User, verbosity Verbosity) string RenderComment(comment *core.Comment, verbosity Verbosity) string @@ -17,6 +18,7 @@ type Renderer interface { RenderIssueList(issues []core.Issue, verbosity Verbosity, page *Pagination) string RenderCycleList(cycles []core.Cycle, verbosity Verbosity, page *Pagination) string RenderProjectList(projects []core.Project, verbosity Verbosity, page *Pagination) string + RenderMilestoneList(milestones []core.ProjectMilestone, verbosity Verbosity) string RenderTeamList(teams []core.Team, verbosity Verbosity) string RenderUserList(users []core.User, verbosity Verbosity) string RenderCommentList(comments []core.Comment, verbosity Verbosity) string diff --git a/internal/format/text_renderer.go b/internal/format/text_renderer.go index 16ca2df..c5c011a 100644 --- a/internal/format/text_renderer.go +++ b/internal/format/text_renderer.go @@ -114,6 +114,10 @@ func (r *TextRenderer) issueCompact(issue *core.Issue) string { b.WriteString(fmtSprintf(" Project: %s\n", issue.Project.Name)) } + if issue.ProjectMilestone != nil { + b.WriteString(fmtSprintf(" Milestone: %s\n", issue.ProjectMilestone.Name)) + } + // Line 4: Parent/Children (if any) if issue.Parent != nil { b.WriteString(fmtSprintf(" Parent: %s\n", issue.Parent.Identifier)) @@ -397,6 +401,118 @@ func (r *TextRenderer) projectCompact(project *core.Project) string { return b.String() } +// --- Milestone Rendering --- + +func (r *TextRenderer) RenderMilestone(milestone *core.ProjectMilestone, verbosity Verbosity) string { + if milestone == nil { + return "" + } + + switch verbosity { + case VerbosityMinimal: + return r.milestoneMinimal(milestone) + case VerbosityCompact: + return r.milestoneCompact(milestone) + case VerbosityDetailed, VerbosityFull: + return r.milestoneFull(milestone) + default: + return r.milestoneCompact(milestone) + } +} + +func (r *TextRenderer) RenderMilestoneList(milestones []core.ProjectMilestone, verbosity Verbosity) string { + if len(milestones) == 0 { + return "No milestones found." + } + + var b strings.Builder + b.WriteString(fmtSprintf("MILESTONES (%d)\n", len(milestones))) + b.WriteString(line(40)) + b.WriteString("\n") + + for _, milestone := range milestones { + b.WriteString(r.RenderMilestone(&milestone, verbosity)) + b.WriteString("\n") + } + + return b.String() +} + +func (r *TextRenderer) milestoneMinimal(milestone *core.ProjectMilestone) string { + if date := formatDate(milestone.TargetDate); date != "" { + return fmtSprintf("%s [%s] %s", milestone.Name, milestone.Status, date) + } + return fmtSprintf("%s [%s]", milestone.Name, milestone.Status) +} + +func (r *TextRenderer) milestoneCompact(milestone *core.ProjectMilestone) string { + var b strings.Builder + + targetDate := "No target" + if milestone.TargetDate != "" { + targetDate = formatDate(milestone.TargetDate) + } + + b.WriteString(fmtSprintf("%s [%s]\n", milestone.Name, milestone.Status)) + b.WriteString(fmtSprintf(" Target: %s | Progress: %.0f%%", targetDate, milestone.Progress)) + if milestone.Project != nil { + b.WriteString(fmtSprintf(" | Project: %s", milestone.Project.Name)) + } + b.WriteString("\n") + + if milestone.Description != "" { + b.WriteString(fmtSprintf(" %s\n", truncate(cleanDescription(milestone.Description), 100))) + } + + if milestone.Issues != nil && len(milestone.Issues.Nodes) > 0 { + b.WriteString(fmtSprintf(" Issues: %d\n", len(milestone.Issues.Nodes))) + } + + return b.String() +} + +func (r *TextRenderer) milestoneFull(milestone *core.ProjectMilestone) string { + var b strings.Builder + + b.WriteString(fmtSprintf("%s\n", milestone.Name)) + b.WriteString(line(50)) + b.WriteString("\n") + b.WriteString(fmtSprintf("Status: %s\n", milestone.Status)) + b.WriteString(fmtSprintf("Progress: %.1f%%\n", milestone.Progress)) + if milestone.TargetDate != "" { + b.WriteString(fmtSprintf("Target: %s\n", formatDate(milestone.TargetDate))) + } + if milestone.Project != nil { + b.WriteString(fmtSprintf("Project: %s\n", milestone.Project.Name)) + } + if milestone.Description != "" { + b.WriteString("\nDESCRIPTION\n") + b.WriteString(line(40)) + b.WriteString("\n") + b.WriteString(cleanDescription(milestone.Description)) + b.WriteString("\n") + } + + if milestone.Issues != nil && len(milestone.Issues.Nodes) > 0 { + b.WriteString(fmtSprintf("\nISSUES (%d)\n", len(milestone.Issues.Nodes))) + b.WriteString(line(40)) + b.WriteString("\n") + for _, issue := range milestone.Issues.Nodes { + assignee := "Unassigned" + if issue.Assignee != nil { + assignee = "@" + issue.Assignee.Name + } + b.WriteString(fmtSprintf(" %s [%s] %s (%s)\n", + issue.Identifier, issue.State.Name, truncate(issue.Title, 40), assignee)) + } + } + + b.WriteString(fmtSprintf("\nCreated: %s\n", formatDateTime(milestone.CreatedAt))) + b.WriteString(fmtSprintf("Updated: %s\n", formatDateTime(milestone.UpdatedAt))) + + return b.String() +} + // --- Team Rendering --- func (r *TextRenderer) RenderTeam(team *core.Team, verbosity Verbosity) string { diff --git a/internal/service/client_interfaces.go b/internal/service/client_interfaces.go index b97ee84..2d2dd3b 100644 --- a/internal/service/client_interfaces.go +++ b/internal/service/client_interfaces.go @@ -6,6 +6,7 @@ import ( "github.com/joa23/linear-cli/pkg/linear/core" "github.com/joa23/linear-cli/pkg/linear/cycles" "github.com/joa23/linear-cli/pkg/linear/issues" + "github.com/joa23/linear-cli/pkg/linear/milestones" "github.com/joa23/linear-cli/pkg/linear/projects" "github.com/joa23/linear-cli/pkg/linear/teams" "github.com/joa23/linear-cli/pkg/linear/workflows" @@ -30,6 +31,7 @@ type IssueClientOperations interface { ResolveCycleIdentifier(numberOrNameOrID, teamID string) (string, error) ResolveLabelIdentifier(labelName, teamID string) (string, error) ResolveProjectIdentifier(nameOrID, teamID string) (string, error) + ResolveProjectMilestoneIdentifier(nameOrID, projectID string) (string, error) // Relation operations CreateRelation(issueID, relatedIssueID string, relationType core.IssueRelationType) error @@ -74,6 +76,22 @@ type ProjectClientOperations interface { TeamClient() *teams.Client } +// MilestoneClientOperations defines the minimal interface needed by MilestoneService. +type MilestoneClientOperations interface { + ListProjectMilestones(projectID string, limit int) ([]core.ProjectMilestone, error) + GetProjectMilestone(id string) (*core.ProjectMilestone, error) + CreateProjectMilestone(input *core.CreateProjectMilestoneInput) (*core.ProjectMilestone, error) + UpdateProjectMilestone(id string, input *core.UpdateProjectMilestoneInput) (*core.ProjectMilestone, error) + DeleteProjectMilestone(id string) error + + ResolveTeamIdentifier(keyOrName string) (string, error) + ResolveProjectIdentifier(nameOrID, teamID string) (string, error) + ResolveProjectMilestoneIdentifier(nameOrID, projectID string) (string, error) + + MilestoneClient() *milestones.Client + ProjectClient() *projects.Client +} + // UserClientOperations defines the minimal interface needed by UserService type UserClientOperations interface { // Smart resolver-aware methods (kept in Phase 2) @@ -99,6 +117,7 @@ type SearchClientOperations interface { ResolveCycleIdentifier(numberOrNameOrID, teamID string) (string, error) ResolveLabelIdentifier(labelName, teamID string) (string, error) ResolveProjectIdentifier(nameOrID, teamID string) (string, error) + ResolveProjectMilestoneIdentifier(nameOrID, projectID string) (string, error) // Sub-client access (Phase 2 - use sub-clients directly) IssueClient() *issues.Client diff --git a/internal/service/interfaces.go b/internal/service/interfaces.go index ea27810..9fe6111 100644 --- a/internal/service/interfaces.go +++ b/internal/service/interfaces.go @@ -47,6 +47,15 @@ type ProjectServiceInterface interface { Update(projectID string, input *UpdateProjectInput) (string, error) } +// MilestoneServiceInterface defines the contract for project milestone operations. +type MilestoneServiceInterface interface { + List(input *MilestoneListInput, verbosity format.Verbosity, outputType format.OutputType) (string, error) + Get(identifier string, projectID string, teamID string, verbosity format.Verbosity, outputType format.OutputType) (string, error) + Create(input *CreateMilestoneInput, verbosity format.Verbosity, outputType format.OutputType) (string, error) + Update(identifier string, input *UpdateMilestoneInput, verbosity format.Verbosity, outputType format.OutputType) (string, error) + Delete(identifier string, projectID string, teamID string) (string, error) +} + // SearchServiceInterface defines the contract for unified search type SearchServiceInterface interface { Search(opts *SearchOptions) (string, error) @@ -93,6 +102,7 @@ var ( _ IssueServiceInterface = (*IssueService)(nil) _ CycleServiceInterface = (*CycleService)(nil) _ ProjectServiceInterface = (*ProjectService)(nil) + _ MilestoneServiceInterface = (*MilestoneService)(nil) _ SearchServiceInterface = (*SearchService)(nil) _ TeamServiceInterface = (*TeamService)(nil) _ UserServiceInterface = (*UserService)(nil) diff --git a/internal/service/issue.go b/internal/service/issue.go index 9732485..feb1e65 100644 --- a/internal/service/issue.go +++ b/internal/service/issue.go @@ -5,9 +5,9 @@ import ( "sort" "github.com/joa23/linear-cli/internal/format" + "github.com/joa23/linear-cli/pkg/linear/core" "github.com/joa23/linear-cli/pkg/linear/identifiers" paginationutil "github.com/joa23/linear-cli/pkg/linear/pagination" - "github.com/joa23/linear-cli/pkg/linear/core" ) // IssueService handles issue-related operations @@ -26,16 +26,17 @@ func NewIssueService(client IssueClientOperations, formatter *format.Formatter) // SearchFilters represents filters for searching issues type SearchFilters struct { - TeamID string - ProjectID string - AssigneeID string - CycleID string - StateIDs []string - LabelIDs []string + TeamID string + ProjectID string + MilestoneID string + AssigneeID string + CycleID string + StateIDs []string + LabelIDs []string ExcludeLabelIDs []string - Priority *int - SearchTerm string - OrderBy string + Priority *int + SearchTerm string + OrderBy string // Date filters (RFC3339 timestamps). Set via CLI --created-since/--created-after/--created-before. CreatedAfter string CreatedBefore string @@ -103,6 +104,14 @@ func (s *IssueService) Search(filters *SearchFilters) (string, error) { linearFilters.ProjectID = projectID } + if filters.MilestoneID != "" { + milestoneID, err := s.client.ResolveProjectMilestoneIdentifier(filters.MilestoneID, linearFilters.ProjectID) + if err != nil { + return "", fmt.Errorf("failed to resolve milestone '%s': %w", filters.MilestoneID, err) + } + linearFilters.ProjectMilestoneID = milestoneID + } + // Resolve assignee identifier if provided if filters.AssigneeID != "" { resolved, err := s.client.ResolveUserIdentifier(filters.AssigneeID) @@ -217,6 +226,14 @@ func (s *IssueService) SearchWithOutput(filters *SearchFilters, verbosity format linearFilters.ProjectID = projectID } + if filters.MilestoneID != "" { + milestoneID, err := s.client.ResolveProjectMilestoneIdentifier(filters.MilestoneID, linearFilters.ProjectID) + if err != nil { + return "", fmt.Errorf("failed to resolve milestone '%s': %w", filters.MilestoneID, err) + } + linearFilters.ProjectMilestoneID = milestoneID + } + // Resolve assignee identifier if provided if filters.AssigneeID != "" { resolved, err := s.client.ResolveUserIdentifier(filters.AssigneeID) @@ -262,22 +279,6 @@ func (s *IssueService) SearchWithOutput(filters *SearchFilters, verbosity format linearFilters.LabelIDs = resolvedLabels } - // Resolve project identifier if provided - if filters.ProjectID != "" { - teamID := linearFilters.TeamID - if teamID == "" { - // Try to resolve team for project lookup - if resolvedTeam, err := s.client.ResolveTeamIdentifier(filters.TeamID); err == nil { - teamID = resolvedTeam - } - } - projectID, err := s.client.ResolveProjectIdentifier(filters.ProjectID, teamID) - if err != nil { - return "", fmt.Errorf("failed to resolve project '%s': %w", filters.ProjectID, err) - } - linearFilters.ProjectID = projectID - } - // Resolve exclude-label names to IDs (requires team) if len(filters.ExcludeLabelIDs) > 0 { if linearFilters.TeamID == "" { @@ -407,6 +408,8 @@ func convertIssueDetails(details []core.IssueWithDetails) []core.Issue { }{ID: d.State.ID, Name: d.State.Name}, Priority: &priority, Assignee: d.Assignee, + Project: d.Project, + ProjectMilestone: d.ProjectMilestone, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt, } @@ -438,20 +441,21 @@ func sortIssues(issues []core.IssueWithDetails, sortBy, direction string) { // CreateIssueInput represents input for creating an issue type CreateIssueInput struct { - Title string - Description string - TeamID string - StateID string - AssigneeID string - ProjectID string - ParentID string - CycleID string - Priority *int - Estimate *float64 - DueDate string - LabelIDs []string - DependsOn []string // Issue identifiers this issue depends on (stored in metadata) - BlockedBy []string // Issue identifiers that block this issue (stored in metadata) + Title string + Description string + TeamID string + StateID string + AssigneeID string + ProjectID string + ProjectMilestoneID string + ParentID string + CycleID string + Priority *int + Estimate *float64 + DueDate string + LabelIDs []string + DependsOn []string // Issue identifiers this issue depends on (stored in metadata) + BlockedBy []string // Issue identifiers that block this issue (stored in metadata) } // Create creates a new issue @@ -507,6 +511,14 @@ func (s *IssueService) Create(input *CreateIssueInput) (string, error) { createInput.ProjectID = projectID } + if input.ProjectMilestoneID != "" { + milestoneID, err := s.client.ResolveProjectMilestoneIdentifier(input.ProjectMilestoneID, createInput.ProjectID) + if err != nil { + return "", fmt.Errorf("failed to resolve milestone '%s': %w", input.ProjectMilestoneID, err) + } + createInput.ProjectMilestoneID = milestoneID + } + if input.CycleID != "" { cycleID, err := s.client.ResolveCycleIdentifier(input.CycleID, teamID) if err != nil { @@ -556,22 +568,23 @@ func (s *IssueService) Create(input *CreateIssueInput) (string, error) { // UpdateIssueInput represents input for updating an issue type UpdateIssueInput struct { - Title *string - Description *string - StateID *string - AssigneeID *string - ProjectID *string - ParentID *string - TeamID *string - CycleID *string - Priority *int - Estimate *float64 - DueDate *string - LabelIDs []string // Replace mode: replaces all labels - AddLabelIDs []string // Additive mode: labels to add (names, resolved later) - RemoveLabelIDs []string // Subtractive mode: labels to remove (names, resolved later) - DependsOn []string // Issue identifiers this issue depends on (stored in metadata) - BlockedBy []string // Issue identifiers that block this issue (stored in metadata) + Title *string + Description *string + StateID *string + AssigneeID *string + ProjectID *string + ProjectMilestoneID *string + ParentID *string + TeamID *string + CycleID *string + Priority *int + Estimate *float64 + DueDate *string + LabelIDs []string // Replace mode: replaces all labels + AddLabelIDs []string // Additive mode: labels to add (names, resolved later) + RemoveLabelIDs []string // Subtractive mode: labels to remove (names, resolved later) + DependsOn []string // Issue identifiers this issue depends on (stored in metadata) + BlockedBy []string // Issue identifiers that block this issue (stored in metadata) } // Update updates an existing issue @@ -646,6 +659,19 @@ func (s *IssueService) Update(identifier string, input *UpdateIssueInput) (strin } linearInput.ProjectID = &projectID } + if input.ProjectMilestoneID != nil { + projectID := "" + if linearInput.ProjectID != nil { + projectID = *linearInput.ProjectID + } else if issue.Project != nil { + projectID = issue.Project.ID + } + milestoneID, err := s.client.ResolveProjectMilestoneIdentifier(*input.ProjectMilestoneID, projectID) + if err != nil { + return "", fmt.Errorf("failed to resolve milestone '%s': %w", *input.ProjectMilestoneID, err) + } + linearInput.ProjectMilestoneID = &milestoneID + } if input.ParentID != nil { linearInput.ParentID = input.ParentID } @@ -859,6 +885,7 @@ func hasServiceFieldsToUpdate(input core.UpdateIssueInput) bool { input.AssigneeID != nil || input.DelegateID != nil || input.ProjectID != nil || + input.ProjectMilestoneID != nil || input.ParentID != nil || input.TeamID != nil || input.CycleID != nil || diff --git a/internal/service/issue_create_test.go b/internal/service/issue_create_test.go index cc1b9b5..42716bb 100644 --- a/internal/service/issue_create_test.go +++ b/internal/service/issue_create_test.go @@ -72,6 +72,9 @@ func (m *mockIssueClientForCreate) CreateRelation(issueID, relatedIssueID string func (m *mockIssueClientForCreate) ResolveProjectIdentifier(nameOrID, teamID string) (string, error) { return "project-uuid", nil } +func (m *mockIssueClientForCreate) ResolveProjectMilestoneIdentifier(nameOrID, projectID string) (string, error) { + return "milestone-uuid", nil +} func (m *mockIssueClientForCreate) CommentClient() *comments.Client { return nil } func (m *mockIssueClientForCreate) WorkflowClient() *workflows.Client { return nil } func (m *mockIssueClientForCreate) IssueClient() *issues.Client { return nil } diff --git a/internal/service/issue_delegate_test.go b/internal/service/issue_delegate_test.go index c253fc9..7f1ce4e 100644 --- a/internal/service/issue_delegate_test.go +++ b/internal/service/issue_delegate_test.go @@ -61,6 +61,9 @@ func (m *mockIssueClientForDelegate) ResolveLabelIdentifier(label, team string) func (m *mockIssueClientForDelegate) ResolveProjectIdentifier(nameOrID, teamID string) (string, error) { return "project-uuid", nil } +func (m *mockIssueClientForDelegate) ResolveProjectMilestoneIdentifier(nameOrID, projectID string) (string, error) { + return "milestone-uuid", nil +} func (m *mockIssueClientForDelegate) CreateRelation(issueID, relatedIssueID string, relationType core.IssueRelationType) error { return nil } diff --git a/internal/service/issue_relation_test.go b/internal/service/issue_relation_test.go index 5389a27..551e232 100644 --- a/internal/service/issue_relation_test.go +++ b/internal/service/issue_relation_test.go @@ -83,6 +83,9 @@ func (m *mockIssueClientForRelation) ResolveLabelIdentifier(label, team string) func (m *mockIssueClientForRelation) ResolveProjectIdentifier(nameOrID, teamID string) (string, error) { return "project-uuid", nil } +func (m *mockIssueClientForRelation) ResolveProjectMilestoneIdentifier(nameOrID, projectID string) (string, error) { + return "milestone-uuid", nil +} func (m *mockIssueClientForRelation) UpdateIssueMetadataKey(id, key string, val interface{}) error { return nil } diff --git a/internal/service/milestone.go b/internal/service/milestone.go new file mode 100644 index 0000000..1fbe668 --- /dev/null +++ b/internal/service/milestone.go @@ -0,0 +1,187 @@ +package service + +import ( + "fmt" + + "github.com/joa23/linear-cli/internal/format" + "github.com/joa23/linear-cli/pkg/linear/core" +) + +// MilestoneService handles project milestone operations. +type MilestoneService struct { + client MilestoneClientOperations + formatter *format.Formatter +} + +// NewMilestoneService creates a new MilestoneService. +func NewMilestoneService(client MilestoneClientOperations, formatter *format.Formatter) *MilestoneService { + return &MilestoneService{client: client, formatter: formatter} +} + +// MilestoneListInput contains filters for listing project milestones. +type MilestoneListInput struct { + ProjectID string + TeamID string + Limit int +} + +// CreateMilestoneInput contains CLI-level input for creating a project milestone. +type CreateMilestoneInput struct { + Name string + Description string + ProjectID string + TeamID string + TargetDate string +} + +// UpdateMilestoneInput contains CLI-level input for updating a project milestone. +type UpdateMilestoneInput struct { + Name *string + Description *string + LookupProjectID string + TeamID string + TargetDate *string +} + +// List lists milestones for a project. +func (s *MilestoneService) List(input *MilestoneListInput, verbosity format.Verbosity, outputType format.OutputType) (string, error) { + projectID, err := s.resolveProject(input.ProjectID, input.TeamID) + if err != nil { + return "", err + } + + limit := input.Limit + if limit <= 0 { + limit = 50 + } + + milestones, err := s.client.ListProjectMilestones(projectID, limit) + if err != nil { + return "", fmt.Errorf("failed to list milestones: %w", err) + } + + return s.formatter.RenderMilestoneList(milestones, verbosity, outputType), nil +} + +// Get retrieves a milestone by ID or by name within a project. +func (s *MilestoneService) Get(identifier string, projectID string, teamID string, verbosity format.Verbosity, outputType format.OutputType) (string, error) { + id, err := s.resolveMilestone(identifier, projectID, teamID) + if err != nil { + return "", err + } + + milestone, err := s.client.GetProjectMilestone(id) + if err != nil { + return "", fmt.Errorf("failed to get milestone: %w", err) + } + + return s.formatter.RenderMilestone(milestone, verbosity, outputType), nil +} + +// Create creates a project milestone. +func (s *MilestoneService) Create(input *CreateMilestoneInput, verbosity format.Verbosity, outputType format.OutputType) (string, error) { + if input == nil { + return "", fmt.Errorf("input is required") + } + if input.Name == "" { + return "", fmt.Errorf("name is required") + } + + projectID, err := s.resolveProject(input.ProjectID, input.TeamID) + if err != nil { + return "", err + } + + milestone, err := s.client.CreateProjectMilestone(&core.CreateProjectMilestoneInput{ + Name: input.Name, + Description: input.Description, + ProjectID: projectID, + TargetDate: input.TargetDate, + }) + if err != nil { + return "", fmt.Errorf("failed to create milestone: %w", err) + } + + return s.formatter.RenderMilestone(milestone, verbosity, outputType), nil +} + +// Update updates a project milestone. +func (s *MilestoneService) Update(identifier string, input *UpdateMilestoneInput, verbosity format.Verbosity, outputType format.OutputType) (string, error) { + if input == nil { + return "", fmt.Errorf("input is required") + } + + id, err := s.resolveMilestone(identifier, input.LookupProjectID, input.TeamID) + if err != nil { + return "", err + } + + updateInput := &core.UpdateProjectMilestoneInput{ + Name: input.Name, + Description: input.Description, + TargetDate: input.TargetDate, + } + + milestone, err := s.client.UpdateProjectMilestone(id, updateInput) + if err != nil { + return "", fmt.Errorf("failed to update milestone: %w", err) + } + + return s.formatter.RenderMilestone(milestone, verbosity, outputType), nil +} + +// Delete deletes a project milestone. +func (s *MilestoneService) Delete(identifier string, projectID string, teamID string) (string, error) { + id, err := s.resolveMilestone(identifier, projectID, teamID) + if err != nil { + return "", err + } + + if err := s.client.DeleteProjectMilestone(id); err != nil { + return "", fmt.Errorf("failed to delete milestone: %w", err) + } + + return fmt.Sprintf("Deleted milestone: %s", identifier), nil +} + +func (s *MilestoneService) resolveProject(projectIdentifier string, teamIdentifier string) (string, error) { + if projectIdentifier == "" { + return "", fmt.Errorf("project is required") + } + + var teamID string + if teamIdentifier != "" { + resolvedTeamID, err := s.client.ResolveTeamIdentifier(teamIdentifier) + if err != nil { + return "", fmt.Errorf("failed to resolve team '%s': %w", teamIdentifier, err) + } + teamID = resolvedTeamID + } + + projectID, err := s.client.ResolveProjectIdentifier(projectIdentifier, teamID) + if err != nil { + return "", fmt.Errorf("failed to resolve project '%s': %w", projectIdentifier, err) + } + return projectID, nil +} + +func (s *MilestoneService) resolveMilestone(identifier string, projectIdentifier string, teamIdentifier string) (string, error) { + if identifier == "" { + return "", fmt.Errorf("milestone is required") + } + + projectID := projectIdentifier + if projectID != "" { + resolvedProjectID, err := s.resolveProject(projectID, teamIdentifier) + if err != nil { + return "", err + } + projectID = resolvedProjectID + } + + milestoneID, err := s.client.ResolveProjectMilestoneIdentifier(identifier, projectID) + if err != nil { + return "", fmt.Errorf("failed to resolve milestone '%s': %w", identifier, err) + } + return milestoneID, nil +} diff --git a/internal/service/milestone_test.go b/internal/service/milestone_test.go new file mode 100644 index 0000000..afcb4f7 --- /dev/null +++ b/internal/service/milestone_test.go @@ -0,0 +1,238 @@ +package service + +import ( + "errors" + "testing" + + "github.com/joa23/linear-cli/internal/format" + "github.com/joa23/linear-cli/pkg/linear/core" + "github.com/joa23/linear-cli/pkg/linear/milestones" + "github.com/joa23/linear-cli/pkg/linear/projects" +) + +// mockMilestoneClient implements MilestoneClientOperations with configurable +// return values and call tracking for MilestoneService tests. +type mockMilestoneClient struct { + // Configured return values + listResult []core.ProjectMilestone + listErr error + getResult *core.ProjectMilestone + getErr error + createResult *core.ProjectMilestone + createErr error + updateResult *core.ProjectMilestone + updateErr error + deleteErr error + + resolveTeamResult string + resolveTeamErr error + resolveProjectResult string + resolveProjectErr error + resolveMilestoneResult string + resolveMilestoneErr error + + // Captured inputs + lastListProjectID string + lastCreateInput *core.CreateProjectMilestoneInput + lastUpdateID string + lastUpdateInput *core.UpdateProjectMilestoneInput + lastDeleteID string + lastResolveMilestoneArg string + lastResolveProjectArg string +} + +func (m *mockMilestoneClient) ListProjectMilestones(projectID string, limit int) ([]core.ProjectMilestone, error) { + m.lastListProjectID = projectID + return m.listResult, m.listErr +} + +func (m *mockMilestoneClient) GetProjectMilestone(id string) (*core.ProjectMilestone, error) { + return m.getResult, m.getErr +} + +func (m *mockMilestoneClient) CreateProjectMilestone(input *core.CreateProjectMilestoneInput) (*core.ProjectMilestone, error) { + m.lastCreateInput = input + return m.createResult, m.createErr +} + +func (m *mockMilestoneClient) UpdateProjectMilestone(id string, input *core.UpdateProjectMilestoneInput) (*core.ProjectMilestone, error) { + m.lastUpdateID = id + m.lastUpdateInput = input + return m.updateResult, m.updateErr +} + +func (m *mockMilestoneClient) DeleteProjectMilestone(id string) error { + m.lastDeleteID = id + return m.deleteErr +} + +func (m *mockMilestoneClient) ResolveTeamIdentifier(keyOrName string) (string, error) { + return m.resolveTeamResult, m.resolveTeamErr +} + +func (m *mockMilestoneClient) ResolveProjectIdentifier(nameOrID, teamID string) (string, error) { + m.lastResolveProjectArg = nameOrID + return m.resolveProjectResult, m.resolveProjectErr +} + +func (m *mockMilestoneClient) ResolveProjectMilestoneIdentifier(nameOrID, projectID string) (string, error) { + m.lastResolveMilestoneArg = nameOrID + return m.resolveMilestoneResult, m.resolveMilestoneErr +} + +func (m *mockMilestoneClient) MilestoneClient() *milestones.Client { return nil } +func (m *mockMilestoneClient) ProjectClient() *projects.Client { return nil } + +func newMilestoneService(client *mockMilestoneClient) *MilestoneService { + return NewMilestoneService(client, format.New()) +} + +func TestMilestoneService_List(t *testing.T) { + t.Run("requires a project", func(t *testing.T) { + client := &mockMilestoneClient{} + s := newMilestoneService(client) + + _, err := s.List(&MilestoneListInput{}, format.VerbosityCompact, format.OutputText) + if err == nil { + t.Fatal("expected error when no project is provided") + } + }) + + t.Run("resolves project and lists milestones", func(t *testing.T) { + client := &mockMilestoneClient{ + resolveProjectResult: "project-uuid", + listResult: []core.ProjectMilestone{ + {Name: "Alpha", Status: "done"}, + }, + } + s := newMilestoneService(client) + + result, err := s.List(&MilestoneListInput{ProjectID: "Q3 Launch"}, format.VerbosityCompact, format.OutputText) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if client.lastListProjectID != "project-uuid" { + t.Errorf("expected resolved project ID to be passed through, got %q", client.lastListProjectID) + } + if result == "" { + t.Error("expected non-empty rendered result") + } + }) + + t.Run("propagates client errors", func(t *testing.T) { + client := &mockMilestoneClient{ + resolveProjectResult: "project-uuid", + listErr: errors.New("boom"), + } + s := newMilestoneService(client) + + _, err := s.List(&MilestoneListInput{ProjectID: "Q3 Launch"}, format.VerbosityCompact, format.OutputText) + if err == nil { + t.Fatal("expected error to propagate from client") + } + }) +} + +func TestMilestoneService_Get(t *testing.T) { + client := &mockMilestoneClient{ + resolveMilestoneResult: "milestone-uuid", + getResult: &core.ProjectMilestone{Name: "Beta"}, + } + s := newMilestoneService(client) + + result, err := s.Get("Beta", "Q3 Launch", "", format.VerbosityFull, format.OutputText) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if client.lastResolveMilestoneArg != "Beta" { + t.Errorf("expected resolver to be called with 'Beta', got %q", client.lastResolveMilestoneArg) + } + if result == "" { + t.Error("expected non-empty rendered result") + } +} + +func TestMilestoneService_Create(t *testing.T) { + t.Run("requires input", func(t *testing.T) { + s := newMilestoneService(&mockMilestoneClient{}) + if _, err := s.Create(nil, format.VerbosityFull, format.OutputText); err == nil { + t.Fatal("expected error for nil input") + } + }) + + t.Run("requires a name", func(t *testing.T) { + s := newMilestoneService(&mockMilestoneClient{}) + _, err := s.Create(&CreateMilestoneInput{ProjectID: "Q3 Launch"}, format.VerbosityFull, format.OutputText) + if err == nil { + t.Fatal("expected error for empty name") + } + }) + + t.Run("resolves project and creates milestone", func(t *testing.T) { + client := &mockMilestoneClient{ + resolveProjectResult: "project-uuid", + createResult: &core.ProjectMilestone{Name: "Beta"}, + } + s := newMilestoneService(client) + + _, err := s.Create(&CreateMilestoneInput{ + Name: "Beta", + ProjectID: "Q3 Launch", + }, format.VerbosityFull, format.OutputText) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if client.lastCreateInput == nil || client.lastCreateInput.ProjectID != "project-uuid" { + t.Error("expected create input to use the resolved project ID") + } + }) +} + +func TestMilestoneService_Update(t *testing.T) { + t.Run("requires input", func(t *testing.T) { + s := newMilestoneService(&mockMilestoneClient{}) + if _, err := s.Update("Beta", nil, format.VerbosityFull, format.OutputText); err == nil { + t.Fatal("expected error for nil input") + } + }) + + t.Run("does not move milestone between projects", func(t *testing.T) { + // UpdateMilestoneInput has no ProjectID field: --project only scopes + // the lookup, it never becomes the mutation's projectId. + client := &mockMilestoneClient{ + resolveMilestoneResult: "milestone-uuid", + updateResult: &core.ProjectMilestone{Name: "Private beta"}, + } + s := newMilestoneService(client) + + name := "Private beta" + _, err := s.Update("Beta", &UpdateMilestoneInput{ + Name: &name, + LookupProjectID: "Q3 Launch", + }, format.VerbosityFull, format.OutputText) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if client.lastUpdateID != "milestone-uuid" { + t.Errorf("expected resolved milestone ID, got %q", client.lastUpdateID) + } + if client.lastUpdateInput.Name == nil || *client.lastUpdateInput.Name != "Private beta" { + t.Error("expected name to be passed through to the update input") + } + }) +} + +func TestMilestoneService_Delete(t *testing.T) { + client := &mockMilestoneClient{ + resolveMilestoneResult: "milestone-uuid", + } + s := newMilestoneService(client) + + _, err := s.Delete("Beta", "Q3 Launch", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if client.lastDeleteID != "milestone-uuid" { + t.Errorf("expected resolved milestone ID to be deleted, got %q", client.lastDeleteID) + } +} diff --git a/internal/service/search_resolve_test.go b/internal/service/search_resolve_test.go index d88a6e5..120b2ac 100644 --- a/internal/service/search_resolve_test.go +++ b/internal/service/search_resolve_test.go @@ -57,6 +57,9 @@ func (m *mockIssueClient) ResolveLabelIdentifier(label, team string) (string, er func (m *mockIssueClient) ResolveProjectIdentifier(nameOrID, teamID string) (string, error) { return m.resolveProjectResult, m.resolveProjectErr } +func (m *mockIssueClient) ResolveProjectMilestoneIdentifier(nameOrID, projectID string) (string, error) { + return nameOrID, nil +} func (m *mockIssueClient) CreateRelation(issueID, relatedIssueID string, relationType core.IssueRelationType) error { return nil } @@ -102,10 +105,13 @@ func (m *mockSearchClient) ResolveLabelIdentifier(label, team string) (string, e func (m *mockSearchClient) ResolveProjectIdentifier(nameOrID, teamID string) (string, error) { return m.resolveProjectResult, m.resolveProjectErr } -func (m *mockSearchClient) IssueClient() *issues.Client { return nil } -func (m *mockSearchClient) ProjectClient() *projects.Client { return nil } -func (m *mockSearchClient) TeamClient() *teams.Client { return nil } -func (m *mockSearchClient) WorkflowClient() *workflows.Client { return m.workflowClient } +func (m *mockSearchClient) ResolveProjectMilestoneIdentifier(nameOrID, projectID string) (string, error) { + return nameOrID, nil +} +func (m *mockSearchClient) IssueClient() *issues.Client { return nil } +func (m *mockSearchClient) ProjectClient() *projects.Client { return nil } +func (m *mockSearchClient) TeamClient() *teams.Client { return nil } +func (m *mockSearchClient) WorkflowClient() *workflows.Client { return m.workflowClient } // --- IssueService.Search tests --- diff --git a/internal/service/service.go b/internal/service/service.go index 64b56ed..8d372fd 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -12,6 +12,7 @@ import ( type Services struct { Issues *IssueService Projects *ProjectService + Milestones *MilestoneService Cycles *CycleService Teams *TeamService Users *UserService @@ -31,6 +32,7 @@ func New(client *linear.Client) *Services { return &Services{ Issues: NewIssueService(client, formatter), Projects: NewProjectService(client, formatter), + Milestones: NewMilestoneService(client, formatter), Cycles: NewCycleService(client, formatter), Teams: NewTeamService(client, formatter), Users: NewUserService(client, formatter), diff --git a/internal/skills/linear/SKILL.md b/internal/skills/linear/SKILL.md index d771f32..7c7c89c 100644 --- a/internal/skills/linear/SKILL.md +++ b/internal/skills/linear/SKILL.md @@ -58,6 +58,11 @@ linear i react 👍 # Add reaction linear p list [--mine] # List projects linear p create [flags] # Create project +# Milestones (alias: m) +linear m list --project # List project milestones +linear m get --project # Get milestone details +linear m create --project # Create milestone + # Cycles (alias: c) linear c list [--active] # List cycles linear c get # Get cycle (requires init) @@ -109,6 +114,7 @@ linear cycles analyze --team CEN --output json > velocity.json - `issues list`, `issues get` - `cycles list`, `cycles get`, `cycles analyze` - `projects list`, `projects get` +- `milestones list`, `milestones get`, `milestones create`, `milestones update` - `teams list`, `teams get`, `teams labels`, `teams states` - `users list`, `users get`, `users me` - `search` (all operations) @@ -192,6 +198,9 @@ linear search --state Backlog --has-blockers --team CEN # Customer-facing bugs in current cycle linear i list --labels customer,bug --cycle 65 --format full +# Issues in a project milestone +linear i list --project "Q3 Launch" --milestone Beta --format full + # Unassigned high-priority work linear search --priority 1 --assignee none --team CEN @@ -218,12 +227,30 @@ linear i create "Add OAuth integration" \ --estimate 5 \ --cycle 65 \ --project "Auth Revamp" \ + --milestone Beta \ --due 2026-02-01 # With description from file cat spec.md | linear i create "Feature title" --team CEN -d - ``` +## Milestone Operations + +```bash +# List milestones for a project +linear m list --project "Q3 Launch" + +# Create a milestone with a target date +linear m create Beta --project "Q3 Launch" --target-date 2026-08-01 + +# Rename a milestone +linear m update Beta --project "Q3 Launch" --name "Private beta" + +# Assign issues to a milestone +linear i create "Build invite flow" --project "Q3 Launch" --milestone Beta +linear i update CEN-123 --project "Q3 Launch" --milestone Beta +``` + ## Piping Support (Powerful!) **All description and body flags support stdin via `-`:** @@ -378,6 +405,7 @@ linear i list --creator me --team CEN - `-a, --assignee ` - Assign to user - `-c, --cycle ` - Cycle number - `-P, --project ` - Project name +- `-m, --milestone ` - Project milestone name or UUID - `-e, --estimate ` - Story points - `-l, --labels ` - Comma-separated - `-d, --description ` - Description (- for stdin) diff --git a/pkg/linear/client.go b/pkg/linear/client.go index c20f8d0..e73ed18 100644 --- a/pkg/linear/client.go +++ b/pkg/linear/client.go @@ -6,18 +6,19 @@ import ( "os" "github.com/joa23/linear-cli/internal/config" + "github.com/joa23/linear-cli/internal/oauth" + "github.com/joa23/linear-cli/internal/token" "github.com/joa23/linear-cli/pkg/linear/attachments" "github.com/joa23/linear-cli/pkg/linear/comments" "github.com/joa23/linear-cli/pkg/linear/core" "github.com/joa23/linear-cli/pkg/linear/cycles" "github.com/joa23/linear-cli/pkg/linear/identifiers" "github.com/joa23/linear-cli/pkg/linear/issues" + "github.com/joa23/linear-cli/pkg/linear/milestones" "github.com/joa23/linear-cli/pkg/linear/projects" "github.com/joa23/linear-cli/pkg/linear/teams" "github.com/joa23/linear-cli/pkg/linear/users" "github.com/joa23/linear-cli/pkg/linear/workflows" - "github.com/joa23/linear-cli/internal/oauth" - "github.com/joa23/linear-cli/internal/token" ) // Client represents the main Linear API client that orchestrates all sub-clients. @@ -36,6 +37,7 @@ type Client struct { Workflows *workflows.Client Attachments *attachments.Client Cycles *cycles.Client + Milestones *milestones.Client // Resolver for human-readable identifier translation resolver *Resolver @@ -71,6 +73,7 @@ func NewClientWithAuthMode(apiToken string, authMode string) *Client { Workflows: workflows.NewClient(base), Attachments: attachments.NewClient(base), Cycles: cycles.NewClient(base), + Milestones: milestones.NewClient(base), apiToken: apiToken, authMode: authMode, } @@ -150,6 +153,7 @@ func NewClientWithTokenPath(tokenPath string) *Client { Workflows: workflows.NewClient(base), Attachments: attachments.NewClient(base), Cycles: cycles.NewClient(base), + Milestones: milestones.NewClient(base), apiToken: apiToken, authMode: authMode, } @@ -231,6 +235,10 @@ func (c *Client) ProjectClient() *projects.Client { return c.Projects } +func (c *Client) MilestoneClient() *milestones.Client { + return c.Milestones +} + func (c *Client) TeamClient() *teams.Client { return c.Teams } @@ -483,6 +491,27 @@ func (c *Client) RemoveProjectMetadataKey(projectID, key string) error { return c.Projects.RemoveProjectMetadataKey(projectID, key) } +// Project milestone operations +func (c *Client) ListProjectMilestones(projectID string, limit int) ([]core.ProjectMilestone, error) { + return c.Milestones.List(projectID, limit) +} + +func (c *Client) GetProjectMilestone(id string) (*core.ProjectMilestone, error) { + return c.Milestones.Get(id) +} + +func (c *Client) CreateProjectMilestone(input *core.CreateProjectMilestoneInput) (*core.ProjectMilestone, error) { + return c.Milestones.Create(input) +} + +func (c *Client) UpdateProjectMilestone(id string, input *core.UpdateProjectMilestoneInput) (*core.ProjectMilestone, error) { + return c.Milestones.Update(id, input) +} + +func (c *Client) DeleteProjectMilestone(id string) error { + return c.Milestones.Delete(id) +} + // Cycle operations func (c *Client) GetCycle(cycleID string) (*core.Cycle, error) { return c.Cycles.GetCycle(cycleID) @@ -670,6 +699,10 @@ func (c *Client) ResolveProjectIdentifier(nameOrID string, teamID string) (strin return c.resolver.ResolveProject(nameOrID, teamID) } +func (c *Client) ResolveProjectMilestoneIdentifier(nameOrID string, projectID string) (string, error) { + return c.resolver.ResolveProjectMilestone(nameOrID, projectID) +} + // Issue search operations func (c *Client) SearchIssues(filters *core.IssueSearchFilters) (*core.IssueSearchResult, error) { return c.Issues.SearchIssuesEnhanced(filters) diff --git a/pkg/linear/core/types.go b/pkg/linear/core/types.go index 37b6f7f..02df252 100644 --- a/pkg/linear/core/types.go +++ b/pkg/linear/core/types.go @@ -101,14 +101,14 @@ func (u *User) UnmarshalJSON(data []byte) error { // Team represents a Linear team type Team struct { - ID string `json:"id"` - Name string `json:"name"` - Key string `json:"key"` - Description string `json:"description"` - IssueEstimationType string `json:"issueEstimationType,omitempty"` // notUsed, exponential, fibonacci, linear, tShirt - IssueEstimationAllowZero bool `json:"issueEstimationAllowZero,omitempty"` // Whether 0 is allowed as estimate - IssueEstimationExtended bool `json:"issueEstimationExtended,omitempty"` // Whether extended estimates are enabled - DefaultIssueEstimate *float64 `json:"defaultIssueEstimate,omitempty"` // Default estimate for new issues + ID string `json:"id"` + Name string `json:"name"` + Key string `json:"key"` + Description string `json:"description"` + IssueEstimationType string `json:"issueEstimationType,omitempty"` // notUsed, exponential, fibonacci, linear, tShirt + IssueEstimationAllowZero bool `json:"issueEstimationAllowZero,omitempty"` // Whether 0 is allowed as estimate + IssueEstimationExtended bool `json:"issueEstimationExtended,omitempty"` // Whether extended estimates are enabled + DefaultIssueEstimate *float64 `json:"defaultIssueEstimate,omitempty"` // Default estimate for new issues } // EstimateScale represents the available estimate values for a team @@ -191,35 +191,36 @@ type WorkflowState struct { // Issue represents a Linear issue type Issue struct { - ID string `json:"id"` - Identifier string `json:"identifier"` - Title string `json:"title"` - Description string `json:"description"` + ID string `json:"id"` + Identifier string `json:"identifier"` + Title string `json:"title"` + Description string `json:"description"` State struct { ID string `json:"id"` Name string `json:"name"` } `json:"state"` - Project *Project `json:"project,omitempty"` - Creator *User `json:"creator,omitempty"` - Assignee *User `json:"assignee,omitempty"` - Delegate *User `json:"delegate,omitempty"` // Agent user delegated to work on issue (OAuth apps) - Parent *ParentIssue `json:"parent,omitempty"` - Children ChildrenNodes `json:"children,omitempty"` - Cycle *CycleReference `json:"cycle,omitempty"` - Labels *LabelConnection `json:"labels,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` - Priority *int `json:"priority,omitempty"` - Estimate *float64 `json:"estimate,omitempty"` - DueDate *string `json:"dueDate,omitempty"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - URL string `json:"url"` - + Project *Project `json:"project,omitempty"` + ProjectMilestone *ProjectMilestone `json:"projectMilestone,omitempty"` + Creator *User `json:"creator,omitempty"` + Assignee *User `json:"assignee,omitempty"` + Delegate *User `json:"delegate,omitempty"` // Agent user delegated to work on issue (OAuth apps) + Parent *ParentIssue `json:"parent,omitempty"` + Children ChildrenNodes `json:"children,omitempty"` + Cycle *CycleReference `json:"cycle,omitempty"` + Labels *LabelConnection `json:"labels,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + Priority *int `json:"priority,omitempty"` + Estimate *float64 `json:"estimate,omitempty"` + DueDate *string `json:"dueDate,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + URL string `json:"url"` + // Attachment support - AttachmentCount int `json:"attachmentCount"` // Total number of attachments - HasAttachments bool `json:"hasAttachments"` // Computed: AttachmentCount > 0 - Attachments *AttachmentConnection `json:"attachments,omitempty"` - Comments *CommentConnection `json:"comments,omitempty"` + AttachmentCount int `json:"attachmentCount"` // Total number of attachments + HasAttachments bool `json:"hasAttachments"` // Computed: AttachmentCount > 0 + Attachments *AttachmentConnection `json:"attachments,omitempty"` + Comments *CommentConnection `json:"comments,omitempty"` } // AttachmentConnection represents a paginated collection of attachments @@ -255,10 +256,10 @@ type IssueCompact struct { ID string `json:"id"` Name string `json:"name"` } `json:"state"` - Assignee *User `json:"assignee,omitempty"` - Priority *int `json:"priority,omitempty"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + Assignee *User `json:"assignee,omitempty"` + Priority *int `json:"priority,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` Parent *struct { ID string `json:"id"` Identifier string `json:"identifier"` @@ -341,24 +342,24 @@ func (i *Issue) ToCompact() IssueCompact { // Attachment represents a file attachment on an issue // Based on Linear's GraphQL schema research type Attachment struct { - ID string `json:"id"` - URL string `json:"url"` - Title string `json:"title"` - Subtitle string `json:"subtitle,omitempty"` - Filename string `json:"filename,omitempty"` // From UploadFile if available - ContentType string `json:"contentType,omitempty"` // From UploadFile if available - Size int64 `json:"size,omitempty"` // From UploadFile if available - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - ArchivedAt *string `json:"archivedAt,omitempty"` - Creator *User `json:"creator,omitempty"` - ExternalCreator *ExternalUser `json:"externalUserCreator,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` // Custom metadata - Source map[string]interface{} `json:"source,omitempty"` // Source information - SourceType string `json:"sourceType,omitempty"` - GroupBySource bool `json:"groupBySource"` - Issue *Issue `json:"issue,omitempty"` // Parent issue - OriginalIssue *Issue `json:"originalIssue,omitempty"` // If moved/copied + ID string `json:"id"` + URL string `json:"url"` + Title string `json:"title"` + Subtitle string `json:"subtitle,omitempty"` + Filename string `json:"filename,omitempty"` // From UploadFile if available + ContentType string `json:"contentType,omitempty"` // From UploadFile if available + Size int64 `json:"size,omitempty"` // From UploadFile if available + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + ArchivedAt *string `json:"archivedAt,omitempty"` + Creator *User `json:"creator,omitempty"` + ExternalCreator *ExternalUser `json:"externalUserCreator,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` // Custom metadata + Source map[string]interface{} `json:"source,omitempty"` // Source information + SourceType string `json:"sourceType,omitempty"` + GroupBySource bool `json:"groupBySource"` + Issue *Issue `json:"issue,omitempty"` // Parent issue + OriginalIssue *Issue `json:"originalIssue,omitempty"` // If moved/copied } // ExternalUser represents a user from external systems (like Slack) @@ -387,30 +388,45 @@ type SubIssue struct { // ParentIssue represents a parent issue type ParentIssue struct { - ID string `json:"id"` - Identifier string `json:"identifier"` - Title string `json:"title"` - Description string `json:"description"` + ID string `json:"id"` + Identifier string `json:"identifier"` + Title string `json:"title"` + Description string `json:"description"` State struct { ID string `json:"id"` Name string `json:"name"` } `json:"state"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } // Project represents a Linear project type Project struct { ID string `json:"id"` Name string `json:"name"` - Description string `json:"description"` // Short description (255 char limit) - Content string `json:"content,omitempty"` // Long markdown content (no limit) - State string `json:"state"` // planned, started, completed, etc. + Description string `json:"description"` // Short description (255 char limit) + Content string `json:"content,omitempty"` // Long markdown content (no limit) + State string `json:"state"` // planned, started, completed, etc. Issues *IssueConnection `json:"issues,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } +// ProjectMilestone represents a milestone within a Linear project. +type ProjectMilestone struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + TargetDate string `json:"targetDate,omitempty"` + Status string `json:"status"` + Progress float64 `json:"progress"` + Project *Project `json:"project,omitempty"` + Issues *IssueConnection `json:"issues,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + ArchivedAt *string `json:"archivedAt,omitempty"` +} + // IssueConnection represents the GraphQL connection for issues type IssueConnection struct { Nodes []ProjectIssue `json:"nodes"` @@ -436,31 +452,46 @@ type ProjectIssue struct { Assignee *User `json:"assignee,omitempty"` } +// CreateProjectMilestoneInput represents the input for creating a project milestone. +type CreateProjectMilestoneInput struct { + Name string + Description string + ProjectID string + TargetDate string +} + +// UpdateProjectMilestoneInput represents the input for updating a project milestone. +type UpdateProjectMilestoneInput struct { + Name *string + Description *string + TargetDate *string +} + // Cycle represents a Linear cycle (sprint/iteration) type Cycle struct { - ID string `json:"id"` - Name string `json:"name"` - Number int `json:"number"` - Description string `json:"description,omitempty"` - StartsAt string `json:"startsAt"` - EndsAt string `json:"endsAt"` - CompletedAt *string `json:"completedAt,omitempty"` - Progress float64 `json:"progress"` - Team *Team `json:"team,omitempty"` - IsActive bool `json:"isActive"` - IsFuture bool `json:"isFuture"` - IsPast bool `json:"isPast"` - IsNext bool `json:"isNext"` - IsPrevious bool `json:"isPrevious"` - ScopeHistory []int `json:"scopeHistory,omitempty"` - CompletedScopeHistory []int `json:"completedScopeHistory,omitempty"` - CompletedIssueCountHistory []int `json:"completedIssueCountHistory,omitempty"` - InProgressScopeHistory []int `json:"inProgressScopeHistory,omitempty"` - IssueCountHistory []int `json:"issueCountHistory,omitempty"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - ArchivedAt *string `json:"archivedAt,omitempty"` - AutoArchivedAt *string `json:"autoArchivedAt,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Number int `json:"number"` + Description string `json:"description,omitempty"` + StartsAt string `json:"startsAt"` + EndsAt string `json:"endsAt"` + CompletedAt *string `json:"completedAt,omitempty"` + Progress float64 `json:"progress"` + Team *Team `json:"team,omitempty"` + IsActive bool `json:"isActive"` + IsFuture bool `json:"isFuture"` + IsPast bool `json:"isPast"` + IsNext bool `json:"isNext"` + IsPrevious bool `json:"isPrevious"` + ScopeHistory []int `json:"scopeHistory,omitempty"` + CompletedScopeHistory []int `json:"completedScopeHistory,omitempty"` + CompletedIssueCountHistory []int `json:"completedIssueCountHistory,omitempty"` + InProgressScopeHistory []int `json:"inProgressScopeHistory,omitempty"` + IssueCountHistory []int `json:"issueCountHistory,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + ArchivedAt *string `json:"archivedAt,omitempty"` + AutoArchivedAt *string `json:"autoArchivedAt,omitempty"` } // CycleMinimal represents a minimal cycle (~30 tokens) @@ -586,15 +617,15 @@ type CommentWithReplies struct { // Notification represents a Linear notification type Notification struct { - ID string `json:"id"` - Type string `json:"type"` - CreatedAt string `json:"createdAt"` - ReadAt *string `json:"readAt,omitempty"` - ArchivedAt *string `json:"archivedAt,omitempty"` - SnoozedUntilAt *string `json:"snoozedUntilAt,omitempty"` - User *User `json:"user,omitempty"` - Issue *NotificationIssue `json:"issue,omitempty"` - Comment *NotificationComment `json:"comment,omitempty"` + ID string `json:"id"` + Type string `json:"type"` + CreatedAt string `json:"createdAt"` + ReadAt *string `json:"readAt,omitempty"` + ArchivedAt *string `json:"archivedAt,omitempty"` + SnoozedUntilAt *string `json:"snoozedUntilAt,omitempty"` + User *User `json:"user,omitempty"` + Issue *NotificationIssue `json:"issue,omitempty"` + Comment *NotificationComment `json:"comment,omitempty"` } // NotificationIssue represents issue info in a notification @@ -613,36 +644,38 @@ type NotificationComment struct { // IssueCreateInput represents the input for creating an issue atomically. // All optional fields are resolved to UUIDs by the service layer before populating this struct. type IssueCreateInput struct { - Title string - Description string - TeamID string - AssigneeID string - CycleID string - DueDate string - Estimate *float64 - LabelIDs []string - ParentID string - Priority *int - ProjectID string - StateID string + Title string + Description string + TeamID string + AssigneeID string + CycleID string + DueDate string + Estimate *float64 + LabelIDs []string + ParentID string + Priority *int + ProjectID string + ProjectMilestoneID string + StateID string } // UpdateIssueInput represents the input for updating an issue // All fields are optional to support partial updates type UpdateIssueInput struct { - Title *string `json:"title,omitempty"` - Description *string `json:"description,omitempty"` - Priority *int `json:"priority,omitempty"` // 0 = No priority, 1 = Urgent, 2 = High, 3 = Medium, 4 = Low - Estimate *float64 `json:"estimate,omitempty"` // Story points estimate - DueDate *string `json:"dueDate,omitempty"` // ISO 8601 date format - StateID *string `json:"stateId,omitempty"` // Workflow state ID - AssigneeID *string `json:"assigneeId,omitempty"` // User ID to assign to (for human users) - DelegateID *string `json:"delegateId,omitempty"` // Application ID to delegate to (for OAuth apps) - ProjectID *string `json:"projectId,omitempty"` // Project ID to move issue to - ParentID *string `json:"parentId,omitempty"` // Parent issue ID for sub-issues - TeamID *string `json:"teamId,omitempty"` // Team ID to move issue to - CycleID *string `json:"cycleId,omitempty"` // Cycle ID to move issue to - LabelIDs []string `json:"labelIds,omitempty"` // Label IDs to apply + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Priority *int `json:"priority,omitempty"` // 0 = No priority, 1 = Urgent, 2 = High, 3 = Medium, 4 = Low + Estimate *float64 `json:"estimate,omitempty"` // Story points estimate + DueDate *string `json:"dueDate,omitempty"` // ISO 8601 date format + StateID *string `json:"stateId,omitempty"` // Workflow state ID + AssigneeID *string `json:"assigneeId,omitempty"` // User ID to assign to (for human users) + DelegateID *string `json:"delegateId,omitempty"` // Application ID to delegate to (for OAuth apps) + ProjectID *string `json:"projectId,omitempty"` // Project ID to move issue to + ProjectMilestoneID *string `json:"projectMilestoneId,omitempty"` // Project milestone ID + ParentID *string `json:"parentId,omitempty"` // Parent issue ID for sub-issues + TeamID *string `json:"teamId,omitempty"` // Team ID to move issue to + CycleID *string `json:"cycleId,omitempty"` // Cycle ID to move issue to + LabelIDs []string `json:"labelIds,omitempty"` // Label IDs to apply } // IssueFilter represents filter options for listing issues @@ -652,12 +685,13 @@ type IssueFilter struct { After string `json:"after,omitempty"` // Cursor for pagination // Filters - StateIDs []string `json:"stateIds,omitempty"` // Filter by workflow state IDs - AssigneeID string `json:"assigneeId,omitempty"` // Filter by assignee user ID - LabelIDs []string `json:"labelIds,omitempty"` // Filter by label IDs - ExcludeLabelIDs []string `json:"excludeLabelIds,omitempty"` // Exclude issues with these label IDs - ProjectID string `json:"projectId,omitempty"` // Filter by project ID - TeamID string `json:"teamId,omitempty"` // Filter by team ID + StateIDs []string `json:"stateIds,omitempty"` // Filter by workflow state IDs + AssigneeID string `json:"assigneeId,omitempty"` // Filter by assignee user ID + LabelIDs []string `json:"labelIds,omitempty"` // Filter by label IDs + ExcludeLabelIDs []string `json:"excludeLabelIds,omitempty"` // Exclude issues with these label IDs + ProjectID string `json:"projectId,omitempty"` // Filter by project ID + ProjectMilestoneID string `json:"projectMilestoneId,omitempty"` // Filter by project milestone ID + TeamID string `json:"teamId,omitempty"` // Filter by team ID // Date filters (ISO-8601 timestamps) CreatedAfter string `json:"createdAfter,omitempty"` // Issues created on/after this timestamp @@ -681,19 +715,20 @@ type ListAllIssuesResult struct { // IssueWithDetails represents an issue with full details including metadata type IssueWithDetails struct { - ID string `json:"id"` - Identifier string `json:"identifier"` - Title string `json:"title"` - Description string `json:"description"` - Priority int `json:"priority"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - State WorkflowState `json:"state"` - Assignee *User `json:"assignee,omitempty"` - Labels []Label `json:"labels"` - Project *Project `json:"project,omitempty"` - Team Team `json:"team"` - Metadata *map[string]interface{} `json:"metadata,omitempty"` + ID string `json:"id"` + Identifier string `json:"identifier"` + Title string `json:"title"` + Description string `json:"description"` + Priority int `json:"priority"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + State WorkflowState `json:"state"` + Assignee *User `json:"assignee,omitempty"` + Labels []Label `json:"labels"` + Project *Project `json:"project,omitempty"` + ProjectMilestone *ProjectMilestone `json:"projectMilestone,omitempty"` + Team Team `json:"team"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` } // Label represents a Linear label @@ -738,10 +773,10 @@ type LabelConnection struct { type UserFilter struct { // Filter by team membership TeamID string `json:"teamId,omitempty"` - + // Filter by active status (nil means include all) ActiveOnly *bool `json:"activeOnly,omitempty"` - + // Pagination First int `json:"first"` // Number of items to fetch (default 50, max 250) After string `json:"after,omitempty"` // Cursor for pagination @@ -762,6 +797,9 @@ type IssueSearchFilters struct { // Project filter ProjectID string `json:"projectId,omitempty"` + // Project milestone filter + ProjectMilestoneID string `json:"projectMilestoneId,omitempty"` + // Identifier filter (e.g., "CEN-123") Identifier string `json:"identifier,omitempty"` @@ -769,7 +807,7 @@ type IssueSearchFilters struct { StateIDs []string `json:"stateIds,omitempty"` // Label filters - LabelIDs []string `json:"labelIds,omitempty"` + LabelIDs []string `json:"labelIds,omitempty"` ExcludeLabelIDs []string `json:"excludeLabelIds,omitempty"` // Assignee filter @@ -813,11 +851,11 @@ type IssueSearchResult struct { // BatchIssueUpdate represents update fields for batch operations type BatchIssueUpdate struct { - StateID string `json:"stateId,omitempty"` - AssigneeID string `json:"assigneeId,omitempty"` - LabelIDs []string `json:"labelIds,omitempty"` - Priority *int `json:"priority,omitempty"` - ProjectID string `json:"projectId,omitempty"` + StateID string `json:"stateId,omitempty"` + AssigneeID string `json:"assigneeId,omitempty"` + LabelIDs []string `json:"labelIds,omitempty"` + Priority *int `json:"priority,omitempty"` + ProjectID string `json:"projectId,omitempty"` } // BatchIssueUpdateResult represents the result of a batch update operation @@ -855,14 +893,14 @@ type IssueRelationConnection struct { // IssueWithRelations extends Issue with relation information type IssueWithRelations struct { - ID string `json:"id"` - Identifier string `json:"identifier"` - Title string `json:"title"` - State struct { + ID string `json:"id"` + Identifier string `json:"identifier"` + Title string `json:"title"` + State struct { ID string `json:"id"` Name string `json:"name"` } `json:"state"` - Project *struct { + Project *struct { ID string `json:"id"` Name string `json:"name"` } `json:"project"` @@ -876,4 +914,4 @@ type PaginationInput struct { Limit int `json:"limit"` // Number of items per page Sort string `json:"sort"` // Sort field: priority|created|updated Direction string `json:"direction"` // Sort direction: asc|desc -} \ No newline at end of file +} diff --git a/pkg/linear/issues/client.go b/pkg/linear/issues/client.go index 1631f88..ef2703d 100644 --- a/pkg/linear/issues/client.go +++ b/pkg/linear/issues/client.go @@ -80,6 +80,12 @@ linear_create_issue("Task title", "Description", teams[0].id)`) id name } + projectMilestone { + id + name + targetDate + status + } parent { id identifier @@ -118,6 +124,9 @@ linear_create_issue("Task title", "Description", teams[0].id)`) if input.ProjectID != "" { gqlInput["projectId"] = input.ProjectID } + if input.ProjectMilestoneID != "" { + gqlInput["projectMilestoneId"] = input.ProjectMilestoneID + } if input.ParentID != "" { gqlInput["parentId"] = input.ParentID } @@ -140,23 +149,23 @@ linear_create_issue("Task title", "Description", teams[0].id)`) variables := map[string]interface{}{ "input": gqlInput, } - + var response struct { IssueCreate struct { - Success bool `json:"success"` + Success bool `json:"success"` Issue core.Issue `json:"issue"` } `json:"issueCreate"` } - + err := ic.base.ExecuteRequest(mutation, variables, &response) if err != nil { return nil, fmt.Errorf("failed to create issue: %w", err) } - + if !response.IssueCreate.Success { return nil, fmt.Errorf("issue creation was not successful") } - + // Extract metadata from description if present // Why: We store metadata in issue descriptions as hidden markdown. // After creating an issue, we need to extract this metadata to populate @@ -166,7 +175,7 @@ linear_create_issue("Task title", "Description", teams[0].id)`) response.IssueCreate.Issue.Metadata = metadata response.IssueCreate.Issue.Description = cleanDesc } - + return &response.IssueCreate.Issue, nil } @@ -180,7 +189,7 @@ func (ic *Client) GetIssue(issueID string) (*core.Issue, error) { if issueID == "" { return nil, &core.ValidationError{Field: "issueID", Message: "issueID cannot be empty"} } - + const query = ` query GetIssue($id: String!) { issue(id: $id) { @@ -233,6 +242,12 @@ func (ic *Client) GetIssue(issueID string) (*core.Issue, error) { id name } + projectMilestone { + id + name + targetDate + status + } parent { id identifier @@ -275,11 +290,11 @@ func (ic *Client) GetIssue(issueID string) (*core.Issue, error) { } } ` - + variables := map[string]interface{}{ "id": issueID, } - + var response struct { Issue core.Issue `json:"issue"` } @@ -342,7 +357,7 @@ func (ic *Client) getIssueWithProjectContextInternal(issueID string) (*core.Issu if issueID == "" { return nil, &core.ValidationError{Field: "issueID", Message: "issueID cannot be empty"} } - + const query = ` query GetIssueWithProject($id: String!) { issue(id: $id) { @@ -399,6 +414,12 @@ func (ic *Client) getIssueWithProjectContextInternal(issueID string) (*core.Issu createdAt updatedAt } + projectMilestone { + id + name + targetDate + status + } parent { id identifier @@ -454,14 +475,14 @@ func (ic *Client) getIssueWithProjectContextInternal(issueID string) (*core.Issu if err != nil { return nil, fmt.Errorf("failed to get issue with project context: %w", err) } - + // Extract metadata from issue description if response.Issue.Description != "" { metadata, cleanDesc := metadata.ExtractMetadataFromDescription(response.Issue.Description) response.Issue.Metadata = metadata response.Issue.Description = cleanDesc } - + // Extract metadata from project description if project exists // Why: Projects can also have metadata. When fetching project context, // we want to ensure project metadata is also extracted and available. @@ -470,7 +491,7 @@ func (ic *Client) getIssueWithProjectContextInternal(issueID string) (*core.Issu response.Issue.Project.Metadata = projectMetadata response.Issue.Project.Description = cleanProjectDesc } - + return &response.Issue, nil } @@ -500,7 +521,7 @@ func (ic *Client) getIssueWithParentContextInternal(issueID string) (*core.Issue if issueID == "" { return nil, &core.ValidationError{Field: "issueID", Message: "issueID cannot be empty"} } - + const query = ` query GetIssueWithParent($id: String!) { issue(id: $id) { @@ -553,6 +574,12 @@ func (ic *Client) getIssueWithParentContextInternal(issueID string) (*core.Issue id name } + projectMilestone { + id + name + targetDate + status + } parent { id identifier @@ -613,14 +640,14 @@ func (ic *Client) getIssueWithParentContextInternal(issueID string) (*core.Issue if err != nil { return nil, fmt.Errorf("failed to get issue with parent context: %w", err) } - + // Extract metadata from issue description if response.Issue.Description != "" { metadata, cleanDesc := metadata.ExtractMetadataFromDescription(response.Issue.Description) response.Issue.Metadata = metadata response.Issue.Description = cleanDesc } - + // Extract metadata from parent description if parent exists // Why: Parent issues may contain metadata that provides context for // sub-tasks. Extracting it ensures complete metadata visibility. @@ -629,7 +656,7 @@ func (ic *Client) getIssueWithParentContextInternal(issueID string) (*core.Issue response.Issue.Parent.Metadata = parentMetadata response.Issue.Parent.Description = cleanParentDesc } - + return &response.Issue, nil } @@ -646,7 +673,7 @@ func (ic *Client) UpdateIssueState(issueID, stateID string) error { if stateID == "" { return &core.ValidationError{Field: "stateID", Message: "stateID cannot be empty"} } - + const mutation = ` mutation UpdateIssueState($issueId: String!, $stateId: String!) { issueUpdate( @@ -664,12 +691,12 @@ func (ic *Client) UpdateIssueState(issueID, stateID string) error { } } ` - + variables := map[string]interface{}{ "issueId": issueID, "stateId": stateID, } - + var response struct { IssueUpdate struct { Success bool `json:"success"` @@ -682,19 +709,19 @@ func (ic *Client) UpdateIssueState(issueID, stateID string) error { } `json:"issue"` } `json:"issueUpdate"` } - + err := ic.base.ExecuteRequest(mutation, variables, &response) if err != nil { // Check if this is a state ID not found error // Why: The Linear API returns specific error messages when state IDs // are invalid. We want to provide helpful guidance to users. - if strings.Contains(err.Error(), "Entity not found in validateAccess: stateId") || - strings.Contains(err.Error(), "does not exist") && strings.Contains(err.Error(), "state") { + if strings.Contains(err.Error(), "Entity not found in validateAccess: stateId") || + strings.Contains(err.Error(), "does not exist") && strings.Contains(err.Error(), "state") { return guidance.InvalidStateIDError(stateID, err) } return guidance.EnhanceGenericError("update issue state", err) } - + if !response.IssueUpdate.Success { return guidance.OperationFailedError("Update issue state", "issue", []string{ "Verify the issue ID exists using linear_get_issue", @@ -702,7 +729,7 @@ func (ic *Client) UpdateIssueState(issueID, stateID string) error { "Ensure you have permission to update this issue", }) } - + return nil } @@ -713,7 +740,7 @@ func (ic *Client) AssignIssue(issueID, assigneeID string) error { if issueID == "" { return &core.ValidationError{Field: "issueID", Message: "issueID cannot be empty"} } - + const mutation = ` mutation AssignIssue($issueId: String!, $assigneeId: String) { issueUpdate( @@ -752,22 +779,22 @@ func (ic *Client) AssignIssue(issueID, assigneeID string) error { "issueId": issueID, "assigneeId": assigneeInput, } - + var response struct { IssueUpdate struct { Success bool `json:"success"` } `json:"issueUpdate"` } - + err := ic.base.ExecuteRequest(mutation, variables, &response) if err != nil { return fmt.Errorf("failed to assign issue: %w", err) } - + if !response.IssueUpdate.Success { return fmt.Errorf("issue assignment was not successful") } - + return nil } @@ -781,7 +808,7 @@ func (ic *Client) ListAssignedIssues(limit int) ([]core.Issue, error) { if limit <= 0 { limit = 50 } - + const query = ` query ListAssignedIssues($filter: IssueFilter, $first: Int) { issues(filter: $filter, first: $first) { @@ -831,7 +858,7 @@ func (ic *Client) ListAssignedIssues(limit int) ([]core.Issue, error) { } } ` - + // Filter for issues assigned to the current user // Why: The "me" identifier is Linear's way of referring to the // authenticated user without needing to know their specific ID. @@ -842,23 +869,23 @@ func (ic *Client) ListAssignedIssues(limit int) ([]core.Issue, error) { }, }, } - + variables := map[string]interface{}{ "filter": filter, "first": limit, } - + var response struct { Issues struct { Nodes []core.Issue `json:"nodes"` } `json:"issues"` } - + err := ic.base.ExecuteRequest(query, variables, &response) if err != nil { return nil, fmt.Errorf("failed to list assigned issues: %w", err) } - + // Extract metadata from descriptions // Why: Each issue might have metadata. We extract it here to ensure // consistent metadata access across all retrieval methods. @@ -869,7 +896,7 @@ func (ic *Client) ListAssignedIssues(limit int) ([]core.Issue, error) { response.Issues.Nodes[i].Description = cleanDesc } } - + return response.Issues.Nodes, nil } @@ -884,7 +911,7 @@ func (ic *Client) SearchIssuesEnhanced(filters *core.IssueSearchFilters) (*core. if filters.Limit <= 0 { filters.Limit = 10 // Reduced from 50 to minimize token usage } - + const query = ` query SearchIssuesEnhanced($filter: IssueFilter, $first: Int, $after: String, $includeArchived: Boolean, $orderBy: PaginationOrderBy) { issues(filter: $filter, first: $first, after: $after, includeArchived: $includeArchived, orderBy: $orderBy) { @@ -926,6 +953,12 @@ func (ic *Client) SearchIssuesEnhanced(filters *core.IssueSearchFilters) (*core. id name } + projectMilestone { + id + name + targetDate + status + } cycle { id name @@ -958,7 +991,7 @@ func (ic *Client) SearchIssuesEnhanced(filters *core.IssueSearchFilters) (*core. // Build filter object filter := make(map[string]interface{}) - + // Team filter if filters.TeamID != "" { // Linear's team filter requires IDComparator format @@ -994,7 +1027,7 @@ func (ic *Client) SearchIssuesEnhanced(filters *core.IssueSearchFilters) (*core. }, } } - + // Label filters (include and/or exclude) hasIncludeLabels := len(filters.LabelIDs) > 0 hasExcludeLabels := len(filters.ExcludeLabelIDs) > 0 @@ -1029,7 +1062,7 @@ func (ic *Client) SearchIssuesEnhanced(filters *core.IssueSearchFilters) (*core. }, } } - + // Assignee filter if filters.AssigneeID != "" { filter["assignee"] = map[string]interface{}{ @@ -1038,7 +1071,7 @@ func (ic *Client) SearchIssuesEnhanced(filters *core.IssueSearchFilters) (*core. }, } } - + // Priority filter if filters.Priority != nil { filter["priority"] = map[string]interface{}{ @@ -1046,11 +1079,11 @@ func (ic *Client) SearchIssuesEnhanced(filters *core.IssueSearchFilters) (*core. } } - // Project filter - if filters.ProjectID != "" { - filter["project"] = map[string]interface{}{ + // Project milestone filter + if filters.ProjectMilestoneID != "" { + filter["projectMilestone"] = map[string]interface{}{ "id": map[string]interface{}{ - "eq": filters.ProjectID, + "eq": filters.ProjectMilestoneID, }, } } @@ -1096,7 +1129,7 @@ func (ic *Client) SearchIssuesEnhanced(filters *core.IssueSearchFilters) (*core. } filter["updatedAt"].(map[string]interface{})["lte"] = filters.UpdatedBefore } - + variables := map[string]interface{}{ "first": filters.Limit, } @@ -1113,7 +1146,7 @@ func (ic *Client) SearchIssuesEnhanced(filters *core.IssueSearchFilters) (*core. // Always include the includeArchived parameter (defaults to false) variables["includeArchived"] = filters.IncludeArchived - + // Add orderBy if specified if filters.OrderBy != "" { variables["orderBy"] = filters.OrderBy @@ -1128,12 +1161,12 @@ func (ic *Client) SearchIssuesEnhanced(filters *core.IssueSearchFilters) (*core. } `json:"pageInfo"` } `json:"issues"` } - + err := ic.base.ExecuteRequest(query, variables, &response) if err != nil { return nil, fmt.Errorf("failed to search issues: %w", err) } - + return &core.IssueSearchResult{ Issues: response.Issues.Nodes, HasNextPage: response.Issues.PageInfo.HasNextPage, @@ -1148,7 +1181,7 @@ func (ic *Client) BatchUpdateIssues(issueIDs []string, update core.BatchIssueUpd if len(issueIDs) == 0 { return nil, fmt.Errorf("no issue IDs provided") } - + const mutation = ` mutation BatchUpdateIssues($issueIds: [String!]!, $input: IssueUpdateInput!) { issueBatchUpdate(ids: $issueIds, input: $input) { @@ -1191,10 +1224,10 @@ func (ic *Client) BatchUpdateIssues(issueIDs []string, update core.BatchIssueUpd } } ` - + // Build the update input input := make(map[string]interface{}) - + if update.StateID != "" { input["stateId"] = update.StateID } @@ -1210,25 +1243,25 @@ func (ic *Client) BatchUpdateIssues(issueIDs []string, update core.BatchIssueUpd if update.ProjectID != "" { input["projectId"] = update.ProjectID } - + if len(input) == 0 { return nil, fmt.Errorf("no update fields provided") } - + variables := map[string]interface{}{ "issueIds": issueIDs, "input": input, } - + var response struct { IssueBatchUpdate core.BatchIssueUpdateResult `json:"issueBatchUpdate"` } - + err := ic.base.ExecuteRequest(mutation, variables, &response) if err != nil { return nil, fmt.Errorf("failed to batch update issues: %w", err) } - + return &response.IssueBatchUpdate, nil } @@ -1240,13 +1273,13 @@ func (ic *Client) GetIssueWithBestContext(issueID string) (*core.Issue, error) { if issueID == "" { return nil, &core.ValidationError{Field: "issueID", Message: "issueID cannot be empty"} } - + // First, get basic issue info to determine what context to fetch issue, err := ic.GetIssue(issueID) if err != nil { return nil, fmt.Errorf("failed to get issue: %w", err) } - + // Determine the best context based on what the issue has if issue.Parent != nil && issue.Parent.ID != "" { // Issue has a parent - fetch with parent context for sibling information @@ -1256,7 +1289,7 @@ func (ic *Client) GetIssueWithBestContext(issueID string) (*core.Issue, error) { return issue, nil } return parentContextIssue, nil - + } else if issue.Project != nil && issue.Project.ID != "" { // Issue has a project but no parent - fetch with project context projectContextIssue, err := ic.GetIssueWithProjectContext(issueID) @@ -1266,7 +1299,7 @@ func (ic *Client) GetIssueWithBestContext(issueID string) (*core.Issue, error) { } return projectContextIssue, nil } - + // Standalone issue - we already have all the data we need return issue, nil } @@ -1278,7 +1311,7 @@ func (ic *Client) GetSubIssues(parentIssueID string) ([]core.SubIssue, error) { if parentIssueID == "" { return nil, &core.ValidationError{Field: "parentIssueID", Message: "parentIssueID cannot be empty"} } - + const query = ` query GetSubIssues($id: String!) { issue(id: $id) { @@ -1296,11 +1329,11 @@ func (ic *Client) GetSubIssues(parentIssueID string) ([]core.SubIssue, error) { } } ` - + variables := map[string]interface{}{ "id": parentIssueID, } - + var response struct { Issue struct { Children struct { @@ -1308,12 +1341,12 @@ func (ic *Client) GetSubIssues(parentIssueID string) ([]core.SubIssue, error) { } `json:"children"` } `json:"issue"` } - + err := ic.base.ExecuteRequest(query, variables, &response) if err != nil { return nil, fmt.Errorf("failed to get sub-issues: %w", err) } - + return response.Issue.Children.Nodes, nil } @@ -1324,7 +1357,7 @@ func (ic *Client) UpdateIssueDescription(issueID, newDescription string) error { if issueID == "" { return &core.ValidationError{Field: "issueID", Message: "issueID cannot be empty"} } - + // First, get the current issue to preserve metadata // Why: We need to extract existing metadata before updating the description // to ensure we don't lose any stored metadata during the update. @@ -1332,7 +1365,7 @@ func (ic *Client) UpdateIssueDescription(issueID, newDescription string) error { if err != nil { return fmt.Errorf("failed to get current issue: %w", err) } - + // Preserve existing metadata // Why: The issue.Metadata field contains the extracted metadata from the // current description. We need to inject this back into the new description. @@ -1340,7 +1373,7 @@ func (ic *Client) UpdateIssueDescription(issueID, newDescription string) error { if issue.Metadata != nil && len(issue.Metadata) > 0 { descriptionWithMetadata = metadata.InjectMetadataIntoDescription(newDescription, issue.Metadata) } - + const mutation = ` mutation UpdateIssueDescription($issueId: String!, $description: String!) { issueUpdate( @@ -1351,27 +1384,27 @@ func (ic *Client) UpdateIssueDescription(issueID, newDescription string) error { } } ` - + variables := map[string]interface{}{ "issueId": issueID, "description": descriptionWithMetadata, } - + var response struct { IssueUpdate struct { Success bool `json:"success"` } `json:"issueUpdate"` } - + err = ic.base.ExecuteRequest(mutation, variables, &response) if err != nil { return fmt.Errorf("failed to update issue description: %w", err) } - + if !response.IssueUpdate.Success { return fmt.Errorf("issue description update was not successful") } - + return nil } @@ -1396,7 +1429,7 @@ func (ic *Client) UpdateIssueMetadataKey(issueID, key string, value interface{}) if err != nil { return fmt.Errorf("failed to get current issue: %w", err) } - + // Initialize metadata if needed and update the key // Why: The issue might not have any metadata yet. We initialize // it as an empty map if needed before adding the new key. @@ -1404,12 +1437,12 @@ func (ic *Client) UpdateIssueMetadataKey(issueID, key string, value interface{}) issue.Metadata = make(map[string]interface{}) } issue.Metadata[key] = value - + // Update the description with new metadata // Why: Metadata is stored in the description field. We need to // inject the updated metadata back into the description. descriptionWithMetadata := metadata.InjectMetadataIntoDescription(issue.Description, issue.Metadata) - + const mutation = ` mutation UpdateIssueDescription($issueId: String!, $description: String!) { issueUpdate( @@ -1420,27 +1453,27 @@ func (ic *Client) UpdateIssueMetadataKey(issueID, key string, value interface{}) } } ` - + variables := map[string]interface{}{ "issueId": issueID, "description": descriptionWithMetadata, } - + var response struct { IssueUpdate struct { Success bool `json:"success"` } `json:"issueUpdate"` } - + err = ic.base.ExecuteRequest(mutation, variables, &response) if err != nil { return fmt.Errorf("failed to update issue metadata: %w", err) } - + if !response.IssueUpdate.Success { return fmt.Errorf("issue metadata update was not successful") } - + return nil } @@ -1454,13 +1487,13 @@ func (ic *Client) RemoveIssueMetadataKey(issueID, key string) error { if key == "" { return &core.ValidationError{Field: "key", Message: "key cannot be empty"} } - + // Get current issue issue, err := ic.GetIssue(issueID) if err != nil { return fmt.Errorf("failed to get current issue: %w", err) } - + // Remove the key if metadata exists // Why: We only proceed if there's metadata and the key exists. // No need to update if there's nothing to remove. @@ -1530,7 +1563,7 @@ func (ic *Client) GetIssueSimplified(issueID string) (*core.Issue, error) { if issueID == "" { return nil, &core.ValidationError{Field: "issueID", Message: "issueID cannot be empty"} } - + const query = ` query GetIssueSimplified($id: String!) { issue(id: $id) { @@ -1583,6 +1616,12 @@ func (ic *Client) GetIssueSimplified(issueID string) (*core.Issue, error) { id name } + projectMilestone { + id + name + targetDate + status + } parent { id identifier @@ -1591,30 +1630,30 @@ func (ic *Client) GetIssueSimplified(issueID string) (*core.Issue, error) { } } ` - + variables := map[string]interface{}{ "id": issueID, } - + var response struct { Issue core.Issue `json:"issue"` } - + err := ic.base.ExecuteRequest(query, variables, &response) if err != nil { return nil, fmt.Errorf("failed to get issue (simplified): %w", err) } - + // Extract metadata from description if response.Issue.Description != "" { metadata, cleanDesc := metadata.ExtractMetadataFromDescription(response.Issue.Description) response.Issue.Metadata = metadata response.Issue.Description = cleanDesc } - + // Initialize empty children to maintain consistency response.Issue.Children.Nodes = []core.SubIssue{} - + return &response.Issue, nil } @@ -1628,7 +1667,7 @@ func (ic *Client) GetIssueWithFallback(issueID string) (*core.Issue, error) { if err == nil { return issue, nil } - + // Check if it's a server error (500) or complexity error // Need to unwrap the error to check for HTTPError var httpErr *core.HTTPError @@ -1636,7 +1675,7 @@ func (ic *Client) GetIssueWithFallback(issueID string) (*core.Issue, error) { // Try simplified query return ic.GetIssueSimplified(issueID) } - + // For other errors, return the original error return nil, err } @@ -1650,31 +1689,31 @@ func (ic *Client) UpdateIssue(issueID string, input core.UpdateIssueInput) (*cor if issueID == "" { return nil, &core.ValidationError{Field: "issueID", Message: "issueID cannot be empty"} } - + // Check if there are any fields to update if !hasFieldsToUpdate(input) { return nil, &core.ValidationError{Field: "input", Message: "no fields to update"} } - + // Validate priority if provided if input.Priority != nil && (*input.Priority < 0 || *input.Priority > 4) { return nil, &core.ValidationError{Field: "priority", Message: fmt.Sprintf("invalid priority value: %d (must be between 0-4)", *input.Priority)} } - + // If updating description, preserve existing metadata if input.Description != nil { issue, err := ic.GetIssue(issueID) if err != nil { return nil, fmt.Errorf("failed to get current issue for metadata preservation: %w", err) } - + // Preserve metadata in the new description if issue.Metadata != nil && len(issue.Metadata) > 0 { descWithMetadata := metadata.InjectMetadataIntoDescription(*input.Description, issue.Metadata) input.Description = &descWithMetadata } } - + // Build the GraphQL mutation const mutation = ` mutation UpdateIssue($issueId: String!, $input: IssueUpdateInput!) { @@ -1711,6 +1750,12 @@ func (ic *Client) UpdateIssue(issueID string, input core.UpdateIssueInput) (*cor id name } + projectMilestone { + id + name + targetDate + status + } createdAt updatedAt url @@ -1737,38 +1782,38 @@ func (ic *Client) UpdateIssue(issueID string, input core.UpdateIssueInput) (*cor } } ` - + // Build the input object updateInput := buildUpdateInput(input) - + variables := map[string]interface{}{ "issueId": issueID, "input": updateInput, } - + var response struct { IssueUpdate struct { - Success bool `json:"success"` + Success bool `json:"success"` Issue core.Issue `json:"issue"` } `json:"issueUpdate"` } - + err := ic.base.ExecuteRequest(mutation, variables, &response) if err != nil { return nil, fmt.Errorf("failed to update issue: %w", err) } - + if !response.IssueUpdate.Success { return nil, fmt.Errorf("issue update was not successful") } - + // Extract metadata from description if present if response.IssueUpdate.Issue.Description != "" { metadata, cleanDesc := metadata.ExtractMetadataFromDescription(response.IssueUpdate.Issue.Description) response.IssueUpdate.Issue.Metadata = metadata response.IssueUpdate.Issue.Description = cleanDesc } - + return &response.IssueUpdate.Issue, nil } @@ -1783,6 +1828,7 @@ func hasFieldsToUpdate(input core.UpdateIssueInput) bool { input.AssigneeID != nil || input.DelegateID != nil || input.ProjectID != nil || + input.ProjectMilestoneID != nil || input.ParentID != nil || input.TeamID != nil || input.CycleID != nil || @@ -1814,6 +1860,9 @@ func buildUpdateInput(input core.UpdateIssueInput) map[string]interface{} { if input.ProjectID != nil { updateInput["projectId"] = *input.ProjectID } + if input.ProjectMilestoneID != nil { + updateInput["projectMilestoneId"] = *input.ProjectMilestoneID + } if input.ParentID != nil { updateInput["parentId"] = *input.ParentID } @@ -1931,6 +1980,12 @@ func (ic *Client) ListAllIssues(filter *core.IssueFilter) (*core.ListAllIssuesRe createdAt updatedAt } + projectMilestone { + id + name + targetDate + status + } team { id name @@ -1967,20 +2022,21 @@ func (ic *Client) ListAllIssues(filter *core.IssueFilter) (*core.ListAllIssuesRe var response struct { Issues struct { Nodes []struct { - ID string `json:"id"` - Identifier string `json:"identifier"` - Title string `json:"title"` - Description string `json:"description"` - Priority int `json:"priority"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + ID string `json:"id"` + Identifier string `json:"identifier"` + Title string `json:"title"` + Description string `json:"description"` + Priority int `json:"priority"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` State core.WorkflowState `json:"state"` Assignee *core.User `json:"assignee"` Labels struct { Nodes []core.Label `json:"nodes"` } `json:"labels"` - Project *core.Project `json:"project"` - Team core.Team `json:"team"` + Project *core.Project `json:"project"` + ProjectMilestone *core.ProjectMilestone `json:"projectMilestone"` + Team core.Team `json:"team"` } `json:"nodes"` PageInfo struct { HasNextPage bool `json:"hasNextPage"` @@ -2005,18 +2061,19 @@ func (ic *Client) ListAllIssues(filter *core.IssueFilter) (*core.ListAllIssuesRe // Process each issue for _, node := range response.Issues.Nodes { issue := core.IssueWithDetails{ - ID: node.ID, - Identifier: node.Identifier, - Title: node.Title, - Description: node.Description, - Priority: node.Priority, - CreatedAt: node.CreatedAt, - UpdatedAt: node.UpdatedAt, - State: node.State, - Assignee: node.Assignee, - Labels: node.Labels.Nodes, - Project: node.Project, - Team: node.Team, + ID: node.ID, + Identifier: node.Identifier, + Title: node.Title, + Description: node.Description, + Priority: node.Priority, + CreatedAt: node.CreatedAt, + UpdatedAt: node.UpdatedAt, + State: node.State, + Assignee: node.Assignee, + Labels: node.Labels.Nodes, + Project: node.Project, + ProjectMilestone: node.ProjectMilestone, + Team: node.Team, } // Extract metadata from description @@ -2047,6 +2104,7 @@ func hasFilters(filter *core.IssueFilter) bool { filter.AssigneeID != "" || len(filter.LabelIDs) > 0 || filter.ProjectID != "" || + filter.ProjectMilestoneID != "" || filter.TeamID != "" } @@ -2109,6 +2167,15 @@ func buildFilterObject(filter *core.IssueFilter) map[string]interface{} { } } + // Project milestone filter + if filter.ProjectMilestoneID != "" { + filterObj["projectMilestone"] = map[string]interface{}{ + "id": map[string]interface{}{ + "eq": filter.ProjectMilestoneID, + }, + } + } + // Team filter if filter.TeamID != "" { filterObj["team"] = map[string]interface{}{ @@ -2169,7 +2236,7 @@ func buildOrderByObject(field, direction string) map[string]interface{} { func (ic *Client) ListIssueAttachments(issueID string) ([]core.Attachment, error) { // Validate input if issueID == "" { - return nil, guidance.ValidationErrorWithExample("issueID", "cannot be empty", + return nil, guidance.ValidationErrorWithExample("issueID", "cannot be empty", `// First get an issue ID issue = linear_get_issue("some-issue-id") // Then list its attachments @@ -2421,4 +2488,4 @@ func (ic *Client) GetTeamIssuesWithRelations(teamID string, limit int) ([]core.I } return response.Issues.Nodes, nil -} \ No newline at end of file +} diff --git a/pkg/linear/milestones/client.go b/pkg/linear/milestones/client.go new file mode 100644 index 0000000..cdecf24 --- /dev/null +++ b/pkg/linear/milestones/client.go @@ -0,0 +1,277 @@ +package milestones + +import ( + "fmt" + + "github.com/joa23/linear-cli/pkg/linear/core" +) + +// Client handles project milestone operations for the Linear API. +type Client struct { + base *core.BaseClient +} + +// NewClient creates a new milestones client with the provided base client. +func NewClient(base *core.BaseClient) *Client { + return &Client{base: base} +} + +const milestoneFields = ` + id + name + description + targetDate + status + progress + createdAt + updatedAt + archivedAt + project { + id + name + } +` + +// milestoneDetailFields extends milestoneFields with the issues connection. +// Only Get uses it; list/create/update skip issues to keep responses token-efficient. +const milestoneDetailFields = milestoneFields + ` + issues { + nodes { + id + identifier + title + state { + id + name + } + assignee { + id + name + email + } + } + } +` + +// List returns milestones for a project. +func (mc *Client) List(projectID string, limit int) ([]core.ProjectMilestone, error) { + if projectID == "" { + return nil, &core.ValidationError{Field: "projectID", Message: "projectID cannot be empty"} + } + if limit <= 0 { + limit = 50 + } + if limit > 250 { + limit = 250 + } + + query := fmt.Sprintf(` + query ListProjectMilestones($first: Int, $filter: ProjectMilestoneFilter) { + projectMilestones(first: $first, filter: $filter) { + nodes { + %s + } + } + } + `, milestoneFields) + + variables := map[string]interface{}{ + "first": limit, + "filter": map[string]interface{}{ + "project": map[string]interface{}{ + "id": map[string]interface{}{ + "eq": projectID, + }, + }, + }, + } + + var response struct { + ProjectMilestones struct { + Nodes []core.ProjectMilestone `json:"nodes"` + } `json:"projectMilestones"` + } + + if err := mc.base.ExecuteRequest(query, variables, &response); err != nil { + return nil, fmt.Errorf("failed to list project milestones: %w", err) + } + + return response.ProjectMilestones.Nodes, nil +} + +// Get returns a project milestone by ID. +func (mc *Client) Get(id string) (*core.ProjectMilestone, error) { + if id == "" { + return nil, &core.ValidationError{Field: "id", Message: "id cannot be empty"} + } + + query := fmt.Sprintf(` + query GetProjectMilestone($id: String!) { + projectMilestone(id: $id) { + %s + } + } + `, milestoneDetailFields) + + var response struct { + ProjectMilestone core.ProjectMilestone `json:"projectMilestone"` + } + variables := map[string]interface{}{"id": id} + + if err := mc.base.ExecuteRequest(query, variables, &response); err != nil { + return nil, fmt.Errorf("failed to get project milestone: %w", err) + } + if response.ProjectMilestone.ID == "" { + return nil, &core.NotFoundError{ResourceType: "project milestone", ResourceID: id} + } + + return &response.ProjectMilestone, nil +} + +// Create creates a project milestone. +func (mc *Client) Create(input *core.CreateProjectMilestoneInput) (*core.ProjectMilestone, error) { + if input == nil { + return nil, &core.ValidationError{Field: "input", Message: "input cannot be nil"} + } + if input.Name == "" { + return nil, &core.ValidationError{Field: "name", Message: "name cannot be empty"} + } + if input.ProjectID == "" { + return nil, &core.ValidationError{Field: "projectID", Message: "projectID cannot be empty"} + } + + mutation := fmt.Sprintf(` + mutation CreateProjectMilestone($input: ProjectMilestoneCreateInput!) { + projectMilestoneCreate(input: $input) { + success + projectMilestone { + %s + } + } + } + `, milestoneFields) + + var response struct { + ProjectMilestoneCreate struct { + Success bool `json:"success"` + ProjectMilestone core.ProjectMilestone `json:"projectMilestone"` + } `json:"projectMilestoneCreate"` + } + variables := map[string]interface{}{"input": buildCreateInput(input)} + + if err := mc.base.ExecuteRequest(mutation, variables, &response); err != nil { + return nil, fmt.Errorf("failed to create project milestone: %w", err) + } + if !response.ProjectMilestoneCreate.Success { + return nil, fmt.Errorf("project milestone creation was not successful") + } + + return &response.ProjectMilestoneCreate.ProjectMilestone, nil +} + +// Update updates a project milestone. +func (mc *Client) Update(id string, input *core.UpdateProjectMilestoneInput) (*core.ProjectMilestone, error) { + if id == "" { + return nil, &core.ValidationError{Field: "id", Message: "id cannot be empty"} + } + if input == nil { + return nil, &core.ValidationError{Field: "input", Message: "input cannot be nil"} + } + + inputMap := buildUpdateInput(input) + if len(inputMap) == 0 { + return nil, &core.ValidationError{Field: "input", Message: "at least one field must be provided"} + } + + mutation := fmt.Sprintf(` + mutation UpdateProjectMilestone($id: String!, $input: ProjectMilestoneUpdateInput!) { + projectMilestoneUpdate(id: $id, input: $input) { + success + projectMilestone { + %s + } + } + } + `, milestoneFields) + + var response struct { + ProjectMilestoneUpdate struct { + Success bool `json:"success"` + ProjectMilestone core.ProjectMilestone `json:"projectMilestone"` + } `json:"projectMilestoneUpdate"` + } + variables := map[string]interface{}{ + "id": id, + "input": inputMap, + } + + if err := mc.base.ExecuteRequest(mutation, variables, &response); err != nil { + return nil, fmt.Errorf("failed to update project milestone: %w", err) + } + if !response.ProjectMilestoneUpdate.Success { + return nil, fmt.Errorf("project milestone update was not successful") + } + + return &response.ProjectMilestoneUpdate.ProjectMilestone, nil +} + +// Delete deletes a project milestone. +func (mc *Client) Delete(id string) error { + if id == "" { + return &core.ValidationError{Field: "id", Message: "id cannot be empty"} + } + + const mutation = ` + mutation DeleteProjectMilestone($id: String!) { + projectMilestoneDelete(id: $id) { + success + entityId + } + } + ` + + var response struct { + ProjectMilestoneDelete struct { + Success bool `json:"success"` + EntityID string `json:"entityId"` + } `json:"projectMilestoneDelete"` + } + variables := map[string]interface{}{"id": id} + + if err := mc.base.ExecuteRequest(mutation, variables, &response); err != nil { + return fmt.Errorf("failed to delete project milestone: %w", err) + } + if !response.ProjectMilestoneDelete.Success { + return fmt.Errorf("project milestone deletion was not successful") + } + + return nil +} + +func buildCreateInput(input *core.CreateProjectMilestoneInput) map[string]interface{} { + inputMap := map[string]interface{}{ + "name": input.Name, + "projectId": input.ProjectID, + } + if input.Description != "" { + inputMap["description"] = input.Description + } + if input.TargetDate != "" { + inputMap["targetDate"] = input.TargetDate + } + return inputMap +} + +func buildUpdateInput(input *core.UpdateProjectMilestoneInput) map[string]interface{} { + inputMap := make(map[string]interface{}) + if input.Name != nil { + inputMap["name"] = *input.Name + } + if input.Description != nil { + inputMap["description"] = *input.Description + } + if input.TargetDate != nil { + inputMap["targetDate"] = *input.TargetDate + } + return inputMap +} diff --git a/pkg/linear/milestones/client_test.go b/pkg/linear/milestones/client_test.go new file mode 100644 index 0000000..d0b8e16 --- /dev/null +++ b/pkg/linear/milestones/client_test.go @@ -0,0 +1,198 @@ +package milestones + +import ( + "testing" + + "github.com/joa23/linear-cli/pkg/linear/core" +) + +func strPtr(s string) *string { return &s } + +func TestBuildCreateInput(t *testing.T) { + tests := []struct { + name string + input *core.CreateProjectMilestoneInput + expectDescription bool + expectTargetDate bool + }{ + { + name: "required fields only", + input: &core.CreateProjectMilestoneInput{Name: "Beta", ProjectID: "project-1"}, + expectDescription: false, + expectTargetDate: false, + }, + { + name: "with description and target date", + input: &core.CreateProjectMilestoneInput{ + Name: "Beta", + ProjectID: "project-1", + Description: "Public beta", + TargetDate: "2026-08-01", + }, + expectDescription: true, + expectTargetDate: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := buildCreateInput(tt.input) + + if result["name"] != tt.input.Name { + t.Errorf("name = %v, want %v", result["name"], tt.input.Name) + } + if result["projectId"] != tt.input.ProjectID { + t.Errorf("projectId = %v, want %v", result["projectId"], tt.input.ProjectID) + } + + _, hasDescription := result["description"] + if hasDescription != tt.expectDescription { + t.Errorf("description presence = %v, want %v", hasDescription, tt.expectDescription) + } + + _, hasTargetDate := result["targetDate"] + if hasTargetDate != tt.expectTargetDate { + t.Errorf("targetDate presence = %v, want %v", hasTargetDate, tt.expectTargetDate) + } + }) + } +} + +func TestBuildUpdateInput(t *testing.T) { + tests := []struct { + name string + input *core.UpdateProjectMilestoneInput + expectName bool + expectDescription bool + expectTargetDate bool + }{ + { + name: "no fields set", + input: &core.UpdateProjectMilestoneInput{}, + expectName: false, + }, + { + name: "rename only", + input: &core.UpdateProjectMilestoneInput{Name: strPtr("Private beta")}, + expectName: true, + }, + { + name: "all fields set", + input: &core.UpdateProjectMilestoneInput{ + Name: strPtr("Private beta"), + Description: strPtr("Updated description"), + TargetDate: strPtr("2026-08-15"), + }, + expectName: true, + expectDescription: true, + expectTargetDate: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := buildUpdateInput(tt.input) + + _, hasName := result["name"] + if hasName != tt.expectName { + t.Errorf("name presence = %v, want %v", hasName, tt.expectName) + } + + _, hasDescription := result["description"] + if hasDescription != tt.expectDescription { + t.Errorf("description presence = %v, want %v", hasDescription, tt.expectDescription) + } + + _, hasTargetDate := result["targetDate"] + if hasTargetDate != tt.expectTargetDate { + t.Errorf("targetDate presence = %v, want %v", hasTargetDate, tt.expectTargetDate) + } + }) + } +} + +func TestList_ValidationError(t *testing.T) { + client := NewClient(nil) + + _, err := client.List("", 50) + if err == nil { + t.Fatal("expected validation error for empty projectID") + } + if _, ok := err.(*core.ValidationError); !ok { + t.Errorf("expected *core.ValidationError, got %T", err) + } +} + +func TestGet_ValidationError(t *testing.T) { + client := NewClient(nil) + + _, err := client.Get("") + if err == nil { + t.Fatal("expected validation error for empty id") + } + if _, ok := err.(*core.ValidationError); !ok { + t.Errorf("expected *core.ValidationError, got %T", err) + } +} + +func TestCreate_ValidationErrors(t *testing.T) { + client := NewClient(nil) + + tests := []struct { + name string + input *core.CreateProjectMilestoneInput + }{ + {"nil input", nil}, + {"empty name", &core.CreateProjectMilestoneInput{ProjectID: "project-1"}}, + {"empty projectID", &core.CreateProjectMilestoneInput{Name: "Beta"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := client.Create(tt.input) + if err == nil { + t.Fatal("expected validation error") + } + if _, ok := err.(*core.ValidationError); !ok { + t.Errorf("expected *core.ValidationError, got %T", err) + } + }) + } +} + +func TestUpdate_ValidationErrors(t *testing.T) { + client := NewClient(nil) + + t.Run("empty id", func(t *testing.T) { + _, err := client.Update("", &core.UpdateProjectMilestoneInput{Name: strPtr("Beta")}) + if err == nil { + t.Fatal("expected validation error for empty id") + } + }) + + t.Run("nil input", func(t *testing.T) { + _, err := client.Update("milestone-1", nil) + if err == nil { + t.Fatal("expected validation error for nil input") + } + }) + + t.Run("no fields set", func(t *testing.T) { + _, err := client.Update("milestone-1", &core.UpdateProjectMilestoneInput{}) + if err == nil { + t.Fatal("expected validation error when no fields are set") + } + }) +} + +func TestDelete_ValidationError(t *testing.T) { + client := NewClient(nil) + + err := client.Delete("") + if err == nil { + t.Fatal("expected validation error for empty id") + } + if _, ok := err.(*core.ValidationError); !ok { + t.Errorf("expected *core.ValidationError, got %T", err) + } +} diff --git a/pkg/linear/resolver.go b/pkg/linear/resolver.go index 327b007..0acd940 100644 --- a/pkg/linear/resolver.go +++ b/pkg/linear/resolver.go @@ -545,6 +545,93 @@ func (r *Resolver) ResolveProject(nameOrID string, teamID string) (string, error return project.ID, nil } +// ResolveProjectMilestone resolves a milestone identifier (name or UUID) to a milestone UUID. +// Milestone names are scoped to a project, so projectID is required for name resolution. +func (r *Resolver) ResolveProjectMilestone(nameOrID string, projectID string) (string, error) { + if nameOrID == "" { + return "", &core.ValidationError{ + Field: "milestone", + Message: "milestone identifier cannot be empty", + } + } + + if identifiers.IsUUID(nameOrID) { + return nameOrID, nil + } + + if projectID == "" { + return "", &core.ValidationError{ + Field: "project", + Message: "project is required to resolve milestone names", + } + } + + if milestoneID, found := r.cache.getProjectMilestoneByName(projectID, nameOrID); found { + return milestoneID, nil + } + + milestones, err := r.client.Milestones.List(projectID, 250) + if err != nil { + return "", fmt.Errorf("failed to fetch milestones for resolution: %w", err) + } + + var matches []core.ProjectMilestone + nameLower := strings.ToLower(nameOrID) + for _, milestone := range milestones { + if strings.ToLower(milestone.Name) == nameLower { + matches = append(matches, milestone) + } + } + + if len(matches) == 0 { + var available []string + for _, milestone := range milestones { + available = append(available, milestone.Name) + } + + return "", &guidance.ErrorWithGuidance{ + Operation: "Resolve milestone", + Reason: fmt.Sprintf("milestone '%s' not found", nameOrID), + Guidance: []string{ + "Check the milestone name spelling (case-insensitive)", + "Use 'linear milestones list --project ' to see available milestones", + "Use the milestone UUID for exact matching", + }, + Example: fmt.Sprintf("Available milestones: %s", strings.Join(available, ", ")), + OriginalErr: &core.NotFoundError{ + ResourceType: "project milestone", + ResourceID: nameOrID, + }, + } + } + + if len(matches) > 1 { + var suggestions []string + for _, milestone := range matches { + suggestions = append(suggestions, fmt.Sprintf("%s (ID: %s)", milestone.Name, milestone.ID)) + } + + return "", &guidance.ErrorWithGuidance{ + Operation: "Resolve milestone", + Reason: fmt.Sprintf("multiple milestones match '%s'", nameOrID), + Guidance: []string{ + "Use the milestone UUID for exact matching", + "Choose from the suggestions below", + }, + Example: fmt.Sprintf("Matching milestones: %s", strings.Join(suggestions, ", ")), + OriginalErr: &core.ValidationError{ + Field: "milestone", + Value: nameOrID, + Reason: fmt.Sprintf("ambiguous, matches: %s", strings.Join(suggestions, ", ")), + }, + } + } + + milestone := matches[0] + r.cache.setProjectMilestoneByName(projectID, nameOrID, milestone.ID) + return milestone.ID, nil +} + // ResolveLabel resolves a label name to a label UUID within a specific team // Labels are team-scoped, so teamID is required // diff --git a/pkg/linear/resolver_cache.go b/pkg/linear/resolver_cache.go index d2abb55..92519b9 100644 --- a/pkg/linear/resolver_cache.go +++ b/pkg/linear/resolver_cache.go @@ -1,7 +1,6 @@ package linear import ( - "sync" "time" ) @@ -40,6 +39,9 @@ type resolverCache struct { // Project resolution cache projectByName map[string]*cacheEntry // project name → projectID + // Project milestone resolution cache (keyed by projectID:name) + projectMilestoneByName map[string]*cacheEntry // projectID:name → milestoneID + mu sync.RWMutex ttl time.Duration } @@ -47,14 +49,15 @@ type resolverCache struct { // newResolverCache creates a new resolver cache with the specified TTL func newResolverCache(ttl time.Duration) *resolverCache { cache := &resolverCache{ - userByEmail: make(map[string]*cacheEntry), - userByName: make(map[string]*cacheEntry), - teamByName: make(map[string]*cacheEntry), - teamByKey: make(map[string]*cacheEntry), - issueByIdentifier: make(map[string]*cacheEntry), - labelByName: make(map[string]*cacheEntry), - projectByName: make(map[string]*cacheEntry), - ttl: ttl, + userByEmail: make(map[string]*cacheEntry), + userByName: make(map[string]*cacheEntry), + teamByName: make(map[string]*cacheEntry), + teamByKey: make(map[string]*cacheEntry), + issueByIdentifier: make(map[string]*cacheEntry), + labelByName: make(map[string]*cacheEntry), + projectByName: make(map[string]*cacheEntry), + projectMilestoneByName: make(map[string]*cacheEntry), + ttl: ttl, } // Start background cleanup goroutine @@ -222,6 +225,31 @@ func (rc *resolverCache) setProjectByName(name, projectID string) { } } +// Project milestone cache methods + +func (rc *resolverCache) getProjectMilestoneByName(projectID, name string) (string, bool) { + rc.mu.RLock() + defer rc.mu.RUnlock() + + key := projectID + ":" + name + entry, exists := rc.projectMilestoneByName[key] + if !exists || entry.isExpired() { + return "", false + } + return entry.value, true +} + +func (rc *resolverCache) setProjectMilestoneByName(projectID, name, milestoneID string) { + rc.mu.Lock() + defer rc.mu.Unlock() + + key := projectID + ":" + name + rc.projectMilestoneByName[key] = &cacheEntry{ + value: milestoneID, + expiresAt: time.Now().Add(rc.ttl), + } +} + // Utility methods // cleanup removes expired entries from the cache @@ -278,6 +306,13 @@ func (rc *resolverCache) cleanup() { delete(rc.projectByName, name) } } + + // Clean up project milestone cache + for key, entry := range rc.projectMilestoneByName { + if entry.expiresAt.Before(now) { + delete(rc.projectMilestoneByName, key) + } + } } // runCleanup runs periodic cleanup in a background goroutine @@ -305,4 +340,5 @@ func (rc *resolverCache) clear() { rc.issueByIdentifier = make(map[string]*cacheEntry) rc.labelByName = make(map[string]*cacheEntry) rc.projectByName = make(map[string]*cacheEntry) + rc.projectMilestoneByName = make(map[string]*cacheEntry) }