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/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..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/) @@ -151,29 +154,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/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/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/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/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/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/_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/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/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/api/providers/kanban/github_projects.rs b/src/api/providers/kanban/github_projects.rs new file mode 100644 index 0000000..7c68163 --- /dev/null +++ b/src/api/providers/kanban/github_projects.rs @@ -0,0 +1,1722 @@ +//! 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. 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") + || lower.contains("scope") + || lower.contains("not authorized")) + { + return Err(ApiError::http( + PROVIDER_NAME, + 403, + format!("{SCOPE_ERROR_MSG} (raw GraphQL error: {combined})"), + )); + } + 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 { + // 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) { + ... 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 + 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 + avatarUrl + } + } + labels(first: 20) { nodes { id name } } + } + ... on DraftIssue { + id + title + body + assignees(first: 10) { + nodes { + login + databaseId + name + 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/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/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 90f5ebf..9f5b7b3 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, @@ -279,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 } @@ -287,9 +296,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?; @@ -302,8 +378,15 @@ 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 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 +424,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 +462,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 +491,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 +505,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..510256f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -20,18 +20,21 @@ 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, KanbanOnboardingDialog, + KanbanView, SessionRecoveryDialog, SyncConfirmDialog, TerminalGuard, }; use std::sync::Arc; mod agents; mod data_sync; +mod git_onboarding; mod kanban; +mod kanban_onboarding; mod keyboard; mod pr_workflow; mod review; mod session; +mod status_actions; mod tickets; #[cfg(test)] @@ -77,6 +80,12 @@ 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 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 @@ -281,6 +290,9 @@ impl App { collection_dialog: CollectionSwitchDialog::new(), 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, @@ -388,6 +400,8 @@ impl App { self.kanban_view.render(f, f.area()); } self.sync_confirm_dialog.render(f); + self.git_token_dialog.render(f); + self.kanban_onboarding_dialog.render(f); } })?; @@ -406,7 +420,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..749b9ea 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 @@ -1068,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 { @@ -1077,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 @@ -1145,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)] @@ -1152,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, } @@ -1576,6 +1748,7 @@ mod tests { yolo: true, permission_mode: Some("delegate".to_string()), flags: vec!["--verbose".to_string()], + ..Default::default() }), }; @@ -1767,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/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/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/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/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/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..5ad1437 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, } @@ -472,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 // ============================================================================= @@ -1009,6 +1338,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 +1348,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 +1380,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 +1418,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..e50aa3b 100644 --- a/src/rest/mod.rs +++ b/src/rest/mod.rs @@ -116,14 +116,45 @@ 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 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/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/launch.rs b/src/rest/routes/launch.rs index 3171948..30f2e2e 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,249 @@ 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 config = Config { + delegators, + ..Default::default() + }; + 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/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/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/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 71860ba..19b902f 100644 --- a/src/ui/dialogs/mod.rs +++ b/src/ui/dialogs/mod.rs @@ -1,5 +1,7 @@ mod confirm; +mod git_token; mod help; +mod kanban_onboarding; mod rejection; mod session_recovery; mod sync_confirm; @@ -7,7 +9,12 @@ mod sync_confirm; pub use confirm::{ ConfirmDialog, ConfirmDialogFocus, ConfirmSelection, SelectedOption, SessionPlacementPreview, }; +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/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/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/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..20d1569 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -5,22 +5,26 @@ 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, 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/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/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/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/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-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..94040f1 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": { @@ -458,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" @@ -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/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/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..0c32155 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_...', @@ -168,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=""`); } /** @@ -207,13 +215,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-...', @@ -261,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/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..7ca1f67 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; @@ -75,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 @@ -127,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, @@ -206,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', @@ -227,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/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..d7d5672 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,11 +66,16 @@ 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 */ export interface KanbanProviderState { - provider: 'jira' | 'linear'; + provider: 'jira' | 'linear' | 'github'; key: string; enabled: boolean; displayName: string; @@ -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..24a4f79 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 not Red + const prereqSection = this.sectionMap.get(prereqId); + if (!prereqSection) { return false; } + return prerequisitesMet(prereqSection) && getSectionHealth(prereqId) !== 'Red'; + }); + }; - 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/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/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/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/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/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'] ); }); 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/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 e79a2c2..e13c937 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', @@ -132,6 +135,7 @@ const DEFAULT_CONFIG: Config = { kanban: { jira: {}, linear: {}, + github: {}, }, version_check: { enabled: true,