Skip to main content

gor/cmd/
issue.rs

1//! Implementation of the `gor issue` subcommand.
2//!
3//! Provides issue listing and management commands.
4//! Currently supports `gor issue list` for listing issues.
5
6#![allow(clippy::print_stdout)]
7
8use crate::cli::IssueCommand;
9use crate::client::Client;
10use crate::output::{format_date, print_json};
11use crate::repository::{detect_remote, parse_repo_spec};
12use anyhow::Context;
13
14/// Run the `gor issue` subcommand.
15///
16/// # Errors
17///
18/// Returns an error if the command execution fails.
19pub fn run(cmd: IssueCommand) -> anyhow::Result<()> {
20    match cmd {
21        IssueCommand::List {
22            owner_repo,
23            state,
24            labels,
25            assignee,
26            author,
27            mention,
28            milestone,
29            limit,
30            web,
31            json,
32            hostname,
33        } => list(
34            owner_repo,
35            &state,
36            &labels,
37            assignee.as_deref(),
38            author.as_deref(),
39            mention.as_deref(),
40            milestone.as_deref(),
41            limit,
42            web,
43            json,
44            hostname.as_deref(),
45        ),
46        IssueCommand::View {
47            number,
48            repo,
49            web,
50            comments,
51            json,
52            hostname,
53        } => view(
54            number,
55            repo.as_deref(),
56            web,
57            comments,
58            json,
59            hostname.as_deref(),
60        ),
61        IssueCommand::Close {
62            number,
63            repo,
64            comment,
65            reason,
66            hostname,
67        } => close(
68            number,
69            repo.as_deref(),
70            comment.as_deref(),
71            reason.as_deref(),
72            hostname.as_deref(),
73        ),
74        IssueCommand::Reopen {
75            number,
76            repo,
77            comment,
78            hostname,
79        } => reopen(
80            number,
81            repo.as_deref(),
82            comment.as_deref(),
83            hostname.as_deref(),
84        ),
85        IssueCommand::Comment {
86            number,
87            repo,
88            body,
89            body_file,
90            web,
91            hostname,
92        } => comment(
93            number,
94            repo.as_deref(),
95            body.as_deref(),
96            body_file.as_deref(),
97            web,
98            hostname.as_deref(),
99        ),
100        IssueCommand::Create {
101            repo,
102            title,
103            body,
104            labels,
105            assignee,
106            milestone,
107            project,
108            web,
109            hostname,
110        } => issue_create(
111            repo.as_deref(),
112            title.as_deref(),
113            body.as_deref(),
114            &labels,
115            &assignee,
116            milestone.as_deref(),
117            project,
118            web,
119            hostname.as_deref(),
120        ),
121        IssueCommand::Edit {
122            number,
123            repo,
124            title,
125            body,
126            add_label,
127            remove_label,
128            add_assignee,
129            remove_assignee,
130            milestone,
131            hostname,
132        } => edit(
133            number,
134            repo.as_deref(),
135            title.as_deref(),
136            body.as_deref(),
137            &add_label,
138            &remove_label,
139            &add_assignee,
140            &remove_assignee,
141            milestone.as_deref(),
142            hostname.as_deref(),
143        ),
144        IssueCommand::Transfer {
145            number,
146            destination,
147            repo,
148            hostname,
149        } => transfer(number, &destination, repo.as_deref(), hostname.as_deref()),
150    }
151}
152
153/// Execute `gor issue list`.
154///
155/// Lists issues for a repository with filtering by state, labels, assignee,
156/// author, mention, and milestone. Supports table output, JSON output, and
157/// opening the issue list in a browser.
158///
159/// # Errors
160///
161/// Returns an error if the repository cannot be found or the API request fails.
162#[allow(clippy::too_many_arguments)]
163fn list(
164    owner_repo: Option<String>,
165    state: &str,
166    labels: &[String],
167    assignee: Option<&str>,
168    author: Option<&str>,
169    mention: Option<&str>,
170    milestone: Option<&str>,
171    limit: u32,
172    web: bool,
173    json: Option<Vec<String>>,
174    hostname: Option<&str>,
175) -> anyhow::Result<()> {
176    // Resolve the repo spec
177    let spec = match owner_repo {
178        Some(s) => parse_repo_spec(&s).context("invalid repository spec")?,
179        None => detect_remote().ok_or_else(|| {
180            anyhow::anyhow!(
181                "could not detect repository from current directory; specify OWNER/REPO"
182            )
183        })?,
184    };
185
186    let host = hostname.unwrap_or("github.com");
187
188    // Handle --web flag: open in browser
189    if web {
190        let web_url = format!("https://{host}/{}/{}/issues", spec.owner, spec.repo);
191        open_in_browser(&web_url);
192        return Ok(());
193    }
194
195    let client = Client::new(host).context("failed to create HTTP client")?;
196
197    // Build query parameters for the API call
198    let mut query_params = vec![
199        ("state", state.to_string()),
200        ("per_page", limit.min(100).to_string()),
201    ];
202
203    if !labels.is_empty() {
204        query_params.push(("labels", labels.join(",")));
205    }
206    if let Some(a) = assignee {
207        query_params.push(("assignee", (*a).to_string()));
208    }
209    if let Some(a) = author {
210        // GitHub Issues API uses "creator" for filtering by author
211        query_params.push(("creator", (*a).to_string()));
212    }
213    if let Some(m) = mention {
214        query_params.push(("mentioned", (*m).to_string()));
215    }
216    if let Some(m) = milestone {
217        query_params.push(("milestone", (*m).to_string()));
218    }
219
220    let query_string = query_params
221        .iter()
222        .map(|(k, v)| format!("{k}={v}"))
223        .collect::<Vec<_>>()
224        .join("&");
225
226    let path = format!("/repos/{}/{}/issues?{query_string}", spec.owner, spec.repo);
227
228    let response = client.get(&path).context("failed to fetch issues")?;
229
230    let status = response.status();
231    if status == reqwest::StatusCode::NOT_FOUND {
232        anyhow::bail!("repository '{spec}' not found");
233    }
234    if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
235        anyhow::bail!("authentication required to list issues for '{spec}'");
236    }
237    if !status.is_success() {
238        anyhow::bail!("failed to list issues for '{spec}': HTTP {status}");
239    }
240
241    let mut issues: Vec<serde_json::Value> =
242        response.json().context("failed to parse issue response")?;
243
244    // Client-side filtering for fields the API may not fully support
245    if let Some(a) = author {
246        issues.retain(|issue| {
247            issue["user"]["login"]
248                .as_str()
249                .is_some_and(|login| login.eq_ignore_ascii_case(a))
250        });
251    }
252    if !labels.is_empty() {
253        issues.retain(|issue| {
254            let issue_labels: Vec<&str> = issue["labels"]
255                .as_array()
256                .map(|arr| arr.iter().filter_map(|l| l["name"].as_str()).collect())
257                .unwrap_or_default();
258            labels
259                .iter()
260                .all(|label| issue_labels.iter().any(|l| l.eq_ignore_ascii_case(label)))
261        });
262    }
263    if let Some(a) = assignee {
264        issues.retain(|issue| {
265            issue["assignees"].as_array().is_some_and(|arr| {
266                arr.iter().any(|assignee| {
267                    assignee["login"]
268                        .as_str()
269                        .is_some_and(|login| login.eq_ignore_ascii_case(a))
270                })
271            })
272        });
273    }
274    if let Some(m) = mention {
275        issues.retain(|issue| {
276            let body = issue["body"].as_str().unwrap_or("");
277            body.to_lowercase().contains(&m.to_lowercase())
278        });
279    }
280    if let Some(m) = milestone {
281        issues.retain(|issue| {
282            issue["milestone"]["title"]
283                .as_str()
284                .is_some_and(|title| title.eq_ignore_ascii_case(m))
285        });
286    }
287
288    // Handle --json flag
289    if let Some(fields) = json {
290        let fields_ref: Option<&[String]> = if fields.is_empty() {
291            None
292        } else {
293            Some(&fields)
294        };
295        print_json(&issues, fields_ref);
296        return Ok(());
297    }
298
299    // Default: print formatted table
300    print_issue_table(&issues);
301    Ok(())
302}
303
304/// Execute `gor issue view`.
305///
306/// Displays the full details of a single issue, including title, body,
307/// author, state, labels, assignees, milestone, and optionally comments.
308/// Supports JSON output and opening the issue in a browser.
309///
310/// # Errors
311///
312/// Returns an error if the repository cannot be found, the issue does not
313/// exist, or the API request fails.
314#[allow(clippy::too_many_arguments)]
315fn view(
316    number: u64,
317    repo: Option<&str>,
318    web: bool,
319    comments: bool,
320    json: Option<Vec<String>>,
321    hostname: Option<&str>,
322) -> anyhow::Result<()> {
323    // Resolve the repo spec
324    let spec = match repo {
325        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
326        None => detect_remote().ok_or_else(|| {
327            anyhow::anyhow!(
328                "could not detect repository from current directory; specify OWNER/REPO"
329            )
330        })?,
331    };
332
333    let host = hostname.unwrap_or("github.com");
334
335    // Handle --web flag: open in browser
336    if web {
337        let web_url = format!(
338            "https://{host}/{}/{}/issues/{number}",
339            spec.owner, spec.repo
340        );
341        open_in_browser(&web_url);
342        return Ok(());
343    }
344
345    let client = Client::new(host).context("failed to create HTTP client")?;
346
347    // Fetch the issue details
348    let path = format!("/repos/{}/{}/issues/{number}", spec.owner, spec.repo);
349    let response = client.get(&path).context("failed to fetch issue")?;
350
351    let status = response.status();
352    if status == reqwest::StatusCode::NOT_FOUND {
353        anyhow::bail!("issue #{number} not found in '{spec}'");
354    }
355    if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
356        anyhow::bail!("authentication required to view issue #{number}");
357    }
358    if !status.is_success() {
359        anyhow::bail!("failed to view issue #{number}: HTTP {status}");
360    }
361
362    let issue: serde_json::Value = response.json().context("failed to parse issue response")?;
363
364    // Handle --json flag
365    if let Some(fields) = json {
366        let fields_ref: Option<&[String]> = if fields.is_empty() {
367            None
368        } else {
369            Some(&fields)
370        };
371        print_json(&issue, fields_ref);
372        return Ok(());
373    }
374
375    // Fetch comments if --comments flag is set
376    let comments_data = if comments {
377        let comments_path = format!(
378            "/repos/{}/{}/issues/{number}/comments",
379            spec.owner, spec.repo
380        );
381        client
382            .get(&comments_path)
383            .ok()
384            .and_then(|r| r.json().ok())
385            .unwrap_or_default()
386    } else {
387        Vec::<serde_json::Value>::new()
388    };
389
390    // Print formatted view
391    print_issue_view(&issue, &comments_data);
392    Ok(())
393}
394
395/// Execute `gor issue close`.
396///
397/// Closes an issue by PATCHing its state to "closed". Optionally adds a
398/// closing comment and sets a close reason.
399///
400/// # Errors
401///
402/// Returns an error if the repository cannot be found, the issue does not
403/// exist, or the API request fails.
404fn close(
405    number: u64,
406    repo: Option<&str>,
407    comment: Option<&str>,
408    reason: Option<&str>,
409    hostname: Option<&str>,
410) -> anyhow::Result<()> {
411    let spec = match repo {
412        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
413        None => detect_remote().ok_or_else(|| {
414            anyhow::anyhow!(
415                "could not detect repository from current directory; specify OWNER/REPO"
416            )
417        })?,
418    };
419
420    let host = hostname.unwrap_or("github.com");
421    let client = Client::new(host).context("failed to create HTTP client")?;
422
423    // Build the PATCH body
424    let mut body = serde_json::json!({
425        "state": "closed",
426    });
427
428    if let Some(r) = reason {
429        body["state_reason"] = serde_json::Value::String(r.to_string());
430    }
431
432    let path = format!("/repos/{}/{}/issues/{number}", spec.owner, spec.repo);
433    let response = client
434        .request("PATCH", &path, &[], Some(serde_json::to_vec(&body)?))
435        .context("failed to close issue")?;
436
437    let status = response.status();
438    if status == reqwest::StatusCode::NOT_FOUND {
439        anyhow::bail!("issue #{number} not found in '{spec}'");
440    }
441    if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
442        anyhow::bail!("authentication required to close issue #{number}");
443    }
444    if !status.is_success() {
445        anyhow::bail!("failed to close issue #{number}: HTTP {status}");
446    }
447
448    // Add comment if requested
449    if let Some(c) = comment {
450        let comment_path = format!(
451            "/repos/{}/{}/issues/{number}/comments",
452            spec.owner, spec.repo
453        );
454        let comment_body = serde_json::json!({ "body": c });
455        let comment_response = client
456            .request(
457                "POST",
458                &comment_path,
459                &[],
460                Some(serde_json::to_vec(&comment_body)?),
461            )
462            .context("failed to add closing comment")?;
463        if !comment_response.status().is_success() {
464            tracing::warn!(
465                "failed to add closing comment: HTTP {}",
466                comment_response.status()
467            );
468        }
469    }
470
471    let reason_str = reason.map_or_else(String::new, |r| format!(" as {r}"));
472    println!("✓ issue #{number} closed{reason_str}");
473    Ok(())
474}
475
476/// Execute `gor issue reopen`.
477///
478/// Reopens a closed issue by PATCHing its state to "open". Optionally adds
479/// a comment when reopening.
480///
481/// # Errors
482///
483/// Returns an error if the repository cannot be found, the issue does not
484/// exist, or the API request fails.
485fn reopen(
486    number: u64,
487    repo: Option<&str>,
488    comment: Option<&str>,
489    hostname: Option<&str>,
490) -> anyhow::Result<()> {
491    let spec = match repo {
492        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
493        None => detect_remote().ok_or_else(|| {
494            anyhow::anyhow!(
495                "could not detect repository from current directory; specify OWNER/REPO"
496            )
497        })?,
498    };
499
500    let host = hostname.unwrap_or("github.com");
501    let client = Client::new(host).context("failed to create HTTP client")?;
502
503    let body = serde_json::json!({
504        "state": "open",
505    });
506
507    let path = format!("/repos/{}/{}/issues/{number}", spec.owner, spec.repo);
508    let response = client
509        .request("PATCH", &path, &[], Some(serde_json::to_vec(&body)?))
510        .context("failed to reopen issue")?;
511
512    let status = response.status();
513    if status == reqwest::StatusCode::NOT_FOUND {
514        anyhow::bail!("issue #{number} not found in '{spec}'");
515    }
516    if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
517        anyhow::bail!("authentication required to reopen issue #{number}");
518    }
519    if !status.is_success() {
520        anyhow::bail!("failed to reopen issue #{number}: HTTP {status}");
521    }
522
523    // Add comment if requested
524    if let Some(c) = comment {
525        let comment_path = format!(
526            "/repos/{}/{}/issues/{number}/comments",
527            spec.owner, spec.repo
528        );
529        let comment_body = serde_json::json!({ "body": c });
530        let comment_response = client
531            .request(
532                "POST",
533                &comment_path,
534                &[],
535                Some(serde_json::to_vec(&comment_body)?),
536            )
537            .context("failed to add comment")?;
538        if !comment_response.status().is_success() {
539            tracing::warn!("failed to add comment: HTTP {}", comment_response.status());
540        }
541    }
542
543    println!("✓ issue #{number} reopened");
544    Ok(())
545}
546
547/// Execute `gor issue comment`.
548///
549/// Adds a comment to an existing issue. Supports reading the comment body
550/// from a `--body` flag, a file (`--body-file`), or stdin (`@-`).
551///
552/// # Errors
553///
554/// Returns an error if the repository cannot be found, the issue does not
555/// exist, or the API request fails.
556fn comment(
557    number: u64,
558    repo: Option<&str>,
559    body: Option<&str>,
560    body_file: Option<&str>,
561    web: bool,
562    hostname: Option<&str>,
563) -> anyhow::Result<()> {
564    let spec = match repo {
565        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
566        None => detect_remote().ok_or_else(|| {
567            anyhow::anyhow!(
568                "could not detect repository from current directory; specify OWNER/REPO"
569            )
570        })?,
571    };
572
573    let host = hostname.unwrap_or("github.com");
574
575    // Handle --web flag: open in browser
576    if web {
577        let web_url = format!(
578            "https://{host}/{}/{}/issues/{number}",
579            spec.owner, spec.repo
580        );
581        open_in_browser(&web_url);
582        return Ok(());
583    }
584
585    // Resolve the comment body
586    let comment_body = match (body, body_file) {
587        (Some(b), None) => b.to_string(),
588        (None, Some(f)) => {
589            if f == "@-" {
590                let mut buf = String::new();
591                std::io::stdin()
592                    .read_line(&mut buf)
593                    .context("failed to read from stdin")?;
594                buf
595            } else {
596                std::fs::read_to_string(f)
597                    .with_context(|| format!("failed to read body file '{f}'"))?
598            }
599        }
600        (None, None) => anyhow::bail!("either --body or --body-file is required"),
601        (Some(_), Some(_)) => unreachable!(), // clap conflicts_with prevents this
602    };
603
604    let client = Client::new(host).context("failed to create HTTP client")?;
605
606    let path = format!(
607        "/repos/{}/{}/issues/{number}/comments",
608        spec.owner, spec.repo
609    );
610    let request_body = serde_json::json!({ "body": comment_body });
611    let response = client
612        .request("POST", &path, &[], Some(serde_json::to_vec(&request_body)?))
613        .context("failed to post comment")?;
614
615    let status = response.status();
616    if status == reqwest::StatusCode::NOT_FOUND {
617        anyhow::bail!("issue #{number} not found in '{spec}'");
618    }
619    if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
620        anyhow::bail!("authentication required to comment on issue #{number}");
621    }
622    if !status.is_success() {
623        anyhow::bail!("failed to comment on issue #{number}: HTTP {status}");
624    }
625
626    let comment_json: serde_json::Value = response
627        .json()
628        .context("failed to parse comment response")?;
629
630    let html_url = comment_json["html_url"]
631        .as_str()
632        .unwrap_or("https://github.com/");
633    println!("✓ comment posted: {html_url}");
634    Ok(())
635}
636
637/// Execute `gor issue create`.
638///
639/// Creates a new issue in a repository. Requires a title. Supports labels,
640/// assignees, milestones, and project board assignment.
641///
642/// # Errors
643///
644/// Returns an error if the repository cannot be found, the issue creation
645/// fails, or required fields are missing.
646#[allow(clippy::too_many_arguments)]
647fn issue_create(
648    repo: Option<&str>,
649    title: Option<&str>,
650    body: Option<&str>,
651    labels: &[String],
652    assignees: &[String],
653    _milestone: Option<&str>,
654    _project: Option<u32>,
655    web: bool,
656    hostname: Option<&str>,
657) -> anyhow::Result<()> {
658    let spec = match repo {
659        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
660        None => detect_remote().ok_or_else(|| {
661            anyhow::anyhow!(
662                "could not detect repository from current directory; specify OWNER/REPO with --repo"
663            )
664        })?,
665    };
666
667    let host = hostname.unwrap_or("github.com");
668    let client = Client::new(host).context("failed to create HTTP client")?;
669
670    // Build the request body
671    let mut body_map = serde_json::Map::new();
672    body_map.insert(
673        "title".to_string(),
674        serde_json::Value::String(
675            title
676                .ok_or_else(|| anyhow::anyhow!("issue title is required; use --title"))?
677                .to_string(),
678        ),
679    );
680    if let Some(b) = body {
681        body_map.insert("body".to_string(), serde_json::Value::String(b.to_string()));
682    }
683    if !labels.is_empty() {
684        body_map.insert(
685            "labels".to_string(),
686            serde_json::Value::Array(
687                labels
688                    .iter()
689                    .map(|l| serde_json::Value::String(l.clone()))
690                    .collect(),
691            ),
692        );
693    }
694    if !assignees.is_empty() {
695        body_map.insert(
696            "assignees".to_string(),
697            serde_json::Value::Array(
698                assignees
699                    .iter()
700                    .map(|a| serde_json::Value::String(a.clone()))
701                    .collect(),
702            ),
703        );
704    }
705
706    let body_value = serde_json::Value::Object(body_map);
707
708    // Create the issue
709    let path = format!("/repos/{}/{}/issues", spec.owner, spec.repo);
710    let response = client
711        .post(&path, &body_value)
712        .context("failed to create issue")?;
713
714    let status = response.status();
715    if status == reqwest::StatusCode::NOT_FOUND {
716        anyhow::bail!("repository '{spec}' not found");
717    }
718    if !status.is_success() {
719        let err_body: serde_json::Value = response.json().unwrap_or_default();
720        let msg = err_body["message"].as_str().unwrap_or("creation failed");
721        anyhow::bail!("failed to create issue: {msg}");
722    }
723
724    let issue: serde_json::Value = response.json().context("failed to parse issue response")?;
725
726    let issue_number = issue["number"].as_u64().unwrap_or(0);
727    let issue_url = issue["html_url"].as_str().unwrap_or("");
728
729    // Handle --web flag: open in browser
730    if web && !issue_url.is_empty() {
731        open_in_browser(issue_url);
732    }
733
734    println!(
735        "https://github.com/{}/{}/issues/{issue_number}",
736        spec.owner, spec.repo
737    );
738    Ok(())
739}
740
741/// Execute `gor issue edit`.
742///
743/// Edits an existing issue's title, body, labels, assignees, or milestone.
744/// Only fields that are provided will be updated.
745///
746/// # Errors
747///
748/// Returns an error if the issue does not exist, the API request fails,
749/// or the user does not have permission to edit the issue.
750#[allow(clippy::too_many_arguments)]
751fn edit(
752    number: u64,
753    repo: Option<&str>,
754    title: Option<&str>,
755    body: Option<&str>,
756    add_label: &[String],
757    remove_label: &[String],
758    add_assignee: &[String],
759    remove_assignee: &[String],
760    _milestone: Option<&str>,
761    hostname: Option<&str>,
762) -> anyhow::Result<()> {
763    let spec = match repo {
764        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
765        None => detect_remote().ok_or_else(|| {
766            anyhow::anyhow!(
767                "could not detect repository from current directory; specify OWNER/REPO with --repo"
768            )
769        })?,
770    };
771
772    let host = hostname.unwrap_or("github.com");
773    let client = Client::new(host).context("failed to create HTTP client")?;
774
775    // Build the request body with only the fields that were provided
776    let mut body_map = serde_json::Map::new();
777
778    if let Some(t) = title {
779        body_map.insert(
780            "title".to_string(),
781            serde_json::Value::String(t.to_string()),
782        );
783    }
784    if let Some(b) = body {
785        body_map.insert("body".to_string(), serde_json::Value::String(b.to_string()));
786    }
787
788    // Build the final labels list: current labels + add - remove
789    if !add_label.is_empty() || !remove_label.is_empty() {
790        // Fetch current labels first
791        let get_path = format!("/repos/{}/{}/issues/{number}", spec.owner, spec.repo);
792        let current: serde_json::Value = client
793            .get(&get_path)
794            .context("failed to fetch current issue labels")?
795            .json()
796            .context("failed to parse issue response")?;
797
798        let current_labels: Vec<String> = current["labels"]
799            .as_array()
800            .map(|arr| {
801                arr.iter()
802                    .filter_map(|l| l["name"].as_str().map(String::from))
803                    .collect()
804            })
805            .unwrap_or_default();
806
807        let mut new_labels = current_labels;
808        // Add new labels
809        for label in add_label {
810            if !new_labels.contains(label) {
811                new_labels.push(label.clone());
812            }
813        }
814        // Remove labels
815        new_labels.retain(|l| !remove_label.contains(l));
816
817        body_map.insert(
818            "labels".to_string(),
819            serde_json::Value::Array(
820                new_labels
821                    .iter()
822                    .map(|l| serde_json::Value::String(l.clone()))
823                    .collect(),
824            ),
825        );
826    }
827
828    // Handle assignees similarly
829    if !add_assignee.is_empty() || !remove_assignee.is_empty() {
830        // Fetch current assignees if we need to modify
831        if !add_assignee.is_empty() || !remove_assignee.is_empty() {
832            let get_path = format!("/repos/{}/{}/issues/{number}", spec.owner, spec.repo);
833            let current: serde_json::Value = client
834                .get(&get_path)
835                .context("failed to fetch current assignees")?
836                .json()
837                .context("failed to parse issue response")?;
838
839            let current_assignees: Vec<String> = current["assignees"]
840                .as_array()
841                .map(|arr| {
842                    arr.iter()
843                        .filter_map(|l| l["login"].as_str().map(String::from))
844                        .collect()
845                })
846                .unwrap_or_default();
847
848            let mut new_assignees = current_assignees;
849            for a in add_assignee {
850                if !new_assignees.contains(a) {
851                    new_assignees.push(a.clone());
852                }
853            }
854            new_assignees.retain(|a| !remove_assignee.contains(a));
855
856            body_map.insert(
857                "assignees".to_string(),
858                serde_json::Value::Array(
859                    new_assignees
860                        .iter()
861                        .map(|a| serde_json::Value::String(a.clone()))
862                        .collect(),
863                ),
864            );
865        }
866    }
867
868    let body_value = serde_json::Value::Object(body_map);
869
870    let path = format!("/repos/{}/{}/issues/{number}", spec.owner, spec.repo);
871    let response = client
872        .request("PATCH", &path, &[], Some(serde_json::to_vec(&body_value)?))
873        .context("failed to update issue")?;
874
875    let status = response.status();
876    if status == reqwest::StatusCode::NOT_FOUND {
877        anyhow::bail!("issue #{number} not found in '{spec}'");
878    }
879    if !status.is_success() {
880        let err_body: serde_json::Value = response.json().unwrap_or_default();
881        let msg = err_body["message"].as_str().unwrap_or("update failed");
882        anyhow::bail!("failed to update issue #{number}: {msg}");
883    }
884
885    let updated: serde_json::Value = response
886        .json()
887        .context("failed to parse updated issue response")?;
888
889    let issue_number = updated["number"].as_u64().unwrap_or(number);
890    println!(
891        "https://github.com/{}/{}/issues/{issue_number}",
892        spec.owner, spec.repo
893    );
894    Ok(())
895}
896
897/// Execute `gor issue transfer`.
898///
899/// Transfers an issue to a different repository.
900///
901/// # Errors
902///
903/// Returns an error if the source repo cannot be determined, the API request
904/// fails, or the destination repo does not exist.
905fn transfer(
906    number: u64,
907    destination: &str,
908    repo: Option<&str>,
909    hostname: Option<&str>,
910) -> anyhow::Result<()> {
911    let host = hostname.unwrap_or("github.com");
912    let client = Client::new(host).context("failed to create HTTP client")?;
913
914    // Determine the source repository.
915    let spec = if let Some(r) = repo {
916        parse_repo_spec(r).with_context(|| format!("invalid repository: {r}"))?
917    } else {
918        detect_remote().context("could not detect repository from git remote")?
919    };
920
921    // Parse the destination repository.
922    let dest_spec = parse_repo_spec(destination)
923        .with_context(|| format!("invalid destination repository: {destination}"))?;
924
925    let path = format!(
926        "/repos/{}/{}/issues/{number}/transfer",
927        spec.owner, spec.repo
928    );
929
930    let body = serde_json::json!({
931        "new_owner": dest_spec.owner,
932        "new_repo": dest_spec.repo,
933    });
934
935    let response = client
936        .post(&path, &body)
937        .context("failed to transfer issue")?;
938
939    let status = response.status();
940    if !status.is_success() {
941        let err_body: serde_json::Value = response.json().unwrap_or_default();
942        let msg = err_body["message"].as_str().unwrap_or("transfer failed");
943        anyhow::bail!("failed to transfer issue #{number}: {msg}");
944    }
945
946    let result: serde_json::Value = response.json().context("failed to parse response")?;
947    let new_url = result["html_url"].as_str().unwrap_or("—");
948    println!("Issue #{number} transferred to {destination}: {new_url}");
949    Ok(())
950}
951
952/// Print a formatted issue detail view.
953///
954/// Displays title, metadata (state, author, dates, labels, assignees,
955/// milestone), body, and optionally comments.
956fn print_issue_view(issue: &serde_json::Value, comments: &[serde_json::Value]) {
957    // Title
958    let title = issue["title"].as_str().unwrap_or("(no title)");
959    println!("{title}");
960    let separator_len = title.len().min(80);
961    println!("{}", "─".repeat(separator_len));
962    println!();
963
964    // Metadata
965    let state = issue["state"].as_str().unwrap_or("unknown");
966    let author = issue["user"]["login"].as_str().unwrap_or("unknown");
967    let created = issue["created_at"]
968        .as_str()
969        .map_or_else(|| "—".to_string(), format_date);
970    let updated = issue["updated_at"]
971        .as_str()
972        .map_or_else(|| "—".to_string(), format_date);
973
974    println!("State:   {state}");
975    println!("Author:  {author}");
976    println!("Created: {created}");
977    println!("Updated: {updated}");
978
979    // Labels
980    let labels_str = issue["labels"]
981        .as_array()
982        .map(|arr| {
983            arr.iter()
984                .filter_map(|l| l["name"].as_str())
985                .collect::<Vec<_>>()
986                .join(", ")
987        })
988        .unwrap_or_default();
989    if !labels_str.is_empty() {
990        println!("Labels:  {labels_str}");
991    }
992
993    // Assignees
994    let assignees_str = issue["assignees"]
995        .as_array()
996        .map(|arr| {
997            arr.iter()
998                .filter_map(|a| a["login"].as_str())
999                .collect::<Vec<_>>()
1000                .join(", ")
1001        })
1002        .unwrap_or_default();
1003    if !assignees_str.is_empty() {
1004        println!("Assignees: {assignees_str}");
1005    }
1006
1007    // Milestone
1008    if let Some(milestone_title) = issue["milestone"]["title"].as_str() {
1009        println!("Milestone: {milestone_title}");
1010    }
1011
1012    println!();
1013
1014    // Body
1015    let body = issue["body"].as_str().unwrap_or("");
1016    if !body.is_empty() {
1017        println!("{body}");
1018        println!();
1019    }
1020
1021    // Comments
1022    if !comments.is_empty() {
1023        println!("── Comments ──");
1024        println!();
1025        for comment in comments {
1026            let comment_author = comment["user"]["login"].as_str().unwrap_or("unknown");
1027            let comment_date = comment["created_at"]
1028                .as_str()
1029                .map_or_else(|| "—".to_string(), format_date);
1030            let comment_body = comment["body"].as_str().unwrap_or("");
1031            println!("{comment_author} commented on {comment_date}");
1032            println!();
1033            println!("{comment_body}");
1034            println!();
1035        }
1036    }
1037}
1038
1039/// Print a formatted issue list table.
1040///
1041/// Columns: NUMBER, TITLE, AUTHOR, LABELS, STATE
1042fn print_issue_table(issues: &[serde_json::Value]) {
1043    if issues.is_empty() {
1044        println!("No issues found.");
1045        return;
1046    }
1047
1048    // Column widths
1049    let num_width = 8;
1050    let title_width = 50;
1051    let author_width = 14;
1052    let labels_width = 14;
1053    let state_width = 8;
1054
1055    // Header
1056    println!(
1057        "{:>num_width$}  {:<title_width$}  {:<author_width$}  {:<labels_width$}  {:<state_width$}",
1058        "NUMBER", "TITLE", "AUTHOR", "LABELS", "STATE",
1059    );
1060
1061    for issue in issues {
1062        let number = issue["number"]
1063            .as_u64()
1064            .map_or_else(|| "—".to_string(), |n| n.to_string());
1065        let title = issue["title"].as_str().unwrap_or("—");
1066        let author = issue["user"]["login"].as_str().unwrap_or("—");
1067        let state = issue["state"].as_str().unwrap_or("—");
1068
1069        let labels_str = issue["labels"]
1070            .as_array()
1071            .map(|arr| {
1072                arr.iter()
1073                    .filter_map(|l| l["name"].as_str())
1074                    .collect::<Vec<_>>()
1075                    .join(", ")
1076            })
1077            .unwrap_or_default();
1078        let labels_display = if labels_str.is_empty() {
1079            "—".to_string()
1080        } else {
1081            labels_str
1082        };
1083
1084        let title_truncated = crate::cmd::util::truncate(title, title_width);
1085        let author_truncated = crate::cmd::util::truncate(author, author_width);
1086        let labels_truncated = crate::cmd::util::truncate(&labels_display, labels_width);
1087
1088        println!(
1089            "{number:>num_width$}  {title_truncated:<title_width$}  {author_truncated:<author_width$}  {labels_truncated:<labels_width$}  {state:<state_width$}",
1090        );
1091    }
1092}
1093
1094/// Open a URL in the default browser using the system's default handler.
1095fn open_in_browser(url: &str) {
1096    #[cfg(target_os = "linux")]
1097    {
1098        let _ = std::process::Command::new("xdg-open").arg(url).spawn();
1099    }
1100    #[cfg(target_os = "macos")]
1101    {
1102        let _ = std::process::Command::new("open").arg(url).spawn();
1103    }
1104    #[cfg(target_os = "windows")]
1105    {
1106        let _ = std::process::Command::new("cmd")
1107            .args(["/c", "start", url])
1108            .spawn();
1109    }
1110    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1111    {
1112        println!("Open {url} in your browser");
1113    }
1114}
1115
1116#[cfg(test)]
1117#[allow(clippy::expect_used)]
1118mod tests {
1119    use super::*;
1120    use serde_json::json;
1121
1122    #[test]
1123    fn print_issue_table_basic() {
1124        let issues = vec![json!({
1125            "number": 42,
1126            "title": "Fix authentication bug in login flow",
1127            "state": "open",
1128            "user": { "login": "octocat" },
1129            "labels": [
1130                { "name": "bug" },
1131                { "name": "security" }
1132            ]
1133        })];
1134        // Should not panic
1135        print_issue_table(&issues);
1136    }
1137
1138    #[test]
1139    fn print_issue_table_closed() {
1140        let issues = vec![json!({
1141            "number": 100,
1142            "title": "Add new feature",
1143            "state": "closed",
1144            "user": { "login": "dev-user" },
1145            "labels": []
1146        })];
1147        // Should not panic; closed issue should show "closed" state
1148        print_issue_table(&issues);
1149    }
1150
1151    #[test]
1152    fn print_issue_table_empty() {
1153        let issues: Vec<serde_json::Value> = vec![];
1154        // Should not panic
1155        print_issue_table(&issues);
1156    }
1157
1158    #[test]
1159    fn print_issue_table_multiple() {
1160        let issues = vec![
1161            json!({
1162                "number": 1,
1163                "title": "First issue",
1164                "state": "open",
1165                "user": { "login": "alice" },
1166                "labels": [{"name": "enhancement"}]
1167            }),
1168            json!({
1169                "number": 2,
1170                "title": "Second issue with a very long title that should be truncated in the table output",
1171                "state": "closed",
1172                "user": { "login": "bob" },
1173                "labels": [{"name": "bug"}, {"name": "docs"}]
1174            }),
1175        ];
1176        // Should not panic
1177        print_issue_table(&issues);
1178    }
1179
1180    #[test]
1181    fn print_issue_table_null_fields() {
1182        let issues = vec![json!({
1183            "number": 99,
1184            "title": null,
1185            "state": null,
1186            "user": null,
1187            "labels": null
1188        })];
1189        // Should not panic with null fields
1190        print_issue_table(&issues);
1191    }
1192
1193    #[test]
1194    fn open_in_browser_does_not_panic() {
1195        // Just verify it doesn't panic — actual browser opening is a no-op in tests
1196        open_in_browser("https://github.com/octocat/hello-world/issues");
1197    }
1198
1199    #[test]
1200    fn print_issue_view_basic() {
1201        let issue = json!({
1202            "number": 42,
1203            "title": "Fix authentication bug",
1204            "state": "open",
1205            "user": { "login": "octocat" },
1206            "created_at": "2024-01-15T10:30:00Z",
1207            "updated_at": "2024-01-16T12:00:00Z",
1208            "body": "This issue describes an authentication bug.",
1209            "labels": [
1210                { "name": "bug" },
1211                { "name": "security" }
1212            ],
1213            "assignees": [
1214                { "login": "alice" }
1215            ],
1216            "milestone": { "title": "v1.0" }
1217        });
1218        let comments: Vec<serde_json::Value> = vec![];
1219        // Should not panic
1220        print_issue_view(&issue, &comments);
1221    }
1222
1223    #[test]
1224    fn print_issue_view_closed() {
1225        let issue = json!({
1226            "number": 100,
1227            "title": "Add new feature",
1228            "state": "closed",
1229            "user": { "login": "dev-user" },
1230            "created_at": "2024-01-10T08:00:00Z",
1231            "updated_at": "2024-01-15T10:30:00Z",
1232            "body": "This adds a new feature.",
1233            "labels": [],
1234            "assignees": [],
1235            "milestone": null
1236        });
1237        let comments: Vec<serde_json::Value> = vec![];
1238        // Should not panic
1239        print_issue_view(&issue, &comments);
1240    }
1241
1242    #[test]
1243    fn print_issue_view_with_comments() {
1244        let issue = json!({
1245            "number": 42,
1246            "title": "Fix bug",
1247            "state": "open",
1248            "user": { "login": "octocat" },
1249            "created_at": "2024-01-15T10:30:00Z",
1250            "updated_at": "2024-01-16T12:00:00Z",
1251            "body": "Fixes a bug.",
1252            "labels": [],
1253            "assignees": [],
1254            "milestone": null
1255        });
1256        let comments = vec![
1257            json!({
1258                "user": { "login": "reviewer1" },
1259                "created_at": "2024-01-16T14:00:00Z",
1260                "body": "Looks good to me!"
1261            }),
1262            json!({
1263                "user": { "login": "octocat" },
1264                "created_at": "2024-01-16T15:00:00Z",
1265                "body": "Thanks for the review!"
1266            }),
1267        ];
1268        // Should not panic
1269        print_issue_view(&issue, &comments);
1270    }
1271
1272    #[test]
1273    fn print_issue_view_null_fields() {
1274        let issue = json!({
1275            "number": 99,
1276            "title": null,
1277            "state": null,
1278            "user": null,
1279            "created_at": null,
1280            "updated_at": null,
1281            "body": null,
1282            "labels": null,
1283            "assignees": null,
1284            "milestone": null
1285        });
1286        let comments: Vec<serde_json::Value> = vec![];
1287        // Should not panic with null fields
1288        print_issue_view(&issue, &comments);
1289    }
1290
1291    #[test]
1292    fn print_issue_view_with_assignees() {
1293        let issue = json!({
1294            "number": 50,
1295            "title": "Multiple assignees",
1296            "state": "open",
1297            "user": { "login": "owner" },
1298            "created_at": "2024-02-01T10:00:00Z",
1299            "updated_at": "2024-02-02T10:00:00Z",
1300            "body": "This issue has multiple assignees.",
1301            "labels": [],
1302            "assignees": [
1303                { "login": "alice" },
1304                { "login": "bob" },
1305                { "login": "carol" }
1306            ],
1307            "milestone": null
1308        });
1309        let comments: Vec<serde_json::Value> = vec![];
1310        // Should not panic
1311        print_issue_view(&issue, &comments);
1312    }
1313
1314    #[test]
1315    fn print_issue_view_with_milestone() {
1316        let issue = json!({
1317            "number": 60,
1318            "title": "Milestone issue",
1319            "state": "open",
1320            "user": { "login": "planner" },
1321            "created_at": "2024-03-01T10:00:00Z",
1322            "updated_at": "2024-03-02T10:00:00Z",
1323            "body": "This issue is part of a milestone.",
1324            "labels": [{"name": "enhancement"}],
1325            "assignees": [],
1326            "milestone": { "title": "v2.0" }
1327        });
1328        let comments: Vec<serde_json::Value> = vec![];
1329        // Should not panic
1330        print_issue_view(&issue, &comments);
1331    }
1332}