summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-11-04 11:30:10 +0100
committerDaniel Baumann <daniel@debian.org>2024-11-20 07:38:21 +0100
commit34f503aa3bfba930fd7978a0071786884d73749f (patch)
tree7e3c8e2506fdd93e29958d9f8cb36fbed4a5af7d
parentInitial commit. (diff)
downloadforgejo-cli-upstream.tar.xz
forgejo-cli-upstream.zip
Adding upstream version 0.1.1.upstream/0.1.1upstream
Signed-off-by: Daniel Baumann <daniel@debian.org>
-rw-r--r--.cargo_vcs_info.json6
-rw-r--r--.gitignore1
-rw-r--r--.woodpecker/check.yml13
-rw-r--r--.woodpecker/deploy.yml48
-rw-r--r--Cargo.lock2401
-rw-r--r--Cargo.toml125
-rw-r--r--Cargo.toml.orig44
-rw-r--r--Dockerfile4
-rw-r--r--LICENSE-APACHE201
-rw-r--r--LICENSE-MIT21
-rw-r--r--README.md86
-rw-r--r--src/auth.rs236
-rw-r--r--src/issues.rs674
-rw-r--r--src/keys.rs129
-rw-r--r--src/main.rs800
-rw-r--r--src/prs.rs1564
-rw-r--r--src/release.rs614
-rw-r--r--src/repo.rs766
-rw-r--r--src/user.rs1019
-rw-r--r--src/wiki.rs158
20 files changed, 8910 insertions, 0 deletions
diff --git a/.cargo_vcs_info.json b/.cargo_vcs_info.json
new file mode 100644
index 0000000..ab776dd
--- /dev/null
+++ b/.cargo_vcs_info.json
@@ -0,0 +1,6 @@
+{
+ "git": {
+ "sha1": "b3c242d8c5e57959f28fa084619027bc1715bdc0"
+ },
+ "path_in_vcs": ""
+} \ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/.woodpecker/check.yml b/.woodpecker/check.yml
new file mode 100644
index 0000000..20508ea
--- /dev/null
+++ b/.woodpecker/check.yml
@@ -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
diff --git a/.woodpecker/deploy.yml b/.woodpecker/deploy.yml
new file mode 100644
index 0000000..18f88e4
--- /dev/null
+++ b/.woodpecker/deploy.yml
@@ -0,0 +1,48 @@
+when:
+ - event: tag
+steps:
+ compile-linux:
+ image: rust:latest
+ 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
+ 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 ]
+
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..dcfad8d
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,2401 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "addr2line"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678"
+dependencies = [
+ "gimli",
+]
+
+[[package]]
+name = "adler"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anstream"
+version = "0.6.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad186efb764318d35165f1758e7dcef3b10628e26d41a44bc5550652e6804391"
+dependencies = [
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19"
+dependencies = [
+ "anstyle",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "async-trait"
+version = "0.1.81"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "auth-git2"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e51bd0e4592409df8631ca807716dc1e5caafae5d01ce0157c966c71c7e49c3c"
+dependencies = [
+ "dirs",
+ "git2",
+ "terminal-prompt",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0"
+
+[[package]]
+name = "backtrace"
+version = "0.3.73"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a"
+dependencies = [
+ "addr2line",
+ "cc",
+ "cfg-if",
+ "libc",
+ "miniz_oxide",
+ "object",
+ "rustc-demangle",
+]
+
+[[package]]
+name = "base64"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "base64ct"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
+
+[[package]]
+name = "bincode"
+version = "1.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "bit-set"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
+
+[[package]]
+name = "bytes"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a12916984aab3fa6e39d655a33e09c0071eb36d6ab3aea5c2d78551f1df6d952"
+
+[[package]]
+name = "caseless"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "808dab3318747be122cb31d36de18d4d1c81277a76f8332a02b81a3d73463d7f"
+dependencies = [
+ "regex",
+ "unicode-normalization",
+]
+
+[[package]]
+name = "cc"
+version = "1.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2aba8f4e9906c7ce3c73463f62a7f0c65183ada1a2d47e397cc8810827f9694f"
+dependencies = [
+ "jobserver",
+ "libc",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "clap"
+version = "4.5.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35723e6a11662c2afb578bcf0b88bf6ea8e21282a953428f240574fcc3a2b5b3"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.5.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49eb96cbfa7cfa35017b7cd548c75b14c3118c98b423041d70562665e07fb0fa"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+ "terminal_size",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.5.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d029b67f89d30bbb547c89fd5161293c0aec155fc691d7924b64550662db93e"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b82cf0babdbd58558212896d1a4272303a57bdb245c2bf1147185fb45640e70"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422"
+
+[[package]]
+name = "comrak"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "395ab67843c57df5a4ee29d610740828dbc928cc64ecf0f2a1d5cd0e98e107a9"
+dependencies = [
+ "caseless",
+ "clap",
+ "derive_builder",
+ "entities",
+ "memchr",
+ "once_cell",
+ "regex",
+ "shell-words",
+ "slug",
+ "syntect",
+ "typed-arena",
+ "unicode_categories",
+ "xdg",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossterm"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df"
+dependencies = [
+ "bitflags 2.6.0",
+ "crossterm_winapi",
+ "libc",
+ "mio 0.8.11",
+ "parking_lot",
+ "signal-hook",
+ "signal-hook-mio",
+ "winapi",
+]
+
+[[package]]
+name = "crossterm_winapi"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "darling"
+version = "0.20.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.20.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.20.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "deranged"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
+dependencies = [
+ "powerfmt",
+ "serde",
+]
+
+[[package]]
+name = "derive_builder"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0350b5cb0331628a5916d6c5c0b72e97393b8b6b03b47a9284f4e7f5a405ffd7"
+dependencies = [
+ "derive_builder_macro",
+]
+
+[[package]]
+name = "derive_builder_core"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d48cda787f839151732d396ac69e3473923d54312c070ee21e9effcaa8ca0b1d"
+dependencies = [
+ "darling",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "derive_builder_macro"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "206868b8242f27cecce124c19fd88157fbd0dd334df2587f36417bafbc85097b"
+dependencies = [
+ "derive_builder_core",
+ "syn",
+]
+
+[[package]]
+name = "deunicode"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "339544cc9e2c4dc3fc7149fd630c5f22263a4fdf18a98afd0075784968b5cf00"
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "directories"
+version = "5.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs"
+version = "5.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "entities"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca"
+
+[[package]]
+name = "equivalent"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
+
+[[package]]
+name = "errno"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "eyre"
+version = "0.6.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec"
+dependencies = [
+ "indenter",
+ "once_cell",
+]
+
+[[package]]
+name = "fancy-regex"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
+dependencies = [
+ "bit-set",
+ "regex",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a"
+
+[[package]]
+name = "flate2"
+version = "1.0.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foreign-types"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
+dependencies = [
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
+[[package]]
+name = "forgejo-api"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f682f4a0bd862be530f229a04ebca8b7a3842066b00cc7c0a915c5f1d2a8812d"
+dependencies = [
+ "base64ct",
+ "bytes",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "soft_assert",
+ "thiserror",
+ "time",
+ "tokio",
+ "url",
+ "zeroize",
+]
+
+[[package]]
+name = "forgejo-cli"
+version = "0.1.1"
+dependencies = [
+ "auth-git2",
+ "base64ct",
+ "clap",
+ "comrak",
+ "crossterm",
+ "directories",
+ "eyre",
+ "forgejo-api",
+ "futures",
+ "git2",
+ "hyper 1.4.1",
+ "hyper-util",
+ "open",
+ "rand",
+ "semver",
+ "serde",
+ "serde_json",
+ "sha256",
+ "soft_assert",
+ "time",
+ "tokio",
+ "url",
+ "uuid",
+]
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5"
+
+[[package]]
+name = "futures-task"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004"
+
+[[package]]
+name = "futures-util"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "pin-utils",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "gimli"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd"
+
+[[package]]
+name = "git2"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b903b73e45dc0c6c596f2d37eccece7c1c8bb6e4407b001096387c63d0d93724"
+dependencies = [
+ "bitflags 2.6.0",
+ "libc",
+ "libgit2-sys",
+ "log",
+ "openssl-probe",
+ "openssl-sys",
+ "url",
+]
+
+[[package]]
+name = "h2"
+version = "0.3.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8"
+dependencies = [
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "futures-util",
+ "http 0.2.12",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "h2"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa82e28a107a8cc405f0839610bdc9b15f1e25ec7d696aa5cf173edbcb1486ab"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http 1.1.0",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hermit-abi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "http"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
+[[package]]
+name = "http"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
+dependencies = [
+ "bytes",
+ "http 0.2.12",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http 1.1.0",
+]
+
+[[package]]
+name = "httparse"
+version = "1.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "hyper"
+version = "0.14.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a152ddd61dfaec7273fe8419ab357f33aee0d914c5f4efbf0d96fa749eea5ec9"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "h2 0.3.26",
+ "http 0.2.12",
+ "http-body 0.4.6",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+ "want",
+]
+
+[[package]]
+name = "hyper"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50dfd22e0e76d0f662d429a5f80fcaf3855009297eab6a0a9f8543834744ba05"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "h2 0.4.5",
+ "http 1.1.0",
+ "http-body 1.0.1",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+]
+
+[[package]]
+name = "hyper-tls"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
+dependencies = [
+ "bytes",
+ "hyper 0.14.30",
+ "native-tls",
+ "tokio",
+ "tokio-native-tls",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ab92f4f49ee4fb4f997c784b7a2e0fa70050211e0b6a287f898c3c9785ca956"
+dependencies = [
+ "bytes",
+ "futures-util",
+ "http 1.1.0",
+ "http-body 1.0.1",
+ "hyper 1.4.1",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "idna"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+]
+
+[[package]]
+name = "indenter"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683"
+
+[[package]]
+name = "indexmap"
+version = "2.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3"
+
+[[package]]
+name = "is-docker"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "is-wsl"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
+dependencies = [
+ "is-docker",
+ "once_cell",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800"
+
+[[package]]
+name = "itoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
+
+[[package]]
+name = "jobserver"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2b099aaa34a9751c5bf0878add70444e1ed2dd73f347be99003d4577277de6e"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d"
+dependencies = [
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.155"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
+
+[[package]]
+name = "libgit2-sys"
+version = "0.17.0+1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10472326a8a6477c3c20a64547b0059e4b0d086869eee31e6d7da728a8eb7224"
+dependencies = [
+ "cc",
+ "libc",
+ "libssh2-sys",
+ "libz-sys",
+ "openssl-sys",
+ "pkg-config",
+]
+
+[[package]]
+name = "libredox"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
+dependencies = [
+ "bitflags 2.6.0",
+ "libc",
+]
+
+[[package]]
+name = "libssh2-sys"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2dc8a030b787e2119a731f1951d6a773e2280c660f8ec4b0f5e1505a386e71ee"
+dependencies = [
+ "cc",
+ "libc",
+ "libz-sys",
+ "openssl-sys",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "libz-sys"
+version = "1.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c15da26e5af7e25c90b37a2d75cdbf940cf4a55316de9d84c679c9b8bfabf82e"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "linked-hash-map"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
+
+[[package]]
+name = "lock_api"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17"
+dependencies = [
+ "autocfg",
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
+
+[[package]]
+name = "memchr"
+version = "2.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08"
+dependencies = [
+ "adler",
+]
+
+[[package]]
+name = "mio"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "mio"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4569e456d394deccd22ce1c1913e6ea0e54519f577285001215d33557431afe4"
+dependencies = [
+ "hermit-abi",
+ "libc",
+ "wasi",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "native-tls"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466"
+dependencies = [
+ "libc",
+ "log",
+ "openssl",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "security-framework",
+ "security-framework-sys",
+ "tempfile",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
+
+[[package]]
+name = "num_threads"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "object"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "081b846d1d56ddfc18fdf1a922e4f6e07a11768ea1b92dec44e42b72712ccfce"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
+
+[[package]]
+name = "onig"
+version = "6.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c4b31c8722ad9171c6d77d3557db078cab2bd50afcc9d09c8b315c59df8ca4f"
+dependencies = [
+ "bitflags 1.3.2",
+ "libc",
+ "once_cell",
+ "onig_sys",
+]
+
+[[package]]
+name = "onig_sys"
+version = "69.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b829e3d7e9cc74c7e315ee8edb185bf4190da5acde74afd7fc59c35b1f086e7"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
+
+[[package]]
+name = "open"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61a877bf6abd716642a53ef1b89fb498923a4afca5c754f9050b4d081c05c4b3"
+dependencies = [
+ "is-wsl",
+ "libc",
+ "pathdiff",
+]
+
+[[package]]
+name = "openssl"
+version = "0.10.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2823eb4c6453ed64055057ea8bd416eda38c71018723869dd043a3b1186115e"
+dependencies = [
+ "bitflags 2.6.0",
+ "cfg-if",
+ "foreign-types",
+ "libc",
+ "once_cell",
+ "openssl-macros",
+ "openssl-sys",
+]
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "openssl-probe"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "pathdiff"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"
+
+[[package]]
+name = "plist"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42cf17e9a1800f5f396bc67d193dc9411b59012a5876445ef450d449881e1016"
+dependencies = [
+ "base64 0.22.1",
+ "indexmap",
+ "quick-xml",
+ "serde",
+ "time",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.86"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.32.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rand"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4"
+dependencies = [
+ "bitflags 2.6.0",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891"
+dependencies = [
+ "getrandom",
+ "libredox",
+ "thiserror",
+]
+
+[[package]]
+name = "regex"
+version = "1.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b"
+
+[[package]]
+name = "reqwest"
+version = "0.11.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62"
+dependencies = [
+ "base64 0.21.7",
+ "bytes",
+ "encoding_rs",
+ "futures-core",
+ "futures-util",
+ "h2 0.3.26",
+ "http 0.2.12",
+ "http-body 0.4.6",
+ "hyper 0.14.30",
+ "hyper-tls",
+ "ipnet",
+ "js-sys",
+ "log",
+ "mime",
+ "mime_guess",
+ "native-tls",
+ "once_cell",
+ "percent-encoding",
+ "pin-project-lite",
+ "rustls-pemfile",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "system-configuration",
+ "tokio",
+ "tokio-native-tls",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "winreg",
+]
+
+[[package]]
+name = "rustc-demangle"
+version = "0.1.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
+
+[[package]]
+name = "rustix"
+version = "0.38.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f"
+dependencies = [
+ "bitflags 2.6.0",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rustls-pemfile"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c"
+dependencies = [
+ "base64 0.21.7",
+]
+
+[[package]]
+name = "ryu"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "schannel"
+version = "0.1.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534"
+dependencies = [
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "security-framework"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
+dependencies = [
+ "bitflags 2.6.0",
+ "core-foundation",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75da29fe9b9b08fe9d6b22b5b4bcbc75d8db3aa31e639aa56bb62e9d46bfceaf"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b"
+
+[[package]]
+name = "serde"
+version = "1.0.204"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.204"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.120"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e0d21c9a8cae1235ad58a00c11cb40d4b1e5c784f1ef2c537876ed6ffd8b7c5"
+dependencies = [
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sha256"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18278f6a914fa3070aa316493f7d2ddfb9ac86ebc06fa3b83bffda487e9065b0"
+dependencies = [
+ "async-trait",
+ "bytes",
+ "hex",
+ "sha2",
+ "tokio",
+]
+
+[[package]]
+name = "shell-words"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"
+
+[[package]]
+name = "signal-hook"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801"
+dependencies = [
+ "libc",
+ "signal-hook-registry",
+]
+
+[[package]]
+name = "signal-hook-mio"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af"
+dependencies = [
+ "libc",
+ "mio 0.8.11",
+ "signal-hook",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "slug"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3bd94acec9c8da640005f8e135a39fc0372e74535e6b368b7a04b875f784c8c4"
+dependencies = [
+ "deunicode",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
+
+[[package]]
+name = "socket2"
+version = "0.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "soft_assert"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5097ec7ea7218135541ad96348f1441d0c616537dd4ed9c47205920c35d7d97"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "syn"
+version = "2.0.71"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b146dcf730474b4bcd16c311627b31ede9ab149045db4d6088b3becaea046462"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
+
+[[package]]
+name = "syntect"
+version = "5.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "874dcfa363995604333cf947ae9f751ca3af4522c60886774c4963943b4746b1"
+dependencies = [
+ "bincode",
+ "bitflags 1.3.2",
+ "fancy-regex",
+ "flate2",
+ "fnv",
+ "once_cell",
+ "onig",
+ "plist",
+ "regex-syntax",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "thiserror",
+ "walkdir",
+ "yaml-rust",
+]
+
+[[package]]
+name = "system-configuration"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation",
+ "system-configuration-sys",
+]
+
+[[package]]
+name = "system-configuration-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1"
+dependencies = [
+ "cfg-if",
+ "fastrand",
+ "rustix",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "terminal-prompt"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "572818b3472910acbd5dff46a3413715c18e934b071ab2ba464a7b2c2af16376"
+dependencies = [
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "terminal_size"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21bebf2b7c9e0a515f6e0f8c51dc0f8e4696391e6f1ff30379559f8365fb0df7"
+dependencies = [
+ "rustix",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.63"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.63"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "time"
+version = "0.3.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885"
+dependencies = [
+ "deranged",
+ "itoa",
+ "libc",
+ "num-conv",
+ "num_threads",
+ "powerfmt",
+ "serde",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
+
+[[package]]
+name = "time-macros"
+version = "0.2.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.39.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d040ac2b29ab03b09d4129c2f5bbd012a3ac2f79d38ff506a4bf8dd34b0eac8a"
+dependencies = [
+ "backtrace",
+ "bytes",
+ "libc",
+ "mio 1.0.1",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio-native-tls"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
+dependencies = [
+ "native-tls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tower-service"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52"
+
+[[package]]
+name = "tracing"
+version = "0.1.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
+dependencies = [
+ "pin-project-lite",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "typed-arena"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
+
+[[package]]
+name = "typenum"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
+
+[[package]]
+name = "unicase"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89"
+dependencies = [
+ "version_check",
+]
+
+[[package]]
+name = "unicode-bidi"
+version = "0.3.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "unicode_categories"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
+
+[[package]]
+name = "url"
+version = "2.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "uuid"
+version = "1.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "version_check"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen-macro",
+]
+
+[[package]]
+name = "wasm-bindgen-backend"
+version = "0.2.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da"
+dependencies = [
+ "bumpalo",
+ "log",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-backend",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96"
+
+[[package]]
+name = "web-sys"
+version = "0.3.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b"
+dependencies = [
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "winreg"
+version = "0.50.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
+dependencies = [
+ "cfg-if",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "xdg"
+version = "2.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "213b7324336b53d2414b2db8537e56544d981803139155afa84f76eeebb7a546"
+
+[[package]]
+name = "yaml-rust"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
+dependencies = [
+ "linked-hash-map",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..2095468
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,125 @@
+# 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.1.1"
+build = 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.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.4.1"
+
+[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"]
+
+[features]
+update-check = ["dep:semver"]
diff --git a/Cargo.toml.orig b/Cargo.toml.orig
new file mode 100644
index 0000000..c0815e5
--- /dev/null
+++ b/Cargo.toml.orig
@@ -0,0 +1,44 @@
+[package]
+name = "forgejo-cli"
+version = "0.1.1"
+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"] }
+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.4.1"
+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"] }
+
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..5a1e3d2
--- /dev/null
+++ b/Dockerfile
@@ -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
diff --git a/LICENSE-APACHE b/LICENSE-APACHE
new file mode 100644
index 0000000..261eeb9
--- /dev/null
+++ b/LICENSE-APACHE
@@ -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.
diff --git a/LICENSE-MIT b/LICENSE-MIT
new file mode 100644
index 0000000..8aa2645
--- /dev/null
+++ b/LICENSE-MIT
@@ -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.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..82cbb25
--- /dev/null
+++ b/README.md
@@ -0,0 +1,86 @@
+# 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
+```
+
+### 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.
+
diff --git a/src/auth.rs b/src/auth.rs
new file mode 100644
index 0000000..072dc30
--- /dev/null
+++ b/src/auth.rs
@@ -0,0 +1,236 @@
+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 domain name of the forgejo instance.
+ host: String,
+ /// 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)?;
+ 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();
+ let mut applications_url = host_url.clone();
+ applications_url
+ .path_segments_mut()
+ .map_err(|_| eyre::eyre!("invalid url"))?
+ .extend(["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 { host, user, key } => {
+ let key = match key {
+ Some(key) => key,
+ None => crate::readline("new key: ").await?.trim().to_string(),
+ };
+ if keys.hosts.get(&user).is_none() {
+ keys.hosts.insert(
+ host,
+ crate::keys::LoginInfo::Application {
+ name: user,
+ token: key,
+ },
+ );
+ } else {
+ println!("key for {} already exists", host);
+ }
+ }
+ 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 (url.host_str()?, 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 login_info = crate::keys::LoginInfo::OAuth {
+ name,
+ token: response.access_token,
+ refresh_token: response.refresh_token,
+ expires_at,
+ };
+ keys.hosts
+ .insert(host.host_str().unwrap().to_string(), 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)
+}
diff --git a/src/issues.rs b/src/issues.rs
new file mode 100644
index 0000000..a65e3d4
--- /dev/null
+++ b/src/issues.rs
@@ -0,0 +1,674 @@
+use std::str::FromStr;
+
+use clap::{Args, Subcommand};
+use eyre::{eyre, 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: String,
+ #[clap(long)]
+ body: Option<String>,
+ #[clap(long, short, id = "[HOST/]OWNER/REPO")]
+ repo: Option<RepoArg>,
+ },
+ /// 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())?;
+ 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,
+ } => create_issue(&repo, &api, title, body).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: String,
+ 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
+ }
+ };
+ 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);
+ 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(html_url.as_str())?;
+ 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(())
+}
diff --git a/src/keys.rs b/src/keys.rs
new file mode 100644
index 0000000..33ac02e
--- /dev/null
+++ b/src/keys.rs
@@ -0,0 +1,129 @@
+use eyre::eyre;
+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>,
+}
+
+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) -> eyre::Result<&mut LoginInfo> {
+ let host_str = url
+ .host_str()
+ .ok_or_else(|| eyre!("remote url does not have host"))?;
+ let domain = if let Some(port) = url.port() {
+ format!("{}:{}", host_str, port)
+ } else {
+ host_str.to_owned()
+ };
+
+ let login_info = self
+ .hosts
+ .get_mut(&domain)
+ .ok_or_else(|| eyre!("not signed in to {domain}"))?;
+ Ok(login_info)
+ }
+
+ pub async fn get_api(&mut self, url: &Url) -> eyre::Result<forgejo_api::Forgejo> {
+ self.get_login(url)?.api_for(url).await.map_err(Into::into)
+ }
+}
+
+#[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_api::Forgejo> {
+ match self {
+ LoginInfo::Application { token, .. } => {
+ let api = forgejo_api::Forgejo::new(forgejo_api::Auth::Token(token), url.clone())?;
+ Ok(api)
+ }
+ LoginInfo::OAuth {
+ token,
+ refresh_token,
+ expires_at,
+ ..
+ } => {
+ if time::OffsetDateTime::now_utc() >= *expires_at {
+ let api = forgejo_api::Forgejo::new(forgejo_api::Auth::None, url.clone())?;
+ 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_api::Forgejo::new(forgejo_api::Auth::Token(token), url.clone())?;
+ Ok(api)
+ }
+ }
+ }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..c26081f
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,800 @@
+use std::io::IsTerminal;
+
+use clap::{Parser, Subcommand};
+use eyre::{eyre, Context, OptionExt};
+use tokio::io::AsyncWriteExt;
+
+mod keys;
+use keys::*;
+
+mod auth;
+mod issues;
+mod prs;
+mod release;
+mod repo;
+mod user;
+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 {
+ #[clap(long, short)]
+ remote: Option<String>,
+ },
+ #[clap(subcommand)]
+ Auth(auth::AuthCommand),
+ Release(release::ReleaseCommand),
+ User(user::UserCommand),
+ Version {
+ /// Checks for updates
+ #[clap(long)]
+ #[cfg(feature = "update-check")]
+ check: bool,
+ },
+}
+
+#[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 { remote } => {
+ let url = repo::RepoInfo::get_current(host_name, None, remote.as_deref())
+ .wrap_err("could not find host, try specifying with --host")?
+ .host_url()
+ .clone();
+ let name = keys.get_login(&url)?.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());
+ }
+ }
+ 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 {
+ #[cfg(feature = "update-check")]
+ check,
+ } => {
+ println!("{}", env!("CARGO_PKG_VERSION"));
+ #[cfg(feature = "update-check")]
+ update_msg(check).await?;
+ }
+ }
+
+ keys.save().await?;
+ Ok(())
+}
+
+#[cfg(feature = "update-check")]
+async fn update_msg(check: bool) -> eyre::Result<()> {
+ use std::cmp::Ordering;
+
+ if 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(())
+}
+
+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);
+
+ // Closure 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))
+}
+
+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 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 (terminal_width, _) = crossterm::terminal::size().unwrap_or((80, 24));
+ let max_line_len = (terminal_width as usize - 2).min(80);
+
+ 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(())
+ }
+}
diff --git a/src/prs.rs b/src/prs.rs
new file mode 100644
index 0000000..840ae12
--- /dev/null
+++ b/src/prs.rs
@@ -0,0 +1,1564 @@
+use std::str::FromStr;
+
+use clap::{Args, Subcommand};
+use eyre::{Context, OptionExt};
+use forgejo_api::{
+ structs::{
+ CreatePullRequestOption, MergePullRequestOption, RepoGetPullRequestCommitsQuery,
+ RepoGetPullRequestFilesQuery, StateType,
+ },
+ Forgejo,
+};
+
+use crate::{
+ issues::IssueId,
+ repo::{RepoArg, RepoInfo, RepoName},
+ SpecialRender,
+};
+
+#[derive(Args, Clone, Debug)]
+pub struct PrCommand {
+ /// The local git remote that points to the repo to operate on.
+ #[clap(long, short = 'R')]
+ remote: Option<String>,
+ #[clap(subcommand)]
+ command: PrSubcommand,
+}
+
+#[derive(Subcommand, Clone, Debug)]
+pub enum PrSubcommand {
+ /// Search a repository's pull requests
+ Search {
+ 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<crate::issues::State>,
+ /// The repo to search in
+ #[clap(long, short)]
+ repo: Option<RepoArg>,
+ },
+ /// Create a new pull request
+ Create {
+ /// The branch to merge onto.
+ #[clap(long)]
+ base: Option<String>,
+ /// The branch to pull changes from.
+ #[clap(long)]
+ head: Option<String>,
+ /// What to name the new pull request.
+ ///
+ /// Prefix with "WIP: " to mark this PR as a draft.
+ title: String,
+ /// The text body of the pull request.
+ ///
+ /// Leaving this out will open your editor.
+ #[clap(long)]
+ body: Option<String>,
+ /// The repo to create this issue on
+ #[clap(long, short, id = "[HOST/]OWNER/REPO")]
+ repo: Option<RepoArg>,
+ },
+ /// View the contents of a pull request
+ View {
+ /// The pull request to view.
+ #[clap(id = "[REPO#]ID")]
+ id: Option<IssueId>,
+ #[clap(subcommand)]
+ command: Option<ViewCommand>,
+ },
+ /// View the mergability and CI status of a pull request
+ Status {
+ /// The pull request to view.
+ #[clap(id = "[REPO#]ID")]
+ id: Option<IssueId>,
+ },
+ /// Checkout a pull request in a new branch
+ Checkout {
+ /// The pull request to check out.
+ ///
+ /// Prefix with ^ to get a pull request from the parent repo.
+ #[clap(id = "ID")]
+ pr: PrNumber,
+ /// The name to give the newly created branch.
+ ///
+ /// Defaults to naming after the host url, repo owner, and PR number.
+ #[clap(long, id = "NAME")]
+ branch_name: Option<String>,
+ },
+ /// Add a comment on a pull request
+ Comment {
+ /// The pull request to comment on.
+ #[clap(id = "[REPO#]ID")]
+ pr: Option<IssueId>,
+ /// The text content of the comment.
+ ///
+ /// Not including this in the command will open your editor.
+ body: Option<String>,
+ },
+ /// Edit the contents of a pull request
+ Edit {
+ /// The pull request to edit.
+ #[clap(id = "[REPO#]ID")]
+ pr: Option<IssueId>,
+ #[clap(subcommand)]
+ command: EditCommand,
+ },
+ /// Close a pull request, without merging.
+ Close {
+ /// The pull request to close.
+ #[clap(id = "[REPO#]ID")]
+ pr: Option<IssueId>,
+ /// A comment to add before closing.
+ ///
+ /// Adding without an argument will open your editor
+ #[clap(long, short)]
+ with_msg: Option<Option<String>>,
+ },
+ /// Merge a pull request
+ Merge {
+ /// The pull request to merge.
+ #[clap(id = "[REPO#]ID")]
+ pr: Option<IssueId>,
+ /// The merge style to use.
+ #[clap(long, short = 'M')]
+ method: Option<MergeMethod>,
+ /// Option to delete the corresponding branch afterwards.
+ #[clap(long, short)]
+ delete: bool,
+ /// The title of the merge or squash commit to be created
+ #[clap(long, short)]
+ title: Option<String>,
+ /// The body of the merge or squash commit to be created
+ #[clap(long, short)]
+ message: Option<Option<String>>,
+ },
+ /// Open a pull request in your browser
+ Browse {
+ /// The pull request to open in your browser.
+ #[clap(id = "[REPO#]ID")]
+ id: Option<IssueId>,
+ },
+}
+
+#[derive(clap::ValueEnum, Clone, Copy, Debug)]
+pub enum MergeMethod {
+ Merge,
+ Rebase,
+ RebaseMerge,
+ Squash,
+ Manual,
+}
+
+#[derive(Clone, Copy, Debug)]
+pub enum PrNumber {
+ This(u64),
+ Parent(u64),
+}
+
+impl PrNumber {
+ fn number(self) -> u64 {
+ match self {
+ PrNumber::This(x) => x,
+ PrNumber::Parent(x) => x,
+ }
+ }
+}
+
+impl FromStr for PrNumber {
+ type Err = std::num::ParseIntError;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ if let Some(num) = s.strip_prefix("^") {
+ Ok(Self::Parent(num.parse()?))
+ } else {
+ Ok(Self::This(s.parse()?))
+ }
+ }
+}
+
+impl From<MergeMethod> for forgejo_api::structs::MergePullRequestOptionDo {
+ fn from(value: MergeMethod) -> Self {
+ use forgejo_api::structs::MergePullRequestOptionDo::*;
+ match value {
+ MergeMethod::Merge => Merge,
+ MergeMethod::Rebase => Rebase,
+ MergeMethod::RebaseMerge => RebaseMerge,
+ MergeMethod::Squash => Squash,
+ MergeMethod::Manual => ManuallyMerged,
+ }
+ }
+}
+
+#[derive(Subcommand, Clone, Debug)]
+pub enum EditCommand {
+ /// Edit the title
+ Title {
+ /// New PR title.
+ ///
+ /// Leaving this out will open the current title in your editor.
+ new_title: Option<String>,
+ },
+ /// Edit the text body
+ Body {
+ /// New PR body.
+ ///
+ /// Leaving this out will open the current body in your editor.
+ new_body: Option<String>,
+ },
+ /// Edit a comment
+ Comment {
+ /// The index of the comment to edit, 0-indexed.
+ idx: usize,
+ /// New comment body.
+ ///
+ /// Leaving this out will open the current body in your editor.
+ new_body: Option<String>,
+ },
+ Labels {
+ /// The labels to add.
+ #[clap(long, short)]
+ add: Vec<String>,
+ /// The labels to remove.
+ #[clap(long, short)]
+ rm: Vec<String>,
+ },
+}
+
+#[derive(Subcommand, Clone, Debug)]
+pub enum ViewCommand {
+ /// View the title and body of a pull request.
+ Body,
+ /// View a comment on a pull request.
+ Comment {
+ /// The index of the comment to view, 0-indexed.
+ idx: usize,
+ },
+ /// View all comments on a pull request.
+ Comments,
+ /// View the labels applied to a pull request.
+ Labels,
+ /// View the diff between the base and head branches of a pull request.
+ Diff {
+ /// Get the diff in patch format
+ #[clap(long, short)]
+ patch: bool,
+ /// View the diff in your text editor
+ #[clap(long, short)]
+ editor: bool,
+ },
+ /// View the files changed in a pull request.
+ Files,
+ /// View the commits in a pull request.
+ Commits {
+ /// View one commit per line
+ #[clap(long, short)]
+ oneline: bool,
+ },
+}
+
+impl PrCommand {
+ pub async fn run(self, keys: &mut crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> {
+ use PrSubcommand::*;
+ let repo = RepoInfo::get_current(host_name, self.repo(), self.remote.as_deref())?;
+ let api = keys.get_api(repo.host_url()).await?;
+ let repo = repo.name().ok_or_else(|| self.no_repo_error())?;
+ match self.command {
+ Create {
+ title,
+ base,
+ head,
+ body,
+ repo: _,
+ } => create_pr(&repo, &api, title, base, head, body).await?,
+ Merge {
+ pr,
+ method,
+ delete,
+ title,
+ message,
+ } => {
+ merge_pr(
+ &repo,
+ &api,
+ pr.map(|id| id.number),
+ method,
+ delete,
+ title,
+ message,
+ )
+ .await?
+ }
+ View { id, command } => {
+ let id = id.map(|id| id.number);
+ match command.unwrap_or(ViewCommand::Body) {
+ ViewCommand::Body => view_pr(&repo, &api, id).await?,
+ ViewCommand::Comment { idx } => {
+ let (repo, id) = try_get_pr_number(&repo, &api, id).await?;
+ crate::issues::view_comment(&repo, &api, id, idx).await?
+ }
+ ViewCommand::Comments => {
+ let (repo, id) = try_get_pr_number(&repo, &api, id).await?;
+ crate::issues::view_comments(&repo, &api, id).await?
+ }
+ ViewCommand::Labels => view_pr_labels(&repo, &api, id).await?,
+ ViewCommand::Diff { patch, editor } => {
+ view_diff(&repo, &api, id, patch, editor).await?
+ }
+ ViewCommand::Files => view_pr_files(&repo, &api, id).await?,
+ ViewCommand::Commits { oneline } => {
+ view_pr_commits(&repo, &api, id, oneline).await?
+ }
+ }
+ }
+ Status { id } => view_pr_status(&repo, &api, id.map(|id| id.number)).await?,
+ Search {
+ query,
+ labels,
+ creator,
+ assignee,
+ state,
+ repo: _,
+ } => view_prs(&repo, &api, query, labels, creator, assignee, state).await?,
+ Edit { pr, command } => {
+ let pr = pr.map(|pr| pr.number);
+ match command {
+ EditCommand::Title { new_title } => {
+ let (repo, id) = try_get_pr_number(&repo, &api, pr).await?;
+ crate::issues::edit_title(&repo, &api, id, new_title).await?
+ }
+ EditCommand::Body { new_body } => {
+ let (repo, id) = try_get_pr_number(&repo, &api, pr).await?;
+ crate::issues::edit_body(&repo, &api, id, new_body).await?
+ }
+ EditCommand::Comment { idx, new_body } => {
+ let (repo, id) = try_get_pr_number(&repo, &api, pr).await?;
+ crate::issues::edit_comment(&repo, &api, id, idx, new_body).await?
+ }
+ EditCommand::Labels { add, rm } => {
+ edit_pr_labels(&repo, &api, pr, add, rm).await?
+ }
+ }
+ }
+ Close { pr, with_msg } => {
+ let (repo, pr) = try_get_pr_number(&repo, &api, pr.map(|pr| pr.number)).await?;
+ crate::issues::close_issue(&repo, &api, pr, with_msg).await?
+ }
+ Checkout { pr, branch_name } => checkout_pr(&repo, &api, pr, branch_name).await?,
+ Browse { id } => {
+ let (repo, id) = try_get_pr_number(&repo, &api, id.map(|pr| pr.number)).await?;
+ browse_pr(&repo, &api, id).await?
+ }
+ Comment { pr, body } => {
+ let (repo, pr) = try_get_pr_number(&repo, &api, pr.map(|pr| pr.number)).await?;
+ crate::issues::add_comment(&repo, &api, pr, body).await?
+ }
+ }
+ Ok(())
+ }
+
+ fn repo(&self) -> Option<&RepoArg> {
+ use PrSubcommand::*;
+ match &self.command {
+ Search { repo, .. } | Create { repo, .. } => repo.as_ref(),
+ Checkout { .. } => None,
+ View { id: pr, .. }
+ | Status { id: pr, .. }
+ | Comment { pr, .. }
+ | Edit { pr, .. }
+ | Close { pr, .. }
+ | Merge { pr, .. }
+ | Browse { id: pr } => pr.as_ref().and_then(|x| x.repo.as_ref()),
+ }
+ }
+
+ fn no_repo_error(&self) -> eyre::Error {
+ use PrSubcommand::*;
+ match &self.command {
+ Search { .. } | Create { .. } => {
+ eyre::eyre!("can't figure what repo to access, try specifying with `--repo`")
+ }
+ Checkout { .. } => {
+ if git2::Repository::open(".").is_ok() {
+ eyre::eyre!("can't figure out what repo to access, try setting a remote tracking branch")
+ } else {
+ eyre::eyre!("pr checkout only works if the current directory is a git repo")
+ }
+ }
+ View { id: pr, .. }
+ | Status { id: pr, .. }
+ | Comment { pr, .. }
+ | Edit { pr, .. }
+ | Close { pr, .. }
+ | Merge { pr, .. }
+ | Browse { id: pr, .. } => match pr {
+ Some(pr) => eyre::eyre!(
+ "can't figure out what repo to access, try specifying with `{{owner}}/{{repo}}#{}`",
+ pr.number
+ ),
+ None => eyre::eyre!(
+ "can't figure out what repo to access, try specifying with `{{owner}}/{{repo}}#{{pr}}`",
+ ),
+ },
+ }
+ }
+}
+
+pub async fn view_pr(repo: &RepoName, api: &Forgejo, id: Option<u64>) -> eyre::Result<()> {
+ let crate::SpecialRender {
+ dash,
+
+ bright_red,
+ bright_green,
+ bright_magenta,
+ yellow,
+ dark_grey,
+ light_grey,
+ white,
+ reset,
+ ..
+ } = crate::special_render();
+ let pr = try_get_pr(repo, api, id).await?;
+ let id = pr.number.ok_or_eyre("pr does not have number")? as u64;
+ let repo = repo_name_from_pr(&pr)?;
+
+ let mut additions = 0;
+ let mut deletions = 0;
+ let query = RepoGetPullRequestFilesQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ let (_, files) = api
+ .repo_get_pull_request_files(repo.owner(), repo.name(), id, query)
+ .await?;
+ for file in files {
+ additions += file.additions.unwrap_or_default();
+ deletions += file.deletions.unwrap_or_default();
+ }
+ let title = pr
+ .title
+ .as_deref()
+ .ok_or_else(|| eyre::eyre!("pr does not have title"))?;
+ let title_no_wip = title
+ .strip_prefix("WIP: ")
+ .or_else(|| title.strip_prefix("WIP:"));
+ let (title, is_draft) = match title_no_wip {
+ Some(title) => (title, true),
+ None => (title, false),
+ };
+ let state = pr
+ .state
+ .ok_or_else(|| eyre::eyre!("pr does not have state"))?;
+ let is_merged = pr.merged.unwrap_or_default();
+ let state = match state {
+ StateType::Open if is_draft => format!("{light_grey}Draft{reset}"),
+ StateType::Open => format!("{bright_green}Open{reset}"),
+ StateType::Closed if is_merged => format!("{bright_magenta}Merged{reset}"),
+ StateType::Closed => format!("{bright_red}Closed{reset}"),
+ };
+ let base = pr.base.as_ref().ok_or_eyre("pr does not have base")?;
+ let base_repo = base
+ .repo
+ .as_ref()
+ .ok_or_eyre("base does not have repo")?
+ .full_name
+ .as_deref()
+ .ok_or_eyre("base repo does not have name")?;
+ let base_name = base
+ .label
+ .as_deref()
+ .ok_or_eyre("base does not have label")?;
+ let head = pr.head.as_ref().ok_or_eyre("pr does not have head")?;
+ let head_repo = head
+ .repo
+ .as_ref()
+ .ok_or_eyre("head does not have repo")?
+ .full_name
+ .as_deref()
+ .ok_or_eyre("head repo does not have name")?;
+ let head_name = head
+ .label
+ .as_deref()
+ .ok_or_eyre("head does not have label")?;
+ let head_name = if base_repo != head_repo {
+ format!("{head_repo}:{head_name}")
+ } else {
+ head_name.to_owned()
+ };
+ let user = pr
+ .user
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("pr does not have creator"))?;
+ let username = user
+ .login
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("user does not have login"))?;
+ let comments = pr.comments.unwrap_or_default();
+ println!("{yellow}{title}{reset} {dark_grey}#{id}{reset}");
+ println!(
+ "By {white}{username}{reset} {dash} {state} {dash} {bright_green}+{additions} {bright_red}-{deletions}{reset}"
+ );
+ println!("From `{head_name}` into `{base_name}`");
+
+ if let Some(body) = &pr.body {
+ if !body.trim().is_empty() {
+ println!();
+ println!("{}", crate::markdown(body));
+ }
+ }
+ println!();
+ if comments == 1 {
+ println!("1 comment");
+ } else {
+ println!("{comments} comments");
+ }
+ Ok(())
+}
+
+async fn view_pr_labels(repo: &RepoName, api: &Forgejo, pr: Option<u64>) -> eyre::Result<()> {
+ let pr = try_get_pr(repo, api, pr).await?;
+ let labels = pr.labels.as_deref().unwrap_or_default();
+ let SpecialRender {
+ fancy,
+ black,
+ white,
+ reset,
+ ..
+ } = *crate::special_render();
+ if fancy {
+ let mut total_width = 0;
+ for label in labels {
+ let name = label.name.as_deref().unwrap_or("???").trim();
+ if total_width + name.len() > 40 {
+ println!();
+ total_width = 0;
+ }
+ let color_s = label.color.as_deref().unwrap_or("FFFFFF");
+ let (r, g, b) = parse_color(color_s)?;
+ let text_color = if luma(r, g, b) > 0.5 { black } else { white };
+ let rgb_bg = format!("\x1b[48;2;{r};{g};{b}m");
+ if label.exclusive.unwrap_or_default() {
+ let (r2, g2, b2) = darken(r, g, b);
+ let (category, name) = name
+ .split_once("/")
+ .ok_or_eyre("label is exclusive but does not have slash")?;
+ let rgb_bg_dark = format!("\x1b[48;2;{r2};{g2};{b2}m");
+ print!("{rgb_bg_dark}{text_color} {category} {rgb_bg} {name} {reset} ");
+ } else {
+ print!("{rgb_bg}{text_color} {name} {reset} ");
+ }
+ total_width += name.len();
+ }
+ println!();
+ } else {
+ for label in labels {
+ let name = label.name.as_deref().unwrap_or("???");
+ println!("{name}");
+ }
+ }
+ Ok(())
+}
+
+fn parse_color(color: &str) -> eyre::Result<(u8, u8, u8)> {
+ eyre::ensure!(color.len() == 6, "color string wrong length");
+ let mut iter = color.chars();
+ let mut next_digit = || {
+ iter.next()
+ .unwrap()
+ .to_digit(16)
+ .ok_or_eyre("invalid digit")
+ };
+ let r1 = next_digit()?;
+ let r2 = next_digit()?;
+ let g1 = next_digit()?;
+ let g2 = next_digit()?;
+ let b1 = next_digit()?;
+ let b2 = next_digit()?;
+ let r = ((r1 << 4) | (r2)) as u8;
+ let g = ((g1 << 4) | (g2)) as u8;
+ let b = ((b1 << 4) | (b2)) as u8;
+ Ok((r, g, b))
+}
+
+// Thanks, wikipedia.
+fn luma(r: u8, g: u8, b: u8) -> f32 {
+ ((0.299 * (r as f32)) + (0.578 * (g as f32)) + (0.114 * (b as f32))) / 255.0
+}
+
+fn darken(r: u8, g: u8, b: u8) -> (u8, u8, u8) {
+ (
+ ((r as f32) * 0.85) as u8,
+ ((g as f32) * 0.85) as u8,
+ ((b as f32) * 0.85) as u8,
+ )
+}
+
+async fn view_pr_status(repo: &RepoName, api: &Forgejo, id: Option<u64>) -> eyre::Result<()> {
+ let pr = try_get_pr(repo, api, id).await?;
+ let repo = repo_name_from_pr(&pr)?;
+
+ let SpecialRender {
+ bright_magenta,
+ bright_red,
+ bright_green,
+ yellow,
+ light_grey,
+ dash,
+ bullet,
+ reset,
+ ..
+ } = *crate::special_render();
+
+ if pr.merged.ok_or_eyre("pr merge status unknown")? {
+ let merged_by = pr.merged_by.ok_or_eyre("pr not merged by anyone")?;
+ let merged_by = merged_by
+ .login
+ .as_deref()
+ .ok_or_eyre("pr merger does not have login")?;
+ let merged_at = pr.merged_at.ok_or_eyre("pr does not have merge date")?;
+ let date_format = time::macros::format_description!(
+ "on [month repr:long] [day], [year], at [hour repr:12]:[minute] [period]"
+ );
+ let tz_format = time::macros::format_description!(
+ "[offset_hour padding:zero sign:mandatory]:[offset_minute]"
+ );
+ let (merged_at, show_tz) = if let Ok(local_offset) = time::UtcOffset::current_local_offset()
+ {
+ let merged_at = merged_at.to_offset(local_offset);
+ (merged_at, false)
+ } else {
+ (merged_at, true)
+ };
+ print!(
+ "{bright_magenta}Merged{reset} by {merged_by} {}",
+ merged_at.format(date_format)?
+ );
+ if show_tz {
+ print!("{}", merged_at.format(tz_format)?);
+ }
+ println!();
+ } else {
+ let pr_number = pr.number.ok_or_eyre("pr does not have number")? as u64;
+ let query = forgejo_api::structs::RepoGetPullRequestCommitsQuery {
+ page: None,
+ limit: Some(u32::MAX),
+ verification: Some(false),
+ files: Some(false),
+ };
+ let (_commit_headers, commits) = api
+ .repo_get_pull_request_commits(repo.owner(), repo.name(), pr_number, query)
+ .await?;
+ let latest_commit = commits
+ .iter()
+ .max_by_key(|x| x.created)
+ .ok_or_eyre("no commits in pr")?;
+ let sha = latest_commit
+ .sha
+ .as_deref()
+ .ok_or_eyre("commit does not have sha")?;
+ let query = forgejo_api::structs::RepoGetCombinedStatusByRefQuery {
+ page: None,
+ limit: Some(u32::MAX),
+ };
+ let combined_status = api
+ .repo_get_combined_status_by_ref(repo.owner(), repo.name(), sha, query)
+ .await?;
+
+ let state = pr.state.ok_or_eyre("pr does not have state")?;
+ let is_draft = pr.title.as_deref().is_some_and(|s| s.starts_with("WIP:"));
+ match state {
+ StateType::Open => {
+ if is_draft {
+ println!("{light_grey}Draft{reset} {dash} Can't merge draft PR")
+ } else {
+ print!("{bright_green}Open{reset} {dash} ");
+ let mergable = pr.mergeable.ok_or_eyre("pr does not have mergable")?;
+ if mergable {
+ println!("Can be merged");
+ } else {
+ println!("{bright_red}Merge conflicts{reset}");
+ }
+ }
+ }
+ StateType::Closed => println!("{bright_red}Closed{reset} {dash} Reopen to merge"),
+ }
+
+ let commit_statuses = combined_status
+ .statuses
+ .ok_or_eyre("combined status does not have status list")?;
+ for status in commit_statuses {
+ let state = status
+ .status
+ .as_deref()
+ .ok_or_eyre("status does not have status")?;
+ let context = status
+ .context
+ .as_deref()
+ .ok_or_eyre("status does not have context")?;
+ print!("{bullet} ");
+ match state {
+ "success" => print!("{bright_green}Success{reset}"),
+ "pending" => print!("{yellow}Pending{reset}"),
+ "failure" => print!("{bright_red}Failure{reset}"),
+ _ => eyre::bail!("invalid status"),
+ };
+ println!(" {dash} {context}");
+ }
+ }
+
+ Ok(())
+}
+
+async fn edit_pr_labels(
+ repo: &RepoName,
+ api: &Forgejo,
+ pr: Option<u64>,
+ add: Vec<String>,
+ rm: Vec<String>,
+) -> eyre::Result<()> {
+ let pr = try_get_pr(repo, api, pr).await?;
+ let pr_number = pr.number.ok_or_eyre("pr does not have number")? as u64;
+ let repo = repo_name_from_pr(&pr)?;
+
+ let query = forgejo_api::structs::IssueListLabelsQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ let mut labels = api
+ .issue_list_labels(repo.owner(), repo.name(), query)
+ .await?;
+ let query = forgejo_api::structs::OrgListLabelsQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ let org_labels = api
+ .org_list_labels(repo.owner(), query)
+ .await
+ .unwrap_or_default();
+ labels.extend(org_labels);
+
+ let mut unknown_labels = Vec::new();
+
+ let mut add_ids = Vec::with_capacity(add.len());
+ for label_name in &add {
+ let maybe_label = labels
+ .iter()
+ .find(|label| label.name.as_ref() == Some(&label_name));
+ if let Some(label) = maybe_label {
+ add_ids.push(serde_json::Value::Number(
+ label.id.ok_or_eyre("label does not have id")?.into(),
+ ));
+ } else {
+ unknown_labels.push(label_name);
+ }
+ }
+
+ let mut rm_ids = Vec::with_capacity(add.len());
+ for label_name in &rm {
+ let maybe_label = labels
+ .iter()
+ .find(|label| label.name.as_ref() == Some(&label_name));
+ if let Some(label) = maybe_label {
+ rm_ids.push(label.id.ok_or_eyre("label does not have id")?);
+ } else {
+ unknown_labels.push(label_name);
+ }
+ }
+
+ let opts = forgejo_api::structs::IssueLabelsOption {
+ labels: Some(add_ids),
+ updated_at: None,
+ };
+ api.issue_add_label(repo.owner(), repo.name(), pr_number, opts)
+ .await?;
+ let opts = forgejo_api::structs::DeleteLabelsOption { updated_at: None };
+ for id in rm_ids {
+ api.issue_remove_label(
+ repo.owner(),
+ repo.name(),
+ pr_number,
+ id as u64,
+ opts.clone(),
+ )
+ .await?;
+ }
+
+ if !unknown_labels.is_empty() {
+ if unknown_labels.len() == 1 {
+ println!("'{}' doesn't exist", &unknown_labels[0]);
+ } else {
+ let SpecialRender { bullet, .. } = *crate::special_render();
+ println!("The following labels don't exist:");
+ for unknown_label in unknown_labels {
+ println!("{bullet} {unknown_label}");
+ }
+ }
+ }
+
+ Ok(())
+}
+
+async fn create_pr(
+ repo: &RepoName,
+ api: &Forgejo,
+ title: String,
+ base: Option<String>,
+ head: Option<String>,
+ body: Option<String>,
+) -> eyre::Result<()> {
+ let mut repo_data = api.repo_get(repo.owner(), repo.name()).await?;
+
+ let head = match head {
+ Some(head) => head,
+ None => {
+ let local_repo = git2::Repository::open(".")?;
+ let head = local_repo.head()?;
+ eyre::ensure!(
+ head.is_branch(),
+ "HEAD is not on branch, can't guess head branch"
+ );
+
+ let branch_ref = head
+ .name()
+ .ok_or_eyre("current branch does not have utf8 name")?;
+ let upstream_remote = local_repo.branch_upstream_remote(branch_ref)?;
+ let remote_name = upstream_remote
+ .as_str()
+ .ok_or_eyre("remote does not have utf8 name")?;
+
+ let remote = local_repo.find_remote(remote_name)?;
+ let remote_url_s = remote.url().ok_or_eyre("remote does not have utf8 url")?;
+ let remote_url = url::Url::parse(remote_url_s)?;
+
+ let clone_url = repo_data
+ .clone_url
+ .as_ref()
+ .ok_or_eyre("repo does not have git url")?;
+ let html_url = repo_data
+ .html_url
+ .as_ref()
+ .ok_or_eyre("repo does not have html url")?;
+ let ssh_url = repo_data
+ .ssh_url
+ .as_ref()
+ .ok_or_eyre("repo does not have ssh url")?;
+ eyre::ensure!(
+ &remote_url == clone_url || &remote_url == html_url || &remote_url == ssh_url,
+ "branch does not track that repo"
+ );
+
+ let upstream_branch = local_repo.branch_upstream_name(branch_ref)?;
+ let upstream_branch = upstream_branch
+ .as_str()
+ .ok_or_eyre("remote branch does not have utf8 name")?;
+ upstream_branch
+ .rsplit_once("/")
+ .map(|(_, b)| b)
+ .unwrap_or(upstream_branch)
+ .to_owned()
+ }
+ };
+
+ let (base, base_is_parent) = match base {
+ Some(base) => match base.strip_prefix("^") {
+ Some(stripped) if stripped.is_empty() => (None, true),
+ Some(stripped) => (Some(stripped.to_owned()), true),
+ None => (Some(base), false),
+ },
+ None => (None, false),
+ };
+
+ let (repo_owner, repo_name, base_repo, head) = if base_is_parent {
+ let parent_repo = *repo_data
+ .parent
+ .take()
+ .ok_or_eyre("cannot create pull request upstream, there is no upstream")?;
+ let parent_owner = parent_repo
+ .owner
+ .as_ref()
+ .ok_or_eyre("parent has no owner")?
+ .login
+ .as_deref()
+ .ok_or_eyre("parent owner has no login")?
+ .to_owned();
+ let parent_name = parent_repo
+ .name
+ .as_deref()
+ .ok_or_eyre("parent has no name")?
+ .to_owned();
+
+ (
+ parent_owner,
+ parent_name,
+ parent_repo,
+ format!("{}:{}", repo.owner(), head),
+ )
+ } else {
+ (
+ repo.owner().to_owned(),
+ repo.name().to_owned(),
+ repo_data,
+ head,
+ )
+ };
+
+ let base = match base {
+ Some(base) => base,
+ None => base_repo
+ .default_branch
+ .as_deref()
+ .ok_or_eyre("repo does not have default branch")?
+ .to_owned(),
+ };
+
+ let body = match body {
+ Some(body) => body,
+ None => {
+ let mut body = String::new();
+ crate::editor(&mut body, Some("md")).await?;
+ body
+ }
+ };
+ let pr = api
+ .repo_create_pull_request(
+ &repo_owner,
+ &repo_name,
+ CreatePullRequestOption {
+ assignee: None,
+ assignees: None,
+ base: Some(base.to_owned()),
+ body: Some(body),
+ due_date: None,
+ head: Some(head),
+ labels: None,
+ milestone: None,
+ title: Some(title),
+ },
+ )
+ .await?;
+ let number = pr
+ .number
+ .ok_or_else(|| eyre::eyre!("pr does not have number"))?;
+ let title = pr
+ .title
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("pr does not have title"))?;
+ println!("created pull request #{}: {}", number, title);
+ Ok(())
+}
+
+async fn merge_pr(
+ repo: &RepoName,
+ api: &Forgejo,
+ pr: Option<u64>,
+ method: Option<MergeMethod>,
+ delete: bool,
+ title: Option<String>,
+ message: Option<Option<String>>,
+) -> eyre::Result<()> {
+ let repo_info = api.repo_get(repo.owner(), repo.name()).await?;
+
+ let pr_info = try_get_pr(repo, api, pr).await?;
+ let repo = repo_name_from_pr(&pr_info)?;
+ let pr_html_url = pr_info
+ .html_url
+ .as_ref()
+ .ok_or_eyre("pr does not have url")?;
+
+ let default_merge = repo_info
+ .default_merge_style
+ .map(|x| x.into())
+ .unwrap_or(forgejo_api::structs::MergePullRequestOptionDo::Merge);
+ let merge_style = method.map(|x| x.into()).unwrap_or(default_merge);
+
+ use forgejo_api::structs::MergePullRequestOptionDo::*;
+ if title.is_some() {
+ match merge_style {
+ Rebase => eyre::bail!("rebase does not support commit title"),
+ FastForwardOnly => eyre::bail!("ff-only does not support commit title"),
+ ManuallyMerged => eyre::bail!("manually merged does not support commit title"),
+ _ => (),
+ }
+ }
+ let default_message = || format!("Reviewed-on: {pr_html_url}");
+ let message = match message {
+ Some(Some(s)) => s,
+ Some(None) => {
+ let mut body = default_message();
+ crate::editor(&mut body, Some("md")).await?;
+ body
+ }
+ None => default_message(),
+ };
+
+ let request = MergePullRequestOption {
+ r#do: merge_style,
+ merge_commit_id: None,
+ merge_message_field: Some(message),
+ merge_title_field: title,
+ delete_branch_after_merge: Some(delete),
+ force_merge: None,
+ head_commit_id: None,
+ merge_when_checks_succeed: None,
+ };
+ let pr_number = pr_info.number.ok_or_eyre("pr does not have number")? as u64;
+ api.repo_merge_pull_request(repo.owner(), repo.name(), pr_number, request)
+ .await?;
+
+ let pr_title = pr_info
+ .title
+ .as_deref()
+ .ok_or_eyre("pr does not have title")?;
+ let pr_base = pr_info.base.as_ref().ok_or_eyre("pr does not have base")?;
+ let base_label = pr_base
+ .label
+ .as_ref()
+ .ok_or_eyre("base does not have label")?;
+ println!("Merged PR #{pr_number} \"{pr_title}\" into `{base_label}`");
+ Ok(())
+}
+
+async fn checkout_pr(
+ repo: &RepoName,
+ api: &Forgejo,
+ pr: PrNumber,
+ branch_name: Option<String>,
+) -> eyre::Result<()> {
+ let local_repo = git2::Repository::open(".").unwrap();
+
+ let mut options = git2::StatusOptions::new();
+ options.include_ignored(false);
+ let has_no_uncommited = local_repo.statuses(Some(&mut options)).unwrap().is_empty();
+ eyre::ensure!(
+ has_no_uncommited,
+ "Cannot checkout PR, working directory has uncommited changes"
+ );
+
+ let remote_repo = match pr {
+ PrNumber::Parent(_) => {
+ let mut this_repo = api.repo_get(repo.owner(), repo.name()).await?;
+ let name = this_repo.full_name.as_deref().unwrap_or("???/???");
+ *this_repo
+ .parent
+ .take()
+ .ok_or_else(|| eyre::eyre!("cannot get parent repo, {name} is not a fork"))?
+ }
+ PrNumber::This(_) => api.repo_get(repo.owner(), repo.name()).await?,
+ };
+
+ let (repo_owner, repo_name) = repo_name_from_repo(&remote_repo)?;
+
+ let pull_data = api
+ .repo_get_pull_request(repo_owner, repo_name, pr.number())
+ .await?;
+
+ let url = remote_repo
+ .clone_url
+ .as_ref()
+ .ok_or_eyre("repo has no clone url")?;
+ let mut remote = local_repo.remote_anonymous(url.as_str())?;
+ let branch_name = branch_name.unwrap_or_else(|| {
+ format!(
+ "pr-{}-{}-{}",
+ url.host_str().unwrap_or("unknown"),
+ repo_owner,
+ pr.number(),
+ )
+ });
+
+ auth_git2::GitAuthenticator::new().fetch(
+ &local_repo,
+ &mut remote,
+ &[&format!("pull/{}/head", pr.number())],
+ None,
+ )?;
+
+ let reference = local_repo.find_reference("FETCH_HEAD")?.resolve()?;
+ let commit = reference.peel_to_commit()?;
+
+ let mut branch_is_new = true;
+ let branch =
+ if let Ok(mut branch) = local_repo.find_branch(&branch_name, git2::BranchType::Local) {
+ branch_is_new = false;
+ branch
+ .get_mut()
+ .set_target(commit.id(), "update pr branch")?;
+ branch
+ } else {
+ local_repo.branch(&branch_name, &commit, false)?
+ };
+ let branch_ref = branch
+ .get()
+ .name()
+ .ok_or_eyre("branch does not have name")?;
+
+ local_repo.set_head(branch_ref)?;
+ local_repo
+ // for some reason, `.force()` is required to make it actually update
+ // file contents. thank you git2 examples for noticing this too, I would
+ // have pulled out so much hair figuring this out myself.
+ .checkout_head(Some(git2::build::CheckoutBuilder::default().force()))
+ .unwrap();
+
+ let pr_title = pull_data.title.as_deref().ok_or_eyre("pr has no title")?;
+ println!("Checked out PR #{}: {pr_title}", pr.number());
+ if branch_is_new {
+ println!("On new branch {branch_name}");
+ } else {
+ println!("Updated branch to latest commit");
+ }
+
+ Ok(())
+}
+
+async fn view_prs(
+ repo: &RepoName,
+ api: &Forgejo,
+ query_str: Option<String>,
+ labels: Option<String>,
+ creator: Option<String>,
+ assignee: Option<String>,
+ state: Option<crate::issues::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: Some(forgejo_api::structs::IssueListIssuesQueryType::Pulls),
+ milestones: None,
+ since: None,
+ before: None,
+ mentioned_by: None,
+ page: None,
+ limit: None,
+ };
+ let prs = api
+ .issue_list_issues(repo.owner(), repo.name(), query)
+ .await?;
+ if prs.len() == 1 {
+ println!("1 pull request");
+ } else {
+ println!("{} pull requests", prs.len());
+ }
+ for pr in prs {
+ let number = pr
+ .number
+ .ok_or_else(|| eyre::eyre!("pr does not have number"))?;
+ let title = pr
+ .title
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("pr does not have title"))?;
+ let user = pr
+ .user
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("pr 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(())
+}
+
+async fn view_diff(
+ repo: &RepoName,
+ api: &Forgejo,
+ pr: Option<u64>,
+ patch: bool,
+ editor: bool,
+) -> eyre::Result<()> {
+ let pr = try_get_pr(repo, api, pr).await?;
+ let pr_number = pr.number.ok_or_eyre("pr does not have number")? as u64;
+ let repo = repo_name_from_pr(&pr)?;
+ let diff_type = if patch { "patch" } else { "diff" };
+ let diff = api
+ .repo_download_pull_diff_or_patch(
+ repo.owner(),
+ repo.name(),
+ pr_number,
+ diff_type,
+ forgejo_api::structs::RepoDownloadPullDiffOrPatchQuery::default(),
+ )
+ .await?;
+ if editor {
+ let mut view = diff.clone();
+ crate::editor(&mut view, Some(diff_type)).await?;
+ if view != diff {
+ println!("changes made to the diff will not persist");
+ }
+ } else {
+ println!("{diff}");
+ }
+ Ok(())
+}
+
+async fn view_pr_files(repo: &RepoName, api: &Forgejo, pr: Option<u64>) -> eyre::Result<()> {
+ let pr = try_get_pr(repo, api, pr).await?;
+ let pr_number = pr.number.ok_or_eyre("pr does not have number")? as u64;
+ let repo = repo_name_from_pr(&pr)?;
+ let crate::SpecialRender {
+ bright_red,
+ bright_green,
+ reset,
+ ..
+ } = crate::special_render();
+
+ let query = RepoGetPullRequestFilesQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ let (_, files) = api
+ .repo_get_pull_request_files(repo.owner(), repo.name(), pr_number, query)
+ .await?;
+ let max_additions = files
+ .iter()
+ .map(|x| x.additions.unwrap_or_default())
+ .max()
+ .unwrap_or_default();
+ let max_deletions = files
+ .iter()
+ .map(|x| x.deletions.unwrap_or_default())
+ .max()
+ .unwrap_or_default();
+
+ let additions_width = max_additions.checked_ilog10().unwrap_or_default() as usize + 1;
+ let deletions_width = max_deletions.checked_ilog10().unwrap_or_default() as usize + 1;
+
+ for file in files {
+ let name = file.filename.as_deref().unwrap_or("???");
+ let additions = file.additions.unwrap_or_default();
+ let deletions = file.deletions.unwrap_or_default();
+ println!("{bright_green}+{additions:<additions_width$} {bright_red}-{deletions:<deletions_width$}{reset} {name}");
+ }
+ Ok(())
+}
+
+async fn view_pr_commits(
+ repo: &RepoName,
+ api: &Forgejo,
+ pr: Option<u64>,
+ oneline: bool,
+) -> eyre::Result<()> {
+ let pr = try_get_pr(repo, api, pr).await?;
+ let pr_number = pr.number.ok_or_eyre("pr does not have number")? as u64;
+ let repo = repo_name_from_pr(&pr)?;
+ let query = RepoGetPullRequestCommitsQuery {
+ limit: Some(u32::MAX),
+ files: Some(false),
+ ..Default::default()
+ };
+ let (_headers, commits) = api
+ .repo_get_pull_request_commits(repo.owner(), repo.name(), pr_number, query)
+ .await?;
+
+ let max_additions = commits
+ .iter()
+ .filter_map(|x| x.stats.as_ref())
+ .map(|x| x.additions.unwrap_or_default())
+ .max()
+ .unwrap_or_default();
+ let max_deletions = commits
+ .iter()
+ .filter_map(|x| x.stats.as_ref())
+ .map(|x| x.deletions.unwrap_or_default())
+ .max()
+ .unwrap_or_default();
+
+ let additions_width = max_additions.checked_ilog10().unwrap_or_default() as usize + 1;
+ let deletions_width = max_deletions.checked_ilog10().unwrap_or_default() as usize + 1;
+
+ let crate::SpecialRender {
+ bright_red,
+ bright_green,
+ yellow,
+ reset,
+ ..
+ } = crate::special_render();
+ for commit in commits {
+ let repo_commit = commit
+ .commit
+ .as_ref()
+ .ok_or_eyre("commit does not have commit?")?;
+
+ let message = repo_commit.message.as_deref().unwrap_or("[no msg]");
+ let name = message.lines().next().unwrap_or(&message);
+
+ let sha = commit
+ .sha
+ .as_deref()
+ .ok_or_eyre("commit does not have sha")?;
+ let short_sha = &sha[..7];
+
+ let stats = commit
+ .stats
+ .as_ref()
+ .ok_or_eyre("commit does not have stats")?;
+ let additions = stats.additions.unwrap_or_default();
+ let deletions = stats.deletions.unwrap_or_default();
+
+ if oneline {
+ println!("{yellow}{short_sha} {bright_green}+{additions:<additions_width$} {bright_red}-{deletions:<deletions_width$}{reset} {name}");
+ } else {
+ let author = repo_commit
+ .author
+ .as_ref()
+ .ok_or_eyre("commit has no author")?;
+ let author_name = author.name.as_deref().ok_or_eyre("author has no name")?;
+ let author_email = author.email.as_deref().ok_or_eyre("author has no email")?;
+ let date = commit
+ .created
+ .as_ref()
+ .ok_or_eyre("commit as no creation date")?;
+
+ println!("{yellow}commit {sha}{reset} ({bright_green}+{additions}{reset}, {bright_red}-{deletions}{reset})");
+ println!("Author: {author_name} <{author_email}>");
+ print!("Date: ");
+ let format = time::macros::format_description!("[weekday repr:short] [month repr:short] [day] [hour repr:24]:[minute]:[second] [year] [offset_hour sign:mandatory][offset_minute]");
+ date.format_into(&mut std::io::stdout().lock(), format)?;
+ println!();
+ println!();
+ for line in message.lines() {
+ println!(" {line}");
+ }
+ println!();
+ }
+ }
+ Ok(())
+}
+
+pub async fn browse_pr(repo: &RepoName, api: &Forgejo, id: u64) -> eyre::Result<()> {
+ let pr = api
+ .repo_get_pull_request(repo.owner(), repo.name(), id)
+ .await?;
+ let html_url = pr
+ .html_url
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("pr does not have html_url"))?;
+ open::that(html_url.as_str())?;
+ Ok(())
+}
+
+async fn try_get_pr_number(
+ repo: &RepoName,
+ api: &Forgejo,
+ number: Option<u64>,
+) -> eyre::Result<(RepoName, u64)> {
+ let pr = match number {
+ Some(number) => (repo.clone(), number),
+ None => {
+ let pr = guess_pr(repo, api)
+ .await
+ .wrap_err("could not guess pull request number, please specify")?;
+ let number = pr.number.ok_or_eyre("pr does not have number")? as u64;
+ let repo = repo_name_from_pr(&pr)?;
+ (repo, number)
+ }
+ };
+ Ok(pr)
+}
+
+async fn try_get_pr(
+ repo: &RepoName,
+ api: &Forgejo,
+ number: Option<u64>,
+) -> eyre::Result<forgejo_api::structs::PullRequest> {
+ let pr = match number {
+ Some(number) => {
+ api.repo_get_pull_request(repo.owner(), repo.name(), number)
+ .await?
+ }
+ None => guess_pr(repo, api)
+ .await
+ .wrap_err("could not guess pull request number, please specify")?,
+ };
+ Ok(pr)
+}
+
+async fn guess_pr(
+ repo: &RepoName,
+ api: &Forgejo,
+) -> eyre::Result<forgejo_api::structs::PullRequest> {
+ let local_repo = git2::Repository::open(".")?;
+ let head = local_repo.head()?;
+ eyre::ensure!(head.is_branch(), "head is not on branch");
+ let local_branch = git2::Branch::wrap(head);
+ let remote_branch = local_branch.upstream()?;
+ let remote_head_name = remote_branch
+ .get()
+ .name()
+ .ok_or_eyre("remote branch does not have valid name")?;
+ let remote_head_short = remote_head_name
+ .rsplit_once("/")
+ .map(|(_, b)| b)
+ .unwrap_or(remote_head_name);
+ let this_repo = api.repo_get(repo.owner(), repo.name()).await?;
+
+ // check for PRs on the main branch first
+ let base = this_repo
+ .default_branch
+ .as_deref()
+ .ok_or_eyre("repo does not have default branch")?;
+ if let Ok(pr) = api
+ .repo_get_pull_request_by_base_head(repo.owner(), repo.name(), base, remote_head_short)
+ .await
+ {
+ return Ok(pr);
+ }
+
+ let this_full_name = this_repo
+ .full_name
+ .as_deref()
+ .ok_or_eyre("repo does not have full name")?;
+ let parent_remote_head_name = format!("{this_full_name}:{remote_head_short}");
+
+ if let Some(parent) = this_repo.parent.as_deref() {
+ let (parent_owner, parent_name) = repo_name_from_repo(parent)?;
+ let parent_base = this_repo
+ .default_branch
+ .as_deref()
+ .ok_or_eyre("repo does not have default branch")?;
+ if let Ok(pr) = api
+ .repo_get_pull_request_by_base_head(
+ parent_owner,
+ parent_name,
+ parent_base,
+ &parent_remote_head_name,
+ )
+ .await
+ {
+ return Ok(pr);
+ }
+ }
+
+ // then iterate all branches
+ if let Some(pr) = find_pr_from_branch(repo.owner(), repo.name(), api, remote_head_short).await?
+ {
+ return Ok(pr);
+ }
+
+ if let Some(parent) = this_repo.parent.as_deref() {
+ let (parent_owner, parent_name) = repo_name_from_repo(parent)?;
+
+ if let Some(pr) =
+ find_pr_from_branch(parent_owner, parent_name, api, &parent_remote_head_name).await?
+ {
+ return Ok(pr);
+ }
+ }
+
+ eyre::bail!("could not find PR");
+}
+
+async fn find_pr_from_branch(
+ repo_owner: &str,
+ repo_name: &str,
+ api: &Forgejo,
+ head: &str,
+) -> eyre::Result<Option<forgejo_api::structs::PullRequest>> {
+ for page in 1.. {
+ let branch_query = forgejo_api::structs::RepoListBranchesQuery {
+ page: Some(page),
+ limit: Some(30),
+ };
+ let remote_branches = match api
+ .repo_list_branches(repo_owner, repo_name, branch_query)
+ .await
+ {
+ Ok(x) if !x.is_empty() => x,
+ _ => break,
+ };
+
+ let prs = futures::future::try_join_all(
+ remote_branches
+ .into_iter()
+ .map(|branch| check_branch_pair(repo_owner, repo_name, api, branch, head)),
+ )
+ .await?;
+ for pr in prs {
+ if pr.is_some() {
+ return Ok(pr);
+ }
+ }
+ }
+ Ok(None)
+}
+
+async fn check_branch_pair(
+ repo_owner: &str,
+ repo_name: &str,
+ api: &Forgejo,
+ base: forgejo_api::structs::Branch,
+ head: &str,
+) -> eyre::Result<Option<forgejo_api::structs::PullRequest>> {
+ let base_name = base
+ .name
+ .as_deref()
+ .ok_or_eyre("remote branch does not have name")?;
+ match api
+ .repo_get_pull_request_by_base_head(repo_owner, repo_name, base_name, head)
+ .await
+ {
+ Ok(pr) => Ok(Some(pr)),
+ Err(_) => Ok(None),
+ }
+}
+
+fn repo_name_from_repo(repo: &forgejo_api::structs::Repository) -> eyre::Result<(&str, &str)> {
+ let owner = repo
+ .owner
+ .as_ref()
+ .ok_or_eyre("repo does not have owner")?
+ .login
+ .as_deref()
+ .ok_or_eyre("repo owner does not have name")?;
+ let name = repo.name.as_deref().ok_or_eyre("repo does not have name")?;
+ Ok((owner, name))
+}
+
+fn repo_name_from_pr(pr: &forgejo_api::structs::PullRequest) -> eyre::Result<RepoName> {
+ let base_branch = pr.base.as_ref().ok_or_eyre("pr does not have base")?;
+ let repo = base_branch
+ .repo
+ .as_ref()
+ .ok_or_eyre("branch does not have repo")?;
+ let (owner, name) = repo_name_from_repo(repo)?;
+ let repo_name = RepoName::new(owner.to_owned(), name.to_owned());
+ Ok(repo_name)
+}
+
+//async fn guess_pr(
+// repo: &RepoName,
+// api: &Forgejo,
+//) -> eyre::Result<forgejo_api::structs::PullRequest> {
+// let local_repo = git2::Repository::open(".")?;
+// let head_id = local_repo.head()?.peel_to_commit()?.id();
+// let sha = oid_to_string(head_id);
+// let pr = api
+// .repo_get_commit_pull_request(repo.owner(), repo.name(), &sha)
+// .await?;
+// Ok(pr)
+//}
+//
+//fn oid_to_string(oid: git2::Oid) -> String {
+// let mut s = String::with_capacity(40);
+// for byte in oid.as_bytes() {
+// s.push(
+// char::from_digit((byte & 0xF) as u32, 16).expect("every nibble is a valid hex digit"),
+// );
+// s.push(
+// char::from_digit(((byte >> 4) & 0xF) as u32, 16)
+// .expect("every nibble is a valid hex digit"),
+// );
+// }
+// s
+//}
diff --git a/src/release.rs b/src/release.rs
new file mode 100644
index 0000000..fa9d7d7
--- /dev/null
+++ b/src/release.rs
@@ -0,0 +1,614 @@
+use clap::{Args, Subcommand};
+use eyre::{bail, eyre, 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())?;
+ 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,
+ tokio::fs::read(file).await?,
+ 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(html_url.as_str())?;
+ }
+ 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(html_url.as_str())?;
+ }
+ }
+ 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,
+ tokio::fs::read(file).await?,
+ 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(())
+}
diff --git a/src/repo.rs b/src/repo.rs
new file mode 100644
index 0000000..dce5487
--- /dev/null
+++ b/src/repo.rs
@@ -0,0 +1,766 @@
+use std::{io::Write, path::PathBuf, str::FromStr};
+
+use clap::Subcommand;
+use eyre::{eyre, OptionExt};
+use forgejo_api::{structs::CreateRepoOption, Forgejo};
+use url::Url;
+
+use crate::SpecialRender;
+
+pub struct RepoInfo {
+ url: Url,
+ name: Option<RepoName>,
+}
+
+impl RepoInfo {
+ pub fn get_current(
+ host: Option<&str>,
+ repo: Option<&RepoArg>,
+ remote: Option<&str>,
+ ) -> eyre::Result<Self> {
+ // l = domain/owner/name
+ // s = owner/name
+ // x = is present
+ // i = found locally by git
+ //
+ // | repo | host | remote | ans-host | ans-repo |
+ // |------|------|--------|----------|----------|
+ // | l | x | x | repo | repo |
+ // | l | x | i | repo | repo |
+ // | l | x | | repo | repo |
+ // | l | | x | repo | repo |
+ // | l | | i | repo | repo |
+ // | l | | | repo | repo |
+ // | s | x | x | host | repo |
+ // | s | x | i | host | repo |
+ // | s | x | | host | repo |
+ // | s | | x | remote | repo |
+ // | s | | i | remote | repo |
+ // | s | | | err | repo |
+ // | | x | x | remote | remote |
+ // | | x | i | host | ?remote |
+ // | | x | | host | none |
+ // | | | x | remote | remote |
+ // | | | i | remote | remote |
+ // | | | | err | remote |
+
+ let mut repo_url: Option<Url> = None;
+ let mut repo_name: Option<RepoName> = None;
+
+ if let Some(repo) = repo {
+ if let Some(host) = &repo.host {
+ if let Ok(url) = Url::parse(host) {
+ repo_url = Some(url);
+ } else if let Ok(url) = Url::parse(&format!("https://{host}/")) {
+ repo_url = Some(url);
+ }
+ }
+ repo_name = Some(RepoName {
+ owner: repo.owner.clone(),
+ name: repo.name.clone(),
+ });
+ }
+
+ let repo_url = repo_url;
+ let repo_name = repo_name;
+
+ let host_url = host.and_then(|host| {
+ Url::parse(host)
+ .ok()
+ .or_else(|| Url::parse(&format!("https://{host}/")).ok())
+ });
+
+ let (remote_url, remote_repo_name) = {
+ let mut out = (None, None);
+ if let Ok(local_repo) = git2::Repository::open(".") {
+ let mut name = remote.map(|s| s.to_owned());
+
+ // if there's only one remote, use that
+ if name.is_none() {
+ let all_remotes = local_repo.remotes()?;
+ if all_remotes.len() == 1 {
+ if let Some(remote_name) = all_remotes.get(0) {
+ name = Some(remote_name.to_owned());
+ }
+ }
+ }
+
+ // if the current branch is tracking a remote branch, use that remote
+ if name.is_none() {
+ let head = local_repo.head()?;
+ let branch_name = head.name().ok_or_eyre("branch name not UTF-8")?;
+
+ if let Ok(remote_name) = local_repo.branch_upstream_remote(branch_name) {
+ let remote_name_s =
+ remote_name.as_str().ok_or_eyre("remote name invalid")?;
+
+ if let Some(host_url) = &host_url {
+ let remote = local_repo.find_remote(&remote_name_s)?;
+ let url_s = std::str::from_utf8(remote.url_bytes())?;
+ let url = Url::parse(url_s)?;
+
+ if url.host_str() == host_url.host_str() {
+ name = Some(remote_name_s.to_owned());
+ }
+ } else {
+ name = Some(remote_name_s.to_owned());
+ }
+ }
+ }
+
+ // if there's a remote whose host url matches the one
+ // specified with `--host`, use that
+ //
+ // This is different than using `--host` itself, since this
+ // will include the repo name, which `--host` can't do.
+ if name.is_none() {
+ if let Some(host_url) = &host_url {
+ let all_remotes = local_repo.remotes()?;
+ for remote_name in all_remotes.iter() {
+ let Some(remote_name) = remote_name else {
+ continue;
+ };
+ let remote = local_repo.find_remote(remote_name)?;
+
+ if let Some(url) = remote.url() {
+ let (url, _) = url_strip_repo_name(Url::parse(url)?)?;
+ if url.host_str() == host_url.host_str()
+ && url.path() == host_url.path()
+ {
+ name = Some(remote_name.to_owned());
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ if let Some(name) = name {
+ if let Ok(remote) = local_repo.find_remote(&name) {
+ let url_s = std::str::from_utf8(remote.url_bytes())?;
+ let url = Url::parse(url_s)?;
+ let (url, name) = url_strip_repo_name(url)?;
+
+ out = (Some(url), Some(name))
+ }
+ }
+ } else {
+ eyre::ensure!(remote.is_none(), "remote specified but no git repo found");
+ }
+ out
+ };
+
+ let (url, name) = if repo_url.is_some() {
+ (repo_url, repo_name)
+ } else if repo_name.is_some() {
+ (host_url.or(remote_url), repo_name)
+ } else {
+ if remote.is_some() {
+ (remote_url, remote_repo_name)
+ } else if host_url.is_none() || remote_url == host_url {
+ (remote_url, remote_repo_name)
+ } else {
+ (host_url, None)
+ }
+ };
+
+ let url = url.or_else(fallback_host);
+
+ let info = match (url, name) {
+ (Some(url), name) => RepoInfo { url, name },
+ (None, Some(_)) => eyre::bail!("cannot find repo, no host specified"),
+ (None, None) => eyre::bail!("no repo info specified"),
+ };
+
+ Ok(info)
+ }
+
+ pub fn name(&self) -> Option<&RepoName> {
+ self.name.as_ref()
+ }
+
+ pub fn host_url(&self) -> &Url {
+ &self.url
+ }
+}
+
+fn fallback_host() -> Option<Url> {
+ if let Some(envvar) = std::env::var_os("FJ_FALLBACK_HOST") {
+ let out = envvar.to_str().and_then(|x| x.parse::<Url>().ok());
+ if out.is_none() {
+ println!("warn: `FJ_FALLBACK_HOST` is not set to a valid url");
+ }
+ out
+ } else {
+ None
+ }
+}
+
+fn url_strip_repo_name(mut url: Url) -> eyre::Result<(Url, RepoName)> {
+ let mut iter = url
+ .path_segments()
+ .ok_or_eyre("repo url cannot be a base")?
+ .rev();
+
+ let name = iter.next().ok_or_eyre("repo url too short")?;
+ let name = name.strip_suffix(".git").unwrap_or(name).to_owned();
+
+ let owner = iter.next().ok_or_eyre("repo url too short")?.to_owned();
+
+ // Remove the username and repo name from the url
+ url.path_segments_mut()
+ .map_err(|_| eyre!("repo url cannot be a base"))?
+ .pop()
+ .pop();
+
+ Ok((url, RepoName { owner, name }))
+}
+
+#[derive(Clone, Debug)]
+pub struct RepoName {
+ owner: String,
+ name: String,
+}
+
+impl RepoName {
+ pub fn new(owner: String, name: String) -> Self {
+ Self { owner, name }
+ }
+ pub fn owner(&self) -> &str {
+ &self.owner
+ }
+
+ pub fn name(&self) -> &str {
+ &self.name
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct RepoArg {
+ host: Option<String>,
+ owner: String,
+ name: String,
+}
+
+impl std::fmt::Display for RepoArg {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match &self.host {
+ Some(host) => write!(f, "{host}/{}/{}", self.owner, self.name),
+ None => write!(f, "{}/{}", self.owner, self.name),
+ }
+ }
+}
+
+impl FromStr for RepoArg {
+ type Err = RepoArgError;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ let (head, name) = s.rsplit_once("/").ok_or(RepoArgError::NoOwner)?;
+ let name = name.strip_suffix(".git").unwrap_or(name);
+ let (host, owner) = match head.rsplit_once("/") {
+ Some((host, owner)) => (Some(host), owner),
+ None => (None, head),
+ };
+ Ok(Self {
+ host: host.map(|s| s.to_owned()),
+ owner: owner.to_owned(),
+ name: name.to_owned(),
+ })
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RepoArgError {
+ NoOwner,
+}
+
+impl std::error::Error for RepoArgError {}
+
+impl std::fmt::Display for RepoArgError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ RepoArgError::NoOwner => {
+ write!(f, "repo name should be in the format [HOST/]OWNER/NAME")
+ }
+ }
+ }
+}
+
+#[derive(Subcommand, Clone, Debug)]
+pub enum RepoCommand {
+ /// Creates a new repository
+ Create {
+ repo: String,
+
+ // flags
+ #[clap(long, short)]
+ description: Option<String>,
+ #[clap(long, short = 'P')]
+ private: bool,
+ /// Creates a new remote with the given name for the new repo
+ #[clap(long, short)]
+ remote: Option<String>,
+ /// Pushes the current branch to the default branch on the new repo.
+ /// Implies `--remote=origin` (setting remote manually overrides this)
+ #[clap(long, short)]
+ push: bool,
+ },
+ /// Fork a repository onto your account
+ Fork {
+ #[clap(id = "[HOST/]OWNER/REPO")]
+ repo: RepoArg,
+ #[clap(long)]
+ name: Option<String>,
+ #[clap(long, short = 'R')]
+ remote: Option<String>,
+ },
+ /// View a repo's info
+ View {
+ #[clap(id = "[HOST/]OWNER/REPO")]
+ name: Option<RepoArg>,
+ #[clap(long, short = 'R')]
+ remote: Option<String>,
+ },
+ /// Clone a repo's code locally
+ Clone {
+ #[clap(id = "[HOST/]OWNER/REPO")]
+ repo: RepoArg,
+ path: Option<PathBuf>,
+ },
+ /// Add a star to a repo
+ Star {
+ #[clap(id = "[HOST/]OWNER/REPO")]
+ repo: RepoArg,
+ },
+ /// Take away a star from a repo
+ Unstar {
+ #[clap(id = "[HOST/]OWNER/REPO")]
+ repo: RepoArg,
+ },
+ /// Delete a repository
+ ///
+ /// This cannot be undone!
+ Delete {
+ #[clap(id = "[HOST/]OWNER/REPO")]
+ repo: RepoArg,
+ },
+ /// Open a repository's page in your browser
+ Browse {
+ #[clap(id = "[HOST/]OWNER/REPO")]
+ name: Option<RepoArg>,
+ #[clap(long, short = 'R')]
+ remote: Option<String>,
+ },
+}
+
+impl RepoCommand {
+ pub async fn run(self, keys: &mut crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> {
+ match self {
+ RepoCommand::Create {
+ repo,
+
+ description,
+ private,
+ remote,
+ push,
+ } => {
+ let host = RepoInfo::get_current(host_name, None, None)?;
+ let api = keys.get_api(host.host_url()).await?;
+ create_repo(&api, repo, description, private, remote, push).await?;
+ }
+ RepoCommand::Fork { repo, name, remote } => {
+ fn strip(s: &str) -> &str {
+ let no_scheme = s
+ .strip_prefix("https://")
+ .or_else(|| s.strip_prefix("http://"))
+ .unwrap_or(s);
+ let no_trailing_slash = no_scheme.strip_suffix("/").unwrap_or(no_scheme);
+ no_trailing_slash
+ }
+ match (repo.host.as_deref(), host_name) {
+ (Some(a), Some(b)) => {
+ if strip(a) != strip(b) {
+ eyre::bail!("conflicting hosts {a} and {b}. please only specify one");
+ }
+ }
+ _ => (),
+ }
+
+ let repo_info = RepoInfo::get_current(host_name, Some(&repo), remote.as_deref())?;
+ let api = keys.get_api(&repo_info.host_url()).await?;
+ let repo = repo_info
+ .name()
+ .ok_or_eyre("couldn't get repo name, please specify")?;
+ fork_repo(&api, repo, name).await?
+ }
+ RepoCommand::View { name, remote } => {
+ let repo = RepoInfo::get_current(host_name, name.as_ref(), remote.as_deref())?;
+ let api = keys.get_api(&repo.host_url()).await?;
+ let repo = repo
+ .name()
+ .ok_or_eyre("couldn't get repo name, please specify")?;
+ view_repo(&api, &repo).await?
+ }
+ RepoCommand::Clone { repo, path } => {
+ let repo = RepoInfo::get_current(host_name, Some(&repo), None)?;
+ let api = keys.get_api(&repo.host_url()).await?;
+ let name = repo.name().unwrap();
+ cmd_clone_repo(&api, &name, path).await?;
+ }
+ RepoCommand::Star { repo } => {
+ let repo = RepoInfo::get_current(host_name, Some(&repo), None)?;
+ let api = keys.get_api(&repo.host_url()).await?;
+ let name = repo.name().unwrap();
+ api.user_current_put_star(name.owner(), name.name()).await?;
+ println!("Starred {}/{}!", name.owner(), name.name());
+ }
+ RepoCommand::Unstar { repo } => {
+ let repo = RepoInfo::get_current(host_name, Some(&repo), None)?;
+ let api = keys.get_api(&repo.host_url()).await?;
+ let name = repo.name().unwrap();
+ api.user_current_delete_star(name.owner(), name.name())
+ .await?;
+ println!("Removed star from {}/{}", name.owner(), name.name());
+ }
+ RepoCommand::Delete { repo } => {
+ let repo = RepoInfo::get_current(host_name, Some(&repo), None)?;
+ let api = keys.get_api(&repo.host_url()).await?;
+ let name = repo.name().unwrap();
+ delete_repo(&api, &name).await?;
+ }
+ RepoCommand::Browse { name, remote } => {
+ let repo = RepoInfo::get_current(host_name, name.as_ref(), remote.as_deref())?;
+ let mut url = repo.host_url().clone();
+ let repo = repo
+ .name()
+ .ok_or_eyre("couldn't get repo name, please specify")?;
+ url.path_segments_mut()
+ .map_err(|_| eyre!("url invalid"))?
+ .extend([repo.owner(), repo.name()]);
+
+ open::that(url.as_str())?;
+ }
+ };
+ Ok(())
+ }
+}
+
+pub async fn create_repo(
+ api: &Forgejo,
+ repo: String,
+ description: Option<String>,
+ private: bool,
+ remote: Option<String>,
+ push: bool,
+) -> eyre::Result<()> {
+ if remote.is_some() || push {
+ let repo = git2::Repository::open(".")?;
+
+ let upstream = remote.as_deref().unwrap_or("origin");
+ if repo.find_remote(upstream).is_ok() {
+ eyre::bail!("A remote named \"{upstream}\" already exists");
+ }
+ }
+ let repo_spec = CreateRepoOption {
+ auto_init: Some(false),
+ default_branch: Some("main".into()),
+ description,
+ gitignores: None,
+ issue_labels: None,
+ license: None,
+ name: repo,
+ object_format_name: None,
+ private: Some(private),
+ readme: Some(String::new()),
+ template: Some(false),
+ trust_model: Some(forgejo_api::structs::CreateRepoOptionTrustModel::Default),
+ };
+ let new_repo = api.create_current_user_repo(repo_spec).await?;
+ let html_url = new_repo
+ .html_url
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("new_repo does not have html_url"))?;
+ println!("created new repo at {}", html_url);
+
+ if remote.is_some() || push {
+ let repo = git2::Repository::open(".")?;
+
+ let upstream = remote.as_deref().unwrap_or("origin");
+ let clone_url = new_repo
+ .clone_url
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("new_repo does not have clone_url"))?;
+ let mut remote = repo.remote(upstream, clone_url.as_str())?;
+
+ if push {
+ let head = repo.head()?;
+ if !head.is_branch() {
+ eyre::bail!("HEAD is not on a branch; cannot push to remote");
+ }
+ let branch_shorthand = head
+ .shorthand()
+ .ok_or_else(|| eyre!("branch name invalid utf-8"))?
+ .to_owned();
+ let branch_name = std::str::from_utf8(head.name_bytes())?.to_owned();
+
+ let auth = auth_git2::GitAuthenticator::new();
+ auth.push(&repo, &mut remote, &[&branch_name])?;
+
+ remote.fetch(&[&branch_shorthand], None, None)?;
+
+ let mut current_branch = git2::Branch::wrap(head);
+ current_branch.set_upstream(Some(&format!("{upstream}/{branch_shorthand}")))?;
+ }
+ }
+
+ Ok(())
+}
+
+async fn fork_repo(api: &Forgejo, repo: &RepoName, name: Option<String>) -> eyre::Result<()> {
+ let opt = forgejo_api::structs::CreateForkOption {
+ name,
+ organization: None,
+ };
+ let new_fork = api.create_fork(repo.owner(), repo.name(), opt).await?;
+ let fork_full_name = new_fork
+ .full_name
+ .as_deref()
+ .ok_or_eyre("fork does not have name")?;
+ println!(
+ "Forked {}/{} into {}",
+ repo.owner(),
+ repo.name(),
+ fork_full_name
+ );
+
+ Ok(())
+}
+
+async fn view_repo(api: &Forgejo, repo: &RepoName) -> eyre::Result<()> {
+ let repo = api.repo_get(repo.owner(), repo.name()).await?;
+
+ let SpecialRender {
+ dash,
+ body_prefix,
+ dark_grey,
+ reset,
+ ..
+ } = crate::special_render();
+
+ println!("{}", repo.full_name.ok_or_eyre("no full name")?);
+
+ if let Some(parent) = &repo.parent {
+ println!(
+ "Fork of {}",
+ parent.full_name.as_ref().ok_or_eyre("no full name")?
+ );
+ }
+ if repo.mirror == Some(true) {
+ if let Some(original) = &repo.original_url {
+ println!("Mirror of {original}")
+ }
+ }
+ let desc = repo.description.as_deref().unwrap_or_default();
+ // Don't use body::markdown, this is plain text.
+ if !desc.is_empty() {
+ if desc.lines().count() > 1 {
+ println!();
+ }
+ for line in desc.lines() {
+ println!("{dark_grey}{body_prefix}{reset} {line}");
+ }
+ }
+ println!();
+
+ let lang = repo.language.as_deref().unwrap_or_default();
+ if !lang.is_empty() {
+ println!("Primary language is {lang}");
+ }
+
+ let stars = repo.stars_count.unwrap_or_default();
+ if stars == 1 {
+ print!("{stars} star {dash} ");
+ } else {
+ print!("{stars} stars {dash} ");
+ }
+
+ let watchers = repo.watchers_count.unwrap_or_default();
+ print!("{watchers} watching {dash} ");
+
+ let forks = repo.forks_count.unwrap_or_default();
+ if forks == 1 {
+ print!("{forks} fork");
+ } else {
+ print!("{forks} forks");
+ }
+ println!();
+
+ let mut first = true;
+ if repo.has_issues.unwrap_or_default() && repo.external_tracker.is_none() {
+ let issues = repo.open_issues_count.unwrap_or_default();
+ if issues == 1 {
+ print!("{issues} issue");
+ } else {
+ print!("{issues} issues");
+ }
+ first = false;
+ }
+ if repo.has_pull_requests.unwrap_or_default() {
+ if !first {
+ print!(" {dash} ");
+ }
+ let pulls = repo.open_pr_counter.unwrap_or_default();
+ if pulls == 1 {
+ print!("{pulls} PR");
+ } else {
+ print!("{pulls} PRs");
+ }
+ first = false;
+ }
+ if repo.has_releases.unwrap_or_default() {
+ if !first {
+ print!(" {dash} ");
+ }
+ let releases = repo.release_counter.unwrap_or_default();
+ if releases == 1 {
+ print!("{releases} release");
+ } else {
+ print!("{releases} releases");
+ }
+ first = false;
+ }
+ if !first {
+ println!();
+ }
+ if let Some(external_tracker) = &repo.external_tracker {
+ if let Some(tracker_url) = &external_tracker.external_tracker_url {
+ println!("Issue tracker is at {tracker_url}");
+ }
+ }
+
+ if let Some(html_url) = &repo.html_url {
+ println!();
+ println!("View online at {html_url}");
+ }
+
+ Ok(())
+}
+
+async fn cmd_clone_repo(
+ api: &Forgejo,
+ name: &RepoName,
+ path: Option<std::path::PathBuf>,
+) -> eyre::Result<()> {
+ let repo_data = api.repo_get(name.owner(), name.name()).await?;
+ let clone_url = repo_data
+ .clone_url
+ .as_ref()
+ .ok_or_eyre("repo does not have clone url")?;
+
+ 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 path = path.unwrap_or_else(|| PathBuf::from(format!("./{repo_name}")));
+
+ let local_repo = clone_repo(&repo_full_name, &clone_url, &path)?;
+
+ if let Some(parent) = repo_data.parent.as_deref() {
+ let parent_clone_url = parent
+ .clone_url
+ .as_ref()
+ .ok_or_eyre("parent repo does not have clone url")?;
+ local_repo.remote("upstream", parent_clone_url.as_str())?;
+ }
+
+ Ok(())
+}
+
+pub fn clone_repo(
+ repo_name: &str,
+ url: &url::Url,
+ path: &std::path::Path,
+) -> eyre::Result<git2::Repository> {
+ let SpecialRender {
+ fancy,
+ hide_cursor,
+ show_cursor,
+ clear_line,
+ ..
+ } = *crate::special_render();
+
+ let auth = auth_git2::GitAuthenticator::new();
+ let git_config = git2::Config::open_default()?;
+
+ let mut options = git2::FetchOptions::new();
+ let mut callbacks = git2::RemoteCallbacks::new();
+ callbacks.credentials(auth.credentials(&git_config));
+
+ if fancy {
+ print!("{hide_cursor}");
+ print!(" Preparing...");
+ let _ = std::io::stdout().flush();
+
+ callbacks.transfer_progress(|progress| {
+ print!("{clear_line}\r");
+ if progress.received_objects() == progress.total_objects() {
+ if progress.indexed_deltas() == progress.total_deltas() {
+ print!("Finishing up...");
+ } else {
+ let percent = 100.0 * (progress.indexed_deltas() as f64)
+ / (progress.total_deltas() as f64);
+ print!(" Resolving... {percent:.01}%");
+ }
+ } else {
+ let bytes = progress.received_bytes();
+ let percent = 100.0 * (progress.received_objects() as f64)
+ / (progress.total_objects() as f64);
+ print!(" Downloading... {percent:.01}%");
+ match bytes {
+ 0..=1023 => print!(" ({}b)", bytes),
+ 1024..=1048575 => print!(" ({:.01}kb)", (bytes as f64) / 1024.0),
+ 1048576..=1073741823 => {
+ print!(" ({:.01}mb)", (bytes as f64) / 1048576.0)
+ }
+ 1073741824.. => {
+ print!(" ({:.01}gb)", (bytes as f64) / 1073741824.0)
+ }
+ }
+ }
+ let _ = std::io::stdout().flush();
+ true
+ });
+ options.remote_callbacks(callbacks);
+ }
+
+ let local_repo = git2::build::RepoBuilder::new()
+ .fetch_options(options)
+ .clone(url.as_str(), &path)?;
+ if fancy {
+ print!("{clear_line}{show_cursor}\r");
+ }
+ println!("Cloned {} into {}", repo_name, path.display());
+ Ok(local_repo)
+}
+
+async fn delete_repo(api: &Forgejo, name: &RepoName) -> eyre::Result<()> {
+ print!(
+ "Are you sure you want to delete {}/{}? (y/N) ",
+ name.owner(),
+ name.name()
+ );
+ let user_response = crate::readline("").await?;
+ let yes = matches!(user_response.trim(), "y" | "Y" | "yes" | "Yes");
+ if yes {
+ api.repo_delete(name.owner(), name.name()).await?;
+ println!("Deleted {}/{}", name.owner(), name.name());
+ } else {
+ println!("Did not delete");
+ }
+ Ok(())
+}
diff --git a/src/user.rs b/src/user.rs
new file mode 100644
index 0000000..d38d8b5
--- /dev/null
+++ b/src/user.rs
@@ -0,0 +1,1019 @@
+use clap::{Args, Subcommand};
+use eyre::OptionExt;
+use forgejo_api::Forgejo;
+
+use crate::{repo::RepoInfo, SpecialRender};
+
+#[derive(Args, Clone, Debug)]
+pub struct UserCommand {
+ /// The local git remote that points to the repo to operate on.
+ #[clap(long, short = 'R')]
+ remote: Option<String>,
+ #[clap(subcommand)]
+ command: UserSubcommand,
+}
+
+#[derive(Subcommand, Clone, Debug)]
+pub enum UserSubcommand {
+ /// Search for a user by username
+ Search {
+ /// The name to search for
+ query: String,
+ #[clap(long, short)]
+ page: Option<usize>,
+ },
+ /// View a user's profile page
+ View {
+ /// The name of the user to view
+ ///
+ /// Omit to view your own page
+ user: Option<String>,
+ },
+ /// Open a user's profile page in your browser
+ Browse {
+ /// The name of the user to open in your browser
+ ///
+ /// Omit to view your own page
+ user: Option<String>,
+ },
+ /// Follow a user
+ Follow {
+ /// The name of the user to follow
+ user: String,
+ },
+ /// Unfollow a user
+ Unfollow {
+ /// The name of the user to follow
+ user: String,
+ },
+ /// List everyone a user's follows
+ Following {
+ /// The name of the user whose follows to list
+ ///
+ /// Omit to view your own follows
+ user: Option<String>,
+ },
+ /// List a user's followers
+ Followers {
+ /// The name of the user whose followers to list
+ ///
+ /// Omit to view your own followers
+ user: Option<String>,
+ },
+ /// Block a user
+ Block {
+ /// The name of the user to block
+ user: String,
+ },
+ /// Unblock a user
+ Unblock {
+ /// The name of the user to unblock
+ user: String,
+ },
+ /// List a user's repositories
+ Repos {
+ /// The name of the user whose repos to list
+ ///
+ /// Omit to view your own repos.
+ user: Option<String>,
+ /// List starred repos instead of owned repos
+ #[clap(long)]
+ starred: bool,
+ /// Method by which to sort the list
+ #[clap(long)]
+ sort: Option<RepoSortOrder>,
+ },
+ /// List the organizations a user is a member of
+ Orgs {
+ /// The name of the user to view org membership of
+ ///
+ /// Omit to view your own orgs.
+ user: Option<String>,
+ },
+ /// List a user's recent activity
+ Activity {
+ /// The name of the user to view the activity of
+ ///
+ /// Omit to view your own activity.
+ user: Option<String>,
+ },
+ /// Edit your user settings
+ #[clap(subcommand)]
+ Edit(EditCommand),
+}
+
+#[derive(Subcommand, Clone, Debug)]
+pub enum EditCommand {
+ /// Set your bio
+ Bio {
+ /// The new description. Leave this out to open your editor.
+ content: Option<String>,
+ },
+ /// Set your full name
+ Name {
+ /// The new name.
+ #[clap(group = "arg")]
+ name: Option<String>,
+ /// Remove your name from your profile
+ #[clap(long, short, group = "arg")]
+ unset: bool,
+ },
+ /// Set your pronouns
+ Pronouns {
+ /// The new pronouns.
+ #[clap(group = "arg")]
+ pronouns: Option<String>,
+ /// Remove your pronouns from your profile
+ #[clap(long, short, group = "arg")]
+ unset: bool,
+ },
+ /// Set your activity visibility
+ Location {
+ /// The new location.
+ #[clap(group = "arg")]
+ location: Option<String>,
+ /// Remove your location from your profile
+ #[clap(long, short, group = "arg")]
+ unset: bool,
+ },
+ /// Set your activity visibility
+ Activity {
+ /// The visibility of your activity.
+ #[clap(long, short)]
+ visibility: VisbilitySetting,
+ },
+ /// Manage the email addresses associated with your account
+ Email {
+ /// Set the visibility of your email address.
+ #[clap(long, short)]
+ visibility: Option<VisbilitySetting>,
+ /// Add a new email address
+ #[clap(long, short)]
+ add: Vec<String>,
+ /// Remove an email address
+ #[clap(long, short)]
+ rm: Vec<String>,
+ },
+ /// Set your linked website
+ Website {
+ /// Your website URL.
+ #[clap(group = "arg")]
+ url: Option<String>,
+ /// Remove your website from your profile
+ #[clap(long, short, group = "arg")]
+ unset: bool,
+ },
+}
+
+#[derive(clap::ValueEnum, Clone, Debug, PartialEq, Eq)]
+pub enum VisbilitySetting {
+ Hidden,
+ Public,
+}
+
+impl UserCommand {
+ pub async fn run(self, keys: &mut crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> {
+ let repo = RepoInfo::get_current(host_name, None, self.remote.as_deref())?;
+ let api = keys.get_api(repo.host_url()).await?;
+ match self.command {
+ UserSubcommand::Search { query, page } => user_search(&api, &query, page).await?,
+ UserSubcommand::View { user } => view_user(&api, user.as_deref()).await?,
+ UserSubcommand::Browse { user } => {
+ browse_user(&api, repo.host_url(), user.as_deref()).await?
+ }
+ UserSubcommand::Follow { user } => follow_user(&api, &user).await?,
+ UserSubcommand::Unfollow { user } => unfollow_user(&api, &user).await?,
+ UserSubcommand::Following { user } => list_following(&api, user.as_deref()).await?,
+ UserSubcommand::Followers { user } => list_followers(&api, user.as_deref()).await?,
+ UserSubcommand::Block { user } => block_user(&api, &user).await?,
+ UserSubcommand::Unblock { user } => unblock_user(&api, &user).await?,
+ UserSubcommand::Repos {
+ user,
+ starred,
+ sort,
+ } => list_repos(&api, user.as_deref(), starred, sort).await?,
+ UserSubcommand::Orgs { user } => list_orgs(&api, user.as_deref()).await?,
+ UserSubcommand::Activity { user } => list_activity(&api, user.as_deref()).await?,
+ UserSubcommand::Edit(cmd) => match cmd {
+ EditCommand::Bio { content } => edit_bio(&api, content).await?,
+ EditCommand::Name { name, unset } => edit_name(&api, name, unset).await?,
+ EditCommand::Pronouns { pronouns, unset } => {
+ edit_pronouns(&api, pronouns, unset).await?
+ }
+ EditCommand::Location { location, unset } => {
+ edit_location(&api, location, unset).await?
+ }
+ EditCommand::Activity { visibility } => edit_activity(&api, visibility).await?,
+ EditCommand::Email {
+ visibility,
+ add,
+ rm,
+ } => edit_email(&api, visibility, add, rm).await?,
+ EditCommand::Website { url, unset } => edit_website(&api, url, unset).await?,
+ },
+ }
+ Ok(())
+ }
+}
+
+async fn user_search(api: &Forgejo, query: &str, page: Option<usize>) -> eyre::Result<()> {
+ let page = page.unwrap_or(1);
+ if page == 0 {
+ println!("There is no page 0");
+ }
+ let query = forgejo_api::structs::UserSearchQuery {
+ q: Some(query.to_owned()),
+ ..Default::default()
+ };
+ let result = api.user_search(query).await?;
+ let users = result.data.ok_or_eyre("search did not return data")?;
+ let ok = result.ok.ok_or_eyre("search did not return ok")?;
+ if !ok {
+ println!("Search failed");
+ return Ok(());
+ }
+ if users.is_empty() {
+ println!("No users matched that query");
+ } else {
+ let SpecialRender {
+ bullet,
+ dash,
+ bold,
+ reset,
+ ..
+ } = *crate::special_render();
+ let page_start = (page - 1) * 20;
+ let pages_total = users.len().div_ceil(20);
+ if page_start >= users.len() {
+ if pages_total == 1 {
+ println!("There is only 1 page");
+ } else {
+ println!("There are only {pages_total} pages");
+ }
+ } else {
+ for user in users.iter().skip(page_start).take(20) {
+ let username = user
+ .login
+ .as_deref()
+ .ok_or_eyre("user does not have name")?;
+ println!("{bullet} {bold}{username}{reset}");
+ }
+ println!(
+ "Showing {bold}{}{dash}{}{reset} of {bold}{}{reset} results ({page}/{pages_total})",
+ page_start + 1,
+ (page_start + 20).min(users.len()),
+ users.len()
+ );
+ if users.len() > 20 {
+ println!("View more with the --page flag");
+ }
+ }
+ }
+ Ok(())
+}
+
+async fn view_user(api: &Forgejo, user: Option<&str>) -> eyre::Result<()> {
+ let SpecialRender {
+ bold,
+ dash,
+ bright_cyan,
+ light_grey,
+ reset,
+ ..
+ } = *crate::special_render();
+
+ let user_data = match user {
+ Some(user) => api.user_get(user).await?,
+ None => api.user_get_current().await?,
+ };
+ let username = user_data
+ .login
+ .as_deref()
+ .ok_or_eyre("user has no username")?;
+ print!("{bright_cyan}{bold}{username}{reset}");
+ if let Some(pronouns) = user_data.pronouns.as_deref() {
+ if !pronouns.is_empty() {
+ print!("{light_grey} {dash} {bold}{pronouns}{reset}");
+ }
+ }
+ println!();
+ let followers = user_data.followers_count.unwrap_or_default();
+ let following = user_data.following_count.unwrap_or_default();
+ println!("{bold}{followers}{reset} followers {dash} {bold}{following}{reset} following");
+ let mut first = true;
+ if let Some(website) = user_data.website.as_deref() {
+ if !website.is_empty() {
+ print!("{bold}{website}{reset}");
+ first = false;
+ }
+ }
+ if let Some(email) = user_data.email.as_deref() {
+ if !email.is_empty() && !email.contains("noreply") {
+ if !first {
+ print!(" {dash} ");
+ }
+ print!("{bold}{email}{reset}");
+ }
+ }
+ if !first {
+ println!();
+ }
+
+ if let Some(desc) = user_data.description.as_deref() {
+ if !desc.is_empty() {
+ println!();
+ println!("{}", crate::markdown(desc));
+ println!();
+ }
+ }
+
+ let joined = user_data
+ .created
+ .ok_or_eyre("user does not have join date")?;
+ let date_format = time::macros::format_description!("[month repr:short] [day], [year]");
+ println!("Joined on {bold}{}{reset}", joined.format(&date_format)?);
+
+ Ok(())
+}
+
+async fn browse_user(api: &Forgejo, host_url: &url::Url, user: Option<&str>) -> eyre::Result<()> {
+ let username = match user {
+ Some(user) => user.to_owned(),
+ None => {
+ let myself = api.user_get_current().await?;
+ myself
+ .login
+ .ok_or_eyre("authenticated user does not have login")?
+ }
+ };
+ // `User` doesn't have an `html_url` field, so we gotta construct the user
+ // page url ourselves
+ let mut url = host_url.clone();
+ url.path_segments_mut()
+ .map_err(|_| eyre::eyre!("invalid host url"))?
+ .push(&username);
+ open::that(url.as_str())?;
+
+ Ok(())
+}
+
+async fn follow_user(api: &Forgejo, user: &str) -> eyre::Result<()> {
+ api.user_current_put_follow(user).await?;
+ println!("Followed {user}");
+ Ok(())
+}
+
+async fn unfollow_user(api: &Forgejo, user: &str) -> eyre::Result<()> {
+ api.user_current_delete_follow(user).await?;
+ println!("Unfollowed {user}");
+ Ok(())
+}
+
+async fn list_following(api: &Forgejo, user: Option<&str>) -> eyre::Result<()> {
+ let following = match user {
+ Some(user) => {
+ let query = forgejo_api::structs::UserListFollowingQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ api.user_list_following(user, query).await?
+ }
+ None => {
+ let query = forgejo_api::structs::UserCurrentListFollowingQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ api.user_current_list_following(query).await?
+ }
+ };
+
+ if following.is_empty() {
+ match user {
+ Some(name) => println!("{name} isn't following anyone"),
+ None => println!("You aren't following anyone"),
+ }
+ } else {
+ match user {
+ Some(name) => println!("{name} is following:"),
+ None => println!("You are following:"),
+ }
+ let SpecialRender { bullet, .. } = *crate::special_render();
+
+ for followed in following {
+ let username = followed
+ .login
+ .as_deref()
+ .ok_or_eyre("user does not have username")?;
+ println!("{bullet} {username}");
+ }
+ }
+
+ Ok(())
+}
+
+async fn list_followers(api: &Forgejo, user: Option<&str>) -> eyre::Result<()> {
+ let followers = match user {
+ Some(user) => {
+ let query = forgejo_api::structs::UserListFollowersQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ api.user_list_followers(user, query).await?
+ }
+ None => {
+ let query = forgejo_api::structs::UserCurrentListFollowersQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ api.user_current_list_followers(query).await?
+ }
+ };
+
+ if followers.is_empty() {
+ match user {
+ Some(name) => println!("{name} has no followers"),
+ None => println!("You have no followers :("),
+ }
+ } else {
+ match user {
+ Some(name) => println!("{name} is followed by:"),
+ None => println!("You are followed by:"),
+ }
+ let SpecialRender { bullet, .. } = *crate::special_render();
+
+ for follower in followers {
+ let username = follower
+ .login
+ .as_deref()
+ .ok_or_eyre("user does not have username")?;
+ println!("{bullet} {username}");
+ }
+ }
+
+ Ok(())
+}
+
+async fn block_user(api: &Forgejo, user: &str) -> eyre::Result<()> {
+ api.user_block_user(user).await?;
+ println!("Blocked {user}");
+ Ok(())
+}
+
+async fn unblock_user(api: &Forgejo, user: &str) -> eyre::Result<()> {
+ api.user_unblock_user(user).await?;
+ println!("Unblocked {user}");
+ Ok(())
+}
+
+#[derive(clap::ValueEnum, Clone, Debug, Default)]
+pub enum RepoSortOrder {
+ #[default]
+ Name,
+ Modified,
+ Created,
+ Stars,
+ Forks,
+}
+
+async fn list_repos(
+ api: &Forgejo,
+ user: Option<&str>,
+ starred: bool,
+ sort: Option<RepoSortOrder>,
+) -> eyre::Result<()> {
+ let mut repos = if starred {
+ match user {
+ Some(user) => {
+ let query = forgejo_api::structs::UserListStarredQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ api.user_list_starred(user, query).await?
+ }
+ None => {
+ let query = forgejo_api::structs::UserCurrentListStarredQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ api.user_current_list_starred(query).await?
+ }
+ }
+ } else {
+ match user {
+ Some(user) => {
+ let query = forgejo_api::structs::UserListReposQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ api.user_list_repos(user, query).await?
+ }
+ None => {
+ let query = forgejo_api::structs::UserCurrentListReposQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ api.user_current_list_repos(query).await?
+ }
+ }
+ };
+
+ if repos.is_empty() {
+ if starred {
+ match user {
+ Some(user) => println!("{user} has not starred any repos"),
+ None => println!("You have not starred any repos"),
+ }
+ } else {
+ match user {
+ Some(user) => println!("{user} does not own any repos"),
+ None => println!("You do not own any repos"),
+ }
+ };
+ } else {
+ let sort_fn: fn(
+ &forgejo_api::structs::Repository,
+ &forgejo_api::structs::Repository,
+ ) -> std::cmp::Ordering = match sort.unwrap_or_default() {
+ RepoSortOrder::Name => |a, b| a.full_name.cmp(&b.full_name),
+ RepoSortOrder::Modified => |a, b| b.updated_at.cmp(&a.updated_at),
+ RepoSortOrder::Created => |a, b| b.created_at.cmp(&a.created_at),
+ RepoSortOrder::Stars => |a, b| b.stars_count.cmp(&a.stars_count),
+ RepoSortOrder::Forks => |a, b| b.forks_count.cmp(&a.forks_count),
+ };
+ repos.sort_unstable_by(sort_fn);
+
+ let SpecialRender { bullet, .. } = *crate::special_render();
+ for repo in &repos {
+ let name = repo
+ .full_name
+ .as_deref()
+ .ok_or_eyre("repo does not have name")?;
+ println!("{bullet} {name}");
+ }
+ if repos.len() == 1 {
+ println!("1 repo");
+ } else {
+ println!("{} repos", repos.len());
+ }
+ }
+
+ Ok(())
+}
+
+async fn list_orgs(api: &Forgejo, user: Option<&str>) -> eyre::Result<()> {
+ let mut orgs = match user {
+ Some(user) => {
+ let query = forgejo_api::structs::OrgListUserOrgsQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ api.org_list_user_orgs(user, query).await?
+ }
+ None => {
+ let query = forgejo_api::structs::OrgListCurrentUserOrgsQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ api.org_list_current_user_orgs(query).await?
+ }
+ };
+
+ if orgs.is_empty() {
+ match user {
+ Some(user) => println!("{user} is not a member of any organizations"),
+ None => println!("You are not a member of any organizations"),
+ }
+ } else {
+ orgs.sort_unstable_by(|a, b| a.name.cmp(&b.name));
+
+ let SpecialRender { bullet, dash, .. } = *crate::special_render();
+ for org in &orgs {
+ let name = org.name.as_deref().ok_or_eyre("org does not have name")?;
+ let full_name = org
+ .full_name
+ .as_deref()
+ .ok_or_eyre("org does not have name")?;
+ if !full_name.is_empty() {
+ println!("{bullet} {name} {dash} \"{full_name}\"");
+ } else {
+ println!("{bullet} {name}");
+ }
+ }
+ if orgs.len() == 1 {
+ println!("1 organization");
+ } else {
+ println!("{} organizations", orgs.len());
+ }
+ }
+ Ok(())
+}
+
+async fn list_activity(api: &Forgejo, user: Option<&str>) -> eyre::Result<()> {
+ let user = match user {
+ Some(s) => s.to_owned(),
+ None => {
+ let myself = api.user_get_current().await?;
+ myself.login.ok_or_eyre("current user does not have name")?
+ }
+ };
+ let query = forgejo_api::structs::UserListActivityFeedsQuery {
+ only_performed_by: Some(true),
+ ..Default::default()
+ };
+ let feed = api.user_list_activity_feeds(&user, query).await?;
+
+ let SpecialRender {
+ bold,
+ yellow,
+ bright_cyan,
+ reset,
+ ..
+ } = *crate::special_render();
+
+ for activity in feed {
+ let actor = activity
+ .act_user
+ .as_ref()
+ .ok_or_eyre("activity does not have actor")?;
+ let actor_name = actor
+ .login
+ .as_deref()
+ .ok_or_eyre("actor does not have name")?;
+ let op_type = activity
+ .op_type
+ .as_ref()
+ .ok_or_eyre("activity does not have op type")?;
+
+ // do not add ? to these. they are here to make each branch smaller
+ let repo = activity
+ .repo
+ .as_ref()
+ .ok_or_eyre("activity does not have repo");
+ let content = activity
+ .content
+ .as_deref()
+ .ok_or_eyre("activity does not have content");
+ let ref_name = activity
+ .ref_name
+ .as_deref()
+ .ok_or_eyre("repo does not have full name");
+
+ fn issue_name<'a, 'b>(
+ repo: &'a forgejo_api::structs::Repository,
+ content: &'b str,
+ ) -> eyre::Result<(&'a str, &'b str)> {
+ let full_name = repo
+ .full_name
+ .as_deref()
+ .ok_or_eyre("repo does not have full name")?;
+ let (issue_id, _issue_name) = content.split_once("|").unwrap_or((content, ""));
+ Ok((full_name, issue_id))
+ }
+
+ print!("");
+ use forgejo_api::structs::ActivityOpType;
+ match op_type {
+ ActivityOpType::CreateRepo => {
+ let repo = repo?;
+ let full_name = repo
+ .full_name
+ .as_deref()
+ .ok_or_eyre("repo does not have full name")?;
+ if let Some(parent) = &repo.parent {
+ let parent_full_name = parent
+ .full_name
+ .as_deref()
+ .ok_or_eyre("parent repo does not have full name")?;
+ println!("{bold}{actor_name}{reset} forked repository {bold}{yellow}{parent_full_name}{reset} to {bold}{yellow}{full_name}{reset}");
+ } else {
+ if repo.mirror.is_some_and(|b| b) {
+ println!("{bold}{actor_name}{reset} created mirror {bold}{yellow}{full_name}{reset}");
+ } else {
+ println!("{bold}{actor_name}{reset} created repository {bold}{yellow}{full_name}{reset}");
+ }
+ }
+ }
+ ActivityOpType::RenameRepo => {
+ let repo = repo?;
+ let content = content?;
+ let full_name = repo
+ .full_name
+ .as_deref()
+ .ok_or_eyre("repo does not have full name")?;
+ println!("{bold}{actor_name}{reset} renamed repository from {bold}{yellow}\"{content}\"{reset} to {bold}{yellow}{full_name}{reset}");
+ }
+ ActivityOpType::StarRepo => {
+ let repo = repo?;
+ let full_name = repo
+ .full_name
+ .as_deref()
+ .ok_or_eyre("repo does not have full name")?;
+ println!(
+ "{bold}{actor_name}{reset} starred repository {bold}{yellow}{full_name}{reset}"
+ );
+ }
+ ActivityOpType::WatchRepo => {
+ let repo = repo?;
+ let full_name = repo
+ .full_name
+ .as_deref()
+ .ok_or_eyre("repo does not have full name")?;
+ println!(
+ "{bold}{actor_name}{reset} watched repository {bold}{yellow}{full_name}{reset}"
+ );
+ }
+ ActivityOpType::CommitRepo => {
+ let repo = repo?;
+ let full_name = repo
+ .full_name
+ .as_deref()
+ .ok_or_eyre("repo does not have full name")?;
+ let ref_name = ref_name?;
+ let branch = ref_name
+ .rsplit_once("/")
+ .map(|(_, b)| b)
+ .unwrap_or(ref_name);
+ if !content?.is_empty() {
+ println!("{bold}{actor_name}{reset} pushed to {bold}{bright_cyan}{branch}{reset} on {bold}{yellow}{full_name}{reset}");
+ }
+ }
+ ActivityOpType::CreateIssue => {
+ let (name, id) = issue_name(repo?, content?)?;
+ println!("{bold}{actor_name}{reset} opened issue {bold}{yellow}{name}#{id}{reset}");
+ }
+ ActivityOpType::CreatePullRequest => {
+ let (name, id) = issue_name(repo?, content?)?;
+ println!("{bold}{actor_name}{reset} created pull request {bold}{yellow}{name}#{id}{reset}");
+ }
+ ActivityOpType::TransferRepo => {
+ let repo = repo?;
+ let full_name = repo
+ .full_name
+ .as_deref()
+ .ok_or_eyre("repo does not have full name")?;
+ let content = content?;
+ println!("{bold}{actor_name}{reset} transfered repository {bold}{yellow}{content}{reset} to {bold}{yellow}{full_name}{reset}");
+ }
+ ActivityOpType::PushTag => {
+ let repo = repo?;
+ let full_name = repo
+ .full_name
+ .as_deref()
+ .ok_or_eyre("repo does not have full name")?;
+ let ref_name = ref_name?;
+ let tag = ref_name
+ .rsplit_once("/")
+ .map(|(_, b)| b)
+ .unwrap_or(ref_name);
+ println!("{bold}{actor_name}{reset} pushed tag {bold}{bright_cyan}{tag}{reset} to {bold}{yellow}{full_name}{reset}");
+ }
+ ActivityOpType::CommentIssue => {
+ let (name, id) = issue_name(repo?, content?)?;
+ println!(
+ "{bold}{actor_name}{reset} commented on issue {bold}{yellow}{name}#{id}{reset}"
+ );
+ }
+ ActivityOpType::MergePullRequest | ActivityOpType::AutoMergePullRequest => {
+ let (name, id) = issue_name(repo?, content?)?;
+ println!("{bold}{actor_name}{reset} merged pull request {bold}{yellow}{name}#{id}{reset}");
+ }
+ ActivityOpType::CloseIssue => {
+ let (name, id) = issue_name(repo?, content?)?;
+ println!("{bold}{actor_name}{reset} closed issue {bold}{yellow}{name}#{id}{reset}");
+ }
+ ActivityOpType::ReopenIssue => {
+ let (name, id) = issue_name(repo?, content?)?;
+ println!(
+ "{bold}{actor_name}{reset} reopened issue {bold}{yellow}{name}#{id}{reset}"
+ );
+ }
+ ActivityOpType::ClosePullRequest => {
+ let (name, id) = issue_name(repo?, content?)?;
+ println!("{bold}{actor_name}{reset} closed pull request {bold}{yellow}{name}#{id}{reset}");
+ }
+ ActivityOpType::ReopenPullRequest => {
+ let (name, id) = issue_name(repo?, content?)?;
+ println!("{bold}{actor_name}{reset} reopened pull request {bold}{yellow}{name}#{id}{reset}");
+ }
+ ActivityOpType::DeleteTag => {
+ let repo = repo?;
+ let full_name = repo
+ .full_name
+ .as_deref()
+ .ok_or_eyre("repo does not have full name")?;
+ let ref_name = ref_name?;
+ let tag = ref_name
+ .rsplit_once("/")
+ .map(|(_, b)| b)
+ .unwrap_or(ref_name);
+ println!("{bold}{actor_name}{reset} deleted tag {bold}{bright_cyan}{tag}{reset} from {bold}{yellow}{full_name}{reset}");
+ }
+ ActivityOpType::DeleteBranch => {
+ let repo = repo?;
+ let full_name = repo
+ .full_name
+ .as_deref()
+ .ok_or_eyre("repo does not have full name")?;
+ let ref_name = ref_name?;
+ let branch = ref_name
+ .rsplit_once("/")
+ .map(|(_, b)| b)
+ .unwrap_or(ref_name);
+ println!("{bold}{actor_name}{reset} deleted branch {bold}{bright_cyan}{branch}{reset} from {bold}{yellow}{full_name}{reset}");
+ }
+ ActivityOpType::MirrorSyncPush => {}
+ ActivityOpType::MirrorSyncCreate => {}
+ ActivityOpType::MirrorSyncDelete => {}
+ ActivityOpType::ApprovePullRequest => {
+ let (name, id) = issue_name(repo?, content?)?;
+ println!("{bold}{actor_name}{reset} approved {bold}{yellow}{name}#{id}{reset}");
+ }
+ ActivityOpType::RejectPullRequest => {
+ let (name, id) = issue_name(repo?, content?)?;
+ println!("{bold}{actor_name}{reset} suggested changes for {bold}{yellow}{name}#{id}{reset}");
+ }
+ ActivityOpType::CommentPull => {
+ let (name, id) = issue_name(repo?, content?)?;
+ println!("{bold}{actor_name}{reset} commented on pull request {bold}{yellow}{name}#{id}{reset}");
+ }
+ ActivityOpType::PublishRelease => {
+ let repo = repo?;
+ let full_name = repo
+ .full_name
+ .as_deref()
+ .ok_or_eyre("repo does not have full name")?;
+ let content = content?;
+ println!("{bold}{actor_name}{reset} created release {bold}{bright_cyan}\"{content}\"{reset} to {bold}{yellow}{full_name}{reset}");
+ }
+ ActivityOpType::PullReviewDismissed => {}
+ ActivityOpType::PullRequestReadyForReview => {}
+ }
+ }
+ Ok(())
+}
+
+fn default_settings_opt() -> forgejo_api::structs::UserSettingsOptions {
+ forgejo_api::structs::UserSettingsOptions {
+ description: None,
+ diff_view_style: None,
+ enable_repo_unit_hints: None,
+ full_name: None,
+ hide_activity: None,
+ hide_email: None,
+ language: None,
+ location: None,
+ pronouns: None,
+ theme: None,
+ website: None,
+ }
+}
+
+async fn edit_bio(api: &Forgejo, new_bio: Option<String>) -> eyre::Result<()> {
+ let new_bio = match new_bio {
+ Some(s) => s,
+ None => {
+ let mut bio = api
+ .user_get_current()
+ .await?
+ .description
+ .unwrap_or_default();
+ crate::editor(&mut bio, Some("md")).await?;
+ bio
+ }
+ };
+ let opt = forgejo_api::structs::UserSettingsOptions {
+ description: Some(new_bio),
+ ..default_settings_opt()
+ };
+ api.update_user_settings(opt).await?;
+ Ok(())
+}
+
+async fn edit_name(api: &Forgejo, new_name: Option<String>, unset: bool) -> eyre::Result<()> {
+ match (new_name, unset) {
+ (Some(_), true) => unreachable!(),
+ (Some(name), false) if !name.is_empty() => {
+ let opt = forgejo_api::structs::UserSettingsOptions {
+ full_name: Some(name),
+ ..default_settings_opt()
+ };
+ api.update_user_settings(opt).await?;
+ }
+ (None, true) => {
+ let opt = forgejo_api::structs::UserSettingsOptions {
+ full_name: Some(String::new()),
+ ..default_settings_opt()
+ };
+ api.update_user_settings(opt).await?;
+ }
+ _ => println!("Use --unset to remove your name from your profile"),
+ }
+ Ok(())
+}
+
+async fn edit_pronouns(
+ api: &Forgejo,
+ new_pronouns: Option<String>,
+ unset: bool,
+) -> eyre::Result<()> {
+ match (new_pronouns, unset) {
+ (Some(_), true) => unreachable!(),
+ (Some(pronouns), false) if !pronouns.is_empty() => {
+ let opt = forgejo_api::structs::UserSettingsOptions {
+ pronouns: Some(pronouns),
+ ..default_settings_opt()
+ };
+ api.update_user_settings(opt).await?;
+ }
+ (None, true) => {
+ let opt = forgejo_api::structs::UserSettingsOptions {
+ pronouns: Some(String::new()),
+ ..default_settings_opt()
+ };
+ api.update_user_settings(opt).await?;
+ }
+ _ => println!("Use --unset to remove your pronouns from your profile"),
+ }
+ Ok(())
+}
+
+async fn edit_location(
+ api: &Forgejo,
+ new_location: Option<String>,
+ unset: bool,
+) -> eyre::Result<()> {
+ match (new_location, unset) {
+ (Some(_), true) => unreachable!(),
+ (Some(location), false) if !location.is_empty() => {
+ let opt = forgejo_api::structs::UserSettingsOptions {
+ location: Some(location),
+ ..default_settings_opt()
+ };
+ api.update_user_settings(opt).await?;
+ }
+ (None, true) => {
+ let opt = forgejo_api::structs::UserSettingsOptions {
+ location: Some(String::new()),
+ ..default_settings_opt()
+ };
+ api.update_user_settings(opt).await?;
+ }
+ _ => println!("Use --unset to remove your location from your profile"),
+ }
+ Ok(())
+}
+
+async fn edit_activity(api: &Forgejo, visibility: VisbilitySetting) -> eyre::Result<()> {
+ let opt = forgejo_api::structs::UserSettingsOptions {
+ hide_activity: Some(visibility == VisbilitySetting::Hidden),
+ ..default_settings_opt()
+ };
+ api.update_user_settings(opt).await?;
+ Ok(())
+}
+
+async fn edit_email(
+ api: &Forgejo,
+ visibility: Option<VisbilitySetting>,
+ add: Vec<String>,
+ rm: Vec<String>,
+) -> eyre::Result<()> {
+ if let Some(vis) = visibility {
+ let opt = forgejo_api::structs::UserSettingsOptions {
+ hide_activity: Some(vis == VisbilitySetting::Hidden),
+ ..default_settings_opt()
+ };
+ api.update_user_settings(opt).await?;
+ }
+ if !add.is_empty() {
+ let opt = forgejo_api::structs::CreateEmailOption { emails: Some(add) };
+ api.user_add_email(opt).await?;
+ }
+ if !rm.is_empty() {
+ let opt = forgejo_api::structs::DeleteEmailOption { emails: Some(rm) };
+ api.user_delete_email(opt).await?;
+ }
+ Ok(())
+}
+
+async fn edit_website(api: &Forgejo, new_url: Option<String>, unset: bool) -> eyre::Result<()> {
+ match (new_url, unset) {
+ (Some(_), true) => unreachable!(),
+ (Some(url), false) if !url.is_empty() => {
+ let opt = forgejo_api::structs::UserSettingsOptions {
+ website: Some(url),
+ ..default_settings_opt()
+ };
+ api.update_user_settings(opt).await?;
+ }
+ (None, true) => {
+ let opt = forgejo_api::structs::UserSettingsOptions {
+ website: Some(String::new()),
+ ..default_settings_opt()
+ };
+ api.update_user_settings(opt).await?;
+ }
+ _ => println!("Use --unset to remove your name from your profile"),
+ }
+ Ok(())
+}
diff --git a/src/wiki.rs b/src/wiki.rs
new file mode 100644
index 0000000..63d344f
--- /dev/null
+++ b/src/wiki.rs
@@ -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())?;
+ 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(html_url.as_str())?;
+ 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(())
+}