Skip to main content

gor/cmd/
label.rs

1//! Implementation of the `gor label` subcommand.
2//!
3//! Provides label listing functionality.
4//! Currently supports `gor label list` for listing repository labels.
5
6#![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
14/// Run the `gor label` subcommand.
15///
16/// # Errors
17///
18/// Returns an error if the command execution fails.
19pub 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
77/// Execute `gor label list`.
78///
79/// Lists all labels in a repository. Supports filtering by name substring,
80/// limiting results, and JSON output with field selection.
81///
82/// # Errors
83///
84/// Returns an error if the repository cannot be found or the API request fails.
85fn 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    // Client-side search filter
124    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    // Apply limit
134    labels.truncate(limit as usize);
135
136    // Handle --json flag
137    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    // Default: print formatted table
148    print_label_table(&labels);
149    Ok(())
150}
151
152/// Print a formatted label list table.
153///
154/// Columns: NAME, COLOR, DESCRIPTION
155fn 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
184/// Execute `gor label create`.
185///
186/// Creates a new label with the given name, optional color, and description.
187///
188/// # Errors
189///
190/// Returns an error if the repository cannot be found or the API request fails.
191fn 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
251/// Execute `gor label edit`.
252///
253/// Edits an existing label's name, color, or description.
254///
255/// # Errors
256///
257/// Returns an error if the label does not exist or the API request fails.
258fn 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
331/// Execute `gor label delete`.
332///
333/// Deletes a label by name. Prompts for confirmation unless `--yes` is passed.
334///
335/// # Errors
336///
337/// Returns an error if the label does not exist or the API request fails.
338fn 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    // Confirmation prompt
349    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
390/// Execute `gor label clone`.
391///
392/// Clones all labels from a source repository to the target repository.
393/// Supports `--force` to overwrite existing labels.
394///
395/// # Errors
396///
397/// Returns an error if the source or target repository cannot be found,
398/// or if any API request fails.
399fn 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    // Fetch source labels
420    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    // Fetch existing target labels for conflict detection
438    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                // Update existing label
462                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            // Create new label
496            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
525/// URL-encode a label name for use in API paths.
526fn 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}