xbp_cli/commands/linear_cmd/
meta.rs1use 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, Deserialize)]
36struct TeamsPage {
37 nodes: Vec<TeamNode>,
38 #[serde(rename = "pageInfo")]
39 page_info: PageInfo,
40}
41
42#[derive(Debug, Deserialize)]
43struct TeamNode {
44 id: String,
45 key: String,
46 name: String,
47}
48
49#[derive(Debug, Deserialize)]
50struct PageInfo {
51 #[serde(rename = "hasNextPage")]
52 has_next_page: bool,
53 #[serde(rename = "endCursor")]
54 end_cursor: Option<String>,
55}
56
57#[derive(Debug, Deserialize)]
58struct StatesPage {
59 nodes: Vec<StateNode>,
60}
61
62#[derive(Debug, Deserialize)]
63struct StateNode {
64 id: String,
65 name: String,
66 #[serde(rename = "type")]
67 state_type: String,
68}
69
70#[derive(Debug, Deserialize)]
71struct LabelsPage {
72 nodes: Vec<LabelNode>,
73 #[serde(rename = "pageInfo")]
74 page_info: PageInfo,
75}
76
77#[derive(Debug, Deserialize)]
78struct LabelNode {
79 id: String,
80 name: String,
81}
82
83#[derive(Debug, Deserialize)]
84struct UsersPage {
85 nodes: Vec<UserNode>,
86 #[serde(rename = "pageInfo")]
87 page_info: PageInfo,
88}
89
90#[derive(Debug, Deserialize)]
91struct UserNode {
92 id: String,
93 name: String,
94 #[serde(default)]
95 email: Option<String>,
96 #[serde(default, rename = "displayName")]
97 display_name: Option<String>,
98}
99
100pub async fn list_teams(api_key: &str) -> Result<Vec<LinearTeam>, String> {
101 let mut teams = Vec::new();
102 let mut after: Option<String> = None;
103 loop {
104 let body = linear_graphql_request(
105 api_key,
106 &json!({
107 "query": r#"
108 query XbpLinearTeams($after: String) {
109 teams(first: 50, after: $after, includeArchived: false) {
110 nodes { id key name }
111 pageInfo { hasNextPage endCursor }
112 }
113 }
114 "#,
115 "variables": { "after": after }
116 }),
117 )
118 .await?;
119 let page: TeamsPage = extract_data(body, "teams")?;
120 teams.extend(page.nodes.into_iter().map(|n| LinearTeam {
121 id: n.id,
122 key: n.key,
123 name: n.name,
124 }));
125 if !page.page_info.has_next_page {
126 break;
127 }
128 after = page.page_info.end_cursor;
129 if after.is_none() {
130 break;
131 }
132 }
133 Ok(teams)
134}
135
136pub async fn resolve_team_id(api_key: &str, team: &str) -> Result<String, String> {
137 let team = team.trim();
138 if team.is_empty() {
139 return Err("Team is required.".into());
140 }
141 if team.len() >= 32 && team.contains('-') {
143 return Ok(team.to_string());
144 }
145 let teams = list_teams(api_key).await?;
146 let lower = team.to_ascii_lowercase();
147 if let Some(found) = teams.iter().find(|t| {
148 t.id == team
149 || t.key.eq_ignore_ascii_case(team)
150 || t.name.eq_ignore_ascii_case(team)
151 || t.key.to_ascii_lowercase() == lower
152 || t.name.to_ascii_lowercase() == lower
153 }) {
154 return Ok(found.id.clone());
155 }
156 Err(format!(
157 "No Linear team matching `{team}`. Use a team key, name, or id."
158 ))
159}
160
161pub async fn list_workflow_states(
162 api_key: &str,
163 team_id: &str,
164) -> Result<Vec<LinearWorkflowState>, String> {
165 let body = linear_graphql_request(
166 api_key,
167 &json!({
168 "query": r#"
169 query XbpLinearStates($teamId: String!) {
170 team(id: $teamId) {
171 states {
172 nodes { id name type }
173 }
174 }
175 }
176 "#,
177 "variables": { "teamId": team_id }
178 }),
179 )
180 .await?;
181 linear_graphql_errors(&body)?;
182 let states = body
183 .get("data")
184 .and_then(|d| d.get("team"))
185 .and_then(|t| t.get("states"))
186 .cloned()
187 .ok_or_else(|| "Linear team states query returned no data.".to_string())?;
188 let page: StatesPage = serde_json::from_value(states)
189 .map_err(|e| format!("Failed to decode Linear states: {e}"))?;
190 Ok(page
191 .nodes
192 .into_iter()
193 .map(|n| LinearWorkflowState {
194 id: n.id,
195 name: n.name,
196 state_type: n.state_type,
197 })
198 .collect())
199}
200
201pub async fn resolve_state_id(
202 api_key: &str,
203 team_id: &str,
204 state: &str,
205) -> Result<String, String> {
206 let state = state.trim();
207 if state.is_empty() {
208 return Err("State is required.".into());
209 }
210 if state.len() >= 32 && state.contains('-') {
211 return Ok(state.to_string());
212 }
213 let states = list_workflow_states(api_key, team_id).await?;
214 let lower = state.to_ascii_lowercase();
215 if let Some(found) = states.iter().find(|s| {
216 s.id == state
217 || s.name.eq_ignore_ascii_case(state)
218 || s.state_type.eq_ignore_ascii_case(state)
219 || s.name.to_ascii_lowercase() == lower
220 }) {
221 return Ok(found.id.clone());
222 }
223 Err(format!(
224 "No workflow state matching `{state}` on this team."
225 ))
226}
227
228pub async fn list_labels(api_key: &str, team_id: Option<&str>) -> Result<Vec<LinearLabel>, String> {
229 let mut labels = Vec::new();
230 let mut after: Option<String> = None;
231 loop {
232 let body = if let Some(team_id) = team_id {
233 linear_graphql_request(
234 api_key,
235 &json!({
236 "query": r#"
237 query XbpLinearTeamLabels($teamId: String!, $after: String) {
238 team(id: $teamId) {
239 labels(first: 50, after: $after) {
240 nodes { id name }
241 pageInfo { hasNextPage endCursor }
242 }
243 }
244 }
245 "#,
246 "variables": { "teamId": team_id, "after": after }
247 }),
248 )
249 .await?
250 } else {
251 linear_graphql_request(
252 api_key,
253 &json!({
254 "query": r#"
255 query XbpLinearLabels($after: String) {
256 issueLabels(first: 50, after: $after) {
257 nodes { id name }
258 pageInfo { hasNextPage endCursor }
259 }
260 }
261 "#,
262 "variables": { "after": after }
263 }),
264 )
265 .await?
266 };
267
268 linear_graphql_errors(&body)?;
269 let page_value = if team_id.is_some() {
270 body.get("data")
271 .and_then(|d| d.get("team"))
272 .and_then(|t| t.get("labels"))
273 .cloned()
274 } else {
275 body.get("data")
276 .and_then(|d| d.get("issueLabels"))
277 .cloned()
278 }
279 .ok_or_else(|| "Linear labels query returned no data.".to_string())?;
280
281 let page: LabelsPage = serde_json::from_value(page_value)
282 .map_err(|e| format!("Failed to decode Linear labels: {e}"))?;
283 labels.extend(page.nodes.into_iter().map(|n| LinearLabel {
284 id: n.id,
285 name: n.name,
286 }));
287 if !page.page_info.has_next_page {
288 break;
289 }
290 after = page.page_info.end_cursor;
291 if after.is_none() {
292 break;
293 }
294 }
295 Ok(labels)
296}
297
298pub async fn resolve_label_ids(
299 api_key: &str,
300 team_id: Option<&str>,
301 labels: &[String],
302) -> Result<Vec<String>, String> {
303 if labels.is_empty() {
304 return Ok(Vec::new());
305 }
306 let known = list_labels(api_key, team_id).await?;
307 let mut ids = Vec::new();
308 for label in labels {
309 let label = label.trim();
310 if label.is_empty() {
311 continue;
312 }
313 if label.len() >= 32 && label.contains('-') {
314 ids.push(label.to_string());
315 continue;
316 }
317 if let Some(found) = known
318 .iter()
319 .find(|l| l.id == label || l.name.eq_ignore_ascii_case(label))
320 {
321 ids.push(found.id.clone());
322 } else {
323 return Err(format!("No Linear label matching `{label}`."));
324 }
325 }
326 Ok(ids)
327}
328
329pub async fn ensure_label_ids(
331 api_key: &str,
332 team_id: Option<&str>,
333 labels: &[String],
334) -> Result<Vec<String>, String> {
335 if labels.is_empty() {
336 return Ok(Vec::new());
337 }
338 let mut known = list_labels(api_key, team_id).await?;
339 let mut ids = Vec::new();
340 for label in labels {
341 let label = label.trim();
342 if label.is_empty() {
343 continue;
344 }
345 if label.len() >= 32 && label.contains('-') {
346 ids.push(label.to_string());
347 continue;
348 }
349 if let Some(found) = known
350 .iter()
351 .find(|l| l.id == label || l.name.eq_ignore_ascii_case(label))
352 {
353 ids.push(found.id.clone());
354 continue;
355 }
356 if let Some(team_id) = team_id {
358 match create_issue_label(api_key, team_id, label).await {
359 Ok(created) => {
360 known.push(created.clone());
361 ids.push(created.id);
362 }
363 Err(err) => {
364 eprintln!(
366 "warning: could not create Linear label `{label}`: {err}"
367 );
368 }
369 }
370 } else {
371 eprintln!("warning: Linear label `{label}` not found and no team to create it on");
372 }
373 }
374 Ok(ids)
375}
376
377async fn create_issue_label(
378 api_key: &str,
379 team_id: &str,
380 name: &str,
381) -> Result<LinearLabel, String> {
382 let body = linear_graphql_request(
383 api_key,
384 &json!({
385 "query": r#"
386 mutation XbpLinearLabelCreate($input: IssueLabelCreateInput!) {
387 issueLabelCreate(input: $input) {
388 success
389 issueLabel { id name }
390 }
391 }
392 "#,
393 "variables": {
394 "input": {
395 "name": name,
396 "teamId": team_id
397 }
398 }
399 }),
400 )
401 .await?;
402 linear_graphql_errors(&body)?;
403 let label = body
404 .get("data")
405 .and_then(|d| d.get("issueLabelCreate"))
406 .and_then(|c| c.get("issueLabel"))
407 .cloned()
408 .ok_or_else(|| "Linear issueLabelCreate returned no label.".to_string())?;
409 let id = label
410 .get("id")
411 .and_then(JsonValue::as_str)
412 .ok_or_else(|| "Linear label missing id.".to_string())?
413 .to_string();
414 let name = label
415 .get("name")
416 .and_then(JsonValue::as_str)
417 .unwrap_or(name)
418 .to_string();
419 Ok(LinearLabel { id, name })
420}
421
422pub async fn resolve_team_id_auto(
424 api_key: &str,
425 preferred: Option<&str>,
426) -> Result<String, String> {
427 if let Some(preferred) = preferred.map(str::trim).filter(|s| !s.is_empty()) {
428 return resolve_team_id(api_key, preferred).await;
429 }
430 let teams = list_teams(api_key).await?;
431 if teams.len() == 1 {
432 return Ok(teams[0].id.clone());
433 }
434 if teams.is_empty() {
435 return Err("No Linear teams available for this API key.".into());
436 }
437 Err(
438 "Multiple Linear teams found. Pass `--team KEY` or set `linear.default_team_key` in `.xbp/xbp.yaml`."
439 .into(),
440 )
441}
442
443pub async fn list_users(api_key: &str) -> Result<Vec<LinearUser>, String> {
444 let mut users = Vec::new();
445 let mut after: Option<String> = None;
446 loop {
447 let body = linear_graphql_request(
448 api_key,
449 &json!({
450 "query": r#"
451 query XbpLinearUsers($after: String) {
452 users(first: 50, after: $after, includeDisabled: false) {
453 nodes { id name email displayName }
454 pageInfo { hasNextPage endCursor }
455 }
456 }
457 "#,
458 "variables": { "after": after }
459 }),
460 )
461 .await?;
462 let page: UsersPage = extract_data(body, "users")?;
463 users.extend(page.nodes.into_iter().map(|n| LinearUser {
464 id: n.id,
465 name: n.name,
466 email: n.email,
467 display_name: n.display_name,
468 }));
469 if !page.page_info.has_next_page {
470 break;
471 }
472 after = page.page_info.end_cursor;
473 if after.is_none() {
474 break;
475 }
476 }
477 Ok(users)
478}
479
480pub async fn resolve_assignee_id(api_key: &str, assignee: &str) -> Result<Option<String>, String> {
481 let assignee = assignee.trim();
482 if assignee.is_empty() || assignee.eq_ignore_ascii_case("none") {
483 return Ok(None);
484 }
485 if assignee.eq_ignore_ascii_case("me") {
486 return Ok(Some(viewer_id(api_key).await?));
487 }
488 if assignee.len() >= 32 && assignee.contains('-') {
489 return Ok(Some(assignee.to_string()));
490 }
491 let users = list_users(api_key).await?;
492 let lower = assignee.to_ascii_lowercase();
493 if let Some(found) = users.iter().find(|u| {
494 u.id == assignee
495 || u.name.eq_ignore_ascii_case(assignee)
496 || u.email
497 .as_deref()
498 .is_some_and(|e| e.eq_ignore_ascii_case(assignee))
499 || u.display_name
500 .as_deref()
501 .is_some_and(|d| d.eq_ignore_ascii_case(assignee))
502 || u.name.to_ascii_lowercase() == lower
503 }) {
504 return Ok(Some(found.id.clone()));
505 }
506 Err(format!("No Linear user matching `{assignee}`."))
507}
508
509async fn viewer_id(api_key: &str) -> Result<String, String> {
510 let body = linear_graphql_request(
511 api_key,
512 &json!({
513 "query": r#"query XbpLinearViewer { viewer { id } }"#
514 }),
515 )
516 .await?;
517 linear_graphql_errors(&body)?;
518 body.get("data")
519 .and_then(|d| d.get("viewer"))
520 .and_then(|v| v.get("id"))
521 .and_then(JsonValue::as_str)
522 .map(str::to_string)
523 .ok_or_else(|| "Linear viewer query returned no id.".to_string())
524}
525
526fn extract_data<T: for<'de> Deserialize<'de>>(body: JsonValue, field: &str) -> Result<T, String> {
527 linear_graphql_errors(&body)?;
528 let value = body
529 .get("data")
530 .and_then(|d| d.get(field))
531 .cloned()
532 .ok_or_else(|| format!("Linear query returned no `{field}` data."))?;
533 serde_json::from_value(value).map_err(|e| format!("Failed to decode Linear `{field}`: {e}"))
534}
535
536