diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7267a65..469e9c9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,22 +12,17 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - neovim_version: ['nightly'] + neovim_version: ['stable', 'nightly'] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - run: date +%F > todays-date - name: Restore cache for today's nightly. - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: _neovim key: ${{ runner.os }}-x64-${{ hashFiles('todays-date') }} - - name: Prepare plenary - run: | - git clone --depth 1 https://github.com/nvim-lua/plenary.nvim ~/.local/share/nvim/site/pack/vendor/start/plenary.nvim - ln -s "$(pwd)" ~/.local/share/nvim/site/pack/vendor/start - - name: Build rust module run: | sudo apt install -y libluajit-5.1-dev @@ -40,5 +35,4 @@ jobs: version: ${{ matrix.neovim_version }} - name: Run tests - run: | - nvim --headless --noplugin -u tests/minimal.vim -c "PlenaryBustedDirectory tests/ {minimal_init = 'tests/minimal.vim'}" + run: make test diff --git a/.gitignore b/.gitignore index 2cf7b49..c1be013 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ -lua/deps -lua/libgh_actions_rust.so +lua/pipeline_native/deps +lua/pipeline_native/yaml.so target + +.tests diff --git a/.luacheckrc b/.luacheckrc index 490f98f..9d9b0f2 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -1,5 +1,6 @@ ignore = { - "631", -- max_line_length + '212', -- unused_argument + '631', -- max_line_length } read_globals = { vim = { @@ -15,7 +16,7 @@ read_globals = { }, }, }, - "describe", - "it", - "assert", + 'describe', + 'it', + 'assert', } diff --git a/Cargo.lock b/Cargo.lock index 5077b4f..a4cab52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,276 +3,284 @@ version = 3 [[package]] -name = "aho-corasick" -version = "0.7.20" +name = "autocfg" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac" -dependencies = [ - "memchr", -] +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] -name = "autocfg" -version = "1.1.0" +name = "bitflags" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" [[package]] name = "bstr" -version = "0.2.17" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3569f383e8f1598449f1a423e72e99569137b47740b1da11ef19af3d5c3223" +checksum = "1a68f1f47cdf0ec8ee4b941b2eee2a80cb796db73118c0dd09ac63fbe405be22" dependencies = [ "memchr", + "serde", ] [[package]] name = "cc" -version = "1.0.79" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" - -[[package]] -name = "either" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" - -[[package]] -name = "erased-serde" -version = "0.3.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2b0c2380453a92ea8b6c8e5f64ecaafccddde8ceab55ff7a8ac1029f894569" +checksum = "fd9de9f2205d5ef3fd67e685b0df337994ddd4495e2a28d185500d0e1edfea47" dependencies = [ - "serde", + "shlex", ] [[package]] -name = "futures-core" -version = "0.3.28" +name = "cfg-if" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] -name = "futures-macro" -version = "0.3.28" +name = "either" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.15", -] +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" [[package]] -name = "futures-task" -version = "0.3.28" +name = "equivalent" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] -name = "futures-util" -version = "0.3.28" +name = "erased-serde" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" -dependencies = [ - "futures-core", - "futures-macro", - "futures-task", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "gh-actions-rust" -version = "0.0.1" +checksum = "24e2389d65ab4fab27dc2a5de7b191e1f6617d1f1c8855c0dc569c94a4cbb18d" dependencies = [ - "mlua", "serde", - "serde_yaml", + "typeid", ] [[package]] name = "hashbrown" -version = "0.12.3" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +checksum = "3a9bfc1af68b1726ea47d3d5109de126281def866b33970e10fbab11b5dafab3" [[package]] name = "indexmap" -version = "1.9.3" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ - "autocfg", + "equivalent", "hashbrown", ] [[package]] -name = "itertools" -version = "0.10.5" +name = "itoa" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] +checksum = "7a73e9fe3c49d7afb2ace819fa181a287ce54a0983eda4e0eb05c22f82ffe534" [[package]] -name = "itoa" -version = "1.0.6" +name = "libc" +version = "0.2.164" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6" +checksum = "433bfe06b8c75da9b2e3fbea6e5329ff87748f0b144ef75306e674c3f6f7c13f" + +[[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 = "memchr" -version = "2.5.0" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "mlua" -version = "0.6.6" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4235d7e740d73d7429df6f176c81b248f05c39d67264d45a7d8cecb67c227f6f" +checksum = "0ae9546e4a268c309804e8bbb7526e31cbfdedca7cd60ac1b987d0b212e0d876" dependencies = [ "bstr", - "cc", + "either", "erased-serde", - "futures-core", - "futures-task", - "futures-util", + "mlua-sys", "mlua_derive", "num-traits", - "once_cell", - "pkg-config", + "parking_lot", + "rustc-hash", "serde", + "serde-value", +] + +[[package]] +name = "mlua-sys" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efa6bf1a64f06848749b7e7727417f4ec2121599e2a10ef0a8a3888b0e9a5a0d" +dependencies = [ + "cc", + "cfg-if", + "pkg-config", ] [[package]] name = "mlua_derive" -version = "0.6.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1713774a29db53a48932596dc943439dd54eb56a9efaace716719cc10fa82d5b" +checksum = "2cfc5faa2e0d044b3f5f0879be2920e0a711c97744c42cf1c295cb183668933e" dependencies = [ - "itertools", - "once_cell", - "proc-macro-error", "proc-macro2", "quote", - "regex", - "syn 1.0.109", + "syn", ] [[package]] name = "num-traits" -version = "0.2.15" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] [[package]] -name = "once_cell" -version = "1.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" - -[[package]] -name = "pin-project-lite" -version = "0.2.9" +name = "ordered-float" +version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] [[package]] -name = "pin-utils" -version = "0.1.0" +name = "parking_lot" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] [[package]] -name = "pkg-config" -version = "0.3.26" +name = "parking_lot_core" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] [[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +name = "pipeline_native" +version = "0.0.1" dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", + "mlua", + "serde", + "serde_yaml", ] [[package]] -name = "proc-macro-error-attr" -version = "1.0.4" +name = "pkg-config" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" [[package]] name = "proc-macro2" -version = "1.0.56" +version = "1.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b63bdb0cd06f1f4dedf69b254734f9b45af66e4a031e42a7480257d9898b435" +checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.26" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4424af4bf778aae2051a77b60283332f386554255d722233d09fbfc7e30da2fc" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" dependencies = [ "proc-macro2", ] [[package]] -name = "regex" -version = "1.7.3" +name = "redox_syscall" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1f693b24f6ac912f4893ef08244d70b6067480d2f1a46e950c9691e6749d1d" +checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", + "bitflags", ] [[package]] -name = "regex-syntax" -version = "0.6.29" +name = "rustc-hash" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" +checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" [[package]] name = "ryu" -version = "1.0.13" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "scopeguard" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.160" +version = "1.0.215" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb2f3770c8bce3bcda7e149193a069a0f4365bda1fa5cd88e03bca26afc1216c" +checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.215" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "serde_yaml" -version = "0.9.21" +version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9d684e3ec7de3bf5466b32bd75303ac16f0736426e5a4e0d6e489559ce1249c" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ "indexmap", "itoa", @@ -282,41 +290,39 @@ dependencies = [ ] [[package]] -name = "slab" -version = "0.4.8" +name = "shlex" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6528351c9bc8ab22353f9d776db39a20288e8d6c37ef8cfe3317cf875eecfc2d" -dependencies = [ - "autocfg", -] +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] -name = "syn" -version = "1.0.109" +name = "smallvec" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "syn" -version = "2.0.15" +version = "2.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a34fcf3e8b60f57e6a14301a2e916d323af98b0ea63c599441eec8558660c822" +checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "typeid" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e13db2e0ccd5e14a544e8a246ba2312cd25223f616442d7f2cb0e3db614236e" + [[package]] name = "unicode-ident" -version = "1.0.8" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5464a87b239f13a63a501f2701565754bae92d243d4bb7eb12f6d57d2269bf4" +checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" [[package]] name = "unsafe-libyaml" @@ -325,7 +331,65 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" [[package]] -name = "version_check" -version = "0.9.4" +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/Cargo.toml b/Cargo.toml index 0e4712b..fc14f74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = 'gh-actions-rust' +name = 'pipeline_native' version = '0.0.1' edition = '2021' @@ -7,6 +7,6 @@ edition = '2021' crate-type = ["cdylib"] [dependencies] -mlua = {version = "0.6", features = ["luajit", "module", "macros", "async", "serialize"]} +mlua = { version = "0.10", features = ["luajit", "module", "serialize"] } serde = "1.0" serde_yaml = "0.9" diff --git a/Makefile b/Makefile index 212fef8..7dd872d 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,35 @@ -.PHONY: build +.PHONY: all build clean prepare_test test + +all: build copy + +clean: + rm -rf ./lua/pipeline_native/yaml.so ./lua/pipeline_native/deps + +depsdir: + mkdir -p ./lua/pipeline_native/deps build: cargo build --release - mkdir -p ./lua/deps/ - rm -f ./lua/libgh_actions_rust.so - cp ./target/release/libgh_actions_rust.dylib ./lua/libgh_actions_rust.so || true - cp ./target/release/libgh_actions_rust.so ./lua/libgh_actions_rust.so || true - cp ./target/release/deps/*.rlib ./lua/deps/ + +copy: clean depsdir + cp ./target/release/libpipeline_rust.dylib ./lua/pipeline_native/yaml.so || true + cp ./target/release/libpipeline_rust.so ./lua/pipeline_native/yaml.so || true + cp ./target/release/deps/*.rlib ./lua/pipeline_native/deps/ + +plugin_dir := ./.tests/site/pack/deps/start +plugins := $(plugin_dir)/plenary.nvim $(plugin_dir)/nui.nvim + +$(plugin_dir)/plenary.nvim: + git clone --depth 1 https://github.com/nvim-lua/plenary.nvim $(plugin_dir)/plenary.nvim + +$(plugin_dir)/nui.nvim: + git clone --depth 1 https://github.com/MunifTanjim/nui.nvim $(plugin_dir)/nui.nvim + +prepare_test: + mkdir -p $(plugin_dir) + +test: prepare_test $(plugins) + nvim --headless --noplugin -u tests/init.lua -c "PlenaryBustedDirectory tests/ {minimal_init = 'tests/init.lua'}" + +clean-test: + rm -rf ./.tests diff --git a/README.md b/README.md index cf44507..e7648e8 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,27 @@ -# gh-actions.nvim +# pipeline.nvim -The gh-actions plugin for Neovim allows developers to easily manage and dispatch their GitHub Actions workflow runs directly from within the editor. +The pipeline.nvim plugin for Neovim allows developers to easily manage and dispatch their CI/CD Pipelines, like GitHub Actions or Gitlab CI, directly from within the editor.

- Screenshot of gh-actions + Screenshot of pipeline.nvim

+## CI/CD Platform Support + +- [GitHub Actions](https://github.com/features/actions) +- [Gitlab CI/CD](https://docs.gitlab.com/ee/ci/) (fairly untested, feel free to + report bugs or open PRs) + ## Features -- List workflows and their runs for the current repository -- Run/dispatch workflows with `workflow_dispatch` +- List pipelines and their runs for the current repository +- Run/dispatch pipelines with `workflow_dispatch` ## ToDo -- Rerun a failed workflow +- Rerun a failed pipeline or job - Configurable keybindings -- `gw` (goto workflow) should open workflow file in buffer instead of browser -- Allow to cycle between inputs on workflow_dispatch +- Allow to cycle between inputs on dispatch ## Installation @@ -27,62 +32,72 @@ Either have the cli [yq](https://github.com/mikefarah/yq) installed or: - [GNU Make](https://www.gnu.org/software/make/) - [Cargo](https://doc.rust-lang.org/cargo/) +Additionally, the Gitlab provider needs the [`glab`](https://docs.gitlab.com/ee/editor_extensions/gitlab_cli/) cli to be installed. + ### lazy.nvim Using [lazy.nvim](https://github.com/folke/lazy.nvim) ```lua { - 'topaxi/gh-actions.nvim', + 'topaxi/pipeline.nvim', keys = { - { 'gh', 'GhActions', desc = 'Open Github Actions' }, + { 'ci', 'Pipeline', desc = 'Open pipeline.nvim' }, }, -- optional, you can also install and use `yq` instead. build = 'make', - ---@type GhActionsConfig + ---@type pipeline.Config opts = {}, }, ``` ## Authentication +### GitHub + The plugin requires authentication with your GitHub account to access your workflows and runs. You can authenticate by running the `gh auth login command` in your terminal and following the prompts. Alternatively, define a `GITHUB_TOKEN` variable in your environment. +### Gitlab + +The plugin interacts with Gitlab via the `glab` cli, all that is needed is being authenticated through `glab auth login`. + ## Usage ### Commands -- `:GhActions` or `:GhActions toggle` toggles the `gh-actions` split -- `:GhActions open` opens the `gh-actions` split -- `:GhActions close` closes the `gh-actions` split +- `:Pipeline` or `:Pipeline toggle` toggles the `pipeline.nvim` split +- `:Pipeline open` opens the `pipeline.nvim` split +- `:Pipeline close` closes the `pipeline.nvim` split ### Keybindings The following keybindings are provided by the plugin: -- `q` - closes the `gh-actions` the split -- `gw` - open the workflow file below the cursor on GitHub -- `gr` - open the workflow run below the cursor on GitHub +- `q` - closes the `pipeline.nvim` the split +- `gp` - open the pipeline below the cursor on GitHub +- `gr` - open the run below the cursor on GitHub - `gj` - open the job of the workflow run below the cursor on GitHub - `d` - dispatch a new run for the workflow below the cursor on GitHub ### Options -The default options (as defined in [lua/config.lua](./blob/main/lua/gh-actions/config.lua)) +The default options (as defined in [lua/config.lua](./blob/main/lua/pipeline/config.lua)) ```lua { --- The browser executable path to open workflow runs/jobs in - ---@type string|nil browser = nil, --- Interval to refresh in seconds refresh_interval = 10, --- How much workflow runs and jobs should be indented indent = 2, + providers = { + github = {}, + gitlab = {}, + }, --- Allowed hosts to fetch data from, github.com is always allowed - --- @type string[] allowed_hosts = {}, icons = { workflow_dispatch = '⚡️', @@ -103,21 +118,21 @@ The default options (as defined in [lua/config.lua](./blob/main/lua/gh-actions/c }, }, highlights = { - GhActionsRunIconSuccess = { link = 'LspDiagnosticsVirtualTextHint' }, - GhActionsRunIconFailure = { link = 'LspDiagnosticsVirtualTextError' }, - GhActionsRunIconStartup_failure = { link = 'LspDiagnosticsVirtualTextError' }, - GhActionsRunIconPending = { link = 'LspDiagnosticsVirtualTextWarning' }, - GhActionsRunIconRequested = { link = 'LspDiagnosticsVirtualTextWarning' }, - GhActionsRunIconWaiting = { link = 'LspDiagnosticsVirtualTextWarning' }, - GhActionsRunIconIn_progress = { link = 'LspDiagnosticsVirtualTextWarning' }, - GhActionsRunIconCancelled = { link = 'Comment' }, - GhActionsRunIconSkipped = { link = 'Comment' }, - GhActionsRunCancelled = { link = 'Comment' }, - GhActionsRunSkipped = { link = 'Comment' }, - GhActionsJobCancelled = { link = 'Comment' }, - GhActionsJobSkipped = { link = 'Comment' }, - GhActionsStepCancelled = { link = 'Comment' }, - GhActionsStepSkipped = { link = 'Comment' }, + PipelineRunIconSuccess = { link = 'LspDiagnosticsVirtualTextHint' }, + PipelineRunIconFailure = { link = 'LspDiagnosticsVirtualTextError' }, + PipelineRunIconStartup_failure = { link = 'LspDiagnosticsVirtualTextError' }, + PipelineRunIconPending = { link = 'LspDiagnosticsVirtualTextWarning' }, + PipelineRunIconRequested = { link = 'LspDiagnosticsVirtualTextWarning' }, + PipelineRunIconWaiting = { link = 'LspDiagnosticsVirtualTextWarning' }, + PipelineRunIconIn_progress = { link = 'LspDiagnosticsVirtualTextWarning' }, + PipelineRunIconCancelled = { link = 'Comment' }, + PipelineRunIconSkipped = { link = 'Comment' }, + PipelineRunCancelled = { link = 'Comment' }, + PipelineRunSkipped = { link = 'Comment' }, + PipelineJobCancelled = { link = 'Comment' }, + PipelineJobSkipped = { link = 'Comment' }, + PipelineStepCancelled = { link = 'Comment' }, + PipelineStepSkipped = { link = 'Comment' }, }, split = { relative = 'editor', @@ -142,7 +157,7 @@ The default options (as defined in [lua/config.lua](./blob/main/lua/gh-actions/c require('lualine').setup({ sections = { lualine_a = { - { 'gh-actions' }, + { 'pipeline' }, }, } }) @@ -155,7 +170,7 @@ require('lualine').setup({ sections = { lualine_a = { -- with default options - { 'gh-actions', icon = '' }, + { 'pipeline', icon = '' }, }, } }) diff --git a/lazy.lua b/lazy.lua index c9e575b..d76df74 100644 --- a/lazy.lua +++ b/lazy.lua @@ -1,11 +1,14 @@ return { { 'nvim-lua/plenary.nvim', lazy = true }, - { 'MunifTanjim/nui.nvim', lazy = true }, + { 'MunifTanjim/nui.nvim', lazy = true }, { - 'topaxi/gh-actions.nvim', - cmd = 'GhActions', + 'topaxi/pipeline.nvim', + cmd = { 'Pipeline', 'GhActions' }, lazy = true, dependencies = { 'nvim-lua/plenary.nvim', 'MunifTanjim/nui.nvim' }, opts = {}, + config = function(_, opts) + require('pipeline').setup(opts) + end, }, } diff --git a/lua/gh-actions/command.lua b/lua/gh-actions/command.lua deleted file mode 100644 index 9d182ac..0000000 --- a/lua/gh-actions/command.lua +++ /dev/null @@ -1,38 +0,0 @@ -local M = {} - -local function handle_gh_actions_command(a) - local gha = require('gh-actions') - - local action = a.fargs[1] or 'toggle' - - if action == 'open' then - return gha.open() - elseif action == 'close' then - return gha.close() - elseif action == 'toggle' then - local ui = require('gh-actions.ui') - - if ui.split.winid then - return gha.close() - else - return gha.open() - end - end -end - -local function completion_customlist() - return { - 'open', - 'close', - 'toggle', - } -end - -function M.setup() - vim.api.nvim_create_user_command('GhActions', handle_gh_actions_command, { - nargs = '?', - complete = completion_customlist, - }) -end - -return M diff --git a/lua/gh-actions/config.lua b/lua/gh-actions/config.lua deleted file mode 100644 index f88a8ff..0000000 --- a/lua/gh-actions/config.lua +++ /dev/null @@ -1,83 +0,0 @@ ----@class GhActionsConfig -local defaultConfig = { - --- The browser executable path to open workflow runs/jobs in - ---@type string|nil - browser = nil, - --- Interval to refresh in seconds - refresh_interval = 10, - --- How much workflow runs and jobs should be indented - indent = 2, - --- Allowed hosts to fetch data from, github.com is always allowed - --- @type string[] - allowed_hosts = {}, - ---@class GhActionsIcons - icons = { - workflow_dispatch = '⚡️', - ---@class GhActionsIconsConclusion - conclusion = { - success = '✓', - failure = 'X', - startup_failure = 'X', - cancelled = '⊘', - skipped = '◌', - action_required = '⚠', - }, - ---@class GhActionsIconsStatus - status = { - unknown = '?', - pending = '○', - queued = '○', - requested = '○', - waiting = '○', - in_progress = '●', - }, - }, - ---@class GhActionsHighlights - highlights = { - GhActionsRunIconSuccess = { link = 'LspDiagnosticsVirtualTextHint' }, - GhActionsRunIconFailure = { link = 'LspDiagnosticsVirtualTextError' }, - GhActionsRunIconStartup_failure = { - link = 'LspDiagnosticsVirtualTextError', - }, - GhActionsRunIconPending = { link = 'LspDiagnosticsVirtualTextWarning' }, - GhActionsRunIconRequested = { link = 'LspDiagnosticsVirtualTextWarning' }, - GhActionsRunIconWaiting = { link = 'LspDiagnosticsVirtualTextWarning' }, - GhActionsRunIconIn_progress = { link = 'LspDiagnosticsVirtualTextWarning' }, - GhActionsRunIconCancelled = { link = 'Comment' }, - GhActionsRunIconSkipped = { link = 'Comment' }, - GhActionsRunCancelled = { link = 'Comment' }, - GhActionsRunSkipped = { link = 'Comment' }, - GhActionsJobCancelled = { link = 'Comment' }, - GhActionsJobSkipped = { link = 'Comment' }, - GhActionsStepCancelled = { link = 'Comment' }, - GhActionsStepSkipped = { link = 'Comment' }, - }, - split = { - relative = 'editor', - position = 'right', - size = 60, - win_options = { - wrap = false, - number = false, - foldlevel = nil, - foldcolumn = '0', - cursorcolumn = false, - signcolumn = 'no', - }, - }, -} - -local M = { - options = defaultConfig, -} - ----@param opts? GhActionsConfig -function M.setup(opts) - opts = opts or {} - - M.options = vim.tbl_deep_extend('force', defaultConfig, opts) - M.options.allowed_hosts = M.options.allowed_hosts or {} - table.insert(M.options.allowed_hosts, 'github.com') -end - -return M diff --git a/lua/gh-actions/health.lua b/lua/gh-actions/health.lua deleted file mode 100644 index b104961..0000000 --- a/lua/gh-actions/health.lua +++ /dev/null @@ -1,46 +0,0 @@ -local M = {} - -function M.check() - local health = vim.health or require('health') - - local start = health.start or health.report_start - local ok = health.ok or health.report_ok - local warn = health.warn or health.report_warn - local error = health.error or health.report_error - - start('Checking ability to parse yaml files') - - local has_rust_module = pcall(require, 'gh-actions.rust') - - if has_rust_module then - ok('Found rust module') - else - warn('No rust module found') - end - - local has_yq_installed = vim.fn.executable('yq') == 1 - - if has_yq_installed then - ok('Found yq executable') - else - warn('No yq executable found') - end - - if has_rust_module or has_yq_installed then - ok('Found yaml parser') - else - error('No yaml parser found') - end - - start('Checking for GitHub token') - - local k, token = pcall(require('gh-actions.github').get_github_token) - - if k and token then - ok('Found GitHub token') - else - error('No GitHub token found') - end -end - -return M diff --git a/lua/gh-actions/init.lua b/lua/gh-actions/init.lua index 02b32eb..7ae8fae 100644 --- a/lua/gh-actions/init.lua +++ b/lua/gh-actions/init.lua @@ -1,361 +1,13 @@ -local M = { - setup_called = false, - init_root = '', - ---@type uv_timer_t|nil - timer = nil, - timers = 0, -} +local gha = setmetatable({}, { __index = require('pipeline') }) ----@param opts? GhActionsConfig -function M.setup(opts) - opts = opts or {} +---@override +function gha.setup(...) + vim.notify_once( + 'topaxi/gh-actions.nvim is deprecated, use topaxi/pipeline.nvim instead', + vim.log.levels.WARN + ) - M.init_root = vim.fn.getcwd() - - M.setup_called = true - - require('gh-actions.config').setup(opts) - require('gh-actions.ui').setup() - require('gh-actions.command').setup() -end - -local function is_host_allowed(host) - local config = require('gh-actions.config') - - for _, allowed_host in ipairs(config.options.allowed_hosts) do - if host == allowed_host then - return true - end - end - - return false -end - ---TODO Only periodically fetch all workflows --- then fetch runs for a single workflow (tabs/expandable) --- Maybe periodically fetch all workflow runs to update --- "toplevel" workflow states ---TODO Maybe send lsp progress events when fetching, to interact --- with fidget.nvim -local function fetch_data() - local gh = require('gh-actions.github') - local store = require('gh-actions.store') - local server, repo = gh.get_current_repository() - - store.update_state(function(state) - state.repo = repo - state.server = server - end) - - if not is_host_allowed(server) then - return - end - - gh.get_workflows(server, repo, { - callback = function(workflows) - store.update_state(function(state) - state.workflows = workflows - end) - end, - }) - - gh.get_repository_workflow_runs(server, repo, 100, { - callback = function(workflow_runs) - local utils = require('gh-actions.utils') - local old_workflow_runs = store.get_state().workflow_runs - - store.update_state(function(state) - state.workflow_runs = workflow_runs - end) - - local running_workflows = utils.uniq( - function(run) - return run.id - end, - vim.tbl_filter(function(run) - return run.status ~= 'completed' and run.status ~= 'skipped' - end, { unpack(old_workflow_runs), unpack(workflow_runs) }) - ) - - for _, run in ipairs(running_workflows) do - gh.get_workflow_run_jobs(server, repo, run.id, 20, { - callback = function(jobs) - store.update_state(function(state) - state.workflow_jobs[run.id] = jobs - end) - end, - }) - end - end, - }) -end - -function M.start_polling() - M.timers = M.timers + 1 - - if not M.timer then - M.timer = vim.loop.new_timer() - M.timer:start( - 0, - require('gh-actions.config').options.refresh_interval * 1000, - vim.schedule_wrap(fetch_data) - ) - end -end - -function M.stop_polling() - M.timers = M.timers - 1 - - if M.timers == 0 then - M.timer:stop() - M.timer:close() - M.timer = nil - end -end - -local function now() - return os.time() -end - -local WORKFLOW_CONFIG_CACHE_TTL_S = 10 - ----TODO We should run this after fetching the workflows instead of within the state update event ----@param state GhActionsState -function M.update_workflow_configs(state) - local gh = require('gh-actions.github') - local n = now() - - for _, workflow in ipairs(state.workflows) do - if - not state.workflow_configs[workflow.id] - or (n - state.workflow_configs[workflow.id].last_read) - > WORKFLOW_CONFIG_CACHE_TTL_S - then - state.workflow_configs[workflow.id] = { - last_read = n, - config = gh.get_workflow_config(workflow.path), - } - end - end -end - ----@param opts { prompt: string, title: string, default_value: string, on_submit: fun(value: string) } -local function text(opts) - local Input = require('nui.input') - - return Input({ - relative = 'editor', - position = '50%', - size = { - width = #opts.prompt + 32, - }, - border = { - style = 'rounded', - text = { top = opts.title }, - }, - }, { - prompt = opts.prompt, - default_value = opts.default_value, - on_submit = opts.on_submit, - }) -end - ----@param opts { prompt: string, title: string, options: string[], on_submit: fun(value: { text: string }) } -local function menu(opts) - local Menu = require('nui.menu') - local lines = { Menu.separator(opts.prompt) } - - for _, option in ipairs(opts.options) do - table.insert(lines, Menu.item(option)) - end - - return Menu({ - relative = 'editor', - position = '50%', - size = { - width = #opts.prompt + 32, - }, - border = { - style = 'rounded', - text = { top = opts.title }, - }, - }, { - lines = lines, - on_submit = opts.on_submit, - keymap = { - focus_next = { 'j', '', '' }, - focus_prev = { 'k', '', '' }, - close = { '', '' }, - submit = { '', '' }, - }, - }) -end - -function M.open() - local ui = require('gh-actions.ui') - local store = require('gh-actions.store') - local utils = require('gh-actions.utils') - - ui.open() - ui.split:map('n', 'q', M.close, { noremap = true }) - - ui.split:map('n', 'gw', function() - local workflow = ui.get_workflow() - - if workflow then - utils.open(workflow.html_url) - - return - end - end, { noremap = true }) - - ui.split:map('n', 'gr', function() - local workflow_run = ui.get_workflow_run() - - if workflow_run then - utils.open(workflow_run.html_url) - - return - end - end, { noremap = true }) - - ui.split:map('n', 'gj', function() - local workflow_job = ui.get_workflow_job() - - if workflow_job then - utils.open(workflow_job.html_url) - - return - end - end, { noremap = true }) - - -- TODO Move this into its own module, ui? - ui.split:map('n', 'd', function() - local gh = require('gh-actions.github') - local workflow = ui.get_workflow() - - if workflow then - local server = store.get_state().server - local repo = store.get_state().repo - - -- TODO should we get current ref instead or show an input with the - -- default branch or current ref preselected? - local default_branch = require('gh-actions.git').get_default_branch() - local workflow_config = - require('gh-actions.yaml').read_yaml_file(workflow.path) - - if not workflow_config or not workflow_config.on.workflow_dispatch then - return - end - - local inputs = {} - - if not utils.is_nil(workflow_config.on.workflow_dispatch) then - inputs = workflow_config.on.workflow_dispatch.inputs - end - - local event = require('nui.utils.autocmd').event - local questions = {} - local i = 0 - local input_values = vim.empty_dict() - - -- TODO: Would be great to be able to cycle back to previous inputs - local function ask_next() - i = i + 1 - - if #questions > 0 and i <= #questions then - questions[i]:mount() - else - gh.dispatch_workflow(server, repo, workflow.id, default_branch, { - body = { inputs = input_values or {} }, - callback = function(_res) - utils.delay(2000, function() - gh.get_workflow_runs(server, repo, workflow.id, 5, { - callback = function(workflow_runs) - store.update_state(function(state) - state.workflow_runs = utils.uniq(function(run) - return run.id - end, { - unpack(workflow_runs), - unpack(state.workflow_runs), - }) - end) - end, - }) - end) - end, - }) - - if #questions == 0 then - vim.notify(string.format('Dispatched %s', workflow.name)) - else - -- TODO format by iterating instead of inspect - vim.notify( - string.format( - 'Dispatched %s with %s', - workflow.name, - vim.inspect(input_values) - ) - ) - end - end - end - - for name, input in pairs(inputs) do - local prompt = string.format('%s: ', input.description or name) - - if input.type == 'choice' then - local question = menu { - prompt = prompt, - title = workflow.name, - options = input.options, - on_submit = function(value) - input_values[name] = value.text - ask_next() - end, - } - - question:on(event.BufLeave, function() - question:unmount() - end) - - table.insert(questions, question) - else - local question = text { - prompt = prompt, - title = workflow.name, - default_value = input.default, - on_submit = function(value) - input_values[name] = value - ask_next() - end, - } - - question:on(event.BufLeave, function() - question:unmount() - end) - - table.insert(questions, question) - end - end - - ask_next() - end - end, { noremap = true }) - - M.start_polling() - - --TODO: This might get called after rendering.. - store.on_update(M.update_workflow_configs) -end - -function M.close() - local ui = require('gh-actions.ui') - local store = require('gh-actions.store') - - ui.close() - M.stop_polling() - store.off_update(M.update_workflow_configs) + require('pipeline').setup(...) end -return M +return gha diff --git a/lua/gh-actions/rust.lua b/lua/gh-actions/rust.lua deleted file mode 100644 index a753268..0000000 --- a/lua/gh-actions/rust.lua +++ /dev/null @@ -1,8 +0,0 @@ ----@class gh_actions_rust ----@field parse_yaml fun(yamlstr: string): table ----@field NIL unknown - ----@type gh_actions_rust -local M = require('libgh_actions_rust') - -return M diff --git a/lua/gh-actions/store.lua b/lua/gh-actions/store.lua deleted file mode 100644 index e2d57a1..0000000 --- a/lua/gh-actions/store.lua +++ /dev/null @@ -1,63 +0,0 @@ -local utils = require('gh-actions.utils') - ----@class GhActionsStateWorkflowConfig ----@field last_read integer ----@field config table - ----@class GhActionsState ----@field repo string ----@field server string ----@field workflows GhWorkflow[] ----@field workflow_runs GhWorkflowRun[] ----@field workflow_jobs table ----@field workflow_configs table - ----@type GhActionsState -local initialState = { - repo = '', - server = '', - workflows = {}, - workflow_runs = {}, - workflow_jobs = {}, - workflow_configs = {}, -} - -local M = { - _state = initialState, - _update = {}, -} - ----@param state GhActionsState -local function emit_update(state) - for _, update in ipairs(M._update) do - update(state) - end -end - -emit_update = utils.debounced(vim.schedule_wrap(emit_update)) - ----@param fn fun(render_state: GhActionsState): GhActionsState|nil -function M.update_state(fn) - M._state = fn(M._state) or M._state - - emit_update(M._state) -end - ----@param fn function -function M.on_update(fn) - table.insert(M._update, fn) -end - ----@param fn function -function M.off_update(fn) - M._update = vim.tbl_filter(function(f) - return f ~= fn - end, M._update) -end - ----@return GhActionsState -function M.get_state() - return M._state -end - -return M diff --git a/lua/lualine/components/gh-actions.lua b/lua/lualine/components/gh-actions.lua deleted file mode 100644 index 5ec19e0..0000000 --- a/lua/lualine/components/gh-actions.lua +++ /dev/null @@ -1,50 +0,0 @@ -local function gh() - return require('gh-actions.github') -end - -local component = require('lualine.component'):extend() - ----@class GhActionsComponent -local default_options = { - icon = '', -} - ----@override ----@param options GhActionsComponent -function component:init(options) - component.super.init(self, options) - - self.options = vim.tbl_deep_extend('force', default_options, options or {}) - self.store = require('gh-actions.store') - self.icons = require('gh-actions.utils.icons') - - local server, repo = gh().get_current_repository() - - if not server or not repo then - return - end - - require('gh-actions').start_polling() - - self.store.on_update(function() - require('lualine').refresh() - end) -end - ----@override -function component:update_status() - local state = self.store.get_state() - - local latest_workflow_run = state.workflow_runs and state.workflow_runs[1] - or {} - - if not latest_workflow_run.status then - return '' - end - - return self.icons.get_workflow_run_icon(latest_workflow_run) - .. ' ' - .. latest_workflow_run.name -end - -return component diff --git a/lua/lualine/components/pipeline.lua b/lua/lualine/components/pipeline.lua new file mode 100644 index 0000000..341f2bf --- /dev/null +++ b/lua/lualine/components/pipeline.lua @@ -0,0 +1,56 @@ +---@class pipeline.lualine.Component +---@field protected super { init: fun(self: table, options: table) } +local Component = require('lualine.component'):extend() + +---@class pipeline.lualine.ComponentOptions +local default_options = { + icon = '', + ---@param component pipeline.lualine.Component + ---@param state pipeline.State + ---@return string + format = function(component, state) + local latest_run = state.latest_run + + if not latest_run or not latest_run.status then + return '' + end + + return component.icons.get_workflow_run_icon(latest_run) + .. ' ' + .. latest_run.name + end, + + on_click = function() + require('pipeline').toggle() + end, +} + +---@override +---@param options pipeline.lualine.ComponentOptions +function Component:init(options) + self.options = vim.tbl_deep_extend('force', default_options, options or {}) + + Component.super.init(self, self.options) + + self.store = require('pipeline.store') + self.icons = require('pipeline.utils.icons') + + local server, repo = require('pipeline.git').get_current_repository() + + if not server or not repo then + return + end + + require('pipeline').start_polling() + + self.store.on_update(function() + require('lualine').refresh() + end) +end + +---@override +function Component:update_status() + return self.options.format(self, self.store.get_state()) +end + +return Component diff --git a/lua/pipeline/command.lua b/lua/pipeline/command.lua new file mode 100644 index 0000000..81ac07a --- /dev/null +++ b/lua/pipeline/command.lua @@ -0,0 +1,43 @@ +local M = {} + +local function handle_pipeline_command(a) + local pipeline = require('pipeline') + + if a.name == 'GhActions' then + vim.notify_once( + 'GhActions command is deprecated, use Pipeline instead', + vim.log.levels.WARN + ) + end + + local action = a.fargs[1] or 'toggle' + + if action == 'open' then + return pipeline.open() + elseif action == 'close' then + return pipeline.close() + elseif action == 'toggle' then + return pipeline.toggle() + end +end + +local function completion_customlist() + return { + 'open', + 'close', + 'toggle', + } +end + +function M.setup() + vim.api.nvim_create_user_command('Pipeline', handle_pipeline_command, { + nargs = '?', + complete = completion_customlist, + }) + vim.api.nvim_create_user_command('GhActions', handle_pipeline_command, { + nargs = '?', + complete = completion_customlist, + }) +end + +return M diff --git a/lua/pipeline/config.lua b/lua/pipeline/config.lua new file mode 100644 index 0000000..397f6cf --- /dev/null +++ b/lua/pipeline/config.lua @@ -0,0 +1,115 @@ +---@class pipeline.Config +local defaultConfig = { + --- The browser executable path to open workflow runs/jobs in + ---@type string|nil + browser = nil, + --- How much workflow runs and jobs should be indented + indent = 2, + --- Provider options + ---@class pipeline.config.Providers + ---@field github? pipeline.providers.github.rest.Options + ---@field gitlab? pipeline.providers.gitlab.graphql.Options + providers = { + github = {}, + gitlab = {}, + }, + --- Allowed hosts to fetch data from, github.com is always allowed + --- @type string[] + allowed_hosts = {}, + ---@class pipeline.config.Icons + icons = { + workflow_dispatch = '⚡️', + ---@class pipeline.config.IconConclusion + conclusion = { + success = '✓', + failure = 'X', + startup_failure = 'X', + cancelled = '⊘', + skipped = '◌', + action_required = '⚠', + }, + ---@class pipeline.config.IconStatus + status = { + unknown = '?', + pending = '○', + queued = '○', + requested = '○', + waiting = '○', + in_progress = '●', + }, + }, + ---@class pipeline.config.Highlights + highlights = { + ---@type vim.api.keyset.highlight + PipelineRunIconSuccess = { link = 'LspDiagnosticsVirtualTextHint' }, + ---@type vim.api.keyset.highlight + PipelineRunIconFailure = { link = 'LspDiagnosticsVirtualTextError' }, + ---@type vim.api.keyset.highlight + PipelineRunIconStartup_failure = { + link = 'LspDiagnosticsVirtualTextError', + }, + ---@type vim.api.keyset.highlight + PipelineRunIconPending = { link = 'LspDiagnosticsVirtualTextWarning' }, + ---@type vim.api.keyset.highlight + PipelineRunIconRequested = { link = 'LspDiagnosticsVirtualTextWarning' }, + ---@type vim.api.keyset.highlight + PipelineRunIconWaiting = { link = 'LspDiagnosticsVirtualTextWarning' }, + ---@type vim.api.keyset.highlight + PipelineRunIconIn_progress = { link = 'LspDiagnosticsVirtualTextWarning' }, + ---@type vim.api.keyset.highlight + PipelineRunIconCancelled = { link = 'Comment' }, + ---@type vim.api.keyset.highlight + PipelineRunIconSkipped = { link = 'Comment' }, + ---@type vim.api.keyset.highlight + PipelineRunCancelled = { link = 'Comment' }, + ---@type vim.api.keyset.highlight + PipelineRunSkipped = { link = 'Comment' }, + ---@type vim.api.keyset.highlight + PipelineJobCancelled = { link = 'Comment' }, + ---@type vim.api.keyset.highlight + PipelineJobSkipped = { link = 'Comment' }, + ---@type vim.api.keyset.highlight + PipelineStepCancelled = { link = 'Comment' }, + ---@type vim.api.keyset.highlight + PipelineStepSkipped = { link = 'Comment' }, + }, + ---@type nui_split_options + split = { + relative = 'editor', + position = 'right', + size = 60, + win_options = { + wrap = false, + number = false, + foldlevel = nil, + foldcolumn = '0', + cursorcolumn = false, + signcolumn = 'no', + }, + }, +} + +local M = { + options = defaultConfig, +} + +---@param opts? pipeline.Config +function M.setup(opts) + opts = opts or {} + + M.options = vim.tbl_deep_extend('force', defaultConfig, opts) + M.options.allowed_hosts = M.options.allowed_hosts or {} + table.insert(M.options.allowed_hosts, 'github.com') +end + +function M.is_host_allowed(host) + for _, allowed_host in ipairs(M.options.allowed_hosts) do + if host == allowed_host then + return true + end + end + + return false +end + +return M diff --git a/lua/gh-actions/git.lua b/lua/pipeline/git.lua similarity index 50% rename from lua/gh-actions/git.lua rename to lua/pipeline/git.lua index 3df65a6..f47bcaa 100644 --- a/lua/gh-actions/git.lua +++ b/lua/pipeline/git.lua @@ -5,6 +5,38 @@ local function create_job(job) return require('plenary.job'):new(job) end +---@param str string +---@return string +local function strip_git_suffix(str) + if str:sub(-4) == '.git' then + return str:sub(1, -5) + end + + return str +end + +---@return string, string +---@nodiscard +function M.get_current_repository() + local origin_url_job = create_job { + command = 'git', + args = { + 'config', + '--get', + 'remote.origin.url', + }, + } + + origin_url_job:sync() + + local origin_url = table.concat(origin_url_job:result(), '') + + local server, repo = + strip_git_suffix(origin_url):match('([^@/:]+)[:/]([^/]+/[^/]+)$') + + return server, repo +end + function M.get_current_branch() local job = create_job { command = 'git', diff --git a/lua/pipeline/health.lua b/lua/pipeline/health.lua new file mode 100644 index 0000000..b0fc737 --- /dev/null +++ b/lua/pipeline/health.lua @@ -0,0 +1,34 @@ +local M = {} + +function M.check() + local health = vim.health + + health.start('Checking ability to parse yaml files') + + local has_native_module = pcall(require, 'pipeline_native.yaml') + + if has_native_module then + health.ok('Found native module') + else + health.warn('No native module found') + end + + local has_yq_installed = vim.fn.executable('yq') == 1 + + if has_yq_installed then + health.ok('Found yq executable') + else + health.warn('No yq executable found') + end + + if has_native_module or has_yq_installed then + health.ok('Found yaml parser') + else + health.error('No yaml parser found') + end + + require('pipeline.providers.github.rest.health').check() + require('pipeline.providers.gitlab.graphql.health').check() +end + +return M diff --git a/lua/pipeline/init.lua b/lua/pipeline/init.lua new file mode 100644 index 0000000..4f1434e --- /dev/null +++ b/lua/pipeline/init.lua @@ -0,0 +1,156 @@ +local M = { + init_root = '', +} + +---@param opts? pipeline.Config +function M.setup(opts) + opts = opts or {} + + M.init_root = vim.fn.getcwd() + + require('pipeline.config').setup(opts) + require('pipeline.ui').setup() + require('pipeline.command').setup() + + M.setup_provider() +end + +function M.setup_provider() + if M.pipeline then + return + end + + local config = require('pipeline.config') + local store = require('pipeline.store') + + M.pipeline = require('pipeline.providers.provider'):new(config.options, store) + for provider, provider_options in pairs(config.options.providers) do + local Provider = require('pipeline.providers')[provider] + + if Provider.detect() then + M.pipeline = Provider:new(config.options, store, provider_options) + end + end +end + +function M.start_polling() + M.pipeline:listen() +end + +function M.stop_polling() + M.pipeline:close() +end + +local function now() + return os.time() +end + +local WORKFLOW_CONFIG_CACHE_TTL_S = 10 + +---TODO We should run this after fetching the workflows instead of within the state update event +---@param state pipeline.State +function M.update_workflow_configs(state) + local gh_utils = require('pipeline.providers.github.utils') + local n = now() + + for _, pipeline in ipairs(state.pipelines) do + if + not state.workflow_configs[pipeline.pipeline_id] + or (n - state.workflow_configs[pipeline.pipeline_id].last_read) + > WORKFLOW_CONFIG_CACHE_TTL_S + then + state.workflow_configs[pipeline.pipeline_id] = { + last_read = n, + config = gh_utils.get_workflow_config(pipeline.meta.workflow_path), + } + end + end +end + +---@param pipeline_object pipeline.PipelineObject|nil +local function open_pipeline_url(pipeline_object) + if not pipeline_object then + return + end + + if type(pipeline_object.url) ~= 'string' or pipeline_object.url == '' then + return + end + + require('pipeline.utils').open(pipeline_object.url) +end + +function M.open() + local ui = require('pipeline.ui') + local store = require('pipeline.store') + + ui.open() + ui.split:map('n', 'q', M.close, { noremap = true }) + + ui.split:map('n', 'gp', function() + open_pipeline_url(ui.get_pipeline()) + end, { noremap = true, desc = 'Open pipeline URL' }) + + ui.split:map('n', 'gw', function() + vim.notify( + 'Keybind gw to jump to workflow is deprecated, use gp instead', + vim.log.levels.WARN + ) + + open_pipeline_url(ui.get_pipeline()) + end, { noremap = true, desc = 'Open pipeline URL (deprecated)' }) + + ui.split:map('n', 'gr', function() + open_pipeline_url(ui.get_run()) + end, { noremap = true, desc = 'Open pipeline run URL' }) + + ui.split:map('n', 'gj', function() + open_pipeline_url(ui.get_job()) + end, { noremap = true, desc = 'Open pipeline job URL' }) + + ui.split:map('n', 'gs', function() + open_pipeline_url(ui.get_step()) + end, { noremap = true, desc = 'Open pipeline step URL' }) + + ui.split:map('n', 'd', function() + M.pipeline:dispatch(ui.get_pipeline()) + end, { noremap = true, desc = 'Dispatch pipeline run' }) + + ui.split:map('n', 'rr', function() + M.pipeline:retry(ui.get_run()) + end, { noremap = true, desc = 'Retry pipeline run' }) + + ui.split:map('n', 'rj', function() + M.pipeline:retry(ui.get_job()) + end, { noremap = true, desc = 'Retry pipeline job' }) + + ui.split:map('n', 'rs', function() + M.pipeline:retry(ui.get_step()) + end, { noremap = true, desc = 'Retry pipeline step' }) + + M.start_polling() + + --TODO: This might get called after rendering.. + store.on_update(M.update_workflow_configs) +end + +function M.close() + local ui = require('pipeline.ui') + local store = require('pipeline.store') + + ui.close() + M.stop_polling() + store.off_update(M.update_workflow_configs) +end + +function M.toggle() + local ui = require('pipeline.ui') + + if ui.split.winid then + return M.close() + else + return M.open() + end +end + +return M diff --git a/lua/gh-actions/github.lua b/lua/pipeline/providers/github/rest/_api.lua similarity index 74% rename from lua/gh-actions/github.lua rename to lua/pipeline/providers/github/rest/_api.lua index c7defa2..b0f9eec 100644 --- a/lua/gh-actions/github.lua +++ b/lua/pipeline/providers/github/rest/_api.lua @@ -1,71 +1,9 @@ -local M = {} - ----@param str string ----@return string -local function strip_git_suffix(str) - if str:sub(-4) == '.git' then - return str:sub(1, -5) - end - - return str +local function gh_utils() + return require('pipeline.providers.github.utils') end -function M.get_current_repository() - local job = require('plenary.job') - local origin_url_job = job:new { - command = 'git', - args = { - 'config', - '--get', - 'remote.origin.url', - }, - } - - origin_url_job:sync() - - local origin_url = table.concat(origin_url_job:result(), '') - - return strip_git_suffix(origin_url):match('([^@/:]+)[:/]([^/]+/[^/]+)$') -end - ----@param cmd? string ----@param server? string ----@return string|nil -local function get_token_from_gh_cli(cmd, server) - local has_gh_installed = vim.fn.executable('gh') == 1 - if not has_gh_installed and not cmd then - return nil - end - - local res - if cmd then - res = vim.fn.system(cmd) - else - local gh_enterprise_flag = '' - if server ~= nil and server ~= '' then - gh_enterprise_flag = ' --hostname ' .. vim.fn.shellescape(server) - end - res = vim.fn.system('gh auth token' .. gh_enterprise_flag) - end - - local token = string.gsub(res or '', '\n', '') - - if token == '' then - return nil - end - - return token -end - ----@param cmd? string ----@param server? string ----@return string -function M.get_github_token(cmd, server) - return vim.env.GITHUB_TOKEN - or get_token_from_gh_cli(cmd, server) - -- TODO: We could also ask for the token here via nui - or assert(nil, 'No GITHUB_TOKEN found in env and no gh cli config found') -end +---@class pipeline.providers.github.rest.Api +local M = {} ---@param server string ---@param path string @@ -99,7 +37,7 @@ function M.fetch(server, path, opts) headers = { Authorization = string.format( 'Bearer %s', - M.get_github_token(nil, server) + gh_utils().get_github_token(nil, server) ), }, }) @@ -212,7 +150,7 @@ end ---@param server string ---@param repo string ----@param workflow_id integer +---@param workflow_id integer|string ---@param per_page? integer ---@param opts? { callback?: fun(workflow_runs: GhWorkflowRun[]): any } function M.get_workflow_runs(server, repo, workflow_id, per_page, opts) @@ -229,7 +167,7 @@ end ---@param server string ---@param repo string ----@param workflow_id integer +---@param workflow_id integer|string ---@param ref string ---@param opts? table function M.dispatch_workflow(server, repo, workflow_id, ref, opts) @@ -303,21 +241,4 @@ function M.get_workflow_run_jobs(server, repo, workflow_run_id, per_page, opts) ) end ----@param path string ----@return table -function M.get_workflow_config(path) - path = vim.fn.expand(path) - - local utils = require('gh-actions.utils') - local workflow_yaml = utils.read_file(path) or '' - local config = { - on = { - workflow_dispatch = workflow_yaml:find('workflow_dispatch'), - }, - } - - ---@cast config table - return config -end - return M diff --git a/lua/pipeline/providers/github/rest/_mapper.lua b/lua/pipeline/providers/github/rest/_mapper.lua new file mode 100644 index 0000000..db362c4 --- /dev/null +++ b/lua/pipeline/providers/github/rest/_mapper.lua @@ -0,0 +1,66 @@ +---@class pipeline.providers.github.rest.Mapper +local M = {} + +---By default the workflow.html_url points to the workflow definition file. +---We want to jump to the UI of all the workflow runs instead. +---Example: +--- input: https://github.com/topaxi/pipeline.nvim/blob/main/.github/workflows/dispatch-echo.yaml +--- output: https://github.com/topaxi/pipeline.nvim/actions/workflows/dispatch-echo.yaml +---@param workflow GhWorkflow +local function workflow_url(workflow) + return workflow.html_url:gsub('blob/main/%.github', 'actions') +end + +---@class pipeline.providers.github.rest.Pipeline: pipeline.Pipeline +---@field meta { workflow_path: string } + +---@param workflow GhWorkflow +---@return pipeline.providers.github.rest.Pipeline +function M.to_pipeline(workflow) + return { + pipeline_id = workflow.id, + name = workflow.name, + url = workflow_url(workflow), + meta = { workflow_path = workflow.path }, + } +end + +---@param workflow_run GhWorkflowRun +---@return pipeline.Run +function M.to_run(workflow_run) + return { + run_id = workflow_run.id, + pipeline_id = workflow_run.workflow_id, + name = workflow_run.head_commit.message:gsub('\n.*', ''), + url = workflow_run.html_url, + status = workflow_run.status, + conclusion = workflow_run.conclusion, + } +end + +---@param workflow_job GhWorkflowRunJob +---@return pipeline.Job +function M.to_job(workflow_job) + return { + job_id = workflow_job.id, + run_id = workflow_job.run_id, + name = workflow_job.name, + status = workflow_job.status, + conclusion = workflow_job.conclusion, + } +end + +---@param job_id integer +---@param workflow_job_step GhWorkflowRunJobStep +---@return pipeline.Step +function M.to_step(job_id, workflow_job_step) + return { + step_id = string.format('%s:%s', job_id, workflow_job_step.number), + job_id = job_id, + name = workflow_job_step.name, + status = workflow_job_step.status, + conclusion = workflow_job_step.conclusion, + } +end + +return M diff --git a/lua/pipeline/providers/github/rest/health.lua b/lua/pipeline/providers/github/rest/health.lua new file mode 100644 index 0000000..14f1489 --- /dev/null +++ b/lua/pipeline/providers/github/rest/health.lua @@ -0,0 +1,18 @@ +local M = {} + +function M.check() + local health = vim.health + + health.start('Github REST provider') + + local k, token = + pcall(require('pipeline.providers.github.utils').get_github_token) + + if k and token then + health.ok('Found GitHub token') + else + health.error('No GitHub token found') + end +end + +return M diff --git a/lua/pipeline/providers/github/rest/init.lua b/lua/pipeline/providers/github/rest/init.lua new file mode 100644 index 0000000..8e01c2a --- /dev/null +++ b/lua/pipeline/providers/github/rest/init.lua @@ -0,0 +1,269 @@ +local utils = require('pipeline.utils') +local Provider = require('pipeline.providers.provider') + +local function git() + return require('pipeline.git') +end + +local function gh_api() + return require('pipeline.providers.github.rest._api') +end + +---@class pipeline.providers.github.rest.Options +---@field refresh_interval? number +local defaultOptions = { + refresh_interval = 10, +} + +---@class pipeline.providers.github.rest.Provider: pipeline.Provider +---@field protected opts pipeline.providers.github.rest.Options +---@field private server string +---@field private repo string +local GithubRestProvider = Provider:extend() + +function GithubRestProvider.detect() + if not utils.file_exists('.github/workflows') then + return false + end + + local config = require('pipeline.config') + local server, repo = git().get_current_repository() + + if not config.is_host_allowed(server) then + return + end + + return server ~= nil and repo ~= nil +end + +---@param opts pipeline.providers.github.rest.Options +function GithubRestProvider:init(opts) + local server, repo = git().get_current_repository() + + self.opts = vim.tbl_deep_extend('force', defaultOptions, opts) + self.server = server + self.repo = repo + + self.store.update_state(function(state) + state.title = string.format('Github Workflows for %s', repo) + state.server = server + state.repo = repo + end) +end + +--TODO Only periodically fetch all workflows +-- then fetch runs for a single workflow (tabs/expandable) +-- Maybe periodically fetch all workflow runs to update +-- "toplevel" workflow states +--TODO Maybe send lsp progress events when fetching, to interact +-- with fidget.nvim +function GithubRestProvider:fetch() + local Mapper = require('pipeline.providers.github.rest._mapper') + + gh_api().get_workflows(self.server, self.repo, { + callback = function(workflows) + self.store.update_state(function(state) + state.pipelines = vim.tbl_map(Mapper.to_pipeline, workflows) + end) + end, + }) + + gh_api().get_repository_workflow_runs(self.server, self.repo, 100, { + callback = function(workflow_runs) + ---@type pipeline.Run[] + local runs = vim.tbl_map(Mapper.to_run, workflow_runs) + ---@type pipeline.Run[] + local old_runs = vim.iter(self.store.get_state().runs):flatten():totable() + + self.store.update_state(function(state) + state.latest_run = runs[1] + state.runs = utils.group_by(function(run) + return run.pipeline_id + end, runs) + end) + + local running_workflows = utils.uniq( + function(run) + return run.run_id + end, + vim.tbl_filter(function(run) + return run.status ~= 'completed' and run.status ~= 'skipped' + end, { unpack(runs), unpack(old_runs) }) + ) + + for _, run in ipairs(running_workflows) do + gh_api().get_workflow_run_jobs(self.server, self.repo, run.run_id, 20, { + callback = function(jobs) + self.store.update_state(function(state) + state.jobs[run.run_id] = vim.tbl_map(Mapper.to_job, jobs) + + for _, job in ipairs(jobs) do + state.steps[job.id] = vim.tbl_map(function(step) + return Mapper.to_step(job.id, step) + end, job.steps) + end + end) + end, + }) + end + end, + }) +end + +---@param pipeline pipeline.providers.github.rest.Pipeline|nil +function GithubRestProvider:dispatch(pipeline) + if not pipeline then + return + end + + local store = require('pipeline.store') + + if pipeline then + local server = store.get_state().server + local repo = store.get_state().repo + + -- TODO should we get current ref instead or show an input with the + -- default branch or current ref preselected? + local default_branch = require('pipeline.git').get_default_branch() + ---@type pipeline.providers.github.WorkflowDef|nil + local workflow_config = + require('pipeline.yaml').read_yaml_file(pipeline.meta.workflow_path) + + if not workflow_config or not workflow_config.on.workflow_dispatch then + return + end + + ---@type pipeline.providers.github.WorkflowDef.DispatchInputs + local inputs = {} + + if not utils.is_nil(workflow_config.on.workflow_dispatch) then + ---@type pipeline.providers.github.WorkflowDef.DispatchInputs + inputs = workflow_config.on.workflow_dispatch.inputs + end + + local questions = {} + local i = 0 + local input_values = vim.empty_dict() + + -- TODO: Would be great to be able to cycle back to previous inputs + local function ask_next() + i = i + 1 + + if #questions > 0 and i <= #questions then + questions[i]:mount() + else + gh_api().dispatch_workflow( + server, + repo, + pipeline.pipeline_id, + default_branch, + { + body = { inputs = input_values or {} }, + callback = function(_res) + utils.delay(2000, function() + gh_api().get_workflow_runs( + server, + repo, + pipeline.pipeline_id, + 5, + { + callback = function(workflow_runs) + local Mapper = + require('pipeline.providers.github.rest._mapper') + local runs = vim.tbl_map(Mapper.to_run, workflow_runs) + + store.update_state(function(state) + state.runs = utils.group_by( + function(run) + return run.pipeline_id + end, + utils.uniq(function(run) + return run.run_id + end, { + unpack(runs), + unpack(vim.iter(state.runs):flatten():totable()), + }) + ) + end) + end, + } + ) + end) + end, + } + ) + + if #questions == 0 then + vim.notify(string.format('Dispatched %s', pipeline.name)) + else + -- TODO format by iterating instead of inspect + vim.notify( + string.format( + 'Dispatched %s with %s', + pipeline.name, + vim.inspect(input_values) + ) + ) + end + end + end + + for name, input in pairs(inputs) do + local prompt = string.format('%s: ', input.description or name) + + if input.type == 'choice' then + local question = require('pipeline.ui.components.select') { + prompt = prompt, + title = pipeline.name, + options = input.options, + on_submit = function(value) + input_values[name] = value.text + ask_next() + end, + } + + question:on('BufLeave', function() + question:unmount() + end) + + table.insert(questions, question) + else + local question = require('pipeline.ui.components.input') { + prompt = prompt, + title = pipeline.name, + default_value = input.default, + on_submit = function(value) + input_values[name] = value + ask_next() + end, + } + + question:on('BufLeave', function() + question:unmount() + end) + + table.insert(questions, question) + end + end + + ask_next() + end +end + +function GithubRestProvider:connect() + self.timer = vim.loop.new_timer() + self.timer:start( + 0, + self.opts.refresh_interval * 1000, + vim.schedule_wrap(function() + self:fetch() + end) + ) +end + +function GithubRestProvider:disconnect() + self.timer:stop() + self.timer = nil +end + +return GithubRestProvider diff --git a/lua/pipeline/providers/github/types.lua b/lua/pipeline/providers/github/types.lua new file mode 100644 index 0000000..224ff73 --- /dev/null +++ b/lua/pipeline/providers/github/types.lua @@ -0,0 +1,20 @@ +---@meta + +---@class pipeline.providers.github.WorkflowDef +---@field on? { workflow_dispatch?: { inputs?: pipeline.providers.github.WorkflowDef.DispatchInputs } } + +---@alias pipeline.providers.github.WorkflowDef.DispatchInputs table + +---@class pipeline.providers.github.WorkflowDef.DispatchInputBase +---@field description? string +---@field required? boolean +---@field default? string + +---@class pipeline.providers.github.WorkflowDef.DispatchInputString: pipeline.providers.github.WorkflowDef.DispatchInputBase +---@field type? 'string' + +---@class pipeline.providers.github.WorkflowDef.DispatchInputChoice: pipeline.providers.github.WorkflowDef.DispatchInputBase +---@field type 'choice' +---@field options? string[] + +---@alias pipeline.providers.github.WorkflowDef.DispatchInput pipeline.providers.github.WorkflowDef.DispatchInputString | pipeline.providers.github.WorkflowDef.DispatchInputChoice diff --git a/lua/pipeline/providers/github/utils.lua b/lua/pipeline/providers/github/utils.lua new file mode 100644 index 0000000..4c3e28c --- /dev/null +++ b/lua/pipeline/providers/github/utils.lua @@ -0,0 +1,60 @@ +---@class pipeline.providers.github.Utils +local M = {} + +---@param cmd? string +---@param server? string +---@return string|nil +local function get_token_from_gh_cli(cmd, server) + local has_gh_installed = vim.fn.executable('gh') == 1 + if not has_gh_installed and not cmd then + return nil + end + + local res + if cmd then + res = vim.fn.system(cmd) + else + local gh_enterprise_flag = '' + if server ~= nil and server ~= '' then + gh_enterprise_flag = ' --hostname ' .. vim.fn.shellescape(server) + end + res = vim.fn.system('gh auth token' .. gh_enterprise_flag) + end + + local token = string.gsub(res or '', '\n', '') + + if token == '' then + return nil + end + + return token +end + +---@param cmd? string +---@param server? string +---@return string +function M.get_github_token(cmd, server) + return vim.env.GITHUB_TOKEN + or get_token_from_gh_cli(cmd, server) + -- TODO: We could also ask for the token here via nui + or assert(nil, 'No GITHUB_TOKEN found in env and no gh cli config found') +end + +---@param path string +---@return table +function M.get_workflow_config(path) + path = vim.fn.expand(path) + + local utils = require('pipeline.utils') + local workflow_yaml = utils.read_file(path) or '' + local config = { + on = { + workflow_dispatch = workflow_yaml:find('workflow_dispatch'), + }, + } + + ---@cast config table + return config +end + +return M diff --git a/lua/pipeline/providers/gitlab/graphql/_api.lua b/lua/pipeline/providers/gitlab/graphql/_api.lua new file mode 100644 index 0000000..085d631 --- /dev/null +++ b/lua/pipeline/providers/gitlab/graphql/_api.lua @@ -0,0 +1,109 @@ +local M = {} + +local pipelines_with_jobs_query = [[ + query ($repo: ID!, $limit: Int!) { + project(fullPath: $repo) { + id + ciConfigPathOrDefault + pipelines(first: $limit) { + nodes { + id + name + commit { + message + } + path + cancelable + retryable + createdAt + status + jobs { + nodes { + id + name + status + manualJob + retryable + cancelable + stage { + name + } + webPath + } + } + } + } + } + } +]] + +---@param job Job +local function create_job(job) + return require('plenary.job'):new(job) +end + +local function glab_graphql(query, variables) + local args = { + 'api', + 'graphql', + '-f', + 'query=' .. query, + } + + for key, value in pairs(variables) do + table.insert(args, '-F') + table.insert(args, key .. '=' .. value) + end + + return create_job { + command = 'glab', + args = args, + } +end + +---@alias pipeline.providers.gitlab.graphql.CiJobStatus 'CANCELED'|'CANCELING'|'CREATED'|'FAILED'|'MANUAL'|'PENDING'|'PREPARING'|'RUNNING'|'SCHEDULED'|'SKIPPED'|'SUCCESS'|'WAITING_FOR_CALLBACK'|'WAITING_FOR_RESOURCE' +---@alias pipeline.providers.gitlab.graphql.PipelineStatus 'CREATED'|'WAITING_FOR_RESOURCE'|'PREPARING'|'WAITING_FOR_CALLBACK'|'PENDING'|'RUNNING'|'FAILED'|'SUCCESS'|'CANCELED'|'CANCELING'|'SKIPPED'|'MANUAL'|'SCHEDULED' + +---@class pipeline.providers.gitlab.graphql.QueryResponseJob +---@field id string +---@field name string +---@field status pipeline.providers.gitlab.graphql.CiJobStatus +---@field manualJob boolean +---@field retryable boolean +---@field cancelable boolean +---@field stage { name: string } +---@field webPath string + +---@class pipeline.providers.gitlab.graphql.QueryResponsePipeline +---@field id string +---@field name string|nil +---@field commit { message: string } +---@field path string +---@field cancelable boolean +---@field retryable boolean +---@field createdAt string +---@field status pipeline.providers.gitlab.graphql.PipelineStatus +---@field jobs { nodes: pipeline.providers.gitlab.graphql.QueryResponseJob[] } + +---@class pipeline.providers.gitlab.graphql.QueryResponseProject +---@field id string +---@field ciConfigPathOrDefault string +---@field pipelines { nodes: pipeline.providers.gitlab.graphql.QueryResponsePipeline[] } + +---@class pipeline.providers.gitlab.graphql.QueryResponse +---@field data { project: pipeline.providers.gitlab.graphql.QueryResponseProject } + +---@param repo string +---@param limit number +---@param callback fun(response: pipeline.providers.gitlab.graphql.QueryResponse) +function M.get_project_pipelines(repo, limit, callback) + local query_job = + glab_graphql(pipelines_with_jobs_query, { repo = repo, limit = limit }) + + query_job:start() + query_job:after(function(job) + callback(vim.json.decode(table.concat(job:result(), ''))) + end) +end + +return M diff --git a/lua/pipeline/providers/gitlab/graphql/_mapper.lua b/lua/pipeline/providers/gitlab/graphql/_mapper.lua new file mode 100644 index 0000000..3252f5d --- /dev/null +++ b/lua/pipeline/providers/gitlab/graphql/_mapper.lua @@ -0,0 +1,90 @@ +---@class pipeline.providers.gitlab.graphql.Mapper +local M = {} + +---@type table +local status_map = { + CANCELED = 'completed', + CANCELING = 'waiting', + CREATED = 'pending', + FAILED = 'completed', + MANUAL = 'unknown', + PENDING = 'pending', + PREPARING = 'queued', + RUNNING = 'in_progress', + SCHEDULED = 'queued', + SKIPPED = 'completed', + SUCCESS = 'completed', + WAITING_FOR_CALLBACK = 'waiting', + WAITING_FOR_RESOURCE = 'waiting', +} + +---@type table +local conclusion_map = { + CANCELED = 'cancelled', + CANCELING = 'cancelled', + CREATED = 'unknown', + FAILED = 'failure', + MANUAL = 'unknown', + PENDING = 'unknown', + PREPARING = 'unknown', + RUNNING = 'unknown', + SCHEDULED = 'unknown', + SKIPPED = 'skipped', + SUCCESS = 'success', + WAITING_FOR_CALLBACK = 'unknown', + WAITING_FOR_RESOURCE = 'unknown', +} + +---@param status pipeline.providers.gitlab.graphql.CiJobStatus|pipeline.providers.gitlab.graphql.PipelineStatus +---@return pipeline.Status +local function map_status(status) + return status_map[status] or 'unknown' +end + +---@param status pipeline.providers.gitlab.graphql.CiJobStatus|pipeline.providers.gitlab.graphql.PipelineStatus +---@return pipeline.Conclusion +local function map_conclusion(status) + return conclusion_map[status] or 'unknown' +end + +---@class pipeline.providers.gitlab.graphql.Pipeline: pipeline.Pipeline +---@field meta { ci_config_path: string } + +---@param project pipeline.providers.gitlab.graphql.QueryResponseProject +---@return pipeline.providers.github.rest.Pipeline +function M.to_pipeline(project) + return { + pipeline_id = project.id, + name = project.ciConfigPathOrDefault, + meta = { ci_config_path = project.ciConfigPathOrDefault }, + } +end + +---@param pipeline_id string +---@param pipeline pipeline.providers.gitlab.graphql.QueryResponsePipeline +---@return pipeline.Run +function M.to_run(pipeline_id, pipeline) + return { + run_id = pipeline.id, + pipeline_id = pipeline_id, + name = pipeline.commit.message:gsub('\n.*', ''), + url = pipeline.path, + status = map_status(pipeline.status), + conclusion = map_conclusion(pipeline.status), + } +end + +---@param run_id string +---@param job pipeline.providers.gitlab.graphql.QueryResponseJob +---@return pipeline.Job +function M.to_job(run_id, job) + return { + job_id = job.id, + run_id = run_id, + name = job.name, + status = map_status(job.status), + conclusion = map_conclusion(job.status), + } +end + +return M diff --git a/lua/pipeline/providers/gitlab/graphql/health.lua b/lua/pipeline/providers/gitlab/graphql/health.lua new file mode 100644 index 0000000..34aec79 --- /dev/null +++ b/lua/pipeline/providers/gitlab/graphql/health.lua @@ -0,0 +1,15 @@ +local M = {} + +function M.check() + local health = vim.health + + health.start('Gitlab GraphQL provider') + + if vim.fn.executable('glab') then + health.ok('Found glab cli') + else + health.error('glab cli not found') + end +end + +return M diff --git a/lua/pipeline/providers/gitlab/graphql/init.lua b/lua/pipeline/providers/gitlab/graphql/init.lua new file mode 100644 index 0000000..d08ddce --- /dev/null +++ b/lua/pipeline/providers/gitlab/graphql/init.lua @@ -0,0 +1,125 @@ +local utils = require('pipeline.utils') +local Provider = require('pipeline.providers.provider') + +local function git() + return require('pipeline.git') +end + +local function glab_api() + return require('pipeline.providers.gitlab.graphql._api') +end + +---@class pipeline.providers.gitlab.graphql.Options +---@field refresh_interval? number +local defaultOptions = { + refresh_interval = 10, +} + +---@class pipeline.providers.gitlab.graphql.Provider: pipeline.Provider +---@field protected opts pipeline.providers.gitlab.graphql.Options +---@field private server string +---@field private repo string +local GitlabGraphQLProvider = Provider:extend() + +function GitlabGraphQLProvider.detect() + if not utils.file_exists('.gitlab-ci.yml') then + return false + end + + local config = require('pipeline.config') + local server, repo = git().get_current_repository() + + if not config.is_host_allowed(server) then + return + end + + return server ~= nil and repo ~= nil +end + +---@param opts pipeline.providers.github.rest.Options +function GitlabGraphQLProvider:init(opts) + local server, repo = git().get_current_repository() + + self.opts = vim.tbl_deep_extend('force', defaultOptions, opts) + self.server = server + self.repo = repo + + self.store.update_state(function(state) + state.title = string.format('Gitlab Pipelines for %s', repo) + state.server = server + state.repo = repo + end) +end + +function GitlabGraphQLProvider:fetch() + local Mapper = require('pipeline.providers.gitlab.graphql._mapper') + + glab_api().get_project_pipelines(self.repo, 10, function(response) + if + utils.is_nil(response.data) or type(response.data.project) == 'userdata' + then + -- TODO: Handle errors + return + end + + local pipeline = Mapper.to_pipeline(response.data.project) + local runs = { + [pipeline.pipeline_id] = vim.tbl_map(function(node) + return Mapper.to_run(pipeline.pipeline_id, node) + end, response.data.project.pipelines.nodes), + } + local jobs = utils.group_by( + function(job) + return job.run_id + end, + vim + .iter(response.data.project.pipelines.nodes) + :map(function(node) + return vim.tbl_map(function(job) + return Mapper.to_job(node.id, job) + end, node.jobs.nodes) + end) + :flatten() + :totable() + ) + + self.store.update_state(function(state) + state.pipelines = { pipeline } + state.latest_run = runs[pipeline.pipeline_id][1] + state.runs = runs + state.jobs = jobs + end) + end) +end + +---@param pipeline pipeline.providers.gitlab.graphql.Pipeline|nil +function GitlabGraphQLProvider:dispatch(pipeline) + if not pipeline then + return + end + + if pipeline then + vim.notify( + 'Gitlab Pipeline dispatch is not yet implemented', + vim.log.levels.INFO + ) + end +end + +function GitlabGraphQLProvider:connect() + self.timer = vim.loop.new_timer() + self.timer:start( + 0, + self.opts.refresh_interval * 1000, + vim.schedule_wrap(function() + self:fetch() + end) + ) +end + +function GitlabGraphQLProvider:disconnect() + self.timer:stop() + self.timer = nil +end + +return GitlabGraphQLProvider diff --git a/lua/pipeline/providers/init.lua b/lua/pipeline/providers/init.lua new file mode 100644 index 0000000..34e4234 --- /dev/null +++ b/lua/pipeline/providers/init.lua @@ -0,0 +1,14 @@ +local providers = { + gitlab = 'gitlab.graphql', + github = 'github.rest', +} + +---@class pipeline.Providers: { [string]: pipeline.Provider } +---@field github pipeline.providers.github.rest.Provider +local M = setmetatable({}, { + __index = function(_, key) + return require('pipeline.providers.' .. providers[key]) + end, +}) + +return M diff --git a/lua/pipeline/providers/provider.lua b/lua/pipeline/providers/provider.lua new file mode 100644 index 0000000..6093200 --- /dev/null +++ b/lua/pipeline/providers/provider.lua @@ -0,0 +1,93 @@ +--# selene: allow(unused_variable) + +--- Generic class for all providers +--- +--- We assume the following is true for most ci/cd systems: +--- 1. There's multiple different pipelines defined +--- - Github calls these Workflow +--- - Gitlab calls these Pipeline +--- - We call it Pipeline +--- 2. Each pipeline trigger an instance of a pipeline run +--- - Github calls these WorkflowRun +--- - Gitlab calls these Pipeline (they don't seem to have a different +--- terminology for the pipeline definition and a pipeline run) +--- - We call it Run +--- 3. Each pipeline has multiple jobs +--- - We all call these Job +--- 4. Each job has a name and multiple steps +--- - Github calls these Step with an optional name +--- - Gitlab has just a list of scripts +--- - We call it Step +--- +--- Optionally, some providers have a concept of stages, ex. Gitlab, to group +--- jobs within a Pipeline. Groups can be used to sort/group these in the UI +--- tree. + +---@class pipeline.Provider +---@field protected config pipeline.Config +---@field protected store pipeline.Store +---@field private listener_count integer +local Provider = {} + +---@return pipeline.Provider +function Provider:extend() + return setmetatable({}, { + __index = self, + }) +end + +function Provider.detect() + vim.notify_once('Provider does not implement detect', vim.log.levels.WARN) + + return false +end + +---@generic T: pipeline.Provider +---@param config pipeline.Config +---@param store pipeline.Store +---@param opts? table +---@return self +function Provider:new(config, store, opts) + local instance = setmetatable({}, { + __index = self, + }) + instance.config = config + instance.store = store + instance.listener_count = 0 + instance:init(opts or {}) + return instance +end + +---Constructor function, called when creating a new Provider instance. +---@param opts table +function Provider:init(opts) end + +---Start fetching or listening to data from the provider. +function Provider:connect() end + +---Stop fetching or listening to data from the provider. +function Provider:disconnect() end + +---Dispatch a new pipeline run +---@param pipeline pipeline.Pipeline|nil +function Provider:dispatch(pipeline) end + +---Retry a failed pipeline run/job/step +---@param pipeline_object pipeline.PipelineObject|nil +function Provider:retry(pipeline_object) end + +function Provider:listen() + if self.listener_count == 0 then + self:connect() + end + self.listener_count = self.listener_count + 1 +end + +function Provider:close() + self.listener_count = self.listener_count - 1 + if self.listener_count == 0 then + self:disconnect() + end +end + +return Provider diff --git a/lua/pipeline/providers/types.lua b/lua/pipeline/providers/types.lua new file mode 100644 index 0000000..0ef5391 --- /dev/null +++ b/lua/pipeline/providers/types.lua @@ -0,0 +1,39 @@ +---@meta + +---@alias pipeline.Status 'pending'|'queued'|'requested'|'waiting'|'in_progress'|'completed'|'unknown' +---@alias pipeline.Conclusion 'success'|'failure'|'cancelled'|'skipped'|'action_required'|'unknown' + +---@class pipeline.BasePipelineObject +---@field name? string +---@field action? fun() +---@field url? string +---@field status? pipeline.Status +---@field conclusion? pipeline.Conclusion +---@field cancelable? boolean +---@field meta? table +---@package + +---A pipeline definition, in Github terms, this would be a workflow. +---@class pipeline.Pipeline: pipeline.BasePipelineObject +---@field kind 'pipeline' +---@field pipeline_id string + +---A run of a pipeline, in Github terms, this would be a workflow run. +---@class pipeline.Run: pipeline.BasePipelineObject +---@field kind 'run' +---@field run_id string +---@field pipeline_id string + +---A job within a pipeline run. +---@class pipeline.Job: pipeline.BasePipelineObject +---@field kind 'job' +---@field job_id string +---@field run_id string + +---A step within a job. +---@class pipeline.Step: pipeline.BasePipelineObject +---@field kind 'step' +---@field step_id string +---@field job_id string + +---@alias pipeline.PipelineObject pipeline.Pipeline|pipeline.Run|pipeline.Job|pipeline.Step diff --git a/lua/pipeline/store.lua b/lua/pipeline/store.lua new file mode 100644 index 0000000..e9ed5b2 --- /dev/null +++ b/lua/pipeline/store.lua @@ -0,0 +1,69 @@ +local utils = require('pipeline.utils') + +---@class pipeline.StatePipelineConfig +---@field last_read integer +---@field config table + +---@class pipeline.State +---@field title string +---@field repo string +---@field server string +---@field pipelines pipeline.Pipeline[] +---@field runs table Runs indexed by pipeline id +---@field jobs table Jobs indexed by run id +---@field steps table Steps indexed by job id +---@field workflow_configs table +local initialState = { + title = 'pipeline.nvim', + repo = '', + server = '', + pipelines = {}, + latest_run = nil, + runs = {}, + jobs = {}, + steps = {}, + workflow_configs = {}, +} + +---@class pipeline.Store +---@field package _state pipeline.State +---@field package _update fun(state: pipeline.State)[] +local M = { + _state = initialState, + _update = {}, +} + +---@param state pipeline.State +local function emit_update(state) + for _, update in ipairs(M._update) do + update(state) + end +end + +emit_update = utils.debounced(vim.schedule_wrap(emit_update)) + +---@param fn fun(render_state: pipeline.State): pipeline.State|nil +function M.update_state(fn) + M._state = fn(M._state) or M._state + + emit_update(M._state) +end + +---@param fn function +function M.on_update(fn) + table.insert(M._update, fn) +end + +---@param fn function +function M.off_update(fn) + M._update = vim.tbl_filter(function(f) + return f ~= fn + end, M._update) +end + +---@return pipeline.State +function M.get_state() + return M._state +end + +return M diff --git a/lua/gh-actions/ui.lua b/lua/pipeline/ui.lua similarity index 57% rename from lua/gh-actions/ui.lua rename to lua/pipeline/ui.lua index 55936d1..ebb2541 100644 --- a/lua/gh-actions/ui.lua +++ b/lua/pipeline/ui.lua @@ -1,11 +1,9 @@ -local Split = require('nui.split') -local Config = require('gh-actions.config') -local store = require('gh-actions.store') -local Render = require('gh-actions.ui.render') +local store = require('pipeline.store') local M = { ---@type NuiSplit split = nil, + ---@type pipeline.Render renderer = nil, } @@ -13,31 +11,36 @@ local function get_cursor_line(line) return line or vim.api.nvim_win_get_cursor(M.split.winid)[1] end ----TODO: This should be a local function +---@param kind pipeline.RenderLocationKind ---@param line? integer ----@return GhWorkflow|nil -function M.get_workflow(line) +local function get_location(kind, line) line = get_cursor_line(line) - return M.renderer:get_location('workflow', line) + return M.renderer:get_location(kind, line) end ----TODO: This should be a local function ---@param line? integer ----@return GhWorkflowRun|nil -function M.get_workflow_run(line) - line = get_cursor_line(line) +---@return pipeline.Pipeline|nil +function M.get_pipeline(line) + return get_location('pipeline', line) +end - return M.renderer:get_location('workflow_run', line) +---@param line? integer +---@return pipeline.Run|nil +function M.get_run(line) + return get_location('run', line) end ----TODO: This should be a local function ---@param line? integer ----@return GhWorkflowRunJob|nil -function M.get_workflow_job(line) - line = get_cursor_line(line) +---@return pipeline.Job|nil +function M.get_job(line) + return get_location('job', line) +end - return M.renderer:get_location('workflow_job', line) +---@param line? integer +---@return pipeline.Step|nil +function M.get_step(line) + return get_location('step', line) end local function is_visible() @@ -59,6 +62,10 @@ function M.render() end function M.setup() + local Split = require('nui.split') + local Config = require('pipeline.config') + local Render = require('pipeline.ui.render') + M.split = Split(Config.options.split) M.renderer = Render.new(store) diff --git a/lua/gh-actions/ui/buffer.lua b/lua/pipeline/ui/buffer.lua similarity index 95% rename from lua/gh-actions/ui/buffer.lua rename to lua/pipeline/ui/buffer.lua index 043614c..175c968 100644 --- a/lua/gh-actions/ui/buffer.lua +++ b/lua/pipeline/ui/buffer.lua @@ -8,23 +8,28 @@ ---@field protected _lines Line[] ---@field protected _indent number local Buffer = { - ns = vim.api.nvim_create_namespace('gh-actions'), + ns = vim.api.nvim_create_namespace('pipeline.nvim'), } ---@param opts? { indent?: integer } function Buffer.new(opts) - opts = opts or {} - local self = setmetatable({}, { __index = Buffer, }) - self._lines = {} - self._indent = opts.indent or 2 + self:init(opts) return self end +---@param opts? { indent?: integer } +function Buffer:init(opts) + opts = opts or {} + + self._lines = {} + self._indent = opts.indent or 2 +end + ---@param line Line ---@param opts? { indent?: number | nil } function Buffer:append_line(line, opts) diff --git a/lua/pipeline/ui/components/input.lua b/lua/pipeline/ui/components/input.lua new file mode 100644 index 0000000..b804777 --- /dev/null +++ b/lua/pipeline/ui/components/input.lua @@ -0,0 +1,22 @@ +---@param opts { prompt: string, title: string, default_value: string, on_submit: fun(value: string) } +local function Input(opts) + local NuiInput = require('nui.input') + + return NuiInput({ + relative = 'editor', + position = '50%', + size = { + width = #opts.prompt + 32, + }, + border = { + style = 'rounded', + text = { top = opts.title }, + }, + }, { + prompt = opts.prompt, + default_value = opts.default_value, + on_submit = opts.on_submit, + }) +end + +return Input diff --git a/lua/pipeline/ui/components/select.lua b/lua/pipeline/ui/components/select.lua new file mode 100644 index 0000000..aaece61 --- /dev/null +++ b/lua/pipeline/ui/components/select.lua @@ -0,0 +1,32 @@ +---@param opts { prompt: string, title: string, options: string[], on_submit: fun(value: { text: string }) } +local function Select(opts) + local Menu = require('nui.menu') + local lines = { Menu.separator(opts.prompt) } + + for _, option in ipairs(opts.options) do + table.insert(lines, Menu.item(option)) + end + + return Menu({ + relative = 'editor', + position = '50%', + size = { + width = #opts.prompt + 32, + }, + border = { + style = 'rounded', + text = { top = opts.title }, + }, + }, { + lines = lines, + on_submit = opts.on_submit, + keymap = { + focus_next = { 'j', '', '' }, + focus_prev = { 'k', '', '' }, + close = { '', '' }, + submit = { '', '' }, + }, + }) +end + +return Select diff --git a/lua/gh-actions/ui/render.lua b/lua/pipeline/ui/render.lua similarity index 50% rename from lua/gh-actions/ui/render.lua rename to lua/pipeline/ui/render.lua index c5865cd..22af12e 100644 --- a/lua/gh-actions/ui/render.lua +++ b/lua/pipeline/ui/render.lua @@ -1,23 +1,25 @@ -local Config = require('gh-actions.config') -local Buffer = require('gh-actions.ui.buffer') -local utils = require('gh-actions.utils') +local Config = require('pipeline.config') +local Buffer = require('pipeline.ui.buffer') +local utils = require('pipeline.utils') ---TODO: Shade background like https://github.com/akinsho/toggleterm.nvim/blob/2e477f7ee8ee8229ff3158e3018a067797b9cd38/lua/toggleterm/colors.lua ----@class GhActionsRenderLocation +---@alias pipeline.RenderLocationKind 'pipeline'|'run'|'job'|'step' + +---@class pipeline.RenderLocation ---@field value any ----@field kind string +---@field kind pipeline.RenderLocationKind ---@field from? integer ---@field to? integer ----@class GhActionsRender:Buffer ----@field store { get_state: fun(): GhActionsState } ----@field locations GhActionsRenderLocation[] -local GhActionsRender = { +---@class pipeline.Render:Buffer +---@field store { get_state: fun(): pipeline.State } +---@field locations pipeline.RenderLocation[] +local PipelineRender = { locations = {}, } -setmetatable(GhActionsRender, { __index = Buffer }) +setmetatable(PipelineRender, { __index = Buffer }) ---@param run { status: string, conclusion: string } ---@return string @@ -43,23 +45,24 @@ local function get_status_highlight(run, prefix) end if run.status == 'completed' then - return 'GhActions' + return 'Pipeline' .. utils.string.upper_first(prefix) .. utils.string.upper_first(run.conclusion) end - return 'GhActions' + return 'Pipeline' .. utils.string.upper_first(prefix) .. utils.string.upper_first(run.status) end ----@param store { get_state: fun(): GhActionsState } ----@return GhActionsRender -function GhActionsRender.new(store) - local self = setmetatable(Buffer.new { indent = Config.options.indent }, { - __index = GhActionsRender, +---@param store { get_state: fun(): pipeline.State } +---@return pipeline.Render +function PipelineRender.new(store) + local self = setmetatable({}, { + __index = PipelineRender, }) - ---@cast self GhActionsRender + + Buffer.init(self, { indent = Config.options.indent }) self.store = store @@ -67,60 +70,47 @@ function GhActionsRender.new(store) end ---@param bufnr integer -function GhActionsRender:render(bufnr) +function PipelineRender:render(bufnr) self._lines = {} self.locations = {} local state = self.store:get_state() self:title(state) - self:workflows(state) + self:pipelines(state) self:trim() Buffer.render(self, bufnr) end --- Render title of the split window ----@param state GhActionsState -function GhActionsRender:title(state) - if not state.repo then - self:append('Github Workflows'):nl():nl() - else - self:append(string.format('Github Workflows for %s', state.repo)):nl():nl() - end +---@param state pipeline.State +function PipelineRender:title(state) + self:append(state.title):nl():nl() end ---- Render each workflow ----@param state GhActionsState -function GhActionsRender:workflows(state) - local workflows = state.workflows - local workflow_runs = state.workflow_runs - - local workflow_runs_by_workflow_id = utils.group_by(function(workflow_run) - return workflow_run.workflow_id - end, workflow_runs) - - for _, workflow in ipairs(workflows) do - local runs = workflow_runs_by_workflow_id[workflow.id] or {} - - self:workflow(state, workflow, runs) +--- Render each pipeline +---@param state pipeline.State +function PipelineRender:pipelines(state) + for _, pipeline in ipairs(state.pipelines) do + self:pipeline(state, pipeline, state.runs[pipeline.pipeline_id] or {}) end end ----@param state GhActionsState ----@param workflow GhWorkflow ----@param runs GhWorkflowRun[] -function GhActionsRender:workflow(state, workflow, runs) - self:with_location({ kind = 'workflow', value = workflow }, function() +---@param state pipeline.State +---@param pipeline pipeline.Pipeline +---@param runs pipeline.Run[] +function PipelineRender:pipeline(state, pipeline, runs) + self:with_location({ kind = 'pipeline', value = pipeline }, function() local runs_n = math.min(5, #runs) self :status_icon(runs[1]) :append(' ') - :append(workflow.name, get_status_highlight(runs[1], 'run')) + :append(pipeline.name, get_status_highlight(runs[1], 'run')) :append( - state.workflow_configs[workflow.id] - and state.workflow_configs[workflow.id].config.on.workflow_dispatch + state.workflow_configs[pipeline.pipeline_id] + and state.workflow_configs[pipeline.pipeline_id].config.on.workflow_dispatch and (' ' .. Config.options.icons.workflow_dispatch) or '' ) @@ -129,7 +119,7 @@ function GhActionsRender:workflow(state, workflow, runs) -- TODO cutting down on how many we list here, as we fetch 100 overall repo -- runs on opening the split. I guess we do want to have this configurable. for _, run in ipairs { unpack(runs, 1, runs_n) } do - self:workflow_run(state, run) + self:run(state, run) end end) @@ -138,47 +128,45 @@ function GhActionsRender:workflow(state, workflow, runs) end end ----@param state GhActionsState ----@param run GhWorkflowRun -function GhActionsRender:workflow_run(state, run) - self:with_location({ kind = 'workflow_run', value = run }, function() +---@param state pipeline.State +---@param run pipeline.Run +function PipelineRender:run(state, run) + self:with_location({ kind = 'run', value = run }, function() self :status_icon(run, { indent = 1 }) :append(' ') - :append( - run.head_commit.message:gsub('\n.*', ''), - get_status_highlight(run, 'run') - ) + :append(run.name, get_status_highlight(run, 'run')) :nl() if run.status ~= 'completed' then - for _, job in ipairs(state.workflow_jobs[run.id] or {}) do - self:workflow_job(job) + for _, job in ipairs(state.jobs[run.run_id] or {}) do + self:job(state, job) end end end) end ----@param job GhWorkflowRunJob -function GhActionsRender:workflow_job(job) - self:with_location({ kind = 'workflow_job', value = job }, function() +---@param state pipeline.State +---@param job pipeline.Job +function PipelineRender:job(state, job) + self:with_location({ kind = 'job', value = job }, function() self :status_icon(job, { indent = 2 }) :append(' ') :append(job.name, get_status_highlight(job, 'job')) :nl() - if job.status ~= 'completed' then - for _, step in ipairs(job.steps) do - self:workflow_step(step) + if job.status ~= 'completed' and state.steps[job.job_id] then + for _, step in ipairs(state.steps[job.job_id]) do + self:step(step) end end end) end ----@param step GhWorkflowRunJobStep -function GhActionsRender:workflow_step(step) - self:with_location({ kind = 'workflow_step', value = step }, function() +---@param step pipeline.Step +function PipelineRender:step(step) + self:with_location({ kind = 'step', value = step }, function() self :status_icon(step, { indent = 3 }) :append(' ') @@ -189,7 +177,7 @@ end ---@param status { status: string, conclusion: string } ---@param opts? { indent?: number | nil } -function GhActionsRender:status_icon(status, opts) +function PipelineRender:status_icon(status, opts) opts = opts or {} self:append( @@ -201,17 +189,17 @@ function GhActionsRender:status_icon(status, opts) return self end ----@param location GhActionsRenderLocation -function GhActionsRender:append_location(location) +---@param location pipeline.RenderLocation +function PipelineRender:append_location(location) table.insert( self.locations, vim.tbl_extend('keep', location, { to = self:get_current_line_nr() - 1 }) ) end ----@param kind string +---@param kind pipeline.RenderLocationKind ---@param line integer -function GhActionsRender:get_location(kind, line) +function PipelineRender:get_location(kind, line) for _, loc in ipairs(self.locations) do if loc.kind == kind and line >= loc.from and line <= loc.to then return loc.value @@ -219,9 +207,9 @@ function GhActionsRender:get_location(kind, line) end end ----@param location GhActionsRenderLocation +---@param location pipeline.RenderLocation ---@param fn fun() -function GhActionsRender:with_location(location, fn) +function PipelineRender:with_location(location, fn) local start_line_nr = self:get_current_line_nr() fn() @@ -231,4 +219,4 @@ function GhActionsRender:with_location(location, fn) ) end -return GhActionsRender +return PipelineRender diff --git a/lua/gh-actions/utils.lua b/lua/pipeline/utils.lua similarity index 94% rename from lua/gh-actions/utils.lua rename to lua/pipeline/utils.lua index f73aa36..615e613 100644 --- a/lua/gh-actions/utils.lua +++ b/lua/pipeline/utils.lua @@ -1,4 +1,4 @@ -local stringUtils = require('gh-actions.utils.string') +local stringUtils = require('pipeline.utils.string') local M = { string = stringUtils, @@ -105,7 +105,7 @@ function M.open(uri) return M.float { style = '', file = uri } end - local Config = require('gh-actions.config') + local Config = require('pipeline.config') local cmd if Config.options.browser then @@ -141,7 +141,7 @@ end function M.is_nil(value) return value == nil or value == vim.NIL - or require('gh-actions.yaml').is_yaml_nil(value) + or require('pipeline.yaml').is_yaml_nil(value) end return M diff --git a/lua/gh-actions/utils/icons.lua b/lua/pipeline/utils/icons.lua similarity index 87% rename from lua/gh-actions/utils/icons.lua rename to lua/pipeline/utils/icons.lua index 5c32eaa..57df520 100644 --- a/lua/gh-actions/utils/icons.lua +++ b/lua/pipeline/utils/icons.lua @@ -1,10 +1,10 @@ local function Config() - return require('gh-actions.config') + return require('pipeline.config') end local M = {} ----@return GhActionsIcons +---@return pipeline.config.Icons function M.get_icons() return Config().options.icons end diff --git a/lua/gh-actions/utils/string.lua b/lua/pipeline/utils/string.lua similarity index 100% rename from lua/gh-actions/utils/string.lua rename to lua/pipeline/utils/string.lua diff --git a/lua/gh-actions/yaml.lua b/lua/pipeline/yaml.lua similarity index 65% rename from lua/gh-actions/yaml.lua rename to lua/pipeline/yaml.lua index 9a6abad..0ad0eb9 100644 --- a/lua/gh-actions/yaml.lua +++ b/lua/pipeline/yaml.lua @@ -1,5 +1,5 @@ -local utils = require('gh-actions.utils') -local has_rust_module, rust = pcall(require, 'gh-actions.rust') +local utils = require('pipeline.utils') +local has_native_module, native_yaml = pcall(require, 'pipeline_native.yaml') local M = {} @@ -14,8 +14,8 @@ end ---@param yamlstr string|nil ---@return table|nil function M.parse_yaml(yamlstr) - if has_rust_module then - return rust.parse_yaml(yamlstr or '') + if has_native_module then + return native_yaml.parse_yaml(yamlstr or '') else local result = vim .system({ 'yq', '-j' }, { @@ -28,7 +28,7 @@ function M.parse_yaml(yamlstr) end function M.is_yaml_nil(value) - return has_rust_module and value == rust.NIL + return has_native_module and value == native_yaml.NIL end return M diff --git a/lua/pipeline_native/.gitkeep b/lua/pipeline_native/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/lua/pipeline_native/yaml.meta.lua b/lua/pipeline_native/yaml.meta.lua new file mode 100644 index 0000000..2bd405e --- /dev/null +++ b/lua/pipeline_native/yaml.meta.lua @@ -0,0 +1,13 @@ +---@meta pipeline_native.yaml +--# selene: allow(unused_variable) + +local M = {} + +---@param yamlstr string +---@return table|nil +function M.parse_yaml(yamlstr) end + +---@type unknown +M.NIL = nil + +return M diff --git a/src/lib.rs b/src/lib.rs index 21e3145..6a1f613 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,14 +2,14 @@ use mlua::prelude::{Lua, LuaError, LuaResult, LuaTable}; use mlua::LuaSerdeExt; use serde_yaml::Value as YamlValue; -fn yaml_to_lua(lua: &Lua, yamlstr: String) -> LuaResult> { +fn yaml_to_lua(lua: &Lua, yamlstr: String) -> LuaResult { let yaml: YamlValue = serde_yaml::from_str(&yamlstr).map_err(LuaError::external)?; lua.to_value(&yaml) } #[mlua::lua_module] -fn libgh_actions_rust(lua: &Lua) -> LuaResult { +fn pipeline_native_yaml(lua: &Lua) -> LuaResult { let exports = lua.create_table()?; exports.set("NIL", lua.null())?; diff --git a/tests/github_spec.lua b/tests/github_spec.lua index 868ddb7..85d6f77 100644 --- a/tests/github_spec.lua +++ b/tests/github_spec.lua @@ -1,6 +1,6 @@ -local gh = require('gh-actions.github') - describe('get_github_token', function() + local gh = require('pipeline.providers.github.utils') + before_each(function() vim.env.GITHUB_TOKEN = nil end) diff --git a/tests/init.lua b/tests/init.lua new file mode 100644 index 0000000..646b826 --- /dev/null +++ b/tests/init.lua @@ -0,0 +1,28 @@ +-- mimic startup option `--clean` +local function clean_startup() + for _, path in ipairs(vim.split(vim.o.runtimepath, ',')) do + if + string.find(path, vim.fn.expand('~/.config/nvim')) + or string.find(path, vim.fn.expand('~/.local/share/nvim/site')) + then + vim.opt.packpath:remove(path) + vim.opt.runtimepath:remove(path) + end + end +end + +clean_startup() + +local root_dir = vim.fn + .fnamemodify(vim.trim(vim.fn.system('git rev-parse --show-toplevel')), ':p') + :gsub('/$', '') + +package.path = + string.format('%s;%s/?.lua;%s/?/init.lua', package.path, root_dir, root_dir) + +vim.opt.packpath:prepend(root_dir .. '/.tests/site') + +vim.cmd([[ + packadd plenary.nvim + packadd nui.nvim +]]) diff --git a/tests/minimal.vim b/tests/minimal.vim deleted file mode 100644 index fea3608..0000000 --- a/tests/minimal.vim +++ /dev/null @@ -1,6 +0,0 @@ -set rtp+=. -set rtp+=../plenary.nvim -set rtp+=../nui.nvim - -runtime! plugin/plenary.vim -runtime! plugin/nui.vim diff --git a/tests/minimal_init.vim b/tests/minimal_init.vim deleted file mode 120000 index 636c5dd..0000000 --- a/tests/minimal_init.vim +++ /dev/null @@ -1 +0,0 @@ -minimal.vim \ No newline at end of file diff --git a/tests/utils_spec.lua b/tests/utils_spec.lua index 86a5832..9c67eb8 100644 --- a/tests/utils_spec.lua +++ b/tests/utils_spec.lua @@ -1,13 +1,16 @@ -local utils = require("gh-actions.utils") +local utils = require('pipeline.utils') -describe("uniq", function() - it("it remove duplicate values from table", function() +describe('uniq', function() + it('it remove duplicate values from table', function() local list = { { id = 1 }, { id = 1 }, { id = 2 }, { id = 1 }, { id = 3 } } local function get_id(li) return li.id end - assert.are.same(utils.uniq(get_id, list), { { id = 1 }, { id = 2 }, { id = 3 } }) + assert.are.same( + utils.uniq(get_id, list), + { { id = 1 }, { id = 2 }, { id = 3 } } + ) end) end)