use std::io::{Read, Write};
use cliban_core::contexts::issues::{CreateIssue, ListOpts, UpdateIssue};
use cliban_core::contexts::{issues, milestones, relations};
use cliban_core::schema::{Issue, ISSUE_PRIORITIES, ISSUE_STATUSES};
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, 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),
Tick(TickArgs),
Promote(PromoteArgs),
#[command(name = "archive-done")]
ArchiveDone(ArchiveDoneArgs),
Import(ImportArgs),
Mv {
key: String,
status: String,
#[arg(long)]
note: Option<String>,
},
#[command(hide = true)]
Rm { key: String },
Archive { key: String },
Unarchive { key: String },
Current {
#[arg(long)]
json: bool,
},
Blocked {
#[arg(long)]
project: Option<String>,
#[arg(long)]
json: bool,
#[arg(long)]
full: bool,
},
}
#[derive(clap::Args)]
pub struct ShowArgs {
key: String,
#[arg(long)]
json: bool,
#[arg(long)]
section: Option<String>,
#[arg(long)]
pager: bool,
}
#[derive(clap::Args)]
pub struct LsArgs {
#[arg(long)]
project: Option<String>,
#[arg(long)]
status: Option<String>,
#[arg(long)]
priority: Option<String>,
#[arg(long)]
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)]
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,
}
#[derive(clap::Args)]
pub struct AddArgs {
#[arg(long)]
project: String,
#[arg(long)]
title: Option<String>,
#[arg(long)]
description: Option<String>,
#[arg(long = "description-file")]
description_file: Option<String>,
#[arg(long)]
parent: Option<String>,
#[arg(long)]
milestone: Option<String>,
#[arg(long)]
priority: Option<String>,
#[arg(long)]
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)]
editor: bool,
}
#[derive(clap::Args)]
pub struct EditArgs {
key: String,
#[arg(long)]
title: Option<String>,
#[arg(long)]
description: Option<String>,
#[arg(long = "description-file")]
description_file: Option<String>,
#[arg(long)]
priority: Option<String>,
#[arg(long)]
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)]
json: bool,
}
#[derive(clap::Args)]
pub struct LogArgs {
key: String,
message: Option<String>,
#[arg(long = "message-file")]
message_file: Option<String>,
#[arg(long)]
json: bool,
}
#[derive(clap::Args)]
pub struct TickArgs {
key: String,
#[arg(long)]
task: i32,
#[arg(long)]
step: i32,
#[arg(long)]
json: bool,
}
#[derive(clap::Args)]
pub struct PromoteArgs {
key: String,
#[arg(long)]
task: 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,
}
#[derive(clap::Args)]
pub struct ArchiveDoneArgs {
#[arg(long)]
project: Option<String>,
#[arg(long)]
auto: bool,
#[arg(long)]
json: bool,
}
#[derive(clap::Args)]
pub struct ImportArgs {
file_arg: Option<String>,
#[arg(long)]
file: Option<String>,
#[arg(long)]
project: Option<String>,
#[arg(long)]
json: 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).await,
IssueCmd::Tick(a) => tick(db, a).await,
IssueCmd::Promote(a) => promote(db, a).await,
IssueCmd::ArchiveDone(a) => archive_done(db, a).await,
IssueCmd::Import(a) => import(db, a).await,
IssueCmd::Mv { key, status, note } => mv(db, key, status, note).await,
IssueCmd::Rm { key } => {
let key = parse_issue_key(&key)?;
set_archived(db, key.clone(), true).await?;
println!(
"archived {key} — cliban archives instead of deleting \
(undo: cliban issue unarchive {key})"
);
Ok(())
}
IssueCmd::Archive { key } => set_archived(db, key, true).await,
IssueCmd::Unarchive { key } => set_archived(db, key, false).await,
IssueCmd::Current { json } => current(db, json).await,
IssueCmd::Blocked {
project,
json,
full,
} => blocked(db, project, json, full).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)"
)))
}
}
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)> {
if let Some(file) = description_file {
if description.is_some() {
return Err(CliError::validation(
"--description and --description-file are mutually exclusive",
));
}
if file == "-" {
return Ok((read_stdin()?, true));
}
match std::fs::read_to_string(&file) {
Ok(s) => Ok((s, true)),
Err(e) => Err(CliError::validation(e.to_string())),
}
} else if let Some(desc) = description {
if desc == "-" {
Ok((read_stdin()?, true))
} else {
Ok((desc, true))
}
} else {
Ok((String::new(), false))
}
}
fn read_stdin() -> CliResult<String> {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(|e| CliError::other(e.to_string()))?;
Ok(buf)
}
async fn add(db: &Option<String>, a: AddArgs) -> CliResult<()> {
let project_key = a.project.to_uppercase();
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(
"--title 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.filter(|p| !p.is_empty());
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", a.json).await
}
async fn print_issue_result(store: &Store, issue: &Issue, verb: &str, json: bool) -> CliResult<()> {
if json {
let inputs = issue_json_inputs(store, issue).await?;
println!(
"{}",
serde_json::to_string_pretty(&build_issue_json(inputs, Detail::Full)).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 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,
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 section_anchor(s: &str) -> Result<&'static str, CliError> {
match s {
"spec" => Ok("Spec"),
"plan" => Ok("Plan"),
"activity" => Ok("Activity Log"),
"notes" => Ok("Notes"),
_ => Err(CliError::validation(format!(
"invalid --section {s:?} (want spec|plan|activity|notes)"
))),
}
}
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 let Some(section) = &a.section {
let anchor = section_anchor(section)?;
let recorded = if anchor == "Activity Log" {
let id = issue.id;
store
.call(move |conn| {
cliban_core::contexts::activity_log::list_for_issue(conn, id, 200)
})
.await?
} else {
Vec::new()
};
let (start, end, ok) = find_section(&issue.description, anchor);
if !ok && recorded.is_empty() {
return Err(CliError::not_found(format!(
"not found: no ## {anchor} section in {}",
a.key
)));
}
let written = if ok {
&issue.description[start..end]
} else {
""
};
if recorded.is_empty() {
print!("{written}");
} else {
print!("{}", merge_activity_view(written, &recorded));
}
return Ok(());
}
if a.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 merge_activity_view(
written: &str,
recorded: &[cliban_core::schema::ActivityLogEntry],
) -> String {
const STAMP: &str = "%Y-%m-%dT%H:%MZ";
let already: std::collections::HashSet<(i64, String)> = recorded
.iter()
.filter(|e| e.kind == "log")
.map(|e| crate::audit::log_dedupe_key(e.ts, &e.message))
.collect();
let mut lines: Vec<(chrono::DateTime<Utc>, String)> =
descmd::parse_activity_log(&format!("## Activity Log\n{written}"))
.into_iter()
.filter(|(ts, msg)| !already.contains(&crate::audit::log_dedupe_key(*ts, msg)))
.map(|(ts, msg)| (ts, format!("- {} — {msg}", ts.format(STAMP))))
.collect();
for e in recorded {
let who = crate::audit::actor_of(&e.extra)
.map(|a| format!("[{a}] "))
.unwrap_or_default();
lines.push((
e.ts,
format!("- {} — {who}{}", e.ts.format(STAMP), e.message),
));
}
lines.sort_by_key(|(ts, _)| *ts);
let mut out = String::from("\n");
for (_, line) in &lines {
out.push_str(line);
out.push('\n');
}
out
}
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 = a.project.as_deref().map(str::to_uppercase);
let status = match &a.status {
Some(s) if !s.is_empty() => Some(parse_status(s)?),
_ => None,
};
let priority = match &a.priority {
Some(p) if !p.is_empty() => Some(parse_priority(p)?),
_ => None,
};
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);
}
base_order(&mut issues);
if let Some(spec) = &sort {
sort_issues(&mut issues, spec);
}
if a.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: a.project.as_deref().map(str::to_uppercase),
label: a.label.clone(),
milestone: a.milestone.clone(),
status: a.status.clone(),
priority: a.priority.clone(),
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 a.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(())
}
fn project_prefix(key: &str) -> &str {
match key.rfind('-') {
Some(idx) => &key[..idx],
None => key,
}
}
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 title = a.title.clone(); let priority = match &a.priority {
Some(p) => Some(parse_priority(p)?),
None => None,
};
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();
if has_field_update {
let lookup = key.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()
};
store
.call(move |conn| {
let issue =
issues::get_by_key(conn, &lookup)?.ok_or(cliban_core::Error::NotFound)?;
issues::update(conn, &issue, upd)?;
Ok(())
})
.await?;
} else {
let lookup = key.clone();
store
.call(move |conn| issues::get_by_key(conn, &lookup))
.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 {
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", a.json).await
}
async fn log(db: &Option<String>, a: LogArgs) -> CliResult<()> {
let key = parse_issue_key(&a.key)?;
let mut msg = a.message.clone().unwrap_or_default();
if let Some(file) = &a.message_file {
if !msg.is_empty() {
return Err(CliError::validation(
"pass <message> OR --message-file, not both",
));
}
let content = if file == "-" {
read_stdin()?
} else {
std::fs::read_to_string(file).map_err(|e| CliError::validation(e.to_string()))?
};
msg = content.trim_end_matches('\n').to_string();
}
if msg.is_empty() {
return Err(CliError::validation(
"message required (positional or --message-file)",
));
}
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 a.json {
let mut m = serde_json::Map::new();
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_pretty(&serde_json::Value::Object(m)).unwrap()
);
} 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 = a.task;
let step = a.step;
let updated_at = 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 = match descmd::tick_step(&issue.description, task, step) {
Ok(d) => d,
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)
})
.await?;
if a.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));
m.insert("step".into(), serde_json::json!(a.step));
m.insert("task".into(), serde_json::json!(a.task));
m.insert("updated_at".into(), serde_json::json!(updated_at));
println!(
"{}",
serde_json::to_string_pretty(&serde_json::Value::Object(m)).unwrap()
);
} else {
println!("ticked {} Task {} Step {}", a.key, a.task, a.step);
}
Ok(())
}
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.title.is_empty() {
return Err(CliError::validation("--title required"));
}
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 = 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 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 step_obj = find_step_for_rewrite(&parent_desc, task, step);
let raw = match step_obj {
Some(s) => s,
None => {
return Err(cliban_core::Error::validation(
"plan",
&format!("cannot find Task {task} Step {step} in parent description"),
))
}
};
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)
})
.await?;
if a.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!(a.task));
println!(
"{}",
serde_json::to_string_pretty(&serde_json::Value::Object(m)).unwrap()
);
} else {
println!(
"promoted {} Task {} Step {} → {}",
a.key, a.task, a.step, new_key
);
}
Ok(())
}
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 archive_done(db: &Option<String>, a: ArchiveDoneArgs) -> CliResult<()> {
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 a.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_pretty(&serde_json::Value::Object(m)).unwrap()
);
} else {
println!("archived {n} done issue(s) (auto sweep)");
}
return Ok(());
}
let project = a.project.filter(|p| !p.is_empty());
let project = match project {
Some(p) => p.to_uppercase(),
None => {
return Err(CliError::validation(
"--project is required (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 a.json {
let mut m = serde_json::Map::new();
m.insert("archived".into(), serde_json::json!(n));
println!(
"{}",
serde_json::to_string_pretty(&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 path = a.file_arg.clone().or(a.file.clone());
let content = match path.as_deref() {
None | Some("") | Some("-") => read_stdin()?,
Some(p) => std::fs::read_to_string(p).map_err(|e| CliError::other(e.to_string()))?,
};
let default_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 a.json {
let inputs = issue_json_inputs(&store, &issue).await?;
println!(
"{}",
serde_json::to_string(&build_issue_json(inputs, Detail::Full)).unwrap()
);
}
}
if !a.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>,
) -> CliResult<()> {
let key = parse_issue_key(&key)?;
let status = parse_status(&status)?;
let store = store_open::open(db).await?;
store
.call(move |conn| {
let issue = issues::get_by_key(conn, &key)?.ok_or(cliban_core::Error::NotFound)?;
let from = issue.status.clone();
issues::move_issue(conn, &issue, &status)?;
crate::audit::record_move(conn, &issue, &from, &status, note.as_deref());
Ok(())
})
.await?;
Ok(())
}
async fn set_archived(db: &Option<String>, key: String, archived: bool) -> CliResult<()> {
let key = parse_issue_key(&key)?;
let store = store_open::open(db).await?;
store
.call(move |conn| {
let issue = issues::get_by_key(conn, &key)?.ok_or(cliban_core::Error::NotFound)?;
issues::update(
conn,
&issue,
UpdateIssue {
archived: Some(archived),
..Default::default()
},
)?;
crate::audit::record(
conn,
&issue,
"archive",
if archived { "archived" } else { "unarchived" },
);
Ok(())
})
.await?;
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>, json: bool) -> 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 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(())
}
async fn blocked(
db: &Option<String>,
project: Option<String>,
json: bool,
full: bool,
) -> CliResult<()> {
let project = project.map(|p| p.to_uppercase()).filter(|p| !p.is_empty());
let store = store_open::open(db).await?;
let pk = project.clone();
let mut issues = store
.call(move |conn| relations::list_blocked(conn, pk.as_deref()))
.await?;
base_order(&mut issues);
if 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(full)))
.unwrap()
);
}
return Ok(());
}
let rows = issue_rows(&store, &issues).await?;
print!("{}", write_issue_table(&rows));
Ok(())
}