Sign Up
Log In
Log In
or
Sign Up
Places
All Projects
Status Monitor
Collapse sidebar
openSUSE:Backports:SLE-15-SP4:FactoryCandidates
forgejo-cli
forgejo-cli-0.1.1.obscpio
Overview
Repositories
Revisions
Requests
Users
Attributes
Meta
File forgejo-cli-0.1.1.obscpio of Package forgejo-cli
07070100000000000081A400000000000000000000000166B6670E00000008000000000000000000000000000000000000001D00000000forgejo-cli-0.1.1/.gitignore/target 07070100000001000041ED00000000000000000000000266B6670E00000000000000000000000000000000000000000000001E00000000forgejo-cli-0.1.1/.woodpecker07070100000002000081A400000000000000000000000166B6670E000000DC000000000000000000000000000000000000002800000000forgejo-cli-0.1.1/.woodpecker/check.ymlwhen: - event: manual - event: pull_request steps: check: image: rust commands: - cargo check check-fmt: image: rust commands: - rustup component add rustfmt - cargo fmt --check 07070100000003000081A400000000000000000000000166B6670E000007A7000000000000000000000000000000000000002900000000forgejo-cli-0.1.1/.woodpecker/deploy.ymlwhen: - 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 ] 07070100000004000081A400000000000000000000000166B6670E0000ED36000000000000000000000000000000000000001D00000000forgejo-cli-0.1.1/Cargo.lock# 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" 07070100000005000081A400000000000000000000000166B6670E000004D3000000000000000000000000000000000000001D00000000forgejo-cli-0.1.1/Cargo.toml[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"] } 07070100000006000081A400000000000000000000000166B6670E0000008E000000000000000000000000000000000000001D00000000forgejo-cli-0.1.1/DockerfileFROM 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 07070100000007000081A400000000000000000000000166B6670E00002C5D000000000000000000000000000000000000002100000000forgejo-cli-0.1.1/LICENSE-APACHE 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. 07070100000008000081A400000000000000000000000166B6670E0000042D000000000000000000000000000000000000001E00000000forgejo-cli-0.1.1/LICENSE-MITMIT 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. 07070100000009000081A400000000000000000000000166B6670E00000841000000000000000000000000000000000000001C00000000forgejo-cli-0.1.1/README.md# 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. 0707010000000A000041ED00000000000000000000000266B6670E00000000000000000000000000000000000000000000001600000000forgejo-cli-0.1.1/src0707010000000B000081A400000000000000000000000166B6670E000022A0000000000000000000000000000000000000001E00000000forgejo-cli-0.1.1/src/auth.rsuse 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) } 0707010000000C000081A400000000000000000000000166B6670E0000490A000000000000000000000000000000000000002000000000forgejo-cli-0.1.1/src/issues.rsuse 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(()) } 0707010000000D000081A400000000000000000000000166B6670E0000119B000000000000000000000000000000000000001E00000000forgejo-cli-0.1.1/src/keys.rsuse 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) } } } } 0707010000000E000081A400000000000000000000000166B6670E00005DD4000000000000000000000000000000000000001E00000000forgejo-cli-0.1.1/src/main.rsuse 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(()) } } 0707010000000F000081A400000000000000000000000166B6670E0000C5B4000000000000000000000000000000000000001D00000000forgejo-cli-0.1.1/src/prs.rsuse 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 //} 07070100000010000081A400000000000000000000000166B6670E0000490D000000000000000000000000000000000000002100000000forgejo-cli-0.1.1/src/release.rsuse 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(()) } 07070100000011000081A400000000000000000000000166B6670E0000635A000000000000000000000000000000000000001E00000000forgejo-cli-0.1.1/src/repo.rsuse 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(()) } 07070100000012000081A400000000000000000000000166B6670E000089EC000000000000000000000000000000000000001E00000000forgejo-cli-0.1.1/src/user.rsuse 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(()) } 07070100000013000081A400000000000000000000000166B6670E000011A4000000000000000000000000000000000000001E00000000forgejo-cli-0.1.1/src/wiki.rsuse 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(()) } 07070100000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000B00000000TRAILER!!!533 blocks
Locations
Projects
Search
Status Monitor
Help
OpenBuildService.org
Documentation
API Documentation
Code of Conduct
Contact
Support
@OBShq
Terms
openSUSE Build Service is sponsored by
The Open Build Service is an
openSUSE project
.
Sign Up
Log In
Places
Places
All Projects
Status Monitor