1#![allow(clippy::print_stdout)]
7
8use crate::cli::LabelCommand;
9use crate::client::Client;
10use crate::output::print_json;
11use crate::repository::{detect_remote, parse_repo_spec};
12use anyhow::Context;
13
14pub fn run(cmd: LabelCommand) -> anyhow::Result<()> {
20 match cmd {
21 LabelCommand::List {
22 repo,
23 search,
24 limit,
25 json,
26 hostname,
27 } => list(
28 repo.as_deref(),
29 search.as_deref(),
30 limit,
31 json,
32 hostname.as_deref(),
33 ),
34 LabelCommand::Create {
35 name,
36 color,
37 description,
38 repo,
39 hostname,
40 } => create(
41 &name,
42 color.as_deref(),
43 description.as_deref(),
44 repo.as_deref(),
45 hostname.as_deref(),
46 ),
47 LabelCommand::Edit {
48 name,
49 rename,
50 color,
51 description,
52 repo,
53 hostname,
54 } => edit(
55 &name,
56 rename.as_deref(),
57 color.as_deref(),
58 description.as_deref(),
59 repo.as_deref(),
60 hostname.as_deref(),
61 ),
62 LabelCommand::Delete {
63 name,
64 repo,
65 yes,
66 hostname,
67 } => delete(&name, repo.as_deref(), yes, hostname.as_deref()),
68 LabelCommand::Clone {
69 source,
70 repo,
71 force,
72 hostname,
73 } => clone_labels(&source, repo.as_deref(), force, hostname.as_deref()),
74 }
75}
76
77fn list(
86 repo: Option<&str>,
87 search: Option<&str>,
88 limit: u32,
89 json: Option<Vec<String>>,
90 hostname: Option<&str>,
91) -> anyhow::Result<()> {
92 let spec = match repo {
93 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
94 None => detect_remote().ok_or_else(|| {
95 anyhow::anyhow!(
96 "could not detect repository from current directory; specify OWNER/REPO with --repo"
97 )
98 })?,
99 };
100
101 let host = hostname.unwrap_or("github.com");
102 let client = Client::new(host).context("failed to create HTTP client")?;
103
104 let path = format!(
105 "/repos/{}/{}/labels?per_page={}",
106 spec.owner,
107 spec.repo,
108 limit.min(100)
109 );
110 let response = client.get(&path).context("failed to fetch labels")?;
111
112 let status = response.status();
113 if status == reqwest::StatusCode::NOT_FOUND {
114 anyhow::bail!("repository '{spec}' not found");
115 }
116 if !status.is_success() {
117 anyhow::bail!("failed to list labels for '{spec}': HTTP {status}");
118 }
119
120 let mut labels: Vec<serde_json::Value> =
121 response.json().context("failed to parse labels response")?;
122
123 if let Some(query) = search {
125 let query_lower = query.to_lowercase();
126 labels.retain(|label| {
127 label["name"]
128 .as_str()
129 .is_some_and(|name| name.to_lowercase().contains(&query_lower))
130 });
131 }
132
133 labels.truncate(limit as usize);
135
136 if let Some(fields) = json {
138 let fields_ref: Option<&[String]> = if fields.is_empty() {
139 None
140 } else {
141 Some(&fields)
142 };
143 print_json(&labels, fields_ref);
144 return Ok(());
145 }
146
147 print_label_table(&labels);
149 Ok(())
150}
151
152fn print_label_table(labels: &[serde_json::Value]) {
156 if labels.is_empty() {
157 println!("No labels found.");
158 return;
159 }
160
161 let name_width = 24;
162 let color_width = 10;
163 let desc_width = 60;
164
165 println!(
166 "{:<name_width$} {:<color_width$} {:<desc_width$}",
167 "NAME", "COLOR", "DESCRIPTION",
168 );
169
170 for label in labels {
171 let name = label["name"].as_str().unwrap_or("—");
172 let color = label["color"].as_str().unwrap_or("—");
173 let description = label["description"].as_str().unwrap_or("—");
174
175 let name_truncated = crate::cmd::util::truncate(name, name_width);
176 let desc_truncated = crate::cmd::util::truncate(description, desc_width);
177
178 println!(
179 "{name_truncated:<name_width$} #{color:<color_width$} {desc_truncated:<desc_width$}",
180 );
181 }
182}
183
184fn create(
192 name: &str,
193 color: Option<&str>,
194 description: Option<&str>,
195 repo: Option<&str>,
196 hostname: Option<&str>,
197) -> anyhow::Result<()> {
198 let spec = match repo {
199 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
200 None => detect_remote().ok_or_else(|| {
201 anyhow::anyhow!(
202 "could not detect repository from current directory; specify OWNER/REPO with --repo"
203 )
204 })?,
205 };
206
207 let host = hostname.unwrap_or("github.com");
208 let client = Client::new(host).context("failed to create HTTP client")?;
209
210 let color_value = color.unwrap_or("ededed");
211
212 let mut body_map = serde_json::Map::new();
213 body_map.insert(
214 "name".to_string(),
215 serde_json::Value::String(name.to_string()),
216 );
217 body_map.insert(
218 "color".to_string(),
219 serde_json::Value::String(color_value.to_string()),
220 );
221 if let Some(desc) = description {
222 body_map.insert(
223 "description".to_string(),
224 serde_json::Value::String(desc.to_string()),
225 );
226 }
227
228 let path = format!("/repos/{}/{}/labels", spec.owner, spec.repo);
229 let body_value = serde_json::Value::Object(body_map);
230 let response = client
231 .post(&path, &body_value)
232 .context("failed to create label")?;
233
234 let status = response.status();
235 if status == reqwest::StatusCode::UNPROCESSABLE_ENTITY {
236 anyhow::bail!("label '{name}' already exists in '{spec}'");
237 }
238 if !status.is_success() {
239 let err_body: serde_json::Value = response.json().unwrap_or_default();
240 let msg = err_body["message"].as_str().unwrap_or("creation failed");
241 anyhow::bail!("failed to create label '{name}': {msg}");
242 }
243
244 let label: serde_json::Value = response.json().context("failed to parse response")?;
245 let label_name = label["name"].as_str().unwrap_or(name);
246 let label_color = label["color"].as_str().unwrap_or(color_value);
247 println!("✓ Created label '{label_name}' (#{label_color})");
248 Ok(())
249}
250
251fn edit(
259 name: &str,
260 rename: Option<&str>,
261 color: Option<&str>,
262 description: Option<&str>,
263 repo: Option<&str>,
264 hostname: Option<&str>,
265) -> anyhow::Result<()> {
266 let spec = match repo {
267 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
268 None => detect_remote().ok_or_else(|| {
269 anyhow::anyhow!(
270 "could not detect repository from current directory; specify OWNER/REPO with --repo"
271 )
272 })?,
273 };
274
275 let host = hostname.unwrap_or("github.com");
276 let client = Client::new(host).context("failed to create HTTP client")?;
277
278 let mut body_map = serde_json::Map::new();
279 if let Some(new_name) = rename {
280 body_map.insert(
281 "new_name".to_string(),
282 serde_json::Value::String(new_name.to_string()),
283 );
284 }
285 if let Some(c) = color {
286 body_map.insert(
287 "color".to_string(),
288 serde_json::Value::String(c.to_string()),
289 );
290 }
291 if let Some(desc) = description {
292 body_map.insert(
293 "description".to_string(),
294 serde_json::Value::String(desc.to_string()),
295 );
296 }
297
298 if body_map.is_empty() {
299 anyhow::bail!("no changes specified; use --rename, --color, or --description");
300 }
301
302 let path = format!(
303 "/repos/{}/{}/labels/{}",
304 spec.owner,
305 spec.repo,
306 urlencoding(name)
307 );
308 let body_value = serde_json::Value::Object(body_map);
309 let response = client
310 .request("PATCH", &path, &[], Some(serde_json::to_vec(&body_value)?))
311 .context("failed to edit label")?;
312
313 let status = response.status();
314 if status == reqwest::StatusCode::NOT_FOUND {
315 anyhow::bail!("label '{name}' not found in '{spec}'");
316 }
317 if !status.is_success() {
318 let err_body: serde_json::Value = response.json().unwrap_or_default();
319 let msg = err_body["message"].as_str().unwrap_or("edit failed");
320 anyhow::bail!("failed to edit label '{name}': {msg}");
321 }
322
323 let label: serde_json::Value = response.json().context("failed to parse response")?;
324 let label_name = label["name"].as_str().unwrap_or(name);
325 let label_color = label["color"].as_str().unwrap_or("—");
326 let label_desc = label["description"].as_str().unwrap_or("—");
327 println!("✓ Updated label '{label_name}' (#{label_color}): {label_desc}");
328 Ok(())
329}
330
331fn delete(name: &str, repo: Option<&str>, yes: bool, hostname: Option<&str>) -> anyhow::Result<()> {
339 let spec = match repo {
340 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
341 None => detect_remote().ok_or_else(|| {
342 anyhow::anyhow!(
343 "could not detect repository from current directory; specify OWNER/REPO with --repo"
344 )
345 })?,
346 };
347
348 if !yes {
350 use std::io::Write;
351 print!("Delete label '{name}' from '{spec}'? [y/N] ");
352 std::io::stdout().flush().ok();
353 let mut input = String::new();
354 std::io::stdin().read_line(&mut input).ok();
355 let trimmed = input.trim().to_lowercase();
356 if trimmed != "y" && trimmed != "yes" {
357 println!("Cancelled.");
358 return Ok(());
359 }
360 }
361
362 let host = hostname.unwrap_or("github.com");
363 let client = Client::new(host).context("failed to create HTTP client")?;
364
365 let path = format!(
366 "/repos/{}/{}/labels/{}",
367 spec.owner,
368 spec.repo,
369 urlencoding(name)
370 );
371 let response = client
372 .request("DELETE", &path, &[], None)
373 .context("failed to delete label")?;
374
375 let status = response.status();
376 if status == reqwest::StatusCode::NOT_FOUND {
377 anyhow::bail!("label '{name}' not found in '{spec}'");
378 }
379 if status == reqwest::StatusCode::NO_CONTENT {
380 println!("✓ Deleted label '{name}'");
381 return Ok(());
382 }
383 if !status.is_success() {
384 anyhow::bail!("failed to delete label '{name}': HTTP {status}");
385 }
386
387 Ok(())
388}
389
390fn clone_labels(
400 source: &str,
401 repo: Option<&str>,
402 force: bool,
403 hostname: Option<&str>,
404) -> anyhow::Result<()> {
405 let source_spec = parse_repo_spec(source).context("invalid source repository spec")?;
406
407 let target_spec = match repo {
408 Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
409 None => detect_remote().ok_or_else(|| {
410 anyhow::anyhow!(
411 "could not detect repository from current directory; specify OWNER/REPO with --repo"
412 )
413 })?,
414 };
415
416 let host = hostname.unwrap_or("github.com");
417 let client = Client::new(host).context("failed to create HTTP client")?;
418
419 let source_path = format!(
421 "/repos/{}/{}/labels?per_page=100",
422 source_spec.owner, source_spec.repo
423 );
424 let resp = client
425 .get(&source_path)
426 .context("failed to fetch source labels")?;
427 let status = resp.status();
428 if status == reqwest::StatusCode::NOT_FOUND {
429 anyhow::bail!("source repository '{source_spec}' not found");
430 }
431 if !status.is_success() {
432 anyhow::bail!("failed to fetch labels from '{source_spec}': HTTP {status}");
433 }
434 let source_labels: Vec<serde_json::Value> =
435 resp.json().context("failed to parse labels response")?;
436
437 let target_path = format!(
439 "/repos/{}/{}/labels?per_page=100",
440 target_spec.owner, target_spec.repo
441 );
442 let existing: Vec<serde_json::Value> = client
443 .get(&target_path)
444 .ok()
445 .and_then(|r| r.json().ok())
446 .unwrap_or_default();
447 let existing_names: std::collections::HashSet<&str> =
448 existing.iter().filter_map(|l| l["name"].as_str()).collect();
449
450 let mut created = 0u32;
451 let mut updated = 0u32;
452 let mut skipped = 0u32;
453
454 for label in &source_labels {
455 let label_name = label["name"].as_str().unwrap_or("");
456 let label_color = label["color"].as_str().unwrap_or("ededed");
457 let label_desc = label["description"].as_str().unwrap_or("");
458
459 if existing_names.contains(label_name) {
460 if force {
461 let mut body_map = serde_json::Map::new();
463 body_map.insert(
464 "color".to_string(),
465 serde_json::Value::String(label_color.to_string()),
466 );
467 body_map.insert(
468 "description".to_string(),
469 serde_json::Value::String(label_desc.to_string()),
470 );
471 let edit_path = format!(
472 "/repos/{}/{}/labels/{}",
473 target_spec.owner,
474 target_spec.repo,
475 urlencoding(label_name)
476 );
477 let body_value = serde_json::Value::Object(body_map);
478 if client
479 .request(
480 "PATCH",
481 &edit_path,
482 &[],
483 Some(serde_json::to_vec(&body_value).unwrap_or_default()),
484 )
485 .is_ok()
486 {
487 updated += 1;
488 } else {
489 skipped += 1;
490 }
491 } else {
492 skipped += 1;
493 }
494 } else {
495 let mut body_map = serde_json::Map::new();
497 body_map.insert(
498 "name".to_string(),
499 serde_json::Value::String(label_name.to_string()),
500 );
501 body_map.insert(
502 "color".to_string(),
503 serde_json::Value::String(label_color.to_string()),
504 );
505 body_map.insert(
506 "description".to_string(),
507 serde_json::Value::String(label_desc.to_string()),
508 );
509 let create_path = format!("/repos/{}/{}/labels", target_spec.owner, target_spec.repo);
510 let body_value = serde_json::Value::Object(body_map);
511 if client.post(&create_path, &body_value).is_ok() {
512 created += 1;
513 } else {
514 skipped += 1;
515 }
516 }
517 }
518
519 println!(
520 "✓ Cloned labels from '{source_spec}' to '{target_spec}': {created} created, {updated} updated, {skipped} skipped"
521 );
522 Ok(())
523}
524
525fn urlencoding(s: &str) -> String {
527 s.replace('#', "%23")
528 .replace(' ', "%20")
529 .replace('?', "%3F")
530 .replace('&', "%26")
531}
532
533#[cfg(test)]
534#[allow(clippy::expect_used)]
535mod tests {
536 use super::*;
537 use serde_json::json;
538
539 #[test]
540 fn print_label_table_basic() {
541 let labels = vec![json!({
542 "name": "bug",
543 "color": "d73a4a",
544 "description": "Something isn't working"
545 })];
546 print_label_table(&labels);
547 }
548
549 #[test]
550 fn print_label_table_empty() {
551 let labels: Vec<serde_json::Value> = vec![];
552 print_label_table(&labels);
553 }
554
555 #[test]
556 fn print_label_table_multiple() {
557 let labels = vec![
558 json!({
559 "name": "bug",
560 "color": "d73a4a",
561 "description": "Something isn't working"
562 }),
563 json!({
564 "name": "enhancement",
565 "color": "a2eeef",
566 "description": "New feature or request"
567 }),
568 json!({
569 "name": "documentation",
570 "color": "0075ca",
571 "description": "Improvements or additions to documentation"
572 }),
573 ];
574 print_label_table(&labels);
575 }
576
577 #[test]
578 fn print_label_table_null_fields() {
579 let labels = vec![json!({
580 "name": null,
581 "color": null,
582 "description": null
583 })];
584 print_label_table(&labels);
585 }
586}