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:
@@ -75,6 +75,15 @@ Terminal=false
|
|||||||
Either way the URL lives in the repository's own `.git/config`, not in this
|
Either way the URL lives in the repository's own `.git/config`, not in this
|
||||||
app's settings, so every workspace keeps its own.
|
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,
|
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
|
and pushes (setting the upstream on the first push). Until origin is set it
|
||||||
commits locally and says so in the log.
|
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
|
The token is stored in `config.json` in plain text, the same as the Mistral API
|
||||||
key.
|
key.
|
||||||
|
|
||||||
Note that this is the *only* place the app creates a remote repository, and it
|
This path runs entirely inside the template's own hook, so it applies to
|
||||||
runs entirely inside the template's hook — the app itself never talks to Gitea.
|
projects made through **New project** and nothing else, and it does nothing if
|
||||||
So it applies to projects made through **New project** and nothing else, and it
|
**Run the template's hooks** is off, if any of the three Gitea settings is
|
||||||
does nothing if **Run the template's hooks** is off, if any of the three Gitea
|
blank, or if the template has no such hook. When it fails it fails in someone
|
||||||
settings is blank, or if the template has no such hook. For any workspace that
|
else's Python, and all this app can report is that a hook exited non-zero — the
|
||||||
did not come from the template, attach the remote with **Settings ▸ Git
|
Git log window carries whatever the hook printed.
|
||||||
remote…** instead.
|
|
||||||
|
**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
|
## New files from a template
|
||||||
|
|
||||||
|
|||||||
@@ -209,6 +209,13 @@ pub struct App {
|
|||||||
remote_input: String,
|
remote_input: String,
|
||||||
/// Whether the git-remote dialog is up.
|
/// Whether the git-remote dialog is up.
|
||||||
show_git_remote: bool,
|
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<std::sync::mpsc::Receiver<Result<crate::gitea::Repo, String>>>,
|
||||||
|
/// What the Gitea section is saying about the last (or running) attempt.
|
||||||
|
gitea_status: String,
|
||||||
status: String,
|
status: String,
|
||||||
show_log: bool,
|
show_log: bool,
|
||||||
git_log: String,
|
git_log: String,
|
||||||
@@ -379,6 +386,9 @@ impl App {
|
|||||||
confirm_delete: false,
|
confirm_delete: false,
|
||||||
remote_input: String::new(),
|
remote_input: String::new(),
|
||||||
show_git_remote: false,
|
show_git_remote: false,
|
||||||
|
gitea_repo_name: String::new(),
|
||||||
|
gitea_rx: None,
|
||||||
|
gitea_status: String::new(),
|
||||||
status: String::new(),
|
status: String::new(),
|
||||||
show_log: false,
|
show_log: false,
|
||||||
git_log: String::new(),
|
git_log: String::new(),
|
||||||
@@ -545,6 +555,7 @@ impl eframe::App for App {
|
|||||||
if self.show_git_remote {
|
if self.show_git_remote {
|
||||||
self.git_remote_window(ctx);
|
self.git_remote_window(ctx);
|
||||||
}
|
}
|
||||||
|
self.poll_gitea_create();
|
||||||
|
|
||||||
if self.show_new_project {
|
if self.show_new_project {
|
||||||
self.new_project_window(ctx);
|
self.new_project_window(ctx);
|
||||||
|
|||||||
+7
-5
@@ -506,7 +506,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(6.0);
|
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")
|
egui::Grid::new("gitea_grid")
|
||||||
.num_columns(2)
|
.num_columns(2)
|
||||||
.spacing([10.0, 8.0])
|
.spacing([10.0, 8.0])
|
||||||
@@ -539,10 +539,12 @@ impl App {
|
|||||||
});
|
});
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new(
|
egui::RichText::new(
|
||||||
"Passed to the hook as GITEA_URL / GITEA_USER / GITEA_TOKEN. \
|
"Used two ways: the app creates repositories with them from \
|
||||||
Leave blank and the hook skips publishing — the project is \
|
Settings ▸ Git remote, and passes them to a template's hooks \
|
||||||
still created. The token is stored in this app's config file \
|
as GITEA_URL / GITEA_USER / GITEA_TOKEN. Leave them blank and \
|
||||||
in plain text.",
|
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()
|
.small()
|
||||||
.weak(),
|
.weak(),
|
||||||
|
|||||||
+133
-2
@@ -1034,9 +1034,70 @@ impl App {
|
|||||||
/// box shows what the repository actually holds.
|
/// box shows what the repository actually holds.
|
||||||
pub(super) fn open_git_remote(&mut self) {
|
pub(super) fn open_git_remote(&mut self) {
|
||||||
self.remote_input = gitsync::origin_url(self.workspace()).unwrap_or_default();
|
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;
|
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.
|
/// 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
|
/// 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 open = self.show_git_remote;
|
||||||
let mut apply = false;
|
let mut apply = false;
|
||||||
let mut close = false;
|
let mut close = false;
|
||||||
|
let mut create = false;
|
||||||
|
let mut save_now = false;
|
||||||
let is_repo = self.is_repo;
|
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
|
let root = self
|
||||||
.repo_root
|
.repo_root
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -1123,15 +1191,78 @@ impl App {
|
|||||||
.small()
|
.small()
|
||||||
.weak(),
|
.weak(),
|
||||||
);
|
);
|
||||||
ui.separator();
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
apply |= ui.add_enabled(is_repo, egui::Button::new("Set origin")).clicked();
|
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 {
|
if apply {
|
||||||
self.set_git_remote();
|
self.set_git_remote();
|
||||||
}
|
}
|
||||||
|
if create {
|
||||||
|
self.start_gitea_create(ctx);
|
||||||
|
}
|
||||||
self.show_git_remote = open && !close;
|
self.show_git_remote = open && !close;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+42
-2
@@ -116,10 +116,18 @@ pub struct Config {
|
|||||||
/// Gitea account name, passed to hooks as `GITEA_USER`.
|
/// Gitea account name, passed to hooks as `GITEA_USER`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub gitea_user: String,
|
pub gitea_user: String,
|
||||||
/// Gitea API token, passed to hooks as `GITEA_TOKEN`. Stored in plain text,
|
/// Gitea API token, passed to hooks as `GITEA_TOKEN` and used for the app's
|
||||||
/// like the Mistral key.
|
/// own API calls. Stored in plain text, like the Mistral key.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub gitea_token: String,
|
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
|
/// Folders kept out of the file panel entirely, matched leniently so
|
||||||
/// `Archive` covers `10-Archive`. An archive of dead drafts can hold
|
/// `Archive` covers `10-Archive`. An archive of dead drafts can hold
|
||||||
/// hundreds of files that would otherwise swamp the tree.
|
/// hundreds of files that would otherwise swamp the tree.
|
||||||
@@ -186,6 +194,11 @@ pub fn default_project_run_hooks() -> bool {
|
|||||||
true
|
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,
|
/// Folders excluded from the panel by default: the snowflake layout's archive,
|
||||||
/// which holds superseded drafts rather than working material.
|
/// which holds superseded drafts rather than working material.
|
||||||
pub fn default_hidden_folders() -> Vec<String> {
|
pub fn default_hidden_folders() -> Vec<String> {
|
||||||
@@ -301,6 +314,8 @@ impl Default for Config {
|
|||||||
gitea_url: String::new(),
|
gitea_url: String::new(),
|
||||||
gitea_user: String::new(),
|
gitea_user: String::new(),
|
||||||
gitea_token: String::new(),
|
gitea_token: String::new(),
|
||||||
|
gitea_private: false,
|
||||||
|
gitea_use_ssh: default_gitea_use_ssh(),
|
||||||
hidden_folders: default_hidden_folders(),
|
hidden_folders: default_hidden_folders(),
|
||||||
archive_folder: default_archive_folder(),
|
archive_folder: default_archive_folder(),
|
||||||
manuscript_title: String::new(),
|
manuscript_title: String::new(),
|
||||||
@@ -418,6 +433,31 @@ mod tests {
|
|||||||
// The new field is filled in from its default rather than left blank.
|
// The new field is filled in from its default rather than left blank.
|
||||||
assert_eq!(cfg.new_file_template, default_new_file_template());
|
assert_eq!(cfg.new_file_template, default_new_file_template());
|
||||||
assert!(cfg.new_file_template.contains("{{marker}}"));
|
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]
|
#[test]
|
||||||
|
|||||||
+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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ mod characters;
|
|||||||
mod config;
|
mod config;
|
||||||
mod cookiecutter;
|
mod cookiecutter;
|
||||||
mod fountain;
|
mod fountain;
|
||||||
|
mod gitea;
|
||||||
mod gitsync;
|
mod gitsync;
|
||||||
mod help;
|
mod help;
|
||||||
mod langtool;
|
mod langtool;
|
||||||
|
|||||||
Reference in New Issue
Block a user