16 Commits

Author SHA1 Message Date
4553f60150 Drop the on-push-branch trigger, prep for full test 2025-07-02 15:17:33 -05:00
077ad67c99 The workdir worked! Add it to the gbp call, too 2025-07-02 15:17:33 -05:00
41277146a0 Always install build-deps, don't ask for user input 2025-07-02 15:17:33 -05:00
6c0578e466 Set workdir on the git-checkout step. Stupid 2025-07-02 15:17:33 -05:00
15d5f9872c Try relative pathing to get into the project dir 2025-07-02 15:17:33 -05:00
660ca7353d Fix: remove extra indentation in the run string
I think YAML is leaving the spaces in there and passing `  build-essential` to the shell. There is no package with a space in the name, especially not a leading space.
2025-07-02 15:17:33 -05:00
67e5e4de0f Add experimental Debian package autobuild 2025-07-02 15:17:33 -05:00
bbae6b4395 Update changelog for 2.1.0-1 release 2025-06-12 17:51:23 -05:00
2c03c5ba4d Merge tag 'v2.1.0' into deb/bookworm
Hotfix for Attachment decode error
2025-06-12 17:48:09 -05:00
6a9ec25d1a Update changelog for 2.0.0-1 release 2025-06-12 16:28:43 -05:00
395ce8a804 Rediff patches
Drop 0002-Use-pre-Rust-1.81-compatible-file-exists-test.patch: <REASON>
2025-06-12 16:24:48 -05:00
616be020f0 Merge tag 'v2.0.0' into deb/bookworm
Mark release for v2

Now with better error diagnostics!... better... They're still not great.
2025-06-12 16:22:33 -05:00
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
20 changed files with 276 additions and 617 deletions

View File

@@ -8,11 +8,6 @@ jobs:
name: Compile and upload a release build
steps:
- uses: actions/checkout@v4
- name: Get Cargo version
run: echo "cargo_version=v$(grep "^version" Cargo.toml | cut -d \" -f2)" >> $GITHUB_ENV
- name: Abort if Cargo.toml & Git Tag versions don't match
if: ${{ env.cargo_version != github.ref_name }}
run: exit 1
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@stable
- name: Build binary crate
@@ -22,13 +17,45 @@ jobs:
- name: Upload the program (using itself!)
run: >
target/release/gt-tool-${{ github.ref_name }}-$(arch)
-u ${{ vars.DEST_GITEA }}
-o ${{ vars.DEST_OWNER }}
-r ${{ vars.DEST_REPO }}
-u ${{ vars.DEST_GITEA }} -r ${{ vars.DEST_REPO }}
upload-release
"${{ github.ref_name }}"
target/release/gt-tool-${{ github.ref_name }}-$(arch)
env:
RELEASE_KEY_GITEA: ${{ secrets.RELEASE_KEY_GITEA }}
debian-release:
name: Build and upload the Debian 12 package
runs-on: ubuntu-latest
container:
image: debian:12
steps:
- name: Install Tools
run: >
apt-get update;
apt-get install -y --no-install-recommends
build-essential
git
git-buildpackage;
- name: Checkout Repo (can't use actions/checkout@v4, no NodeJS)
run: git clone ${{ github.event.repository.clone_url }}
- name: Switch to Debian package branch
run: git checkout deb
working-directory: ./gt-tool
- name: Install build-deps
run: apt-get build-dep -y .
working-directory: ./gt-tool
- name: Build the package
run: gbp buildpackage
working-directory: ./gt-tool
- name: Install the tool we just built
run: dpkg -i gt-tool*.deb # TODO: Pick out the exact version instead of globbing
- name: Upload the packaging parts
run: > # The file globs are like that to avoid matching the gt-tool/ folder. I don't want that uploaded.
gt-tool
-u ${{ vars.DEST_GITEA }} -r ${{ vars.DEST_REPO }}
upload-release
"${{ github.ref_name }}"
gt-tool-*
gt-tool_*
...

View File

@@ -1,18 +1,20 @@
[package]
name = "gt-tool"
version = "3.0.1"
version = "1.0.0"
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"] }
colored = "2.2.0"
itertools = "0.13.0"
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"
clap = { version = "4.0.7", features = ["derive", "env"] }
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"] }
# 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"

113
README.md
View File

@@ -1,11 +1,11 @@
# gt-tool
# gt-tools
CLI tools for interacting with the Gitea API. Use interactively to talk to your Gitea instance, or automatically via a CI/CD pipeline.
## Usage
```txt
Usage: gt-tool [OPTIONS] <COMMAND>
Usage: gt-tools --url <GITEA_URL> --repo <REPO> <COMMAND>
Commands:
list-releases
@@ -14,52 +14,35 @@ Commands:
help Print this message or the help of the given subcommand(s)
Options:
-u, --url <GITEA_URL> [env: GTTOOL_GITEA_URL=]
-o, --owner <OWNER> [env: GTTOOL_OWNER=]
-r, --repo <REPO> [env: GTTOOL_REPO=]
-p, --project <PROJECT> Path to project (relative or absolute). Used to override configuration selection.
-h, --help Print help
-V, --version Print version
-u, --url <GITEA_URL> [env: GTTOOL_GITEA_URL=]
-r, --repo <REPO> [env: GTTOOL_FQRN=]
-h, --help Print help
-V, --version Print version
```
### Required Information
To function, this program requires knowledge of these items:
- Gitea URL
- Owner of repository
- Repository name
This info will be gathered from these locations, in order of priority:
1. CLI argument
2. Environment variable
3. Configuration files
It's worth noting that the "owner" is the entity that controls the repo on the Gitea instance. This will be the first part of the route in the URL: `http://demo.gitea.com/{owner}`.
Likewise, the "repo" is what ever the Gitea instance thinks it's called -- which doesn't have to match anyone's local copy! It will be the second part of the route in the URL: `http://demo.gitea.com/{owner}/{repo}`.
### Authentication
Authentication is token-based. There is no CLI option to prevent the token from appearing in any command logs.
Authentication is token-based via environment variable `RELEASE_KEY_GITEA`.
In order of priority, the token is loaded from:
Ensure your token has the appropriate access for your usage. This depends on what you're doing and how your Gitea instance is configured, so you'll have to figure it out for yourself.
1. The environment variable `RELEASE_KEY_GITEA`
2. Config files, key `token`
Most likely, you will need a token with "repository: read-and-write" permissions. See Gitea's documentation on [token scopes](https://docs.gitea.com/development/oauth2-provider#scopes) for more.
Whether or not it is required depends on how your Gitea instance and the repositories inside are configured. For example, a default Gitea configuration will allow unauthenticated users to see public repositories but not make any changes. This means no token is required to run `gt-tool list-releases`, while `gt-tool upload-release` *will* require a token.
### `<GITEA_URL>`:
For details, see Gitea's documentation on [token scopes](https://docs.gitea.com/development/oauth2-provider#scopes).
The Gitea server URL must be provided with `--url` or `-u` on the command line, or via the environment variable `GTTOOL_GITEA_URL`. Use the base URL for your Gitea instance.
### The `--project` option
E.g.: Using the Gitea org's demo instance, it would be: `--url "https://demo.gitea.com/"`
Settings retrieved from config files are selected based on the project's path. By default, the current directory will be used. In case that guess is incorrect, this option can be specified with another path.
### `<REPO>`:
See [configuration](#configuration) for details on format and file locations.
The repository name must be provided with `--repo` or `-u` on the command line, or via the environment variable `GTTOOL_GITEA_FQRN` ("fully qualified repo name"). Use the format `<owner>/<repo>`, which is the route immediately following the GITEA_URL base. This is how GitHub and Gitea identify repos in the URL, and how Golang locates it's modules, so this tool does the same.
### Commands:
E.g.: `--repo "go-gitea/gitea"` would name the Gitea repo belonging to the go-gitea organization.
### `<COMMAND>`:
One of these, defaults to `help`:
| Command | Description |
|-|-|
@@ -68,61 +51,3 @@ See [configuration](#configuration) for details on format and file locations.
| 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). |
## Configuration
Instead of specifying everything on the command line every single run, some TOML files can be used to persist project settings.
> Exporting some environment variables would be similar, but that would be *more* annoying when working on multiple projects. One would have to constantly re-export the settings or use two shells. But then there's the issue of losing track of which shell has which settings.
### File Format
Settings are retrieved from a table named the same as the project's path on disk. For example, gt_tool itself could have an entry as follows:
```toml
["/home/robert/projects/gt-tool"]
gitea_url = "https://demo.gitea.com/"
owner = "dummy"
repo = "gt-tool"
token = "fake-token"
```
Sometimes one may want to apply a setting to all projects. For this, they can use the special `[all]` table.
```toml
[all]
gitea_url = "https://demo.gitea.com/"
```
Since the more-specific settings are preferred, these can be combined to have an override effect.
```toml
[all]
gitea_url = "https://demo.gitea.com/"
owner = "robert"
token = "fake-token"
# Override Gitea target so I can test my uploads privately.
["/home/robert/projects/gt-tool"]
gitea_url = "http://localhost:3000"
repo = "gt-tool"
```
### Search Paths
Similar to how unspecified project settings will fall back to those in the "`[all]`" table, whole files will fall back to other, lower priority files.
1. First, each dir in `$XDG_CONFIG_DIRS` is scanned for a `gt-tool.toml` file.
2. Then, `/etc/gt-tool.toml`.
> All config files **MUST** be named named `gt-tool.toml`.
### Recognized Keys
| Key | Description |
|-|-|
| gitea_url | URL of the Gitea server. Same as `-u`, `--url`, and `$GTTOOL_GITEA_URL`. |
| owner | Owner of the repository (individual, or organization). Combined with "repo" key to produce the fully-qualified-repo-name. Front-half of `-r`, `--repo`, and `$GTTOOL_FQRN` |
| repo | Name of the repository on the Gitea server. Combined with "owner" key to produce the fully-qualified-repo-name. Back-half of `-r`, `--repo`, and `$GTTOOL_FQRN` |
| 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

29
debian/changelog vendored Normal file
View File

@@ -0,0 +1,29 @@
gt-tool (2.1.0-1) unstable; urgency=medium
* Fix: incorrect field names for `Attachment`
-- Robert Garrett <robertgarrett404@gmail.com> Thu, 12 Jun 2025 17:51:12 -0500
gt-tool (2.0.0-1) unstable; urgency=medium
* Interrogate list_releases result more closely
* Interrogate create_release result more closely
* Drop unused imports
* "Fix" the test case
* Interrogate create_release_attachment result
* Fold client-error-decode into a util function
* Add `Attachment` struct, new iface for create-rel
* Update main.rs to use new attachment iface
* Delete the unit tests
* ... and the unit testing notes in README.md
* Drop unused import in api/release.rs
* Use pre Rust 1.81 compatible file-exists test
* Rediff patches
-- Robert Garrett <robertgarrett404@gmail.com> Thu, 12 Jun 2025 16:28:18 -0500
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"] }

1
debian/patches/series vendored Normal file
View File

@@ -0,0 +1 @@
0001-Rust-edition-downgrade-to-2021.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,12 @@
use crate::{
Result,
structs::release::{CreateReleaseOption, Release},
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> {
@@ -17,7 +20,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(crate::Error::WrappedReqwestErr)?;
let response = req.map_err(|reqwest_err| crate::Error::WrappedReqwestErr(reqwest_err))?;
if response.status().is_success() {
let release_list = response
.json::<Vec<Release>>()
@@ -47,22 +50,22 @@ pub async fn create_release(
.json(&submission)
.send()
.await
.map_err(crate::Error::from)?;
.map_err(|e| crate::Error::from(e))?;
if response.status().is_success() {
let new_release = response
.json::<Release>()
.await
.map_err(crate::Error::from)?;
.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));
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

@@ -22,10 +22,8 @@ pub async fn create_release_attachment(
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);
@@ -46,7 +44,7 @@ pub async fn create_release_attachment(
let attachment_desc = response
.json::<Attachment>()
.await
.map_err(crate::Error::from)?;
.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?;

View File

@@ -4,17 +4,9 @@ use clap::{Parser, Subcommand};
#[command(version, about, long_about = None)]
pub struct Args {
#[arg(short = 'u', long = "url", env = "GTTOOL_GITEA_URL")]
pub gitea_url: Option<String>,
#[arg(short = 'o', long = "owner", env = "GTTOOL_OWNER")]
pub owner: Option<String>,
#[arg(short = 'r', long = "repo", env = "GTTOOL_REPO")]
pub repo: Option<String>,
#[arg(
short = 'p',
long = "project",
help = "Path to project (relative or absolute). Used to override configuration selection."
)]
pub project: Option<String>,
pub gitea_url: String,
#[arg(short = 'r', long = "repo", env = "GTTOOL_FQRN")]
pub repo: String,
#[command(subcommand)]
pub command: Commands,

View File

@@ -1,352 +0,0 @@
use std::path::PathBuf;
use toml::{Value, value::Table};
pub type Result<T> = core::result::Result<T, Error>;
#[derive(Debug)]
#[cfg_attr(test, derive(PartialEq))]
pub enum Error {
BadFormat,
NoSuchProperty,
NoSuchTable,
CouldntReadFile,
TomlWrap(toml::de::Error),
}
impl From<toml::de::Error> for Error {
fn from(value: toml::de::Error) -> Self {
Error::TomlWrap(value)
}
}
impl core::fmt::Display for Error {
fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// FIXME: Print a nice output, don't just reuse the Debug impl
write!(fmt, "{self:?}")
}
}
impl std::error::Error for Error {}
/// Creates an iterator of default (Linux) search paths. The iterator output
/// is a list of files named "gt-tool.toml" found in decreasingly specific
/// configuration folders.
///
/// - any dirs listed in env var `$XDG_CONFIG_DIRS`
/// - and the `/etc` dir
///
/// This is so that user-specific configs are used first, then machine-wide
/// ones.
pub fn default_paths() -> impl Iterator<Item = PathBuf> {
// Read env var `XDG_CONFIG_DIRS` and split on ":" to get highest-priority list
// TODO: Emit warning when paths aren't unicode
std::env::var("XDG_CONFIG_DIRS")
.unwrap_or(String::from(""))
.split(":")
// Set up the "/etc" list
// Which is pretty silly, in this case.
// Maybe a future version will scan nested folders and this will make
// more sense.
// glue on the "/etc" path
.chain(["/etc"])
.map(|path_str| {
let mut path = PathBuf::from(path_str);
path.push("gt-tool.toml");
path
})
.collect::<Vec<_>>()
.into_iter()
}
/// Searches through the files, `search_files`, for configuration related to a
/// project, `project`.
///
/// The project string is used as a map key and should match the real path of a
/// project on disk. These are the table names for each config section.
///
/// The search files iterator must produce *files* not *folders.* For now,
/// there is no mechanism to ensure correct usage. Files that can't be opened
/// will quietly be skipped, so there will be no warning when one gives a
/// folder.
///
/// Use `fn default_paths()` to get a reasonable (Linux) default.
///
/// TODO: Check for, and warn or error when given a dir.
pub fn get_config(
project: &str,
search_files: impl Iterator<Item = PathBuf>,
) -> Result<PartialConfig> {
/*
1. Get conf search (from fn input)
2. Iterate config dirs
3. Try load toml::Value from file
4. Try-get proj-specific table
5. Try-get "[all]" table
6. (merge) Update `Option::None`s in proj-spec with `Some(_)`s from "[all]"
7. (merge, again) Fold the PartialConfigs into a finished one
*/
let file_iter = search_files;
let toml_iter = file_iter
.map(std::fs::read_to_string) // read text from file
.filter_map(|res| res.ok()) // remove any error messages
// TODO: Log warnings when files couldn't be read.
.map(|toml_text| toml_text.parse::<Value>()) // try convert to `toml::Value`
.filter_map(|res| res.ok()); // remove any failed parses
let config_iter = toml_iter
.map(|val| -> Result<PartialConfig> {
// Like `fn read_conf_str(...)`, but doesn't produce a `WholeFile`
// 1. Get the top-level table that is the config file
let cfg_table = val.as_table().ok_or(Error::BadFormat)?;
// 2. Get table
get_table(cfg_table, project)
// 3a. convert to PartialConfig
.and_then(PartialConfig::try_from)
// 3b. or default, if the table couldn't be found.
.or(Ok(PartialConfig::default()))
// 4. assemble a 2-tuple of PartialConfigs by...
.map(|proj| {
(
// 4-1. Passing in the project-specific PartialConfig
proj.project_path(project),
// 4-2. Getting and converting to PartialConfig, or returning any Err() if one appears.
get_table(cfg_table, "all")
.and_then(PartialConfig::try_from)
.unwrap_or(PartialConfig::default()),
)
})
.map(|pair| pair.0.merge(pair.1))
})
.filter_map(|res| res.ok())
.fold(PartialConfig::default(), |acc, inc| acc.merge(inc));
Ok(config_iter)
}
#[derive(Debug, Default)]
#[cfg_attr(test, derive(PartialEq))]
pub struct PartialConfig {
project_path: Option<String>,
pub gitea_url: Option<String>,
pub owner: Option<String>,
pub repo: Option<String>,
pub token: Option<String>,
}
impl PartialConfig {
// One lonely builder-pattern function to set the project path.
// This is so I can do continuation style calls instead of a bunch of
// successive `let conf = ...` temporaries.
fn project_path(self, path: impl ToString) -> Self {
PartialConfig {
project_path: Some(path.to_string()),
..self
}
}
// Merges two `PartialConfig`'s together, producing a new one.
// Non-None values on the right-hand side are used to replace values
// in the left-hand side, even if they are Some(_).
fn merge(self, other: Self) -> Self {
Self {
project_path: other.project_path.or(self.project_path),
gitea_url: other.gitea_url.or(self.gitea_url),
owner: other.owner.or(self.owner),
repo: other.repo.or(self.repo),
token: other.token.or(self.token),
}
}
}
impl TryFrom<&Table> for PartialConfig {
type Error = crate::config::Error;
/// Scans properties out of a `toml::Table` to get a PartialConfig.
///
/// `Error::NoSuchProperty` is quietly ignored (mapped to `None`) since it
/// isn't an error in this context.
///
/// All other errors are propagated and should be treated as real failures.
fn try_from(value: &Table) -> Result<Self> {
Ok(Self {
// can't get table name because that key is gone by this point.
project_path: None,
gitea_url: get_maybe_property(value, "gitea_url")?.cloned(),
owner: get_maybe_property(value, "owner")?.cloned(),
repo: get_maybe_property(value, "repo")?.cloned(),
token: get_maybe_property(value, "token")?.cloned(),
})
}
}
/// The outer value must be a Table so we can get the sub-table from it.
fn get_table(outer: &Table, table_name: impl ToString) -> Result<&Table> {
outer
.get(&table_name.to_string())
.ok_or(Error::NoSuchTable)?
.as_table()
.ok_or(Error::BadFormat)
}
/// Similar to `get_property()` but maps the "Error::NoSuchProperty" result to
/// Option::None. Some properties aren't specified, and that's okay... sometimes.
fn get_maybe_property(outer: &Table, property: impl ToString) -> Result<Option<&String>> {
let maybe_prop = get_property(outer, property);
match maybe_prop {
Ok(value) => Ok(Some(value)),
Err(e) => {
if let Error::NoSuchProperty = e {
Ok(None)
} else {
Err(e)
}
}
}
}
/// The config properties are individual strings. This gets the named property,
/// or an error explaining why it couldn't be fetched.
fn get_property(outer: &Table, property: impl ToString) -> Result<&String> {
let maybe_prop = outer
.get(&property.to_string())
.ok_or(Error::NoSuchProperty)?;
if let Value::String(text) = maybe_prop {
Ok(text)
} else {
Err(Error::BadFormat)
}
}
#[cfg(test)]
mod tests {
use toml::map::Map;
use super::*;
#[test]
fn read_single_prop() -> Result<()> {
let fx_input_str = "owner = \"dingus\"";
let fx_value = fx_input_str.parse::<Value>()?;
let fx_value = fx_value.as_table().ok_or(Error::NoSuchTable)?;
let expected = "dingus";
let res = get_property(&fx_value, String::from("owner"))?;
assert_eq!(res, expected);
Ok(())
}
// The property is given the value of empty-string `""`
#[test]
fn read_single_prop_empty_quotes() -> Result<()> {
let fx_input_str = "owner = \"\"";
let fx_value = fx_input_str.parse::<Value>()?;
let fx_value = fx_value.as_table().ok_or(Error::NoSuchTable)?;
let expected = "";
let res = get_property(&fx_value, String::from("owner"))?;
assert_eq!(res, expected);
Ok(())
}
#[test]
fn read_table() -> Result<()> {
let fx_input_str = "[tab]\nwith_a_garbage = \"value\"";
let fx_value = fx_input_str.parse::<Value>()?;
let fx_value = fx_value.as_table().ok_or(Error::BadFormat)?;
let mut expected: Map<String, Value> = Map::new();
expected.insert(
String::from("with_a_garbage"),
Value::String(String::from("value")),
);
let res = get_table(&fx_value, String::from("tab"))?;
assert_eq!(res, &expected);
Ok(())
}
#[test]
fn test_get_config_with_specific_match() -> Result<()> {
let search_paths = ["./test_data/sample_config.toml"]
.into_iter()
.map(PathBuf::from);
let load_result = get_config("/home/robert/projects/gt-tool", search_paths)?;
let expected = PartialConfig {
project_path: Some(String::from("/home/robert/projects/gt-tool")),
owner: Some(String::from("robert")),
repo: Some(String::from("gt-tool")),
gitea_url: Some(String::from("http://localhost:3000")),
token: Some(String::from("fake-token")),
};
assert_eq!(load_result, expected);
Ok(())
}
// Ensure the config comes back with something even when there is no
// matching project-specific table.
#[test]
fn test_get_config_no_specific_match() -> Result<()> {
let search_paths = ["./test_data/sample_config.toml"]
.into_iter()
.map(PathBuf::from);
let load_result = get_config("/no/such/project", search_paths)?;
let expected = PartialConfig {
project_path: Some(String::from("/no/such/project")),
owner: None,
repo: None,
gitea_url: Some(String::from("http://localhost:3000")),
token: Some(String::from("fake-token")),
};
assert_eq!(load_result, expected);
Ok(())
}
// Ensure the config comes back with something even when there is no
// "[all]" table
#[test]
fn test_get_config_without_all() -> Result<()> {
let search_paths = ["./test_data/missing_all_table.toml"]
.into_iter()
.map(PathBuf::from);
let load_result = get_config("/some/other/path", search_paths)?;
let expected = PartialConfig {
project_path: Some(String::from("/some/other/path")),
gitea_url: Some(String::from("fake-url")),
..PartialConfig::default()
};
assert_eq!(load_result, expected);
Ok(())
}
// Ensure that trying to load files that don't exist simply get skipped over
// instead of causing a short-circuit exit or other bogus output.
#[test]
fn test_get_config_many_missing_files() -> Result<()> {
let search_paths = [
"./test_data/not_real_1.toml",
"./test_data/not_real_2.toml",
"./test_data/not_real_3.toml",
"./test_data/not_real_4.toml",
"./test_data/not_real_5.toml",
"./test_data/sample_config.toml",
"./test_data/not_real_6.toml",
]
.into_iter()
.map(PathBuf::from);
let load_result = get_config("/home/robert/projects/gt-tool", search_paths)?;
let expected = PartialConfig {
project_path: Some(String::from("/home/robert/projects/gt-tool")),
owner: Some(String::from("robert")),
repo: Some(String::from("gt-tool")),
gitea_url: Some(String::from("http://localhost:3000")),
token: Some(String::from("fake-token")),
};
assert_eq!(load_result, expected);
Ok(())
}
}

View File

@@ -2,7 +2,6 @@ use serde::{Deserialize, Serialize};
pub mod api;
pub mod cli;
pub mod config;
pub mod structs;
#[derive(Debug, Deserialize, Serialize)]
@@ -11,21 +10,18 @@ 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(crate::Error::WrappedReqwestErr)
.map_err(|reqwest_err| {
crate::Error::WrappedReqwestErr(reqwest_err)
})
}
#[derive(Debug)]
pub enum Error {
Placeholder, // TODO: Enumerate error modes
MissingGiteaUrl, // the gitea URL wasn't specified on the CLI, env, or config file.
MissingRepoFRQN, // either the owner, repo, or both weren't specified in the loaded PartialConfig
MissingRepoOwner,
MissingRepoName,
WrappedConfigErr(config::Error),
Placeholder, // TODO: Enumerate error modes
WrappedReqwestErr(reqwest::Error),
MissingAuthToken,
NoSuchFile, // for release attachment 'file exists' pre-check.
@@ -39,10 +35,4 @@ impl From<reqwest::Error> for crate::Error {
}
}
impl From<crate::config::Error> for crate::Error {
fn from(value: crate::config::Error) -> Self {
Self::WrappedConfigErr(value)
}
}
type Result<T> = core::result::Result<T, Error>;

View File

@@ -1,4 +1,5 @@
use std::path::{self, PathBuf};
use std::path;
use gt_tool::cli::Args;
use gt_tool::structs::release::{CreateReleaseOption, Release};
@@ -12,38 +13,6 @@ use reqwest::header::ACCEPT;
async fn main() -> Result<(), gt_tool::Error> {
let args = Args::parse();
let project_path =
args.project
.map(PathBuf::from)
.unwrap_or(std::env::current_dir().map_err(|_e| {
gt_tool::Error::WrappedConfigErr(gt_tool::config::Error::CouldntReadFile)
})?);
let config = gt_tool::config::get_config(
project_path
.to_str()
.expect("I assumed the path can be UTF-8, but that didn't work out..."),
gt_tool::config::default_paths(),
)?;
println!("->> Loaded Config: {config:?}");
// arg parser also checks the environment. Prefer CLI/env, then config file.
let gitea_url = args
.gitea_url
.or(config.gitea_url)
.ok_or(gt_tool::Error::MissingGiteaUrl)?;
let owner = args
.owner
.or(config.owner)
.ok_or(gt_tool::Error::MissingRepoOwner)?;
let repo = args
.repo
.or(config.repo)
.or_else(infer_repo)
.ok_or(gt_tool::Error::MissingRepoName)?;
let repo_fqrn = format!("{owner}/{repo}");
let mut headers = reqwest::header::HeaderMap::new();
headers.append(ACCEPT, header::HeaderValue::from_static("application/json"));
@@ -53,23 +22,20 @@ 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()?;
match args.command {
gt_tool::cli::Commands::ListReleases => {
let releases =
gt_tool::api::release::list_releases(&client, &gitea_url, &repo_fqrn).await?;
// 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::api::release::list_releases(&client, &args.gitea_url, &args.repo).await?;
for release in releases {
println!("{:?}", release);
}
}
gt_tool::cli::Commands::CreateRelease {
name,
@@ -86,8 +52,13 @@ async fn main() -> Result<(), gt_tool::Error> {
tag_name,
target_commitish,
};
gt_tool::api::release::create_release(&client, &gitea_url, &repo_fqrn, submission)
.await?;
gt_tool::api::release::create_release(
&client,
&args.gitea_url,
&args.repo,
submission,
)
.await?;
}
gt_tool::cli::Commands::UploadRelease {
tag_name,
@@ -107,7 +78,7 @@ async fn main() -> Result<(), gt_tool::Error> {
// Grab all, find the one that matches the input tag.
// Scream if there are multiple matches.
let release_candidates =
gt_tool::api::release::list_releases(&client, &gitea_url, &repo_fqrn).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) {
for file in &files {
@@ -117,15 +88,17 @@ async fn main() -> Result<(), gt_tool::Error> {
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 {
let _attach_desc = gt_tool::api::release_attachment::create_release_attachment(
&client, &gitea_url, &repo_fqrn, release.id, file,
&client,
&args.gitea_url,
&args.repo,
release.id,
file,
)
.await?;
}
@@ -170,12 +143,5 @@ fn match_release_by_tag(tag: &String, releases: Vec<Release>) -> Option<Release>
}
}
}
release
}
fn infer_repo() -> Option<String> {
let pwd = std::env::current_dir().ok()?;
let file_name = pwd.file_name()?;
let file_name_string = file_name.to_str()?;
Some(String::from(file_name_string))
return release;
}

View File

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

View File

@@ -1,5 +0,0 @@
# There must be an "[all]" table or the loader will reject the config file.
["/some/other/path"]
gitea_url = "fake-url"

View File

@@ -1,15 +0,0 @@
[all]
gitea_url = "http://localhost:3000"
token = "fake-token"
["/home/robert/projects/gt-tool"]
owner = "robert"
repo = "gt-tool"
["/home/robert/projects/rcalc"]
owner = "jamis"
repo = "rcalc"
["/home/robert/projects/rcalc-builders"]
owner = "jamis"
repo = "rcalc"