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 viewer_id: std::sync::Arc<std::sync::RwLock<Option<String>>>,
15}
16
17#[derive(Debug, Deserialize)]
18struct GraphQLResponse<T> {
19 data: Option<T>,
20 errors: Option<Vec<GraphQLError>>,
21}
22
23#[derive(Debug, Deserialize)]
24struct GraphQLError {
25 message: String,
26}
27
28#[derive(Debug, Deserialize)]
31struct IssuesData {
32 issues: IssueConnection,
33}
34
35#[derive(Debug, Deserialize)]
36struct IssueConnection {
37 nodes: Vec<LinearIssue>,
38 #[serde(rename = "pageInfo")]
39 page_info: PageInfo,
40}
41
42#[derive(Debug, Deserialize)]
43struct PageInfo {
44 #[serde(rename = "hasNextPage")]
45 has_next_page: bool,
46 #[serde(rename = "endCursor")]
47 end_cursor: Option<String>,
48}
49
50#[derive(Debug, Deserialize)]
51struct LinearIssue {
52 id: String,
53 identifier: String,
54 url: String,
55 title: String,
56 description: Option<String>,
57 priority: i32,
58 #[serde(rename = "createdAt")]
59 created_at: String,
60 #[serde(rename = "updatedAt")]
61 updated_at: String,
62 state: LinearState,
63 team: LinearTeam,
64 assignee: Option<LinearUser>,
65 project: Option<LinearProject>,
66 labels: LinearLabelConnection,
67 #[serde(default)]
68 relations: LinearRelationConnection,
69 #[serde(rename = "branchName")]
70 branch_name: Option<String>,
71}
72
73#[derive(Debug, Deserialize, Default)]
74struct LinearRelationConnection {
75 nodes: Vec<LinearRelation>,
76}
77
78#[derive(Debug, Deserialize)]
79struct LinearRelation {
80 id: String,
81 #[serde(rename = "type")]
82 relation_type: String,
83 #[serde(rename = "relatedIssue")]
84 related_issue: LinearRelatedIssue,
85}
86
87#[derive(Debug, Deserialize)]
88struct LinearRelatedIssue {
89 id: String,
90 identifier: String,
91}
92
93#[derive(Debug, Deserialize)]
94struct LinearState {
95 name: String,
96 #[serde(rename = "type")]
97 state_type: String,
98}
99
100#[derive(Debug, Deserialize)]
101struct LinearTeam {
102 key: String,
103}
104
105#[derive(Debug, Deserialize)]
106struct LinearUser {
107 name: String,
108}
109
110#[derive(Debug, Deserialize)]
111struct LinearExternalUser {
112 name: Option<String>,
113 #[serde(rename = "displayName")]
114 display_name: Option<String>,
115}
116
117#[derive(Debug, Deserialize)]
118struct LinearProject {
119 name: String,
120}
121
122#[derive(Debug, Deserialize)]
123struct LinearLabelConnection {
124 nodes: Vec<LinearLabel>,
125}
126
127#[derive(Debug, Deserialize)]
128struct LinearLabel {
129 id: String,
130 name: String,
131}
132
133#[derive(Debug, Deserialize)]
136struct TeamsData {
137 teams: TeamConnection,
138}
139
140#[derive(Debug, Deserialize)]
141struct TeamConnection {
142 nodes: Vec<TeamNode>,
143}
144
145#[derive(Debug, Deserialize)]
146#[allow(dead_code)]
147pub struct TeamNode {
148 pub id: String,
149 pub key: String,
150 pub name: String,
151}
152
153#[derive(Debug, Clone)]
154pub struct LabelCatalogEntry {
155 pub id: String,
156 pub name: String,
157 pub color: Option<String>,
158 pub parent_id: Option<String>,
159}
160
161#[derive(Debug, Deserialize)]
164struct CreateIssueData {
165 #[serde(rename = "issueCreate")]
166 issue_create: CreateIssuePayload,
167}
168
169#[derive(Debug, Deserialize)]
170struct CreateIssuePayload {
171 success: bool,
172 issue: Option<CreatedIssue>,
173}
174
175#[derive(Debug, Deserialize)]
176struct CreatedIssue {
177 id: String,
178 identifier: String,
179}
180
181#[derive(Debug, Deserialize)]
184struct CreateCommentData {
185 #[serde(rename = "commentCreate")]
186 comment_create: CreateCommentPayload,
187}
188
189#[derive(Debug, Deserialize)]
190struct CreateCommentPayload {
191 success: bool,
192}
193
194#[derive(Debug, Deserialize)]
197struct CommentsData {
198 comments: LinearCommentConnection,
199}
200
201#[derive(Debug, Deserialize)]
202struct LinearCommentConnection {
203 nodes: Vec<LinearComment>,
204 #[serde(rename = "pageInfo")]
205 page_info: PageInfo,
206}
207
208#[derive(Debug, Deserialize)]
209struct LinearComment {
210 id: String,
211 body: String,
212 #[serde(rename = "createdAt")]
213 created_at: String,
214 #[serde(rename = "updatedAt")]
215 updated_at: String,
216 #[serde(rename = "parentId")]
217 parent_id: Option<String>,
218 url: String,
219 user: Option<LinearUser>,
220 #[serde(rename = "externalUser")]
221 external_user: Option<LinearExternalUser>,
222}
223
224#[derive(Debug, Deserialize)]
227struct UpdateIssueData {
228 #[serde(rename = "issueUpdate")]
229 issue_update: UpdateIssuePayload,
230}
231
232#[derive(Debug, Deserialize)]
233struct UpdateIssuePayload {
234 success: bool,
235}
236
237#[derive(Debug, Deserialize)]
240struct CreateRelationData {
241 #[serde(rename = "issueRelationCreate")]
242 issue_relation_create: CreateRelationPayload,
243}
244
245#[derive(Debug, Deserialize)]
246struct CreateRelationPayload {
247 success: bool,
248 #[serde(rename = "issueRelation")]
249 issue_relation: Option<CreatedRelation>,
250}
251
252#[derive(Debug, Deserialize)]
253struct CreatedRelation {
254 id: String,
255}
256
257#[derive(Debug, Deserialize)]
258struct DeleteRelationData {
259 #[serde(rename = "issueRelationDelete")]
260 issue_relation_delete: DeleteRelationPayload,
261}
262
263#[derive(Debug, Deserialize)]
264struct DeleteRelationPayload {
265 success: bool,
266}
267
268#[derive(Debug, Deserialize)]
271struct SingleIssueData {
272 issue: LinearIssue,
273}
274
275impl LinearClient {
276 pub fn new(config: &Config) -> Result<Self> {
277 let api_key = config.linear_api_key()?.to_string();
278 let client = reqwest::Client::new();
279 Ok(Self { client, api_key, viewer_id: std::sync::Arc::new(std::sync::RwLock::new(None)) })
280 }
281
282 pub fn with_api_key(api_key: &str) -> Self {
284 Self {
285 client: reqwest::Client::new(),
286 api_key: api_key.to_string(),
287 viewer_id: std::sync::Arc::new(std::sync::RwLock::new(None)),
288 }
289 }
290
291 pub fn with_http_client(client: reqwest::Client, api_key: &str) -> Self {
296 Self {
297 client,
298 api_key: api_key.to_string(),
299 viewer_id: std::sync::Arc::new(std::sync::RwLock::new(None)),
300 }
301 }
302
303 async fn query<T: serde::de::DeserializeOwned>(
304 &self,
305 query: &str,
306 variables: serde_json::Value,
307 ) -> Result<T> {
308 let body = serde_json::json!({
309 "query": query,
310 "variables": variables,
311 });
312
313 let resp = self
314 .client
315 .post(LINEAR_API_URL)
316 .header("Authorization", &self.api_key)
317 .header("Content-Type", "application/json")
318 .json(&body)
319 .send()
320 .await
321 .context("Failed to send request to Linear API")?;
322
323 let status = resp.status();
324 if !status.is_success() {
325 let text = resp.text().await.unwrap_or_default();
326 anyhow::bail!("Linear API returned {}: {}", status, text);
327 }
328
329 let response: GraphQLResponse<T> = resp
330 .json()
331 .await
332 .context("Failed to parse Linear response")?;
333
334 if let Some(errors) = response.errors {
335 let msgs: Vec<_> = errors.iter().map(|e| e.message.as_str()).collect();
336 anyhow::bail!("Linear API errors: {}", msgs.join(", "));
337 }
338
339 response.data.context("No data in Linear response")
340 }
341
342 pub async fn list_teams(&self) -> Result<Vec<TeamNode>> {
343 let data: TeamsData = self
344 .query(
345 "query { teams { nodes { id key name } } }",
346 serde_json::json!({}),
347 )
348 .await?;
349 Ok(data.teams.nodes)
350 }
351
352 fn extract_relations(issue_id: &str, linear_issue: &LinearIssue) -> Vec<db::Relation> {
353 linear_issue
354 .relations
355 .nodes
356 .iter()
357 .map(|r| db::Relation {
358 id: r.id.clone(),
359 issue_id: issue_id.to_string(),
360 related_issue_id: r.related_issue.id.clone(),
361 related_issue_identifier: r.related_issue.identifier.clone(),
362 relation_type: r.relation_type.clone(),
363 })
364 .collect()
365 }
366
367 pub async fn fetch_issues(
368 &self,
369 team_key: &str,
370 after_cursor: Option<&str>,
371 updated_after: Option<&str>,
372 include_archived: bool,
373 ) -> Result<(Vec<(db::Issue, Vec<db::Relation>, Vec<String>)>, bool, Option<String>)> {
374 let mut filter_parts = vec![format!("team: {{ key: {{ eq: \"{}\" }} }}", team_key)];
375 if let Some(after) = updated_after {
376 filter_parts.push(format!("updatedAt: {{ gt: \"{}\" }}", after));
377 }
378 let filter = filter_parts.join(", ");
379
380 let after_param = if let Some(c) = after_cursor {
381 format!(", after: \"{}\"", c)
382 } else {
383 String::new()
384 };
385
386 let include_archive = if include_archived { "true" } else { "false" };
387
388 let query = format!(
389 r#"query {{
390 issues(
391 first: 250,
392 filter: {{ {} }},
393 includeArchived: {}
394 orderBy: updatedAt
395 {}
396 ) {{
397 nodes {{
398 id identifier url title description priority branchName
399 createdAt updatedAt
400 state {{ name type }}
401 team {{ key }}
402 assignee {{ name }}
403 project {{ name }}
404 labels {{ nodes {{ id name }} }}
405 relations {{ nodes {{ id type relatedIssue {{ id identifier }} }} }}
406 }}
407 pageInfo {{ hasNextPage endCursor }}
408 }}
409 }}"#,
410 filter, include_archive, after_param
411 );
412
413 let data: IssuesData = self.query(&query, serde_json::json!({})).await?;
414
415 let issues: Vec<(db::Issue, Vec<db::Relation>, Vec<String>)> = data
416 .issues
417 .nodes
418 .into_iter()
419 .map(Self::convert_linear_issue)
420 .collect();
421
422 Ok((
423 issues,
424 data.issues.page_info.has_next_page,
425 data.issues.page_info.end_cursor,
426 ))
427 }
428
429 pub async fn sync_team(
430 &self,
431 db: &Database,
432 team_key: &str,
433 workspace_id: &str,
434 full: bool,
435 include_archived: bool,
436 progress: Option<&(dyn Fn(usize) + Send + Sync)>,
437 ) -> Result<usize> {
438 if let Err(e) = self.sync_labels_catalog(db, workspace_id).await {
442 eprintln!("warning: failed to sync label catalog for workspace '{}': {}", workspace_id, e);
443 }
444
445 let updated_after = if full {
446 None
447 } else {
448 db.get_sync_cursor(workspace_id, team_key)?
449 };
450
451 let mut total = 0;
452 let mut cursor: Option<String> = None;
453 let mut max_updated: Option<String> = None;
454
455 loop {
456 let (issues, has_next, next_cursor) = self
457 .fetch_issues(
458 team_key,
459 cursor.as_deref(),
460 updated_after.as_deref(),
461 include_archived,
462 )
463 .await?;
464
465 let count = issues.len();
466 for (mut issue, relations, label_ids) in issues {
467 issue.workspace_id = workspace_id.to_string();
468 if max_updated.is_none() || Some(&issue.updated_at) > max_updated.as_ref() {
469 max_updated = Some(issue.updated_at.clone());
470 }
471 db.upsert_issue(&issue)?;
472 db.upsert_relations(&issue.id, &relations)?;
473 db.replace_issue_labels(&issue.id, &label_ids)?;
474 if let Err(e) = self
475 .sync_issue_comments(db, &issue.id, workspace_id)
476 .await
477 {
478 eprintln!(
479 "warning: failed to sync comments for issue {}: {}",
480 issue.identifier,
481 Self::redacted_error_message(&e)
482 );
483 }
484 }
485 total += count;
486
487 if let Some(cb) = progress {
488 cb(total);
489 }
490
491 if !has_next || count == 0 {
492 break;
493 }
494 cursor = next_cursor;
495 }
496
497 if let Some(max) = max_updated {
498 db.set_sync_cursor(workspace_id, team_key, &max)?;
499 }
500
501 Ok(total)
502 }
503
504 pub async fn create_issue(
505 &self,
506 team_id: &str,
507 title: &str,
508 description: Option<&str>,
509 priority: Option<i32>,
510 label_ids: &[String],
511 assignee_id: Option<&str>,
512 parent_id: Option<&str>,
513 ) -> Result<(String, String)> {
514 let mut input = serde_json::json!({
515 "teamId": team_id,
516 "title": title,
517 });
518
519 if let Some(desc) = description {
520 input["description"] = serde_json::Value::String(desc.to_string());
521 }
522 if let Some(p) = priority {
523 input["priority"] = serde_json::Value::Number(p.into());
524 }
525 if !label_ids.is_empty() {
526 input["labelIds"] = serde_json::json!(label_ids);
527 }
528 if let Some(aid) = assignee_id {
529 input["assigneeId"] = serde_json::Value::String(aid.to_string());
530 }
531 if let Some(pid) = parent_id {
532 input["parentId"] = serde_json::Value::String(pid.to_string());
533 }
534
535 let query = r#"
536 mutation($input: IssueCreateInput!) {
537 issueCreate(input: $input) {
538 success
539 issue { id identifier }
540 }
541 }
542 "#;
543
544 let data: CreateIssueData = self
545 .query(query, serde_json::json!({ "input": input }))
546 .await?;
547
548 if !data.issue_create.success {
549 anyhow::bail!("Failed to create issue");
550 }
551
552 let issue = data.issue_create.issue.context("No issue returned")?;
553 Ok((issue.id, issue.identifier))
554 }
555
556 pub async fn add_comment(&self, issue_id: &str, body: &str) -> Result<()> {
557 let query = r#"
558 mutation($input: CommentCreateInput!) {
559 commentCreate(input: $input) {
560 success
561 }
562 }
563 "#;
564
565 let input = serde_json::json!({
566 "issueId": issue_id,
567 "body": body,
568 });
569
570 let data: CreateCommentData = self
571 .query(query, serde_json::json!({ "input": input }))
572 .await?;
573
574 if !data.comment_create.success {
575 anyhow::bail!("Failed to create comment");
576 }
577
578 Ok(())
579 }
580
581 pub async fn fetch_issue_comments(&self, issue_id: &str) -> Result<Vec<db::Comment>> {
582 let query = r#"
583 query($issueId: ID!, $after: String) {
584 comments(
585 filter: { issue: { id: { eq: $issueId } } },
586 first: 100,
587 after: $after,
588 includeArchived: true,
589 orderBy: createdAt
590 ) {
591 nodes {
592 id body createdAt updatedAt parentId url
593 user { name }
594 externalUser { displayName name }
595 }
596 pageInfo { hasNextPage endCursor }
597 }
598 }
599 "#;
600
601 let mut comments = Vec::new();
602 let mut cursor: Option<String> = None;
603
604 loop {
605 let data: CommentsData = self
606 .query(
607 query,
608 serde_json::json!({
609 "issueId": issue_id,
610 "after": cursor.as_deref(),
611 }),
612 )
613 .await?;
614
615 comments.extend(
616 data.comments
617 .nodes
618 .into_iter()
619 .map(|comment| Self::convert_linear_comment(issue_id, comment)),
620 );
621
622 if !data.comments.page_info.has_next_page {
623 break;
624 }
625 cursor = data.comments.page_info.end_cursor;
626 if cursor.is_none() {
627 break;
628 }
629 }
630
631 Ok(comments)
632 }
633
634 pub async fn sync_issue_comments(
635 &self,
636 db: &Database,
637 issue_id: &str,
638 workspace_id: &str,
639 ) -> Result<usize> {
640 match self.fetch_issue_comments(issue_id).await {
641 Ok(mut comments) => {
642 for comment in &mut comments {
643 comment.workspace_id = workspace_id.to_string();
644 }
645 let count = comments.len();
646 db.replace_issue_comments(issue_id, workspace_id, &comments)?;
647 db.mark_comments_synced(issue_id, workspace_id, count)?;
648 Ok(count)
649 }
650 Err(error) => {
651 let status = Self::comment_error_status(&error);
652 let message = Self::redacted_error_message(&error);
653 db.mark_comments_sync_failed(issue_id, workspace_id, status, &message)?;
654 Err(error)
655 }
656 }
657 }
658
659 pub fn comment_error_status(error: &anyhow::Error) -> &'static str {
660 let message = error.to_string().to_lowercase();
661 if message.contains("permission")
662 || message.contains("forbidden")
663 || message.contains("unauthorized")
664 || message.contains("access")
665 {
666 "permission_denied"
667 } else {
668 "unavailable"
669 }
670 }
671
672 fn redacted_error_message(error: &anyhow::Error) -> String {
673 error.to_string().chars().take(500).collect()
674 }
675
676 pub async fn update_issue(
677 &self,
678 issue_id: &str,
679 title: Option<&str>,
680 description: Option<&str>,
681 priority: Option<i32>,
682 state_id: Option<&str>,
683 label_ids: Option<&[String]>,
684 project_id: Option<&str>,
685 assignee_id: Option<&str>,
686 ) -> Result<()> {
687 let mut input = serde_json::Map::new();
688 if let Some(t) = title {
689 input.insert("title".into(), serde_json::Value::String(t.to_string()));
690 }
691 if let Some(d) = description {
692 input.insert(
693 "description".into(),
694 serde_json::Value::String(d.to_string()),
695 );
696 }
697 if let Some(p) = priority {
698 input.insert("priority".into(), serde_json::Value::Number(p.into()));
699 }
700 if let Some(sid) = state_id {
701 input.insert("stateId".into(), serde_json::Value::String(sid.to_string()));
702 }
703 if let Some(lids) = label_ids {
704 input.insert("labelIds".into(), serde_json::json!(lids));
705 }
706 if let Some(pid) = project_id {
707 let value = if pid.is_empty() {
708 serde_json::Value::Null
709 } else {
710 serde_json::Value::String(pid.to_string())
711 };
712 input.insert("projectId".into(), value);
713 }
714 if let Some(aid) = assignee_id {
715 let value = if aid.is_empty() {
716 serde_json::Value::Null
717 } else {
718 serde_json::Value::String(aid.to_string())
719 };
720 input.insert("assigneeId".into(), value);
721 }
722
723 let query = r#"
724 mutation($id: String!, $input: IssueUpdateInput!) {
725 issueUpdate(id: $id, input: $input) {
726 success
727 }
728 }
729 "#;
730
731 let data: UpdateIssueData = self
732 .query(query, serde_json::json!({ "id": issue_id, "input": input }))
733 .await?;
734
735 if !data.issue_update.success {
736 anyhow::bail!("Failed to update issue");
737 }
738
739 Ok(())
740 }
741
742 pub async fn fetch_single_issue(
743 &self,
744 issue_id: &str,
745 ) -> Result<(db::Issue, Vec<db::Relation>, Vec<String>)> {
746 let query = r#"
747 query($id: String!) {
748 issue(id: $id) {
749 id identifier url title description priority branchName
750 createdAt updatedAt
751 state { name type }
752 team { key }
753 assignee { name }
754 project { name }
755 labels { nodes { id name } }
756 relations { nodes { id type relatedIssue { id identifier } } }
757 }
758 }
759 "#;
760
761 let data: SingleIssueData = self
762 .query(query, serde_json::json!({ "id": issue_id }))
763 .await?;
764
765 Ok(Self::convert_linear_issue(data.issue))
766 }
767
768 pub async fn fetch_issue_by_identifier(
771 &self,
772 identifier: &str,
773 ) -> Result<Option<(db::Issue, Vec<db::Relation>, Vec<String>)>> {
774 let parts: Vec<&str> = identifier.rsplitn(2, '-').collect();
776 if parts.len() != 2 {
777 anyhow::bail!(
778 "Invalid issue identifier '{}': expected format like 'ENG-123'",
779 identifier
780 );
781 }
782 let number: i32 = parts[0]
783 .parse()
784 .with_context(|| format!("Invalid issue number in '{}'", identifier))?;
785 let team_key = parts[1];
786
787 let query = format!(
788 r#"query {{
789 issues(
790 filter: {{
791 team: {{ key: {{ eq: "{}" }} }},
792 number: {{ eq: {} }}
793 }},
794 first: 1,
795 includeArchived: true
796 ) {{
797 nodes {{
798 id identifier url title description priority branchName
799 createdAt updatedAt
800 state {{ name type }}
801 team {{ key }}
802 assignee {{ name }}
803 project {{ name }}
804 labels {{ nodes {{ id name }} }}
805 relations {{ nodes {{ id type relatedIssue {{ id identifier }} }} }}
806 }}
807 pageInfo {{ hasNextPage endCursor }}
808 }}
809 }}"#,
810 team_key, number
811 );
812
813 let data: IssuesData = self.query(&query, serde_json::json!({})).await?;
814
815 Ok(data
816 .issues
817 .nodes
818 .into_iter()
819 .next()
820 .map(Self::convert_linear_issue))
821 }
822
823 fn convert_linear_issue(i: LinearIssue) -> (db::Issue, Vec<db::Relation>, Vec<String>) {
824 let labels: Vec<String> = i.labels.nodes.iter().map(|l| l.name.clone()).collect();
825 let label_ids: Vec<String> = i.labels.nodes.iter().map(|l| l.id.clone()).collect();
826 let labels_json = serde_json::to_string(&labels).unwrap_or_else(|_| "[]".to_string());
827
828 let mut hasher = Sha256::new();
829 hasher.update(&i.title);
830 hasher.update(i.description.as_deref().unwrap_or(""));
831 hasher.update(&labels_json);
832 let content_hash = hex::encode(hasher.finalize());
833
834 let relations = Self::extract_relations(&i.id, &i);
835
836 let issue = db::Issue {
837 id: i.id,
838 identifier: i.identifier,
839 url: i.url,
840 team_key: i.team.key,
841 title: i.title,
842 description: i.description,
843 state_name: i.state.name,
844 state_type: i.state.state_type,
845 priority: i.priority,
846 assignee_name: i.assignee.map(|a| a.name),
847 project_name: i.project.map(|p| p.name),
848 labels_json,
849 created_at: i.created_at,
850 updated_at: i.updated_at,
851 content_hash,
852 synced_at: None,
853 branch_name: i.branch_name,
854 workspace_id: "default".to_string(),
855 };
856
857 (issue, relations, label_ids)
858 }
859
860 fn convert_linear_comment(issue_id: &str, comment: LinearComment) -> db::Comment {
861 let external_name = comment
862 .external_user
863 .and_then(|u| u.display_name.or(u.name));
864 db::Comment {
865 id: comment.id,
866 issue_id: issue_id.to_string(),
867 body: comment.body,
868 user_name: comment.user.map(|u| u.name).or(external_name),
869 created_at: comment.created_at,
870 updated_at: Some(comment.updated_at),
871 parent_id: comment.parent_id,
872 url: Some(comment.url),
873 workspace_id: "default".to_string(),
874 }
875 }
876
877 pub async fn get_team_id(&self, team_key: &str) -> Result<String> {
879 let teams = self.list_teams().await?;
880 teams
881 .iter()
882 .find(|t| t.key.eq_ignore_ascii_case(team_key))
883 .map(|t| t.id.clone())
884 .with_context(|| format!("Team '{}' not found", team_key))
885 }
886
887 pub async fn get_state_id(&self, team_key: &str, state_name: &str) -> Result<String> {
890 let team_id = self.get_team_id(team_key).await?;
891 let query = r#"
892 query($teamId: String!) {
893 team(id: $teamId) {
894 states { nodes { id name type } }
895 }
896 }
897 "#;
898
899 let data: serde_json::Value = self
900 .query(query, serde_json::json!({ "teamId": team_id }))
901 .await?;
902
903 let states = data["team"]["states"]["nodes"]
904 .as_array()
905 .context("No states in response")?;
906
907 for state in states {
908 if let Some(name) = state["name"].as_str() {
909 if name.eq_ignore_ascii_case(state_name) {
910 return state["id"]
911 .as_str()
912 .map(|s| s.to_string())
913 .context("State has no id");
914 }
915 }
916 }
917
918 for state in states {
920 if let Some(t) = state["type"].as_str() {
921 if t.eq_ignore_ascii_case(state_name) {
922 return state["id"]
923 .as_str()
924 .map(|s| s.to_string())
925 .context("State has no id");
926 }
927 }
928 }
929
930 let available: Vec<&str> = states.iter().filter_map(|s| s["name"].as_str()).collect();
931 anyhow::bail!(
932 "State '{}' not found for team {}. Available: {}",
933 state_name,
934 team_key,
935 available.join(", ")
936 )
937 }
938
939 pub async fn get_label_ids(&self, label_names: &[String]) -> Result<Vec<String>> {
943 if label_names.is_empty() {
944 return Ok(Vec::new());
945 }
946
947 let query = r#"
948 query {
949 issueLabels(first: 250) {
950 nodes { id name }
951 }
952 }
953 "#;
954
955 let data: serde_json::Value = self.query(query, serde_json::json!({})).await?;
956
957 let labels = data["issueLabels"]["nodes"]
958 .as_array()
959 .context("No labels in response")?;
960
961 let mut ids = Vec::new();
962 for name in label_names {
963 let found = labels.iter().find(|l| {
964 l["name"]
965 .as_str()
966 .is_some_and(|n| n.eq_ignore_ascii_case(name))
967 });
968 match found {
969 Some(l) => {
970 ids.push(l["id"].as_str().context("Label has no id")?.to_string());
971 }
972 None => {
973 let available: Vec<&str> =
974 labels.iter().filter_map(|l| l["name"].as_str()).collect();
975 anyhow::bail!(
976 "Label '{}' not found. Available: {}",
977 name,
978 available.join(", ")
979 );
980 }
981 }
982 }
983
984 Ok(ids)
985 }
986
987 pub async fn resolve_assignee_id(&self, input: &str) -> Result<String> {
994 let trimmed = input.trim();
995 if trimmed.eq_ignore_ascii_case("none") {
996 return Ok(String::new());
997 }
998 if trimmed.eq_ignore_ascii_case("me") {
999 if let Some(cached) = self.viewer_id.read().unwrap().clone() {
1000 return Ok(cached);
1001 }
1002 let data: serde_json::Value = self
1003 .query("query { viewer { id } }", serde_json::json!({}))
1004 .await?;
1005 let id = data["viewer"]["id"]
1006 .as_str()
1007 .context("viewer query returned no id")?
1008 .to_string();
1009 *self.viewer_id.write().unwrap() = Some(id.clone());
1010 return Ok(id);
1011 }
1012
1013 let data: serde_json::Value = self
1015 .query(
1016 "query { users(first: 250) { nodes { id name } } }",
1017 serde_json::json!({}),
1018 )
1019 .await?;
1020 let nodes = data["users"]["nodes"]
1021 .as_array()
1022 .context("users query returned no nodes")?;
1023 let matches: Vec<(String, String)> = nodes
1024 .iter()
1025 .filter_map(|n| {
1026 let name = n["name"].as_str()?;
1027 if name.eq_ignore_ascii_case(trimmed) {
1028 Some((n["id"].as_str()?.to_string(), name.to_string()))
1029 } else {
1030 None
1031 }
1032 })
1033 .collect();
1034
1035 match matches.len() {
1036 0 => anyhow::bail!("Assignee '{}' not found in Linear users.", trimmed),
1037 1 => Ok(matches.into_iter().next().unwrap().0),
1038 _ => {
1039 let names: Vec<&str> = matches.iter().map(|(_, n)| n.as_str()).collect();
1040 anyhow::bail!(
1041 "Assignee '{}' matched multiple users: {}. Use a more specific name.",
1042 trimmed,
1043 names.join(", ")
1044 )
1045 }
1046 }
1047 }
1048
1049 pub async fn fetch_labels(&self) -> Result<Vec<LabelCatalogEntry>> {
1051 let mut out = Vec::new();
1052 let mut cursor: Option<String> = None;
1053 loop {
1054 let after_param = match cursor {
1055 Some(ref c) => format!(", after: \"{}\"", c),
1056 None => String::new(),
1057 };
1058 let query = format!(
1059 r#"query {{
1060 issueLabels(first: 250{}) {{
1061 nodes {{ id name color parent {{ id }} }}
1062 pageInfo {{ hasNextPage endCursor }}
1063 }}
1064 }}"#,
1065 after_param
1066 );
1067 let data: serde_json::Value = self.query(&query, serde_json::json!({})).await?;
1068 let nodes = data["issueLabels"]["nodes"]
1069 .as_array()
1070 .context("No issueLabels.nodes in response")?;
1071 for n in nodes {
1072 let id = n["id"].as_str().context("label has no id")?.to_string();
1073 let name = n["name"].as_str().unwrap_or("").to_string();
1074 let color = n["color"].as_str().map(|s| s.to_string());
1075 let parent_id = n["parent"]["id"].as_str().map(|s| s.to_string());
1076 out.push(LabelCatalogEntry { id, name, color, parent_id });
1077 }
1078 let has_next = data["issueLabels"]["pageInfo"]["hasNextPage"].as_bool().unwrap_or(false);
1079 if !has_next { break; }
1080 cursor = data["issueLabels"]["pageInfo"]["endCursor"].as_str().map(|s| s.to_string());
1081 if cursor.is_none() { break; }
1082 }
1083 Ok(out)
1084 }
1085
1086 pub async fn sync_labels_catalog(&self, db: &Database, workspace_id: &str) -> Result<usize> {
1089 let entries = self.fetch_labels().await?;
1090 let keep_ids: Vec<String> = entries.iter().map(|e| e.id.clone()).collect();
1091 for e in &entries {
1092 db.upsert_label(&db::Label {
1093 id: e.id.clone(),
1094 workspace_id: workspace_id.to_string(),
1095 name: e.name.clone(),
1096 color: e.color.clone(),
1097 parent_id: e.parent_id.clone(),
1098 })?;
1099 }
1100 db.delete_labels_for_workspace_not_in(workspace_id, &keep_ids)?;
1101 Ok(entries.len())
1102 }
1103
1104 pub async fn get_project_id(&self, project_name: &str) -> Result<String> {
1106 let query = r#"
1107 query {
1108 projects(first: 250) {
1109 nodes { id name }
1110 }
1111 }
1112 "#;
1113
1114 let data: serde_json::Value = self.query(query, serde_json::json!({})).await?;
1115
1116 let projects = data["projects"]["nodes"]
1117 .as_array()
1118 .context("No projects in response")?;
1119
1120 for project in projects {
1121 if let Some(name) = project["name"].as_str() {
1122 if name.eq_ignore_ascii_case(project_name) {
1123 return project["id"]
1124 .as_str()
1125 .map(|s| s.to_string())
1126 .context("Project has no id");
1127 }
1128 }
1129 }
1130
1131 let available: Vec<&str> = projects.iter().filter_map(|p| p["name"].as_str()).collect();
1132 anyhow::bail!(
1133 "Project '{}' not found. Available: {}",
1134 project_name,
1135 available.join(", ")
1136 )
1137 }
1138
1139 pub async fn create_relation(
1143 &self,
1144 issue_id: &str,
1145 related_issue_id: &str,
1146 relation_type: &str,
1147 ) -> Result<String> {
1148 let (actual_issue_id, actual_related_id, api_type) = if relation_type == "blocked_by" {
1149 (related_issue_id, issue_id, "blocks")
1150 } else {
1151 (issue_id, related_issue_id, relation_type)
1152 };
1153
1154 let query = r#"
1155 mutation($input: IssueRelationCreateInput!) {
1156 issueRelationCreate(input: $input) {
1157 success
1158 issueRelation { id }
1159 }
1160 }
1161 "#;
1162
1163 let input = serde_json::json!({
1164 "issueId": actual_issue_id,
1165 "relatedIssueId": actual_related_id,
1166 "type": api_type,
1167 });
1168
1169 let data: CreateRelationData = self
1170 .query(query, serde_json::json!({ "input": input }))
1171 .await?;
1172
1173 if !data.issue_relation_create.success {
1174 anyhow::bail!("Failed to create relation");
1175 }
1176
1177 let relation = data
1178 .issue_relation_create
1179 .issue_relation
1180 .context("No relation returned")?;
1181 Ok(relation.id)
1182 }
1183
1184 pub async fn delete_relation(&self, relation_id: &str) -> Result<()> {
1186 let query = r#"
1187 mutation($id: String!) {
1188 issueRelationDelete(id: $id) {
1189 success
1190 }
1191 }
1192 "#;
1193
1194 let data: DeleteRelationData = self
1195 .query(query, serde_json::json!({ "id": relation_id }))
1196 .await?;
1197
1198 if !data.issue_relation_delete.success {
1199 anyhow::bail!("Failed to delete relation");
1200 }
1201
1202 Ok(())
1203 }
1204}