Adding upstream version 0.2.0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
01ae482a94
commit
80bb1315ee
25 changed files with 9820 additions and 0 deletions
6
.cargo_vcs_info.json
Normal file
6
.cargo_vcs_info.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"git": {
|
||||
"sha1": "d1450c0b816c7c6f8dc05a3d99204d445519a752"
|
||||
},
|
||||
"path_in_vcs": ""
|
||||
}
|
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
/target
|
||||
|
||||
# Nix output
|
||||
/result*
|
13
.woodpecker/check.yml
Normal file
13
.woodpecker/check.yml
Normal 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
52
.woodpecker/deploy.yml
Normal 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
2443
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
132
Cargo.toml
Normal file
132
Cargo.toml
Normal 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
48
Cargo.toml.orig
generated
Normal 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
4
Dockerfile
Normal 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
201
LICENSE-APACHE
Normal 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
21
LICENSE-MIT
Normal 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
105
README.md
Normal 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
6
build.rs
Normal file
|
@ -0,0 +1,6 @@
|
|||
fn main() {
|
||||
println!(
|
||||
"cargo:rustc-env=BUILD_TARGET={}",
|
||||
std::env::var("TARGET").unwrap()
|
||||
);
|
||||
}
|
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal 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
48
flake.nix
Normal 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
269
src/auth.rs
Normal 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
700
src/issues.rs
Normal 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
148
src/keys.rs
Normal 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
761
src/main.rs
Normal 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
1804
src/prs.rs
Normal file
File diff suppressed because it is too large
Load diff
621
src/release.rs
Normal file
621
src/release.rs
Normal 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
1093
src/repo.rs
Normal file
File diff suppressed because it is too large
Load diff
1019
src/user.rs
Normal file
1019
src/user.rs
Normal file
File diff suppressed because it is too large
Load diff
74
src/version.rs
Normal file
74
src/version.rs
Normal 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
29
src/whoami.rs
Normal 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
158
src/wiki.rs
Normal 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(())
|
||||
}
|
Loading…
Add table
Reference in a new issue