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