2 Commits

Author SHA1 Message Date
4c05749d02 Rediff patches
Add 0002-Use-pre-Rust-1.81-compatible-file-exists-test.patch: <REASON>
Add 0001-Rust-edition-downgrade-to-2021.patch: <REASON>
2025-06-06 19:22:09 -05:00
c0e6c5d89d Create Debian packaging files 2025-06-06 19:19:39 -05:00
16 changed files with 384 additions and 136 deletions

View File

@@ -51,3 +51,14 @@ One of these, defaults to `help`:
| upload-release | Uploads one-or-more files to an existing release, identified by it's tag name. |
| help | prints the help text (the usage summary above). |
## Unit Testing
The unit test~~s~~ require a Gitea server to execute against. This information is supplied by environment variables rather than on the command line, but it is otherwise exactly the same usage.
| Variable | Description |
|-|-|
| TEST_GITEA_SERVER | Server URL, match `-u`, `--url` |
| TEST_GITEA_REPO | Owner + repo name, match `-u` `--repo` |
| TEST_GITEA_KEY | API key, match `RELEASE_KEY_GITEA`. The use of a new variable for the API token is to help avoid accidentally touching a production environment during test execution. |
| TEST_GITEA_RELEASE_TAG | Git tag used to identify the Release. Same as `upload-release`'s positional argument `<TAG_NAME>`. |

5
debian/changelog vendored Normal file
View File

@@ -0,0 +1,5 @@
gt-tool (1.0.0-1) UNRELEASED; urgency=low
* Experimental release.
-- Robert Garrett <robertgarrett404@gmail.com> Sun, 1 Jun 2025 16:05:00 -0500

30
debian/control vendored Normal file
View File

@@ -0,0 +1,30 @@
Source: gt-tool
Maintainer: Robert Garrett <robertgarrett404@gmail.com>
Section: misc
Priority: optional
Standards-Version: 4.6.2
Build-Depends:
debhelper-compat (= 13),
dh-cargo,
librust-clap-dev,
librust-reqwest-dev,
librust-tokio-dev,
librust-serde-dev,
Homepage: https://git.gelvin.dev/robert/gt-tool
Vcs-Git: https://git.gelvin.dev/robert/gt-tool
Vcs-Browser: https://git.gelvin.dev/robert/gt-tool
Rules-Requires-Root: no
Package: gt-tool
Architecture: any
Depends:
${misc:Depends},
${shlibs:Depends},
Description: CLI tools for interacting with the Gitea API.
Use interactively to talk to your Gitea instance, or automatically via a CI/CD
pipeline. Currently supports:
.
- showing the Releases for a project
- creating a new Release for a project
- attaching files to a release

43
debian/copyright vendored Normal file
View File

@@ -0,0 +1,43 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: gt-tools
Upstream-Contact: Robert Garrett <robertgarrett404@gmail.com>
Source: https://source.mnt.re/reform/mnt-reform-setup-wizard
Files: *
Copyright: 2025 Robert Garrett <robertgarrett404@gmail.com>
License: GPL-3+
Files: debian/*
Copyright: 2025 Robert Garrett <robertgarrett404@gmail.com>
License: GPL-3+
Files: debian/rules
Copyright:
Johannes Schauer Marin Rodrigues <josch@debian.org>
2025 Robert Garrett <robertgarrett404@gmail.com>
License: GPL-3+
Comment:
The debian/rules file is liften directly from the tuigreet package. It was
linked in the Debian Rust Team Book as a pretty simple example package. The
only change I've made is to remove the documentation generation target.
.
https://salsa.debian.org/debian/tuigreet/-/blob/master/debian/rules?ref_type=heads
License: GPL-3+
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
.
It is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public
License for more details.
.
You should have received a copy of the GNU General Public License
along with it. If not, see <http://www.gnu.org/licenses/>.
.
On Debian systems, the full text of the GNU General Public License version 3
can be found in the file /usr/share/common-licenses/GPL-3.

6
debian/gbp.conf vendored Normal file
View File

@@ -0,0 +1,6 @@
[DEFAULT]
compression = xz
compression-level = 9
upstream-tag = v%(version)s
debian-branch = deb

View File

@@ -0,0 +1,23 @@
From: Robert Garrett <robertgarrett404@gmail.com>
Date: Sun, 1 Jun 2025 17:59:20 -0500
Subject: Rust edition downgrade to 2021
Debian Bookworm uses Rust 1.64 which only supports up to the 2021
edition.
---
Cargo.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Cargo.toml b/Cargo.toml
index febccc4..cf52754 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,7 +1,7 @@
[package]
name = "gt-tool"
version = "1.0.0"
-edition = "2024"
+edition = "2021"
[dependencies]
clap = { version = "4.0.7", features = ["derive", "env"] }

View File

@@ -0,0 +1,31 @@
From: Robert Garrett <robertgarrett404@gmail.com>
Date: Sun, 1 Jun 2025 18:01:21 -0500
Subject: Use pre Rust 1.81 compatible file-exists test
The function `std::fs::exists(...)` was stabilized in Rust 1.81, which
means it can't be used in the Debian Bookworm build. This patch swaps to
a compatible implementation leaning on the std::path::Path struct.
---
src/api/release_attachment.rs | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/api/release_attachment.rs b/src/api/release_attachment.rs
index 9af71bf..8a0c798 100644
--- a/src/api/release_attachment.rs
+++ b/src/api/release_attachment.rs
@@ -1,4 +1,4 @@
-use std::fs;
+use std::{fs, path};
pub fn check_release_match_repo() {}
pub fn get_release_attachment() {}
@@ -16,7 +16,8 @@ pub async fn create_release_attachment(
// Ensure all files exists before starting the uploads
for file in &files {
- match fs::exists(file) {
+ let path = path::Path::new(file);
+ match path.try_exists() {
Ok(true) => continue,
Ok(false) => return Err(crate::Error::NoSuchFile),
Err(e) => {

2
debian/patches/series vendored Normal file
View File

@@ -0,0 +1,2 @@
0001-Rust-edition-downgrade-to-2021.patch
0002-Use-pre-Rust-1.81-compatible-file-exists-test.patch

26
debian/rules vendored Executable file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/make -f
export DEB_BUILD_MAINT_OPTIONS = hardening=+all
DPKG_EXPORT_BUILDFLAGS = 1
include /usr/share/dpkg/default.mk
include /usr/share/rustc/architecture.mk
export DEB_HOST_RUST_TYPE
export PATH:=/usr/share/cargo/bin:$(PATH)
export CARGO=/usr/share/cargo/bin/cargo
export CARGO_HOME=$(CURDIR)/debian/cargo_home
export CARGO_REGISTRY=$(CURDIR)/debian/cargo_registry
export DEB_CARGO_CRATE=$(DEB_SOURCE)_$(DEB_VERSION_UPSTREAM)
%:
dh $@ --buildsystem=cargo
execute_after_dh_auto_clean:
$(CARGO) clean
rm -rf $(CARGO_HOME)
rm -rf $(CARGO_REGISTRY)
rm -f debian/cargo-checksum.json
execute_before_dh_auto_configure:
$(CARGO) prepare-debian $(CARGO_REGISTRY) --link-from-system
rm -f Cargo.lock
touch debian/cargo-checksum.json

1
debian/source/format vendored Normal file
View File

@@ -0,0 +1 @@
3.0 (quilt)

View File

@@ -1,7 +1,9 @@
use serde::{Deserialize, Serialize};
use crate::{
Result,
ApiError, Result,
structs::{
self,
release::{CreateReleaseOption, Release},
},
};
@@ -21,21 +23,23 @@ pub async fn list_releases(
let request_url = format!("{gitea_url}/api/v1/repos/{repo}/releases/");
let req = client.get(request_url).send().await;
let response = req.map_err(|reqwest_err| crate::Error::WrappedReqwestErr(reqwest_err))?;
if response.status().is_success() {
let release_list = response
.json::<Vec<Release>>()
.await
.map_err(|reqwest_err| {
// Convert reqwest errors to my own
// TODO: Create all error variants (see lib.rs)
crate::Error::WrappedReqwestErr(reqwest_err)
})?;
return Ok(release_list);
} else if response.status().is_client_error() {
let mesg = crate::decode_client_error(response).await?;
return Err(crate::Error::ApiErrorMessage(mesg));
}
panic!("Reached end of list_releases without matching a return pathway.");
let release_list = response
.json::<Vec<Release>>()
.await
.map_err(|reqwest_err| {
// Convert reqwest errors to my own
// TODO: Create all error variants (see lib.rs)
crate::Error::WrappedReqwestErr(reqwest_err)
})?;
return Ok(release_list);
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(untagged)]
enum CreateResult {
Success(structs::release::Release),
ErrWithMessage(ApiError),
Empty,
}
pub async fn create_release(
@@ -45,23 +49,27 @@ pub async fn create_release(
submission: CreateReleaseOption,
) -> Result<Release> {
let request_url = format!("{gitea_url}/api/v1/repos/{repo}/releases");
let response = client
let req = client
.post(request_url)
.json(&submission)
.send()
.await
.map_err(|e| crate::Error::from(e))?;
if response.status().is_success() {
let new_release = response
.json::<Release>()
.await
.map_err(|e| crate::Error::from(e))?;
return Ok(new_release);
} else if response.status().is_client_error() {
let mesg = crate::decode_client_error(response).await?;
return Err(crate::Error::ApiErrorMessage(mesg))
let new_release = req
.json::<CreateResult>()
.await
.map_err(|e| crate::Error::from(e))?;
match new_release {
CreateResult::Success(release) => Ok(release),
CreateResult::ErrWithMessage(api_error) => {
if api_error.message == "token is required" {
Err(crate::Error::MissingAuthToken)
} else {
Err(crate::Error::ApiErrorMessage(api_error))
}
}
CreateResult::Empty => panic!("How can we have 200 OK and no release info? No. Crash"),
}
panic!("Reached end of create_release without matching a return path");
}
pub fn edit_release(id: u64) -> Result<Release> {
todo!();

View File

@@ -1,6 +1,4 @@
use std::{fs, path};
use crate::structs::Attachment;
use std::fs;
pub fn check_release_match_repo() {}
pub fn get_release_attachment() {}
@@ -12,45 +10,174 @@ pub async fn create_release_attachment(
gitea_url: &str,
repo: &str,
release_id: usize,
file: String,
) -> crate::Result<Attachment> {
files: Vec<String>,
) -> crate::Result<()> {
let request_url = format!("{gitea_url}/api/v1/repos/{repo}/releases/{release_id}/assets");
let path = path::Path::new(&file);
match path.try_exists() {
Ok(true) => (),
Ok(false) => return Err(crate::Error::NoSuchFile),
Err(e) => {
eprintln!("Uh oh! The file-exists check couldn't be done: {e}");
panic!("TODO: Deal with scenario where the file's existence cannot be checked (e.g.: no permission)");
},
// Ensure all files exists before starting the uploads
for file in &files {
match fs::exists(file) {
Ok(true) => continue,
Ok(false) => return Err(crate::Error::NoSuchFile),
Err(e) => {
eprintln!("Uh oh! The file-exists check couldn't be done: {e}");
panic!("TODO: Deal with scenario where the file's existence cannot be checked (e.g.: no permission)");
},
}
}
println!("Uploading file {}", &file);
let data = reqwest::multipart::Part::stream(fs::read(&file).unwrap())
.file_name("attachment")
.mime_str("text/plain")?;
for file in files {
println!("Uploading file {}", &file);
let data = reqwest::multipart::Part::stream(fs::read(&file).unwrap())
.file_name("attachment")
.mime_str("text/plain")?;
let form = reqwest::multipart::Form::new().part("attachment", data);
let form = reqwest::multipart::Form::new().part("attachment", data);
let response = client
.post(&request_url)
.multipart(form)
.query(&[("name", file.split("/").last())])
.send()
.await?;
if response.status().is_success() {
// TODO: create a struct Attachment and return it to the caller.
let attachment_desc = response
.json::<Attachment>()
.await
.map_err(|e| crate::Error::from(e))?;
return Ok(attachment_desc);
} else if response.status().is_client_error() {
let mesg = crate::decode_client_error(response).await?;
return Err(crate::Error::ApiErrorMessage(mesg));
let request = client
.post(&request_url)
.multipart(form)
.query(&[("name", file.split("/").last())])
.send()
.await?;
}
panic!("Reached end of release_attachment without matching a return path");
Ok(())
}
pub fn edit_release_attachment() {}
pub fn delete_release_attachment() {}
#[cfg(test)]
mod tests {
use reqwest::header::{self, ACCEPT};
use crate::structs::release::Release;
#[tokio::test]
async fn attach_file_exists() {
let conf = TestConfig::new();
let release_candidates =
crate::api::release::list_releases(
&conf.client,
&conf.server,
&conf.repo
)
.await
.expect("Failed to get releases. Pre-conditions unmet, aborting test!");
let release = match_release_by_tag(&conf.release_tag, release_candidates)
.expect("Failed to select matching release. Pre-conditions unmet, aborting test!");
let api_result = super::create_release_attachment(
&conf.client,
&conf.server,
&conf.repo,
release.id,
vec![String::from("Cargo.toml")],
)
.await;
}
#[tokio::test]
async fn attach_file_missing() {
let conf = TestConfig::new();
let release_candidates =
crate::api::release::list_releases(
&conf.client,
&conf.server,
&conf.repo
)
.await
.expect("Failed to get releases. Pre-conditions unmet, aborting test!");
let release = match_release_by_tag(&conf.release_tag, release_candidates)
.expect("Failed to select matching release. Pre-conditions unmet, aborting test!");
let api_result = super::create_release_attachment(
&conf.client,
&conf.server,
&conf.repo,
release.id,
vec![String::from("./this-file-doesnt-exist")],
)
.await;
let api_err = api_result.expect_err("Received Ok(()) after uploading non-existent file. That's nonsense, the API Function is wrong.");
match api_err {
crate::Error::Placeholder => panic!("Received dummy response from the API function. Finish implementing it, stupid"),
crate::Error::WrappedReqwestErr(error) => panic!("Received a reqwest::Error from the API function: {error}"),
crate::Error::MissingAuthToken => unreachable!("Missing auth token... in a unit test that already panics without the auth token..."),
crate::Error::NoSuchFile => (), // test passes
crate::Error::ApiErrorMessage(api_error) => panic!("Received an error message from the API: {api_error:?}"),
}
}
struct TestConfig {
server: String,
repo: String,
release_tag: String,
client: reqwest::Client,
}
impl TestConfig {
fn new() -> Self {
let server = std::env::var("TEST_GITEA_SERVER")
.expect("Must set server address in env var \"TEST_GITEA_SERVER\"");
let repo = std::env::var("TEST_GITEA_REPO")
.expect("Must set <user>/<repo> name in env var \"TEST_GITEA_REPO\"");
let token = format!(
"token {}",
std::env::var("TEST_GITEA_KEY")
.expect("Must set the API token in env var \"TEST_GITEA_KEY\"")
);
let release_tag = std::env::var("TEST_GITEA_RELEASE_TAG")
.expect("Must set the target release tag in env var \"TEST_GITEA_RELEASE_TAG\"");
let mut headers = reqwest::header::HeaderMap::new();
headers.append(ACCEPT, header::HeaderValue::from_static("application/json"));
headers.append("Authorization", token.parse().unwrap());
let client = reqwest::Client::builder()
.user_agent(format!(
"gt-tools-autotest-agent{}",
env!("CARGO_PKG_VERSION")
))
.default_headers(headers)
.build()
.expect("Failed to build reqwest::Client.");
return Self {
server,
repo,
release_tag,
client
};
}
}
// Testing utils
fn match_release_by_tag(tag: &String, releases: Vec<Release>) -> Option<Release> {
let mut release: Option<Release> = None;
for rel in releases {
if rel.tag_name == *tag {
// Only store the value if one hasn't been stored already
if let Some(first_release) = &release {
// if there was already a match, begin the error diagnostic creation.
let first_id = first_release.id;
let second_id = rel.id;
assert!(
first_id != second_id,
"FAILURE: Found the same release ID twice while scanning for duplicate tags. How did we get the same one twice?"
);
eprintln!("ERROR: Two releases have been found for the tag \"{tag}\".");
eprintln!("ERROR: first ID: {first_id}");
eprintln!("ERROR: second ID: {second_id}");
panic!("ERROR: Nonsense detected, I'm bailing out!");
} else {
// else, store our first (and hopefully only) match
release = Some(rel);
}
}
}
return release;
}
}

View File

@@ -10,15 +10,6 @@ pub struct ApiError {
url: String,
}
pub (crate) async fn decode_client_error(response: reqwest::Response) -> Result<ApiError> {
response
.json::<ApiError>()
.await
.map_err(|reqwest_err| {
crate::Error::WrappedReqwestErr(reqwest_err)
})
}
#[derive(Debug)]
pub enum Error {
Placeholder, // TODO: Enumerate error modes

View File

@@ -1,6 +1,3 @@
use std::path;
use gt_tool::cli::Args;
use gt_tool::structs::release::{CreateReleaseOption, Release};
@@ -34,7 +31,7 @@ async fn main() -> Result<(), gt_tool::Error> {
let releases =
gt_tool::api::release::list_releases(&client, &args.gitea_url, &args.repo).await?;
for release in releases {
println!("{}", release);
println!("{:?}", release);
}
}
gt_tool::cli::Commands::CreateRelease {
@@ -81,27 +78,14 @@ async fn main() -> Result<(), gt_tool::Error> {
gt_tool::api::release::list_releases(&client, &args.gitea_url, &args.repo).await?;
if let Some(release) = match_release_by_tag(&tag_name, release_candidates) {
for file in &files {
let path = path::Path::new(&file);
match path.try_exists() {
Ok(true) => continue,
Ok(false) => return Err(gt_tool::Error::NoSuchFile),
Err(e) => {
eprintln!("Uh oh! The file-exists check couldn't be done: {e}");
panic!("TODO: Deal with scenario where the file's existence cannot be checked (e.g.: no permission)");
},
}
}
for file in files {
let _attach_desc = gt_tool::api::release_attachment::create_release_attachment(
&client,
&args.gitea_url,
&args.repo,
release.id,
file,
)
.await?;
}
gt_tool::api::release_attachment::create_release_attachment(
&client,
&args.gitea_url,
&args.repo,
release.id,
files,
)
.await?;
} else {
println!("ERR: Couldn't find a release matching the tag \"{tag_name}\".");
return Err(gt_tool::Error::NoSuchRelease);

View File

@@ -1,15 +1,2 @@
use serde::{Deserialize, Serialize};
pub mod release;
pub mod repo;
#[derive(Debug, Deserialize, Serialize)]
pub struct Attachment {
id: usize,
name: String,
size: i64,
download_count: i64,
created_at: String, // TODO: Date-time struct
uuid: String,
browser_download_url: String,
}

View File

@@ -1,5 +1,3 @@
use std::fmt::Display;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
@@ -21,31 +19,6 @@ pub struct Release {
author: Author,
}
impl Display for Release {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let body = if self.body.len() > 0 {
&self.body
} else {
&String::from("(empty body)")
};
write!(f,
"Tag: {}
Name: {}
{}
Published: {} (created: {})
Author: {} ({})
",
self.tag_name,
self.name,
body,
self.published_at,
self.created_at,
self.author.login,
self.author.email,
)
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Author {
id: usize,