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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ tags, PR merge commits, and tag-to-tag commit history.

## [Unreleased]

### Added

- Accept Linear URLs as issue, project, team and comment references, plus GitHub PR
URLs attached to a Linear issue. e.g.
`linctl issue get https://linear.app/acme/issue/ENG-123/fix-the-thing`

## [v0.1.12] - 2026-08-23

### Added
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,22 @@ This improves performance and prevents overwhelming data loads. To see older ite
- Need archived matches? Add `--include-archived` when using `issue search`.


## Pasting URLs

Paste Linear URLs anywhere `linctl` takes an issue, project, team or comment.

```bash
linctl issue get https://linear.app/acme/issue/ENG-123/fix-the-thing
linctl comment get 'https://linear.app/acme/issue/ENG-123/fix-the-thing#comment-b68a4bf5'
linctl project get https://linear.app/acme/project/roadmap-d05c5c7e8a5c/overview
linctl issue list --team https://linear.app/acme/team/ENG/active
linctl issue get https://github.com/acme/api/pull/6153
```

Quote URLs containing `#`. GitHub PR URLs will work as well, but only if one is
attached to an existing Linear issue.


## Quick Start

> **IMPORTANT** Agents like Claude Code, Cursor, and Gemini should use the `--json` flag on all read operations.
Expand Down
2 changes: 2 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Use this skill when the user wants to inspect or modify Linear data through `lin
- Before writing, inspect current state first (`get` / `list --json`).
- Use command-specific help for exact flags and validation rules: `linctl <command> <subcommand> --help`.
- Be explicit with filters; defaults can hide expected results.
- Linear URLs work as entity references. Quote URLs containing `#`. Also GitHub pull
request URLs, but only if it's attached to a Linear issue.

## High-Impact Gotchas

Expand Down
20 changes: 15 additions & 5 deletions cmd/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -848,7 +848,12 @@ func buildIssueFilter(cmd *cobra.Command) map[string]interface{} {
}

if team, _ := cmd.Flags().GetString("team"); team != "" {
filter["team"] = map[string]interface{}{"key": map[string]interface{}{"eq": team}}
teamKey, err := api.NormalizeTeamRef(team)
if err != nil {
output.Error(err.Error(), viper.GetBool("plaintext"), viper.GetBool("json"))
os.Exit(1)
}
filter["team"] = map[string]interface{}{"key": map[string]interface{}{"eq": teamKey}}
}

if priority, _ := cmd.Flags().GetInt("priority"); priority != -1 {
Expand Down Expand Up @@ -970,17 +975,17 @@ func isUnsetValue(value string) bool {
return false
}
}


func findProjectByNameOrID(projects []api.Project, value string) *api.Project {
normalized := strings.TrimSpace(value)
if normalized == "" {
return nil
}

for i := range projects {
if projects[i].ID == normalized || strings.EqualFold(projects[i].Name, normalized) {
return &projects[i]
project := &projects[i]
if project.ID == normalized || strings.EqualFold(project.Name, normalized) ||
project.SlugId == normalized || (project.SlugId != "" && strings.HasSuffix(normalized, "-"+project.SlugId)) {
return project
}
}

Expand Down Expand Up @@ -1023,6 +1028,11 @@ func listAllProjects(ctx context.Context, client *api.Client) ([]api.Project, er
}

func resolveProjectID(ctx context.Context, client *api.Client, projectValue string) (string, error) {
projectValue, err := api.NormalizeProjectRef(projectValue)
if err != nil {
return "", err
}

projects, err := listAllProjects(ctx, client)
if err != nil {
return "", err
Expand Down
34 changes: 34 additions & 0 deletions cmd/issue_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,3 +361,37 @@ func TestEstimateFlagRegistered(t *testing.T) {
t.Fatal("issue update is missing --estimate flag")
}
}

func TestBuildIssueFilterAcceptsTeamURL(t *testing.T) {
resetIssueCommandFlags(t, issueListCmd, "team")
_ = issueListCmd.Flags().Set("team", "https://linear.app/glif/team/API/active")
defer resetIssueCommandFlags(t, issueListCmd, "team")

filter := buildIssueFilter(issueListCmd)
team, ok := filter["team"].(map[string]interface{})
if !ok {
t.Fatalf("expected a team filter, got %#v", filter["team"])
}
key, ok := team["key"].(map[string]interface{})
if !ok || key["eq"] != "API" {
t.Fatalf("expected team key API, got %#v", team["key"])
}
}

func TestFindProjectByNameOrIDMatchesSlugID(t *testing.T) {
projects := []api.Project{
{ID: "uuid-1", Name: "Benchmarkmaxx", SlugId: "d05c5c7e8a5c"},
{ID: "uuid-2", Name: "Agent Evals", SlugId: "4c5eb664551d"},
}

for _, value := range []string{"benchmarkmaxx-d05c5c7e8a5c", "d05c5c7e8a5c", "Benchmarkmaxx", "uuid-1"} {
project := findProjectByNameOrID(projects, value)
if project == nil || project.ID != "uuid-1" {
t.Fatalf("findProjectByNameOrID(%q) = %#v, want uuid-1", value, project)
}
}

if project := findProjectByNameOrID(projects, "not-a-project"); project != nil {
t.Fatalf("expected no match, got %#v", project)
}
}
30 changes: 30 additions & 0 deletions pkg/api/project_status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,33 @@ func TestGetProjectStatuses(t *testing.T) {
t.Fatalf("unexpected status: %+v", got)
}
}

func TestGetProjectsSelectsSlugIDAndStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req gqlTestRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode request: %v", err)
}
for _, field := range []string{"slugId", "status {"} {
if !strings.Contains(req.Query, field) {
t.Fatalf("expected Projects query to select %q, got: %s", field, req.Query)
}
}

w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"projects":{"nodes":[{"id":"project-1","slugId":"abc123","name":"Roof","status":{"id":"status-shaping","name":"Shaping","type":"backlog"}}],"pageInfo":{"hasNextPage":false}}}}`))
}))
defer srv.Close()

c := NewClientWithURL(srv.URL, "Bearer test")
projects, err := c.GetProjects(context.Background(), nil, 10, "", "")
if err != nil {
t.Fatalf("GetProjects returned error: %v", err)
}
if len(projects.Nodes) != 1 {
t.Fatalf("expected one project, got %d", len(projects.Nodes))
}
if got := projects.Nodes[0]; got.SlugId != "abc123" || got.Status == nil || got.Status.Name != "Shaping" {
t.Fatalf("unexpected project: %+v", got)
}
}
Loading