diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cecf72..3ce25d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `projects create --summary` and `projects update --summary` — write the short line shown under the project title in the Linear UI. It was previously unreachable under its own name, and is validated against Linear's 255-character cap locally instead of failing with an opaque GraphQL error. + +### Changed + +- **Breaking:** `projects create -d/--description` and `projects update -d/--description` now write the project's full description document, not the short summary. Previously the only writable field was the 255-character summary, so `cat spec.md | linear projects create ... -d -` failed on any real spec. Scripts passing a short blurb to `--description` should switch to `--summary`; nothing errors if they don't, the text just lands in the description instead. +- **Breaking:** project JSON renames `description` to `summary` and `content` to `description`, matching the flags and the Linear UI. `jq '.description'` on a project now returns the long document; use `.summary` for the short line. +- Project text output labels the two fields `Summary:` and `DESCRIPTION` (was `Description:` and `CONTENT`). + +### Fixed + +- `issues blocked-by` and `issues blocking` now read Linear's native issue relations, the same source as `deps`. Both read a metadata block in the issue description that nothing has written since `63fc213`, first released in v1.5.0; `blocking` never had a writer in any version. `blocked-by` answered `check description or Linear UI for blocking issues` whenever the description was non-empty and `none` when it was not — so its answer turned on whether the description happened to be blank, never on the relations. Output is now one line per issue as `ABC-123 [State] Title`, replacing the Go slice syntax (`[DEV-12 DEV-9]`) the old code would have printed, and still `none` when there is nothing to report. Blockers in a completed state are listed with their state rather than hidden, since omitting a real relation is how the old commands misled in the first place. + +### Removed + +- `issues dependencies` — it read a metadata block in the issue description that nothing has written since `63fc213`, first released in v1.5.0, moved dependency writes to Linear's native `issueRelationCreate`. It reported `none` for every issue, including issues with real relations, so its output read as "unblocked" when it was really "not implemented". `deps ` reads the native relations and covers the same ground. + +- **Breaking (library):** the description-embedded metadata store is gone. This removes the `pkg/linear/metadata` package, `Client.UpdateIssueMetadataKey` / `RemoveIssueMetadataKey` / `UpdateProjectMetadataKey` / `RemoveProjectMetadataKey` and their sub-client methods, `validation.IsValidMetadataKey`, and the `Metadata` field on `core.Issue`, `core.ParentIssue`, `core.Project` and `core.IssueWithDetails`. `core.Attachment.Metadata` is untouched — that one is Linear's own `Attachment.metadata` field, not this store. + + It stored structured data by appending a `
` block to the description. Linear's editor has no raw-HTML passthrough, so that block rendered as literal visible text rather than the collapsible section the code claimed; the only feature it offered over a plain description was one it did not have. `issues create/update --depends-on` and `--blocked-by` wrote to it up to and including v1.4.1; v1.5.0 moved those flags to native relations and nothing has written to it since. The last readers went with `issues dependencies`. + + Two behaviour changes fall out. Extraction used to run on every issue and project read to strip these blocks out of displayed descriptions, so an issue written by v1.4.1 or earlier will now show its block in CLI output. Linear's own UI has always displayed that block, so this makes the CLI agree with the web UI rather than exposing anything new. And `UpdateIssue` no longer issues an extra `GetIssue` before every description update, since it has no metadata left to preserve — one fewer API round-trip per description edit. + ## [1.10.0] - 2026-07-14 ### Added diff --git a/README.md b/README.md index 081cc91..7e2ecc8 100644 --- a/README.md +++ b/README.md @@ -642,6 +642,18 @@ linear projects create "Q1 Release" --team ENG linear projects update PROJECT-ID --state completed ``` +A project carries two pieces of prose, named as they are in the Linear UI: + +- `--summary` — the short line under the project title, capped at 255 characters +- `--description` — the full project document, no length limit + +```bash +linear projects update PROJECT-ID --summary "Ship the new pipeline" +cat spec.md | linear projects update PROJECT-ID -d - # replace the description +``` + +JSON output uses those same two names (`.summary` and `.description`). + ### Cycles ```bash diff --git a/internal/cli/issues.go b/internal/cli/issues.go index 9567429..02cc67a 100644 --- a/internal/cli/issues.go +++ b/internal/cli/issues.go @@ -6,6 +6,7 @@ import ( "strconv" "github.com/joa23/linear-cli/internal/format" + "github.com/joa23/linear-cli/pkg/linear/core" paginationutil "github.com/joa23/linear-cli/pkg/linear/pagination" "github.com/joa23/linear-cli/internal/service" "github.com/spf13/cobra" @@ -30,7 +31,6 @@ func newIssuesCmd() *cobra.Command { newIssuesExportCmd(), newIssuesReplyCmd(), newIssuesReactCmd(), - newIssuesDependenciesCmd(), newIssuesBlockedByCmd(), newIssuesBlockingCmd(), ) @@ -960,42 +960,50 @@ func newIssuesReactCmd() *cobra.Command { } } -func newIssuesDependenciesCmd() *cobra.Command { - return &cobra.Command{ - Use: "dependencies ", - Short: "List issue dependencies (what it depends on)", - Long: "Show compressed list of issues this ticket depends on. Uses metadata or URL references.", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - issueID := args[0] - deps, err := getDeps(cmd) - if err != nil { - return err - } - - issue, err := deps.Client.Issues.GetIssue(issueID) - if err != nil { - return fmt.Errorf("failed to get issue: %w", err) - } +// relationTitleWidth bounds the title in blocked-by/blocking output. Wider than +// the graph in `deps`, which spends its width on tree indentation. +const relationTitleWidth = 60 + +// blockerLines lists the issues blocking the queried issue, one line each. +// +// Linear stores a blocker as "blocker blocks queried", so the queried issue is +// on the inverse side and rel.Issue is the blocker. Both connections also carry +// related/duplicate/similar relations, which say nothing about blocking. +func blockerLines(issue *core.IssueWithRelations) []string { + lines := make([]string, 0, len(issue.InverseRelations.Nodes)) + for _, rel := range issue.InverseRelations.Nodes { + if rel.Type == core.RelationBlocks && rel.Issue != nil { + lines = append(lines, relationLine(rel.Issue)) + } + } + return lines +} - // Check metadata for dependency info - depIssues := []string{} - if metadata, ok := issue.Metadata["dependencies"].([]interface{}); ok { - for _, dep := range metadata { - if depStr, ok := dep.(string); ok { - depIssues = append(depIssues, depStr) - } - } - } +// blockedLines lists the issues the queried issue blocks, one line each. +func blockedLines(issue *core.IssueWithRelations) []string { + lines := make([]string, 0, len(issue.Relations.Nodes)) + for _, rel := range issue.Relations.Nodes { + if rel.Type == core.RelationBlocks && rel.RelatedIssue != nil { + lines = append(lines, relationLine(rel.RelatedIssue)) + } + } + return lines +} - if len(depIssues) == 0 { - fmt.Println("none") - return nil - } +// relationLine renders one related issue as "ABC-123 [State] Title". The state +// is not decoration: a blocker that is already Done does not block anything, +// and the identifier alone cannot tell you that. +func relationLine(issue *core.IssueMinimal) string { + return fmt.Sprintf("%s [%s] %s", issue.Identifier, issue.State.Name, truncateTitle(issue.Title, relationTitleWidth)) +} - fmt.Printf("%v\n", depIssues) - return nil - }, +func printRelationLines(lines []string) { + if len(lines) == 0 { + fmt.Println("none") + return + } + for _, line := range lines { + fmt.Println(line) } } @@ -1003,43 +1011,24 @@ func newIssuesBlockedByCmd() *cobra.Command { return &cobra.Command{ Use: "blocked-by ", Short: "List issues blocking this one", - Long: "Show compressed list of issues that are blocking this ticket.", - Args: cobra.ExactArgs(1), + Long: `Show the issues blocking this ticket, one per line, as "ABC-123 [State] Title". + +Reads Linear's native issue relations, the same source as 'linear deps'. Prints +"none" when nothing blocks the issue. Blockers already in a completed state are +listed too — their state is shown so you can tell them apart.`, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - issueID := args[0] deps, err := getDeps(cmd) if err != nil { return err } - issue, err := deps.Client.Issues.GetIssue(issueID) + issue, err := deps.Client.Issues.GetIssueWithRelations(args[0]) if err != nil { return fmt.Errorf("failed to get issue: %w", err) } - // Check if blocked in metadata or description - blockedIssues := []string{} - if blockList, ok := issue.Metadata["blocked_by"].([]interface{}); ok { - for _, blocker := range blockList { - if blockerStr, ok := blocker.(string); ok { - blockedIssues = append(blockedIssues, blockerStr) - } - } - } - - // Check description for "Blocked by:" mentions - if len(blockedIssues) == 0 && issue.Description != "" { - // Simple extraction - in practice would be more sophisticated - fmt.Println("check description or Linear UI for blocking issues") - return nil - } - - if len(blockedIssues) == 0 { - fmt.Println("none") - return nil - } - - fmt.Printf("%v\n", blockedIssues) + printRelationLines(blockerLines(issue)) return nil }, } @@ -1049,36 +1038,23 @@ func newIssuesBlockingCmd() *cobra.Command { return &cobra.Command{ Use: "blocking ", Short: "List issues blocked by this one", - Long: "Show compressed list of issues that are blocked by this ticket.", - Args: cobra.ExactArgs(1), + Long: `Show the issues this ticket blocks, one per line, as "ABC-123 [State] Title". + +Reads Linear's native issue relations, the same source as 'linear deps'. Prints +"none" when the issue blocks nothing.`, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - issueID := args[0] deps, err := getDeps(cmd) if err != nil { return err } - issue, err := deps.Client.Issues.GetIssue(issueID) + issue, err := deps.Client.Issues.GetIssueWithRelations(args[0]) if err != nil { return fmt.Errorf("failed to get issue: %w", err) } - // Check metadata for blocked issues - blockingIssues := []string{} - if blockList, ok := issue.Metadata["blocking"].([]interface{}); ok { - for _, blocked := range blockList { - if blockedStr, ok := blocked.(string); ok { - blockingIssues = append(blockingIssues, blockedStr) - } - } - } - - if len(blockingIssues) == 0 { - fmt.Println("none") - return nil - } - - fmt.Printf("%v\n", blockingIssues) + printRelationLines(blockedLines(issue)) return nil }, } diff --git a/internal/cli/issues_relations_test.go b/internal/cli/issues_relations_test.go new file mode 100644 index 0000000..739a174 --- /dev/null +++ b/internal/cli/issues_relations_test.go @@ -0,0 +1,135 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/joa23/linear-cli/pkg/linear/core" +) + +// relatedIssue builds an IssueMinimal; State is an anonymous struct, so it has +// to be filled in after construction rather than in a literal. +func relatedIssue(identifier, state, title string) *core.IssueMinimal { + issue := &core.IssueMinimal{Identifier: identifier, Title: title} + issue.State.Name = state + return issue +} + +// blocker is a relation as it appears on InverseRelations: the other issue +// blocks the queried one. +func blocker(relType core.IssueRelationType, issue *core.IssueMinimal) core.IssueRelation { + return core.IssueRelation{Type: relType, Issue: issue} +} + +// blocked is a relation as it appears on Relations: the queried issue blocks +// the other one. +func blocked(relType core.IssueRelationType, issue *core.IssueMinimal) core.IssueRelation { + return core.IssueRelation{Type: relType, RelatedIssue: issue} +} + +func TestBlockerLines(t *testing.T) { + issue := &core.IssueWithRelations{Identifier: "DEV-14"} + issue.InverseRelations.Nodes = []core.IssueRelation{ + blocker(core.RelationBlocks, relatedIssue("DEV-12", "Backlog", "Metadata via library writer")), + blocker(core.RelationBlocks, relatedIssue("DEV-9", "Done", "Set up the test workspace")), + } + + lines := blockerLines(issue) + want := []string{ + "DEV-12 [Backlog] Metadata via library writer", + "DEV-9 [Done] Set up the test workspace", + } + assertLines(t, lines, want) +} + +func TestBlockedLines(t *testing.T) { + issue := &core.IssueWithRelations{Identifier: "DEV-14"} + issue.Relations.Nodes = []core.IssueRelation{ + blocked(core.RelationBlocks, relatedIssue("DEV-11", "Backlog", "Decide what happens on partial failure")), + } + + assertLines(t, blockedLines(issue), []string{"DEV-11 [Backlog] Decide what happens on partial failure"}) +} + +// Linear stores related/duplicate/similar on the same two connections. None of +// them means "blocks", so reporting them would invent blockers that don't exist. +func TestRelationLines_IgnoreNonBlockingTypes(t *testing.T) { + issue := &core.IssueWithRelations{Identifier: "DEV-14"} + issue.InverseRelations.Nodes = []core.IssueRelation{ + blocker(core.RelationRelated, relatedIssue("DEV-2", "Backlog", "Related work")), + blocker(core.RelationDuplicate, relatedIssue("DEV-3", "Backlog", "Duplicate")), + blocker(core.RelationBlocks, relatedIssue("DEV-12", "Backlog", "Real blocker")), + } + issue.Relations.Nodes = []core.IssueRelation{ + blocked(core.RelationRelated, relatedIssue("DEV-4", "Backlog", "Related work")), + } + + assertLines(t, blockerLines(issue), []string{"DEV-12 [Backlog] Real blocker"}) + assertLines(t, blockedLines(issue), nil) +} + +// The two directions must not be crossed: a blocker read off the wrong +// connection turns "blocked by" into "blocking" and inverts the answer. +func TestRelationLines_DirectionsDoNotLeak(t *testing.T) { + issue := &core.IssueWithRelations{Identifier: "DEV-14"} + issue.InverseRelations.Nodes = []core.IssueRelation{ + blocker(core.RelationBlocks, relatedIssue("DEV-12", "Backlog", "Blocks DEV-14")), + } + issue.Relations.Nodes = []core.IssueRelation{ + blocked(core.RelationBlocks, relatedIssue("DEV-11", "Backlog", "Blocked by DEV-14")), + } + + assertLines(t, blockerLines(issue), []string{"DEV-12 [Backlog] Blocks DEV-14"}) + assertLines(t, blockedLines(issue), []string{"DEV-11 [Backlog] Blocked by DEV-14"}) +} + +// An issue with no relations at all must come back empty rather than panicking +// on the nil connections. +func TestRelationLines_NoRelations(t *testing.T) { + issue := &core.IssueWithRelations{Identifier: "DEV-11"} + + assertLines(t, blockerLines(issue), nil) + assertLines(t, blockedLines(issue), nil) +} + +// A relation whose issue side is absent carries no identifier to print. +func TestRelationLines_SkipsNilIssues(t *testing.T) { + issue := &core.IssueWithRelations{Identifier: "DEV-14"} + issue.InverseRelations.Nodes = []core.IssueRelation{ + blocker(core.RelationBlocks, nil), + blocker(core.RelationBlocks, relatedIssue("DEV-12", "Backlog", "Real blocker")), + } + // A relations-side node with only the inverse field set is equally unusable. + issue.Relations.Nodes = []core.IssueRelation{ + blocker(core.RelationBlocks, relatedIssue("DEV-12", "Backlog", "Wrong side")), + } + + assertLines(t, blockerLines(issue), []string{"DEV-12 [Backlog] Real blocker"}) + assertLines(t, blockedLines(issue), nil) +} + +func TestRelationLine_TruncatesLongTitles(t *testing.T) { + long := strings.Repeat("x", relationTitleWidth+20) + line := relationLine(relatedIssue("DEV-1", "Backlog", long)) + + title := strings.TrimPrefix(line, "DEV-1 [Backlog] ") + if len(title) != relationTitleWidth { + t.Errorf("title width = %d, want %d (line: %q)", len(title), relationTitleWidth, line) + } + if !strings.HasSuffix(title, "...") { + t.Errorf("truncated title should end in an ellipsis, got %q", title) + } +} + +func assertLines(t *testing.T, got, want []string) { + t.Helper() + + if len(got) != len(want) { + t.Fatalf("got %d lines %q, want %d %q", len(got), got, len(want), want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("line %d = %q, want %q", i, got[i], want[i]) + } + } +} diff --git a/internal/cli/projects.go b/internal/cli/projects.go index 376beed..e52af1d 100644 --- a/internal/cli/projects.go +++ b/internal/cli/projects.go @@ -6,6 +6,7 @@ import ( "github.com/joa23/linear-cli/internal/format" "github.com/joa23/linear-cli/internal/service" + "github.com/joa23/linear-cli/pkg/linear/validation" "github.com/spf13/cobra" ) @@ -153,9 +154,33 @@ func newProjectsGetCmd() *cobra.Command { return cmd } +// projectSummaryMaxLength is the cap Linear puts on a project summary. +const projectSummaryMaxLength = 255 + +// validateProjectSummary rejects an over-long summary before it reaches the API, +// which would otherwise fail with "Argument Validation Error" naming no field. +// Measuring the limit is validation's job; what is added here is the way out, +// since only this layer knows the longer text has a flag of its own. The error is +// rewritten rather than wrapped because ValidateStringLength puts the offending +// value in its message, which for a summary means printing the whole over-long +// line back at the user. +// +// ValidateStringLength counts bytes today, so a summary of 255 Japanese +// characters is refused even though the API accepts it — Linear counts code +// points. DEV-18 tracks that fix; the wording below names no unit, so it stays +// true both before and after. +func validateProjectSummary(summary string) error { + if err := validation.ValidateStringLength(summary, "summary", projectSummaryMaxLength); err != nil { + return fmt.Errorf("summary is too long, the limit is %d; use --description for longer text", + projectSummaryMaxLength) + } + return nil +} + func newProjectsCreateCmd() *cobra.Command { var ( team string + summary string description string state string lead string @@ -166,12 +191,18 @@ func newProjectsCreateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "create ", Short: "Create a new project", - Long: `Create a new project. States: planned, started, paused, completed, canceled.`, + Long: `Create a new project. States: planned, started, paused, completed, canceled. + +A project has two pieces of prose, matching the Linear UI: a short summary shown +under the title (255 characters max) and a full description document.`, Example: ` # Create a simple project linear projects create "Q1 Release" --team CEN - # Create with description from stdin - cat project-spec.md | linear projects create "Q1 Release" --team CEN + # Create with a summary + linear projects create "Q1 Release" --team CEN --summary "Ship the new pipeline" + + # Create with the description read from stdin + cat project-spec.md | linear projects create "Q1 Release" --team CEN -d - # Create with all options linear projects create "Q1 Release" --team CEN --state started --lead Stefan`, @@ -197,10 +228,15 @@ func newProjectsCreateCmd() *cobra.Command { return fmt.Errorf("failed to read description: %w", err) } + if err := validateProjectSummary(summary); err != nil { + return err + } + // Build create input input := &service.CreateProjectInput{ Name: name, TeamID: team, + Summary: summary, Description: desc, } @@ -235,7 +271,8 @@ func newProjectsCreateCmd() *cobra.Command { // Add flags (with short versions for common flags) cmd.Flags().StringVarP(&team, "team", "t", "", TeamFlagDescription) - cmd.Flags().StringVarP(&description, "description", "d", "", "Project description (or pipe to stdin)") + cmd.Flags().StringVar(&summary, "summary", "", "Short summary shown under the title (255 chars max)") + cmd.Flags().StringVarP(&description, "description", "d", "", "Project description document (use - to read stdin)") cmd.Flags().StringVarP(&state, "state", "s", "", "Project state: planned, started, paused, completed, canceled") cmd.Flags().StringVarP(&lead, "lead", "l", "", "Project lead name (use 'me' for yourself)") cmd.Flags().StringVar(&startDate, "start-date", "", "Start date YYYY-MM-DD") @@ -247,6 +284,7 @@ func newProjectsCreateCmd() *cobra.Command { func newProjectsUpdateCmd() *cobra.Command { var ( name string + summary string description string state string lead string @@ -257,15 +295,21 @@ func newProjectsUpdateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "update ", Short: "Update an existing project", - Long: `Update an existing project. Only provided flags are changed.`, + Long: `Update an existing project. Only provided flags are changed. + +A project has two pieces of prose, matching the Linear UI: a short summary shown +under the title (255 characters max) and a full description document.`, Example: ` # Update project state linear projects update PROJ-123 --state completed # Update project lead linear projects update PROJ-123 --lead john@example.com - # Update description from stdin - cat updated-spec.md | linear projects update PROJ-123`, + # Rewrite the summary + linear projects update PROJ-123 --summary "Ship the new pipeline" + + # Replace the description from stdin + cat updated-spec.md | linear projects update PROJ-123 -d -`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { projectID := args[0] @@ -275,7 +319,7 @@ func newProjectsUpdateCmd() *cobra.Command { } // Check if any updates provided (description="-" means stdin) - hasFlags := name != "" || description != "" || state != "" || + hasFlags := name != "" || summary != "" || description != "" || state != "" || lead != "" || startDate != "" || endDate != "" if !hasFlags { @@ -288,12 +332,19 @@ func newProjectsUpdateCmd() *cobra.Command { return fmt.Errorf("failed to read description: %w", err) } + if err := validateProjectSummary(summary); err != nil { + return err + } + // Build update input input := &service.UpdateProjectInput{} if name != "" { input.Name = &name } + if summary != "" { + input.Summary = &summary + } if desc != "" { input.Description = &desc } @@ -327,7 +378,8 @@ func newProjectsUpdateCmd() *cobra.Command { // Add flags (with short versions for common flags) cmd.Flags().StringVarP(&name, "name", "n", "", "Update project name") - cmd.Flags().StringVarP(&description, "description", "d", "", "Update description (or pipe to stdin)") + cmd.Flags().StringVar(&summary, "summary", "", "Update the short summary (255 chars max)") + cmd.Flags().StringVarP(&description, "description", "d", "", "Update the description document (use - to read stdin)") cmd.Flags().StringVarP(&state, "state", "s", "", "Update state: planned, started, paused, completed, canceled") cmd.Flags().StringVarP(&lead, "lead", "l", "", "Update project lead (use 'me' for yourself)") cmd.Flags().StringVar(&startDate, "start-date", "", "Update start date YYYY-MM-DD") diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 8b4e474..3e30323 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -73,7 +73,7 @@ func TestIssuesSubcommands(t *testing.T) { require.NotNil(t, issuesCmd) // Only test subcommands that are actually implemented - expectedSubCmds := []string{"list", "get", "dependencies", "blocked-by", "blocking"} + expectedSubCmds := []string{"list", "get", "blocked-by", "blocking"} for _, subCmdName := range expectedSubCmds { found := false for _, c := range issuesCmd.Commands() { diff --git a/internal/format/json_dtos.go b/internal/format/json_dtos.go index 288c60c..91b152c 100644 --- a/internal/format/json_dtos.go +++ b/internal/format/json_dtos.go @@ -116,9 +116,9 @@ type CycleFullDTO struct { type ProjectDTO struct { ID string `json:"id"` Name string `json:"name"` - Description string `json:"description"` + Summary string `json:"summary"` State string `json:"state"` - Content string `json:"content"` + Description string `json:"description"` Issues []IssueRefDTO `json:"issues"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` @@ -474,9 +474,9 @@ func ProjectToDTO(project *core.Project) ProjectDTO { dto := ProjectDTO{ ID: project.ID, Name: project.Name, - Description: project.Description, + Summary: project.Summary, State: project.State, - Content: project.Content, + Description: project.Description, CreatedAt: project.CreatedAt, UpdatedAt: project.UpdatedAt, } diff --git a/internal/format/project.go b/internal/format/project.go index 0acd533..4675cdf 100644 --- a/internal/format/project.go +++ b/internal/format/project.go @@ -22,21 +22,21 @@ func (f *Formatter) Project(project *core.Project) string { // State b.WriteString(fmtSprintf("State: %s\n", project.State)) - // Description - if project.Description != "" { - b.WriteString(fmtSprintf("Description: %s\n", truncate(project.Description, 100))) + // Summary + if project.Summary != "" { + b.WriteString(fmtSprintf("Summary: %s\n", truncate(project.Summary, 100))) } // Timestamps b.WriteString(fmtSprintf("Created: %s\n", formatDateTime(project.CreatedAt))) b.WriteString(fmtSprintf("Updated: %s\n", formatDateTime(project.UpdatedAt))) - // Content (long description) - truncated for display - if project.Content != "" { - b.WriteString("\nCONTENT\n") + // Description - truncated for display + if project.Description != "" { + b.WriteString("\nDESCRIPTION\n") b.WriteString(line(40)) b.WriteString("\n") - b.WriteString(truncate(cleanDescription(project.Content), 500)) + b.WriteString(truncate(cleanDescription(project.Description), 500)) b.WriteString("\n") } @@ -94,9 +94,9 @@ func (f *Formatter) projectCompact(project *core.Project) string { // Line 1: Name and state b.WriteString(fmtSprintf("%s [%s]\n", project.Name, project.State)) - // Line 2: Description (if any) - if project.Description != "" { - b.WriteString(fmtSprintf(" %s\n", truncate(project.Description, 80))) + // Line 2: Summary (if any) + if project.Summary != "" { + b.WriteString(fmtSprintf(" %s\n", truncate(project.Summary, 80))) } // Line 3: Issue count (if available) diff --git a/internal/format/text_renderer.go b/internal/format/text_renderer.go index 16ca2df..24e923d 100644 --- a/internal/format/text_renderer.go +++ b/internal/format/text_renderer.go @@ -340,21 +340,21 @@ func (r *TextRenderer) projectFull(project *core.Project) string { // State b.WriteString(fmtSprintf("State: %s\n", project.State)) - // Description - if project.Description != "" { - b.WriteString(fmtSprintf("Description: %s\n", truncate(project.Description, 100))) + // Summary + if project.Summary != "" { + b.WriteString(fmtSprintf("Summary: %s\n", truncate(project.Summary, 100))) } // Timestamps b.WriteString(fmtSprintf("Created: %s\n", formatDateTime(project.CreatedAt))) b.WriteString(fmtSprintf("Updated: %s\n", formatDateTime(project.UpdatedAt))) - // Content (long description) - truncated for display - if project.Content != "" { - b.WriteString("\nCONTENT\n") + // Description - truncated for display + if project.Description != "" { + b.WriteString("\nDESCRIPTION\n") b.WriteString(line(40)) b.WriteString("\n") - b.WriteString(truncate(cleanDescription(project.Content), 500)) + b.WriteString(truncate(cleanDescription(project.Description), 500)) b.WriteString("\n") } @@ -383,9 +383,9 @@ func (r *TextRenderer) projectCompact(project *core.Project) string { // Line 1: Name and state b.WriteString(fmtSprintf("%s [%s]\n", project.Name, project.State)) - // Line 2: Description (if any) - if project.Description != "" { - b.WriteString(fmtSprintf(" %s\n", truncate(project.Description, 80))) + // Line 2: Summary (if any) + if project.Summary != "" { + b.WriteString(fmtSprintf(" %s\n", truncate(project.Summary, 80))) } // Line 3: Issue count (if available) diff --git a/internal/service/client_interfaces.go b/internal/service/client_interfaces.go index b97ee84..ba7000d 100644 --- a/internal/service/client_interfaces.go +++ b/internal/service/client_interfaces.go @@ -34,9 +34,6 @@ type IssueClientOperations interface { // Relation operations CreateRelation(issueID, relatedIssueID string, relationType core.IssueRelationType) error - // Metadata operations (kept in Phase 2) - UpdateIssueMetadataKey(issueID, key string, value interface{}) error - // Sub-client access (Phase 2 - use sub-clients directly) CommentClient() *comments.Client WorkflowClient() *workflows.Client @@ -63,7 +60,7 @@ type CycleClientOperations interface { // ProjectClientOperations defines the minimal interface needed by ProjectService type ProjectClientOperations interface { // Smart resolver-aware methods (kept in Phase 2) - CreateProject(name, description, teamKeyOrName string) (*core.Project, error) + CreateProject(name, summary, description, teamKeyOrName string) (*core.Project, error) // Resolver operations ResolveTeamIdentifier(keyOrName string) (string, error) diff --git a/internal/service/issue.go b/internal/service/issue.go index 2a1537d..7edb543 100644 --- a/internal/service/issue.go +++ b/internal/service/issue.go @@ -450,8 +450,8 @@ type CreateIssueInput struct { 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) + DependsOn []string // Issue identifiers this issue depends on (native "blocks" relations) + BlockedBy []string // Issue identifiers that block this issue (native "blocks" relations) } // Create creates a new issue @@ -576,8 +576,8 @@ type UpdateIssueInput struct { 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) + DependsOn []string // Issue identifiers this issue depends on (native "blocks" relations) + BlockedBy []string // Issue identifiers that block this issue (native "blocks" relations) } // Update updates an existing issue diff --git a/internal/service/issue_create_test.go b/internal/service/issue_create_test.go index c538e8c..139de19 100644 --- a/internal/service/issue_create_test.go +++ b/internal/service/issue_create_test.go @@ -65,9 +65,6 @@ func (m *mockIssueClientForCreate) ListAssignedIssues(limit int) ([]core.Issue, func (m *mockIssueClientForCreate) SearchIssues(filters *core.IssueSearchFilters) (*core.IssueSearchResult, error) { return nil, nil } -func (m *mockIssueClientForCreate) UpdateIssueMetadataKey(id, key string, val interface{}) error { - return nil -} func (m *mockIssueClientForCreate) CreateRelation(issueID, relatedIssueID string, relationType core.IssueRelationType) error { return nil } diff --git a/internal/service/issue_delegate_test.go b/internal/service/issue_delegate_test.go index c253fc9..c6faf72 100644 --- a/internal/service/issue_delegate_test.go +++ b/internal/service/issue_delegate_test.go @@ -64,9 +64,6 @@ func (m *mockIssueClientForDelegate) ResolveProjectIdentifier(nameOrID, teamID s func (m *mockIssueClientForDelegate) CreateRelation(issueID, relatedIssueID string, relationType core.IssueRelationType) error { return nil } -func (m *mockIssueClientForDelegate) UpdateIssueMetadataKey(id, key string, val interface{}) error { - return nil -} func (m *mockIssueClientForDelegate) CommentClient() interface{} { return nil } func (m *mockIssueClientForDelegate) WorkflowClient() interface{} { return &mockWorkflowClient{} diff --git a/internal/service/issue_relation_test.go b/internal/service/issue_relation_test.go index 1125d65..d571781 100644 --- a/internal/service/issue_relation_test.go +++ b/internal/service/issue_relation_test.go @@ -83,9 +83,6 @@ func (m *mockIssueClientForRelation) ResolveLabelIdentifier(label, team string) func (m *mockIssueClientForRelation) ResolveProjectIdentifier(nameOrID, teamID string) (string, error) { return "project-uuid", nil } -func (m *mockIssueClientForRelation) UpdateIssueMetadataKey(id, key string, val interface{}) error { - return nil -} func (m *mockIssueClientForRelation) CommentClient() *comments.Client { return nil } func (m *mockIssueClientForRelation) WorkflowClient() *workflows.Client { return nil } func (m *mockIssueClientForRelation) IssueClient() *issues.Client { return nil } diff --git a/internal/service/project.go b/internal/service/project.go index 40c8583..0db81b5 100644 --- a/internal/service/project.go +++ b/internal/service/project.go @@ -152,7 +152,8 @@ func (s *ProjectService) ListUserProjectsWithOutput(limit int, verbosity format. // CreateProjectInput represents input for creating a project type CreateProjectInput struct { Name string - Description string + Summary string // Short summary shown under the project title (255 char limit) + Description string // Long-form project document (no length limit) TeamID string State string // planned, started, paused, completed, canceled LeadID string // Project lead user ID @@ -175,7 +176,7 @@ func (s *ProjectService) Create(input *CreateProjectInput) (string, error) { return "", fmt.Errorf("failed to resolve team '%s': %w", input.TeamID, err) } - project, err := s.client.CreateProject(input.Name, input.Description, teamID) + project, err := s.client.CreateProject(input.Name, input.Summary, input.Description, teamID) if err != nil { return "", fmt.Errorf("failed to create project: %w", err) } @@ -219,7 +220,8 @@ func (s *ProjectService) Create(input *CreateProjectInput) (string, error) { // UpdateProjectInput represents input for updating a project type UpdateProjectInput struct { Name *string - Description *string + Summary *string // Short summary shown under the project title (255 char limit) + Description *string // Long-form project document (no length limit) State *string // planned, started, paused, completed, canceled LeadID *string // Project lead user ID StartDate *string // Start date YYYY-MM-DD @@ -234,6 +236,9 @@ func (s *ProjectService) Update(projectID string, input *UpdateProjectInput) (st if input.Name != nil { linearInput.Name = input.Name } + if input.Summary != nil { + linearInput.Summary = input.Summary + } if input.Description != nil { linearInput.Description = input.Description } diff --git a/internal/service/search_resolve_test.go b/internal/service/search_resolve_test.go index d88a6e5..20725ed 100644 --- a/internal/service/search_resolve_test.go +++ b/internal/service/search_resolve_test.go @@ -60,9 +60,6 @@ func (m *mockIssueClient) ResolveProjectIdentifier(nameOrID, teamID string) (str func (m *mockIssueClient) CreateRelation(issueID, relatedIssueID string, relationType core.IssueRelationType) error { return nil } -func (m *mockIssueClient) UpdateIssueMetadataKey(id, key string, val interface{}) error { - return nil -} func (m *mockIssueClient) CommentClient() *comments.Client { return nil } func (m *mockIssueClient) WorkflowClient() *workflows.Client { return m.workflowClient } func (m *mockIssueClient) IssueClient() *issues.Client { return nil } diff --git a/internal/skills/link-deps/SKILL.md b/internal/skills/link-deps/SKILL.md index 0008c4e..d0df34d 100644 --- a/internal/skills/link-deps/SKILL.md +++ b/internal/skills/link-deps/SKILL.md @@ -247,9 +247,6 @@ linear issues blocked-by # Check what an issue blocks linear issues blocking - -# List issue dependencies -linear issues dependencies ``` ## Example Workflow diff --git a/pkg/linear/client.go b/pkg/linear/client.go index c20f8d0..c88d540 100644 --- a/pkg/linear/client.go +++ b/pkg/linear/client.go @@ -345,18 +345,10 @@ func (c *Client) UpdateIssueDescription(issueID, newDescription string) error { return c.Issues.UpdateIssueDescription(issueID, newDescription) } -func (c *Client) UpdateIssueMetadataKey(issueID, key string, value interface{}) error { - return c.Issues.UpdateIssueMetadataKey(issueID, key, value) -} - func (c *Client) CreateRelation(issueID, relatedIssueID string, relationType core.IssueRelationType) error { return c.Issues.CreateRelation(issueID, relatedIssueID, relationType) } -func (c *Client) RemoveIssueMetadataKey(issueID, key string) error { - return c.Issues.RemoveIssueMetadataKey(issueID, key) -} - // GetIssueSimplified retrieves basic issue information using a simplified query // Use this as a fallback when the full context queries fail due to server issues. func (c *Client) GetIssueSimplified(issueID string) (*core.Issue, error) { @@ -422,7 +414,7 @@ func (c *Client) ListAllIssues(filter *core.IssueFilter) (*core.ListAllIssuesRes } // Project operations -func (c *Client) CreateProject(name, description, teamKeyOrName string) (*core.Project, error) { +func (c *Client) CreateProject(name, summary, description, teamKeyOrName string) (*core.Project, error) { // Resolve team name/key to UUID if needed teamID := teamKeyOrName if !identifiers.IsUUID(teamKeyOrName) { @@ -433,7 +425,7 @@ func (c *Client) CreateProject(name, description, teamKeyOrName string) (*core.P teamID = resolvedID } - return c.Projects.CreateProject(name, description, teamID) + return c.Projects.CreateProject(name, summary, description, teamID) } func (c *Client) GetProject(projectID string) (*core.Project, error) { @@ -475,14 +467,6 @@ func (c *Client) UpdateProjectDescription(projectID, newDescription string) erro return c.Projects.UpdateProjectDescription(projectID, newDescription) } -func (c *Client) UpdateProjectMetadataKey(projectID, key string, value interface{}) error { - return c.Projects.UpdateProjectMetadataKey(projectID, key, value) -} - -func (c *Client) RemoveProjectMetadataKey(projectID, key string) error { - return c.Projects.RemoveProjectMetadataKey(projectID, key) -} - // Cycle operations func (c *Client) GetCycle(cycleID string) (*core.Cycle, error) { return c.Cycles.GetCycle(cycleID) diff --git a/pkg/linear/core/types.go b/pkg/linear/core/types.go index 37b6f7f..4f73a41 100644 --- a/pkg/linear/core/types.go +++ b/pkg/linear/core/types.go @@ -207,7 +207,6 @@ type Issue struct { 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"` @@ -395,18 +394,20 @@ type ParentIssue struct { ID string `json:"id"` Name string `json:"name"` } `json:"state"` - Metadata map[string]interface{} `json:"metadata,omitempty"` } // Project represents a Linear project +// +// The struct tags are the only place Linear's own names for the two text fields +// appear: its "description" is the short summary under the project title, its +// "content" is the long-form document. 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) + Summary string `json:"description"` // 255 char limit + Description string `json:"content,omitempty"` // no length 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"` } @@ -679,7 +680,7 @@ type ListAllIssuesResult struct { TotalCount int `json:"totalCount"` } -// IssueWithDetails represents an issue with full details including metadata +// IssueWithDetails represents an issue with full details type IssueWithDetails struct { ID string `json:"id"` Identifier string `json:"identifier"` @@ -693,7 +694,6 @@ type IssueWithDetails struct { Labels []Label `json:"labels"` Project *Project `json:"project,omitempty"` Team Team `json:"team"` - Metadata *map[string]interface{} `json:"metadata,omitempty"` } // Label represents a Linear label diff --git a/pkg/linear/doc.go b/pkg/linear/doc.go index 18a3d44..dd9f989 100644 --- a/pkg/linear/doc.go +++ b/pkg/linear/doc.go @@ -2,8 +2,7 @@ // GraphQL support, automatic rate limiting, and robust error handling. // // The client supports all major Linear operations including issue management, -// comments, notifications, team operations, and custom metadata storage using -// a description-based approach. +// comments, notifications, and team operations. // // # Authentication // @@ -40,26 +39,6 @@ // log.Fatal(err) // } // -// # Metadata Management -// -// This client supports storing custom metadata in Linear issue and project -// descriptions using a collapsible markdown format. Metadata is automatically -// extracted when fetching issues/projects and preserved when updating descriptions. -// -// Update metadata for an issue: -// -// err := client.UpdateIssueMetadataKey("issue-id", "priority", "high") -// if err != nil { -// log.Fatal(err) -// } -// -// Remove metadata: -// -// err := client.RemoveIssueMetadataKey("issue-id", "priority") -// if err != nil { -// log.Fatal(err) -// } -// // # Error Handling // // The package defines custom error types for better error handling: diff --git a/pkg/linear/issues/client.go b/pkg/linear/issues/client.go index 1631f88..09068e2 100644 --- a/pkg/linear/issues/client.go +++ b/pkg/linear/issues/client.go @@ -7,7 +7,6 @@ import ( "github.com/joa23/linear-cli/pkg/linear/core" "github.com/joa23/linear-cli/pkg/linear/guidance" - "github.com/joa23/linear-cli/pkg/linear/metadata" "github.com/joa23/linear-cli/pkg/linear/validation" ) @@ -156,23 +155,12 @@ linear_create_issue("Task title", "Description", teams[0].id)`) 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 - // the Metadata field in our Issue struct for consistent access. - if response.IssueCreate.Issue.Description != "" { - metadata, cleanDesc := metadata.ExtractMetadataFromDescription(response.IssueCreate.Issue.Description) - response.IssueCreate.Issue.Metadata = metadata - response.IssueCreate.Issue.Description = cleanDesc - } - + return &response.IssueCreate.Issue, nil } // GetIssue retrieves a single issue by ID // Why: This is the primary method for fetching detailed issue information. -// It automatically extracts metadata from the description for easy access. func (ic *Client) GetIssue(issueID string) (*core.Issue, error) { // Validate input // Why: An empty issue ID would cause the query to fail. Early validation @@ -298,13 +286,6 @@ func (ic *Client) GetIssue(issueID string) (*core.Issue, error) { } } - // Extract metadata from description - if response.Issue.Description != "" { - metadata, cleanDesc := metadata.ExtractMetadataFromDescription(response.Issue.Description) - response.Issue.Metadata = metadata - response.Issue.Description = cleanDesc - } - // Set computed attachment fields if response.Issue.Attachments != nil { response.Issue.AttachmentCount = len(response.Issue.Attachments.Nodes) @@ -316,7 +297,7 @@ func (ic *Client) GetIssue(issueID string) (*core.Issue, error) { // GetIssueWithProjectContext retrieves an issue with additional project information // Why: When working within a project context, we need more project details like -// metadata and state. This method provides that extended information in one call. +// its name and state. This method provides that extended information in one call. func (ic *Client) GetIssueWithProjectContext(issueID string) (*core.Issue, error) { issue, err := ic.getIssueWithProjectContextInternal(issueID) if err != nil { @@ -454,23 +435,7 @@ 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. - if response.Issue.Project != nil && response.Issue.Project.Description != "" { - projectMetadata, cleanProjectDesc := metadata.ExtractMetadataFromDescription(response.Issue.Project.Description) - response.Issue.Project.Metadata = projectMetadata - response.Issue.Project.Description = cleanProjectDesc - } - + return &response.Issue, nil } @@ -613,23 +578,7 @@ 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. - if response.Issue.Parent != nil && response.Issue.Parent.Description != "" { - parentMetadata, cleanParentDesc := metadata.ExtractMetadataFromDescription(response.Issue.Parent.Description) - response.Issue.Parent.Metadata = parentMetadata - response.Issue.Parent.Description = cleanParentDesc - } - + return &response.Issue, nil } @@ -858,18 +807,7 @@ func (ic *Client) ListAssignedIssues(limit int) ([]core.Issue, error) { 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. - for i := range response.Issues.Nodes { - if response.Issues.Nodes[i].Description != "" { - metadata, cleanDesc := metadata.ExtractMetadataFromDescription(response.Issues.Nodes[i].Description) - response.Issues.Nodes[i].Metadata = metadata - response.Issues.Nodes[i].Description = cleanDesc - } - } - + return response.Issues.Nodes, nil } @@ -1317,99 +1255,12 @@ func (ic *Client) GetSubIssues(parentIssueID string) ([]core.SubIssue, error) { return response.Issue.Children.Nodes, nil } -// UpdateIssueDescription updates an issue's description while preserving metadata -// Why: Descriptions may contain both user content and hidden metadata. This method -// ensures metadata is preserved when users update descriptions. +// UpdateIssueDescription replaces an issue's description. 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. - issue, err := ic.GetIssue(issueID) - 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. - descriptionWithMetadata := newDescription - if issue.Metadata != nil && len(issue.Metadata) > 0 { - descriptionWithMetadata = metadata.InjectMetadataIntoDescription(newDescription, issue.Metadata) - } - - const mutation = ` - mutation UpdateIssueDescription($issueId: String!, $description: String!) { - issueUpdate( - id: $issueId, - input: { description: $description } - ) { - success - } - } - ` - - 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 -} - -// UpdateIssueMetadataKey updates a specific metadata key for an issue -// Why: Granular metadata updates are more efficient than replacing all metadata. -// This method allows updating individual keys without affecting others. -func (ic *Client) UpdateIssueMetadataKey(issueID, key string, value interface{}) error { - if issueID == "" { - return &core.ValidationError{Field: "issueID", Message: "issueID cannot be empty"} - } - if key == "" { - return &core.ValidationError{Field: "key", Message: "key cannot be empty"} - } - if !validation.IsValidMetadataKey(key) { - return &core.ValidationError{Field: "key", Value: key, Reason: "must be alphanumeric with underscores or hyphens, starting with letter or underscore"} - } - // Get current issue to access existing metadata - // Why: We need to merge the new key-value with existing metadata - // to avoid losing other metadata entries during the update. - issue, err := ic.GetIssue(issueID) - 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. - if issue.Metadata == nil { - 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( @@ -1423,85 +1274,7 @@ 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 -} - -// RemoveIssueMetadataKey removes a specific metadata key from an issue -// Why: Sometimes metadata keys become obsolete or need to be cleaned up. -// This method provides that capability without affecting other metadata. -func (ic *Client) RemoveIssueMetadataKey(issueID, key string) error { - if issueID == "" { - return &core.ValidationError{Field: "issueID", Message: "issueID cannot be empty"} - } - 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. - if issue.Metadata == nil || len(issue.Metadata) == 0 { - // No metadata to remove from, nothing to do - return nil - } - - // Check if key exists before attempting removal - if _, exists := issue.Metadata[key]; !exists { - // Key doesn't exist, nothing to do - return nil - } - - delete(issue.Metadata, key) - - // Update description with modified metadata - // Why: After removing the key, we need to update the description - // with the remaining metadata, or remove metadata entirely if empty. - var descriptionWithMetadata string - if len(issue.Metadata) > 0 { - descriptionWithMetadata = metadata.InjectMetadataIntoDescription(issue.Description, issue.Metadata) - } else { - // No metadata left, just use the clean description - descriptionWithMetadata = issue.Description - } - - const mutation = ` - mutation UpdateIssueDescription($issueId: String!, $description: String!) { - issueUpdate( - id: $issueId, - input: { description: $description } - ) { - success - } - } - ` - - variables := map[string]interface{}{ - "issueId": issueID, - "description": descriptionWithMetadata, + "description": newDescription, } var response struct { @@ -1510,15 +1283,15 @@ func (ic *Client) RemoveIssueMetadataKey(issueID, key string) error { } `json:"issueUpdate"` } - err = ic.base.ExecuteRequest(mutation, variables, &response) + 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 metadata removal was not successful") + return fmt.Errorf("issue description update was not successful") } - + return nil } @@ -1604,14 +1377,7 @@ func (ic *Client) GetIssueSimplified(issueID string) (*core.Issue, error) { 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{} @@ -1643,8 +1409,7 @@ func (ic *Client) GetIssueWithFallback(issueID string) (*core.Issue, error) { // UpdateIssue updates an issue with the provided fields // Why: Issues need to be updated with various fields like title, description, priority, etc. -// This method provides a flexible way to update any combination of fields while preserving -// existing data like metadata. +// This method provides a flexible way to update any combination of fields. func (ic *Client) UpdateIssue(issueID string, input core.UpdateIssueInput) (*core.Issue, error) { // Validate inputs if issueID == "" { @@ -1660,21 +1425,7 @@ func (ic *Client) UpdateIssue(issueID string, input core.UpdateIssueInput) (*cor 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!) { @@ -1761,14 +1512,7 @@ func (ic *Client) UpdateIssue(issueID string, input core.UpdateIssueInput) (*cor 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 } @@ -1865,7 +1609,7 @@ func parseLinearIdentifier(identifier string) string { // ListAllIssues retrieves issues with comprehensive filtering, pagination, and sorting options // Why: Users need flexible ways to query issues across teams, projects, states, etc. -// This method provides a powerful search interface with metadata support. +// This method provides a powerful search interface. func (ic *Client) ListAllIssues(filter *core.IssueFilter) (*core.ListAllIssuesResult, error) { // Validate required fields if filter == nil { @@ -2019,22 +1763,6 @@ func (ic *Client) ListAllIssues(filter *core.IssueFilter) (*core.ListAllIssuesRe Team: node.Team, } - // Extract metadata from description - if issue.Description != "" { - metadata, cleanDesc := metadata.ExtractMetadataFromDescription(issue.Description) - if len(metadata) > 0 { - issue.Metadata = &metadata - } - issue.Description = cleanDesc - } - - // Extract metadata from project description if present - if issue.Project != nil && issue.Project.Description != "" { - projectMetadata, cleanProjectDesc := metadata.ExtractMetadataFromDescription(issue.Project.Description) - issue.Project.Metadata = projectMetadata - issue.Project.Description = cleanProjectDesc - } - result.Issues = append(result.Issues, issue) } diff --git a/pkg/linear/metadata/doc.go b/pkg/linear/metadata/doc.go deleted file mode 100644 index b731c31..0000000 --- a/pkg/linear/metadata/doc.go +++ /dev/null @@ -1,68 +0,0 @@ -// Package metadata provides HTML-based metadata storage for Linear issues and projects. -// -// Linear doesn't provide native custom fields, so this package implements -// metadata storage by embedding HTML comment blocks in markdown descriptions. -// The metadata is invisible in Linear's UI but accessible via the API. -// -// # Metadata Format -// -// Metadata is stored as JSON in an HTML comment block: -// -// -// -// # Extraction -// -// Extract metadata from a description: -// -// description := "Issue description\n" -// metadata, cleanDesc := metadata.ExtractMetadataFromDescription(description) -// // metadata = map[string]interface{}{"key": "value"} -// // cleanDesc = "Issue description" -// -// # Injection -// -// Inject metadata into a description: -// -// metadata := map[string]interface{}{"priority": "high", "customer": "Acme Corp"} -// newDesc := metadata.InjectMetadataIntoDescription("User description", metadata) -// // Adds HTML comment block with metadata to description -// -// # Preservation During Updates -// -// When updating descriptions, preserve existing metadata: -// -// oldDesc := "Old text\n" -// newDesc := "New text" -// finalDesc := metadata.UpdateDescriptionPreservingMetadata(oldDesc, newDesc) -// // finalDesc = "New text\n" -// -// # Use Cases -// -// Common metadata use cases: -// - Custom priority systems -// - Customer/stakeholder tracking -// - External system IDs -// - AI agent state tracking -// - Custom workflow flags -// -// # Design Rationale -// -// This approach has several advantages: -// - Works within Linear's existing API (no schema changes needed) -// - Invisible to users in Linear's UI (clean UX) -// - Survives copy/paste operations -// - Compatible with Linear's markdown rendering -// - Type-safe JSON storage -// -// The HTML comment format was chosen because: -// - Linear renders it invisibly (unlike code blocks) -// - It's valid markdown -// - It doesn't interfere with descriptions -// - It's easily parsable and detectable -package metadata diff --git a/pkg/linear/metadata/metadata.go b/pkg/linear/metadata/metadata.go deleted file mode 100644 index a2a5893..0000000 --- a/pkg/linear/metadata/metadata.go +++ /dev/null @@ -1,126 +0,0 @@ -package metadata - -import ( - - "encoding/json" - "fmt" - "regexp" - "strings" -) - -// Metadata pattern for description-based storage -const metadataPattern = `(?s)
🤖 Metadata\s*` + "```json\n(.*?)\n```" + `\s*
` - -var metadataRegex = regexp.MustCompile(metadataPattern) - -// extractMetadataFromDescription extracts metadata JSON from a description and returns both -// the metadata and the description without the metadata section. -// -// Why this approach: We store metadata as a hidden collapsible section in descriptions -// to avoid cluttering the UI while preserving structured data. This allows metadata to -// travel with issues and projects without requiring separate API calls. -func ExtractMetadataFromDescription(description string) (map[string]interface{}, string) { - if description == "" { - return make(map[string]interface{}), "" - } - - // Find metadata section - matches := metadataRegex.FindStringSubmatch(description) - if len(matches) < 2 { - // No metadata found, return original description - return make(map[string]interface{}), description - } - - // Parse the JSON metadata - var metadata map[string]interface{} - jsonStr := matches[1] - if err := json.Unmarshal([]byte(jsonStr), &metadata); err != nil { - // If JSON is malformed, log it but still remove the metadata section - // Why: We don't want malformed metadata to break issue retrieval. - // The description is still valid even if metadata parsing fails. - fmt.Printf("Warning: Failed to parse metadata JSON: %v\n", err) - // Remove the malformed metadata section from description - cleanDescription := metadataRegex.ReplaceAllString(description, "") - // Clean up extra newlines that might be left after removal - cleanDescription = strings.ReplaceAll(cleanDescription, "\n\n\n\n", "\n\n") - cleanDescription = strings.TrimSpace(cleanDescription) - return make(map[string]interface{}), cleanDescription - } - - // Remove metadata section from description - // Why: We want to present a clean description to users without the - // technical metadata markup. The metadata is available separately. - cleanDescription := metadataRegex.ReplaceAllString(description, "") - // Clean up extra newlines that might be left after removal - cleanDescription = strings.ReplaceAll(cleanDescription, "\n\n\n\n", "\n\n") - cleanDescription = strings.TrimSpace(cleanDescription) - - return metadata, cleanDescription -} - -// injectMetadataIntoDescription adds metadata to a description as a collapsible section. -// If the description already contains metadata, it will be replaced. -// -// Why this approach: By always replacing existing metadata, we ensure there's only -// one metadata section and it's always up-to-date. The collapsible format keeps -// the description readable while preserving the data. -func InjectMetadataIntoDescription(description string, metadata map[string]interface{}) string { - if metadata == nil || len(metadata) == 0 { - // No metadata to inject - return description - } - - // Convert metadata to pretty-printed JSON - // Why: Pretty printing makes the metadata human-readable if someone - // expands the collapsible section in the Linear UI. - jsonBytes, err := json.MarshalIndent(metadata, "", " ") - if err != nil { - // If we can't marshal the metadata, return original description - // Why: Better to preserve the description than fail the operation - // due to metadata serialization issues. - fmt.Printf("Warning: Failed to marshal metadata: %v\n", err) - return description - } - - // Remove any existing metadata section - // Why: We want to avoid duplicate metadata sections which could - // cause confusion and parsing issues. - cleanDescription := description - if metadataRegex.MatchString(description) { - cleanDescription = metadataRegex.ReplaceAllString(description, "") - cleanDescription = strings.TrimSpace(cleanDescription) - } - - // Create the metadata section - // Why: The specific format with emoji and markdown ensures consistent - // rendering across different contexts and makes it easily identifiable. - metadataSection := fmt.Sprintf("
🤖 Metadata\n\n```json\n%s\n```\n
", string(jsonBytes)) - - // Append metadata section to description - // Why: Appending at the end keeps the main description content at the - // top where it's most visible and relevant to users. - if cleanDescription == "" { - return metadataSection - } - return cleanDescription + "\n\n" + metadataSection -} - -// updateDescriptionPreservingMetadata updates a description while preserving any existing metadata. -// This is useful when updating description content without losing metadata. -// -// Why: Users often want to update the human-readable description without worrying -// about preserving technical metadata. This function handles that automatically. -func UpdateDescriptionPreservingMetadata(oldDescription, newDescription string) string { - // Extract metadata from old description - metadata, _ := ExtractMetadataFromDescription(oldDescription) - - // If there was metadata, inject it into the new description - // Why: This ensures metadata isn't accidentally lost when users update - // descriptions through various interfaces. - if metadata != nil && len(metadata) > 0 { - return InjectMetadataIntoDescription(newDescription, metadata) - } - - // No metadata to preserve - return newDescription -} \ No newline at end of file diff --git a/pkg/linear/projects/client.go b/pkg/linear/projects/client.go index 67a7e2a..9d67b3b 100644 --- a/pkg/linear/projects/client.go +++ b/pkg/linear/projects/client.go @@ -6,7 +6,6 @@ import ( "github.com/joa23/linear-cli/pkg/linear/core" "github.com/joa23/linear-cli/pkg/linear/guidance" - "github.com/joa23/linear-cli/pkg/linear/metadata" ) // ProjectClient handles all project-related operations for the Linear API. @@ -24,7 +23,8 @@ func NewClient(base *core.BaseClient) *Client { // CreateProject creates a new project in Linear // Why: Projects are containers for organizing related issues. This method // enables project creation with proper team assignment. -func (pc *Client) CreateProject(name, description, teamID string) (*core.Project, error) { +// The summary is capped at 255 characters by Linear; the description is not. +func (pc *Client) CreateProject(name, summary, description, teamID string) (*core.Project, error) { // Validate required inputs // Why: Name and teamID are mandatory for project creation. Early // validation provides clearer error messages than API errors. @@ -43,6 +43,7 @@ func (pc *Client) CreateProject(name, description, teamID string) (*core.Project id name description + content state createdAt updatedAt @@ -59,15 +60,18 @@ func (pc *Client) CreateProject(name, description, teamID string) (*core.Project ` // Build the input object - // Why: Linear's API expects specific fields. We conditionally include - // description only if provided to avoid sending empty strings. + // Why: Linear's API expects specific fields. We conditionally include the + // text fields only if provided to avoid sending empty strings. // Note: Linear API requires teamIds (plural, array) not teamId (singular). input := map[string]interface{}{ "name": name, "teamIds": []string{teamID}, } + if summary != "" { + input["description"] = summary + } if description != "" { - input["description"] = description + input["content"] = description } variables := map[string]interface{}{ @@ -89,22 +93,13 @@ func (pc *Client) CreateProject(name, description, teamID string) (*core.Project if !response.ProjectCreate.Success { return nil, fmt.Errorf("project creation was not successful") } - - // Extract metadata from description if present - // Why: Projects can have metadata stored in descriptions. We extract - // it immediately after creation for consistent access. - if response.ProjectCreate.Project.Description != "" { - metadata, cleanDesc := metadata.ExtractMetadataFromDescription(response.ProjectCreate.Project.Description) - response.ProjectCreate.Project.Metadata = metadata - response.ProjectCreate.Project.Description = cleanDesc - } - + return &response.ProjectCreate.Project, nil } // GetProject retrieves a single project by ID // Why: This is the primary method for fetching detailed project information -// including associated issues and metadata. +// including associated issues. func (pc *Client) GetProject(projectID string) (*core.Project, error) { // Validate input // Why: Empty project ID would cause the query to fail with unclear @@ -188,21 +183,6 @@ linear_get_project(correctProject.id)`), } } - // Extract metadata from content (or fallback to description for backwards compatibility) - // Why: Metadata is embedded in project content as hidden markdown. - // Content is preferred over description as it has no character limit. - // Extracting it here ensures consistent access across all retrieval methods. - if response.Project.Content != "" { - metadata, cleanContent := metadata.ExtractMetadataFromDescription(response.Project.Content) - response.Project.Metadata = metadata - response.Project.Content = cleanContent - } else if response.Project.Description != "" { - // Fallback: check description for backwards compatibility with old metadata storage - metadata, cleanDesc := metadata.ExtractMetadataFromDescription(response.Project.Description) - response.Project.Metadata = metadata - response.Project.Description = cleanDesc - } - return &response.Project, nil } @@ -248,22 +228,6 @@ func (pc *Client) ListAllProjects(limit int) ([]core.Project, error) { return nil, fmt.Errorf("failed to list projects: %w", err) } - // Extract metadata from content (or fallback to description) - // Why: Each project might have metadata. We extract it here to ensure - // users can access metadata without additional calls. - for i := range response.Projects.Nodes { - if response.Projects.Nodes[i].Content != "" { - metadata, cleanContent := metadata.ExtractMetadataFromDescription(response.Projects.Nodes[i].Content) - response.Projects.Nodes[i].Metadata = metadata - response.Projects.Nodes[i].Content = cleanContent - } else if response.Projects.Nodes[i].Description != "" { - // Fallback to description for backwards compatibility - metadata, cleanDesc := metadata.ExtractMetadataFromDescription(response.Projects.Nodes[i].Description) - response.Projects.Nodes[i].Metadata = metadata - response.Projects.Nodes[i].Description = cleanDesc - } - } - return response.Projects.Nodes, nil } @@ -316,19 +280,6 @@ func (pc *Client) ListByTeam(teamID string, limit int) ([]core.Project, error) { return nil, fmt.Errorf("failed to list projects by team: %w", err) } - // Extract metadata from content (or fallback to description) - for i := range response.Team.Projects.Nodes { - if response.Team.Projects.Nodes[i].Content != "" { - metadata, cleanContent := metadata.ExtractMetadataFromDescription(response.Team.Projects.Nodes[i].Content) - response.Team.Projects.Nodes[i].Metadata = metadata - response.Team.Projects.Nodes[i].Content = cleanContent - } else if response.Team.Projects.Nodes[i].Description != "" { - metadata, cleanDesc := metadata.ExtractMetadataFromDescription(response.Team.Projects.Nodes[i].Description) - response.Team.Projects.Nodes[i].Metadata = metadata - response.Team.Projects.Nodes[i].Description = cleanDesc - } - } - return response.Team.Projects.Nodes, nil } @@ -410,28 +361,14 @@ func (pc *Client) ListUserProjects(userID string, limit int) ([]core.Project, er } } - // Extract metadata from content (or fallback to description) - for i := range filteredProjects { - if filteredProjects[i].Content != "" { - metadata, cleanContent := metadata.ExtractMetadataFromDescription(filteredProjects[i].Content) - filteredProjects[i].Metadata = metadata - filteredProjects[i].Content = cleanContent - } else if filteredProjects[i].Description != "" { - // Fallback to description for backwards compatibility - metadata, cleanDesc := metadata.ExtractMetadataFromDescription(filteredProjects[i].Description) - filteredProjects[i].Metadata = metadata - filteredProjects[i].Description = cleanDesc - } - } - return filteredProjects, nil } // UpdateProjectInput represents the input for updating a project type UpdateProjectInput struct { Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - Content *string `json:"content,omitempty"` + Summary *string `json:"description,omitempty"` + Description *string `json:"content,omitempty"` State *string `json:"state,omitempty"` LeadID *string `json:"leadId,omitempty"` StartDate *string `json:"startDate,omitempty"` @@ -439,7 +376,7 @@ type UpdateProjectInput struct { } // UpdateProject updates a project with the provided input -// Supports updating name, description, state, lead, start date, and target date +// Supports updating name, summary, description, state, lead, start date, and target date func (pc *Client) UpdateProject(projectID string, input UpdateProjectInput) (*core.Project, error) { if projectID == "" { return nil, &core.ValidationError{Field: "projectID", Message: "projectID cannot be empty"} @@ -470,11 +407,11 @@ func (pc *Client) UpdateProject(projectID string, input UpdateProjectInput) (*co if input.Name != nil { inputMap["name"] = *input.Name } - if input.Description != nil { - inputMap["description"] = *input.Description + if input.Summary != nil { + inputMap["description"] = *input.Summary } - if input.Content != nil { - inputMap["content"] = *input.Content + if input.Description != nil { + inputMap["content"] = *input.Description } if input.State != nil { inputMap["state"] = *input.State @@ -573,9 +510,7 @@ func (pc *Client) UpdateProjectState(projectID, state string) error { return nil } -// UpdateProjectDescription updates a project's content while preserving metadata -// Why: Project content may contain both user content and metadata. This -// method ensures metadata is preserved during content updates. +// UpdateProjectDescription replaces a project's content. // Note: Linear has two fields - 'description' (255 char limit) and 'content' (no limit). // We use 'content' for longer text to avoid the character limit. func (pc *Client) UpdateProjectDescription(projectID, newContent string) error { @@ -583,22 +518,6 @@ func (pc *Client) UpdateProjectDescription(projectID, newContent string) error { return &core.ValidationError{Field: "projectID", Message: "projectID cannot be empty"} } - // First, get the current project to preserve metadata - // Why: We need to extract existing metadata before updating to ensure - // it's not lost during the content update. - project, err := pc.GetProject(projectID) - if err != nil { - return fmt.Errorf("failed to get current project: %w", err) - } - - // Preserve existing metadata - // Why: The project.Metadata field contains extracted metadata that - // needs to be injected back into the new content. - contentWithMetadata := newContent - if project.Metadata != nil && len(project.Metadata) > 0 { - contentWithMetadata = metadata.InjectMetadataIntoDescription(newContent, project.Metadata) - } - const mutation = ` mutation UpdateProjectContent($projectId: String!, $content: String!) { projectUpdate( @@ -612,7 +531,7 @@ func (pc *Client) UpdateProjectDescription(projectID, newContent string) error { variables := map[string]interface{}{ "projectId": projectID, - "content": contentWithMetadata, + "content": newContent, } var response struct { @@ -621,7 +540,7 @@ func (pc *Client) UpdateProjectDescription(projectID, newContent string) error { } `json:"projectUpdate"` } - err = pc.base.ExecuteRequest(mutation, variables, &response) + err := pc.base.ExecuteRequest(mutation, variables, &response) if err != nil { return fmt.Errorf("failed to update project content: %w", err) } @@ -632,149 +551,3 @@ func (pc *Client) UpdateProjectDescription(projectID, newContent string) error { return nil } - -// UpdateProjectMetadataKey updates a specific metadata key for a project -// Why: Granular metadata updates allow changing individual values without -// affecting other metadata. This is more efficient than full replacements. -// Note: Uses 'content' field instead of 'description' to avoid 255 char limit. -func (pc *Client) UpdateProjectMetadataKey(projectID, key string, value interface{}) error { - if projectID == "" { - return &core.ValidationError{Field: "projectID", Message: "projectID cannot be empty"} - } - if key == "" { - return &core.ValidationError{Field: "key", Message: "key cannot be empty"} - } - - // Get current project to access existing metadata - // Why: We need to merge the new key-value with existing metadata - // to preserve other metadata entries. - project, err := pc.GetProject(projectID) - if err != nil { - return fmt.Errorf("failed to get current project: %w", err) - } - - // Special handling for projects with null/empty content - // Linear's API may reject updates to projects with null content - // For now, we'll work around this by ensuring we always have content - if project.Content == "" { - // Set a minimal placeholder that won't be visible in Linear UI - // but ensures the API accepts our update - project.Content = " " // Single space - } - - // Initialize metadata if needed and update the key - // Why: The project might not have metadata yet. We initialize it - // before adding the new key-value pair. - if project.Metadata == nil { - project.Metadata = make(map[string]interface{}) - } - project.Metadata[key] = value - - // Update the content with new metadata - contentWithMetadata := metadata.InjectMetadataIntoDescription(project.Content, project.Metadata) - - const mutation = ` - mutation UpdateProjectContent($projectId: String!, $content: String!) { - projectUpdate( - id: $projectId, - input: { content: $content } - ) { - success - } - } - ` - - variables := map[string]interface{}{ - "projectId": projectID, - "content": contentWithMetadata, - } - - var response struct { - ProjectUpdate struct { - Success bool `json:"success"` - } `json:"projectUpdate"` - } - - err = pc.base.ExecuteRequest(mutation, variables, &response) - if err != nil { - // Add more context for debugging - return fmt.Errorf("failed to update project metadata (projectID: %s, key: %s, content length: %d): %w", - projectID, key, len(contentWithMetadata), err) - } - - if !response.ProjectUpdate.Success { - return fmt.Errorf("project metadata update was not successful") - } - - return nil -} - -// RemoveProjectMetadataKey removes a specific metadata key from a project -// Why: Metadata keys may become obsolete. This method allows selective -// removal without affecting other metadata. -// Note: Uses 'content' field instead of 'description' to avoid 255 char limit. -func (pc *Client) RemoveProjectMetadataKey(projectID, key string) error { - if projectID == "" { - return &core.ValidationError{Field: "projectID", Message: "projectID cannot be empty"} - } - if key == "" { - return &core.ValidationError{Field: "key", Message: "key cannot be empty"} - } - - // Get current project - project, err := pc.GetProject(projectID) - if err != nil { - return fmt.Errorf("failed to get current project: %w", err) - } - - // Remove the key if metadata exists - // Why: We only update if there's metadata and the key exists. - // No API call needed if there's nothing to remove. - if project.Metadata != nil { - delete(project.Metadata, key) - - // Update content with modified metadata - // Why: After removing the key, we either update with remaining - // metadata or remove the metadata section entirely if empty. - var contentWithMetadata string - if len(project.Metadata) > 0 { - contentWithMetadata = metadata.InjectMetadataIntoDescription(project.Content, project.Metadata) - } else { - // No metadata left, just use the clean content - contentWithMetadata = project.Content - } - - const mutation = ` - mutation UpdateProjectContent($projectId: String!, $content: String!) { - projectUpdate( - id: $projectId, - input: { content: $content } - ) { - success - } - } - ` - - variables := map[string]interface{}{ - "projectId": projectID, - "content": contentWithMetadata, - } - - var response struct { - ProjectUpdate struct { - Success bool `json:"success"` - } `json:"projectUpdate"` - } - - err = pc.base.ExecuteRequest(mutation, variables, &response) - if err != nil { - return fmt.Errorf("failed to update project content: %w", err) - } - - if !response.ProjectUpdate.Success { - return fmt.Errorf("project metadata removal was not successful") - } - } - - return nil -} \ No newline at end of file diff --git a/pkg/linear/validation/doc.go b/pkg/linear/validation/doc.go index ac8a2f2..3de4c59 100644 --- a/pkg/linear/validation/doc.go +++ b/pkg/linear/validation/doc.go @@ -31,16 +31,12 @@ // // # Special Format Validation // -// Validate emoji and metadata key formats: +// Validate emoji format: // // if !validation.IsValidEmoji("👍") { // return errors.New("invalid emoji") // } // -// if !validation.IsValidMetadataKey("my-key_123") { -// return errors.New("key must be alphanumeric with hyphens/underscores") -// } -// // # Design Principles // // Validation in this package follows these principles: diff --git a/pkg/linear/validation/validation.go b/pkg/linear/validation/validation.go index adce11b..96fe2fc 100644 --- a/pkg/linear/validation/validation.go +++ b/pkg/linear/validation/validation.go @@ -4,8 +4,6 @@ import ( "github.com/joa23/linear-cli/pkg/linear/core" "fmt" - "regexp" - "unicode" ) // Constants for validation limits @@ -18,27 +16,6 @@ const ( MaxNotificationLimit = 100 ) -// isValidMetadataKey validates that a metadata key follows proper naming conventions -// Valid keys must: -// - Not be empty -// - Start with a letter or underscore -// - Contain only letters, numbers, underscores, or hyphens -func IsValidMetadataKey(key string) bool { - if key == "" { - return false - } - - // Must start with letter or underscore - firstRune := rune(key[0]) - if !unicode.IsLetter(firstRune) && firstRune != '_' { - return false - } - - // Rest must be alphanumeric, underscore, or hyphen - validKeyRegex := regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_-]*$`) - return validKeyRegex.MatchString(key) -} - // isValidEmoji checks if a string is a single valid emoji func IsValidEmoji(emoji string) bool { if emoji == "" {