//! Creating a repository on a Gitea server. //! //! This used to be a cookiecutter template's job: its post-generation hook read //! `GITEA_*` out of the environment and called the API itself. That only ever //! covered projects made through **New project**, and when it went wrong it did //! so inside someone else's Python, where this app could only report that a //! hook had exited non-zero. //! //! So the app now talks to Gitea directly. Everything here is one HTTP call and //! the parsing around it; attaching the result to a workspace is //! [`crate::gitsync::set_origin`]'s job. use std::time::Duration; use serde::Deserialize; /// What to ask the server to create. pub struct NewRepo { /// Repository name, as it will appear under the owner. pub name: String, /// Optional description; blank is fine. pub description: String, /// Whether the repository is private. pub private: bool, } /// The repository the server ended up with — created just now, or already there. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Repo { /// `owner/name`, as Gitea reports it. pub full_name: String, /// SSH clone URL, which is what a key-based setup wants. pub ssh_url: String, /// HTTPS clone URL, which needs credentials on every push. pub clone_url: String, /// Page to open in a browser. pub html_url: String, /// False when the repository was already there and we attached to it. The /// distinction matters to the message shown, not to what happens next. pub created: bool, } impl Repo { /// The URL to point `origin` at. pub fn remote_url(&self, use_ssh: bool) -> &str { if use_ssh { &self.ssh_url } else { &self.clone_url } } } /// What Gitea sends back for a repository. Only the fields used here are named; /// the reply carries a few dozen more. #[derive(Deserialize)] struct RepoReply { full_name: String, ssh_url: String, clone_url: String, html_url: String, } impl RepoReply { fn into_repo(self, created: bool) -> Repo { Repo { full_name: self.full_name, ssh_url: self.ssh_url, clone_url: self.clone_url, html_url: self.html_url, created, } } } /// The API root for a server URL, or an error naming what is missing. /// /// Accepts the server URL with or without a trailing slash, and tolerates the /// `/api/v1` suffix already being there — it is the sort of thing that gets /// pasted in, and silently producing `/api/v1/api/v1` would be a 404 with no /// hint as to why. pub fn api_base(server: &str) -> Result { let base = server.trim().trim_end_matches('/'); if base.is_empty() { return Err( "No Gitea server set (open Settings ▸ New project and fill in the \ Gitea rows)" .to_string(), ); } if !base.starts_with("http://") && !base.starts_with("https://") { return Err(format!("Gitea URL must start with http:// or https:// — got {base}")); } let base = base.trim_end_matches("/api/v1").trim_end_matches('/'); Ok(format!("{base}/api/v1")) } /// Turn a repository name into what Gitea will actually call it. /// /// Gitea replaces characters it does not allow, so a name typed with spaces /// comes back different from what was asked for; normalising up front means the /// URL that gets written into `origin` is the one the server agreed to. pub fn normalize_name(name: &str) -> String { let cleaned: String = name .trim() .chars() .map(|c| { if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '-' } }) .collect(); // Collapse the runs a replacement can leave behind, and never lead or // trail with punctuation. let mut out = String::with_capacity(cleaned.len()); for c in cleaned.chars() { if c == '-' && out.ends_with('-') { continue; } out.push(c); } out.trim_matches(|c| c == '-' || c == '.').to_string() } /// The host in a URL: no scheme, no credentials, no port, no path. pub fn host_of(url: &str) -> &str { let s = url.trim(); let s = s.split_once("://").map_or(s, |(_, rest)| rest); let s = s.split(['/', '?', '#']).next().unwrap_or(s); let s = s.rsplit_once('@').map_or(s, |(_, host)| host); // Only a numeric tail is a port; anything else is part of the host. match s.rsplit_once(':') { Some((host, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => host, _ => s, } } /// Rewrite the host of an SSH clone URL to `host`, keeping user, port and path. /// /// Gitea builds `ssh_url` from its own `SSH_DOMAIN`, which is whatever the /// server was configured to advertise — not necessarily an address the machine /// running this app can reach. A server told to advertise a public hostname /// whose SSH port is only open on the LAN hands out a URL that connects from /// nowhere, and the failure only shows up at the first push. /// /// The app already knows one address that works, because it just made an API /// call over it, so the created remote is pointed at that host instead. Both /// URL shapes git accepts are handled: `ssh://user@host:port/path` and the /// scp-like `user@host:path`. pub fn rehost_ssh_url(ssh_url: &str, host: &str) -> String { let host = host.trim(); if host.is_empty() || ssh_url.trim().is_empty() { return ssh_url.to_string(); } if let Some(rest) = ssh_url.strip_prefix("ssh://") { let (authority, path) = match rest.find('/') { Some(i) => rest.split_at(i), None => (rest, ""), }; let mut out = String::from("ssh://"); if let Some((user, _)) = authority.rsplit_once('@') { out.push_str(user); out.push('@'); } out.push_str(host); if let Some(port) = port_of(authority) { out.push(':'); out.push_str(port); } out.push_str(path); return out; } // scp-like: everything before the first colon is [user@]host. if let Some((authority, path)) = ssh_url.split_once(':') { let mut out = String::new(); if let Some((user, _)) = authority.rsplit_once('@') { out.push_str(user); out.push('@'); } out.push_str(host); out.push(':'); out.push_str(path); return out; } ssh_url.to_string() } /// The numeric port of an `[user@]host[:port]` authority, if it has one. fn port_of(authority: &str) -> Option<&str> { let hostport = authority.rsplit_once('@').map_or(authority, |(_, h)| h); match hostport.rsplit_once(':') { Some((_, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => { Some(port) } _ => None, } } /// Create `repo` under the token's own account, returning what the server holds. /// /// A name that is already taken is not treated as a failure: the existing /// repository is fetched and returned with `created: false`, so pointing a /// workspace at a repository made on an earlier attempt works the same as /// making a fresh one. pub fn create(server: &str, user: &str, token: &str, repo: &NewRepo) -> Result { let api = api_base(server)?; let token = token.trim(); if token.is_empty() { return Err( "No Gitea token set (open Settings ▸ New project and fill in the \ Gitea rows)" .to_string(), ); } let name = normalize_name(&repo.name); if name.is_empty() { return Err("Give the repository a name".to_string()); } let agent = ureq::AgentBuilder::new() .timeout(Duration::from_secs(30)) .build(); let url = format!("{api}/user/repos"); let body = serde_json::json!({ "name": name, "description": repo.description.trim(), "private": repo.private, // The workspace already has (or will have) its own history; letting the // server commit a README first would mean the first push is rejected as // a non-fast-forward. "auto_init": false, }); // Serialised here rather than through `send_json`, which is behind a ureq // feature this build does not enable; serde_json is already a dependency. let result = agent .post(&url) .set("Authorization", &format!("token {token}")) .set("Content-Type", "application/json") .send_string(&body.to_string()); let repo = match result { Ok(resp) => { let text = resp .into_string() .map_err(|e| format!("Could not read Gitea's reply: {e}"))?; parse_repo(&text, true) } // Already there: attach to it rather than making the user rename. Err(ureq::Error::Status(409, _)) => fetch(&agent, &api, token, user, &name), Err(e) => Err(friendly_error(&api, e)), }; // Point SSH at the host the API call just succeeded over, rather than // whatever the server advertises — see `rehost_ssh_url`. repo.map(|mut repo| { repo.ssh_url = rehost_ssh_url(&repo.ssh_url, host_of(server)); repo }) } /// Read back a repository that already exists. fn fetch( agent: &ureq::Agent, api: &str, token: &str, user: &str, name: &str, ) -> Result { let owner = user.trim(); if owner.is_empty() { return Err(format!( "A repository named {name} already exists, but there is no Gitea user \ set to look it up under" )); } let url = format!("{api}/repos/{owner}/{name}"); let resp = agent .get(&url) .set("Authorization", &format!("token {token}")) .call() .map_err(|e| friendly_error(api, e))?; let text = resp .into_string() .map_err(|e| format!("Could not read Gitea's reply: {e}"))?; parse_repo(&text, false) } /// Pull the clone URLs out of a repository reply. fn parse_repo(body: &str, created: bool) -> Result { let reply: RepoReply = serde_json::from_str(body) .map_err(|e| format!("Unexpected reply from Gitea: {e}"))?; Ok(reply.into_repo(created)) } /// Turn a `ureq` error into something that says what to change. /// /// The status codes here are the ones a misconfiguration actually produces, and /// each has a different fix — a bad token and a bad URL are not distinguishable /// from "it didn't work". fn friendly_error(api: &str, err: ureq::Error) -> String { match err { ureq::Error::Status(401, _) => { "Gitea rejected the token (401) — check the token in Settings ▸ New project" .to_string() } ureq::Error::Status(403, _) => { "Gitea refused the request (403) — the token needs write access to \ repositories" .to_string() } ureq::Error::Status(404, _) => { format!("No Gitea API at {api} (404) — check the server URL") } ureq::Error::Status(422, resp) => { let detail = resp .into_string() .ok() .and_then(|b| serde_json::from_str::(&b).ok()) .and_then(|v| v.get("message").and_then(|m| m.as_str()).map(str::to_string)) .unwrap_or_else(|| "the name may be invalid".to_string()); format!("Gitea would not create it: {detail}") } ureq::Error::Status(code, _) => format!("Gitea returned HTTP {code}"), ureq::Error::Transport(t) => { format!("Could not reach Gitea at {api} — is the server up? ({t})") } } } #[cfg(test)] mod tests { use super::*; #[test] fn the_api_root_is_built_from_the_server_url() { assert_eq!( api_base("http://192.168.1.11:4000").unwrap(), "http://192.168.1.11:4000/api/v1" ); // A trailing slash is what you get from copying out of a browser bar. assert_eq!( api_base("https://git.example.com/").unwrap(), "https://git.example.com/api/v1" ); // Pasting the API root itself must not double it up. assert_eq!( api_base("https://git.example.com/api/v1").unwrap(), "https://git.example.com/api/v1" ); } #[test] fn an_unusable_server_url_says_what_is_wrong() { assert!(api_base("").is_err()); assert!(api_base(" ").is_err()); // Without a scheme ureq cannot build a request at all, and the error it // would raise says nothing useful. let err = api_base("192.168.1.11:4000").unwrap_err(); assert!(err.contains("http://"), "{err}"); } #[test] fn a_repository_name_is_normalised_the_way_gitea_would() { assert_eq!(normalize_name("My Book"), "My-Book"); assert_eq!(normalize_name(" spaced out "), "spaced-out"); // Runs collapse rather than piling up separators. assert_eq!(normalize_name("a // b"), "a-b"); // Legal punctuation survives. assert_eq!(normalize_name("the_book-2.md"), "the_book-2.md"); // Leading and trailing punctuation goes. assert_eq!(normalize_name("--draft--"), "draft"); assert_eq!(normalize_name("!!!"), ""); } #[test] fn the_ssh_host_becomes_the_one_the_api_call_reached() { // The real shape of the bug: Gitea advertises a public hostname whose // SSH port is only open on the LAN, so the URL connects from nowhere. assert_eq!( rehost_ssh_url( "ssh://git@gitlab.example.ca:2228/landon/book.git", "100.106.219.48", ), "ssh://git@100.106.219.48:2228/landon/book.git" ); // The port is the server's business and is kept; so is the path. assert_eq!( rehost_ssh_url("ssh://git@old/deep/path/repo.git", "new"), "ssh://git@new/deep/path/repo.git" ); } #[test] fn the_scp_like_url_shape_is_rewritten_too() { // Gitea emits this form when it is not using a custom SSH port. assert_eq!( rehost_ssh_url("git@gitlab.example.ca:landon/book.git", "100.106.219.48"), "git@100.106.219.48:landon/book.git" ); } #[test] fn rehosting_leaves_a_url_alone_when_there_is_nothing_to_put_in() { let url = "ssh://git@host:2228/landon/book.git"; assert_eq!(rehost_ssh_url(url, ""), url); assert_eq!(rehost_ssh_url(url, " "), url); assert_eq!(rehost_ssh_url("", "newhost"), ""); } #[test] fn the_host_is_pulled_out_of_the_server_url() { assert_eq!(host_of("http://100.106.219.48:4000"), "100.106.219.48"); assert_eq!(host_of("https://git.example.com/"), "git.example.com"); assert_eq!(host_of("https://git.example.com/api/v1"), "git.example.com"); // A bare host, as someone might type it. assert_eq!(host_of("192.168.1.11:4000"), "192.168.1.11"); // A non-numeric tail after the colon is not a port. assert_eq!(host_of("http://user:pass@git.example.com/x"), "git.example.com"); } #[test] fn the_clone_urls_come_out_of_the_reply() { // Trimmed to the fields used; a real reply carries dozens more, which // must not stop it deserializing. let body = r#"{ "id": 7, "full_name": "landon/my-book", "ssh_url": "ssh://git@192.168.1.11:2228/landon/my-book.git", "clone_url": "http://192.168.1.11:4000/landon/my-book.git", "html_url": "http://192.168.1.11:4000/landon/my-book", "empty": true, "size": 0 }"#; let repo = parse_repo(body, true).unwrap(); assert_eq!(repo.full_name, "landon/my-book"); assert!(repo.created); // SSH is the default because it pushes without embedding a credential. assert_eq!(repo.remote_url(true), "ssh://git@192.168.1.11:2228/landon/my-book.git"); assert_eq!(repo.remote_url(false), "http://192.168.1.11:4000/landon/my-book.git"); } #[test] fn a_reply_that_is_not_a_repository_is_reported_not_panicked() { // What a non-Gitea server at that address would send back. let err = parse_repo("Not found", true).unwrap_err(); assert!(err.contains("Unexpected reply from Gitea"), "{err}"); } #[test] fn creating_without_a_token_stops_before_the_network() { let repo = NewRepo { name: "book".to_string(), description: String::new(), private: false, }; let err = create("http://192.168.1.11:4000", "landon", " ", &repo).unwrap_err(); assert!(err.contains("token"), "{err}"); } #[test] fn creating_without_a_name_stops_before_the_network() { let repo = NewRepo { name: " !! ".to_string(), description: String::new(), private: false, }; let err = create("http://192.168.1.11:4000", "landon", "tok", &repo).unwrap_err(); assert!(err.contains("name"), "{err}"); } }