use std::collections::HashMap;
use serde_json::{json, Map, Value};
use chrono::{DateTime, Utc};
use cliban_core::contexts::issues::ListOpts;
use cliban_core::contexts::{activity_log, issues, milestones, projects};
use cliban_core::schema::{ActivityLogEntry, Issue};
use cliban_core::time::{format_usec, relative};
use crate::brief::elide;
use crate::descmd;
use crate::errors::CliResult;
use crate::store_open;
const FEED_TIME_FORMAT: &str = "%Y-%m-%dT%H:%MZ";
const TEXT_WIDTH: usize = 110;
#[derive(clap::Args)]
pub struct ActivityArgs {
#[arg(long)]
since: Option<String>,
#[arg(long, short = 'p')]
project: Option<String>,
#[arg(long, short = 'm')]
milestone: Option<String>,
#[arg(long)]
issue: Option<String>,
#[arg(long)]
archived: bool,
#[arg(long)]
limit: Option<i64>,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "json")]
table: bool,
#[arg(long, conflicts_with_all = ["json", "table"])]
brief: bool,
}
struct Event {
ts: DateTime<Utc>,
key: String,
project: String,
kind: String,
status: String,
title: String,
milestone: Option<String>,
actor: Option<String>,
message: Option<String>,
}
impl Event {
fn text(&self) -> String {
let body = self.message.as_deref().unwrap_or(&self.title);
match &self.actor {
Some(a) => format!("[{a}] {body}"),
None => body.to_string(),
}
}
}
pub async fn run(db: &Option<String>, a: ActivityArgs) -> CliResult<()> {
let since_str = a.since.clone().unwrap_or_else(|| {
if a.issue.is_some() {
"1970-01-01".to_string()
} else {
"1d".to_string()
}
});
let since = crate::since::parse(&since_str, "--since")?;
let limit = a.limit.unwrap_or(if a.issue.is_some() { 0 } else { 15 });
let issue_key = a.issue.as_deref().map(str::to_uppercase);
let project = crate::scope::project(a.project.clone());
let store = store_open::open(db).await?;
let (list_project, list_milestone, archived) =
(project.clone(), a.milestone.clone(), a.archived);
let recorded = store
.call(move |conn| activity_log::list_since(conn, since))
.await?;
let mut by_issue: HashMap<i64, Vec<&ActivityLogEntry>> = HashMap::new();
for entry in &recorded {
by_issue.entry(entry.issue_id).or_default().push(entry);
}
let rows = store
.call(move |conn| {
let mut list = issues::list(
conn,
ListOpts {
project: list_project.as_deref(),
milestone: list_milestone.as_deref(),
archived: false,
..Default::default()
},
)?;
if archived {
list.extend(issues::list(
conn,
ListOpts {
project: list_project.as_deref(),
milestone: list_milestone.as_deref(),
archived: true,
..Default::default()
},
)?);
}
let mut out = Vec::with_capacity(list.len());
for i in list {
let project_key = projects::get_by_id(conn, i.project_id)?
.map(|p| p.key)
.unwrap_or_default();
let milestone = match i.milestone_id {
Some(id) => milestones::get_by_id(conn, id)?.map(|m| m.name),
None => None,
};
out.push((i, project_key, milestone));
}
Ok(out)
})
.await?;
let mut events = Vec::new();
for (issue, project_key, milestone) in &rows {
events.extend(events_for(
issue,
project_key,
milestone.as_deref(),
since,
by_issue.get(&issue.id).map(Vec::as_slice).unwrap_or(&[]),
));
}
if let Some(k) = &issue_key {
events.retain(|e| &e.key == k);
}
events.sort_by(|a, b| b.ts.cmp(&a.ts).then_with(|| a.key.cmp(&b.key)));
if limit > 0 {
events.truncate(limit as usize);
}
let now = Utc::now();
if a.brief {
let evs: Vec<_> = events
.iter()
.map(|e| {
let text = match (e.kind.as_str(), &e.message) {
("created" | "completed", None) => {
format!("{} was {}: {}", e.key, e.kind, e.title.trim_end_matches('.'))
}
(_, m) => format!("{}: {}", e.key, m.as_deref().unwrap_or(&e.title)),
};
(e.ts, text)
})
.collect();
print!("{}", crate::brief::feed(&since_str, &evs, now));
return Ok(());
}
if crate::output::mode(a.json, a.table).is_json() {
for e in &events {
println!("{}", serde_json::to_string(&event_json(e)).unwrap());
}
return Ok(());
}
if events.is_empty() {
println!("no activity since {since_str}");
return Ok(());
}
let key_width = events
.iter()
.map(|e| e.key.chars().count())
.max()
.unwrap_or(8)
.max(8);
for e in &events {
println!(
"{} {:<key_width$} {:<9} {:<11} {}",
e.ts.format(FEED_TIME_FORMAT),
e.key,
e.kind,
e.status,
elide(&e.text(), TEXT_WIDTH),
);
}
eprintln!(
"— {} event(s) since {} ({})",
events.len(),
since_str,
relative(since, now)
);
Ok(())
}
fn events_for(
issue: &Issue,
project: &str,
milestone: Option<&str>,
since: DateTime<Utc>,
recorded: &[&ActivityLogEntry],
) -> Vec<Event> {
let make = |ts: DateTime<Utc>, kind: &str, message: Option<String>| Event {
ts,
key: issue.key.clone(),
project: project.to_string(),
kind: kind.to_string(),
status: issue.status.clone(),
title: issue.title.clone(),
milestone: milestone.map(str::to_string),
actor: None,
message,
};
let mut out = Vec::new();
if issue.inserted_at >= since {
out.push(make(issue.inserted_at, "created", None));
}
if let Some(done) = issue.completed_at.filter(|t| *t >= since) {
out.push(make(done, "completed", None));
}
if out.is_empty() && issue.updated_at >= since {
out.push(make(issue.updated_at, "updated", None));
}
for entry in recorded {
let mut e = make(entry.ts, &entry.kind, Some(entry.message.clone()));
e.actor = crate::audit::actor_of(&entry.extra);
out.push(e);
}
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();
for (ts, message) in descmd::parse_activity_log(&issue.description) {
if ts >= since && !already.contains(&crate::audit::log_dedupe_key(ts, &message)) {
out.push(make(ts, "log", Some(message)));
}
}
out
}
fn event_json(e: &Event) -> Value {
let mut m = Map::new();
if let Some(a) = &e.actor {
m.insert("actor".into(), json!(a));
}
m.insert("key".into(), json!(e.key));
m.insert("kind".into(), json!(e.kind));
if let Some(msg) = &e.message {
m.insert("message".into(), json!(msg));
}
if let Some(ms) = &e.milestone {
m.insert("milestone".into(), json!(ms));
}
m.insert("project".into(), json!(e.project));
m.insert("title".into(), json!(e.title));
m.insert(
"ts".into(),
json!(crate::output::trim_usec(&format_usec(e.ts))),
);
Value::Object(m)
}