4 Commits

Author SHA1 Message Date
324c7e67a7 Re-assign v1.0.0-1 to 'unstable'
I should have done this before publishing the build, but it's too late
now. Instead, I'll update it here and continue forward with gbp-dch to
help generate more correct changelogs.
2025-06-12 12:55:16 -05:00
984974c240 Update debian/gbp.conf to use new branch name
I'm switching to use nested tags for the packaging branches. I want to
have a deb/bookworm and a deb/experimental branch. The gbp-pq tool
already seems to do this, so I'll follow it's lead.
2025-06-12 12:50:48 -05:00
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
17 changed files with 407 additions and 161 deletions

View File

@@ -1,12 +1,10 @@
[package] [package]
name = "gt-tool" name = "gt-tool"
version = "2.2.0" version = "1.0.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
clap = { version = "4.0.7", features = ["derive", "env"] } 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"] } reqwest = { version = "0.11.13", features = ["json", "stream", "multipart"] }
serde = { version = "1.0.152", features = ["derive"] } serde = { version = "1.0.152", features = ["derive"] }
tokio = { version = "1.24.2", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.24.2", features = ["macros", "rt-multi-thread"] }

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. | | 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>`. |

5
debian/changelog vendored Normal file
View File

@@ -0,0 +1,5 @@
gt-tool (1.0.0-1) unstable; 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/bookworm

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,9 +1,14 @@
use serde::{Deserialize, Serialize};
use crate::{ use crate::{
Result, ApiError, Result,
structs::release::{CreateReleaseOption, Release}, structs::{
self,
release::{CreateReleaseOption, Release},
},
}; };
pub fn get_release(_id: u64) -> Result<Release> { pub fn get_release(id: u64) -> Result<Release> {
todo!(); todo!();
} }
pub fn get_latest_release() -> Result<Release> { pub fn get_latest_release() -> Result<Release> {
@@ -17,22 +22,24 @@ pub async fn list_releases(
) -> Result<Vec<Release>> { ) -> Result<Vec<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.get(request_url).send().await; let req = client.get(request_url).send().await;
let response = req.map_err(crate::Error::WrappedReqwestErr)?; 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 .map_err(|reqwest_err| {
.map_err(|reqwest_err| { // Convert reqwest errors to my own
// Convert reqwest errors to my own // TODO: Create all error variants (see lib.rs)
// TODO: Create all error variants (see lib.rs) 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(
@@ -42,27 +49,31 @@ 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 response = client let req = client
.post(request_url) .post(request_url)
.json(&submission) .json(&submission)
.send() .send()
.await .await
.map_err(crate::Error::from)?; .map_err(|e| crate::Error::from(e))?;
if response.status().is_success() { let new_release = req
let new_release = response .json::<CreateResult>()
.json::<Release>() .await
.await .map_err(|e| crate::Error::from(e))?;
.map_err(crate::Error::from)?; 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!();
} }
pub fn delete_release(_id: u64) -> Result<()> { pub fn delete_release(id: u64) -> Result<()> {
todo!(); todo!();
} }

View File

@@ -1,6 +1,4 @@
use std::{fs, path}; use std::fs;
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() {}
@@ -12,47 +10,174 @@ pub async fn create_release_attachment(
gitea_url: &str, gitea_url: &str,
repo: &str, repo: &str,
release_id: usize, release_id: usize,
file: String, files: Vec<String>,
) -> crate::Result<Attachment> { ) -> crate::Result<()> {
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");
let path = path::Path::new(&file); // Ensure all files exists before starting the uploads
match path.try_exists() { for file in &files {
Ok(true) => (), match fs::exists(file) {
Ok(false) => return Err(crate::Error::NoSuchFile), Ok(true) => continue,
Err(e) => { Ok(false) => return Err(crate::Error::NoSuchFile),
eprintln!("Uh oh! The file-exists check couldn't be done: {e}"); Err(e) => {
panic!( eprintln!("Uh oh! The file-exists check couldn't be done: {e}");
"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); for file in files {
let data = reqwest::multipart::Part::stream(fs::read(&file).unwrap()) println!("Uploading file {}", &file);
.file_name("attachment") let data = reqwest::multipart::Part::stream(fs::read(&file).unwrap())
.mime_str("text/plain")?; .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 let request = 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(crate::Error::from)?;
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));
} }
panic!("Reached end of release_attachment without matching a return path"); Ok(())
} }
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,13 +10,6 @@ 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(crate::Error::WrappedReqwestErr)
}
#[derive(Debug)] #[derive(Debug)]
pub enum Error { pub enum Error {
Placeholder, // TODO: Enumerate error modes Placeholder, // TODO: Enumerate error modes

View File

@@ -1,5 +1,3 @@
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};
@@ -21,7 +19,10 @@ async fn main() -> Result<(), gt_tool::Error> {
headers.append("Authorization", token.parse().unwrap()); headers.append("Authorization", token.parse().unwrap());
} }
let client = reqwest::Client::builder() 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) .default_headers(headers)
.build()?; .build()?;
@@ -29,15 +30,9 @@ async fn main() -> Result<(), gt_tool::Error> {
gt_tool::cli::Commands::ListReleases => { gt_tool::cli::Commands::ListReleases => {
let releases = let releases =
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?;
// Print in reverse order so the newest items are closest to the for release in releases {
// user's command prompt. Otherwise the newest item scrolls off the println!("{:?}", release);
// 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 { gt_tool::cli::Commands::CreateRelease {
name, name,
@@ -54,8 +49,13 @@ async fn main() -> Result<(), gt_tool::Error> {
tag_name, tag_name,
target_commitish, target_commitish,
}; };
gt_tool::api::release::create_release(&client, &args.gitea_url, &args.repo, submission) gt_tool::api::release::create_release(
.await?; &client,
&args.gitea_url,
&args.repo,
submission,
)
.await?;
} }
gt_tool::cli::Commands::UploadRelease { gt_tool::cli::Commands::UploadRelease {
tag_name, tag_name,
@@ -78,29 +78,14 @@ 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) {
for file in &files { gt_tool::api::release_attachment::create_release_attachment(
let path = path::Path::new(&file); &client,
match path.try_exists() { &args.gitea_url,
Ok(true) => continue, &args.repo,
Ok(false) => return Err(gt_tool::Error::NoSuchFile), release.id,
Err(e) => { files,
eprintln!("Uh oh! The file-exists check couldn't be done: {e}"); )
panic!( .await?;
"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?;
}
} 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);
@@ -142,5 +127,5 @@ fn match_release_by_tag(tag: &String, releases: Vec<Release>) -> Option<Release>
} }
} }
} }
release return release;
} }

View File

@@ -1,15 +1,2 @@
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_at: String, // TODO: Date-time struct
uuid: String,
browser_download_url: String,
}

View File

@@ -1,4 +1,3 @@
use colored::Colorize;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
@@ -20,36 +19,6 @@ pub struct Release {
author: Author, 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)] #[derive(Debug, Deserialize, Serialize)]
pub struct Author { pub struct Author {
id: usize, id: usize,