From 56ad845ec317529c0493002ef784c1575d581cab Mon Sep 17 00:00:00 2001 From: Jamie Dubs <1903+jamiew@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:24:45 -0400 Subject: [PATCH 1/6] Ignore .claude/ --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index cc50a9a..eabd494 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,7 @@ dist/ # Test files .env.test coverage.out -coverage.html \ No newline at end of file +coverage.html + +# Claude Code directory +.claude/ \ No newline at end of file From 93a575ea2a80f618a7d19934565a0e4936eb1ed7 Mon Sep 17 00:00:00 2001 From: Jamie Dubs <1903+jamiew@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:58:06 -0400 Subject: [PATCH 2/6] Accept Linear web app URLs as entity references - new pkg/api/reference.go parses linear.app URLs for issues, projects, documents, initiatives, teams and reviews, plus scheme-less pastes and linear:// deep links - wired into the API client, so URLs work for positional args and for flags like --team, --project and --parent - comment URLs carry only an 8-char prefix, so those resolve via the issue's comments - a GitHub pull request URL resolves to the issue it is attached to - review URLs cannot be resolved to an issue by Linear's API, so they now fail with an explanation and a suggested issue search --- CHANGELOG.md | 14 ++ README.md | 37 +++++ SKILL.md | 5 + cmd/issue.go | 19 ++- cmd/issue_cmd_test.go | 34 ++++ pkg/api/queries.go | 118 ++++++++++++-- pkg/api/reference.go | 288 ++++++++++++++++++++++++++++++++ pkg/api/reference_test.go | 334 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 832 insertions(+), 17 deletions(-) create mode 100644 pkg/api/reference.go create mode 100644 pkg/api/reference_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 10eb712..f17bee0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ tags, PR merge commits, and tag-to-tag commit history. ## [Unreleased] +### Added + +- Linear web app URLs are now accepted anywhere `linctl` takes an issue, project, + team or comment reference, including flags such as `--team`, `--project` and + `--parent`. Issue, project, document, initiative and team URLs are parsed locally; + comment URLs (`...#comment-b68a4bf5`) are resolved to the full comment ID. Bare + identifiers, UUIDs, team keys and project names are unchanged. +- A GitHub pull request URL passed as an issue reference resolves to the issue the + PR is attached to. +- Linear review URLs (`linear.app//review/...`) now fail with an + explanation instead of a generic not-found: they point at a pull request, which + Linear's API cannot map back to an issue. The error suggests an `issue search` + built from the review slug. + ## [v0.1.11] - 2026-07-29 ### Fixed diff --git a/README.md b/README.md index 08b5777..597e588 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,43 @@ This improves performance and prevents overwhelming data loads. To see older ite - Need archived matches? Add `--include-archived` when using `issue search`. +## Pasting Linear URLs + +Anywhere `linctl` takes an issue, project, team or comment reference, you can paste +the URL straight from the Linear web app instead of looking up the ID. + +```bash +# Issue URLs, with or without the title slug +linctl issue get https://linear.app/acme/issue/ENG-123/fix-the-thing +linctl issue update https://linear.app/acme/issue/ENG-123 --state "In Progress" + +# Comment URLs (use Linear's "Copy link" on the comment itself) +linctl comment get 'https://linear.app/acme/issue/ENG-123/fix-the-thing#comment-b68a4bf5' + +# Project and team URLs +linctl project get https://linear.app/acme/project/roadmap-d05c5c7e8a5c/overview +linctl issue list --team https://linear.app/acme/team/ENG/active + +# GitHub pull request URLs resolve to the issue the PR is attached to +linctl issue get https://github.com/acme/api/pull/6153 +``` + +This works for flags too, such as `--team`, `--project` and `--parent`. Bare +identifiers (`ENG-123`), UUIDs, team keys and project names keep working exactly as +before. + +Document and initiative URLs are recognised too, so you get a clear error rather than +a confusing one when you paste them at a command that wants an issue. + +Two things to know: + +- Quote URLs that contain a `#`, or your shell will strip the comment fragment. +- Linear review URLs (`linear.app/acme/review/...`) cannot be resolved. They point at + a pull request, and Linear's API cannot map one back to an issue. Pass the GitHub + pull request URL instead. The error also suggests an `issue search` built from the + review slug, which usually finds the issue in one go. + + ## Quick Start > **IMPORTANT** Agents like Claude Code, Cursor, and Gemini should use the `--json` flag on all read operations. diff --git a/SKILL.md b/SKILL.md index 633c5a1..ae4b82e 100644 --- a/SKILL.md +++ b/SKILL.md @@ -14,6 +14,9 @@ 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 --help`. - Be explicit with filters; defaults can hide expected results. +- Linear web app URLs work anywhere an issue, project, team or comment reference is + accepted, including flags such as `--team` and `--project`. A GitHub pull request + URL resolves to the issue it is attached to. Quote URLs containing `#`. ## High-Impact Gotchas @@ -22,6 +25,8 @@ Use this skill when the user wants to inspect or modify Linear data through `lin - `issue search` may also need `--include-archived` for archived matches. - `issue list --cycle current` can validly return no rows if no active cycle exists. - Parent/sub-issue links are set via `issue update --parent` (not `issue create`). +- Linear review URLs (`linear.app//review/...`) cannot be resolved. They + point at a pull request, which Linear's API cannot map back to an issue. - If results look incomplete, retry with: - `--newer-than all_time` - `--include-completed` diff --git a/cmd/issue.go b/cmd/issue.go index e0d91aa..8393910 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -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 { @@ -984,6 +989,13 @@ func findProjectByNameOrID(projects []api.Project, value string) *api.Project { } } + // Project URLs carry a slug id, either bare or suffixed onto the project name. + for i := range projects { + if slugID := projects[i].SlugId; slugID != "" && (normalized == slugID || strings.HasSuffix(normalized, "-"+slugID)) { + return &projects[i] + } + } + return nil } @@ -1023,6 +1035,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 diff --git a/cmd/issue_cmd_test.go b/cmd/issue_cmd_test.go index 9c66a3b..c7863fe 100644 --- a/cmd/issue_cmd_test.go +++ b/cmd/issue_cmd_test.go @@ -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) + } +} diff --git a/pkg/api/queries.go b/pkg/api/queries.go index 3b7b49e..1ff8dfc 100644 --- a/pkg/api/queries.go +++ b/pkg/api/queries.go @@ -603,6 +603,11 @@ func (c *Client) IssueSearch(ctx context.Context, term string, filter map[string // GetIssue returns a single issue by ID func (c *Client) GetIssue(ctx context.Context, id string) (*Issue, error) { + id, err := c.ResolveIssueRef(ctx, id) + if err != nil { + return nil, err + } + query := ` query Issue($id: String!) { issue(id: $id) { @@ -869,7 +874,7 @@ func (c *Client) GetIssue(ctx context.Context, id string) (*Issue, error) { Issue Issue `json:"issue"` } - err := c.Execute(ctx, query, variables, &response) + err = c.Execute(ctx, query, variables, &response) if err != nil { return nil, err } @@ -879,6 +884,11 @@ func (c *Client) GetIssue(ctx context.Context, id string) (*Issue, error) { // GetIssueAgentSession returns issue delegate and agent sessions in recent comments. func (c *Client) GetIssueAgentSession(ctx context.Context, issueID string) (*Issue, error) { + issueID, err := c.ResolveIssueRef(ctx, issueID) + if err != nil { + return nil, err + } + query := ` query IssueAgentSession($id: String!) { issue(id: $id) { @@ -947,7 +957,7 @@ func (c *Client) GetIssueAgentSession(ctx context.Context, issueID string) (*Iss Issue Issue `json:"issue"` } - err := c.Execute(ctx, query, variables, &response) + err = c.Execute(ctx, query, variables, &response) if err != nil { return nil, err } @@ -1005,6 +1015,7 @@ func (c *Client) GetProjects(ctx context.Context, filter map[string]interface{}, projects(filter: $filter, first: $first, after: $after, orderBy: $orderBy) { nodes { id + slugId name description state @@ -1062,6 +1073,11 @@ func (c *Client) GetProjects(ctx context.Context, filter map[string]interface{}, // GetProject returns a single project by ID func (c *Client) GetProject(ctx context.Context, id string) (*Project, error) { + id, err := NormalizeProjectRef(id) + if err != nil { + return nil, err + } + query := ` query Project($id: String!) { project(id: $id) { @@ -1209,7 +1225,7 @@ func (c *Client) GetProject(ctx context.Context, id string) (*Project, error) { Project Project `json:"project"` } - err := c.Execute(ctx, query, variables, &response) + err = c.Execute(ctx, query, variables, &response) if err != nil { return nil, err } @@ -1334,6 +1350,11 @@ func (c *Client) CreateProject(ctx context.Context, input map[string]interface{} // UpdateProject updates an existing project func (c *Client) UpdateProject(ctx context.Context, id string, input map[string]interface{}) (*Project, error) { + id, err := NormalizeProjectRef(id) + if err != nil { + return nil, err + } + query := ` mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) { projectUpdate(id: $id, input: $input) { @@ -1381,7 +1402,7 @@ func (c *Client) UpdateProject(ctx context.Context, id string, input map[string] } `json:"projectUpdate"` } - err := c.Execute(ctx, query, variables, &response) + err = c.Execute(ctx, query, variables, &response) if err != nil { return nil, err } @@ -1391,6 +1412,11 @@ func (c *Client) UpdateProject(ctx context.Context, id string, input map[string] // DeleteProject permanently deletes a project func (c *Client) DeleteProject(ctx context.Context, id string) error { + id, err := NormalizeProjectRef(id) + if err != nil { + return err + } + query := ` mutation DeleteProject($id: String!) { projectDelete(id: $id) { @@ -1409,7 +1435,7 @@ func (c *Client) DeleteProject(ctx context.Context, id string) error { } `json:"projectDelete"` } - err := c.Execute(ctx, query, variables, &response) + err = c.Execute(ctx, query, variables, &response) if err != nil { return err } @@ -1423,6 +1449,11 @@ func (c *Client) DeleteProject(ctx context.Context, id string) error { // ArchiveProject archives a project (soft delete) func (c *Client) ArchiveProject(ctx context.Context, id string) (*Project, error) { + id, err := NormalizeProjectRef(id) + if err != nil { + return nil, err + } + query := ` mutation ArchiveProject($id: String!) { projectArchive(id: $id) { @@ -1447,7 +1478,7 @@ func (c *Client) ArchiveProject(ctx context.Context, id string) (*Project, error } `json:"projectArchive"` } - err := c.Execute(ctx, query, variables, &response) + err = c.Execute(ctx, query, variables, &response) if err != nil { return nil, err } @@ -1457,6 +1488,11 @@ func (c *Client) ArchiveProject(ctx context.Context, id string) (*Project, error // UpdateIssue updates an issue's fields func (c *Client) UpdateIssue(ctx context.Context, id string, input map[string]interface{}) (*Issue, error) { + id, err := c.ResolveIssueRef(ctx, id) + if err != nil { + return nil, err + } + query := ` mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { @@ -1524,7 +1560,7 @@ func (c *Client) UpdateIssue(ctx context.Context, id string, input map[string]in } `json:"issueUpdate"` } - err := c.Execute(ctx, query, variables, &response) + err = c.Execute(ctx, query, variables, &response) if err != nil { return nil, err } @@ -1610,6 +1646,11 @@ func (c *Client) CreateIssue(ctx context.Context, input map[string]interface{}) // GetTeam returns a single team by key func (c *Client) GetTeam(ctx context.Context, key string) (*Team, error) { + key, err := NormalizeTeamRef(key) + if err != nil { + return nil, err + } + query := ` query Team($key: String!) { team(id: $key) { @@ -1631,7 +1672,7 @@ func (c *Client) GetTeam(ctx context.Context, key string) (*Team, error) { Team Team `json:"team"` } - err := c.Execute(ctx, query, variables, &response) + err = c.Execute(ctx, query, variables, &response) if err != nil { return nil, err } @@ -1641,6 +1682,11 @@ func (c *Client) GetTeam(ctx context.Context, key string) (*Team, error) { // GetTeamLabels returns all issue labels for a team key. func (c *Client) GetTeamLabels(ctx context.Context, teamKey string) ([]Label, error) { + teamKey, err := NormalizeTeamRef(teamKey) + if err != nil { + return nil, err + } + query := ` query TeamLabels($key: String!, $first: Int, $after: String) { team(id: $key) { @@ -1900,6 +1946,11 @@ type WorkflowState struct { // GetTeamStates returns workflow states for a team func (c *Client) GetTeamStates(ctx context.Context, teamKey string) ([]WorkflowState, error) { + teamKey, err := NormalizeTeamRef(teamKey) + if err != nil { + return nil, err + } + query := ` query TeamStates($key: String!) { team(id: $key) { @@ -1929,7 +1980,7 @@ func (c *Client) GetTeamStates(ctx context.Context, teamKey string) ([]WorkflowS } `json:"team"` } - err := c.Execute(ctx, query, variables, &response) + err = c.Execute(ctx, query, variables, &response) if err != nil { return nil, err } @@ -1980,6 +2031,11 @@ func (c *Client) UpdateWorkflowState(ctx context.Context, id string, input map[s // GetTeamMembers returns members of a specific team func (c *Client) GetTeamMembers(ctx context.Context, teamKey string) (*Users, error) { + teamKey, err := NormalizeTeamRef(teamKey) + if err != nil { + return nil, err + } + query := ` query TeamMembers($key: String!) { team(id: $key) { @@ -2012,7 +2068,7 @@ func (c *Client) GetTeamMembers(ctx context.Context, teamKey string) (*Users, er } `json:"team"` } - err := c.Execute(ctx, query, variables, &response) + err = c.Execute(ctx, query, variables, &response) if err != nil { return nil, err } @@ -2128,6 +2184,11 @@ func (c *Client) FindUserByIdentifier(ctx context.Context, identifier string) (* // GetIssueComments returns comments for a specific issue func (c *Client) GetIssueComments(ctx context.Context, issueID string, first int, after string, orderBy string) (*Comments, error) { + issueID, err := c.ResolveIssueRef(ctx, issueID) + if err != nil { + return nil, err + } + query := ` query IssueComments($id: String!, $first: Int, $after: String, $orderBy: PaginationOrderBy) { issue(id: $id) { @@ -2169,7 +2230,7 @@ func (c *Client) GetIssueComments(ctx context.Context, issueID string, first int } `json:"issue"` } - err := c.Execute(ctx, query, variables, &response) + err = c.Execute(ctx, query, variables, &response) if err != nil { return nil, err } @@ -2179,6 +2240,11 @@ func (c *Client) GetIssueComments(ctx context.Context, issueID string, first int // CreateComment creates a new comment on an issue func (c *Client) CreateComment(ctx context.Context, issueID string, body string) (*Comment, error) { + issueID, err := c.ResolveIssueRef(ctx, issueID) + if err != nil { + return nil, err + } + query := ` mutation CreateComment($input: CommentCreateInput!) { commentCreate(input: $input) { @@ -2212,7 +2278,7 @@ func (c *Client) CreateComment(ctx context.Context, issueID string, body string) } `json:"commentCreate"` } - err := c.Execute(ctx, query, variables, &response) + err = c.Execute(ctx, query, variables, &response) if err != nil { return nil, err } @@ -2222,6 +2288,11 @@ func (c *Client) CreateComment(ctx context.Context, issueID string, body string) // GetComment returns a single comment by ID. func (c *Client) GetComment(ctx context.Context, id string) (*Comment, error) { + id, err := c.ResolveCommentRef(ctx, id) + if err != nil { + return nil, err + } + query := ` query Comment($id: String!) { comment(id: $id) { @@ -2250,7 +2321,7 @@ func (c *Client) GetComment(ctx context.Context, id string) (*Comment, error) { Comment Comment `json:"comment"` } - err := c.Execute(ctx, query, variables, &response) + err = c.Execute(ctx, query, variables, &response) if err != nil { return nil, err } @@ -2260,6 +2331,11 @@ func (c *Client) GetComment(ctx context.Context, id string) (*Comment, error) { // UpdateComment updates an existing comment by ID. func (c *Client) UpdateComment(ctx context.Context, id string, body string) (*Comment, error) { + id, err := c.ResolveCommentRef(ctx, id) + if err != nil { + return nil, err + } + query := ` mutation UpdateComment($id: String!, $input: CommentUpdateInput!) { commentUpdate(id: $id, input: $input) { @@ -2297,7 +2373,7 @@ func (c *Client) UpdateComment(ctx context.Context, id string, body string) (*Co } `json:"commentUpdate"` } - err := c.Execute(ctx, query, variables, &response) + err = c.Execute(ctx, query, variables, &response) if err != nil { return nil, err } @@ -2310,6 +2386,11 @@ func (c *Client) UpdateComment(ctx context.Context, id string, body string) (*Co // DeleteComment deletes a comment by ID. func (c *Client) DeleteComment(ctx context.Context, id string) error { + id, err := c.ResolveCommentRef(ctx, id) + if err != nil { + return err + } + query := ` mutation DeleteComment($id: String!) { commentDelete(id: $id) { @@ -2328,7 +2409,7 @@ func (c *Client) DeleteComment(ctx context.Context, id string) error { } `json:"commentDelete"` } - err := c.Execute(ctx, query, variables, &response) + err = c.Execute(ctx, query, variables, &response) if err != nil { return err } @@ -2496,6 +2577,11 @@ func (c *Client) DeleteIssueRelation(ctx context.Context, relationID string) err // GetIssueRelations returns all relations for a given issue. func (c *Client) GetIssueRelations(ctx context.Context, issueID string) ([]IssueRelation, error) { + issueID, err := c.ResolveIssueRef(ctx, issueID) + if err != nil { + return nil, err + } + query := ` query IssueRelations($id: String!) { issue(id: $id) { @@ -2550,7 +2636,7 @@ func (c *Client) GetIssueRelations(ctx context.Context, issueID string) ([]Issue } `json:"issue"` } - err := c.Execute(ctx, query, variables, &response) + err = c.Execute(ctx, query, variables, &response) if err != nil { return nil, err } diff --git a/pkg/api/reference.go b/pkg/api/reference.go new file mode 100644 index 0000000..f5d09b9 --- /dev/null +++ b/pkg/api/reference.go @@ -0,0 +1,288 @@ +package api + +import ( + "context" + "fmt" + "net/url" + "regexp" + "strconv" + "strings" +) + +// LinearRefKind identifies the entity a linear.app URL points at. +type LinearRefKind string + +const ( + LinearRefIssue LinearRefKind = "issue" + LinearRefProject LinearRefKind = "project" + LinearRefDocument LinearRefKind = "document" + LinearRefTeam LinearRefKind = "team" + LinearRefInitiative LinearRefKind = "initiative" + LinearRefReview LinearRefKind = "review" +) + +// LinearRef is a linear.app URL split into the parts Linear's API understands. +type LinearRef struct { + Kind LinearRefKind + Workspace string + // ID is the value Linear accepts for the entity: an issue identifier such as + // ENG-123, a team key such as ENG, or a slug id such as roadmap-d05c5c7e8a5c. + ID string + // CommentID is set when the URL points at a comment. Web app links carry only + // the first 8 characters of the comment UUID. + CommentID string +} + +var ( + uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + // Issue identifiers look like ENG-123. Linear accepts them in any case. + issueIdentifierPattern = regexp.MustCompile(`^[A-Za-z0-9]+-[0-9]+$`) + // Projects, documents, initiatives and reviews end their URL slug with a short + // hex id, which is what Linear calls the slug id. + slugIDPattern = regexp.MustCompile(`^[0-9a-f]{8,16}$`) +) + +// ParseLinearURL parses a linear.app URL. It reports false when raw is not one, +// which is the common case: most references are bare identifiers or UUIDs. +func ParseLinearURL(raw string) (LinearRef, bool) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return LinearRef{}, false + } + + // People paste bare hosts as often as full URLs. + if !strings.Contains(trimmed, "://") { + if !strings.HasPrefix(strings.ToLower(trimmed), "linear.app/") { + return LinearRef{}, false + } + trimmed = "https://" + trimmed + } + + parsed, err := url.Parse(trimmed) + if err != nil { + return LinearRef{}, false + } + host := strings.ToLower(parsed.Hostname()) + if host != "linear.app" && host != "www.linear.app" { + return LinearRef{}, false + } + + var segments []string + for _, segment := range strings.Split(parsed.Path, "/") { + if segment != "" { + segments = append(segments, segment) + } + } + + for i, segment := range segments { + kind := LinearRefKind(strings.ToLower(segment)) + switch kind { + case LinearRefIssue, LinearRefProject, LinearRefDocument, LinearRefTeam, LinearRefInitiative, LinearRefReview: + default: + continue + } + if i == len(segments)-1 { + return LinearRef{}, false + } + + ref := LinearRef{Kind: kind, ID: segments[i+1], CommentID: commentIDFromURL(parsed)} + if i > 0 { + ref.Workspace = segments[0] + } + return ref, true + } + + return LinearRef{}, false +} + +func commentIDFromURL(parsed *url.URL) string { + if value := strings.TrimSpace(parsed.Query().Get("commentId")); value != "" { + return value + } + if fragment := strings.TrimSpace(parsed.Fragment); strings.HasPrefix(fragment, "comment-") { + return strings.TrimPrefix(fragment, "comment-") + } + return "" +} + +// NormalizeIssueRef accepts an issue identifier, a UUID or a linear.app issue URL +// and returns the value Linear's issue(id:) query understands. +func NormalizeIssueRef(ref string) (string, error) { + return normalizeRef(ref, LinearRefIssue, "issue") +} + +// NormalizeProjectRef accepts a project name, a UUID, a slug id or a linear.app +// project URL and returns the value Linear's project(id:) query understands. +func NormalizeProjectRef(ref string) (string, error) { + return normalizeRef(ref, LinearRefProject, "project") +} + +// NormalizeTeamRef accepts a team key, a UUID or a linear.app team URL and returns +// the value Linear's team(id:) query understands. +func NormalizeTeamRef(ref string) (string, error) { + return normalizeRef(ref, LinearRefTeam, "team") +} + +func normalizeRef(ref string, want LinearRefKind, label string) (string, error) { + trimmed := strings.TrimSpace(ref) + if trimmed == "" { + return "", fmt.Errorf("%s reference cannot be empty", label) + } + + parsed, ok := ParseLinearURL(trimmed) + if !ok { + return trimmed, nil + } + if parsed.Kind != want { + return "", wrongRefKindError(trimmed, parsed, label) + } + if want == LinearRefIssue && !issueIdentifierPattern.MatchString(parsed.ID) && !uuidPattern.MatchString(parsed.ID) { + return "", fmt.Errorf("%s does not contain an issue identifier", trimmed) + } + return parsed.ID, nil +} + +func wrongRefKindError(raw string, parsed LinearRef, want string) error { + if parsed.Kind == LinearRefReview { + return fmt.Errorf("%s is a Linear review URL. It points at a pull request, and Linear's API cannot map one back to %s. Pass the GitHub pull request URL instead, or find the issue with: linctl issue search %q", + raw, withArticle(want), reviewSearchTerms(parsed.ID)) + } + return fmt.Errorf("%s is %s URL, not %s URL", raw, withArticle("Linear "+string(parsed.Kind)), withArticle(want)) +} + +// reviewSearchTerms turns the slug of a review URL into words worth searching for, +// dropping the trailing slug id. Review slugs come from the pull request title, +// which usually echoes the issue title closely enough for full-text search. +func reviewSearchTerms(slug string) string { + words := strings.Split(slug, "-") + if n := len(words); n > 1 && slugIDPattern.MatchString(words[n-1]) { + words = words[:n-1] + } + return strings.Join(words, " ") +} + +func withArticle(noun string) string { + if noun == "" { + return noun + } + if strings.ContainsRune("aeiouAEIOU", rune(noun[0])) { + return "an " + noun + } + return "a " + noun +} + +// ResolveIssueRef resolves a user-supplied issue reference. Identifiers and UUIDs +// pass through untouched, linear.app issue URLs are parsed locally, and GitHub pull +// request URLs are looked up through the attachment that links them to an issue. +func (c *Client) ResolveIssueRef(ctx context.Context, ref string) (string, error) { + trimmed := strings.TrimSpace(ref) + if prURL, ok := canonicalGitHubPullRequestURL(trimmed); ok { + return c.issueRefFromPullRequestURL(ctx, prURL) + } + return NormalizeIssueRef(trimmed) +} + +// ResolveCommentRef resolves a comment UUID or a linear.app comment URL to a comment +// UUID. Web app links carry only an 8-character prefix, so those cost a lookup. +func (c *Client) ResolveCommentRef(ctx context.Context, ref string) (string, error) { + trimmed := strings.TrimSpace(ref) + if trimmed == "" { + return "", fmt.Errorf("comment reference cannot be empty") + } + + parsed, ok := ParseLinearURL(trimmed) + if !ok { + return trimmed, nil + } + if parsed.Kind != LinearRefIssue { + return "", wrongRefKindError(trimmed, parsed, "issue") + } + if parsed.CommentID == "" { + return "", fmt.Errorf("%s does not point at a comment. Use Linear's 'Copy link' on the comment itself", trimmed) + } + if uuidPattern.MatchString(parsed.CommentID) { + return parsed.CommentID, nil + } + return c.findCommentByIDPrefix(ctx, parsed.ID, parsed.CommentID) +} + +func (c *Client) findCommentByIDPrefix(ctx context.Context, issueRef string, prefix string) (string, error) { + after := "" + for { + comments, err := c.GetIssueComments(ctx, issueRef, 100, after, "") + if err != nil { + return "", err + } + for _, comment := range comments.Nodes { + if strings.HasPrefix(comment.ID, prefix) { + return comment.ID, nil + } + } + if !comments.PageInfo.HasNextPage || comments.PageInfo.EndCursor == "" { + return "", fmt.Errorf("no comment starting with %q found on issue %s", prefix, issueRef) + } + after = comments.PageInfo.EndCursor + } +} + +func (c *Client) issueRefFromPullRequestURL(ctx context.Context, prURL string) (string, error) { + query := ` + query AttachmentsForURL($url: String!) { + attachmentsForURL(url: $url, first: 10) { + nodes { + issue { + identifier + } + } + } + } + ` + + var response struct { + AttachmentsForURL struct { + Nodes []struct { + Issue *struct { + Identifier string `json:"identifier"` + } `json:"issue"` + } `json:"nodes"` + } `json:"attachmentsForURL"` + } + + if err := c.Execute(ctx, query, map[string]interface{}{"url": prURL}, &response); err != nil { + return "", err + } + + for _, node := range response.AttachmentsForURL.Nodes { + if node.Issue != nil && node.Issue.Identifier != "" { + return node.Issue.Identifier, nil + } + } + + return "", fmt.Errorf("no Linear issue is linked to %s", prURL) +} + +// canonicalGitHubPullRequestURL recognises a GitHub pull request URL and rewrites it +// to the form Linear stores on the issue attachment. +func canonicalGitHubPullRequestURL(raw string) (string, bool) { + if !strings.Contains(raw, "://") { + return "", false + } + parsed, err := url.Parse(raw) + if err != nil { + return "", false + } + host := strings.ToLower(parsed.Hostname()) + if host != "github.com" && host != "www.github.com" { + return "", false + } + + parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") + if len(parts) < 4 || parts[2] != "pull" { + return "", false + } + if _, err := strconv.Atoi(parts[3]); err != nil { + return "", false + } + + return fmt.Sprintf("https://github.com/%s/%s/pull/%s", parts[0], parts[1], parts[3]), true +} diff --git a/pkg/api/reference_test.go b/pkg/api/reference_test.go new file mode 100644 index 0000000..5bd730f --- /dev/null +++ b/pkg/api/reference_test.go @@ -0,0 +1,334 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestParseLinearURL(t *testing.T) { + cases := []struct { + name string + raw string + want LinearRef + wantK bool + }{ + { + name: "issue url with slug", + raw: "https://linear.app/glif/issue/API-379/block-signups-from-example", + want: LinearRef{Kind: LinearRefIssue, Workspace: "glif", ID: "API-379"}, + wantK: true, + }, + { + name: "issue url without slug", + raw: "https://linear.app/glif/issue/API-379", + want: LinearRef{Kind: LinearRefIssue, Workspace: "glif", ID: "API-379"}, + wantK: true, + }, + { + name: "issue url with comment fragment", + raw: "https://linear.app/glif/issue/GTM-580/make-it-generic#comment-b68a4bf5", + want: LinearRef{Kind: LinearRefIssue, Workspace: "glif", ID: "GTM-580", CommentID: "b68a4bf5"}, + wantK: true, + }, + { + name: "issue url with commentId query", + raw: "https://linear.app/glif/issue/GTM-580?commentId=b68a4bf5-8a34-473e-af4c-b8892a78a9af", + want: LinearRef{Kind: LinearRefIssue, Workspace: "glif", ID: "GTM-580", CommentID: "b68a4bf5-8a34-473e-af4c-b8892a78a9af"}, + wantK: true, + }, + { + name: "issue url with agent session fragment", + raw: "https://linear.app/glif/issue/API-379/slug#agent-session-56881231", + want: LinearRef{Kind: LinearRefIssue, Workspace: "glif", ID: "API-379"}, + wantK: true, + }, + { + name: "scheme-less url", + raw: "linear.app/glif/issue/API-379/slug", + want: LinearRef{Kind: LinearRefIssue, Workspace: "glif", ID: "API-379"}, + wantK: true, + }, + { + name: "desktop deep link", + raw: "linear://linear.app/glif/issue/API-379", + want: LinearRef{Kind: LinearRefIssue, Workspace: "glif", ID: "API-379"}, + wantK: true, + }, + { + name: "project url", + raw: "https://linear.app/glif/project/benchmarkmaxx-d05c5c7e8a5c/overview", + want: LinearRef{Kind: LinearRefProject, Workspace: "glif", ID: "benchmarkmaxx-d05c5c7e8a5c"}, + wantK: true, + }, + { + name: "team url", + raw: "https://linear.app/glif/team/API/active", + want: LinearRef{Kind: LinearRefTeam, Workspace: "glif", ID: "API"}, + wantK: true, + }, + { + name: "document url", + raw: "https://linear.app/glif/document/glif-tgim-sync-507735cb56c1", + want: LinearRef{Kind: LinearRefDocument, Workspace: "glif", ID: "glif-tgim-sync-507735cb56c1"}, + wantK: true, + }, + { + name: "review url", + raw: "https://linear.app/glif/review/replace-polymorphic-apitokenid-ca4153a35dc0", + want: LinearRef{Kind: LinearRefReview, Workspace: "glif", ID: "replace-polymorphic-apitokenid-ca4153a35dc0"}, + wantK: true, + }, + {name: "bare identifier", raw: "API-379"}, + {name: "uuid", raw: "b68a4bf5-8a34-473e-af4c-b8892a78a9af"}, + {name: "github url", raw: "https://github.com/glifxyz/glif-graph/pull/6153"}, + {name: "empty", raw: ""}, + {name: "linear url with no entity segment", raw: "https://linear.app/glif/settings"}, + {name: "issue segment with nothing after it", raw: "https://linear.app/glif/issue"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := ParseLinearURL(tc.raw) + if ok != tc.wantK { + t.Fatalf("ParseLinearURL(%q) ok = %v, want %v", tc.raw, ok, tc.wantK) + } + if got != tc.want { + t.Fatalf("ParseLinearURL(%q) = %#v, want %#v", tc.raw, got, tc.want) + } + }) + } +} + +func TestNormalizeIssueRef(t *testing.T) { + cases := []struct { + name string + ref string + want string + wantErr string + }{ + {name: "identifier passes through", ref: "API-379", want: "API-379"}, + {name: "uuid passes through", ref: "b68a4bf5-8a34-473e-af4c-b8892a78a9af", want: "b68a4bf5-8a34-473e-af4c-b8892a78a9af"}, + {name: "surrounding whitespace trimmed", ref: " API-379\n", want: "API-379"}, + {name: "issue url", ref: "https://linear.app/glif/issue/API-379/some-slug", want: "API-379"}, + {name: "empty", ref: " ", wantErr: "issue reference cannot be empty"}, + {name: "project url", ref: "https://linear.app/glif/project/benchmarkmaxx-d05c5c7e8a5c", wantErr: "is a Linear project URL"}, + {name: "review url", ref: "https://linear.app/glif/review/replace-polymorphic-ca4153a35dc0", wantErr: "is a Linear review URL"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := NormalizeIssueRef(tc.ref) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("NormalizeIssueRef(%q) error = %v, want it to contain %q", tc.ref, err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("NormalizeIssueRef(%q) returned error: %v", tc.ref, err) + } + if got != tc.want { + t.Fatalf("NormalizeIssueRef(%q) = %q, want %q", tc.ref, got, tc.want) + } + }) + } +} + +func TestNormalizeProjectAndTeamRefs(t *testing.T) { + projectID, err := NormalizeProjectRef("https://linear.app/glif/project/benchmarkmaxx-d05c5c7e8a5c/issues") + if err != nil { + t.Fatalf("NormalizeProjectRef returned error: %v", err) + } + if projectID != "benchmarkmaxx-d05c5c7e8a5c" { + t.Fatalf("expected slug id, got %q", projectID) + } + + teamKey, err := NormalizeTeamRef("https://linear.app/glif/team/API/all") + if err != nil { + t.Fatalf("NormalizeTeamRef returned error: %v", err) + } + if teamKey != "API" { + t.Fatalf("expected team key API, got %q", teamKey) + } + + if _, err := NormalizeTeamRef("https://linear.app/glif/issue/API-379"); err == nil { + t.Fatal("expected an error for an issue URL passed as a team ref") + } +} + +func TestGetIssueAcceptsIssueURL(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) + } + if req.Variables["id"] != "API-379" { + t.Fatalf("expected id API-379, got %v", req.Variables["id"]) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"issue":{"id":"i1","identifier":"API-379","title":"Block signups"}}}`)) + })) + defer srv.Close() + + c := NewClientWithURL(srv.URL, "Bearer test") + issue, err := c.GetIssue(context.Background(), "https://linear.app/glif/issue/API-379/block-signups") + if err != nil { + t.Fatalf("GetIssue returned error: %v", err) + } + if issue.Identifier != "API-379" { + t.Fatalf("expected API-379, got %s", issue.Identifier) + } +} + +func TestResolveIssueRefFromGitHubPullRequestURL(t *testing.T) { + var sawURL interface{} + 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) + } + if !strings.Contains(req.Query, "query AttachmentsForURL(") { + t.Fatalf("expected AttachmentsForURL query, got: %s", req.Query) + } + sawURL = req.Variables["url"] + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"attachmentsForURL":{"nodes":[{"issue":null},{"issue":{"identifier":"API-285"}}]}}}`)) + })) + defer srv.Close() + + c := NewClientWithURL(srv.URL, "Bearer test") + ref, err := c.ResolveIssueRef(context.Background(), "https://github.com/glifxyz/glif-graph/pull/6153?w=1") + if err != nil { + t.Fatalf("ResolveIssueRef returned error: %v", err) + } + if ref != "API-285" { + t.Fatalf("expected API-285, got %q", ref) + } + if sawURL != "https://github.com/glifxyz/glif-graph/pull/6153" { + t.Fatalf("expected the canonical PR URL, got %v", sawURL) + } +} + +func TestResolveIssueRefUnlinkedPullRequest(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"attachmentsForURL":{"nodes":[]}}}`)) + })) + defer srv.Close() + + c := NewClientWithURL(srv.URL, "Bearer test") + _, err := c.ResolveIssueRef(context.Background(), "https://github.com/glifxyz/glif-graph/pull/6153") + if err == nil || !strings.Contains(err.Error(), "no Linear issue is linked") { + t.Fatalf("expected an unlinked-PR error, got %v", err) + } +} + +func TestResolveCommentRefFromURL(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) + } + if !strings.Contains(req.Query, "query IssueComments(") { + t.Fatalf("expected IssueComments query, got: %s", req.Query) + } + if req.Variables["id"] != "GTM-580" { + t.Fatalf("expected id GTM-580, got %v", req.Variables["id"]) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"issue":{"comments":{"nodes":[{"id":"11111111-1111-1111-1111-111111111111"},{"id":"b68a4bf5-8a34-473e-af4c-b8892a78a9af"}],"pageInfo":{"hasNextPage":false,"endCursor":""}}}}}`)) + })) + defer srv.Close() + + c := NewClientWithURL(srv.URL, "Bearer test") + id, err := c.ResolveCommentRef(context.Background(), "https://linear.app/glif/issue/GTM-580/make-it-generic#comment-b68a4bf5") + if err != nil { + t.Fatalf("ResolveCommentRef returned error: %v", err) + } + if id != "b68a4bf5-8a34-473e-af4c-b8892a78a9af" { + t.Fatalf("expected the full comment UUID, got %q", id) + } +} + +func TestResolveCommentRefWithoutCommentFragment(t *testing.T) { + c := NewClientWithURL("http://127.0.0.1:0", "Bearer test") + _, err := c.ResolveCommentRef(context.Background(), "https://linear.app/glif/issue/GTM-580/make-it-generic") + if err == nil || !strings.Contains(err.Error(), "does not point at a comment") { + t.Fatalf("expected a missing-comment error, got %v", err) + } +} + +func TestParseLinearURLMoreVariants(t *testing.T) { + cases := []struct { + name string + raw string + want LinearRef + }{ + { + name: "initiative url", + raw: "https://linear.app/glif/initiative/reduce-costs-83e5d6e7a371", + want: LinearRef{Kind: LinearRefInitiative, Workspace: "glif", ID: "reduce-costs-83e5d6e7a371"}, + }, + { + name: "trailing slash", + raw: "https://linear.app/glif/issue/API-379/", + want: LinearRef{Kind: LinearRefIssue, Workspace: "glif", ID: "API-379"}, + }, + { + name: "query string", + raw: "https://linear.app/glif/issue/API-379/slug?tab=activity", + want: LinearRef{Kind: LinearRefIssue, Workspace: "glif", ID: "API-379"}, + }, + { + name: "www host", + raw: "https://www.linear.app/glif/issue/API-379", + want: LinearRef{Kind: LinearRefIssue, Workspace: "glif", ID: "API-379"}, + }, + { + name: "lowercase identifier", + raw: "https://linear.app/glif/issue/api-379/slug", + want: LinearRef{Kind: LinearRefIssue, Workspace: "glif", ID: "api-379"}, + }, + { + name: "team cycle view", + raw: "https://linear.app/glif/team/API/cycle/12", + want: LinearRef{Kind: LinearRefTeam, Workspace: "glif", ID: "API"}, + }, + { + name: "project sub-tab", + raw: "https://linear.app/glif/project/benchmarkmaxx-d05c5c7e8a5c/documents", + want: LinearRef{Kind: LinearRefProject, Workspace: "glif", ID: "benchmarkmaxx-d05c5c7e8a5c"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := ParseLinearURL(tc.raw) + if !ok { + t.Fatalf("ParseLinearURL(%q) did not parse", tc.raw) + } + if got != tc.want { + t.Fatalf("ParseLinearURL(%q) = %#v, want %#v", tc.raw, got, tc.want) + } + }) + } +} + +func TestNormalizeIssueRefRejectsNonIssuePaths(t *testing.T) { + cases := map[string]string{ + "https://linear.app/glif/issue/new": "does not contain an issue identifier", + "https://linear.app/glif/initiative/reduce-costs-83e5d6e7a371": "is a Linear initiative URL", + "https://linear.app/glif/document/glif-tgim-sync-507735cb56c1": "is a Linear document URL", + "https://linear.app/glif/review/replace-polymorphic-ca4153a35dc0": "linctl issue search \"replace polymorphic\"", + } + + for ref, want := range cases { + if _, err := NormalizeIssueRef(ref); err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("NormalizeIssueRef(%q) error = %v, want it to contain %q", ref, err, want) + } + } +} From b977f740167101ee6db39ce65ba4df374ecebec3 Mon Sep 17 00:00:00 2001 From: Jamie Dubs <1903+jamiew@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:00:43 -0400 Subject: [PATCH 3/6] Simplify --- CHANGELOG.md | 13 +- README.md | 30 +-- SKILL.md | 8 +- cmd/issue.go | 15 +- pkg/api/queries.go | 25 ++- pkg/api/reference.go | 290 ++++++++++++-------------- pkg/api/reference_test.go | 416 +++++++++++++------------------------- 7 files changed, 298 insertions(+), 499 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f17bee0..c1fd0db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,17 +9,8 @@ tags, PR merge commits, and tag-to-tag commit history. ### Added -- Linear web app URLs are now accepted anywhere `linctl` takes an issue, project, - team or comment reference, including flags such as `--team`, `--project` and - `--parent`. Issue, project, document, initiative and team URLs are parsed locally; - comment URLs (`...#comment-b68a4bf5`) are resolved to the full comment ID. Bare - identifiers, UUIDs, team keys and project names are unchanged. -- A GitHub pull request URL passed as an issue reference resolves to the issue the - PR is attached to. -- Linear review URLs (`linear.app//review/...`) now fail with an - explanation instead of a generic not-found: they point at a pull request, which - Linear's API cannot map back to an issue. The error suggests an `issue search` - built from the review slug. +- Accept Linear URLs for issue, project, team and comment references. GitHub pull + request URLs also resolve to their attached Linear issue. ## [v0.1.11] - 2026-07-29 diff --git a/README.md b/README.md index 597e588..edddd65 100644 --- a/README.md +++ b/README.md @@ -74,41 +74,21 @@ This improves performance and prevents overwhelming data loads. To see older ite - Need archived matches? Add `--include-archived` when using `issue search`. -## Pasting Linear URLs +## Pasting URLs -Anywhere `linctl` takes an issue, project, team or comment reference, you can paste -the URL straight from the Linear web app instead of looking up the ID. +You can use Linear URLs anywhere `linctl` accepts an issue, project, team or comment. ```bash -# Issue URLs, with or without the title slug linctl issue get https://linear.app/acme/issue/ENG-123/fix-the-thing -linctl issue update https://linear.app/acme/issue/ENG-123 --state "In Progress" - -# Comment URLs (use Linear's "Copy link" on the comment itself) linctl comment get 'https://linear.app/acme/issue/ENG-123/fix-the-thing#comment-b68a4bf5' - -# Project and team URLs linctl project get https://linear.app/acme/project/roadmap-d05c5c7e8a5c/overview linctl issue list --team https://linear.app/acme/team/ENG/active - -# GitHub pull request URLs resolve to the issue the PR is attached to linctl issue get https://github.com/acme/api/pull/6153 ``` -This works for flags too, such as `--team`, `--project` and `--parent`. Bare -identifiers (`ENG-123`), UUIDs, team keys and project names keep working exactly as -before. - -Document and initiative URLs are recognised too, so you get a clear error rather than -a confusing one when you paste them at a command that wants an issue. - -Two things to know: - -- Quote URLs that contain a `#`, or your shell will strip the comment fragment. -- Linear review URLs (`linear.app/acme/review/...`) cannot be resolved. They point at - a pull request, and Linear's API cannot map one back to an issue. Pass the GitHub - pull request URL instead. The error also suggests an `issue search` built from the - review slug, which usually finds the issue in one go. +Quote URLs containing `#`. GitHub pull request URLs resolve through their Linear +attachment. Linear review URLs do not expose the pull request URL, so use the GitHub +URL instead. ## Quick Start diff --git a/SKILL.md b/SKILL.md index ae4b82e..8d1ebd7 100644 --- a/SKILL.md +++ b/SKILL.md @@ -14,9 +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 --help`. - Be explicit with filters; defaults can hide expected results. -- Linear web app URLs work anywhere an issue, project, team or comment reference is - accepted, including flags such as `--team` and `--project`. A GitHub pull request - URL resolves to the issue it is attached to. Quote URLs containing `#`. +- Linear and attached GitHub pull request URLs work as entity references. Quote URLs + containing `#`. ## High-Impact Gotchas @@ -25,8 +24,7 @@ Use this skill when the user wants to inspect or modify Linear data through `lin - `issue search` may also need `--include-archived` for archived matches. - `issue list --cycle current` can validly return no rows if no active cycle exists. - Parent/sub-issue links are set via `issue update --parent` (not `issue create`). -- Linear review URLs (`linear.app//review/...`) cannot be resolved. They - point at a pull request, which Linear's API cannot map back to an issue. +- Use the GitHub pull request URL instead of a Linear review URL. - If results look incomplete, retry with: - `--newer-than all_time` - `--include-completed` diff --git a/cmd/issue.go b/cmd/issue.go index 8393910..d1657ad 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -975,8 +975,6 @@ func isUnsetValue(value string) bool { return false } } - - func findProjectByNameOrID(projects []api.Project, value string) *api.Project { normalized := strings.TrimSpace(value) if normalized == "" { @@ -984,15 +982,10 @@ func findProjectByNameOrID(projects []api.Project, value string) *api.Project { } for i := range projects { - if projects[i].ID == normalized || strings.EqualFold(projects[i].Name, normalized) { - return &projects[i] - } - } - - // Project URLs carry a slug id, either bare or suffixed onto the project name. - for i := range projects { - if slugID := projects[i].SlugId; slugID != "" && (normalized == slugID || strings.HasSuffix(normalized, "-"+slugID)) { - 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 } } diff --git a/pkg/api/queries.go b/pkg/api/queries.go index 1ff8dfc..30c28b0 100644 --- a/pkg/api/queries.go +++ b/pkg/api/queries.go @@ -603,7 +603,7 @@ func (c *Client) IssueSearch(ctx context.Context, term string, filter map[string // GetIssue returns a single issue by ID func (c *Client) GetIssue(ctx context.Context, id string) (*Issue, error) { - id, err := c.ResolveIssueRef(ctx, id) + id, err := c.resolveIssueRef(ctx, id) if err != nil { return nil, err } @@ -884,7 +884,7 @@ func (c *Client) GetIssue(ctx context.Context, id string) (*Issue, error) { // GetIssueAgentSession returns issue delegate and agent sessions in recent comments. func (c *Client) GetIssueAgentSession(ctx context.Context, issueID string) (*Issue, error) { - issueID, err := c.ResolveIssueRef(ctx, issueID) + issueID, err := c.resolveIssueRef(ctx, issueID) if err != nil { return nil, err } @@ -1235,6 +1235,11 @@ func (c *Client) GetProject(ctx context.Context, id string) (*Project, error) { // GetProjectMilestones returns all milestones for a specific project. func (c *Client) GetProjectMilestones(ctx context.Context, projectID string) ([]ProjectMilestone, error) { + projectID, err := NormalizeProjectRef(projectID) + if err != nil { + return nil, err + } + query := ` query ProjectMilestones($id: String!, $first: Int, $after: String) { project(id: $id) { @@ -1278,7 +1283,7 @@ func (c *Client) GetProjectMilestones(ctx context.Context, projectID string) ([] } `json:"project"` } - err := c.Execute(ctx, query, variables, &response) + err = c.Execute(ctx, query, variables, &response) if err != nil { return nil, err } @@ -1488,7 +1493,7 @@ func (c *Client) ArchiveProject(ctx context.Context, id string) (*Project, error // UpdateIssue updates an issue's fields func (c *Client) UpdateIssue(ctx context.Context, id string, input map[string]interface{}) (*Issue, error) { - id, err := c.ResolveIssueRef(ctx, id) + id, err := c.resolveIssueRef(ctx, id) if err != nil { return nil, err } @@ -2184,7 +2189,7 @@ func (c *Client) FindUserByIdentifier(ctx context.Context, identifier string) (* // GetIssueComments returns comments for a specific issue func (c *Client) GetIssueComments(ctx context.Context, issueID string, first int, after string, orderBy string) (*Comments, error) { - issueID, err := c.ResolveIssueRef(ctx, issueID) + issueID, err := c.resolveIssueRef(ctx, issueID) if err != nil { return nil, err } @@ -2240,7 +2245,7 @@ func (c *Client) GetIssueComments(ctx context.Context, issueID string, first int // CreateComment creates a new comment on an issue func (c *Client) CreateComment(ctx context.Context, issueID string, body string) (*Comment, error) { - issueID, err := c.ResolveIssueRef(ctx, issueID) + issueID, err := c.resolveIssueRef(ctx, issueID) if err != nil { return nil, err } @@ -2288,7 +2293,7 @@ func (c *Client) CreateComment(ctx context.Context, issueID string, body string) // GetComment returns a single comment by ID. func (c *Client) GetComment(ctx context.Context, id string) (*Comment, error) { - id, err := c.ResolveCommentRef(ctx, id) + id, err := c.resolveCommentRef(ctx, id) if err != nil { return nil, err } @@ -2331,7 +2336,7 @@ func (c *Client) GetComment(ctx context.Context, id string) (*Comment, error) { // UpdateComment updates an existing comment by ID. func (c *Client) UpdateComment(ctx context.Context, id string, body string) (*Comment, error) { - id, err := c.ResolveCommentRef(ctx, id) + id, err := c.resolveCommentRef(ctx, id) if err != nil { return nil, err } @@ -2386,7 +2391,7 @@ func (c *Client) UpdateComment(ctx context.Context, id string, body string) (*Co // DeleteComment deletes a comment by ID. func (c *Client) DeleteComment(ctx context.Context, id string) error { - id, err := c.ResolveCommentRef(ctx, id) + id, err := c.resolveCommentRef(ctx, id) if err != nil { return err } @@ -2577,7 +2582,7 @@ func (c *Client) DeleteIssueRelation(ctx context.Context, relationID string) err // GetIssueRelations returns all relations for a given issue. func (c *Client) GetIssueRelations(ctx context.Context, issueID string) ([]IssueRelation, error) { - issueID, err := c.ResolveIssueRef(ctx, issueID) + issueID, err := c.resolveIssueRef(ctx, issueID) if err != nil { return nil, err } diff --git a/pkg/api/reference.go b/pkg/api/reference.go index f5d09b9..7091324 100644 --- a/pkg/api/reference.go +++ b/pkg/api/reference.go @@ -5,208 +5,143 @@ import ( "fmt" "net/url" "regexp" + "sort" "strconv" "strings" ) -// LinearRefKind identifies the entity a linear.app URL points at. -type LinearRefKind string +var ( + issueIdentifierPattern = regexp.MustCompile(`^[A-Za-z0-9]+-[0-9]+$`) + uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) +) const ( - LinearRefIssue LinearRefKind = "issue" - LinearRefProject LinearRefKind = "project" - LinearRefDocument LinearRefKind = "document" - LinearRefTeam LinearRefKind = "team" - LinearRefInitiative LinearRefKind = "initiative" - LinearRefReview LinearRefKind = "review" + refIssue = "issue" + refProject = "project" + refDocument = "document" + refTeam = "team" + refInitiative = "initiative" + refReview = "review" ) -// LinearRef is a linear.app URL split into the parts Linear's API understands. -type LinearRef struct { - Kind LinearRefKind - Workspace string - // ID is the value Linear accepts for the entity: an issue identifier such as - // ENG-123, a team key such as ENG, or a slug id such as roadmap-d05c5c7e8a5c. - ID string - // CommentID is set when the URL points at a comment. Web app links carry only - // the first 8 characters of the comment UUID. - CommentID string +type linearRef struct { + kind string + id string + commentID string } -var ( - uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) - // Issue identifiers look like ENG-123. Linear accepts them in any case. - issueIdentifierPattern = regexp.MustCompile(`^[A-Za-z0-9]+-[0-9]+$`) - // Projects, documents, initiatives and reviews end their URL slug with a short - // hex id, which is what Linear calls the slug id. - slugIDPattern = regexp.MustCompile(`^[0-9a-f]{8,16}$`) -) - -// ParseLinearURL parses a linear.app URL. It reports false when raw is not one, -// which is the common case: most references are bare identifiers or UUIDs. -func ParseLinearURL(raw string) (LinearRef, bool) { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return LinearRef{}, false - } - - // People paste bare hosts as often as full URLs. - if !strings.Contains(trimmed, "://") { - if !strings.HasPrefix(strings.ToLower(trimmed), "linear.app/") { - return LinearRef{}, false +// parseLinearURL parses // Linear web and desktop URLs. +func parseLinearURL(raw string) (linearRef, bool) { + raw = strings.TrimSpace(raw) + if !strings.Contains(raw, "://") { + if !strings.HasPrefix(strings.ToLower(raw), "linear.app/") { + return linearRef{}, false } - trimmed = "https://" + trimmed + raw = "https://" + raw } - parsed, err := url.Parse(trimmed) + parsed, err := url.Parse(raw) if err != nil { - return LinearRef{}, false + return linearRef{}, false + } + switch strings.ToLower(parsed.Scheme) { + case "http", "https", "linear": + default: + return linearRef{}, false } host := strings.ToLower(parsed.Hostname()) if host != "linear.app" && host != "www.linear.app" { - return LinearRef{}, false + return linearRef{}, false } - var segments []string - for _, segment := range strings.Split(parsed.Path, "/") { - if segment != "" { - segments = append(segments, segment) - } + parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") + if len(parts) < 3 || parts[0] == "" || parts[2] == "" { + return linearRef{}, false } - for i, segment := range segments { - kind := LinearRefKind(strings.ToLower(segment)) - switch kind { - case LinearRefIssue, LinearRefProject, LinearRefDocument, LinearRefTeam, LinearRefInitiative, LinearRefReview: - default: - continue - } - if i == len(segments)-1 { - return LinearRef{}, false - } - - ref := LinearRef{Kind: kind, ID: segments[i+1], CommentID: commentIDFromURL(parsed)} - if i > 0 { - ref.Workspace = segments[0] - } - return ref, true + kind := strings.ToLower(parts[1]) + switch kind { + case refIssue, refProject, refDocument, refTeam, refInitiative, refReview: + default: + return linearRef{}, false } - return LinearRef{}, false -} - -func commentIDFromURL(parsed *url.URL) string { - if value := strings.TrimSpace(parsed.Query().Get("commentId")); value != "" { - return value - } - if fragment := strings.TrimSpace(parsed.Fragment); strings.HasPrefix(fragment, "comment-") { - return strings.TrimPrefix(fragment, "comment-") + commentID := strings.TrimSpace(parsed.Query().Get("commentId")) + if commentID == "" && strings.HasPrefix(parsed.Fragment, "comment-") { + commentID = strings.TrimPrefix(parsed.Fragment, "comment-") } - return "" -} -// NormalizeIssueRef accepts an issue identifier, a UUID or a linear.app issue URL -// and returns the value Linear's issue(id:) query understands. -func NormalizeIssueRef(ref string) (string, error) { - return normalizeRef(ref, LinearRefIssue, "issue") + return linearRef{kind: kind, id: parts[2], commentID: commentID}, true } -// NormalizeProjectRef accepts a project name, a UUID, a slug id or a linear.app -// project URL and returns the value Linear's project(id:) query understands. -func NormalizeProjectRef(ref string) (string, error) { - return normalizeRef(ref, LinearRefProject, "project") +// NormalizeIssueRef accepts an issue identifier, UUID or Linear issue URL. +func NormalizeIssueRef(raw string) (string, error) { + return normalizeRef(raw, refIssue) } -// NormalizeTeamRef accepts a team key, a UUID or a linear.app team URL and returns -// the value Linear's team(id:) query understands. -func NormalizeTeamRef(ref string) (string, error) { - return normalizeRef(ref, LinearRefTeam, "team") +// NormalizeProjectRef accepts a project name, UUID, slug ID or Linear project URL. +func NormalizeProjectRef(raw string) (string, error) { + return normalizeRef(raw, refProject) } -func normalizeRef(ref string, want LinearRefKind, label string) (string, error) { - trimmed := strings.TrimSpace(ref) - if trimmed == "" { - return "", fmt.Errorf("%s reference cannot be empty", label) - } - - parsed, ok := ParseLinearURL(trimmed) - if !ok { - return trimmed, nil - } - if parsed.Kind != want { - return "", wrongRefKindError(trimmed, parsed, label) - } - if want == LinearRefIssue && !issueIdentifierPattern.MatchString(parsed.ID) && !uuidPattern.MatchString(parsed.ID) { - return "", fmt.Errorf("%s does not contain an issue identifier", trimmed) - } - return parsed.ID, nil +// NormalizeTeamRef accepts a team key, UUID or Linear team URL. +func NormalizeTeamRef(raw string) (string, error) { + return normalizeRef(raw, refTeam) } -func wrongRefKindError(raw string, parsed LinearRef, want string) error { - if parsed.Kind == LinearRefReview { - return fmt.Errorf("%s is a Linear review URL. It points at a pull request, and Linear's API cannot map one back to %s. Pass the GitHub pull request URL instead, or find the issue with: linctl issue search %q", - raw, withArticle(want), reviewSearchTerms(parsed.ID)) +func normalizeRef(raw, want string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", fmt.Errorf("%s reference cannot be empty", want) } - return fmt.Errorf("%s is %s URL, not %s URL", raw, withArticle("Linear "+string(parsed.Kind)), withArticle(want)) -} -// reviewSearchTerms turns the slug of a review URL into words worth searching for, -// dropping the trailing slug id. Review slugs come from the pull request title, -// which usually echoes the issue title closely enough for full-text search. -func reviewSearchTerms(slug string) string { - words := strings.Split(slug, "-") - if n := len(words); n > 1 && slugIDPattern.MatchString(words[n-1]) { - words = words[:n-1] + ref, ok := parseLinearURL(raw) + if !ok { + return raw, nil } - return strings.Join(words, " ") -} - -func withArticle(noun string) string { - if noun == "" { - return noun + if ref.kind != want { + if ref.kind == refReview && want == refIssue { + return "", fmt.Errorf("%s is a Linear review URL, not an issue URL. Pass the GitHub pull request URL instead", raw) + } + return "", fmt.Errorf("%s is a Linear %s URL, not a %s URL", raw, ref.kind, want) } - if strings.ContainsRune("aeiouAEIOU", rune(noun[0])) { - return "an " + noun + if want == refIssue && !issueIdentifierPattern.MatchString(ref.id) && !uuidPattern.MatchString(ref.id) { + return "", fmt.Errorf("%s does not contain an issue identifier", raw) } - return "a " + noun + return ref.id, nil } -// ResolveIssueRef resolves a user-supplied issue reference. Identifiers and UUIDs -// pass through untouched, linear.app issue URLs are parsed locally, and GitHub pull -// request URLs are looked up through the attachment that links them to an issue. -func (c *Client) ResolveIssueRef(ctx context.Context, ref string) (string, error) { - trimmed := strings.TrimSpace(ref) - if prURL, ok := canonicalGitHubPullRequestURL(trimmed); ok { +func (c *Client) resolveIssueRef(ctx context.Context, raw string) (string, error) { + if prURL, ok := canonicalGitHubPullRequestURL(strings.TrimSpace(raw)); ok { return c.issueRefFromPullRequestURL(ctx, prURL) } - return NormalizeIssueRef(trimmed) + return NormalizeIssueRef(raw) } -// ResolveCommentRef resolves a comment UUID or a linear.app comment URL to a comment -// UUID. Web app links carry only an 8-character prefix, so those cost a lookup. -func (c *Client) ResolveCommentRef(ctx context.Context, ref string) (string, error) { - trimmed := strings.TrimSpace(ref) - if trimmed == "" { +func (c *Client) resolveCommentRef(ctx context.Context, raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { return "", fmt.Errorf("comment reference cannot be empty") } - parsed, ok := ParseLinearURL(trimmed) + ref, ok := parseLinearURL(raw) if !ok { - return trimmed, nil + return raw, nil } - if parsed.Kind != LinearRefIssue { - return "", wrongRefKindError(trimmed, parsed, "issue") + if ref.kind != refIssue { + return "", fmt.Errorf("%s is a Linear %s URL, not a comment URL", raw, ref.kind) } - if parsed.CommentID == "" { - return "", fmt.Errorf("%s does not point at a comment. Use Linear's 'Copy link' on the comment itself", trimmed) + if ref.commentID == "" { + return "", fmt.Errorf("%s does not point at a comment. Use Linear's 'Copy link' on the comment itself", raw) } - if uuidPattern.MatchString(parsed.CommentID) { - return parsed.CommentID, nil + if uuidPattern.MatchString(ref.commentID) { + return ref.commentID, nil } - return c.findCommentByIDPrefix(ctx, parsed.ID, parsed.CommentID) + return c.findCommentByIDPrefix(ctx, ref.id, ref.commentID) } -func (c *Client) findCommentByIDPrefix(ctx context.Context, issueRef string, prefix string) (string, error) { +func (c *Client) findCommentByIDPrefix(ctx context.Context, issueRef, prefix string) (string, error) { + match := "" after := "" for { comments, err := c.GetIssueComments(ctx, issueRef, 100, after, "") @@ -214,26 +149,42 @@ func (c *Client) findCommentByIDPrefix(ctx context.Context, issueRef string, pre return "", err } for _, comment := range comments.Nodes { - if strings.HasPrefix(comment.ID, prefix) { - return comment.ID, nil + if !strings.HasPrefix(comment.ID, prefix) || comment.ID == match { + continue } + if match != "" { + return "", fmt.Errorf("comment prefix %q is ambiguous on issue %s", prefix, issueRef) + } + match = comment.ID + } + if !comments.PageInfo.HasNextPage { + break } - if !comments.PageInfo.HasNextPage || comments.PageInfo.EndCursor == "" { - return "", fmt.Errorf("no comment starting with %q found on issue %s", prefix, issueRef) + if comments.PageInfo.EndCursor == "" { + return "", fmt.Errorf("comment lookup for issue %s returned no page cursor", issueRef) } after = comments.PageInfo.EndCursor } + + if match == "" { + return "", fmt.Errorf("no comment starting with %q found on issue %s", prefix, issueRef) + } + return match, nil } func (c *Client) issueRefFromPullRequestURL(ctx context.Context, prURL string) (string, error) { query := ` query AttachmentsForURL($url: String!) { - attachmentsForURL(url: $url, first: 10) { + attachmentsForURL(url: $url, first: 100) { nodes { issue { identifier } } + pageInfo { + hasNextPage + endCursor + } } } ` @@ -245,30 +196,40 @@ func (c *Client) issueRefFromPullRequestURL(ctx context.Context, prURL string) ( Identifier string `json:"identifier"` } `json:"issue"` } `json:"nodes"` + PageInfo PageInfo `json:"pageInfo"` } `json:"attachmentsForURL"` } - if err := c.Execute(ctx, query, map[string]interface{}{"url": prURL}, &response); err != nil { return "", err } + if response.AttachmentsForURL.PageInfo.HasNextPage { + return "", fmt.Errorf("too many Linear attachments match %s", prURL) + } + identifiers := make(map[string]struct{}) for _, node := range response.AttachmentsForURL.Nodes { if node.Issue != nil && node.Issue.Identifier != "" { - return node.Issue.Identifier, nil + identifiers[node.Issue.Identifier] = struct{}{} + } + } + if len(identifiers) > 1 { + matches := make([]string, 0, len(identifiers)) + for identifier := range identifiers { + matches = append(matches, identifier) } + sort.Strings(matches) + return "", fmt.Errorf("%s is linked to multiple Linear issues: %s", prURL, strings.Join(matches, ", ")) } + for identifier := range identifiers { + return identifier, nil + } return "", fmt.Errorf("no Linear issue is linked to %s", prURL) } -// canonicalGitHubPullRequestURL recognises a GitHub pull request URL and rewrites it -// to the form Linear stores on the issue attachment. func canonicalGitHubPullRequestURL(raw string) (string, bool) { - if !strings.Contains(raw, "://") { - return "", false - } parsed, err := url.Parse(raw) - if err != nil { + if err != nil || parsed.Scheme == "" { return "", false } host := strings.ToLower(parsed.Hostname()) @@ -277,12 +238,13 @@ func canonicalGitHubPullRequestURL(raw string) (string, bool) { } parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") - if len(parts) < 4 || parts[2] != "pull" { + if len(parts) < 4 || parts[0] == "" || parts[1] == "" || parts[2] != "pull" { return "", false } - if _, err := strconv.Atoi(parts[3]); err != nil { + number, err := strconv.ParseUint(parts[3], 10, 64) + if err != nil || number == 0 { return "", false } - return fmt.Sprintf("https://github.com/%s/%s/pull/%s", parts[0], parts[1], parts[3]), true + return fmt.Sprintf("https://github.com/%s/%s/pull/%d", parts[0], parts[1], number), true } diff --git a/pkg/api/reference_test.go b/pkg/api/reference_test.go index 5bd730f..a384d4e 100644 --- a/pkg/api/reference_test.go +++ b/pkg/api/reference_test.go @@ -10,325 +10,195 @@ import ( ) func TestParseLinearURL(t *testing.T) { - cases := []struct { - name string - raw string - want LinearRef - wantK bool + tests := []struct { + name string + raw string + want linearRef + ok bool }{ - { - name: "issue url with slug", - raw: "https://linear.app/glif/issue/API-379/block-signups-from-example", - want: LinearRef{Kind: LinearRefIssue, Workspace: "glif", ID: "API-379"}, - wantK: true, - }, - { - name: "issue url without slug", - raw: "https://linear.app/glif/issue/API-379", - want: LinearRef{Kind: LinearRefIssue, Workspace: "glif", ID: "API-379"}, - wantK: true, - }, - { - name: "issue url with comment fragment", - raw: "https://linear.app/glif/issue/GTM-580/make-it-generic#comment-b68a4bf5", - want: LinearRef{Kind: LinearRefIssue, Workspace: "glif", ID: "GTM-580", CommentID: "b68a4bf5"}, - wantK: true, - }, - { - name: "issue url with commentId query", - raw: "https://linear.app/glif/issue/GTM-580?commentId=b68a4bf5-8a34-473e-af4c-b8892a78a9af", - want: LinearRef{Kind: LinearRefIssue, Workspace: "glif", ID: "GTM-580", CommentID: "b68a4bf5-8a34-473e-af4c-b8892a78a9af"}, - wantK: true, - }, - { - name: "issue url with agent session fragment", - raw: "https://linear.app/glif/issue/API-379/slug#agent-session-56881231", - want: LinearRef{Kind: LinearRefIssue, Workspace: "glif", ID: "API-379"}, - wantK: true, - }, - { - name: "scheme-less url", - raw: "linear.app/glif/issue/API-379/slug", - want: LinearRef{Kind: LinearRefIssue, Workspace: "glif", ID: "API-379"}, - wantK: true, - }, - { - name: "desktop deep link", - raw: "linear://linear.app/glif/issue/API-379", - want: LinearRef{Kind: LinearRefIssue, Workspace: "glif", ID: "API-379"}, - wantK: true, - }, - { - name: "project url", - raw: "https://linear.app/glif/project/benchmarkmaxx-d05c5c7e8a5c/overview", - want: LinearRef{Kind: LinearRefProject, Workspace: "glif", ID: "benchmarkmaxx-d05c5c7e8a5c"}, - wantK: true, - }, - { - name: "team url", - raw: "https://linear.app/glif/team/API/active", - want: LinearRef{Kind: LinearRefTeam, Workspace: "glif", ID: "API"}, - wantK: true, - }, - { - name: "document url", - raw: "https://linear.app/glif/document/glif-tgim-sync-507735cb56c1", - want: LinearRef{Kind: LinearRefDocument, Workspace: "glif", ID: "glif-tgim-sync-507735cb56c1"}, - wantK: true, - }, - { - name: "review url", - raw: "https://linear.app/glif/review/replace-polymorphic-apitokenid-ca4153a35dc0", - want: LinearRef{Kind: LinearRefReview, Workspace: "glif", ID: "replace-polymorphic-apitokenid-ca4153a35dc0"}, - wantK: true, - }, - {name: "bare identifier", raw: "API-379"}, - {name: "uuid", raw: "b68a4bf5-8a34-473e-af4c-b8892a78a9af"}, - {name: "github url", raw: "https://github.com/glifxyz/glif-graph/pull/6153"}, - {name: "empty", raw: ""}, - {name: "linear url with no entity segment", raw: "https://linear.app/glif/settings"}, - {name: "issue segment with nothing after it", raw: "https://linear.app/glif/issue"}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got, ok := ParseLinearURL(tc.raw) - if ok != tc.wantK { - t.Fatalf("ParseLinearURL(%q) ok = %v, want %v", tc.raw, ok, tc.wantK) - } - if got != tc.want { - t.Fatalf("ParseLinearURL(%q) = %#v, want %#v", tc.raw, got, tc.want) + {"issue", "https://linear.app/glif/issue/API-379/a-title", linearRef{kind: refIssue, id: "API-379"}, true}, + {"comment fragment", "https://linear.app/glif/issue/GTM-580/title#comment-b68a4bf5", linearRef{kind: refIssue, id: "GTM-580", commentID: "b68a4bf5"}, true}, + {"comment query", "https://linear.app/glif/issue/GTM-580?commentId=b68a4bf5-8a34-473e-af4c-b8892a78a9af", linearRef{kind: refIssue, id: "GTM-580", commentID: "b68a4bf5-8a34-473e-af4c-b8892a78a9af"}, true}, + {"other fragment", "https://linear.app/glif/issue/API-379/title#agent-session-56881231", linearRef{kind: refIssue, id: "API-379"}, true}, + {"without scheme", "linear.app/glif/issue/API-379", linearRef{kind: refIssue, id: "API-379"}, true}, + {"desktop link", "linear://linear.app/glif/issue/API-379", linearRef{kind: refIssue, id: "API-379"}, true}, + {"www host", "https://www.linear.app/glif/team/API/active", linearRef{kind: refTeam, id: "API"}, true}, + {"project", "https://linear.app/glif/project/roadmap-d05c5c7e8a5c/overview", linearRef{kind: refProject, id: "roadmap-d05c5c7e8a5c"}, true}, + {"document", "https://linear.app/glif/document/notes-507735cb56c1", linearRef{kind: refDocument, id: "notes-507735cb56c1"}, true}, + {"initiative", "https://linear.app/glif/initiative/costs-83e5d6e7a371", linearRef{kind: refInitiative, id: "costs-83e5d6e7a371"}, true}, + {"review", "https://linear.app/glif/review/change-ca4153a35dc0", linearRef{kind: refReview, id: "change-ca4153a35dc0"}, true}, + {"bare identifier", "API-379", linearRef{}, false}, + {"wrong host", "https://github.com/acme/api/pull/1", linearRef{}, false}, + {"wrong scheme", "ftp://linear.app/glif/issue/API-379", linearRef{}, false}, + {"malformed URL", "https://linear.app/%zz", linearRef{}, false}, + {"missing workspace", "https://linear.app/issue/API-379", linearRef{}, false}, + {"missing id", "https://linear.app/glif/issue", linearRef{}, false}, + {"settings", "https://linear.app/glif/settings", linearRef{}, false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, ok := parseLinearURL(test.raw) + if got != test.want || ok != test.ok { + t.Fatalf("parseLinearURL(%q) = %#v, %v; want %#v, %v", test.raw, got, ok, test.want, test.ok) } }) } } -func TestNormalizeIssueRef(t *testing.T) { - cases := []struct { +func TestNormalizeRefs(t *testing.T) { + tests := []struct { name string - ref string + fn func(string) (string, error) + raw string want string wantErr string }{ - {name: "identifier passes through", ref: "API-379", want: "API-379"}, - {name: "uuid passes through", ref: "b68a4bf5-8a34-473e-af4c-b8892a78a9af", want: "b68a4bf5-8a34-473e-af4c-b8892a78a9af"}, - {name: "surrounding whitespace trimmed", ref: " API-379\n", want: "API-379"}, - {name: "issue url", ref: "https://linear.app/glif/issue/API-379/some-slug", want: "API-379"}, - {name: "empty", ref: " ", wantErr: "issue reference cannot be empty"}, - {name: "project url", ref: "https://linear.app/glif/project/benchmarkmaxx-d05c5c7e8a5c", wantErr: "is a Linear project URL"}, - {name: "review url", ref: "https://linear.app/glif/review/replace-polymorphic-ca4153a35dc0", wantErr: "is a Linear review URL"}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got, err := NormalizeIssueRef(tc.ref) - if tc.wantErr != "" { - if err == nil || !strings.Contains(err.Error(), tc.wantErr) { - t.Fatalf("NormalizeIssueRef(%q) error = %v, want it to contain %q", tc.ref, err, tc.wantErr) + {"issue identifier", NormalizeIssueRef, " API-379\n", "API-379", ""}, + {"issue UUID", NormalizeIssueRef, "b68a4bf5-8a34-473e-af4c-b8892a78a9af", "b68a4bf5-8a34-473e-af4c-b8892a78a9af", ""}, + {"issue URL", NormalizeIssueRef, "https://linear.app/glif/issue/API-379/title", "API-379", ""}, + {"project URL", NormalizeProjectRef, "https://linear.app/glif/project/roadmap-d05c5c7e8a5c/issues", "roadmap-d05c5c7e8a5c", ""}, + {"team URL", NormalizeTeamRef, "https://linear.app/glif/team/API/all", "API", ""}, + {"empty issue", NormalizeIssueRef, " ", "", "issue reference cannot be empty"}, + {"wrong kind", NormalizeTeamRef, "https://linear.app/glif/issue/API-379", "", "Linear issue URL, not a team URL"}, + {"new issue page", NormalizeIssueRef, "https://linear.app/glif/issue/new", "", "does not contain an issue identifier"}, + {"review URL", NormalizeIssueRef, "https://linear.app/glif/review/change-ca4153a35dc0", "", "Pass the GitHub pull request URL"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := test.fn(test.raw) + if test.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("error = %v, want it to contain %q", err, test.wantErr) } return } - if err != nil { - t.Fatalf("NormalizeIssueRef(%q) returned error: %v", tc.ref, err) - } - if got != tc.want { - t.Fatalf("NormalizeIssueRef(%q) = %q, want %q", tc.ref, got, tc.want) + if err != nil || got != test.want { + t.Fatalf("got %q, %v; want %q, nil", got, err, test.want) } }) } } -func TestNormalizeProjectAndTeamRefs(t *testing.T) { - projectID, err := NormalizeProjectRef("https://linear.app/glif/project/benchmarkmaxx-d05c5c7e8a5c/issues") - if err != nil { - t.Fatalf("NormalizeProjectRef returned error: %v", err) - } - if projectID != "benchmarkmaxx-d05c5c7e8a5c" { - t.Fatalf("expected slug id, got %q", projectID) - } - - teamKey, err := NormalizeTeamRef("https://linear.app/glif/team/API/all") - if err != nil { - t.Fatalf("NormalizeTeamRef returned error: %v", err) - } - if teamKey != "API" { - t.Fatalf("expected team key API, got %q", teamKey) - } - - if _, err := NormalizeTeamRef("https://linear.app/glif/issue/API-379"); err == nil { - t.Fatal("expected an error for an issue URL passed as a team ref") - } -} - func TestGetIssueAcceptsIssueURL(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) - } + server := graphqlTestServer(t, func(req gqlTestRequest) string { if req.Variables["id"] != "API-379" { - t.Fatalf("expected id API-379, got %v", req.Variables["id"]) + t.Fatalf("id = %v, want API-379", req.Variables["id"]) } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"data":{"issue":{"id":"i1","identifier":"API-379","title":"Block signups"}}}`)) - })) - defer srv.Close() + return `{"data":{"issue":{"id":"i1","identifier":"API-379"}}}` + }) + defer server.Close() - c := NewClientWithURL(srv.URL, "Bearer test") - issue, err := c.GetIssue(context.Background(), "https://linear.app/glif/issue/API-379/block-signups") - if err != nil { - t.Fatalf("GetIssue returned error: %v", err) - } - if issue.Identifier != "API-379" { - t.Fatalf("expected API-379, got %s", issue.Identifier) + issue, err := NewClientWithURL(server.URL, "test").GetIssue(context.Background(), "https://linear.app/glif/issue/API-379/title") + if err != nil || issue.Identifier != "API-379" { + t.Fatalf("GetIssue() = %#v, %v", issue, err) } } -func TestResolveIssueRefFromGitHubPullRequestURL(t *testing.T) { - var sawURL interface{} - 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) - } - if !strings.Contains(req.Query, "query AttachmentsForURL(") { - t.Fatalf("expected AttachmentsForURL query, got: %s", req.Query) +func TestGetProjectMilestonesAcceptsProjectURL(t *testing.T) { + server := graphqlTestServer(t, func(req gqlTestRequest) string { + if req.Variables["id"] != "roadmap-d05c5c7e8a5c" { + t.Fatalf("id = %v, want project URL identifier", req.Variables["id"]) } - sawURL = req.Variables["url"] - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"data":{"attachmentsForURL":{"nodes":[{"issue":null},{"issue":{"identifier":"API-285"}}]}}}`)) - })) - defer srv.Close() + return `{"data":{"project":{"projectMilestones":{"nodes":[],"pageInfo":{"hasNextPage":false}}}}}` + }) + defer server.Close() - c := NewClientWithURL(srv.URL, "Bearer test") - ref, err := c.ResolveIssueRef(context.Background(), "https://github.com/glifxyz/glif-graph/pull/6153?w=1") + _, err := NewClientWithURL(server.URL, "test").GetProjectMilestones(context.Background(), "https://linear.app/glif/project/roadmap-d05c5c7e8a5c/overview") if err != nil { - t.Fatalf("ResolveIssueRef returned error: %v", err) - } - if ref != "API-285" { - t.Fatalf("expected API-285, got %q", ref) - } - if sawURL != "https://github.com/glifxyz/glif-graph/pull/6153" { - t.Fatalf("expected the canonical PR URL, got %v", sawURL) - } -} - -func TestResolveIssueRefUnlinkedPullRequest(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"data":{"attachmentsForURL":{"nodes":[]}}}`)) - })) - defer srv.Close() - - c := NewClientWithURL(srv.URL, "Bearer test") - _, err := c.ResolveIssueRef(context.Background(), "https://github.com/glifxyz/glif-graph/pull/6153") - if err == nil || !strings.Contains(err.Error(), "no Linear issue is linked") { - t.Fatalf("expected an unlinked-PR error, got %v", err) + t.Fatalf("GetProjectMilestones() error = %v", err) } } -func TestResolveCommentRefFromURL(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) - } - if !strings.Contains(req.Query, "query IssueComments(") { - t.Fatalf("expected IssueComments query, got: %s", req.Query) - } - if req.Variables["id"] != "GTM-580" { - t.Fatalf("expected id GTM-580, got %v", req.Variables["id"]) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"data":{"issue":{"comments":{"nodes":[{"id":"11111111-1111-1111-1111-111111111111"},{"id":"b68a4bf5-8a34-473e-af4c-b8892a78a9af"}],"pageInfo":{"hasNextPage":false,"endCursor":""}}}}}`)) - })) - defer srv.Close() - - c := NewClientWithURL(srv.URL, "Bearer test") - id, err := c.ResolveCommentRef(context.Background(), "https://linear.app/glif/issue/GTM-580/make-it-generic#comment-b68a4bf5") - if err != nil { - t.Fatalf("ResolveCommentRef returned error: %v", err) - } - if id != "b68a4bf5-8a34-473e-af4c-b8892a78a9af" { - t.Fatalf("expected the full comment UUID, got %q", id) +func TestResolveIssueRefFromPullRequest(t *testing.T) { + tests := []struct { + name string + body string + want string + wantErr string + }{ + {"linked", `{"data":{"attachmentsForURL":{"nodes":[{"issue":null},{"issue":{"identifier":"API-285"}}]}}}`, "API-285", ""}, + {"unlinked", `{"data":{"attachmentsForURL":{"nodes":[]}}}`, "", "no Linear issue is linked"}, + {"ambiguous", `{"data":{"attachmentsForURL":{"nodes":[{"issue":{"identifier":"API-285"}},{"issue":{"identifier":"API-379"}}]}}}`, "", "linked to multiple Linear issues: API-285, API-379"}, + {"truncated", `{"data":{"attachmentsForURL":{"nodes":[{"issue":{"identifier":"API-285"}}],"pageInfo":{"hasNextPage":true}}}}`, "", "too many Linear attachments"}, } -} -func TestResolveCommentRefWithoutCommentFragment(t *testing.T) { - c := NewClientWithURL("http://127.0.0.1:0", "Bearer test") - _, err := c.ResolveCommentRef(context.Background(), "https://linear.app/glif/issue/GTM-580/make-it-generic") - if err == nil || !strings.Contains(err.Error(), "does not point at a comment") { - t.Fatalf("expected a missing-comment error, got %v", err) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := graphqlTestServer(t, func(req gqlTestRequest) string { + if req.Variables["url"] != "https://github.com/acme/api/pull/6153" { + t.Fatalf("url = %v, want canonical URL", req.Variables["url"]) + } + return test.body + }) + defer server.Close() + + got, err := NewClientWithURL(server.URL, "test").resolveIssueRef(context.Background(), "https://www.github.com/acme/api/pull/6153/files?w=1") + if test.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("error = %v, want it to contain %q", err, test.wantErr) + } + return + } + if err != nil || got != test.want { + t.Fatalf("got %q, %v; want %q, nil", got, err, test.want) + } + }) } } -func TestParseLinearURLMoreVariants(t *testing.T) { - cases := []struct { - name string - raw string - want LinearRef +func TestResolveCommentRef(t *testing.T) { + tests := []struct { + name string + body string + want string + wantErr string }{ - { - name: "initiative url", - raw: "https://linear.app/glif/initiative/reduce-costs-83e5d6e7a371", - want: LinearRef{Kind: LinearRefInitiative, Workspace: "glif", ID: "reduce-costs-83e5d6e7a371"}, - }, - { - name: "trailing slash", - raw: "https://linear.app/glif/issue/API-379/", - want: LinearRef{Kind: LinearRefIssue, Workspace: "glif", ID: "API-379"}, - }, - { - name: "query string", - raw: "https://linear.app/glif/issue/API-379/slug?tab=activity", - want: LinearRef{Kind: LinearRefIssue, Workspace: "glif", ID: "API-379"}, - }, - { - name: "www host", - raw: "https://www.linear.app/glif/issue/API-379", - want: LinearRef{Kind: LinearRefIssue, Workspace: "glif", ID: "API-379"}, - }, - { - name: "lowercase identifier", - raw: "https://linear.app/glif/issue/api-379/slug", - want: LinearRef{Kind: LinearRefIssue, Workspace: "glif", ID: "api-379"}, - }, - { - name: "team cycle view", - raw: "https://linear.app/glif/team/API/cycle/12", - want: LinearRef{Kind: LinearRefTeam, Workspace: "glif", ID: "API"}, - }, - { - name: "project sub-tab", - raw: "https://linear.app/glif/project/benchmarkmaxx-d05c5c7e8a5c/documents", - want: LinearRef{Kind: LinearRefProject, Workspace: "glif", ID: "benchmarkmaxx-d05c5c7e8a5c"}, - }, + {"unique", `{"data":{"issue":{"comments":{"nodes":[{"id":"b68a4bf5-8a34-473e-af4c-b8892a78a9af"}],"pageInfo":{"hasNextPage":false}}}}}`, "b68a4bf5-8a34-473e-af4c-b8892a78a9af", ""}, + {"ambiguous", `{"data":{"issue":{"comments":{"nodes":[{"id":"b68a4bf5-1111-1111-1111-111111111111"},{"id":"b68a4bf5-2222-2222-2222-222222222222"}],"pageInfo":{"hasNextPage":false}}}}}`, "", "comment prefix \"b68a4bf5\" is ambiguous"}, } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got, ok := ParseLinearURL(tc.raw) - if !ok { - t.Fatalf("ParseLinearURL(%q) did not parse", tc.raw) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := graphqlTestServer(t, func(req gqlTestRequest) string { + if req.Variables["id"] != "GTM-580" { + t.Fatalf("id = %v, want GTM-580", req.Variables["id"]) + } + return test.body + }) + defer server.Close() + + got, err := NewClientWithURL(server.URL, "test").resolveCommentRef(context.Background(), "https://linear.app/glif/issue/GTM-580/title#comment-b68a4bf5") + if test.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("error = %v, want it to contain %q", err, test.wantErr) + } + return } - if got != tc.want { - t.Fatalf("ParseLinearURL(%q) = %#v, want %#v", tc.raw, got, tc.want) + if err != nil || got != test.want { + t.Fatalf("got %q, %v; want %q, nil", got, err, test.want) } }) } } -func TestNormalizeIssueRefRejectsNonIssuePaths(t *testing.T) { - cases := map[string]string{ - "https://linear.app/glif/issue/new": "does not contain an issue identifier", - "https://linear.app/glif/initiative/reduce-costs-83e5d6e7a371": "is a Linear initiative URL", - "https://linear.app/glif/document/glif-tgim-sync-507735cb56c1": "is a Linear document URL", - "https://linear.app/glif/review/replace-polymorphic-ca4153a35dc0": "linctl issue search \"replace polymorphic\"", +func TestResolveCommentRefRequiresCommentLink(t *testing.T) { + client := NewClientWithURL("http://127.0.0.1:0", "test") + _, err := client.resolveCommentRef(context.Background(), "https://linear.app/glif/issue/GTM-580/title") + if err == nil || !strings.Contains(err.Error(), "does not point at a comment") { + t.Fatalf("error = %v, want missing comment error", err) } +} - for ref, want := range cases { - if _, err := NormalizeIssueRef(ref); err == nil || !strings.Contains(err.Error(), want) { - t.Fatalf("NormalizeIssueRef(%q) error = %v, want it to contain %q", ref, err, want) +func graphqlTestServer(t *testing.T, respond func(gqlTestRequest) string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request gqlTestRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatalf("decode request: %v", err) } - } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(respond(request))) + })) } From 073dccec126356cf8c2a69531f418db94eac4eb6 Mon Sep 17 00:00:00 2001 From: Jamie Dubs <1903+jamiew@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:28:05 -0400 Subject: [PATCH 4/6] Address review feedback - drop the unrelated .claude/ gitignore change - assert GetProjects still selects both slugId and status - say plainly that Linear review URLs are not references --- .gitignore | 5 +---- README.md | 4 ++-- SKILL.md | 6 +++--- pkg/api/project_status_test.go | 30 ++++++++++++++++++++++++++++++ 4 files changed, 36 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index eabd494..cc50a9a 100644 --- a/.gitignore +++ b/.gitignore @@ -42,7 +42,4 @@ dist/ # Test files .env.test coverage.out -coverage.html - -# Claude Code directory -.claude/ \ No newline at end of file +coverage.html \ No newline at end of file diff --git a/README.md b/README.md index dc6accd..e0c2ae5 100644 --- a/README.md +++ b/README.md @@ -87,8 +87,8 @@ linctl issue get https://github.com/acme/api/pull/6153 ``` Quote URLs containing `#`. GitHub pull request URLs resolve through their Linear -attachment. Linear review URLs do not expose the pull request URL, so use the GitHub -URL instead. +attachment. Linear review URLs (`/review/...`) are not references: they do not expose +the pull request URL, so use the GitHub URL instead. ## Quick Start diff --git a/SKILL.md b/SKILL.md index 7ded474..4a39b7c 100644 --- a/SKILL.md +++ b/SKILL.md @@ -14,8 +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 --help`. - Be explicit with filters; defaults can hide expected results. -- Linear and attached GitHub pull request URLs work as entity references. Quote URLs - containing `#`. +- Linear issue, project, team and comment URLs work as entity references, as do + GitHub pull request URLs with a Linear attachment. Quote URLs containing `#`. ## High-Impact Gotchas @@ -24,7 +24,7 @@ Use this skill when the user wants to inspect or modify Linear data through `lin - `issue search` may also need `--include-archived` for archived matches. - `issue list --cycle current` can validly return no rows if no active cycle exists. - Parent/sub-issue links are set via `issue update --parent` (not `issue create`). -- Use the GitHub pull request URL instead of a Linear review URL. +- Linear review URLs (`/review/...`) are not references. Pass the GitHub pull request URL instead. - If results look incomplete, retry with: - `--newer-than all_time` - `--include-completed` diff --git a/pkg/api/project_status_test.go b/pkg/api/project_status_test.go index e41f411..3e57a89 100644 --- a/pkg/api/project_status_test.go +++ b/pkg/api/project_status_test.go @@ -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) + } +} From e1010a4a575c4bd404dac57864d15041ecdc9904 Mon Sep 17 00:00:00 2001 From: Jamie Dubs <1903+jamiew@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:36:32 -0400 Subject: [PATCH 5/6] Tighten the URL docs --- CHANGELOG.md | 4 ++-- README.md | 7 +++---- SKILL.md | 5 ++--- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c69462..280ed40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,8 @@ tags, PR merge commits, and tag-to-tag commit history. ### Added -- Accept Linear URLs for issue, project, team and comment references. GitHub pull - request URLs also resolve to their attached Linear issue. +- Accept Linear URLs as issue, project, team and comment references, plus GitHub PR + URLs attached to a Linear issue. ## [v0.1.12] - 2026-08-23 diff --git a/README.md b/README.md index e0c2ae5..04705c4 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ This improves performance and prevents overwhelming data loads. To see older ite ## Pasting URLs -You can use Linear URLs anywhere `linctl` accepts an issue, project, team or comment. +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 @@ -86,9 +86,8 @@ 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 pull request URLs resolve through their Linear -attachment. Linear review URLs (`/review/...`) are not references: they do not expose -the pull request URL, so use the GitHub URL instead. +Quote URLs containing `#`. GitHub PR URLs will work as well, but only if one is +attached to an existing Linear issue. ## Quick Start diff --git a/SKILL.md b/SKILL.md index 4a39b7c..bcb7b12 100644 --- a/SKILL.md +++ b/SKILL.md @@ -14,8 +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 --help`. - Be explicit with filters; defaults can hide expected results. -- Linear issue, project, team and comment URLs work as entity references, as do - GitHub pull request URLs with a Linear attachment. Quote URLs containing `#`. +- 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 @@ -24,7 +24,6 @@ Use this skill when the user wants to inspect or modify Linear data through `lin - `issue search` may also need `--include-archived` for archived matches. - `issue list --cycle current` can validly return no rows if no active cycle exists. - Parent/sub-issue links are set via `issue update --parent` (not `issue create`). -- Linear review URLs (`/review/...`) are not references. Pass the GitHub pull request URL instead. - If results look incomplete, retry with: - `--newer-than all_time` - `--include-completed` From 558795299959b121c79c4d849aa4a30ea8c7b8e8 Mon Sep 17 00:00:00 2001 From: Jamie Dubs <1903+jamiew@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:43:28 -0400 Subject: [PATCH 6/6] Add an example URL to the changelog entry --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 280ed40..aca4c01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,8 @@ tags, PR merge commits, and tag-to-tag commit history. ### Added - Accept Linear URLs as issue, project, team and comment references, plus GitHub PR - URLs attached to a Linear issue. + 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