323 lines
16 KiB
Go
323 lines
16 KiB
Go
// Package linear implements the Linear integration: a single install-wide
|
|
// configuration, 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"
|
|
|
|
"github.com/kandev/kandev/internal/integrations/optional"
|
|
)
|
|
|
|
// 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.
|
|
type LinearConfig struct {
|
|
WorkspaceID string `json:"workspaceId,omitempty" 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
|
|
// Linear configuration. When Secret is empty on update, the existing secret
|
|
// is retained; when non-empty it replaces the stored value.
|
|
type SetConfigRequest struct {
|
|
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"`
|
|
Created string `json:"created,omitempty"` // createdAt timestamp from Linear
|
|
URL string `json:"url"`
|
|
States []LinearWorkflowState `json:"states"`
|
|
}
|
|
|
|
// IssueSortBy selects the order in which a watch's matched issues are published
|
|
// (and therefore dispatched) under the per-watch in-flight cap. The empty value
|
|
// preserves Linear's API order (updatedAt asc).
|
|
type IssueSortBy string
|
|
|
|
const (
|
|
SortByDefault IssueSortBy = "" // preserve Linear API order (updatedAt asc)
|
|
SortByPriorityDesc IssueSortBy = "priority" // most important first: urgent>high>medium>low>none
|
|
SortByPriorityAsc IssueSortBy = "priority_asc" // least important first
|
|
SortByCreatedDesc IssueSortBy = "created_desc" // newest created first
|
|
SortByCreatedAsc IssueSortBy = "created_asc" // oldest created first
|
|
SortByUpdatedDesc IssueSortBy = "updated_desc" // most recently updated first
|
|
SortByUpdatedAsc IssueSortBy = "updated_asc" // least recently updated first
|
|
)
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// LinearLabel is an issue label belonging to a team. Labels are returned by
|
|
// `GET /api/v1/linear/teams/:key/labels` and surfaced in the filter UI for
|
|
// issue watches.
|
|
type LinearLabel struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Color string `json:"color,omitempty"`
|
|
}
|
|
|
|
// LinearUser is a workspace member returned by `GET /api/v1/linear/teams/:key/members`
|
|
// (and the generic users list). Used as options in creator/assignee selectors.
|
|
type LinearUser struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
DisplayName string `json:"displayName,omitempty"`
|
|
Email string `json:"email,omitempty"`
|
|
AvatarURL string `json:"avatarUrl,omitempty"`
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// All fields are optional; an empty filter returns every issue the API key can
|
|
// see. Watcher creation rejects fully-empty filters via filterIsEmpty.
|
|
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)
|
|
// Priorities filters issues whose priority is in this set. Linear uses
|
|
// 0=None, 1=Urgent, 2=High, 3=Medium, 4=Low. Empty slice means no
|
|
// priority filter; a slice with 0 in it includes "No priority" issues.
|
|
Priorities []int `json:"priorities,omitempty"`
|
|
// LabelIDs filters issues that have ANY of the given label UUIDs (Linear's
|
|
// labels filter is OR by default).
|
|
LabelIDs []string `json:"labelIds,omitempty"`
|
|
// CreatorID restricts to issues created by a specific user UUID. Empty
|
|
// means any creator.
|
|
CreatorID string `json:"creatorId,omitempty"`
|
|
// EstimateMin / EstimateMax bound the issue's point estimate. nil disables
|
|
// that bound; the two together act as a closed range.
|
|
EstimateMin *float64 `json:"estimateMin,omitempty"`
|
|
EstimateMax *float64 `json:"estimateMax,omitempty"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// SecretKey is the legacy secret-store key used for the old install-wide Linear
|
|
// API key. New workspace-scoped configs use SecretKeyForWorkspace.
|
|
const SecretKey = "linear:singleton:token"
|
|
|
|
// SecretKeyForWorkspace returns the workspace-scoped Linear secret key.
|
|
func SecretKeyForWorkspace(workspaceID string) string {
|
|
return "linear:" + workspaceID + ":token"
|
|
}
|
|
|
|
// LegacySecretKeyForWorkspace is kept for older tests/callers.
|
|
func LegacySecretKeyForWorkspace(workspaceID string) string {
|
|
return SecretKeyForWorkspace(workspaceID)
|
|
}
|
|
|
|
// DefaultIssueWatchPollInterval is the polling cadence assigned to a watcher
|
|
// when the caller does not specify one. Five minutes balances freshness
|
|
// against Linear rate limits when many workspaces have watches configured.
|
|
const DefaultIssueWatchPollInterval = 300
|
|
|
|
// IssueWatch configures periodic Linear search-polling. The filter is a
|
|
// structured SearchFilter (Linear has no JQL equivalent) persisted as JSON;
|
|
// the poller deserialises it back to SearchFilter at the store boundary.
|
|
//
|
|
// As with Jira, Linear issues have no repository affinity — the target
|
|
// workflow step's defaults determine where the resulting task runs.
|
|
type IssueWatch struct {
|
|
ID string `json:"id" db:"id"`
|
|
WorkspaceID string `json:"workspaceId" db:"workspace_id"`
|
|
WorkflowID string `json:"workflowId" db:"workflow_id"`
|
|
WorkflowStepID string `json:"workflowStepId" db:"workflow_step_id"`
|
|
// RepositoryID optionally binds watcher-created tasks to a repository so the
|
|
// agent launches in an isolated worktree of that repo instead of a blank
|
|
// scratch checkout. Empty = unbound, which preserves the historical
|
|
// repo-less behaviour. When set, the resulting task carries a single
|
|
// (repository_id, base_branch) pair.
|
|
RepositoryID string `json:"repositoryId" db:"repository_id"`
|
|
// BaseBranch is the branch the per-task worktree is cut from. Empty defaults
|
|
// to the repository's default branch (resolved at create/update time).
|
|
// Meaningful only when RepositoryID is set.
|
|
BaseBranch string `json:"baseBranch" db:"base_branch"`
|
|
Filter SearchFilter `json:"filter"`
|
|
AgentProfileID string `json:"agentProfileId" db:"agent_profile_id"`
|
|
ExecutorProfileID string `json:"executorProfileId" db:"executor_profile_id"`
|
|
Prompt string `json:"prompt" db:"prompt"`
|
|
Enabled bool `json:"enabled" db:"enabled"`
|
|
PollIntervalSeconds int `json:"pollIntervalSeconds" db:"poll_interval_seconds"`
|
|
// MaxInflightTasks caps how many open watcher-created tasks this watch can
|
|
// hold at once. nil = uncapped. Values <= 0 are rejected at the API layer.
|
|
// See docs/specs/tasks/system-design/wip-limit-pull-system.md for the open-task definition.
|
|
MaxInflightTasks *int `json:"maxInflightTasks,omitempty" db:"max_inflight_tasks"`
|
|
// SortBy sets the dispatch order for matched issues; empty = Linear default order.
|
|
SortBy IssueSortBy `json:"sortBy,omitempty" db:"sort_by"`
|
|
LastPolledAt *time.Time `json:"lastPolledAt,omitempty" db:"last_polled_at"`
|
|
// LastError / LastErrorAt are stamped when the dispatch pipeline self-
|
|
// heals the watcher (e.g. the bound agent profile was soft-deleted).
|
|
// Empty for a healthy watcher.
|
|
LastError string `json:"lastError,omitempty" db:"last_error"`
|
|
LastErrorAt *time.Time `json:"lastErrorAt,omitempty" db:"last_error_at"`
|
|
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
|
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
|
}
|
|
|
|
// IssueWatchTask deduplicates task creation per (watch, issue) tuple. The
|
|
// UNIQUE constraint on (issue_watch_id, issue_identifier) prevents two
|
|
// concurrent pollers from racing to create duplicate tasks for the same
|
|
// issue. We key on Identifier (e.g. "ENG-123") rather than the GraphQL UUID
|
|
// because it's stable, what humans see, and present on every search result.
|
|
type IssueWatchTask struct {
|
|
ID string `json:"id" db:"id"`
|
|
IssueWatchID string `json:"issueWatchId" db:"issue_watch_id"`
|
|
IssueIdentifier string `json:"issueIdentifier" db:"issue_identifier"`
|
|
IssueURL string `json:"issueUrl" db:"issue_url"`
|
|
TaskID string `json:"taskId" db:"task_id"`
|
|
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
|
}
|
|
|
|
// NewLinearIssueEvent is published on the bus whenever the poller observes an
|
|
// issue matching a watch that has no existing dedup row. The orchestrator
|
|
// consumes this to create (and optionally auto-start) a Kandev task.
|
|
type NewLinearIssueEvent struct {
|
|
IssueWatchID string `json:"issueWatchId"`
|
|
WorkspaceID string `json:"workspaceId"`
|
|
WorkflowID string `json:"workflowId"`
|
|
WorkflowStepID string `json:"workflowStepId"`
|
|
// RepositoryID / BaseBranch carry the watch's optional repository binding so
|
|
// the orchestrator source can populate IssueTaskRequest.Repositories without
|
|
// reloading the watch row. Empty RepositoryID = unbound (repo-less task).
|
|
RepositoryID string `json:"repositoryId,omitempty"`
|
|
BaseBranch string `json:"baseBranch,omitempty"`
|
|
AgentProfileID string `json:"agentProfileId"`
|
|
ExecutorProfileID string `json:"executorProfileId"`
|
|
Prompt string `json:"prompt"`
|
|
// MaxInflightTasks mirrors the watch row's per-watcher throttle cap so the
|
|
// orchestrator's gate can read it without loading the row again. nil =
|
|
// uncapped.
|
|
MaxInflightTasks *int `json:"maxInflightTasks,omitempty"`
|
|
Issue *LinearIssue `json:"issue"`
|
|
}
|
|
|
|
// CreateIssueWatchRequest is the payload for POST /api/v1/linear/watches/issue.
|
|
type CreateIssueWatchRequest struct {
|
|
WorkspaceID string `json:"workspaceId"`
|
|
WorkflowID string `json:"workflowId"`
|
|
WorkflowStepID string `json:"workflowStepId"`
|
|
RepositoryID string `json:"repositoryId"`
|
|
BaseBranch string `json:"baseBranch"`
|
|
Filter SearchFilter `json:"filter"`
|
|
AgentProfileID string `json:"agentProfileId"`
|
|
ExecutorProfileID string `json:"executorProfileId"`
|
|
Prompt string `json:"prompt"`
|
|
PollIntervalSeconds int `json:"pollIntervalSeconds"`
|
|
MaxInflightTasks *int `json:"maxInflightTasks,omitempty"`
|
|
SortBy IssueSortBy `json:"sortBy,omitempty"`
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
}
|
|
|
|
// UpdateIssueWatchRequest is the payload for PATCH /api/v1/linear/watches/issue/:id.
|
|
// Most fields are pointers so callers can omit the ones they don't want to
|
|
// change. MaxInflightTasks uses optional.Int for tri-state PATCH semantics
|
|
// (absent = unchanged, null = uncapped, positive int = cap).
|
|
type UpdateIssueWatchRequest struct {
|
|
WorkflowID *string `json:"workflowId,omitempty"`
|
|
WorkflowStepID *string `json:"workflowStepId,omitempty"`
|
|
RepositoryID *string `json:"repositoryId,omitempty"`
|
|
BaseBranch *string `json:"baseBranch,omitempty"`
|
|
Filter *SearchFilter `json:"filter,omitempty"`
|
|
AgentProfileID *string `json:"agentProfileId,omitempty"`
|
|
ExecutorProfileID *string `json:"executorProfileId,omitempty"`
|
|
Prompt *string `json:"prompt,omitempty"`
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
PollIntervalSeconds *int `json:"pollIntervalSeconds,omitempty"`
|
|
// MaxInflightTasks is tri-state so a partial PATCH that omits the field
|
|
// leaves the cap unchanged (a plain *int can't tell "omitted" from
|
|
// "null"). Absent = unchanged, null = uncapped, positive int = cap.
|
|
MaxInflightTasks optional.Int `json:"maxInflightTasks"`
|
|
// SortBy is a pointer for tri-state PATCH semantics: nil means "omitted,
|
|
// leave unchanged"; a non-nil pointer (including "") sets the value.
|
|
SortBy *IssueSortBy `json:"sortBy,omitempty"`
|
|
}
|