Compare commits
38 Commits
cf9b37fe99
...
trunk
| Author | SHA1 | Date | |
|---|---|---|---|
| c5c5598fb7 | |||
| 9e47cb72d5 | |||
| ff2286f44b | |||
| d982f42ae7 | |||
| 641efc3bf7 | |||
| 144fba5373 | |||
| 7f35b808e5 | |||
| 00edaf87ce | |||
| 250140a954 | |||
| b290a8b1d6 | |||
| b952e40060 | |||
| 4b9257a9a7 | |||
| d34eda77dc | |||
| 3315c18ed2 | |||
| 0e7bca80cb | |||
| 5bd2862498 | |||
| 333636b524 | |||
| e954a2b09a | |||
| da8f008f1a | |||
| 7c0966be30 | |||
| c1019afa7a | |||
| 1a619d7bb4 | |||
| fc0d1b569c | |||
| 8cfc6605c9 | |||
| 0e814b86a1 | |||
| 0e3aa16e00 | |||
| 04dd333d72 | |||
| 13ef1d25eb | |||
| 56b0580a9a | |||
| 46d8618e74 | |||
| 73363718c3 | |||
| 5b8a09e9ca | |||
| 3453f64312 | |||
| 63d0a868ec | |||
| 4e9a5dd25b | |||
| ce480306e0 | |||
| 6ca279de49 | |||
| 64215cefcc |
@@ -22,7 +22,9 @@ jobs:
|
|||||||
- name: Upload the program (using itself!)
|
- name: Upload the program (using itself!)
|
||||||
run: >
|
run: >
|
||||||
target/release/gt-tool-${{ github.ref_name }}-$(arch)
|
target/release/gt-tool-${{ github.ref_name }}-$(arch)
|
||||||
-u ${{ vars.DEST_GITEA }} -r ${{ vars.DEST_REPO }}
|
-u ${{ vars.DEST_GITEA }}
|
||||||
|
-o ${{ vars.DEST_OWNER }}
|
||||||
|
-r ${{ vars.DEST_REPO }}
|
||||||
upload-release
|
upload-release
|
||||||
"${{ github.ref_name }}"
|
"${{ github.ref_name }}"
|
||||||
target/release/gt-tool-${{ github.ref_name }}-$(arch)
|
target/release/gt-tool-${{ github.ref_name }}-$(arch)
|
||||||
|
|||||||
31
Cargo.toml
31
Cargo.toml
@@ -1,23 +1,18 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gt-tool"
|
name = "gt-tool"
|
||||||
version = "2.2.0"
|
version = "3.0.1"
|
||||||
edition = "2024"
|
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]
|
[dependencies]
|
||||||
clap = { version = "4.0.7", features = ["derive", "env"] }
|
clap = { version = "4.5.23", features = ["derive", "env"] }
|
||||||
colored = "2.0.0"
|
colored = "2.2.0"
|
||||||
itertools = "0.10.0"
|
itertools = "0.13.0"
|
||||||
reqwest = { version = "0.11.13", features = ["json", "stream", "multipart"] }
|
reqwest = { version = "0.12.15", features = ["json", "stream", "multipart"] }
|
||||||
serde = { version = "1.0.152", features = ["derive"] }
|
serde = { version = "1.0.217", features = ["derive"] }
|
||||||
tokio = { version = "1.24.2", features = ["macros", "rt-multi-thread"] }
|
tokio = { version = "1.43.1", features = ["macros", "rt-multi-thread"] }
|
||||||
toml = "0.5"
|
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"
|
|
||||||
|
|||||||
105
README.md
105
README.md
@@ -5,7 +5,7 @@ CLI tools for interacting with the Gitea API. Use interactively to talk to your
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
Usage: gt-tools --url <GITEA_URL> --repo <REPO> <COMMAND>
|
Usage: gt-tool [OPTIONS] <COMMAND>
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
list-releases
|
list-releases
|
||||||
@@ -15,34 +15,51 @@ Commands:
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
-u, --url <GITEA_URL> [env: GTTOOL_GITEA_URL=]
|
-u, --url <GITEA_URL> [env: GTTOOL_GITEA_URL=]
|
||||||
-r, --repo <REPO> [env: GTTOOL_FQRN=]
|
-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
|
-h, --help Print help
|
||||||
-V, --version Print version
|
-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
|
||||||
|
|
||||||
Authentication is token-based via environment variable `RELEASE_KEY_GITEA`.
|
Authentication is token-based. There is no CLI option to prevent the token from appearing in any command logs.
|
||||||
|
|
||||||
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.
|
In order of priority, the token is loaded from:
|
||||||
|
|
||||||
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.
|
1. The environment variable `RELEASE_KEY_GITEA`
|
||||||
|
2. Config files, key `token`
|
||||||
|
|
||||||
### `<GITEA_URL>`:
|
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.
|
||||||
|
|
||||||
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.
|
For details, see Gitea's documentation on [token scopes](https://docs.gitea.com/development/oauth2-provider#scopes).
|
||||||
|
|
||||||
E.g.: Using the Gitea org's demo instance, it would be: `--url "https://demo.gitea.com/"`
|
### The `--project` option
|
||||||
|
|
||||||
### `<REPO>`:
|
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.
|
||||||
|
|
||||||
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.
|
See [configuration](#configuration) for details on format and file locations.
|
||||||
|
|
||||||
E.g.: `--repo "go-gitea/gitea"` would name the Gitea repo belonging to the go-gitea organization.
|
### Commands:
|
||||||
|
|
||||||
### `<COMMAND>`:
|
|
||||||
|
|
||||||
One of these, defaults to `help`:
|
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|-|-|
|
|-|-|
|
||||||
@@ -51,3 +68,61 @@ 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). |
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|||||||
14
src/cli.rs
14
src/cli.rs
@@ -4,9 +4,17 @@ use clap::{Parser, Subcommand};
|
|||||||
#[command(version, about, long_about = None)]
|
#[command(version, about, long_about = None)]
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
#[arg(short = 'u', long = "url", env = "GTTOOL_GITEA_URL")]
|
#[arg(short = 'u', long = "url", env = "GTTOOL_GITEA_URL")]
|
||||||
pub gitea_url: String,
|
pub gitea_url: Option<String>,
|
||||||
#[arg(short = 'r', long = "repo", env = "GTTOOL_FQRN")]
|
#[arg(short = 'o', long = "owner", env = "GTTOOL_OWNER")]
|
||||||
pub repo: String,
|
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>,
|
||||||
|
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
pub command: Commands,
|
pub command: Commands,
|
||||||
|
|||||||
296
src/config.rs
296
src/config.rs
@@ -20,7 +20,7 @@ impl From<toml::de::Error> for Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl core::fmt::Display for Error{
|
impl core::fmt::Display for Error {
|
||||||
fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
// FIXME: Print a nice output, don't just reuse the Debug impl
|
// FIXME: Print a nice output, don't just reuse the Debug impl
|
||||||
write!(fmt, "{self:?}")
|
write!(fmt, "{self:?}")
|
||||||
@@ -73,9 +73,9 @@ pub fn default_paths() -> impl Iterator<Item = PathBuf> {
|
|||||||
/// Use `fn default_paths()` to get a reasonable (Linux) default.
|
/// Use `fn default_paths()` to get a reasonable (Linux) default.
|
||||||
///
|
///
|
||||||
/// TODO: Check for, and warn or error when given a dir.
|
/// TODO: Check for, and warn or error when given a dir.
|
||||||
pub fn get_config (
|
pub fn get_config(
|
||||||
project: &str,
|
project: &str,
|
||||||
search_files: impl Iterator<Item=PathBuf>
|
search_files: impl Iterator<Item = PathBuf>,
|
||||||
) -> Result<PartialConfig> {
|
) -> Result<PartialConfig> {
|
||||||
/*
|
/*
|
||||||
1. Get conf search (from fn input)
|
1. Get conf search (from fn input)
|
||||||
@@ -87,7 +87,6 @@ pub fn get_config (
|
|||||||
7. (merge, again) Fold the PartialConfigs into a finished one
|
7. (merge, again) Fold the PartialConfigs into a finished one
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
let file_iter = search_files;
|
let file_iter = search_files;
|
||||||
|
|
||||||
let toml_iter = file_iter
|
let toml_iter = file_iter
|
||||||
@@ -104,46 +103,45 @@ pub fn get_config (
|
|||||||
let cfg_table = val.as_table().ok_or(Error::BadFormat)?;
|
let cfg_table = val.as_table().ok_or(Error::BadFormat)?;
|
||||||
|
|
||||||
// 2. Get table
|
// 2. Get table
|
||||||
let maybe_proj = get_table(cfg_table, project)
|
|
||||||
// 3. convert to PartialConfig
|
get_table(cfg_table, project)
|
||||||
|
// 3a. convert to PartialConfig
|
||||||
.and_then(PartialConfig::try_from)
|
.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...
|
// 4. assemble a 2-tuple of PartialConfigs by...
|
||||||
.and_then(|proj| {
|
.map(|proj| {
|
||||||
Ok((
|
(
|
||||||
// 4-1. Passing in the project-specific PartialConfig
|
// 4-1. Passing in the project-specific PartialConfig
|
||||||
proj.project_path(project),
|
proj.project_path(project),
|
||||||
// 4-2. Getting and converting to PartialConfig, or returning any Err() if one appears.
|
// 4-2. Getting and converting to PartialConfig, or returning any Err() if one appears.
|
||||||
get_table(cfg_table, "all").and_then(PartialConfig::try_from)?,
|
get_table(cfg_table, "all")
|
||||||
))
|
.and_then(PartialConfig::try_from)
|
||||||
|
.unwrap_or(PartialConfig::default()),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
// 5. Merge the PartialConfigs together, project-specific has higher priority
|
.map(|pair| pair.0.merge(pair.1))
|
||||||
.and_then(|pair| {
|
|
||||||
Ok(pair.0.merge(pair.1))
|
|
||||||
});
|
|
||||||
maybe_proj
|
|
||||||
})
|
})
|
||||||
.filter_map(|res| res.ok())
|
.filter_map(|res| res.ok())
|
||||||
.fold(PartialConfig::default(), |acc, inc|{
|
.fold(PartialConfig::default(), |acc, inc| acc.merge(inc));
|
||||||
acc.merge(inc)
|
Ok(config_iter)
|
||||||
});
|
|
||||||
return Ok(config_iter);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
#[cfg_attr(test, derive(PartialEq))]
|
#[cfg_attr(test, derive(PartialEq))]
|
||||||
pub struct PartialConfig {
|
pub struct PartialConfig {
|
||||||
project_path: Option<String>,
|
project_path: Option<String>,
|
||||||
gitea_url: Option<String>,
|
pub gitea_url: Option<String>,
|
||||||
owner: Option<String>,
|
pub owner: Option<String>,
|
||||||
repo: Option<String>,
|
pub repo: Option<String>,
|
||||||
token: Option<String>,
|
pub token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PartialConfig {
|
impl PartialConfig {
|
||||||
// One lonely builder-pattern function to set the project path.
|
// One lonely builder-pattern function to set the project path.
|
||||||
// This is so I can do continuation style calls instead of a bunch of
|
// This is so I can do continuation style calls instead of a bunch of
|
||||||
// successive `let conf = ...` temporaries.
|
// successive `let conf = ...` temporaries.
|
||||||
fn project_path(self, path: impl ToString) -> Self{
|
fn project_path(self, path: impl ToString) -> Self {
|
||||||
PartialConfig {
|
PartialConfig {
|
||||||
project_path: Some(path.to_string()),
|
project_path: Some(path.to_string()),
|
||||||
..self
|
..self
|
||||||
@@ -167,88 +165,44 @@ impl PartialConfig {
|
|||||||
impl TryFrom<&Table> for PartialConfig {
|
impl TryFrom<&Table> for PartialConfig {
|
||||||
type Error = crate::config::Error;
|
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> {
|
fn try_from(value: &Table) -> Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
// can't get table name because that key is gone by this point.
|
// can't get table name because that key is gone by this point.
|
||||||
project_path: None,
|
project_path: None,
|
||||||
gitea_url: get_maybe_property(&value, "gitea_url")?.cloned(),
|
gitea_url: get_maybe_property(value, "gitea_url")?.cloned(),
|
||||||
owner: get_maybe_property(&value, "owner")?.cloned(),
|
owner: get_maybe_property(value, "owner")?.cloned(),
|
||||||
repo: get_maybe_property(&value, "repo")?.cloned(),
|
repo: get_maybe_property(value, "repo")?.cloned(),
|
||||||
token: get_maybe_property(&value, "token")?.cloned(),
|
token: get_maybe_property(value, "token")?.cloned(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
#[cfg_attr(test, derive(PartialEq))]
|
|
||||||
struct WholeFile {
|
|
||||||
all: PartialConfig,
|
|
||||||
project_overrides: Vec<PartialConfig>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_from_file(path: &str) -> Result<WholeFile> {
|
|
||||||
let res = std::fs::read_to_string(path);
|
|
||||||
match res {
|
|
||||||
Ok(s) => read_conf_str(s.as_str()),
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("->> file io err: {:?}", e);
|
|
||||||
Err(Error::CouldntReadFile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_conf_str(text: &str) -> Result<WholeFile> {
|
|
||||||
let mut whole = WholeFile::default();
|
|
||||||
|
|
||||||
let toml_val = text.parse::<Value>()?;
|
|
||||||
|
|
||||||
// The config file is one big table. If the string we decoded is
|
|
||||||
// some other toml::Value variant, it's not correct.
|
|
||||||
// Try getting it as a table, return Err(BadFormat) otherwise.
|
|
||||||
let cfg_table = toml_val.as_table().ok_or(Error::BadFormat)?;
|
|
||||||
|
|
||||||
// Get the global config out of the file
|
|
||||||
let table_all = get_table(cfg_table, "all")?;
|
|
||||||
whole.all = PartialConfig::try_from(table_all)?;
|
|
||||||
|
|
||||||
// Loop over the per-project configs, if any.
|
|
||||||
let per_project_keys = cfg_table
|
|
||||||
.keys()
|
|
||||||
.filter(|s| { // Discard the "[all]" table
|
|
||||||
*s != "all"
|
|
||||||
});
|
|
||||||
|
|
||||||
for path in per_project_keys {
|
|
||||||
let tab = get_table(cfg_table, path)?;
|
|
||||||
let part_cfg = PartialConfig::try_from(tab)?
|
|
||||||
.project_path(path.clone());
|
|
||||||
whole.project_overrides.push(part_cfg);
|
|
||||||
}
|
|
||||||
Ok(whole)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The outer value must be a Table so we can get the sub-table from it.
|
/// The outer value must be a Table so we can get the sub-table from it.
|
||||||
fn get_table<'outer>(outer: &'outer Table, table_name: impl ToString) -> Result<&'outer Table> {
|
fn get_table(outer: &Table, table_name: impl ToString) -> Result<&Table> {
|
||||||
Ok(outer
|
outer
|
||||||
.get(&table_name.to_string())
|
.get(&table_name.to_string())
|
||||||
.ok_or(Error::NoSuchTable)?
|
.ok_or(Error::NoSuchTable)?
|
||||||
.as_table()
|
.as_table()
|
||||||
.ok_or(Error::BadFormat)?)
|
.ok_or(Error::BadFormat)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Similar to `get_property()` but maps the "Error::NoSuchProperty" result to
|
/// Similar to `get_property()` but maps the "Error::NoSuchProperty" result to
|
||||||
/// Option::None. Some properties aren't specified, and that's okay... sometimes.
|
/// Option::None. Some properties aren't specified, and that's okay... sometimes.
|
||||||
fn get_maybe_property<'outer> (outer: &'outer Table, property: impl ToString) -> Result<Option<&'outer String>> {
|
fn get_maybe_property(outer: &Table, property: impl ToString) -> Result<Option<&String>> {
|
||||||
let maybe_prop = get_property(outer, property);
|
let maybe_prop = get_property(outer, property);
|
||||||
match maybe_prop {
|
match maybe_prop {
|
||||||
Ok(value) => Ok(Some(value)),
|
Ok(value) => Ok(Some(value)),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if let Error::NoSuchProperty = e {
|
if let Error::NoSuchProperty = e {
|
||||||
return Ok(None);
|
Ok(None)
|
||||||
} else {
|
} else {
|
||||||
return Err(e);
|
Err(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,8 +210,10 @@ fn get_maybe_property<'outer> (outer: &'outer Table, property: impl ToString) ->
|
|||||||
|
|
||||||
/// The config properties are individual strings. This gets the named property,
|
/// The config properties are individual strings. This gets the named property,
|
||||||
/// or an error explaining why it couldn't be fetched.
|
/// or an error explaining why it couldn't be fetched.
|
||||||
fn get_property<'outer>(outer: &'outer Table, property: impl ToString) -> Result<&'outer String> {
|
fn get_property(outer: &Table, property: impl ToString) -> Result<&String> {
|
||||||
let maybe_prop = outer.get(&property.to_string()).ok_or(Error::NoSuchProperty)?;
|
let maybe_prop = outer
|
||||||
|
.get(&property.to_string())
|
||||||
|
.ok_or(Error::NoSuchProperty)?;
|
||||||
if let Value::String(text) = maybe_prop {
|
if let Value::String(text) = maybe_prop {
|
||||||
Ok(text)
|
Ok(text)
|
||||||
} else {
|
} else {
|
||||||
@@ -271,43 +227,6 @@ mod tests {
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
// Util for generating a reference struct
|
|
||||||
fn gen_expected_struct() -> WholeFile {
|
|
||||||
WholeFile {
|
|
||||||
all: PartialConfig {
|
|
||||||
project_path: None,
|
|
||||||
gitea_url: Some(String::from("http://localhost:3000")),
|
|
||||||
owner: None,
|
|
||||||
repo: None,
|
|
||||||
token: Some(String::from("fake-token"))
|
|
||||||
},
|
|
||||||
project_overrides: vec![
|
|
||||||
PartialConfig {
|
|
||||||
project_path: Some(String::from("/home/robert/projects/gt-tool")),
|
|
||||||
gitea_url: None,
|
|
||||||
owner: Some(String::from("robert")),
|
|
||||||
repo: Some(String::from("gt-tool")),
|
|
||||||
token: None,
|
|
||||||
},
|
|
||||||
PartialConfig {
|
|
||||||
project_path: Some(String::from("/home/robert/projects/rcalc")),
|
|
||||||
gitea_url: None,
|
|
||||||
owner: Some(String::from("jamis")),
|
|
||||||
repo: Some(String::from("rcalc")),
|
|
||||||
token: None,
|
|
||||||
},
|
|
||||||
PartialConfig {
|
|
||||||
project_path: Some(String::from("/home/robert/projects/rcalc-builders")),
|
|
||||||
gitea_url: None,
|
|
||||||
owner: Some(String::from("jamis")),
|
|
||||||
repo: Some(String::from("rcalc")),
|
|
||||||
token: None,
|
|
||||||
},
|
|
||||||
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn read_single_prop() -> Result<()> {
|
fn read_single_prop() -> Result<()> {
|
||||||
let fx_input_str = "owner = \"dingus\"";
|
let fx_input_str = "owner = \"dingus\"";
|
||||||
@@ -339,7 +258,10 @@ mod tests {
|
|||||||
let fx_value = fx_input_str.parse::<Value>()?;
|
let fx_value = fx_input_str.parse::<Value>()?;
|
||||||
let fx_value = fx_value.as_table().ok_or(Error::BadFormat)?;
|
let fx_value = fx_value.as_table().ok_or(Error::BadFormat)?;
|
||||||
let mut expected: Map<String, Value> = Map::new();
|
let mut expected: Map<String, Value> = Map::new();
|
||||||
expected.insert(String::from("with_a_garbage"), Value::String(String::from("value")));
|
expected.insert(
|
||||||
|
String::from("with_a_garbage"),
|
||||||
|
Value::String(String::from("value")),
|
||||||
|
);
|
||||||
|
|
||||||
let res = get_table(&fx_value, String::from("tab"))?;
|
let res = get_table(&fx_value, String::from("tab"))?;
|
||||||
assert_eq!(res, &expected);
|
assert_eq!(res, &expected);
|
||||||
@@ -347,69 +269,75 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn read_config_string_ok() -> Result<()> {
|
fn test_get_config_with_specific_match() -> Result<()> {
|
||||||
let fx_sample_config_string = include_str!("../test_data/sample_config.toml");
|
|
||||||
let fx_expected_struct = gen_expected_struct();
|
|
||||||
let conf = read_conf_str(fx_sample_config_string)?;
|
|
||||||
|
|
||||||
assert_eq!(conf, fx_expected_struct);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/* TODO: Improve semantics around reading an empty string
|
|
||||||
An empty config string will result in Error::NoSuchTable when "[all]"
|
|
||||||
is retrieved. But this will *also* happen when other configs are present,
|
|
||||||
but "[all]" isn't. Do I treat these as valid configurations, using some
|
|
||||||
hard-coded default as the fallback? Or do I reject configs that don't have
|
|
||||||
an all-table?
|
|
||||||
*/
|
|
||||||
#[test]
|
|
||||||
fn read_config_string_empty() {
|
|
||||||
let fx_sample_cfg = "";
|
|
||||||
let conf = read_conf_str(fx_sample_cfg);
|
|
||||||
assert!(conf.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
// File exists and has valid configuration.
|
|
||||||
fn load_from_file_ok() -> Result<()> {
|
|
||||||
let conf = load_from_file("test_data/sample_config.toml")?;
|
|
||||||
assert_eq!(conf, gen_expected_struct());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
// File does not exist.
|
|
||||||
fn load_from_file_missing() -> Result<()> {
|
|
||||||
let res = load_from_file("test_data/doesnt_exist.toml");
|
|
||||||
let err = res.unwrap_err();
|
|
||||||
assert_eq!(err, Error::CouldntReadFile);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
// File exists but has garbage inside.
|
|
||||||
// TODO: This bumps against the same semantic issue as the todo note on
|
|
||||||
// the 'read_config_string_empty' test
|
|
||||||
fn load_from_file_bad() {
|
|
||||||
let res = load_from_file("test_data/missing_all_table.toml");
|
|
||||||
assert!(res.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
// FIXME: Allow user-specified search paths, then update test to use them.
|
|
||||||
// This test can only work if there's a config file the program can find.
|
|
||||||
// Right now, that means a file called "gt-tool.toml" in
|
|
||||||
// 1. `/etc`
|
|
||||||
// 2. anything inside `$XDG_CONFIG_DIRS`
|
|
||||||
fn check_get_config() -> Result<()>{
|
|
||||||
let search_paths = ["./test_data/sample_config.toml"]
|
let search_paths = ["./test_data/sample_config.toml"]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(PathBuf::from);
|
.map(PathBuf::from);
|
||||||
let load_result = get_config(
|
let load_result = get_config("/home/robert/projects/gt-tool", search_paths)?;
|
||||||
"/home/robert/projects/gt-tool",
|
let expected = PartialConfig {
|
||||||
search_paths
|
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 {
|
let expected = PartialConfig {
|
||||||
project_path: Some(String::from("/home/robert/projects/gt-tool")),
|
project_path: Some(String::from("/home/robert/projects/gt-tool")),
|
||||||
owner: Some(String::from("robert")),
|
owner: Some(String::from("robert")),
|
||||||
|
|||||||
11
src/lib.rs
11
src/lib.rs
@@ -21,6 +21,11 @@ pub(crate) async fn decode_client_error(response: reqwest::Response) -> Result<A
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
Placeholder, // TODO: Enumerate error modes
|
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),
|
||||||
WrappedReqwestErr(reqwest::Error),
|
WrappedReqwestErr(reqwest::Error),
|
||||||
MissingAuthToken,
|
MissingAuthToken,
|
||||||
NoSuchFile, // for release attachment 'file exists' pre-check.
|
NoSuchFile, // for release attachment 'file exists' pre-check.
|
||||||
@@ -34,4 +39,10 @@ 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>;
|
type Result<T> = core::result::Result<T, Error>;
|
||||||
|
|||||||
55
src/main.rs
55
src/main.rs
@@ -1,4 +1,4 @@
|
|||||||
use std::path;
|
use std::path::{self, PathBuf};
|
||||||
|
|
||||||
use gt_tool::cli::Args;
|
use gt_tool::cli::Args;
|
||||||
use gt_tool::structs::release::{CreateReleaseOption, Release};
|
use gt_tool::structs::release::{CreateReleaseOption, Release};
|
||||||
@@ -12,6 +12,38 @@ use reqwest::header::ACCEPT;
|
|||||||
async fn main() -> Result<(), gt_tool::Error> {
|
async fn main() -> Result<(), gt_tool::Error> {
|
||||||
let args = Args::parse();
|
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();
|
let mut headers = reqwest::header::HeaderMap::new();
|
||||||
headers.append(ACCEPT, header::HeaderValue::from_static("application/json"));
|
headers.append(ACCEPT, header::HeaderValue::from_static("application/json"));
|
||||||
|
|
||||||
@@ -28,7 +60,7 @@ async fn main() -> Result<(), gt_tool::Error> {
|
|||||||
match args.command {
|
match args.command {
|
||||||
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, &gitea_url, &repo_fqrn).await?;
|
||||||
// Print in reverse order so the newest items are closest to the
|
// Print in reverse order so the newest items are closest to the
|
||||||
// user's command prompt. Otherwise the newest item scrolls off the
|
// user's command prompt. Otherwise the newest item scrolls off the
|
||||||
// screen and can't be seen.
|
// screen and can't be seen.
|
||||||
@@ -36,7 +68,7 @@ async fn main() -> Result<(), gt_tool::Error> {
|
|||||||
releases.iter().rev().map(|release| release.colorized()),
|
releases.iter().rev().map(|release| release.colorized()),
|
||||||
String::from(""),
|
String::from(""),
|
||||||
)
|
)
|
||||||
.map(|release| println!("{}", release))
|
.map(|release| println!("{release}"))
|
||||||
.fold((), |_, _| ());
|
.fold((), |_, _| ());
|
||||||
}
|
}
|
||||||
gt_tool::cli::Commands::CreateRelease {
|
gt_tool::cli::Commands::CreateRelease {
|
||||||
@@ -54,7 +86,7 @@ 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(&client, &gitea_url, &repo_fqrn, submission)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
gt_tool::cli::Commands::UploadRelease {
|
gt_tool::cli::Commands::UploadRelease {
|
||||||
@@ -75,7 +107,7 @@ async fn main() -> Result<(), gt_tool::Error> {
|
|||||||
// Grab all, find the one that matches the input tag.
|
// Grab all, find the one that matches the input tag.
|
||||||
// Scream if there are multiple matches.
|
// Scream if there are multiple matches.
|
||||||
let release_candidates =
|
let release_candidates =
|
||||||
gt_tool::api::release::list_releases(&client, &args.gitea_url, &args.repo).await?;
|
gt_tool::api::release::list_releases(&client, &gitea_url, &repo_fqrn).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 {
|
for file in &files {
|
||||||
@@ -93,11 +125,7 @@ async fn main() -> Result<(), gt_tool::Error> {
|
|||||||
}
|
}
|
||||||
for file in files {
|
for file in files {
|
||||||
let _attach_desc = gt_tool::api::release_attachment::create_release_attachment(
|
let _attach_desc = gt_tool::api::release_attachment::create_release_attachment(
|
||||||
&client,
|
&client, &gitea_url, &repo_fqrn, release.id, file,
|
||||||
&args.gitea_url,
|
|
||||||
&args.repo,
|
|
||||||
release.id,
|
|
||||||
file,
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
@@ -144,3 +172,10 @@ fn match_release_by_tag(tag: &String, releases: Vec<Release>) -> Option<Release>
|
|||||||
}
|
}
|
||||||
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))
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user