linear-cli 0.2.2

A powerful CLI for Linear.app - manage issues, projects, cycles, and more from your terminal
use anyhow::Result;
use clap::Subcommand;
use serde_json::json;
use tabled::{Table, Tabled};

use crate::api::LinearClient;
use crate::output::{print_json, OutputOptions};
use crate::text::truncate;
use crate::DISPLAY_OPTIONS;

#[derive(Subcommand, Debug)]
pub enum TriageCommands {
    /// List triage issues (unassigned, no project)
    List {
        /// Team key or ID
        #[arg(short, long)]
        team: Option<String>,
    },
    /// Assign issue to self and move to backlog
    Claim {
        /// Issue identifier (e.g., LIN-123)
        id: String,
    },
    /// Snooze issue for later
    Snooze {
        /// Issue identifier
        id: String,
        /// Snooze duration (e.g., 1d, 1w)
        #[arg(short, long, default_value = "1d")]
        duration: String,
    },
}

#[derive(Tabled)]
struct TriageRow {
    #[tabled(rename = "ID")]
    identifier: String,
    #[tabled(rename = "Title")]
    title: String,
    #[tabled(rename = "Created")]
    created: String,
    #[tabled(rename = "Team")]
    team: String,
}

pub async fn handle(cmd: TriageCommands, output: &OutputOptions) -> Result<()> {
    match cmd {
        TriageCommands::List { team } => list_triage(team, output).await,
        TriageCommands::Claim { id } => claim_issue(&id, output).await,
        TriageCommands::Snooze { id, duration } => snooze_issue(&id, &duration, output).await,
    }
}

async fn list_triage(team: Option<String>, output: &OutputOptions) -> Result<()> {
    let client = LinearClient::new()?;

    let query = r#"
        query($filter: IssueFilter) {
            issues(first: 50, filter: $filter) {
                nodes {
                    id
                    identifier
                    title
                    createdAt
                    team {
                        key
                        name
                    }
                    state {
                        name
                        type
                    }
                }
            }
        }
    "#;

    // Filter for triage: no assignee, state is "triage" type or backlog
    let mut filter = json!({
        "assignee": { "null": true },
        "state": { "type": { "eq": "triage" } }
    });

    if let Some(ref t) = team {
        filter["team"] = json!({ "key": { "eq": t } });
    }

    let result = client
        .query(query, Some(json!({ "filter": filter })))
        .await?;
    let issues = &result["data"]["issues"]["nodes"];

    if output.is_json() {
        print_json(issues, output)?;
    } else {
        let display = DISPLAY_OPTIONS.get().cloned().unwrap_or_default();
        let max_width = display.max_width(50);

        let rows: Vec<TriageRow> = issues
            .as_array()
            .unwrap_or(&vec![])
            .iter()
            .map(|i| TriageRow {
                identifier: i["identifier"].as_str().unwrap_or("-").to_string(),
                title: truncate(i["title"].as_str().unwrap_or("-"), max_width),
                created: i["createdAt"]
                    .as_str()
                    .unwrap_or("-")
                    .chars()
                    .take(10)
                    .collect(),
                team: i["team"]["key"].as_str().unwrap_or("-").to_string(),
            })
            .collect();

        if rows.is_empty() {
            println!("No triage issues found - inbox zero!");
        } else {
            println!("{}", Table::new(rows));
        }
    }

    Ok(())
}

async fn claim_issue(id: &str, output: &OutputOptions) -> Result<()> {
    let client = LinearClient::new()?;

    // Get current user
    let me_query = r#"query { viewer { id } }"#;
    let me_result = client.query(me_query, None).await?;
    let my_id = me_result["data"]["viewer"]["id"]
        .as_str()
        .ok_or_else(|| anyhow::anyhow!("Could not get current user"))?;

    // Update issue
    let mutation = r#"
        mutation($id: String!, $assigneeId: String!) {
            issueUpdate(id: $id, input: { assigneeId: $assigneeId }) {
                success
                issue {
                    id
                    identifier
                    title
                    assignee { name }
                }
            }
        }
    "#;

    let result = client
        .mutate(
            mutation,
            Some(json!({ "id": id, "assigneeId": my_id })),
        )
        .await?;

    if output.is_json() {
        print_json(&result["data"]["issueUpdate"], output)?;
    } else {
        let issue = &result["data"]["issueUpdate"]["issue"];
        println!(
            "Claimed {} - {}",
            issue["identifier"].as_str().unwrap_or(id),
            issue["title"].as_str().unwrap_or("")
        );
    }

    Ok(())
}

async fn snooze_issue(id: &str, duration: &str, output: &OutputOptions) -> Result<()> {
    let client = LinearClient::new()?;

    // Parse duration to calculate snooze until date
    let days = match duration {
        "1d" => 1,
        "2d" => 2,
        "3d" => 3,
        "1w" => 7,
        "2w" => 14,
        _ => duration
            .trim_end_matches('d')
            .parse::<i64>()
            .unwrap_or(1),
    };

    let snooze_until = chrono::Utc::now() + chrono::Duration::days(days);

    let mutation = r#"
        mutation($id: String!, $snoozedUntilAt: DateTime!) {
            issueUpdate(id: $id, input: { snoozedUntilAt: $snoozedUntilAt }) {
                success
                issue {
                    id
                    identifier
                    snoozedUntilAt
                }
            }
        }
    "#;

    let result = client
        .mutate(
            mutation,
            Some(json!({ "id": id, "snoozedUntilAt": snooze_until.to_rfc3339() })),
        )
        .await?;

    if output.is_json() {
        print_json(&result["data"]["issueUpdate"], output)?;
    } else {
        println!("Snoozed {} for {}", id, duration);
    }

    Ok(())
}