diff --git a/ai/memories/changelogs/202603311620-docs-linear-task-plugin-integration-design.md b/ai/memories/changelogs/202603311620-docs-linear-task-plugin-integration-design.md new file mode 100644 index 00000000..e335dbea --- /dev/null +++ b/ai/memories/changelogs/202603311620-docs-linear-task-plugin-integration-design.md @@ -0,0 +1,15 @@ +## 2026-03-31 16:20: docs: add Linear task plugin integration design + +**What changed:** +- Added a new product/technical design document for Linear task integration as an independent plugin. +- Defined scope for OAuth connection, bidirectional task sync, and connection status management in Settings. +- Documented architecture decisions aligned with current plugin runtime, including required plugin permissions and SDK task wrapper extension. +- Added phased implementation plan, sync/conflict strategy, risks, and acceptance mapping. + +**Why:** +- Provide a concrete implementation blueprint before coding the Linear integration. +- Ensure requirements are explicit: plugin install-gated behavior, two-way sync, and visible connection state. + +**Files affected:** +- `docs/plans/2026-03-31-linear-task-plugin-integration-design.md` +- `ai/memories/changelogs/202603311620-docs-linear-task-plugin-integration-design.md` diff --git a/ai/memories/changelogs/202603311644-feat-linear-api-key-auth.md b/ai/memories/changelogs/202603311644-feat-linear-api-key-auth.md new file mode 100644 index 00000000..99534c85 --- /dev/null +++ b/ai/memories/changelogs/202603311644-feat-linear-api-key-auth.md @@ -0,0 +1,20 @@ +## 2026-03-31 16:44: feat: switch Linear integration auth from OAuth to API key + +**What changed:** +- Reworked `plugins/linear` authentication flow from OAuth to API key secret storage (`linear-api-key`). +- Replaced panel connect UX with API key input/save flow, keeping disconnect and manual sync actions. +- Updated plugin manifest tools to `linear_set_api_key` and removed OAuth-specific permissions/tool surface. +- Updated integration design and manual QA docs to align with API key onboarding, validation, and failure paths. +- Rebuilt and installed latest plugin artifact to `~/.peekoo/plugins/linear` for immediate local validation. + +**Why:** +- Match product requirement update to use Linear personal/team API keys. +- Simplify desktop setup by avoiding browser callback/OAuth token lifecycle handling. + +**Files affected:** +- `plugins/linear/peekoo-plugin.toml` +- `plugins/linear/src/lib.rs` +- `plugins/linear/ui/panel.html` +- `plugins/linear/ui/panel.js` +- `docs/plans/2026-03-31-linear-task-plugin-integration-design.md` +- `docs/plans/2026-03-31-linear-integration-manual-qa.md` diff --git a/ai/memories/changelogs/202603311740-feat-linear-plugin-foundation.md b/ai/memories/changelogs/202603311740-feat-linear-plugin-foundation.md new file mode 100644 index 00000000..927fd6d0 --- /dev/null +++ b/ai/memories/changelogs/202603311740-feat-linear-plugin-foundation.md @@ -0,0 +1,26 @@ +## 2026-03-31 17:40: feat: add Linear plugin foundation with task sync and settings status + +**What changed:** +- Added a new first-party plugin at `plugins/linear` with OAuth connect/disconnect, panel snapshot provider, manual sync trigger, periodic scheduler sync, and two-way task sync scaffolding against Linear GraphQL. +- Added a new `tasks` module in `crates/peekoo-plugin-sdk` and wired raw host function bindings so plugins can create/list/update/delete/toggle/assign Peekoo tasks safely. +- Added a new settings hook and UI section to show Linear integration status in Settings, including install/enable/connected/sync/error surfaces. +- Updated `justfile` to include `linear` in `plugin-build-all`. + +**Why:** +- Implement the approved design direction for Linear as an install-gated independent plugin. +- Enable plugin-side background synchronization without coupling Linear logic into core app crates. +- Satisfy acceptance criteria that connection status is visible in Settings. + +**Files affected:** +- `plugins/linear/Cargo.toml` +- `plugins/linear/peekoo-plugin.toml` +- `plugins/linear/src/lib.rs` +- `plugins/linear/ui/panel.html` +- `plugins/linear/ui/panel.css` +- `plugins/linear/ui/panel.js` +- `crates/peekoo-plugin-sdk/src/host_fns.rs` +- `crates/peekoo-plugin-sdk/src/tasks.rs` +- `crates/peekoo-plugin-sdk/src/lib.rs` +- `apps/desktop-ui/src/features/settings/useLinearIntegrationStatus.ts` +- `apps/desktop-ui/src/features/settings/SettingsPanel.tsx` +- `justfile` diff --git a/apps/desktop-ui/src/features/tasks/components/TaskLabelPills.tsx b/apps/desktop-ui/src/features/tasks/components/TaskLabelPills.tsx index fe2d27c9..2728933a 100644 --- a/apps/desktop-ui/src/features/tasks/components/TaskLabelPills.tsx +++ b/apps/desktop-ui/src/features/tasks/components/TaskLabelPills.tsx @@ -17,17 +17,23 @@ export function TaskLabelPills({ labels, maxVisible = 3 }: TaskLabelPillsProps) {visibleLabels.map((label) => { const predefined = PREDEFINED_LABELS.find((l) => l.name === label); const color = predefined?.color ?? getLabelColor(label); + const backgroundColor = withAlpha(color, 0.18); + const borderColor = withAlpha(color, 0.35); return ( + {label} ); @@ -38,3 +44,30 @@ export function TaskLabelPills({ labels, maxVisible = 3 }: TaskLabelPillsProps) ); } + +function withAlpha(color: string, alpha: number): string { + if (color.startsWith("#")) { + const hex = color.slice(1); + const normalized = + hex.length === 3 + ? hex + .split("") + .map((part) => part + part) + .join("") + : hex; + const r = Number.parseInt(normalized.slice(0, 2), 16); + const g = Number.parseInt(normalized.slice(2, 4), 16); + const b = Number.parseInt(normalized.slice(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + } + + if (color.startsWith("hsl(") && color.endsWith(")")) { + return color.replace(/^hsl\((.*)\)$/, `hsla($1, ${alpha})`); + } + + if (color.startsWith("rgb(") && color.endsWith(")")) { + return color.replace(/^rgb\((.*)\)$/, `rgba($1, ${alpha})`); + } + + return color; +} diff --git a/crates/peekoo-plugin-sdk/src/host_fns.rs b/crates/peekoo-plugin-sdk/src/host_fns.rs index 889dc84d..aa509970 100644 --- a/crates/peekoo-plugin-sdk/src/host_fns.rs +++ b/crates/peekoo-plugin-sdk/src/host_fns.rs @@ -197,6 +197,12 @@ pub(crate) struct OkResponse { pub ok: bool, } +#[derive(Serialize, Deserialize)] +pub(crate) struct TaskListRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub status_filter: Option, +} + #[derive(Serialize, Deserialize)] pub(crate) struct BridgeFsReadResponse { pub content: Option, @@ -368,6 +374,12 @@ extern "ExtismHost" { input: Json, ) -> Json; pub(crate) fn peekoo_set_mood(input: Json) -> Json; + pub(crate) fn peekoo_task_create(input: Json) -> Json; + pub(crate) fn peekoo_task_list(input: Json) -> Json; + pub(crate) fn peekoo_task_update(input: Json) -> Json; + pub(crate) fn peekoo_task_delete(input: Json) -> Json; + pub(crate) fn peekoo_task_toggle(input: Json) -> Json; + pub(crate) fn peekoo_task_assign(input: Json) -> Json; } #[cfg(test)] diff --git a/crates/peekoo-plugin-sdk/src/lib.rs b/crates/peekoo-plugin-sdk/src/lib.rs index 2b5f1f5b..f77ee666 100644 --- a/crates/peekoo-plugin-sdk/src/lib.rs +++ b/crates/peekoo-plugin-sdk/src/lib.rs @@ -88,10 +88,10 @@ pub mod types; pub mod badge; pub mod bridge; pub mod config; -pub mod http; pub mod crypto; pub mod events; pub mod fs; +pub mod http; pub mod log; pub mod mood; pub mod notify; @@ -100,6 +100,7 @@ pub mod schedule; pub mod secrets; pub mod state; pub mod system; +pub mod tasks; pub mod websocket; /// The `peekoo` namespace — plugin authors access all APIs through this. @@ -117,10 +118,10 @@ pub mod peekoo { pub use crate::badge; pub use crate::bridge; pub use crate::config; - pub use crate::http; pub use crate::crypto; pub use crate::events; pub use crate::fs; + pub use crate::http; pub use crate::log; pub use crate::mood; pub use crate::notify; @@ -129,6 +130,7 @@ pub mod peekoo { pub use crate::secrets; pub use crate::state; pub use crate::system; + pub use crate::tasks; pub use crate::websocket; } diff --git a/crates/peekoo-plugin-sdk/src/tasks.rs b/crates/peekoo-plugin-sdk/src/tasks.rs new file mode 100644 index 00000000..b1bb5575 --- /dev/null +++ b/crates/peekoo-plugin-sdk/src/tasks.rs @@ -0,0 +1,55 @@ +//! Task operations for plugins. +//! +//! Requires the `tasks` permission in `peekoo-plugin.toml`. + +use extism_pdk::{Error, Json}; +use serde::de::DeserializeOwned; +use serde_json::{json, Value}; + +use crate::host_fns::{ + peekoo_task_assign, peekoo_task_create, peekoo_task_delete, peekoo_task_list, + peekoo_task_toggle, peekoo_task_update, TaskListRequest, +}; + +fn decode(value: Value, op: &str) -> Result { + serde_json::from_value(value).map_err(|e| Error::msg(format!("{op} decode error: {e}"))) +} + +pub fn create(payload: Value) -> Result { + let response = unsafe { peekoo_task_create(Json(payload))? }; + decode(response.0, "tasks::create") +} + +pub fn list(status_filter: Option<&str>) -> Result, Error> { + let response = unsafe { + peekoo_task_list(Json(TaskListRequest { + status_filter: status_filter.map(ToString::to_string), + }))? + }; + decode(response.0, "tasks::list") +} + +pub fn update(payload: Value) -> Result { + let response = unsafe { peekoo_task_update(Json(payload))? }; + decode(response.0, "tasks::update") +} + +pub fn delete(id: &str) -> Result { + let response = unsafe { peekoo_task_delete(Json(json!({ "id": id })))? }; + Ok(response.0.ok) +} + +pub fn toggle(id: &str) -> Result { + let response = unsafe { peekoo_task_toggle(Json(json!({ "id": id })))? }; + decode(response.0, "tasks::toggle") +} + +pub fn assign(id: &str, assignee: &str) -> Result { + let response = unsafe { + peekoo_task_assign(Json(json!({ + "id": id, + "assignee": assignee, + })))? + }; + decode(response.0, "tasks::assign") +} diff --git a/plugins/linear/Cargo.toml b/plugins/linear/Cargo.toml new file mode 100644 index 00000000..862386fb --- /dev/null +++ b/plugins/linear/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "linear" +version = "0.1.0" +edition = "2021" + +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +peekoo-plugin-sdk = { path = "../../crates/peekoo-plugin-sdk" } +extism-pdk = "1.4" diff --git a/plugins/linear/linear-v012.wasm b/plugins/linear/linear-v012.wasm new file mode 100755 index 00000000..8949a451 Binary files /dev/null and b/plugins/linear/linear-v012.wasm differ diff --git a/plugins/linear/peekoo-plugin.toml b/plugins/linear/peekoo-plugin.toml new file mode 100644 index 00000000..b2d7403f --- /dev/null +++ b/plugins/linear/peekoo-plugin.toml @@ -0,0 +1,80 @@ +[plugin] +key = "linear" +name = "Linear" +version = "0.1.2" +author = "Peekoo Team" +description = "Sync Linear issues with Peekoo tasks." +min_peekoo_version = "0.1.0" +wasm = "linear-v012.wasm" + +[permissions] +required = ["scheduler", "state:read", "state:write", "secrets:read", "secrets:write", "net:http", "tasks"] +allowed_hosts = ["api.linear.app", "linear.app"] + +[[tools.definitions]] +name = "linear_set_api_key" +description = "Set and verify Linear API key" +parameters = '{"type":"object","properties":{"apiKey":{"type":"string"}},"required":["apiKey"]}' +return_type = "object" + +[[tools.definitions]] +name = "linear_disconnect" +description = "Disconnect the current Linear account" +parameters = '{"type":"object","properties":{},"required":[]}' +return_type = "object" + +[[tools.definitions]] +name = "linear_sync_now" +description = "Run one immediate two-way sync" +parameters = '{"type":"object","properties":{},"required":[]}' +return_type = "object" + +[[tools.definitions]] +name = "linear_sync_linear" +description = "Pull issues from Linear into Peekoo tasks" +parameters = '{"type":"object","properties":{},"required":[]}' +return_type = "object" + +[[tools.definitions]] +name = "linear_sync_local" +description = "Push local Peekoo tasks to Linear issues" +parameters = '{"type":"object","properties":{},"required":[]}' +return_type = "object" + +[[tools.definitions]] +name = "linear_sync_preview" +description = "Preview how many local tasks are pending for push to Linear" +parameters = '{"type":"object","properties":{},"required":[]}' +return_type = "object" + +[[tools.definitions]] +name = "linear_set_sync_settings" +description = "Update Linear sync settings" +parameters = '{"type":"object","properties":{"defaultTeamId":{"type":"string"},"autoPushNewTasks":{"type":"boolean"}},"required":[]}' +return_type = "object" + +[[tools.definitions]] +name = "linear_get_connection_status" +description = "Get current Linear connection status" +parameters = '{"type":"object","properties":{},"required":[]}' +return_type = "object" + +[events] +subscribe = ["schedule:fired"] + +[[data.providers]] +name = "connection_status" +description = "Current Linear integration connection and sync status" +schema = '{"type":"object"}' + +[[data.providers]] +name = "panel_snapshot" +description = "Current Linear panel state" +schema = '{"type":"object"}' + +[[ui.panels]] +label = "panel-linear" +title = "Linear" +width = 420 +height = 560 +entry = "ui/panel.html" diff --git a/plugins/linear/src/lib.rs b/plugins/linear/src/lib.rs new file mode 100644 index 00000000..5931fec9 --- /dev/null +++ b/plugins/linear/src/lib.rs @@ -0,0 +1,1458 @@ +#![cfg_attr(not(test), no_main)] + +use chrono::{DateTime, Duration, SecondsFormat, Utc}; +use peekoo_plugin_sdk::prelude::*; +use serde_json::json; + +const API_KEY_KEY: &str = "linear-api-key"; +const STATE_KEY: &str = "linear-state"; +const SYNC_SCHEDULE_KEY: &str = "linear-sync"; + +const LINEAR_GRAPHQL_URL: &str = "https://api.linear.app/graphql"; +const DEFAULT_REFRESH_INTERVAL_SECS: u64 = 300; +const MAX_REMOTE_PAGES_PER_RUN: usize = 4; +const MAX_REMOTE_ISSUES_PER_RUN: usize = 200; +const MAX_PUSH_UPDATES_PER_RUN: usize = 60; +const MAX_PUSH_CREATES_PER_RUN: usize = 40; +const MANUAL_SYNC_REMOTE_PAGES: usize = 1; +const MANUAL_SYNC_REMOTE_ISSUES: usize = 30; +const CURRENT_ASSIGNEE_SENTINEL: &str = "__current__"; +const DEFAULT_SYNC_STATE_NAMES: &[&str] = &["backlog", "todo", "in review", "in progress"]; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct LinearState { + connection: ConnectionState, + sync: SyncState, + preferences: SyncPreferences, + mappings: Vec, + cached_teams: Vec, + cached_users: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct ConnectionState { + status: String, + viewer_id: Option, + workspace_name: Option, + user_name: Option, + user_email: Option, + last_error: Option, +} + +impl Default for ConnectionState { + fn default() -> Self { + Self { + status: "disconnected".to_string(), + viewer_id: None, + workspace_name: None, + user_name: None, + user_email: None, + last_error: None, + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SyncState { + last_sync_at: Option, + last_pull_cursor: Option, + last_push_cursor: Option, + error_count: u32, + next_run_at: Option, + last_error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct SyncPreferences { + assignee_id: Option, + default_team_id: Option, + auto_push_new_tasks: bool, + sync_state_names: Vec, +} + +impl Default for SyncPreferences { + fn default() -> Self { + Self { + assignee_id: None, + default_team_id: None, + auto_push_new_tasks: false, + sync_state_names: DEFAULT_SYNC_STATE_NAMES + .iter() + .map(|value| value.to_string()) + .collect(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TaskMapping { + task_id: String, + issue_id: String, + issue_identifier: String, + team_id: Option, + last_local_updated_at: Option, + last_remote_updated_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LinearTeam { + id: String, + key: String, + name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LinearUser { + id: String, + name: Option, + email: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SetApiKeyInput { + api_key: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SyncSummaryDto { + pulled: usize, + pushed: usize, + linked: usize, + last_sync_at: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SyncPreviewDto { + pending_local_task_count: usize, + target_team_id: Option, + target_team_name: Option, + target_assignee_id: Option, + target_assignee_name: Option, + target_assignee_email: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ConnectionStatusDto { + plugin_key: String, + plugin_enabled: bool, + connected: bool, + status: String, + viewer_id: Option, + workspace_name: Option, + user_name: Option, + user_email: Option, + last_sync_at: Option, + last_error: Option, + error_count: u32, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct PanelSnapshotDto { + status: ConnectionStatusDto, + teams: Vec, + users: Vec, + preferences: SyncPreferences, + mapping_count: usize, +} + +#[derive(Debug, Serialize)] +struct LinearGraphqlRequest<'a> { + query: &'a str, + variables: Value, +} + +#[derive(Debug, Deserialize)] +struct LinearGraphqlResponse { + data: Option, + errors: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LinearGraphqlError { + message: String, + extensions: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LinearGraphqlErrorExtensions { + code: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ViewerQueryData { + viewer: ViewerNode, + users: UserConnection, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ViewerNode { + id: String, + name: Option, + email: Option, + organization: Option, + teams: TeamConnection, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ViewerOrganization { + name: Option, +} + +#[derive(Debug, Deserialize)] +struct TeamConnection { + nodes: Vec, +} + +#[derive(Debug, Deserialize)] +struct UserConnection { + nodes: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct IssuesQueryData { + issues: IssuesConnection, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct IssuesConnection { + nodes: Vec, + page_info: PageInfo, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PageInfo { + has_next_page: bool, + end_cursor: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LinearIssue { + id: String, + identifier: String, + title: String, + description: Option, + priority: i64, + updated_at: String, + state: LinearIssueState, + team: LinearIssueTeam, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LinearIssueState { + name: Option, + #[serde(rename = "type")] + state_type: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LinearIssueTeam { + id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TeamStatesQueryData { + team: Option, +} + +#[derive(Debug, Deserialize)] +struct TeamNode { + states: StateConnection, +} + +#[derive(Debug, Deserialize)] +struct StateConnection { + nodes: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LinearWorkflowState { + id: String, + #[serde(rename = "type")] + state_type: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct IssueUpdateMutationData { + issue_update: MutationIssuePayload, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct IssueCreateMutationData { + issue_create: MutationIssuePayload, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MutationIssuePayload { + success: bool, + issue: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MutationIssueNode { + id: String, + identifier: String, + updated_at: String, + team: LinearIssueTeam, +} + +#[derive(Debug, Clone, Deserialize)] +struct LocalTask { + id: String, + title: String, + description: Option, + status: String, + priority: String, + #[serde(default)] + labels: Vec, + #[serde(default, alias = "updatedAt")] + updated_at: Option, +} + +#[derive(Debug, Default)] +struct SyncSummary { + pulled: usize, + pushed: usize, + linked: usize, +} + +#[derive(Debug, Default)] +struct TeamStateMap { + todo_state_id: Option, + in_progress_state_id: Option, + done_state_id: Option, + cancelled_state_id: Option, +} + +#[derive(Debug, Clone, Copy)] +enum SyncMode { + Bidirectional, + PullOnly, + PushOnly, +} + +#[plugin_fn] +pub fn plugin_init(_: String) -> FnResult { + ensure_sync_schedule().map_err(Error::msg)?; + bootstrap_connection_status().map_err(Error::msg)?; + Ok(r#"{"status":"ok"}"#.to_string()) +} + +#[plugin_fn] +pub fn on_event(input: String) -> FnResult { + let event: Value = serde_json::from_str(&input)?; + let event_name = event["event"].as_str().unwrap_or_default(); + let key = event["payload"]["key"].as_str().unwrap_or_default(); + + if event_name == "schedule:fired" && key == SYNC_SCHEDULE_KEY { + let _ = sync_once(false, SyncMode::Bidirectional); + } + + Ok(r#"{"ok":true}"#.to_string()) +} + +#[plugin_fn] +pub fn tool_linear_set_api_key(input: String) -> FnResult { + let payload: SetApiKeyInput = serde_json::from_str(&input)?; + let api_key = payload.api_key.trim(); + if api_key.is_empty() { + return Err(Error::msg("apiKey is required").into()); + } + + let mut state = load_state().map_err(Error::msg)?; + refresh_viewer_context(api_key, &mut state).map_err(Error::msg)?; + peekoo::secrets::set(API_KEY_KEY, api_key)?; + + state.connection.status = "connected".to_string(); + state.connection.last_error = None; + state.sync.last_error = None; + state.sync.error_count = 0; + save_state(&state).map_err(Error::msg)?; + + Ok(r#"{"ok":true}"#.to_string()) +} + +#[plugin_fn] +pub fn tool_linear_disconnect(_: String) -> FnResult { + let _ = peekoo::secrets::delete(API_KEY_KEY); + let mut state = load_state().map_err(Error::msg)?; + + state.connection = ConnectionState::default(); + state.sync = SyncState::default(); + + save_state(&state).map_err(Error::msg)?; + Ok(r#"{"ok":true}"#.to_string()) +} + +#[plugin_fn] +pub fn tool_linear_sync_now(_: String) -> FnResult { + let summary = sync_once(true, SyncMode::Bidirectional).map_err(Error::msg)?; + Ok(serde_json::to_string(&SyncSummaryDto { + pulled: summary.pulled, + pushed: summary.pushed, + linked: summary.linked, + last_sync_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + })?) +} + +#[plugin_fn] +pub fn tool_linear_sync_linear(_: String) -> FnResult { + let summary = sync_once(true, SyncMode::PullOnly).map_err(Error::msg)?; + Ok(serde_json::to_string(&SyncSummaryDto { + pulled: summary.pulled, + pushed: summary.pushed, + linked: summary.linked, + last_sync_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + })?) +} + +#[plugin_fn] +pub fn tool_linear_sync_local(_: String) -> FnResult { + let summary = sync_once(true, SyncMode::PushOnly).map_err(Error::msg)?; + Ok(serde_json::to_string(&SyncSummaryDto { + pulled: summary.pulled, + pushed: summary.pushed, + linked: summary.linked, + last_sync_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + })?) +} + +#[plugin_fn] +pub fn tool_linear_sync_preview(_: String) -> FnResult { + Ok(serde_json::to_string(&sync_preview().map_err(Error::msg)?)?) +} + +#[plugin_fn] +pub fn tool_linear_set_sync_settings(input: String) -> FnResult { + let payload: Value = serde_json::from_str(&input)?; + let mut state = load_state().map_err(Error::msg)?; + + if state.connection.viewer_id.is_none() { + if let Some(api_key) = load_api_key().map_err(Error::msg)? { + let _ = refresh_viewer_context(&api_key, &mut state); + } + } + + let assignee_raw = payload + .get("assigneeId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + let assignee = match assignee_raw.as_deref() { + Some(CURRENT_ASSIGNEE_SENTINEL) => state.connection.viewer_id.clone(), + Some(_) => assignee_raw.clone(), + None => None, + }; + let team = payload + .get("defaultTeamId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + + if payload.get("assigneeId").is_some() { + state.preferences.assignee_id = assignee.clone(); + if assignee.is_some() { + state.preferences.default_team_id = None; + } + } + if payload.get("defaultTeamId").is_some() { + state.preferences.default_team_id = team.clone(); + if team.is_some() { + state.preferences.assignee_id = None; + } + } + if payload.get("syncStateNames").is_some() { + let normalized = payload + .get("syncStateNames") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .map(normalize_sync_state_name) + .filter(|value| !value.is_empty()) + .collect::>() + }) + .unwrap_or_default(); + state.preferences.sync_state_names = if normalized.is_empty() { + DEFAULT_SYNC_STATE_NAMES + .iter() + .map(|value| value.to_string()) + .collect() + } else { + normalized + }; + } + if let Some(value) = payload.get("autoPushNewTasks").and_then(Value::as_bool) { + state.preferences.auto_push_new_tasks = value; + } + + save_state(&state).map_err(Error::msg)?; + Ok(serde_json::to_string(&panel_snapshot().map_err(Error::msg)?)?) +} + +#[plugin_fn] +pub fn tool_linear_get_connection_status(_: String) -> FnResult { + Ok(serde_json::to_string(&connection_status().map_err(Error::msg)?)?) +} + +#[plugin_fn] +pub fn data_connection_status(_: String) -> FnResult { + Ok(serde_json::to_string(&connection_status().map_err(Error::msg)?)?) +} + +#[plugin_fn] +pub fn data_panel_snapshot(_: String) -> FnResult { + Ok(serde_json::to_string(&panel_snapshot().map_err(Error::msg)?)?) +} + +fn panel_snapshot() -> Result { + let state = load_state()?; + Ok(PanelSnapshotDto { + status: ConnectionStatusDto { + plugin_key: "linear".to_string(), + plugin_enabled: true, + connected: load_api_key()?.is_some(), + status: state.connection.status, + viewer_id: state.connection.viewer_id, + workspace_name: state.connection.workspace_name, + user_name: state.connection.user_name, + user_email: state.connection.user_email, + last_sync_at: state.sync.last_sync_at, + last_error: state.sync.last_error.or(state.connection.last_error), + error_count: state.sync.error_count, + }, + teams: state.cached_teams, + users: state.cached_users, + preferences: state.preferences, + mapping_count: state.mappings.len(), + }) +} + +fn connection_status() -> Result { + let state = load_state()?; + Ok(ConnectionStatusDto { + plugin_key: "linear".to_string(), + plugin_enabled: true, + connected: load_api_key()?.is_some(), + status: state.connection.status, + viewer_id: state.connection.viewer_id, + workspace_name: state.connection.workspace_name, + user_name: state.connection.user_name, + user_email: state.connection.user_email, + last_sync_at: state.sync.last_sync_at, + last_error: state.sync.last_error.or(state.connection.last_error), + error_count: state.sync.error_count, + }) +} + +fn sync_preview() -> Result { + let mut state = load_state()?; + let api_key = load_api_key()?; + if state.cached_teams.is_empty() || state.connection.viewer_id.is_none() { + if let Some(api_key) = api_key.as_deref() { + let _ = refresh_viewer_context(api_key, &mut state); + } + } + + let local_tasks = peekoo::tasks::list::(None).map_err(|e| e.to_string())?; + let pending_local_task_count = local_tasks + .iter() + .filter(|task| is_local_task_eligible_for_create(task, state.mappings.as_slice())) + .count(); + + let target_team_id = resolve_default_team_id(&state); + let target_team_name = target_team_id + .as_deref() + .and_then(|id| state.cached_teams.iter().find(|team| team.id == id)) + .map(|team| team.name.clone()); + + let target_assignee_id = resolve_default_assignee_id(&state); + let target_assignee = target_assignee_id + .as_deref() + .and_then(|id| state.cached_users.iter().find(|user| user.id == id)); + + Ok(SyncPreviewDto { + pending_local_task_count, + target_team_id, + target_team_name, + target_assignee_id, + target_assignee_name: target_assignee.and_then(|user| user.name.clone()), + target_assignee_email: target_assignee.and_then(|user| user.email.clone()), + }) +} + +fn bootstrap_connection_status() -> Result<(), String> { + let mut state = load_state()?; + let connected = load_api_key()?.is_some(); + + if !connected { + state.connection.status = "disconnected".to_string(); + save_state(&state)?; + return Ok(()); + } + + if state.connection.status == "disconnected" || state.connection.status == "error" { + state.connection.status = "connected".to_string(); + state.connection.last_error = None; + state.sync.last_error = None; + save_state(&state)?; + } + + Ok(()) +} + +fn sync_once(force: bool, mode: SyncMode) -> Result { + ensure_sync_schedule()?; + + let Some(api_key) = load_api_key()? else { + let mut state = load_state()?; + state.connection.status = "disconnected".to_string(); + state.connection.last_error = Some("Linear API key is not configured".to_string()); + save_state(&state)?; + return Err("Linear API key is not configured".to_string()); + }; + + let mut state = load_state()?; + state.connection.status = "syncing".to_string(); + state.connection.last_error = None; + save_state(&state)?; + + let sync_result = run_sync_cycle(force, mode, &api_key, &mut state); + match sync_result { + Ok(summary) => { + let now = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); + state.connection.status = "connected".to_string(); + state.sync.last_sync_at = Some(now.clone()); + state.sync.last_pull_cursor = Some(now.clone()); + state.sync.last_push_cursor = Some(now.clone()); + state.sync.next_run_at = Some( + (Utc::now() + Duration::seconds(DEFAULT_REFRESH_INTERVAL_SECS as i64)) + .to_rfc3339_opts(SecondsFormat::Secs, true), + ); + state.sync.last_error = None; + save_state(&state)?; + Ok(summary) + } + Err(error) => { + state.connection.status = "error".to_string(); + state.connection.last_error = Some(error.clone()); + state.sync.last_error = Some(error.clone()); + state.sync.error_count = state.sync.error_count.saturating_add(1); + save_state(&state)?; + Err(error) + } + } +} + +fn run_sync_cycle( + force: bool, + mode: SyncMode, + api_key: &str, + state: &mut LinearState, +) -> Result { + if state.cached_teams.is_empty() || state.connection.workspace_name.is_none() { + refresh_viewer_context(api_key, state)?; + } + + let mut local_tasks = peekoo::tasks::list::(None).map_err(|e| e.to_string())?; + let mut summary = SyncSummary::default(); + if matches!(mode, SyncMode::Bidirectional | SyncMode::PullOnly) { + let page_limit = if force { + MANUAL_SYNC_REMOTE_PAGES + } else { + MAX_REMOTE_PAGES_PER_RUN + }; + let issue_limit = if force { + MANUAL_SYNC_REMOTE_ISSUES + } else { + MAX_REMOTE_ISSUES_PER_RUN + }; + + let remote_issues = fetch_remote_issues( + api_key, + state + .preferences + .assignee_id + .as_deref() + .or(state.connection.viewer_id.as_deref()), + state.preferences.default_team_id.as_deref(), + page_limit, + issue_limit, + )?; + let state_filtered_issues = remote_issues + .into_iter() + .filter(|issue| issue_matches_sync_state(issue, &state.preferences.sync_state_names)) + .collect::>(); + let filtered_issues = if force { + state_filtered_issues + } else if let Some(cursor) = state.sync.last_pull_cursor.as_deref() { + state_filtered_issues + .into_iter() + .filter(|issue| is_after_cursor(&issue.updated_at, cursor)) + .collect() + } else { + state_filtered_issues + }; + + summary.pulled = pull_remote_into_local(&filtered_issues, state, &mut local_tasks)?; + } + + if matches!(mode, SyncMode::Bidirectional | SyncMode::PushOnly) { + let push_summary = push_local_to_remote(api_key, state, &mut local_tasks, force)?; + summary.pushed = push_summary.pushed; + summary.linked = push_summary.linked; + } + + Ok(summary) +} + +fn pull_remote_into_local( + issues: &[LinearIssue], + state: &mut LinearState, + local_tasks: &mut Vec, +) -> Result { + let mut applied = 0usize; + + for issue in issues { + if let Some(index) = mapping_index_by_issue(&state.mappings, &issue.id) { + let mapping = &mut state.mappings[index]; + let should_apply = mapping + .last_remote_updated_at + .as_deref() + .map(|last| is_after_cursor(&issue.updated_at, last)) + .unwrap_or(true); + + if !should_apply { + continue; + } + + let updated = peekoo::tasks::update::(json!({ + "id": mapping.task_id, + "title": issue.title, + "description": issue.description, + "status": linear_state_to_task_status(&issue.state.state_type), + "priority": linear_priority_to_task_priority(issue.priority), + })) + .map_err(|e| e.to_string())?; + + mapping.last_local_updated_at = Some(local_task_updated_marker(&updated)); + mapping.last_remote_updated_at = Some(issue.updated_at.clone()); + + if let Some(position) = local_tasks.iter().position(|task| task.id == updated.id) { + local_tasks[position] = updated; + } + + applied = applied.saturating_add(1); + continue; + } + + let created = peekoo::tasks::create::(json!({ + "title": issue.title, + "priority": linear_priority_to_task_priority(issue.priority), + "assignee": "user", + "labels": vec!["linear".to_string(), format!("linear:{}", issue.identifier.to_lowercase())], + "description": issue.description, + "scheduled_start_at": Value::Null, + "scheduled_end_at": Value::Null, + "estimated_duration_min": Value::Null, + "recurrence_rule": Value::Null, + "recurrence_time_of_day": Value::Null, + })) + .map_err(|e| e.to_string())?; + + state.mappings.push(TaskMapping { + task_id: created.id.clone(), + issue_id: issue.id.clone(), + issue_identifier: issue.identifier.clone(), + team_id: Some(issue.team.id.clone()), + last_local_updated_at: Some(local_task_updated_marker(&created)), + last_remote_updated_at: Some(issue.updated_at.clone()), + }); + + local_tasks.push(created); + applied = applied.saturating_add(1); + } + + Ok(applied) +} + +fn push_local_to_remote( + api_key: &str, + state: &mut LinearState, + local_tasks: &mut [LocalTask], + allow_create_when_manual_sync: bool, +) -> Result { + let mut summary = SyncSummary::default(); + let mut state_maps = std::collections::HashMap::::new(); + let mut update_budget = MAX_PUSH_UPDATES_PER_RUN; + let mut create_budget = MAX_PUSH_CREATES_PER_RUN; + + for mapping in &mut state.mappings { + if update_budget == 0 { + break; + } + + let Some(task) = local_tasks.iter().find(|task| task.id == mapping.task_id) else { + continue; + }; + + let should_push = match (&task.updated_at, mapping.last_local_updated_at.as_deref()) { + (Some(task_updated_at), Some(last)) => is_after_cursor(task_updated_at, last), + _ => true, + }; + + if !should_push { + continue; + } + + let team_id = mapping + .team_id + .as_deref() + .or(state.preferences.default_team_id.as_deref()); + let state_id = match team_id { + Some(team_id) => { + let entry = state_maps + .entry(team_id.to_string()) + .or_insert_with(|| fetch_team_state_map(api_key, team_id).unwrap_or_default()); + choose_linear_state_id(entry, &task.status) + } + None => None, + }; + + let updated_issue = update_remote_issue(api_key, &mapping.issue_id, task, state_id)?; + mapping.last_local_updated_at = Some(local_task_updated_marker(task)); + mapping.last_remote_updated_at = Some(updated_issue.updated_at.clone()); + summary.pushed = summary.pushed.saturating_add(1); + update_budget = update_budget.saturating_sub(1); + } + + if !state.preferences.auto_push_new_tasks && !allow_create_when_manual_sync { + return Ok(summary); + } + + let Some(default_team_id) = resolve_default_team_id(state) else { + return Ok(summary); + }; + let default_assignee_id = resolve_default_assignee_id(state); + + let state_map = state_maps + .entry(default_team_id.clone()) + .or_insert_with(|| fetch_team_state_map(api_key, &default_team_id).unwrap_or_default()); + + for task in local_tasks { + if create_budget == 0 { + break; + } + + if !is_local_task_eligible_for_create(task, state.mappings.as_slice()) { + continue; + } + + let state_id = choose_linear_state_id(state_map, &task.status); + let created_issue = create_remote_issue( + api_key, + &default_team_id, + task, + state_id, + default_assignee_id.as_deref(), + )?; + + let mut labels = task.labels.clone(); + labels.push("linear".to_string()); + labels.push(format!("linear:{}", created_issue.identifier.to_lowercase())); + + let updated_local = peekoo::tasks::update::(json!({ + "id": task.id, + "labels": labels, + })) + .map_err(|e| e.to_string())?; + + let updated_local_marker = local_task_updated_marker(&updated_local); + state.mappings.push(TaskMapping { + task_id: updated_local.id, + issue_id: created_issue.id, + issue_identifier: created_issue.identifier, + team_id: Some(created_issue.team.id), + last_local_updated_at: Some(updated_local_marker), + last_remote_updated_at: Some(created_issue.updated_at), + }); + + summary.linked = summary.linked.saturating_add(1); + summary.pushed = summary.pushed.saturating_add(1); + create_budget = create_budget.saturating_sub(1); + } + + Ok(summary) +} + +fn fetch_remote_issues( + api_key: &str, + assignee_id: Option<&str>, + team_id: Option<&str>, + page_limit: usize, + issue_limit: usize, +) -> Result, String> { + const QUERY_ASSIGNEE_WITH_TEAM: &str = r#" + query LinearIssues($after: String, $assigneeId: ID!, $teamId: ID!) { + issues(first: 50, after: $after, filter: { and: [{ assignee: { id: { eq: $assigneeId } } }, { team: { id: { eq: $teamId } } }] }) { + nodes { + id + identifier + title + description + priority + updatedAt + state { type name } + team { id } + } + pageInfo { + hasNextPage + endCursor + } + } + } + "#; + + const QUERY_ASSIGNEE_ONLY: &str = r#" + query LinearIssues($after: String, $assigneeId: ID!) { + issues(first: 50, after: $after, filter: { assignee: { id: { eq: $assigneeId } } }) { + nodes { + id + identifier + title + description + priority + updatedAt + state { type name } + team { id } + } + pageInfo { + hasNextPage + endCursor + } + } + } + "#; + + const QUERY_TEAM_ONLY: &str = r#" + query LinearIssues($after: String, $teamId: ID!) { + issues(first: 50, after: $after, filter: { team: { id: { eq: $teamId } } }) { + nodes { + id + identifier + title + description + priority + updatedAt + state { type name } + team { id } + } + pageInfo { + hasNextPage + endCursor + } + } + } + "#; + + let mut issues = Vec::new(); + let mut after: Option = None; + + for _ in 0..page_limit { + let (query, variables) = match (assignee_id, team_id) { + (Some(assignee_id), Some(team_id)) => ( + QUERY_ASSIGNEE_WITH_TEAM, + json!({"after": after, "assigneeId": assignee_id, "teamId": team_id}), + ), + (Some(assignee_id), None) => ( + QUERY_ASSIGNEE_ONLY, + json!({"after": after, "assigneeId": assignee_id}), + ), + (None, Some(team_id)) => ( + QUERY_TEAM_ONLY, + json!({"after": after, "teamId": team_id}), + ), + (None, None) => { + return Err("Linear sync target is not configured.".to_string()); + } + }; + + let data: IssuesQueryData = linear_graphql(api_key, query, variables)?; + issues.extend(data.issues.nodes.into_iter()); + if issues.len() >= issue_limit { + issues.truncate(issue_limit); + break; + } + + if !data.issues.page_info.has_next_page { + break; + } + after = data.issues.page_info.end_cursor; + if after.is_none() { + break; + } + } + + Ok(issues) +} + +fn update_remote_issue( + api_key: &str, + issue_id: &str, + task: &LocalTask, + state_id: Option, +) -> Result { + const MUTATION: &str = r#" + mutation UpdateIssue($id: ID!, $input: IssueUpdateInput!) { + issueUpdate(id: $id, input: $input) { + success + issue { + id + identifier + updatedAt + team { id } + } + } + } + "#; + + let mut input = json!({ + "title": task.title, + "description": task.description, + "priority": task_priority_to_linear_priority(&task.priority), + }); + + if let Some(state_id) = state_id { + input["stateId"] = Value::String(state_id); + } + + let data: IssueUpdateMutationData = linear_graphql( + api_key, + MUTATION, + json!({ + "id": issue_id, + "input": input, + }), + )?; + + if !data.issue_update.success { + return Err("Linear issueUpdate returned success=false".to_string()); + } + + data.issue_update + .issue + .ok_or_else(|| "Linear issueUpdate missing issue payload".to_string()) +} + +fn create_remote_issue( + api_key: &str, + team_id: &str, + task: &LocalTask, + state_id: Option, + assignee_id: Option<&str>, +) -> Result { + const MUTATION: &str = r#" + mutation CreateIssue($input: IssueCreateInput!) { + issueCreate(input: $input) { + success + issue { + id + identifier + updatedAt + team { id } + } + } + } + "#; + + let mut input = json!({ + "teamId": team_id, + "title": task.title, + "description": task.description, + "priority": task_priority_to_linear_priority(&task.priority), + }); + + if let Some(state_id) = state_id { + input["stateId"] = Value::String(state_id); + } + if let Some(assignee_id) = assignee_id { + input["assigneeId"] = Value::String(assignee_id.to_string()); + } + + let data: IssueCreateMutationData = linear_graphql(api_key, MUTATION, json!({ "input": input }))?; + + if !data.issue_create.success { + return Err("Linear issueCreate returned success=false".to_string()); + } + + data.issue_create + .issue + .ok_or_else(|| "Linear issueCreate missing issue payload".to_string()) +} + +fn refresh_viewer_context(api_key: &str, state: &mut LinearState) -> Result<(), String> { + const QUERY: &str = r#" + query ViewerContext { + viewer { + id + name + email + organization { name } + teams { + nodes { + id + key + name + } + } + } + users(first: 100, filter: { active: { eq: true } }) { + nodes { + id + name + email + } + } + } + "#; + + let data: ViewerQueryData = linear_graphql(api_key, QUERY, json!({}))?; + state.connection.viewer_id = Some(data.viewer.id); + state.connection.workspace_name = data + .viewer + .organization + .and_then(|organization| organization.name); + state.connection.user_name = data.viewer.name; + state.connection.user_email = data.viewer.email; + state.cached_teams = data.viewer.teams.nodes; + state.cached_users = data.users.nodes; + + if state.preferences.assignee_id.is_none() { + state.preferences.assignee_id = state.connection.viewer_id.clone(); + } + + Ok(()) +} + +fn fetch_team_state_map(api_key: &str, team_id: &str) -> Result { + const QUERY: &str = r#" + query TeamStates($teamId: ID!) { + team(id: $teamId) { + states { + nodes { + id + type + } + } + } + } + "#; + + let data: TeamStatesQueryData = linear_graphql(api_key, QUERY, json!({ "teamId": team_id }))?; + + let mut map = TeamStateMap::default(); + let Some(team) = data.team else { + return Ok(map); + }; + + for state in team.states.nodes { + match state.state_type.as_str() { + "unstarted" | "backlog" | "triage" => { + if map.todo_state_id.is_none() { + map.todo_state_id = Some(state.id); + } + } + "started" => { + if map.in_progress_state_id.is_none() { + map.in_progress_state_id = Some(state.id); + } + } + "completed" => { + if map.done_state_id.is_none() { + map.done_state_id = Some(state.id); + } + } + "canceled" => { + if map.cancelled_state_id.is_none() { + map.cancelled_state_id = Some(state.id); + } + } + _ => {} + } + } + + Ok(map) +} + +fn choose_linear_state_id(map: &TeamStateMap, task_status: &str) -> Option { + match task_status { + "todo" => map.todo_state_id.clone(), + "in_progress" => map.in_progress_state_id.clone(), + "done" => map.done_state_id.clone(), + "cancelled" => map + .cancelled_state_id + .clone() + .or_else(|| map.done_state_id.clone()), + _ => map.todo_state_id.clone(), + } +} + +fn linear_graphql( + api_key: &str, + query: &str, + variables: Value, +) -> Result { + let body = serde_json::to_string(&LinearGraphqlRequest { query, variables }) + .map_err(|e| e.to_string())?; + + let response = peekoo::http::request(peekoo::http::Request { + method: "POST", + url: LINEAR_GRAPHQL_URL, + headers: vec![ + ("Authorization", api_key), + ("Content-Type", "application/json"), + ("Accept", "application/json"), + ("User-Agent", "Peekoo-Desktop/0.1.0"), + ], + body: Some(&body), + }) + .map_err(|e| e.to_string())?; + + if response.status == 429 { + return Err("Linear API rate limited (HTTP 429)".to_string()); + } + if response.status >= 400 { + return Err(format!( + "Linear GraphQL request failed ({}): {}", + response.status, response.body + )); + } + + let parsed: LinearGraphqlResponse = + serde_json::from_str(&response.body).map_err(|e| e.to_string())?; + + if let Some(errors) = parsed.errors { + let rate_limited = errors.iter().any(|error| { + error + .extensions + .as_ref() + .and_then(|extensions| extensions.code.as_deref()) + == Some("RATELIMITED") + }); + let message = errors + .into_iter() + .map(|error| error.message) + .collect::>() + .join(" | "); + if rate_limited { + return Err(format!("Linear API rate limited: {message}")); + } + return Err(format!("Linear GraphQL error: {message}")); + } + + parsed + .data + .ok_or_else(|| "Linear GraphQL response missing data".to_string()) +} + +fn load_api_key() -> Result, String> { + peekoo::secrets::get(API_KEY_KEY).map_err(|e| e.to_string()) +} + +fn load_state() -> Result { + Ok(peekoo::state::get(STATE_KEY) + .map_err(|e| e.to_string())? + .unwrap_or_default()) +} + +fn save_state(state: &LinearState) -> Result<(), String> { + peekoo::state::set(STATE_KEY, state).map_err(|e| e.to_string()) +} + +fn ensure_sync_schedule() -> Result<(), String> { + peekoo::schedule::set(SYNC_SCHEDULE_KEY, DEFAULT_REFRESH_INTERVAL_SECS, true, None) + .map_err(|e| e.to_string()) +} + +fn mapping_index_by_issue(mappings: &[TaskMapping], issue_id: &str) -> Option { + mappings + .iter() + .position(|mapping| mapping.issue_id == issue_id) +} + +fn mapping_index_by_task(mappings: &[TaskMapping], task_id: &str) -> Option { + mappings + .iter() + .position(|mapping| mapping.task_id == task_id) +} + +fn is_local_task_eligible_for_create(task: &LocalTask, mappings: &[TaskMapping]) -> bool { + if mapping_index_by_task(mappings, &task.id).is_some() { + return false; + } + !task + .labels + .iter() + .any(|label| label == "linear" || label.starts_with("linear:")) +} + +fn resolve_default_team_id(state: &LinearState) -> Option { + state + .preferences + .default_team_id + .clone() + .or_else(|| state.cached_teams.first().map(|team| team.id.clone())) +} + +fn resolve_default_assignee_id(state: &LinearState) -> Option { + state + .preferences + .assignee_id + .clone() + .or_else(|| state.connection.viewer_id.clone()) +} + +fn local_task_updated_marker(task: &LocalTask) -> String { + task.updated_at.clone().unwrap_or_else(|| { + Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true) + }) +} + +fn normalize_sync_state_name(value: &str) -> String { + value + .trim() + .to_lowercase() + .replace('_', " ") + .replace('-', " ") +} + +fn issue_matches_sync_state(issue: &LinearIssue, selected_states: &[String]) -> bool { + if selected_states.is_empty() { + return true; + } + + let selected = selected_states + .iter() + .map(|value| normalize_sync_state_name(value)) + .collect::>(); + + let mut candidates = Vec::new(); + if let Some(name) = issue.state.name.as_deref() { + candidates.push(normalize_sync_state_name(name)); + } + + let fallback = match issue.state.state_type.as_str() { + "unstarted" => "todo", + "backlog" => "backlog", + "triage" => "triage", + "started" => "in progress", + "completed" => "done", + "canceled" => "canceled", + other => other, + }; + candidates.push(normalize_sync_state_name(fallback)); + + if candidates.iter().any(|candidate| candidate == "in view") { + candidates.push("in review".to_string()); + } + + selected + .iter() + .any(|target| candidates.iter().any(|candidate| candidate == target)) +} + +fn is_after_cursor(candidate: &str, cursor: &str) -> bool { + parse_rfc3339(candidate) + .zip(parse_rfc3339(cursor)) + .map(|(candidate, cursor)| candidate > cursor) + .unwrap_or_else(|| candidate > cursor) +} + +fn parse_rfc3339(value: &str) -> Option> { + DateTime::parse_from_rfc3339(value) + .ok() + .map(|dt| dt.with_timezone(&Utc)) +} + +fn linear_state_to_task_status(state_type: &str) -> &'static str { + match state_type { + "started" => "in_progress", + "completed" => "done", + "canceled" => "cancelled", + _ => "todo", + } +} + +fn linear_priority_to_task_priority(priority: i64) -> &'static str { + match priority { + 1 | 2 => "high", + 4 => "low", + _ => "medium", + } +} + +fn task_priority_to_linear_priority(priority: &str) -> i64 { + match priority { + "high" => 2, + "low" => 4, + _ => 3, + } +} + +#[cfg(test)] +mod tests { + use super::{ + linear_priority_to_task_priority, linear_state_to_task_status, + task_priority_to_linear_priority, + }; + + #[test] + fn maps_linear_state_to_task_status() { + assert_eq!(linear_state_to_task_status("started"), "in_progress"); + assert_eq!(linear_state_to_task_status("completed"), "done"); + assert_eq!(linear_state_to_task_status("canceled"), "cancelled"); + assert_eq!(linear_state_to_task_status("triage"), "todo"); + } + + #[test] + fn maps_priorities_bidirectionally() { + assert_eq!(linear_priority_to_task_priority(2), "high"); + assert_eq!(linear_priority_to_task_priority(4), "low"); + assert_eq!(linear_priority_to_task_priority(3), "medium"); + + assert_eq!(task_priority_to_linear_priority("high"), 2); + assert_eq!(task_priority_to_linear_priority("medium"), 3); + assert_eq!(task_priority_to_linear_priority("low"), 4); + } +} diff --git a/plugins/linear/target/wasm32-wasip1/release/linear.wasm b/plugins/linear/target/wasm32-wasip1/release/linear.wasm new file mode 100755 index 00000000..8949a451 Binary files /dev/null and b/plugins/linear/target/wasm32-wasip1/release/linear.wasm differ diff --git a/plugins/linear/ui/panel.css b/plugins/linear/ui/panel.css new file mode 100644 index 00000000..9172e5ff --- /dev/null +++ b/plugins/linear/ui/panel.css @@ -0,0 +1,219 @@ +:root { + color-scheme: light; + --bg: #f3efeb; + --panel: rgba(255, 255, 255, 0.72); + --panel-strong: rgba(255, 255, 255, 0.9); + --line: rgba(56, 71, 56, 0.14); + --line-strong: rgba(64, 109, 78, 0.32); + --text: #2a2f2a; + --muted: #7d857f; + --primary: #3f7153; + --primary-soft: rgba(63, 113, 83, 0.12); + --danger: #c94b4b; + --success: #2c8a54; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + background: + radial-gradient(circle at 8% -12%, rgba(155, 183, 159, 0.28), transparent 44%), + radial-gradient(circle at 96% 118%, rgba(190, 175, 156, 0.2), transparent 52%), + var(--bg); + color: var(--text); + font-family: "SF Pro Text", "Avenir Next", "Segoe UI", sans-serif; +} + +.panel { + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.panel__header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.panel__header h1 { + margin: 0; + font-size: 18px; + font-weight: 700; +} + +.section { + padding: 12px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel); + backdrop-filter: blur(12px); +} + +.section h2 { + margin: 0 0 10px; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 10px; +} + +.field span, +.checkbox span { + font-size: 12px; + color: var(--muted); +} + +.field input, +.field select { + width: 100%; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-strong); + color: var(--text); + padding: 8px 10px; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.field input::placeholder { + color: #9ca39e; +} + +.field input:focus, +.field select:focus { + outline: none; + border-color: var(--line-strong); + box-shadow: 0 0 0 2px rgba(63, 113, 83, 0.14); +} + +.checkbox { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; +} + +.sync-state-list { + display: grid; + grid-template-columns: 1fr; + gap: 8px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-strong); + padding: 8px 10px; + max-height: 150px; + overflow-y: auto; +} + +.sync-state-item { + display: flex; + align-items: center; + gap: 6px; + color: var(--text); + font-size: 12px; + line-height: 1.2; +} + +.sync-state-item input[type="checkbox"] { + width: 14px; + min-width: 14px; + height: 14px; + margin: 0; + padding: 0; + border: 1px solid var(--line); + border-radius: 4px; + background: #fff; + accent-color: var(--primary); + box-shadow: none; +} + +.sync-state-item input[type="checkbox"]:focus { + outline: none; + box-shadow: 0 0 0 2px rgba(63, 113, 83, 0.18); +} + +.row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.btn { + border: 1px solid var(--line); + background: var(--panel-strong); + color: var(--text); + border-radius: 8px; + padding: 8px 10px; + cursor: pointer; + font-size: 12px; + transition: border-color 0.15s ease, background-color 0.15s ease, transform 0.12s ease; +} + +.btn:hover { + border-color: var(--line-strong); + background: #ffffff; + transform: translateY(-1px); +} + +.btn:disabled { + opacity: 0.55; + cursor: not-allowed; + color: var(--muted); +} + +.btn--primary { + border-color: rgba(63, 113, 83, 0.36); + background: linear-gradient(135deg, rgba(116, 167, 128, 0.35), rgba(83, 130, 96, 0.3)); + color: #1f2c22; +} + +.badge { + font-size: 11px; + border-radius: 999px; + border: 1px solid var(--line); + padding: 3px 8px; + color: #44644a; + background: var(--primary-soft); +} + +.muted { + margin: 0; + color: var(--muted); + font-size: 12px; +} + +.error, +.success { + margin: 0; + padding: 8px 10px; + border-radius: 8px; + font-size: 12px; +} + +.error { + border: 1px solid rgba(201, 75, 75, 0.36); + color: var(--danger); + background: rgba(255, 234, 234, 0.7); +} + +.success { + border: 1px solid rgba(44, 138, 84, 0.34); + color: var(--success); + background: rgba(229, 247, 235, 0.74); +} + +.hidden { + display: none; +} diff --git a/plugins/linear/ui/panel.html b/plugins/linear/ui/panel.html new file mode 100644 index 00000000..5be09de3 --- /dev/null +++ b/plugins/linear/ui/panel.html @@ -0,0 +1,61 @@ + + + + + + Linear + + + +
+
+

Linear Sync

+ Unknown +
+ +

Loading status...

+

+

+ +
+

Connection

+
+ + + +
+

+ + +
+ +
+

API Key

+ +

API key will be stored securely and auto-saved.

+
+ +
+

Sync Settings

+ + + +
+ +
+ + + + diff --git a/plugins/linear/ui/panel.js b/plugins/linear/ui/panel.js new file mode 100644 index 00000000..84c7b853 --- /dev/null +++ b/plugins/linear/ui/panel.js @@ -0,0 +1,321 @@ +const invoke = window.__TAURI__.core.invoke; + +const statusBadge = document.getElementById("statusBadge"); +const statusLine = document.getElementById("statusLine"); +const workspaceLine = document.getElementById("workspaceLine"); +const userLine = document.getElementById("userLine"); +const apiKeyInput = document.getElementById("apiKeyInput"); +const apiKeyHint = document.getElementById("apiKeyHint"); +const disconnectButton = document.getElementById("disconnectButton"); +const syncLinearButton = document.getElementById("syncLinearButton"); +const syncLocalButton = document.getElementById("syncLocalButton"); +const syncTargetSelect = document.getElementById("syncTargetSelect"); +const syncStateList = document.getElementById("syncStateList"); +const autoPushToggle = document.getElementById("autoPushToggle"); +const lastSyncLine = document.getElementById("lastSyncLine"); +const successBanner = document.getElementById("successBanner"); +const errorBanner = document.getElementById("errorBanner"); +const MASKED_KEY_VALUE = "****************"; +const CURRENT_ASSIGNEE_SENTINEL = "__current__"; +const DEFAULT_SYNC_STATES = ["backlog", "todo", "in review", "in progress"]; +const SYNC_STATE_OPTIONS = [ + { value: "triage", label: "Triage" }, + { value: "backlog", label: "Backlog" }, + { value: "todo", label: "Todo" }, + { value: "in progress", label: "In Progress" }, + { value: "in review", label: "In Review" }, + { value: "done", label: "Done" }, + { value: "canceled", label: "Canceled" }, +]; +let isConnected = false; +let apiKeySaveTimer = null; +let settingsSaveTimer = null; + +function showError(message) { + if (!message) { + errorBanner.classList.add("hidden"); + errorBanner.textContent = ""; + return; + } + errorBanner.textContent = message; + errorBanner.classList.remove("hidden"); +} + +function showSuccess(message) { + if (!message) { + successBanner.classList.add("hidden"); + successBanner.textContent = ""; + return; + } + successBanner.textContent = message; + successBanner.classList.remove("hidden"); + setTimeout(() => { + successBanner.classList.add("hidden"); + successBanner.textContent = ""; + }, 3000); +} + +function formatStatus(status) { + switch (status) { + case "connected": + return "Connected"; + case "syncing": + return "Syncing"; + case "error": + return "Error"; + case "disconnected": + return "Disconnected"; + default: + return "Unknown"; + } +} + +function renderSyncTargets(status, teams, preferences) { + syncTargetSelect.innerHTML = ""; + const me = document.createElement("option"); + me.value = `assignee:${CURRENT_ASSIGNEE_SENTINEL}`; + me.textContent = "Current account (Me)"; + syncTargetSelect.appendChild(me); + + teams.forEach((team) => { + const option = document.createElement("option"); + option.value = `team:${team.id}`; + option.textContent = `Team: ${team.name} (${team.key})`; + syncTargetSelect.appendChild(option); + }); + + const selected = preferences.assigneeId + ? `assignee:${preferences.assigneeId}` + : preferences.defaultTeamId + ? `team:${preferences.defaultTeamId}` + : `assignee:${CURRENT_ASSIGNEE_SENTINEL}`; + + if (Array.from(syncTargetSelect.options).some((option) => option.value === selected)) { + syncTargetSelect.value = selected; + } else { + syncTargetSelect.value = `assignee:${CURRENT_ASSIGNEE_SENTINEL}`; + } +} + +function normalizeStateName(value) { + return (value || "").trim().toLowerCase().replaceAll("_", " ").replaceAll("-", " "); +} + +function renderSyncStateList(preferences) { + syncStateList.innerHTML = ""; + const selected = new Set( + (preferences.syncStateNames && preferences.syncStateNames.length > 0 + ? preferences.syncStateNames + : DEFAULT_SYNC_STATES).map(normalizeStateName), + ); + + SYNC_STATE_OPTIONS.forEach((option) => { + const label = document.createElement("label"); + label.className = "sync-state-item"; + + const input = document.createElement("input"); + input.type = "checkbox"; + input.value = option.value; + input.checked = selected.has(normalizeStateName(option.value)); + input.addEventListener("change", scheduleSettingsSave); + + const text = document.createElement("span"); + text.textContent = option.label; + + label.appendChild(input); + label.appendChild(text); + syncStateList.appendChild(label); + }); +} + +function applySnapshot(snapshot) { + const { status, teams, preferences, mappingCount } = snapshot; + const pretty = formatStatus(status.status); + isConnected = Boolean(status.connected); + + statusBadge.textContent = pretty; + statusLine.textContent = `Status: ${pretty} · ${status.connected ? "Connected" : "Not connected"}`; + workspaceLine.textContent = status.workspaceName ? `Workspace: ${status.workspaceName}` : ""; + userLine.textContent = status.userEmail + ? `User: ${status.userName || ""} ${status.userEmail}`.trim() + : ""; + + autoPushToggle.checked = Boolean(preferences.autoPushNewTasks); + renderSyncTargets(status, teams || [], preferences); + renderSyncStateList(preferences); + + lastSyncLine.textContent = status.lastSyncAt + ? `Last sync: ${status.lastSyncAt} · Linked tasks: ${mappingCount}` + : "No sync yet"; + + disconnectButton.disabled = !status.connected; + syncLinearButton.disabled = !status.connected; + syncLocalButton.disabled = !status.connected; + + if (isConnected) { + apiKeyHint.textContent = "API key is already saved securely. You only need to enter it again when replacing it."; + if (!apiKeyInput.value || apiKeyInput.value === MASKED_KEY_VALUE) { + apiKeyInput.value = MASKED_KEY_VALUE; + } + } else { + apiKeyHint.textContent = "API key will be stored securely and auto-saved."; + apiKeyInput.value = ""; + apiKeyInput.placeholder = "lin_api_..."; + } + + showError(status.status === "error" ? (status.lastError || "Sync failed.") : null); +} + +async function refreshSnapshot(runSync = false) { + try { + if (runSync) { + await invoke("plugin_call_panel_tool", { + pluginKey: "linear", + toolName: "linear_sync_now", + argsJson: "{}", + }); + } + + const raw = await invoke("plugin_query_data", { + pluginKey: "linear", + providerName: "panel_snapshot", + }); + applySnapshot(JSON.parse(raw)); + return true; + } catch (error) { + showError(String(error)); + return false; + } +} + +async function runManualSync(toolName, button, syncingLabel, doneLabel) { + const previousLabel = button.textContent; + const wasDisabled = button.disabled; + button.disabled = true; + button.textContent = syncingLabel; + try { + await invoke("plugin_call_panel_tool", { + pluginKey: "linear", + toolName, + argsJson: "{}", + }); + await refreshSnapshot(false); + showSuccess(doneLabel); + } catch (error) { + showError(String(error)); + } finally { + button.textContent = previousLabel; + if (!wasDisabled) { + button.disabled = false; + } + } +} + +async function saveApiKeyIfNeeded() { + try { + showError(null); + const nextApiKey = (apiKeyInput.value || "").trim(); + if (!nextApiKey || (isConnected && nextApiKey === MASKED_KEY_VALUE)) { + return; + } + await invoke("plugin_call_panel_tool", { + pluginKey: "linear", + toolName: "linear_set_api_key", + argsJson: JSON.stringify({ + apiKey: nextApiKey, + }), + }); + showSuccess("API key auto-saved."); + await refreshSnapshot(false); + } catch (error) { + showError(String(error)); + } +} + +async function saveSyncSettings() { + try { + const selectedStates = Array.from( + syncStateList.querySelectorAll("input[type='checkbox']:checked"), + ).map((entry) => normalizeStateName(entry.value)); + + await invoke("plugin_call_panel_tool", { + pluginKey: "linear", + toolName: "linear_set_sync_settings", + argsJson: JSON.stringify({ + assigneeId: syncTargetSelect.value.startsWith("assignee:") + ? syncTargetSelect.value.replace("assignee:", "") + : null, + defaultTeamId: syncTargetSelect.value.startsWith("team:") + ? syncTargetSelect.value.replace("team:", "") + : null, + syncStateNames: selectedStates.length > 0 ? selectedStates : DEFAULT_SYNC_STATES, + autoPushNewTasks: Boolean(autoPushToggle.checked), + }), + }); + showSuccess("Settings auto-saved."); + } catch (error) { + showError(String(error)); + } +} + +apiKeyInput.addEventListener("focus", () => { + if (apiKeyInput.value === MASKED_KEY_VALUE) { + apiKeyInput.value = ""; + } +}); + +apiKeyInput.addEventListener("input", () => { + if (apiKeyInput.value === MASKED_KEY_VALUE) { + return; + } + if (apiKeySaveTimer) { + window.clearTimeout(apiKeySaveTimer); + } + apiKeySaveTimer = window.setTimeout(() => { + void saveApiKeyIfNeeded(); + }, 700); +}); + +apiKeyInput.addEventListener("blur", () => { + if (apiKeySaveTimer) { + window.clearTimeout(apiKeySaveTimer); + apiKeySaveTimer = null; + } + void saveApiKeyIfNeeded(); +}); + +disconnectButton.addEventListener("click", async () => { + try { + await invoke("plugin_call_panel_tool", { + pluginKey: "linear", + toolName: "linear_disconnect", + argsJson: "{}", + }); + showSuccess("Disconnected."); + await refreshSnapshot(false); + } catch (error) { + showError(String(error)); + } +}); + +syncLinearButton.addEventListener("click", () => + runManualSync("linear_sync_linear", syncLinearButton, "Syncing...", "Linear pull completed."), +); + +syncLocalButton.addEventListener("click", () => + runManualSync("linear_sync_local", syncLocalButton, "Syncing...", "Local push completed."), +); + +function scheduleSettingsSave() { + if (settingsSaveTimer) { + window.clearTimeout(settingsSaveTimer); + } + settingsSaveTimer = window.setTimeout(() => { + void saveSyncSettings(); + }, 300); +} + +syncTargetSelect.addEventListener("change", scheduleSettingsSave); +autoPushToggle.addEventListener("change", scheduleSettingsSave); + +void refreshSnapshot(false);