magi-linear-axi 0.2.3

Agent-native Linear GraphQL CLI with TOON output
Documentation
use crate::{cli::Cli, config, error::AppError, output::Output};
use serde_json::{Value, json};
use std::{
    fs,
    io::{self, Read},
};

#[derive(Clone, Copy)]
pub enum Kind {
    Team,
    User,
    Project,
    ProjectUpdate,
    Cycle,
    Milestone,
    Initiative,
    InitiativeUpdate,
    Label,
    Document,
}

pub fn run(cli: &Cli, kind: Kind, op: &str, a: crate::cli::ResourceArgs) -> Result<(), AppError> {
    run_impl(cli, kind, op, a, None)
}
pub fn run_with_fields(
    cli: &Cli,
    kind: Kind,
    op: &str,
    a: crate::cli::ResourceArgs,
    fields: Option<crate::cli::FieldPreset>,
) -> Result<(), AppError> {
    run_impl(cli, kind, op, a, fields)
}
fn run_impl(
    cli: &Cli,
    kind: Kind,
    op: &str,
    a: crate::cli::ResourceArgs,
    fields: Option<crate::cli::FieldPreset>,
) -> Result<(), AppError> {
    let cfg = config::Config::load(cli.endpoint.clone(), cli.workspace.clone())?;
    let client = crate::client::LinearClient::new(cfg.endpoint, config::api_key()?)?;
    let out = Output::new(cli.format.clone(), cli.full);
    let id = a.args.first().cloned();
    if matches!((kind, op), (Kind::Team, "id")) {
        println!("{}", id.unwrap_or_default());
        return Ok(());
    }
    if a.web || a.app {
        open_url(cli, kind, id.as_deref())?;
        return Ok(());
    }
    let vars = vars(&a)?;
    let (query, variables) = document(kind, op, id.as_deref(), vars, fields)?;
    let value = if a.all && op == "list" {
        client.paginate(query, variables)?
    } else if op == "list" || op == "view" || op == "members" || op == "states" || op == "autolinks"
    {
        client.query(query, variables)?
    } else {
        client.mutation(query, variables)?
    };
    out.render_owned(value)
}

fn vars(a: &crate::cli::ResourceArgs) -> Result<Value, AppError> {
    let mut input = serde_json::Map::new();
    macro_rules! put {
        ($f:ident,$k:literal) => {
            if let Some(v) = &a.$f {
                input.insert($k.into(), v.clone().into());
            }
        };
    }
    put!(name, "name");
    put!(title, "title");
    put!(body, "body");
    put!(content, "content");
    put!(description, "description");
    put!(team, "teamId");
    put!(project, "projectId");
    put!(issue, "issueId");
    put!(status, "status");
    put!(health, "health");
    put!(owner, "ownerId");
    put!(target_date, "targetDate");
    if let Some(p) = &a.file {
        let mut s = String::new();
        fs::File::open(p)
            .map_err(|e| AppError::usage(e.to_string()))?
            .read_to_string(&mut s)
            .map_err(|e| AppError::usage(e.to_string()))?;
        input.insert("content".into(), s.into());
    }
    if a.stdin {
        let mut s = String::new();
        io::stdin()
            .read_to_string(&mut s)
            .map_err(|e| AppError::usage(e.to_string()))?;
        input.insert("content".into(), s.into());
    }
    let mut filter = serde_json::Map::new();
    if let Some(team) = &a.team {
        filter.insert("team".into(), json!({"id":{"eq":team}}));
    }
    if let Some(project) = &a.project {
        filter.insert("project".into(), json!({"id":{"eq":project}}));
    }
    Ok(
        json!({"id":a.args.first(),"input":input,"first":a.limit,"after":null,"filter":filter,"includeArchived":a.archived}),
    )
}
fn document(
    k: Kind,
    op: &str,
    _id: Option<&str>,
    mut v: Value,
    fields: Option<crate::cli::FieldPreset>,
) -> Result<(&'static str, Value), AppError> {
    normalize_variables(k, op, &mut v)?;
    if matches!(
        (k, op, fields),
        (
            Kind::Project,
            "view",
            Some(crate::cli::FieldPreset::Compact)
        )
    ) {
        return Ok((
            "query GetProjectDetails($id:String!){project(id:$id){id name url status{name type}}}",
            v,
        ));
    }
    let q = match (k, op) {
        (Kind::Team, "list") => {
            "query GetTeams($first:Int,$after:String){teams(first:$first,after:$after){nodes{id name key description icon color cyclesEnabled createdAt updatedAt archivedAt} pageInfo{hasNextPage endCursor}}}"
        }
        (Kind::Team, "create") => {
            "mutation CreateTeam($input:TeamCreateInput!){teamCreate(input:$input){success team{id name key}}}"
        }
        (Kind::Team, "delete") => "mutation DeleteTeam($id:String!){teamDelete(id:$id){success}}",
        (Kind::Team, "members") => {
            "query GetTeamMembers($id:String!,$first:Int,$after:String){team(id:$id){members(first:$first,after:$after){nodes{id name displayName email active} pageInfo{hasNextPage endCursor}}}}"
        }
        (Kind::Team, "states") => {
            "query GetWorkflowStates($id:String!){team(id:$id){states{nodes{id name type position}}}}"
        }
        (Kind::Team, "autolinks") => {
            "query GetTeamAutolinks($id:String!){team(id:$id){id key name}}"
        }
        (Kind::User, "list") => {
            "query GetOrganizationMembers($first:Int,$after:String){organization{members(first:$first,after:$after){nodes{id name displayName email active} pageInfo{hasNextPage endCursor}}}}"
        }
        (Kind::Project, "list") => {
            "query GetProjects($filter:ProjectFilter,$first:Int,$after:String){projects(filter:$filter,first:$first,after:$after){nodes{id name description slugId url status{name type color} lead{name displayName} priority health startDate targetDate} pageInfo{hasNextPage endCursor}}}"
        }
        (Kind::Project, "view") => {
            "query GetProjectDetails($id:String!){project(id:$id){id name description slugId url status{name type color} lead{name displayName} priority health startDate targetDate createdAt updatedAt}}"
        }
        (Kind::Project, "create") => {
            "mutation CreateProject($input:ProjectCreateInput!){projectCreate(input:$input){success project{id slugId name url}}}"
        }
        (Kind::Project, "update") => {
            "mutation UpdateProject($id:String!,$input:ProjectUpdateInput!){projectUpdate(id:$id,input:$input){success project{id slugId name url}}}"
        }
        (Kind::Project, "delete") => {
            "mutation DeleteProject($id:String!){projectDelete(id:$id){success entity{id name}}}"
        }
        (Kind::ProjectUpdate, "list") => {
            "query ListProjectUpdates($id:String!,$first:Int){project(id:$id){name projectUpdates(first:$first){nodes{id body health url createdAt user{name displayName}}}}}"
        }
        (Kind::ProjectUpdate, "create") => {
            "mutation CreateProjectUpdate($input:ProjectUpdateCreateInput!){projectUpdateCreate(input:$input){success projectUpdate{id body health url}}}"
        }
        (Kind::Cycle, "list") => {
            "query GetTeamCycles($id:String!){team(id:$id){cycles{nodes{id number name startsAt endsAt completedAt isActive isFuture isPast}}}}"
        }
        (Kind::Cycle, "view") => {
            "query GetCycleDetails($id:String!){cycle(id:$id){id number name description startsAt endsAt completedAt isActive isFuture isPast team{id key name}}}"
        }
        (Kind::Milestone, "list") => {
            "query GetProjectMilestones($projectId:String!){project(id:$projectId){projectMilestones{nodes{id name description targetDate sortOrder project{id name}}}}}"
        }
        (Kind::Milestone, "view") => {
            "query GetMilestoneDetails($id:String!){projectMilestone(id:$id){id name description targetDate sortOrder project{id name slugId url}}}"
        }
        (Kind::Milestone, "create") => {
            "mutation CreateProjectMilestone($input:ProjectMilestoneCreateInput!){projectMilestoneCreate(input:$input){success projectMilestone{id name targetDate}}}"
        }
        (Kind::Milestone, "update") => {
            "mutation UpdateProjectMilestone($id:String!,$input:ProjectMilestoneUpdateInput!){projectMilestoneUpdate(id:$id,input:$input){success projectMilestone{id name targetDate}}}"
        }
        (Kind::Milestone, "delete") => {
            "mutation DeleteProjectMilestone($id:String!){projectMilestoneDelete(id:$id){success}}"
        }
        (Kind::Initiative, "list") => {
            "query GetInitiatives($filter:InitiativeFilter,$includeArchived:Boolean){initiatives(filter:$filter,includeArchived:$includeArchived){nodes{id slugId name description status targetDate health color icon url archivedAt owner{id displayName}} pageInfo{hasNextPage endCursor}}}"
        }
        (Kind::Initiative, "view") => {
            "query GetInitiativeDetails($id:String!){initiative(id:$id){id slugId name description status targetDate health color icon url archivedAt owner{id name displayName} projects{nodes{id name}}}}"
        }
        (Kind::Initiative, "create") => {
            "mutation CreateInitiative($input:InitiativeCreateInput!){initiativeCreate(input:$input){success initiative{id slugId name url}}}"
        }
        (Kind::Initiative, "update") => {
            "mutation UpdateInitiative($id:String!,$input:InitiativeUpdateInput!){initiativeUpdate(id:$id,input:$input){success initiative{id slugId name url}}}"
        }
        (Kind::Initiative, "archive") => {
            "mutation ArchiveInitiative($id:String!){initiativeArchive(id:$id){success}}"
        }
        (Kind::Initiative, "unarchive") => {
            "mutation UnarchiveInitiative($id:String!){initiativeUnarchive(id:$id){success entity{id name url}}}"
        }
        (Kind::Initiative, "delete") => {
            "mutation DeleteInitiative($id:String!){initiativeDelete(id:$id){success}}"
        }
        (Kind::Initiative, "add-project") => {
            "mutation AddProjectToInitiative($input:InitiativeToProjectCreateInput!){initiativeToProjectCreate(input:$input){success initiativeToProject{id}}}"
        }
        (Kind::Initiative, "remove-project") => {
            "mutation RemoveProjectFromInitiative($id:String!){initiativeToProjectDelete(id:$id){success}}"
        }
        (Kind::InitiativeUpdate, "list") => {
            "query ListInitiativeUpdates($id:String!,$first:Int){initiative(id:$id){name initiativeUpdates(first:$first){nodes{id body health url createdAt user{name}}}}}"
        }
        (Kind::InitiativeUpdate, "create") => {
            "mutation CreateInitiativeUpdate($input:InitiativeUpdateCreateInput!){initiativeUpdateCreate(input:$input){success initiativeUpdate{id body health url}}}"
        }
        (Kind::Label, "list") => {
            "query GetIssueLabels($filter:IssueLabelFilter,$first:Int,$after:String){issueLabels(filter:$filter,first:$first,after:$after){nodes{id name description color team{key name}} pageInfo{hasNextPage endCursor}}}"
        }
        (Kind::Label, "create") => {
            "mutation CreateIssueLabel($input:IssueLabelCreateInput!){issueLabelCreate(input:$input){success issueLabel{id name color description}}}"
        }
        (Kind::Label, "delete") => {
            "mutation DeleteIssueLabel($id:String!){issueLabelDelete(id:$id){success}}"
        }
        (Kind::Document, "list") => {
            "query ListDocuments($filter:DocumentFilter,$first:Int){documents(filter:$filter,first:$first){nodes{id title slugId url updatedAt project{name slugId} issue{identifier title}} pageInfo{hasNextPage endCursor}}}"
        }
        (Kind::Document, "view") => {
            "query GetDocument($id:String!){document(id:$id){id title slugId content url createdAt updatedAt creator{name email} project{name slugId} issue{identifier title}}}"
        }
        (Kind::Document, "create") => {
            "mutation CreateDocument($input:DocumentCreateInput!){documentCreate(input:$input){success document{id slugId title url}}}"
        }
        (Kind::Document, "update") => {
            "mutation UpdateDocument($id:String!,$input:DocumentUpdateInput!){documentUpdate(id:$id,input:$input){success document{id slugId title url}}}"
        }
        (Kind::Document, "delete") => {
            "mutation DeleteDocument($id:String!){documentDelete(id:$id){success}}"
        }
        _ => return Err(AppError::usage("unsupported resource operation")),
    };
    Ok((q, v))
}
fn normalize_variables(k: Kind, op: &str, variables: &mut Value) -> Result<(), AppError> {
    let root = variables
        .as_object_mut()
        .ok_or_else(|| AppError::usage("invalid command variables"))?;
    let id = root.get("id").cloned().unwrap_or(Value::Null);
    let input = root
        .get_mut("input")
        .and_then(Value::as_object_mut)
        .ok_or_else(|| AppError::usage("invalid command input"))?;

    if matches!(k, Kind::Project) {
        if let Some(team_id) = input.remove("teamId") {
            input.insert("teamIds".into(), json!([team_id]));
        }
        if let Some(status_id) = input.remove("status") {
            input.insert("statusId".into(), status_id);
        }
    }

    match (k, op) {
        (Kind::Milestone, "list") => {
            let project_id = input.remove("projectId").unwrap_or(id);
            root.insert("projectId".into(), project_id);
        }
        (Kind::Initiative, "add-project") => {
            input.insert("initiativeId".into(), id);
        }
        (Kind::InitiativeUpdate, "create") => {
            if let Some(initiative_id) = input.remove("issueId") {
                input.insert("initiativeId".into(), initiative_id);
            }
        }
        _ => {}
    }
    Ok(())
}

fn open_url(cli: &Cli, _: Kind, id: Option<&str>) -> Result<(), AppError> {
    let w = cli
        .workspace
        .as_deref()
        .ok_or_else(|| AppError::usage("--workspace required for browser opening"))?;
    let u = format!("https://linear.app/{w}/{}", id.unwrap_or(""));
    let s = if cfg!(target_os = "macos") {
        std::process::Command::new("open").arg(&u).status()
    } else if cfg!(target_os = "windows") {
        std::process::Command::new("cmd")
            .args(["/C", "start", &u])
            .status()
    } else {
        std::process::Command::new("xdg-open").arg(&u).status()
    };
    s.map_err(|e| AppError::usage(e.to_string()))?;
    Ok(())
}