From 7d9492e0e085c53ff8ea88b22e0e2ae42cdda26d Mon Sep 17 00:00:00 2001 From: untra Date: Tue, 24 Mar 2026 06:42:14 -0600 Subject: [PATCH 1/5] version rev 0.1.28 TUI ui refactor, tightening and aligning design between these control panes advanced, more applicable controls for launching tickets vscode extension updates, structured updates for TUI registration and git provider setup Co-Authored-By: Claude Opus 4.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 42 +- VERSION | 2 +- backstage-server/package.json | 2 +- bindings/BackstageConfig.ts | 4 + bindings/CreateDelegatorFromToolRequest.ts | 31 + bindings/DefaultLlmResponse.ts | 14 + bindings/DelegatorLaunchConfig.ts | 25 +- bindings/DelegatorLaunchConfigDto.ts | 25 +- bindings/LlmToolsConfig.ts | 8 + bindings/PanelNamesConfig.ts | 2 +- bindings/SectionDefinition.ts | 7 + bindings/SectionHealth.ts | 6 + bindings/SectionId.ts | 8 + bindings/SetDefaultLlmRequest.ts | 14 + docs/_config.yml | 2 +- opr8r/Cargo.toml | 2 +- src/agents/delegator_resolution.rs | 368 ++++ src/agents/launcher/llm_command.rs | 2 + src/agents/launcher/mod.rs | 56 +- src/agents/launcher/options.rs | 8 + src/agents/launcher/tests.rs | 202 ++ src/agents/launcher/worktree_setup.rs | 7 +- src/agents/mod.rs | 1 + src/app/agents.rs | 109 +- src/app/data_sync.rs | 62 + src/app/git_onboarding.rs | 261 +++ src/app/keyboard.rs | 173 +- src/app/mod.rs | 12 +- src/app/review.rs | 10 +- src/app/status_actions.rs | 293 +++ src/app/tests.rs | 2 + src/app/tickets.rs | 32 +- src/config.rs | 55 +- src/docs_gen/shortcuts.rs | 3 + src/editors.rs | 215 ++ src/issuetypes/loader.rs | 1 + src/issuetypes/schema.rs | 5 + src/lib.rs | 1 + src/llm/detection.rs | 2 + src/main.rs | 4 +- src/queue/creator.rs | 33 +- src/rest/dto.rs | 65 +- src/rest/mod.rs | 12 +- src/rest/openapi.rs | 20 +- src/rest/routes/delegators.rs | 209 +- src/rest/routes/launch.rs | 417 ++-- src/rest/routes/llm_tools.rs | 71 +- src/ui/dashboard.rs | 393 +++- src/ui/dialogs/git_token.rs | 344 ++++ src/ui/dialogs/help.rs | 21 + src/ui/dialogs/mod.rs | 2 + src/ui/in_progress_panel.rs | 426 ++++ src/ui/keybindings.rs | 126 +- src/ui/mod.rs | 9 +- src/ui/panels.rs | 403 +--- src/ui/sections/config_section.rs | 249 +++ src/ui/sections/connections_section.rs | 252 +++ src/ui/sections/delegator_section.rs | 70 + src/ui/sections/git_section.rs | 317 +++ src/ui/sections/kanban_section.rs | 211 ++ src/ui/sections/llm_section.rs | 100 + src/ui/sections/mod.rs | 13 + src/ui/status_panel.rs | 1313 ++++++++++++ tests/feature_parity_test.rs | 95 + vscode-extension/package-lock.json | 8 +- vscode-extension/package.json | 64 +- vscode-extension/src/extension.ts | 1771 ++++++++--------- vscode-extension/src/git-onboarding.ts | 36 +- .../src/sections/config-section.ts | 80 +- .../src/sections/connections-section.ts | 12 +- .../src/sections/delegator-section.ts | 9 +- vscode-extension/src/sections/git-section.ts | 10 +- .../src/sections/issuetype-section.ts | 9 +- .../src/sections/kanban-section.ts | 8 +- vscode-extension/src/sections/llm-section.ts | 64 +- .../src/sections/managed-projects-section.ts | 9 +- vscode-extension/src/sections/types.ts | 16 +- vscode-extension/src/status-item.ts | 45 +- vscode-extension/src/status-provider.ts | 41 +- vscode-extension/src/terminal-manager.ts | 32 +- vscode-extension/src/ticket-provider.ts | 15 +- vscode-extension/src/tickets-dir.ts | 109 + vscode-extension/src/webhook-server.ts | 2 +- .../test/suite/command-registration.test.ts | 169 ++ .../test/suite/manifest-parity.test.ts | 81 + .../test/suite/terminal-manager.test.ts | 59 + vscode-extension/webview-ui/types/defaults.ts | 7 +- 89 files changed, 8044 insertions(+), 1865 deletions(-) create mode 100644 bindings/CreateDelegatorFromToolRequest.ts create mode 100644 bindings/DefaultLlmResponse.ts create mode 100644 bindings/SectionDefinition.ts create mode 100644 bindings/SectionHealth.ts create mode 100644 bindings/SectionId.ts create mode 100644 bindings/SetDefaultLlmRequest.ts create mode 100644 src/agents/delegator_resolution.rs create mode 100644 src/app/git_onboarding.rs create mode 100644 src/app/status_actions.rs create mode 100644 src/editors.rs create mode 100644 src/ui/dialogs/git_token.rs create mode 100644 src/ui/in_progress_panel.rs create mode 100644 src/ui/sections/config_section.rs create mode 100644 src/ui/sections/connections_section.rs create mode 100644 src/ui/sections/delegator_section.rs create mode 100644 src/ui/sections/git_section.rs create mode 100644 src/ui/sections/kanban_section.rs create mode 100644 src/ui/sections/llm_section.rs create mode 100644 src/ui/sections/mod.rs create mode 100644 src/ui/status_panel.rs create mode 100644 vscode-extension/src/tickets-dir.ts create mode 100644 vscode-extension/test/suite/command-registration.test.ts create mode 100644 vscode-extension/test/suite/manifest-parity.test.ts create mode 100644 vscode-extension/test/suite/terminal-manager.test.ts diff --git a/Cargo.lock b/Cargo.lock index f54bb33..3de48a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2003,7 +2003,7 @@ dependencies = [ [[package]] name = "operator" -version = "0.1.27" +version = "0.1.28" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index d06fd48..7aa2de3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "operator" -version = "0.1.27" +version = "0.1.28" edition = "2021" description = "Multi-agent orchestration dashboard for gbqr.us" authors = ["gbqr.us"] diff --git a/README.md b/README.md index beb5d90..36c749d 100644 --- a/README.md +++ b/README.md @@ -151,29 +151,25 @@ Within each priority level, tickets are processed FIFO by timestamp. ## Dashboard Layout ``` -┌─────────────────────────────────────────────────────────────┐ -│ operator v0.1.0 ▶ RUNNING 5/7 agents │ -├─────────────┬─────────────┬─────────────┬───────────────────┤ -│ QUEUE (12) │ RUNNING (5) │ AWAITING (1)│ COMPLETED (8) │ -├─────────────┼─────────────┼─────────────┼───────────────────┤ -│ INV-003 ‼️ │ backend │ SPIKE-015 │ ✓ FEAT-040 12:30 │ -│ FIX-089 │ FEAT-042 │ "what auth │ ✓ FIX-088 12:15 │ -│ FIX-090 │ ██████░░ │ pattern?" │ ✓ FEAT-041 11:45 │ -│ FEAT-043 │ frontend │ │ ✓ FIX-087 11:30 │ -│ FEAT-044 │ FIX-091 │ [R]espond │ │ -│ FEAT-045 │ ████░░░░ │ │ │ -│ │ api │ │ │ -│ │ FEAT-046 │ │ │ -│ │ ██░░░░░░ │ │ │ -│ │ admin │ │ │ -│ │ FEAT-047 │ │ │ -│ │ █████████ │ │ │ -│ │ infra │ │ │ -│ │ FIX-092 │ │ │ -│ │ ███░░░░░ │ │ │ -├─────────────┴─────────────┴─────────────┴───────────────────┤ -│ [Q]ueue [L]aunch [P]ause [R]esume [A]gents [N]otifs [?]Help│ -└─────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────┐ +│ operator v0.1.28 ▶ RUNNING 5/7 │ +├──────────┬────────────┬──────────────────┬───────────────────┤ +│ STATUS │ QUEUE (12) │ IN PROGRESS (5) │ DONE (8) │ +├──────────┼────────────┼──────────────────┼───────────────────┤ +│ ▾ Config │ INV-003 ‼️ │ A▶ backend │ ✓ FEAT-040 12:30 │ +│ ✓ dir │ FIX-089 │ FEAT-042 5m │ ✓ FIX-088 12:15 │ +│ ✓ cfg │ FIX-090 │ A▶ frontend │ ✓ FEAT-041 11:45 │ +│ ✓ tkts │ FEAT-043 │ FIX-091 3m │ ✓ FIX-087 11:30 │ +│ ▾ Conns │ FEAT-044 │ C⏸ api │ │ +│ ✓ API │ FEAT-045 │ SPIKE-015 12m │ │ +│ ✓ Web │ │ Awaiting input │ │ +│ tmux │ │ A▶ admin │ │ +│ ▸ Kanban │ │ FEAT-047 1m │ │ +│ ▸ LLM │ │ A▶ infra │ │ +│ ▸ Git │ │ FIX-092 8m │ │ +├──────────┴────────────┴──────────────────┴───────────────────┤ +│ [Q]ueue [L]aunch [P]ause [R]esume [A]gents [?]Help [q]uit │ +└──────────────────────────────────────────────────────────────┘ ``` ## Keyboard Shortcuts diff --git a/VERSION b/VERSION index a2e1aa9..baec65a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.27 +0.1.28 diff --git a/backstage-server/package.json b/backstage-server/package.json index 7f6b2e2..66c13c6 100644 --- a/backstage-server/package.json +++ b/backstage-server/package.json @@ -1,6 +1,6 @@ { "name": "operator-backstage", - "version": "0.1.27", + "version": "0.1.28", "author": { "name": "Samuel Volin", "email": "untra.sam@gmail.com", diff --git a/bindings/BackstageConfig.ts b/bindings/BackstageConfig.ts index 77aa8ae..9a22876 100644 --- a/bindings/BackstageConfig.ts +++ b/bindings/BackstageConfig.ts @@ -9,6 +9,10 @@ export type BackstageConfig = { * Whether Backstage integration is enabled */ enabled: boolean, +/** + * Whether to show Backstage in the Connections status section + */ +display: boolean, /** * Port for the Backstage server */ diff --git a/bindings/CreateDelegatorFromToolRequest.ts b/bindings/CreateDelegatorFromToolRequest.ts new file mode 100644 index 0000000..4c5a9a0 --- /dev/null +++ b/bindings/CreateDelegatorFromToolRequest.ts @@ -0,0 +1,31 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DelegatorLaunchConfigDto } from "./DelegatorLaunchConfigDto"; + +/** + * Request to create a delegator from a detected LLM tool + * + * Pre-populates delegator fields from the detected tool, requiring minimal input. + * If `name` is omitted, auto-generates as `"{tool_name}-{model}"`. + * If `model` is omitted, uses the tool's first model alias. + */ +export type CreateDelegatorFromToolRequest = { +/** + * Name of the detected tool (e.g., "claude", "codex", "gemini") + */ +tool_name: string, +/** + * Model alias to use (e.g., "opus"). If omitted, uses the tool's first model alias. + */ +model: string | null, +/** + * Custom delegator name. If omitted, auto-generates as `"{tool_name}-{model}"`. + */ +name: string | null, +/** + * Optional display name for UI + */ +display_name: string | null, +/** + * Optional launch configuration + */ +launch_config: DelegatorLaunchConfigDto | null, }; diff --git a/bindings/DefaultLlmResponse.ts b/bindings/DefaultLlmResponse.ts new file mode 100644 index 0000000..1cd6b73 --- /dev/null +++ b/bindings/DefaultLlmResponse.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Response with the current default LLM tool and model + */ +export type DefaultLlmResponse = { +/** + * Default tool name (empty string if not set) + */ +tool: string, +/** + * Default model alias (empty string if not set) + */ +model: string, }; diff --git a/bindings/DelegatorLaunchConfig.ts b/bindings/DelegatorLaunchConfig.ts index 545303d..aee60b0 100644 --- a/bindings/DelegatorLaunchConfig.ts +++ b/bindings/DelegatorLaunchConfig.ts @@ -2,6 +2,9 @@ /** * Launch configuration for a delegator + * + * Controls how the delegator launches agents. Optional fields use tri-state + * semantics: `None` = inherit from global config, `Some(true/false)` = override. */ export type DelegatorLaunchConfig = { /** @@ -15,4 +18,24 @@ permission_mode: string | null, /** * Additional CLI flags */ -flags: Array, }; +flags: Array, +/** + * Override global `git.use_worktrees` per-delegator (None = use global setting) + */ +use_worktrees: boolean | null, +/** + * Whether to create a git branch for the ticket (None = default behavior) + */ +create_branch: boolean | null, +/** + * Run in docker container (None = use global `launch.docker.enabled`) + */ +docker: boolean | null, +/** + * Prompt text to prepend before the generated step prompt + */ +prompt_prefix: string | null, +/** + * Prompt text to append after the generated step prompt + */ +prompt_suffix: string | null, }; diff --git a/bindings/DelegatorLaunchConfigDto.ts b/bindings/DelegatorLaunchConfigDto.ts index f1ae1a5..aa17fb4 100644 --- a/bindings/DelegatorLaunchConfigDto.ts +++ b/bindings/DelegatorLaunchConfigDto.ts @@ -2,6 +2,9 @@ /** * Launch configuration DTO for delegators + * + * Optional fields use tri-state semantics: `None` = inherit global config, + * `Some(true/false)` = explicit override per-delegator. */ export type DelegatorLaunchConfigDto = { /** @@ -15,4 +18,24 @@ permission_mode: string | null, /** * Additional CLI flags */ -flags: Array, }; +flags: Array, +/** + * Override global `git.use_worktrees` (None = use global setting) + */ +use_worktrees: boolean | null, +/** + * Whether to create a git branch for the ticket (None = default behavior) + */ +create_branch: boolean | null, +/** + * Run in docker container (None = use global `launch.docker.enabled`) + */ +docker: boolean | null, +/** + * Prompt text to prepend before the generated step prompt + */ +prompt_prefix: string | null, +/** + * Prompt text to append after the generated step prompt + */ +prompt_suffix: string | null, }; diff --git a/bindings/LlmToolsConfig.ts b/bindings/LlmToolsConfig.ts index c79a97a..d67e8ac 100644 --- a/bindings/LlmToolsConfig.ts +++ b/bindings/LlmToolsConfig.ts @@ -20,6 +20,14 @@ providers: Array, * Whether detection has been completed */ detection_complete: boolean, +/** + * User's preferred default LLM tool (e.g., "claude") + */ +default_tool: string | null, +/** + * User's preferred default model alias (e.g., "opus") + */ +default_model: string | null, /** * Per-tool overrides for skill directories (keyed by `tool_name`) */ diff --git a/bindings/PanelNamesConfig.ts b/bindings/PanelNamesConfig.ts index d8e9fb6..38e49e8 100644 --- a/bindings/PanelNamesConfig.ts +++ b/bindings/PanelNamesConfig.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type PanelNamesConfig = { queue: string, agents: string, awaiting: string, completed: string, }; +export type PanelNamesConfig = { status: string, queue: string, in_progress: string, completed: string, }; diff --git a/bindings/SectionDefinition.ts b/bindings/SectionDefinition.ts new file mode 100644 index 0000000..3b0bb8d --- /dev/null +++ b/bindings/SectionDefinition.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SectionId } from "./SectionId"; + +/** + * Declarative section metadata — shared between TUI and `VSCode`. + */ +export type SectionDefinition = { id: SectionId, label: string, prerequisites: Array, }; diff --git a/bindings/SectionHealth.ts b/bindings/SectionHealth.ts new file mode 100644 index 0000000..422f675 --- /dev/null +++ b/bindings/SectionHealth.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Health state of a section — controls the header color. + */ +export type SectionHealth = "Green" | "Yellow" | "Red" | "Gray"; diff --git a/bindings/SectionId.ts b/bindings/SectionId.ts new file mode 100644 index 0000000..e3bfcca --- /dev/null +++ b/bindings/SectionId.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Identifies a collapsible section in the status tree. + * + * String values match the `sectionId` used in the `VSCode` extension tree routing. + */ +export type SectionId = "config" | "connections" | "kanban" | "llm" | "git" | "issuetypes" | "delegators" | "projects"; diff --git a/bindings/SetDefaultLlmRequest.ts b/bindings/SetDefaultLlmRequest.ts new file mode 100644 index 0000000..44687d8 --- /dev/null +++ b/bindings/SetDefaultLlmRequest.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Request to set the global default LLM tool and model + */ +export type SetDefaultLlmRequest = { +/** + * Tool name (must match a detected tool, e.g., "claude") + */ +tool: string, +/** + * Model alias (e.g., "opus", "sonnet") + */ +model: string, }; diff --git a/docs/_config.yml b/docs/_config.yml index e80ea3e..d0b134b 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -42,7 +42,7 @@ collections_dir: . # Permalink structure permalink: pretty -version: 0.1.27 +version: 0.1.28 # Google Analytics ga_tag: G-5JZPJWWT7S # Replace with actual GA4 measurement ID from analytics.google.com diff --git a/opr8r/Cargo.toml b/opr8r/Cargo.toml index 569a90c..f53355e 100644 --- a/opr8r/Cargo.toml +++ b/opr8r/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opr8r" -version = "0.1.27" +version = "0.1.28" edition = "2021" description = "Minimal CLI wrapper for LLM commands in multi-step ticket workflows" license = "MIT" diff --git a/src/agents/delegator_resolution.rs b/src/agents/delegator_resolution.rs new file mode 100644 index 0000000..cba929b --- /dev/null +++ b/src/agents/delegator_resolution.rs @@ -0,0 +1,368 @@ +//! Shared delegator resolution logic for building `LaunchOptions`. +//! +//! Used by both the REST API launch endpoint and the TUI auto-launch path. + +use crate::agents::LaunchOptions; +use crate::config::{Config, Delegator, DelegatorLaunchConfig, LlmProvider}; + +/// Issuetype/step agent context for delegator resolution during launch. +/// +/// Extracted from the issuetype registry before calling resolution, +/// so the registry read lock doesn't need to be held across the entire call. +pub struct AgentContext { + /// Agent (delegator) name from the ticket's current step (highest priority) + pub step_agent: Option, + /// Agent (delegator) name from the issuetype level (fallback) + pub issuetype_agent: Option, +} + +/// Error type for delegator resolution failures. +#[derive(Debug, thiserror::Error)] +pub enum ResolutionError { + #[error("Unknown delegator '{0}'")] + UnknownDelegator(String), + #[error("Unknown provider '{0}'")] + UnknownProvider(String), +} + +/// Convert a `Delegator` into an `LlmProvider` +fn delegator_to_provider(d: &Delegator) -> LlmProvider { + LlmProvider { + tool: d.llm_tool.clone(), + model: d.model.clone(), + ..Default::default() + } +} + +/// Apply a delegator's launch config to launch options +fn apply_delegator_launch_config( + options: &mut LaunchOptions, + launch_config: &Option, +) { + if let Some(ref lc) = launch_config { + options.yolo_mode = options.yolo_mode || lc.yolo; + options.extra_flags.clone_from(&lc.flags); + if let Some(docker) = lc.docker { + options.docker_mode = docker; + } + options.use_worktrees_override = lc.use_worktrees; + options.create_branch_override = lc.create_branch; + options.prompt_prefix.clone_from(&lc.prompt_prefix); + options.prompt_suffix.clone_from(&lc.prompt_suffix); + } +} + +/// Resolve a default delegator when none is explicitly specified. +/// +/// Resolution chain: +/// 1. Single configured delegator -> use it +/// 2. Delegator matching the user's preferred LLM tool -> use it +/// 3. None -> caller falls back to first detected tool + first model alias +fn resolve_default_delegator(config: &Config) -> Option<&Delegator> { + match config.delegators.len() { + 0 => None, + 1 => Some(&config.delegators[0]), + _ => { + let preferred_tool = config + .llm_tools + .default_tool + .as_deref() + .or_else(|| config.llm_tools.detected.first().map(|t| t.name.as_str())); + if let Some(tool_name) = preferred_tool { + config.delegators.iter().find(|d| d.llm_tool == tool_name) + } else { + Some(&config.delegators[0]) + } + } + } +} + +/// Look up a delegator by name in the config +fn resolve_delegator_by_name<'a>(config: &'a Config, name: &str) -> Option<&'a Delegator> { + config.delegators.iter().find(|d| d.name == name) +} + +/// Resolve launch options from config, an optional explicit request, and agent context. +/// +/// Resolution chain (highest to lowest priority): +/// 1. Explicit delegator name +/// 2. Step-level agent from issuetype +/// 3. Issuetype-level agent +/// 4. Legacy provider/model +/// 5. Default delegator from config +/// 6. Detected tool defaults +pub fn resolve_launch_options( + config: &Config, + explicit_delegator: Option<&str>, + explicit_provider: Option<&str>, + explicit_model: Option<&str>, + yolo_mode: bool, + agent_context: Option<&AgentContext>, +) -> Result { + let mut options = LaunchOptions { + yolo_mode, + ..Default::default() + }; + + // 1. Explicit delegator name takes precedence + if let Some(delegator_name) = explicit_delegator { + let delegator = config + .delegators + .iter() + .find(|d| d.name == delegator_name) + .ok_or_else(|| ResolutionError::UnknownDelegator(delegator_name.to_string()))?; + + options.provider = Some(delegator_to_provider(delegator)); + options.delegator_name = Some(delegator.name.clone()); + apply_delegator_launch_config(&mut options, &delegator.launch_config); + return Ok(options); + } + + // 2. Step-level agent from issuetype template + if let Some(ctx) = agent_context { + if let Some(ref step_agent) = ctx.step_agent { + if let Some(delegator) = resolve_delegator_by_name(config, step_agent) { + options.provider = Some(delegator_to_provider(delegator)); + options.delegator_name = Some(delegator.name.clone()); + apply_delegator_launch_config(&mut options, &delegator.launch_config); + return Ok(options); + } + // Step agent name doesn't match any delegator — fall through + } + + // 3. Issuetype-level agent + if let Some(ref it_agent) = ctx.issuetype_agent { + if let Some(delegator) = resolve_delegator_by_name(config, it_agent) { + options.provider = Some(delegator_to_provider(delegator)); + options.delegator_name = Some(delegator.name.clone()); + apply_delegator_launch_config(&mut options, &delegator.launch_config); + return Ok(options); + } + } + } + + // 4. Legacy: explicit provider/model + if let Some(provider_name) = explicit_provider { + let provider = config + .llm_tools + .providers + .iter() + .find(|p| p.tool == *provider_name) + .cloned(); + + if let Some(p) = provider { + let model = explicit_model + .map(std::string::ToString::to_string) + .unwrap_or(p.model.clone()); + options.provider = Some(LlmProvider { + tool: p.tool, + model, + ..Default::default() + }); + } else { + return Err(ResolutionError::UnknownProvider(provider_name.to_string())); + } + + return Ok(options); + } + + if let Some(model) = explicit_model { + if let Some(p) = config.llm_tools.providers.first().cloned() { + options.provider = Some(LlmProvider { + tool: p.tool, + model: model.to_string(), + ..Default::default() + }); + } + + return Ok(options); + } + + // 5. No explicit selection — resolve default delegator + if let Some(delegator) = resolve_default_delegator(config) { + options.provider = Some(delegator_to_provider(delegator)); + options.delegator_name = Some(delegator.name.clone()); + apply_delegator_launch_config(&mut options, &delegator.launch_config); + return Ok(options); + } + + // 6. No delegators at all — fall back to default tool/model or first detected + let tool = config + .llm_tools + .default_tool + .as_deref() + .and_then(|name| config.llm_tools.detected.iter().find(|t| t.name == name)) + .or_else(|| config.llm_tools.detected.first()); + + if let Some(tool) = tool { + let model = config + .llm_tools + .default_model + .clone() + .or_else(|| tool.model_aliases.first().cloned()) + .unwrap_or_else(|| "default".to_string()); + options.provider = Some(LlmProvider { + tool: tool.name.clone(), + model, + ..Default::default() + }); + } + + Ok(options) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + + fn make_delegator(name: &str, tool: &str, model: &str) -> Delegator { + Delegator { + name: name.to_string(), + llm_tool: tool.to_string(), + model: model.to_string(), + display_name: None, + model_properties: std::collections::HashMap::new(), + launch_config: None, + } + } + + #[test] + fn test_resolve_default_no_delegators() { + let config = Config::default(); + let options = resolve_launch_options(&config, None, None, None, false, None).unwrap(); + assert!(options.provider.is_none()); + assert!(!options.yolo_mode); + } + + #[test] + fn test_resolve_single_delegator_is_default() { + let mut config = Config::default(); + config + .delegators + .push(make_delegator("claude-opus", "claude", "opus")); + + let options = resolve_launch_options(&config, None, None, None, false, None).unwrap(); + let provider = options.provider.unwrap(); + assert_eq!(provider.tool, "claude"); + assert_eq!(provider.model, "opus"); + assert_eq!(options.delegator_name.as_deref(), Some("claude-opus")); + } + + #[test] + fn test_resolve_explicit_delegator() { + let mut config = Config::default(); + config + .delegators + .push(make_delegator("claude-opus", "claude", "opus")); + config + .delegators + .push(make_delegator("gemini-pro", "gemini", "pro")); + + let options = + resolve_launch_options(&config, Some("gemini-pro"), None, None, false, None).unwrap(); + let provider = options.provider.unwrap(); + assert_eq!(provider.tool, "gemini"); + assert_eq!(provider.model, "pro"); + } + + #[test] + fn test_resolve_unknown_delegator_errors() { + let config = Config::default(); + let result = resolve_launch_options(&config, Some("nonexistent"), None, None, false, None); + assert!(result.is_err()); + } + + #[test] + fn test_resolve_step_agent_overrides_issuetype() { + let mut config = Config::default(); + config + .delegators + .push(make_delegator("claude-opus", "claude", "opus")); + config + .delegators + .push(make_delegator("claude-sonnet", "claude", "sonnet")); + + let ctx = AgentContext { + step_agent: Some("claude-opus".to_string()), + issuetype_agent: Some("claude-sonnet".to_string()), + }; + + let options = resolve_launch_options(&config, None, None, None, false, Some(&ctx)).unwrap(); + let provider = options.provider.unwrap(); + assert_eq!(provider.model, "opus"); + } + + #[test] + fn test_resolve_issuetype_agent_fallback() { + let mut config = Config::default(); + config + .delegators + .push(make_delegator("claude-opus", "claude", "opus")); + + let ctx = AgentContext { + step_agent: None, + issuetype_agent: Some("claude-opus".to_string()), + }; + + let options = resolve_launch_options(&config, None, None, None, false, Some(&ctx)).unwrap(); + let provider = options.provider.unwrap(); + assert_eq!(provider.model, "opus"); + } + + #[test] + fn test_resolve_unknown_step_agent_falls_through() { + let mut config = Config::default(); + config + .delegators + .push(make_delegator("claude-opus", "claude", "opus")); + + let ctx = AgentContext { + step_agent: Some("nonexistent".to_string()), + issuetype_agent: Some("claude-opus".to_string()), + }; + + let options = resolve_launch_options(&config, None, None, None, false, Some(&ctx)).unwrap(); + let provider = options.provider.unwrap(); + assert_eq!(provider.model, "opus"); + } + + #[test] + fn test_resolve_delegator_applies_launch_config() { + let mut config = Config::default(); + config.delegators.push(Delegator { + name: "full".to_string(), + llm_tool: "claude".to_string(), + model: "opus".to_string(), + display_name: None, + model_properties: std::collections::HashMap::new(), + launch_config: Some(DelegatorLaunchConfig { + yolo: true, + permission_mode: None, + flags: vec!["--verbose".to_string()], + use_worktrees: Some(true), + create_branch: Some(false), + docker: Some(true), + prompt_prefix: Some("PREFIX".to_string()), + prompt_suffix: Some("SUFFIX".to_string()), + }), + }); + + let options = + resolve_launch_options(&config, Some("full"), None, None, false, None).unwrap(); + assert!(options.yolo_mode); + assert!(options.docker_mode); + assert_eq!(options.use_worktrees_override, Some(true)); + assert_eq!(options.create_branch_override, Some(false)); + assert_eq!(options.extra_flags, vec!["--verbose".to_string()]); + assert_eq!(options.prompt_prefix.as_deref(), Some("PREFIX")); + assert_eq!(options.prompt_suffix.as_deref(), Some("SUFFIX")); + } + + #[test] + fn test_resolve_yolo_passthrough() { + let config = Config::default(); + let options = resolve_launch_options(&config, None, None, None, true, None).unwrap(); + assert!(options.yolo_mode); + } +} diff --git a/src/agents/launcher/llm_command.rs b/src/agents/launcher/llm_command.rs index 933e9b9..421a023 100644 --- a/src/agents/launcher/llm_command.rs +++ b/src/agents/launcher/llm_command.rs @@ -278,6 +278,8 @@ mod tests { }], detection_complete: true, skill_directory_overrides: std::collections::HashMap::new(), + default_tool: None, + default_model: None, }, ..Default::default() } diff --git a/src/agents/launcher/mod.rs b/src/agents/launcher/mod.rs index 053dfbd..833581a 100644 --- a/src/agents/launcher/mod.rs +++ b/src/agents/launcher/mod.rs @@ -50,6 +50,16 @@ use self::prompt::{ /// Session name prefix for operator-managed tmux sessions pub const SESSION_PREFIX: &str = "op-"; +/// Apply delegator prompt prefix/suffix wrapping to a generated prompt +fn apply_prompt_wrapping(prompt: String, options: &LaunchOptions) -> String { + match (&options.prompt_prefix, &options.prompt_suffix) { + (Some(pre), Some(suf)) => format!("{pre}\n\n{prompt}\n\n{suf}"), + (Some(pre), None) => format!("{pre}\n\n{prompt}"), + (None, Some(suf)) => format!("{prompt}\n\n{suf}"), + (None, None) => prompt, + } +} + /// Result of preparing a launch without executing it /// /// Contains all the information needed to launch an agent in any wrapper @@ -237,9 +247,14 @@ impl Launcher { }; // Setup worktree for per-ticket isolation (if project is a git repo) - let working_dir = setup_worktree_for_ticket(&self.config, &mut ticket, &project_path) - .await - .context("Failed to setup worktree for ticket")?; + let working_dir = setup_worktree_for_ticket( + &self.config, + &mut ticket, + &project_path, + options.use_worktrees_override, + ) + .await + .context("Failed to setup worktree for ticket")?; // Deploy operator skills for all tools this ticket may use across steps let primary_tool = options @@ -256,6 +271,7 @@ impl Launcher { // Generate the initial prompt for the agent let initial_prompt = generate_prompt(&self.config, &ticket); + let initial_prompt = apply_prompt_wrapping(initial_prompt, &options); // Dispatch based on session wrapper type let (session_name, wrapper_name, cmux_refs) = @@ -413,7 +429,7 @@ impl Launcher { }; // Setup worktree for per-ticket isolation (if project is a git repo) - let working_dir = setup_worktree_for_ticket(&self.config, &mut ticket, &project_path) + let working_dir = setup_worktree_for_ticket(&self.config, &mut ticket, &project_path, None) .await .context("Failed to setup worktree for ticket")?; @@ -481,6 +497,7 @@ impl Launcher { // Build the full prompt using the interpolation engine let initial_prompt = generate_prompt(&self.config, &ticket); + let initial_prompt = apply_prompt_wrapping(initial_prompt, &options); let full_prompt = if get_template_prompt(&ticket.ticket_type).is_some() { let interpolator = PromptInterpolator::new(); match interpolator.build_launch_prompt(&self.config, &ticket, &working_dir_str) { @@ -619,6 +636,8 @@ impl Launcher { PathBuf::from(self.get_project_path(&ticket)?) }; + let worktree_override = options.launch_options.use_worktrees_override; + // Get working directory (reuse existing worktree or create new one) let working_dir = if let Some(ref worktree_path) = ticket.worktree_path { let path = PathBuf::from(worktree_path); @@ -627,13 +646,18 @@ impl Launcher { path } else { // Worktree was deleted, recreate it - setup_worktree_for_ticket(&self.config, &mut ticket, &project_path) - .await - .context("Failed to recreate worktree for ticket")? + setup_worktree_for_ticket( + &self.config, + &mut ticket, + &project_path, + worktree_override, + ) + .await + .context("Failed to recreate worktree for ticket")? } } else { // No worktree yet, try to create one - setup_worktree_for_ticket(&self.config, &mut ticket, &project_path) + setup_worktree_for_ticket(&self.config, &mut ticket, &project_path, worktree_override) .await .context("Failed to setup worktree for ticket")? }; @@ -706,6 +730,7 @@ impl Launcher { // Build the full prompt using the interpolation engine let initial_prompt = generate_prompt(&self.config, &ticket); + let initial_prompt = apply_prompt_wrapping(initial_prompt, &options.launch_options); let mut full_prompt = if get_template_prompt(&ticket.ticket_type).is_some() { let interpolator = PromptInterpolator::new(); match interpolator.build_launch_prompt(&self.config, &ticket, &working_dir_str) { @@ -848,6 +873,7 @@ impl Launcher { // Get working directory (use existing worktree, or setup new one) let project_path = PathBuf::from(self.get_project_path(&ticket)?); + let worktree_override = options.launch_options.use_worktrees_override; let working_dir = if let Some(ref worktree_path) = ticket.worktree_path { let path = PathBuf::from(worktree_path); if path.exists() { @@ -855,13 +881,18 @@ impl Launcher { path } else { // Worktree was deleted, recreate it - setup_worktree_for_ticket(&self.config, &mut ticket, &project_path) - .await - .context("Failed to recreate worktree for ticket")? + setup_worktree_for_ticket( + &self.config, + &mut ticket, + &project_path, + worktree_override, + ) + .await + .context("Failed to recreate worktree for ticket")? } } else { // No worktree yet, try to create one - setup_worktree_for_ticket(&self.config, &mut ticket, &project_path) + setup_worktree_for_ticket(&self.config, &mut ticket, &project_path, worktree_override) .await .context("Failed to setup worktree for ticket")? }; @@ -882,6 +913,7 @@ impl Launcher { // Generate the initial prompt for the agent let initial_prompt = generate_prompt(&self.config, &ticket); + let initial_prompt = apply_prompt_wrapping(initial_prompt, &options.launch_options); // Dispatch based on session wrapper type let (session_name, wrapper_name, cmux_refs) = diff --git a/src/agents/launcher/options.rs b/src/agents/launcher/options.rs index 6fd1a51..df99645 100644 --- a/src/agents/launcher/options.rs +++ b/src/agents/launcher/options.rs @@ -17,6 +17,14 @@ pub struct LaunchOptions { pub yolo_mode: bool, /// Override project path (if None, use ticket's project) pub project_override: Option, + /// Override global `git.use_worktrees` from delegator (None = use global config) + pub use_worktrees_override: Option, + /// Override branch creation from delegator (None = default behavior) + pub create_branch_override: Option, + /// Prompt text to prepend before the generated step prompt + pub prompt_prefix: Option, + /// Prompt text to append after the generated step prompt + pub prompt_suffix: Option, } impl LaunchOptions { diff --git a/src/agents/launcher/tests.rs b/src/agents/launcher/tests.rs index 136bf07..2e069b4 100644 --- a/src/agents/launcher/tests.rs +++ b/src/agents/launcher/tests.rs @@ -71,6 +71,8 @@ fn make_test_config(temp_dir: &TempDir) -> Config { }], detection_complete: true, skill_directory_overrides: std::collections::HashMap::new(), + default_tool: None, + default_model: None, }, // Disable notifications in tests to avoid DBus requirement on Linux CI notifications: crate::config::NotificationsConfig { @@ -1244,3 +1246,203 @@ fn test_relaunch_missing_prompt_fresh_start() { "Should fall back to fresh start when prompt file missing, got: {script_content}" ); } + +#[test] +fn test_apply_prompt_wrapping_both() { + let options = LaunchOptions { + prompt_prefix: Some("PREFIX".to_string()), + prompt_suffix: Some("SUFFIX".to_string()), + ..Default::default() + }; + let result = super::apply_prompt_wrapping("BODY".to_string(), &options); + assert_eq!(result, "PREFIX\n\nBODY\n\nSUFFIX"); +} + +#[test] +fn test_apply_prompt_wrapping_prefix_only() { + let options = LaunchOptions { + prompt_prefix: Some("PREFIX".to_string()), + ..Default::default() + }; + let result = super::apply_prompt_wrapping("BODY".to_string(), &options); + assert_eq!(result, "PREFIX\n\nBODY"); +} + +#[test] +fn test_apply_prompt_wrapping_suffix_only() { + let options = LaunchOptions { + prompt_suffix: Some("SUFFIX".to_string()), + ..Default::default() + }; + let result = super::apply_prompt_wrapping("BODY".to_string(), &options); + assert_eq!(result, "BODY\n\nSUFFIX"); +} + +#[test] +fn test_apply_prompt_wrapping_none() { + let options = LaunchOptions::default(); + let result = super::apply_prompt_wrapping("BODY".to_string(), &options); + assert_eq!(result, "BODY"); +} + +// --- Project directory and permission layering tests --- + +#[test] +fn test_launch_correct_project_directory_from_ticket() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + let mock = Arc::new(MockTmuxClient::new()); + let tmux: Arc = mock.clone(); + let ticket = make_test_ticket("test-project"); + let project_path = temp_dir + .path() + .join("projects") + .join("test-project") + .to_string_lossy() + .to_string(); + let options = LaunchOptions::default(); + + let result = super::tmux_session::launch_in_tmux_with_options( + &config, + &tmux, + &ticket, + &project_path, + "Test prompt", + &options, + ); + + assert!(result.is_ok()); + let session_name = result.unwrap(); + let working_dir = mock.get_session_working_dir(&session_name); + assert!(working_dir.is_some(), "Session should have been created"); + assert_eq!(working_dir.unwrap(), project_path); +} + +#[test] +fn test_launch_with_different_project_directories() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + // Create a second project + let second_project = temp_dir.path().join("projects").join("second-project"); + std::fs::create_dir_all(&second_project).unwrap(); + std::fs::write(second_project.join("CLAUDE.md"), "# Second").unwrap(); + + let mock = Arc::new(MockTmuxClient::new()); + let launcher = Launcher::with_tmux_client(&config, mock).unwrap(); + + let ticket_a = make_test_ticket("test-project"); + let ticket_b = make_test_ticket("second-project"); + + let path_a = launcher.get_project_path(&ticket_a).unwrap(); + let path_b = launcher.get_project_path(&ticket_b).unwrap(); + + assert_ne!(path_a, path_b); + assert!(path_a.ends_with("test-project")); + assert!(path_b.ends_with("second-project")); +} + +#[test] +fn test_launch_provider_from_delegator_determines_tool() { + let temp_dir = TempDir::new().unwrap(); + let mut config = make_test_config(&temp_dir); + // Add codex as a detected tool + config.llm_tools.detected.push(crate::config::DetectedTool { + name: "codex".to_string(), + path: "/usr/bin/codex".to_string(), + version: "1.0.0".to_string(), + min_version: None, + version_ok: true, + model_aliases: vec!["o3".to_string()], + command_template: + "codex {{config_flags}}{{model_flag}}--session {{session_id}} --prompt {{prompt_file}}" + .to_string(), + capabilities: crate::config::ToolCapabilities::default(), + yolo_flags: vec!["--full-auto".to_string()], + }); + + let mock = Arc::new(MockTmuxClient::new()); + let tmux: Arc = mock.clone(); + let ticket = make_test_ticket("test-project"); + let project_path = temp_dir + .path() + .join("projects") + .join("test-project") + .to_string_lossy() + .to_string(); + + // Launch with codex provider (simulating a delegator that maps to codex) + let options = LaunchOptions { + provider: Some(crate::config::LlmProvider { + tool: "codex".to_string(), + model: "o3".to_string(), + ..Default::default() + }), + ..Default::default() + }; + + let result = super::tmux_session::launch_in_tmux_with_options( + &config, + &tmux, + &ticket, + &project_path, + "Test prompt", + &options, + ); + + assert!(result.is_ok()); + let session_name = result.unwrap(); + let keys_sent = mock.get_session_keys_sent(&session_name); + let sent_cmd = &keys_sent.unwrap()[0]; + + let script_content = read_command_file_content(sent_cmd).expect("Should read command file"); + assert!( + script_content.contains("codex"), + "Command should use codex tool, got: {script_content}" + ); + assert!( + script_content.contains("--model o3"), + "Command should use o3 model, got: {script_content}" + ); +} + +#[test] +fn test_launch_yolo_flags_per_tool() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + let mock = Arc::new(MockTmuxClient::new()); + let tmux: Arc = mock.clone(); + let ticket = make_test_ticket("test-project"); + let project_path = temp_dir + .path() + .join("projects") + .join("test-project") + .to_string_lossy() + .to_string(); + + // Claude's yolo flag is --dangerously-skip-permissions + let options = LaunchOptions { + yolo_mode: true, + ..Default::default() + }; + + let result = super::tmux_session::launch_in_tmux_with_options( + &config, + &tmux, + &ticket, + &project_path, + "Test prompt", + &options, + ); + + assert!(result.is_ok()); + let session_name = result.unwrap(); + let keys_sent = mock.get_session_keys_sent(&session_name); + let sent_cmd = &keys_sent.unwrap()[0]; + + let script_content = read_command_file_content(sent_cmd).expect("Should read command file"); + assert!( + script_content.contains("--dangerously-skip-permissions"), + "Claude yolo should use --dangerously-skip-permissions, got: {script_content}" + ); +} diff --git a/src/agents/launcher/worktree_setup.rs b/src/agents/launcher/worktree_setup.rs index 5bff98d..2b6c1a3 100644 --- a/src/agents/launcher/worktree_setup.rs +++ b/src/agents/launcher/worktree_setup.rs @@ -47,6 +47,7 @@ pub fn branch_name_for_ticket(ticket: &Ticket) -> String { /// * `config` - Operator configuration /// * `ticket` - The ticket to create a worktree for (will be mutated to set `worktree_path` and branch) /// * `project_path` - Path to the project directory +/// * `use_worktrees_override` - Per-delegator override for worktree behavior (None = use global config) /// /// # Returns /// * `Ok(PathBuf)` - The path to use as working directory (worktree or project) @@ -55,9 +56,11 @@ pub async fn setup_worktree_for_ticket( config: &Config, ticket: &mut Ticket, project_path: &Path, + use_worktrees_override: Option, ) -> Result { - // Check if worktrees are enabled in config - if !config.git.use_worktrees { + // Check if worktrees are enabled (delegator override takes precedence over global config) + let use_worktrees = use_worktrees_override.unwrap_or(config.git.use_worktrees); + if !use_worktrees { // Use branch-only workflow instead return setup_branch_for_ticket(config, ticket, project_path).await; } diff --git a/src/agents/mod.rs b/src/agents/mod.rs index 4ad534f..e0d6edc 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -5,6 +5,7 @@ pub mod activity; pub mod agent_switcher; pub mod artifact_detector; pub mod cmux; +pub mod delegator_resolution; mod generator; pub mod hooks; pub mod idle_detector; diff --git a/src/app/agents.rs b/src/app/agents.rs index 5a2c768..015a428 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -187,6 +187,92 @@ impl App { Ok(()) } + /// Auto-launch the selected ticket using the delegator resolution chain, + /// skipping the confirmation dialog. + pub(super) async fn auto_launch(&mut self) -> Result<()> { + // Same validation as try_launch + let state = State::load(&self.config)?; + let running_count = state.running_agents().len(); + let max = self.config.effective_max_agents(); + + if running_count >= max { + self.dashboard.set_status(&format!( + "Cannot launch: {running_count}/{max} agents active" + )); + return Ok(()); + } + + if self.dashboard.paused { + self.dashboard.set_status("Cannot launch: queue is paused"); + return Ok(()); + } + + let Some(ticket) = self.dashboard.selected_ticket().cloned() else { + return Ok(()); + }; + + if state.is_project_busy(&ticket.project) { + self.dashboard.set_status(&format!( + "Cannot launch: {} has an active agent", + ticket.project + )); + return Ok(()); + } + + // Build agent context from issuetype registry + let agent_context = self + .issue_type_registry + .get(&ticket.ticket_type.to_uppercase()) + .map(|issue_type| { + use crate::agents::delegator_resolution::AgentContext; + let step_agent = if ticket.step.is_empty() { + issue_type.first_step().and_then(|s| s.agent.clone()) + } else { + issue_type + .get_step(&ticket.step) + .and_then(|s| s.agent.clone()) + }; + AgentContext { + step_agent, + issuetype_agent: issue_type.agent.clone(), + } + }); + + // Resolve launch options via the delegator chain + let options = match crate::agents::delegator_resolution::resolve_launch_options( + &self.config, + None, // no explicit delegator — let the chain resolve + None, // no explicit provider + None, // no explicit model + false, + agent_context.as_ref(), + ) { + Ok(opts) => opts, + Err(e) => { + self.dashboard + .set_status(&format!("Auto-launch failed: {e}")); + return Ok(()); + } + }; + + let delegator_label = options + .delegator_name + .as_deref() + .unwrap_or("default") + .to_string(); + + let launcher = Launcher::new(&self.config)?; + launcher.launch_with_options(&ticket, options).await?; + + self.dashboard.set_status(&format!( + "Auto-launched {} → {}", + ticket.id, delegator_label + )); + self.refresh_data()?; + + Ok(()) + } + pub(super) async fn launch_confirmed(&mut self) -> Result<()> { if let Some(ticket) = self.confirm_dialog.ticket.take() { let launcher = Launcher::new(&self.config)?; @@ -206,6 +292,7 @@ impl App { docker_mode: self.confirm_dialog.docker_selected, yolo_mode: self.confirm_dialog.yolo_selected, project_override, + ..Default::default() }; launcher.launch_with_options(&ticket, options).await?; @@ -218,14 +305,14 @@ impl App { /// Get the selected session info (name, wrapper, context ref) based on focused panel. fn selected_session_info(&self) -> (Option, Option, Option) { match self.dashboard.focused { - FocusedPanel::Agents => { + FocusedPanel::InProgress => { // Check if an orphan session is selected if let Some(orphan) = self.dashboard.selected_orphan() { (Some(orphan.session_name.clone()), None, None) } else { - // Otherwise get selected running agent's session + // Otherwise get selected agent's session self.dashboard - .selected_running_agent() + .selected_agent() .map_or((None, None, None), |a| { ( a.session_name.clone(), @@ -235,17 +322,6 @@ impl App { }) } } - FocusedPanel::Awaiting => { - self.dashboard - .selected_awaiting_agent() - .map_or((None, None, None), |a| { - ( - a.session_name.clone(), - a.session_wrapper.clone(), - a.session_context_ref.clone(), - ) - }) - } _ => (None, None, None), } } @@ -435,10 +511,9 @@ impl App { /// Show session preview for the selected agent pub(super) fn show_session_preview(&mut self) -> Result<()> { - // Only works when agents or awaiting panel is focused + // Only works when in-progress panel is focused let agent = match self.dashboard.focused { - FocusedPanel::Agents => self.dashboard.selected_running_agent().cloned(), - FocusedPanel::Awaiting => self.dashboard.selected_awaiting_agent().cloned(), + FocusedPanel::InProgress => self.dashboard.selected_agent().cloned(), _ => None, }; diff --git a/src/app/data_sync.rs b/src/app/data_sync.rs index 5e7de39..3279e74 100644 --- a/src/app/data_sync.rs +++ b/src/app/data_sync.rs @@ -2,9 +2,11 @@ use anyhow::Result; use std::collections::HashMap; use std::path::PathBuf; +use crate::config::SessionWrapperType; use crate::notifications::NotificationEvent; use crate::queue::Queue; use crate::state::State; +use crate::ui::status_panel::WrapperConnectionStatus; use super::App; @@ -36,9 +38,69 @@ impl App { self.dashboard .update_backstage_status(self.backstage_server.status()); + // Update wrapper connection status + let wrapper_status = self.check_wrapper_connection(); + self.dashboard + .update_wrapper_connection_status(wrapper_status); + Ok(()) } + /// Check the health of the active session wrapper connection. + pub(super) fn check_wrapper_connection(&self) -> WrapperConnectionStatus { + match self.config.sessions.wrapper { + SessionWrapperType::Tmux => self.check_tmux_status(), + SessionWrapperType::Vscode => { + let port = self.config.sessions.vscode.webhook_port; + // Quick health check against the webhook server + let webhook_running = std::net::TcpStream::connect_timeout( + &std::net::SocketAddr::from(([127, 0, 0, 1], port)), + std::time::Duration::from_millis(100), + ) + .is_ok(); + WrapperConnectionStatus::Vscode { + webhook_running, + port: Some(port), + } + } + SessionWrapperType::Cmux => { + let binary_path = &self.config.sessions.cmux.binary_path; + let binary_available = std::path::Path::new(binary_path).exists(); + let in_cmux = std::env::var("CMUX_WORKSPACE_ID").is_ok(); + WrapperConnectionStatus::Cmux { + binary_available, + in_cmux, + } + } + SessionWrapperType::Zellij => { + let binary_available = which::which("zellij").is_ok(); + let in_zellij = std::env::var("ZELLIJ").is_ok(); + WrapperConnectionStatus::Zellij { + binary_available, + in_zellij, + } + } + } + } + + /// Check tmux connection status using the `TmuxClient` trait. + /// + /// Uses the proper `TmuxClient` abstraction which handles socket names + /// and correctly interprets exit codes (e.g., exit code 1 = server running, + /// no sessions). + pub(super) fn check_tmux_status(&self) -> WrapperConnectionStatus { + let (available, version) = match self.tmux_client.check_available() { + Ok(v) => (true, Some(v.raw)), + Err(_) => (false, None), + }; + let server_running = self.tmux_client.server_running(); + WrapperConnectionStatus::Tmux { + available, + server_running, + version, + } + } + /// Reconcile state with actual tmux sessions on startup pub(super) fn reconcile_sessions(&self) -> Result<()> { let result = self.session_monitor.reconcile_on_startup()?; diff --git a/src/app/git_onboarding.rs b/src/app/git_onboarding.rs new file mode 100644 index 0000000..58238c1 --- /dev/null +++ b/src/app/git_onboarding.rs @@ -0,0 +1,261 @@ +//! Git provider onboarding logic. +//! +//! Detects CLI tools, grabs tokens, validates credentials, and resolves +//! the appropriate onboarding step for a given provider. + +use std::process::{Command, Stdio}; + +use anyhow::{Context, Result}; + +use crate::config::{Config, GitProviderConfig}; + +/// Per-provider constants for onboarding. +struct ProviderMeta { + cli_command: &'static str, + cli_auth_args: &'static [&'static str], + cli_install_url: &'static str, + pat_url: &'static str, + display_name: &'static str, + placeholder: &'static str, +} + +const GITHUB: ProviderMeta = ProviderMeta { + cli_command: "gh", + cli_auth_args: &["auth", "token"], + cli_install_url: "https://cli.github.com/", + pat_url: "https://github.com/settings/personal-access-tokens/new", + display_name: "GitHub", + placeholder: "ghp_...", +}; + +const GITLAB: ProviderMeta = ProviderMeta { + cli_command: "glab", + cli_auth_args: &["auth", "token"], + cli_install_url: "https://docs.gitlab.com/cli", + pat_url: "https://gitlab.com/-/user_settings/personal_access_tokens", + display_name: "GitLab", + placeholder: "glpat-...", +}; + +fn meta_for(provider: &str) -> Option<&'static ProviderMeta> { + match provider { + "github" => Some(&GITHUB), + "gitlab" => Some(&GITLAB), + _ => None, + } +} + +/// The resolved onboarding step for a provider. +#[derive(Debug)] +pub enum OnboardingStep { + /// CLI not installed — open install page. + InstallCli { + install_url: String, + provider_display: String, + }, + /// CLI installed but no token — show PAT dialog. + CollectToken { + pat_url: String, + provider: String, + provider_display: String, + placeholder: String, + }, + /// CLI installed and authenticated — token ready to use. + AutoConfigured { + username: String, + token: String, + provider: String, + provider_display: String, + }, +} + +/// Check if a CLI tool is available on PATH (synchronous). +fn is_cli_installed(command: &str) -> bool { + Command::new(command) + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Try to grab an auth token from a CLI tool (synchronous). +fn grab_cli_token(command: &str, args: &[&str]) -> Option { + let output = Command::new(command) + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .ok()?; + + if output.status.success() { + let token = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if token.is_empty() { + None + } else { + Some(token) + } + } else { + None + } +} + +/// Validate a GitHub personal access token and return the username. +pub fn validate_github_token(token: &str) -> Result { + let client = reqwest::blocking::Client::new(); + let resp = client + .get("https://api.github.com/user") + .header("Authorization", format!("Bearer {token}")) + .header("User-Agent", "operator") + .send() + .context("Failed to reach GitHub API")?; + + if !resp.status().is_success() { + anyhow::bail!("GitHub token validation failed (HTTP {})", resp.status()); + } + + let body: serde_json::Value = resp.json().context("Failed to parse GitHub response")?; + body["login"] + .as_str() + .map(std::string::ToString::to_string) + .context("GitHub response missing 'login' field") +} + +/// Validate a GitLab personal access token and return the username. +pub fn validate_gitlab_token(token: &str) -> Result { + let client = reqwest::blocking::Client::new(); + let resp = client + .get("https://gitlab.com/api/v4/user") + .header("Private-Token", token) + .header("User-Agent", "operator") + .send() + .context("Failed to reach GitLab API")?; + + if !resp.status().is_success() { + anyhow::bail!("GitLab token validation failed (HTTP {})", resp.status()); + } + + let body: serde_json::Value = resp.json().context("Failed to parse GitLab response")?; + body["username"] + .as_str() + .map(std::string::ToString::to_string) + .context("GitLab response missing 'username' field") +} + +/// Resolve the onboarding step for a provider. +/// +/// Checks CLI installation → CLI authentication → returns the appropriate step. +pub fn resolve_onboarding(provider: &str) -> Option { + let meta = meta_for(provider)?; + + if !is_cli_installed(meta.cli_command) { + return Some(OnboardingStep::InstallCli { + install_url: meta.cli_install_url.to_string(), + provider_display: meta.display_name.to_string(), + }); + } + + if let Some(token) = grab_cli_token(meta.cli_command, meta.cli_auth_args) { + // Validate the token + let username = match provider { + "github" => validate_github_token(&token), + "gitlab" => validate_gitlab_token(&token), + _ => return None, + }; + + if let Ok(username) = username { + return Some(OnboardingStep::AutoConfigured { + username, + token, + provider: provider.to_string(), + provider_display: meta.display_name.to_string(), + }); + } + // CLI token is stale/invalid, fall through to manual entry + } + + Some(OnboardingStep::CollectToken { + pat_url: meta.pat_url.to_string(), + provider: provider.to_string(), + provider_display: meta.display_name.to_string(), + placeholder: meta.placeholder.to_string(), + }) +} + +/// Complete git onboarding by writing provider config and setting the env var. +pub fn complete_git_onboarding(config: &mut Config, provider: &str, token: &str) -> Result<()> { + match provider { + "github" => { + config.git.provider = Some(GitProviderConfig::GitHub); + config.git.github.enabled = true; + config.save()?; + std::env::set_var(&config.git.github.token_env, token); + } + "gitlab" => { + config.git.provider = Some(GitProviderConfig::GitLab); + config.git.gitlab.enabled = true; + config.save()?; + std::env::set_var(&config.git.gitlab.token_env, token); + } + _ => anyhow::bail!("Unsupported provider: {provider}"), + } + Ok(()) +} + +/// Validate a token for the given provider, returning the username on success. +pub fn validate_token(provider: &str, token: &str) -> Result { + match provider { + "github" => validate_github_token(token), + "gitlab" => validate_gitlab_token(token), + _ => anyhow::bail!("Unsupported provider: {provider}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_meta_for_github() { + let meta = meta_for("github").unwrap(); + assert_eq!(meta.cli_command, "gh"); + assert_eq!(meta.display_name, "GitHub"); + assert_eq!( + meta.pat_url, + "https://github.com/settings/personal-access-tokens/new" + ); + } + + #[test] + fn test_meta_for_gitlab() { + let meta = meta_for("gitlab").unwrap(); + assert_eq!(meta.cli_command, "glab"); + assert_eq!(meta.display_name, "GitLab"); + assert_eq!( + meta.pat_url, + "https://gitlab.com/-/user_settings/personal_access_tokens" + ); + } + + #[test] + fn test_meta_for_unknown_returns_none() { + assert!(meta_for("bitbucket").is_none()); + assert!(meta_for("").is_none()); + } + + #[test] + fn test_is_cli_installed_nonexistent() { + assert!(!is_cli_installed("nonexistent-cli-tool-xyz-12345")); + } + + #[test] + fn test_grab_cli_token_nonexistent() { + assert!(grab_cli_token("nonexistent-cli-tool-xyz-12345", &["auth", "token"]).is_none()); + } + + #[test] + fn test_resolve_onboarding_unknown_provider() { + assert!(resolve_onboarding("bitbucket").is_none()); + } +} diff --git a/src/app/keyboard.rs b/src/app/keyboard.rs index 90f5ebf..65c4041 100644 --- a/src/app/keyboard.rs +++ b/src/app/keyboard.rs @@ -1,20 +1,25 @@ use anyhow::Result; -use crossterm::event::KeyCode; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::ui::setup::SetupResult; +use crate::ui::status_panel::ActionButton; use crate::ui::{ConfirmSelection, KanbanViewResult, SessionRecoverySelection, SyncConfirmResult}; +use super::git_onboarding; use super::{App, AppTerminal}; impl App { pub(super) async fn handle_key( &mut self, - key: KeyCode, + key: KeyEvent, terminal: &mut AppTerminal, ) -> Result<()> { + let code = key.code; + let mods = key.modifiers; + // Setup screen takes absolute priority if let Some(ref mut setup) = self.setup_screen { - match key { + match code { KeyCode::Char('i' | 'I') => { if setup.confirm_selected { self.initialize_tickets()?; @@ -76,7 +81,7 @@ impl App { // Session preview handling if self.session_preview.visible { - match key { + match code { KeyCode::Esc | KeyCode::Char('q') => { self.session_preview.hide(); } @@ -105,7 +110,7 @@ impl App { // Create dialog handling if self.create_dialog.visible { - if let Some(result) = self.create_dialog.handle_key(key) { + if let Some(result) = self.create_dialog.handle_key(code) { self.create_ticket(result, terminal)?; } return Ok(()); @@ -113,7 +118,7 @@ impl App { // Projects dialog handling if self.projects_dialog.visible { - if let Some(result) = self.projects_dialog.handle_key(key) { + if let Some(result) = self.projects_dialog.handle_key(code) { self.execute_project_action(result)?; } return Ok(()); @@ -123,7 +128,7 @@ impl App { if self.confirm_dialog.visible { // Check if options are focused for different key behavior if self.confirm_dialog.is_options_focused() { - match key { + match code { // Down or Enter moves focus back to buttons KeyCode::Down | KeyCode::Enter => { self.confirm_dialog.focus_buttons(); @@ -164,7 +169,7 @@ impl App { } } else { // Buttons focused (default behavior) - match key { + match code { KeyCode::Char('y' | 'Y') => { self.launch_confirmed().await?; } @@ -219,7 +224,7 @@ impl App { // Session recovery dialog handling if self.session_recovery_dialog.visible { - match key { + match code { KeyCode::Char('r' | 'R') => { if self.session_recovery_dialog.has_session_id() { self.handle_session_recovery(SessionRecoverySelection::ResumeSession) @@ -254,7 +259,7 @@ impl App { // Collection dialog handling if self.collection_dialog.visible { - if let Some(result) = self.collection_dialog.handle_key(key) { + if let Some(result) = self.collection_dialog.handle_key(code) { self.handle_collection_switch(result)?; } return Ok(()); @@ -262,7 +267,7 @@ impl App { // Kanban view handling if self.kanban_view.visible { - if let Some(result) = self.kanban_view.handle_key(key) { + if let Some(result) = self.kanban_view.handle_key(code) { match result { KanbanViewResult::Sync { provider, @@ -287,9 +292,76 @@ impl App { return Ok(()); } + // Git token dialog handling + if self.git_token_dialog.visible { + match code { + KeyCode::Esc => { + self.git_token_dialog.hide(); + } + KeyCode::Enter => { + let token = self.git_token_dialog.token().to_string(); + if token.is_empty() { + self.git_token_dialog.set_error("Token cannot be empty"); + } else { + let provider = self.git_token_dialog.provider.clone(); + let provider_display = self.git_token_dialog.provider_display.clone(); + match git_onboarding::validate_token(&provider, &token) { + Ok(username) => { + match git_onboarding::complete_git_onboarding( + &mut self.config, + &provider, + &token, + ) { + Ok(()) => { + self.git_token_dialog.hide(); + self.dashboard.update_config(&self.config); + self.refresh_data()?; + self.dashboard.set_status(&format!( + "{provider_display} connected as {username}" + )); + } + Err(e) => { + self.git_token_dialog + .set_error(&format!("Failed to save config: {e}")); + } + } + } + Err(e) => { + self.git_token_dialog + .set_error(&format!("Token validation failed: {e}")); + } + } + } + } + KeyCode::Char(c) => { + self.git_token_dialog.handle_char(c); + } + KeyCode::Backspace => { + self.git_token_dialog.handle_backspace(); + } + KeyCode::Delete => { + self.git_token_dialog.handle_delete(); + } + KeyCode::Left => { + self.git_token_dialog.cursor_left(); + } + KeyCode::Right => { + self.git_token_dialog.cursor_right(); + } + KeyCode::Home => { + self.git_token_dialog.cursor_home(); + } + KeyCode::End => { + self.git_token_dialog.cursor_end(); + } + _ => {} + } + return Ok(()); + } + // Sync confirm dialog handling if self.sync_confirm_dialog.visible { - if let Some(result) = self.sync_confirm_dialog.handle_key(key) { + if let Some(result) = self.sync_confirm_dialog.handle_key(code) { match result { SyncConfirmResult::Confirmed => { self.run_kanban_sync_all().await?; @@ -303,7 +375,7 @@ impl App { } // Normal mode - match key { + match code { KeyCode::Char('q') => { // Stop servers if running before exiting if self.rest_api_server.is_running() || self.backstage_server.is_running() { @@ -341,13 +413,27 @@ impl App { self.dashboard.focus_next(); } KeyCode::Enter => { - // Enter key behavior depends on focused panel + // Enter key behavior depends on focused panel and modifiers match self.dashboard.focused { + crate::ui::dashboard::FocusedPanel::Status => { + let button = if mods.contains(KeyModifiers::SHIFT) { + ActionButton::X + } else if mods.contains(KeyModifiers::CONTROL) { + ActionButton::Y + } else { + ActionButton::A + }; + let action = self.dashboard.status_action(button); + self.execute_status_action(action, terminal)?; + } crate::ui::dashboard::FocusedPanel::Queue => { - self.try_launch()?; + if mods.contains(KeyModifiers::SHIFT) { + self.auto_launch().await?; + } else { + self.try_launch()?; + } } - crate::ui::dashboard::FocusedPanel::Agents - | crate::ui::dashboard::FocusedPanel::Awaiting => { + crate::ui::dashboard::FocusedPanel::InProgress => { self.attach_to_session(terminal)?; } crate::ui::dashboard::FocusedPanel::Completed => { @@ -365,7 +451,7 @@ impl App { self.dashboard.focused = crate::ui::dashboard::FocusedPanel::Queue; } KeyCode::Char('A' | 'a') => { - self.dashboard.focused = crate::ui::dashboard::FocusedPanel::Agents; + self.dashboard.focused = crate::ui::dashboard::FocusedPanel::InProgress; } KeyCode::Char('C') => { self.create_dialog.show(); @@ -394,49 +480,7 @@ impl App { } } KeyCode::Char('W' | 'w') => { - // Toggle both REST API and Backstage servers together - let backstage_running = self.backstage_server.is_running(); - let rest_running = self.rest_api_server.is_running(); - - if backstage_running && rest_running { - // Both running - stop both - self.rest_api_server.stop(); - if let Err(e) = self.backstage_server.stop() { - tracing::error!("Backstage stop failed: {}", e); - } - } else { - // Show yellow "Starting" indicator immediately for feedback - use crate::backstage::ServerStatus; - self.dashboard - .update_backstage_status(ServerStatus::Starting); - terminal.draw(|f| self.dashboard.render(f))?; - - // Start both if not running - if !rest_running { - if let Err(e) = self.rest_api_server.start() { - tracing::error!("REST API start failed: {}", e); - } - } - if !backstage_running { - if let Err(e) = self.backstage_server.start() { - tracing::error!("Backstage start failed: {}", e); - } - } - // Wait for server to be ready before opening browser - // Polls /health every 500ms, up to 50 times (25 seconds) - if self.backstage_server.is_running() { - match self.backstage_server.wait_for_ready(25000) { - Ok(()) => { - if let Err(e) = self.backstage_server.open_browser() { - tracing::warn!("Failed to open browser: {}", e); - } - } - Err(e) => { - tracing::error!("Server not ready: {}", e); - } - } - } - } + self.toggle_web_servers(terminal)?; } KeyCode::Char('T' | 't') => { // Open collection switch dialog @@ -450,6 +494,13 @@ impl App { // Focus agent's cmux window (cmux power-user action) self.focus_agent_window()?; } + KeyCode::Esc | KeyCode::Backspace + if self.dashboard.focused == crate::ui::dashboard::FocusedPanel::Status => + { + // B-action: go back / collapse section in status panel + let action = self.dashboard.status_action(ActionButton::B); + self.execute_status_action(action, terminal)?; + } _ => {} } diff --git a/src/app/mod.rs b/src/app/mod.rs index 5f59e87..946cd79 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -20,18 +20,20 @@ use crate::ui::projects_dialog::ProjectsDialog; use crate::ui::session_preview::SessionPreview; use crate::ui::setup::{DetectedToolInfo, SetupScreen}; use crate::ui::{ - CollectionSwitchDialog, ConfirmDialog, Dashboard, KanbanView, SessionRecoveryDialog, - SyncConfirmDialog, TerminalGuard, + CollectionSwitchDialog, ConfirmDialog, Dashboard, GitTokenDialog, KanbanView, + SessionRecoveryDialog, SyncConfirmDialog, TerminalGuard, }; use std::sync::Arc; mod agents; mod data_sync; +mod git_onboarding; mod kanban; mod keyboard; mod pr_workflow; mod review; mod session; +mod status_actions; mod tickets; #[cfg(test)] @@ -77,6 +79,8 @@ pub struct App { pub(crate) kanban_view: KanbanView, /// Kanban sync confirmation dialog pub(crate) sync_confirm_dialog: SyncConfirmDialog, + /// Git token input dialog + pub(crate) git_token_dialog: GitTokenDialog, /// Kanban sync service pub(crate) kanban_sync_service: KanbanSyncService, /// Issue type registry for dynamic issue types @@ -281,6 +285,7 @@ impl App { collection_dialog: CollectionSwitchDialog::new(), kanban_view: KanbanView::new(), sync_confirm_dialog: SyncConfirmDialog::new(), + git_token_dialog: GitTokenDialog::new(), kanban_sync_service, issue_type_registry, pr_event_rx, @@ -388,6 +393,7 @@ impl App { self.kanban_view.render(f, f.area()); } self.sync_confirm_dialog.render(f); + self.git_token_dialog.render(f); } })?; @@ -406,7 +412,7 @@ impl App { self.exit_confirmation_mode = false; self.exit_confirmation_time = None; } - self.handle_key(key.code, &mut terminal).await?; + self.handle_key(key, &mut terminal).await?; } } } diff --git a/src/app/review.rs b/src/app/review.rs index 4faab2b..4f3c433 100644 --- a/src/app/review.rs +++ b/src/app/review.rs @@ -10,10 +10,9 @@ impl App { /// Only works for agents in `awaiting_input` with a `review_state` of `pending_plan` or `pending_visual`. /// Creates a signal file to trigger resume in the next sync cycle. pub(super) fn handle_review_approval(&mut self) -> Result<()> { - // Only works when agents or awaiting panel is focused + // Only works when in-progress panel is focused let agent = match self.dashboard.focused { - FocusedPanel::Agents => self.dashboard.selected_running_agent().cloned(), - FocusedPanel::Awaiting => self.dashboard.selected_awaiting_agent().cloned(), + FocusedPanel::InProgress => self.dashboard.selected_agent().cloned(), _ => None, }; @@ -48,10 +47,9 @@ impl App { /// For now, this just logs the rejection. A full implementation would show a dialog /// for entering a rejection reason and possibly restart the step. pub(super) fn handle_review_rejection(&mut self) -> Result<()> { - // Only works when agents or awaiting panel is focused + // Only works when in-progress panel is focused let agent = match self.dashboard.focused { - FocusedPanel::Agents => self.dashboard.selected_running_agent().cloned(), - FocusedPanel::Awaiting => self.dashboard.selected_awaiting_agent().cloned(), + FocusedPanel::InProgress => self.dashboard.selected_agent().cloned(), _ => None, }; diff --git a/src/app/status_actions.rs b/src/app/status_actions.rs new file mode 100644 index 0000000..1a818df --- /dev/null +++ b/src/app/status_actions.rs @@ -0,0 +1,293 @@ +use anyhow::Result; + +use crate::config::SessionWrapperType; +use crate::ui::status_panel::StatusAction; +use crate::ui::with_suspended_tui; + +use super::git_onboarding; +use super::{App, AppTerminal}; + +/// Open a URL in the default browser. +pub(super) fn open_in_browser(url: &str) -> std::io::Result<()> { + let opener = if cfg!(target_os = "macos") { + "open" + } else if cfg!(target_os = "windows") { + "cmd" + } else { + "xdg-open" + }; + + if cfg!(target_os = "windows") { + std::process::Command::new(opener) + .args(["/C", "start", url]) + .spawn()?; + } else { + std::process::Command::new(opener).arg(url).spawn()?; + } + Ok(()) +} + +impl App { + /// Execute an action from the status panel. + pub(super) fn execute_status_action( + &mut self, + action: StatusAction, + terminal: &mut AppTerminal, + ) -> Result<()> { + match action { + StatusAction::ToggleSection(_) => { + // Already handled by dashboard.status_action() + } + StatusAction::OpenDirectory(path) => { + if let Err(e) = open_in_browser(&path) { + self.dashboard.set_status(&format!("Failed to open: {e}")); + } + } + StatusAction::EditFile(path) => { + let cmd = self.dashboard.editor_config.file_editor().to_string(); + with_suspended_tui(terminal, || { + let (prog, args) = crate::editors::EditorConfig::split_command(&cmd); + let result = std::process::Command::new(prog) + .args(&args) + .arg(&path) + .status(); + if let Err(e) = result { + tracing::warn!("Failed to open editor: {}", e); + } + Ok(()) + })?; + } + StatusAction::OpenUrl(url) => { + if let Err(e) = open_in_browser(&url) { + self.dashboard + .set_status(&format!("Failed to open URL: {e}")); + } + } + StatusAction::StartApi => { + if !self.rest_api_server.is_running() { + if let Err(e) = self.rest_api_server.start() { + self.dashboard + .set_status(&format!("Failed to start API: {e}")); + } else { + self.dashboard.set_status("Starting API server..."); + } + } + } + StatusAction::OpenSwagger { port } => { + let url = format!("http://localhost:{port}/swagger-ui/"); + if let Err(e) = open_in_browser(&url) { + self.dashboard + .set_status(&format!("Failed to open Swagger: {e}")); + } + } + StatusAction::RestartWrapperConnection => { + self.restart_wrapper_connection(); + } + StatusAction::ToggleWebServers => { + self.toggle_web_servers(terminal)?; + } + StatusAction::SetDefaultLlm { tool_name, model } => { + self.set_default_llm(&tool_name, &model); + } + StatusAction::ConfigureKanbanProvider { provider } => { + let url = match provider.as_str() { + "jira" => "https://id.atlassian.com/manage-profile/security/api-tokens", + "linear" => "https://linear.app/settings/api", + _ => return Ok(()), + }; + if let Err(e) = open_in_browser(url) { + self.dashboard + .set_status(&format!("Failed to open {provider} setup: {e}")); + } else { + self.dashboard.set_status(&format!( + "Opened {provider} API key page — add credentials to config.toml" + )); + } + } + StatusAction::ConfigureGitProvider { provider } => { + match git_onboarding::resolve_onboarding(&provider) { + Some(git_onboarding::OnboardingStep::InstallCli { + install_url, + provider_display, + }) => { + if let Err(e) = open_in_browser(&install_url) { + self.dashboard.set_status(&format!( + "Failed to open {provider_display} setup: {e}" + )); + } else { + self.dashboard + .set_status(&format!("Opened {provider_display} CLI install page")); + } + } + Some(git_onboarding::OnboardingStep::CollectToken { + pat_url, + provider, + provider_display, + placeholder, + }) => { + let _ = open_in_browser(&pat_url); + self.git_token_dialog.show( + &provider, + &provider_display, + &pat_url, + &placeholder, + ); + } + Some(git_onboarding::OnboardingStep::AutoConfigured { + username, + token, + provider, + provider_display, + }) => { + match git_onboarding::complete_git_onboarding( + &mut self.config, + &provider, + &token, + ) { + Ok(()) => { + self.dashboard.update_config(&self.config); + self.refresh_data()?; + self.dashboard.set_status(&format!( + "{provider_display} connected as {username}" + )); + } + Err(e) => { + self.dashboard.set_status(&format!("Git setup failed: {e}")); + } + } + } + None => { + self.dashboard.set_status("Unsupported git provider"); + } + } + } + StatusAction::RefreshSection(_section_id) => { + self.refresh_data()?; + } + StatusAction::ResetConfig => { + // TODO: implement double-confirm dialog (type working dir name to confirm) + self.dashboard + .set_status("Config reset requires confirmation — not yet implemented"); + } + StatusAction::ReloadConfig => match crate::config::Config::load(None) { + Ok(new_config) => { + self.config = new_config; + self.dashboard.update_config(&self.config); + self.refresh_data()?; + self.dashboard.set_status("Configuration reloaded"); + } + Err(e) => { + self.dashboard + .set_status(&format!("Failed to reload config: {e}")); + } + }, + StatusAction::None => {} + } + Ok(()) + } + + /// Attempt to restart the session wrapper connection. + /// After attempting restart, immediately re-checks connection status + /// so the UI reflects the result without waiting for the next periodic refresh. + fn restart_wrapper_connection(&mut self) { + match self.config.sessions.wrapper { + SessionWrapperType::Tmux => { + let socket = &self.config.sessions.tmux.socket_name; + match std::process::Command::new("tmux") + .args(["-L", socket, "start-server"]) + .status() + { + Ok(status) if status.success() => { + // Re-check connection status immediately + let wrapper_status = self.check_tmux_status(); + self.dashboard + .update_wrapper_connection_status(wrapper_status.clone()); + if wrapper_status.is_connected() { + self.dashboard.set_status("tmux server connected"); + } else { + self.dashboard + .set_status("tmux server started (no sessions)"); + } + } + Ok(_) => { + self.dashboard.set_status("Failed to start tmux server"); + } + Err(e) => { + self.dashboard.set_status(&format!("tmux not found: {e}")); + } + } + } + SessionWrapperType::Vscode => { + self.dashboard + .set_status("Webhook managed by VS Code extension"); + } + SessionWrapperType::Cmux => { + self.dashboard + .set_status("Start operator inside cmux to connect"); + } + SessionWrapperType::Zellij => { + self.dashboard + .set_status("Start operator inside zellij to connect"); + } + } + } + + fn set_default_llm(&mut self, tool_name: &str, model: &str) { + self.config.llm_tools.default_tool = Some(tool_name.to_string()); + self.config.llm_tools.default_model = Some(model.to_string()); + if let Err(e) = self.config.save() { + self.dashboard + .set_status(&format!("Failed to save config: {e}")); + return; + } + self.dashboard.update_config(&self.config); + self.dashboard + .set_status(&format!("Default LLM set to {tool_name}:{model}")); + } + + /// Toggle both REST API and Backstage servers together. + pub(super) fn toggle_web_servers(&mut self, terminal: &mut AppTerminal) -> Result<()> { + let backstage_running = self.backstage_server.is_running(); + let rest_running = self.rest_api_server.is_running(); + + if backstage_running && rest_running { + // Both running - stop both + self.rest_api_server.stop(); + if let Err(e) = self.backstage_server.stop() { + tracing::error!("Backstage stop failed: {}", e); + } + } else { + // Show yellow "Starting" indicator immediately for feedback + use crate::backstage::ServerStatus; + self.dashboard + .update_backstage_status(ServerStatus::Starting); + terminal.draw(|f| self.dashboard.render(f))?; + + // Start both if not running + if !rest_running { + if let Err(e) = self.rest_api_server.start() { + tracing::error!("REST API start failed: {}", e); + } + } + if !backstage_running { + if let Err(e) = self.backstage_server.start() { + tracing::error!("Backstage start failed: {}", e); + } + } + // Wait for server to be ready before opening browser + if self.backstage_server.is_running() { + match self.backstage_server.wait_for_ready(25000) { + Ok(()) => { + if let Err(e) = self.backstage_server.open_browser() { + tracing::warn!("Failed to open browser: {}", e); + } + } + Err(e) => { + tracing::error!("Server not ready: {}", e); + } + } + } + } + Ok(()) + } +} diff --git a/src/app/tests.rs b/src/app/tests.rs index df6496d..6170a9c 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -58,6 +58,8 @@ fn make_test_config(temp_dir: &TempDir) -> Config { }], detection_complete: true, skill_directory_overrides: std::collections::HashMap::new(), + default_tool: None, + default_model: None, }, // Disable notifications in tests notifications: crate::config::NotificationsConfig { diff --git a/src/app/tickets.rs b/src/app/tickets.rs index eed2ace..f4797ec 100644 --- a/src/app/tickets.rs +++ b/src/app/tickets.rs @@ -237,10 +237,14 @@ impl App { ) -> Result<()> { let config = self.config.clone(); + let editor_cmd = self.dashboard.editor_config.file_editor().to_string(); let result = with_suspended_tui(terminal, || { let creator = TicketCreator::new(&config); - // Use the new method that accepts pre-filled values - creator.create_ticket_with_values(dialog_result.template_type, &dialog_result.values) + creator.create_ticket_with_values( + dialog_result.template_type, + &dialog_result.values, + &editor_cmd, + ) }); // Handle result after TUI is restored @@ -341,12 +345,16 @@ impl App { return Ok(()); }; + let visual = self.dashboard.editor_config.visual.clone(); with_suspended_tui(terminal, || { - // Try $VISUAL first, then fall back to `open` (macOS) - let result = if let Ok(visual) = std::env::var("VISUAL") { - std::process::Command::new(&visual).arg(&filepath).status() - } else { + let result = if visual.is_empty() { std::process::Command::new("open").arg(&filepath).status() + } else { + let (prog, args) = crate::editors::EditorConfig::split_command(&visual); + std::process::Command::new(prog) + .args(&args) + .arg(&filepath) + .status() }; if let Err(e) = result { @@ -363,13 +371,17 @@ impl App { return Ok(()); }; - let Ok(editor) = std::env::var("EDITOR") else { - // No EDITOR set, do nothing + let editor = self.dashboard.editor_config.editor.clone(); + if editor.is_empty() { return Ok(()); - }; + } with_suspended_tui(terminal, || { - let result = std::process::Command::new(&editor).arg(&filepath).status(); + let (prog, args) = crate::editors::EditorConfig::split_command(&editor); + let result = std::process::Command::new(prog) + .args(&args) + .arg(&filepath) + .status(); if let Err(e) = result { tracing::warn!("Failed to open editor: {}", e); diff --git a/src/config.rs b/src/config.rs index 11162fa..c640cb1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -258,26 +258,26 @@ pub struct UiConfig { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] pub struct PanelNamesConfig { + #[serde(default = "default_status_name")] + pub status: String, #[serde(default = "default_todo_name")] pub queue: String, - #[serde(default = "default_doing_name")] - pub agents: String, - #[serde(default = "default_awaiting_name")] - pub awaiting: String, + #[serde(default = "default_in_progress_name", alias = "agents")] + pub in_progress: String, #[serde(default = "default_done_name")] pub completed: String, } -fn default_todo_name() -> String { - "TODO QUEUE".to_string() +fn default_status_name() -> String { + "STATUS".to_string() } -fn default_doing_name() -> String { - "DOING".to_string() +fn default_todo_name() -> String { + "TODO QUEUE".to_string() } -fn default_awaiting_name() -> String { - "AWAITING".to_string() +fn default_in_progress_name() -> String { + "IN PROGRESS".to_string() } fn default_done_name() -> String { @@ -287,9 +287,9 @@ fn default_done_name() -> String { impl Default for PanelNamesConfig { fn default() -> Self { Self { + status: default_status_name(), queue: default_todo_name(), - agents: default_doing_name(), - awaiting: default_awaiting_name(), + in_progress: default_in_progress_name(), completed: default_done_name(), } } @@ -584,6 +584,9 @@ pub struct BackstageConfig { /// Whether Backstage integration is enabled #[serde(default = "default_backstage_enabled")] pub enabled: bool, + /// Whether to show Backstage in the Connections status section + #[serde(default)] + pub display: bool, /// Port for the Backstage server #[serde(default = "default_backstage_port")] pub port: u16, @@ -723,6 +726,7 @@ impl Default for BackstageConfig { fn default() -> Self { Self { enabled: default_backstage_enabled(), + display: false, port: default_backstage_port(), auto_start: false, subpath: default_backstage_subpath(), @@ -784,6 +788,14 @@ pub struct LlmToolsConfig { #[serde(default)] pub detection_complete: bool, + /// User's preferred default LLM tool (e.g., "claude") + #[serde(default)] + pub default_tool: Option, + + /// User's preferred default model alias (e.g., "opus") + #[serde(default)] + pub default_model: Option, + /// Per-tool overrides for skill directories (keyed by `tool_name`) #[serde(default)] pub skill_directory_overrides: std::collections::HashMap, @@ -907,6 +919,9 @@ pub struct Delegator { } /// Launch configuration for a delegator +/// +/// Controls how the delegator launches agents. Optional fields use tri-state +/// semantics: `None` = inherit from global config, `Some(true/false)` = override. #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] pub struct DelegatorLaunchConfig { @@ -919,6 +934,21 @@ pub struct DelegatorLaunchConfig { /// Additional CLI flags #[serde(default)] pub flags: Vec, + /// Override global `git.use_worktrees` per-delegator (None = use global setting) + #[serde(default)] + pub use_worktrees: Option, + /// Whether to create a git branch for the ticket (None = default behavior) + #[serde(default)] + pub create_branch: Option, + /// Run in docker container (None = use global `launch.docker.enabled`) + #[serde(default)] + pub docker: Option, + /// Prompt text to prepend before the generated step prompt + #[serde(default)] + pub prompt_prefix: Option, + /// Prompt text to append after the generated step prompt + #[serde(default)] + pub prompt_suffix: Option, } /// Predefined issue type collections @@ -1576,6 +1606,7 @@ mod tests { yolo: true, permission_mode: Some("delegate".to_string()), flags: vec!["--verbose".to_string()], + ..Default::default() }), }; diff --git a/src/docs_gen/shortcuts.rs b/src/docs_gen/shortcuts.rs index 61ff9e4..b84cbd1 100644 --- a/src/docs_gen/shortcuts.rs +++ b/src/docs_gen/shortcuts.rs @@ -79,6 +79,9 @@ impl ShortcutsDocGenerator { ShortcutContext::Preview => { "These shortcuts are available when viewing a session preview." } + ShortcutContext::StatusPanel => { + "These shortcuts are available when the status panel is focused. Actions use an ABXY gamepad-style mapping." + } ShortcutContext::LaunchDialog => { "These shortcuts are available in the ticket launch confirmation dialog." } diff --git a/src/editors.rs b/src/editors.rs new file mode 100644 index 0000000..c526cb5 --- /dev/null +++ b/src/editors.rs @@ -0,0 +1,215 @@ +//! Centralized editor environment variable detection and resolution. +//! +//! Resolves `$EDITOR`, `$VISUAL`, and `$IDE` with wrapper-aware defaults. +//! The session wrapper type must be known before detection, ensuring +//! wrapper inference precedes editor defaults. + +use crate::config::SessionWrapperType; + +/// Resolved editor environment variables, detected once at startup. +#[derive(Debug, Clone)] +pub struct EditorConfig { + /// Resolved `$EDITOR` value (fallback/terminal editor) + pub editor: String, + /// Resolved `$VISUAL` value (full-screen/GUI editor) + pub visual: String, +} + +impl EditorConfig { + /// Detect editor configuration from environment variables, + /// falling back to wrapper-specific defaults. + /// + /// The `wrapper` parameter enforces that wrapper inference is resolved + /// before editor defaults are computed. + pub fn detect(wrapper: SessionWrapperType) -> Self { + let (default_editor, default_visual) = match wrapper { + SessionWrapperType::Vscode => ("vim", "code --wait"), + SessionWrapperType::Tmux | SessionWrapperType::Cmux | SessionWrapperType::Zellij => { + ("vim", "") + } + }; + + Self { + editor: std::env::var("EDITOR").unwrap_or_else(|_| default_editor.to_string()), + visual: std::env::var("VISUAL").unwrap_or_else(|_| default_visual.to_string()), + } + } + + /// Returns the command to use for editing files. + /// Follows the convention: `$VISUAL || $EDITOR || "vim"`. + pub fn file_editor(&self) -> &str { + if !self.visual.is_empty() { + &self.visual + } else if !self.editor.is_empty() { + &self.editor + } else { + "vim" + } + } + + /// Split a command string like `"code --wait"` into program and args. + /// Returns `(program, args)`. + pub fn split_command(cmd: &str) -> (&str, Vec<&str>) { + let mut parts = cmd.split_whitespace(); + let program = parts.next().unwrap_or("vim"); + let args: Vec<&str> = parts.collect(); + (program, args) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + // Serialize env-var-mutating tests + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + /// Helper: clear editor env vars, run closure, restore. + fn with_clean_env R, R>(f: F) -> R { + let _guard = ENV_LOCK.lock().unwrap(); + let saved_editor = std::env::var("EDITOR").ok(); + let saved_visual = std::env::var("VISUAL").ok(); + + std::env::remove_var("EDITOR"); + std::env::remove_var("VISUAL"); + + let result = f(); + + // Restore + match saved_editor { + Some(v) => std::env::set_var("EDITOR", v), + None => std::env::remove_var("EDITOR"), + } + match saved_visual { + Some(v) => std::env::set_var("VISUAL", v), + None => std::env::remove_var("VISUAL"), + } + + result + } + + #[test] + fn test_vscode_wrapper_defaults_when_env_unset() { + with_clean_env(|| { + let config = EditorConfig::detect(SessionWrapperType::Vscode); + assert_eq!(config.editor, "vim"); + assert_eq!(config.visual, "code --wait"); + }); + } + + #[test] + fn test_tmux_wrapper_defaults_when_env_unset() { + with_clean_env(|| { + let config = EditorConfig::detect(SessionWrapperType::Tmux); + assert_eq!(config.editor, "vim"); + assert_eq!(config.visual, ""); + }); + } + + #[test] + fn test_wrapper_inference_precedes_editor_defaults() { + // Same empty environment, different wrapper → different defaults. + // This proves wrapper type drives the defaults. + with_clean_env(|| { + let vscode = EditorConfig::detect(SessionWrapperType::Vscode); + let tmux = EditorConfig::detect(SessionWrapperType::Tmux); + + // Vscode gets GUI-aware defaults + assert_eq!(vscode.visual, "code --wait"); + + // Tmux gets terminal-only defaults + assert_eq!(tmux.visual, ""); + + // Both share the same terminal editor fallback + assert_eq!(vscode.editor, tmux.editor); + }); + } + + #[test] + fn test_env_vars_override_wrapper_defaults() { + with_clean_env(|| { + std::env::set_var("EDITOR", "nano"); + std::env::set_var("VISUAL", "subl -w"); + + let config = EditorConfig::detect(SessionWrapperType::Vscode); + assert_eq!(config.editor, "nano"); + assert_eq!(config.visual, "subl -w"); + }); + } + + #[test] + fn test_partial_env_override() { + with_clean_env(|| { + std::env::set_var("EDITOR", "nano"); + // VISUAL not set — should get vscode default + + let config = EditorConfig::detect(SessionWrapperType::Vscode); + assert_eq!(config.editor, "nano"); + assert_eq!(config.visual, "code --wait"); + }); + } + + #[test] + fn test_file_editor_prefers_visual() { + let config = EditorConfig { + editor: "vim".into(), + visual: "code --wait".into(), + }; + assert_eq!(config.file_editor(), "code --wait"); + } + + #[test] + fn test_file_editor_falls_back_to_editor() { + let config = EditorConfig { + editor: "nano".into(), + visual: String::new(), + }; + assert_eq!(config.file_editor(), "nano"); + } + + #[test] + fn test_file_editor_ultimate_fallback() { + let config = EditorConfig { + editor: String::new(), + visual: String::new(), + }; + assert_eq!(config.file_editor(), "vim"); + } + + #[test] + fn test_split_command_with_args() { + let (prog, args) = EditorConfig::split_command("code --wait"); + assert_eq!(prog, "code"); + assert_eq!(args, vec!["--wait"]); + } + + #[test] + fn test_split_command_no_args() { + let (prog, args) = EditorConfig::split_command("vim"); + assert_eq!(prog, "vim"); + assert!(args.is_empty()); + } + + #[test] + fn test_split_command_multiple_args() { + let (prog, args) = EditorConfig::split_command("subl -w --new-window"); + assert_eq!(prog, "subl"); + assert_eq!(args, vec!["-w", "--new-window"]); + } + + #[test] + fn test_cmux_and_zellij_match_tmux_defaults() { + with_clean_env(|| { + let cmux = EditorConfig::detect(SessionWrapperType::Cmux); + let zellij = EditorConfig::detect(SessionWrapperType::Zellij); + let tmux = EditorConfig::detect(SessionWrapperType::Tmux); + + assert_eq!(cmux.editor, tmux.editor); + assert_eq!(cmux.visual, tmux.visual); + + assert_eq!(zellij.editor, tmux.editor); + assert_eq!(zellij.visual, tmux.visual); + }); + } +} diff --git a/src/issuetypes/loader.rs b/src/issuetypes/loader.rs index 8c3459c..c08d3fa 100644 --- a/src/issuetypes/loader.rs +++ b/src/issuetypes/loader.rs @@ -62,6 +62,7 @@ fn template_schema_to_issuetype(schema: TemplateSchema, source: IssueTypeSource) fields: schema.fields, steps: schema.steps, agent_prompt: schema.agent_prompt, + agent: schema.agent, source, external_id: None, } diff --git a/src/issuetypes/schema.rs b/src/issuetypes/schema.rs index 2c55ad8..99a08b5 100644 --- a/src/issuetypes/schema.rs +++ b/src/issuetypes/schema.rs @@ -51,6 +51,9 @@ pub struct IssueType { /// Prompt for generating this issue type's operator agent via `claude -p` #[serde(default)] pub agent_prompt: Option, + /// Default delegator name for this issuetype (overridden by step.agent) + #[serde(default)] + pub agent: Option, /// Source of this issue type (builtin, user, import) #[serde(default)] pub source: IssueTypeSource, @@ -169,6 +172,7 @@ impl IssueType { agent: None, }], agent_prompt: None, + agent: None, source: IssueTypeSource::Import { provider, project }, external_id, } @@ -337,6 +341,7 @@ mod tests { agent: None, }], agent_prompt: None, + agent: None, source: IssueTypeSource::User, external_id: None, } diff --git a/src/lib.rs b/src/lib.rs index a94551f..058f831 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub mod agents; pub mod api; pub mod config; +pub mod editors; pub mod git; pub mod queue; pub mod rest; diff --git a/src/llm/detection.rs b/src/llm/detection.rs index aff8fe3..79cd1ef 100644 --- a/src/llm/detection.rs +++ b/src/llm/detection.rs @@ -37,6 +37,8 @@ pub fn detect_all_tools() -> LlmToolsConfig { providers, detection_complete: true, skill_directory_overrides: std::collections::HashMap::new(), + default_tool: None, + default_model: None, } } diff --git a/src/main.rs b/src/main.rs index 162b17f..c20e5ce 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod app; mod backstage; mod collections; mod config; +mod editors; mod git; mod issuetypes; mod llm; @@ -578,8 +579,9 @@ async fn cmd_create( let project = project.unwrap_or_else(|| "global".to_string()); + let editor_config = editors::EditorConfig::detect(config.sessions.wrapper); let creator = TicketCreator::new(config); - let filepath = creator.create_ticket(template_type, &project)?; + let filepath = creator.create_ticket(template_type, &project, editor_config.file_editor())?; println!("Created ticket: {}", filepath.display()); diff --git a/src/queue/creator.rs b/src/queue/creator.rs index 4c553cd..5996549 100644 --- a/src/queue/creator.rs +++ b/src/queue/creator.rs @@ -26,13 +26,15 @@ impl TicketCreator { } } - /// Create a new ticket from template with pre-filled values and open in $EDITOR + /// Create a new ticket from template with pre-filled values and open in editor. /// - /// Returns the path to the created ticket file + /// Returns the path to the created ticket file. + /// `editor_cmd` is the resolved editor command (e.g. from `EditorConfig::file_editor()`). pub fn create_ticket_with_values( &self, template_type: TemplateType, values: &HashMap, + editor_cmd: &str, ) -> Result { // Generate filename with timestamp let now = Utc::now(); @@ -59,19 +61,23 @@ impl TicketCreator { // Write to file fs::write(&filepath, &content).context("Failed to write ticket file")?; - // Open in $EDITOR - self.open_in_editor(&filepath)?; + // Open in editor + self.open_in_editor(&filepath, editor_cmd)?; Ok(filepath) } - /// Create a new ticket from template and open in $EDITOR (legacy method) + /// Create a new ticket from template and open in editor (legacy method). /// - /// Returns the path to the created ticket file - pub fn create_ticket(&self, template_type: TemplateType, project: &str) -> Result { - // Generate auto-filled values + /// Returns the path to the created ticket file. + pub fn create_ticket( + &self, + template_type: TemplateType, + project: &str, + editor_cmd: &str, + ) -> Result { let values = self.generate_default_values(template_type, project); - self.create_ticket_with_values(template_type, &values) + self.create_ticket_with_values(template_type, &values, editor_cmd) } /// Generate default values for auto-filled fields @@ -104,13 +110,14 @@ impl TicketCreator { } /// Open a file in the user's preferred editor - fn open_in_editor(&self, filepath: &PathBuf) -> Result<()> { - let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".to_string()); + fn open_in_editor(&self, filepath: &PathBuf, editor_cmd: &str) -> Result<()> { + let (prog, args) = crate::editors::EditorConfig::split_command(editor_cmd); - let status = Command::new(&editor) + let status = Command::new(prog) + .args(&args) .arg(filepath) .status() - .context(format!("Failed to open editor: {editor}"))?; + .context(format!("Failed to open editor: {editor_cmd}"))?; if !status.success() { anyhow::bail!("Editor exited with non-zero status"); diff --git a/src/rest/dto.rs b/src/rest/dto.rs index e914cb0..12468bf 100644 --- a/src/rest/dto.rs +++ b/src/rest/dto.rs @@ -143,6 +143,7 @@ impl CreateIssueTypeRequest { .map(std::convert::Into::into) .collect(), agent_prompt: None, + agent: None, source: IssueTypeSource::User, external_id: None, } @@ -1009,6 +1010,9 @@ pub struct CreateDelegatorRequest { } /// Launch configuration DTO for delegators +/// +/// Optional fields use tri-state semantics: `None` = inherit global config, +/// `Some(true/false)` = explicit override per-delegator. #[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] pub struct DelegatorLaunchConfigDto { @@ -1016,11 +1020,26 @@ pub struct DelegatorLaunchConfigDto { #[serde(default)] pub yolo: bool, /// Permission mode override - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub permission_mode: Option, /// Additional CLI flags #[serde(default)] pub flags: Vec, + /// Override global `git.use_worktrees` (None = use global setting) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub use_worktrees: Option, + /// Whether to create a git branch for the ticket (None = default behavior) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub create_branch: Option, + /// Run in docker container (None = use global `launch.docker.enabled`) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub docker: Option, + /// Prompt text to prepend before the generated step prompt + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt_prefix: Option, + /// Prompt text to append after the generated step prompt + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt_suffix: Option, } /// Response listing all delegators @@ -1033,6 +1052,30 @@ pub struct DelegatorsResponse { pub total: usize, } +/// Request to create a delegator from a detected LLM tool +/// +/// Pre-populates delegator fields from the detected tool, requiring minimal input. +/// If `name` is omitted, auto-generates as `"{tool_name}-{model}"`. +/// If `model` is omitted, uses the tool's first model alias. +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct CreateDelegatorFromToolRequest { + /// Name of the detected tool (e.g., "claude", "codex", "gemini") + pub tool_name: String, + /// Model alias to use (e.g., "opus"). If omitted, uses the tool's first model alias. + #[serde(default)] + pub model: Option, + /// Custom delegator name. If omitted, auto-generates as `"{tool_name}-{model}"`. + #[serde(default)] + pub name: Option, + /// Optional display name for UI + #[serde(default)] + pub display_name: Option, + /// Optional launch configuration + #[serde(default)] + pub launch_config: Option, +} + // ============================================================================= // LLM Tools DTOs // ============================================================================= @@ -1047,6 +1090,26 @@ pub struct LlmToolsResponse { pub total: usize, } +/// Request to set the global default LLM tool and model +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SetDefaultLlmRequest { + /// Tool name (must match a detected tool, e.g., "claude") + pub tool: String, + /// Model alias (e.g., "opus", "sonnet") + pub model: String, +} + +/// Response with the current default LLM tool and model +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct DefaultLlmResponse { + /// Default tool name (empty string if not set) + pub tool: String, + /// Default model alias (empty string if not set) + pub model: String, +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/rest/mod.rs b/src/rest/mod.rs index c381118..ef2e856 100644 --- a/src/rest/mod.rs +++ b/src/rest/mod.rs @@ -118,12 +118,22 @@ pub fn build_router(state: ApiState) -> Router { ) // Skills endpoint .route("/api/v1/skills", get(routes::skills::list)) - // LLM tools endpoint + // LLM tools endpoints .route("/api/v1/llm-tools", get(routes::llm_tools::list)) + .route( + "/api/v1/llm-tools/default", + get(routes::llm_tools::get_default).put(routes::llm_tools::set_default), + ) // Delegator endpoints .route("/api/v1/delegators", get(routes::delegators::list)) .route("/api/v1/delegators", post(routes::delegators::create)) + // from-tool must be registered before :name to avoid path capture + .route( + "/api/v1/delegators/from-tool", + post(routes::delegators::create_from_tool), + ) .route("/api/v1/delegators/:name", get(routes::delegators::get_one)) + .route("/api/v1/delegators/:name", put(routes::delegators::update)) .route( "/api/v1/delegators/:name", delete(routes::delegators::delete), diff --git a/src/rest/openapi.rs b/src/rest/openapi.rs index ba6b1e5..5523c0a 100644 --- a/src/rest/openapi.rs +++ b/src/rest/openapi.rs @@ -4,11 +4,11 @@ use utoipa::OpenApi; use crate::mcp::descriptor::McpDescriptorResponse; use crate::rest::dto::{ - CollectionResponse, CreateDelegatorRequest, CreateFieldRequest, CreateIssueTypeRequest, - CreateStepRequest, DelegatorLaunchConfigDto, DelegatorResponse, DelegatorsResponse, - FieldResponse, HealthResponse, IssueTypeResponse, IssueTypeSummary, LaunchTicketRequest, - LaunchTicketResponse, SkillEntry, SkillsResponse, StatusResponse, StepResponse, - UpdateIssueTypeRequest, UpdateStepRequest, + CollectionResponse, CreateDelegatorFromToolRequest, CreateDelegatorRequest, CreateFieldRequest, + CreateIssueTypeRequest, CreateStepRequest, DefaultLlmResponse, DelegatorLaunchConfigDto, + DelegatorResponse, DelegatorsResponse, FieldResponse, HealthResponse, IssueTypeResponse, + IssueTypeSummary, LaunchTicketRequest, LaunchTicketResponse, SetDefaultLlmRequest, SkillEntry, + SkillsResponse, StatusResponse, StepResponse, UpdateIssueTypeRequest, UpdateStepRequest, }; use crate::rest::error::ErrorResponse; @@ -52,7 +52,13 @@ use crate::rest::error::ErrorResponse; crate::rest::routes::delegators::list, crate::rest::routes::delegators::get_one, crate::rest::routes::delegators::create, + crate::rest::routes::delegators::create_from_tool, + crate::rest::routes::delegators::update, crate::rest::routes::delegators::delete, + // LLM tools endpoints + crate::rest::routes::llm_tools::list, + crate::rest::routes::llm_tools::get_default, + crate::rest::routes::llm_tools::set_default, // MCP endpoints crate::mcp::descriptor::descriptor, ), @@ -82,7 +88,11 @@ use crate::rest::error::ErrorResponse; DelegatorResponse, DelegatorsResponse, CreateDelegatorRequest, + CreateDelegatorFromToolRequest, DelegatorLaunchConfigDto, + // LLM tools types + SetDefaultLlmRequest, + DefaultLlmResponse, // MCP types McpDescriptorResponse, ) diff --git a/src/rest/routes/delegators.rs b/src/rest/routes/delegators.rs index 0f8e10d..830a6b8 100644 --- a/src/rest/routes/delegators.rs +++ b/src/rest/routes/delegators.rs @@ -10,7 +10,8 @@ use axum::{ use crate::config::{Config, Delegator, DelegatorLaunchConfig}; use crate::rest::dto::{ - CreateDelegatorRequest, DelegatorLaunchConfigDto, DelegatorResponse, DelegatorsResponse, + CreateDelegatorFromToolRequest, CreateDelegatorRequest, DelegatorLaunchConfigDto, + DelegatorResponse, DelegatorsResponse, }; use crate::rest::error::ApiError; use crate::rest::state::ApiState; @@ -91,11 +92,7 @@ pub async fn create( model: req.model, display_name: req.display_name, model_properties: req.model_properties, - launch_config: req.launch_config.map(|lc| DelegatorLaunchConfig { - yolo: lc.yolo, - permission_mode: lc.permission_mode, - flags: lc.flags, - }), + launch_config: req.launch_config.map(dto_to_launch_config), }; // Read current config, add delegator, save @@ -144,6 +141,34 @@ pub async fn delete( Ok(Json(response)) } +/// Convert a `DelegatorLaunchConfigDto` to a `DelegatorLaunchConfig` +fn dto_to_launch_config(lc: DelegatorLaunchConfigDto) -> DelegatorLaunchConfig { + DelegatorLaunchConfig { + yolo: lc.yolo, + permission_mode: lc.permission_mode, + flags: lc.flags, + use_worktrees: lc.use_worktrees, + create_branch: lc.create_branch, + docker: lc.docker, + prompt_prefix: lc.prompt_prefix, + prompt_suffix: lc.prompt_suffix, + } +} + +/// Convert a `DelegatorLaunchConfig` to a `DelegatorLaunchConfigDto` +fn launch_config_to_dto(lc: &DelegatorLaunchConfig) -> DelegatorLaunchConfigDto { + DelegatorLaunchConfigDto { + yolo: lc.yolo, + permission_mode: lc.permission_mode.clone(), + flags: lc.flags.clone(), + use_worktrees: lc.use_worktrees, + create_branch: lc.create_branch, + docker: lc.docker, + prompt_prefix: lc.prompt_prefix.clone(), + prompt_suffix: lc.prompt_suffix.clone(), + } +} + /// Convert a Delegator config to a `DelegatorResponse` DTO fn delegator_to_response(d: &Delegator) -> DelegatorResponse { DelegatorResponse { @@ -152,12 +177,119 @@ fn delegator_to_response(d: &Delegator) -> DelegatorResponse { model: d.model.clone(), display_name: d.display_name.clone(), model_properties: d.model_properties.clone(), - launch_config: d.launch_config.as_ref().map(|lc| DelegatorLaunchConfigDto { - yolo: lc.yolo, - permission_mode: lc.permission_mode.clone(), - flags: lc.flags.clone(), - }), + launch_config: d.launch_config.as_ref().map(launch_config_to_dto), + } +} + +/// Create a delegator from a detected LLM tool +/// +/// Pre-populates delegator fields from the detected tool, requiring minimal input. +#[utoipa::path( + post, + path = "/api/v1/delegators/from-tool", + tag = "Delegators", + request_body = CreateDelegatorFromToolRequest, + responses( + (status = 200, description = "Delegator created from tool", body = DelegatorResponse), + (status = 404, description = "Tool not detected"), + (status = 409, description = "Delegator already exists") + ) +)] +pub async fn create_from_tool( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + // Find the detected tool + let tool = state + .config + .llm_tools + .detected + .iter() + .find(|t| t.name == req.tool_name) + .ok_or_else(|| ApiError::NotFound(format!("Tool '{}' not detected", req.tool_name)))?; + + // Resolve model (explicit or first alias or "default") + let model = req.model.unwrap_or_else(|| { + tool.model_aliases + .first() + .cloned() + .unwrap_or_else(|| "default".to_string()) + }); + + // Auto-generate name if not provided + let name = req + .name + .unwrap_or_else(|| format!("{}-{}", tool.name, model)); + + // Check for duplicate + if state.config.delegators.iter().any(|d| d.name == name) { + return Err(ApiError::Conflict(format!( + "Delegator '{name}' already exists" + ))); + } + + let delegator = Delegator { + name, + llm_tool: tool.name.clone(), + model, + display_name: req.display_name, + model_properties: std::collections::HashMap::new(), + launch_config: req.launch_config.map(dto_to_launch_config), + }; + + // Save to config + let mut config = Config::load(None).unwrap_or_else(|_| (*state.config).clone()); + config.delegators.push(delegator.clone()); + config + .save() + .map_err(|e| ApiError::InternalError(format!("Failed to save config: {e}")))?; + + Ok(Json(delegator_to_response(&delegator))) +} + +/// Update an existing delegator +#[utoipa::path( + put, + path = "/api/v1/delegators/{name}", + tag = "Delegators", + params( + ("name" = String, Path, description = "Delegator name") + ), + request_body = CreateDelegatorRequest, + responses( + (status = 200, description = "Delegator updated", body = DelegatorResponse), + (status = 404, description = "Delegator not found") + ) +)] +pub async fn update( + State(state): State, + Path(name): Path, + Json(req): Json, +) -> Result, ApiError> { + // Verify the delegator exists + if !state.config.delegators.iter().any(|d| d.name == name) { + return Err(ApiError::NotFound(format!("Delegator '{name}' not found"))); + } + + let updated = Delegator { + name: name.clone(), + llm_tool: req.llm_tool, + model: req.model, + display_name: req.display_name, + model_properties: req.model_properties, + launch_config: req.launch_config.map(dto_to_launch_config), + }; + + // Replace in config and save + let mut config = Config::load(None).unwrap_or_else(|_| (*state.config).clone()); + if let Some(existing) = config.delegators.iter_mut().find(|d| d.name == name) { + *existing = updated.clone(); } + config + .save() + .map_err(|e| ApiError::InternalError(format!("Failed to save config: {e}")))?; + + Ok(Json(delegator_to_response(&updated))) } #[cfg(test)] @@ -216,6 +348,7 @@ mod tests { yolo: true, permission_mode: None, flags: vec![], + ..Default::default() }), }); let state = ApiState::new(config, PathBuf::from("/tmp/test")); @@ -227,4 +360,58 @@ mod tests { assert_eq!(resp.llm_tool, "codex"); assert!(resp.launch_config.as_ref().unwrap().yolo); } + + #[tokio::test] + async fn test_get_one_with_extended_launch_config() { + let mut config = Config::default(); + config.delegators.push(Delegator { + name: "full-config".to_string(), + llm_tool: "claude".to_string(), + model: "opus".to_string(), + display_name: Some("Full Config".to_string()), + model_properties: std::collections::HashMap::new(), + launch_config: Some(DelegatorLaunchConfig { + yolo: true, + permission_mode: Some("accept-edits".to_string()), + flags: vec!["--verbose".to_string()], + use_worktrees: Some(true), + create_branch: Some(true), + docker: Some(false), + prompt_prefix: Some("Always follow TDD.".to_string()), + prompt_suffix: Some("Run tests before finishing.".to_string()), + }), + }); + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let result = get_one(State(state), Path("full-config".to_string())).await; + assert!(result.is_ok()); + let resp = result.unwrap(); + let lc = resp.launch_config.as_ref().unwrap(); + assert!(lc.yolo); + assert_eq!(lc.use_worktrees, Some(true)); + assert_eq!(lc.create_branch, Some(true)); + assert_eq!(lc.docker, Some(false)); + assert_eq!(lc.prompt_prefix.as_deref(), Some("Always follow TDD.")); + assert_eq!( + lc.prompt_suffix.as_deref(), + Some("Run tests before finishing.") + ); + } + + #[tokio::test] + async fn test_create_from_tool_unknown() { + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let req = crate::rest::dto::CreateDelegatorFromToolRequest { + tool_name: "nonexistent".to_string(), + model: None, + name: None, + display_name: None, + launch_config: None, + }; + + let result = create_from_tool(State(state), Json(req)).await; + assert!(result.is_err()); + } } diff --git a/src/rest/routes/launch.rs b/src/rest/routes/launch.rs index 3171948..9eb3c65 100644 --- a/src/rest/routes/launch.rs +++ b/src/rest/routes/launch.rs @@ -8,8 +8,8 @@ use axum::{ Json, }; +use crate::agents::delegator_resolution::{self, AgentContext}; use crate::agents::{LaunchOptions, Launcher, PreparedLaunch, RelaunchOptions}; -use crate::config::{Config, Delegator, LlmProvider}; use crate::queue::Queue; use crate::rest::dto::{ LaunchTicketRequest, LaunchTicketResponse, NextStepInfo, StepCompleteRequest, @@ -69,6 +69,26 @@ pub async fn launch_ticket( .map_err(|e| ApiError::InternalError(e.to_string()))? .ok_or_else(|| ApiError::NotFound(format!("Ticket '{ticket_id}' not found")))?; + // Resolve issuetype agent context for delegator layering + let agent_context = { + let registry = state.registry.read().await; + registry + .get(&ticket.ticket_type.to_uppercase()) + .map(|issue_type| { + let step_agent = if ticket.step.is_empty() { + issue_type.first_step().and_then(|s| s.agent.clone()) + } else { + issue_type + .get_step(&ticket.step) + .and_then(|s| s.agent.clone()) + }; + AgentContext { + step_agent, + issuetype_agent: issue_type.agent.clone(), + } + }) + }; + // Check if ticket is in-progress directory let in_progress_path = state .config @@ -82,14 +102,14 @@ pub async fn launch_ticket( let prepared = if in_progress_path.exists() { // Ticket is in-progress - use relaunch flow (no claim needed) - let relaunch_options = build_relaunch_options(&state, &request)?; + let relaunch_options = build_relaunch_options(&state, &request, agent_context.as_ref())?; launcher .prepare_relaunch(&ticket, relaunch_options) .await .map_err(|e| ApiError::InternalError(e.to_string()))? } else { // New launch - claim ticket from queue - let launch_options = build_launch_options(&state, &request)?; + let launch_options = build_launch_options(&state, &request, agent_context.as_ref())?; launcher .prepare_launch(&ticket, launch_options) .await @@ -99,144 +119,30 @@ pub async fn launch_ticket( Ok(Json(prepared_launch_to_response(prepared))) } -/// Convert a `Delegator` into an `LlmProvider` -fn delegator_to_provider(d: &Delegator) -> LlmProvider { - LlmProvider { - tool: d.llm_tool.clone(), - model: d.model.clone(), - ..Default::default() - } -} - -/// Resolve a default delegator when none is explicitly specified. -/// -/// Resolution chain: -/// 1. Single configured delegator → use it -/// 2. Delegator matching the user's preferred LLM tool → use it -/// 3. None → caller falls back to first detected tool + first model alias -fn resolve_default_delegator(config: &Config) -> Option<&Delegator> { - match config.delegators.len() { - 0 => None, - 1 => Some(&config.delegators[0]), - _ => { - // Prefer delegator matching the user's preferred LLM tool - let preferred_tool = config.llm_tools.detected.first().map(|t| &t.name); - if let Some(tool_name) = preferred_tool { - config.delegators.iter().find(|d| &d.llm_tool == tool_name) - } else { - Some(&config.delegators[0]) - } - } - } -} - -/// Build `LaunchOptions` from the request -/// -/// Resolution chain: delegator name > provider/model > default delegator > detected tool defaults +/// Build `LaunchOptions` from the request, delegating to the shared resolution module. fn build_launch_options( state: &ApiState, request: &LaunchTicketRequest, + agent_context: Option<&AgentContext>, ) -> Result { - let mut options = LaunchOptions { - yolo_mode: request.yolo_mode, - ..Default::default() - }; - - // 1. Explicit delegator name takes precedence - if let Some(ref delegator_name) = request.delegator { - let delegator = state - .config - .delegators - .iter() - .find(|d| d.name == *delegator_name) - .ok_or_else(|| ApiError::BadRequest(format!("Unknown delegator '{delegator_name}'")))?; - - options.provider = Some(delegator_to_provider(delegator)); - options.delegator_name = Some(delegator.name.clone()); - - // Apply delegator launch_config - if let Some(ref lc) = delegator.launch_config { - options.yolo_mode = options.yolo_mode || lc.yolo; - options.extra_flags.clone_from(&lc.flags); - } - - return Ok(options); - } - - // 2. Legacy: explicit provider/model - if let Some(ref provider_name) = request.provider { - let provider = state - .config - .llm_tools - .providers - .iter() - .find(|p| p.tool == *provider_name) - .cloned(); - - if let Some(p) = provider { - let model = request.model.clone().unwrap_or(p.model.clone()); - options.provider = Some(LlmProvider { - tool: p.tool, - model, - ..Default::default() - }); - } else { - return Err(ApiError::BadRequest(format!( - "Unknown provider '{provider_name}'" - ))); - } - - return Ok(options); - } - - if let Some(ref model) = request.model { - if let Some(p) = state.config.llm_tools.providers.first().cloned() { - options.provider = Some(LlmProvider { - tool: p.tool, - model: model.clone(), - ..Default::default() - }); - } - - return Ok(options); - } - - // 3. No explicit selection — resolve default delegator - if let Some(delegator) = resolve_default_delegator(&state.config) { - options.provider = Some(delegator_to_provider(delegator)); - options.delegator_name = Some(delegator.name.clone()); - - if let Some(ref lc) = delegator.launch_config { - options.yolo_mode = options.yolo_mode || lc.yolo; - options.extra_flags.clone_from(&lc.flags); - } - - return Ok(options); - } - - // 4. No delegators at all — fall back to first detected tool + first model alias - if let Some(tool) = state.config.llm_tools.detected.first() { - let model = tool - .model_aliases - .first() - .cloned() - .unwrap_or_else(|| "default".to_string()); - options.provider = Some(LlmProvider { - tool: tool.name.clone(), - model, - ..Default::default() - }); - } - - Ok(options) + delegator_resolution::resolve_launch_options( + &state.config, + request.delegator.as_deref(), + request.provider.as_deref(), + request.model.as_deref(), + request.yolo_mode, + agent_context, + ) + .map_err(|e| ApiError::BadRequest(e.to_string())) } /// Build `RelaunchOptions` from the request fn build_relaunch_options( state: &ApiState, request: &LaunchTicketRequest, + agent_context: Option<&AgentContext>, ) -> Result { - let launch_options = build_launch_options(state, request)?; + let launch_options = build_launch_options(state, request, agent_context)?; Ok(RelaunchOptions { launch_options, @@ -396,7 +302,7 @@ mod tests { resume_session_id: None, }; - let result = build_launch_options(&state, &request); + let result = build_launch_options(&state, &request, None); assert!(result.is_ok()); let options = result.unwrap(); @@ -417,7 +323,7 @@ mod tests { resume_session_id: None, }; - let result = build_launch_options(&state, &request); + let result = build_launch_options(&state, &request, None); assert!(result.is_ok()); let options = result.unwrap(); @@ -437,7 +343,7 @@ mod tests { resume_session_id: None, }; - let result = build_launch_options(&state, &request); + let result = build_launch_options(&state, &request, None); assert!(result.is_err()); } @@ -454,7 +360,7 @@ mod tests { resume_session_id: Some("abc-123".to_string()), }; - let result = build_relaunch_options(&state, &request); + let result = build_relaunch_options(&state, &request, None); assert!(result.is_ok()); let options = result.unwrap(); @@ -465,4 +371,247 @@ mod tests { ); assert_eq!(options.resume_session_id, Some("abc-123".to_string())); } + + #[test] + fn test_build_launch_options_delegator_propagates_all_fields() { + let mut config = Config::default(); + config.delegators.push(crate::config::Delegator { + name: "full-delegator".to_string(), + llm_tool: "claude".to_string(), + model: "opus".to_string(), + display_name: None, + model_properties: std::collections::HashMap::new(), + launch_config: Some(crate::config::DelegatorLaunchConfig { + yolo: true, + permission_mode: Some("accept-edits".to_string()), + flags: vec!["--verbose".to_string()], + use_worktrees: Some(true), + create_branch: Some(false), + docker: Some(true), + prompt_prefix: Some("PREFIX".to_string()), + prompt_suffix: Some("SUFFIX".to_string()), + }), + }); + let state = ApiState::new(config, PathBuf::from("/tmp/test-launch")); + + let request = LaunchTicketRequest { + delegator: Some("full-delegator".to_string()), + provider: None, + model: None, + yolo_mode: false, + wrapper: None, + retry_reason: None, + resume_session_id: None, + }; + + let result = build_launch_options(&state, &request, None); + assert!(result.is_ok()); + + let options = result.unwrap(); + assert!(options.yolo_mode); + assert!(options.docker_mode); + assert_eq!(options.use_worktrees_override, Some(true)); + assert_eq!(options.create_branch_override, Some(false)); + assert_eq!(options.prompt_prefix.as_deref(), Some("PREFIX")); + assert_eq!(options.prompt_suffix.as_deref(), Some("SUFFIX")); + assert_eq!(options.extra_flags, vec!["--verbose".to_string()]); + assert_eq!(options.delegator_name.as_deref(), Some("full-delegator")); + } + + #[test] + fn test_build_launch_options_delegator_none_overrides_inherit() { + let mut config = Config::default(); + config.delegators.push(crate::config::Delegator { + name: "minimal".to_string(), + llm_tool: "claude".to_string(), + model: "sonnet".to_string(), + display_name: None, + model_properties: std::collections::HashMap::new(), + launch_config: Some(crate::config::DelegatorLaunchConfig::default()), + }); + let state = ApiState::new(config, PathBuf::from("/tmp/test-launch")); + + let request = LaunchTicketRequest { + delegator: Some("minimal".to_string()), + provider: None, + model: None, + yolo_mode: false, + wrapper: None, + retry_reason: None, + resume_session_id: None, + }; + + let result = build_launch_options(&state, &request, None); + assert!(result.is_ok()); + + let options = result.unwrap(); + assert!(!options.yolo_mode); + assert!(!options.docker_mode); + assert!(options.use_worktrees_override.is_none()); + assert!(options.create_branch_override.is_none()); + assert!(options.prompt_prefix.is_none()); + assert!(options.prompt_suffix.is_none()); + } + + // --- Layered delegator resolution tests --- + + fn make_state_with_delegators(delegators: Vec) -> ApiState { + let mut config = Config::default(); + config.delegators = delegators; + ApiState::new(config, PathBuf::from("/tmp/test-launch")) + } + + fn make_delegator(name: &str, tool: &str, model: &str) -> crate::config::Delegator { + crate::config::Delegator { + name: name.to_string(), + llm_tool: tool.to_string(), + model: model.to_string(), + display_name: None, + model_properties: std::collections::HashMap::new(), + launch_config: None, + } + } + + fn empty_request() -> LaunchTicketRequest { + LaunchTicketRequest { + delegator: None, + provider: None, + model: None, + yolo_mode: false, + wrapper: None, + retry_reason: None, + resume_session_id: None, + } + } + + #[test] + fn test_build_launch_options_step_agent_resolves() { + let state = + make_state_with_delegators(vec![make_delegator("claude-opus", "claude", "opus")]); + let ctx = AgentContext { + step_agent: Some("claude-opus".to_string()), + issuetype_agent: None, + }; + + let options = build_launch_options(&state, &empty_request(), Some(&ctx)).unwrap(); + let provider = options.provider.unwrap(); + assert_eq!(provider.tool, "claude"); + assert_eq!(provider.model, "opus"); + assert_eq!(options.delegator_name.as_deref(), Some("claude-opus")); + } + + #[test] + fn test_build_launch_options_issuetype_agent_fallback() { + let state = + make_state_with_delegators(vec![make_delegator("claude-opus", "claude", "opus")]); + let ctx = AgentContext { + step_agent: None, + issuetype_agent: Some("claude-opus".to_string()), + }; + + let options = build_launch_options(&state, &empty_request(), Some(&ctx)).unwrap(); + let provider = options.provider.unwrap(); + assert_eq!(provider.tool, "claude"); + assert_eq!(provider.model, "opus"); + } + + #[test] + fn test_build_launch_options_step_agent_overrides_issuetype() { + let state = make_state_with_delegators(vec![ + make_delegator("claude-opus", "claude", "opus"), + make_delegator("claude-sonnet", "claude", "sonnet"), + ]); + let ctx = AgentContext { + step_agent: Some("claude-opus".to_string()), + issuetype_agent: Some("claude-sonnet".to_string()), + }; + + let options = build_launch_options(&state, &empty_request(), Some(&ctx)).unwrap(); + let provider = options.provider.unwrap(); + assert_eq!(provider.model, "opus"); + assert_eq!(options.delegator_name.as_deref(), Some("claude-opus")); + } + + #[test] + fn test_build_launch_options_request_delegator_overrides_context() { + let state = make_state_with_delegators(vec![ + make_delegator("claude-opus", "claude", "opus"), + make_delegator("gemini-pro", "gemini", "pro"), + ]); + let ctx = AgentContext { + step_agent: Some("claude-opus".to_string()), + issuetype_agent: Some("claude-opus".to_string()), + }; + let request = LaunchTicketRequest { + delegator: Some("gemini-pro".to_string()), + ..empty_request() + }; + + let options = build_launch_options(&state, &request, Some(&ctx)).unwrap(); + let provider = options.provider.unwrap(); + assert_eq!(provider.tool, "gemini"); + assert_eq!(provider.model, "pro"); + assert_eq!(options.delegator_name.as_deref(), Some("gemini-pro")); + } + + #[test] + fn test_build_launch_options_unknown_step_agent_falls_through() { + let state = + make_state_with_delegators(vec![make_delegator("claude-opus", "claude", "opus")]); + let ctx = AgentContext { + step_agent: Some("nonexistent-delegator".to_string()), + issuetype_agent: Some("claude-opus".to_string()), + }; + + let options = build_launch_options(&state, &empty_request(), Some(&ctx)).unwrap(); + let provider = options.provider.unwrap(); + assert_eq!(provider.model, "opus"); + assert_eq!(options.delegator_name.as_deref(), Some("claude-opus")); + } + + #[test] + fn test_build_launch_options_no_context_preserves_existing() { + let state = + make_state_with_delegators(vec![make_delegator("claude-opus", "claude", "opus")]); + + // With a single delegator and no context, should resolve to default delegator + let options = build_launch_options(&state, &empty_request(), None).unwrap(); + let provider = options.provider.unwrap(); + assert_eq!(provider.tool, "claude"); + assert_eq!(provider.model, "opus"); + } + + #[test] + fn test_build_launch_options_step_agent_applies_launch_config() { + let state = make_state_with_delegators(vec![crate::config::Delegator { + name: "codex-auto".to_string(), + llm_tool: "codex".to_string(), + model: "o3".to_string(), + display_name: None, + model_properties: std::collections::HashMap::new(), + launch_config: Some(crate::config::DelegatorLaunchConfig { + yolo: true, + permission_mode: None, + flags: vec!["--full-auto".to_string()], + use_worktrees: Some(true), + create_branch: Some(true), + docker: Some(false), + prompt_prefix: Some("BEGIN".to_string()), + prompt_suffix: Some("END".to_string()), + }), + }]); + let ctx = AgentContext { + step_agent: Some("codex-auto".to_string()), + issuetype_agent: None, + }; + + let options = build_launch_options(&state, &empty_request(), Some(&ctx)).unwrap(); + assert!(options.yolo_mode); + assert!(!options.docker_mode); + assert_eq!(options.use_worktrees_override, Some(true)); + assert_eq!(options.create_branch_override, Some(true)); + assert_eq!(options.extra_flags, vec!["--full-auto".to_string()]); + assert_eq!(options.prompt_prefix.as_deref(), Some("BEGIN")); + assert_eq!(options.prompt_suffix.as_deref(), Some("END")); + } } diff --git a/src/rest/routes/llm_tools.rs b/src/rest/routes/llm_tools.rs index 6ef1211..8de3199 100644 --- a/src/rest/routes/llm_tools.rs +++ b/src/rest/routes/llm_tools.rs @@ -6,7 +6,9 @@ use axum::extract::State; use axum::Json; -use crate::rest::dto::LlmToolsResponse; +use crate::config::Config; +use crate::rest::dto::{DefaultLlmResponse, LlmToolsResponse, SetDefaultLlmRequest}; +use crate::rest::error::ApiError; use crate::rest::state::ApiState; /// List detected LLM tools with model aliases @@ -24,6 +26,73 @@ pub async fn list(State(state): State) -> Json { Json(LlmToolsResponse { tools, total }) } +/// Get the current default LLM tool and model +#[utoipa::path( + get, + path = "/api/v1/llm-tools/default", + tag = "LLM Tools", + responses( + (status = 200, description = "Current default LLM", body = DefaultLlmResponse) + ) +)] +pub async fn get_default(State(state): State) -> Json { + Json(DefaultLlmResponse { + tool: state + .config + .llm_tools + .default_tool + .clone() + .unwrap_or_default(), + model: state + .config + .llm_tools + .default_model + .clone() + .unwrap_or_default(), + }) +} + +/// Set the global default LLM tool and model +#[utoipa::path( + put, + path = "/api/v1/llm-tools/default", + tag = "LLM Tools", + request_body = SetDefaultLlmRequest, + responses( + (status = 200, description = "Default LLM set", body = DefaultLlmResponse), + (status = 404, description = "Tool not detected") + ) +)] +pub async fn set_default( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + if !state + .config + .llm_tools + .detected + .iter() + .any(|t| t.name == req.tool) + { + return Err(ApiError::NotFound(format!( + "Tool '{}' not detected", + req.tool + ))); + } + + let mut config = Config::load(None).unwrap_or_else(|_| (*state.config).clone()); + config.llm_tools.default_tool = Some(req.tool.clone()); + config.llm_tools.default_model = Some(req.model.clone()); + config + .save() + .map_err(|e| ApiError::InternalError(format!("Failed to save config: {e}")))?; + + Ok(Json(DefaultLlmResponse { + tool: req.tool, + model: req.model, + })) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/ui/dashboard.rs b/src/ui/dashboard.rs index 4928767..2632185 100644 --- a/src/ui/dashboard.rs +++ b/src/ui/dashboard.rs @@ -1,5 +1,6 @@ #![allow(dead_code)] +use std::path::Path; use std::time::Instant; use ratatui::{ @@ -7,25 +8,31 @@ use ratatui::{ Frame, }; -use super::panels::{AgentsPanel, AwaitingPanel, CompletedPanel, HeaderBar, QueuePanel, StatusBar}; +use super::in_progress_panel::InProgressPanel; +use super::panels::{CompletedPanel, HeaderBar, QueuePanel, StatusBar}; +use super::status_panel::{ + DelegatorInfo, KanbanProviderInfo, LlmToolInfo, StatusPanel, StatusSnapshot, + WrapperConnectionStatus, +}; use crate::backstage::ServerStatus; -use crate::config::Config; +use crate::config::{Config, GitProviderConfig, SessionWrapperType}; +use crate::editors::EditorConfig; use crate::queue::Ticket; use crate::rest::RestApiStatus; use crate::state::{AgentState, CompletedTicket, OrphanSession}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FocusedPanel { + Status, Queue, - Agents, - Awaiting, + InProgress, Completed, } pub struct Dashboard { + pub status_panel: StatusPanel, pub queue_panel: QueuePanel, - pub agents_panel: AgentsPanel, - pub awaiting_panel: AwaitingPanel, + pub in_progress_panel: InProgressPanel, pub completed_panel: CompletedPanel, pub focused: FocusedPanel, pub paused: bool, @@ -44,16 +51,22 @@ pub struct Dashboard { pub status_message: Option, /// When the status message was set pub status_message_at: Option, + /// Cached wrapper connection status (updated periodically) + pub wrapper_connection_status: WrapperConnectionStatus, + /// Config snapshot for status panel + config: Config, + /// Resolved editor environment variables + pub editor_config: EditorConfig, } impl Dashboard { pub fn new(config: &Config) -> Self { - Self { + let mut dashboard = Self { + status_panel: StatusPanel::new(config.ui.panel_names.status.clone()), queue_panel: QueuePanel::new(config.ui.panel_names.queue.clone()), - agents_panel: AgentsPanel::new(config.ui.panel_names.agents.clone()), - awaiting_panel: AwaitingPanel::new(config.ui.panel_names.awaiting.clone()), + in_progress_panel: InProgressPanel::new(config.ui.panel_names.in_progress.clone()), completed_panel: CompletedPanel::new(config.ui.panel_names.completed.clone()), - focused: FocusedPanel::Queue, + focused: FocusedPanel::Status, paused: false, max_agents: config.effective_max_agents(), wrapper_name: config.sessions.wrapper.display_name(), @@ -63,6 +76,30 @@ impl Dashboard { update_available_version: None, status_message: None, status_message_at: None, + wrapper_connection_status: Self::initial_wrapper_status(config), + config: config.clone(), + editor_config: EditorConfig::detect(config.sessions.wrapper), + }; + dashboard.compute_initial_focus(); + dashboard + } + + /// Determine the best panel to focus on startup. + /// + /// Priority: + /// 1. Status panel — if any section needs attention (Yellow/Red), focus there + /// and select the first section that needs attention + /// 2. In Progress — if there are active agents + /// 3. Queue — default fallback + pub fn compute_initial_focus(&mut self) { + let snapshot = self.build_status_snapshot(); + if self.status_panel.has_attention_needed(&snapshot) { + self.focused = FocusedPanel::Status; + self.status_panel.focus_first_attention(&snapshot); + } else if !self.in_progress_panel.agents.is_empty() { + self.focused = FocusedPanel::InProgress; + } else { + self.focused = FocusedPanel::Queue; } } @@ -98,18 +135,33 @@ impl Dashboard { } } + pub fn update_config(&mut self, config: &Config) { + self.config = config.clone(); + } + + pub fn expand_and_focus_section(&mut self, section_id: super::status_panel::SectionId) { + let snapshot = self.build_status_snapshot(); + self.status_panel + .tree_state + .expanded + .insert(section_id, true); + // Find the header row for the section and select it + let rows = self.status_panel.flatten(&snapshot); + for (i, row) in rows.iter().enumerate() { + if row.is_header && row.section_id == section_id { + self.status_panel.tree_state.selected = i; + break; + } + } + } + pub fn update_queue(&mut self, tickets: Vec) { self.queue_panel.tickets = tickets; } pub fn update_agents(&mut self, agents: Vec) { - // Split into running and awaiting - let (awaiting, running): (Vec<_>, Vec<_>) = agents - .into_iter() - .partition(|a| a.status == "awaiting_input"); - - self.agents_panel.agents = running; - self.awaiting_panel.agents = awaiting; + // All agents go to the unified in_progress_panel + self.in_progress_panel.agents = agents; } pub fn update_completed(&mut self, tickets: Vec) { @@ -117,10 +169,129 @@ impl Dashboard { } pub fn update_orphan_sessions(&mut self, orphans: Vec) { - self.agents_panel.orphan_sessions = orphans; + self.in_progress_panel.orphan_sessions = orphans; + } + + /// Create initial wrapper connection status based on config. + fn initial_wrapper_status(config: &Config) -> WrapperConnectionStatus { + match config.sessions.wrapper { + SessionWrapperType::Tmux => WrapperConnectionStatus::Tmux { + available: false, + server_running: false, + version: None, + }, + SessionWrapperType::Vscode => WrapperConnectionStatus::Vscode { + webhook_running: false, + port: Some(config.sessions.vscode.webhook_port), + }, + SessionWrapperType::Cmux => WrapperConnectionStatus::Cmux { + binary_available: false, + in_cmux: std::env::var("CMUX_WORKSPACE_ID").is_ok(), + }, + SessionWrapperType::Zellij => WrapperConnectionStatus::Zellij { + binary_available: false, + in_zellij: std::env::var("ZELLIJ").is_ok(), + }, + } + } + + /// Update the cached wrapper connection status. + pub fn update_wrapper_connection_status(&mut self, status: WrapperConnectionStatus) { + self.wrapper_connection_status = status; + } + + /// Build a status snapshot from current config and runtime state + fn build_status_snapshot(&self) -> StatusSnapshot { + let config = &self.config; + + // Working directory is where the operator process runs from + let working_dir = std::env::current_dir() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(); + let config_path = Config::operator_config_path() + .to_string_lossy() + .into_owned(); + let tickets_dir = config.paths.tickets.clone(); + let tickets_dir_exists = Path::new(&tickets_dir).exists(); + + // Build kanban provider info from jira + linear configs + let mut kanban_providers: Vec = Vec::new(); + for domain in config.kanban.jira.keys() { + kanban_providers.push(KanbanProviderInfo { + provider_type: "jira".to_string(), + domain: domain.clone(), + }); + } + for slug in config.kanban.linear.keys() { + kanban_providers.push(KanbanProviderInfo { + provider_type: "linear".to_string(), + domain: slug.clone(), + }); + } + + // Build LLM tool info from detected tools + let llm_tools: Vec = config + .llm_tools + .detected + .iter() + .map(|t| LlmToolInfo { + name: t.name.clone(), + version: t.version.clone(), + model_aliases: t.model_aliases.clone(), + }) + .collect(); + + // Build delegator info + let delegators: Vec = config + .delegators + .iter() + .map(|d| DelegatorInfo { + name: d.name.clone(), + display_name: d.display_name.clone(), + llm_tool: d.llm_tool.clone(), + model: d.model.clone(), + yolo: d.launch_config.as_ref().is_some_and(|lc| lc.yolo), + }) + .collect(); + + // Git config + let git_provider = config.git.provider.as_ref().map(|p| format!("{p:?}")); + let git_token_set = match config.git.provider { + Some(GitProviderConfig::GitLab) => std::env::var(&config.git.gitlab.token_env).is_ok(), + // GitHub is the default for all other providers (including None) + _ => std::env::var(&config.git.github.token_env).is_ok(), + }; + + StatusSnapshot { + working_dir, + config_file_found: true, // We have a config if we're running + config_path, + tickets_dir, + tickets_dir_exists, + wrapper_type: config.sessions.wrapper.display_name().to_string(), + operator_version: env!("CARGO_PKG_VERSION").to_string(), + api_status: self.rest_api_status.clone(), + backstage_status: self.backstage_status.clone(), + backstage_display: config.backstage.display, + kanban_providers, + llm_tools, + default_llm_tool: config.llm_tools.default_tool.clone(), + default_llm_model: config.llm_tools.default_model.clone(), + delegators, + git_provider, + git_token_set, + git_branch_format: Some(config.git.branch_format.clone()), + git_use_worktrees: config.git.use_worktrees, + update_available_version: self.update_available_version.clone(), + wrapper_connection_status: self.wrapper_connection_status.clone(), + env_editor: self.editor_config.editor.clone(), + env_visual: self.editor_config.visual.clone(), + } } pub fn render(&mut self, frame: &mut Frame) { + let snapshot = self.build_status_snapshot(); + let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -137,32 +308,40 @@ impl Dashboard { }; header.render(frame, chunks[0]); - // Main content - 4 columns + // Main content - 4 columns: Status | Queue | In Progress | Completed + // Focused panel gets 40% width, others get 20% + let (s, q, ip, c) = match self.focused { + FocusedPanel::Status => (40, 20, 20, 20), + FocusedPanel::Queue => (20, 40, 20, 20), + FocusedPanel::InProgress => (20, 20, 40, 20), + FocusedPanel::Completed => (20, 20, 20, 40), + }; let main_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([ - Constraint::Percentage(25), // Queue - Constraint::Percentage(30), // Running - Constraint::Percentage(25), // Awaiting - Constraint::Percentage(20), // Completed + Constraint::Percentage(s), + Constraint::Percentage(q), + Constraint::Percentage(ip), + Constraint::Percentage(c), ]) .split(chunks[1]); // Render panels - self.queue_panel - .render(frame, main_chunks[0], self.focused == FocusedPanel::Queue); - - self.agents_panel.render( + self.status_panel.render( frame, - main_chunks[1], - self.focused == FocusedPanel::Agents, - self.max_agents, + main_chunks[0], + self.focused == FocusedPanel::Status, + &snapshot, ); - self.awaiting_panel.render( + self.queue_panel + .render(frame, main_chunks[1], self.focused == FocusedPanel::Queue); + + self.in_progress_panel.render( frame, main_chunks[2], - self.focused == FocusedPanel::Awaiting, + self.focused == FocusedPanel::InProgress, + self.max_agents, ); self.completed_panel.render( @@ -174,7 +353,7 @@ impl Dashboard { // Status bar let status = StatusBar { paused: self.paused, - agent_count: self.agents_panel.agents.len() + self.awaiting_panel.agents.len(), + agent_count: self.in_progress_panel.agents.len(), max_agents: self.max_agents, backstage_status: self.backstage_status.clone(), rest_api_status: self.rest_api_status.clone(), @@ -187,24 +366,28 @@ impl Dashboard { pub fn focus_next(&mut self) { self.focused = match self.focused { - FocusedPanel::Queue => FocusedPanel::Agents, - FocusedPanel::Agents => FocusedPanel::Awaiting, - FocusedPanel::Awaiting => FocusedPanel::Completed, - FocusedPanel::Completed => FocusedPanel::Queue, + FocusedPanel::Status => FocusedPanel::Queue, + FocusedPanel::Queue => FocusedPanel::InProgress, + FocusedPanel::InProgress => FocusedPanel::Completed, + FocusedPanel::Completed => FocusedPanel::Status, }; } pub fn focus_prev(&mut self) { self.focused = match self.focused { - FocusedPanel::Queue => FocusedPanel::Completed, - FocusedPanel::Agents => FocusedPanel::Queue, - FocusedPanel::Awaiting => FocusedPanel::Agents, - FocusedPanel::Completed => FocusedPanel::Awaiting, + FocusedPanel::Status => FocusedPanel::Completed, + FocusedPanel::Queue => FocusedPanel::Status, + FocusedPanel::InProgress => FocusedPanel::Queue, + FocusedPanel::Completed => FocusedPanel::InProgress, }; } pub fn select_next(&mut self) { match self.focused { + FocusedPanel::Status => { + let snapshot = self.build_status_snapshot(); + self.status_panel.select_next(&snapshot); + } FocusedPanel::Queue => { let len = self.queue_panel.tickets.len(); if len > 0 { @@ -218,31 +401,17 @@ impl Dashboard { self.queue_panel.state.select(Some(i)); } } - FocusedPanel::Agents => { - // Include orphan sessions in total count - let len = self.agents_panel.total_items(); + FocusedPanel::InProgress => { + let len = self.in_progress_panel.total_items(); if len > 0 { - let i = self.agents_panel.state.selected().map_or(0, |i| { + let i = self.in_progress_panel.state.selected().map_or(0, |i| { if i >= len - 1 { 0 } else { i + 1 } }); - self.agents_panel.state.select(Some(i)); - } - } - FocusedPanel::Awaiting => { - let len = self.awaiting_panel.agents.len(); - if len > 0 { - let i = self.awaiting_panel.state.selected().map_or(0, |i| { - if i >= len - 1 { - 0 - } else { - i + 1 - } - }); - self.awaiting_panel.state.select(Some(i)); + self.in_progress_panel.state.select(Some(i)); } } FocusedPanel::Completed => {} @@ -251,6 +420,10 @@ impl Dashboard { pub fn select_prev(&mut self) { match self.focused { + FocusedPanel::Status => { + let snapshot = self.build_status_snapshot(); + self.status_panel.select_prev(&snapshot); + } FocusedPanel::Queue => { let len = self.queue_panel.tickets.len(); if len > 0 { @@ -264,37 +437,33 @@ impl Dashboard { self.queue_panel.state.select(Some(i)); } } - FocusedPanel::Agents => { - // Include orphan sessions in total count - let len = self.agents_panel.total_items(); + FocusedPanel::InProgress => { + let len = self.in_progress_panel.total_items(); if len > 0 { - let i = self.agents_panel.state.selected().map_or(0, |i| { + let i = self.in_progress_panel.state.selected().map_or(0, |i| { if i == 0 { len - 1 } else { i - 1 } }); - self.agents_panel.state.select(Some(i)); - } - } - FocusedPanel::Awaiting => { - let len = self.awaiting_panel.agents.len(); - if len > 0 { - let i = self.awaiting_panel.state.selected().map_or(0, |i| { - if i == 0 { - len - 1 - } else { - i - 1 - } - }); - self.awaiting_panel.state.select(Some(i)); + self.in_progress_panel.state.select(Some(i)); } } FocusedPanel::Completed => {} } } + /// Get the action for the currently selected status panel row. + /// Section toggles are handled internally by the status panel. + pub fn status_action( + &mut self, + button: super::status_panel::ActionButton, + ) -> super::status_panel::StatusAction { + let snapshot = self.build_status_snapshot(); + self.status_panel.action_for_current(&snapshot, button) + } + pub fn selected_ticket(&self) -> Option<&Ticket> { if self.focused == FocusedPanel::Queue { self.queue_panel @@ -308,39 +477,18 @@ impl Dashboard { pub fn selected_agent(&self) -> Option<&AgentState> { match self.focused { - FocusedPanel::Agents => self - .agents_panel + FocusedPanel::InProgress => self + .in_progress_panel .state .selected() - .and_then(|i| self.agents_panel.agents.get(i)), - FocusedPanel::Awaiting => self - .awaiting_panel - .state - .selected() - .and_then(|i| self.awaiting_panel.agents.get(i)), + .and_then(|i| self.in_progress_panel.agents.get(i)), _ => None, } } - /// Get the selected running agent (from agents panel) - pub fn selected_running_agent(&self) -> Option<&AgentState> { - self.agents_panel - .state - .selected() - .and_then(|i| self.agents_panel.agents.get(i)) - } - - /// Get the selected awaiting agent (from awaiting panel) - pub fn selected_awaiting_agent(&self) -> Option<&AgentState> { - self.awaiting_panel - .state - .selected() - .and_then(|i| self.awaiting_panel.agents.get(i)) - } - - /// Get the selected orphan session (from agents panel, below the fold) + /// Get the selected orphan session (from `in_progress` panel, below the fold) pub fn selected_orphan(&self) -> Option<&OrphanSession> { - self.agents_panel.selected_orphan() + self.in_progress_panel.selected_orphan() } } @@ -416,4 +564,43 @@ mod tests { ); } } + + #[test] + fn test_focus_next_cycles_through_all_panels() { + let mut dashboard = make_test_dashboard(); + dashboard.focused = FocusedPanel::Status; + + dashboard.focus_next(); + assert_eq!(dashboard.focused, FocusedPanel::Queue); + dashboard.focus_next(); + assert_eq!(dashboard.focused, FocusedPanel::InProgress); + dashboard.focus_next(); + assert_eq!(dashboard.focused, FocusedPanel::Completed); + dashboard.focus_next(); + assert_eq!(dashboard.focused, FocusedPanel::Status); + } + + #[test] + fn test_focus_prev_cycles_through_all_panels() { + let mut dashboard = make_test_dashboard(); + dashboard.focused = FocusedPanel::Status; + + dashboard.focus_prev(); + assert_eq!(dashboard.focused, FocusedPanel::Completed); + dashboard.focus_prev(); + assert_eq!(dashboard.focused, FocusedPanel::InProgress); + dashboard.focus_prev(); + assert_eq!(dashboard.focused, FocusedPanel::Queue); + dashboard.focus_prev(); + assert_eq!(dashboard.focused, FocusedPanel::Status); + } + + #[test] + fn test_update_agents_no_partition() { + let mut dashboard = make_test_dashboard(); + // All agents should go to in_progress_panel without splitting + let agents = vec![]; + dashboard.update_agents(agents); + assert!(dashboard.in_progress_panel.agents.is_empty()); + } } diff --git a/src/ui/dialogs/git_token.rs b/src/ui/dialogs/git_token.rs new file mode 100644 index 0000000..836538c --- /dev/null +++ b/src/ui/dialogs/git_token.rs @@ -0,0 +1,344 @@ +//! Git token input dialog with masked display. + +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, + Frame, +}; + +use super::centered_rect; + +/// Dialog for collecting a git personal access token with masked input. +pub struct GitTokenDialog { + pub visible: bool, + /// Lowercase provider key ("github" or "gitlab"). + pub provider: String, + /// Display name ("GitHub" or "GitLab"). + pub provider_display: String, + /// PAT creation URL (opened in browser before dialog is shown). + pub pat_url: String, + /// Placeholder text for the input field. + pub placeholder: String, + /// Inline error message (shown below input on validation failure). + pub error: Option, + token: String, + cursor_position: usize, +} + +impl GitTokenDialog { + pub fn new() -> Self { + Self { + visible: false, + provider: String::new(), + provider_display: String::new(), + pat_url: String::new(), + placeholder: String::new(), + error: None, + token: String::new(), + cursor_position: 0, + } + } + + /// Show the dialog for a specific provider. + pub fn show( + &mut self, + provider: &str, + provider_display: &str, + pat_url: &str, + placeholder: &str, + ) { + self.provider = provider.to_string(); + self.provider_display = provider_display.to_string(); + self.pat_url = pat_url.to_string(); + self.placeholder = placeholder.to_string(); + self.token.clear(); + self.cursor_position = 0; + self.error = None; + self.visible = true; + } + + pub fn hide(&mut self) { + self.visible = false; + self.token.clear(); + self.cursor_position = 0; + self.error = None; + } + + /// Get the current token value. + pub fn token(&self) -> &str { + &self.token + } + + /// Set an inline error message. + pub fn set_error(&mut self, msg: &str) { + self.error = Some(msg.to_string()); + } + + pub fn handle_char(&mut self, c: char) { + self.token.insert(self.cursor_position, c); + self.cursor_position += 1; + self.error = None; // clear error on new input + } + + pub fn handle_backspace(&mut self) { + if self.cursor_position > 0 { + self.cursor_position -= 1; + self.token.remove(self.cursor_position); + self.error = None; + } + } + + pub fn handle_delete(&mut self) { + if self.cursor_position < self.token.len() { + self.token.remove(self.cursor_position); + self.error = None; + } + } + + pub fn cursor_left(&mut self) { + if self.cursor_position > 0 { + self.cursor_position -= 1; + } + } + + pub fn cursor_right(&mut self) { + if self.cursor_position < self.token.len() { + self.cursor_position += 1; + } + } + + pub fn cursor_home(&mut self) { + self.cursor_position = 0; + } + + pub fn cursor_end(&mut self) { + self.cursor_position = self.token.len(); + } + + pub fn render(&self, frame: &mut Frame) { + if !self.visible { + return; + } + + let area = centered_rect(60, 40, frame.area()); + frame.render_widget(Clear, area); + + let title = format!(" {} Authentication ", self.provider_display); + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + let has_error = self.error.is_some(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(if has_error { + vec![ + Constraint::Length(2), // Prompt + Constraint::Length(3), // Input + Constraint::Length(2), // Error + Constraint::Min(0), // Spacer + Constraint::Length(2), // Instructions + ] + } else { + vec![ + Constraint::Length(2), // Prompt + Constraint::Length(3), // Input + Constraint::Min(0), // Spacer + Constraint::Length(2), // Instructions + Constraint::Length(0), // Unused + ] + }) + .margin(1) + .split(inner); + + // Prompt + let prompt = Line::from(vec![Span::styled( + format!( + "Enter your {} Personal Access Token:", + self.provider_display + ), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )]); + frame.render_widget(Paragraph::new(prompt), chunks[0]); + + // Masked input + let display_text = if self.token.is_empty() { + Span::styled(&self.placeholder, Style::default().fg(Color::DarkGray)) + } else { + let masked: String = "•".repeat(self.token.len()); + Span::styled(masked, Style::default().fg(Color::White)) + }; + + let input = Paragraph::new(display_text) + .block(Block::default().borders(Borders::ALL).border_style( + Style::default().fg(if has_error { Color::Red } else { Color::Cyan }), + )) + .wrap(Wrap { trim: false }); + frame.render_widget(input, chunks[1]); + + // Cursor + let input_inner = Block::default().borders(Borders::ALL).inner(chunks[1]); + frame.set_cursor_position((input_inner.x + self.cursor_position as u16, input_inner.y)); + + // Error message (if present) + if has_error { + let error_text = Line::from(vec![Span::styled( + self.error.as_deref().unwrap_or(""), + Style::default().fg(Color::Red), + )]); + frame.render_widget(Paragraph::new(error_text), chunks[2]); + } + + // Instructions (last non-empty chunk) + let instructions_idx = if has_error { 4 } else { 3 }; + let instructions = Line::from(vec![ + Span::styled("Enter", Style::default().fg(Color::Yellow)), + Span::raw(" to submit "), + Span::styled("Esc", Style::default().fg(Color::Yellow)), + Span::raw(" to cancel"), + ]); + frame.render_widget( + Paragraph::new(instructions).alignment(Alignment::Center), + chunks[instructions_idx], + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_git_token_dialog_new_is_hidden() { + let dialog = GitTokenDialog::new(); + assert!(!dialog.visible); + assert!(dialog.token().is_empty()); + assert_eq!(dialog.cursor_position, 0); + assert!(dialog.error.is_none()); + } + + #[test] + fn test_git_token_dialog_show_and_hide() { + let mut dialog = GitTokenDialog::new(); + + dialog.show("github", "GitHub", "https://example.com/pat", "ghp_..."); + assert!(dialog.visible); + assert_eq!(dialog.provider, "github"); + assert_eq!(dialog.provider_display, "GitHub"); + assert_eq!(dialog.pat_url, "https://example.com/pat"); + assert_eq!(dialog.placeholder, "ghp_..."); + + dialog.handle_char('t'); + dialog.handle_char('o'); + dialog.handle_char('k'); + assert_eq!(dialog.token(), "tok"); + + dialog.hide(); + assert!(!dialog.visible); + assert!(dialog.token().is_empty()); + assert!(dialog.error.is_none()); + } + + #[test] + fn test_git_token_dialog_char_input() { + let mut dialog = GitTokenDialog::new(); + dialog.show("github", "GitHub", "", ""); + + dialog.handle_char('g'); + dialog.handle_char('h'); + dialog.handle_char('p'); + + assert_eq!(dialog.token(), "ghp"); + assert_eq!(dialog.cursor_position, 3); + } + + #[test] + fn test_git_token_dialog_backspace() { + let mut dialog = GitTokenDialog::new(); + dialog.show("github", "GitHub", "", ""); + + dialog.handle_char('a'); + dialog.handle_char('b'); + dialog.handle_backspace(); + + assert_eq!(dialog.token(), "a"); + assert_eq!(dialog.cursor_position, 1); + } + + #[test] + fn test_git_token_dialog_backspace_at_start() { + let mut dialog = GitTokenDialog::new(); + dialog.show("github", "GitHub", "", ""); + + dialog.handle_backspace(); + assert!(dialog.token().is_empty()); + assert_eq!(dialog.cursor_position, 0); + } + + #[test] + fn test_git_token_dialog_cursor_movement() { + let mut dialog = GitTokenDialog::new(); + dialog.show("github", "GitHub", "", ""); + dialog.handle_char('a'); + dialog.handle_char('b'); + dialog.handle_char('c'); + + dialog.cursor_left(); + assert_eq!(dialog.cursor_position, 2); + + dialog.cursor_right(); + assert_eq!(dialog.cursor_position, 3); + + dialog.cursor_home(); + assert_eq!(dialog.cursor_position, 0); + + dialog.cursor_end(); + assert_eq!(dialog.cursor_position, 3); + } + + #[test] + fn test_git_token_dialog_delete() { + let mut dialog = GitTokenDialog::new(); + dialog.show("github", "GitHub", "", ""); + dialog.handle_char('a'); + dialog.handle_char('b'); + dialog.handle_char('c'); + dialog.cursor_home(); + dialog.handle_delete(); + + assert_eq!(dialog.token(), "bc"); + } + + #[test] + fn test_git_token_dialog_error_clears_on_input() { + let mut dialog = GitTokenDialog::new(); + dialog.show("github", "GitHub", "", ""); + + dialog.set_error("Validation failed"); + assert!(dialog.error.is_some()); + + dialog.handle_char('x'); + assert!(dialog.error.is_none()); + } + + #[test] + fn test_git_token_dialog_token_getter() { + let mut dialog = GitTokenDialog::new(); + dialog.show("github", "GitHub", "", ""); + dialog.handle_char('t'); + dialog.handle_char('o'); + dialog.handle_char('k'); + assert_eq!(dialog.token(), "tok"); + + dialog.hide(); + assert!(dialog.token().is_empty()); + } +} diff --git a/src/ui/dialogs/help.rs b/src/ui/dialogs/help.rs index 706905e..0ae9af3 100644 --- a/src/ui/dialogs/help.rs +++ b/src/ui/dialogs/help.rs @@ -63,6 +63,27 @@ impl HelpDialog { } } + // Add Status Panel section + help_text.push(Line::from("")); + help_text.push(Line::from(Span::styled( + "In Status Panel:", + Style::default() + .add_modifier(Modifier::BOLD) + .fg(Color::Cyan), + ))); + + for (_, shortcuts) in shortcuts_by_category_for_context(ShortcutContext::StatusPanel) { + for shortcut in shortcuts { + help_text.push(Line::from(vec![ + Span::styled( + shortcut.key_display_padded(), + Style::default().fg(Color::Yellow), + ), + Span::raw(shortcut.description), + ])); + } + } + // Add Launch Dialog section help_text.push(Line::from("")); help_text.push(Line::from(Span::styled( diff --git a/src/ui/dialogs/mod.rs b/src/ui/dialogs/mod.rs index 71860ba..093ed4f 100644 --- a/src/ui/dialogs/mod.rs +++ b/src/ui/dialogs/mod.rs @@ -1,4 +1,5 @@ mod confirm; +mod git_token; mod help; mod rejection; mod session_recovery; @@ -7,6 +8,7 @@ mod sync_confirm; pub use confirm::{ ConfirmDialog, ConfirmDialogFocus, ConfirmSelection, SelectedOption, SessionPlacementPreview, }; +pub use git_token::GitTokenDialog; pub use help::HelpDialog; pub use rejection::{RejectionDialog, RejectionResult}; pub use session_recovery::{SessionRecoveryDialog, SessionRecoverySelection}; diff --git a/src/ui/in_progress_panel.rs b/src/ui/in_progress_panel.rs new file mode 100644 index 0000000..ea3e513 --- /dev/null +++ b/src/ui/in_progress_panel.rs @@ -0,0 +1,426 @@ +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState}, + Frame, +}; + +use crate::state::{AgentState, OrphanSession}; +use crate::ui::panels::format_display_id; + +pub struct InProgressPanel { + pub agents: Vec, + pub orphan_sessions: Vec, + pub state: ListState, + pub title: String, +} + +impl InProgressPanel { + pub fn new(title: String) -> Self { + Self { + agents: Vec::new(), + orphan_sessions: Vec::new(), + state: ListState::default(), + title, + } + } + + pub fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, max_agents: usize) { + let has_awaiting = self.agents.iter().any(|a| a.status == "awaiting_input"); + + let border_style = if focused { + Style::default().fg(Color::Cyan) + } else if has_awaiting { + // Strobe effect: 6-second cycle with pulse for first 500ms + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + + let cycle_position = now % 6000; + + if cycle_position < 500 { + // Pulse ON - bright orange + Style::default().fg(Color::Rgb(255, 165, 0)) + } else if cycle_position < 1000 { + // Fade out from orange to gray + let progress = (cycle_position - 500) as f32 / 500.0; + let r = (255.0 - progress * 127.0) as u8; // 255 -> 128 + let g = (165.0 - progress * 83.0) as u8; // 165 -> 82 + let b = (progress * 82.0) as u8; // 0 -> 82 + Style::default().fg(Color::Rgb(r, g, b)) + } else { + Style::default().fg(Color::Gray) + } + } else { + Style::default().fg(Color::Gray) + }; + + let mut items: Vec = self + .agents + .iter() + .map(|a| { + // Check review state first for awaiting_input agents + let (status_icon, status_color) = if a.status == "awaiting_input" { + match a.review_state.as_deref() { + Some("pending_plan") => ("\u{1f4cb}", Color::Yellow), // 📋 Plan review + Some("pending_visual") => ("\u{1f441}", Color::Magenta), // 👁 Visual review + Some("pending_pr_creation") => ("\u{1f504}", Color::Blue), // 🔄 Creating PR + Some("pending_pr_merge") => ("\u{1f517}", Color::Cyan), // 🔗 Awaiting merge + _ => ("⏸", Color::Yellow), // Standard awaiting + } + } else { + match a.status.as_str() { + "running" => ("▶", Color::Green), + "completing" => ("✓", Color::Cyan), + _ => ("?", Color::Gray), + } + }; + + // Tool indicator (A=Anthropic/Claude, G=Gemini, O=OpenAI/Codex) + let tool_indicator = match a.llm_tool.as_deref() { + Some("claude") => ("A", Color::Rgb(193, 95, 60)), + Some("gemini") => ("G", Color::Rgb(111, 66, 193)), + Some("codex") => ("O", Color::Green), + _ => (" ", Color::Reset), + }; + + // Check launch mode for docker and yolo + let is_docker = a.launch_mode.as_ref().is_some_and(|m| m.contains("docker")); + let is_yolo = a.launch_mode.as_ref().is_some_and(|m| m.contains("yolo")); + + // YOLO indicator with rainbow animation (6-second cycle: R -> G -> B) + let yolo_indicator = if is_yolo { + let phase = (chrono::Utc::now().timestamp() / 2) % 3; + let color = match phase { + 0 => Color::Red, + 1 => Color::Green, + _ => Color::Blue, + }; + ("Y", color) + } else { + (" ", Color::Reset) + }; + + // Docker indicator (D on gray background) + let docker_indicator = if is_docker { + ("D", Color::White) + } else { + (" ", Color::Reset) + }; + let docker_bg = if is_docker { + Color::DarkGray + } else { + Color::Reset + }; + + // Wrapper badge: C=cmux, T=tmux, Z=zellij, V=vscode + let wrapper_badge = match a.session_wrapper.as_deref() { + Some("cmux") => "C", + Some("tmux") => "T", + Some("zellij") => "Z", + Some("vscode") => "V", + _ => " ", + }; + + // Get the current step display text + let step_display = a + .current_step + .as_ref() + .map(|s| format!("[{s}]")) + .unwrap_or_default(); + + // Calculate elapsed time + let elapsed = chrono::Utc::now() + .signed_duration_since(a.started_at) + .num_seconds(); + let elapsed_display = if elapsed >= 3600 { + format!("{}h{}m", elapsed / 3600, (elapsed % 3600) / 60) + } else if elapsed >= 60 { + format!("{}m", elapsed / 60) + } else { + format!("{elapsed}s") + }; + + // Build the first line with tool indicators + let mut line1_spans = vec![Span::styled( + tool_indicator.0, + Style::default().fg(tool_indicator.1), + )]; + + // Add YOLO indicator (with or without docker background) + if is_yolo { + line1_spans.push(Span::styled( + yolo_indicator.0, + Style::default().fg(yolo_indicator.1).bg(docker_bg), + )); + } else if is_docker { + // Docker without YOLO - show D + line1_spans.push(Span::styled( + docker_indicator.0, + Style::default().fg(docker_indicator.1).bg(docker_bg), + )); + } + + // Wrapper badge + line1_spans.push(Span::styled( + wrapper_badge, + Style::default().fg(Color::DarkGray), + )); + + line1_spans.extend(vec![ + Span::styled(status_icon, Style::default().fg(status_color)), + Span::raw(" "), + Span::styled(&a.project, Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" "), + Span::styled(step_display, Style::default().fg(Color::Cyan)), + ]); + + // Build line 2: ticket ID, elapsed, and cmux refs if applicable + let mut line2_spans = vec![ + Span::raw(" "), + Span::styled( + format_display_id(&a.ticket_id), + Style::default().fg(Color::Gray), + ), + Span::raw(" "), + Span::styled(elapsed_display, Style::default().fg(Color::DarkGray)), + ]; + + // Add cmux workspace/window refs (abbreviated to first 6 chars) + if a.session_wrapper.as_deref() == Some("cmux") { + if let Some(ref ws_ref) = a.session_context_ref { + let abbrev = &ws_ref[..ws_ref.len().min(6)]; + line2_spans.push(Span::styled( + format!(" ws:{abbrev}"), + Style::default().fg(Color::DarkGray), + )); + } + if let Some(ref win_ref) = a.session_window_ref { + let abbrev = &win_ref[..win_ref.len().min(6)]; + line2_spans.push(Span::styled( + format!(" win:{abbrev}"), + Style::default().fg(Color::DarkGray), + )); + } + } + + let mut lines = vec![Line::from(line1_spans), Line::from(line2_spans)]; + + // Add review hint line for agents awaiting review + if a.status == "awaiting_input" { + let hint = match a.review_state.as_deref() { + Some("pending_plan") => Some("[a]pprove [r]eject plan"), + Some("pending_visual") => Some("[a]pprove [r]eject visual"), + Some("pending_pr_creation") => Some("Creating PR..."), + Some("pending_pr_merge") => { + if a.pr_url.is_some() { + None + } else { + Some("Waiting for PR merge") + } + } + _ => None, + }; + + if let Some(hint_text) = hint { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + hint_text, + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + ), + ])); + } + } + + ListItem::new(lines) + }) + .collect(); + + // Add orphan sessions below a fold separator if any exist + if !self.orphan_sessions.is_empty() { + // Add separator line + items.push(ListItem::new(Line::from(vec![Span::styled( + "── Orphan Sessions ──", + Style::default().fg(Color::DarkGray), + )]))); + + // Add each orphan session + for orphan in &self.orphan_sessions { + let mut spans = vec![ + Span::styled("⚠ ", Style::default().fg(Color::Red)), + Span::styled( + &orphan.session_name, + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + ), + ]; + + if orphan.attached { + spans.push(Span::styled( + " [attached]", + Style::default().fg(Color::Yellow), + )); + } + + items.push(ListItem::new(Line::from(spans))); + } + } + + let title = format!("{} ({}/{})", self.title, self.agents.len(), max_agents); + let list = List::new(items) + .block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(border_style), + ) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + + frame.render_stateful_widget(list, area, &mut self.state); + } + + /// Get the total number of items (agents + separator + orphans) for selection + pub fn total_items(&self) -> usize { + let orphan_items = if self.orphan_sessions.is_empty() { + 0 + } else { + 1 + self.orphan_sessions.len() // separator + orphans + }; + self.agents.len() + orphan_items + } + + /// Get the selected orphan session, if any + pub fn selected_orphan(&self) -> Option<&OrphanSession> { + if let Some(selected) = self.state.selected() { + if selected > self.agents.len() && !self.orphan_sessions.is_empty() { + // selected - agents.len() - 1 (for separator) = orphan index + let orphan_idx = selected - self.agents.len() - 1; + return self.orphan_sessions.get(orphan_idx); + } + } + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + + fn make_agent(id: &str, status: &str) -> AgentState { + AgentState { + id: id.to_string(), + ticket_id: format!("FEAT-{id}"), + ticket_type: "FEAT".to_string(), + project: "test-project".to_string(), + status: status.to_string(), + started_at: Utc::now(), + last_activity: Utc::now(), + last_message: None, + paired: false, + session_name: None, + session_wrapper: None, + session_window_ref: None, + session_context_ref: None, + session_pane_ref: None, + content_hash: None, + current_step: None, + step_started_at: None, + last_content_change: None, + pr_url: None, + pr_number: None, + github_repo: None, + pr_status: None, + completed_steps: Vec::new(), + llm_tool: None, + llm_model: None, + launch_mode: None, + review_state: None, + dev_server_pid: None, + worktree_path: None, + } + } + + fn make_orphan(name: &str, attached: bool) -> OrphanSession { + OrphanSession { + session_name: name.to_string(), + created: None, + attached, + } + } + + #[test] + fn test_new_creates_empty_panel() { + let panel = InProgressPanel::new("In Progress".to_string()); + assert!(panel.agents.is_empty()); + assert!(panel.orphan_sessions.is_empty()); + assert_eq!(panel.title, "In Progress"); + assert_eq!(panel.state.selected(), None); + } + + #[test] + fn test_total_items_agents_only() { + let mut panel = InProgressPanel::new("In Progress".to_string()); + panel.agents = vec![ + make_agent("1", "running"), + make_agent("2", "running"), + make_agent("3", "awaiting_input"), + ]; + assert_eq!(panel.total_items(), 3); + } + + #[test] + fn test_total_items_with_orphans() { + let mut panel = InProgressPanel::new("In Progress".to_string()); + panel.agents = vec![ + make_agent("1", "running"), + make_agent("2", "running"), + make_agent("3", "awaiting_input"), + ]; + panel.orphan_sessions = vec![make_orphan("op-abc", false), make_orphan("op-def", true)]; + // 3 agents + 1 separator + 2 orphans = 6 + assert_eq!(panel.total_items(), 6); + } + + #[test] + fn test_selected_orphan_returns_none_for_agent_selection() { + let mut panel = InProgressPanel::new("In Progress".to_string()); + panel.agents = vec![make_agent("1", "running"), make_agent("2", "running")]; + panel.orphan_sessions = vec![make_orphan("op-abc", false)]; + panel.state.select(Some(0)); // selecting first agent + assert!(panel.selected_orphan().is_none()); + + panel.state.select(Some(1)); // selecting second agent + assert!(panel.selected_orphan().is_none()); + } + + #[test] + fn test_selected_orphan_returns_orphan_past_separator() { + let mut panel = InProgressPanel::new("In Progress".to_string()); + panel.agents = vec![make_agent("1", "running"), make_agent("2", "running")]; + panel.orphan_sessions = vec![make_orphan("op-abc", false), make_orphan("op-def", true)]; + + // Index 2 = separator (agents.len() == 2), should return None + panel.state.select(Some(2)); + assert!(panel.selected_orphan().is_none()); + + // Index 3 = first orphan (2 agents + 1 separator = index 3) + panel.state.select(Some(3)); + let orphan = panel.selected_orphan(); + assert!(orphan.is_some()); + assert_eq!(orphan.unwrap().session_name, "op-abc"); + + // Index 4 = second orphan + panel.state.select(Some(4)); + let orphan = panel.selected_orphan(); + assert!(orphan.is_some()); + assert_eq!(orphan.unwrap().session_name, "op-def"); + assert!(orphan.unwrap().attached); + } +} diff --git a/src/ui/keybindings.rs b/src/ui/keybindings.rs index 4def662..33090a7 100644 --- a/src/ui/keybindings.rs +++ b/src/ui/keybindings.rs @@ -5,13 +5,15 @@ //! - `HelpDialog` for displaying help text //! - `ShortcutsDocGenerator` for generating documentation -use crossterm::event::KeyCode; +use crossterm::event::{KeyCode, KeyModifiers}; /// A keyboard shortcut definition #[derive(Debug, Clone)] pub struct Shortcut { /// Primary key for this shortcut pub key: KeyCode, + /// Modifier keys required (e.g., Shift, Ctrl) + pub modifiers: KeyModifiers, /// Alternative key (e.g., lowercase variant or arrow key) pub alt_key: Option, /// Human-readable description of what this shortcut does @@ -36,6 +38,8 @@ pub enum ShortcutCategory { pub enum ShortcutContext { /// Active in the main dashboard Global, + /// Active when the status panel is focused + StatusPanel, /// Active in session preview mode Preview, /// Active in the launch confirmation dialog @@ -69,6 +73,7 @@ impl ShortcutContext { pub fn display_name(&self) -> &'static str { match self { ShortcutContext::Global => "Dashboard", + ShortcutContext::StatusPanel => "Status Panel", ShortcutContext::Preview => "Session Preview", ShortcutContext::LaunchDialog => "Launch Dialog", } @@ -78,6 +83,7 @@ impl ShortcutContext { pub fn all() -> &'static [ShortcutContext] { &[ ShortcutContext::Global, + ShortcutContext::StatusPanel, ShortcutContext::Preview, ShortcutContext::LaunchDialog, ] @@ -85,9 +91,19 @@ impl ShortcutContext { } impl Shortcut { - /// Format key for display (e.g., "q", "Tab", "j/↓") + /// Format key for display (e.g., "q", "Tab", "Shift+Enter", "j/↓") pub fn key_display(&self) -> String { - let primary = format_keycode(&self.key); + let mut prefix = String::new(); + if self.modifiers.contains(KeyModifiers::CONTROL) { + prefix.push_str("Ctrl+"); + } + if self.modifiers.contains(KeyModifiers::SHIFT) { + prefix.push_str("Shift+"); + } + if self.modifiers.contains(KeyModifiers::ALT) { + prefix.push_str("Alt+"); + } + let primary = format!("{}{}", prefix, format_keycode(&self.key)); match &self.alt_key { Some(alt) => format!("{}/{}", primary, format_keycode(alt)), None => primary, @@ -129,6 +145,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ // General Shortcut { key: KeyCode::Char('q'), + modifiers: KeyModifiers::NONE, alt_key: None, description: "Quit Operator", category: ShortcutCategory::General, @@ -136,6 +153,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Char('?'), + modifiers: KeyModifiers::NONE, alt_key: None, description: "Toggle help", category: ShortcutCategory::General, @@ -144,6 +162,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ // Navigation Shortcut { key: KeyCode::Tab, + modifiers: KeyModifiers::NONE, alt_key: None, description: "Switch between panels", category: ShortcutCategory::Navigation, @@ -151,6 +170,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Char('j'), + modifiers: KeyModifiers::NONE, alt_key: Some(KeyCode::Down), description: "Move down", category: ShortcutCategory::Navigation, @@ -158,6 +178,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Char('k'), + modifiers: KeyModifiers::NONE, alt_key: Some(KeyCode::Up), description: "Move up", category: ShortcutCategory::Navigation, @@ -165,6 +186,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Char('Q'), + modifiers: KeyModifiers::NONE, alt_key: None, description: "Focus Queue panel", category: ShortcutCategory::Navigation, @@ -172,6 +194,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Char('A'), + modifiers: KeyModifiers::NONE, alt_key: Some(KeyCode::Char('a')), description: "Focus Agents panel", category: ShortcutCategory::Navigation, @@ -179,6 +202,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Char('h'), + modifiers: KeyModifiers::NONE, alt_key: Some(KeyCode::Left), description: "Previous panel", category: ShortcutCategory::Navigation, @@ -186,6 +210,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Char('l'), + modifiers: KeyModifiers::NONE, alt_key: Some(KeyCode::Right), description: "Next panel", category: ShortcutCategory::Navigation, @@ -194,13 +219,23 @@ pub static SHORTCUTS: &[Shortcut] = &[ // Actions Shortcut { key: KeyCode::Enter, + modifiers: KeyModifiers::NONE, alt_key: None, description: "Select / Confirm", category: ShortcutCategory::Actions, context: ShortcutContext::Global, }, + Shortcut { + key: KeyCode::Enter, + modifiers: KeyModifiers::SHIFT, + alt_key: None, + description: "Auto-launch (delegator chain)", + category: ShortcutCategory::Actions, + context: ShortcutContext::Global, + }, Shortcut { key: KeyCode::Esc, + modifiers: KeyModifiers::NONE, alt_key: None, description: "Cancel / Close", category: ShortcutCategory::Actions, @@ -208,6 +243,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Char('L'), + modifiers: KeyModifiers::NONE, alt_key: None, description: "Launch selected ticket", category: ShortcutCategory::Actions, @@ -215,6 +251,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Char('P'), + modifiers: KeyModifiers::NONE, alt_key: Some(KeyCode::Char('p')), description: "Pause queue processing", category: ShortcutCategory::Actions, @@ -222,6 +259,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Char('R'), + modifiers: KeyModifiers::NONE, alt_key: Some(KeyCode::Char('r')), description: "Resume queue processing", category: ShortcutCategory::Actions, @@ -229,6 +267,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Char('S'), + modifiers: KeyModifiers::NONE, alt_key: None, description: "Sync kanban collections", category: ShortcutCategory::Actions, @@ -236,6 +275,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Char('Y'), + modifiers: KeyModifiers::NONE, alt_key: Some(KeyCode::Char('y')), description: "Approve review (agents panel)", category: ShortcutCategory::Actions, @@ -243,6 +283,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Char('X'), + modifiers: KeyModifiers::NONE, alt_key: Some(KeyCode::Char('x')), description: "Reject review (agents panel)", category: ShortcutCategory::Actions, @@ -250,6 +291,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Char('W'), + modifiers: KeyModifiers::NONE, alt_key: Some(KeyCode::Char('w')), description: "Toggle Backstage server", category: ShortcutCategory::Actions, @@ -257,6 +299,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Char('V'), + modifiers: KeyModifiers::NONE, alt_key: Some(KeyCode::Char('v')), description: "Show session preview", category: ShortcutCategory::Actions, @@ -264,6 +307,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Char('F'), + modifiers: KeyModifiers::NONE, alt_key: None, description: "Focus cmux window", category: ShortcutCategory::Actions, @@ -272,6 +316,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ // Dialogs Shortcut { key: KeyCode::Char('C'), + modifiers: KeyModifiers::NONE, alt_key: None, description: "Create new ticket", category: ShortcutCategory::Dialogs, @@ -279,6 +324,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Char('J'), + modifiers: KeyModifiers::NONE, alt_key: None, description: "Open Projects menu", category: ShortcutCategory::Dialogs, @@ -286,6 +332,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Char('T'), + modifiers: KeyModifiers::NONE, alt_key: Some(KeyCode::Char('t')), description: "Switch issue type collection", category: ShortcutCategory::Dialogs, @@ -293,14 +340,49 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Char('K'), + modifiers: KeyModifiers::NONE, alt_key: None, description: "Open Kanban providers view", category: ShortcutCategory::Dialogs, context: ShortcutContext::Global, }, + // === Status Panel Context === + Shortcut { + key: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + alt_key: None, + description: "Activate (A)", + category: ShortcutCategory::Actions, + context: ShortcutContext::StatusPanel, + }, + Shortcut { + key: KeyCode::Esc, + modifiers: KeyModifiers::NONE, + alt_key: Some(KeyCode::Backspace), + description: "Go back (B)", + category: ShortcutCategory::Navigation, + context: ShortcutContext::StatusPanel, + }, + Shortcut { + key: KeyCode::Enter, + modifiers: KeyModifiers::SHIFT, + alt_key: None, + description: "Special action (X) *", + category: ShortcutCategory::Actions, + context: ShortcutContext::StatusPanel, + }, + Shortcut { + key: KeyCode::Enter, + modifiers: KeyModifiers::CONTROL, + alt_key: None, + description: "Refresh (Y) \u{27F3}", + category: ShortcutCategory::Actions, + context: ShortcutContext::StatusPanel, + }, // === Preview Context === Shortcut { key: KeyCode::Char('g'), + modifiers: KeyModifiers::NONE, alt_key: None, description: "Scroll to top", category: ShortcutCategory::Navigation, @@ -308,6 +390,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Char('G'), + modifiers: KeyModifiers::NONE, alt_key: None, description: "Scroll to bottom", category: ShortcutCategory::Navigation, @@ -315,6 +398,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::PageUp, + modifiers: KeyModifiers::NONE, alt_key: None, description: "Page up", category: ShortcutCategory::Navigation, @@ -322,6 +406,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::PageDown, + modifiers: KeyModifiers::NONE, alt_key: None, description: "Page down", category: ShortcutCategory::Navigation, @@ -329,6 +414,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Esc, + modifiers: KeyModifiers::NONE, alt_key: Some(KeyCode::Char('q')), description: "Close preview", category: ShortcutCategory::Actions, @@ -337,6 +423,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ // === Launch Dialog Context === Shortcut { key: KeyCode::Char('L'), + modifiers: KeyModifiers::NONE, alt_key: Some(KeyCode::Char('l')), description: "Launch agent", category: ShortcutCategory::Actions, @@ -344,6 +431,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Char('V'), + modifiers: KeyModifiers::NONE, alt_key: Some(KeyCode::Char('v')), description: "View ticket ($VISUAL or open)", category: ShortcutCategory::Actions, @@ -351,6 +439,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Char('E'), + modifiers: KeyModifiers::NONE, alt_key: Some(KeyCode::Char('e')), description: "Edit ticket ($EDITOR)", category: ShortcutCategory::Actions, @@ -358,6 +447,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Char('N'), + modifiers: KeyModifiers::NONE, alt_key: Some(KeyCode::Char('n')), description: "Cancel", category: ShortcutCategory::Actions, @@ -365,6 +455,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Char('M'), + modifiers: KeyModifiers::NONE, alt_key: Some(KeyCode::Char('m')), description: "Cycle provider/model", category: ShortcutCategory::Actions, @@ -372,6 +463,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Char('D'), + modifiers: KeyModifiers::NONE, alt_key: Some(KeyCode::Char('d')), description: "Toggle Docker mode", category: ShortcutCategory::Actions, @@ -379,6 +471,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ }, Shortcut { key: KeyCode::Char('Y'), + modifiers: KeyModifiers::NONE, alt_key: Some(KeyCode::Char('y')), description: "Toggle Auto-accept (YOLO)", category: ShortcutCategory::Actions, @@ -439,6 +532,7 @@ mod tests { fn test_key_display_single_key() { let shortcut = Shortcut { key: KeyCode::Char('q'), + modifiers: KeyModifiers::NONE, alt_key: None, description: "Test", category: ShortcutCategory::General, @@ -451,6 +545,7 @@ mod tests { fn test_key_display_with_alt() { let shortcut = Shortcut { key: KeyCode::Char('j'), + modifiers: KeyModifiers::NONE, alt_key: Some(KeyCode::Down), description: "Test", category: ShortcutCategory::Navigation, @@ -459,6 +554,29 @@ mod tests { assert_eq!(shortcut.key_display(), "j/↓"); } + #[test] + fn test_key_display_with_modifiers() { + let shortcut = Shortcut { + key: KeyCode::Enter, + modifiers: KeyModifiers::SHIFT, + alt_key: None, + description: "Test", + category: ShortcutCategory::Actions, + context: ShortcutContext::StatusPanel, + }; + assert_eq!(shortcut.key_display(), "Shift+Enter"); + + let shortcut = Shortcut { + key: KeyCode::Enter, + modifiers: KeyModifiers::CONTROL, + alt_key: None, + description: "Test", + category: ShortcutCategory::Actions, + context: ShortcutContext::StatusPanel, + }; + assert_eq!(shortcut.key_display(), "Ctrl+Enter"); + } + #[test] fn test_key_display_special_keys() { assert_eq!(format_keycode(&KeyCode::Enter), "Enter"); @@ -509,6 +627,6 @@ mod tests { #[test] fn test_all_shortcuts_grouped() { let grouped = all_shortcuts_grouped(); - assert_eq!(grouped.len(), 3); // Global, Preview, LaunchDialog + assert_eq!(grouped.len(), 4); // Global, StatusPanel, Preview, LaunchDialog } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 1da0e79..8f346cd 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -5,22 +5,25 @@ pub mod create_dialog; pub mod dashboard; pub mod dialogs; pub mod form_field; +pub mod in_progress_panel; pub mod kanban_view; pub mod keybindings; pub mod paginated_list; mod panels; pub mod projects_dialog; +pub mod sections; pub mod session_preview; pub mod setup; +pub mod status_panel; pub mod terminal_guard; pub mod terminal_suspend; pub use collection_dialog::{CollectionInfo, CollectionSwitchDialog, CollectionSwitchResult}; pub use dashboard::Dashboard; pub use dialogs::{ - ConfirmDialog, ConfirmDialogFocus, ConfirmSelection, RejectionDialog, RejectionResult, - SelectedOption, SessionRecoveryDialog, SessionRecoverySelection, SyncConfirmDialog, - SyncConfirmResult, + ConfirmDialog, ConfirmDialogFocus, ConfirmSelection, GitTokenDialog, RejectionDialog, + RejectionResult, SelectedOption, SessionRecoveryDialog, SessionRecoverySelection, + SyncConfirmDialog, SyncConfirmResult, }; pub use kanban_view::{KanbanView, KanbanViewResult}; pub use paginated_list::{render_paginated_list, PaginatedList}; diff --git a/src/ui/panels.rs b/src/ui/panels.rs index 87c5b7e..e3c9a44 100644 --- a/src/ui/panels.rs +++ b/src/ui/panels.rs @@ -9,7 +9,7 @@ use ratatui::{ use crate::backstage::ServerStatus; use crate::queue::Ticket; use crate::rest::RestApiStatus; -use crate::state::{AgentState, CompletedTicket, OrphanSession}; +use crate::state::CompletedTicket; use crate::templates::{color_for_key, glyph_for_key}; /// Format the ticket ID for display. @@ -98,407 +98,6 @@ impl QueuePanel { } } -pub struct AgentsPanel { - pub agents: Vec, - pub orphan_sessions: Vec, - pub state: ListState, - pub title: String, -} - -impl AgentsPanel { - pub fn new(title: String) -> Self { - Self { - agents: Vec::new(), - orphan_sessions: Vec::new(), - state: ListState::default(), - title, - } - } - - pub fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, max_agents: usize) { - let border_style = if focused { - Style::default().fg(Color::Cyan) - } else { - Style::default().fg(Color::Gray) - }; - - let mut items: Vec = self - .agents - .iter() - .map(|a| { - // Check review state first for awaiting_input agents - let (status_icon, status_color) = if a.status == "awaiting_input" { - match a.review_state.as_deref() { - Some("pending_plan") => ("📋", Color::Yellow), // Plan review - Some("pending_visual") => ("👁", Color::Magenta), // Visual review - Some("pending_pr_creation") => ("🔄", Color::Blue), // Creating PR - Some("pending_pr_merge") => ("🔗", Color::Cyan), // Awaiting merge - _ => ("⏸", Color::Yellow), // Standard awaiting - } - } else { - match a.status.as_str() { - "running" => ("▶", Color::Green), - "completing" => ("✓", Color::Cyan), - _ => ("?", Color::Gray), - } - }; - - // Tool indicator (A=Anthropic/Claude, G=Gemini, O=OpenAI/Codex) - // Colors: Claude=#C15F3C (rust), Gemini=#6F42C1 (purple), Codex=Green - let tool_indicator = match a.llm_tool.as_deref() { - Some("claude") => ("A", Color::Rgb(193, 95, 60)), // #C15F3C - Some("gemini") => ("G", Color::Rgb(111, 66, 193)), // #6F42C1 - Some("codex") => ("O", Color::Green), - _ => (" ", Color::Reset), - }; - - // Check launch mode for docker and yolo - let is_docker = a.launch_mode.as_ref().is_some_and(|m| m.contains("docker")); - let is_yolo = a.launch_mode.as_ref().is_some_and(|m| m.contains("yolo")); - - // YOLO indicator with rainbow animation (6-second cycle: R -> G -> B) - let yolo_indicator = if is_yolo { - // Cycle R -> G -> B every 2 seconds (6 second full cycle) - let phase = (chrono::Utc::now().timestamp() / 2) % 3; - let color = match phase { - 0 => Color::Red, - 1 => Color::Green, - _ => Color::Blue, - }; - ("Y", color) - } else { - (" ", Color::Reset) - }; - - // Docker indicator (D on gray background) - let docker_indicator = if is_docker { - ("D", Color::White) - } else { - (" ", Color::Reset) - }; - let docker_bg = if is_docker { - Color::DarkGray - } else { - Color::Reset - }; - - // Wrapper badge: C=cmux, T=tmux, Z=zellij, V=vscode - let wrapper_badge = match a.session_wrapper.as_deref() { - Some("cmux") => "C", - Some("tmux") => "T", - Some("zellij") => "Z", - Some("vscode") => "V", - _ => " ", - }; - - // Get the current step display text - let step_display = a - .current_step - .as_ref() - .map(|s| format!("[{s}]")) - .unwrap_or_default(); - - // Calculate elapsed time - let elapsed = chrono::Utc::now() - .signed_duration_since(a.started_at) - .num_seconds(); - let elapsed_display = if elapsed >= 3600 { - format!("{}h{}m", elapsed / 3600, (elapsed % 3600) / 60) - } else if elapsed >= 60 { - format!("{}m", elapsed / 60) - } else { - format!("{elapsed}s") - }; - - // Build the first line with tool indicators - let mut line1_spans = vec![Span::styled( - tool_indicator.0, - Style::default().fg(tool_indicator.1), - )]; - - // Add YOLO indicator (with or without docker background) - if is_yolo { - line1_spans.push(Span::styled( - yolo_indicator.0, - Style::default().fg(yolo_indicator.1).bg(docker_bg), - )); - } else if is_docker { - // Docker without YOLO - show D - line1_spans.push(Span::styled( - docker_indicator.0, - Style::default().fg(docker_indicator.1).bg(docker_bg), - )); - } - - // Wrapper badge - line1_spans.push(Span::styled( - wrapper_badge, - Style::default().fg(Color::DarkGray), - )); - - line1_spans.extend(vec![ - Span::styled(status_icon, Style::default().fg(status_color)), - Span::raw(" "), - Span::styled(&a.project, Style::default().add_modifier(Modifier::BOLD)), - Span::raw(" "), - Span::styled(step_display, Style::default().fg(Color::Cyan)), - ]); - - // Build line 2: ticket ID, elapsed, and cmux refs if applicable - let mut line2_spans = vec![ - Span::raw(" "), - Span::styled( - format_display_id(&a.ticket_id), - Style::default().fg(Color::Gray), - ), - Span::raw(" "), - Span::styled(elapsed_display, Style::default().fg(Color::DarkGray)), - ]; - - // Add cmux workspace/window refs (abbreviated to first 6 chars) - if a.session_wrapper.as_deref() == Some("cmux") { - if let Some(ref ws_ref) = a.session_context_ref { - let abbrev = &ws_ref[..ws_ref.len().min(6)]; - line2_spans.push(Span::styled( - format!(" ws:{abbrev}"), - Style::default().fg(Color::DarkGray), - )); - } - if let Some(ref win_ref) = a.session_window_ref { - let abbrev = &win_ref[..win_ref.len().min(6)]; - line2_spans.push(Span::styled( - format!(" win:{abbrev}"), - Style::default().fg(Color::DarkGray), - )); - } - } - - let mut lines = vec![Line::from(line1_spans), Line::from(line2_spans)]; - - // Add review hint line for agents awaiting review - if a.status == "awaiting_input" { - let hint = match a.review_state.as_deref() { - Some("pending_plan") => Some("[a]pprove [r]eject plan"), - Some("pending_visual") => Some("[a]pprove [r]eject visual"), - Some("pending_pr_creation") => Some("Creating PR..."), - Some("pending_pr_merge") => { - if a.pr_url.is_some() { - // PR URL shown elsewhere - None - } else { - Some("Waiting for PR merge") - } - } - _ => None, // No hint for standard awaiting - }; - - if let Some(hint_text) = hint { - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled( - hint_text, - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::ITALIC), - ), - ])); - } - } - - ListItem::new(lines) - }) - .collect(); - - // Add orphan sessions below a fold separator if any exist - if !self.orphan_sessions.is_empty() { - // Add separator line - items.push(ListItem::new(Line::from(vec![Span::styled( - "── Orphan Sessions ──", - Style::default().fg(Color::DarkGray), - )]))); - - // Add each orphan session - for orphan in &self.orphan_sessions { - let mut spans = vec![ - Span::styled("⚠ ", Style::default().fg(Color::Red)), - Span::styled( - &orphan.session_name, - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::ITALIC), - ), - ]; - - if orphan.attached { - spans.push(Span::styled( - " [attached]", - Style::default().fg(Color::Yellow), - )); - } - - items.push(ListItem::new(Line::from(spans))); - } - } - - let title = format!("{} ({}/{})", self.title, self.agents.len(), max_agents); - let list = List::new(items) - .block( - Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(border_style), - ) - .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); - - frame.render_stateful_widget(list, area, &mut self.state); - } - - /// Get the total number of items (agents + separator + orphans) for selection - pub fn total_items(&self) -> usize { - let orphan_items = if self.orphan_sessions.is_empty() { - 0 - } else { - 1 + self.orphan_sessions.len() // separator + orphans - }; - self.agents.len() + orphan_items - } - - /// Get the selected orphan session, if any - pub fn selected_orphan(&self) -> Option<&OrphanSession> { - if let Some(selected) = self.state.selected() { - if selected > self.agents.len() && !self.orphan_sessions.is_empty() { - // selected - agents.len() - 1 (for separator) = orphan index - let orphan_idx = selected - self.agents.len() - 1; - return self.orphan_sessions.get(orphan_idx); - } - } - None - } -} - -pub struct AwaitingPanel { - pub agents: Vec, - pub state: ListState, - pub title: String, -} - -impl AwaitingPanel { - pub fn new(title: String) -> Self { - Self { - agents: Vec::new(), - state: ListState::default(), - title, - } - } - - pub fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool) { - let border_style = if focused { - Style::default().fg(Color::Yellow) - } else if !self.agents.is_empty() { - // Strobe effect: 6-second cycle with pulse for first 500ms - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis(); - - let cycle_position = now % 6000; // 6-second cycle - - if cycle_position < 500 { - // Pulse ON - bright orange - Style::default().fg(Color::Rgb(255, 165, 0)) - } else if cycle_position < 1000 { - // Fade out from orange to gray - let progress = (cycle_position - 500) as f32 / 500.0; - let r = (255.0 - progress * 127.0) as u8; // 255 -> 128 - let g = (165.0 - progress * 83.0) as u8; // 165 -> 82 - let b = (progress * 82.0) as u8; // 0 -> 82 - Style::default().fg(Color::Rgb(r, g, b)) - } else { - Style::default().fg(Color::Gray) - } - } else { - Style::default().fg(Color::Gray) - }; - - let items: Vec = self - .agents - .iter() - .map(|a| { - // Wrapper badge - let wrapper_badge = match a.session_wrapper.as_deref() { - Some("cmux") => "C", - Some("tmux") => "T", - Some("zellij") => "Z", - Some("vscode") => "V", - _ => " ", - }; - - // Get the current step display text - let step_display = a - .current_step - .as_ref() - .map(|s| format!("[{s}]")) - .unwrap_or_default(); - - // Build line 2 with optional cmux refs - let mut line2_spans = vec![ - Span::raw(" "), - Span::styled( - a.last_message.as_deref().unwrap_or("Awaiting input..."), - Style::default() - .fg(Color::White) - .add_modifier(Modifier::ITALIC), - ), - ]; - - // Add cmux refs for cmux agents - if a.session_wrapper.as_deref() == Some("cmux") { - if let Some(ref ws_ref) = a.session_context_ref { - let abbrev = &ws_ref[..ws_ref.len().min(6)]; - line2_spans.push(Span::styled( - format!(" ws:{abbrev}"), - Style::default().fg(Color::DarkGray), - )); - } - } - - let lines = vec![ - Line::from(vec![ - Span::styled( - wrapper_badge.to_string(), - Style::default().fg(Color::DarkGray), - ), - Span::styled("⏸ ", Style::default().fg(Color::Yellow)), - Span::styled(&a.project, Style::default().add_modifier(Modifier::BOLD)), - Span::raw(" "), - Span::styled(step_display, Style::default().fg(Color::Cyan)), - Span::raw(" "), - Span::styled( - format!("[{}]", format_display_id(&a.ticket_id)), - Style::default().fg(Color::Gray), - ), - ]), - Line::from(line2_spans), - ]; - - ListItem::new(lines) - }) - .collect(); - - let title = format!("{} ({})", self.title, self.agents.len()); - let list = List::new(items) - .block( - Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(border_style), - ) - .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); - - frame.render_stateful_widget(list, area, &mut self.state); - } -} - pub struct CompletedPanel { pub tickets: Vec, pub title: String, diff --git a/src/ui/sections/config_section.rs b/src/ui/sections/config_section.rs new file mode 100644 index 0000000..8a848bb --- /dev/null +++ b/src/ui/sections/config_section.rs @@ -0,0 +1,249 @@ +use std::path::Path; + +use crate::ui::status_panel::{ + ActionMeta, ActionSet, SectionHealth, SectionId, StatusAction, StatusIcon, StatusSection, + StatusSnapshot, TreeRow, +}; + +pub struct ConfigSection; + +impl StatusSection for ConfigSection { + fn section_id(&self) -> SectionId { + SectionId::Configuration + } + + fn label(&self) -> &'static str { + "Configuration" + } + + fn prerequisites(&self) -> &[SectionId] { + &[] // Always visible + } + + fn health(&self, snapshot: &StatusSnapshot) -> SectionHealth { + if !snapshot.config_file_found { + return SectionHealth::Red; + } + if !snapshot.tickets_dir_exists || snapshot.working_dir.is_empty() { + return SectionHealth::Yellow; + } + SectionHealth::Green + } + + fn description(&self, snapshot: &StatusSnapshot) -> String { + if !snapshot.config_file_found { + return "Config not found".into(); + } + if !snapshot.tickets_dir_exists { + return "Tickets dir missing".into(); + } + if snapshot.working_dir.is_empty() { + return "Working dir not set".into(); + } + Path::new(&snapshot.working_dir) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| snapshot.working_dir.clone()) + } + + fn children(&self, snapshot: &StatusSnapshot) -> Vec { + vec![ + // Working Dir: primary=open, special=none (must launch from dir), refresh=none + TreeRow { + section_id: SectionId::Configuration, + depth: 1, + label: "Working Dir".into(), + description: if snapshot.working_dir.is_empty() { + "Not set".into() + } else { + Path::new(&snapshot.working_dir) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| snapshot.working_dir.clone()) + }, + icon: if snapshot.working_dir.is_empty() { + StatusIcon::Warning + } else { + StatusIcon::Check + }, + is_header: false, + actions: ActionSet::primary(if snapshot.working_dir.is_empty() { + StatusAction::None + } else { + StatusAction::OpenDirectory(snapshot.working_dir.clone()) + }), + health: SectionHealth::Gray, + }, + // Config: primary=edit, special=reset to defaults, refresh=reload config + TreeRow { + section_id: SectionId::Configuration, + depth: 1, + label: "Config".into(), + description: if snapshot.config_file_found { + snapshot.config_path.clone() + } else { + "Not found".into() + }, + icon: if snapshot.config_file_found { + StatusIcon::Check + } else { + StatusIcon::Cross + }, + is_header: false, + actions: if snapshot.config_file_found { + ActionSet { + primary: StatusAction::EditFile(snapshot.config_path.clone()), + back: StatusAction::None, + special: StatusAction::ResetConfig, + special_meta: Some(ActionMeta { + title: "Reset", + tooltip: + "Reset configuration to factory defaults (requires confirmation)", + }), + refresh: StatusAction::ReloadConfig, + refresh_meta: Some(ActionMeta { + title: "Reload", + tooltip: "Reload configuration from disk and restart", + }), + } + } else { + ActionSet::none() + }, + health: SectionHealth::Gray, + }, + // Tickets: primary=open dir, no special or refresh + TreeRow { + section_id: SectionId::Configuration, + depth: 1, + label: "Tickets".into(), + description: if snapshot.tickets_dir_exists { + snapshot.tickets_dir.clone() + } else { + "Not found".into() + }, + icon: if snapshot.tickets_dir_exists { + StatusIcon::Check + } else { + StatusIcon::Cross + }, + is_header: false, + actions: ActionSet::primary(if snapshot.tickets_dir_exists { + StatusAction::OpenDirectory(snapshot.tickets_dir.clone()) + } else { + StatusAction::None + }), + health: SectionHealth::Gray, + }, + // Wrapper connection status (moved from Connections section) + { + let wrapper = &snapshot.wrapper_connection_status; + TreeRow { + section_id: SectionId::Configuration, + depth: 1, + label: wrapper.label().into(), + description: wrapper.description(), + icon: if wrapper.is_connected() { + StatusIcon::Check + } else { + StatusIcon::Cross + }, + is_header: false, + actions: ActionSet { + primary: if wrapper.is_connected() { + StatusAction::None + } else { + StatusAction::RestartWrapperConnection + }, + back: StatusAction::None, + special: StatusAction::None, + special_meta: None, + refresh: StatusAction::RestartWrapperConnection, + refresh_meta: Some(ActionMeta { + title: "Retry", + tooltip: "Reconnect the session wrapper", + }), + }, + health: SectionHealth::Gray, + } + }, + // Wrapper type: display-only + TreeRow { + section_id: SectionId::Configuration, + depth: 1, + label: "Wrapper".into(), + description: snapshot.wrapper_type.clone(), + icon: StatusIcon::Tool, + is_header: false, + actions: ActionSet::none(), + health: SectionHealth::Gray, + }, + // $EDITOR: display-only + TreeRow { + section_id: SectionId::Configuration, + depth: 1, + label: "$EDITOR".into(), + description: if snapshot.env_editor.is_empty() { + "Not set".into() + } else { + snapshot.env_editor.clone() + }, + icon: if snapshot.env_editor.is_empty() { + StatusIcon::Warning + } else { + StatusIcon::Check + }, + is_header: false, + actions: ActionSet::none(), + health: SectionHealth::Gray, + }, + // $VISUAL: display-only + TreeRow { + section_id: SectionId::Configuration, + depth: 1, + label: "$VISUAL".into(), + description: if snapshot.env_visual.is_empty() { + "Not set".into() + } else { + snapshot.env_visual.clone() + }, + icon: if snapshot.env_visual.is_empty() { + StatusIcon::Warning + } else { + StatusIcon::Check + }, + is_header: false, + actions: ActionSet::none(), + health: SectionHealth::Gray, + }, + // Version: primary=open downloads, refresh=check for updates + TreeRow { + section_id: SectionId::Configuration, + depth: 1, + label: "Version".into(), + description: if let Some(ref update) = snapshot.update_available_version { + format!("{} → {} available", snapshot.operator_version, update) + } else { + snapshot.operator_version.clone() + }, + icon: if snapshot.update_available_version.is_some() { + StatusIcon::Warning + } else { + StatusIcon::None + }, + is_header: false, + actions: ActionSet { + primary: StatusAction::OpenUrl("https://operator.untra.io/downloads/".into()), + back: StatusAction::None, + special: StatusAction::None, + special_meta: None, + refresh: StatusAction::RefreshSection(SectionId::Configuration), + refresh_meta: Some(ActionMeta { + title: "Check", + tooltip: "Check for new operator versions", + }), + }, + health: SectionHealth::Gray, + }, + ] + } +} diff --git a/src/ui/sections/connections_section.rs b/src/ui/sections/connections_section.rs new file mode 100644 index 0000000..7835d7e --- /dev/null +++ b/src/ui/sections/connections_section.rs @@ -0,0 +1,252 @@ +use crate::backstage::ServerStatus; +use crate::rest::RestApiStatus; +use crate::ui::status_panel::{ + ActionMeta, ActionSet, SectionHealth, SectionId, StatusAction, StatusIcon, StatusSection, + StatusSnapshot, TreeRow, WrapperConnectionStatus, +}; + +pub struct ConnectionsSection; + +impl StatusSection for ConnectionsSection { + fn section_id(&self) -> SectionId { + SectionId::Connections + } + + fn label(&self) -> &'static str { + "Connections" + } + + fn prerequisites(&self) -> &[SectionId] { + &[SectionId::Configuration] + } + + fn health(&self, snapshot: &StatusSnapshot) -> SectionHealth { + let api_ok = matches!(snapshot.api_status, RestApiStatus::Running { .. }); + let api_starting = matches!(snapshot.api_status, RestApiStatus::Starting); + let wrapper_ok = snapshot.wrapper_connection_status.is_connected(); + + // When backstage is hidden, health is based on API + wrapper only + if !snapshot.backstage_display { + return match (api_ok, wrapper_ok) { + (true, true) => SectionHealth::Green, + _ if api_starting => SectionHealth::Yellow, + (true, false) | (false, true) => SectionHealth::Yellow, + (false, false) => SectionHealth::Red, + }; + } + + // When backstage is displayed, include it in health + let bs_ok = matches!(snapshot.backstage_status, ServerStatus::Running { .. }); + let bs_starting = matches!(snapshot.backstage_status, ServerStatus::Starting); + let all_ok = api_ok && bs_ok && wrapper_ok; + let any_starting = api_starting || bs_starting; + + if all_ok { + SectionHealth::Green + } else if any_starting || api_ok || bs_ok || wrapper_ok { + SectionHealth::Yellow + } else { + SectionHealth::Red + } + } + + fn description(&self, snapshot: &StatusSnapshot) -> String { + let api_ok = matches!(snapshot.api_status, RestApiStatus::Running { .. }); + let api_starting = matches!(snapshot.api_status, RestApiStatus::Starting); + let wrapper_ok = snapshot.wrapper_connection_status.is_connected(); + + if api_starting { + return "Starting...".into(); + } + if api_ok && wrapper_ok { + return "Connected".into(); + } + if !api_ok && !wrapper_ok { + return "Disconnected".into(); + } + "Partial".into() + } + + fn children(&self, snapshot: &StatusSnapshot) -> Vec { + let mut rows = vec![ + // 1. Operator API + TreeRow { + section_id: SectionId::Connections, + depth: 1, + label: "Operator API".into(), + description: match &snapshot.api_status { + RestApiStatus::Running { port } => format!(":{port}"), + RestApiStatus::Starting => "Starting...".into(), + RestApiStatus::Stopping => "Stopping...".into(), + RestApiStatus::Stopped => "Stopped".into(), + RestApiStatus::Error(e) => format!("Error: {e}"), + }, + icon: match &snapshot.api_status { + RestApiStatus::Running { .. } => StatusIcon::Check, + RestApiStatus::Starting => StatusIcon::Warning, + _ => StatusIcon::Cross, + }, + is_header: false, + actions: ActionSet { + primary: match &snapshot.api_status { + RestApiStatus::Running { port } => { + StatusAction::OpenSwagger { port: *port } + } + RestApiStatus::Stopped | RestApiStatus::Error(_) => StatusAction::StartApi, + _ => StatusAction::None, + }, + back: StatusAction::None, + special: match &snapshot.api_status { + RestApiStatus::Running { port } => { + StatusAction::OpenSwagger { port: *port } + } + _ => StatusAction::None, + }, + special_meta: Some(ActionMeta { + title: "Docs", + tooltip: "Open Swagger API documentation", + }), + refresh: StatusAction::StartApi, + refresh_meta: Some(ActionMeta { + title: "Start", + tooltip: "Start or restart the Operator API server", + }), + }, + health: SectionHealth::Gray, + }, + ]; + + // 2. Backstage (conditionally displayed) + if snapshot.backstage_display { + rows.push(TreeRow { + section_id: SectionId::Connections, + depth: 1, + label: "Backstage".into(), + description: format!("{:?}", snapshot.backstage_status), + icon: if matches!(snapshot.backstage_status, ServerStatus::Running { .. }) { + StatusIcon::Check + } else { + StatusIcon::Cross + }, + is_header: false, + actions: ActionSet::primary(StatusAction::ToggleWebServers), + health: SectionHealth::Gray, + }); + } + + rows + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::status_panel::{DelegatorInfo, KanbanProviderInfo, LlmToolInfo}; + + fn base_snapshot() -> StatusSnapshot { + StatusSnapshot { + working_dir: "/test".into(), + config_file_found: true, + config_path: "operator.toml".into(), + tickets_dir: ".tickets".into(), + tickets_dir_exists: true, + wrapper_type: "tmux".into(), + operator_version: "0.1.28".into(), + api_status: RestApiStatus::Running { port: 7008 }, + backstage_status: ServerStatus::Stopped, + backstage_display: false, + kanban_providers: vec![], + llm_tools: vec![], + default_llm_tool: None, + default_llm_model: None, + delegators: vec![], + git_provider: None, + git_token_set: false, + git_branch_format: None, + git_use_worktrees: false, + update_available_version: None, + wrapper_connection_status: WrapperConnectionStatus::Tmux { + available: true, + server_running: true, + version: Some("tmux 3.4".into()), + }, + env_editor: "vim".into(), + env_visual: String::new(), + } + } + + #[test] + fn test_connections_tmux_connected_green_health() { + let section = ConnectionsSection; + let snap = base_snapshot(); + // API running + tmux connected = Green + assert_eq!(section.health(&snap), SectionHealth::Green); + } + + #[test] + fn test_connections_startup_grace_yellow_not_red() { + let section = ConnectionsSection; + let mut snap = base_snapshot(); + snap.api_status = RestApiStatus::Starting; + // API starting + tmux connected should be Yellow, not Red + assert_eq!(section.health(&snap), SectionHealth::Yellow); + } + + #[test] + fn test_connections_startup_grace_both_down_is_red() { + let section = ConnectionsSection; + let mut snap = base_snapshot(); + snap.api_status = RestApiStatus::Stopped; + snap.wrapper_connection_status = WrapperConnectionStatus::Tmux { + available: false, + server_running: false, + version: None, + }; + assert_eq!(section.health(&snap), SectionHealth::Red); + } + + #[test] + fn test_connections_api_running_opens_swagger() { + let section = ConnectionsSection; + let snap = base_snapshot(); + let children = section.children(&snap); + let api_row = children.iter().find(|r| r.label == "Operator API").unwrap(); + assert_eq!( + api_row.actions.primary, + StatusAction::OpenSwagger { port: 7008 } + ); + } + + #[test] + fn test_connections_api_stopped_starts_api() { + let section = ConnectionsSection; + let mut snap = base_snapshot(); + snap.api_status = RestApiStatus::Stopped; + let children = section.children(&snap); + let api_row = children.iter().find(|r| r.label == "Operator API").unwrap(); + assert_eq!(api_row.actions.primary, StatusAction::StartApi); + } + + #[test] + fn test_connections_backstage_hidden_by_default() { + let section = ConnectionsSection; + let snap = base_snapshot(); + let children = section.children(&snap); + assert!( + !children.iter().any(|r| r.label == "Backstage"), + "Backstage should be hidden when backstage_display is false" + ); + } + + #[test] + fn test_connections_backstage_shown_when_display_true() { + let section = ConnectionsSection; + let mut snap = base_snapshot(); + snap.backstage_display = true; + let children = section.children(&snap); + assert!( + children.iter().any(|r| r.label == "Backstage"), + "Backstage should be shown when backstage_display is true" + ); + } +} diff --git a/src/ui/sections/delegator_section.rs b/src/ui/sections/delegator_section.rs new file mode 100644 index 0000000..1c721be --- /dev/null +++ b/src/ui/sections/delegator_section.rs @@ -0,0 +1,70 @@ +use crate::ui::status_panel::{ + ActionMeta, ActionSet, SectionHealth, SectionId, StatusAction, StatusIcon, StatusSection, + StatusSnapshot, TreeRow, +}; + +pub struct DelegatorSection; + +impl StatusSection for DelegatorSection { + fn section_id(&self) -> SectionId { + SectionId::Delegators + } + + fn label(&self) -> &'static str { + "Delegators" + } + + fn prerequisites(&self) -> &[SectionId] { + &[SectionId::LlmTools] + } + + fn health(&self, snapshot: &StatusSnapshot) -> SectionHealth { + if snapshot.delegators.is_empty() { + SectionHealth::Yellow + } else { + SectionHealth::Green + } + } + + fn description(&self, snapshot: &StatusSnapshot) -> String { + let count = snapshot.delegators.len(); + if count == 0 { + "None configured".into() + } else { + format!("{count} delegator{}", if count == 1 { "" } else { "s" }) + } + } + + fn children(&self, snapshot: &StatusSnapshot) -> Vec { + snapshot + .delegators + .iter() + .map(|d| { + let label = d.display_name.as_deref().unwrap_or(&d.name).to_string(); + let yolo_flag = if d.yolo { " · yolo" } else { "" }; + let description = format!("{}:{}{}", d.llm_tool, d.model, yolo_flag); + + TreeRow { + section_id: SectionId::Delegators, + depth: 1, + label, + description, + icon: StatusIcon::Tool, + is_header: false, + actions: ActionSet { + primary: StatusAction::None, + back: StatusAction::None, + special: StatusAction::EditFile(snapshot.config_path.clone()), + special_meta: Some(ActionMeta { + title: "Config", + tooltip: "Edit delegator configuration", + }), + refresh: StatusAction::None, + refresh_meta: None, + }, + health: SectionHealth::Gray, + } + }) + .collect() + } +} diff --git a/src/ui/sections/git_section.rs b/src/ui/sections/git_section.rs new file mode 100644 index 0000000..be1bab8 --- /dev/null +++ b/src/ui/sections/git_section.rs @@ -0,0 +1,317 @@ +use crate::ui::status_panel::{ + ActionMeta, ActionSet, SectionHealth, SectionId, StatusAction, StatusIcon, StatusSection, + StatusSnapshot, TreeRow, +}; + +pub struct GitSection; + +impl StatusSection for GitSection { + fn section_id(&self) -> SectionId { + SectionId::Git + } + + fn label(&self) -> &'static str { + "Git" + } + + fn prerequisites(&self) -> &[SectionId] { + &[SectionId::Connections] + } + + fn health(&self, snapshot: &StatusSnapshot) -> SectionHealth { + match (&snapshot.git_provider, snapshot.git_token_set) { + (Some(_), true) => SectionHealth::Green, + (Some(_), false) => SectionHealth::Yellow, + (None, _) => SectionHealth::Red, + } + } + + fn description(&self, snapshot: &StatusSnapshot) -> String { + snapshot + .git_provider + .clone() + .unwrap_or_else(|| "Not configured".into()) + } + + fn children(&self, snapshot: &StatusSnapshot) -> Vec { + match &snapshot.git_provider { + None => { + vec![ + TreeRow { + section_id: SectionId::Git, + depth: 1, + label: "Configure GitHub".into(), + description: "Set up GitHub".into(), + icon: StatusIcon::Plug, + is_header: false, + actions: ActionSet::primary(StatusAction::ConfigureGitProvider { + provider: "github".into(), + }), + health: SectionHealth::Gray, + }, + TreeRow { + section_id: SectionId::Git, + depth: 1, + label: "Configure GitLab".into(), + description: "Set up GitLab".into(), + icon: StatusIcon::Plug, + is_header: false, + actions: ActionSet::primary(StatusAction::ConfigureGitProvider { + provider: "gitlab".into(), + }), + health: SectionHealth::Gray, + }, + ] + } + Some(provider) => { + let provider_lower = provider.to_lowercase(); + + let mut rows = vec![ + TreeRow { + section_id: SectionId::Git, + depth: 1, + label: "Provider".into(), + description: provider.clone(), + icon: StatusIcon::Branch, + is_header: false, + actions: ActionSet { + primary: StatusAction::None, + back: StatusAction::None, + special: StatusAction::EditFile(snapshot.config_path.clone()), + special_meta: Some(ActionMeta { + title: "Config", + tooltip: "Edit git provider configuration", + }), + refresh: StatusAction::None, + refresh_meta: None, + }, + health: SectionHealth::Gray, + }, + TreeRow { + section_id: SectionId::Git, + depth: 1, + label: "Token".into(), + description: if snapshot.git_token_set { + "Set".into() + } else { + "Not set".into() + }, + icon: if snapshot.git_token_set { + StatusIcon::Key + } else { + StatusIcon::Warning + }, + is_header: false, + actions: ActionSet::primary(if snapshot.git_token_set { + StatusAction::None + } else { + StatusAction::ConfigureGitProvider { + provider: provider_lower, + } + }), + health: SectionHealth::Gray, + }, + ]; + + if let Some(ref fmt) = snapshot.git_branch_format { + rows.push(TreeRow { + section_id: SectionId::Git, + depth: 1, + label: "Branch Format".into(), + description: fmt.clone(), + icon: StatusIcon::Branch, + is_header: false, + actions: ActionSet::none(), + health: SectionHealth::Gray, + }); + } + + rows.push(TreeRow { + section_id: SectionId::Git, + depth: 1, + label: "Worktrees".into(), + description: if snapshot.git_use_worktrees { + "Enabled".into() + } else { + "Disabled".into() + }, + icon: StatusIcon::Branch, + is_header: false, + actions: ActionSet::none(), + health: SectionHealth::Gray, + }); + + rows + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backstage::ServerStatus; + use crate::rest::RestApiStatus; + use crate::ui::status_panel::{ + DelegatorInfo, KanbanProviderInfo, LlmToolInfo, WrapperConnectionStatus, + }; + + fn base_snapshot() -> StatusSnapshot { + StatusSnapshot { + working_dir: "/test".into(), + config_file_found: true, + config_path: "operator.toml".into(), + tickets_dir: ".tickets".into(), + tickets_dir_exists: true, + wrapper_type: "tmux".into(), + operator_version: "0.1.28".into(), + api_status: RestApiStatus::Running { port: 7008 }, + backstage_status: ServerStatus::Stopped, + backstage_display: false, + kanban_providers: vec![], + llm_tools: vec![], + delegators: vec![], + git_provider: None, + git_token_set: false, + git_branch_format: None, + git_use_worktrees: false, + default_llm_tool: None, + default_llm_model: None, + update_available_version: None, + wrapper_connection_status: WrapperConnectionStatus::Tmux { + available: true, + server_running: true, + version: Some("tmux 3.4".into()), + }, + env_editor: "vim".into(), + env_visual: String::new(), + } + } + + #[test] + fn test_git_health_red_when_no_provider() { + let section = GitSection; + let snap = base_snapshot(); + assert_eq!(section.health(&snap), SectionHealth::Red); + } + + #[test] + fn test_git_health_yellow_when_provider_no_token() { + let section = GitSection; + let mut snap = base_snapshot(); + snap.git_provider = Some("GitHub".into()); + assert_eq!(section.health(&snap), SectionHealth::Yellow); + } + + #[test] + fn test_git_health_green_when_provider_and_token() { + let section = GitSection; + let mut snap = base_snapshot(); + snap.git_provider = Some("GitHub".into()); + snap.git_token_set = true; + assert_eq!(section.health(&snap), SectionHealth::Green); + } + + #[test] + fn test_git_unconfigured_shows_provider_options() { + let section = GitSection; + let snap = base_snapshot(); + let children = section.children(&snap); + + assert_eq!(children.len(), 2); + assert_eq!(children[0].label, "Configure GitHub"); + assert_eq!(children[0].description, "Set up GitHub"); + assert_eq!( + children[0].actions.primary, + StatusAction::ConfigureGitProvider { + provider: "github".into() + } + ); + assert_eq!(children[1].label, "Configure GitLab"); + assert_eq!(children[1].description, "Set up GitLab"); + assert_eq!( + children[1].actions.primary, + StatusAction::ConfigureGitProvider { + provider: "gitlab".into() + } + ); + } + + #[test] + fn test_git_configured_shows_provider_token_worktrees() { + let section = GitSection; + let mut snap = base_snapshot(); + snap.git_provider = Some("GitHub".into()); + snap.git_token_set = true; + let children = section.children(&snap); + + assert_eq!(children[0].label, "Provider"); + assert_eq!(children[0].description, "GitHub"); + assert_eq!(children[1].label, "Token"); + assert_eq!(children[1].description, "Set"); + assert!(matches!(children[1].icon, StatusIcon::Key)); + // Last row is Worktrees (no branch format set) + let last = children.last().unwrap(); + assert_eq!(last.label, "Worktrees"); + } + + #[test] + fn test_git_branch_format_shown_when_set() { + let section = GitSection; + let mut snap = base_snapshot(); + snap.git_provider = Some("GitHub".into()); + snap.git_branch_format = Some("feature/{ticket}".into()); + let children = section.children(&snap); + + let fmt_row = children.iter().find(|r| r.label == "Branch Format"); + assert!(fmt_row.is_some()); + assert_eq!(fmt_row.unwrap().description, "feature/{ticket}"); + } + + #[test] + fn test_git_branch_format_hidden_when_unset() { + let section = GitSection; + let mut snap = base_snapshot(); + snap.git_provider = Some("GitHub".into()); + snap.git_branch_format = None; + let children = section.children(&snap); + + assert!( + !children.iter().any(|r| r.label == "Branch Format"), + "Branch Format row should be hidden when git_branch_format is None" + ); + } + + #[test] + fn test_git_token_clickable_when_not_set() { + let section = GitSection; + let mut snap = base_snapshot(); + snap.git_provider = Some("GitHub".into()); + snap.git_token_set = false; + let children = section.children(&snap); + + let token_row = children.iter().find(|r| r.label == "Token").unwrap(); + assert_eq!(token_row.description, "Not set"); + assert!(matches!(token_row.icon, StatusIcon::Warning)); + assert_eq!( + token_row.actions.primary, + StatusAction::ConfigureGitProvider { + provider: "github".into() + } + ); + } + + #[test] + fn test_git_token_not_clickable_when_set() { + let section = GitSection; + let mut snap = base_snapshot(); + snap.git_provider = Some("GitHub".into()); + snap.git_token_set = true; + let children = section.children(&snap); + + let token_row = children.iter().find(|r| r.label == "Token").unwrap(); + assert_eq!(token_row.description, "Set"); + assert!(matches!(token_row.icon, StatusIcon::Key)); + assert_eq!(token_row.actions.primary, StatusAction::None); + } +} diff --git a/src/ui/sections/kanban_section.rs b/src/ui/sections/kanban_section.rs new file mode 100644 index 0000000..f3c7d02 --- /dev/null +++ b/src/ui/sections/kanban_section.rs @@ -0,0 +1,211 @@ +use crate::ui::status_panel::{ + ActionMeta, ActionSet, SectionHealth, SectionId, StatusAction, StatusIcon, StatusSection, + StatusSnapshot, TreeRow, +}; + +pub struct KanbanSection; + +impl StatusSection for KanbanSection { + fn section_id(&self) -> SectionId { + SectionId::Kanban + } + + fn label(&self) -> &'static str { + "Kanban" + } + + fn prerequisites(&self) -> &[SectionId] { + &[SectionId::Connections] + } + + fn health(&self, snapshot: &StatusSnapshot) -> SectionHealth { + if snapshot.kanban_providers.is_empty() { + SectionHealth::Yellow + } else { + SectionHealth::Green + } + } + + fn description(&self, snapshot: &StatusSnapshot) -> String { + snapshot + .kanban_providers + .first() + .map(|p| p.provider_type.clone()) + .unwrap_or_else(|| "No provider connected".into()) + } + + fn children(&self, snapshot: &StatusSnapshot) -> Vec { + if snapshot.kanban_providers.is_empty() { + return vec![ + TreeRow { + section_id: SectionId::Kanban, + depth: 1, + label: "Configure Jira".into(), + description: "Connect to Jira Cloud".into(), + icon: StatusIcon::Plug, + is_header: false, + actions: ActionSet::primary(StatusAction::ConfigureKanbanProvider { + provider: "jira".into(), + }), + health: SectionHealth::Gray, + }, + TreeRow { + section_id: SectionId::Kanban, + depth: 1, + label: "Configure Linear".into(), + description: "Connect to Linear".into(), + icon: StatusIcon::Plug, + is_header: false, + actions: ActionSet::primary(StatusAction::ConfigureKanbanProvider { + provider: "linear".into(), + }), + health: SectionHealth::Gray, + }, + ]; + } + + snapshot + .kanban_providers + .iter() + .map(|provider| TreeRow { + section_id: SectionId::Kanban, + depth: 1, + label: provider.provider_type.clone(), + description: provider.domain.clone(), + icon: StatusIcon::Plug, + is_header: false, + actions: ActionSet { + primary: StatusAction::None, + back: StatusAction::None, + special: StatusAction::None, + special_meta: None, + refresh: StatusAction::RefreshSection(SectionId::Kanban), + refresh_meta: Some(ActionMeta { + title: "Sync", + tooltip: "Re-check kanban provider connection", + }), + }, + health: SectionHealth::Gray, + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backstage::ServerStatus; + use crate::rest::RestApiStatus; + use crate::ui::status_panel::{ + DelegatorInfo, KanbanProviderInfo, LlmToolInfo, WrapperConnectionStatus, + }; + + fn base_snapshot() -> StatusSnapshot { + StatusSnapshot { + working_dir: "/test".into(), + config_file_found: true, + config_path: "operator.toml".into(), + tickets_dir: ".tickets".into(), + tickets_dir_exists: true, + wrapper_type: "tmux".into(), + operator_version: "0.1.28".into(), + api_status: RestApiStatus::Running { port: 7008 }, + backstage_status: ServerStatus::Stopped, + backstage_display: false, + kanban_providers: vec![], + llm_tools: vec![], + default_llm_tool: None, + default_llm_model: None, + delegators: vec![], + git_provider: None, + git_token_set: false, + git_branch_format: None, + git_use_worktrees: false, + update_available_version: None, + wrapper_connection_status: WrapperConnectionStatus::Tmux { + available: true, + server_running: true, + version: Some("tmux 3.4".into()), + }, + env_editor: "vim".into(), + env_visual: String::new(), + } + } + + #[test] + fn test_kanban_health_yellow_when_no_providers() { + let section = KanbanSection; + let snap = base_snapshot(); + assert_eq!(section.health(&snap), SectionHealth::Yellow); + } + + #[test] + fn test_kanban_health_green_when_providers_configured() { + let section = KanbanSection; + let mut snap = base_snapshot(); + snap.kanban_providers.push(KanbanProviderInfo { + provider_type: "jira".into(), + domain: "myteam.atlassian.net".into(), + }); + assert_eq!(section.health(&snap), SectionHealth::Green); + } + + #[test] + fn test_kanban_description_no_provider() { + let section = KanbanSection; + let snap = base_snapshot(); + assert_eq!(section.description(&snap), "No provider connected"); + } + + #[test] + fn test_kanban_description_with_provider() { + let section = KanbanSection; + let mut snap = base_snapshot(); + snap.kanban_providers.push(KanbanProviderInfo { + provider_type: "Linear".into(), + domain: "myteam".into(), + }); + assert_eq!(section.description(&snap), "Linear"); + } + + #[test] + fn test_kanban_children_empty_shows_configure_options() { + let section = KanbanSection; + let snap = base_snapshot(); + let children = section.children(&snap); + + assert_eq!(children.len(), 2); + assert_eq!(children[0].label, "Configure Jira"); + assert_eq!(children[0].description, "Connect to Jira Cloud"); + assert_eq!( + children[0].actions.primary, + StatusAction::ConfigureKanbanProvider { + provider: "jira".into() + } + ); + assert_eq!(children[1].label, "Configure Linear"); + assert_eq!(children[1].description, "Connect to Linear"); + assert_eq!( + children[1].actions.primary, + StatusAction::ConfigureKanbanProvider { + provider: "linear".into() + } + ); + } + + #[test] + fn test_kanban_children_with_providers_shows_provider_rows() { + let section = KanbanSection; + let mut snap = base_snapshot(); + snap.kanban_providers.push(KanbanProviderInfo { + provider_type: "jira".into(), + domain: "myteam.atlassian.net".into(), + }); + let children = section.children(&snap); + + assert_eq!(children.len(), 1); + assert_eq!(children[0].label, "jira"); + assert_eq!(children[0].description, "myteam.atlassian.net"); + assert_eq!(children[0].actions.primary, StatusAction::None); + } +} diff --git a/src/ui/sections/llm_section.rs b/src/ui/sections/llm_section.rs new file mode 100644 index 0000000..9f51413 --- /dev/null +++ b/src/ui/sections/llm_section.rs @@ -0,0 +1,100 @@ +use crate::ui::status_panel::{ + ActionMeta, ActionSet, SectionHealth, SectionId, StatusAction, StatusIcon, StatusSection, + StatusSnapshot, TreeRow, +}; + +pub struct LlmSection; + +impl StatusSection for LlmSection { + fn section_id(&self) -> SectionId { + SectionId::LlmTools + } + + fn label(&self) -> &'static str { + "LLM Tools" + } + + fn prerequisites(&self) -> &[SectionId] { + &[SectionId::Connections] + } + + fn health(&self, snapshot: &StatusSnapshot) -> SectionHealth { + if snapshot.llm_tools.is_empty() { + SectionHealth::Yellow + } else { + SectionHealth::Green + } + } + + fn description(&self, snapshot: &StatusSnapshot) -> String { + match (&snapshot.default_llm_tool, &snapshot.default_llm_model) { + (Some(tool), Some(model)) => format!("Default: {tool}:{model}"), + (Some(tool), None) => format!("Default: {tool}"), + _ => snapshot + .llm_tools + .first() + .map(|t| t.name.clone()) + .unwrap_or_else(|| "No tools detected".into()), + } + } + + fn children(&self, snapshot: &StatusSnapshot) -> Vec { + let mut rows = Vec::new(); + + for tool in &snapshot.llm_tools { + // Depth 1: tool name + version + rows.push(TreeRow { + section_id: SectionId::LlmTools, + depth: 1, + label: tool.name.clone(), + description: tool.version.clone(), + icon: StatusIcon::Tool, + is_header: false, + actions: ActionSet { + primary: StatusAction::None, + back: StatusAction::None, + special: StatusAction::EditFile(snapshot.config_path.clone()), + special_meta: Some(ActionMeta { + title: "Config", + tooltip: "Edit LLM tool configuration", + }), + refresh: StatusAction::None, + refresh_meta: None, + }, + health: SectionHealth::Gray, + }); + + // Depth 2: model aliases — selecting sets as default + for model in &tool.model_aliases { + let is_default = snapshot.default_llm_tool.as_deref() == Some(&tool.name) + && snapshot.default_llm_model.as_deref() == Some(model.as_str()); + let icon = if is_default { + StatusIcon::Check + } else { + StatusIcon::Key + }; + let label = if is_default { + format!("{model} (default)") + } else { + model.clone() + }; + + rows.push(TreeRow { + section_id: SectionId::LlmTools, + depth: 2, + label, + description: format!("{}:{}", tool.name, model), + icon, + is_header: false, + actions: ActionSet::primary(StatusAction::SetDefaultLlm { + tool_name: tool.name.clone(), + model: model.clone(), + }), + health: SectionHealth::Gray, + }); + } + } + + rows + } +} diff --git a/src/ui/sections/mod.rs b/src/ui/sections/mod.rs new file mode 100644 index 0000000..a581e4b --- /dev/null +++ b/src/ui/sections/mod.rs @@ -0,0 +1,13 @@ +mod config_section; +mod connections_section; +mod delegator_section; +mod git_section; +mod kanban_section; +mod llm_section; + +pub use config_section::ConfigSection; +pub use connections_section::ConnectionsSection; +pub use delegator_section::DelegatorSection; +pub use git_section::GitSection; +pub use kanban_section::KanbanSection; +pub use llm_section::LlmSection; diff --git a/src/ui/status_panel.rs b/src/ui/status_panel.rs new file mode 100644 index 0000000..91bf1a9 --- /dev/null +++ b/src/ui/status_panel.rs @@ -0,0 +1,1313 @@ +use std::collections::{HashMap, HashSet}; + +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::backstage::ServerStatus; +use crate::rest::RestApiStatus; + +use super::sections::{ + ConfigSection, ConnectionsSection, DelegatorSection, GitSection, KanbanSection, LlmSection, +}; + +// --------------------------------------------------------------------------- +// Shared types (exported to TypeScript via ts-rs) +// --------------------------------------------------------------------------- + +/// Identifies a collapsible section in the status tree. +/// +/// String values match the `sectionId` used in the `VSCode` extension tree routing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)] +#[ts(export)] +pub enum SectionId { + #[serde(rename = "config")] + Configuration, + #[serde(rename = "connections")] + Connections, + #[serde(rename = "kanban")] + Kanban, + #[serde(rename = "llm")] + LlmTools, + #[serde(rename = "git")] + Git, + #[serde(rename = "issuetypes")] + IssueTypes, + #[serde(rename = "delegators")] + Delegators, + #[serde(rename = "projects")] + ManagedProjects, +} + +/// Health state of a section — controls the header color. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +pub enum SectionHealth { + /// All good + Green, + /// Needs attention + Yellow, + /// Broken / missing + Red, + /// Info-only / not applicable + Gray, +} + +impl SectionHealth { + pub fn to_color(self) -> Color { + match self { + SectionHealth::Green => Color::Rgb(0, 200, 83), + SectionHealth::Yellow => Color::Rgb(255, 193, 7), + SectionHealth::Red => Color::Rgb(244, 67, 54), + SectionHealth::Gray => Color::Gray, + } + } +} + +/// Declarative section metadata — shared between TUI and `VSCode`. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +#[allow(dead_code)] +pub struct SectionDefinition { + pub id: SectionId, + pub label: String, + pub prerequisites: Vec, +} + +// --------------------------------------------------------------------------- +// Icon enum +// --------------------------------------------------------------------------- + +/// Icon rendered beside a tree row. +#[derive(Debug, Clone, Copy)] +#[allow(dead_code)] +pub enum StatusIcon { + Check, + Cross, + Warning, + Folder, + File, + Plug, + Key, + Branch, + Tool, + None, +} + +impl StatusIcon { + pub fn as_span(self) -> Span<'static> { + match self { + StatusIcon::Check => Span::styled("✓ ", Style::default().fg(Color::Green)), + StatusIcon::Cross => Span::styled("✗ ", Style::default().fg(Color::Red)), + StatusIcon::Warning => Span::styled("⚠ ", Style::default().fg(Color::Yellow)), + StatusIcon::Folder => Span::styled("D ", Style::default().fg(Color::Cyan)), + StatusIcon::File => Span::styled("F ", Style::default().fg(Color::White)), + StatusIcon::Plug => Span::styled("C ", Style::default().fg(Color::Green)), + StatusIcon::Key => Span::styled("K ", Style::default().fg(Color::Yellow)), + StatusIcon::Branch => Span::styled("⑂ ", Style::default().fg(Color::Cyan)), + StatusIcon::Tool => Span::styled("T ", Style::default().fg(Color::Magenta)), + StatusIcon::None => Span::raw(" "), + } + } +} + +// --------------------------------------------------------------------------- +// Tree row and action +// --------------------------------------------------------------------------- + +/// A single visible row in the status tree. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct TreeRow { + pub section_id: SectionId, + pub depth: u16, + pub label: String, + pub description: String, + pub icon: StatusIcon, + pub is_header: bool, + pub actions: ActionSet, + pub health: SectionHealth, +} + +/// Action to perform when a button is pressed on a status panel row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StatusAction { + /// Toggle expand/collapse of a section header + ToggleSection(SectionId), + /// Open a directory in the OS file browser (`open` on macOS, `xdg-open` on Linux) + OpenDirectory(String), + /// Open a file in `$VISUAL` / `$EDITOR` + EditFile(String), + /// Open a URL in the default browser + OpenUrl(String), + /// Start the REST API server (without backstage) + StartApi, + /// Open Swagger UI for the running API + OpenSwagger { port: u16 }, + /// Restart the session wrapper connection + RestartWrapperConnection, + /// Toggle the web servers (backstage + REST API) + ToggleWebServers, + /// Set the global default LLM tool and model + SetDefaultLlm { tool_name: String, model: String }, + /// Open onboarding for a kanban provider (e.g. "jira", "linear") + ConfigureKanbanProvider { provider: String }, + /// Open setup page for a git provider (e.g. "github", "gitlab") + ConfigureGitProvider { provider: String }, + /// Re-check a specific section's health status + RefreshSection(SectionId), + /// Reset config to factory defaults (TUI: double-confirm dialog) + ResetConfig, + /// Reload config from disk and restart operator experience + ReloadConfig, + /// No action available for this row + None, +} + +/// Which button was pressed — maps to ABXY gamepad layout. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActionButton { + /// A (Enter) — primary/affirm/activate + A, + /// B (Esc/Backspace) — go back, collapse parent + B, + /// X (Shift+Enter) — special/tertiary action + X, + /// Y (Ctrl+Enter) — contextual refresh/update + Y, +} + +/// Display metadata for an action — short title for TUI and title+tooltip for `VSCode`. +#[derive(Debug, Clone)] +pub struct ActionMeta { + /// Short label (max 6 chars) shown right-aligned on the selected row in TUI, + /// and as the command title in `VSCode`. + pub title: &'static str, + /// Sentence description shown as tooltip in `VSCode` and in the help dialog. + #[allow(dead_code)] + pub tooltip: &'static str, +} + +/// Four action slots mapped to ABXY gamepad buttons. +#[derive(Debug, Clone)] +pub struct ActionSet { + /// A (Enter) — primary/affirm/activate + pub primary: StatusAction, + /// B (Esc) — go back, collapse parent + pub back: StatusAction, + /// X (Shift+Enter) — special/tertiary + pub special: StatusAction, + /// Display metadata for the special action (shown in TUI and `VSCode`). + pub special_meta: Option, + /// Y (Ctrl+Enter) — contextual refresh + pub refresh: StatusAction, + /// Display metadata for the refresh action. + pub refresh_meta: Option, +} + +impl ActionSet { + /// Create an action set with only a primary action; others default to None. + pub fn primary(action: StatusAction) -> Self { + Self { + primary: action, + back: StatusAction::None, + special: StatusAction::None, + special_meta: None, + refresh: StatusAction::None, + refresh_meta: None, + } + } + + /// All actions are None. + pub fn none() -> Self { + Self::primary(StatusAction::None) + } + + /// Select an action by button. + pub fn for_button(&self, button: ActionButton) -> &StatusAction { + match button { + ActionButton::A => &self.primary, + ActionButton::B => &self.back, + ActionButton::X => &self.special, + ActionButton::Y => &self.refresh, + } + } + + /// Get the short title for the special action, or `"*"` as fallback. + pub fn special_title(&self) -> &str { + self.special_meta.as_ref().map(|m| m.title).unwrap_or("*") + } + + /// Get the short title for the refresh action, or `"⟳"` as fallback. + pub fn refresh_title(&self) -> &str { + self.refresh_meta + .as_ref() + .map(|m| m.title) + .unwrap_or("\u{27F3}") + } +} + +// --------------------------------------------------------------------------- +// Snapshot data +// --------------------------------------------------------------------------- + +/// Information about a configured kanban provider. +#[derive(Debug, Clone)] +pub struct KanbanProviderInfo { + pub provider_type: String, + pub domain: String, +} + +/// Information about a configured LLM tool. +#[derive(Debug, Clone)] +pub struct LlmToolInfo { + pub name: String, + pub version: String, + pub model_aliases: Vec, +} + +/// Information about a configured delegator. +#[derive(Debug, Clone)] +pub struct DelegatorInfo { + pub name: String, + pub display_name: Option, + pub llm_tool: String, + pub model: String, + pub yolo: bool, +} + +/// Connection status for the active session wrapper. +#[derive(Debug, Clone)] +pub enum WrapperConnectionStatus { + Tmux { + available: bool, + server_running: bool, + version: Option, + }, + Vscode { + webhook_running: bool, + port: Option, + }, + Cmux { + binary_available: bool, + in_cmux: bool, + }, + Zellij { + binary_available: bool, + in_zellij: bool, + }, +} + +impl WrapperConnectionStatus { + pub fn is_connected(&self) -> bool { + match self { + Self::Tmux { + available, + server_running, + .. + } => *available && *server_running, + Self::Vscode { + webhook_running, .. + } => *webhook_running, + Self::Cmux { + binary_available, + in_cmux, + } => *binary_available && *in_cmux, + Self::Zellij { + binary_available, + in_zellij, + } => *binary_available && *in_zellij, + } + } + + pub fn label(&self) -> &'static str { + match self { + Self::Tmux { .. } => "tmux", + Self::Vscode { .. } => "vscode", + Self::Cmux { .. } => "cmux", + Self::Zellij { .. } => "zellij", + } + } + + pub fn description(&self) -> String { + match self { + Self::Tmux { + available, + server_running, + version, + } => match (available, server_running) { + (true, true) => format!( + "Connected{}", + version + .as_ref() + .map(|v| format!(" ({v})")) + .unwrap_or_default() + ), + (true, false) => "Server not running".into(), + (false, _) => "Not installed".into(), + }, + Self::Vscode { + webhook_running, + port, + } => { + if *webhook_running { + format!("Webhook :{}", port.unwrap_or(7009)) + } else { + "Webhook stopped".into() + } + } + Self::Cmux { + binary_available, + in_cmux, + } => match (binary_available, in_cmux) { + (true, true) => "Connected".into(), + (true, false) => "Not in cmux session".into(), + (false, _) => "Binary not found".into(), + }, + Self::Zellij { + binary_available, + in_zellij, + } => match (binary_available, in_zellij) { + (true, true) => "Connected".into(), + (true, false) => "Not in zellij session".into(), + (false, _) => "Binary not found".into(), + }, + } + } +} + +/// A point-in-time snapshot of everything the status panel needs to render. +#[derive(Debug)] +#[allow(dead_code)] +pub struct StatusSnapshot { + pub working_dir: String, + pub config_file_found: bool, + pub config_path: String, + pub tickets_dir: String, + pub tickets_dir_exists: bool, + pub wrapper_type: String, + pub operator_version: String, + pub api_status: RestApiStatus, + pub backstage_status: ServerStatus, + pub backstage_display: bool, + pub kanban_providers: Vec, + pub llm_tools: Vec, + pub default_llm_tool: Option, + pub default_llm_model: Option, + pub delegators: Vec, + pub git_provider: Option, + pub git_token_set: bool, + pub git_branch_format: Option, + pub git_use_worktrees: bool, + pub update_available_version: Option, + pub wrapper_connection_status: WrapperConnectionStatus, + /// Resolved `$EDITOR` value + pub env_editor: String, + /// Resolved `$VISUAL` value + pub env_visual: String, +} + +// --------------------------------------------------------------------------- +// Section trait +// --------------------------------------------------------------------------- + +/// Trait for each status panel section (mirrors the `StatusSection` interface from the `VSCode` extension). +pub trait StatusSection { + /// Unique identifier for this section. + fn section_id(&self) -> SectionId; + + /// Display label for the section header. + fn label(&self) -> &'static str; + + /// Which section IDs must be Green before this section is visible. + fn prerequisites(&self) -> &[SectionId]; + + /// Current health state — determines header color. + fn health(&self, snapshot: &StatusSnapshot) -> SectionHealth; + + /// Summary description shown next to the section header. + fn description(&self, snapshot: &StatusSnapshot) -> String; + + /// Child rows when this section is expanded. + fn children(&self, snapshot: &StatusSnapshot) -> Vec; + + /// Build the `SectionDefinition` metadata for this section. + #[allow(dead_code)] + fn definition(&self) -> SectionDefinition { + SectionDefinition { + id: self.section_id(), + label: self.label().to_string(), + prerequisites: self.prerequisites().to_vec(), + } + } +} + +// --------------------------------------------------------------------------- +// Tree state +// --------------------------------------------------------------------------- + +/// Tracks which sections are expanded/collapsed and the cursor position. +#[derive(Debug, Clone)] +pub struct TreeState { + pub expanded: HashMap, + pub selected: usize, + pub scroll_offset: usize, + /// Rows currently running a refresh action (`section_id`, row label). + /// Used to render ⟳ in yellow while refreshing. + pub refreshing: HashSet<(SectionId, String)>, +} + +impl TreeState { + pub fn new() -> Self { + let mut expanded = HashMap::new(); + expanded.insert(SectionId::Configuration, true); + expanded.insert(SectionId::Connections, false); + expanded.insert(SectionId::Kanban, false); + expanded.insert(SectionId::LlmTools, false); + expanded.insert(SectionId::Delegators, false); + expanded.insert(SectionId::Git, false); + Self { + expanded, + selected: 0, + scroll_offset: 0, + refreshing: HashSet::new(), + } + } +} + +// --------------------------------------------------------------------------- +// Status panel (orchestrator) +// --------------------------------------------------------------------------- + +/// The status panel widget — a collapsible tree with progressive disclosure. +pub struct StatusPanel { + pub tree_state: TreeState, + pub title: String, + sections: Vec>, +} + +impl StatusPanel { + pub fn new(title: String) -> Self { + let sections: Vec> = vec![ + Box::new(ConfigSection), + Box::new(ConnectionsSection), + Box::new(KanbanSection), + Box::new(LlmSection), + Box::new(DelegatorSection), + Box::new(GitSection), + ]; + Self { + tree_state: TreeState::new(), + title, + sections, + } + } + + fn is_expanded(&self, id: SectionId) -> bool { + self.tree_state.expanded.get(&id).copied().unwrap_or(false) + } + + /// Check if all prerequisite sections are Green (transitively). + /// A section is visible only if its prerequisites are Green AND those + /// prerequisites' own prerequisites are also met. + fn prerequisites_met(&self, section: &dyn StatusSection, snapshot: &StatusSnapshot) -> bool { + section.prerequisites().iter().all(|prereq_id| { + self.sections + .iter() + .find(|s| s.section_id() == *prereq_id) + .is_some_and(|s| { + // Prerequisite must itself be visible (transitive check) + self.prerequisites_met_by_id(s.section_id(), snapshot) + && s.health(snapshot) == SectionHealth::Green + }) + }) + } + + fn prerequisites_met_by_id(&self, id: SectionId, snapshot: &StatusSnapshot) -> bool { + self.sections + .iter() + .find(|s| s.section_id() == id) + .is_some_and(|s| self.prerequisites_met(s.as_ref(), snapshot)) + } + + /// Build the list of visible rows, respecting expand/collapse and + /// prerequisite-based progressive disclosure. + pub fn flatten(&self, snapshot: &StatusSnapshot) -> Vec { + let mut rows: Vec = Vec::new(); + + for section in &self.sections { + if !self.prerequisites_met(section.as_ref(), snapshot) { + continue; + } + + let health = section.health(snapshot); + + // Header row + rows.push(TreeRow { + section_id: section.section_id(), + depth: 0, + label: section.label().to_string(), + description: section.description(snapshot), + icon: StatusIcon::None, + is_header: true, + actions: ActionSet::primary(StatusAction::ToggleSection(section.section_id())), + health, + }); + + // Children (if expanded) + if self.is_expanded(section.section_id()) { + let sid = section.section_id(); + let mut children = section.children(snapshot); + // Auto-populate back action on child rows: collapse parent section + for child in &mut children { + if child.actions.back == StatusAction::None { + child.actions.back = StatusAction::ToggleSection(sid); + } + } + rows.extend(children); + } + } + + rows + } + + /// Returns true if any visible section has Yellow or Red health. + pub fn has_attention_needed(&self, snapshot: &StatusSnapshot) -> bool { + self.sections.iter().any(|s| { + self.prerequisites_met(s.as_ref(), snapshot) + && matches!( + s.health(snapshot), + SectionHealth::Yellow | SectionHealth::Red + ) + }) + } + + /// Select the first header row that has Yellow or Red health. + /// Expands that section so its children are visible for interaction. + pub fn focus_first_attention(&mut self, snapshot: &StatusSnapshot) { + let rows = self.flatten(snapshot); + for (i, row) in rows.iter().enumerate() { + if row.is_header && matches!(row.health, SectionHealth::Yellow | SectionHealth::Red) { + self.tree_state.selected = i; + // Expand the section so the user sees what needs attention + self.tree_state.expanded.insert(row.section_id, true); + return; + } + } + } + + /// Get the action for the currently selected row and button. + /// If the action is `ToggleSection`, perform the toggle internally. + pub fn action_for_current( + &mut self, + snapshot: &StatusSnapshot, + button: ActionButton, + ) -> StatusAction { + let rows = self.flatten(snapshot); + let action = rows + .get(self.tree_state.selected) + .map(|r| r.actions.for_button(button).clone()) + .unwrap_or(StatusAction::None); + + // Only toggle sections on primary (A) button press + if button == ActionButton::A { + if let StatusAction::ToggleSection(section_id) = &action { + let entry = self.tree_state.expanded.entry(*section_id).or_insert(false); + *entry = !*entry; + } + } + + // B button on headers: toggle collapse + if button == ActionButton::B { + if let Some(row) = rows.get(self.tree_state.selected) { + if row.is_header && self.is_expanded(row.section_id) { + self.tree_state.expanded.insert(row.section_id, false); + return StatusAction::None; + } + } + } + + action + } + + /// Move selection down, wrapping to the top. + pub fn select_next(&mut self, snapshot: &StatusSnapshot) { + let count = self.flatten(snapshot).len(); + if count == 0 { + return; + } + self.tree_state.selected = (self.tree_state.selected + 1) % count; + self.adjust_scroll(snapshot); + } + + /// Move selection up, wrapping to the bottom. + pub fn select_prev(&mut self, snapshot: &StatusSnapshot) { + let count = self.flatten(snapshot).len(); + if count == 0 { + return; + } + if self.tree_state.selected == 0 { + self.tree_state.selected = count - 1; + } else { + self.tree_state.selected -= 1; + } + self.adjust_scroll(snapshot); + } + + /// Number of currently visible (flattened) rows. + #[allow(dead_code)] + pub fn visible_count(&self, snapshot: &StatusSnapshot) -> usize { + self.flatten(snapshot).len() + } + + /// Render the status panel into the given area. + pub fn render(&self, frame: &mut Frame, area: Rect, focused: bool, snapshot: &StatusSnapshot) { + let rows = self.flatten(snapshot); + let inner_height = area.height.saturating_sub(2) as usize; + let offset = self.tree_state.scroll_offset; + + let visible_rows = rows.iter().skip(offset).take(inner_height); + + let lines: Vec = visible_rows + .enumerate() + .map(|(i, row)| { + let abs_idx = offset + i; + let is_selected = abs_idx == self.tree_state.selected; + + let mut spans: Vec = Vec::new(); + + if row.is_header { + let chevron = if self.is_expanded(row.section_id) { + "▾ " + } else { + "▸ " + }; + spans.push(Span::raw(chevron)); + // Header label colored by health state + spans.push(Span::styled( + row.label.clone(), + Style::default() + .fg(row.health.to_color()) + .add_modifier(Modifier::BOLD), + )); + if !row.description.is_empty() { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + row.description.clone(), + Style::default().fg(Color::Gray), + )); + } + } else { + spans.push(Span::raw(" ")); + spans.push(row.icon.as_span()); + spans.push(Span::raw(row.label.clone())); + if !row.description.is_empty() { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + row.description.clone(), + Style::default().fg(Color::Gray), + )); + } + } + + // Right-aligned action indicators + let has_special = row.actions.special != StatusAction::None; + let has_refresh = row.actions.refresh != StatusAction::None; + + if (is_selected && has_special) || has_refresh { + // Calculate content width so far + let content_width: usize = spans.iter().map(|s| s.content.len()).sum(); + // Inner width = area minus border chars (2) + let inner_width = area.width.saturating_sub(2) as usize; + + // Build the right-side indicator string + let mut indicator = String::new(); + if is_selected && has_special { + let title = row.actions.special_title(); + indicator.push_str(title); + } + if has_refresh { + if !indicator.is_empty() { + indicator.push(' '); + } + indicator.push_str(row.actions.refresh_title()); + } + + // Pad to right-align + let indicator_width = indicator.chars().count(); + let gap = inner_width.saturating_sub(content_width + indicator_width); + if gap > 0 { + spans.push(Span::raw(" ".repeat(gap))); + } + + // Render indicator spans with appropriate colors + if is_selected && has_special { + let title = row.actions.special_title(); + spans.push(Span::styled( + title.to_string(), + Style::default().fg(Color::DarkGray), + )); + } + if has_refresh { + if is_selected && has_special { + spans.push(Span::raw(" ")); + } + let is_refreshing = self + .tree_state + .refreshing + .contains(&(row.section_id, row.label.clone())); + let color = if is_refreshing { + Color::Rgb(255, 193, 7) // Yellow while refreshing + } else { + Color::White + }; + spans.push(Span::styled( + row.actions.refresh_title().to_string(), + Style::default().fg(color), + )); + } + } + + let line = Line::from(spans); + if is_selected { + line.style(Style::default().add_modifier(Modifier::REVERSED)) + } else { + line + } + }) + .collect(); + + let border_style = if focused { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::Gray) + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(self.title.clone()); + + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, area); + } + + fn adjust_scroll(&mut self, snapshot: &StatusSnapshot) { + let rows = self.flatten(snapshot); + let count = rows.len(); + if count == 0 { + self.tree_state.scroll_offset = 0; + return; + } + if self.tree_state.selected < self.tree_state.scroll_offset { + self.tree_state.scroll_offset = self.tree_state.selected; + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn test_snapshot() -> StatusSnapshot { + StatusSnapshot { + working_dir: "/home/user/project".into(), + config_file_found: true, + config_path: "operator.toml".into(), + tickets_dir: ".tickets".into(), + tickets_dir_exists: true, + wrapper_type: "tmux".into(), + operator_version: "0.1.28".into(), + api_status: RestApiStatus::Running { port: 3100 }, + backstage_status: ServerStatus::Running { + port: 7007, + pid: 1234, + }, + kanban_providers: vec![KanbanProviderInfo { + provider_type: "Linear".into(), + domain: "myteam.linear.app".into(), + }], + llm_tools: vec![LlmToolInfo { + name: "Claude".into(), + version: "3.5".into(), + model_aliases: vec!["opus".into(), "sonnet".into(), "haiku".into()], + }], + default_llm_tool: None, + default_llm_model: None, + delegators: vec![DelegatorInfo { + name: "claude-opus".into(), + display_name: Some("Claude Opus".into()), + llm_tool: "claude".into(), + model: "opus".into(), + yolo: false, + }], + git_provider: Some("GitHub".into()), + git_token_set: true, + git_branch_format: Some("feature/{ticket}".into()), + git_use_worktrees: false, + update_available_version: None, + wrapper_connection_status: WrapperConnectionStatus::Tmux { + available: true, + server_running: true, + version: Some("3.4".into()), + }, + env_editor: "vim".into(), + env_visual: String::new(), + backstage_display: false, + } + } + + #[test] + fn test_flatten_tier0_always_visible() { + let panel = StatusPanel::new("Status".into()); + // With a healthy snapshot, Configuration is always visible + let snap = test_snapshot(); + let rows = panel.flatten(&snap); + + assert!(rows[0].is_header); + assert_eq!(rows[0].label, "Configuration"); + } + + #[test] + fn test_flatten_progressive_disclosure() { + let panel = StatusPanel::new("Status".into()); + + // With all green, all sections visible + let snap = test_snapshot(); + let rows = panel.flatten(&snap); + assert!( + rows.iter().any(|r| r.section_id == SectionId::Connections), + "Connections should appear when Configuration is green" + ); + assert!( + rows.iter().any(|r| r.section_id == SectionId::Kanban), + "Kanban should appear when Connections is green" + ); + + // With config missing, only Configuration shows (red) + let mut bad_snap = test_snapshot(); + bad_snap.config_file_found = false; + let rows = panel.flatten(&bad_snap); + assert!( + !rows.iter().any(|r| r.section_id == SectionId::Connections), + "Connections should NOT appear when Configuration is red" + ); + } + + #[test] + fn test_flatten_expanded_shows_children() { + let panel = StatusPanel::new("Status".into()); + let snap = test_snapshot(); + + // Configuration is expanded by default + let rows = panel.flatten(&snap); + let config_children: Vec<_> = rows + .iter() + .filter(|r| r.section_id == SectionId::Configuration && !r.is_header) + .collect(); + assert_eq!(config_children.len(), 8, "Should have 8 config children"); + assert_eq!(config_children[0].label, "Working Dir"); + assert_eq!(config_children[1].label, "Config"); + assert_eq!(config_children[2].label, "Tickets"); + assert_eq!(config_children[3].label, "tmux"); // wrapper connection + assert_eq!(config_children[4].label, "Wrapper"); + assert_eq!(config_children[5].label, "$EDITOR"); + assert_eq!(config_children[6].label, "$VISUAL"); + assert_eq!(config_children[7].label, "Version"); + } + + #[test] + fn test_action_for_current_toggles_header() { + let mut panel = StatusPanel::new("Status".into()); + let snap = test_snapshot(); + + panel.tree_state.selected = 0; + assert!(panel.is_expanded(SectionId::Configuration)); + + let action = panel.action_for_current(&snap, ActionButton::A); + assert_eq!( + action, + StatusAction::ToggleSection(SectionId::Configuration) + ); + assert!(!panel.is_expanded(SectionId::Configuration)); + + let action = panel.action_for_current(&snap, ActionButton::A); + assert_eq!( + action, + StatusAction::ToggleSection(SectionId::Configuration) + ); + assert!(panel.is_expanded(SectionId::Configuration)); + } + + #[test] + fn test_action_for_current_child_rows() { + let mut panel = StatusPanel::new("Status".into()); + let snap = test_snapshot(); + + // Working Dir (index 1) — should open directory + panel.tree_state.selected = 1; + let action = panel.action_for_current(&snap, ActionButton::A); + assert!(matches!(action, StatusAction::OpenDirectory(_))); + + // Config (index 2) — should edit file + panel.tree_state.selected = 2; + let action = panel.action_for_current(&snap, ActionButton::A); + assert!(matches!(action, StatusAction::EditFile(_))); + + // Tickets (index 3) — should open directory + panel.tree_state.selected = 3; + let action = panel.action_for_current(&snap, ActionButton::A); + assert!(matches!(action, StatusAction::OpenDirectory(_))); + + // Wrapper (index 4) — read-only + panel.tree_state.selected = 4; + let action = panel.action_for_current(&snap, ActionButton::A); + assert_eq!(action, StatusAction::None); + + // $EDITOR (index 5) — read-only + panel.tree_state.selected = 5; + let action = panel.action_for_current(&snap, ActionButton::A); + assert_eq!(action, StatusAction::None); + + // $VISUAL (index 6) — read-only + panel.tree_state.selected = 6; + let action = panel.action_for_current(&snap, ActionButton::A); + assert_eq!(action, StatusAction::None); + + // $IDE (index 7) — read-only + panel.tree_state.selected = 7; + let action = panel.action_for_current(&snap, ActionButton::A); + assert_eq!(action, StatusAction::None); + + // Version (index 8) — opens downloads URL + panel.tree_state.selected = 8; + let action = panel.action_for_current(&snap, ActionButton::A); + assert!(matches!(action, StatusAction::OpenUrl(_))); + } + + #[test] + fn test_section_health_colors() { + let snap = test_snapshot(); + let panel = StatusPanel::new("Status".into()); + let rows = panel.flatten(&snap); + + // Configuration should be green (all good) + let config_header = rows + .iter() + .find(|r| r.section_id == SectionId::Configuration && r.is_header) + .unwrap(); + assert_eq!(config_header.health, SectionHealth::Green); + + // Test red state + let mut bad_snap = test_snapshot(); + bad_snap.config_file_found = false; + let rows = panel.flatten(&bad_snap); + let config_header = rows + .iter() + .find(|r| r.section_id == SectionId::Configuration && r.is_header) + .unwrap(); + assert_eq!(config_header.health, SectionHealth::Red); + + // Test yellow state + let mut warn_snap = test_snapshot(); + warn_snap.tickets_dir_exists = false; + let rows = panel.flatten(&warn_snap); + let config_header = rows + .iter() + .find(|r| r.section_id == SectionId::Configuration && r.is_header) + .unwrap(); + assert_eq!(config_header.health, SectionHealth::Yellow); + } + + #[test] + fn test_working_dir_shows_check_when_configured() { + let panel = StatusPanel::new("Status".into()); + let snap = test_snapshot(); + let rows = panel.flatten(&snap); + + let working_dir = rows + .iter() + .find(|r| r.label == "Working Dir" && !r.is_header) + .unwrap(); + assert!( + matches!(working_dir.icon, StatusIcon::Check), + "Working Dir should show Check icon when configured" + ); + } + + #[test] + fn test_select_next_wraps() { + let mut panel = StatusPanel::new("Status".into()); + // Collapse config so only the header is visible + panel + .tree_state + .expanded + .insert(SectionId::Configuration, false); + + // Use a snapshot where only Configuration is green but Connections prerequisites fail + let mut snap = test_snapshot(); + snap.config_file_found = false; // Makes Configuration red, hiding Connections + let count = panel.visible_count(&snap); + assert_eq!(count, 1, "Only 1 row visible"); + + panel.tree_state.selected = 0; + panel.select_next(&snap); + assert_eq!(panel.tree_state.selected, 0, "Should wrap"); + } + + #[test] + fn test_visible_count() { + let panel = StatusPanel::new("Status".into()); + let snap = test_snapshot(); + let count = panel.visible_count(&snap); + let rows = panel.flatten(&snap); + assert_eq!(count, rows.len()); + } + + #[test] + fn test_wrapper_connection_tmux_connected() { + let status = WrapperConnectionStatus::Tmux { + available: true, + server_running: true, + version: Some("tmux 3.4".into()), + }; + assert!(status.is_connected()); + assert_eq!(status.label(), "tmux"); + assert_eq!(status.description(), "Connected (tmux 3.4)"); + } + + #[test] + fn test_wrapper_connection_tmux_server_not_running() { + let status = WrapperConnectionStatus::Tmux { + available: true, + server_running: false, + version: Some("tmux 3.4".into()), + }; + assert!(!status.is_connected()); + assert_eq!(status.description(), "Server not running"); + } + + #[test] + fn test_wrapper_connection_tmux_not_installed() { + let status = WrapperConnectionStatus::Tmux { + available: false, + server_running: false, + version: None, + }; + assert!(!status.is_connected()); + assert_eq!(status.description(), "Not installed"); + } + + #[test] + fn test_wrapper_connection_vscode() { + let status = WrapperConnectionStatus::Vscode { + webhook_running: true, + port: Some(7009), + }; + assert!(status.is_connected()); + assert_eq!(status.label(), "vscode"); + assert_eq!(status.description(), "Webhook :7009"); + + let stopped = WrapperConnectionStatus::Vscode { + webhook_running: false, + port: None, + }; + assert!(!stopped.is_connected()); + assert_eq!(stopped.description(), "Webhook stopped"); + } + + #[test] + fn test_wrapper_connection_cmux() { + let status = WrapperConnectionStatus::Cmux { + binary_available: true, + in_cmux: true, + }; + assert!(status.is_connected()); + assert_eq!(status.label(), "cmux"); + + let not_in = WrapperConnectionStatus::Cmux { + binary_available: true, + in_cmux: false, + }; + assert!(!not_in.is_connected()); + assert_eq!(not_in.description(), "Not in cmux session"); + } + + #[test] + fn test_wrapper_connection_zellij() { + let status = WrapperConnectionStatus::Zellij { + binary_available: true, + in_zellij: true, + }; + assert!(status.is_connected()); + assert_eq!(status.label(), "zellij"); + + let no_binary = WrapperConnectionStatus::Zellij { + binary_available: false, + in_zellij: false, + }; + assert!(!no_binary.is_connected()); + assert_eq!(no_binary.description(), "Binary not found"); + } + + #[test] + fn test_action_set_primary_constructor() { + let set = ActionSet::primary(StatusAction::StartApi); + assert_eq!(set.primary, StatusAction::StartApi); + assert_eq!(set.back, StatusAction::None); + assert_eq!(set.special, StatusAction::None); + assert_eq!(set.refresh, StatusAction::None); + } + + #[test] + fn test_action_set_none_constructor() { + let set = ActionSet::none(); + assert_eq!(set.primary, StatusAction::None); + assert_eq!(set.back, StatusAction::None); + assert_eq!(set.special, StatusAction::None); + assert_eq!(set.refresh, StatusAction::None); + } + + #[test] + fn test_action_set_for_button() { + let set = ActionSet { + primary: StatusAction::StartApi, + back: StatusAction::ToggleSection(SectionId::Configuration), + special: StatusAction::EditFile("config.toml".into()), + special_meta: Some(ActionMeta { + title: "Config", + tooltip: "Edit config", + }), + refresh: StatusAction::RefreshSection(SectionId::Connections), + refresh_meta: Some(ActionMeta { + title: "Sync", + tooltip: "Refresh connections", + }), + }; + assert_eq!(*set.for_button(ActionButton::A), StatusAction::StartApi); + assert_eq!( + *set.for_button(ActionButton::B), + StatusAction::ToggleSection(SectionId::Configuration) + ); + assert_eq!( + *set.for_button(ActionButton::X), + StatusAction::EditFile("config.toml".into()) + ); + assert_eq!( + *set.for_button(ActionButton::Y), + StatusAction::RefreshSection(SectionId::Connections) + ); + } + + #[test] + fn test_flatten_auto_populates_back_on_children() { + let panel = StatusPanel::new("Status".into()); + let snap = test_snapshot(); + let rows = panel.flatten(&snap); + + // Config children should have back = ToggleSection(Configuration) + let config_child = rows + .iter() + .find(|r| r.label == "Working Dir" && !r.is_header) + .unwrap(); + assert_eq!( + config_child.actions.back, + StatusAction::ToggleSection(SectionId::Configuration) + ); + } + + #[test] + fn test_action_for_current_b_collapses_header() { + let mut panel = StatusPanel::new("Status".into()); + let snap = test_snapshot(); + + // Configuration is expanded + panel.tree_state.selected = 0; + assert!(panel.is_expanded(SectionId::Configuration)); + + // B on expanded header should collapse it + let action = panel.action_for_current(&snap, ActionButton::B); + assert_eq!(action, StatusAction::None); + assert!(!panel.is_expanded(SectionId::Configuration)); + } + + #[test] + fn test_action_for_current_x_returns_special() { + let mut panel = StatusPanel::new("Status".into()); + let snap = test_snapshot(); + + // Config row (index 2) should have a special action (ResetConfig) + panel.tree_state.selected = 2; + let action = panel.action_for_current(&snap, ActionButton::X); + assert_eq!( + action, + StatusAction::ResetConfig, + "Config special action should be ResetConfig, got {action:?}" + ); + } + + #[test] + fn test_action_for_current_y_returns_refresh() { + let mut panel = StatusPanel::new("Status".into()); + let snap = test_snapshot(); + + // Version row (index 8) should have a refresh action + panel.tree_state.selected = 8; + let action = panel.action_for_current(&snap, ActionButton::Y); + assert!( + matches!(action, StatusAction::RefreshSection(_)), + "Version refresh action should be RefreshSection, got {action:?}" + ); + } + + #[test] + fn test_special_indicator_only_on_rows_with_special_action() { + let panel = StatusPanel::new("Status".into()); + let snap = test_snapshot(); + let rows = panel.flatten(&snap); + + // Wrapper row should NOT have special action + let wrapper = rows.iter().find(|r| r.label == "Wrapper").unwrap(); + assert_eq!(wrapper.actions.special, StatusAction::None); + + // Config row SHOULD have special action (ResetConfig) + let config = rows.iter().find(|r| r.label == "Config").unwrap(); + assert_ne!(config.actions.special, StatusAction::None); + } + + #[test] + fn test_refresh_indicator_only_on_rows_with_refresh_action() { + let panel = StatusPanel::new("Status".into()); + let snap = test_snapshot(); + let rows = panel.flatten(&snap); + + // Version row should have refresh action + let version = rows.iter().find(|r| r.label == "Version").unwrap(); + assert_ne!(version.actions.refresh, StatusAction::None); + + // Config row SHOULD have refresh action (ReloadConfig) + let config = rows.iter().find(|r| r.label == "Config").unwrap(); + assert_ne!(config.actions.refresh, StatusAction::None); + } + + #[test] + fn test_refreshing_set_tracks_state() { + let mut state = TreeState::new(); + let key = (SectionId::Configuration, "Version".to_string()); + assert!(!state.refreshing.contains(&key)); + state.refreshing.insert(key.clone()); + assert!(state.refreshing.contains(&key)); + state.refreshing.remove(&key); + assert!(!state.refreshing.contains(&key)); + } +} diff --git a/tests/feature_parity_test.rs b/tests/feature_parity_test.rs index f09efc0..57e5d6b 100644 --- a/tests/feature_parity_test.rs +++ b/tests/feature_parity_test.rs @@ -176,6 +176,101 @@ fn test_feature_parity_summary() { println!(); } +// ============================================================================= +// View Structure Parity +// ============================================================================= + +/// The four canonical views that all interfaces must implement. +/// Each tuple: (view name, TUI panel pattern in dashboard.rs, `VSCode` view ID in package.json) +const CANONICAL_VIEWS: &[(&str, &str, &str)] = &[ + ("Status", "StatusPanel", "operator-status"), + ("Queue", "QueuePanel", "operator-queue"), + ("In Progress", "InProgressPanel", "operator-in-progress"), + ("Completed", "CompletedPanel", "operator-completed"), +]; + +/// Status panel sections that must exist in both TUI and `VSCode`. +/// Each tuple: (section name, TUI `SectionId` variant, `VSCode` `sectionId` string) +const STATUS_SECTIONS: &[(&str, &str, &str)] = &[ + ("Configuration", "Configuration", "config"), + ("Connections", "Connections", "connections"), + ("Kanban", "Kanban", "kanban"), + ("LLM Tools", "LlmTools", "llm"), + ("Delegators", "Delegators", "delegators"), + ("Git", "Git", "git"), +]; + +/// Verify TUI has all 4 canonical view panels +#[test] +fn test_tui_has_all_canonical_views() { + let dashboard_src = include_str!("../src/ui/dashboard.rs"); + for (name, tui_pattern, _) in CANONICAL_VIEWS { + assert!( + dashboard_src.contains(tui_pattern), + "TUI dashboard should contain {tui_pattern} for '{name}' view" + ); + } +} + +/// Verify `VSCode` extension has all 4 canonical views +#[test] +fn test_vscode_has_all_canonical_views() { + let package_json = include_str!("../vscode-extension/package.json"); + for (name, _, vscode_id) in CANONICAL_VIEWS { + assert!( + package_json.contains(vscode_id), + "VSCode extension should have view '{vscode_id}' for '{name}'" + ); + } +} + +/// Verify TUI status panel has all canonical sections +#[test] +fn test_tui_has_all_status_sections() { + let status_panel_src = include_str!("../src/ui/status_panel.rs"); + for (name, tui_variant, _) in STATUS_SECTIONS { + assert!( + status_panel_src.contains(tui_variant), + "TUI StatusPanel should have SectionId::{tui_variant} for '{name}'" + ); + } +} + +/// Verify `VSCode` extension has all status sections +#[test] +fn test_vscode_has_all_status_sections() { + let status_provider_src = include_str!("../vscode-extension/src/status-provider.ts"); + for (name, _, vscode_section) in STATUS_SECTIONS { + assert!( + status_provider_src.contains(vscode_section), + "VSCode StatusTreeProvider should have sectionId '{vscode_section}' for '{name}'" + ); + } +} + +/// View structure parity summary +#[test] +fn test_view_structure_parity_summary() { + let dashboard_src = include_str!("../src/ui/dashboard.rs"); + let package_json = include_str!("../vscode-extension/package.json"); + + println!("\n=== View Structure Parity ===\n"); + println!("{:<15} | {:<5} | {:<7}", "View", "TUI", "VSCode"); + println!("{:-<15}-+-{:-<5}-+-{:-<7}", "", "", ""); + + for (name, tui_pattern, vscode_id) in CANONICAL_VIEWS { + let has_tui = dashboard_src.contains(tui_pattern); + let has_vscode = package_json.contains(vscode_id); + println!( + "{:<15} | {:<5} | {:<7}", + name, + if has_tui { "✓" } else { "✗" }, + if has_vscode { "✓" } else { "✗" }, + ); + } + println!(); +} + #[cfg(test)] mod detailed_tests { use super::*; diff --git a/vscode-extension/package-lock.json b/vscode-extension/package-lock.json index e6e27fa..0af0236 100644 --- a/vscode-extension/package-lock.json +++ b/vscode-extension/package-lock.json @@ -1,12 +1,12 @@ { "name": "operator-terminals", - "version": "0.1.26", + "version": "0.1.28", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "operator-terminals", - "version": "0.1.26", + "version": "0.1.28", "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", @@ -26,7 +26,7 @@ "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@types/sinon": "^17.0.3", - "@types/vscode": "^1.85.0", + "@types/vscode": "^1.93.0", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "@vscode/test-cli": "^0.0.4", @@ -48,7 +48,7 @@ "webpack-cli": "^6.0.0" }, "engines": { - "vscode": "^1.85.0" + "vscode": "^1.93.0" } }, "node_modules/@azure/abort-controller": { diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 3a07bd6..1166c6b 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -2,7 +2,7 @@ "name": "operator-terminals", "displayName": "Operator! Terminals for vscode", "description": "VS Code terminal integration for Operator! multi-agent orchestration", - "version": "0.1.27", + "version": "0.1.28", "publisher": "untra", "author": { "name": "Samuel Volin", @@ -15,7 +15,7 @@ }, "license": "MIT", "engines": { - "vscode": "^1.85.0" + "vscode": "^1.93.0" }, "categories": [ "Other" @@ -29,7 +29,17 @@ ], "icon": "images/operator-logo-128.png", "activationEvents": [ - "onStartupFinished" + "onStartupFinished", + "onView:operator-status", + "onView:operator-in-progress", + "onView:operator-queue", + "onView:operator-completed", + "onCommand:operator.showStatus", + "onCommand:operator.startOperatorServer", + "onCommand:operator.launchTicket", + "onCommand:operator.openSettings", + "onCommand:operator.openWalkthrough", + "onCommand:operator.selectWorkingDirectory" ], "main": "./out/src/extension.js", "contributes": { @@ -122,6 +132,18 @@ "title": "Refresh Tickets", "icon": "$(refresh)" }, + { + "command": "operator.statusSpecialAction", + "title": "Operator: Special Action (X)" + }, + { + "command": "operator.statusRefreshAction", + "title": "Operator: Refresh Action (Y)" + }, + { + "command": "operator.statusBackAction", + "title": "Operator: Go Back (B)" + }, { "command": "operator.focusTicket", "title": "Focus Terminal" @@ -225,6 +247,10 @@ "command": "operator.detectLlmTools", "title": "Operator: Detect LLM Tools" }, + { + "command": "operator.setDefaultLlm", + "title": "Operator: Set Default LLM" + }, { "command": "operator.openWalkthrough", "title": "Operator: Open Setup Walkthrough" @@ -253,6 +279,36 @@ "command": "operator.connectMcpServer", "title": "Operator: Connect MCP Server", "icon": "$(plug)" + }, + { + "command": "operator.runSetup", + "title": "Operator: Run Setup" + }, + { + "command": "operator.startWebhookServer", + "title": "Operator: Start Webhook Server" + } + ], + "keybindings": [ + { + "command": "operator.statusSpecialAction", + "key": "shift+enter", + "when": "focusedView == operator-status" + }, + { + "command": "operator.statusRefreshAction", + "key": "ctrl+enter", + "when": "focusedView == operator-status" + }, + { + "command": "operator.statusBackAction", + "key": "escape", + "when": "focusedView == operator-status && listHasSelectionOrFocus" + }, + { + "command": "operator.launchTicketWithOptions", + "key": "enter", + "when": "focusedView == operator-queue && listHasSelectionOrFocus" } ], "menus": { @@ -481,7 +537,7 @@ "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@types/sinon": "^17.0.3", - "@types/vscode": "^1.85.0", + "@types/vscode": "^1.93.0", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "@vscode/test-cli": "^0.0.4", diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index 9118d17..3d7704f 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -11,27 +11,25 @@ import * as vscode from 'vscode'; import * as path from 'path'; -import * as fs from 'fs/promises'; import * as os from 'os'; import { TerminalManager } from './terminal-manager'; import { WebhookServer } from './webhook-server'; import { TicketTreeProvider, TicketItem } from './ticket-provider'; import { StatusTreeProvider, StatusItem } from './status-provider'; import { LaunchManager } from './launch-manager'; +import { IssueTypeService } from './issuetype-service'; +import { TicketInfo } from './types'; +import { OperatorApiClient, discoverApiUrl } from './api-client'; import { showLaunchOptionsDialog, showTicketPicker } from './launch-dialog'; import { parseTicketMetadata, getCurrentSessionId } from './ticket-parser'; -import { TicketInfo } from './types'; -import { OperatorApiClient } from './api-client'; -import { IssueTypeService } from './issuetype-service'; import { - downloadOperator, getOperatorPath, - isOperatorAvailable, getOperatorVersion, getExtensionVersion, + isOperatorAvailable, + downloadOperator, } from './operator-binary'; import { - updateWalkthroughContext, selectWorkingDirectory, checkKanbanConnection, configureJira, @@ -39,511 +37,322 @@ import { detectLlmTools, openWalkthrough, startKanbanOnboarding, + updateWalkthroughContext, initializeTicketsDirectory, } from './walkthrough'; -import { addJiraProject, addLinearTeam } from './kanban-onboarding'; import { startGitOnboarding, onboardGitHub, onboardGitLab } from './git-onboarding'; import { ConfigPanel } from './config-panel'; -import { configFileExists } from './config-paths'; import { connectMcpServer } from './mcp-connect'; +import { configFileExists } from './config-paths'; +import { findParentTicketsDir, findTicketsDir, findOperatorServerDir } from './tickets-dir'; +import { addJiraProject, addLinearTeam } from './kanban-onboarding'; -/** - * Show a notification when config.toml is missing, with a button to open the walkthrough. - */ -function showConfigMissingNotification(): void { - // Fire notification without awaiting to prevent blocking activation - void vscode.window.showInformationMessage( - 'Could not find Operator! configuration file for this repository workspace. Run the setup walkthrough to create it and get started.', - 'Open Setup' - ).then((choice) => { - if (choice === 'Open Setup') { - void vscode.commands.executeCommand( - 'workbench.action.openWalkthrough', - 'untra.operator-terminals#operator-setup', - true - ); - } - }); +// --------------------------------------------------------------------------- +// CommandContext interface +// --------------------------------------------------------------------------- + +interface CommandContext { + extensionContext: vscode.ExtensionContext; + terminalManager: TerminalManager; + webhookServer: WebhookServer; + launchManager: LaunchManager; + issueTypeService: IssueTypeService; + statusProvider: StatusTreeProvider; + statusTreeView: vscode.TreeView; + queueProvider: TicketTreeProvider; + inProgressProvider: TicketTreeProvider; + completedProvider: TicketTreeProvider; + statusBarItem: vscode.StatusBarItem; + createBarItem: vscode.StatusBarItem; + outputChannel: vscode.OutputChannel; + getCurrentTicketsDir: () => string | undefined; + setCurrentTicketsDir: (dir: string | undefined) => void; + refreshAllProviders: () => Promise; + setTicketsDir: (dir: string | undefined) => Promise; } -let terminalManager: TerminalManager; -let webhookServer: WebhookServer; -let statusBarItem: vscode.StatusBarItem; -let createBarItem: vscode.StatusBarItem; -let launchManager: LaunchManager; -let issueTypeService: IssueTypeService; - -// TreeView providers -let statusProvider: StatusTreeProvider; -let inProgressProvider: TicketTreeProvider; -let queueProvider: TicketTreeProvider; -let completedProvider: TicketTreeProvider; +// --------------------------------------------------------------------------- +// Module state +// --------------------------------------------------------------------------- -// Current tickets directory let currentTicketsDir: string | undefined; -// Output channel for logging -let outputChannel: vscode.OutputChannel; - -// Extension context for use in commands -let extensionContext: vscode.ExtensionContext; - -/** - * Extension activation - */ -export async function activate( - context: vscode.ExtensionContext -): Promise { - // Store context for use in commands - extensionContext = context; - - // Create output channel for logging - outputChannel = vscode.window.createOutputChannel('Operator'); - context.subscriptions.push(outputChannel); - - // Initialize issue type service (fetches types from API) - issueTypeService = new IssueTypeService(outputChannel); - await issueTypeService.refresh(); - - terminalManager = new TerminalManager(); - terminalManager.setIssueTypeService(issueTypeService); - webhookServer = new WebhookServer(terminalManager); - launchManager = new LaunchManager(terminalManager); - - // Create status bar item - statusBarItem = vscode.window.createStatusBarItem( - vscode.StatusBarAlignment.Right, - 100 - ); - statusBarItem.command = 'operator.showStatus'; - context.subscriptions.push(statusBarItem); +// --------------------------------------------------------------------------- +// Launch commands +// --------------------------------------------------------------------------- - // Create "New" status bar item - createBarItem = vscode.window.createStatusBarItem( - vscode.StatusBarAlignment.Right, - 99 +function isTicketFile(filePath: string): boolean { + const normalized = filePath.replace(/\\/g, '/'); + return ( + (normalized.includes('.tickets/queue/') || + normalized.includes('.tickets/in-progress/')) && + normalized.endsWith('.md') ); - createBarItem.text = '$(add) New'; - createBarItem.tooltip = 'Create new delegator, issue type, or project'; - createBarItem.command = 'operator.showCreateMenu'; - createBarItem.show(); - context.subscriptions.push(createBarItem); - - // Create TreeView providers (with issue type service) - statusProvider = new StatusTreeProvider(context); - statusProvider.setWebhookServer(webhookServer); - inProgressProvider = new TicketTreeProvider('in-progress', issueTypeService, terminalManager); - queueProvider = new TicketTreeProvider('queue', issueTypeService); - completedProvider = new TicketTreeProvider('completed', issueTypeService); +} - // Register TreeViews - context.subscriptions.push( - vscode.window.registerTreeDataProvider('operator-status', statusProvider), - vscode.window.registerTreeDataProvider( - 'operator-in-progress', - inProgressProvider - ), - vscode.window.registerTreeDataProvider('operator-queue', queueProvider), - vscode.window.registerTreeDataProvider( - 'operator-completed', - completedProvider - ) - ); +async function launchTicketCommand( + ctx: CommandContext, + treeItem?: TicketItem +): Promise { + let ticket: TicketInfo | undefined; - // Register commands - context.subscriptions.push( - vscode.commands.registerCommand('operator.showStatus', showStatus), - vscode.commands.registerCommand('operator.refreshTickets', refreshAllProviders), - vscode.commands.registerCommand('operator.focusTicket', focusTicketTerminal), - vscode.commands.registerCommand('operator.openTicket', openTicketFile), - vscode.commands.registerCommand('operator.launchTicket', launchTicketCommand), - vscode.commands.registerCommand( - 'operator.launchTicketWithOptions', - launchTicketWithOptionsCommand - ), - vscode.commands.registerCommand('operator.relaunchTicket', relaunchTicketCommand), - vscode.commands.registerCommand( - 'operator.launchTicketFromEditor', - launchTicketFromEditorCommand - ), - vscode.commands.registerCommand( - 'operator.launchTicketFromEditorWithOptions', - launchTicketFromEditorWithOptionsCommand - ), - vscode.commands.registerCommand( - 'operator.downloadOperator', - downloadOperatorCommand - ), - vscode.commands.registerCommand('operator.pauseQueue', pauseQueueCommand), - vscode.commands.registerCommand('operator.resumeQueue', resumeQueueCommand), - vscode.commands.registerCommand('operator.syncKanban', syncKanbanCommand), - vscode.commands.registerCommand( - 'operator.approveReview', - approveReviewCommand - ), - vscode.commands.registerCommand( - 'operator.rejectReview', - rejectReviewCommand - ), - vscode.commands.registerCommand( - 'operator.startOperatorServer', - startOperatorServerCommand - ), - vscode.commands.registerCommand( - 'operator.selectWorkingDirectory', - async () => { - const operatorPath = await getOperatorPath(extensionContext); - await selectWorkingDirectory(extensionContext, operatorPath ?? undefined); - } - ), - vscode.commands.registerCommand( - 'operator.runSetup', - async () => { - const workingDir = extensionContext.globalState.get('operator.workingDirectory'); - if (!workingDir) { - await vscode.commands.executeCommand('operator.selectWorkingDirectory'); - return; - } + if (treeItem?.ticket) { + ticket = treeItem.ticket; + } else { + const tickets = ctx.queueProvider.getTickets(); + if (tickets.length === 0) { + void vscode.window.showInformationMessage('No tickets in queue'); + return; + } + ticket = await showTicketPicker(tickets); + } - const choice = await vscode.window.showInformationMessage( - `Run operator setup in ${workingDir.replace(os.homedir(), '~')}?`, - 'Yes', - 'Cancel' - ); + if (!ticket) { + return; + } - if (choice !== 'Yes') { - return; - } + await ctx.launchManager.launchTicket(ticket, { + delegator: null, + model: 'sonnet', + yoloMode: false, + resumeSession: false, + }); +} - const operatorPath = await getOperatorPath(extensionContext); - const success = await initializeTicketsDirectory(workingDir, operatorPath ?? undefined); - - if (success) { - // Use the known working dir directly — findParentTicketsDir searches - // relative to workspace folder and may not find the newly created dir - currentTicketsDir = path.join(workingDir, '.tickets'); - await setTicketsDir(currentTicketsDir); - - const watcher = vscode.workspace.createFileSystemWatcher( - new vscode.RelativePattern(currentTicketsDir, '**/*.md') - ); - watcher.onDidChange(() => refreshAllProviders()); - watcher.onDidCreate(() => refreshAllProviders()); - watcher.onDidDelete(() => refreshAllProviders()); - extensionContext.subscriptions.push(watcher); - - await updateOperatorContext(); - void vscode.window.showInformationMessage('Operator setup completed successfully.'); - } else { - void vscode.window.showErrorMessage('Failed to run operator setup.'); - } - } - ), - vscode.commands.registerCommand( - 'operator.checkKanbanConnection', - () => checkKanbanConnection(extensionContext) - ), - vscode.commands.registerCommand( - 'operator.configureJira', - () => configureJira(extensionContext) - ), - vscode.commands.registerCommand( - 'operator.configureLinear', - () => configureLinear(extensionContext) - ), - vscode.commands.registerCommand( - 'operator.startKanbanOnboarding', - () => startKanbanOnboarding(extensionContext) - ), - vscode.commands.registerCommand( - 'operator.startGitOnboarding', - () => startGitOnboarding().then(() => refreshAllProviders()) - ), - vscode.commands.registerCommand( - 'operator.configureGitHub', - () => onboardGitHub().then(() => refreshAllProviders()) - ), - vscode.commands.registerCommand( - 'operator.configureGitLab', - () => onboardGitLab().then(() => refreshAllProviders()) - ), - vscode.commands.registerCommand( - 'operator.showCreateMenu', - showCreateMenu - ), - vscode.commands.registerCommand( - 'operator.openCreateDelegator', - (tool?: string, model?: string) => openCreateDelegator(tool, model) - ), - vscode.commands.registerCommand( - 'operator.detectLlmTools', - () => detectLlmTools(extensionContext, getOperatorPath) - ), - vscode.commands.registerCommand('operator.openWalkthrough', openWalkthrough), - vscode.commands.registerCommand('operator.openSettings', () => - ConfigPanel.createOrShow(context.extensionUri) - ), - vscode.commands.registerCommand( - 'operator.syncKanbanCollection', - syncKanbanCollectionCommand - ), - vscode.commands.registerCommand( - 'operator.addJiraProject', - (workspaceKey: string) => addJiraProjectCommand(workspaceKey) - ), - vscode.commands.registerCommand( - 'operator.addLinearTeam', - (workspaceKey: string) => addLinearTeamCommand(workspaceKey) - ), - vscode.commands.registerCommand( - 'operator.revealTicketsDir', - revealTicketsDirCommand - ), - vscode.commands.registerCommand( - 'operator.startWebhookServer', - startServer - ), - vscode.commands.registerCommand( - 'operator.connectMcpServer', - () => connectMcpServer(currentTicketsDir) - ) - ); +async function launchTicketWithOptionsCommand( + ctx: CommandContext, + treeItem?: TicketItem +): Promise { + let ticket: TicketInfo | undefined; - // Find tickets directory (check parent first, then workspace) - currentTicketsDir = await findParentTicketsDir(); - await setTicketsDir(currentTicketsDir); + if (treeItem?.ticket) { + ticket = treeItem.ticket; + } else { + const tickets = ctx.queueProvider.getTickets(); + if (tickets.length === 0) { + void vscode.window.showInformationMessage('No tickets in queue'); + return; + } + ticket = await showTicketPicker(tickets); + } - // Set up file watcher if tickets directory exists - if (currentTicketsDir) { - const watcher = vscode.workspace.createFileSystemWatcher( - new vscode.RelativePattern(currentTicketsDir, '**/*.md') - ); - watcher.onDidChange(() => refreshAllProviders()); - watcher.onDidCreate(() => refreshAllProviders()); - watcher.onDidDelete(() => refreshAllProviders()); - context.subscriptions.push(watcher); + if (!ticket) { + return; } - // Auto-start if configured and config.toml exists - const autoStart = vscode.workspace - .getConfiguration('operator') - .get('autoStart', true); - if (autoStart) { - const hasConfig = await configFileExists(); - if (hasConfig) { - await startServer(); - } else { - showConfigMissingNotification(); - } + const metadata = await parseTicketMetadata(ticket.filePath); + const hasSession = metadata ? !!getCurrentSessionId(metadata) : false; + + const options = await showLaunchOptionsDialog(ticket, hasSession); + if (!options) { + return; } - updateStatusBar(); + await ctx.launchManager.launchTicket(ticket, options); +} - // Set initial context for command visibility - await updateOperatorContext(); +async function relaunchTicketCommand( + ctx: CommandContext, + ticket: TicketInfo +): Promise { + await ctx.launchManager.offerRelaunch(ticket); +} - // Restore working directory from persistent VS Code settings if globalState is empty - const configWorkingDir = vscode.workspace.getConfiguration('operator').get('workingDirectory'); - if (configWorkingDir && !context.globalState.get('operator.workingDirectory')) { - await context.globalState.update('operator.workingDirectory', configWorkingDir); +async function launchTicketFromEditorCommand( + ctx: CommandContext +): Promise { + const editor = vscode.window.activeTextEditor; + if (!editor) { + void vscode.window.showWarningMessage('No active editor'); + return; } - // Auto-open walkthrough for new users with no working directory - const workingDirectory = context.globalState.get('operator.workingDirectory'); - if (!workingDirectory) { - void vscode.commands.executeCommand( - 'workbench.action.openWalkthrough', - 'untra.operator-terminals#operator-setup', - false + const filePath = editor.document.uri.fsPath; + if (!isTicketFile(filePath)) { + void vscode.window.showWarningMessage( + 'Current file is not a ticket in .tickets/ directory' ); + return; } -} -/** - * Find .tickets directory - check parent directory first, then workspace - */ -async function findParentTicketsDir(): Promise { - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (!workspaceFolder) { - return undefined; + const metadata = await parseTicketMetadata(filePath); + if (!metadata?.id) { + void vscode.window.showErrorMessage('Could not parse ticket ID from file'); + return; } - // First check parent directory for .tickets (monorepo setup) - const parentDir = path.dirname(workspaceFolder.uri.fsPath); - const parentTicketsPath = path.join(parentDir, '.tickets'); + const apiClient = new OperatorApiClient(); try { - await fs.access(parentTicketsPath); - return parentTicketsPath; + await apiClient.health(); } catch { - // Parent doesn't have .tickets, check workspace + void vscode.window.showErrorMessage( + 'Operator API not running. Start operator first.' + ); + return; } - // Fall back to configured tickets directory in workspace - const configuredDir = vscode.workspace - .getConfiguration('operator') - .get('ticketsDir', '.tickets'); + try { + const response = await apiClient.launchTicket(metadata.id, { + delegator: null, + provider: null, + wrapper: 'vscode', + model: 'sonnet', + yolo_mode: false, + retry_reason: null, + resume_session_id: null, + }); + + ctx.terminalManager.create({ + name: response.terminal_name, + workingDir: response.working_directory, + }); + ctx.terminalManager.send(response.terminal_name, response.command); + ctx.terminalManager.focus(response.terminal_name); - const ticketsPath = path.isAbsolute(configuredDir) - ? configuredDir - : path.join(workspaceFolder.uri.fsPath, configuredDir); + const worktreeMsg = response.worktree_created ? ' (worktree created)' : ''; + void vscode.window.showInformationMessage( + `Launched agent for ${response.ticket_id}${worktreeMsg}` + ); - try { - await fs.access(ticketsPath); - return ticketsPath; - } catch { - return undefined; + await ctx.refreshAllProviders(); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + void vscode.window.showErrorMessage(`Failed to launch: ${msg}`); } } -/** - * Find the .tickets directory for webhook session file. - * Walks up from workspace to find existing .tickets, or creates in parent (org level). - */ -async function findTicketsDir(): Promise { - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (!workspaceFolder) { - return undefined; +async function launchTicketFromEditorWithOptionsCommand( + ctx: CommandContext +): Promise { + const editor = vscode.window.activeTextEditor; + if (!editor) { + void vscode.window.showWarningMessage('No active editor'); + return; } - const configuredDir = vscode.workspace - .getConfiguration('operator') - .get('ticketsDir', '.tickets'); - - // If absolute path configured, check if it exists - if (path.isAbsolute(configuredDir)) { - try { - await fs.access(configuredDir); - return configuredDir; - } catch { - return undefined; - } + const filePath = editor.document.uri.fsPath; + if (!isTicketFile(filePath)) { + void vscode.window.showWarningMessage( + 'Current file is not a ticket in .tickets/ directory' + ); + return; } - // Walk up from workspace to find existing .tickets directory - let currentDir = workspaceFolder.uri.fsPath; - const root = path.parse(currentDir).root; - - while (currentDir !== root) { - const ticketsPath = path.join(currentDir, configuredDir); - try { - await fs.access(ticketsPath); - return ticketsPath; // Found existing .tickets - } catch { - // Not found, try parent - currentDir = path.dirname(currentDir); - } + const metadata = await parseTicketMetadata(filePath); + if (!metadata?.id) { + void vscode.window.showErrorMessage('Could not parse ticket ID from file'); + return; } - // Not found anywhere - return undefined; -} + const ticketType = ctx.issueTypeService.extractTypeFromId(metadata.id); + const ticketStatus = (metadata.status === 'in-progress' || metadata.status === 'completed') + ? metadata.status as 'in-progress' | 'completed' + : 'queue' as const; + const ticketInfo: TicketInfo = { + id: metadata.id, + type: ticketType, + title: 'Ticket from editor', + status: ticketStatus, + filePath: filePath, + }; -/** - * Find the directory to run the operator server in. - * Prefers parent directory if it has .tickets/operator/, otherwise uses workspace. - */ -async function findOperatorServerDir(): Promise { - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (!workspaceFolder) { - return undefined; + const hasSession = !!getCurrentSessionId(metadata); + const options = await showLaunchOptionsDialog(ticketInfo, hasSession); + if (!options) { + return; } - const workspaceDir = workspaceFolder.uri.fsPath; - const parentDir = path.dirname(workspaceDir); + const apiClient = new OperatorApiClient(); - // Check if parent has .tickets/operator/ (initialized operator setup) - const parentOperatorPath = path.join(parentDir, '.tickets', 'operator'); try { - await fs.access(parentOperatorPath); - return parentDir; // Parent has initialized operator + await apiClient.health(); } catch { - // Parent doesn't have .tickets/operator + void vscode.window.showErrorMessage( + 'Operator API not running. Start operator first.' + ); + return; } - // Fall back to workspace directory - return workspaceDir; -} + try { + const response = await apiClient.launchTicket(metadata.id, { + delegator: options.delegator ?? null, + provider: null, + wrapper: 'vscode', + model: options.model, + yolo_mode: options.yoloMode, + retry_reason: null, + resume_session_id: null, + }); -/** - * Set tickets directory for all providers - */ -async function setTicketsDir(dir: string | undefined): Promise { - await statusProvider.setTicketsDir(dir); - await inProgressProvider.setTicketsDir(dir); - await queueProvider.setTicketsDir(dir); - await completedProvider.setTicketsDir(dir); - launchManager.setTicketsDir(dir); -} + ctx.terminalManager.create({ + name: response.terminal_name, + workingDir: response.working_directory, + }); + ctx.terminalManager.send(response.terminal_name, response.command); + ctx.terminalManager.focus(response.terminal_name); -/** - * Refresh all TreeView providers - */ -async function refreshAllProviders(): Promise { - await statusProvider.refresh(); - await inProgressProvider.refresh(); - await queueProvider.refresh(); - await completedProvider.refresh(); + const worktreeMsg = response.worktree_created ? ' (worktree created)' : ''; + void vscode.window.showInformationMessage( + `Launched agent for ${response.ticket_id}${worktreeMsg}` + ); + + await ctx.refreshAllProviders(); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + void vscode.window.showErrorMessage(`Failed to launch: ${msg}`); + } } -/** - * Focus a terminal by name, or offer relaunch if not found - */ async function focusTicketTerminal( + ctx: CommandContext, terminalName: string, ticket?: TicketInfo ): Promise { - if (terminalManager.exists(terminalName)) { - terminalManager.focus(terminalName); + if (ctx.terminalManager.exists(terminalName)) { + ctx.terminalManager.focus(terminalName); } else if (ticket) { - await launchManager.offerRelaunch(ticket); + await ctx.launchManager.offerRelaunch(ticket); } else { void vscode.window.showWarningMessage(`Terminal '${terminalName}' not found`); } } -/** - * Open a ticket file - */ function openTicketFile(filePath: string): void { void vscode.workspace.openTextDocument(filePath).then((doc) => { void vscode.window.showTextDocument(doc); }); } -/** - * Start the webhook server - */ -async function startServer(): Promise { - // Require config.toml before starting webhook server +// --------------------------------------------------------------------------- +// Server commands +// --------------------------------------------------------------------------- + +async function startServer(ctx: CommandContext): Promise { const hasConfig = await configFileExists(); if (!hasConfig) { showConfigMissingNotification(); return; } - if (webhookServer.isRunning()) { - // Re-register session file if it was lost (fixes status showing "Stopped") + if (ctx.webhookServer.isRunning()) { const ticketsDir = await findTicketsDir(); if (ticketsDir) { - await webhookServer.ensureSessionFile(ticketsDir); + await ctx.webhookServer.ensureSessionFile(ticketsDir); } void vscode.window.showInformationMessage( - `Webhook connected on port ${webhookServer.getPort()}` + `Webhook connected on port ${ctx.webhookServer.getPort()}` ); - await refreshAllProviders(); + await ctx.refreshAllProviders(); return; } try { - // Find tickets directory for session file const ticketsDir = await findTicketsDir(); + await ctx.webhookServer.start(ticketsDir); - // Start server with optional session file registration - await webhookServer.start(ticketsDir); - - const port = webhookServer.getPort(); - const configuredPort = webhookServer.getConfiguredPort(); + const port = ctx.webhookServer.getPort(); + const configuredPort = ctx.webhookServer.getConfiguredPort(); if (port !== configuredPort) { void vscode.window.showInformationMessage( @@ -555,22 +364,19 @@ async function startServer(): Promise { ); } - updateStatusBar(); - await refreshAllProviders(); + updateStatusBar(ctx); + await ctx.refreshAllProviders(); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; void vscode.window.showErrorMessage(`Failed to start webhook server: ${msg}`); } } -/** - * Show server status - */ -function showStatus(): void { - const running = webhookServer.isRunning(); - const port = webhookServer.getPort(); - const configuredPort = webhookServer.getConfiguredPort(); - const terminals = terminalManager.list(); +function showStatus(ctx: CommandContext): void { + const running = ctx.webhookServer.isRunning(); + const port = ctx.webhookServer.getPort(); + const configuredPort = ctx.webhookServer.getConfiguredPort(); + const terminals = ctx.terminalManager.list(); let message: string; if (running) { @@ -586,146 +392,127 @@ function showStatus(): void { void vscode.window.showInformationMessage(message); } -/** - * Update status bar appearance - */ -function updateStatusBar(): void { - if (webhookServer.isRunning()) { - const port = webhookServer.getPort(); - statusBarItem.text = `$(terminal) Operator :${port}`; - statusBarItem.tooltip = `Operator webhook server running on port ${port}`; - statusBarItem.backgroundColor = undefined; +function updateStatusBar(ctx: CommandContext): void { + if (ctx.webhookServer.isRunning()) { + const port = ctx.webhookServer.getPort(); + ctx.statusBarItem.text = `$(terminal) Operator :${port}`; + ctx.statusBarItem.tooltip = `Operator webhook server running on port ${port}`; + ctx.statusBarItem.backgroundColor = undefined; } else { - statusBarItem.text = '$(terminal) Operator (off)'; - statusBarItem.tooltip = 'Operator webhook server stopped'; - statusBarItem.backgroundColor = new vscode.ThemeColor( + ctx.statusBarItem.text = '$(terminal) Operator (off)'; + ctx.statusBarItem.tooltip = 'Operator webhook server stopped'; + ctx.statusBarItem.backgroundColor = new vscode.ThemeColor( 'statusBarItem.warningBackground' ); } - statusBarItem.show(); + ctx.statusBarItem.show(); } -/** - * Command: Launch ticket (quick, uses defaults) - * - * When invoked from inline button on tree item, the TicketItem is passed. - * When invoked from command palette, shows a ticket picker. - */ -async function launchTicketCommand(treeItem?: TicketItem): Promise { - let ticket: TicketInfo | undefined; +// --------------------------------------------------------------------------- +// Queue commands +// --------------------------------------------------------------------------- - // If called from inline button, treeItem contains the ticket - if (treeItem?.ticket) { - ticket = treeItem.ticket; - } else { - // Called from command palette - show picker - const tickets = queueProvider.getTickets(); - if (tickets.length === 0) { - void vscode.window.showInformationMessage('No tickets in queue'); - return; - } - ticket = await showTicketPicker(tickets); - } +async function pauseQueueCommand(ctx: CommandContext): Promise { + const apiClient = new OperatorApiClient(); - if (!ticket) { + try { + await apiClient.health(); + } catch { + void vscode.window.showErrorMessage( + 'Operator API not running. Start operator first.' + ); return; } - await launchManager.launchTicket(ticket, { - delegator: null, - model: 'sonnet', - yoloMode: false, - resumeSession: false, - }); + try { + const result = await apiClient.pauseQueue(); + void vscode.window.showInformationMessage(result.message); + await ctx.refreshAllProviders(); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + void vscode.window.showErrorMessage(`Failed to pause queue: ${msg}`); + } } -/** - * Command: Launch ticket with options dialog - * - * When invoked from inline button on tree item, the TicketItem is passed. - * When invoked from command palette, shows a ticket picker. - */ -async function launchTicketWithOptionsCommand( - treeItem?: TicketItem -): Promise { - let ticket: TicketInfo | undefined; - - // If called from inline button, treeItem contains the ticket - if (treeItem?.ticket) { - ticket = treeItem.ticket; - } else { - // Called from command palette - show picker - const tickets = queueProvider.getTickets(); - if (tickets.length === 0) { - void vscode.window.showInformationMessage('No tickets in queue'); - return; - } - ticket = await showTicketPicker(tickets); - } +async function resumeQueueCommand(ctx: CommandContext): Promise { + const apiClient = new OperatorApiClient(); - if (!ticket) { + try { + await apiClient.health(); + } catch { + void vscode.window.showErrorMessage( + 'Operator API not running. Start operator first.' + ); return; } - const metadata = await parseTicketMetadata(ticket.filePath); - const hasSession = metadata ? !!getCurrentSessionId(metadata) : false; - - const options = await showLaunchOptionsDialog(ticket, hasSession); - if (!options) { - return; + try { + const result = await apiClient.resumeQueue(); + void vscode.window.showInformationMessage(result.message); + await ctx.refreshAllProviders(); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + void vscode.window.showErrorMessage(`Failed to resume queue: ${msg}`); } - - await launchManager.launchTicket(ticket, options); -} - -/** - * Command: Relaunch in-progress ticket - */ -async function relaunchTicketCommand(ticket: TicketInfo): Promise { - await launchManager.offerRelaunch(ticket); } -/** - * Check if a file path is a ticket file in the .tickets directory - */ -function isTicketFile(filePath: string): boolean { - const normalized = filePath.replace(/\\/g, '/'); - return ( - (normalized.includes('.tickets/queue/') || - normalized.includes('.tickets/in-progress/')) && - normalized.endsWith('.md') - ); -} +// --------------------------------------------------------------------------- +// Review commands +// --------------------------------------------------------------------------- -/** - * Command: Launch ticket from the active editor - * - * Uses the Operator API to properly claim the ticket and track state. - */ -async function launchTicketFromEditorCommand(): Promise { - const editor = vscode.window.activeTextEditor; - if (!editor) { - void vscode.window.showWarningMessage('No active editor'); - return; - } +async function showAwaitingAgentPicker( + _apiClient: OperatorApiClient +): Promise { + try { + const response = await fetch( + `${vscode.workspace.getConfiguration('operator').get('apiUrl', 'http://localhost:7008')}/api/v1/agents/active` + ); + if (!response.ok) { + void vscode.window.showErrorMessage('Failed to fetch active agents'); + return undefined; + } + const data = (await response.json()) as { + agents: Array<{ + id: string; + ticket_id: string; + project: string; + status: string; + }>; + }; - const filePath = editor.document.uri.fsPath; - if (!isTicketFile(filePath)) { - void vscode.window.showWarningMessage( - 'Current file is not a ticket in .tickets/ directory' + const awaitingAgents = data.agents.filter( + (a) => a.status === 'awaiting_input' ); - return; - } - const metadata = await parseTicketMetadata(filePath); - if (!metadata?.id) { - void vscode.window.showErrorMessage('Could not parse ticket ID from file'); - return; + if (awaitingAgents.length === 0) { + void vscode.window.showInformationMessage('No agents awaiting review'); + return undefined; + } + + const items = awaitingAgents.map((a) => ({ + label: a.ticket_id, + description: a.project, + detail: `Agent: ${a.id}`, + agentId: a.id, + })); + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: 'Select agent to review', + }); + + return selected?.agentId; + } catch { + void vscode.window.showErrorMessage('Failed to fetch agents'); + return undefined; } +} +async function approveReviewCommand( + ctx: CommandContext, + agentId: string +): Promise { const apiClient = new OperatorApiClient(); - - // Check if Operator API is running + let selectedAgentId: string | undefined = agentId; try { await apiClient.health(); } catch { @@ -735,85 +522,77 @@ async function launchTicketFromEditorCommand(): Promise { return; } - // Launch via Operator API - try { - const response = await apiClient.launchTicket(metadata.id, { - delegator: null, - provider: null, - wrapper: 'vscode', - model: 'sonnet', - yolo_mode: false, - retry_reason: null, - resume_session_id: null, - }); - - // Create terminal and execute command - terminalManager.create({ - name: response.terminal_name, - workingDir: response.working_directory, - }); - terminalManager.send(response.terminal_name, response.command); - terminalManager.focus(response.terminal_name); - - const worktreeMsg = response.worktree_created ? ' (worktree created)' : ''; - void vscode.window.showInformationMessage( - `Launched agent for ${response.ticket_id}${worktreeMsg}` - ); + if (!agentId) { + selectedAgentId = await showAwaitingAgentPicker(apiClient); + if (!selectedAgentId) { + return; + } + } - // Refresh ticket providers to reflect the change - await refreshAllProviders(); + try { + const result = await apiClient.approveReview(selectedAgentId); + void vscode.window.showInformationMessage(result.message); + await ctx.refreshAllProviders(); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; - void vscode.window.showErrorMessage(`Failed to launch: ${msg}`); + void vscode.window.showErrorMessage(`Failed to approve review: ${msg}`); } } -/** - * Command: Launch ticket from editor with options dialog - */ -async function launchTicketFromEditorWithOptionsCommand(): Promise { - const editor = vscode.window.activeTextEditor; - if (!editor) { - void vscode.window.showWarningMessage('No active editor'); - return; - } - - const filePath = editor.document.uri.fsPath; - if (!isTicketFile(filePath)) { - void vscode.window.showWarningMessage( - 'Current file is not a ticket in .tickets/ directory' +async function rejectReviewCommand( + ctx: CommandContext, + agentId: string +): Promise { + const apiClient = new OperatorApiClient(); + let selectedAgentId: string | undefined = agentId; + try { + await apiClient.health(); + } catch { + void vscode.window.showErrorMessage( + 'Operator API not running. Start operator first.' ); return; } - const metadata = await parseTicketMetadata(filePath); - if (!metadata?.id) { - void vscode.window.showErrorMessage('Could not parse ticket ID from file'); - return; + if (!agentId) { + selectedAgentId = await showAwaitingAgentPicker(apiClient); + if (!selectedAgentId) { + return; + } } - // Create a minimal TicketInfo for the dialog - const ticketType = issueTypeService.extractTypeFromId(metadata.id); - const ticketStatus = (metadata.status === 'in-progress' || metadata.status === 'completed') - ? metadata.status as 'in-progress' | 'completed' - : 'queue' as const; - const ticketInfo: TicketInfo = { - id: metadata.id, - type: ticketType, - title: 'Ticket from editor', - status: ticketStatus, - filePath: filePath, - }; + const reason = await vscode.window.showInputBox({ + prompt: 'Enter rejection reason', + placeHolder: 'Why is this being rejected?', + validateInput: (value) => { + if (!value || value.trim().length === 0) { + return 'Rejection reason is required'; + } + return null; + }, + }); - const hasSession = !!getCurrentSessionId(metadata); - const options = await showLaunchOptionsDialog(ticketInfo, hasSession); - if (!options) { + if (!reason) { return; } + try { + const result = await apiClient.rejectReview(selectedAgentId, reason); + void vscode.window.showInformationMessage(result.message); + await ctx.refreshAllProviders(); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + void vscode.window.showErrorMessage(`Failed to reject review: ${msg}`); + } +} + +// --------------------------------------------------------------------------- +// Kanban commands +// --------------------------------------------------------------------------- + +async function syncKanbanCommand(ctx: CommandContext): Promise { const apiClient = new OperatorApiClient(); - // Check if Operator API is running try { await apiClient.health(); } catch { @@ -823,68 +602,159 @@ async function launchTicketFromEditorWithOptionsCommand(): Promise { return; } - // Launch via Operator API try { - const response = await apiClient.launchTicket(metadata.id, { - delegator: options.delegator ?? null, - provider: null, - wrapper: 'vscode', - model: options.model, - yolo_mode: options.yoloMode, - retry_reason: null, - resume_session_id: null, - }); - - // Create terminal and execute command - terminalManager.create({ - name: response.terminal_name, - workingDir: response.working_directory, - }); - terminalManager.send(response.terminal_name, response.command); - terminalManager.focus(response.terminal_name); + const result = await apiClient.syncKanban(); + const message = `Synced: ${result.created.length} created, ${result.skipped.length} skipped`; + if (result.errors.length > 0) { + void vscode.window.showWarningMessage( + `${message}, ${result.errors.length} errors` + ); + } else { + void vscode.window.showInformationMessage(message); + } + await ctx.refreshAllProviders(); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + void vscode.window.showErrorMessage(`Failed to sync kanban: ${msg}`); + } +} - const worktreeMsg = response.worktree_created ? ' (worktree created)' : ''; - void vscode.window.showInformationMessage( - `Launched agent for ${response.ticket_id}${worktreeMsg}` +async function syncKanbanCollectionCommand( + ctx: CommandContext, + item: StatusItem +): Promise { + const provider = item.provider; + const projectKey = item.projectKey; + + if (!provider || !projectKey) { + void vscode.window.showWarningMessage('No collection selected for sync.'); + return; + } + + const apiClient = new OperatorApiClient(); + + try { + await apiClient.health(); + } catch { + void vscode.window.showErrorMessage( + 'Operator API not running. Start operator first.' ); + return; + } - // Refresh ticket providers to reflect the change - await refreshAllProviders(); + try { + const result = await apiClient.syncKanbanCollection(provider, projectKey); + const createdList = result.created.length > 0 + ? ` (${result.created.join(', ')})` + : ''; + const message = `Synced ${projectKey}: ${result.created.length} created${createdList}, ${result.skipped.length} skipped`; + if (result.errors.length > 0) { + void vscode.window.showWarningMessage(`${message}, ${result.errors.length} errors`); + } else { + void vscode.window.showInformationMessage(message); + } + await ctx.refreshAllProviders(); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; - void vscode.window.showErrorMessage(`Failed to launch: ${msg}`); + void vscode.window.showErrorMessage(`Failed to sync collection: ${msg}`); } } -/** - * Update context variables for command visibility - */ -async function updateOperatorContext(): Promise { - const operatorAvailable = await isOperatorAvailable(extensionContext); +async function addJiraProjectCommand( + ctx: CommandContext, + workspaceKey: string +): Promise { + await addJiraProject(ctx.extensionContext, workspaceKey); + await ctx.refreshAllProviders(); +} + +async function addLinearTeamCommand( + ctx: CommandContext, + workspaceKey: string +): Promise { + await addLinearTeam(ctx.extensionContext, workspaceKey); + await ctx.refreshAllProviders(); +} + +// --------------------------------------------------------------------------- +// Setup commands +// --------------------------------------------------------------------------- + +function showConfigMissingNotification(): void { + void vscode.window.showInformationMessage( + 'Could not find Operator! configuration file for this repository workspace. Run the setup walkthrough to create it and get started.', + 'Open Setup' + ).then((choice) => { + if (choice === 'Open Setup') { + void vscode.commands.executeCommand( + 'workbench.action.openWalkthrough', + 'untra.operator-terminals#operator-setup', + true + ); + } + }); +} + +async function updateOperatorContext(ctx: CommandContext): Promise { + const operatorAvailable = await isOperatorAvailable(ctx.extensionContext); await vscode.commands.executeCommand( 'setContext', 'operator.operatorAvailable', operatorAvailable ); - // Check if parent directory has .tickets/ - const ticketsParentFound = currentTicketsDir !== undefined; + const ticketsParentFound = ctx.getCurrentTicketsDir() !== undefined; await vscode.commands.executeCommand( 'setContext', 'operator.ticketsParentFound', ticketsParentFound ); - // Update walkthrough context keys - await updateWalkthroughContext(extensionContext); + await updateWalkthroughContext(ctx.extensionContext); } -/** - * Command: Download Operator binary - */ -async function downloadOperatorCommand(): Promise { - // Check if already installed - const existingPath = await getOperatorPath(extensionContext); +async function runSetupCommand(ctx: CommandContext): Promise { + const workingDir = ctx.extensionContext.globalState.get('operator.workingDirectory'); + if (!workingDir) { + await vscode.commands.executeCommand('operator.selectWorkingDirectory'); + return; + } + + const choice = await vscode.window.showInformationMessage( + `Run operator setup in ${workingDir.replace(os.homedir(), '~')}?`, + 'Yes', + 'Cancel' + ); + + if (choice !== 'Yes') { + return; + } + + const operatorPath = await getOperatorPath(ctx.extensionContext); + const success = await initializeTicketsDirectory(workingDir, operatorPath ?? undefined); + + if (success) { + const ticketsDir = path.join(workingDir, '.tickets'); + ctx.setCurrentTicketsDir(ticketsDir); + await ctx.setTicketsDir(ticketsDir); + + const watcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(ticketsDir, '**/*.md') + ); + watcher.onDidChange(() => void ctx.refreshAllProviders()); + watcher.onDidCreate(() => void ctx.refreshAllProviders()); + watcher.onDidDelete(() => void ctx.refreshAllProviders()); + ctx.extensionContext.subscriptions.push(watcher); + + await updateOperatorContext(ctx); + void vscode.window.showInformationMessage('Operator setup completed successfully.'); + } else { + void vscode.window.showErrorMessage('Failed to run operator setup.'); + } +} + +async function downloadOperatorCommand(ctx: CommandContext): Promise { + const existingPath = await getOperatorPath(ctx.extensionContext); if (existingPath) { const version = await getOperatorVersion(existingPath); const choice = await vscode.window.showInformationMessage( @@ -905,22 +775,18 @@ async function downloadOperatorCommand(): Promise { } try { - const downloadedPath = await downloadOperator(extensionContext); + const downloadedPath = await downloadOperator(ctx.extensionContext); const version = await getOperatorVersion(downloadedPath); void vscode.window.showInformationMessage( `Operator ${version ?? getExtensionVersion()} downloaded successfully to ${downloadedPath}` ); - // Update context for command visibility - await updateOperatorContext(); - - // Refresh status provider - await refreshAllProviders(); + await updateOperatorContext(ctx); + await ctx.refreshAllProviders(); } catch (error) { const msg = error instanceof Error ? error.message : 'Unknown error'; - // Offer to open downloads page on failure const choice = await vscode.window.showErrorMessage( `Failed to download Operator: ${msg}`, 'Open Downloads Page', @@ -935,18 +801,14 @@ async function downloadOperatorCommand(): Promise { } } -/** - * Command: Start Operator API server - */ -async function startOperatorServerCommand(): Promise { - // Ensure config.toml exists before starting the server +async function startOperatorServerCommand(ctx: CommandContext): Promise { const hasConfig = await configFileExists(); if (!hasConfig) { showConfigMissingNotification(); return; } - const operatorPath = await getOperatorPath(extensionContext); + const operatorPath = await getOperatorPath(ctx.extensionContext); if (!operatorPath) { const choice = await vscode.window.showErrorMessage( @@ -956,19 +818,17 @@ async function startOperatorServerCommand(): Promise { ); if (choice === 'Download Operator') { - await downloadOperatorCommand(); + await downloadOperatorCommand(ctx); } return; } - // Find the directory to run the operator server in const serverDir = await findOperatorServerDir(); if (!serverDir) { void vscode.window.showErrorMessage('No workspace folder found.'); return; } - // Check if Operator is already running const apiClient = new OperatorApiClient(); try { await apiClient.health(); @@ -978,321 +838,46 @@ async function startOperatorServerCommand(): Promise { // Not running, proceed to start } - // Create terminal and run operator api const terminalName = 'Operator API'; - if (terminalManager.exists(terminalName)) { - terminalManager.focus(terminalName); + if (ctx.terminalManager.exists(terminalName)) { + ctx.terminalManager.focus(terminalName); return; } - terminalManager.create({ + ctx.terminalManager.create({ name: terminalName, workingDir: serverDir, }); - terminalManager.send(terminalName, `"${operatorPath}" api`); - terminalManager.focus(terminalName); + ctx.terminalManager.send(terminalName, `"${operatorPath}" api`); + ctx.terminalManager.focus(terminalName); void vscode.window.showInformationMessage( `Starting Operator API server in ${serverDir}...` ); - // Wait a moment and refresh providers to pick up the new status setTimeout(() => { - void refreshAllProviders(); + void ctx.refreshAllProviders(); }, 2000); } -/** - * Command: Pause queue processing - */ -async function pauseQueueCommand(): Promise { - const apiClient = new OperatorApiClient(); - - try { - await apiClient.health(); - } catch { - void vscode.window.showErrorMessage( - 'Operator API not running. Start operator first.' - ); - return; - } - - try { - const result = await apiClient.pauseQueue(); - void vscode.window.showInformationMessage(result.message); - await refreshAllProviders(); - } catch (err) { - const msg = err instanceof Error ? err.message : 'Unknown error'; - void vscode.window.showErrorMessage(`Failed to pause queue: ${msg}`); - } -} - -/** - * Command: Resume queue processing - */ -async function resumeQueueCommand(): Promise { - const apiClient = new OperatorApiClient(); - - try { - await apiClient.health(); - } catch { - void vscode.window.showErrorMessage( - 'Operator API not running. Start operator first.' - ); - return; - } - - try { - const result = await apiClient.resumeQueue(); - void vscode.window.showInformationMessage(result.message); - await refreshAllProviders(); - } catch (err) { - const msg = err instanceof Error ? err.message : 'Unknown error'; - void vscode.window.showErrorMessage(`Failed to resume queue: ${msg}`); - } -} - -/** - * Command: Sync kanban collections - */ -async function syncKanbanCommand(): Promise { - const apiClient = new OperatorApiClient(); - - try { - await apiClient.health(); - } catch { - void vscode.window.showErrorMessage( - 'Operator API not running. Start operator first.' - ); - return; - } - - try { - const result = await apiClient.syncKanban(); - const message = `Synced: ${result.created.length} created, ${result.skipped.length} skipped`; - if (result.errors.length > 0) { - void vscode.window.showWarningMessage( - `${message}, ${result.errors.length} errors` - ); - } else { - void vscode.window.showInformationMessage(message); - } - await refreshAllProviders(); - } catch (err) { - const msg = err instanceof Error ? err.message : 'Unknown error'; - void vscode.window.showErrorMessage(`Failed to sync kanban: ${msg}`); - } -} - -/** - * Command: Approve agent review - */ -async function approveReviewCommand(agentId: string): Promise { - const apiClient = new OperatorApiClient(); - let selectedAgentId : string | undefined = agentId; - try { - await apiClient.health(); - } catch { - void vscode.window.showErrorMessage( - 'Operator API not running. Start operator first.' - ); - return; - } - - // If no agent ID provided, show picker for awaiting agents - if (!agentId) { - selectedAgentId = await showAwaitingAgentPicker(apiClient); - if (!selectedAgentId) { - return; - } - } - - try { - const result = await apiClient.approveReview(selectedAgentId); - void vscode.window.showInformationMessage(result.message); - await refreshAllProviders(); - } catch (err) { - const msg = err instanceof Error ? err.message : 'Unknown error'; - void vscode.window.showErrorMessage(`Failed to approve review: ${msg}`); - } -} - -/** - * Command: Reject agent review - */ -async function rejectReviewCommand(agentId: string): Promise { - const apiClient = new OperatorApiClient(); - let selectedAgentId : string | undefined = agentId; - try { - await apiClient.health(); - } catch { - void vscode.window.showErrorMessage( - 'Operator API not running. Start operator first.' - ); - return; - } - - // If no agent ID provided, show picker for awaiting agents - if (!agentId) { - selectedAgentId = await showAwaitingAgentPicker(apiClient); - if (!selectedAgentId) { - return; - } - } - - // Ask for rejection reason - const reason = await vscode.window.showInputBox({ - prompt: 'Enter rejection reason', - placeHolder: 'Why is this being rejected?', - validateInput: (value) => { - if (!value || value.trim().length === 0) { - return 'Rejection reason is required'; - } - return null; - }, - }); - - if (!reason) { - return; - } - - try { - const result = await apiClient.rejectReview(selectedAgentId, reason); - void vscode.window.showInformationMessage(result.message); - await refreshAllProviders(); - } catch (err) { - const msg = err instanceof Error ? err.message : 'Unknown error'; - void vscode.window.showErrorMessage(`Failed to reject review: ${msg}`); - } -} - -/** - * Helper: Show picker for agents awaiting review - */ -async function showAwaitingAgentPicker( - _apiClient: OperatorApiClient -): Promise { - // Fetch active agents from Operator API - try { - const response = await fetch( - `${vscode.workspace.getConfiguration('operator').get('apiUrl', 'http://localhost:7008')}/api/v1/agents/active` - ); - if (!response.ok) { - void vscode.window.showErrorMessage('Failed to fetch active agents'); - return undefined; - } - const data = (await response.json()) as { - agents: Array<{ - id: string; - ticket_id: string; - project: string; - status: string; - }>; - }; - - const awaitingAgents = data.agents.filter( - (a) => a.status === 'awaiting_input' - ); - - if (awaitingAgents.length === 0) { - void vscode.window.showInformationMessage('No agents awaiting review'); - return undefined; - } - - const items = awaitingAgents.map((a) => ({ - label: a.ticket_id, - description: a.project, - detail: `Agent: ${a.id}`, - agentId: a.id, - })); - - const selected = await vscode.window.showQuickPick(items, { - placeHolder: 'Select agent to review', - }); - - return selected?.agentId; - } catch (err) { - void vscode.window.showErrorMessage('Failed to fetch agents'); - return undefined; - } -} - -/** - * Command: Sync a specific kanban collection - */ -async function syncKanbanCollectionCommand(item: StatusItem): Promise { - const provider = item.provider; - const projectKey = item.projectKey; - - if (!provider || !projectKey) { - void vscode.window.showWarningMessage('No collection selected for sync.'); - return; - } - - const apiClient = new OperatorApiClient(); - - try { - await apiClient.health(); - } catch { - void vscode.window.showErrorMessage( - 'Operator API not running. Start operator first.' - ); - return; - } - - try { - const result = await apiClient.syncKanbanCollection(provider, projectKey); - const createdList = result.created.length > 0 - ? ` (${result.created.join(', ')})` - : ''; - const message = `Synced ${projectKey}: ${result.created.length} created${createdList}, ${result.skipped.length} skipped`; - if (result.errors.length > 0) { - void vscode.window.showWarningMessage(`${message}, ${result.errors.length} errors`); - } else { - void vscode.window.showInformationMessage(message); - } - await refreshAllProviders(); - } catch (err) { - const msg = err instanceof Error ? err.message : 'Unknown error'; - void vscode.window.showErrorMessage(`Failed to sync collection: ${msg}`); - } -} - -/** - * Command: Add a Jira project to an existing workspace - */ -async function addJiraProjectCommand(workspaceKey: string): Promise { - await addJiraProject(extensionContext, workspaceKey); - await refreshAllProviders(); -} - -/** - * Command: Add a Linear team to an existing workspace - */ -async function addLinearTeamCommand(workspaceKey: string): Promise { - await addLinearTeam(extensionContext, workspaceKey); - await refreshAllProviders(); -} - -/** - * Command: Reveal .tickets directory in the OS file explorer - */ -async function revealTicketsDirCommand(): Promise { - if (!currentTicketsDir) { +async function revealTicketsDirCommand(ctx: CommandContext): Promise { + const dir = ctx.getCurrentTicketsDir(); + if (!dir) { void vscode.window.showWarningMessage('No .tickets directory found.'); return; } - const uri = vscode.Uri.file(currentTicketsDir); + const uri = vscode.Uri.file(dir); await vscode.commands.executeCommand('revealFileInOS', uri); } -/** - * Command: Show "Create New" menu - */ -async function showCreateMenu(): Promise { +// --------------------------------------------------------------------------- +// Create commands +// --------------------------------------------------------------------------- + +async function showCreateMenu(ctx: CommandContext): Promise { const choice = await vscode.window.showQuickPick( [ { label: '$(rocket) New Delegator', detail: 'delegator', description: 'Create a tool+model pairing for autonomous launches' }, @@ -1309,24 +894,21 @@ async function showCreateMenu(): Promise { switch (choice.detail) { case 'delegator': - openCreateDelegator(); + openCreateDelegator(ctx); break; case 'issuetype': - ConfigPanel.createOrShow(extensionContext.extensionUri); + ConfigPanel.createOrShow(ctx.extensionContext.extensionUri); ConfigPanel.navigateTo('section-kanban', { action: 'createIssueType' }); break; case 'project': - ConfigPanel.createOrShow(extensionContext.extensionUri); + ConfigPanel.createOrShow(ctx.extensionContext.extensionUri); ConfigPanel.navigateTo('section-projects'); break; } } -/** - * Command: Open delegator creation, optionally pre-filled with tool+model - */ -function openCreateDelegator(tool?: string, model?: string): void { - ConfigPanel.createOrShow(extensionContext.extensionUri); +function openCreateDelegator(ctx: CommandContext, tool?: string, model?: string): void { + ConfigPanel.createOrShow(ctx.extensionContext.extensionUri); ConfigPanel.navigateTo('section-agents', { action: 'createDelegator', tool, @@ -1334,10 +916,285 @@ function openCreateDelegator(tool?: string, model?: string): void { }); } +// --------------------------------------------------------------------------- +// Extension activation +// --------------------------------------------------------------------------- + +export async function activate( + context: vscode.ExtensionContext +): Promise { + // Create output channel for logging + const outputChannel = vscode.window.createOutputChannel('Operator'); + context.subscriptions.push(outputChannel); + outputChannel.appendLine('[Operator] Activation started'); + + // Initialize issue type service (constructor is safe — no network calls) + const issueTypeService = new IssueTypeService(outputChannel); + + // Register tree view providers IMMEDIATELY so VS Code never shows + // "no data provider registered" — they start empty and populate async. + const statusProvider = new StatusTreeProvider(context); + const inProgressProvider = new TicketTreeProvider('in-progress', issueTypeService); + const queueProvider = new TicketTreeProvider('queue', issueTypeService); + const completedProvider = new TicketTreeProvider('completed', issueTypeService); + + const statusTreeView = vscode.window.createTreeView('operator-status', { + treeDataProvider: statusProvider, + }); + context.subscriptions.push( + statusTreeView, + vscode.window.registerTreeDataProvider('operator-in-progress', inProgressProvider), + vscode.window.registerTreeDataProvider('operator-queue', queueProvider), + vscode.window.registerTreeDataProvider('operator-completed', completedProvider) + ); + + // Synchronous object construction — these constructors do no I/O + const terminalManager = new TerminalManager(); + terminalManager.setIssueTypeService(issueTypeService); + inProgressProvider.setTerminalManager(terminalManager); + + const webhookServer = new WebhookServer(terminalManager); + const launchManager = new LaunchManager(terminalManager); + + statusProvider.setWebhookServer(webhookServer); + + // Create status bar items + const statusBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Right, + 100 + ); + statusBarItem.command = 'operator.showStatus'; + context.subscriptions.push(statusBarItem); + + const createBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Right, + 99 + ); + createBarItem.text = '$(add) New'; + createBarItem.tooltip = 'Create new delegator, issue type, or project'; + createBarItem.command = 'operator.showCreateMenu'; + createBarItem.show(); + context.subscriptions.push(createBarItem); + + // Build shared command context + const ctx: CommandContext = { + extensionContext: context, + terminalManager, + webhookServer, + launchManager, + issueTypeService, + statusProvider, + statusTreeView, + queueProvider, + inProgressProvider, + completedProvider, + statusBarItem, + createBarItem, + outputChannel, + getCurrentTicketsDir: () => currentTicketsDir, + setCurrentTicketsDir: (dir) => { currentTicketsDir = dir; }, + refreshAllProviders: async () => { + await statusProvider.refresh(); + await inProgressProvider.refresh(); + await queueProvider.refresh(); + await completedProvider.refresh(); + }, + setTicketsDir: async (dir) => { + await statusProvider.setTicketsDir(dir); + await inProgressProvider.setTicketsDir(dir); + await queueProvider.setTicketsDir(dir); + await completedProvider.setTicketsDir(dir); + launchManager.setTicketsDir(dir); + }, + }; + + // Register all commands BEFORE any async work — ensures commands are + // always available even if network/API initialization fails. + context.subscriptions.push( + vscode.commands.registerCommand('operator.showStatus', () => showStatus(ctx)), + vscode.commands.registerCommand('operator.refreshTickets', () => ctx.refreshAllProviders()), + vscode.commands.registerCommand('operator.focusTicket', + (name: string, ticket?: TicketInfo) => focusTicketTerminal(ctx, name, ticket)), + vscode.commands.registerCommand('operator.openTicket', openTicketFile), + vscode.commands.registerCommand('operator.launchTicket', + (treeItem?: TicketItem) => launchTicketCommand(ctx, treeItem)), + vscode.commands.registerCommand('operator.launchTicketWithOptions', + (treeItem?: TicketItem) => launchTicketWithOptionsCommand(ctx, treeItem)), + vscode.commands.registerCommand('operator.relaunchTicket', + (ticket: TicketInfo) => relaunchTicketCommand(ctx, ticket)), + vscode.commands.registerCommand('operator.launchTicketFromEditor', + () => launchTicketFromEditorCommand(ctx)), + vscode.commands.registerCommand('operator.launchTicketFromEditorWithOptions', + () => launchTicketFromEditorWithOptionsCommand(ctx)), + vscode.commands.registerCommand('operator.downloadOperator', + () => downloadOperatorCommand(ctx)), + vscode.commands.registerCommand('operator.pauseQueue', + () => pauseQueueCommand(ctx)), + vscode.commands.registerCommand('operator.resumeQueue', + () => resumeQueueCommand(ctx)), + vscode.commands.registerCommand('operator.syncKanban', + () => syncKanbanCommand(ctx)), + vscode.commands.registerCommand('operator.approveReview', + (agentId: string) => approveReviewCommand(ctx, agentId)), + vscode.commands.registerCommand('operator.rejectReview', + (agentId: string) => rejectReviewCommand(ctx, agentId)), + vscode.commands.registerCommand('operator.startOperatorServer', + () => startOperatorServerCommand(ctx)), + vscode.commands.registerCommand('operator.selectWorkingDirectory', + async () => { + const operatorPath = await getOperatorPath(ctx.extensionContext); + await selectWorkingDirectory(ctx.extensionContext, operatorPath ?? undefined); + }), + vscode.commands.registerCommand('operator.runSetup', + () => runSetupCommand(ctx)), + vscode.commands.registerCommand('operator.checkKanbanConnection', + () => checkKanbanConnection(ctx.extensionContext)), + vscode.commands.registerCommand('operator.configureJira', + () => configureJira(ctx.extensionContext)), + vscode.commands.registerCommand('operator.configureLinear', + () => configureLinear(ctx.extensionContext)), + vscode.commands.registerCommand('operator.startKanbanOnboarding', + () => startKanbanOnboarding(ctx.extensionContext)), + vscode.commands.registerCommand('operator.startGitOnboarding', + () => startGitOnboarding().then(() => ctx.refreshAllProviders())), + vscode.commands.registerCommand('operator.configureGitHub', + () => onboardGitHub().then(() => ctx.refreshAllProviders())), + vscode.commands.registerCommand('operator.configureGitLab', + () => onboardGitLab().then(() => ctx.refreshAllProviders())), + vscode.commands.registerCommand('operator.showCreateMenu', + () => showCreateMenu(ctx)), + vscode.commands.registerCommand('operator.openCreateDelegator', + (tool?: string, model?: string) => openCreateDelegator(ctx, tool, model)), + vscode.commands.registerCommand('operator.detectLlmTools', + () => detectLlmTools(ctx.extensionContext, getOperatorPath)), + vscode.commands.registerCommand('operator.setDefaultLlm', + async (tool?: string, model?: string) => { + if (!tool || !model) { return; } + try { + const apiUrl = await discoverApiUrl(ctx.getCurrentTicketsDir()); + const resp = await fetch(`${apiUrl}/api/v1/llm-tools/default`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tool, model }), + }); + if (resp.ok) { + void vscode.window.showInformationMessage(`Default LLM set to ${tool}:${model}`); + void ctx.refreshAllProviders(); + } else { + void vscode.window.showErrorMessage('Failed to set default LLM'); + } + } catch { + void vscode.window.showErrorMessage('Operator API not available'); + } + }), + vscode.commands.registerCommand('operator.openWalkthrough', openWalkthrough), + vscode.commands.registerCommand('operator.openSettings', + () => ConfigPanel.createOrShow(ctx.extensionContext.extensionUri)), + vscode.commands.registerCommand('operator.syncKanbanCollection', + (item: StatusItem) => syncKanbanCollectionCommand(ctx, item)), + vscode.commands.registerCommand('operator.addJiraProject', + (workspaceKey: string) => addJiraProjectCommand(ctx, workspaceKey)), + vscode.commands.registerCommand('operator.addLinearTeam', + (workspaceKey: string) => addLinearTeamCommand(ctx, workspaceKey)), + vscode.commands.registerCommand('operator.revealTicketsDir', + () => revealTicketsDirCommand(ctx)), + vscode.commands.registerCommand('operator.startWebhookServer', + () => startServer(ctx)), + vscode.commands.registerCommand('operator.connectMcpServer', + () => connectMcpServer(ctx.getCurrentTicketsDir())), + // ABXY navigation commands for status panel — registered last but still before async init + vscode.commands.registerCommand('operator.statusSpecialAction', () => { + const selected = ctx.statusTreeView?.selection?.[0]; + if (selected instanceof StatusItem && selected.specialCommand) { + const args = (selected.specialCommand.arguments ?? []) as unknown[]; + void vscode.commands.executeCommand( + selected.specialCommand.command, + ...args + ); + } + }), + vscode.commands.registerCommand('operator.statusRefreshAction', () => { + const selected = ctx.statusTreeView?.selection?.[0]; + if (selected instanceof StatusItem && selected.refreshCommand) { + const args = (selected.refreshCommand.arguments ?? []) as unknown[]; + void vscode.commands.executeCommand( + selected.refreshCommand.command, + ...args + ); + } + }), + vscode.commands.registerCommand('operator.statusBackAction', () => { + void vscode.commands.executeCommand('list.collapse'); + }), + ); + + outputChannel.appendLine('[Operator] Command registration complete'); + + // Async initialization — failures here are recoverable; commands still work. + try { + await issueTypeService.refresh(); + + // Find tickets directory (check parent first, then workspace) + currentTicketsDir = await findParentTicketsDir(); + await ctx.setTicketsDir(currentTicketsDir); + + // Set up file watcher if tickets directory exists + if (currentTicketsDir) { + const watcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(currentTicketsDir, '**/*.md') + ); + watcher.onDidChange(() => void ctx.refreshAllProviders()); + watcher.onDidCreate(() => void ctx.refreshAllProviders()); + watcher.onDidDelete(() => void ctx.refreshAllProviders()); + context.subscriptions.push(watcher); + } + + // Auto-start if configured and config.toml exists + const autoStart = vscode.workspace + .getConfiguration('operator') + .get('autoStart', true); + if (autoStart) { + const hasConfig = await configFileExists(); + if (hasConfig) { + await startServer(ctx); + } else { + showConfigMissingNotification(); + } + } + + updateStatusBar(ctx); + + // Set initial context for command visibility + await updateOperatorContext(ctx); + + // Restore working directory from persistent VS Code settings if globalState is empty + const configWorkingDir = vscode.workspace.getConfiguration('operator').get('workingDirectory'); + if (configWorkingDir && !context.globalState.get('operator.workingDirectory')) { + await context.globalState.update('operator.workingDirectory', configWorkingDir); + } + + // Auto-open walkthrough for new users with no working directory + const workingDirectory = context.globalState.get('operator.workingDirectory'); + if (!workingDirectory) { + void vscode.commands.executeCommand( + 'workbench.action.openWalkthrough', + 'untra.operator-terminals#operator-setup', + false + ); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + outputChannel.appendLine(`[Operator] Activation error: ${msg}`); + if (err instanceof Error && err.stack) { + outputChannel.appendLine(err.stack); + } + void vscode.window.showErrorMessage(`Operator extension failed to fully activate: ${msg}`); + } +} + /** * Extension deactivation */ export function deactivate(): void { - void webhookServer?.stop(); - terminalManager?.dispose(); + // Cleanup handled by disposables registered in context.subscriptions } diff --git a/vscode-extension/src/git-onboarding.ts b/vscode-extension/src/git-onboarding.ts index 746ed35..df1ed7f 100644 --- a/vscode-extension/src/git-onboarding.ts +++ b/vscode-extension/src/git-onboarding.ts @@ -116,13 +116,23 @@ export async function onboardGitHub(): Promise { // Fall back to manual input if (!token) { - const message = ghPath - ? 'gh CLI found but not authenticated. Enter a GitHub Personal Access Token:' - : 'Enter a GitHub Personal Access Token (or install gh CLI for auto-detection):'; + if (!ghPath) { + // CLI not installed — open install page + await vscode.env.openExternal(vscode.Uri.parse('https://cli.github.com/')); + void vscode.window.showInformationMessage( + 'Install the GitHub CLI (gh), then re-run this command to connect.' + ); + return; + } + + // CLI installed but not authenticated — open PAT creation page, then prompt + await vscode.env.openExternal( + vscode.Uri.parse('https://github.com/settings/personal-access-tokens/new') + ); token = await vscode.window.showInputBox({ title: 'GitHub Authentication', - prompt: message, + prompt: 'gh CLI found but not authenticated. Enter a GitHub Personal Access Token:', password: true, ignoreFocusOut: true, placeHolder: 'ghp_...', @@ -207,13 +217,23 @@ export async function onboardGitLab(): Promise { // Fall back to manual input if (!token) { - const message = glabPath - ? 'glab CLI found but not authenticated. Enter a GitLab Personal Access Token:' - : 'Enter a GitLab Personal Access Token (or install glab CLI for auto-detection):'; + if (!glabPath) { + // CLI not installed — open install page + await vscode.env.openExternal(vscode.Uri.parse('https://docs.gitlab.com/cli')); + void vscode.window.showInformationMessage( + 'Install the GitLab CLI (glab), then re-run this command to connect.' + ); + return; + } + + // CLI installed but not authenticated — open PAT creation page, then prompt + await vscode.env.openExternal( + vscode.Uri.parse('https://gitlab.com/-/user_settings/personal_access_tokens') + ); token = await vscode.window.showInputBox({ title: 'GitLab Authentication', - prompt: message, + prompt: 'glab CLI found but not authenticated. Enter a GitLab Personal Access Token:', password: true, ignoreFocusOut: true, placeHolder: 'glpat-...', diff --git a/vscode-extension/src/sections/config-section.ts b/vscode-extension/src/sections/config-section.ts index 3a0d082..2578f36 100644 --- a/vscode-extension/src/sections/config-section.ts +++ b/vscode-extension/src/sections/config-section.ts @@ -2,26 +2,38 @@ import * as vscode from 'vscode'; import * as path from 'path'; import { StatusItem } from '../status-item'; import type { SectionContext, StatusSection, ConfigState } from './types'; +import type { SectionId, SectionHealth } from '../generated'; import { resolveWorkingDirectory, configFileExists, getResolvedConfigPath, } from '../config-paths'; +import { getOperatorPath, getOperatorVersion } from '../operator-binary'; export class ConfigSection implements StatusSection { - readonly sectionId = 'config'; + readonly sectionId: SectionId = 'config'; + readonly prerequisites: SectionId[] = []; private state: ConfigState = { workingDirSet: false, workingDir: '', configExists: false, configPath: '', + wrapperType: 'vscode', + editorVar: process.env.EDITOR || 'vim', + visualVar: process.env.VISUAL || 'code --wait', }; isReady(): boolean { return this.state.workingDirSet && this.state.configExists; } + health(): SectionHealth { + if (!this.state.configExists) { return 'Red'; } + if (!this.state.workingDirSet) { return 'Yellow'; } + return 'Green'; + } + async check(ctx: SectionContext): Promise { const workingDir = ctx.extensionContext.globalState.get('operator.workingDirectory') || resolveWorkingDirectory(); @@ -29,11 +41,38 @@ export class ConfigSection implements StatusSection { const configExists = await configFileExists(); const configPath = getResolvedConfigPath(); + // Read wrapper type from config + let wrapperType = 'vscode'; + let operatorVersion: string | undefined; + try { + const config = await ctx.readConfigToml(); + const sessions = config.sessions as Record | undefined; + if (sessions?.wrapper && typeof sessions.wrapper === 'string') { + wrapperType = sessions.wrapper; + } + } catch { + // Default to vscode + } + + // Try to get operator version from binary + try { + const operatorPath = await getOperatorPath(ctx.extensionContext); + if (operatorPath) { + operatorVersion = await getOperatorVersion(operatorPath) || undefined; + } + } catch { + // Version unknown + } + this.state = { workingDirSet, workingDir: workingDir || '', configExists, configPath: configPath || '', + wrapperType, + operatorVersion, + editorVar: process.env.EDITOR || 'vim', + visualVar: process.env.VISUAL || 'code --wait', }; } @@ -118,6 +157,45 @@ export class ConfigSection implements StatusSection { })); } + // Session wrapper (readonly) + items.push(new StatusItem({ + label: 'Wrapper', + description: this.state.wrapperType, + icon: 'terminal', + sectionId: this.sectionId, + })); + + // Editor environment variables + items.push(new StatusItem({ + label: '$EDITOR', + description: this.state.editorVar || 'Not set', + icon: this.state.editorVar ? 'check' : 'warning', + sectionId: this.sectionId, + })); + + items.push(new StatusItem({ + label: '$VISUAL', + description: this.state.visualVar || 'Not set', + icon: this.state.visualVar ? 'check' : 'warning', + sectionId: this.sectionId, + })); + + // Operator version with update nudge + const versionDesc = this.state.updateAvailable + ? `${this.state.operatorVersion ?? 'Unknown'} → ${this.state.updateAvailable} available` + : this.state.operatorVersion ?? 'Unknown'; + items.push(new StatusItem({ + label: 'Version', + description: versionDesc, + icon: this.state.updateAvailable ? 'warning' : 'versions', + command: { + command: 'vscode.open', + title: 'Open Downloads', + arguments: [vscode.Uri.parse('https://operator.untra.io/downloads/')], + }, + sectionId: this.sectionId, + })); + return items; } } diff --git a/vscode-extension/src/sections/connections-section.ts b/vscode-extension/src/sections/connections-section.ts index 8f93f98..74e54e3 100644 --- a/vscode-extension/src/sections/connections-section.ts +++ b/vscode-extension/src/sections/connections-section.ts @@ -3,13 +3,15 @@ import * as path from 'path'; import * as fs from 'fs/promises'; import { StatusItem } from '../status-item'; import type { SectionContext, StatusSection, WebhookStatus, ApiStatus } from './types'; +import type { SectionId, SectionHealth } from '../generated'; import { SessionInfo } from '../types'; import { discoverApiUrl, ApiSessionInfo } from '../api-client'; import { getOperatorPath, getOperatorVersion } from '../operator-binary'; import { isMcpServerRegistered } from '../mcp-connect'; export class ConnectionsSection implements StatusSection { - readonly sectionId = 'connections'; + readonly sectionId: SectionId = 'connections'; + readonly prerequisites: SectionId[] = ['config']; private webhookStatus: WebhookStatus = { running: false }; private apiStatus: ApiStatus = { connected: false }; @@ -25,6 +27,14 @@ export class ConnectionsSection implements StatusSection { return this.apiStatus.connected || this.webhookStatus.running; } + health(): SectionHealth { + const api = this.apiStatus.connected; + const wh = this.webhookStatus.running; + if (api && wh) { return 'Green'; } + if (api || wh) { return 'Yellow'; } + return 'Red'; + } + async check(ctx: SectionContext): Promise { await Promise.allSettled([ this.checkWebhookStatus(ctx), diff --git a/vscode-extension/src/sections/delegator-section.ts b/vscode-extension/src/sections/delegator-section.ts index 5a5a362..58caf86 100644 --- a/vscode-extension/src/sections/delegator-section.ts +++ b/vscode-extension/src/sections/delegator-section.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { StatusItem } from '../status-item'; import type { SectionContext, StatusSection } from './types'; +import type { SectionId, SectionHealth } from '../generated'; import { discoverApiUrl } from '../api-client'; import type { DelegatorResponse } from '../generated/DelegatorResponse'; import type { DelegatorsResponse } from '../generated/DelegatorsResponse'; @@ -11,10 +12,16 @@ interface DelegatorState { } export class DelegatorSection implements StatusSection { - readonly sectionId = 'delegators'; + readonly sectionId: SectionId = 'delegators'; + readonly prerequisites: SectionId[] = ['llm']; private state: DelegatorState = { apiAvailable: false, delegators: [] }; + health(): SectionHealth { + if (!this.state.apiAvailable) { return 'Yellow'; } + return this.state.delegators.length > 0 ? 'Green' : 'Yellow'; + } + async check(ctx: SectionContext): Promise { try { const apiUrl = await discoverApiUrl(ctx.ticketsDir); diff --git a/vscode-extension/src/sections/git-section.ts b/vscode-extension/src/sections/git-section.ts index b52013b..ae0c34e 100644 --- a/vscode-extension/src/sections/git-section.ts +++ b/vscode-extension/src/sections/git-section.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { StatusItem } from '../status-item'; import type { SectionContext, StatusSection, GitState } from './types'; +import type { SectionId, SectionHealth } from '../generated'; /** Map provider names to branded ThemeIcon IDs */ const PROVIDER_ICONS: Record = { @@ -11,7 +12,8 @@ const PROVIDER_ICONS: Record = { }; export class GitSection implements StatusSection { - readonly sectionId = 'git'; + readonly sectionId: SectionId = 'git'; + readonly prerequisites: SectionId[] = ['connections']; private state: GitState = { configured: false }; @@ -19,6 +21,12 @@ export class GitSection implements StatusSection { return this.state.configured; } + health(): SectionHealth { + if (!this.state.configured) { return 'Red'; } + if (!this.state.tokenSet) { return 'Yellow'; } + return 'Green'; + } + async check(ctx: SectionContext): Promise { const config = await ctx.readConfigToml(); const gitSection = config.git as Record | undefined; diff --git a/vscode-extension/src/sections/issuetype-section.ts b/vscode-extension/src/sections/issuetype-section.ts index 732952d..8483818 100644 --- a/vscode-extension/src/sections/issuetype-section.ts +++ b/vscode-extension/src/sections/issuetype-section.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { StatusItem } from '../status-item'; import type { SectionContext, StatusSection } from './types'; +import type { SectionId, SectionHealth } from '../generated'; import type { IssueTypeSummary } from '../generated/IssueTypeSummary'; import { DEFAULT_ISSUE_TYPES, GLYPH_TO_ICON, COLOR_TO_THEME } from '../issuetype-service'; import { discoverApiUrl } from '../api-client'; @@ -11,10 +12,16 @@ interface IssueTypeState { } export class IssueTypeSection implements StatusSection { - readonly sectionId = 'issuetypes'; + readonly sectionId: SectionId = 'issuetypes'; + readonly prerequisites: SectionId[] = ['kanban']; private state: IssueTypeState = { apiAvailable: false, types: [] }; + health(): SectionHealth { + if (!this.state.apiAvailable) { return 'Yellow'; } + return this.state.types.length > 0 ? 'Green' : 'Yellow'; + } + async check(ctx: SectionContext): Promise { // Try fetching from API try { diff --git a/vscode-extension/src/sections/kanban-section.ts b/vscode-extension/src/sections/kanban-section.ts index 276518b..d25b822 100644 --- a/vscode-extension/src/sections/kanban-section.ts +++ b/vscode-extension/src/sections/kanban-section.ts @@ -1,10 +1,12 @@ import * as vscode from 'vscode'; import { StatusItem } from '../status-item'; import type { SectionContext, StatusSection, KanbanState, KanbanProviderState } from './types'; +import type { SectionId, SectionHealth } from '../generated'; import { getKanbanWorkspaces } from '../walkthrough'; export class KanbanSection implements StatusSection { - readonly sectionId = 'kanban'; + readonly sectionId: SectionId = 'kanban'; + readonly prerequisites: SectionId[] = ['connections']; private state: KanbanState = { configured: false, providers: [] }; @@ -12,6 +14,10 @@ export class KanbanSection implements StatusSection { return this.state.configured; } + health(): SectionHealth { + return this.state.configured ? 'Green' : 'Red'; + } + async check(ctx: SectionContext): Promise { const config = await ctx.readConfigToml(); const kanbanSection = config.kanban as Record | undefined; diff --git a/vscode-extension/src/sections/llm-section.ts b/vscode-extension/src/sections/llm-section.ts index 62b07d6..cf29774 100644 --- a/vscode-extension/src/sections/llm-section.ts +++ b/vscode-extension/src/sections/llm-section.ts @@ -1,19 +1,25 @@ import * as vscode from 'vscode'; import { StatusItem } from '../status-item'; import type { SectionContext, StatusSection, LlmState, LlmToolInfo } from './types'; +import type { SectionId, SectionHealth } from '../generated'; import { detectInstalledLlmTools } from '../walkthrough'; import { discoverApiUrl } from '../api-client'; import type { DetectedTool } from '../generated/DetectedTool'; export class LlmSection implements StatusSection { - readonly sectionId = 'llm'; + readonly sectionId: SectionId = 'llm'; + readonly prerequisites: SectionId[] = ['connections']; - private state: LlmState = { detected: false, tools: [], configDetected: [], toolDetails: [] }; + private state: LlmState = { detected: false, tools: [], configDetected: [], toolDetails: [], defaultTool: undefined, defaultModel: undefined }; isConfigured(): boolean { return this.state.detected; } + health(): SectionHealth { + return this.state.detected ? 'Green' : 'Yellow'; + } + async check(ctx: SectionContext): Promise { const toolDetails: LlmToolInfo[] = []; const seen = new Set(); @@ -81,11 +87,31 @@ export class LlmSection implements StatusSection { ) : []; + // Fetch current default LLM tool + model + let defaultTool: string | undefined; + let defaultModel: string | undefined; + try { + const apiUrl = await discoverApiUrl(ctx.ticketsDir); + const defaultResp = await fetch(`${apiUrl}/api/v1/llm-tools/default`); + if (defaultResp.ok) { + const data = await defaultResp.json() as { tool: string; model: string }; + if (data.tool) { defaultTool = data.tool; defaultModel = data.model; } + } + } catch { + // API not available — fall back to config TOML + const cfgForDefault = await ctx.readConfigToml(); + const llmToolsCfg = cfgForDefault.llm_tools as Record | undefined; + if (typeof llmToolsCfg?.default_tool === 'string') { defaultTool = llmToolsCfg.default_tool; } + if (typeof llmToolsCfg?.default_model === 'string') { defaultModel = llmToolsCfg.default_model; } + } + this.state = { detected: toolDetails.length > 0, tools, configDetected, toolDetails, + defaultTool, + defaultModel, }; } @@ -169,20 +195,32 @@ export class LlmSection implements StatusSection { const tool = this.state.toolDetails.find(t => t.name === toolName); if (!tool) { return []; } - return tool.models.map(model => new StatusItem({ - label: model, - icon: 'symbol-field', - tooltip: `Create delegator for ${toolName}:${model}`, - command: { - command: 'operator.openCreateDelegator', - title: 'Create Delegator', - arguments: [toolName, model], - }, - sectionId: this.sectionId, - })); + return tool.models.map(model => { + const isDefault = this.state.defaultTool === toolName + && this.state.defaultModel === model; + return new StatusItem({ + label: isDefault ? `${model} (default)` : model, + icon: isDefault ? 'check' : 'symbol-field', + tooltip: isDefault + ? `${toolName}:${model} is the current default` + : `Set ${toolName}:${model} as default`, + command: { + command: 'operator.setDefaultLlm', + title: 'Set as Default LLM', + arguments: [toolName, model], + }, + sectionId: this.sectionId, + }); + }); } private getLlmSummary(): string { + if (this.state.defaultTool && this.state.defaultModel) { + return `Default: ${this.state.defaultTool}:${this.state.defaultModel}`; + } + if (this.state.defaultTool) { + return `Default: ${this.state.defaultTool}`; + } const count = this.state.toolDetails.length; if (count === 0) { return ''; } const first = this.state.toolDetails[0]!; diff --git a/vscode-extension/src/sections/managed-projects-section.ts b/vscode-extension/src/sections/managed-projects-section.ts index f9f0568..ce80d84 100644 --- a/vscode-extension/src/sections/managed-projects-section.ts +++ b/vscode-extension/src/sections/managed-projects-section.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { StatusItem } from '../status-item'; import type { SectionContext, StatusSection } from './types'; +import type { SectionId, SectionHealth } from '../generated'; import { discoverApiUrl } from '../api-client'; import type { ProjectSummary } from '../generated/ProjectSummary'; @@ -10,10 +11,16 @@ interface ManagedProjectsState { } export class ManagedProjectsSection implements StatusSection { - readonly sectionId = 'projects'; + readonly sectionId: SectionId = 'projects'; + readonly prerequisites: SectionId[] = ['git']; private state: ManagedProjectsState = { configured: false, projects: [] }; + health(): SectionHealth { + if (!this.state.configured) { return 'Yellow'; } + return this.state.projects.length > 0 ? 'Green' : 'Yellow'; + } + async check(ctx: SectionContext): Promise { try { const apiUrl = await discoverApiUrl(ctx.ticketsDir); diff --git a/vscode-extension/src/sections/types.ts b/vscode-extension/src/sections/types.ts index e46d2a5..690d4e9 100644 --- a/vscode-extension/src/sections/types.ts +++ b/vscode-extension/src/sections/types.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { StatusItem } from '../status-item'; import type { DetectedToolResult } from '../walkthrough'; +import type { SectionId, SectionHealth } from '../generated'; /** Shared context provided by the orchestrator to all sections */ export interface SectionContext { @@ -26,8 +27,14 @@ export interface SectionContext { /** Every status tree section implements this interface */ export interface StatusSection { - readonly sectionId: string; + /** Canonical section identifier (matches Rust SectionId enum) */ + readonly sectionId: SectionId; + /** Which sections must be healthy before this section is visible */ + readonly prerequisites: SectionId[]; + /** Run health/state checks */ check(ctx: SectionContext): Promise; + /** Current health state — controls header icon/color */ + health(): SectionHealth; getTopLevelItem(ctx: SectionContext): StatusItem; getChildren(ctx: SectionContext, element?: StatusItem): StatusItem[]; } @@ -59,6 +66,11 @@ export interface ConfigState { workingDir: string; configExists: boolean; configPath: string; + wrapperType: string; + operatorVersion?: string; + updateAvailable?: string; + editorVar: string; + visualVar: string; } /** Config-driven state for a single kanban provider */ @@ -94,6 +106,8 @@ export interface LlmState { tools: DetectedToolResult[]; configDetected: Array<{ name: string; version?: string }>; toolDetails: LlmToolInfo[]; + defaultTool?: string; + defaultModel?: string; } /** Internal state for the Git section */ diff --git a/vscode-extension/src/status-item.ts b/vscode-extension/src/status-item.ts index 40bbf4c..bafdf29 100644 --- a/vscode-extension/src/status-item.ts +++ b/vscode-extension/src/status-item.ts @@ -15,6 +15,10 @@ export interface StatusItemOptions { provider?: string; // 'jira' | 'linear' workspaceKey?: string; // domain or teamId (config key) projectKey?: string; // project/team sync config key + /** X button (Shift+Enter) — special/tertiary action */ + specialCommand?: vscode.Command; + /** Y button (Ctrl+Enter) — contextual refresh */ + refreshCommand?: vscode.Command; } /** @@ -25,6 +29,10 @@ export class StatusItem extends vscode.TreeItem { public readonly provider?: string; public readonly workspaceKey?: string; public readonly projectKey?: string; + /** X button (Shift+Enter) — special/tertiary action */ + public readonly specialCommand?: vscode.Command; + /** Y button (Ctrl+Enter) — contextual refresh */ + public readonly refreshCommand?: vscode.Command; constructor(opts: StatusItemOptions) { super( @@ -35,12 +43,43 @@ export class StatusItem extends vscode.TreeItem { this.provider = opts.provider; this.workspaceKey = opts.workspaceKey; this.projectKey = opts.projectKey; - if (opts.description !== undefined) { - this.description = opts.description; + this.specialCommand = opts.specialCommand; + this.refreshCommand = opts.refreshCommand; + + // Build description with action indicator titles + let desc = opts.description ?? ''; + const indicators: string[] = []; + if (opts.specialCommand) { + indicators.push(opts.specialCommand.title || '*'); + } + if (opts.refreshCommand) { + indicators.push(opts.refreshCommand.title || '\u27F3'); + } + if (indicators.length > 0) { + desc = desc ? `${desc} ${indicators.join(' ')}` : indicators.join(' '); } - this.tooltip = opts.tooltip || (opts.description + + if (desc) { + this.description = desc; + } + + // Build rich tooltip with action hints + const tooltipLines: string[] = []; + const baseTooltip = opts.tooltip || (opts.description ? `${opts.label}: ${opts.description}` : opts.label); + tooltipLines.push(baseTooltip); + if (opts.command) { + tooltipLines.push(`Enter: ${opts.command.title}`); + } + if (opts.specialCommand?.tooltip) { + tooltipLines.push(`Shift+Enter: ${opts.specialCommand.tooltip}`); + } + if (opts.refreshCommand?.tooltip) { + tooltipLines.push(`Ctrl+Enter: ${opts.refreshCommand.tooltip}`); + } + this.tooltip = tooltipLines.join('\n'); + this.iconPath = new vscode.ThemeIcon(opts.icon); if (opts.command) { this.command = opts.command; diff --git a/vscode-extension/src/status-provider.ts b/vscode-extension/src/status-provider.ts index c4d6f37..b63e991 100644 --- a/vscode-extension/src/status-provider.ts +++ b/vscode-extension/src/status-provider.ts @@ -8,7 +8,7 @@ * Tier 0: Configuration (always visible) * Tier 1: Connections (requires configReady) * Tier 2: Kanban, LLM Tools, Git (requires connectionsReady) - * Tier 3: Issue Types (kanbanConfigured), Delegators (llmConfigured), Managed Projects (gitConfigured) + * Tier 3: Issue Types/issuetypes (kanbanConfigured), Delegators/delegators (llmConfigured), Managed Projects/projects (gitConfigured) */ import * as vscode from 'vscode'; @@ -160,25 +160,34 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { } /** - * Build the list of sections visible based on current readiness flags. + * Build the list of sections visible based on prerequisite health. + * + * A section is visible when all its prerequisite sections report Green health. + * This replaces the hardcoded tier system with a declarative, data-driven approach + * that matches the Rust TUI's `StatusSection` trait prerequisites. */ private getVisibleSections(): StatusSection[] { - const visible: StatusSection[] = [this.configSection]; - - // Tier 1: requires config ready - if (!this.ctx.configReady) { return visible; } - visible.push(this.connectionsSection); - - // Tier 2: requires connections ready (API or webhook) - if (!this.ctx.connectionsReady) { return visible; } - visible.push(this.kanbanSection, this.llmSection, this.gitSection); + const healthCache = new Map(); + + const getSectionHealth = (sectionId: string): string => { + if (healthCache.has(sectionId)) { return healthCache.get(sectionId)!; } + const section = this.sectionMap.get(sectionId); + if (!section) { return 'Red'; } + const h = section.health(); + healthCache.set(sectionId, h); + return h; + }; - // Tier 3: each requires its parent tier-2 section configured - if (this.ctx.kanbanConfigured) { visible.push(this.issueTypeSection); } - if (this.ctx.llmConfigured) { visible.push(this.delegatorSection); } - if (this.ctx.gitConfigured) { visible.push(this.managedProjectsSection); } + const prerequisitesMet = (section: StatusSection): boolean => { + return section.prerequisites.every(prereqId => { + // Prerequisite must itself be visible (transitive) and Green + const prereqSection = this.sectionMap.get(prereqId); + if (!prereqSection) { return false; } + return prerequisitesMet(prereqSection) && getSectionHealth(prereqId) === 'Green'; + }); + }; - return visible; + return this.allSections.filter(s => prerequisitesMet(s)); } getTreeItem(element: StatusItem): vscode.TreeItem { diff --git a/vscode-extension/src/terminal-manager.ts b/vscode-extension/src/terminal-manager.ts index f652764..2536307 100644 --- a/vscode-extension/src/terminal-manager.ts +++ b/vscode-extension/src/terminal-manager.ts @@ -20,20 +20,26 @@ export class TerminalManager { private issueTypeService: IssueTypeService | undefined; constructor() { - // Track shell execution for activity detection + // Track shell execution for activity detection (requires VS Code 1.93+) + if (typeof vscode.window.onDidStartTerminalShellExecution === 'function') { + this.disposables.push( + vscode.window.onDidStartTerminalShellExecution((e) => { + const name = this.findTerminalName(e.terminal); + if (name && this.terminals.has(name)) { + this.activityState.set(name, 'running'); + } + }), + vscode.window.onDidEndTerminalShellExecution((e) => { + const name = this.findTerminalName(e.terminal); + if (name && this.terminals.has(name)) { + this.activityState.set(name, 'idle'); + } + }) + ); + } + + // Always track terminal close (available in all supported VS Code versions) this.disposables.push( - vscode.window.onDidStartTerminalShellExecution((e) => { - const name = this.findTerminalName(e.terminal); - if (name && this.terminals.has(name)) { - this.activityState.set(name, 'running'); - } - }), - vscode.window.onDidEndTerminalShellExecution((e) => { - const name = this.findTerminalName(e.terminal); - if (name && this.terminals.has(name)) { - this.activityState.set(name, 'idle'); - } - }), vscode.window.onDidCloseTerminal((t) => { const name = this.findTerminalName(t); if (name) { diff --git a/vscode-extension/src/ticket-provider.ts b/vscode-extension/src/ticket-provider.ts index ae936b6..bb220b5 100644 --- a/vscode-extension/src/ticket-provider.ts +++ b/vscode-extension/src/ticket-provider.ts @@ -29,9 +29,13 @@ export class TicketTreeProvider constructor( private readonly status: 'in-progress' | 'queue' | 'completed', private readonly issueTypeService: IssueTypeService, - private readonly terminalManager?: TerminalManager + private terminalManager?: TerminalManager ) {} + setTerminalManager(manager: TerminalManager): void { + this.terminalManager = manager; + } + async setTicketsDir(dir: string | undefined): Promise { this.ticketsDir = dir; await this.refresh(); @@ -138,8 +142,15 @@ export class TicketItem extends vscode.TreeItem { title: 'Focus Terminal', arguments: [ticket.terminalName, ticket], }; + } else if (ticket.status === 'queue') { + // Queue items open the launch confirmation dialog + this.command = { + command: 'operator.launchTicketWithOptions', + title: 'Launch Ticket', + arguments: [this], + }; } else { - // Queue and completed items open the file + // Completed items open the file this.command = { command: 'operator.openTicket', title: 'Open Ticket', diff --git a/vscode-extension/src/tickets-dir.ts b/vscode-extension/src/tickets-dir.ts new file mode 100644 index 0000000..36ea764 --- /dev/null +++ b/vscode-extension/src/tickets-dir.ts @@ -0,0 +1,109 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs/promises'; + +/** + * Find .tickets directory - check parent directory first, then workspace + */ +export async function findParentTicketsDir(): Promise { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + return undefined; + } + + // First check parent directory for .tickets (monorepo setup) + const parentDir = path.dirname(workspaceFolder.uri.fsPath); + const parentTicketsPath = path.join(parentDir, '.tickets'); + + try { + await fs.access(parentTicketsPath); + return parentTicketsPath; + } catch { + // Parent doesn't have .tickets, check workspace + } + + // Fall back to configured tickets directory in workspace + const configuredDir = vscode.workspace + .getConfiguration('operator') + .get('ticketsDir', '.tickets'); + + const ticketsPath = path.isAbsolute(configuredDir) + ? configuredDir + : path.join(workspaceFolder.uri.fsPath, configuredDir); + + try { + await fs.access(ticketsPath); + return ticketsPath; + } catch { + return undefined; + } +} + +/** + * Find the .tickets directory for webhook session file. + * Walks up from workspace to find existing .tickets, or creates in parent (org level). + */ +export async function findTicketsDir(): Promise { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + return undefined; + } + + const configuredDir = vscode.workspace + .getConfiguration('operator') + .get('ticketsDir', '.tickets'); + + // If absolute path configured, check if it exists + if (path.isAbsolute(configuredDir)) { + try { + await fs.access(configuredDir); + return configuredDir; + } catch { + return undefined; + } + } + + // Walk up from workspace to find existing .tickets directory + let currentDir = workspaceFolder.uri.fsPath; + const root = path.parse(currentDir).root; + + while (currentDir !== root) { + const ticketsPath = path.join(currentDir, configuredDir); + try { + await fs.access(ticketsPath); + return ticketsPath; // Found existing .tickets + } catch { + // Not found, try parent + currentDir = path.dirname(currentDir); + } + } + + // Not found anywhere + return undefined; +} + +/** + * Find the directory to run the operator server in. + * Prefers parent directory if it has .tickets/operator/, otherwise uses workspace. + */ +export async function findOperatorServerDir(): Promise { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + return undefined; + } + + const workspaceDir = workspaceFolder.uri.fsPath; + const parentDir = path.dirname(workspaceDir); + + // Check if parent has .tickets/operator/ (initialized operator setup) + const parentOperatorPath = path.join(parentDir, '.tickets', 'operator'); + try { + await fs.access(parentOperatorPath); + return parentDir; // Parent has initialized operator + } catch { + // Parent doesn't have .tickets/operator + } + + // Fall back to workspace directory + return workspaceDir; +} diff --git a/vscode-extension/src/webhook-server.ts b/vscode-extension/src/webhook-server.ts index 6bf4307..1fb0702 100644 --- a/vscode-extension/src/webhook-server.ts +++ b/vscode-extension/src/webhook-server.ts @@ -22,7 +22,7 @@ import { SessionInfo, } from './types'; -const VERSION = '0.1.27'; +const VERSION = '0.1.28'; /** * HTTP server for operator <-> extension communication diff --git a/vscode-extension/test/suite/command-registration.test.ts b/vscode-extension/test/suite/command-registration.test.ts new file mode 100644 index 0000000..eb18ec2 --- /dev/null +++ b/vscode-extension/test/suite/command-registration.test.ts @@ -0,0 +1,169 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Tests that verify command registration works correctly in the extension. + * + * These tests enforce: + * 1. Every command in package.json is actually registered at runtime + * 2. Every registered operator.* command has a package.json entry + * 3. Activation events include onView and onCommand triggers (not just onStartupFinished) + * 4. Commands are available immediately after activation + */ +suite('Command Registration Tests', () => { + let extension: vscode.Extension | undefined; + let packageJson: { + activationEvents?: string[]; + contributes?: { + commands?: Array<{ command: string }>; + views?: { + 'operator-sidebar'?: Array<{ id: string }>; + }; + }; + }; + + suiteSetup(async () => { + extension = vscode.extensions.getExtension('untra.operator-terminals'); + assert.ok(extension, 'Extension must be present'); + packageJson = extension.packageJSON as typeof packageJson; + if (!extension.isActive) { + await extension.activate(); + } + }); + + // ----------------------------------------------------------------------- + // Manifest parity: every contributed command must be registered at runtime + // ----------------------------------------------------------------------- + + test('All package.json commands are registered at runtime', async () => { + const manifestCommands = (packageJson.contributes?.commands ?? []).map(c => c.command); + assert.ok(manifestCommands.length > 0, 'package.json should contribute at least one command'); + + const registeredCommands = await vscode.commands.getCommands(true); + + const missing: string[] = []; + for (const cmd of manifestCommands) { + if (!registeredCommands.includes(cmd)) { + missing.push(cmd); + } + } + + assert.strictEqual( + missing.length, + 0, + `Commands declared in package.json but NOT registered at runtime:\n ${missing.join('\n ')}` + ); + }); + + // ----------------------------------------------------------------------- + // Reverse parity: every registered operator.* command should be in manifest + // ----------------------------------------------------------------------- + + test('All registered operator.* commands are declared in package.json', async () => { + const manifestCommands = new Set( + (packageJson.contributes?.commands ?? []).map(c => c.command) + ); + + const registeredCommands = await vscode.commands.getCommands(true); + const operatorCommands = registeredCommands.filter(c => c.startsWith('operator.')); + + const undeclared: string[] = []; + for (const cmd of operatorCommands) { + if (!manifestCommands.has(cmd)) { + undeclared.push(cmd); + } + } + + assert.strictEqual( + undeclared.length, + 0, + `Commands registered at runtime but NOT in package.json:\n ${undeclared.join('\n ')}` + ); + }); + + // ----------------------------------------------------------------------- + // Activation events: extension must activate on view open AND commands + // ----------------------------------------------------------------------- + + test('activationEvents includes onView triggers for sidebar views', () => { + const activationEvents = packageJson.activationEvents ?? []; + const viewIds = (packageJson.contributes?.views?.['operator-sidebar'] ?? []).map(v => v.id); + + assert.ok(viewIds.length > 0, 'Should have sidebar views defined'); + + const missingViews: string[] = []; + for (const viewId of viewIds) { + if (!activationEvents.includes(`onView:${viewId}`)) { + missingViews.push(viewId); + } + } + + assert.strictEqual( + missingViews.length, + 0, + `activationEvents missing onView triggers for:\n ${missingViews.join('\n ')}` + ); + }); + + test('activationEvents includes onCommand triggers for key commands', () => { + const activationEvents = packageJson.activationEvents ?? []; + + // These are commands users invoke from command palette or keybindings — + // the extension MUST activate when they fire. + const criticalCommands = [ + 'operator.showStatus', + 'operator.startOperatorServer', + 'operator.launchTicket', + 'operator.openSettings', + 'operator.openWalkthrough', + 'operator.selectWorkingDirectory', + ]; + + const missing: string[] = []; + for (const cmd of criticalCommands) { + if (!activationEvents.includes(`onCommand:${cmd}`)) { + missing.push(cmd); + } + } + + assert.strictEqual( + missing.length, + 0, + `activationEvents missing onCommand triggers for:\n ${missing.join('\n ')}` + ); + }); + + // ----------------------------------------------------------------------- + // Key commands must be available immediately after activation + // ----------------------------------------------------------------------- + + test('Critical commands are available after activation', async () => { + const commands = await vscode.commands.getCommands(true); + + const critical = [ + 'operator.showStatus', + 'operator.startOperatorServer', + 'operator.launchTicket', + 'operator.startWebhookServer', + 'operator.refreshTickets', + 'operator.openSettings', + 'operator.selectWorkingDirectory', + 'operator.detectLlmTools', + ]; + + const missing: string[] = []; + for (const cmd of critical) { + if (!commands.includes(cmd)) { + missing.push(cmd); + } + } + + assert.strictEqual( + missing.length, + 0, + `Critical commands not registered:\n ${missing.join('\n ')}` + ); + }); +}); diff --git a/vscode-extension/test/suite/manifest-parity.test.ts b/vscode-extension/test/suite/manifest-parity.test.ts new file mode 100644 index 0000000..7b6ff9a --- /dev/null +++ b/vscode-extension/test/suite/manifest-parity.test.ts @@ -0,0 +1,81 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; + +/** + * Tests that validate the extension manifest (package.json) is consistent + * with runtime behavior and VS Code API requirements. + */ +suite('Manifest Parity Tests', () => { + let extension: vscode.Extension | undefined; + let packageJson: { + engines?: { vscode?: string }; + activationEvents?: string[]; + contributes?: { + commands?: Array<{ command: string }>; + views?: { + 'operator-sidebar'?: Array<{ id: string }>; + }; + }; + }; + + suiteSetup(() => { + extension = vscode.extensions.getExtension('untra.operator-terminals'); + assert.ok(extension, 'Extension must be present'); + packageJson = extension.packageJSON as typeof packageJson; + }); + + // ----------------------------------------------------------------------- + // engines.vscode must be >= 1.93 for terminal shell execution APIs + // ----------------------------------------------------------------------- + + test('engines.vscode floor is at least 1.93 for shell execution APIs', () => { + const enginesVscode = packageJson.engines?.vscode; + assert.ok(enginesVscode, 'engines.vscode must be defined'); + + // Extract the minimum version number from the semver range (e.g. "^1.93.0" -> "1.93.0") + const match = enginesVscode.match(/(\d+)\.(\d+)/); + assert.ok(match, `Could not parse version from engines.vscode: ${enginesVscode}`); + + const major = parseInt(match[1]!, 10); + const minor = parseInt(match[2]!, 10); + + // onDidStartTerminalShellExecution was added in 1.93 + const meetsMinimum = major > 1 || (major === 1 && minor >= 93); + assert.ok( + meetsMinimum, + `engines.vscode "${enginesVscode}" is below 1.93 — TerminalManager uses ` + + `onDidStartTerminalShellExecution/onDidEndTerminalShellExecution which require VS Code 1.93+` + ); + }); + + // ----------------------------------------------------------------------- + // activationEvents must not be empty/too narrow + // ----------------------------------------------------------------------- + + test('activationEvents should include more than just onStartupFinished', () => { + const events = packageJson.activationEvents ?? []; + + // onStartupFinished alone is too narrow — commands and views should also trigger activation + const hasViewOrCommandTrigger = events.some( + e => e.startsWith('onView:') || e.startsWith('onCommand:') + ); + + assert.ok( + hasViewOrCommandTrigger, + `activationEvents only contains [${events.join(', ')}] — ` + + 'should include onView: or onCommand: triggers for reliable activation' + ); + }); + + // ----------------------------------------------------------------------- + // Command count sanity + // ----------------------------------------------------------------------- + + test('Extension contributes a reasonable number of commands', () => { + const commands = packageJson.contributes?.commands ?? []; + assert.ok( + commands.length >= 10, + `Expected at least 10 contributed commands, got ${commands.length}` + ); + }); +}); diff --git a/vscode-extension/test/suite/terminal-manager.test.ts b/vscode-extension/test/suite/terminal-manager.test.ts new file mode 100644 index 0000000..a02c5ca --- /dev/null +++ b/vscode-extension/test/suite/terminal-manager.test.ts @@ -0,0 +1,59 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; + +/** + * Tests for TerminalManager resilience. + * + * The TerminalManager constructor subscribes to terminal shell execution + * events (onDidStartTerminalShellExecution, onDidEndTerminalShellExecution) + * which were added in VS Code 1.93. If the extension declares a lower + * engines.vscode floor, the constructor must not throw when these APIs + * are unavailable. + */ +suite('TerminalManager Resilience Tests', () => { + + test('Terminal shell execution APIs exist on vscode.window', () => { + // This test documents that the APIs we depend on actually exist + // in the test VS Code version. If this fails, we're testing against + // a VS Code version older than 1.93 and TerminalManager will throw. + assert.ok( + typeof vscode.window.onDidStartTerminalShellExecution === 'function', + 'onDidStartTerminalShellExecution should be available on vscode.window' + ); + assert.ok( + typeof vscode.window.onDidEndTerminalShellExecution === 'function', + 'onDidEndTerminalShellExecution should be available on vscode.window' + ); + }); + + test('TerminalManager constructor should not throw', async () => { + // Dynamic import to catch constructor-time errors + const { TerminalManager } = await import('../../src/terminal-manager.js'); + + let manager: InstanceType | undefined; + assert.doesNotThrow(() => { + manager = new TerminalManager(); + }, 'TerminalManager constructor should not throw'); + + // Cleanup + if (manager) { + manager.dispose(); + } + }); + + test('TerminalManager should handle missing shell execution APIs gracefully', async () => { + // If shell execution APIs are guarded, constructing TerminalManager + // should succeed even conceptually without them. We verify here that + // the manager is functional after construction. + const { TerminalManager } = await import('../../src/terminal-manager.js'); + + const manager = new TerminalManager(); + + // Basic operations should work + assert.strictEqual(manager.exists('nonexistent'), false); + assert.strictEqual(manager.getActivity('nonexistent'), 'unknown'); + assert.deepStrictEqual(manager.list(), []); + + manager.dispose(); + }); +}); diff --git a/vscode-extension/webview-ui/types/defaults.ts b/vscode-extension/webview-ui/types/defaults.ts index e79a2c2..76ae68c 100644 --- a/vscode-extension/webview-ui/types/defaults.ts +++ b/vscode-extension/webview-ui/types/defaults.ts @@ -35,9 +35,9 @@ const DEFAULT_CONFIG: Config = { completed_history_hours: BigInt(24), summary_max_length: 80, panel_names: { + status: 'Status', queue: 'Queue', - agents: 'Agents', - awaiting: 'Awaiting', + in_progress: 'In Progress', completed: 'Completed', }, }, @@ -94,10 +94,13 @@ const DEFAULT_CONFIG: Config = { detected: [], providers: [], detection_complete: false, + default_tool: null, + default_model: null, skill_directory_overrides: {}, }, backstage: { enabled: false, + display: false, port: 7009, auto_start: false, subpath: '/backstage', From 51a97eaa9529137749b7fecd90ded5dc077ae316 Mon Sep 17 00:00:00 2001 From: untra Date: Sun, 29 Mar 2026 16:42:50 -0600 Subject: [PATCH 2/5] test updates Co-Authored-By: Claude Opus 4.5 --- src/rest/routes/launch.rs | 6 ++++-- vscode-extension/src/status-provider.ts | 4 ++-- vscode-extension/test/suite/status-provider.test.ts | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/rest/routes/launch.rs b/src/rest/routes/launch.rs index 9eb3c65..30f2e2e 100644 --- a/src/rest/routes/launch.rs +++ b/src/rest/routes/launch.rs @@ -456,8 +456,10 @@ mod tests { // --- Layered delegator resolution tests --- fn make_state_with_delegators(delegators: Vec) -> ApiState { - let mut config = Config::default(); - config.delegators = delegators; + let config = Config { + delegators, + ..Default::default() + }; ApiState::new(config, PathBuf::from("/tmp/test-launch")) } diff --git a/vscode-extension/src/status-provider.ts b/vscode-extension/src/status-provider.ts index b63e991..24a4f79 100644 --- a/vscode-extension/src/status-provider.ts +++ b/vscode-extension/src/status-provider.ts @@ -180,10 +180,10 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { const prerequisitesMet = (section: StatusSection): boolean => { return section.prerequisites.every(prereqId => { - // Prerequisite must itself be visible (transitive) and Green + // Prerequisite must itself be visible (transitive) and not Red const prereqSection = this.sectionMap.get(prereqId); if (!prereqSection) { return false; } - return prerequisitesMet(prereqSection) && getSectionHealth(prereqId) === 'Green'; + return prerequisitesMet(prereqSection) && getSectionHealth(prereqId) !== 'Red'; }); }; diff --git a/vscode-extension/test/suite/status-provider.test.ts b/vscode-extension/test/suite/status-provider.test.ts index cc3811e..acd964b 100644 --- a/vscode-extension/test/suite/status-provider.test.ts +++ b/vscode-extension/test/suite/status-provider.test.ts @@ -338,7 +338,7 @@ suite('Status Provider Test Suite', () => { const labels = getSectionLabels(provider.getChildren()); assert.deepStrictEqual( labels, - ['Configuration', 'Connections', 'Kanban', 'LLM Tools', 'Git'] + ['Configuration', 'Connections', 'Kanban', 'LLM Tools', 'Git', 'Delegators'] ); }); @@ -442,7 +442,7 @@ suite('Status Provider Test Suite', () => { const labels = getSectionLabels(provider.getChildren()); assert.deepStrictEqual( labels, - ['Configuration', 'Connections', 'Kanban', 'LLM Tools', 'Git', 'Issue Types', 'Managed Projects'] + ['Configuration', 'Connections', 'Kanban', 'LLM Tools', 'Git', 'Issue Types', 'Delegators', 'Managed Projects'] ); }); From 92b8e3b91ad6f243fb77b1b6725b041bf3214fbf Mon Sep 17 00:00:00 2001 From: untra Date: Sun, 29 Mar 2026 17:29:57 -0600 Subject: [PATCH 3/5] vscode coverage github projects support, kanban onboarding coordination, api derived kanban onboarding, seperate kanban issuetypes and operator issuetypes github projects integration tests Co-Authored-By: Claude Opus 4.5 --- .github/workflows/integration-tests.yml | 15 + .github/workflows/vscode-extension.yaml | 1 + README.md | 7 +- bindings/GithubCredentials.ts | 14 + bindings/GithubProjectInfoDto.ts | 26 + bindings/GithubProjectsConfig.ts | 33 + bindings/GithubSessionEnv.ts | 6 + bindings/GithubValidationDetailsDto.ts | 25 + bindings/JiraCredentials.ts | 22 + bindings/JiraIssueTypeRef.ts | 4 + bindings/JiraSessionEnv.ts | 6 + bindings/JiraValidationDetailsDto.ts | 14 + bindings/KanbanConfig.ts | 13 +- bindings/KanbanIssueTypeResponse.ts | 38 + bindings/KanbanProjectInfo.ts | 6 + bindings/KanbanProviderKind.ts | 6 + bindings/LinearCredentials.ts | 10 + bindings/LinearSessionEnv.ts | 6 + bindings/LinearTeamInfoDto.ts | 6 + bindings/LinearValidationDetailsDto.ts | 11 + bindings/ListKanbanProjectsRequest.ts | 10 + bindings/ListKanbanProjectsResponse.ts | 7 + bindings/ProjectSyncConfig.ts | 10 +- bindings/SetKanbanSessionEnvRequest.ts | 11 + bindings/SetKanbanSessionEnvResponse.ts | 18 + bindings/SyncKanbanIssueTypesResponse.ts | 15 + bindings/ValidateKanbanCredentialsRequest.ts | 10 + bindings/ValidateKanbanCredentialsResponse.ts | 12 + bindings/WriteGithubConfigBody.ts | 24 + bindings/WriteJiraConfigBody.ts | 6 + bindings/WriteKanbanConfigRequest.ts | 13 + bindings/WriteKanbanConfigResponse.ts | 15 + bindings/WriteLinearConfigBody.ts | 6 + docs/getting-started/kanban/github.md | 263 +++ docs/kanban/index.md | 10 + src/api/providers/kanban/github_projects.rs | 1717 +++++++++++++++++ src/api/providers/kanban/jira.rs | 151 +- src/api/providers/kanban/linear.rs | 283 ++- src/api/providers/kanban/mod.rs | 107 +- src/app/kanban.rs | 11 +- src/app/kanban_onboarding.rs | 372 ++++ src/app/keyboard.rs | 11 + src/app/mod.rs | 12 +- src/config.rs | 280 ++- src/issuetypes/kanban_type.rs | 293 +++ src/issuetypes/mod.rs | 1 + src/rest/dto.rs | 328 ++++ src/rest/mod.rs | 21 + src/rest/routes/kanban.rs | 114 +- src/rest/routes/kanban_onboarding.rs | 68 + src/rest/routes/mod.rs | 1 + src/services/kanban_issuetype_service.rs | 478 +++++ src/services/kanban_onboarding.rs | 602 ++++++ src/services/kanban_sync.rs | 154 +- src/services/mod.rs | 2 + src/ui/dialogs/kanban_onboarding.rs | 1169 +++++++++++ src/ui/dialogs/mod.rs | 5 + src/ui/dialogs/sync_confirm.rs | 9 +- src/ui/kanban_view.rs | 29 +- src/ui/mod.rs | 7 +- src/ui/setup/steps/kanban.rs | 14 +- tests/kanban_integration.rs | 400 +++- vscode-extension/package.json | 2 +- vscode-extension/src/api-client.ts | 154 ++ vscode-extension/src/config-panel.ts | 126 +- vscode-extension/src/git-onboarding.ts | 8 +- vscode-extension/src/kanban-onboarding.ts | 1158 +++++------ .../src/sections/kanban-section.ts | 57 +- vscode-extension/src/sections/types.ts | 2 +- vscode-extension/src/walkthrough.ts | 20 +- vscode-extension/test/suite/index.ts | 31 +- vscode-extension/webview-ui/App.tsx | 2 +- .../components/kanban/ProjectRow.tsx | 2 +- vscode-extension/webview-ui/types/defaults.ts | 1 + 74 files changed, 7936 insertions(+), 965 deletions(-) create mode 100644 bindings/GithubCredentials.ts create mode 100644 bindings/GithubProjectInfoDto.ts create mode 100644 bindings/GithubProjectsConfig.ts create mode 100644 bindings/GithubSessionEnv.ts create mode 100644 bindings/GithubValidationDetailsDto.ts create mode 100644 bindings/JiraCredentials.ts create mode 100644 bindings/JiraSessionEnv.ts create mode 100644 bindings/JiraValidationDetailsDto.ts create mode 100644 bindings/KanbanIssueTypeResponse.ts create mode 100644 bindings/KanbanProjectInfo.ts create mode 100644 bindings/KanbanProviderKind.ts create mode 100644 bindings/LinearCredentials.ts create mode 100644 bindings/LinearSessionEnv.ts create mode 100644 bindings/LinearTeamInfoDto.ts create mode 100644 bindings/LinearValidationDetailsDto.ts create mode 100644 bindings/ListKanbanProjectsRequest.ts create mode 100644 bindings/ListKanbanProjectsResponse.ts create mode 100644 bindings/SetKanbanSessionEnvRequest.ts create mode 100644 bindings/SetKanbanSessionEnvResponse.ts create mode 100644 bindings/SyncKanbanIssueTypesResponse.ts create mode 100644 bindings/ValidateKanbanCredentialsRequest.ts create mode 100644 bindings/ValidateKanbanCredentialsResponse.ts create mode 100644 bindings/WriteGithubConfigBody.ts create mode 100644 bindings/WriteJiraConfigBody.ts create mode 100644 bindings/WriteKanbanConfigRequest.ts create mode 100644 bindings/WriteKanbanConfigResponse.ts create mode 100644 bindings/WriteLinearConfigBody.ts create mode 100644 docs/getting-started/kanban/github.md create mode 100644 src/api/providers/kanban/github_projects.rs create mode 100644 src/app/kanban_onboarding.rs create mode 100644 src/issuetypes/kanban_type.rs create mode 100644 src/rest/routes/kanban_onboarding.rs create mode 100644 src/services/kanban_issuetype_service.rs create mode 100644 src/services/kanban_onboarding.rs create mode 100644 src/ui/dialogs/kanban_onboarding.rs diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 67521e8..f63a2f6 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -28,6 +28,10 @@ on: description: 'Run Linear integration tests' type: boolean default: true + run_github: + description: 'Run GitHub Projects integration tests' + type: boolean + default: true run_git: description: 'Run Git integration tests' type: boolean @@ -86,6 +90,15 @@ jobs: OPERATOR_LINEAR_TEST_TEAM: ${{ secrets.OPERATOR_LINEAR_TEST_TEAM }} run: cargo test --test kanban_integration linear_tests -- --nocapture --test-threads=1 + - name: Run GitHub Projects integration tests + if: >- + (github.event_name != 'workflow_dispatch' || inputs.run_github) && + env.OPERATOR_GITHUB_TOKEN != '' + env: + OPERATOR_GITHUB_TOKEN: ${{ secrets.OPERATOR_GITHUB_TOKEN }} + OPERATOR_GITHUB_TEST_PROJECT: ${{ secrets.OPERATOR_GITHUB_TEST_PROJECT }} + run: cargo test --test kanban_integration github_tests -- --nocapture --test-threads=1 + - name: Run cross-provider tests env: OPERATOR_JIRA_DOMAIN: ${{ secrets.OPERATOR_JIRA_DOMAIN }} @@ -94,6 +107,8 @@ jobs: OPERATOR_JIRA_TEST_PROJECT: ${{ secrets.OPERATOR_JIRA_TEST_PROJECT }} OPERATOR_LINEAR_API_KEY: ${{ secrets.OPERATOR_LINEAR_API_KEY }} OPERATOR_LINEAR_TEST_TEAM: ${{ secrets.OPERATOR_LINEAR_TEST_TEAM }} + OPERATOR_GITHUB_TOKEN: ${{ secrets.OPERATOR_GITHUB_TOKEN }} + OPERATOR_GITHUB_TEST_PROJECT: ${{ secrets.OPERATOR_GITHUB_TEST_PROJECT }} run: cargo test --test kanban_integration test_provider_interface_consistency -- --nocapture # Git Integration Tests diff --git a/.github/workflows/vscode-extension.yaml b/.github/workflows/vscode-extension.yaml index 81dfe69..2200805 100644 --- a/.github/workflows/vscode-extension.yaml +++ b/.github/workflows/vscode-extension.yaml @@ -60,6 +60,7 @@ jobs: DISPLAY: ':99.0' - name: Upload coverage to Codecov + if: always() uses: codecov/codecov-action@v5 with: files: vscode-extension/coverage/lcov.info diff --git a/README.md b/README.md index 36c749d..02ac29e 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,10 @@ # Operator! [![GitHub Tag](https://img.shields.io/github/v/tag/untra/operator)](https://github.com/untra/operator/releases) [![codecov](https://codecov.io/gh/untra/operator/branch/main/graph/badge.svg)](https://codecov.io/gh/untra/operator) [![VS Code Marketplace Installs](https://img.shields.io/visual-studio-marketplace/i/untra.operator-terminals?label=VS%20Code%20Installs)](https://marketplace.visualstudio.com/items?itemName=untra.operator-terminals) -**Session** [![tmux](https://img.shields.io/badge/tmux-1BB91F?logo=tmux&logoColor=white)](https://operator.untra.io/getting-started/sessions/tmux/) [![cmux](https://img.shields.io/badge/cmux-333333)](https://operator.untra.io/getting-started/sessions/cmux/) [![Zellij](https://img.shields.io/badge/Zellij-E8590C)](https://operator.untra.io/getting-started/sessions/zellij/) **|** **LLM Tool** [![Claude](https://img.shields.io/badge/Claude-D97757?logo=claude&logoColor=white)](https://operator.untra.io/getting-started/agents/claude/) [![Codex](https://img.shields.io/badge/Codex-000000?logo=openai&logoColor=white)](https://operator.untra.io/getting-started/agents/codex/) [![Gemini CLI](https://img.shields.io/badge/Gemini_CLI-8E75B2?logo=googlegemini&logoColor=white)](https://operator.untra.io/getting-started/agents/gemini-cli/) **|** **Kanban Provider** [![Jira](https://img.shields.io/badge/Jira-0052CC?logo=jira&logoColor=white)](https://operator.untra.io/getting-started/kanban/jira/) [![Linear](https://img.shields.io/badge/Linear-5E6AD2?logo=linear&logoColor=white)](https://operator.untra.io/getting-started/kanban/linear/) **|** **Git Version Control** [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://operator.untra.io/getting-started/git/github/) +**|** **Session** [![tmux](https://img.shields.io/badge/tmux-1BB91F?logo=tmux&logoColor=white)](https://operator.untra.io/getting-started/sessions/tmux/) [![cmux](https://img.shields.io/badge/cmux-333333)](https://operator.untra.io/getting-started/sessions/cmux/) [![Zellij](https://img.shields.io/badge/Zellij-E8590C)](https://operator.untra.io/getting-started/sessions/zellij/) +**|** **LLM Tool** [![Claude](https://img.shields.io/badge/Claude-D97757?logo=claude&logoColor=white)](https://operator.untra.io/getting-started/agents/claude/) [![Codex](https://img.shields.io/badge/Codex-000000?logo=openai&logoColor=white)](https://operator.untra.io/getting-started/agents/codex/) [![Gemini CLI](https://img.shields.io/badge/Gemini_CLI-8E75B2?logo=googlegemini&logoColor=white)](https://operator.untra.io/getting-started/agents/gemini-cli/) +**|** **Kanban Provider** [![Jira](https://img.shields.io/badge/Jira-0052CC?logo=jira&logoColor=white)](https://operator.untra.io/getting-started/kanban/jira/) [![Linear](https://img.shields.io/badge/Linear-5E6AD2?logo=linear&logoColor=white)](https://operator.untra.io/getting-started/kanban/linear/) [![GitHub Projects](https://img.shields.io/badge/GitHub_Projects-181717?logo=github&logoColor=white)](https://operator.untra.io/getting-started/kanban/github/) +**|** **Git Version Control** [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://operator.untra.io/getting-started/git/github/) An orchestration tool for [**AI-assisted**](https://operator.untra.io/getting-started/agents/) [_kanban-shaped_](https://operator.untra.io/getting-started/kanban/) [git-versioned](https://operator.untra.io/getting-started/git/) software development. @@ -11,7 +14,7 @@ An orchestration tool for [**AI-assisted**](https://operator.untra.io/getting-st **Operator** is for you if: -- you do work assigned from tickets on a kanban board, such as [_Jira Cloud_](https://operator.untra.io/getting-started/kanban/jira/) or [_Linear_](https://operator.untra.io/getting-started/kanban/linear/) +- you do work assigned from tickets on a kanban board, such as [_Jira Cloud_](https://operator.untra.io/getting-started/kanban/jira/), [_Linear_](https://operator.untra.io/getting-started/kanban/linear/), or [_GitHub Projects_](https://operator.untra.io/getting-started/kanban/github/) - you use LLM assisted coding agent tools to accomplish work, such as [_Claude Code_](https://operator.untra.io/getting-started/agents/claude/), [_OpenAI Codex_](https://operator.untra.io/getting-started/agents/codex/), or [_Gemini CLI_](https://operator.untra.io/getting-started/agents/gemini-cli/) - your work is version controlled with a git repository provider like [_GitHub_](https://operator.untra.io/getting-started/git/github/) or [_GitLab_](https://operator.untra.io/getting-started/git/gitlab/) diff --git a/bindings/GithubCredentials.ts b/bindings/GithubCredentials.ts new file mode 100644 index 0000000..bc1baba --- /dev/null +++ b/bindings/GithubCredentials.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Ephemeral GitHub Projects credentials supplied by a client during onboarding. + * + * The token must have `project` (or `read:project`) scope. A repo-only token + * (the kind used for `GITHUB_TOKEN` and operator's git provider) will be + * rejected at validation time with a friendly "lacks `project` scope" error. + */ +export type GithubCredentials = { +/** + * GitHub PAT, fine-grained PAT, or app installation token + */ +token: string, }; diff --git a/bindings/GithubProjectInfoDto.ts b/bindings/GithubProjectInfoDto.ts new file mode 100644 index 0000000..a73e686 --- /dev/null +++ b/bindings/GithubProjectInfoDto.ts @@ -0,0 +1,26 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A GitHub Project v2 surfaced during onboarding for project picker UIs. + */ +export type GithubProjectInfoDto = { +/** + * `GraphQL` node ID (e.g., `PVT_kwDOABcdefg`) — used as the project key + */ +node_id: string, +/** + * Project number (e.g., 42) within the owner + */ +number: number, +/** + * Human-readable project title + */ +title: string, +/** + * Owner login (org or user name) + */ +owner_login: string, +/** + * "Organization" or "User" + */ +owner_kind: string, }; diff --git a/bindings/GithubProjectsConfig.ts b/bindings/GithubProjectsConfig.ts new file mode 100644 index 0000000..d372e4a --- /dev/null +++ b/bindings/GithubProjectsConfig.ts @@ -0,0 +1,33 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ProjectSyncConfig } from "./ProjectSyncConfig"; + +/** + * GitHub Projects v2 (kanban) provider configuration + * + * The owner login (user or org) is specified as the `HashMap` key in + * `KanbanConfig.github`. Project keys inside `projects` are `GraphQL` node + * IDs (e.g., `PVT_kwDOABcdefg`) — opaque, stable identifiers used directly + * by every GitHub Projects v2 mutation without needing a lookup. + * + * **Distinct from `GitHubConfig`** (the git provider used for PR/branch + * operations). They live in different parts of the config tree, use + * different env vars (`OPERATOR_GITHUB_TOKEN` vs `GITHUB_TOKEN`), and + * require different OAuth scopes (`project` vs `repo`). See + * `docs/getting-started/kanban/github.md` for the full rationale. + */ +export type GithubProjectsConfig = { +/** + * Whether this provider is enabled + */ +enabled: boolean, +/** + * Environment variable name containing the GitHub token (default: + * `OPERATOR_GITHUB_TOKEN`). The token must have `project` (or + * `read:project`) scope, NOT just `repo` — see the disambiguation + * guide in the kanban github docs. + */ +api_key_env: string, +/** + * Per-project sync configuration. Keys are `GraphQL` project node IDs. + */ +projects: { [key in string]?: ProjectSyncConfig }, }; diff --git a/bindings/GithubSessionEnv.ts b/bindings/GithubSessionEnv.ts new file mode 100644 index 0000000..9dc5afb --- /dev/null +++ b/bindings/GithubSessionEnv.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * GitHub Projects session env body — includes the actual secret to set in env. + */ +export type GithubSessionEnv = { token: string, api_key_env: string, }; diff --git a/bindings/GithubValidationDetailsDto.ts b/bindings/GithubValidationDetailsDto.ts new file mode 100644 index 0000000..59d3b9d --- /dev/null +++ b/bindings/GithubValidationDetailsDto.ts @@ -0,0 +1,25 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GithubProjectInfoDto } from "./GithubProjectInfoDto"; + +/** + * GitHub-specific validation details (returned on success). + */ +export type GithubValidationDetailsDto = { +/** + * Authenticated user's login (e.g., "octocat") + */ +user_login: string, +/** + * Authenticated user's numeric `databaseId` as a string (used as `sync_user_id`) + */ +user_id: string, +/** + * All Projects v2 visible to the token (across viewer + organizations) + */ +projects: Array, +/** + * The env var name the validated token came from. Used by clients to + * display "Connected via `OPERATOR_GITHUB_TOKEN`" so users can rotate the + * right token. See Token Disambiguation in the kanban github docs. + */ +resolved_env_var: string, }; diff --git a/bindings/JiraCredentials.ts b/bindings/JiraCredentials.ts new file mode 100644 index 0000000..0dd4a45 --- /dev/null +++ b/bindings/JiraCredentials.ts @@ -0,0 +1,22 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Ephemeral Jira credentials supplied by a client during onboarding. + * + * These are never persisted to disk by the onboarding endpoints that take + * this struct — the actual secret stays in the env var named in + * `api_key_env` once set via `/api/v1/kanban/session-env`. + */ +export type JiraCredentials = { +/** + * Jira Cloud domain (e.g., "acme.atlassian.net") + */ +domain: string, +/** + * Atlassian account email for Basic Auth + */ +email: string, +/** + * API token / personal access token + */ +api_token: string, }; diff --git a/bindings/JiraIssueTypeRef.ts b/bindings/JiraIssueTypeRef.ts index 0199373..1b0d38a 100644 --- a/bindings/JiraIssueTypeRef.ts +++ b/bindings/JiraIssueTypeRef.ts @@ -4,6 +4,10 @@ * Reference to an issue type */ export type JiraIssueTypeRef = { +/** + * Issue type ID (e.g., "10001") + */ +id: string | null, /** * Issue type name (e.g., "Bug", "Story", "Task") */ diff --git a/bindings/JiraSessionEnv.ts b/bindings/JiraSessionEnv.ts new file mode 100644 index 0000000..66f8729 --- /dev/null +++ b/bindings/JiraSessionEnv.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Jira session env body — includes the actual secret to set in env. + */ +export type JiraSessionEnv = { domain: string, email: string, api_token: string, api_key_env: string, }; diff --git a/bindings/JiraValidationDetailsDto.ts b/bindings/JiraValidationDetailsDto.ts new file mode 100644 index 0000000..5f4c79e --- /dev/null +++ b/bindings/JiraValidationDetailsDto.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Jira-specific validation details (returned on success). + */ +export type JiraValidationDetailsDto = { +/** + * Atlassian accountId (used as `sync_user_id`) + */ +account_id: string, +/** + * User display name + */ +display_name: string, }; diff --git a/bindings/KanbanConfig.ts b/bindings/KanbanConfig.ts index dcf4fb9..e90992f 100644 --- a/bindings/KanbanConfig.ts +++ b/bindings/KanbanConfig.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GithubProjectsConfig } from "./GithubProjectsConfig"; import type { JiraConfig } from "./JiraConfig"; import type { LinearConfig } from "./LinearConfig"; @@ -8,6 +9,7 @@ import type { LinearConfig } from "./LinearConfig"; * Providers are keyed by domain/workspace: * - Jira: keyed by domain (e.g., "foobar.atlassian.net") * - Linear: keyed by workspace slug (e.g., "myworkspace") + * - GitHub Projects: keyed by owner login (e.g., "my-org") */ export type KanbanConfig = { /** @@ -17,4 +19,13 @@ jira: { [key in string]?: JiraConfig }, /** * Linear instances keyed by workspace slug */ -linear: { [key in string]?: LinearConfig }, }; +linear: { [key in string]?: LinearConfig }, +/** + * GitHub Projects v2 instances keyed by owner login (user or org) + * + * NOTE: This is the *kanban* GitHub integration (Projects v2), distinct + * from `GitHubConfig` which is the *git provider* used for PRs and + * branches. The two use different env vars and different scopes — see + * `docs/getting-started/kanban/github.md` for the full disambiguation. + */ +github: { [key in string]?: GithubProjectsConfig }, }; diff --git a/bindings/KanbanIssueTypeResponse.ts b/bindings/KanbanIssueTypeResponse.ts new file mode 100644 index 0000000..df00e44 --- /dev/null +++ b/bindings/KanbanIssueTypeResponse.ts @@ -0,0 +1,38 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A synced kanban issue type from the persisted catalog. + */ +export type KanbanIssueTypeResponse = { +/** + * Provider-specific ID (Jira type ID, Linear label ID) + */ +id: string, +/** + * Display name (e.g., "Bug", "Story", "Task") + */ +name: string, +/** + * Description from the provider + */ +description: string | null, +/** + * Icon/avatar URL from the provider + */ +icon_url: string | null, +/** + * Provider name ("jira", "linear", or "github") + */ +provider: string, +/** + * Project/team key + */ +project: string, +/** + * What this type represents in the provider ("issuetype" or "label") + */ +source_kind: string, +/** + * ISO 8601 timestamp of last sync + */ +synced_at: string, }; diff --git a/bindings/KanbanProjectInfo.ts b/bindings/KanbanProjectInfo.ts new file mode 100644 index 0000000..d69f793 --- /dev/null +++ b/bindings/KanbanProjectInfo.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A project/team entry returned by `list_projects`. + */ +export type KanbanProjectInfo = { id: string, key: string, name: string, }; diff --git a/bindings/KanbanProviderKind.ts b/bindings/KanbanProviderKind.ts new file mode 100644 index 0000000..ee00687 --- /dev/null +++ b/bindings/KanbanProviderKind.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Which kanban provider an onboarding request targets. + */ +export type KanbanProviderKind = "jira" | "linear" | "github"; diff --git a/bindings/LinearCredentials.ts b/bindings/LinearCredentials.ts new file mode 100644 index 0000000..0584fbc --- /dev/null +++ b/bindings/LinearCredentials.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Ephemeral Linear credentials supplied by a client during onboarding. + */ +export type LinearCredentials = { +/** + * Linear API key (prefixed `lin_api_`) + */ +api_key: string, }; diff --git a/bindings/LinearSessionEnv.ts b/bindings/LinearSessionEnv.ts new file mode 100644 index 0000000..81c50d9 --- /dev/null +++ b/bindings/LinearSessionEnv.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Linear session env body — includes the actual secret to set in env. + */ +export type LinearSessionEnv = { api_key: string, api_key_env: string, }; diff --git a/bindings/LinearTeamInfoDto.ts b/bindings/LinearTeamInfoDto.ts new file mode 100644 index 0000000..85dd37f --- /dev/null +++ b/bindings/LinearTeamInfoDto.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A Linear team exposed to onboarding clients for project selection. + */ +export type LinearTeamInfoDto = { id: string, key: string, name: string, }; diff --git a/bindings/LinearValidationDetailsDto.ts b/bindings/LinearValidationDetailsDto.ts new file mode 100644 index 0000000..8eafa30 --- /dev/null +++ b/bindings/LinearValidationDetailsDto.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LinearTeamInfoDto } from "./LinearTeamInfoDto"; + +/** + * Linear-specific validation details (returned on success). + */ +export type LinearValidationDetailsDto = { +/** + * Linear viewer user ID (used as `sync_user_id`) + */ +user_id: string, user_name: string, org_name: string, teams: Array, }; diff --git a/bindings/ListKanbanProjectsRequest.ts b/bindings/ListKanbanProjectsRequest.ts new file mode 100644 index 0000000..6ca8c6e --- /dev/null +++ b/bindings/ListKanbanProjectsRequest.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GithubCredentials } from "./GithubCredentials"; +import type { JiraCredentials } from "./JiraCredentials"; +import type { KanbanProviderKind } from "./KanbanProviderKind"; +import type { LinearCredentials } from "./LinearCredentials"; + +/** + * Request to list projects/teams from a provider using ephemeral creds. + */ +export type ListKanbanProjectsRequest = { provider: KanbanProviderKind, jira: JiraCredentials | null, linear: LinearCredentials | null, github: GithubCredentials | null, }; diff --git a/bindings/ListKanbanProjectsResponse.ts b/bindings/ListKanbanProjectsResponse.ts new file mode 100644 index 0000000..6b85270 --- /dev/null +++ b/bindings/ListKanbanProjectsResponse.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { KanbanProjectInfo } from "./KanbanProjectInfo"; + +/** + * Response wrapper for list-projects (wrapped for utoipa compatibility). + */ +export type ListKanbanProjectsResponse = { projects: Array, }; diff --git a/bindings/ProjectSyncConfig.ts b/bindings/ProjectSyncConfig.ts index e132668..9b433a0 100644 --- a/bindings/ProjectSyncConfig.ts +++ b/bindings/ProjectSyncConfig.ts @@ -8,6 +8,7 @@ export type ProjectSyncConfig = { * User ID to sync issues for (provider-specific format) * - Jira: accountId (e.g., "5e3f7acd9876543210abcdef") * - Linear: user ID (e.g., "abc12345-6789-0abc-def0-123456789abc") + * - GitHub Projects: numeric GitHub `databaseId` (e.g., "12345678") */ sync_user_id: string, /** @@ -15,11 +16,12 @@ sync_user_id: string, */ sync_statuses: Array, /** - * `IssueTypeCollection` name this project maps to + * Optional `IssueTypeCollection` name this project maps to. + * Not required for kanban onboarding or sync. */ -collection_name: string, +collection_name: string | null, /** - * Optional explicit mapping overrides: external issue type name → operator issue type key - * When empty, convention-based auto-matching is used (Bug→FIX, Story→FEAT, etc.) + * Explicit mapping: kanban issue type ID → operator issue type key (e.g., TASK, FEAT, FIX). + * Multiple kanban types can map to the same operator template. */ type_mappings: { [key in string]?: string }, }; diff --git a/bindings/SetKanbanSessionEnvRequest.ts b/bindings/SetKanbanSessionEnvRequest.ts new file mode 100644 index 0000000..506fd2d --- /dev/null +++ b/bindings/SetKanbanSessionEnvRequest.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GithubSessionEnv } from "./GithubSessionEnv"; +import type { JiraSessionEnv } from "./JiraSessionEnv"; +import type { KanbanProviderKind } from "./KanbanProviderKind"; +import type { LinearSessionEnv } from "./LinearSessionEnv"; + +/** + * Request to set kanban-related env vars on the server for the current + * session so subsequent `from_config` calls find the API key. + */ +export type SetKanbanSessionEnvRequest = { provider: KanbanProviderKind, jira: JiraSessionEnv | null, linear: LinearSessionEnv | null, github: GithubSessionEnv | null, }; diff --git a/bindings/SetKanbanSessionEnvResponse.ts b/bindings/SetKanbanSessionEnvResponse.ts new file mode 100644 index 0000000..92cb749 --- /dev/null +++ b/bindings/SetKanbanSessionEnvResponse.ts @@ -0,0 +1,18 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Response from setting session env vars. + * + * `shell_export_block` uses `` placeholders, NOT the actual + * secret — it is meant for the user to copy into their shell profile. + */ +export type SetKanbanSessionEnvResponse = { +/** + * Names (not values) of env vars that were set in the server process. + */ +env_vars_set: Array, +/** + * Multi-line `export FOO=""` block for the user to copy + * into `~/.zshrc` / `~/.bashrc`. + */ +shell_export_block: string, }; diff --git a/bindings/SyncKanbanIssueTypesResponse.ts b/bindings/SyncKanbanIssueTypesResponse.ts new file mode 100644 index 0000000..3986643 --- /dev/null +++ b/bindings/SyncKanbanIssueTypesResponse.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { KanbanIssueTypeResponse } from "./KanbanIssueTypeResponse"; + +/** + * Response from syncing kanban issue types from a provider. + */ +export type SyncKanbanIssueTypesResponse = { +/** + * Number of issue types synced + */ +synced: number, +/** + * The synced issue types + */ +types: Array, }; diff --git a/bindings/ValidateKanbanCredentialsRequest.ts b/bindings/ValidateKanbanCredentialsRequest.ts new file mode 100644 index 0000000..bc6c262 --- /dev/null +++ b/bindings/ValidateKanbanCredentialsRequest.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GithubCredentials } from "./GithubCredentials"; +import type { JiraCredentials } from "./JiraCredentials"; +import type { KanbanProviderKind } from "./KanbanProviderKind"; +import type { LinearCredentials } from "./LinearCredentials"; + +/** + * Request to validate kanban credentials without persisting them. + */ +export type ValidateKanbanCredentialsRequest = { provider: KanbanProviderKind, jira: JiraCredentials | null, linear: LinearCredentials | null, github: GithubCredentials | null, }; diff --git a/bindings/ValidateKanbanCredentialsResponse.ts b/bindings/ValidateKanbanCredentialsResponse.ts new file mode 100644 index 0000000..090cbc5 --- /dev/null +++ b/bindings/ValidateKanbanCredentialsResponse.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GithubValidationDetailsDto } from "./GithubValidationDetailsDto"; +import type { JiraValidationDetailsDto } from "./JiraValidationDetailsDto"; +import type { LinearValidationDetailsDto } from "./LinearValidationDetailsDto"; + +/** + * Response from validating kanban credentials. + * + * `valid: false` is returned for auth failures — never a 4xx/5xx HTTP + * status — so clients can display `error` inline without exception handling. + */ +export type ValidateKanbanCredentialsResponse = { valid: boolean, error: string | null, jira: JiraValidationDetailsDto | null, linear: LinearValidationDetailsDto | null, github: GithubValidationDetailsDto | null, }; diff --git a/bindings/WriteGithubConfigBody.ts b/bindings/WriteGithubConfigBody.ts new file mode 100644 index 0000000..116ff8a --- /dev/null +++ b/bindings/WriteGithubConfigBody.ts @@ -0,0 +1,24 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Body for writing a GitHub Projects v2 config section. + */ +export type WriteGithubConfigBody = { +/** + * GitHub owner login (user or org), used as the workspace key + */ +owner: string, +/** + * Env var name where the project-scoped token is set + * (default: `OPERATOR_GITHUB_TOKEN`). MUST be distinct from `GITHUB_TOKEN` + * — see Token Disambiguation in the kanban github docs. + */ +api_key_env: string, +/** + * `GraphQL` project node ID (e.g., `PVT_kwDOABcdefg`) + */ +project_key: string, +/** + * Numeric GitHub `databaseId` of the user whose items to sync + */ +sync_user_id: string, }; diff --git a/bindings/WriteJiraConfigBody.ts b/bindings/WriteJiraConfigBody.ts new file mode 100644 index 0000000..bb7b1fd --- /dev/null +++ b/bindings/WriteJiraConfigBody.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Body for writing a Jira project config section. + */ +export type WriteJiraConfigBody = { domain: string, email: string, api_key_env: string, project_key: string, sync_user_id: string, }; diff --git a/bindings/WriteKanbanConfigRequest.ts b/bindings/WriteKanbanConfigRequest.ts new file mode 100644 index 0000000..ca79427 --- /dev/null +++ b/bindings/WriteKanbanConfigRequest.ts @@ -0,0 +1,13 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { KanbanProviderKind } from "./KanbanProviderKind"; +import type { WriteGithubConfigBody } from "./WriteGithubConfigBody"; +import type { WriteJiraConfigBody } from "./WriteJiraConfigBody"; +import type { WriteLinearConfigBody } from "./WriteLinearConfigBody"; + +/** + * Request to write or upsert a kanban config section. + * + * This endpoint does NOT take the secret — only the env var NAME + * (`api_key_env`). The secret is set via `/api/v1/kanban/session-env`. + */ +export type WriteKanbanConfigRequest = { provider: KanbanProviderKind, jira: WriteJiraConfigBody | null, linear: WriteLinearConfigBody | null, github: WriteGithubConfigBody | null, }; diff --git a/bindings/WriteKanbanConfigResponse.ts b/bindings/WriteKanbanConfigResponse.ts new file mode 100644 index 0000000..582b14f --- /dev/null +++ b/bindings/WriteKanbanConfigResponse.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Response after writing a kanban config section. + */ +export type WriteKanbanConfigResponse = { +/** + * Filesystem path that was written (e.g., ".tickets/operator/config.toml") + */ +written_path: string, +/** + * Header of the top-level section that was upserted + * (e.g., `[kanban.jira."acme.atlassian.net"]`) + */ +section_header: string, }; diff --git a/bindings/WriteLinearConfigBody.ts b/bindings/WriteLinearConfigBody.ts new file mode 100644 index 0000000..b9ae3e7 --- /dev/null +++ b/bindings/WriteLinearConfigBody.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Body for writing a Linear project/team config section. + */ +export type WriteLinearConfigBody = { workspace_key: string, api_key_env: string, project_key: string, sync_user_id: string, }; diff --git a/docs/getting-started/kanban/github.md b/docs/getting-started/kanban/github.md new file mode 100644 index 0000000..320f5b1 --- /dev/null +++ b/docs/getting-started/kanban/github.md @@ -0,0 +1,263 @@ +--- +title: "GitHub Projects" +description: "Configure GitHub Projects v2 integration with Operator." +layout: doc +--- + +# GitHub Projects + +Connect Operator to [**GitHub Projects v2**](https://docs.github.com/en/issues/planning-and-tracking-with-projects/learning-about-projects/about-projects) for issue tracking and project management. + +> **⚠ Token Disambiguation — read this first** +> +> GitHub Projects uses a **separate** API token from Operator's git provider (the one that creates pull requests). Even if you've already set `GITHUB_TOKEN` for PR workflows, you'll need a *second* token in `OPERATOR_GITHUB_TOKEN` with the `project` (or `read:project`) scope. The two **can** be the same physical PAT minted with both scopes — but they must be exposed via two different environment variables so Operator can route them correctly. +> +> | Operator subsystem | Env var | Required scopes | Configured at | +> |-------------------------------|--------------------------|--------------------------------------------------|------------------------------------| +> | Git provider (PRs, branches) | `GITHUB_TOKEN` | `repo` (or fine-grained Contents + PRs) | `[git.github]` | +> | Kanban provider (Projects v2) | `OPERATOR_GITHUB_TOKEN` | `project` or `read:project` (or fine-grained Projects) | `[kanban.github.""]` | +> +> Operator deliberately **does not** fall back from `OPERATOR_GITHUB_TOKEN` to `GITHUB_TOKEN`. Silently using a repo-scoped token would produce confusing 403s deep in the sync loop. If only `GITHUB_TOKEN` is set, the kanban provider stays inactive. + +## Prerequisites + +- A GitHub account with access to at least one Project v2 (user-owned or org-owned) +- A Personal Access Token (PAT) — classic or fine-grained — with the `project` scope, or a GitHub App installation token with `organization_projects: write` +- Operator installed and running + +## Create a Token + +You have two options. **Fine-grained PATs are recommended** because they're scoped to specific orgs/repos and have built-in expiration. + +### Option A — Classic Personal Access Token (simpler) + +1. Go to [github.com/settings/tokens](https://github.com/settings/tokens) +2. Click **Generate new token (classic)** +3. Name it something like *"Operator Kanban (read+write)"* +4. Select scopes: + - `project` (full read + write to Projects v2) — **or** `read:project` (read-only) + - Optionally `read:org` if you need to enumerate org projects +5. Click **Generate token**, then copy the `ghp_...` value + +### Option B — Fine-Grained Personal Access Token (recommended) + +1. Go to [github.com/settings/personal-access-tokens](https://github.com/settings/personal-access-tokens) +2. Click **Generate new token** +3. **Resource owner**: select the user or org that owns the projects you want to sync +4. **Repository access**: select the repos whose issues should appear as project items (use *Public Repositories* for read-only org-wide access, or *Selected repositories* for tighter scoping) +5. **Permissions**: + - **Organization → Projects**: Read-and-write (or Read-only) + - **Repository → Issues**: Read (so issue content is fetched alongside project items) + - **Repository → Contents**: Read (only if you also want body/labels) +6. Click **Generate token**, then copy the `github_pat_...` value + +## Configuration + +### 1. Export the token + +```bash +# Kanban projects token (this guide) +export OPERATOR_GITHUB_TOKEN="ghp_xxxxxxxxxxxxxxxx" + +# Optional: separate token for git/PR operations (NOT this guide) +export GITHUB_TOKEN="ghp_yyyyyyyyyyyyyyyy" +``` + +### 2. Add a kanban section to `~/.config/operator/config.toml` + +```toml +[kanban.github."my-org"] +enabled = true +api_key_env = "OPERATOR_GITHUB_TOKEN" # default + +[kanban.github."my-org".projects.PVT_kwDOABcdefg] +sync_user_id = "12345678" # numeric GitHub `databaseId` +sync_statuses = ["In Progress", "Todo"] +collection_name = "dev_kanban" +``` + +The hashmap key under `[kanban.github.""]` is the GitHub owner login (user or org). Project keys inside `projects` are **GraphQL node IDs** (e.g. `PVT_kwDOABcdefg`) — not project numbers — because every Projects v2 mutation needs the node ID and storing it directly avoids an extra lookup per call. + +### 3. Multiple Owners with Different Tokens + +You can scope distinct tokens per owner via `api_key_env`: + +```toml +[kanban.github."my-personal-account"] +enabled = true +api_key_env = "OPERATOR_GITHUB_TOKEN" # personal PAT + +[kanban.github."my-employer-org"] +enabled = true +api_key_env = "OPERATOR_GITHUB_WORK_TOKEN" # work fine-grained PAT +``` + +Then set both env vars: + +```bash +export OPERATOR_GITHUB_TOKEN="ghp_personal..." +export OPERATOR_GITHUB_WORK_TOKEN="github_pat_work..." +``` + +## Finding Your Project Node ID + +Easiest path is via `gh`: + +```bash +gh api graphql -f query=' +query { + viewer { + projectsV2(first: 10) { + nodes { id number title owner { ... on Organization { login } ... on User { login } } } + } + } +} +' +``` + +For org-owned projects: + +```bash +gh api graphql -f query=' +query($login: String!) { + organization(login: $login) { + projectsV2(first: 20) { + nodes { id number title } + } + } +} +' -F login=my-org +``` + +The `id` field is what you put in `[kanban.github."".projects.]`. + +If you'd rather skip this step, use the **VS Code extension** or **Operator TUI** onboarding flow — both will list your projects after validating your token and write the config for you. + +## Finding Your `sync_user_id` + +`sync_user_id` is your GitHub user's numeric `databaseId` (NOT your login string). The validation step in onboarding fetches this for you, but you can also get it manually: + +```bash +gh api user --jq .id +# 12345678 +``` + +Or via GraphQL: + +```bash +gh api graphql -f query='query { viewer { databaseId login } }' +``` + +## Issue Mapping + +Operator's GitHub Projects provider exposes issue types via two paths, in order of preference: + +1. **Org-level Issue Types** (recommended where available) — the new first-class GitHub feature. See [docs.github.com/en/issues/tracking-your-work-with-issues/configuring-issues/managing-issue-types-in-an-organization](https://docs.github.com/en/issues/tracking-your-work-with-issues/configuring-issues/managing-issue-types-in-an-organization). If your org has issue types configured, the provider exposes them directly. +2. **Repo labels (fallback)** — when issue types aren't available (user-owned projects or orgs without the feature), the provider aggregates labels from all repos linked through project items. + +Configure mappings via `type_mappings` in your `ProjectSyncConfig`: + +| GitHub source | Operator type | +|--------------------------------|---------------| +| `bug` (label) / `Bug` (issue type) | `FIX` | +| `feature` (label) / `Feature` (issue type) | `FEAT` | +| `enhancement` (label) | `FEAT` | +| `spike` (label) / `Spike` (issue type) | `SPIKE` | + +Operator's `kanban_issuetype_service` syncs the available types into a local catalog at `.tickets/operator/kanban/github//issuetypes.json` after onboarding completes. + +## Per-Project Configuration + +```toml +[kanban.github."my-org".projects.PVT_kwDOABcdefg] +sync_user_id = "12345678" # your numeric GitHub databaseId +sync_statuses = ["In Progress", "Todo"] # Status field option names to sync +collection_name = "dev_kanban" # IssueTypeCollection to use + +[kanban.github."my-org".projects.PVT_kwDOABcdefg.type_mappings] +"L_bug" = "FIX" +"L_feature" = "FEAT" +"L_spike" = "SPIKE" +``` + +The keys in `type_mappings` are the GraphQL label IDs (or issue type IDs) returned by `get_issue_types()` — they're persisted in the local issue type catalog after the first sync, and you can find them with: + +```bash +cat .tickets/operator/kanban/github/PVT_kwDOABcdefg/issuetypes.json +``` + +## Syncing Issues + +Pull issues from GitHub Projects: + +```bash +operator sync +``` + +The provider client-side filters by your `sync_user_id` (project items don't support server-side assignee filtering in the GraphQL API), so very large projects may pull a few extra pages before applying the filter. Status filtering uses the `Status` single-select field's option names — make sure the values in `sync_statuses` exactly match the names defined in your project (case-insensitive). + +### What gets synced + +- **Real issues** linked to the project +- **Pull requests** linked to the project +- **Draft issues** (project-only items, no underlying repo issue) + +The `key` field on the synced ticket follows these formats: + +| Item type | Key format | +|---------------|---------------------------| +| Issue | `octocat/hello#42` | +| Pull request | `octocat/hello!42` | +| Draft issue | `draft:PVTI_lAHO_xxxxxxx` | + +## Creating New Issues + +For v1, the GitHub Projects provider creates **draft issues only** via the `addProjectV2DraftIssue` mutation. Draft issues live inside the project (not in any repo) and can be promoted to real issues later from the GitHub UI. + +If you need real repo issues, create them through GitHub's normal flows — they'll appear in operator after the next sync if they're added to a project the operator is configured for. + +## Troubleshooting + +### "Token authenticated but lacks 'project' scope" + +This is the disambiguation guard rail firing. It means the token reached GitHub's API successfully but doesn't have the `project` scope — most likely you accidentally pasted your `GITHUB_TOKEN` (which is repo-scoped for PR workflows). Re-mint a token with the `project` (or `read:project`) scope and re-run onboarding. + +If you're using a fine-grained PAT and you're sure it has Projects permissions, double-check the **Resource owner** matches the org/user whose projects you're trying to sync — fine-grained PATs are scoped per resource owner. + +### Authentication errors + +Verify your token reaches the API: + +```bash +curl -H "Authorization: bearer $OPERATOR_GITHUB_TOKEN" \ + -H "User-Agent: operator" \ + https://api.github.com/graphql \ + -d '{"query":"{ viewer { login databaseId } }"}' +``` + +For classic PATs, also check the response headers — they include `x-oauth-scopes`: + +```bash +curl -i -H "Authorization: bearer $OPERATOR_GITHUB_TOKEN" \ + https://api.github.com/user 2>&1 | grep -i x-oauth-scopes +# x-oauth-scopes: project, read:org, repo +``` + +If `project` (or `read:project`) is missing, that's your problem. + +### Missing issues + +- Confirm `sync_user_id` is the numeric `databaseId`, **not** your login. `gh api user --jq .id` returns the right value. +- Confirm the issue is actually assigned to that user. Operator filters client-side after fetching, so unassigned items are dropped silently. +- Confirm the issue's Status field value appears in `sync_statuses`. Match is case-insensitive but must otherwise be exact. +- For huge projects (>500 items), check the operator logs for pagination warnings. + +### "No GitHub Projects v2 found for this token" + +Either your token genuinely has no project access, or the projects you expected to see aren't visible to the authenticated user. For org projects, you may need `read:org` scope (classic) or *Members → Read* permission (fine-grained) so the org enumeration works. + +## See Also + +- [Jira Cloud setup](./jira.md) +- [Linear setup](./linear.md) +- [Kanban workflow overview](../../kanban/index.md) diff --git a/docs/kanban/index.md b/docs/kanban/index.md index 47a8aad..1089702 100644 --- a/docs/kanban/index.md +++ b/docs/kanban/index.md @@ -53,3 +53,13 @@ When work finishes: - **Autonomous agents** (FEAT, FIX) can run in parallel on different projects - **Paired agents** (SPIKE, INV) run one at a time per operator - **Same project** = sequential execution to avoid conflicts + +## External Providers + +In addition to the local `.tickets/` queue described above, Operator! can sync items from external kanban systems: + +- [**Jira Cloud**](../getting-started/kanban/jira.md) — REST API, project + issue type sync +- [**Linear**](../getting-started/kanban/linear.md) — GraphQL API, team-scoped sync +- [**GitHub Projects v2**](../getting-started/kanban/github.md) — GraphQL API, project node ID sync + +GitHub Projects integration uses a **separate token** from Operator!'s PR/git workflows — the kanban provider needs the `project` scope while the git provider needs `repo`. See the [GitHub Projects guide](../getting-started/kanban/github.md) for the full disambiguation. diff --git a/src/api/providers/kanban/github_projects.rs b/src/api/providers/kanban/github_projects.rs new file mode 100644 index 0000000..d08ae03 --- /dev/null +++ b/src/api/providers/kanban/github_projects.rs @@ -0,0 +1,1717 @@ +//! GitHub Projects v2 (kanban) provider implementation +//! +//! # Token Disambiguation +//! +//! GitHub uses one token type but two scope families. Operator splits them +//! into two distinct env vars/config trees: +//! +//! | Subsystem | Env var | Required scopes | +//! |-------------------------|--------------------------|------------------------------------------| +//! | Git provider (PRs) | `GITHUB_TOKEN` | `repo` | +//! | Kanban provider (this) | `OPERATOR_GITHUB_TOKEN` | `project` or `read:project` | +//! +//! `from_env()` here reads **only** `OPERATOR_GITHUB_TOKEN` and never falls +//! back to `GITHUB_TOKEN`. `validate_detailed()` performs scope verification +//! and returns a friendly "lacks `project` scope" error if the token looks +//! like a repo-only token. See `docs/getting-started/kanban/github.md`. + +use async_trait::async_trait; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::env; +use tokio::sync::RwLock; +use tracing::{debug, warn}; + +use super::{ + CreateIssueRequest, CreateIssueResponse, ExternalIssue, ExternalIssueType, ExternalUser, + KanbanProvider, ProjectInfo, UpdateStatusRequest, +}; +use crate::api::error::ApiError; +use crate::issuetypes::kanban_type::KanbanIssueTypeRef; + +const GITHUB_GRAPHQL_URL: &str = "https://api.github.com/graphql"; +const GITHUB_REST_USER_URL: &str = "https://api.github.com/user"; +const PROVIDER_NAME: &str = "github"; +const USER_AGENT: &str = concat!("operator/", env!("CARGO_PKG_VERSION")); +const DEFAULT_ENV_VAR: &str = "OPERATOR_GITHUB_TOKEN"; + +/// Friendly error returned when a token authenticated but lacks `project` scope. +const SCOPE_ERROR_MSG: &str = + "Token authenticated but lacks 'project' scope. This looks like a repo-scoped token \ + (the kind operator uses for GitHub PR workflows via GITHUB_TOKEN). Mint a new PAT at \ + https://github.com/settings/tokens with the 'project' (or 'read:project') scope, or \ + extend a fine-grained PAT to include the Projects permission. \ + See docs/getting-started/kanban/github.md."; + +// ─── Public types ──────────────────────────────────────────────────────────── + +/// Info about a GitHub Project v2 returned by `validate_detailed`. +#[derive(Debug, Clone)] +pub struct GithubProjectInfo { + pub node_id: String, + pub number: i32, + pub title: String, + pub owner_login: String, + /// "Organization" or "User" + pub owner_kind: String, +} + +/// Detailed validation result for GitHub Projects onboarding. +#[derive(Debug, Clone)] +pub struct GithubValidationDetails { + /// Authenticated user's login + pub user_login: String, + /// Authenticated user's `databaseId` rendered as a string (used as `sync_user_id`) + pub user_id: String, + /// Projects visible to the token (across viewer + orgs) + pub projects: Vec, + /// Env var name the validated token came from. Surfaced to clients so + /// they can display "Connected via X" and rotate the right token. + pub resolved_env_var: String, +} + +// ─── Status field cache ────────────────────────────────────────────────────── + +/// Cached `Status` field info for a single project. +#[derive(Debug, Clone)] +struct StatusFieldCache { + field_id: String, + /// Lowercased option name → option id (for case-insensitive lookup). + options_by_name: HashMap, + /// Original-case option names in declared order (for `list_statuses` output). + ordered_names: Vec, +} + +/// Resolved (project, item) pair for a given external `issue_key`. +/// +/// Populated by `list_issues` so `update_issue_status` can resolve the +/// human-readable key (e.g. `octocat/hello#42`) back to the `GraphQL` IDs +/// it needs for the mutation. Cache miss → `update_issue_status` returns +/// a clear error asking the caller to run `list_issues` first. +#[derive(Debug, Clone)] +struct ItemLookup { + project_id: String, + item_id: String, +} + +// ─── Provider struct ───────────────────────────────────────────────────────── + +/// GitHub Projects v2 (kanban) API provider. +pub struct GithubProjectsProvider { + token: String, + client: Client, + /// Env var the token was sourced from. Used by `validate_detailed`. + resolved_env_var: String, + /// `project_node_id` → cached Status field info. + status_field_cache: RwLock>, + /// `issue_key` (as returned in `ExternalIssue.key`) → lookup info, populated by `list_issues`. + item_lookup: RwLock>, +} + +impl GithubProjectsProvider { + /// Create a new GitHub Projects provider. + /// + /// `resolved_env_var` should be the name of the env var the token came + /// from (e.g. `"OPERATOR_GITHUB_TOKEN"`), used for "Connected via X" + /// feedback in the validation response. + pub fn new(token: String, resolved_env_var: String) -> Self { + Self { + token, + client: Client::new(), + resolved_env_var, + status_field_cache: RwLock::new(HashMap::new()), + item_lookup: RwLock::new(HashMap::new()), + } + } + + /// Create from environment. + /// + /// Reads **only** `OPERATOR_GITHUB_TOKEN`. Does **not** fall back to + /// `GITHUB_TOKEN` even if it exists — that env var belongs to operator's + /// git provider (PR/branch workflows) and almost certainly lacks the + /// `project` scope, which would surface confusing 403s deeper in the + /// stack. See module-level Token Disambiguation note. + pub fn from_env() -> Result { + match env::var(DEFAULT_ENV_VAR) { + Ok(token) if !token.is_empty() => Ok(Self::new(token, DEFAULT_ENV_VAR.to_string())), + _ => Err(ApiError::not_configured(PROVIDER_NAME)), + } + } + + /// Create from config. The owner is passed for symmetry with the other + /// providers (it's the `HashMap` key in `KanbanConfig.github`); the + /// token itself is read from the env var named in `config.api_key_env`. + pub fn from_config( + _owner: &str, + config: &crate::config::GithubProjectsConfig, + ) -> Result { + let token = env::var(&config.api_key_env).ok(); + match token { + Some(t) if !t.is_empty() => Ok(Self::new(t, config.api_key_env.clone())), + _ => Err(ApiError::not_configured(PROVIDER_NAME)), + } + } + + /// Build the standard set of headers used for both `GraphQL` and REST calls. + fn auth_headers(&self) -> reqwest::header::HeaderMap { + use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT as UA}; + let mut h = HeaderMap::new(); + h.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {}", self.token)) + .unwrap_or_else(|_| HeaderValue::from_static("Bearer invalid")), + ); + h.insert( + ACCEPT, + HeaderValue::from_static("application/vnd.github+json"), + ); + h.insert(UA, HeaderValue::from_static(USER_AGENT)); + h + } + + /// Execute a `GraphQL` query against the GitHub API. + async fn graphql Deserialize<'de>>( + &self, + query: &str, + variables: Option, + ) -> Result { + #[derive(Serialize)] + struct GraphQLRequest<'a> { + query: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + variables: Option, + } + + #[derive(Deserialize)] + struct GraphQLResponse { + data: Option, + errors: Option>, + } + + #[derive(Deserialize)] + struct GraphQLError { + message: String, + #[serde(default)] + #[allow(dead_code)] + #[serde(rename = "type")] + err_type: Option, + } + + let request = GraphQLRequest { query, variables }; + + debug!("GitHub GraphQL query"); + + let response = self + .client + .post(GITHUB_GRAPHQL_URL) + .headers(self.auth_headers()) + .json(&request) + .send() + .await + .map_err(|e| ApiError::network(PROVIDER_NAME, e.to_string()))?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return match status.as_u16() { + 401 => Err(ApiError::unauthorized(PROVIDER_NAME)), + 403 => Err(ApiError::http(PROVIDER_NAME, 403, body)), + 429 => Err(ApiError::rate_limited(PROVIDER_NAME, None)), + _ => Err(ApiError::http(PROVIDER_NAME, status.as_u16(), body)), + }; + } + + let gql_response: GraphQLResponse = response + .json() + .await + .map_err(|e| ApiError::http(PROVIDER_NAME, 0, format!("Parse error: {e}")))?; + + if let Some(errors) = gql_response.errors { + let messages: Vec = errors.into_iter().map(|e| e.message).collect(); + let combined = messages.join("; "); + // If the `GraphQL` errors mention permissions/scopes/projects, escalate + // with the friendly disambiguation hint so users see it via the + // generic provider_error_message helper. + let lower = combined.to_lowercase(); + if lower.contains("project") + && (lower.contains("permission") + || lower.contains("scope") + || lower.contains("not authorized")) + { + return Err(ApiError::http( + PROVIDER_NAME, + 403, + SCOPE_ERROR_MSG.to_string(), + )); + } + return Err(ApiError::http(PROVIDER_NAME, 0, combined)); + } + + gql_response + .data + .ok_or_else(|| ApiError::http(PROVIDER_NAME, 0, "No data in response".to_string())) + } + + /// Detailed credential validation for onboarding. + /// + /// Performs: + /// + /// 1. A `viewer { login databaseId projectsV2 organizations }` `GraphQL` query + /// to confirm the token is valid and to enumerate visible projects. + /// 2. A side-channel `GET /user` REST call to read the `x-oauth-scopes` + /// header (classic PATs only). If the header is non-empty and does + /// not include `project` or `read:project`, returns a friendly error. + /// 3. A behavior probe: if the `GraphQL` query surfaced no projects at + /// all (and the header check was inconclusive, as for fine-grained + /// PATs), treats that as a likely scope problem and returns the same + /// friendly error. + pub async fn validate_detailed(&self) -> Result { + let query = r" + query { + viewer { + login + databaseId + projectsV2(first: 50) { + nodes { + id + number + title + owner { + __typename + ... on Organization { login } + ... on User { login } + } + } + } + organizations(first: 20) { + nodes { + login + projectsV2(first: 50) { + nodes { + id + number + title + } + } + } + } + } + } + "; + + #[derive(Deserialize)] + struct ValidateResponse { + viewer: ViewerNode, + } + + #[derive(Deserialize)] + struct ViewerNode { + login: String, + #[serde(rename = "databaseId")] + database_id: i64, + #[serde(rename = "projectsV2")] + projects_v2: ProjectsV2Conn, + organizations: OrgsConn, + } + + #[derive(Deserialize)] + struct ProjectsV2Conn { + nodes: Vec, + } + + #[derive(Deserialize)] + struct ProjectNode { + id: String, + number: i32, + title: String, + #[serde(default)] + owner: Option, + } + + #[derive(Deserialize)] + struct OwnerRef { + #[serde(rename = "__typename")] + typename: String, + #[serde(default)] + login: Option, + } + + #[derive(Deserialize)] + struct OrgsConn { + nodes: Vec, + } + + #[derive(Deserialize)] + struct OrgNode { + login: String, + #[serde(rename = "projectsV2")] + projects_v2: OrgProjectsConn, + } + + #[derive(Deserialize)] + struct OrgProjectsConn { + nodes: Vec, + } + + #[derive(Deserialize)] + struct OrgProjectNode { + id: String, + number: i32, + title: String, + } + + let resp: ValidateResponse = self.graphql(query, None).await?; + + let mut projects: Vec = Vec::new(); + + // Viewer's own projects (User-owned). + for p in resp.viewer.projects_v2.nodes { + let (owner_login, owner_kind) = p + .owner + .map(|o| (o.login.unwrap_or_default(), o.typename)) + .unwrap_or_else(|| (resp.viewer.login.clone(), "User".to_string())); + projects.push(GithubProjectInfo { + node_id: p.id, + number: p.number, + title: p.title, + owner_login, + owner_kind, + }); + } + + // Org-owned projects. + for org in resp.viewer.organizations.nodes { + for p in org.projects_v2.nodes { + projects.push(GithubProjectInfo { + node_id: p.id, + number: p.number, + title: p.title, + owner_login: org.login.clone(), + owner_kind: "Organization".to_string(), + }); + } + } + + // Scope verification — header scrape (classic PATs). + let scopes_header = self.fetch_oauth_scopes().await; + + if let Some(scopes) = &scopes_header { + let lower = scopes.to_lowercase(); + if !lower.contains("project") && !lower.contains("read:project") { + return Err(ApiError::http( + PROVIDER_NAME, + 403, + SCOPE_ERROR_MSG.to_string(), + )); + } + } else if projects.is_empty() { + // Fine-grained PAT (no x-oauth-scopes header) AND no projects came + // back. Most likely cause: token lacks Projects permission. + return Err(ApiError::http( + PROVIDER_NAME, + 403, + SCOPE_ERROR_MSG.to_string(), + )); + } + + Ok(GithubValidationDetails { + user_login: resp.viewer.login, + user_id: resp.viewer.database_id.to_string(), + projects, + resolved_env_var: self.resolved_env_var.clone(), + }) + } + + /// Fetch the `x-oauth-scopes` header via a `GET /user` REST call. + /// + /// Returns `None` if the header is absent (fine-grained PATs and app + /// tokens don't return it) or the request fails. + async fn fetch_oauth_scopes(&self) -> Option { + let resp = self + .client + .get(GITHUB_REST_USER_URL) + .headers(self.auth_headers()) + .send() + .await + .ok()?; + + if !resp.status().is_success() { + return None; + } + + resp.headers() + .get("x-oauth-scopes") + .and_then(|v| v.to_str().ok()) + .map(std::string::ToString::to_string) + .filter(|s| !s.is_empty()) + } + + /// Resolve the owner login + kind for a project node id. + /// + /// Used by `get_issue_types` to know which org to query for `issueTypes`. + async fn resolve_project_owner(&self, project_id: &str) -> Result<(String, String), ApiError> { + let query = r" + query($projectId: ID!) { + node(id: $projectId) { + ... on ProjectV2 { + owner { + __typename + ... on Organization { login } + ... on User { login } + } + } + } + } + "; + + #[derive(Deserialize)] + struct Resp { + node: NodeWrap, + } + + #[derive(Deserialize)] + struct NodeWrap { + #[serde(default)] + owner: Option, + } + + #[derive(Deserialize)] + struct OwnerRef { + #[serde(rename = "__typename")] + typename: String, + #[serde(default)] + login: Option, + } + + let variables = serde_json::json!({ "projectId": project_id }); + let resp: Resp = self.graphql(query, Some(variables)).await?; + let owner = resp.node.owner.ok_or_else(|| { + ApiError::http( + PROVIDER_NAME, + 404, + format!("Project {project_id} has no owner"), + ) + })?; + Ok((owner.login.unwrap_or_default(), owner.typename)) + } + + /// Try to fetch org-level issue types. Returns `Ok(None)` if the project + /// owner is a User (orgs only) or if the org has no issue types + /// configured. Returns `Err` only on auth/network failures. + async fn fetch_org_issue_types( + &self, + owner_login: &str, + ) -> Result>, ApiError> { + let query = r" + query($login: String!) { + organization(login: $login) { + issueTypes(first: 20) { + nodes { + id + name + description + } + } + } + } + "; + + #[derive(Deserialize)] + struct Resp { + #[serde(default)] + organization: Option, + } + + #[derive(Deserialize)] + struct OrgWrap { + #[serde(rename = "issueTypes", default)] + issue_types: Option, + } + + #[derive(Deserialize)] + struct TypesConn { + nodes: Vec, + } + + #[derive(Deserialize)] + struct IssueTypeNode { + id: String, + name: String, + #[serde(default)] + description: Option, + } + + let variables = serde_json::json!({ "login": owner_login }); + let resp: Result = self.graphql(query, Some(variables)).await; + + match resp { + Ok(r) => { + let nodes = r + .organization + .and_then(|o| o.issue_types) + .map(|t| t.nodes) + .unwrap_or_default(); + if nodes.is_empty() { + Ok(None) + } else { + Ok(Some( + nodes + .into_iter() + .map(|n| ExternalIssueType { + id: n.id, + name: n.name, + description: n.description, + icon_url: None, + custom_fields: vec![], + }) + .collect(), + )) + } + } + // `GraphQL` errors here usually mean the schema doesn't expose + // `issueTypes` (older orgs) — treat that as "no types available" + // and let the caller fall back to labels. + Err(ApiError::HttpError { message, .. }) if message.contains("issueTypes") => { + warn!("issueTypes field not available, falling back to labels"); + Ok(None) + } + Err(e) => Err(e), + } + } + + /// Aggregate labels from repos linked to the given project, deduped by id. + /// Used as the fallback path when org-level issue types aren't available. + async fn fetch_project_labels( + &self, + project_id: &str, + ) -> Result, ApiError> { + let query = r" + query($projectId: ID!) { + node(id: $projectId) { + ... on ProjectV2 { + items(first: 100) { + nodes { + content { + __typename + ... on Issue { + repository { + labels(first: 50) { + nodes { id name description } + } + } + } + ... on PullRequest { + repository { + labels(first: 50) { + nodes { id name description } + } + } + } + } + } + } + } + } + } + "; + + #[derive(Deserialize)] + struct Resp { + node: NodeWrap, + } + + #[derive(Deserialize)] + struct NodeWrap { + #[serde(default)] + items: Option, + } + + #[derive(Deserialize)] + struct ItemsConn { + nodes: Vec, + } + + #[derive(Deserialize)] + struct ItemNode { + #[serde(default)] + content: Option, + } + + #[derive(Deserialize)] + struct ContentNode { + #[serde(default)] + repository: Option, + } + + #[derive(Deserialize)] + struct RepoNode { + #[serde(default)] + labels: Option, + } + + #[derive(Deserialize)] + struct LabelsConn { + nodes: Vec, + } + + #[derive(Deserialize)] + struct LabelNode { + id: String, + name: String, + #[serde(default)] + description: Option, + } + + let variables = serde_json::json!({ "projectId": project_id }); + let resp: Resp = self.graphql(query, Some(variables)).await?; + + let mut by_id: HashMap = HashMap::new(); + if let Some(items) = resp.node.items { + for item in items.nodes { + let Some(repo) = item.content.and_then(|c| c.repository) else { + continue; + }; + let Some(labels) = repo.labels else { continue }; + for label in labels.nodes { + by_id.entry(label.id.clone()).or_insert(ExternalIssueType { + id: label.id, + name: label.name, + description: label.description, + icon_url: None, + custom_fields: vec![], + }); + } + } + } + Ok(by_id.into_values().collect()) + } + + /// Load + cache the `Status` single-select field for a project. + async fn ensure_status_field(&self, project_id: &str) -> Result { + if let Some(cached) = self.status_field_cache.read().await.get(project_id) { + return Ok(cached.clone()); + } + + let query = r#" + query($projectId: ID!) { + node(id: $projectId) { + ... on ProjectV2 { + field(name: "Status") { + __typename + ... on ProjectV2SingleSelectField { + id + name + options { id name } + } + } + } + } + } + "#; + + #[derive(Deserialize)] + struct Resp { + node: NodeWrap, + } + + #[derive(Deserialize)] + struct NodeWrap { + #[serde(default)] + field: Option, + } + + #[derive(Deserialize)] + struct FieldNode { + id: String, + #[serde(default)] + options: Vec, + } + + #[derive(Deserialize)] + struct OptionNode { + id: String, + name: String, + } + + let variables = serde_json::json!({ "projectId": project_id }); + let resp: Resp = self.graphql(query, Some(variables)).await?; + let field = resp.node.field.ok_or_else(|| { + ApiError::http( + PROVIDER_NAME, + 404, + format!("Project {project_id} has no Status field"), + ) + })?; + + let mut options_by_name: HashMap = HashMap::new(); + let mut ordered_names: Vec = Vec::new(); + for opt in field.options { + options_by_name.insert(opt.name.to_lowercase(), opt.id.clone()); + ordered_names.push(opt.name); + } + + let cache = StatusFieldCache { + field_id: field.id, + options_by_name, + ordered_names, + }; + + self.status_field_cache + .write() + .await + .insert(project_id.to_string(), cache.clone()); + + Ok(cache) + } +} + +// ─── Item / list_issues response types ────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct ListItemsResponse { + node: ListItemsNode, +} + +#[derive(Debug, Deserialize)] +struct ListItemsNode { + #[serde(default)] + items: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ItemsPage { + #[serde(default)] + page_info: Option, + #[serde(default)] + nodes: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PageInfo { + has_next_page: bool, + end_cursor: Option, +} + +#[derive(Debug, Deserialize)] +struct RawProjectItem { + id: String, + #[serde(default, rename = "type")] + item_type: Option, + #[serde(default)] + content: Option, + #[serde(default, rename = "fieldValues")] + field_values: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "__typename")] +enum RawContent { + Issue { + #[serde(default)] + id: Option, + #[serde(default)] + number: Option, + title: String, + #[serde(default)] + body: Option, + #[serde(default)] + url: Option, + #[serde(default)] + repository: Option, + #[serde(default)] + assignees: Option, + #[serde(default)] + labels: Option, + #[serde(default, rename = "issueType")] + issue_type: Option, + }, + PullRequest { + #[serde(default)] + id: Option, + #[serde(default)] + number: Option, + title: String, + #[serde(default)] + body: Option, + #[serde(default)] + url: Option, + #[serde(default)] + repository: Option, + #[serde(default)] + assignees: Option, + #[serde(default)] + labels: Option, + }, + DraftIssue { + #[serde(default)] + id: Option, + title: String, + #[serde(default)] + body: Option, + #[serde(default)] + assignees: Option, + }, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawRepoRef { + name_with_owner: String, +} + +#[derive(Debug, Deserialize)] +struct RawAssignees { + nodes: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawAssignee { + login: String, + #[serde(default)] + database_id: Option, + #[serde(default)] + name: Option, + #[serde(default)] + email: Option, + #[serde(default)] + avatar_url: Option, +} + +#[derive(Debug, Deserialize)] +struct RawLabels { + nodes: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawLabel { + id: String, + name: String, +} + +#[derive(Debug, Deserialize)] +struct RawIssueType { + id: String, + name: String, +} + +#[derive(Debug, Deserialize)] +struct RawFieldValues { + nodes: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "__typename")] +enum RawFieldValue { + ProjectV2ItemFieldSingleSelectValue { + #[serde(default)] + name: Option, + #[serde(default)] + field: Option, + }, + #[serde(other)] + Other, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "__typename")] +enum RawFieldRef { + ProjectV2SingleSelectField { + #[serde(default)] + name: Option, + }, + #[serde(other)] + Other, +} + +// ─── KanbanProvider trait impl ─────────────────────────────────────────────── + +#[async_trait] +impl KanbanProvider for GithubProjectsProvider { + fn name(&self) -> &str { + PROVIDER_NAME + } + + fn is_configured(&self) -> bool { + !self.token.is_empty() + } + + async fn list_projects(&self) -> Result, ApiError> { + // Reuse the validate_detailed query — it's the canonical projects discovery. + let details = self.validate_detailed().await?; + Ok(details + .projects + .into_iter() + .map(|p| ProjectInfo { + id: p.node_id.clone(), + key: p.node_id, + name: format!("{}/#{} {}", p.owner_login, p.number, p.title), + }) + .collect()) + } + + async fn get_issue_types(&self, project_key: &str) -> Result, ApiError> { + // Resolve owner first; only orgs can have issueTypes. + let (owner_login, owner_kind) = self.resolve_project_owner(project_key).await?; + + if owner_kind == "Organization" { + if let Some(types) = self.fetch_org_issue_types(&owner_login).await? { + return Ok(types); + } + } + + // Fallback: aggregate labels from items' linked repos. + self.fetch_project_labels(project_key).await + } + + async fn test_connection(&self) -> Result { + let query = r" + query { + viewer { login } + } + "; + + #[derive(Deserialize)] + struct Resp { + #[allow(dead_code)] + viewer: ViewerNode, + } + + #[derive(Deserialize)] + struct ViewerNode { + #[allow(dead_code)] + login: String, + } + + match self.graphql::(query, None).await { + Ok(_) => Ok(true), + Err(e) if e.is_auth_error() => { + warn!("GitHub authentication failed"); + Ok(false) + } + Err(e) => Err(e), + } + } + + async fn list_users(&self, project_key: &str) -> Result, ApiError> { + // Derive users from the union of assignees seen across the project's items. + let items = self.fetch_items_page(project_key, None).await?; + let mut by_login: HashMap = HashMap::new(); + for item in items.nodes { + let assignees_opt = match item.content { + Some(RawContent::Issue { assignees, .. }) => assignees, + Some(RawContent::PullRequest { assignees, .. }) => assignees, + Some(RawContent::DraftIssue { assignees, .. }) => assignees, + None => None, + }; + if let Some(assignees) = assignees_opt { + for a in assignees.nodes { + by_login.entry(a.login.clone()).or_insert(ExternalUser { + id: a + .database_id + .map(|n| n.to_string()) + .unwrap_or(a.login.clone()), + name: a.name.unwrap_or_else(|| a.login.clone()), + email: a.email, + avatar_url: a.avatar_url, + }); + } + } + } + Ok(by_login.into_values().collect()) + } + + async fn list_statuses(&self, project_key: &str) -> Result, ApiError> { + let cache = self.ensure_status_field(project_key).await?; + Ok(cache.ordered_names) + } + + async fn list_issues( + &self, + project_key: &str, + user_id: &str, + statuses: &[String], + ) -> Result, ApiError> { + // Paginate through all items. + let mut all_raw: Vec = Vec::new(); + let mut cursor: Option = None; + loop { + let page = self + .fetch_items_page(project_key, cursor.as_deref()) + .await?; + all_raw.extend(page.nodes); + match page.page_info { + Some(p) if p.has_next_page => { + cursor = p.end_cursor; + if cursor.is_none() { + break; + } + } + _ => break, + } + } + + let status_filter: Vec = statuses.iter().map(|s| s.to_lowercase()).collect(); + let mut out: Vec = Vec::new(); + let mut lookup_writes: Vec<(String, ItemLookup)> = Vec::new(); + + for raw in all_raw { + let item_id = raw.id.clone(); + let (status_name, _priority) = extract_status_and_priority(&raw.field_values); + + // Filter by status if requested. + if !status_filter.is_empty() { + let status_matches = status_name + .as_ref() + .map(|s| status_filter.contains(&s.to_lowercase())) + .unwrap_or(false); + if !status_matches { + continue; + } + } + + let Some(content) = raw.content else { + continue; + }; + + let assignees = content_assignees(&content); + + // Filter by user_id (matches against either the assignee's databaseId or login). + let user_match = assignees.iter().any(|a| { + a.database_id + .map(|n| n.to_string()) + .as_deref() + .map(|s| s == user_id) + .unwrap_or(false) + || a.login == user_id + }); + if !user_match { + continue; + } + + let assignee = assignees.first().map(|a| ExternalUser { + id: a + .database_id + .map(|n| n.to_string()) + .unwrap_or_else(|| a.login.clone()), + name: a.name.clone().unwrap_or_else(|| a.login.clone()), + email: a.email.clone(), + avatar_url: a.avatar_url.clone(), + }); + + let (issue_id, key, summary, description, url, kanban_issue_types) = match content { + RawContent::Issue { + id, + number, + title, + body, + url, + repository, + labels, + issue_type, + .. + } => { + let repo = repository + .map(|r| r.name_with_owner) + .unwrap_or_else(|| "unknown/unknown".to_string()); + let num = number.unwrap_or(0); + let key = format!("{repo}#{num}"); + let kits = if let Some(it) = issue_type { + vec![KanbanIssueTypeRef { + id: it.id, + name: it.name, + }] + } else { + labels + .map(|l| { + l.nodes + .into_iter() + .map(|n| KanbanIssueTypeRef { + id: n.id, + name: n.name, + }) + .collect() + }) + .unwrap_or_default() + }; + ( + id.unwrap_or_else(|| key.clone()), + key, + title, + body, + url.unwrap_or_default(), + kits, + ) + } + RawContent::PullRequest { + id, + number, + title, + body, + url, + repository, + labels, + .. + } => { + let repo = repository + .map(|r| r.name_with_owner) + .unwrap_or_else(|| "unknown/unknown".to_string()); + let num = number.unwrap_or(0); + let key = format!("{repo}!{num}"); + let kits = labels + .map(|l| { + l.nodes + .into_iter() + .map(|n| KanbanIssueTypeRef { + id: n.id, + name: n.name, + }) + .collect() + }) + .unwrap_or_default(); + ( + id.unwrap_or_else(|| key.clone()), + key, + title, + body, + url.unwrap_or_default(), + kits, + ) + } + RawContent::DraftIssue { + id, title, body, .. + } => { + let key = format!("draft:{item_id}"); + ( + id.unwrap_or_else(|| key.clone()), + key, + title, + body, + String::new(), + Vec::new(), + ) + } + }; + + // Cache the lookup so update_issue_status can resolve this key later. + lookup_writes.push(( + key.clone(), + ItemLookup { + project_id: project_key.to_string(), + item_id: item_id.clone(), + }, + )); + + out.push(ExternalIssue { + id: issue_id, + key, + summary, + description, + kanban_issue_types, + status: status_name.unwrap_or_default(), + assignee, + url, + priority: None, // TODO: extract from a Priority single-select field if present + }); + } + + // Persist lookups for update_issue_status. + if !lookup_writes.is_empty() { + let mut guard = self.item_lookup.write().await; + for (key, lookup) in lookup_writes { + guard.insert(key, lookup); + } + } + + Ok(out) + } + + async fn create_issue( + &self, + project_key: &str, + request: CreateIssueRequest, + ) -> Result { + // v1: draft issues only. Real repo issues are out of scope per plan. + let mutation = r" + mutation($input: AddProjectV2DraftIssueInput!) { + addProjectV2DraftIssue(input: $input) { + projectItem { + id + content { + __typename + ... on DraftIssue { + id + title + body + } + } + } + } + } + "; + + let mut input = serde_json::json!({ + "projectId": project_key, + "title": request.summary, + }); + if let Some(body) = request.description { + input["body"] = serde_json::json!(body); + } + if let Some(assignee) = request.assignee_id { + input["assigneeIds"] = serde_json::json!([assignee]); + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Resp { + add_project_v2_draft_issue: AddResp, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct AddResp { + project_item: ProjectItem, + } + + #[derive(Deserialize)] + struct ProjectItem { + id: String, + #[serde(default)] + content: Option, + } + + #[derive(Deserialize)] + #[serde(tag = "__typename")] + enum DraftContent { + DraftIssue { + #[serde(default)] + id: Option, + title: String, + #[serde(default)] + body: Option, + }, + } + + let resp: Resp = self + .graphql(mutation, Some(serde_json::json!({ "input": input }))) + .await?; + + let item = resp.add_project_v2_draft_issue.project_item; + let item_id = item.id; + let key = format!("draft:{item_id}"); + + let (issue_id, summary, description) = match item.content { + Some(DraftContent::DraftIssue { id, title, body }) => { + (id.unwrap_or_else(|| item_id.clone()), title, body) + } + None => (item_id.clone(), request.summary.clone(), None), + }; + + // Cache the lookup so a follow-up update_issue_status works without + // a list_issues call. + self.item_lookup.write().await.insert( + key.clone(), + ItemLookup { + project_id: project_key.to_string(), + item_id, + }, + ); + + Ok(CreateIssueResponse { + issue: ExternalIssue { + id: issue_id, + key, + summary, + description, + kanban_issue_types: Vec::new(), + status: String::new(), + assignee: None, + url: String::new(), + priority: None, + }, + }) + } + + async fn update_issue_status( + &self, + issue_key: &str, + request: UpdateStatusRequest, + ) -> Result { + // Resolve the (project_id, item_id) pair from the lookup cache. + let lookup = self + .item_lookup + .read() + .await + .get(issue_key) + .cloned() + .ok_or_else(|| { + ApiError::http( + PROVIDER_NAME, + 400, + format!( + "GitHub Projects update_issue_status: no cached lookup for '{issue_key}'. \ + Call list_issues() (or create_issue()) first so the provider can map the \ + key back to its project + item ids." + ), + ) + })?; + + let cache = self.ensure_status_field(&lookup.project_id).await?; + let option_id = cache + .options_by_name + .get(&request.status.to_lowercase()) + .cloned() + .ok_or_else(|| { + ApiError::http( + PROVIDER_NAME, + 400, + format!( + "Status '{}' not found in project. Available: {}", + request.status, + cache.ordered_names.join(", ") + ), + ) + })?; + + let mutation = r" + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, + itemId: $itemId, + fieldId: $fieldId, + value: { singleSelectOptionId: $optionId } + }) { + projectV2Item { id } + } + } + "; + + let variables = serde_json::json!({ + "projectId": lookup.project_id, + "itemId": lookup.item_id, + "fieldId": cache.field_id, + "optionId": option_id, + }); + + // Discard the response — we only care that it didn't error. + let _: serde_json::Value = self.graphql(mutation, Some(variables)).await?; + + // Return a minimal updated ExternalIssue. Re-fetching the full item + // would be a second round-trip; the caller already has the rest from + // its previous list_issues call. + Ok(ExternalIssue { + id: lookup.item_id, + key: issue_key.to_string(), + summary: String::new(), + description: None, + kanban_issue_types: Vec::new(), + status: request.status, + assignee: None, + url: String::new(), + priority: None, + }) + } +} + +impl GithubProjectsProvider { + /// Fetch a single page of project items. Helper used by both + /// `list_issues` and `list_users`. + async fn fetch_items_page( + &self, + project_id: &str, + after: Option<&str>, + ) -> Result { + let query = r" + query($projectId: ID!, $first: Int!, $after: String) { + node(id: $projectId) { + ... on ProjectV2 { + items(first: $first, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { + id + type + content { + __typename + ... on Issue { + id + number + title + body + url + repository { nameWithOwner } + assignees(first: 10) { + nodes { + login + databaseId + name + email + avatarUrl + } + } + labels(first: 20) { nodes { id name } } + issueType { id name } + } + ... on PullRequest { + id + number + title + body + url + repository { nameWithOwner } + assignees(first: 10) { + nodes { + login + databaseId + name + email + avatarUrl + } + } + labels(first: 20) { nodes { id name } } + } + ... on DraftIssue { + id + title + body + assignees(first: 10) { + nodes { + login + databaseId + name + email + avatarUrl + } + } + } + } + fieldValues(first: 20) { + nodes { + __typename + ... on ProjectV2ItemFieldSingleSelectValue { + name + field { + __typename + ... on ProjectV2SingleSelectField { name } + } + } + } + } + } + } + } + } + } + "; + + let variables = serde_json::json!({ + "projectId": project_id, + "first": 100, + "after": after, + }); + + let resp: ListItemsResponse = self.graphql(query, Some(variables)).await?; + Ok(resp.node.items.unwrap_or(ItemsPage { + page_info: None, + nodes: Vec::new(), + })) + } +} + +// ─── Helper functions ──────────────────────────────────────────────────────── + +/// Extract the Status field value (and a placeholder for Priority) from +/// an item's `fieldValues` connection. +fn extract_status_and_priority( + field_values: &Option, +) -> (Option, Option) { + let Some(values) = field_values else { + return (None, None); + }; + let mut status: Option = None; + for v in &values.nodes { + if let RawFieldValue::ProjectV2ItemFieldSingleSelectValue { name, field } = v { + let field_name = match field { + Some(RawFieldRef::ProjectV2SingleSelectField { name }) => name.clone(), + _ => None, + }; + if let Some(fname) = field_name { + if fname.eq_ignore_ascii_case("Status") && status.is_none() { + status.clone_from(name); + } + } + } + } + (status, None) +} + +/// Extract assignees from a content variant. Returns an empty slice for None. +fn content_assignees(content: &RawContent) -> &[RawAssignee] { + let assignees = match content { + RawContent::Issue { assignees, .. } => assignees.as_ref(), + RawContent::PullRequest { assignees, .. } => assignees.as_ref(), + RawContent::DraftIssue { assignees, .. } => assignees.as_ref(), + }; + assignees.map(|a| a.nodes.as_slice()).unwrap_or(&[]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_env_not_configured() { + env::remove_var("OPERATOR_GITHUB_TOKEN"); + let result = GithubProjectsProvider::from_env(); + assert!(result.is_err()); + } + + #[test] + fn test_from_env_does_not_fall_back_to_github_token() { + // Token Disambiguation rule 1: must NOT use GITHUB_TOKEN. + env::remove_var("OPERATOR_GITHUB_TOKEN"); + env::set_var("GITHUB_TOKEN", "ghp_should_not_be_used"); + let result = GithubProjectsProvider::from_env(); + assert!( + result.is_err(), + "from_env must not fall back to GITHUB_TOKEN — see Token Disambiguation rule 1" + ); + env::remove_var("GITHUB_TOKEN"); + } + + #[test] + fn test_deserialize_items_page_with_issue() { + let json = r#"{ + "node": { + "items": { + "pageInfo": { "hasNextPage": false, "endCursor": null }, + "nodes": [ + { + "id": "PVTI_lAHO_test", + "type": "ISSUE", + "content": { + "__typename": "Issue", + "id": "I_kwDO_test", + "number": 42, + "title": "Fix login bug", + "body": "Users cannot log in", + "url": "https://github.com/octocat/hello/issues/42", + "repository": { "nameWithOwner": "octocat/hello" }, + "assignees": { + "nodes": [ + { + "login": "octocat", + "databaseId": 583231, + "name": "The Octocat", + "email": null, + "avatarUrl": "https://github.com/octocat.png" + } + ] + }, + "labels": { + "nodes": [ + { "id": "L_bug", "name": "bug" } + ] + }, + "issueType": null + }, + "fieldValues": { + "nodes": [ + { + "__typename": "ProjectV2ItemFieldSingleSelectValue", + "name": "In Progress", + "field": { + "__typename": "ProjectV2SingleSelectField", + "name": "Status" + } + } + ] + } + } + ] + } + } + }"#; + + let resp: ListItemsResponse = serde_json::from_str(json).unwrap(); + let page = resp.node.items.unwrap(); + assert_eq!(page.nodes.len(), 1); + let item = &page.nodes[0]; + assert_eq!(item.id, "PVTI_lAHO_test"); + + let (status, _) = extract_status_and_priority(&item.field_values); + assert_eq!(status.as_deref(), Some("In Progress")); + } + + #[test] + fn test_deserialize_items_page_with_draft() { + let json = r#"{ + "node": { + "items": { + "pageInfo": { "hasNextPage": false, "endCursor": null }, + "nodes": [ + { + "id": "PVTI_lAHO_draft", + "type": "DRAFT_ISSUE", + "content": { + "__typename": "DraftIssue", + "id": "DI_lAHO_test", + "title": "A draft idea", + "body": "needs fleshing out", + "assignees": { "nodes": [] } + }, + "fieldValues": { "nodes": [] } + } + ] + } + } + }"#; + + let resp: ListItemsResponse = serde_json::from_str(json).unwrap(); + let page = resp.node.items.unwrap(); + assert_eq!(page.nodes.len(), 1); + let item = &page.nodes[0]; + match &item.content { + Some(RawContent::DraftIssue { title, .. }) => { + assert_eq!(title, "A draft idea"); + } + _ => panic!("Expected DraftIssue variant"), + } + } + + #[test] + fn test_extract_status_and_priority_no_field_values() { + let (status, priority) = extract_status_and_priority(&None); + assert!(status.is_none()); + assert!(priority.is_none()); + } + + #[test] + fn test_extract_status_picks_status_field_only() { + let json = r#"{ + "nodes": [ + { + "__typename": "ProjectV2ItemFieldSingleSelectValue", + "name": "P1", + "field": { + "__typename": "ProjectV2SingleSelectField", + "name": "Priority" + } + }, + { + "__typename": "ProjectV2ItemFieldSingleSelectValue", + "name": "Done", + "field": { + "__typename": "ProjectV2SingleSelectField", + "name": "Status" + } + } + ] + }"#; + let fv: RawFieldValues = serde_json::from_str(json).unwrap(); + let (status, _) = extract_status_and_priority(&Some(fv)); + assert_eq!(status.as_deref(), Some("Done")); + } +} diff --git a/src/api/providers/kanban/jira.rs b/src/api/providers/kanban/jira.rs index 3f36c37..b8e9937 100644 --- a/src/api/providers/kanban/jira.rs +++ b/src/api/providers/kanban/jira.rs @@ -10,10 +10,20 @@ use ts_rs::TS; use super::{ExternalIssue, ExternalIssueType, ExternalUser, KanbanProvider, ProjectInfo}; use crate::api::error::ApiError; -use crate::issuetypes::IssueType; +use crate::issuetypes::kanban_type::KanbanIssueTypeRef; const PROVIDER_NAME: &str = "jira"; +/// Detailed validation result for Jira onboarding. +/// +/// Richer than `KanbanProvider::test_connection` — includes the authenticated +/// user's `accountId` (used as `sync_user_id` in config) and display name. +#[derive(Debug, Clone)] +pub struct JiraValidationDetails { + pub account_id: String, + pub display_name: String, +} + /// Jira Cloud API provider pub struct JiraProvider { domain: String, @@ -203,7 +213,10 @@ impl JiraProvider { key: issue.key, summary: issue.fields.summary, description: extract_description_text(&issue.fields.description), - issue_type: issue.fields.issuetype.name, + kanban_issue_types: vec![KanbanIssueTypeRef { + id: String::new(), // not available from single issue fetch + name: issue.fields.issuetype.name, + }], status: issue.fields.status.name, assignee: issue.fields.assignee.map(|u| ExternalUser { id: u.account_id, @@ -215,6 +228,27 @@ impl JiraProvider { priority: issue.fields.priority.map(|p| p.name), }) } + + /// Detailed credential validation for onboarding. + /// + /// Hits `/rest/api/3/myself` and returns the authenticated user's + /// `accountId` + `displayName`, which the onboarding flow uses as + /// `sync_user_id` in `ProjectSyncConfig`. + pub async fn validate_detailed(&self) -> Result { + #[derive(Deserialize)] + struct MySelf { + #[serde(rename = "accountId")] + account_id: String, + #[serde(rename = "displayName", default)] + display_name: String, + } + + let me: MySelf = self.get("/myself").await?; + Ok(JiraValidationDetails { + account_id: me.account_id, + display_name: me.display_name, + }) + } } /// Simple Base64 encoding implementation (for Basic Auth only) @@ -398,6 +432,9 @@ pub struct JiraDescription { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] pub struct JiraIssueTypeRef { + /// Issue type ID (e.g., "10001") + #[serde(default)] + pub id: Option, /// Issue type name (e.g., "Bug", "Story", "Task") pub name: String, } @@ -512,36 +549,6 @@ impl KanbanProvider for JiraProvider { .collect()) } - fn convert_to_issuetype(&self, external: &ExternalIssueType, project_key: &str) -> IssueType { - // Sanitize key: uppercase, letters only, max 10 chars - let key: String = external - .name - .chars() - .filter(char::is_ascii_alphabetic) - .take(10) - .collect::() - .to_uppercase(); - - // Ensure minimum key length - let key = if key.len() < 2 { - format!("{key}X") - } else { - key - }; - - IssueType::new_imported( - key, - external.name.clone(), - external - .description - .clone() - .unwrap_or_else(|| format!("Imported from Jira: {}", external.name)), - "jira".to_string(), - project_key.to_string(), - Some(external.id.clone()), - ) - } - async fn test_connection(&self) -> Result { // Try to get current user to verify credentials #[derive(Deserialize)] @@ -621,7 +628,10 @@ impl KanbanProvider for JiraProvider { key: issue.key, summary: issue.fields.summary, description: extract_description_text(&issue.fields.description), - issue_type: issue.fields.issuetype.name, + kanban_issue_types: vec![KanbanIssueTypeRef { + id: issue.fields.issuetype.id.unwrap_or_default(), + name: issue.fields.issuetype.name, + }], status: issue.fields.status.name, assignee: issue.fields.assignee.map(|u| ExternalUser { id: u.account_id, @@ -754,71 +764,20 @@ mod tests { } #[test] - fn test_convert_to_issuetype() { - let provider = JiraProvider::new( - "test.atlassian.net".to_string(), - "test@test.com".to_string(), - "token".to_string(), - ); - - let external = ExternalIssueType { - id: "10001".to_string(), - name: "Bug".to_string(), - description: Some("A software bug".to_string()), - icon_url: None, - custom_fields: vec![], - }; - - let issue_type = provider.convert_to_issuetype(&external, "PROJ"); - - assert_eq!(issue_type.key, "BUG"); - assert_eq!(issue_type.name, "Bug"); - assert_eq!(issue_type.glyph, "B"); - assert!(issue_type.is_autonomous()); - } - - #[test] - fn test_convert_long_name() { - let provider = JiraProvider::new( - "test.atlassian.net".to_string(), - "test@test.com".to_string(), - "token".to_string(), - ); - - let external = ExternalIssueType { - id: "10001".to_string(), - name: "Very Long Issue Type Name".to_string(), - description: None, - icon_url: None, - custom_fields: vec![], - }; - - let issue_type = provider.convert_to_issuetype(&external, "PROJ"); - - // Should be truncated to 10 chars - assert!(issue_type.key.len() <= 10); - assert!(issue_type.key.chars().all(|c| c.is_ascii_uppercase())); + fn test_issue_type_ref_has_id() { + // JiraIssueTypeRef now includes optional id for kanban issue type refs + let json = r#"{"id": "10001", "name": "Bug"}"#; + let type_ref: JiraIssueTypeRef = serde_json::from_str(json).unwrap(); + assert_eq!(type_ref.id, Some("10001".to_string())); + assert_eq!(type_ref.name, "Bug"); } #[test] - fn test_convert_short_name() { - let provider = JiraProvider::new( - "test.atlassian.net".to_string(), - "test@test.com".to_string(), - "token".to_string(), - ); - - let external = ExternalIssueType { - id: "10001".to_string(), - name: "X".to_string(), - description: None, - icon_url: None, - custom_fields: vec![], - }; - - let issue_type = provider.convert_to_issuetype(&external, "PROJ"); - - // Should be padded to minimum 2 chars - assert!(issue_type.key.len() >= 2); + fn test_issue_type_ref_no_id() { + // id is optional for backward compatibility + let json = r#"{"name": "Bug"}"#; + let type_ref: JiraIssueTypeRef = serde_json::from_str(json).unwrap(); + assert_eq!(type_ref.id, None); + assert_eq!(type_ref.name, "Bug"); } } diff --git a/src/api/providers/kanban/linear.rs b/src/api/providers/kanban/linear.rs index ec3add7..ae3a478 100644 --- a/src/api/providers/kanban/linear.rs +++ b/src/api/providers/kanban/linear.rs @@ -8,11 +8,31 @@ use tracing::{debug, warn}; use super::{ExternalIssue, ExternalIssueType, ExternalUser, KanbanProvider, ProjectInfo}; use crate::api::error::ApiError; -use crate::issuetypes::IssueType; +use crate::issuetypes::kanban_type::KanbanIssueTypeRef; const LINEAR_API_URL: &str = "https://api.linear.app/graphql"; const PROVIDER_NAME: &str = "linear"; +/// Info about a Linear team (returned by `validate_detailed`). +#[derive(Debug, Clone)] +pub struct LinearTeamInfo { + pub id: String, + pub key: String, + pub name: String, +} + +/// Detailed validation result for Linear onboarding. +/// +/// Richer than `KanbanProvider::test_connection` — includes viewer, org, and +/// the full list of teams available to the API key in a single round-trip. +#[derive(Debug, Clone)] +pub struct LinearValidationDetails { + pub user_id: String, + pub user_name: String, + pub org_name: String, + pub teams: Vec, +} + /// Linear API provider pub struct LinearProvider { api_key: String, @@ -187,6 +207,74 @@ impl LinearProvider { ) }) } + + /// Detailed credential validation for onboarding. + /// + /// A single `GraphQL` query returns viewer (user), organization, and the + /// full list of teams accessible to the API key. The onboarding flow + /// uses the `viewer.id` as `sync_user_id` and shows the team list in a + /// picker. + pub async fn validate_detailed(&self) -> Result { + let query = r" + query { + viewer { id name } + organization { name urlKey } + teams { nodes { id key name } } + } + "; + + #[derive(Deserialize)] + struct ValidateResponse { + viewer: ViewerNode, + organization: OrgNode, + teams: TeamsNodesPayload, + } + + #[derive(Deserialize)] + struct ViewerNode { + id: String, + #[serde(default)] + name: String, + } + + #[derive(Deserialize)] + struct OrgNode { + #[serde(default)] + name: String, + #[serde(default, rename = "urlKey")] + #[allow(dead_code)] + url_key: String, + } + + #[derive(Deserialize)] + struct TeamsNodesPayload { + nodes: Vec, + } + + #[derive(Deserialize)] + struct TeamNodePayload { + id: String, + key: String, + name: String, + } + + let resp: ValidateResponse = self.graphql(query, None).await?; + Ok(LinearValidationDetails { + user_id: resp.viewer.id, + user_name: resp.viewer.name, + org_name: resp.organization.name, + teams: resp + .teams + .nodes + .into_iter() + .map(|t| LinearTeamInfo { + id: t.id, + key: t.key, + name: t.name, + }) + .collect(), + }) + } } // Linear GraphQL response types @@ -303,6 +391,8 @@ struct LinearIssue { assignee: Option, priority: i32, url: String, + #[serde(default)] + labels: Option, } #[derive(Debug, Deserialize)] @@ -464,36 +554,6 @@ impl KanbanProvider for LinearProvider { .collect()) } - fn convert_to_issuetype(&self, external: &ExternalIssueType, project_key: &str) -> IssueType { - // Sanitize key: uppercase, letters only, max 10 chars - let key: String = external - .name - .chars() - .filter(char::is_ascii_alphabetic) - .take(10) - .collect::() - .to_uppercase(); - - // Ensure minimum key length - let key = if key.len() < 2 { - format!("{key}X") - } else { - key - }; - - IssueType::new_imported( - key, - external.name.clone(), - external - .description - .clone() - .unwrap_or_else(|| format!("Imported from Linear: {}", external.name)), - "linear".to_string(), - project_key.to_string(), - Some(external.id.clone()), - ) - } - async fn test_connection(&self) -> Result { let query = r" query { @@ -626,6 +686,12 @@ impl KanbanProvider for LinearProvider { } priority url + labels { + nodes { + id + name + } + } } } } @@ -657,6 +723,12 @@ impl KanbanProvider for LinearProvider { } priority url + labels { + nodes { + id + name + } + } } } } @@ -682,21 +754,36 @@ impl KanbanProvider for LinearProvider { .issues .nodes .into_iter() - .map(|issue| ExternalIssue { - id: issue.id, - key: issue.identifier, - summary: issue.title, - description: issue.description, - issue_type: "Issue".to_string(), // Linear doesn't have issue types - status: issue.state.name, - assignee: issue.assignee.map(|u| ExternalUser { - id: u.id, - name: u.name, - email: u.email, - avatar_url: u.avatar_url, - }), - url: issue.url, - priority: priority_to_string(issue.priority), + .map(|issue| { + let kanban_issue_types = issue + .labels + .map(|labels| { + labels + .nodes + .into_iter() + .map(|l| KanbanIssueTypeRef { + id: l.id, + name: l.name, + }) + .collect() + }) + .unwrap_or_default(); + ExternalIssue { + id: issue.id, + key: issue.identifier, + summary: issue.title, + description: issue.description, + kanban_issue_types, + status: issue.state.name, + assignee: issue.assignee.map(|u| ExternalUser { + id: u.id, + name: u.name, + email: u.email, + avatar_url: u.avatar_url, + }), + url: issue.url, + priority: priority_to_string(issue.priority), + } }) .collect()) } @@ -726,6 +813,12 @@ impl KanbanProvider for LinearProvider { } priority url + labels { + nodes { + id + name + } + } } } } @@ -769,7 +862,19 @@ impl KanbanProvider for LinearProvider { key: issue.identifier, summary: issue.title, description: issue.description, - issue_type: "Issue".to_string(), + kanban_issue_types: issue + .labels + .map(|labels| { + labels + .nodes + .into_iter() + .map(|l| KanbanIssueTypeRef { + id: l.id, + name: l.name, + }) + .collect() + }) + .unwrap_or_default(), status: issue.state.name, assignee: issue.assignee.map(|u| ExternalUser { id: u.id, @@ -814,6 +919,12 @@ impl KanbanProvider for LinearProvider { } priority url + labels { + nodes { + id + name + } + } } } } @@ -847,7 +958,19 @@ impl KanbanProvider for LinearProvider { key: issue.identifier, summary: issue.title, description: issue.description, - issue_type: "Issue".to_string(), + kanban_issue_types: issue + .labels + .map(|labels| { + labels + .nodes + .into_iter() + .map(|l| KanbanIssueTypeRef { + id: l.id, + name: l.name, + }) + .collect() + }) + .unwrap_or_default(), status: issue.state.name, assignee: issue.assignee.map(|u| ExternalUser { id: u.id, @@ -874,41 +997,37 @@ mod tests { } #[test] - fn test_convert_to_issuetype() { - let provider = LinearProvider::new("test_key".to_string()); - - let external = ExternalIssueType { - id: "label-123".to_string(), - name: "Feature".to_string(), - description: Some("A feature request".to_string()), - icon_url: None, - custom_fields: vec![], - }; - - let issue_type = provider.convert_to_issuetype(&external, "TEAM-ABC"); - - assert_eq!(issue_type.key, "FEATURE"); - assert_eq!(issue_type.name, "Feature"); - assert_eq!(issue_type.glyph, "F"); - assert!(issue_type.is_autonomous()); - } - - #[test] - fn test_convert_with_numbers() { - let provider = LinearProvider::new("test_key".to_string()); - - let external = ExternalIssueType { - id: "label-123".to_string(), - name: "P0 Bug".to_string(), - description: None, - icon_url: None, - custom_fields: vec![], - }; - - let issue_type = provider.convert_to_issuetype(&external, "TEAM-ABC"); + fn test_labels_deserialized_on_issue() { + let json = r#"{ + "issues": { + "nodes": [ + { + "id": "issue-with-labels", + "identifier": "ENG-789", + "title": "Issue with labels", + "description": null, + "state": { "name": "Todo" }, + "assignee": null, + "priority": 3, + "url": "https://linear.app/team/issue/ENG-789", + "labels": { + "nodes": [ + { "id": "label-bug", "name": "Bug", "description": null, "color": null }, + { "id": "label-urgent", "name": "Urgent", "description": null, "color": null } + ] + } + } + ] + } + }"#; - // Should filter out numbers and spaces - assert_eq!(issue_type.key, "PBUG"); + let response: IssuesResponse = serde_json::from_str(json).unwrap(); + let issue = &response.issues.nodes[0]; + assert!(issue.labels.is_some()); + let labels = issue.labels.as_ref().unwrap(); + assert_eq!(labels.nodes.len(), 2); + assert_eq!(labels.nodes[0].id, "label-bug"); + assert_eq!(labels.nodes[0].name, "Bug"); } #[test] diff --git a/src/api/providers/kanban/mod.rs b/src/api/providers/kanban/mod.rs index 376d2c1..1ac1e36 100644 --- a/src/api/providers/kanban/mod.rs +++ b/src/api/providers/kanban/mod.rs @@ -4,11 +4,13 @@ //! //! Supports importing issue types and syncing work items from Jira, Linear, and other kanban providers. +mod github_projects; mod jira; mod linear; -pub use jira::JiraProvider; -pub use linear::LinearProvider; +pub use github_projects::{GithubProjectInfo, GithubProjectsProvider, GithubValidationDetails}; +pub use jira::{JiraProvider, JiraValidationDetails}; +pub use linear::{LinearProvider, LinearTeamInfo, LinearValidationDetails}; // Re-export Jira API response types for schema/binding generation pub use jira::{ @@ -20,7 +22,7 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use crate::api::error::ApiError; -use crate::issuetypes::IssueType; +use crate::issuetypes::kanban_type::KanbanIssueTypeRef; /// Information about a project/team from a kanban provider #[derive(Debug, Clone, Serialize, Deserialize)] @@ -57,8 +59,8 @@ pub struct ExternalIssue { pub summary: String, /// Full description (may be markdown) pub description: Option, - /// Issue type name (e.g., "Bug", "Story", "Task") - pub issue_type: String, + /// Kanban issue type refs from the provider (Jira: one issuetype, Linear: labels) + pub kanban_issue_types: Vec, /// Current status name (e.g., "To Do", "In Progress") pub status: String, /// Assigned user (if any) @@ -85,7 +87,7 @@ pub struct ExternalIssueType { } /// External field definition from a kanban provider -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ExternalField { /// Field identifier pub id: String, @@ -145,9 +147,6 @@ pub trait KanbanProvider: Send + Sync { /// Get issue types for a project async fn get_issue_types(&self, project_key: &str) -> Result, ApiError>; - /// Convert an external issue type to an Operator `IssueType` - fn convert_to_issuetype(&self, external: &ExternalIssueType, project_key: &str) -> IssueType; - /// Test connectivity to the API async fn test_connection(&self) -> Result; @@ -211,6 +210,13 @@ pub fn detect_configured_providers() -> Vec { providers.push("linear".to_string()); } + if GithubProjectsProvider::from_env() + .map(|p| p.is_configured()) + .unwrap_or(false) + { + providers.push("github".to_string()); + } + providers } @@ -219,6 +225,7 @@ pub fn detect_configured_providers() -> Vec { pub enum KanbanProviderType { Jira, Linear, + Github, } impl KanbanProviderType { @@ -227,6 +234,7 @@ impl KanbanProviderType { match self { KanbanProviderType::Jira => "Jira Cloud", KanbanProviderType::Linear => "Linear", + KanbanProviderType::Github => "GitHub Projects", } } @@ -235,6 +243,7 @@ impl KanbanProviderType { match self { KanbanProviderType::Jira => "OPERATOR_JIRA_API_KEY", KanbanProviderType::Linear => "OPERATOR_LINEAR_API_KEY", + KanbanProviderType::Github => "OPERATOR_GITHUB_TOKEN", } } } @@ -289,6 +298,14 @@ impl DetectedKanbanProvider { // Linear just needs API key self.env_vars_found.iter().any(|v| v.contains("API_KEY")) } + KanbanProviderType::Github => { + // GitHub Projects just needs the token. Note: only + // OPERATOR_GITHUB_TOKEN counts here — see Token + // Disambiguation rule 5 in github_projects.rs. + self.env_vars_found + .iter() + .any(|v| v.contains("TOKEN") || v.contains("API_KEY")) + } } } } @@ -341,6 +358,24 @@ pub fn detect_kanban_env_vars() -> Vec { }); } + // Check for GitHub Projects environment variables. + // + // Token Disambiguation rule 5: ONLY OPERATOR_GITHUB_TOKEN qualifies. + // GITHUB_TOKEN belongs to the git provider (PR/branch workflows) and + // detecting it here would surface a spurious "GitHub kanban detected" + // prompt for every operator user with a PR-flow git token. + let github_token = env::var("OPERATOR_GITHUB_TOKEN").ok(); + + if github_token.is_some() { + providers.push(DetectedKanbanProvider { + provider_type: KanbanProviderType::Github, + domain: "github.com".to_string(), + email: None, + env_vars_found: vec!["OPERATOR_GITHUB_TOKEN".to_string()], + status: ProviderStatus::Untested, + }); + } + // Scan for custom-named Jira instances (OPERATOR_JIRA__API_KEY pattern) for (key, _value) in env::vars() { if let Some(instance_name) = parse_custom_jira_env_var(&key) { @@ -460,6 +495,16 @@ pub async fn test_provider_credentials(provider: &DetectedKanbanProvider) -> Res Ok(()) } + KanbanProviderType::Github => { + let gh = GithubProjectsProvider::from_env() + .map_err(|e| format!("Failed to create provider: {e}"))?; + + gh.test_connection() + .await + .map_err(|e| format!("Connection failed: {e}"))?; + + Ok(()) + } } } @@ -472,6 +517,9 @@ pub fn get_provider(name: &str) -> Option> { "linear" => LinearProvider::from_env() .ok() .map(|p| Box::new(p) as Box), + "github" => GithubProjectsProvider::from_env() + .ok() + .map(|p| Box::new(p) as Box), _ => None, } } @@ -504,8 +552,20 @@ pub fn get_provider_from_config( LinearProvider::from_config(workspace, cfg) .map(|p| Box::new(p) as Box) } + "github" => { + let (owner, cfg) = kanban + .github + .iter() + .find(|(_, cfg)| cfg.enabled && cfg.projects.contains_key(project_key)) + .or_else(|| kanban.github.iter().find(|(_, cfg)| cfg.enabled)) + .ok_or_else(|| { + ApiError::not_configured("No enabled GitHub Projects provider configured") + })?; + GithubProjectsProvider::from_config(owner, cfg) + .map(|p| Box::new(p) as Box) + } _ => Err(ApiError::not_configured(format!( - "Unknown provider: '{provider_name}'. Supported: jira, linear" + "Unknown provider: '{provider_name}'. Supported: jira, linear, github" ))), } } @@ -557,7 +617,10 @@ mod tests { key: "PROJ-123".to_string(), summary: "Fix login bug".to_string(), description: Some("Users cannot log in with SSO".to_string()), - issue_type: "Bug".to_string(), + kanban_issue_types: vec![KanbanIssueTypeRef { + id: "10001".to_string(), + name: "Bug".to_string(), + }], status: "To Do".to_string(), assignee: Some(ExternalUser { id: "user-123".to_string(), @@ -581,7 +644,10 @@ mod tests { key: "ENG-456".to_string(), summary: "Add dark mode".to_string(), description: None, - issue_type: "Feature".to_string(), + kanban_issue_types: vec![KanbanIssueTypeRef { + id: "label-feat".to_string(), + name: "Feature".to_string(), + }], status: "Backlog".to_string(), assignee: None, url: "https://linear.app/team/ENG-456".to_string(), @@ -620,6 +686,7 @@ mod tests { fn test_kanban_provider_type_display_name() { assert_eq!(KanbanProviderType::Jira.display_name(), "Jira Cloud"); assert_eq!(KanbanProviderType::Linear.display_name(), "Linear"); + assert_eq!(KanbanProviderType::Github.display_name(), "GitHub Projects"); } #[test] @@ -632,6 +699,10 @@ mod tests { KanbanProviderType::Linear.default_api_key_env(), "OPERATOR_LINEAR_API_KEY" ); + assert_eq!( + KanbanProviderType::Github.default_api_key_env(), + "OPERATOR_GITHUB_TOKEN" + ); } #[test] @@ -674,6 +745,18 @@ mod tests { assert!(provider.has_required_env_vars()); } + #[test] + fn test_detected_provider_has_required_env_vars_github() { + let provider = DetectedKanbanProvider { + provider_type: KanbanProviderType::Github, + domain: "github.com".to_string(), + email: None, + env_vars_found: vec!["OPERATOR_GITHUB_TOKEN".to_string()], + status: ProviderStatus::Untested, + }; + assert!(provider.has_required_env_vars()); + } + #[test] fn test_parse_custom_jira_env_var_standard() { // Standard vars should return None diff --git a/src/app/kanban.rs b/src/app/kanban.rs index ef62d5c..f473519 100644 --- a/src/app/kanban.rs +++ b/src/app/kanban.rs @@ -83,14 +83,15 @@ impl App { ); } - /// Show the kanban providers view + /// Show the kanban providers view. + /// + /// If no providers are configured, opens the onboarding wizard dialog + /// directly so the user can add their first provider without manually + /// editing config.toml. pub(super) fn show_kanban_view(&mut self) { let collections = self.kanban_sync_service.configured_collections(); if collections.is_empty() { - // No kanban providers configured, show a message - self.sync_status_message = Some( - "No kanban providers configured. Add [kanban] section to config.toml".to_string(), - ); + self.show_kanban_onboarding_dialog(); return; } self.kanban_view.show(collections); diff --git a/src/app/kanban_onboarding.rs b/src/app/kanban_onboarding.rs new file mode 100644 index 0000000..e577177 --- /dev/null +++ b/src/app/kanban_onboarding.rs @@ -0,0 +1,372 @@ +//! App-side async dispatch for the kanban onboarding dialog. +//! +//! The dialog is purely UI state; this module reacts to actions emitted +//! by the dialog and calls `services::kanban_onboarding` directly. + +use anyhow::Result; + +use crate::rest::dto::{ + JiraCredentials, JiraSessionEnv, KanbanProviderKind, LinearCredentials, LinearSessionEnv, + ListKanbanProjectsRequest, SetKanbanSessionEnvRequest, ValidateKanbanCredentialsRequest, + WriteJiraConfigBody, WriteKanbanConfigRequest, WriteLinearConfigBody, +}; +use crate::services::kanban_onboarding; +use crate::ui::{KanbanOnboardingAction, KanbanOnboardingProject, KanbanOnboardingProvider}; + +use super::App; + +/// Stash credentials between the validate and writeConfig stages so the +/// dialog doesn't have to expose them. +#[derive(Debug, Clone, Default)] +pub(crate) struct KanbanOnboardingCreds { + pub jira: Option, + pub linear: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct JiraCredsInflight { + pub domain: String, + pub email: String, + pub api_token: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct LinearCredsInflight { + pub api_key: String, +} + +impl App { + /// Show the kanban onboarding dialog (entry point from the kanban view). + pub(super) fn show_kanban_onboarding_dialog(&mut self) { + self.kanban_onboarding_dialog.show(); + self.kanban_onboarding_creds = KanbanOnboardingCreds::default(); + } + + /// Handle an action emitted by the kanban onboarding dialog. + /// Performs async work (validate / list projects / write config / sync) + /// and updates the dialog state via its setters. + pub(super) async fn handle_kanban_onboarding_action( + &mut self, + action: KanbanOnboardingAction, + ) -> Result<()> { + match action { + KanbanOnboardingAction::None + | KanbanOnboardingAction::PickedProvider(_) + | KanbanOnboardingAction::Cancelled + | KanbanOnboardingAction::Done => { + // Pure UI transitions — no async work needed. + } + KanbanOnboardingAction::SubmitJiraCreds { + domain, + email, + token, + } => { + // Stash for later (write_config + set_session_env) + self.kanban_onboarding_creds.jira = Some(JiraCredsInflight { + domain: domain.clone(), + email: email.clone(), + api_token: token.clone(), + }); + + // Validate + let req = ValidateKanbanCredentialsRequest { + provider: KanbanProviderKind::Jira, + jira: Some(JiraCredentials { + domain: domain.clone(), + email: email.clone(), + api_token: token.clone(), + }), + linear: None, + github: None, + }; + let resp = match kanban_onboarding::validate_credentials(req).await { + Ok(r) => r, + Err(e) => { + self.kanban_onboarding_dialog + .set_error(format!("Could not reach provider: {e:?}")); + return Ok(()); + } + }; + if !resp.valid { + self.kanban_onboarding_dialog.set_error( + resp.error + .unwrap_or_else(|| "Validation failed".to_string()), + ); + return Ok(()); + } + let Some(jira_details) = resp.jira else { + self.kanban_onboarding_dialog + .set_error("Validation succeeded but no Jira details returned".to_string()); + return Ok(()); + }; + self.kanban_onboarding_dialog + .set_validation_jira(jira_details.account_id, jira_details.display_name); + + // Now list projects + let list_req = ListKanbanProjectsRequest { + provider: KanbanProviderKind::Jira, + jira: Some(JiraCredentials { + domain, + email, + api_token: token, + }), + linear: None, + github: None, + }; + let projects = match kanban_onboarding::list_projects(list_req).await { + Ok(r) => r.projects, + Err(e) => { + self.kanban_onboarding_dialog + .set_error(format!("Failed to list projects: {e:?}")); + return Ok(()); + } + }; + if projects.is_empty() { + self.kanban_onboarding_dialog + .set_error("No Jira projects found. Check your permissions.".to_string()); + return Ok(()); + } + let dialog_projects: Vec = projects + .into_iter() + .map(|p| KanbanOnboardingProject { + id: p.id, + key: p.key, + name: p.name, + }) + .collect(); + self.kanban_onboarding_dialog.set_projects(dialog_projects); + } + KanbanOnboardingAction::SubmitLinearCreds { api_key } => { + // Stash creds; workspace_key gets filled in after we know the team + self.kanban_onboarding_creds.linear = Some(LinearCredsInflight { + api_key: api_key.clone(), + }); + + let req = ValidateKanbanCredentialsRequest { + provider: KanbanProviderKind::Linear, + jira: None, + linear: Some(LinearCredentials { + api_key: api_key.clone(), + }), + github: None, + }; + let resp = match kanban_onboarding::validate_credentials(req).await { + Ok(r) => r, + Err(e) => { + self.kanban_onboarding_dialog + .set_error(format!("Could not reach provider: {e:?}")); + return Ok(()); + } + }; + if !resp.valid { + self.kanban_onboarding_dialog.set_error( + resp.error + .unwrap_or_else(|| "Validation failed".to_string()), + ); + return Ok(()); + } + let Some(linear_details) = resp.linear else { + self.kanban_onboarding_dialog.set_error( + "Validation succeeded but no Linear details returned".to_string(), + ); + return Ok(()); + }; + self.kanban_onboarding_dialog.set_validation_linear( + linear_details.user_id, + linear_details.user_name, + linear_details.org_name, + ); + + // For Linear we already have the team list from validate; + // turn it into the project picker. + if linear_details.teams.is_empty() { + self.kanban_onboarding_dialog + .set_error("No Linear teams found. Check your permissions.".to_string()); + return Ok(()); + } + let dialog_projects: Vec = linear_details + .teams + .into_iter() + .map(|t| KanbanOnboardingProject { + id: t.id, + key: t.key, + name: t.name, + }) + .collect(); + self.kanban_onboarding_dialog.set_projects(dialog_projects); + } + KanbanOnboardingAction::PickedProject { + provider, + project_key, + project_name, + } => { + // Build write_config + set_session_env requests from stashed creds + let result = self + .finish_kanban_onboarding(provider, project_key, project_name) + .await; + if let Err(e) = result { + self.kanban_onboarding_dialog + .set_error(format!("Failed to write config: {e}")); + } + } + KanbanOnboardingAction::CopyExportBlock => { + // No-op on the Rust side — the dialog displays the block; + // the user can manually copy from the terminal. Future + // enhancement: integrate with arboard for system clipboard. + self.sync_status_message = Some( + "Export block displayed in dialog — copy manually from the terminal" + .to_string(), + ); + } + } + Ok(()) + } + + /// Final step: write config + set session env + sync issue types. + async fn finish_kanban_onboarding( + &mut self, + provider: KanbanOnboardingProvider, + project_key: String, + project_name: String, + ) -> Result<()> { + match provider { + KanbanOnboardingProvider::Jira => { + let creds = self + .kanban_onboarding_creds + .jira + .clone() + .ok_or_else(|| anyhow::anyhow!("Missing stashed Jira credentials"))?; + let account_id = self.kanban_onboarding_dialog.jira_account_id.clone(); + let api_key_env = "OPERATOR_JIRA_API_KEY".to_string(); + + // Write config + let write_req = WriteKanbanConfigRequest { + provider: KanbanProviderKind::Jira, + jira: Some(WriteJiraConfigBody { + domain: creds.domain.clone(), + email: creds.email.clone(), + api_key_env: api_key_env.clone(), + project_key: project_key.clone(), + sync_user_id: account_id, + }), + linear: None, + github: None, + }; + kanban_onboarding::write_config(write_req, None) + .map_err(|e| anyhow::anyhow!("write_config failed: {e:?}"))?; + + // Set session env + let env_req = SetKanbanSessionEnvRequest { + provider: KanbanProviderKind::Jira, + jira: Some(JiraSessionEnv { + domain: creds.domain, + email: creds.email, + api_token: creds.api_token, + api_key_env, + }), + linear: None, + github: None, + }; + let env_resp = kanban_onboarding::set_session_env(env_req); + + // Sync issue types (best effort — non-fatal) + self.try_sync_kanban_issue_types("jira", &project_key).await; + + self.kanban_onboarding_dialog.set_success( + format!("Jira project {project_name} configured!"), + env_resp.shell_export_block, + ); + Ok(()) + } + KanbanOnboardingProvider::Linear => { + let creds = self + .kanban_onboarding_creds + .linear + .clone() + .ok_or_else(|| anyhow::anyhow!("Missing stashed Linear credentials"))?; + let user_id = self.kanban_onboarding_dialog.linear_user_id.clone(); + let api_key_env = "OPERATOR_LINEAR_API_KEY".to_string(); + // Use the project_key (team key) as the workspace key for Linear + let workspace_key = project_key.clone(); + + let write_req = WriteKanbanConfigRequest { + provider: KanbanProviderKind::Linear, + jira: None, + linear: Some(WriteLinearConfigBody { + workspace_key: workspace_key.clone(), + api_key_env: api_key_env.clone(), + project_key: project_key.clone(), + sync_user_id: user_id, + }), + github: None, + }; + kanban_onboarding::write_config(write_req, None) + .map_err(|e| anyhow::anyhow!("write_config failed: {e:?}"))?; + + let env_req = SetKanbanSessionEnvRequest { + provider: KanbanProviderKind::Linear, + jira: None, + linear: Some(LinearSessionEnv { + api_key: creds.api_key, + api_key_env, + }), + github: None, + }; + let env_resp = kanban_onboarding::set_session_env(env_req); + + self.try_sync_kanban_issue_types("linear", &project_key) + .await; + + self.kanban_onboarding_dialog.set_success( + format!("Linear team {project_name} configured!"), + env_resp.shell_export_block, + ); + Ok(()) + } + } + } + + /// Best-effort issue type sync after onboarding completes. + /// Non-fatal — onboarding succeeds even if the sync fails. + async fn try_sync_kanban_issue_types(&mut self, provider: &str, project_key: &str) { + use crate::api::providers::kanban::get_provider_from_config; + use crate::config::Config; + use crate::services::kanban_issuetype_service::KanbanIssueTypeService; + + // Reload fresh config from disk so the just-written provider is found. + let fresh_config = match Config::load(None) { + Ok(c) => c, + Err(e) => { + tracing::warn!("Could not reload config for issue type sync: {}", e); + return; + } + }; + let kanban_provider = + match get_provider_from_config(&fresh_config.kanban, provider, project_key) { + Ok(p) => p, + Err(e) => { + tracing::warn!("Could not build provider for sync: {}", e); + return; + } + }; + let service = KanbanIssueTypeService::from_tickets_path(std::path::Path::new( + &fresh_config.paths.tickets, + )); + match service + .sync_issue_types(kanban_provider.as_ref(), project_key) + .await + { + Ok(types) => { + tracing::info!( + "Synced {} issue types for {}/{}", + types.len(), + provider, + project_key + ); + } + Err(e) => { + tracing::warn!("Issue type sync failed: {}", e); + } + } + } +} diff --git a/src/app/keyboard.rs b/src/app/keyboard.rs index 65c4041..9f5b7b3 100644 --- a/src/app/keyboard.rs +++ b/src/app/keyboard.rs @@ -284,6 +284,10 @@ impl App { self.kanban_view.syncing = false; self.kanban_view.hide(); } + KanbanViewResult::AddProvider => { + // Open the kanban onboarding wizard + self.show_kanban_onboarding_dialog(); + } KanbanViewResult::Dismissed => { // Already hidden by handle_key } @@ -374,6 +378,13 @@ impl App { return Ok(()); } + // Kanban onboarding dialog handling + if self.kanban_onboarding_dialog.visible { + let action = self.kanban_onboarding_dialog.handle_key(code); + self.handle_kanban_onboarding_action(action).await?; + return Ok(()); + } + // Normal mode match code { KeyCode::Char('q') => { diff --git a/src/app/mod.rs b/src/app/mod.rs index 946cd79..510256f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -20,8 +20,8 @@ use crate::ui::projects_dialog::ProjectsDialog; use crate::ui::session_preview::SessionPreview; use crate::ui::setup::{DetectedToolInfo, SetupScreen}; use crate::ui::{ - CollectionSwitchDialog, ConfirmDialog, Dashboard, GitTokenDialog, KanbanView, - SessionRecoveryDialog, SyncConfirmDialog, TerminalGuard, + CollectionSwitchDialog, ConfirmDialog, Dashboard, GitTokenDialog, KanbanOnboardingDialog, + KanbanView, SessionRecoveryDialog, SyncConfirmDialog, TerminalGuard, }; use std::sync::Arc; @@ -29,6 +29,7 @@ mod agents; mod data_sync; mod git_onboarding; mod kanban; +mod kanban_onboarding; mod keyboard; mod pr_workflow; mod review; @@ -81,6 +82,10 @@ pub struct App { pub(crate) sync_confirm_dialog: SyncConfirmDialog, /// Git token input dialog pub(crate) git_token_dialog: GitTokenDialog, + /// Kanban onboarding wizard dialog (new providers from main TUI) + pub(crate) kanban_onboarding_dialog: KanbanOnboardingDialog, + /// In-flight credentials for kanban onboarding (cleared on dialog close) + pub(crate) kanban_onboarding_creds: kanban_onboarding::KanbanOnboardingCreds, /// Kanban sync service pub(crate) kanban_sync_service: KanbanSyncService, /// Issue type registry for dynamic issue types @@ -286,6 +291,8 @@ impl App { kanban_view: KanbanView::new(), sync_confirm_dialog: SyncConfirmDialog::new(), git_token_dialog: GitTokenDialog::new(), + kanban_onboarding_dialog: KanbanOnboardingDialog::new(), + kanban_onboarding_creds: kanban_onboarding::KanbanOnboardingCreds::default(), kanban_sync_service, issue_type_registry, pr_event_rx, @@ -394,6 +401,7 @@ impl App { } self.sync_confirm_dialog.render(f); self.git_token_dialog.render(f); + self.kanban_onboarding_dialog.render(f); } })?; diff --git a/src/config.rs b/src/config.rs index c640cb1..749b9ea 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1098,6 +1098,7 @@ impl Default for ApiConfig { /// Providers are keyed by domain/workspace: /// - Jira: keyed by domain (e.g., "foobar.atlassian.net") /// - Linear: keyed by workspace slug (e.g., "myworkspace") +/// - GitHub Projects: keyed by owner login (e.g., "my-org") #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS, Default)] #[ts(export)] pub struct KanbanConfig { @@ -1107,6 +1108,14 @@ pub struct KanbanConfig { /// Linear instances keyed by workspace slug #[serde(default)] pub linear: std::collections::HashMap, + /// GitHub Projects v2 instances keyed by owner login (user or org) + /// + /// NOTE: This is the *kanban* GitHub integration (Projects v2), distinct + /// from `GitHubConfig` which is the *git provider* used for PRs and + /// branches. The two use different env vars and different scopes — see + /// `docs/getting-started/kanban/github.md` for the full disambiguation. + #[serde(default)] + pub github: std::collections::HashMap, } /// Jira Cloud provider configuration @@ -1175,6 +1184,137 @@ impl Default for LinearConfig { } } +/// GitHub Projects v2 (kanban) provider configuration +/// +/// The owner login (user or org) is specified as the `HashMap` key in +/// `KanbanConfig.github`. Project keys inside `projects` are `GraphQL` node +/// IDs (e.g., `PVT_kwDOABcdefg`) — opaque, stable identifiers used directly +/// by every GitHub Projects v2 mutation without needing a lookup. +/// +/// **Distinct from `GitHubConfig`** (the git provider used for PR/branch +/// operations). They live in different parts of the config tree, use +/// different env vars (`OPERATOR_GITHUB_TOKEN` vs `GITHUB_TOKEN`), and +/// require different OAuth scopes (`project` vs `repo`). See +/// `docs/getting-started/kanban/github.md` for the full rationale. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct GithubProjectsConfig { + /// Whether this provider is enabled + #[serde(default)] + pub enabled: bool, + /// Environment variable name containing the GitHub token (default: + /// `OPERATOR_GITHUB_TOKEN`). The token must have `project` (or + /// `read:project`) scope, NOT just `repo` — see the disambiguation + /// guide in the kanban github docs. + #[serde(default = "default_github_projects_api_key_env")] + pub api_key_env: String, + /// Per-project sync configuration. Keys are `GraphQL` project node IDs. + #[serde(default)] + pub projects: std::collections::HashMap, +} + +fn default_github_projects_api_key_env() -> String { + "OPERATOR_GITHUB_TOKEN".to_string() +} + +impl Default for GithubProjectsConfig { + fn default() -> Self { + Self { + enabled: false, + api_key_env: default_github_projects_api_key_env(), + projects: std::collections::HashMap::new(), + } + } +} + +impl KanbanConfig { + /// Insert or update a Jira project entry in the config. + /// + /// If the workspace (keyed by domain) doesn't exist, it is created with + /// `enabled = true` and the provided email + `api_key_env`. If it already + /// exists, the email and `api_key_env` are updated and the project is + /// upserted into its `projects` map without clobbering sibling projects. + pub fn upsert_jira_project( + &mut self, + domain: &str, + email: &str, + api_key_env: &str, + project_key: &str, + sync_user_id: &str, + ) { + let entry = self.jira.entry(domain.to_string()).or_default(); + entry.enabled = true; + entry.email = email.to_string(); + entry.api_key_env = api_key_env.to_string(); + entry.projects.insert( + project_key.to_string(), + ProjectSyncConfig { + sync_user_id: sync_user_id.to_string(), + sync_statuses: Vec::new(), + collection_name: None, + type_mappings: std::collections::HashMap::new(), + }, + ); + } + + /// Insert or update a Linear team entry in the config. + /// + /// If the workspace (keyed by workspace slug) doesn't exist, it is + /// created with `enabled = true` and the provided `api_key_env`. If it + /// already exists, the `api_key_env` is updated and the project/team is + /// upserted into its `projects` map without clobbering siblings. + pub fn upsert_linear_project( + &mut self, + workspace: &str, + api_key_env: &str, + project_key: &str, + sync_user_id: &str, + ) { + let entry = self.linear.entry(workspace.to_string()).or_default(); + entry.enabled = true; + entry.api_key_env = api_key_env.to_string(); + entry.projects.insert( + project_key.to_string(), + ProjectSyncConfig { + sync_user_id: sync_user_id.to_string(), + sync_statuses: Vec::new(), + collection_name: None, + type_mappings: std::collections::HashMap::new(), + }, + ); + } + + /// Insert or update a GitHub Projects v2 entry in the config. + /// + /// If the owner (keyed by login) doesn't exist, it is created with + /// `enabled = true` and the provided `api_key_env`. If it already + /// exists, the `api_key_env` is updated and the project is upserted + /// into its `projects` map without clobbering siblings. + /// + /// `project_key` is the `GraphQL` project node ID (e.g., `PVT_kwDO...`) + /// and `sync_user_id` is the user's numeric GitHub `databaseId`. + pub fn upsert_github_project( + &mut self, + owner: &str, + api_key_env: &str, + project_key: &str, + sync_user_id: &str, + ) { + let entry = self.github.entry(owner.to_string()).or_default(); + entry.enabled = true; + entry.api_key_env = api_key_env.to_string(); + entry.projects.insert( + project_key.to_string(), + ProjectSyncConfig { + sync_user_id: sync_user_id.to_string(), + sync_statuses: Vec::new(), + collection_name: None, + type_mappings: std::collections::HashMap::new(), + }, + ); + } +} + /// Per-project/team sync configuration for a kanban provider #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] @@ -1182,16 +1322,18 @@ pub struct ProjectSyncConfig { /// User ID to sync issues for (provider-specific format) /// - Jira: accountId (e.g., "5e3f7acd9876543210abcdef") /// - Linear: user ID (e.g., "abc12345-6789-0abc-def0-123456789abc") + /// - GitHub Projects: numeric GitHub `databaseId` (e.g., "12345678") #[serde(default)] pub sync_user_id: String, /// Workflow statuses to sync (empty = default/first status only) #[serde(default)] pub sync_statuses: Vec, - /// `IssueTypeCollection` name this project maps to - #[serde(default)] - pub collection_name: String, - /// Optional explicit mapping overrides: external issue type name → operator issue type key - /// When empty, convention-based auto-matching is used (Bug→FIX, Story→FEAT, etc.) + /// Optional `IssueTypeCollection` name this project maps to. + /// Not required for kanban onboarding or sync. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub collection_name: Option, + /// Explicit mapping: kanban issue type ID → operator issue type key (e.g., TASK, FEAT, FIX). + /// Multiple kanban types can map to the same operator template. #[serde(default)] pub type_mappings: std::collections::HashMap, } @@ -1798,4 +1940,132 @@ mod tests { let dir = default_worktrees_dir(); assert!(dir.contains("worktrees")); } + + #[test] + fn test_upsert_jira_project_inserts_new_workspace() { + let mut kanban = KanbanConfig::default(); + kanban.upsert_jira_project( + "acme.atlassian.net", + "user@acme.com", + "OPERATOR_JIRA_API_KEY", + "PROJ", + "acct-123", + ); + + let ws = kanban + .jira + .get("acme.atlassian.net") + .expect("workspace should be inserted"); + assert!(ws.enabled); + assert_eq!(ws.email, "user@acme.com"); + assert_eq!(ws.api_key_env, "OPERATOR_JIRA_API_KEY"); + + let project = ws.projects.get("PROJ").expect("project should exist"); + assert_eq!(project.sync_user_id, "acct-123"); + } + + #[test] + fn test_upsert_jira_project_adds_to_existing_workspace_without_clobber() { + let mut kanban = KanbanConfig::default(); + // Seed with an existing workspace and project + kanban.upsert_jira_project( + "acme.atlassian.net", + "user@acme.com", + "OPERATOR_JIRA_API_KEY", + "EXISTING", + "acct-existing", + ); + + // Add a second project to the same workspace + kanban.upsert_jira_project( + "acme.atlassian.net", + "user@acme.com", + "OPERATOR_JIRA_API_KEY", + "NEWONE", + "acct-new", + ); + + let ws = kanban.jira.get("acme.atlassian.net").unwrap(); + assert_eq!(ws.projects.len(), 2, "both projects should be preserved"); + assert_eq!(ws.projects["EXISTING"].sync_user_id, "acct-existing"); + assert_eq!(ws.projects["NEWONE"].sync_user_id, "acct-new"); + } + + #[test] + fn test_upsert_jira_project_replaces_existing_project_entry() { + let mut kanban = KanbanConfig::default(); + kanban.upsert_jira_project( + "acme.atlassian.net", + "user@acme.com", + "OPERATOR_JIRA_API_KEY", + "PROJ", + "acct-old", + ); + // Upsert same project with new sync_user_id + kanban.upsert_jira_project( + "acme.atlassian.net", + "user@acme.com", + "OPERATOR_JIRA_API_KEY", + "PROJ", + "acct-new", + ); + + let ws = kanban.jira.get("acme.atlassian.net").unwrap(); + assert_eq!(ws.projects.len(), 1); + assert_eq!(ws.projects["PROJ"].sync_user_id, "acct-new"); + } + + #[test] + fn test_upsert_linear_project_inserts_new_workspace() { + let mut kanban = KanbanConfig::default(); + kanban.upsert_linear_project( + "myworkspace", + "OPERATOR_LINEAR_API_KEY", + "ENG", + "user-uuid-1", + ); + + let ws = kanban.linear.get("myworkspace").unwrap(); + assert!(ws.enabled); + assert_eq!(ws.api_key_env, "OPERATOR_LINEAR_API_KEY"); + assert_eq!(ws.projects["ENG"].sync_user_id, "user-uuid-1"); + } + + #[test] + fn test_upsert_linear_project_adds_to_existing_workspace_without_clobber() { + let mut kanban = KanbanConfig::default(); + kanban.upsert_linear_project("myworkspace", "OPERATOR_LINEAR_API_KEY", "ENG", "user-a"); + kanban.upsert_linear_project("myworkspace", "OPERATOR_LINEAR_API_KEY", "DESIGN", "user-b"); + + let ws = kanban.linear.get("myworkspace").unwrap(); + assert_eq!(ws.projects.len(), 2); + assert_eq!(ws.projects["ENG"].sync_user_id, "user-a"); + assert_eq!(ws.projects["DESIGN"].sync_user_id, "user-b"); + } + + #[test] + fn test_upsert_jira_does_not_touch_other_workspaces() { + let mut kanban = KanbanConfig::default(); + kanban.upsert_jira_project( + "first.atlassian.net", + "u1@first.com", + "OPERATOR_JIRA_API_KEY", + "FIRST", + "acct-1", + ); + kanban.upsert_jira_project( + "second.atlassian.net", + "u2@second.com", + "OPERATOR_JIRA_SECOND_API_KEY", + "SECOND", + "acct-2", + ); + + assert_eq!(kanban.jira.len(), 2); + assert_eq!(kanban.jira["first.atlassian.net"].email, "u1@first.com"); + assert_eq!( + kanban.jira["second.atlassian.net"].api_key_env, + "OPERATOR_JIRA_SECOND_API_KEY" + ); + } } diff --git a/src/issuetypes/kanban_type.rs b/src/issuetypes/kanban_type.rs new file mode 100644 index 0000000..baef3be --- /dev/null +++ b/src/issuetypes/kanban_type.rs @@ -0,0 +1,293 @@ +//! Kanban issue type definitions synced from external providers. +//! +//! These are provider metadata only -- not operator workflow definitions. +//! A `KanbanIssueType` represents a type/label from Jira or Linear that +//! can be mapped to an operator `IssueType` template (TASK, FEAT, FIX, etc.). + +use serde::{Deserialize, Serialize}; + +use crate::api::providers::kanban::{ExternalField, ExternalIssueType}; + +/// A kanban issue type synced from an external provider. +/// +/// This is provider metadata only -- not an operator workflow definition. +/// Persisted in `.tickets/operator/kanban///issuetypes.json`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct KanbanIssueType { + /// Provider-specific ID (Jira type ID, Linear label ID) + pub id: String, + /// Display name (e.g., "Bug", "Story", "Task") + pub name: String, + /// Description from the provider + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Icon/avatar URL from the provider + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon_url: Option, + /// Summary of custom fields defined for this type + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub custom_fields: Vec, + /// Provider name ("jira" or "linear") + pub provider: String, + /// Project/team key in the provider + pub project: String, + /// What this type represents in the provider ("issuetype" for Jira, "label" for Linear) + pub source_kind: String, + /// ISO 8601 timestamp of last sync + pub synced_at: String, +} + +/// Lightweight reference to a kanban issue type on an issue. +/// +/// Each issue carries one or more of these refs to indicate its +/// provider-side type classification. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct KanbanIssueTypeRef { + /// Provider-specific type/label ID + pub id: String, + /// Display name + pub name: String, +} + +impl KanbanIssueType { + /// Create from an `ExternalIssueType` with provider context. + pub fn from_external( + external: &ExternalIssueType, + provider: &str, + project: &str, + source_kind: &str, + synced_at: &str, + ) -> Self { + Self { + id: external.id.clone(), + name: external.name.clone(), + description: external.description.clone(), + icon_url: external.icon_url.clone(), + custom_fields: external.custom_fields.clone(), + provider: provider.to_string(), + project: project.to_string(), + source_kind: source_kind.to_string(), + synced_at: synced_at.to_string(), + } + } + + /// Create a `KanbanIssueTypeRef` from this type. + pub fn as_ref(&self) -> KanbanIssueTypeRef { + KanbanIssueTypeRef { + id: self.id.clone(), + name: self.name.clone(), + } + } +} + +/// Sanitize an external type name into a valid operator issuetype key. +/// +/// Rules: uppercase, letters only, max 10 chars, min 2 chars (padded with X). +pub fn sanitize_key(name: &str) -> String { + let key: String = name + .chars() + .filter(char::is_ascii_alphabetic) + .take(10) + .collect::() + .to_uppercase(); + + if key.len() < 2 { + format!("{key}X") + } else { + key + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_external() -> ExternalIssueType { + ExternalIssueType { + id: "10001".to_string(), + name: "Bug".to_string(), + description: Some("A software bug".to_string()), + icon_url: Some("https://example.com/bug.png".to_string()), + custom_fields: vec![], + } + } + + #[test] + fn test_from_external() { + let external = sample_external(); + let kanban = KanbanIssueType::from_external( + &external, + "jira", + "PROJ", + "issuetype", + "2026-04-05T12:00:00Z", + ); + + assert_eq!(kanban.id, "10001"); + assert_eq!(kanban.name, "Bug"); + assert_eq!(kanban.description, Some("A software bug".to_string())); + assert_eq!( + kanban.icon_url, + Some("https://example.com/bug.png".to_string()) + ); + assert_eq!(kanban.provider, "jira"); + assert_eq!(kanban.project, "PROJ"); + assert_eq!(kanban.source_kind, "issuetype"); + assert_eq!(kanban.synced_at, "2026-04-05T12:00:00Z"); + } + + #[test] + fn test_from_external_linear_label() { + let external = ExternalIssueType { + id: "label-abc".to_string(), + name: "Feature".to_string(), + description: None, + icon_url: None, + custom_fields: vec![], + }; + let kanban = KanbanIssueType::from_external( + &external, + "linear", + "TEAM-XYZ", + "label", + "2026-04-05T12:00:00Z", + ); + + assert_eq!(kanban.provider, "linear"); + assert_eq!(kanban.source_kind, "label"); + assert!(kanban.description.is_none()); + assert!(kanban.icon_url.is_none()); + } + + #[test] + fn test_as_ref() { + let kanban = KanbanIssueType::from_external( + &sample_external(), + "jira", + "PROJ", + "issuetype", + "2026-04-05T12:00:00Z", + ); + let r = kanban.as_ref(); + + assert_eq!(r.id, "10001"); + assert_eq!(r.name, "Bug"); + } + + #[test] + fn test_serialization_roundtrip() { + let kanban = KanbanIssueType::from_external( + &sample_external(), + "jira", + "PROJ", + "issuetype", + "2026-04-05T12:00:00Z", + ); + + let json = serde_json::to_string(&kanban).unwrap(); + let deserialized: KanbanIssueType = serde_json::from_str(&json).unwrap(); + + assert_eq!(kanban, deserialized); + } + + #[test] + fn test_ref_serialization_roundtrip() { + let r = KanbanIssueTypeRef { + id: "10001".to_string(), + name: "Bug".to_string(), + }; + + let json = serde_json::to_string(&r).unwrap(); + let deserialized: KanbanIssueTypeRef = serde_json::from_str(&json).unwrap(); + + assert_eq!(r, deserialized); + } + + #[test] + fn test_skip_serializing_none_fields() { + let external = ExternalIssueType { + id: "10001".to_string(), + name: "Task".to_string(), + description: None, + icon_url: None, + custom_fields: vec![], + }; + let kanban = KanbanIssueType::from_external( + &external, + "jira", + "PROJ", + "issuetype", + "2026-04-05T12:00:00Z", + ); + + let json = serde_json::to_string(&kanban).unwrap(); + assert!(!json.contains("description")); + assert!(!json.contains("icon_url")); + assert!(!json.contains("custom_fields")); + } + + #[test] + fn test_sanitize_key_normal() { + assert_eq!(sanitize_key("Bug"), "BUG"); + assert_eq!(sanitize_key("Story"), "STORY"); + assert_eq!(sanitize_key("Feature"), "FEATURE"); + assert_eq!(sanitize_key("Task"), "TASK"); + } + + #[test] + fn test_sanitize_key_filters_non_alpha() { + assert_eq!(sanitize_key("P0 Bug"), "PBUG"); + assert_eq!(sanitize_key("Sub-task"), "SUBTASK"); + assert_eq!(sanitize_key("User Story 2"), "USERSTORY"); + } + + #[test] + fn test_sanitize_key_truncates_long_names() { + assert_eq!(sanitize_key("Very Long Issue Type Name"), "VERYLONGIS"); + } + + #[test] + fn test_sanitize_key_pads_short_names() { + assert_eq!(sanitize_key("X"), "XX"); + assert_eq!(sanitize_key("A"), "AX"); + } + + #[test] + fn test_sanitize_key_empty_after_filter() { + assert_eq!(sanitize_key("123"), "X"); + assert_eq!(sanitize_key(""), "X"); + } + + #[test] + fn test_vec_serialization() { + let types = vec![ + KanbanIssueType::from_external( + &sample_external(), + "jira", + "PROJ", + "issuetype", + "2026-04-05T12:00:00Z", + ), + KanbanIssueType::from_external( + &ExternalIssueType { + id: "10002".to_string(), + name: "Story".to_string(), + description: None, + icon_url: None, + custom_fields: vec![], + }, + "jira", + "PROJ", + "issuetype", + "2026-04-05T12:00:00Z", + ), + ]; + + let json = serde_json::to_string_pretty(&types).unwrap(); + let deserialized: Vec = serde_json::from_str(&json).unwrap(); + + assert_eq!(types.len(), deserialized.len()); + assert_eq!(types[0], deserialized[0]); + assert_eq!(types[1], deserialized[1]); + } +} diff --git a/src/issuetypes/mod.rs b/src/issuetypes/mod.rs index 412c7ba..668a5f8 100644 --- a/src/issuetypes/mod.rs +++ b/src/issuetypes/mod.rs @@ -43,6 +43,7 @@ #![allow(dead_code)] // PARTIAL: Schema used internally, registry not yet exposed to UI pub mod collection; +pub mod kanban_type; pub mod loader; pub mod schema; diff --git a/src/rest/dto.rs b/src/rest/dto.rs index 12468bf..5ad1437 100644 --- a/src/rest/dto.rs +++ b/src/rest/dto.rs @@ -473,6 +473,334 @@ pub struct ExternalIssueTypeSummary { pub icon_url: Option, } +// ============================================================================= +// Kanban Issue Type Catalog DTOs +// ============================================================================= + +/// A synced kanban issue type from the persisted catalog. +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct KanbanIssueTypeResponse { + /// Provider-specific ID (Jira type ID, Linear label ID) + pub id: String, + /// Display name (e.g., "Bug", "Story", "Task") + pub name: String, + /// Description from the provider + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Icon/avatar URL from the provider + #[serde(skip_serializing_if = "Option::is_none")] + pub icon_url: Option, + /// Provider name ("jira", "linear", or "github") + pub provider: String, + /// Project/team key + pub project: String, + /// What this type represents in the provider ("issuetype" or "label") + pub source_kind: String, + /// ISO 8601 timestamp of last sync + pub synced_at: String, +} + +/// Response from syncing kanban issue types from a provider. +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SyncKanbanIssueTypesResponse { + /// Number of issue types synced + pub synced: usize, + /// The synced issue types + pub types: Vec, +} + +// ============================================================================= +// Kanban Onboarding DTOs +// ============================================================================= + +/// Which kanban provider an onboarding request targets. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS, PartialEq, Eq)] +#[ts(export)] +#[serde(rename_all = "lowercase")] +pub enum KanbanProviderKind { + Jira, + Linear, + Github, +} + +/// Ephemeral Jira credentials supplied by a client during onboarding. +/// +/// These are never persisted to disk by the onboarding endpoints that take +/// this struct — the actual secret stays in the env var named in +/// `api_key_env` once set via `/api/v1/kanban/session-env`. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct JiraCredentials { + /// Jira Cloud domain (e.g., "acme.atlassian.net") + pub domain: String, + /// Atlassian account email for Basic Auth + pub email: String, + /// API token / personal access token + pub api_token: String, +} + +/// Ephemeral Linear credentials supplied by a client during onboarding. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct LinearCredentials { + /// Linear API key (prefixed `lin_api_`) + pub api_key: String, +} + +/// Ephemeral GitHub Projects credentials supplied by a client during onboarding. +/// +/// The token must have `project` (or `read:project`) scope. A repo-only token +/// (the kind used for `GITHUB_TOKEN` and operator's git provider) will be +/// rejected at validation time with a friendly "lacks `project` scope" error. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct GithubCredentials { + /// GitHub PAT, fine-grained PAT, or app installation token + pub token: String, +} + +/// Request to validate kanban credentials without persisting them. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ValidateKanbanCredentialsRequest { + pub provider: KanbanProviderKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub jira: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub linear: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, +} + +/// Jira-specific validation details (returned on success). +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct JiraValidationDetailsDto { + /// Atlassian accountId (used as `sync_user_id`) + pub account_id: String, + /// User display name + pub display_name: String, +} + +/// A Linear team exposed to onboarding clients for project selection. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct LinearTeamInfoDto { + pub id: String, + pub key: String, + pub name: String, +} + +/// Linear-specific validation details (returned on success). +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct LinearValidationDetailsDto { + /// Linear viewer user ID (used as `sync_user_id`) + pub user_id: String, + pub user_name: String, + pub org_name: String, + pub teams: Vec, +} + +/// A GitHub Project v2 surfaced during onboarding for project picker UIs. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct GithubProjectInfoDto { + /// `GraphQL` node ID (e.g., `PVT_kwDOABcdefg`) — used as the project key + pub node_id: String, + /// Project number (e.g., 42) within the owner + pub number: i32, + /// Human-readable project title + pub title: String, + /// Owner login (org or user name) + pub owner_login: String, + /// "Organization" or "User" + pub owner_kind: String, +} + +/// GitHub-specific validation details (returned on success). +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct GithubValidationDetailsDto { + /// Authenticated user's login (e.g., "octocat") + pub user_login: String, + /// Authenticated user's numeric `databaseId` as a string (used as `sync_user_id`) + pub user_id: String, + /// All Projects v2 visible to the token (across viewer + organizations) + pub projects: Vec, + /// The env var name the validated token came from. Used by clients to + /// display "Connected via `OPERATOR_GITHUB_TOKEN`" so users can rotate the + /// right token. See Token Disambiguation in the kanban github docs. + pub resolved_env_var: String, +} + +/// Response from validating kanban credentials. +/// +/// `valid: false` is returned for auth failures — never a 4xx/5xx HTTP +/// status — so clients can display `error` inline without exception handling. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ValidateKanbanCredentialsResponse { + pub valid: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub jira: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub linear: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, +} + +/// Request to list projects/teams from a provider using ephemeral creds. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ListKanbanProjectsRequest { + pub provider: KanbanProviderKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub jira: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub linear: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, +} + +/// A project/team entry returned by `list_projects`. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct KanbanProjectInfo { + pub id: String, + pub key: String, + pub name: String, +} + +/// Response wrapper for list-projects (wrapped for utoipa compatibility). +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ListKanbanProjectsResponse { + pub projects: Vec, +} + +/// Body for writing a Jira project config section. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct WriteJiraConfigBody { + pub domain: String, + pub email: String, + pub api_key_env: String, + pub project_key: String, + pub sync_user_id: String, +} + +/// Body for writing a Linear project/team config section. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct WriteLinearConfigBody { + pub workspace_key: String, + pub api_key_env: String, + pub project_key: String, + pub sync_user_id: String, +} + +/// Body for writing a GitHub Projects v2 config section. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct WriteGithubConfigBody { + /// GitHub owner login (user or org), used as the workspace key + pub owner: String, + /// Env var name where the project-scoped token is set + /// (default: `OPERATOR_GITHUB_TOKEN`). MUST be distinct from `GITHUB_TOKEN` + /// — see Token Disambiguation in the kanban github docs. + pub api_key_env: String, + /// `GraphQL` project node ID (e.g., `PVT_kwDOABcdefg`) + pub project_key: String, + /// Numeric GitHub `databaseId` of the user whose items to sync + pub sync_user_id: String, +} + +/// Request to write or upsert a kanban config section. +/// +/// This endpoint does NOT take the secret — only the env var NAME +/// (`api_key_env`). The secret is set via `/api/v1/kanban/session-env`. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct WriteKanbanConfigRequest { + pub provider: KanbanProviderKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub jira: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub linear: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, +} + +/// Response after writing a kanban config section. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct WriteKanbanConfigResponse { + /// Filesystem path that was written (e.g., ".tickets/operator/config.toml") + pub written_path: String, + /// Header of the top-level section that was upserted + /// (e.g., `[kanban.jira."acme.atlassian.net"]`) + pub section_header: String, +} + +/// Jira session env body — includes the actual secret to set in env. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct JiraSessionEnv { + pub domain: String, + pub email: String, + pub api_token: String, + pub api_key_env: String, +} + +/// Linear session env body — includes the actual secret to set in env. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct LinearSessionEnv { + pub api_key: String, + pub api_key_env: String, +} + +/// GitHub Projects session env body — includes the actual secret to set in env. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct GithubSessionEnv { + pub token: String, + pub api_key_env: String, +} + +/// Request to set kanban-related env vars on the server for the current +/// session so subsequent `from_config` calls find the API key. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SetKanbanSessionEnvRequest { + pub provider: KanbanProviderKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub jira: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub linear: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, +} + +/// Response from setting session env vars. +/// +/// `shell_export_block` uses `` placeholders, NOT the actual +/// secret — it is meant for the user to copy into their shell profile. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SetKanbanSessionEnvResponse { + /// Names (not values) of env vars that were set in the server process. + pub env_vars_set: Vec, + /// Multi-line `export FOO=""` block for the user to copy + /// into `~/.zshrc` / `~/.bashrc`. + pub shell_export_block: String, +} + // ============================================================================= // Health/Status DTOs // ============================================================================= diff --git a/src/rest/mod.rs b/src/rest/mod.rs index ef2e856..e50aa3b 100644 --- a/src/rest/mod.rs +++ b/src/rest/mod.rs @@ -116,6 +116,27 @@ pub fn build_router(state: ApiState) -> Router { "/api/v1/kanban/:provider/:project_key/issuetypes", get(routes::kanban::external_issue_types), ) + .route( + "/api/v1/kanban/:provider/:project_key/issuetypes/sync", + post(routes::kanban::sync_issue_types), + ) + // Kanban onboarding endpoints (validate, list projects, write config, set env) + .route( + "/api/v1/kanban/validate", + post(routes::kanban_onboarding::validate_credentials), + ) + .route( + "/api/v1/kanban/projects", + post(routes::kanban_onboarding::list_projects), + ) + .route( + "/api/v1/kanban/config", + put(routes::kanban_onboarding::write_config), + ) + .route( + "/api/v1/kanban/session-env", + post(routes::kanban_onboarding::set_session_env), + ) // Skills endpoint .route("/api/v1/skills", get(routes::skills::list)) // LLM tools endpoints diff --git a/src/rest/routes/kanban.rs b/src/rest/routes/kanban.rs index a9748af..ec17fa6 100644 --- a/src/rest/routes/kanban.rs +++ b/src/rest/routes/kanban.rs @@ -4,18 +4,47 @@ use axum::extract::{Path, State}; use axum::Json; use crate::api::providers::kanban::get_provider_from_config; -use crate::rest::dto::ExternalIssueTypeSummary; +use crate::config::Config; +use crate::rest::dto::{ + ExternalIssueTypeSummary, KanbanIssueTypeResponse, SyncKanbanIssueTypesResponse, +}; use crate::rest::error::ApiError; use crate::rest::state::ApiState; +use crate::services::kanban_issuetype_service::KanbanIssueTypeService; /// GET /`api/v1/kanban/:provider/:project_key/issuetypes` /// -/// Returns issue types from an external kanban provider for a given project. +/// Returns kanban issue types from the persisted catalog for a given provider/project. +/// Falls back to fetching live from the provider if no catalog exists. pub async fn external_issue_types( State(state): State, Path((provider_name, project_key)): Path<(String, String)>, ) -> Result>, ApiError> { - let provider = get_provider_from_config(&state.config.kanban, &provider_name, &project_key) + // Try reading from persisted catalog first + let service = KanbanIssueTypeService::from_tickets_path(std::path::Path::new( + &state.config.paths.tickets, + )); + let catalog_types = service + .list_kanban_types(&provider_name, &project_key) + .map_err(|e| ApiError::InternalError(format!("Failed to read catalog: {e}")))?; + + if !catalog_types.is_empty() { + let summaries: Vec = catalog_types + .into_iter() + .map(|kt| ExternalIssueTypeSummary { + id: kt.id, + name: kt.name, + description: kt.description, + icon_url: kt.icon_url, + }) + .collect(); + return Ok(Json(summaries)); + } + + // Fall back to live provider fetch. Reload config from disk so freshly + // onboarded providers are visible without requiring a server restart. + let fresh_config = Config::load(None).unwrap_or_else(|_| (*state.config).clone()); + let provider = get_provider_from_config(&fresh_config.kanban, &provider_name, &project_key) .map_err(|e| ApiError::BadRequest(e.to_string()))?; let external_types = provider @@ -36,6 +65,46 @@ pub async fn external_issue_types( Ok(Json(summaries)) } +/// POST /`api/v1/kanban/:provider/:project_key/issuetypes/sync` +/// +/// Refreshes the local kanban issue type catalog from the provider. +pub async fn sync_issue_types( + State(state): State, + Path((provider_name, project_key)): Path<(String, String)>, +) -> Result, ApiError> { + // Reload config from disk so freshly onboarded providers are visible + // without requiring a server restart. + let fresh_config = Config::load(None).unwrap_or_else(|_| (*state.config).clone()); + let provider = get_provider_from_config(&fresh_config.kanban, &provider_name, &project_key) + .map_err(|e| ApiError::BadRequest(e.to_string()))?; + + let service = KanbanIssueTypeService::from_tickets_path(std::path::Path::new( + &state.config.paths.tickets, + )); + + let synced_types = service + .sync_issue_types(provider.as_ref(), &project_key) + .await + .map_err(|e| ApiError::InternalError(format!("Failed to sync issue types: {e}")))?; + + let types: Vec = synced_types + .into_iter() + .map(|kt| KanbanIssueTypeResponse { + id: kt.id, + name: kt.name, + description: kt.description, + icon_url: kt.icon_url, + provider: kt.provider, + project: kt.project, + source_kind: kt.source_kind, + synced_at: kt.synced_at, + }) + .collect(); + + let synced = types.len(); + Ok(Json(SyncKanbanIssueTypesResponse { synced, types })) +} + #[cfg(test)] mod tests { use super::*; @@ -53,4 +122,43 @@ mod tests { assert!(json.contains("\"name\":\"Bug\"")); assert!(!json.contains("icon_url")); // None fields skipped } + + #[test] + fn test_kanban_issue_type_response_serialization() { + let response = KanbanIssueTypeResponse { + id: "10001".to_string(), + name: "Bug".to_string(), + description: Some("A bug".to_string()), + icon_url: None, + provider: "jira".to_string(), + project: "PROJ".to_string(), + source_kind: "issuetype".to_string(), + synced_at: "2026-04-05T12:00:00Z".to_string(), + }; + + let json = serde_json::to_string(&response).unwrap(); + assert!(json.contains("\"provider\":\"jira\"")); + assert!(json.contains("\"source_kind\":\"issuetype\"")); + assert!(!json.contains("icon_url")); // None skipped + } + + #[test] + fn test_sync_response_serialization() { + let response = SyncKanbanIssueTypesResponse { + synced: 2, + types: vec![KanbanIssueTypeResponse { + id: "10001".to_string(), + name: "Bug".to_string(), + description: None, + icon_url: None, + provider: "jira".to_string(), + project: "PROJ".to_string(), + source_kind: "issuetype".to_string(), + synced_at: "2026-04-05T12:00:00Z".to_string(), + }], + }; + + let json = serde_json::to_string(&response).unwrap(); + assert!(json.contains("\"synced\":2")); + } } diff --git a/src/rest/routes/kanban_onboarding.rs b/src/rest/routes/kanban_onboarding.rs new file mode 100644 index 0000000..26d937d --- /dev/null +++ b/src/rest/routes/kanban_onboarding.rs @@ -0,0 +1,68 @@ +//! Kanban onboarding REST endpoints. +//! +//! Thin wrappers around `services::kanban_onboarding` — each handler +//! deserializes its DTO, delegates to the service, and serializes the +//! response. Business logic lives in the service module. + +use axum::extract::State; +use axum::Json; + +use crate::rest::dto::{ + ListKanbanProjectsRequest, ListKanbanProjectsResponse, SetKanbanSessionEnvRequest, + SetKanbanSessionEnvResponse, ValidateKanbanCredentialsRequest, + ValidateKanbanCredentialsResponse, WriteKanbanConfigRequest, WriteKanbanConfigResponse, +}; +use crate::rest::error::ApiError; +use crate::rest::state::ApiState; +use crate::services::kanban_onboarding; + +/// POST /`api/v1/kanban/validate` +/// +/// Validate credentials against the live provider API without persisting +/// anything. Auth failures return `valid: false` with an `error` string +/// rather than a 4xx/5xx status so clients can display errors inline. +pub async fn validate_credentials( + State(_state): State, + Json(req): Json, +) -> Result, ApiError> { + let resp = kanban_onboarding::validate_credentials(req).await?; + Ok(Json(resp)) +} + +/// POST /`api/v1/kanban/projects` +/// +/// List available projects/teams for the given provider using ephemeral +/// credentials. No persistence side effects. +pub async fn list_projects( + State(_state): State, + Json(req): Json, +) -> Result, ApiError> { + let resp = kanban_onboarding::list_projects(req).await?; + Ok(Json(resp)) +} + +/// PUT /`api/v1/kanban/config` +/// +/// Write or upsert a kanban provider+project section into `config.toml`. +/// Does NOT receive the actual secret — only the env var name (`api_key_env`). +pub async fn write_config( + State(_state): State, + Json(req): Json, +) -> Result, ApiError> { + // Pass `None` so the service uses the production config path. + let resp = kanban_onboarding::write_config(req, None)?; + Ok(Json(resp)) +} + +/// POST /`api/v1/kanban/session-env` +/// +/// Set kanban env vars on the server process for the current session so +/// subsequent `from_config()` calls find the API key. Returns a +/// `shell_export_block` with placeholder values for the client to display. +pub async fn set_session_env( + State(_state): State, + Json(req): Json, +) -> Result, ApiError> { + let resp = kanban_onboarding::set_session_env(req); + Ok(Json(resp)) +} diff --git a/src/rest/routes/mod.rs b/src/rest/routes/mod.rs index 0441f0f..fa6fe8c 100644 --- a/src/rest/routes/mod.rs +++ b/src/rest/routes/mod.rs @@ -6,6 +6,7 @@ pub mod delegators; pub mod health; pub mod issuetypes; pub mod kanban; +pub mod kanban_onboarding; pub mod launch; pub mod llm_tools; pub mod projects; diff --git a/src/services/kanban_issuetype_service.rs b/src/services/kanban_issuetype_service.rs new file mode 100644 index 0000000..a0a9d2c --- /dev/null +++ b/src/services/kanban_issuetype_service.rs @@ -0,0 +1,478 @@ +//! Kanban Issue Type Sync Service +//! +//! Syncs issue types from external kanban providers into a local catalog, +//! and resolves kanban issue type refs to operator issuetype keys. + +#![allow(dead_code)] // Infrastructure for kanban sync integration + +use anyhow::{Context, Result}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use tracing::{debug, info, warn}; + +use crate::api::providers::kanban::{ExternalIssueType, KanbanProvider}; +use crate::issuetypes::kanban_type::{KanbanIssueType, KanbanIssueTypeRef}; + +/// Service for syncing and managing kanban issue types. +pub struct KanbanIssueTypeService { + /// Root path for kanban catalog (e.g., `.tickets/operator/kanban`) + catalog_root: PathBuf, +} + +impl KanbanIssueTypeService { + /// Create a new service with the given catalog root path. + pub fn new(catalog_root: PathBuf) -> Self { + Self { catalog_root } + } + + /// Create from a tickets path (e.g., `.tickets`). + pub fn from_tickets_path(tickets_path: &Path) -> Self { + Self { + catalog_root: tickets_path.join("operator/kanban"), + } + } + + /// Get the catalog file path for a provider/project. + fn catalog_path(&self, provider: &str, project: &str) -> PathBuf { + self.catalog_root + .join(provider) + .join(project) + .join("issuetypes.json") + } + + /// Sync issue types from a provider for a specific project. + /// + /// Fetches issue types from the provider API and writes them to the local catalog. + /// Returns the synced types. + pub async fn sync_issue_types( + &self, + provider: &dyn KanbanProvider, + project_key: &str, + ) -> Result> { + let provider_name = provider.name(); + info!( + "Syncing kanban issue types from {}/{}", + provider_name, project_key + ); + + let external_types = provider + .get_issue_types(project_key) + .await + .context("Failed to fetch issue types from provider")?; + + let source_kind = match provider_name { + "linear" => "label", + _ => "issuetype", + }; + + let now = chrono::Utc::now().to_rfc3339(); + let kanban_types: Vec = external_types + .iter() + .map(|et| { + KanbanIssueType::from_external(et, provider_name, project_key, source_kind, &now) + }) + .collect(); + + self.write_catalog(provider_name, project_key, &kanban_types)?; + + info!( + "Synced {} kanban issue types for {}/{}", + kanban_types.len(), + provider_name, + project_key + ); + + Ok(kanban_types) + } + + /// List kanban types from the persisted catalog for a provider/project. + pub fn list_kanban_types(&self, provider: &str, project: &str) -> Result> { + self.read_catalog(provider, project) + } + + /// List all kanban types across all providers and projects. + pub fn list_all_kanban_types(&self) -> Result> { + let mut all = Vec::new(); + + if !self.catalog_root.exists() { + return Ok(all); + } + + // Iterate provider directories + for provider_entry in fs::read_dir(&self.catalog_root)? { + let provider_entry = provider_entry?; + if !provider_entry.file_type()?.is_dir() { + continue; + } + let provider_name = provider_entry.file_name().to_string_lossy().to_string(); + + // Iterate project directories + for project_entry in fs::read_dir(provider_entry.path())? { + let project_entry = project_entry?; + if !project_entry.file_type()?.is_dir() { + continue; + } + let project_name = project_entry.file_name().to_string_lossy().to_string(); + + match self.read_catalog(&provider_name, &project_name) { + Ok(types) => all.extend(types), + Err(e) => { + warn!( + "Failed to read kanban catalog for {}/{}: {}", + provider_name, project_name, e + ); + } + } + } + } + + Ok(all) + } + + /// Resolve a kanban issue type ref to an operator issuetype key. + /// + /// Looks up the ref's ID in `type_mappings`. Returns `None` if unmapped. + pub fn resolve_operator_key( + kanban_ref: &KanbanIssueTypeRef, + type_mappings: &HashMap, + ) -> Option { + type_mappings.get(&kanban_ref.id).cloned() + } + + /// Resolve operator key from multiple kanban refs (e.g., Linear labels). + /// + /// Sorts refs by name for deterministic resolution, picks first mapped ref. + /// Returns `None` if no refs are mapped. + pub fn resolve_operator_key_from_refs( + kanban_refs: &[KanbanIssueTypeRef], + type_mappings: &HashMap, + ) -> Option { + let mut sorted: Vec<_> = kanban_refs.iter().collect(); + sorted.sort_by(|a, b| a.name.cmp(&b.name)); + + for r in sorted { + if let Some(key) = type_mappings.get(&r.id) { + return Some(key.clone()); + } + } + + None + } + + /// Attempt to resolve a legacy name-based mapping key against the synced catalog. + /// + /// If a `type_mappings` key is not a known external ID, looks up by synced name + /// and returns the resolved ID if found. + pub fn resolve_legacy_mapping( + &self, + mapping_key: &str, + provider: &str, + project: &str, + ) -> Option { + let types = self.read_catalog(provider, project).ok()?; + // Check if the key is already a valid ID + if types.iter().any(|t| t.id == mapping_key) { + return Some(mapping_key.to_string()); + } + // Try matching by name (case-insensitive) + types + .iter() + .find(|t| t.name.eq_ignore_ascii_case(mapping_key)) + .map(|t| t.id.clone()) + } + + /// Write the kanban catalog to disk. + fn write_catalog( + &self, + provider: &str, + project: &str, + types: &[KanbanIssueType], + ) -> Result<()> { + let path = self.catalog_path(provider, project); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create catalog dir: {}", parent.display()))?; + } + let json = serde_json::to_string_pretty(types)?; + fs::write(&path, json)?; + debug!("Wrote kanban catalog to {}", path.display()); + Ok(()) + } + + /// Read the kanban catalog from disk. + fn read_catalog(&self, provider: &str, project: &str) -> Result> { + let path = self.catalog_path(provider, project); + if !path.exists() { + return Ok(Vec::new()); + } + let content = fs::read_to_string(&path) + .with_context(|| format!("Failed to read catalog: {}", path.display()))?; + let types: Vec = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse catalog: {}", path.display()))?; + Ok(types) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn sample_external_types() -> Vec { + vec![ + ExternalIssueType { + id: "10001".to_string(), + name: "Bug".to_string(), + description: Some("A bug report".to_string()), + icon_url: None, + custom_fields: vec![], + }, + ExternalIssueType { + id: "10002".to_string(), + name: "Story".to_string(), + description: Some("A user story".to_string()), + icon_url: None, + custom_fields: vec![], + }, + ExternalIssueType { + id: "10003".to_string(), + name: "Task".to_string(), + description: None, + icon_url: None, + custom_fields: vec![], + }, + ] + } + + fn create_service_with_catalog(types: &[KanbanIssueType]) -> (KanbanIssueTypeService, TempDir) { + let tmp = TempDir::new().unwrap(); + let service = KanbanIssueTypeService::new(tmp.path().to_path_buf()); + + if !types.is_empty() { + let provider = &types[0].provider; + let project = &types[0].project; + service.write_catalog(provider, project, types).unwrap(); + } + + (service, tmp) + } + + #[test] + fn test_catalog_path() { + let service = KanbanIssueTypeService::new(PathBuf::from("/tmp/kanban")); + let path = service.catalog_path("jira", "PROJ"); + assert_eq!(path, PathBuf::from("/tmp/kanban/jira/PROJ/issuetypes.json")); + } + + #[test] + fn test_from_tickets_path() { + let service = KanbanIssueTypeService::from_tickets_path(Path::new(".tickets")); + assert_eq!( + service.catalog_root, + PathBuf::from(".tickets/operator/kanban") + ); + } + + #[test] + fn test_write_and_read_catalog() { + let tmp = TempDir::new().unwrap(); + let service = KanbanIssueTypeService::new(tmp.path().to_path_buf()); + + let types: Vec = sample_external_types() + .iter() + .map(|et| { + KanbanIssueType::from_external( + et, + "jira", + "PROJ", + "issuetype", + "2026-04-05T12:00:00Z", + ) + }) + .collect(); + + service.write_catalog("jira", "PROJ", &types).unwrap(); + let read_types = service.read_catalog("jira", "PROJ").unwrap(); + + assert_eq!(types.len(), read_types.len()); + assert_eq!(types[0].id, read_types[0].id); + assert_eq!(types[1].name, read_types[1].name); + } + + #[test] + fn test_read_catalog_nonexistent() { + let tmp = TempDir::new().unwrap(); + let service = KanbanIssueTypeService::new(tmp.path().to_path_buf()); + + let types = service.read_catalog("jira", "NONEXISTENT").unwrap(); + assert!(types.is_empty()); + } + + #[test] + fn test_list_kanban_types() { + let types: Vec = sample_external_types() + .iter() + .map(|et| { + KanbanIssueType::from_external( + et, + "jira", + "PROJ", + "issuetype", + "2026-04-05T12:00:00Z", + ) + }) + .collect(); + + let (service, _tmp) = create_service_with_catalog(&types); + let listed = service.list_kanban_types("jira", "PROJ").unwrap(); + assert_eq!(listed.len(), 3); + } + + #[test] + fn test_list_all_kanban_types() { + let tmp = TempDir::new().unwrap(); + let service = KanbanIssueTypeService::new(tmp.path().to_path_buf()); + + // Write two catalogs + let jira_types: Vec = sample_external_types()[..2] + .iter() + .map(|et| { + KanbanIssueType::from_external( + et, + "jira", + "PROJ", + "issuetype", + "2026-04-05T12:00:00Z", + ) + }) + .collect(); + service.write_catalog("jira", "PROJ", &jira_types).unwrap(); + + let linear_types = vec![KanbanIssueType::from_external( + &sample_external_types()[0], + "linear", + "TEAM", + "label", + "2026-04-05T12:00:00Z", + )]; + service + .write_catalog("linear", "TEAM", &linear_types) + .unwrap(); + + let all = service.list_all_kanban_types().unwrap(); + assert_eq!(all.len(), 3); // 2 jira + 1 linear + } + + #[test] + fn test_list_all_empty() { + let tmp = TempDir::new().unwrap(); + let service = KanbanIssueTypeService::new(tmp.path().to_path_buf()); + let all = service.list_all_kanban_types().unwrap(); + assert!(all.is_empty()); + } + + #[test] + fn test_resolve_operator_key() { + let mut mappings = HashMap::new(); + mappings.insert("10001".to_string(), "FIX".to_string()); + mappings.insert("10002".to_string(), "FEAT".to_string()); + + let r = KanbanIssueTypeRef { + id: "10001".to_string(), + name: "Bug".to_string(), + }; + assert_eq!( + KanbanIssueTypeService::resolve_operator_key(&r, &mappings), + Some("FIX".to_string()) + ); + + let unmapped = KanbanIssueTypeRef { + id: "99999".to_string(), + name: "Unknown".to_string(), + }; + assert_eq!( + KanbanIssueTypeService::resolve_operator_key(&unmapped, &mappings), + None + ); + } + + #[test] + fn test_resolve_operator_key_from_refs_sorted() { + let mut mappings = HashMap::new(); + mappings.insert("label-bug".to_string(), "FIX".to_string()); + mappings.insert("label-feat".to_string(), "FEAT".to_string()); + + let refs = vec![ + KanbanIssueTypeRef { + id: "label-feat".to_string(), + name: "Feature".to_string(), + }, + KanbanIssueTypeRef { + id: "label-bug".to_string(), + name: "Bug".to_string(), + }, + ]; + + // Sorted by name: Bug < Feature, so Bug matches first -> FIX + let result = KanbanIssueTypeService::resolve_operator_key_from_refs(&refs, &mappings); + assert_eq!(result, Some("FIX".to_string())); + } + + #[test] + fn test_resolve_operator_key_from_refs_empty() { + let mappings = HashMap::new(); + let result = KanbanIssueTypeService::resolve_operator_key_from_refs(&[], &mappings); + assert_eq!(result, None); + } + + #[test] + fn test_resolve_legacy_mapping_by_id() { + let types = vec![KanbanIssueType::from_external( + &sample_external_types()[0], + "jira", + "PROJ", + "issuetype", + "2026-04-05T12:00:00Z", + )]; + + let (service, _tmp) = create_service_with_catalog(&types); + + // Exact ID match + let result = service.resolve_legacy_mapping("10001", "jira", "PROJ"); + assert_eq!(result, Some("10001".to_string())); + } + + #[test] + fn test_resolve_legacy_mapping_by_name() { + let types = vec![KanbanIssueType::from_external( + &sample_external_types()[0], + "jira", + "PROJ", + "issuetype", + "2026-04-05T12:00:00Z", + )]; + + let (service, _tmp) = create_service_with_catalog(&types); + + // Name-based lookup (case-insensitive) + let result = service.resolve_legacy_mapping("bug", "jira", "PROJ"); + assert_eq!(result, Some("10001".to_string())); + } + + #[test] + fn test_resolve_legacy_mapping_not_found() { + let types = vec![KanbanIssueType::from_external( + &sample_external_types()[0], + "jira", + "PROJ", + "issuetype", + "2026-04-05T12:00:00Z", + )]; + + let (service, _tmp) = create_service_with_catalog(&types); + + let result = service.resolve_legacy_mapping("Nonexistent", "jira", "PROJ"); + assert_eq!(result, None); + } +} diff --git a/src/services/kanban_onboarding.rs b/src/services/kanban_onboarding.rs new file mode 100644 index 0000000..5339eef --- /dev/null +++ b/src/services/kanban_onboarding.rs @@ -0,0 +1,602 @@ +//! Kanban onboarding service. +//! +//! Owns validation / project listing / config writing / session env setup +//! for Jira, Linear, and GitHub Projects onboarding flows. Both the REST API +//! handlers and the TUI onboarding dialog call the same functions here so +//! there's a single source of truth for config mutation. + +use std::path::PathBuf; + +use tracing::info; + +use crate::api::providers::kanban::{GithubProjectsProvider, JiraProvider, LinearProvider}; +use crate::config::Config; +use crate::rest::dto::{ + GithubProjectInfoDto, GithubValidationDetailsDto, JiraValidationDetailsDto, KanbanProjectInfo, + KanbanProviderKind, LinearTeamInfoDto, LinearValidationDetailsDto, ListKanbanProjectsRequest, + ListKanbanProjectsResponse, SetKanbanSessionEnvRequest, SetKanbanSessionEnvResponse, + ValidateKanbanCredentialsRequest, ValidateKanbanCredentialsResponse, WriteKanbanConfigRequest, + WriteKanbanConfigResponse, +}; +use crate::rest::error::ApiError; + +// ─── Error helpers ────────────────────────────────────────────────────────── + +/// Map a provider-layer `ApiError` into a human-readable string for inline +/// display in client UIs. +fn provider_error_message(err: &crate::api::error::ApiError) -> String { + use crate::api::error::ApiError as ProviderErr; + match err { + ProviderErr::Unauthorized { .. } => { + "Invalid credentials (401). Check your email/domain and API token.".to_string() + } + ProviderErr::Forbidden { .. } => { + "Access forbidden (403). Token may lack required permissions.".to_string() + } + ProviderErr::RateLimited { .. } => { + "Rate limited by provider. Please try again in a moment.".to_string() + } + ProviderErr::NetworkError { message, .. } => { + format!("Network error: {message}") + } + ProviderErr::HttpError { + status, message, .. + } => { + format!("Provider HTTP {status}: {message}") + } + ProviderErr::NotConfigured { .. } => "Provider not configured.".to_string(), + } +} + +// ─── validate_credentials ─────────────────────────────────────────────────── + +/// Validate credentials against the live provider API without persisting +/// anything or mutating any state. +pub async fn validate_credentials( + req: ValidateKanbanCredentialsRequest, +) -> Result { + match req.provider { + KanbanProviderKind::Jira => { + let creds = req.jira.ok_or_else(|| { + ApiError::BadRequest("Missing `jira` field for jira provider".to_string()) + })?; + let provider = + JiraProvider::new(creds.domain.clone(), creds.email.clone(), creds.api_token); + + match provider.validate_detailed().await { + Ok(details) => Ok(ValidateKanbanCredentialsResponse { + valid: true, + error: None, + jira: Some(JiraValidationDetailsDto { + account_id: details.account_id, + display_name: details.display_name, + }), + linear: None, + github: None, + }), + Err(e) => Ok(ValidateKanbanCredentialsResponse { + valid: false, + error: Some(provider_error_message(&e)), + jira: None, + linear: None, + github: None, + }), + } + } + KanbanProviderKind::Linear => { + let creds = req.linear.ok_or_else(|| { + ApiError::BadRequest("Missing `linear` field for linear provider".to_string()) + })?; + let provider = LinearProvider::new(creds.api_key); + + match provider.validate_detailed().await { + Ok(details) => Ok(ValidateKanbanCredentialsResponse { + valid: true, + error: None, + jira: None, + linear: Some(LinearValidationDetailsDto { + user_id: details.user_id, + user_name: details.user_name, + org_name: details.org_name, + teams: details + .teams + .into_iter() + .map(|t| LinearTeamInfoDto { + id: t.id, + key: t.key, + name: t.name, + }) + .collect(), + }), + github: None, + }), + Err(e) => Ok(ValidateKanbanCredentialsResponse { + valid: false, + error: Some(provider_error_message(&e)), + jira: None, + linear: None, + github: None, + }), + } + } + KanbanProviderKind::Github => { + let creds = req.github.ok_or_else(|| { + ApiError::BadRequest("Missing `github` field for github provider".to_string()) + })?; + // Onboarding always uses an ephemeral session token; the env var + // it would land in defaults to OPERATOR_GITHUB_TOKEN unless the + // client overrode it via /api/v1/kanban/session-env. + let provider = + GithubProjectsProvider::new(creds.token, "OPERATOR_GITHUB_TOKEN".to_string()); + + match provider.validate_detailed().await { + Ok(details) => Ok(ValidateKanbanCredentialsResponse { + valid: true, + error: None, + jira: None, + linear: None, + github: Some(GithubValidationDetailsDto { + user_login: details.user_login, + user_id: details.user_id, + projects: details + .projects + .into_iter() + .map(|p| GithubProjectInfoDto { + node_id: p.node_id, + number: p.number, + title: p.title, + owner_login: p.owner_login, + owner_kind: p.owner_kind, + }) + .collect(), + resolved_env_var: details.resolved_env_var, + }), + }), + Err(e) => Ok(ValidateKanbanCredentialsResponse { + valid: false, + error: Some(provider_error_message(&e)), + jira: None, + linear: None, + github: None, + }), + } + } + } +} + +// ─── list_projects ────────────────────────────────────────────────────────── + +/// Fetch the list of projects (Jira) or teams (Linear) for the given creds. +pub async fn list_projects( + req: ListKanbanProjectsRequest, +) -> Result { + use crate::api::providers::kanban::KanbanProvider; + + let projects = match req.provider { + KanbanProviderKind::Jira => { + let creds = req.jira.ok_or_else(|| { + ApiError::BadRequest("Missing `jira` field for jira provider".to_string()) + })?; + let provider = JiraProvider::new(creds.domain, creds.email, creds.api_token); + provider + .list_projects() + .await + .map_err(|e| ApiError::BadRequest(provider_error_message(&e)))? + } + KanbanProviderKind::Linear => { + let creds = req.linear.ok_or_else(|| { + ApiError::BadRequest("Missing `linear` field for linear provider".to_string()) + })?; + let provider = LinearProvider::new(creds.api_key); + provider + .list_projects() + .await + .map_err(|e| ApiError::BadRequest(provider_error_message(&e)))? + } + KanbanProviderKind::Github => { + let creds = req.github.ok_or_else(|| { + ApiError::BadRequest("Missing `github` field for github provider".to_string()) + })?; + let provider = + GithubProjectsProvider::new(creds.token, "OPERATOR_GITHUB_TOKEN".to_string()); + provider + .list_projects() + .await + .map_err(|e| ApiError::BadRequest(provider_error_message(&e)))? + } + }; + + Ok(ListKanbanProjectsResponse { + projects: projects + .into_iter() + .map(|p| KanbanProjectInfo { + id: p.id, + key: p.key, + name: p.name, + }) + .collect(), + }) +} + +// ─── write_config ─────────────────────────────────────────────────────────── + +/// Write or upsert a kanban config section to `config.toml`. +/// +/// `config_override_path` is optional — when `None`, falls back to +/// `Config::operator_config_path()` (which is what production uses). +/// When `Some`, the config is loaded from and saved to that path instead +/// (used by unit tests). +pub fn write_config( + req: WriteKanbanConfigRequest, + config_override_path: Option<&PathBuf>, +) -> Result { + // Load existing config (from disk — not from in-memory ApiState, so that + // concurrent writes don't clobber each other). If load fails, start with + // a default config. + let mut config = match config_override_path { + Some(p) => load_config_from_path(p).unwrap_or_default(), + None => Config::load(None).unwrap_or_default(), + }; + + let section_header = match req.provider { + KanbanProviderKind::Jira => { + let body = req.jira.ok_or_else(|| { + ApiError::BadRequest("Missing `jira` field for jira provider".to_string()) + })?; + config.kanban.upsert_jira_project( + &body.domain, + &body.email, + &body.api_key_env, + &body.project_key, + &body.sync_user_id, + ); + format!("[kanban.jira.\"{}\"]", body.domain) + } + KanbanProviderKind::Linear => { + let body = req.linear.ok_or_else(|| { + ApiError::BadRequest("Missing `linear` field for linear provider".to_string()) + })?; + config.kanban.upsert_linear_project( + &body.workspace_key, + &body.api_key_env, + &body.project_key, + &body.sync_user_id, + ); + format!("[kanban.linear.\"{}\"]", body.workspace_key) + } + KanbanProviderKind::Github => { + let body = req.github.ok_or_else(|| { + ApiError::BadRequest("Missing `github` field for github provider".to_string()) + })?; + config.kanban.upsert_github_project( + &body.owner, + &body.api_key_env, + &body.project_key, + &body.sync_user_id, + ); + format!("[kanban.github.\"{}\"]", body.owner) + } + }; + + let written_path = if let Some(p) = config_override_path { + save_config_to_path(&config, p) + .map_err(|e| ApiError::InternalError(format!("Failed to save config: {e}")))?; + p.display().to_string() + } else { + config + .save() + .map_err(|e| ApiError::InternalError(format!("Failed to save config: {e}")))?; + Config::operator_config_path().display().to_string() + }; + + info!(section = %section_header, "Wrote kanban config section"); + + Ok(WriteKanbanConfigResponse { + written_path, + section_header, + }) +} + +/// Test-only helper: load a Config from an explicit TOML path. +fn load_config_from_path(path: &PathBuf) -> anyhow::Result { + let raw = std::fs::read_to_string(path)?; + let cfg: Config = toml::from_str(&raw)?; + Ok(cfg) +} + +/// Test-only helper: save a Config to an explicit TOML path. +fn save_config_to_path(config: &Config, path: &PathBuf) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let raw = toml::to_string_pretty(config)?; + std::fs::write(path, raw)?; + Ok(()) +} + +// ─── set_session_env ──────────────────────────────────────────────────────── + +/// Set kanban-related env vars on the server process for the current session +/// and return a shell export block the client can show to the user for +/// copying into their shell profile. +/// +/// Security note: the `shell_export_block` uses `` placeholders, +/// NOT the actual secret value supplied in the request. The secret lives +/// only in the process env. +pub fn set_session_env(req: SetKanbanSessionEnvRequest) -> SetKanbanSessionEnvResponse { + let mut env_vars_set: Vec = Vec::new(); + + match req.provider { + KanbanProviderKind::Jira => { + if let Some(body) = req.jira { + // SAFETY: set_var is safe in single-threaded startup contexts; + // the operator REST server runs inside a tokio runtime, but + // the set_var pattern is already established in + // src/app/git_onboarding.rs and src/main.rs. Kanban onboarding + // is a user-driven one-shot and we accept the same tradeoff. + std::env::set_var(&body.api_key_env, &body.api_token); + std::env::set_var("OPERATOR_JIRA_DOMAIN", &body.domain); + std::env::set_var("OPERATOR_JIRA_EMAIL", &body.email); + env_vars_set.push(body.api_key_env.clone()); + env_vars_set.push("OPERATOR_JIRA_DOMAIN".to_string()); + env_vars_set.push("OPERATOR_JIRA_EMAIL".to_string()); + + let shell_export_block = build_shell_export_block_jira(&body.api_key_env); + return SetKanbanSessionEnvResponse { + env_vars_set, + shell_export_block, + }; + } + } + KanbanProviderKind::Linear => { + if let Some(body) = req.linear { + std::env::set_var(&body.api_key_env, &body.api_key); + env_vars_set.push(body.api_key_env.clone()); + + let shell_export_block = build_shell_export_block_linear(&body.api_key_env); + return SetKanbanSessionEnvResponse { + env_vars_set, + shell_export_block, + }; + } + } + KanbanProviderKind::Github => { + if let Some(body) = req.github { + std::env::set_var(&body.api_key_env, &body.token); + env_vars_set.push(body.api_key_env.clone()); + + let shell_export_block = build_shell_export_block_github(&body.api_key_env); + return SetKanbanSessionEnvResponse { + env_vars_set, + shell_export_block, + }; + } + } + } + + // No body supplied for the selected provider — return empty envelope. + SetKanbanSessionEnvResponse { + env_vars_set, + shell_export_block: String::new(), + } +} + +/// Build a copy-paste-ready `export` block for Jira's env vars. +/// +/// Uses placeholders — never embeds the actual token in the returned +/// string. +pub fn build_shell_export_block_jira(api_key_env: &str) -> String { + format!("export {api_key_env}=\"\"") +} + +/// Build a copy-paste-ready `export` block for Linear's env var. +/// +/// Uses placeholders — never embeds the actual token in the returned +/// string. +pub fn build_shell_export_block_linear(api_key_env: &str) -> String { + format!("export {api_key_env}=\"\"") +} + +/// Build a copy-paste-ready `export` block for the GitHub Projects token. +/// +/// Uses placeholders — never embeds the actual token in the returned string. +/// The placeholder text reminds the user this is the *projects* token, not +/// the repo token used by `GITHUB_TOKEN` (Token Disambiguation rule 4). +pub fn build_shell_export_block_github(api_key_env: &str) -> String { + format!("export {api_key_env}=\"\"") +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::rest::dto::{WriteGithubConfigBody, WriteJiraConfigBody, WriteLinearConfigBody}; + use tempfile::tempdir; + + #[test] + fn test_write_config_jira_writes_new_section() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + + let req = WriteKanbanConfigRequest { + provider: KanbanProviderKind::Jira, + jira: Some(WriteJiraConfigBody { + domain: "acme.atlassian.net".to_string(), + email: "user@acme.com".to_string(), + api_key_env: "OPERATOR_JIRA_API_KEY".to_string(), + project_key: "PROJ".to_string(), + sync_user_id: "acct-123".to_string(), + }), + linear: None, + github: None, + }; + + let resp = write_config(req, Some(&path)).unwrap(); + assert_eq!(resp.section_header, "[kanban.jira.\"acme.atlassian.net\"]"); + + let contents = std::fs::read_to_string(&path).unwrap(); + assert!(contents.contains("acme.atlassian.net")); + assert!(contents.contains("user@acme.com")); + assert!(contents.contains("OPERATOR_JIRA_API_KEY")); + assert!(contents.contains("PROJ")); + assert!(contents.contains("acct-123")); + } + + #[test] + fn test_write_config_linear_writes_new_section() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + + let req = WriteKanbanConfigRequest { + provider: KanbanProviderKind::Linear, + jira: None, + linear: Some(WriteLinearConfigBody { + workspace_key: "myws".to_string(), + api_key_env: "OPERATOR_LINEAR_API_KEY".to_string(), + project_key: "ENG".to_string(), + sync_user_id: "user-uuid-42".to_string(), + }), + github: None, + }; + + let resp = write_config(req, Some(&path)).unwrap(); + assert_eq!(resp.section_header, "[kanban.linear.\"myws\"]"); + + let contents = std::fs::read_to_string(&path).unwrap(); + assert!(contents.contains("myws")); + assert!(contents.contains("OPERATOR_LINEAR_API_KEY")); + assert!(contents.contains("ENG")); + assert!(contents.contains("user-uuid-42")); + } + + #[test] + fn test_write_config_upsert_preserves_siblings() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + + // Write first project + write_config( + WriteKanbanConfigRequest { + provider: KanbanProviderKind::Jira, + jira: Some(WriteJiraConfigBody { + domain: "acme.atlassian.net".to_string(), + email: "u@acme.com".to_string(), + api_key_env: "OPERATOR_JIRA_API_KEY".to_string(), + project_key: "FIRST".to_string(), + sync_user_id: "acct-1".to_string(), + }), + linear: None, + github: None, + }, + Some(&path), + ) + .unwrap(); + + // Write second project to the same workspace + write_config( + WriteKanbanConfigRequest { + provider: KanbanProviderKind::Jira, + jira: Some(WriteJiraConfigBody { + domain: "acme.atlassian.net".to_string(), + email: "u@acme.com".to_string(), + api_key_env: "OPERATOR_JIRA_API_KEY".to_string(), + project_key: "SECOND".to_string(), + sync_user_id: "acct-2".to_string(), + }), + linear: None, + github: None, + }, + Some(&path), + ) + .unwrap(); + + let contents = std::fs::read_to_string(&path).unwrap(); + assert!(contents.contains("FIRST"), "first project preserved"); + assert!(contents.contains("SECOND"), "second project added"); + } + + #[test] + fn test_build_shell_export_block_jira_uses_placeholder() { + let block = build_shell_export_block_jira("OPERATOR_JIRA_API_KEY"); + assert_eq!( + block, + "export OPERATOR_JIRA_API_KEY=\"\"" + ); + assert!(!block.contains("real"), "no real secret should leak"); + } + + #[test] + fn test_build_shell_export_block_linear_uses_placeholder() { + let block = build_shell_export_block_linear("OPERATOR_LINEAR_API_KEY"); + assert_eq!( + block, + "export OPERATOR_LINEAR_API_KEY=\"\"" + ); + } + + #[test] + fn test_build_shell_export_block_github_uses_placeholder() { + let block = build_shell_export_block_github("OPERATOR_GITHUB_TOKEN"); + assert_eq!( + block, + "export OPERATOR_GITHUB_TOKEN=\"\"" + ); + // The placeholder must distinguish this from the repo-token GITHUB_TOKEN + // (Token Disambiguation rule 4). + assert!(block.contains("github-projects")); + } + + #[test] + fn test_write_config_github_writes_new_section() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + + let req = WriteKanbanConfigRequest { + provider: KanbanProviderKind::Github, + jira: None, + linear: None, + github: Some(WriteGithubConfigBody { + owner: "octo-org".to_string(), + api_key_env: "OPERATOR_GITHUB_TOKEN".to_string(), + project_key: "PVT_kwDOABcdefg".to_string(), + sync_user_id: "12345678".to_string(), + }), + }; + + let resp = write_config(req, Some(&path)).unwrap(); + assert_eq!(resp.section_header, "[kanban.github.\"octo-org\"]"); + + let contents = std::fs::read_to_string(&path).unwrap(); + assert!(contents.contains("octo-org")); + assert!(contents.contains("OPERATOR_GITHUB_TOKEN")); + assert!(contents.contains("PVT_kwDOABcdefg")); + assert!(contents.contains("12345678")); + } + + #[test] + fn test_validate_missing_jira_body_returns_bad_request() { + let req = ValidateKanbanCredentialsRequest { + provider: KanbanProviderKind::Jira, + jira: None, + linear: None, + github: None, + }; + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(validate_credentials(req)); + assert!(matches!(result, Err(ApiError::BadRequest(_)))); + } + + #[test] + fn test_validate_missing_github_body_returns_bad_request() { + let req = ValidateKanbanCredentialsRequest { + provider: KanbanProviderKind::Github, + jira: None, + linear: None, + github: None, + }; + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(validate_credentials(req)); + assert!(matches!(result, Err(ApiError::BadRequest(_)))); + } +} diff --git a/src/services/kanban_sync.rs b/src/services/kanban_sync.rs index 96eeb11..b025cfc 100644 --- a/src/services/kanban_sync.rs +++ b/src/services/kanban_sync.rs @@ -15,8 +15,9 @@ use std::fs; use std::path::Path; use tracing::{debug, info, warn}; -use crate::api::providers::kanban::{get_provider, ExternalIssue, KanbanProvider}; +use crate::api::providers::kanban::{get_provider, ExternalIssue}; use crate::config::{Config, ProjectSyncConfig}; +use crate::issuetypes::kanban_type::KanbanIssueTypeRef; /// A collection that can be synced from a kanban provider #[derive(Debug, Clone)] @@ -25,8 +26,8 @@ pub struct SyncableCollection { pub provider: String, /// Project/team key in the provider pub project_key: String, - /// `IssueTypeCollection` name in Operator - pub collection_name: String, + /// Optional `IssueTypeCollection` name in Operator + pub collection_name: Option, /// User ID to sync issues for pub sync_user_id: String, /// Statuses to sync (empty = default only) @@ -110,6 +111,21 @@ impl KanbanSyncService { } } + // Check all GitHub Projects instances (keyed by owner login) + for github_config in self.config.kanban.github.values() { + if github_config.enabled { + for (project_key, project_config) in &github_config.projects { + collections.push(SyncableCollection { + provider: "github".to_string(), + project_key: project_key.clone(), + collection_name: project_config.collection_name.clone(), + sync_user_id: project_config.sync_user_id.clone(), + sync_statuses: project_config.sync_statuses.clone(), + }); + } + } + } + collections } @@ -164,7 +180,12 @@ impl KanbanSyncService { continue; } - match self.create_ticket_from_issue(&issue, provider_name, project_key) { + let type_mappings = if project_config.type_mappings.is_empty() { + None + } else { + Some(&project_config.type_mappings) + }; + match self.create_ticket_from_issue(&issue, provider_name, project_key, type_mappings) { Ok(filename) => { info!("Created ticket: {}", filename); result.created.push(issue.key.clone()); @@ -289,6 +310,7 @@ impl KanbanSyncService { issue: &ExternalIssue, provider: &str, project_key: &str, + type_mappings: Option<&std::collections::HashMap>, ) -> Result { let queue_path = Path::new(&self.config.paths.tickets).join("queue"); fs::create_dir_all(&queue_path)?; @@ -296,11 +318,19 @@ impl KanbanSyncService { // Generate filename: YYYYMMDD-HHMM-TYPE-PROJECT-summary.md let now = Local::now(); let timestamp = now.format("%Y%m%d-%H%M").to_string(); - let ticket_type = map_issue_type_to_operator(&issue.issue_type); + // Resolve operator type from kanban issue type refs via type_mappings, + // falling back to TASK with needs_issuetype_mapping flag + let (ticket_type, needs_mapping) = + resolve_ticket_type(&issue.kanban_issue_types, type_mappings); let slug = slugify(&issue.summary, 50); let filename = format!("{timestamp}-{ticket_type}-{project_key}-{slug}.md"); // Build frontmatter + let needs_mapping_line = if needs_mapping { + "\nneeds_issuetype_mapping: true" + } else { + "" + }; let frontmatter = format!( r"--- id: {}-{} @@ -309,7 +339,7 @@ priority: {} step: plan external_id: {} external_url: {} -external_provider: {} +external_provider: {}{} ---", ticket_type, issue.key.replace('-', ""), @@ -317,6 +347,7 @@ external_provider: {} issue.key, issue.url, provider, + needs_mapping_line, ); // Build content @@ -365,15 +396,35 @@ fn extract_external_id(content: &str) -> Option { None } -/// Map external issue type to Operator type -fn map_issue_type_to_operator(issue_type: &str) -> &'static str { - match issue_type.to_lowercase().as_str() { - "bug" | "fix" | "defect" => "FIX", - "feature" | "story" | "user story" | "enhancement" => "FEAT", - "spike" | "research" | "investigation" => "SPIKE", - "task" | "sub-task" | "subtask" => "TASK", - _ => "TASK", // Default to TASK for unknown types +/// Resolve operator issuetype from kanban issue type refs. +/// +/// Uses `type_mappings` (keyed by provider type ID) to resolve. +/// For Linear: sorts labels by name, picks first mapped label. +/// Returns `(operator_key, needs_mapping)` -- if unmapped, returns `("TASK", true)`. +fn resolve_ticket_type( + kanban_refs: &[KanbanIssueTypeRef], + type_mappings: Option<&std::collections::HashMap>, +) -> (&'static str, bool) { + if let Some(mappings) = type_mappings { + // Sort refs by name for deterministic resolution (important for Linear labels) + let mut sorted_refs: Vec<_> = kanban_refs.iter().collect(); + sorted_refs.sort_by(|a, b| a.name.cmp(&b.name)); + + for r in &sorted_refs { + if let Some(operator_key) = mappings.get(&r.id) { + return (leak_string(operator_key), false); + } + } } + + // No mapping found -- fallback to TASK with mapping nudge + ("TASK", true) +} + +/// Leak a string to get a `&'static str`. +/// Used for dynamic operator keys from `type_mappings`. +fn leak_string(s: &str) -> &'static str { + Box::leak(s.to_string().into_boxed_str()) } /// Map external priority to Operator priority @@ -418,14 +469,73 @@ mod tests { use super::*; #[test] - fn test_map_issue_type_to_operator() { - assert_eq!(map_issue_type_to_operator("Bug"), "FIX"); - assert_eq!(map_issue_type_to_operator("bug"), "FIX"); - assert_eq!(map_issue_type_to_operator("Feature"), "FEAT"); - assert_eq!(map_issue_type_to_operator("Story"), "FEAT"); - assert_eq!(map_issue_type_to_operator("Spike"), "SPIKE"); - assert_eq!(map_issue_type_to_operator("Task"), "TASK"); - assert_eq!(map_issue_type_to_operator("Unknown"), "TASK"); + fn test_resolve_ticket_type_with_mapping() { + let mut mappings = std::collections::HashMap::new(); + mappings.insert("10001".to_string(), "FIX".to_string()); + mappings.insert("10002".to_string(), "FEAT".to_string()); + + let refs = vec![KanbanIssueTypeRef { + id: "10001".to_string(), + name: "Bug".to_string(), + }]; + let (key, needs) = resolve_ticket_type(&refs, Some(&mappings)); + assert_eq!(key, "FIX"); + assert!(!needs); + } + + #[test] + fn test_resolve_ticket_type_no_mapping() { + let refs = vec![KanbanIssueTypeRef { + id: "10001".to_string(), + name: "Bug".to_string(), + }]; + let (key, needs) = resolve_ticket_type(&refs, None); + assert_eq!(key, "TASK"); + assert!(needs); + } + + #[test] + fn test_resolve_ticket_type_unmapped_id() { + let mut mappings = std::collections::HashMap::new(); + mappings.insert("10002".to_string(), "FEAT".to_string()); + + let refs = vec![KanbanIssueTypeRef { + id: "10001".to_string(), + name: "Bug".to_string(), + }]; + let (key, needs) = resolve_ticket_type(&refs, Some(&mappings)); + assert_eq!(key, "TASK"); + assert!(needs); + } + + #[test] + fn test_resolve_ticket_type_linear_labels_sorted() { + let mut mappings = std::collections::HashMap::new(); + mappings.insert("label-bug".to_string(), "FIX".to_string()); + mappings.insert("label-feat".to_string(), "FEAT".to_string()); + + // Multiple labels -- should sort by name and pick first mapped + let refs = vec![ + KanbanIssueTypeRef { + id: "label-feat".to_string(), + name: "Feature".to_string(), + }, + KanbanIssueTypeRef { + id: "label-bug".to_string(), + name: "Bug".to_string(), + }, + ]; + // Sorted by name: Bug < Feature, so Bug matches first -> FIX + let (key, needs) = resolve_ticket_type(&refs, Some(&mappings)); + assert_eq!(key, "FIX"); + assert!(!needs); + } + + #[test] + fn test_resolve_ticket_type_empty_refs() { + let (key, needs) = resolve_ticket_type(&[], None); + assert_eq!(key, "TASK"); + assert!(needs); } #[test] diff --git a/src/services/mod.rs b/src/services/mod.rs index 61ce0cb..baa8b1f 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -5,6 +5,8 @@ #![allow(unused_imports)] // Re-exports for future integration +pub mod kanban_issuetype_service; +pub mod kanban_onboarding; pub mod kanban_sync; pub mod pr_monitor; diff --git a/src/ui/dialogs/kanban_onboarding.rs b/src/ui/dialogs/kanban_onboarding.rs new file mode 100644 index 0000000..8e2ec4b --- /dev/null +++ b/src/ui/dialogs/kanban_onboarding.rs @@ -0,0 +1,1169 @@ +//! Kanban onboarding dialog for the TUI. +//! +//! Multi-state wizard: pick provider → collect creds → validate → pick +//! project → write config + set session env + sync issue types → show +//! shell export nudge. All async work (`validate_credentials` / +//! `list_projects` / `write_config` / sync) is dispatched by the `App` +//! event loop calling `services::kanban_onboarding` directly — this +//! dialog is purely UI state + rendering + key handling. + +use crossterm::event::KeyCode; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}, + Frame, +}; + +use super::centered_rect; + +/// Provider selection in the dialog. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KanbanOnboardingProvider { + Jira, + Linear, +} + +/// Multi-state wizard state machine. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum KanbanOnboardingState { + /// Initial state — pick Jira or Linear. + PickProvider, + /// Collecting Jira domain. + JiraDomain, + /// Collecting Jira email. + JiraEmail, + /// Collecting Jira token (masked). + JiraToken, + /// Collecting Linear API key (masked). + LinearApiKey, + /// Calling `validate_credentials` async. + Validating, + /// Showing project picker after validation. + PickProject, + /// Calling `write_config` + `set_session_env` + `sync_issue_types` async. + Writing, + /// Showing the shell export nudge after success. + EnvExportNudge, + /// Inline error — user can press Enter to retry from the relevant input. + Error, +} + +/// Action emitted by the dialog after a key press; the App handles async +/// dispatch and updates the dialog via the setters below. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum KanbanOnboardingAction { + /// No state-machine transition; just a focus/cursor move. + None, + /// User picked a provider — App should advance to the first input step. + PickedProvider(KanbanOnboardingProvider), + /// User submitted full Jira credentials — App should call + /// `services::kanban_onboarding::validate_credentials`. + SubmitJiraCreds { + domain: String, + email: String, + token: String, + }, + /// User submitted Linear API key — App should call + /// `services::kanban_onboarding::validate_credentials`. + SubmitLinearCreds { api_key: String }, + /// User picked a project — App should call `write_config` + + /// `set_session_env` + `sync_issue_types`. + PickedProject { + provider: KanbanOnboardingProvider, + project_key: String, + project_name: String, + }, + /// User pressed C to copy the export block to clipboard. + CopyExportBlock, + /// User dismissed the dialog. + Cancelled, + /// Final dismissal after Done. + Done, +} + +/// A project entry shown in the picker. Mirrors `dto::KanbanProjectInfo` +/// but lives here so the dialog has no `rest::dto` dependency. +#[derive(Debug, Clone)] +pub struct KanbanOnboardingProject { + /// Provider-specific opaque ID. Currently unused by the dialog itself + /// (we display key + name) but kept on the type because it's part of + /// the data contract surfaced to App-side handlers. + #[allow(dead_code)] + pub id: String, + pub key: String, + pub name: String, +} + +pub struct KanbanOnboardingDialog { + pub visible: bool, + pub state: KanbanOnboardingState, + pub provider: KanbanOnboardingProvider, + /// Picker selection on the `PickProvider` step. + provider_index: usize, + + // Input buffers (separate per field — we don't share across steps) + domain_buf: String, + email_buf: String, + token_buf: String, + api_key_buf: String, + cursor_position: usize, + + // Validation results — populated by App after validate_credentials + pub jira_account_id: String, + pub jira_display_name: String, + pub linear_user_id: String, + pub linear_user_name: String, + pub linear_org_name: String, + + // Project picker + projects: Vec, + project_list_state: ListState, + + // Result of write_config + set_session_env + export_block: String, + success_message: String, + + // Inline error message (shown in Error state) + error_message: String, +} + +impl Default for KanbanOnboardingDialog { + fn default() -> Self { + Self::new() + } +} + +impl KanbanOnboardingDialog { + pub fn new() -> Self { + Self { + visible: false, + state: KanbanOnboardingState::PickProvider, + provider: KanbanOnboardingProvider::Jira, + provider_index: 0, + domain_buf: String::new(), + email_buf: String::new(), + token_buf: String::new(), + api_key_buf: String::new(), + cursor_position: 0, + jira_account_id: String::new(), + jira_display_name: String::new(), + linear_user_id: String::new(), + linear_user_name: String::new(), + linear_org_name: String::new(), + projects: Vec::new(), + project_list_state: ListState::default(), + export_block: String::new(), + success_message: String::new(), + error_message: String::new(), + } + } + + pub fn show(&mut self) { + self.visible = true; + self.state = KanbanOnboardingState::PickProvider; + self.provider_index = 0; + self.domain_buf.clear(); + self.email_buf.clear(); + self.token_buf.clear(); + self.api_key_buf.clear(); + self.cursor_position = 0; + self.jira_account_id.clear(); + self.jira_display_name.clear(); + self.linear_user_id.clear(); + self.linear_user_name.clear(); + self.linear_org_name.clear(); + self.projects.clear(); + self.project_list_state.select(None); + self.export_block.clear(); + self.success_message.clear(); + self.error_message.clear(); + } + + pub fn hide(&mut self) { + self.visible = false; + // Wipe sensitive fields + self.domain_buf.clear(); + self.email_buf.clear(); + self.token_buf.clear(); + self.api_key_buf.clear(); + } + + // ─── Setters called by App after async work ───────────────────────── + + pub fn set_validation_jira(&mut self, account_id: String, display_name: String) { + self.jira_account_id = account_id; + self.jira_display_name = display_name; + } + + pub fn set_validation_linear(&mut self, user_id: String, user_name: String, org_name: String) { + self.linear_user_id = user_id; + self.linear_user_name = user_name; + self.linear_org_name = org_name; + } + + pub fn set_projects(&mut self, projects: Vec) { + self.projects = projects; + if !self.projects.is_empty() { + self.project_list_state.select(Some(0)); + } + self.state = KanbanOnboardingState::PickProject; + } + + pub fn set_error(&mut self, msg: String) { + self.error_message = msg; + self.state = KanbanOnboardingState::Error; + } + + pub fn set_success(&mut self, success_message: String, export_block: String) { + self.success_message = success_message; + self.export_block = export_block; + self.state = KanbanOnboardingState::EnvExportNudge; + } + + /// Get the shell export block to display. Used by tests and may be used + /// by future clipboard integration. + #[allow(dead_code)] + pub fn export_block(&self) -> &str { + &self.export_block + } + + // ─── Input helpers ─────────────────────────────────────────────────── + + fn current_buf(&self) -> &str { + match self.state { + KanbanOnboardingState::JiraDomain => &self.domain_buf, + KanbanOnboardingState::JiraEmail => &self.email_buf, + KanbanOnboardingState::JiraToken => &self.token_buf, + KanbanOnboardingState::LinearApiKey => &self.api_key_buf, + _ => "", + } + } + + fn current_buf_mut(&mut self) -> Option<&mut String> { + match self.state { + KanbanOnboardingState::JiraDomain => Some(&mut self.domain_buf), + KanbanOnboardingState::JiraEmail => Some(&mut self.email_buf), + KanbanOnboardingState::JiraToken => Some(&mut self.token_buf), + KanbanOnboardingState::LinearApiKey => Some(&mut self.api_key_buf), + _ => None, + } + } + + fn input_label(&self) -> &'static str { + match self.state { + KanbanOnboardingState::JiraDomain => "Jira domain (e.g. acme.atlassian.net)", + KanbanOnboardingState::JiraEmail => "Jira email", + KanbanOnboardingState::JiraToken => "Jira API token", + KanbanOnboardingState::LinearApiKey => "Linear API key (lin_api_…)", + _ => "", + } + } + + fn is_password_step(&self) -> bool { + matches!( + self.state, + KanbanOnboardingState::JiraToken | KanbanOnboardingState::LinearApiKey + ) + } + + fn validate_current_input(&self) -> Result<(), &'static str> { + match self.state { + KanbanOnboardingState::JiraDomain => { + if self.domain_buf.is_empty() { + Err("Domain is required") + } else if !self.domain_buf.ends_with(".atlassian.net") { + Err("Must end in .atlassian.net") + } else { + Ok(()) + } + } + KanbanOnboardingState::JiraEmail => { + if self.email_buf.is_empty() { + Err("Email is required") + } else if !self.email_buf.contains('@') || !self.email_buf.contains('.') { + Err("Enter a valid email") + } else { + Ok(()) + } + } + KanbanOnboardingState::JiraToken => { + if self.token_buf.is_empty() { + Err("API token is required") + } else { + Ok(()) + } + } + KanbanOnboardingState::LinearApiKey => { + if self.api_key_buf.is_empty() { + Err("API key is required") + } else if !self.api_key_buf.starts_with("lin_api_") { + Err("Linear API keys start with \"lin_api_\"") + } else { + Ok(()) + } + } + _ => Ok(()), + } + } + + // ─── Key handling ──────────────────────────────────────────────────── + + pub fn handle_key(&mut self, key: KeyCode) -> KanbanOnboardingAction { + // Block input during async ops + if matches!( + self.state, + KanbanOnboardingState::Validating | KanbanOnboardingState::Writing + ) { + return KanbanOnboardingAction::None; + } + + match self.state { + KanbanOnboardingState::PickProvider => self.handle_pick_provider_key(key), + KanbanOnboardingState::JiraDomain + | KanbanOnboardingState::JiraEmail + | KanbanOnboardingState::JiraToken + | KanbanOnboardingState::LinearApiKey => self.handle_input_key(key), + KanbanOnboardingState::PickProject => self.handle_pick_project_key(key), + KanbanOnboardingState::EnvExportNudge => self.handle_nudge_key(key), + KanbanOnboardingState::Error => self.handle_error_key(key), + KanbanOnboardingState::Validating | KanbanOnboardingState::Writing => { + KanbanOnboardingAction::None + } + } + } + + fn handle_pick_provider_key(&mut self, key: KeyCode) -> KanbanOnboardingAction { + match key { + KeyCode::Up | KeyCode::Char('k') => { + if self.provider_index > 0 { + self.provider_index -= 1; + } + KanbanOnboardingAction::None + } + KeyCode::Down | KeyCode::Char('j') => { + if self.provider_index < 1 { + self.provider_index += 1; + } + KanbanOnboardingAction::None + } + KeyCode::Enter => { + self.provider = if self.provider_index == 0 { + KanbanOnboardingProvider::Jira + } else { + KanbanOnboardingProvider::Linear + }; + self.state = match self.provider { + KanbanOnboardingProvider::Jira => KanbanOnboardingState::JiraDomain, + KanbanOnboardingProvider::Linear => KanbanOnboardingState::LinearApiKey, + }; + self.cursor_position = 0; + self.error_message.clear(); + KanbanOnboardingAction::PickedProvider(self.provider) + } + KeyCode::Esc => { + self.hide(); + KanbanOnboardingAction::Cancelled + } + _ => KanbanOnboardingAction::None, + } + } + + fn handle_input_key(&mut self, key: KeyCode) -> KanbanOnboardingAction { + match key { + KeyCode::Char(c) => { + let pos = self.cursor_position; + let inserted = if let Some(buf) = self.current_buf_mut() { + if pos <= buf.len() { + buf.insert(pos, c); + true + } else { + false + } + } else { + false + }; + if inserted { + self.cursor_position += 1; + } + self.error_message.clear(); + KanbanOnboardingAction::None + } + KeyCode::Backspace => { + if self.cursor_position > 0 { + self.cursor_position -= 1; + let pos = self.cursor_position; + if let Some(buf) = self.current_buf_mut() { + if pos < buf.len() { + buf.remove(pos); + } + } + } + self.error_message.clear(); + KanbanOnboardingAction::None + } + KeyCode::Left => { + if self.cursor_position > 0 { + self.cursor_position -= 1; + } + KanbanOnboardingAction::None + } + KeyCode::Right => { + let len = self.current_buf().len(); + if self.cursor_position < len { + self.cursor_position += 1; + } + KanbanOnboardingAction::None + } + KeyCode::Enter => { + if let Err(msg) = self.validate_current_input() { + self.error_message = msg.to_string(); + return KanbanOnboardingAction::None; + } + // Advance to next step + match self.state { + KanbanOnboardingState::JiraDomain => { + self.state = KanbanOnboardingState::JiraEmail; + self.cursor_position = self.email_buf.len(); + KanbanOnboardingAction::None + } + KanbanOnboardingState::JiraEmail => { + self.state = KanbanOnboardingState::JiraToken; + self.cursor_position = self.token_buf.len(); + KanbanOnboardingAction::None + } + KanbanOnboardingState::JiraToken => { + // Submit creds — App will dispatch validate + self.state = KanbanOnboardingState::Validating; + KanbanOnboardingAction::SubmitJiraCreds { + domain: self.domain_buf.clone(), + email: self.email_buf.clone(), + token: self.token_buf.clone(), + } + } + KanbanOnboardingState::LinearApiKey => { + self.state = KanbanOnboardingState::Validating; + KanbanOnboardingAction::SubmitLinearCreds { + api_key: self.api_key_buf.clone(), + } + } + _ => KanbanOnboardingAction::None, + } + } + KeyCode::Esc => { + // Go back one step + self.error_message.clear(); + match self.state { + KanbanOnboardingState::JiraDomain => { + self.state = KanbanOnboardingState::PickProvider; + } + KanbanOnboardingState::JiraEmail => { + self.state = KanbanOnboardingState::JiraDomain; + self.cursor_position = self.domain_buf.len(); + } + KanbanOnboardingState::JiraToken => { + self.state = KanbanOnboardingState::JiraEmail; + self.cursor_position = self.email_buf.len(); + } + KanbanOnboardingState::LinearApiKey => { + self.state = KanbanOnboardingState::PickProvider; + } + _ => {} + } + KanbanOnboardingAction::None + } + _ => KanbanOnboardingAction::None, + } + } + + fn handle_pick_project_key(&mut self, key: KeyCode) -> KanbanOnboardingAction { + match key { + KeyCode::Up | KeyCode::Char('k') => { + let cur = self.project_list_state.selected().unwrap_or(0); + if cur > 0 { + self.project_list_state.select(Some(cur - 1)); + } + KanbanOnboardingAction::None + } + KeyCode::Down | KeyCode::Char('j') => { + let cur = self.project_list_state.selected().unwrap_or(0); + if cur + 1 < self.projects.len() { + self.project_list_state.select(Some(cur + 1)); + } + KanbanOnboardingAction::None + } + KeyCode::Enter => { + let idx = self.project_list_state.selected().unwrap_or(0); + if let Some(p) = self.projects.get(idx) { + self.state = KanbanOnboardingState::Writing; + KanbanOnboardingAction::PickedProject { + provider: self.provider, + project_key: p.key.clone(), + project_name: p.name.clone(), + } + } else { + KanbanOnboardingAction::None + } + } + KeyCode::Esc => { + self.hide(); + KanbanOnboardingAction::Cancelled + } + _ => KanbanOnboardingAction::None, + } + } + + fn handle_nudge_key(&mut self, key: KeyCode) -> KanbanOnboardingAction { + match key { + KeyCode::Char('c' | 'C') => KanbanOnboardingAction::CopyExportBlock, + KeyCode::Enter | KeyCode::Esc => { + self.hide(); + KanbanOnboardingAction::Done + } + _ => KanbanOnboardingAction::None, + } + } + + fn handle_error_key(&mut self, key: KeyCode) -> KanbanOnboardingAction { + match key { + KeyCode::Enter => { + // Retry from the relevant input step + self.error_message.clear(); + self.state = match self.provider { + KanbanOnboardingProvider::Jira => KanbanOnboardingState::JiraToken, + KanbanOnboardingProvider::Linear => KanbanOnboardingState::LinearApiKey, + }; + KanbanOnboardingAction::None + } + KeyCode::Esc => { + self.hide(); + KanbanOnboardingAction::Cancelled + } + _ => KanbanOnboardingAction::None, + } + } + + // ─── Rendering ────────────────────────────────────────────────────── + + pub fn render(&mut self, frame: &mut Frame) { + if !self.visible { + return; + } + + let area = centered_rect(70, 80, frame.area()); + frame.render_widget(Clear, area); + + let title = match self.provider { + KanbanOnboardingProvider::Jira => " Onboard: Jira Cloud ", + KanbanOnboardingProvider::Linear => " Onboard: Linear ", + }; + let title = if matches!(self.state, KanbanOnboardingState::PickProvider) { + " Connect Kanban Provider " + } else { + title + }; + + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + match self.state { + KanbanOnboardingState::PickProvider => self.render_pick_provider(frame, inner), + KanbanOnboardingState::JiraDomain + | KanbanOnboardingState::JiraEmail + | KanbanOnboardingState::JiraToken + | KanbanOnboardingState::LinearApiKey => self.render_input(frame, inner), + KanbanOnboardingState::Validating => { + self.render_progress(frame, inner, "Validating credentials..."); + } + KanbanOnboardingState::PickProject => self.render_pick_project(frame, inner), + KanbanOnboardingState::Writing => { + self.render_progress(frame, inner, "Writing config + syncing issue types..."); + } + KanbanOnboardingState::EnvExportNudge => self.render_nudge(frame, inner), + KanbanOnboardingState::Error => self.render_error(frame, inner), + } + } + + fn render_pick_provider(&self, frame: &mut Frame, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(2), // Prompt + Constraint::Length(1), // Spacer + Constraint::Min(4), // Options + Constraint::Length(2), // Footer + ]) + .split(area); + + let prompt = Paragraph::new("Which kanban provider do you use?") + .style(Style::default().fg(Color::White)) + .alignment(Alignment::Center); + frame.render_widget(prompt, chunks[0]); + + let options: Vec = vec![ + self.option_line("Jira Cloud", "Connect with API token", 0), + self.option_line("Linear", "Connect with API key", 1), + ]; + let opts_widget = Paragraph::new(options).alignment(Alignment::Center); + frame.render_widget(opts_widget, chunks[2]); + + let footer = Line::from(vec![ + Span::styled("[↑/↓]", Style::default().fg(Color::Yellow)), + Span::raw(" Select "), + Span::styled("[Enter]", Style::default().fg(Color::Yellow)), + Span::raw(" Confirm "), + Span::styled("[Esc]", Style::default().fg(Color::Yellow)), + Span::raw(" Cancel"), + ]); + frame.render_widget( + Paragraph::new(footer).alignment(Alignment::Center), + chunks[3], + ); + } + + fn option_line(&self, label: &str, desc: &str, index: usize) -> Line<'static> { + let selected = index == self.provider_index; + let marker = if selected { "▶ " } else { " " }; + let style = if selected { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Gray) + }; + Line::from(vec![ + Span::styled(marker.to_string(), style), + Span::styled(label.to_string(), style), + Span::raw(" "), + Span::styled(format!("({desc})"), Style::default().fg(Color::DarkGray)), + ]) + } + + fn render_input(&self, frame: &mut Frame, area: Rect) { + let has_error = !self.error_message.is_empty(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints(if has_error { + vec![ + Constraint::Length(2), // Label + Constraint::Length(3), // Input + Constraint::Length(2), // Error + Constraint::Min(0), // Spacer + Constraint::Length(2), // Footer + ] + } else { + vec![ + Constraint::Length(2), + Constraint::Length(3), + Constraint::Min(0), + Constraint::Length(2), + Constraint::Length(0), + ] + }) + .split(area); + + let label = Paragraph::new(Line::from(vec![Span::styled( + self.input_label().to_string(), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )])); + frame.render_widget(label, chunks[0]); + + let display: String = if self.is_password_step() { + "•".repeat(self.current_buf().len()) + } else { + self.current_buf().to_string() + }; + let input = Paragraph::new(display) + .block(Block::default().borders(Borders::ALL).border_style( + Style::default().fg(if has_error { Color::Red } else { Color::Cyan }), + )) + .wrap(Wrap { trim: false }); + frame.render_widget(input, chunks[1]); + + // Cursor + let input_inner = Block::default().borders(Borders::ALL).inner(chunks[1]); + frame.set_cursor_position((input_inner.x + self.cursor_position as u16, input_inner.y)); + + if has_error { + let err = Paragraph::new(Line::from(vec![Span::styled( + self.error_message.clone(), + Style::default().fg(Color::Red), + )])); + frame.render_widget(err, chunks[2]); + } + + let footer_idx = if has_error { 4 } else { 3 }; + let footer = Line::from(vec![ + Span::styled("[Enter]", Style::default().fg(Color::Yellow)), + Span::raw(" Next "), + Span::styled("[Esc]", Style::default().fg(Color::Yellow)), + Span::raw(" Back"), + ]); + frame.render_widget( + Paragraph::new(footer).alignment(Alignment::Center), + chunks[footer_idx], + ); + } + + fn render_progress(&self, frame: &mut Frame, area: Rect, message: &str) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Min(2), // Spacer + Constraint::Length(2), + Constraint::Min(2), // Spacer + ]) + .split(area); + let p = Paragraph::new(Line::from(vec![Span::styled( + message.to_string(), + Style::default().fg(Color::Yellow), + )])) + .alignment(Alignment::Center); + frame.render_widget(p, chunks[1]); + } + + fn render_pick_project(&mut self, frame: &mut Frame, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(2), // Header + Constraint::Min(5), // List + Constraint::Length(2), // Footer + ]) + .split(area); + + let auth_msg = match self.provider { + KanbanOnboardingProvider::Jira => format!( + "Authenticated as {} ({})", + self.jira_display_name, self.jira_account_id + ), + KanbanOnboardingProvider::Linear => format!( + "Authenticated as {} in {}", + self.linear_user_name, self.linear_org_name + ), + }; + let header = Paragraph::new(Line::from(vec![ + Span::raw("✓ "), + Span::styled(auth_msg, Style::default().fg(Color::Green)), + ])); + frame.render_widget(header, chunks[0]); + + let items: Vec = self + .projects + .iter() + .map(|p| { + ListItem::new(Line::from(vec![ + Span::styled( + format!("{:8}", p.key), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" — "), + Span::styled(p.name.clone(), Style::default().fg(Color::White)), + ])) + }) + .collect(); + + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Projects ") + .border_style(Style::default().fg(Color::DarkGray)), + ) + .highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▶ "); + + frame.render_stateful_widget(list, chunks[1], &mut self.project_list_state); + + let footer = Line::from(vec![ + Span::styled("[↑/↓]", Style::default().fg(Color::Yellow)), + Span::raw(" Select "), + Span::styled("[Enter]", Style::default().fg(Color::Yellow)), + Span::raw(" Confirm "), + Span::styled("[Esc]", Style::default().fg(Color::Yellow)), + Span::raw(" Cancel"), + ]); + frame.render_widget( + Paragraph::new(footer).alignment(Alignment::Center), + chunks[2], + ); + } + + fn render_nudge(&self, frame: &mut Frame, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(2), // Success + Constraint::Length(2), // Instructions + Constraint::Length(3), // Export block + Constraint::Min(0), + Constraint::Length(2), // Footer + ]) + .split(area); + + let success = Paragraph::new(Line::from(vec![ + Span::raw("✓ "), + Span::styled( + self.success_message.clone(), + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + ])); + frame.render_widget(success, chunks[0]); + + let instructions = Paragraph::new( + "Add this to your shell profile (~/.zshrc or ~/.bashrc) for persistence:", + ) + .style(Style::default().fg(Color::Gray)); + frame.render_widget(instructions, chunks[1]); + + let export = Paragraph::new(self.export_block.clone()) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)), + ) + .style(Style::default().fg(Color::White)); + frame.render_widget(export, chunks[2]); + + let footer = Line::from(vec![ + Span::styled("[C]", Style::default().fg(Color::Yellow)), + Span::raw(" Copy "), + Span::styled("[Enter]", Style::default().fg(Color::Yellow)), + Span::raw(" Done"), + ]); + frame.render_widget( + Paragraph::new(footer).alignment(Alignment::Center), + chunks[4], + ); + } + + fn render_error(&self, frame: &mut Frame, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(2), + Constraint::Min(2), + Constraint::Length(2), + ]) + .split(area); + + let header = Paragraph::new(Line::from(vec![ + Span::raw("✗ "), + Span::styled( + "Error", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + ])); + frame.render_widget(header, chunks[0]); + + let body = Paragraph::new(self.error_message.clone()) + .style(Style::default().fg(Color::Red)) + .wrap(Wrap { trim: false }); + frame.render_widget(body, chunks[1]); + + let footer = Line::from(vec![ + Span::styled("[Enter]", Style::default().fg(Color::Yellow)), + Span::raw(" Retry "), + Span::styled("[Esc]", Style::default().fg(Color::Yellow)), + Span::raw(" Cancel"), + ]); + frame.render_widget( + Paragraph::new(footer).alignment(Alignment::Center), + chunks[2], + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dialog_starts_hidden() { + let dialog = KanbanOnboardingDialog::new(); + assert!(!dialog.visible); + assert_eq!(dialog.state, KanbanOnboardingState::PickProvider); + } + + #[test] + fn test_show_resets_state() { + let mut dialog = KanbanOnboardingDialog::new(); + dialog.domain_buf = "stale".to_string(); + dialog.show(); + assert!(dialog.visible); + assert!(dialog.domain_buf.is_empty()); + assert_eq!(dialog.state, KanbanOnboardingState::PickProvider); + } + + #[test] + fn test_pick_jira_advances_to_jira_domain() { + let mut dialog = KanbanOnboardingDialog::new(); + dialog.show(); + let action = dialog.handle_key(KeyCode::Enter); + assert_eq!( + action, + KanbanOnboardingAction::PickedProvider(KanbanOnboardingProvider::Jira) + ); + assert_eq!(dialog.state, KanbanOnboardingState::JiraDomain); + } + + #[test] + fn test_pick_linear_advances_to_api_key() { + let mut dialog = KanbanOnboardingDialog::new(); + dialog.show(); + dialog.handle_key(KeyCode::Down); + let action = dialog.handle_key(KeyCode::Enter); + assert_eq!( + action, + KanbanOnboardingAction::PickedProvider(KanbanOnboardingProvider::Linear) + ); + assert_eq!(dialog.state, KanbanOnboardingState::LinearApiKey); + } + + #[test] + fn test_jira_domain_validation_rejects_non_atlassian() { + let mut dialog = KanbanOnboardingDialog::new(); + dialog.show(); + dialog.handle_key(KeyCode::Enter); // pick Jira + for c in "notjira.example.com".chars() { + dialog.handle_key(KeyCode::Char(c)); + } + let action = dialog.handle_key(KeyCode::Enter); + assert_eq!(action, KanbanOnboardingAction::None); + assert_eq!(dialog.state, KanbanOnboardingState::JiraDomain); + assert!(!dialog.error_message.is_empty()); + } + + #[test] + fn test_jira_full_flow_to_validating() { + let mut dialog = KanbanOnboardingDialog::new(); + dialog.show(); + dialog.handle_key(KeyCode::Enter); // pick Jira + + // Domain + for c in "acme.atlassian.net".chars() { + dialog.handle_key(KeyCode::Char(c)); + } + dialog.handle_key(KeyCode::Enter); + assert_eq!(dialog.state, KanbanOnboardingState::JiraEmail); + + // Email + for c in "u@acme.com".chars() { + dialog.handle_key(KeyCode::Char(c)); + } + dialog.handle_key(KeyCode::Enter); + assert_eq!(dialog.state, KanbanOnboardingState::JiraToken); + + // Token + for c in "secret-token".chars() { + dialog.handle_key(KeyCode::Char(c)); + } + let action = dialog.handle_key(KeyCode::Enter); + match action { + KanbanOnboardingAction::SubmitJiraCreds { + domain, + email, + token, + } => { + assert_eq!(domain, "acme.atlassian.net"); + assert_eq!(email, "u@acme.com"); + assert_eq!(token, "secret-token"); + } + other => panic!("expected SubmitJiraCreds, got {other:?}"), + } + assert_eq!(dialog.state, KanbanOnboardingState::Validating); + } + + #[test] + fn test_linear_validation_rejects_wrong_prefix() { + let mut dialog = KanbanOnboardingDialog::new(); + dialog.show(); + dialog.handle_key(KeyCode::Down); + dialog.handle_key(KeyCode::Enter); // pick Linear + + for c in "wrong_prefix_xxx".chars() { + dialog.handle_key(KeyCode::Char(c)); + } + let action = dialog.handle_key(KeyCode::Enter); + assert_eq!(action, KanbanOnboardingAction::None); + assert_eq!(dialog.state, KanbanOnboardingState::LinearApiKey); + assert!(dialog.error_message.contains("lin_api_")); + } + + #[test] + fn test_linear_full_flow_to_validating() { + let mut dialog = KanbanOnboardingDialog::new(); + dialog.show(); + dialog.handle_key(KeyCode::Down); + dialog.handle_key(KeyCode::Enter); // pick Linear + + for c in "lin_api_realtoken".chars() { + dialog.handle_key(KeyCode::Char(c)); + } + let action = dialog.handle_key(KeyCode::Enter); + match action { + KanbanOnboardingAction::SubmitLinearCreds { api_key } => { + assert_eq!(api_key, "lin_api_realtoken"); + } + other => panic!("expected SubmitLinearCreds, got {other:?}"), + } + assert_eq!(dialog.state, KanbanOnboardingState::Validating); + } + + #[test] + fn test_set_projects_advances_to_pick_project() { + let mut dialog = KanbanOnboardingDialog::new(); + dialog.show(); + dialog.state = KanbanOnboardingState::Validating; + dialog.set_projects(vec![KanbanOnboardingProject { + id: "1".to_string(), + key: "PROJ".to_string(), + name: "My Project".to_string(), + }]); + assert_eq!(dialog.state, KanbanOnboardingState::PickProject); + assert_eq!(dialog.project_list_state.selected(), Some(0)); + } + + #[test] + fn test_pick_project_emits_action_with_project_key() { + let mut dialog = KanbanOnboardingDialog::new(); + dialog.show(); + dialog.handle_key(KeyCode::Enter); // pick Jira + dialog.provider = KanbanOnboardingProvider::Jira; + dialog.set_projects(vec![ + KanbanOnboardingProject { + id: "1".to_string(), + key: "PROJ".to_string(), + name: "First".to_string(), + }, + KanbanOnboardingProject { + id: "2".to_string(), + key: "OTHER".to_string(), + name: "Second".to_string(), + }, + ]); + // Move to second + dialog.handle_key(KeyCode::Down); + let action = dialog.handle_key(KeyCode::Enter); + match action { + KanbanOnboardingAction::PickedProject { + provider, + project_key, + project_name, + } => { + assert_eq!(provider, KanbanOnboardingProvider::Jira); + assert_eq!(project_key, "OTHER"); + assert_eq!(project_name, "Second"); + } + other => panic!("expected PickedProject, got {other:?}"), + } + assert_eq!(dialog.state, KanbanOnboardingState::Writing); + } + + #[test] + fn test_set_error_transitions_to_error_state() { + let mut dialog = KanbanOnboardingDialog::new(); + dialog.show(); + dialog.set_error("Invalid credentials".to_string()); + assert_eq!(dialog.state, KanbanOnboardingState::Error); + assert_eq!(dialog.error_message, "Invalid credentials"); + } + + #[test] + fn test_error_retry_returns_to_token_step_for_jira() { + let mut dialog = KanbanOnboardingDialog::new(); + dialog.show(); + dialog.provider = KanbanOnboardingProvider::Jira; + dialog.set_error("Auth failed".to_string()); + dialog.handle_key(KeyCode::Enter); + assert_eq!(dialog.state, KanbanOnboardingState::JiraToken); + assert!(dialog.error_message.is_empty()); + } + + #[test] + fn test_set_success_transitions_to_nudge() { + let mut dialog = KanbanOnboardingDialog::new(); + dialog.show(); + dialog.set_success( + "Jira configured!".to_string(), + "export OPERATOR_JIRA_API_KEY=\"\"".to_string(), + ); + assert_eq!(dialog.state, KanbanOnboardingState::EnvExportNudge); + assert!(dialog.export_block().contains("OPERATOR_JIRA_API_KEY")); + } + + #[test] + fn test_nudge_copy_emits_action() { + let mut dialog = KanbanOnboardingDialog::new(); + dialog.show(); + dialog.set_success("ok".to_string(), "export FOO=bar".to_string()); + let action = dialog.handle_key(KeyCode::Char('c')); + assert_eq!(action, KanbanOnboardingAction::CopyExportBlock); + } + + #[test] + fn test_nudge_enter_dismisses() { + let mut dialog = KanbanOnboardingDialog::new(); + dialog.show(); + dialog.set_success("ok".to_string(), "export FOO=bar".to_string()); + let action = dialog.handle_key(KeyCode::Enter); + assert_eq!(action, KanbanOnboardingAction::Done); + assert!(!dialog.visible); + } + + #[test] + fn test_validating_state_blocks_input() { + let mut dialog = KanbanOnboardingDialog::new(); + dialog.show(); + dialog.state = KanbanOnboardingState::Validating; + let action = dialog.handle_key(KeyCode::Char('x')); + assert_eq!(action, KanbanOnboardingAction::None); + // State unchanged + assert_eq!(dialog.state, KanbanOnboardingState::Validating); + } + + #[test] + fn test_backspace_removes_char_from_buffer() { + let mut dialog = KanbanOnboardingDialog::new(); + dialog.show(); + dialog.handle_key(KeyCode::Enter); // pick Jira + for c in "abc".chars() { + dialog.handle_key(KeyCode::Char(c)); + } + dialog.handle_key(KeyCode::Backspace); + assert_eq!(dialog.domain_buf, "ab"); + assert_eq!(dialog.cursor_position, 2); + } + + #[test] + fn test_esc_from_input_goes_back_one_step() { + let mut dialog = KanbanOnboardingDialog::new(); + dialog.show(); + dialog.handle_key(KeyCode::Enter); // pick Jira + // Now in JiraDomain. Esc goes back to PickProvider. + dialog.handle_key(KeyCode::Esc); + assert_eq!(dialog.state, KanbanOnboardingState::PickProvider); + } +} diff --git a/src/ui/dialogs/mod.rs b/src/ui/dialogs/mod.rs index 093ed4f..19b902f 100644 --- a/src/ui/dialogs/mod.rs +++ b/src/ui/dialogs/mod.rs @@ -1,6 +1,7 @@ mod confirm; mod git_token; mod help; +mod kanban_onboarding; mod rejection; mod session_recovery; mod sync_confirm; @@ -10,6 +11,10 @@ pub use confirm::{ }; pub use git_token::GitTokenDialog; pub use help::HelpDialog; +pub use kanban_onboarding::{ + KanbanOnboardingAction, KanbanOnboardingDialog, KanbanOnboardingProject, + KanbanOnboardingProvider, KanbanOnboardingState, +}; pub use rejection::{RejectionDialog, RejectionResult}; pub use session_recovery::{SessionRecoveryDialog, SessionRecoverySelection}; pub use sync_confirm::{SyncConfirmDialog, SyncConfirmResult, SyncableCollectionDisplay}; diff --git a/src/ui/dialogs/sync_confirm.rs b/src/ui/dialogs/sync_confirm.rs index efe7d6c..e17076f 100644 --- a/src/ui/dialogs/sync_confirm.rs +++ b/src/ui/dialogs/sync_confirm.rs @@ -15,7 +15,7 @@ use super::centered_rect; pub struct SyncableCollectionDisplay { pub provider: String, pub project_key: String, - pub collection_name: String, + pub collection_name: Option, pub status_count: usize, } @@ -241,7 +241,10 @@ impl SyncConfirmDialog { ), Span::styled("→ ", Style::default().fg(Color::DarkGray)), Span::styled( - &collection.collection_name, + collection + .collection_name + .as_deref() + .unwrap_or("(unmapped)"), Style::default().fg(Color::Cyan), ), Span::styled(status_suffix, Style::default().fg(Color::DarkGray)), @@ -297,7 +300,7 @@ mod tests { let collections = vec![crate::services::SyncableCollection { provider: "jira".to_string(), project_key: "PROJ".to_string(), - collection_name: "jira-proj".to_string(), + collection_name: Some("jira-proj".to_string()), sync_user_id: "user123".to_string(), sync_statuses: vec!["To Do".to_string()], }]; diff --git a/src/ui/kanban_view.rs b/src/ui/kanban_view.rs index 07a85b1..9a1faf6 100644 --- a/src/ui/kanban_view.rs +++ b/src/ui/kanban_view.rs @@ -18,8 +18,8 @@ pub struct KanbanCollectionInfo { pub provider: String, /// Project/team key pub project_key: String, - /// Collection name in Operator - pub collection_name: String, + /// Optional collection name in Operator + pub collection_name: Option, /// User ID configured for sync (will be displayed when sync UI is expanded) #[allow(dead_code)] pub sync_user_id: String, @@ -47,6 +47,8 @@ pub enum KanbanViewResult { provider: String, project_key: String, }, + /// User requested to add a new kanban provider via the onboarding wizard. + AddProvider, /// User dismissed the view Dismissed, } @@ -175,6 +177,11 @@ impl KanbanView { project_key: collection.project_key.clone(), }) } + KeyCode::Char('a' | 'A') => { + // Add a new provider via the onboarding wizard + self.hide(); + Some(KanbanViewResult::AddProvider) + } KeyCode::Esc | KeyCode::Char('q') => { self.hide(); Some(KanbanViewResult::Dismissed) @@ -277,7 +284,13 @@ impl KanbanView { // Collection name let collection_name = Span::styled( - format!(" → {} ", collection.collection_name), + format!( + " → {} ", + collection + .collection_name + .as_deref() + .unwrap_or("(unmapped)") + ), Style::default().fg(Color::DarkGray), ); @@ -324,6 +337,8 @@ impl KanbanView { vec![ Span::styled("[S]", Style::default().fg(Color::Cyan)), Span::raw("ync "), + Span::styled("[A]", Style::default().fg(Color::Cyan)), + Span::raw("dd provider "), Span::styled("[↑/↓]", Style::default().fg(Color::Cyan)), Span::raw("Navigate "), Span::styled("[Esc]", Style::default().fg(Color::Cyan)), @@ -357,7 +372,7 @@ mod tests { let collections = vec![SyncableCollection { provider: "jira".to_string(), project_key: "PROJ".to_string(), - collection_name: "jira-proj".to_string(), + collection_name: Some("jira-proj".to_string()), sync_user_id: "user123".to_string(), sync_statuses: vec!["To Do".to_string()], }]; @@ -379,14 +394,14 @@ mod tests { SyncableCollection { provider: "jira".to_string(), project_key: "PROJ1".to_string(), - collection_name: "jira-proj1".to_string(), + collection_name: Some("jira-proj1".to_string()), sync_user_id: "user1".to_string(), sync_statuses: vec![], }, SyncableCollection { provider: "linear".to_string(), project_key: "ENG".to_string(), - collection_name: "linear-eng".to_string(), + collection_name: Some("linear-eng".to_string()), sync_user_id: "user2".to_string(), sync_statuses: vec![], }, @@ -417,7 +432,7 @@ mod tests { let collections = vec![SyncableCollection { provider: "jira".to_string(), project_key: "PROJ".to_string(), - collection_name: "jira-proj".to_string(), + collection_name: Some("jira-proj".to_string()), sync_user_id: "user123".to_string(), sync_statuses: vec![], }]; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 8f346cd..20d1569 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -21,9 +21,10 @@ pub mod terminal_suspend; pub use collection_dialog::{CollectionInfo, CollectionSwitchDialog, CollectionSwitchResult}; pub use dashboard::Dashboard; pub use dialogs::{ - ConfirmDialog, ConfirmDialogFocus, ConfirmSelection, GitTokenDialog, RejectionDialog, - RejectionResult, SelectedOption, SessionRecoveryDialog, SessionRecoverySelection, - SyncConfirmDialog, SyncConfirmResult, + ConfirmDialog, ConfirmDialogFocus, ConfirmSelection, GitTokenDialog, KanbanOnboardingAction, + KanbanOnboardingDialog, KanbanOnboardingProject, KanbanOnboardingProvider, + KanbanOnboardingState, RejectionDialog, RejectionResult, SelectedOption, SessionRecoveryDialog, + SessionRecoverySelection, SyncConfirmDialog, SyncConfirmResult, }; pub use kanban_view::{KanbanView, KanbanViewResult}; pub use paginated_list::{render_paginated_list, PaginatedList}; diff --git a/src/ui/setup/steps/kanban.rs b/src/ui/setup/steps/kanban.rs index d260a88..b6435ff 100644 --- a/src/ui/setup/steps/kanban.rs +++ b/src/ui/setup/steps/kanban.rs @@ -32,7 +32,7 @@ impl SetupScreen { Constraint::Length(2), // Description Constraint::Length(1), // Spacer Constraint::Length(3), // Supported providers header - Constraint::Length(4), // Supported providers list + Constraint::Length(5), // Supported providers list (3 providers) Constraint::Length(1), // Spacer Constraint::Length(2), // Detected header Constraint::Min(6), // Detected providers list @@ -88,6 +88,16 @@ impl SetupScreen { ), Span::raw(")"), ]), + Line::from(vec![ + Span::raw(" • "), + Span::styled("GitHub Projects", Style::default().fg(Color::White)), + Span::raw(" ("), + Span::styled( + "OPERATOR_GITHUB_TOKEN", + Style::default().fg(Color::DarkGray), + ), + Span::raw(")"), + ]), ]); frame.render_widget(supported, chunks[4]); @@ -118,6 +128,7 @@ impl SetupScreen { let provider_name = match provider.provider_type { KanbanProviderType::Jira => "Jira", KanbanProviderType::Linear => "Linear", + KanbanProviderType::Github => "GitHub", }; let status_text = match &provider.status { @@ -194,6 +205,7 @@ impl SetupScreen { let provider_name = match p.provider_type { KanbanProviderType::Jira => "Jira", KanbanProviderType::Linear => "Linear", + KanbanProviderType::Github => "GitHub", }; format!(" Setup: {} - {} ", provider_name, p.domain) } else { diff --git a/tests/kanban_integration.rs b/tests/kanban_integration.rs index a718b31..b08e81b 100644 --- a/tests/kanban_integration.rs +++ b/tests/kanban_integration.rs @@ -1,4 +1,4 @@ -//! Integration tests for Kanban providers (Jira and Linear) +//! Integration tests for Kanban providers (Jira, Linear, and GitHub Projects) //! //! These tests require real API credentials and test workspaces. //! They are skipped when credentials are not available. @@ -15,6 +15,18 @@ //! - `OPERATOR_LINEAR_API_KEY`: Linear API key //! - `OPERATOR_LINEAR_TEST_TEAM`: Test team ID (UUID) //! +//! ### For GitHub Projects: +//! - `OPERATOR_GITHUB_TOKEN`: PAT with `project` (or `read:project`) scope. +//! MUST be distinct from `GITHUB_TOKEN` used for PR workflows — the kanban +//! provider deliberately does not fall back. See +//! `docs/getting-started/kanban/github.md`. +//! - `OPERATOR_GITHUB_TEST_PROJECT`: `ProjectV2` `GraphQL` node ID +//! (starts with `PVT_`). Fetch via: +//! `gh api graphql -f query='query { viewer { projectsV2(first: 20) { nodes { id number title } } } }'`. +//! The project must have a Status single-select field with at least one +//! terminal option (Done/Complete/Closed/Resolved) — default GitHub +//! project templates satisfy this. +//! //! ## Running Tests //! //! ```bash @@ -26,10 +38,23 @@ //! //! # Linear tests only //! cargo test --test kanban_integration linear_tests -- --nocapture --test-threads=1 +//! +//! # GitHub tests only +//! cargo test --test kanban_integration github_tests -- --nocapture --test-threads=1 //! ``` +//! +//! ## A note on GitHub test drafts +//! +//! `GithubProjectsProvider::create_issue` (v1) produces draft issues via +//! `AddProjectV2DraftIssueInput`. The provider does not expose item +//! deletion, so test drafts are moved to a terminal status (Done) for +//! cleanup but remain in the project. Periodically filter the test project +//! by the `[OPTEST]` prefix and archive manually, or point +//! `OPERATOR_GITHUB_TEST_PROJECT` at a dedicated throwaway project. use operator::api::providers::kanban::{ - CreateIssueRequest, JiraProvider, KanbanProvider, LinearProvider, UpdateStatusRequest, + CreateIssueRequest, GithubProjectsProvider, JiraProvider, KanbanProvider, LinearProvider, + UpdateStatusRequest, }; use std::env; use tokio::sync::OnceCell; @@ -37,6 +62,7 @@ use tokio::sync::OnceCell; // Cached credential validation results static JIRA_CREDENTIALS_VALID: OnceCell = OnceCell::const_new(); static LINEAR_CREDENTIALS_VALID: OnceCell = OnceCell::const_new(); +static GITHUB_CREDENTIALS_VALID: OnceCell = OnceCell::const_new(); // ─── Configuration Helpers ─────────────────────────────────────────────────── @@ -66,6 +92,21 @@ fn linear_configured() -> bool { .unwrap_or(false) } +/// Check if GitHub Projects credentials are configured (non-empty env vars). +/// +/// Only `OPERATOR_GITHUB_TOKEN` is consulted — the provider deliberately does +/// NOT fall back to `GITHUB_TOKEN` (which is reserved for the git/PR provider +/// and typically lacks the `project` scope). See +/// `src/api/providers/kanban/github_projects.rs` module docs. +fn github_configured() -> bool { + env::var("OPERATOR_GITHUB_TOKEN") + .map(|s| !s.is_empty()) + .unwrap_or(false) + && env::var("OPERATOR_GITHUB_TEST_PROJECT") + .map(|s| !s.is_empty()) + .unwrap_or(false) +} + /// Get the Jira test project key fn jira_test_project() -> String { env::var("OPERATOR_JIRA_TEST_PROJECT").expect("OPERATOR_JIRA_TEST_PROJECT required") @@ -76,6 +117,11 @@ fn linear_test_team() -> String { env::var("OPERATOR_LINEAR_TEST_TEAM").expect("OPERATOR_LINEAR_TEST_TEAM required") } +/// Get the GitHub test project node ID (e.g. `PVT_kwDOABC123`) +fn github_test_project() -> String { + env::var("OPERATOR_GITHUB_TEST_PROJECT").expect("OPERATOR_GITHUB_TEST_PROJECT required") +} + /// Generate a unique test issue title with [OPTEST] prefix fn test_issue_title(suffix: &str) -> String { let uuid = std::time::SystemTime::now() @@ -163,6 +209,39 @@ async fn linear_credentials_valid() -> bool { .await } +/// Validate GitHub Projects credentials by testing the connection. +/// Result is cached for the duration of the test run. +async fn github_credentials_valid() -> bool { + if !github_configured() { + return false; + } + + *GITHUB_CREDENTIALS_VALID + .get_or_init(|| async { + match GithubProjectsProvider::from_env() { + Ok(provider) => match provider.test_connection().await { + Ok(valid) => { + if !valid { + eprintln!( + "GitHub credentials validation failed: connection test returned false" + ); + } + valid + } + Err(e) => { + eprintln!("GitHub credentials validation failed: {e}"); + false + } + }, + Err(e) => { + eprintln!("GitHub provider initialization failed: {e}"); + false + } + } + }) + .await +} + /// Macro to skip test if provider is not configured or credentials are invalid macro_rules! skip_if_not_configured { ($configured:expr, $valid:expr, $provider:expr) => { @@ -677,15 +756,321 @@ mod linear_tests { } } +// ─── GitHub Projects Integration Tests ─────────────────────────────────────── + +mod github_tests { + use super::*; + + fn get_provider() -> GithubProjectsProvider { + GithubProjectsProvider::from_env().expect("GitHub provider should be configured") + } + + #[tokio::test] + async fn test_connection() { + skip_if_not_configured!(github_configured(), github_credentials_valid(), "GitHub"); + let provider = get_provider(); + + let result = provider.test_connection().await; + assert!(result.is_ok(), "Connection test failed: {result:?}"); + assert!(result.unwrap(), "Connection should be valid"); + } + + #[tokio::test] + async fn test_list_projects() { + skip_if_not_configured!(github_configured(), github_credentials_valid(), "GitHub"); + let provider = get_provider(); + + let projects = provider + .list_projects() + .await + .expect("Should list projects"); + assert!(!projects.is_empty(), "Should have at least one project"); + + // GitHub populates both id and key with the ProjectV2 node_id + // (src/api/providers/kanban/github_projects.rs list_projects). + let test_project = github_test_project(); + assert!( + projects.iter().any(|p| p.key == test_project), + "Test project {} should exist in {:?}", + test_project, + projects + .iter() + .map(|p| (&p.key, &p.name)) + .collect::>() + ); + } + + #[tokio::test] + async fn test_list_users() { + skip_if_not_configured!(github_configured(), github_credentials_valid(), "GitHub"); + let provider = get_provider(); + let project = github_test_project(); + + // GitHub derives users from assignees on existing project items + // (list_users scans items). A fresh test project with only draft + // issues may legitimately return an empty list — unlike Jira/Linear + // where team members/assignable users are a separate endpoint. + let users = provider + .list_users(&project) + .await + .expect("Should list users (may be empty for fresh project)"); + + eprintln!("Found {} GitHub project assignees", users.len()); + + for user in &users { + assert!(!user.id.is_empty(), "User should have ID"); + assert!(!user.name.is_empty(), "User should have name"); + } + } + + #[tokio::test] + async fn test_list_statuses() { + skip_if_not_configured!(github_configured(), github_credentials_valid(), "GitHub"); + let provider = get_provider(); + let project = github_test_project(); + + let statuses = provider + .list_statuses(&project) + .await + .expect("Should list statuses"); + + // A configured Status field is a hard prerequisite — the create/ + // update_status tests below depend on it. Fail loudly if missing. + assert!( + !statuses.is_empty(), + "Test project must have a Status single-select field with at \ + least one option. See docs/getting-started/kanban/github.md." + ); + + eprintln!("Available GitHub statuses: {statuses:?}"); + } + + #[tokio::test] + async fn test_get_issue_types() { + skip_if_not_configured!(github_configured(), github_credentials_valid(), "GitHub"); + let provider = get_provider(); + let project = github_test_project(); + + // May be empty: only orgs with issue types enabled return a + // non-empty list, otherwise the provider falls back to aggregated + // labels from linked repos, which may also be empty. + let types = provider + .get_issue_types(&project) + .await + .expect("Should get issue types / labels"); + + eprintln!( + "Available GitHub issue types/labels: {:?}", + types.iter().map(|t| &t.name).collect::>() + ); + } + + #[tokio::test] + async fn test_list_issues() { + skip_if_not_configured!(github_configured(), github_credentials_valid(), "GitHub"); + let provider = get_provider(); + let project = github_test_project(); + + let users = provider + .list_users(&project) + .await + .expect("Should list users"); + if users.is_empty() { + eprintln!("No project assignees, skipping list_issues test"); + return; + } + + let user_id = &users[0].id; + let issues = provider + .list_issues(&project, user_id, &[]) + .await + .expect("Should list issues"); + + eprintln!("Found {} issues for user {}", issues.len(), users[0].name); + + for issue in &issues { + assert!(!issue.id.is_empty(), "Issue should have ID"); + assert!(!issue.key.is_empty(), "Issue should have key"); + } + } + + #[tokio::test] + async fn test_create_issue() { + skip_if_not_configured!(github_configured(), github_credentials_valid(), "GitHub"); + let provider = get_provider(); + let project = github_test_project(); + + let request = CreateIssueRequest { + summary: test_issue_title("GitHub Create Test"), + description: Some("Created by integration test - safe to archive".to_string()), + assignee_id: None, + status: None, + priority: None, + }; + + let response = provider + .create_issue(&project, request) + .await + .expect("Should create draft issue"); + + eprintln!("Created GitHub issue: {}", response.issue.key); + + // v1 creates draft issues only (AddProjectV2DraftIssueInput). + // The resulting key is formatted `draft:{project_item_id}`. + assert!( + response.issue.key.starts_with("draft:"), + "v1 should create draft issues with 'draft:' key prefix, got: {}", + response.issue.key + ); + assert!( + response.issue.summary.contains("[OPTEST]"), + "Issue should have OPTEST prefix" + ); + + // ─── Cleanup: Move draft to terminal status ──────────────────────────────── + // The provider exposes no deletion API; we move to Done so the test + // project remains visually sane. Drafts still accumulate — see file + // doc comment. + let statuses = provider + .list_statuses(&project) + .await + .expect("Should get statuses for cleanup"); + + if let Some(done_status) = find_terminal_status(&statuses) { + eprintln!( + "Cleanup: Moving draft {} to {}", + response.issue.key, done_status + ); + let _ = provider + .update_issue_status( + &response.issue.key, + UpdateStatusRequest { + status: done_status, + }, + ) + .await; + } + } + + #[tokio::test] + async fn test_update_issue_status() { + skip_if_not_configured!(github_configured(), github_credentials_valid(), "GitHub"); + let provider = get_provider(); + let project = github_test_project(); + + // First create a draft issue. create_issue populates the item_lookup + // cache directly, so we don't need a list_issues call before the + // update (github_projects.rs create_issue). + let request = CreateIssueRequest { + summary: test_issue_title("GitHub Status Test"), + description: Some("Testing status transition".to_string()), + assignee_id: None, + status: None, + priority: None, + }; + + let created = provider + .create_issue(&project, request) + .await + .expect("Should create draft issue"); + + // GitHub create_issue returns status="" — the draft does not yet + // have a Status field value assigned. That's fine for this test. + eprintln!( + "Created draft {} (initial status: {:?})", + created.issue.key, created.issue.status + ); + + // Get available statuses. + let statuses = provider + .list_statuses(&project) + .await + .expect("Should get statuses"); + + // Find terminal status for later cleanup. + let terminal_status = find_terminal_status(&statuses); + + // Find a non-terminal status to transition to first. + let target_status = statuses + .iter() + .find(|s| { + terminal_status + .as_ref() + .is_none_or(|t| !s.eq_ignore_ascii_case(t)) + }) + .cloned(); + + if let Some(target) = target_status { + eprintln!("Transitioning to: {target}"); + + let update_request = UpdateStatusRequest { + status: target.clone(), + }; + + // update_issue_status returns a minimal ExternalIssue — only + // id/key/status are populated (github_projects.rs:1404-1415), + // so we only assert on status here. + let updated = provider + .update_issue_status(&created.issue.key, update_request) + .await + .expect("Should update status"); + + eprintln!("New status: {}", updated.status); + assert_eq!( + updated.status.to_lowercase(), + target.to_lowercase(), + "Status should be updated" + ); + } else { + eprintln!("No non-terminal status available for transition test"); + } + + // ─── Cleanup: Move draft to terminal status (Done) ───────────────────────── + if let Some(done_status) = terminal_status { + eprintln!("Cleanup: Transitioning draft to terminal status: {done_status}"); + + let done_request = UpdateStatusRequest { + status: done_status.clone(), + }; + + match provider + .update_issue_status(&created.issue.key, done_request) + .await + { + Ok(final_issue) => { + eprintln!( + "Draft {} moved to terminal status: {}", + final_issue.key, final_issue.status + ); + assert_eq!( + final_issue.status.to_lowercase(), + done_status.to_lowercase(), + "Draft should be in terminal status" + ); + } + Err(e) => { + eprintln!( + "Warning: Could not move draft to terminal status '{done_status}': {e}" + ); + // Don't fail the test - cleanup is best-effort + } + } + } else { + eprintln!("Warning: No terminal status found in available statuses: {statuses:?}"); + } + } +} + // ─── Cross-Provider Tests ──────────────────────────────────────────────────── #[tokio::test] async fn test_provider_interface_consistency() { - // This test verifies both providers implement the same interface + // This test verifies all three providers implement the same interface let jira_ok = jira_configured() && jira_credentials_valid().await; let linear_ok = linear_configured() && linear_credentials_valid().await; + let github_ok = github_configured() && github_credentials_valid().await; - if !jira_ok && !linear_ok { + if !jira_ok && !linear_ok && !github_ok { eprintln!("Skipping: No providers configured or credentials invalid"); return; } @@ -703,4 +1088,11 @@ async fn test_provider_interface_consistency() { assert!(provider.is_configured()); eprintln!("Linear provider: configured and ready"); } + + if github_ok { + let provider = GithubProjectsProvider::from_env().unwrap(); + assert_eq!(provider.name(), "github"); + assert!(provider.is_configured()); + eprintln!("GitHub provider: configured and ready"); + } } diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 1166c6b..94040f1 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -514,7 +514,7 @@ "pretest": "npm run compile && npm run lint", "lint": "eslint src --ext ts && eslint webview-ui --ext ts,tsx", "test": "vscode-test", - "test:coverage": "c8 --reporter=lcov --reporter=text --report-dir=coverage vscode-test", + "test:coverage": "node out/test/runTest.js", "package": "vsce package", "publish": "vsce publish", "generate:icons": "fantasticon" diff --git a/vscode-extension/src/api-client.ts b/vscode-extension/src/api-client.ts index 9d33ac2..751d760 100644 --- a/vscode-extension/src/api-client.ts +++ b/vscode-extension/src/api-client.ts @@ -20,6 +20,16 @@ import type { ExternalIssueTypeSummary, CreateIssueTypeRequest, UpdateIssueTypeRequest, + SyncKanbanIssueTypesResponse, + ValidateKanbanCredentialsRequest, + ValidateKanbanCredentialsResponse, + ListKanbanProjectsRequest, + ListKanbanProjectsResponse, + KanbanProjectInfo, + WriteKanbanConfigRequest, + WriteKanbanConfigResponse, + SetKanbanSessionEnvRequest, + SetKanbanSessionEnvResponse, } from './generated'; // Re-export generated types for consumers @@ -32,6 +42,16 @@ export type { ExternalIssueTypeSummary, CreateIssueTypeRequest, UpdateIssueTypeRequest, + SyncKanbanIssueTypesResponse, + ValidateKanbanCredentialsRequest, + ValidateKanbanCredentialsResponse, + ListKanbanProjectsRequest, + ListKanbanProjectsResponse, + KanbanProjectInfo, + WriteKanbanConfigRequest, + WriteKanbanConfigResponse, + SetKanbanSessionEnvRequest, + SetKanbanSessionEnvResponse, }; /** @@ -532,4 +552,138 @@ export class OperatorApiClient { return (await response.json()) as ExternalIssueTypeSummary[]; } + + /** + * Sync kanban issue types from a provider for a project. + * Triggers a fresh fetch from the external provider and persists to the local catalog. + */ + async syncKanbanIssueTypes( + provider: string, + projectKey: string + ): Promise { + const response = await fetch( + `${this.baseUrl}/api/v1/kanban/${encodeURIComponent(provider)}/${encodeURIComponent(projectKey)}/issuetypes/sync`, + { method: 'POST' } + ); + + if (!response.ok) { + const error = (await response.json().catch(() => ({ + error: 'unknown', + message: `HTTP ${response.status}: ${response.statusText}`, + }))) as ApiError; + throw new Error(error.message); + } + + return (await response.json()) as SyncKanbanIssueTypesResponse; + } + + // ─── Kanban Onboarding ──────────────────────────────────────────────── + + /** + * Validate kanban provider credentials against the live provider API. + * + * Auth failures return `valid: false` with `error` set — NOT a thrown + * exception — so callers can display errors inline and offer retry. + * Network / server errors throw. + */ + async validateKanbanCredentials( + req: ValidateKanbanCredentialsRequest + ): Promise { + const response = await fetch(`${this.baseUrl}/api/v1/kanban/validate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(req), + }); + + if (!response.ok) { + const error = (await response.json().catch(() => ({ + error: 'unknown', + message: `HTTP ${response.status}: ${response.statusText}`, + }))) as ApiError; + throw new Error(error.message); + } + + return (await response.json()) as ValidateKanbanCredentialsResponse; + } + + /** + * List available projects/teams from a kanban provider using ephemeral + * credentials. No persistence side effects. + */ + async listKanbanProjects( + req: ListKanbanProjectsRequest + ): Promise { + const response = await fetch(`${this.baseUrl}/api/v1/kanban/projects`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(req), + }); + + if (!response.ok) { + const error = (await response.json().catch(() => ({ + error: 'unknown', + message: `HTTP ${response.status}: ${response.statusText}`, + }))) as ApiError; + throw new Error(error.message); + } + + const body = (await response.json()) as ListKanbanProjectsResponse; + return body.projects; + } + + /** + * Write (upsert) a kanban provider + project section into config.toml. + * + * Does NOT receive the actual secret — only the env var name + * (`api_key_env`). The secret is set via `setKanbanSessionEnv`. + */ + async writeKanbanConfig( + req: WriteKanbanConfigRequest + ): Promise { + const response = await fetch(`${this.baseUrl}/api/v1/kanban/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(req), + }); + + if (!response.ok) { + const error = (await response.json().catch(() => ({ + error: 'unknown', + message: `HTTP ${response.status}: ${response.statusText}`, + }))) as ApiError; + throw new Error(error.message); + } + + return (await response.json()) as WriteKanbanConfigResponse; + } + + /** + * Set kanban env vars on the server process for the current session + * so subsequent sync calls find the API key. + * + * The returned `shell_export_block` uses `` placeholders, + * not the real secret — safe to display to the user. + */ + async setKanbanSessionEnv( + req: SetKanbanSessionEnvRequest + ): Promise { + const response = await fetch( + `${this.baseUrl}/api/v1/kanban/session-env`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(req), + } + ); + + if (!response.ok) { + const error = (await response.json().catch(() => ({ + error: 'unknown', + message: `HTTP ${response.status}: ${response.statusText}`, + }))) as ApiError; + throw new Error(error.message); + } + + return (await response.json()) as SetKanbanSessionEnvResponse; + } } diff --git a/vscode-extension/src/config-panel.ts b/vscode-extension/src/config-panel.ts index 20b8909..a7a7b5e 100644 --- a/vscode-extension/src/config-panel.ts +++ b/vscode-extension/src/config-panel.ts @@ -12,11 +12,6 @@ import * as path from 'path'; async function importSmolToml() { return await import('smol-toml'); } -import { - validateJiraCredentials, - fetchJiraProjects, - validateLinearCredentials, -} from './kanban-onboarding'; import { detectInstalledLlmTools } from './walkthrough'; import { getConfigDir, @@ -180,28 +175,61 @@ export class ConfigPanel { } case 'validateJira': { - const result = await validateJiraCredentials( - message.domain as string, - message.email as string, - message.apiToken as string - ); - + // Delegate credential validation to the Operator REST API. + const workDir = resolveWorkingDirectory(); + const ticketsDir = workDir ? path.join(workDir, '.tickets') : undefined; + const apiUrl = await discoverApiUrl(ticketsDir); + const client = new OperatorApiClient(apiUrl); + + let valid = false; + let displayName = ''; + let accountId = ''; + let errorMsg: string | undefined; let projects: Array<{ key: string; name: string }> = []; - if (result.valid) { - projects = await fetchJiraProjects( - message.domain as string, - message.email as string, - message.apiToken as string - ); + + try { + const result = await client.validateKanbanCredentials({ + provider: 'jira', + jira: { + domain: message.domain as string, + email: message.email as string, + api_token: message.apiToken as string, + }, + linear: null, + github: null, + }); + valid = result.valid; + if (result.jira) { + displayName = result.jira.display_name; + accountId = result.jira.account_id; + } + errorMsg = result.error ?? undefined; + + if (valid) { + const projs = await client.listKanbanProjects({ + provider: 'jira', + jira: { + domain: message.domain as string, + email: message.email as string, + api_token: message.apiToken as string, + }, + linear: null, + github: null, + }); + projects = projs.map((p) => ({ key: p.key, name: p.name })); + } + } catch (err) { + valid = false; + errorMsg = err instanceof Error ? err.message : 'Unknown error'; } void this._panel.webview.postMessage({ type: 'jiraValidationResult', result: { - valid: result.valid, - displayName: result.displayName, - accountId: result.accountId, - error: result.error, + valid, + displayName, + accountId, + error: errorMsg, projects, }, }); @@ -209,19 +237,51 @@ export class ConfigPanel { } case 'validateLinear': { - const result = await validateLinearCredentials( - message.apiKey as string - ); + const workDir = resolveWorkingDirectory(); + const ticketsDir = workDir ? path.join(workDir, '.tickets') : undefined; + const apiUrl = await discoverApiUrl(ticketsDir); + const client = new OperatorApiClient(apiUrl); + + let valid = false; + let userName = ''; + let orgName = ''; + let userId = ''; + let teams: Array<{ id: string; name: string; key: string }> = []; + let errorMsg: string | undefined; + + try { + const result = await client.validateKanbanCredentials({ + provider: 'linear', + jira: null, + linear: { api_key: message.apiKey as string }, + github: null, + }); + valid = result.valid; + if (result.linear) { + userName = result.linear.user_name; + orgName = result.linear.org_name; + userId = result.linear.user_id; + teams = result.linear.teams.map((t) => ({ + id: t.id, + name: t.name, + key: t.key, + })); + } + errorMsg = result.error ?? undefined; + } catch (err) { + valid = false; + errorMsg = err instanceof Error ? err.message : 'Unknown error'; + } void this._panel.webview.postMessage({ type: 'linearValidationResult', result: { - valid: result.valid, - userName: result.userName, - orgName: result.orgName, - userId: result.userId, - error: result.error, - teams: result.teams, + valid, + userName, + orgName, + userId, + error: errorMsg, + teams, }, }); break; @@ -652,7 +712,7 @@ async function writeConfigField( delete projects[oldKeys[0]]; projects[value as string] = oldProject; } else { - projects[value as string] = { sync_user_id: '', collection_name: 'dev_kanban' }; + projects[value as string] = { sync_user_id: '' }; } } else if (key === 'sync_statuses' || key === 'collection_name' || key === 'sync_user_id' || key === 'type_mappings') { // Write to the first project sub-table @@ -670,7 +730,7 @@ async function writeConfigField( const field = parts.slice(2).join('.'); if (!ws.projects) { ws.projects = {}; } const projects = ws.projects as TomlConfig; - if (!projects[pKey]) { projects[pKey] = { sync_user_id: '', collection_name: 'dev_kanban' }; } + if (!projects[pKey]) { projects[pKey] = { sync_user_id: '' }; } (projects[pKey] as TomlConfig)[field] = value; } } else { @@ -711,7 +771,7 @@ async function writeConfigField( const field = parts.slice(2).join('.'); if (!ws.projects) { ws.projects = {}; } const projects = ws.projects as TomlConfig; - if (!projects[pKey]) { projects[pKey] = { sync_user_id: '', collection_name: '' }; } + if (!projects[pKey]) { projects[pKey] = { sync_user_id: '' }; } (projects[pKey] as TomlConfig)[field] = value; } } else { diff --git a/vscode-extension/src/git-onboarding.ts b/vscode-extension/src/git-onboarding.ts index df1ed7f..0c32155 100644 --- a/vscode-extension/src/git-onboarding.ts +++ b/vscode-extension/src/git-onboarding.ts @@ -178,9 +178,7 @@ export async function onboardGitHub(): Promise { `GitHub connected as ${user.login}! Config written to ${getResolvedConfigPath()}` ); - await showEnvVarInstructions([ - `export GITHUB_TOKEN=""`, - ]); + await showEnvVarInstructions(`export GITHUB_TOKEN=""`); } /** @@ -281,9 +279,7 @@ export async function onboardGitLab(): Promise { `GitLab connected as ${user.username}! Config written to ${getResolvedConfigPath()}` ); - await showEnvVarInstructions([ - `export GITLAB_TOKEN=""`, - ]); + await showEnvVarInstructions(`export GITLAB_TOKEN=""`); } /** diff --git a/vscode-extension/src/kanban-onboarding.ts b/vscode-extension/src/kanban-onboarding.ts index 6cd0c0f..12c6393 100644 --- a/vscode-extension/src/kanban-onboarding.ts +++ b/vscode-extension/src/kanban-onboarding.ts @@ -2,295 +2,68 @@ * Interactive Kanban Onboarding for Operator VS Code Extension * * Provides multi-step QuickPick/InputBox flows for configuring - * Jira Cloud and Linear kanban integrations. Validates credentials - * against live APIs, writes TOML config, and sets env vars for - * the current session. + * Jira Cloud and Linear kanban integrations. All credential validation, + * project fetching, TOML config writing, and env var setting is + * delegated to the Operator REST API — this file is UI-only. */ import * as vscode from 'vscode'; -import * as fs from 'fs/promises'; +import * as path from 'path'; import { updateWalkthroughContext } from './walkthrough'; -import { getConfigDir, getResolvedConfigPath, resolveWorkingDirectory } from './config-paths'; - -// smol-toml is ESM-only, must use dynamic import -async function importSmolToml() { - return await import('smol-toml'); -} - -/** Linear GraphQL API URL */ -const LINEAR_API_URL = 'https://api.linear.app/graphql'; - -// ─── TOML Config Utilities ───────────────────────────────────────────── +import { resolveWorkingDirectory } from './config-paths'; +import { + OperatorApiClient, + discoverApiUrl, + type KanbanProjectInfo, +} from './api-client'; /** - * Generate TOML config section for a Jira workspace + project + * Build an API client pointed at the local Operator server, honoring + * the session file if present. */ -export function generateJiraToml( - domain: string, - email: string, - apiKeyEnv: string, - projectKey: string, - accountId: string -): string { - return [ - `[kanban.jira."${domain}"]`, - `enabled = true`, - `email = "${email}"`, - `api_key_env = "${apiKeyEnv}"`, - ``, - `[kanban.jira."${domain}".projects.${projectKey}]`, - `sync_user_id = "${accountId}"`, - `collection_name = "dev_kanban"`, - ``, - ].join('\n'); -} - -/** - * Generate TOML config section for a Linear team + project - */ -export function generateLinearToml( - teamId: string, - apiKeyEnv: string, - userId: string -): string { - return [ - `[kanban.linear."${teamId}"]`, - `enabled = true`, - `api_key_env = "${apiKeyEnv}"`, - ``, - `[kanban.linear."${teamId}".projects.default]`, - `sync_user_id = "${userId}"`, - `collection_name = "dev_kanban"`, - ``, - ].join('\n'); -} - -/** - * Read config.toml, append or replace a kanban section, write back. - * - * If the section header already exists, prompts user to confirm replacement. - * Returns true if written successfully. - */ -export async function writeKanbanConfig(section: string): Promise { - try { - const configDir = getConfigDir(resolveWorkingDirectory()); - await fs.mkdir(configDir, { recursive: true }); - } catch { - // directory may already exist - } - - const configPath = getResolvedConfigPath(); - let existing = ''; - try { - existing = await fs.readFile(configPath, 'utf-8'); - } catch { - // file doesn't exist yet, start fresh - } - - // Extract the section header (first line) to check for duplicates - const headerLine = section.split('\n')[0]!; - if (existing.includes(headerLine)) { - const replace = await vscode.window.showWarningMessage( - `Config already contains ${headerLine}. Replace it?`, - 'Replace', - 'Cancel' - ); - if (replace !== 'Replace') { - return false; - } - - // Remove old section: from header line to next top-level section or EOF - const headerEscaped = headerLine.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const sectionRegex = new RegExp( - `${headerEscaped}[\\s\\S]*?(?=\\n\\[(?!kanban\\.)|\n*$)`, - 'm' - ); - existing = existing.replace(sectionRegex, ''); - } - - // Ensure trailing newline before appending - const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n\n' : '\n'; - const newContent = existing.length > 0 ? existing.trimEnd() + separator + section : section; - - await fs.writeFile(configPath, newContent, 'utf-8'); - return true; -} - -// ─── API Validation ──────────────────────────────────────────────────── - -export interface JiraValidationResult { - valid: boolean; - accountId: string; - displayName: string; - error?: string; -} - -export interface JiraProject { - key: string; - name: string; -} - -export interface LinearTeam { - id: string; - name: string; - key: string; -} - -export interface LinearValidationResult { - valid: boolean; - userId: string; - userName: string; - orgName: string; - teams: LinearTeam[]; - error?: string; +async function buildClient(): Promise { + const workDir = resolveWorkingDirectory(); + const ticketsDir = workDir ? path.join(workDir, '.tickets') : undefined; + const apiUrl = await discoverApiUrl(ticketsDir); + return new OperatorApiClient(apiUrl); } /** - * Validate Jira credentials by calling GET /rest/api/3/myself + * After onboarding, sync kanban issue types from the provider and nudge + * the user to configure mappings. Non-fatal -- degrades gracefully if + * the Operator API is not running. */ -export async function validateJiraCredentials( - domain: string, - email: string, - apiToken: string -): Promise { - const auth = Buffer.from(`${email}:${apiToken}`).toString('base64'); +async function syncAndNudgeIssueTypes( + provider: 'jira' | 'linear' | 'github', + projectKey: string, + displayName: string +): Promise { try { - const response = await fetch(`https://${domain}/rest/api/3/myself`, { - headers: { - Authorization: `Basic ${auth}`, - Accept: 'application/json', - }, - }); - - if (!response.ok) { - const status = response.status; - if (status === 401) { - return { valid: false, accountId: '', displayName: '', error: 'Invalid credentials (401). Check email and API token.' }; - } - if (status === 403) { - return { valid: false, accountId: '', displayName: '', error: 'Access forbidden (403). Token may lack permissions.' }; - } - return { valid: false, accountId: '', displayName: '', error: `Jira API error: ${status}` }; - } - - const data = (await response.json()) as { - accountId?: string; - displayName?: string; - }; - - if (!data.accountId) { - return { valid: false, accountId: '', displayName: '', error: 'No accountId in response' }; - } + const client = await buildClient(); - return { - valid: true, - accountId: data.accountId, - displayName: data.displayName ?? '', - }; - } catch (err) { - const msg = err instanceof Error ? err.message : 'Unknown error'; - return { valid: false, accountId: '', displayName: '', error: `Connection failed: ${msg}` }; - } -} - -/** - * Fetch Jira projects for QuickPick selection - */ -export async function fetchJiraProjects( - domain: string, - email: string, - apiToken: string -): Promise { - const auth = Buffer.from(`${email}:${apiToken}`).toString('base64'); - try { - const response = await fetch( - `https://${domain}/rest/api/3/project/search?maxResults=50&orderBy=name`, + const result = await vscode.window.withProgress( { - headers: { - Authorization: `Basic ${auth}`, - Accept: 'application/json', - }, - } - ); - - if (!response.ok) { - return []; - } - - const data = (await response.json()) as { - values?: Array<{ key?: string; name?: string }>; - }; - - return (data.values ?? []) - .filter((p): p is { key: string; name: string } => !!p.key && !!p.name) - .map((p) => ({ key: p.key, name: p.name })); - } catch { - return []; - } -} - -/** - * Validate Linear credentials by querying viewer + organization + teams - */ -export async function validateLinearCredentials( - apiKey: string -): Promise { - const query = ` - query { - viewer { id name email } - organization { name urlKey } - teams { nodes { id name key } } - } - `; - - try { - const response = await fetch(LINEAR_API_URL, { - method: 'POST', - headers: { - Authorization: apiKey, - 'Content-Type': 'application/json', + location: vscode.ProgressLocation.Notification, + title: `Syncing ${displayName} issue types...`, + cancellable: false, }, - body: JSON.stringify({ query }), - }); + () => client.syncKanbanIssueTypes(provider, projectKey) + ); - if (!response.ok) { - const status = response.status; - if (status === 401) { - return { valid: false, userId: '', userName: '', orgName: '', teams: [], error: 'Invalid API key (401).' }; + if (result.synced > 0) { + const action = await vscode.window.showInformationMessage( + `Synced ${result.synced} issue type${result.synced === 1 ? '' : 's'} from ${displayName}. Map them to Operator types for better ticket routing.`, + 'Configure Mappings' + ); + if (action === 'Configure Mappings') { + await vscode.commands.executeCommand('operator.openSettings'); } - return { valid: false, userId: '', userName: '', orgName: '', teams: [], error: `Linear API error: ${status}` }; } - - const data = (await response.json()) as { - data?: { - viewer?: { id?: string; name?: string }; - organization?: { name?: string }; - teams?: { nodes?: Array<{ id?: string; name?: string; key?: string }> }; - }; - }; - - const viewer = data?.data?.viewer; - const org = data?.data?.organization; - const teamNodes = data?.data?.teams?.nodes ?? []; - - if (!viewer?.id) { - return { valid: false, userId: '', userName: '', orgName: '', teams: [], error: 'Could not retrieve user info' }; - } - - const teams: LinearTeam[] = teamNodes - .filter((t): t is { id: string; name: string; key: string } => !!t.id && !!t.name && !!t.key) - .map((t) => ({ id: t.id, name: t.name, key: t.key })); - - return { - valid: true, - userId: viewer.id, - userName: viewer.name ?? '', - orgName: org?.name ?? '', - teams, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : 'Unknown error'; - return { valid: false, userId: '', userName: '', orgName: '', teams: [], error: `Connection failed: ${msg}` }; + } catch { + // Non-fatal: Operator API may not be running during initial onboarding + void vscode.window.showWarningMessage( + 'Could not sync issue types automatically. You can sync them later from Settings.' + ); } } @@ -374,11 +147,12 @@ export function showInputBoxWithBack(options: { } /** - * Show info message with copy-to-clipboard action for shell profile env vars + * Show info message with copy-to-clipboard action for shell profile env vars. + * + * `exportBlock` is the multi-line `export FOO=""` string + * returned by the server's `setKanbanSessionEnv` endpoint. */ -export async function showEnvVarInstructions(envLines: string[]): Promise { - const exportBlock = envLines.join('\n'); - +export async function showEnvVarInstructions(exportBlock: string): Promise { const action = await vscode.window.showInformationMessage( 'Add these to your shell profile (~/.zshrc or ~/.bashrc) for persistence across restarts:', 'Copy to Clipboard' @@ -393,19 +167,16 @@ export async function showEnvVarInstructions(envLines: string[]): Promise // ─── Interactive Onboarding Flows ────────────────────────────────────── /** - * Jira Cloud onboarding: domain -> email -> API token -> validate -> pick project -> write config + * Collect Jira credentials via a 3-step InputBox wizard. + * Returns null if the user cancelled. */ -export async function onboardJira( - context: vscode.ExtensionContext -): Promise { - const title = 'Configure Jira Cloud'; +async function collectJiraCreds( + title: string, + initial: { domain: string; email: string; apiToken: string } +): Promise<{ domain: string; email: string; apiToken: string } | null> { + let { domain, email, apiToken } = initial; let step = 1; - // Collect credentials with back navigation - let domain = ''; - let email = ''; - let apiToken = ''; - while (step >= 1 && step <= 3) { if (step === 1) { const result = await showInputBoxWithBack({ @@ -426,7 +197,7 @@ export async function onboardJira( }, }); - if (result === undefined) { return; } + if (result === undefined) { return null; } if (result === 'back') { step--; continue; } domain = result; step = 2; @@ -449,7 +220,7 @@ export async function onboardJira( }, }); - if (result === undefined) { return; } + if (result === undefined) { return null; } if (result === 'back') { step--; continue; } email = result; step = 3; @@ -505,26 +276,58 @@ export async function onboardJira( input.show(); }); - if (result === undefined) { return; } + if (result === undefined) { return null; } if (result === 'back') { step--; continue; } apiToken = result; - step = 4; // proceed to validation + step = 4; // done } } - // Validate credentials - const validation = await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: 'Validating Jira credentials...', - cancellable: false, - }, - () => validateJiraCredentials(domain, email, apiToken) - ); + return { domain, email, apiToken }; +} - if (!validation.valid) { +/** + * Jira Cloud onboarding: collect creds -> validate via API -> set session env -> + * list projects via API -> pick one -> write config via API -> sync issuetypes. + */ +export async function onboardJira( + context: vscode.ExtensionContext +): Promise { + const title = 'Configure Jira Cloud'; + const client = await buildClient(); + + const creds = await collectJiraCreds(title, { domain: '', email: '', apiToken: '' }); + if (!creds) { return; } + + // Validate credentials via the Operator API + let validation; + try { + validation = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Validating Jira credentials...', + cancellable: false, + }, + () => client.validateKanbanCredentials({ + provider: 'jira', + jira: { + domain: creds.domain, + email: creds.email, + api_token: creds.apiToken, + }, + linear: null, + github: null, + }) + ); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + void vscode.window.showErrorMessage(`Could not reach Operator API: ${msg}`); + return; + } + + if (!validation.valid || !validation.jira) { const retry = await vscode.window.showErrorMessage( - `Jira validation failed: ${validation.error}`, + `Jira validation failed: ${validation.error ?? 'unknown error'}`, 'Retry', 'Cancel' ); @@ -535,18 +338,55 @@ export async function onboardJira( } void vscode.window.showInformationMessage( - `Authenticated as ${validation.displayName} (${validation.accountId})` + `Authenticated as ${validation.jira.display_name} (${validation.jira.account_id})` ); - // Fetch and select project - const projects = await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: 'Fetching Jira projects...', - cancellable: false, - }, - () => fetchJiraProjects(domain, email, apiToken) - ); + // Set session env so subsequent API calls can use the token server-side + const apiKeyEnv = 'OPERATOR_JIRA_API_KEY'; + let envInfo; + try { + envInfo = await client.setKanbanSessionEnv({ + provider: 'jira', + jira: { + domain: creds.domain, + email: creds.email, + api_token: creds.apiToken, + api_key_env: apiKeyEnv, + }, + linear: null, + github: null, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + void vscode.window.showErrorMessage(`Failed to set session env: ${msg}`); + return; + } + + // Fetch projects via the API + let projects: KanbanProjectInfo[]; + try { + projects = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Fetching Jira projects...', + cancellable: false, + }, + () => client.listKanbanProjects({ + provider: 'jira', + jira: { + domain: creds.domain, + email: creds.email, + api_token: creds.apiToken, + }, + linear: null, + github: null, + }) + ); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + void vscode.window.showErrorMessage(`Failed to list Jira projects: ${msg}`); + return; + } if (projects.length === 0) { void vscode.window.showWarningMessage( @@ -570,48 +410,44 @@ export async function onboardJira( return; } - // Write config - const envVarName = 'OPERATOR_JIRA_API_KEY'; - const toml = generateJiraToml( - domain, - email, - envVarName, - selectedProject.label, - validation.accountId - ); - - const written = await writeKanbanConfig(toml); - if (!written) { + // Write config via the API + try { + await client.writeKanbanConfig({ + provider: 'jira', + jira: { + domain: creds.domain, + email: creds.email, + api_key_env: apiKeyEnv, + project_key: selectedProject.label, + sync_user_id: validation.jira.account_id, + }, + linear: null, + github: null, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + void vscode.window.showErrorMessage(`Failed to write config: ${msg}`); return; } - // Set env vars for current session - process.env['OPERATOR_JIRA_API_KEY'] = apiToken; - process.env['OPERATOR_JIRA_DOMAIN'] = domain; - process.env['OPERATOR_JIRA_EMAIL'] = email; - - // Show success + env var instructions void vscode.window.showInformationMessage( - `Jira configured! Config written to ${getResolvedConfigPath()}` + `Jira configured! Run Operator to activate.` ); - await showEnvVarInstructions([ - `export OPERATOR_JIRA_API_KEY=""`, - ]); + await showEnvVarInstructions(envInfo.shell_export_block); // Update walkthrough context await updateWalkthroughContext(context); + + // Auto-sync issue types and nudge user to map them + await syncAndNudgeIssueTypes('jira', selectedProject.label, `Jira ${selectedProject.label}`); } /** - * Linear onboarding: API key -> validate -> pick team -> write config + * Prompt for a Linear API key via InputBox (with external-link button). + * Returns null if cancelled. */ -export async function onboardLinear( - context: vscode.ExtensionContext -): Promise { - const title = 'Configure Linear'; - - // Step 1: API key +async function collectLinearApiKey(title: string): Promise { const openLinearSettings: vscode.QuickInputButton = { iconPath: new vscode.ThemeIcon('link-external'), tooltip: 'Open Linear API Settings', @@ -671,23 +507,47 @@ export async function onboardLinear( input.show(); }); - if (!apiKey) { + return apiKey ?? null; +} + +/** + * Linear onboarding: prompt for API key -> validate via API -> set session env -> + * pick team -> write config via API -> sync issuetypes. + */ +export async function onboardLinear( + context: vscode.ExtensionContext +): Promise { + const title = 'Configure Linear'; + const client = await buildClient(); + + const apiKey = await collectLinearApiKey(title); + if (!apiKey) { return; } + + // Validate credentials via the Operator API + let validation; + try { + validation = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Validating Linear credentials...', + cancellable: false, + }, + () => client.validateKanbanCredentials({ + provider: 'linear', + jira: null, + linear: { api_key: apiKey }, + github: null, + }) + ); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + void vscode.window.showErrorMessage(`Could not reach Operator API: ${msg}`); return; } - // Validate credentials - const validation = await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: 'Validating Linear credentials...', - cancellable: false, - }, - () => validateLinearCredentials(apiKey) - ); - - if (!validation.valid) { + if (!validation.valid || !validation.linear) { const retry = await vscode.window.showErrorMessage( - `Linear validation failed: ${validation.error}`, + `Linear validation failed: ${validation.error ?? 'unknown error'}`, 'Retry', 'Cancel' ); @@ -698,18 +558,34 @@ export async function onboardLinear( } void vscode.window.showInformationMessage( - `Authenticated as ${validation.userName} in ${validation.orgName}` + `Authenticated as ${validation.linear.user_name} in ${validation.linear.org_name}` ); - // Step 2: Select team - if (validation.teams.length === 0) { + // Set session env + const apiKeyEnv = 'OPERATOR_LINEAR_API_KEY'; + let envInfo; + try { + envInfo = await client.setKanbanSessionEnv({ + provider: 'linear', + jira: null, + linear: { api_key: apiKey, api_key_env: apiKeyEnv }, + github: null, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + void vscode.window.showErrorMessage(`Failed to set session env: ${msg}`); + return; + } + + // Select team from the teams returned by validation + if (validation.linear.teams.length === 0) { void vscode.window.showWarningMessage( 'No teams found. Check your permissions. Config was not written.' ); return; } - const teamItems = validation.teams.map((t) => ({ + const teamItems = validation.linear.teams.map((t) => ({ label: t.name, description: t.key, detail: t.id, @@ -718,46 +594,259 @@ export async function onboardLinear( const selectedTeam = await vscode.window.showQuickPick(teamItems, { title: 'Select Linear Team', placeHolder: 'Choose a team to sync tickets from', - step: 2, - totalSteps: 2, ignoreFocusOut: true, - } as vscode.QuickPickOptions & { step: number; totalSteps: number }); + }); if (!selectedTeam) { return; } - // Write config - const envVarName = 'OPERATOR_LINEAR_API_KEY'; - const toml = generateLinearToml( - selectedTeam.detail ?? '', - envVarName, - validation.userId + // Write config via the API. For Linear, we use the org slug / a default + // workspace key. Since validation doesn't return a workspace slug directly, + // use the org name (sanitized) as the workspace key. + const workspaceKey = selectedTeam.detail ?? 'default'; + try { + await client.writeKanbanConfig({ + provider: 'linear', + jira: null, + linear: { + workspace_key: workspaceKey, + api_key_env: apiKeyEnv, + project_key: 'default', + sync_user_id: validation.linear.user_id, + }, + github: null, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + void vscode.window.showErrorMessage(`Failed to write config: ${msg}`); + return; + } + + void vscode.window.showInformationMessage(`Linear configured!`); + + await showEnvVarInstructions(envInfo.shell_export_block); + + // Update walkthrough context + await updateWalkthroughContext(context); + + // Auto-sync issue types and nudge user to map them + await syncAndNudgeIssueTypes('linear', 'default', `Linear ${selectedTeam.description ?? selectedTeam.label}`); +} + +/** + * Prompt for a GitHub Projects token via InputBox. + * + * IMPORTANT: This is the *projects* token, not the *repo* token. It must + * have the `project` (or `read:project`) scope. A repo-only token (the kind + * typically set as `GITHUB_TOKEN` for PR workflows) will be rejected by the + * server's scope verification with a friendly error pointing to the docs. + */ +async function collectGithubToken(title: string): Promise { + const openGithubSettings: vscode.QuickInputButton = { + iconPath: new vscode.ThemeIcon('link-external'), + tooltip: 'Open GitHub Token Settings', + }; + + const input = vscode.window.createInputBox(); + input.title = title; + input.prompt = + 'Enter a GitHub PAT with the `project` (or `read:project`) scope — NOT a repo-only token\nhttps://github.com/settings/personal-access-tokens'; + input.placeholder = 'ghp_xxxxxxxxxxxxxxxx or github_pat_xxxxxxxx'; + input.step = 1; + input.totalSteps = 2; + input.password = true; + input.ignoreFocusOut = true; + input.buttons = [openGithubSettings]; + + const isRecognizedPrefix = (val: string): boolean => + val.startsWith('ghp_') || val.startsWith('github_pat_') || val.startsWith('gho_'); + + const token = await new Promise((resolve) => { + let resolved = false; + + input.onDidChangeValue((value) => { + if (value && !isRecognizedPrefix(value)) { + input.validationMessage = + 'GitHub tokens start with "ghp_", "github_pat_", or "gho_"'; + } else { + input.validationMessage = ''; + } + }); + + input.onDidAccept(() => { + const val = input.value.trim(); + if (!val) { + input.validationMessage = 'Token is required'; + return; + } + if (!isRecognizedPrefix(val)) { + input.validationMessage = + 'GitHub tokens start with "ghp_", "github_pat_", or "gho_"'; + return; + } + resolved = true; + input.dispose(); + resolve(val); + }); + + input.onDidTriggerButton((button) => { + if (button === openGithubSettings) { + void vscode.env.openExternal( + vscode.Uri.parse('https://github.com/settings/tokens') + ); + } + }); + + input.onDidHide(() => { + if (!resolved) { + resolved = true; + resolve(undefined); + } + }); + + input.show(); + }); + + return token ?? null; +} + +/** + * GitHub Projects v2 onboarding: prompt for token -> validate (with scope + * verification) -> set session env -> pick project -> write config -> sync + * issue types. + * + * The validate step performs the Token Disambiguation scope check on the + * server side; if the token is repo-only the user gets a friendly error + * pointing them at the docs. + */ +export async function onboardGithub( + context: vscode.ExtensionContext +): Promise { + const title = 'Configure GitHub Projects'; + const client = await buildClient(); + + const token = await collectGithubToken(title); + if (!token) { return; } + + // Validate credentials via the Operator API (includes scope verification). + let validation; + try { + validation = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Validating GitHub Projects credentials...', + cancellable: false, + }, + () => client.validateKanbanCredentials({ + provider: 'github', + jira: null, + linear: null, + github: { token }, + }) + ); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + void vscode.window.showErrorMessage(`Could not reach Operator API: ${msg}`); + return; + } + + if (!validation.valid || !validation.github) { + const retry = await vscode.window.showErrorMessage( + `GitHub validation failed: ${validation.error ?? 'unknown error'}`, + 'Retry', + 'Cancel' + ); + if (retry === 'Retry') { + return onboardGithub(context); + } + return; + } + + void vscode.window.showInformationMessage( + `Authenticated as ${validation.github.user_login} (connected via ${validation.github.resolved_env_var})` ); - const written = await writeKanbanConfig(toml); - if (!written) { + // Set session env so subsequent API calls can use the token server-side. + const apiKeyEnv = 'OPERATOR_GITHUB_TOKEN'; + let envInfo; + try { + envInfo = await client.setKanbanSessionEnv({ + provider: 'github', + jira: null, + linear: null, + github: { token, api_key_env: apiKeyEnv }, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + void vscode.window.showErrorMessage(`Failed to set session env: ${msg}`); return; } - // Set env var for current session - process.env['OPERATOR_LINEAR_API_KEY'] = apiKey; + // Project picker: use projects from validation (no extra round-trip). + if (validation.github.projects.length === 0) { + void vscode.window.showWarningMessage( + 'No GitHub Projects v2 found for this token. Confirm the token has the `project` scope and that you have access to at least one project. Config was not written.' + ); + return; + } + + const projectItems = validation.github.projects.map((p) => ({ + label: `${p.owner_login}/#${p.number} ${p.title}`, + description: p.owner_kind, + detail: p.node_id, + project: p, + })); + + const selectedProject = await vscode.window.showQuickPick(projectItems, { + title: 'Select GitHub Project', + placeHolder: 'Choose a project to sync tickets from', + ignoreFocusOut: true, + }); + + if (!selectedProject) { + return; + } + + // Write config — owner is the workspace key, project node id is the project key. + try { + await client.writeKanbanConfig({ + provider: 'github', + jira: null, + linear: null, + github: { + owner: selectedProject.project.owner_login, + api_key_env: apiKeyEnv, + project_key: selectedProject.project.node_id, + sync_user_id: validation.github.user_id, + }, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + void vscode.window.showErrorMessage(`Failed to write config: ${msg}`); + return; + } - // Show success + env var instructions void vscode.window.showInformationMessage( - `Linear configured! Config written to ${getResolvedConfigPath()}` + `GitHub Projects configured for ${selectedProject.project.owner_login}/#${selectedProject.project.number}!` ); - await showEnvVarInstructions([ - `export OPERATOR_LINEAR_API_KEY=""`, - ]); + await showEnvVarInstructions(envInfo.shell_export_block); // Update walkthrough context await updateWalkthroughContext(context); + + // Auto-sync issue types and nudge user to map them. + await syncAndNudgeIssueTypes( + 'github', + selectedProject.project.node_id, + `GitHub ${selectedProject.project.owner_login}/#${selectedProject.project.number}` + ); } /** - * Entry-point: let user pick Jira or Linear, then route to the right flow + * Entry-point: let user pick Jira, Linear, or GitHub Projects, then route to + * the right flow. */ export async function startKanbanOnboarding( context: vscode.ExtensionContext @@ -774,6 +863,11 @@ export async function startKanbanOnboarding( description: 'Connect to Linear with API key', provider: 'linear' as const, }, + { + label: '$(github) GitHub Projects', + description: 'Connect to GitHub Projects v2 with a personal access token', + provider: 'github' as const, + }, { label: '$(close) Skip for now', description: 'You can configure this later', @@ -791,306 +885,56 @@ export async function startKanbanOnboarding( return; } - if (choice.provider === 'jira') { - await onboardJira(context); - } else { - await onboardLinear(context); + switch (choice.provider) { + case 'jira': + await onboardJira(context); + break; + case 'linear': + await onboardLinear(context); + break; + case 'github': + await onboardGithub(context); + break; } } // ─── Add Project/Team Flows ─────────────────────────────────────────── /** - * Read and parse config.toml - */ -async function readParsedConfig(): Promise> { - const configPath = getResolvedConfigPath(); - if (!configPath) { return {}; } - try { - const raw = await fs.readFile(configPath, 'utf-8'); - if (!raw.trim()) { return {}; } - const { parse } = await importSmolToml(); - return parse(raw) as Record; - } catch { - return {}; - } -} - -/** - * Generate TOML for a single Jira project section to append - */ -function generateJiraProjectToml( - domain: string, - projectKey: string, - accountId: string, - collectionName: string -): string { - return [ - `[kanban.jira."${domain}".projects.${projectKey}]`, - `sync_user_id = "${accountId}"`, - `collection_name = "${collectionName}"`, - ``, - ].join('\n'); -} - -/** - * Generate TOML for a single Linear team section to append - */ -function generateLinearTeamToml( - workspaceKey: string, - teamKey: string, - userId: string, - collectionName: string -): string { - return [ - `[kanban.linear."${workspaceKey}".projects.${teamKey}]`, - `sync_user_id = "${userId}"`, - `collection_name = "${collectionName}"`, - ``, - ].join('\n'); -} - -/** - * Add a new Jira project to an existing workspace in config.toml - * - * Reads existing Jira workspace config (email, api_key_env), fetches available - * projects from the Jira API, shows a QuickPick, and writes the new project section. + * Add a new Jira project. Since all credential state lives on the server + * now, this flow is a simplified version of `onboardJira` — it collects + * credentials again, validates, picks a project, and writes config. */ export async function addJiraProject( context: vscode.ExtensionContext, domain?: string ): Promise { - if (!domain) { - void vscode.window.showErrorMessage('No Jira domain specified.'); - return; - } - - // Read config.toml to get workspace credentials - const config = await readParsedConfig(); - const kanban = config.kanban as Record | undefined; - const jiraSection = kanban?.jira as Record | undefined; - const wsConfig = jiraSection?.[domain] as Record | undefined; - - let email: string | undefined; - let apiKeyEnv: string; - let apiToken: string | undefined; - const fromEnvVars = !wsConfig; - - if (wsConfig) { - email = wsConfig.email as string | undefined; - apiKeyEnv = (wsConfig.api_key_env as string) || 'OPERATOR_JIRA_API_KEY'; - apiToken = process.env[apiKeyEnv]; - } else { - // Fall back to env-var detection - const envEmail = process.env['OPERATOR_JIRA_EMAIL']; - const envApiKey = process.env['OPERATOR_JIRA_API_KEY']; - if (!envEmail || !envApiKey) { - void vscode.window.showErrorMessage(`No Jira workspace configured for ${domain}.`); - return; - } - email = envEmail; - apiToken = envApiKey; - apiKeyEnv = 'OPERATOR_JIRA_API_KEY'; - } - - if (!email) { - void vscode.window.showErrorMessage(`No email configured for Jira workspace ${domain}.`); - return; - } - - // Prompt for API token if not in env - if (!apiToken) { - apiToken = await vscode.window.showInputBox({ - title: 'Jira API Token', - prompt: `Enter API token for ${domain} (env var ${apiKeyEnv} not set)`, - password: true, - ignoreFocusOut: true, - }) ?? undefined; - if (!apiToken) { return; } - // Set for current session - process.env[apiKeyEnv] = apiToken; - } - - // Find already-configured project keys - const existingProjects = new Set(); - const projectsSection = wsConfig?.projects as Record | undefined; - if (projectsSection) { - for (const key of Object.keys(projectsSection)) { - existingProjects.add(key); - } - } - - // Fetch available projects - const projects = await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: 'Fetching Jira projects...', - cancellable: false, - }, - () => fetchJiraProjects(domain, email, apiToken) - ); - - if (projects.length === 0) { - void vscode.window.showWarningMessage('No projects found. Check your permissions.'); - return; - } - - // Filter out already-configured projects - const available = projects.filter((p) => !existingProjects.has(p.key)); - if (available.length === 0) { - const action = await vscode.window.showInformationMessage( - `All projects on ${domain} are already configured.`, - 'Connect Another Workspace' - ); - if (action === 'Connect Another Workspace') { - await vscode.commands.executeCommand('operator.startKanbanOnboarding'); - } - return; - } - - const selected = await vscode.window.showQuickPick( - available.map((p) => ({ label: p.key, description: p.name })), - { - title: `Add Jira Project to ${domain}`, - placeHolder: 'Select a project to sync', - ignoreFocusOut: true, - } - ); - - if (!selected) { return; } - - // Get the user's account ID from validation - const validation = await validateJiraCredentials(domain, email, apiToken); - if (!validation.valid) { - void vscode.window.showErrorMessage(`Jira validation failed: ${validation.error}`); - return; - } - - // Write project section to config.toml - // When from env vars, write the full workspace section to promote into TOML - const toml = fromEnvVars - ? generateJiraToml(domain, email, apiKeyEnv, selected.label, validation.accountId) - : generateJiraProjectToml(domain, selected.label, validation.accountId, 'dev_kanban'); - const written = await writeKanbanConfig(toml); - if (!written) { return; } - - void vscode.window.showInformationMessage( - `Added Jira project ${selected.label} to ${domain}` - ); - - await updateWalkthroughContext(context); + // Delegate to the full onboarding flow. The domain hint isn't used — + // the user re-enters credentials. Future enhancement: load existing + // workspace config via a GET /api/v1/kanban/config endpoint to skip + // the domain/email steps. + void domain; + await onboardJira(context); } /** - * Add a new Linear team to an existing workspace in config.toml - * - * Reads existing Linear workspace config (api_key_env), fetches available - * teams from the Linear API, shows a QuickPick, and writes the new team section. + * Add a new Linear team. Same simplification as `addJiraProject`. */ export async function addLinearTeam( context: vscode.ExtensionContext, workspaceKey?: string ): Promise { - if (!workspaceKey) { - void vscode.window.showErrorMessage('No Linear workspace specified.'); - return; - } - - // Read config.toml to get workspace credentials - const config = await readParsedConfig(); - const kanban = config.kanban as Record | undefined; - const linearSection = kanban?.linear as Record | undefined; - const wsConfig = linearSection?.[workspaceKey] as Record | undefined; - - let apiKeyEnv: string; - let apiKey: string | undefined; - const fromEnvVars = !wsConfig; - - if (wsConfig) { - apiKeyEnv = (wsConfig.api_key_env as string) || 'OPERATOR_LINEAR_API_KEY'; - apiKey = process.env[apiKeyEnv]; - } else { - // Fall back to env-var detection - const envApiKey = process.env['OPERATOR_LINEAR_API_KEY']; - if (!envApiKey) { - void vscode.window.showErrorMessage(`No Linear workspace configured for ${workspaceKey}.`); - return; - } - apiKey = envApiKey; - apiKeyEnv = 'OPERATOR_LINEAR_API_KEY'; - } - - // Prompt for API key if not in env - if (!apiKey) { - apiKey = await vscode.window.showInputBox({ - title: 'Linear API Key', - prompt: `Enter API key for Linear (env var ${apiKeyEnv} not set)`, - password: true, - ignoreFocusOut: true, - }) ?? undefined; - if (!apiKey) { return; } - // Set for current session - process.env[apiKeyEnv] = apiKey; - } - - // Find already-configured team keys - const existingTeams = new Set(); - const projectsSection = wsConfig?.projects as Record | undefined; - if (projectsSection) { - for (const key of Object.keys(projectsSection)) { - existingTeams.add(key); - } - } - - // Fetch available teams - const validation = await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: 'Fetching Linear teams...', - cancellable: false, - }, - () => validateLinearCredentials(apiKey) - ); - - if (!validation.valid) { - void vscode.window.showErrorMessage(`Linear validation failed: ${validation.error}`); - return; - } - - if (validation.teams.length === 0) { - void vscode.window.showWarningMessage('No teams found. Check your permissions.'); - return; - } - - // Filter out already-configured teams - const available = validation.teams.filter((t) => !existingTeams.has(t.key)); - if (available.length === 0) { - void vscode.window.showInformationMessage('All available teams are already configured.'); - return; - } - - const selected = await vscode.window.showQuickPick( - available.map((t) => ({ label: t.key, description: t.name, detail: t.id })), - { - title: 'Add Linear Workspace', - placeHolder: 'Select a team to sync', - ignoreFocusOut: true, - } - ); - - if (!selected) { return; } - - // Write team section to config.toml - // When from env vars, write the full workspace section to promote into TOML - const toml = fromEnvVars - ? generateLinearToml(workspaceKey, apiKeyEnv, validation.userId) - : generateLinearTeamToml(workspaceKey, selected.label, validation.userId, 'dev_kanban'); - const written = await writeKanbanConfig(toml); - if (!written) { return; } - - void vscode.window.showInformationMessage( - `Added Linear team ${selected.label} (${selected.description})` - ); + void workspaceKey; + await onboardLinear(context); +} - await updateWalkthroughContext(context); +/** + * Add a new GitHub Project. Same simplification as `addJiraProject`. + */ +export async function addGithubProject( + context: vscode.ExtensionContext, + owner?: string +): Promise { + void owner; + await onboardGithub(context); } diff --git a/vscode-extension/src/sections/kanban-section.ts b/vscode-extension/src/sections/kanban-section.ts index d25b822..7ca1f67 100644 --- a/vscode-extension/src/sections/kanban-section.ts +++ b/vscode-extension/src/sections/kanban-section.ts @@ -81,6 +81,38 @@ export class KanbanSection implements StatusSection { }); } } + + // Parse GitHub Projects providers from config.toml + const githubSection = kanbanSection.github as Record | undefined; + if (githubSection) { + for (const [owner, wsConfig] of Object.entries(githubSection)) { + const ws = wsConfig as Record; + if (ws.enabled === false) { continue; } + const projects: KanbanProviderState['projects'] = []; + const projectsSection = ws.projects as Record | undefined; + if (projectsSection) { + for (const [projectKey, projConfig] of Object.entries(projectsSection)) { + const proj = projConfig as Record; + // Project keys are GraphQL node IDs; we can't link directly to + // them without the project number, so link to the owner's + // projects index page. + projects.push({ + key: projectKey, + collectionName: (proj.collection_name as string) || 'dev_kanban', + url: `https://github.com/${owner}?tab=projects`, + }); + } + } + providers.push({ + provider: 'github', + key: owner, + enabled: ws.enabled !== false, + displayName: owner, + url: `https://github.com/${owner}?tab=projects`, + projects, + }); + } + } } // Fall back to env-var-based detection if config.toml has no kanban section @@ -133,8 +165,14 @@ export class KanbanSection implements StatusSection { if (this.state.configured) { for (const prov of this.state.providers) { - const providerLabel = prov.provider === 'jira' ? 'Jira' : 'Linear'; - const providerIcon = prov.provider === 'jira' ? 'operator-atlassian' : 'operator-linear'; + const providerLabel = + prov.provider === 'jira' ? 'Jira' + : prov.provider === 'linear' ? 'Linear' + : 'GitHub Projects'; + const providerIcon = + prov.provider === 'jira' ? 'operator-atlassian' + : prov.provider === 'linear' ? 'operator-linear' + : 'github'; items.push(new StatusItem({ label: providerLabel, description: prov.displayName, @@ -212,8 +250,14 @@ export class KanbanSection implements StatusSection { })); } - const addLabel = provider === 'jira' ? 'Add Jira Project' : 'Add Linear Workspace'; - const addCommand = provider === 'jira' ? 'operator.addJiraProject' : 'operator.addLinearTeam'; + const addLabel = + provider === 'jira' ? 'Add Jira Project' + : provider === 'linear' ? 'Add Linear Workspace' + : 'Add GitHub Project'; + const addCommand = + provider === 'jira' ? 'operator.addJiraProject' + : provider === 'linear' ? 'operator.addLinearTeam' + : 'operator.addGithubProject'; items.push(new StatusItem({ label: addLabel, icon: 'add', @@ -233,7 +277,10 @@ export class KanbanSection implements StatusSection { if (!prov) { return ''; } - const provider = prov.provider === 'jira' ? 'Jira' : 'Linear'; + const provider = + prov.provider === 'jira' ? 'Jira' + : prov.provider === 'linear' ? 'Linear' + : 'GitHub'; return `${provider}: ${prov.displayName}`; } } diff --git a/vscode-extension/src/sections/types.ts b/vscode-extension/src/sections/types.ts index 690d4e9..d7d5672 100644 --- a/vscode-extension/src/sections/types.ts +++ b/vscode-extension/src/sections/types.ts @@ -75,7 +75,7 @@ export interface ConfigState { /** Config-driven state for a single kanban provider */ export interface KanbanProviderState { - provider: 'jira' | 'linear'; + provider: 'jira' | 'linear' | 'github'; key: string; enabled: boolean; displayName: string; diff --git a/vscode-extension/src/walkthrough.ts b/vscode-extension/src/walkthrough.ts index 77a42f4..4fc6eb1 100644 --- a/vscode-extension/src/walkthrough.ts +++ b/vscode-extension/src/walkthrough.ts @@ -16,7 +16,7 @@ import { promisify } from 'util'; const execAsync = promisify(exec); /** Kanban provider types */ -export type KanbanProviderType = 'jira' | 'linear'; +export type KanbanProviderType = 'jira' | 'linear' | 'github'; /** Detected kanban workspace with connection details */ export interface KanbanWorkspace { @@ -52,6 +52,13 @@ export const KANBAN_ENV_VARS = { linear: { apiKey: ['OPERATOR_LINEAR_API_KEY', 'LINEAR_API_KEY'] as const, }, + github: { + // Token Disambiguation: ONLY OPERATOR_GITHUB_TOKEN is checked here. + // We deliberately do NOT fall through to GITHUB_TOKEN — that env var + // belongs to the git provider (PR/branch workflows) and detecting it + // here would surface a spurious "GitHub kanban detected" prompt. + apiKey: ['OPERATOR_GITHUB_TOKEN'] as const, + }, } as const; /** Linear GraphQL API URL */ @@ -169,6 +176,17 @@ export function checkKanbanEnvVars(): KanbanEnvResult { }); } + // Check GitHub Projects - token only, name resolved server-side at validation + const githubToken = findEnvVar(KANBAN_ENV_VARS.github.apiKey); + if (githubToken) { + workspaces.push({ + provider: 'github', + name: 'GitHub Projects', + url: 'https://github.com', + configured: true, + }); + } + return { workspaces, anyConfigured: workspaces.length > 0, diff --git a/vscode-extension/test/suite/index.ts b/vscode-extension/test/suite/index.ts index b799bdc..3c387bf 100644 --- a/vscode-extension/test/suite/index.ts +++ b/vscode-extension/test/suite/index.ts @@ -63,13 +63,16 @@ export async function run(): Promise { await nyc.reset(); await nyc.wrap(); - // Re-require already-loaded modules for instrumentation - Object.keys(require.cache) - .filter((f) => nyc.exclude.shouldInstrument(f)) - .forEach((m) => { - delete require.cache[m]; - require(m); - }); + // Clear all out/ module cache so tests load fresh through NYC's hooked require. + // Extension activation loads src modules before NYC hooks are set up. + // By clearing both src and test caches, when Mocha loads test files they'll + // require instrumented src modules through NYC's hooks. + const outDir = path.join(workspaceRoot, 'out'); + for (const key of Object.keys(require.cache)) { + if (key.startsWith(outDir) && !key.includes('node_modules')) { + delete require.cache[key]; + } + } // Create the mocha test const mocha = new Mocha({ @@ -87,6 +90,20 @@ export async function run(): Promise { mocha.run((failures) => { // Write coverage data and report asynchronously, then resolve/reject void (async () => { + // Load any src modules not yet required by tests for `all` coverage. + // This ensures files like extension.ts appear in the report even if + // no test imports them directly. + const srcGlob = await glob('out/src/**/*.js', { + cwd: workspaceRoot, + ignore: ['out/src/generated/**'], + }); + for (const f of srcGlob) { + const fullPath = path.join(workspaceRoot, f); + if (!require.cache[fullPath]) { + try { require(fullPath); } catch { /* ok — some modules need VS Code context */ } + } + } + await nyc.writeCoverageFile(); // Generate and display coverage report diff --git a/vscode-extension/webview-ui/App.tsx b/vscode-extension/webview-ui/App.tsx index 25ca833..1a235f5 100644 --- a/vscode-extension/webview-ui/App.tsx +++ b/vscode-extension/webview-ui/App.tsx @@ -381,7 +381,7 @@ function deepMerge>(target: T, source: T): T { const DEFAULT_JIRA: JiraConfig = { enabled: false, api_key_env: 'OPERATOR_JIRA_API_KEY', email: '', projects: {} }; const DEFAULT_LINEAR: LinearConfig = { enabled: false, api_key_env: 'OPERATOR_LINEAR_API_KEY', projects: {} }; -const DEFAULT_PROJECT_SYNC: ProjectSyncConfig = { sync_user_id: '', sync_statuses: [], collection_name: '', type_mappings: {} }; +const DEFAULT_PROJECT_SYNC: ProjectSyncConfig = { sync_user_id: '', sync_statuses: [], collection_name: null, type_mappings: {} }; /** Apply an update to the config object by section/key path */ function applyUpdate( diff --git a/vscode-extension/webview-ui/components/kanban/ProjectRow.tsx b/vscode-extension/webview-ui/components/kanban/ProjectRow.tsx index 3dcaf03..b3abe79 100644 --- a/vscode-extension/webview-ui/components/kanban/ProjectRow.tsx +++ b/vscode-extension/webview-ui/components/kanban/ProjectRow.tsx @@ -126,7 +126,7 @@ export function ProjectRow({ provider={provider} domain={domain} projectKey={projectKey} - collectionName={project.collection_name} + collectionName={project.collection_name||''} typeMappings={project.type_mappings ?? {}} issueTypes={issueTypes} externalTypes={externalTypes} diff --git a/vscode-extension/webview-ui/types/defaults.ts b/vscode-extension/webview-ui/types/defaults.ts index 76ae68c..e13c937 100644 --- a/vscode-extension/webview-ui/types/defaults.ts +++ b/vscode-extension/webview-ui/types/defaults.ts @@ -135,6 +135,7 @@ const DEFAULT_CONFIG: Config = { kanban: { jira: {}, linear: {}, + github: {}, }, version_check: { enabled: true, From d832198634cd47a31d3410d381227f089264852d Mon Sep 17 00:00:00 2001 From: untra Date: Sat, 11 Apr 2026 13:42:09 -0600 Subject: [PATCH 4/5] f --- src/api/providers/kanban/github_projects.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/api/providers/kanban/github_projects.rs b/src/api/providers/kanban/github_projects.rs index d08ae03..bd4f2f6 100644 --- a/src/api/providers/kanban/github_projects.rs +++ b/src/api/providers/kanban/github_projects.rs @@ -232,7 +232,10 @@ impl GithubProjectsProvider { let combined = messages.join("; "); // If the `GraphQL` errors mention permissions/scopes/projects, escalate // with the friendly disambiguation hint so users see it via the - // generic provider_error_message helper. + // generic provider_error_message helper. Preserve the raw error so + // legitimate bugs (field-level permission failures, feature-gated + // fields, etc.) are still debuggable — the hint alone was masking + // real root causes. let lower = combined.to_lowercase(); if lower.contains("project") && (lower.contains("permission") @@ -242,7 +245,7 @@ impl GithubProjectsProvider { return Err(ApiError::http( PROVIDER_NAME, 403, - SCOPE_ERROR_MSG.to_string(), + format!("{SCOPE_ERROR_MSG} (raw GraphQL error: {combined})"), )); } return Err(ApiError::http(PROVIDER_NAME, 0, combined)); From e142f1de245a6d8ee8bbbaba18c055b2e76c13e9 Mon Sep 17 00:00:00 2001 From: untra Date: Sat, 11 Apr 2026 13:51:34 -0600 Subject: [PATCH 5/5] remove email from user request scope --- src/api/providers/kanban/github_projects.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/api/providers/kanban/github_projects.rs b/src/api/providers/kanban/github_projects.rs index bd4f2f6..7c68163 100644 --- a/src/api/providers/kanban/github_projects.rs +++ b/src/api/providers/kanban/github_projects.rs @@ -1427,6 +1427,11 @@ impl GithubProjectsProvider { project_id: &str, after: Option<&str>, ) -> Result { + // NOTE: assignees.nodes must NOT request `email` — GitHub gates the + // `User.email` field behind `user:email` or `read:user` scope, which + // is orthogonal to the `project` scope this provider requires and + // would break any token scoped to projects-only. `RawAssignee.email` + // stays in the struct (serde-default `None`) for forward compat. let query = r" query($projectId: ID!, $first: Int!, $after: String) { node(id: $projectId) { @@ -1450,7 +1455,6 @@ impl GithubProjectsProvider { login databaseId name - email avatarUrl } } @@ -1469,7 +1473,6 @@ impl GithubProjectsProvider { login databaseId name - email avatarUrl } } @@ -1484,7 +1487,6 @@ impl GithubProjectsProvider { login databaseId name - email avatarUrl } }