diff --git a/CHANGELOG.md b/CHANGELOG.md index 949e708..aca4c01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ tags, PR merge commits, and tag-to-tag commit history. ## [Unreleased] +### Added + +- Accept Linear URLs as issue, project, team and comment references, plus GitHub PR + URLs attached to a Linear issue. e.g. + `linctl issue get https://linear.app/acme/issue/ENG-123/fix-the-thing` + ## [v0.1.12] - 2026-08-23 ### Added diff --git a/README.md b/README.md index d9dc3a0..04705c4 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,22 @@ This improves performance and prevents overwhelming data loads. To see older ite - Need archived matches? Add `--include-archived` when using `issue search`. +## Pasting URLs + +Paste Linear URLs anywhere `linctl` takes an issue, project, team or comment. + +```bash +linctl issue get https://linear.app/acme/issue/ENG-123/fix-the-thing +linctl comment get 'https://linear.app/acme/issue/ENG-123/fix-the-thing#comment-b68a4bf5' +linctl project get https://linear.app/acme/project/roadmap-d05c5c7e8a5c/overview +linctl issue list --team https://linear.app/acme/team/ENG/active +linctl issue get https://github.com/acme/api/pull/6153 +``` + +Quote URLs containing `#`. GitHub PR URLs will work as well, but only if one is +attached to an existing Linear issue. + + ## Quick Start > **IMPORTANT** Agents like Claude Code, Cursor, and Gemini should use the `--json` flag on all read operations. diff --git a/SKILL.md b/SKILL.md index 02c2459..bcb7b12 100644 --- a/SKILL.md +++ b/SKILL.md @@ -14,6 +14,8 @@ Use this skill when the user wants to inspect or modify Linear data through `lin - Before writing, inspect current state first (`get` / `list --json`). - Use command-specific help for exact flags and validation rules: `linctl --help`. - Be explicit with filters; defaults can hide expected results. +- Linear URLs work as entity references. Quote URLs containing `#`. Also GitHub pull + request URLs, but only if it's attached to a Linear issue. ## High-Impact Gotchas diff --git a/cmd/issue.go b/cmd/issue.go index e0d91aa..d1657ad 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 { @@ -970,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 == "" { @@ -979,8 +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 := &projects[i] + if project.ID == normalized || strings.EqualFold(project.Name, normalized) || + project.SlugId == normalized || (project.SlugId != "" && strings.HasSuffix(normalized, "-"+project.SlugId)) { + return project } } @@ -1023,6 +1028,11 @@ func listAllProjects(ctx context.Context, client *api.Client) ([]api.Project, er } func resolveProjectID(ctx context.Context, client *api.Client, projectValue string) (string, error) { + projectValue, err := api.NormalizeProjectRef(projectValue) + if err != nil { + return "", err + } + projects, err := listAllProjects(ctx, client) if err != nil { return "", err 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/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) + } +} diff --git a/pkg/api/queries.go b/pkg/api/queries.go index 076a3ae..339df4e 100644 --- a/pkg/api/queries.go +++ b/pkg/api/queries.go @@ -615,6 +615,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) { @@ -888,7 +893,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 } @@ -898,6 +903,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) { @@ -966,7 +976,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 } @@ -1022,19 +1032,20 @@ func (c *Client) GetProjects(ctx context.Context, filter map[string]interface{}, query := ` query Projects($filter: ProjectFilter, $first: Int, $after: String, $orderBy: PaginationOrderBy) { projects(filter: $filter, first: $first, after: $after, orderBy: $orderBy) { - nodes { + nodes { + id + slugId + name + description + state + status { id name - description - state - status { - id - name - type - color - position - } - progress + type + color + position + } + progress startDate targetDate url @@ -1088,6 +1099,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) { @@ -1242,7 +1258,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 } @@ -1281,6 +1297,11 @@ func (c *Client) GetProjectStatuses(ctx context.Context) ([]ProjectStatus, 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) { @@ -1324,7 +1345,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 } @@ -1403,6 +1424,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) { @@ -1457,7 +1483,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 } @@ -1467,6 +1493,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) { @@ -1485,7 +1516,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 } @@ -1499,6 +1530,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) { @@ -1523,7 +1559,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 } @@ -1533,6 +1569,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) { @@ -1600,7 +1641,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 } @@ -1686,6 +1727,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) { @@ -1707,7 +1753,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 } @@ -1717,6 +1763,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) { @@ -1976,6 +2027,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) { @@ -2005,7 +2061,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 } @@ -2056,6 +2112,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) { @@ -2088,7 +2149,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 } @@ -2204,6 +2265,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) { @@ -2245,7 +2311,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 } @@ -2255,6 +2321,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) { @@ -2288,7 +2359,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 } @@ -2298,6 +2369,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) { @@ -2326,7 +2402,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 } @@ -2336,6 +2412,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) { @@ -2373,7 +2454,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 } @@ -2386,6 +2467,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) { @@ -2404,7 +2490,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 } @@ -2572,6 +2658,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) { @@ -2626,7 +2717,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..7091324 --- /dev/null +++ b/pkg/api/reference.go @@ -0,0 +1,250 @@ +package api + +import ( + "context" + "fmt" + "net/url" + "regexp" + "sort" + "strconv" + "strings" +) + +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 ( + refIssue = "issue" + refProject = "project" + refDocument = "document" + refTeam = "team" + refInitiative = "initiative" + refReview = "review" +) + +type linearRef struct { + kind string + id string + commentID string +} + +// 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 + } + raw = "https://" + raw + } + + parsed, err := url.Parse(raw) + if err != nil { + 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 + } + + parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") + if len(parts) < 3 || parts[0] == "" || parts[2] == "" { + return linearRef{}, false + } + + kind := strings.ToLower(parts[1]) + switch kind { + case refIssue, refProject, refDocument, refTeam, refInitiative, refReview: + default: + return linearRef{}, false + } + + commentID := strings.TrimSpace(parsed.Query().Get("commentId")) + if commentID == "" && strings.HasPrefix(parsed.Fragment, "comment-") { + commentID = strings.TrimPrefix(parsed.Fragment, "comment-") + } + + return linearRef{kind: kind, id: parts[2], commentID: commentID}, true +} + +// NormalizeIssueRef accepts an issue identifier, UUID or Linear issue URL. +func NormalizeIssueRef(raw string) (string, error) { + return normalizeRef(raw, refIssue) +} + +// NormalizeProjectRef accepts a project name, UUID, slug ID or Linear project URL. +func NormalizeProjectRef(raw string) (string, error) { + return normalizeRef(raw, refProject) +} + +// NormalizeTeamRef accepts a team key, UUID or Linear team URL. +func NormalizeTeamRef(raw string) (string, error) { + return normalizeRef(raw, refTeam) +} + +func normalizeRef(raw, want string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", fmt.Errorf("%s reference cannot be empty", want) + } + + ref, ok := parseLinearURL(raw) + if !ok { + return raw, nil + } + 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 want == refIssue && !issueIdentifierPattern.MatchString(ref.id) && !uuidPattern.MatchString(ref.id) { + return "", fmt.Errorf("%s does not contain an issue identifier", raw) + } + return ref.id, nil +} + +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(raw) +} + +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") + } + + ref, ok := parseLinearURL(raw) + if !ok { + return raw, nil + } + if ref.kind != refIssue { + return "", fmt.Errorf("%s is a Linear %s URL, not a comment URL", raw, ref.kind) + } + 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(ref.commentID) { + return ref.commentID, nil + } + return c.findCommentByIDPrefix(ctx, ref.id, ref.commentID) +} + +func (c *Client) findCommentByIDPrefix(ctx context.Context, issueRef, prefix string) (string, error) { + match := "" + 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) || 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.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: 100) { + nodes { + issue { + identifier + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + ` + + var response struct { + AttachmentsForURL struct { + Nodes []struct { + Issue *struct { + 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 != "" { + 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) +} + +func canonicalGitHubPullRequestURL(raw string) (string, bool) { + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme == "" { + 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[0] == "" || parts[1] == "" || parts[2] != "pull" { + return "", false + } + number, err := strconv.ParseUint(parts[3], 10, 64) + if err != nil || number == 0 { + return "", false + } + + 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 new file mode 100644 index 0000000..a384d4e --- /dev/null +++ b/pkg/api/reference_test.go @@ -0,0 +1,204 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestParseLinearURL(t *testing.T) { + tests := []struct { + name string + raw string + want linearRef + ok bool + }{ + {"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 TestNormalizeRefs(t *testing.T) { + tests := []struct { + name string + fn func(string) (string, error) + raw string + want string + wantErr string + }{ + {"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 || got != test.want { + t.Fatalf("got %q, %v; want %q, nil", got, err, test.want) + } + }) + } +} + +func TestGetIssueAcceptsIssueURL(t *testing.T) { + server := graphqlTestServer(t, func(req gqlTestRequest) string { + if req.Variables["id"] != "API-379" { + t.Fatalf("id = %v, want API-379", req.Variables["id"]) + } + return `{"data":{"issue":{"id":"i1","identifier":"API-379"}}}` + }) + defer server.Close() + + 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 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"]) + } + return `{"data":{"project":{"projectMilestones":{"nodes":[],"pageInfo":{"hasNextPage":false}}}}}` + }) + defer server.Close() + + _, err := NewClientWithURL(server.URL, "test").GetProjectMilestones(context.Background(), "https://linear.app/glif/project/roadmap-d05c5c7e8a5c/overview") + if err != nil { + t.Fatalf("GetProjectMilestones() error = %v", err) + } +} + +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"}, + } + + 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 TestResolveCommentRef(t *testing.T) { + tests := []struct { + name string + body string + want string + wantErr string + }{ + {"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 _, 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 err != nil || got != test.want { + t.Fatalf("got %q, %v; want %q, nil", got, err, test.want) + } + }) + } +} + +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) + } +} + +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))) + })) +}