Skip to main content

gor/cmd/
repo.rs

1//! Implementation of the `gor repo` subcommand.
2//!
3//! Provides repository viewing, listing, and management commands.
4//! Currently supports `gor repo view` for displaying repository metadata.
5
6#![allow(clippy::print_stdout, clippy::print_stderr)]
7
8use crate::cli::RepoCommand;
9use crate::client::Client;
10use crate::output::{format_count, format_date, print_json};
11use crate::repository::{detect_remote, parse_repo_spec};
12use anyhow::Context;
13use std::io::Write;
14
15/// Run the `gor repo` subcommand.
16///
17/// # Errors
18///
19/// Returns an error if the command execution fails.
20pub fn run(cmd: RepoCommand) -> anyhow::Result<()> {
21    match cmd {
22        RepoCommand::View {
23            owner_repo,
24            web,
25            json,
26            hostname,
27        } => view(owner_repo, web, json, hostname.as_deref()),
28        RepoCommand::List {
29            owner,
30            visibility,
31            fork,
32            language,
33            limit,
34            json,
35            hostname,
36        } => list(
37            owner.as_deref(),
38            &visibility,
39            &fork,
40            language.as_deref(),
41            limit,
42            json,
43            hostname.as_deref(),
44        ),
45        RepoCommand::Clone {
46            owner_repo,
47            directory,
48            upstream_remote_name,
49            hostname,
50        } => clone(
51            &owner_repo,
52            directory.as_deref(),
53            &upstream_remote_name,
54            hostname.as_deref(),
55        ),
56        RepoCommand::Create {
57            name,
58            description,
59            private,
60            internal,
61            org,
62            template,
63            clone,
64            remote,
65            push,
66            disable_wiki,
67            disable_issues,
68            hostname,
69        } => repo_create(
70            &name,
71            description.as_deref(),
72            private,
73            internal,
74            org.as_deref(),
75            template.as_deref(),
76            clone,
77            &remote,
78            push,
79            disable_wiki,
80            disable_issues,
81            hostname.as_deref(),
82        ),
83        RepoCommand::Fork {
84            owner_repo,
85            org,
86            clone,
87            remote,
88            fork_name,
89            hostname,
90        } => repo_fork(
91            &owner_repo,
92            org.as_deref(),
93            clone,
94            &remote,
95            fork_name.as_deref(),
96            hostname.as_deref(),
97        ),
98        RepoCommand::Delete {
99            owner_repo,
100            yes,
101            hostname,
102        } => repo_delete(&owner_repo, yes, hostname.as_deref()),
103        RepoCommand::Edit {
104            repo,
105            description,
106            visibility,
107            add_topic,
108            remove_topic,
109            default_branch,
110            enable_wiki,
111            enable_issues,
112            enable_projects,
113            hostname,
114        } => repo_edit(
115            repo.as_deref(),
116            description.as_deref(),
117            visibility.as_deref(),
118            &add_topic,
119            &remove_topic,
120            default_branch.as_deref(),
121            enable_wiki.as_deref(),
122            enable_issues.as_deref(),
123            enable_projects.as_deref(),
124            hostname.as_deref(),
125        ),
126        RepoCommand::Transfer {
127            target,
128            repo,
129            new_name,
130            team,
131            yes,
132            hostname,
133        } => repo_transfer(
134            &target,
135            repo.as_deref(),
136            new_name.as_deref(),
137            &team,
138            yes,
139            hostname.as_deref(),
140        ),
141        RepoCommand::Sync {
142            repo,
143            branch,
144            hostname,
145        } => sync(repo.as_deref(), branch.as_deref(), hostname.as_deref()),
146    }
147}
148
149/// Execute `gor repo view`.
150///
151/// Displays repository metadata including description, stats, language,
152/// license, and other details. Supports JSON output and browser opening.
153///
154/// # Errors
155///
156/// Returns an error if the repository cannot be found or the API request fails.
157fn view(
158    owner_repo: Option<String>,
159    web: bool,
160    json: Option<Vec<String>>,
161    hostname: Option<&str>,
162) -> anyhow::Result<()> {
163    // Resolve the repo spec
164    let spec = match owner_repo {
165        Some(s) => parse_repo_spec(&s).context("invalid repository spec")?,
166        None => detect_remote().ok_or_else(|| {
167            anyhow::anyhow!(
168                "could not detect repository from current directory; specify OWNER/REPO"
169            )
170        })?,
171    };
172
173    let host = hostname.unwrap_or("github.com");
174    let client = Client::new(host).context("failed to create HTTP client")?;
175
176    let path = format!("/repos/{}/{}", spec.owner, spec.repo);
177    let response = client
178        .get(&path)
179        .context("failed to fetch repository data")?;
180
181    let status = response.status();
182    if status == reqwest::StatusCode::NOT_FOUND {
183        anyhow::bail!("repository '{spec}' not found");
184    }
185    if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
186        anyhow::bail!("authentication required to view repository '{spec}'");
187    }
188    if !status.is_success() {
189        anyhow::bail!("failed to fetch repository '{spec}': HTTP {status}");
190    }
191
192    let repo: serde_json::Value = response
193        .json()
194        .context("failed to parse repository response")?;
195
196    // Handle --web flag: open in browser
197    if web {
198        if let Some(url) = repo["html_url"].as_str() {
199            open_in_browser(url);
200        }
201        return Ok(());
202    }
203
204    // Handle --json flag
205    if let Some(fields) = json {
206        let fields_ref: Option<&[String]> = if fields.is_empty() {
207            None
208        } else {
209            Some(&fields)
210        };
211        print_json(&repo, fields_ref);
212        return Ok(());
213    }
214
215    // Default: print formatted table
216    print_repo_table(&repo);
217    Ok(())
218}
219
220/// Execute `gor repo list`.
221///
222/// Lists repositories for the authenticated user or a specified owner.
223/// Supports filtering by visibility, fork status, and language.
224/// Supports table output, JSON output, and pagination.
225///
226/// # Errors
227///
228/// Returns an error if the API request fails.
229fn list(
230    owner: Option<&str>,
231    visibility: &str,
232    fork: &str,
233    language: Option<&str>,
234    limit: u32,
235    json: Option<Vec<String>>,
236    hostname: Option<&str>,
237) -> anyhow::Result<()> {
238    let host = hostname.unwrap_or("github.com");
239    let client = Client::new(host).context("failed to create HTTP client")?;
240
241    // Build the API path based on whether an owner is specified
242    let path = match owner {
243        Some(owner_name) => {
244            // Try users endpoint first; if 404, try orgs endpoint
245            let user_path = format!("/users/{owner_name}/repos");
246            let response = client
247                .get(&user_path)
248                .context("failed to fetch repositories")?;
249            if response.status() == reqwest::StatusCode::NOT_FOUND {
250                format!("/orgs/{owner_name}/repos")
251            } else if !response.status().is_success() {
252                let status = response.status();
253                anyhow::bail!("failed to fetch repositories for '{owner_name}': HTTP {status}");
254            } else {
255                // We got a valid response — process it inline
256                let repos: Vec<serde_json::Value> = response
257                    .json()
258                    .context("failed to parse repository response")?;
259                let filtered = filter_repos(repos, visibility, fork, language, limit);
260                output_repos(&filtered, json);
261                return Ok(());
262            }
263        }
264        None => "/user/repos".to_string(),
265    };
266
267    // Build query parameters
268    let mut query_params = vec![
269        ("per_page", limit.min(100).to_string()),
270        ("type", "all".to_string()),
271    ];
272
273    // Add visibility filter
274    if visibility != "all" {
275        query_params.push(("visibility", visibility.to_string()));
276    }
277
278    // Add sort by updated
279    query_params.push(("sort", "updated".to_string()));
280
281    let query_string = query_params
282        .iter()
283        .map(|(k, v)| format!("{k}={v}"))
284        .collect::<Vec<_>>()
285        .join("&");
286
287    let full_path = format!("{path}?{query_string}");
288
289    let response = client
290        .get(&full_path)
291        .context("failed to fetch repositories")?;
292
293    let status = response.status();
294    if status == reqwest::StatusCode::NOT_FOUND {
295        if let Some(owner_name) = owner {
296            anyhow::bail!("user or organization '{owner_name}' not found");
297        }
298        anyhow::bail!("could not fetch repositories");
299    }
300    if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
301        anyhow::bail!("authentication required to list repositories");
302    }
303    if !status.is_success() {
304        anyhow::bail!("failed to list repositories: HTTP {status}");
305    }
306
307    let repos: Vec<serde_json::Value> = response
308        .json()
309        .context("failed to parse repository response")?;
310
311    let filtered = filter_repos(repos, visibility, fork, language, limit);
312    output_repos(&filtered, json);
313    Ok(())
314}
315
316/// Execute `gor repo clone`.
317///
318/// Clones a GitHub repository to the local machine using `gix`.
319/// Supports OWNER/REPO format and full URLs. For forks, automatically
320/// adds an upstream remote pointing to the parent repository.
321///
322/// # Errors
323///
324/// Returns an error if the repository cannot be found, the clone fails,
325/// or the upstream remote cannot be added.
326fn clone(
327    owner_repo: &str,
328    directory: Option<&str>,
329    upstream_remote_name: &str,
330    hostname: Option<&str>,
331) -> anyhow::Result<()> {
332    let host = hostname.unwrap_or("github.com");
333
334    // Determine if input is a URL or OWNER/REPO
335    let (clone_url, _is_fork, parent_clone_url) =
336        if owner_repo.starts_with("https://") || owner_repo.starts_with("git@") {
337            // Full URL provided — use it directly, no API lookup for fork info
338            (owner_repo.to_string(), false, None)
339        } else {
340            // OWNER/REPO format — fetch repo info from API
341            let spec = parse_repo_spec(owner_repo).context("invalid repository spec")?;
342            let client = Client::new(host).context("failed to create HTTP client")?;
343
344            let path = format!("/repos/{}/{}", spec.owner, spec.repo);
345            let response = client
346                .get(&path)
347                .context("failed to fetch repository data")?;
348
349            let status = response.status();
350            if status == reqwest::StatusCode::NOT_FOUND {
351                anyhow::bail!("repository '{spec}' not found");
352            }
353            if !status.is_success() {
354                anyhow::bail!("failed to fetch repository '{spec}': HTTP {status}");
355            }
356
357            let repo: serde_json::Value = response
358                .json()
359                .context("failed to parse repository response")?;
360
361            let clone_url = repo["clone_url"]
362                .as_str()
363                .ok_or_else(|| anyhow::anyhow!("no clone_url in repository response"))?
364                .to_string();
365
366            let is_fork = repo["fork"].as_bool().unwrap_or(false);
367            let parent_clone_url = if is_fork {
368                repo["parent"]["clone_url"].as_str().map(String::from)
369            } else {
370                None
371            };
372
373            (clone_url, is_fork, parent_clone_url)
374        };
375
376    // Determine target directory
377    let dest_dir = directory.map_or_else(
378        || {
379            // Derive directory name from the repo name
380            let repo_name = clone_url.rfind('/').map_or(clone_url.as_str(), |pos| {
381                clone_url[pos + 1..].trim_end_matches(".git")
382            });
383            std::path::PathBuf::from(repo_name)
384        },
385        std::path::PathBuf::from,
386    );
387
388    // Clone the repository using gix
389    eprintln!("Cloning into '{}'...", dest_dir.display());
390
391    let mut prepare_fetch =
392        gix::prepare_clone(clone_url.as_str(), &dest_dir).context("failed to prepare clone")?;
393
394    let (mut prepare_checkout, _outcome) = prepare_fetch
395        .fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
396        .context("failed to fetch repository")?;
397
398    let (_repo, _checkout_outcome) = prepare_checkout
399        .main_worktree(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
400        .context("failed to checkout worktree")?;
401
402    // If it's a fork, add the upstream remote
403    if let Some(parent_url) = parent_clone_url {
404        eprintln!("Adding upstream remote '{upstream_remote_name}'...");
405
406        // Open the cloned repo to add the remote
407        let repo = gix::open(&dest_dir).context("failed to open cloned repository")?;
408
409        // Write the remote section directly to the git config file
410        let config_path = repo.git_dir().join("config");
411        let mut file = std::fs::OpenOptions::new()
412            .append(true)
413            .open(&config_path)
414            .context("failed to open git config for appending")?;
415
416        let url_escaped = parent_url.replace('"', "\\\"");
417        writeln!(
418            file,
419            "[remote \"{upstream_remote_name}\"]\n\turl = {url_escaped}\n\tfetch = +refs/heads/*:refs/remotes/{upstream_remote_name}/*"
420        )
421        .context("failed to write upstream remote config")?;
422
423        eprintln!("Added upstream remote '{upstream_remote_name}'");
424    }
425
426    eprintln!("Clone complete.");
427    Ok(())
428}
429
430/// Execute `gor repo create`.
431///
432/// Creates a new GitHub repository under the authenticated user or an
433/// organization. Supports setting description, visibility, feature toggles,
434/// and optionally cloning the new repository locally.
435///
436/// # Errors
437///
438/// Returns an error if the API request fails.
439#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
440fn repo_create(
441    name: &str,
442    description: Option<&str>,
443    private: bool,
444    internal: bool,
445    org: Option<&str>,
446    _template: Option<&str>,
447    clone: bool,
448    _remote: &str,
449    _push: bool,
450    disable_wiki: bool,
451    disable_issues: bool,
452    hostname: Option<&str>,
453) -> anyhow::Result<()> {
454    let host = hostname.unwrap_or("github.com");
455    let client = Client::new(host).context("failed to create HTTP client")?;
456
457    // Build the request body
458    let mut body_map = serde_json::Map::new();
459    body_map.insert(
460        "name".to_string(),
461        serde_json::Value::String(name.to_string()),
462    );
463    body_map.insert(
464        "private".to_string(),
465        serde_json::Value::Bool(private || internal),
466    );
467    if internal {
468        body_map.insert(
469            "visibility".to_string(),
470            serde_json::Value::String("internal".to_string()),
471        );
472    }
473    if let Some(d) = description {
474        body_map.insert(
475            "description".to_string(),
476            serde_json::Value::String(d.to_string()),
477        );
478    }
479    if disable_wiki {
480        body_map.insert("has_wiki".to_string(), serde_json::Value::Bool(false));
481    }
482    if disable_issues {
483        body_map.insert("has_issues".to_string(), serde_json::Value::Bool(false));
484    }
485
486    let body_value = serde_json::Value::Object(body_map);
487
488    // Determine the API path
489    let path = org.map_or_else(|| "/user/repos".to_string(), |o| format!("/orgs/{o}/repos"));
490
491    let response = client
492        .post(&path, &body_value)
493        .context("failed to create repository")?;
494
495    let status = response.status();
496    if !status.is_success() {
497        let err_body: serde_json::Value = response.json().unwrap_or_default();
498        let msg = err_body["message"].as_str().unwrap_or("creation failed");
499        anyhow::bail!("failed to create repository: {msg}");
500    }
501
502    let repo: serde_json::Value = response
503        .json()
504        .context("failed to parse repository response")?;
505
506    let clone_url = repo["clone_url"].as_str().unwrap_or("");
507    let html_url = repo["html_url"].as_str().unwrap_or("");
508    let full_name = repo["full_name"].as_str().unwrap_or("");
509
510    println!("{html_url}");
511
512    // Clone the new repository locally if requested
513    if clone && !clone_url.is_empty() {
514        let dest_dir = std::path::PathBuf::from(name);
515        eprintln!("Cloning into '{}'...", dest_dir.display());
516
517        let url =
518            gix::url::parse(clone_url.as_bytes().into()).context("failed to parse clone URL")?;
519        let mut prepare_fetch =
520            gix::prepare_clone(url, &dest_dir).context("failed to prepare clone")?;
521        let (mut prepare_checkout, _outcome) = prepare_fetch
522            .fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
523            .context("failed to fetch repository")?;
524        let (_repo, _checkout_outcome) = prepare_checkout
525            .main_worktree(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
526            .context("failed to checkout worktree")?;
527        eprintln!("Cloned {full_name}");
528    }
529
530    Ok(())
531}
532
533/// Execute `gor repo fork`.
534///
535/// Forks a repository to the authenticated user's account or an organization.
536/// Optionally clones the fork locally after creation.
537///
538/// # Errors
539///
540/// Returns an error if the API request fails.
541fn repo_fork(
542    owner_repo: &str,
543    org: Option<&str>,
544    clone: bool,
545    _remote: &str,
546    fork_name: Option<&str>,
547    hostname: Option<&str>,
548) -> anyhow::Result<()> {
549    let spec = parse_repo_spec(owner_repo).context("invalid repository spec")?;
550    let host = hostname.unwrap_or("github.com");
551    let client = Client::new(host).context("failed to create HTTP client")?;
552
553    // Build the request body
554    let mut body_map = serde_json::Map::new();
555    if let Some(o) = org {
556        body_map.insert(
557            "organization".to_string(),
558            serde_json::Value::String(o.to_string()),
559        );
560    }
561    if let Some(n) = fork_name {
562        body_map.insert("name".to_string(), serde_json::Value::String(n.to_string()));
563    }
564
565    let body_value = serde_json::Value::Object(body_map);
566
567    let path = format!("/repos/{}/{}/forks", spec.owner, spec.repo);
568    let response = client
569        .post(&path, &body_value)
570        .context("failed to fork repository")?;
571
572    let status = response.status();
573    if status == reqwest::StatusCode::NOT_FOUND {
574        anyhow::bail!("repository '{spec}' not found");
575    }
576    if status == reqwest::StatusCode::ACCEPTED {
577        // Fork is being created asynchronously
578        let fork: serde_json::Value = response.json().context("failed to parse fork response")?;
579        let html_url = fork["html_url"].as_str().unwrap_or("");
580        println!("{html_url}");
581        eprintln!("Fork is being created. It may take a few minutes.");
582        return Ok(());
583    }
584    if !status.is_success() {
585        let err_body: serde_json::Value = response.json().unwrap_or_default();
586        let msg = err_body["message"].as_str().unwrap_or("fork failed");
587        anyhow::bail!("failed to fork repository: {msg}");
588    }
589
590    let fork: serde_json::Value = response.json().context("failed to parse fork response")?;
591
592    let clone_url = fork["clone_url"].as_str().unwrap_or("");
593    let html_url = fork["html_url"].as_str().unwrap_or("");
594    let full_name = fork["full_name"].as_str().unwrap_or("");
595
596    println!("{html_url}");
597
598    // Clone the fork locally if requested
599    if clone && !clone_url.is_empty() {
600        let repo_name = fork_name.unwrap_or(&spec.repo);
601        let dest_dir = std::path::PathBuf::from(repo_name);
602        eprintln!("Cloning into '{}'...", dest_dir.display());
603
604        let url =
605            gix::url::parse(clone_url.as_bytes().into()).context("failed to parse clone URL")?;
606        let mut prepare_fetch =
607            gix::prepare_clone(url, &dest_dir).context("failed to prepare clone")?;
608        let (mut prepare_checkout, _outcome) = prepare_fetch
609            .fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
610            .context("failed to fetch repository")?;
611        let (_repo, _checkout_outcome) = prepare_checkout
612            .main_worktree(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
613            .context("failed to checkout worktree")?;
614        eprintln!("Cloned {full_name}");
615    }
616
617    Ok(())
618}
619
620/// Execute `gor repo delete`.
621///
622/// Deletes a repository. Requires confirmation unless --yes is provided.
623///
624/// # Errors
625///
626/// Returns an error if the API request fails or the user cancels.
627fn repo_delete(owner_repo: &str, yes: bool, hostname: Option<&str>) -> anyhow::Result<()> {
628    let spec = parse_repo_spec(owner_repo).context("invalid repository spec")?;
629
630    // Confirmation prompt
631    if !yes {
632        eprintln!("WARNING: This will delete the repository '{spec}' and all its data.");
633        eprint!("Type the repository name '{spec}' to confirm: ");
634        std::io::stdout().flush().ok();
635        let mut input = String::new();
636        std::io::stdin()
637            .read_line(&mut input)
638            .context("failed to read input")?;
639        let input = input.trim();
640        if input != spec.to_string() {
641            anyhow::bail!("confirmation failed: expected '{spec}', got '{input}'");
642        }
643    }
644
645    let host = hostname.unwrap_or("github.com");
646    let client = Client::new(host).context("failed to create HTTP client")?;
647
648    let path = format!("/repos/{}/{}", spec.owner, spec.repo);
649    let response = client
650        .request("DELETE", &path, &[], None)
651        .context("failed to delete repository")?;
652
653    let status = response.status();
654    if status == reqwest::StatusCode::NOT_FOUND {
655        anyhow::bail!("repository '{spec}' not found");
656    }
657    if status == reqwest::StatusCode::FORBIDDEN {
658        anyhow::bail!("you do not have permission to delete '{spec}'");
659    }
660    if !status.is_success() {
661        anyhow::bail!("failed to delete repository '{spec}': HTTP {status}");
662    }
663
664    println!("Deleted repository '{spec}'");
665    Ok(())
666}
667
668/// Execute `gor repo edit`.
669///
670/// Edits a repository's settings including description, visibility, topics,
671/// default branch, and feature toggles.
672///
673/// # Errors
674///
675/// Returns an error if the repository cannot be found or the API request fails.
676#[allow(clippy::too_many_arguments)]
677fn repo_edit(
678    repo: Option<&str>,
679    description: Option<&str>,
680    visibility: Option<&str>,
681    add_topic: &[String],
682    remove_topic: &[String],
683    default_branch: Option<&str>,
684    enable_wiki: Option<&str>,
685    enable_issues: Option<&str>,
686    enable_projects: Option<&str>,
687    hostname: Option<&str>,
688) -> anyhow::Result<()> {
689    let spec = match repo {
690        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
691        None => detect_remote().ok_or_else(|| {
692            anyhow::anyhow!(
693                "could not detect repository from current directory; specify OWNER/REPO with --repo"
694            )
695        })?,
696    };
697
698    let host = hostname.unwrap_or("github.com");
699    let client = Client::new(host).context("failed to create HTTP client")?;
700
701    // Build the request body with only the fields that were provided
702    let mut body_map = serde_json::Map::new();
703
704    if let Some(d) = description {
705        body_map.insert(
706            "description".to_string(),
707            serde_json::Value::String(d.to_string()),
708        );
709    }
710    if let Some(v) = visibility {
711        body_map.insert(
712            "visibility".to_string(),
713            serde_json::Value::String(v.to_string()),
714        );
715    }
716    if let Some(b) = default_branch {
717        body_map.insert(
718            "default_branch".to_string(),
719            serde_json::Value::String(b.to_string()),
720        );
721    }
722    if let Some(w) = enable_wiki {
723        body_map.insert("has_wiki".to_string(), serde_json::Value::Bool(w == "true"));
724    }
725    if let Some(i) = enable_issues {
726        body_map.insert(
727            "has_issues".to_string(),
728            serde_json::Value::Bool(i == "true"),
729        );
730    }
731    if let Some(p) = enable_projects {
732        body_map.insert(
733            "has_projects".to_string(),
734            serde_json::Value::Bool(p == "true"),
735        );
736    }
737
738    let body_value = serde_json::Value::Object(body_map);
739
740    let path = format!("/repos/{}/{}", spec.owner, spec.repo);
741    let response = client
742        .request("PATCH", &path, &[], Some(serde_json::to_vec(&body_value)?))
743        .context("failed to update repository")?;
744
745    let status = response.status();
746    if status == reqwest::StatusCode::NOT_FOUND {
747        anyhow::bail!("repository '{spec}' not found");
748    }
749    if !status.is_success() {
750        let err_body: serde_json::Value = response.json().unwrap_or_default();
751        let msg = err_body["message"].as_str().unwrap_or("update failed");
752        anyhow::bail!("failed to update repository '{spec}': {msg}");
753    }
754
755    let updated: serde_json::Value = response
756        .json()
757        .context("failed to parse repository response")?;
758
759    // Handle topics separately (GitHub API has a separate endpoint)
760    if !add_topic.is_empty() || !remove_topic.is_empty() {
761        // Get current topics first
762        let topics_path = format!("/repos/{}/{}/topics", spec.owner, spec.repo);
763        let topics_response = client.get(&topics_path).context("failed to fetch topics")?;
764        let mut current_topics: Vec<String> = topics_response
765            .json::<serde_json::Value>()
766            .ok()
767            .and_then(|v| v["names"].as_array().cloned())
768            .unwrap_or_default()
769            .into_iter()
770            .filter_map(|v| v.as_str().map(String::from))
771            .collect();
772
773        // Add new topics
774        for topic in add_topic {
775            if !current_topics.contains(topic) {
776                current_topics.push(topic.clone());
777            }
778        }
779
780        // Remove topics
781        current_topics.retain(|t| !remove_topic.contains(t));
782
783        // Update topics
784        let topics_body = serde_json::json!({"names": current_topics});
785        let accept_header = "application/vnd.github.mercy-preview+json".to_string();
786        if let Err(e) = client.request(
787            "PUT",
788            &topics_path,
789            &[format!("Accept: {accept_header}")],
790            Some(serde_json::to_vec(&topics_body)?),
791        ) {
792            eprintln!("Warning: failed to update topics: {e}");
793        }
794    }
795
796    // Print updated details
797    let full_name = updated["full_name"].as_str().unwrap_or("");
798    let desc = updated["description"].as_str().unwrap_or("—");
799    let vis = updated["visibility"].as_str().unwrap_or("public");
800    let branch = updated["default_branch"].as_str().unwrap_or("—");
801    let wiki = updated["has_wiki"].as_bool().unwrap_or(true);
802    let issues = updated["has_issues"].as_bool().unwrap_or(true);
803    let projects = updated["has_projects"].as_bool().unwrap_or(true);
804
805    println!("Updated repository {full_name}");
806    println!("  description:  {desc}");
807    println!("  visibility:   {vis}");
808    println!("  default:      {branch}");
809    println!("  wiki:         {wiki}");
810    println!("  issues:       {issues}");
811    println!("  projects:     {projects}");
812
813    Ok(())
814}
815
816/// Execute `gor repo transfer`.
817///
818/// Transfers a repository to another user or organization.
819/// Requires confirmation unless --yes is provided.
820///
821/// # Errors
822///
823/// Returns an error if the API request fails or the user cancels.
824fn repo_transfer(
825    target: &str,
826    repo: Option<&str>,
827    new_name: Option<&str>,
828    _teams: &[String],
829    yes: bool,
830    hostname: Option<&str>,
831) -> anyhow::Result<()> {
832    let spec = match repo {
833        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
834        None => detect_remote().ok_or_else(|| {
835            anyhow::anyhow!(
836                "could not detect repository from current directory; specify OWNER/REPO with --repo"
837            )
838        })?,
839    };
840
841    // Confirmation prompt
842    if !yes {
843        eprintln!("WARNING: This will transfer repository '{spec}' to '{target}'.");
844        eprint!("Type the repository name '{spec}' to confirm: ");
845        std::io::stdout().flush().ok();
846        let mut input = String::new();
847        std::io::stdin()
848            .read_line(&mut input)
849            .context("failed to read input")?;
850        let input = input.trim();
851        if input != spec.to_string() {
852            anyhow::bail!("confirmation failed: expected '{spec}', got '{input}'");
853        }
854    }
855
856    let host = hostname.unwrap_or("github.com");
857    let client = Client::new(host).context("failed to create HTTP client")?;
858
859    // Build the request body
860    let mut body_map = serde_json::Map::new();
861    body_map.insert(
862        "new_owner".to_string(),
863        serde_json::Value::String(target.to_string()),
864    );
865    if let Some(n) = new_name {
866        body_map.insert(
867            "new_name".to_string(),
868            serde_json::Value::String(n.to_string()),
869        );
870    }
871
872    let body_value = serde_json::Value::Object(body_map);
873
874    let path = format!("/repos/{}/{}/transfer", spec.owner, spec.repo);
875    let response = client
876        .request("POST", &path, &[], Some(serde_json::to_vec(&body_value)?))
877        .context("failed to transfer repository")?;
878
879    let status = response.status();
880    if status == reqwest::StatusCode::NOT_FOUND {
881        anyhow::bail!("repository '{spec}' not found");
882    }
883    if status == reqwest::StatusCode::FORBIDDEN {
884        anyhow::bail!("you do not have permission to transfer '{spec}' to '{target}'");
885    }
886    if !status.is_success() {
887        let err_body: serde_json::Value = response.json().unwrap_or_default();
888        let msg = err_body["message"].as_str().unwrap_or("transfer failed");
889        anyhow::bail!("failed to transfer repository: {msg}");
890    }
891
892    let result: serde_json::Value = response
893        .json()
894        .context("failed to parse transfer response")?;
895
896    let html_url = result["html_url"].as_str().unwrap_or("");
897    println!("Transferred repository to {target}");
898    println!("{html_url}");
899
900    Ok(())
901}
902
903/// Filter repositories by visibility, fork status, and language.
904fn filter_repos(
905    repos: Vec<serde_json::Value>,
906    visibility: &str,
907    fork: &str,
908    language: Option<&str>,
909    limit: u32,
910) -> Vec<serde_json::Value> {
911    let mut filtered = repos;
912
913    // Client-side visibility filter (API may not support all cases)
914    if visibility != "all" {
915        filtered.retain(|repo| {
916            let is_private = repo["private"].as_bool().unwrap_or(false);
917            match visibility {
918                "public" => !is_private,
919                "private" => is_private,
920                _ => true,
921            }
922        });
923    }
924
925    // Fork filter
926    match fork {
927        "exclude" => filtered.retain(|repo| !repo["fork"].as_bool().unwrap_or(false)),
928        "only" => filtered.retain(|repo| repo["fork"].as_bool().unwrap_or(false)),
929        _ => {} // "include" — no filter
930    }
931
932    // Language filter
933    if let Some(lang) = language {
934        filtered.retain(|repo| {
935            repo["language"]
936                .as_str()
937                .is_some_and(|l| l.eq_ignore_ascii_case(lang))
938        });
939    }
940
941    // Apply limit
942    filtered.truncate(limit as usize);
943    filtered
944}
945
946/// Output repositories as either JSON or a formatted table.
947fn output_repos(repos: &Vec<serde_json::Value>, json: Option<Vec<String>>) {
948    if let Some(fields) = json {
949        let fields_ref: Option<&[String]> = if fields.is_empty() {
950            None
951        } else {
952            Some(&fields)
953        };
954        print_json(repos, fields_ref);
955    } else {
956        print_repo_list_table(repos);
957    }
958}
959
960/// Print a formatted repository list table.
961///
962/// Columns: NAME, DESCRIPTION, VISIBILITY, LANGUAGE, UPDATED
963fn print_repo_list_table(repos: &[serde_json::Value]) {
964    if repos.is_empty() {
965        println!("No repositories found.");
966        return;
967    }
968
969    // Column widths
970    let name_width = 30;
971    let desc_width = 50;
972    let vis_width = 10;
973    let lang_width = 14;
974    let date_width = 16;
975
976    // Header
977    println!(
978        "{:<name_width$}  {:<desc_width$}  {:<vis_width$}  {:<lang_width$}  {:<date_width$}",
979        "NAME", "DESCRIPTION", "VISIBILITY", "LANGUAGE", "UPDATED",
980    );
981
982    for repo in repos {
983        let name = repo["full_name"].as_str().unwrap_or("—");
984        let description = repo["description"].as_str().unwrap_or("—");
985        let is_private = repo["private"].as_bool().unwrap_or(false);
986        let visibility = if is_private { "private" } else { "public" };
987        let language = repo["language"].as_str().unwrap_or("—");
988        let updated = repo["updated_at"]
989            .as_str()
990            .map_or_else(|| "—".to_string(), format_date);
991
992        let name_truncated = crate::cmd::util::truncate(name, name_width);
993        let desc_truncated = crate::cmd::util::truncate(description, desc_width);
994
995        println!(
996            "{name_truncated:<name_width$}  {desc_truncated:<desc_width$}  {visibility:<vis_width$}  {language:<lang_width$}  {updated:<date_width$}",
997        );
998    }
999}
1000
1001/// Print a formatted repository information table.
1002fn print_repo_table(repo: &serde_json::Value) {
1003    let full_name = repo["full_name"].as_str().unwrap_or("—");
1004    let description = repo["description"].as_str().unwrap_or("—");
1005    let html_url = repo["html_url"].as_str().unwrap_or("—");
1006    let is_private = repo["private"].as_bool().unwrap_or(false);
1007    let visibility = if is_private { "private" } else { "public" };
1008    let stars = repo["stargazers_count"].as_u64().unwrap_or(0);
1009    let forks = repo["forks_count"].as_u64().unwrap_or(0);
1010    let issues = repo["open_issues_count"].as_u64().unwrap_or(0);
1011    let language = repo["language"].as_str().unwrap_or("—");
1012    let license_name = repo["license"]["spdx_id"]
1013        .as_str()
1014        .or_else(|| repo["license"]["name"].as_str())
1015        .unwrap_or("—");
1016    let default_branch = repo["default_branch"].as_str().unwrap_or("—");
1017    let pushed_at = repo["pushed_at"].as_str().unwrap_or("—");
1018
1019    println!("name:        {full_name}");
1020    println!("description: {description}");
1021    println!("url:         {html_url}");
1022    println!("visibility:  {visibility}");
1023    println!("stars:       {}", format_count(stars));
1024    println!("forks:       {}", format_count(forks));
1025    println!("issues:      {}", format_count(issues));
1026    println!("language:    {language}");
1027    println!("license:     {license_name}");
1028    println!("default:     {default_branch}");
1029    println!("updated:     {}", format_date(pushed_at));
1030}
1031
1032/// Execute `gor repo sync`.
1033///
1034/// Syncs a fork's default branch from its upstream repository.
1035///
1036/// # Errors
1037///
1038/// Returns an error if the repository is not a fork, the API request fails,
1039/// or the sync would result in merge conflicts.
1040fn sync(repo: Option<&str>, branch: Option<&str>, hostname: Option<&str>) -> anyhow::Result<()> {
1041    let spec = match repo {
1042        Some(s) => parse_repo_spec(s).context("invalid repository spec")?,
1043        None => detect_remote().ok_or_else(|| {
1044            anyhow::anyhow!(
1045                "could not detect repository from current directory; specify OWNER/REPO with --repo"
1046            )
1047        })?,
1048    };
1049
1050    let host = hostname.unwrap_or("github.com");
1051    let client = Client::new(host).context("failed to create HTTP client")?;
1052
1053    let path = format!("/repos/{}/{}/merge-upstream", spec.owner, spec.repo);
1054
1055    let mut body_map = serde_json::Map::new();
1056    body_map.insert(
1057        "branch".to_string(),
1058        serde_json::Value::String(branch.unwrap_or("main").to_string()),
1059    );
1060    let body_value = serde_json::Value::Object(body_map);
1061
1062    let response = client
1063        .post(&path, &body_value)
1064        .context("failed to sync repository")?;
1065
1066    let status = response.status();
1067    if status == reqwest::StatusCode::NOT_FOUND {
1068        anyhow::bail!("repository '{spec}' not found or is not a fork");
1069    }
1070    if status == reqwest::StatusCode::CONFLICT {
1071        anyhow::bail!(
1072            "sync would result in merge conflicts; resolve conflicts manually and try again"
1073        );
1074    }
1075    if status == reqwest::StatusCode::UNPROCESSABLE_ENTITY {
1076        let err_body: serde_json::Value = response.json().unwrap_or_default();
1077        let msg = err_body["message"].as_str().unwrap_or(
1078            "sync failed - check that the repository is a fork and has an upstream configured",
1079        );
1080        anyhow::bail!("{msg}");
1081    }
1082    if !status.is_success() {
1083        let err_body: serde_json::Value = response.json().unwrap_or_default();
1084        let msg = err_body["message"].as_str().unwrap_or("sync failed");
1085        anyhow::bail!("failed to sync '{spec}': {msg}");
1086    }
1087
1088    let result: serde_json::Value = response.json().context("failed to parse sync response")?;
1089
1090    if let Some(merge_sha) = result["merge_after"].as_str() {
1091        println!("✓ Synced '{spec}' with upstream (merge commit: {merge_sha})");
1092    } else {
1093        println!("✓ '{spec}' is already up to date with upstream");
1094    }
1095
1096    Ok(())
1097}
1098
1099/// Open a URL in the default browser using the system's default handler.
1100fn open_in_browser(url: &str) {
1101    #[cfg(target_os = "linux")]
1102    {
1103        let _ = std::process::Command::new("xdg-open").arg(url).spawn();
1104    }
1105    #[cfg(target_os = "macos")]
1106    {
1107        let _ = std::process::Command::new("open").arg(url).spawn();
1108    }
1109    #[cfg(target_os = "windows")]
1110    {
1111        let _ = std::process::Command::new("cmd")
1112            .args(["/c", "start", url])
1113            .spawn();
1114    }
1115    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1116    {
1117        println!("Open {url} in your browser");
1118    }
1119}
1120
1121#[cfg(test)]
1122#[allow(clippy::expect_used)]
1123mod tests {
1124    use super::*;
1125    use serde_json::json;
1126
1127    #[test]
1128    fn print_repo_table_basic() {
1129        let repo = json!({
1130            "full_name": "octocat/hello-world",
1131            "description": "My first repository",
1132            "html_url": "https://github.com/octocat/hello-world",
1133            "private": false,
1134            "stargazers_count": 1234,
1135            "forks_count": 56,
1136            "open_issues_count": 12,
1137            "language": "Rust",
1138            "license": { "spdx_id": "MIT" },
1139            "default_branch": "main",
1140            "pushed_at": "2024-01-15T10:30:00Z"
1141        });
1142        // Should not panic
1143        print_repo_table(&repo);
1144    }
1145
1146    #[test]
1147    fn print_repo_table_private() {
1148        let repo = json!({
1149            "full_name": "org/private-repo",
1150            "description": null,
1151            "html_url": "https://github.com/org/private-repo",
1152            "private": true,
1153            "stargazers_count": 0,
1154            "forks_count": 0,
1155            "open_issues_count": 0,
1156            "language": null,
1157            "license": null,
1158            "default_branch": "main",
1159            "pushed_at": "2024-01-15T10:30:00Z"
1160        });
1161        // Should not panic with null fields
1162        print_repo_table(&repo);
1163    }
1164
1165    #[test]
1166    fn print_repo_table_no_license() {
1167        let repo = json!({
1168            "full_name": "user/test",
1169            "description": "A test repo",
1170            "html_url": "https://github.com/user/test",
1171            "private": false,
1172            "stargazers_count": 42,
1173            "forks_count": 7,
1174            "open_issues_count": 3,
1175            "language": "Python",
1176            "license": null,
1177            "default_branch": "master",
1178            "pushed_at": "2023-12-25T00:00:00Z"
1179        });
1180        // Should not panic with null license
1181        print_repo_table(&repo);
1182    }
1183
1184    #[test]
1185    fn open_in_browser_does_not_panic() {
1186        // Just verify it doesn't panic — actual browser opening is a no-op in tests
1187        open_in_browser("https://github.com/octocat/hello-world");
1188    }
1189
1190    #[test]
1191    fn print_repo_list_table_basic() {
1192        let repos = vec![json!({
1193            "full_name": "octocat/hello-world",
1194            "description": "My first repository",
1195            "private": false,
1196            "language": "Rust",
1197            "updated_at": "2024-01-15T10:30:00Z"
1198        })];
1199        print_repo_list_table(&repos);
1200    }
1201
1202    #[test]
1203    fn print_repo_list_table_empty() {
1204        let repos: Vec<serde_json::Value> = vec![];
1205        print_repo_list_table(&repos);
1206    }
1207
1208    #[test]
1209    fn print_repo_list_table_multiple() {
1210        let repos = vec![
1211            json!({
1212                "full_name": "alice/project-a",
1213                "description": "First project",
1214                "private": false,
1215                "language": "Rust",
1216                "updated_at": "2024-01-15T10:30:00Z"
1217            }),
1218            json!({
1219                "full_name": "bob/private-repo",
1220                "description": "A secret project with a very long description that should be truncated",
1221                "private": true,
1222                "language": "Python",
1223                "updated_at": "2024-02-20T12:00:00Z"
1224            }),
1225        ];
1226        print_repo_list_table(&repos);
1227    }
1228
1229    #[test]
1230    fn print_repo_list_table_null_fields() {
1231        let repos = vec![json!({
1232            "full_name": "test/repo",
1233            "description": null,
1234            "private": false,
1235            "language": null,
1236            "updated_at": null
1237        })];
1238        print_repo_list_table(&repos);
1239    }
1240
1241    #[test]
1242    fn filter_repos_by_visibility() {
1243        let repos = vec![
1244            json!({"full_name": "a/public", "private": false, "fork": false, "language": "Rust"}),
1245            json!({"full_name": "b/private", "private": true, "fork": false, "language": "Rust"}),
1246        ];
1247        let filtered = filter_repos(repos, "public", "include", None, 30);
1248        assert_eq!(filtered.len(), 1);
1249        assert_eq!(filtered[0]["full_name"], "a/public");
1250    }
1251
1252    #[test]
1253    fn filter_repos_by_fork_exclude() {
1254        let repos = vec![
1255            json!({"full_name": "a/original", "private": false, "fork": false, "language": "Rust"}),
1256            json!({"full_name": "b/forked", "private": false, "fork": true, "language": "Rust"}),
1257        ];
1258        let filtered = filter_repos(repos, "all", "exclude", None, 30);
1259        assert_eq!(filtered.len(), 1);
1260        assert_eq!(filtered[0]["full_name"], "a/original");
1261    }
1262
1263    #[test]
1264    fn filter_repos_by_fork_only() {
1265        let repos = vec![
1266            json!({"full_name": "a/original", "private": false, "fork": false, "language": "Rust"}),
1267            json!({"full_name": "b/forked", "private": false, "fork": true, "language": "Rust"}),
1268        ];
1269        let filtered = filter_repos(repos, "all", "only", None, 30);
1270        assert_eq!(filtered.len(), 1);
1271        assert_eq!(filtered[0]["full_name"], "b/forked");
1272    }
1273
1274    #[test]
1275    fn filter_repos_by_language() {
1276        let repos = vec![
1277            json!({"full_name": "a/rust-repo", "private": false, "fork": false, "language": "Rust"}),
1278            json!({"full_name": "b/python-repo", "private": false, "fork": false, "language": "Python"}),
1279        ];
1280        let filtered = filter_repos(repos, "all", "include", Some("Rust"), 30);
1281        assert_eq!(filtered.len(), 1);
1282        assert_eq!(filtered[0]["full_name"], "a/rust-repo");
1283    }
1284
1285    #[test]
1286    fn filter_repos_by_limit() {
1287        let repos: Vec<serde_json::Value> = (0..10)
1288            .map(|i| json!({"full_name": format!("owner/repo{i}"), "private": false, "fork": false, "language": "Rust"}))
1289            .collect();
1290        let filtered = filter_repos(repos, "all", "include", None, 5);
1291        assert_eq!(filtered.len(), 5);
1292    }
1293
1294    #[test]
1295    fn clone_directory_name_from_url() {
1296        // Test the directory name derivation logic used in clone()
1297        let url = "https://github.com/octocat/hello-world.git";
1298        let name = url
1299            .rfind('/')
1300            .map_or(url, |pos| url[pos + 1..].trim_end_matches(".git"));
1301        assert_eq!(name, "hello-world");
1302
1303        // Without .git suffix
1304        let url = "https://github.com/octocat/hello-world";
1305        let name = url
1306            .rfind('/')
1307            .map_or(url, |pos| url[pos + 1..].trim_end_matches(".git"));
1308        assert_eq!(name, "hello-world");
1309
1310        // SSH URL with path
1311        let url = "git@github.com:octocat/repo.git";
1312        let name = url
1313            .rfind('/')
1314            .map_or(url, |pos| url[pos + 1..].trim_end_matches(".git"));
1315        assert_eq!(name, "repo");
1316    }
1317}