12 Commits

Author SHA1 Message Date
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
06795df3f7 Update main.rs to use new attachment iface 2025-06-07 23:57:19 -05:00
a5f6335b5f Add Attachment struct, new iface for create-rel
The Attachment struct exists, but this makes it glaringly obvious that
I've made a bad interface. The create_release_attachment should only
accept one file at a time and the loop over all files should happen in
main.rs

I've changed the signature, removed the loops, and wired in the newer
error handling routines. Needs fixing at call sites.
2025-06-07 23:50:16 -05:00
4a0addda67 Fold client-error-decode into a util function 2025-06-07 23:40:58 -05:00
0c70b584ba Interrogate create_release_attachment result 2025-06-07 23:30:56 -05:00
8a11c21b73 "Fix" the test case
I can't meaningfully unit test these things like this. I'll explore creating a tarball of a known Gitea configuration and using Docker to test against that. For now, just... kinda keep the test building.
2025-06-07 23:24:16 -05:00
d42cbbc1ec Drop unused imports 2025-06-07 23:22:48 -05:00
96e9ff4ce6 Interrogate create_release result more closely 2025-06-07 23:22:20 -05:00
6bdad44cc6 Interrogate list_releases result more closely 2025-06-07 23:15:39 -05:00
6 changed files with 108 additions and 216 deletions

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. | | 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). | | 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,9 +1,7 @@
use serde::{Deserialize, Serialize};
use crate::{ use crate::{
ApiError, Result, Result,
structs::{ structs::{
self,
release::{CreateReleaseOption, Release}, release::{CreateReleaseOption, Release},
}, },
}; };
@@ -23,6 +21,7 @@ pub async fn list_releases(
let request_url = format!("{gitea_url}/api/v1/repos/{repo}/releases/"); let request_url = format!("{gitea_url}/api/v1/repos/{repo}/releases/");
let req = client.get(request_url).send().await; 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(|reqwest_err| crate::Error::WrappedReqwestErr(reqwest_err))?;
if response.status().is_success() {
let release_list = response let release_list = response
.json::<Vec<Release>>() .json::<Vec<Release>>()
.await .await
@@ -32,14 +31,11 @@ pub async fn list_releases(
crate::Error::WrappedReqwestErr(reqwest_err) crate::Error::WrappedReqwestErr(reqwest_err)
})?; })?;
return Ok(release_list); return Ok(release_list);
} } else if response.status().is_client_error() {
let mesg = crate::decode_client_error(response).await?;
#[derive(Debug, Deserialize, Serialize)] return Err(crate::Error::ApiErrorMessage(mesg));
#[serde(untagged)] }
enum CreateResult { panic!("Reached end of list_releases without matching a return pathway.");
Success(structs::release::Release),
ErrWithMessage(ApiError),
Empty,
} }
pub async fn create_release( pub async fn create_release(
@@ -49,27 +45,23 @@ pub async fn create_release(
submission: CreateReleaseOption, submission: CreateReleaseOption,
) -> Result<Release> { ) -> Result<Release> {
let request_url = format!("{gitea_url}/api/v1/repos/{repo}/releases"); let request_url = format!("{gitea_url}/api/v1/repos/{repo}/releases");
let req = client let response = client
.post(request_url) .post(request_url)
.json(&submission) .json(&submission)
.send() .send()
.await .await
.map_err(|e| crate::Error::from(e))?; .map_err(|e| crate::Error::from(e))?;
let new_release = req if response.status().is_success() {
.json::<CreateResult>() let new_release = response
.json::<Release>()
.await .await
.map_err(|e| crate::Error::from(e))?; .map_err(|e| crate::Error::from(e))?;
match new_release { return Ok(new_release);
CreateResult::Success(release) => Ok(release), } else if response.status().is_client_error() {
CreateResult::ErrWithMessage(api_error) => { let mesg = crate::decode_client_error(response).await?;
if api_error.message == "token is required" { return Err(crate::Error::ApiErrorMessage(mesg))
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> { pub fn edit_release(id: u64) -> Result<Release> {
todo!(); todo!();

View File

@@ -1,4 +1,6 @@
use std::fs; use std::{fs, path};
use crate::structs::Attachment;
pub fn check_release_match_repo() {} pub fn check_release_match_repo() {}
pub fn get_release_attachment() {} pub fn get_release_attachment() {}
@@ -10,23 +12,20 @@ pub async fn create_release_attachment(
gitea_url: &str, gitea_url: &str,
repo: &str, repo: &str,
release_id: usize, release_id: usize,
files: Vec<String>, file: String,
) -> crate::Result<()> { ) -> crate::Result<Attachment> {
let request_url = format!("{gitea_url}/api/v1/repos/{repo}/releases/{release_id}/assets"); let request_url = format!("{gitea_url}/api/v1/repos/{repo}/releases/{release_id}/assets");
// Ensure all files exists before starting the uploads let path = path::Path::new(&file);
for file in &files { match path.try_exists() {
match fs::exists(file) { Ok(true) => (),
Ok(true) => continue,
Ok(false) => return Err(crate::Error::NoSuchFile), Ok(false) => return Err(crate::Error::NoSuchFile),
Err(e) => { Err(e) => {
eprintln!("Uh oh! The file-exists check couldn't be done: {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 {
println!("Uploading file {}", &file); println!("Uploading file {}", &file);
let data = reqwest::multipart::Part::stream(fs::read(&file).unwrap()) let data = reqwest::multipart::Part::stream(fs::read(&file).unwrap())
.file_name("attachment") .file_name("attachment")
@@ -34,150 +33,24 @@ pub async fn create_release_attachment(
let form = reqwest::multipart::Form::new().part("attachment", data); let form = reqwest::multipart::Form::new().part("attachment", data);
let request = client let response = client
.post(&request_url) .post(&request_url)
.multipart(form) .multipart(form)
.query(&[("name", file.split("/").last())]) .query(&[("name", file.split("/").last())])
.send() .send()
.await?; .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));
} }
Ok(()) panic!("Reached end of release_attachment without matching a return path");
} }
pub fn edit_release_attachment() {} pub fn edit_release_attachment() {}
pub fn delete_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,6 +10,15 @@ pub struct ApiError {
url: String, 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)] #[derive(Debug)]
pub enum Error { pub enum Error {
Placeholder, // TODO: Enumerate error modes Placeholder, // TODO: Enumerate error modes

View File

@@ -1,3 +1,6 @@
use std::path;
use gt_tool::cli::Args; use gt_tool::cli::Args;
use gt_tool::structs::release::{CreateReleaseOption, Release}; use gt_tool::structs::release::{CreateReleaseOption, Release};
@@ -78,14 +81,27 @@ async fn main() -> Result<(), gt_tool::Error> {
gt_tool::api::release::list_releases(&client, &args.gitea_url, &args.repo).await?; 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) { if let Some(release) = match_release_by_tag(&tag_name, release_candidates) {
gt_tool::api::release_attachment::create_release_attachment( 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, &client,
&args.gitea_url, &args.gitea_url,
&args.repo, &args.repo,
release.id, release.id,
files, file,
) )
.await?; .await?;
}
} else { } else {
println!("ERR: Couldn't find a release matching the tag \"{tag_name}\"."); println!("ERR: Couldn't find a release matching the tag \"{tag_name}\".");
return Err(gt_tool::Error::NoSuchRelease); return Err(gt_tool::Error::NoSuchRelease);

View File

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