diff --git a/AGENTS.md b/AGENTS.md index ee9b2877b1..bc2f80514e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,6 +87,8 @@ apps/backend/ │ ├── gateway/ # WebSocket gateway │ ├── github/ # GitHub API integration (PRs, reviews, webhooks) │ ├── integration/ # External integrations +│ ├── jira/ # Jira/Atlassian Cloud integration (config, REST client, poller) +│ ├── linear/ # Linear integration (config, GraphQL client, poller) │ ├── lsp/ # LSP server │ ├── mcp/ # MCP protocol support │ ├── health/ # Health check endpoints diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index ab43d72037..dffc8bf0b3 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -37,6 +37,7 @@ import ( "github.com/kandev/kandev/internal/github" "github.com/kandev/kandev/internal/health" "github.com/kandev/kandev/internal/jira" + "github.com/kandev/kandev/internal/linear" mcphandlers "github.com/kandev/kandev/internal/mcp/handlers" mcpserver "github.com/kandev/kandev/internal/mcp/server" notificationcontroller "github.com/kandev/kandev/internal/notifications/controller" @@ -481,6 +482,11 @@ func registerSecondaryRoutes( p.log.Debug("Registered JIRA handlers (HTTP + WebSocket)") } + if p.services.Linear != nil { + linear.RegisterRoutes(p.router, p.gateway.Dispatcher, p.services.Linear, p.log) + p.log.Debug("Registered Linear handlers (HTTP + WebSocket)") + } + docker.RegisterDockerRoutes(p.router, p.lifecycleMgr.DockerClientProvider(), p.log) p.log.Debug("Registered Docker management handlers (HTTP)") diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 74f5a6a1b8..f863fa620c 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -31,6 +31,7 @@ import ( // JIRA integration jirapkg "github.com/kandev/kandev/internal/jira" + linearpkg "github.com/kandev/kandev/internal/linear" // Agent infrastructure "github.com/kandev/kandev/internal/agent/hostutility" @@ -341,6 +342,13 @@ func startAgentInfrastructure( addCleanup(func() error { jiraPoller.Stop(); return nil }) } + // Start Linear auth-health poller, mirroring the Jira poller. + if services.Linear != nil { + linearPoller := linearpkg.NewPoller(services.Linear, log) + linearPoller.Start(ctx) + addCleanup(func() error { linearPoller.Stop(); return nil }) + } + return startGatewayAndServe(ctx, cfg, log, eventBus, repos, services, agentSettingsController, lifecycleMgr, agentRegistry, orchestratorSvc, msgCreator, addCleanup, runCleanups) } diff --git a/apps/backend/cmd/kandev/services.go b/apps/backend/cmd/kandev/services.go index 2c14cb19dd..0805f8584f 100644 --- a/apps/backend/cmd/kandev/services.go +++ b/apps/backend/cmd/kandev/services.go @@ -16,6 +16,7 @@ import ( "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/github" "github.com/kandev/kandev/internal/jira" + "github.com/kandev/kandev/internal/linear" promptservice "github.com/kandev/kandev/internal/prompts/service" "github.com/kandev/kandev/internal/secrets" taskmodels "github.com/kandev/kandev/internal/task/models" @@ -101,6 +102,13 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories log.Warn("JIRA service initialization failed (non-fatal)", zap.Error(jiraErr)) } + // Initialize Linear service + linearSecrets := &linearSecretAdapter{store: repos.Secrets} + linearSvc, _, linearErr := linear.Provide(dbPool.Writer(), dbPool.Reader(), linearSecrets, log) + if linearErr != nil { + log.Warn("Linear service initialization failed (non-fatal)", zap.Error(linearErr)) + } + return &Services{ Task: taskSvc, User: userSvc, @@ -110,6 +118,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories Workflow: workflowSvc, GitHub: githubSvc, Jira: jiraSvc, + Linear: linearSvc, // Notification service is initialized after gateway is available. Notification: nil, }, agentSettingsController, nil @@ -274,6 +283,46 @@ func (a *jiraSecretAdapter) Exists(ctx context.Context, id string) (bool, error) return true, nil } +// linearSecretAdapter adapts secrets.SecretStore to linear.SecretStore. Linear +// stores its API key keyed by "linear:{workspaceID}:token". The adapter mirrors +// the upsert-onto-create/update translation that jiraSecretAdapter does. +type linearSecretAdapter struct { + store secrets.SecretStore +} + +func (a *linearSecretAdapter) Reveal(ctx context.Context, id string) (string, error) { + return a.store.Reveal(ctx, id) +} + +func (a *linearSecretAdapter) Set(ctx context.Context, id, name, value string) error { + exists, err := a.Exists(ctx, id) + if err != nil { + return err + } + if exists { + return a.store.Update(ctx, id, &secrets.UpdateSecretRequest{Value: &value}) + } + return a.store.Create(ctx, &secrets.SecretWithValue{ + Secret: secrets.Secret{ID: id, Name: name}, + Value: value, + }) +} + +func (a *linearSecretAdapter) Delete(ctx context.Context, id string) error { + return a.store.Delete(ctx, id) +} + +func (a *linearSecretAdapter) Exists(ctx context.Context, id string) (bool, error) { + _, err := a.store.Get(ctx, id) + if err != nil { + if strings.HasPrefix(err.Error(), "secret not found:") { + return false, nil + } + return false, err + } + return true, nil +} + // workflowProviderAdapter adapts task service to workflow service's WorkflowProvider interface. type workflowProviderAdapter struct { svc *taskservice.Service diff --git a/apps/backend/cmd/kandev/types.go b/apps/backend/cmd/kandev/types.go index 42fff0c4c0..fee624d0a0 100644 --- a/apps/backend/cmd/kandev/types.go +++ b/apps/backend/cmd/kandev/types.go @@ -7,6 +7,7 @@ import ( editorstore "github.com/kandev/kandev/internal/editors/store" "github.com/kandev/kandev/internal/github" "github.com/kandev/kandev/internal/jira" + "github.com/kandev/kandev/internal/linear" notificationservice "github.com/kandev/kandev/internal/notifications/service" notificationstore "github.com/kandev/kandev/internal/notifications/store" promptservice "github.com/kandev/kandev/internal/prompts/service" @@ -45,4 +46,5 @@ type Services struct { Workflow *workflowservice.Service GitHub *github.Service Jira *jira.Service + Linear *linear.Service } diff --git a/apps/backend/internal/linear/client.go b/apps/backend/internal/linear/client.go new file mode 100644 index 0000000000..4c26ba500e --- /dev/null +++ b/apps/backend/internal/linear/client.go @@ -0,0 +1,33 @@ +package linear + +import ( + "context" + "errors" + "fmt" +) + +// ErrNotConfigured is returned when a Linear operation is attempted without a +// workspace configuration. +var ErrNotConfigured = errors.New("linear: workspace not configured") + +// Client is the minimal interface the service needs from a Linear backend. The +// real implementation is GraphQLClient; tests can substitute a fake. +type Client interface { + TestAuth(ctx context.Context) (*TestConnectionResult, error) + GetIssue(ctx context.Context, identifier string) (*LinearIssue, error) + ListStates(ctx context.Context, teamKey string) ([]LinearWorkflowState, error) + SetIssueState(ctx context.Context, issueID, stateID string) error + ListTeams(ctx context.Context) ([]LinearTeam, error) + SearchIssues(ctx context.Context, filter SearchFilter, pageToken string, maxResults int) (*SearchResult, error) +} + +// APIError captures an upstream non-2xx (or GraphQL-error) response so handlers +// can surface a meaningful status to the UI. +type APIError struct { + StatusCode int + Message string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("linear api: status %d: %s", e.StatusCode, e.Message) +} diff --git a/apps/backend/internal/linear/graphql_client.go b/apps/backend/internal/linear/graphql_client.go new file mode 100644 index 0000000000..b5837da8be --- /dev/null +++ b/apps/backend/internal/linear/graphql_client.go @@ -0,0 +1,535 @@ +package linear + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net/http" + "strings" + "time" +) + +// LinearAPIEndpoint is the single GraphQL endpoint exposed by Linear. There is +// no per-organization host: auth scopes the request to the right workspace. +const LinearAPIEndpoint = "https://api.linear.app/graphql" + +// GraphQLClient talks to Linear's GraphQL API using a Personal API Key. The +// client holds no state beyond the credential so it can be recreated cheaply +// per workspace if config changes. +type GraphQLClient struct { + http *http.Client + endpoint string + apiKey string + maxBodySize int64 +} + +// NewGraphQLClient builds a client from a LinearConfig + secret. +func NewGraphQLClient(_ *LinearConfig, secret string) *GraphQLClient { + return &GraphQLClient{ + http: &http.Client{ + Timeout: 30 * time.Second, + }, + endpoint: LinearAPIEndpoint, + apiKey: secret, + maxBodySize: 4 << 20, // 4 MB — Linear payloads are small by design. + } +} + +const userAgent = "kandev/1.0 (+https://github.com/kdlbs/kandev)" + +// graphqlRequest is the standard {query, variables} envelope. +type graphqlRequest struct { + Query string `json:"query"` + Variables map[string]interface{} `json:"variables,omitempty"` +} + +// graphqlError captures one entry from the GraphQL `errors` array. +type graphqlError struct { + Message string `json:"message"` + Path []interface{} `json:"path,omitempty"` + Extensions map[string]interface{} `json:"extensions,omitempty"` +} + +// graphqlResponse is the raw envelope; `Data` is decoded into the caller's +// struct via a second pass to avoid generics. +type graphqlResponse struct { + Data json.RawMessage `json:"data"` + Errors []graphqlError `json:"errors,omitempty"` +} + +// do executes a GraphQL operation and decodes `data` into out (may be nil). +// Non-2xx responses and GraphQL-level errors both surface as *APIError so +// callers can switch on status. Linear returns 4xx for auth failures and 200 +// with errors[] for everything else. +func (c *GraphQLClient) do(ctx context.Context, query string, variables map[string]interface{}, out interface{}) error { + body, err := json.Marshal(graphqlRequest{Query: query, Variables: variables}) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", userAgent) + // Linear accepts the API key directly as the Authorization header value. + req.Header.Set("Authorization", c.apiKey) + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + raw, err := io.ReadAll(io.LimitReader(resp.Body, c.maxBodySize)) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return &APIError{StatusCode: resp.StatusCode, Message: summarizeBody(raw)} + } + var env graphqlResponse + if err := json.Unmarshal(raw, &env); err != nil { + return &APIError{StatusCode: resp.StatusCode, Message: "invalid GraphQL response: " + err.Error()} + } + if len(env.Errors) > 0 { + return &APIError{StatusCode: graphqlErrorStatus(env.Errors), Message: joinGraphQLErrors(env.Errors)} + } + if out == nil || len(env.Data) == 0 { + return nil + } + return json.Unmarshal(env.Data, out) +} + +// graphqlErrorStatus maps a GraphQL error envelope to an HTTP-like status. Auth +// failures get 401 so the UI can surface them as "credentials invalid"; +// everything else falls back to 400 (request-level failure rather than server). +func graphqlErrorStatus(errs []graphqlError) int { + for _, e := range errs { + if e.Extensions == nil { + continue + } + if t, ok := e.Extensions["type"].(string); ok { + switch strings.ToLower(t) { + case "authentication error", "authentication", "unauthenticated": + return http.StatusUnauthorized + case "feature not access", "permission", "forbidden": + return http.StatusForbidden + case "invalid input": + return http.StatusBadRequest + } + } + } + return http.StatusBadRequest +} + +func joinGraphQLErrors(errs []graphqlError) string { + if len(errs) == 1 { + return errs[0].Message + } + parts := make([]string, 0, len(errs)) + for _, e := range errs { + parts = append(parts, e.Message) + } + return strings.Join(parts, "; ") +} + +// summarizeBody trims an error body to a useful length for log/error messages. +func summarizeBody(raw []byte) string { + const maxMsg = 500 + s := strings.TrimSpace(string(raw)) + if len(s) > maxMsg { + return s[:maxMsg] + "…" + } + if s == "" { + return "(empty body)" + } + return s +} + +// --- viewer / TestAuth --- + +const viewerQuery = ` +query Viewer { + viewer { + id + name + displayName + email + } + organization { + urlKey + name + } +}` + +type viewerData struct { + Viewer struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"displayName"` + Email string `json:"email"` + } `json:"viewer"` + Organization struct { + URLKey string `json:"urlKey"` + Name string `json:"name"` + } `json:"organization"` +} + +// TestAuth hits the viewer query — the cheapest authenticated query Linear +// exposes — and returns a structured result so the UI can render a meaningful +// failure inline. +func (c *GraphQLClient) TestAuth(ctx context.Context) (*TestConnectionResult, error) { + var data viewerData + if err := c.do(ctx, viewerQuery, nil, &data); err != nil { + var apiErr *APIError + if errors.As(err, &apiErr) { + return &TestConnectionResult{OK: false, Error: apiErr.Error()}, nil + } + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + name := data.Viewer.DisplayName + if name == "" { + name = data.Viewer.Name + } + return &TestConnectionResult{ + OK: true, + UserID: data.Viewer.ID, + DisplayName: name, + Email: data.Viewer.Email, + OrgSlug: data.Organization.URLKey, + OrgName: data.Organization.Name, + }, nil +} + +// --- teams --- + +const teamsQuery = ` +query Teams { + teams(first: 100, orderBy: updatedAt) { + nodes { id key name } + } +}` + +type teamsData struct { + Teams struct { + Nodes []struct { + ID string `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + } `json:"nodes"` + } `json:"teams"` +} + +// ListTeams returns up to 100 teams (Linear's max page size). Fine for the +// settings dropdown; pagination can be added later if needed. +func (c *GraphQLClient) ListTeams(ctx context.Context) ([]LinearTeam, error) { + var data teamsData + if err := c.do(ctx, teamsQuery, nil, &data); err != nil { + return nil, err + } + out := make([]LinearTeam, 0, len(data.Teams.Nodes)) + for _, t := range data.Teams.Nodes { + out = append(out, LinearTeam{ID: t.ID, Key: t.Key, Name: t.Name}) + } + return out, nil +} + +// --- workflow states --- + +const teamStatesQuery = ` +query TeamStates($filter: WorkflowStateFilter!) { + workflowStates(first: 100, filter: $filter) { + nodes { id name type color position } + } +}` + +type statesData struct { + WorkflowStates struct { + Nodes []struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Color string `json:"color"` + Position float64 `json:"position"` + } `json:"nodes"` + } `json:"workflowStates"` +} + +// ListStates returns the workflow states for a team identified by its key. +// Equivalent of Jira's "ListTransitions" but unconditional — the same set of +// states is available regardless of the issue's current state. +func (c *GraphQLClient) ListStates(ctx context.Context, teamKey string) ([]LinearWorkflowState, error) { + if teamKey == "" { + return nil, fmt.Errorf("teamKey required") + } + vars := map[string]interface{}{ + "filter": map[string]interface{}{ + "team": map[string]interface{}{"key": map[string]interface{}{"eq": teamKey}}, + }, + } + var data statesData + if err := c.do(ctx, teamStatesQuery, vars, &data); err != nil { + return nil, err + } + out := make([]LinearWorkflowState, 0, len(data.WorkflowStates.Nodes)) + for _, s := range data.WorkflowStates.Nodes { + out = append(out, LinearWorkflowState{ + ID: s.ID, + Name: s.Name, + Type: s.Type, + // Linear uses fractional indexing for state ordering — round + // rather than truncate so adjacent fractional positions don't + // collapse onto the same int. + Color: s.Color, + Position: int(math.Round(s.Position)), + }) + } + return out, nil +} + +// --- issues --- + +// issueFragment is the projection used by both single-issue fetches and search +// results. Centralised so the wire schema stays consistent. +const issueFragment = ` + id + identifier + title + description + url + updatedAt + priority + priorityLabel + state { id name type color } + team { id key } + assignee { name email avatarUrl } + creator { name avatarUrl } +` + +type issueNode struct { + ID string `json:"id"` + Identifier string `json:"identifier"` + Title string `json:"title"` + Description string `json:"description"` + URL string `json:"url"` + UpdatedAt string `json:"updatedAt"` + Priority int `json:"priority"` + PriorityLab string `json:"priorityLabel"` + State struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Color string `json:"color"` + } `json:"state"` + Team struct { + ID string `json:"id"` + Key string `json:"key"` + } `json:"team"` + Assignee *struct { + Name string `json:"name"` + Email string `json:"email"` + AvatarURL string `json:"avatarUrl"` + } `json:"assignee"` + Creator *struct { + Name string `json:"name"` + AvatarURL string `json:"avatarUrl"` + } `json:"creator"` +} + +func issueNodeToIssue(n *issueNode) LinearIssue { + out := LinearIssue{ + ID: n.ID, + Identifier: n.Identifier, + Title: n.Title, + Description: n.Description, + StateID: n.State.ID, + StateName: n.State.Name, + StateType: n.State.Type, + StateCategory: stateCategory(n.State.Type), + TeamID: n.Team.ID, + TeamKey: n.Team.Key, + Priority: n.Priority, + PriorityLabel: n.PriorityLab, + Updated: n.UpdatedAt, + URL: n.URL, + } + if n.Assignee != nil { + out.AssigneeName = n.Assignee.Name + out.AssigneeEmail = n.Assignee.Email + out.AssigneeIcon = n.Assignee.AvatarURL + } + if n.Creator != nil { + out.CreatorName = n.Creator.Name + out.CreatorIcon = n.Creator.AvatarURL + } + return out +} + +// stateCategory maps Linear's state type onto Jira's three-bucket category so +// the frontend can style status pills uniformly across integrations. +func stateCategory(stateType string) string { + switch strings.ToLower(stateType) { + case "backlog", "unstarted", "triage": + return "new" + case "started": + return "indeterminate" + case "completed", "canceled": + return "done" + default: + return "new" + } +} + +const issueByIDQuery = ` +query IssueByID($id: String!) { + issue(id: $id) {` + issueFragment + `} +}` + +// GetIssue loads a Linear issue by identifier (e.g. "ENG-123") and attaches +// the team's available workflow states. Linear's `issue(id)` field accepts +// either the UUID or the human identifier. +func (c *GraphQLClient) GetIssue(ctx context.Context, identifier string) (*LinearIssue, error) { + var data struct { + Issue *issueNode `json:"issue"` + } + if err := c.do(ctx, issueByIDQuery, map[string]interface{}{"id": identifier}, &data); err != nil { + return nil, err + } + if data.Issue == nil { + return nil, &APIError{StatusCode: http.StatusNotFound, Message: "issue not found: " + identifier} + } + issue := issueNodeToIssue(data.Issue) + states, err := c.ListStates(ctx, issue.TeamKey) + if err != nil { + // Caller cancellation / deadline must propagate — returning a + // partial-success response after the request was abandoned would + // break downstream correctness. + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return nil, err + } + var apiErr *APIError + if errors.As(err, &apiErr) && apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 { + return nil, err + } + // Network blip or 5xx: keep the issue and let the UI render without + // the state selector. The user can still read the issue and refresh. + } else { + issue.States = states + } + return &issue, nil +} + +// --- mutations --- + +const setStateMutation = ` +mutation SetIssueState($id: String!, $stateId: String!) { + issueUpdate(id: $id, input: { stateId: $stateId }) { + success + } +}` + +type setStateData struct { + IssueUpdate struct { + Success bool `json:"success"` + } `json:"issueUpdate"` +} + +// SetIssueState moves an issue to a new workflow state. issueID may be either +// the UUID or the human identifier — Linear accepts both. Mirrors Jira's +// DoTransition. +func (c *GraphQLClient) SetIssueState(ctx context.Context, issueID, stateID string) error { + var data setStateData + vars := map[string]interface{}{"id": issueID, "stateId": stateID} + if err := c.do(ctx, setStateMutation, vars, &data); err != nil { + return err + } + if !data.IssueUpdate.Success { + return &APIError{StatusCode: http.StatusInternalServerError, Message: "issueUpdate returned success=false"} + } + return nil +} + +// --- search --- + +const searchIssuesQuery = ` +query SearchIssues($filter: IssueFilter, $first: Int!, $after: String) { + issues(filter: $filter, first: $first, after: $after, orderBy: updatedAt) { + nodes {` + issueFragment + `} + pageInfo { hasNextPage endCursor } + } +}` + +type searchIssuesData struct { + Issues struct { + Nodes []issueNode `json:"nodes"` + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + EndCursor string `json:"endCursor"` + } `json:"pageInfo"` + } `json:"issues"` +} + +// SearchIssues runs a filtered search and returns a page of issues. pageToken +// is the `endCursor` returned in the previous page; pass "" for the first +// page. maxResults is capped at 100 (Linear's max). +func (c *GraphQLClient) SearchIssues(ctx context.Context, filter SearchFilter, pageToken string, maxResults int) (*SearchResult, error) { + if maxResults <= 0 { + maxResults = 25 + } + if maxResults > 100 { + maxResults = 100 + } + gqlFilter := buildIssueFilter(filter) + vars := map[string]interface{}{"filter": gqlFilter, "first": maxResults} + if pageToken != "" { + vars["after"] = pageToken + } + var data searchIssuesData + if err := c.do(ctx, searchIssuesQuery, vars, &data); err != nil { + return nil, err + } + out := &SearchResult{ + MaxResults: maxResults, + IsLast: !data.Issues.PageInfo.HasNextPage, + NextPageToken: data.Issues.PageInfo.EndCursor, + Issues: make([]LinearIssue, 0, len(data.Issues.Nodes)), + } + for i := range data.Issues.Nodes { + out.Issues = append(out.Issues, issueNodeToIssue(&data.Issues.Nodes[i])) + } + return out, nil +} + +// buildIssueFilter translates our SearchFilter into Linear's IssueFilter input. +// Empty fields are dropped so we don't send `{ "team": null }` and similar +// (which Linear treats as a typed null and rejects). +func buildIssueFilter(f SearchFilter) map[string]interface{} { + out := map[string]interface{}{} + if q := strings.TrimSpace(f.Query); q != "" { + // Linear has no top-level free-text field, but `searchableContent` + // matches across title and description. + out["searchableContent"] = map[string]interface{}{"contains": q} + } + if f.TeamKey != "" { + out["team"] = map[string]interface{}{"key": map[string]interface{}{"eq": f.TeamKey}} + } + if len(f.StateIDs) > 0 { + out["state"] = map[string]interface{}{"id": map[string]interface{}{"in": f.StateIDs}} + } + switch f.Assigned { + case "me": + // `null: false` + assignee filter would require the viewer ID; instead + // we use the `viewer` shortcut Linear exposes via `assignee.isMe`. + out["assignee"] = map[string]interface{}{"isMe": map[string]interface{}{"eq": true}} + case "unassigned": + out["assignee"] = map[string]interface{}{"null": true} + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/apps/backend/internal/linear/graphql_client_test.go b/apps/backend/internal/linear/graphql_client_test.go new file mode 100644 index 0000000000..58e37cba0b --- /dev/null +++ b/apps/backend/internal/linear/graphql_client_test.go @@ -0,0 +1,256 @@ +package linear + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func newMockServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + s := httptest.NewServer(handler) + t.Cleanup(s.Close) + return s +} + +// pointTo overrides the GraphQL endpoint on a freshly-built client so tests +// hit the test server without needing a mockable URL on the production +// constructor. +func pointTo(c *GraphQLClient, url string) *GraphQLClient { + c.endpoint = url + return c +} + +// readReq drains a request body into a generic JSON map so tests can assert on +// the GraphQL query+variables payload. +func readReq(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + var m map[string]interface{} + if err := json.Unmarshal(body, &m); err != nil { + t.Fatalf("unmarshal request: %v", err) + } + return m +} + +func TestGraphQLClient_AuthHeader(t *testing.T) { + var gotAuth string + ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"viewer":{"id":"u","name":"Alice","displayName":"Alice","email":"a@x"},"organization":{"urlKey":"acme","name":"Acme"}}}`)) + }) + c := pointTo(NewGraphQLClient(&LinearConfig{}, "lin_api_xyz"), ts.URL) + res, err := c.TestAuth(context.Background()) + if err != nil { + t.Fatalf("test auth: %v", err) + } + if !res.OK { + t.Fatalf("expected OK=true, got %+v", res) + } + if gotAuth != "lin_api_xyz" { + t.Errorf("auth header = %q, want bare API key", gotAuth) + } + if res.OrgSlug != "acme" { + t.Errorf("expected orgSlug=acme, got %q", res.OrgSlug) + } + if res.DisplayName != "Alice" { + t.Errorf("expected displayName=Alice, got %q", res.DisplayName) + } +} + +func TestGraphQLClient_TestAuth_BadCreds_ReportsErrorInResult(t *testing.T) { + ts := newMockServer(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"invalid api key"}`)) + }) + c := pointTo(NewGraphQLClient(&LinearConfig{}, "bad"), ts.URL) + res, err := c.TestAuth(context.Background()) + if err != nil { + t.Fatalf("test auth should not error on 401, got %v", err) + } + if res.OK { + t.Errorf("expected OK=false, got %+v", res) + } + if !strings.Contains(res.Error, "401") { + t.Errorf("expected 401 in error, got %q", res.Error) + } +} + +func TestGraphQLClient_TestAuth_GraphQLErrors(t *testing.T) { + ts := newMockServer(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"errors":[{"message":"Authentication required","extensions":{"type":"AUTHENTICATION_ERROR"}}]}`)) + }) + c := pointTo(NewGraphQLClient(&LinearConfig{}, "x"), ts.URL) + res, err := c.TestAuth(context.Background()) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if res.OK { + t.Errorf("expected OK=false on GraphQL error") + } + if !strings.Contains(res.Error, "Authentication required") { + t.Errorf("expected GraphQL error message preserved, got %q", res.Error) + } +} + +func TestGraphQLClient_ListTeams(t *testing.T) { + ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + body := readReq(t, r) + if !strings.Contains(body["query"].(string), "teams(") { + t.Errorf("expected teams query, got %q", body["query"]) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"teams":{"nodes":[{"id":"t1","key":"ENG","name":"Engineering"}]}}}`)) + }) + c := pointTo(NewGraphQLClient(&LinearConfig{}, "x"), ts.URL) + teams, err := c.ListTeams(context.Background()) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(teams) != 1 || teams[0].Key != "ENG" { + t.Errorf("teams = %+v", teams) + } +} + +func TestGraphQLClient_ListStates_RequiresTeamKey(t *testing.T) { + c := pointTo(NewGraphQLClient(&LinearConfig{}, "x"), "http://nope") + if _, err := c.ListStates(context.Background(), ""); err == nil { + t.Error("expected error for empty team key") + } +} + +func TestGraphQLClient_GetIssue_AttachesStates(t *testing.T) { + calls := 0 + ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + body := readReq(t, r) + q, _ := body["query"].(string) + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(q, "issue(id:"): + calls++ + _, _ = w.Write([]byte(`{"data":{"issue":{"id":"i1","identifier":"ENG-1","title":"Hi","description":"body","url":"https://linear.app/acme/issue/ENG-1","priority":2,"priorityLabel":"High","state":{"id":"s1","name":"In Progress","type":"started","color":"#fff"},"team":{"id":"t1","key":"ENG"},"assignee":null,"creator":null}}}`)) + case strings.Contains(q, "workflowStates"): + calls++ + _, _ = w.Write([]byte(`{"data":{"workflowStates":{"nodes":[{"id":"s1","name":"In Progress","type":"started","color":"#fff","position":2}]}}}`)) + default: + t.Errorf("unexpected query: %q", q) + } + }) + c := pointTo(NewGraphQLClient(&LinearConfig{}, "x"), ts.URL) + issue, err := c.GetIssue(context.Background(), "ENG-1") + if err != nil { + t.Fatalf("get issue: %v", err) + } + if issue.Identifier != "ENG-1" || issue.StateName != "In Progress" { + t.Errorf("issue mismatch: %+v", issue) + } + if issue.StateCategory != "indeterminate" { + t.Errorf("expected category=indeterminate for started, got %q", issue.StateCategory) + } + if len(issue.States) != 1 { + t.Errorf("expected 1 state attached, got %d", len(issue.States)) + } + if calls != 2 { + t.Errorf("expected 2 GraphQL calls (issue + states), got %d", calls) + } +} + +func TestGraphQLClient_SearchIssues_PaginationCursor(t *testing.T) { + ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + body := readReq(t, r) + vars, _ := body["variables"].(map[string]interface{}) + if got, _ := vars["after"].(string); got != "cursor-1" { + t.Errorf("expected after=cursor-1, got %v", vars["after"]) + } + if got, _ := vars["first"].(float64); got != 25 { + t.Errorf("expected first=25, got %v", vars["first"]) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"issues":{"nodes":[{"id":"i1","identifier":"ENG-1","title":"a","description":"","url":"u","priority":0,"priorityLabel":"None","state":{"id":"s","name":"S","type":"backlog","color":""},"team":{"id":"t","key":"ENG"}}],"pageInfo":{"hasNextPage":true,"endCursor":"cursor-2"}}}}`)) + }) + c := pointTo(NewGraphQLClient(&LinearConfig{}, "x"), ts.URL) + res, err := c.SearchIssues(context.Background(), SearchFilter{Query: "auth"}, "cursor-1", 0) + if err != nil { + t.Fatalf("search: %v", err) + } + if res.IsLast { + t.Error("expected IsLast=false when hasNextPage=true") + } + if res.NextPageToken != "cursor-2" { + t.Errorf("expected next cursor, got %q", res.NextPageToken) + } + if len(res.Issues) != 1 { + t.Errorf("expected 1 issue, got %d", len(res.Issues)) + } +} + +func TestGraphQLClient_SetIssueState(t *testing.T) { + var seenVars map[string]interface{} + ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + body := readReq(t, r) + seenVars, _ = body["variables"].(map[string]interface{}) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"issueUpdate":{"success":true}}}`)) + }) + c := pointTo(NewGraphQLClient(&LinearConfig{}, "x"), ts.URL) + if err := c.SetIssueState(context.Background(), "ENG-1", "state-id"); err != nil { + t.Fatalf("set state: %v", err) + } + if seenVars["id"] != "ENG-1" || seenVars["stateId"] != "state-id" { + t.Errorf("vars = %+v", seenVars) + } +} + +func TestGraphQLClient_SetIssueState_FailureFlag(t *testing.T) { + ts := newMockServer(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"issueUpdate":{"success":false}}}`)) + }) + c := pointTo(NewGraphQLClient(&LinearConfig{}, "x"), ts.URL) + err := c.SetIssueState(context.Background(), "ENG-1", "state-id") + if err == nil { + t.Error("expected error when success=false") + } +} + +func TestStateCategoryMapping(t *testing.T) { + cases := map[string]string{ + "backlog": "new", + "unstarted": "new", + "triage": "new", + "started": "indeterminate", + "completed": "done", + "canceled": "done", + "weird": "new", + } + for input, want := range cases { + if got := stateCategory(input); got != want { + t.Errorf("stateCategory(%q)=%q, want %q", input, got, want) + } + } +} + +func TestBuildIssueFilter_DropsEmpty(t *testing.T) { + if got := buildIssueFilter(SearchFilter{}); got != nil { + t.Errorf("expected nil filter for empty input, got %+v", got) + } + got := buildIssueFilter(SearchFilter{Query: "auth", TeamKey: "ENG", Assigned: "me"}) + if _, ok := got["searchableContent"]; !ok { + t.Error("expected searchableContent filter") + } + if _, ok := got["team"]; !ok { + t.Error("expected team filter") + } + if _, ok := got["assignee"]; !ok { + t.Error("expected assignee filter") + } +} diff --git a/apps/backend/internal/linear/handlers.go b/apps/backend/internal/linear/handlers.go new file mode 100644 index 0000000000..a1b52f7796 --- /dev/null +++ b/apps/backend/internal/linear/handlers.go @@ -0,0 +1,385 @@ +package linear + +import ( + "context" + "errors" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + ws "github.com/kandev/kandev/pkg/websocket" +) + +// errMsgWorkspaceIDRequired is the response body returned when a handler is +// invoked without the workspace_id query parameter. Centralised because the +// linter flags repeated string literals at three or more occurrences. +const errMsgWorkspaceIDRequired = "workspace_id required" + +// RegisterRoutes wires the Linear HTTP and WebSocket handlers. +func RegisterRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, svc *Service, log *logger.Logger) { + ctrl := &Controller{service: svc, logger: log} + ctrl.RegisterHTTPRoutes(router) + registerWSHandlers(dispatcher, svc) +} + +// Controller holds HTTP route handlers for the Linear integration. +type Controller struct { + service *Service + logger *logger.Logger +} + +// RegisterHTTPRoutes attaches the Linear HTTP endpoints to router. +func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { + api := router.Group("/api/v1/linear") + api.GET("/config", c.httpGetConfig) + api.POST("/config", c.httpSetConfig) + api.DELETE("/config", c.httpDeleteConfig) + api.POST("/config/test", c.httpTestConfig) + api.GET("/teams", c.httpListTeams) + api.GET("/states", c.httpListStates) + api.GET("/issues", c.httpSearchIssues) + api.GET("/issues/:id", c.httpGetIssue) + api.POST("/issues/:id/state", c.httpSetIssueState) +} + +// --- HTTP handlers --- + +func (c *Controller) httpGetConfig(ctx *gin.Context) { + workspaceID := ctx.Query("workspace_id") + if workspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": errMsgWorkspaceIDRequired}) + return + } + cfg, err := c.service.GetConfig(ctx.Request.Context(), workspaceID) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if cfg == nil { + ctx.Status(http.StatusNoContent) + return + } + ctx.JSON(http.StatusOK, cfg) +} + +func (c *Controller) httpSetConfig(ctx *gin.Context) { + var req SetConfigRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + cfg, err := c.service.SetConfig(ctx.Request.Context(), &req) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, ErrInvalidConfig) { + status = http.StatusBadRequest + } + ctx.JSON(status, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, cfg) +} + +func (c *Controller) httpDeleteConfig(ctx *gin.Context) { + workspaceID := ctx.Query("workspace_id") + if workspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": errMsgWorkspaceIDRequired}) + return + } + if err := c.service.DeleteConfig(ctx.Request.Context(), workspaceID); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func (c *Controller) httpTestConfig(ctx *gin.Context) { + var req SetConfigRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + result, err := c.service.TestConnection(ctx.Request.Context(), &req) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, result) +} + +func (c *Controller) httpListTeams(ctx *gin.Context) { + workspaceID := ctx.Query("workspace_id") + if workspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": errMsgWorkspaceIDRequired}) + return + } + teams, err := c.service.ListTeams(ctx.Request.Context(), workspaceID) + if err != nil { + c.writeClientError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"teams": teams}) +} + +func (c *Controller) httpListStates(ctx *gin.Context) { + workspaceID := ctx.Query("workspace_id") + teamKey := ctx.Query("team_key") + if workspaceID == "" || teamKey == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id and team_key required"}) + return + } + states, err := c.service.ListStates(ctx.Request.Context(), workspaceID, teamKey) + if err != nil { + c.writeClientError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"states": states}) +} + +func (c *Controller) httpSearchIssues(ctx *gin.Context) { + workspaceID := ctx.Query("workspace_id") + if workspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": errMsgWorkspaceIDRequired}) + return + } + filter := SearchFilter{ + Query: ctx.Query("query"), + TeamKey: ctx.Query("team_key"), + Assigned: ctx.Query("assigned"), + } + if states := ctx.Query("state_ids"); states != "" { + filter.StateIDs = splitCSV(states) + } + pageToken := ctx.Query("page_token") + maxResults, _ := strconv.Atoi(ctx.Query("max_results")) + result, err := c.service.SearchIssues(ctx.Request.Context(), workspaceID, filter, pageToken, maxResults) + if err != nil { + c.writeClientError(ctx, err) + return + } + ctx.JSON(http.StatusOK, result) +} + +func (c *Controller) httpGetIssue(ctx *gin.Context) { + workspaceID := ctx.Query("workspace_id") + if workspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": errMsgWorkspaceIDRequired}) + return + } + id := ctx.Param("id") + issue, err := c.service.GetIssue(ctx.Request.Context(), workspaceID, id) + if err != nil { + c.writeClientError(ctx, err) + return + } + ctx.JSON(http.StatusOK, issue) +} + +func (c *Controller) httpSetIssueState(ctx *gin.Context) { + workspaceID := ctx.Query("workspace_id") + if workspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": errMsgWorkspaceIDRequired}) + return + } + id := ctx.Param("id") + var req struct { + StateID string `json:"stateId"` + } + if err := ctx.ShouldBindJSON(&req); err != nil || req.StateID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "stateId required"}) + return + } + if err := c.service.SetIssueState(ctx.Request.Context(), workspaceID, id, req.StateID); err != nil { + c.writeClientError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"transitioned": true}) +} + +// errCodeLinearNotConfigured is the wire-level code surfaced to the UI when +// the workspace has no saved Linear credentials. +const errCodeLinearNotConfigured = "LINEAR_NOT_CONFIGURED" + +// writeClientError maps service-level errors to HTTP responses. +func (c *Controller) writeClientError(ctx *gin.Context, err error) { + if errors.Is(err, ErrNotConfigured) { + ctx.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "Linear is not configured for this workspace", + "code": errCodeLinearNotConfigured, + }) + return + } + var apiErr *APIError + if errors.As(err, &apiErr) { + status := http.StatusInternalServerError + switch apiErr.StatusCode { + case http.StatusNotFound, http.StatusUnauthorized, http.StatusForbidden, http.StatusBadRequest: + status = apiErr.StatusCode + } + ctx.JSON(status, gin.H{"error": apiErr.Error()}) + return + } + c.logger.Warn("linear handler error", zap.Error(err)) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +} + +func splitCSV(raw string) []string { + parts := strings.Split(raw, ",") + out := parts[:0] + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +// --- WebSocket handlers --- + +func registerWSHandlers(dispatcher *ws.Dispatcher, svc *Service) { + dispatcher.RegisterFunc(ws.ActionLinearConfigGet, wsGetConfig(svc)) + dispatcher.RegisterFunc(ws.ActionLinearConfigSet, wsSetConfig(svc)) + dispatcher.RegisterFunc(ws.ActionLinearConfigDelete, wsDeleteConfig(svc)) + dispatcher.RegisterFunc(ws.ActionLinearConfigTest, wsTestConfig(svc)) + dispatcher.RegisterFunc(ws.ActionLinearIssueGet, wsGetIssue(svc)) + dispatcher.RegisterFunc(ws.ActionLinearIssueTransition, wsSetIssueState(svc)) + dispatcher.RegisterFunc(ws.ActionLinearTeamsList, wsListTeams(svc)) +} + +func wsReply(msg *ws.Message, payload interface{}) (*ws.Message, error) { + resp, err := ws.NewResponse(msg.ID, msg.Action, payload) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return resp, nil +} + +func wsFail(msg *ws.Message, err error) (*ws.Message, error) { + if errors.Is(err, ErrNotConfigured) { + return ws.NewError(msg.ID, msg.Action, errCodeLinearNotConfigured, err.Error(), nil) + } + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) +} + +func wsGetConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var p struct { + WorkspaceID string `json:"workspaceId"` + } + if err := msg.ParsePayload(&p); err != nil || p.WorkspaceID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspaceId required", nil) + } + cfg, err := svc.GetConfig(ctx, p.WorkspaceID) + if err != nil { + return wsFail(msg, err) + } + return wsReply(msg, gin.H{"config": cfg}) + } +} + +func wsSetConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req SetConfigRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) + } + cfg, err := svc.SetConfig(ctx, &req) + if err != nil { + if errors.Is(err, ErrInvalidConfig) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, err.Error(), nil) + } + return wsFail(msg, err) + } + return wsReply(msg, gin.H{"config": cfg}) + } +} + +func wsDeleteConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var p struct { + WorkspaceID string `json:"workspaceId"` + } + if err := msg.ParsePayload(&p); err != nil || p.WorkspaceID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspaceId required", nil) + } + if err := svc.DeleteConfig(ctx, p.WorkspaceID); err != nil { + return wsFail(msg, err) + } + return wsReply(msg, gin.H{"deleted": true}) + } +} + +func wsTestConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req SetConfigRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) + } + result, err := svc.TestConnection(ctx, &req) + if err != nil { + return wsFail(msg, err) + } + return wsReply(msg, result) + } +} + +func wsGetIssue(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var p struct { + WorkspaceID string `json:"workspaceId"` + Identifier string `json:"identifier"` + } + if err := msg.ParsePayload(&p); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) + } + if p.WorkspaceID == "" || p.Identifier == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspaceId and identifier required", nil) + } + issue, err := svc.GetIssue(ctx, p.WorkspaceID, p.Identifier) + if err != nil { + return wsFail(msg, err) + } + return wsReply(msg, issue) + } +} + +func wsSetIssueState(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var p struct { + WorkspaceID string `json:"workspaceId"` + IssueID string `json:"issueId"` + StateID string `json:"stateId"` + } + if err := msg.ParsePayload(&p); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) + } + if p.WorkspaceID == "" || p.IssueID == "" || p.StateID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspaceId, issueId, stateId required", nil) + } + if err := svc.SetIssueState(ctx, p.WorkspaceID, p.IssueID, p.StateID); err != nil { + return wsFail(msg, err) + } + return wsReply(msg, gin.H{"transitioned": true}) + } +} + +func wsListTeams(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var p struct { + WorkspaceID string `json:"workspaceId"` + } + if err := msg.ParsePayload(&p); err != nil || p.WorkspaceID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspaceId required", nil) + } + teams, err := svc.ListTeams(ctx, p.WorkspaceID) + if err != nil { + return wsFail(msg, err) + } + return wsReply(msg, gin.H{"teams": teams}) + } +} diff --git a/apps/backend/internal/linear/handlers_test.go b/apps/backend/internal/linear/handlers_test.go new file mode 100644 index 0000000000..6cc0de74ea --- /dev/null +++ b/apps/backend/internal/linear/handlers_test.go @@ -0,0 +1,194 @@ +package linear + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/common/logger" + ws "github.com/kandev/kandev/pkg/websocket" +) + +func TestSplitCSV(t *testing.T) { + cases := []struct { + in string + want []string + }{ + {"", []string{}}, + {"a", []string{"a"}}, + {"a,b,c", []string{"a", "b", "c"}}, + {"a, b , c", []string{"a", "b", "c"}}, // surrounding spaces trimmed + {"a,,b", []string{"a", "b"}}, // empty segment dropped + {",a,b,", []string{"a", "b"}}, // leading/trailing commas dropped + {" , ", []string{}}, // whitespace-only segments dropped + } + for _, tc := range cases { + got := splitCSV(tc.in) + // splitCSV may return a non-nil empty slice; normalise to a length + // comparison so a `nil` vs `[]string{}` mismatch doesn't fail the test. + if len(got) == 0 && len(tc.want) == 0 { + continue + } + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("splitCSV(%q) = %#v, want %#v", tc.in, got, tc.want) + } + } +} + +func newTestController(t *testing.T) (*Controller, *gin.Engine, *fakeClient) { + t.Helper() + store := newTestStore(t) + secrets := newFakeSecretStore() + client := &fakeClient{} + svc := NewService(store, secrets, func(_ *LinearConfig, _ string) Client { + return client + }, logger.Default()) + gin.SetMode(gin.TestMode) + router := gin.New() + ctrl := &Controller{service: svc, logger: logger.Default()} + ctrl.RegisterHTTPRoutes(router) + return ctrl, router, client +} + +func TestWriteClientError_NotConfigured_Returns503WithCode(t *testing.T) { + ctrl, _, _ := newTestController(t) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + ctrl.writeClientError(c, ErrNotConfigured) + if w.Code != http.StatusServiceUnavailable { + t.Errorf("status = %d, want 503", w.Code) + } + body := w.Body.String() + // errCodeLinearNotConfigured is private — assert on the wire constant. + if !strings.Contains(body, `"code":"LINEAR_NOT_CONFIGURED"`) { + t.Errorf("body does not surface code: %s", body) + } +} + +func TestWriteClientError_APIError_PassesThroughKnownStatuses(t *testing.T) { + cases := []struct { + upstream int + want int + }{ + {http.StatusUnauthorized, http.StatusUnauthorized}, + {http.StatusForbidden, http.StatusForbidden}, + {http.StatusNotFound, http.StatusNotFound}, + {http.StatusBadRequest, http.StatusBadRequest}, + {http.StatusBadGateway, http.StatusInternalServerError}, // unmapped → 500 + } + ctrl, _, _ := newTestController(t) + for _, tc := range cases { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + ctrl.writeClientError(c, &APIError{StatusCode: tc.upstream, Message: "msg"}) + if w.Code != tc.want { + t.Errorf("upstream %d → got status %d, want %d", tc.upstream, w.Code, tc.want) + } + } +} + +func TestWriteClientError_GenericError_Returns500(t *testing.T) { + ctrl, _, _ := newTestController(t) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + ctrl.writeClientError(c, errors.New("boom")) + if w.Code != http.StatusInternalServerError { + t.Errorf("status = %d, want 500", w.Code) + } +} + +func TestHTTPGetConfig_RequiresWorkspaceID(t *testing.T) { + _, router, _ := newTestController(t) + req := httptest.NewRequest(http.MethodGet, "/api/v1/linear/config", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestHTTPGetConfig_NoConfig_Returns204(t *testing.T) { + _, router, _ := newTestController(t) + req := httptest.NewRequest(http.MethodGet, "/api/v1/linear/config?workspace_id=missing", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusNoContent { + t.Errorf("status = %d, want 204", w.Code) + } +} + +func TestHTTPListStates_RequiresBothParams(t *testing.T) { + _, router, _ := newTestController(t) + for _, url := range []string{ + "/api/v1/linear/states", + "/api/v1/linear/states?workspace_id=ws-1", + "/api/v1/linear/states?team_key=ENG", + } { + req := httptest.NewRequest(http.MethodGet, url, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("%s: status = %d, want 400", url, w.Code) + } + } +} + +func TestHTTPListStates_RoutesThroughService(t *testing.T) { + ctrl, router, client := newTestController(t) + ctx := context.Background() + if err := ctrl.service.store.UpsertConfig(ctx, &LinearConfig{ + WorkspaceID: "ws-1", + AuthMethod: AuthMethodAPIKey, + }); err != nil { + t.Fatalf("upsert: %v", err) + } + if err := ctrl.service.secrets.Set(ctx, SecretKeyForWorkspace("ws-1"), "linear", "tok"); err != nil { + t.Fatalf("set secret: %v", err) + } + var seenTeam string + client.listStatesFn = func(teamKey string) ([]LinearWorkflowState, error) { + seenTeam = teamKey + return []LinearWorkflowState{{ID: "s1", Name: "In Progress"}}, nil + } + req := httptest.NewRequest(http.MethodGet, "/api/v1/linear/states?workspace_id=ws-1&team_key=ENG", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200; body=%s", w.Code, w.Body.String()) + } + if seenTeam != "ENG" { + t.Errorf("team key forwarded to client = %q, want ENG", seenTeam) + } +} + +func TestRegisterRoutes_RegistersWSHandlers(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + dispatcher := ws.NewDispatcher() + store := newTestStore(t) + svc := NewService(store, newFakeSecretStore(), func(_ *LinearConfig, _ string) Client { + return &fakeClient{} + }, logger.Default()) + + RegisterRoutes(router, dispatcher, svc, logger.Default()) + + for _, action := range []string{ + ws.ActionLinearConfigGet, + ws.ActionLinearConfigSet, + ws.ActionLinearConfigDelete, + ws.ActionLinearConfigTest, + ws.ActionLinearIssueGet, + ws.ActionLinearIssueTransition, + ws.ActionLinearTeamsList, + } { + if !dispatcher.HasHandler(action) { + t.Errorf("WS action %q not registered", action) + } + } +} diff --git a/apps/backend/internal/linear/models.go b/apps/backend/internal/linear/models.go new file mode 100644 index 0000000000..82a6a54044 --- /dev/null +++ b/apps/backend/internal/linear/models.go @@ -0,0 +1,135 @@ +// Package linear implements the Linear integration: workspace-scoped +// configuration storage, a GraphQL client for issues and workflow states, and +// the HTTP and WebSocket handlers that expose these capabilities to the +// frontend. +package linear + +import "time" + +// AuthMethodAPIKey is the only auth method Linear supports today: a Personal +// API Key sent as the `Authorization` header (no Bearer prefix). The constant +// exists so the wire format mirrors the Jira integration's `authMethod` field +// and leaves room for OAuth in the future. +const AuthMethodAPIKey = "api_key" + +// LinearConfig is the workspace-scoped configuration for the Linear +// integration. The API key is stored separately in the encrypted secret store +// under the key returned by SecretKeyForWorkspace. +type LinearConfig struct { + WorkspaceID string `json:"workspaceId" db:"workspace_id"` + AuthMethod string `json:"authMethod" db:"auth_method"` + DefaultTeamKey string `json:"defaultTeamKey" db:"default_team_key"` + HasSecret bool `json:"hasSecret" db:"-"` + // OrgSlug is captured from the most recent successful probe so the UI can + // build canonical issue URLs (linear.app//issue/) without an + // extra round-trip. Empty until the first probe succeeds. + OrgSlug string `json:"orgSlug,omitempty" db:"org_slug"` + // LastCheckedAt / LastOk / LastError are written by the background auth + // poller. They let the UI render a "connected/disconnected + checked Xs ago" + // indicator without doing its own probing. + LastCheckedAt *time.Time `json:"lastCheckedAt,omitempty" db:"last_checked_at"` + LastOk bool `json:"lastOk" db:"last_ok"` + LastError string `json:"lastError,omitempty" db:"last_error"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` +} + +// SetConfigRequest is the payload sent by the UI to create or update the +// workspace's Linear configuration. When Secret is empty on update, the +// existing secret is retained; when non-empty it replaces the stored value. +type SetConfigRequest struct { + WorkspaceID string `json:"workspaceId"` + AuthMethod string `json:"authMethod"` + DefaultTeamKey string `json:"defaultTeamKey"` + Secret string `json:"secret"` +} + +// TestConnectionResult reports what the backend learned when pinging Linear +// with the supplied credentials. +type TestConnectionResult struct { + OK bool `json:"ok"` + UserID string `json:"userId,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Email string `json:"email,omitempty"` + OrgSlug string `json:"orgSlug,omitempty"` + OrgName string `json:"orgName,omitempty"` + Error string `json:"error,omitempty"` +} + +// LinearIssue is the subset of Linear's issue payload that Kandev consumes. +// Kept small intentionally: the UI needs enough to prefill a task, show the +// current state, and surface a few familiar fields (assignee, priority, team) +// in the popover. +type LinearIssue struct { + ID string `json:"id"` + Identifier string `json:"identifier"` // e.g. "ENG-123" + Title string `json:"title"` + Description string `json:"description"` + // State mirrors Jira's status tuple (id, name, category) so frontend code + // styling status pills can branch on Category without per-integration + // switches. Linear's StateType values map onto: backlog/unstarted → "new", + // started → "indeterminate", completed/canceled → "done". + StateID string `json:"stateId"` + StateName string `json:"stateName"` + StateType string `json:"stateType"` // backlog | unstarted | started | completed | canceled | triage + StateCategory string `json:"stateCategory"` + TeamID string `json:"teamId"` + TeamKey string `json:"teamKey"` + Priority int `json:"priority"` // 0=none, 1=urgent, 2=high, 3=med, 4=low + PriorityLabel string `json:"priorityLabel,omitempty"` + AssigneeName string `json:"assigneeName,omitempty"` + AssigneeEmail string `json:"assigneeEmail,omitempty"` + AssigneeIcon string `json:"assigneeIcon,omitempty"` + CreatorName string `json:"creatorName,omitempty"` + CreatorIcon string `json:"creatorIcon,omitempty"` + Updated string `json:"updated,omitempty"` + URL string `json:"url"` + States []LinearWorkflowState `json:"states"` +} + +// LinearWorkflowState is one of the team workflow states an issue can be +// transitioned into. Unlike Jira transitions (which are edges), Linear states +// are nodes — to "transition" we set the issue's stateId to one of the team's +// states. State IDs are stable per team. +type LinearWorkflowState struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` // backlog | unstarted | started | completed | canceled | triage + Color string `json:"color,omitempty"` + Position int `json:"position"` +} + +// LinearTeam is the minimal shape used by the team selector on the settings +// page and by the issue browser to scope searches. +type LinearTeam struct { + ID string `json:"id"` + Key string `json:"key"` + Name string `json:"name"` +} + +// SearchFilter is a structured search filter used by SearchIssues. Linear has +// no JQL equivalent, so we expose a small set of structured fields that map +// cleanly to GraphQL filter inputs. +type SearchFilter struct { + Query string `json:"query,omitempty"` // free-text title/description/identifier match + TeamKey string `json:"teamKey,omitempty"` // restrict to one team + StateIDs []string `json:"stateIds,omitempty"` // restrict to specific workflow states + Assigned string `json:"assigned,omitempty"` // "me" | "unassigned" | "" (any) +} + +// SearchResult is a page of issues from a search. Linear uses cursor-based +// pagination (endCursor + hasNextPage), which we expose here under the same +// shape as the Jira SearchResult so the frontend pagination component can be +// reused. +type SearchResult struct { + Issues []LinearIssue `json:"issues"` + MaxResults int `json:"maxResults"` + IsLast bool `json:"isLast"` + NextPageToken string `json:"nextPageToken,omitempty"` +} + +// SecretKeyForWorkspace returns the secret-store key used for the Linear API +// key of a given workspace. Centralised so the service and store agree. +func SecretKeyForWorkspace(workspaceID string) string { + return "linear:" + workspaceID + ":token" +} diff --git a/apps/backend/internal/linear/poller.go b/apps/backend/internal/linear/poller.go new file mode 100644 index 0000000000..a9d650c974 --- /dev/null +++ b/apps/backend/internal/linear/poller.go @@ -0,0 +1,96 @@ +package linear + +import ( + "context" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +// defaultAuthPollInterval is how often the auth-health poller probes each +// configured workspace. +const defaultAuthPollInterval = 90 * time.Second + +// Poller probes the stored Linear credentials of every configured workspace on +// a fixed cadence and persists the result on the LinearConfig row. +type Poller struct { + service *Service + logger *logger.Logger + interval time.Duration + + mu sync.Mutex + cancel context.CancelFunc + wg sync.WaitGroup + started bool +} + +// NewPoller returns a poller that uses the default 90s cadence. +func NewPoller(svc *Service, log *logger.Logger) *Poller { + return &Poller{service: svc, logger: log, interval: defaultAuthPollInterval} +} + +// Start launches the background loop. Calling Start more than once without +// Stop is a no-op. +func (p *Poller) Start(ctx context.Context) { + p.mu.Lock() + defer p.mu.Unlock() + if p.started || p.service == nil { + return + } + p.started = true + ctx, p.cancel = context.WithCancel(ctx) + p.wg.Add(1) + go p.loop(ctx) + p.logger.Info("Linear auth poller started") +} + +// Stop cancels the loop and waits for it to drain. +func (p *Poller) Stop() { + p.mu.Lock() + if !p.started { + p.mu.Unlock() + return + } + cancel := p.cancel + p.mu.Unlock() + if cancel != nil { + cancel() + } + p.wg.Wait() + p.mu.Lock() + p.started = false + p.mu.Unlock() + p.logger.Info("Linear auth poller stopped") +} + +func (p *Poller) loop(ctx context.Context) { + defer p.wg.Done() + p.probeAll(ctx) + ticker := time.NewTicker(p.interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + p.probeAll(ctx) + } + } +} + +func (p *Poller) probeAll(ctx context.Context) { + ids, err := p.service.Store().ListConfiguredWorkspaces(ctx) + if err != nil { + p.logger.Warn("linear poller: list workspaces failed", zap.Error(err)) + return + } + for _, id := range ids { + if ctx.Err() != nil { + return + } + p.service.RecordAuthHealth(ctx, id) + } +} diff --git a/apps/backend/internal/linear/poller_test.go b/apps/backend/internal/linear/poller_test.go new file mode 100644 index 0000000000..a91e407112 --- /dev/null +++ b/apps/backend/internal/linear/poller_test.go @@ -0,0 +1,187 @@ +package linear + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" +) + +type pollerFixture struct { + store *Store + secrets *fakeSecretStore + client *fakeClient + svc *Service + poller *Poller +} + +func newPollerFixture(t *testing.T) *pollerFixture { + t.Helper() + f := &pollerFixture{ + store: newTestStore(t), + secrets: newFakeSecretStore(), + client: &fakeClient{}, + } + f.svc = NewService(f.store, f.secrets, func(_ *LinearConfig, _ string) Client { + return f.client + }, logger.Default()) + f.poller = NewPoller(f.svc, logger.Default()) + return f +} + +// saveConfig persists a workspace config directly via the store + secret +// fakes — bypassing Service.SetConfig avoids racing against its async probe. +func (f *pollerFixture) saveConfig(t *testing.T, workspaceID, secret string) { + t.Helper() + ctx := context.Background() + if err := f.store.UpsertConfig(ctx, &LinearConfig{ + WorkspaceID: workspaceID, + AuthMethod: AuthMethodAPIKey, + }); err != nil { + t.Fatalf("save config %s: %v", workspaceID, err) + } + if err := f.secrets.Set(ctx, SecretKeyForWorkspace(workspaceID), + "linear", secret); err != nil { + t.Fatalf("save secret %s: %v", workspaceID, err) + } +} + +func TestPoller_ProbeAll_RecordsSuccess(t *testing.T) { + f := newPollerFixture(t) + f.saveConfig(t, "ws-1", "tok") + f.client.testAuthFn = func() (*TestConnectionResult, error) { + return &TestConnectionResult{OK: true, OrgSlug: "acme"}, nil + } + + f.poller.probeAll(context.Background()) + + cfg, _ := f.store.GetConfig(context.Background(), "ws-1") + if cfg == nil { + t.Fatal("config disappeared") + } + if !cfg.LastOk { + t.Error("expected LastOk=true after successful probe") + } + if cfg.OrgSlug != "acme" { + t.Errorf("expected org_slug captured, got %q", cfg.OrgSlug) + } +} + +func TestPoller_ProbeAll_RecordsFailure(t *testing.T) { + f := newPollerFixture(t) + f.saveConfig(t, "ws-1", "tok") + f.client.testAuthFn = func() (*TestConnectionResult, error) { + return &TestConnectionResult{OK: false, Error: "401 unauthorized"}, nil + } + + f.poller.probeAll(context.Background()) + + cfg, _ := f.store.GetConfig(context.Background(), "ws-1") + if cfg.LastOk { + t.Error("expected LastOk=false after failed probe") + } + if cfg.LastError != "401 unauthorized" { + t.Errorf("expected error preserved, got %q", cfg.LastError) + } +} + +func TestPoller_ProbeAll_ClientError(t *testing.T) { + f := newPollerFixture(t) + f.saveConfig(t, "ws-1", "tok") + f.client.testAuthFn = func() (*TestConnectionResult, error) { + return nil, errors.New("network timeout") + } + + f.poller.probeAll(context.Background()) + + cfg, _ := f.store.GetConfig(context.Background(), "ws-1") + if cfg.LastOk { + t.Error("expected LastOk=false on client error") + } +} + +func TestPoller_ProbeAll_NoWorkspaces(t *testing.T) { + f := newPollerFixture(t) + f.poller.probeAll(context.Background()) +} + +func TestPoller_ProbeAll_MultipleWorkspaces(t *testing.T) { + f := newPollerFixture(t) + f.saveConfig(t, "ws-a", "tok-a") + f.saveConfig(t, "ws-b", "tok-b") + calls := 0 + f.client.testAuthFn = func() (*TestConnectionResult, error) { + calls++ + return &TestConnectionResult{OK: true}, nil + } + + f.poller.probeAll(context.Background()) + + if calls != 2 { + t.Errorf("expected 2 probe calls, got %d", calls) + } + for _, id := range []string{"ws-a", "ws-b"} { + cfg, _ := f.store.GetConfig(context.Background(), id) + if !cfg.LastOk || cfg.LastCheckedAt == nil { + t.Errorf("workspace %s missing health update: %+v", id, cfg) + } + } +} + +func TestPoller_StartStop(t *testing.T) { + f := newPollerFixture(t) + f.saveConfig(t, "ws-1", "tok") + probed := make(chan struct{}, 1) + f.client.testAuthFn = func() (*TestConnectionResult, error) { + select { + case probed <- struct{}{}: + default: + } + return &TestConnectionResult{OK: true}, nil + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + f.poller.Start(ctx) + defer f.poller.Stop() + + select { + case <-probed: + case <-time.After(2 * time.Second): + t.Fatal("poller did not record an initial probe within 2s") + } +} + +func TestPoller_StartIsIdempotent(t *testing.T) { + f := newPollerFixture(t) + f.saveConfig(t, "ws-1", "tok") + var calls int32 + probed := make(chan struct{}, 1) + f.client.testAuthFn = func() (*TestConnectionResult, error) { + atomic.AddInt32(&calls, 1) + select { + case probed <- struct{}{}: + default: + } + return &TestConnectionResult{OK: true}, nil + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + f.poller.Start(ctx) + f.poller.Start(ctx) + + select { + case <-probed: + case <-time.After(2 * time.Second): + t.Fatal("poller did not run an initial probe within 2s") + } + f.poller.Stop() + + if got := atomic.LoadInt32(&calls); got != 1 { + t.Errorf("expected exactly 1 probe call from the initial run, got %d", got) + } +} diff --git a/apps/backend/internal/linear/provider.go b/apps/backend/internal/linear/provider.go new file mode 100644 index 0000000000..70f24485b0 --- /dev/null +++ b/apps/backend/internal/linear/provider.go @@ -0,0 +1,20 @@ +package linear + +import ( + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/common/logger" +) + +// Provide builds the Linear service. Cleanup is a no-op today — the service +// holds only in-memory client caches — but the signature mirrors other +// integration providers so callers can register it uniformly. +func Provide(writer, reader *sqlx.DB, secrets SecretStore, log *logger.Logger) (*Service, func() error, error) { + store, err := NewStore(writer, reader) + if err != nil { + return nil, nil, err + } + svc := NewService(store, secrets, DefaultClientFactory, log) + cleanup := func() error { return nil } + return svc, cleanup, nil +} diff --git a/apps/backend/internal/linear/service.go b/apps/backend/internal/linear/service.go new file mode 100644 index 0000000000..0dbe677d27 --- /dev/null +++ b/apps/backend/internal/linear/service.go @@ -0,0 +1,333 @@ +package linear + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +// SecretStore is the subset of the secrets store the service needs. +type SecretStore interface { + Reveal(ctx context.Context, id string) (string, error) + Set(ctx context.Context, id, name, value string) error + Delete(ctx context.Context, id string) error + Exists(ctx context.Context, id string) (bool, error) +} + +// Service orchestrates Linear config storage, the per-workspace client cache, +// and the fetch/transition operations used by the WebSocket + HTTP handlers. +type Service struct { + store *Store + secrets SecretStore + log *logger.Logger + mu sync.Mutex + clientFn ClientFactory + cache map[string]Client // workspaceID → client, cleared on config change. + probeHook func(workspaceID string) +} + +// ClientFactory builds a Client for the given config + secret. Overridable so +// tests can inject fakes without touching HTTP. +type ClientFactory func(cfg *LinearConfig, secret string) Client + +// DefaultClientFactory returns a real GraphQLClient. +func DefaultClientFactory(cfg *LinearConfig, secret string) Client { + return NewGraphQLClient(cfg, secret) +} + +// NewService wires the service. Pass nil for clientFn to use the default. +func NewService(store *Store, secrets SecretStore, clientFn ClientFactory, log *logger.Logger) *Service { + if clientFn == nil { + clientFn = DefaultClientFactory + } + return &Service{ + store: store, + secrets: secrets, + log: log, + clientFn: clientFn, + cache: make(map[string]Client), + } +} + +// GetConfig returns the workspace config enriched with a HasSecret flag. +func (s *Service) GetConfig(ctx context.Context, workspaceID string) (*LinearConfig, error) { + cfg, err := s.store.GetConfig(ctx, workspaceID) + if err != nil || cfg == nil { + return cfg, err + } + if s.secrets == nil { + return cfg, nil + } + exists, existsErr := s.secrets.Exists(ctx, SecretKeyForWorkspace(workspaceID)) + if existsErr != nil { + s.log.Warn("linear: secret exists check failed", + zap.String("workspace_id", workspaceID), zap.Error(existsErr)) + } + cfg.HasSecret = exists + return cfg, nil +} + +// ErrInvalidConfig is returned by SetConfig when the request fails validation. +var ErrInvalidConfig = errors.New("linear: invalid configuration") + +// SetConfig is upsert. An empty Secret on update keeps the existing token. +func (s *Service) SetConfig(ctx context.Context, req *SetConfigRequest) (*LinearConfig, error) { + if err := validateConfigRequest(req); err != nil { + return nil, fmt.Errorf("%w: %s", ErrInvalidConfig, err.Error()) + } + cfg := &LinearConfig{ + WorkspaceID: req.WorkspaceID, + AuthMethod: req.AuthMethod, + DefaultTeamKey: req.DefaultTeamKey, + } + if err := s.store.UpsertConfig(ctx, cfg); err != nil { + return nil, fmt.Errorf("upsert linear config: %w", err) + } + if req.Secret != "" && s.secrets != nil { + if err := s.secrets.Set(ctx, + SecretKeyForWorkspace(req.WorkspaceID), + "Linear API key ("+req.WorkspaceID+")", + req.Secret, + ); err != nil { + return nil, fmt.Errorf("store linear secret: %w", err) + } + } + s.invalidateClient(req.WorkspaceID) + // Probe asynchronously so a slow Linear doesn't stall the save response. + go func(workspaceID string) { + s.RecordAuthHealth(context.Background(), workspaceID) + }(req.WorkspaceID) + return s.GetConfig(ctx, req.WorkspaceID) +} + +// DeleteConfig removes both the config row and the stored secret. +func (s *Service) DeleteConfig(ctx context.Context, workspaceID string) error { + if err := s.store.DeleteConfig(ctx, workspaceID); err != nil { + return err + } + if s.secrets != nil { + if err := s.secrets.Delete(ctx, SecretKeyForWorkspace(workspaceID)); err != nil { + s.log.Warn("linear: secret delete failed", + zap.String("workspace_id", workspaceID), zap.Error(err)) + } + } + s.invalidateClient(workspaceID) + return nil +} + +// TestConnection validates credentials either from a fresh SetConfigRequest +// (before persisting) or from the stored config (after saving). +func (s *Service) TestConnection(ctx context.Context, req *SetConfigRequest) (*TestConnectionResult, error) { + cfg, secret, err := s.resolveCredentials(ctx, req) + if err != nil { + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + client := s.clientFn(cfg, secret) + return client.TestAuth(ctx) +} + +// ProbeAuth validates the stored credentials for a workspace. +func (s *Service) ProbeAuth(ctx context.Context, workspaceID string) (*TestConnectionResult, error) { + client, err := s.clientFor(ctx, workspaceID) + if err != nil { + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + return client.TestAuth(ctx) +} + +// Store exposes the underlying store so background workers can persist state. +func (s *Service) Store() *Store { + return s.store +} + +// authProbeTimeout caps a single auth-health probe. +const authProbeTimeout = 15 * time.Second + +// authHealthWriteTimeout bounds the DB write that persists the probe outcome. +const authHealthWriteTimeout = 5 * time.Second + +// SetProbeHook installs a callback fired at the end of each RecordAuthHealth +// call. Production code never sets this; tests use it to synchronise on probe +// completion without sleep-polling. +func (s *Service) SetProbeHook(fn func(workspaceID string)) { + s.mu.Lock() + defer s.mu.Unlock() + s.probeHook = fn +} + +// RecordAuthHealth probes credentials and writes the outcome onto the row. +func (s *Service) RecordAuthHealth(ctx context.Context, workspaceID string) { + probeCtx, cancel := context.WithTimeout(ctx, authProbeTimeout) + defer cancel() + res, err := s.ProbeAuth(probeCtx, workspaceID) + ok := err == nil && res != nil && res.OK + errMsg := "" + switch { + case err != nil: + errMsg = err.Error() + case res != nil && !res.OK: + errMsg = res.Error + } + orgSlug := "" + if res != nil && ok { + orgSlug = res.OrgSlug + } + // Detach the DB write from ctx so a probe that exhausted its deadline can + // still record the failure. + writeCtx, writeCancel := context.WithTimeout(context.Background(), authHealthWriteTimeout) + defer writeCancel() + if updateErr := s.store.UpdateAuthHealth(writeCtx, workspaceID, ok, errMsg, orgSlug, time.Now().UTC()); updateErr != nil { + s.log.Warn("linear: update auth health failed", + zap.String("workspace_id", workspaceID), zap.Error(updateErr)) + } + s.mu.Lock() + hook := s.probeHook + s.mu.Unlock() + if hook != nil { + hook(workspaceID) + } +} + +// GetIssue loads a Linear issue by identifier (e.g. "ENG-123"). +func (s *Service) GetIssue(ctx context.Context, workspaceID, identifier string) (*LinearIssue, error) { + client, err := s.clientFor(ctx, workspaceID) + if err != nil { + return nil, err + } + return client.GetIssue(ctx, identifier) +} + +// SetIssueState moves an issue into the requested workflow state. +func (s *Service) SetIssueState(ctx context.Context, workspaceID, issueID, stateID string) error { + client, err := s.clientFor(ctx, workspaceID) + if err != nil { + return err + } + return client.SetIssueState(ctx, issueID, stateID) +} + +// ListTeams populates the team selector on the settings page. +func (s *Service) ListTeams(ctx context.Context, workspaceID string) ([]LinearTeam, error) { + client, err := s.clientFor(ctx, workspaceID) + if err != nil { + return nil, err + } + return client.ListTeams(ctx) +} + +// ListStates returns the workflow states for a team identified by its key. +// Mirrors the other Service methods so handlers never reach into clientFor +// directly. +func (s *Service) ListStates(ctx context.Context, workspaceID, teamKey string) ([]LinearWorkflowState, error) { + client, err := s.clientFor(ctx, workspaceID) + if err != nil { + return nil, err + } + return client.ListStates(ctx, teamKey) +} + +// SearchIssues runs a filtered search for the workspace. +func (s *Service) SearchIssues(ctx context.Context, workspaceID string, filter SearchFilter, pageToken string, maxResults int) (*SearchResult, error) { + client, err := s.clientFor(ctx, workspaceID) + if err != nil { + return nil, err + } + return client.SearchIssues(ctx, filter, pageToken, maxResults) +} + +// clientFor returns a cached client, creating one if needed. +func (s *Service) clientFor(ctx context.Context, workspaceID string) (Client, error) { + s.mu.Lock() + if c, ok := s.cache[workspaceID]; ok { + s.mu.Unlock() + return c, nil + } + s.mu.Unlock() + + cfg, err := s.store.GetConfig(ctx, workspaceID) + if err != nil { + return nil, err + } + if cfg == nil { + return nil, ErrNotConfigured + } + secret := "" + if s.secrets != nil { + secret, err = s.secrets.Reveal(ctx, SecretKeyForWorkspace(workspaceID)) + if err != nil { + return nil, fmt.Errorf("read linear secret: %w", err) + } + if secret == "" { + return nil, ErrNotConfigured + } + } + client := s.clientFn(cfg, secret) + s.mu.Lock() + defer s.mu.Unlock() + if existing, ok := s.cache[workspaceID]; ok { + return existing, nil + } + s.cache[workspaceID] = client + return client, nil +} + +// invalidateClient drops a cached client so the next request rebuilds it. +func (s *Service) invalidateClient(workspaceID string) { + s.mu.Lock() + delete(s.cache, workspaceID) + s.mu.Unlock() +} + +// resolveCredentials picks credentials for a test: inline if the request +// carries a secret, otherwise the stored secret. +func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) (*LinearConfig, string, error) { + cfg := &LinearConfig{ + WorkspaceID: req.WorkspaceID, + AuthMethod: req.AuthMethod, + } + if req.Secret != "" { + return cfg, req.Secret, nil + } + if s.secrets == nil { + return nil, "", errors.New("no secret store configured") + } + secret, err := s.secrets.Reveal(ctx, SecretKeyForWorkspace(req.WorkspaceID)) + if err != nil { + s.log.Warn("linear: secret reveal failed", + zap.String("workspace_id", req.WorkspaceID), zap.Error(err)) + return nil, "", fmt.Errorf("read linear secret: %w", err) + } + if secret == "" { + return nil, "", errors.New("no api key stored — paste one to test") + } + stored, storeErr := s.store.GetConfig(ctx, req.WorkspaceID) + if storeErr != nil { + // Soft-fail: a transient DB error here only loses the saved-config + // fallback values; the inline credentials still work for the test. + s.log.Warn("linear: load stored config for credential resolution failed", + zap.String("workspace_id", req.WorkspaceID), zap.Error(storeErr)) + } + if stored != nil && cfg.AuthMethod == "" { + cfg.AuthMethod = stored.AuthMethod + } + return cfg, secret, nil +} + +func validateConfigRequest(req *SetConfigRequest) error { + if req.WorkspaceID == "" { + return errors.New("workspaceId required") + } + if req.AuthMethod == "" { + req.AuthMethod = AuthMethodAPIKey + } + if req.AuthMethod != AuthMethodAPIKey { + return fmt.Errorf("unknown auth method: %q", req.AuthMethod) + } + return nil +} diff --git a/apps/backend/internal/linear/service_test.go b/apps/backend/internal/linear/service_test.go new file mode 100644 index 0000000000..426d53701b --- /dev/null +++ b/apps/backend/internal/linear/service_test.go @@ -0,0 +1,369 @@ +package linear + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" +) + +// fakeSecretStore is an in-memory SecretStore for tests. +type fakeSecretStore struct { + mu sync.Mutex + secrets map[string]string +} + +func newFakeSecretStore() *fakeSecretStore { + return &fakeSecretStore{secrets: map[string]string{}} +} + +func (f *fakeSecretStore) Reveal(_ context.Context, id string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + v, ok := f.secrets[id] + if !ok { + return "", errors.New("not found") + } + return v, nil +} + +func (f *fakeSecretStore) Set(_ context.Context, id, _, value string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.secrets[id] = value + return nil +} + +func (f *fakeSecretStore) Delete(_ context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.secrets, id) + return nil +} + +func (f *fakeSecretStore) Exists(_ context.Context, id string) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + _, ok := f.secrets[id] + return ok, nil +} + +// fakeClient is an in-memory Client for verifying service plumbing. +type fakeClient struct { + testAuthFn func() (*TestConnectionResult, error) + getIssueFn func(id string) (*LinearIssue, error) + setStateFn func(issueID, stateID string) error + listTeamsFn func() ([]LinearTeam, error) + listStatesFn func(teamKey string) ([]LinearWorkflowState, error) + transitionLog []string + searchIssuesFn func(filter SearchFilter, pageToken string, max int) (*SearchResult, error) +} + +func (c *fakeClient) TestAuth(_ context.Context) (*TestConnectionResult, error) { + if c.testAuthFn != nil { + return c.testAuthFn() + } + return &TestConnectionResult{OK: true}, nil +} + +func (c *fakeClient) GetIssue(_ context.Context, id string) (*LinearIssue, error) { + if c.getIssueFn != nil { + return c.getIssueFn(id) + } + return &LinearIssue{Identifier: id, ID: id}, nil +} + +func (c *fakeClient) ListStates(_ context.Context, teamKey string) ([]LinearWorkflowState, error) { + if c.listStatesFn != nil { + return c.listStatesFn(teamKey) + } + return nil, nil +} + +func (c *fakeClient) SetIssueState(_ context.Context, issueID, stateID string) error { + c.transitionLog = append(c.transitionLog, issueID+":"+stateID) + if c.setStateFn != nil { + return c.setStateFn(issueID, stateID) + } + return nil +} + +func (c *fakeClient) ListTeams(_ context.Context) ([]LinearTeam, error) { + if c.listTeamsFn != nil { + return c.listTeamsFn() + } + return nil, nil +} + +func (c *fakeClient) SearchIssues(_ context.Context, filter SearchFilter, pageToken string, max int) (*SearchResult, error) { + if c.searchIssuesFn != nil { + return c.searchIssuesFn(filter, pageToken, max) + } + return &SearchResult{}, nil +} + +type svcFixture struct { + svc *Service + store *Store + secrets *fakeSecretStore + client *fakeClient + factoryHit atomic.Int32 + probed chan string +} + +func newSvcFixture(t *testing.T) *svcFixture { + t.Helper() + f := &svcFixture{ + store: newTestStore(t), + secrets: newFakeSecretStore(), + client: &fakeClient{}, + probed: make(chan string, 8), + } + f.svc = NewService(f.store, f.secrets, func(_ *LinearConfig, _ string) Client { + f.factoryHit.Add(1) + return f.client + }, logger.Default()) + f.svc.SetProbeHook(func(workspaceID string) { + select { + case f.probed <- workspaceID: + default: + } + }) + return f +} + +// waitForAuthProbe blocks until the async probe has fired. +func waitForAuthProbe(t *testing.T, f *svcFixture, workspaceID string) *LinearConfig { + t.Helper() + for { + select { + case got := <-f.probed: + if got != workspaceID { + continue + } + cfg, err := f.svc.GetConfig(context.Background(), workspaceID) + if err != nil { + t.Fatalf("get config after probe: %v", err) + } + return cfg + case <-time.After(2 * time.Second): + t.Fatalf("async probe hook did not fire for %q within 2s", workspaceID) + return nil + } + } +} + +func TestService_SetConfig_UpsertsAndStoresSecret(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + + cfg, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + WorkspaceID: "ws-1", + AuthMethod: AuthMethodAPIKey, + DefaultTeamKey: "ENG", + Secret: "lin_api_xyz", + }) + if err != nil { + t.Fatalf("set: %v", err) + } + if cfg.DefaultTeamKey != "ENG" { + t.Errorf("team key not stored: %q", cfg.DefaultTeamKey) + } + if !cfg.HasSecret { + t.Error("expected HasSecret=true") + } + if got, _ := f.secrets.Reveal(ctx, SecretKeyForWorkspace("ws-1")); got != "lin_api_xyz" { + t.Errorf("secret stored = %q", got) + } +} + +func TestService_SetConfig_ProbesAuthImmediately(t *testing.T) { + f := newSvcFixture(t) + f.client.testAuthFn = func() (*TestConnectionResult, error) { + return &TestConnectionResult{OK: true, DisplayName: "Alice", OrgSlug: "acme"}, nil + } + if _, err := f.svc.SetConfig(context.Background(), &SetConfigRequest{ + WorkspaceID: "ws-1", AuthMethod: AuthMethodAPIKey, Secret: "tok", + }); err != nil { + t.Fatalf("set: %v", err) + } + cfg := waitForAuthProbe(t, f, "ws-1") + if !cfg.LastOk { + t.Errorf("expected LastOk=true after async probe, got %+v", cfg) + } + if cfg.OrgSlug != "acme" { + t.Errorf("expected org_slug captured, got %q", cfg.OrgSlug) + } +} + +func TestService_SetConfig_PersistsProbeFailure(t *testing.T) { + f := newSvcFixture(t) + f.client.testAuthFn = func() (*TestConnectionResult, error) { + return &TestConnectionResult{OK: false, Error: "401 unauthorized"}, nil + } + if _, err := f.svc.SetConfig(context.Background(), &SetConfigRequest{ + WorkspaceID: "ws-1", AuthMethod: AuthMethodAPIKey, Secret: "bad", + }); err != nil { + t.Fatalf("set: %v", err) + } + cfg := waitForAuthProbe(t, f, "ws-1") + if cfg.LastOk { + t.Error("expected LastOk=false after failed probe") + } + if cfg.LastError != "401 unauthorized" { + t.Errorf("expected probe error preserved, got %q", cfg.LastError) + } +} + +func TestService_SetConfig_EmptySecret_KeepsExisting(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + WorkspaceID: "ws-1", AuthMethod: AuthMethodAPIKey, Secret: "first", + }); err != nil { + t.Fatalf("initial: %v", err) + } + if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + WorkspaceID: "ws-1", AuthMethod: AuthMethodAPIKey, DefaultTeamKey: "MOB", + }); err != nil { + t.Fatalf("update: %v", err) + } + if got, _ := f.secrets.Reveal(ctx, SecretKeyForWorkspace("ws-1")); got != "first" { + t.Errorf("secret should be preserved, got %q", got) + } +} + +func TestService_SetConfig_InvalidatesClientCache(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + _, _ = f.svc.SetConfig(ctx, &SetConfigRequest{ + WorkspaceID: "ws-1", AuthMethod: AuthMethodAPIKey, Secret: "t", + }) + waitForAuthProbe(t, f, "ws-1") + if _, err := f.svc.GetIssue(ctx, "ws-1", "A-1"); err != nil { + t.Fatalf("get1: %v", err) + } + hits := f.factoryHit.Load() + if _, err := f.svc.GetIssue(ctx, "ws-1", "A-2"); err != nil { + t.Fatalf("get2: %v", err) + } + if got := f.factoryHit.Load(); got != hits { + t.Errorf("factory should be cached, hits %d→%d", hits, got) + } + _, _ = f.svc.SetConfig(ctx, &SetConfigRequest{ + WorkspaceID: "ws-1", AuthMethod: AuthMethodAPIKey, Secret: "t2", + }) + waitForAuthProbe(t, f, "ws-1") + if _, err := f.svc.GetIssue(ctx, "ws-1", "A-3"); err != nil { + t.Fatalf("get3: %v", err) + } + if got := f.factoryHit.Load(); got <= hits { + t.Errorf("factory should rebuild after config change, hits=%d", got) + } +} + +func TestService_Validation(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + cases := []struct { + name string + req SetConfigRequest + }{ + {"missing ws", SetConfigRequest{AuthMethod: AuthMethodAPIKey}}, + {"bad auth", SetConfigRequest{WorkspaceID: "w", AuthMethod: "bogus"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := f.svc.SetConfig(ctx, &tc.req); err == nil { + t.Error("expected validation error") + } + }) + } +} + +func TestService_Validation_DefaultsAuthMethod(t *testing.T) { + f := newSvcFixture(t) + // Empty AuthMethod is filled with AuthMethodAPIKey rather than rejected. + if _, err := f.svc.SetConfig(context.Background(), &SetConfigRequest{ + WorkspaceID: "ws-1", Secret: "tok", + }); err != nil { + t.Fatalf("expected default auth method, got error: %v", err) + } +} + +func TestService_TestConnection_InlineSecret(t *testing.T) { + f := newSvcFixture(t) + called := false + f.client.testAuthFn = func() (*TestConnectionResult, error) { + called = true + return &TestConnectionResult{OK: true, DisplayName: "Alice"}, nil + } + res, err := f.svc.TestConnection(context.Background(), &SetConfigRequest{ + WorkspaceID: "ws-1", AuthMethod: AuthMethodAPIKey, Secret: "inline", + }) + if err != nil || !called { + t.Fatalf("called=%v err=%v", called, err) + } + if !res.OK || res.DisplayName != "Alice" { + t.Errorf("result: %+v", res) + } +} + +func TestService_TestConnection_NoStoredSecret_ReturnsFailure(t *testing.T) { + f := newSvcFixture(t) + res, err := f.svc.TestConnection(context.Background(), &SetConfigRequest{ + WorkspaceID: "ws-nope", AuthMethod: AuthMethodAPIKey, + }) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if res.OK { + t.Error("expected OK=false when no secret stored") + } +} + +func TestService_GetIssue_NotConfigured(t *testing.T) { + f := newSvcFixture(t) + _, err := f.svc.GetIssue(context.Background(), "missing", "ENG-1") + if !errors.Is(err, ErrNotConfigured) { + t.Errorf("expected ErrNotConfigured, got %v", err) + } +} + +func TestService_DeleteConfig_RemovesSecretAndCache(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + _, _ = f.svc.SetConfig(ctx, &SetConfigRequest{ + WorkspaceID: "ws-1", AuthMethod: AuthMethodAPIKey, Secret: "t", + }) + waitForAuthProbe(t, f, "ws-1") + if err := f.svc.DeleteConfig(ctx, "ws-1"); err != nil { + t.Fatalf("delete: %v", err) + } + if exists, _ := f.secrets.Exists(ctx, SecretKeyForWorkspace("ws-1")); exists { + t.Error("expected secret removed") + } + cfg, _ := f.svc.GetConfig(ctx, "ws-1") + if cfg != nil { + t.Errorf("expected config gone, got %+v", cfg) + } +} + +func TestService_SetIssueState_Forwards(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + _, _ = f.svc.SetConfig(ctx, &SetConfigRequest{ + WorkspaceID: "ws-1", AuthMethod: AuthMethodAPIKey, Secret: "t", + }) + waitForAuthProbe(t, f, "ws-1") + if err := f.svc.SetIssueState(ctx, "ws-1", "ENG-1", "state-id"); err != nil { + t.Fatalf("set state: %v", err) + } + if len(f.client.transitionLog) != 1 || f.client.transitionLog[0] != "ENG-1:state-id" { + t.Errorf("transition log: %v", f.client.transitionLog) + } +} diff --git a/apps/backend/internal/linear/store.go b/apps/backend/internal/linear/store.go new file mode 100644 index 0000000000..cd1951f0e6 --- /dev/null +++ b/apps/backend/internal/linear/store.go @@ -0,0 +1,182 @@ +package linear + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jmoiron/sqlx" +) + +// Store persists Linear workspace configurations. Secret values are delegated +// to the shared encrypted secret store and not stored here. +type Store struct { + db *sqlx.DB + ro *sqlx.DB +} + +// NewStore creates a new Store and initializes the schema if needed. +func NewStore(writer, reader *sqlx.DB) (*Store, error) { + s := &Store{db: writer, ro: reader} + if err := s.initSchema(); err != nil { + return nil, fmt.Errorf("linear schema init: %w", err) + } + return s, nil +} + +const createTablesSQL = ` + CREATE TABLE IF NOT EXISTS linear_configs ( + workspace_id TEXT PRIMARY KEY, + auth_method TEXT NOT NULL, + default_team_key TEXT NOT NULL DEFAULT '', + org_slug TEXT NOT NULL DEFAULT '', + last_checked_at DATETIME, + last_ok INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); +` + +// addedColumns lists columns introduced after the initial schema. Kept for +// parity with the Jira store so future column additions follow the same +// migration pattern; empty for now since the table ships fully-formed. +var addedColumns = []struct { + name string + sql string +}{} + +func (s *Store) initSchema() error { + if _, err := s.db.Exec(createTablesSQL); err != nil { + return err + } + return s.migrateAddedColumns() +} + +// migrateAddedColumns applies ALTER TABLE statements for columns introduced +// after the initial schema. Existing databases need these columns backfilled; +// new databases already have them from createTablesSQL and the ALTERs are +// skipped. +func (s *Store) migrateAddedColumns() error { + if len(addedColumns) == 0 { + return nil + } + existing, err := s.tableColumns("linear_configs") + if err != nil { + return err + } + for _, col := range addedColumns { + if _, ok := existing[col.name]; ok { + continue + } + if _, err := s.db.Exec(col.sql); err != nil { + return fmt.Errorf("add column %s: %w", col.name, err) + } + } + return nil +} + +func (s *Store) tableColumns(table string) (map[string]struct{}, error) { + rows, err := s.db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table)) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + cols := make(map[string]struct{}) + for rows.Next() { + var ( + cid int + name string + ctype string + notnull int + dflt sql.NullString + pk int + ) + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { + return nil, err + } + cols[name] = struct{}{} + } + return cols, rows.Err() +} + +const selectConfigColumns = `workspace_id, auth_method, default_team_key, org_slug, + last_checked_at, last_ok, last_error, created_at, updated_at` + +// GetConfig returns the Linear config for a workspace, or nil when no row +// exists. +func (s *Store) GetConfig(ctx context.Context, workspaceID string) (*LinearConfig, error) { + var cfg LinearConfig + err := s.ro.GetContext(ctx, &cfg, + `SELECT `+selectConfigColumns+` FROM linear_configs WHERE workspace_id = ?`, workspaceID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &cfg, nil +} + +// UpsertConfig inserts or updates the config row for a workspace. It never +// touches the secret store — callers must persist the token separately. The +// last_* health columns and org_slug are deliberately not touched here; the +// poller owns those and writes them via UpdateAuthHealth. +func (s *Store) UpsertConfig(ctx context.Context, cfg *LinearConfig) error { + now := time.Now().UTC() + if cfg.CreatedAt.IsZero() { + cfg.CreatedAt = now + } + cfg.UpdatedAt = now + _, err := s.db.ExecContext(ctx, ` + INSERT INTO linear_configs (workspace_id, auth_method, default_team_key, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(workspace_id) DO UPDATE SET + auth_method = excluded.auth_method, + default_team_key = excluded.default_team_key, + updated_at = excluded.updated_at`, + cfg.WorkspaceID, cfg.AuthMethod, cfg.DefaultTeamKey, cfg.CreatedAt, cfg.UpdatedAt) + return err +} + +// DeleteConfig removes the Linear config row for a workspace. Secrets must be +// cleared separately by the caller. +func (s *Store) DeleteConfig(ctx context.Context, workspaceID string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM linear_configs WHERE workspace_id = ?`, workspaceID) + return err +} + +// ListConfiguredWorkspaces returns the IDs of all workspaces that have a +// Linear config row. Used by the auth-health poller. +func (s *Store) ListConfiguredWorkspaces(ctx context.Context) ([]string, error) { + var ids []string + err := s.ro.SelectContext(ctx, &ids, + `SELECT workspace_id FROM linear_configs ORDER BY workspace_id`) + if err != nil { + return nil, err + } + return ids, nil +} + +// UpdateAuthHealth records the result of a credential probe. orgSlug is +// captured opportunistically from successful probes; pass "" to leave the +// existing slug unchanged. If the workspace row no longer exists, the update +// is a silent no-op. +func (s *Store) UpdateAuthHealth(ctx context.Context, workspaceID string, ok bool, errMsg, orgSlug string, checkedAt time.Time) error { + if orgSlug != "" { + _, err := s.db.ExecContext(ctx, ` + UPDATE linear_configs + SET last_checked_at = ?, last_ok = ?, last_error = ?, org_slug = ? + WHERE workspace_id = ?`, + checkedAt, ok, errMsg, orgSlug, workspaceID) + return err + } + _, err := s.db.ExecContext(ctx, ` + UPDATE linear_configs + SET last_checked_at = ?, last_ok = ?, last_error = ? + WHERE workspace_id = ?`, + checkedAt, ok, errMsg, workspaceID) + return err +} diff --git a/apps/backend/internal/linear/store_test.go b/apps/backend/internal/linear/store_test.go new file mode 100644 index 0000000000..35d33d5ac8 --- /dev/null +++ b/apps/backend/internal/linear/store_test.go @@ -0,0 +1,161 @@ +package linear + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + raw, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + raw.SetMaxOpenConns(1) + raw.SetMaxIdleConns(1) + db := sqlx.NewDb(raw, "sqlite3") + t.Cleanup(func() { _ = db.Close() }) + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("new store: %v", err) + } + return store +} + +func TestStore_UpsertGetDelete(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + cfg := &LinearConfig{ + WorkspaceID: "ws-1", + AuthMethod: AuthMethodAPIKey, + DefaultTeamKey: "ENG", + } + if err := store.UpsertConfig(ctx, cfg); err != nil { + t.Fatalf("upsert: %v", err) + } + + got, err := store.GetConfig(ctx, "ws-1") + if err != nil { + t.Fatalf("get: %v", err) + } + if got == nil { + t.Fatal("expected config, got nil") + } + if got.AuthMethod != cfg.AuthMethod || got.DefaultTeamKey != cfg.DefaultTeamKey { + t.Errorf("round-trip mismatch: %+v vs %+v", got, cfg) + } + if got.CreatedAt.IsZero() || got.UpdatedAt.IsZero() { + t.Error("timestamps not set") + } + + cfg.DefaultTeamKey = "MOB" + if err := store.UpsertConfig(ctx, cfg); err != nil { + t.Fatalf("update upsert: %v", err) + } + got2, _ := store.GetConfig(ctx, "ws-1") + if got2.DefaultTeamKey != "MOB" { + t.Errorf("expected team update, got %q", got2.DefaultTeamKey) + } + + if err := store.DeleteConfig(ctx, "ws-1"); err != nil { + t.Fatalf("delete: %v", err) + } + gone, err := store.GetConfig(ctx, "ws-1") + if err != nil { + t.Fatalf("get after delete: %v", err) + } + if gone != nil { + t.Errorf("expected nil after delete, got %+v", gone) + } +} + +func TestStore_GetConfig_Missing(t *testing.T) { + store := newTestStore(t) + cfg, err := store.GetConfig(context.Background(), "does-not-exist") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg != nil { + t.Errorf("expected nil for missing config, got %+v", cfg) + } +} + +func TestStore_ListConfiguredWorkspaces(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + mustUpsert := func(id string) { + if err := store.UpsertConfig(ctx, &LinearConfig{ + WorkspaceID: id, + AuthMethod: AuthMethodAPIKey, + }); err != nil { + t.Fatalf("upsert %s: %v", id, err) + } + } + mustUpsert("ws-b") + mustUpsert("ws-a") + ids, err := store.ListConfiguredWorkspaces(ctx) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(ids) != 2 || ids[0] != "ws-a" || ids[1] != "ws-b" { + t.Errorf("expected sorted [ws-a ws-b], got %v", ids) + } +} + +func TestStore_UpdateAuthHealth(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + if err := store.UpsertConfig(ctx, &LinearConfig{ + WorkspaceID: "ws-1", + AuthMethod: AuthMethodAPIKey, + }); err != nil { + t.Fatalf("upsert: %v", err) + } + + cfg, _ := store.GetConfig(ctx, "ws-1") + if cfg.LastCheckedAt != nil { + t.Errorf("expected nil last_checked_at on fresh row, got %v", cfg.LastCheckedAt) + } + + now := time.Now().UTC().Truncate(time.Second) + if err := store.UpdateAuthHealth(ctx, "ws-1", true, "", "acme", now); err != nil { + t.Fatalf("update ok: %v", err) + } + cfg, _ = store.GetConfig(ctx, "ws-1") + if !cfg.LastOk { + t.Error("expected last_ok=true after successful probe") + } + if cfg.OrgSlug != "acme" { + t.Errorf("expected org_slug=acme, got %q", cfg.OrgSlug) + } + if cfg.LastCheckedAt == nil || !cfg.LastCheckedAt.Equal(now) { + t.Errorf("expected last_checked_at=%v, got %v", now, cfg.LastCheckedAt) + } + + // Empty orgSlug should leave the existing slug intact. + failAt := now.Add(time.Minute) + if err := store.UpdateAuthHealth(ctx, "ws-1", false, "401 unauthorized", "", failAt); err != nil { + t.Fatalf("update fail: %v", err) + } + cfg, _ = store.GetConfig(ctx, "ws-1") + if cfg.LastOk { + t.Error("expected last_ok=false after failure") + } + if cfg.LastError != "401 unauthorized" { + t.Errorf("expected last_error preserved, got %q", cfg.LastError) + } + if cfg.OrgSlug != "acme" { + t.Errorf("orgSlug should be preserved across failed probe, got %q", cfg.OrgSlug) + } + + // Update for missing workspace must not error. + if err := store.UpdateAuthHealth(ctx, "missing", true, "", "x", now); err != nil { + t.Errorf("expected no-op for missing row, got %v", err) + } +} diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index b6c26c0076..c688b0ccc1 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -402,6 +402,17 @@ const ( ActionJiraProjectsList = "jira.projects.list" ) +// Linear integration actions +const ( + ActionLinearConfigGet = "linear.config.get" + ActionLinearConfigSet = "linear.config.set" + ActionLinearConfigDelete = "linear.config.delete" + ActionLinearConfigTest = "linear.config.test" + ActionLinearIssueGet = "linear.issue.get" + ActionLinearIssueTransition = "linear.issue.transition" + ActionLinearTeamsList = "linear.teams.list" +) + // Error codes const ( ErrorCodeBadRequest = "BAD_REQUEST" diff --git a/apps/web/app/linear/linear-page-client.tsx b/apps/web/app/linear/linear-page-client.tsx new file mode 100644 index 0000000000..0a5cc977b4 --- /dev/null +++ b/apps/web/app/linear/linear-page-client.tsx @@ -0,0 +1,525 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { IconExternalLink, IconHexagon, IconPlus, IconSearch } from "@tabler/icons-react"; +import { Alert, AlertDescription } from "@kandev/ui/alert"; +import { Avatar, AvatarFallback, AvatarImage } from "@kandev/ui/avatar"; +import { Badge } from "@kandev/ui/badge"; +import { Button } from "@kandev/ui/button"; +import { Input } from "@kandev/ui/input"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationNext, + PaginationPrevious, +} from "@kandev/ui/pagination"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { PageTopbar } from "@/components/page-topbar"; +import { useLinearAvailable } from "@/components/linear/use-linear-availability"; +import { + formatRelative, + LinearErrorMessage, + stateBadgeClass, +} from "@/components/linear/linear-issue-common"; +import { LinearIssueDialog } from "@/components/linear/linear-issue-dialog"; +import { LinearQuickTaskLauncher } from "@/components/linear/linear-quick-task-launcher"; +import { getLinearConfig, listLinearTeams, searchLinearIssues } from "@/lib/api/domains/linear-api"; +import type { LinearIssue, LinearTeam } from "@/lib/types/linear"; +import type { Workflow, WorkflowStep } from "@/lib/types/http"; + +const PAGE_SIZE = 25; + +type LinearPageClientProps = { + workspaceId?: string; + workflows: Workflow[]; + steps: WorkflowStep[]; +}; + +function NotConfiguredNotice({ workspaceId }: { workspaceId?: string }) { + return ( +
+ + + Linear is not configured for this workspace.{" "} + + Configure Linear + {" "} + to see your issues here. + + +
+ ); +} + +function useLinearPageData(workspaceId?: string) { + const [loaded, setLoaded] = useState(false); + const [configured, setConfigured] = useState(false); + const [teams, setTeams] = useState([]); + + useEffect(() => { + let cancelled = false; + async function load() { + if (!workspaceId) { + setLoaded(true); + return; + } + try { + const cfg = await getLinearConfig(workspaceId); + if (cancelled) return; + const ok = !!cfg && cfg.hasSecret; + setConfigured(ok); + if (ok) { + try { + const list = await listLinearTeams(workspaceId); + if (!cancelled) setTeams(list.teams ?? []); + } catch { + // Non-fatal: team filter just stays empty. + } + } + } finally { + if (!cancelled) setLoaded(true); + } + } + void load(); + return () => { + cancelled = true; + }; + }, [workspaceId]); + + return { loaded, configured, teams }; +} + +type SearchState = { + items: LinearIssue[]; + loading: boolean; + error: string | null; + page: number; + pageSize: number; + isLast: boolean; + goNext: () => void; + goPrev: () => void; +}; + +// useIssueSearch is page-based: it caches each page's cursor in `tokensRef` so +// the user can step backward without re-querying from the first page. Linear +// returns no total count, so the UI shows a row range plus a Page N indicator. +function useIssueSearch( + workspaceId: string | undefined, + query: string, + teamKey: string, + assigned: string, +): SearchState { + const [items, setItems] = useState([]); + const [page, setPage] = useState(1); + const [isLast, setIsLast] = useState(true); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + // tokens[i] is the page_token for page i+1; tokens[0] is always "". + const tokensRef = useRef([""]); + const reqRef = useRef(0); + + const fetchPage = useCallback( + (p: number) => + searchLinearIssues(workspaceId!, { + query: query || undefined, + teamKey: teamKey || undefined, + assigned: assigned || undefined, + pageToken: tokensRef.current[p - 1] || undefined, + maxResults: PAGE_SIZE, + }), + [workspaceId, query, teamKey, assigned], + ); + + const run = useCallback( + async (p: number) => { + if (!workspaceId) return; + const reqId = ++reqRef.current; + setLoading(true); + setError(null); + try { + const res = await fetchPage(p); + if (reqId !== reqRef.current) return; + setItems(res.issues ?? []); + setIsLast(res.isLast ?? true); + if (!res.isLast && res.nextPageToken) { + tokensRef.current[p] = res.nextPageToken; + } + } catch (err) { + if (reqId !== reqRef.current) return; + setError(err instanceof Error ? err.message : String(err)); + } finally { + if (reqId === reqRef.current) setLoading(false); + } + }, + [workspaceId, fetchPage], + ); + + // Reset cursor stack and snap back to page 1 when filters change. + useEffect(() => { + tokensRef.current = [""]; + setPage(1); + }, [workspaceId, query, teamKey, assigned]); + + // 250ms debounce keeps the keyboard input from firing a request per char. + useEffect(() => { + if (!workspaceId) return; + const id = setTimeout(() => void run(page), 250); + return () => clearTimeout(id); + }, [run, page, workspaceId]); + + return { + items, + loading, + error, + page, + pageSize: PAGE_SIZE, + isLast, + goNext: () => { + if (!isLast) setPage((p) => p + 1); + }, + goPrev: () => { + setPage((p) => Math.max(1, p - 1)); + }, + }; +} + +function AssigneeCell({ issue }: { issue: LinearIssue }) { + if (!issue.assigneeName) { + return Unassigned; + } + return ( +
+ + {issue.assigneeIcon && } + {issue.assigneeName.charAt(0)} + + {issue.assigneeName} +
+ ); +} + +function IssueRow({ + issue, + onOpen, + onStartTask, +}: { + issue: LinearIssue; + onOpen: (i: LinearIssue) => void; + onStartTask: (i: LinearIssue) => void; +}) { + const relative = formatRelative(issue.updated); + return ( +
+ +
+ + +
+
+ ); +} + +function FilterControls({ + query, + setQuery, + teamKey, + setTeamKey, + assigned, + setAssigned, + teams, +}: { + query: string; + setQuery: (v: string) => void; + teamKey: string; + setTeamKey: (v: string) => void; + assigned: string; + setAssigned: (v: string) => void; + teams: LinearTeam[]; +}) { + return ( +
+
+ + setQuery(e.target.value)} + className="pl-8" + /> +
+ + +
+ ); +} + +function PaginationBar({ + page, + pageSize, + itemCount, + isLast, + onNext, + onPrev, +}: { + page: number; + pageSize: number; + itemCount: number; + isLast: boolean; + onNext: () => void; + onPrev: () => void; +}) { + if (page === 1 && isLast) return null; + const start = (page - 1) * pageSize + 1; + const end = (page - 1) * pageSize + itemCount; + const prevDisabled = page <= 1; + const nextDisabled = isLast; + return ( +
+
+ {itemCount === 0 ? "No results" : `${start}–${end}`} +
+ + + + { + e.preventDefault(); + if (!prevDisabled) onPrev(); + }} + aria-disabled={prevDisabled} + className={prevDisabled ? "pointer-events-none opacity-50" : "cursor-pointer"} + /> + + + Page {page} + + + { + e.preventDefault(); + if (!nextDisabled) onNext(); + }} + aria-disabled={nextDisabled} + className={nextDisabled ? "pointer-events-none opacity-50" : "cursor-pointer"} + /> + + + +
+ ); +} + +function PageShell({ children }: { children: React.ReactNode }) { + return ( +
+ } /> + {children} +
+ ); +} + +function DisabledNotice({ workspaceId }: { workspaceId: string }) { + return ( +
+ + + Linear integration is disabled for this workspace.{" "} + + Re-enable it in settings + + . + + +
+ ); +} + +function ResultsArea({ + search, + empty, + workspaceId, + onOpen, + onStartTask, +}: { + search: SearchState; + empty: boolean; + workspaceId: string; + onOpen: (issue: LinearIssue) => void; + onStartTask: (issue: LinearIssue) => void; +}) { + return ( +
+ {search.error && !search.loading && ( +
+ +
+ )} + {!search.error && empty && ( +
+ No issues match your filters. +
+ )} + {search.items.map((issue) => ( + + ))} + {search.loading && search.items.length === 0 && ( +
Loading issues…
+ )} +
+ ); +} + +export function LinearPageClient({ workspaceId, workflows, steps }: LinearPageClientProps) { + const available = useLinearAvailable(workspaceId); + const { loaded, configured, teams } = useLinearPageData(workspaceId); + + const [query, setQuery] = useState(""); + const [teamKey, setTeamKey] = useState(""); + const [assigned, setAssigned] = useState("me"); + const search = useIssueSearch(workspaceId, query, teamKey, assigned); + + const [openIssue, setOpenIssue] = useState(null); + const [launchIssue, setLaunchIssue] = useState(null); + + const empty = useMemo( + () => loaded && configured && !search.loading && search.items.length === 0, + [loaded, configured, search.loading, search.items.length], + ); + + if (!workspaceId) { + return ( + + + + ); + } + if (loaded && !configured) { + return ( + + + + ); + } + if (!available && loaded && configured) { + return ( + + + + ); + } + + return ( + + + + + !open && setOpenIssue(null)} + workspaceId={workspaceId} + identifier={openIssue?.identifier ?? null} + initialIssue={openIssue} + onStartTask={setLaunchIssue} + /> + setLaunchIssue(null)} + /> + + ); +} diff --git a/apps/web/app/linear/page.tsx b/apps/web/app/linear/page.tsx new file mode 100644 index 0000000000..0e0327c876 --- /dev/null +++ b/apps/web/app/linear/page.tsx @@ -0,0 +1,65 @@ +import { + listWorkspacesAction, + listWorkflowsAction, + listWorkspaceWorkflowStepsAction, +} from "@/app/actions/workspaces"; +import { fetchUserSettings } from "@/lib/api"; +import { StateHydrator } from "@/components/state-hydrator"; +import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; +import { LinearPageClient } from "./linear-page-client"; +import type { Workflow, WorkflowStep, Workspace, UserSettingsResponse } from "@/lib/types/http"; +import type { AppState } from "@/lib/state/store"; + +export default async function LinearPage() { + let workspaces: Workspace[] = []; + let workflows: Workflow[] = []; + let steps: WorkflowStep[] = []; + let workspaceId: string | undefined; + let userSettingsResponse: UserSettingsResponse | null = null; + + try { + const [workspacesResponse, settingsResponse] = await Promise.all([ + listWorkspacesAction(), + fetchUserSettings({ cache: "no-store" }).catch(() => null), + ]); + workspaces = workspacesResponse.workspaces; + userSettingsResponse = settingsResponse; + workspaceId = settingsResponse?.settings?.workspace_id || workspaces[0]?.id; + + if (workspaceId) { + const [workflowsRes, stepsRes] = await Promise.all([ + listWorkflowsAction(workspaceId), + listWorkspaceWorkflowStepsAction(workspaceId), + ]); + workflows = workflowsRes.workflows; + steps = stepsRes.steps; + } + } catch (error) { + console.error("Failed to load Linear page data:", error); + } + + const mappedUserSettings = mapUserSettingsResponse(userSettingsResponse); + + const initialState: Partial = { + workspaces: { items: workspaces, activeId: workspaceId ?? null }, + workflows: { + items: workflows.map((w) => ({ + id: w.id, + workspaceId: w.workspace_id, + name: w.name, + description: w.description ?? null, + sortOrder: w.sort_order ?? 0, + ...(w.agent_profile_id ? { agent_profile_id: w.agent_profile_id } : {}), + })), + activeId: workflows[0]?.id ?? null, + }, + userSettings: { ...mappedUserSettings, workspaceId: workspaceId ?? null }, + }; + + return ( + <> + + + + ); +} diff --git a/apps/web/app/settings/workspace/[id]/linear/page.tsx b/apps/web/app/settings/workspace/[id]/linear/page.tsx new file mode 100644 index 0000000000..0df5691471 --- /dev/null +++ b/apps/web/app/settings/workspace/[id]/linear/page.tsx @@ -0,0 +1,15 @@ +import { LinearSettings } from "@/components/linear/linear-settings"; +import { StateProvider } from "@/components/state-provider"; + +export default async function WorkspaceLinearPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + // `key={id}` forces a remount when the URL parameter changes so config / + // form / testResult / teams from a previous workspace can never bleed into + // the next one — Next.js doesn't unmount route segments on dynamic param + // changes by default, only re-renders them. + return ( + + + + ); +} diff --git a/apps/web/components/kanban/kanban-header.tsx b/apps/web/components/kanban/kanban-header.tsx index e6f8a034a0..a1471615f7 100644 --- a/apps/web/components/kanban/kanban-header.tsx +++ b/apps/web/components/kanban/kanban-header.tsx @@ -15,8 +15,10 @@ import { IconTimeline, IconBrandGithub, IconTicket, + IconHexagon, } from "@tabler/icons-react"; import { useJiraAvailable } from "@/components/jira/my-jira/use-jira-availability"; +import { useLinearAvailable } from "@/components/linear/use-linear-availability"; import { KanbanDisplayDropdown } from "../kanban-display-dropdown"; import { ReleaseNotesButton } from "../release-notes/release-notes-button"; import { ReleaseNotesDialog } from "../release-notes/release-notes-dialog"; @@ -88,6 +90,23 @@ function JiraTopbarButton({ workspaceId }: { workspaceId: string | undefined }) ); } +function LinearTopbarButton({ workspaceId }: { workspaceId: string | undefined }) { + const available = useLinearAvailable(workspaceId); + if (!available) return null; + return ( + + + + + Linear + + ); +} + function ViewToggleGroup({ toggleValue, onValueChange, @@ -169,6 +188,7 @@ function TabletHeader({ + {onSearchChange && ( @@ -250,6 +270,7 @@ function DesktopHeader({ + + + + Import from Linear issue URL or identifier + + +
+
Import Linear issue
+ setValue(e.target.value)} + placeholder="ENG-123 or paste issue URL" + className="h-8 text-xs" + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + void submit(); + } + }} + /> + {error && ( +

+ {error} +

+ )} +
+ +
+
+
+ + ); +} diff --git a/apps/web/components/linear/linear-issue-button.tsx b/apps/web/components/linear/linear-issue-button.tsx new file mode 100644 index 0000000000..da541e8543 --- /dev/null +++ b/apps/web/components/linear/linear-issue-button.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useState } from "react"; +import { IconHexagon } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { LinearIssueDialog } from "./linear-issue-dialog"; +import { extractLinearKey } from "./linear-issue-common"; + +export { extractLinearKey }; + +type LinearIssueButtonProps = { + workspaceId: string | null | undefined; + taskTitle: string | undefined | null; +}; + +// LinearIssueButton sits in the task top bar. It extracts a Linear identifier +// from the task title and opens a full issue dialog on click. +export function LinearIssueButton({ workspaceId, taskTitle }: LinearIssueButtonProps) { + const identifier = extractLinearKey(taskTitle); + const [open, setOpen] = useState(false); + + if (!workspaceId || !identifier) return null; + + return ( + <> + + + + + Open Linear issue {identifier} + + + + ); +} diff --git a/apps/web/components/linear/linear-issue-common.test.tsx b/apps/web/components/linear/linear-issue-common.test.tsx new file mode 100644 index 0000000000..ec9d411676 --- /dev/null +++ b/apps/web/components/linear/linear-issue-common.test.tsx @@ -0,0 +1,106 @@ +import { describe, it, expect } from "vitest"; +import { + cleanLinearErrorMessage, + extractLinearKey, + isLinearAuthError, + LINEAR_KEY_RE, + priorityClass, + stateBadgeClass, +} from "./linear-issue-common"; + +describe("LINEAR_KEY_RE / extractLinearKey", () => { + it("matches uppercase TEAM-N identifiers anywhere in the string", () => { + expect(extractLinearKey("ENG-123")).toBe("ENG-123"); + expect(extractLinearKey("[ABC-1] fix login")).toBe("ABC-1"); + expect(extractLinearKey("Migrate Z9-42 to new shape")).toBe("Z9-42"); + }); + + it("rejects identifiers that don't start with a capital letter", () => { + expect(extractLinearKey("eng-123")).toBeNull(); + expect(extractLinearKey("123-456")).toBeNull(); + }); + + it("does not match version-style strings like v1-2", () => { + expect(extractLinearKey("upgrade v1-2 to v1-3")).toBeNull(); + }); + + it("returns null for nullish or empty input", () => { + expect(extractLinearKey(null)).toBeNull(); + expect(extractLinearKey(undefined)).toBeNull(); + expect(extractLinearKey("")).toBeNull(); + }); + + it("requires a word-boundary after the numeric tail", () => { + // The regex is digit-greedy, so a trailing "999" is part of the key. + expect("ENG-123999".match(LINEAR_KEY_RE)?.[0]).toBe("ENG-123999"); + // A trailing letter breaks the closing word boundary, so the whole + // candidate is rejected rather than being truncated. + expect(extractLinearKey("ENG-123abc")).toBeNull(); + }); +}); + +describe("isLinearAuthError", () => { + it("flags 401 and 403 as auth errors", () => { + expect(isLinearAuthError("linear api: status 401: invalid api key")).toBe(true); + expect(isLinearAuthError("linear api: status 403: forbidden")).toBe(true); + }); + + it("does not flag 400 / 404 / 500 as auth errors", () => { + expect(isLinearAuthError("linear api: status 400: bad input")).toBe(false); + expect(isLinearAuthError("linear api: status 404: not found")).toBe(false); + expect(isLinearAuthError("linear api: status 500: server error")).toBe(false); + }); + + it("matches case-insensitively on the status keyword", () => { + expect(isLinearAuthError("STATUS 401")).toBe(true); + }); +}); + +describe("cleanLinearErrorMessage", () => { + it("strips URLs", () => { + expect(cleanLinearErrorMessage("Error see https://example.com/docs/x for details")).toBe( + "Error see for details", + ); + }); + + it("collapses runs of whitespace", () => { + expect(cleanLinearErrorMessage("foo bar\n\nbaz")).toBe("foo bar baz"); + }); + + it("trims leading and trailing whitespace", () => { + expect(cleanLinearErrorMessage(" hi ")).toBe("hi"); + }); +}); + +describe("stateBadgeClass", () => { + it("returns the green palette for done", () => { + expect(stateBadgeClass("done")).toContain("green"); + }); + + it("returns the amber palette for indeterminate (in-progress)", () => { + expect(stateBadgeClass("indeterminate")).toContain("amber"); + }); + + it("returns the blue palette for new", () => { + expect(stateBadgeClass("new")).toContain("blue"); + }); + + it("returns an empty string for unknown / empty category", () => { + expect(stateBadgeClass("")).toBe(""); + expect(stateBadgeClass(undefined)).toBe(""); + }); +}); + +describe("priorityClass", () => { + it("flags urgent, high, medium with distinct color tones", () => { + expect(priorityClass(1)).toContain("red"); + expect(priorityClass(2)).toContain("orange"); + expect(priorityClass(3)).toContain("amber"); + }); + + it("falls back to muted for low (4), none (0), and undefined", () => { + expect(priorityClass(4)).toContain("muted"); + expect(priorityClass(0)).toContain("muted"); + expect(priorityClass(undefined)).toContain("muted"); + }); +}); diff --git a/apps/web/components/linear/linear-issue-common.tsx b/apps/web/components/linear/linear-issue-common.tsx new file mode 100644 index 0000000000..3f38b28e09 --- /dev/null +++ b/apps/web/components/linear/linear-issue-common.tsx @@ -0,0 +1,211 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useState } from "react"; +import { formatDistanceToNow } from "date-fns"; +import { IconLockExclamation } from "@tabler/icons-react"; +import { Avatar, AvatarFallback, AvatarImage } from "@kandev/ui/avatar"; +import { Button } from "@kandev/ui/button"; +import { getLinearIssue, setLinearIssueState } from "@/lib/api/domains/linear-api"; +import type { LinearIssue, LinearStateCategory } from "@/lib/types/linear"; + +// Matches Linear identifiers like ENG-123. Linear team keys are always +// uppercase and 1+ chars; we require a leading capital letter to avoid catching +// random UUID fragments or version strings (v1-2). Anchored on word boundaries +// so we can extract from "ENG-12: fix login". +export const LINEAR_KEY_RE = /\b[A-Z][A-Z0-9]*-\d+\b/; + +export function extractLinearKey(title: string | undefined | null): string | null { + if (!title) return null; + const match = title.match(LINEAR_KEY_RE); + return match ? match[0] : null; +} + +// Map Linear's stateCategory to Tailwind colour classes — same three buckets +// the Jira integration uses so status pills look consistent. +export function stateBadgeClass(category: LinearStateCategory | undefined): string { + switch (category) { + case "done": + return "bg-green-500/15 text-green-700 dark:text-green-400 border-green-500/30"; + case "indeterminate": + return "bg-amber-500/15 text-amber-700 dark:text-amber-400 border-amber-500/30"; + case "new": + return "bg-blue-500/15 text-blue-700 dark:text-blue-400 border-blue-500/30"; + default: + return ""; + } +} + +export function formatRelative(iso: string | undefined): string { + if (!iso) return ""; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + return formatDistanceToNow(d, { addSuffix: true }); +} + +export function PersonCell({ name, avatar }: { name?: string; avatar?: string }) { + if (!name) return Unassigned; + return ( + <> + + {avatar && } + {name.charAt(0)} + + {name} + + ); +} + +// Linear priority enum: 0=none, 1=urgent, 2=high, 3=medium, 4=low. The label +// the API returns is already humanised — we just keep the colour cue. +export function priorityClass(priority: number | undefined): string { + switch (priority) { + case 1: + return "text-red-600 dark:text-red-400"; + case 2: + return "text-orange-600 dark:text-orange-400"; + case 3: + return "text-amber-600 dark:text-amber-400"; + case 4: + return "text-muted-foreground"; + default: + return "text-muted-foreground"; + } +} + +export type IssueState = { + issue: LinearIssue | null; + loading: boolean; + error: string | null; + pendingState: string | null; + load: () => Promise; + handleStateChange: (stateId: string) => Promise; +}; + +// Shared hook for loading a Linear issue and changing its workflow state. +// Mirrors the Jira useTicketState hook. +export function useIssueState( + workspaceId: string, + identifier: string, + enabled: boolean, +): IssueState { + const [issue, setIssue] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [pendingState, setPendingState] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const i = await getLinearIssue(workspaceId, identifier); + setIssue(i); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [workspaceId, identifier]); + + useEffect(() => { + if (!enabled || !workspaceId || !identifier) return; + let cancelled = false; + async function run() { + setIssue(null); + setLoading(true); + setError(null); + try { + const i = await getLinearIssue(workspaceId, identifier); + if (!cancelled) setIssue(i); + } catch (err) { + if (!cancelled) setError(err instanceof Error ? err.message : String(err)); + } finally { + if (!cancelled) setLoading(false); + } + } + void run(); + return () => { + cancelled = true; + }; + }, [enabled, workspaceId, identifier]); + + const handleStateChange = useCallback( + async (stateId: string) => { + if (!issue) return; + setPendingState(stateId); + setError(null); + try { + await setLinearIssueState(workspaceId, issue.id, stateId); + await load(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setPendingState(null); + } + }, + [workspaceId, issue, load], + ); + + return { issue, loading, error, pendingState, load, handleStateChange }; +} + +// Backend wraps Linear errors as `linear api: status N: …`. 401/403 mean the +// API key is invalid; everything else is propagated verbatim. +const AUTH_STATUS_RE = /\bstatus (?:401|403)\b/i; + +export function isLinearAuthError(error: string): boolean { + return AUTH_STATUS_RE.test(error); +} + +const URL_RE = /\bhttps?:\/\/\S+/g; + +export function cleanLinearErrorMessage(error: string): string { + return error.replace(URL_RE, "").replace(/\s+/g, " ").trim(); +} + +type LinearErrorMessageProps = { + error: string; + workspaceId?: string | null; + compact?: boolean; +}; + +export function LinearErrorMessage({ error, workspaceId, compact }: LinearErrorMessageProps) { + const isAuth = isLinearAuthError(error); + const settingsHref = workspaceId ? `/settings/workspace/${workspaceId}/linear` : "/settings"; + + if (compact) { + return ( +
+ + {isAuth ? "Linear authentication required." : cleanLinearErrorMessage(error)} + + {isAuth && ( + + )} +
+ ); + } + + return ( +
+ {isAuth ? ( + <> + +
+

Linear authentication required

+

+ Your Linear API key is invalid or has been revoked. Reconnect to view this issue. +

+
+ + + ) : ( +

{cleanLinearErrorMessage(error)}

+ )} +
+ ); +} diff --git a/apps/web/components/linear/linear-issue-dialog.tsx b/apps/web/components/linear/linear-issue-dialog.tsx new file mode 100644 index 0000000000..4ed2580b7f --- /dev/null +++ b/apps/web/components/linear/linear-issue-dialog.tsx @@ -0,0 +1,320 @@ +"use client"; + +import { IconExternalLink, IconPlus, IconRefresh } from "@tabler/icons-react"; +import { Badge } from "@kandev/ui/badge"; +import { Button } from "@kandev/ui/button"; +import { Dialog, DialogContent, DialogTitle } from "@kandev/ui/dialog"; +import type { LinearIssue, LinearWorkflowState } from "@/lib/types/linear"; +import { + LinearErrorMessage, + PersonCell, + formatRelative, + priorityClass, + stateBadgeClass, + useIssueState, + type IssueState, +} from "./linear-issue-common"; + +type LinearIssueDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + workspaceId: string | null | undefined; + identifier: string | null | undefined; + /** Shown while the full issue is loading so the dialog doesn't flash empty. */ + initialIssue?: LinearIssue | null; + /** When set, the dialog footer shows a Start task button that calls this. */ + onStartTask?: (issue: LinearIssue) => void; +}; + +function dialogTitle(issue: LinearIssue | null, identifier: string | null | undefined): string { + return issue?.title ?? identifier ?? "Linear issue"; +} + +function effectiveIdentifier( + issue: LinearIssue | null, + identifier: string | null | undefined, +): string { + return identifier ?? issue?.identifier ?? ""; +} + +export function LinearIssueDialog({ + open, + onOpenChange, + workspaceId, + identifier, + initialIssue, + onStartTask, +}: LinearIssueDialogProps) { + const enabled = open && !!workspaceId && !!identifier; + const state = useIssueState(workspaceId ?? "", identifier ?? "", enabled); + const issue = state.issue ?? initialIssue ?? null; + + const errorOnly = state.error !== null && issue === null; + const showFooter = issue !== null && onStartTask !== undefined; + + // The "Start task" button hands the dialog's issue back to the caller and + // closes — the caller is responsible for opening the task creation flow. + const handleStart = () => { + if (issue && onStartTask) { + onStartTask(issue); + onOpenChange(false); + } + }; + + return ( + + + {dialogTitle(issue, identifier)} + {errorOnly ? ( +
+ +
+ ) : ( + <> + void state.load()} + /> + + {showFooter && } + + )} +
+
+ ); +} + +function IssueFooter({ onStart }: { onStart: () => void }) { + return ( +
+ +
+ ); +} + +function DialogBody({ + issue, + state, + identifier, + workspaceId, +}: { + issue: LinearIssue | null; + state: IssueState; + identifier: string; + workspaceId: string | null | undefined; +}) { + return ( +
+ {state.error && issue && ( +
+ +
+ )} + {!issue && state.loading && ( +
Loading issue…
+ )} + {issue && } +
+ ); +} + +type TopBarProps = { + issue: LinearIssue | null; + loading: boolean; + onRefresh: () => void; +}; + +function IssueTopBar({ issue, loading, onRefresh }: TopBarProps) { + return ( +
+ {issue?.url && ( + + )} + +
+ ); +} + +function IssueBody({ + issue, + state, + identifier, +}: { + issue: LinearIssue; + state: IssueState; + identifier: string; +}) { + return ( +
+
+ + +
+ +
+ ); +} + +function IssueHeading({ issue, identifier }: { issue: LinearIssue; identifier: string }) { + return ( +
+
+ {identifier} + {issue.teamKey && ( + <> + · + {issue.teamKey} + + )} +
+

{issue.title}

+
+ ); +} + +function SectionHeading({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function DescriptionSection({ description }: { description: string }) { + return ( +
+ Description +
+ {description ? ( +
{description}
+ ) : ( +
No description.
+ )} +
+
+ ); +} + +type StateBlockProps = { + issue: LinearIssue; + states: LinearWorkflowState[] | null | undefined; + pending: string | null; + onChange: (stateId: string) => void; +}; + +// Hide the issue's current state from the available transitions list — moving +// to the same state would be a no-op and clutters the UI. +function visibleStates(issue: LinearIssue, list: LinearWorkflowState[]): LinearWorkflowState[] { + return list.filter((s) => s.id !== issue.stateId); +} + +function StateBlock({ issue, states, pending, onChange }: StateBlockProps) { + const list = states ?? []; + const next = visibleStates(issue, list); + return ( +
+ {issue.stateName && ( +
+ + {issue.stateName} + +
+ )} + {next.length > 0 && ( +
+ {next.map((s) => ( + + ))} +
+ )} +
+ ); +} + +function DetailsCard({ issue }: { issue: LinearIssue }) { + return ( +
+
+ Details +
+
+ + + + + + + + {issue.priorityLabel || "—"} + + + {issue.teamKey} + +
+
+ ); +} + +function DetailRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ); +} + +function MetaFooter({ issue }: { issue: LinearIssue }) { + const updated = formatRelative(issue.updated); + if (!updated) return null; + return ( +
+ Updated {updated} +
+ ); +} diff --git a/apps/web/components/linear/linear-link-button.tsx b/apps/web/components/linear/linear-link-button.tsx new file mode 100644 index 0000000000..5b2b4eb105 --- /dev/null +++ b/apps/web/components/linear/linear-link-button.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { IconLink } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Input } from "@kandev/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { toast } from "sonner"; +import { getLinearIssue } from "@/lib/api/domains/linear-api"; +import { updateTask } from "@/lib/api/domains/kanban-api"; +import { LINEAR_KEY_RE } from "./linear-issue-common"; +import { useLinearAvailable } from "./use-linear-availability"; + +type LinearLinkButtonProps = { + taskId: string | null | undefined; + workspaceId: string | null | undefined; + taskTitle: string | undefined | null; +}; + +// LinearLinkButton lets the user attach a Linear issue to an existing task by +// prepending the issue identifier to its title ("ENG-123: ..."). The +// LinearIssueButton picks up the identifier automatically once the title is +// updated. +export function LinearLinkButton({ taskId, workspaceId, taskTitle }: LinearLinkButtonProps) { + const available = useLinearAvailable(workspaceId); + const [open, setOpen] = useState(false); + const [value, setValue] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const submit = useCallback(async () => { + if (!taskId || !workspaceId) return; + const match = value.toUpperCase().match(LINEAR_KEY_RE); + if (!match) { + setError("Paste a Linear issue URL or identifier (ENG-123)"); + return; + } + const key = match[0]; + setLoading(true); + setError(null); + try { + await getLinearIssue(workspaceId, key); + // Strip any existing leading "ENG-123: " so re-linking replaces the + // prefix instead of stacking it. + const stripped = (taskTitle ?? "").trim().replace(/^[A-Z][A-Z0-9]*-\d+:\s*/, ""); + const newTitle = stripped ? `${key}: ${stripped}` : key; + await updateTask(taskId, { title: newTitle }); + toast.success(`Linked to ${key}`); + setOpen(false); + setValue(""); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [taskId, workspaceId, value, taskTitle]); + + if (!available || !taskId || !workspaceId) return null; + + // Closing the popover discards any stale validation/error so reopening + // starts clean. + const handleOpenChange = (next: boolean) => { + setOpen(next); + if (!next) setError(null); + }; + + return ( + + + + + + + + Link this task to a Linear issue + + +
+
Link to Linear issue
+ setValue(e.target.value)} + placeholder="ENG-123 or paste issue URL" + className="h-8 text-xs" + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + void submit(); + } + }} + /> + {error && ( +

+ {error} +

+ )} +
+ +
+
+
+
+ ); +} diff --git a/apps/web/components/linear/linear-quick-task-launcher.tsx b/apps/web/components/linear/linear-quick-task-launcher.tsx new file mode 100644 index 0000000000..7457761810 --- /dev/null +++ b/apps/web/components/linear/linear-quick-task-launcher.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useMemo } from "react"; +import { useRouter } from "next/navigation"; +import { TaskCreateDialog } from "@/components/task-create-dialog"; +import type { Task, Workflow, WorkflowStep } from "@/lib/types/http"; +import type { LinearIssue } from "@/lib/types/linear"; + +type QuickTaskLauncherProps = { + workspaceId: string | null; + workflows: Workflow[]; + steps: WorkflowStep[]; + issue: LinearIssue | null; + onClose: () => void; +}; + +// Build the prefilled title/description for a Linear-launched task. Mirrors +// the Jira prompt shape so users coming from either integration get a +// consistent skeleton. +function buildDialogState(issue: LinearIssue) { + const title = `${issue.identifier}: ${issue.title}`; + const description = [ + `Linear issue: ${issue.identifier}`, + `URL: ${issue.url}`, + "", + "Title:", + issue.title, + "", + "Description:", + issue.description?.trim() || "(no description)", + ].join("\n"); + return { title, description }; +} + +// Renders the TaskCreateDialog prefilled from a Linear issue. Hidden until an +// `issue` is supplied, which keeps the dialog a single React tree per page. +export function LinearQuickTaskLauncher({ + workspaceId, + workflows, + steps, + issue, + onClose, +}: QuickTaskLauncherProps) { + const router = useRouter(); + + // Default to the first workflow that actually has steps, not just + // workflows[0] — otherwise an empty first workflow blocks Start task even + // when other workflows would work fine. Bundling the lookup, the sort and + // the dialog projection into one memo keeps the launcher's hook list flat + // and avoids cascading useMemo deps. + const launchData = useMemo(() => { + for (const wf of workflows) { + const wfSteps = steps + .filter((s) => s.workflow_id === wf.id) + .sort((a, b) => a.position - b.position); + if (wfSteps.length > 0) { + return { + defaultWorkflow: wf as Workflow | undefined, + defaultStep: wfSteps[0], + stepsForWorkflow: wfSteps.map((s) => ({ + id: s.id, + title: s.name, + events: s.events, + })), + }; + } + } + return { + defaultWorkflow: undefined as Workflow | undefined, + defaultStep: undefined as WorkflowStep | undefined, + stepsForWorkflow: [] as Array<{ + id: string; + title: string; + events: WorkflowStep["events"]; + }>, + }; + }, [workflows, steps]); + const { defaultWorkflow, defaultStep, stepsForWorkflow } = launchData; + const dialog = useMemo(() => (issue ? buildDialogState(issue) : null), [issue]); + + const handleOpenChange = (open: boolean) => { + if (!open) onClose(); + }; + const handleSuccess = (task: Task) => { + onClose(); + router.push(`/t/${task.id}`); + }; + + if (!workspaceId || !defaultWorkflow || !defaultStep || !dialog) return null; + + return ( + + ); +} diff --git a/apps/web/components/linear/linear-settings.tsx b/apps/web/components/linear/linear-settings.tsx new file mode 100644 index 0000000000..11f7a04675 --- /dev/null +++ b/apps/web/components/linear/linear-settings.tsx @@ -0,0 +1,494 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { formatDistanceToNow } from "date-fns"; +import { IconHexagon, IconCheck, IconAlertTriangle } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Card, CardContent } from "@kandev/ui/card"; +import { Input } from "@kandev/ui/input"; +import { Label } from "@kandev/ui/label"; +import { Separator } from "@kandev/ui/separator"; +import { Alert, AlertDescription } from "@kandev/ui/alert"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { Switch } from "@kandev/ui/switch"; +import { useToast } from "@/components/toast-provider"; +import { SettingsSection } from "@/components/settings/settings-section"; +import { useLinearEnabled } from "./use-linear-enabled"; +import { + getLinearConfig, + setLinearConfig, + deleteLinearConfig, + testLinearConnection, + listLinearTeams, +} from "@/lib/api/domains/linear-api"; +import type { LinearConfig, LinearTeam, TestLinearConnectionResult } from "@/lib/types/linear"; + +// How often to re-fetch the config so the auth-health indicator picks up new +// probe results from the backend poller (which runs every 90s). +const STATUS_REFRESH_MS = 90_000; + +type FormState = { + defaultTeamKey: string; + secret: string; +}; + +const emptyForm: FormState = { defaultTeamKey: "", secret: "" }; + +function configToForm(cfg: LinearConfig | null): FormState { + if (!cfg) return emptyForm; + return { defaultTeamKey: cfg.defaultTeamKey, secret: "" }; +} + +function saveLabel(saving: boolean, hasConfig: boolean): string { + if (saving) return "Saving..."; + return hasConfig ? "Update" : "Save"; +} + +type FieldsRowProps = { + form: FormState; + loading: boolean; + update: (key: K, value: FormState[K]) => void; + hasSavedSecret: boolean; + teams: LinearTeam[]; + loadingTeams: boolean; +}; + +function SecretField({ + form, + loading, + update, + hasSavedSecret, +}: Omit) { + return ( +
+ + update("secret", e.target.value)} + disabled={loading} + /> +

+ Create a personal API key at{" "} + + linear.app/settings/account/security + +

+
+ ); +} + +function TeamSelector({ form, loading, update, teams, loadingTeams }: FieldsRowProps) { + return ( +
+ + +
+ ); +} + +function TestResultAlert({ result }: { result: TestLinearConnectionResult | null }) { + if (!result) return null; + return ( + + + {result.ok + ? `Connected as ${result.displayName || result.email || result.userId}${result.orgName ? ` (${result.orgName})` : ""}` + : `Failed: ${result.error}`} + + + ); +} + +type AuthHealth = { + ok: boolean; + error: string; + checkedAt: Date | null; +}; + +function configToHealth(config: LinearConfig | null): AuthHealth | null { + if (!config?.hasSecret) return null; + if (!config.lastCheckedAt) return { ok: false, error: "", checkedAt: null }; + return { + ok: !!config.lastOk, + error: config.lastError ?? "", + checkedAt: new Date(config.lastCheckedAt), + }; +} + +function useTick(intervalMs: number) { + const [, setTick] = useState(0); + useEffect(() => { + const id = setInterval(() => setTick((n) => n + 1), intervalMs); + return () => clearInterval(id); + }, [intervalMs]); +} + +function LastCheckedLabel({ checkedAt }: { checkedAt: Date | null }) { + useTick(30_000); + if (!checkedAt) return null; + return ( + + · checked {formatDistanceToNow(checkedAt, { addSuffix: true })} + + ); +} + +function AuthStatusBanner({ health }: { health: AuthHealth | null }) { + if (!health) return null; + if (!health.checkedAt) { + return ( + + + Waiting for the next backend health check… + + + ); + } + if (health.ok) { + return ( + + + + Authenticated + + + + ); + } + return ( + + + + Authentication failed: {health.error || "unknown error"} + + + + ); +} + +type ActionBarProps = { + saving: boolean; + testing: boolean; + loading: boolean; + hasConfig: boolean; + disableSave: boolean; + disableTest: boolean; + onTest: () => void; + onSave: () => void; + onDelete: () => void; +}; + +function ActionBar({ + saving, + testing, + loading, + hasConfig, + disableSave, + disableTest, + onTest, + onSave, + onDelete, +}: ActionBarProps) { + return ( +
+ + + {hasConfig && ( + + )} +
+ ); +} + +type LinearSettingsProps = { + workspaceId: string; +}; + +type SettingsActionsArgs = { + workspaceId: string; + form: FormState; + setConfig: (cfg: LinearConfig | null) => void; + setForm: (form: FormState) => void; + setTestResult: (r: TestLinearConnectionResult | null) => void; +}; + +function useSettingsActions({ + workspaceId, + form, + setConfig, + setForm, + setTestResult, +}: SettingsActionsArgs) { + const { toast } = useToast(); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + + const handleTest = useCallback(async () => { + setTesting(true); + setTestResult(null); + try { + const res = await testLinearConnection({ + workspaceId, + authMethod: "api_key", + secret: form.secret || undefined, + }); + setTestResult(res); + } catch (err) { + setTestResult({ ok: false, error: String(err) }); + } finally { + setTesting(false); + } + }, [workspaceId, form, setTestResult]); + + const handleSave = useCallback(async () => { + setSaving(true); + try { + const saved = await setLinearConfig({ + workspaceId, + authMethod: "api_key", + defaultTeamKey: form.defaultTeamKey, + secret: form.secret || undefined, + }); + setConfig(saved); + setForm(configToForm(saved)); + setTestResult(null); + toast({ description: "Linear configuration saved", variant: "success" }); + } catch (err) { + toast({ description: `Save failed: ${String(err)}`, variant: "error" }); + } finally { + setSaving(false); + } + }, [workspaceId, form, toast, setConfig, setForm, setTestResult]); + + const handleDelete = useCallback(async () => { + if (!confirm("Remove Linear configuration for this workspace?")) return; + try { + await deleteLinearConfig(workspaceId); + setConfig(null); + setForm(emptyForm); + setTestResult(null); + toast({ description: "Linear configuration removed", variant: "success" }); + } catch (err) { + toast({ description: `Delete failed: ${String(err)}`, variant: "error" }); + } + }, [workspaceId, toast, setConfig, setForm, setTestResult]); + + return { saving, testing, handleTest, handleSave, handleDelete }; +} + +function useTeamsLoader( + workspaceId: string, + hasSecret: boolean | undefined, + lastOk: boolean | undefined, +) { + // `teams === null` means "no fetch attempt yet", so the dropdown can show a + // "Loading…" placeholder without us calling setState synchronously inside + // the effect (which the lint rule forbids). Once a fetch settles we always + // store an array, even if empty. + const [teams, setTeams] = useState(null); + // Fetch teams once a working configuration exists. Skips when there's no + // saved secret (the API call would 503). Stale teams from a previous save + // remain visible after deletion, but the dropdown is gated on hasSecret so + // the user never sees them. + useEffect(() => { + if (!hasSecret) return; + let cancelled = false; + listLinearTeams(workspaceId) + .then((res) => { + if (!cancelled) setTeams(res.teams ?? []); + }) + .catch(() => { + if (!cancelled) setTeams([]); + }); + return () => { + cancelled = true; + }; + }, [workspaceId, hasSecret, lastOk]); + return { teams: teams ?? [], loadingTeams: teams === null && !!hasSecret }; +} + +function useLinearSettings(workspaceId: string) { + const { toast } = useToast(); + const [config, setConfig] = useState(null); + const [form, setForm] = useState(emptyForm); + const [loading, setLoading] = useState(true); + const [testResult, setTestResult] = useState(null); + const health = configToHealth(config); + const { teams, loadingTeams } = useTeamsLoader(workspaceId, config?.hasSecret, config?.lastOk); + + const load = useCallback(async () => { + setLoading(true); + try { + const cfg = await getLinearConfig(workspaceId); + setConfig(cfg); + setForm(configToForm(cfg)); + } catch (err) { + toast({ description: `Failed to load Linear config: ${String(err)}`, variant: "error" }); + } finally { + setLoading(false); + } + }, [workspaceId, toast]); + + useEffect(() => { + void load(); + }, [load]); + + // Background refresh so the auth-health banner picks up new probe results. + useEffect(() => { + const id = setInterval(() => { + getLinearConfig(workspaceId) + .then((cfg) => setConfig(cfg)) + .catch(() => { + /* transient failures are fine — next tick retries */ + }); + }, STATUS_REFRESH_MS); + return () => clearInterval(id); + }, [workspaceId]); + + const update = useCallback( + (key: K, value: FormState[K]) => + setForm((prev) => ({ ...prev, [key]: value })), + [], + ); + + const { saving, testing, handleTest, handleSave, handleDelete } = useSettingsActions({ + workspaceId, + form, + setConfig, + setForm, + setTestResult, + }); + + return { + config, + form, + loading, + saving, + testing, + testResult, + health, + teams, + loadingTeams, + update, + handleTest, + handleSave, + handleDelete, + }; +} + +function EnabledPill({ workspaceId }: { workspaceId: string }) { + const { enabled, setEnabled } = useLinearEnabled(workspaceId); + return ( +
+ + +
+ ); +} + +export function LinearSettings({ workspaceId }: LinearSettingsProps) { + const s = useLinearSettings(workspaceId); + const missingSecret = !s.config?.hasSecret && !s.form.secret; + const disableSave = s.saving || missingSecret; + const disableTest = missingSecret; + + return ( +
+ } + title="Linear integration" + description="Connect this workspace to Linear with a personal API key. Credentials are stored encrypted server-side." + action={} + > + + + + + + + + + + + +
+ ); +} diff --git a/apps/web/components/linear/use-linear-availability.ts b/apps/web/components/linear/use-linear-availability.ts new file mode 100644 index 0000000000..95d0d94867 --- /dev/null +++ b/apps/web/components/linear/use-linear-availability.ts @@ -0,0 +1,44 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getLinearConfig } from "@/lib/api/domains/linear-api"; +import { useLinearEnabled } from "./use-linear-enabled"; + +const LINEAR_STATUS_REFRESH_MS = 90_000; + +type AuthState = { workspaceId: string | undefined; authed: boolean }; + +// Reads the backend-recorded auth health for a workspace. Returns true only +// when a config exists, has a secret, and the most recent probe succeeded. +export function useLinearAuthed(workspaceId: string | undefined): boolean { + const [state, setState] = useState({ workspaceId: undefined, authed: false }); + useEffect(() => { + if (!workspaceId) return; + let cancelled = false; + async function refresh() { + try { + const cfg = await getLinearConfig(workspaceId!); + if (cancelled) return; + setState({ workspaceId, authed: !!cfg?.hasSecret && !!cfg.lastOk }); + } catch { + if (!cancelled) setState({ workspaceId, authed: false }); + } + } + void refresh(); + const id = setInterval(() => void refresh(), LINEAR_STATUS_REFRESH_MS); + return () => { + cancelled = true; + clearInterval(id); + }; + }, [workspaceId]); + return state.workspaceId === workspaceId && state.authed; +} + +// Combined check for showing Linear UI: the workspace toggle is on AND the +// backend reports a configured, healthy connection. +export function useLinearAvailable(workspaceId: string | null | undefined): boolean { + const ws = workspaceId ?? undefined; + const { enabled, loaded } = useLinearEnabled(ws); + const authed = useLinearAuthed(enabled && loaded ? ws : undefined); + return enabled && authed; +} diff --git a/apps/web/components/linear/use-linear-enabled.test.ts b/apps/web/components/linear/use-linear-enabled.test.ts new file mode 100644 index 0000000000..f0b01b9219 --- /dev/null +++ b/apps/web/components/linear/use-linear-enabled.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { useLinearEnabled } from "./use-linear-enabled"; + +const STORAGE_KEY = (workspaceId: string) => `kandev:linear:enabled:${workspaceId}:v1`; + +describe("useLinearEnabled", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + afterEach(() => { + window.localStorage.clear(); + }); + + it("defaults to enabled=true when no localStorage entry exists", async () => { + const { result } = renderHook(() => useLinearEnabled("ws-1")); + await waitFor(() => expect(result.current.loaded).toBe(true)); + expect(result.current.enabled).toBe(true); + }); + + it('reads enabled=false when stored as the literal string "false"', async () => { + window.localStorage.setItem(STORAGE_KEY("ws-1"), "false"); + const { result } = renderHook(() => useLinearEnabled("ws-1")); + await waitFor(() => expect(result.current.loaded).toBe(true)); + expect(result.current.enabled).toBe(false); + }); + + it.each(["true", "1", "yes", "legacy"])( + 'treats persisted value %p as enabled — only the literal "false" disables', + async (storedValue) => { + window.localStorage.setItem(STORAGE_KEY("ws-1"), storedValue); + const { result } = renderHook(() => useLinearEnabled("ws-1")); + await waitFor(() => expect(result.current.loaded).toBe(true)); + expect(result.current.enabled).toBe(true); + }, + ); + + it("setEnabled persists to localStorage and updates state", async () => { + const { result } = renderHook(() => useLinearEnabled("ws-1")); + await waitFor(() => expect(result.current.loaded).toBe(true)); + + act(() => result.current.setEnabled(false)); + + expect(result.current.enabled).toBe(false); + expect(window.localStorage.getItem(STORAGE_KEY("ws-1"))).toBe("false"); + }); + + it("ignores writes when no workspaceId is provided", async () => { + const { result } = renderHook(() => useLinearEnabled(undefined)); + await waitFor(() => expect(result.current.loaded).toBe(true)); + + act(() => result.current.setEnabled(false)); + + // No workspace key means no persisted state and no in-memory flip — the + // setter is a no-op rather than silently writing under a sentinel key. + expect(result.current.enabled).toBe(true); + expect(window.localStorage.length).toBe(0); + }); + + it("re-runs the read when workspaceId changes", async () => { + window.localStorage.setItem(STORAGE_KEY("ws-1"), "false"); + window.localStorage.setItem(STORAGE_KEY("ws-2"), "true"); + + const { result, rerender } = renderHook(({ id }) => useLinearEnabled(id), { + initialProps: { id: "ws-1" as string | undefined }, + }); + await waitFor(() => expect(result.current.loaded).toBe(true)); + expect(result.current.enabled).toBe(false); + + rerender({ id: "ws-2" }); + await waitFor(() => expect(result.current.loaded).toBe(true)); + expect(result.current.enabled).toBe(true); + }); + + it("propagates updates dispatched via the kandev:linear:enabled-changed event", async () => { + const { result } = renderHook(() => useLinearEnabled("ws-1")); + await waitFor(() => expect(result.current.loaded).toBe(true)); + expect(result.current.enabled).toBe(true); + + act(() => { + window.localStorage.setItem(STORAGE_KEY("ws-1"), "false"); + window.dispatchEvent(new Event("kandev:linear:enabled-changed")); + }); + + await waitFor(() => expect(result.current.enabled).toBe(false)); + }); +}); diff --git a/apps/web/components/linear/use-linear-enabled.ts b/apps/web/components/linear/use-linear-enabled.ts new file mode 100644 index 0000000000..185fb4ecb3 --- /dev/null +++ b/apps/web/components/linear/use-linear-enabled.ts @@ -0,0 +1,73 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +// Workspace-scoped: a workspace can have Linear configured but the user may +// want to silence the integration UI without removing credentials. +const storageKey = (workspaceId: string) => `kandev:linear:enabled:${workspaceId}:v1`; + +const SYNC_EVENT = "kandev:linear:enabled-changed"; + +function readEnabled(workspaceId: string): boolean { + if (typeof window === "undefined") return true; + try { + const raw = window.localStorage.getItem(storageKey(workspaceId)); + if (raw === null) return true; + return raw !== "false"; + } catch { + return true; + } +} + +// State tagged with the workspace it was read for. Comparing the tag against +// the currently-requested workspaceId at read time gives us a stale-free +// `loaded` signal without calling setState synchronously inside the effect +// body (which the react-hooks lint rule forbids). +type EnabledState = { workspaceId: string | undefined; enabled: boolean }; + +export function useLinearEnabled(workspaceId: string | undefined) { + const [state, setState] = useState({ workspaceId: undefined, enabled: true }); + + useEffect(() => { + let cancelled = false; + async function init() { + if (cancelled) return; + const next = workspaceId ? readEnabled(workspaceId) : true; + setState({ workspaceId, enabled: next }); + } + void init(); + if (!workspaceId) return; + const onChange = () => { + const next = readEnabled(workspaceId); + setState({ workspaceId, enabled: next }); + }; + window.addEventListener("storage", onChange); + window.addEventListener(SYNC_EVENT, onChange); + return () => { + cancelled = true; + window.removeEventListener("storage", onChange); + window.removeEventListener(SYNC_EVENT, onChange); + }; + }, [workspaceId]); + + const setEnabled = useCallback( + (next: boolean) => { + if (!workspaceId || typeof window === "undefined") return; + try { + window.localStorage.setItem(storageKey(workspaceId), String(next)); + window.dispatchEvent(new Event(SYNC_EVENT)); + } catch { + // Quota or private mode: state still updates in-memory. + } + setState({ workspaceId, enabled: next }); + }, + [workspaceId], + ); + + // `loaded` flips true once the state's workspace tag matches the caller's; + // this naturally resets when workspaceId changes without us ever writing a + // synchronous `setLoaded(false)` inside the effect. + const loaded = state.workspaceId === workspaceId; + const enabled = loaded ? state.enabled : true; + return { enabled, setEnabled, loaded }; +} diff --git a/apps/web/components/settings/settings-app-sidebar.tsx b/apps/web/components/settings/settings-app-sidebar.tsx index 6db4ac4626..0a426a106e 100644 --- a/apps/web/components/settings/settings-app-sidebar.tsx +++ b/apps/web/components/settings/settings-app-sidebar.tsx @@ -13,6 +13,7 @@ import { IconKey, IconMessageCircle, IconBrandGithub, + IconHexagon, IconSparkles, IconWand, IconGitBranch, @@ -109,6 +110,7 @@ function WorkspacesSidebarSection({ pathname, workspaces }: WorkspacesSidebarSec const repositoriesPath = `${workspacePath}/repositories`; const githubPath = `${workspacePath}/github`; const jiraPath = `${workspacePath}/jira`; + const linearPath = `${workspacePath}/linear`; return ( @@ -154,6 +156,14 @@ function WorkspacesSidebarSection({ pathname, workspaces }: WorkspacesSidebarSec + + + + + Linear + + + ); diff --git a/apps/web/components/task-create-dialog-form-body.tsx b/apps/web/components/task-create-dialog-form-body.tsx index a46f9a13d6..92b80b883a 100644 --- a/apps/web/components/task-create-dialog-form-body.tsx +++ b/apps/web/components/task-create-dialog-form-body.tsx @@ -14,6 +14,7 @@ import { computeBranchPlaceholder, } from "@/components/task-create-dialog-fresh-branch"; import type { JiraTicket } from "@/lib/types/jira"; +import type { LinearIssue } from "@/lib/types/linear"; type SelectorOption = { value: string; @@ -360,8 +361,23 @@ export type DialogPromptSectionProps = { enhance?: { onEnhance: () => void; isLoading: boolean; isConfigured: boolean }; workspaceId?: string | null; onJiraImport?: (ticket: JiraTicket) => void; + onLinearImport?: (issue: LinearIssue) => void; }; +// importBindings collapses the optional Jira/Linear import callbacks into the +// shape TaskFormInputs expects, dropping integrations that aren't applicable +// (session mode, started tasks, or no callback wired). Keeps DialogPromptSection +// below the cyclomatic-complexity bar. +function importBindings( + enabled: boolean, + workspaceId: string | null, + isPassthroughProfile: boolean, + onImport: ((value: T) => void) | undefined, +) { + if (!enabled || !onImport) return undefined; + return { workspaceId, disabled: isPassthroughProfile, onImport }; +} + export function DialogPromptSection({ isSessionMode, isTaskStarted, @@ -373,8 +389,10 @@ export function DialogPromptSection({ enhance, workspaceId, onJiraImport, + onLinearImport, }: DialogPromptSectionProps) { - const showJiraImport = !isSessionMode && !isTaskStarted && !!onJiraImport; + const importsEnabled = !isSessionMode && !isTaskStarted; + const ws = workspaceId ?? null; return ( <> {isPassthroughProfile && hasDescription && (

Prompt ignored — passthrough mode active

diff --git a/apps/web/components/task-create-dialog-selectors.tsx b/apps/web/components/task-create-dialog-selectors.tsx index a16bbf7a9f..09c26341bf 100644 --- a/apps/web/components/task-create-dialog-selectors.tsx +++ b/apps/web/components/task-create-dialog-selectors.tsx @@ -17,7 +17,9 @@ import type { ContextItem, ImageContextItem, FileAttachmentContextItem } from "@ import type { TaskFormInputsHandle } from "@/components/task-create-dialog-types"; import { EnhancePromptButton } from "@/components/enhance-prompt-button"; import { JiraImportBar } from "@/components/jira/jira-import-bar"; +import { LinearImportBar } from "@/components/linear/linear-import-bar"; import type { JiraTicket } from "@/lib/types/jira"; +import type { LinearIssue } from "@/lib/types/linear"; const CURSOR_POINTER_CLASS = "cursor-pointer"; @@ -259,6 +261,11 @@ type TaskFormInputsProps = { disabled?: boolean; onImport: (ticket: JiraTicket) => void; }; + linearImport?: { + workspaceId: string | null; + disabled?: boolean; + onImport: (issue: LinearIssue) => void; + }; }; function useFileAttachments() { @@ -474,6 +481,7 @@ type FormInputsToolbarProps = { isEnhancingPrompt?: boolean; isUtilityConfigured?: boolean; jiraImport?: TaskFormInputsProps["jiraImport"]; + linearImport?: TaskFormInputsProps["linearImport"]; }; function FormInputsToolbar({ @@ -483,6 +491,7 @@ function FormInputsToolbar({ isEnhancingPrompt, isUtilityConfigured, jiraImport, + linearImport, }: FormInputsToolbarProps) { return (
@@ -501,6 +510,13 @@ function FormInputsToolbar({ onImport={jiraImport.onImport} /> )} + {linearImport && ( + + )}
); } @@ -518,6 +534,7 @@ export const TaskFormInputs = memo(function TaskFormInputs({ isEnhancingPrompt, isUtilityConfigured, jiraImport, + linearImport, }: TaskFormInputsProps) { const fileInputRef = useRef(null); const { attachments, isDragging, setIsDragging, addFiles, handleRemoveAttachment } = @@ -584,6 +601,7 @@ export const TaskFormInputs = memo(function TaskFormInputs({ isEnhancingPrompt={isEnhancingPrompt} isUtilityConfigured={isUtilityConfigured} jiraImport={jiraImport} + linearImport={linearImport} /> void; + onLinearImport?: (issue: LinearIssue) => void; branchOptions: ReturnType; branchesLoading: boolean; agentProfileOptions: ReturnType; @@ -117,6 +119,7 @@ function DialogFormBody({ hasDescription, workspaceId, onJiraImport, + onLinearImport, branchOptions, branchesLoading, agentProfileOptions, @@ -153,6 +156,7 @@ function DialogFormBody({ enhance={enhance} workspaceId={workspaceId} onJiraImport={onJiraImport} + onLinearImport={onLinearImport} /> {!isSessionMode && renderCreateEditSelectors({ @@ -290,6 +294,22 @@ function useJiraImportHandler(fs: DialogFormState) { ); } +function useLinearImportHandler(fs: DialogFormState) { + return useCallback( + (issue: LinearIssue) => { + const title = `[${issue.identifier}] ${issue.title}`; + fs.setTaskName(title); + fs.setHasTitle(true); + const description = issue.description?.trim() + ? `${issue.description}\n\n---\nLinear: ${issue.url}` + : `Linear: ${issue.url}`; + fs.descriptionInputRef.current?.setValue(description); + fs.setHasDescription(true); + }, + [fs], + ); +} + type SubmitWiringArgs = { props: TaskCreateDialogProps; fs: ReturnType; @@ -425,18 +445,88 @@ function useTaskCreateDialogSetup(props: TaskCreateDialogProps) { handleKeyDown, enhance: useEnhanceForDialog(fs), handleJiraImport: useJiraImportHandler(fs), + handleLinearImport: useLinearImportHandler(fs), }; } +type DialogFormProps = { + setup: ReturnType; + workspaceId: string | null; +}; + +function DialogForm({ setup, workspaceId }: DialogFormProps) { + const { fs, isSessionMode, isCreateMode, isTaskStarted, workflows, agentProfiles } = setup; + const { snapshots, branchesLoading, computed, handlers, handleKeyDown } = setup; + const { handleJiraImport, handleLinearImport } = setup; + const { handleSubmit, handleUpdateWithoutAgent, handleCreateWithoutAgent } = setup.submitHandlers; + const { handleCreateWithPlanMode, handleCancel } = setup.submitHandlers; + return ( +
+ + + + + + ); +} + export function TaskCreateDialog(props: TaskCreateDialogProps) { const { open, onOpenChange, initialValues, workspaceId } = props; const setup = useTaskCreateDialogSetup(props); - const { fs, isSessionMode, isEditMode, isCreateMode, isTaskStarted } = setup; - const { sessionRepoName, workflows, agentProfiles, snapshots } = setup; - const { repositoriesLoading, branchesLoading, computed, handlers, handleKeyDown } = setup; - const { handleSubmit, handleUpdateWithoutAgent, handleCreateWithoutAgent } = setup.submitHandlers; - const { handleCreateWithPlanMode, handleCancel } = setup.submitHandlers; - const { handleJiraImport } = setup; + const { fs, isCreateMode, isEditMode, isTaskStarted } = setup; + const { repositoriesLoading, computed, handlers } = setup; return ( -
- - - - - +
diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index efbe0e24db..eb46824f7e 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -23,6 +23,10 @@ import { VcsSplitButton } from "@/components/vcs-split-button"; import { PRTopbarButton } from "@/components/github/pr-topbar-button"; import { JiraTicketButton, extractJiraKey } from "@/components/jira/jira-ticket-button"; import { JiraLinkButton } from "@/components/jira/jira-link-button"; +import { LinearIssueButton, extractLinearKey } from "@/components/linear/linear-issue-button"; +import { LinearLinkButton } from "@/components/linear/linear-link-button"; +import { useJiraAvailable } from "@/components/jira/my-jira/use-jira-availability"; +import { useLinearAvailable } from "@/components/linear/use-linear-availability"; import { PortForwardButton } from "@/components/task/port-forward-dialog"; import { WorkflowStepper, type WorkflowStepperStep } from "@/components/task/workflow-stepper"; import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip"; @@ -149,6 +153,40 @@ const TaskTopBar = memo(function TaskTopBar({ ); }); +// IssueTrackerButtons picks the right ticket button for a task. Jira and +// Linear use the same TEAM-NUMBER identifier shape, so both `extract` calls +// would match "ENG-123" — we resolve ambiguity by preferring whichever +// integration is currently available for the workspace, with Jira winning the +// tie-break since it shipped first. When the title carries no identifier, +// both link buttons are offered (each gated on its own availability). +function IssueTrackerButtons({ + taskId, + workspaceId, + taskTitle, +}: { + taskId: string | null | undefined; + workspaceId: string | null | undefined; + taskTitle: string | null | undefined; +}) { + const jiraAvailable = useJiraAvailable(workspaceId); + const linearAvailable = useLinearAvailable(workspaceId); + const jiraKey = extractJiraKey(taskTitle); + const linearKey = extractLinearKey(taskTitle); + + if (jiraKey && jiraAvailable) { + return ; + } + if (linearKey && linearAvailable) { + return ; + } + return ( + <> + + + + ); +} + /** Left section: breadcrumbs, branch pill */ function TopBarLeft({ taskId, @@ -349,11 +387,7 @@ function TopBarRight({ isAgentctlReady={isAgentctlReady} /> - {extractJiraKey(taskTitle) ? ( - - ) : ( - - )} + diff --git a/apps/web/lib/api/domains/linear-api.test.ts b/apps/web/lib/api/domains/linear-api.test.ts new file mode 100644 index 0000000000..666884925e --- /dev/null +++ b/apps/web/lib/api/domains/linear-api.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Pin the backend config to a deterministic base so URL assertions don't +// depend on whatever environment the tests inherit. +vi.mock("@/lib/config", () => ({ + getBackendConfig: () => ({ apiBaseUrl: "http://api.test" }), +})); + +import { + deleteLinearConfig, + getLinearConfig, + getLinearIssue, + listLinearStates, + listLinearTeams, + searchLinearIssues, + setLinearConfig, + setLinearIssueState, + testLinearConnection, +} from "./linear-api"; + +// Repeated literals factored out so the assertions read as the route shape +// they care about, and so the duplicate-strings ESLint rule (currently warn, +// trending toward error) doesn't trip on each new test that joins this file. +const WS_ID = "ws-1"; +const BASE = "http://api.test/api/v1/linear"; +const CONFIG_URL = `${BASE}/config`; +const CONFIG_URL_FOR_WS = `${CONFIG_URL}?workspace_id=${WS_ID}`; +const AUTH = "api_key" as const; + +type FetchInput = Parameters[0]; +type FetchInit = Parameters[1]; + +const fetchSpy = vi.fn<[FetchInput, FetchInit?], Promise>(); + +beforeEach(() => { + fetchSpy.mockReset(); + vi.stubGlobal("fetch", fetchSpy); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function jsonResponse(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + ...init, + }); +} + +function noContent(): Response { + return new Response(null, { status: 204 }); +} + +function lastCall(): { url: string; init: FetchInit | undefined } { + const call = fetchSpy.mock.calls.at(-1); + if (!call) throw new Error("expected fetch to have been called"); + return { url: String(call[0]), init: call[1] }; +} + +describe("getLinearConfig", () => { + it("returns null on 204 No Content (not configured yet)", async () => { + fetchSpy.mockResolvedValueOnce(noContent()); + const cfg = await getLinearConfig(WS_ID); + expect(cfg).toBeNull(); + }); + + it("URL-encodes the workspace id", async () => { + fetchSpy.mockResolvedValueOnce(noContent()); + await getLinearConfig("ws/with space"); + expect(lastCall().url).toBe(`${CONFIG_URL}?workspace_id=ws%2Fwith%20space`); + }); + + it("returns the parsed config on 200", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse({ + workspaceId: WS_ID, + authMethod: AUTH, + defaultTeamKey: "ENG", + hasSecret: true, + lastOk: true, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }), + ); + const cfg = await getLinearConfig(WS_ID); + expect(cfg?.defaultTeamKey).toBe("ENG"); + }); +}); + +describe("setLinearConfig", () => { + it("POSTs the payload to /api/v1/linear/config", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ workspaceId: WS_ID })); + await setLinearConfig({ workspaceId: WS_ID, authMethod: AUTH, secret: "tok" }); + const { url, init } = lastCall(); + expect(url).toBe(CONFIG_URL); + expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toMatchObject({ + workspaceId: WS_ID, + secret: "tok", + }); + }); +}); + +describe("deleteLinearConfig", () => { + it("issues DELETE with the workspace id in the query string", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ deleted: true })); + await deleteLinearConfig(WS_ID); + const { url, init } = lastCall(); + expect(url).toBe(CONFIG_URL_FOR_WS); + expect(init?.method).toBe("DELETE"); + }); +}); + +describe("testLinearConnection", () => { + it("POSTs to /api/v1/linear/config/test", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ ok: true })); + await testLinearConnection({ workspaceId: WS_ID, authMethod: AUTH, secret: "x" }); + const { url, init } = lastCall(); + expect(url).toBe(`${CONFIG_URL}/test`); + expect(init?.method).toBe("POST"); + }); +}); + +describe("listLinearTeams + listLinearStates", () => { + it("listLinearTeams targets /api/v1/linear/teams", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ teams: [] })); + await listLinearTeams(WS_ID); + expect(lastCall().url).toBe(`${BASE}/teams?workspace_id=${WS_ID}`); + }); + + it("listLinearStates includes the team_key", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ states: [] })); + await listLinearStates(WS_ID, "ENG"); + expect(lastCall().url).toBe(`${BASE}/states?workspace_id=${WS_ID}&team_key=ENG`); + }); +}); + +describe("searchLinearIssues", () => { + it("joins stateIds as a CSV in the state_ids query param", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ issues: [], maxResults: 25, isLast: true })); + await searchLinearIssues(WS_ID, { stateIds: ["s1", "s2", "s3"] }); + const { url } = lastCall(); + expect(url).toContain("state_ids=s1%2Cs2%2Cs3"); + }); + + it("omits empty optional filters from the URL", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ issues: [], maxResults: 25, isLast: true })); + await searchLinearIssues(WS_ID, {}); + const { url } = lastCall(); + expect(url).toContain(`workspace_id=${WS_ID}`); + expect(url).not.toContain("query="); + expect(url).not.toContain("team_key="); + expect(url).not.toContain("assigned="); + expect(url).not.toContain("state_ids="); + expect(url).not.toContain("page_token="); + expect(url).not.toContain("max_results="); + }); + + it("encodes a multi-word query string", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ issues: [], maxResults: 25, isLast: true })); + await searchLinearIssues(WS_ID, { query: "fix login & signup" }); + const { url } = lastCall(); + // URLSearchParams uses + for spaces, %26 for & — both indicate proper encoding. + expect(url).toContain("query=fix+login+%26+signup"); + }); +}); + +describe("getLinearIssue", () => { + it("URL-encodes the identifier as a path segment and workspaceId as a query param", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ id: "i1", identifier: "ENG/1" })); + await getLinearIssue("ws/space", "ENG/1"); + expect(lastCall().url).toBe(`${BASE}/issues/ENG%2F1?workspace_id=ws%2Fspace`); + }); +}); + +describe("setLinearIssueState", () => { + it("POSTs { stateId } in the body to the issue's /state route", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ transitioned: true })); + await setLinearIssueState(WS_ID, "ENG-1", "state-id-123"); + const { url, init } = lastCall(); + expect(url).toBe(`${BASE}/issues/ENG-1/state?workspace_id=${WS_ID}`); + expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toEqual({ stateId: "state-id-123" }); + }); +}); diff --git a/apps/web/lib/api/domains/linear-api.ts b/apps/web/lib/api/domains/linear-api.ts new file mode 100644 index 0000000000..4fa89dc9ce --- /dev/null +++ b/apps/web/lib/api/domains/linear-api.ts @@ -0,0 +1,113 @@ +import { fetchJson, type ApiRequestOptions } from "../client"; +import type { + LinearConfig, + LinearIssue, + LinearSearchFilter, + LinearSearchResult, + LinearTeam, + LinearWorkflowState, + SetLinearConfigRequest, + TestLinearConnectionResult, +} from "@/lib/types/linear"; + +// getLinearConfig returns null when the backend responds 204 (no config yet). +export async function getLinearConfig( + workspaceId: string, + options?: ApiRequestOptions, +): Promise { + const res = await fetchJson( + `/api/v1/linear/config?workspace_id=${encodeURIComponent(workspaceId)}`, + options, + ); + return res ?? null; +} + +export async function setLinearConfig( + payload: SetLinearConfigRequest, + options?: ApiRequestOptions, +) { + return fetchJson(`/api/v1/linear/config`, { + ...options, + init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify(payload) }, + }); +} + +export async function deleteLinearConfig(workspaceId: string, options?: ApiRequestOptions) { + return fetchJson<{ deleted: boolean }>( + `/api/v1/linear/config?workspace_id=${encodeURIComponent(workspaceId)}`, + { ...options, init: { ...(options?.init ?? {}), method: "DELETE" } }, + ); +} + +export async function testLinearConnection( + payload: SetLinearConfigRequest, + options?: ApiRequestOptions, +) { + return fetchJson(`/api/v1/linear/config/test`, { + ...options, + init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify(payload) }, + }); +} + +export async function listLinearTeams(workspaceId: string, options?: ApiRequestOptions) { + return fetchJson<{ teams: LinearTeam[] }>( + `/api/v1/linear/teams?workspace_id=${encodeURIComponent(workspaceId)}`, + options, + ); +} + +export async function listLinearStates( + workspaceId: string, + teamKey: string, + options?: ApiRequestOptions, +) { + return fetchJson<{ states: LinearWorkflowState[] }>( + `/api/v1/linear/states?workspace_id=${encodeURIComponent(workspaceId)}&team_key=${encodeURIComponent(teamKey)}`, + options, + ); +} + +export async function getLinearIssue( + workspaceId: string, + identifier: string, + options?: ApiRequestOptions, +) { + return fetchJson( + `/api/v1/linear/issues/${encodeURIComponent(identifier)}?workspace_id=${encodeURIComponent(workspaceId)}`, + options, + ); +} + +export async function searchLinearIssues( + workspaceId: string, + params: LinearSearchFilter & { pageToken?: string; maxResults?: number }, + options?: ApiRequestOptions, +) { + const search = new URLSearchParams({ workspace_id: workspaceId }); + if (params.query) search.set("query", params.query); + if (params.teamKey) search.set("team_key", params.teamKey); + if (params.stateIds?.length) search.set("state_ids", params.stateIds.join(",")); + if (params.assigned) search.set("assigned", params.assigned); + if (params.pageToken) search.set("page_token", params.pageToken); + if (params.maxResults) search.set("max_results", String(params.maxResults)); + return fetchJson(`/api/v1/linear/issues?${search.toString()}`, options); +} + +export async function setLinearIssueState( + workspaceId: string, + issueID: string, + stateID: string, + options?: ApiRequestOptions, +) { + return fetchJson<{ transitioned: boolean }>( + `/api/v1/linear/issues/${encodeURIComponent(issueID)}/state?workspace_id=${encodeURIComponent(workspaceId)}`, + { + ...options, + init: { + ...(options?.init ?? {}), + method: "POST", + body: JSON.stringify({ stateId: stateID }), + }, + }, + ); +} diff --git a/apps/web/lib/types/linear.ts b/apps/web/lib/types/linear.ts new file mode 100644 index 0000000000..0af109b6ea --- /dev/null +++ b/apps/web/lib/types/linear.ts @@ -0,0 +1,93 @@ +export type LinearAuthMethod = "api_key"; + +export interface LinearConfig { + workspaceId: string; + authMethod: LinearAuthMethod; + defaultTeamKey: string; + hasSecret: boolean; + /** Captured from the most recent successful probe; used to build canonical URLs. */ + orgSlug?: string; + /** Last time the backend probed credentials, or null if never probed. */ + lastCheckedAt?: string | null; + /** Whether the most recent backend probe succeeded. */ + lastOk: boolean; + /** Error message from the most recent failed probe; empty when ok or unprobed. */ + lastError?: string; + createdAt: string; + updatedAt: string; +} + +export interface SetLinearConfigRequest { + workspaceId: string; + authMethod: LinearAuthMethod; + defaultTeamKey?: string; + secret?: string; +} + +export interface TestLinearConnectionResult { + ok: boolean; + userId?: string; + displayName?: string; + email?: string; + orgSlug?: string; + orgName?: string; + error?: string; +} + +export interface LinearWorkflowState { + id: string; + name: string; + /** backlog | unstarted | started | completed | canceled | triage */ + type: string; + color?: string; + position: number; +} + +/** Three-bucket category Kandev uses across integrations to style status pills. */ +export type LinearStateCategory = "new" | "indeterminate" | "done" | ""; + +export interface LinearIssue { + id: string; + /** Human identifier, e.g. "ENG-123". */ + identifier: string; + title: string; + description: string; + stateId: string; + stateName: string; + stateType: string; + stateCategory: LinearStateCategory; + teamId: string; + teamKey: string; + /** 0=none, 1=urgent, 2=high, 3=medium, 4=low. */ + priority: number; + priorityLabel?: string; + assigneeName?: string; + assigneeEmail?: string; + assigneeIcon?: string; + creatorName?: string; + creatorIcon?: string; + updated?: string; + url: string; + states: LinearWorkflowState[]; +} + +export interface LinearTeam { + id: string; + key: string; + name: string; +} + +export interface LinearSearchFilter { + query?: string; + teamKey?: string; + stateIds?: string[]; + /** "me" | "unassigned" | "" (any) */ + assigned?: string; +} + +export interface LinearSearchResult { + issues: LinearIssue[]; + maxResults: number; + isLast: boolean; + nextPageToken?: string; +}