2335 lines
75 KiB
Markdown
2335 lines
75 KiB
Markdown
# Orchai Phase 3: Agent Pipeline 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.
|
|
|
|
**Goal:** Build the agent orchestrator that consumes pending tickets via a sequential FIFO queue, runs a two-step CLI pipeline (analyst then developer), manages git worktrees for code fixes, and provides a frontend for viewing results (markdown reports + diff).
|
|
|
|
**Architecture:** A background tokio task (orchestrator) polls the DB every 10 seconds for Pending tickets. For each ticket it runs an analyst CLI command (e.g. `claude --print`) with a structured prompt via stdin, stores the markdown report, then optionally creates a git worktree and runs a developer CLI command. Tauri events stream progress to the frontend. A Worktree Manager service handles git worktree lifecycle (create, diff, cherry-pick, delete). The frontend adds ticket list/detail pages with markdown rendering and a diff viewer.
|
|
|
|
**Tech Stack:** tokio (process spawning, async I/O), git CLI (worktree ops), react-markdown + remark-gfm (report rendering)
|
|
|
|
---
|
|
|
|
## Phasing Context
|
|
|
|
This is Plan 3 of 4:
|
|
- **Plan 1 (done):** Foundation -- Tauri scaffold, SQLite, Project Manager
|
|
- **Plan 2 (done):** Tuleap Integration -- credentials, API client, poller, filter engine, tracker config
|
|
- **Plan 3 (this):** Agent Pipeline -- orchestrator, worktree manager, ticket processing, results UI
|
|
- **Plan 4:** Notifications + Polish -- notifier, system notifications, dashboard
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
```
|
|
src-tauri/
|
|
Cargo.toml # modify: add tokio process+io-util features
|
|
src/
|
|
lib.rs # modify: start orchestrator, register new commands
|
|
models/
|
|
mod.rs # modify: add worktree module
|
|
ticket.rs # modify: add update methods, list_pending, status constants
|
|
worktree.rs # create: Worktree struct + CRUD
|
|
services/
|
|
mod.rs # modify: add orchestrator, worktree_manager
|
|
worktree_manager.rs # create: git worktree operations
|
|
orchestrator.rs # create: queue consumer, CLI runner, prompt builder
|
|
commands/
|
|
mod.rs # modify: add orchestrator, worktree
|
|
orchestrator.rs # create: retry_ticket, cancel_ticket, get_ticket_result
|
|
worktree.rs # create: list_worktrees, get_diff, apply_fix, delete, list_branches
|
|
|
|
src/
|
|
lib/
|
|
types.ts # modify: add Worktree type, TicketResult type
|
|
api.ts # modify: add orchestrator + worktree API wrappers
|
|
components/
|
|
tickets/
|
|
TicketList.tsx # create: filterable ticket table
|
|
TicketDetail.tsx # create: info + markdown reports + diff + actions
|
|
projects/
|
|
ProjectDashboard.tsx # modify: make ticket items clickable links
|
|
App.tsx # modify: add /projects/:projectId/tickets and /tickets/:ticketId routes
|
|
```
|
|
|
|
---
|
|
|
|
### Task 1: Add tokio features + extend ProcessedTicket model
|
|
|
|
**Files:**
|
|
- Modify: `src-tauri/Cargo.toml`
|
|
- Modify: `src-tauri/src/models/ticket.rs`
|
|
|
|
- [ ] **Step 1: Add tokio process and io-util features to Cargo.toml**
|
|
|
|
In `src-tauri/Cargo.toml`, change the tokio line:
|
|
|
|
```toml
|
|
tokio = { version = "1", features = ["time", "sync", "macros", "process", "io-util"] }
|
|
```
|
|
|
|
- [ ] **Step 2: Write failing tests for ProcessedTicket update methods**
|
|
|
|
Append these tests to the existing `#[cfg(test)] mod tests` block in `src-tauri/src/models/ticket.rs`:
|
|
|
|
```rust
|
|
#[test]
|
|
fn test_update_status() {
|
|
let (conn, tracker_id) = setup();
|
|
let ticket = ProcessedTicket::insert_if_new(&conn, &tracker_id, 1, "T1", "{}")
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
ProcessedTicket::update_status(&conn, &ticket.id, "Analyzing").unwrap();
|
|
let updated = ProcessedTicket::get_by_id(&conn, &ticket.id).unwrap();
|
|
assert_eq!(updated.status, "Analyzing");
|
|
}
|
|
|
|
#[test]
|
|
fn test_set_analyst_report() {
|
|
let (conn, tracker_id) = setup();
|
|
let ticket = ProcessedTicket::insert_if_new(&conn, &tracker_id, 1, "T1", "{}")
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
ProcessedTicket::set_analyst_report(&conn, &ticket.id, "## Report\nAll good.").unwrap();
|
|
let updated = ProcessedTicket::get_by_id(&conn, &ticket.id).unwrap();
|
|
assert_eq!(updated.analyst_report.unwrap(), "## Report\nAll good.");
|
|
}
|
|
|
|
#[test]
|
|
fn test_set_developer_report() {
|
|
let (conn, tracker_id) = setup();
|
|
let ticket = ProcessedTicket::insert_if_new(&conn, &tracker_id, 1, "T1", "{}")
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
ProcessedTicket::set_developer_report(&conn, &ticket.id, "Fixed in main.rs").unwrap();
|
|
let updated = ProcessedTicket::get_by_id(&conn, &ticket.id).unwrap();
|
|
assert_eq!(updated.developer_report.unwrap(), "Fixed in main.rs");
|
|
assert!(updated.processed_at.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_set_worktree_info() {
|
|
let (conn, tracker_id) = setup();
|
|
let ticket = ProcessedTicket::insert_if_new(&conn, &tracker_id, 1, "T1", "{}")
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
ProcessedTicket::set_worktree_info(&conn, &ticket.id, "/tmp/wt", "orchai/1").unwrap();
|
|
let updated = ProcessedTicket::get_by_id(&conn, &ticket.id).unwrap();
|
|
assert_eq!(updated.worktree_path.unwrap(), "/tmp/wt");
|
|
assert_eq!(updated.branch_name.unwrap(), "orchai/1");
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_pending() {
|
|
let (conn, tracker_id) = setup();
|
|
ProcessedTicket::insert_if_new(&conn, &tracker_id, 1, "T1", "{}").unwrap();
|
|
ProcessedTicket::insert_if_new(&conn, &tracker_id, 2, "T2", "{}").unwrap();
|
|
|
|
let pending = ProcessedTicket::list_pending(&conn).unwrap();
|
|
assert_eq!(pending.len(), 2);
|
|
// FIFO order: T1 first (oldest detected_at)
|
|
assert_eq!(pending[0].artifact_id, 1);
|
|
assert_eq!(pending[1].artifact_id, 2);
|
|
|
|
// Mark one as Analyzing, it should no longer be in pending
|
|
ProcessedTicket::update_status(&conn, &pending[0].id, "Analyzing").unwrap();
|
|
let pending2 = ProcessedTicket::list_pending(&conn).unwrap();
|
|
assert_eq!(pending2.len(), 1);
|
|
assert_eq!(pending2[0].artifact_id, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_set_error() {
|
|
let (conn, tracker_id) = setup();
|
|
let ticket = ProcessedTicket::insert_if_new(&conn, &tracker_id, 1, "T1", "{}")
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
ProcessedTicket::set_error(&conn, &ticket.id, "CLI timeout after 600s").unwrap();
|
|
let updated = ProcessedTicket::get_by_id(&conn, &ticket.id).unwrap();
|
|
assert_eq!(updated.status, "Error");
|
|
assert_eq!(updated.analyst_report.unwrap(), "CLI timeout after 600s");
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run tests to verify they fail**
|
|
|
|
Run: `cd /home/leclere/Projets/orchai/src-tauri && cargo test --lib models::ticket::tests -- --nocapture 2>&1 | tail -20`
|
|
Expected: compilation errors (methods don't exist yet)
|
|
|
|
- [ ] **Step 4: Implement the update methods**
|
|
|
|
Add these methods to `impl ProcessedTicket` in `src-tauri/src/models/ticket.rs`, before the closing `}` of the impl block:
|
|
|
|
```rust
|
|
pub fn update_status(conn: &Connection, id: &str, status: &str) -> Result<()> {
|
|
conn.execute(
|
|
"UPDATE processed_tickets SET status = ?1 WHERE id = ?2",
|
|
params![status, id],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn set_analyst_report(conn: &Connection, id: &str, report: &str) -> Result<()> {
|
|
conn.execute(
|
|
"UPDATE processed_tickets SET analyst_report = ?1 WHERE id = ?2",
|
|
params![report, id],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn set_developer_report(conn: &Connection, id: &str, report: &str) -> Result<()> {
|
|
conn.execute(
|
|
"UPDATE processed_tickets SET developer_report = ?1, processed_at = datetime('now') WHERE id = ?2",
|
|
params![report, id],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn set_worktree_info(
|
|
conn: &Connection,
|
|
id: &str,
|
|
worktree_path: &str,
|
|
branch_name: &str,
|
|
) -> Result<()> {
|
|
conn.execute(
|
|
"UPDATE processed_tickets SET worktree_path = ?1, branch_name = ?2 WHERE id = ?3",
|
|
params![worktree_path, branch_name, id],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn list_pending(conn: &Connection) -> Result<Vec<ProcessedTicket>> {
|
|
let sql = format!("{} WHERE status = 'Pending' ORDER BY detected_at ASC", SELECT_ALL_COLS);
|
|
let mut stmt = conn.prepare(&sql)?;
|
|
let rows = stmt.query_map([], from_row)?;
|
|
rows.collect()
|
|
}
|
|
|
|
pub fn set_error(conn: &Connection, id: &str, error_message: &str) -> Result<()> {
|
|
conn.execute(
|
|
"UPDATE processed_tickets SET status = 'Error', analyst_report = COALESCE(analyst_report, '') || ?1, processed_at = datetime('now') WHERE id = ?2",
|
|
params![error_message, id],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
Also remove `#[allow(dead_code)]` from `list_by_tracker` and `get_by_id` since they will be used now.
|
|
|
|
- [ ] **Step 5: Run tests to verify they pass**
|
|
|
|
Run: `cd /home/leclere/Projets/orchai/src-tauri && cargo test --lib models::ticket::tests -- --nocapture`
|
|
Expected: all ticket tests pass
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
cd /home/leclere/Projets/orchai
|
|
git add src-tauri/Cargo.toml src-tauri/src/models/ticket.rs
|
|
git commit -m "feat: extend ProcessedTicket with status updates, list_pending, and set_error"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Create Worktree model
|
|
|
|
**Files:**
|
|
- Create: `src-tauri/src/models/worktree.rs`
|
|
- Modify: `src-tauri/src/models/mod.rs`
|
|
|
|
- [ ] **Step 1: Add worktree module to mod.rs**
|
|
|
|
In `src-tauri/src/models/mod.rs`, add:
|
|
|
|
```rust
|
|
pub mod worktree;
|
|
```
|
|
|
|
- [ ] **Step 2: Create worktree.rs with struct, CRUD, and tests**
|
|
|
|
Create `src-tauri/src/models/worktree.rs`:
|
|
|
|
```rust
|
|
use rusqlite::{params, Connection, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Worktree {
|
|
pub id: String,
|
|
pub ticket_id: String,
|
|
pub path: String,
|
|
pub branch_name: String,
|
|
pub status: String,
|
|
pub created_at: String,
|
|
pub merged_at: Option<String>,
|
|
pub merged_into: Option<String>,
|
|
}
|
|
|
|
fn from_row(row: &rusqlite::Row) -> rusqlite::Result<Worktree> {
|
|
Ok(Worktree {
|
|
id: row.get(0)?,
|
|
ticket_id: row.get(1)?,
|
|
path: row.get(2)?,
|
|
branch_name: row.get(3)?,
|
|
status: row.get(4)?,
|
|
created_at: row.get(5)?,
|
|
merged_at: row.get(6)?,
|
|
merged_into: row.get(7)?,
|
|
})
|
|
}
|
|
|
|
const SELECT_ALL_COLS: &str = "SELECT id, ticket_id, path, branch_name, status, \
|
|
created_at, merged_at, merged_into FROM worktrees";
|
|
|
|
impl Worktree {
|
|
pub fn insert(
|
|
conn: &Connection,
|
|
ticket_id: &str,
|
|
path: &str,
|
|
branch_name: &str,
|
|
) -> Result<Worktree> {
|
|
let id = Uuid::new_v4().to_string();
|
|
let now = chrono::Utc::now().to_rfc3339();
|
|
|
|
conn.execute(
|
|
"INSERT INTO worktrees (id, ticket_id, path, branch_name, status, created_at) \
|
|
VALUES (?1, ?2, ?3, ?4, 'Active', ?5)",
|
|
params![id, ticket_id, path, branch_name, now],
|
|
)?;
|
|
|
|
Ok(Worktree {
|
|
id,
|
|
ticket_id: ticket_id.to_string(),
|
|
path: path.to_string(),
|
|
branch_name: branch_name.to_string(),
|
|
status: "Active".to_string(),
|
|
created_at: now,
|
|
merged_at: None,
|
|
merged_into: None,
|
|
})
|
|
}
|
|
|
|
pub fn get_by_id(conn: &Connection, id: &str) -> Result<Worktree> {
|
|
let sql = format!("{} WHERE id = ?1", SELECT_ALL_COLS);
|
|
conn.query_row(&sql, params![id], from_row)
|
|
}
|
|
|
|
pub fn get_by_ticket_id(conn: &Connection, ticket_id: &str) -> Result<Option<Worktree>> {
|
|
let sql = format!("{} WHERE ticket_id = ?1", SELECT_ALL_COLS);
|
|
let mut stmt = conn.prepare(&sql)?;
|
|
let mut rows = stmt.query_map(params![ticket_id], from_row)?;
|
|
match rows.next() {
|
|
Some(Ok(w)) => Ok(Some(w)),
|
|
Some(Err(e)) => Err(e),
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
pub fn list_by_project(conn: &Connection, project_id: &str) -> Result<Vec<Worktree>> {
|
|
let sql = format!(
|
|
"SELECT w.id, w.ticket_id, w.path, w.branch_name, w.status, \
|
|
w.created_at, w.merged_at, w.merged_into \
|
|
FROM worktrees w \
|
|
JOIN processed_tickets pt ON w.ticket_id = pt.id \
|
|
JOIN watched_trackers wt ON pt.tracker_id = wt.id \
|
|
WHERE wt.project_id = ?1 \
|
|
ORDER BY w.created_at DESC"
|
|
);
|
|
let mut stmt = conn.prepare(&sql)?;
|
|
let rows = stmt.query_map(params![project_id], from_row)?;
|
|
rows.collect()
|
|
}
|
|
|
|
pub fn set_merged(
|
|
conn: &Connection,
|
|
id: &str,
|
|
target_branch: &str,
|
|
) -> Result<()> {
|
|
conn.execute(
|
|
"UPDATE worktrees SET status = 'Merged', merged_at = datetime('now'), merged_into = ?1 WHERE id = ?2",
|
|
params![target_branch, id],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn delete(conn: &Connection, id: &str) -> Result<()> {
|
|
conn.execute("DELETE FROM worktrees WHERE id = ?1", params![id])?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::db;
|
|
use crate::models::project::Project;
|
|
use crate::models::ticket::ProcessedTicket;
|
|
use crate::models::tracker::{AgentConfig, WatchedTracker};
|
|
|
|
fn setup() -> (Connection, String) {
|
|
let conn = db::init_in_memory().expect("db init");
|
|
let project = Project::insert(&conn, "Test", "/path", None, "main").unwrap();
|
|
let agent_config = AgentConfig {
|
|
analyst_command: "echo".into(),
|
|
analyst_args: vec![],
|
|
developer_command: "echo".into(),
|
|
developer_args: vec![],
|
|
};
|
|
let tracker =
|
|
WatchedTracker::insert(&conn, &project.id, 100, "Bugs", 10, agent_config, vec![])
|
|
.unwrap();
|
|
let ticket = ProcessedTicket::insert_if_new(&conn, &tracker.id, 42, "Bug 42", "{}")
|
|
.unwrap()
|
|
.unwrap();
|
|
(conn, ticket.id)
|
|
}
|
|
|
|
#[test]
|
|
fn test_insert_and_get_by_id() {
|
|
let (conn, ticket_id) = setup();
|
|
|
|
let wt = Worktree::insert(&conn, &ticket_id, "/tmp/orchai-42", "orchai/42").unwrap();
|
|
assert_eq!(wt.status, "Active");
|
|
assert_eq!(wt.branch_name, "orchai/42");
|
|
|
|
let found = Worktree::get_by_id(&conn, &wt.id).unwrap();
|
|
assert_eq!(found.id, wt.id);
|
|
assert_eq!(found.ticket_id, ticket_id);
|
|
assert_eq!(found.path, "/tmp/orchai-42");
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_by_ticket_id() {
|
|
let (conn, ticket_id) = setup();
|
|
|
|
let none = Worktree::get_by_ticket_id(&conn, &ticket_id).unwrap();
|
|
assert!(none.is_none());
|
|
|
|
Worktree::insert(&conn, &ticket_id, "/tmp/wt", "orchai/42").unwrap();
|
|
|
|
let some = Worktree::get_by_ticket_id(&conn, &ticket_id).unwrap();
|
|
assert!(some.is_some());
|
|
assert_eq!(some.unwrap().ticket_id, ticket_id);
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_by_project() {
|
|
let conn = db::init_in_memory().expect("db init");
|
|
let project = Project::insert(&conn, "P1", "/path", None, "main").unwrap();
|
|
let agent_config = AgentConfig {
|
|
analyst_command: "echo".into(),
|
|
analyst_args: vec![],
|
|
developer_command: "echo".into(),
|
|
developer_args: vec![],
|
|
};
|
|
let tracker =
|
|
WatchedTracker::insert(&conn, &project.id, 100, "Bugs", 10, agent_config, vec![])
|
|
.unwrap();
|
|
let t1 = ProcessedTicket::insert_if_new(&conn, &tracker.id, 1, "T1", "{}")
|
|
.unwrap()
|
|
.unwrap();
|
|
let t2 = ProcessedTicket::insert_if_new(&conn, &tracker.id, 2, "T2", "{}")
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
Worktree::insert(&conn, &t1.id, "/wt1", "orchai/1").unwrap();
|
|
Worktree::insert(&conn, &t2.id, "/wt2", "orchai/2").unwrap();
|
|
|
|
let worktrees = Worktree::list_by_project(&conn, &project.id).unwrap();
|
|
assert_eq!(worktrees.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_set_merged() {
|
|
let (conn, ticket_id) = setup();
|
|
let wt = Worktree::insert(&conn, &ticket_id, "/tmp/wt", "orchai/42").unwrap();
|
|
|
|
Worktree::set_merged(&conn, &wt.id, "feature/login").unwrap();
|
|
let updated = Worktree::get_by_id(&conn, &wt.id).unwrap();
|
|
assert_eq!(updated.status, "Merged");
|
|
assert_eq!(updated.merged_into.unwrap(), "feature/login");
|
|
assert!(updated.merged_at.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_delete() {
|
|
let (conn, ticket_id) = setup();
|
|
let wt = Worktree::insert(&conn, &ticket_id, "/tmp/wt", "orchai/42").unwrap();
|
|
|
|
Worktree::delete(&conn, &wt.id).unwrap();
|
|
let result = Worktree::get_by_id(&conn, &wt.id);
|
|
assert!(result.is_err()); // Not found
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run tests to verify they pass**
|
|
|
|
Run: `cd /home/leclere/Projets/orchai/src-tauri && cargo test --lib models::worktree::tests -- --nocapture`
|
|
Expected: all 5 worktree tests pass
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
cd /home/leclere/Projets/orchai
|
|
git add src-tauri/src/models/mod.rs src-tauri/src/models/worktree.rs
|
|
git commit -m "feat: add Worktree model with CRUD operations"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Worktree Manager service
|
|
|
|
**Files:**
|
|
- Create: `src-tauri/src/services/worktree_manager.rs`
|
|
- Modify: `src-tauri/src/services/mod.rs`
|
|
|
|
- [ ] **Step 1: Add worktree_manager to services/mod.rs**
|
|
|
|
In `src-tauri/src/services/mod.rs`, add:
|
|
|
|
```rust
|
|
pub mod worktree_manager;
|
|
```
|
|
|
|
- [ ] **Step 2: Create worktree_manager.rs with git operations**
|
|
|
|
Create `src-tauri/src/services/worktree_manager.rs`:
|
|
|
|
```rust
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
|
|
fn run_git(project_path: &str, args: &[&str]) -> Result<String, String> {
|
|
let output = Command::new("git")
|
|
.args(args)
|
|
.current_dir(project_path)
|
|
.output()
|
|
.map_err(|e| format!("Failed to run git: {}", e))?;
|
|
|
|
if output.status.success() {
|
|
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
|
} else {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
Err(format!("git {} failed: {}", args.join(" "), stderr))
|
|
}
|
|
}
|
|
|
|
/// Creates a git worktree at `.orchai/worktrees/orchai-{artifact_id}`
|
|
/// with a new branch `orchai/{artifact_id}` based on `base_branch`.
|
|
/// Returns (worktree_path, branch_name).
|
|
pub fn create_worktree(
|
|
project_path: &str,
|
|
base_branch: &str,
|
|
artifact_id: i32,
|
|
) -> Result<(String, String), String> {
|
|
let orchai_dir = Path::new(project_path).join(".orchai").join("worktrees");
|
|
std::fs::create_dir_all(&orchai_dir)
|
|
.map_err(|e| format!("Failed to create .orchai/worktrees dir: {}", e))?;
|
|
|
|
let worktree_name = format!("orchai-{}", artifact_id);
|
|
let worktree_path = orchai_dir.join(&worktree_name);
|
|
let branch_name = format!("orchai/{}", artifact_id);
|
|
|
|
let wt_path_str = worktree_path
|
|
.to_str()
|
|
.ok_or("Invalid worktree path")?;
|
|
|
|
run_git(
|
|
project_path,
|
|
&["worktree", "add", wt_path_str, "-b", &branch_name, base_branch],
|
|
)?;
|
|
|
|
Ok((wt_path_str.to_string(), branch_name))
|
|
}
|
|
|
|
/// Returns the unified diff between the base branch and the worktree branch.
|
|
pub fn get_diff(project_path: &str, base_branch: &str, branch_name: &str) -> Result<String, String> {
|
|
let range = format!("{}...{}", base_branch, branch_name);
|
|
run_git(project_path, &["diff", &range])
|
|
}
|
|
|
|
/// Lists commit hashes on the worktree branch that are not on the base branch (oldest first).
|
|
pub fn list_commits(
|
|
project_path: &str,
|
|
base_branch: &str,
|
|
branch_name: &str,
|
|
) -> Result<Vec<String>, String> {
|
|
let range = format!("{}..{}", base_branch, branch_name);
|
|
let output = run_git(project_path, &["log", &range, "--format=%H", "--reverse"])?;
|
|
Ok(output.lines().filter(|l| !l.is_empty()).map(String::from).collect())
|
|
}
|
|
|
|
/// Cherry-picks commits from the worktree branch into the target branch.
|
|
/// Saves and restores the current branch.
|
|
pub fn apply_fix(
|
|
project_path: &str,
|
|
base_branch: &str,
|
|
branch_name: &str,
|
|
target_branch: &str,
|
|
) -> Result<(), String> {
|
|
let commits = list_commits(project_path, base_branch, branch_name)?;
|
|
if commits.is_empty() {
|
|
return Err("No commits to cherry-pick".to_string());
|
|
}
|
|
|
|
// Save current branch
|
|
let current = run_git(project_path, &["rev-parse", "--abbrev-ref", "HEAD"])?;
|
|
let current = current.trim();
|
|
|
|
// Checkout target branch
|
|
run_git(project_path, &["checkout", target_branch])?;
|
|
|
|
// Cherry-pick each commit
|
|
let mut cherry_args = vec!["cherry-pick"];
|
|
let commit_refs: Vec<&str> = commits.iter().map(|s| s.as_str()).collect();
|
|
cherry_args.extend(&commit_refs);
|
|
|
|
let result = run_git(project_path, &cherry_args);
|
|
|
|
if let Err(e) = &result {
|
|
// Abort cherry-pick on conflict
|
|
let _ = run_git(project_path, &["cherry-pick", "--abort"]);
|
|
// Restore original branch
|
|
let _ = run_git(project_path, &["checkout", current]);
|
|
return Err(format!("Cherry-pick failed (conflict?): {}", e));
|
|
}
|
|
|
|
// Restore original branch
|
|
run_git(project_path, &["checkout", current])?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Removes the git worktree and deletes the local branch.
|
|
pub fn delete_worktree(
|
|
project_path: &str,
|
|
worktree_path: &str,
|
|
branch_name: &str,
|
|
) -> Result<(), String> {
|
|
// Remove worktree (force in case of unclean state)
|
|
run_git(project_path, &["worktree", "remove", worktree_path, "--force"])?;
|
|
// Delete branch
|
|
let _ = run_git(project_path, &["branch", "-D", branch_name]);
|
|
Ok(())
|
|
}
|
|
|
|
/// Lists local branch names.
|
|
pub fn list_local_branches(project_path: &str) -> Result<Vec<String>, String> {
|
|
let output = run_git(project_path, &["branch", "--format=%(refname:short)"])?;
|
|
Ok(output.lines().filter(|l| !l.is_empty()).map(String::from).collect())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::process::Command;
|
|
|
|
fn setup_test_repo() -> tempfile::TempDir {
|
|
let dir = tempfile::tempdir().expect("create temp dir");
|
|
let path = dir.path().to_str().unwrap();
|
|
|
|
Command::new("git").args(["init"]).current_dir(path).output().unwrap();
|
|
Command::new("git").args(["config", "user.email", "test@test.com"]).current_dir(path).output().unwrap();
|
|
Command::new("git").args(["config", "user.name", "Test"]).current_dir(path).output().unwrap();
|
|
|
|
// Create initial commit on main
|
|
std::fs::write(dir.path().join("README.md"), "# Test").unwrap();
|
|
Command::new("git").args(["add", "."]).current_dir(path).output().unwrap();
|
|
Command::new("git").args(["commit", "-m", "init"]).current_dir(path).output().unwrap();
|
|
|
|
dir
|
|
}
|
|
|
|
#[test]
|
|
fn test_create_worktree() {
|
|
let dir = setup_test_repo();
|
|
let path = dir.path().to_str().unwrap();
|
|
|
|
let (wt_path, branch) = create_worktree(path, "main", 42).unwrap();
|
|
assert!(wt_path.contains("orchai-42"));
|
|
assert_eq!(branch, "orchai/42");
|
|
assert!(Path::new(&wt_path).exists());
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_diff_empty() {
|
|
let dir = setup_test_repo();
|
|
let path = dir.path().to_str().unwrap();
|
|
|
|
let (_, branch) = create_worktree(path, "main", 1).unwrap();
|
|
let diff = get_diff(path, "main", &branch).unwrap();
|
|
assert!(diff.is_empty(), "No changes yet, diff should be empty");
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_diff_with_changes() {
|
|
let dir = setup_test_repo();
|
|
let path = dir.path().to_str().unwrap();
|
|
|
|
let (wt_path, branch) = create_worktree(path, "main", 2).unwrap();
|
|
|
|
// Make a change in the worktree
|
|
std::fs::write(Path::new(&wt_path).join("fix.txt"), "fixed").unwrap();
|
|
Command::new("git").args(["add", "."]).current_dir(&wt_path).output().unwrap();
|
|
Command::new("git").args(["commit", "-m", "fix"]).current_dir(&wt_path).output().unwrap();
|
|
|
|
let diff = get_diff(path, "main", &branch).unwrap();
|
|
assert!(diff.contains("fix.txt"));
|
|
assert!(diff.contains("+fixed"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_commits() {
|
|
let dir = setup_test_repo();
|
|
let path = dir.path().to_str().unwrap();
|
|
|
|
let (wt_path, branch) = create_worktree(path, "main", 3).unwrap();
|
|
|
|
// Make two commits in the worktree
|
|
std::fs::write(Path::new(&wt_path).join("a.txt"), "a").unwrap();
|
|
Command::new("git").args(["add", "."]).current_dir(&wt_path).output().unwrap();
|
|
Command::new("git").args(["commit", "-m", "first"]).current_dir(&wt_path).output().unwrap();
|
|
|
|
std::fs::write(Path::new(&wt_path).join("b.txt"), "b").unwrap();
|
|
Command::new("git").args(["add", "."]).current_dir(&wt_path).output().unwrap();
|
|
Command::new("git").args(["commit", "-m", "second"]).current_dir(&wt_path).output().unwrap();
|
|
|
|
let commits = list_commits(path, "main", &branch).unwrap();
|
|
assert_eq!(commits.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_local_branches() {
|
|
let dir = setup_test_repo();
|
|
let path = dir.path().to_str().unwrap();
|
|
|
|
create_worktree(path, "main", 10).unwrap();
|
|
let branches = list_local_branches(path).unwrap();
|
|
assert!(branches.contains(&"main".to_string()));
|
|
assert!(branches.contains(&"orchai/10".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_delete_worktree() {
|
|
let dir = setup_test_repo();
|
|
let path = dir.path().to_str().unwrap();
|
|
|
|
let (wt_path, branch) = create_worktree(path, "main", 99).unwrap();
|
|
assert!(Path::new(&wt_path).exists());
|
|
|
|
delete_worktree(path, &wt_path, &branch).unwrap();
|
|
assert!(!Path::new(&wt_path).exists());
|
|
|
|
let branches = list_local_branches(path).unwrap();
|
|
assert!(!branches.contains(&"orchai/99".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_apply_fix() {
|
|
let dir = setup_test_repo();
|
|
let path = dir.path().to_str().unwrap();
|
|
|
|
// Create a target branch
|
|
Command::new("git").args(["branch", "feature/test"]).current_dir(path).output().unwrap();
|
|
|
|
// Create worktree and make a change
|
|
let (wt_path, branch) = create_worktree(path, "main", 7).unwrap();
|
|
std::fs::write(Path::new(&wt_path).join("fix.txt"), "the fix").unwrap();
|
|
Command::new("git").args(["add", "."]).current_dir(&wt_path).output().unwrap();
|
|
Command::new("git").args(["commit", "-m", "apply fix"]).current_dir(&wt_path).output().unwrap();
|
|
|
|
// Apply fix to feature branch
|
|
apply_fix(path, "main", &branch, "feature/test").unwrap();
|
|
|
|
// Verify: check out feature branch and verify the file exists
|
|
Command::new("git").args(["checkout", "feature/test"]).current_dir(path).output().unwrap();
|
|
assert!(Path::new(path).join("fix.txt").exists());
|
|
|
|
// Go back to main
|
|
Command::new("git").args(["checkout", "main"]).current_dir(path).output().unwrap();
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Add `tempfile` dev-dependency to Cargo.toml**
|
|
|
|
In `src-tauri/Cargo.toml`, add under `[dev-dependencies]`:
|
|
|
|
```toml
|
|
[dev-dependencies]
|
|
tempfile = "3"
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run: `cd /home/leclere/Projets/orchai/src-tauri && cargo test --lib services::worktree_manager::tests -- --nocapture`
|
|
Expected: all 7 worktree manager tests pass
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
cd /home/leclere/Projets/orchai
|
|
git add src-tauri/Cargo.toml src-tauri/src/services/mod.rs src-tauri/src/services/worktree_manager.rs
|
|
git commit -m "feat: add Worktree Manager service with git worktree operations"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Agent Orchestrator - prompt building and verdict parsing
|
|
|
|
**Files:**
|
|
- Create: `src-tauri/src/services/orchestrator.rs`
|
|
- Modify: `src-tauri/src/services/mod.rs`
|
|
|
|
- [ ] **Step 1: Add orchestrator to services/mod.rs**
|
|
|
|
In `src-tauri/src/services/mod.rs`, add:
|
|
|
|
```rust
|
|
pub mod orchestrator;
|
|
```
|
|
|
|
- [ ] **Step 2: Create orchestrator.rs with prompt building, verdict parsing, and tests**
|
|
|
|
Create `src-tauri/src/services/orchestrator.rs`:
|
|
|
|
```rust
|
|
use crate::models::project::Project;
|
|
use crate::models::ticket::ProcessedTicket;
|
|
use crate::models::tracker::WatchedTracker;
|
|
use crate::models::worktree::Worktree;
|
|
use crate::services::worktree_manager;
|
|
use rusqlite::Connection;
|
|
use std::sync::{Arc, Mutex};
|
|
use tauri::{AppHandle, Emitter};
|
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
|
use tokio::process::Command;
|
|
use tokio::time::{interval, timeout, Duration};
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum Verdict {
|
|
FixNeeded,
|
|
NoFix,
|
|
}
|
|
|
|
pub fn build_analyst_prompt(ticket: &ProcessedTicket, project: &Project) -> String {
|
|
format!(
|
|
r#"Tu es un analyste technique. Voici un ticket Tuleap a analyser.
|
|
|
|
## Ticket
|
|
- ID: {artifact_id}
|
|
- Titre: {title}
|
|
- Donnees: {data}
|
|
|
|
## Contexte
|
|
- Projet: {project_name}
|
|
- Repo: {project_path}
|
|
- Branche de base: {base_branch}
|
|
|
|
## Ta mission
|
|
1. Analyse le ticket et identifie les fichiers/fonctions concernes
|
|
2. Explique techniquement le probleme
|
|
3. Evalue si une correction de code est necessaire
|
|
4. Produis un rapport structure en markdown
|
|
|
|
Termine ton rapport par un de ces verdicts sur une ligne separee:
|
|
[VERDICT: FIX_NEEDED] si une correction de code est necessaire
|
|
[VERDICT: NO_FIX] si aucune correction n'est necessaire"#,
|
|
artifact_id = ticket.artifact_id,
|
|
title = ticket.artifact_title,
|
|
data = ticket.artifact_data,
|
|
project_name = project.name,
|
|
project_path = project.path,
|
|
base_branch = project.base_branch,
|
|
)
|
|
}
|
|
|
|
pub fn build_developer_prompt(
|
|
ticket: &ProcessedTicket,
|
|
project: &Project,
|
|
analyst_report: &str,
|
|
worktree_path: &str,
|
|
) -> String {
|
|
format!(
|
|
r#"Tu es un developpeur. Tu dois corriger un bug ou implementer une fonctionnalite d'apres l'analyse suivante.
|
|
|
|
## Rapport d'analyse
|
|
{analyst_report}
|
|
|
|
## Ticket
|
|
- ID: {artifact_id}
|
|
- Titre: {title}
|
|
|
|
## Contexte
|
|
- Projet: {project_name}
|
|
- Repo (worktree): {worktree_path}
|
|
- Branche de base: {base_branch}
|
|
|
|
## Ta mission
|
|
1. Implemente la correction dans le code
|
|
2. Fais des commits atomiques avec des messages clairs
|
|
3. Produis un rapport en markdown decrivant les changements effectues"#,
|
|
analyst_report = analyst_report,
|
|
artifact_id = ticket.artifact_id,
|
|
title = ticket.artifact_title,
|
|
project_name = project.name,
|
|
worktree_path = worktree_path,
|
|
base_branch = project.base_branch,
|
|
)
|
|
}
|
|
|
|
pub fn parse_verdict(report: &str) -> Verdict {
|
|
// Search from the end of the report for the verdict line
|
|
for line in report.lines().rev() {
|
|
let trimmed = line.trim();
|
|
if trimmed.contains("[VERDICT: NO_FIX]") {
|
|
return Verdict::NoFix;
|
|
}
|
|
if trimmed.contains("[VERDICT: FIX_NEEDED]") {
|
|
return Verdict::FixNeeded;
|
|
}
|
|
}
|
|
// Default: assume fix is needed if no verdict found
|
|
Verdict::FixNeeded
|
|
}
|
|
|
|
/// Runs a CLI command with the prompt piped to stdin.
|
|
/// Streams stdout lines as Tauri events. Returns the full stdout output.
|
|
pub async fn run_cli_command(
|
|
command: &str,
|
|
args: &[String],
|
|
prompt: &str,
|
|
working_dir: &str,
|
|
timeout_secs: u64,
|
|
app_handle: &AppHandle,
|
|
ticket_id: &str,
|
|
) -> Result<String, String> {
|
|
let mut child = Command::new(command)
|
|
.args(args)
|
|
.stdin(std::process::Stdio::piped())
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::piped())
|
|
.current_dir(working_dir)
|
|
.spawn()
|
|
.map_err(|e| format!("Failed to spawn '{}': {}", command, e))?;
|
|
|
|
// Write prompt to stdin
|
|
if let Some(mut stdin) = child.stdin.take() {
|
|
use tokio::io::AsyncWriteExt;
|
|
stdin
|
|
.write_all(prompt.as_bytes())
|
|
.await
|
|
.map_err(|e| format!("Failed to write to stdin: {}", e))?;
|
|
// stdin is dropped here, closing it
|
|
}
|
|
|
|
// Read stdout line by line, streaming events
|
|
let stdout = child.stdout.take().ok_or("Failed to capture stdout")?;
|
|
let mut reader = BufReader::new(stdout).lines();
|
|
let mut output = String::new();
|
|
|
|
let read_future = async {
|
|
while let Ok(Some(line)) = reader.next_line().await {
|
|
let _ = app_handle.emit(
|
|
"ticket-processing-progress",
|
|
serde_json::json!({
|
|
"ticket_id": ticket_id,
|
|
"output_chunk": line,
|
|
}),
|
|
);
|
|
output.push_str(&line);
|
|
output.push('\n');
|
|
}
|
|
output
|
|
};
|
|
|
|
let result = timeout(Duration::from_secs(timeout_secs), read_future)
|
|
.await
|
|
.map_err(|_| format!("CLI command timed out after {}s", timeout_secs))?;
|
|
|
|
// Wait for process to finish
|
|
let status = child
|
|
.wait()
|
|
.await
|
|
.map_err(|e| format!("Failed to wait for process: {}", e))?;
|
|
|
|
if !status.success() {
|
|
let code = status.code().unwrap_or(-1);
|
|
return Err(format!("CLI command exited with code {}", code));
|
|
}
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
/// Processes a single ticket through the analyst -> developer pipeline.
|
|
async fn process_ticket(
|
|
db: &Arc<Mutex<Connection>>,
|
|
app_handle: &AppHandle,
|
|
) -> Result<bool, String> {
|
|
// 1. Get next pending ticket + its tracker + project (under lock)
|
|
let (ticket, tracker, project) = {
|
|
let conn = db.lock().map_err(|e| format!("DB lock failed: {}", e))?;
|
|
|
|
let pending = ProcessedTicket::list_pending(&conn)
|
|
.map_err(|e| format!("list_pending failed: {}", e))?;
|
|
|
|
let ticket = match pending.into_iter().next() {
|
|
Some(t) => t,
|
|
None => return Ok(false), // No pending tickets
|
|
};
|
|
|
|
let tracker = WatchedTracker::get_by_id(&conn, &ticket.tracker_id)
|
|
.map_err(|e| format!("get tracker failed: {}", e))?;
|
|
|
|
let project = Project::get_by_id(&conn, &tracker.project_id)
|
|
.map_err(|e| format!("get project failed: {}", e))?;
|
|
|
|
// Mark as Analyzing before releasing lock
|
|
ProcessedTicket::update_status(&conn, &ticket.id, "Analyzing")
|
|
.map_err(|e| format!("update_status failed: {}", e))?;
|
|
|
|
(ticket, tracker, project)
|
|
}; // lock released
|
|
|
|
// Emit start event
|
|
let _ = app_handle.emit(
|
|
"ticket-processing-started",
|
|
serde_json::json!({
|
|
"ticket_id": ticket.id,
|
|
"step": "analyst",
|
|
}),
|
|
);
|
|
|
|
// 2. Run analyst
|
|
let analyst_prompt = build_analyst_prompt(&ticket, &project);
|
|
let analyst_result = run_cli_command(
|
|
&tracker.agent_config.analyst_command,
|
|
&tracker.agent_config.analyst_args,
|
|
&analyst_prompt,
|
|
&project.path,
|
|
600, // 10 minute timeout
|
|
app_handle,
|
|
&ticket.id,
|
|
)
|
|
.await;
|
|
|
|
let analyst_report = match analyst_result {
|
|
Ok(report) => report,
|
|
Err(e) => {
|
|
let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?;
|
|
let _ = ProcessedTicket::set_error(&conn, &ticket.id, &e);
|
|
let _ = app_handle.emit(
|
|
"ticket-processing-error",
|
|
serde_json::json!({ "ticket_id": ticket.id, "error": e }),
|
|
);
|
|
return Ok(true); // Processed (with error), continue to next
|
|
}
|
|
};
|
|
|
|
// 3. Store analyst report
|
|
{
|
|
let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?;
|
|
ProcessedTicket::set_analyst_report(&conn, &ticket.id, &analyst_report)
|
|
.map_err(|e| format!("set_analyst_report: {}", e))?;
|
|
}
|
|
|
|
// 4. Check verdict
|
|
let verdict = parse_verdict(&analyst_report);
|
|
if verdict == Verdict::NoFix {
|
|
let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?;
|
|
ProcessedTicket::update_status(&conn, &ticket.id, "Done")
|
|
.map_err(|e| format!("update_status: {}", e))?;
|
|
let _ = app_handle.emit(
|
|
"ticket-processing-done",
|
|
serde_json::json!({ "ticket_id": ticket.id }),
|
|
);
|
|
return Ok(true);
|
|
}
|
|
|
|
// 5. Check if ticket was cancelled while analyst was running
|
|
{
|
|
let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?;
|
|
let current = ProcessedTicket::get_by_id(&conn, &ticket.id)
|
|
.map_err(|e| format!("get_by_id: {}", e))?;
|
|
if current.status == "Cancelled" {
|
|
return Ok(true);
|
|
}
|
|
}
|
|
|
|
// 6. Create worktree
|
|
let (wt_path, branch_name) =
|
|
worktree_manager::create_worktree(&project.path, &project.base_branch, ticket.artifact_id)
|
|
.map_err(|e| {
|
|
let conn = db.lock().ok();
|
|
if let Some(conn) = conn {
|
|
let _ = ProcessedTicket::set_error(&conn, &ticket.id, &e);
|
|
}
|
|
e
|
|
})?;
|
|
|
|
// Store worktree info in DB
|
|
{
|
|
let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?;
|
|
ProcessedTicket::set_worktree_info(&conn, &ticket.id, &wt_path, &branch_name)
|
|
.map_err(|e| format!("set_worktree_info: {}", e))?;
|
|
Worktree::insert(&conn, &ticket.id, &wt_path, &branch_name)
|
|
.map_err(|e| format!("insert worktree: {}", e))?;
|
|
ProcessedTicket::update_status(&conn, &ticket.id, "Developing")
|
|
.map_err(|e| format!("update_status: {}", e))?;
|
|
}
|
|
|
|
// Emit developer start
|
|
let _ = app_handle.emit(
|
|
"ticket-processing-started",
|
|
serde_json::json!({
|
|
"ticket_id": ticket.id,
|
|
"step": "developer",
|
|
}),
|
|
);
|
|
|
|
// 7. Run developer
|
|
let developer_prompt =
|
|
build_developer_prompt(&ticket, &project, &analyst_report, &wt_path);
|
|
let developer_result = run_cli_command(
|
|
&tracker.agent_config.developer_command,
|
|
&tracker.agent_config.developer_args,
|
|
&developer_prompt,
|
|
&wt_path,
|
|
600,
|
|
app_handle,
|
|
&ticket.id,
|
|
)
|
|
.await;
|
|
|
|
let developer_report = match developer_result {
|
|
Ok(report) => report,
|
|
Err(e) => {
|
|
let conn = db.lock().map_err(|e2| format!("DB lock: {}", e2))?;
|
|
let _ = ProcessedTicket::set_error(&conn, &ticket.id, &e);
|
|
let _ = app_handle.emit(
|
|
"ticket-processing-error",
|
|
serde_json::json!({ "ticket_id": ticket.id, "error": e }),
|
|
);
|
|
return Ok(true);
|
|
}
|
|
};
|
|
|
|
// 8. Store developer report and mark done
|
|
{
|
|
let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?;
|
|
ProcessedTicket::set_developer_report(&conn, &ticket.id, &developer_report)
|
|
.map_err(|e| format!("set_developer_report: {}", e))?;
|
|
ProcessedTicket::update_status(&conn, &ticket.id, "Done")
|
|
.map_err(|e| format!("update_status: {}", e))?;
|
|
}
|
|
|
|
let _ = app_handle.emit(
|
|
"ticket-processing-done",
|
|
serde_json::json!({ "ticket_id": ticket.id }),
|
|
);
|
|
|
|
Ok(true) // Processed a ticket
|
|
}
|
|
|
|
/// Starts the orchestrator background task that consumes the ticket queue.
|
|
pub fn start(db: Arc<Mutex<Connection>>, app_handle: AppHandle) {
|
|
tokio::spawn(async move {
|
|
let mut tick = interval(Duration::from_secs(10));
|
|
loop {
|
|
tick.tick().await;
|
|
match process_ticket(&db, &app_handle).await {
|
|
Ok(true) => {
|
|
// Processed a ticket, immediately check for more
|
|
continue;
|
|
}
|
|
Ok(false) => {
|
|
// No pending tickets, wait for next tick
|
|
}
|
|
Err(e) => {
|
|
eprintln!("orchestrator: {}", e);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_build_analyst_prompt_contains_ticket_info() {
|
|
let ticket = ProcessedTicket {
|
|
id: "t1".into(),
|
|
tracker_id: "tr1".into(),
|
|
artifact_id: 42,
|
|
artifact_title: "Login crash on empty password".into(),
|
|
artifact_data: r#"{"id":42,"title":"Login crash"}"#.into(),
|
|
status: "Pending".into(),
|
|
analyst_report: None,
|
|
developer_report: None,
|
|
worktree_path: None,
|
|
branch_name: None,
|
|
detected_at: "2026-01-01T00:00:00Z".into(),
|
|
processed_at: None,
|
|
};
|
|
let project = Project {
|
|
id: "p1".into(),
|
|
name: "MyApp".into(),
|
|
path: "/home/user/myapp".into(),
|
|
cloned_from: None,
|
|
base_branch: "stable".into(),
|
|
created_at: "2026-01-01T00:00:00Z".into(),
|
|
};
|
|
|
|
let prompt = build_analyst_prompt(&ticket, &project);
|
|
assert!(prompt.contains("42"));
|
|
assert!(prompt.contains("Login crash on empty password"));
|
|
assert!(prompt.contains("MyApp"));
|
|
assert!(prompt.contains("/home/user/myapp"));
|
|
assert!(prompt.contains("stable"));
|
|
assert!(prompt.contains("[VERDICT: FIX_NEEDED]"));
|
|
assert!(prompt.contains("[VERDICT: NO_FIX]"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_developer_prompt_contains_report() {
|
|
let ticket = ProcessedTicket {
|
|
id: "t1".into(),
|
|
tracker_id: "tr1".into(),
|
|
artifact_id: 42,
|
|
artifact_title: "Login crash".into(),
|
|
artifact_data: "{}".into(),
|
|
status: "Developing".into(),
|
|
analyst_report: None,
|
|
developer_report: None,
|
|
worktree_path: None,
|
|
branch_name: None,
|
|
detected_at: "2026-01-01T00:00:00Z".into(),
|
|
processed_at: None,
|
|
};
|
|
let project = Project {
|
|
id: "p1".into(),
|
|
name: "MyApp".into(),
|
|
path: "/home/user/myapp".into(),
|
|
cloned_from: None,
|
|
base_branch: "main".into(),
|
|
created_at: "2026-01-01T00:00:00Z".into(),
|
|
};
|
|
|
|
let prompt = build_developer_prompt(&ticket, &project, "## Bug found in auth.rs", "/tmp/wt");
|
|
assert!(prompt.contains("## Bug found in auth.rs"));
|
|
assert!(prompt.contains("42"));
|
|
assert!(prompt.contains("/tmp/wt"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_verdict_fix_needed() {
|
|
let report = "## Analysis\nBug found.\n[VERDICT: FIX_NEEDED]\n";
|
|
assert_eq!(parse_verdict(report), Verdict::FixNeeded);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_verdict_no_fix() {
|
|
let report = "## Analysis\nThis is a feature request, not a bug.\n[VERDICT: NO_FIX]\n";
|
|
assert_eq!(parse_verdict(report), Verdict::NoFix);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_verdict_missing_defaults_to_fix() {
|
|
let report = "## Analysis\nSomething is wrong but I forgot the verdict.";
|
|
assert_eq!(parse_verdict(report), Verdict::FixNeeded);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_verdict_embedded_in_line() {
|
|
let report = "Verdict: [VERDICT: NO_FIX] - no code change needed.";
|
|
assert_eq!(parse_verdict(report), Verdict::NoFix);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run tests to verify they pass**
|
|
|
|
Run: `cd /home/leclere/Projets/orchai/src-tauri && cargo test --lib services::orchestrator::tests -- --nocapture`
|
|
Expected: all 6 orchestrator tests pass
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
cd /home/leclere/Projets/orchai
|
|
git add src-tauri/src/services/mod.rs src-tauri/src/services/orchestrator.rs
|
|
git commit -m "feat: add Agent Orchestrator with prompt building, verdict parsing, and CLI pipeline"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Tauri commands for orchestrator and worktree
|
|
|
|
**Files:**
|
|
- Create: `src-tauri/src/commands/orchestrator.rs`
|
|
- Create: `src-tauri/src/commands/worktree.rs`
|
|
- Modify: `src-tauri/src/commands/mod.rs`
|
|
|
|
- [ ] **Step 1: Add new command modules to commands/mod.rs**
|
|
|
|
In `src-tauri/src/commands/mod.rs`, add:
|
|
|
|
```rust
|
|
pub mod orchestrator;
|
|
pub mod worktree;
|
|
```
|
|
|
|
- [ ] **Step 2: Create commands/orchestrator.rs**
|
|
|
|
Create `src-tauri/src/commands/orchestrator.rs`:
|
|
|
|
```rust
|
|
use crate::error::AppError;
|
|
use crate::models::ticket::ProcessedTicket;
|
|
use crate::models::worktree::Worktree;
|
|
use crate::AppState;
|
|
use serde::Serialize;
|
|
use tauri::State;
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct TicketResult {
|
|
pub ticket: ProcessedTicket,
|
|
pub worktree: Option<Worktree>,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_ticket_result(
|
|
state: State<'_, AppState>,
|
|
ticket_id: String,
|
|
) -> Result<TicketResult, AppError> {
|
|
let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?;
|
|
let ticket = ProcessedTicket::get_by_id(&conn, &ticket_id)?;
|
|
let worktree = Worktree::get_by_ticket_id(&conn, &ticket_id)?;
|
|
Ok(TicketResult { ticket, worktree })
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn retry_ticket(
|
|
state: State<'_, AppState>,
|
|
ticket_id: String,
|
|
) -> Result<(), AppError> {
|
|
let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?;
|
|
let ticket = ProcessedTicket::get_by_id(&conn, &ticket_id)?;
|
|
|
|
// Only allow retry for Error or Done tickets
|
|
if ticket.status != "Error" && ticket.status != "Done" && ticket.status != "Cancelled" {
|
|
return Err(AppError::from(format!(
|
|
"Cannot retry ticket with status '{}'",
|
|
ticket.status
|
|
)));
|
|
}
|
|
|
|
// Reset to Pending
|
|
ProcessedTicket::update_status(&conn, &ticket_id, "Pending")?;
|
|
// Clear reports
|
|
conn.execute(
|
|
"UPDATE processed_tickets SET analyst_report = NULL, developer_report = NULL, \
|
|
worktree_path = NULL, branch_name = NULL, processed_at = NULL WHERE id = ?1",
|
|
rusqlite::params![ticket_id],
|
|
)?;
|
|
|
|
// Clean up worktree if exists
|
|
if let Some(wt) = Worktree::get_by_ticket_id(&conn, &ticket_id)? {
|
|
if wt.status == "Active" {
|
|
// Best effort: delete the worktree on disk
|
|
let project_id = {
|
|
let tracker = crate::models::tracker::WatchedTracker::get_by_id(&conn, &ticket.tracker_id)?;
|
|
tracker.project_id
|
|
};
|
|
let project = crate::models::project::Project::get_by_id(&conn, &project_id)?;
|
|
let _ = crate::services::worktree_manager::delete_worktree(
|
|
&project.path,
|
|
&wt.path,
|
|
&wt.branch_name,
|
|
);
|
|
}
|
|
Worktree::delete(&conn, &wt.id)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn cancel_ticket(
|
|
state: State<'_, AppState>,
|
|
ticket_id: String,
|
|
) -> Result<(), AppError> {
|
|
let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?;
|
|
let ticket = ProcessedTicket::get_by_id(&conn, &ticket_id)?;
|
|
|
|
if ticket.status == "Done" || ticket.status == "Cancelled" {
|
|
return Err(AppError::from(format!(
|
|
"Cannot cancel ticket with status '{}'",
|
|
ticket.status
|
|
)));
|
|
}
|
|
|
|
ProcessedTicket::update_status(&conn, &ticket_id, "Cancelled")?;
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create commands/worktree.rs**
|
|
|
|
Create `src-tauri/src/commands/worktree.rs`:
|
|
|
|
```rust
|
|
use crate::error::AppError;
|
|
use crate::models::project::Project;
|
|
use crate::models::ticket::ProcessedTicket;
|
|
use crate::models::tracker::WatchedTracker;
|
|
use crate::models::worktree::Worktree;
|
|
use crate::services::worktree_manager;
|
|
use crate::AppState;
|
|
use tauri::State;
|
|
|
|
#[tauri::command]
|
|
pub fn list_worktrees(
|
|
state: State<'_, AppState>,
|
|
project_id: String,
|
|
) -> Result<Vec<Worktree>, AppError> {
|
|
let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?;
|
|
let worktrees = Worktree::list_by_project(&conn, &project_id)?;
|
|
Ok(worktrees)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_worktree_diff(
|
|
state: State<'_, AppState>,
|
|
worktree_id: String,
|
|
) -> Result<String, AppError> {
|
|
let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?;
|
|
|
|
let wt = Worktree::get_by_id(&conn, &worktree_id)?;
|
|
let ticket = ProcessedTicket::get_by_id(&conn, &wt.ticket_id)?;
|
|
let tracker = WatchedTracker::get_by_id(&conn, &ticket.tracker_id)?;
|
|
let project = Project::get_by_id(&conn, &tracker.project_id)?;
|
|
|
|
drop(conn); // Release lock before git operations
|
|
|
|
let diff = worktree_manager::get_diff(&project.path, &project.base_branch, &wt.branch_name)
|
|
.map_err(AppError::from)?;
|
|
|
|
Ok(diff)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn apply_fix_to_branch(
|
|
state: State<'_, AppState>,
|
|
worktree_id: String,
|
|
target_branch: String,
|
|
) -> Result<(), AppError> {
|
|
let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?;
|
|
|
|
let wt = Worktree::get_by_id(&conn, &worktree_id)?;
|
|
let ticket = ProcessedTicket::get_by_id(&conn, &wt.ticket_id)?;
|
|
let tracker = WatchedTracker::get_by_id(&conn, &ticket.tracker_id)?;
|
|
let project = Project::get_by_id(&conn, &tracker.project_id)?;
|
|
|
|
drop(conn); // Release lock before git operations
|
|
|
|
worktree_manager::apply_fix(
|
|
&project.path,
|
|
&project.base_branch,
|
|
&wt.branch_name,
|
|
&target_branch,
|
|
)
|
|
.map_err(AppError::from)?;
|
|
|
|
// Mark worktree as merged
|
|
let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?;
|
|
Worktree::set_merged(&conn, &worktree_id, &target_branch)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn delete_worktree_cmd(
|
|
state: State<'_, AppState>,
|
|
worktree_id: String,
|
|
) -> Result<(), AppError> {
|
|
let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?;
|
|
|
|
let wt = Worktree::get_by_id(&conn, &worktree_id)?;
|
|
let ticket = ProcessedTicket::get_by_id(&conn, &wt.ticket_id)?;
|
|
let tracker = WatchedTracker::get_by_id(&conn, &ticket.tracker_id)?;
|
|
let project = Project::get_by_id(&conn, &tracker.project_id)?;
|
|
|
|
drop(conn); // Release lock before git operations
|
|
|
|
worktree_manager::delete_worktree(&project.path, &wt.path, &wt.branch_name)
|
|
.map_err(AppError::from)?;
|
|
|
|
let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?;
|
|
Worktree::delete(&conn, &worktree_id)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn list_local_branches(
|
|
state: State<'_, AppState>,
|
|
project_id: String,
|
|
) -> Result<Vec<String>, AppError> {
|
|
let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?;
|
|
let project = Project::get_by_id(&conn, &project_id)?;
|
|
|
|
drop(conn);
|
|
|
|
let branches =
|
|
worktree_manager::list_local_branches(&project.path).map_err(AppError::from)?;
|
|
Ok(branches)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Verify compilation**
|
|
|
|
Run: `cd /home/leclere/Projets/orchai/src-tauri && cargo check 2>&1 | tail -10`
|
|
Expected: compiles without errors
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
cd /home/leclere/Projets/orchai
|
|
git add src-tauri/src/commands/mod.rs src-tauri/src/commands/orchestrator.rs src-tauri/src/commands/worktree.rs
|
|
git commit -m "feat: add Tauri commands for orchestrator (retry, cancel, result) and worktree (diff, apply, delete, branches)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Wire orchestrator into app startup and register commands
|
|
|
|
**Files:**
|
|
- Modify: `src-tauri/src/lib.rs`
|
|
|
|
- [ ] **Step 1: Start orchestrator in setup and register new commands**
|
|
|
|
In `src-tauri/src/lib.rs`, modify the `setup` closure to start the orchestrator after the poller, and add all new commands to the invoke_handler.
|
|
|
|
The setup block should clone db_arc a second time for the orchestrator:
|
|
|
|
```rust
|
|
// Start background poller
|
|
services::poller::start(
|
|
db_arc.clone(),
|
|
encryption_key,
|
|
http_client,
|
|
app.handle().clone(),
|
|
);
|
|
|
|
// Start agent orchestrator
|
|
services::orchestrator::start(
|
|
db_arc,
|
|
app.handle().clone(),
|
|
);
|
|
```
|
|
|
|
Add the new commands to `invoke_handler`:
|
|
|
|
```rust
|
|
.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,
|
|
commands::orchestrator::get_ticket_result,
|
|
commands::orchestrator::retry_ticket,
|
|
commands::orchestrator::cancel_ticket,
|
|
commands::worktree::list_worktrees,
|
|
commands::worktree::get_worktree_diff,
|
|
commands::worktree::apply_fix_to_branch,
|
|
commands::worktree::delete_worktree_cmd,
|
|
commands::worktree::list_local_branches,
|
|
])
|
|
```
|
|
|
|
- [ ] **Step 2: Verify full build compiles**
|
|
|
|
Run: `cd /home/leclere/Projets/orchai/src-tauri && cargo check 2>&1 | tail -10`
|
|
Expected: compiles without errors
|
|
|
|
- [ ] **Step 3: Run all tests**
|
|
|
|
Run: `cd /home/leclere/Projets/orchai/src-tauri && cargo test 2>&1 | tail -20`
|
|
Expected: all tests pass (existing + new)
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
cd /home/leclere/Projets/orchai
|
|
git add src-tauri/src/lib.rs
|
|
git commit -m "feat: wire orchestrator startup and register Phase 3 Tauri commands"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Frontend dependencies, types, and API wrappers
|
|
|
|
**Files:**
|
|
- Modify: `src/lib/types.ts`
|
|
- Modify: `src/lib/api.ts`
|
|
- Modify: `package.json` (via npm install)
|
|
|
|
- [ ] **Step 1: Install frontend dependencies**
|
|
|
|
```bash
|
|
cd /home/leclere/Projets/orchai && npm install react-markdown remark-gfm
|
|
```
|
|
|
|
- [ ] **Step 2: Add Worktree and TicketResult types to types.ts**
|
|
|
|
Append to `src/lib/types.ts`:
|
|
|
|
```typescript
|
|
export interface Worktree {
|
|
id: string;
|
|
ticket_id: string;
|
|
path: string;
|
|
branch_name: string;
|
|
status: string;
|
|
created_at: string;
|
|
merged_at: string | null;
|
|
merged_into: string | null;
|
|
}
|
|
|
|
export interface TicketResult {
|
|
ticket: ProcessedTicket;
|
|
worktree: Worktree | null;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Add API wrappers to api.ts**
|
|
|
|
Add the import for the new types in `src/lib/api.ts`:
|
|
|
|
```typescript
|
|
import type {
|
|
Project,
|
|
TuleapCredentialsSafe,
|
|
AgentConfig,
|
|
FilterGroup,
|
|
WatchedTracker,
|
|
TrackerField,
|
|
ProcessedTicket,
|
|
Worktree,
|
|
TicketResult,
|
|
} from "./types";
|
|
```
|
|
|
|
Append these functions to `src/lib/api.ts`:
|
|
|
|
```typescript
|
|
// Orchestrator
|
|
export async function getTicketResult(ticketId: string): Promise<TicketResult> {
|
|
return invoke("get_ticket_result", { ticketId });
|
|
}
|
|
export async function retryTicket(ticketId: string): Promise<void> {
|
|
return invoke("retry_ticket", { ticketId });
|
|
}
|
|
export async function cancelTicket(ticketId: string): Promise<void> {
|
|
return invoke("cancel_ticket", { ticketId });
|
|
}
|
|
|
|
// Worktrees
|
|
export async function listWorktrees(projectId: string): Promise<Worktree[]> {
|
|
return invoke("list_worktrees", { projectId });
|
|
}
|
|
export async function getWorktreeDiff(worktreeId: string): Promise<string> {
|
|
return invoke("get_worktree_diff", { worktreeId });
|
|
}
|
|
export async function applyFixToBranch(worktreeId: string, targetBranch: string): Promise<void> {
|
|
return invoke("apply_fix_to_branch", { worktreeId, targetBranch });
|
|
}
|
|
export async function deleteWorktreeCmd(worktreeId: string): Promise<void> {
|
|
return invoke("delete_worktree_cmd", { worktreeId });
|
|
}
|
|
export async function listLocalBranches(projectId: string): Promise<string[]> {
|
|
return invoke("list_local_branches", { projectId });
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Verify TypeScript compiles**
|
|
|
|
Run: `cd /home/leclere/Projets/orchai && npx tsc --noEmit 2>&1 | tail -10`
|
|
Expected: no errors
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
cd /home/leclere/Projets/orchai
|
|
git add package.json package-lock.json src/lib/types.ts src/lib/api.ts
|
|
git commit -m "feat: add frontend types, API wrappers, and markdown deps for Phase 3"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Ticket list page
|
|
|
|
**Files:**
|
|
- Create: `src/components/tickets/TicketList.tsx`
|
|
- Modify: `src/components/projects/ProjectDashboard.tsx`
|
|
- Modify: `src/App.tsx`
|
|
|
|
- [ ] **Step 1: Create TicketList component**
|
|
|
|
Create `src/components/tickets/TicketList.tsx`:
|
|
|
|
```tsx
|
|
import { useEffect, useState } from "react";
|
|
import { useParams, Link } from "react-router-dom";
|
|
import { listProcessedTickets, getProject } from "../../lib/api";
|
|
import type { ProcessedTicket, Project } from "../../lib/types";
|
|
|
|
function statusBadgeClass(status: string): string {
|
|
switch (status) {
|
|
case "Pending":
|
|
return "bg-yellow-100 text-yellow-700";
|
|
case "Analyzing":
|
|
return "bg-blue-100 text-blue-700";
|
|
case "Developing":
|
|
return "bg-purple-100 text-purple-700";
|
|
case "Done":
|
|
return "bg-green-100 text-green-700";
|
|
case "Error":
|
|
return "bg-red-100 text-red-700";
|
|
case "Cancelled":
|
|
return "bg-gray-100 text-gray-500";
|
|
default:
|
|
return "bg-gray-100 text-gray-700";
|
|
}
|
|
}
|
|
|
|
export default function TicketList() {
|
|
const { projectId } = useParams();
|
|
const [project, setProject] = useState<Project | null>(null);
|
|
const [tickets, setTickets] = useState<ProcessedTicket[]>([]);
|
|
const [filter, setFilter] = useState<string>("all");
|
|
|
|
useEffect(() => {
|
|
if (!projectId) return;
|
|
Promise.all([getProject(projectId), listProcessedTickets(projectId)]).then(
|
|
([proj, tkts]) => {
|
|
setProject(proj);
|
|
setTickets(tkts);
|
|
}
|
|
);
|
|
}, [projectId]);
|
|
|
|
const filtered =
|
|
filter === "all" ? tickets : tickets.filter((t) => t.status === filter);
|
|
|
|
return (
|
|
<div className="p-8">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<Link
|
|
to={`/projects/${projectId}`}
|
|
className="text-sm text-blue-600 hover:underline"
|
|
>
|
|
{project?.name}
|
|
</Link>
|
|
<h2 className="text-xl font-bold">Processed Tickets</h2>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2 mb-4">
|
|
{["all", "Pending", "Analyzing", "Developing", "Done", "Error"].map(
|
|
(s) => (
|
|
<button
|
|
key={s}
|
|
onClick={() => setFilter(s)}
|
|
className={`px-3 py-1 rounded text-sm ${
|
|
filter === s
|
|
? "bg-gray-900 text-white"
|
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
|
}`}
|
|
>
|
|
{s === "all" ? "All" : s}
|
|
{s !== "all" && (
|
|
<span className="ml-1 text-xs opacity-60">
|
|
({tickets.filter((t) => t.status === s).length})
|
|
</span>
|
|
)}
|
|
</button>
|
|
)
|
|
)}
|
|
</div>
|
|
|
|
{filtered.length === 0 ? (
|
|
<div className="text-sm text-gray-400 py-8 text-center">
|
|
No tickets found.
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{filtered.map((ticket) => (
|
|
<Link
|
|
key={ticket.id}
|
|
to={`/tickets/${ticket.id}`}
|
|
className="block bg-white rounded-lg border border-gray-200 p-4 hover:border-blue-300 transition-colors"
|
|
>
|
|
<div className="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 className="text-xs text-gray-400 mt-1">
|
|
{new Date(ticket.detected_at).toLocaleString()}
|
|
{ticket.processed_at && (
|
|
<span className="ml-2">
|
|
Processed: {new Date(ticket.processed_at).toLocaleString()}
|
|
</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>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Make dashboard ticket items link to detail view**
|
|
|
|
In `src/components/projects/ProjectDashboard.tsx`, wrap each ticket in the "Recent Tickets" section with a `Link`. Replace the ticket `<div>` with:
|
|
|
|
Change the ticket rendering from a plain `<div>` to a `<Link>`:
|
|
|
|
```tsx
|
|
<Link
|
|
key={ticket.id}
|
|
to={`/tickets/${ticket.id}`}
|
|
className="bg-white rounded-lg border border-gray-200 p-4 flex items-center justify-between gap-4 hover:border-blue-300 transition-colors"
|
|
>
|
|
```
|
|
|
|
Close with `</Link>` instead of `</div>`.
|
|
|
|
Also add a "View all tickets" link after the recent tickets list, and a `Link` to the ticket list page in the section header:
|
|
|
|
Replace the "Recent Tickets" header with:
|
|
|
|
```tsx
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-lg font-semibold">Recent Tickets</h3>
|
|
{tickets.length > 0 && (
|
|
<Link
|
|
to={`/projects/${project.id}/tickets`}
|
|
className="text-sm text-blue-600 hover:underline"
|
|
>
|
|
View all ({tickets.length})
|
|
</Link>
|
|
)}
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Step 3: Add routes to App.tsx**
|
|
|
|
In `src/App.tsx`, add imports:
|
|
|
|
```tsx
|
|
import TicketList from "./components/tickets/TicketList";
|
|
```
|
|
|
|
Add routes inside the `<Route element={<AppLayout />}>` block:
|
|
|
|
```tsx
|
|
<Route path="/projects/:projectId/tickets" element={<TicketList />} />
|
|
```
|
|
|
|
- [ ] **Step 4: Verify TypeScript compiles**
|
|
|
|
Run: `cd /home/leclere/Projets/orchai && npx tsc --noEmit 2>&1 | tail -10`
|
|
Expected: no errors
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
cd /home/leclere/Projets/orchai
|
|
git add src/components/tickets/TicketList.tsx src/components/projects/ProjectDashboard.tsx src/App.tsx
|
|
git commit -m "feat: add ticket list page with status filtering and dashboard links"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Ticket detail page with markdown reports, diff viewer, and actions
|
|
|
|
**Files:**
|
|
- Create: `src/components/tickets/TicketDetail.tsx`
|
|
- Modify: `src/App.tsx`
|
|
|
|
- [ ] **Step 1: Create TicketDetail component**
|
|
|
|
Create `src/components/tickets/TicketDetail.tsx`:
|
|
|
|
```tsx
|
|
import { useEffect, useState } from "react";
|
|
import { useParams, useNavigate } from "react-router-dom";
|
|
import Markdown from "react-markdown";
|
|
import remarkGfm from "remark-gfm";
|
|
import {
|
|
getTicketResult,
|
|
retryTicket,
|
|
cancelTicket,
|
|
getWorktreeDiff,
|
|
applyFixToBranch,
|
|
deleteWorktreeCmd,
|
|
listLocalBranches,
|
|
} from "../../lib/api";
|
|
import type { ProcessedTicket, Worktree } from "../../lib/types";
|
|
|
|
function statusBadgeClass(status: string): string {
|
|
switch (status) {
|
|
case "Pending":
|
|
return "bg-yellow-100 text-yellow-700";
|
|
case "Analyzing":
|
|
return "bg-blue-100 text-blue-700";
|
|
case "Developing":
|
|
return "bg-purple-100 text-purple-700";
|
|
case "Done":
|
|
return "bg-green-100 text-green-700";
|
|
case "Error":
|
|
return "bg-red-100 text-red-700";
|
|
case "Cancelled":
|
|
return "bg-gray-100 text-gray-500";
|
|
default:
|
|
return "bg-gray-100 text-gray-700";
|
|
}
|
|
}
|
|
|
|
function DiffViewer({ diff }: { diff: string }) {
|
|
if (!diff) {
|
|
return (
|
|
<div className="text-sm text-gray-400 py-4 text-center">
|
|
No changes detected.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const lines = diff.split("\n");
|
|
return (
|
|
<pre className="text-xs font-mono bg-gray-900 text-gray-100 p-4 rounded-lg overflow-auto max-h-[600px]">
|
|
{lines.map((line, i) => {
|
|
let cls = "";
|
|
if (line.startsWith("+++") || line.startsWith("---"))
|
|
cls = "text-gray-400";
|
|
else if (line.startsWith("+")) cls = "text-green-400 bg-green-900/20";
|
|
else if (line.startsWith("-")) cls = "text-red-400 bg-red-900/20";
|
|
else if (line.startsWith("@@")) cls = "text-blue-400";
|
|
else if (line.startsWith("diff ")) cls = "text-yellow-400 font-bold";
|
|
return (
|
|
<div key={i} className={cls}>
|
|
{line}
|
|
</div>
|
|
);
|
|
})}
|
|
</pre>
|
|
);
|
|
}
|
|
|
|
export default function TicketDetail() {
|
|
const { ticketId } = useParams();
|
|
const navigate = useNavigate();
|
|
const [ticket, setTicket] = useState<ProcessedTicket | null>(null);
|
|
const [worktree, setWorktree] = useState<Worktree | null>(null);
|
|
const [diff, setDiff] = useState<string | null>(null);
|
|
const [branches, setBranches] = useState<string[]>([]);
|
|
const [targetBranch, setTargetBranch] = useState("");
|
|
const [tab, setTab] = useState<"info" | "analyst" | "developer" | "diff">(
|
|
"info"
|
|
);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState("");
|
|
|
|
async function loadData() {
|
|
if (!ticketId) return;
|
|
try {
|
|
const result = await getTicketResult(ticketId);
|
|
setTicket(result.ticket);
|
|
setWorktree(result.worktree);
|
|
|
|
// Auto-select the most relevant tab
|
|
if (result.ticket.developer_report) setTab("developer");
|
|
else if (result.ticket.analyst_report) setTab("analyst");
|
|
|
|
// Load diff if worktree exists
|
|
if (result.worktree && result.worktree.status === "Active") {
|
|
try {
|
|
const d = await getWorktreeDiff(result.worktree.id);
|
|
setDiff(d);
|
|
} catch {
|
|
setDiff(null);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [ticketId]);
|
|
|
|
async function handleRetry() {
|
|
if (!ticketId) return;
|
|
setLoading(true);
|
|
try {
|
|
await retryTicket(ticketId);
|
|
await loadData();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
setLoading(false);
|
|
}
|
|
|
|
async function handleCancel() {
|
|
if (!ticketId) return;
|
|
setLoading(true);
|
|
try {
|
|
await cancelTicket(ticketId);
|
|
await loadData();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
setLoading(false);
|
|
}
|
|
|
|
async function handleApplyFix() {
|
|
if (!worktree || !targetBranch) return;
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
await applyFixToBranch(worktree.id, targetBranch);
|
|
await loadData();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
setLoading(false);
|
|
}
|
|
|
|
async function handleDeleteWorktree() {
|
|
if (!worktree) return;
|
|
if (!window.confirm("Delete this worktree and its branch?")) return;
|
|
setLoading(true);
|
|
try {
|
|
await deleteWorktreeCmd(worktree.id);
|
|
setWorktree(null);
|
|
setDiff(null);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
setLoading(false);
|
|
}
|
|
|
|
async function loadBranches() {
|
|
if (!ticket) return;
|
|
try {
|
|
// We need the project ID -- navigate through the ticket data
|
|
// The tracker_id on the ticket links to the watched tracker
|
|
// For simplicity, parse project_id from the URL if available, or use a dedicated call
|
|
// Since we already have the worktree, we can get branches through the ticket result
|
|
const result = await getTicketResult(ticket.id);
|
|
// Get project_id through the ticket -> tracker chain is done server-side
|
|
// For branches, we need the project_id. Let's add it to the load flow.
|
|
// Actually, we'll use a simple approach: the branches list is loaded on demand
|
|
// when the user opens the "Apply fix" section
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
if (!ticket) {
|
|
return <div className="p-8 text-gray-400">Loading...</div>;
|
|
}
|
|
|
|
const tabs = [
|
|
{ key: "info" as const, label: "Info" },
|
|
{
|
|
key: "analyst" as const,
|
|
label: "Analyst Report",
|
|
disabled: !ticket.analyst_report,
|
|
},
|
|
{
|
|
key: "developer" as const,
|
|
label: "Developer Report",
|
|
disabled: !ticket.developer_report,
|
|
},
|
|
{ key: "diff" as const, label: "Diff", disabled: !diff && !worktree },
|
|
];
|
|
|
|
return (
|
|
<div className="p-8">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<button
|
|
onClick={() => navigate(-1)}
|
|
className="text-sm text-blue-600 hover:underline mb-1"
|
|
>
|
|
Back
|
|
</button>
|
|
<h2 className="text-xl font-bold flex items-center gap-3">
|
|
<span className="text-gray-400 font-mono text-base">
|
|
#{ticket.artifact_id}
|
|
</span>
|
|
{ticket.artifact_title}
|
|
<span
|
|
className={`text-xs px-2 py-0.5 rounded-full font-medium ${statusBadgeClass(
|
|
ticket.status
|
|
)}`}
|
|
>
|
|
{ticket.status}
|
|
</span>
|
|
</h2>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{(ticket.status === "Error" ||
|
|
ticket.status === "Done" ||
|
|
ticket.status === "Cancelled") && (
|
|
<button
|
|
onClick={handleRetry}
|
|
disabled={loading}
|
|
className="px-3 py-1 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50"
|
|
>
|
|
Retry
|
|
</button>
|
|
)}
|
|
{(ticket.status === "Pending" ||
|
|
ticket.status === "Analyzing" ||
|
|
ticket.status === "Developing") && (
|
|
<button
|
|
onClick={handleCancel}
|
|
disabled={loading}
|
|
className="px-3 py-1 bg-red-100 text-red-700 rounded text-sm hover:bg-red-200 disabled:opacity-50"
|
|
>
|
|
Cancel
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="bg-red-50 border border-red-200 text-red-700 text-sm p-3 rounded mb-4">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Tabs */}
|
|
<div className="flex gap-1 border-b border-gray-200 mb-6">
|
|
{tabs.map((t) => (
|
|
<button
|
|
key={t.key}
|
|
onClick={() => setTab(t.key)}
|
|
disabled={t.disabled}
|
|
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
|
tab === t.key
|
|
? "border-blue-600 text-blue-600"
|
|
: t.disabled
|
|
? "border-transparent text-gray-300 cursor-not-allowed"
|
|
: "border-transparent text-gray-500 hover:text-gray-700"
|
|
}`}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Tab content */}
|
|
{tab === "info" && (
|
|
<div className="space-y-4">
|
|
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
|
|
<div>
|
|
<span className="text-sm text-gray-500">Status:</span>
|
|
<span className="ml-2 text-sm">{ticket.status}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-sm text-gray-500">Detected:</span>
|
|
<span className="ml-2 text-sm">
|
|
{new Date(ticket.detected_at).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
{ticket.processed_at && (
|
|
<div>
|
|
<span className="text-sm text-gray-500">Processed:</span>
|
|
<span className="ml-2 text-sm">
|
|
{new Date(ticket.processed_at).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{worktree && (
|
|
<div>
|
|
<span className="text-sm text-gray-500">Worktree:</span>
|
|
<span className="ml-2 text-sm font-mono">
|
|
{worktree.branch_name}
|
|
</span>
|
|
<span
|
|
className={`ml-2 text-xs px-2 py-0.5 rounded-full ${
|
|
worktree.status === "Active"
|
|
? "bg-green-100 text-green-700"
|
|
: worktree.status === "Merged"
|
|
? "bg-blue-100 text-blue-700"
|
|
: "bg-gray-100 text-gray-500"
|
|
}`}
|
|
>
|
|
{worktree.status}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Worktree actions */}
|
|
{worktree && worktree.status === "Active" && (
|
|
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
<h3 className="text-sm font-semibold mb-3">Worktree Actions</h3>
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<input
|
|
type="text"
|
|
placeholder="Target branch (e.g. feature/login)"
|
|
value={targetBranch}
|
|
onChange={(e) => setTargetBranch(e.target.value)}
|
|
className="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
/>
|
|
<button
|
|
onClick={handleApplyFix}
|
|
disabled={loading || !targetBranch}
|
|
className="px-3 py-1.5 bg-green-600 text-white rounded text-sm hover:bg-green-700 disabled:opacity-50"
|
|
>
|
|
Apply fix
|
|
</button>
|
|
</div>
|
|
<button
|
|
onClick={handleDeleteWorktree}
|
|
disabled={loading}
|
|
className="text-sm text-red-600 hover:underline"
|
|
>
|
|
Delete worktree
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{worktree && worktree.status === "Merged" && (
|
|
<div className="bg-blue-50 border border-blue-200 text-blue-700 text-sm p-3 rounded">
|
|
Fix applied to branch: {worktree.merged_into}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{tab === "analyst" && ticket.analyst_report && (
|
|
<div className="bg-white rounded-lg border border-gray-200 p-6 prose prose-sm max-w-none">
|
|
<Markdown remarkPlugins={[remarkGfm]}>
|
|
{ticket.analyst_report}
|
|
</Markdown>
|
|
</div>
|
|
)}
|
|
|
|
{tab === "developer" && ticket.developer_report && (
|
|
<div className="bg-white rounded-lg border border-gray-200 p-6 prose prose-sm max-w-none">
|
|
<Markdown remarkPlugins={[remarkGfm]}>
|
|
{ticket.developer_report}
|
|
</Markdown>
|
|
</div>
|
|
)}
|
|
|
|
{tab === "diff" && <DiffViewer diff={diff || ""} />}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add ticket detail route to App.tsx**
|
|
|
|
In `src/App.tsx`, add the import:
|
|
|
|
```tsx
|
|
import TicketDetail from "./components/tickets/TicketDetail";
|
|
```
|
|
|
|
Add the route inside `<Route element={<AppLayout />}>`:
|
|
|
|
```tsx
|
|
<Route path="/tickets/:ticketId" element={<TicketDetail />} />
|
|
```
|
|
|
|
- [ ] **Step 3: Verify TypeScript compiles**
|
|
|
|
Run: `cd /home/leclere/Projets/orchai && npx tsc --noEmit 2>&1 | tail -10`
|
|
Expected: no errors
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
cd /home/leclere/Projets/orchai
|
|
git add src/components/tickets/TicketDetail.tsx src/App.tsx
|
|
git commit -m "feat: add ticket detail page with markdown reports, diff viewer, and worktree actions"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Final verification
|
|
|
|
- [ ] **Step 1: Run all backend tests**
|
|
|
|
Run: `cd /home/leclere/Projets/orchai/src-tauri && cargo test 2>&1 | tail -30`
|
|
Expected: all tests pass, including new tests from Tasks 1-4
|
|
|
|
- [ ] **Step 2: Run clippy**
|
|
|
|
Run: `cd /home/leclere/Projets/orchai/src-tauri && cargo clippy -- -D warnings 2>&1 | tail -20`
|
|
Expected: no warnings
|
|
|
|
- [ ] **Step 3: Verify frontend builds**
|
|
|
|
Run: `cd /home/leclere/Projets/orchai && npx tsc --noEmit && echo "OK"`
|
|
Expected: "OK"
|
|
|
|
- [ ] **Step 4: Commit any fixes needed from verification**
|
|
|
|
If clippy or tsc produced warnings/errors, fix them and commit:
|
|
|
|
```bash
|
|
cd /home/leclere/Projets/orchai
|
|
git add -u
|
|
git commit -m "fix: resolve clippy warnings and TypeScript errors from Phase 3"
|
|
```
|