Merge feat/phase1-foundation: Phase 1 (foundation) + Phase 2 (Tuleap integration)
This commit is contained in:
commit
33c3a4a19f
53 changed files with 18260 additions and 54 deletions
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Node
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Rust / Tauri
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
|
||||
# Local dev config (machine-specific paths)
|
||||
src-tauri/.cargo/config.toml
|
||||
|
||||
# Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.local
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Orchai Phase 1: Foundation Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
||||
|
||||
**Goal:** Get a working Tauri 2 desktop app with SQLite storage and full Project CRUD (create from local path or clone URL, list, edit, delete) with a React UI.
|
||||
|
||||
|
|
@ -78,7 +78,7 @@ orchai/
|
|||
- Create: entire project scaffold via CLI
|
||||
- Preserve: `docs/`, `.git/`
|
||||
|
||||
- [ ] **Step 1: Save existing repo contents**
|
||||
- [x] **Step 1: Save existing repo contents**
|
||||
|
||||
```bash
|
||||
cd /home/leclere/Projets
|
||||
|
|
@ -86,7 +86,7 @@ cp -r orchai/docs /tmp/orchai-docs-backup
|
|||
cp -r orchai/.git /tmp/orchai-git-backup
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Scaffold Tauri 2 project**
|
||||
- [x] **Step 2: Scaffold Tauri 2 project**
|
||||
|
||||
```bash
|
||||
cd /home/leclere/Projets
|
||||
|
|
@ -102,7 +102,7 @@ When prompted, select:
|
|||
- UI template: `React`
|
||||
- UI flavor: `TypeScript`
|
||||
|
||||
- [ ] **Step 3: Restore repo history and docs**
|
||||
- [x] **Step 3: Restore repo history and docs**
|
||||
|
||||
```bash
|
||||
cd /home/leclere/Projets/orchai
|
||||
|
|
@ -112,7 +112,7 @@ cp -r /tmp/orchai-docs-backup docs
|
|||
rm -rf /tmp/orchai-docs-backup /tmp/orchai-git-backup
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Install dependencies and verify**
|
||||
- [x] **Step 4: Install dependencies and verify**
|
||||
|
||||
```bash
|
||||
cd /home/leclere/Projets/orchai
|
||||
|
|
@ -123,7 +123,7 @@ cd ..
|
|||
|
||||
Expected: build succeeds with no errors.
|
||||
|
||||
- [ ] **Step 5: Verify dev server starts**
|
||||
- [x] **Step 5: Verify dev server starts**
|
||||
|
||||
```bash
|
||||
npm run tauri dev
|
||||
|
|
@ -131,7 +131,7 @@ npm run tauri dev
|
|||
|
||||
Expected: Tauri window opens with the default React starter page. Close it after verifying.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
- [x] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
|
|
@ -147,14 +147,14 @@ git commit -m "scaffold: Tauri 2 + React + TypeScript via create-tauri-app"
|
|||
- Modify: `src/index.css`, `package.json`, `src-tauri/tauri.conf.json`
|
||||
- Delete: `src/App.css`
|
||||
|
||||
- [ ] **Step 1: Install Tailwind**
|
||||
- [x] **Step 1: Install Tailwind**
|
||||
|
||||
```bash
|
||||
cd /home/leclere/Projets/orchai
|
||||
npm install -D tailwindcss @tailwindcss/vite
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add Tailwind to Vite config**
|
||||
- [x] **Step 2: Add Tailwind to Vite config**
|
||||
|
||||
Replace the contents of `vite.config.ts`:
|
||||
|
||||
|
|
@ -186,7 +186,7 @@ export default defineConfig(async () => ({
|
|||
}));
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace index.css with Tailwind directives**
|
||||
- [x] **Step 3: Replace index.css with Tailwind directives**
|
||||
|
||||
Replace the contents of `src/index.css`:
|
||||
|
||||
|
|
@ -194,7 +194,7 @@ Replace the contents of `src/index.css`:
|
|||
@import "tailwindcss";
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Delete App.css and clean up App.tsx**
|
||||
- [x] **Step 4: Delete App.css and clean up App.tsx**
|
||||
|
||||
Delete `src/App.css`.
|
||||
|
||||
|
|
@ -212,7 +212,7 @@ function App() {
|
|||
export default App;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update Tauri config**
|
||||
- [x] **Step 5: Update Tauri config**
|
||||
|
||||
In `src-tauri/tauri.conf.json`, update the `app` section:
|
||||
|
||||
|
|
@ -244,7 +244,7 @@ In `src-tauri/tauri.conf.json`, update the `app` section:
|
|||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify Tailwind works**
|
||||
- [x] **Step 6: Verify Tailwind works**
|
||||
|
||||
```bash
|
||||
npm run tauri dev
|
||||
|
|
@ -252,7 +252,7 @@ npm run tauri dev
|
|||
|
||||
Expected: window opens, "Orchai" heading rendered with Tailwind styling (bold, large). Close after verifying.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
- [x] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
|
|
@ -270,7 +270,7 @@ git commit -m "configure: Tailwind CSS + app metadata"
|
|||
- Create: `src-tauri/src/error.rs`
|
||||
- Modify: `src-tauri/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test for db initialization**
|
||||
- [x] **Step 1: Write the failing test for db initialization**
|
||||
|
||||
Add dependencies to `src-tauri/Cargo.toml` under `[dependencies]`:
|
||||
|
||||
|
|
@ -344,7 +344,7 @@ mod tests {
|
|||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
- [x] **Step 2: Run tests to verify they fail**
|
||||
|
||||
```bash
|
||||
cd /home/leclere/Projets/orchai/src-tauri
|
||||
|
|
@ -353,7 +353,7 @@ cargo test db::tests
|
|||
|
||||
Expected: 3 failures with `not yet implemented`.
|
||||
|
||||
- [ ] **Step 3: Create migration SQL**
|
||||
- [x] **Step 3: Create migration SQL**
|
||||
|
||||
Create `src-tauri/migrations/001_init.sql`:
|
||||
|
||||
|
|
@ -421,7 +421,7 @@ CREATE TABLE IF NOT EXISTS notifications (
|
|||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Implement db::init and db::init_in_memory**
|
||||
- [x] **Step 4: Implement db::init and db::init_in_memory**
|
||||
|
||||
Replace the `todo!()` implementations in `src-tauri/src/db.rs`:
|
||||
|
||||
|
|
@ -463,7 +463,7 @@ fn migrate(conn: &Connection) -> Result<()> {
|
|||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run tests to verify they pass**
|
||||
- [x] **Step 5: Run tests to verify they pass**
|
||||
|
||||
```bash
|
||||
cd /home/leclere/Projets/orchai/src-tauri
|
||||
|
|
@ -489,7 +489,7 @@ Replace the WAL test assertion:
|
|||
|
||||
Re-run tests. Expected: 3 pass.
|
||||
|
||||
- [ ] **Step 6: Create error type**
|
||||
- [x] **Step 6: Create error type**
|
||||
|
||||
Create `src-tauri/src/error.rs`:
|
||||
|
||||
|
|
@ -530,7 +530,7 @@ impl std::fmt::Display for AppError {
|
|||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Wire up db module in lib.rs**
|
||||
- [x] **Step 7: Wire up db module in lib.rs**
|
||||
|
||||
Replace `src-tauri/src/lib.rs`:
|
||||
|
||||
|
|
@ -562,7 +562,7 @@ pub fn run() {
|
|||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Verify it compiles and runs**
|
||||
- [x] **Step 8: Verify it compiles and runs**
|
||||
|
||||
```bash
|
||||
cd /home/leclere/Projets/orchai/src-tauri
|
||||
|
|
@ -571,7 +571,7 @@ cargo build
|
|||
|
||||
Expected: compiles with no errors.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
- [x] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
|
|
@ -587,7 +587,7 @@ git commit -m "feat: SQLite database with migration system and full schema"
|
|||
- Create: `src-tauri/src/models/project.rs`
|
||||
- Modify: `src-tauri/src/lib.rs` (add `mod models`)
|
||||
|
||||
- [ ] **Step 1: Write failing tests for Project CRUD**
|
||||
- [x] **Step 1: Write failing tests for Project CRUD**
|
||||
|
||||
Create `src-tauri/src/models/mod.rs`:
|
||||
|
||||
|
|
@ -740,7 +740,7 @@ mod tests {
|
|||
|
||||
Add `mod models;` to `src-tauri/src/lib.rs` (after `mod error;`).
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
- [x] **Step 2: Run tests to verify they fail**
|
||||
|
||||
```bash
|
||||
cd /home/leclere/Projets/orchai/src-tauri
|
||||
|
|
@ -749,7 +749,7 @@ cargo test models::project::tests
|
|||
|
||||
Expected: 8 failures with `not yet implemented`.
|
||||
|
||||
- [ ] **Step 3: Implement Project CRUD**
|
||||
- [x] **Step 3: Implement Project CRUD**
|
||||
|
||||
Replace the `todo!()` implementations in `src-tauri/src/models/project.rs`:
|
||||
|
||||
|
|
@ -833,7 +833,7 @@ Add the missing imports at the top of the file:
|
|||
use chrono;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
- [x] **Step 4: Run tests to verify they pass**
|
||||
|
||||
```bash
|
||||
cd /home/leclere/Projets/orchai/src-tauri
|
||||
|
|
@ -842,7 +842,7 @@ cargo test models::project::tests
|
|||
|
||||
Expected: 8 tests pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
|
|
@ -859,7 +859,7 @@ git commit -m "feat: Project model with CRUD operations and tests"
|
|||
- Modify: `src-tauri/src/lib.rs`
|
||||
- Modify: `src-tauri/Cargo.toml` (add tauri-plugin-dialog)
|
||||
|
||||
- [ ] **Step 1: Add dialog plugin dependency**
|
||||
- [x] **Step 1: Add dialog plugin dependency**
|
||||
|
||||
Add to `src-tauri/Cargo.toml` under `[dependencies]`:
|
||||
|
||||
|
|
@ -873,7 +873,7 @@ Add to the `capabilities/default.json` permissions array:
|
|||
"dialog:default"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create commands module**
|
||||
- [x] **Step 2: Create commands module**
|
||||
|
||||
Create `src-tauri/src/commands/mod.rs`:
|
||||
|
||||
|
|
@ -966,7 +966,7 @@ pub fn delete_project(state: State<'_, AppState>, id: String) -> Result<(), AppE
|
|||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `dirs` dependency**
|
||||
- [x] **Step 3: Add `dirs` dependency**
|
||||
|
||||
Add to `src-tauri/Cargo.toml` under `[dependencies]`:
|
||||
|
||||
|
|
@ -974,7 +974,7 @@ Add to `src-tauri/Cargo.toml` under `[dependencies]`:
|
|||
dirs = "5"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Wire up commands in lib.rs**
|
||||
- [x] **Step 4: Wire up commands in lib.rs**
|
||||
|
||||
Replace `src-tauri/src/lib.rs`:
|
||||
|
||||
|
|
@ -1016,7 +1016,7 @@ pub fn run() {
|
|||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify it compiles**
|
||||
- [x] **Step 5: Verify it compiles**
|
||||
|
||||
```bash
|
||||
cd /home/leclere/Projets/orchai/src-tauri
|
||||
|
|
@ -1025,7 +1025,7 @@ cargo build
|
|||
|
||||
Expected: compiles with no errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
- [x] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
|
|
@ -1040,7 +1040,7 @@ git commit -m "feat: Tauri commands for project CRUD with git clone support"
|
|||
- Create: `src/lib/types.ts`
|
||||
- Create: `src/lib/api.ts`
|
||||
|
||||
- [ ] **Step 1: Install frontend dependencies**
|
||||
- [x] **Step 1: Install frontend dependencies**
|
||||
|
||||
```bash
|
||||
cd /home/leclere/Projets/orchai
|
||||
|
|
@ -1048,7 +1048,7 @@ npm install react-router-dom
|
|||
npm install @tauri-apps/plugin-dialog
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create TypeScript types**
|
||||
- [x] **Step 2: Create TypeScript types**
|
||||
|
||||
Create `src/lib/types.ts`:
|
||||
|
||||
|
|
@ -1063,7 +1063,7 @@ export interface Project {
|
|||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create API wrapper**
|
||||
- [x] **Step 3: Create API wrapper**
|
||||
|
||||
Create `src/lib/api.ts`:
|
||||
|
||||
|
|
@ -1104,7 +1104,7 @@ export async function deleteProject(id: string): Promise<void> {
|
|||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
- [x] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
|
|
@ -1120,7 +1120,7 @@ git commit -m "feat: TypeScript types and Tauri API wrappers for project CRUD"
|
|||
- Create: `src/components/layout/AppLayout.tsx`
|
||||
- Create: `src/components/layout/Sidebar.tsx`
|
||||
|
||||
- [ ] **Step 1: Create Sidebar component**
|
||||
- [x] **Step 1: Create Sidebar component**
|
||||
|
||||
Create directory structure:
|
||||
|
||||
|
|
@ -1196,7 +1196,7 @@ export default function Sidebar() {
|
|||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create AppLayout component**
|
||||
- [x] **Step 2: Create AppLayout component**
|
||||
|
||||
Create `src/components/layout/AppLayout.tsx`:
|
||||
|
||||
|
|
@ -1216,7 +1216,7 @@ export default function AppLayout() {
|
|||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Set up router in App.tsx**
|
||||
- [x] **Step 3: Set up router in App.tsx**
|
||||
|
||||
Replace `src/App.tsx`:
|
||||
|
||||
|
|
@ -1251,7 +1251,7 @@ function App() {
|
|||
export default App;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify the shell renders**
|
||||
- [x] **Step 4: Verify the shell renders**
|
||||
|
||||
```bash
|
||||
npm run tauri dev
|
||||
|
|
@ -1259,7 +1259,7 @@ npm run tauri dev
|
|||
|
||||
Expected: window opens with dark sidebar on the left showing "Orchai" header, "Projects" section with "No projects yet" message, and a "+" button. Main area shows "Select a project or create a new one". Close after verifying.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
|
|
@ -1275,7 +1275,7 @@ git commit -m "feat: React app shell with router, sidebar layout"
|
|||
- Create: `src/components/projects/ProjectDashboard.tsx`
|
||||
- Modify: `src/App.tsx`
|
||||
|
||||
- [ ] **Step 1: Create ProjectForm component**
|
||||
- [x] **Step 1: Create ProjectForm component**
|
||||
|
||||
Create `src/components/projects/ProjectForm.tsx`:
|
||||
|
||||
|
|
@ -1456,7 +1456,7 @@ export default function ProjectForm() {
|
|||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create ProjectDashboard placeholder**
|
||||
- [x] **Step 2: Create ProjectDashboard placeholder**
|
||||
|
||||
Create `src/components/projects/ProjectDashboard.tsx`:
|
||||
|
||||
|
|
@ -1539,7 +1539,7 @@ export default function ProjectDashboard() {
|
|||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Wire up routes in App.tsx**
|
||||
- [x] **Step 3: Wire up routes in App.tsx**
|
||||
|
||||
Replace `src/App.tsx`:
|
||||
|
||||
|
|
@ -1576,7 +1576,7 @@ function App() {
|
|||
export default App;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify the full flow in the browser**
|
||||
- [x] **Step 4: Verify the full flow in the browser**
|
||||
|
||||
```bash
|
||||
npm run tauri dev
|
||||
|
|
@ -1590,7 +1590,7 @@ Test the following:
|
|||
5. Click "Edit" -- form pre-fills with project data
|
||||
6. Click "Delete" -- project removed from sidebar
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
|
|
@ -1605,7 +1605,7 @@ git commit -m "feat: project create/edit/delete UI with folder picker and git cl
|
|||
- Verify all tests pass
|
||||
- Clean up any scaffold files not needed
|
||||
|
||||
- [ ] **Step 1: Run all Rust tests**
|
||||
- [x] **Step 1: Run all Rust tests**
|
||||
|
||||
```bash
|
||||
cd /home/leclere/Projets/orchai/src-tauri
|
||||
|
|
@ -1614,7 +1614,7 @@ cargo test
|
|||
|
||||
Expected: all tests pass (8 model tests + 3 db tests = 11 tests).
|
||||
|
||||
- [ ] **Step 2: Run Rust clippy**
|
||||
- [x] **Step 2: Run Rust clippy**
|
||||
|
||||
```bash
|
||||
cd /home/leclere/Projets/orchai/src-tauri
|
||||
|
|
@ -1623,7 +1623,7 @@ cargo clippy -- -D warnings
|
|||
|
||||
Expected: no warnings. If there are warnings, fix them.
|
||||
|
||||
- [ ] **Step 3: Verify frontend builds**
|
||||
- [x] **Step 3: Verify frontend builds**
|
||||
|
||||
```bash
|
||||
cd /home/leclere/Projets/orchai
|
||||
|
|
@ -1632,13 +1632,13 @@ npm run build
|
|||
|
||||
Expected: Vite build succeeds.
|
||||
|
||||
- [ ] **Step 4: Clean up scaffold files**
|
||||
- [x] **Step 4: Clean up scaffold files**
|
||||
|
||||
Remove any remaining scaffold assets that are not needed:
|
||||
- `src/assets/react.svg` (if still present)
|
||||
- Any other default scaffold content
|
||||
|
||||
- [ ] **Step 5: Final integration test**
|
||||
- [x] **Step 5: Final integration test**
|
||||
|
||||
```bash
|
||||
npm run tauri dev
|
||||
|
|
@ -1652,7 +1652,7 @@ Test the complete flow one more time:
|
|||
5. Delete the project
|
||||
6. Verify sidebar is empty again
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
- [x] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
2335
docs/superpowers/plans/2026-04-13-orchai-phase3-agent-pipeline.md
Normal file
2335
docs/superpowers/plans/2026-04-13-orchai-phase3-agent-pipeline.md
Normal file
File diff suppressed because it is too large
Load diff
12
index.html
Normal file
12
index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Orchai</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2757
package-lock.json
generated
Normal file
2757
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
29
package.json
Normal file
29
package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "orchai",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
5733
src-tauri/Cargo.lock
generated
Normal file
5733
src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
43
src-tauri/Cargo.toml
Normal file
43
src-tauri/Cargo.toml
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
[package]
|
||||
name = "orchai"
|
||||
version = "0.1.0"
|
||||
description = "Orchai - Tuleap tracker monitor & AI agent dispatcher"
|
||||
authors = []
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "orchai_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dirs = "5"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
tokio = { version = "1", features = ["time", "sync", "macros"] }
|
||||
aes-gcm = "0.10"
|
||||
rand = "0.8"
|
||||
base64 = "0.22"
|
||||
|
||||
[profile.dev]
|
||||
incremental = true # Compiles your binary in smaller steps.
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1 # Allows LLVM to perform better optimization.
|
||||
lto = true # Enables link-time-optimizations.
|
||||
opt-level = "s" # Prioritizes small binary size. Use `3` if you prefer speed.
|
||||
panic = "abort" # Higher performance by disabling panic handlers.
|
||||
strip = true # Ensures debug symbols are removed.
|
||||
3
src-tauri/build.rs
Normal file
3
src-tauri/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
10
src-tauri/capabilities/default.json
Normal file
10
src-tauri/capabilities/default.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
BIN
src-tauri/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 299 B |
BIN
src-tauri/icons/128x128@2x.png
Normal file
BIN
src-tauri/icons/128x128@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 666 B |
BIN
src-tauri/icons/32x32.png
Normal file
BIN
src-tauri/icons/32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 104 B |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.icns
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
src-tauri/icons/icon.ico
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 688 B |
63
src-tauri/migrations/001_init.sql
Normal file
63
src-tauri/migrations/001_init.sql
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
cloned_from TEXT,
|
||||
base_branch TEXT NOT NULL DEFAULT 'main',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tuleap_credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
tuleap_url TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password_encrypted TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS watched_trackers (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
tracker_id INTEGER NOT NULL,
|
||||
tracker_label TEXT NOT NULL,
|
||||
polling_interval INTEGER NOT NULL DEFAULT 10,
|
||||
agent_config_json TEXT NOT NULL,
|
||||
filters_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS processed_tickets (
|
||||
id TEXT PRIMARY KEY,
|
||||
tracker_id TEXT NOT NULL REFERENCES watched_trackers(id) ON DELETE CASCADE,
|
||||
artifact_id INTEGER NOT NULL,
|
||||
artifact_title TEXT NOT NULL,
|
||||
artifact_data TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'Pending',
|
||||
analyst_report TEXT,
|
||||
developer_report TEXT,
|
||||
worktree_path TEXT,
|
||||
branch_name TEXT,
|
||||
detected_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
processed_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS worktrees (
|
||||
id TEXT PRIMARY KEY,
|
||||
ticket_id TEXT NOT NULL REFERENCES processed_tickets(id),
|
||||
path TEXT NOT NULL,
|
||||
branch_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'Active',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
merged_at TEXT,
|
||||
merged_into TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
ticket_id TEXT REFERENCES processed_tickets(id),
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
read INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
2
src-tauri/migrations/002_add_last_polled.sql
Normal file
2
src-tauri/migrations/002_add_last_polled.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE watched_trackers ADD COLUMN last_polled_at TEXT;
|
||||
ALTER TABLE watched_trackers ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1;
|
||||
80
src-tauri/src/commands/credential.rs
Normal file
80
src-tauri/src/commands/credential.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
use crate::error::AppError;
|
||||
use crate::models::credential::{TuleapCredentials, TuleapCredentialsSafe};
|
||||
use crate::services::crypto;
|
||||
use crate::AppState;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_tuleap_credentials(
|
||||
state: State<'_, AppState>,
|
||||
tuleap_url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<TuleapCredentialsSafe, AppError> {
|
||||
let password_encrypted = crypto::encrypt(&state.encryption_key, &password)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let db = state
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
|
||||
|
||||
let creds = TuleapCredentials::upsert(&db, &tuleap_url, &username, &password_encrypted)?;
|
||||
Ok(creds.to_safe())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_tuleap_credentials(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Option<TuleapCredentialsSafe>, AppError> {
|
||||
let db = state
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
|
||||
|
||||
let result = TuleapCredentials::get(&db)?.map(|c| c.to_safe());
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_tuleap_credentials(state: State<'_, AppState>) -> Result<(), AppError> {
|
||||
let db = state
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
|
||||
|
||||
TuleapCredentials::delete(&db)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn test_tuleap_connection(state: State<'_, AppState>) -> Result<String, AppError> {
|
||||
let (tuleap_url, username, password) = {
|
||||
let db = state
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
|
||||
|
||||
let creds = TuleapCredentials::get(&db)?
|
||||
.ok_or_else(|| AppError::from("No credentials configured".to_string()))?;
|
||||
|
||||
let password = crypto::decrypt(&state.encryption_key, &creds.password_encrypted)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
(creds.tuleap_url, creds.username, password)
|
||||
};
|
||||
|
||||
let url = format!("{}/api/projects?limit=1", tuleap_url.trim_end_matches('/'));
|
||||
|
||||
state
|
||||
.http_client
|
||||
.get(&url)
|
||||
.basic_auth(&username, Some(&password))
|
||||
.send()
|
||||
.await
|
||||
.map_err(AppError::from)?
|
||||
.error_for_status()
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
Ok("Connection successful".to_string())
|
||||
}
|
||||
4
src-tauri/src/commands/mod.rs
Normal file
4
src-tauri/src/commands/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub mod credential;
|
||||
pub mod poller;
|
||||
pub mod project;
|
||||
pub mod tracker;
|
||||
98
src-tauri/src/commands/poller.rs
Normal file
98
src-tauri/src/commands/poller.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
use crate::error::AppError;
|
||||
use crate::models::credential::TuleapCredentials;
|
||||
use crate::models::ticket::ProcessedTicket;
|
||||
use crate::models::tracker::WatchedTracker;
|
||||
use crate::services::{crypto, filter_engine};
|
||||
use crate::services::tuleap_client::TuleapClient;
|
||||
use crate::AppState;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn manual_poll(
|
||||
state: State<'_, AppState>,
|
||||
tracker_id: String,
|
||||
) -> Result<Vec<ProcessedTicket>, AppError> {
|
||||
let (tracker, client) = {
|
||||
let db = state
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
|
||||
|
||||
let tracker = WatchedTracker::get_by_id(&db, &tracker_id)?;
|
||||
|
||||
let cred = TuleapCredentials::get(&db)?
|
||||
.ok_or_else(|| AppError::from("No Tuleap credentials configured".to_string()))?;
|
||||
|
||||
let password = crypto::decrypt(&state.encryption_key, &cred.password_encrypted)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let client = TuleapClient::new(
|
||||
&state.http_client,
|
||||
&cred.tuleap_url,
|
||||
&cred.username,
|
||||
&password,
|
||||
);
|
||||
|
||||
(tracker, client)
|
||||
}; // lock dropped here
|
||||
|
||||
let artifacts = client
|
||||
.get_artifacts(tracker.tracker_id)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let filtered = filter_engine::apply_filters(&artifacts, &tracker.filters);
|
||||
|
||||
let mut newly_inserted: Vec<ProcessedTicket> = Vec::new();
|
||||
|
||||
{
|
||||
let db = state
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
|
||||
|
||||
for artifact in &filtered {
|
||||
let artifact_id = artifact
|
||||
.get("id")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0) as i32;
|
||||
|
||||
let artifact_title = artifact
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let artifact_data = serde_json::to_string(artifact)
|
||||
.unwrap_or_else(|_| "{}".to_string());
|
||||
|
||||
if let Some(ticket) = ProcessedTicket::insert_if_new(
|
||||
&db,
|
||||
&tracker.id,
|
||||
artifact_id,
|
||||
&artifact_title,
|
||||
&artifact_data,
|
||||
)? {
|
||||
newly_inserted.push(ticket);
|
||||
}
|
||||
}
|
||||
|
||||
WatchedTracker::update_last_polled(&db, &tracker.id)?;
|
||||
}
|
||||
|
||||
Ok(newly_inserted)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_queue_status(
|
||||
state: State<'_, AppState>,
|
||||
project_id: String,
|
||||
) -> Result<Vec<ProcessedTicket>, AppError> {
|
||||
let db = state
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
|
||||
|
||||
let tickets = ProcessedTicket::list_by_project(&db, &project_id)?;
|
||||
Ok(tickets)
|
||||
}
|
||||
83
src-tauri/src/commands/project.rs
Normal file
83
src-tauri/src/commands/project.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
use crate::error::AppError;
|
||||
use crate::models::project::Project;
|
||||
use crate::AppState;
|
||||
use std::process::Command;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_project(
|
||||
state: State<'_, AppState>,
|
||||
name: String,
|
||||
path_or_url: String,
|
||||
base_branch: String,
|
||||
) -> Result<Project, AppError> {
|
||||
let is_url = path_or_url.starts_with("http://")
|
||||
|| path_or_url.starts_with("https://")
|
||||
|| path_or_url.starts_with("git@");
|
||||
|
||||
let (local_path, cloned_from) = if is_url {
|
||||
let home = dirs::home_dir().ok_or_else(|| AppError::from("Cannot determine home directory".to_string()))?;
|
||||
let clone_dir = home.join("orchai-repos").join(&name);
|
||||
std::fs::create_dir_all(&clone_dir)?;
|
||||
|
||||
let clone_dir_str = clone_dir.to_str()
|
||||
.ok_or_else(|| AppError::from("Clone path contains invalid characters".to_string()))?;
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["clone", &path_or_url, clone_dir_str])
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(AppError::from(format!("git clone failed: {}", stderr)));
|
||||
}
|
||||
|
||||
(clone_dir.to_string_lossy().to_string(), Some(path_or_url))
|
||||
} else {
|
||||
let path = std::path::Path::new(&path_or_url);
|
||||
if !path.exists() {
|
||||
return Err(AppError::from(format!("Path does not exist: {}", path_or_url)));
|
||||
}
|
||||
if !path.join(".git").exists() {
|
||||
return Err(AppError::from(format!("Not a git repository: {}", path_or_url)));
|
||||
}
|
||||
(path_or_url, None)
|
||||
};
|
||||
|
||||
let db = state.db.lock().map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
|
||||
let project = Project::insert(&db, &name, &local_path, cloned_from.as_deref(), &base_branch)?;
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, AppError> {
|
||||
let db = state.db.lock().map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
|
||||
let projects = Project::list(&db)?;
|
||||
Ok(projects)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_project(state: State<'_, AppState>, id: String) -> Result<Project, AppError> {
|
||||
let db = state.db.lock().map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
|
||||
let project = Project::get_by_id(&db, &id)?;
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_project(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
name: String,
|
||||
base_branch: String,
|
||||
) -> Result<(), AppError> {
|
||||
let db = state.db.lock().map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
|
||||
Project::update(&db, &id, &name, &base_branch)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_project(state: State<'_, AppState>, id: String) -> Result<(), AppError> {
|
||||
let db = state.db.lock().map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
|
||||
Project::delete(&db, &id)?;
|
||||
Ok(())
|
||||
}
|
||||
126
src-tauri/src/commands/tracker.rs
Normal file
126
src-tauri/src/commands/tracker.rs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
use crate::error::AppError;
|
||||
use crate::models::credential::TuleapCredentials;
|
||||
use crate::models::ticket::ProcessedTicket;
|
||||
use crate::models::tracker::{AgentConfig, FilterGroup, WatchedTracker};
|
||||
use crate::services::crypto;
|
||||
use crate::services::tuleap_client::TuleapClient;
|
||||
use crate::AppState;
|
||||
use tauri::State;
|
||||
|
||||
fn build_tuleap_client(state: &State<AppState>) -> Result<TuleapClient, AppError> {
|
||||
let db = state
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
|
||||
|
||||
let cred = TuleapCredentials::get(&db)?
|
||||
.ok_or_else(|| AppError::from("No Tuleap credentials configured".to_string()))?;
|
||||
|
||||
let password = crypto::decrypt(&state.encryption_key, &cred.password_encrypted)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
Ok(TuleapClient::new(
|
||||
&state.http_client,
|
||||
&cred.tuleap_url,
|
||||
&cred.username,
|
||||
&password,
|
||||
))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn add_tracker(
|
||||
state: State<'_, AppState>,
|
||||
project_id: String,
|
||||
tracker_id: i32,
|
||||
tracker_label: String,
|
||||
polling_interval: i32,
|
||||
agent_config: AgentConfig,
|
||||
filters: Vec<FilterGroup>,
|
||||
) -> Result<WatchedTracker, AppError> {
|
||||
let db = state
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
|
||||
|
||||
let tracker = WatchedTracker::insert(
|
||||
&db,
|
||||
&project_id,
|
||||
tracker_id,
|
||||
&tracker_label,
|
||||
polling_interval,
|
||||
agent_config,
|
||||
filters,
|
||||
)?;
|
||||
|
||||
Ok(tracker)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_trackers(
|
||||
state: State<'_, AppState>,
|
||||
project_id: String,
|
||||
) -> Result<Vec<WatchedTracker>, AppError> {
|
||||
let db = state
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
|
||||
|
||||
let trackers = WatchedTracker::list_by_project(&db, &project_id)?;
|
||||
Ok(trackers)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_tracker(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
polling_interval: i32,
|
||||
agent_config: AgentConfig,
|
||||
filters: Vec<FilterGroup>,
|
||||
enabled: bool,
|
||||
) -> Result<(), AppError> {
|
||||
let db = state
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
|
||||
|
||||
WatchedTracker::update(&db, &id, polling_interval, agent_config, filters, enabled)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn remove_tracker(state: State<'_, AppState>, id: String) -> Result<(), AppError> {
|
||||
let db = state
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
|
||||
|
||||
WatchedTracker::delete(&db, &id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_tracker_fields(
|
||||
state: State<'_, AppState>,
|
||||
tracker_id: i32,
|
||||
) -> Result<Vec<crate::services::tuleap_client::TrackerField>, AppError> {
|
||||
let client = build_tuleap_client(&state)?;
|
||||
let fields = client
|
||||
.get_tracker_fields(tracker_id)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(fields)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_processed_tickets(
|
||||
state: State<'_, AppState>,
|
||||
project_id: String,
|
||||
) -> Result<Vec<ProcessedTicket>, AppError> {
|
||||
let db = state
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
|
||||
|
||||
let tickets = ProcessedTicket::list_by_project(&db, &project_id)?;
|
||||
Ok(tickets)
|
||||
}
|
||||
91
src-tauri/src/db.rs
Normal file
91
src-tauri/src/db.rs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
use rusqlite::{Connection, Result};
|
||||
use std::path::Path;
|
||||
|
||||
const MIGRATION_001: &str = include_str!("../migrations/001_init.sql");
|
||||
const MIGRATION_002: &str = include_str!("../migrations/002_add_last_polled.sql");
|
||||
|
||||
pub fn init(db_path: &Path) -> Result<Connection> {
|
||||
let conn = Connection::open(db_path)?;
|
||||
configure(&conn)?;
|
||||
migrate(&conn)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn init_in_memory() -> Result<Connection> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
configure(&conn)?;
|
||||
migrate(&conn)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
fn configure(conn: &Connection) -> Result<()> {
|
||||
conn.pragma_update(None, "journal_mode", "wal")?;
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate(conn: &Connection) -> Result<()> {
|
||||
let version: i32 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
|
||||
|
||||
if version < 1 {
|
||||
conn.execute_batch(MIGRATION_001)?;
|
||||
conn.pragma_update(None, "user_version", 1)?;
|
||||
}
|
||||
if version < 2 {
|
||||
conn.execute_batch(MIGRATION_002)?;
|
||||
conn.pragma_update(None, "user_version", 2)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_init_in_memory_creates_tables() {
|
||||
let conn = init_in_memory().expect("should initialize");
|
||||
|
||||
// Verify all 6 tables exist
|
||||
let tables: Vec<String> = conn
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
.unwrap()
|
||||
.query_map([], |row| row.get(0))
|
||||
.unwrap()
|
||||
.collect::<Result<Vec<String>, _>>()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
tables,
|
||||
vec![
|
||||
"notifications",
|
||||
"processed_tickets",
|
||||
"projects",
|
||||
"tuleap_credentials",
|
||||
"watched_trackers",
|
||||
"worktrees",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_in_memory_enables_foreign_keys() {
|
||||
let conn = init_in_memory().expect("should initialize");
|
||||
let fk_enabled: i32 = conn
|
||||
.query_row("PRAGMA foreign_keys", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(fk_enabled, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_is_idempotent() {
|
||||
let conn = init_in_memory().expect("should initialize");
|
||||
// Running init again on same connection should not fail
|
||||
let version: i32 = conn
|
||||
.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(version, 2);
|
||||
}
|
||||
}
|
||||
42
src-tauri/src/error.rs
Normal file
42
src-tauri/src/error.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AppError {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for AppError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
AppError {
|
||||
message: e.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for AppError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
AppError {
|
||||
message: e.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for AppError {
|
||||
fn from(s: String) -> Self {
|
||||
AppError { message: s }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for AppError {
|
||||
fn from(e: reqwest::Error) -> Self {
|
||||
AppError { message: e.to_string() }
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AppError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AppError {}
|
||||
88
src-tauri/src/lib.rs
Normal file
88
src-tauri/src/lib.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
mod commands;
|
||||
mod db;
|
||||
mod error;
|
||||
mod models;
|
||||
mod services;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::Manager;
|
||||
|
||||
pub struct AppState {
|
||||
pub db: Arc<Mutex<rusqlite::Connection>>,
|
||||
pub encryption_key: [u8; 32],
|
||||
pub http_client: reqwest::Client,
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.setup(|app| {
|
||||
let db_dir = app.path().app_data_dir()?;
|
||||
std::fs::create_dir_all(&db_dir)?;
|
||||
|
||||
let db_path = db_dir.join("orchai.db");
|
||||
let conn = db::init(&db_path).expect("Failed to initialize database");
|
||||
|
||||
let key_path = db_dir.join("orchai.key");
|
||||
let encryption_key = load_or_generate_key(&key_path)?;
|
||||
|
||||
let http_client = reqwest::Client::new();
|
||||
|
||||
let db_arc = Arc::new(Mutex::new(conn));
|
||||
app.manage(AppState {
|
||||
db: db_arc.clone(),
|
||||
encryption_key,
|
||||
http_client: http_client.clone(),
|
||||
});
|
||||
|
||||
// Start background poller
|
||||
services::poller::start(
|
||||
db_arc,
|
||||
encryption_key,
|
||||
http_client,
|
||||
app.handle().clone(),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::project::create_project,
|
||||
commands::project::list_projects,
|
||||
commands::project::get_project,
|
||||
commands::project::update_project,
|
||||
commands::project::delete_project,
|
||||
commands::credential::set_tuleap_credentials,
|
||||
commands::credential::get_tuleap_credentials,
|
||||
commands::credential::delete_tuleap_credentials,
|
||||
commands::credential::test_tuleap_connection,
|
||||
commands::tracker::add_tracker,
|
||||
commands::tracker::list_trackers,
|
||||
commands::tracker::update_tracker,
|
||||
commands::tracker::remove_tracker,
|
||||
commands::tracker::get_tracker_fields,
|
||||
commands::tracker::list_processed_tickets,
|
||||
commands::poller::manual_poll,
|
||||
commands::poller::get_queue_status,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
fn load_or_generate_key(path: &std::path::Path) -> Result<[u8; 32], Box<dyn std::error::Error>> {
|
||||
use rand::RngCore;
|
||||
if path.exists() {
|
||||
let bytes = std::fs::read(path)?;
|
||||
if bytes.len() != 32 {
|
||||
return Err("Invalid key file size".into());
|
||||
}
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&bytes);
|
||||
Ok(key)
|
||||
} else {
|
||||
let mut key = [0u8; 32];
|
||||
rand::rngs::OsRng.fill_bytes(&mut key);
|
||||
std::fs::write(path, key)?;
|
||||
Ok(key)
|
||||
}
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
6
src-tauri/src/main.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
orchai_lib::run()
|
||||
}
|
||||
165
src-tauri/src/models/credential.rs
Normal file
165
src-tauri/src/models/credential.rs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
use rusqlite::{params, Connection, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TuleapCredentials {
|
||||
pub id: String,
|
||||
pub tuleap_url: String,
|
||||
pub username: String,
|
||||
pub password_encrypted: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TuleapCredentialsSafe {
|
||||
pub id: String,
|
||||
pub tuleap_url: String,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
impl TuleapCredentials {
|
||||
pub fn upsert(
|
||||
conn: &Connection,
|
||||
tuleap_url: &str,
|
||||
username: &str,
|
||||
password_encrypted: &str,
|
||||
) -> Result<TuleapCredentials> {
|
||||
conn.execute("DELETE FROM tuleap_credentials", [])?;
|
||||
|
||||
let id = Uuid::new_v4().to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO tuleap_credentials (id, tuleap_url, username, password_encrypted) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![id, tuleap_url, username, password_encrypted],
|
||||
)?;
|
||||
|
||||
Ok(TuleapCredentials {
|
||||
id,
|
||||
tuleap_url: tuleap_url.to_string(),
|
||||
username: username.to_string(),
|
||||
password_encrypted: password_encrypted.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get(conn: &Connection) -> Result<Option<TuleapCredentials>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, tuleap_url, username, password_encrypted FROM tuleap_credentials LIMIT 1",
|
||||
)?;
|
||||
let mut rows = stmt.query_map([], |row| {
|
||||
Ok(TuleapCredentials {
|
||||
id: row.get(0)?,
|
||||
tuleap_url: row.get(1)?,
|
||||
username: row.get(2)?,
|
||||
password_encrypted: row.get(3)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
match rows.next() {
|
||||
Some(row) => Ok(Some(row?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete(conn: &Connection) -> Result<()> {
|
||||
conn.execute("DELETE FROM tuleap_credentials", [])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn to_safe(&self) -> TuleapCredentialsSafe {
|
||||
TuleapCredentialsSafe {
|
||||
id: self.id.clone(),
|
||||
tuleap_url: self.tuleap_url.clone(),
|
||||
username: self.username.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db;
|
||||
|
||||
fn setup() -> Connection {
|
||||
db::init_in_memory().expect("db init should succeed")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_creates_credentials() {
|
||||
let conn = setup();
|
||||
let creds = TuleapCredentials::upsert(
|
||||
&conn,
|
||||
"https://tuleap.example.com",
|
||||
"alice",
|
||||
"encrypted_password",
|
||||
)
|
||||
.expect("upsert should succeed");
|
||||
|
||||
assert_eq!(creds.tuleap_url, "https://tuleap.example.com");
|
||||
assert_eq!(creds.username, "alice");
|
||||
assert_eq!(creds.password_encrypted, "encrypted_password");
|
||||
assert!(!creds.id.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_replaces_existing() {
|
||||
let conn = setup();
|
||||
TuleapCredentials::upsert(&conn, "https://old.example.com", "old_user", "old_enc")
|
||||
.expect("first upsert should succeed");
|
||||
|
||||
let second = TuleapCredentials::upsert(
|
||||
&conn,
|
||||
"https://new.example.com",
|
||||
"new_user",
|
||||
"new_enc",
|
||||
)
|
||||
.expect("second upsert should succeed");
|
||||
|
||||
// Only one record should exist
|
||||
let creds = TuleapCredentials::get(&conn)
|
||||
.expect("get should succeed")
|
||||
.expect("should have credentials");
|
||||
|
||||
assert_eq!(creds.id, second.id);
|
||||
assert_eq!(creds.tuleap_url, "https://new.example.com");
|
||||
assert_eq!(creds.username, "new_user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_returns_none_when_empty() {
|
||||
let conn = setup();
|
||||
let result = TuleapCredentials::get(&conn).expect("get should succeed");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_returns_credentials() {
|
||||
let conn = setup();
|
||||
let created = TuleapCredentials::upsert(
|
||||
&conn,
|
||||
"https://tuleap.example.com",
|
||||
"bob",
|
||||
"enc_pass",
|
||||
)
|
||||
.expect("upsert should succeed");
|
||||
|
||||
let fetched = TuleapCredentials::get(&conn)
|
||||
.expect("get should succeed")
|
||||
.expect("should have credentials");
|
||||
|
||||
assert_eq!(fetched.id, created.id);
|
||||
assert_eq!(fetched.tuleap_url, "https://tuleap.example.com");
|
||||
assert_eq!(fetched.username, "bob");
|
||||
assert_eq!(fetched.password_encrypted, "enc_pass");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_removes_credentials() {
|
||||
let conn = setup();
|
||||
TuleapCredentials::upsert(&conn, "https://tuleap.example.com", "carol", "enc")
|
||||
.expect("upsert should succeed");
|
||||
|
||||
TuleapCredentials::delete(&conn).expect("delete should succeed");
|
||||
|
||||
let result = TuleapCredentials::get(&conn).expect("get should succeed");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
}
|
||||
4
src-tauri/src/models/mod.rs
Normal file
4
src-tauri/src/models/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub mod credential;
|
||||
pub mod project;
|
||||
pub mod ticket;
|
||||
pub mod tracker;
|
||||
190
src-tauri/src/models/project.rs
Normal file
190
src-tauri/src/models/project.rs
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
use rusqlite::{params, Connection, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub cloned_from: Option<String>,
|
||||
pub base_branch: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl Project {
|
||||
pub fn insert(
|
||||
conn: &Connection,
|
||||
name: &str,
|
||||
path: &str,
|
||||
cloned_from: Option<&str>,
|
||||
base_branch: &str,
|
||||
) -> Result<Project> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO projects (id, name, path, cloned_from, base_branch, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![id, name, path, cloned_from, base_branch, now],
|
||||
)?;
|
||||
|
||||
Ok(Project {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
path: path.to_string(),
|
||||
cloned_from: cloned_from.map(String::from),
|
||||
base_branch: base_branch.to_string(),
|
||||
created_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list(conn: &Connection) -> Result<Vec<Project>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, path, cloned_from, base_branch, created_at FROM projects ORDER BY created_at DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(Project {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
path: row.get(2)?,
|
||||
cloned_from: row.get(3)?,
|
||||
base_branch: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn get_by_id(conn: &Connection, id: &str) -> Result<Project> {
|
||||
conn.query_row(
|
||||
"SELECT id, name, path, cloned_from, base_branch, created_at FROM projects WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
Ok(Project {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
path: row.get(2)?,
|
||||
cloned_from: row.get(3)?,
|
||||
base_branch: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn update(conn: &Connection, id: &str, name: &str, base_branch: &str) -> Result<()> {
|
||||
let affected = conn.execute(
|
||||
"UPDATE projects SET name = ?1, base_branch = ?2 WHERE id = ?3",
|
||||
params![name, base_branch, id],
|
||||
)?;
|
||||
if affected == 0 {
|
||||
return Err(rusqlite::Error::QueryReturnedNoRows);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete(conn: &Connection, id: &str) -> Result<()> {
|
||||
let affected = conn.execute("DELETE FROM projects WHERE id = ?1", params![id])?;
|
||||
if affected == 0 {
|
||||
return Err(rusqlite::Error::QueryReturnedNoRows);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db;
|
||||
|
||||
fn setup() -> Connection {
|
||||
db::init_in_memory().expect("db init should succeed")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_project_local_path() {
|
||||
let conn = setup();
|
||||
let project = Project::insert(&conn, "My Project", "/home/user/code/myproject", None, "main")
|
||||
.expect("insert should succeed");
|
||||
|
||||
assert_eq!(project.name, "My Project");
|
||||
assert_eq!(project.path, "/home/user/code/myproject");
|
||||
assert!(project.cloned_from.is_none());
|
||||
assert_eq!(project.base_branch, "main");
|
||||
assert!(!project.id.is_empty());
|
||||
assert!(!project.created_at.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_project_cloned() {
|
||||
let conn = setup();
|
||||
let project = Project::insert(
|
||||
&conn,
|
||||
"Cloned Project",
|
||||
"/home/user/code/cloned",
|
||||
Some("https://github.com/org/repo.git"),
|
||||
"stable",
|
||||
)
|
||||
.expect("insert should succeed");
|
||||
|
||||
assert_eq!(project.cloned_from.as_deref(), Some("https://github.com/org/repo.git"));
|
||||
assert_eq!(project.base_branch, "stable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_projects_empty() {
|
||||
let conn = setup();
|
||||
let projects = Project::list(&conn).expect("list should succeed");
|
||||
assert!(projects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_projects_returns_all() {
|
||||
let conn = setup();
|
||||
Project::insert(&conn, "A", "/path/a", None, "main").unwrap();
|
||||
Project::insert(&conn, "B", "/path/b", None, "main").unwrap();
|
||||
|
||||
let projects = Project::list(&conn).expect("list should succeed");
|
||||
assert_eq!(projects.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_by_id() {
|
||||
let conn = setup();
|
||||
let created = Project::insert(&conn, "Test", "/path/test", None, "main").unwrap();
|
||||
let found = Project::get_by_id(&conn, &created.id).expect("get should succeed");
|
||||
|
||||
assert_eq!(found.id, created.id);
|
||||
assert_eq!(found.name, "Test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_by_id_not_found() {
|
||||
let conn = setup();
|
||||
let result = Project::get_by_id(&conn, "nonexistent");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_project() {
|
||||
let conn = setup();
|
||||
let created = Project::insert(&conn, "Old Name", "/path", None, "main").unwrap();
|
||||
|
||||
Project::update(&conn, &created.id, "New Name", "develop").expect("update should succeed");
|
||||
|
||||
let updated = Project::get_by_id(&conn, &created.id).unwrap();
|
||||
assert_eq!(updated.name, "New Name");
|
||||
assert_eq!(updated.base_branch, "develop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_project() {
|
||||
let conn = setup();
|
||||
let created = Project::insert(&conn, "ToDelete", "/path", None, "main").unwrap();
|
||||
|
||||
Project::delete(&conn, &created.id).expect("delete should succeed");
|
||||
|
||||
let result = Project::get_by_id(&conn, &created.id);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
242
src-tauri/src/models/ticket.rs
Normal file
242
src-tauri/src/models/ticket.rs
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
use rusqlite::{params, Connection, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProcessedTicket {
|
||||
pub id: String,
|
||||
pub tracker_id: String,
|
||||
pub artifact_id: i32,
|
||||
pub artifact_title: String,
|
||||
pub artifact_data: String,
|
||||
pub status: String,
|
||||
pub analyst_report: Option<String>,
|
||||
pub developer_report: Option<String>,
|
||||
pub worktree_path: Option<String>,
|
||||
pub branch_name: Option<String>,
|
||||
pub detected_at: String,
|
||||
pub processed_at: Option<String>,
|
||||
}
|
||||
|
||||
fn from_row(row: &rusqlite::Row) -> rusqlite::Result<ProcessedTicket> {
|
||||
Ok(ProcessedTicket {
|
||||
id: row.get(0)?,
|
||||
tracker_id: row.get(1)?,
|
||||
artifact_id: row.get(2)?,
|
||||
artifact_title: row.get(3)?,
|
||||
artifact_data: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
analyst_report: row.get(6)?,
|
||||
developer_report: row.get(7)?,
|
||||
worktree_path: row.get(8)?,
|
||||
branch_name: row.get(9)?,
|
||||
detected_at: row.get(10)?,
|
||||
processed_at: row.get(11)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
const SELECT_ALL_COLS: &str = "SELECT id, tracker_id, artifact_id, artifact_title, artifact_data, \
|
||||
status, analyst_report, developer_report, worktree_path, branch_name, \
|
||||
detected_at, processed_at FROM processed_tickets";
|
||||
|
||||
impl ProcessedTicket {
|
||||
/// Insert a new ticket if one with the same (tracker_id, artifact_id) doesn't exist.
|
||||
/// Returns Some(ticket) if inserted, None if it was a duplicate.
|
||||
pub fn insert_if_new(
|
||||
conn: &Connection,
|
||||
tracker_id: &str,
|
||||
artifact_id: i32,
|
||||
artifact_title: &str,
|
||||
artifact_data: &str,
|
||||
) -> Result<Option<ProcessedTicket>> {
|
||||
if Self::exists(conn, tracker_id, artifact_id)? {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO processed_tickets \
|
||||
(id, tracker_id, artifact_id, artifact_title, artifact_data, status, detected_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, 'Pending', ?6)",
|
||||
params![id, tracker_id, artifact_id, artifact_title, artifact_data, now],
|
||||
)?;
|
||||
|
||||
let ticket = ProcessedTicket {
|
||||
id,
|
||||
tracker_id: tracker_id.to_string(),
|
||||
artifact_id,
|
||||
artifact_title: artifact_title.to_string(),
|
||||
artifact_data: artifact_data.to_string(),
|
||||
status: "Pending".to_string(),
|
||||
analyst_report: None,
|
||||
developer_report: None,
|
||||
worktree_path: None,
|
||||
branch_name: None,
|
||||
detected_at: now,
|
||||
processed_at: None,
|
||||
};
|
||||
|
||||
Ok(Some(ticket))
|
||||
}
|
||||
|
||||
/// Returns true if a ticket with (tracker_id, artifact_id) already exists.
|
||||
pub fn exists(conn: &Connection, tracker_id: &str, artifact_id: i32) -> Result<bool> {
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM processed_tickets WHERE tracker_id = ?1 AND artifact_id = ?2",
|
||||
params![tracker_id, artifact_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn list_by_tracker(conn: &Connection, tracker_id: &str) -> Result<Vec<ProcessedTicket>> {
|
||||
let sql = format!(
|
||||
"{} WHERE tracker_id = ?1 ORDER BY detected_at DESC",
|
||||
SELECT_ALL_COLS
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params![tracker_id], from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn list_by_project(conn: &Connection, project_id: &str) -> Result<Vec<ProcessedTicket>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT pt.id, pt.tracker_id, pt.artifact_id, pt.artifact_title, pt.artifact_data, \
|
||||
pt.status, pt.analyst_report, pt.developer_report, pt.worktree_path, pt.branch_name, \
|
||||
pt.detected_at, pt.processed_at \
|
||||
FROM processed_tickets pt \
|
||||
JOIN watched_trackers wt ON pt.tracker_id = wt.id \
|
||||
WHERE wt.project_id = ?1 \
|
||||
ORDER BY pt.detected_at DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![project_id], from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_by_id(conn: &Connection, id: &str) -> Result<ProcessedTicket> {
|
||||
let sql = format!("{} WHERE id = ?1", SELECT_ALL_COLS);
|
||||
conn.query_row(&sql, params![id], from_row)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db;
|
||||
use crate::models::project::Project;
|
||||
use crate::models::tracker::{AgentConfig, WatchedTracker};
|
||||
|
||||
fn setup() -> (Connection, String) {
|
||||
let conn = db::init_in_memory().expect("db init should succeed");
|
||||
let project = Project::insert(&conn, "Test", "/path", None, "main").unwrap();
|
||||
let agent_config = AgentConfig {
|
||||
analyst_command: "claude".into(),
|
||||
analyst_args: vec![],
|
||||
developer_command: "claude".into(),
|
||||
developer_args: vec![],
|
||||
};
|
||||
let tracker =
|
||||
WatchedTracker::insert(&conn, &project.id, 456, "Bugs", 10, agent_config, vec![])
|
||||
.unwrap();
|
||||
(conn, tracker.id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_if_new_creates_ticket() {
|
||||
let (conn, tracker_id) = setup();
|
||||
|
||||
let result = ProcessedTicket::insert_if_new(
|
||||
&conn,
|
||||
&tracker_id,
|
||||
101,
|
||||
"Fix login bug",
|
||||
"{\"id\": 101}",
|
||||
)
|
||||
.expect("insert_if_new should succeed");
|
||||
|
||||
assert!(result.is_some());
|
||||
let ticket = result.unwrap();
|
||||
assert_eq!(ticket.status, "Pending");
|
||||
assert_eq!(ticket.tracker_id, tracker_id);
|
||||
assert_eq!(ticket.artifact_id, 101);
|
||||
assert_eq!(ticket.artifact_title, "Fix login bug");
|
||||
assert!(ticket.analyst_report.is_none());
|
||||
assert!(ticket.processed_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_if_new_returns_none_for_duplicate() {
|
||||
let (conn, tracker_id) = setup();
|
||||
|
||||
let first = ProcessedTicket::insert_if_new(
|
||||
&conn,
|
||||
&tracker_id,
|
||||
202,
|
||||
"Crash on startup",
|
||||
"{\"id\": 202}",
|
||||
)
|
||||
.expect("first insert should succeed");
|
||||
assert!(first.is_some());
|
||||
|
||||
let second = ProcessedTicket::insert_if_new(
|
||||
&conn,
|
||||
&tracker_id,
|
||||
202,
|
||||
"Crash on startup",
|
||||
"{\"id\": 202}",
|
||||
)
|
||||
.expect("second insert_if_new should succeed");
|
||||
assert!(second.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exists() {
|
||||
let (conn, tracker_id) = setup();
|
||||
|
||||
let before = ProcessedTicket::exists(&conn, &tracker_id, 303)
|
||||
.expect("exists check should succeed");
|
||||
assert!(!before);
|
||||
|
||||
ProcessedTicket::insert_if_new(&conn, &tracker_id, 303, "Some ticket", "{}")
|
||||
.expect("insert should succeed");
|
||||
|
||||
let after = ProcessedTicket::exists(&conn, &tracker_id, 303)
|
||||
.expect("exists check after insert should succeed");
|
||||
assert!(after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_by_tracker() {
|
||||
let (conn, tracker_id) = setup();
|
||||
|
||||
ProcessedTicket::insert_if_new(&conn, &tracker_id, 1, "Ticket One", "{}").unwrap();
|
||||
ProcessedTicket::insert_if_new(&conn, &tracker_id, 2, "Ticket Two", "{}").unwrap();
|
||||
|
||||
let tickets =
|
||||
ProcessedTicket::list_by_tracker(&conn, &tracker_id).expect("list should succeed");
|
||||
assert_eq!(tickets.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_by_id() {
|
||||
let (conn, tracker_id) = setup();
|
||||
|
||||
let inserted =
|
||||
ProcessedTicket::insert_if_new(&conn, &tracker_id, 404, "Not Found Bug", "{\"id\": 404}")
|
||||
.expect("insert should succeed")
|
||||
.expect("should be Some");
|
||||
|
||||
let found =
|
||||
ProcessedTicket::get_by_id(&conn, &inserted.id).expect("get_by_id should succeed");
|
||||
|
||||
assert_eq!(found.id, inserted.id);
|
||||
assert_eq!(found.artifact_id, 404);
|
||||
assert_eq!(found.artifact_title, "Not Found Bug");
|
||||
assert_eq!(found.status, "Pending");
|
||||
}
|
||||
}
|
||||
359
src-tauri/src/models/tracker.rs
Normal file
359
src-tauri/src/models/tracker.rs
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
use rusqlite::{params, Connection, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentConfig {
|
||||
pub analyst_command: String,
|
||||
pub analyst_args: Vec<String>,
|
||||
pub developer_command: String,
|
||||
pub developer_args: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FilterGroup {
|
||||
pub conditions: Vec<Filter>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Filter {
|
||||
pub field: String,
|
||||
pub operator: String, // "In", "NotIn", "Equals", "NotEquals"
|
||||
pub value: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WatchedTracker {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub tracker_id: i32,
|
||||
pub tracker_label: String,
|
||||
pub polling_interval: i32,
|
||||
pub agent_config: AgentConfig,
|
||||
pub filters: Vec<FilterGroup>,
|
||||
pub enabled: bool,
|
||||
pub last_polled_at: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
fn from_row(row: &rusqlite::Row) -> rusqlite::Result<WatchedTracker> {
|
||||
let agent_config_json: String = row.get(5)?;
|
||||
let filters_json: String = row.get(6)?;
|
||||
let enabled_int: i32 = row.get(7)?;
|
||||
|
||||
let agent_config: AgentConfig = serde_json::from_str(&agent_config_json)
|
||||
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(5, rusqlite::types::Type::Text, Box::new(e)))?;
|
||||
let filters: Vec<FilterGroup> = serde_json::from_str(&filters_json)
|
||||
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Text, Box::new(e)))?;
|
||||
|
||||
Ok(WatchedTracker {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
tracker_id: row.get(2)?,
|
||||
tracker_label: row.get(3)?,
|
||||
polling_interval: row.get(4)?,
|
||||
agent_config,
|
||||
filters,
|
||||
enabled: enabled_int != 0,
|
||||
last_polled_at: row.get(8)?,
|
||||
created_at: row.get(9)?,
|
||||
})
|
||||
}
|
||||
|
||||
impl WatchedTracker {
|
||||
pub fn insert(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
tracker_id: i32,
|
||||
tracker_label: &str,
|
||||
polling_interval: i32,
|
||||
agent_config: AgentConfig,
|
||||
filters: Vec<FilterGroup>,
|
||||
) -> Result<WatchedTracker> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let agent_config_json = serde_json::to_string(&agent_config)
|
||||
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
|
||||
let filters_json = serde_json::to_string(&filters)
|
||||
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO watched_trackers (id, project_id, tracker_id, tracker_label, polling_interval, agent_config_json, filters_json, created_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
params![id, project_id, tracker_id, tracker_label, polling_interval, agent_config_json, filters_json, now],
|
||||
)?;
|
||||
|
||||
Ok(WatchedTracker {
|
||||
id,
|
||||
project_id: project_id.to_string(),
|
||||
tracker_id,
|
||||
tracker_label: tracker_label.to_string(),
|
||||
polling_interval,
|
||||
agent_config,
|
||||
filters,
|
||||
enabled: true,
|
||||
last_polled_at: None,
|
||||
created_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_by_project(conn: &Connection, project_id: &str) -> Result<Vec<WatchedTracker>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, project_id, tracker_id, tracker_label, polling_interval, agent_config_json, filters_json, enabled, last_polled_at, created_at \
|
||||
FROM watched_trackers WHERE project_id = ?1 ORDER BY created_at DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![project_id], from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn list_all_enabled(conn: &Connection) -> Result<Vec<WatchedTracker>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, project_id, tracker_id, tracker_label, polling_interval, agent_config_json, filters_json, enabled, last_polled_at, created_at \
|
||||
FROM watched_trackers WHERE enabled = 1 ORDER BY created_at DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map([], from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn get_by_id(conn: &Connection, id: &str) -> Result<WatchedTracker> {
|
||||
conn.query_row(
|
||||
"SELECT id, project_id, tracker_id, tracker_label, polling_interval, agent_config_json, filters_json, enabled, last_polled_at, created_at \
|
||||
FROM watched_trackers WHERE id = ?1",
|
||||
params![id],
|
||||
from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn update(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
polling_interval: i32,
|
||||
agent_config: AgentConfig,
|
||||
filters: Vec<FilterGroup>,
|
||||
enabled: bool,
|
||||
) -> Result<()> {
|
||||
let agent_config_json = serde_json::to_string(&agent_config)
|
||||
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
|
||||
let filters_json = serde_json::to_string(&filters)
|
||||
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
|
||||
let enabled_int = if enabled { 1i32 } else { 0i32 };
|
||||
|
||||
let affected = conn.execute(
|
||||
"UPDATE watched_trackers SET polling_interval = ?1, agent_config_json = ?2, filters_json = ?3, enabled = ?4 WHERE id = ?5",
|
||||
params![polling_interval, agent_config_json, filters_json, enabled_int, id],
|
||||
)?;
|
||||
|
||||
if affected == 0 {
|
||||
return Err(rusqlite::Error::QueryReturnedNoRows);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_last_polled(conn: &Connection, id: &str) -> Result<()> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let affected = conn.execute(
|
||||
"UPDATE watched_trackers SET last_polled_at = ?1 WHERE id = ?2",
|
||||
params![now, id],
|
||||
)?;
|
||||
if affected == 0 {
|
||||
return Err(rusqlite::Error::QueryReturnedNoRows);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete(conn: &Connection, id: &str) -> Result<()> {
|
||||
let affected = conn.execute("DELETE FROM watched_trackers WHERE id = ?1", params![id])?;
|
||||
if affected == 0 {
|
||||
return Err(rusqlite::Error::QueryReturnedNoRows);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db;
|
||||
use crate::models::project::Project;
|
||||
|
||||
fn setup() -> Connection {
|
||||
let conn = db::init_in_memory().expect("db init should succeed");
|
||||
Project::insert(&conn, "Test Project", "/path/test", None, "main").unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
fn project_id(conn: &Connection) -> String {
|
||||
Project::list(conn).unwrap().into_iter().next().unwrap().id
|
||||
}
|
||||
|
||||
fn sample_agent_config() -> AgentConfig {
|
||||
AgentConfig {
|
||||
analyst_command: "analyst".to_string(),
|
||||
analyst_args: vec!["--mode".to_string(), "analyze".to_string()],
|
||||
developer_command: "developer".to_string(),
|
||||
developer_args: vec!["--fix".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_filters() -> Vec<FilterGroup> {
|
||||
vec![FilterGroup {
|
||||
conditions: vec![Filter {
|
||||
field: "status".to_string(),
|
||||
operator: "In".to_string(),
|
||||
value: vec!["Open".to_string(), "In Progress".to_string()],
|
||||
}],
|
||||
}]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_tracker() {
|
||||
let conn = setup();
|
||||
let pid = project_id(&conn);
|
||||
|
||||
let tracker = WatchedTracker::insert(
|
||||
&conn,
|
||||
&pid,
|
||||
42,
|
||||
"Bug Tracker",
|
||||
15,
|
||||
sample_agent_config(),
|
||||
sample_filters(),
|
||||
)
|
||||
.expect("insert should succeed");
|
||||
|
||||
assert!(!tracker.id.is_empty());
|
||||
assert_eq!(tracker.project_id, pid);
|
||||
assert_eq!(tracker.tracker_id, 42);
|
||||
assert_eq!(tracker.tracker_label, "Bug Tracker");
|
||||
assert_eq!(tracker.polling_interval, 15);
|
||||
assert!(tracker.enabled);
|
||||
assert!(tracker.last_polled_at.is_none());
|
||||
assert!(!tracker.created_at.is_empty());
|
||||
assert_eq!(tracker.agent_config.analyst_command, "analyst");
|
||||
assert_eq!(tracker.filters.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_by_project() {
|
||||
let conn = setup();
|
||||
let pid = project_id(&conn);
|
||||
|
||||
WatchedTracker::insert(&conn, &pid, 1, "Tracker A", 10, sample_agent_config(), vec![]).unwrap();
|
||||
WatchedTracker::insert(&conn, &pid, 2, "Tracker B", 20, sample_agent_config(), vec![]).unwrap();
|
||||
|
||||
let trackers = WatchedTracker::list_by_project(&conn, &pid).expect("list should succeed");
|
||||
assert_eq!(trackers.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_all_enabled() {
|
||||
let conn = setup();
|
||||
let pid = project_id(&conn);
|
||||
|
||||
let t1 = WatchedTracker::insert(&conn, &pid, 1, "Enabled", 10, sample_agent_config(), vec![]).unwrap();
|
||||
let t2 = WatchedTracker::insert(&conn, &pid, 2, "Disabled", 10, sample_agent_config(), vec![]).unwrap();
|
||||
|
||||
// Disable t2
|
||||
WatchedTracker::update(
|
||||
&conn,
|
||||
&t2.id,
|
||||
t2.polling_interval,
|
||||
sample_agent_config(),
|
||||
vec![],
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let enabled = WatchedTracker::list_all_enabled(&conn).expect("list_all_enabled should succeed");
|
||||
assert_eq!(enabled.len(), 1);
|
||||
assert_eq!(enabled[0].id, t1.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_by_id() {
|
||||
let conn = setup();
|
||||
let pid = project_id(&conn);
|
||||
|
||||
let created = WatchedTracker::insert(
|
||||
&conn,
|
||||
&pid,
|
||||
99,
|
||||
"My Tracker",
|
||||
30,
|
||||
sample_agent_config(),
|
||||
sample_filters(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let found = WatchedTracker::get_by_id(&conn, &created.id).expect("get_by_id should succeed");
|
||||
assert_eq!(found.id, created.id);
|
||||
assert_eq!(found.tracker_id, 99);
|
||||
assert_eq!(found.tracker_label, "My Tracker");
|
||||
assert_eq!(found.polling_interval, 30);
|
||||
assert_eq!(found.filters.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_tracker() {
|
||||
let conn = setup();
|
||||
let pid = project_id(&conn);
|
||||
|
||||
let created = WatchedTracker::insert(
|
||||
&conn,
|
||||
&pid,
|
||||
10,
|
||||
"Original",
|
||||
5,
|
||||
sample_agent_config(),
|
||||
sample_filters(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let new_filters = vec![FilterGroup {
|
||||
conditions: vec![Filter {
|
||||
field: "priority".to_string(),
|
||||
operator: "Equals".to_string(),
|
||||
value: vec!["High".to_string()],
|
||||
}],
|
||||
}];
|
||||
|
||||
WatchedTracker::update(&conn, &created.id, 60, sample_agent_config(), new_filters, false)
|
||||
.expect("update should succeed");
|
||||
|
||||
let updated = WatchedTracker::get_by_id(&conn, &created.id).unwrap();
|
||||
assert_eq!(updated.polling_interval, 60);
|
||||
assert!(!updated.enabled);
|
||||
assert_eq!(updated.filters[0].conditions[0].field, "priority");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_last_polled() {
|
||||
let conn = setup();
|
||||
let pid = project_id(&conn);
|
||||
|
||||
let created =
|
||||
WatchedTracker::insert(&conn, &pid, 5, "Poller", 10, sample_agent_config(), vec![]).unwrap();
|
||||
|
||||
assert!(created.last_polled_at.is_none());
|
||||
|
||||
WatchedTracker::update_last_polled(&conn, &created.id).expect("update_last_polled should succeed");
|
||||
|
||||
let updated = WatchedTracker::get_by_id(&conn, &created.id).unwrap();
|
||||
assert!(updated.last_polled_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_tracker() {
|
||||
let conn = setup();
|
||||
let pid = project_id(&conn);
|
||||
|
||||
let created =
|
||||
WatchedTracker::insert(&conn, &pid, 7, "ToDelete", 10, sample_agent_config(), vec![]).unwrap();
|
||||
|
||||
WatchedTracker::delete(&conn, &created.id).expect("delete should succeed");
|
||||
|
||||
let result = WatchedTracker::get_by_id(&conn, &created.id);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
105
src-tauri/src/services/crypto.rs
Normal file
105
src-tauri/src/services/crypto.rs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
use aes_gcm::{
|
||||
aead::{Aead, KeyInit, OsRng},
|
||||
Aes256Gcm, Key, Nonce,
|
||||
};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use rand::RngCore;
|
||||
|
||||
pub fn encrypt(key: &[u8; 32], plaintext: &str) -> Result<String, String> {
|
||||
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
OsRng.fill_bytes(&mut nonce_bytes);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce, plaintext.as_bytes())
|
||||
.map_err(|e| format!("encryption failed: {}", e))?;
|
||||
let mut combined = nonce_bytes.to_vec();
|
||||
combined.extend(ciphertext);
|
||||
Ok(STANDARD.encode(&combined))
|
||||
}
|
||||
|
||||
pub fn decrypt(key: &[u8; 32], encrypted: &str) -> Result<String, String> {
|
||||
let combined = STANDARD
|
||||
.decode(encrypted)
|
||||
.map_err(|e| format!("base64 decode failed: {}", e))?;
|
||||
if combined.len() < 13 {
|
||||
return Err("encrypted data too short".to_string());
|
||||
}
|
||||
let (nonce_bytes, ciphertext) = combined.split_at(12);
|
||||
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
.map_err(|_| "decryption failed (wrong key or corrupted data)".to_string())?;
|
||||
String::from_utf8(plaintext).map_err(|e| format!("invalid UTF-8: {}", e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_key() -> [u8; 32] {
|
||||
[42u8; 32]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_roundtrip() {
|
||||
let key = test_key();
|
||||
let plaintext = "hello world";
|
||||
let encrypted = encrypt(&key, plaintext).unwrap();
|
||||
let decrypted = decrypt(&key, &encrypted).unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_produces_different_ciphertext() {
|
||||
let key = test_key();
|
||||
let plaintext = "same input";
|
||||
let enc1 = encrypt(&key, plaintext).unwrap();
|
||||
let enc2 = encrypt(&key, plaintext).unwrap();
|
||||
assert_ne!(enc1, enc2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_with_wrong_key_fails() {
|
||||
let key = test_key();
|
||||
let wrong_key = [99u8; 32];
|
||||
let encrypted = encrypt(&key, "secret").unwrap();
|
||||
let result = decrypt(&wrong_key, &encrypted);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_invalid_base64_fails() {
|
||||
let key = test_key();
|
||||
let result = decrypt(&key, "not valid base64!!!");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_too_short_fails() {
|
||||
let key = test_key();
|
||||
// Base64 of 5 bytes (less than 13)
|
||||
let short = STANDARD.encode(&[0u8; 5]);
|
||||
let result = decrypt(&key, &short);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("too short"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_empty_string() {
|
||||
let key = test_key();
|
||||
let encrypted = encrypt(&key, "").unwrap();
|
||||
let decrypted = decrypt(&key, &encrypted).unwrap();
|
||||
assert_eq!(decrypted, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_unicode() {
|
||||
let key = test_key();
|
||||
let plaintext = "héllo wörld àèìòù";
|
||||
let encrypted = encrypt(&key, plaintext).unwrap();
|
||||
let decrypted = decrypt(&key, &encrypted).unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
}
|
||||
212
src-tauri/src/services/filter_engine.rs
Normal file
212
src-tauri/src/services/filter_engine.rs
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
use crate::models::tracker::{Filter, FilterGroup};
|
||||
use crate::services::tuleap_client::extract_artifact_field_values;
|
||||
|
||||
pub fn apply_filters(
|
||||
artifacts: &[serde_json::Value],
|
||||
filter_groups: &[FilterGroup],
|
||||
) -> Vec<serde_json::Value> {
|
||||
if filter_groups.is_empty() {
|
||||
return artifacts.to_vec();
|
||||
}
|
||||
artifacts
|
||||
.iter()
|
||||
.filter(|a| matches_all_groups(a, filter_groups))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn matches_all_groups(artifact: &serde_json::Value, groups: &[FilterGroup]) -> bool {
|
||||
groups.iter().all(|g| matches_any_condition(artifact, &g.conditions))
|
||||
}
|
||||
|
||||
fn matches_any_condition(artifact: &serde_json::Value, conditions: &[Filter]) -> bool {
|
||||
if conditions.is_empty() {
|
||||
return true;
|
||||
}
|
||||
conditions.iter().any(|c| matches_condition(artifact, c))
|
||||
}
|
||||
|
||||
fn matches_condition(artifact: &serde_json::Value, condition: &Filter) -> bool {
|
||||
let field_values = extract_artifact_field_values(artifact, &condition.field);
|
||||
match condition.operator.as_str() {
|
||||
"Equals" => {
|
||||
condition.value.len() == 1
|
||||
&& field_values.iter().any(|v| v == &condition.value[0])
|
||||
}
|
||||
"NotEquals" => {
|
||||
condition.value.len() == 1
|
||||
&& !field_values.iter().any(|v| v == &condition.value[0])
|
||||
}
|
||||
"In" => field_values.iter().any(|v| condition.value.contains(v)),
|
||||
"NotIn" => !field_values.iter().any(|v| condition.value.contains(v)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn make_artifact(status: &str, assigned: &str, priority: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"id": 123,
|
||||
"title": "Test ticket",
|
||||
"values": [
|
||||
{
|
||||
"field_id": 1,
|
||||
"label": "Status",
|
||||
"type": "sb",
|
||||
"values": [{ "id": 1, "label": status }]
|
||||
},
|
||||
{
|
||||
"field_id": 2,
|
||||
"label": "Assigned to",
|
||||
"type": "msb",
|
||||
"bind_value_objects": [{ "id": 2, "display_name": assigned }]
|
||||
},
|
||||
{
|
||||
"field_id": 3,
|
||||
"label": "Priority",
|
||||
"type": "sb",
|
||||
"values": [{ "id": 3, "label": priority }]
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_filters_returns_all() {
|
||||
let artifacts = vec![
|
||||
make_artifact("Nouveau", "Alice", "Haute"),
|
||||
make_artifact("Ferme", "Bob", "Basse"),
|
||||
];
|
||||
let result = apply_filters(&artifacts, &[]);
|
||||
assert_eq!(result.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_in_filter() {
|
||||
let artifacts = vec![
|
||||
make_artifact("Nouveau", "Alice", "Haute"),
|
||||
make_artifact("Ferme", "Bob", "Basse"),
|
||||
];
|
||||
let groups = vec![FilterGroup {
|
||||
conditions: vec![Filter {
|
||||
field: "Status".to_string(),
|
||||
operator: "In".to_string(),
|
||||
value: vec!["Nouveau".to_string()],
|
||||
}],
|
||||
}];
|
||||
let result = apply_filters(&artifacts, &groups);
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0]["values"][0]["values"][0]["label"], "Nouveau");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_or_within_group() {
|
||||
let artifacts = vec![
|
||||
make_artifact("Nouveau", "Alice", "Haute"),
|
||||
make_artifact("A traiter", "Bob", "Moyenne"),
|
||||
make_artifact("Ferme", "Carol", "Basse"),
|
||||
];
|
||||
let groups = vec![FilterGroup {
|
||||
conditions: vec![
|
||||
Filter {
|
||||
field: "Status".to_string(),
|
||||
operator: "Equals".to_string(),
|
||||
value: vec!["Nouveau".to_string()],
|
||||
},
|
||||
Filter {
|
||||
field: "Status".to_string(),
|
||||
operator: "Equals".to_string(),
|
||||
value: vec!["A traiter".to_string()],
|
||||
},
|
||||
],
|
||||
}];
|
||||
let result = apply_filters(&artifacts, &groups);
|
||||
assert_eq!(result.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_and_across_groups() {
|
||||
let artifacts = vec![
|
||||
make_artifact("Nouveau", "Team Maintenance", "Haute"),
|
||||
make_artifact("A traiter", "Other Team", "Moyenne"),
|
||||
make_artifact("Ferme", "Team Maintenance", "Basse"),
|
||||
];
|
||||
let groups = vec![
|
||||
FilterGroup {
|
||||
conditions: vec![Filter {
|
||||
field: "Status".to_string(),
|
||||
operator: "In".to_string(),
|
||||
value: vec!["Nouveau".to_string(), "A traiter".to_string()],
|
||||
}],
|
||||
},
|
||||
FilterGroup {
|
||||
conditions: vec![Filter {
|
||||
field: "Assigned to".to_string(),
|
||||
operator: "In".to_string(),
|
||||
value: vec!["Team Maintenance".to_string()],
|
||||
}],
|
||||
},
|
||||
];
|
||||
let result = apply_filters(&artifacts, &groups);
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0]["values"][0]["values"][0]["label"], "Nouveau");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_not_in_filter() {
|
||||
let artifacts = vec![
|
||||
make_artifact("Nouveau", "Alice", "Haute"),
|
||||
make_artifact("Ferme", "Bob", "Basse"),
|
||||
make_artifact("Ferme", "Carol", "Moyenne"),
|
||||
];
|
||||
let groups = vec![FilterGroup {
|
||||
conditions: vec![Filter {
|
||||
field: "Status".to_string(),
|
||||
operator: "NotIn".to_string(),
|
||||
value: vec!["Ferme".to_string()],
|
||||
}],
|
||||
}];
|
||||
let result = apply_filters(&artifacts, &groups);
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0]["values"][0]["values"][0]["label"], "Nouveau");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_equals_filter() {
|
||||
let artifacts = vec![
|
||||
make_artifact("Nouveau", "Alice", "Haute"),
|
||||
make_artifact("Nouveau", "Bob", "Basse"),
|
||||
make_artifact("Ferme", "Carol", "Haute"),
|
||||
];
|
||||
let groups = vec![FilterGroup {
|
||||
conditions: vec![Filter {
|
||||
field: "Priority".to_string(),
|
||||
operator: "Equals".to_string(),
|
||||
value: vec!["Haute".to_string()],
|
||||
}],
|
||||
}];
|
||||
let result = apply_filters(&artifacts, &groups);
|
||||
assert_eq!(result.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_match_returns_empty() {
|
||||
let artifacts = vec![
|
||||
make_artifact("Nouveau", "Alice", "Haute"),
|
||||
make_artifact("A traiter", "Bob", "Moyenne"),
|
||||
];
|
||||
let groups = vec![FilterGroup {
|
||||
conditions: vec![Filter {
|
||||
field: "Status".to_string(),
|
||||
operator: "Equals".to_string(),
|
||||
value: vec!["Ferme".to_string()],
|
||||
}],
|
||||
}];
|
||||
let result = apply_filters(&artifacts, &groups);
|
||||
assert_eq!(result.len(), 0);
|
||||
}
|
||||
}
|
||||
4
src-tauri/src/services/mod.rs
Normal file
4
src-tauri/src/services/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub mod crypto;
|
||||
pub mod filter_engine;
|
||||
pub mod poller;
|
||||
pub mod tuleap_client;
|
||||
180
src-tauri/src/services/poller.rs
Normal file
180
src-tauri/src/services/poller.rs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
use crate::models::credential::TuleapCredentials;
|
||||
use crate::models::ticket::ProcessedTicket;
|
||||
use crate::models::tracker::WatchedTracker;
|
||||
use crate::services::{crypto, filter_engine};
|
||||
use crate::services::tuleap_client::TuleapClient;
|
||||
use rusqlite::Connection;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::time::{interval, Duration};
|
||||
|
||||
pub fn start(
|
||||
db: Arc<Mutex<Connection>>,
|
||||
encryption_key: [u8; 32],
|
||||
http_client: reqwest::Client,
|
||||
app_handle: AppHandle,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let mut tick = interval(Duration::from_secs(60));
|
||||
loop {
|
||||
tick.tick().await;
|
||||
poll_all_trackers(&db, &encryption_key, &http_client, &app_handle).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn poll_all_trackers(
|
||||
db: &Arc<Mutex<Connection>>,
|
||||
encryption_key: &[u8; 32],
|
||||
http_client: &reqwest::Client,
|
||||
app_handle: &AppHandle,
|
||||
) {
|
||||
// 1. Read all enabled trackers and credentials from DB
|
||||
let (trackers, client) = {
|
||||
let conn = match db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("poller: failed to lock db: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let trackers = match WatchedTracker::list_all_enabled(&conn) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
eprintln!("poller: failed to list trackers: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Read credentials; bail silently if none
|
||||
let creds = match TuleapCredentials::get(&conn) {
|
||||
Ok(Some(c)) => c,
|
||||
Ok(None) => return,
|
||||
Err(e) => {
|
||||
eprintln!("poller: failed to read credentials: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let password = match crypto::decrypt(encryption_key, &creds.password_encrypted) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("poller: failed to decrypt password: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let client = TuleapClient::new(http_client, &creds.tuleap_url, &creds.username, &password);
|
||||
|
||||
(trackers, client)
|
||||
}; // lock released
|
||||
|
||||
// 3. For each tracker that should_poll, poll it
|
||||
for tracker in &trackers {
|
||||
if should_poll(tracker) {
|
||||
poll_single_tracker(db, &client, tracker, app_handle).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn should_poll(tracker: &WatchedTracker) -> bool {
|
||||
let last_polled_at = match &tracker.last_polled_at {
|
||||
None => return true, // Never polled
|
||||
Some(s) => s,
|
||||
};
|
||||
|
||||
let last = match chrono::DateTime::parse_from_rfc3339(last_polled_at) {
|
||||
Ok(dt) => dt,
|
||||
Err(e) => {
|
||||
eprintln!("poller: failed to parse last_polled_at '{}': {}", last_polled_at, e);
|
||||
return true; // Treat as never polled on parse error
|
||||
}
|
||||
};
|
||||
|
||||
let elapsed = chrono::Utc::now().signed_duration_since(last).num_minutes();
|
||||
elapsed >= tracker.polling_interval as i64
|
||||
}
|
||||
|
||||
async fn poll_single_tracker(
|
||||
db: &Arc<Mutex<Connection>>,
|
||||
client: &TuleapClient,
|
||||
tracker: &WatchedTracker,
|
||||
app_handle: &AppHandle,
|
||||
) {
|
||||
// 1. Fetch artifacts
|
||||
let artifacts = match client.get_artifacts(tracker.tracker_id).await {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
eprintln!("poller: failed to fetch artifacts for tracker {}: {}", tracker.id, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Apply filters
|
||||
let filtered = filter_engine::apply_filters(&artifacts, &tracker.filters);
|
||||
|
||||
// 3. Insert new tickets and update last_polled_at
|
||||
let new_count = {
|
||||
let conn = match db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("poller: failed to lock db for insert: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut count = 0usize;
|
||||
|
||||
for artifact in &filtered {
|
||||
let artifact_id = artifact
|
||||
.get("id")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0) as i32;
|
||||
|
||||
let artifact_title = artifact
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let artifact_data = serde_json::to_string(artifact)
|
||||
.unwrap_or_else(|_| "{}".to_string());
|
||||
|
||||
match ProcessedTicket::insert_if_new(
|
||||
&conn,
|
||||
&tracker.id,
|
||||
artifact_id,
|
||||
&artifact_title,
|
||||
&artifact_data,
|
||||
) {
|
||||
Ok(Some(_)) => count += 1,
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
eprintln!("poller: failed to insert ticket (artifact {}): {}", artifact_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Update last_polled_at
|
||||
if let Err(e) = WatchedTracker::update_last_polled(&conn, &tracker.id) {
|
||||
eprintln!("poller: failed to update last_polled_at for tracker {}: {}", tracker.id, e);
|
||||
}
|
||||
|
||||
count
|
||||
}; // lock released
|
||||
|
||||
// 5. Emit event if new tickets found
|
||||
if new_count > 0 {
|
||||
if let Err(e) = app_handle.emit(
|
||||
"new-tickets-detected",
|
||||
serde_json::json!({
|
||||
"tracker_id": tracker.id,
|
||||
"tracker_label": tracker.tracker_label,
|
||||
"count": new_count,
|
||||
}),
|
||||
) {
|
||||
eprintln!("poller: failed to emit event: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
411
src-tauri/src/services/tuleap_client.rs
Normal file
411
src-tauri/src/services/tuleap_client.rs
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TrackerField {
|
||||
pub field_id: i64,
|
||||
pub label: String,
|
||||
pub field_type: String,
|
||||
pub values: Vec<FieldValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FieldValue {
|
||||
pub id: i64,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
pub struct TuleapClient {
|
||||
http: reqwest::Client,
|
||||
base_url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
impl TuleapClient {
|
||||
pub fn new(http: &reqwest::Client, base_url: &str, username: &str, password: &str) -> Self {
|
||||
Self {
|
||||
http: http.clone(),
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn test_connection(&self) -> Result<(), String> {
|
||||
let url = format!("{}/api/projects?limit=1", self.base_url);
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.basic_auth(&self.username, Some(&self.password))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("request failed: {}", e))?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("connection test failed: HTTP {}", resp.status()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_tracker_fields(&self, tracker_id: i32) -> Result<Vec<TrackerField>, String> {
|
||||
let url = format!("{}/api/trackers/{}", self.base_url, tracker_id);
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.basic_auth(&self.username, Some(&self.password))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("request failed: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("get tracker fields failed: HTTP {}", resp.status()));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("failed to parse response: {}", e))?;
|
||||
|
||||
Ok(parse_tracker_fields(&body))
|
||||
}
|
||||
|
||||
pub async fn get_artifacts(&self, tracker_id: i32) -> Result<Vec<serde_json::Value>, String> {
|
||||
let mut all_artifacts: Vec<serde_json::Value> = Vec::new();
|
||||
let mut offset = 0usize;
|
||||
let limit = 50usize;
|
||||
|
||||
loop {
|
||||
let url = format!(
|
||||
"{}/api/trackers/{}/artifacts?values=all&limit={}&offset={}",
|
||||
self.base_url, tracker_id, limit, offset
|
||||
);
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.basic_auth(&self.username, Some(&self.password))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("request failed: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("get artifacts failed: HTTP {}", resp.status()));
|
||||
}
|
||||
|
||||
// Read total size from header before consuming body
|
||||
let total: usize = resp
|
||||
.headers()
|
||||
.get("x-pagination-size")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("failed to parse response: {}", e))?;
|
||||
|
||||
let artifacts = body
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let fetched = artifacts.len();
|
||||
all_artifacts.extend(artifacts);
|
||||
|
||||
offset += fetched;
|
||||
|
||||
if fetched == 0 || offset >= total {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_artifacts)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure parsing functions (unit-testable without HTTP)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn parse_tracker_fields(tracker_json: &serde_json::Value) -> Vec<TrackerField> {
|
||||
let fields = match tracker_json.get("fields").and_then(|f| f.as_array()) {
|
||||
Some(f) => f,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
fields
|
||||
.iter()
|
||||
.filter_map(|field| {
|
||||
let field_id = field.get("field_id")?.as_i64()?;
|
||||
let label = field.get("label")?.as_str()?.to_string();
|
||||
let field_type = field.get("type")?.as_str()?.to_string();
|
||||
|
||||
let values = match field_type.as_str() {
|
||||
"sb" | "msb" | "rb" | "cb" => extract_field_values(field),
|
||||
_ => vec![],
|
||||
};
|
||||
|
||||
Some(TrackerField {
|
||||
field_id,
|
||||
label,
|
||||
field_type,
|
||||
values,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn extract_field_values(field: &serde_json::Value) -> Vec<FieldValue> {
|
||||
// Try "values" first (sb, rb), then "bind_value_objects" (msb)
|
||||
let candidates = field
|
||||
.get("values")
|
||||
.and_then(|v| v.as_array())
|
||||
.filter(|arr| !arr.is_empty())
|
||||
.or_else(|| {
|
||||
field
|
||||
.get("bind_value_objects")
|
||||
.and_then(|v| v.as_array())
|
||||
});
|
||||
|
||||
let arr = match candidates {
|
||||
Some(a) => a,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
arr.iter()
|
||||
.filter_map(|v| {
|
||||
let id = v.get("id")?.as_i64()?;
|
||||
let label = v
|
||||
.get("label")
|
||||
.and_then(|l| l.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
// Filter out "None" labels
|
||||
if label == "None" {
|
||||
return None;
|
||||
}
|
||||
Some(FieldValue { id, label })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn extract_artifact_field_values(
|
||||
artifact: &serde_json::Value,
|
||||
field_label: &str,
|
||||
) -> Vec<String> {
|
||||
let values_arr = match artifact.get("values").and_then(|v| v.as_array()) {
|
||||
Some(a) => a,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
// Find the field entry matching the label
|
||||
let field_entry = values_arr
|
||||
.iter()
|
||||
.find(|entry| entry.get("label").and_then(|l| l.as_str()) == Some(field_label));
|
||||
|
||||
let entry = match field_entry {
|
||||
Some(e) => e,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
let field_type = entry
|
||||
.get("type")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
match field_type {
|
||||
"sb" | "rb" => {
|
||||
// values[*].label
|
||||
entry
|
||||
.get("values")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.get("label").and_then(|l| l.as_str()).map(str::to_string))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
"msb" | "cb" => {
|
||||
// bind_value_objects[*].display_name, fallback to label
|
||||
entry
|
||||
.get("bind_value_objects")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| {
|
||||
v.get("display_name")
|
||||
.and_then(|d| d.as_str())
|
||||
.or_else(|| v.get("label").and_then(|l| l.as_str()))
|
||||
.map(str::to_string)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
"string" | "text" | "int" | "float" => {
|
||||
// scalar "value" field
|
||||
entry
|
||||
.get("value")
|
||||
.map(|v| match v {
|
||||
serde_json::Value::String(s) => vec![s.clone()],
|
||||
serde_json::Value::Number(n) => vec![n.to_string()],
|
||||
serde_json::Value::Bool(b) => vec![b.to_string()],
|
||||
serde_json::Value::Null => vec![],
|
||||
other => vec![other.to_string()],
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_parse_tracker_fields_extracts_sb() {
|
||||
let tracker = json!({
|
||||
"fields": [
|
||||
{
|
||||
"field_id": 1,
|
||||
"label": "Status",
|
||||
"type": "sb",
|
||||
"values": [
|
||||
{ "id": 0, "label": "None" },
|
||||
{ "id": 1, "label": "Nouveau" },
|
||||
{ "id": 2, "label": "En cours" }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let fields = parse_tracker_fields(&tracker);
|
||||
assert_eq!(fields.len(), 1);
|
||||
let f = &fields[0];
|
||||
assert_eq!(f.label, "Status");
|
||||
assert_eq!(f.field_type, "sb");
|
||||
// "None" is filtered out
|
||||
assert_eq!(f.values.len(), 2);
|
||||
assert_eq!(f.values[0].label, "Nouveau");
|
||||
assert_eq!(f.values[1].label, "En cours");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tracker_fields_extracts_msb() {
|
||||
let tracker = json!({
|
||||
"fields": [
|
||||
{
|
||||
"field_id": 2,
|
||||
"label": "Assigned to",
|
||||
"type": "msb",
|
||||
"bind_value_objects": [
|
||||
{ "id": 10, "label": "Alice" },
|
||||
{ "id": 11, "label": "Bob" }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let fields = parse_tracker_fields(&tracker);
|
||||
assert_eq!(fields.len(), 1);
|
||||
let f = &fields[0];
|
||||
assert_eq!(f.label, "Assigned to");
|
||||
assert_eq!(f.values.len(), 2);
|
||||
assert_eq!(f.values[0].label, "Alice");
|
||||
assert_eq!(f.values[1].label, "Bob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tracker_fields_skips_text_fields() {
|
||||
let tracker = json!({
|
||||
"fields": [
|
||||
{
|
||||
"field_id": 3,
|
||||
"label": "Summary",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let fields = parse_tracker_fields(&tracker);
|
||||
assert_eq!(fields.len(), 1);
|
||||
let f = &fields[0];
|
||||
assert_eq!(f.label, "Summary");
|
||||
assert_eq!(f.field_type, "text");
|
||||
assert!(f.values.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_artifact_field_values_sb() {
|
||||
let artifact = json!({
|
||||
"values": [
|
||||
{
|
||||
"field_id": 1,
|
||||
"label": "Status",
|
||||
"type": "sb",
|
||||
"values": [
|
||||
{ "id": 1, "label": "Nouveau" }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let result = extract_artifact_field_values(&artifact, "Status");
|
||||
assert_eq!(result, vec!["Nouveau"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_artifact_field_values_msb() {
|
||||
let artifact = json!({
|
||||
"values": [
|
||||
{
|
||||
"field_id": 2,
|
||||
"label": "Assigned to",
|
||||
"type": "msb",
|
||||
"bind_value_objects": [
|
||||
{ "id": 10, "display_name": "Team Maintenance" }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let result = extract_artifact_field_values(&artifact, "Assigned to");
|
||||
assert_eq!(result, vec!["Team Maintenance"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_artifact_field_values_missing_field() {
|
||||
let artifact = json!({
|
||||
"values": []
|
||||
});
|
||||
|
||||
let result = extract_artifact_field_values(&artifact, "Status");
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_artifact_field_values_string_field() {
|
||||
let artifact = json!({
|
||||
"values": [
|
||||
{
|
||||
"field_id": 5,
|
||||
"label": "Summary",
|
||||
"type": "string",
|
||||
"value": "Login broken"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let result = extract_artifact_field_values(&artifact, "Summary");
|
||||
assert_eq!(result, vec!["Login broken"]);
|
||||
}
|
||||
}
|
||||
35
src-tauri/tauri.conf.json
Normal file
35
src-tauri/tauri.conf.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Orchai",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.orchai.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Orchai",
|
||||
"width": 1200,
|
||||
"height": 800
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src ipc: http://ipc.localhost"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
34
src/App.tsx
Normal file
34
src/App.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import AppLayout from "./components/layout/AppLayout";
|
||||
import ProjectForm from "./components/projects/ProjectForm";
|
||||
import ProjectDashboard from "./components/projects/ProjectDashboard";
|
||||
import SettingsPage from "./components/settings/SettingsPage";
|
||||
import TrackerConfig from "./components/trackers/TrackerConfig";
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
<p>Select a project or create a new one</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<EmptyState />} />
|
||||
<Route path="/projects/new" element={<ProjectForm />} />
|
||||
<Route path="/projects/:projectId" element={<ProjectDashboard />} />
|
||||
<Route path="/projects/:projectId/edit" element={<ProjectForm />} />
|
||||
<Route path="/projects/:projectId/trackers/new" element={<TrackerConfig />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
13
src/components/layout/AppLayout.tsx
Normal file
13
src/components/layout/AppLayout.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Outlet } from "react-router-dom";
|
||||
import Sidebar from "./Sidebar";
|
||||
|
||||
export default function AppLayout() {
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-y-auto bg-gray-50">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
src/components/layout/Sidebar.tsx
Normal file
69
src/components/layout/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { listProjects } from "../../lib/api";
|
||||
import type { Project } from "../../lib/types";
|
||||
|
||||
export default function Sidebar() {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const { projectId } = useParams();
|
||||
|
||||
useEffect(() => {
|
||||
listProjects().then(setProjects);
|
||||
}, []);
|
||||
|
||||
// Expose a refresh function via custom event
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
listProjects().then(setProjects);
|
||||
};
|
||||
window.addEventListener("orchai:refresh-projects", handler);
|
||||
return () => window.removeEventListener("orchai:refresh-projects", handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<aside className="w-64 bg-gray-900 text-gray-100 flex flex-col h-screen">
|
||||
<div className="p-4 border-b border-gray-700">
|
||||
<h1 className="text-lg font-bold">Orchai</h1>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto p-2">
|
||||
<div className="flex items-center justify-between px-2 py-1 mb-1">
|
||||
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||
Projects
|
||||
</span>
|
||||
<Link
|
||||
to="/projects/new"
|
||||
className="text-gray-400 hover:text-white text-lg leading-none"
|
||||
title="Add project"
|
||||
>
|
||||
+
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{projects.map((project) => (
|
||||
<Link
|
||||
key={project.id}
|
||||
to={`/projects/${project.id}`}
|
||||
className={`block px-3 py-2 rounded text-sm ${
|
||||
projectId === project.id
|
||||
? "bg-gray-700 text-white"
|
||||
: "text-gray-300 hover:bg-gray-800 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{project.name}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{projects.length === 0 && (
|
||||
<p className="px-3 py-2 text-sm text-gray-500">No projects yet</p>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="p-2 border-t border-gray-700">
|
||||
<Link to="/settings" className="block px-3 py-2 rounded text-sm text-gray-300 hover:bg-gray-800 hover:text-white">
|
||||
Settings
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
133
src/components/projects/ProjectDashboard.tsx
Normal file
133
src/components/projects/ProjectDashboard.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useParams, Link, useNavigate } from "react-router-dom";
|
||||
import { getProject, deleteProject, listTrackers, listProcessedTickets } from "../../lib/api";
|
||||
import type { Project, WatchedTracker, ProcessedTicket } from "../../lib/types";
|
||||
import TrackerList from "../trackers/TrackerList";
|
||||
|
||||
export default function ProjectDashboard() {
|
||||
const { projectId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [trackers, setTrackers] = useState<WatchedTracker[]>([]);
|
||||
const [tickets, setTickets] = useState<ProcessedTicket[]>([]);
|
||||
|
||||
async function loadData() {
|
||||
if (!projectId) return;
|
||||
const [proj, trks, tkts] = await Promise.all([
|
||||
getProject(projectId),
|
||||
listTrackers(projectId),
|
||||
listProcessedTickets(projectId),
|
||||
]);
|
||||
setProject(proj);
|
||||
setTrackers(trks);
|
||||
setTickets(tkts);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [projectId]);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!projectId) return;
|
||||
if (!window.confirm(`Delete project "${project?.name}"?`)) return;
|
||||
|
||||
await deleteProject(projectId);
|
||||
window.dispatchEvent(new Event("orchai:refresh-projects"));
|
||||
navigate("/");
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: string): string {
|
||||
switch (status) {
|
||||
case "Pending":
|
||||
return "bg-yellow-100 text-yellow-700";
|
||||
case "Done":
|
||||
return "bg-green-100 text-green-700";
|
||||
case "Error":
|
||||
return "bg-red-100 text-red-700";
|
||||
default:
|
||||
return "bg-blue-100 text-blue-700";
|
||||
}
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return <div className="p-8 text-gray-400">Loading...</div>;
|
||||
}
|
||||
|
||||
const recentTickets = tickets.slice(-10).reverse();
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-bold">{project.name}</h2>
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
to={`/projects/${project.id}/edit`}
|
||||
className="px-3 py-1 bg-gray-200 rounded text-sm hover:bg-gray-300"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="px-3 py-1 bg-red-100 text-red-700 rounded text-sm hover:bg-red-200"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">Path:</span>
|
||||
<span className="ml-2 text-sm font-mono">{project.path}</span>
|
||||
</div>
|
||||
{project.cloned_from && (
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">Cloned from:</span>
|
||||
<span className="ml-2 text-sm font-mono">{project.cloned_from}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">Base branch:</span>
|
||||
<span className="ml-2 text-sm font-mono">{project.base_branch}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">Created:</span>
|
||||
<span className="ml-2 text-sm">{new Date(project.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<h3 className="text-lg font-semibold mb-4">Watched Trackers</h3>
|
||||
<TrackerList trackers={trackers} projectId={project.id} onRefresh={loadData} />
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<h3 className="text-lg font-semibold mb-4">Recent Tickets</h3>
|
||||
{recentTickets.length === 0 ? (
|
||||
<div className="text-sm text-gray-400">No tickets processed yet.</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentTickets.map((ticket) => (
|
||||
<div
|
||||
key={ticket.id}
|
||||
className="bg-white rounded-lg border border-gray-200 p-4 flex items-center justify-between gap-4"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400 font-mono">#{ticket.artifact_id}</span>
|
||||
<span className="text-sm font-medium truncate">{ticket.artifact_title}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-medium shrink-0 ${statusBadgeClass(ticket.status)}`}
|
||||
>
|
||||
{ticket.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
src/components/projects/ProjectForm.tsx
Normal file
174
src/components/projects/ProjectForm.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { createProject, getProject, updateProject } from "../../lib/api";
|
||||
|
||||
export default function ProjectForm() {
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams();
|
||||
const isEditing = Boolean(projectId);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [pathOrUrl, setPathOrUrl] = useState("");
|
||||
const [baseBranch, setBaseBranch] = useState("main");
|
||||
const [mode, setMode] = useState<"local" | "clone">("local");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
getProject(projectId).then((project) => {
|
||||
setName(project.name);
|
||||
setPathOrUrl(project.path);
|
||||
setBaseBranch(project.base_branch);
|
||||
if (project.cloned_from) {
|
||||
setMode("clone");
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
async function handleBrowse() {
|
||||
const selected = await open({ directory: true, multiple: false });
|
||||
if (selected) {
|
||||
setPathOrUrl(selected as string);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (isEditing && projectId) {
|
||||
await updateProject(projectId, name, baseBranch);
|
||||
} else {
|
||||
await createProject(name, pathOrUrl, baseBranch);
|
||||
}
|
||||
window.dispatchEvent(new Event("orchai:refresh-projects"));
|
||||
navigate("/");
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto p-8">
|
||||
<h2 className="text-xl font-bold mb-6">
|
||||
{isEditing ? "Edit project" : "New project"}
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Project name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Source
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-1 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
checked={mode === "local"}
|
||||
onChange={() => setMode("local")}
|
||||
/>
|
||||
Local folder
|
||||
</label>
|
||||
<label className="flex items-center gap-1 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
checked={mode === "clone"}
|
||||
onChange={() => setMode("clone")}
|
||||
/>
|
||||
Clone from URL
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{mode === "local" ? "Folder path" : "Git URL"}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={pathOrUrl}
|
||||
onChange={(e) => setPathOrUrl(e.target.value)}
|
||||
required
|
||||
placeholder={
|
||||
mode === "local"
|
||||
? "/home/user/code/myproject"
|
||||
: "https://github.com/org/repo.git"
|
||||
}
|
||||
className="flex-1 border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
{mode === "local" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBrowse}
|
||||
className="px-3 py-2 bg-gray-200 rounded text-sm hover:bg-gray-300"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Base branch
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={baseBranch}
|
||||
onChange={(e) => setBaseBranch(e.target.value)}
|
||||
required
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-600 text-sm bg-red-50 border border-red-200 rounded p-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Saving..." : isEditing ? "Save" : "Create"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
className="px-4 py-2 bg-gray-200 rounded text-sm hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
src/components/settings/SettingsPage.tsx
Normal file
174
src/components/settings/SettingsPage.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
getTuleapCredentials,
|
||||
setTuleapCredentials,
|
||||
deleteTuleapCredentials,
|
||||
testTuleapConnection,
|
||||
} from "../../lib/api";
|
||||
import type { TuleapCredentialsSafe } from "../../lib/types";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [tuleapUrl, setTuleapUrl] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [existing, setExisting] = useState<TuleapCredentialsSafe | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getTuleapCredentials().then((creds) => {
|
||||
if (creds) {
|
||||
setExisting(creds);
|
||||
setTuleapUrl(creds.tuleap_url);
|
||||
setUsername(creds.username);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
function clearMessages() {
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
}
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
clearMessages();
|
||||
setSaving(true);
|
||||
try {
|
||||
const creds = await setTuleapCredentials(tuleapUrl, username, password);
|
||||
setExisting(creds);
|
||||
setPassword("");
|
||||
setSuccess("Credentials saved.");
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
clearMessages();
|
||||
setTesting(true);
|
||||
try {
|
||||
const msg = await testTuleapConnection();
|
||||
setSuccess(msg);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!window.confirm("Delete Tuleap credentials? This cannot be undone.")) return;
|
||||
clearMessages();
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteTuleapCredentials();
|
||||
setExisting(null);
|
||||
setTuleapUrl("");
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setSuccess("Credentials deleted.");
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto p-8">
|
||||
<h2 className="text-xl font-bold mb-6">Settings</h2>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h3 className="text-base font-semibold mb-4">Tuleap credentials</h3>
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tuleap URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={tuleapUrl}
|
||||
onChange={(e) => setTuleapUrl(e.target.value)}
|
||||
required
|
||||
placeholder="https://tuleap.example.com"
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={existing ? "Leave empty to keep current" : ""}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-600 text-sm bg-red-50 border border-red-200 rounded p-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="text-green-700 text-sm bg-green-50 border border-green-200 rounded p-2">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={testing || !existing}
|
||||
className="px-4 py-2 bg-gray-200 rounded text-sm hover:bg-gray-300 disabled:opacity-50"
|
||||
>
|
||||
{testing ? "Testing..." : "Test connection"}
|
||||
</button>
|
||||
{existing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded text-sm hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{deleting ? "Deleting..." : "Delete"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
182
src/components/trackers/FilterBuilder.tsx
Normal file
182
src/components/trackers/FilterBuilder.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import type { FilterGroup, Filter, TrackerField } from "../../lib/types";
|
||||
|
||||
const OPERATORS = ["In", "NotIn", "Equals", "NotEquals"] as const;
|
||||
|
||||
interface Props {
|
||||
groups: FilterGroup[];
|
||||
onChange: (groups: FilterGroup[]) => void;
|
||||
availableFields: TrackerField[];
|
||||
}
|
||||
|
||||
export default function FilterBuilder({ groups, onChange, availableFields }: Props) {
|
||||
function updateGroup(groupIndex: number, group: FilterGroup) {
|
||||
const next = groups.map((g, i) => (i === groupIndex ? group : g));
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
function removeGroup(groupIndex: number) {
|
||||
onChange(groups.filter((_, i) => i !== groupIndex));
|
||||
}
|
||||
|
||||
function addGroup() {
|
||||
onChange([...groups, { conditions: [{ field: "", operator: "In", value: [] }] }]);
|
||||
}
|
||||
|
||||
function updateCondition(groupIndex: number, condIndex: number, cond: Filter) {
|
||||
const group = groups[groupIndex];
|
||||
const conditions = group.conditions.map((c, i) => (i === condIndex ? cond : c));
|
||||
updateGroup(groupIndex, { conditions });
|
||||
}
|
||||
|
||||
function removeCondition(groupIndex: number, condIndex: number) {
|
||||
const group = groups[groupIndex];
|
||||
const conditions = group.conditions.filter((_, i) => i !== condIndex);
|
||||
updateGroup(groupIndex, { conditions });
|
||||
}
|
||||
|
||||
function addCondition(groupIndex: number) {
|
||||
const group = groups[groupIndex];
|
||||
updateGroup(groupIndex, {
|
||||
conditions: [...group.conditions, { field: "", operator: "In", value: [] }],
|
||||
});
|
||||
}
|
||||
|
||||
function toggleValue(groupIndex: number, condIndex: number, val: string) {
|
||||
const cond = groups[groupIndex].conditions[condIndex];
|
||||
const next = cond.value.includes(val)
|
||||
? cond.value.filter((v) => v !== val)
|
||||
: [...cond.value, val];
|
||||
updateCondition(groupIndex, condIndex, { ...cond, value: next });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{groups.map((group, gi) => (
|
||||
<div key={gi}>
|
||||
{gi > 0 && (
|
||||
<div className="flex items-center gap-2 my-2">
|
||||
<div className="flex-1 border-t border-gray-200" />
|
||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">AND</span>
|
||||
<div className="flex-1 border-t border-gray-200" />
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-gray-700">Group {gi + 1}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeGroup(gi)}
|
||||
className="text-xs text-red-500 hover:text-red-700"
|
||||
>
|
||||
Remove group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{group.conditions.map((cond, ci) => {
|
||||
const fieldDef = availableFields.find((f) => f.label === cond.field);
|
||||
return (
|
||||
<div key={ci}>
|
||||
{ci > 0 && (
|
||||
<div className="flex items-center gap-2 my-2">
|
||||
<div className="flex-1 border-t border-dashed border-gray-200" />
|
||||
<span className="text-xs font-semibold text-blue-400 uppercase tracking-wide">OR</span>
|
||||
<div className="flex-1 border-t border-dashed border-gray-200" />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Field dropdown */}
|
||||
<select
|
||||
value={cond.field}
|
||||
onChange={(e) =>
|
||||
updateCondition(gi, ci, {
|
||||
...cond,
|
||||
field: e.target.value,
|
||||
value: [],
|
||||
})
|
||||
}
|
||||
className="border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 flex-1"
|
||||
>
|
||||
<option value="">Select field...</option>
|
||||
{availableFields.map((f) => (
|
||||
<option key={f.field_id} value={f.label}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Operator dropdown */}
|
||||
<select
|
||||
value={cond.operator}
|
||||
onChange={(e) =>
|
||||
updateCondition(gi, ci, { ...cond, operator: e.target.value })
|
||||
}
|
||||
className="border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{OPERATORS.map((op) => (
|
||||
<option key={op} value={op}>
|
||||
{op}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Remove condition */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeCondition(gi, ci)}
|
||||
className="text-xs text-red-400 hover:text-red-600 whitespace-nowrap"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Value pills */}
|
||||
{fieldDef && fieldDef.values.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pl-1">
|
||||
{fieldDef.values.map((v) => {
|
||||
const selected = cond.value.includes(String(v.id));
|
||||
return (
|
||||
<button
|
||||
key={v.id}
|
||||
type="button"
|
||||
onClick={() => toggleValue(gi, ci, String(v.id))}
|
||||
className={`px-2 py-0.5 rounded-full text-xs border transition-colors ${
|
||||
selected
|
||||
? "bg-blue-600 text-white border-blue-600"
|
||||
: "bg-white text-gray-700 border-gray-300 hover:border-blue-400"
|
||||
}`}
|
||||
>
|
||||
{v.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addCondition(gi)}
|
||||
className="mt-3 text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
+ Add OR condition
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={addGroup}
|
||||
className="w-full px-4 py-2 border-2 border-dashed border-gray-300 rounded text-sm text-gray-500 hover:border-blue-400 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
+ Add filter group (AND)
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
190
src/components/trackers/TrackerConfig.tsx
Normal file
190
src/components/trackers/TrackerConfig.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { addTracker, getTrackerFields } from "../../lib/api";
|
||||
import type { FilterGroup, TrackerField, AgentConfig } from "../../lib/types";
|
||||
import FilterBuilder from "./FilterBuilder";
|
||||
|
||||
export default function TrackerConfig() {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [trackerId, setTrackerId] = useState<number | "">("");
|
||||
const [trackerLabel, setTrackerLabel] = useState("");
|
||||
const [pollingInterval, setPollingInterval] = useState(10);
|
||||
const [fields, setFields] = useState<TrackerField[]>([]);
|
||||
const [fieldsLoaded, setFieldsLoaded] = useState(false);
|
||||
const [fieldsLoading, setFieldsLoading] = useState(false);
|
||||
const [filters, setFilters] = useState<FilterGroup[]>([]);
|
||||
const [analystCommand, setAnalystCommand] = useState("claude");
|
||||
const [developerCommand, setDeveloperCommand] = useState("claude");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleLoadFields() {
|
||||
if (!trackerId) return;
|
||||
setFieldsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getTrackerFields(Number(trackerId));
|
||||
setFields(result);
|
||||
setFieldsLoaded(true);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setFieldsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!projectId || trackerId === "") return;
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const agentConfig: AgentConfig = {
|
||||
analyst_command: analystCommand,
|
||||
analyst_args: [],
|
||||
developer_command: developerCommand,
|
||||
developer_args: [],
|
||||
};
|
||||
|
||||
try {
|
||||
await addTracker(projectId, Number(trackerId), trackerLabel, pollingInterval, agentConfig, filters);
|
||||
navigate(`/projects/${projectId}`);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-8">
|
||||
<h2 className="text-xl font-bold mb-6">Add tracker</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Basic fields */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tracker ID
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={trackerId}
|
||||
onChange={(e) => {
|
||||
setTrackerId(e.target.value === "" ? "" : Number(e.target.value));
|
||||
setFieldsLoaded(false);
|
||||
setFields([]);
|
||||
}}
|
||||
required
|
||||
min={1}
|
||||
className="w-40 border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="e.g. 42"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoadFields}
|
||||
disabled={!trackerId || fieldsLoading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{fieldsLoading ? "Loading..." : "Load tracker fields"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Label
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={trackerLabel}
|
||||
onChange={(e) => setTrackerLabel(e.target.value)}
|
||||
required
|
||||
placeholder="e.g. Bugs"
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Polling interval (minutes)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={pollingInterval}
|
||||
onChange={(e) => setPollingInterval(Number(e.target.value))}
|
||||
required
|
||||
min={1}
|
||||
className="w-40 border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter builder */}
|
||||
{fieldsLoaded && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-2">Filters</h3>
|
||||
<FilterBuilder
|
||||
groups={filters}
|
||||
onChange={setFilters}
|
||||
availableFields={fields}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent config */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700">Agent configuration</h3>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Analyst command
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={analystCommand}
|
||||
onChange={(e) => setAnalystCommand(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Developer command
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={developerCommand}
|
||||
onChange={(e) => setDeveloperCommand(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-600 text-sm bg-red-50 border border-red-200 rounded p-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Adding..." : "Add tracker"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/projects/${projectId}`)}
|
||||
className="px-4 py-2 bg-gray-200 rounded text-sm hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
src/components/trackers/TrackerList.tsx
Normal file
112
src/components/trackers/TrackerList.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import { manualPoll, updateTracker, removeTracker } from "../../lib/api";
|
||||
import type { WatchedTracker } from "../../lib/types";
|
||||
|
||||
interface Props {
|
||||
trackers: WatchedTracker[];
|
||||
projectId: string;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
|
||||
async function handlePollNow(tracker: WatchedTracker) {
|
||||
try {
|
||||
await manualPoll(tracker.id);
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
console.error("Poll failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleEnabled(tracker: WatchedTracker) {
|
||||
try {
|
||||
await updateTracker(
|
||||
tracker.id,
|
||||
tracker.polling_interval,
|
||||
tracker.agent_config,
|
||||
tracker.filters,
|
||||
!tracker.enabled
|
||||
);
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
console.error("Update failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(tracker: WatchedTracker) {
|
||||
if (!window.confirm(`Remove tracker "${tracker.tracker_label}"?`)) return;
|
||||
try {
|
||||
await removeTracker(tracker.id);
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
console.error("Remove failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{trackers.length === 0 && (
|
||||
<div className="text-sm text-gray-400">No trackers configured.</div>
|
||||
)}
|
||||
|
||||
{trackers.map((tracker) => (
|
||||
<div
|
||||
key={tracker.id}
|
||||
className="bg-white rounded-lg border border-gray-200 p-4 flex items-center justify-between gap-4"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{tracker.tracker_label}</span>
|
||||
<span className="text-xs text-gray-400">#{tracker.tracker_id}</span>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
tracker.enabled
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-gray-100 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{tracker.enabled ? "Active" : "Paused"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
{tracker.last_polled_at
|
||||
? `Last poll: ${new Date(tracker.last_polled_at).toLocaleString()}`
|
||||
: "Never polled"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePollNow(tracker)}
|
||||
className="px-3 py-1 bg-blue-600 text-white rounded text-xs hover:bg-blue-700"
|
||||
>
|
||||
Poll now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleEnabled(tracker)}
|
||||
className="px-3 py-1 bg-gray-200 text-gray-700 rounded text-xs hover:bg-gray-300"
|
||||
>
|
||||
{tracker.enabled ? "Pause" : "Resume"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(tracker)}
|
||||
className="px-3 py-1 bg-red-100 text-red-700 rounded text-xs hover:bg-red-200"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Link
|
||||
to={`/projects/${projectId}/trackers/new`}
|
||||
className="inline-block px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700"
|
||||
>
|
||||
Add tracker
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/index.css
Normal file
1
src/index.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import "tailwindcss";
|
||||
84
src/lib/api.ts
Normal file
84
src/lib/api.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
Project,
|
||||
TuleapCredentialsSafe,
|
||||
AgentConfig,
|
||||
FilterGroup,
|
||||
WatchedTracker,
|
||||
TrackerField,
|
||||
ProcessedTicket,
|
||||
} from "./types";
|
||||
|
||||
export async function createProject(
|
||||
name: string,
|
||||
pathOrUrl: string,
|
||||
baseBranch: string
|
||||
): Promise<Project> {
|
||||
return invoke("create_project", {
|
||||
name,
|
||||
pathOrUrl,
|
||||
baseBranch,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listProjects(): Promise<Project[]> {
|
||||
return invoke("list_projects");
|
||||
}
|
||||
|
||||
export async function getProject(id: string): Promise<Project> {
|
||||
return invoke("get_project", { id });
|
||||
}
|
||||
|
||||
export async function updateProject(
|
||||
id: string,
|
||||
name: string,
|
||||
baseBranch: string
|
||||
): Promise<void> {
|
||||
return invoke("update_project", { id, name, baseBranch });
|
||||
}
|
||||
|
||||
export async function deleteProject(id: string): Promise<void> {
|
||||
return invoke("delete_project", { id });
|
||||
}
|
||||
|
||||
// Credentials
|
||||
export async function setTuleapCredentials(tuleapUrl: string, username: string, password: string): Promise<TuleapCredentialsSafe> {
|
||||
return invoke("set_tuleap_credentials", { tuleapUrl, username, password });
|
||||
}
|
||||
export async function getTuleapCredentials(): Promise<TuleapCredentialsSafe | null> {
|
||||
return invoke("get_tuleap_credentials");
|
||||
}
|
||||
export async function deleteTuleapCredentials(): Promise<void> {
|
||||
return invoke("delete_tuleap_credentials");
|
||||
}
|
||||
export async function testTuleapConnection(): Promise<string> {
|
||||
return invoke("test_tuleap_connection");
|
||||
}
|
||||
|
||||
// Trackers
|
||||
export async function addTracker(projectId: string, trackerId: number, trackerLabel: string, pollingInterval: number, agentConfig: AgentConfig, filters: FilterGroup[]): Promise<WatchedTracker> {
|
||||
return invoke("add_tracker", { projectId, trackerId, trackerLabel, pollingInterval, agentConfig, filters });
|
||||
}
|
||||
export async function listTrackers(projectId: string): Promise<WatchedTracker[]> {
|
||||
return invoke("list_trackers", { projectId });
|
||||
}
|
||||
export async function updateTracker(id: string, pollingInterval: number, agentConfig: AgentConfig, filters: FilterGroup[], enabled: boolean): Promise<void> {
|
||||
return invoke("update_tracker", { id, pollingInterval, agentConfig, filters, enabled });
|
||||
}
|
||||
export async function removeTracker(id: string): Promise<void> {
|
||||
return invoke("remove_tracker", { id });
|
||||
}
|
||||
export async function getTrackerFields(trackerId: number): Promise<TrackerField[]> {
|
||||
return invoke("get_tracker_fields", { trackerId });
|
||||
}
|
||||
|
||||
// Tickets & Polling
|
||||
export async function listProcessedTickets(projectId: string): Promise<ProcessedTicket[]> {
|
||||
return invoke("list_processed_tickets", { projectId });
|
||||
}
|
||||
export async function manualPoll(trackerId: string): Promise<ProcessedTicket[]> {
|
||||
return invoke("manual_poll", { trackerId });
|
||||
}
|
||||
export async function getQueueStatus(projectId: string): Promise<ProcessedTicket[]> {
|
||||
return invoke("get_queue_status", { projectId });
|
||||
}
|
||||
71
src/lib/types.ts
Normal file
71
src/lib/types.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
cloned_from: string | null;
|
||||
base_branch: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TuleapCredentialsSafe {
|
||||
id: string;
|
||||
tuleap_url: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface AgentConfig {
|
||||
analyst_command: string;
|
||||
analyst_args: string[];
|
||||
developer_command: string;
|
||||
developer_args: string[];
|
||||
}
|
||||
|
||||
export interface Filter {
|
||||
field: string;
|
||||
operator: string;
|
||||
value: string[];
|
||||
}
|
||||
|
||||
export interface FilterGroup {
|
||||
conditions: Filter[];
|
||||
}
|
||||
|
||||
export interface TrackerField {
|
||||
field_id: number;
|
||||
label: string;
|
||||
field_type: string;
|
||||
values: FieldValue[];
|
||||
}
|
||||
|
||||
export interface FieldValue {
|
||||
id: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface WatchedTracker {
|
||||
id: string;
|
||||
project_id: string;
|
||||
tracker_id: number;
|
||||
tracker_label: string;
|
||||
polling_interval: number;
|
||||
agent_config: AgentConfig;
|
||||
filters: FilterGroup[];
|
||||
enabled: boolean;
|
||||
last_polled_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ProcessedTicket {
|
||||
id: string;
|
||||
tracker_id: string;
|
||||
artifact_id: number;
|
||||
artifact_title: string;
|
||||
artifact_data: string;
|
||||
status: string;
|
||||
analyst_report: string | null;
|
||||
developer_report: string | null;
|
||||
worktree_path: string | null;
|
||||
branch_name: string | null;
|
||||
detected_at: string;
|
||||
processed_at: string | null;
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
25
vite.config.ts
Normal file
25
vite.config.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [react(), tailwindcss()],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||
Loading…
Reference in a new issue