magi-linear-axi 0.2.3

Agent-native Linear GraphQL CLI with TOON output
Documentation
use crate::{cli::*, config, error::AppError, output::Output};
use serde_json::{Value, json};
use std::process::Command as ProcessCommand;

fn ctx(cli: &Cli) -> Result<(crate::client::LinearClient, Output), AppError> {
    let c = config::Config::load(cli.endpoint.clone(), cli.workspace.clone())?;
    Ok((
        crate::client::LinearClient::new(c.endpoint, config::api_key()?)?,
        Output::new(cli.format.clone(), cli.full),
    ))
}
fn resolve_issue_identifier(value: Option<&str>) -> Result<String, AppError> {
    if let Some(id) = value {
        if !id.trim().is_empty() {
            return Ok(id.to_uppercase());
        }
    }
    if let Ok(branch) = ProcessCommand::new("git")
        .args(["branch", "--show-current"])
        .output()
    {
        let b = String::from_utf8_lossy(&branch.stdout);
        for part in b.split(|c: char| !c.is_ascii_alphanumeric() && c != '-') {
            if part.split_once('-').is_some()
                && part
                    .rsplit_once('-')
                    .and_then(|(_, n)| n.parse::<u64>().ok())
                    .is_some()
            {
                return Ok(part.to_uppercase());
            }
        }
    }
    Err(AppError::usage(
        "issue identifier required (for example ENG-123), or run on issue branch",
    ))
}
fn resolve_issue_id(cli: &Cli, identifier: Option<&str>) -> Result<(), AppError> {
    let identifier = resolve_issue_identifier(identifier)?;
    let (client, output) = ctx(cli)?;
    let value = client.query(
        "query IssueId($identifier:String!){issue(id:$identifier){id identifier}}",
        json!({"identifier": identifier}),
    )?;
    if value.get("issue").is_none_or(Value::is_null) {
        return Err(AppError::api(format!("issue {identifier} not found")));
    }
    output.render_owned(value)
}
fn run(cli: &Cli, query: &str, vars: Value) -> Result<(), AppError> {
    let (c, o) = ctx(cli)?;
    o.render_owned(c.query(query, vars)?)
}
fn one(cli: &Cli, id: &str, field: &str) -> Result<(), AppError> {
    let q =
        format!("query Issue($id:String!) {{ issue(id:$id) {{ identifier title url {field} }} }}");
    let (c, o) = ctx(cli)?;
    let value = c.query(&q, json!({"id":id}))?;
    if value.get("issue").is_none_or(Value::is_null) {
        return Err(AppError::api(format!("issue {id} not found")));
    }
    o.render_owned(value)
}
pub fn execute(cli: &Cli, command: IssueCommand) -> Result<(), AppError> {
    match command {
        IssueCommand::Id(a) => resolve_issue_id(cli, a.identifier.as_deref()),
        IssueCommand::Identifier(a) => {
            println!("{}", resolve_issue_identifier(a.identifier.as_deref())?);
            Ok(())
        }
        IssueCommand::Title(a) => one(cli, &resolve_issue_identifier(a.issue.as_deref())?, "title"),
        IssueCommand::Url(a) => one(cli, &resolve_issue_identifier(a.issue.as_deref())?, "url"),
        IssueCommand::Describe(a) => one(
            cli,
            &resolve_issue_identifier(a.issue.as_deref())?,
            "description",
        ),
        IssueCommand::View(a) => {
            let id = resolve_issue_identifier(a.issue.issue.as_deref())?;
            if a.issue.web || a.issue.app {
                return open_issue_url(cli, &id, a.issue.web);
            }
            one(
                cli,
                &id,
                match a.fields {
                    Some(FieldPreset::Compact) => "state { name type }",
                    None => {
                        "description state { name type } assignee { name } comments { nodes { id body } }"
                    }
                },
            )
        }
        IssueCommand::Mine(a) => query(cli, a.query, a.fields, true),
        IssueCommand::List(a) | IssueCommand::Query(a) => query(cli, a.query, a.fields, false),
        IssueCommand::Start(a) => {
            let id = resolve_issue_identifier(a.issue.as_deref())?;
            mutate(
                cli,
                "mutation StartIssue($id:String!,$input:IssueUpdateInput!){issueUpdate(id:$id,input:$input){success issue{identifier title url state{name type}}}}",
                json!({"id":id,"input":{"stateId":"started"}}),
            )
        }
        IssueCommand::PullRequest(a) => {
            let id = resolve_issue_identifier(a.issue.as_deref())?;
            let status = ProcessCommand::new("gh")
                .args(["pr", "list", "--head", "HEAD", "--json", "url"])
                .status()
                .map_err(|e| AppError::usage(format!("gh unavailable: {e}")))?;
            if !status.success() {
                return Err(AppError::network("gh pr list failed"));
            }
            one(cli, &id, "url")
        }
        IssueCommand::Commits(a) => {
            let id = resolve_issue_identifier(a.issue.as_deref())?;
            let output = ProcessCommand::new("git")
                .args(["log", "-n", "50", "--pretty=format:%H%x09%s"])
                .output()
                .map_err(|e| AppError::usage(e.to_string()))?;
            let commits: Vec<Value> = String::from_utf8_lossy(&output.stdout)
                .lines()
                .map(|line| {
                    let (hash, subject) = line.split_once('\t').unwrap_or((line, ""));
                    json!({"hash":hash,"subject":subject})
                })
                .collect();
            let _ = id;
            Output::new(cli.format.clone(), cli.full).render_owned(json!({"commits":commits}))
        }
        IssueCommand::Delete(a) => {
            delete_issue(cli, &resolve_issue_identifier(a.issue.as_deref())?)
        }
        IssueCommand::Create(a) => create(cli, a),
        IssueCommand::Update(a) => update(cli, a),
        IssueCommand::Comment { command } => comment(cli, command),
        IssueCommand::Attach(a) => attach(cli, a),
        IssueCommand::Link(a) => mutate(
            cli,
            "mutation AttachmentCreate($input:AttachmentCreateInput!){ attachmentCreate(input:$input){success attachment{id url title}} }",
            json!({"input":{"issueId":a.issue,"url":a.url,"title":a.title.unwrap_or(a.url)}}),
        ),
        IssueCommand::Relation { command } => relation(cli, command),
        IssueCommand::AgentSession { command } => agent_session(cli, command),
    }
}
fn query(cli: &Cli, a: QueryArgs, fields: Option<FieldPreset>, mine: bool) -> Result<(), AppError> {
    let mut filter = serde_json::Map::new();
    if let Some(s) = a.search {
        filter.insert("title".into(), json!({"containsIgnoreCase": s}));
    }
    if let Some(t) = a.team {
        filter.insert("team".into(), json!({"key": {"eq": t.to_uppercase()}}));
    }
    if let Some(x) = a.assignee {
        filter.insert("assignee".into(), json!({"id": {"eq": x}}));
    }
    if !a.state.is_empty() {
        filter.insert("state".into(), json!({"name":{"in":a.state}}));
    }
    if let Some(project) = a.project {
        filter.insert("project".into(), json!({"id":{"eq":project}}));
    }
    if let Some(milestone) = a.milestone {
        filter.insert("projectMilestone".into(), json!({"id":{"eq":milestone}}));
    }
    if !a.label.is_empty() {
        filter.insert("labels".into(), json!({"some":{"name":{"in":a.label}}}));
    }
    if a.unassigned {
        filter.insert("assignee".into(), json!({"id": null}));
    }
    let (client, output) = ctx(cli)?;
    if mine {
        let viewer = client.query("query Viewer { viewer { id } }", json!({}))?;
        let id = viewer
            .get("viewer")
            .and_then(|v| v.get("id"))
            .and_then(Value::as_str)
            .ok_or_else(|| AppError::api("viewer has no id"))?;
        filter.insert("assignee".into(), json!({"id": {"eq": id}}));
    }
    let q = match fields {
        Some(FieldPreset::Compact) => {
            "query Issues($filter:IssueFilter,$first:Int,$after:String){issues(filter:$filter,first:$first,after:$after){nodes{identifier title} pageInfo{hasNextPage endCursor}}}"
        }
        None => {
            "query Issues($filter:IssueFilter,$first:Int,$after:String){issues(filter:$filter,first:$first,after:$after){nodes{id identifier title url priority state{name type} assignee{name}} pageInfo{hasNextPage endCursor}}}"
        }
    };
    output.render_owned(client.query(q, json!({"filter":filter,"first":a.limit,"after":null}))?)
}
fn mutate(cli: &Cli, q: &str, v: Value) -> Result<(), AppError> {
    let (c, o) = ctx(cli)?;
    o.render_owned(c.mutation(q, v)?)
}
fn delete_issue(cli: &Cli, id: &str) -> Result<(), AppError> {
    let (c, o) = ctx(cli)?;
    let value = c.mutation(
        "mutation IssueDelete($id:String!){issueDelete(id:$id){success}}",
        json!({"id":id}),
    )?;
    if value
        .get("issueDelete")
        .and_then(|v| v.get("success"))
        .and_then(Value::as_bool)
        != Some(true)
    {
        return Err(AppError::api("issue delete was not successful"));
    }
    o.render_owned(value)
}
fn create(cli: &Cli, a: IssueCreateArgs) -> Result<(), AppError> {
    let title = a.title.ok_or_else(|| AppError::usage("--title required"))?;
    let team = a.team.ok_or_else(|| AppError::usage("--team required"))?;
    mutate(
        cli,
        "mutation IssueCreate($input:IssueCreateInput!){ issueCreate(input:$input){success issue{id identifier title url}} }",
        json!({"input":{"title":title,"description":a.description,"teamId":team,"priority":a.priority}}),
    )
}
fn update(cli: &Cli, a: IssueUpdateArgs) -> Result<(), AppError> {
    let id = resolve_issue_identifier(a.issue.as_deref())?;
    let mut input = json!({});
    let o = input.as_object_mut().unwrap();
    if let Some(x) = a.title {
        o.insert("title".into(), x.into());
    }
    if let Some(x) = a.description {
        o.insert("description".into(), x.into());
    }
    if let Some(x) = a.priority {
        o.insert("priority".into(), x.into());
    }
    if a.unassign {
        o.insert("assigneeId".into(), Value::Null);
    };
    if o.is_empty() {
        return Err(AppError::usage("update requires at least one field"));
    }
    mutate(
        cli,
        "mutation IssueUpdate($id:String!,$input:IssueUpdateInput!){ issueUpdate(id:$id,input:$input){success issue{identifier title url}} }",
        json!({"id":id,"input":input}),
    )
}
fn comment(cli: &Cli, c: CommentCommand) -> Result<(), AppError> {
    match c {
        CommentCommand::List(a) => {
            let id = resolve_issue_identifier(a.issue.issue.as_deref())?;
            match a.fields {
                Some(FieldPreset::Compact) => run(
                    cli,
                    "query Comments($id:String!,$first:Int,$after:String){issue(id:$id){comments(first:$first,after:$after){nodes{id body} pageInfo{hasNextPage endCursor}}}}",
                    json!({"id":id,"first":a.limit,"after":null}),
                ),
                None => run(
                    cli,
                    "query Comments($id:String!){ issue(id:$id){ comments{nodes{id body createdAt user{name}}}}}",
                    json!({"id":id}),
                ),
            }
        }
        CommentCommand::Add(a) => mutate(
            cli,
            "mutation CommentCreate($input:CommentCreateInput!){ commentCreate(input:$input){success comment{id body}} }",
            json!({"input":{"issueId":resolve_issue_identifier(a.issue.as_deref())?,"body":a.body.ok_or_else(||AppError::usage("--body required"))?}}),
        ),
        CommentCommand::Delete(a) => mutate(
            cli,
            "mutation CommentDelete($id:String!){ commentDelete(id:$id){success} }",
            json!({"id":a.issue.ok_or_else(||AppError::usage("comment ID required"))?}),
        ),
        CommentCommand::Update(a) => mutate(
            cli,
            "mutation CommentUpdate($id:String!,$input:CommentUpdateInput!){ commentUpdate(id:$id,input:$input){success comment{id body}} }",
            json!({"id":a.comment,"input":{"body":a.body.ok_or_else(||AppError::usage("--body required"))?}}),
        ),
    }
}
fn attach(cli: &Cli, a: AttachArgs) -> Result<(), AppError> {
    if !a.filepath.is_file() {
        return Err(AppError::usage(format!(
            "file not found: {}",
            a.filepath.display()
        )));
    }
    let (client, output) = ctx(cli)?;
    let url = client.upload(&a.filepath, a.public)?;
    let title = a.title.unwrap_or_else(|| {
        a.filepath
            .file_name()
            .unwrap_or_default()
            .to_string_lossy()
            .into_owned()
    });
    output.render_owned(client.mutation(
        "mutation AttachmentCreate($input:AttachmentCreateInput!){attachmentCreate(input:$input){success attachment{id url title}}}",
        json!({"input":{"issueId":a.issue,"url":url,"title":title}}),
    )?)
}
fn relation(cli: &Cli, c: RelationCommand) -> Result<(), AppError> {
    match c {
        RelationCommand::List(a) => {
            let id = resolve_issue_identifier(a.issue.issue.as_deref())?;
            match a.fields {
                Some(FieldPreset::Compact) => run(
                    cli,
                    "query Relations($id:String!,$first:Int,$after:String){issue(id:$id){identifier relations(first:$first,after:$after){nodes{type relatedIssue{identifier title}} pageInfo{hasNextPage endCursor}} inverseRelations(first:$first,after:$after){nodes{type issue{identifier title}} pageInfo{hasNextPage endCursor}}}}",
                    json!({"id":id,"first":a.limit,"after":null}),
                ),
                None => run(
                    cli,
                    "query Relations($id:String!){issue(id:$id){identifier relations{nodes{id type relatedIssue{identifier title}}} inverseRelations{nodes{id type issue{identifier title}}}}}",
                    json!({"id":id}),
                ),
            }
        }
        RelationCommand::Add(a) => mutate(
            cli,
            "mutation RelationCreate($input:IssueRelationCreateInput!){issueRelationCreate(input:$input){success issueRelation{id}}}",
            json!({"input":{"issueId":resolve_issue_identifier(Some(&a.issue))?,"relatedIssueId":resolve_issue_identifier(Some(&a.related_issue))?,"type":a.relation_type}}),
        ),
        RelationCommand::Delete(a) => mutate(
            cli,
            "mutation RelationDelete($id:String!){issueRelationDelete(id:$id){success}}",
            json!({"id":a.related_issue}),
        ),
    }
}
fn agent_session(cli: &Cli, c: AgentSessionCommand) -> Result<(), AppError> {
    match c {
        AgentSessionCommand::List(a) | AgentSessionCommand::View(a) => run(
            cli,
            "query AgentSessions($id:String!){issue(id:$id){identifier agentSessions{nodes{id status createdAt}}}}",
            json!({"id":resolve_issue_identifier(a.issue.as_deref())?}),
        ),
    }
}
fn open_issue_url(cli: &Cli, id: &str, web: bool) -> Result<(), AppError> {
    let workspace = cli
        .workspace
        .as_deref()
        .ok_or_else(|| AppError::usage("--workspace required for browser opening"))?;
    let url = if web {
        format!("https://linear.app/{workspace}/issue/{id}")
    } else {
        format!("linear://issue/{id}")
    };
    let status = if cfg!(target_os = "macos") {
        ProcessCommand::new("open").arg(url).status()
    } else if cfg!(target_os = "windows") {
        ProcessCommand::new("cmd")
            .args(["/C", "start", &url])
            .status()
    } else {
        ProcessCommand::new("xdg-open").arg(url).status()
    };
    status
        .map_err(|e| AppError::usage(e.to_string()))
        .and_then(|s| {
            s.success()
                .then_some(())
                .ok_or_else(|| AppError::usage("browser open failed"))
        })
}