Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <issue-id>` 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 `<details><summary>` 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
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
138 changes: 57 additions & 81 deletions internal/cli/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -30,7 +31,6 @@ func newIssuesCmd() *cobra.Command {
newIssuesExportCmd(),
newIssuesReplyCmd(),
newIssuesReactCmd(),
newIssuesDependenciesCmd(),
newIssuesBlockedByCmd(),
newIssuesBlockingCmd(),
)
Expand Down Expand Up @@ -960,86 +960,75 @@ func newIssuesReactCmd() *cobra.Command {
}
}

func newIssuesDependenciesCmd() *cobra.Command {
return &cobra.Command{
Use: "dependencies <issue-id>",
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)
}
}

func newIssuesBlockedByCmd() *cobra.Command {
return &cobra.Command{
Use: "blocked-by <issue-id>",
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
},
}
Expand All @@ -1049,36 +1038,23 @@ func newIssuesBlockingCmd() *cobra.Command {
return &cobra.Command{
Use: "blocking <issue-id>",
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
},
}
Expand Down
135 changes: 135 additions & 0 deletions internal/cli/issues_relations_test.go
Original file line number Diff line number Diff line change
@@ -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])
}
}
}
Loading