1
0
Fork 0

Adding upstream version 0.2.0.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-02-11 12:14:08 +01:00
parent 01ae482a94
commit 80bb1315ee
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
25 changed files with 9820 additions and 0 deletions

6
.cargo_vcs_info.json Normal file
View file

@ -0,0 +1,6 @@
{
"git": {
"sha1": "d1450c0b816c7c6f8dc05a3d99204d445519a752"
},
"path_in_vcs": ""
}

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/target
# Nix output
/result*

13
.woodpecker/check.yml Normal file
View file

@ -0,0 +1,13 @@
when:
- event: manual
- event: pull_request
steps:
check:
image: rust
commands:
- cargo check
check-fmt:
image: rust
commands:
- rustup component add rustfmt
- cargo fmt --check

52
.woodpecker/deploy.yml Normal file
View file

@ -0,0 +1,52 @@
when:
- event: tag
steps:
compile-linux:
image: rust:latest
environment:
BUILD_TYPE: "release ci"
commands:
- rustup target add x86_64-unknown-linux-gnu
- cargo build --target=x86_64-unknown-linux-gnu --release --features update-check
- strip target/x86_64-unknown-linux-gnu/release/fj
secrets: [ client_info_codeberg ]
compile-windows:
image: rust:latest
environment:
BUILD_TYPE: "release ci"
commands:
- rustup target add x86_64-pc-windows-gnu
- apt update
- apt install gcc-mingw-w64-x86-64 -y
- cargo build --target=x86_64-pc-windows-gnu --release --features update-check
- strip target/x86_64-pc-windows-gnu/release/fj.exe
secrets: [ client_info_codeberg ]
zip:
image: debian:12
commands:
- apt update
- apt install zip -y
- cd target/x86_64-pc-windows-gnu/release
- zip ../../../forgejo-cli-windows.zip fj.exe
- cd ../../..
- gzip -c target/x86_64-unknown-linux-gnu/release/fj > forgejo-cli-linux.gz
deploy-container:
image: gcr.io/kaniko-project/executor:debug
commands:
- export FORGE_HOST=$(echo $CI_FORGE_URL | sed -E 's_^https?://__')
- export AUTH="$(echo -n $CI_REPO_OWNER:$TOKEN | base64)"
- echo "{\"auths\":{\"$FORGE_HOST\":{\"auth\":\"$AUTH\"}}}" > "/kaniko/.docker/config.json"
- export CONTAINER_OWNER=$(echo $CI_REPO_OWNER | awk '{print tolower($0)}')
- executor --context ./ --dockerfile ./Dockerfile --destination "$FORGE_HOST/$CONTAINER_OWNER/forgejo-cli:latest"
secrets: [ token ]
release:
image: codeberg.org/cyborus/forgejo-cli:latest
pull: true
commands:
- export FORGE_HOST=$(echo $CI_FORGE_URL | sed -E 's_^https?://__')
- fj auth add-key $FORGE_HOST $CI_REPO_OWNER $TOKEN
- fj release --repo $CI_REPO_URL asset create $CI_COMMIT_TAG forgejo-cli-windows.zip
- fj release --repo $CI_REPO_URL asset create $CI_COMMIT_TAG forgejo-cli-linux.gz
- fj auth logout $FORGE_HOST
secrets: [ token ]

2443
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

132
Cargo.toml Normal file
View file

@ -0,0 +1,132 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2021"
name = "forgejo-cli"
version = "0.2.0"
build = "build.rs"
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "CLI tool for Forgejo"
readme = "README.md"
keywords = [
"cli",
"forgejo",
]
categories = [
"command-line-utilities",
"development-tools",
]
license = "Apache-2.0 OR MIT"
repository = "https://codeberg.org/Cyborus/forgejo-cli/"
[[bin]]
name = "fj"
path = "src/main.rs"
[dependencies.auth-git2]
version = "0.5.4"
[dependencies.base64ct]
version = "1.6.0"
features = ["std"]
[dependencies.cfg-if]
version = "1.0.0"
[dependencies.clap]
version = "4.5.11"
features = ["derive"]
[dependencies.comrak]
version = "0.26.0"
[dependencies.crossterm]
version = "0.27.0"
[dependencies.directories]
version = "5.0.1"
[dependencies.eyre]
version = "0.6.12"
[dependencies.forgejo-api]
version = "0.5.0"
[dependencies.futures]
version = "0.3.30"
[dependencies.git2]
version = "0.19.0"
[dependencies.hyper]
version = "1.4.1"
[dependencies.hyper-util]
version = "0.1.6"
features = [
"tokio",
"server",
"http1",
"http2",
]
[dependencies.open]
version = "5.3.0"
[dependencies.rand]
version = "0.8.5"
[dependencies.semver]
version = "1.0.23"
optional = true
[dependencies.serde]
version = "1.0.204"
features = ["derive"]
[dependencies.serde_json]
version = "1.0.120"
[dependencies.sha256]
version = "1.5.0"
[dependencies.soft_assert]
version = "0.1.1"
[dependencies.time]
version = "0.3.36"
features = [
"formatting",
"local-offset",
"macros",
]
[dependencies.tokio]
version = "1.39.1"
features = ["full"]
[dependencies.url]
version = "2.5.2"
[dependencies.uuid]
version = "1.10.0"
features = ["v4"]
[build-dependencies.git2]
version = "0.19.0"
[features]
update-check = ["dep:semver"]

48
Cargo.toml.orig generated Normal file
View file

@ -0,0 +1,48 @@
[package]
name = "forgejo-cli"
version = "0.2.0"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://codeberg.org/Cyborus/forgejo-cli/"
description = "CLI tool for Forgejo"
keywords = ["cli", "forgejo"]
categories = ["command-line-utilities", "development-tools"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[[bin]]
name = "fj"
path = "src/main.rs"
[features]
update-check = ["dep:semver"]
[dependencies]
auth-git2 = "0.5.4"
base64ct = { version = "1.6.0", features = ["std"] }
cfg-if = "1.0.0"
clap = { version = "4.5.11", features = ["derive"] }
comrak = "0.26.0"
crossterm = "0.27.0"
directories = "5.0.1"
eyre = "0.6.12"
forgejo-api = "0.5.0"
futures = "0.3.30"
git2 = "0.19.0"
hyper = "1.4.1"
hyper-util = { version = "0.1.6", features = ["tokio", "server", "http1", "http2"] }
open = "5.3.0"
rand = "0.8.5"
semver = { version = "1.0.23", optional = true }
serde = { version = "1.0.204", features = ["derive"] }
serde_json = "1.0.120"
sha256 = "1.5.0"
soft_assert = "0.1.1"
time = { version = "0.3.36", features = ["formatting", "local-offset", "macros"] }
tokio = { version = "1.39.1", features = ["full"] }
url = "2.5.2"
uuid = { version = "1.10.0", features = ["v4"] }
[build-dependencies]
git2 = "0.19.0"

4
Dockerfile Normal file
View file

@ -0,0 +1,4 @@
FROM debian:12
RUN apt update
RUN apt install libssl-dev ca-certificates -y
COPY target/x86_64-unknown-linux-gnu/release/fj /usr/local/bin/fj

201
LICENSE-APACHE Normal file
View file

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

21
LICENSE-MIT Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) [year] [fullname]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

105
README.md Normal file
View file

@ -0,0 +1,105 @@
# forgejo-cli
CLI tool for interacting with Forgejo
[Matrix Chat](https://matrix.to/#/#forgejo-cli:cartoon-aa.xyz)
## Installation
### Pre-built
Pre-built binaries are available for `x86_64` Windows and Linux (GNU) on the
[releases tab](https://codeberg.org/Cyborus/forgejo-cli/releases/latest).
### From source
Install with `cargo install`
```
# Latest version
cargo install forgejo-cli
# From `main`
cargo install --git https://codeberg.org/Cyborus/forgejo-cli.git --branch main
```
### Nix
A Nix flake is included in this repository that you may use. You could install it into your Nix
profile, for example:
```
nix profile install git+https://codeberg.org/Cyborus/forgejo-cli
```
...or include it in the flake inputs of your NixOS system:
```nix
{
inputs = {
# ...
forgejo-cli.url = "git+https://codeberg.org/Cyborus/forgejo-cli";
};
# ...
}
```
### OCI Container
`forgejo-cli` is available as an OCI container for use in CI, at
`codeberg.org/cyborus/forgejo-cli:latest`
## Usage
### Instance-specific aliases
While you can just use the `fj` binary directly, it can be useful to alias it
with the `--host` flag set, to create shorthands for certain instances.
```bash
# For example, a `cb` command for interacting with codeberg
alias cb="fj --host codeberg.org"
# Or disroot
alias dr="fj --host git.disroot.org"
# Or any other instance you want!
# And the alias name can be whatever, as long as the `--host` flag is set.
```
Now, when you reference a repository such as `forgejo/forgejo`, it will
implicitly get it from whichever alias you used!
```
$ cb repo info forgejo/forgejo
forgejo/forgejo
> Beyond coding. We forge.
Primary language is Go
# etc...
```
When using `fj` directly, you'd have to use a URL to access it.
```
$ fj repo info codeberg.org/forgejo/forgejo
forgejo/forgejo
> Beyond coding. We forge.
Primary language is Go
# etc...
# Notice the "dr", trying to access Disroot, still works when you specify Codeberg in the repository name!
$ dr repo info codeberg.org/forgejo/forgejo
forgejo/forgejo
> Beyond coding. We forge.
Primary language is Go
# etc...
```
## Licensing
This project is licensed under either
[Apache License Version 2.0](LICENSE-APACHE) or [MIT License](LICENSE-MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted
for inclusion in the work by you, as defined in the Apache-2.0 license, shall be
dual licensed as above, without any additional terms or conditions.

6
build.rs Normal file
View file

@ -0,0 +1,6 @@
fn main() {
println!(
"cargo:rustc-env=BUILD_TARGET={}",
std::env::var("TARGET").unwrap()
);
}

61
flake.lock generated Normal file
View file

@ -0,0 +1,61 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1723151389,
"narHash": "sha256-9AVY0ReCmSGXHrlx78+1RrqcDgVSRhHUKDVV1LLBy28=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "13fe00cb6c75461901f072ae62b5805baef9f8b2",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"utils": "utils"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

48
flake.nix Normal file
View file

@ -0,0 +1,48 @@
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, utils }: utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
in
rec {
packages.forgejo-cli = pkgs.rustPlatform.buildRustPackage {
pname = "forgejo-cli";
version = "0.2.0";
src = pkgs.lib.cleanSource ./.;
cargoLock.lockFile = ./Cargo.lock;
nativeBuildInputs = with pkgs; [ pkg-config ];
buildInputs = with pkgs; [ openssl ];
meta = with pkgs.lib; {
description = "CLI tool for Forgejo";
homepage = "https://codeberg.org/Cyborus/forgejo-cli/";
license = with licenses; [ asl20 /* or */ mit ];
};
env = {
BUILD_TYPE = "flake";
};
};
packages.default = packages.forgejo-cli;
devShells.default = pkgs.mkShell {
inputsFrom = [ packages.default ];
nativeBuildInputs = with pkgs; [
cargo
rustc
];
# Required for rust-analyzer to work
RUST_SRC_PATH = "${pkgs.rustPlatform.rustcSrc}/library";
};
});
}

269
src/auth.rs Normal file
View file

@ -0,0 +1,269 @@
use clap::Subcommand;
use eyre::OptionExt;
#[derive(Subcommand, Clone, Debug)]
pub enum AuthCommand {
/// Log in to an instance.
///
/// Opens an auth page in your browser
Login,
/// Deletes login info for an instance
Logout { host: String },
/// Add an application token for an instance
///
/// Use this if `fj auth login` doesn't work
AddKey {
/// The user that the key is associated with
user: String,
/// The key to add. If not present, the key will be read in from stdin.
key: Option<String>,
},
/// List all instances you're currently logged into
List,
}
impl AuthCommand {
pub async fn run(self, keys: &mut crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> {
match self {
AuthCommand::Login => {
let repo_info = crate::repo::RepoInfo::get_current(host_name, None, None, &keys)?;
let host_url = repo_info.host_url();
let client_info = get_client_info_for(host_url);
if let Some((client_id, _)) = client_info {
oauth_login(keys, host_url, client_id).await?;
} else {
let host_domain = host_url.host_str().ok_or_eyre("invalid host")?;
let host_path = host_url.path().strip_suffix("/").unwrap_or(host_url.path());
let applications_url =
format!("https://{host_domain}{host_path}/user/settings/applications");
println!("{host_domain}{host_path} doesn't support easy login");
println!();
println!("Please visit {applications_url}");
println!("to create a token, and use it to log in with `fj auth add-key`");
}
}
AuthCommand::Logout { host } => {
let info_opt = keys.hosts.remove(&host);
if let Some(info) = info_opt {
eprintln!("signed out of {}@{}", &info.username(), host);
} else {
eprintln!("already not signed in to {host}");
}
}
AuthCommand::AddKey { user, key } => {
let repo_info = crate::repo::RepoInfo::get_current(host_name, None, None, &keys)?;
let host_url = repo_info.host_url();
let key = match key {
Some(key) => key,
None => crate::readline("new key: ").await?.trim().to_string(),
};
let host = crate::host_with_port(&host_url);
if !keys.hosts.contains_key(host) {
let mut login = crate::keys::LoginInfo::Application {
name: user,
token: key,
};
add_ssh_alias(&mut login, host_url, keys).await;
keys.hosts.insert(host.to_owned(), login);
} else {
println!("key for {host} already exists");
}
}
AuthCommand::List => {
if keys.hosts.is_empty() {
println!("No logins.");
}
for (host_url, login_info) in &keys.hosts {
println!("{}@{}", login_info.username(), host_url);
}
}
}
Ok(())
}
}
pub fn get_client_info_for(url: &url::Url) -> Option<(&'static str, &'static str)> {
let client_info = match (crate::host_with_port(url), url.path()) {
("codeberg.org", "/") => option_env!("CLIENT_INFO_CODEBERG"),
_ => None,
};
client_info.and_then(|info| info.split_once(":"))
}
async fn oauth_login(
keys: &mut crate::KeyInfo,
host: &url::Url,
client_id: &'static str,
) -> eyre::Result<()> {
use base64ct::Encoding;
use rand::{distributions::Alphanumeric, prelude::*};
let mut rng = thread_rng();
let state = (0..32)
.map(|_| rng.sample(Alphanumeric) as char)
.collect::<String>();
let code_verifier = (0..43)
.map(|_| rng.sample(Alphanumeric) as char)
.collect::<String>();
let code_challenge =
base64ct::Base64Url::encode_string(sha256::digest(&code_verifier).as_bytes());
let mut auth_url = host.clone();
auth_url
.path_segments_mut()
.map_err(|_| eyre::eyre!("invalid url"))?
.extend(["login", "oauth", "authorize"]);
auth_url.query_pairs_mut().extend_pairs([
("client_id", client_id),
("redirect_uri", "http://127.0.0.1:26218/"),
("response_type", "code"),
("code_challenge_method", "S256"),
("code_challenge", &code_challenge),
("state", &state),
]);
open::that(auth_url.as_str()).unwrap();
let (handle, mut rx) = auth_server();
let res = rx.recv().await.unwrap();
handle.abort();
let code = match res {
Ok(Some((code, returned_state))) => {
if returned_state == state {
code
} else {
eyre::bail!("returned with invalid state");
}
}
Ok(None) => {
println!("Login canceled");
return Ok(());
}
Err(e) => {
eyre::bail!("Failed to authenticate: {e}");
}
};
let api = forgejo_api::Forgejo::new(forgejo_api::Auth::None, host.clone())?;
let request = forgejo_api::structs::OAuthTokenRequest::Public {
client_id,
code_verifier: &code_verifier,
code: &code,
redirect_uri: url::Url::parse("http://127.0.0.1:26218/").unwrap(),
};
let response = api.oauth_get_access_token(request).await?;
let api = forgejo_api::Forgejo::new(
forgejo_api::Auth::OAuth2(&response.access_token),
host.clone(),
)?;
let current_user = api.user_get_current().await?;
let name = current_user
.login
.ok_or_eyre("user does not have login name")?;
// A minute less, in case any weirdness happens at the exact moment it
// expires. Better to refresh slightly too soon than slightly too late.
let expires_in = std::time::Duration::from_secs(response.expires_in.saturating_sub(60) as u64);
let expires_at = time::OffsetDateTime::now_utc() + expires_in;
let mut login_info = crate::keys::LoginInfo::OAuth {
name,
token: response.access_token,
refresh_token: response.refresh_token,
expires_at,
};
add_ssh_alias(&mut login_info, host, keys).await;
let domain = crate::host_with_port(&host);
keys.hosts.insert(domain.to_owned(), login_info);
Ok(())
}
use tokio::{sync::mpsc::Receiver, task::JoinHandle};
fn auth_server() -> (
JoinHandle<eyre::Result<()>>,
Receiver<Result<Option<(String, String)>, String>>,
) {
let addr: std::net::SocketAddr = ([127, 0, 0, 1], 26218).into();
let (tx, rx) = tokio::sync::mpsc::channel(1);
let tx = std::sync::Arc::new(tx);
let handle = tokio::spawn(async move {
let listener = tokio::net::TcpListener::bind(addr).await?;
let server =
hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new());
let svc = hyper::service::service_fn(|req: hyper::Request<hyper::body::Incoming>| {
let tx = std::sync::Arc::clone(&tx);
async move {
let mut code = None;
let mut state = None;
let mut error_description = None;
if let Some(query) = req.uri().query() {
for item in query.split("&") {
let (key, value) = item.split_once("=").unwrap_or((item, ""));
match key {
"code" => code = Some(value),
"state" => state = Some(value),
"error_description" => error_description = Some(value),
_ => eprintln!("unknown key {key} {value}"),
}
}
}
let (response, message) = match (code, state, error_description) {
(_, _, Some(error)) => (Err(error.to_owned()), "Failed to authenticate"),
(Some(code), Some(state), None) => (
Ok(Some((code.to_owned(), state.to_owned()))),
"Authenticated! Close this tab and head back to your terminal",
),
_ => (Ok(None), "Canceled"),
};
tx.send(response).await.unwrap();
Ok::<_, hyper::Error>(hyper::Response::new(message.to_owned()))
}
});
loop {
let (connection, _addr) = listener.accept().await.unwrap();
server
.serve_connection(hyper_util::rt::TokioIo::new(connection), svc)
.await
.unwrap();
}
});
(handle, rx)
}
async fn add_ssh_alias(
login: &mut crate::keys::LoginInfo,
host_url: &url::Url,
keys: &mut crate::keys::KeyInfo,
) {
let api = match login.api_for(host_url).await {
Ok(x) => x,
Err(_) => return,
};
if let Some(ssh_url) = get_instance_ssh_url(api).await {
let http_host = crate::host_with_port(&host_url);
let ssh_host = crate::host_with_port(&ssh_url);
if http_host != ssh_host {
keys.aliases
.insert(ssh_host.to_string(), http_host.to_string());
}
}
}
async fn get_instance_ssh_url(api: forgejo_api::Forgejo) -> Option<url::Url> {
let query = forgejo_api::structs::RepoSearchQuery {
limit: Some(1),
..Default::default()
};
let results = api.repo_search(query).await.ok()?;
if let Some(mut repos) = results.data {
if let Some(repo) = repos.pop() {
if let Some(ssh_url) = repo.ssh_url {
return Some(ssh_url);
}
}
}
None
}

700
src/issues.rs Normal file
View file

@ -0,0 +1,700 @@
use std::str::FromStr;
use clap::{Args, Subcommand};
use eyre::{eyre, Context, OptionExt};
use forgejo_api::structs::{
Comment, CreateIssueCommentOption, CreateIssueOption, EditIssueOption, IssueGetCommentsQuery,
};
use forgejo_api::Forgejo;
use crate::repo::{RepoArg, RepoInfo, RepoName};
#[derive(Args, Clone, Debug)]
pub struct IssueCommand {
/// The local git remote that points to the repo to operate on.
#[clap(long, short = 'R')]
remote: Option<String>,
#[clap(subcommand)]
command: IssueSubcommand,
}
#[derive(Subcommand, Clone, Debug)]
pub enum IssueSubcommand {
/// Create a new issue on a repo
Create {
title: Option<String>,
#[clap(long)]
body: Option<String>,
#[clap(long, short, id = "[HOST/]OWNER/REPO")]
repo: Option<RepoArg>,
#[clap(long)]
web: bool,
},
/// Edit an issue
Edit {
#[clap(id = "[REPO#]ID")]
issue: IssueId,
#[clap(subcommand)]
command: EditCommand,
},
/// Add a comment on an issue
Comment {
#[clap(id = "[REPO#]ID")]
issue: IssueId,
body: Option<String>,
},
/// Close an issue
Close {
#[clap(id = "[REPO#]ID")]
issue: IssueId,
/// A comment to leave on the issue before closing it
#[clap(long, short)]
with_msg: Option<Option<String>>,
},
/// Search for an issue in a repo
Search {
#[clap(long, short, id = "[HOST/]OWNER/REPO")]
repo: Option<RepoArg>,
query: Option<String>,
#[clap(long, short)]
labels: Option<String>,
#[clap(long, short)]
creator: Option<String>,
#[clap(long, short)]
assignee: Option<String>,
#[clap(long, short)]
state: Option<State>,
},
/// View an issue's info
View {
#[clap(id = "[REPO#]ID")]
id: IssueId,
#[clap(subcommand)]
command: Option<ViewCommand>,
},
/// Open an issue in your browser
Browse {
#[clap(id = "[REPO#]ID")]
id: IssueId,
},
}
#[derive(Clone, Debug)]
pub struct IssueId {
pub repo: Option<RepoArg>,
pub number: u64,
}
impl FromStr for IssueId {
type Err = IssueIdError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (repo, number) = match s.rsplit_once("#") {
Some((repo, number)) => (Some(repo.parse::<RepoArg>()?), number),
None => (None, s),
};
Ok(Self {
repo,
number: number.parse()?,
})
}
}
#[derive(Debug, Clone)]
pub enum IssueIdError {
Repo(crate::repo::RepoArgError),
Number(std::num::ParseIntError),
}
impl std::fmt::Display for IssueIdError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
IssueIdError::Repo(e) => e.fmt(f),
IssueIdError::Number(e) => e.fmt(f),
}
}
}
impl From<crate::repo::RepoArgError> for IssueIdError {
fn from(value: crate::repo::RepoArgError) -> Self {
Self::Repo(value)
}
}
impl From<std::num::ParseIntError> for IssueIdError {
fn from(value: std::num::ParseIntError) -> Self {
Self::Number(value)
}
}
impl std::error::Error for IssueIdError {}
#[derive(clap::ValueEnum, Clone, Copy, Debug)]
pub enum State {
Open,
Closed,
}
impl From<State> for forgejo_api::structs::IssueListIssuesQueryState {
fn from(value: State) -> Self {
match value {
State::Open => forgejo_api::structs::IssueListIssuesQueryState::Open,
State::Closed => forgejo_api::structs::IssueListIssuesQueryState::Closed,
}
}
}
#[derive(Subcommand, Clone, Debug)]
pub enum EditCommand {
/// Edit an issue's title
Title { new_title: Option<String> },
/// Edit an issue's text content
Body { new_body: Option<String> },
/// Edit a comment on an issue
Comment {
idx: usize,
new_body: Option<String>,
},
}
#[derive(Subcommand, Clone, Debug)]
pub enum ViewCommand {
/// View an issue's title and body. The default
Body,
/// View a specific
Comment { idx: usize },
/// List every comment
Comments,
}
impl IssueCommand {
pub async fn run(self, keys: &mut crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> {
use IssueSubcommand::*;
let repo = RepoInfo::get_current(host_name, self.repo(), self.remote.as_deref(), &keys)?;
let api = keys.get_api(repo.host_url()).await?;
let repo = repo.name().ok_or_else(|| self.no_repo_error())?;
match self.command {
Create {
repo: _,
title,
body,
web,
} => create_issue(repo, &api, title, body, web).await?,
View { id, command } => match command.unwrap_or(ViewCommand::Body) {
ViewCommand::Body => view_issue(repo, &api, id.number).await?,
ViewCommand::Comment { idx } => view_comment(repo, &api, id.number, idx).await?,
ViewCommand::Comments => view_comments(repo, &api, id.number).await?,
},
Search {
repo: _,
query,
labels,
creator,
assignee,
state,
} => view_issues(repo, &api, query, labels, creator, assignee, state).await?,
Edit { issue, command } => match command {
EditCommand::Title { new_title } => {
edit_title(repo, &api, issue.number, new_title).await?
}
EditCommand::Body { new_body } => {
edit_body(repo, &api, issue.number, new_body).await?
}
EditCommand::Comment { idx, new_body } => {
edit_comment(repo, &api, issue.number, idx, new_body).await?
}
},
Close { issue, with_msg } => close_issue(repo, &api, issue.number, with_msg).await?,
Browse { id } => browse_issue(repo, &api, id.number).await?,
Comment { issue, body } => add_comment(repo, &api, issue.number, body).await?,
}
Ok(())
}
fn repo(&self) -> Option<&RepoArg> {
use IssueSubcommand::*;
match &self.command {
Create { repo, .. } | Search { repo, .. } => repo.as_ref(),
View { id: issue, .. }
| Edit { issue, .. }
| Close { issue, .. }
| Comment { issue, .. }
| Browse { id: issue, .. } => issue.repo.as_ref(),
}
}
fn no_repo_error(&self) -> eyre::Error {
use IssueSubcommand::*;
match &self.command {
Create { .. } | Search { .. } => {
eyre::eyre!("can't figure what repo to access, try specifying with `--repo`")
}
View { id: issue, .. }
| Edit { issue, .. }
| Close { issue, .. }
| Comment { issue, .. }
| Browse { id: issue, .. } => eyre::eyre!(
"can't figure out what repo to access, try specifying with `{{owner}}/{{repo}}#{}`",
issue.number
),
}
}
}
async fn create_issue(
repo: &RepoName,
api: &Forgejo,
title: Option<String>,
body: Option<String>,
web: bool,
) -> eyre::Result<()> {
match (title, web) {
(Some(title), false) => {
let body = match body {
Some(body) => body,
None => {
let mut body = String::new();
crate::editor(&mut body, Some("md")).await?;
body
}
};
let issue = api
.issue_create_issue(
repo.owner(),
repo.name(),
CreateIssueOption {
body: Some(body),
title,
assignee: None,
assignees: None,
closed: None,
due_date: None,
labels: None,
milestone: None,
r#ref: None,
},
)
.await?;
let number = issue
.number
.ok_or_else(|| eyre::eyre!("issue does not have number"))?;
let title = issue
.title
.as_ref()
.ok_or_else(|| eyre::eyre!("issue does not have title"))?;
eprintln!("created issue #{}: {}", number, title);
}
(None, true) => {
let base_repo = api.repo_get(repo.owner(), repo.name()).await?;
let mut issue_create_url = base_repo
.html_url
.clone()
.ok_or_eyre("repo does not have html url")?;
issue_create_url
.path_segments_mut()
.expect("invalid url")
.extend(["issues", "new"]);
open::that_detached(issue_create_url.as_str()).wrap_err("Failed to open URL")?;
}
(None, false) => {
eyre::bail!("requires either issue title or --web flag")
}
(Some(_), true) => {
eyre::bail!("issue title and --web flag are mutually exclusive")
}
}
Ok(())
}
pub async fn view_issue(repo: &RepoName, api: &Forgejo, id: u64) -> eyre::Result<()> {
let crate::SpecialRender {
dash,
bright_red,
bright_green,
yellow,
dark_grey,
white,
reset,
..
} = crate::special_render();
let issue = api.issue_get_issue(repo.owner(), repo.name(), id).await?;
// if it's a pull request, display it as one instead
if issue.pull_request.is_some() {
crate::prs::view_pr(repo, api, Some(id)).await?;
return Ok(());
}
let title = issue
.title
.as_ref()
.ok_or_else(|| eyre::eyre!("issue does not have title"))?;
let user = issue
.user
.as_ref()
.ok_or_else(|| eyre::eyre!("issue does not have creator"))?;
let username = user
.login
.as_ref()
.ok_or_else(|| eyre::eyre!("user does not have login"))?;
let state = issue
.state
.ok_or_else(|| eyre::eyre!("pr does not have state"))?;
let comments = issue.comments.unwrap_or_default();
println!("{yellow}{title} {dark_grey}#{id}{reset}");
print!("By {white}{username}{reset} {dash} ");
use forgejo_api::structs::StateType;
match state {
StateType::Open => println!("{bright_green}Open{reset}"),
StateType::Closed => println!("{bright_red}Closed{reset}"),
};
if let Some(body) = &issue.body {
if !body.is_empty() {
println!();
println!("{}", crate::markdown(body));
}
}
println!();
if comments == 1 {
println!("1 comment");
} else {
println!("{comments} comments");
}
Ok(())
}
async fn view_issues(
repo: &RepoName,
api: &Forgejo,
query_str: Option<String>,
labels: Option<String>,
creator: Option<String>,
assignee: Option<String>,
state: Option<State>,
) -> eyre::Result<()> {
let labels = labels
.map(|s| s.split(',').map(|s| s.to_string()).collect::<Vec<_>>())
.unwrap_or_default();
let query = forgejo_api::structs::IssueListIssuesQuery {
q: query_str,
labels: Some(labels.join(",")),
created_by: creator,
assigned_by: assignee,
state: state.map(|s| s.into()),
r#type: None,
milestones: None,
since: None,
before: None,
mentioned_by: None,
page: None,
limit: None,
};
let issues = api
.issue_list_issues(repo.owner(), repo.name(), query)
.await?;
if issues.len() == 1 {
println!("1 issue");
} else {
println!("{} issues", issues.len());
}
for issue in issues {
let number = issue
.number
.ok_or_else(|| eyre::eyre!("issue does not have number"))?;
let title = issue
.title
.as_ref()
.ok_or_else(|| eyre::eyre!("issue does not have title"))?;
let user = issue
.user
.as_ref()
.ok_or_else(|| eyre::eyre!("issue does not have creator"))?;
let username = user
.login
.as_ref()
.ok_or_else(|| eyre::eyre!("user does not have login"))?;
println!("#{}: {} (by {})", number, title, username);
}
Ok(())
}
pub async fn view_comment(repo: &RepoName, api: &Forgejo, id: u64, idx: usize) -> eyre::Result<()> {
let query = IssueGetCommentsQuery {
since: None,
before: None,
};
let comments = api
.issue_get_comments(repo.owner(), repo.name(), id, query)
.await?;
let comment = comments
.get(idx)
.ok_or_else(|| eyre!("comment {idx} doesn't exist"))?;
print_comment(comment)?;
Ok(())
}
pub async fn view_comments(repo: &RepoName, api: &Forgejo, id: u64) -> eyre::Result<()> {
let query = IssueGetCommentsQuery {
since: None,
before: None,
};
let comments = api
.issue_get_comments(repo.owner(), repo.name(), id, query)
.await?;
for comment in comments {
print_comment(&comment)?;
}
Ok(())
}
fn print_comment(comment: &Comment) -> eyre::Result<()> {
let body = comment
.body
.as_ref()
.ok_or_else(|| eyre::eyre!("comment does not have body"))?;
let user = comment
.user
.as_ref()
.ok_or_else(|| eyre::eyre!("comment does not have user"))?;
let username = user
.login
.as_ref()
.ok_or_else(|| eyre::eyre!("user does not have login"))?;
println!("{} said:", username);
println!("{}", crate::markdown(body));
let assets = comment
.assets
.as_ref()
.ok_or_else(|| eyre::eyre!("comment does not have assets"))?;
if !assets.is_empty() {
println!("({} attachments)", assets.len());
}
Ok(())
}
pub async fn browse_issue(repo: &RepoName, api: &Forgejo, id: u64) -> eyre::Result<()> {
let issue = api.issue_get_issue(repo.owner(), repo.name(), id).await?;
let html_url = issue
.html_url
.as_ref()
.ok_or_else(|| eyre::eyre!("issue does not have html_url"))?;
open::that_detached(html_url.as_str()).wrap_err("Failed to open URL")?;
Ok(())
}
pub async fn add_comment(
repo: &RepoName,
api: &Forgejo,
issue: u64,
body: Option<String>,
) -> eyre::Result<()> {
let body = match body {
Some(body) => body,
None => {
let mut body = String::new();
crate::editor(&mut body, Some("md")).await?;
body
}
};
api.issue_create_comment(
repo.owner(),
repo.name(),
issue,
forgejo_api::structs::CreateIssueCommentOption {
body,
updated_at: None,
},
)
.await?;
Ok(())
}
pub async fn edit_title(
repo: &RepoName,
api: &Forgejo,
issue: u64,
new_title: Option<String>,
) -> eyre::Result<()> {
let new_title = match new_title {
Some(s) => s,
None => {
let issue_info = api
.issue_get_issue(repo.owner(), repo.name(), issue)
.await?;
let mut title = issue_info
.title
.ok_or_else(|| eyre::eyre!("issue does not have title"))?;
crate::editor(&mut title, Some("md")).await?;
title
}
};
let new_title = new_title.trim();
if new_title.is_empty() {
eyre::bail!("title cannot be empty");
}
if new_title.contains('\n') {
eyre::bail!("title cannot contain newlines");
}
api.issue_edit_issue(
repo.owner(),
repo.name(),
issue,
forgejo_api::structs::EditIssueOption {
title: Some(new_title.to_owned()),
assignee: None,
assignees: None,
body: None,
due_date: None,
milestone: None,
r#ref: None,
state: None,
unset_due_date: None,
updated_at: None,
},
)
.await?;
Ok(())
}
pub async fn edit_body(
repo: &RepoName,
api: &Forgejo,
issue: u64,
new_body: Option<String>,
) -> eyre::Result<()> {
let new_body = match new_body {
Some(s) => s,
None => {
let issue_info = api
.issue_get_issue(repo.owner(), repo.name(), issue)
.await?;
let mut body = issue_info
.body
.ok_or_else(|| eyre::eyre!("issue does not have body"))?;
crate::editor(&mut body, Some("md")).await?;
body
}
};
api.issue_edit_issue(
repo.owner(),
repo.name(),
issue,
forgejo_api::structs::EditIssueOption {
body: Some(new_body),
assignee: None,
assignees: None,
due_date: None,
milestone: None,
r#ref: None,
state: None,
title: None,
unset_due_date: None,
updated_at: None,
},
)
.await?;
Ok(())
}
pub async fn edit_comment(
repo: &RepoName,
api: &Forgejo,
issue: u64,
idx: usize,
new_body: Option<String>,
) -> eyre::Result<()> {
let comments = api
.issue_get_comments(
repo.owner(),
repo.name(),
issue,
IssueGetCommentsQuery {
since: None,
before: None,
},
)
.await?;
let comment = comments
.get(idx)
.ok_or_else(|| eyre!("comment not found"))?;
let new_body = match new_body {
Some(s) => s,
None => {
let mut body = comment
.body
.clone()
.ok_or_else(|| eyre::eyre!("issue does not have body"))?;
crate::editor(&mut body, Some("md")).await?;
body
}
};
let id = comment
.id
.ok_or_else(|| eyre::eyre!("comment does not have id"))? as u64;
api.issue_edit_comment(
repo.owner(),
repo.name(),
id,
forgejo_api::structs::EditIssueCommentOption {
body: new_body,
updated_at: None,
},
)
.await?;
Ok(())
}
pub async fn close_issue(
repo: &RepoName,
api: &Forgejo,
issue: u64,
message: Option<Option<String>>,
) -> eyre::Result<()> {
if let Some(message) = message {
let body = match message {
Some(m) => m,
None => {
let mut s = String::new();
crate::editor(&mut s, Some("md")).await?;
s
}
};
let opt = CreateIssueCommentOption {
body,
updated_at: None,
};
api.issue_create_comment(repo.owner(), repo.name(), issue, opt)
.await?;
}
let edit = EditIssueOption {
state: Some("closed".into()),
assignee: None,
assignees: None,
body: None,
due_date: None,
milestone: None,
r#ref: None,
title: None,
unset_due_date: None,
updated_at: None,
};
let issue_data = api
.issue_edit_issue(repo.owner(), repo.name(), issue, edit)
.await?;
let issue_title = issue_data
.title
.as_deref()
.ok_or_eyre("issue does not have title")?;
println!("Closed issue {issue}: \"{issue_title}\"");
Ok(())
}

148
src/keys.rs Normal file
View file

@ -0,0 +1,148 @@
use eyre::eyre;
use forgejo_api::{Auth, Forgejo};
use std::{collections::BTreeMap, io::ErrorKind};
use tokio::io::AsyncWriteExt;
use url::Url;
#[derive(serde::Serialize, serde::Deserialize, Clone, Default)]
pub struct KeyInfo {
pub hosts: BTreeMap<String, LoginInfo>,
#[serde(default)]
pub aliases: BTreeMap<String, String>,
}
impl KeyInfo {
pub async fn load() -> eyre::Result<Self> {
let path = directories::ProjectDirs::from("", "Cyborus", "forgejo-cli")
.ok_or_else(|| eyre!("Could not find data directory"))?
.data_dir()
.join("keys.json");
let json = tokio::fs::read(path).await;
let this = match json {
Ok(x) => serde_json::from_slice::<Self>(&x)?,
Err(e) if e.kind() == ErrorKind::NotFound => {
eprintln!("keys file not found, creating");
Self::default()
}
Err(e) => return Err(e.into()),
};
Ok(this)
}
pub async fn save(&self) -> eyre::Result<()> {
let json = serde_json::to_vec_pretty(self)?;
let dirs = directories::ProjectDirs::from("", "Cyborus", "forgejo-cli")
.ok_or_else(|| eyre!("Could not find data directory"))?;
let path = dirs.data_dir();
tokio::fs::create_dir_all(path).await?;
tokio::fs::File::create(path.join("keys.json"))
.await?
.write_all(&json)
.await?;
Ok(())
}
pub fn get_login(&mut self, url: &Url) -> Option<&mut LoginInfo> {
let host = crate::host_with_port(url);
let login_info = self.hosts.get_mut(host)?;
Some(login_info)
}
pub async fn get_api(&mut self, url: &Url) -> eyre::Result<Forgejo> {
match self.get_login(url) {
Some(login) => login.api_for(url).await,
None => Forgejo::new(Auth::None, url.clone()).map_err(Into::into),
}
}
pub fn deref_alias(&self, url: url::Url) -> url::Url {
match self.aliases.get(crate::host_with_port(&url)) {
Some(replacement) => {
let s = format!(
"{}{}{}",
&url[..url::Position::BeforeHost],
replacement,
&url[url::Position::AfterPort..]
);
url::Url::parse(&s).unwrap()
}
None => url,
}
}
}
pub const USER_AGENT: &str = concat!(
env!("CARGO_PKG_NAME"),
"/",
env!("CARGO_PKG_VERSION"),
" (",
env!("CARGO_PKG_REPOSITORY"),
")"
);
#[derive(serde::Serialize, serde::Deserialize, Clone)]
#[serde(tag = "type")]
pub enum LoginInfo {
Application {
name: String,
token: String,
},
OAuth {
name: String,
token: String,
refresh_token: String,
expires_at: time::OffsetDateTime,
},
}
impl LoginInfo {
pub fn username(&self) -> &str {
match self {
LoginInfo::Application { name, .. } => name,
LoginInfo::OAuth { name, .. } => name,
}
}
pub async fn api_for(&mut self, url: &Url) -> eyre::Result<Forgejo> {
match self {
LoginInfo::Application { token, .. } => {
let api = Forgejo::with_user_agent(Auth::Token(token), url.clone(), USER_AGENT)?;
Ok(api)
}
LoginInfo::OAuth {
token,
refresh_token,
expires_at,
..
} => {
if time::OffsetDateTime::now_utc() >= *expires_at {
let api = Forgejo::with_user_agent(Auth::None, url.clone(), USER_AGENT)?;
let (client_id, client_secret) = crate::auth::get_client_info_for(url)
.ok_or_else(|| {
eyre::eyre!("Can't refresh token; no client info for {url}. How did this happen?")
})?;
let response = api
.oauth_get_access_token(forgejo_api::structs::OAuthTokenRequest::Refresh {
refresh_token,
client_id,
client_secret,
})
.await?;
*token = response.access_token;
*refresh_token = response.refresh_token;
// A minute less, in case any weirdness happens at the exact moment it
// expires. Better to refresh slightly too soon than slightly too late.
let expires_in = std::time::Duration::from_secs(
response.expires_in.saturating_sub(60) as u64,
);
*expires_at = time::OffsetDateTime::now_utc() + expires_in;
}
let api = Forgejo::with_user_agent(Auth::Token(token), url.clone(), USER_AGENT)?;
Ok(api)
}
}
}
}

761
src/main.rs Normal file
View file

@ -0,0 +1,761 @@
use std::io::IsTerminal;
use clap::{Parser, Subcommand};
use eyre::eyre;
use tokio::io::AsyncWriteExt;
mod keys;
use keys::*;
mod auth;
mod issues;
mod prs;
mod release;
mod repo;
mod user;
mod version;
mod whoami;
mod wiki;
#[derive(Parser, Debug)]
pub struct App {
#[clap(long, short = 'H')]
host: Option<String>,
#[clap(long)]
style: Option<Style>,
#[clap(subcommand)]
command: Command,
}
#[derive(Subcommand, Clone, Debug)]
pub enum Command {
#[clap(subcommand)]
Repo(repo::RepoCommand),
Issue(issues::IssueCommand),
Pr(prs::PrCommand),
Wiki(wiki::WikiCommand),
#[command(name = "whoami")]
WhoAmI(whoami::WhoAmICommand),
#[clap(subcommand)]
Auth(auth::AuthCommand),
Release(release::ReleaseCommand),
User(user::UserCommand),
Version(version::VersionCommand),
}
#[tokio::main]
async fn main() -> eyre::Result<()> {
let args = App::parse();
let _ = SPECIAL_RENDER.set(SpecialRender::new(args.style.unwrap_or_default()));
let mut keys = KeyInfo::load().await?;
let host_name = args.host.as_deref();
// let remote = repo::RepoInfo::get_current(host_name, remote_name)?;
match args.command {
Command::Repo(subcommand) => subcommand.run(&mut keys, host_name).await?,
Command::Issue(subcommand) => subcommand.run(&mut keys, host_name).await?,
Command::Pr(subcommand) => subcommand.run(&mut keys, host_name).await?,
Command::Wiki(subcommand) => subcommand.run(&mut keys, host_name).await?,
Command::WhoAmI(command) => command.run(&mut keys, host_name).await?,
Command::Auth(subcommand) => subcommand.run(&mut keys, host_name).await?,
Command::Release(subcommand) => subcommand.run(&mut keys, host_name).await?,
Command::User(subcommand) => subcommand.run(&mut keys, host_name).await?,
Command::Version(command) => command.run().await?,
}
keys.save().await?;
Ok(())
}
async fn readline(msg: &str) -> eyre::Result<String> {
use std::io::Write;
print!("{msg}");
std::io::stdout().flush()?;
tokio::task::spawn_blocking(|| {
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
Ok(input)
})
.await?
}
async fn editor(contents: &mut String, ext: Option<&str>) -> eyre::Result<()> {
let editor = std::path::PathBuf::from(
std::env::var_os("EDITOR").ok_or_else(|| eyre!("unable to locate editor"))?,
);
let (mut file, path) = tempfile(ext).await?;
file.write_all(contents.as_bytes()).await?;
drop(file);
// Async block acting as a try/catch block so that the temp file is deleted even
// on errors
let res = async {
eprint!("waiting on editor\r");
let flags = get_editor_flags(&editor);
let status = tokio::process::Command::new(editor)
.args(flags)
.arg(&path)
.status()
.await?;
if !status.success() {
eyre::bail!("editor exited unsuccessfully");
}
*contents = tokio::fs::read_to_string(&path).await?;
eprint!(" \r");
Ok(())
}
.await;
tokio::fs::remove_file(path).await?;
res?;
Ok(())
}
fn get_editor_flags(editor_path: &std::path::Path) -> &'static [&'static str] {
let editor_name = match editor_path.file_stem().and_then(|s| s.to_str()) {
Some(name) => name,
None => return &[],
};
if editor_name == "code" {
return &["--wait"];
}
&[]
}
async fn tempfile(ext: Option<&str>) -> tokio::io::Result<(tokio::fs::File, std::path::PathBuf)> {
let filename = uuid::Uuid::new_v4();
let mut path = std::env::temp_dir().join(filename.to_string());
if let Some(ext) = ext {
path.set_extension(ext);
}
let file = tokio::fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.open(&path)
.await?;
Ok((file, path))
}
fn ssh_url_parse(s: &str) -> Result<url::Url, url::ParseError> {
url::Url::parse(s).or_else(|_| {
let mut new_s = String::new();
new_s.push_str("ssh://");
let auth_end = s.find("@").unwrap_or(0);
new_s.push_str(&s[..auth_end]);
new_s.push_str(&s[auth_end..].replacen(":", "/", 1));
url::Url::parse(&new_s)
})
}
fn host_with_port(url: &url::Url) -> &str {
&url[url::Position::BeforeHost..url::Position::AfterPort]
}
use std::sync::OnceLock;
static SPECIAL_RENDER: OnceLock<SpecialRender> = OnceLock::new();
fn special_render() -> &'static SpecialRender {
SPECIAL_RENDER
.get()
.expect("attempted to get special characters before that was initialized")
}
#[derive(clap::ValueEnum, Clone, Copy, Debug, Default)]
enum Style {
/// Use special characters, and colors.
#[default]
Fancy,
/// No special characters and no colors. Always used in non-terminal contexts (i.e. pipes)
Minimal,
}
struct SpecialRender {
fancy: bool,
dash: char,
bullet: char,
body_prefix: char,
horiz_rule: char,
// Uncomment these as needed
// red: &'static str,
bright_red: &'static str,
// green: &'static str,
bright_green: &'static str,
// blue: &'static str,
bright_blue: &'static str,
// cyan: &'static str,
bright_cyan: &'static str,
yellow: &'static str,
// bright_yellow: &'static str,
// magenta: &'static str,
bright_magenta: &'static str,
black: &'static str,
dark_grey: &'static str,
light_grey: &'static str,
white: &'static str,
no_fg: &'static str,
reset: &'static str,
dark_grey_bg: &'static str,
// no_bg: &'static str,
hide_cursor: &'static str,
show_cursor: &'static str,
clear_line: &'static str,
italic: &'static str,
bold: &'static str,
strike: &'static str,
no_italic_bold: &'static str,
no_strike: &'static str,
}
impl SpecialRender {
fn new(display: Style) -> Self {
let is_tty = std::io::stdout().is_terminal();
match display {
_ if !is_tty => Self::minimal(),
Style::Fancy => Self::fancy(),
Style::Minimal => Self::minimal(),
}
}
fn fancy() -> Self {
Self {
fancy: true,
dash: '',
bullet: '',
body_prefix: '',
horiz_rule: '',
// red: "\x1b[31m",
bright_red: "\x1b[91m",
// green: "\x1b[32m",
bright_green: "\x1b[92m",
// blue: "\x1b[34m",
bright_blue: "\x1b[94m",
// cyan: "\x1b[36m",
bright_cyan: "\x1b[96m",
yellow: "\x1b[33m",
// bright_yellow: "\x1b[93m",
// magenta: "\x1b[35m",
bright_magenta: "\x1b[95m",
black: "\x1b[30m",
dark_grey: "\x1b[90m",
light_grey: "\x1b[37m",
white: "\x1b[97m",
no_fg: "\x1b[39m",
reset: "\x1b[0m",
dark_grey_bg: "\x1b[100m",
// no_bg: "\x1b[49",
hide_cursor: "\x1b[?25l",
show_cursor: "\x1b[?25h",
clear_line: "\x1b[2K",
italic: "\x1b[3m",
bold: "\x1b[1m",
strike: "\x1b[9m",
no_italic_bold: "\x1b[23m",
no_strike: "\x1b[29m",
}
}
fn minimal() -> Self {
Self {
fancy: false,
dash: '-',
bullet: '-',
body_prefix: '>',
horiz_rule: '-',
// red: "",
bright_red: "",
// green: "",
bright_green: "",
// blue: "",
bright_blue: "",
// cyan: "",
bright_cyan: "",
yellow: "",
// bright_yellow: "",
// magenta: "",
bright_magenta: "",
black: "",
dark_grey: "",
light_grey: "",
white: "",
no_fg: "",
reset: "",
dark_grey_bg: "",
// no_bg: "",
hide_cursor: "",
show_cursor: "",
clear_line: "",
italic: "",
bold: "",
strike: "~~",
no_italic_bold: "",
no_strike: "~~",
}
}
}
fn max_line_length() -> usize {
let (terminal_width, _) = crossterm::terminal::size().unwrap_or((80, 24));
(terminal_width as usize - 2).min(80)
}
fn render_text(text: &str) -> String {
let mut ansi_printer = AnsiPrinter::new(max_line_length());
ansi_printer.pause_style();
ansi_printer.prefix();
ansi_printer.resume_style();
ansi_printer.text(text);
ansi_printer.out
}
fn markdown(text: &str) -> String {
let SpecialRender {
fancy,
bullet,
horiz_rule,
bright_blue,
dark_grey_bg,
body_prefix,
..
} = *special_render();
if !fancy {
let mut out = String::new();
for line in text.lines() {
use std::fmt::Write;
let _ = writeln!(&mut out, "{body_prefix} {line}");
}
return out;
}
let arena = comrak::Arena::new();
let mut options = comrak::Options::default();
options.extension.strikethrough = true;
let root = comrak::parse_document(&arena, text, &options);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Side {
Start,
End,
}
let mut explore_stack = Vec::new();
let mut render_queue = Vec::new();
explore_stack.extend(root.reverse_children().map(|x| (x, Side::Start)));
while let Some((node, side)) = explore_stack.pop() {
if side == Side::Start {
explore_stack.push((node, Side::End));
explore_stack.extend(node.reverse_children().map(|x| (x, Side::Start)));
}
render_queue.push((node, side));
}
let mut list_numbers = Vec::new();
let max_line_len = max_line_length();
let mut links = Vec::new();
let mut ansi_printer = AnsiPrinter::new(max_line_len);
ansi_printer.pause_style();
ansi_printer.prefix();
ansi_printer.resume_style();
let mut iter = render_queue.into_iter().peekable();
while let Some((item, side)) = iter.next() {
use comrak::nodes::NodeValue;
use Side::*;
match (&item.data.borrow().value, side) {
(NodeValue::Paragraph, Start) => (),
(NodeValue::Paragraph, End) => {
if iter.peek().is_some_and(|(_, side)| *side == Start) {
ansi_printer.newline();
ansi_printer.newline();
}
}
(NodeValue::Text(s), Start) => ansi_printer.text(s),
(NodeValue::Link(_), Start) => {
ansi_printer.start_fg(bright_blue);
}
(NodeValue::Link(link), End) => {
use std::fmt::Write;
ansi_printer.stop_fg();
links.push(link.url.clone());
let _ = write!(&mut ansi_printer, "({})", links.len());
}
(NodeValue::Image(_), Start) => {
ansi_printer.start_fg(bright_blue);
}
(NodeValue::Image(link), End) => {
use std::fmt::Write;
ansi_printer.stop_fg();
links.push(link.url.clone());
let _ = write!(&mut ansi_printer, "({})", links.len());
}
(NodeValue::Code(code), Start) => {
ansi_printer.pause_style();
ansi_printer.start_bg(dark_grey_bg);
ansi_printer.text(&code.literal);
ansi_printer.resume_style();
}
(NodeValue::CodeBlock(code), Start) => {
if ansi_printer.cur_line_len != 0 {
ansi_printer.newline();
}
ansi_printer.pause_style();
ansi_printer.start_bg(dark_grey_bg);
ansi_printer.text(&code.literal);
ansi_printer.newline();
ansi_printer.resume_style();
ansi_printer.newline();
}
(NodeValue::BlockQuote, Start) => {
ansi_printer.blockquote_depth += 1;
ansi_printer.pause_style();
ansi_printer.prefix();
ansi_printer.resume_style();
}
(NodeValue::BlockQuote, End) => {
ansi_printer.blockquote_depth -= 1;
ansi_printer.newline();
}
(NodeValue::HtmlInline(html), Start) => {
ansi_printer.pause_style();
ansi_printer.text(html);
ansi_printer.resume_style();
}
(NodeValue::HtmlBlock(html), Start) => {
if ansi_printer.cur_line_len != 0 {
ansi_printer.newline();
}
ansi_printer.pause_style();
ansi_printer.text(&html.literal);
ansi_printer.newline();
ansi_printer.resume_style();
}
(NodeValue::Heading(heading), Start) => {
ansi_printer.reset();
ansi_printer.start_bold();
ansi_printer
.out
.extend(std::iter::repeat('#').take(heading.level as usize));
ansi_printer.out.push(' ');
ansi_printer.cur_line_len += heading.level as usize + 1;
}
(NodeValue::Heading(_), End) => {
ansi_printer.reset();
ansi_printer.newline();
ansi_printer.newline();
}
(NodeValue::List(list), Start) => {
if list.list_type == comrak::nodes::ListType::Ordered {
list_numbers.push(0);
}
}
(NodeValue::List(list), End) => {
if list.list_type == comrak::nodes::ListType::Ordered {
list_numbers.pop();
}
ansi_printer.newline();
}
(NodeValue::Item(list), Start) => {
if list.list_type == comrak::nodes::ListType::Ordered {
use std::fmt::Write;
let number: usize = if let Some(number) = list_numbers.last_mut() {
*number += 1;
*number
} else {
0
};
let _ = write!(&mut ansi_printer, "{number}. ");
} else {
ansi_printer.out.push(bullet);
ansi_printer.out.push(' ');
ansi_printer.cur_line_len += 2;
}
}
(NodeValue::Item(_), End) => {
ansi_printer.newline();
}
(NodeValue::LineBreak, Start) => ansi_printer.newline(),
(NodeValue::SoftBreak, Start) => ansi_printer.newline(),
(NodeValue::ThematicBreak, Start) => {
if ansi_printer.cur_line_len != 0 {
ansi_printer.newline();
}
ansi_printer
.out
.extend(std::iter::repeat(horiz_rule).take(max_line_len));
ansi_printer.newline();
ansi_printer.newline();
}
(NodeValue::Emph, Start) => ansi_printer.start_italic(),
(NodeValue::Emph, End) => ansi_printer.stop_italic(),
(NodeValue::Strong, Start) => ansi_printer.start_bold(),
(NodeValue::Strong, End) => ansi_printer.stop_bold(),
(NodeValue::Strikethrough, Start) => ansi_printer.start_strike(),
(NodeValue::Strikethrough, End) => ansi_printer.stop_strike(),
(NodeValue::Escaped, Start) => (),
(_, End) => (),
(_, Start) => ansi_printer.text("?TODO?"),
}
}
if !links.is_empty() {
ansi_printer.out.push('\n');
for (i, url) in links.into_iter().enumerate() {
use std::fmt::Write;
let _ = writeln!(&mut ansi_printer.out, "({}. {url} )", i + 1);
}
}
ansi_printer.out
}
#[derive(Default)]
struct RenderStyling {
bold: bool,
italic: bool,
strike: bool,
fg: Option<&'static str>,
bg: Option<&'static str>,
}
struct AnsiPrinter {
special_render: &'static SpecialRender,
out: String,
cur_line_len: usize,
max_line_len: usize,
blockquote_depth: usize,
style_frames: Vec<RenderStyling>,
}
impl AnsiPrinter {
fn new(max_line_len: usize) -> Self {
Self {
special_render: special_render(),
out: String::new(),
cur_line_len: 0,
max_line_len,
blockquote_depth: 0,
style_frames: vec![RenderStyling::default()],
}
}
fn text(&mut self, text: &str) {
let mut iter = text.lines().peekable();
while let Some(mut line) = iter.next() {
loop {
let this_len = line.chars().count();
if self.cur_line_len + this_len > self.max_line_len {
let mut split_at = self.max_line_len - self.cur_line_len;
loop {
if line.is_char_boundary(split_at) {
break;
}
split_at -= 1;
}
let split_at = line
.split_at(split_at)
.0
.char_indices()
.rev()
.find(|(_, c)| c.is_whitespace())
.map(|(i, _)| i)
.unwrap_or(split_at);
let (head, tail) = line.split_at(split_at);
self.out.push_str(head);
self.cur_line_len += split_at;
self.newline();
line = tail.trim_start();
} else {
self.out.push_str(line);
self.cur_line_len += this_len;
break;
}
}
if iter.peek().is_some() {
self.newline();
}
}
}
// Uncomment if needed
// fn current_fg(&self) -> Option<&'static str> {
// self.current_style().fg
// }
fn start_fg(&mut self, color: &'static str) {
self.current_style_mut().fg = Some(color);
self.out.push_str(color);
}
fn stop_fg(&mut self) {
self.current_style_mut().fg = None;
self.out.push_str(self.special_render.no_fg);
}
fn current_bg(&self) -> Option<&'static str> {
self.current_style().bg
}
fn start_bg(&mut self, color: &'static str) {
self.current_style_mut().bg = Some(color);
self.out.push_str(color);
}
// Uncomment if needed
// fn stop_bg(&mut self) {
// self.current_style_mut().bg = None;
// self.out.push_str(self.special_render.no_bg);
// }
fn is_bold(&self) -> bool {
self.current_style().bold
}
fn start_bold(&mut self) {
self.current_style_mut().bold = true;
self.out.push_str(self.special_render.bold);
}
fn stop_bold(&mut self) {
self.current_style_mut().bold = false;
self.out.push_str(self.special_render.reset);
if self.is_italic() {
self.out.push_str(self.special_render.italic);
}
if self.is_strike() {
self.out.push_str(self.special_render.strike);
}
}
fn is_italic(&self) -> bool {
self.current_style().italic
}
fn start_italic(&mut self) {
self.current_style_mut().italic = true;
self.out.push_str(self.special_render.italic);
}
fn stop_italic(&mut self) {
self.current_style_mut().italic = false;
self.out.push_str(self.special_render.no_italic_bold);
if self.is_bold() {
self.out.push_str(self.special_render.bold);
}
}
fn is_strike(&self) -> bool {
self.current_style().strike
}
fn start_strike(&mut self) {
self.current_style_mut().strike = true;
self.out.push_str(self.special_render.strike);
}
fn stop_strike(&mut self) {
self.current_style_mut().strike = false;
self.out.push_str(self.special_render.no_strike);
}
fn reset(&mut self) {
*self.current_style_mut() = RenderStyling::default();
self.out.push_str(self.special_render.reset);
}
fn pause_style(&mut self) {
self.out.push_str(self.special_render.reset);
self.style_frames.push(RenderStyling::default());
}
fn resume_style(&mut self) {
self.out.push_str(self.special_render.reset);
self.style_frames.pop();
if let Some(bg) = self.current_bg() {
self.out.push_str(bg);
}
if self.is_bold() {
self.out.push_str(self.special_render.bold);
}
if self.is_italic() {
self.out.push_str(self.special_render.italic);
}
if self.is_strike() {
self.out.push_str(self.special_render.strike);
}
}
fn newline(&mut self) {
if self.current_bg().is_some() {
self.out
.extend(std::iter::repeat(' ').take(self.max_line_len - self.cur_line_len));
}
self.pause_style();
self.out.push('\n');
self.prefix();
for _ in 0..self.blockquote_depth {
self.prefix();
}
self.resume_style();
self.cur_line_len = self.blockquote_depth * 2;
}
fn prefix(&mut self) {
self.out.push_str(self.special_render.dark_grey);
self.out.push(self.special_render.body_prefix);
self.out.push(' ');
}
fn current_style(&self) -> &RenderStyling {
self.style_frames.last().expect("Ran out of style frames")
}
fn current_style_mut(&mut self) -> &mut RenderStyling {
self.style_frames
.last_mut()
.expect("Ran out of style frames")
}
}
impl std::fmt::Write for AnsiPrinter {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
self.text(s);
Ok(())
}
}

1804
src/prs.rs Normal file

File diff suppressed because it is too large Load diff

621
src/release.rs Normal file
View file

@ -0,0 +1,621 @@
use clap::{Args, Subcommand};
use eyre::{bail, eyre, Context, OptionExt};
use forgejo_api::{
structs::{RepoCreateReleaseAttachmentQuery, RepoListReleasesQuery},
Forgejo,
};
use tokio::io::AsyncWriteExt;
use crate::{
keys::KeyInfo,
repo::{RepoArg, RepoInfo, RepoName},
SpecialRender,
};
#[derive(Args, Clone, Debug)]
pub struct ReleaseCommand {
/// The local git remote that points to the repo to operate on.
#[clap(long, short = 'R')]
remote: Option<String>,
/// The name of the repository to operate on.
#[clap(long, short, id = "[HOST/]OWNER/REPO")]
repo: Option<RepoArg>,
#[clap(subcommand)]
command: ReleaseSubcommand,
}
#[derive(Subcommand, Clone, Debug)]
pub enum ReleaseSubcommand {
/// Create a new release
Create {
name: String,
#[clap(long, short = 'T')]
/// Create a new cooresponding tag for this release. Defaults to release's name.
create_tag: Option<Option<String>>,
#[clap(long, short = 't')]
/// Pre-existing tag to use
///
/// If you need to create a new tag for this release, use `--create-tag`
tag: Option<String>,
#[clap(
long,
short,
help = "Include a file as an attachment",
long_help = "Include a file as an attachment
`--attach=<FILE>` will set the attachment's name to the file name
`--attach=<FILE>:<ASSET>` will use the provided name for the attachment"
)]
attach: Vec<String>,
#[clap(long, short)]
/// Text of the release body.
///
/// Using this flag without an argument will open your editor.
body: Option<Option<String>>,
#[clap(long, short = 'B')]
branch: Option<String>,
#[clap(long, short)]
draft: bool,
#[clap(long, short)]
prerelease: bool,
},
/// Edit a release's info
Edit {
name: String,
#[clap(long, short = 'n')]
rename: Option<String>,
#[clap(long, short = 't')]
/// Corresponding tag for this release.
tag: Option<String>,
#[clap(long, short)]
/// Text of the release body.
///
/// Using this flag without an argument will open your editor.
body: Option<Option<String>>,
#[clap(long, short)]
draft: Option<bool>,
#[clap(long, short)]
prerelease: Option<bool>,
},
/// Delete a release
Delete {
name: String,
#[clap(long, short = 't')]
by_tag: bool,
},
/// List all the releases on a repo
List {
#[clap(long, short = 'p')]
include_prerelease: bool,
#[clap(long, short = 'd')]
include_draft: bool,
},
/// View a release's info
View {
name: String,
#[clap(long, short = 't')]
by_tag: bool,
},
/// Open a release in your browser
Browse { name: Option<String> },
/// Commands on a release's attached files
#[clap(subcommand)]
Asset(AssetCommand),
}
#[derive(Subcommand, Clone, Debug)]
pub enum AssetCommand {
/// Create a new attachment on a release
Create {
release: String,
path: std::path::PathBuf,
name: Option<String>,
},
/// Remove an attachment from a release
Delete { release: String, asset: String },
/// Download an attached file
///
/// Use `source.zip` or `source.tar.gz` to download the repo archive
Download {
release: String,
asset: String,
#[clap(long, short)]
output: Option<std::path::PathBuf>,
},
}
impl ReleaseCommand {
pub async fn run(self, keys: &mut KeyInfo, remote_name: Option<&str>) -> eyre::Result<()> {
let repo = RepoInfo::get_current(
remote_name,
self.repo.as_ref(),
self.remote.as_deref(),
&keys,
)?;
let api = keys.get_api(repo.host_url()).await?;
let repo = repo
.name()
.ok_or_eyre("couldn't get repo name, try specifying with --repo")?;
match self.command {
ReleaseSubcommand::Create {
name,
create_tag,
tag,
attach,
body,
branch,
draft,
prerelease,
} => {
create_release(
repo, &api, name, create_tag, tag, attach, body, branch, draft, prerelease,
)
.await?
}
ReleaseSubcommand::Edit {
name,
rename,
tag,
body,
draft,
prerelease,
} => edit_release(repo, &api, name, rename, tag, body, draft, prerelease).await?,
ReleaseSubcommand::Delete { name, by_tag } => {
delete_release(repo, &api, name, by_tag).await?
}
ReleaseSubcommand::List {
include_prerelease,
include_draft,
} => list_releases(repo, &api, include_prerelease, include_draft).await?,
ReleaseSubcommand::View { name, by_tag } => {
view_release(repo, &api, name, by_tag).await?
}
ReleaseSubcommand::Browse { name } => browse_release(repo, &api, name).await?,
ReleaseSubcommand::Asset(subcommand) => match subcommand {
AssetCommand::Create {
release,
path,
name,
} => create_asset(repo, &api, release, path, name).await?,
AssetCommand::Delete { release, asset } => {
delete_asset(repo, &api, release, asset).await?
}
AssetCommand::Download {
release,
asset,
output,
} => download_asset(repo, &api, release, asset, output).await?,
},
}
Ok(())
}
}
async fn create_release(
repo: &RepoName,
api: &Forgejo,
name: String,
create_tag: Option<Option<String>>,
tag: Option<String>,
attachments: Vec<String>,
body: Option<Option<String>>,
branch: Option<String>,
draft: bool,
prerelease: bool,
) -> eyre::Result<()> {
let tag_name = match (tag, create_tag) {
(None, None) => bail!("must select tag with `--tag` or `--create-tag`"),
(Some(tag), None) => tag,
(None, Some(tag)) => {
let tag = tag.unwrap_or_else(|| name.clone());
let opt = forgejo_api::structs::CreateTagOption {
message: None,
tag_name: tag.clone(),
target: branch,
};
api.repo_create_tag(repo.owner(), repo.name(), opt).await?;
tag
}
(Some(_), Some(_)) => {
bail!("`--tag` and `--create-tag` are mutually exclusive; please pick just one")
}
};
let body = match body {
Some(Some(body)) => Some(body),
Some(None) => {
let mut s = String::new();
crate::editor(&mut s, Some("md")).await?;
Some(s)
}
None => None,
};
let release_opt = forgejo_api::structs::CreateReleaseOption {
hide_archive_links: None,
body,
draft: Some(draft),
name: Some(name.clone()),
prerelease: Some(prerelease),
tag_name,
target_commitish: None,
};
let release = api
.repo_create_release(repo.owner(), repo.name(), release_opt)
.await?;
for attachment in attachments {
let (file, asset) = match attachment.split_once(':') {
Some((file, asset)) => (std::path::Path::new(file), asset),
None => {
let file = std::path::Path::new(&attachment);
let asset = file
.file_name()
.ok_or_else(|| eyre!("{attachment} does not have a file name"))?
.to_str()
.unwrap();
(file, asset)
}
};
let query = RepoCreateReleaseAttachmentQuery {
name: Some(asset.into()),
};
let id = release
.id
.ok_or_else(|| eyre::eyre!("release does not have id"))? as u64;
api.repo_create_release_attachment(
repo.owner(),
repo.name(),
id,
Some(tokio::fs::read(file).await?),
None,
query,
)
.await?;
}
println!("Created release {name}");
Ok(())
}
async fn edit_release(
repo: &RepoName,
api: &Forgejo,
name: String,
rename: Option<String>,
tag: Option<String>,
body: Option<Option<String>>,
draft: Option<bool>,
prerelease: Option<bool>,
) -> eyre::Result<()> {
let release = find_release(repo, api, &name).await?;
let body = match body {
Some(Some(body)) => Some(body),
Some(None) => {
let mut s = release
.body
.clone()
.ok_or_else(|| eyre::eyre!("release does not have body"))?;
crate::editor(&mut s, Some("md")).await?;
Some(s)
}
None => None,
};
let release_edit = forgejo_api::structs::EditReleaseOption {
hide_archive_links: None,
name: rename,
tag_name: tag,
body,
draft,
prerelease,
target_commitish: None,
};
let id = release
.id
.ok_or_else(|| eyre::eyre!("release does not have id"))? as u64;
api.repo_edit_release(repo.owner(), repo.name(), id, release_edit)
.await?;
Ok(())
}
async fn list_releases(
repo: &RepoName,
api: &Forgejo,
prerelease: bool,
draft: bool,
) -> eyre::Result<()> {
let query = forgejo_api::structs::RepoListReleasesQuery {
pre_release: Some(prerelease),
draft: Some(draft),
page: None,
limit: None,
};
let releases = api
.repo_list_releases(repo.owner(), repo.name(), query)
.await?;
for release in releases {
let name = release
.name
.as_ref()
.ok_or_else(|| eyre::eyre!("release does not have name"))?;
let draft = release
.draft
.as_ref()
.ok_or_else(|| eyre::eyre!("release does not have draft"))?;
let prerelease = release
.prerelease
.as_ref()
.ok_or_else(|| eyre::eyre!("release does not have prerelease"))?;
print!("{}", name);
match (draft, prerelease) {
(false, false) => (),
(true, false) => print!(" (draft)"),
(false, true) => print!(" (prerelease)"),
(true, true) => print!(" (draft, prerelease)"),
}
println!();
}
Ok(())
}
async fn view_release(
repo: &RepoName,
api: &Forgejo,
name: String,
by_tag: bool,
) -> eyre::Result<()> {
let release = if by_tag {
api.repo_get_release_by_tag(repo.owner(), repo.name(), &name)
.await?
} else {
find_release(repo, api, &name).await?
};
let name = release
.name
.as_ref()
.ok_or_else(|| eyre::eyre!("release does not have name"))?;
let author = release
.author
.as_ref()
.ok_or_else(|| eyre::eyre!("release does not have author"))?;
let login = author
.login
.as_ref()
.ok_or_else(|| eyre::eyre!("autho does not have login"))?;
let created_at = release
.created_at
.ok_or_else(|| eyre::eyre!("release does not have created_at"))?;
println!("{}", name);
print!("By {} on ", login);
created_at.format_into(
&mut std::io::stdout(),
&time::format_description::well_known::Rfc2822,
)?;
println!();
let SpecialRender { bullet, .. } = crate::special_render();
let body = release
.body
.as_ref()
.ok_or_else(|| eyre::eyre!("release does not have body"))?;
if !body.is_empty() {
println!();
println!("{}", crate::markdown(body));
println!();
}
let assets = release
.assets
.as_ref()
.ok_or_else(|| eyre::eyre!("release does not have assets"))?;
if !assets.is_empty() {
println!("{} assets", assets.len() + 2);
for asset in assets {
let name = asset
.name
.as_ref()
.ok_or_else(|| eyre::eyre!("asset does not have name"))?;
println!("{bullet} {}", name);
}
println!("{bullet} source.zip");
println!("{bullet} source.tar.gz");
}
Ok(())
}
async fn browse_release(repo: &RepoName, api: &Forgejo, name: Option<String>) -> eyre::Result<()> {
match name {
Some(name) => {
let release = find_release(repo, api, &name).await?;
let html_url = release
.html_url
.as_ref()
.ok_or_else(|| eyre::eyre!("release does not have html_url"))?;
open::that_detached(html_url.as_str()).wrap_err("Failed to open URL")?;
}
None => {
let repo_data = api.repo_get(repo.owner(), repo.name()).await?;
let mut html_url = repo_data
.html_url
.clone()
.ok_or_else(|| eyre::eyre!("repository does not have html_url"))?;
html_url.path_segments_mut().unwrap().push("releases");
open::that_detached(html_url.as_str()).wrap_err("Failed to open URL")?;
}
}
Ok(())
}
async fn create_asset(
repo: &RepoName,
api: &Forgejo,
release: String,
file: std::path::PathBuf,
asset: Option<String>,
) -> eyre::Result<()> {
let (file, asset) = match asset {
Some(ref asset) => (&*file, &**asset),
None => {
let asset = file
.file_name()
.ok_or_else(|| eyre!("{} does not have a file name", file.display()))?
.to_str()
.unwrap();
(&*file, asset)
}
};
let id = find_release(repo, api, &release)
.await?
.id
.ok_or_else(|| eyre::eyre!("release does not have id"))? as u64;
let query = RepoCreateReleaseAttachmentQuery {
name: Some(asset.to_owned()),
};
api.repo_create_release_attachment(
repo.owner(),
repo.name(),
id,
Some(tokio::fs::read(file).await?),
None,
query,
)
.await?;
println!("Added attachment `{}` to {}", asset, release);
Ok(())
}
async fn delete_asset(
repo: &RepoName,
api: &Forgejo,
release_name: String,
asset_name: String,
) -> eyre::Result<()> {
let release = find_release(repo, api, &release_name).await?;
let assets = release
.assets
.as_ref()
.ok_or_else(|| eyre::eyre!("release does not have assets"))?;
let asset = assets
.iter()
.find(|a| a.name.as_ref() == Some(&asset_name))
.ok_or_else(|| eyre!("asset not found"))?;
let release_id = release
.id
.ok_or_else(|| eyre::eyre!("release does not have id"))? as u64;
let asset_id = asset
.id
.ok_or_else(|| eyre::eyre!("asset does not have id"))? as u64;
api.repo_delete_release_attachment(repo.owner(), repo.name(), release_id, asset_id)
.await?;
println!("Removed attachment `{}` from {}", asset_name, release_name);
Ok(())
}
async fn download_asset(
repo: &RepoName,
api: &Forgejo,
release: String,
asset: String,
output: Option<std::path::PathBuf>,
) -> eyre::Result<()> {
let release = find_release(repo, api, &release).await?;
let file = match &*asset {
"source.zip" => {
let tag_name = release
.tag_name
.as_ref()
.ok_or_else(|| eyre::eyre!("release does not have tag_name"))?;
api.repo_get_archive(repo.owner(), repo.name(), &format!("{}.zip", tag_name))
.await?
}
"source.tar.gz" => {
let tag_name = release
.tag_name
.as_ref()
.ok_or_else(|| eyre::eyre!("release does not have tag_name"))?;
api.repo_get_archive(repo.owner(), repo.name(), &format!("{}.tar.gz", tag_name))
.await?
}
name => {
let assets = release
.assets
.as_ref()
.ok_or_else(|| eyre::eyre!("release does not have assets"))?;
let asset = assets
.iter()
.find(|a| a.name.as_deref() == Some(name))
.ok_or_else(|| eyre!("asset not found"))?;
let release_id = release
.id
.ok_or_else(|| eyre::eyre!("release does not have id"))?
as u64;
let asset_id = asset
.id
.ok_or_else(|| eyre::eyre!("asset does not have id"))?
as u64;
api.download_release_attachment(repo.owner(), repo.name(), release_id, asset_id)
.await?
.to_vec()
}
};
let real_output = output
.as_deref()
.unwrap_or_else(|| std::path::Path::new(&asset));
tokio::fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(real_output)
.await?
.write_all(file.as_ref())
.await?;
if output.is_some() {
println!("Downloaded {asset} into {}", real_output.display());
} else {
println!("Downloaded {asset}");
}
Ok(())
}
async fn find_release(
repo: &RepoName,
api: &Forgejo,
name: &str,
) -> eyre::Result<forgejo_api::structs::Release> {
let query = RepoListReleasesQuery {
draft: None,
pre_release: None,
page: None,
limit: None,
};
let mut releases = api
.repo_list_releases(repo.owner(), repo.name(), query)
.await?;
let idx = releases
.iter()
.position(|r| r.name.as_deref() == Some(name))
.ok_or_else(|| eyre!("release not found"))?;
Ok(releases.swap_remove(idx))
}
async fn delete_release(
repo: &RepoName,
api: &Forgejo,
name: String,
by_tag: bool,
) -> eyre::Result<()> {
if by_tag {
api.repo_delete_release_by_tag(repo.owner(), repo.name(), &name)
.await?;
} else {
let id = find_release(repo, api, &name)
.await?
.id
.ok_or_else(|| eyre::eyre!("release does not have id"))? as u64;
api.repo_delete_release(repo.owner(), repo.name(), id)
.await?;
}
Ok(())
}

1093
src/repo.rs Normal file

File diff suppressed because it is too large Load diff

1019
src/user.rs Normal file

File diff suppressed because it is too large Load diff

74
src/version.rs Normal file
View file

@ -0,0 +1,74 @@
use clap::Args;
#[cfg(feature = "update-check")]
use eyre::OptionExt;
#[derive(Args, Clone, Debug)]
pub struct VersionCommand {
/// Checks for updates
#[clap(long)]
#[cfg(feature = "update-check")]
check: bool,
#[clap(short, long)]
verbose: bool,
}
const BUILD_TYPE: &str = match option_env!("BUILD_TYPE") {
Some(s) => s,
None => "crates.io",
};
impl VersionCommand {
pub async fn run(self) -> eyre::Result<()> {
println!("{} v{}", env!("CARGO_BIN_NAME"), env!("CARGO_PKG_VERSION"));
if self.verbose {
println!("user agent: {}", crate::keys::USER_AGENT);
println!("build type: {BUILD_TYPE}");
println!(" target: {}", env!("BUILD_TARGET"));
}
#[cfg(feature = "update-check")]
self.update_msg().await?;
Ok(())
}
#[cfg(feature = "update-check")]
pub async fn update_msg(self) -> eyre::Result<()> {
use std::cmp::Ordering;
if self.check {
let url = url::Url::parse("https://codeberg.org/")?;
let api = forgejo_api::Forgejo::new(forgejo_api::Auth::None, url)?;
let latest = api
.repo_get_latest_release("Cyborus", "forgejo-cli")
.await?;
let latest_tag = latest
.tag_name
.ok_or_eyre("latest release does not have name")?;
let latest_ver = latest_tag
.strip_prefix("v")
.unwrap_or(&latest_tag)
.parse::<semver::Version>()?;
let current_ver = env!("CARGO_PKG_VERSION").parse::<semver::Version>()?;
match current_ver.cmp(&latest_ver) {
Ordering::Less => {
let latest_url = latest
.html_url
.ok_or_eyre("latest release does not have url")?;
println!("New version available: {latest_ver}");
println!("Get it at {}", latest_url);
}
Ordering::Equal => {
println!("Up to date!");
}
Ordering::Greater => {
println!("You are ahead of the latest published version");
}
}
} else {
println!("Check for a new version with `fj version --check`");
}
Ok(())
}
}

29
src/whoami.rs Normal file
View file

@ -0,0 +1,29 @@
use clap::{self, Args};
use eyre::{Context, OptionExt};
use crate::{repo::RepoInfo, KeyInfo};
#[derive(Args, Clone, Debug)]
pub struct WhoAmICommand {
#[clap(long, short)]
remote: Option<String>,
}
impl WhoAmICommand {
pub async fn run(self, keys: &mut KeyInfo, host_name: Option<&str>) -> eyre::Result<()> {
let url = RepoInfo::get_current(host_name, None, self.remote.as_deref(), &keys)
.wrap_err("could not find host, try specifying with --host")?
.host_url()
.clone();
let name = keys.get_login(&url).ok_or_eyre("not logged in")?.username();
let host = url
.host_str()
.ok_or_eyre("instance url does not have host")?;
if url.path() == "/" || url.path().is_empty() {
println!("currently signed in to {name}@{host}");
} else {
println!("currently signed in to {name}@{host}{}", url.path());
};
Ok(())
}
}

158
src/wiki.rs Normal file
View file

@ -0,0 +1,158 @@
use std::path::PathBuf;
use base64ct::Encoding;
use clap::{Args, Subcommand};
use eyre::{Context, OptionExt};
use forgejo_api::Forgejo;
use crate::{
repo::{RepoArg, RepoInfo, RepoName},
SpecialRender,
};
#[derive(Args, Clone, Debug)]
pub struct WikiCommand {
/// The local git remote that points to the repo to operate on.
#[clap(long, short = 'R')]
remote: Option<String>,
#[clap(subcommand)]
command: WikiSubcommand,
}
#[derive(Subcommand, Clone, Debug)]
pub enum WikiSubcommand {
Contents {
repo: Option<RepoArg>,
},
View {
#[clap(long, short)]
repo: Option<RepoArg>,
page: String,
},
Clone {
repo: Option<RepoArg>,
#[clap(long, short)]
path: Option<PathBuf>,
},
Browse {
#[clap(long, short)]
repo: Option<RepoArg>,
page: String,
},
}
impl WikiCommand {
pub async fn run(self, keys: &mut crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> {
use WikiSubcommand::*;
let repo = RepoInfo::get_current(host_name, self.repo(), self.remote.as_deref(), &keys)?;
let api = keys.get_api(repo.host_url()).await?;
let repo = repo
.name()
.ok_or_else(|| eyre::eyre!("couldn't guess repo"))?;
match self.command {
Contents { repo: _ } => wiki_contents(repo, &api).await?,
View { repo: _, page } => view_wiki_page(repo, &api, &page).await?,
Clone { repo: _, path } => clone_wiki(repo, &api, path).await?,
Browse { repo: _, page } => browse_wiki_page(repo, &api, &page).await?,
}
Ok(())
}
fn repo(&self) -> Option<&RepoArg> {
use WikiSubcommand::*;
match &self.command {
Contents { repo } | View { repo, .. } | Clone { repo, .. } | Browse { repo, .. } => {
repo.as_ref()
}
}
}
}
async fn wiki_contents(repo: &RepoName, api: &Forgejo) -> eyre::Result<()> {
let SpecialRender { bullet, .. } = *crate::special_render();
let query = forgejo_api::structs::RepoGetWikiPagesQuery {
page: None,
limit: None,
};
let pages = api
.repo_get_wiki_pages(repo.owner(), repo.name(), query)
.await?;
for page in pages {
let title = page
.title
.as_deref()
.ok_or_eyre("page does not have title")?;
println!("{bullet} {title}");
}
Ok(())
}
async fn view_wiki_page(repo: &RepoName, api: &Forgejo, page: &str) -> eyre::Result<()> {
let SpecialRender { bold, reset, .. } = *crate::special_render();
let page = api
.repo_get_wiki_page(repo.owner(), repo.name(), page)
.await?;
let title = page
.title
.as_deref()
.ok_or_eyre("page does not have title")?;
println!("{bold}{title}{reset}");
println!();
let contents_b64 = page
.content_base64
.as_deref()
.ok_or_eyre("page does not have content")?;
let contents = String::from_utf8(base64ct::Base64::decode_vec(contents_b64)?)
.wrap_err("page content is not utf-8")?;
println!("{}", crate::markdown(&contents));
Ok(())
}
async fn browse_wiki_page(repo: &RepoName, api: &Forgejo, page: &str) -> eyre::Result<()> {
let page = api
.repo_get_wiki_page(repo.owner(), repo.name(), page)
.await?;
let html_url = page
.html_url
.as_ref()
.ok_or_eyre("page does not have html url")?;
open::that_detached(html_url.as_str()).wrap_err("Failed to open URL")?;
Ok(())
}
async fn clone_wiki(repo: &RepoName, api: &Forgejo, path: Option<PathBuf>) -> eyre::Result<()> {
let repo_data = api.repo_get(repo.owner(), repo.name()).await?;
let clone_url = repo_data
.clone_url
.as_ref()
.ok_or_eyre("repo does not have clone url")?;
let git_stripped = clone_url
.as_str()
.strip_suffix(".git")
.unwrap_or(clone_url.as_str());
let clone_url = url::Url::parse(&format!("{}.wiki.git", git_stripped))?;
let repo_name = repo_data
.name
.as_deref()
.ok_or_eyre("repo does not have name")?;
let repo_full_name = repo_data
.full_name
.as_deref()
.ok_or_eyre("repo does not have full name")?;
let name = format!("{}'s wiki", repo_full_name);
let path = path.unwrap_or_else(|| PathBuf::from(format!("./{repo_name}-wiki")));
crate::repo::clone_repo(&name, &clone_url, &path)?;
Ok(())
}