7 Commits

Author SHA1 Message Date
024646acf4 WIP: Deb upload, todos, and errata 2025-09-14 11:57:46 -05:00
ab8b21d01c Fix deserialization with some attributes
The "repository" field doesn't have a type right now, so I'm going to
skip deserializing it. The returned JSON has a value for this field, but
Serde will ignore it.

The `Packages.pkg_type` field is called "type" in the JSON string (and
Go code, as mentioned in the comment). Similarly, the PackageType enum
I have to represent this field in Rust has capital starting letters
where the JSON uses all lowercase. Both problems are solved with a Serde
rename attribute.
2025-09-12 13:00:11 -05:00
c6dd63143e Fix: Typo, missing 'a' in Package.created_at 2025-09-12 12:59:56 -05:00
94ec8cfd71 Add and connect "list-packages" subcommand
And with that I have the API call being made!... except it doesn't work
quite right.
2025-09-12 11:53:09 -05:00
55b992b5c4 Impl the list_packages API function 2025-09-12 09:29:33 -05:00
7b02063bb7 Add new structs for describing packages 2025-09-12 09:28:16 -05:00
59f67e1d4e Bump version to 4.0-dev 2025-09-12 09:24:29 -05:00
8 changed files with 235 additions and 7 deletions

View File

@@ -1,12 +1,7 @@
[package]
name = "gt-tool"
version = "3.0.1"
version = "4.0.0-dev"
edition = "2024"
license = "GPL-3.0-only"
description = "CLI tools for interacting with the Gitea API. Mainly for attaching files to releases."
# homepage = "" I have no website for a project home page :(
repository = "https://git.gelvin.dev/robert/gt-tool"
readme = "README.md"
[dependencies]
clap = { version = "4.5.23", features = ["derive", "env"] }
@@ -16,3 +11,13 @@ reqwest = { version = "0.12.15", features = ["json", "stream", "multipart"] }
serde = { version = "1.0.217", features = ["derive"] }
tokio = { version = "1.43.1", features = ["macros", "rt-multi-thread"] }
toml = "0.8.19"
# Packages available in Debian (Sid)
# clap = "4.5.23"
# reqwest = "0.12.15"
# tokio = "1.43.1"
# Debian (Bookworm)
# clap = "4.0.32"
# reqwest = "0.11.13"
# tokio = "1.24.2"

View File

@@ -126,3 +126,11 @@ Similar to how unspecified project settings will fall back to those in the "`[al
| token | Gitea auth token, exactly the same as `$RELEASE_KEY_GITEA` |
Additional keys are quietly ignored. The config loading is done by querying a HashMap, so anything not touched doesn't get inspected. The only requirements are that the file is valid TOML, and that these keys are all strings.h
# Errata
- Gitea's package *uploading* API endpoints are not documented with Swagger.
* https://github.com/go-gitea/gitea/issues/30597
* It seems this is intentional because of which side defines the protocol (the consumer, not Gitea).
* This is an open issue (as of 12-Sep-2025) that doesn't have much attention.
* HTTP-POST (`curl`) is sufficient for upload. Use that either directly or as reference to make custom code.

View File

@@ -1,4 +1,42 @@
pub fn list_packages() {}
use std::{fs, path::Path};
use crate::structs::package::{Package, PackageType};
/// Gets all packages of an owner
///
/// https://github.com/go-gitea/gitea/blob/main/routers/api/v1/packages/package.go#L22
///
/// Matches the route `GET /packages/{owner}`
pub async fn list_packages(
client: &reqwest::Client,
gitea_url: &str,
owner: &str,
pkg_type: Option<PackageType>,
) -> crate::Result<Vec<Package>> {
let request_url = format!("{gitea_url}/api/v1/packages/{owner}");
// add the query parameter only when a package type filter has been set.
let request_url = if let Some(pkg_type) = pkg_type {
let pkg_type = pkg_type.to_string();
format!("{request_url}?type={pkg_type}")
} else {
request_url
};
let req = client.get(request_url).send().await;
let response = req.map_err(crate::Error::WrappedReqwestErr)?;
if response.status().is_success() {
let release_list = response
.json::<Vec<Package>>()
.await
.map_err(crate::Error::WrappedReqwestErr)?;
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 the end of `api::list_packages()` without matching a return pathway.");
}
pub fn get_packages() {}
pub fn delete_package() {}
pub fn list_package_files() {}
@@ -6,3 +44,73 @@ pub fn get_latest_package_version() {}
pub fn link_package() {}
pub fn unlink_package() {}
pub fn search_packages() {}
/// Upload a Debian package and link it to it's source repository
fn upload_debian(
client: &reqwest::Client,
gitea_url: &str,
repo: &str,
file: &Path,
owner: &str,
distribution: &str,
component: &str,
) -> crate::Result<()> {
let request_url = format!("{gitea_url}/api/packages/{owner}/debian/pool/{distribution}/{component}/upload");
match file.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)"
);
}
};
let data = reqwest::multipart::Part::stream(fs::read(&file).unwrap())
.file_name("deb-pkg")
.mime_str("text/plain")?;
Ok(())
}
/// Uploads a package file to the Gitea registry.
///
/// This route is not documented in Swagger. The reason seems to be that some
/// package managers have their own upload protocol (e.g. `docker push`). Gitea
/// implements it but does not define it, so it doesn't include the API docs.
pub fn upload_package(
client: &reqwest::Client,
gitea_url: &str,
file: String, // TODO: Use a path buffer of some flavor.
) {
let request_url = format!("{gitea_url}/{}", route_for_upload(PackageType::Debian));
}
fn route_for_upload(kind: PackageType) -> &'static str {
match kind {
PackageType::Alpine => "https://gitea.example.com/api/packages/{owner}/alpine/{branch}/{repository}",
PackageType::Arch => "api/packages/{owner}/arch/{repository}",
PackageType::Cargo => todo!(),
PackageType::Chef => todo!(),
PackageType::Composer => todo!(),
PackageType::Conan => todo!(),
PackageType::Conda => todo!(),
PackageType::Container => todo!(),
PackageType::Cran => todo!(),
PackageType::Debian => "https://gitea.example.com/api/packages/{owner}/debian/pool/{distribution}/{component}/upload",
PackageType::Generic => todo!(),
PackageType::Go => todo!(),
PackageType::Helm => todo!(),
PackageType::Maven => todo!(),
PackageType::Npm => todo!(),
PackageType::Nuget => todo!(),
PackageType::Pub => todo!(),
PackageType::PyPi => todo!(),
PackageType::Rpm => todo!(),
PackageType::RubyGems => todo!(),
PackageType::Swift => todo!(),
PackageType::Vagrant => todo!(),
}
}

View File

@@ -44,4 +44,5 @@ pub enum Commands {
#[arg()]
files: Vec<String>,
},
ListPackages,
}

View File

@@ -134,6 +134,16 @@ async fn main() -> Result<(), gt_tool::Error> {
return Err(gt_tool::Error::NoSuchRelease);
}
}
gt_tool::cli::Commands::ListPackages => {
let packages = gt_tool::api::packages::list_packages(&client, &gitea_url, &owner, None).await?;
itertools::Itertools::intersperse(
packages
.iter()
.rev()
.map(|pkg| pkg.colorized()),
String::from("")
).for_each(|package| println!("{package}"));
}
}
Ok(())

View File

@@ -1,5 +1,6 @@
use serde::{Deserialize, Serialize};
pub mod package;
pub mod release;
pub mod repo;

94
src/structs/package.rs Normal file
View File

@@ -0,0 +1,94 @@
use colored::Colorize;
use serde::{Deserialize, Serialize};
use crate::structs::release::Author;
/// Represents the package as described by JSON
///
/// https://github.com/go-gitea/gitea/blob/main/modules/structs/package.go
#[derive(Debug, Deserialize, Serialize)]
pub struct Package {
created_at: String, // TODO: Datetime struct
creator: Author,
html_url: String,
id: u64,
name: String,
owner: Author,
#[serde(skip)]
repository: (), // TODO: Create a `struct Repository`
#[serde(rename = "type")] // field is "type" in JSON & Go, but that's a
pkg_type: PackageType, // keyword in Rust so we need to serde-rename it.
version: String,
}
impl Package {
pub fn colorized(&self) -> String {
let name = "Name:".green().bold();
let version = &self.version;
let pkg_type = "Type:".green();
format!(
"{name} {} {version}
{pkg_type} {}",
self.name,
self.version,
)
}
}
/// A marker for the kind of package being handled.
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PackageType {
Alpine,
Arch,
Cargo,
Chef,
Composer,
Conan,
Conda,
Container,
Cran,
Debian,
Generic,
Go,
Helm,
Maven,
Npm,
Nuget,
Pub,
PyPi,
Rpm,
RubyGems,
Swift,
Vagrant,
}
impl ToString for PackageType {
fn to_string(&self) -> String {
match self {
PackageType::Alpine => "alpine".into(),
PackageType::Arch => "arch".into(),
PackageType::Cargo => "cargo".into(),
PackageType::Chef => "chef".into(),
PackageType::Composer => "composer".into(),
PackageType::Conan => "conan".into(),
PackageType::Conda => "conda".into(),
PackageType::Container => "container".into(),
PackageType::Cran => "cran".into(),
PackageType::Debian => "debian".into(),
PackageType::Generic => "generic".into(),
PackageType::Go => "go".into(),
PackageType::Helm => "helm".into(),
PackageType::Maven => "maven".into(),
PackageType::Npm => "npm".into(),
PackageType::Nuget => "nuget".into(),
PackageType::Pub => "pub".into(),
PackageType::PyPi => "pypi".into(),
PackageType::Rpm => "rpm".into(),
PackageType::RubyGems => "rubygems".into(),
PackageType::Swift => "swift".into(),
PackageType::Vagrant => "vagrant".into(),
}
}
}

View File

@@ -50,6 +50,7 @@ impl Release {
}
}
// TODO: Rename to "User" to match Gitea's code
#[derive(Debug, Deserialize, Serialize)]
pub struct Author {
id: usize,