diff options
author | Daniel Baumann <daniel@debian.org> | 2024-11-04 11:30:10 +0100 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-11-20 07:38:21 +0100 |
commit | 34f503aa3bfba930fd7978a0071786884d73749f (patch) | |
tree | 7e3c8e2506fdd93e29958d9f8cb36fbed4a5af7d | |
parent | Initial commit. (diff) | |
download | forgejo-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.json | 6 | ||||
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | .woodpecker/check.yml | 13 | ||||
-rw-r--r-- | .woodpecker/deploy.yml | 48 | ||||
-rw-r--r-- | Cargo.lock | 2401 | ||||
-rw-r--r-- | Cargo.toml | 125 | ||||
-rw-r--r-- | Cargo.toml.orig | 44 | ||||
-rw-r--r-- | Dockerfile | 4 | ||||
-rw-r--r-- | LICENSE-APACHE | 201 | ||||
-rw-r--r-- | LICENSE-MIT | 21 | ||||
-rw-r--r-- | README.md | 86 | ||||
-rw-r--r-- | src/auth.rs | 236 | ||||
-rw-r--r-- | src/issues.rs | 674 | ||||
-rw-r--r-- | src/keys.rs | 129 | ||||
-rw-r--r-- | src/main.rs | 800 | ||||
-rw-r--r-- | src/prs.rs | 1564 | ||||
-rw-r--r-- | src/release.rs | 614 | ||||
-rw-r--r-- | src/repo.rs | 766 | ||||
-rw-r--r-- | src/user.rs | 1019 | ||||
-rw-r--r-- | src/wiki.rs | 158 |
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(()) +} |