diff --git a/README.md b/README.md index cb876b8..17beb72 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,15 @@ Terminal=false Either way the URL lives in the repository's own `.git/config`, not in this app's settings, so every workspace keeps its own. + The same dialog can **make the repository for you**. Fill in the Gitea rows + under **Settings ▸ New project** (server, user, token — the token needs write + access to repositories), then give the repository a name and press **Create + and attach**. The app calls Gitea's API itself, then points `origin` at what + came back. A name that already exists is attached to rather than treated as + an error, so a second attempt after a failure does the right thing. Untick + **Attach over SSH** to use the HTTPS clone URL instead, which asks for a + credential on every push. + After that, the **⟳ Sync (git)** button commits all changes, `pull --rebase`s, and pushes (setting the upstream on the first push). Until origin is set it commits locally and says so in the log. @@ -516,13 +525,18 @@ push command alone and is not written into the project's `.git/config`. The token is stored in `config.json` in plain text, the same as the Mistral API key. -Note that this is the *only* place the app creates a remote repository, and it -runs entirely inside the template's hook — the app itself never talks to Gitea. -So it applies to projects made through **New project** and nothing else, and it -does nothing if **Run the template's hooks** is off, if any of the three Gitea -settings is blank, or if the template has no such hook. For any workspace that -did not come from the template, attach the remote with **Settings ▸ Git -remote…** instead. +This path runs entirely inside the template's own hook, so it applies to +projects made through **New project** and nothing else, and it does nothing if +**Run the template's hooks** is off, if any of the three Gitea settings is +blank, or if the template has no such hook. When it fails it fails in someone +else's Python, and all this app can report is that a hook exited non-zero — the +Git log window carries whatever the hook printed. + +**Settings ▸ Git remote… does not go through the template at all.** It calls the +Gitea API directly, works on any workspace, and reports what the server actually +said (a rejected token, a URL that is not a Gitea API, a name the server would +not take). If the hook is not publishing and you would rather not debug it, that +is the way round. ## New files from a template diff --git a/src/app/mod.rs b/src/app/mod.rs index f1f4aaa..dd73238 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -209,6 +209,13 @@ pub struct App { remote_input: String, /// Whether the git-remote dialog is up. show_git_remote: bool, + /// Name to give the repository the Gitea section would create. + gitea_repo_name: String, + /// Result channel for the in-flight Gitea call, if there is one. Its + /// presence is also what greys the button out, so only one can run. + gitea_rx: Option>>, + /// What the Gitea section is saying about the last (or running) attempt. + gitea_status: String, status: String, show_log: bool, git_log: String, @@ -379,6 +386,9 @@ impl App { confirm_delete: false, remote_input: String::new(), show_git_remote: false, + gitea_repo_name: String::new(), + gitea_rx: None, + gitea_status: String::new(), status: String::new(), show_log: false, git_log: String::new(), @@ -545,6 +555,7 @@ impl eframe::App for App { if self.show_git_remote { self.git_remote_window(ctx); } + self.poll_gitea_create(); if self.show_new_project { self.new_project_window(ctx); diff --git a/src/app/project.rs b/src/app/project.rs index 84eb275..96687a8 100644 --- a/src/app/project.rs +++ b/src/app/project.rs @@ -506,7 +506,7 @@ impl App { } ui.add_space(6.0); - ui.label(egui::RichText::new("Gitea (used by the template's hook)").strong()); + ui.label(egui::RichText::new("Gitea").strong()); egui::Grid::new("gitea_grid") .num_columns(2) .spacing([10.0, 8.0]) @@ -539,10 +539,12 @@ impl App { }); ui.label( egui::RichText::new( - "Passed to the hook as GITEA_URL / GITEA_USER / GITEA_TOKEN. \ - Leave blank and the hook skips publishing — the project is \ - still created. The token is stored in this app's config file \ - in plain text.", + "Used two ways: the app creates repositories with them from \ + Settings ▸ Git remote, and passes them to a template's hooks \ + as GITEA_URL / GITEA_USER / GITEA_TOKEN. Leave them blank and \ + both simply skip — the project is still created. The token \ + needs write access to repositories, and is stored in this \ + app's config file in plain text.", ) .small() .weak(), diff --git a/src/app/workspace.rs b/src/app/workspace.rs index 19c1844..9762520 100644 --- a/src/app/workspace.rs +++ b/src/app/workspace.rs @@ -1034,9 +1034,70 @@ impl App { /// box shows what the repository actually holds. pub(super) fn open_git_remote(&mut self) { self.remote_input = gitsync::origin_url(self.workspace()).unwrap_or_default(); + if self.gitea_repo_name.trim().is_empty() { + // The project folder's name is nearly always what the repository + // should be called, and it is the tedious part to type. + self.gitea_repo_name = self + .project_root() + .file_name() + .map(|n| crate::gitea::normalize_name(&n.to_string_lossy())) + .unwrap_or_default(); + } + self.gitea_status.clear(); self.show_git_remote = true; } + /// Ask Gitea for the repository named in the dialog, on a worker thread. + /// + /// The call goes over the network, so it cannot happen on the UI thread; + /// [`App::poll_gitea_create`] picks the answer up. While one is in flight + /// `gitea_rx` is `Some`, which is also what greys the button out. + fn start_gitea_create(&mut self, ctx: &egui::Context) { + if self.gitea_rx.is_some() { + return; + } + let server = self.config.gitea_url.clone(); + let user = self.config.gitea_user.clone(); + let token = self.config.gitea_token.clone(); + let repo = crate::gitea::NewRepo { + name: self.gitea_repo_name.clone(), + description: String::new(), + private: self.config.gitea_private, + }; + let (tx, rx) = std::sync::mpsc::channel(); + self.gitea_rx = Some(rx); + self.gitea_status = "Asking Gitea\u{2026}".to_string(); + let ctx = ctx.clone(); + std::thread::spawn(move || { + let _ = tx.send(crate::gitea::create(&server, &user, &token, &repo)); + ctx.request_repaint(); + }); + } + + /// Pick up a finished Gitea call and point `origin` at what came back. + /// + /// Creating the repository and attaching to it are one action as far as the + /// reader is concerned, so the remote is set here rather than left as a + /// second button to remember. Pushing is still [`App::git_sync`]'s job — + /// this only makes it possible. + pub(super) fn poll_gitea_create(&mut self) { + let received = self.gitea_rx.as_ref().and_then(|rx| rx.try_recv().ok()); + let Some(result) = received else { return }; + self.gitea_rx = None; + match result { + Ok(repo) => { + self.remote_input = repo.remote_url(self.config.gitea_use_ssh).to_string(); + self.set_git_remote(); + let verb = if repo.created { "Created" } else { "Already had" }; + self.gitea_status = format!( + "\u{2714} {verb} {} \u{2014} origin set. Press \u{27f3} Sync (git) to push.", + repo.full_name + ); + } + Err(e) => self.gitea_status = format!("\u{2716} {e}"), + } + } + /// Point the workspace's repository at the typed remote. /// /// Nothing about the URL is kept in this app's config: it is written to the @@ -1072,7 +1133,14 @@ impl App { let mut open = self.show_git_remote; let mut apply = false; let mut close = false; + let mut create = false; + let mut save_now = false; let is_repo = self.is_repo; + let busy = self.gitea_rx.is_some(); + // Read out before the closure borrows `self` mutably to draw with. + let server = self.config.gitea_url.trim().to_string(); + let user = self.config.gitea_user.trim().to_string(); + let have_token = !self.config.gitea_token.trim().is_empty(); let root = self .repo_root .as_ref() @@ -1123,15 +1191,78 @@ impl App { .small() .weak(), ); - ui.separator(); ui.horizontal(|ui| { apply |= ui.add_enabled(is_repo, egui::Button::new("Set origin")).clicked(); - close |= ui.button("Close").clicked(); }); + + ui.separator(); + ui.label(egui::RichText::new("Create it on Gitea").strong()); + if server.is_empty() || !have_token { + ui.label( + egui::RichText::new( + "Set the Gitea server, user and token in Settings \u{25b8} New \ + project first \u{2014} the app uses them for this too.", + ) + .small() + .weak(), + ); + } else { + ui.label( + egui::RichText::new(format!("{server} as {user}")) + .small() + .weak(), + ); + } + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label("Name:"); + ui.add( + egui::TextEdit::singleline(&mut self.gitea_repo_name) + .desired_width(200.0) + .hint_text("my-book"), + ); + save_now |= ui.checkbox(&mut self.config.gitea_private, "Private").changed(); + }); + save_now |= ui + .checkbox(&mut self.config.gitea_use_ssh, "Attach over SSH") + .on_hover_text( + "SSH pushes on a key. Turn this off to use the HTTPS clone \ + URL, which asks for a credential on every push.", + ) + .changed(); + ui.add_space(4.0); + ui.horizontal(|ui| { + let ready = is_repo && !server.is_empty() && have_token && !busy; + create |= ui + .add_enabled(ready, egui::Button::new("Create and attach")) + .on_hover_text( + "Create the repository on Gitea and point origin at it. \ + A name that already exists is attached to rather than \ + treated as an error.", + ) + .clicked(); + if busy { + ui.spinner(); + } + }); + if !self.gitea_status.is_empty() { + ui.label(egui::RichText::new(&self.gitea_status).small()); + } + + ui.separator(); + if ui.button("Close").clicked() { + close = true; + } + if save_now { + self.config.save(); + } }); if apply { self.set_git_remote(); } + if create { + self.start_gitea_create(ctx); + } self.show_git_remote = open && !close; } diff --git a/src/config.rs b/src/config.rs index b111eef..2088bbe 100644 --- a/src/config.rs +++ b/src/config.rs @@ -116,10 +116,18 @@ pub struct Config { /// Gitea account name, passed to hooks as `GITEA_USER`. #[serde(default)] pub gitea_user: String, - /// Gitea API token, passed to hooks as `GITEA_TOKEN`. Stored in plain text, - /// like the Mistral key. + /// Gitea API token, passed to hooks as `GITEA_TOKEN` and used for the app's + /// own API calls. Stored in plain text, like the Mistral key. #[serde(default)] pub gitea_token: String, + /// Whether repositories the app creates on Gitea are private. + #[serde(default)] + pub gitea_private: bool, + /// Whether to point `origin` at the SSH clone URL rather than the HTTPS one. + /// SSH is the default: it pushes on a key, where HTTPS would want a + /// credential typed in (or stored) on every push. + #[serde(default = "default_gitea_use_ssh")] + pub gitea_use_ssh: bool, /// Folders kept out of the file panel entirely, matched leniently so /// `Archive` covers `10-Archive`. An archive of dead drafts can hold /// hundreds of files that would otherwise swamp the tree. @@ -186,6 +194,11 @@ pub fn default_project_run_hooks() -> bool { true } +/// SSH by default — see [`Config::gitea_use_ssh`]. +pub fn default_gitea_use_ssh() -> bool { + true +} + /// Folders excluded from the panel by default: the snowflake layout's archive, /// which holds superseded drafts rather than working material. pub fn default_hidden_folders() -> Vec { @@ -301,6 +314,8 @@ impl Default for Config { gitea_url: String::new(), gitea_user: String::new(), gitea_token: String::new(), + gitea_private: false, + gitea_use_ssh: default_gitea_use_ssh(), hidden_folders: default_hidden_folders(), archive_folder: default_archive_folder(), manuscript_title: String::new(), @@ -418,6 +433,31 @@ mod tests { // The new field is filled in from its default rather than left blank. assert_eq!(cfg.new_file_template, default_new_file_template()); assert!(cfg.new_file_template.contains("{{marker}}")); + // Likewise the Gitea pair. `use_ssh` defaulting to *true* is the point: + // a `#[serde(default)]` bool would come back false and quietly attach + // every repository over HTTPS. + assert!(cfg.gitea_use_ssh); + assert!(!cfg.gitea_private); + } + + /// A defaulted-to-`true` flag is only half done: the default has to lose to + /// what is actually in the file, or turning SSH off would never stick. + #[test] + fn choosing_https_survives_a_reload() { + let saved = r####"{ + "workspace": "/home/writer/Manuscript", + "export_path": "/home/writer/Manuscript/book.odt", + "show_preview": true, + "draft_marker": "### Rough Draft:", + "editor_zoom": 10.0, + "languagetool_host": "lt.example.com", + "mistral_api_key": "secret", + "gitea_use_ssh": false, + "gitea_private": true + }"####; + let cfg: Config = serde_json::from_str(saved).expect("config must parse"); + assert!(!cfg.gitea_use_ssh, "an explicit false must beat the true default"); + assert!(cfg.gitea_private); } #[test] diff --git a/src/gitea.rs b/src/gitea.rs new file mode 100644 index 0000000..65f2fcc --- /dev/null +++ b/src/gitea.rs @@ -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 { + 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}"); + } +} diff --git a/src/main.rs b/src/main.rs index 3ad7000..e395568 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod characters; mod config; mod cookiecutter; mod fountain; +mod gitea; mod gitsync; mod help; mod langtool;