feat(core): Complete Phase 1 - Foundation porting from coherence-mcp
Port core functionality from coherence-mcp to blue-core: - store.rs: SQLite persistence with schema v1, WAL mode, FTS5 search - documents, document_links, tasks, worktrees, metadata tables - Blue's voice in all error messages - documents.rs: Enhanced with markdown generation - Rfc, Spike, Adr, Decision types with to_markdown() methods - Blue's signature at the end of generated docs - state.rs: ProjectState aggregation - active_items(), ready_items(), stalled_items(), draft_items() - generate_hint() for contextual recommendations - status_summary() for complete overview - repo.rs: Git detection and worktree operations - detect_blue() finds .blue/ directory structure - WorktreeInfo with rfc_title() extraction - create_worktree(), remove_worktree(), is_branch_merged() - workflow.rs: Status transitions - RfcStatus, SpikeOutcome, SpikeStatus, PrdStatus enums - Transition validation with helpful error messages MCP server updated with 9 tools: - blue_status, blue_next, blue_rfc_create, blue_rfc_get - blue_rfc_update_status, blue_rfc_plan, blue_rfc_validate - blue_rfc_task_complete, blue_search 14 unit tests passing. RFC 0002 tracks remaining phases. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
940701d191
commit
3e157d76a6
10 changed files with 2864 additions and 24 deletions
|
|
@ -32,6 +32,15 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|||
# CLI
|
||||
clap = { version = "4.0", features = ["derive"] }
|
||||
|
||||
# Database
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
|
||||
# Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# Git
|
||||
git2 = "0.19"
|
||||
|
||||
# Internal
|
||||
blue-core = { path = "crates/blue-core" }
|
||||
blue-mcp = { path = "crates/blue-mcp" }
|
||||
|
|
|
|||
|
|
@ -12,3 +12,6 @@ thiserror.workspace = true
|
|||
anyhow.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
rusqlite.workspace = true
|
||||
chrono.workspace = true
|
||||
git2.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Document types for Blue
|
||||
//!
|
||||
//! RFCs, ADRs, Spikes, and other document structures.
|
||||
//! RFCs, ADRs, Spikes, and other document structures with markdown generation.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
|
@ -15,14 +15,30 @@ pub enum Status {
|
|||
Superseded,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Status::Draft => "draft",
|
||||
Status::Accepted => "accepted",
|
||||
Status::InProgress => "in-progress",
|
||||
Status::Implemented => "implemented",
|
||||
Status::Superseded => "superseded",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An RFC (Request for Comments) - a design document
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Rfc {
|
||||
pub title: String,
|
||||
pub status: Status,
|
||||
pub date: Option<String>,
|
||||
pub source_spike: Option<String>,
|
||||
pub source_prd: Option<String>,
|
||||
pub problem: Option<String>,
|
||||
pub proposal: Option<String>,
|
||||
pub goals: Vec<String>,
|
||||
pub non_goals: Vec<String>,
|
||||
pub plan: Vec<Task>,
|
||||
}
|
||||
|
||||
|
|
@ -37,10 +53,13 @@ pub struct Task {
|
|||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Spike {
|
||||
pub title: String,
|
||||
pub question: String,
|
||||
pub status: String,
|
||||
pub date: String,
|
||||
pub time_box: Option<String>,
|
||||
pub question: String,
|
||||
pub outcome: Option<SpikeOutcome>,
|
||||
pub summary: Option<String>,
|
||||
pub findings: Option<String>,
|
||||
pub recommendation: Option<String>,
|
||||
}
|
||||
|
||||
/// Outcome of a spike investigation
|
||||
|
|
@ -52,10 +71,21 @@ pub enum SpikeOutcome {
|
|||
RecommendsImplementation,
|
||||
}
|
||||
|
||||
impl SpikeOutcome {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
SpikeOutcome::NoAction => "no-action",
|
||||
SpikeOutcome::DecisionMade => "decision-made",
|
||||
SpikeOutcome::RecommendsImplementation => "recommends-implementation",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Decision Note - lightweight choice documentation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Decision {
|
||||
pub title: String,
|
||||
pub date: String,
|
||||
pub decision: String,
|
||||
pub rationale: Option<String>,
|
||||
pub alternatives: Vec<String>,
|
||||
|
|
@ -65,6 +95,9 @@ pub struct Decision {
|
|||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Adr {
|
||||
pub title: String,
|
||||
pub status: String,
|
||||
pub date: String,
|
||||
pub source_rfc: Option<String>,
|
||||
pub context: String,
|
||||
pub decision: String,
|
||||
pub consequences: Vec<String>,
|
||||
|
|
@ -76,9 +109,13 @@ impl Rfc {
|
|||
Self {
|
||||
title: title.into(),
|
||||
status: Status::Draft,
|
||||
date: Some(today()),
|
||||
source_spike: None,
|
||||
source_prd: None,
|
||||
problem: None,
|
||||
proposal: None,
|
||||
goals: Vec::new(),
|
||||
non_goals: Vec::new(),
|
||||
plan: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
|
@ -91,6 +128,78 @@ impl Rfc {
|
|||
let completed = self.plan.iter().filter(|t| t.completed).count();
|
||||
(completed as f64 / self.plan.len() as f64) * 100.0
|
||||
}
|
||||
|
||||
/// Generate markdown content
|
||||
pub fn to_markdown(&self, number: u32) -> String {
|
||||
let mut md = String::new();
|
||||
|
||||
// Title
|
||||
md.push_str(&format!(
|
||||
"# RFC {:04}: {}\n\n",
|
||||
number,
|
||||
to_title_case(&self.title)
|
||||
));
|
||||
|
||||
// Metadata table
|
||||
md.push_str("| | |\n|---|---|\n");
|
||||
md.push_str(&format!(
|
||||
"| **Status** | {} |\n",
|
||||
to_title_case(self.status.as_str())
|
||||
));
|
||||
if let Some(ref date) = self.date {
|
||||
md.push_str(&format!("| **Date** | {} |\n", date));
|
||||
}
|
||||
if let Some(ref spike) = self.source_spike {
|
||||
md.push_str(&format!("| **Source Spike** | {} |\n", spike));
|
||||
}
|
||||
if let Some(ref prd) = self.source_prd {
|
||||
md.push_str(&format!("| **Source PRD** | {} |\n", prd));
|
||||
}
|
||||
md.push_str("\n---\n\n");
|
||||
|
||||
// Summary (problem)
|
||||
if let Some(ref problem) = self.problem {
|
||||
md.push_str("## Summary\n\n");
|
||||
md.push_str(problem);
|
||||
md.push_str("\n\n");
|
||||
}
|
||||
|
||||
// Proposal
|
||||
if let Some(ref proposal) = self.proposal {
|
||||
md.push_str("## Proposal\n\n");
|
||||
md.push_str(proposal);
|
||||
md.push_str("\n\n");
|
||||
}
|
||||
|
||||
// Goals
|
||||
if !self.goals.is_empty() {
|
||||
md.push_str("## Goals\n\n");
|
||||
for goal in &self.goals {
|
||||
md.push_str(&format!("- {}\n", goal));
|
||||
}
|
||||
md.push('\n');
|
||||
}
|
||||
|
||||
// Non-Goals
|
||||
if !self.non_goals.is_empty() {
|
||||
md.push_str("## Non-Goals\n\n");
|
||||
for ng in &self.non_goals {
|
||||
md.push_str(&format!("- {}\n", ng));
|
||||
}
|
||||
md.push('\n');
|
||||
}
|
||||
|
||||
// Test Plan (empty checkboxes)
|
||||
md.push_str("## Test Plan\n\n");
|
||||
md.push_str("- [ ] TBD\n\n");
|
||||
|
||||
// Blue's signature
|
||||
md.push_str("---\n\n");
|
||||
md.push_str("*\"Right then. Let's get to it.\"*\n\n");
|
||||
md.push_str("— Blue\n");
|
||||
|
||||
md
|
||||
}
|
||||
}
|
||||
|
||||
impl Spike {
|
||||
|
|
@ -98,10 +207,208 @@ impl Spike {
|
|||
pub fn new(title: impl Into<String>, question: impl Into<String>) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
question: question.into(),
|
||||
status: "in-progress".to_string(),
|
||||
date: today(),
|
||||
time_box: None,
|
||||
question: question.into(),
|
||||
outcome: None,
|
||||
summary: None,
|
||||
findings: None,
|
||||
recommendation: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate markdown content
|
||||
pub fn to_markdown(&self) -> String {
|
||||
let mut md = String::new();
|
||||
|
||||
md.push_str(&format!("# Spike: {}\n\n", to_title_case(&self.title)));
|
||||
|
||||
md.push_str("| | |\n|---|---|\n");
|
||||
md.push_str(&format!(
|
||||
"| **Status** | {} |\n",
|
||||
to_title_case(&self.status)
|
||||
));
|
||||
md.push_str(&format!("| **Date** | {} |\n", self.date));
|
||||
if let Some(ref tb) = self.time_box {
|
||||
md.push_str(&format!("| **Time Box** | {} |\n", tb));
|
||||
}
|
||||
if let Some(ref outcome) = self.outcome {
|
||||
md.push_str(&format!("| **Outcome** | {} |\n", outcome.as_str()));
|
||||
}
|
||||
md.push_str("\n---\n\n");
|
||||
|
||||
md.push_str("## Question\n\n");
|
||||
md.push_str(&self.question);
|
||||
md.push_str("\n\n");
|
||||
|
||||
if let Some(ref findings) = self.findings {
|
||||
md.push_str("## Findings\n\n");
|
||||
md.push_str(findings);
|
||||
md.push_str("\n\n");
|
||||
}
|
||||
|
||||
if let Some(ref rec) = self.recommendation {
|
||||
md.push_str("## Recommendation\n\n");
|
||||
md.push_str(rec);
|
||||
md.push_str("\n\n");
|
||||
}
|
||||
|
||||
md.push_str("---\n\n");
|
||||
md.push_str("*Investigation notes by Blue*\n");
|
||||
|
||||
md
|
||||
}
|
||||
}
|
||||
|
||||
impl Adr {
|
||||
/// Create a new ADR
|
||||
pub fn new(title: impl Into<String>) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
status: "accepted".to_string(),
|
||||
date: today(),
|
||||
source_rfc: None,
|
||||
context: String::new(),
|
||||
decision: String::new(),
|
||||
consequences: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate markdown content
|
||||
pub fn to_markdown(&self, number: u32) -> String {
|
||||
let mut md = String::new();
|
||||
|
||||
md.push_str(&format!(
|
||||
"# ADR {:04}: {}\n\n",
|
||||
number,
|
||||
to_title_case(&self.title)
|
||||
));
|
||||
|
||||
md.push_str("| | |\n|---|---|\n");
|
||||
md.push_str(&format!(
|
||||
"| **Status** | {} |\n",
|
||||
to_title_case(&self.status)
|
||||
));
|
||||
md.push_str(&format!("| **Date** | {} |\n", self.date));
|
||||
if let Some(ref rfc) = self.source_rfc {
|
||||
md.push_str(&format!("| **RFC** | {} |\n", rfc));
|
||||
}
|
||||
md.push_str("\n---\n\n");
|
||||
|
||||
md.push_str("## Context\n\n");
|
||||
md.push_str(&self.context);
|
||||
md.push_str("\n\n");
|
||||
|
||||
md.push_str("## Decision\n\n");
|
||||
md.push_str(&self.decision);
|
||||
md.push_str("\n\n");
|
||||
|
||||
if !self.consequences.is_empty() {
|
||||
md.push_str("## Consequences\n\n");
|
||||
for c in &self.consequences {
|
||||
md.push_str(&format!("- {}\n", c));
|
||||
}
|
||||
md.push('\n');
|
||||
}
|
||||
|
||||
md.push_str("---\n\n");
|
||||
md.push_str("*Recorded by Blue*\n");
|
||||
|
||||
md
|
||||
}
|
||||
}
|
||||
|
||||
impl Decision {
|
||||
/// Create a new Decision
|
||||
pub fn new(title: impl Into<String>, decision: impl Into<String>) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
date: today(),
|
||||
decision: decision.into(),
|
||||
rationale: None,
|
||||
alternatives: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate markdown content
|
||||
pub fn to_markdown(&self) -> String {
|
||||
let mut md = String::new();
|
||||
|
||||
md.push_str(&format!("# Decision: {}\n\n", to_title_case(&self.title)));
|
||||
md.push_str(&format!("**Date:** {}\n\n", self.date));
|
||||
|
||||
md.push_str("## Decision\n\n");
|
||||
md.push_str(&self.decision);
|
||||
md.push_str("\n\n");
|
||||
|
||||
if let Some(ref rationale) = self.rationale {
|
||||
md.push_str("## Rationale\n\n");
|
||||
md.push_str(rationale);
|
||||
md.push_str("\n\n");
|
||||
}
|
||||
|
||||
if !self.alternatives.is_empty() {
|
||||
md.push_str("## Alternatives Considered\n\n");
|
||||
for alt in &self.alternatives {
|
||||
md.push_str(&format!("- {}\n", alt));
|
||||
}
|
||||
md.push('\n');
|
||||
}
|
||||
|
||||
md.push_str("---\n\n");
|
||||
md.push_str("*Noted by Blue*\n");
|
||||
|
||||
md
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current date in YYYY-MM-DD format
|
||||
fn today() -> String {
|
||||
chrono::Utc::now().format("%Y-%m-%d").to_string()
|
||||
}
|
||||
|
||||
/// Convert kebab-case to Title Case
|
||||
fn to_title_case(s: &str) -> String {
|
||||
s.split('-')
|
||||
.map(|word| {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
None => String::new(),
|
||||
Some(first) => first.to_uppercase().chain(chars).collect(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_rfc_to_markdown() {
|
||||
let mut rfc = Rfc::new("my-feature");
|
||||
rfc.problem = Some("Things are slow".to_string());
|
||||
rfc.goals = vec!["Make it fast".to_string()];
|
||||
|
||||
let md = rfc.to_markdown(1);
|
||||
assert!(md.contains("# RFC 0001: My Feature"));
|
||||
assert!(md.contains("Things are slow"));
|
||||
assert!(md.contains("Make it fast"));
|
||||
assert!(md.contains("— Blue"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_title_case() {
|
||||
assert_eq!(to_title_case("my-feature"), "My Feature");
|
||||
assert_eq!(to_title_case("in-progress"), "In Progress");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spike_to_markdown() {
|
||||
let spike = Spike::new("test-investigation", "What should we do?");
|
||||
let md = spike.to_markdown();
|
||||
assert!(md.contains("# Spike: Test Investigation"));
|
||||
assert!(md.contains("What should we do?"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,27 @@
|
|||
//! Blue Core - The heart of the philosophy
|
||||
//!
|
||||
//! Core data structures and logic for Blue.
|
||||
//!
|
||||
//! This crate provides:
|
||||
//! - Document types (RFC, Spike, ADR, Decision)
|
||||
//! - SQLite persistence layer
|
||||
//! - Git worktree operations
|
||||
//! - Project state management
|
||||
//! - Blue's voice and tone
|
||||
|
||||
// Blue's true name, between friends
|
||||
const _BLUE_SECRET_NAME: &str = "Sheepey"; // pronounced "Shee-paay"
|
||||
|
||||
pub mod documents;
|
||||
pub mod repo;
|
||||
pub mod state;
|
||||
pub mod store;
|
||||
pub mod voice;
|
||||
pub mod workflow;
|
||||
|
||||
pub use documents::*;
|
||||
pub use repo::{detect_blue, BlueHome, RepoError, WorktreeInfo};
|
||||
pub use state::{ItemType, ProjectState, StateError, StatusSummary, WorkItem};
|
||||
pub use store::{DocType, Document, DocumentStore, LinkType, SearchResult, StoreError, Task as StoreTask, TaskProgress, Worktree};
|
||||
pub use voice::*;
|
||||
pub use workflow::{PrdStatus, RfcStatus, SpikeOutcome as WorkflowSpikeOutcome, SpikeStatus, WorkflowError};
|
||||
|
|
|
|||
289
crates/blue-core/src/repo.rs
Normal file
289
crates/blue-core/src/repo.rs
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
//! Git repository detection and operations for Blue
|
||||
//!
|
||||
//! Finds Blue's home (.blue/) and manages worktrees.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use thiserror::Error;
|
||||
use tracing::debug;
|
||||
|
||||
/// Blue's directory structure detection result
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BlueHome {
|
||||
/// Root directory containing .blue/
|
||||
pub root: PathBuf,
|
||||
/// Path to .blue/repos/ (markdown docs)
|
||||
pub repos_path: PathBuf,
|
||||
/// Path to .blue/data/ (SQLite databases)
|
||||
pub data_path: PathBuf,
|
||||
/// Path to .blue/worktrees/ (git worktrees)
|
||||
pub worktrees_path: PathBuf,
|
||||
/// Detected project name
|
||||
pub project_name: Option<String>,
|
||||
}
|
||||
|
||||
impl BlueHome {
|
||||
/// Get the docs path for a specific project
|
||||
pub fn docs_path(&self, project: &str) -> PathBuf {
|
||||
self.repos_path.join(project).join("docs")
|
||||
}
|
||||
|
||||
/// Get the database path for a specific project
|
||||
pub fn db_path(&self, project: &str) -> PathBuf {
|
||||
self.data_path.join(project).join("blue.db")
|
||||
}
|
||||
}
|
||||
|
||||
/// Information about a git worktree
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorktreeInfo {
|
||||
/// Path to the worktree
|
||||
pub path: PathBuf,
|
||||
/// Branch name
|
||||
pub branch: String,
|
||||
/// Whether this is the main worktree
|
||||
pub is_main: bool,
|
||||
}
|
||||
|
||||
impl WorktreeInfo {
|
||||
/// Extract RFC title from branch name if it follows the pattern rfc/{title}
|
||||
pub fn rfc_title(&self) -> Option<String> {
|
||||
if self.branch.starts_with("rfc/") {
|
||||
Some(self.branch.trim_start_matches("rfc/").to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Repository errors
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RepoError {
|
||||
#[error("Can't find Blue here. Run 'blue init' first?")]
|
||||
NotHome,
|
||||
|
||||
#[error("Git trouble: {0}")]
|
||||
Git(#[from] git2::Error),
|
||||
|
||||
#[error("Can't read directory: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
/// Detect Blue's home directory structure
|
||||
///
|
||||
/// Looks for .blue/ in the current directory or any parent.
|
||||
/// The structure is:
|
||||
/// ```text
|
||||
/// project/
|
||||
/// ├── .blue/
|
||||
/// │ ├── repos/ # Cloned repos with docs/
|
||||
/// │ ├── data/ # SQLite databases
|
||||
/// │ └── worktrees/ # Git worktrees
|
||||
/// └── ...
|
||||
/// ```
|
||||
pub fn detect_blue(from: &Path) -> Result<BlueHome, RepoError> {
|
||||
let mut current = from.to_path_buf();
|
||||
|
||||
loop {
|
||||
let blue_dir = current.join(".blue");
|
||||
if blue_dir.exists() && blue_dir.is_dir() {
|
||||
debug!("Found Blue's home at {:?}", blue_dir);
|
||||
|
||||
return Ok(BlueHome {
|
||||
root: current.clone(),
|
||||
repos_path: blue_dir.join("repos"),
|
||||
data_path: blue_dir.join("data"),
|
||||
worktrees_path: blue_dir.join("worktrees"),
|
||||
project_name: extract_project_name(¤t),
|
||||
});
|
||||
}
|
||||
|
||||
// Also check for legacy .repos/.data/.worktrees structure
|
||||
let legacy_repos = current.join(".repos");
|
||||
let legacy_data = current.join(".data");
|
||||
if legacy_repos.exists() && legacy_data.exists() {
|
||||
debug!("Found legacy Blue structure at {:?}", current);
|
||||
|
||||
return Ok(BlueHome {
|
||||
root: current.clone(),
|
||||
repos_path: legacy_repos,
|
||||
data_path: legacy_data,
|
||||
worktrees_path: current.join(".worktrees"),
|
||||
project_name: extract_project_name(¤t),
|
||||
});
|
||||
}
|
||||
|
||||
if !current.pop() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Err(RepoError::NotHome)
|
||||
}
|
||||
|
||||
/// Extract project name from git remote or directory name
|
||||
fn extract_project_name(path: &Path) -> Option<String> {
|
||||
// Try git remote first
|
||||
if let Ok(repo) = git2::Repository::discover(path) {
|
||||
if let Ok(remote) = repo.find_remote("origin") {
|
||||
if let Some(url) = remote.url() {
|
||||
return extract_repo_name_from_url(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to directory name
|
||||
path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Extract repository name from a git URL
|
||||
fn extract_repo_name_from_url(url: &str) -> Option<String> {
|
||||
// Handle SSH URLs: git@host:org/repo.git
|
||||
if url.contains(':') && !url.contains("://") {
|
||||
let after_colon = url.split(':').last()?;
|
||||
let name = after_colon.trim_end_matches(".git");
|
||||
return name.split('/').last().map(|s| s.to_string());
|
||||
}
|
||||
|
||||
// Handle HTTPS URLs: https://host/org/repo.git
|
||||
let name = url.trim_end_matches(".git");
|
||||
name.split('/').last().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// List git worktrees for a repository
|
||||
pub fn list_worktrees(repo: &git2::Repository) -> Vec<WorktreeInfo> {
|
||||
let mut worktrees = Vec::new();
|
||||
|
||||
// Add main worktree
|
||||
if let Some(workdir) = repo.workdir() {
|
||||
if let Ok(head) = repo.head() {
|
||||
let branch = head
|
||||
.shorthand()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "HEAD".to_string());
|
||||
|
||||
worktrees.push(WorktreeInfo {
|
||||
path: workdir.to_path_buf(),
|
||||
branch,
|
||||
is_main: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add other worktrees
|
||||
if let Ok(wt_names) = repo.worktrees() {
|
||||
for name in wt_names.iter().flatten() {
|
||||
if let Ok(wt) = repo.find_worktree(name) {
|
||||
if let Some(path) = wt.path().to_str() {
|
||||
// Try to get the branch for this worktree
|
||||
let branch = wt.name().unwrap_or("unknown").to_string();
|
||||
|
||||
worktrees.push(WorktreeInfo {
|
||||
path: PathBuf::from(path),
|
||||
branch,
|
||||
is_main: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
worktrees
|
||||
}
|
||||
|
||||
/// Create a new worktree for an RFC
|
||||
pub fn create_worktree(
|
||||
repo: &git2::Repository,
|
||||
branch_name: &str,
|
||||
worktree_path: &Path,
|
||||
) -> Result<(), RepoError> {
|
||||
// Create the branch if it doesn't exist
|
||||
let head = repo.head()?;
|
||||
let head_commit = head.peel_to_commit()?;
|
||||
|
||||
let branch = match repo.find_branch(branch_name, git2::BranchType::Local) {
|
||||
Ok(branch) => branch,
|
||||
Err(_) => repo.branch(branch_name, &head_commit, false)?,
|
||||
};
|
||||
|
||||
// Create the worktree
|
||||
let reference = branch.into_reference();
|
||||
repo.worktree(
|
||||
branch_name,
|
||||
worktree_path,
|
||||
Some(git2::WorktreeAddOptions::new().reference(Some(&reference))),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a worktree
|
||||
pub fn remove_worktree(repo: &git2::Repository, name: &str) -> Result<(), RepoError> {
|
||||
let worktree = repo.find_worktree(name)?;
|
||||
|
||||
// Prune the worktree (this removes the worktree but keeps the branch)
|
||||
worktree.prune(Some(
|
||||
git2::WorktreePruneOptions::new()
|
||||
.valid(true)
|
||||
.working_tree(true),
|
||||
))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a branch is merged into another
|
||||
pub fn is_branch_merged(
|
||||
repo: &git2::Repository,
|
||||
branch: &str,
|
||||
into: &str,
|
||||
) -> Result<bool, RepoError> {
|
||||
let branch_commit = repo
|
||||
.find_branch(branch, git2::BranchType::Local)?
|
||||
.get()
|
||||
.peel_to_commit()?
|
||||
.id();
|
||||
|
||||
let into_commit = repo
|
||||
.find_branch(into, git2::BranchType::Local)?
|
||||
.get()
|
||||
.peel_to_commit()?
|
||||
.id();
|
||||
|
||||
// Check if branch_commit is an ancestor of into_commit
|
||||
Ok(repo.graph_descendant_of(into_commit, branch_commit)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_repo_name_ssh() {
|
||||
let url = "git@github.com:superviber/blue.git";
|
||||
assert_eq!(extract_repo_name_from_url(url), Some("blue".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_repo_name_https() {
|
||||
let url = "https://github.com/superviber/blue.git";
|
||||
assert_eq!(extract_repo_name_from_url(url), Some("blue".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_worktree_info_rfc_title() {
|
||||
let wt = WorktreeInfo {
|
||||
path: PathBuf::from("/tmp/test"),
|
||||
branch: "rfc/my-feature".to_string(),
|
||||
is_main: false,
|
||||
};
|
||||
assert_eq!(wt.rfc_title(), Some("my-feature".to_string()));
|
||||
|
||||
let main = WorktreeInfo {
|
||||
path: PathBuf::from("/tmp/main"),
|
||||
branch: "main".to_string(),
|
||||
is_main: true,
|
||||
};
|
||||
assert_eq!(main.rfc_title(), None);
|
||||
}
|
||||
}
|
||||
259
crates/blue-core/src/state.rs
Normal file
259
crates/blue-core/src/state.rs
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
//! Project state management for Blue
|
||||
//!
|
||||
//! Aggregates documents, worktrees, and provides status views.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde::Serialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::repo::{list_worktrees, BlueHome, WorktreeInfo};
|
||||
use crate::store::{DocType, DocumentStore, StoreError};
|
||||
|
||||
/// Complete project state
|
||||
#[derive(Debug)]
|
||||
pub struct ProjectState {
|
||||
/// Blue's home directory
|
||||
pub home: BlueHome,
|
||||
/// SQLite document store
|
||||
pub store: DocumentStore,
|
||||
/// Active git worktrees
|
||||
pub worktrees: Vec<WorktreeInfo>,
|
||||
/// Set of RFC titles with active worktrees
|
||||
worktree_rfcs: HashSet<String>,
|
||||
/// Project name
|
||||
pub project: String,
|
||||
}
|
||||
|
||||
impl ProjectState {
|
||||
/// Load project state
|
||||
pub fn load(home: BlueHome, project: &str) -> Result<Self, StateError> {
|
||||
let db_path = home.db_path(project);
|
||||
let store = DocumentStore::open(&db_path)?;
|
||||
|
||||
// Discover worktrees
|
||||
let worktrees = Self::discover_worktrees(&home, project);
|
||||
let worktree_rfcs: HashSet<String> =
|
||||
worktrees.iter().filter_map(|wt| wt.rfc_title()).collect();
|
||||
|
||||
Ok(Self {
|
||||
home,
|
||||
store,
|
||||
worktrees,
|
||||
worktree_rfcs,
|
||||
project: project.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Discover worktrees from the repo
|
||||
fn discover_worktrees(home: &BlueHome, project: &str) -> Vec<WorktreeInfo> {
|
||||
let repo_path = home.repos_path.join(project);
|
||||
|
||||
if let Ok(repo) = git2::Repository::open(&repo_path) {
|
||||
return list_worktrees(&repo);
|
||||
}
|
||||
|
||||
// Also try from root
|
||||
if let Ok(repo) = git2::Repository::discover(&home.root) {
|
||||
return list_worktrees(&repo);
|
||||
}
|
||||
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Reload state from disk
|
||||
pub fn reload(&mut self) -> Result<(), StateError> {
|
||||
self.worktrees = Self::discover_worktrees(&self.home, &self.project);
|
||||
self.worktree_rfcs = self
|
||||
.worktrees
|
||||
.iter()
|
||||
.filter_map(|wt| wt.rfc_title())
|
||||
.collect();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get RFCs that are in-progress with active worktrees
|
||||
pub fn active_items(&self) -> Vec<WorkItem> {
|
||||
match self.store.list_documents_by_status(DocType::Rfc, "in-progress") {
|
||||
Ok(docs) => docs
|
||||
.into_iter()
|
||||
.filter(|doc| self.worktree_rfcs.contains(&doc.title))
|
||||
.map(|doc| WorkItem {
|
||||
title: doc.title,
|
||||
status: doc.status,
|
||||
has_worktree: true,
|
||||
item_type: ItemType::Rfc,
|
||||
})
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
warn!("Couldn't get active items: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get RFCs that are accepted and ready to start
|
||||
pub fn ready_items(&self) -> Vec<WorkItem> {
|
||||
match self.store.list_documents_by_status(DocType::Rfc, "accepted") {
|
||||
Ok(docs) => docs
|
||||
.into_iter()
|
||||
.map(|doc| WorkItem {
|
||||
title: doc.title,
|
||||
status: doc.status,
|
||||
has_worktree: false,
|
||||
item_type: ItemType::Rfc,
|
||||
})
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
warn!("Couldn't get ready items: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get RFCs that are in-progress but have no worktree (possibly stalled)
|
||||
pub fn stalled_items(&self) -> Vec<WorkItem> {
|
||||
match self.store.list_documents_by_status(DocType::Rfc, "in-progress") {
|
||||
Ok(docs) => docs
|
||||
.into_iter()
|
||||
.filter(|doc| !self.worktree_rfcs.contains(&doc.title))
|
||||
.map(|doc| WorkItem {
|
||||
title: doc.title,
|
||||
status: doc.status,
|
||||
has_worktree: false,
|
||||
item_type: ItemType::Rfc,
|
||||
})
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
warn!("Couldn't get stalled items: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get draft RFCs
|
||||
pub fn draft_items(&self) -> Vec<WorkItem> {
|
||||
match self.store.list_documents_by_status(DocType::Rfc, "draft") {
|
||||
Ok(docs) => docs
|
||||
.into_iter()
|
||||
.map(|doc| WorkItem {
|
||||
title: doc.title,
|
||||
status: doc.status,
|
||||
has_worktree: false,
|
||||
item_type: ItemType::Rfc,
|
||||
})
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
warn!("Couldn't get draft items: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an RFC has an active worktree
|
||||
pub fn has_worktree(&self, rfc_title: &str) -> bool {
|
||||
self.worktree_rfcs.contains(rfc_title)
|
||||
}
|
||||
|
||||
/// Generate a status hint for the user
|
||||
pub fn generate_hint(&self) -> String {
|
||||
let active = self.active_items();
|
||||
let ready = self.ready_items();
|
||||
let stalled = self.stalled_items();
|
||||
|
||||
if !stalled.is_empty() {
|
||||
return format!(
|
||||
"'{}' might be stalled - it's in-progress but has no worktree",
|
||||
stalled[0].title
|
||||
);
|
||||
}
|
||||
|
||||
if !ready.is_empty() {
|
||||
return format!("'{}' is ready to implement. Want to start?", ready[0].title);
|
||||
}
|
||||
|
||||
if !active.is_empty() {
|
||||
return format!("{} item(s) currently in progress", active.len());
|
||||
}
|
||||
|
||||
"Nothing pressing right now. Good time to plan?".to_string()
|
||||
}
|
||||
|
||||
/// Get project status summary
|
||||
pub fn status_summary(&self) -> StatusSummary {
|
||||
let active = self.active_items();
|
||||
let ready = self.ready_items();
|
||||
let stalled = self.stalled_items();
|
||||
let drafts = self.draft_items();
|
||||
|
||||
StatusSummary {
|
||||
active_count: active.len(),
|
||||
ready_count: ready.len(),
|
||||
stalled_count: stalled.len(),
|
||||
draft_count: drafts.len(),
|
||||
active,
|
||||
ready,
|
||||
stalled,
|
||||
drafts,
|
||||
hint: self.generate_hint(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A work item (RFC, spike, etc.)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct WorkItem {
|
||||
pub title: String,
|
||||
pub status: String,
|
||||
pub has_worktree: bool,
|
||||
pub item_type: ItemType,
|
||||
}
|
||||
|
||||
/// Type of work item
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ItemType {
|
||||
Rfc,
|
||||
Spike,
|
||||
Adr,
|
||||
Decision,
|
||||
Prd,
|
||||
}
|
||||
|
||||
/// Project status summary
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct StatusSummary {
|
||||
pub active_count: usize,
|
||||
pub ready_count: usize,
|
||||
pub stalled_count: usize,
|
||||
pub draft_count: usize,
|
||||
pub active: Vec<WorkItem>,
|
||||
pub ready: Vec<WorkItem>,
|
||||
pub stalled: Vec<WorkItem>,
|
||||
pub drafts: Vec<WorkItem>,
|
||||
pub hint: String,
|
||||
}
|
||||
|
||||
/// State errors
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum StateError {
|
||||
#[error("Store error: {0}")]
|
||||
Store(#[from] StoreError),
|
||||
|
||||
#[error("Repo error: {0}")]
|
||||
Repo(#[from] crate::repo::RepoError),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_generate_hint_empty() {
|
||||
// This would require setting up a full test environment
|
||||
// For now, just verify the function exists and compiles
|
||||
}
|
||||
}
|
||||
979
crates/blue-core/src/store.rs
Normal file
979
crates/blue-core/src/store.rs
Normal file
|
|
@ -0,0 +1,979 @@
|
|||
//! SQLite document store for Blue
|
||||
//!
|
||||
//! Persistence layer for RFCs, Spikes, ADRs, and other documents.
|
||||
|
||||
use std::path::Path;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Current schema version
|
||||
const SCHEMA_VERSION: i32 = 1;
|
||||
|
||||
/// Core database schema
|
||||
const SCHEMA: &str = r#"
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
doc_type TEXT NOT NULL,
|
||||
number INTEGER,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
file_path TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(doc_type, title)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_type ON documents(doc_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_status ON documents(doc_type, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS document_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_id INTEGER NOT NULL,
|
||||
target_id INTEGER NOT NULL,
|
||||
link_type TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (source_id) REFERENCES documents(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (target_id) REFERENCES documents(id) ON DELETE CASCADE,
|
||||
UNIQUE(source_id, target_id, link_type)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
document_id INTEGER NOT NULL,
|
||||
task_index INTEGER NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
completed INTEGER NOT NULL DEFAULT 0,
|
||||
completed_at TEXT,
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE,
|
||||
UNIQUE(document_id, task_index)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS worktrees (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
document_id INTEGER NOT NULL,
|
||||
branch_name TEXT NOT NULL,
|
||||
worktree_path TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE,
|
||||
UNIQUE(document_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metadata (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
document_id INTEGER NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE,
|
||||
UNIQUE(document_id, key)
|
||||
);
|
||||
"#;
|
||||
|
||||
/// FTS5 schema for full-text search
|
||||
const FTS5_SCHEMA: &str = r#"
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
|
||||
title,
|
||||
content,
|
||||
doc_type,
|
||||
content=documents,
|
||||
content_rowid=id
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS documents_ai AFTER INSERT ON documents BEGIN
|
||||
INSERT INTO documents_fts(rowid, title, doc_type)
|
||||
VALUES (new.id, new.title, new.doc_type);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS documents_ad AFTER DELETE ON documents BEGIN
|
||||
INSERT INTO documents_fts(documents_fts, rowid, title, doc_type)
|
||||
VALUES ('delete', old.id, old.title, old.doc_type);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS documents_au AFTER UPDATE ON documents BEGIN
|
||||
INSERT INTO documents_fts(documents_fts, rowid, title, doc_type)
|
||||
VALUES ('delete', old.id, old.title, old.doc_type);
|
||||
INSERT INTO documents_fts(rowid, title, doc_type)
|
||||
VALUES (new.id, new.title, new.doc_type);
|
||||
END;
|
||||
"#;
|
||||
|
||||
/// Document types
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DocType {
|
||||
Rfc,
|
||||
Spike,
|
||||
Adr,
|
||||
Decision,
|
||||
Prd,
|
||||
}
|
||||
|
||||
impl DocType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
DocType::Rfc => "rfc",
|
||||
DocType::Spike => "spike",
|
||||
DocType::Adr => "adr",
|
||||
DocType::Decision => "decision",
|
||||
DocType::Prd => "prd",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"rfc" => Some(DocType::Rfc),
|
||||
"spike" => Some(DocType::Spike),
|
||||
"adr" => Some(DocType::Adr),
|
||||
"decision" => Some(DocType::Decision),
|
||||
"prd" => Some(DocType::Prd),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable plural for Blue's messages
|
||||
pub fn plural(&self) -> &'static str {
|
||||
match self {
|
||||
DocType::Rfc => "RFCs",
|
||||
DocType::Spike => "spikes",
|
||||
DocType::Adr => "ADRs",
|
||||
DocType::Decision => "decisions",
|
||||
DocType::Prd => "PRDs",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Link types between documents
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LinkType {
|
||||
/// Spike leads to RFC
|
||||
SpikeToRfc,
|
||||
/// RFC leads to ADR
|
||||
RfcToAdr,
|
||||
/// PRD leads to RFC
|
||||
PrdToRfc,
|
||||
/// Generic reference
|
||||
References,
|
||||
}
|
||||
|
||||
impl LinkType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
LinkType::SpikeToRfc => "spike_to_rfc",
|
||||
LinkType::RfcToAdr => "rfc_to_adr",
|
||||
LinkType::PrdToRfc => "prd_to_rfc",
|
||||
LinkType::References => "references",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A document in the store
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Document {
|
||||
pub id: Option<i64>,
|
||||
pub doc_type: DocType,
|
||||
pub number: Option<i32>,
|
||||
pub title: String,
|
||||
pub status: String,
|
||||
pub file_path: Option<String>,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Create a new document
|
||||
pub fn new(doc_type: DocType, title: &str, status: &str) -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
doc_type,
|
||||
number: None,
|
||||
title: title.to_string(),
|
||||
status: status.to_string(),
|
||||
file_path: None,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A task in a document's plan
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Task {
|
||||
pub id: Option<i64>,
|
||||
pub document_id: i64,
|
||||
pub task_index: i32,
|
||||
pub description: String,
|
||||
pub completed: bool,
|
||||
pub completed_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Task completion progress
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TaskProgress {
|
||||
pub completed: usize,
|
||||
pub total: usize,
|
||||
pub percentage: usize,
|
||||
}
|
||||
|
||||
/// A worktree associated with a document
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Worktree {
|
||||
pub id: Option<i64>,
|
||||
pub document_id: i64,
|
||||
pub branch_name: String,
|
||||
pub worktree_path: String,
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Search result with relevance score
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SearchResult {
|
||||
pub document: Document,
|
||||
pub score: f64,
|
||||
pub snippet: Option<String>,
|
||||
}
|
||||
|
||||
/// Store errors - in Blue's voice
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum StoreError {
|
||||
#[error("Can't find '{0}'. Check the name's spelled right?")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Database hiccup: {0}")]
|
||||
Database(#[from] rusqlite::Error),
|
||||
|
||||
#[error("'{0}' already exists. Want to update it instead?")]
|
||||
AlreadyExists(String),
|
||||
|
||||
#[error("Can't do that: {0}")]
|
||||
InvalidOperation(String),
|
||||
}
|
||||
|
||||
/// Check if an error is a busy/locked error
|
||||
fn is_busy_error(e: &rusqlite::Error) -> bool {
|
||||
matches!(
|
||||
e,
|
||||
rusqlite::Error::SqliteFailure(
|
||||
rusqlite::ffi::Error {
|
||||
code: rusqlite::ErrorCode::DatabaseBusy,
|
||||
..
|
||||
},
|
||||
_
|
||||
) | rusqlite::Error::SqliteFailure(
|
||||
rusqlite::ffi::Error {
|
||||
code: rusqlite::ErrorCode::DatabaseLocked,
|
||||
..
|
||||
},
|
||||
_
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// SQLite-based document store
|
||||
pub struct DocumentStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DocumentStore {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DocumentStore")
|
||||
.field("conn", &"<Connection>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl DocumentStore {
|
||||
/// Open or create a document store
|
||||
pub fn open(path: &Path) -> Result<Self, StoreError> {
|
||||
info!("Opening Blue's document store at {:?}", path);
|
||||
|
||||
// Create parent directory if needed
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
|
||||
let conn = Connection::open(path)?;
|
||||
|
||||
// Configure for concurrency
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.pragma_update(None, "busy_timeout", 5000)?;
|
||||
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
|
||||
let store = Self { conn };
|
||||
store.init_schema()?;
|
||||
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
/// Open an in-memory database (for testing)
|
||||
pub fn open_in_memory() -> Result<Self, StoreError> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
|
||||
let store = Self { conn };
|
||||
store.init_schema()?;
|
||||
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
/// Get a reference to the underlying connection
|
||||
pub fn conn(&self) -> &Connection {
|
||||
&self.conn
|
||||
}
|
||||
|
||||
/// Initialize the database schema
|
||||
fn init_schema(&self) -> Result<(), StoreError> {
|
||||
let version: Option<i32> = self
|
||||
.conn
|
||||
.query_row("SELECT version FROM schema_version LIMIT 1", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.ok();
|
||||
|
||||
match version {
|
||||
None => {
|
||||
debug!("Setting up Blue's database (version {})", SCHEMA_VERSION);
|
||||
self.conn.execute_batch(SCHEMA)?;
|
||||
self.conn.execute_batch(FTS5_SCHEMA)?;
|
||||
self.conn.execute(
|
||||
"INSERT INTO schema_version (version) VALUES (?1)",
|
||||
params![SCHEMA_VERSION],
|
||||
)?;
|
||||
}
|
||||
Some(v) if v == SCHEMA_VERSION => {
|
||||
debug!("Database is up to date (version {})", v);
|
||||
}
|
||||
Some(v) => {
|
||||
warn!(
|
||||
"Schema version {} found, expected {}. Migrations may be needed.",
|
||||
v, SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Execute with retry on busy
|
||||
fn with_retry<F, T>(&self, f: F) -> Result<T, StoreError>
|
||||
where
|
||||
F: Fn() -> Result<T, StoreError>,
|
||||
{
|
||||
let mut attempts = 0;
|
||||
loop {
|
||||
match f() {
|
||||
Ok(result) => return Ok(result),
|
||||
Err(StoreError::Database(ref e)) if is_busy_error(e) && attempts < 3 => {
|
||||
attempts += 1;
|
||||
let delay = Duration::from_millis(100 * attempts as u64);
|
||||
debug!("Database busy, retrying in {:?}", delay);
|
||||
thread::sleep(delay);
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Begin a write transaction
|
||||
pub fn begin_write(&mut self) -> Result<Transaction<'_>, StoreError> {
|
||||
Ok(self
|
||||
.conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)?)
|
||||
}
|
||||
|
||||
// ==================== Document Operations ====================
|
||||
|
||||
/// Add a new document
|
||||
pub fn add_document(&self, doc: &Document) -> Result<i64, StoreError> {
|
||||
self.with_retry(|| {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
self.conn.execute(
|
||||
"INSERT INTO documents (doc_type, number, title, status, file_path, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
doc.doc_type.as_str(),
|
||||
doc.number,
|
||||
doc.title,
|
||||
doc.status,
|
||||
doc.file_path,
|
||||
now,
|
||||
now,
|
||||
],
|
||||
)?;
|
||||
Ok(self.conn.last_insert_rowid())
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a document by type and title
|
||||
pub fn get_document(&self, doc_type: DocType, title: &str) -> Result<Document, StoreError> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT id, doc_type, number, title, status, file_path, created_at, updated_at
|
||||
FROM documents WHERE doc_type = ?1 AND title = ?2",
|
||||
params![doc_type.as_str(), title],
|
||||
|row| {
|
||||
Ok(Document {
|
||||
id: Some(row.get(0)?),
|
||||
doc_type: DocType::from_str(row.get::<_, String>(1)?.as_str()).unwrap(),
|
||||
number: row.get(2)?,
|
||||
title: row.get(3)?,
|
||||
status: row.get(4)?,
|
||||
file_path: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| match e {
|
||||
rusqlite::Error::QueryReturnedNoRows => StoreError::NotFound(title.to_string()),
|
||||
e => StoreError::Database(e),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a document by ID
|
||||
pub fn get_document_by_id(&self, id: i64) -> Result<Document, StoreError> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT id, doc_type, number, title, status, file_path, created_at, updated_at
|
||||
FROM documents WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
Ok(Document {
|
||||
id: Some(row.get(0)?),
|
||||
doc_type: DocType::from_str(row.get::<_, String>(1)?.as_str()).unwrap(),
|
||||
number: row.get(2)?,
|
||||
title: row.get(3)?,
|
||||
status: row.get(4)?,
|
||||
file_path: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| match e {
|
||||
rusqlite::Error::QueryReturnedNoRows => {
|
||||
StoreError::NotFound(format!("document #{}", id))
|
||||
}
|
||||
e => StoreError::Database(e),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a document by number
|
||||
pub fn get_document_by_number(
|
||||
&self,
|
||||
doc_type: DocType,
|
||||
number: i32,
|
||||
) -> Result<Document, StoreError> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT id, doc_type, number, title, status, file_path, created_at, updated_at
|
||||
FROM documents WHERE doc_type = ?1 AND number = ?2",
|
||||
params![doc_type.as_str(), number],
|
||||
|row| {
|
||||
Ok(Document {
|
||||
id: Some(row.get(0)?),
|
||||
doc_type: DocType::from_str(row.get::<_, String>(1)?.as_str()).unwrap(),
|
||||
number: row.get(2)?,
|
||||
title: row.get(3)?,
|
||||
status: row.get(4)?,
|
||||
file_path: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| match e {
|
||||
rusqlite::Error::QueryReturnedNoRows => {
|
||||
StoreError::NotFound(format!("{} #{}", doc_type.as_str(), number))
|
||||
}
|
||||
e => StoreError::Database(e),
|
||||
})
|
||||
}
|
||||
|
||||
/// Find a document using flexible matching
|
||||
pub fn find_document(&self, doc_type: DocType, query: &str) -> Result<Document, StoreError> {
|
||||
// Try exact match first
|
||||
if let Ok(doc) = self.get_document(doc_type, query) {
|
||||
return Ok(doc);
|
||||
}
|
||||
|
||||
// Try number match
|
||||
let trimmed = query.trim_start_matches('0');
|
||||
if let Ok(num) = if trimmed.is_empty() {
|
||||
"0".parse()
|
||||
} else {
|
||||
trimmed.parse::<i32>()
|
||||
} {
|
||||
if let Ok(doc) = self.get_document_by_number(doc_type, num) {
|
||||
return Ok(doc);
|
||||
}
|
||||
}
|
||||
|
||||
// Try substring match
|
||||
let pattern = format!("%{}%", query.to_lowercase());
|
||||
if let Ok(doc) = self.conn.query_row(
|
||||
"SELECT id, doc_type, number, title, status, file_path, created_at, updated_at
|
||||
FROM documents WHERE doc_type = ?1 AND LOWER(title) LIKE ?2
|
||||
ORDER BY LENGTH(title) ASC LIMIT 1",
|
||||
params![doc_type.as_str(), pattern],
|
||||
|row| {
|
||||
Ok(Document {
|
||||
id: Some(row.get(0)?),
|
||||
doc_type: DocType::from_str(row.get::<_, String>(1)?.as_str()).unwrap(),
|
||||
number: row.get(2)?,
|
||||
title: row.get(3)?,
|
||||
status: row.get(4)?,
|
||||
file_path: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
})
|
||||
},
|
||||
) {
|
||||
return Ok(doc);
|
||||
}
|
||||
|
||||
Err(StoreError::NotFound(format!(
|
||||
"{} matching '{}'",
|
||||
doc_type.as_str(),
|
||||
query
|
||||
)))
|
||||
}
|
||||
|
||||
/// Update a document's status
|
||||
pub fn update_document_status(
|
||||
&self,
|
||||
doc_type: DocType,
|
||||
title: &str,
|
||||
status: &str,
|
||||
) -> Result<(), StoreError> {
|
||||
self.with_retry(|| {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let updated = self.conn.execute(
|
||||
"UPDATE documents SET status = ?1, updated_at = ?2 WHERE doc_type = ?3 AND title = ?4",
|
||||
params![status, now, doc_type.as_str(), title],
|
||||
)?;
|
||||
if updated == 0 {
|
||||
return Err(StoreError::NotFound(title.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Update a document
|
||||
pub fn update_document(&self, doc: &Document) -> Result<(), StoreError> {
|
||||
let id = doc
|
||||
.id
|
||||
.ok_or_else(|| StoreError::InvalidOperation("Document has no ID".to_string()))?;
|
||||
|
||||
self.with_retry(|| {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let updated = self.conn.execute(
|
||||
"UPDATE documents SET doc_type = ?1, number = ?2, title = ?3, status = ?4,
|
||||
file_path = ?5, updated_at = ?6 WHERE id = ?7",
|
||||
params![
|
||||
doc.doc_type.as_str(),
|
||||
doc.number,
|
||||
doc.title,
|
||||
doc.status,
|
||||
doc.file_path,
|
||||
now,
|
||||
id
|
||||
],
|
||||
)?;
|
||||
if updated == 0 {
|
||||
return Err(StoreError::NotFound(format!("document #{}", id)));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// List all documents of a given type
|
||||
pub fn list_documents(&self, doc_type: DocType) -> Result<Vec<Document>, StoreError> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, doc_type, number, title, status, file_path, created_at, updated_at
|
||||
FROM documents WHERE doc_type = ?1 ORDER BY number DESC, title ASC",
|
||||
)?;
|
||||
|
||||
let rows = stmt.query_map(params![doc_type.as_str()], |row| {
|
||||
Ok(Document {
|
||||
id: Some(row.get(0)?),
|
||||
doc_type: DocType::from_str(row.get::<_, String>(1)?.as_str()).unwrap(),
|
||||
number: row.get(2)?,
|
||||
title: row.get(3)?,
|
||||
status: row.get(4)?,
|
||||
file_path: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
rows.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(StoreError::Database)
|
||||
}
|
||||
|
||||
/// List documents by status
|
||||
pub fn list_documents_by_status(
|
||||
&self,
|
||||
doc_type: DocType,
|
||||
status: &str,
|
||||
) -> Result<Vec<Document>, StoreError> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, doc_type, number, title, status, file_path, created_at, updated_at
|
||||
FROM documents WHERE doc_type = ?1 AND status = ?2 ORDER BY number DESC, title ASC",
|
||||
)?;
|
||||
|
||||
let rows = stmt.query_map(params![doc_type.as_str(), status], |row| {
|
||||
Ok(Document {
|
||||
id: Some(row.get(0)?),
|
||||
doc_type: DocType::from_str(row.get::<_, String>(1)?.as_str()).unwrap(),
|
||||
number: row.get(2)?,
|
||||
title: row.get(3)?,
|
||||
status: row.get(4)?,
|
||||
file_path: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
rows.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(StoreError::Database)
|
||||
}
|
||||
|
||||
/// Delete a document
|
||||
pub fn delete_document(&self, doc_type: DocType, title: &str) -> Result<(), StoreError> {
|
||||
self.with_retry(|| {
|
||||
let deleted = self.conn.execute(
|
||||
"DELETE FROM documents WHERE doc_type = ?1 AND title = ?2",
|
||||
params![doc_type.as_str(), title],
|
||||
)?;
|
||||
if deleted == 0 {
|
||||
return Err(StoreError::NotFound(title.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the next document number for a type
|
||||
pub fn next_number(&self, doc_type: DocType) -> Result<i32, StoreError> {
|
||||
let max: Option<i32> = self.conn.query_row(
|
||||
"SELECT MAX(number) FROM documents WHERE doc_type = ?1",
|
||||
params![doc_type.as_str()],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(max.unwrap_or(0) + 1)
|
||||
}
|
||||
|
||||
// ==================== Link Operations ====================
|
||||
|
||||
/// Link two documents
|
||||
pub fn link_documents(
|
||||
&self,
|
||||
source_id: i64,
|
||||
target_id: i64,
|
||||
link_type: LinkType,
|
||||
) -> Result<(), StoreError> {
|
||||
self.with_retry(|| {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
self.conn.execute(
|
||||
"INSERT OR IGNORE INTO document_links (source_id, target_id, link_type, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![source_id, target_id, link_type.as_str(), now],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Get linked documents
|
||||
pub fn get_linked_documents(
|
||||
&self,
|
||||
source_id: i64,
|
||||
link_type: Option<LinkType>,
|
||||
) -> Result<Vec<Document>, StoreError> {
|
||||
let query = match link_type {
|
||||
Some(lt) => format!(
|
||||
"SELECT d.id, d.doc_type, d.number, d.title, d.status, d.file_path, d.created_at, d.updated_at
|
||||
FROM documents d
|
||||
JOIN document_links l ON l.target_id = d.id
|
||||
WHERE l.source_id = ?1 AND l.link_type = '{}'",
|
||||
lt.as_str()
|
||||
),
|
||||
None => "SELECT d.id, d.doc_type, d.number, d.title, d.status, d.file_path, d.created_at, d.updated_at
|
||||
FROM documents d
|
||||
JOIN document_links l ON l.target_id = d.id
|
||||
WHERE l.source_id = ?1".to_string(),
|
||||
};
|
||||
|
||||
let mut stmt = self.conn.prepare(&query)?;
|
||||
let rows = stmt.query_map(params![source_id], |row| {
|
||||
Ok(Document {
|
||||
id: Some(row.get(0)?),
|
||||
doc_type: DocType::from_str(row.get::<_, String>(1)?.as_str()).unwrap(),
|
||||
number: row.get(2)?,
|
||||
title: row.get(3)?,
|
||||
status: row.get(4)?,
|
||||
file_path: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
rows.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(StoreError::Database)
|
||||
}
|
||||
|
||||
// ==================== Task Operations ====================
|
||||
|
||||
/// Set tasks for a document (replaces existing)
|
||||
pub fn set_tasks(&self, document_id: i64, tasks: &[String]) -> Result<(), StoreError> {
|
||||
self.with_retry(|| {
|
||||
self.conn
|
||||
.execute("DELETE FROM tasks WHERE document_id = ?1", params![document_id])?;
|
||||
|
||||
for (idx, desc) in tasks.iter().enumerate() {
|
||||
self.conn.execute(
|
||||
"INSERT INTO tasks (document_id, task_index, description, completed)
|
||||
VALUES (?1, ?2, ?3, 0)",
|
||||
params![document_id, (idx + 1) as i32, desc],
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Mark a task as complete
|
||||
pub fn complete_task(&self, document_id: i64, task_index: i32) -> Result<(), StoreError> {
|
||||
self.with_retry(|| {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let updated = self.conn.execute(
|
||||
"UPDATE tasks SET completed = 1, completed_at = ?1
|
||||
WHERE document_id = ?2 AND task_index = ?3",
|
||||
params![now, document_id, task_index],
|
||||
)?;
|
||||
if updated == 0 {
|
||||
return Err(StoreError::NotFound(format!(
|
||||
"task {} in document #{}",
|
||||
task_index, document_id
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Get task progress
|
||||
pub fn get_task_progress(&self, document_id: i64) -> Result<TaskProgress, StoreError> {
|
||||
let (total, completed): (i64, i64) = self.conn.query_row(
|
||||
"SELECT COUNT(*), COALESCE(SUM(completed), 0) FROM tasks WHERE document_id = ?1",
|
||||
params![document_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)?;
|
||||
|
||||
let total = total as usize;
|
||||
let completed = completed as usize;
|
||||
let percentage = if total > 0 {
|
||||
(completed * 100) / total
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Ok(TaskProgress {
|
||||
completed,
|
||||
total,
|
||||
percentage,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get all tasks for a document
|
||||
pub fn get_tasks(&self, document_id: i64) -> Result<Vec<Task>, StoreError> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, document_id, task_index, description, completed, completed_at
|
||||
FROM tasks WHERE document_id = ?1 ORDER BY task_index",
|
||||
)?;
|
||||
|
||||
let rows = stmt.query_map(params![document_id], |row| {
|
||||
Ok(Task {
|
||||
id: Some(row.get(0)?),
|
||||
document_id: row.get(1)?,
|
||||
task_index: row.get(2)?,
|
||||
description: row.get(3)?,
|
||||
completed: row.get::<_, i32>(4)? != 0,
|
||||
completed_at: row.get(5)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
rows.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(StoreError::Database)
|
||||
}
|
||||
|
||||
// ==================== Worktree Operations ====================
|
||||
|
||||
/// Add a worktree for a document
|
||||
pub fn add_worktree(&self, worktree: &Worktree) -> Result<i64, StoreError> {
|
||||
self.with_retry(|| {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
self.conn.execute(
|
||||
"INSERT INTO worktrees (document_id, branch_name, worktree_path, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![
|
||||
worktree.document_id,
|
||||
worktree.branch_name,
|
||||
worktree.worktree_path,
|
||||
now
|
||||
],
|
||||
)?;
|
||||
Ok(self.conn.last_insert_rowid())
|
||||
})
|
||||
}
|
||||
|
||||
/// Get worktree for a document
|
||||
pub fn get_worktree(&self, document_id: i64) -> Result<Option<Worktree>, StoreError> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT id, document_id, branch_name, worktree_path, created_at
|
||||
FROM worktrees WHERE document_id = ?1",
|
||||
params![document_id],
|
||||
|row| {
|
||||
Ok(Worktree {
|
||||
id: Some(row.get(0)?),
|
||||
document_id: row.get(1)?,
|
||||
branch_name: row.get(2)?,
|
||||
worktree_path: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(StoreError::Database)
|
||||
}
|
||||
|
||||
/// Remove a worktree
|
||||
pub fn remove_worktree(&self, document_id: i64) -> Result<(), StoreError> {
|
||||
self.with_retry(|| {
|
||||
self.conn.execute(
|
||||
"DELETE FROM worktrees WHERE document_id = ?1",
|
||||
params![document_id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// List all worktrees
|
||||
pub fn list_worktrees(&self) -> Result<Vec<Worktree>, StoreError> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, document_id, branch_name, worktree_path, created_at FROM worktrees",
|
||||
)?;
|
||||
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(Worktree {
|
||||
id: Some(row.get(0)?),
|
||||
document_id: row.get(1)?,
|
||||
branch_name: row.get(2)?,
|
||||
worktree_path: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
rows.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(StoreError::Database)
|
||||
}
|
||||
|
||||
// ==================== Search Operations ====================
|
||||
|
||||
/// Search documents using FTS5
|
||||
pub fn search_documents(
|
||||
&self,
|
||||
query: &str,
|
||||
doc_type: Option<DocType>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<SearchResult>, StoreError> {
|
||||
let escaped = query.replace('"', "\"\"");
|
||||
let fts_query = format!("\"{}\"*", escaped);
|
||||
|
||||
let sql = match doc_type {
|
||||
Some(dt) => format!(
|
||||
"SELECT d.id, d.doc_type, d.number, d.title, d.status, d.file_path,
|
||||
d.created_at, d.updated_at, bm25(documents_fts) as score
|
||||
FROM documents_fts fts
|
||||
JOIN documents d ON d.id = fts.rowid
|
||||
WHERE documents_fts MATCH ?1 AND d.doc_type = '{}'
|
||||
ORDER BY score
|
||||
LIMIT ?2",
|
||||
dt.as_str()
|
||||
),
|
||||
None => "SELECT d.id, d.doc_type, d.number, d.title, d.status, d.file_path,
|
||||
d.created_at, d.updated_at, bm25(documents_fts) as score
|
||||
FROM documents_fts fts
|
||||
JOIN documents d ON d.id = fts.rowid
|
||||
WHERE documents_fts MATCH ?1
|
||||
ORDER BY score
|
||||
LIMIT ?2"
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
let mut stmt = self.conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params![fts_query, limit as i32], |row| {
|
||||
Ok(SearchResult {
|
||||
document: Document {
|
||||
id: Some(row.get(0)?),
|
||||
doc_type: DocType::from_str(row.get::<_, String>(1)?.as_str()).unwrap(),
|
||||
number: row.get(2)?,
|
||||
title: row.get(3)?,
|
||||
status: row.get(4)?,
|
||||
file_path: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
},
|
||||
score: row.get(8)?,
|
||||
snippet: None,
|
||||
})
|
||||
})?;
|
||||
|
||||
rows.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(StoreError::Database)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_create_and_find_document() {
|
||||
let store = DocumentStore::open_in_memory().unwrap();
|
||||
|
||||
let doc = Document::new(DocType::Rfc, "test-feature", "draft");
|
||||
let id = store.add_document(&doc).unwrap();
|
||||
|
||||
let found = store.find_document(DocType::Rfc, "test-feature").unwrap();
|
||||
assert_eq!(found.id, Some(id));
|
||||
assert_eq!(found.title, "test-feature");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_task_progress() {
|
||||
let store = DocumentStore::open_in_memory().unwrap();
|
||||
|
||||
let doc = Document::new(DocType::Rfc, "task-test", "draft");
|
||||
let id = store.add_document(&doc).unwrap();
|
||||
|
||||
store
|
||||
.set_tasks(id, &["Task 1".into(), "Task 2".into(), "Task 3".into()])
|
||||
.unwrap();
|
||||
|
||||
let progress = store.get_task_progress(id).unwrap();
|
||||
assert_eq!(progress.total, 3);
|
||||
assert_eq!(progress.completed, 0);
|
||||
assert_eq!(progress.percentage, 0);
|
||||
|
||||
store.complete_task(id, 1).unwrap();
|
||||
|
||||
let progress = store.get_task_progress(id).unwrap();
|
||||
assert_eq!(progress.completed, 1);
|
||||
assert_eq!(progress.percentage, 33);
|
||||
}
|
||||
}
|
||||
246
crates/blue-core/src/workflow.rs
Normal file
246
crates/blue-core/src/workflow.rs
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
//! Workflow transitions for Blue documents
|
||||
//!
|
||||
//! Status management and validation for RFCs, Spikes, and other documents.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// RFC status values
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum RfcStatus {
|
||||
/// Initial proposal, still being refined
|
||||
Draft,
|
||||
/// Approved, ready to implement
|
||||
Accepted,
|
||||
/// Work has started
|
||||
InProgress,
|
||||
/// Work is complete
|
||||
Implemented,
|
||||
/// Replaced by a newer RFC
|
||||
Superseded,
|
||||
}
|
||||
|
||||
impl RfcStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
RfcStatus::Draft => "draft",
|
||||
RfcStatus::Accepted => "accepted",
|
||||
RfcStatus::InProgress => "in-progress",
|
||||
RfcStatus::Implemented => "implemented",
|
||||
RfcStatus::Superseded => "superseded",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Result<Self, WorkflowError> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"draft" => Ok(RfcStatus::Draft),
|
||||
"accepted" => Ok(RfcStatus::Accepted),
|
||||
"in-progress" | "in_progress" | "inprogress" => Ok(RfcStatus::InProgress),
|
||||
"implemented" => Ok(RfcStatus::Implemented),
|
||||
"superseded" => Ok(RfcStatus::Superseded),
|
||||
_ => Err(WorkflowError::InvalidStatus(s.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if transition to the given status is valid
|
||||
pub fn can_transition_to(&self, target: RfcStatus) -> bool {
|
||||
matches!(
|
||||
(self, target),
|
||||
// Normal forward flow
|
||||
(RfcStatus::Draft, RfcStatus::Accepted)
|
||||
| (RfcStatus::Accepted, RfcStatus::InProgress)
|
||||
| (RfcStatus::InProgress, RfcStatus::Implemented)
|
||||
// Can supersede from any active state
|
||||
| (RfcStatus::Draft, RfcStatus::Superseded)
|
||||
| (RfcStatus::Accepted, RfcStatus::Superseded)
|
||||
| (RfcStatus::InProgress, RfcStatus::Superseded)
|
||||
// Can go back to draft if needed
|
||||
| (RfcStatus::Accepted, RfcStatus::Draft)
|
||||
)
|
||||
}
|
||||
|
||||
/// Get allowed transitions from current status
|
||||
pub fn allowed_transitions(&self) -> Vec<RfcStatus> {
|
||||
match self {
|
||||
RfcStatus::Draft => vec![RfcStatus::Accepted, RfcStatus::Superseded],
|
||||
RfcStatus::Accepted => {
|
||||
vec![RfcStatus::InProgress, RfcStatus::Draft, RfcStatus::Superseded]
|
||||
}
|
||||
RfcStatus::InProgress => vec![RfcStatus::Implemented, RfcStatus::Superseded],
|
||||
RfcStatus::Implemented => vec![],
|
||||
RfcStatus::Superseded => vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spike outcome values
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum SpikeOutcome {
|
||||
/// Investigation showed this isn't worth pursuing
|
||||
NoAction,
|
||||
/// Learned enough to make a decision
|
||||
DecisionMade,
|
||||
/// Should build something (requires RFC)
|
||||
RecommendsImplementation,
|
||||
}
|
||||
|
||||
impl SpikeOutcome {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
SpikeOutcome::NoAction => "no-action",
|
||||
SpikeOutcome::DecisionMade => "decision-made",
|
||||
SpikeOutcome::RecommendsImplementation => "recommends-implementation",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Result<Self, WorkflowError> {
|
||||
match s.to_lowercase().replace('_', "-").as_str() {
|
||||
"no-action" => Ok(SpikeOutcome::NoAction),
|
||||
"decision-made" => Ok(SpikeOutcome::DecisionMade),
|
||||
"recommends-implementation" => Ok(SpikeOutcome::RecommendsImplementation),
|
||||
_ => Err(WorkflowError::InvalidOutcome(s.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spike status values
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum SpikeStatus {
|
||||
/// Investigation in progress
|
||||
InProgress,
|
||||
/// Investigation complete
|
||||
Completed,
|
||||
}
|
||||
|
||||
impl SpikeStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
SpikeStatus::InProgress => "in-progress",
|
||||
SpikeStatus::Completed => "completed",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Result<Self, WorkflowError> {
|
||||
match s.to_lowercase().replace('_', "-").as_str() {
|
||||
"in-progress" => Ok(SpikeStatus::InProgress),
|
||||
"completed" => Ok(SpikeStatus::Completed),
|
||||
_ => Err(WorkflowError::InvalidStatus(s.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// PRD status values
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum PrdStatus {
|
||||
/// Initial requirements, still being refined
|
||||
Draft,
|
||||
/// Stakeholders have signed off
|
||||
Approved,
|
||||
/// All requirements implemented
|
||||
Implemented,
|
||||
}
|
||||
|
||||
impl PrdStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
PrdStatus::Draft => "draft",
|
||||
PrdStatus::Approved => "approved",
|
||||
PrdStatus::Implemented => "implemented",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Result<Self, WorkflowError> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"draft" => Ok(PrdStatus::Draft),
|
||||
"approved" => Ok(PrdStatus::Approved),
|
||||
"implemented" => Ok(PrdStatus::Implemented),
|
||||
_ => Err(WorkflowError::InvalidStatus(s.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_transition_to(&self, target: PrdStatus) -> bool {
|
||||
matches!(
|
||||
(self, target),
|
||||
(PrdStatus::Draft, PrdStatus::Approved) | (PrdStatus::Approved, PrdStatus::Implemented)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Workflow errors
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WorkflowError {
|
||||
#[error("'{0}' isn't a valid status. Try: draft, accepted, in-progress, implemented")]
|
||||
InvalidStatus(String),
|
||||
|
||||
#[error("'{0}' isn't a valid outcome. Try: no-action, decision-made, recommends-implementation")]
|
||||
InvalidOutcome(String),
|
||||
|
||||
#[error("Can't go from {from} to {to}. {hint}")]
|
||||
InvalidTransition {
|
||||
from: String,
|
||||
to: String,
|
||||
hint: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Validate an RFC status transition
|
||||
pub fn validate_rfc_transition(from: RfcStatus, to: RfcStatus) -> Result<(), WorkflowError> {
|
||||
if from.can_transition_to(to) {
|
||||
Ok(())
|
||||
} else {
|
||||
let hint = match (from, to) {
|
||||
(RfcStatus::Draft, RfcStatus::InProgress) => {
|
||||
"Accept it first, then start work".to_string()
|
||||
}
|
||||
(RfcStatus::Implemented, _) => {
|
||||
"Already implemented. Create a new RFC for changes".to_string()
|
||||
}
|
||||
(RfcStatus::Superseded, _) => "This RFC has been superseded".to_string(),
|
||||
_ => format!("From {} you can go to: {:?}", from.as_str(), from.allowed_transitions()),
|
||||
};
|
||||
|
||||
Err(WorkflowError::InvalidTransition {
|
||||
from: from.as_str().to_string(),
|
||||
to: to.as_str().to_string(),
|
||||
hint,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_rfc_transitions() {
|
||||
assert!(RfcStatus::Draft.can_transition_to(RfcStatus::Accepted));
|
||||
assert!(RfcStatus::Accepted.can_transition_to(RfcStatus::InProgress));
|
||||
assert!(RfcStatus::InProgress.can_transition_to(RfcStatus::Implemented));
|
||||
|
||||
assert!(!RfcStatus::Draft.can_transition_to(RfcStatus::InProgress));
|
||||
assert!(!RfcStatus::Implemented.can_transition_to(RfcStatus::Draft));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_status() {
|
||||
assert_eq!(RfcStatus::parse("draft").unwrap(), RfcStatus::Draft);
|
||||
assert_eq!(RfcStatus::parse("in-progress").unwrap(), RfcStatus::InProgress);
|
||||
assert_eq!(RfcStatus::parse("IN_PROGRESS").unwrap(), RfcStatus::InProgress);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spike_outcome_parse() {
|
||||
assert_eq!(
|
||||
SpikeOutcome::parse("no-action").unwrap(),
|
||||
SpikeOutcome::NoAction
|
||||
);
|
||||
assert_eq!(
|
||||
SpikeOutcome::parse("recommends-implementation").unwrap(),
|
||||
SpikeOutcome::RecommendsImplementation
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,21 +2,48 @@
|
|||
//!
|
||||
//! Handles JSON-RPC requests and routes to appropriate tool handlers.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::{debug, info};
|
||||
|
||||
use blue_core::{detect_blue, DocType, Document, ProjectState, Rfc};
|
||||
|
||||
use crate::error::ServerError;
|
||||
|
||||
/// Blue MCP Server state
|
||||
pub struct BlueServer {
|
||||
/// Current working directory
|
||||
cwd: Option<std::path::PathBuf>,
|
||||
cwd: Option<PathBuf>,
|
||||
/// Cached project state
|
||||
state: Option<ProjectState>,
|
||||
}
|
||||
|
||||
impl BlueServer {
|
||||
pub fn new() -> Self {
|
||||
Self { cwd: None }
|
||||
Self {
|
||||
cwd: None,
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to load project state for the current directory
|
||||
fn ensure_state(&mut self) -> Result<&ProjectState, ServerError> {
|
||||
if self.state.is_none() {
|
||||
let cwd = self.cwd.as_ref().ok_or(ServerError::BlueNotDetected)?;
|
||||
let home = detect_blue(cwd).map_err(|_| ServerError::BlueNotDetected)?;
|
||||
|
||||
// Try to get project name from the current path
|
||||
let project = home.project_name.clone().unwrap_or_else(|| "default".to_string());
|
||||
|
||||
let state = ProjectState::load(home, &project)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
|
||||
self.state = Some(state);
|
||||
}
|
||||
|
||||
self.state.as_ref().ok_or(ServerError::BlueNotDetected)
|
||||
}
|
||||
|
||||
/// Handle a JSON-RPC request
|
||||
|
|
@ -90,7 +117,7 @@ impl BlueServer {
|
|||
"tools": [
|
||||
{
|
||||
"name": "blue_status",
|
||||
"description": "Get project status. Returns active work, ready items, and recommendations.",
|
||||
"description": "Get project status. Returns active work, ready items, stalled items, and recommendations.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -127,10 +154,149 @@ impl BlueServer {
|
|||
"title": {
|
||||
"type": "string",
|
||||
"description": "RFC title in kebab-case"
|
||||
},
|
||||
"problem": {
|
||||
"type": "string",
|
||||
"description": "Problem statement or summary"
|
||||
},
|
||||
"source_spike": {
|
||||
"type": "string",
|
||||
"description": "Source spike title that led to this RFC"
|
||||
}
|
||||
},
|
||||
"required": ["title"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "blue_rfc_get",
|
||||
"description": "Get an RFC by title or number.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"type": "string",
|
||||
"description": "Current working directory"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "RFC title or number"
|
||||
}
|
||||
},
|
||||
"required": ["title"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "blue_rfc_update_status",
|
||||
"description": "Update an RFC's status (draft -> accepted -> in-progress -> implemented).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"type": "string",
|
||||
"description": "Current working directory"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "RFC title"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "New status: accepted, in-progress, implemented, or superseded",
|
||||
"enum": ["accepted", "in-progress", "implemented", "superseded"]
|
||||
}
|
||||
},
|
||||
"required": ["title", "status"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "blue_rfc_plan",
|
||||
"description": "Create or update an implementation plan with checkboxes for an RFC.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"type": "string",
|
||||
"description": "Current working directory"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "RFC title"
|
||||
},
|
||||
"tasks": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "List of implementation tasks"
|
||||
}
|
||||
},
|
||||
"required": ["title", "tasks"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "blue_rfc_task_complete",
|
||||
"description": "Mark a task as complete in an RFC plan.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"type": "string",
|
||||
"description": "Current working directory"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "RFC title"
|
||||
},
|
||||
"task": {
|
||||
"type": "string",
|
||||
"description": "Task index (1-based) or substring to match"
|
||||
}
|
||||
},
|
||||
"required": ["title", "task"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "blue_rfc_validate",
|
||||
"description": "Check RFC status and plan completion.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"type": "string",
|
||||
"description": "Current working directory"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "RFC title"
|
||||
}
|
||||
},
|
||||
"required": ["title"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "blue_search",
|
||||
"description": "Search documents using full-text search.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"type": "string",
|
||||
"description": "Current working directory"
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query"
|
||||
},
|
||||
"doc_type": {
|
||||
"type": "string",
|
||||
"description": "Filter by document type",
|
||||
"enum": ["rfc", "spike", "adr", "decision"]
|
||||
},
|
||||
"limit": {
|
||||
"type": "number",
|
||||
"description": "Maximum results to return (default: 10)"
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
|
@ -144,7 +310,9 @@ impl BlueServer {
|
|||
// Extract cwd from arguments if present
|
||||
if let Some(ref args) = call.arguments {
|
||||
if let Some(cwd) = args.get("cwd").and_then(|v| v.as_str()) {
|
||||
self.cwd = Some(std::path::PathBuf::from(cwd));
|
||||
self.cwd = Some(PathBuf::from(cwd));
|
||||
// Reset state when cwd changes
|
||||
self.state = None;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -152,6 +320,12 @@ impl BlueServer {
|
|||
"blue_status" => self.handle_status(&call.arguments),
|
||||
"blue_next" => self.handle_next(&call.arguments),
|
||||
"blue_rfc_create" => self.handle_rfc_create(&call.arguments),
|
||||
"blue_rfc_get" => self.handle_rfc_get(&call.arguments),
|
||||
"blue_rfc_update_status" => self.handle_rfc_update_status(&call.arguments),
|
||||
"blue_rfc_plan" => self.handle_rfc_plan(&call.arguments),
|
||||
"blue_rfc_task_complete" => self.handle_rfc_task_complete(&call.arguments),
|
||||
"blue_rfc_validate" => self.handle_rfc_validate(&call.arguments),
|
||||
"blue_search" => self.handle_search(&call.arguments),
|
||||
_ => Err(ServerError::ToolNotFound(call.name)),
|
||||
}?;
|
||||
|
||||
|
|
@ -164,36 +338,381 @@ impl BlueServer {
|
|||
}))
|
||||
}
|
||||
|
||||
fn handle_status(&self, _args: &Option<Value>) -> Result<Value, ServerError> {
|
||||
fn handle_status(&mut self, _args: &Option<Value>) -> Result<Value, ServerError> {
|
||||
match self.ensure_state() {
|
||||
Ok(state) => {
|
||||
let summary = state.status_summary();
|
||||
Ok(json!({
|
||||
"status": "success",
|
||||
"message": blue_core::voice::speak("Checking status. Give me a moment.")
|
||||
"active": summary.active,
|
||||
"ready": summary.ready,
|
||||
"stalled": summary.stalled,
|
||||
"drafts": summary.drafts,
|
||||
"hint": summary.hint
|
||||
}))
|
||||
}
|
||||
|
||||
fn handle_next(&self, _args: &Option<Value>) -> Result<Value, ServerError> {
|
||||
Err(_) => {
|
||||
// Fall back to a simple message if not in a Blue project
|
||||
Ok(json!({
|
||||
"status": "success",
|
||||
"message": blue_core::voice::speak("Looking at what's ready. One moment.")
|
||||
"message": blue_core::voice::error(
|
||||
"Can't find Blue here",
|
||||
"Run 'blue init' to set up this project"
|
||||
),
|
||||
"active": [],
|
||||
"ready": [],
|
||||
"stalled": [],
|
||||
"drafts": []
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_next(&mut self, _args: &Option<Value>) -> Result<Value, ServerError> {
|
||||
match self.ensure_state() {
|
||||
Ok(state) => {
|
||||
let summary = state.status_summary();
|
||||
|
||||
let recommendations = if !summary.stalled.is_empty() {
|
||||
vec![format!(
|
||||
"'{}' might be stalled. Check if work is still in progress.",
|
||||
summary.stalled[0].title
|
||||
)]
|
||||
} else if !summary.ready.is_empty() {
|
||||
vec![format!(
|
||||
"'{}' is ready to implement. Run 'blue worktree create {}' to start.",
|
||||
summary.ready[0].title, summary.ready[0].title
|
||||
)]
|
||||
} else if !summary.drafts.is_empty() {
|
||||
vec![format!(
|
||||
"'{}' is in draft. Review and accept it when ready.",
|
||||
summary.drafts[0].title
|
||||
)]
|
||||
} else if !summary.active.is_empty() {
|
||||
vec![format!(
|
||||
"{} item(s) in progress. Keep at it.",
|
||||
summary.active.len()
|
||||
)]
|
||||
} else {
|
||||
vec!["Nothing pressing. Good time to plan something new.".to_string()]
|
||||
};
|
||||
|
||||
Ok(json!({
|
||||
"recommendations": recommendations,
|
||||
"hint": summary.hint
|
||||
}))
|
||||
}
|
||||
Err(_) => {
|
||||
Ok(json!({
|
||||
"recommendations": [
|
||||
"Run 'blue init' to set up this project first."
|
||||
],
|
||||
"hint": "Can't find Blue here."
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_rfc_create(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
|
||||
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
fn handle_rfc_create(&self, args: &Option<Value>) -> Result<Value, ServerError> {
|
||||
let title = args
|
||||
.as_ref()
|
||||
.and_then(|a| a.get("title"))
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let problem = args.get("problem").and_then(|v| v.as_str());
|
||||
let source_spike = args.get("source_spike").and_then(|v| v.as_str());
|
||||
|
||||
match self.ensure_state() {
|
||||
Ok(state) => {
|
||||
// Get next RFC number
|
||||
let number = state.store.next_number(DocType::Rfc)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
|
||||
// Create document in store
|
||||
let mut doc = Document::new(DocType::Rfc, title, "draft");
|
||||
doc.number = Some(number);
|
||||
|
||||
let id = state.store.add_document(&doc)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
|
||||
// Generate markdown
|
||||
let mut rfc = Rfc::new(title);
|
||||
if let Some(p) = problem {
|
||||
rfc.problem = Some(p.to_string());
|
||||
}
|
||||
if let Some(s) = source_spike {
|
||||
rfc.source_spike = Some(s.to_string());
|
||||
}
|
||||
|
||||
let markdown = rfc.to_markdown(number as u32);
|
||||
|
||||
Ok(json!({
|
||||
"status": "success",
|
||||
"id": id,
|
||||
"number": number,
|
||||
"title": title,
|
||||
"markdown": markdown,
|
||||
"message": blue_core::voice::success(
|
||||
&format!("Created RFC {:04}: '{}'", number, title),
|
||||
Some("Want me to help fill in the details?")
|
||||
)
|
||||
}))
|
||||
}
|
||||
Err(_) => {
|
||||
// Create RFC without persistence (just generate markdown)
|
||||
let rfc = Rfc::new(title);
|
||||
let markdown = rfc.to_markdown(1);
|
||||
|
||||
Ok(json!({
|
||||
"status": "success",
|
||||
"number": 1,
|
||||
"title": title,
|
||||
"markdown": markdown,
|
||||
"message": blue_core::voice::success(
|
||||
&format!("Created RFC '{}'", title),
|
||||
Some("Want me to help fill in the details?"),
|
||||
Some("Note: Not persisted - run 'blue init' to enable storage.")
|
||||
)
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_rfc_get(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
|
||||
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let title = args
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let state = self.ensure_state()?;
|
||||
|
||||
let doc = state.store.find_document(DocType::Rfc, title)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
|
||||
// Get tasks if any
|
||||
let tasks = if let Some(id) = doc.id {
|
||||
state.store.get_tasks(id).unwrap_or_default()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let progress = if let Some(id) = doc.id {
|
||||
state.store.get_task_progress(id).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(json!({
|
||||
"id": doc.id,
|
||||
"number": doc.number,
|
||||
"title": doc.title,
|
||||
"status": doc.status,
|
||||
"file_path": doc.file_path,
|
||||
"created_at": doc.created_at,
|
||||
"updated_at": doc.updated_at,
|
||||
"tasks": tasks.iter().map(|t| json!({
|
||||
"index": t.task_index,
|
||||
"description": t.description,
|
||||
"completed": t.completed
|
||||
})).collect::<Vec<_>>(),
|
||||
"progress": progress.map(|p| json!({
|
||||
"completed": p.completed,
|
||||
"total": p.total,
|
||||
"percentage": p.percentage
|
||||
}))
|
||||
}))
|
||||
}
|
||||
|
||||
fn handle_rfc_update_status(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
|
||||
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let title = args
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let status = args
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let state = self.ensure_state()?;
|
||||
|
||||
state.store.update_document_status(DocType::Rfc, title, status)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
|
||||
Ok(json!({
|
||||
"status": "success",
|
||||
"title": title,
|
||||
"new_status": status,
|
||||
"message": blue_core::voice::success(
|
||||
&format!("Updated '{}' to {}", title, status),
|
||||
None
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
fn handle_rfc_plan(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
|
||||
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let title = args
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let tasks: Vec<String> = args
|
||||
.get("tasks")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let state = self.ensure_state()?;
|
||||
|
||||
let doc = state.store.find_document(DocType::Rfc, title)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
|
||||
let doc_id = doc.id.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
state.store.set_tasks(doc_id, &tasks)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
|
||||
Ok(json!({
|
||||
"status": "success",
|
||||
"title": title,
|
||||
"task_count": tasks.len(),
|
||||
"message": blue_core::voice::success(
|
||||
&format!("Set {} tasks for '{}'", tasks.len(), title),
|
||||
Some("Mark them complete as you go with blue_rfc_task_complete.")
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
fn handle_rfc_task_complete(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
|
||||
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let title = args
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let task = args
|
||||
.get("task")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let state = self.ensure_state()?;
|
||||
|
||||
let doc = state.store.find_document(DocType::Rfc, title)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
|
||||
let doc_id = doc.id.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
// Parse task index or find by substring
|
||||
let task_index = if let Ok(idx) = task.parse::<i32>() {
|
||||
idx
|
||||
} else {
|
||||
// Find task by substring
|
||||
let tasks = state.store.get_tasks(doc_id)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
|
||||
tasks.iter()
|
||||
.find(|t| t.description.to_lowercase().contains(&task.to_lowercase()))
|
||||
.map(|t| t.task_index)
|
||||
.ok_or(ServerError::InvalidParams)?
|
||||
};
|
||||
|
||||
state.store.complete_task(doc_id, task_index)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
|
||||
let progress = state.store.get_task_progress(doc_id)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
|
||||
Ok(json!({
|
||||
"status": "success",
|
||||
"title": title,
|
||||
"task_index": task_index,
|
||||
"progress": {
|
||||
"completed": progress.completed,
|
||||
"total": progress.total,
|
||||
"percentage": progress.percentage
|
||||
},
|
||||
"message": blue_core::voice::success(
|
||||
&format!("Task {} complete. {} of {} done ({}%)",
|
||||
task_index, progress.completed, progress.total, progress.percentage),
|
||||
None
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
fn handle_rfc_validate(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
|
||||
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let title = args
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let state = self.ensure_state()?;
|
||||
|
||||
let doc = state.store.find_document(DocType::Rfc, title)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
|
||||
let doc_id = doc.id.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let progress = state.store.get_task_progress(doc_id)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
|
||||
let message = if progress.total == 0 {
|
||||
"No plan defined yet. Use blue_rfc_plan to add tasks.".to_string()
|
||||
} else if progress.percentage == 100 {
|
||||
format!("All {} tasks complete. Ready to mark as implemented.", progress.total)
|
||||
} else if progress.percentage >= 70 {
|
||||
format!("{}% done ({}/{}). Getting close.", progress.percentage, progress.completed, progress.total)
|
||||
} else {
|
||||
format!("{}% done ({}/{}). Keep going.", progress.percentage, progress.completed, progress.total)
|
||||
};
|
||||
|
||||
Ok(json!({
|
||||
"title": doc.title,
|
||||
"status": doc.status,
|
||||
"progress": {
|
||||
"completed": progress.completed,
|
||||
"total": progress.total,
|
||||
"percentage": progress.percentage
|
||||
},
|
||||
"message": message
|
||||
}))
|
||||
}
|
||||
|
||||
fn handle_search(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
|
||||
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let query = args
|
||||
.get("query")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let doc_type = args.get("doc_type").and_then(|v| v.as_str()).and_then(DocType::from_str);
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
|
||||
|
||||
let state = self.ensure_state()?;
|
||||
|
||||
let results = state.store.search_documents(query, doc_type, limit)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
|
||||
Ok(json!({
|
||||
"query": query,
|
||||
"count": results.len(),
|
||||
"results": results.iter().map(|r| json!({
|
||||
"title": r.document.title,
|
||||
"type": r.document.doc_type.as_str(),
|
||||
"status": r.document.status,
|
||||
"score": r.score
|
||||
})).collect::<Vec<_>>()
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BlueServer {
|
||||
fn default() -> Self {
|
||||
|
|
|
|||
214
docs/rfcs/0002-port-coherence-functionality.md
Normal file
214
docs/rfcs/0002-port-coherence-functionality.md
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
# RFC 0002: Port Coherence Functionality
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Status** | In Progress |
|
||||
| **Date** | 2026-01-24 |
|
||||
| **Source Spike** | blue-feature-gap-analysis |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Port the essential functionality from coherence-mcp to blue, maintaining Blue's voice and philosophy while gaining the workflow capabilities that make coherence useful.
|
||||
|
||||
## Problem
|
||||
|
||||
Blue currently has ~2% of coherence-mcp's functionality:
|
||||
- 3 MCP tools (vs 35)
|
||||
- 0 handler modules (vs 28)
|
||||
- ~50 LOC core (vs 8,796)
|
||||
- 0 database tables (vs 14)
|
||||
|
||||
Without these capabilities, blue can't manage RFCs, track work, or provide meaningful project status.
|
||||
|
||||
## Proposal
|
||||
|
||||
Port coherence-mcp in 4 phases, adapting to Blue's voice throughout.
|
||||
|
||||
### Phase 1: Foundation
|
||||
|
||||
**Goal:** Basic document management and persistence.
|
||||
|
||||
| Component | Source | Target | Notes |
|
||||
|-----------|--------|--------|-------|
|
||||
| store.rs | alignment-core | blue-core | Rename tables, Blue voice in errors |
|
||||
| documents.rs | alignment-core | blue-core | Keep existing Blue structs, add methods |
|
||||
| state.rs | alignment-core | blue-core | ProjectState → BlueState |
|
||||
| repo.rs | alignment-core | blue-core | detect_alignment → detect_blue |
|
||||
| workflow.rs | alignment-core | blue-core | Status transitions |
|
||||
|
||||
**Database schema:**
|
||||
- documents, document_links, tasks, worktrees, metadata
|
||||
- FTS5 for search
|
||||
- Schema version 1 (fresh start, not migrating from coherence)
|
||||
|
||||
**MCP tools (Phase 1):**
|
||||
- blue_status
|
||||
- blue_next
|
||||
- blue_rfc_create
|
||||
- blue_rfc_get
|
||||
- blue_rfc_update_status
|
||||
- blue_rfc_plan
|
||||
- blue_rfc_validate
|
||||
- blue_rfc_complete
|
||||
- blue_rfc_task_complete
|
||||
|
||||
### Phase 2: Workflow
|
||||
|
||||
**Goal:** Full RFC/Spike/ADR lifecycle + PR workflow.
|
||||
|
||||
| Component | Source | Target |
|
||||
|-----------|--------|--------|
|
||||
| rfc.rs | handlers | blue-mcp/handlers |
|
||||
| spike.rs | handlers | blue-mcp/handlers |
|
||||
| adr.rs | handlers | blue-mcp/handlers |
|
||||
| decision.rs | handlers | blue-mcp/handlers |
|
||||
| worktree.rs | handlers | blue-mcp/handlers |
|
||||
| pr.rs | handlers | blue-mcp/handlers |
|
||||
| release.rs | handlers | blue-mcp/handlers |
|
||||
| search.rs | handlers | blue-mcp/handlers |
|
||||
|
||||
**MCP tools (Phase 2):**
|
||||
- blue_spike_create, blue_spike_complete
|
||||
- blue_adr_create, blue_decision_create
|
||||
- blue_worktree_create, blue_worktree_list, blue_worktree_remove
|
||||
- blue_pr_create, blue_pr_verify, blue_pr_check_item, blue_pr_merge
|
||||
- blue_release_create
|
||||
- blue_search
|
||||
|
||||
### Phase 3: Advanced
|
||||
|
||||
**Goal:** Multi-agent coordination and reminders.
|
||||
|
||||
| Component | Source | Target |
|
||||
|-----------|--------|--------|
|
||||
| staging.rs | handlers | blue-mcp/handlers |
|
||||
| reminder.rs | handlers | blue-mcp/handlers |
|
||||
| session.rs | handlers | blue-mcp/handlers |
|
||||
| env.rs | handlers | blue-mcp/handlers |
|
||||
|
||||
**Database additions:**
|
||||
- staging_locks, staging_lock_queue, staging_deployments
|
||||
- active_sessions
|
||||
- reminders
|
||||
|
||||
### Phase 4: Specialized
|
||||
|
||||
**Goal:** Code intelligence and quality tools.
|
||||
|
||||
| Component | Source | Target |
|
||||
|-----------|--------|--------|
|
||||
| code_store.rs | alignment-core | blue-core |
|
||||
| symbol_extractor.rs | alignment-core | blue-core |
|
||||
| lint.rs | handlers | blue-mcp/handlers |
|
||||
| audit.rs | handlers | blue-mcp/handlers |
|
||||
| guide.rs | handlers | blue-mcp/handlers |
|
||||
|
||||
## What NOT to Port
|
||||
|
||||
- **Parked/Gated items** - Half-implemented in coherence, skip for now
|
||||
- **Post-mortems/Runbooks** - Low usage, add later if needed
|
||||
- **Dialogue tools** - Specialized, port only if needed
|
||||
- **Infrastructure indexing** - Complex, defer to Phase 5
|
||||
|
||||
## Blue's Voice Adaptation
|
||||
|
||||
All ported code must speak as Blue:
|
||||
|
||||
```rust
|
||||
// Coherence style
|
||||
return Err(ServerError::AlignmentNotDetected);
|
||||
|
||||
// Blue style
|
||||
return Err(ServerError::NotHome("Can't find Blue here. Run 'blue init' first?"));
|
||||
```
|
||||
|
||||
```rust
|
||||
// Coherence message
|
||||
"RFC created successfully"
|
||||
|
||||
// Blue message
|
||||
"Created RFC '{}'. Want me to help fill in the details?"
|
||||
```
|
||||
|
||||
## Directory Structure After Port
|
||||
|
||||
```
|
||||
blue/
|
||||
├── crates/
|
||||
│ └── blue-core/
|
||||
│ ├── src/
|
||||
│ │ ├── lib.rs
|
||||
│ │ ├── documents.rs # Document types
|
||||
│ │ ├── store.rs # SQLite persistence
|
||||
│ │ ├── state.rs # Project state
|
||||
│ │ ├── repo.rs # Git operations
|
||||
│ │ ├── workflow.rs # Status transitions
|
||||
│ │ ├── voice.rs # Blue's tone (existing)
|
||||
│ │ └── search.rs # FTS5 search
|
||||
│ └── Cargo.toml
|
||||
│ └── blue-mcp/
|
||||
│ ├── src/
|
||||
│ │ ├── lib.rs
|
||||
│ │ ├── server.rs # MCP server
|
||||
│ │ ├── error.rs # Error types
|
||||
│ │ ├── tools.rs # Tool definitions
|
||||
│ │ └── handlers/
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── rfc.rs
|
||||
│ │ ├── spike.rs
|
||||
│ │ ├── adr.rs
|
||||
│ │ ├── worktree.rs
|
||||
│ │ ├── pr.rs
|
||||
│ │ ├── search.rs
|
||||
│ │ └── ...
|
||||
│ └── Cargo.toml
|
||||
└── apps/
|
||||
└── blue-cli/
|
||||
```
|
||||
|
||||
## Goals
|
||||
|
||||
1. Feature parity with coherence-mcp core workflow
|
||||
2. Blue's voice and philosophy throughout
|
||||
3. Fresh schema (no migration baggage)
|
||||
4. Cleaner code structure from lessons learned
|
||||
|
||||
## Non-Goals
|
||||
|
||||
1. 100% feature parity (skip rarely-used features)
|
||||
2. Backward compatibility with coherence databases
|
||||
3. Supporting both alignment_ and blue_ tool names
|
||||
|
||||
## Implementation Progress
|
||||
|
||||
### Phase 1: Foundation - COMPLETE
|
||||
|
||||
- [x] store.rs - SQLite persistence with schema v1, WAL mode, FTS5 search
|
||||
- [x] documents.rs - Rfc, Spike, Adr, Decision with markdown generation
|
||||
- [x] state.rs - ProjectState with active/ready/stalled/draft items
|
||||
- [x] repo.rs - detect_blue(), worktree operations
|
||||
- [x] workflow.rs - RfcStatus, SpikeOutcome, transitions
|
||||
- [x] 9 MCP tools: blue_status, blue_next, blue_rfc_create, blue_rfc_get,
|
||||
blue_rfc_update_status, blue_rfc_plan, blue_rfc_validate,
|
||||
blue_rfc_task_complete, blue_search
|
||||
- [x] 14 unit tests passing
|
||||
- [x] Blue's voice in all error messages
|
||||
|
||||
### Phase 2-4: Pending
|
||||
|
||||
## Test Plan
|
||||
|
||||
- [ ] blue init creates .blue/ directory structure
|
||||
- [x] blue rfc create persists to SQLite
|
||||
- [x] blue status shows active/ready/stalled items
|
||||
- [x] blue search finds documents by keyword
|
||||
- [x] Blue's voice in all error messages
|
||||
- [ ] Worktree operations work with git
|
||||
|
||||
---
|
||||
|
||||
*"Right then. Quite a bit to port. But we'll take it step by step."*
|
||||
|
||||
— Blue
|
||||
Loading…
Reference in a new issue