feat: add Linear integration (#736)

* feat: add Linear integration

Mirrors the existing Jira integration so a workspace can connect to Linear
with a personal API key, browse and search issues, and start a Kandev task
from any issue. Backend uses Linear's GraphQL API with a workspace-scoped
config + secret store and a 90s auth-health poller; the frontend adds a
settings page, an issue dialog with state transitions, link/import buttons
in the task UI, and a paginated /linear browse page with a Start task flow.

* fix(linear): address PR review feedback

- Route httpListStates through Service.ListStates instead of clientFor
- Extract repeated workspace_id required string into a constant
- Log GetConfig errors during credential resolution instead of dropping them
- Clear popover error state on close in import + link buttons
- Pick the first workflow with steps in the quick task launcher (don't hard-pin to workflows[0])
- Reset useLinearEnabled state on workspace change via tag-based pattern
- Add cursor-pointer to anchor + Link elements per project convention
- Add tests: handlers_test.go, linear-api.test.ts, use-linear-enabled.test.ts, linear-issue-common.test.tsx
- Document internal/jira and internal/linear in CLAUDE.md/AGENTS.md

* fix(linear): address second-round PR review feedback

- Round Linear's fractional state position instead of truncating it via
  int(math.Round(s.Position)) so adjacent fractional positions don't collapse
- Force LinearSettings to remount on workspace change with key={id} so the
  previous workspace's config / form / testResult / teams cannot bleed into
  the next workspace before the new fetches settle
- Broaden the legacy-value useLinearEnabled test to cover "true", "1", "yes",
  and "legacy" so a regression to strict value === "true" would fail

* fix(linear): address third-round PR review feedback

- Propagate context.Canceled / context.DeadlineExceeded from ListStates in
  GetIssue instead of swallowing them — a caller cancellation must not return
  a partial-success response
- Drop the one-off contains() test helper in handlers_test.go and use
  strings.Contains directly so the assertion is self-documenting
- Add getLinearIssue URL-shape test that locks identifier-as-path-segment
  vs workspaceId-as-query-param so accidentally swapping them would fail

* test(linear): extract repeated literals in linear-api.test.ts

Pulls "ws-1", the API base URL, and the auth-method literal into module-
level constants so the assertions read as the route shape they care about
and the duplicate-strings ESLint rule (currently warn, threshold 4)
stays quiet as the file grows.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-05-01 10:50:14 +01:00
committed by GitHub
parent 9496068558
commit 7b5b3f329d
42 changed files with 5894 additions and 79 deletions
+2
View File
@@ -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
+6
View File
@@ -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)")
+8
View File
@@ -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)
}
+49
View File
@@ -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
+2
View File
@@ -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
}
+33
View File
@@ -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)
}
@@ -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
}
@@ -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")
}
}
+385
View File
@@ -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})
}
}
@@ -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)
}
}
}
+135
View File
@@ -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/<slug>/issue/<id>) 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"
}
+96
View File
@@ -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)
}
}
+187
View File
@@ -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)
}
}
+20
View File
@@ -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
}
+333
View File
@@ -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
}
@@ -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)
}
}
+182
View File
@@ -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, &notnull, &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
}
+161
View File
@@ -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)
}
}
+11
View File
@@ -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"
+525
View File
@@ -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 (
<div className="p-6 max-w-2xl">
<Alert>
<AlertDescription>
Linear is not configured for this workspace.{" "}
<Link
href={workspaceId ? `/settings/workspace/${workspaceId}/linear` : "/settings"}
className="underline font-medium cursor-pointer"
>
Configure Linear
</Link>{" "}
to see your issues here.
</AlertDescription>
</Alert>
</div>
);
}
function useLinearPageData(workspaceId?: string) {
const [loaded, setLoaded] = useState(false);
const [configured, setConfigured] = useState(false);
const [teams, setTeams] = useState<LinearTeam[]>([]);
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<LinearIssue[]>([]);
const [page, setPage] = useState(1);
const [isLast, setIsLast] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// tokens[i] is the page_token for page i+1; tokens[0] is always "".
const tokensRef = useRef<string[]>([""]);
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 <span className="text-xs text-muted-foreground">Unassigned</span>;
}
return (
<div className="flex items-center gap-1.5 min-w-0">
<Avatar size="sm" className="size-5">
{issue.assigneeIcon && <AvatarImage src={issue.assigneeIcon} alt={issue.assigneeName} />}
<AvatarFallback className="text-[10px]">{issue.assigneeName.charAt(0)}</AvatarFallback>
</Avatar>
<span className="text-xs text-muted-foreground truncate">{issue.assigneeName}</span>
</div>
);
}
function IssueRow({
issue,
onOpen,
onStartTask,
}: {
issue: LinearIssue;
onOpen: (i: LinearIssue) => void;
onStartTask: (i: LinearIssue) => void;
}) {
const relative = formatRelative(issue.updated);
return (
<div className="flex items-start gap-3 py-3 border-b last:border-b-0">
<button
type="button"
onClick={() => onOpen(issue)}
className="flex-1 min-w-0 space-y-1 text-left cursor-pointer rounded -mx-2 px-2 py-1 hover:bg-muted/50 transition-colors"
title="Open issue details"
>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{issue.identifier}</span>
{issue.priorityLabel && <span>· {issue.priorityLabel}</span>}
</div>
<div className="text-sm font-medium truncate" title={issue.title}>
{issue.title}
</div>
<div className="flex items-center gap-2 flex-wrap">
{issue.stateName && (
<Badge variant="outline" className={stateBadgeClass(issue.stateCategory)}>
{issue.stateName}
</Badge>
)}
<AssigneeCell issue={issue} />
{relative && <span className="text-xs text-muted-foreground">· updated {relative}</span>}
</div>
</button>
<div className="flex items-center gap-1 shrink-0">
<Button asChild variant="ghost" size="icon-sm" className="cursor-pointer">
<a href={issue.url} target="_blank" rel="noreferrer" title="Open in Linear">
<IconExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
<Button
size="sm"
variant="outline"
className="cursor-pointer h-7 px-2 gap-1 text-xs"
onClick={() => onStartTask(issue)}
>
<IconPlus className="h-3.5 w-3.5" />
Start task
</Button>
</div>
</div>
);
}
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 (
<div className="flex flex-wrap items-center gap-2 px-6 py-3 border-b">
<div className="relative flex-1 min-w-[280px]">
<IconSearch className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search title or description"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-8"
/>
</div>
<Select
value={teamKey || "__all__"}
onValueChange={(v) => setTeamKey(v === "__all__" ? "" : v)}
>
<SelectTrigger className="w-44">
<SelectValue placeholder="All teams" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All teams</SelectItem>
{teams.map((t) => (
<SelectItem key={t.id} value={t.key}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={assigned || "__any__"}
onValueChange={(v) => setAssigned(v === "__any__" ? "" : v)}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__any__">Any assignee</SelectItem>
<SelectItem value="me">Assigned to me</SelectItem>
<SelectItem value="unassigned">Unassigned</SelectItem>
</SelectContent>
</Select>
</div>
);
}
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 (
<div className="flex items-center justify-between px-6 py-3 border-t shrink-0">
<div className="text-xs text-muted-foreground tabular-nums">
{itemCount === 0 ? "No results" : `${start}–${end}`}
</div>
<Pagination className="mx-0 w-auto justify-end">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault();
if (!prevDisabled) onPrev();
}}
aria-disabled={prevDisabled}
className={prevDisabled ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
<PaginationItem>
<span className="px-3 text-sm tabular-nums">Page {page}</span>
</PaginationItem>
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault();
if (!nextDisabled) onNext();
}}
aria-disabled={nextDisabled}
className={nextDisabled ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
);
}
function PageShell({ children }: { children: React.ReactNode }) {
return (
<div className="flex flex-col h-full">
<PageTopbar title="Linear" icon={<IconHexagon className="h-4 w-4" />} />
{children}
</div>
);
}
function DisabledNotice({ workspaceId }: { workspaceId: string }) {
return (
<div className="p-6 max-w-2xl">
<Alert>
<AlertDescription>
Linear integration is disabled for this workspace.{" "}
<Link
href={`/settings/workspace/${workspaceId}/linear`}
className="underline font-medium cursor-pointer"
>
Re-enable it in settings
</Link>
.
</AlertDescription>
</Alert>
</div>
);
}
function ResultsArea({
search,
empty,
workspaceId,
onOpen,
onStartTask,
}: {
search: SearchState;
empty: boolean;
workspaceId: string;
onOpen: (issue: LinearIssue) => void;
onStartTask: (issue: LinearIssue) => void;
}) {
return (
<div className="flex-1 overflow-y-auto px-6 py-3">
{search.error && !search.loading && (
<div className="py-8 flex justify-center">
<LinearErrorMessage error={search.error} workspaceId={workspaceId} />
</div>
)}
{!search.error && empty && (
<div className="text-sm text-muted-foreground py-12 text-center">
No issues match your filters.
</div>
)}
{search.items.map((issue) => (
<IssueRow key={issue.id} issue={issue} onOpen={onOpen} onStartTask={onStartTask} />
))}
{search.loading && search.items.length === 0 && (
<div className="text-sm text-muted-foreground py-12 text-center">Loading issues…</div>
)}
</div>
);
}
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<LinearIssue | null>(null);
const [launchIssue, setLaunchIssue] = useState<LinearIssue | null>(null);
const empty = useMemo(
() => loaded && configured && !search.loading && search.items.length === 0,
[loaded, configured, search.loading, search.items.length],
);
if (!workspaceId) {
return (
<PageShell>
<NotConfiguredNotice />
</PageShell>
);
}
if (loaded && !configured) {
return (
<PageShell>
<NotConfiguredNotice workspaceId={workspaceId} />
</PageShell>
);
}
if (!available && loaded && configured) {
return (
<PageShell>
<DisabledNotice workspaceId={workspaceId} />
</PageShell>
);
}
return (
<PageShell>
<FilterControls
query={query}
setQuery={setQuery}
teamKey={teamKey}
setTeamKey={setTeamKey}
assigned={assigned}
setAssigned={setAssigned}
teams={teams}
/>
<ResultsArea
search={search}
empty={empty}
workspaceId={workspaceId}
onOpen={setOpenIssue}
onStartTask={setLaunchIssue}
/>
<PaginationBar
page={search.page}
pageSize={search.pageSize}
itemCount={search.items.length}
isLast={search.isLast}
onNext={search.goNext}
onPrev={search.goPrev}
/>
<LinearIssueDialog
open={openIssue !== null}
onOpenChange={(open) => !open && setOpenIssue(null)}
workspaceId={workspaceId}
identifier={openIssue?.identifier ?? null}
initialIssue={openIssue}
onStartTask={setLaunchIssue}
/>
<LinearQuickTaskLauncher
workspaceId={workspaceId}
workflows={workflows}
steps={steps}
issue={launchIssue}
onClose={() => setLaunchIssue(null)}
/>
</PageShell>
);
}
+65
View File
@@ -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<AppState> = {
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 (
<>
<StateHydrator initialState={initialState} />
<LinearPageClient workspaceId={workspaceId} workflows={workflows} steps={steps} />
</>
);
}
@@ -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 (
<StateProvider initialState={{}}>
<LinearSettings key={id} workspaceId={id} />
</StateProvider>
);
}
@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" asChild className="cursor-pointer">
<Link href="/linear">
<IconHexagon className="h-4 w-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>Linear</TooltipContent>
</Tooltip>
);
}
function ViewToggleGroup({
toggleValue,
onValueChange,
@@ -169,6 +188,7 @@ function TabletHeader({
<TooltipProvider>
<GitHubTopbarButton />
<JiraTopbarButton workspaceId={workspaceId} />
<LinearTopbarButton workspaceId={workspaceId} />
</TooltipProvider>
</div>
{onSearchChange && (
@@ -250,6 +270,7 @@ function DesktopHeader({
<TooltipProvider>
<GitHubTopbarButton />
<JiraTopbarButton workspaceId={workspaceId} />
<LinearTopbarButton workspaceId={workspaceId} />
</TooltipProvider>
<Button variant="outline" asChild className="cursor-pointer gap-2">
<Link href="/stats">
@@ -0,0 +1,120 @@
"use client";
import { useCallback, useState } from "react";
import { IconHexagon } 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 { getLinearIssue } from "@/lib/api/domains/linear-api";
import type { LinearIssue } from "@/lib/types/linear";
import { LINEAR_KEY_RE } from "./linear-issue-common";
import { useLinearAvailable } from "./use-linear-availability";
function extractKey(input: string): string | null {
const match = input.toUpperCase().match(LINEAR_KEY_RE);
return match ? match[0] : null;
}
type LinearImportBarProps = {
workspaceId: string | null;
disabled?: boolean;
onImport: (issue: LinearIssue) => void;
};
export function LinearImportBar({ workspaceId, disabled, onImport }: LinearImportBarProps) {
const available = useLinearAvailable(workspaceId);
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = useCallback(async () => {
if (!workspaceId) {
setError("Select a workspace first");
return;
}
const key = extractKey(value.trim());
if (!key) {
setError("Paste a Linear issue URL or identifier (ENG-123)");
return;
}
setLoading(true);
setError(null);
try {
const issue = await getLinearIssue(workspaceId, key);
onImport(issue);
setOpen(false);
setValue("");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [workspaceId, value, onImport]);
if (!available) return null;
// Closing the popover discards any stale validation/error state so the
// next open starts clean rather than rehydrating yesterday's failure.
const handleOpenChange = (next: boolean) => {
setOpen(next);
if (!next) setError(null);
};
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled}
aria-label="Import from Linear"
className="h-7 w-7 cursor-pointer hover:bg-muted/40 text-slate-400"
>
<IconHexagon className="h-4 w-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Import from Linear issue URL or identifier</TooltipContent>
</Tooltip>
<PopoverContent align="start" className="w-80 p-3">
<div className="space-y-2">
<div className="text-xs font-medium">Import Linear issue</div>
<Input
autoFocus
value={value}
onChange={(e) => 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 && (
<p className="text-[11px] text-destructive" role="alert">
{error}
</p>
)}
<div className="flex justify-end">
<Button
type="button"
size="sm"
onClick={() => void submit()}
disabled={loading || !value.trim()}
className="h-7 cursor-pointer"
>
{loading ? "Loading..." : "Import"}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
}
@@ -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 (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="cursor-pointer px-2 gap-1"
onClick={() => setOpen(true)}
>
<IconHexagon className="h-4 w-4" />
<span className="text-xs font-medium">{identifier}</span>
</Button>
</TooltipTrigger>
<TooltipContent>Open Linear issue {identifier}</TooltipContent>
</Tooltip>
<LinearIssueDialog
open={open}
onOpenChange={setOpen}
workspaceId={workspaceId}
identifier={identifier}
/>
</>
);
}
@@ -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");
});
});
@@ -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 <span className="text-muted-foreground">Unassigned</span>;
return (
<>
<Avatar size="sm" className="size-5">
{avatar && <AvatarImage src={avatar} alt={name} />}
<AvatarFallback className="text-[10px]">{name.charAt(0)}</AvatarFallback>
</Avatar>
<span className="truncate">{name}</span>
</>
);
}
// 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<void>;
handleStateChange: (stateId: string) => Promise<void>;
};
// 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<LinearIssue | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pendingState, setPendingState] = useState<string | null>(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 (
<div className="flex items-center gap-3 text-sm">
<span className={isAuth ? "text-muted-foreground" : "text-destructive"}>
{isAuth ? "Linear authentication required." : cleanLinearErrorMessage(error)}
</span>
{isAuth && (
<Button asChild size="sm" variant="outline" className="cursor-pointer h-7 text-xs">
<Link href={settingsHref}>Reconnect Linear</Link>
</Button>
)}
</div>
);
}
return (
<div className="max-w-md text-center space-y-4">
{isAuth ? (
<>
<IconLockExclamation className="h-10 w-10 mx-auto text-muted-foreground" />
<div className="space-y-1.5">
<h2 className="text-lg font-semibold">Linear authentication required</h2>
<p className="text-sm text-muted-foreground">
Your Linear API key is invalid or has been revoked. Reconnect to view this issue.
</p>
</div>
<Button asChild size="sm" className="cursor-pointer">
<Link href={settingsHref}>Reconnect Linear</Link>
</Button>
</>
) : (
<p className="text-sm text-destructive">{cleanLinearErrorMessage(error)}</p>
)}
</div>
);
}
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!max-w-[min(1280px,95vw)] w-[95vw] max-h-[90vh] overflow-hidden flex flex-col gap-0 p-0 sm:rounded-lg">
<DialogTitle className="sr-only">{dialogTitle(issue, identifier)}</DialogTitle>
{errorOnly ? (
<div className="flex-1 flex items-center justify-center px-8 py-16">
<LinearErrorMessage error={state.error ?? ""} workspaceId={workspaceId} />
</div>
) : (
<>
<IssueTopBar
issue={issue}
loading={state.loading}
onRefresh={() => void state.load()}
/>
<DialogBody
issue={issue}
state={state}
identifier={effectiveIdentifier(issue, identifier)}
workspaceId={workspaceId}
/>
{showFooter && <IssueFooter onStart={handleStart} />}
</>
)}
</DialogContent>
</Dialog>
);
}
function IssueFooter({ onStart }: { onStart: () => void }) {
return (
<div className="flex items-center justify-end gap-2 px-6 py-3 border-t bg-muted/20 shrink-0">
<Button size="sm" variant="default" className="cursor-pointer gap-1.5" onClick={onStart}>
<IconPlus className="h-4 w-4" />
Start task
</Button>
</div>
);
}
function DialogBody({
issue,
state,
identifier,
workspaceId,
}: {
issue: LinearIssue | null;
state: IssueState;
identifier: string;
workspaceId: string | null | undefined;
}) {
return (
<div className="flex-1 overflow-y-auto">
{state.error && issue && (
<div className="px-8 pt-4">
<LinearErrorMessage error={state.error} workspaceId={workspaceId} compact />
</div>
)}
{!issue && state.loading && (
<div className="text-sm text-muted-foreground py-16 text-center">Loading issue…</div>
)}
{issue && <IssueBody issue={issue} state={state} identifier={identifier} />}
</div>
);
}
type TopBarProps = {
issue: LinearIssue | null;
loading: boolean;
onRefresh: () => void;
};
function IssueTopBar({ issue, loading, onRefresh }: TopBarProps) {
return (
<div className="flex items-center justify-end gap-1 pl-4 pr-12 py-2 border-b shrink-0">
{issue?.url && (
<Button
asChild
variant="ghost"
size="icon-sm"
className="cursor-pointer"
title="Open in Linear"
>
<a href={issue.url} target="_blank" rel="noreferrer">
<IconExternalLink className="h-4 w-4" />
</a>
</Button>
)}
<Button
variant="ghost"
size="icon-sm"
className="cursor-pointer"
onClick={onRefresh}
disabled={loading}
title="Refresh"
>
<IconRefresh className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
</div>
);
}
function IssueBody({
issue,
state,
identifier,
}: {
issue: LinearIssue;
state: IssueState;
identifier: string;
}) {
return (
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_340px] gap-8 px-8 py-6">
<div className="min-w-0 space-y-6">
<IssueHeading issue={issue} identifier={identifier} />
<DescriptionSection description={issue.description} />
</div>
<aside className="space-y-4">
<StateBlock
issue={issue}
states={issue.states}
pending={state.pendingState}
onChange={(id) => void state.handleStateChange(id)}
/>
<DetailsCard issue={issue} />
<MetaFooter issue={issue} />
</aside>
</div>
);
}
function IssueHeading({ issue, identifier }: { issue: LinearIssue; identifier: string }) {
return (
<div className="space-y-3">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground font-mono">
<span>{identifier}</span>
{issue.teamKey && (
<>
<span>·</span>
<span>{issue.teamKey}</span>
</>
)}
</div>
<h2 className="text-2xl font-semibold leading-tight">{issue.title}</h2>
</div>
);
}
function SectionHeading({ children }: { children: React.ReactNode }) {
return <div className="text-sm font-semibold">{children}</div>;
}
function DescriptionSection({ description }: { description: string }) {
return (
<section className="space-y-2">
<SectionHeading>Description</SectionHeading>
<div className="rounded-md border bg-muted/30 px-4 py-3">
{description ? (
<div className="text-sm whitespace-pre-wrap leading-relaxed">{description}</div>
) : (
<div className="text-sm text-muted-foreground italic">No description.</div>
)}
</div>
</section>
);
}
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 (
<section className="space-y-2">
{issue.stateName && (
<div>
<Badge
variant="outline"
className={`text-sm px-3 py-1 ${stateBadgeClass(issue.stateCategory)}`}
>
{issue.stateName}
</Badge>
</div>
)}
{next.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{next.map((s) => (
<Button
key={s.id}
variant="outline"
size="sm"
className="cursor-pointer h-7 text-xs"
disabled={pending !== null}
onClick={() => onChange(s.id)}
>
{pending === s.id ? "…" : `→ ${s.name}`}
</Button>
))}
</div>
)}
</section>
);
}
function DetailsCard({ issue }: { issue: LinearIssue }) {
return (
<section className="rounded-md border bg-background overflow-hidden">
<div className="px-4 py-3 border-b bg-muted/30">
<SectionHeading>Details</SectionHeading>
</div>
<div className="px-4 py-3 space-y-3 text-sm">
<DetailRow label="Assignee">
<PersonCell name={issue.assigneeName} avatar={issue.assigneeIcon} />
</DetailRow>
<DetailRow label="Creator">
<PersonCell name={issue.creatorName} avatar={issue.creatorIcon} />
</DetailRow>
<DetailRow label="Priority">
<span className={priorityClass(issue.priority)}>{issue.priorityLabel || "—"}</span>
</DetailRow>
<DetailRow label="Team">
<span className="font-mono text-xs">{issue.teamKey}</span>
</DetailRow>
</div>
</section>
);
}
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-start gap-3">
<span className="text-muted-foreground text-xs w-20 shrink-0 pt-0.5">{label}</span>
<div className="min-w-0 flex items-center gap-1.5 flex-1">{children}</div>
</div>
);
}
function MetaFooter({ issue }: { issue: LinearIssue }) {
const updated = formatRelative(issue.updated);
if (!updated) return null;
return (
<div className="text-xs text-muted-foreground px-1" title={issue.updated}>
Updated {updated}
</div>
);
}
@@ -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<string | null>(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 (
<Popover open={open} onOpenChange={handleOpenChange}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button size="sm" variant="outline" className="cursor-pointer px-2 gap-1">
<IconLink className="h-4 w-4" />
<span className="text-xs font-medium">Link Linear</span>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Link this task to a Linear issue</TooltipContent>
</Tooltip>
<PopoverContent align="end" className="w-80 p-3">
<div className="space-y-2">
<div className="text-xs font-medium">Link to Linear issue</div>
<Input
autoFocus
value={value}
onChange={(e) => 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 && (
<p className="text-[11px] text-destructive" role="alert">
{error}
</p>
)}
<div className="flex justify-end">
<Button
type="button"
size="sm"
onClick={() => void submit()}
disabled={loading || !value.trim()}
className="h-7 cursor-pointer"
>
{loading ? "Linking..." : "Link"}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
}
@@ -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 (
<TaskCreateDialog
open={true}
onOpenChange={handleOpenChange}
mode="create"
workspaceId={workspaceId}
workflowId={defaultWorkflow.id}
defaultStepId={defaultStep.id}
steps={stepsForWorkflow}
initialValues={{ title: dialog.title, description: dialog.description }}
onSuccess={handleSuccess}
/>
);
}
@@ -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: <K extends keyof FormState>(key: K, value: FormState[K]) => void;
hasSavedSecret: boolean;
teams: LinearTeam[];
loadingTeams: boolean;
};
function SecretField({
form,
loading,
update,
hasSavedSecret,
}: Omit<FieldsRowProps, "teams" | "loadingTeams">) {
return (
<div className="space-y-1.5">
<Label htmlFor="linear-secret">
API key
{hasSavedSecret && (
<span className="text-xs text-muted-foreground ml-2">
(saved — leave blank to keep the current value)
</span>
)}
</Label>
<Input
id="linear-secret"
type="password"
placeholder={hasSavedSecret ? "••••••••" : "lin_api_..."}
value={form.secret}
onChange={(e) => update("secret", e.target.value)}
disabled={loading}
/>
<p className="text-xs text-muted-foreground">
Create a personal API key at{" "}
<a
className="underline cursor-pointer"
href="https://linear.app/settings/account/security"
target="_blank"
rel="noreferrer"
>
linear.app/settings/account/security
</a>
</p>
</div>
);
}
function TeamSelector({ form, loading, update, teams, loadingTeams }: FieldsRowProps) {
return (
<div className="space-y-1.5">
<Label htmlFor="linear-team">Default team (optional)</Label>
<Select
value={form.defaultTeamKey || "__none__"}
onValueChange={(v) => update("defaultTeamKey", v === "__none__" ? "" : v)}
disabled={loading || loadingTeams}
>
<SelectTrigger id="linear-team" className="w-full">
<SelectValue placeholder={loadingTeams ? "Loading teams…" : "Choose a team"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No default</SelectItem>
{teams.map((t) => (
<SelectItem key={t.id} value={t.key}>
{t.name} ({t.key})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
function TestResultAlert({ result }: { result: TestLinearConnectionResult | null }) {
if (!result) return null;
return (
<Alert variant={result.ok ? "default" : "destructive"}>
<AlertDescription>
{result.ok
? `Connected as ${result.displayName || result.email || result.userId}${result.orgName ? ` (${result.orgName})` : ""}`
: `Failed: ${result.error}`}
</AlertDescription>
</Alert>
);
}
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 (
<span className="text-xs text-muted-foreground ml-2">
· checked {formatDistanceToNow(checkedAt, { addSuffix: true })}
</span>
);
}
function AuthStatusBanner({ health }: { health: AuthHealth | null }) {
if (!health) return null;
if (!health.checkedAt) {
return (
<Alert>
<AlertDescription className="text-sm">
Waiting for the next backend health check…
</AlertDescription>
</Alert>
);
}
if (health.ok) {
return (
<Alert className="border-green-500/40 bg-green-500/10 dark:border-green-400/30 dark:bg-green-400/10">
<IconCheck className="h-4 w-4 text-green-600 dark:text-green-400" />
<AlertDescription className="text-sm font-medium">
Authenticated
<LastCheckedLabel checkedAt={health.checkedAt} />
</AlertDescription>
</Alert>
);
}
return (
<Alert variant="destructive">
<IconAlertTriangle className="h-4 w-4" />
<AlertDescription className="text-sm">
Authentication failed: {health.error || "unknown error"}
<LastCheckedLabel checkedAt={health.checkedAt} />
</AlertDescription>
</Alert>
);
}
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 (
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
onClick={onTest}
disabled={testing || loading || disableTest}
className="cursor-pointer"
title={disableTest ? "Paste an API key to test the connection" : undefined}
>
{testing ? "Testing..." : "Test connection"}
</Button>
<Button type="button" onClick={onSave} disabled={disableSave} className="cursor-pointer">
{saveLabel(saving, hasConfig)}
</Button>
{hasConfig && (
<Button
type="button"
variant="destructive"
onClick={onDelete}
className="ml-auto cursor-pointer"
>
Remove configuration
</Button>
)}
</div>
);
}
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<LinearTeam[] | null>(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<LinearConfig | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [loading, setLoading] = useState(true);
const [testResult, setTestResult] = useState<TestLinearConnectionResult | null>(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(
<K extends keyof FormState>(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 (
<div className="flex items-center gap-2 rounded-full border bg-muted/30 px-3 py-1">
<Switch
id="linear-enabled"
checked={enabled}
onCheckedChange={setEnabled}
className="cursor-pointer"
/>
<Label htmlFor="linear-enabled" className="text-xs cursor-pointer">
{enabled ? "Enabled" : "Disabled"}
</Label>
</div>
);
}
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 (
<div className="space-y-8">
<SettingsSection
icon={<IconHexagon className="h-5 w-5" />}
title="Linear integration"
description="Connect this workspace to Linear with a personal API key. Credentials are stored encrypted server-side."
action={<EnabledPill workspaceId={workspaceId} />}
>
<Card>
<CardContent className="space-y-4 pt-6">
<AuthStatusBanner health={s.health} />
<SecretField
form={s.form}
loading={s.loading}
update={s.update}
hasSavedSecret={!!s.config?.hasSecret}
/>
<TeamSelector
form={s.form}
loading={s.loading}
update={s.update}
hasSavedSecret={!!s.config?.hasSecret}
teams={s.teams}
loadingTeams={s.loadingTeams}
/>
<TestResultAlert result={s.testResult} />
<Separator />
<ActionBar
saving={s.saving}
testing={s.testing}
loading={s.loading}
hasConfig={!!s.config}
disableSave={disableSave}
disableTest={disableTest}
onTest={s.handleTest}
onSave={s.handleSave}
onDelete={s.handleDelete}
/>
</CardContent>
</Card>
</SettingsSection>
</div>
);
}
@@ -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<AuthState>({ 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;
}
@@ -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));
});
});
@@ -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<EnabledState>({ 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 };
}
@@ -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 (
<SidebarMenuSubItem key={workspace.id}>
@@ -154,6 +156,14 @@ function WorkspacesSidebarSection({ pathname, workspaces }: WorkspacesSidebarSec
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton asChild size="sm" isActive={pathname === linearPath}>
<Link href={linearPath}>
<IconHexagon className="h-3.5 w-3.5" />
<span>Linear</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</SidebarMenuSubItem>
);
@@ -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<T>(
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 (
<>
<TaskFormInputs
@@ -390,15 +408,8 @@ export function DialogPromptSection({
onEnhancePrompt={enhance?.onEnhance}
isEnhancingPrompt={enhance?.isLoading}
isUtilityConfigured={enhance?.isConfigured}
jiraImport={
showJiraImport && onJiraImport
? {
workspaceId: workspaceId ?? null,
disabled: isPassthroughProfile,
onImport: onJiraImport,
}
: undefined
}
jiraImport={importBindings(importsEnabled, ws, isPassthroughProfile, onJiraImport)}
linearImport={importBindings(importsEnabled, ws, isPassthroughProfile, onLinearImport)}
/>
{isPassthroughProfile && hasDescription && (
<p className="text-xs text-amber-500">Prompt ignored — passthrough mode active</p>
@@ -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 (
<div className="flex items-center px-1 pb-1">
@@ -501,6 +510,13 @@ function FormInputsToolbar({
onImport={jiraImport.onImport}
/>
)}
{linearImport && (
<LinearImportBar
workspaceId={linearImport.workspaceId}
disabled={linearImport.disabled}
onImport={linearImport.onImport}
/>
)}
</div>
);
}
@@ -518,6 +534,7 @@ export const TaskFormInputs = memo(function TaskFormInputs({
isEnhancingPrompt,
isUtilityConfigured,
jiraImport,
linearImport,
}: TaskFormInputsProps) {
const fileInputRef = useRef<HTMLInputElement>(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}
/>
<input
ref={fileInputRef}
+98 -64
View File
@@ -2,6 +2,7 @@
import { FormEvent, useCallback } from "react";
import type { JiraTicket } from "@/lib/types/jira";
import type { LinearIssue } from "@/lib/types/linear";
import { Dialog, DialogContent, DialogHeader, DialogFooter } from "@kandev/ui/dialog";
import type { Task } from "@/lib/types/http";
import type { AgentProfileOption } from "@/lib/state/slices";
@@ -80,6 +81,7 @@ type DialogFormBodyProps = {
hasDescription: boolean;
workspaceId: string | null;
onJiraImport?: (ticket: JiraTicket) => void;
onLinearImport?: (issue: LinearIssue) => void;
branchOptions: ReturnType<typeof useBranchOptions>;
branchesLoading: boolean;
agentProfileOptions: ReturnType<typeof useAgentProfileOptions>;
@@ -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<typeof useDialogFormState>;
@@ -425,18 +445,88 @@ function useTaskCreateDialogSetup(props: TaskCreateDialogProps) {
handleKeyDown,
enhance: useEnhanceForDialog(fs),
handleJiraImport: useJiraImportHandler(fs),
handleLinearImport: useLinearImportHandler(fs),
};
}
type DialogFormProps = {
setup: ReturnType<typeof useTaskCreateDialogSetup>;
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 (
<form onSubmit={handleSubmit} className="flex flex-col gap-4 overflow-hidden">
<DialogFormBody
isSessionMode={isSessionMode}
isCreateMode={isCreateMode}
isTaskStarted={isTaskStarted}
isPassthroughProfile={computed.isPassthroughProfile}
initialDescription={fs.currentDefaults.description}
hasDescription={fs.hasDescription}
workspaceId={workspaceId}
onJiraImport={handleJiraImport}
onLinearImport={handleLinearImport}
branchOptions={computed.branchOptions}
branchesLoading={branchesLoading || (fs.useGitHubUrl && fs.githubBranchesLoading)}
agentProfileOptions={computed.agentProfileOptions}
executorProfileOptions={computed.executorProfileOptions}
agentProfiles={agentProfiles}
agentProfilesLoading={computed.agentProfilesLoading}
executorsLoading={computed.executorsLoading}
isCreatingSession={fs.isCreatingSession}
workflows={workflows}
snapshots={snapshots}
effectiveWorkflowId={computed.effectiveWorkflowId ?? null}
fs={fs}
handleKeyDown={handleKeyDown}
onBranchChange={handlers.handleBranchChange}
onAgentProfileChange={handlers.handleAgentProfileChange}
onExecutorProfileChange={handlers.handleExecutorProfileChange}
onWorkflowChange={handlers.handleWorkflowChange}
hasRepositorySelection={computed.hasRepositorySelection}
isLocalExecutor={computed.isLocalExecutor}
enhance={setup.enhance}
workflowAgentLocked={computed.workflowAgentLocked}
onToggleFreshBranch={handlers.handleToggleFreshBranch}
/>
<DialogFooter className="border-t border-border pt-3 flex-col gap-3 sm:flex-row sm:gap-2">
<TaskCreateDialogFooter
isSessionMode={isSessionMode}
isCreateMode={isCreateMode}
isEditMode={setup.isEditMode}
isTaskStarted={isTaskStarted}
isPassthroughProfile={computed.isPassthroughProfile}
isCreatingSession={fs.isCreatingSession}
isCreatingTask={fs.isCreatingTask}
hasTitle={fs.hasTitle}
hasDescription={fs.hasDescription}
hasRepositorySelection={computed.hasRepositorySelection}
branch={fs.branch}
agentProfileId={computed.effectiveAgentProfileId}
workspaceId={workspaceId}
effectiveWorkflowId={computed.effectiveWorkflowId ?? null}
executorHint={computed.executorHint}
onCancel={handleCancel}
onUpdateWithoutAgent={handleUpdateWithoutAgent}
onCreateWithoutAgent={handleCreateWithoutAgent}
onCreateWithPlanMode={handleCreateWithPlanMode}
/>
</DialogFooter>
</form>
);
}
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
@@ -448,7 +538,7 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) {
isCreateMode={isCreateMode}
isEditMode={isEditMode}
isTaskStarted={isTaskStarted}
sessionRepoName={sessionRepoName}
sessionRepoName={setup.sessionRepoName}
initialTitle={initialValues?.title}
taskName={fs.taskName}
repositoryId={fs.repositoryId}
@@ -466,63 +556,7 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) {
onGitHubUrlChange={handlers.handleGitHubUrlChange}
/>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 overflow-hidden">
<DialogFormBody
isSessionMode={isSessionMode}
isCreateMode={isCreateMode}
isTaskStarted={isTaskStarted}
isPassthroughProfile={computed.isPassthroughProfile}
initialDescription={fs.currentDefaults.description}
hasDescription={fs.hasDescription}
workspaceId={workspaceId}
onJiraImport={handleJiraImport}
branchOptions={computed.branchOptions}
branchesLoading={branchesLoading || (fs.useGitHubUrl && fs.githubBranchesLoading)}
agentProfileOptions={computed.agentProfileOptions}
executorProfileOptions={computed.executorProfileOptions}
agentProfiles={agentProfiles}
agentProfilesLoading={computed.agentProfilesLoading}
executorsLoading={computed.executorsLoading}
isCreatingSession={fs.isCreatingSession}
workflows={workflows}
snapshots={snapshots}
effectiveWorkflowId={computed.effectiveWorkflowId ?? null}
fs={fs}
handleKeyDown={handleKeyDown}
onBranchChange={handlers.handleBranchChange}
onAgentProfileChange={handlers.handleAgentProfileChange}
onExecutorProfileChange={handlers.handleExecutorProfileChange}
onWorkflowChange={handlers.handleWorkflowChange}
hasRepositorySelection={computed.hasRepositorySelection}
isLocalExecutor={computed.isLocalExecutor}
enhance={setup.enhance}
workflowAgentLocked={computed.workflowAgentLocked}
onToggleFreshBranch={handlers.handleToggleFreshBranch}
/>
<DialogFooter className="border-t border-border pt-3 flex-col gap-3 sm:flex-row sm:gap-2">
<TaskCreateDialogFooter
isSessionMode={isSessionMode}
isCreateMode={isCreateMode}
isEditMode={isEditMode}
isTaskStarted={isTaskStarted}
isPassthroughProfile={computed.isPassthroughProfile}
isCreatingSession={fs.isCreatingSession}
isCreatingTask={fs.isCreatingTask}
hasTitle={fs.hasTitle}
hasDescription={fs.hasDescription}
hasRepositorySelection={computed.hasRepositorySelection}
branch={fs.branch}
agentProfileId={computed.effectiveAgentProfileId}
workspaceId={workspaceId}
effectiveWorkflowId={computed.effectiveWorkflowId ?? null}
executorHint={computed.executorHint}
onCancel={handleCancel}
onUpdateWithoutAgent={handleUpdateWithoutAgent}
onCreateWithoutAgent={handleCreateWithoutAgent}
onCreateWithPlanMode={handleCreateWithPlanMode}
/>
</DialogFooter>
</form>
<DialogForm setup={setup} workspaceId={workspaceId} />
<PendingDiscardModal pending={setup.submitHandlers.pendingDiscard} />
</DialogContent>
</Dialog>
+39 -5
View File
@@ -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 <JiraTicketButton workspaceId={workspaceId} taskTitle={taskTitle} />;
}
if (linearKey && linearAvailable) {
return <LinearIssueButton workspaceId={workspaceId} taskTitle={taskTitle} />;
}
return (
<>
<JiraLinkButton taskId={taskId} workspaceId={workspaceId} taskTitle={taskTitle} />
<LinearLinkButton taskId={taskId} workspaceId={workspaceId} taskTitle={taskTitle} />
</>
);
}
/** Left section: breadcrumbs, branch pill */
function TopBarLeft({
taskId,
@@ -349,11 +387,7 @@ function TopBarRight({
isAgentctlReady={isAgentctlReady}
/>
<PRTopbarButton />
{extractJiraKey(taskTitle) ? (
<JiraTicketButton workspaceId={workspaceId} taskTitle={taskTitle} />
) : (
<JiraLinkButton taskId={taskId} workspaceId={workspaceId} taskTitle={taskTitle} />
)}
<IssueTrackerButtons taskId={taskId} workspaceId={workspaceId} taskTitle={taskTitle} />
<QuickChatButton workspaceId={workspaceId} />
<VcsSplitButton sessionId={activeSessionId ?? null} baseBranch={baseBranch} />
<LayoutPresetSelector />
+187
View File
@@ -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<typeof fetch>[0];
type FetchInit = Parameters<typeof fetch>[1];
const fetchSpy = vi.fn<[FetchInput, FetchInit?], Promise<Response>>();
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" });
});
});
+113
View File
@@ -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<LinearConfig | null> {
const res = await fetchJson<LinearConfig | undefined>(
`/api/v1/linear/config?workspace_id=${encodeURIComponent(workspaceId)}`,
options,
);
return res ?? null;
}
export async function setLinearConfig(
payload: SetLinearConfigRequest,
options?: ApiRequestOptions,
) {
return fetchJson<LinearConfig>(`/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<TestLinearConnectionResult>(`/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<LinearIssue>(
`/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<LinearSearchResult>(`/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 }),
},
},
);
}
+93
View File
@@ -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;
}