Skip to main content

gor/cmd/
pr.rs

1//! Implementation of the `gor pr` subcommand.
2//!
3//! Provides pull request listing, viewing, and management commands.
4//! Currently supports `gor pr list` for listing pull requests.
5
6#![allow(clippy::print_stdout, clippy::print_stderr)]
7
8use crate::cli::PrCommand;
9use crate::client::Client;
10use crate::output::{format_date, print_json};
11use crate::repository::{detect_remote, parse_repo_spec};
12use anyhow::Context;
13use gix::bstr::ByteSlice;
14use std::collections::BTreeMap;
15use std::io::Write;
16
17/// Run the `gor pr` subcommand.
18///
19/// # Errors
20///
21/// Returns an error if the command execution fails.
22pub fn run(cmd: PrCommand) -> anyhow::Result<()> {
23    match cmd {
24        PrCommand::List {
25            owner_repo,
26            state,
27            base,
28            head,
29            author,
30            labels,
31            assignee,
32            limit,
33            web,
34            json,
35            hostname,
36        } => list(
37            owner_repo,
38            &state,
39            base.as_deref(),
40            head.as_deref(),
41            author.as_deref(),
42            &labels,
43            assignee.as_deref(),
44            limit,
45            web,
46            json,
47            hostname.as_deref(),
48        ),
49        PrCommand::View {
50            number,
51            repo,
52            web,
53            comments,
54            json,
55            hostname,
56        } => view(
57            number,
58            repo.as_deref(),
59            web,
60            comments,
61            json,
62            hostname.as_deref(),
63        ),
64        PrCommand::Create {
65            repo,
66            title,
67            body,
68            base,
69            head,
70            draft,
71            labels,
72            assignee,
73            milestone,
74            project,
75            web,
76            hostname,
77        } => create(
78            repo.as_deref(),
79            title.as_deref(),
80            body.as_deref(),
81            base.as_deref(),
82            head.as_deref(),
83            draft,
84            &labels,
85            &assignee,
86            milestone.as_deref(),
87            project,
88            web,
89            hostname.as_deref(),
90        ),
91        PrCommand::Close {
92            number,
93            repo,
94            comment,
95            hostname,
96        } => close(
97            number,
98            repo.as_deref(),
99            comment.as_deref(),
100            hostname.as_deref(),
101        ),
102        PrCommand::Reopen {
103            number,
104            repo,
105            comment,
106            hostname,
107        } => reopen(
108            number,
109            repo.as_deref(),
110            comment.as_deref(),
111            hostname.as_deref(),
112        ),
113        PrCommand::Comment {
114            number,
115            repo,
116            body,
117            body_file,
118            web,
119            hostname,
120        } => pr_comment(
121            number,
122            repo.as_deref(),
123            body.as_deref(),
124            body_file.as_deref(),
125            web,
126            hostname.as_deref(),
127        ),
128        PrCommand::Merge {
129            number,
130            repo,
131            merge,
132            squash,
133            rebase,
134            body,
135            subject,
136            delete_branch,
137            admin,
138            auto,
139            hostname,
140        } => pr_merge(
141            number,
142            repo.as_deref(),
143            merge,
144            squash,
145            rebase,
146            body.as_deref(),
147            subject.as_deref(),
148            delete_branch,
149            admin,
150            auto,
151            hostname.as_deref(),
152        ),
153        PrCommand::Checkout {
154            number,
155            repo,
156            branch,
157            recurse_submodules,
158            hostname,
159        } => pr_checkout(
160            number,
161            repo.as_deref(),
162            branch.as_deref(),
163            recurse_submodules,
164            hostname.as_deref(),
165        ),
166        PrCommand::Diff {
167            number,
168            repo,
169            color,
170            name_only,
171            hostname,
172        } => diff(
173            number,
174            repo.as_deref(),
175            &color,
176            name_only,
177            hostname.as_deref(),
178        ),
179        PrCommand::Edit {
180            number,
181            repo,
182            title,
183            body,
184            base,
185            add_label,
186            remove_label,
187            add_assignee,
188            remove_assignee,
189            milestone,
190            hostname,
191        } => pr_edit(
192            number,
193            repo.as_deref(),
194            title.as_deref(),
195            body.as_deref(),
196            base.as_deref(),
197            &add_label,
198            &remove_label,
199            &add_assignee,
200            &remove_assignee,
201            milestone.as_deref(),
202            hostname.as_deref(),
203        ),
204        PrCommand::Review {
205            number,
206            repo,
207            approve,
208            request_changes,
209            comment,
210            body,
211            hostname,
212        } => review(
213            number,
214            repo.as_deref(),
215            approve,
216            request_changes,
217            comment,
218            body.as_deref(),
219            hostname.as_deref(),
220        ),
221        PrCommand::Checks {
222            number,
223            repo,
224            watch,
225            json,
226            hostname,
227        } => checks(number, repo.as_deref(), watch, json, hostname.as_deref()),
228        PrCommand::Ready {
229            number,
230            repo,
231            hostname,
232        } => ready(number, repo.as_deref(), hostname.as_deref()),
233    }
234}
235
236/// Execute `gor pr list`.
237///
238/// Lists pull requests for a repository with filtering by state, base branch,
239/// head branch, author, labels, and assignee. Supports table output, JSON
240/// output, and opening the PR list in a browser.
241///
242/// # Errors
243///
244/// Returns an error if the repository cannot be found or the API request fails.
245#[allow(clippy::too_many_arguments)]
246fn list(
247    owner_repo: Option<String>,
248    state: &str,
249    base: Option<&str>,
250    head: Option<&str>,
251    author: Option<&str>,
252    labels: &[String],
253    assignee: Option<&str>,
254    limit: u32,
255    web: bool,
256    json: Option<Vec<String>>,
257    hostname: Option<&str>,
258) -> anyhow::Result<()> {
259    // Resolve the repo spec
260    let spec = match owner_repo {
261        Some(s) => parse_repo_spec(&s).context("invalid repository spec")?,
262        None => detect_remote().ok_or_else(|| {
263            anyhow::anyhow!(
264                "could not detect repository from current directory; specify OWNER/REPO"
265            )
266        })?,
267    };
268
269    let host = hostname.unwrap_or("github.com");
270
271    // Handle --web flag: open in browser
272    if web {
273        let web_url = format!("https://{host}/{}/{}/pulls", spec.owner, spec.repo);
274        open_in_browser(&web_url);
275        return Ok(());
276    }
277
278    let client = Client::new(host).context("failed to create HTTP client")?;
279
280    // Build query parameters for the API call
281    // The GitHub API doesn't support "merged" as a state value; we use "all"
282    // and filter client-side for merged PRs.
283    let needs_merged_filter = state == "merged";
284    let api_state = if needs_merged_filter { "all" } else { state };
285
286    let mut query_params = vec![
287        ("state", api_state.to_string()),
288        ("per_page", limit.min(100).to_string()),
289    ];
290
291    if let Some(b) = base {
292        query_params.push(("base", (*b).to_string()));
293    }
294    if let Some(h) = head {
295        query_params.push(("head", (*h).to_string()));
296    }
297
298    let query_string = query_params
299        .iter()
300        .map(|(k, v)| format!("{k}={v}"))
301        .collect::<Vec<_>>()
302        .join("&");
303
304    let path = format!("/repos/{}/{}/pulls?{query_string}", spec.owner, spec.repo);
305
306    let response = client.get(&path).context("failed to fetch pull requests")?;
307
308    let status = response.status();
309    if status == reqwest::StatusCode::NOT_FOUND {
310        anyhow::bail!("repository '{spec}' not found");
311    }
312    if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
313        anyhow::bail!("authentication required to list pull requests for '{spec}'");
314    }
315    if !status.is_success() {
316        anyhow::bail!("failed to list pull requests for '{spec}': HTTP {status}");
317    }
318
319    let mut prs: Vec<serde_json::Value> = response
320        .json()
321        .context("failed to parse pull request response")?;
322
323    // Client-side filtering
324    if needs_merged_filter {
325        prs.retain(|pr| pr["merged_at"].as_str().is_some());
326    }
327    if let Some(a) = author {
328        prs.retain(|pr| {
329            pr["user"]["login"]
330                .as_str()
331                .is_some_and(|login| login.eq_ignore_ascii_case(a))
332        });
333    }
334    if !labels.is_empty() {
335        prs.retain(|pr| {
336            let pr_labels: Vec<&str> = pr["labels"]
337                .as_array()
338                .map(|arr| arr.iter().filter_map(|l| l["name"].as_str()).collect())
339                .unwrap_or_default();
340            labels
341                .iter()
342                .all(|label| pr_labels.iter().any(|l| l.eq_ignore_ascii_case(label)))
343        });
344    }
345    if let Some(a) = assignee {
346        prs.retain(|pr| {
347            pr["assignees"].as_array().is_some_and(|arr| {
348                arr.iter().any(|assignee| {
349                    assignee["login"]
350                        .as_str()
351                        .is_some_and(|login| login.eq_ignore_ascii_case(a))
352                })
353            })
354        });
355    }
356
357    // Handle --json flag
358    if let Some(fields) = json {
359        let fields_ref: Option<&[String]> = if fields.is_empty() {
360            None
361        } else {
362            Some(&fields)
363        };
364        print_json(&prs, fields_ref);
365        return Ok(());
366    }
367
368    // Default: print formatted table
369    print_pr_table(&prs);
370    Ok(())
371}
372
373/// Execute `gor pr view`.
374///
375/// Displays the full details of a single pull request, including title, body,
376/// author, state, branch information, labels, review status, merge status, and
377/// CI check status. Supports JSON output and opening the PR in a browser.
378///
379/// # Errors
380///
381/// Returns an error if the repository cannot be found, the PR does not exist,
382/// or the API request fails.
383#[allow(clippy::too_many_arguments)]
384fn view(
385    number: u64,
386    repo: Option<&str>,
387    web: bool,
388    comments: bool,
389    json: Option<Vec<String>>,
390    hostname: Option<&str>,
391) -> anyhow::Result<()> {
392    // Resolve the repo spec
393    let spec = match repo {
394        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
395        None => detect_remote().ok_or_else(|| {
396            anyhow::anyhow!(
397                "could not detect repository from current directory; specify OWNER/REPO"
398            )
399        })?,
400    };
401
402    let host = hostname.unwrap_or("github.com");
403
404    // Handle --web flag: open in browser
405    if web {
406        let web_url = format!("https://{host}/{}/{}/pull/{number}", spec.owner, spec.repo);
407        open_in_browser(&web_url);
408        return Ok(());
409    }
410
411    let client = Client::new(host).context("failed to create HTTP client")?;
412
413    // Fetch the PR details
414    let path = format!("/repos/{}/{}/pulls/{number}", spec.owner, spec.repo);
415    let response = client.get(&path).context("failed to fetch pull request")?;
416
417    let status = response.status();
418    if status == reqwest::StatusCode::NOT_FOUND {
419        anyhow::bail!("pull request #{number} not found in '{spec}'");
420    }
421    if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
422        anyhow::bail!("authentication required to view pull request #{number}");
423    }
424    if !status.is_success() {
425        anyhow::bail!("failed to view pull request #{number}: HTTP {status}");
426    }
427
428    let pr: serde_json::Value = response
429        .json()
430        .context("failed to parse pull request response")?;
431
432    // Handle --json flag
433    if let Some(fields) = json {
434        let fields_ref: Option<&[String]> = if fields.is_empty() {
435            None
436        } else {
437            Some(&fields)
438        };
439        print_json(&pr, fields_ref);
440        return Ok(());
441    }
442
443    // Fetch reviews for review status
444    let reviews_path = format!("/repos/{}/{}/pulls/{number}/reviews", spec.owner, spec.repo);
445    let reviews: Vec<serde_json::Value> = client
446        .get(&reviews_path)
447        .ok()
448        .and_then(|r| r.json().ok())
449        .unwrap_or_default();
450
451    // Fetch CI check status
452    let head_sha = pr["head"]["sha"].as_str().unwrap_or("");
453    let ci_status = if head_sha.is_empty() {
454        None
455    } else {
456        let status_path = format!(
457            "/repos/{}/{}/commits/{head_sha}/status",
458            spec.owner, spec.repo
459        );
460        client.get(&status_path).ok().and_then(|r| r.json().ok())
461    };
462
463    // Fetch comments if --comments flag is set
464    let comments_data = if comments {
465        let comments_path = format!(
466            "/repos/{}/{}/issues/{number}/comments",
467            spec.owner, spec.repo
468        );
469        client
470            .get(&comments_path)
471            .ok()
472            .and_then(|r| r.json().ok())
473            .unwrap_or_default()
474    } else {
475        Vec::<serde_json::Value>::new()
476    };
477
478    // Print formatted view
479    print_pr_view(&pr, &reviews, ci_status.as_ref(), &comments_data);
480    Ok(())
481}
482
483/// Execute `gor pr create`.
484///
485/// Creates a pull request from the current branch to the base branch.
486/// Auto-detects the head branch from the current git branch and the base
487/// branch from the repository's default branch. Supports draft PRs, labels,
488/// assignees, milestones, and project board assignment.
489///
490/// # Errors
491///
492/// Returns an error if the repository cannot be found, the PR creation fails,
493/// or required fields are missing.
494#[allow(clippy::too_many_arguments)]
495fn create(
496    repo: Option<&str>,
497    title: Option<&str>,
498    body: Option<&str>,
499    base: Option<&str>,
500    head: Option<&str>,
501    draft: bool,
502    labels: &[String],
503    assignees: &[String],
504    _milestone: Option<&str>,
505    _project: Option<u32>,
506    web: bool,
507    hostname: Option<&str>,
508) -> anyhow::Result<()> {
509    // Resolve the repo spec
510    let spec = match repo {
511        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
512        None => detect_remote().ok_or_else(|| {
513            anyhow::anyhow!(
514                "could not detect repository from current directory; specify OWNER/REPO with --repo"
515            )
516        })?,
517    };
518
519    let host = hostname.unwrap_or("github.com");
520    let client = Client::new(host).context("failed to create HTTP client")?;
521
522    // Auto-detect head branch from current git branch if not specified
523    let head_branch = if let Some(b) = head {
524        b.to_string()
525    } else {
526        let repo =
527            gix::discover(std::env::current_dir().context("failed to get current directory")?)
528                .context("failed to discover git repository")?;
529        let head_ref = repo.head().context("failed to get HEAD")?;
530        head_ref
531            .name()
532            .shorten()
533            .to_str()
534            .context("branch name is not valid UTF-8")?
535            .to_string()
536    };
537
538    // Auto-detect base branch from repo's default branch if not specified
539    let base_branch = if let Some(b) = base {
540        b.to_string()
541    } else {
542        let path = format!("/repos/{}/{}", spec.owner, spec.repo);
543        let response = client
544            .get(&path)
545            .context("failed to fetch repository data")?;
546        let status = response.status();
547        if !status.is_success() {
548            anyhow::bail!("failed to fetch repository '{spec}': HTTP {status}");
549        }
550        let repo_data: serde_json::Value = response
551            .json()
552            .context("failed to parse repository response")?;
553        repo_data["default_branch"]
554            .as_str()
555            .ok_or_else(|| anyhow::anyhow!("could not determine default branch"))?
556            .to_string()
557    };
558
559    // Build the request body
560    let mut body_map = serde_json::Map::new();
561    body_map.insert(
562        "title".to_string(),
563        serde_json::Value::String(
564            title
565                .ok_or_else(|| anyhow::anyhow!("PR title is required; use --title"))?
566                .to_string(),
567        ),
568    );
569    body_map.insert("head".to_string(), serde_json::Value::String(head_branch));
570    body_map.insert("base".to_string(), serde_json::Value::String(base_branch));
571    if let Some(b) = body {
572        body_map.insert("body".to_string(), serde_json::Value::String(b.to_string()));
573    }
574    if draft {
575        body_map.insert("draft".to_string(), serde_json::Value::Bool(true));
576    }
577
578    let body_value = serde_json::Value::Object(body_map);
579
580    // Create the PR
581    let path = format!("/repos/{}/{}/pulls", spec.owner, spec.repo);
582    let response = client
583        .post(&path, &body_value)
584        .context("failed to create pull request")?;
585
586    let status = response.status();
587    if status == reqwest::StatusCode::NOT_FOUND {
588        anyhow::bail!("repository '{spec}' not found");
589    }
590    if status == reqwest::StatusCode::UNPROCESSABLE_ENTITY {
591        let err_body: serde_json::Value = response.json().unwrap_or_default();
592        let msg = err_body["message"].as_str().unwrap_or("validation failed");
593        anyhow::bail!("failed to create pull request: {msg}");
594    }
595    if !status.is_success() {
596        anyhow::bail!("failed to create pull request: HTTP {status}");
597    }
598
599    let pr: serde_json::Value = response
600        .json()
601        .context("failed to parse pull request response")?;
602
603    let pr_number = pr["number"].as_u64().unwrap_or(0);
604    let pr_url = pr["html_url"].as_str().unwrap_or("");
605
606    // Handle --web flag: open in browser
607    if web && !pr_url.is_empty() {
608        open_in_browser(pr_url);
609    }
610
611    // Print success message
612    println!(
613        "https://github.com/{}/{}/pull/{pr_number}",
614        spec.owner, spec.repo
615    );
616
617    // Add labels if specified
618    if !labels.is_empty() {
619        let labels_path = format!(
620            "/repos/{}/{}/issues/{pr_number}/labels",
621            spec.owner, spec.repo
622        );
623        let labels_body = serde_json::json!({"labels": labels});
624        if let Err(e) = client.post(&labels_path, &labels_body) {
625            eprintln!("Warning: failed to add labels: {e}");
626        }
627    }
628
629    // Add assignees if specified
630    if !assignees.is_empty() {
631        let assignees_path = format!(
632            "/repos/{}/{}/issues/{pr_number}/assignees",
633            spec.owner, spec.repo
634        );
635        let assignees_body = serde_json::json!({"assignees": assignees});
636        if let Err(e) = client.post(&assignees_path, &assignees_body) {
637            eprintln!("Warning: failed to add assignees: {e}");
638        }
639    }
640
641    Ok(())
642}
643
644/// Execute `gor pr close`.
645///
646/// Closes a pull request by its number. Optionally adds a closing comment.
647///
648/// # Errors
649///
650/// Returns an error if the repository cannot be found, the PR does not exist,
651/// or the API request fails.
652fn close(
653    number: u64,
654    repo: Option<&str>,
655    comment: Option<&str>,
656    hostname: Option<&str>,
657) -> anyhow::Result<()> {
658    // Resolve the repo spec
659    let spec = match repo {
660        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
661        None => detect_remote().ok_or_else(|| {
662            anyhow::anyhow!(
663                "could not detect repository from current directory; specify OWNER/REPO with --repo"
664            )
665        })?,
666    };
667
668    let host = hostname.unwrap_or("github.com");
669    let client = Client::new(host).context("failed to create HTTP client")?;
670
671    // Add a closing comment if specified
672    if let Some(body) = comment {
673        let comment_path = format!(
674            "/repos/{}/{}/issues/{number}/comments",
675            spec.owner, spec.repo
676        );
677        let comment_body = serde_json::json!({"body": body});
678        client
679            .post(&comment_path, &comment_body)
680            .context("failed to add closing comment")?;
681    }
682
683    // Close the PR by setting state to "closed"
684    let path = format!("/repos/{}/{}/pulls/{number}", spec.owner, spec.repo);
685    let body = serde_json::json!({"state": "closed"});
686    let response = client
687        .request(
688            "PATCH",
689            &path,
690            &[],
691            Some(serde_json::to_vec(&body).unwrap_or_default()),
692        )
693        .context("failed to close pull request")?;
694
695    let status = response.status();
696    if status == reqwest::StatusCode::NOT_FOUND {
697        anyhow::bail!("pull request #{number} not found in '{spec}'");
698    }
699    if !status.is_success() {
700        anyhow::bail!("failed to close pull request #{number}: HTTP {status}");
701    }
702
703    println!("Closed pull request #{number} in {spec}");
704    Ok(())
705}
706
707/// Execute `gor pr reopen`.
708///
709/// Reopens a closed pull request by its number. Optionally adds a comment.
710///
711/// # Errors
712///
713/// Returns an error if the repository cannot be found, the PR does not exist,
714/// or the API request fails.
715fn reopen(
716    number: u64,
717    repo: Option<&str>,
718    comment: Option<&str>,
719    hostname: Option<&str>,
720) -> anyhow::Result<()> {
721    // Resolve the repo spec
722    let spec = match repo {
723        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
724        None => detect_remote().ok_or_else(|| {
725            anyhow::anyhow!(
726                "could not detect repository from current directory; specify OWNER/REPO with --repo"
727            )
728        })?,
729    };
730
731    let host = hostname.unwrap_or("github.com");
732    let client = Client::new(host).context("failed to create HTTP client")?;
733
734    // Add a comment if specified
735    if let Some(body) = comment {
736        let comment_path = format!(
737            "/repos/{}/{}/issues/{number}/comments",
738            spec.owner, spec.repo
739        );
740        let comment_body = serde_json::json!({"body": body});
741        client
742            .post(&comment_path, &comment_body)
743            .context("failed to add comment")?;
744    }
745
746    // Reopen the PR by setting state to "open"
747    let path = format!("/repos/{}/{}/pulls/{number}", spec.owner, spec.repo);
748    let body = serde_json::json!({"state": "open"});
749    let response = client
750        .request(
751            "PATCH",
752            &path,
753            &[],
754            Some(serde_json::to_vec(&body).unwrap_or_default()),
755        )
756        .context("failed to reopen pull request")?;
757
758    let status = response.status();
759    if status == reqwest::StatusCode::NOT_FOUND {
760        anyhow::bail!("pull request #{number} not found in '{spec}'");
761    }
762    if !status.is_success() {
763        anyhow::bail!("failed to reopen pull request #{number}: HTTP {status}");
764    }
765
766    println!("Reopened pull request #{number} in {spec}");
767    Ok(())
768}
769
770/// Execute `gor pr comment`.
771///
772/// Adds a comment to a pull request's conversation thread.
773/// Supports markdown body text, reading from a file or stdin, and
774/// opening the PR in a browser after commenting.
775///
776/// # Errors
777///
778/// Returns an error if the repository cannot be found, the PR does not exist,
779/// or the API request fails.
780fn pr_comment(
781    number: u64,
782    repo: Option<&str>,
783    body: Option<&str>,
784    body_file: Option<&str>,
785    web: bool,
786    hostname: Option<&str>,
787) -> anyhow::Result<()> {
788    let spec = match repo {
789        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
790        None => detect_remote().ok_or_else(|| {
791            anyhow::anyhow!(
792                "could not detect repository from current directory; specify OWNER/REPO with --repo"
793            )
794        })?,
795    };
796
797    let host = hostname.unwrap_or("github.com");
798
799    // Handle --web flag: open in browser
800    if web {
801        let web_url = format!("https://{host}/{}/{}/pull/{number}", spec.owner, spec.repo);
802        open_in_browser(&web_url);
803        return Ok(());
804    }
805
806    // Resolve the comment body
807    let comment_body = match (body, body_file) {
808        (Some(b), None) => b.to_string(),
809        (None, Some(f)) => {
810            if f == "@-" {
811                let mut buf = String::new();
812                std::io::stdin()
813                    .read_line(&mut buf)
814                    .context("failed to read from stdin")?;
815                buf
816            } else {
817                std::fs::read_to_string(f)
818                    .with_context(|| format!("failed to read body file '{f}'"))?
819            }
820        }
821        (None, None) => anyhow::bail!("either --body or --body-file is required"),
822        (Some(_), Some(_)) => unreachable!(), // clap conflicts_with prevents this
823    };
824
825    let client = Client::new(host).context("failed to create HTTP client")?;
826
827    let path = format!(
828        "/repos/{}/{}/issues/{number}/comments",
829        spec.owner, spec.repo
830    );
831    let request_body = serde_json::json!({"body": comment_body});
832    let response = client
833        .request("POST", &path, &[], Some(serde_json::to_vec(&request_body)?))
834        .context("failed to post comment")?;
835
836    let status = response.status();
837    if status == reqwest::StatusCode::NOT_FOUND {
838        anyhow::bail!("pull request #{number} not found in '{spec}'");
839    }
840    if !status.is_success() {
841        anyhow::bail!("failed to comment on pull request #{number}: HTTP {status}");
842    }
843
844    let comment: serde_json::Value = response
845        .json()
846        .context("failed to parse comment response")?;
847
848    let comment_url = comment["html_url"].as_str().unwrap_or("");
849    println!("{comment_url}");
850
851    Ok(())
852}
853
854/// Execute `gor pr merge`.
855///
856/// Merges a pull request into its base branch. Supports merge commit, squash,
857/// and rebase strategies. Can delete the head branch after merging, bypass
858/// branch protection with admin privileges, and enable auto-merge.
859///
860/// # Errors
861///
862/// Returns an error if the repository cannot be found, the PR does not exist,
863/// the merge fails, or the API request fails.
864#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
865fn pr_merge(
866    number: u64,
867    repo: Option<&str>,
868    _merge: bool,
869    squash: bool,
870    rebase: bool,
871    body: Option<&str>,
872    subject: Option<&str>,
873    delete_branch: bool,
874    _admin: bool,
875    _auto: bool,
876    hostname: Option<&str>,
877) -> anyhow::Result<()> {
878    // Resolve the repo spec
879    let spec = match repo {
880        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
881        None => detect_remote().ok_or_else(|| {
882            anyhow::anyhow!(
883                "could not detect repository from current directory; specify OWNER/REPO with --repo"
884            )
885        })?,
886    };
887
888    let host = hostname.unwrap_or("github.com");
889    let client = Client::new(host).context("failed to create HTTP client")?;
890
891    // Determine merge method
892    let merge_method = if squash {
893        "squash"
894    } else if rebase {
895        "rebase"
896    } else {
897        "merge"
898    };
899
900    // Build the request body
901    let mut body_map = serde_json::Map::new();
902    body_map.insert(
903        "merge_method".to_string(),
904        serde_json::Value::String(merge_method.to_string()),
905    );
906    if let Some(s) = subject {
907        body_map.insert(
908            "commit_title".to_string(),
909            serde_json::Value::String(s.to_string()),
910        );
911    }
912    if let Some(b) = body {
913        body_map.insert(
914            "commit_message".to_string(),
915            serde_json::Value::String(b.to_string()),
916        );
917    }
918
919    let body_value = serde_json::Value::Object(body_map);
920
921    // Merge the PR
922    let path = format!("/repos/{}/{}/pulls/{number}/merge", spec.owner, spec.repo);
923    let response = client
924        .request("PUT", &path, &[], Some(serde_json::to_vec(&body_value)?))
925        .context("failed to merge pull request")?;
926
927    let status = response.status();
928    if status == reqwest::StatusCode::NOT_FOUND {
929        anyhow::bail!("pull request #{number} not found in '{spec}'");
930    }
931    if status == reqwest::StatusCode::METHOD_NOT_ALLOWED {
932        anyhow::bail!("pull request #{number} cannot be merged");
933    }
934    if !status.is_success() {
935        let err_body: serde_json::Value = response.json().unwrap_or_default();
936        let msg = err_body["message"].as_str().unwrap_or("merge failed");
937        anyhow::bail!("failed to merge pull request #{number}: {msg}");
938    }
939
940    let result: serde_json::Value = response.json().context("failed to parse merge response")?;
941
942    let sha = result["sha"].as_str().unwrap_or("");
943    let merged = result["merged"].as_bool().unwrap_or(false);
944
945    if merged {
946        println!("Merged pull request #{number} in {spec} (SHA: {sha})");
947    } else {
948        let msg = result["message"].as_str().unwrap_or("unknown reason");
949        anyhow::bail!("failed to merge pull request #{number}: {msg}");
950    }
951
952    // Delete the head branch if requested
953    if delete_branch {
954        // First, get the PR details to find the head branch ref
955        let pr_path = format!("/repos/{}/{}/pulls/{number}", spec.owner, spec.repo);
956        let pr_response = client
957            .get(&pr_path)
958            .context("failed to fetch pull request details")?;
959        if let Ok(pr_data) = pr_response.json::<serde_json::Value>() {
960            if let (Some(ref_name), Some(repo_name)) = (
961                pr_data["head"]["ref"].as_str(),
962                pr_data["head"]["repo"]["full_name"].as_str(),
963            ) {
964                // Only delete if the head branch is in the same repo
965                if repo_name == spec.to_string() {
966                    let delete_path = format!(
967                        "/repos/{}/{}/git/refs/heads/{ref_name}",
968                        spec.owner, spec.repo
969                    );
970                    if let Err(e) = client.request("DELETE", &delete_path, &[], None) {
971                        eprintln!("Warning: failed to delete head branch '{ref_name}': {e}");
972                    } else {
973                        println!("Deleted head branch '{ref_name}'");
974                    }
975                }
976            }
977        }
978    }
979
980    Ok(())
981}
982
983/// Execute `gor pr checkout`.
984///
985/// Fetches and checks out a pull request's head branch locally.
986/// Adds the remote if not already present. Supports custom local branch
987/// names.
988///
989/// # Errors
990///
991/// Returns an error if the repository cannot be found, the PR does not exist,
992/// or the checkout fails.
993fn pr_checkout(
994    number: u64,
995    repo: Option<&str>,
996    branch: Option<&str>,
997    _recurse_submodules: bool,
998    hostname: Option<&str>,
999) -> anyhow::Result<()> {
1000    // Resolve the repo spec
1001    let spec = match repo {
1002        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
1003        None => detect_remote().ok_or_else(|| {
1004            anyhow::anyhow!(
1005                "could not detect repository from current directory; specify OWNER/REPO with --repo"
1006            )
1007        })?,
1008    };
1009
1010    let host = hostname.unwrap_or("github.com");
1011    let client = Client::new(host).context("failed to create HTTP client")?;
1012
1013    // Fetch PR details to get head branch info
1014    let path = format!("/repos/{}/{}/pulls/{number}", spec.owner, spec.repo);
1015    let response = client.get(&path).context("failed to fetch pull request")?;
1016
1017    let status = response.status();
1018    if status == reqwest::StatusCode::NOT_FOUND {
1019        anyhow::bail!("pull request #{number} not found in '{spec}'");
1020    }
1021    if !status.is_success() {
1022        anyhow::bail!("failed to fetch pull request #{number}: HTTP {status}");
1023    }
1024
1025    let pr: serde_json::Value = response
1026        .json()
1027        .context("failed to parse pull request response")?;
1028
1029    let head_ref = pr["head"]["ref"]
1030        .as_str()
1031        .ok_or_else(|| anyhow::anyhow!("could not determine head branch"))?;
1032    let _head_sha = pr["head"]["sha"]
1033        .as_str()
1034        .ok_or_else(|| anyhow::anyhow!("could not determine head SHA"))?;
1035    let head_repo_full_name = pr["head"]["repo"]["full_name"].as_str();
1036    let head_clone_url = pr["head"]["repo"]["clone_url"].as_str();
1037
1038    // Determine the local branch name
1039    let local_branch = branch.unwrap_or(head_ref);
1040
1041    // Open the local git repo
1042    let local_repo =
1043        gix::discover(std::env::current_dir().context("failed to get current directory")?)
1044            .context("failed to discover git repository")?;
1045
1046    // Determine the remote name to use
1047    let remote_name = if head_repo_full_name.is_some()
1048        && head_repo_full_name != Some(spec.to_string().as_str())
1049    {
1050        // PR is from a fork — use the fork owner as remote name
1051        let fork_owner = head_repo_full_name
1052            .and_then(|n| n.split('/').next())
1053            .unwrap_or("fork");
1054
1055        // Check if the remote already exists
1056        let remote_exists = local_repo.find_remote(fork_owner).is_ok();
1057
1058        if !remote_exists {
1059            if let Some(clone_url) = head_clone_url {
1060                eprintln!("Adding remote '{fork_owner}' -> {clone_url}");
1061                let config_path = local_repo.git_dir().join("config");
1062                let mut file = std::fs::OpenOptions::new()
1063                    .append(true)
1064                    .open(&config_path)
1065                    .context("failed to open git config")?;
1066                let url_escaped = clone_url.replace('"', "\\\"");
1067                writeln!(
1068                    file,
1069                    "[remote \"{fork_owner}\"]\n\turl = {url_escaped}\n\tfetch = +refs/heads/*:refs/remotes/{fork_owner}/*"
1070                )
1071                .context("failed to write remote config")?;
1072            }
1073        }
1074
1075        fork_owner.to_string()
1076    } else {
1077        // PR is from the same repo — use origin
1078        "origin".to_string()
1079    };
1080
1081    // Fetch the PR head branch using gix
1082    eprintln!("Fetching remote '{remote_name}' with branch '{head_ref}'...");
1083
1084    let workdir = local_repo
1085        .workdir()
1086        .ok_or_else(|| anyhow::anyhow!("bare repository cannot check out"))?;
1087    let workdir_str = workdir.to_str().unwrap_or(".");
1088
1089    // Fetch the specific ref from the remote using system git
1090    let fetch_status = std::process::Command::new("git")
1091        .args([
1092            "-C",
1093            workdir_str,
1094            "fetch",
1095            &remote_name,
1096            &format!("+refs/heads/{head_ref}:refs/remotes/{remote_name}/{head_ref}"),
1097        ])
1098        .status()
1099        .context("failed to run git fetch")?;
1100
1101    if !fetch_status.success() {
1102        anyhow::bail!("failed to fetch from remote '{remote_name}'");
1103    }
1104
1105    // Create or update the local branch and check it out
1106    eprintln!("Checking out '{local_branch}'...");
1107
1108    let checkout_status = std::process::Command::new("git")
1109        .args([
1110            "-C",
1111            workdir_str,
1112            "checkout",
1113            "-B",
1114            local_branch,
1115            &format!("refs/remotes/{remote_name}/{head_ref}"),
1116        ])
1117        .status()
1118        .context("failed to run git checkout")?;
1119
1120    if !checkout_status.success() {
1121        anyhow::bail!("failed to checkout branch '{local_branch}'");
1122    }
1123
1124    println!("Checked out PR #{number} as '{local_branch}'");
1125    Ok(())
1126}
1127
1128/// Print a formatted pull request detail view.
1129///
1130/// Displays title, metadata, body, review status, merge status, CI checks,
1131/// and optionally comments.
1132fn print_pr_view(
1133    pr: &serde_json::Value,
1134    reviews: &[serde_json::Value],
1135    ci_status: Option<&serde_json::Value>,
1136    comments: &[serde_json::Value],
1137) {
1138    // Title
1139    let title = pr["title"].as_str().unwrap_or("(no title)");
1140    println!("{title}");
1141    let separator_len = title.len().min(80);
1142    println!("{}", "─".repeat(separator_len));
1143    println!();
1144
1145    // Metadata
1146    let state = pr["state"].as_str().unwrap_or("unknown");
1147    let display_state = if pr["merged_at"].as_str().is_some() {
1148        "merged"
1149    } else {
1150        state
1151    };
1152    let author = pr["user"]["login"].as_str().unwrap_or("unknown");
1153    let created = pr["created_at"]
1154        .as_str()
1155        .map_or_else(|| "—".to_string(), format_date);
1156    let updated = pr["updated_at"]
1157        .as_str()
1158        .map_or_else(|| "—".to_string(), format_date);
1159    let base_branch = pr["base"]["ref"].as_str().unwrap_or("?");
1160    let head_branch = pr["head"]["ref"].as_str().unwrap_or("?");
1161
1162    let labels_str = pr["labels"]
1163        .as_array()
1164        .map(|arr| {
1165            arr.iter()
1166                .filter_map(|l| l["name"].as_str())
1167                .collect::<Vec<_>>()
1168                .join(", ")
1169        })
1170        .unwrap_or_default();
1171
1172    println!("State:  {display_state}");
1173    println!("Author: {author}");
1174    println!("Created: {created}");
1175    println!("Updated: {updated}");
1176    println!("Branches: {base_branch} ← {head_branch}");
1177    if !labels_str.is_empty() {
1178        println!("Labels: {labels_str}");
1179    }
1180    println!();
1181
1182    // Body
1183    let body = pr["body"].as_str().unwrap_or("");
1184    if !body.is_empty() {
1185        println!("{body}");
1186        println!();
1187    }
1188
1189    // Review status
1190    print_review_status(reviews);
1191
1192    // Merge status
1193    print_merge_status(pr);
1194
1195    // CI checks
1196    print_ci_status(ci_status);
1197
1198    // Comments
1199    if !comments.is_empty() {
1200        println!("── Comments ──");
1201        println!();
1202        for comment in comments {
1203            let comment_author = comment["user"]["login"].as_str().unwrap_or("unknown");
1204            let comment_date = comment["created_at"]
1205                .as_str()
1206                .map_or_else(|| "—".to_string(), format_date);
1207            let comment_body = comment["body"].as_str().unwrap_or("");
1208            println!("{comment_author} commented on {comment_date}");
1209            println!();
1210            println!("{comment_body}");
1211            println!();
1212        }
1213    }
1214}
1215
1216/// Print the review status section.
1217fn print_review_status(reviews: &[serde_json::Value]) {
1218    // Aggregate the latest review state per reviewer
1219    // Reviews are ordered oldest-first; we want the latest per user
1220    let mut latest_state: BTreeMap<&str, &str> = BTreeMap::new();
1221    for review in reviews {
1222        let user = review["user"]["login"].as_str();
1223        let state_val = review["state"].as_str();
1224        if let (Some(u), Some(s)) = (user, state_val) {
1225            if s != "COMMENTED" && s != "DISMISSED" {
1226                // APPROVED or CHANGES_REQUESTED override previous
1227                latest_state.insert(u, s);
1228            } else if s == "COMMENTED" && !latest_state.contains_key(u) {
1229                // Only set COMMENTED if no approval/change request yet
1230                latest_state.insert(u, s);
1231            }
1232        }
1233    }
1234
1235    if latest_state.is_empty() {
1236        return;
1237    }
1238
1239    println!("── Review Status ──");
1240
1241    let mut approved: Vec<&str> = Vec::new();
1242    let mut changes_requested: Vec<&str> = Vec::new();
1243    let mut commented: Vec<&str> = Vec::new();
1244
1245    for (user, state_val) in &latest_state {
1246        match *state_val {
1247            "APPROVED" => approved.push(user),
1248            "CHANGES_REQUESTED" => changes_requested.push(user),
1249            _ => commented.push(user),
1250        }
1251    }
1252
1253    if !approved.is_empty() {
1254        println!("Approved by: {}", approved.join(", "));
1255    }
1256    if !changes_requested.is_empty() {
1257        println!("Changes requested by: {}", changes_requested.join(", "));
1258    }
1259    if !commented.is_empty() {
1260        println!("Commented by: {}", commented.join(", "));
1261    }
1262
1263    println!();
1264}
1265
1266/// Print the merge status section.
1267fn print_merge_status(pr: &serde_json::Value) {
1268    println!("── Merge Status ──");
1269
1270    let mergeable = pr["mergeable"].as_bool();
1271    let mergeable_status = match mergeable {
1272        Some(true) => "yes",
1273        Some(false) => "no (conflicts)",
1274        None => "unknown (checking)",
1275    };
1276    println!("Mergeable: {mergeable_status}");
1277
1278    if let Some(merged_at) = pr["merged_at"].as_str() {
1279        let merged_date = format_date(merged_at);
1280        let merged_by = pr["merged_by"]["login"].as_str().unwrap_or("unknown");
1281        println!("Merged: yes ({merged_date}) by {merged_by}");
1282    } else {
1283        println!("Merged: no");
1284    }
1285
1286    println!();
1287}
1288
1289/// Print the CI check status section.
1290fn print_ci_status(ci_status: Option<&serde_json::Value>) {
1291    let statuses = ci_status
1292        .and_then(|s| s["statuses"].as_array())
1293        .cloned()
1294        .unwrap_or_default();
1295
1296    if statuses.is_empty() {
1297        return;
1298    }
1299
1300    println!("── CI Checks ──");
1301
1302    for check in &statuses {
1303        let name = check["context"].as_str().unwrap_or("?");
1304        let state_val = check["state"].as_str().unwrap_or("unknown");
1305        let (icon, display_state) = match state_val {
1306            "success" => ("✓", "success"),
1307            "failure" => ("✗", "failure"),
1308            "pending" => ("○", "pending"),
1309            _ => ("○", state_val),
1310        };
1311        println!("  {icon} {name} ({display_state})");
1312    }
1313
1314    println!();
1315}
1316
1317/// Print a formatted pull request list table.
1318///
1319/// Columns: NUMBER, TITLE, AUTHOR, HEAD BRANCH, LABELS, STATE
1320fn print_pr_table(prs: &[serde_json::Value]) {
1321    if prs.is_empty() {
1322        println!("No pull requests found.");
1323        return;
1324    }
1325
1326    // Column widths
1327    let num_width = 8;
1328    let title_width = 50;
1329    let author_width = 14;
1330    let branch_width = 14;
1331    let labels_width = 14;
1332    let state_width = 8;
1333
1334    // Header
1335    println!(
1336        "{:>num_width$}  {:<title_width$}  {:<author_width$}  {:<branch_width$}  {:<labels_width$}  {:<state_width$}",
1337        "NUMBER", "TITLE", "AUTHOR", "HEAD BRANCH", "LABELS", "STATE",
1338    );
1339
1340    for pr in prs {
1341        let number = pr["number"]
1342            .as_u64()
1343            .map_or_else(|| "—".to_string(), |n| n.to_string());
1344        let title = pr["title"].as_str().unwrap_or("—");
1345        let author = pr["user"]["login"].as_str().unwrap_or("—");
1346        let head_branch = pr["head"]["ref"].as_str().unwrap_or("—");
1347        let state = pr["state"].as_str().unwrap_or("—");
1348
1349        // Determine display state: if merged_at is set, show "merged"
1350        let display_state = if pr["merged_at"].as_str().is_some() {
1351            "merged"
1352        } else {
1353            state
1354        };
1355
1356        let labels_str = pr["labels"]
1357            .as_array()
1358            .map(|arr| {
1359                arr.iter()
1360                    .filter_map(|l| l["name"].as_str())
1361                    .collect::<Vec<_>>()
1362                    .join(", ")
1363            })
1364            .unwrap_or_default();
1365        let labels_display = if labels_str.is_empty() {
1366            "—".to_string()
1367        } else {
1368            labels_str
1369        };
1370
1371        let title_truncated = crate::cmd::util::truncate(title, title_width);
1372        let author_truncated = crate::cmd::util::truncate(author, author_width);
1373        let branch_truncated = crate::cmd::util::truncate(head_branch, branch_width);
1374        let labels_truncated = crate::cmd::util::truncate(&labels_display, labels_width);
1375
1376        println!(
1377            "{number:>num_width$}  {title_truncated:<title_width$}  {author_truncated:<author_width$}  {branch_truncated:<branch_width$}  {labels_truncated:<labels_width$}  {display_state:<state_width$}",
1378        );
1379    }
1380}
1381
1382/// Open a URL in the default browser using the system's default handler.
1383fn open_in_browser(url: &str) {
1384    #[cfg(target_os = "linux")]
1385    {
1386        let _ = std::process::Command::new("xdg-open").arg(url).spawn();
1387    }
1388    #[cfg(target_os = "macos")]
1389    {
1390        let _ = std::process::Command::new("open").arg(url).spawn();
1391    }
1392    #[cfg(target_os = "windows")]
1393    {
1394        let _ = std::process::Command::new("cmd")
1395            .args(["/c", "start", url])
1396            .spawn();
1397    }
1398    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1399    {
1400        println!("Open {url} in your browser");
1401    }
1402}
1403
1404/// Execute `gor pr diff`.
1405///
1406/// Shows the unified diff of a pull request. Supports color control and
1407/// name-only mode.
1408///
1409/// # Errors
1410///
1411/// Returns an error if the PR cannot be found or the API request fails.
1412fn diff(
1413    number: u64,
1414    repo: Option<&str>,
1415    _color: &str,
1416    name_only: bool,
1417    hostname: Option<&str>,
1418) -> anyhow::Result<()> {
1419    let spec = match repo {
1420        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
1421        None => detect_remote().ok_or_else(|| {
1422            anyhow::anyhow!(
1423                "could not detect repository from current directory; specify OWNER/REPO"
1424            )
1425        })?,
1426    };
1427
1428    let host = hostname.unwrap_or("github.com");
1429    let client = Client::new(host).context("failed to create HTTP client")?;
1430
1431    if name_only {
1432        // Fetch list of changed files
1433        let files_path = format!(
1434            "/repos/{}/{}/pulls/{number}/files?per_page=100",
1435            spec.owner, spec.repo
1436        );
1437        let resp = client
1438            .get(&files_path)
1439            .context("failed to fetch PR files")?;
1440        let status = resp.status();
1441        if status == reqwest::StatusCode::NOT_FOUND {
1442            anyhow::bail!("pull request #{number} not found in '{spec}'");
1443        }
1444        if !status.is_success() {
1445            anyhow::bail!("failed to fetch PR files: HTTP {status}");
1446        }
1447        let files: Vec<serde_json::Value> =
1448            resp.json().context("failed to parse files response")?;
1449        for file in &files {
1450            let filename = file["filename"].as_str().unwrap_or("—");
1451            let status_str = file["status"].as_str().unwrap_or("");
1452            let additions = file["additions"].as_u64().unwrap_or(0);
1453            let deletions = file["deletions"].as_u64().unwrap_or(0);
1454            println!("{status_str:8} +{additions:4} -{deletions:4}  {filename}");
1455        }
1456    } else {
1457        // Fetch the diff
1458        let diff_path = format!("/repos/{}/{}/pulls/{number}", spec.owner, spec.repo);
1459        let resp = client
1460            .request(
1461                "GET",
1462                &diff_path,
1463                &["Accept: application/vnd.github.v3.diff".to_string()],
1464                None,
1465            )
1466            .context("failed to fetch PR diff")?;
1467        let status = resp.status();
1468        if status == reqwest::StatusCode::NOT_FOUND {
1469            anyhow::bail!("pull request #{number} not found in '{spec}'");
1470        }
1471        if !status.is_success() {
1472            anyhow::bail!("failed to fetch PR diff: HTTP {status}");
1473        }
1474        let diff_text = resp.text().context("failed to read diff response")?;
1475        println!("{diff_text}");
1476    }
1477
1478    Ok(())
1479}
1480
1481/// Execute `gor pr edit`.
1482///
1483/// Edits a pull request's title, body, base branch, labels, assignees,
1484/// or milestone.
1485///
1486/// # Errors
1487///
1488/// Returns an error if the PR cannot be found or the API request fails.
1489#[allow(clippy::too_many_arguments)]
1490fn pr_edit(
1491    number: u64,
1492    repo: Option<&str>,
1493    title: Option<&str>,
1494    body: Option<&str>,
1495    base: Option<&str>,
1496    add_label: &[String],
1497    remove_label: &[String],
1498    add_assignee: &[String],
1499    remove_assignee: &[String],
1500    milestone: Option<&str>,
1501    hostname: Option<&str>,
1502) -> anyhow::Result<()> {
1503    let spec = match repo {
1504        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
1505        None => detect_remote().ok_or_else(|| {
1506            anyhow::anyhow!(
1507                "could not detect repository from current directory; specify OWNER/REPO"
1508            )
1509        })?,
1510    };
1511
1512    let host = hostname.unwrap_or("github.com");
1513    let client = Client::new(host).context("failed to create HTTP client")?;
1514
1515    let mut body_map = serde_json::Map::new();
1516    if let Some(t) = title {
1517        body_map.insert(
1518            "title".to_string(),
1519            serde_json::Value::String(t.to_string()),
1520        );
1521    }
1522    if let Some(b) = body {
1523        body_map.insert("body".to_string(), serde_json::Value::String(b.to_string()));
1524    }
1525    if let Some(b) = base {
1526        body_map.insert("base".to_string(), serde_json::Value::String(b.to_string()));
1527    }
1528    if let Some(m) = milestone {
1529        if let Ok(id) = m.parse::<u64>() {
1530            body_map.insert(
1531                "milestone".to_string(),
1532                serde_json::Value::Number(serde_json::Number::from(id)),
1533            );
1534        } else {
1535            body_map.insert(
1536                "milestone".to_string(),
1537                serde_json::Value::String(m.to_string()),
1538            );
1539        }
1540    }
1541
1542    // Handle labels: fetch current, add, remove
1543    if !add_label.is_empty() || !remove_label.is_empty() {
1544        let get_path = format!("/repos/{}/{}/pulls/{number}", spec.owner, spec.repo);
1545        let current: serde_json::Value = client
1546            .get(&get_path)
1547            .context("failed to fetch current PR")?
1548            .json()
1549            .context("failed to parse PR response")?;
1550        let current_labels: Vec<String> = current["labels"]
1551            .as_array()
1552            .map(|arr| {
1553                arr.iter()
1554                    .filter_map(|l| l["name"].as_str().map(String::from))
1555                    .collect()
1556            })
1557            .unwrap_or_default();
1558        let mut new_labels = current_labels;
1559        for label in add_label {
1560            if !new_labels.contains(label) {
1561                new_labels.push(label.clone());
1562            }
1563        }
1564        new_labels.retain(|l| !remove_label.contains(l));
1565        body_map.insert(
1566            "labels".to_string(),
1567            serde_json::Value::Array(
1568                new_labels
1569                    .iter()
1570                    .map(|l| serde_json::Value::String(l.clone()))
1571                    .collect(),
1572            ),
1573        );
1574    }
1575
1576    // Handle assignees
1577    if !add_assignee.is_empty() || !remove_assignee.is_empty() {
1578        let get_path = format!("/repos/{}/{}/pulls/{number}", spec.owner, spec.repo);
1579        let current: serde_json::Value = client
1580            .get(&get_path)
1581            .context("failed to fetch current PR")?
1582            .json()
1583            .context("failed to parse PR response")?;
1584        let current_assignees: Vec<String> = current["assignees"]
1585            .as_array()
1586            .map(|arr| {
1587                arr.iter()
1588                    .filter_map(|a| a["login"].as_str().map(String::from))
1589                    .collect()
1590            })
1591            .unwrap_or_default();
1592        let mut new_assignees = current_assignees;
1593        for a in add_assignee {
1594            if !new_assignees.contains(a) {
1595                new_assignees.push(a.clone());
1596            }
1597        }
1598        new_assignees.retain(|a| !remove_assignee.contains(a));
1599        body_map.insert(
1600            "assignees".to_string(),
1601            serde_json::Value::Array(
1602                new_assignees
1603                    .iter()
1604                    .map(|a| serde_json::Value::String(a.clone()))
1605                    .collect(),
1606            ),
1607        );
1608    }
1609
1610    if body_map.is_empty() {
1611        anyhow::bail!("no changes specified");
1612    }
1613
1614    let path = format!("/repos/{}/{}/pulls/{number}", spec.owner, spec.repo);
1615    let body_value = serde_json::Value::Object(body_map);
1616    let response = client
1617        .request("PATCH", &path, &[], Some(serde_json::to_vec(&body_value)?))
1618        .context("failed to edit PR")?;
1619
1620    let status = response.status();
1621    if status == reqwest::StatusCode::NOT_FOUND {
1622        anyhow::bail!("pull request #{number} not found in '{spec}'");
1623    }
1624    if !status.is_success() {
1625        let err_body: serde_json::Value = response.json().unwrap_or_default();
1626        let msg = err_body["message"].as_str().unwrap_or("edit failed");
1627        anyhow::bail!("failed to edit PR #{number}: {msg}");
1628    }
1629
1630    let pr: serde_json::Value = response.json().context("failed to parse response")?;
1631    let pr_number = pr["number"].as_u64().unwrap_or(number);
1632    let pr_title = pr["title"].as_str().unwrap_or("—");
1633    let pr_base = pr["base"]["ref"].as_str().unwrap_or("—");
1634    let pr_labels: Vec<&str> = pr["labels"]
1635        .as_array()
1636        .map(|arr| arr.iter().filter_map(|l| l["name"].as_str()).collect())
1637        .unwrap_or_default();
1638    let pr_assignees: Vec<&str> = pr["assignees"]
1639        .as_array()
1640        .map(|arr| arr.iter().filter_map(|a| a["login"].as_str()).collect())
1641        .unwrap_or_default();
1642    let pr_milestone = pr["milestone"]["title"].as_str().unwrap_or("—");
1643
1644    println!("✓ Updated PR #{pr_number}: {pr_title}");
1645    println!("  Base:      {pr_base}");
1646    println!("  Labels:    {}", pr_labels.join(", "));
1647    println!("  Assignees: {}", pr_assignees.join(", "));
1648    println!("  Milestone: {pr_milestone}");
1649    Ok(())
1650}
1651
1652/// Execute `gor pr review`.
1653///
1654/// Submits a review on a pull request with approve, request_changes,
1655/// or comment state.
1656///
1657/// # Errors
1658///
1659/// Returns an error if the PR cannot be found or the API request fails.
1660fn review(
1661    number: u64,
1662    repo: Option<&str>,
1663    approve: bool,
1664    request_changes: bool,
1665    _comment: bool,
1666    body: Option<&str>,
1667    hostname: Option<&str>,
1668) -> anyhow::Result<()> {
1669    let spec = match repo {
1670        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
1671        None => detect_remote().ok_or_else(|| {
1672            anyhow::anyhow!(
1673                "could not detect repository from current directory; specify OWNER/REPO"
1674            )
1675        })?,
1676    };
1677
1678    let host = hostname.unwrap_or("github.com");
1679    let client = Client::new(host).context("failed to create HTTP client")?;
1680
1681    let event = if approve {
1682        "APPROVE"
1683    } else if request_changes {
1684        "REQUEST_CHANGES"
1685    } else {
1686        "COMMENT"
1687    };
1688
1689    let mut body_map = serde_json::Map::new();
1690    body_map.insert(
1691        "event".to_string(),
1692        serde_json::Value::String(event.to_string()),
1693    );
1694    if let Some(b) = body {
1695        body_map.insert("body".to_string(), serde_json::Value::String(b.to_string()));
1696    }
1697
1698    let path = format!("/repos/{}/{}/pulls/{number}/reviews", spec.owner, spec.repo);
1699    let body_value = serde_json::Value::Object(body_map);
1700    let response = client
1701        .post(&path, &body_value)
1702        .context("failed to submit review")?;
1703
1704    let status = response.status();
1705    if status == reqwest::StatusCode::NOT_FOUND {
1706        anyhow::bail!("pull request #{number} not found in '{spec}'");
1707    }
1708    if !status.is_success() {
1709        let err_body: serde_json::Value = response.json().unwrap_or_default();
1710        let msg = err_body["message"].as_str().unwrap_or("review failed");
1711        anyhow::bail!("failed to submit review for PR #{number}: {msg}");
1712    }
1713
1714    let review: serde_json::Value = response.json().context("failed to parse response")?;
1715    let state = review["state"].as_str().unwrap_or(event);
1716    println!("✓ Submitted {state} review on PR #{number}");
1717    Ok(())
1718}
1719
1720/// Execute `gor pr checks`.
1721///
1722/// Shows CI check status for a pull request. Supports --watch for polling
1723/// and --json for structured output.
1724///
1725/// # Errors
1726///
1727/// Returns an error if the PR cannot be found or the API request fails.
1728#[allow(clippy::needless_pass_by_value)]
1729fn checks(
1730    number: u64,
1731    repo: Option<&str>,
1732    watch: bool,
1733    json: Option<Vec<String>>,
1734    hostname: Option<&str>,
1735) -> anyhow::Result<()> {
1736    let spec = match repo {
1737        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
1738        None => detect_remote().ok_or_else(|| {
1739            anyhow::anyhow!(
1740                "could not detect repository from current directory; specify OWNER/REPO"
1741            )
1742        })?,
1743    };
1744
1745    let host = hostname.unwrap_or("github.com");
1746    let client = Client::new(host).context("failed to create HTTP client")?;
1747
1748    // Fetch the PR to get the head SHA
1749    let pr_path = format!("/repos/{}/{}/pulls/{number}", spec.owner, spec.repo);
1750    let pr_resp = client.get(&pr_path).context("failed to fetch PR")?;
1751    let pr_status = pr_resp.status();
1752    if pr_status == reqwest::StatusCode::NOT_FOUND {
1753        anyhow::bail!("pull request #{number} not found in '{spec}'");
1754    }
1755    if !pr_status.is_success() {
1756        anyhow::bail!("failed to fetch PR #{number}: HTTP {pr_status}");
1757    }
1758    let pr: serde_json::Value = pr_resp.json().context("failed to parse PR response")?;
1759    let head_sha = pr["head"]["sha"].as_str().context("PR missing head SHA")?;
1760
1761    loop {
1762        let checks_path = format!(
1763            "/repos/{}/{}/commits/{head_sha}/check-runs?per_page=100",
1764            spec.owner, spec.repo
1765        );
1766        let resp = client
1767            .get(&checks_path)
1768            .context("failed to fetch check runs")?;
1769        let status = resp.status();
1770        if !status.is_success() {
1771            anyhow::bail!("failed to fetch checks: HTTP {status}");
1772        }
1773        let data: serde_json::Value = resp.json().context("failed to parse checks response")?;
1774        let check_runs = data["check_runs"]
1775            .as_array()
1776            .map_or(&[] as &[serde_json::Value], |a| a);
1777
1778        if let Some(ref fields) = json {
1779            let fields_ref: Option<&[String]> = if fields.is_empty() {
1780                None
1781            } else {
1782                Some(fields)
1783            };
1784            print_json(&check_runs, fields_ref);
1785            return Ok(());
1786        }
1787
1788        if check_runs.is_empty() {
1789            println!("No checks found for PR #{number}.");
1790            return Ok(());
1791        }
1792
1793        let mut passed = 0u32;
1794        let mut failed = 0u32;
1795        let mut pending = 0u32;
1796
1797        for check in check_runs {
1798            let name = check["name"].as_str().unwrap_or("—");
1799            let check_status = check["status"].as_str().unwrap_or("unknown");
1800            let conclusion = check["conclusion"].as_str().unwrap_or("pending");
1801            let details_url = check["html_url"].as_str().unwrap_or("—");
1802
1803            let icon = match (check_status, conclusion) {
1804                ("completed", "success" | "neutral") => {
1805                    passed += 1;
1806                    "✓"
1807                }
1808                ("completed", "failure" | "timed_out" | "cancelled" | "action_required") => {
1809                    failed += 1;
1810                    "✗"
1811                }
1812                _ => {
1813                    pending += 1;
1814                    "○"
1815                }
1816            };
1817
1818            println!("{icon} {name:<40} {check_status:<12} {conclusion:<16} {details_url}");
1819        }
1820
1821        println!();
1822        println!("{passed} passed, {failed} failed, {pending} pending");
1823
1824        if !watch || pending == 0 {
1825            if failed > 0 {
1826                std::process::exit(1);
1827            }
1828            return Ok(());
1829        }
1830
1831        std::thread::sleep(std::time::Duration::from_secs(5));
1832    }
1833}
1834
1835/// Execute `gor pr ready`.
1836///
1837/// Marks a draft pull request as ready for review.
1838///
1839/// # Errors
1840///
1841/// Returns an error if the PR does not exist or the API request fails.
1842fn ready(number: u64, repo: Option<&str>, hostname: Option<&str>) -> anyhow::Result<()> {
1843    let host = hostname.unwrap_or("github.com");
1844    let client = Client::new(host).context("failed to create HTTP client")?;
1845
1846    let spec = if let Some(r) = repo {
1847        parse_repo_spec(r).with_context(|| format!("invalid repository: {r}"))?
1848    } else {
1849        detect_remote().context("could not detect repository from git remote")?
1850    };
1851
1852    let path = format!("/repos/{}/{}/pulls/{number}", spec.owner, spec.repo);
1853
1854    // First, fetch the PR to check if it's a draft.
1855    let response = client.get(&path).context("failed to fetch PR")?;
1856    let status = response.status();
1857    if !status.is_success() {
1858        anyhow::bail!("failed to fetch PR #{number}: HTTP {status}");
1859    }
1860
1861    let pr: serde_json::Value = response.json().context("failed to parse PR response")?;
1862    let is_draft = pr["draft"].as_bool().unwrap_or(false);
1863
1864    if !is_draft {
1865        println!("PR #{number} is already ready for review.");
1866        return Ok(());
1867    }
1868
1869    // Mark the PR as ready by setting draft to false.
1870    let body = serde_json::json!({"draft": false});
1871    let body_bytes = serde_json::to_vec(&body).context("failed to serialize body")?;
1872    let update_response = client
1873        .request("PATCH", &path, &[], Some(body_bytes))
1874        .context("failed to update PR")?;
1875
1876    let update_status = update_response.status();
1877    if !update_status.is_success() {
1878        let err_body: serde_json::Value = update_response.json().unwrap_or_default();
1879        let msg = err_body["message"].as_str().unwrap_or("update failed");
1880        anyhow::bail!("failed to mark PR #{number} as ready: {msg}");
1881    }
1882
1883    println!("PR #{number} is now ready for review.");
1884    Ok(())
1885}
1886
1887#[cfg(test)]
1888#[allow(clippy::expect_used)]
1889mod tests {
1890    use super::*;
1891    use serde_json::json;
1892
1893    #[test]
1894    fn print_pr_table_basic() {
1895        let prs = vec![json!({
1896            "number": 42,
1897            "title": "Fix authentication bug in login flow",
1898            "state": "open",
1899            "merged_at": null,
1900            "user": { "login": "octocat" },
1901            "head": { "ref": "fix-auth" },
1902            "labels": [
1903                { "name": "bug" },
1904                { "name": "security" }
1905            ]
1906        })];
1907        // Should not panic
1908        print_pr_table(&prs);
1909    }
1910
1911    #[test]
1912    fn print_pr_table_merged() {
1913        let prs = vec![json!({
1914            "number": 100,
1915            "title": "Add new feature",
1916            "state": "closed",
1917            "merged_at": "2024-01-15T10:30:00Z",
1918            "user": { "login": "dev-user" },
1919            "head": { "ref": "feature-branch" },
1920            "labels": []
1921        })];
1922        // Should not panic; merged PR should show "merged" state
1923        print_pr_table(&prs);
1924    }
1925
1926    #[test]
1927    fn print_pr_table_empty() {
1928        let prs: Vec<serde_json::Value> = vec![];
1929        // Should not panic
1930        print_pr_table(&prs);
1931    }
1932
1933    #[test]
1934    fn print_pr_table_multiple() {
1935        let prs = vec![
1936            json!({
1937                "number": 1,
1938                "title": "First PR",
1939                "state": "open",
1940                "merged_at": null,
1941                "user": { "login": "alice" },
1942                "head": { "ref": "feature-a" },
1943                "labels": [{"name": "enhancement"}]
1944            }),
1945            json!({
1946                "number": 2,
1947                "title": "Second PR with a very long title that should be truncated in the table output",
1948                "state": "open",
1949                "merged_at": null,
1950                "user": { "login": "bob" },
1951                "head": { "ref": "feature-b" },
1952                "labels": [{"name": "bug"}, {"name": "docs"}]
1953            }),
1954        ];
1955        // Should not panic
1956        print_pr_table(&prs);
1957    }
1958
1959    #[test]
1960    fn print_pr_table_null_fields() {
1961        let prs = vec![json!({
1962            "number": 99,
1963            "title": null,
1964            "state": null,
1965            "merged_at": null,
1966            "user": null,
1967            "head": null,
1968            "labels": null
1969        })];
1970        // Should not panic with null fields
1971        print_pr_table(&prs);
1972    }
1973
1974    #[test]
1975    fn open_in_browser_does_not_panic() {
1976        // Just verify it doesn't panic — actual browser opening is a no-op in tests
1977        open_in_browser("https://github.com/octocat/hello-world/pulls");
1978    }
1979
1980    #[test]
1981    fn print_pr_view_basic() {
1982        let pr = json!({
1983            "number": 42,
1984            "title": "Fix authentication bug",
1985            "state": "open",
1986            "merged_at": null,
1987            "user": { "login": "octocat" },
1988            "created_at": "2024-01-15T10:30:00Z",
1989            "updated_at": "2024-01-16T12:00:00Z",
1990            "body": "This PR fixes the authentication bug.",
1991            "base": { "ref": "main" },
1992            "head": { "ref": "fix-auth", "sha": "abc123" },
1993            "labels": [
1994                { "name": "bug" },
1995                { "name": "security" }
1996            ],
1997            "mergeable": true,
1998            "merged_by": null
1999        });
2000        let reviews: Vec<serde_json::Value> = vec![];
2001        let ci_status: Option<serde_json::Value> = None;
2002        let comments: Vec<serde_json::Value> = vec![];
2003        // Should not panic
2004        print_pr_view(&pr, &reviews, ci_status.as_ref(), &comments);
2005    }
2006
2007    #[test]
2008    fn print_pr_view_merged() {
2009        let pr = json!({
2010            "number": 100,
2011            "title": "Add new feature",
2012            "state": "closed",
2013            "merged_at": "2024-01-15T10:30:00Z",
2014            "user": { "login": "dev-user" },
2015            "created_at": "2024-01-10T08:00:00Z",
2016            "updated_at": "2024-01-15T10:30:00Z",
2017            "body": "This adds a new feature.",
2018            "base": { "ref": "main" },
2019            "head": { "ref": "feature-branch", "sha": "def456" },
2020            "labels": [],
2021            "mergeable": null,
2022            "merged_by": { "login": "admin" }
2023        });
2024        let reviews: Vec<serde_json::Value> = vec![];
2025        let ci_status: Option<serde_json::Value> = None;
2026        let comments: Vec<serde_json::Value> = vec![];
2027        // Should not panic
2028        print_pr_view(&pr, &reviews, ci_status.as_ref(), &comments);
2029    }
2030
2031    #[test]
2032    fn print_pr_view_with_reviews() {
2033        let pr = json!({
2034            "number": 42,
2035            "title": "Fix bug",
2036            "state": "open",
2037            "merged_at": null,
2038            "user": { "login": "octocat" },
2039            "created_at": "2024-01-15T10:30:00Z",
2040            "updated_at": "2024-01-16T12:00:00Z",
2041            "body": "Fixes a bug.",
2042            "base": { "ref": "main" },
2043            "head": { "ref": "fix-bug", "sha": "abc123" },
2044            "labels": [],
2045            "mergeable": true,
2046            "merged_by": null
2047        });
2048        let reviews = vec![
2049            json!({
2050                "user": { "login": "reviewer1" },
2051                "state": "APPROVED"
2052            }),
2053            json!({
2054                "user": { "login": "reviewer2" },
2055                "state": "CHANGES_REQUESTED"
2056            }),
2057            json!({
2058                "user": { "login": "reviewer3" },
2059                "state": "COMMENTED"
2060            }),
2061        ];
2062        let ci_status: Option<serde_json::Value> = None;
2063        let comments: Vec<serde_json::Value> = vec![];
2064        // Should not panic
2065        print_pr_view(&pr, &reviews, ci_status.as_ref(), &comments);
2066    }
2067
2068    #[test]
2069    fn print_pr_view_with_ci() {
2070        let pr = json!({
2071            "number": 42,
2072            "title": "Fix bug",
2073            "state": "open",
2074            "merged_at": null,
2075            "user": { "login": "octocat" },
2076            "created_at": "2024-01-15T10:30:00Z",
2077            "updated_at": "2024-01-16T12:00:00Z",
2078            "body": "Fixes a bug.",
2079            "base": { "ref": "main" },
2080            "head": { "ref": "fix-bug", "sha": "abc123" },
2081            "labels": [],
2082            "mergeable": true,
2083            "merged_by": null
2084        });
2085        let reviews: Vec<serde_json::Value> = vec![];
2086        let ci_status = Some(json!({
2087            "statuses": [
2088                { "context": "CI / test", "state": "success" },
2089                { "context": "CI / lint", "state": "failure" },
2090                { "context": "CI / build", "state": "pending" }
2091            ]
2092        }));
2093        let comments: Vec<serde_json::Value> = vec![];
2094        // Should not panic
2095        print_pr_view(&pr, &reviews, ci_status.as_ref(), &comments);
2096    }
2097
2098    #[test]
2099    fn print_pr_view_with_comments() {
2100        let pr = json!({
2101            "number": 42,
2102            "title": "Fix bug",
2103            "state": "open",
2104            "merged_at": null,
2105            "user": { "login": "octocat" },
2106            "created_at": "2024-01-15T10:30:00Z",
2107            "updated_at": "2024-01-16T12:00:00Z",
2108            "body": "Fixes a bug.",
2109            "base": { "ref": "main" },
2110            "head": { "ref": "fix-bug", "sha": "abc123" },
2111            "labels": [],
2112            "mergeable": true,
2113            "merged_by": null
2114        });
2115        let reviews: Vec<serde_json::Value> = vec![];
2116        let ci_status: Option<serde_json::Value> = None;
2117        let comments = vec![
2118            json!({
2119                "user": { "login": "reviewer1" },
2120                "created_at": "2024-01-16T14:00:00Z",
2121                "body": "Looks good to me!"
2122            }),
2123            json!({
2124                "user": { "login": "octocat" },
2125                "created_at": "2024-01-16T15:00:00Z",
2126                "body": "Thanks for the review!"
2127            }),
2128        ];
2129        // Should not panic
2130        print_pr_view(&pr, &reviews, ci_status.as_ref(), &comments);
2131    }
2132
2133    #[test]
2134    fn print_pr_view_null_fields() {
2135        let pr = json!({
2136            "number": 99,
2137            "title": null,
2138            "state": null,
2139            "merged_at": null,
2140            "user": null,
2141            "created_at": null,
2142            "updated_at": null,
2143            "body": null,
2144            "base": null,
2145            "head": null,
2146            "labels": null,
2147            "mergeable": null,
2148            "merged_by": null
2149        });
2150        let reviews: Vec<serde_json::Value> = vec![];
2151        let ci_status: Option<serde_json::Value> = None;
2152        let comments: Vec<serde_json::Value> = vec![];
2153        // Should not panic with null fields
2154        print_pr_view(&pr, &reviews, ci_status.as_ref(), &comments);
2155    }
2156
2157    #[test]
2158    fn print_review_status_empty() {
2159        let reviews: Vec<serde_json::Value> = vec![];
2160        // Should not panic
2161        print_review_status(&reviews);
2162    }
2163
2164    #[test]
2165    fn print_review_status_with_reviews() {
2166        let reviews = vec![
2167            json!({
2168                "user": { "login": "alice" },
2169                "state": "APPROVED"
2170            }),
2171            json!({
2172                "user": { "login": "bob" },
2173                "state": "CHANGES_REQUESTED"
2174            }),
2175            json!({
2176                "user": { "login": "carol" },
2177                "state": "COMMENTED"
2178            }),
2179        ];
2180        // Should not panic
2181        print_review_status(&reviews);
2182    }
2183
2184    #[test]
2185    fn print_merge_status_mergeable() {
2186        let pr = json!({
2187            "mergeable": true,
2188            "merged_at": null,
2189            "merged_by": null
2190        });
2191        // Should not panic
2192        print_merge_status(&pr);
2193    }
2194
2195    #[test]
2196    fn print_merge_status_conflicts() {
2197        let pr = json!({
2198            "mergeable": false,
2199            "merged_at": null,
2200            "merged_by": null
2201        });
2202        // Should not panic
2203        print_merge_status(&pr);
2204    }
2205
2206    #[test]
2207    fn print_merge_status_merged() {
2208        let pr = json!({
2209            "mergeable": null,
2210            "merged_at": "2024-01-15T10:30:00Z",
2211            "merged_by": { "login": "admin" }
2212        });
2213        // Should not panic
2214        print_merge_status(&pr);
2215    }
2216
2217    #[test]
2218    fn print_ci_status_empty() {
2219        let ci_status: Option<serde_json::Value> = None;
2220        // Should not panic
2221        print_ci_status(ci_status.as_ref());
2222    }
2223
2224    #[test]
2225    fn print_ci_status_with_checks() {
2226        let ci_status = Some(json!({
2227            "statuses": [
2228                { "context": "CI / test", "state": "success" },
2229                { "context": "CI / lint", "state": "failure" },
2230                { "context": "CI / build", "state": "pending" }
2231            ]
2232        }));
2233        // Should not panic
2234        print_ci_status(ci_status.as_ref());
2235    }
2236}