RFC 0014: Workflow Enforcement Parity (#1)
Co-authored-by: Eric Garcia <eric.garcia@gmail.com> Co-committed-by: Eric Garcia <eric.garcia@gmail.com>
This commit is contained in:
parent
1afdd05ea6
commit
a07737f3dc
5 changed files with 132 additions and 32 deletions
|
|
@ -35,4 +35,4 @@ pub use repo::{detect_blue, BlueHome, RepoError, WorktreeInfo};
|
|||
pub use state::{ItemType, ProjectState, StateError, StatusSummary, WorkItem};
|
||||
pub use store::{DocType, Document, DocumentStore, FileIndexEntry, IndexSearchResult, IndexStatus, LinkType, Reminder, ReminderStatus, SearchResult, Session, SessionType, StagingLock, StagingLockQueueEntry, StagingLockResult, StoreError, SymbolIndexEntry, Task as StoreTask, TaskProgress, Worktree, INDEX_PROMPT_VERSION};
|
||||
pub use voice::*;
|
||||
pub use workflow::{PrdStatus, RfcStatus, SpikeOutcome as WorkflowSpikeOutcome, SpikeStatus, WorkflowError};
|
||||
pub use workflow::{PrdStatus, RfcStatus, SpikeOutcome as WorkflowSpikeOutcome, SpikeStatus, WorkflowError, validate_rfc_transition};
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ pub enum ServerError {
|
|||
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Workflow error: {0}")]
|
||||
Workflow(String),
|
||||
}
|
||||
|
||||
impl ServerError {
|
||||
|
|
@ -41,6 +44,7 @@ impl ServerError {
|
|||
ServerError::StateLoadFailed(_) => -32001,
|
||||
ServerError::CommandFailed(_) => -32002,
|
||||
ServerError::NotFound(_) => -32003,
|
||||
ServerError::Workflow(_) => -32004,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
use std::process::Command;
|
||||
|
||||
use blue_core::{CreatePrOpts, MergeStrategy, ProjectState, create_forge_cached, detect_forge_type_cached, parse_git_url};
|
||||
use blue_core::{CreatePrOpts, DocType, MergeStrategy, ProjectState, create_forge_cached, detect_forge_type_cached, parse_git_url};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::error::ServerError;
|
||||
|
|
@ -41,6 +41,36 @@ pub enum TaskCategory {
|
|||
pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
|
||||
let rfc = args.get("rfc").and_then(|v| v.as_str());
|
||||
|
||||
// If RFC is provided, validate workflow state (RFC 0014)
|
||||
if let Some(rfc_title) = rfc {
|
||||
// Check RFC exists and has worktree
|
||||
if let Ok(doc) = state.store.find_document(DocType::Rfc, rfc_title) {
|
||||
// Warn if RFC isn't implemented yet
|
||||
if doc.status != "implemented" && doc.status != "in-progress" {
|
||||
return Ok(json!({
|
||||
"status": "error",
|
||||
"message": blue_core::voice::error(
|
||||
&format!("RFC '{}' is {} - complete implementation first", rfc_title, doc.status),
|
||||
"Use blue_rfc_complete after finishing work"
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
// Check worktree exists
|
||||
if let Some(doc_id) = doc.id {
|
||||
if state.store.get_worktree(doc_id).ok().flatten().is_none() {
|
||||
return Ok(json!({
|
||||
"status": "warning",
|
||||
"message": blue_core::voice::error(
|
||||
"No worktree for this RFC",
|
||||
"PRs usually come from worktrees. Proceed with caution."
|
||||
)
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If RFC is provided, format title as "RFC NNNN: Title Case Name"
|
||||
let title = if let Some(rfc_title) = rfc {
|
||||
let (stripped, number) = strip_rfc_number_prefix(rfc_title);
|
||||
|
|
|
|||
|
|
@ -119,9 +119,30 @@ pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, Server
|
|||
}));
|
||||
}
|
||||
|
||||
// Check RFC has a plan (RFC 0014: plan enforcement)
|
||||
let doc_id = doc.id.ok_or(ServerError::InvalidParams)?;
|
||||
let tasks = state
|
||||
.store
|
||||
.get_tasks(doc_id)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
|
||||
if tasks.is_empty() {
|
||||
return Ok(json!({
|
||||
"status": "error",
|
||||
"message": blue_core::voice::error(
|
||||
&format!("RFC '{}' needs a plan before creating worktree", title),
|
||||
"Create a plan first with blue_rfc_plan"
|
||||
),
|
||||
"next_action": {
|
||||
"tool": "blue_rfc_plan",
|
||||
"args": { "title": title },
|
||||
"hint": "Create implementation tasks before starting work"
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Check if worktree already exists
|
||||
if let Some(id) = doc.id {
|
||||
if let Ok(Some(_existing)) = state.store.get_worktree(id) {
|
||||
if let Ok(Some(_existing)) = state.store.get_worktree(doc_id) {
|
||||
return Ok(json!({
|
||||
"status": "error",
|
||||
"message": blue_core::voice::error(
|
||||
|
|
@ -130,7 +151,6 @@ pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, Server
|
|||
)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Create branch name and worktree path (RFC 0007: strip number prefix)
|
||||
let (stripped_name, _rfc_number) = strip_rfc_number_prefix(title);
|
||||
|
|
@ -144,7 +164,6 @@ pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, Server
|
|||
match blue_core::repo::create_worktree(&repo, &branch_name, &worktree_path) {
|
||||
Ok(()) => {
|
||||
// Record in store
|
||||
if let Some(doc_id) = doc.id {
|
||||
let wt = StoreWorktree {
|
||||
id: None,
|
||||
document_id: doc_id,
|
||||
|
|
@ -153,7 +172,6 @@ pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, Server
|
|||
created_at: None,
|
||||
};
|
||||
let _ = state.store.add_worktree(&wt);
|
||||
}
|
||||
|
||||
// Update RFC status to in-progress if accepted
|
||||
if doc.status == "accepted" {
|
||||
|
|
@ -479,4 +497,23 @@ mod tests {
|
|||
assert_eq!(stripped, "0007feature");
|
||||
assert_eq!(number, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_worktree_requires_plan() {
|
||||
use blue_core::{Document, ProjectState};
|
||||
|
||||
let state = ProjectState::for_test();
|
||||
|
||||
// Create an accepted RFC without a plan
|
||||
let mut doc = Document::new(DocType::Rfc, "test-rfc", "accepted");
|
||||
doc.number = Some(1);
|
||||
state.store.add_document(&doc).unwrap();
|
||||
|
||||
// Try to create worktree - should fail due to missing plan
|
||||
let args = serde_json::json!({ "title": "test-rfc" });
|
||||
let result = handle_create(&state, &args).unwrap();
|
||||
|
||||
assert_eq!(result["status"], "error");
|
||||
assert!(result["message"].as_str().unwrap().contains("needs a plan"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use serde::Deserialize;
|
|||
use serde_json::{json, Value};
|
||||
use tracing::{debug, info};
|
||||
|
||||
use blue_core::{detect_blue, DocType, Document, ProjectState, Rfc};
|
||||
use blue_core::{detect_blue, DocType, Document, ProjectState, Rfc, RfcStatus, validate_rfc_transition};
|
||||
|
||||
use crate::error::ServerError;
|
||||
|
||||
|
|
@ -2398,39 +2398,68 @@ impl BlueServer {
|
|||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let status = args
|
||||
let status_str = args
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let state = self.ensure_state()?;
|
||||
|
||||
// Find the document to get its file path
|
||||
// Find the document to get its file path and current status
|
||||
let doc = state.store.find_document(DocType::Rfc, title)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
|
||||
// Parse statuses and validate transition (RFC 0014)
|
||||
let current_status = RfcStatus::parse(&doc.status)
|
||||
.map_err(|e| ServerError::Workflow(e.to_string()))?;
|
||||
let target_status = RfcStatus::parse(status_str)
|
||||
.map_err(|e| ServerError::Workflow(e.to_string()))?;
|
||||
|
||||
// Validate the transition
|
||||
validate_rfc_transition(current_status, target_status)
|
||||
.map_err(|e| ServerError::Workflow(e.to_string()))?;
|
||||
|
||||
// Check for worktree if going to in-progress (RFC 0011)
|
||||
let has_worktree = state.has_worktree(title);
|
||||
let worktree_warning = if status == "in-progress" && !has_worktree {
|
||||
let worktree_warning = if status_str == "in-progress" && !has_worktree {
|
||||
Some("No worktree exists for this RFC. Consider using blue_worktree_create for isolated development.")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Update database
|
||||
state.store.update_document_status(DocType::Rfc, title, status)
|
||||
state.store.update_document_status(DocType::Rfc, title, status_str)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
|
||||
// Update markdown file (RFC 0008)
|
||||
let file_updated = if let Some(ref file_path) = doc.file_path {
|
||||
let full_path = state.home.docs_path.join(file_path);
|
||||
blue_core::update_markdown_status(&full_path, status).unwrap_or(false)
|
||||
blue_core::update_markdown_status(&full_path, status_str).unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// Conversational hints guide Claude to next action (RFC 0014)
|
||||
let hint = match target_status {
|
||||
RfcStatus::Accepted => Some(
|
||||
"RFC accepted. Ask the user: 'Ready to begin implementation? \
|
||||
I'll create a worktree and set up the environment.'"
|
||||
),
|
||||
RfcStatus::InProgress => Some(
|
||||
"Implementation started. Work in the worktree, mark plan tasks \
|
||||
as you complete them."
|
||||
),
|
||||
RfcStatus::Implemented => Some(
|
||||
"Implementation complete. Ask the user: 'Ready to create a PR?'"
|
||||
),
|
||||
RfcStatus::Superseded => Some(
|
||||
"RFC superseded. The newer RFC takes precedence."
|
||||
),
|
||||
RfcStatus::Draft => None,
|
||||
};
|
||||
|
||||
// Build next_action for accepted status (RFC 0011)
|
||||
let next_action = if status == "accepted" {
|
||||
let next_action = if status_str == "accepted" {
|
||||
Some(json!({
|
||||
"tool": "blue_worktree_create",
|
||||
"args": { "title": title },
|
||||
|
|
@ -2443,11 +2472,11 @@ impl BlueServer {
|
|||
let mut response = json!({
|
||||
"status": "success",
|
||||
"title": title,
|
||||
"new_status": status,
|
||||
"new_status": status_str,
|
||||
"file_updated": file_updated,
|
||||
"message": blue_core::voice::success(
|
||||
&format!("Updated '{}' to {}", title, status),
|
||||
None
|
||||
&format!("Updated '{}' to {}", title, status_str),
|
||||
hint
|
||||
)
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue