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 LinearProject {
112 name: String,
113}
114
115#[derive(Debug, Deserialize)]
116struct LinearLabelConnection {
117 nodes: Vec<LinearLabel>,
118}
119
120#[derive(Debug, Deserialize)]
121struct LinearLabel {
122 id: String,
123 name: String,
124}
125
126#[derive(Debug, Deserialize)]
129struct TeamsData {
130 teams: TeamConnection,
131}
132
133#[derive(Debug, Deserialize)]
134struct TeamConnection {
135 nodes: Vec<TeamNode>,
136}
137
138#[derive(Debug, Deserialize)]
139#[allow(dead_code)]
140pub struct TeamNode {
141 pub id: String,
142 pub key: String,
143 pub name: String,
144}
145
146#[derive(Debug, Clone)]
147pub struct LabelCatalogEntry {
148 pub id: String,
149 pub name: String,
150 pub color: Option<String>,
151 pub parent_id: Option<String>,
152}
153
154#[derive(Debug, Deserialize)]
157struct CreateIssueData {
158 #[serde(rename = "issueCreate")]
159 issue_create: CreateIssuePayload,
160}
161
162#[derive(Debug, Deserialize)]
163struct CreateIssuePayload {
164 success: bool,
165 issue: Option<CreatedIssue>,
166}
167
168#[derive(Debug, Deserialize)]
169struct CreatedIssue {
170 id: String,
171 identifier: String,
172}
173
174#[derive(Debug, Deserialize)]
177struct CreateCommentData {
178 #[serde(rename = "commentCreate")]
179 comment_create: CreateCommentPayload,
180}
181
182#[derive(Debug, Deserialize)]
183struct CreateCommentPayload {
184 success: bool,
185}
186
187#[derive(Debug, Deserialize)]
190struct UpdateIssueData {
191 #[serde(rename = "issueUpdate")]
192 issue_update: UpdateIssuePayload,
193}
194
195#[derive(Debug, Deserialize)]
196struct UpdateIssuePayload {
197 success: bool,
198}
199
200#[derive(Debug, Deserialize)]
203struct CreateRelationData {
204 #[serde(rename = "issueRelationCreate")]
205 issue_relation_create: CreateRelationPayload,
206}
207
208#[derive(Debug, Deserialize)]
209struct CreateRelationPayload {
210 success: bool,
211 #[serde(rename = "issueRelation")]
212 issue_relation: Option<CreatedRelation>,
213}
214
215#[derive(Debug, Deserialize)]
216struct CreatedRelation {
217 id: String,
218}
219
220#[derive(Debug, Deserialize)]
221struct DeleteRelationData {
222 #[serde(rename = "issueRelationDelete")]
223 issue_relation_delete: DeleteRelationPayload,
224}
225
226#[derive(Debug, Deserialize)]
227struct DeleteRelationPayload {
228 success: bool,
229}
230
231#[derive(Debug, Deserialize)]
234struct SingleIssueData {
235 issue: LinearIssue,
236}
237
238impl LinearClient {
239 pub fn new(config: &Config) -> Result<Self> {
240 let api_key = config.linear_api_key()?.to_string();
241 let client = reqwest::Client::new();
242 Ok(Self { client, api_key, viewer_id: std::sync::Arc::new(std::sync::RwLock::new(None)) })
243 }
244
245 pub fn with_api_key(api_key: &str) -> Self {
247 Self {
248 client: reqwest::Client::new(),
249 api_key: api_key.to_string(),
250 viewer_id: std::sync::Arc::new(std::sync::RwLock::new(None)),
251 }
252 }
253
254 pub fn with_http_client(client: reqwest::Client, api_key: &str) -> Self {
259 Self {
260 client,
261 api_key: api_key.to_string(),
262 viewer_id: std::sync::Arc::new(std::sync::RwLock::new(None)),
263 }
264 }
265
266 async fn query<T: serde::de::DeserializeOwned>(
267 &self,
268 query: &str,
269 variables: serde_json::Value,
270 ) -> Result<T> {
271 let body = serde_json::json!({
272 "query": query,
273 "variables": variables,
274 });
275
276 let resp = self
277 .client
278 .post(LINEAR_API_URL)
279 .header("Authorization", &self.api_key)
280 .header("Content-Type", "application/json")
281 .json(&body)
282 .send()
283 .await
284 .context("Failed to send request to Linear API")?;
285
286 let status = resp.status();
287 if !status.is_success() {
288 let text = resp.text().await.unwrap_or_default();
289 anyhow::bail!("Linear API returned {}: {}", status, text);
290 }
291
292 let response: GraphQLResponse<T> = resp
293 .json()
294 .await
295 .context("Failed to parse Linear response")?;
296
297 if let Some(errors) = response.errors {
298 let msgs: Vec<_> = errors.iter().map(|e| e.message.as_str()).collect();
299 anyhow::bail!("Linear API errors: {}", msgs.join(", "));
300 }
301
302 response.data.context("No data in Linear response")
303 }
304
305 pub async fn list_teams(&self) -> Result<Vec<TeamNode>> {
306 let data: TeamsData = self
307 .query(
308 "query { teams { nodes { id key name } } }",
309 serde_json::json!({}),
310 )
311 .await?;
312 Ok(data.teams.nodes)
313 }
314
315 fn extract_relations(issue_id: &str, linear_issue: &LinearIssue) -> Vec<db::Relation> {
316 linear_issue
317 .relations
318 .nodes
319 .iter()
320 .map(|r| db::Relation {
321 id: r.id.clone(),
322 issue_id: issue_id.to_string(),
323 related_issue_id: r.related_issue.id.clone(),
324 related_issue_identifier: r.related_issue.identifier.clone(),
325 relation_type: r.relation_type.clone(),
326 })
327 .collect()
328 }
329
330 pub async fn fetch_issues(
331 &self,
332 team_key: &str,
333 after_cursor: Option<&str>,
334 updated_after: Option<&str>,
335 include_archived: bool,
336 ) -> Result<(Vec<(db::Issue, Vec<db::Relation>, Vec<String>)>, bool, Option<String>)> {
337 let mut filter_parts = vec![format!("team: {{ key: {{ eq: \"{}\" }} }}", team_key)];
338 if let Some(after) = updated_after {
339 filter_parts.push(format!("updatedAt: {{ gt: \"{}\" }}", after));
340 }
341 let filter = filter_parts.join(", ");
342
343 let after_param = if let Some(c) = after_cursor {
344 format!(", after: \"{}\"", c)
345 } else {
346 String::new()
347 };
348
349 let include_archive = if include_archived { "true" } else { "false" };
350
351 let query = format!(
352 r#"query {{
353 issues(
354 first: 250,
355 filter: {{ {} }},
356 includeArchived: {}
357 orderBy: updatedAt
358 {}
359 ) {{
360 nodes {{
361 id identifier url title description priority branchName
362 createdAt updatedAt
363 state {{ name type }}
364 team {{ key }}
365 assignee {{ name }}
366 project {{ name }}
367 labels {{ nodes {{ id name }} }}
368 relations {{ nodes {{ id type relatedIssue {{ id identifier }} }} }}
369 }}
370 pageInfo {{ hasNextPage endCursor }}
371 }}
372 }}"#,
373 filter, include_archive, after_param
374 );
375
376 let data: IssuesData = self.query(&query, serde_json::json!({})).await?;
377
378 let issues: Vec<(db::Issue, Vec<db::Relation>, Vec<String>)> = data
379 .issues
380 .nodes
381 .into_iter()
382 .map(Self::convert_linear_issue)
383 .collect();
384
385 Ok((
386 issues,
387 data.issues.page_info.has_next_page,
388 data.issues.page_info.end_cursor,
389 ))
390 }
391
392 pub async fn sync_team(
393 &self,
394 db: &Database,
395 team_key: &str,
396 workspace_id: &str,
397 full: bool,
398 include_archived: bool,
399 progress: Option<&(dyn Fn(usize) + Send + Sync)>,
400 ) -> Result<usize> {
401 if let Err(e) = self.sync_labels_catalog(db, workspace_id).await {
405 eprintln!("warning: failed to sync label catalog for workspace '{}': {}", workspace_id, e);
406 }
407
408 let updated_after = if full {
409 None
410 } else {
411 db.get_sync_cursor(workspace_id, team_key)?
412 };
413
414 let mut total = 0;
415 let mut cursor: Option<String> = None;
416 let mut max_updated: Option<String> = None;
417
418 loop {
419 let (issues, has_next, next_cursor) = self
420 .fetch_issues(
421 team_key,
422 cursor.as_deref(),
423 updated_after.as_deref(),
424 include_archived,
425 )
426 .await?;
427
428 let count = issues.len();
429 for (mut issue, relations, label_ids) in issues {
430 issue.workspace_id = workspace_id.to_string();
431 if max_updated.is_none() || Some(&issue.updated_at) > max_updated.as_ref() {
432 max_updated = Some(issue.updated_at.clone());
433 }
434 db.upsert_issue(&issue)?;
435 db.upsert_relations(&issue.id, &relations)?;
436 db.replace_issue_labels(&issue.id, &label_ids)?;
437 }
438 total += count;
439
440 if let Some(cb) = progress {
441 cb(total);
442 }
443
444 if !has_next || count == 0 {
445 break;
446 }
447 cursor = next_cursor;
448 }
449
450 if let Some(max) = max_updated {
451 db.set_sync_cursor(workspace_id, team_key, &max)?;
452 }
453
454 Ok(total)
455 }
456
457 pub async fn create_issue(
458 &self,
459 team_id: &str,
460 title: &str,
461 description: Option<&str>,
462 priority: Option<i32>,
463 label_ids: &[String],
464 assignee_id: Option<&str>,
465 parent_id: Option<&str>,
466 ) -> Result<(String, String)> {
467 let mut input = serde_json::json!({
468 "teamId": team_id,
469 "title": title,
470 });
471
472 if let Some(desc) = description {
473 input["description"] = serde_json::Value::String(desc.to_string());
474 }
475 if let Some(p) = priority {
476 input["priority"] = serde_json::Value::Number(p.into());
477 }
478 if !label_ids.is_empty() {
479 input["labelIds"] = serde_json::json!(label_ids);
480 }
481 if let Some(aid) = assignee_id {
482 input["assigneeId"] = serde_json::Value::String(aid.to_string());
483 }
484 if let Some(pid) = parent_id {
485 input["parentId"] = serde_json::Value::String(pid.to_string());
486 }
487
488 let query = r#"
489 mutation($input: IssueCreateInput!) {
490 issueCreate(input: $input) {
491 success
492 issue { id identifier }
493 }
494 }
495 "#;
496
497 let data: CreateIssueData = self
498 .query(query, serde_json::json!({ "input": input }))
499 .await?;
500
501 if !data.issue_create.success {
502 anyhow::bail!("Failed to create issue");
503 }
504
505 let issue = data.issue_create.issue.context("No issue returned")?;
506 Ok((issue.id, issue.identifier))
507 }
508
509 pub async fn add_comment(&self, issue_id: &str, body: &str) -> Result<()> {
510 let query = r#"
511 mutation($input: CommentCreateInput!) {
512 commentCreate(input: $input) {
513 success
514 }
515 }
516 "#;
517
518 let input = serde_json::json!({
519 "issueId": issue_id,
520 "body": body,
521 });
522
523 let data: CreateCommentData = self
524 .query(query, serde_json::json!({ "input": input }))
525 .await?;
526
527 if !data.comment_create.success {
528 anyhow::bail!("Failed to create comment");
529 }
530
531 Ok(())
532 }
533
534 pub async fn update_issue(
535 &self,
536 issue_id: &str,
537 title: Option<&str>,
538 description: Option<&str>,
539 priority: Option<i32>,
540 state_id: Option<&str>,
541 label_ids: Option<&[String]>,
542 project_id: Option<&str>,
543 assignee_id: Option<&str>,
544 ) -> Result<()> {
545 let mut input = serde_json::Map::new();
546 if let Some(t) = title {
547 input.insert("title".into(), serde_json::Value::String(t.to_string()));
548 }
549 if let Some(d) = description {
550 input.insert(
551 "description".into(),
552 serde_json::Value::String(d.to_string()),
553 );
554 }
555 if let Some(p) = priority {
556 input.insert("priority".into(), serde_json::Value::Number(p.into()));
557 }
558 if let Some(sid) = state_id {
559 input.insert("stateId".into(), serde_json::Value::String(sid.to_string()));
560 }
561 if let Some(lids) = label_ids {
562 input.insert("labelIds".into(), serde_json::json!(lids));
563 }
564 if let Some(pid) = project_id {
565 let value = if pid.is_empty() {
566 serde_json::Value::Null
567 } else {
568 serde_json::Value::String(pid.to_string())
569 };
570 input.insert("projectId".into(), value);
571 }
572 if let Some(aid) = assignee_id {
573 let value = if aid.is_empty() {
574 serde_json::Value::Null
575 } else {
576 serde_json::Value::String(aid.to_string())
577 };
578 input.insert("assigneeId".into(), value);
579 }
580
581 let query = r#"
582 mutation($id: String!, $input: IssueUpdateInput!) {
583 issueUpdate(id: $id, input: $input) {
584 success
585 }
586 }
587 "#;
588
589 let data: UpdateIssueData = self
590 .query(query, serde_json::json!({ "id": issue_id, "input": input }))
591 .await?;
592
593 if !data.issue_update.success {
594 anyhow::bail!("Failed to update issue");
595 }
596
597 Ok(())
598 }
599
600 pub async fn fetch_single_issue(
601 &self,
602 issue_id: &str,
603 ) -> Result<(db::Issue, Vec<db::Relation>, Vec<String>)> {
604 let query = r#"
605 query($id: String!) {
606 issue(id: $id) {
607 id identifier url title description priority branchName
608 createdAt updatedAt
609 state { name type }
610 team { key }
611 assignee { name }
612 project { name }
613 labels { nodes { id name } }
614 relations { nodes { id type relatedIssue { id identifier } } }
615 }
616 }
617 "#;
618
619 let data: SingleIssueData = self
620 .query(query, serde_json::json!({ "id": issue_id }))
621 .await?;
622
623 Ok(Self::convert_linear_issue(data.issue))
624 }
625
626 pub async fn fetch_issue_by_identifier(
629 &self,
630 identifier: &str,
631 ) -> Result<Option<(db::Issue, Vec<db::Relation>, Vec<String>)>> {
632 let parts: Vec<&str> = identifier.rsplitn(2, '-').collect();
634 if parts.len() != 2 {
635 anyhow::bail!(
636 "Invalid issue identifier '{}': expected format like 'ENG-123'",
637 identifier
638 );
639 }
640 let number: i32 = parts[0]
641 .parse()
642 .with_context(|| format!("Invalid issue number in '{}'", identifier))?;
643 let team_key = parts[1];
644
645 let query = format!(
646 r#"query {{
647 issues(
648 filter: {{
649 team: {{ key: {{ eq: "{}" }} }},
650 number: {{ eq: {} }}
651 }},
652 first: 1
653 ) {{
654 nodes {{
655 id identifier url title description priority branchName
656 createdAt updatedAt
657 state {{ name type }}
658 team {{ key }}
659 assignee {{ name }}
660 project {{ name }}
661 labels {{ nodes {{ id name }} }}
662 relations {{ nodes {{ id type relatedIssue {{ id identifier }} }} }}
663 }}
664 pageInfo {{ hasNextPage endCursor }}
665 }}
666 }}"#,
667 team_key, number
668 );
669
670 let data: IssuesData = self.query(&query, serde_json::json!({})).await?;
671
672 Ok(data
673 .issues
674 .nodes
675 .into_iter()
676 .next()
677 .map(Self::convert_linear_issue))
678 }
679
680 fn convert_linear_issue(i: LinearIssue) -> (db::Issue, Vec<db::Relation>, Vec<String>) {
681 let labels: Vec<String> = i.labels.nodes.iter().map(|l| l.name.clone()).collect();
682 let label_ids: Vec<String> = i.labels.nodes.iter().map(|l| l.id.clone()).collect();
683 let labels_json = serde_json::to_string(&labels).unwrap_or_else(|_| "[]".to_string());
684
685 let mut hasher = Sha256::new();
686 hasher.update(&i.title);
687 hasher.update(i.description.as_deref().unwrap_or(""));
688 hasher.update(&labels_json);
689 let content_hash = hex::encode(hasher.finalize());
690
691 let relations = Self::extract_relations(&i.id, &i);
692
693 let issue = db::Issue {
694 id: i.id,
695 identifier: i.identifier,
696 url: i.url,
697 team_key: i.team.key,
698 title: i.title,
699 description: i.description,
700 state_name: i.state.name,
701 state_type: i.state.state_type,
702 priority: i.priority,
703 assignee_name: i.assignee.map(|a| a.name),
704 project_name: i.project.map(|p| p.name),
705 labels_json,
706 created_at: i.created_at,
707 updated_at: i.updated_at,
708 content_hash,
709 synced_at: None,
710 branch_name: i.branch_name,
711 workspace_id: "default".to_string(),
712 };
713
714 (issue, relations, label_ids)
715 }
716
717 pub async fn get_team_id(&self, team_key: &str) -> Result<String> {
719 let teams = self.list_teams().await?;
720 teams
721 .iter()
722 .find(|t| t.key.eq_ignore_ascii_case(team_key))
723 .map(|t| t.id.clone())
724 .with_context(|| format!("Team '{}' not found", team_key))
725 }
726
727 pub async fn get_state_id(&self, team_key: &str, state_name: &str) -> Result<String> {
730 let team_id = self.get_team_id(team_key).await?;
731 let query = r#"
732 query($teamId: String!) {
733 team(id: $teamId) {
734 states { nodes { id name type } }
735 }
736 }
737 "#;
738
739 let data: serde_json::Value = self
740 .query(query, serde_json::json!({ "teamId": team_id }))
741 .await?;
742
743 let states = data["team"]["states"]["nodes"]
744 .as_array()
745 .context("No states in response")?;
746
747 for state in states {
748 if let Some(name) = state["name"].as_str() {
749 if name.eq_ignore_ascii_case(state_name) {
750 return state["id"]
751 .as_str()
752 .map(|s| s.to_string())
753 .context("State has no id");
754 }
755 }
756 }
757
758 for state in states {
760 if let Some(t) = state["type"].as_str() {
761 if t.eq_ignore_ascii_case(state_name) {
762 return state["id"]
763 .as_str()
764 .map(|s| s.to_string())
765 .context("State has no id");
766 }
767 }
768 }
769
770 let available: Vec<&str> = states.iter().filter_map(|s| s["name"].as_str()).collect();
771 anyhow::bail!(
772 "State '{}' not found for team {}. Available: {}",
773 state_name,
774 team_key,
775 available.join(", ")
776 )
777 }
778
779 pub async fn get_label_ids(&self, label_names: &[String]) -> Result<Vec<String>> {
783 if label_names.is_empty() {
784 return Ok(Vec::new());
785 }
786
787 let query = r#"
788 query {
789 issueLabels(first: 250) {
790 nodes { id name }
791 }
792 }
793 "#;
794
795 let data: serde_json::Value = self.query(query, serde_json::json!({})).await?;
796
797 let labels = data["issueLabels"]["nodes"]
798 .as_array()
799 .context("No labels in response")?;
800
801 let mut ids = Vec::new();
802 for name in label_names {
803 let found = labels.iter().find(|l| {
804 l["name"]
805 .as_str()
806 .is_some_and(|n| n.eq_ignore_ascii_case(name))
807 });
808 match found {
809 Some(l) => {
810 ids.push(l["id"].as_str().context("Label has no id")?.to_string());
811 }
812 None => {
813 let available: Vec<&str> =
814 labels.iter().filter_map(|l| l["name"].as_str()).collect();
815 anyhow::bail!(
816 "Label '{}' not found. Available: {}",
817 name,
818 available.join(", ")
819 );
820 }
821 }
822 }
823
824 Ok(ids)
825 }
826
827 pub async fn resolve_assignee_id(&self, input: &str) -> Result<String> {
834 let trimmed = input.trim();
835 if trimmed.eq_ignore_ascii_case("none") {
836 return Ok(String::new());
837 }
838 if trimmed.eq_ignore_ascii_case("me") {
839 if let Some(cached) = self.viewer_id.read().unwrap().clone() {
840 return Ok(cached);
841 }
842 let data: serde_json::Value = self
843 .query("query { viewer { id } }", serde_json::json!({}))
844 .await?;
845 let id = data["viewer"]["id"]
846 .as_str()
847 .context("viewer query returned no id")?
848 .to_string();
849 *self.viewer_id.write().unwrap() = Some(id.clone());
850 return Ok(id);
851 }
852
853 let data: serde_json::Value = self
855 .query(
856 "query { users(first: 250) { nodes { id name } } }",
857 serde_json::json!({}),
858 )
859 .await?;
860 let nodes = data["users"]["nodes"]
861 .as_array()
862 .context("users query returned no nodes")?;
863 let matches: Vec<(String, String)> = nodes
864 .iter()
865 .filter_map(|n| {
866 let name = n["name"].as_str()?;
867 if name.eq_ignore_ascii_case(trimmed) {
868 Some((n["id"].as_str()?.to_string(), name.to_string()))
869 } else {
870 None
871 }
872 })
873 .collect();
874
875 match matches.len() {
876 0 => anyhow::bail!("Assignee '{}' not found in Linear users.", trimmed),
877 1 => Ok(matches.into_iter().next().unwrap().0),
878 _ => {
879 let names: Vec<&str> = matches.iter().map(|(_, n)| n.as_str()).collect();
880 anyhow::bail!(
881 "Assignee '{}' matched multiple users: {}. Use a more specific name.",
882 trimmed,
883 names.join(", ")
884 )
885 }
886 }
887 }
888
889 pub async fn fetch_labels(&self) -> Result<Vec<LabelCatalogEntry>> {
891 let mut out = Vec::new();
892 let mut cursor: Option<String> = None;
893 loop {
894 let after_param = match cursor {
895 Some(ref c) => format!(", after: \"{}\"", c),
896 None => String::new(),
897 };
898 let query = format!(
899 r#"query {{
900 issueLabels(first: 250{}) {{
901 nodes {{ id name color parent {{ id }} }}
902 pageInfo {{ hasNextPage endCursor }}
903 }}
904 }}"#,
905 after_param
906 );
907 let data: serde_json::Value = self.query(&query, serde_json::json!({})).await?;
908 let nodes = data["issueLabels"]["nodes"]
909 .as_array()
910 .context("No issueLabels.nodes in response")?;
911 for n in nodes {
912 let id = n["id"].as_str().context("label has no id")?.to_string();
913 let name = n["name"].as_str().unwrap_or("").to_string();
914 let color = n["color"].as_str().map(|s| s.to_string());
915 let parent_id = n["parent"]["id"].as_str().map(|s| s.to_string());
916 out.push(LabelCatalogEntry { id, name, color, parent_id });
917 }
918 let has_next = data["issueLabels"]["pageInfo"]["hasNextPage"].as_bool().unwrap_or(false);
919 if !has_next { break; }
920 cursor = data["issueLabels"]["pageInfo"]["endCursor"].as_str().map(|s| s.to_string());
921 if cursor.is_none() { break; }
922 }
923 Ok(out)
924 }
925
926 pub async fn sync_labels_catalog(&self, db: &Database, workspace_id: &str) -> Result<usize> {
929 let entries = self.fetch_labels().await?;
930 let keep_ids: Vec<String> = entries.iter().map(|e| e.id.clone()).collect();
931 for e in &entries {
932 db.upsert_label(&db::Label {
933 id: e.id.clone(),
934 workspace_id: workspace_id.to_string(),
935 name: e.name.clone(),
936 color: e.color.clone(),
937 parent_id: e.parent_id.clone(),
938 })?;
939 }
940 db.delete_labels_for_workspace_not_in(workspace_id, &keep_ids)?;
941 Ok(entries.len())
942 }
943
944 pub async fn get_project_id(&self, project_name: &str) -> Result<String> {
946 let query = r#"
947 query {
948 projects(first: 250) {
949 nodes { id name }
950 }
951 }
952 "#;
953
954 let data: serde_json::Value = self.query(query, serde_json::json!({})).await?;
955
956 let projects = data["projects"]["nodes"]
957 .as_array()
958 .context("No projects in response")?;
959
960 for project in projects {
961 if let Some(name) = project["name"].as_str() {
962 if name.eq_ignore_ascii_case(project_name) {
963 return project["id"]
964 .as_str()
965 .map(|s| s.to_string())
966 .context("Project has no id");
967 }
968 }
969 }
970
971 let available: Vec<&str> = projects.iter().filter_map(|p| p["name"].as_str()).collect();
972 anyhow::bail!(
973 "Project '{}' not found. Available: {}",
974 project_name,
975 available.join(", ")
976 )
977 }
978
979 pub async fn create_relation(
983 &self,
984 issue_id: &str,
985 related_issue_id: &str,
986 relation_type: &str,
987 ) -> Result<String> {
988 let (actual_issue_id, actual_related_id, api_type) = if relation_type == "blocked_by" {
989 (related_issue_id, issue_id, "blocks")
990 } else {
991 (issue_id, related_issue_id, relation_type)
992 };
993
994 let query = r#"
995 mutation($input: IssueRelationCreateInput!) {
996 issueRelationCreate(input: $input) {
997 success
998 issueRelation { id }
999 }
1000 }
1001 "#;
1002
1003 let input = serde_json::json!({
1004 "issueId": actual_issue_id,
1005 "relatedIssueId": actual_related_id,
1006 "type": api_type,
1007 });
1008
1009 let data: CreateRelationData = self
1010 .query(query, serde_json::json!({ "input": input }))
1011 .await?;
1012
1013 if !data.issue_relation_create.success {
1014 anyhow::bail!("Failed to create relation");
1015 }
1016
1017 let relation = data
1018 .issue_relation_create
1019 .issue_relation
1020 .context("No relation returned")?;
1021 Ok(relation.id)
1022 }
1023
1024 pub async fn delete_relation(&self, relation_id: &str) -> Result<()> {
1026 let query = r#"
1027 mutation($id: String!) {
1028 issueRelationDelete(id: $id) {
1029 success
1030 }
1031 }
1032 "#;
1033
1034 let data: DeleteRelationData = self
1035 .query(query, serde_json::json!({ "id": relation_id }))
1036 .await?;
1037
1038 if !data.issue_relation_delete.success {
1039 anyhow::bail!("Failed to delete relation");
1040 }
1041
1042 Ok(())
1043 }
1044}