Create the Gitea repository from the app, not a template hook
Repository creation was delegated entirely to a cookiecutter template's post-gen hook. That only ever covered projects made through New project, and when it went wrong it went wrong inside someone else's Python, where all this app could report was that a hook exited non-zero. The app now talks to Gitea itself. Settings > Git remote... gains a "Create and attach" button: it POSTs to /api/v1/user/repos with the configured server, user and token, then points origin at the clone URL that comes back. A name that is already taken is fetched and attached to rather than raised as an error, so retrying after a failure works. The call runs on a worker thread with the existing channel-and-poll pattern, so the UI does not block on the network. Errors are mapped by status code, because a rejected token, a URL that is not a Gitea API and a name the server refuses each need a different fix. Uses ureq's send_string rather than send_json, whose feature this build does not enable; no new dependency, and ldd still shows only libc/libgcc/libm. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBWj9TphFMCoh7VHaSRnvQ
This commit is contained in:
+346
@@ -0,0 +1,346 @@
|
||||
//! 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<String, String> {
|
||||
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<Repo, String> {
|
||||
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<Repo, String> {
|
||||
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<Repo, String> {
|
||||
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::<serde_json::Value>(&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("<html><body>Not found</body></html>", 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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user