diff --git a/internal/linear/client.go b/internal/linear/client.go index e6ef333..153062e 100644 --- a/internal/linear/client.go +++ b/internal/linear/client.go @@ -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 @@ -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( diff --git a/internal/linear/client_test.go b/internal/linear/client_test.go index 444a522..84ea01f 100644 --- a/internal/linear/client_test.go +++ b/internal/linear/client_test.go @@ -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()) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 067015e..7b08465 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,15 +1,18 @@ package server import ( + "bytes" "context" "encoding/json" "fmt" + "io" "log/slog" "net" "net/http" "strings" "time" + "github.com/jordan/go-symphony/internal/linear" "github.com/jordan/go-symphony/internal/orchestrator" ) @@ -19,18 +22,28 @@ type orchClient interface { TriggerPoll(ctx context.Context) } +// videoAttacher is the subset of the Linear client used for video attachment. +type videoAttacher interface { + FetchIssueIDByIdentifier(ctx context.Context, identifier string) (string, error) + RequestFileUpload(ctx context.Context, filename, contentType string, size int) (*linear.FileUploadInfo, error) + UploadFileToURL(ctx context.Context, info *linear.FileUploadInfo, contentType string, size int64, data io.Reader) error + CreateAttachment(ctx context.Context, issueID, title, url string) error +} + // Server is the optional HTTP observability server. type Server struct { orch orchClient + va videoAttacher // optional; nil disables the attach-video endpoint logger *slog.Logger srv *http.Server addr string } // New creates a new HTTP server. -func New(orch *orchestrator.Orchestrator, port int, logger *slog.Logger) *Server { +func New(orch *orchestrator.Orchestrator, va videoAttacher, port int, logger *slog.Logger) *Server { s := &Server{ orch: orch, + va: va, logger: logger, addr: fmt.Sprintf("127.0.0.1:%d", port), } @@ -40,6 +53,7 @@ func New(orch *orchestrator.Orchestrator, port int, logger *slog.Logger) *Server mux.HandleFunc("GET /api/v1/state", s.handleState) mux.HandleFunc("GET /api/v1/{identifier}", s.handleIssue) mux.HandleFunc("POST /api/v1/refresh", s.handleRefresh) + mux.HandleFunc("POST /api/v1/attach-video/{identifier}", s.handleAttachVideo) s.srv = &http.Server{ Addr: s.addr, @@ -97,6 +111,16 @@ th { background: #16213e; } .running { color: #4ecca3; } .retrying { color: #e94560; } .totals { background: #16213e; padding: 1em; border-radius: 8px; display: inline-block; margin: 1em 0; } +.recorder { background: #16213e; padding: 1em; border-radius: 8px; margin: 1em 0; } +.recorder h2 { margin: 0 0 0.75em; color: #4ecca3; } +.recorder label { display: block; margin-bottom: 0.4em; font-size: 0.9em; color: #aaa; } +.recorder input { background: #0d1117; border: 1px solid #444; color: #e0e0e0; padding: 4px 8px; border-radius: 4px; width: 180px; } +.recorder button { margin: 0.5em 0.4em 0.5em 0; padding: 6px 14px; border: none; border-radius: 4px; cursor: pointer; font-family: monospace; font-size: 0.9em; } +#rec-start { background: #4ecca3; color: #1a1a2e; } +#rec-stop { background: #e94560; color: #fff; } +#rec-download, #rec-attach { background: #0f3460; color: #e0e0e0; } +button:disabled { opacity: 0.4; cursor: not-allowed; } +#rec-status { margin-top: 0.6em; font-size: 0.85em; color: #aaa; min-height: 1.2em; }
@@ -140,7 +164,75 @@ Runtime: %.1fs fmt.Fprintf(w, "\n") } - fmt.Fprintf(w, "Generated at %s