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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions internal/linear/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@ const (
networkTimeout = 30 * time.Second
)

// UploadHeader is a key/value pair required when uploading to a pre-signed URL.
type UploadHeader struct {
Key string `json:"key"`
Value string `json:"value"`
}

// FileUploadInfo holds the result of a Linear file upload request.
type FileUploadInfo struct {
UploadURL string
AssetURL string
Headers []UploadHeader
}

// Client is the Linear GraphQL API client.
type Client struct {
endpoint string
Expand Down Expand Up @@ -410,6 +423,165 @@ func (c *Client) TransitionIssueState(ctx context.Context, issueID, targetStateN
return nil
}

// FetchIssueIDByIdentifier resolves a human-readable issue identifier (e.g. "ZYX-75") to its internal UUID.
func (c *Client) FetchIssueIDByIdentifier(ctx context.Context, identifier string) (string, error) {
query := `query($identifier: String!) {
issues(filter: { identifier: { eq: $identifier } }) {
nodes {
id
identifier
}
}
}`

resp, err := c.doQuery(ctx, query, map[string]any{"identifier": identifier})
if err != nil {
return "", fmt.Errorf("linear_api_request: fetch issue by identifier: %w", err)
}

var result struct {
Data struct {
Issues struct {
Nodes []struct {
ID string `json:"id"`
Identifier string `json:"identifier"`
} `json:"nodes"`
} `json:"issues"`
} `json:"data"`
Errors []graphqlError `json:"errors"`
}
if err := json.Unmarshal(resp, &result); err != nil {
return "", fmt.Errorf("linear_unknown_payload: %w", err)
}
if len(result.Errors) > 0 {
return "", fmt.Errorf("linear_graphql_errors: %s", result.Errors[0].Message)
}
if len(result.Data.Issues.Nodes) == 0 {
return "", fmt.Errorf("linear_issue_not_found: no issue with identifier %q", identifier)
}

return result.Data.Issues.Nodes[0].ID, nil
}

// RequestFileUpload requests a pre-signed upload URL from Linear for a file attachment.
func (c *Client) RequestFileUpload(ctx context.Context, filename, contentType string, size int) (*FileUploadInfo, error) {
mutation := `mutation($contentType: String!, $filename: String!, $size: Int!) {
fileUpload(contentType: $contentType, filename: $filename, size: $size) {
uploadFile {
uploadUrl
assetUrl
headers {
key
value
}
}
}
}`

resp, err := c.doQuery(ctx, mutation, map[string]any{
"contentType": contentType,
"filename": filename,
"size": size,
})
if err != nil {
return nil, fmt.Errorf("linear_api_request: file upload: %w", err)
}

var result struct {
Data struct {
FileUpload struct {
UploadFile struct {
UploadURL string `json:"uploadUrl"`
AssetURL string `json:"assetUrl"`
Headers []UploadHeader `json:"headers"`
} `json:"uploadFile"`
} `json:"fileUpload"`
} `json:"data"`
Errors []graphqlError `json:"errors"`
}
if err := json.Unmarshal(resp, &result); err != nil {
return nil, fmt.Errorf("linear_unknown_payload: %w", err)
}
if len(result.Errors) > 0 {
return nil, fmt.Errorf("linear_graphql_errors: %s", result.Errors[0].Message)
}

f := result.Data.FileUpload.UploadFile
return &FileUploadInfo{
UploadURL: f.UploadURL,
AssetURL: f.AssetURL,
Headers: f.Headers,
}, nil
}

// UploadFileToURL uploads data to a pre-signed URL returned by RequestFileUpload.
func (c *Client) UploadFileToURL(ctx context.Context, info *FileUploadInfo, contentType string, size int64, data io.Reader) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, info.UploadURL, data)
if err != nil {
return fmt.Errorf("linear_file_upload: create request: %w", err)
}
req.ContentLength = size
req.Header.Set("Content-Type", contentType)
for _, h := range info.Headers {
req.Header.Set(h.Key, h.Value)
}

resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("linear_file_upload: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("linear_file_upload_status: %d: %s", resp.StatusCode, string(body))
}

return nil
}

// CreateAttachment creates an attachment on a Linear issue linking to the given URL.
func (c *Client) CreateAttachment(ctx context.Context, issueID, title, url string) error {
mutation := `mutation($issueId: String!, $title: String!, $url: String!) {
attachmentCreate(input: { issueId: $issueId, title: $title, url: $url }) {
success
attachment {
id
url
}
}
}`

resp, err := c.doQuery(ctx, mutation, map[string]any{
"issueId": issueID,
"title": title,
"url": url,
})
if err != nil {
return fmt.Errorf("linear_api_request: create attachment: %w", err)
}

var result struct {
Data struct {
AttachmentCreate struct {
Success bool `json:"success"`
} `json:"attachmentCreate"`
} `json:"data"`
Errors []graphqlError `json:"errors"`
}
if err := json.Unmarshal(resp, &result); err != nil {
return fmt.Errorf("linear_unknown_payload: %w", err)
}
if len(result.Errors) > 0 {
return fmt.Errorf("linear_graphql_errors: %s", result.Errors[0].Message)
}
if !result.Data.AttachmentCreate.Success {
return fmt.Errorf("linear_attachment_failed: attachmentCreate returned success=false")
}

return nil
}

func (c *Client) fetchIssuePage(ctx context.Context, activeStates []string, cursor *string) ([]model.Issue, *string, error) {
query := `query($projectSlug: String!, $stateNames: [String!]!, $first: Int!, $after: String) {
issues(
Expand Down
192 changes: 192 additions & 0 deletions internal/linear/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,3 +258,195 @@ func TestCreateIssue_SuccessFalse(t *testing.T) {
t.Errorf("expected %q, got %q", want, err.Error())
}
}

func TestFetchIssueIDByIdentifier_Success(t *testing.T) {
called := false
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
called = true
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode request: %v", err)
}
vars := req["variables"].(map[string]any)
if vars["identifier"] != "ZYX-75" {
t.Errorf("expected identifier=ZYX-75, got %v", vars["identifier"])
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"issues": map[string]any{
"nodes": []map[string]any{
{"id": "uuid-abc", "identifier": "ZYX-75"},
},
},
},
})
})

id, err := client.FetchIssueIDByIdentifier(context.Background(), "ZYX-75")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if id != "uuid-abc" {
t.Errorf("expected uuid-abc, got %s", id)
}
if !called {
t.Fatal("handler was not called")
}
}

func TestFetchIssueIDByIdentifier_NotFound(t *testing.T) {
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"issues": map[string]any{"nodes": []any{}},
},
})
})

_, err := client.FetchIssueIDByIdentifier(context.Background(), "ZYX-99")
if err == nil {
t.Fatal("expected error, got nil")
}
if want := `linear_issue_not_found: no issue with identifier "ZYX-99"`; err.Error() != want {
t.Errorf("expected %q, got %q", want, err.Error())
}
}

func TestRequestFileUpload_Success(t *testing.T) {
called := false
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
called = true
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode request: %v", err)
}
vars := req["variables"].(map[string]any)
if vars["filename"] != "recording.webm" {
t.Errorf("expected filename=recording.webm, got %v", vars["filename"])
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"fileUpload": map[string]any{
"uploadFile": map[string]any{
"uploadUrl": "https://s3.example.com/upload",
"assetUrl": "https://cdn.example.com/asset.webm",
"headers": []map[string]any{
{"key": "x-amz-acl", "value": "public-read"},
},
},
},
},
})
})

info, err := client.RequestFileUpload(context.Background(), "recording.webm", "video/webm", 1024)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if info.UploadURL != "https://s3.example.com/upload" {
t.Errorf("unexpected UploadURL: %s", info.UploadURL)
}
if info.AssetURL != "https://cdn.example.com/asset.webm" {
t.Errorf("unexpected AssetURL: %s", info.AssetURL)
}
if len(info.Headers) != 1 || info.Headers[0].Key != "x-amz-acl" {
t.Errorf("unexpected headers: %+v", info.Headers)
}
if !called {
t.Fatal("handler was not called")
}
}

func TestRequestFileUpload_GraphQLError(t *testing.T) {
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"errors": []map[string]any{{"message": "unauthorized"}},
})
})

_, err := client.RequestFileUpload(context.Background(), "rec.webm", "video/webm", 512)
if err == nil {
t.Fatal("expected error, got nil")
}
if want := "linear_graphql_errors: unauthorized"; err.Error() != want {
t.Errorf("expected %q, got %q", want, err.Error())
}
}

func TestCreateAttachment_Success(t *testing.T) {
called := false
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
called = true
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode request: %v", err)
}
vars := req["variables"].(map[string]any)
if vars["issueId"] != "issue-123" {
t.Errorf("expected issueId=issue-123, got %v", vars["issueId"])
}
if vars["url"] != "https://cdn.example.com/rec.webm" {
t.Errorf("expected url=..., got %v", vars["url"])
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"attachmentCreate": map[string]any{
"success": true,
"attachment": map[string]any{
"id": "att-1",
"url": "https://cdn.example.com/rec.webm",
},
},
},
})
})

err := client.CreateAttachment(context.Background(), "issue-123", "Screen recording", "https://cdn.example.com/rec.webm")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !called {
t.Fatal("handler was not called")
}
}

func TestCreateAttachment_SuccessFalse(t *testing.T) {
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"attachmentCreate": map[string]any{"success": false},
},
})
})

err := client.CreateAttachment(context.Background(), "issue-123", "rec", "https://example.com/v.webm")
if err == nil {
t.Fatal("expected error, got nil")
}
if want := "linear_attachment_failed: attachmentCreate returned success=false"; err.Error() != want {
t.Errorf("expected %q, got %q", want, err.Error())
}
}

func TestCreateAttachment_GraphQLError(t *testing.T) {
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"errors": []map[string]any{{"message": "not found"}},
})
})

err := client.CreateAttachment(context.Background(), "issue-123", "rec", "https://example.com/v.webm")
if err == nil {
t.Fatal("expected error, got nil")
}
if want := "linear_graphql_errors: not found"; err.Error() != want {
t.Errorf("expected %q, got %q", want, err.Error())
}
}
Loading
Loading