From dd2edb3f4ea4f338cbd04cc24459050d2934f1e5 Mon Sep 17 00:00:00 2001 From: untra Date: Sun, 15 Feb 2026 19:41:41 -0700 Subject: [PATCH 1/4] vscode-extension qol, getting started and configuration improvements, css for settings view in the vscode extension, setup feeling good, build process tight Co-Authored-By: Claude Opus 4.5 --- .claude/commands/build-backstage-server.md | 27 + .claude/commands/build-docs.md | 23 + .claude/commands/build-vscode-extension.md | 28 + .../src/components/IssueTypeFormPage.tsx | 4 +- .../src/components/IssueTypesPage.tsx | 2 +- src/rest/mod.rs | 4 + src/rest/routes/queue.rs | 42 +- vscode-extension/.eslintrc.json | 2 +- vscode-extension/.vscodeignore | 6 + vscode-extension/package-lock.json | 2143 ++++++++++++++++- vscode-extension/package.json | 75 +- vscode-extension/scripts/copy-types.js | 100 +- vscode-extension/src/api-client.ts | 26 + vscode-extension/src/config-panel.ts | 498 ++++ vscode-extension/src/config-paths.ts | 63 + vscode-extension/src/extension.ts | 173 +- vscode-extension/src/kanban-onboarding.ts | 1061 ++++++++ vscode-extension/src/status-provider.ts | 869 ++++++- vscode-extension/src/walkthrough.ts | 251 +- vscode-extension/test/suite/index.ts | 28 + .../test/suite/walkthrough.test.ts | 10 +- vscode-extension/tsconfig.webview.json | 17 + .../walkthrough/connect-kanban.md | 26 +- .../walkthrough/install-llm-tool.md | 6 +- .../walkthrough/select-directory.md | 16 +- vscode-extension/walkthrough/welcome.md | 13 +- vscode-extension/webpack.webview.config.js | 43 + vscode-extension/webview-ui/App.tsx | 270 +++ .../webview-ui/components/ConfigPage.tsx | 112 + .../webview-ui/components/OperatorBrand.tsx | 7 + .../webview-ui/components/SectionHeader.tsx | 20 + .../webview-ui/components/SidebarNav.tsx | 104 + .../sections/CodingAgentsSection.tsx | 143 ++ .../sections/GitRepositoriesSection.tsx | 116 + .../sections/KanbanProvidersSection.tsx | 332 +++ .../sections/PrimaryConfigSection.tsx | 88 + vscode-extension/webview-ui/index.tsx | 9 + .../webview-ui/theme/ThemeWrapper.tsx | 36 + .../webview-ui/theme/computeStyles.ts | 106 + .../webview-ui/theme/createVSCodeTheme.ts | 201 ++ vscode-extension/webview-ui/types/defaults.ts | 138 ++ vscode-extension/webview-ui/types/messages.ts | 48 + vscode-extension/webview-ui/vscodeApi.ts | 32 + 43 files changed, 6958 insertions(+), 360 deletions(-) create mode 100644 .claude/commands/build-backstage-server.md create mode 100644 .claude/commands/build-docs.md create mode 100644 .claude/commands/build-vscode-extension.md create mode 100644 vscode-extension/src/config-panel.ts create mode 100644 vscode-extension/src/config-paths.ts create mode 100644 vscode-extension/src/kanban-onboarding.ts create mode 100644 vscode-extension/tsconfig.webview.json create mode 100644 vscode-extension/webpack.webview.config.js create mode 100644 vscode-extension/webview-ui/App.tsx create mode 100644 vscode-extension/webview-ui/components/ConfigPage.tsx create mode 100644 vscode-extension/webview-ui/components/OperatorBrand.tsx create mode 100644 vscode-extension/webview-ui/components/SectionHeader.tsx create mode 100644 vscode-extension/webview-ui/components/SidebarNav.tsx create mode 100644 vscode-extension/webview-ui/components/sections/CodingAgentsSection.tsx create mode 100644 vscode-extension/webview-ui/components/sections/GitRepositoriesSection.tsx create mode 100644 vscode-extension/webview-ui/components/sections/KanbanProvidersSection.tsx create mode 100644 vscode-extension/webview-ui/components/sections/PrimaryConfigSection.tsx create mode 100644 vscode-extension/webview-ui/index.tsx create mode 100644 vscode-extension/webview-ui/theme/ThemeWrapper.tsx create mode 100644 vscode-extension/webview-ui/theme/computeStyles.ts create mode 100644 vscode-extension/webview-ui/theme/createVSCodeTheme.ts create mode 100644 vscode-extension/webview-ui/types/defaults.ts create mode 100644 vscode-extension/webview-ui/types/messages.ts create mode 100644 vscode-extension/webview-ui/vscodeApi.ts diff --git a/.claude/commands/build-backstage-server.md b/.claude/commands/build-backstage-server.md new file mode 100644 index 0000000..98f5a0d --- /dev/null +++ b/.claude/commands/build-backstage-server.md @@ -0,0 +1,27 @@ +--- +description: Build, lint, test, and run the backstage-server locally +allowed-tools: Bash, Read +model: sonnet +--- + +# Build Backstage Server + +Build, validate, and run the `backstage-server` subproject locally for inspection. Stop immediately on any failure and report the error. + +## Workflow + +Run each step sequentially from the `backstage-server/` directory. If any step fails, stop and report the failure clearly. + +1. **Install dependencies**: `cd backstage-server && bun install` +2. **Lint**: `cd backstage-server && bun run lint` +3. **Typecheck**: `cd backstage-server && bun run typecheck` +4. **Test**: `cd backstage-server && bun test` +5. **Build**: `cd backstage-server && bun run build` (builds frontend, embeds assets, produces standalone binary at `dist/backstage-server`) +6. **Verify binary**: `ls -lh backstage-server/dist/backstage-server` (confirm binary exists and report its size) +7. **Run dev server**: `cd backstage-server && bun run dev` (run in background with hot-reload on port 7007) +8. **Report**: Confirm the server is running at http://localhost:7007. Note that it proxies to the Operator API at :7008. + +## Notes + +- If port 7007 is already in use, report the conflict and suggest killing the existing process. +- To stop the dev server later, kill the background Bun process. diff --git a/.claude/commands/build-docs.md b/.claude/commands/build-docs.md new file mode 100644 index 0000000..39d622c --- /dev/null +++ b/.claude/commands/build-docs.md @@ -0,0 +1,23 @@ +--- +description: Build and serve the docs site locally with Jekyll +allowed-tools: Bash, Read +model: sonnet +--- + +# Build Docs + +Build and serve the `docs/` subproject locally for inspection. Stop immediately on any failure and report the error. + +## Workflow + +Run each step sequentially from the `docs/` directory. If any step fails, stop and report the failure clearly. + +1. **Install dependencies**: `cd docs && bundle install` +2. **Build site**: `cd docs && bundle exec jekyll build` +3. **Serve locally**: `cd docs && bundle exec jekyll serve` (run in background so the session remains interactive; serves on port 4000) +4. **Report**: Confirm the site is running at http://localhost:4000. Let the user know it auto-rebuilds on file changes. + +## Notes + +- If port 4000 is already in use, report the conflict and suggest killing the existing process or using `--port` to pick a different one. +- To stop the server later, kill the background Jekyll process. diff --git a/.claude/commands/build-vscode-extension.md b/.claude/commands/build-vscode-extension.md new file mode 100644 index 0000000..b61f251 --- /dev/null +++ b/.claude/commands/build-vscode-extension.md @@ -0,0 +1,28 @@ +--- +description: Build, lint, test, package, and install the VS Code extension locally +allowed-tools: Bash, Read +model: sonnet +--- + +# Build VS Code Extension + +Build, validate, package, and install the `vscode-extension` subproject for local inspection. Stop immediately on any failure and report the error. + +## Workflow + +Run each step sequentially from the `vscode-extension/` directory. If any step fails, stop and report the failure clearly. + +1. **Install dependencies**: `cd vscode-extension && npm install` +2. **Lint**: `cd vscode-extension && npm run lint` +3. **Compile**: `cd vscode-extension && npm run compile` +4. **Test**: `cd vscode-extension && npm test` (note: requires a display environment for @vscode/test-electron; if tests fail due to missing display, report it and continue) +5. **Package**: `cd vscode-extension && npm run package` (creates a `.vsix` file via vsce) +6. **Detect version**: `cd vscode-extension && node -p "require('./package.json').version"` +7. **Install extension**: `cd vscode-extension && code --install-extension ./operator-terminals-VERSION.vsix` (substitute the detected version) +8. **Report**: Confirm the extension was installed successfully. Remind the user they must reload their VS Code window (`Developer: Reload Window` from the command palette) for changes to take effect. + +## Notes + +- The `npm run compile` step runs `copy-types` then `tsc`. +- The `.vsix` filename follows the pattern `operator-terminals-VERSION.vsix`. +- If `code` CLI is not on PATH, suggest the user install it via VS Code command palette: "Shell Command: Install 'code' command in PATH". diff --git a/plugins/plugin-issuetypes/src/components/IssueTypeFormPage.tsx b/plugins/plugin-issuetypes/src/components/IssueTypeFormPage.tsx index 2ac65ed..0ffcc9e 100644 --- a/plugins/plugin-issuetypes/src/components/IssueTypeFormPage.tsx +++ b/plugins/plugin-issuetypes/src/components/IssueTypeFormPage.tsx @@ -142,7 +142,7 @@ function StepEditor({ - Permission Mode + Permission Mode setMode(e.target.value as ExecutionMode)} diff --git a/plugins/plugin-issuetypes/src/components/IssueTypesPage.tsx b/plugins/plugin-issuetypes/src/components/IssueTypesPage.tsx index e46bc43..8557a18 100644 --- a/plugins/plugin-issuetypes/src/components/IssueTypesPage.tsx +++ b/plugins/plugin-issuetypes/src/components/IssueTypesPage.tsx @@ -165,7 +165,7 @@ export function IssueTypesPage() { - Source + Source onUpdate('git', 'provider', e.target.value)} + > + GitHub + GitLab + Bitbucket + Azure DevOps + + + + onUpdate('git.github', 'enabled', e.target.checked)} + /> + } + label="GitHub integration enabled" + /> + + onUpdate('git.github', 'token_env', e.target.value)} + placeholder="GITHUB_TOKEN" + helperText="Name of the environment variable containing your GitHub personal access token" + disabled={!githubEnabled} + /> + + onUpdate('git', 'branch_format', e.target.value)} + placeholder="{type}/{ticket_id}-{slug}" + helperText="Template for branch names. Variables: {type}, {ticket_id}, {slug}" + /> + + onUpdate('git', 'use_worktrees', e.target.checked)} + /> + } + label="Use git worktrees for parallel agent branches" + /> + + + + Projects + + {projects.length > 0 ? ( + + {projects.map((project) => ( + + ))} + + ) : ( + + No projects configured. Set a working directory to discover projects. + + )} + + + + ); +} diff --git a/vscode-extension/webview-ui/components/sections/KanbanProvidersSection.tsx b/vscode-extension/webview-ui/components/sections/KanbanProvidersSection.tsx new file mode 100644 index 0000000..3b9fa9f --- /dev/null +++ b/vscode-extension/webview-ui/components/sections/KanbanProvidersSection.tsx @@ -0,0 +1,332 @@ +import React, { useState } from 'react'; +import Box from '@mui/material/Box'; +import TextField from '@mui/material/TextField'; +import Button from '@mui/material/Button'; +import Switch from '@mui/material/Switch'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Typography from '@mui/material/Typography'; +import Alert from '@mui/material/Alert'; +import Link from '@mui/material/Link'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import CircularProgress from '@mui/material/CircularProgress'; +import { SectionHeader } from '../SectionHeader'; +import type { JiraValidationInfo, LinearValidationInfo } from '../../types/messages'; + +interface KanbanProvidersSectionProps { + kanban: Record; + onUpdate: (section: string, key: string, value: unknown) => void; + onValidateJira: (domain: string, email: string, apiToken: string) => void; + onValidateLinear: (apiKey: string) => void; + jiraResult: JiraValidationInfo | null; + linearResult: LinearValidationInfo | null; + validatingJira: boolean; + validatingLinear: boolean; +} + +/** Extract first entry from a domain-keyed map */ +function firstEntry(map: Record): [string, Record] { + const keys = Object.keys(map); + if (keys.length === 0) { + return ['', {}]; + } + return [keys[0], (map[keys[0]] ?? {}) as Record]; +} + +/** Extract first project from projects sub-map */ +function firstProject(ws: Record): [string, Record] { + const projects = (ws.projects ?? {}) as Record; + const keys = Object.keys(projects); + if (keys.length === 0) { + return ['', {}]; + } + return [keys[0], (projects[keys[0]] ?? {}) as Record]; +} + +export function KanbanProvidersSection({ + kanban, + onUpdate, + onValidateJira, + onValidateLinear, + jiraResult, + linearResult, + validatingJira, + validatingLinear, +}: KanbanProvidersSectionProps) { + const jiraMap = (kanban.jira ?? {}) as Record; + const linearMap = (kanban.linear ?? {}) as Record; + + const [jiraDomain, jiraWs] = firstEntry(jiraMap); + const [jiraProjectKey, jiraProject] = firstProject(jiraWs); + const jiraEnabled = (jiraWs.enabled as boolean) ?? false; + const jiraEmail = (jiraWs.email as string) ?? ''; + const jiraApiKeyEnv = (jiraWs.api_key_env as string) ?? 'OPERATOR_JIRA_API_KEY'; + + const [linearTeamId, linearWs] = firstEntry(linearMap); + const [, linearProject] = firstProject(linearWs); + const linearEnabled = (linearWs.enabled as boolean) ?? false; + const linearApiKeyEnv = (linearWs.api_key_env as string) ?? 'OPERATOR_LINEAR_API_KEY'; + + const [jiraApiToken, setJiraApiToken] = useState(''); + const [linearApiKey, setLinearApiKey] = useState(''); + + return ( + + + + Configure kanban board integrations for ticket management. For more details see the kanban documentation + + + + {/* Jira Cloud */} + + + + + Jira Cloud + + + onUpdate('kanban.jira', 'enabled', e.target.checked) + } + size="small" + /> + } + label="Enabled" + /> + + + + onUpdate('kanban.jira', 'domain', e.target.value)} + placeholder="your-org.atlassian.net" + disabled={!jiraEnabled} + helperText="Your Jira Cloud instance domain (e.g. your-org.atlassian.net)" + /> + + onUpdate('kanban.jira', 'email', e.target.value)} + placeholder="you@example.com" + disabled={!jiraEnabled} + helperText="Email address associated with your Jira account" + /> + + onUpdate('kanban.jira', 'api_key_env', e.target.value)} + placeholder="OPERATOR_JIRA_API_KEY" + disabled={!jiraEnabled} + helperText="Name of the environment variable containing your Jira API token" + /> + + onUpdate('kanban.jira', 'project_key', e.target.value)} + placeholder="PROJ" + disabled={!jiraEnabled} + helperText="Jira project key to sync issues from (e.g. PROJ)" + /> + + { + const statuses = e.target.value + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + onUpdate('kanban.jira', 'sync_statuses', statuses); + }} + placeholder="To Do, In Progress" + disabled={!jiraEnabled} + helperText="Workflow statuses to sync (comma-separated, e.g., To Do, In Progress)" + /> + + onUpdate('kanban.jira', 'collection_name', e.target.value)} + placeholder="dev_kanban" + disabled={!jiraEnabled} + helperText="IssueType collection this project maps to" + /> + + + + Validate Connection + + + setJiraApiToken(e.target.value)} + placeholder="Paste token to validate" + disabled={!jiraEnabled} + sx={{ flexGrow: 1 }} + helperText="Paste your Jira API token to test the connection" + /> + + + + + {jiraResult && ( + + {jiraResult.valid + ? `Authenticated as ${jiraResult.displayName} (${jiraResult.accountId})` + : jiraResult.error} + + )} + + + + + {/* Linear */} + + + + + Linear + + + onUpdate('kanban.linear', 'enabled', e.target.checked) + } + size="small" + /> + } + label="Enabled" + /> + + + + onUpdate('kanban.linear', 'team_id', e.target.value)} + placeholder="Team identifier" + disabled={!linearEnabled} + helperText="Linear team identifier to sync issues from" + /> + + onUpdate('kanban.linear', 'api_key_env', e.target.value)} + placeholder="OPERATOR_LINEAR_API_KEY" + disabled={!linearEnabled} + helperText="Name of the environment variable containing your Linear API key" + /> + + { + const statuses = e.target.value + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + onUpdate('kanban.linear', 'sync_statuses', statuses); + }} + placeholder="To Do, In Progress" + disabled={!linearEnabled} + helperText="Workflow statuses to sync (comma-separated, e.g., To Do, In Progress)" + /> + + onUpdate('kanban.linear', 'collection_name', e.target.value)} + placeholder="dev_kanban" + disabled={!linearEnabled} + helperText="IssueType collection this project maps to" + /> + + + + Validate Connection + + + setLinearApiKey(e.target.value)} + placeholder="lin_api_xxxxx" + disabled={!linearEnabled} + sx={{ flexGrow: 1 }} + helperText="Paste your Linear API key to test the connection" + /> + + + + + {linearResult && ( + + {linearResult.valid + ? `Authenticated as ${linearResult.userName} in ${linearResult.orgName}` + : linearResult.error} + + )} + + + + + + ); +} diff --git a/vscode-extension/webview-ui/components/sections/PrimaryConfigSection.tsx b/vscode-extension/webview-ui/components/sections/PrimaryConfigSection.tsx new file mode 100644 index 0000000..547ce04 --- /dev/null +++ b/vscode-extension/webview-ui/components/sections/PrimaryConfigSection.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import TextField from '@mui/material/TextField'; +import Button from '@mui/material/Button'; +import FormControl from '@mui/material/FormControl'; +import InputLabel from '@mui/material/InputLabel'; +import Select from '@mui/material/Select'; +import MenuItem from '@mui/material/MenuItem'; +import Typography from '@mui/material/Typography'; +import { SectionHeader } from '../SectionHeader'; +import Link from '@mui/material/Link'; +import { OperatorBrand } from '../OperatorBrand'; + +interface PrimaryConfigSectionProps { + working_directory: string; + sessions_wrapper: string; + onUpdate: (section: string, key: string, value: unknown) => void; + onBrowseFolder: (field: string) => void; +} + +export function PrimaryConfigSection({ + working_directory, + sessions_wrapper, + onUpdate, + onBrowseFolder, +}: PrimaryConfigSectionProps) { + return ( + + + + These are settings for Operator! configuration for the VS Code extension. For more details see the configuration documentation + + + + + Working Directory + + + + onUpdate('primary', 'working_directory', e.target.value) + } + placeholder="/path/to/your/repos" + helperText="Parent directory of Operator! managed code repositories containing .tickets/ working directory" + /> + + + + + + Session Wrapper + + + Designates how launched ticket work is wrapped when started from VS Code + + + + ); +} diff --git a/vscode-extension/webview-ui/index.tsx b/vscode-extension/webview-ui/index.tsx new file mode 100644 index 0000000..f33ce58 --- /dev/null +++ b/vscode-extension/webview-ui/index.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App'; + +const container = document.getElementById('root'); +if (container) { + const root = createRoot(container); + root.render(); +} diff --git a/vscode-extension/webview-ui/theme/ThemeWrapper.tsx b/vscode-extension/webview-ui/theme/ThemeWrapper.tsx new file mode 100644 index 0000000..c0155af --- /dev/null +++ b/vscode-extension/webview-ui/theme/ThemeWrapper.tsx @@ -0,0 +1,36 @@ +import React, { useEffect, useState, type ReactNode } from 'react'; +import { ThemeProvider, type Theme } from '@mui/material/styles'; +import CssBaseline from '@mui/material/CssBaseline'; +import { computeStyles } from './computeStyles'; +import { createVSCodeTheme } from './createVSCodeTheme'; + +interface ThemeWrapperProps { + children: ReactNode; +} + +export function ThemeWrapper({ children }: ThemeWrapperProps) { + const [theme, setTheme] = useState(() => + createVSCodeTheme(computeStyles()) + ); + + useEffect(() => { + // Re-compute theme when VS Code changes theme (body class changes) + const observer = new MutationObserver(() => { + setTheme(createVSCodeTheme(computeStyles())); + }); + + observer.observe(document.body, { + attributes: true, + attributeFilter: ['class'], + }); + + return () => observer.disconnect(); + }, []); + + return ( + + + {children} + + ); +} diff --git a/vscode-extension/webview-ui/theme/computeStyles.ts b/vscode-extension/webview-ui/theme/computeStyles.ts new file mode 100644 index 0000000..95db6f2 --- /dev/null +++ b/vscode-extension/webview-ui/theme/computeStyles.ts @@ -0,0 +1,106 @@ +/** Operator! brand palette (from docs/assets/css/main.css) */ +export const OPERATOR_BRAND = { + terracotta: '#E05D44', + terracottaLight: '#F0796A', + terracottaDark: '#C24A35', + cream: '#F2EAC9', + sage: '#66AA99', + teal: '#448880', + deepPine: '#115566', + cornflower: '#6688AA', +} as const; + +/** VS Code CSS variable values extracted from the webview DOM */ +export interface VSCodeStyles { + // Background & foreground + background: string; + foreground: string; + focusBorder: string; + + // Button + buttonBackground: string; + buttonForeground: string; + buttonHoverBackground: string; + buttonSecondaryBackground: string; + buttonSecondaryForeground: string; + + // Input + inputBackground: string; + inputForeground: string; + inputBorder: string; + inputPlaceholderForeground: string; + + // Sidebar / Panel + sideBarBackground: string; + sideBarForeground: string; + sideBarBorder: string; + + // List + listActiveSelectionBackground: string; + listActiveSelectionForeground: string; + listHoverBackground: string; + + // Text + textLinkForeground: string; + textLinkActiveForeground: string; + descriptionForeground: string; + + // Badge + badgeBackground: string; + badgeForeground: string; + + // Error / Warning + errorForeground: string; + notificationsWarningIconForeground: string; + + // Font + fontFamily: string; + fontSize: string; +} + +function cssVar(style: CSSStyleDeclaration, name: string, fallback: string): string { + return style.getPropertyValue(name).trim() || fallback; +} + +/** Read ~25 VS Code CSS variables from the webview body */ +export function computeStyles(): VSCodeStyles { + const style = getComputedStyle(document.body); + + return { + background: cssVar(style, '--vscode-editor-background', '#1e1e1e'), + foreground: cssVar(style, '--vscode-editor-foreground', '#cccccc'), + focusBorder: cssVar(style, '--vscode-focusBorder', '#007fd4'), + + buttonBackground: cssVar(style, '--vscode-button-background', '#0e639c'), + buttonForeground: cssVar(style, '--vscode-button-foreground', '#ffffff'), + buttonHoverBackground: cssVar(style, '--vscode-button-hoverBackground', '#1177bb'), + buttonSecondaryBackground: cssVar(style, '--vscode-button-secondaryBackground', '#3a3d41'), + buttonSecondaryForeground: cssVar(style, '--vscode-button-secondaryForeground', '#cccccc'), + + inputBackground: cssVar(style, '--vscode-input-background', '#3c3c3c'), + inputForeground: cssVar(style, '--vscode-input-foreground', '#cccccc'), + inputBorder: cssVar(style, '--vscode-input-border', '#3c3c3c'), + inputPlaceholderForeground: cssVar(style, '--vscode-input-placeholderForeground', '#a6a6a6'), + + sideBarBackground: cssVar(style, '--vscode-sideBar-background', '#252526'), + sideBarForeground: cssVar(style, '--vscode-sideBar-foreground', '#cccccc'), + sideBarBorder: cssVar(style, '--vscode-sideBar-border', '#252526'), + + listActiveSelectionBackground: cssVar(style, '--vscode-list-activeSelectionBackground', '#094771'), + listActiveSelectionForeground: cssVar(style, '--vscode-list-activeSelectionForeground', '#ffffff'), + listHoverBackground: cssVar(style, '--vscode-list-hoverBackground', '#2a2d2e'), + + textLinkForeground: cssVar(style, '--vscode-textLink-foreground', '#3794ff'), + textLinkActiveForeground: cssVar(style, '--vscode-textLink-activeForeground', '#3794ff'), + descriptionForeground: cssVar(style, '--vscode-descriptionForeground', '#a0a0a0'), + + badgeBackground: cssVar(style, '--vscode-badge-background', '#4d4d4d'), + badgeForeground: cssVar(style, '--vscode-badge-foreground', '#ffffff'), + + errorForeground: cssVar(style, '--vscode-errorForeground', '#f48771'), + notificationsWarningIconForeground: cssVar(style, '--vscode-notificationsWarningIcon-foreground', '#cca700'), + + fontFamily: cssVar(style, '--vscode-font-family', "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"), + fontSize: cssVar(style, '--vscode-font-size', '13px'), + }; +} diff --git a/vscode-extension/webview-ui/theme/createVSCodeTheme.ts b/vscode-extension/webview-ui/theme/createVSCodeTheme.ts new file mode 100644 index 0000000..de23d96 --- /dev/null +++ b/vscode-extension/webview-ui/theme/createVSCodeTheme.ts @@ -0,0 +1,201 @@ +import { createTheme, type Theme } from '@mui/material/styles'; +import { OPERATOR_BRAND, type VSCodeStyles } from './computeStyles'; + +/** Detect if VS Code is using a dark theme based on body class */ +function isDarkTheme(): boolean { + return document.body.classList.contains('vscode-dark') || + document.body.classList.contains('vscode-high-contrast'); +} + +/** Create an MUI theme that matches the current VS Code color scheme */ +export function createVSCodeTheme(styles: VSCodeStyles): Theme { + const dark = isDarkTheme(); + + return createTheme({ + palette: { + mode: dark ? 'dark' : 'light', + primary: { + main: styles.buttonBackground, + contrastText: styles.buttonForeground, + }, + secondary: { + main: styles.buttonSecondaryBackground, + contrastText: styles.buttonSecondaryForeground, + }, + background: { + default: styles.background, + paper: styles.sideBarBackground, + }, + text: { + primary: styles.foreground, + secondary: styles.descriptionForeground, + }, + error: { + main: styles.errorForeground, + }, + warning: { + main: styles.notificationsWarningIconForeground, + }, + divider: styles.sideBarBorder, + }, + typography: { + fontFamily: styles.fontFamily, + fontSize: parseInt(styles.fontSize, 10) || 13, + h6: { + fontSize: '1.1rem', + fontWeight: 600, + }, + body1: { + fontSize: '0.9rem', + }, + body2: { + fontSize: '0.8rem', + }, + }, + components: { + MuiButtonBase: { + defaultProps: { + disableRipple: true, + }, + }, + MuiCssBaseline: { + styleOverrides: { + body: { + backgroundColor: styles.background, + color: styles.foreground, + }, + }, + }, + MuiButton: { + styleOverrides: { + root: { + textTransform: 'none', + borderRadius: 2, + }, + contained: { + backgroundColor: styles.buttonBackground, + color: styles.buttonForeground, + '&:hover': { + backgroundColor: styles.buttonHoverBackground, + }, + }, + outlined: { + borderColor: styles.inputBorder, + color: styles.foreground, + '&:hover': { + borderColor: styles.focusBorder, + backgroundColor: 'transparent', + }, + }, + }, + }, + MuiTextField: { + defaultProps: { + margin: 'dense', + }, + }, + MuiOutlinedInput: { + styleOverrides: { + root: { + backgroundColor: styles.inputBackground, + color: styles.inputForeground, + '& .MuiOutlinedInput-notchedOutline': { + borderColor: styles.inputBorder, + }, + '&:hover .MuiOutlinedInput-notchedOutline': { + borderColor: styles.focusBorder, + }, + '&.Mui-focused .MuiOutlinedInput-notchedOutline': { + borderColor: styles.focusBorder, + }, + }, + input: { + '&::placeholder': { + color: styles.inputPlaceholderForeground, + opacity: 1, + }, + }, + }, + }, + MuiInputLabel: { + styleOverrides: { + root: { + color: styles.descriptionForeground, + '&.Mui-focused': { + color: styles.focusBorder, + }, + }, + }, + }, + MuiLink: { + styleOverrides: { + root: { + color: styles.textLinkForeground, + '&:hover': { + color: styles.textLinkActiveForeground, + }, + }, + }, + }, + MuiListItemButton: { + styleOverrides: { + root: { + '&.Mui-selected': { + backgroundColor: styles.listActiveSelectionBackground, + color: styles.listActiveSelectionForeground, + '&:hover': { + backgroundColor: styles.listActiveSelectionBackground, + }, + }, + '&:hover': { + backgroundColor: styles.listHoverBackground, + }, + }, + }, + }, + MuiPaper: { + styleOverrides: { + root: { + backgroundImage: 'none', + }, + }, + }, + MuiSelect: { + styleOverrides: { + icon: { + color: styles.foreground, + }, + }, + }, + MuiSwitch: { + styleOverrides: { + switchBase: { + '&.Mui-checked': { + color: OPERATOR_BRAND.terracotta, + '& + .MuiSwitch-track': { + backgroundColor: OPERATOR_BRAND.terracotta, + }, + }, + }, + }, + }, + MuiCard: { + styleOverrides: { + root: { + borderColor: dark + ? `${OPERATOR_BRAND.terracotta}40` + : `${OPERATOR_BRAND.terracotta}25`, + }, + }, + }, + MuiChip: { + styleOverrides: { + root: { + backgroundColor: styles.badgeBackground, + color: styles.badgeForeground, + }, + }, + }, + }, + }); +} diff --git a/vscode-extension/webview-ui/types/defaults.ts b/vscode-extension/webview-ui/types/defaults.ts new file mode 100644 index 0000000..3bfcb17 --- /dev/null +++ b/vscode-extension/webview-ui/types/defaults.ts @@ -0,0 +1,138 @@ +import type { WebviewConfig } from './messages'; +import type { Config } from '../../src/generated/Config'; + +/** Sensible defaults matching Rust Config::default() */ +const DEFAULT_CONFIG: Config = { + projects: [], + agents: { + max_parallel: 2, + cores_reserved: 1, + health_check_interval: BigInt(30), + generation_timeout_secs: BigInt(300), + sync_interval: BigInt(60), + step_timeout: BigInt(1800), + silence_threshold: BigInt(30), + }, + notifications: { + enabled: true, + os: { enabled: true, sound: false, events: [] }, + webhook: null, + webhooks: [], + }, + queue: { + auto_assign: true, + priority_order: ['INV', 'FIX', 'FEAT', 'SPIKE'], + poll_interval_ms: BigInt(2000), + }, + paths: { + tickets: '.tickets', + projects: '.', + state: '.tickets/operator', + worktrees: '.worktrees', + }, + ui: { + refresh_rate_ms: BigInt(1000), + completed_history_hours: BigInt(24), + summary_max_length: 80, + panel_names: { + queue: 'Queue', + agents: 'Agents', + awaiting: 'Awaiting', + completed: 'Completed', + }, + }, + launch: { + confirm_autonomous: false, + confirm_paired: true, + launch_delay_ms: BigInt(500), + docker: { + enabled: false, + image: '', + extra_args: [], + mount_path: '/workspace', + env_vars: [], + }, + yolo: { enabled: false }, + }, + templates: { + preset: 'dev_kanban', + collection: [], + active_collection: null, + }, + api: { + pr_check_interval_secs: BigInt(300), + rate_limit_check_interval_secs: BigInt(60), + rate_limit_warning_threshold: 80, + }, + logging: { + level: 'info', + to_file: false, + }, + tmux: { + config_generated: false, + }, + sessions: { + wrapper: 'vscode', + tmux: { + config_generated: false, + socket_name: 'operator', + }, + vscode: { + webhook_port: 7007, + connect_timeout_ms: BigInt(5000), + }, + }, + llm_tools: { + detected: [], + providers: [], + detection_complete: false, + }, + backstage: { + enabled: false, + port: 7009, + auto_start: false, + subpath: '/backstage', + branding_subpath: '/branding', + release_url: '', + local_binary_path: null, + branding: { + app_title: 'Operator', + org_name: '', + logo_path: null, + colors: { + primary: '#4f46e5', + secondary: '#7c3aed', + accent: '#06b6d4', + warning: '#f59e0b', + muted: '#6b7280', + }, + }, + }, + rest_api: { + enabled: false, + port: 7008, + cors_origins: [], + }, + git: { + provider: null, + github: { enabled: true, token_env: 'GITHUB_TOKEN' }, + gitlab: { enabled: false, token_env: 'GITLAB_TOKEN', host: null }, + branch_format: '{type}/{ticket_id}-{slug}', + use_worktrees: false, + }, + kanban: { + jira: {}, + linear: {}, + }, + version_check: { + enabled: true, + url: null, + timeout_secs: BigInt(10), + }, +}; + +export const DEFAULT_WEBVIEW_CONFIG: WebviewConfig = { + config_path: '', + working_directory: '', + config: DEFAULT_CONFIG, +}; diff --git a/vscode-extension/webview-ui/types/messages.ts b/vscode-extension/webview-ui/types/messages.ts new file mode 100644 index 0000000..9983982 --- /dev/null +++ b/vscode-extension/webview-ui/types/messages.ts @@ -0,0 +1,48 @@ +import type { Config } from '../../src/generated/Config'; + +/** Wrapper that pairs the generated Config with extension metadata */ +export interface WebviewConfig { + config_path: string; + working_directory: string; + config: Config; +} + +/** Messages from the webview to the extension host */ +export type WebviewToExtensionMessage = + | { type: 'ready' } + | { type: 'getConfig' } + | { type: 'updateConfig'; section: string; key: string; value: unknown } + | { type: 'browseFile'; field: string } + | { type: 'browseFolder'; field: string } + | { type: 'validateJira'; domain: string; email: string; apiToken: string } + | { type: 'validateLinear'; apiKey: string } + | { type: 'detectLlmTools' } + | { type: 'openExternal'; url: string } + | { type: 'openFile'; filePath: string }; + +/** Messages from the extension host to the webview */ +export type ExtensionToWebviewMessage = + | { type: 'configLoaded'; config: WebviewConfig } + | { type: 'configUpdated'; config: WebviewConfig } + | { type: 'configError'; error: string } + | { type: 'browseResult'; field: string; path: string } + | { type: 'jiraValidationResult'; result: JiraValidationInfo } + | { type: 'linearValidationResult'; result: LinearValidationInfo } + | { type: 'llmToolsDetected'; config: WebviewConfig }; + +export interface JiraValidationInfo { + valid: boolean; + displayName: string; + accountId: string; + error?: string; + projects?: Array<{ key: string; name: string }>; +} + +export interface LinearValidationInfo { + valid: boolean; + userName: string; + orgName: string; + userId: string; + error?: string; + teams?: Array<{ id: string; name: string; key: string }>; +} diff --git a/vscode-extension/webview-ui/vscodeApi.ts b/vscode-extension/webview-ui/vscodeApi.ts new file mode 100644 index 0000000..eed66e2 --- /dev/null +++ b/vscode-extension/webview-ui/vscodeApi.ts @@ -0,0 +1,32 @@ +import type { WebviewToExtensionMessage, ExtensionToWebviewMessage } from './types/messages'; + +interface VSCodeApi { + postMessage(message: WebviewToExtensionMessage): void; + getState(): unknown; + setState(state: unknown): void; +} + +declare function acquireVsCodeApi(): VSCodeApi; + +let _api: VSCodeApi | undefined; + +function getApi(): VSCodeApi { + if (!_api) { + _api = acquireVsCodeApi(); + } + return _api; +} + +export function postMessage(message: WebviewToExtensionMessage): void { + getApi().postMessage(message); +} + +export function onMessage( + handler: (message: ExtensionToWebviewMessage) => void +): () => void { + const listener = (event: MessageEvent) => { + handler(event.data); + }; + window.addEventListener('message', listener); + return () => window.removeEventListener('message', listener); +} From afe95c3056fd5261a78fdcbf004b9e3fc9b7e4fd Mon Sep 17 00:00:00 2001 From: untra Date: Tue, 17 Feb 2026 08:42:08 -0700 Subject: [PATCH 2/4] operator settings unlock with added configuration --- bindings/AssessTicketResponse.ts | 18 ++ bindings/ProjectSummary.ts | 74 ++++++ src/rest/dto.rs | 61 +++++ src/rest/mod.rs | 6 + src/rest/routes/mod.rs | 1 + src/rest/routes/projects.rs | 214 ++++++++++++++++++ vscode-extension/.eslintrc.json | 2 +- vscode-extension/package.json | 2 +- vscode-extension/src/api-client.ts | 69 ++++++ vscode-extension/src/config-panel.ts | 65 ++++++ vscode-extension/src/extension.ts | 34 +-- vscode-extension/webview-ui/.eslintrc.json | 29 +++ vscode-extension/webview-ui/App.tsx | 138 ++++++++--- .../webview-ui/components/ConfigPage.tsx | 56 +++-- .../webview-ui/components/SidebarNav.tsx | 12 +- .../sections/CodingAgentsSection.tsx | 29 +-- .../sections/GitRepositoriesSection.tsx | 35 +-- .../sections/KanbanProvidersSection.tsx | 48 ++-- .../sections/PrimaryConfigSection.tsx | 2 +- .../components/sections/ProjectsSection.tsx | 153 +++++++++++++ vscode-extension/webview-ui/types/messages.ts | 34 ++- 21 files changed, 931 insertions(+), 151 deletions(-) create mode 100644 bindings/AssessTicketResponse.ts create mode 100644 bindings/ProjectSummary.ts create mode 100644 src/rest/routes/projects.rs create mode 100644 vscode-extension/webview-ui/.eslintrc.json create mode 100644 vscode-extension/webview-ui/components/sections/ProjectsSection.tsx diff --git a/bindings/AssessTicketResponse.ts b/bindings/AssessTicketResponse.ts new file mode 100644 index 0000000..e0fa8e2 --- /dev/null +++ b/bindings/AssessTicketResponse.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 creating an ASSESS ticket + */ +export type AssessTicketResponse = { +/** + * Ticket ID (e.g., "ASSESS-1234") + */ +ticket_id: string, +/** + * Path to the created ticket file + */ +ticket_path: string, +/** + * Project name that was assessed + */ +project_name: string, }; diff --git a/bindings/ProjectSummary.ts b/bindings/ProjectSummary.ts new file mode 100644 index 0000000..55867b8 --- /dev/null +++ b/bindings/ProjectSummary.ts @@ -0,0 +1,74 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Summary of a project with analysis data + */ +export type ProjectSummary = { +/** + * Project directory name + */ +project_name: string, +/** + * Absolute path to project root + */ +project_path: string, +/** + * Whether the project directory exists on disk + */ +exists: boolean, +/** + * Whether catalog-info.yaml exists + */ +has_catalog_info: boolean, +/** + * Whether project-context.json exists + */ +has_project_context: boolean, +/** + * Primary Kind from kind_assessment (e.g., "microservice") + */ +kind: string | null, +/** + * Kind confidence score 0.0-1.0 + */ +kind_confidence: number | null, +/** + * Taxonomy tier (e.g., "engines") + */ +kind_tier: string | null, +/** + * Language display names + */ +languages: Array, +/** + * Framework display names + */ +frameworks: Array, +/** + * Database display names + */ +databases: Array, +/** + * Has Dockerfile or docker-compose + */ +has_docker: boolean | null, +/** + * Has test frameworks detected + */ +has_tests: boolean | null, +/** + * Detected port numbers + */ +ports: Array, +/** + * Number of environment variables + */ +env_var_count: number, +/** + * Number of entry points + */ +entry_point_count: number, +/** + * Available command names (start, dev, test, etc.) + */ +commands: Array, }; diff --git a/src/rest/dto.rs b/src/rest/dto.rs index 31a3cf7..66190a0 100644 --- a/src/rest/dto.rs +++ b/src/rest/dto.rs @@ -820,6 +820,67 @@ pub struct RejectReviewRequest { pub reason: String, } +// ============================================================================= +// Project DTOs +// ============================================================================= + +/// Summary of a project with analysis data +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ProjectSummary { + /// Project directory name + pub project_name: String, + /// Absolute path to project root + pub project_path: String, + /// Whether the project directory exists on disk + pub exists: bool, + /// Whether catalog-info.yaml exists + pub has_catalog_info: bool, + /// Whether project-context.json exists + pub has_project_context: bool, + /// Primary Kind from kind_assessment (e.g., "microservice") + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, + /// Kind confidence score 0.0-1.0 + #[serde(skip_serializing_if = "Option::is_none")] + pub kind_confidence: Option, + /// Taxonomy tier (e.g., "engines") + #[serde(skip_serializing_if = "Option::is_none")] + pub kind_tier: Option, + /// Language display names + pub languages: Vec, + /// Framework display names + pub frameworks: Vec, + /// Database display names + pub databases: Vec, + /// Has Dockerfile or docker-compose + #[serde(skip_serializing_if = "Option::is_none")] + pub has_docker: Option, + /// Has test frameworks detected + #[serde(skip_serializing_if = "Option::is_none")] + pub has_tests: Option, + /// Detected port numbers + pub ports: Vec, + /// Number of environment variables + pub env_var_count: usize, + /// Number of entry points + pub entry_point_count: usize, + /// Available command names (start, dev, test, etc.) + pub commands: Vec, +} + +/// Response from creating an ASSESS ticket +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct AssessTicketResponse { + /// Ticket ID (e.g., "ASSESS-1234") + pub ticket_id: String, + /// Path to the created ticket file + pub ticket_path: String, + /// Project name that was assessed + pub project_name: String, +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/rest/mod.rs b/src/rest/mod.rs index 44995a3..7996399 100644 --- a/src/rest/mod.rs +++ b/src/rest/mod.rs @@ -93,6 +93,12 @@ pub fn build_router(state: ApiState) -> Router { "/api/v1/agents/:agent_id/reject", post(routes::agents::reject_review), ) + // Project endpoints + .route("/api/v1/projects", get(routes::projects::list)) + .route( + "/api/v1/projects/:name/assess", + post(routes::projects::assess), + ) // Launch endpoints .route( "/api/v1/tickets/:id/launch", diff --git a/src/rest/routes/mod.rs b/src/rest/routes/mod.rs index 36add8c..dfb760f 100644 --- a/src/rest/routes/mod.rs +++ b/src/rest/routes/mod.rs @@ -5,5 +5,6 @@ pub mod collections; pub mod health; pub mod issuetypes; pub mod launch; +pub mod projects; pub mod queue; pub mod steps; diff --git a/src/rest/routes/projects.rs b/src/rest/routes/projects.rs new file mode 100644 index 0000000..93650fc --- /dev/null +++ b/src/rest/routes/projects.rs @@ -0,0 +1,214 @@ +//! Project listing and assessment endpoints. + +use axum::{ + extract::{Path, State}, + Json, +}; + +use crate::backstage::analyzer::ProjectAnalysis; +use crate::queue::creator::{render_template, TicketCreator}; +use crate::rest::dto::{AssessTicketResponse, ProjectSummary}; +use crate::rest::error::ApiError; +use crate::rest::state::ApiState; +use crate::templates::TemplateType; + +/// List all configured projects with analysis data +#[utoipa::path( + get, + path = "/api/v1/projects", + tag = "Projects", + responses( + (status = 200, description = "List of projects with analysis data", body = Vec) + ) +)] +pub async fn list(State(state): State) -> Json> { + let config = &state.config; + let projects_path = config.projects_path(); + let project_names = &config.projects; + + let mut summaries = Vec::new(); + + for name in project_names { + let project_dir = projects_path.join(name); + let exists = project_dir.is_dir(); + let has_catalog_info = project_dir.join("catalog-info.yaml").is_file(); + let context_path = project_dir.join("project-context.json"); + let has_project_context = context_path.is_file(); + + let mut summary = ProjectSummary { + project_name: name.clone(), + project_path: project_dir.to_string_lossy().to_string(), + exists, + has_catalog_info, + has_project_context, + kind: None, + kind_confidence: None, + kind_tier: None, + languages: Vec::new(), + frameworks: Vec::new(), + databases: Vec::new(), + has_docker: None, + has_tests: None, + ports: Vec::new(), + env_var_count: 0, + entry_point_count: 0, + commands: Vec::new(), + }; + + // Try to read project-context.json for analysis data + if has_project_context { + if let Ok(content) = std::fs::read_to_string(&context_path) { + if let Ok(analysis) = serde_json::from_str::(&content) { + summary.kind = Some(analysis.kind_assessment.primary_kind); + summary.kind_confidence = Some(analysis.kind_assessment.confidence as f64); + summary.kind_tier = Some(analysis.kind_assessment.tier); + summary.languages = analysis + .languages + .iter() + .map(|l| l.display_name.clone()) + .collect(); + summary.frameworks = analysis + .frameworks + .iter() + .map(|f| f.display_name.clone()) + .collect(); + summary.databases = analysis + .databases + .iter() + .map(|d| d.display_name.clone()) + .collect(); + summary.has_docker = + Some(analysis.docker.has_dockerfile || analysis.docker.has_compose); + summary.has_tests = Some(!analysis.testing.is_empty()); + summary.ports = analysis + .ports + .iter() + .filter_map(|p| p.port_number) + .collect(); + summary.env_var_count = analysis.environment.len(); + summary.entry_point_count = analysis.entry_points.len(); + + let cmds = &analysis.commands; + let mut cmd_names = Vec::new(); + if cmds.start.is_some() { + cmd_names.push("start".to_string()); + } + if cmds.dev.is_some() { + cmd_names.push("dev".to_string()); + } + if cmds.test.is_some() { + cmd_names.push("test".to_string()); + } + if cmds.build.is_some() { + cmd_names.push("build".to_string()); + } + if cmds.lint.is_some() { + cmd_names.push("lint".to_string()); + } + if cmds.fmt.is_some() { + cmd_names.push("fmt".to_string()); + } + if cmds.typecheck.is_some() { + cmd_names.push("typecheck".to_string()); + } + summary.commands = cmd_names; + } + } + } + + summaries.push(summary); + } + + Json(summaries) +} + +/// Create an ASSESS ticket for a project +#[utoipa::path( + post, + path = "/api/v1/projects/{name}/assess", + tag = "Projects", + params( + ("name" = String, Path, description = "Project name") + ), + responses( + (status = 200, description = "ASSESS ticket created", body = AssessTicketResponse), + (status = 404, description = "Project not found") + ) +)] +pub async fn assess( + State(state): State, + Path(name): Path, +) -> Result, ApiError> { + let config = &state.config; + + // Validate project exists in config + if !config.projects.contains(&name) { + return Err(ApiError::NotFound(format!( + "Project '{}' not found in configuration", + name + ))); + } + + let creator = TicketCreator::new(config); + let template_type = TemplateType::Assess; + + // Generate default values and add summary + let mut values = creator.generate_default_values(template_type, &name); + values.insert( + "summary".to_string(), + format!("Assess {} for Backstage catalog", name), + ); + + // Render template content + let template = template_type.template_content(); + let content = render_template(template, &values)?; + + // Write ticket file directly (no editor) + let ticket_id = values + .get("id") + .cloned() + .unwrap_or_else(|| "ASSESS-0000".to_string()); + let queue_path = config.tickets_path().join("queue"); + std::fs::create_dir_all(&queue_path) + .map_err(|e| ApiError::InternalError(format!("Failed to create queue directory: {}", e)))?; + + let now = chrono::Utc::now(); + let timestamp = now.format("%Y%m%d-%H%M").to_string(); + let filename = format!("{}-ASSESS-{}-new-ticket.md", timestamp, name); + let filepath = queue_path.join(&filename); + + std::fs::write(&filepath, &content) + .map_err(|e| ApiError::InternalError(format!("Failed to write ticket file: {}", e)))?; + + Ok(Json(AssessTicketResponse { + ticket_id, + ticket_path: filepath.to_string_lossy().to_string(), + project_name: name, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use std::path::PathBuf; + + #[tokio::test] + async fn test_list_empty_projects() { + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test-projects")); + + let resp = list(State(state)).await; + // Default config has no projects + assert!(resp.0.is_empty()); + } + + #[tokio::test] + async fn test_assess_unknown_project() { + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test-projects")); + + let result = assess(State(state), Path("nonexistent".to_string())).await; + assert!(result.is_err()); + } +} diff --git a/vscode-extension/.eslintrc.json b/vscode-extension/.eslintrc.json index f32b1b7..c9433d3 100644 --- a/vscode-extension/.eslintrc.json +++ b/vscode-extension/.eslintrc.json @@ -26,5 +26,5 @@ "@typescript-eslint/semi": "warn", "semi": "off" }, - "ignorePatterns": ["out", "dist", "webview-ui", "**/*.d.ts"] + "ignorePatterns": ["out", "dist", "**/*.d.ts"] } diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 521e186..34b5dca 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -343,7 +343,7 @@ "watch": "npm run copy-types && tsc -watch -p ./", "watch:webview": "webpack --config webpack.webview.config.js --mode development --watch", "pretest": "npm run compile && npm run lint", - "lint": "eslint src --ext ts", + "lint": "eslint src --ext ts && eslint webview-ui --ext ts,tsx", "test": "vscode-test", "test:coverage": "npm run test", "package": "vsce package", diff --git a/vscode-extension/src/api-client.ts b/vscode-extension/src/api-client.ts index cf2872a..7509601 100644 --- a/vscode-extension/src/api-client.ts +++ b/vscode-extension/src/api-client.ts @@ -19,6 +19,38 @@ import type { // Re-export generated types for consumers export type { LaunchTicketResponse, HealthResponse }; +/** + * Summary of a project from the Operator REST API + */ +export interface ProjectSummary { + project_name: string; + project_path: string; + exists: boolean; + has_catalog_info: boolean; + has_project_context: boolean; + kind: string | null; + kind_confidence: number | null; + kind_tier: string | null; + languages: string[]; + frameworks: string[]; + databases: string[]; + has_docker: boolean | null; + has_tests: boolean | null; + ports: number[]; + env_var_count: number; + entry_point_count: number; + commands: string[]; +} + +/** + * Response from creating an ASSESS ticket + */ +export interface AssessTicketResponse { + ticket_id: string; + ticket_path: string; + project_name: string; +} + export interface ApiError { error: string; message: string; @@ -291,4 +323,41 @@ export class OperatorApiClient { return (await response.json()) as ReviewResponse; } + + /** + * List all configured projects with analysis data + */ + async getProjects(): Promise { + const response = await fetch(`${this.baseUrl}/api/v1/projects`); + + 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 ProjectSummary[]; + } + + /** + * Create an ASSESS ticket for a project + */ + async assessProject(name: string): Promise { + const response = await fetch( + `${this.baseUrl}/api/v1/projects/${encodeURIComponent(name)}/assess`, + { 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 AssessTicketResponse; + } } diff --git a/vscode-extension/src/config-panel.ts b/vscode-extension/src/config-panel.ts index 7f850d5..f26c961 100644 --- a/vscode-extension/src/config-panel.ts +++ b/vscode-extension/src/config-panel.ts @@ -7,6 +7,7 @@ import * as vscode from 'vscode'; import * as fs from 'fs/promises'; +import * as path from 'path'; // smol-toml is ESM-only, must use dynamic import async function importSmolToml() { return await import('smol-toml'); @@ -22,6 +23,7 @@ import { getResolvedConfigPath, resolveWorkingDirectory, } from './config-paths'; +import { OperatorApiClient, discoverApiUrl } from './api-client'; /** Message types from the webview */ interface WebviewMessage { @@ -245,6 +247,69 @@ export class ConfigPanel { break; } + case 'checkApiHealth': { + try { + const workDir = resolveWorkingDirectory(); + const ticketsDir = workDir ? path.join(workDir, '.tickets') : undefined; + const apiUrl = await discoverApiUrl(ticketsDir); + const client = new OperatorApiClient(apiUrl); + await client.health(); + this._panel.webview.postMessage({ type: 'apiHealthResult', reachable: true }); + } catch { + this._panel.webview.postMessage({ type: 'apiHealthResult', reachable: false }); + } + break; + } + + case 'getProjects': { + try { + const workDir = resolveWorkingDirectory(); + const ticketsDir = workDir ? path.join(workDir, '.tickets') : undefined; + const apiUrl = await discoverApiUrl(ticketsDir); + const client = new OperatorApiClient(apiUrl); + const projects = await client.getProjects(); + this._panel.webview.postMessage({ type: 'projectsLoaded', projects }); + } catch (err) { + this._panel.webview.postMessage({ + type: 'projectsError', + error: err instanceof Error ? err.message : 'Failed to load projects', + }); + } + break; + } + + case 'assessProject': { + const projectName = message.projectName as string; + try { + const workDir = resolveWorkingDirectory(); + const ticketsDir = workDir ? path.join(workDir, '.tickets') : undefined; + const apiUrl = await discoverApiUrl(ticketsDir); + const client = new OperatorApiClient(apiUrl); + const result = await client.assessProject(projectName); + this._panel.webview.postMessage({ + type: 'assessTicketCreated', + ticketId: result.ticket_id, + projectName: result.project_name, + }); + } catch (err) { + this._panel.webview.postMessage({ + type: 'assessTicketError', + error: err instanceof Error ? err.message : 'Failed to create ASSESS ticket', + projectName, + }); + } + break; + } + + case 'openProjectFolder': { + const projectPath = message.projectPath as string; + if (projectPath) { + const uri = vscode.Uri.file(projectPath); + await vscode.commands.executeCommand('vscode.openFolder', uri, { forceNewWindow: true }); + } + break; + } + case 'openExternal': vscode.env.openExternal(vscode.Uri.parse(message.url as string)); break; diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index eaca666..2c1efc5 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -209,11 +209,11 @@ export async function activate( ), vscode.commands.registerCommand( 'operator.addJiraProject', - (workspaceKey?: string) => addJiraProjectCommand(workspaceKey) + (workspaceKey: string) => addJiraProjectCommand(workspaceKey) ), vscode.commands.registerCommand( 'operator.addLinearTeam', - (workspaceKey?: string) => addLinearTeamCommand(workspaceKey) + (workspaceKey: string) => addLinearTeamCommand(workspaceKey) ), vscode.commands.registerCommand( 'operator.revealTicketsDir', @@ -1009,9 +1009,9 @@ async function syncKanbanCommand(): Promise { /** * Command: Approve agent review */ -async function approveReviewCommand(agentId?: string): Promise { +async function approveReviewCommand(agentId: string): Promise { const apiClient = new OperatorApiClient(); - + let selectedAgentId : string | undefined = agentId; try { await apiClient.health(); } catch { @@ -1023,14 +1023,14 @@ async function approveReviewCommand(agentId?: string): Promise { // If no agent ID provided, show picker for awaiting agents if (!agentId) { - agentId = await showAwaitingAgentPicker(apiClient); - if (!agentId) { + selectedAgentId = await showAwaitingAgentPicker(apiClient); + if (!selectedAgentId) { return; } } try { - const result = await apiClient.approveReview(agentId); + const result = await apiClient.approveReview(selectedAgentId); vscode.window.showInformationMessage(result.message); await refreshAllProviders(); } catch (err) { @@ -1042,9 +1042,9 @@ async function approveReviewCommand(agentId?: string): Promise { /** * Command: Reject agent review */ -async function rejectReviewCommand(agentId?: string): Promise { +async function rejectReviewCommand(agentId: string): Promise { const apiClient = new OperatorApiClient(); - + let selectedAgentId : string | undefined = agentId; try { await apiClient.health(); } catch { @@ -1056,8 +1056,8 @@ async function rejectReviewCommand(agentId?: string): Promise { // If no agent ID provided, show picker for awaiting agents if (!agentId) { - agentId = await showAwaitingAgentPicker(apiClient); - if (!agentId) { + selectedAgentId = await showAwaitingAgentPicker(apiClient); + if (!selectedAgentId) { return; } } @@ -1079,7 +1079,7 @@ async function rejectReviewCommand(agentId?: string): Promise { } try { - const result = await apiClient.rejectReview(agentId, reason); + const result = await apiClient.rejectReview(selectedAgentId, reason); vscode.window.showInformationMessage(result.message); await refreshAllProviders(); } catch (err) { @@ -1142,9 +1142,9 @@ async function showAwaitingAgentPicker( /** * Command: Sync a specific kanban collection */ -async function syncKanbanCollectionCommand(item?: StatusItem): Promise { - const provider = item?.provider; - const projectKey = item?.projectKey; +async function syncKanbanCollectionCommand(item: StatusItem): Promise { + const provider = item.provider; + const projectKey = item.projectKey; if (!provider || !projectKey) { vscode.window.showWarningMessage('No collection selected for sync.'); @@ -1183,7 +1183,7 @@ async function syncKanbanCollectionCommand(item?: StatusItem): Promise { /** * Command: Add a Jira project to an existing workspace */ -async function addJiraProjectCommand(workspaceKey?: string): Promise { +async function addJiraProjectCommand(workspaceKey: string): Promise { await addJiraProject(extensionContext, workspaceKey); await refreshAllProviders(); } @@ -1191,7 +1191,7 @@ async function addJiraProjectCommand(workspaceKey?: string): Promise { /** * Command: Add a Linear team to an existing workspace */ -async function addLinearTeamCommand(workspaceKey?: string): Promise { +async function addLinearTeamCommand(workspaceKey: string): Promise { await addLinearTeam(extensionContext, workspaceKey); await refreshAllProviders(); } diff --git a/vscode-extension/webview-ui/.eslintrc.json b/vscode-extension/webview-ui/.eslintrc.json new file mode 100644 index 0000000..ef50a42 --- /dev/null +++ b/vscode-extension/webview-ui/.eslintrc.json @@ -0,0 +1,29 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2022, + "sourceType": "module", + "ecmaFeatures": { "jsx": true } + }, + "plugins": ["@typescript-eslint"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "../../.eslintrc.base.json" + ], + "env": { "browser": true, "es2022": true }, + "rules": { + "@typescript-eslint/consistent-type-assertions": ["error", { + "assertionStyle": "as", + "objectLiteralTypeAssertions": "never" + }], + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/naming-convention": ["warn", { + "selector": "import", + "format": ["camelCase", "PascalCase"] + }], + "@typescript-eslint/semi": "warn", + "semi": "off" + } +} diff --git a/vscode-extension/webview-ui/App.tsx b/vscode-extension/webview-ui/App.tsx index e4fe576..7f34499 100644 --- a/vscode-extension/webview-ui/App.tsx +++ b/vscode-extension/webview-ui/App.tsx @@ -12,7 +12,11 @@ import type { ExtensionToWebviewMessage, JiraValidationInfo, LinearValidationInfo, + ProjectSummary, } from './types/messages'; +import type { JiraConfig } from '../src/generated/JiraConfig'; +import type { LinearConfig } from '../src/generated/LinearConfig'; +import type { ProjectSyncConfig } from '../src/generated/ProjectSyncConfig'; export function App() { const [config, setConfig] = useState(null); @@ -21,6 +25,10 @@ export function App() { const [linearResult, setLinearResult] = useState(null); const [validatingJira, setValidatingJira] = useState(false); const [validatingLinear, setValidatingLinear] = useState(false); + const [apiReachable, setApiReachable] = useState(false); + const [projects, setProjects] = useState([]); + const [projectsLoading, setProjectsLoading] = useState(false); + const [projectsError, setProjectsError] = useState(null); useEffect(() => { const cleanup = onMessage((msg: ExtensionToWebviewMessage) => { @@ -53,12 +61,36 @@ export function App() { case 'llmToolsDetected': setConfig(mergeWithDefaults(msg.config)); break; + case 'apiHealthResult': + setApiReachable(msg.reachable); + if (msg.reachable) { + setProjectsLoading(true); + postMessage({ type: 'getProjects' }); + } + break; + case 'projectsLoaded': + setProjects(msg.projects); + setProjectsLoading(false); + setProjectsError(null); + break; + case 'projectsError': + setProjectsError(msg.error); + setProjectsLoading(false); + break; + case 'assessTicketCreated': + // Refresh projects after successful assess ticket creation + postMessage({ type: 'getProjects' }); + break; + case 'assessTicketError': + setProjectsError(`Failed to assess ${msg.projectName}: ${msg.error}`); + break; } }); // Signal ready and request config postMessage({ type: 'ready' }); postMessage({ type: 'getConfig' }); + postMessage({ type: 'checkApiHealth' }); return cleanup; }, []); @@ -76,10 +108,6 @@ export function App() { [] ); - const handleBrowseFile = useCallback((field: string) => { - postMessage({ type: 'browseFile', field }); - }, []); - const handleBrowseFolder = useCallback((field: string) => { postMessage({ type: 'browseFolder', field }); }, []); @@ -107,6 +135,20 @@ export function App() { postMessage({ type: 'detectLlmTools' }); }, []); + const handleAssessProject = useCallback((projectName: string) => { + postMessage({ type: 'assessProject', projectName }); + }, []); + + const handleRefreshProjects = useCallback(() => { + setProjectsLoading(true); + setProjectsError(null); + postMessage({ type: 'getProjects' }); + }, []); + + const handleOpenProject = useCallback((projectPath: string) => { + postMessage({ type: 'openProjectFolder', projectPath }); + }, []); + return ( {error && ( @@ -127,6 +169,13 @@ export function App() { linearResult={linearResult} validatingJira={validatingJira} validatingLinear={validatingLinear} + apiReachable={apiReachable} + projects={projects} + projectsLoading={projectsLoading} + projectsError={projectsError} + onAssessProject={handleAssessProject} + onRefreshProjects={handleRefreshProjects} + onOpenProject={handleOpenProject} /> ) : ( @@ -146,16 +195,16 @@ function mergeWithDefaults(incoming: WebviewConfig): WebviewConfig { return { config_path: incoming.config_path || defaults.config_path, working_directory: incoming.working_directory || defaults.working_directory, - config: deepMerge(defaults.config as Record, (incoming.config ?? {}) as Record) as WebviewConfig['config'], + config: deepMerge(defaults.config, incoming.config), }; } /** Recursively merge source into target (source wins for leaf values) */ -function deepMerge(target: Record, source: Record): Record { +function deepMerge>(target: T, source: T): T { const result: Record = { ...target }; for (const key of Object.keys(source)) { - const srcVal = source[key]; - const tgtVal = target[key]; + const srcVal = (source as Record)[key]; + const tgtVal = (target as Record)[key]; if ( srcVal !== null && srcVal !== undefined && @@ -165,14 +214,21 @@ function deepMerge(target: Record, source: Record, srcVal as Record); + result[key] = deepMerge( + tgtVal as Record, + srcVal as Record, + ); } else if (srcVal !== undefined) { result[key] = srcVal; } } - return result; + return result as 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: '' }; + /** Apply an update to the config object by section/key path */ function applyUpdate( config: WebviewConfig, @@ -181,87 +237,95 @@ function applyUpdate( value: unknown ): WebviewConfig { const next = { ...config, config: { ...config.config } }; - const c = next.config as Record; switch (section) { case 'primary': if (key === 'working_directory') { next.working_directory = value as string; } break; - case 'agents': - c.agents = { ...(c.agents as Record ?? {}), [key]: value }; + case 'agents': { + const updated = { ...next.config.agents }; + (updated as Record)[key] = value; + next.config.agents = updated; break; + } - case 'sessions': - c.sessions = { ...(c.sessions as Record ?? {}), [key]: value }; + case 'sessions': { + const updated = { ...next.config.sessions }; + (updated as Record)[key] = value; + next.config.sessions = updated; break; + } case 'kanban.jira': { - const kanban = { ...(c.kanban as Record ?? {}) }; - const jiraMap = { ...(kanban.jira as Record ?? {}) }; + const jiraMap = { ...next.config.kanban.jira }; const domains = Object.keys(jiraMap); const domain = domains[0] ?? 'your-org.atlassian.net'; - const ws = { ...(jiraMap[domain] as Record ?? {}) }; + const ws: JiraConfig = { ...(jiraMap[domain] ?? DEFAULT_JIRA) }; if (key === 'enabled' || key === 'email' || key === 'api_key_env') { - ws[key] = value; + (ws as Record)[key] = value; jiraMap[domain] = ws; } else if (key === 'domain' && typeof value === 'string' && value !== domain) { delete jiraMap[domain]; jiraMap[value] = ws; } else if (key === 'project_key' || key === 'sync_statuses' || key === 'collection_name' || key === 'sync_user_id') { - const projects = { ...(ws.projects as Record ?? {}) }; + const projects = { ...ws.projects }; const pKeys = Object.keys(projects); const pKey = pKeys[0] ?? 'default'; if (key === 'project_key') { - const oldProject = projects[pKey] ?? {}; + const oldProject = projects[pKey] ?? DEFAULT_PROJECT_SYNC; delete projects[pKey]; projects[value as string] = oldProject; } else { - projects[pKey] = { ...(projects[pKey] as Record ?? {}), [key]: value }; + const existing = { ...(projects[pKey] ?? DEFAULT_PROJECT_SYNC) }; + (existing as Record)[key] = value; + projects[pKey] = existing; } ws.projects = projects; jiraMap[domain] = ws; } - kanban.jira = jiraMap; - c.kanban = kanban; + next.config.kanban = { ...next.config.kanban, jira: jiraMap }; break; } case 'kanban.linear': { - const kanban = { ...(c.kanban as Record ?? {}) }; - const linearMap = { ...(kanban.linear as Record ?? {}) }; + const linearMap = { ...next.config.kanban.linear }; const teams = Object.keys(linearMap); const teamId = teams[0] ?? 'default-team'; - const ws = { ...(linearMap[teamId] as Record ?? {}) }; + const ws: LinearConfig = { ...(linearMap[teamId] ?? DEFAULT_LINEAR) }; if (key === 'enabled' || key === 'api_key_env') { - ws[key] = value; + (ws as Record)[key] = value; linearMap[teamId] = ws; } else if (key === 'team_id' && typeof value === 'string' && value !== teamId) { delete linearMap[teamId]; linearMap[value] = ws; } else if (key === 'sync_statuses' || key === 'collection_name' || key === 'sync_user_id') { - const projects = { ...(ws.projects as Record ?? {}) }; + const projects = { ...ws.projects }; const pKeys = Object.keys(projects); const pKey = pKeys[0] ?? 'default'; - projects[pKey] = { ...(projects[pKey] as Record ?? {}), [key]: value }; + const existing = { ...(projects[pKey] ?? DEFAULT_PROJECT_SYNC) }; + (existing as Record)[key] = value; + projects[pKey] = existing; ws.projects = projects; linearMap[teamId] = ws; } - kanban.linear = linearMap; - c.kanban = kanban; + next.config.kanban = { ...next.config.kanban, linear: linearMap }; break; } - case 'git': - c.git = { ...(c.git as Record ?? {}), [key]: value }; + case 'git': { + const updated = { ...next.config.git }; + (updated as Record)[key] = value; + next.config.git = updated; break; + } case 'git.github': { - const git = { ...(c.git as Record ?? {}) }; - git.github = { ...(git.github as Record ?? {}), [key]: value }; - c.git = git; + const github = { ...next.config.git.github }; + (github as Record)[key] = value; + next.config.git = { ...next.config.git, github }; break; } } diff --git a/vscode-extension/webview-ui/components/ConfigPage.tsx b/vscode-extension/webview-ui/components/ConfigPage.tsx index 21ed855..7cb6aa3 100644 --- a/vscode-extension/webview-ui/components/ConfigPage.tsx +++ b/vscode-extension/webview-ui/components/ConfigPage.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from 'react'; +import React, { useMemo, useRef } from 'react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Typography from '@mui/material/Typography'; @@ -8,14 +8,8 @@ import { PrimaryConfigSection } from './sections/PrimaryConfigSection'; import { CodingAgentsSection } from './sections/CodingAgentsSection'; import { KanbanProvidersSection } from './sections/KanbanProvidersSection'; import { GitRepositoriesSection } from './sections/GitRepositoriesSection'; -import type { WebviewConfig, JiraValidationInfo, LinearValidationInfo } from '../types/messages'; - -const NAV_ITEMS: NavItem[] = [ - { id: 'section-primary', label: 'Primary' }, - { id: 'section-agents', label: 'Coding Agents' }, - { id: 'section-kanban', label: 'Kanban' }, - { id: 'section-git', label: 'Git Repos' }, -]; +import { ProjectsSection } from './sections/ProjectsSection'; +import type { WebviewConfig, JiraValidationInfo, LinearValidationInfo, ProjectSummary } from '../types/messages'; interface ConfigPageProps { config: WebviewConfig; @@ -29,6 +23,13 @@ interface ConfigPageProps { linearResult: LinearValidationInfo | null; validatingJira: boolean; validatingLinear: boolean; + apiReachable: boolean; + projects: ProjectSummary[]; + projectsLoading: boolean; + projectsError: string | null; + onAssessProject: (name: string) => void; + onRefreshProjects: () => void; + onOpenProject: (path: string) => void; } export function ConfigPage({ @@ -43,12 +44,28 @@ export function ConfigPage({ linearResult, validatingJira, validatingLinear, + apiReachable, + projects, + projectsLoading, + projectsError, + onAssessProject, + onRefreshProjects, + onOpenProject, }: ConfigPageProps) { const scrollRef = useRef(null); + const hasWorkDir = Boolean(config.working_directory); + + const navItems: NavItem[] = useMemo(() => [ + { id: 'section-primary', label: 'Workspace Configuration' }, + { id: 'section-kanban', label: 'Kanban Providers', disabled: !hasWorkDir }, + { id: 'section-agents', label: 'Coding Agents', disabled: !hasWorkDir }, + { id: 'section-git', label: 'Git Version Control', disabled: !hasWorkDir }, + { id: 'section-projects', label: 'Operator Managed Projects', disabled: !apiReachable }, + ], [hasWorkDir, apiReachable]); return ( - + )?.wrapper as string ?? 'vscode'} + sessions_wrapper={config.config.sessions.wrapper ?? 'vscode'} onUpdate={onUpdate} onBrowseFolder={onBrowseFolder} /> } + kanban={config.config.kanban} onUpdate={onUpdate} onValidateJira={onValidateJira} onValidateLinear={onValidateLinear} @@ -96,16 +113,23 @@ export function ConfigPage({ validatingLinear={validatingLinear} /> } - llm_tools={config.config.llm_tools as Record} + agents={config.config.agents} + llm_tools={config.config.llm_tools} onUpdate={onUpdate} onDetectTools={onDetectTools} /> } - projects={(config.config.projects as string[]) ?? []} + git={config.config.git} onUpdate={onUpdate} /> + ); diff --git a/vscode-extension/webview-ui/components/SidebarNav.tsx b/vscode-extension/webview-ui/components/SidebarNav.tsx index f98d37b..1e9c469 100644 --- a/vscode-extension/webview-ui/components/SidebarNav.tsx +++ b/vscode-extension/webview-ui/components/SidebarNav.tsx @@ -9,6 +9,7 @@ import { OperatorBrand } from './OperatorBrand'; export interface NavItem { id: string; label: string; + disabled?: boolean; } interface SidebarNavProps { @@ -19,8 +20,9 @@ interface SidebarNavProps { export function SidebarNav({ items, scrollContainerRef }: SidebarNavProps) { const [activeId, setActiveId] = useState(items[0]?.id ?? ''); - const handleClick = useCallback((id: string) => { - const element = document.getElementById(id); + const handleClick = useCallback((item: NavItem) => { + if (item.disabled) { return; } + const element = document.getElementById(item.id); if (element) { element.scrollIntoView({ behavior: 'smooth', block: 'start' }); } @@ -31,6 +33,7 @@ export function SidebarNav({ items, scrollContainerRef }: SidebarNavProps) { if (!container) { return; } const sectionElements = items + .filter((item) => !item.disabled) .map((item) => document.getElementById(item.id)) .filter((el): el is HTMLElement => el !== null); @@ -88,8 +91,9 @@ export function SidebarNav({ items, scrollContainerRef }: SidebarNavProps) { {items.map((item) => ( handleClick(item.id)} + selected={activeId === item.id && !item.disabled} + disabled={item.disabled} + onClick={() => handleClick(item)} sx={{ py: 0.5, px: 2 }} > ; - llm_tools: Record; + agents: AgentsConfig; + llm_tools: LlmToolsConfig; onUpdate: (section: string, key: string, value: unknown) => void; onDetectTools: () => void; } @@ -28,17 +23,11 @@ export function CodingAgentsSection({ onUpdate, onDetectTools, }: CodingAgentsSectionProps) { - const maxParallel = Number(agents.max_parallel ?? 2); - const generationTimeout = Number(agents.generation_timeout_secs ?? 300); - const stepTimeout = Number(agents.step_timeout ?? 1800); - const silenceThreshold = Number(agents.silence_threshold ?? 30); - const rawDetected = llm_tools.detected; - const detected: DetectedToolInfo[] = Array.isArray(rawDetected) - ? rawDetected.filter( - (entry): entry is DetectedToolInfo => - typeof entry === 'object' && entry !== null && 'name' in entry - ) - : []; + const maxParallel = agents.max_parallel; + const generationTimeout = Number(agents.generation_timeout_secs); + const stepTimeout = Number(agents.step_timeout); + const silenceThreshold = Number(agents.silence_threshold); + const detected = llm_tools.detected; return ( diff --git a/vscode-extension/webview-ui/components/sections/GitRepositoriesSection.tsx b/vscode-extension/webview-ui/components/sections/GitRepositoriesSection.tsx index 76f6a4f..670f6ef 100644 --- a/vscode-extension/webview-ui/components/sections/GitRepositoriesSection.tsx +++ b/vscode-extension/webview-ui/components/sections/GitRepositoriesSection.tsx @@ -7,29 +7,25 @@ import Select from '@mui/material/Select'; import MenuItem from '@mui/material/MenuItem'; import Switch from '@mui/material/Switch'; import FormControlLabel from '@mui/material/FormControlLabel'; -import Chip from '@mui/material/Chip'; -import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import Link from '@mui/material/Link'; import { SectionHeader } from '../SectionHeader'; +import type { GitConfig } from '../../../src/generated/GitConfig'; interface GitRepositoriesSectionProps { - git: Record; - projects: string[]; + git: GitConfig; onUpdate: (section: string, key: string, value: unknown) => void; } export function GitRepositoriesSection({ git, - projects, onUpdate, }: GitRepositoriesSectionProps) { - const provider = (git.provider as string) ?? ''; - const github = (git.github ?? {}) as Record; - const githubEnabled = (github.enabled as boolean) ?? true; - const githubTokenEnv = (github.token_env as string) ?? 'GITHUB_TOKEN'; - const branchFormat = (git.branch_format as string) ?? '{type}/{ticket_id}-{slug}'; - const useWorktrees = (git.use_worktrees as boolean) ?? false; + const provider = git.provider; + const githubEnabled = git.github.enabled; + const githubTokenEnv = git.github.token_env; + const branchFormat = git.branch_format; + const useWorktrees = git.use_worktrees; return ( @@ -93,23 +89,6 @@ export function GitRepositoriesSection({ } label="Use git worktrees for parallel agent branches" /> - - - - Projects - - {projects.length > 0 ? ( - - {projects.map((project) => ( - - ))} - - ) : ( - - No projects configured. Set a working directory to discover projects. - - )} - ); diff --git a/vscode-extension/webview-ui/components/sections/KanbanProvidersSection.tsx b/vscode-extension/webview-ui/components/sections/KanbanProvidersSection.tsx index 3b9fa9f..1208da5 100644 --- a/vscode-extension/webview-ui/components/sections/KanbanProvidersSection.tsx +++ b/vscode-extension/webview-ui/components/sections/KanbanProvidersSection.tsx @@ -12,9 +12,13 @@ import CardContent from '@mui/material/CardContent'; import CircularProgress from '@mui/material/CircularProgress'; import { SectionHeader } from '../SectionHeader'; import type { JiraValidationInfo, LinearValidationInfo } from '../../types/messages'; +import type { KanbanConfig } from '../../../src/generated/KanbanConfig'; +import type { JiraConfig } from '../../../src/generated/JiraConfig'; +import type { LinearConfig } from '../../../src/generated/LinearConfig'; +import type { ProjectSyncConfig } from '../../../src/generated/ProjectSyncConfig'; interface KanbanProvidersSectionProps { - kanban: Record; + kanban: KanbanConfig; onUpdate: (section: string, key: string, value: unknown) => void; onValidateJira: (domain: string, email: string, apiToken: string) => void; onValidateLinear: (apiKey: string) => void; @@ -25,22 +29,21 @@ interface KanbanProvidersSectionProps { } /** Extract first entry from a domain-keyed map */ -function firstEntry(map: Record): [string, Record] { +function firstEntry(map: { [key in string]?: T }): [string, T | undefined] { const keys = Object.keys(map); if (keys.length === 0) { - return ['', {}]; + return ['', undefined]; } - return [keys[0], (map[keys[0]] ?? {}) as Record]; + return [keys[0], map[keys[0]]]; } /** Extract first project from projects sub-map */ -function firstProject(ws: Record): [string, Record] { - const projects = (ws.projects ?? {}) as Record; +function firstProject(projects: { [key in string]?: ProjectSyncConfig }): [string, ProjectSyncConfig | undefined] { const keys = Object.keys(projects); if (keys.length === 0) { - return ['', {}]; + return ['', undefined]; } - return [keys[0], (projects[keys[0]] ?? {}) as Record]; + return [keys[0], projects[keys[0]]]; } export function KanbanProvidersSection({ @@ -53,19 +56,16 @@ export function KanbanProvidersSection({ validatingJira, validatingLinear, }: KanbanProvidersSectionProps) { - const jiraMap = (kanban.jira ?? {}) as Record; - const linearMap = (kanban.linear ?? {}) as Record; + const [jiraDomain, jiraWs] = firstEntry(kanban.jira); + const [jiraProjectKey, jiraProject] = firstProject(jiraWs?.projects ?? {}); + const jiraEnabled = jiraWs?.enabled ?? false; + const jiraEmail = jiraWs?.email ?? ''; + const jiraApiKeyEnv = jiraWs?.api_key_env ?? 'OPERATOR_JIRA_API_KEY'; - const [jiraDomain, jiraWs] = firstEntry(jiraMap); - const [jiraProjectKey, jiraProject] = firstProject(jiraWs); - const jiraEnabled = (jiraWs.enabled as boolean) ?? false; - const jiraEmail = (jiraWs.email as string) ?? ''; - const jiraApiKeyEnv = (jiraWs.api_key_env as string) ?? 'OPERATOR_JIRA_API_KEY'; - - const [linearTeamId, linearWs] = firstEntry(linearMap); - const [, linearProject] = firstProject(linearWs); - const linearEnabled = (linearWs.enabled as boolean) ?? false; - const linearApiKeyEnv = (linearWs.api_key_env as string) ?? 'OPERATOR_LINEAR_API_KEY'; + const [linearTeamId, linearWs] = firstEntry(kanban.linear); + const [, linearProject] = firstProject(linearWs?.projects ?? {}); + const linearEnabled = linearWs?.enabled ?? false; + const linearApiKeyEnv = linearWs?.api_key_env ?? 'OPERATOR_LINEAR_API_KEY'; const [jiraApiToken, setJiraApiToken] = useState(''); const [linearApiKey, setLinearApiKey] = useState(''); @@ -153,7 +153,7 @@ export function KanbanProvidersSection({ size="small" label="Sync Statuses" InputLabelProps={{ margin: 'dense' }} - value={((jiraProject.sync_statuses as string[]) ?? []).join(', ')} + value={(jiraProject?.sync_statuses ?? []).join(', ')} onChange={(e) => { const statuses = e.target.value .split(',') @@ -171,7 +171,7 @@ export function KanbanProvidersSection({ size="small" label="Collection Name" InputLabelProps={{ margin: 'dense' }} - value={(jiraProject.collection_name as string) ?? ''} + value={jiraProject?.collection_name ?? ''} onChange={(e) => onUpdate('kanban.jira', 'collection_name', e.target.value)} placeholder="dev_kanban" disabled={!jiraEnabled} @@ -265,7 +265,7 @@ export function KanbanProvidersSection({ fullWidth size="small" label="Sync Statuses" - value={((linearProject.sync_statuses as string[]) ?? []).join(', ')} + value={(linearProject?.sync_statuses ?? []).join(', ')} onChange={(e) => { const statuses = e.target.value .split(',') @@ -282,7 +282,7 @@ export function KanbanProvidersSection({ fullWidth size="small" label="Collection Name" - value={(linearProject.collection_name as string) ?? ''} + value={linearProject?.collection_name ?? ''} onChange={(e) => onUpdate('kanban.linear', 'collection_name', e.target.value)} placeholder="dev_kanban" disabled={!linearEnabled} diff --git a/vscode-extension/webview-ui/components/sections/PrimaryConfigSection.tsx b/vscode-extension/webview-ui/components/sections/PrimaryConfigSection.tsx index 547ce04..32e9576 100644 --- a/vscode-extension/webview-ui/components/sections/PrimaryConfigSection.tsx +++ b/vscode-extension/webview-ui/components/sections/PrimaryConfigSection.tsx @@ -26,7 +26,7 @@ export function PrimaryConfigSection({ }: PrimaryConfigSectionProps) { return ( - + These are settings for Operator! configuration for the VS Code extension. For more details see the configuration documentation diff --git a/vscode-extension/webview-ui/components/sections/ProjectsSection.tsx b/vscode-extension/webview-ui/components/sections/ProjectsSection.tsx new file mode 100644 index 0000000..f3a059a --- /dev/null +++ b/vscode-extension/webview-ui/components/sections/ProjectsSection.tsx @@ -0,0 +1,153 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; +import CircularProgress from '@mui/material/CircularProgress'; +import Alert from '@mui/material/Alert'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import Typography from '@mui/material/Typography'; +import { SectionHeader } from '../SectionHeader'; +import type { ProjectSummary } from '../../types/messages'; + +interface ProjectsSectionProps { + projects: ProjectSummary[]; + loading: boolean; + error: string | null; + onAssess: (name: string) => void; + onOpenProject: (path: string) => void; + onRefresh: () => void; +} + +export function ProjectsSection({ + projects, + loading, + error, + onAssess, + onOpenProject, + onRefresh, +}: ProjectsSectionProps) { + return ( + + + + + Projects discovered by the Operator API with analysis data from ASSESS tickets. + + + + + {loading && ( + + + Loading projects... + + )} + + {error && ( + {error} + )} + + {!loading && !error && projects.length === 0 && ( + + No projects found. Ensure the Operator API is running and projects are configured. + + )} + + {!loading && projects.length > 0 && ( + + + + + Project + Kind & Stack + Detections + Config + Actions + + + + {projects.map((project) => ( + + + onOpenProject(project.project_path)} + sx={{ fontWeight: 600, textAlign: 'left' }} + > + {project.project_name} + + {!project.exists && ( + + Directory not found + + )} + + + + {project.kind && ( + + )} + {project.languages.map((lang) => ( + + ))} + {project.frameworks.map((fw) => ( + + ))} + {project.databases.map((db) => ( + + ))} + + + + + {project.has_docker && } + {project.has_tests && } + {project.has_catalog_info && } + {project.has_project_context && } + + + + + {project.ports.length > 0 && `${project.ports.length} ports`} + {project.ports.length > 0 && project.env_var_count > 0 && ' · '} + {project.env_var_count > 0 && `${project.env_var_count} env`} + {(project.ports.length > 0 || project.env_var_count > 0) && project.entry_point_count > 0 && ' · '} + {project.entry_point_count > 0 && `${project.entry_point_count} entry`} + {project.ports.length === 0 && project.env_var_count === 0 && project.entry_point_count === 0 && '—'} + + + + + + + ))} + +
+
+ )} +
+ ); +} diff --git a/vscode-extension/webview-ui/types/messages.ts b/vscode-extension/webview-ui/types/messages.ts index 9983982..25c5258 100644 --- a/vscode-extension/webview-ui/types/messages.ts +++ b/vscode-extension/webview-ui/types/messages.ts @@ -7,6 +7,27 @@ export interface WebviewConfig { config: Config; } +/** Summary of a project from the Operator REST API */ +export interface ProjectSummary { + project_name: string; + project_path: string; + exists: boolean; + has_catalog_info: boolean; + has_project_context: boolean; + kind: string | null; + kind_confidence: number | null; + kind_tier: string | null; + languages: string[]; + frameworks: string[]; + databases: string[]; + has_docker: boolean | null; + has_tests: boolean | null; + ports: number[]; + env_var_count: number; + entry_point_count: number; + commands: string[]; +} + /** Messages from the webview to the extension host */ export type WebviewToExtensionMessage = | { type: 'ready' } @@ -18,7 +39,11 @@ export type WebviewToExtensionMessage = | { type: 'validateLinear'; apiKey: string } | { type: 'detectLlmTools' } | { type: 'openExternal'; url: string } - | { type: 'openFile'; filePath: string }; + | { type: 'openFile'; filePath: string } + | { type: 'checkApiHealth' } + | { type: 'getProjects' } + | { type: 'assessProject'; projectName: string } + | { type: 'openProjectFolder'; projectPath: string }; /** Messages from the extension host to the webview */ export type ExtensionToWebviewMessage = @@ -28,7 +53,12 @@ export type ExtensionToWebviewMessage = | { type: 'browseResult'; field: string; path: string } | { type: 'jiraValidationResult'; result: JiraValidationInfo } | { type: 'linearValidationResult'; result: LinearValidationInfo } - | { type: 'llmToolsDetected'; config: WebviewConfig }; + | { type: 'llmToolsDetected'; config: WebviewConfig } + | { type: 'apiHealthResult'; reachable: boolean } + | { type: 'projectsLoaded'; projects: ProjectSummary[] } + | { type: 'projectsError'; error: string } + | { type: 'assessTicketCreated'; ticketId: string; projectName: string } + | { type: 'assessTicketError'; error: string; projectName: string }; export interface JiraValidationInfo { valid: boolean; From a67fef54d5da6b4bc204afdd6d9baaf04be1a4df Mon Sep 17 00:00:00 2001 From: untra Date: Wed, 18 Feb 2026 22:53:40 -0700 Subject: [PATCH 3/4] opr8r signed for macos Co-Authored-By: Claude Opus 4.5 --- .github/workflows/build.yaml | 49 +++++++++++++++++++ .github/workflows/opr8r.yaml | 18 +++++++ .github/workflows/vscode-extension.yaml | 18 +++++++ scripts/codesign.sh | 64 +++++++++++++++++++++++++ scripts/notarize.sh | 40 ++++++++++++++++ 5 files changed, 189 insertions(+) create mode 100755 scripts/codesign.sh create mode 100755 scripts/notarize.sh diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 1aae1b6..70bcd42 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -253,6 +253,37 @@ jobs: cp target/${{ matrix.target }}/release/operator ${{ matrix.artifact_name }} fi + # Apple code signing + notarization (macOS only) + - name: Codesign operator binary + if: matrix.os == 'macos-14' + env: + APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: scripts/codesign.sh "${{ matrix.artifact_name }}" + + - name: Notarize operator binary + if: matrix.os == 'macos-14' + env: + APPLE_NOTARY_KEY_BASE64: ${{ secrets.APPLE_NOTARY_KEY_BASE64 }} + APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} + APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }} + run: scripts/notarize.sh "${{ matrix.artifact_name }}" + + - name: Codesign backstage-server binary + if: matrix.os == 'macos-14' && steps.backstage.outputs.exists == 'true' + env: + APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: scripts/codesign.sh "target/backstage-server-${{ matrix.bun_target }}" + + - name: Notarize backstage-server binary + if: matrix.os == 'macos-14' && steps.backstage.outputs.exists == 'true' + env: + APPLE_NOTARY_KEY_BASE64: ${{ secrets.APPLE_NOTARY_KEY_BASE64 }} + APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} + APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }} + run: scripts/notarize.sh "target/backstage-server-${{ matrix.bun_target }}" + - name: Upload artifact uses: actions/upload-artifact@v4 with: @@ -314,6 +345,24 @@ jobs: if: runner.os != 'Windows' run: strip target/release/opr8r || true + # Apple code signing + notarization (macOS only) + - name: Codesign opr8r binary + if: matrix.os == 'macos-14' + working-directory: . + env: + APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: scripts/codesign.sh opr8r/target/release/opr8r + + - name: Notarize opr8r binary + if: matrix.os == 'macos-14' + working-directory: . + env: + APPLE_NOTARY_KEY_BASE64: ${{ secrets.APPLE_NOTARY_KEY_BASE64 }} + APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} + APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }} + run: scripts/notarize.sh opr8r/target/release/opr8r + - name: Rename binary shell: bash run: | diff --git a/.github/workflows/opr8r.yaml b/.github/workflows/opr8r.yaml index be0d9d4..9ad245e 100644 --- a/.github/workflows/opr8r.yaml +++ b/.github/workflows/opr8r.yaml @@ -141,6 +141,24 @@ jobs: if: runner.os != 'Windows' run: strip target/release/opr8r || true + # Apple code signing + notarization (macOS only) + - name: Codesign opr8r binary + if: matrix.os == 'macos-14' + working-directory: . + env: + APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: scripts/codesign.sh opr8r/target/release/opr8r + + - name: Notarize opr8r binary + if: matrix.os == 'macos-14' + working-directory: . + env: + APPLE_NOTARY_KEY_BASE64: ${{ secrets.APPLE_NOTARY_KEY_BASE64 }} + APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} + APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }} + run: scripts/notarize.sh opr8r/target/release/opr8r + - name: Upload artifact uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/vscode-extension.yaml b/.github/workflows/vscode-extension.yaml index 7892556..d26d0dd 100644 --- a/.github/workflows/vscode-extension.yaml +++ b/.github/workflows/vscode-extension.yaml @@ -104,6 +104,24 @@ jobs: if: runner.os != 'Windows' run: strip target/release/opr8r || true + # Apple code signing + notarization (macOS only) + - name: Codesign opr8r binary + if: matrix.os == 'macos-14' + working-directory: . + env: + APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: scripts/codesign.sh opr8r/target/release/opr8r + + - name: Notarize opr8r binary + if: matrix.os == 'macos-14' + working-directory: . + env: + APPLE_NOTARY_KEY_BASE64: ${{ secrets.APPLE_NOTARY_KEY_BASE64 }} + APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} + APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }} + run: scripts/notarize.sh opr8r/target/release/opr8r + - name: Upload artifact uses: actions/upload-artifact@v4 with: diff --git a/scripts/codesign.sh b/scripts/codesign.sh new file mode 100755 index 0000000..53d583d --- /dev/null +++ b/scripts/codesign.sh @@ -0,0 +1,64 @@ +#!/bin/bash +set -euo pipefail + +# Signs a macOS binary with a Developer ID Application certificate. +# Usage: codesign.sh +# +# Required environment variables: +# APPLE_CERTIFICATE_P12_BASE64 - base64-encoded .p12 certificate +# APPLE_CERTIFICATE_PASSWORD - password for the .p12 file + +BINARY_PATH="${1:?Usage: codesign.sh }" + +# Unique keychain per invocation (PID avoids collisions) +KEYCHAIN_NAME="signing-$$.keychain-db" +KEYCHAIN_PASSWORD="$(openssl rand -hex 24)" +CERT_PATH="/tmp/codesign-$$.p12" + +cleanup() { + security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true + rm -f "$CERT_PATH" +} +trap cleanup EXIT + +# Decode certificate +echo "$APPLE_CERTIFICATE_P12_BASE64" | base64 --decode > "$CERT_PATH" + +# Create temporary keychain +security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME" +security set-keychain-settings -lut 21600 "$KEYCHAIN_NAME" +security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME" + +# Add to search list (preserve existing keychains) +ORIGINAL_KEYCHAINS=$(security list-keychains -d user | tr -d '"' | tr '\n' ' ') +security list-keychains -d user -s "$KEYCHAIN_NAME" $ORIGINAL_KEYCHAINS + +# Import certificate +echo "Importing certificate ($(stat -f%z "$CERT_PATH") bytes)" +security import "$CERT_PATH" \ + -P "$APPLE_CERTIFICATE_PASSWORD" \ + -A -t cert -f pkcs12 \ + -k "$KEYCHAIN_NAME" + +# Allow codesign to access key without UI prompt +security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME" + +# Find the Developer ID Application identity +IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_NAME" \ + | grep "Developer ID Application" | head -1 | awk -F'"' '{print $2}') + +if [ -z "$IDENTITY" ]; then + echo "ERROR: No 'Developer ID Application' identity found in keychain" >&2 + security find-identity -v -p codesigning "$KEYCHAIN_NAME" + exit 1 +fi + +echo "Signing $BINARY_PATH with identity: $IDENTITY" +codesign --force --options runtime --timestamp \ + --sign "$IDENTITY" \ + --keychain "$KEYCHAIN_NAME" \ + "$BINARY_PATH" + +# Verify signature +codesign --verify --verbose=2 "$BINARY_PATH" +echo "Successfully signed: $BINARY_PATH" diff --git a/scripts/notarize.sh b/scripts/notarize.sh new file mode 100755 index 0000000..3cc4e91 --- /dev/null +++ b/scripts/notarize.sh @@ -0,0 +1,40 @@ +#!/bin/bash +set -euo pipefail + +# Notarizes a signed macOS binary via App Store Connect API key. +# Usage: notarize.sh +# +# Required environment variables: +# APPLE_NOTARY_KEY_BASE64 - base64-encoded .p8 API key +# APPLE_NOTARY_KEY_ID - API key ID +# APPLE_NOTARY_ISSUER_ID - API issuer UUID + +BINARY_PATH="${1:?Usage: notarize.sh }" +ZIP_PATH="${BINARY_PATH}.zip" +KEY_DIR="/tmp/notary-keys-$$" +KEY_PATH="$KEY_DIR/AuthKey_${APPLE_NOTARY_KEY_ID}.p8" + +cleanup() { + rm -f "$ZIP_PATH" + rm -rf "$KEY_DIR" +} +trap cleanup EXIT + +# Decode API key +mkdir -p "$KEY_DIR" +echo "$APPLE_NOTARY_KEY_BASE64" | base64 --decode > "$KEY_PATH" + +# Zip the signed binary (notarytool requires .zip, .dmg, or .pkg) +ditto -c -k --keepParent "$BINARY_PATH" "$ZIP_PATH" + +echo "Submitting $BINARY_PATH for notarization..." +xcrun notarytool submit "$ZIP_PATH" \ + --key "$KEY_PATH" \ + --key-id "$APPLE_NOTARY_KEY_ID" \ + --issuer "$APPLE_NOTARY_ISSUER_ID" \ + --wait \ + --timeout 30m + +echo "Notarization complete: $BINARY_PATH" +# Note: xcrun stapler staple does NOT work on standalone Mach-O binaries. +# macOS checks the notarization ticket online via Gatekeeper. From e19d6fd42d72e571efaab4f4e3f9b22f90bcc093 Mon Sep 17 00:00:00 2001 From: untra Date: Fri, 20 Feb 2026 06:26:59 -0700 Subject: [PATCH 4/4] azure pipelines artifact signing --- .github/workflows/build.yaml | 20 +++- .github/workflows/opr8r.yaml | 15 ++- .github/workflows/sign-windows.yaml | 148 ++++++++++++++++++++++++++++ azure-pipelines.yml | 66 +++++++++++++ 4 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/sign-windows.yaml create mode 100644 azure-pipelines.yml diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 70bcd42..618c1bc 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -378,8 +378,26 @@ jobs: name: ${{ matrix.artifact_name }} path: ${{ matrix.artifact_name }} - release: + sign-windows: needs: [build, build-opr8r] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + strategy: + matrix: + artifact: + - operator-windows-x86_64.exe + - opr8r-windows-x86_64.exe + uses: ./.github/workflows/sign-windows.yaml + with: + artifact-name: ${{ matrix.artifact }} + secrets: + AZURE_STORAGE_CONNECTION_STRING: ${{ secrets.AZURE_STORAGE_CONNECTION_STRING }} + AZ_PIPELINES_PAT: ${{ secrets.AZ_PIPELINES_PAT }} + AZ_ORG: ${{ secrets.AZ_ORG }} + AZ_PROJECT: ${{ secrets.AZ_PROJECT }} + AZ_PIPELINE_DEFINITION_ID: ${{ secrets.AZ_PIPELINE_DEFINITION_ID }} + + release: + needs: [build, build-opr8r, sign-windows] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/opr8r.yaml b/.github/workflows/opr8r.yaml index 9ad245e..80ab276 100644 --- a/.github/workflows/opr8r.yaml +++ b/.github/workflows/opr8r.yaml @@ -164,4 +164,17 @@ jobs: with: name: ${{ matrix.artifact }} path: opr8r/target/release/opr8r${{ matrix.extension || '' }} - retention-days: 30 \ No newline at end of file + retention-days: 30 + + sign-windows: + needs: build + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: ./.github/workflows/sign-windows.yaml + with: + artifact-name: opr8r-windows-x86_64 + secrets: + AZURE_STORAGE_CONNECTION_STRING: ${{ secrets.AZURE_STORAGE_CONNECTION_STRING }} + AZ_PIPELINES_PAT: ${{ secrets.AZ_PIPELINES_PAT }} + AZ_ORG: ${{ secrets.AZ_ORG }} + AZ_PROJECT: ${{ secrets.AZ_PROJECT }} + AZ_PIPELINE_DEFINITION_ID: ${{ secrets.AZ_PIPELINE_DEFINITION_ID }} \ No newline at end of file diff --git a/.github/workflows/sign-windows.yaml b/.github/workflows/sign-windows.yaml new file mode 100644 index 0000000..d5c0dcd --- /dev/null +++ b/.github/workflows/sign-windows.yaml @@ -0,0 +1,148 @@ +name: Sign Windows Binary + +on: + workflow_call: + inputs: + artifact-name: + description: GitHub Actions artifact name containing the unsigned .exe + required: true + type: string + secrets: + AZURE_STORAGE_CONNECTION_STRING: + required: true + AZ_PIPELINES_PAT: + required: true + AZ_ORG: + required: true + AZ_PROJECT: + required: true + AZ_PIPELINE_DEFINITION_ID: + required: true + +jobs: + sign: + runs-on: ubuntu-latest + env: + BLOB_NAME: ${{ github.run_id }}-${{ inputs.artifact-name }} + SIGNED_BLOB_NAME: signed-${{ github.run_id }}-${{ inputs.artifact-name }} + steps: + - name: Download unsigned artifact + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.artifact-name }} + path: unsigned + + - name: Find .exe file + id: find-exe + run: | + EXE_PATH=$(find unsigned -name '*.exe' -type f | head -n 1) + if [ -z "$EXE_PATH" ]; then + echo "::error::No .exe file found in artifact ${{ inputs.artifact-name }}" + exit 1 + fi + EXE_NAME=$(basename "$EXE_PATH") + echo "exe_path=$EXE_PATH" >> "$GITHUB_OUTPUT" + echo "exe_name=$EXE_NAME" >> "$GITHUB_OUTPUT" + echo "Found: $EXE_PATH" + + - name: Upload unsigned binary to Azure Blob + run: | + az storage blob upload \ + --connection-string "${{ secrets.AZURE_STORAGE_CONNECTION_STRING }}" \ + --container-name signing-stage \ + --name "$BLOB_NAME" \ + --file "${{ steps.find-exe.outputs.exe_path }}" \ + --overwrite + + - name: Trigger Azure Pipeline + id: trigger + run: | + RESPONSE=$(curl -s -w "\n%{http_code}" \ + -u ":${{ secrets.AZ_PIPELINES_PAT }}" \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{ + "templateParameters": { + "blobName": "'"$BLOB_NAME"'", + "signedBlobName": "'"$SIGNED_BLOB_NAME"'" + } + }' \ + "https://dev.azure.com/${{ secrets.AZ_ORG }}/${{ secrets.AZ_PROJECT }}/_apis/pipelines/${{ secrets.AZ_PIPELINE_DEFINITION_ID }}/runs?api-version=7.1") + + HTTP_CODE=$(echo "$RESPONSE" | tail -n 1) + BODY=$(echo "$RESPONSE" | sed '$d') + + if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then + echo "::error::Azure Pipeline trigger failed (HTTP $HTTP_CODE): $BODY" + exit 1 + fi + + BUILD_ID=$(echo "$BODY" | jq -r '.id') + echo "build_id=$BUILD_ID" >> "$GITHUB_OUTPUT" + echo "Triggered Azure Pipeline build $BUILD_ID" + + - name: Poll for Azure Pipeline completion + run: | + BUILD_ID=${{ steps.trigger.outputs.build_id }} + MAX_ATTEMPTS=40 # 40 × 15s = 10 minutes + ATTEMPT=0 + + while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do + ATTEMPT=$((ATTEMPT + 1)) + sleep 15 + + RESPONSE=$(curl -s \ + -u ":${{ secrets.AZ_PIPELINES_PAT }}" \ + "https://dev.azure.com/${{ secrets.AZ_ORG }}/${{ secrets.AZ_PROJECT }}/_apis/build/builds/${BUILD_ID}?api-version=7.1") + + STATUS=$(echo "$RESPONSE" | jq -r '.status') + RESULT=$(echo "$RESPONSE" | jq -r '.result') + echo "Attempt $ATTEMPT/$MAX_ATTEMPTS: status=$STATUS result=$RESULT" + + if [ "$STATUS" = "completed" ]; then + if [ "$RESULT" = "succeeded" ]; then + echo "Azure Pipeline succeeded" + exit 0 + else + echo "::error::Azure Pipeline finished with result: $RESULT" + exit 1 + fi + fi + done + + echo "::error::Azure Pipeline timed out after 10 minutes" + exit 1 + + - name: Download signed binary + run: | + mkdir -p signed + az storage blob download \ + --connection-string "${{ secrets.AZURE_STORAGE_CONNECTION_STRING }}" \ + --container-name signing-stage \ + --name "$SIGNED_BLOB_NAME" \ + --file "signed/${{ steps.find-exe.outputs.exe_name }}" + + - name: Upload signed artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact-name }} + path: signed/${{ steps.find-exe.outputs.exe_name }} + overwrite: true + + - name: Cleanup unsigned blob + if: always() + run: | + az storage blob delete \ + --connection-string "${{ secrets.AZURE_STORAGE_CONNECTION_STRING }}" \ + --container-name signing-stage \ + --name "$BLOB_NAME" \ + --delete-snapshots include 2>/dev/null || true + + - name: Cleanup signed blob + if: always() + run: | + az storage blob delete \ + --connection-string "${{ secrets.AZURE_STORAGE_CONNECTION_STRING }}" \ + --container-name signing-stage \ + --name "$SIGNED_BLOB_NAME" \ + --delete-snapshots include 2>/dev/null || true diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 0000000..0338308 --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,66 @@ +trigger: none + +parameters: + - name: blobName + type: string + - name: signedBlobName + type: string + +variables: + - group: signing-secrets + +pool: + vmImage: windows-latest + +steps: + - task: AzureCLI@2 + displayName: Download unsigned binary from blob + inputs: + azureSubscription: signing-service-connection + scriptType: ps + scriptLocation: inlineScript + inlineScript: | + az storage blob download ` + --connection-string "$(AZURE_STORAGE_CONNECTION_STRING)" ` + --container-name signing-stage ` + --name "${{ parameters.blobName }}" ` + --file "$(Build.StagingDirectory)\unsigned.exe" + + - task: AzureCodeSigning@0 + displayName: Sign with Trusted Signing + inputs: + ConnectedServiceName: trusted-signing-service-connection + AccountEndpoint: $(TrustedSigningEndpoint) + CertificateProfileName: $(TrustedSigningProfile) + FilesFolder: $(Build.StagingDirectory) + FilesFolderFilter: unsigned.exe + FileDigest: SHA256 + TimestampDigest: SHA256 + + - task: AzureCLI@2 + displayName: Upload signed binary to blob + inputs: + azureSubscription: signing-service-connection + scriptType: ps + scriptLocation: inlineScript + inlineScript: | + az storage blob upload ` + --connection-string "$(AZURE_STORAGE_CONNECTION_STRING)" ` + --container-name signing-stage ` + --name "${{ parameters.signedBlobName }}" ` + --file "$(Build.StagingDirectory)\unsigned.exe" ` + --overwrite + + - task: PowerShell@2 + displayName: Verify Authenticode signature + inputs: + targetType: inline + script: | + $sig = Get-AuthenticodeSignature "$(Build.StagingDirectory)\unsigned.exe" + Write-Host "Signature status: $($sig.Status)" + Write-Host "Signer: $($sig.SignerCertificate.Subject)" + Write-Host "Timestamp: $($sig.TimeStamperCertificate.Subject)" + if ($sig.Status -ne 'Valid') { + Write-Error "Authenticode signature is not valid: $($sig.Status)" + exit 1 + }