diff --git a/.github/workflows/new-plugin.yml b/.github/workflows/new-plugin.yml new file mode 100644 index 0000000000..39b1fbb869 --- /dev/null +++ b/.github/workflows/new-plugin.yml @@ -0,0 +1,115 @@ +name: new-plugin +on: + push: + branches: + - "main" + paths: + - "plugins/**" + +# Prevent running this workflow concurrently +concurrency: + group: "matrix-messages" + +jobs: + setup: + name: Collect metadata and setup job matrix + runs-on: ubuntu-latest + timeout-minutes: 40 + if: github.repository == 'nix-community/nixvim' + outputs: + json: ${{ steps.get_info.outputs.json }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Nix + uses: cachix/install-nix-action@v26 + with: + nix_path: nixpkgs=channel:nixos-unstable + github_access_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Get plugins info + id: get_info + run: | + # Run the `plugin-info` tool, printing the result to write to GITHUB_OUTPUT + echo "json=$( + nix run .#plugin-info -- \ + -c \ + 'github:${{ github.repository }}/${{ github.event.before }}' + )" >> $GITHUB_OUTPUT + + send: + name: Send matrix message + runs-on: ubuntu-latest + needs: setup + if: ${{ fromJSON(needs.setup.outputs.json).removed == [] && fromJSON(needs.setup.outputs.json).added != [] }} + strategy: + matrix: + added: ${{ fromJSON(needs.setup.outputs.json).added }} + env: + name: ${{ matrix.added.name }} + plain: ${{ matrix.added.plain }} + html: ${{ matrix.added.html }} + markdown: ${{ matrix.added.markdown }} + + steps: + - name: Install matrix-msg tool + uses: lkiesow/matrix-notification@v1 + with: + token: ${{ secrets.CI_MATRIX_TOKEN }} + server: ${{ secrets.CI_MATRIX_SERVER }} + room: ${{ secrets.CI_MATRIX_ROOM }} + tool: true + + - name: Send message and print summary + run: | + # stdout + echo "$plain" + + # markdown summary + echo "$markdown" >> $GITHUG_STEP_SUMMARY + echo -e "\n---\n" >> $GITHUG_STEP_SUMMARY + + # matrix message + if matrix-msg "$plain" "$html" + then + echo "Matrix message sent successfully" >> $GITHUG_STEP_SUMMARY + else + echo "Matrix message failed" >> $GITHUG_STEP_SUMMARY + fi + + report: + name: Report removed plugins + runs-on: ubuntu-latest + needs: setup + if: ${{ fromJSON(needs.setup.outputs.json).removed != [] }} + env: + json: ${{ needs.setup.outputs.json }} + + steps: + - name: Comment on PR + if: ${{ fromJSON(needs.setup.outputs.json).pr }} + env: + GH_TOKEN: ${{ github.token }} + num: ${{ fromJSON(needs.setup.outputs.json).pr.number }} + run: | + body=" + > [!WARNING] + > Not posting announcements to Matrix because plugins were removed in this PR. + > Please post announcements manually. + + Added: + $(echo "$json" | jq -r '.added[] | "- \(.name)"') + + Removed: + $(echo "$json" | jq -r '.removed[] | "- \(.)"') + " + + # Markdown summary + echo "$body" >> $GITHUG_STEP_SUMMARY + + # PR comment + gh pr comment "$num" \ + --repo '${{ github.repository }}' \ + --body "$body" diff --git a/docs/mdbook/default.nix b/docs/mdbook/default.nix index 93d3585f59..b2fa803069 100644 --- a/docs/mdbook/default.nix +++ b/docs/mdbook/default.nix @@ -63,13 +63,13 @@ let maintToMD = m: if m ? github then "[${m.name}](https://github.com/${m.github})" else m.name; in # Make sure this path has a valid info attrset - if info ? file && info ? description && info ? url then + if info._type or null == "nixvimInfo" then "# ${lib.last path}\n\n" - + (lib.optionalString (info.url != null) "**URL:** [${info.url}](${info.url})\n\n") + + (lib.optionalString (info.url or null != null) "**URL:** [${info.url}](${info.url})\n\n") + (lib.optionalString ( maintainers != [ ] ) "**Maintainers:** ${lib.concatStringsSep ", " maintainersNames}\n\n") - + lib.optionalString (info.description != null) '' + + lib.optionalString (info.description or null != null) '' --- diff --git a/flake-modules/dev/ci-new-plugin-matrix.nix b/flake-modules/dev/ci-new-plugin-matrix.nix new file mode 100644 index 0000000000..ca540de5b3 --- /dev/null +++ b/flake-modules/dev/ci-new-plugin-matrix.nix @@ -0,0 +1,15 @@ +{ + perSystem = + { pkgs, ... }: + { + apps.ci-new-plugin-matrix.program = pkgs.writers.writePython3Bin "test_python3" { + libraries = with pkgs.python3Packages; [ + requests + ]; + flakeIgnore = [ + "E501" # Line length + "W503" # Line break before binary operator + ]; + } (builtins.readFile ./ci-new-plugin-matrix.py); + }; +} diff --git a/flake-modules/dev/ci-new-plugin-matrix.py b/flake-modules/dev/ci-new-plugin-matrix.py new file mode 100644 index 0000000000..07ac61b3ce --- /dev/null +++ b/flake-modules/dev/ci-new-plugin-matrix.py @@ -0,0 +1,354 @@ +import argparse +import enum +import json +import os +import re +import subprocess +from sys import stderr + +import requests + + +class Format(enum.Enum): + PLAIN = "plain" + HTML = "html" + MARKDOWN = "markdown" + + +icons = { + "plugin": "💾", + "colorscheme": "🎨", +} + + +def main(args) -> None: + plugins = {"old": get_plugins(args.old), "new": get_plugins(".")} + + # TODO: also guess at "renamed" plugins heuristically + plugin_diff = { + "added": { + ns: plugins["new"][ns] - plugins["old"][ns] + for ns in ["plugins", "colorschemes"] + }, + "removed": { + ns: plugins["old"][ns] - plugins["new"][ns] + for ns in ["plugins", "colorschemes"] + }, + } + + # Flatten the above dict into a list of entries; + # each with 'name' and 'namespace' keys + plugin_entries = { + action: [ + {"name": name, "namespace": namespace} + for namespace, plugins in namespaces.items() + for name in plugins + ] + for action, namespaces in plugin_diff.items() + } + + # Only lookup PR if something was added or removed + if plugin_entries["added"] or plugin_entries["removed"]: + if pr := get_pr( + sha=args.head, + # TODO: should this be configurable? + repo="nix-community/nixvim", + token=args.token, + ): + plugin_entries["pr"] = { + "number": pr["number"], + "url": pr["html_url"], + "author_name": pr["user"]["login"], + "author_url": pr["user"]["html_url"], + } + + # Expand the added plugins with additional metadata from the flake + if "added" in plugin_entries: + print("About to add meta to added plugins") + plugin_entries["added"] = get_plugin_meta(plugin_entries["added"]) + apply_fallback_descriptions(plugin_entries["added"], token=args.token) + + # Unless `--raw`, we should produce formatted text for added plugins + if not args.raw and "added" in plugin_entries: + pr = plugin_entries.get("pr") or None + plugin_entries.update( + added=[ + { + "name": plugin["name"], + "plain": render_added_plugin(plugin, pr, Format.PLAIN), + "html": render_added_plugin(plugin, pr, Format.HTML), + "markdown": render_added_plugin(plugin, pr, Format.MARKDOWN), + } + for plugin in plugin_entries["added"] + ] + ) + + # Print json for use in CI matrix + print( + json.dumps( + plugin_entries, + separators=((",", ":") if args.compact else None), + indent=(None if args.compact else 4), + sort_keys=(not args.compact), + ) + ) + + +# Gets a list of plugins that exist in the flake. +# Grouped as "plugins" and "colorschemes" +def get_plugins(flake: str) -> list[str]: + expr = ( + "options: " + "with builtins; " + "listToAttrs (" + " map" + " (name: { inherit name; value = attrNames options.${name}; })" + ' [ "plugins" "colorschemes" ]' + ")" + ) + cmd = [ + "nix", + "eval", + f"{flake}#nixvimConfiguration.options", + "--apply", + expr, + "--json", + ] + out = subprocess.check_output(cmd) + # Parse as json, converting the lists to sets + return {k: set(v) for k, v in json.loads(out).items()} + + +# Map a list of plugin entries, populating them with additional metadata +def get_plugin_meta(plugins: list[dict]) -> list[dict]: + expr = ( + "cfg: " + "with builtins; " + "let " + # Assume the json won't include any double-single-quotes: + f" plugins = fromJSON ''{json.dumps(plugins, separators=(",", ":"))}'';" + "in " + "map ({name, namespace}: let " + " nixvimInfo = cfg.config.meta.nixvimInfo.${namespace}.${name};" + " package = cfg.options.${namespace}.${name}.package.default; " + "in {" + " inherit name namespace;" + " url = nixvimInfo.url or package.meta.homepage or null;" + " display_name = nixvimInfo.originalName or name;" + ' description = package.meta.description or null;' + "}) plugins" + ) + cmd = [ + "nix", + "eval", + ".#nixvimConfiguration", + "--apply", + expr, + "--json", + ] + out = subprocess.check_output(cmd) + return json.loads(out) + + +# Walks the plugin list, fetching descriptions from github if missing +def apply_fallback_descriptions(plugins: list[dict], token: str): + gh_rxp = re.compile( + r"^https?://github.com/(?P[^/]+)/(?P[^/]+)(?:[/#?].*)?$" + ) + for plugin in plugins: + if plugin.get("description"): + continue + if m := gh_rxp.match(plugin["url"]): + plugin["description"] = ( + get_github_description( + owner=m.group("owner"), repo=m.group("repo"), token=token + ) + or None + ) + continue + plugin["description"] = None + + +# TODO: extract the HTTP request logic into a shared function +def get_github_description(owner: str, repo: str, token: str) -> str: + headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + if token: + headers["Authorization"] = f"Bearer {token}" + + res = requests.get( + url=f"https://api.github.com/repos/{owner}/{repo}", headers=headers + ) + + if res.status_code != 200: + try: + message = res.json()["message"] + except requests.exceptions.JSONDecodeError: + message = res.text + # FIXME: Maybe we should panic and fail CI? + print(f"{message} (HTTP {res.status_code})", file=stderr) + return None + + if data := res.json(): + return data["description"] + return None + + +def get_head_sha() -> str: + return subprocess.check_output(["git", "rev-parse", "HEAD"]).decode("ascii").strip() + + +# TODO: extract the HTTP request logic into a shared function +def get_pr(sha: str, repo: str, token: str = None) -> dict: + headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + if token: + headers["Authorization"] = f"Bearer {token}" + + res = requests.get( + url=f"https://api.github.com/repos/{repo}/commits/{sha}/pulls", headers=headers + ) + + if res.status_code != 200: + try: + message = res.json()["message"] + except requests.exceptions.JSONDecodeError: + message = res.text + # FIXME: Maybe we should panic and fail CI? + print(f"{message} (HTTP {res.status_code})", file=stderr) + return None + + # If no matching PR exists, an empty list is returned + if data := res.json(): + return data[0] + return None + + +def render_added_plugin(plugin: dict, pr: dict, format: Format) -> str: + name = plugin["name"] + display_name = plugin["display_name"] + namespace = plugin["namespace"] + kind = namespace[:-1] + plugin_url = plugin["url"] + docs_url = f"https://nix-community.github.io/nixvim/{namespace}/{name}/index.html" + + match format: + case Format.PLAIN: + return ( + f"[{icons[kind]} NEW {kind.upper()}]\n\n" + f"{display_name} support has been added!\n\n" + + ( + f"Description: {desc}\n" + if (desc := plugin.get("description")) + else "" + ) + + f"URL: {plugin_url}" + f"Docs: {docs_url}\n" + + ( + f"PR #{pr['number']} by {pr['author_name']}: {pr['url']}\n" + if pr + else "No PR\n" + ) + ) + case Format.HTML: + # TODO: render from the markdown below? + return ( + f"

[{icons[kind]} NEW {kind.upper()}]

\n" + f'

{display_name} support has been added!

\n' + "

\n" + + ( + f"Description: {desc}
\n" + if (desc := plugin.get("description")) + else "" + ) + + f'PR #{pr['number']} by {pr['author_name']}\n' + if pr + else "
No PR\n" + ) + + "

\n" + ) + case Format.MARKDOWN: + return ( + f"\\[{icons[kind]} NEW {kind.upper()}\\]\n\n" + f"[{display_name}]({plugin_url}) support has been added!\n\n" + + ( + f"Description: {desc}\n" + if (desc := plugin.get("description")) + else "" + ) + + f"[Documentation]({docs_url})\n" + + ( + f'[PR \\#{pr['number']}]({pr['url']}) by [{pr['author_name']}]({pr['author_url']})\n' + if pr + else "No PR\n" + ) + ) + + +# Describes an argparse type that should represent a flakeref, +# or a partial flakeref that we can normalise using some defaults. +def flakeref(arg): + default_protocol = "github:" + default_repo = "nix-community/nixvim" + sha_rxp = re.compile(r"^[A-Fa-f0-9]{6,40}$") + repo_rxp = re.compile( + r"^(?P[^:/]+:)?(?P(:?[^/]+)/(:?[^/]+))(?P/[A-Fa-f0-9]{6,40})?$" + ) + if sha_rxp.match(arg): + return f"{default_protocol}{default_repo}/{arg}" + elif m := repo_rxp.match(arg): + protocol = m.group("protocol") or default_protocol + repo = m.group("repo") + sha = m.group("sha") or "" + return protocol + repo + sha + else: + raise argparse.ArgumentTypeError(f"Not a valid flakeref: {arg}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + prog="ci-new-plugin-matrix", + description="Generate a JSON matrix for use in CI, describing newly added plugins.", + ) + parser.add_argument( + "old", + metavar="flakeref", + help="the (old) flake ref to compare against", + type=flakeref, + ) + parser.add_argument( + "--head", + help="(optional) the current git commit, will default to using `git rev-parse HEAD`", + ) + parser.add_argument( + "--github-token", + dest="token", + help="(optional) github token, the GITHUB_TOKEN environment variable is used as a fallback", + ) + parser.add_argument( + "--compact", + "-c", + help="produce compact json instead of prettifying", + action="store_true", + ) + parser.add_argument( + "--raw", + "-r", + help="produce raw data instead of message strings", + action="store_true", + ) + args = parser.parse_args() + + # Handle defaults lazily + if not args.token: + args.token = os.getenv("GITHUB_TOKEN") + if not args.head: + args.head = get_head_sha() + + main(args) diff --git a/flake-modules/dev/default.nix b/flake-modules/dev/default.nix index 2f76438a1c..32821276d8 100644 --- a/flake-modules/dev/default.nix +++ b/flake-modules/dev/default.nix @@ -1,7 +1,10 @@ { lib, inputs, ... }: { imports = - [ ./devshell.nix ] + [ + ./ci-new-plugin-matrix.nix + ./devshell.nix + ] ++ lib.optional (inputs.git-hooks ? flakeModule) inputs.git-hooks.flakeModule ++ lib.optional (inputs.treefmt-nix ? flakeModule) inputs.treefmt-nix.flakeModule; diff --git a/lib/neovim-plugin.nix b/lib/neovim-plugin.nix index fce2c0a7fe..85133f15b2 100644 --- a/lib/neovim-plugin.nix +++ b/lib/neovim-plugin.nix @@ -66,7 +66,7 @@ meta = { inherit maintainers; nixvimInfo = { - inherit description; + inherit description originalName; url = args.url or opt.package.default.meta.homepage; path = [ namespace diff --git a/lib/vim-plugin.nix b/lib/vim-plugin.nix index c7c9406d69..335a5090d4 100644 --- a/lib/vim-plugin.nix +++ b/lib/vim-plugin.nix @@ -66,7 +66,7 @@ meta = { inherit maintainers; nixvimInfo = { - inherit description; + inherit description originalName; url = args.url or opt.package.default.meta.homepage; path = [ namespace diff --git a/modules/misc/nixvim-info.nix b/modules/misc/nixvim-info.nix index 219f9202ac..e135a96540 100644 --- a/modules/misc/nixvim-info.nix +++ b/modules/misc/nixvim-info.nix @@ -25,11 +25,13 @@ ( acc: def: lib.recursiveUpdate acc ( - lib.setAttrByPath def.value.path { - inherit (def) file; - url = def.value.url or null; - description = def.value.description or null; - } + lib.setAttrByPath def.value.path ( + { + inherit (def) file; + _type = "nixvimInfo"; + } + // builtins.removeAttrs def.value [ "path" ] + ) ) ) {