use super::discover;
use balls::display;
use balls::error::{BallError, Result};
use balls::participant::Event;
use balls::participant_config::{override_tokens, InvocationOverrides};
use balls::plugin::{self, Rollback};
use balls::ready;
use balls::render_list;
use balls::store::{task_lock, Store};
use balls::task::{NewTaskOpts, Status, Task, TaskType};
use std::env;
pub struct CreateArgs {
pub title: String,
pub priority: u8,
pub task_type: String,
pub parent: Option<String>,
pub dep: Vec<String>,
pub tag: Vec<String>,
pub description: String,
pub target_branch: Option<String>,
pub overrides: InvocationOverrides,
}
pub fn cmd_create(args: CreateArgs) -> Result<()> {
let CreateArgs {
title,
priority,
task_type,
parent,
dep,
tag,
description,
target_branch,
overrides,
} = args;
let store = discover()?;
balls::task::validate_priority(priority)?;
let task_type = TaskType::parse(&task_type)?;
let all = store.all_tasks()?;
if let Some(pid) = &parent {
if !all.iter().any(|t| &t.id == pid) {
return Err(BallError::InvalidTask(format!("parent not found: {pid}")));
}
}
ready::validate_deps(&all, &dep)?;
let opts = NewTaskOpts {
title: title.clone(),
task_type,
priority,
parent,
depends_on: dep.clone(),
description,
tags: tag,
};
let id = balls::task_id::generate_task_id(&store, &title)?;
let mut task = Task::new(opts, id.clone());
task.repo = Some(repo_identity(&store));
task.target_branch = target_branch;
let rb = plugin::state_head(&store)?;
{
let _g = task_lock(&store, &id)?;
store.save_task(&task)?;
store.commit_task(&id, &format!("balls: create {id} - {title}"))?;
}
let tokens = override_tokens(&overrides, false, false);
plugin::finish(
&store,
None,
&task,
Event::Create,
&super::default_identity(),
&overrides,
&tokens,
Rollback::State(rb.as_deref()),
)?;
println!("{id}");
Ok(())
}
fn repo_identity(store: &Store) -> String {
identity_from(git_origin_url(&store.root), &store.root)
}
fn git_origin_url(root: &std::path::Path) -> Option<String> {
let out = balls::git::clean_git_command(root)
.args(["remote", "get-url", "origin"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!url.is_empty()).then_some(url)
}
fn identity_from(url: Option<String>, root: &std::path::Path) -> String {
url.or_else(|| root.file_name().map(|s| s.to_string_lossy().into_owned()))
.unwrap_or_else(|| root.to_string_lossy().into_owned())
}
pub fn cmd_list(
status: Option<String>,
priority: Option<u8>,
parent: Option<String>,
tag: Option<String>,
all: bool,
closed: bool,
json: bool,
) -> Result<()> {
let store = discover()?;
let st = status.as_deref().map(Status::parse).transpose()?;
let want_closed = closed || st.as_ref() == Some(&Status::Closed);
if (want_closed || all) && !balls::archive_recovery::available(&store) {
eprintln!(
"note: closed tasks live only in the git state branch; \
unavailable in this store"
);
}
let mut tasks = if want_closed {
balls::archive_recovery::recover_all(&store)
} else if let Some(s) = &st {
let mut live = store.all_tasks()?;
live.retain(|t| &t.status == s);
live
} else {
let mut live = store.all_tasks()?;
if all {
live.extend(balls::archive_recovery::recover_all(&store));
} else {
live.retain(|t| t.status != Status::Closed);
}
live
};
if let Some(p) = priority {
tasks.retain(|t| t.priority == p);
}
if let Some(pid) = &parent {
tasks.retain(|t| t.parent.as_deref() == Some(pid.as_str()));
}
if let Some(tg) = &tag {
tasks.retain(|t| t.tags.iter().any(|x| x == tg));
}
tasks.sort_by(|a, b| {
a.priority
.cmp(&b.priority)
.then_with(|| a.created_at.cmp(&b.created_at))
});
if json {
println!("{}", serde_json::to_string_pretty(&tasks)?);
} else {
let flat = want_closed || all || st.is_some();
let me = super::default_identity();
let cols = terminal_columns();
let all = store.all_tasks()?;
let ctx = render_list::Ctx {
d: display::global(),
me: &me,
columns: cols,
all: &all,
};
print!("{}", render_list::render(&tasks, flat, &ctx));
}
Ok(())
}
fn terminal_columns() -> usize {
env::var("COLUMNS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(100)
}
pub fn cmd_show(id: String, json: bool, verbose: bool) -> Result<()> {
let store = discover()?;
let task = match store.load_task(&id) {
Err(BallError::TaskNotFound(_)) => balls::archive_recovery::recover_one(&store, &id)
.ok_or(BallError::TaskNotFound(id.clone()))?,
other => other?,
};
let all = store.all_tasks()?;
let delivery = store
.load_config()
.and_then(|c| c.integration_branch_for(&store.root, task.target_branch.as_deref()))
.map_or(
balls::delivery::Delivery {
sha: None,
hint_stale: false,
},
|b| balls::delivery::resolve(&store.root, &b, &task),
);
if json {
let blocked = ready::is_dep_blocked(&all, &task);
let children: Vec<&Task> = ready::children_of(&all, &id);
let mut pretty = serde_json::json!({
"task": task,
"dep_blocked": blocked,
"children": children.iter().map(|t| &t.id).collect::<Vec<_>>(),
"closed_children": task.closed_children,
"completion": ready::completion(&all, &id),
"delivered_in_resolved": delivery.sha,
"delivered_in_hint_stale": delivery.hint_stale,
});
if task.task_type.is_epic() {
let (closed, total) = balls::progress::counts(&all, &id);
pretty["progress"] =
serde_json::json!({ "closed": closed, "total": total });
}
println!("{}", serde_json::to_string_pretty(&pretty)?);
return Ok(());
}
let me = super::default_identity();
let ctx = balls::render_show::Ctx {
d: display::global(),
me: &me,
columns: terminal_columns(),
verbose,
now: chrono::Utc::now(),
};
print!(
"{}",
balls::render_show::render(&task, &all, &delivery, &store.root, &ctx),
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::identity_from;
use std::path::Path;
#[test]
fn identity_prefers_url() {
let got = identity_from(Some("git@h:proj.git".into()), Path::new("/x/y"));
assert_eq!(got, "git@h:proj.git");
}
#[test]
fn identity_falls_back_to_basename() {
assert_eq!(identity_from(None, Path::new("/x/myrepo")), "myrepo");
}
#[test]
fn identity_falls_back_to_path_when_no_basename() {
assert_eq!(identity_from(None, Path::new("/")), "/");
}
}