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:
@@ -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
|
||||
|
||||
@@ -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)")
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package linear
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// Store persists Linear workspace configurations. Secret values are delegated
|
||||
// to the shared encrypted secret store and not stored here.
|
||||
type Store struct {
|
||||
db *sqlx.DB
|
||||
ro *sqlx.DB
|
||||
}
|
||||
|
||||
// NewStore creates a new Store and initializes the schema if needed.
|
||||
func NewStore(writer, reader *sqlx.DB) (*Store, error) {
|
||||
s := &Store{db: writer, ro: reader}
|
||||
if err := s.initSchema(); err != nil {
|
||||
return nil, fmt.Errorf("linear schema init: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
const createTablesSQL = `
|
||||
CREATE TABLE IF NOT EXISTS linear_configs (
|
||||
workspace_id TEXT PRIMARY KEY,
|
||||
auth_method TEXT NOT NULL,
|
||||
default_team_key TEXT NOT NULL DEFAULT '',
|
||||
org_slug TEXT NOT NULL DEFAULT '',
|
||||
last_checked_at DATETIME,
|
||||
last_ok INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
// addedColumns lists columns introduced after the initial schema. Kept for
|
||||
// parity with the Jira store so future column additions follow the same
|
||||
// migration pattern; empty for now since the table ships fully-formed.
|
||||
var addedColumns = []struct {
|
||||
name string
|
||||
sql string
|
||||
}{}
|
||||
|
||||
func (s *Store) initSchema() error {
|
||||
if _, err := s.db.Exec(createTablesSQL); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.migrateAddedColumns()
|
||||
}
|
||||
|
||||
// migrateAddedColumns applies ALTER TABLE statements for columns introduced
|
||||
// after the initial schema. Existing databases need these columns backfilled;
|
||||
// new databases already have them from createTablesSQL and the ALTERs are
|
||||
// skipped.
|
||||
func (s *Store) migrateAddedColumns() error {
|
||||
if len(addedColumns) == 0 {
|
||||
return nil
|
||||
}
|
||||
existing, err := s.tableColumns("linear_configs")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, col := range addedColumns {
|
||||
if _, ok := existing[col.name]; ok {
|
||||
continue
|
||||
}
|
||||
if _, err := s.db.Exec(col.sql); err != nil {
|
||||
return fmt.Errorf("add column %s: %w", col.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) tableColumns(table string) (map[string]struct{}, error) {
|
||||
rows, err := s.db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
cols := make(map[string]struct{})
|
||||
for rows.Next() {
|
||||
var (
|
||||
cid int
|
||||
name string
|
||||
ctype string
|
||||
notnull int
|
||||
dflt sql.NullString
|
||||
pk int
|
||||
)
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cols[name] = struct{}{}
|
||||
}
|
||||
return cols, rows.Err()
|
||||
}
|
||||
|
||||
const selectConfigColumns = `workspace_id, auth_method, default_team_key, org_slug,
|
||||
last_checked_at, last_ok, last_error, created_at, updated_at`
|
||||
|
||||
// GetConfig returns the Linear config for a workspace, or nil when no row
|
||||
// exists.
|
||||
func (s *Store) GetConfig(ctx context.Context, workspaceID string) (*LinearConfig, error) {
|
||||
var cfg LinearConfig
|
||||
err := s.ro.GetContext(ctx, &cfg,
|
||||
`SELECT `+selectConfigColumns+` FROM linear_configs WHERE workspace_id = ?`, workspaceID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// UpsertConfig inserts or updates the config row for a workspace. It never
|
||||
// touches the secret store — callers must persist the token separately. The
|
||||
// last_* health columns and org_slug are deliberately not touched here; the
|
||||
// poller owns those and writes them via UpdateAuthHealth.
|
||||
func (s *Store) UpsertConfig(ctx context.Context, cfg *LinearConfig) error {
|
||||
now := time.Now().UTC()
|
||||
if cfg.CreatedAt.IsZero() {
|
||||
cfg.CreatedAt = now
|
||||
}
|
||||
cfg.UpdatedAt = now
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO linear_configs (workspace_id, auth_method, default_team_key, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(workspace_id) DO UPDATE SET
|
||||
auth_method = excluded.auth_method,
|
||||
default_team_key = excluded.default_team_key,
|
||||
updated_at = excluded.updated_at`,
|
||||
cfg.WorkspaceID, cfg.AuthMethod, cfg.DefaultTeamKey, cfg.CreatedAt, cfg.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteConfig removes the Linear config row for a workspace. Secrets must be
|
||||
// cleared separately by the caller.
|
||||
func (s *Store) DeleteConfig(ctx context.Context, workspaceID string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM linear_configs WHERE workspace_id = ?`, workspaceID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListConfiguredWorkspaces returns the IDs of all workspaces that have a
|
||||
// Linear config row. Used by the auth-health poller.
|
||||
func (s *Store) ListConfiguredWorkspaces(ctx context.Context) ([]string, error) {
|
||||
var ids []string
|
||||
err := s.ro.SelectContext(ctx, &ids,
|
||||
`SELECT workspace_id FROM linear_configs ORDER BY workspace_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// UpdateAuthHealth records the result of a credential probe. orgSlug is
|
||||
// captured opportunistically from successful probes; pass "" to leave the
|
||||
// existing slug unchanged. If the workspace row no longer exists, the update
|
||||
// is a silent no-op.
|
||||
func (s *Store) UpdateAuthHealth(ctx context.Context, workspaceID string, ok bool, errMsg, orgSlug string, checkedAt time.Time) error {
|
||||
if orgSlug != "" {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE linear_configs
|
||||
SET last_checked_at = ?, last_ok = ?, last_error = ?, org_slug = ?
|
||||
WHERE workspace_id = ?`,
|
||||
checkedAt, ok, errMsg, orgSlug, workspaceID)
|
||||
return err
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE linear_configs
|
||||
SET last_checked_at = ?, last_ok = ?, last_error = ?
|
||||
WHERE workspace_id = ?`,
|
||||
checkedAt, ok, errMsg, workspaceID)
|
||||
return err
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user