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