1use crate::commands::linear::{linear_graphql_errors, linear_graphql_request};
4use serde::Deserialize;
5use serde_json::{json, Value as JsonValue};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct LinearTeam {
9 pub id: String,
10 pub key: String,
11 pub name: String,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct LinearWorkflowState {
16 pub id: String,
17 pub name: String,
18 pub state_type: String,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct LinearLabel {
23 pub id: String,
24 pub name: String,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct LinearUser {
29 pub id: String,
30 pub name: String,
31 pub email: Option<String>,
32 pub display_name: Option<String>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct LinearProject {
37 pub id: String,
38 pub name: String,
39 pub state: Option<String>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct LinearCycle {
44 pub id: String,
45 pub name: Option<String>,
46 pub number: Option<i32>,
47 pub starts_at: Option<String>,
48 pub ends_at: Option<String>,
49 pub completed_at: Option<String>,
50}
51
52impl LinearCycle {
53 pub fn display_label(&self) -> String {
54 let name = self
55 .name
56 .as_deref()
57 .map(str::trim)
58 .filter(|s| !s.is_empty());
59 let number = self.number.map(|n| format!("Cycle {n}"));
60 let base = match (name, number) {
61 (Some(n), Some(num)) => format!("{num} — {n}"),
62 (Some(n), None) => n.to_string(),
63 (None, Some(num)) => num,
64 (None, None) => self.id.clone(),
65 };
66 if self.completed_at.is_some() {
67 format!("{base} (completed)")
68 } else {
69 base
70 }
71 }
72}
73
74#[derive(Debug, Deserialize)]
75struct TeamsPage {
76 nodes: Vec<TeamNode>,
77 #[serde(rename = "pageInfo")]
78 page_info: PageInfo,
79}
80
81#[derive(Debug, Deserialize)]
82struct TeamNode {
83 id: String,
84 key: String,
85 name: String,
86}
87
88#[derive(Debug, Deserialize)]
89struct PageInfo {
90 #[serde(rename = "hasNextPage")]
91 has_next_page: bool,
92 #[serde(rename = "endCursor")]
93 end_cursor: Option<String>,
94}
95
96#[derive(Debug, Deserialize)]
97struct StatesPage {
98 nodes: Vec<StateNode>,
99}
100
101#[derive(Debug, Deserialize)]
102struct StateNode {
103 id: String,
104 name: String,
105 #[serde(rename = "type")]
106 state_type: String,
107}
108
109#[derive(Debug, Deserialize)]
110struct LabelsPage {
111 nodes: Vec<LabelNode>,
112 #[serde(rename = "pageInfo")]
113 page_info: PageInfo,
114}
115
116#[derive(Debug, Deserialize)]
117struct LabelNode {
118 id: String,
119 name: String,
120}
121
122#[derive(Debug, Deserialize)]
123struct UsersPage {
124 nodes: Vec<UserNode>,
125 #[serde(rename = "pageInfo")]
126 page_info: PageInfo,
127}
128
129#[derive(Debug, Deserialize)]
130struct UserNode {
131 id: String,
132 name: String,
133 #[serde(default)]
134 email: Option<String>,
135 #[serde(default, rename = "displayName")]
136 display_name: Option<String>,
137}
138
139pub async fn list_teams(api_key: &str) -> Result<Vec<LinearTeam>, String> {
140 let mut teams = Vec::new();
141 let mut after: Option<String> = None;
142 loop {
143 let body = linear_graphql_request(
144 api_key,
145 &json!({
146 "query": r#"
147 query XbpLinearTeams($after: String) {
148 teams(first: 50, after: $after, includeArchived: false) {
149 nodes { id key name }
150 pageInfo { hasNextPage endCursor }
151 }
152 }
153 "#,
154 "variables": { "after": after }
155 }),
156 )
157 .await?;
158 let page: TeamsPage = extract_data(body, "teams")?;
159 teams.extend(page.nodes.into_iter().map(|n| LinearTeam {
160 id: n.id,
161 key: n.key,
162 name: n.name,
163 }));
164 if !page.page_info.has_next_page {
165 break;
166 }
167 after = page.page_info.end_cursor;
168 if after.is_none() {
169 break;
170 }
171 }
172 Ok(teams)
173}
174
175pub async fn resolve_team_id(api_key: &str, team: &str) -> Result<String, String> {
176 let team = team.trim();
177 if team.is_empty() {
178 return Err("Team is required.".into());
179 }
180 if team.len() >= 32 && team.contains('-') {
182 return Ok(team.to_string());
183 }
184 let teams = list_teams(api_key).await?;
185 let lower = team.to_ascii_lowercase();
186 if let Some(found) = teams.iter().find(|t| {
187 t.id == team
188 || t.key.eq_ignore_ascii_case(team)
189 || t.name.eq_ignore_ascii_case(team)
190 || t.key.to_ascii_lowercase() == lower
191 || t.name.to_ascii_lowercase() == lower
192 }) {
193 return Ok(found.id.clone());
194 }
195 Err(format!(
196 "No Linear team matching `{team}`. Use a team key, name, or id."
197 ))
198}
199
200pub async fn list_workflow_states(
201 api_key: &str,
202 team_id: &str,
203) -> Result<Vec<LinearWorkflowState>, String> {
204 let body = linear_graphql_request(
205 api_key,
206 &json!({
207 "query": r#"
208 query XbpLinearStates($teamId: String!) {
209 team(id: $teamId) {
210 states {
211 nodes { id name type }
212 }
213 }
214 }
215 "#,
216 "variables": { "teamId": team_id }
217 }),
218 )
219 .await?;
220 linear_graphql_errors(&body)?;
221 let states = body
222 .get("data")
223 .and_then(|d| d.get("team"))
224 .and_then(|t| t.get("states"))
225 .cloned()
226 .ok_or_else(|| "Linear team states query returned no data.".to_string())?;
227 let page: StatesPage = serde_json::from_value(states)
228 .map_err(|e| format!("Failed to decode Linear states: {e}"))?;
229 Ok(page
230 .nodes
231 .into_iter()
232 .map(|n| LinearWorkflowState {
233 id: n.id,
234 name: n.name,
235 state_type: n.state_type,
236 })
237 .collect())
238}
239
240pub async fn resolve_state_id(
241 api_key: &str,
242 team_id: &str,
243 state: &str,
244) -> Result<String, String> {
245 let state = state.trim();
246 if state.is_empty() {
247 return Err("State is required.".into());
248 }
249 if state.len() >= 32 && state.contains('-') {
250 return Ok(state.to_string());
251 }
252 let states = list_workflow_states(api_key, team_id).await?;
253 let lower = state.to_ascii_lowercase();
254 if let Some(found) = states.iter().find(|s| {
255 s.id == state
256 || s.name.eq_ignore_ascii_case(state)
257 || s.state_type.eq_ignore_ascii_case(state)
258 || s.name.to_ascii_lowercase() == lower
259 }) {
260 return Ok(found.id.clone());
261 }
262 Err(format!(
263 "No workflow state matching `{state}` on this team."
264 ))
265}
266
267pub async fn list_labels(api_key: &str, team_id: Option<&str>) -> Result<Vec<LinearLabel>, String> {
268 let mut labels = Vec::new();
269 let mut after: Option<String> = None;
270 loop {
271 let body = if let Some(team_id) = team_id {
272 linear_graphql_request(
273 api_key,
274 &json!({
275 "query": r#"
276 query XbpLinearTeamLabels($teamId: String!, $after: String) {
277 team(id: $teamId) {
278 labels(first: 50, after: $after) {
279 nodes { id name }
280 pageInfo { hasNextPage endCursor }
281 }
282 }
283 }
284 "#,
285 "variables": { "teamId": team_id, "after": after }
286 }),
287 )
288 .await?
289 } else {
290 linear_graphql_request(
291 api_key,
292 &json!({
293 "query": r#"
294 query XbpLinearLabels($after: String) {
295 issueLabels(first: 50, after: $after) {
296 nodes { id name }
297 pageInfo { hasNextPage endCursor }
298 }
299 }
300 "#,
301 "variables": { "after": after }
302 }),
303 )
304 .await?
305 };
306
307 linear_graphql_errors(&body)?;
308 let page_value = if team_id.is_some() {
309 body.get("data")
310 .and_then(|d| d.get("team"))
311 .and_then(|t| t.get("labels"))
312 .cloned()
313 } else {
314 body.get("data")
315 .and_then(|d| d.get("issueLabels"))
316 .cloned()
317 }
318 .ok_or_else(|| "Linear labels query returned no data.".to_string())?;
319
320 let page: LabelsPage = serde_json::from_value(page_value)
321 .map_err(|e| format!("Failed to decode Linear labels: {e}"))?;
322 labels.extend(page.nodes.into_iter().map(|n| LinearLabel {
323 id: n.id,
324 name: n.name,
325 }));
326 if !page.page_info.has_next_page {
327 break;
328 }
329 after = page.page_info.end_cursor;
330 if after.is_none() {
331 break;
332 }
333 }
334 Ok(labels)
335}
336
337pub async fn resolve_label_ids(
338 api_key: &str,
339 team_id: Option<&str>,
340 labels: &[String],
341) -> Result<Vec<String>, String> {
342 if labels.is_empty() {
343 return Ok(Vec::new());
344 }
345 let known = list_labels(api_key, team_id).await?;
346 let mut ids = Vec::new();
347 for label in labels {
348 let label = label.trim();
349 if label.is_empty() {
350 continue;
351 }
352 if label.len() >= 32 && label.contains('-') {
353 ids.push(label.to_string());
354 continue;
355 }
356 if let Some(found) = known
357 .iter()
358 .find(|l| l.id == label || l.name.eq_ignore_ascii_case(label))
359 {
360 ids.push(found.id.clone());
361 } else {
362 return Err(format!("No Linear label matching `{label}`."));
363 }
364 }
365 Ok(ids)
366}
367
368pub async fn ensure_label_ids(
370 api_key: &str,
371 team_id: Option<&str>,
372 labels: &[String],
373) -> Result<Vec<String>, String> {
374 if labels.is_empty() {
375 return Ok(Vec::new());
376 }
377 let mut known = list_labels(api_key, team_id).await?;
378 let mut ids = Vec::new();
379 for label in labels {
380 let label = label.trim();
381 if label.is_empty() {
382 continue;
383 }
384 if label.len() >= 32 && label.contains('-') {
385 ids.push(label.to_string());
386 continue;
387 }
388 if let Some(found) = known
389 .iter()
390 .find(|l| l.id == label || l.name.eq_ignore_ascii_case(label))
391 {
392 ids.push(found.id.clone());
393 continue;
394 }
395 if let Some(team_id) = team_id {
397 match create_issue_label(api_key, team_id, label).await {
398 Ok(created) => {
399 known.push(created.clone());
400 ids.push(created.id);
401 }
402 Err(err) => {
403 eprintln!(
405 "warning: could not create Linear label `{label}`: {err}"
406 );
407 }
408 }
409 } else {
410 eprintln!("warning: Linear label `{label}` not found and no team to create it on");
411 }
412 }
413 Ok(ids)
414}
415
416async fn create_issue_label(
417 api_key: &str,
418 team_id: &str,
419 name: &str,
420) -> Result<LinearLabel, String> {
421 let body = linear_graphql_request(
422 api_key,
423 &json!({
424 "query": r#"
425 mutation XbpLinearLabelCreate($input: IssueLabelCreateInput!) {
426 issueLabelCreate(input: $input) {
427 success
428 issueLabel { id name }
429 }
430 }
431 "#,
432 "variables": {
433 "input": {
434 "name": name,
435 "teamId": team_id
436 }
437 }
438 }),
439 )
440 .await?;
441 linear_graphql_errors(&body)?;
442 let label = body
443 .get("data")
444 .and_then(|d| d.get("issueLabelCreate"))
445 .and_then(|c| c.get("issueLabel"))
446 .cloned()
447 .ok_or_else(|| "Linear issueLabelCreate returned no label.".to_string())?;
448 let id = label
449 .get("id")
450 .and_then(JsonValue::as_str)
451 .ok_or_else(|| "Linear label missing id.".to_string())?
452 .to_string();
453 let name = label
454 .get("name")
455 .and_then(JsonValue::as_str)
456 .unwrap_or(name)
457 .to_string();
458 Ok(LinearLabel { id, name })
459}
460
461pub async fn resolve_team_id_auto(
463 api_key: &str,
464 preferred: Option<&str>,
465) -> Result<String, String> {
466 if let Some(preferred) = preferred.map(str::trim).filter(|s| !s.is_empty()) {
467 return resolve_team_id(api_key, preferred).await;
468 }
469 let teams = list_teams(api_key).await?;
470 if teams.len() == 1 {
471 return Ok(teams[0].id.clone());
472 }
473 if teams.is_empty() {
474 return Err("No Linear teams available for this API key.".into());
475 }
476 Err(
477 "Multiple Linear teams found. Pass `--team KEY` or set `linear.default_team_key` in `.xbp/xbp.yaml`."
478 .into(),
479 )
480}
481
482pub async fn list_users(api_key: &str) -> Result<Vec<LinearUser>, String> {
483 let mut users = Vec::new();
484 let mut after: Option<String> = None;
485 loop {
486 let body = linear_graphql_request(
487 api_key,
488 &json!({
489 "query": r#"
490 query XbpLinearUsers($after: String) {
491 users(first: 50, after: $after, includeDisabled: false) {
492 nodes { id name email displayName }
493 pageInfo { hasNextPage endCursor }
494 }
495 }
496 "#,
497 "variables": { "after": after }
498 }),
499 )
500 .await?;
501 let page: UsersPage = extract_data(body, "users")?;
502 users.extend(page.nodes.into_iter().map(|n| LinearUser {
503 id: n.id,
504 name: n.name,
505 email: n.email,
506 display_name: n.display_name,
507 }));
508 if !page.page_info.has_next_page {
509 break;
510 }
511 after = page.page_info.end_cursor;
512 if after.is_none() {
513 break;
514 }
515 }
516 Ok(users)
517}
518
519pub async fn resolve_assignee_id(api_key: &str, assignee: &str) -> Result<Option<String>, String> {
520 let assignee = assignee.trim();
521 if assignee.is_empty() || assignee.eq_ignore_ascii_case("none") {
522 return Ok(None);
523 }
524 if assignee.eq_ignore_ascii_case("me") {
525 return Ok(Some(viewer_id(api_key).await?));
526 }
527 if assignee.len() >= 32 && assignee.contains('-') {
528 return Ok(Some(assignee.to_string()));
529 }
530 let users = list_users(api_key).await?;
531 let lower = assignee.to_ascii_lowercase();
532 if let Some(found) = users.iter().find(|u| {
533 u.id == assignee
534 || u.name.eq_ignore_ascii_case(assignee)
535 || u.email
536 .as_deref()
537 .is_some_and(|e| e.eq_ignore_ascii_case(assignee))
538 || u.display_name
539 .as_deref()
540 .is_some_and(|d| d.eq_ignore_ascii_case(assignee))
541 || u.name.to_ascii_lowercase() == lower
542 }) {
543 return Ok(Some(found.id.clone()));
544 }
545 Err(format!("No Linear user matching `{assignee}`."))
546}
547
548async fn viewer_id(api_key: &str) -> Result<String, String> {
549 let body = linear_graphql_request(
550 api_key,
551 &json!({
552 "query": r#"query XbpLinearViewer { viewer { id } }"#
553 }),
554 )
555 .await?;
556 linear_graphql_errors(&body)?;
557 body.get("data")
558 .and_then(|d| d.get("viewer"))
559 .and_then(|v| v.get("id"))
560 .and_then(JsonValue::as_str)
561 .map(str::to_string)
562 .ok_or_else(|| "Linear viewer query returned no id.".to_string())
563}
564
565#[derive(Debug, Deserialize)]
566struct ProjectsPage {
567 nodes: Vec<ProjectNode>,
568 #[serde(rename = "pageInfo")]
569 page_info: PageInfo,
570}
571
572#[derive(Debug, Deserialize)]
573struct ProjectNode {
574 id: String,
575 name: String,
576 #[serde(default)]
577 state: Option<String>,
578}
579
580#[derive(Debug, Deserialize)]
581struct CyclesPage {
582 nodes: Vec<CycleNode>,
583 #[serde(rename = "pageInfo")]
584 page_info: PageInfo,
585}
586
587#[derive(Debug, Deserialize)]
588struct CycleNode {
589 id: String,
590 #[serde(default)]
591 name: Option<String>,
592 #[serde(default)]
593 number: Option<i32>,
594 #[serde(default, rename = "startsAt")]
595 starts_at: Option<String>,
596 #[serde(default, rename = "endsAt")]
597 ends_at: Option<String>,
598 #[serde(default, rename = "completedAt")]
599 completed_at: Option<String>,
600}
601
602pub async fn list_projects(api_key: &str) -> Result<Vec<LinearProject>, String> {
603 let mut projects = Vec::new();
604 let mut after: Option<String> = None;
605 loop {
606 let body = linear_graphql_request(
607 api_key,
608 &json!({
609 "query": r#"
610 query XbpLinearProjects($after: String) {
611 projects(first: 50, after: $after, includeArchived: false) {
612 nodes { id name state }
613 pageInfo { hasNextPage endCursor }
614 }
615 }
616 "#,
617 "variables": { "after": after }
618 }),
619 )
620 .await?;
621 let page: ProjectsPage = extract_data(body, "projects")?;
622 projects.extend(page.nodes.into_iter().map(|n| LinearProject {
623 id: n.id,
624 name: n.name,
625 state: n.state,
626 }));
627 if !page.page_info.has_next_page {
628 break;
629 }
630 after = page.page_info.end_cursor;
631 if after.is_none() {
632 break;
633 }
634 }
635 Ok(projects)
636}
637
638pub async fn resolve_project_id(
639 api_key: &str,
640 project: &str,
641) -> Result<Option<String>, String> {
642 let project = project.trim();
643 if project.is_empty() || project.eq_ignore_ascii_case("none") {
644 return Ok(None);
645 }
646 if project.len() >= 32 && project.contains('-') {
647 return Ok(Some(project.to_string()));
648 }
649 let projects = list_projects(api_key).await?;
650 let lower = project.to_ascii_lowercase();
651 if let Some(found) = projects.iter().find(|p| {
652 p.id == project
653 || p.name.eq_ignore_ascii_case(project)
654 || p.name.to_ascii_lowercase() == lower
655 }) {
656 return Ok(Some(found.id.clone()));
657 }
658 Err(format!("No Linear project matching `{project}`."))
659}
660
661pub async fn list_cycles(api_key: &str, team_id: &str) -> Result<Vec<LinearCycle>, String> {
663 let mut cycles = Vec::new();
664 let mut after: Option<String> = None;
665 loop {
666 let body = linear_graphql_request(
667 api_key,
668 &json!({
669 "query": r#"
670 query XbpLinearCycles($teamId: String!, $after: String) {
671 team(id: $teamId) {
672 cycles(first: 50, after: $after) {
673 nodes { id name number startsAt endsAt completedAt }
674 pageInfo { hasNextPage endCursor }
675 }
676 }
677 }
678 "#,
679 "variables": { "teamId": team_id, "after": after }
680 }),
681 )
682 .await?;
683 linear_graphql_errors(&body)?;
684 let page_value = body
685 .get("data")
686 .and_then(|d| d.get("team"))
687 .and_then(|t| t.get("cycles"))
688 .cloned()
689 .ok_or_else(|| "Linear team cycles query returned no data.".to_string())?;
690 let page: CyclesPage = serde_json::from_value(page_value)
691 .map_err(|e| format!("Failed to decode Linear cycles: {e}"))?;
692 cycles.extend(page.nodes.into_iter().map(|n| LinearCycle {
693 id: n.id,
694 name: n.name,
695 number: n.number,
696 starts_at: n.starts_at,
697 ends_at: n.ends_at,
698 completed_at: n.completed_at,
699 }));
700 if !page.page_info.has_next_page {
701 break;
702 }
703 after = page.page_info.end_cursor;
704 if after.is_none() {
705 break;
706 }
707 }
708 Ok(cycles)
709}
710
711pub async fn resolve_cycle_id(
712 api_key: &str,
713 team_id: &str,
714 cycle: &str,
715) -> Result<Option<String>, String> {
716 let cycle = cycle.trim();
717 if cycle.is_empty() || cycle.eq_ignore_ascii_case("none") {
718 return Ok(None);
719 }
720 if cycle.len() >= 32 && cycle.contains('-') {
721 return Ok(Some(cycle.to_string()));
722 }
723 let cycles = list_cycles(api_key, team_id).await?;
724 let lower = cycle.to_ascii_lowercase();
725 if let Some(found) = cycles.iter().find(|c| {
726 c.id == cycle
727 || c.name
728 .as_deref()
729 .is_some_and(|n| n.eq_ignore_ascii_case(cycle) || n.to_ascii_lowercase() == lower)
730 || c.number
731 .map(|n| n.to_string() == cycle || format!("cycle {n}") == lower)
732 .unwrap_or(false)
733 }) {
734 return Ok(Some(found.id.clone()));
735 }
736 Err(format!("No Linear cycle matching `{cycle}` on this team."))
737}
738
739fn extract_data<T: for<'de> Deserialize<'de>>(body: JsonValue, field: &str) -> Result<T, String> {
740 linear_graphql_errors(&body)?;
741 let value = body
742 .get("data")
743 .and_then(|d| d.get(field))
744 .cloned()
745 .ok_or_else(|| format!("Linear query returned no `{field}` data."))?;
746 serde_json::from_value(value).map_err(|e| format!("Failed to decode Linear `{field}`: {e}"))
747}
748
749