use std::io::Write;
use cliban_core::contexts::issues::{CreateIssue, ListOpts, UpdateIssue};
use cliban_core::contexts::{claims, issues, milestones, relations};
use cliban_core::schema::{Issue, ISSUE_PRIORITIES, ISSUE_STATUSES};
use cliban_core::sections::replace_section;
use cliban_core::time::{format_date, format_usec, parse_date};
use cliban_core::Store;
use chrono::Utc;
use rusqlite::OptionalExtension;
use crate::descmd;
use crate::descmd::find_section;
use crate::errors::{CliError, CliResult};
use crate::output::{
build_issue_json, write_issue_table, Detail, IssueJsonInputs, IssueRow, Mode, RelationOut,
};
use crate::store_open;
#[derive(clap::Args)]
pub struct IssueArgs {
#[command(subcommand)]
pub cmd: IssueCmd,
}
#[derive(clap::Subcommand)]
pub enum IssueCmd {
Add(AddArgs),
Show(ShowArgs),
Ls(LsArgs),
Edit(EditArgs),
Log(LogArgs),
#[command(name = "append-section")]
AppendSection(AppendSectionArgs),
Tick(TickArgs),
Promote(PromoteArgs),
Cp(CpArgs),
#[command(name = "archive-done")]
ArchiveDone(ArchiveDoneArgs),
Import(ImportArgs),
Mv(MvArgs),
Cat(CatArgs),
#[command(hide = true)]
Rm(RmArgs),
#[command(hide = true)]
Close(StatusAliasArgs),
#[command(hide = true)]
Reopen(StatusAliasArgs),
#[command(hide = true)]
Comment(LogArgs),
#[command(hide = true)]
Delete(RmArgs),
Archive {
key: String,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "json")]
table: bool,
},
Unarchive {
key: String,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "json")]
table: bool,
},
Current {
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "json")]
table: bool,
},
Claim {
key: String,
#[arg(long)]
by: Option<String>,
#[arg(long)]
force: bool,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "json")]
table: bool,
},
Release {
key: String,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "json")]
table: bool,
},
Lint {
key: String,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "json")]
table: bool,
},
}
#[derive(clap::Args)]
pub struct ShowArgs {
key: String,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "json")]
table: bool,
#[arg(long)]
pager: bool,
}
#[derive(clap::Args)]
pub struct LsArgs {
#[arg(long, short = 'p')]
project: Option<String>,
#[arg(long, short = 's')]
status: Option<String>,
#[arg(long, value_enum, ignore_case = true)]
priority: Option<Priority>,
#[arg(long, short = 'm')]
milestone: Option<String>,
#[arg(long)]
parent: Option<String>,
#[arg(long)]
sort: Option<String>,
#[arg(long)]
label: Vec<String>,
#[arg(long = "no-subs")]
no_subs: bool,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "json")]
table: bool,
#[arg(long)]
full: bool,
#[arg(long)]
archived: bool,
#[arg(long = "updated-since")]
updated_since: Option<String>,
#[arg(long)]
search: Option<String>,
#[arg(long, default_value_t = 0)]
limit: i64,
#[arg(long, conflicts_with_all = ["blocked", "status", "archived", "search"])]
ready: bool,
#[arg(long, conflicts_with = "search")]
blocked: bool,
}
#[derive(clap::Args)]
pub struct AddArgs {
title: Option<String>,
#[arg(long, short = 'p')]
project: Option<String>,
#[arg(long, allow_hyphen_values = true)]
description: Option<String>,
#[arg(long = "description-file")]
description_file: Option<String>,
#[arg(long)]
parent: Option<String>,
#[arg(long, short = 'm')]
milestone: Option<String>,
#[arg(long, value_enum, ignore_case = true)]
priority: Option<Priority>,
#[arg(long, short = 's')]
status: Option<String>,
#[arg(long)]
due: Option<String>,
#[arg(long)]
label: Vec<String>,
#[arg(long)]
blocks: Vec<String>,
#[arg(long = "blocked-by")]
blocked_by: Vec<String>,
#[arg(long = "related-to")]
related_to: Vec<String>,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "json")]
table: bool,
#[arg(long)]
editor: bool,
}
#[derive(clap::Args)]
pub struct EditArgs {
key: String,
#[arg(long, allow_hyphen_values = true)]
title: Option<String>,
#[arg(long, allow_hyphen_values = true)]
description: Option<String>,
#[arg(long = "description-file")]
description_file: Option<String>,
#[arg(long, value_enum, ignore_case = true)]
priority: Option<Priority>,
#[arg(long, short = 'm')]
milestone: Option<String>,
#[arg(long = "clear-milestone")]
clear_milestone: bool,
#[arg(long)]
parent: Option<String>,
#[arg(long = "clear-parent")]
clear_parent: bool,
#[arg(long)]
due: Option<String>,
#[arg(long = "clear-due")]
clear_due: bool,
#[arg(long)]
label: Vec<String>,
#[arg(long = "remove-label")]
remove_label: Vec<String>,
#[arg(long)]
blocks: Vec<String>,
#[arg(long = "blocked-by")]
blocked_by: Vec<String>,
#[arg(long = "related-to")]
related_to: Vec<String>,
#[arg(long = "remove-relation")]
remove_relation: Vec<String>,
#[arg(long, short = 'e')]
editor: bool,
#[arg(long)]
section: Option<String>,
#[arg(long = "create-section")]
create_section: bool,
#[arg(long = "if-updated-at")]
if_updated_at: Option<String>,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "json")]
table: bool,
}
#[derive(clap::Args)]
pub struct AppendSectionArgs {
key: String,
#[arg(allow_hyphen_values = true)]
text: Option<String>,
#[arg(long)]
section: String,
#[arg(long = "text-file")]
text_file: Option<String>,
#[arg(long = "create-section")]
create_section: bool,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "json")]
table: bool,
}
#[derive(clap::Args)]
pub struct LogArgs {
key: String,
#[arg(allow_hyphen_values = true)]
message: Option<String>,
#[arg(long = "message-file")]
message_file: Option<String>,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "json")]
table: bool,
}
#[derive(clap::Args)]
pub struct MvArgs {
key: String,
status: String,
#[arg(long, allow_hyphen_values = true)]
note: Option<String>,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "json")]
table: bool,
}
#[derive(clap::Args)]
pub struct RmArgs {
key: String,
}
#[derive(clap::Args)]
pub struct CatArgs {
key: String,
#[arg(long)]
section: Option<String>,
}
#[derive(clap::Args)]
pub struct StatusAliasArgs {
key: String,
#[arg(long, allow_hyphen_values = true)]
note: Option<String>,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "json")]
table: bool,
}
#[derive(Clone, Copy)]
struct Teach {
verb: &'static str,
canonical: &'static str,
}
#[derive(clap::Args)]
pub struct TickArgs {
key: String,
#[arg(long)]
task: Option<i32>,
#[arg(long)]
step: i32,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "json")]
table: bool,
}
#[derive(clap::Args)]
pub struct PromoteArgs {
key: String,
#[arg(long)]
task: Option<i32>,
#[arg(long)]
step: i32,
#[arg(long, default_value = "")]
title: String,
#[arg(long = "as", default_value = "sub-issue")]
as_mode: String,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "json")]
table: bool,
}
#[derive(clap::Args)]
pub struct CpArgs {
key: String,
#[arg(long, short = 'p')]
project: Option<String>,
#[arg(long, allow_hyphen_values = true)]
title: Option<String>,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "json")]
table: bool,
}
#[derive(clap::Args)]
pub struct ArchiveDoneArgs {
#[arg(long, short = 'p')]
project: Option<String>,
#[arg(long)]
auto: bool,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "json")]
table: bool,
}
#[derive(clap::Args)]
pub struct ImportArgs {
file_arg: Option<String>,
#[arg(long, short = 'p')]
project: Option<String>,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "json")]
table: bool,
}
pub async fn run(db: &Option<String>, args: IssueArgs) -> CliResult<()> {
match args.cmd {
IssueCmd::Add(a) => add(db, a).await,
IssueCmd::Show(a) => show(db, a).await,
IssueCmd::Ls(a) => ls(db, a).await,
IssueCmd::Edit(a) => edit(db, a).await,
IssueCmd::Log(a) => log(db, a, None).await,
IssueCmd::AppendSection(a) => append_section_cmd(db, a).await,
IssueCmd::Tick(a) => tick(db, a).await,
IssueCmd::Promote(a) => promote(db, a).await,
IssueCmd::Cp(a) => cp(db, a).await,
IssueCmd::ArchiveDone(a) => archive_done(db, a).await,
IssueCmd::Import(a) => import(db, a).await,
IssueCmd::Mv(a) => {
mv(
db,
a.key,
a.status,
a.note,
crate::output::mode(a.json, a.table),
None,
)
.await
}
IssueCmd::Cat(a) => cat(db, a).await,
IssueCmd::Rm(a) => rm_reflex(db, a.key, None).await,
IssueCmd::Close(a) => {
mv(
db,
a.key,
"done".to_string(),
a.note,
crate::output::mode(a.json, a.table),
Some(Teach {
verb: "closed",
canonical: "mv done",
}),
)
.await
}
IssueCmd::Reopen(a) => {
mv(
db,
a.key,
"in-progress".to_string(),
a.note,
crate::output::mode(a.json, a.table),
Some(Teach {
verb: "reopened",
canonical: "mv in-progress",
}),
)
.await
}
IssueCmd::Comment(a) => {
log(
db,
a,
Some(Teach {
verb: "commented",
canonical: "log",
}),
)
.await
}
IssueCmd::Delete(a) => rm_reflex(db, a.key, Some("archive")).await,
IssueCmd::Archive { key, json, table } => {
let store = store_open::open(db).await?;
let (issue, noop) = set_archived_on(&store, key, true).await?;
confirm_issue(
&store,
&issue,
"archived",
noop,
crate::output::mode(json, table),
)
.await
}
IssueCmd::Unarchive { key, json, table } => {
let store = store_open::open(db).await?;
let (issue, noop) = set_archived_on(&store, key, false).await?;
confirm_issue(
&store,
&issue,
"unarchived",
noop,
crate::output::mode(json, table),
)
.await
}
IssueCmd::Current { json, table } => current(db, crate::output::mode(json, table)).await,
IssueCmd::Claim {
key,
by,
force,
json,
table,
} => claim_cmd(db, key, by, force, crate::output::mode(json, table)).await,
IssueCmd::Release { key, json, table } => {
release_cmd(db, key, crate::output::mode(json, table)).await
}
IssueCmd::Lint { key, json, table } => {
lint_cmd(db, key, crate::output::mode(json, table)).await
}
}
}
fn parse_status(s: &str) -> Result<String, CliError> {
let norm = s.trim().to_lowercase();
if ISSUE_STATUSES.contains(&norm.as_str()) {
Ok(norm)
} else {
Err(CliError::other(format!(
"invalid status {s:?} (valid: backlog, in-progress, blocked, in-review, done)"
)))
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum Priority {
None,
Low,
Medium,
High,
Urgent,
}
impl Priority {
fn as_str(self) -> &'static str {
match self {
Priority::None => "none",
Priority::Low => "low",
Priority::Medium => "medium",
Priority::High => "high",
Priority::Urgent => "urgent",
}
}
}
fn parse_priority(s: &str) -> Result<String, CliError> {
let norm = s.trim().to_lowercase();
if ISSUE_PRIORITIES.contains(&norm.as_str()) {
Ok(norm)
} else {
Err(CliError::other(format!(
"invalid priority {s:?} (valid: none, low, medium, high, urgent)"
)))
}
}
fn priority_rank(p: &str) -> i32 {
match p {
"urgent" => 4,
"high" => 3,
"medium" => 2,
"low" => 1,
_ => 0,
}
}
fn parse_issue_key(s: &str) -> Result<String, CliError> {
let s = s.trim();
match s.rfind('-') {
Some(idx) if idx > 0 && idx < s.len() - 1 => {
let project = s[..idx].to_uppercase();
let seq_str = &s[idx + 1..];
match seq_str.parse::<i64>() {
Ok(n) if n > 0 => Ok(format!("{project}-{n}")),
_ => Err(CliError::validation(format!(
"invalid issue key {s:?} (sequence must be positive integer)"
))),
}
}
_ => Err(CliError::validation(format!(
"invalid issue key {s:?} (want PROJECT-N)"
))),
}
}
pub fn parse_issue_key_pub(s: &str) -> Result<String, CliError> {
parse_issue_key(s)
}
fn resolve_description(
description: Option<String>,
description_file: Option<String>,
) -> CliResult<(String, bool)> {
let resolved = crate::stdin_input::resolve(
description,
description_file,
"--description",
"--description-file",
)?;
Ok(resolved.map_or_else(|| (String::new(), false), |s| (s, true)))
}
async fn add(db: &Option<String>, a: AddArgs) -> CliResult<()> {
let project_key = crate::scope::required_project(a.project)?;
let title = a.title.unwrap_or_default();
let (description, desc_set) = resolve_description(a.description, a.description_file)?;
let contentless = title.is_empty() && !desc_set;
if contentless {
if a.editor {
return Err(CliError::validation("--editor requires a TTY"));
}
return Err(CliError::validation(
"a title is required (pass --editor to open $EDITOR)",
));
}
let parent_key = match &a.parent {
Some(p) if !p.is_empty() => Some(parse_issue_key(p)?),
_ => None,
};
let due_date = match &a.due {
Some(d) if !d.is_empty() => match parse_date(d) {
Some(dt) => Some(dt),
None => {
return Err(CliError::validation(format!(
"invalid --due {d:?} (want YYYY-MM-DD)"
)))
}
},
_ => None,
};
let blocks: Vec<String> = a
.blocks
.iter()
.map(|k| parse_issue_key(k))
.collect::<Result<_, _>>()?;
let blocked_by: Vec<String> = a
.blocked_by
.iter()
.map(|k| parse_issue_key(k))
.collect::<Result<_, _>>()?;
let related_to: Vec<String> = a
.related_to
.iter()
.map(|k| parse_issue_key(k))
.collect::<Result<_, _>>()?;
let store = store_open::open(db).await?;
let milestone = a.milestone.filter(|m| !m.is_empty());
let priority = a.priority.map(|p| p.as_str().to_string());
let status = a.status.filter(|s| !s.is_empty());
let create_project = project_key.clone();
let mut issue = store
.call(move |conn| {
issues::create(
conn,
&create_project,
CreateIssue {
title,
description: Some(description),
status,
priority,
milestone,
parent_key,
due_date,
position: None,
},
)
})
.await?;
for lbl in a.label {
let id = issue.id;
let name = lbl;
store
.call(move |conn| {
let issue = issues::get_by_id(conn, id)?.ok_or(cliban_core::Error::NotFound)?;
issues::add_label(conn, &issue, &name)
})
.await?;
}
let issue_key = issue.key.clone();
for other in blocks {
let from = issue_key.clone();
store
.call(move |conn| relations::add(conn, &from, &other, "blocks"))
.await?;
}
for other in blocked_by {
let to = issue_key.clone();
store
.call(move |conn| relations::add(conn, &other, &to, "blocks"))
.await?;
}
for other in related_to {
let from = issue_key.clone();
store
.call(move |conn| relations::add(conn, &from, &other, "related_to"))
.await?;
}
let reload_key = issue.key.clone();
if let Some(fresh) = store
.call(move |conn| issues::get_by_key(conn, &reload_key))
.await?
{
issue = fresh;
}
print_issue_result(
&store,
&issue,
"created",
crate::output::mode(a.json, a.table),
)
.await
}
async fn print_issue_result(store: &Store, issue: &Issue, verb: &str, mode: Mode) -> CliResult<()> {
if mode.is_json() {
let inputs = issue_json_inputs(store, issue).await?;
println!(
"{}",
serde_json::to_string(&build_issue_json(inputs, Detail::Echo)).unwrap()
);
} else {
println!("{verb} {}: {}", issue.key, issue.title);
}
Ok(())
}
pub async fn issue_json_inputs(store: &Store, issue: &Issue) -> CliResult<IssueJsonInputs> {
let id = issue.id;
let milestone_id = issue.milestone_id;
let parent_id = issue.parent_id;
let key = issue.key.clone();
let title = issue.title.clone();
let description = issue.description.clone();
let status = issue.status.clone();
let priority = issue.priority.clone();
let position = issue.position;
let archived = issue.archived;
let due_date = issue.due_date.map(format_date);
let completed_at = issue.completed_at.map(format_usec);
let created_at = format_usec(issue.inserted_at);
let updated_at = format_usec(issue.updated_at);
let inputs = store
.call(move |conn| {
let milestone = match milestone_id {
Some(mid) => milestones::get_by_id(conn, mid)?.map(|m| m.name),
None => None,
};
let parent = match parent_id {
Some(pid) => issues::get_by_id(conn, pid)?.map(|i| i.key),
None => None,
};
let labels = issues::label_names(conn, id)?;
let claimed_by = claims::get(conn, id)?.map(|c| c.claimed_by);
let relations = relations::for_issue(conn, id)?
.into_iter()
.map(|r| RelationOut {
kind: r.kind,
target: r.target_key,
})
.collect();
Ok(IssueJsonInputs {
key,
title,
description,
status,
priority,
position,
archived,
claimed_by,
due_date,
completed_at,
milestone,
parent,
labels,
relations,
created_at,
updated_at,
})
})
.await?;
Ok(inputs)
}
async fn resolve_refs(store: &Store, issue: &Issue) -> CliResult<(String, String)> {
let milestone_id = issue.milestone_id;
let parent_id = issue.parent_id;
let pair = store
.call(move |conn| {
let milestone = match milestone_id {
Some(mid) => milestones::get_by_id(conn, mid)?.map(|m| m.name),
None => None,
};
let parent = match parent_id {
Some(pid) => issues::get_by_id(conn, pid)?.map(|i| i.key),
None => None,
};
Ok((milestone.unwrap_or_default(), parent.unwrap_or_default()))
})
.await?;
Ok(pair)
}
fn resolve_section(s: &str) -> Result<String, CliError> {
let t = s.trim();
if t.is_empty() {
return Err(CliError::validation("--section can't be blank"));
}
Ok(match t.to_lowercase().as_str() {
"spec" => "Spec".to_string(),
"plan" => "Plan".to_string(),
"activity" | "activity log" => "Activity Log".to_string(),
"notes" => "Notes".to_string(),
_ => t.to_string(),
})
}
fn require_section(issue: &Issue, anchor: &str, create: bool) -> Result<(), cliban_core::Error> {
if create || cliban_core::sections::find_section(&issue.description, anchor).2 {
return Ok(());
}
let existing = cliban_core::sections::h2_anchors(&issue.description);
let listing = if existing.is_empty() {
"none".to_string()
} else {
existing.join(", ")
};
Err(cliban_core::Error::validation(
"section",
&format!(
"no ## {anchor} section in {} (sections: {listing}); \
pass --create-section to add it",
issue.key
),
))
}
async fn show(db: &Option<String>, a: ShowArgs) -> CliResult<()> {
let key = parse_issue_key(&a.key)?;
let store = store_open::open(db).await?;
let lookup = key.clone();
let issue = store
.call(move |conn| issues::get_by_key(conn, &lookup))
.await?
.ok_or(cliban_core::Error::NotFound)?;
if crate::output::mode(a.json, a.table).is_json() {
let inputs = issue_json_inputs(&store, &issue).await?;
println!(
"{}",
serde_json::to_string_pretty(&build_issue_json(inputs, Detail::Full)).unwrap()
);
return Ok(());
}
let (ms_name, parent_key) = resolve_refs(&store, &issue).await?;
let body = format!(
"{} — {}\nstatus: {}\npriority: {}\nmilestone: {}\nparent: {}\n\n{}\n",
key,
issue.title,
issue.status,
issue.priority,
dash_if_empty(&ms_name),
dash_if_empty(&parent_key),
issue.description
);
if a.pager {
run_pager(&body)?;
} else {
print!("{body}");
}
Ok(())
}
fn dash_if_empty(s: &str) -> String {
if s.is_empty() {
"-".into()
} else {
s.into()
}
}
fn run_pager(body: &str) -> CliResult<()> {
match std::env::var("PAGER") {
Ok(pager) if !pager.is_empty() => {
use std::process::{Command, Stdio};
let mut child = Command::new("sh")
.arg("-c")
.arg(&pager)
.stdin(Stdio::piped())
.spawn()
.map_err(|e| CliError::other(e.to_string()))?;
if let Some(stdin) = child.stdin.as_mut() {
stdin
.write_all(body.as_bytes())
.map_err(|e| CliError::other(e.to_string()))?;
}
child.wait().map_err(|e| CliError::other(e.to_string()))?;
Ok(())
}
_ => {
print!("{body}");
Ok(())
}
}
}
fn parse_updated_since(s: &str) -> Result<chrono::DateTime<Utc>, CliError> {
crate::since::parse(s, "--updated-since")
}
async fn ls(db: &Option<String>, a: LsArgs) -> CliResult<()> {
if let Some(raw) = &a.search {
if raw.trim().is_empty() {
return Err(CliError::validation("--search requires a non-empty query"));
}
if a.sort.is_some() {
eprintln!("note: --sort is ignored when --search is set");
}
return run_search(db, &a, raw.clone()).await;
}
let project = crate::scope::project(a.project.clone());
let status = match &a.status {
Some(s) if !s.is_empty() => Some(parse_status(s)?),
_ => None,
};
let priority = a.priority.map(|p| p.as_str().to_string());
let parent_key = match &a.parent {
Some(p) if !p.is_empty() => Some(parse_issue_key(p)?),
_ => None,
};
let updated_since = match &a.updated_since {
Some(s) if !s.is_empty() => Some(parse_updated_since(s)?),
_ => None,
};
let sort = a.sort.clone().filter(|s| !s.is_empty());
if let Some(spec) = &sort {
validate_sort_spec(spec)?;
}
let store = store_open::open(db).await?;
let list_project = project.clone();
let list_status = status.clone();
let list_milestone = a.milestone.clone().filter(|m| !m.is_empty());
let include_archived = a.archived;
let mut issues = store
.call(move |conn| {
let mut out = issues::list(
conn,
ListOpts {
project: list_project.as_deref(),
status: list_status.as_deref(),
milestone: list_milestone.as_deref(),
archived: false,
},
)?;
if include_archived {
let archived = issues::list(
conn,
ListOpts {
project: list_project.as_deref(),
status: list_status.as_deref(),
milestone: list_milestone.as_deref(),
archived: true,
},
)?;
out.extend(archived);
}
Ok(out)
})
.await?;
if let Some(pr) = &priority {
issues.retain(|i| &i.priority == pr);
}
if let Some(pk) = &parent_key {
let lookup = pk.clone();
let parent_id = store
.call(move |conn| issues::get_by_key(conn, &lookup).map(|o| o.map(|i| i.id)))
.await?;
match parent_id {
Some(pid) => issues.retain(|i| i.parent_id == Some(pid)),
None => issues.clear(),
}
}
if a.no_subs {
issues.retain(|i| i.parent_id.is_none());
}
if !a.label.is_empty() {
let want = a.label.clone();
let mut kept = Vec::with_capacity(issues.len());
for i in issues.into_iter() {
let id = i.id;
let names = store
.call(move |conn| issues::label_names(conn, id))
.await?;
if want.iter().all(|w| names.iter().any(|n| n == w)) {
kept.push(i);
}
}
issues = kept;
}
if let Some(threshold) = updated_since {
issues.retain(|i| i.updated_at >= threshold);
}
if a.ready {
let pk = project.clone();
let ready_ids: std::collections::HashSet<i64> = store
.call(move |conn| relations::list_ready(conn, pk.as_deref(), None, None))
.await?
.into_iter()
.map(|i| i.id)
.collect();
issues.retain(|i| ready_ids.contains(&i.id));
}
if a.blocked {
let pk = project.clone();
let blocked_ids: std::collections::HashSet<i64> = store
.call(move |conn| relations::list_blocked(conn, pk.as_deref()))
.await?
.into_iter()
.map(|i| i.id)
.collect();
issues.retain(|i| blocked_ids.contains(&i.id));
}
base_order(&mut issues);
if let Some(spec) = &sort {
sort_issues(&mut issues, spec);
}
if a.limit > 0 {
issues.truncate(a.limit as usize);
}
if crate::output::mode(a.json, a.table).is_json() {
for i in &issues {
let inputs = issue_json_inputs(&store, i).await?;
println!(
"{}",
serde_json::to_string(&build_issue_json(inputs, Detail::from_full_flag(a.full)))
.unwrap()
);
}
return Ok(());
}
let rows = issue_rows(&store, &issues).await?;
print!("{}", write_issue_table(&rows));
Ok(())
}
async fn run_search(db: &Option<String>, a: &LsArgs, query: String) -> CliResult<()> {
let effective_limit = if a.limit == 0 { 50 } else { a.limit };
let store = store_open::open(db).await?;
let opts = crate::search::Options {
query,
project: crate::scope::project(a.project.clone()),
label: a.label.clone(),
milestone: a.milestone.clone(),
status: a.status.clone(),
priority: a.priority.map(|p| p.as_str().to_string()),
parent: a.parent.clone(),
include_archived: a.archived,
exclude_subs: a.no_subs,
limit: effective_limit,
};
let matches = crate::search::search(&store, opts).await?;
if crate::output::mode(a.json, a.table).is_json() {
for m in &matches {
let inputs = issue_json_inputs(&store, &m.issue).await?;
println!(
"{}",
serde_json::to_string(&crate::output::build_search_match_json(
inputs,
m.score,
Detail::from_full_flag(a.full)
))
.unwrap()
);
}
return Ok(());
}
let mut rows = Vec::with_capacity(matches.len());
for m in &matches {
let (ms_name, parent_key) = crate::search::resolve_refs(&store, &m.issue).await?;
rows.push(crate::output::SearchRow {
score: m.score,
key: m.issue.key.clone(),
title: m.issue.title.clone(),
status: m.issue.status.clone(),
priority: m.issue.priority.clone(),
milestone: ms_name,
parent: parent_key,
});
}
print!("{}", crate::output::write_search_table(&rows));
Ok(())
}
pub fn project_prefix(key: &str) -> &str {
match key.rfind('-') {
Some(idx) => &key[..idx],
None => key,
}
}
pub fn base_order(issues: &mut [Issue]) {
issues.sort_by(|a, b| {
project_prefix(&a.key)
.cmp(project_prefix(&b.key))
.then_with(|| a.status.cmp(&b.status))
.then_with(|| {
a.position
.partial_cmp(&b.position)
.unwrap_or(std::cmp::Ordering::Equal)
})
});
}
async fn issue_rows(store: &Store, issues: &[Issue]) -> CliResult<Vec<IssueRow>> {
let mut rows = Vec::with_capacity(issues.len());
for i in issues {
let (ms_name, parent_key) = resolve_refs(store, i).await?;
rows.push(IssueRow {
key: i.key.clone(),
title: i.title.clone(),
status: i.status.clone(),
priority: i.priority.clone(),
milestone: ms_name,
parent: parent_key,
});
}
Ok(rows)
}
fn validate_sort_spec(spec: &str) -> Result<(), CliError> {
let (field, dir) = match spec.find(':') {
Some(idx) => (&spec[..idx], &spec[idx + 1..]),
None => (spec, ""),
};
match dir {
"" | "asc" | "desc" => {}
other => {
return Err(CliError::validation(format!(
"invalid sort direction {other:?} (use asc or desc)"
)))
}
}
match field {
"priority" | "created" | "updated" | "position" => Ok(()),
other => Err(CliError::validation(format!(
"invalid --sort field {other:?} (priority|created|updated|position)"
))),
}
}
fn sort_issues(issues: &mut [Issue], spec: &str) {
let (field, dir) = match spec.find(':') {
Some(idx) => (&spec[..idx], &spec[idx + 1..]),
None => (spec, ""),
};
let mut desc = dir == "desc";
match field {
"priority" => {
if dir.is_empty() {
desc = true;
}
issues.sort_by(|a, b| {
let (x, y) = if desc { (b, a) } else { (a, b) };
priority_rank(&x.priority).cmp(&priority_rank(&y.priority))
});
}
"created" => issues.sort_by(|a, b| {
if desc {
b.inserted_at.cmp(&a.inserted_at)
} else {
a.inserted_at.cmp(&b.inserted_at)
}
}),
"updated" => issues.sort_by(|a, b| {
if desc {
b.updated_at.cmp(&a.updated_at)
} else {
a.updated_at.cmp(&b.updated_at)
}
}),
"position" => issues.sort_by(|a, b| {
let ord = a
.position
.partial_cmp(&b.position)
.unwrap_or(std::cmp::Ordering::Equal);
if desc {
ord.reverse()
} else {
ord
}
}),
_ => {}
}
}
async fn edit(db: &Option<String>, a: EditArgs) -> CliResult<()> {
let key = parse_issue_key(&a.key)?;
let project_part = project_prefix(&key).to_string();
let (desc_content, desc_set) = resolve_description(a.description, a.description_file)?;
let section_write: Option<String> = match &a.section {
None => None,
Some(s) => {
let anchor = resolve_section(s)?;
if anchor == "Activity Log" {
return Err(CliError::validation(
"--section activity is owned by `issue log`; append entries there",
));
}
if !desc_set {
return Err(CliError::validation(
"--section needs content: pass --description or --description-file",
));
}
Some(anchor)
}
};
let title = a.title.clone(); let priority = a.priority.map(|p| p.as_str().to_string());
let due_date: Option<Option<chrono::NaiveDate>> = if a.clear_due {
Some(None)
} else if let Some(d) = &a.due {
match parse_date(d) {
Some(dt) => Some(Some(dt)),
None => {
return Err(CliError::validation(format!(
"invalid --due {d:?} (want YYYY-MM-DD)"
)))
}
}
} else {
None
};
let parent_key = if a.clear_parent {
None
} else {
match &a.parent {
Some(p) => Some(parse_issue_key(p)?),
None => None,
}
};
let any_change = title.is_some()
|| desc_set
|| priority.is_some()
|| a.clear_milestone
|| a.milestone.is_some()
|| a.clear_parent
|| a.parent.is_some()
|| a.clear_due
|| a.due.is_some()
|| !a.label.is_empty()
|| !a.remove_label.is_empty()
|| !a.blocks.is_empty()
|| !a.blocked_by.is_empty()
|| !a.related_to.is_empty()
|| !a.remove_relation.is_empty();
if !any_change && a.editor {
return Err(CliError::validation("--editor requires a TTY"));
}
if !any_change {
return Err(CliError::validation(
"no edits requested (pass a flag or --editor)",
));
}
let blocks: Vec<String> = a
.blocks
.iter()
.map(|k| parse_issue_key(k))
.collect::<Result<_, _>>()?;
let blocked_by: Vec<String> = a
.blocked_by
.iter()
.map(|k| parse_issue_key(k))
.collect::<Result<_, _>>()?;
let related_to: Vec<String> = a
.related_to
.iter()
.map(|k| parse_issue_key(k))
.collect::<Result<_, _>>()?;
let remove_relation: Vec<String> = a
.remove_relation
.iter()
.map(|k| parse_issue_key(k))
.collect::<Result<_, _>>()?;
let store = store_open::open(db).await?;
let milestone_field: Option<Option<i64>> = if a.clear_milestone {
Some(None)
} else if let Some(name) = a.milestone.clone() {
let pk = project_part.clone();
let mid = store
.call(move |conn| Ok(milestones::get(conn, &pk, &name)?.map(|m| m.id)))
.await?;
match mid {
Some(id) => Some(Some(id)),
None => {
let n = a.milestone.clone().unwrap_or_default();
return Err(CliError::not_found(format!("not found: milestone {n:?}")));
}
}
} else {
None
};
let parent_field: Option<Option<i64>> = if a.clear_parent {
Some(None)
} else if let Some(pk) = parent_key.clone() {
if project_prefix(&pk) != project_part {
return Err(CliError::validation("parent must be in same project"));
}
if pk == key {
return Err(CliError::validation("issue cannot be its own parent"));
}
let lookup = pk.clone();
let pid = store
.call(move |conn| Ok(issues::get_by_key(conn, &lookup)?.map(|i| i.id)))
.await?;
match pid {
Some(id) => Some(Some(id)),
None => return Err(CliError::not_found(format!("not found: parent {pk}"))),
}
} else {
None
};
let description = if desc_set { Some(desc_content) } else { None };
let snapshot_key = key.clone();
let before = store
.call(move |conn| issues::get_by_key(conn, &snapshot_key))
.await?
.ok_or(cliban_core::Error::NotFound)?;
let (before_milestone, before_parent) = resolve_refs(&store, &before).await?;
let has_field_update = title.is_some()
|| description.is_some()
|| priority.is_some()
|| milestone_field.is_some()
|| parent_field.is_some()
|| due_date.is_some();
let cas = a.if_updated_at.clone();
if has_field_update {
let lookup = key.clone();
let cas_check = cas.clone();
let upd = UpdateIssue {
title: title.clone(),
description: description.clone(),
priority: priority.clone(),
milestone_id: milestone_field,
parent_id: parent_field,
due_date,
..Default::default()
};
let section_for_job = section_write.clone();
let create_section = a.create_section;
store
.call(move |conn| {
let issue =
issues::get_by_key(conn, &lookup)?.ok_or(cliban_core::Error::NotFound)?;
check_cas(&issue, cas_check.as_deref())?;
let mut upd = upd;
if let Some(anchor) = §ion_for_job {
if let Some(body) = upd.description.take() {
require_section(&issue, anchor, create_section)?;
let body = cliban_core::sections::sanitize_section_body(anchor, &body)
.map_err(|m| cliban_core::Error::validation("section", &m))?;
upd.description = Some(replace_section(&issue.description, anchor, &body));
}
}
issues::update(conn, &issue, upd)?;
Ok(())
})
.await?;
} else {
let lookup = key.clone();
let cas_check = cas.clone();
store
.call(move |conn| {
let issue = issues::get_by_key(conn, &lookup)?;
if let Some(i) = &issue {
check_cas(i, cas_check.as_deref())?;
}
Ok(issue)
})
.await?
.ok_or(cliban_core::Error::NotFound)?;
}
let added_labels = a.label.clone();
let removed_labels = a.remove_label.clone();
let summary_blocks = blocks.clone();
let summary_blocked_by = blocked_by.clone();
let summary_related_to = related_to.clone();
let summary_remove_relation = remove_relation.clone();
for lbl in a.label {
let lookup = key.clone();
store
.call(move |conn| {
let issue =
issues::get_by_key(conn, &lookup)?.ok_or(cliban_core::Error::NotFound)?;
issues::add_label(conn, &issue, &lbl)
})
.await?;
}
for lbl in a.remove_label {
let lookup = key.clone();
store
.call(move |conn| {
let issue =
issues::get_by_key(conn, &lookup)?.ok_or(cliban_core::Error::NotFound)?;
issues::remove_label(conn, &issue, &lbl)
})
.await?;
}
for other in blocks {
let from = key.clone();
store
.call(move |conn| relations::add(conn, &from, &other, "blocks"))
.await?;
}
for other in blocked_by {
let to = key.clone();
store
.call(move |conn| relations::add(conn, &other, &to, "blocks"))
.await?;
}
for other in related_to {
let from = key.clone();
store
.call(move |conn| relations::add(conn, &from, &other, "related_to"))
.await?;
}
for other in remove_relation {
let k = key.clone();
store
.call(move |conn| {
let _ = relations::remove(conn, &k, &other, "blocks");
let _ = relations::remove(conn, &other, &k, "blocks");
let _ = relations::remove(conn, &k, &other, "related_to");
Ok(())
})
.await?;
}
let reload = key.clone();
let issue = store
.call(move |conn| issues::get_by_key(conn, &reload))
.await?
.ok_or(cliban_core::Error::NotFound)?;
let (after_milestone, after_parent) = resolve_refs(&store, &issue).await?;
let mut summary = crate::audit::EditSummary::default();
summary.field("title", &before.title, &issue.title);
summary.field("priority", &before.priority, &issue.priority);
summary.field("milestone", &before_milestone, &after_milestone);
summary.field("parent", &before_parent, &after_parent);
summary.field(
"due",
&before.due_date.map(format_date).unwrap_or_default(),
&issue.due_date.map(format_date).unwrap_or_default(),
);
if before.description != issue.description {
if let Some(anchor) = section_write {
summary.note(format!("## {anchor} written"));
} else {
let lost: Vec<&str> = ["Spec", "Plan", "Activity Log", "Notes"]
.into_iter()
.filter(|anchor| {
find_section(&before.description, anchor).2
&& !find_section(&issue.description, anchor).2
})
.collect();
if lost.is_empty() {
summary.note("description rewritten");
} else {
summary.note(format!(
"description rewritten, dropped ## {}",
lost.join(", ## ")
));
}
}
}
for l in &added_labels {
summary.note(format!("+label {l}"));
}
for l in &removed_labels {
summary.note(format!("-label {l}"));
}
for (verb, keys) in [
("blocks", &summary_blocks),
("blocked-by", &summary_blocked_by),
("related-to", &summary_related_to),
] {
for k in keys {
summary.note(format!("+{verb} {k}"));
}
}
for k in &summary_remove_relation {
summary.note(format!("-relation {k}"));
}
if !summary.is_empty() {
let (id, message) = (issue.id, summary.message());
store
.call(move |conn| {
if let Some(i) = issues::get_by_id(conn, id)? {
crate::audit::record(conn, &i, "edit", &message);
}
Ok(())
})
.await?;
}
print_issue_result(
&store,
&issue,
"updated",
crate::output::mode(a.json, a.table),
)
.await
}
async fn log(db: &Option<String>, a: LogArgs, teach: Option<Teach>) -> CliResult<()> {
let key = parse_issue_key(&a.key)?;
let msg = crate::stdin_input::log_message(a.message.clone(), a.message_file.clone())?;
let store = store_open::open(db).await?;
let lookup = key.clone();
let now = Utc::now();
let entry = msg.clone();
store
.call(move |conn| {
let tx = conn.unchecked_transaction()?;
let issue = issues::get_by_key(&tx, &lookup)?.ok_or(cliban_core::Error::NotFound)?;
let new_desc = descmd::append_activity_log(&issue.description, &entry, now);
let updated = format_usec(now);
tx.execute(
"UPDATE issues SET description = ?1, updated_at = ?2 WHERE id = ?3",
rusqlite::params![new_desc, updated, issue.id],
)?;
crate::audit::record(&tx, &issue, "log", &entry);
tx.commit()?;
Ok(())
})
.await?;
if crate::output::mode(a.json, a.table).is_json() {
let mut m = serde_json::Map::new();
if let Some(t) = teach {
m.insert(
"canonical".into(),
serde_json::json!(format!("issue {}", t.canonical)),
);
}
m.insert("entry".into(), serde_json::json!(msg));
m.insert("key".into(), serde_json::json!(a.key));
m.insert("timestamp".into(), serde_json::json!(format_usec(now)));
println!(
"{}",
serde_json::to_string(&serde_json::Value::Object(m)).unwrap()
);
} else if let Some(t) = teach {
println!("{} {} ({}): {}", t.verb, a.key, t.canonical, msg);
} else {
println!("logged on {}: {}", a.key, msg);
}
Ok(())
}
async fn tick(db: &Option<String>, a: TickArgs) -> CliResult<()> {
let key = parse_issue_key(&a.key)?;
let store = store_open::open(db).await?;
let lookup = key.clone();
let task_arg = a.task;
let step = a.step;
let (updated_at, noop, task) = store
.call(move |conn| {
let tx = conn.unchecked_transaction()?;
let issue = issues::get_by_key(&tx, &lookup)?.ok_or(cliban_core::Error::NotFound)?;
let task = resolve_task(task_arg, &issue.description)?;
let new_desc = match descmd::tick_step(&issue.description, task, step) {
Ok(descmd::TickOutcome::Ticked(d)) => d,
Ok(descmd::TickOutcome::AlreadyChecked) => {
return Ok((format_usec(issue.updated_at), true, task));
}
Err(msg) => return Err(cliban_core::Error::validation("plan", &msg)),
};
let now = format_usec(cliban_core::time::now_usec());
tx.execute(
"UPDATE issues SET description = ?1, updated_at = ?2 WHERE id = ?3",
rusqlite::params![new_desc, now, issue.id],
)?;
crate::audit::record(
&tx,
&issue,
"plan",
&format!("ticked Task {task} Step {step}"),
);
tx.commit()?;
Ok((now, false, task))
})
.await?;
if crate::output::mode(a.json, a.table).is_json() {
let mut m = serde_json::Map::new();
m.insert("checked".into(), serde_json::json!(true));
m.insert("key".into(), serde_json::json!(a.key));
if noop {
m.insert("noop".into(), serde_json::json!(true));
}
m.insert("step".into(), serde_json::json!(a.step));
m.insert("task".into(), serde_json::json!(task));
m.insert("updated_at".into(), serde_json::json!(updated_at));
println!(
"{}",
serde_json::to_string(&serde_json::Value::Object(m)).unwrap()
);
} else if noop {
println!(
"ticked {} Task {} Step {} (already checked — nothing to do)",
a.key, task, a.step
);
} else {
println!("ticked {} Task {} Step {}", a.key, task, a.step);
}
Ok(())
}
fn resolve_task(arg: Option<i32>, desc: &str) -> Result<i32, cliban_core::Error> {
match arg {
Some(t) => Ok(t),
None => match descmd::count_tasks(desc) {
1 => Ok(1),
0 => Err(cliban_core::Error::validation(
"plan",
"no `### Task N:` headings in ## Plan",
)),
n => Err(cliban_core::Error::validation(
"plan",
&format!("plan has {n} tasks; pass --task"),
)),
},
}
}
async fn promote(db: &Option<String>, a: PromoteArgs) -> CliResult<()> {
let key = parse_issue_key(&a.key)?;
let project_part = project_prefix(&key).to_string();
if a.as_mode != "sub-issue" && a.as_mode != "related" {
return Err(CliError::validation(format!(
"invalid --as {:?} (want sub-issue|related)",
a.as_mode
)));
}
let store = store_open::open(db).await?;
let lookup = key.clone();
let child_origin = key.clone();
let task = a.task;
let step = a.step;
let title = a.title.clone();
let mode = a.as_mode.clone();
let proj_key = project_part.clone();
let (new_key, task) = store
.call(move |conn| {
let tx = conn.unchecked_transaction()?;
let parent: Option<(i64, i64, String, Option<i64>, i64)> = tx
.query_row(
"SELECT i.id, i.project_id, i.description, i.parent_id, p.issue_seq \
FROM issues i JOIN projects p ON p.id = i.project_id WHERE i.key = ?1",
rusqlite::params![lookup],
|r| {
Ok((
r.get(0)?,
r.get(1)?,
r.get(2)?,
r.get(3)?,
r.get(4)?,
))
},
)
.optional()?;
let (parent_id, proj_id, parent_desc, parent_parent, issue_seq) = match parent {
Some(v) => v,
None => return Err(cliban_core::Error::NotFound),
};
if mode == "sub-issue" && parent_parent.is_some() {
return Err(cliban_core::Error::validation(
"depth",
"cannot promote as sub-issue of a sub-issue (would exceed depth 2)",
));
}
let task = resolve_task(task, &parent_desc)?;
let raw = match find_step_for_rewrite(&parent_desc, task, step) {
Some(s) => s,
None => {
return Err(cliban_core::Error::validation(
"plan",
&format!("cannot find Task {task} Step {step} in parent description"),
))
}
};
let title = if title.is_empty() {
let t = step_title_text(&raw);
if t.is_empty() {
return Err(cliban_core::Error::validation(
"plan",
"the step has no text to use as a title; pass --title",
));
}
t
} else {
title
};
let new_seq = issue_seq + 1;
let max_pos: Option<f64> = tx.query_row(
"SELECT max(position) FROM issues WHERE project_id = ?1 AND status = 'backlog'",
rusqlite::params![proj_id],
|r| r.get(0),
)?;
let pos = max_pos.unwrap_or(0.0) + 1000.0;
let now = format_usec(cliban_core::time::now_usec());
let new_key = format!("{proj_key}-{new_seq}");
let sub_parent: Option<i64> = if mode == "sub-issue" {
Some(parent_id)
} else {
None
};
tx.execute(
"INSERT INTO issues (key, project_id, milestone_id, parent_id, title, \
description, status, priority, position, archived, inserted_at, updated_at) \
VALUES (?1, ?2, NULL, ?3, ?4, '', 'backlog', 'none', ?5, 0, ?6, ?6)",
rusqlite::params![new_key, proj_id, sub_parent, title, pos, now],
)?;
let new_id = tx.last_insert_rowid();
tx.execute(
"UPDATE projects SET issue_seq = ?1, updated_at = ?2 WHERE id = ?3",
rusqlite::params![new_seq, now, proj_id],
)?;
if mode == "related" {
tx.execute(
"INSERT OR IGNORE INTO issue_relation (from_issue_id, to_issue_id, type, created_at) \
VALUES (?1, ?2, 'related_to', ?3)",
rusqlite::params![new_id, parent_id, now],
)?;
tx.execute(
"INSERT OR IGNORE INTO issue_relation (from_issue_id, to_issue_id, type, created_at) \
VALUES (?1, ?2, 'related_to', ?3)",
rusqlite::params![parent_id, new_id, now],
)?;
}
let new_line = build_promoted_line(&raw, &new_key);
let new_desc = match descmd::rewrite_step_line(&parent_desc, task, step, &new_line) {
Ok(d) => d,
Err(msg) => return Err(cliban_core::Error::validation("plan", &msg)),
};
tx.execute(
"UPDATE issues SET description = ?1, updated_at = ?2 WHERE id = ?3",
rusqlite::params![new_desc, now, parent_id],
)?;
if let Some(parent_issue) = issues::get_by_id(&tx, parent_id)? {
crate::audit::record(
&tx,
&parent_issue,
"plan",
&format!("promoted Task {task} Step {step} → {new_key}"),
);
}
if let Some(child) = issues::get_by_id(&tx, new_id)? {
crate::audit::record(
&tx,
&child,
"plan",
&format!("promoted from {} Task {task} Step {step}", child_origin),
);
}
tx.commit()?;
Ok((new_key, task))
})
.await?;
if crate::output::mode(a.json, a.table).is_json() {
let mut m = serde_json::Map::new();
m.insert("new_key".into(), serde_json::json!(new_key));
m.insert("parent".into(), serde_json::json!(a.key));
m.insert("step".into(), serde_json::json!(a.step));
m.insert("task".into(), serde_json::json!(task));
println!(
"{}",
serde_json::to_string(&serde_json::Value::Object(m)).unwrap()
);
} else {
println!(
"promoted {} Task {} Step {} → {}",
a.key, task, a.step, new_key
);
}
Ok(())
}
fn step_title_text(raw: &str) -> String {
let t = raw.trim();
let t = t
.strip_prefix("- [ ]")
.or_else(|| t.strip_prefix("- [x]"))
.unwrap_or(t)
.trim();
let t = t.strip_prefix("**").unwrap_or(t);
let t = t.strip_suffix("**").unwrap_or(t);
let t = match t.split_once(':') {
Some((label, rest))
if label
.strip_prefix("Step ")
.is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())) =>
{
rest
}
_ => t,
};
let t = match t.rsplit_once('→') {
Some((before, after)) if descmd::is_issue_key_shaped(after.trim()) => before,
_ => t,
};
t.trim().to_string()
}
fn find_step_for_rewrite(desc: &str, task_n: i32, step_m: i32) -> Option<String> {
let (plan_start, plan_end, ok) = find_section(desc, "Plan");
if !ok {
return None;
}
let plan_body = &desc[plan_start..plan_end];
let (task_start, task_end, ok) = crate::descmd::find_task(plan_body, task_n);
if !ok {
return None;
}
crate::descmd::find_step(&plan_body[task_start..task_end], step_m).map(|s| s.raw)
}
fn build_promoted_line(original: &str, new_key: &str) -> String {
let trimmed = original.trim_end_matches('\n');
let trimmed = match trimmed.rfind(" → ") {
Some(idx) => &trimmed[..idx],
None => trimmed,
};
format!("{trimmed} → {new_key}\n")
}
async fn cp(db: &Option<String>, a: CpArgs) -> CliResult<()> {
let key = parse_issue_key(&a.key)?;
let source_project = project_prefix(&key).to_string();
let target_project = a
.project
.map(|p| p.trim().to_uppercase())
.filter(|p| !p.is_empty())
.unwrap_or_else(|| source_project.clone());
let same_project = target_project == source_project;
let store = store_open::open(db).await?;
let lookup = key.clone();
let (src, src_milestone, labels) = store
.call(move |conn| {
let issue = issues::get_by_key(conn, &lookup)?.ok_or(cliban_core::Error::NotFound)?;
let milestone = match issue.milestone_id {
Some(mid) => milestones::get_by_id(conn, mid)?.map(|m| m.name),
None => None,
};
let labels = issues::label_names(conn, issue.id)?;
Ok((issue, milestone, labels))
})
.await?;
let milestone_dropped = !same_project && src_milestone.is_some();
let milestone = if same_project { src_milestone } else { None };
let title = a
.title
.filter(|t| !t.is_empty())
.unwrap_or_else(|| src.title.clone());
let description = descmd::copy_description(&src.description);
let priority = src.priority.clone();
let create_project = target_project.clone();
let issue = store
.call(move |conn| {
issues::create(
conn,
&create_project,
CreateIssue {
title,
description: Some(description),
status: None,
priority: Some(priority),
milestone,
parent_key: None,
due_date: None,
position: None,
},
)
})
.await?;
for lbl in labels {
let id = issue.id;
store
.call(move |conn| {
let issue = issues::get_by_id(conn, id)?.ok_or(cliban_core::Error::NotFound)?;
issues::add_label(conn, &issue, &lbl)
})
.await?;
}
let message = if milestone_dropped {
format!("copied from {key} (milestone dropped: names are project-scoped)")
} else {
format!("copied from {key}")
};
let id = issue.id;
store
.call(move |conn| {
if let Some(i) = issues::get_by_id(conn, id)? {
crate::audit::record(conn, &i, "copy", &message);
}
Ok(())
})
.await?;
let reload = issue.key.clone();
let issue = store
.call(move |conn| issues::get_by_key(conn, &reload))
.await?
.ok_or(cliban_core::Error::NotFound)?;
print_issue_result(
&store,
&issue,
"copied",
crate::output::mode(a.json, a.table),
)
.await
}
async fn archive_done(db: &Option<String>, a: ArchiveDoneArgs) -> CliResult<()> {
let mode = crate::output::mode(a.json, a.table);
let store = store_open::open(db).await?;
if a.auto {
let n = store
.call(|conn| {
let now = format_usec(cliban_core::time::now_usec());
let mut pols: Vec<(String, i64)> = Vec::new();
{
let mut stmt = conn.prepare(
"SELECT key, auto_archive_done_after_days FROM projects \
WHERE auto_archive_done_after_days IS NOT NULL",
)?;
let rows =
stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?;
for row in rows {
pols.push(row?);
}
}
let mut total = 0i64;
for (pkey, days) in pols {
let n = conn.execute(
"UPDATE issues SET archived = 1, updated_at = ?1 \
WHERE archived = 0 AND status = 'done' AND completed_at IS NOT NULL \
AND project_id = (SELECT id FROM projects WHERE key = ?2) \
AND datetime(completed_at, '+' || ?3 || ' days') < datetime('now')",
rusqlite::params![now, pkey, days],
)?;
total += n as i64;
}
Ok(total)
})
.await?;
if mode.is_json() {
let mut m = serde_json::Map::new();
m.insert("archived".into(), serde_json::json!(n));
m.insert("mode".into(), serde_json::json!("auto"));
println!(
"{}",
serde_json::to_string(&serde_json::Value::Object(m)).unwrap()
);
} else {
println!("archived {n} done issue(s) (auto sweep)");
}
return Ok(());
}
let project = match crate::scope::project(a.project) {
Some(p) => p,
None => {
return Err(CliError::validation(
"no project scope: pass -p KEY, set $CLIBAN_PROJECT, or use --auto \
for the per-project policy",
))
}
};
let pkey = project.clone();
let n = store
.call(move |conn| {
let now = format_usec(cliban_core::time::now_usec());
let n = conn.execute(
"UPDATE issues SET archived = 1, updated_at = ?1 \
WHERE archived = 0 AND status = 'done' \
AND project_id = (SELECT id FROM projects WHERE key = ?2)",
rusqlite::params![now, pkey],
)?;
Ok(n as i64)
})
.await?;
if mode.is_json() {
let mut m = serde_json::Map::new();
m.insert("archived".into(), serde_json::json!(n));
println!(
"{}",
serde_json::to_string(&serde_json::Value::Object(m)).unwrap()
);
} else {
println!("archived {n} done issue(s) in {project}");
}
Ok(())
}
async fn import(db: &Option<String>, a: ImportArgs) -> CliResult<()> {
let mode = crate::output::mode(a.json, a.table);
let path = a.file_arg.clone();
let content = match path.as_deref() {
None | Some("") | Some("-") => crate::stdin_input::read_stdin()?,
Some(p) => std::fs::read_to_string(p).map_err(|e| CliError::other(e.to_string()))?,
};
let default_project = crate::scope::project(a.project.clone()).unwrap_or_default();
let store = store_open::open(db).await?;
let mut created = 0i64;
let mut line_no = 0i64;
for raw in content.lines() {
line_no += 1;
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let spec: serde_json::Value = serde_json::from_str(line)
.map_err(|e| CliError::other(format!("line {line_no}: invalid JSON: {e}")))?;
let get_str = |k: &str| {
spec.get(k)
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
};
let mut project = get_str("project");
if project.is_empty() {
project = default_project.clone();
}
if project.is_empty() {
return Err(CliError::validation(format!(
"line {line_no}: project required (set per-record or pass --project)"
)));
}
let project = project.to_uppercase();
let title = get_str("title");
let description = get_str("description");
let status = {
let s = get_str("status");
if s.is_empty() {
None
} else {
Some(parse_status_lined(&s, line_no)?)
}
};
let priority = {
let p = get_str("priority");
if p.is_empty() {
None
} else {
Some(parse_priority_lined(&p, line_no)?)
}
};
let milestone = {
let m = get_str("milestone");
if m.is_empty() {
None
} else {
Some(m)
}
};
let parent_key = {
let p = get_str("parent");
if p.is_empty() {
None
} else {
Some(parse_issue_key_lined(&p, line_no)?)
}
};
let labels: Vec<String> = spec
.get("labels")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let create_project = project.clone();
let create = CreateIssue {
title,
description: Some(description),
status,
priority,
milestone,
parent_key,
due_date: None,
position: None,
};
let issue = store
.call(move |conn| issues::create(conn, &create_project, create))
.await
.map_err(|e| prefix_line_err(e, line_no))?;
for lbl in labels {
let id = issue.id;
let name = lbl.clone();
store
.call(move |conn| {
let issue = issues::get_by_id(conn, id)?.ok_or(cliban_core::Error::NotFound)?;
issues::add_label(conn, &issue, &name)
})
.await
.map_err(|e| {
let code = crate::errors::exit_code_for(&e);
let msg = crate::errors::message_for(&e);
CliError::Coded(code, format!("line {line_no}: attach label {lbl:?}: {msg}"))
})?;
}
created += 1;
if mode.is_json() {
let inputs = issue_json_inputs(&store, &issue).await?;
println!(
"{}",
serde_json::to_string(&build_issue_json(inputs, Detail::Echo)).unwrap()
);
}
}
if !mode.is_json() {
println!("imported {created} issue(s)");
}
Ok(())
}
fn prefix_line_err(e: cliban_core::Error, line_no: i64) -> CliError {
let code = crate::errors::exit_code_for(&e);
let msg = crate::errors::message_for(&e);
CliError::Coded(code, format!("line {line_no}: {msg}"))
}
fn parse_status_lined(s: &str, line_no: i64) -> Result<String, CliError> {
parse_status(s)
.map_err(|e| CliError::Coded(e.code(), format!("line {line_no}: {}", e.message())))
}
fn parse_priority_lined(s: &str, line_no: i64) -> Result<String, CliError> {
parse_priority(s)
.map_err(|e| CliError::Coded(e.code(), format!("line {line_no}: {}", e.message())))
}
fn parse_issue_key_lined(s: &str, line_no: i64) -> Result<String, CliError> {
parse_issue_key(s)
.map_err(|e| CliError::Coded(e.code(), format!("line {line_no}: {}", e.message())))
}
async fn mv(
db: &Option<String>,
key: String,
status: String,
note: Option<String>,
mode: Mode,
teach: Option<Teach>,
) -> CliResult<()> {
let key = parse_issue_key(&key)?;
let status = parse_status(&status)?;
#[cfg(feature = "linear")]
let (push_key, push_status) = (key.clone(), status.clone());
let store = store_open::open(db).await?;
let move_key = key.clone();
let move_status = status.clone();
let from = store
.call(move |conn| {
let issue = issues::get_by_key(conn, &move_key)?.ok_or(cliban_core::Error::NotFound)?;
let from = issue.status.clone();
if from == move_status {
return Ok(from);
}
issues::move_issue(conn, &issue, &move_status)?;
crate::audit::record_move(conn, &issue, &from, &move_status, note.as_deref());
Ok(from)
})
.await?;
let noop = from == status;
#[cfg(feature = "linear")]
if from != push_status {
crate::cmd::sync::push_on_move(db, &push_key).await;
}
if mode.is_json() {
let reload = key.clone();
let issue = store
.call(move |conn| issues::get_by_key(conn, &reload))
.await?
.ok_or(cliban_core::Error::NotFound)?;
let inputs = issue_json_inputs(&store, &issue).await?;
let mut v = build_issue_json(inputs, Detail::Echo);
if let serde_json::Value::Object(m) = &mut v {
if noop {
m.insert("noop".into(), serde_json::json!(true));
}
if let Some(t) = teach {
m.insert(
"canonical".into(),
serde_json::json!(format!("issue {}", t.canonical)),
);
}
}
println!("{}", serde_json::to_string(&v).unwrap());
} else {
match (teach, noop) {
(None, true) => println!("{key} already {status} (nothing to do)"),
(None, false) => println!("moved {key}: {from} → {status}"),
(Some(t), true) => println!(
"{} {key} ({}): already {status} (nothing to do)",
t.verb, t.canonical
),
(Some(t), false) => {
println!("{} {key} ({}): {from} → {status}", t.verb, t.canonical)
}
}
}
Ok(())
}
async fn cat(db: &Option<String>, a: CatArgs) -> CliResult<()> {
let key = parse_issue_key(&a.key)?;
let store = store_open::open(db).await?;
let issue = store
.call(move |conn| issues::get_by_key(conn, &key))
.await?
.ok_or(cliban_core::Error::NotFound)?;
if let Some(section) = &a.section {
let anchor = resolve_section(section)?;
let (start, end, ok) = find_section(&issue.description, &anchor);
if !ok {
return Err(CliError::not_found(format!(
"not found: no ## {anchor} section in {}",
a.key
)));
}
print!("{}", &issue.description[start..end]);
return Ok(());
}
print!("{}", issue.description);
Ok(())
}
async fn rm_reflex(db: &Option<String>, key: String, canonical: Option<&str>) -> CliResult<()> {
let key = parse_issue_key(&key)?;
let store = store_open::open(db).await?;
set_archived_on(&store, key.clone(), true).await?;
let teach = canonical.map(|c| format!(" ({c})")).unwrap_or_default();
println!(
"archived {key}{teach} — cliban archives instead of deleting \
(undo: cliban issue unarchive {key})"
);
Ok(())
}
async fn set_archived_on(store: &Store, key: String, archived: bool) -> CliResult<(Issue, bool)> {
let key = parse_issue_key(&key)?;
let result = store
.call(move |conn| {
let issue = issues::get_by_key(conn, &key)?.ok_or(cliban_core::Error::NotFound)?;
if issue.archived == archived {
return Ok((issue, true));
}
let updated = issues::update(
conn,
&issue,
UpdateIssue {
archived: Some(archived),
..Default::default()
},
)?;
crate::audit::record(
conn,
&issue,
"archive",
if archived { "archived" } else { "unarchived" },
);
Ok((updated, false))
})
.await?;
Ok(result)
}
async fn confirm_issue(
store: &Store,
issue: &Issue,
verb: &str,
noop: bool,
mode: Mode,
) -> CliResult<()> {
if mode.is_json() {
let inputs = issue_json_inputs(store, issue).await?;
let mut v = build_issue_json(inputs, Detail::Echo);
if noop {
if let serde_json::Value::Object(m) = &mut v {
m.insert("noop".into(), serde_json::json!(true));
}
}
println!("{}", serde_json::to_string(&v).unwrap());
} else if noop {
println!("{} already {verb} (nothing to do)", issue.key);
} else {
println!("{verb} {}", issue.key);
}
Ok(())
}
fn parse_branch(branch: &str) -> Option<(String, String)> {
let bytes = branch.as_bytes();
let n = bytes.len();
if n == 0 || !bytes[0].is_ascii_lowercase() {
return None;
}
let mut i = 1;
while i < n && (bytes[i].is_ascii_lowercase() || bytes[i].is_ascii_digit()) {
i += 1;
}
if i < 2 {
return None;
}
if i >= n || bytes[i] != b'-' {
return None;
}
let dash = i;
i += 1;
let seq_start = i;
while i < n && bytes[i].is_ascii_digit() {
i += 1;
}
if i == seq_start {
return None;
}
if i != n && bytes[i] != b'-' {
return None;
}
let project = branch[..dash].to_string();
let seq = branch[seq_start..i].to_string();
Some((project, seq))
}
fn current_branch() -> Result<String, CliError> {
if let Ok(v) = std::env::var("CLIBAN_CURRENT_BRANCH_OVERRIDE") {
if !v.is_empty() {
return Ok(v);
}
}
let out = std::process::Command::new("git")
.args(["branch", "--show-current"])
.output()
.map_err(|e| CliError::other(format!("git branch --show-current: {e}")))?;
if !out.status.success() {
return Err(CliError::other(
"git branch --show-current: command failed".to_string(),
));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
async fn current(db: &Option<String>, mode: Mode) -> CliResult<()> {
let branch = current_branch()?;
let (proj, seq) = parse_branch(&branch).ok_or_else(|| {
CliError::not_found(format!(
"not found: no issue found for current branch {branch:?}"
))
})?;
let key = format!("{}-{}", proj.to_uppercase(), seq);
let store = store_open::open(db).await?;
let lookup = key.clone();
let issue = match store
.call(move |conn| issues::get_by_key(conn, &lookup))
.await?
{
Some(i) => i,
None => {
return Err(CliError::not_found(format!(
"not found: no issue found for current branch {branch:?} (parsed {key})"
)))
}
};
if mode.is_json() {
let inputs = issue_json_inputs(&store, &issue).await?;
println!(
"{}",
serde_json::to_string_pretty(&build_issue_json(inputs, Detail::Full)).unwrap()
);
} else {
println!("{} {}", issue.key, issue.title);
}
Ok(())
}
fn check_cas(issue: &Issue, expected: Option<&str>) -> Result<(), cliban_core::Error> {
let Some(expected) = expected else {
return Ok(());
};
let actual = format_usec(issue.updated_at);
if actual != expected {
return Err(cliban_core::Error::validation(
"updated_at",
&format!(
"stale write: {} was updated at {actual}, you read {expected} — re-read and retry",
issue.key
),
));
}
Ok(())
}
async fn claim_cmd(
db: &Option<String>,
key: String,
by: Option<String>,
force: bool,
mode: Mode,
) -> CliResult<()> {
let key = parse_issue_key(&key)?;
let actor = by
.map(|b| b.trim().to_string())
.filter(|b| !b.is_empty())
.or_else(crate::audit::actor)
.ok_or_else(|| {
CliError::validation("no actor to claim as: pass --by or set CLIBAN_ACTOR")
})?;
let store = store_open::open(db).await?;
let lookup = key.clone();
let as_actor = actor.clone();
let (issue, claim) = store
.call(move |conn| {
let issue = issues::get_by_key(conn, &lookup)?.ok_or(cliban_core::Error::NotFound)?;
let prev = claims::get(conn, issue.id)?;
let claim = claims::claim(conn, &issue, &as_actor, force)?;
match prev {
Some(p) if p.claimed_by == as_actor => {} Some(p) => crate::audit::record(
conn,
&issue,
"claim",
&format!("claimed by {as_actor} (took over from {})", p.claimed_by),
),
None => {
crate::audit::record(conn, &issue, "claim", &format!("claimed by {as_actor}"))
}
}
Ok((issue, claim))
})
.await?;
if mode.is_json() {
println!(
"{}",
serde_json::json!({
"claimed_at": claim.claimed_at,
"claimed_by": claim.claimed_by,
"key": issue.key,
})
);
} else {
println!("claimed {} by {}", issue.key, claim.claimed_by);
}
Ok(())
}
async fn release_cmd(db: &Option<String>, key: String, mode: Mode) -> CliResult<()> {
let key = parse_issue_key(&key)?;
let store = store_open::open(db).await?;
let lookup = key.clone();
let (issue, was_held_by) = store
.call(move |conn| {
let issue = issues::get_by_key(conn, &lookup)?.ok_or(cliban_core::Error::NotFound)?;
let was = claims::release(conn, &issue)?;
if let Some(holder) = &was {
crate::audit::record(
conn,
&issue,
"claim",
&format!("released (was held by {holder})"),
);
}
Ok((issue, was))
})
.await?;
if mode.is_json() {
println!(
"{}",
serde_json::json!({
"key": issue.key,
"released": was_held_by.is_some(),
"was_held_by": was_held_by,
})
);
} else {
match was_held_by {
Some(holder) => println!("released {} (was held by {holder})", issue.key),
None => println!("{} was not claimed", issue.key),
}
}
Ok(())
}
async fn lint_cmd(db: &Option<String>, key: String, mode: Mode) -> CliResult<()> {
let key = parse_issue_key(&key)?;
let store = store_open::open(db).await?;
let lookup = key.clone();
let issue = store
.call(move |conn| issues::get_by_key(conn, &lookup))
.await?
.ok_or(cliban_core::Error::NotFound)?;
let findings = crate::lint::lint_description(&issue.description);
let errors = findings
.iter()
.filter(|f| f.severity == crate::lint::Severity::Error)
.count();
if mode.is_json() {
let list: Vec<serde_json::Value> = findings
.iter()
.map(|f| {
serde_json::json!({
"message": f.message,
"severity": f.severity.as_str(),
})
})
.collect();
println!(
"{}",
serde_json::json!({
"errors": errors,
"findings": list,
"key": issue.key,
"warnings": findings.len() - errors,
})
);
} else if findings.is_empty() {
println!("{}: clean", issue.key);
} else {
for f in &findings {
println!("{}: {}", f.severity.as_str(), f.message);
}
}
if errors > 0 {
return Err(CliError::Coded(
2,
format!("lint: {errors} error(s) in {}", issue.key),
));
}
Ok(())
}
async fn append_section_cmd(db: &Option<String>, a: AppendSectionArgs) -> CliResult<()> {
let key = parse_issue_key(&a.key)?;
let anchor = resolve_section(&a.section)?;
if anchor == "Activity Log" {
return Err(CliError::validation(
"--section activity is owned by `issue log`; append entries there",
));
}
let text = match (a.text, a.text_file) {
(Some(_), Some(_)) => {
return Err(CliError::validation(
"pass the text positionally or via --text-file, not both",
))
}
(Some(t), None) => t,
(None, Some(f)) if f == "-" => crate::stdin_input::read_stdin()?,
(None, Some(f)) => {
std::fs::read_to_string(&f).map_err(|e| CliError::other(format!("read {f}: {e}")))?
}
(None, None) => match crate::stdin_input::fallback()? {
Some(piped) => piped,
None => {
return Err(CliError::validation(
"nothing to append: pass the text positionally or via --text-file",
))
}
},
};
if text.trim().is_empty() {
return Err(CliError::validation("nothing to append: text is blank"));
}
let store = store_open::open(db).await?;
let lookup = key.clone();
let anchor_job = anchor.clone();
let create = a.create_section;
let issue = store
.call(move |conn| {
let issue = issues::get_by_key(conn, &lookup)?.ok_or(cliban_core::Error::NotFound)?;
require_section(&issue, &anchor_job, create)?;
let text = cliban_core::sections::sanitize_section_body(&anchor_job, &text)
.map_err(|m| cliban_core::Error::validation("section", &m))?;
let new_desc =
cliban_core::sections::append_section(&issue.description, &anchor_job, &text);
let updated = issues::update(
conn,
&issue,
UpdateIssue {
description: Some(new_desc),
..Default::default()
},
)?;
crate::audit::record(
conn,
&updated,
"edit",
&format!("appended to ## {anchor_job}"),
);
Ok(updated)
})
.await?;
print_issue_result(
&store,
&issue,
"updated",
crate::output::mode(a.json, a.table),
)
.await
}
#[cfg(test)]
mod tests {
use super::*;
use clap::ValueEnum;
#[test]
fn the_priority_flag_offers_exactly_what_the_store_accepts() {
let from_flag: Vec<&str> = Priority::value_variants()
.iter()
.map(|p| p.as_str())
.collect();
assert_eq!(from_flag, ISSUE_PRIORITIES);
for p in Priority::value_variants() {
let shown = p.to_possible_value().expect("not skipped");
assert_eq!(shown.get_name(), p.as_str());
}
}
#[test]
fn priority_rank_orders_every_variant() {
let mut ranked: Vec<&str> = ISSUE_PRIORITIES.to_vec();
ranked.sort_by_key(|p| priority_rank(p));
assert_eq!(ranked, ["none", "low", "medium", "high", "urgent"]);
}
}