use anyhow::{bail, Result};
use serde_json::json;
use crate::config::Config;
use crate::db::Database;
use crate::embedding::{self, Embedder};
use crate::linear::LinearClient;
pub struct MarkTriagedParams<'a> {
pub id: &'a str,
pub priority: i32,
pub title: Option<&'a str>,
pub description: Option<&'a str>,
pub comment: Option<&'a str>,
pub state: Option<&'a str>,
pub labels: Option<&'a [String]>,
pub project: Option<&'a str>,
pub json_output: bool,
pub workspace: &'a str,
}
pub async fn handle_mark_triaged(
db: &Database,
config: &Config,
params: MarkTriagedParams<'_>,
) -> Result<()> {
let MarkTriagedParams {
id,
priority,
title,
description,
comment,
state,
labels,
project,
json_output,
workspace,
} = params;
if !(1..=4).contains(&priority) {
bail!("Priority must be 1 (Urgent), 2 (High), 3 (Medium), or 4 (Low)");
}
let local_issue = db
.get_issue(id)?
.ok_or_else(|| anyhow::anyhow!("Issue '{}' not found", id))?;
let api_key = config.workspace_api_key(workspace)?;
let client = LinearClient::with_api_key(&api_key);
let (issue, issue_relations) = client.fetch_single_issue(&local_issue.id).await?;
db.upsert_issue(&issue)?;
db.upsert_relations(&issue.id, &issue_relations)?;
if issue.priority != 0 {
let result = json!({
"identifier": issue.identifier,
"url": issue.url,
"status": "already_triaged",
"current_priority": issue.priority,
"current_priority_label": issue.priority_label(),
"message": format!(
"{} was already prioritized as {} — skipping",
issue.identifier, issue.priority_label()
),
});
if json_output {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
eprintln!(
"{} was already prioritized as P{} — skipping",
issue.identifier, issue.priority
);
}
return Ok(());
}
if issue.content_hash != local_issue.content_hash {
let mut changes = Vec::new();
if issue.title != local_issue.title {
changes.push(format!(
"title changed: \"{}\" → \"{}\"",
local_issue.title, issue.title
));
}
if issue.description != local_issue.description {
changes.push("description was updated".to_string());
}
if issue.state_name != local_issue.state_name {
changes.push(format!(
"state changed: {} → {}",
local_issue.state_name, issue.state_name
));
}
if issue.assignee_name != local_issue.assignee_name {
changes.push(format!(
"assignee changed: {} → {}",
local_issue.assignee_name.as_deref().unwrap_or("unassigned"),
issue.assignee_name.as_deref().unwrap_or("unassigned")
));
}
let result = json!({
"identifier": issue.identifier,
"url": issue.url,
"status": "modified_since_queued",
"changes": changes,
"current_title": issue.title,
"current_description": issue.description,
"current_state": issue.state_name,
"message": format!(
"{} was modified since the queue was fetched — review the latest version before triaging",
issue.identifier
),
});
if json_output {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
eprintln!(
"{} was modified since last sync — review before triaging",
issue.identifier
);
}
reembed_issue(db, config, &issue).await;
return Ok(());
}
let state_id = if let Some(state_name) = state {
Some(client.get_state_id(&issue.team_key, state_name).await?)
} else {
None
};
let label_ids = if let Some(label_names) = labels {
Some(client.get_label_ids(label_names).await?)
} else {
None
};
let project_id = if let Some(project_name) = project {
if project_name.eq_ignore_ascii_case("none") {
Some(String::new())
} else {
Some(client.get_project_id(project_name).await?)
}
} else {
None
};
let safe_description = description.map(|new_desc| match &issue.description {
Some(original) => crate::mcp::preserve_images(original, new_desc),
None => new_desc.to_string(),
});
client
.update_issue(
&issue.id,
title,
safe_description.as_deref(),
Some(priority),
state_id.as_deref(),
label_ids.as_deref(),
project_id.as_deref(),
)
.await?;
if let Some(comment_text) = comment {
client.add_comment(&issue.id, comment_text).await?;
}
let (updated, updated_relations) = client.fetch_single_issue(&issue.id).await?;
db.upsert_issue(&updated)?;
db.upsert_relations(&updated.id, &updated_relations)?;
if title.is_some() || description.is_some() {
reembed_issue(db, config, &updated).await;
}
let priority_label = match priority {
1 => "Urgent",
2 => "High",
3 => "Medium",
4 => "Low",
_ => "Unknown",
};
let result = json!({
"identifier": issue.identifier,
"url": issue.url,
"priority": priority,
"priority_label": priority_label,
"title": title.unwrap_or(&issue.title),
"status": "triaged",
});
if json_output {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
eprintln!(
"✓ {} marked as P{} ({})",
issue.identifier, priority, priority_label
);
if let Some(t) = title {
eprintln!(" Title: {}", t);
}
if comment.is_some() {
eprintln!(" Comment added");
}
}
Ok(())
}
async fn reembed_issue(db: &Database, config: &Config, issue: &crate::db::Issue) {
let Ok(embedder) = Embedder::new(config) else {
return;
};
let chunks = embedding::chunk_text(
&issue.title,
issue.description.as_deref().unwrap_or(""),
512,
64,
);
if let Ok(embeddings) = embedder.embed_batch(&chunks).await {
let chunk_data: Vec<(usize, String, Vec<u8>)> = chunks
.into_iter()
.zip(embeddings)
.enumerate()
.map(|(i, (text, emb))| (i, text, embedding::embedding_to_bytes(&emb)))
.collect();
let _ = db.upsert_chunks(&issue.id, &chunk_data);
}
}