1use anyhow::{Context, Result};
2use serde::Deserialize;
3use sha2::{Digest, Sha256};
4
5use crate::config::Config;
6use crate::db::{self, Database};
7
8const LINEAR_API_URL: &str = "https://api.linear.app/graphql";
9
10#[derive(Clone)]
11pub struct LinearClient {
12 client: reqwest::Client,
13 api_key: String,
14}
15
16#[derive(Debug, Deserialize)]
17struct GraphQLResponse<T> {
18 data: Option<T>,
19 errors: Option<Vec<GraphQLError>>,
20}
21
22#[derive(Debug, Deserialize)]
23struct GraphQLError {
24 message: String,
25}
26
27#[derive(Debug, Deserialize)]
30struct IssuesData {
31 issues: IssueConnection,
32}
33
34#[derive(Debug, Deserialize)]
35struct IssueConnection {
36 nodes: Vec<LinearIssue>,
37 #[serde(rename = "pageInfo")]
38 page_info: PageInfo,
39}
40
41#[derive(Debug, Deserialize)]
42struct PageInfo {
43 #[serde(rename = "hasNextPage")]
44 has_next_page: bool,
45 #[serde(rename = "endCursor")]
46 end_cursor: Option<String>,
47}
48
49#[derive(Debug, Deserialize)]
50struct LinearIssue {
51 id: String,
52 identifier: String,
53 url: String,
54 title: String,
55 description: Option<String>,
56 priority: i32,
57 #[serde(rename = "createdAt")]
58 created_at: String,
59 #[serde(rename = "updatedAt")]
60 updated_at: String,
61 state: LinearState,
62 team: LinearTeam,
63 assignee: Option<LinearUser>,
64 project: Option<LinearProject>,
65 labels: LinearLabelConnection,
66 #[serde(default)]
67 relations: LinearRelationConnection,
68 #[serde(rename = "branchName")]
69 branch_name: Option<String>,
70}
71
72#[derive(Debug, Deserialize, Default)]
73struct LinearRelationConnection {
74 nodes: Vec<LinearRelation>,
75}
76
77#[derive(Debug, Deserialize)]
78struct LinearRelation {
79 id: String,
80 #[serde(rename = "type")]
81 relation_type: String,
82 #[serde(rename = "relatedIssue")]
83 related_issue: LinearRelatedIssue,
84}
85
86#[derive(Debug, Deserialize)]
87struct LinearRelatedIssue {
88 id: String,
89 identifier: String,
90}
91
92#[derive(Debug, Deserialize)]
93struct LinearState {
94 name: String,
95 #[serde(rename = "type")]
96 state_type: String,
97}
98
99#[derive(Debug, Deserialize)]
100struct LinearTeam {
101 key: String,
102}
103
104#[derive(Debug, Deserialize)]
105struct LinearUser {
106 name: String,
107}
108
109#[derive(Debug, Deserialize)]
110struct LinearProject {
111 name: String,
112}
113
114#[derive(Debug, Deserialize)]
115struct LinearLabelConnection {
116 nodes: Vec<LinearLabel>,
117}
118
119#[derive(Debug, Deserialize)]
120struct LinearLabel {
121 name: String,
122}
123
124#[derive(Debug, Deserialize)]
127struct TeamsData {
128 teams: TeamConnection,
129}
130
131#[derive(Debug, Deserialize)]
132struct TeamConnection {
133 nodes: Vec<TeamNode>,
134}
135
136#[derive(Debug, Deserialize)]
137#[allow(dead_code)]
138pub struct TeamNode {
139 pub id: String,
140 pub key: String,
141 pub name: String,
142}
143
144#[derive(Debug, Deserialize)]
147struct CreateIssueData {
148 #[serde(rename = "issueCreate")]
149 issue_create: CreateIssuePayload,
150}
151
152#[derive(Debug, Deserialize)]
153struct CreateIssuePayload {
154 success: bool,
155 issue: Option<CreatedIssue>,
156}
157
158#[derive(Debug, Deserialize)]
159struct CreatedIssue {
160 id: String,
161 identifier: String,
162}
163
164#[derive(Debug, Deserialize)]
167struct CreateCommentData {
168 #[serde(rename = "commentCreate")]
169 comment_create: CreateCommentPayload,
170}
171
172#[derive(Debug, Deserialize)]
173struct CreateCommentPayload {
174 success: bool,
175}
176
177#[derive(Debug, Deserialize)]
180struct UpdateIssueData {
181 #[serde(rename = "issueUpdate")]
182 issue_update: UpdateIssuePayload,
183}
184
185#[derive(Debug, Deserialize)]
186struct UpdateIssuePayload {
187 success: bool,
188}
189
190#[derive(Debug, Deserialize)]
193struct CreateRelationData {
194 #[serde(rename = "issueRelationCreate")]
195 issue_relation_create: CreateRelationPayload,
196}
197
198#[derive(Debug, Deserialize)]
199struct CreateRelationPayload {
200 success: bool,
201 #[serde(rename = "issueRelation")]
202 issue_relation: Option<CreatedRelation>,
203}
204
205#[derive(Debug, Deserialize)]
206struct CreatedRelation {
207 id: String,
208}
209
210#[derive(Debug, Deserialize)]
211struct DeleteRelationData {
212 #[serde(rename = "issueRelationDelete")]
213 issue_relation_delete: DeleteRelationPayload,
214}
215
216#[derive(Debug, Deserialize)]
217struct DeleteRelationPayload {
218 success: bool,
219}
220
221#[derive(Debug, Deserialize)]
224struct SingleIssueData {
225 issue: LinearIssue,
226}
227
228impl LinearClient {
229 pub fn new(config: &Config) -> Result<Self> {
230 let api_key = config.linear_api_key()?.to_string();
231 let client = reqwest::Client::new();
232 Ok(Self { client, api_key })
233 }
234
235 pub fn with_api_key(api_key: &str) -> Self {
237 Self {
238 client: reqwest::Client::new(),
239 api_key: api_key.to_string(),
240 }
241 }
242
243 pub fn with_http_client(client: reqwest::Client, api_key: &str) -> Self {
248 Self {
249 client,
250 api_key: api_key.to_string(),
251 }
252 }
253
254 async fn query<T: serde::de::DeserializeOwned>(
255 &self,
256 query: &str,
257 variables: serde_json::Value,
258 ) -> Result<T> {
259 let body = serde_json::json!({
260 "query": query,
261 "variables": variables,
262 });
263
264 let resp = self
265 .client
266 .post(LINEAR_API_URL)
267 .header("Authorization", &self.api_key)
268 .header("Content-Type", "application/json")
269 .json(&body)
270 .send()
271 .await
272 .context("Failed to send request to Linear API")?;
273
274 let status = resp.status();
275 if !status.is_success() {
276 let text = resp.text().await.unwrap_or_default();
277 anyhow::bail!("Linear API returned {}: {}", status, text);
278 }
279
280 let response: GraphQLResponse<T> = resp
281 .json()
282 .await
283 .context("Failed to parse Linear response")?;
284
285 if let Some(errors) = response.errors {
286 let msgs: Vec<_> = errors.iter().map(|e| e.message.as_str()).collect();
287 anyhow::bail!("Linear API errors: {}", msgs.join(", "));
288 }
289
290 response.data.context("No data in Linear response")
291 }
292
293 pub async fn list_teams(&self) -> Result<Vec<TeamNode>> {
294 let data: TeamsData = self
295 .query(
296 "query { teams { nodes { id key name } } }",
297 serde_json::json!({}),
298 )
299 .await?;
300 Ok(data.teams.nodes)
301 }
302
303 fn extract_relations(issue_id: &str, linear_issue: &LinearIssue) -> Vec<db::Relation> {
304 linear_issue
305 .relations
306 .nodes
307 .iter()
308 .map(|r| db::Relation {
309 id: r.id.clone(),
310 issue_id: issue_id.to_string(),
311 related_issue_id: r.related_issue.id.clone(),
312 related_issue_identifier: r.related_issue.identifier.clone(),
313 relation_type: r.relation_type.clone(),
314 })
315 .collect()
316 }
317
318 pub async fn fetch_issues(
319 &self,
320 team_key: &str,
321 after_cursor: Option<&str>,
322 updated_after: Option<&str>,
323 include_archived: bool,
324 ) -> Result<(Vec<(db::Issue, Vec<db::Relation>)>, bool, Option<String>)> {
325 let mut filter_parts = vec![format!("team: {{ key: {{ eq: \"{}\" }} }}", team_key)];
326 if let Some(after) = updated_after {
327 filter_parts.push(format!("updatedAt: {{ gt: \"{}\" }}", after));
328 }
329 let filter = filter_parts.join(", ");
330
331 let after_param = if let Some(c) = after_cursor {
332 format!(", after: \"{}\"", c)
333 } else {
334 String::new()
335 };
336
337 let include_archive = if include_archived { "true" } else { "false" };
338
339 let query = format!(
340 r#"query {{
341 issues(
342 first: 250,
343 filter: {{ {} }},
344 includeArchived: {}
345 orderBy: updatedAt
346 {}
347 ) {{
348 nodes {{
349 id identifier url title description priority branchName
350 createdAt updatedAt
351 state {{ name type }}
352 team {{ key }}
353 assignee {{ name }}
354 project {{ name }}
355 labels {{ nodes {{ name }} }}
356 relations {{ nodes {{ id type relatedIssue {{ id identifier }} }} }}
357 }}
358 pageInfo {{ hasNextPage endCursor }}
359 }}
360 }}"#,
361 filter, include_archive, after_param
362 );
363
364 let data: IssuesData = self.query(&query, serde_json::json!({})).await?;
365
366 let issues: Vec<(db::Issue, Vec<db::Relation>)> = data
367 .issues
368 .nodes
369 .into_iter()
370 .map(Self::convert_linear_issue)
371 .collect();
372
373 Ok((
374 issues,
375 data.issues.page_info.has_next_page,
376 data.issues.page_info.end_cursor,
377 ))
378 }
379
380 pub async fn sync_team(
381 &self,
382 db: &Database,
383 team_key: &str,
384 workspace_id: &str,
385 full: bool,
386 include_archived: bool,
387 progress: Option<&(dyn Fn(usize) + Send + Sync)>,
388 ) -> Result<usize> {
389 let updated_after = if full {
390 None
391 } else {
392 db.get_sync_cursor(workspace_id, team_key)?
393 };
394
395 let mut total = 0;
396 let mut cursor: Option<String> = None;
397 let mut max_updated: Option<String> = None;
398
399 loop {
400 let (issues, has_next, next_cursor) = self
401 .fetch_issues(
402 team_key,
403 cursor.as_deref(),
404 updated_after.as_deref(),
405 include_archived,
406 )
407 .await?;
408
409 let count = issues.len();
410 for (mut issue, relations) in issues {
411 issue.workspace_id = workspace_id.to_string();
412 if max_updated.is_none() || Some(&issue.updated_at) > max_updated.as_ref() {
413 max_updated = Some(issue.updated_at.clone());
414 }
415 db.upsert_issue(&issue)?;
416 db.upsert_relations(&issue.id, &relations)?;
417 }
418 total += count;
419
420 if let Some(cb) = progress {
421 cb(total);
422 }
423
424 if !has_next || count == 0 {
425 break;
426 }
427 cursor = next_cursor;
428 }
429
430 if let Some(max) = max_updated {
431 db.set_sync_cursor(workspace_id, team_key, &max)?;
432 }
433
434 Ok(total)
435 }
436
437 pub async fn create_issue(
438 &self,
439 team_id: &str,
440 title: &str,
441 description: Option<&str>,
442 priority: Option<i32>,
443 label_ids: &[String],
444 parent_id: Option<&str>,
445 ) -> Result<(String, String)> {
446 let mut input = serde_json::json!({
447 "teamId": team_id,
448 "title": title,
449 });
450
451 if let Some(desc) = description {
452 input["description"] = serde_json::Value::String(desc.to_string());
453 }
454 if let Some(p) = priority {
455 input["priority"] = serde_json::Value::Number(p.into());
456 }
457 if !label_ids.is_empty() {
458 input["labelIds"] = serde_json::json!(label_ids);
459 }
460 if let Some(pid) = parent_id {
461 input["parentId"] = serde_json::Value::String(pid.to_string());
462 }
463
464 let query = r#"
465 mutation($input: IssueCreateInput!) {
466 issueCreate(input: $input) {
467 success
468 issue { id identifier }
469 }
470 }
471 "#;
472
473 let data: CreateIssueData = self
474 .query(query, serde_json::json!({ "input": input }))
475 .await?;
476
477 if !data.issue_create.success {
478 anyhow::bail!("Failed to create issue");
479 }
480
481 let issue = data.issue_create.issue.context("No issue returned")?;
482 Ok((issue.id, issue.identifier))
483 }
484
485 pub async fn add_comment(&self, issue_id: &str, body: &str) -> Result<()> {
486 let query = r#"
487 mutation($input: CommentCreateInput!) {
488 commentCreate(input: $input) {
489 success
490 }
491 }
492 "#;
493
494 let input = serde_json::json!({
495 "issueId": issue_id,
496 "body": body,
497 });
498
499 let data: CreateCommentData = self
500 .query(query, serde_json::json!({ "input": input }))
501 .await?;
502
503 if !data.comment_create.success {
504 anyhow::bail!("Failed to create comment");
505 }
506
507 Ok(())
508 }
509
510 pub async fn update_issue(
511 &self,
512 issue_id: &str,
513 title: Option<&str>,
514 description: Option<&str>,
515 priority: Option<i32>,
516 state_id: Option<&str>,
517 label_ids: Option<&[String]>,
518 project_id: Option<&str>,
519 ) -> Result<()> {
520 let mut input = serde_json::Map::new();
521 if let Some(t) = title {
522 input.insert("title".into(), serde_json::Value::String(t.to_string()));
523 }
524 if let Some(d) = description {
525 input.insert(
526 "description".into(),
527 serde_json::Value::String(d.to_string()),
528 );
529 }
530 if let Some(p) = priority {
531 input.insert("priority".into(), serde_json::Value::Number(p.into()));
532 }
533 if let Some(sid) = state_id {
534 input.insert("stateId".into(), serde_json::Value::String(sid.to_string()));
535 }
536 if let Some(lids) = label_ids {
537 input.insert("labelIds".into(), serde_json::json!(lids));
538 }
539 if let Some(pid) = project_id {
540 input.insert(
541 "projectId".into(),
542 serde_json::Value::String(pid.to_string()),
543 );
544 }
545
546 let query = r#"
547 mutation($id: String!, $input: IssueUpdateInput!) {
548 issueUpdate(id: $id, input: $input) {
549 success
550 }
551 }
552 "#;
553
554 let data: UpdateIssueData = self
555 .query(query, serde_json::json!({ "id": issue_id, "input": input }))
556 .await?;
557
558 if !data.issue_update.success {
559 anyhow::bail!("Failed to update issue");
560 }
561
562 Ok(())
563 }
564
565 pub async fn fetch_single_issue(
566 &self,
567 issue_id: &str,
568 ) -> Result<(db::Issue, Vec<db::Relation>)> {
569 let query = r#"
570 query($id: String!) {
571 issue(id: $id) {
572 id identifier url title description priority branchName
573 createdAt updatedAt
574 state { name type }
575 team { key }
576 assignee { name }
577 project { name }
578 labels { nodes { name } }
579 relations { nodes { id type relatedIssue { id identifier } } }
580 }
581 }
582 "#;
583
584 let data: SingleIssueData = self
585 .query(query, serde_json::json!({ "id": issue_id }))
586 .await?;
587
588 Ok(Self::convert_linear_issue(data.issue))
589 }
590
591 pub async fn fetch_issue_by_identifier(
594 &self,
595 identifier: &str,
596 ) -> Result<Option<(db::Issue, Vec<db::Relation>)>> {
597 let parts: Vec<&str> = identifier.rsplitn(2, '-').collect();
599 if parts.len() != 2 {
600 anyhow::bail!(
601 "Invalid issue identifier '{}': expected format like 'ENG-123'",
602 identifier
603 );
604 }
605 let number: i32 = parts[0]
606 .parse()
607 .with_context(|| format!("Invalid issue number in '{}'", identifier))?;
608 let team_key = parts[1];
609
610 let query = format!(
611 r#"query {{
612 issues(
613 filter: {{
614 team: {{ key: {{ eq: "{}" }} }},
615 number: {{ eq: {} }}
616 }},
617 first: 1
618 ) {{
619 nodes {{
620 id identifier url title description priority branchName
621 createdAt updatedAt
622 state {{ name type }}
623 team {{ key }}
624 assignee {{ name }}
625 project {{ name }}
626 labels {{ nodes {{ name }} }}
627 relations {{ nodes {{ id type relatedIssue {{ id identifier }} }} }}
628 }}
629 pageInfo {{ hasNextPage endCursor }}
630 }}
631 }}"#,
632 team_key, number
633 );
634
635 let data: IssuesData = self.query(&query, serde_json::json!({})).await?;
636
637 Ok(data
638 .issues
639 .nodes
640 .into_iter()
641 .next()
642 .map(Self::convert_linear_issue))
643 }
644
645 fn convert_linear_issue(i: LinearIssue) -> (db::Issue, Vec<db::Relation>) {
646 let labels: Vec<String> = i.labels.nodes.iter().map(|l| l.name.clone()).collect();
647 let labels_json = serde_json::to_string(&labels).unwrap_or_else(|_| "[]".to_string());
648
649 let mut hasher = Sha256::new();
650 hasher.update(&i.title);
651 hasher.update(i.description.as_deref().unwrap_or(""));
652 hasher.update(&labels_json);
653 let content_hash = hex::encode(hasher.finalize());
654
655 let relations = Self::extract_relations(&i.id, &i);
656
657 let issue = db::Issue {
658 id: i.id,
659 identifier: i.identifier,
660 url: i.url,
661 team_key: i.team.key,
662 title: i.title,
663 description: i.description,
664 state_name: i.state.name,
665 state_type: i.state.state_type,
666 priority: i.priority,
667 assignee_name: i.assignee.map(|a| a.name),
668 project_name: i.project.map(|p| p.name),
669 labels_json,
670 created_at: i.created_at,
671 updated_at: i.updated_at,
672 content_hash,
673 synced_at: None,
674 branch_name: i.branch_name,
675 workspace_id: "default".to_string(),
676 };
677
678 (issue, relations)
679 }
680
681 pub async fn get_team_id(&self, team_key: &str) -> Result<String> {
683 let teams = self.list_teams().await?;
684 teams
685 .iter()
686 .find(|t| t.key.eq_ignore_ascii_case(team_key))
687 .map(|t| t.id.clone())
688 .with_context(|| format!("Team '{}' not found", team_key))
689 }
690
691 pub async fn get_state_id(&self, team_key: &str, state_name: &str) -> Result<String> {
694 let team_id = self.get_team_id(team_key).await?;
695 let query = r#"
696 query($teamId: String!) {
697 team(id: $teamId) {
698 states { nodes { id name type } }
699 }
700 }
701 "#;
702
703 let data: serde_json::Value = self
704 .query(query, serde_json::json!({ "teamId": team_id }))
705 .await?;
706
707 let states = data["team"]["states"]["nodes"]
708 .as_array()
709 .context("No states in response")?;
710
711 for state in states {
712 if let Some(name) = state["name"].as_str() {
713 if name.eq_ignore_ascii_case(state_name) {
714 return state["id"]
715 .as_str()
716 .map(|s| s.to_string())
717 .context("State has no id");
718 }
719 }
720 }
721
722 for state in states {
724 if let Some(t) = state["type"].as_str() {
725 if t.eq_ignore_ascii_case(state_name) {
726 return state["id"]
727 .as_str()
728 .map(|s| s.to_string())
729 .context("State has no id");
730 }
731 }
732 }
733
734 let available: Vec<&str> = states.iter().filter_map(|s| s["name"].as_str()).collect();
735 anyhow::bail!(
736 "State '{}' not found for team {}. Available: {}",
737 state_name,
738 team_key,
739 available.join(", ")
740 )
741 }
742
743 pub async fn get_label_ids(&self, label_names: &[String]) -> Result<Vec<String>> {
747 if label_names.is_empty() {
748 return Ok(Vec::new());
749 }
750
751 let query = r#"
752 query {
753 issueLabels(first: 250) {
754 nodes { id name }
755 }
756 }
757 "#;
758
759 let data: serde_json::Value = self.query(query, serde_json::json!({})).await?;
760
761 let labels = data["issueLabels"]["nodes"]
762 .as_array()
763 .context("No labels in response")?;
764
765 let mut ids = Vec::new();
766 for name in label_names {
767 let found = labels.iter().find(|l| {
768 l["name"]
769 .as_str()
770 .is_some_and(|n| n.eq_ignore_ascii_case(name))
771 });
772 match found {
773 Some(l) => {
774 ids.push(l["id"].as_str().context("Label has no id")?.to_string());
775 }
776 None => {
777 let available: Vec<&str> =
778 labels.iter().filter_map(|l| l["name"].as_str()).collect();
779 anyhow::bail!(
780 "Label '{}' not found. Available: {}",
781 name,
782 available.join(", ")
783 );
784 }
785 }
786 }
787
788 Ok(ids)
789 }
790
791 pub async fn get_project_id(&self, project_name: &str) -> Result<String> {
793 let query = r#"
794 query {
795 projects(first: 250) {
796 nodes { id name }
797 }
798 }
799 "#;
800
801 let data: serde_json::Value = self.query(query, serde_json::json!({})).await?;
802
803 let projects = data["projects"]["nodes"]
804 .as_array()
805 .context("No projects in response")?;
806
807 for project in projects {
808 if let Some(name) = project["name"].as_str() {
809 if name.eq_ignore_ascii_case(project_name) {
810 return project["id"]
811 .as_str()
812 .map(|s| s.to_string())
813 .context("Project has no id");
814 }
815 }
816 }
817
818 let available: Vec<&str> = projects.iter().filter_map(|p| p["name"].as_str()).collect();
819 anyhow::bail!(
820 "Project '{}' not found. Available: {}",
821 project_name,
822 available.join(", ")
823 )
824 }
825
826 pub async fn create_relation(
830 &self,
831 issue_id: &str,
832 related_issue_id: &str,
833 relation_type: &str,
834 ) -> Result<String> {
835 let (actual_issue_id, actual_related_id, api_type) = if relation_type == "blocked_by" {
836 (related_issue_id, issue_id, "blocks")
837 } else {
838 (issue_id, related_issue_id, relation_type)
839 };
840
841 let query = r#"
842 mutation($input: IssueRelationCreateInput!) {
843 issueRelationCreate(input: $input) {
844 success
845 issueRelation { id }
846 }
847 }
848 "#;
849
850 let input = serde_json::json!({
851 "issueId": actual_issue_id,
852 "relatedIssueId": actual_related_id,
853 "type": api_type,
854 });
855
856 let data: CreateRelationData = self
857 .query(query, serde_json::json!({ "input": input }))
858 .await?;
859
860 if !data.issue_relation_create.success {
861 anyhow::bail!("Failed to create relation");
862 }
863
864 let relation = data
865 .issue_relation_create
866 .issue_relation
867 .context("No relation returned")?;
868 Ok(relation.id)
869 }
870
871 pub async fn delete_relation(&self, relation_id: &str) -> Result<()> {
873 let query = r#"
874 mutation($id: String!) {
875 issueRelationDelete(id: $id) {
876 success
877 }
878 }
879 "#;
880
881 let data: DeleteRelationData = self
882 .query(query, serde_json::json!({ "id": relation_id }))
883 .await?;
884
885 if !data.issue_relation_delete.success {
886 anyhow::bail!("Failed to delete relation");
887 }
888
889 Ok(())
890 }
891}