use serde_json::{json, Value};
use super::client::GraphQl;
use super::model::{Issue, Nodes, Team};
use crate::error::{Error, Result};
pub const ISSUE_FIELDS: &str = r#"
id
identifier
title
description
url
updatedAt
priority
dueDate
state { id name type position }
team { id key name }
labels { nodes { name } }
"#;
pub async fn issue_by_key<G: GraphQl>(api: &G, team_key: &str, number: i64) -> Result<Issue> {
let doc = format!(
r#"query IssueByKey($team: String!, $number: Float!) {{
issues(filter: {{ team: {{ key: {{ eq: $team }} }}, number: {{ eq: $number }} }}, first: 1) {{
nodes {{ {ISSUE_FIELDS} }}
}}
}}"#
);
let data = api
.query(&doc, json!({ "team": team_key, "number": number as f64 }))
.await?;
let nodes: Nodes<Issue> = take(data, "issues")?;
nodes
.first()
.ok_or_else(|| Error::NotFound(format!("Linear issue {team_key}-{number}")))
}
pub async fn issue_by_id<G: GraphQl>(api: &G, id: &str) -> Result<Issue> {
let doc = format!(
r#"query IssueById($id: String!) {{
issue(id: $id) {{ {ISSUE_FIELDS} }}
}}"#
);
let data = api.query(&doc, json!({ "id": id })).await?;
let issue = data.get("issue").cloned().unwrap_or(Value::Null);
if issue.is_null() {
return Err(Error::NotFound(format!("Linear issue {id}")));
}
Ok(serde_json::from_value(issue)?)
}
pub async fn team_by_key<G: GraphQl>(api: &G, key: &str) -> Result<Team> {
let doc = r#"query TeamByKey($key: String!) {
teams(filter: { key: { eq: $key } }, first: 1) {
nodes {
id
key
name
states { nodes { id name type position } }
}
}
}"#;
let data = api.query(doc, json!({ "key": key })).await?;
let nodes: Nodes<Team> = take(data, "teams")?;
nodes
.first()
.ok_or_else(|| Error::NotFound(format!("Linear team {key}")))
}
#[derive(Debug, Clone)]
pub struct AssignedIssue {
pub issue: Issue,
pub cycle_id: Option<String>,
pub team_active_cycle_id: Option<String>,
}
impl AssignedIssue {
pub fn in_active_scope(&self) -> bool {
match &self.team_active_cycle_id {
None => true,
Some(active) => self.cycle_id.as_deref() == Some(active.as_str()),
}
}
}
pub async fn assigned_to_viewer<G: GraphQl>(api: &G) -> Result<Vec<AssignedIssue>> {
let doc = format!(
r#"query ViewerAssignedIssues {{
viewer {{
assignedIssues(
filter: {{ state: {{ type: {{ nin: ["completed", "canceled"] }} }} }},
first: 100
) {{
nodes {{ {ISSUE_FIELDS} cycle {{ id }} team {{ activeCycle {{ id }} }} }}
}}
}}
}}"#
);
let data = api.query(&doc, json!({})).await?;
let nodes = data
.pointer("/viewer/assignedIssues/nodes")
.and_then(Value::as_array)
.cloned()
.ok_or_else(|| {
Error::Unexpected("response had no `viewer.assignedIssues.nodes` field".into())
})?;
nodes.into_iter().map(parse_assigned).collect()
}
fn parse_assigned(node: Value) -> Result<AssignedIssue> {
let cycle_id = node
.pointer("/cycle/id")
.and_then(Value::as_str)
.map(str::to_string);
let team_active_cycle_id = node
.pointer("/team/activeCycle/id")
.and_then(Value::as_str)
.map(str::to_string);
let issue: Issue = serde_json::from_value(node)?;
Ok(AssignedIssue {
issue,
cycle_id,
team_active_cycle_id,
})
}
#[derive(Debug, Clone)]
pub struct NewIssue {
pub team_id: String,
pub title: String,
pub description: String,
pub state_id: Option<String>,
pub priority: f64,
}
pub async fn create_issue<G: GraphQl>(api: &G, new: NewIssue) -> Result<Issue> {
let doc = format!(
r#"mutation IssueCreate($input: IssueCreateInput!) {{
issueCreate(input: $input) {{
success
issue {{ {ISSUE_FIELDS} }}
}}
}}"#
);
let mut input = json!({
"teamId": new.team_id,
"title": new.title,
"description": new.description,
"priority": new.priority,
});
if let Some(state_id) = new.state_id {
input["stateId"] = json!(state_id);
}
let data = api.query(&doc, json!({ "input": input })).await?;
payload_issue(data, "issueCreate")
}
#[derive(Debug, Clone, Default)]
pub struct IssuePatch {
pub state_id: Option<String>,
pub description: Option<String>,
}
impl IssuePatch {
pub fn is_empty(&self) -> bool {
self.state_id.is_none() && self.description.is_none()
}
}
pub async fn update_issue<G: GraphQl>(api: &G, id: &str, patch: IssuePatch) -> Result<Issue> {
let doc = format!(
r#"mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) {{
issueUpdate(id: $id, input: $input) {{
success
issue {{ {ISSUE_FIELDS} }}
}}
}}"#
);
let mut input = json!({});
if let Some(state_id) = patch.state_id {
input["stateId"] = json!(state_id);
}
if let Some(description) = patch.description {
input["description"] = json!(description);
}
let data = api.query(&doc, json!({ "id": id, "input": input })).await?;
payload_issue(data, "issueUpdate")
}
pub async fn create_comment<G: GraphQl>(api: &G, issue_id: &str, body: &str) -> Result<String> {
let doc = r#"mutation CommentCreate($input: CommentCreateInput!) {
commentCreate(input: $input) { success comment { id } }
}"#;
let data = api
.query(
doc,
json!({ "input": { "issueId": issue_id, "body": body } }),
)
.await?;
require_success(&data, "commentCreate")?;
data.get("commentCreate")
.and_then(|p| p.get("comment"))
.and_then(|c| c.get("id"))
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| {
Error::Unexpected("commentCreate reported success but returned no comment id".into())
})
}
pub async fn update_comment<G: GraphQl>(api: &G, comment_id: &str, body: &str) -> Result<()> {
let doc = r#"mutation CommentUpdate($id: String!, $input: CommentUpdateInput!) {
commentUpdate(id: $id, input: $input) { success }
}"#;
let data = api
.query(doc, json!({ "id": comment_id, "input": { "body": body } }))
.await?;
require_success(&data, "commentUpdate")
}
pub fn comment_not_found(err: &Error) -> bool {
match err {
Error::NotFound(_) => true,
Error::Api(messages) => messages
.iter()
.any(|m| m.to_ascii_lowercase().contains("not found")),
_ => false,
}
}
pub fn parse_issue_key(key: &str) -> Result<(String, i64)> {
let trimmed = key.trim();
let (team, number) = trimmed
.rsplit_once('-')
.ok_or_else(|| Error::NotFound(format!("{trimmed:?} is not a Linear issue key")))?;
let number: i64 = number.parse().map_err(|_| {
Error::NotFound(format!(
"{trimmed:?} is not a Linear issue key (expected TEAM-123)"
))
})?;
if team.is_empty() {
return Err(Error::NotFound(format!(
"{trimmed:?} is not a Linear issue key (no team)"
)));
}
Ok((team.to_uppercase(), number))
}
fn take<T: serde::de::DeserializeOwned>(data: Value, field: &str) -> Result<T> {
let raw = data
.get(field)
.cloned()
.ok_or_else(|| Error::Unexpected(format!("response had no `{field}` field")))?;
Ok(serde_json::from_value(raw)?)
}
fn payload_issue(data: Value, field: &str) -> Result<Issue> {
require_success(&data, field)?;
let raw = data
.get(field)
.and_then(|p| p.get("issue"))
.cloned()
.unwrap_or(Value::Null);
if raw.is_null() {
return Err(Error::Unexpected(format!(
"{field} reported success but returned no issue"
)));
}
Ok(serde_json::from_value(raw)?)
}
fn require_success(data: &Value, field: &str) -> Result<()> {
let payload = data
.get(field)
.ok_or_else(|| Error::Unexpected(format!("response had no `{field}` field")))?;
match payload.get("success").and_then(Value::as_bool) {
Some(true) => Ok(()),
Some(false) => Err(Error::Api(vec![format!("{field} reported success: false")])),
None => Err(Error::Unexpected(format!(
"{field} payload had no `success` field"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
struct Fake {
replies: RefCell<Vec<Value>>,
seen: RefCell<Vec<(String, Value)>>,
}
impl Fake {
fn new(replies: Vec<Value>) -> Self {
Self {
replies: RefCell::new(replies),
seen: RefCell::new(Vec::new()),
}
}
fn last_vars(&self) -> Value {
self.seen.borrow().last().unwrap().1.clone()
}
fn last_doc(&self) -> String {
self.seen.borrow().last().unwrap().0.clone()
}
}
impl GraphQl for Fake {
async fn query(&self, doc: &str, vars: Value) -> Result<Value> {
self.seen.borrow_mut().push((doc.to_string(), vars));
let mut replies = self.replies.borrow_mut();
if replies.is_empty() {
return Err(Error::Unexpected("fake ran out of replies".into()));
}
Ok(replies.remove(0))
}
}
fn issue_json() -> Value {
json!({
"id": "uuid-1",
"identifier": "ENG-412",
"title": "Fix the thing",
"description": "spec",
"url": "https://linear.app/acme/issue/ENG-412",
"updatedAt": "2026-07-29T12:00:00.000Z",
"priority": 2,
"dueDate": null,
"state": {"id": "s1", "name": "Todo", "type": "unstarted", "position": 1.0},
"team": {"id": "t1", "key": "ENG", "name": "Engineering"},
"labels": {"nodes": [{"name": "bug"}]}
})
}
#[test]
fn parse_issue_key_splits_on_the_last_hyphen() {
assert_eq!(parse_issue_key("ENG-412").unwrap(), ("ENG".into(), 412));
assert_eq!(parse_issue_key("A-B-12").unwrap(), ("A-B".into(), 12));
assert_eq!(parse_issue_key(" eng-7 ").unwrap(), ("ENG".into(), 7));
}
#[test]
fn parse_issue_key_rejects_things_that_are_not_keys() {
for bad in ["ENG", "ENG-", "-12", "ENG-abc", ""] {
assert!(parse_issue_key(bad).is_err(), "{bad:?} should not parse");
}
}
#[tokio::test]
async fn issue_by_key_sends_team_and_number_and_parses_the_node() {
let fake = Fake::new(vec![json!({"issues": {"nodes": [issue_json()]}})]);
let issue = issue_by_key(&fake, "ENG", 412).await.unwrap();
assert_eq!(issue.identifier, "ENG-412");
assert_eq!(issue.label_names(), vec!["bug"]);
let vars = fake.last_vars();
assert_eq!(vars["team"], "ENG");
assert_eq!(vars["number"], 412.0);
}
#[tokio::test]
async fn issue_by_key_reports_not_found_for_an_empty_node_list() {
let fake = Fake::new(vec![json!({"issues": {"nodes": []}})]);
let err = issue_by_key(&fake, "ENG", 999).await.unwrap_err();
assert!(err.to_string().contains("ENG-999"), "{err}");
}
#[tokio::test]
async fn issue_by_id_reports_not_found_for_a_null_issue() {
let fake = Fake::new(vec![json!({"issue": null})]);
let err = issue_by_id(&fake, "uuid-nope").await.unwrap_err();
assert!(err.to_string().contains("uuid-nope"), "{err}");
}
#[tokio::test]
async fn team_by_key_parses_the_state_list() {
let fake = Fake::new(vec![json!({"teams": {"nodes": [{
"id": "t1", "key": "ENG", "name": "Engineering",
"states": {"nodes": [
{"id": "s1", "name": "Todo", "type": "unstarted", "position": 1.0},
{"id": "s2", "name": "Done", "type": "completed", "position": 2.0}
]}
}]}})]);
let team = team_by_key(&fake, "ENG").await.unwrap();
assert_eq!(team.states.nodes.len(), 2);
assert_eq!(team.states.nodes[1].name, "Done");
}
#[tokio::test]
async fn update_issue_omits_fields_the_caller_left_unset() {
let fake = Fake::new(vec![
json!({"issueUpdate": {"success": true, "issue": issue_json()}}),
]);
let patch = IssuePatch {
state_id: Some("s2".into()),
description: None,
};
update_issue(&fake, "uuid-1", patch).await.unwrap();
let input = &fake.last_vars()["input"];
assert_eq!(input["stateId"], "s2");
assert!(
input.get("description").is_none(),
"an unset field must not be sent: {input}"
);
}
#[tokio::test]
async fn create_issue_sends_the_state_only_when_given() {
let fake = Fake::new(vec![
json!({"issueCreate": {"success": true, "issue": issue_json()}}),
]);
create_issue(
&fake,
NewIssue {
team_id: "t1".into(),
title: "T".into(),
description: "D".into(),
state_id: None,
priority: 2.0,
},
)
.await
.unwrap();
let input = &fake.last_vars()["input"];
assert_eq!(input["teamId"], "t1");
assert!(input.get("stateId").is_none());
}
#[tokio::test]
async fn a_mutation_reporting_success_false_is_an_error() {
let fake = Fake::new(vec![
json!({"issueUpdate": {"success": false, "issue": null}}),
]);
let err = update_issue(&fake, "uuid-1", IssuePatch::default())
.await
.unwrap_err();
assert!(err.to_string().contains("success: false"), "{err}");
}
#[tokio::test]
async fn success_true_with_no_issue_is_reported_rather_than_parsed_as_garbage() {
let fake = Fake::new(vec![
json!({"issueCreate": {"success": true, "issue": null}}),
]);
let err = create_issue(
&fake,
NewIssue {
team_id: "t1".into(),
title: "T".into(),
description: "D".into(),
state_id: None,
priority: 0.0,
},
)
.await
.unwrap_err();
assert!(err.to_string().contains("returned no issue"), "{err}");
}
#[tokio::test]
async fn create_comment_checks_success_and_returns_the_comment_id() {
let fake = Fake::new(vec![json!({"commentCreate": {
"success": true, "comment": {"id": "comment-uuid-1"}
}})]);
let id = create_comment(&fake, "uuid-1", "hello").await.unwrap();
assert_eq!(id, "comment-uuid-1");
assert_eq!(fake.last_vars()["input"]["issueId"], "uuid-1");
assert_eq!(fake.last_vars()["input"]["body"], "hello");
assert!(fake.last_doc().contains("commentCreate"));
}
#[tokio::test]
async fn create_comment_success_without_an_id_is_reported() {
let fake = Fake::new(vec![
json!({"commentCreate": {"success": true, "comment": null}}),
]);
let err = create_comment(&fake, "uuid-1", "hello").await.unwrap_err();
assert!(err.to_string().contains("no comment"), "{err}");
}
#[tokio::test]
async fn update_comment_sends_the_id_and_body_and_checks_success() {
let fake = Fake::new(vec![json!({"commentUpdate": {"success": true}})]);
update_comment(&fake, "comment-uuid-1", "new body")
.await
.unwrap();
assert_eq!(fake.last_vars()["id"], "comment-uuid-1");
assert_eq!(fake.last_vars()["input"]["body"], "new body");
assert!(fake.last_doc().contains("commentUpdate"));
}
#[tokio::test]
async fn a_deleted_comment_is_detectable_by_the_caller() {
let err = Error::Api(vec!["Entity not found: Comment".into()]);
assert!(comment_not_found(&err));
assert!(!comment_not_found(&Error::Api(vec!["rate limited".into()])));
assert!(comment_not_found(&Error::NotFound("comment x".into())));
}
fn assigned_node(identifier: &str, cycle: Option<&str>, active: Option<&str>) -> Value {
let mut node = issue_json();
node["identifier"] = json!(identifier);
node["cycle"] = match cycle {
Some(id) => json!({ "id": id }),
None => Value::Null,
};
node["team"]["activeCycle"] = match active {
Some(id) => json!({ "id": id }),
None => Value::Null,
};
node
}
#[tokio::test]
async fn assigned_to_viewer_parses_issues_with_their_cycle_context() {
let fake = Fake::new(vec![json!({"viewer": {"assignedIssues": {"nodes": [
assigned_node("ENG-1", Some("cyc-9"), Some("cyc-9")),
assigned_node("ENG-2", None, None),
]}}})]);
let assigned = assigned_to_viewer(&fake).await.unwrap();
assert_eq!(assigned.len(), 2);
assert_eq!(assigned[0].issue.identifier, "ENG-1");
assert_eq!(assigned[0].cycle_id.as_deref(), Some("cyc-9"));
assert_eq!(assigned[0].team_active_cycle_id.as_deref(), Some("cyc-9"));
assert_eq!(assigned[1].cycle_id, None);
assert_eq!(assigned[1].team_active_cycle_id, None);
}
#[tokio::test]
async fn assigned_to_viewer_asks_only_for_open_states() {
let fake = Fake::new(vec![json!({"viewer": {"assignedIssues": {"nodes": []}}})]);
assigned_to_viewer(&fake).await.unwrap();
let doc = fake.last_doc();
assert!(doc.contains("assignedIssues"), "{doc}");
assert!(doc.contains("completed"), "{doc}");
assert!(doc.contains("canceled"), "{doc}");
assert!(doc.contains("nin"), "{doc}");
}
#[tokio::test]
async fn assigned_to_viewer_reports_a_missing_viewer_rather_than_panicking() {
let fake = Fake::new(vec![json!({"nonsense": 1})]);
let err = assigned_to_viewer(&fake).await.unwrap_err();
assert!(err.to_string().contains("viewer"), "{err}");
}
#[test]
fn in_active_scope_is_everything_when_the_team_has_no_active_cycle() {
for cycle in [None, Some("cyc-1".to_string())] {
let a = AssignedIssue {
issue: serde_json::from_value(issue_json()).unwrap(),
cycle_id: cycle,
team_active_cycle_id: None,
};
assert!(a.in_active_scope());
}
}
#[test]
fn in_active_scope_requires_the_active_cycle_when_the_team_uses_cycles() {
let make = |cycle: Option<&str>| AssignedIssue {
issue: serde_json::from_value(issue_json()).unwrap(),
cycle_id: cycle.map(str::to_string),
team_active_cycle_id: Some("cyc-active".into()),
};
assert!(make(Some("cyc-active")).in_active_scope());
assert!(!make(Some("cyc-old")).in_active_scope(), "a past cycle is backlog, not now");
assert!(!make(None).in_active_scope(), "no cycle at all means backlog on a cycle team");
}
#[test]
fn issue_fields_selects_everything_the_model_requires() {
for field in [
"id",
"identifier",
"title",
"description",
"url",
"updatedAt",
"priority",
"dueDate",
"state {",
"team {",
"labels {",
] {
assert!(
ISSUE_FIELDS.contains(field),
"{field} missing from ISSUE_FIELDS"
);
}
}
}