//! 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() } /// 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()); 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)), } } /// 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_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}"); } }