use super::{default_identity, discover};
use balls::error::{BallError, Result};
use balls::participant::Event;
use balls::plugin;
use balls::policy::{self, ClaimPolicy, LocalConfig, SyncOverride};
use balls::store::{task_lock, Store};
use balls::task::{Status, Task, TaskType};
use balls::{task_io, worktree};
pub fn cmd_claim(
id: String,
identity: Option<String>,
no_worktree: bool,
sync: bool,
no_sync: bool,
) -> Result<()> {
let store = discover()?;
let ident = identity.unwrap_or_else(default_identity);
if store.no_git && !no_worktree {
return Err(BallError::Other(
"no git repo: use `bl claim --no-worktree` to claim without a worktree".into(),
));
}
let claim_policy = resolve_claim_policy(&store, sync, no_sync)?;
if no_worktree {
worktree::claim_no_worktree(&store, &id, &ident, claim_policy)?;
println!("claimed {id} (no worktree)");
} else {
let path = worktree::create_worktree(&store, &id, &ident, claim_policy)?;
let task = store.load_task(&id)?;
if plugin::dispatch_push(&store, &task, Event::Claim, &ident).is_ok() {
let main_branch = balls::git::git_current_branch(&store.root)?;
let _ = balls::git::git_merge(&path, &main_branch);
}
println!("{}", path.display());
}
Ok(())
}
fn resolve_claim_policy(store: &Store, sync: bool, no_sync: bool) -> Result<ClaimPolicy> {
let cli = match (sync, no_sync) {
(true, false) => SyncOverride::Sync,
(false, true) => SyncOverride::NoSync,
_ => SyncOverride::Unset,
};
let repo_default = store
.load_config()
.map(|c| c.require_remote_on_claim)
.unwrap_or(false);
let local = LocalConfig::load(store)?;
Ok(policy::resolve(repo_default, local.as_ref(), cli))
}
pub fn cmd_review(
id: String,
message: Vec<String>,
identity: Option<String>,
sync: bool,
no_sync: bool,
) -> Result<()> {
let store = discover()?;
let ident = identity.unwrap_or_else(default_identity);
let message = balls::commit_msg::join_messages(&message);
if store.no_git {
balls::review::review_no_git(&store, &id, message.as_deref(), &ident)?;
} else {
let (cli, cfg, local) = sync_inputs(&store, sync, no_sync)?;
let repo = cfg.as_ref().is_some_and(|c| c.require_remote_on_review);
let policy = policy::resolve_review(repo, local.as_ref(), cli);
balls::review::review_worktree(&store, &id, message.as_deref(), &ident, policy)?;
}
let task = store.load_task(&id)?;
let _ = plugin::dispatch_push(&store, &task, Event::Review, &ident);
println!("reviewed {id} — from the repo root, run `bl close {id} -m \"...\"` to finish");
Ok(())
}
pub fn cmd_close(
id: String,
message: Vec<String>,
identity: Option<String>,
sync: bool,
no_sync: bool,
) -> Result<()> {
let store = discover()?;
let ident = identity.unwrap_or_else(default_identity);
let message = balls::commit_msg::join_messages(&message);
let task = if store.no_git {
balls::review::close_no_git(&store, &id, message.as_deref(), &ident)?
} else {
let (cli, cfg, local) = sync_inputs(&store, sync, no_sync)?;
let repo = cfg.as_ref().is_some_and(|c| c.require_remote_on_close);
let policy = policy::resolve_close(repo, local.as_ref(), cli);
balls::review::close_worktree(&store, &id, message.as_deref(), &ident, policy)?
};
let _ = plugin::dispatch_push(&store, &task, Event::Close, &ident);
println!("closed {id}");
if !store.no_git {
println!("{}", store.root.display());
}
Ok(())
}
fn sync_inputs(
store: &Store,
sync: bool,
no_sync: bool,
) -> Result<(SyncOverride, Option<balls::config::Config>, Option<LocalConfig>)> {
let cli = match (sync, no_sync) {
(true, false) => SyncOverride::Sync,
(false, true) => SyncOverride::NoSync,
_ => SyncOverride::Unset,
};
let cfg = store.load_config().ok();
let local = LocalConfig::load(store)?;
Ok((cli, cfg, local))
}
pub fn cmd_drop(id: String, force: bool) -> Result<()> {
let store = discover()?;
if store.no_git {
worktree::drop_no_worktree(&store, &id)?;
} else {
worktree::drop_worktree(&store, &id, force)?;
}
println!("dropped {id}");
Ok(())
}
pub fn cmd_update(
id: String,
assignments: Vec<String>,
note: Option<String>,
identity: Option<String>,
) -> Result<()> {
let store = discover()?;
let ident = identity.unwrap_or_else(default_identity);
let closing = assignments.iter().any(|a| a == "status=closed");
let task = {
let _g = task_lock(&store, &id)?;
let mut task = store.load_task(&id)?;
if closing && task.claimed_by.is_some() {
return Err(BallError::InvalidTask(
"use `bl close` for claimed tasks (handles worktree teardown and merge)".into(),
));
}
if closing {
balls::review::enforce_gates(&store, &task)?;
}
for assign in &assignments {
let (field, value) = assign.split_once('=').ok_or_else(|| {
BallError::InvalidTask(format!("expected field=value, got: {assign}"))
})?;
apply_field(&mut task, field, value)?;
}
if closing {
task.closed_at = Some(chrono::Utc::now());
}
task.touch();
if closing {
let msg = match ¬e {
Some(n) => format!("balls: close {} - {}\n\n{}", id, task.title, n),
None => format!("balls: close {} - {}", id, task.title),
};
let _ = &ident; store.close_and_archive(&task, &msg)?;
} else {
store.save_task(&task)?;
if let Some(n) = ¬e {
task_io::append_note_to(&store.task_path(&id)?, &ident, n)?;
}
store.commit_task(&id, &format!("balls: update {} - {}", id, task.title))?;
}
task
};
let event = if closing { Event::Close } else { Event::Update };
let _ = plugin::dispatch_push(&store, &task, event, &ident);
if closing {
println!("closed and archived {id}");
} else {
println!("updated {id}");
}
Ok(())
}
fn apply_field(task: &mut Task, field: &str, value: &str) -> Result<()> {
match field {
"title" => task.title = value.to_string(),
"priority" => task.priority = balls::task::parse_priority(value)?,
"status" => task.status = Status::parse(value)?,
"type" => task.task_type = TaskType::parse(value)?,
"parent" => {
task.parent = if value.is_empty() || value == "null" {
None
} else {
Some(value.to_string())
};
}
"description" => task.description = value.to_string(),
_ => {
return Err(BallError::InvalidTask(format!(
"unknown or unwritable field: {field}"
)));
}
}
Ok(())
}