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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### 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
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])
}
}
}
2 changes: 1 addition & 1 deletion internal/cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
3 changes: 0 additions & 3 deletions internal/service/client_interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions internal/service/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading