use anyhow::{Result, bail};
use super::Linear;
use crate::core::entity::timestamp::parse_timestamp;
use crate::core::entity::{Cycle, IssueRef, Label, Priority, Project, TeamId, User};
use crate::core::message::Message;
use crate::core::store::Store;
use crate::core::usecase;
pub fn is_none(text: &str) -> bool {
text.trim().eq_ignore_ascii_case("none")
}
pub fn pick<'a, T>(
what: &str,
wanted: &str,
items: &'a [T],
names: impl Fn(&T) -> Vec<String>,
label: impl Fn(&T) -> String,
) -> Result<&'a T> {
let wanted = wanted.trim();
let found: Vec<&T> = items
.iter()
.filter(|item| names(item).iter().any(|n| n.eq_ignore_ascii_case(wanted)))
.collect();
match found.as_slice() {
[one] => Ok(one),
[] => {
let mut choices: Vec<String> = items.iter().map(&label).collect();
choices.dedup();
if choices.is_empty() {
bail!("no {what} {wanted}: this team has none")
}
bail!("no {what} {wanted} (choices: {})", choices.join(", "))
}
many => {
let candidates: Vec<String> = many.iter().map(|item| label(item)).collect();
bail!(
"{what} {wanted} is ambiguous: it could be {}",
candidates.join(", ")
)
}
}
}
pub fn priority(level: &str) -> Result<Priority> {
Priority::ALL
.into_iter()
.find(|p| p.label().eq_ignore_ascii_case(level.trim()))
.ok_or_else(|| {
anyhow::anyhow!("no priority {level} (choices: urgent, high, medium, low, none)")
})
}
pub fn estimate(text: &str) -> Result<Option<u32>> {
if is_none(text) {
return Ok(None);
}
text.trim()
.parse()
.map(Some)
.map_err(|_| anyhow::anyhow!("not an estimate: {text} (a whole number, or none)"))
}
fn person_name(user: &User) -> String {
user.display_name
.clone()
.unwrap_or_else(|| user.name.clone())
}
pub struct Team<'a, L: Linear> {
linear: &'a L,
pub id: TeamId,
members: Option<Vec<User>>,
}
impl<'a, L: Linear> Team<'a, L> {
pub fn new(linear: &'a L, id: TeamId) -> Self {
Self {
linear,
id,
members: None,
}
}
async fn members(&mut self) -> Result<&[User]> {
if self.members.is_none() {
let request = usecase::team::Request::Context {
team_id: self.id.clone(),
};
let Message::TeamContext { members, .. } = self.linear.run(request).await? else {
bail!("unexpected answer to a team request");
};
self.members = Some(members);
}
Ok(self.members.as_deref().unwrap_or_default())
}
pub async fn assignee(&mut self, text: &str) -> Result<Option<User>> {
if is_none(text) {
return Ok(None);
}
if text.trim().eq_ignore_ascii_case("me") {
let Message::Viewer { id, .. } = self.linear.run(usecase::user::load()).await? else {
bail!("unexpected answer to a viewer request");
};
let me = self.members().await?.iter().find(|u| u.id == id).cloned();
return Ok(Some(me.unwrap_or(User {
id,
name: "me".into(),
email: None,
display_name: None,
})));
}
let members = self.members().await?;
let user = pick(
"member",
text,
members,
|u| {
let mut names = vec![u.name.clone()];
names.extend(u.display_name.clone());
names.extend(u.email.clone());
names
},
|u| match &u.email {
Some(email) => format!("{} <{email}>", person_name(u)),
None => person_name(u),
},
)?;
Ok(Some(user.clone()))
}
pub async fn labels(&self, names: &[&str]) -> Result<Vec<Label>> {
if names.is_empty() {
return Ok(Vec::new());
}
let request = usecase::team::load_labels(self.id.clone());
let Message::Labels { labels, team_id } = self.linear.run(request).await? else {
bail!("unexpected answer to a labels request");
};
if team_id != self.id {
bail!("Linear answered with another team's labels");
}
names
.iter()
.map(|name| {
pick(
"label",
name,
&labels,
|l| vec![l.name.clone()],
|l| l.name.clone(),
)
.cloned()
})
.collect()
}
pub async fn project(&self, text: &str) -> Result<Option<Project>> {
if is_none(text) {
return Ok(None);
}
let open = usecase::project::open_team_projects(&mut Store::default(), self.id.clone());
let Some(request) = open.request() else {
bail!("the team's projects could not be asked for");
};
let Message::Projects { page, .. } = self.linear.run(request).await? else {
bail!("unexpected answer to a projects request");
};
let project = pick(
"project",
text,
&page.items,
|p| vec![p.name.clone()],
|p| p.name.clone(),
)?;
Ok(Some(project.clone()))
}
pub async fn cycle(&self, text: &str, now: u64) -> Result<Option<Cycle>> {
if is_none(text) {
return Ok(None);
}
let open = usecase::cycle::open_team_cycles(&mut Store::default(), self.id.clone());
let Some(request) = open.request() else {
bail!("the team's cycles could not be asked for");
};
let Message::Cycles { page, .. } = self.linear.run(request).await? else {
bail!("unexpected answer to a cycles request");
};
if text.trim().eq_ignore_ascii_case("current") {
return current_cycle(&page.items, now)
.cloned()
.map(Some)
.ok_or_else(|| anyhow::anyhow!("the team has no cycle under way"));
}
let cycle = pick("cycle", text, &page.items, cycle_names, Cycle::label)?;
Ok(Some(cycle.clone()))
}
}
fn cycle_names(cycle: &Cycle) -> Vec<String> {
let mut names = vec![cycle.label()];
names.extend(cycle.name.clone());
if let Some(number) = cycle.number {
names.push(format!("{number}"));
names.push(format!("Cycle {number}"));
}
names
}
pub fn current_cycle(cycles: &[Cycle], now: u64) -> Option<&Cycle> {
let at = |text: &Option<String>| {
let text = text.as_deref()?;
parse_timestamp(&format!("{}Z", text.get(..19)?))
};
cycles.iter().find(|c| {
matches!((at(&c.starts_at), at(&c.ends_at)), (Some(start), Some(end)) if start <= now && now < end)
})
}
pub async fn parent(linear: &impl Linear, key: &str) -> Result<Option<IssueRef>> {
if is_none(key) {
return Ok(None);
}
let issue = super::issue::fetch(linear, key).await?;
Ok(Some(IssueRef {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
state: issue.state,
}))
}
#[cfg(test)]
mod tests {
use super::*;
fn labels() -> Vec<Label> {
serde_json::from_value(serde_json::json!([
{ "id": "l1", "name": "Bug" },
{ "id": "l2", "name": "bug" },
{ "id": "l3", "name": "Feature" },
]))
.unwrap()
}
fn by_name(l: &Label) -> Vec<String> {
vec![l.name.clone()]
}
#[test]
fn a_name_matches_in_any_case() {
let labels = labels();
let found = pick("label", "feature", &labels, by_name, |l| l.name.clone()).unwrap();
assert_eq!(found.id, "l3");
}
#[test]
fn an_unknown_name_lists_the_choices() {
let labels = labels();
let err = pick("label", "Chore", &labels, by_name, |l| l.name.clone()).unwrap_err();
assert_eq!(
err.to_string(),
"no label Chore (choices: Bug, bug, Feature)"
);
}
#[test]
fn an_ambiguous_name_lists_the_candidates() {
let labels = labels();
let err = pick("label", "BUG", &labels, by_name, |l| l.name.clone()).unwrap_err();
assert_eq!(
err.to_string(),
"label BUG is ambiguous: it could be Bug, bug"
);
}
#[test]
fn priorities_and_estimates_parse_or_say_what_is_valid() {
assert_eq!(priority("LOW").unwrap(), Priority::Low);
assert!(
priority("huge")
.unwrap_err()
.to_string()
.contains("urgent, high")
);
assert_eq!(estimate("3").unwrap(), Some(3));
assert_eq!(estimate("none").unwrap(), None);
assert!(estimate("two").is_err());
}
#[test]
fn the_current_cycle_is_the_one_under_way() {
let cycles: Vec<Cycle> = serde_json::from_value(serde_json::json!([
{ "id": "c1", "number": 1, "startsAt": "2026-09-01T00:00:00.000Z", "endsAt": "2026-09-15T00:00:00.000Z" },
{ "id": "c2", "number": 2, "startsAt": "2026-09-15T00:00:00.000Z", "endsAt": "2026-09-29T00:00:00.000Z" },
]))
.unwrap();
let now = parse_timestamp("2026-09-26T12:00:00Z").unwrap();
assert_eq!(current_cycle(&cycles, now).unwrap().id, "c2");
let later = parse_timestamp("2026-10-26T12:00:00Z").unwrap();
assert!(current_cycle(&cycles, later).is_none());
let cycle = pick("cycle", "cycle 1", &cycles, cycle_names, Cycle::label).unwrap();
assert_eq!(cycle.id, "c1");
}
}