Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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`
20 changes: 20 additions & 0 deletions ai/memories/changelogs/202603311644-feat-linear-api-key-auth.md
Original file line number Diff line number Diff line change
@@ -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`
Original file line number Diff line number Diff line change
@@ -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`
39 changes: 36 additions & 3 deletions apps/desktop-ui/src/features/tasks/components/TaskLabelPills.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@0xRichardH 这个文件的修改,主要是解决 tasks 插件已有的一个错误,同步下来的 linear task, 在 task 标签显示存在背景色和文字颜色一样的问题

const borderColor = withAlpha(color, 0.35);

return (
<span
key={label}
className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium leading-none"
style={{
backgroundColor: `${color}20`,
color,
border: `1px solid ${color}40`,
backgroundColor,
color: "var(--text-primary)",
border: `1px solid ${borderColor}`,
}}
>
<span
className="mr-1 inline-block h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: color }}
/>
{label}
</span>
);
Expand All @@ -38,3 +44,30 @@ export function TaskLabelPills({ labels, maxVisible = 3 }: TaskLabelPillsProps)
</div>
);
}

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;
}
12 changes: 12 additions & 0 deletions crates/peekoo-plugin-sdk/src/host_fns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

#[derive(Serialize, Deserialize)]
pub(crate) struct BridgeFsReadResponse {
pub content: Option<String>,
Expand Down Expand Up @@ -368,6 +374,12 @@ extern "ExtismHost" {
input: Json<CryptoEd25519SignRequest>,
) -> Json<CryptoEd25519SignResponse>;
pub(crate) fn peekoo_set_mood(input: Json<SetMoodRequest>) -> Json<OkResponse>;
pub(crate) fn peekoo_task_create(input: Json<Value>) -> Json<Value>;
pub(crate) fn peekoo_task_list(input: Json<TaskListRequest>) -> Json<Value>;
pub(crate) fn peekoo_task_update(input: Json<Value>) -> Json<Value>;
pub(crate) fn peekoo_task_delete(input: Json<Value>) -> Json<OkResponse>;
pub(crate) fn peekoo_task_toggle(input: Json<Value>) -> Json<Value>;
pub(crate) fn peekoo_task_assign(input: Json<Value>) -> Json<Value>;
}

#[cfg(test)]
Expand Down
6 changes: 4 additions & 2 deletions crates/peekoo-plugin-sdk/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand All @@ -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;
Expand All @@ -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;
}

Expand Down
55 changes: 55 additions & 0 deletions crates/peekoo-plugin-sdk/src/tasks.rs
Original file line number Diff line number Diff line change
@@ -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<T: DeserializeOwned>(value: Value, op: &str) -> Result<T, Error> {
serde_json::from_value(value).map_err(|e| Error::msg(format!("{op} decode error: {e}")))
}

pub fn create<T: DeserializeOwned>(payload: Value) -> Result<T, Error> {
let response = unsafe { peekoo_task_create(Json(payload))? };
decode(response.0, "tasks::create")
}

pub fn list<T: DeserializeOwned>(status_filter: Option<&str>) -> Result<Vec<T>, 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<T: DeserializeOwned>(payload: Value) -> Result<T, Error> {
let response = unsafe { peekoo_task_update(Json(payload))? };
decode(response.0, "tasks::update")
}

pub fn delete(id: &str) -> Result<bool, Error> {
let response = unsafe { peekoo_task_delete(Json(json!({ "id": id })))? };
Ok(response.0.ok)
}

pub fn toggle<T: DeserializeOwned>(id: &str) -> Result<T, Error> {
let response = unsafe { peekoo_task_toggle(Json(json!({ "id": id })))? };
decode(response.0, "tasks::toggle")
}

pub fn assign<T: DeserializeOwned>(id: &str, assignee: &str) -> Result<T, Error> {
let response = unsafe {
peekoo_task_assign(Json(json!({
"id": id,
"assignee": assignee,
})))?
};
decode(response.0, "tasks::assign")
}
16 changes: 16 additions & 0 deletions plugins/linear/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Binary file added plugins/linear/linear-v012.wasm
Binary file not shown.
80 changes: 80 additions & 0 deletions plugins/linear/peekoo-plugin.toml
Original file line number Diff line number Diff line change
@@ -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":[]}'
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The linear_set_sync_settings tool schema only declares defaultTeamId and autoPushNewTasks, but the panel calls this tool with assigneeId and syncStateNames as well. If the host validates tool args against this schema, those fields may be rejected/stripped. Update the tool definition schema to include all supported fields (and their types).

Suggested change
parameters = '{"type":"object","properties":{"defaultTeamId":{"type":"string"},"autoPushNewTasks":{"type":"boolean"}},"required":[]}'
parameters = '{"type":"object","properties":{"defaultTeamId":{"type":"string"},"autoPushNewTasks":{"type":"boolean"},"assigneeId":{"type":"string"},"syncStateNames":{"type":"array","items":{"type":"string"}}},"required":[]}'

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

linear_set_sync_settings tool schema doesn’t include fields that the panel actually sends (assigneeId and syncStateNames). If the host validates tool arguments against this JSON schema, the panel’s auto-save calls can fail/reject. Update the parameters schema to include these properties (and their types) so it matches the Rust handler/panel payload.

Suggested change
parameters = '{"type":"object","properties":{"defaultTeamId":{"type":"string"},"autoPushNewTasks":{"type":"boolean"}},"required":[]}'
parameters = '{"type":"object","properties":{"defaultTeamId":{"type":"string"},"autoPushNewTasks":{"type":"boolean"},"assigneeId":{"type":"string"},"syncStateNames":{"type":"array","items":{"type":"string"}}},"required":[]}'

Copilot uses AI. Check for mistakes.
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"
Loading
Loading