xbp 10.39.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
//! Linear issue comments.

use super::require_interactive;
use crate::cli::commands::{LinearCommentCmd, LinearCommentsCmd};
use crate::cli::ui::Loader;
use crate::commands::linear::{linear_graphql_errors, linear_graphql_request};
use crate::commands::text_util::truncate_chars;
use crate::commands::linear_cmd::issues::get_issue;
use crate::commands::terminal_table::{render_table, TableStyle};
use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, Input};
use serde::Deserialize;
use serde_json::{json, Value as JsonValue};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinearComment {
    pub id: String,
    pub body: String,
    pub created_at: Option<String>,
    pub user_name: Option<String>,
}

#[derive(Debug, Deserialize)]
struct CommentsPage {
    nodes: Vec<CommentNode>,
}

#[derive(Debug, Deserialize)]
struct CommentNode {
    id: String,
    body: String,
    #[serde(default, rename = "createdAt")]
    created_at: Option<String>,
    #[serde(default)]
    user: Option<UserRef>,
}

#[derive(Debug, Deserialize)]
struct UserRef {
    #[serde(default)]
    name: Option<String>,
}

pub async fn list_comments(
    api_key: &str,
    issue_id_or_identifier: &str,
) -> Result<Vec<LinearComment>, String> {
    let issue = get_issue(api_key, issue_id_or_identifier).await?;
    let body = linear_graphql_request(
        api_key,
        &json!({
            "query": r#"
                query XbpLinearComments($id: String!) {
                  issue(id: $id) {
                    comments(first: 100, orderBy: createdAt) {
                      nodes {
                        id
                        body
                        createdAt
                        user { name }
                      }
                    }
                  }
                }
            "#,
            "variables": { "id": issue.id }
        }),
    )
    .await?;
    linear_graphql_errors(&body)?;
    let page = body
        .get("data")
        .and_then(|d| d.get("issue"))
        .and_then(|i| i.get("comments"))
        .cloned()
        .ok_or_else(|| "Linear comments query returned no data.".to_string())?;
    let page: CommentsPage = serde_json::from_value(page)
        .map_err(|e| format!("Failed to decode Linear comments: {e}"))?;
    Ok(page
        .nodes
        .into_iter()
        .map(|n| LinearComment {
            id: n.id,
            body: n.body,
            created_at: n.created_at,
            user_name: n.user.and_then(|u| u.name),
        })
        .collect())
}

pub async fn create_comment(
    api_key: &str,
    issue_id_or_identifier: &str,
    body_text: &str,
) -> Result<LinearComment, String> {
    let issue = get_issue(api_key, issue_id_or_identifier).await?;
    let body_text = body_text.trim();
    if body_text.is_empty() {
        return Err("Comment body cannot be empty.".into());
    }
    let body = linear_graphql_request(
        api_key,
        &json!({
            "query": r#"
                mutation XbpLinearCommentCreate($input: CommentCreateInput!) {
                  commentCreate(input: $input) {
                    success
                    comment {
                      id
                      body
                      createdAt
                      user { name }
                    }
                  }
                }
            "#,
            "variables": {
                "input": {
                    "issueId": issue.id,
                    "body": body_text
                }
            }
        }),
    )
    .await?;
    linear_graphql_errors(&body)?;
    let payload = body
        .get("data")
        .and_then(|d| d.get("commentCreate"))
        .cloned()
        .ok_or_else(|| "Linear commentCreate returned no data.".to_string())?;
    if payload.get("success").and_then(JsonValue::as_bool) == Some(false) {
        return Err("Linear commentCreate reported success=false.".into());
    }
    let comment = payload
        .get("comment")
        .cloned()
        .ok_or_else(|| "Linear commentCreate returned no comment.".to_string())?;
    let node: CommentNode = serde_json::from_value(comment)
        .map_err(|e| format!("Failed to decode created comment: {e}"))?;
    Ok(LinearComment {
        id: node.id,
        body: node.body,
        created_at: node.created_at,
        user_name: node.user.and_then(|u| u.name),
    })
}

pub async fn run_comments(api_key: &str, args: LinearCommentsCmd) -> Result<(), String> {
    let loader = Loader::start("Fetching comments");
    let comments = match list_comments(api_key, &args.id).await {
        Ok(v) => {
            loader.success();
            v
        }
        Err(e) => {
            loader.fail(&e);
            return Err(e);
        }
    };
    print_comments(&args.id, &comments);
    Ok(())
}

pub async fn run_comment(api_key: &str, args: LinearCommentCmd) -> Result<(), String> {
    let body_text = match args.body.as_deref() {
        Some(b) if !b.trim().is_empty() => b.to_string(),
        _ => {
            require_interactive("Writing a Linear comment")?;
            Input::<String>::with_theme(&ColorfulTheme::default())
                .with_prompt(format!("Comment on {}", args.id))
                .interact_text()
                .map_err(|e| e.to_string())?
        }
    };
    let loader = Loader::start("Posting comment");
    let comment = match create_comment(api_key, &args.id, &body_text).await {
        Ok(v) => {
            loader.success();
            v
        }
        Err(e) => {
            loader.fail(&e);
            return Err(e);
        }
    };
    println!(
        "{} comment on {} by {}",
        "OK".bright_green().bold(),
        args.id.bright_white().bold(),
        comment.user_name.as_deref().unwrap_or("you").bright_cyan()
    );
    println!("{}", comment.body);
    Ok(())
}

fn print_comments(issue_id: &str, comments: &[LinearComment]) {
    if comments.is_empty() {
        println!(
            "{} {}",
            issue_id.bright_white().bold(),
            "— no comments".dimmed()
        );
        return;
    }
    println!(
        "{} ({} comment(s))",
        issue_id.bright_white().bold(),
        comments.len()
    );
    let rows: Vec<Vec<String>> = comments
        .iter()
        .map(|c| {
            vec![
                c.user_name
                    .clone()
                    .unwrap_or_else(|| "-".into())
                    .bright_cyan()
                    .to_string(),
                c.created_at
                    .clone()
                    .unwrap_or_else(|| "-".into())
                    .dimmed()
                    .to_string(),
                truncate_chars(&c.body.replace('\n', " "), 80),
            ]
        })
        .collect();
    print!(
        "{}",
        render_table(
            &["Author", "When", "Body"],
            &rows,
            TableStyle::Pipe,
            "",
        )
    );
}