17 Commits

Author SHA1 Message Date
119831481e Bump to v2.2.0
All checks were successful
/ Compile and upload a release build (release) Successful in 44s
2025-07-03 18:13:09 -05:00
7246c7afb6 Oops, missed one 2025-07-03 18:05:18 -05:00
84eaaa1dbd Autoformat 2025-07-03 18:03:33 -05:00
c9dda5760c Prefix unused variables to quiet the linter 2025-07-03 17:56:07 -05:00
336f1453b9 Address most of the cargo-clippy lints 2025-07-03 17:56:07 -05:00
f068e8233e Release.colorized(), not std::fmt::Display
I don't know for sure if the string-ified version of a Release struct is
being printed to the terminal. As such, I don't know if the user wants,
does not want, or has mixed intentions for the stringification of this
thing.

No Display impl, instead just a `colorized()` method.
2025-07-03 17:47:50 -05:00
d4ef21e243 Change to free-fn intersperse for stdlib compat
Itertools warns that the standard library may be stabilizing the
intersperse method soon and recommends using this function instead.
2025-07-02 22:44:06 -05:00
d94c350cde Galaxy-brained newline intersperse function
Itertools already has an intersperse method for me. Why would I build my
own when I can do this? There's even a `fold()` over the units that come
out of the print routine.
2025-07-02 22:29:07 -05:00
8120cb0489 Remove trailing newline in Release item printout 2025-07-02 22:08:45 -05:00
b82cfdb822 Colorize the output! 2025-07-02 22:06:36 -05:00
ea046c929f Print releases in reverse order for easier reading
The result list has the newest item first, but I want to print them the
other way around. This way the newest (and presumably most interesting)
release is always the visible item, regardless of how many others have
printed and scrolled off screen.
2025-07-02 21:42:41 -05:00
135acf09b7 Basic impl Display for the Release struct
I'm not certain what info I want to present when listing the Releases.

The idea is that the release version is the most important, and that it
matches the git-tag associated with the release. I'll print that first.

Next, the name of the release followed by the body text. The list of
releases will become quite large for some projects, and the body text
may include a changelog. Both of these will cause the output to become
quite large. I will need to create a size limiter, but I'm ignoring that
for now.

Who created the release and when may be useful when searching for a
release, so I've included that as the final section.
2025-07-02 12:56:17 -05:00
136c051c82 Fix: incorrect field names for Attachment
All checks were successful
/ Compile and upload a release build (release) Successful in 37s
I think I got the names from the Go source code, but the API emits JSON
that has these names instead. The api/swagger guide even says as much.

This caused the super fun quirk that the upload actually succeedes, but
the program reports an error condition because of the deserialization
failure. Time to bump a minor revision!
2025-06-12 17:21:48 -05:00
a0ba8e7ea8 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.

I'm both "upstreaming" a Debian-specific patch I had to make for the
package, and fixing the additional usage now in `main.rs`. There doesn't
seem to be any compelling reason to avoid using this function, so I
figure I should merge it into the base release.
2025-06-12 16:05:51 -05:00
88cafc096f Drop unused import in api/release.rs 2025-06-08 10:43:32 -05:00
b200785e71 ... and the unit testing notes in README.md 2025-06-08 10:40:54 -05:00
8246337ae4 Delete the unit tests
They aren't useful anyway.
2025-06-08 00:06:59 -05:00
8 changed files with 75 additions and 191 deletions

View File

@@ -1,10 +1,12 @@
[package]
name = "gt-tool"
version = "1.0.0"
version = "2.2.0"
edition = "2024"
[dependencies]
clap = { version = "4.0.7", features = ["derive", "env"] }
colored = "2.0.0"
itertools = "0.10.0"
reqwest = { version = "0.11.13", features = ["json", "stream", "multipart"] }
serde = { version = "1.0.152", features = ["derive"] }
tokio = { version = "1.24.2", features = ["macros", "rt-multi-thread"] }

View File

@@ -51,14 +51,3 @@ 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>`. |

View File

@@ -1,12 +1,9 @@
use crate::{
ApiError, Result,
structs::{
release::{CreateReleaseOption, Release},
},
Result,
structs::release::{CreateReleaseOption, Release},
};
pub fn get_release(id: u64) -> Result<Release> {
pub fn get_release(_id: u64) -> Result<Release> {
todo!();
}
pub fn get_latest_release() -> Result<Release> {
@@ -20,7 +17,7 @@ pub async fn list_releases(
) -> Result<Vec<Release>> {
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))?;
let response = req.map_err(crate::Error::WrappedReqwestErr)?;
if response.status().is_success() {
let release_list = response
.json::<Vec<Release>>()
@@ -50,22 +47,22 @@ pub async fn create_release(
.json(&submission)
.send()
.await
.map_err(|e| crate::Error::from(e))?;
.map_err(crate::Error::from)?;
if response.status().is_success() {
let new_release = response
.json::<Release>()
.await
.map_err(|e| crate::Error::from(e))?;
.map_err(crate::Error::from)?;
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))
return Err(crate::Error::ApiErrorMessage(mesg));
}
panic!("Reached end of create_release without matching a return path");
}
pub fn edit_release(id: u64) -> Result<Release> {
pub fn edit_release(_id: u64) -> Result<Release> {
todo!();
}
pub fn delete_release(id: u64) -> Result<()> {
pub fn delete_release(_id: u64) -> Result<()> {
todo!();
}

View File

@@ -1,4 +1,4 @@
use std::fs;
use std::{fs, path};
use crate::structs::Attachment;
@@ -16,13 +16,16 @@ pub async fn create_release_attachment(
) -> crate::Result<Attachment> {
let request_url = format!("{gitea_url}/api/v1/repos/{repo}/releases/{release_id}/assets");
match fs::exists(&file) {
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)");
},
panic!(
"TODO: Deal with scenario where the file's existence cannot be checked (e.g.: no permission)"
);
}
}
println!("Uploading file {}", &file);
@@ -43,7 +46,7 @@ pub async fn create_release_attachment(
let attachment_desc = response
.json::<Attachment>()
.await
.map_err(|e| crate::Error::from(e))?;
.map_err(crate::Error::from)?;
return Ok(attachment_desc);
} else if response.status().is_client_error() {
let mesg = crate::decode_client_error(response).await?;
@@ -53,140 +56,3 @@ pub async fn create_release_attachment(
}
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=>(),
crate::Error::ApiErrorMessage(api_error)=>panic!("Received an error message from the API: {api_error:?}"),
crate::Error::NoSuchRelease => todo!(),
}
}
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,13 +10,11 @@ pub struct ApiError {
url: String,
}
pub (crate) async fn decode_client_error(response: reqwest::Response) -> Result<ApiError> {
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)
})
.map_err(crate::Error::WrappedReqwestErr)
}
#[derive(Debug)]

View File

@@ -1,4 +1,4 @@
use std::fs;
use std::path;
use gt_tool::cli::Args;
use gt_tool::structs::release::{CreateReleaseOption, Release};
@@ -21,10 +21,7 @@ async fn main() -> Result<(), gt_tool::Error> {
headers.append("Authorization", token.parse().unwrap());
}
let client = reqwest::Client::builder()
.user_agent(format!(
"gt-tools-agent-{}",
env!("CARGO_PKG_VERSION")
))
.user_agent(format!("gt-tools-agent-{}", env!("CARGO_PKG_VERSION")))
.default_headers(headers)
.build()?;
@@ -32,9 +29,15 @@ async fn main() -> Result<(), gt_tool::Error> {
gt_tool::cli::Commands::ListReleases => {
let releases =
gt_tool::api::release::list_releases(&client, &args.gitea_url, &args.repo).await?;
for release in releases {
println!("{:?}", release);
}
// Print in reverse order so the newest items are closest to the
// user's command prompt. Otherwise the newest item scrolls off the
// screen and can't be seen.
itertools::Itertools::intersperse(
releases.iter().rev().map(|release| release.colorized()),
String::from(""),
)
.map(|release| println!("{}", release))
.fold((), |_, _| ());
}
gt_tool::cli::Commands::CreateRelease {
name,
@@ -51,13 +54,8 @@ async fn main() -> Result<(), gt_tool::Error> {
tag_name,
target_commitish,
};
gt_tool::api::release::create_release(
&client,
&args.gitea_url,
&args.repo,
submission,
)
.await?;
gt_tool::api::release::create_release(&client, &args.gitea_url, &args.repo, submission)
.await?;
}
gt_tool::cli::Commands::UploadRelease {
tag_name,
@@ -81,13 +79,16 @@ async fn main() -> Result<(), gt_tool::Error> {
if let Some(release) = match_release_by_tag(&tag_name, release_candidates) {
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(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)");
},
panic!(
"TODO: Deal with scenario where the file's existence cannot be checked (e.g.: no permission)"
);
}
}
}
for file in files {
@@ -141,5 +142,5 @@ fn match_release_by_tag(tag: &String, releases: Vec<Release>) -> Option<Release>
}
}
}
return release;
release
}

View File

@@ -9,7 +9,7 @@ pub struct Attachment {
name: String,
size: i64,
download_count: i64,
created: String, // TODO: Date-time struct
created_at: String, // TODO: Date-time struct
uuid: String,
download_url: String,
browser_download_url: String,
}

View File

@@ -1,3 +1,4 @@
use colored::Colorize;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
@@ -19,6 +20,36 @@ pub struct Release {
author: Author,
}
impl Release {
pub fn colorized(&self) -> String {
let tag = "Tag:".green().bold();
let name = "Name:".green();
let published = "Published:".bright_green();
let created = "Created:".green().dimmed();
let author = "Author:".blue();
let body = if !self.body.is_empty() {
&self.body.white()
} else {
&String::from("(empty body)").dimmed()
};
format!(
"{tag} {}
{name} {}
{}
{published} {} ({created} {})
{author} {} ({})",
self.tag_name.bold(),
self.name,
body,
self.published_at,
self.created_at.dimmed(),
self.author.login,
self.author.email,
)
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Author {
id: usize,