From e8005d880ab28387670735743a78afb4ffde5a1c Mon Sep 17 00:00:00 2001 From: jeanluc Date: Tue, 13 Oct 2026 18:19:35 +0200 Subject: [PATCH] Expose repos as `github` resource in 3008.3+ --- .pre-commit-hooks/make-autodocs.py | 263 +++++- changelog/+resource_type.added.md | 1 + changelog/+sdb-profile-values.added.md | 2 +- changelog/+user_owned_repos.added.md | 2 +- docs/index.rst | 12 + ...ithub.resources.github.modules.actions.rst | 5 + ...ub.resources.github.modules.deploy_key.rst | 5 + ...b.resources.github.modules.environment.rst | 5 + ....github.resources.github.modules.issue.rst | 5 + ....github.resources.github.modules.label.rst | 5 + ...hub.resources.github.modules.milestone.rst | 5 + ....github.resources.github.modules.pages.rst | 5 + ...t.github.resources.github.modules.pull.rst | 5 + ...t.github.resources.github.modules.repo.rst | 5 + ...altext.github.resources.github.modules.rst | 19 + ...ithub.resources.github.modules.ruleset.rst | 5 + ...t.github.resources.github.modules.test.rst | 5 + ...github.resources.github.states.actions.rst | 5 + ...hub.resources.github.states.deploy_key.rst | 5 + ...ub.resources.github.states.environment.rst | 5 + ...t.github.resources.github.states.label.rst | 5 + ...t.github.resources.github.states.pages.rst | 5 + ...xt.github.resources.github.states.repo.rst | 5 + ...saltext.github.resources.github.states.rst | 15 + ...github.resources.github.states.ruleset.rst | 5 + .../saltext.github.resources.github.rst | 14 + docs/ref/utils/index.rst | 8 + ...ext.github.utils.gh.statecores.actions.rst | 5 + ...github.utils.gh.statecores.deploy_keys.rst | 5 + ...ithub.utils.gh.statecores.environments.rst | 5 + ...text.github.utils.gh.statecores.labels.rst | 5 + ...ltext.github.utils.gh.statecores.pages.rst | 5 + ...altext.github.utils.gh.statecores.repo.rst | 5 + .../saltext.github.utils.gh.statecores.rst | 5 + ...xt.github.utils.gh.statecores.rulesets.rst | 5 + docs/topics/configuration.md | 213 ++++- docs/topics/repo_resources.md | 139 +++ src/saltext/github/modules/github.py | 8 +- src/saltext/github/modules/github_org.py | 18 +- src/saltext/github/modules/github_repo.py | 6 +- src/saltext/github/resources/__init__.py | 0 .../github/resources/github/__init__.py | 215 +++++ .../resources/github/modules/__init__.py | 0 .../resources/github/modules/actions.py | 180 ++++ .../resources/github/modules/deploy_key.py | 127 +++ .../resources/github/modules/environment.py | 248 +++++ .../github/resources/github/modules/issue.py | 162 ++++ .../github/resources/github/modules/label.py | 166 ++++ .../resources/github/modules/milestone.py | 115 +++ .../github/resources/github/modules/pages.py | 154 +++ .../github/resources/github/modules/pull.py | 95 ++ .../github/resources/github/modules/repo.py | 164 ++++ .../resources/github/modules/ruleset.py | 151 +++ .../github/resources/github/modules/test.py | 29 + .../resources/github/states/__init__.py | 0 .../github/resources/github/states/actions.py | 143 +++ .../resources/github/states/deploy_key.py | 108 +++ .../resources/github/states/environment.py | 123 +++ .../github/resources/github/states/label.py | 95 ++ .../github/resources/github/states/pages.py | 116 +++ .../github/resources/github/states/repo.py | 129 +++ .../github/resources/github/states/ruleset.py | 105 +++ src/saltext/github/states/github_repo.py | 880 ++++-------------- src/saltext/github/utils/gh/deploy_keys.py | 11 + src/saltext/github/utils/gh/environments.py | 25 + src/saltext/github/utils/gh/profile.py | 316 ++++++- .../github/utils/gh/statecores/__init__.py | 24 + .../github/utils/gh/statecores/actions.py | 180 ++++ .../github/utils/gh/statecores/deploy_keys.py | 106 +++ .../utils/gh/statecores/environments.py | 194 ++++ .../github/utils/gh/statecores/labels.py | 116 +++ .../github/utils/gh/statecores/pages.py | 177 ++++ .../github/utils/gh/statecores/repo.py | 251 +++++ .../github/utils/gh/statecores/rulesets.py | 115 +++ src/saltext/github/utils/gh/teams.py | 31 + tests/functional/resources/__init__.py | 0 tests/functional/resources/conftest.py | 115 +++ .../functional/resources/modules/__init__.py | 0 .../resources/modules/test_actions.py | 31 + .../resources/modules/test_deploy_key.py | 16 + .../resources/modules/test_environment.py | 28 + .../resources/modules/test_issue.py | 13 + .../resources/modules/test_label.py | 14 + .../resources/modules/test_milestone.py | 10 + .../resources/modules/test_pages.py | 21 + .../functional/resources/modules/test_pull.py | 4 + .../functional/resources/modules/test_repo.py | 42 + .../resources/modules/test_ruleset.py | 16 + .../functional/resources/modules/test_test.py | 5 + tests/functional/resources/states/__init__.py | 0 tests/functional/resources/states/conftest.py | 41 + .../resources/states/test_actions.py | 47 + .../resources/states/test_deploy_key.py | 39 + .../resources/states/test_environment.py | 33 + .../functional/resources/states/test_label.py | 49 + .../functional/resources/states/test_pages.py | 35 + .../functional/resources/states/test_repo.py | 44 + .../resources/states/test_ruleset.py | 34 + tests/functional/resources/test_connection.py | 325 +++++++ .../resources/test_multi_profile.py | 110 +++ tests/support/github/entities.py | 3 +- 101 files changed, 6116 insertions(+), 860 deletions(-) create mode 100644 changelog/+resource_type.added.md create mode 100644 docs/ref/resources/github/saltext.github.resources.github.modules.actions.rst create mode 100644 docs/ref/resources/github/saltext.github.resources.github.modules.deploy_key.rst create mode 100644 docs/ref/resources/github/saltext.github.resources.github.modules.environment.rst create mode 100644 docs/ref/resources/github/saltext.github.resources.github.modules.issue.rst create mode 100644 docs/ref/resources/github/saltext.github.resources.github.modules.label.rst create mode 100644 docs/ref/resources/github/saltext.github.resources.github.modules.milestone.rst create mode 100644 docs/ref/resources/github/saltext.github.resources.github.modules.pages.rst create mode 100644 docs/ref/resources/github/saltext.github.resources.github.modules.pull.rst create mode 100644 docs/ref/resources/github/saltext.github.resources.github.modules.repo.rst create mode 100644 docs/ref/resources/github/saltext.github.resources.github.modules.rst create mode 100644 docs/ref/resources/github/saltext.github.resources.github.modules.ruleset.rst create mode 100644 docs/ref/resources/github/saltext.github.resources.github.modules.test.rst create mode 100644 docs/ref/resources/github/saltext.github.resources.github.states.actions.rst create mode 100644 docs/ref/resources/github/saltext.github.resources.github.states.deploy_key.rst create mode 100644 docs/ref/resources/github/saltext.github.resources.github.states.environment.rst create mode 100644 docs/ref/resources/github/saltext.github.resources.github.states.label.rst create mode 100644 docs/ref/resources/github/saltext.github.resources.github.states.pages.rst create mode 100644 docs/ref/resources/github/saltext.github.resources.github.states.repo.rst create mode 100644 docs/ref/resources/github/saltext.github.resources.github.states.rst create mode 100644 docs/ref/resources/github/saltext.github.resources.github.states.ruleset.rst create mode 100644 docs/ref/resources/saltext.github.resources.github.rst create mode 100644 docs/ref/utils/saltext.github.utils.gh.statecores.actions.rst create mode 100644 docs/ref/utils/saltext.github.utils.gh.statecores.deploy_keys.rst create mode 100644 docs/ref/utils/saltext.github.utils.gh.statecores.environments.rst create mode 100644 docs/ref/utils/saltext.github.utils.gh.statecores.labels.rst create mode 100644 docs/ref/utils/saltext.github.utils.gh.statecores.pages.rst create mode 100644 docs/ref/utils/saltext.github.utils.gh.statecores.repo.rst create mode 100644 docs/ref/utils/saltext.github.utils.gh.statecores.rst create mode 100644 docs/ref/utils/saltext.github.utils.gh.statecores.rulesets.rst create mode 100644 docs/topics/repo_resources.md create mode 100644 src/saltext/github/resources/__init__.py create mode 100644 src/saltext/github/resources/github/__init__.py create mode 100644 src/saltext/github/resources/github/modules/__init__.py create mode 100644 src/saltext/github/resources/github/modules/actions.py create mode 100644 src/saltext/github/resources/github/modules/deploy_key.py create mode 100644 src/saltext/github/resources/github/modules/environment.py create mode 100644 src/saltext/github/resources/github/modules/issue.py create mode 100644 src/saltext/github/resources/github/modules/label.py create mode 100644 src/saltext/github/resources/github/modules/milestone.py create mode 100644 src/saltext/github/resources/github/modules/pages.py create mode 100644 src/saltext/github/resources/github/modules/pull.py create mode 100644 src/saltext/github/resources/github/modules/repo.py create mode 100644 src/saltext/github/resources/github/modules/ruleset.py create mode 100644 src/saltext/github/resources/github/modules/test.py create mode 100644 src/saltext/github/resources/github/states/__init__.py create mode 100644 src/saltext/github/resources/github/states/actions.py create mode 100644 src/saltext/github/resources/github/states/deploy_key.py create mode 100644 src/saltext/github/resources/github/states/environment.py create mode 100644 src/saltext/github/resources/github/states/label.py create mode 100644 src/saltext/github/resources/github/states/pages.py create mode 100644 src/saltext/github/resources/github/states/repo.py create mode 100644 src/saltext/github/resources/github/states/ruleset.py create mode 100644 src/saltext/github/utils/gh/statecores/__init__.py create mode 100644 src/saltext/github/utils/gh/statecores/actions.py create mode 100644 src/saltext/github/utils/gh/statecores/deploy_keys.py create mode 100644 src/saltext/github/utils/gh/statecores/environments.py create mode 100644 src/saltext/github/utils/gh/statecores/labels.py create mode 100644 src/saltext/github/utils/gh/statecores/pages.py create mode 100644 src/saltext/github/utils/gh/statecores/repo.py create mode 100644 src/saltext/github/utils/gh/statecores/rulesets.py create mode 100644 tests/functional/resources/__init__.py create mode 100644 tests/functional/resources/conftest.py create mode 100644 tests/functional/resources/modules/__init__.py create mode 100644 tests/functional/resources/modules/test_actions.py create mode 100644 tests/functional/resources/modules/test_deploy_key.py create mode 100644 tests/functional/resources/modules/test_environment.py create mode 100644 tests/functional/resources/modules/test_issue.py create mode 100644 tests/functional/resources/modules/test_label.py create mode 100644 tests/functional/resources/modules/test_milestone.py create mode 100644 tests/functional/resources/modules/test_pages.py create mode 100644 tests/functional/resources/modules/test_pull.py create mode 100644 tests/functional/resources/modules/test_repo.py create mode 100644 tests/functional/resources/modules/test_ruleset.py create mode 100644 tests/functional/resources/modules/test_test.py create mode 100644 tests/functional/resources/states/__init__.py create mode 100644 tests/functional/resources/states/conftest.py create mode 100644 tests/functional/resources/states/test_actions.py create mode 100644 tests/functional/resources/states/test_deploy_key.py create mode 100644 tests/functional/resources/states/test_environment.py create mode 100644 tests/functional/resources/states/test_label.py create mode 100644 tests/functional/resources/states/test_pages.py create mode 100644 tests/functional/resources/states/test_repo.py create mode 100644 tests/functional/resources/states/test_ruleset.py create mode 100644 tests/functional/resources/test_connection.py create mode 100644 tests/functional/resources/test_multi_profile.py diff --git a/.pre-commit-hooks/make-autodocs.py b/.pre-commit-hooks/make-autodocs.py index 2c06b797..5e774389 100644 --- a/.pre-commit-hooks/make-autodocs.py +++ b/.pre-commit-hooks/make-autodocs.py @@ -1,5 +1,5 @@ import ast -import os.path +import posixpath import subprocess from pathlib import Path @@ -24,48 +24,59 @@ def _find_virtualname(path): continue break else: + if path.name == "__init__.py": + # Resource type packages carry their connection module there + path = path.parent virtualname = path.with_suffix("").name return virtualname -def write_module(rst_path, path, use_virtualname=True): - if use_virtualname: - virtualname = "``" + _find_virtualname(path) + "``" - else: - virtualname = make_import_path(path) - header_len = len(virtualname) +def _kind_title(kind): + if kind == "utils": + return "Utilities" + if kind.lower() == "modules": + return "Execution Modules" + return (kind.rstrip("s") + " modules").title() + + +def write_file(rst_path, contents): + if not rst_path.exists() or rst_path.read_text() != contents: + print(rst_path) + rst_path.write_text(contents) + return True + return False + + +def write_module(rst_path, header, paths): + header_len = len(header) # The check-merge-conflict pre-commit hook chokes here: # https://github.com/pre-commit/pre-commit-hooks/issues/100 if header_len == 7: header_len += 1 + automodules = "\n".join(f""".. automodule:: {make_import_path(path)} + :members: +""" for path in paths) module_contents = f"""\ -{virtualname} +{header} {'='*header_len} -.. automodule:: {make_import_path(path)} - :members: -""" - if not rst_path.exists() or rst_path.read_text() != module_contents: - print(rst_path) - rst_path.write_text(module_contents) - return True - return False +{automodules}""" + return write_file(rst_path, module_contents) def write_index(index_rst, import_paths, kind): - if kind == "utils": - header_text = "Utilities" - common_path = os.path.commonpath(tuple(x.replace(".", "/") for x in import_paths)).replace( - "/", "." - ) + header_text = _kind_title(kind) + if kind in ("utils", "resources"): + # These can contain nested modules, so find the common parent + common_path = posixpath.commonpath( + tuple(x.replace(".", "/") for x in import_paths) + ).replace("/", ".") + if any(x == common_path for x in import_paths): common_path = common_path[: common_path.rfind(".")] else: - header_text = ( - "execution modules" if kind.lower() == "modules" else kind.rstrip("s") + " modules" - ) common_path = import_paths[0][: import_paths[0].rfind(".")] - header = f"{'_'*len(header_text)}\n{header_text.title()}\n{'_'*len(header_text)}" + header = f"{'_'*len(header_text)}\n{header_text}\n{'_'*len(header_text)}" index_contents = f"""\ .. all-saltext.github.{kind}: @@ -78,11 +89,7 @@ def write_index(index_rst, import_paths, kind): {chr(10).join(sorted(' '+p[len(common_path)+1:] for p in import_paths))} """ - if not index_rst.exists() or index_rst.read_text() != index_contents: - print(index_rst) - index_rst.write_text(index_contents) - return True - return False + return write_file(index_rst, index_contents) def make_import_path(path): @@ -91,31 +98,209 @@ def make_import_path(path): return ".".join(path.relative_to(repo_path / "src").with_suffix("").parts) +tracked = set( + subprocess.check_output(["git", "ls-files", "-z"], text=True, cwd=repo_path).split("\x00") +) + for path in src_dir.glob("*/*.py"): - if path.name != "__init__.py": + if path.name != "__init__.py" and path.relative_to(repo_path).as_posix() in tracked: kind = path.parent.name - if kind != "utils": + if kind not in ("utils", "resources"): docs_by_kind.setdefault(kind, set()).add(path) # Utils can have subdirectories, treat them separately for path in (src_dir / "utils").rglob("*.py"): + if path.relative_to(repo_path).as_posix() not in tracked: + continue if path.name == "__init__.py" and not path.read_text(): continue docs_by_kind.setdefault("utils", set()).add(path) +# Resource types can be packages containing per-type loader overlay +# directories (resources//{modules,states,grains,...}), treat +# them separately +for path in (src_dir / "resources").rglob("*.py"): + if path.relative_to(repo_path).as_posix() not in tracked: + continue + if path.name == "__init__.py": + # Only a resource type package's own __init__.py is a loader + # module (the connection module), the other ones are packaging + # artifacts. + if path.parent.parent != src_dir / "resources" or not path.read_text(): + continue + docs_by_kind.setdefault("resources", set()).add(path) + + +def _group_by_virtualname(paths): + grouped = {} + for path in sorted(paths): + grouped.setdefault(_find_virtualname(path), []).append(path) + # Make the module whose file name matches the virtualname the + # canonical one, i.e. the first in the group and the one the + # rst file is named after. + for virtualname, group in grouped.items(): + for path in group: + stem = path.parent.name if path.name == "__init__.py" else path.stem + if stem in (virtualname, virtualname + "_mod"): + group.remove(path) + group.insert(0, path) + break + return grouped + + +def _overlay_order(overlay_kind): + """ + Order the per-type loader overlay sections like Salt's docs do, + unknown kinds last in alphabetical order. + """ + try: + return (("modules", "states", "grains").index(overlay_kind), overlay_kind) + except ValueError: + return (99, overlay_kind) + + +def _process_resources(kind_path, paths): + """ + Document each resource type with its connection module page (headed + by its virtualname) as the entry point. Each per-type loader overlay + directory (``resources///``) gets its own index + page ("Execution Modules", "State Modules", ...) hanging off the + type page, so the overlay modules nest under a per-kind subheading + in the navigation instead of being appended to one flat list. + + There is no kind index: the root document's "Resources" toctree + globs ``ref/resources/*``, which picks up exactly the type pages + because everything else is written to a ``/`` subdirectory + (toctree globs do not cross directories). + """ + resources_dir = src_dir / "resources" + types = {} + for path in sorted(paths): + rel = path.relative_to(resources_dir).parts + if len(rel) == 1: + # Flat connection module + types.setdefault(path.stem, {"conn": None, "overlays": {}})["conn"] = path + elif len(rel) == 2 and path.name == "__init__.py": + # Resource type package's connection module + types.setdefault(rel[0], {"conn": None, "overlays": {}})["conn"] = path + else: + overlays = types.setdefault(rel[0], {"conn": None, "overlays": {}})["overlays"] + overlays.setdefault(rel[1], set()).add(path) + + changed = False + index_paths = [] + written_rst_paths = set() + kind_path.mkdir(parents=True, exist_ok=True) + for rtype, info in sorted(types.items()): + conn = info["conn"] + blocks = [] + if conn is not None: + header = f"``{_find_virtualname(conn)}``" + type_import_path = make_import_path(conn) + blocks.append(f".. automodule:: {type_import_path}\n :members:\n") + else: + # Overlay modules without a connection module (unusual) + header = f"``{rtype}``" + type_import_path = f"saltext.github.resources.{rtype}" + header_len = len(header) + if header_len == 7: + header_len += 1 + blocks.insert(0, f"{header}\n{'='*header_len}\n") + + type_path = kind_path / rtype + if info["overlays"]: + type_path.mkdir(parents=True, exist_ok=True) + overlay_index_paths = [] + for overlay_kind in sorted(info["overlays"], key=_overlay_order): + overlay_import_path = f"saltext.github.resources.{rtype}.{overlay_kind}" + entries = [] + for virtualname, group in _group_by_virtualname(info["overlays"][overlay_kind]).items(): + import_path = make_import_path(group[0]) + rst_path = type_path / (import_path + ".rst") + written_rst_paths.add(rst_path) + changed |= write_module(rst_path, f"``{virtualname}``", group) + entries.append(import_path[len(overlay_import_path) + 1 :]) + # One index page per overlay kind: its title provides the + # per-kind subheading the overlay modules nest under in the + # navigation. + title = _kind_title(overlay_kind) + overlay_index_contents = f"""\ +{title} +{'='*len(title)} + +.. currentmodule:: {overlay_import_path} + +.. autosummary:: + :toctree: + +{chr(10).join(' ' + entry for entry in sorted(entries))} +""" + overlay_index_rst = type_path / (overlay_import_path + ".rst") + written_rst_paths.add(overlay_index_rst) + changed |= write_file(overlay_index_rst, overlay_index_contents) + overlay_index_paths.append(f"{rtype}/{overlay_import_path}") + + if overlay_index_paths: + heading = "Provided Modules" + blocks.append(f"{heading}\n{'-'*len(heading)}\n") + blocks.append( + ".. toctree::\n :maxdepth: 1\n\n" + + "\n".join(" " + p for p in overlay_index_paths) + + "\n" + ) + + type_rst_path = kind_path / (type_import_path + ".rst") + written_rst_paths.add(type_rst_path) + changed |= write_file(type_rst_path, "\n".join(blocks)) + index_paths.append(type_import_path) + return changed, index_paths, written_rst_paths + + for kind in docs_by_kind: kind_path = doc_dir / "ref" / kind index_rst = kind_path / "index.rst" import_paths = [] - for path in sorted(docs_by_kind[kind]): - import_path = make_import_path(path) - import_paths.append(import_path) - rst_path = kind_path / (import_path + ".rst") - rst_path.parent.mkdir(parents=True, exist_ok=True) - change = write_module(rst_path, path, use_virtualname=kind != "utils") + written_rst_paths = set() + if kind == "resources": + change, import_paths, written_rst_paths = _process_resources(kind_path, docs_by_kind[kind]) changed_something = changed_something or change + else: + if kind == "utils": + # Utils are documented under their import path, no grouping necessary + grouped = {make_import_path(path): [path] for path in sorted(docs_by_kind[kind])} + else: + grouped = { + f"``{virtualname}``": paths + for virtualname, paths in _group_by_virtualname(docs_by_kind[kind]).items() + } + for header, paths in grouped.items(): + import_path = make_import_path(paths[0]) + import_paths.append(import_path) + rst_path = kind_path / (import_path + ".rst") + rst_path.parent.mkdir(parents=True, exist_ok=True) + written_rst_paths.add(rst_path) + change = write_module(rst_path, header, paths) + changed_something = changed_something or change - write_index(index_rst, import_paths, kind) + # Remove stale generated rst files, e.g. after a module has been renamed/ + # removed or merged into another one's docs by sharing its virtualname. + # Resource type docs nest in per-type subdirectories, search recursively. + stale_glob = kind_path.rglob if kind == "resources" else kind_path.glob + for rst_path in stale_glob(f"saltext.github.{kind}.*.rst"): + if rst_path not in written_rst_paths: + print(f"Removing stale {rst_path}") + rst_path.unlink() + changed_something = True + + if kind == "resources": + # No kind index: the root document's "Resources" toctree globs + # the per-type pages directly. + if index_rst.exists(): + print(f"Removing stale {index_rst}") + index_rst.unlink() + changed_something = True + else: + changed_something |= write_index(index_rst, sorted(import_paths), kind) # Ensure pre-commit realizes we did something diff --git a/changelog/+resource_type.added.md b/changelog/+resource_type.added.md new file mode 100644 index 00000000..462d4812 --- /dev/null +++ b/changelog/+resource_type.added.md @@ -0,0 +1 @@ +Added the {py:mod}`github ` {ref}`Salt resource type ` for Salt 3008.3+, exposing each GitHub repository as an individually targetable resource with per-repository grains and repository-implicit execution and state modules diff --git a/changelog/+sdb-profile-values.added.md b/changelog/+sdb-profile-values.added.md index dc87e03f..95d47896 100644 --- a/changelog/+sdb-profile-values.added.md +++ b/changelog/+sdb-profile-values.added.md @@ -1 +1 @@ -Profile configuration values containing SDB URIs (e.g. `token: sdb://osenv/GITHUB_TOKEN`) are now rendered on access via `config.get`. Salt itself only renders them inside the minion/master configuration during startup, so this makes them work in pillar-sourced profiles as well. +Profile configuration values containing SDB URIs (e.g. `token: sdb://osenv/GITHUB_TOKEN`) are now rendered on access. Salt itself only renders them inside the minion/master configuration during startup, so this makes them work in pillar-sourced profiles as well. diff --git a/changelog/+user_owned_repos.added.md b/changelog/+user_owned_repos.added.md index c419238f..2b50dbf2 100644 --- a/changelog/+user_owned_repos.added.md +++ b/changelog/+user_owned_repos.added.md @@ -1 +1 @@ -The new modules support repositories of any owner. Profiles take an {ghconf}`owner` (organization or user; preferred over the legacy `org_name` alias), which defaults to the token's user when unset. Repository listing and creation live on the `github_repo` module (`list`/`create`), and all of its functions and states as well as all functions of the (now strictly organization-scoped) `github_org` module accept a per-call `owner` override, so repositories, teams, members and rulesets of foreign organizations can be managed as well. Due to GitHub API limitations, repository creation is only supported for organizations and the authenticated user, private repositories of other users are not listable, tokens without an associated user account (e.g. GitHub App installation tokens) require an explicit `owner` and teams always belong to an organization +The new modules support repositories of any owner. Profiles take an {ghconf}`owner` (organization or user; preferred over the legacy `org_name` alias), which defaults to the token's user when unset. Repository listing and creation live on the {py:mod}`github_repo ` module ({py:func}`list ` / {py:func}`create `), and all of its functions and states as well as all functions of the (now strictly organization-scoped) {py:mod}`github_org ` module accept a per-call `owner` override, so repositories, teams, members and rulesets of foreign organizations can be managed as well. Due to GitHub API limitations, repository creation is only supported for organizations and the authenticated user, private repositories of other users are not listable, tokens without an associated user account (e.g. GitHub App installation tokens) require an explicit `owner` and teams always belong to an organization diff --git a/docs/index.rst b/docs/index.rst index 30e341f4..ae7f7825 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -12,6 +12,9 @@ Salt Extension for interacting with GitHub: query their issues, milestones and pull requests via the :py:mod:`github_repo ` execution and state modules. +* On Salt 3008.3+, optionally manage each repository as an individually + targetable Salt resource with per-repository grains, execution and + state modules via the ``github`` :ref:`resource type `. * Works against GitHub Enterprise Server instances via the :ghconf:`api_url` profile parameter. @@ -22,6 +25,7 @@ Salt Extension for interacting with GitHub: topics/installation topics/configuration + topics/repo_resources .. toctree:: :maxdepth: 2 @@ -32,6 +36,14 @@ Salt Extension for interacting with GitHub: ref/states/index ref/utils/index +.. toctree:: + :maxdepth: 3 + :caption: Provided Resources + :hidden: + :glob: + + ref/resources/* + .. toctree:: :maxdepth: 2 :caption: Reference diff --git a/docs/ref/resources/github/saltext.github.resources.github.modules.actions.rst b/docs/ref/resources/github/saltext.github.resources.github.modules.actions.rst new file mode 100644 index 00000000..8477fbeb --- /dev/null +++ b/docs/ref/resources/github/saltext.github.resources.github.modules.actions.rst @@ -0,0 +1,5 @@ +``actions`` +=========== + +.. automodule:: saltext.github.resources.github.modules.actions + :members: diff --git a/docs/ref/resources/github/saltext.github.resources.github.modules.deploy_key.rst b/docs/ref/resources/github/saltext.github.resources.github.modules.deploy_key.rst new file mode 100644 index 00000000..11ac7544 --- /dev/null +++ b/docs/ref/resources/github/saltext.github.resources.github.modules.deploy_key.rst @@ -0,0 +1,5 @@ +``deploy_key`` +============== + +.. automodule:: saltext.github.resources.github.modules.deploy_key + :members: diff --git a/docs/ref/resources/github/saltext.github.resources.github.modules.environment.rst b/docs/ref/resources/github/saltext.github.resources.github.modules.environment.rst new file mode 100644 index 00000000..a773fe3c --- /dev/null +++ b/docs/ref/resources/github/saltext.github.resources.github.modules.environment.rst @@ -0,0 +1,5 @@ +``environment`` +=============== + +.. automodule:: saltext.github.resources.github.modules.environment + :members: diff --git a/docs/ref/resources/github/saltext.github.resources.github.modules.issue.rst b/docs/ref/resources/github/saltext.github.resources.github.modules.issue.rst new file mode 100644 index 00000000..298f5822 --- /dev/null +++ b/docs/ref/resources/github/saltext.github.resources.github.modules.issue.rst @@ -0,0 +1,5 @@ +``issue`` +========= + +.. automodule:: saltext.github.resources.github.modules.issue + :members: diff --git a/docs/ref/resources/github/saltext.github.resources.github.modules.label.rst b/docs/ref/resources/github/saltext.github.resources.github.modules.label.rst new file mode 100644 index 00000000..b390a6fd --- /dev/null +++ b/docs/ref/resources/github/saltext.github.resources.github.modules.label.rst @@ -0,0 +1,5 @@ +``label`` +========= + +.. automodule:: saltext.github.resources.github.modules.label + :members: diff --git a/docs/ref/resources/github/saltext.github.resources.github.modules.milestone.rst b/docs/ref/resources/github/saltext.github.resources.github.modules.milestone.rst new file mode 100644 index 00000000..7e27f53d --- /dev/null +++ b/docs/ref/resources/github/saltext.github.resources.github.modules.milestone.rst @@ -0,0 +1,5 @@ +``milestone`` +============= + +.. automodule:: saltext.github.resources.github.modules.milestone + :members: diff --git a/docs/ref/resources/github/saltext.github.resources.github.modules.pages.rst b/docs/ref/resources/github/saltext.github.resources.github.modules.pages.rst new file mode 100644 index 00000000..94ef43a8 --- /dev/null +++ b/docs/ref/resources/github/saltext.github.resources.github.modules.pages.rst @@ -0,0 +1,5 @@ +``pages`` +========= + +.. automodule:: saltext.github.resources.github.modules.pages + :members: diff --git a/docs/ref/resources/github/saltext.github.resources.github.modules.pull.rst b/docs/ref/resources/github/saltext.github.resources.github.modules.pull.rst new file mode 100644 index 00000000..0af02028 --- /dev/null +++ b/docs/ref/resources/github/saltext.github.resources.github.modules.pull.rst @@ -0,0 +1,5 @@ +``pull`` +======== + +.. automodule:: saltext.github.resources.github.modules.pull + :members: diff --git a/docs/ref/resources/github/saltext.github.resources.github.modules.repo.rst b/docs/ref/resources/github/saltext.github.resources.github.modules.repo.rst new file mode 100644 index 00000000..47808518 --- /dev/null +++ b/docs/ref/resources/github/saltext.github.resources.github.modules.repo.rst @@ -0,0 +1,5 @@ +``repo`` +======== + +.. automodule:: saltext.github.resources.github.modules.repo + :members: diff --git a/docs/ref/resources/github/saltext.github.resources.github.modules.rst b/docs/ref/resources/github/saltext.github.resources.github.modules.rst new file mode 100644 index 00000000..7f46f49e --- /dev/null +++ b/docs/ref/resources/github/saltext.github.resources.github.modules.rst @@ -0,0 +1,19 @@ +Execution Modules +================= + +.. currentmodule:: saltext.github.resources.github.modules + +.. autosummary:: + :toctree: + + actions + deploy_key + environment + issue + label + milestone + pages + pull + repo + ruleset + test diff --git a/docs/ref/resources/github/saltext.github.resources.github.modules.ruleset.rst b/docs/ref/resources/github/saltext.github.resources.github.modules.ruleset.rst new file mode 100644 index 00000000..0265f1f6 --- /dev/null +++ b/docs/ref/resources/github/saltext.github.resources.github.modules.ruleset.rst @@ -0,0 +1,5 @@ +``ruleset`` +=========== + +.. automodule:: saltext.github.resources.github.modules.ruleset + :members: diff --git a/docs/ref/resources/github/saltext.github.resources.github.modules.test.rst b/docs/ref/resources/github/saltext.github.resources.github.modules.test.rst new file mode 100644 index 00000000..d5f982ba --- /dev/null +++ b/docs/ref/resources/github/saltext.github.resources.github.modules.test.rst @@ -0,0 +1,5 @@ +``test`` +======== + +.. automodule:: saltext.github.resources.github.modules.test + :members: diff --git a/docs/ref/resources/github/saltext.github.resources.github.states.actions.rst b/docs/ref/resources/github/saltext.github.resources.github.states.actions.rst new file mode 100644 index 00000000..454ea4fa --- /dev/null +++ b/docs/ref/resources/github/saltext.github.resources.github.states.actions.rst @@ -0,0 +1,5 @@ +``actions`` +=========== + +.. automodule:: saltext.github.resources.github.states.actions + :members: diff --git a/docs/ref/resources/github/saltext.github.resources.github.states.deploy_key.rst b/docs/ref/resources/github/saltext.github.resources.github.states.deploy_key.rst new file mode 100644 index 00000000..47692be8 --- /dev/null +++ b/docs/ref/resources/github/saltext.github.resources.github.states.deploy_key.rst @@ -0,0 +1,5 @@ +``deploy_key`` +============== + +.. automodule:: saltext.github.resources.github.states.deploy_key + :members: diff --git a/docs/ref/resources/github/saltext.github.resources.github.states.environment.rst b/docs/ref/resources/github/saltext.github.resources.github.states.environment.rst new file mode 100644 index 00000000..5c86fdde --- /dev/null +++ b/docs/ref/resources/github/saltext.github.resources.github.states.environment.rst @@ -0,0 +1,5 @@ +``environment`` +=============== + +.. automodule:: saltext.github.resources.github.states.environment + :members: diff --git a/docs/ref/resources/github/saltext.github.resources.github.states.label.rst b/docs/ref/resources/github/saltext.github.resources.github.states.label.rst new file mode 100644 index 00000000..71bdc917 --- /dev/null +++ b/docs/ref/resources/github/saltext.github.resources.github.states.label.rst @@ -0,0 +1,5 @@ +``label`` +========= + +.. automodule:: saltext.github.resources.github.states.label + :members: diff --git a/docs/ref/resources/github/saltext.github.resources.github.states.pages.rst b/docs/ref/resources/github/saltext.github.resources.github.states.pages.rst new file mode 100644 index 00000000..fc280a21 --- /dev/null +++ b/docs/ref/resources/github/saltext.github.resources.github.states.pages.rst @@ -0,0 +1,5 @@ +``pages`` +========= + +.. automodule:: saltext.github.resources.github.states.pages + :members: diff --git a/docs/ref/resources/github/saltext.github.resources.github.states.repo.rst b/docs/ref/resources/github/saltext.github.resources.github.states.repo.rst new file mode 100644 index 00000000..28e683df --- /dev/null +++ b/docs/ref/resources/github/saltext.github.resources.github.states.repo.rst @@ -0,0 +1,5 @@ +``repo`` +======== + +.. automodule:: saltext.github.resources.github.states.repo + :members: diff --git a/docs/ref/resources/github/saltext.github.resources.github.states.rst b/docs/ref/resources/github/saltext.github.resources.github.states.rst new file mode 100644 index 00000000..e3d4c151 --- /dev/null +++ b/docs/ref/resources/github/saltext.github.resources.github.states.rst @@ -0,0 +1,15 @@ +State Modules +============= + +.. currentmodule:: saltext.github.resources.github.states + +.. autosummary:: + :toctree: + + actions + deploy_key + environment + label + pages + repo + ruleset diff --git a/docs/ref/resources/github/saltext.github.resources.github.states.ruleset.rst b/docs/ref/resources/github/saltext.github.resources.github.states.ruleset.rst new file mode 100644 index 00000000..a8c64430 --- /dev/null +++ b/docs/ref/resources/github/saltext.github.resources.github.states.ruleset.rst @@ -0,0 +1,5 @@ +``ruleset`` +=========== + +.. automodule:: saltext.github.resources.github.states.ruleset + :members: diff --git a/docs/ref/resources/saltext.github.resources.github.rst b/docs/ref/resources/saltext.github.resources.github.rst new file mode 100644 index 00000000..13481c7f --- /dev/null +++ b/docs/ref/resources/saltext.github.resources.github.rst @@ -0,0 +1,14 @@ +``github`` +========== + +.. automodule:: saltext.github.resources.github + :members: + +Provided Modules +---------------- + +.. toctree:: + :maxdepth: 1 + + github/saltext.github.resources.github.modules + github/saltext.github.resources.github.states diff --git a/docs/ref/utils/index.rst b/docs/ref/utils/index.rst index 198d2c55..a0661f5a 100644 --- a/docs/ref/utils/index.rst +++ b/docs/ref/utils/index.rst @@ -25,4 +25,12 @@ _________ utils.gh.pulls utils.gh.repos utils.gh.rulesets + utils.gh.statecores + utils.gh.statecores.actions + utils.gh.statecores.deploy_keys + utils.gh.statecores.environments + utils.gh.statecores.labels + utils.gh.statecores.pages + utils.gh.statecores.repo + utils.gh.statecores.rulesets utils.gh.teams diff --git a/docs/ref/utils/saltext.github.utils.gh.statecores.actions.rst b/docs/ref/utils/saltext.github.utils.gh.statecores.actions.rst new file mode 100644 index 00000000..bc9c9b3c --- /dev/null +++ b/docs/ref/utils/saltext.github.utils.gh.statecores.actions.rst @@ -0,0 +1,5 @@ +saltext.github.utils.gh.statecores.actions +========================================== + +.. automodule:: saltext.github.utils.gh.statecores.actions + :members: diff --git a/docs/ref/utils/saltext.github.utils.gh.statecores.deploy_keys.rst b/docs/ref/utils/saltext.github.utils.gh.statecores.deploy_keys.rst new file mode 100644 index 00000000..692d28ad --- /dev/null +++ b/docs/ref/utils/saltext.github.utils.gh.statecores.deploy_keys.rst @@ -0,0 +1,5 @@ +saltext.github.utils.gh.statecores.deploy_keys +============================================== + +.. automodule:: saltext.github.utils.gh.statecores.deploy_keys + :members: diff --git a/docs/ref/utils/saltext.github.utils.gh.statecores.environments.rst b/docs/ref/utils/saltext.github.utils.gh.statecores.environments.rst new file mode 100644 index 00000000..bbc49a18 --- /dev/null +++ b/docs/ref/utils/saltext.github.utils.gh.statecores.environments.rst @@ -0,0 +1,5 @@ +saltext.github.utils.gh.statecores.environments +=============================================== + +.. automodule:: saltext.github.utils.gh.statecores.environments + :members: diff --git a/docs/ref/utils/saltext.github.utils.gh.statecores.labels.rst b/docs/ref/utils/saltext.github.utils.gh.statecores.labels.rst new file mode 100644 index 00000000..d6baa41a --- /dev/null +++ b/docs/ref/utils/saltext.github.utils.gh.statecores.labels.rst @@ -0,0 +1,5 @@ +saltext.github.utils.gh.statecores.labels +========================================= + +.. automodule:: saltext.github.utils.gh.statecores.labels + :members: diff --git a/docs/ref/utils/saltext.github.utils.gh.statecores.pages.rst b/docs/ref/utils/saltext.github.utils.gh.statecores.pages.rst new file mode 100644 index 00000000..21ae78f0 --- /dev/null +++ b/docs/ref/utils/saltext.github.utils.gh.statecores.pages.rst @@ -0,0 +1,5 @@ +saltext.github.utils.gh.statecores.pages +======================================== + +.. automodule:: saltext.github.utils.gh.statecores.pages + :members: diff --git a/docs/ref/utils/saltext.github.utils.gh.statecores.repo.rst b/docs/ref/utils/saltext.github.utils.gh.statecores.repo.rst new file mode 100644 index 00000000..7e2cfc2b --- /dev/null +++ b/docs/ref/utils/saltext.github.utils.gh.statecores.repo.rst @@ -0,0 +1,5 @@ +saltext.github.utils.gh.statecores.repo +======================================= + +.. automodule:: saltext.github.utils.gh.statecores.repo + :members: diff --git a/docs/ref/utils/saltext.github.utils.gh.statecores.rst b/docs/ref/utils/saltext.github.utils.gh.statecores.rst new file mode 100644 index 00000000..dd8eb98c --- /dev/null +++ b/docs/ref/utils/saltext.github.utils.gh.statecores.rst @@ -0,0 +1,5 @@ +saltext.github.utils.gh.statecores +================================== + +.. automodule:: saltext.github.utils.gh.statecores + :members: diff --git a/docs/ref/utils/saltext.github.utils.gh.statecores.rulesets.rst b/docs/ref/utils/saltext.github.utils.gh.statecores.rulesets.rst new file mode 100644 index 00000000..54464076 --- /dev/null +++ b/docs/ref/utils/saltext.github.utils.gh.statecores.rulesets.rst @@ -0,0 +1,5 @@ +saltext.github.utils.gh.statecores.rulesets +=========================================== + +.. automodule:: saltext.github.utils.gh.statecores.rulesets + :members: diff --git a/docs/topics/configuration.md b/docs/topics/configuration.md index d6c1868e..2b09166f 100644 --- a/docs/topics/configuration.md +++ b/docs/topics/configuration.md @@ -2,12 +2,21 @@ # Configuration All modules provided by this extension read their configuration from -*profiles*. A profile is a mapping under a top-level key in the minion -configuration, minion pillar or master configuration. Functions and -states use the profile named `github` by default and accept a -`profile` argument to select a different one. +*profiles*: +(profile-explanation)= +profile +: A profile is a mapping under a top-level key in the minion + configuration, minion pillar or master configuration + (grains work also, but should not contain secrets such as the required {ghconf}`token`). + Functions and states use the profile named `github` by default and accept a + `profile` argument to select a different one. + +(profile)= ## Profile parameters +A profile contains authentication credentials and default configuration +the modules included in this Salt extension reference when running. +The following keys can be specified in the profile: :::{ghconf} token ::: @@ -17,29 +26,33 @@ states use the profile named `github` by default and accept a :::{ghconf} owner ::: `owner` -: Default owner (organization or user) whose resources are managed. - Defaults to the token's user, so organization management and tokens - without an associated user account (e.g. GitHub App installation - tokens) require it to be set. `org_name` is supported as a legacy - alias. +: Default owner (organization or user) whose resources are managed with this profile. + Defaults to the token's user, if the token has one. + + This value can be overridden per call via the `owner` argument. + + Needs to be set explicitly when the token does not have an associated + resolvable user account such as GitHub App installation tokens. :::{versionadded} 1.1.0 Renamed from `org_name`. The new {py:mod}`github_repo ` modules can manage user repositories in addition to organization ones. + `org_name` keeps being supported as a legacy alias. ::: :::{ghconf} repo_name ::: `repo_name` -: The default repository the `github_repo` functions and states operate - on when no `repo_name` is passed. +: The default repository the `github_repo` {py:mod}`functions ` + and {py:mod}`states ` operate + on when no `repo_name` is passed as an argument. + Does not affect the `github` {py:mod}`resource module `. :::{ghconf} allow_repo_privacy_changes ::: `allow_repo_privacy_changes` -: Allow automated repository privacy modifications. It can be dangerous - to change the privacy of a repository in an automated way, hence this - defaults to false. +: Allow automated repository privacy modifications. Defaults to false since + it can be dangerous to change the privacy of a repository in an automated way. :::{ghconf} api_url ::: @@ -52,13 +65,14 @@ states use the profile named `github` by default and accept a Any number of profiles can be defined and selected per call: -```yaml +```{code-block} yaml +:caption: e.g. `/etc/salt/minion.d/github.yml` or `/srv/pillar/github.sls` with associated mapping in `top.sls` github: - token: abc1234 + token: hunter2 owner: my_organization my_user: - token: def5678 + token: hunter3 ``` ```bash @@ -70,11 +84,168 @@ salt-call github_repo.list profile=my_user Profile values containing [SDB URIs](https://docs.saltproject.io/en/latest/topics/sdb/index.html) are rendered on access, which also works in pillar-sourced profiles: -```yaml -# Requires a corresponding SDB profile in the minion configuration, e.g. -# osenv: -# driver: env +```{code-block} yaml +:caption: e.g. `/etc/salt/minion.d/sdb.yml` +osenv: + driver: env +``` + +```{code-block} yaml +:caption: e.g. `/etc/salt/minion.d/github.yml` or `/srv/pillar/github.sls` with associated mapping in `top.sls` +# Requires a corresponding SDB profile in the minion configuration, see above github: token: sdb://osenv/GITHUB_TOKEN owner: my_organization ``` + +(github-resource-configuration)= +## Repository resource declaration + +:::{versionadded} 1.1.0 +::: + +The {py:mod}`github repository resource ` +(Salt 3008.3+, see the {ref}`repository resources guide `) is +declared in the managing minion's pillar, under the subtree selected by +the {conf_minion}`resource_pillar_key` minion option (default: `resources`). + +:::{hint} +The referenced profiles are resolved from the managing minion's own +data, mirroring {py:func}`config.get `'s lookup order – minion configuration, +grains, pillar and master configuration (which reaches the minion via +the master's {conf_master}`pillar_opts` setting) – including SDB rendering. +::: + +:::{ghconf} resource:profile +::: +`profile` +: Name of the default {ref}`profile ` for exposed `github` repository resources. + Defaults to `github`. + +:::{ghconf} resource:owners +::: +`owners` +: Owners (organizations or users) whose repositories to manage. + Defaults to the default profile's {ghconf}`owner` (or the token's + user when unset). Either a plain list of owner names, or a mapping + that overrides any of the other declaration keys per owner – e.g. a + different {ghconf}`profile ` when the repositories + span credentials (separate tokens per organization, or a mix of + github.com and a GitHub Enterprise Server instance), or per-owner + repository selection: + + ```{code-block} yaml + :caption: Plain list + owners: + - my-github-handle + - my-org + ``` + + ```{code-block} yaml + :caption: Mapping with overrides for other declaration-level configs + owners: + my-github-handle: + exclude: + - not-this + other-org: + profile: other_github_org + ``` + + Keys an owner does not override are inherited from the declaration + level. + +:::{ghconf} resource:repos +::: +`repos` +: List of fnmatch patterns selecting the repositories to manage. + Patterns containing a slash match the full name (`owner/repo`), + others the bare repository name. Defaults to all repositories. + Can be overridden per owner in the {ghconf}`owners ` + mapping. + +:::{ghconf} resource:exclude +::: +`exclude` +: List of fnmatch patterns excluding repositories, following the same + matching rules as {ghconf}`repos ` and applied after + it. Defaults to none. + Can be overridden per owner in the {ghconf}`owners ` + mapping. + +:::{ghconf} resource:include_archived +::: +`include_archived` +: Include archived repositories. Defaults to false. + Can be overridden per owner in the {ghconf}`owners ` + mapping. + +### Minimal example config +This example showcases the minimally required config to use the modules +and expose repositories as Salt resources. + +```{code-block} yaml +:caption: e.g. `/etc/salt/minion.d/github.yml` or `/srv/pillar/github.sls` with associated mapping in `top.sls` +# Specify the default token. +# If this is a PAT, it targets repositories owned by you, unless `owner` is specified explicitly. +github: + token: foobar + +# Expose all your repositories as `github:your-handle/your-repo` resources. +resources: + github: {} +``` + +### Full example config +This example showcases an intricate config for different actors/orgs +and repo resource selection while avoiding to include plain text tokens in the config. + +```{code-block} yaml +:caption: e.g. `/etc/salt/minion.d/sdb.yml` +# SDB profile for the `env` SDB module. +# Exposes environment variables as `sdb://osenv/` URIs. +osenv: + driver: env + +# SDB profile for the `vault` SDB module. +# Exposes Vault secrets as `sdb://sdbvault//?` URIs. +sdbvault: + driver: vault +``` + +```{code-block} yaml +:caption: e.g. `/etc/salt/minion.d/github.yml` or `/srv/pillar/github.sls` with associated mapping in `top.sls` +# The default profile when not overridden in the function call +github: + token: sdb://sdbvault/secrets/my_github?token + allow_repo_privacy_changes: true + # This is resolved automatically if the token is associated with your user account: + # owner: my-github-handle + +# Same token, but this profile defaults to organization scope when no `owner` is passed +my_github_org: + token: sdb://sdbvault/secrets/my_github?token + owner: my-org + +# Different token and API endpoint for another organization. +other_enterprise_org: + api_url: https://github.example.com/api/v3 + token: sdb://osenv/GITHUB_TOKEN + owner: other-org + +# Expose specific repositories as resources +resources: + github: + owners: + my-github-handle: {} + my-org: + exclude: + - foo-not-this-repo + other-org: + profile: other_enterprise_org + repos: + - bar-* + include_archived: false + repos: + - foo-* + include_archived: true +``` diff --git a/docs/topics/repo_resources.md b/docs/topics/repo_resources.md new file mode 100644 index 00000000..331c3fcd --- /dev/null +++ b/docs/topics/repo_resources.md @@ -0,0 +1,139 @@ +(repo-resources)= + +# Managing repositories as resources + +:::{versionadded} 1.1.0 +::: + +On Salt 3008.3 and later, this extension provides the `github` +[resource type](https://docs.saltproject.io/en/latest/topics/resources/index.html), +which exposes each GitHub repository as an individually targetable Salt +resource. Compared to the classic +{py:mod}`github_repo ` modules, +resources offer: + +- direct targeting of repositories (`salt 'github:my-org/my-repo' ...`), + including grain-based targeting across all managed repositories +- per-repository grains (owner, default branch, visibility, ...) +- repository-implicit execution and state modules – SLS files describe + one repository's desired configuration and are applied per resource, + without threading `repo_name`/`owner` through every call + +The classic modules remain fully supported (and are the only option on +Salt 3006/3007); both are thin layers over the same core implementation. + +:::{note} +While the resource subsystem was introduced in Salt 3008.0, this +extension requires **3008.3+**: that release made the per-resource +execution loader deny-by-default, so that jobs dispatched to a +resource only see the modules this extension ships for it. On earlier +3008 releases, the loader was additive – stock and classic extension +modules leaked into the resource context – which this extension's +modules are neither written nor tested for. +::: + +## Setup + +Declare the resource type in the managing minion's pillar, under the +subtree selected by the {conf_minion}`resource_pillar_key` minion option (default: +`resources`): + +```{code-block} yaml +:caption: e.g. `/etc/salt/minion.d/github_resource.yml` +resources: + github: + profile: github + owners: + - my-org +``` + +All declaration keys – repository include/exclude patterns, archived +handling and the profile, each overridable per owner (e.g. for +repositories spanning credentials) – are described in the +{ref}`configuration guide `. The +referenced profiles are configured as usual (see the +{ref}`profile parameters `). + +After changing the declaration, refresh the minion's resources: + +```bash +salt 'minion' saltutil.refresh_resources +``` + +Each repository is registered as a resource named `/`, i.e. +with the SRN `github:/`. + +## Targeting + +```bash +# One repository +salt 'github:my-org/my-repo' test.ping + +# By resource grain +salt -G 'github:default_branch:master' repo.info +``` + +The resource grains are provided under the `github` key: `id`, `owner`, +`repo`, `full_name`, `private`, `fork`, `default_branch`, `language`, +`html_url`, `description` and `homepage`. + +## Execution modules + +Jobs dispatched to a `github` resource use repository-implicit, +entity-scoped execution modules instead of the classic ones: + +```bash +salt 'github:my-org/my-repo' repo.update description='Managed by Salt' +salt 'github:my-org/my-repo' actions.set_secret MY_SECRET hunter2 +salt 'github:my-org/my-repo' deploy_key.list +salt 'github:my-org/my-repo' environment.get release +``` + +See the {doc}`module reference ` for the +complete list. + +## States + +State runs dispatched to a resource converge that repository. A typical +`repo_setup.sls`: + +```yaml +Repository settings are managed: + repo.managed: + - description: Managed by Salt + - has_issues: true + - teams: + my-team: push + +release: + environment.present: + - deployment_branches: + - main + label.present: + - color: "#00FF00" + +Autorelease deploy key is present: + deploy_key.present: + - name: autorelease + - key: ssh-ed25519 AAAA... + +Workflow permissions are restricted: + actions.workflow_permissions_managed: + - default_workflow_permissions: read + - can_approve_pull_request_reviews: true +``` + +```bash +salt 'github:my-org/my-repo' state.apply repo_setup +``` + +Unlike the classic {py:func}`github_repo.present ` state, +{py:func}`repo.present ` never +creates the repository – a resource's existence is a given. Repository +creation (and deletion) remains a classic-module task on the managing +minion: + +```bash +salt 'minion' github_repo.create my-new-repo +salt 'minion' saltutil.refresh_resources +``` diff --git a/src/saltext/github/modules/github.py b/src/saltext/github/modules/github.py index 50b24a04..58717b24 100644 --- a/src/saltext/github/modules/github.py +++ b/src/saltext/github/modules/github.py @@ -63,7 +63,7 @@ def _get_conn(profile): """ Return the GitHub connection, cached into __context__ for performance """ - return gh_profile.get_conn(profile, __salt__=__salt__, __context__=__context__) + return gh_profile.get_conn(profile, opts=__opts__, context=__context__) def get_user(name, profile="github", user_details=False): @@ -122,9 +122,7 @@ def _get_config_value(profile, config_name): config_name The configuration item's name to use to return configuration values. """ - return gh_profile.get_config_value( - profile, config_name, __salt__=__salt__, __context__=__context__ - ) + return gh_profile.get_config_value(profile, config_name, opts=__opts__, context=__context__) def _get_owner(profile): @@ -132,7 +130,7 @@ def _get_owner(profile): Helper function that returns a profile's default repository owner (``owner``, legacy alias ``org_name``), or None when unset. """ - return gh_profile.get_owner(profile, __salt__=__salt__, __context__=__context__) + return gh_profile.get_owner(profile, opts=__opts__, context=__context__) def list_users(profile="github", ignore_cache=False): diff --git a/src/saltext/github/modules/github_org.py b/src/saltext/github/modules/github_org.py index 97f0ecb1..f1ff6f83 100644 --- a/src/saltext/github/modules/github_org.py +++ b/src/saltext/github/modules/github_org.py @@ -40,7 +40,6 @@ from saltext.github.utils.gh import profile as gh_profile from saltext.github.utils.gh import rulesets as gh_rulesets from saltext.github.utils.gh import teams as gh_teams -from saltext.github.utils.gh.exceptions import GitHubNotFoundError log = logging.getLogger(__name__) @@ -48,25 +47,14 @@ def _get_conn(profile): - return gh_profile.get_conn(profile, __salt__=__salt__, __context__=__context__) + return gh_profile.get_conn(profile, opts=__opts__, context=__context__) def _get_team(conn, name, owner=None): """ Return a team's cached details, raising when it does not exist. - Teams are matched by exact display name first, then by slug and - case-insensitive display name since GitHub derives team identity - (the slug) case-insensitively. - """ - team = gh_teams.get(conn, name, owner=owner) - if team is None: - for candidate in gh_teams.list_all(conn, owner=owner).values(): - if name == candidate["slug"] or name.lower() == candidate["name"].lower(): - return candidate - raise GitHubNotFoundError( - f"Team {name} does not exist in organization {owner or conn.owner}." - ) - return team + """ + return gh_teams.find(conn, name, owner=owner) def list_members(owner=None, ignore_cache=False, profile="github"): diff --git a/src/saltext/github/modules/github_repo.py b/src/saltext/github/modules/github_repo.py index 4f0b0110..11cc0aab 100644 --- a/src/saltext/github/modules/github_repo.py +++ b/src/saltext/github/modules/github_repo.py @@ -63,13 +63,11 @@ def _get_conn(profile): - return gh_profile.get_conn(profile, __salt__=__salt__, __context__=__context__) + return gh_profile.get_conn(profile, opts=__opts__, context=__context__) def _get_config_value(profile, config_name): - return gh_profile.get_config_value( - profile, config_name, __salt__=__salt__, __context__=__context__ - ) + return gh_profile.get_config_value(profile, config_name, opts=__opts__, context=__context__) def list_(private=None, owner=None, fields=None, ignore_cache=False, profile="github"): diff --git a/src/saltext/github/resources/__init__.py b/src/saltext/github/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/saltext/github/resources/github/__init__.py b/src/saltext/github/resources/github/__init__.py new file mode 100644 index 00000000..87b46f77 --- /dev/null +++ b/src/saltext/github/resources/github/__init__.py @@ -0,0 +1,215 @@ +""" +Connection module for the ``github`` resource type, which manages +GitHub repositories as Salt resources (requires Salt 3008.3+). + +.. versionadded:: 1.1.0 + +Each resource is a single repository, identified by ``owner/repo`` +(e.g. the SRN ``github:my-org/my-repo``). Jobs dispatched to a resource +run the per-type execution and state modules shipped in this package's +``modules``/``states`` directories, which operate on the targeted +repository implicitly. + +Configuration +------------- + +The resource type is declared in the managing minion's pillar, under +the subtree selected by :conf_minion:`resource_pillar_key` (default +``resources``):: + + resources: + github: + profile: github + owners: + - my-org + +The declaration keys (:ghconf:`resource:profile`, :ghconf:`resource:owners`, +:ghconf:`resource:repos`/:ghconf:`resource:exclude` patterns and +:ghconf:`resource:include_archived`) are described in the +:ref:`configuration guide `. ``owners`` +may be a mapping, overriding any of the other declaration keys per +owner:: + + resources: + github: + owners: + my-github-handle: + exclude: + - not-this + my-org: + repos: + - foo* + other-org: + profile: other_github_org +""" + +import fnmatch +import logging + +from salt.exceptions import CommandExecutionError + +from saltext.github.utils.gh import profile as gh_profile +from saltext.github.utils.gh import repos as gh_repos + +log = logging.getLogger(__name__) + +__virtualname__ = "github" + + +def __virtual__(): + return __virtualname__ + + +def _resource_id(): + """ + Return the ID (``owner/repo``) of the resource currently being + operated on. + """ + return __resource__["id"] + + +def _split_id(resource_id): + """ + Split a resource ID into owner and repository name. + """ + owner, _, repo_name = resource_id.partition("/") + return owner, repo_name + + +def _matches(patterns, full_name, name): + """ + Report whether a repository matches any of the fnmatch patterns. + Patterns containing a slash match the full name (``owner/repo``), + others the bare repository name, both case-insensitively. + """ + for pattern in patterns: + target = full_name if "/" in pattern else name + if fnmatch.fnmatch(target.lower(), pattern.lower()): + return True + return False + + +def _conn(opts, owner=None): + """ + Return the connection for an owner per the resource declaration + (per-owner profile override or the declared default profile). + """ + return gh_profile.resource_conn(opts, __context__, owner=owner) + + +def init(opts): + """ + Initialize the ``github`` resource type for this minion. + + Validates that all declared profiles (the default one and any + per-owner overrides) carry a token (without contacting the API) + and records the type-level state in ``__context__``. + """ + profiles = gh_profile.resource_profiles(opts) + # Fail fast on unconfigured profiles; the connections themselves + # are established lazily on first use. + for profile in profiles: + gh_profile.get_config_value(profile, "token", opts=opts, context=__context__) + __context__["github_resource"] = { + "initialized": True, + "profiles": profiles, + } + log.debug("github resource init() done, profiles: %s", profiles) + + +def initialized(): + """ + Return whether ``init()`` has been called successfully for this + resource type. + """ + return __context__.get("github_resource", {}).get("initialized", False) + + +def discover(opts): + """ + Return the ``owner/repo`` IDs of the repositories this minion + manages: all repositories of the declared owners, filtered by the + ``repos``/``exclude`` patterns and the ``include_archived`` flag, + each of which can be overridden per owner. + """ + owners = gh_profile.resource_owners(opts) or [_conn(opts).owner] + + discovered = [] + for owner in owners: + include = gh_profile.resource_owner_setting(opts, owner, "repos") or ["*"] + exclude = gh_profile.resource_owner_setting(opts, owner, "exclude") or [] + include_archived = gh_profile.resource_owner_setting(opts, owner, "include_archived", False) + conn = _conn(opts, owner) + for repo in gh_repos.list_all(conn, owner=owner): + if repo.archived and not include_archived: + continue + if not _matches(include, repo.full_name, repo.name): + continue + if _matches(exclude, repo.full_name, repo.name): + continue + discovered.append(repo.full_name) + log.debug("github resource discover() returning: %s", discovered) + return sorted(discovered) + + +def grains(): + """ + Return the grains of the current resource, a ``github`` mapping + carrying the repository's identity and basic metadata. + """ + resource_id = _resource_id() + owner, repo_name = _split_id(resource_id) + conn = _conn(__opts__, owner) + # Warm the per-owner cache: a single listing serves the grains of + # all of this owner's resources within one context. + gh_repos.list_all(conn, owner=owner) + info = gh_repos.get_info(conn, repo_name, owner=owner) + return { + "github": { + "id": resource_id, + "owner": info["owner"], + "repo": info["name"], + "full_name": info["full_name"], + "private": info["private"], + "fork": info["fork"], + "default_branch": info["default_branch"], + "language": info["language"], + "html_url": info["html_url"], + "description": info["description"], + "homepage": info["homepage"], + } + } + + +def grains_refresh(): + """ + Return freshly fetched grains for the current resource, bypassing + cached repository information. + """ + owner, repo_name = _split_id(_resource_id()) + conn = _conn(__opts__, owner) + gh_repos.get_info(conn, repo_name, owner=owner, ignore_cache=True) + return grains() + + +def ping(): + """ + Return whether the current resource's repository is reachable via + the API. + """ + owner, repo_name = _split_id(_resource_id()) + try: + conn = _conn(__opts__, owner) + gh_repos.get_info(conn, repo_name, owner=owner, ignore_cache=True) + except CommandExecutionError as err: + log.debug("github resource ping() failed for %s/%s: %s", owner, repo_name, err) + return False + return True + + +def shutdown(opts): # pylint: disable=unused-argument + """ + Tear down the ``github`` resource type, dropping the type-level + state from ``__context__``. + """ + __context__.pop("github_resource", None) diff --git a/src/saltext/github/resources/github/modules/__init__.py b/src/saltext/github/resources/github/modules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/saltext/github/resources/github/modules/actions.py b/src/saltext/github/resources/github/modules/actions.py new file mode 100644 index 00000000..d5216034 --- /dev/null +++ b/src/saltext/github/resources/github/modules/actions.py @@ -0,0 +1,180 @@ +""" +Manage the targeted repository's Actions secrets and workflow +permissions. + +.. versionadded:: 1.1.0 + +Part of the :py:mod:`github ` resource type: all functions operate on the +repository the job was dispatched to. The ``profile`` parameter +defaults to the :ghconf:`profile ` named in the resource declaration. +""" + +import logging + +from saltext.github.utils import filter_fields +from saltext.github.utils.gh import actions as gh_actions +from saltext.github.utils.gh import profile as gh_profile + +log = logging.getLogger(__name__) + + +def _conn(profile=None): + owner, _ = _ident() + return gh_profile.resource_conn(__opts__, __context__, profile=profile, owner=owner) + + +def _ident(): + owner, _, repo_name = __resource__["id"].partition("/") + return owner, repo_name + + +def list_secrets(*, fields=None, profile=None): + """ + List the Actions secrets of the targeted repository, as a mapping + of secret name to metadata. Secret values are write-only, so this + only returns metadata (``created_at``, ``updated_at``). + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' actions.list_secrets + + fields + Only include these fields in the output. A single field name + reduces each entry to the field's value. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + secrets = gh_actions.list_secrets(_conn(profile), repo_name, owner=owner) + return filter_fields({secret["name"]: secret for secret in secrets}, fields) + + +def get_secret(name, *, profile=None): + """ + Return the metadata of a single Actions secret of the targeted + repository. The value itself is write-only and cannot be retrieved. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' actions.get_secret MY_SECRET + + name + Name of the secret. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + return gh_actions.get_secret(_conn(profile), repo_name, name, owner=owner) + + +def set_secret(name, value, *, profile=None): + """ + Create or update an Actions secret of the targeted repository. The + value is encrypted to the repository public key before leaving the + minion. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' actions.set_secret MY_SECRET hunter2 + + name + Name of the secret. + + value + Plaintext secret value. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + gh_actions.set_secret(_conn(profile), repo_name, name, value, owner=owner) + return True + + +def remove_secret(name, *, profile=None): + """ + Remove an Actions secret from the targeted repository. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' actions.remove_secret MY_SECRET + + name + Name of the secret. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + gh_actions.delete_secret(_conn(profile), repo_name, name, owner=owner) + return True + + +def get_workflow_permissions(*, profile=None): + """ + Return the default workflow permissions granted to the ``GITHUB_TOKEN`` + in the targeted repository, a mapping with ``default_workflow_permissions`` + (``read``/``write``) and ``can_approve_pull_request_reviews``. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' actions.get_workflow_permissions + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + return gh_actions.get_workflow_permissions(_conn(profile), repo_name, owner=owner) + + +def set_workflow_permissions( + *, default_workflow_permissions=None, can_approve_pull_request_reviews=None, profile=None +): + """ + Update the default workflow permissions granted to the ``GITHUB_TOKEN`` + in the targeted repository. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' actions.set_workflow_permissions \\ + default_workflow_permissions=read can_approve_pull_request_reviews=true + + default_workflow_permissions + Default permissions for the ``GITHUB_TOKEN``, either ``read`` + or ``write``. + + can_approve_pull_request_reviews + Allow GitHub Actions to approve pull requests. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + gh_actions.set_workflow_permissions( + _conn(profile), + repo_name, + default_workflow_permissions=default_workflow_permissions, + can_approve_pull_request_reviews=can_approve_pull_request_reviews, + owner=owner, + ) + return True diff --git a/src/saltext/github/resources/github/modules/deploy_key.py b/src/saltext/github/resources/github/modules/deploy_key.py new file mode 100644 index 00000000..c197d218 --- /dev/null +++ b/src/saltext/github/resources/github/modules/deploy_key.py @@ -0,0 +1,127 @@ +""" +Manage the targeted repository's deploy keys. + +.. versionadded:: 1.1.0 + +Part of the :py:mod:`github ` resource type: all functions operate on the +repository the job was dispatched to. The ``profile`` parameter +defaults to the :ghconf:`profile ` named in the resource declaration. +""" + +import logging + +from saltext.github.utils import filter_fields +from saltext.github.utils.gh import deploy_keys as gh_deploy_keys +from saltext.github.utils.gh import profile as gh_profile + +log = logging.getLogger(__name__) + +__func_alias__ = {"list_": "list"} + + +def _conn(profile=None): + owner, _ = _ident() + return gh_profile.resource_conn(__opts__, __context__, profile=profile, owner=owner) + + +def _ident(): + owner, _, repo_name = __resource__["id"].partition("/") + return owner, repo_name + + +def list_(*, fields=None, profile=None): + """ + List the deploy keys of the targeted repository. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' deploy_key.list + + fields + Only include these fields in the output. A single field name + reduces each entry to the field's value. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + return filter_fields(gh_deploy_keys.list_all(_conn(profile), repo_name, owner=owner), fields) + + +def get(key_id, *, profile=None): + """ + Return the data of a single deploy key of the targeted repository. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' deploy_key.get 12345 + + key_id + ID of the deploy key. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + return gh_deploy_keys.get(_conn(profile), repo_name, key_id, owner=owner) + + +def add(title, key, *, read_only=False, profile=None): + """ + Add a deploy key to the targeted repository. + Returns the data of the created key. + + .. note:: + Deploy keys are immutable. To modify one, remove it and add a replacement. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' deploy_key.add autorelease 'ssh-ed25519 AAAA...' read_only=false + + title + Display title of the deploy key. + + key + SSH public key. + + read_only + Deny write access to the repository for this key. Defaults to false. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + return gh_deploy_keys.create( + _conn(profile), repo_name, title, key, read_only=bool(read_only), owner=owner + ) + + +def remove(key_id, *, profile=None): + """ + Remove a deploy key from the targeted repository. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' deploy_key.remove 12345 + + key_id + ID of the deploy key. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + gh_deploy_keys.delete(_conn(profile), repo_name, key_id, owner=owner) + return True diff --git a/src/saltext/github/resources/github/modules/environment.py b/src/saltext/github/resources/github/modules/environment.py new file mode 100644 index 00000000..e7a5397a --- /dev/null +++ b/src/saltext/github/resources/github/modules/environment.py @@ -0,0 +1,248 @@ +""" +Manage the targeted repository's (deployment) environments. + +.. versionadded:: 1.1.0 + +Part of the :py:mod:`github ` resource type: all functions operate on the +repository the job was dispatched to. The ``profile`` parameter +defaults to the :ghconf:`profile ` named in the resource declaration. +""" + +import logging + +from saltext.github.utils import filter_fields +from saltext.github.utils.gh import environments as gh_environments +from saltext.github.utils.gh import profile as gh_profile + +log = logging.getLogger(__name__) + +__func_alias__ = {"list_": "list", "set_": "set"} + + +def _conn(profile=None): + owner, _ = _ident() + return gh_profile.resource_conn(__opts__, __context__, profile=profile, owner=owner) + + +def _ident(): + owner, _, repo_name = __resource__["id"].partition("/") + return owner, repo_name + + +def list_(*, fields=None, profile=None): + """ + List the environments of the targeted repository, as a mapping of + environment name to details. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' environment.list + + fields + Only include these fields in the output. A single field name + reduces each entry to the field's value. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + environments = gh_environments.list_all(_conn(profile), repo_name, owner=owner) + return filter_fields({env["name"]: env for env in environments}, fields) + + +def get(name, *, profile=None): + """ + Return the data of a single environment of the targeted repository. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' environment.get release + + name + Name of the environment. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + return gh_environments.get(_conn(profile), repo_name, name, owner=owner) + + +def set_( + name, + *, + wait_timer=None, + prevent_self_review=None, + reviewers=None, + deployment_branch_policy=None, + profile=None, +): + """ + Create or update an environment of the targeted repository, + replacing its whole configuration. Unspecified settings are reset + to their defaults. Returns the data of the environment. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' environment.set release \\ + deployment_branch_policy='{protected_branches: false, custom_branch_policies: true}' + + name + Name of the environment. + + wait_timer + Amount of time to delay a job after it is initially triggered, + in minutes. + + prevent_self_review + Prevent a user who created a job from approving it. + + reviewers + People or teams that may review jobs that reference the + environment, as a list of mappings with ``type`` (``User`` or + ``Team``) and ``id``. + + deployment_branch_policy + Type of deployment branch policy for this environment, either + ``null`` (default) to allow all branches or a mapping with + ``protected_branches`` and ``custom_branch_policies`` booleans. + Branch name patterns for custom policies are managed separately, + see :py:func:`environment.add_branch_policy + `. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + return gh_environments.set_( + _conn(profile), + repo_name, + name, + wait_timer=wait_timer, + prevent_self_review=prevent_self_review, + reviewers=reviewers, + deployment_branch_policy=deployment_branch_policy, + owner=owner, + ) + + +def remove(name, *, profile=None): + """ + Remove an environment from the targeted repository. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' environment.remove release + + name + Name of the environment. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + gh_environments.delete(_conn(profile), repo_name, name, owner=owner) + return True + + +def list_branch_policies(environment, *, fields=None, profile=None): + """ + List the deployment branch policies of an environment of the + targeted repository. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' environment.list_branch_policies release + + environment + Name of the environment. + + fields + Only include these fields in the output. A single field name + reduces each entry to the field's value. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + return filter_fields( + gh_environments.list_branch_policies(_conn(profile), repo_name, environment, owner=owner), + fields, + ) + + +def add_branch_policy(environment, name, *, typ="branch", profile=None): + """ + Add a deployment branch (or tag) policy to an environment of the + targeted repository. Returns the data of the created policy. + + The environment's ``deployment_branch_policy`` must have ``custom_branch_policies`` enabled. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' environment.add_branch_policy release main + + environment + Name of the environment. + + name + Name pattern that branches (or tags) must match in order to + deploy to the environment. + + typ + Target of this rule, either ``branch`` or ``tag``. + Defaults to ``branch``. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + return gh_environments.create_branch_policy( + _conn(profile), repo_name, environment, name, typ=typ, owner=owner + ) + + +def remove_branch_policy(environment, policy_id, *, profile=None): + """ + Remove a deployment branch policy from an environment of the + targeted repository. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' environment.remove_branch_policy release 12345 + + environment + Name of the environment. + + policy_id + ID of the branch policy. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + gh_environments.delete_branch_policy( + _conn(profile), repo_name, environment, policy_id, owner=owner + ) + return True diff --git a/src/saltext/github/resources/github/modules/issue.py b/src/saltext/github/resources/github/modules/issue.py new file mode 100644 index 00000000..0513cbdb --- /dev/null +++ b/src/saltext/github/resources/github/modules/issue.py @@ -0,0 +1,162 @@ +""" +Inspect the targeted repository's issues (read-only). + +.. versionadded:: 1.1.0 + +Part of the :py:mod:`github ` resource type: all functions operate on the +repository the job was dispatched to. The ``profile`` parameter +defaults to the :ghconf:`profile ` named in the resource declaration. +""" + +import logging + +from saltext.github.utils import filter_fields +from saltext.github.utils.gh import issues as gh_issues +from saltext.github.utils.gh import profile as gh_profile + +log = logging.getLogger(__name__) + +__func_alias__ = {"list_": "list"} + + +def _conn(profile=None): + owner, _ = _ident() + return gh_profile.resource_conn(__opts__, __context__, profile=profile, owner=owner) + + +def _ident(): + owner, _, repo_name = __resource__["id"].partition("/") + return owner, repo_name + + +def list_( + *, + milestone=None, + state="open", + assignee=None, + creator=None, + mentioned=None, + labels=None, + sort="created", + direction="desc", + since=None, + per_page=None, + fields=None, + profile=None, +): + """ + Return the issues in the targeted repository, based on the search + options. Pull requests are excluded. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' issue.list + + milestone + Number of a GitHub milestone, or a string of either ``*`` or ``none``. + + state + Filter returned issues by their state. Can be either ``open``, + ``closed``, or ``all``. Default is ``open``. + + assignee + Filter returned issues by assigned user handle. Pass ``none`` (as a string) for issues + with no assigned user or ``*`` for issues assigned to any user. + + creator + Filter by the username that created the issue. + + mentioned + Filter issues for a user that's mentioned in the issue. + + labels + Filter issues by associated labels. String of comma separated label names. + For example, ``bug,ui,@high``. + + sort + Field to sort results by. Either ``created``, ``updated``, or + ``comments``. Default is ``created``. + + direction + Sort direction. Either ``asc`` or ``desc``. Default is ``desc``. + + since + Only list issues updated at or after this time. This is a + timestamp in ISO 8601 format: ``YYYY-MM-DDTHH:MM:SSZ``. + + per_page + Pagination size, maximum 100. + + fields + Only include these fields in the output. A single field name + reduces each entry to the field's value. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + issues = gh_issues.list_all( + _conn(profile), + repo_name, + milestone=milestone, + state=state, + assignee=assignee, + creator=creator, + mentioned=mentioned, + labels=labels, + sort=sort, + direction=direction, + since=since, + per_page=per_page, + owner=owner, + ) + return filter_fields(issues, fields) + + +def get(number, *, profile=None): + """ + Return information about a single issue in the targeted repository. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' issue.get 514 + + number + Issue number to retrieve. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + return gh_issues.get(_conn(profile), repo_name, number, owner=owner) + + +def get_comments(number, *, since=None, profile=None): + """ + Return the comments of an issue in the targeted repository. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' issue.get_comments 514 + + number + Issue number for which to retrieve comments. + + since + Only return comments updated at or after this time. This is a + timestamp in ISO 8601 format: ``YYYY-MM-DDTHH:MM:SSZ``. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + return gh_issues.get_comments(_conn(profile), repo_name, number, since=since, owner=owner) diff --git a/src/saltext/github/resources/github/modules/label.py b/src/saltext/github/resources/github/modules/label.py new file mode 100644 index 00000000..cd13d3f3 --- /dev/null +++ b/src/saltext/github/resources/github/modules/label.py @@ -0,0 +1,166 @@ +""" +Manage the targeted repository's labels. + +.. versionadded:: 1.1.0 + +Part of the :py:mod:`github ` resource type: all functions operate on the +repository the job was dispatched to. The ``profile`` parameter +defaults to the :ghconf:`profile ` named in the resource declaration. +""" + +import logging + +from saltext.github.utils import filter_fields +from saltext.github.utils.gh import labels as gh_labels +from saltext.github.utils.gh import profile as gh_profile + +log = logging.getLogger(__name__) + +__func_alias__ = {"list_": "list"} + + +def _conn(profile=None): + owner, _ = _ident() + return gh_profile.resource_conn(__opts__, __context__, profile=profile, owner=owner) + + +def _ident(): + owner, _, repo_name = __resource__["id"].partition("/") + return owner, repo_name + + +def list_(*, fields=None, profile=None): + """ + List the labels of the targeted repository, as a mapping of label + name to details. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' label.list + + fields + Only include these fields in the output. A single field name + reduces each entry to the field's value. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + labels = gh_labels.list_all(_conn(profile), repo_name, owner=owner) + return filter_fields({label["name"]: label for label in labels}, fields) + + +def get(name, *, profile=None): + """ + Return the data of a single label of the targeted repository. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' label.get bug + + name + Name of the label. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + return gh_labels.get(_conn(profile), repo_name, name, owner=owner) + + +def add(name, *, color=None, description=None, profile=None): + """ + Create a label in the targeted repository. + Returns the data of the created label. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' label.add release color=00ff00 + + name + Name of the label. + + color + Hexadecimal color code for the label, without the leading ``#``. + If unset, GitHub assigns a random one. + + description + Short description of the label. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + return gh_labels.create( + _conn(profile), repo_name, name, color=color, description=description, owner=owner + ) + + +def edit(name, *, new_name=None, color=None, description=None, profile=None): + """ + Update an existing label of the targeted repository. + Returns the data of the updated label. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' label.edit release color=00ff00 + + name + Current name of the label. + + new_name + New name for the label. + + color + Hexadecimal color code for the label, without the leading ``#``. + + description + Short description of the label. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + return gh_labels.update( + _conn(profile), + repo_name, + name, + new_name=new_name, + color=color, + description=description, + owner=owner, + ) + + +def remove(name, *, profile=None): + """ + Delete a label from the targeted repository. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' label.remove release + + name + Name of the label. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + gh_labels.delete(_conn(profile), repo_name, name, owner=owner) + return True diff --git a/src/saltext/github/resources/github/modules/milestone.py b/src/saltext/github/resources/github/modules/milestone.py new file mode 100644 index 00000000..e61cd551 --- /dev/null +++ b/src/saltext/github/resources/github/modules/milestone.py @@ -0,0 +1,115 @@ +""" +Inspect the targeted repository's milestones (read-only). + +.. versionadded:: 1.1.0 + +Part of the :py:mod:`github ` resource type: all functions operate on the +repository the job was dispatched to. The ``profile`` parameter +defaults to the :ghconf:`profile ` named in the resource declaration. +""" + +import logging + +from salt.exceptions import CommandExecutionError + +from saltext.github.utils import filter_fields +from saltext.github.utils.gh import milestones as gh_milestones +from saltext.github.utils.gh import profile as gh_profile +from saltext.github.utils.gh.exceptions import GitHubNotFoundError + +log = logging.getLogger(__name__) + +__func_alias__ = {"list_": "list"} + + +def _conn(profile=None): + owner, _ = _ident() + return gh_profile.resource_conn(__opts__, __context__, profile=profile, owner=owner) + + +def _ident(): + owner, _, repo_name = __resource__["id"].partition("/") + return owner, repo_name + + +def list_( + *, state="open", sort="due_on", direction="asc", per_page=None, fields=None, profile=None +): + """ + Return the targeted repository's milestones. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' milestone.list + + state + Filter milestones by their state. Either ``open``, ``closed``, or ``all``. + Default is ``open``. + + sort + Field to sort results by. Either ``due_on`` or ``completeness``. Default + is ``due_on``. + + direction + Sort direction. Either ``asc`` or ``desc``. Default is ``asc``. + + per_page + Pagination size, maximum 100. + + fields + Only include these fields in the output. A single field name + reduces each entry to the field's value. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + milestones = gh_milestones.list_all( + _conn(profile), + repo_name, + state=state, + sort=sort, + direction=direction, + per_page=per_page, + owner=owner, + ) + return filter_fields(milestones, fields) + + +def get(number=None, *, name=None, profile=None): + """ + Return information about a single milestone in the targeted + repository. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' milestone.get 72 + salt 'github:my-org/my-repo' milestone.get name=my_milestone + + number + Milestone number to retrieve. If provided, overrides ``name``. + + name + Title of the milestone to retrieve. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + if not (number or name): + raise CommandExecutionError("Either a milestone 'name' or 'number' must be provided.") + + owner, repo_name = _ident() + if number: + return gh_milestones.get(_conn(profile), repo_name, number, owner=owner) + + milestones = gh_milestones.list_all(_conn(profile), repo_name, state="all", owner=owner) + for milestone in milestones or []: + if milestone.get("title") == name: + return milestone + raise GitHubNotFoundError(f"Milestone {name} was not found in repository {repo_name}.") diff --git a/src/saltext/github/resources/github/modules/pages.py b/src/saltext/github/resources/github/modules/pages.py new file mode 100644 index 00000000..0361bd70 --- /dev/null +++ b/src/saltext/github/resources/github/modules/pages.py @@ -0,0 +1,154 @@ +""" +Manage the targeted repository's GitHub Pages site. + +.. versionadded:: 1.1.0 + +Part of the :py:mod:`github ` resource type: all functions operate on the +repository the job was dispatched to. The ``profile`` parameter +defaults to the :ghconf:`profile ` named in the resource declaration. +""" + +import logging + +from saltext.github.utils.gh import pages as gh_pages +from saltext.github.utils.gh import profile as gh_profile + +log = logging.getLogger(__name__) + + +def _conn(profile=None): + owner, _ = _ident() + return gh_profile.resource_conn(__opts__, __context__, profile=profile, owner=owner) + + +def _ident(): + owner, _, repo_name = __resource__["id"].partition("/") + return owner, repo_name + + +def get(*, profile=None): + """ + Return information about the GitHub Pages site of the targeted + repository. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' pages.get + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + return gh_pages.get(_conn(profile), repo_name, owner=owner) + + +def setup(*, build_type=None, source_branch=None, source_path=None, profile=None): + """ + Configure a GitHub Pages site for the targeted repository. + Returns the data of the created site. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' pages.setup build_type=workflow + + build_type + Process by which the site is built, either ``legacy`` + (deploy from a branch) or ``workflow`` (GitHub Actions). + + source_branch + For ``legacy`` builds, the repository branch to publish from. + + source_path + For ``legacy`` builds, the repository directory to publish from, + either ``/`` or ``/docs``. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + return gh_pages.create( + _conn(profile), + repo_name, + build_type=build_type, + source=gh_pages.build_source(source_branch, source_path), + owner=owner, + ) + + +def update( + *, + build_type=None, + source_branch=None, + source_path=None, + cname=..., + https_enforced=None, + profile=None, +): + """ + Update the GitHub Pages site configuration of the targeted + repository. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' pages.update build_type=workflow + + build_type + Process by which the site is built, either ``legacy`` + (deploy from a branch) or ``workflow`` (GitHub Actions). + + source_branch + For ``legacy`` builds, the repository branch to publish from. + + source_path + For ``legacy`` builds, the repository directory to publish from, + either ``/`` or ``/docs``. + + cname + Custom domain for the site. Pass ``null`` explicitly to remove it, + omit to leave it unchanged. + + https_enforced + Enforce HTTPS for the site. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + gh_pages.update( + _conn(profile), + repo_name, + build_type=build_type, + source=gh_pages.build_source(source_branch, source_path), + cname=cname, + https_enforced=https_enforced, + owner=owner, + ) + return True + + +def remove(*, profile=None): + """ + Delete the GitHub Pages site of the targeted repository. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' pages.remove + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + gh_pages.delete(_conn(profile), repo_name, owner=owner) + return True diff --git a/src/saltext/github/resources/github/modules/pull.py b/src/saltext/github/resources/github/modules/pull.py new file mode 100644 index 00000000..fb503721 --- /dev/null +++ b/src/saltext/github/resources/github/modules/pull.py @@ -0,0 +1,95 @@ +""" +Inspect the targeted repository's pull requests (read-only). + +.. versionadded:: 1.1.0 + +Part of the :py:mod:`github ` resource type: all functions operate on the +repository the job was dispatched to. The ``profile`` parameter +defaults to the :ghconf:`profile ` named in the resource declaration. +""" + +import logging + +from saltext.github.utils import filter_fields +from saltext.github.utils.gh import profile as gh_profile +from saltext.github.utils.gh import pulls as gh_pulls + +log = logging.getLogger(__name__) + +__func_alias__ = {"list_": "list"} + + +def _conn(profile=None): + owner, _ = _ident() + return gh_profile.resource_conn(__opts__, __context__, profile=profile, owner=owner) + + +def _ident(): + owner, _, repo_name = __resource__["id"].partition("/") + return owner, repo_name + + +def list_( + *, + state="open", + head=None, + base=None, + sort="created", + direction="desc", + per_page=None, + fields=None, + profile=None, +): + """ + Return the pull requests in the targeted repository, based on the + search options. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' pull.list + + state + Filter returned pull requests by their state. Can be either + ``open``, ``closed``, or ``all``. Default is ``open``. + + head + Filter pull requests by head user and branch name in the format + ``user:ref-name``. + + base + Filter pulls by base branch name, e.g. ``gh-pages``. + + sort + Field to sort results by. Either ``created``, ``updated``, + ``popularity`` (comment count), or ``long-running`` (age, filtering + by pull requests updated within the last month). Default is ``created``. + + direction + Sort direction. Either ``asc`` or ``desc``. Default is ``desc``. + + per_page + Pagination size, maximum 100. + + fields + Only include these fields in the output. A single field name + reduces each entry to the field's value. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + pulls = gh_pulls.list_all( + _conn(profile), + repo_name, + state=state, + head=head, + base=base, + sort=sort, + direction=direction, + per_page=per_page, + owner=owner, + ) + return filter_fields(pulls, fields) diff --git a/src/saltext/github/resources/github/modules/repo.py b/src/saltext/github/resources/github/modules/repo.py new file mode 100644 index 00000000..05fdd7ea --- /dev/null +++ b/src/saltext/github/resources/github/modules/repo.py @@ -0,0 +1,164 @@ +""" +Manage the targeted repository's settings and inspect its identity +and team associations. + +.. versionadded:: 1.1.0 + +Part of the :py:mod:`github ` resource type: all functions operate on the +repository the job was dispatched to. The ``profile`` parameter +defaults to the :ghconf:`profile ` named in the resource declaration. +""" + +import logging + +from saltext.github.utils import filter_fields +from saltext.github.utils.gh import profile as gh_profile +from saltext.github.utils.gh import repos as gh_repos + +log = logging.getLogger(__name__) + + +def _profile(profile): + owner, _ = _ident() + return profile or gh_profile.resource_owner_profile(__opts__, owner) + + +def _conn(profile=None): + owner, _ = _ident() + return gh_profile.resource_conn(__opts__, __context__, profile=profile, owner=owner) + + +def _ident(): + owner, _, repo_name = __resource__["id"].partition("/") + return owner, repo_name + + +def ident(): + """ + Return the identity of the targeted repository as a mapping with + ``id`` (the resource ID), ``owner`` and ``repo``. Does not contact + the API. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' repo.ident + """ + owner, repo_name = _ident() + return {"id": __resource__["id"], "owner": owner, "repo": repo_name} + + +def info(*, ignore_cache=False, profile=None): + """ + Return information about the targeted repository. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' repo.info + + ignore_cache + Bypass the use of cached repository information. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + return gh_repos.get_info(_conn(profile), repo_name, owner=owner, ignore_cache=ignore_cache) + + +def get_teams(*, fields=None, profile=None): + """ + Return the teams the targeted repository belongs to, as a mapping + of team name to details. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' repo.get_teams + + fields + Only include these fields in the output. A single field name + reduces each entry to the field's value. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + teams = gh_repos.get_teams(_conn(profile), repo_name, owner=owner) + return filter_fields({team["name"]: team for team in teams}, fields) + + +def update( + *, + description=None, + homepage=None, + private=None, + has_issues=None, + has_wiki=None, + has_downloads=None, + default_branch=None, + profile=None, +): + """ + Update the targeted repository's settings. + Returns the refreshed repository information. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' repo.update description='Managed by Salt' + + description + Repository description. + + homepage + URL with more information about the repository, e.g. rendered docs. + + private + Repository visibility. Changing this requires :ghconf:`allow_repo_privacy_changes` + to be enabled in the profile. + + has_issues + Enable issues for this repository. + + has_wiki + Enable the wiki for this repository. + + has_downloads + Enable downloads for this repository. + + default_branch + Default branch of the repository. Must already exist. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + allow_privacy_change = gh_profile.get_config_value( + _profile(profile), + "allow_repo_privacy_changes", + opts=__opts__, + context=__context__, + default=False, + ) + return gh_repos.update( + _conn(profile), + repo_name, + owner=owner, + description=description, + homepage=homepage, + private=private, + has_issues=has_issues, + has_wiki=has_wiki, + has_downloads=has_downloads, + default_branch=default_branch, + allow_privacy_change=allow_privacy_change, + ) diff --git a/src/saltext/github/resources/github/modules/ruleset.py b/src/saltext/github/resources/github/modules/ruleset.py new file mode 100644 index 00000000..62a5688f --- /dev/null +++ b/src/saltext/github/resources/github/modules/ruleset.py @@ -0,0 +1,151 @@ +""" +Manage the targeted repository's rulesets. + +.. versionadded:: 1.1.0 + +Part of the :py:mod:`github ` resource type: all functions operate on the +repository the job was dispatched to. The ``profile`` parameter +defaults to the :ghconf:`profile ` named in the resource declaration. +""" + +import logging + +from saltext.github.utils import filter_fields +from saltext.github.utils.gh import profile as gh_profile +from saltext.github.utils.gh import rulesets as gh_rulesets + +log = logging.getLogger(__name__) + +__func_alias__ = {"list_": "list"} + + +def _conn(profile=None): + owner, _ = _ident() + return gh_profile.resource_conn(__opts__, __context__, profile=profile, owner=owner) + + +def _ident(): + owner, _, repo_name = __resource__["id"].partition("/") + return owner, repo_name + + +def list_(*, per_page=None, fields=None, profile=None): + """ + List all rulesets of the targeted repository. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' ruleset.list + + per_page + Pagination size, maximum 100. + + fields + Only include these fields in the output. A single field name + reduces each entry to the field's value. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + rulesets = gh_rulesets.list_all( + _conn(profile), "repo", owner=owner, repo_name=repo_name, per_page=per_page + ) + return filter_fields(rulesets, fields) + + +def get(ruleset_id, *, profile=None): + """ + Return the data of a single ruleset of the targeted repository. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' ruleset.get 1234 + + ruleset_id + ID of the ruleset. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + return gh_rulesets.get(_conn(profile), ruleset_id, "repo", owner=owner, repo_name=repo_name) + + +def add(ruleset_params, *, profile=None): + """ + Create a new ruleset in the targeted repository. + Returns the data of the created ruleset. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' ruleset.add '{name: test, enforcement: active}' + + ruleset_params + Ruleset definition. Must contain at least ``name`` and ``enforcement``. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + return gh_rulesets.create( + _conn(profile), ruleset_params, "repo", owner=owner, repo_name=repo_name + ) + + +def update(ruleset_id, ruleset_params, *, profile=None): + """ + Update an existing ruleset of the targeted repository. + Returns the data of the updated ruleset. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' ruleset.update 1234 '{enforcement: disabled}' + + ruleset_id + ID of the ruleset. + + ruleset_params + Updated ruleset definition. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + return gh_rulesets.update( + _conn(profile), ruleset_id, ruleset_params, "repo", owner=owner, repo_name=repo_name + ) + + +def remove(ruleset_id, *, profile=None): + """ + Delete a ruleset from the targeted repository. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' ruleset.remove 1234 + + ruleset_id + ID of the ruleset. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + owner, repo_name = _ident() + gh_rulesets.delete(_conn(profile), ruleset_id, "repo", owner=owner, repo_name=repo_name) + return True diff --git a/src/saltext/github/resources/github/modules/test.py b/src/saltext/github/resources/github/modules/test.py new file mode 100644 index 00000000..7aa2b267 --- /dev/null +++ b/src/saltext/github/resources/github/modules/test.py @@ -0,0 +1,29 @@ +""" +Provide the ``test`` execution module for the ``github`` resource type. + +.. versionadded:: 1.1.0 + +Its location in the per-type override directory makes it visible only +to the ``github`` resource loader, so no ``__virtual__`` gate is needed. +""" + +import logging + +log = logging.getLogger(__name__) + + +def ping(): + """ + Return whether the targeted repository is reachable via the API. + + Delegates to the connection module's ``ping()`` via + ``__resource_funcs__`` so the result reflects the actual state of + the resource rather than the managing minion. + + CLI Example: + + .. code-block:: bash + + salt 'github:my-org/my-repo' test.ping + """ + return __resource_funcs__["github.ping"]() diff --git a/src/saltext/github/resources/github/states/__init__.py b/src/saltext/github/resources/github/states/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/saltext/github/resources/github/states/actions.py b/src/saltext/github/resources/github/states/actions.py new file mode 100644 index 00000000..37a9ec1f --- /dev/null +++ b/src/saltext/github/resources/github/states/actions.py @@ -0,0 +1,143 @@ +""" +Manage the targeted repository's Actions secrets and workflow +permissions. + +.. versionadded:: 1.1.0 + +Part of the :py:mod:`github ` resource type: states operate on the repository +the state run was dispatched to, so there is no ``repo_name``/``owner`` +parameter. The ``profile`` parameter defaults to the :ghconf:`profile ` named in +the resource declaration. +""" + +import logging + +from salt.exceptions import CommandExecutionError + +from saltext.github.utils.gh import profile as gh_profile +from saltext.github.utils.gh.statecores import actions as sc_actions + +log = logging.getLogger(__name__) + + +def _ident(): + """ + Resource identity. Salt's authoring docs promise ``__resource__`` + inside state overrides, but no released Salt packs it there — fall + back to the ``repo.ident`` execution override, which does see it. + """ + try: + rid = __resource__["id"] + except NameError: + return __salt__["repo.ident"]() + owner, _, repo_name = rid.partition("/") + return {"id": rid, "owner": owner, "repo": repo_name} + + +def _core_args(profile): + ident = _ident() + conn = gh_profile.resource_conn(__opts__, __context__, profile=profile, owner=ident["owner"]) + return conn, ident["owner"], ident["repo"] + + +def _failed(name, err): + return {"name": name, "changes": {}, "result": False, "comment": str(err)} + + +def secret_present(name, value=None, force=False, profile=None): + """ + Ensure an Actions secret is present in the targeted repository. + + Secret values are write-only in the GitHub API, so this state cannot + detect value drift. By default, an existing secret is left untouched. + Set ``force`` to push the value on every run. + + name + Name of the secret. GitHub treats secret names + case-insensitively. + + value + Plaintext secret value. It is encrypted to the repository + public key before leaving the minion and never appears in the + state output. Required when the secret does not exist yet or + ``force`` is set. + + force + Push the value even when the secret already exists. + Defaults to false. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + try: + conn, owner, repo_name = _core_args(profile) + return sc_actions.secret_present( + conn, + name, + repo_name, + value=value, + force=force, + owner=owner, + test=__opts__["test"], + ) + except CommandExecutionError as err: + return _failed(name, err) + + +def secret_absent(name, profile=None): + """ + Ensure an Actions secret is absent from the targeted repository. + + name + Name of the secret. GitHub treats secret names + case-insensitively. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + try: + conn, owner, repo_name = _core_args(profile) + return sc_actions.secret_absent(conn, name, repo_name, owner=owner, test=__opts__["test"]) + except CommandExecutionError as err: + return _failed(name, err) + + +def workflow_permissions_managed( + name, + default_workflow_permissions=None, + can_approve_pull_request_reviews=None, + profile=None, +): + """ + Manage the default workflow permissions granted to the ``GITHUB_TOKEN`` + in the targeted repository. + + name + State name (the repository is implied by the resource). + + default_workflow_permissions + Default permissions for the ``GITHUB_TOKEN``, either ``read`` + or ``write``. + + can_approve_pull_request_reviews + Allow GitHub Actions to approve pull requests. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + try: + conn, owner, repo_name = _core_args(profile) + return sc_actions.workflow_permissions_managed( + conn, + name, + repo_name, + default_workflow_permissions=default_workflow_permissions, + can_approve_pull_request_reviews=can_approve_pull_request_reviews, + owner=owner, + test=__opts__["test"], + ) + except CommandExecutionError as err: + return _failed(name, err) diff --git a/src/saltext/github/resources/github/states/deploy_key.py b/src/saltext/github/resources/github/states/deploy_key.py new file mode 100644 index 00000000..2dddce80 --- /dev/null +++ b/src/saltext/github/resources/github/states/deploy_key.py @@ -0,0 +1,108 @@ +""" +Manage the targeted repository's deploy keys. + +.. versionadded:: 1.1.0 + +Part of the :py:mod:`github ` resource type: states operate on the repository +the state run was dispatched to, so there is no ``repo_name``/``owner`` +parameter. The ``profile`` parameter defaults to the :ghconf:`profile ` named in +the resource declaration. +""" + +import logging + +from salt.exceptions import CommandExecutionError + +from saltext.github.utils.gh import profile as gh_profile +from saltext.github.utils.gh.statecores import deploy_keys as sc_deploy_keys + +log = logging.getLogger(__name__) + + +def _ident(): + """ + Resource identity. Salt's authoring docs promise ``__resource__`` + inside state overrides, but no released Salt packs it there — fall + back to the ``repo.ident`` execution override, which does see it. + """ + try: + rid = __resource__["id"] + except NameError: + return __salt__["repo.ident"]() + owner, _, repo_name = rid.partition("/") + return {"id": rid, "owner": owner, "repo": repo_name} + + +def _core_args(profile): + ident = _ident() + conn = gh_profile.resource_conn(__opts__, __context__, profile=profile, owner=ident["owner"]) + return conn, ident["owner"], ident["repo"] + + +def _failed(name, err): + return {"name": name, "changes": {}, "result": False, "comment": str(err)} + + +def present(name, key, read_only=False, profile=None): + """ + Ensure a deploy key with this title and public key is present + in the targeted repository. + + Since deploy keys are immutable in the GitHub API, a key whose + ``key`` or ``read_only`` setting differs is replaced (removed + and recreated). + + Example: + + .. code-block:: yaml + + Autorelease deploy key is present: + deploy_key.present: + - name: autorelease + - key: ssh-ed25519 AAAA... + + name + Display title of the deploy key. + + key + SSH public key. + + read_only + Deny write access to the repository for this key. Defaults to false. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + try: + conn, owner, repo_name = _core_args(profile) + return sc_deploy_keys.present( + conn, + name, + key, + repo_name, + read_only=read_only, + owner=owner, + test=__opts__["test"], + ) + except CommandExecutionError as err: + return _failed(name, err) + + +def absent(name, profile=None): + """ + Ensure no deploy key with this title is present in the targeted + repository. + + name + Display title of the deploy key. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + try: + conn, owner, repo_name = _core_args(profile) + return sc_deploy_keys.absent(conn, name, repo_name, owner=owner, test=__opts__["test"]) + except CommandExecutionError as err: + return _failed(name, err) diff --git a/src/saltext/github/resources/github/states/environment.py b/src/saltext/github/resources/github/states/environment.py new file mode 100644 index 00000000..66e9e4c4 --- /dev/null +++ b/src/saltext/github/resources/github/states/environment.py @@ -0,0 +1,123 @@ +""" +Manage the targeted repository's (deployment) environments. + +.. versionadded:: 1.1.0 + +Part of the :py:mod:`github ` resource type: states operate on the repository +the state run was dispatched to, so there is no ``repo_name``/``owner`` +parameter. The ``profile`` parameter defaults to the :ghconf:`profile ` named in +the resource declaration. +""" + +import logging + +from salt.exceptions import CommandExecutionError + +from saltext.github.utils.gh import profile as gh_profile +from saltext.github.utils.gh.statecores import environments as sc_environments + +log = logging.getLogger(__name__) + + +def _ident(): + """ + Resource identity. Salt's authoring docs promise ``__resource__`` + inside state overrides, but no released Salt packs it there — fall + back to the ``repo.ident`` execution override, which does see it. + """ + try: + rid = __resource__["id"] + except NameError: + return __salt__["repo.ident"]() + owner, _, repo_name = rid.partition("/") + return {"id": rid, "owner": owner, "repo": repo_name} + + +def _core_args(profile): + ident = _ident() + conn = gh_profile.resource_conn(__opts__, __context__, profile=profile, owner=ident["owner"]) + return conn, ident["owner"], ident["repo"] + + +def _failed(name, err): + return {"name": name, "changes": {}, "result": False, "comment": str(err)} + + +def present( + name, + wait_timer=None, + prevent_self_review=None, + reviewers=None, + deployment_branches=None, + profile=None, +): + """ + Ensure an environment is present in the targeted repository and + configured as specified. Unspecified settings are left unmanaged. + + Example: + + .. code-block:: yaml + + release: + environment.present: + - deployment_branches: + - main + + name + Name of the environment. + + wait_timer + Amount of time to delay a job after it is initially triggered, + in minutes. + + prevent_self_review + Prevent a user who created a job from approving it. + + reviewers + People or teams that may review jobs that reference the + environment, as a list of mappings with ``type`` (``User`` or + ``Team``) and ``id``. + + deployment_branches + Which branches can deploy to this environment. Either ``all``, + ``protected`` (branches with branch protection rules) or a list + of branch name patterns. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + try: + conn, owner, repo_name = _core_args(profile) + return sc_environments.present( + conn, + name, + repo_name, + wait_timer=wait_timer, + prevent_self_review=prevent_self_review, + reviewers=reviewers, + deployment_branches=deployment_branches, + owner=owner, + test=__opts__["test"], + ) + except CommandExecutionError as err: + return _failed(name, err) + + +def absent(name, profile=None): + """ + Ensure an environment is absent from the targeted repository. + + name + Name of the environment. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + try: + conn, owner, repo_name = _core_args(profile) + return sc_environments.absent(conn, name, repo_name, owner=owner, test=__opts__["test"]) + except CommandExecutionError as err: + return _failed(name, err) diff --git a/src/saltext/github/resources/github/states/label.py b/src/saltext/github/resources/github/states/label.py new file mode 100644 index 00000000..a9eeaeae --- /dev/null +++ b/src/saltext/github/resources/github/states/label.py @@ -0,0 +1,95 @@ +""" +Manage the targeted repository's labels. + +.. versionadded:: 1.1.0 + +Part of the :py:mod:`github ` resource type: states operate on the repository +the state run was dispatched to, so there is no ``repo_name``/``owner`` +parameter. The ``profile`` parameter defaults to the :ghconf:`profile ` named in +the resource declaration. +""" + +import logging + +from salt.exceptions import CommandExecutionError + +from saltext.github.utils.gh import profile as gh_profile +from saltext.github.utils.gh.statecores import labels as sc_labels + +log = logging.getLogger(__name__) + + +def _ident(): + """ + Resource identity. Salt's authoring docs promise ``__resource__`` + inside state overrides, but no released Salt packs it there — fall + back to the ``repo.ident`` execution override, which does see it. + """ + try: + rid = __resource__["id"] + except NameError: + return __salt__["repo.ident"]() + owner, _, repo_name = rid.partition("/") + return {"id": rid, "owner": owner, "repo": repo_name} + + +def _core_args(profile): + ident = _ident() + conn = gh_profile.resource_conn(__opts__, __context__, profile=profile, owner=ident["owner"]) + return conn, ident["owner"], ident["repo"] + + +def _failed(name, err): + return {"name": name, "changes": {}, "result": False, "comment": str(err)} + + +def present(name, color=None, description=None, profile=None): + """ + Ensure a label is present in the targeted repository and configured + as specified. Unspecified settings are left unmanaged. + + name + Name of the label. + + color + Hexadecimal color code for the label. A leading ``#`` + is accepted. + + description + Short description of the label. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + try: + conn, owner, repo_name = _core_args(profile) + return sc_labels.present( + conn, + name, + repo_name, + color=color, + description=description, + owner=owner, + test=__opts__["test"], + ) + except CommandExecutionError as err: + return _failed(name, err) + + +def absent(name, profile=None): + """ + Ensure a label is absent from the targeted repository. + + name + Name of the label. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + try: + conn, owner, repo_name = _core_args(profile) + return sc_labels.absent(conn, name, repo_name, owner=owner, test=__opts__["test"]) + except CommandExecutionError as err: + return _failed(name, err) diff --git a/src/saltext/github/resources/github/states/pages.py b/src/saltext/github/resources/github/states/pages.py new file mode 100644 index 00000000..a8eae493 --- /dev/null +++ b/src/saltext/github/resources/github/states/pages.py @@ -0,0 +1,116 @@ +""" +Manage the targeted repository's GitHub Pages site. + +.. versionadded:: 1.1.0 + +Part of the :py:mod:`github ` resource type: states operate on the repository +the state run was dispatched to, so there is no ``repo_name``/``owner`` +parameter. The ``profile`` parameter defaults to the :ghconf:`profile ` named in +the resource declaration. +""" + +import logging + +from salt.exceptions import CommandExecutionError + +from saltext.github.utils.gh import profile as gh_profile +from saltext.github.utils.gh.statecores import pages as sc_pages + +log = logging.getLogger(__name__) + + +def _ident(): + """ + Resource identity. Salt's authoring docs promise ``__resource__`` + inside state overrides, but no released Salt packs it there — fall + back to the ``repo.ident`` execution override, which does see it. + """ + try: + rid = __resource__["id"] + except NameError: + return __salt__["repo.ident"]() + owner, _, repo_name = rid.partition("/") + return {"id": rid, "owner": owner, "repo": repo_name} + + +def _core_args(profile): + ident = _ident() + conn = gh_profile.resource_conn(__opts__, __context__, profile=profile, owner=ident["owner"]) + return conn, ident["owner"], ident["repo"] + + +def _failed(name, err): + return {"name": name, "changes": {}, "result": False, "comment": str(err)} + + +def present( + name, + build_type=None, + source_branch=None, + source_path=None, + cname=None, + https_enforced=None, + profile=None, +): + """ + Ensure GitHub Pages are enabled for the targeted repository and + configured as specified. Unspecified settings are left unmanaged. + + name + State name (the repository is implied by the resource). + + build_type + Process by which the site is built, either ``legacy`` + (deploy from a branch) or ``workflow`` (GitHub Actions). + + source_branch + For ``legacy`` builds, the repository branch to publish from. + + source_path + For ``legacy`` builds, the repository directory to publish from, + either ``/`` or ``/docs``. + + cname + Custom domain for the site. + + https_enforced + Enforce HTTPS for the site. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + try: + conn, owner, repo_name = _core_args(profile) + return sc_pages.present( + conn, + name, + repo_name, + build_type=build_type, + source_branch=source_branch, + source_path=source_path, + cname=cname, + https_enforced=https_enforced, + owner=owner, + test=__opts__["test"], + ) + except CommandExecutionError as err: + return _failed(name, err) + + +def absent(name, profile=None): + """ + Ensure GitHub Pages are disabled for the targeted repository. + + name + State name (the repository is implied by the resource). + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + try: + conn, owner, repo_name = _core_args(profile) + return sc_pages.absent(conn, name, repo_name, owner=owner, test=__opts__["test"]) + except CommandExecutionError as err: + return _failed(name, err) diff --git a/src/saltext/github/resources/github/states/repo.py b/src/saltext/github/resources/github/states/repo.py new file mode 100644 index 00000000..50689745 --- /dev/null +++ b/src/saltext/github/resources/github/states/repo.py @@ -0,0 +1,129 @@ +""" +Manage the targeted repository's settings and team associations. + +.. versionadded:: 1.1.0 + +Part of the :py:mod:`github ` resource type: states operate on the repository +the state run was dispatched to, so there is no ``repo_name``/``owner`` +parameter. The ``profile`` parameter defaults to the :ghconf:`profile ` named in +the resource declaration. +""" + +import logging + +from salt.exceptions import CommandExecutionError + +from saltext.github.utils.gh import profile as gh_profile +from saltext.github.utils.gh.statecores import repo as sc_repo + +log = logging.getLogger(__name__) + + +def _ident(): + """ + Resource identity. Salt's authoring docs promise ``__resource__`` + inside state overrides, but no released Salt packs it there — fall + back to the ``repo.ident`` execution override, which does see it. + """ + try: + rid = __resource__["id"] + except NameError: + return __salt__["repo.ident"]() + owner, _, repo_name = rid.partition("/") + return {"id": rid, "owner": owner, "repo": repo_name} + + +def _core_args(profile): + ident = _ident() + conn = gh_profile.resource_conn(__opts__, __context__, profile=profile, owner=ident["owner"]) + return conn, ident["owner"], ident["repo"] + + +def _failed(name, err): + return {"name": name, "changes": {}, "result": False, "comment": str(err)} + + +def managed( + name, + description=None, + homepage=None, + private=None, + has_issues=None, + has_wiki=None, + has_downloads=None, + default_branch=None, + teams=None, + profile=None, +): + """ + Ensure the targeted repository is configured as specified. + Unspecified settings are left unmanaged. + + Unlike the classic ``github_repo.present`` state, this never + creates the repository — the resource's existence is a given. + + name + State name (the repository is implied by the resource). + + description + Repository description. + + homepage + URL with more information about the repository, e.g. rendered docs. + + private + Repository visibility. Changing this requires :ghconf:`allow_repo_privacy_changes` + to be enabled in the profile. + + has_issues + Enable issues for this repository. + + has_wiki + Enable the wiki for this repository. + + has_downloads + Enable downloads for this repository. + + default_branch + Default branch of the repository. The branch must already exist. + + teams + Teams this repo should belong to, specified as a dict of team + name to permission (``pull``, ``push`` or ``admin``). + Unlisted teams are removed from the repository. The teams + belong to the repository's owning organization, so this is + only supported for organization-owned repositories. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + try: + conn, owner, repo_name = _core_args(profile) + allow_privacy_change = gh_profile.get_config_value( + profile or gh_profile.resource_owner_profile(__opts__, owner), + "allow_repo_privacy_changes", + opts=__opts__, + context=__context__, + default=False, + ) + ret = sc_repo.present( + conn, + repo_name, + owner=owner, + description=description, + homepage=homepage, + private=private, + has_issues=has_issues, + has_wiki=has_wiki, + has_downloads=has_downloads, + default_branch=default_branch, + teams=teams, + allow_privacy_change=allow_privacy_change, + allow_create=False, + test=__opts__["test"], + ) + ret["name"] = name + return ret + except CommandExecutionError as err: + return _failed(name, err) diff --git a/src/saltext/github/resources/github/states/ruleset.py b/src/saltext/github/resources/github/states/ruleset.py new file mode 100644 index 00000000..c0e2210e --- /dev/null +++ b/src/saltext/github/resources/github/states/ruleset.py @@ -0,0 +1,105 @@ +""" +Manage the targeted repository's rulesets. + +.. versionadded:: 1.1.0 + +Part of the :py:mod:`github ` resource type: states operate on the repository +the state run was dispatched to, so there is no ``repo_name``/``owner`` +parameter. The ``profile`` parameter defaults to the :ghconf:`profile ` named in +the resource declaration. +""" + +import logging + +from salt.exceptions import CommandExecutionError + +from saltext.github.utils.gh import profile as gh_profile +from saltext.github.utils.gh.statecores import rulesets as sc_rulesets + +log = logging.getLogger(__name__) + + +def _ident(): + """ + Resource identity. Salt's authoring docs promise ``__resource__`` + inside state overrides, but no released Salt packs it there — fall + back to the ``repo.ident`` execution override, which does see it. + """ + try: + rid = __resource__["id"] + except NameError: + return __salt__["repo.ident"]() + owner, _, repo_name = rid.partition("/") + return {"id": rid, "owner": owner, "repo": repo_name} + + +def _core_args(profile): + ident = _ident() + conn = gh_profile.resource_conn(__opts__, __context__, profile=profile, owner=ident["owner"]) + return conn, ident["owner"], ident["repo"] + + +def _failed(name, err): + return {"name": name, "changes": {}, "result": False, "comment": str(err)} + + +def present(name, ruleset_params=None, profile=None): + """ + Ensure a ruleset is present in the targeted repository and + configured as specified. Only the parameters included in + ``ruleset_params`` are compared, server-populated fields are + ignored. + + Example: + + .. code-block:: yaml + + Ensure ruleset test is present: + ruleset.present: + - name: test + - ruleset_params: + target: branch + enforcement: active + + name + Ruleset name to manage. + + ruleset_params + Ruleset definition. Must contain at least ``enforcement`` + when the ruleset needs to be created. ``name`` is pulled from + this state's ``name``. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + try: + conn, owner, repo_name = _core_args(profile) + return sc_rulesets.present( + conn, + name, + repo_name, + ruleset_params=ruleset_params, + owner=owner, + test=__opts__["test"], + ) + except CommandExecutionError as err: + return _failed(name, err) + + +def absent(name, profile=None): + """ + Ensure a ruleset is absent from the targeted repository. + + name + Ruleset name to manage. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + try: + conn, owner, repo_name = _core_args(profile) + return sc_rulesets.absent(conn, name, repo_name, owner=owner, test=__opts__["test"]) + except CommandExecutionError as err: + return _failed(name, err) diff --git a/src/saltext/github/states/github_repo.py b/src/saltext/github/states/github_repo.py index f00c7659..731257a3 100644 --- a/src/saltext/github/states/github_repo.py +++ b/src/saltext/github/states/github_repo.py @@ -25,79 +25,48 @@ from salt.exceptions import CommandExecutionError -from saltext.github.utils import filter_unset -from saltext.github.utils.gh import environments as gh_environments -from saltext.github.utils.gh.exceptions import GitHubNotFoundError +from saltext.github.utils.gh import profile as gh_profile +from saltext.github.utils.gh.statecores import actions as sc_actions +from saltext.github.utils.gh.statecores import deploy_keys as sc_deploy_keys +from saltext.github.utils.gh.statecores import environments as sc_environments +from saltext.github.utils.gh.statecores import labels as sc_labels +from saltext.github.utils.gh.statecores import pages as sc_pages +from saltext.github.utils.gh.statecores import repo as sc_repo +from saltext.github.utils.gh.statecores import rulesets as sc_rulesets log = logging.getLogger(__name__) __virtualname__ = "github_repo" -def _differs(current, desired): - """ - Report whether a managed parameter needs updating. The identity check - makes boolean comparisons exact: since ``True == 1`` in Python, an - equality check alone would not register e.g. ``1`` -> ``True``. - """ - return current is not desired and current != desired +def _get_conn(profile): + return gh_profile.get_conn(profile, opts=__opts__, context=__context__) -def _repo_settings_diff(ret, target, managed): +def _resolve_repo_name(repo_name, profile): """ - Record the settings of an existing repository that need updating - in ``ret`` and return them. + Default ``repo_name`` to the profile configuration when unset. """ - update_params = { - param: value - for param, value in managed.items() - if value is not None and _differs(target[param], value) - } - if update_params: - ret["changes"]["old"] = {param: target[param] for param in update_params} - ret["changes"]["new"] = update_params - return update_params - - -def _repo_teams_diff(ret, name, teams, owner, profile): + if repo_name is None: + return gh_profile.get_config_value(profile, "repo_name", opts=__opts__, context=__context__) + return repo_name + + +def _allow_privacy_change(profile): """ - Record the team association changes of an existing repository in - ``ret`` and return them. + Resolve the profile's ``allow_repo_privacy_changes``, defaulting + to false. """ - current_teams = set( - __salt__["github_repo.get_teams"](repo_name=name, owner=owner, profile=profile) + return gh_profile.get_config_value( + profile, "allow_repo_privacy_changes", opts=__opts__, context=__context__, default=False ) - teams_added = sorted(set(teams) - current_teams) - teams_removed = sorted(current_teams - set(teams)) - team_permissions = {} - for team_name in sorted(set(teams) & current_teams): - team_repos = { - repo_name.lower(): repo - for repo_name, repo in __salt__["github_org.list_team_repos"]( - team_name, owner=owner, profile=profile - ).items() - } - current_permission = team_repos.get(name.lower(), {}).get("permission") - if current_permission != teams[team_name]: - team_permissions[team_name] = {"old": current_permission, "new": teams[team_name]} - if teams_added: - ret["changes"]["teams_added"] = teams_added - if teams_removed: - ret["changes"]["teams_removed"] = teams_removed - if team_permissions: - ret["changes"]["team_permissions"] = team_permissions - return teams_added, teams_removed, team_permissions - - -def _create_repo(name, create_params, teams, owner, profile): + + +def _failed(name, err): """ - Create a repository and associate the requested teams. + Render a raised error into a failed state return. """ - __salt__["github_repo.create"](repo_name=name, owner=owner, profile=profile, **create_params) - for team_name, permission in (teams or {}).items(): - __salt__["github_org.add_team_repo"]( - name, team_name, permission=permission, owner=owner, profile=profile - ) + return {"name": name, "changes": {}, "result": False, "comment": str(err)} def present( @@ -173,99 +142,27 @@ def present( profile Profile configuration name to use. Defaults to ``github``. """ - ret = { - "name": name, - "changes": {}, - "result": True, - "comment": f"Repo {name} is present as specified", - } - try: - # This is an optimization to cache all of the owner's repos up front. - # The first use of this state will collect all of the repos and save a bunch - # of API calls for future use. - __salt__["github_repo.list"](owner=owner, profile=profile) - - try: - target = __salt__["github_repo.get_info"](repo_name=name, owner=owner, profile=profile) - except GitHubNotFoundError: - target = None - - update_params = {} - teams_added, teams_removed, team_permissions = [], [], {} - if target is None: - ret["changes"]["created"] = name - if teams: - ret["changes"]["teams_added"] = sorted(teams) - else: - update_params = _repo_settings_diff( - ret, - target, - { - "description": description, - "homepage": homepage, - "private": private, - "has_issues": has_issues, - "has_wiki": has_wiki, - "has_downloads": has_downloads, - "default_branch": default_branch, - }, - ) - if teams is not None: - teams_added, teams_removed, team_permissions = _repo_teams_diff( - ret, name, teams, owner, profile - ) - - if not ret["changes"]: - return ret - - verb = "created" if target is None else "updated" - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Repo {name} would have been {verb}" - return ret - - if target is None: - _create_repo( - name, - { - "description": description, - "homepage": homepage, - "private": private, - "has_issues": has_issues, - "has_wiki": has_wiki, - "has_downloads": has_downloads, - "auto_init": auto_init, - "gitignore_template": gitignore_template, - "license_template": license_template, - }, - teams, - owner, - profile, - ) - else: - if update_params: - __salt__["github_repo.update"]( - repo_name=name, owner=owner, profile=profile, **update_params - ) - for team_name in teams_added: - __salt__["github_org.add_team_repo"]( - name, team_name, permission=teams[team_name], owner=owner, profile=profile - ) - for team_name in teams_removed: - __salt__["github_org.remove_team_repo"]( - name, team_name, owner=owner, profile=profile - ) - for team_name in team_permissions: - __salt__["github_org.add_team_repo"]( - name, team_name, permission=teams[team_name], owner=owner, profile=profile - ) - ret["comment"] = f"Repo {name} has been {verb}" + return sc_repo.present( + _get_conn(profile), + name, + owner=owner, + description=description, + homepage=homepage, + private=private, + has_issues=has_issues, + has_wiki=has_wiki, + has_downloads=has_downloads, + auto_init=auto_init, + gitignore_template=gitignore_template, + license_template=license_template, + default_branch=default_branch, + teams=teams, + allow_privacy_change=_allow_privacy_change(profile), + test=__opts__["test"], + ) except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = str(err) - ret["changes"] = {} - return ret + return _failed(name, err) def absent(name, owner=None, profile="github"): @@ -281,28 +178,10 @@ def absent(name, owner=None, profile="github"): profile Profile configuration name to use. Defaults to ``github``. """ - ret = {"name": name, "changes": {}, "result": True, "comment": ""} - try: - try: - __salt__["github_repo.get_info"](repo_name=name, owner=owner, profile=profile) - except GitHubNotFoundError: - ret["comment"] = f"Repo {name} does not exist" - return ret - - ret["changes"]["deleted"] = name - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Repo {name} would have been deleted" - return ret - - __salt__["github_repo.delete"](repo_name=name, owner=owner, profile=profile) - ret["comment"] = f"Repo {name} has been deleted" + return sc_repo.absent(_get_conn(profile), name, owner=owner, test=__opts__["test"]) except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = str(err) - ret["changes"] = {} - return ret + return _failed(name, err) def ruleset_present(name, ruleset_params=None, repo_name=None, owner=None, profile="github"): @@ -340,57 +219,17 @@ def ruleset_present(name, ruleset_params=None, repo_name=None, owner=None, profi profile Profile configuration name to use. Defaults to ``github``. """ - ret = {"name": name, "changes": {}, "result": True, "comment": ""} - - ruleset_params = dict(ruleset_params or {}) - ruleset_params["name"] = name - try: - rulesets = __salt__["github_repo.list_rulesets"]( - repo_name=repo_name, owner=owner, profile=profile - ) - ruleset = next((rls for rls in rulesets or [] if rls["name"] == name), None) - - if ruleset is None: - ret["changes"]["created"] = name - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Ruleset {name} would have been created" - return ret - __salt__["github_repo.add_ruleset"]( - ruleset_params, repo_name=repo_name, owner=owner, profile=profile - ) - ret["comment"] = f"Ruleset {name} has been created" - return ret - - ruleset_info = __salt__["github_repo.get_ruleset"]( - ruleset["id"], repo_name=repo_name, owner=owner, profile=profile + return sc_rulesets.present( + _get_conn(profile), + name, + _resolve_repo_name(repo_name, profile), + ruleset_params=ruleset_params, + owner=owner, + test=__opts__["test"], ) - - # Only diff the parameters that are being managed. The server - # response carries additional server-populated fields (id, - # timestamps, source, ...) that must not register as drift. - diff = {key: val for key, val in ruleset_params.items() if ruleset_info.get(key) != val} - if not diff: - ret["comment"] = f"Ruleset {name} is already configured as specified" - return ret - - ret["changes"]["old"] = {key: ruleset_info.get(key) for key in diff} - ret["changes"]["new"] = diff - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Ruleset {name} would have been updated" - return ret - - __salt__["github_repo.update_ruleset"]( - ruleset["id"], ruleset_params, repo_name=repo_name, owner=owner, profile=profile - ) - ret["comment"] = f"Ruleset {name} has been updated" except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = str(err) - ret["changes"] = {} - return ret + return _failed(name, err) def ruleset_absent(name, repo_name=None, owner=None, profile="github"): @@ -409,40 +248,16 @@ def ruleset_absent(name, repo_name=None, owner=None, profile="github"): profile Profile configuration name to use. Defaults to ``github``. """ - ret = {"name": name, "changes": {}, "result": True, "comment": ""} - try: - rulesets = __salt__["github_repo.list_rulesets"]( - repo_name=repo_name, owner=owner, profile=profile + return sc_rulesets.absent( + _get_conn(profile), + name, + _resolve_repo_name(repo_name, profile), + owner=owner, + test=__opts__["test"], ) - ruleset = next((rls for rls in rulesets or [] if rls["name"] == name), None) - if ruleset is None: - ret["comment"] = f"Ruleset {name} does not exist" - return ret - - ret["changes"]["deleted"] = name - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Ruleset {name} would have been deleted" - return ret - - __salt__["github_repo.delete_ruleset"]( - ruleset["id"], repo_name=repo_name, owner=owner, profile=profile - ) - ret["comment"] = f"Ruleset {name} has been deleted" except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = str(err) - ret["changes"] = {} - return ret - - -def _key_body(key): - """ - Reduce an SSH public key to its comparable part (type + base64 body), - ignoring whitespace variations and the comment. - """ - return " ".join(key.split()[:2]) + return _failed(name, err) def deploy_key_present(name, key, read_only=False, repo_name=None, owner=None, profile="github"): @@ -482,44 +297,18 @@ def deploy_key_present(name, key, read_only=False, repo_name=None, owner=None, p profile Profile configuration name to use. Defaults to ``github``. """ - ret = {"name": name, "changes": {}, "result": True, "comment": ""} - try: - keys = __salt__["github_repo.list_deploy_keys"]( - repo_name=repo_name, owner=owner, profile=profile - ) - current = next((cur for cur in keys if cur["title"] == name), None) - - if current is not None: - if _key_body(current["key"]) == _key_body(key) and bool(current["read_only"]) is bool( - read_only - ): - ret["comment"] = f"Deploy key {name} is present as specified" - return ret - ret["changes"]["old"] = {"key": current["key"], "read_only": current["read_only"]} - ret["changes"]["new"] = {"key": _key_body(key), "read_only": bool(read_only)} - else: - ret["changes"]["created"] = name - - verb = "added" if current is None else "replaced" - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Deploy key {name} would have been {verb}" - return ret - - if current is not None: - __salt__["github_repo.remove_deploy_key"]( - current["id"], repo_name=repo_name, owner=owner, profile=profile - ) - __salt__["github_repo.add_deploy_key"]( - name, key, read_only=read_only, repo_name=repo_name, owner=owner, profile=profile + return sc_deploy_keys.present( + _get_conn(profile), + name, + key, + _resolve_repo_name(repo_name, profile), + read_only=read_only, + owner=owner, + test=__opts__["test"], ) - ret["comment"] = f"Deploy key {name} has been {verb}" except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = str(err) - ret["changes"] = {} - return ret + return _failed(name, err) def deploy_key_absent(name, repo_name=None, owner=None, profile="github"): @@ -538,32 +327,16 @@ def deploy_key_absent(name, repo_name=None, owner=None, profile="github"): profile Profile configuration name to use. Defaults to ``github``. """ - ret = {"name": name, "changes": {}, "result": True, "comment": ""} - try: - keys = __salt__["github_repo.list_deploy_keys"]( - repo_name=repo_name, owner=owner, profile=profile + return sc_deploy_keys.absent( + _get_conn(profile), + name, + _resolve_repo_name(repo_name, profile), + owner=owner, + test=__opts__["test"], ) - current = next((cur for cur in keys if cur["title"] == name), None) - if current is None: - ret["comment"] = f"Deploy key {name} is already absent" - return ret - - ret["changes"]["deleted"] = name - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Deploy key {name} would have been removed" - return ret - - __salt__["github_repo.remove_deploy_key"]( - current["id"], repo_name=repo_name, owner=owner, profile=profile - ) - ret["comment"] = f"Deploy key {name} has been removed" except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = str(err) - ret["changes"] = {} - return ret + return _failed(name, err) def actions_secret_present( @@ -599,45 +372,18 @@ def actions_secret_present( profile Profile configuration name to use. Defaults to ``github``. """ - ret = {"name": name, "changes": {}, "result": True, "comment": ""} - try: - secrets = __salt__["github_repo.list_actions_secrets"]( - repo_name=repo_name, owner=owner, profile=profile - ) - current = next((sec for sec in secrets if sec.lower() == name.lower()), None) - - if current is not None and not force: - ret["comment"] = ( - f"Secret {name} is already present. Note that secret values are " - "write-only and cannot be verified, set force to push the value." - ) - return ret - - if value is None: - raise CommandExecutionError(f"A value is required to set secret {name}") - - if current is None: - ret["changes"]["created"] = name - verb = "created" - else: - ret["changes"]["updated"] = name - verb = "updated" - - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Secret {name} would have been {verb}" - return ret - - __salt__["github_repo.set_actions_secret"]( - name, value, repo_name=repo_name, owner=owner, profile=profile + return sc_actions.secret_present( + _get_conn(profile), + name, + _resolve_repo_name(repo_name, profile), + value=value, + force=force, + owner=owner, + test=__opts__["test"], ) - ret["comment"] = f"Secret {name} has been {verb}" except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = str(err) - ret["changes"] = {} - return ret + return _failed(name, err) def actions_secret_absent(name, repo_name=None, owner=None, profile="github"): @@ -657,32 +403,16 @@ def actions_secret_absent(name, repo_name=None, owner=None, profile="github"): profile Profile configuration name to use. Defaults to ``github``. """ - ret = {"name": name, "changes": {}, "result": True, "comment": ""} - try: - secrets = __salt__["github_repo.list_actions_secrets"]( - repo_name=repo_name, owner=owner, profile=profile - ) - current = next((sec for sec in secrets if sec.lower() == name.lower()), None) - if current is None: - ret["comment"] = f"Secret {name} is already absent" - return ret - - ret["changes"]["deleted"] = current - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Secret {name} would have been removed" - return ret - - __salt__["github_repo.remove_actions_secret"]( - current, repo_name=repo_name, owner=owner, profile=profile + return sc_actions.secret_absent( + _get_conn(profile), + name, + _resolve_repo_name(repo_name, profile), + owner=owner, + test=__opts__["test"], ) - ret["comment"] = f"Secret {name} has been removed" except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = str(err) - ret["changes"] = {} - return ret + return _failed(name, err) def workflow_permissions_managed( @@ -716,82 +446,18 @@ def workflow_permissions_managed( profile Profile configuration name to use. Defaults to ``github``. """ - ret = {"name": name, "changes": {}, "result": True, "comment": ""} - repo_name = repo_name or name - try: - desired = filter_unset( - { - "default_workflow_permissions": default_workflow_permissions, - "can_approve_pull_request_reviews": can_approve_pull_request_reviews, - } + return sc_actions.workflow_permissions_managed( + _get_conn(profile), + name, + repo_name or name, + default_workflow_permissions=default_workflow_permissions, + can_approve_pull_request_reviews=can_approve_pull_request_reviews, + owner=owner, + test=__opts__["test"], ) - if not desired: - raise CommandExecutionError("At least one setting to manage must be specified") - - current = __salt__["github_repo.get_workflow_permissions"]( - repo_name=repo_name, owner=owner, profile=profile - ) - diff = {key: val for key, val in desired.items() if current.get(key) != val} - if not diff: - ret["comment"] = "Workflow permissions are already set as specified" - return ret - - ret["changes"]["old"] = {key: current.get(key) for key in diff} - ret["changes"]["new"] = diff - if __opts__["test"]: - ret["result"] = None - ret["comment"] = "Workflow permissions would have been updated" - return ret - - __salt__["github_repo.set_workflow_permissions"]( - repo_name=repo_name, owner=owner, profile=profile, **diff - ) - ret["comment"] = "Workflow permissions have been updated" except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = str(err) - ret["changes"] = {} - return ret - - -def _desired_branch_policy(deployment_branches): - """ - Map the state's ``deployment_branches`` parameter onto the API's - ``deployment_branch_policy`` setting. - """ - if deployment_branches == "all": - return None - if deployment_branches == "protected": - return {"protected_branches": True, "custom_branch_policies": False} - if isinstance(deployment_branches, list): - return {"protected_branches": False, "custom_branch_policies": True} - raise CommandExecutionError( - "deployment_branches must be 'all', 'protected' or a list of branch " - f"name patterns, got: {deployment_branches!r}" - ) - - -def _environment_settings_diff(ret, current_settings, desired): - """ - Record an existing environment's settings that need updating in - ``ret`` and return whether there are any. - """ - diff = {} - for key, val in desired.items(): - current_val = current_settings.get(key) - if key == "reviewers": - matches = sorted((reviewer["type"], reviewer["id"]) for reviewer in val) == sorted( - (reviewer["type"], reviewer["id"]) for reviewer in current_val - ) - else: - matches = current_val == val - if not matches: - diff[key] = val - if diff: - ret["changes"]["old"] = {key: current_settings.get(key) for key in diff} - ret["changes"]["new"] = diff - return bool(diff) + return _failed(name, err) def environment_present( @@ -837,90 +503,20 @@ def environment_present( profile Profile configuration name to use. Defaults to ``github``. """ - ret = { - "name": name, - "changes": {}, - "result": True, - "comment": f"Environment {name} is configured as specified", - } - try: - try: - current = __salt__["github_repo.get_environment"]( - name, repo_name=repo_name, owner=owner, profile=profile - ) - except GitHubNotFoundError: - current = None - - desired = filter_unset( - { - "wait_timer": wait_timer, - "prevent_self_review": prevent_self_review, - "reviewers": reviewers, - } + return sc_environments.present( + _get_conn(profile), + name, + _resolve_repo_name(repo_name, profile), + wait_timer=wait_timer, + prevent_self_review=prevent_self_review, + reviewers=reviewers, + deployment_branches=deployment_branches, + owner=owner, + test=__opts__["test"], ) - if deployment_branches is not None: - desired["deployment_branch_policy"] = _desired_branch_policy(deployment_branches) - - settings_diff = False - branches_added, branches_removed, current_policies = [], [], [] - if current is None: - ret["changes"]["created"] = name - set_kwargs = desired - if isinstance(deployment_branches, list): - branches_added = sorted(set(deployment_branches)) - else: - current_settings = gh_environments.normalize(current) - settings_diff = _environment_settings_diff(ret, current_settings, desired) - # The API call replaces the whole configuration, so settings - # that are not managed here need to be carried over. - set_kwargs = {**current_settings, **desired} - if isinstance(deployment_branches, list): - try: - current_policies = __salt__["github_repo.list_deployment_branch_policies"]( - name, repo_name=repo_name, owner=owner, profile=profile - ) - except GitHubNotFoundError: - # Not found when the current policy type is not "custom" - current_policies = [] - current_patterns = {policy["name"] for policy in current_policies} - branches_added = sorted(set(deployment_branches) - current_patterns) - branches_removed = sorted(current_patterns - set(deployment_branches)) - - if branches_added or branches_removed: - ret["changes"]["deployment_branches"] = { - "added": branches_added, - "removed": branches_removed, - } - - if not ret["changes"]: - return ret - - verb = "created" if current is None else "updated" - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Environment {name} would have been {verb}" - return ret - - if current is None or settings_diff: - __salt__["github_repo.set_environment"]( - name, repo_name=repo_name, owner=owner, profile=profile, **set_kwargs - ) - for pattern in branches_added: - __salt__["github_repo.add_deployment_branch_policy"]( - name, pattern, repo_name=repo_name, owner=owner, profile=profile - ) - for policy in current_policies: - if policy["name"] in branches_removed: - __salt__["github_repo.remove_deployment_branch_policy"]( - name, policy["id"], repo_name=repo_name, owner=owner, profile=profile - ) - ret["comment"] = f"Environment {name} has been {verb}" except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = str(err) - ret["changes"] = {} - return ret + return _failed(name, err) def environment_absent(name, repo_name=None, owner=None, profile="github"): @@ -939,41 +535,16 @@ def environment_absent(name, repo_name=None, owner=None, profile="github"): profile Profile configuration name to use. Defaults to ``github``. """ - ret = {"name": name, "changes": {}, "result": True, "comment": ""} - try: - try: - __salt__["github_repo.get_environment"]( - name, repo_name=repo_name, owner=owner, profile=profile - ) - except GitHubNotFoundError: - ret["comment"] = f"Environment {name} is already absent" - return ret - - ret["changes"]["deleted"] = name - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Environment {name} would have been removed" - return ret - - __salt__["github_repo.remove_environment"]( - name, repo_name=repo_name, owner=owner, profile=profile + return sc_environments.absent( + _get_conn(profile), + name, + _resolve_repo_name(repo_name, profile), + owner=owner, + test=__opts__["test"], ) - ret["comment"] = f"Environment {name} has been removed" except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = str(err) - ret["changes"] = {} - return ret - - -def _normalize_color(color): - """ - Normalize a label color for comparisons: lowercase, no leading ``#``. - """ - if color is None: - return None - return color.lstrip("#").lower() + return _failed(name, err) def label_present(name, color=None, description=None, repo_name=None, owner=None, profile="github"): @@ -1000,59 +571,18 @@ def label_present(name, color=None, description=None, repo_name=None, owner=None profile Profile configuration name to use. Defaults to ``github``. """ - ret = {"name": name, "changes": {}, "result": True, "comment": ""} - try: - try: - current = __salt__["github_repo.get_label"]( - name, repo_name=repo_name, owner=owner, profile=profile - ) - except GitHubNotFoundError: - current = None - - if current is None: - ret["changes"]["created"] = name - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Label {name} would have been created" - return ret - __salt__["github_repo.add_label"]( - name, - color=color, - description=description, - repo_name=repo_name, - owner=owner, - profile=profile, - ) - ret["comment"] = f"Label {name} has been created" - return ret - - diff = {} - if color is not None and _normalize_color(current["color"]) != _normalize_color(color): - diff["color"] = color - if description is not None and current.get("description") != description: - diff["description"] = description - - if not diff: - ret["comment"] = f"Label {name} is already configured as specified" - return ret - - ret["changes"]["old"] = {key: current.get(key) for key in diff} - ret["changes"]["new"] = diff - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Label {name} would have been updated" - return ret - - __salt__["github_repo.edit_label"]( - name, repo_name=repo_name, owner=owner, profile=profile, **diff + return sc_labels.present( + _get_conn(profile), + name, + _resolve_repo_name(repo_name, profile), + color=color, + description=description, + owner=owner, + test=__opts__["test"], ) - ret["comment"] = f"Label {name} has been updated" except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = str(err) - ret["changes"] = {} - return ret + return _failed(name, err) def label_absent(name, repo_name=None, owner=None, profile="github"): @@ -1071,32 +601,16 @@ def label_absent(name, repo_name=None, owner=None, profile="github"): profile Profile configuration name to use. Defaults to ``github``. """ - ret = {"name": name, "changes": {}, "result": True, "comment": ""} - try: - try: - __salt__["github_repo.get_label"]( - name, repo_name=repo_name, owner=owner, profile=profile - ) - except GitHubNotFoundError: - ret["comment"] = f"Label {name} is already absent" - return ret - - ret["changes"]["deleted"] = name - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Label {name} would have been removed" - return ret - - __salt__["github_repo.remove_label"]( - name, repo_name=repo_name, owner=owner, profile=profile + return sc_labels.absent( + _get_conn(profile), + name, + _resolve_repo_name(repo_name, profile), + owner=owner, + test=__opts__["test"], ) - ret["comment"] = f"Label {name} has been removed" except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = str(err) - ret["changes"] = {} - return ret + return _failed(name, err) def pages_present( @@ -1143,86 +657,21 @@ def pages_present( profile Profile configuration name to use. Defaults to ``github``. """ - ret = {"name": name, "changes": {}, "result": True, "comment": ""} - repo_name = repo_name or name - try: - try: - current = __salt__["github_repo.get_pages"]( - repo_name=repo_name, owner=owner, profile=profile - ) - except GitHubNotFoundError: - current = None - - if current is None: - ret["changes"]["created"] = repo_name - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Pages would have been enabled for {repo_name}" - return ret - __salt__["github_repo.setup_pages"]( - build_type=build_type, - source_branch=source_branch, - source_path=source_path, - repo_name=repo_name, - owner=owner, - profile=profile, - ) - if cname is not None or https_enforced is not None: - extra = {"https_enforced": https_enforced} - if cname is not None: - extra["cname"] = cname - __salt__["github_repo.update_pages"]( - repo_name=repo_name, owner=owner, profile=profile, **extra - ) - ret["comment"] = f"Pages have been enabled for {repo_name}" - return ret - - current_source = current.get("source") or {} - desired = filter_unset( - { - "build_type": build_type, - "source_branch": source_branch, - "source_path": source_path, - "cname": cname, - "https_enforced": https_enforced, - } + return sc_pages.present( + _get_conn(profile), + name, + repo_name or name, + build_type=build_type, + source_branch=source_branch, + source_path=source_path, + cname=cname, + https_enforced=https_enforced, + owner=owner, + test=__opts__["test"], ) - current_flat = { - "build_type": current.get("build_type"), - "source_branch": current_source.get("branch"), - "source_path": current_source.get("path"), - "cname": current.get("cname"), - "https_enforced": current.get("https_enforced"), - } - diff = {key: val for key, val in desired.items() if current_flat[key] != val} - - if not diff: - ret["comment"] = f"Pages for {repo_name} are already configured as specified" - return ret - - ret["changes"]["old"] = {key: current_flat[key] for key in diff} - ret["changes"]["new"] = diff - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Pages configuration for {repo_name} would have been updated" - return ret - - update_kwargs = dict(diff) - if "source_branch" in diff or "source_path" in diff: - # The source is submitted as a single object, carry over the - # unchanged half - update_kwargs.setdefault("source_branch", current_flat["source_branch"]) - update_kwargs.setdefault("source_path", current_flat["source_path"]) - __salt__["github_repo.update_pages"]( - repo_name=repo_name, owner=owner, profile=profile, **update_kwargs - ) - ret["comment"] = f"Pages configuration for {repo_name} has been updated" except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = str(err) - ret["changes"] = {} - return ret + return _failed(name, err) def pages_absent(name, repo_name=None, owner=None, profile="github"): @@ -1241,26 +690,13 @@ def pages_absent(name, repo_name=None, owner=None, profile="github"): profile Profile configuration name to use. Defaults to ``github``. """ - ret = {"name": name, "changes": {}, "result": True, "comment": ""} - repo_name = repo_name or name - try: - try: - __salt__["github_repo.get_pages"](repo_name=repo_name, owner=owner, profile=profile) - except GitHubNotFoundError: - ret["comment"] = f"Pages are already disabled for {repo_name}" - return ret - - ret["changes"]["deleted"] = repo_name - if __opts__["test"]: - ret["result"] = None - ret["comment"] = f"Pages would have been disabled for {repo_name}" - return ret - - __salt__["github_repo.remove_pages"](repo_name=repo_name, owner=owner, profile=profile) - ret["comment"] = f"Pages have been disabled for {repo_name}" + return sc_pages.absent( + _get_conn(profile), + name, + repo_name or name, + owner=owner, + test=__opts__["test"], + ) except CommandExecutionError as err: - ret["result"] = False - ret["comment"] = str(err) - ret["changes"] = {} - return ret + return _failed(name, err) diff --git a/src/saltext/github/utils/gh/deploy_keys.py b/src/saltext/github/utils/gh/deploy_keys.py index aa5c1818..173f81bd 100644 --- a/src/saltext/github/utils/gh/deploy_keys.py +++ b/src/saltext/github/utils/gh/deploy_keys.py @@ -13,6 +13,17 @@ def _path(conn, owner, repo_name, *suffix): return ["repos", owner or conn.owner, repo_name, "keys", *suffix] +def key_body(key): + """ + Reduce an SSH public key to its comparable part (type + base64 body), + ignoring whitespace variations and the comment. + + key + SSH public key. + """ + return " ".join(key.split()[:2]) + + def list_all(conn, repo_name, *, owner=None): """ Return all deploy keys of a repository. diff --git a/src/saltext/github/utils/gh/environments.py b/src/saltext/github/utils/gh/environments.py index dcbc83af..2718e80f 100644 --- a/src/saltext/github/utils/gh/environments.py +++ b/src/saltext/github/utils/gh/environments.py @@ -5,6 +5,8 @@ import logging from urllib.parse import quote +from salt.exceptions import CommandExecutionError + from saltext.github.utils import filter_unset from saltext.github.utils.gh import client @@ -18,6 +20,29 @@ def _path(conn, owner, repo_name, *suffix): ] +def branch_policy_from_patterns(deployment_branches): + """ + Map a ``deployment_branches`` specification (``all``, ``protected`` + or a list of branch name patterns) onto the API's + ``deployment_branch_policy`` setting. + + deployment_branches + Which branches can deploy. Either ``all``, ``protected`` + (branches with branch protection rules) or a list of branch + name patterns. + """ + if deployment_branches == "all": + return None + if deployment_branches == "protected": + return {"protected_branches": True, "custom_branch_policies": False} + if isinstance(deployment_branches, list): + return {"protected_branches": False, "custom_branch_policies": True} + raise CommandExecutionError( + "deployment_branches must be 'all', 'protected' or a list of branch " + f"name patterns, got: {deployment_branches!r}" + ) + + def normalize(env): """ Extract the managed settings from an environment API payload into a diff --git a/src/saltext/github/utils/gh/profile.py b/src/saltext/github/utils/gh/profile.py index 437c14a3..7424a3ad 100644 --- a/src/saltext/github/utils/gh/profile.py +++ b/src/saltext/github/utils/gh/profile.py @@ -1,7 +1,16 @@ """ Resolution of Salt profile configuration into GitHub connections, shared between the ``github``, ``github_org`` and ``github_repo`` -execution modules. +execution modules as well as the ``github`` resource type. + +Profiles are resolved directly from ``opts``, mirroring ``config.get``'s +lookup order: minion configuration, then grains, then pillar, then +master configuration (which reaches the minion via the master's +``pillar_opts`` setting, rendered under ``pillar["master"]``). SDB URIs +in profile values are rendered on access, like ``config.get`` renders +them. ``config.get`` itself cannot serve as the backend since it is +unavailable to parts of the resource subsystem (type discovery runs +before the minion's module loader exists). """ import hashlib @@ -14,12 +23,27 @@ log = logging.getLogger(__name__) +_UNSET = object() + + +def _render_value(value, opts, utils=None): + """ + Render ``sdb://`` URIs in scalar configuration values, mirroring + what ``config.get`` does for profile values. + """ + if isinstance(value, str) and value.startswith("sdb://"): + # Only required to resolve SDB URIs + import salt.utils.sdb # pylint: disable=import-outside-toplevel + + return salt.utils.sdb.sdb_get(value, opts, utils=utils) + return value + -def get_config_value(profile, config_name, *, __salt__, __context__): +def get_config_value(profile, config_name, *, opts, context=None, utils=None, default=_UNSET): """ Return a profile's configuration value based on the supplied - configuration name. Values are rendered via ``config.get`` - (resolving SDB URIs) and cached in ``context``. + configuration name. See the module docstring for the lookup order + and SDB handling; the first scope carrying the requested value wins. profile Profile name that contains configuration information. @@ -27,56 +51,84 @@ def get_config_value(profile, config_name, *, __salt__, __context__): config_name Configuration item name to look up. - __salt__ - Pass ``__salt__``. + opts + The minion configuration (``__opts__``). + + context + Pass ``__context__`` to cache rendered values, saving repeated + SDB lookups. + + utils + Pass ``__utils__`` when available to avoid building a fresh + utils loader for SDB rendering. - __context__ - Pass ``__context__``. + default + Value to return when the profile does not carry the item. + Without it, a missing item raises a ``CommandExecutionError``. """ - if "gh_profile" not in __context__ or profile not in __context__["gh_profile"]: - if not __salt__["config.get"](profile): + cache = None + if context is not None: + cache = context.setdefault("gh_profile", {}).setdefault(profile, {}) + if cache is None or config_name not in cache: + pillar = opts.get("pillar") or {} + master_pillar = pillar.get("master") + scopes = ( + opts, + opts.get("grains") or {}, + pillar, + master_pillar if isinstance(master_pillar, dict) else {}, + ) + configs = [ + scope[profile] + for scope in scopes + if isinstance(scope.get(profile), dict) and scope[profile] + ] + if not configs: raise CommandExecutionError( f"Authentication information could not be found for the '{profile}' profile." ) - __context__.setdefault("gh_profile", {})[profile] = {} - - cache = __context__["gh_profile"][profile] - if config_name not in cache: - # Unlike config.option, config.get resolves SDB URIs in scalar values, - # hence request each value separately and cache the rendered result. - cache[config_name] = __salt__["config.get"](f"{profile}:{config_name}", default=None) + value = None + for config in configs: + found = config.get(config_name) + if found is not None: + value = _render_value(found, opts, utils=utils) + break + if cache is not None: + cache[config_name] = value + else: + value = cache[config_name] - config_value = cache[config_name] - if config_value is None: + if value is None: + if default is not _UNSET: + return default raise CommandExecutionError( f"The '{config_name}' parameter was not found in the '{profile}' profile." ) - - return config_value + return value -def get_api_url(profile, *, __salt__, __context__): +def get_api_url(profile, *, opts, context=None, utils=None): """ Return the configured GitHub API base URL, defaulting to the public one. profile Profile name that contains configuration information. - __salt__ - Pass ``__salt__``. + opts + The minion configuration (``__opts__``). + + context + Pass ``__context__`` to cache rendered values. - __context__ - Pass ``__context__``. + utils + Pass ``__utils__`` when available (SDB rendering). """ - try: - return get_config_value( - profile, "api_url", __salt__=__salt__, __context__=__context__ - ).rstrip("/") - except CommandExecutionError: - return API_URL_DEFAULT + return get_config_value( + profile, "api_url", opts=opts, context=context, utils=utils, default=API_URL_DEFAULT + ).rstrip("/") -def get_owner(profile, *, __salt__, __context__): +def get_owner(profile, *, opts, context=None, utils=None): """ Return the configured default repository owner: the profile's ``owner``, falling back to the legacy ``org_name`` alias. Returns @@ -86,21 +138,25 @@ def get_owner(profile, *, __salt__, __context__): profile Profile name that contains configuration information. - __salt__ - Pass ``__salt__``. + opts + The minion configuration (``__opts__``). - __context__ - Pass ``__context__``. + context + Pass ``__context__`` to cache rendered values. + + utils + Pass ``__utils__`` when available (SDB rendering). """ for key in ("owner", "org_name"): - try: - return get_config_value(profile, key, __salt__=__salt__, __context__=__context__) - except CommandExecutionError: - continue + owner = get_config_value( + profile, key, opts=opts, context=context, utils=utils, default=None + ) + if owner is not None: + return owner return None -def get_conn(profile, *, __salt__, __context__): +def get_conn(profile, *, opts, context, utils=None): """ Return a :class:`~saltext.github.utils.gh.client.GitHubConnection` for a profile, cached in ``context`` for performance. @@ -108,19 +164,177 @@ def get_conn(profile, *, __salt__, __context__): profile Profile name that contains configuration information. - salt - Pass ``__salt__``. + opts + The minion configuration (``__opts__``). context - Pass ``__context__``. + Pass ``__context__``. Caches the connection and rendered + profile values. + + utils + Pass ``__utils__`` when available (SDB rendering). + """ + token = get_config_value(profile, "token", opts=opts, context=context, utils=utils) + owner = get_owner(profile, opts=opts, context=context, utils=utils) + api_url = get_api_url(profile, opts=opts, context=context, utils=utils) + return _conn_for(token, owner, api_url, context) + + +def _conn_for(token, owner, api_url, context): + """ + Return the connection for the resolved profile values, cached in + ``context`` for performance. """ - token = get_config_value(profile, "token", __salt__=__salt__, __context__=__context__) - owner = get_owner(profile, __salt__=__salt__, __context__=__context__) - api_url = get_api_url(profile, __salt__=__salt__, __context__=__context__) # Hash the token to keep the secret out of context dumps token_hash = hashlib.sha256(token.encode()).hexdigest()[:16] key = f"github.{token_hash}:{owner}:{api_url}" - if key not in __context__: - __context__[key] = GitHubConnection(token, owner, api_url=api_url) - return __context__[key] + if key not in context: + context[key] = GitHubConnection(token, owner, api_url=api_url) + return context[key] + + +def resource_decl(opts): + """ + Return the ``github`` resource type's declaration from the resource + pillar subtree (``{}`` when unset or not a mapping). + + opts + The minion configuration (``__opts__``). + """ + # Local import: the resource subsystem only exists in Salt >= 3008, + # while this module is also imported on earlier releases. + import salt.utils.resources # pylint: disable=import-outside-toplevel + + decl = salt.utils.resources.pillar_resources_tree(opts).get("github") + return decl if isinstance(decl, dict) else {} + + +def resource_profile(opts): + """ + Return the default profile name configured in the ``github`` + resource declaration, defaulting to ``github``. + + opts + The minion configuration (``__opts__``). + """ + return resource_decl(opts).get("profile") or "github" + + +def resource_owners(opts): + """ + Return the owners named in the ``github`` resource declaration as a + list (an empty one when unset). The declaration's ``owners`` may be + a plain list of owner names or a mapping of owner name to per-owner + settings (``profile``, ``repos``, ``exclude``, ``include_archived``). + + opts + The minion configuration (``__opts__``). + """ + owners = resource_decl(opts).get("owners") + if isinstance(owners, dict): + return list(owners) + return list(owners or []) + + +def _owner_settings(opts, owner): + """ + Return one owner's settings mapping from the declaration's + ``owners``, matched case-insensitively (``{}`` when the owners are + a plain list or the owner carries no settings). + """ + owners = resource_decl(opts).get("owners") + if isinstance(owners, dict) and owner is not None: + for name, settings in owners.items(): + if name.lower() == owner.lower() and isinstance(settings, dict): + return settings + return {} + + +def resource_owner_setting(opts, owner, key, default=None): + """ + Return a declaration setting for one owner: the owner's own value + from the ``owners`` mapping when set, otherwise the + declaration-level value, otherwise ``default``. + + opts + The minion configuration (``__opts__``). + + owner + Owner (organization or user) login. + + key + Setting name (``profile``, ``repos``, ``exclude``, + ``include_archived``). + + default + Value to return when neither the owner nor the declaration + carries the setting. + """ + value = _owner_settings(opts, owner).get(key) + if value is None: + value = resource_decl(opts).get(key) + return value if value is not None else default + + +def resource_owner_profile(opts, owner): + """ + Return the profile name to use for one owner's repositories: the + owner's ``profile`` override from the declaration's ``owners`` + mapping when present, otherwise the declaration's default profile. + Owner names are matched case-insensitively. + + opts + The minion configuration (``__opts__``). + + owner + Owner (organization or user) login. + """ + return resource_owner_setting(opts, owner, "profile", "github") + + +def resource_profiles(opts): + """ + Return all distinct profile names referenced by the ``github`` + resource declaration: the default one plus any per-owner overrides. + + opts + The minion configuration (``__opts__``). + """ + profiles = [resource_profile(opts)] + owners = resource_decl(opts).get("owners") + if isinstance(owners, dict): + for settings in owners.values(): + if isinstance(settings, dict) and settings.get("profile"): + if settings["profile"] not in profiles: + profiles.append(settings["profile"]) + return profiles + + +def resource_conn(opts, context, *, profile=None, owner=None, utils=None): + """ + Return the :class:`~saltext.github.utils.gh.client.GitHubConnection` + for a ``github`` resource context, resolved from ``opts``. + + opts + The minion configuration (``__opts__``). + + context + Cache mapping for the connection (``__context__``). + + profile + Profile name override. When unset, the profile is resolved from + the resource declaration: the targeted ``owner``'s override when + declared, otherwise the declaration's default profile + (``github`` when unset). + + owner + Owner the connection is for, used to resolve per-owner profile + overrides. Ignored when ``profile`` is passed. + + utils + Pass ``__utils__`` when available (SDB rendering). + """ + return get_conn( + profile or resource_owner_profile(opts, owner), opts=opts, context=context, utils=utils + ) diff --git a/src/saltext/github/utils/gh/statecores/__init__.py b/src/saltext/github/utils/gh/statecores/__init__.py new file mode 100644 index 00000000..496c7eda --- /dev/null +++ b/src/saltext/github/utils/gh/statecores/__init__.py @@ -0,0 +1,24 @@ +""" +Shared cores implementing the repository-scoped states: the full +inspect -> test-gate -> apply flow, including change reporting. + +They are consumed by the classic :py:mod:`github_repo +` state module as well as the +``github`` resource type's state modules, which are both thin wrappers +handling identity and connection resolution. + +The cores return complete state return dictionaries (``name``, +``changes``, ``result``, ``comment``) and raise +:py:class:`CommandExecutionError ` +(usually the :py:mod:`gh ` subclasses) on +failures, which the wrappers translate into a failed state return. +""" + + +def differs(current, desired): + """ + Report whether a managed parameter needs updating. The identity check + makes boolean comparisons exact: since ``True == 1`` in Python, an + equality check alone would not register e.g. ``1`` -> ``True``. + """ + return current is not desired and current != desired diff --git a/src/saltext/github/utils/gh/statecores/actions.py b/src/saltext/github/utils/gh/statecores/actions.py new file mode 100644 index 00000000..466c3d6b --- /dev/null +++ b/src/saltext/github/utils/gh/statecores/actions.py @@ -0,0 +1,180 @@ +""" +State cores managing repository Actions secrets and workflow permissions. +""" + +import logging + +from salt.exceptions import CommandExecutionError + +from saltext.github.utils import filter_unset +from saltext.github.utils.gh import actions as gh_actions + +log = logging.getLogger(__name__) + + +def secret_present(conn, name, repo_name, *, value=None, force=False, owner=None, test=False): + """ + Ensure an Actions secret is present in a repository. + + Secret values are write-only in the GitHub API, so this core cannot + detect value drift. By default, an existing secret is left untouched. + Set ``force`` to push the value on every run. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + name + Name of the secret. GitHub treats secret names + case-insensitively. + + repo_name + Name of the repository. + + value + Plaintext secret value. Required when the secret does not exist + yet or ``force`` is set. + + force + Push the value even when the secret already exists. + Defaults to false. + + owner + Owner of the repository. Defaults to the connection's owner. + + test + Only project the changes without applying them (``test`` mode). + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + secrets = [sec["name"] for sec in gh_actions.list_secrets(conn, repo_name, owner=owner)] + current = next((sec for sec in secrets if sec.lower() == name.lower()), None) + + if current is not None and not force: + ret["comment"] = ( + f"Secret {name} is already present. Note that secret values are " + "write-only and cannot be verified, set force to push the value." + ) + return ret + + if value is None: + raise CommandExecutionError(f"A value is required to set secret {name}") + + if current is None: + ret["changes"]["created"] = name + verb = "created" + else: + ret["changes"]["updated"] = name + verb = "updated" + + if test: + ret["result"] = None + ret["comment"] = f"Secret {name} would have been {verb}" + return ret + + gh_actions.set_secret(conn, repo_name, name, value, owner=owner) + ret["comment"] = f"Secret {name} has been {verb}" + return ret + + +def secret_absent(conn, name, repo_name, *, owner=None, test=False): + """ + Ensure an Actions secret is absent from a repository. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + name + Name of the secret. GitHub treats secret names + case-insensitively. + + repo_name + Name of the repository. + + owner + Owner of the repository. Defaults to the connection's owner. + + test + Only project the changes without applying them (``test`` mode). + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + secrets = [sec["name"] for sec in gh_actions.list_secrets(conn, repo_name, owner=owner)] + current = next((sec for sec in secrets if sec.lower() == name.lower()), None) + if current is None: + ret["comment"] = f"Secret {name} is already absent" + return ret + + ret["changes"]["deleted"] = current + if test: + ret["result"] = None + ret["comment"] = f"Secret {name} would have been removed" + return ret + + gh_actions.delete_secret(conn, repo_name, current, owner=owner) + ret["comment"] = f"Secret {name} has been removed" + return ret + + +def workflow_permissions_managed( + conn, + name, + repo_name, + *, + default_workflow_permissions=None, + can_approve_pull_request_reviews=None, + owner=None, + test=False, +): + """ + Manage the default workflow permissions granted to the ``GITHUB_TOKEN`` + in a repository. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + name + State name (reported back unchanged). + + repo_name + Name of the repository. + + default_workflow_permissions + Default permissions for the ``GITHUB_TOKEN``, either ``read`` + or ``write``. + + can_approve_pull_request_reviews + Allow GitHub Actions to approve pull requests. + + owner + Owner of the repository. Defaults to the connection's owner. + + test + Only project the changes without applying them (``test`` mode). + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + desired = filter_unset( + { + "default_workflow_permissions": default_workflow_permissions, + "can_approve_pull_request_reviews": can_approve_pull_request_reviews, + } + ) + if not desired: + raise CommandExecutionError("At least one setting to manage must be specified") + + current = gh_actions.get_workflow_permissions(conn, repo_name, owner=owner) + diff = {key: val for key, val in desired.items() if current.get(key) != val} + if not diff: + ret["comment"] = "Workflow permissions are already set as specified" + return ret + + ret["changes"]["old"] = {key: current.get(key) for key in diff} + ret["changes"]["new"] = diff + if test: + ret["result"] = None + ret["comment"] = "Workflow permissions would have been updated" + return ret + + gh_actions.set_workflow_permissions(conn, repo_name, owner=owner, **diff) + ret["comment"] = "Workflow permissions have been updated" + return ret diff --git a/src/saltext/github/utils/gh/statecores/deploy_keys.py b/src/saltext/github/utils/gh/statecores/deploy_keys.py new file mode 100644 index 00000000..f62c95dc --- /dev/null +++ b/src/saltext/github/utils/gh/statecores/deploy_keys.py @@ -0,0 +1,106 @@ +""" +State core managing repository deploy keys. +""" + +import logging + +from saltext.github.utils.gh import deploy_keys as gh_deploy_keys + +log = logging.getLogger(__name__) + + +def present(conn, name, key, repo_name, *, read_only=False, owner=None, test=False): + """ + Ensure a deploy key with this title and public key is present + in a repository. + + Since deploy keys are immutable in the GitHub API, a key whose + ``key`` or ``read_only`` setting differs is replaced (removed + and recreated). + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + name + Display title of the deploy key. + + key + SSH public key. + + repo_name + Name of the repository. + + read_only + Deny write access to the repository for this key. Defaults to false. + + owner + Owner of the repository. Defaults to the connection's owner. + + test + Only project the changes without applying them (``test`` mode). + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + keys = gh_deploy_keys.list_all(conn, repo_name, owner=owner) + current = next((cur for cur in keys if cur["title"] == name), None) + + if current is not None: + if gh_deploy_keys.key_body(current["key"]) == gh_deploy_keys.key_body(key) and bool( + current["read_only"] + ) is bool(read_only): + ret["comment"] = f"Deploy key {name} is present as specified" + return ret + ret["changes"]["old"] = {"key": current["key"], "read_only": current["read_only"]} + ret["changes"]["new"] = {"key": gh_deploy_keys.key_body(key), "read_only": bool(read_only)} + else: + ret["changes"]["created"] = name + + verb = "added" if current is None else "replaced" + if test: + ret["result"] = None + ret["comment"] = f"Deploy key {name} would have been {verb}" + return ret + + if current is not None: + gh_deploy_keys.delete(conn, repo_name, current["id"], owner=owner) + gh_deploy_keys.create(conn, repo_name, name, key, read_only=bool(read_only), owner=owner) + ret["comment"] = f"Deploy key {name} has been {verb}" + return ret + + +def absent(conn, name, repo_name, *, owner=None, test=False): + """ + Ensure no deploy key with this title is present in a repository. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + name + Display title of the deploy key. + + repo_name + Name of the repository. + + owner + Owner of the repository. Defaults to the connection's owner. + + test + Only project the changes without applying them (``test`` mode). + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + keys = gh_deploy_keys.list_all(conn, repo_name, owner=owner) + current = next((cur for cur in keys if cur["title"] == name), None) + if current is None: + ret["comment"] = f"Deploy key {name} is already absent" + return ret + + ret["changes"]["deleted"] = name + if test: + ret["result"] = None + ret["comment"] = f"Deploy key {name} would have been removed" + return ret + + gh_deploy_keys.delete(conn, repo_name, current["id"], owner=owner) + ret["comment"] = f"Deploy key {name} has been removed" + return ret diff --git a/src/saltext/github/utils/gh/statecores/environments.py b/src/saltext/github/utils/gh/statecores/environments.py new file mode 100644 index 00000000..c0faa8e0 --- /dev/null +++ b/src/saltext/github/utils/gh/statecores/environments.py @@ -0,0 +1,194 @@ +""" +State core managing repository (deployment) environments. +""" + +import logging + +from saltext.github.utils import filter_unset +from saltext.github.utils.gh import environments as gh_environments +from saltext.github.utils.gh.exceptions import GitHubNotFoundError + +log = logging.getLogger(__name__) + + +def _environment_settings_diff(ret, current_settings, desired): + """ + Record an existing environment's settings that need updating in + ``ret`` and return whether there are any. + """ + diff = {} + for key, val in desired.items(): + current_val = current_settings.get(key) + if key == "reviewers": + matches = sorted((reviewer["type"], reviewer["id"]) for reviewer in val) == sorted( + (reviewer["type"], reviewer["id"]) for reviewer in current_val + ) + else: + matches = current_val == val + if not matches: + diff[key] = val + if diff: + ret["changes"]["old"] = {key: current_settings.get(key) for key in diff} + ret["changes"]["new"] = diff + return bool(diff) + + +def present( + conn, + name, + repo_name, + *, + wait_timer=None, + prevent_self_review=None, + reviewers=None, + deployment_branches=None, + owner=None, + test=False, +): + """ + Ensure an environment is present in a repository and configured + as specified. Unspecified settings are left unmanaged. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + name + Name of the environment. + + repo_name + Name of the repository. + + wait_timer + Amount of time to delay a job after it is initially triggered, + in minutes. + + prevent_self_review + Prevent a user who created a job from approving it. + + reviewers + People or teams that may review jobs that reference the + environment, as a list of mappings with ``type`` (``User`` or + ``Team``) and ``id``. + + deployment_branches + Which branches can deploy to this environment. Either ``all``, + ``protected`` (branches with branch protection rules) or a list + of branch name patterns. + + owner + Owner of the repository. Defaults to the connection's owner. + + test + Only project the changes without applying them (``test`` mode). + """ + ret = { + "name": name, + "changes": {}, + "result": True, + "comment": f"Environment {name} is configured as specified", + } + + try: + current = gh_environments.get(conn, repo_name, name, owner=owner) + except GitHubNotFoundError: + current = None + + desired = filter_unset( + { + "wait_timer": wait_timer, + "prevent_self_review": prevent_self_review, + "reviewers": reviewers, + } + ) + if deployment_branches is not None: + desired["deployment_branch_policy"] = gh_environments.branch_policy_from_patterns( + deployment_branches + ) + + settings_diff = False + branches_added, branches_removed, current_policies = [], [], [] + if current is None: + ret["changes"]["created"] = name + set_kwargs = desired + if isinstance(deployment_branches, list): + branches_added = sorted(set(deployment_branches)) + else: + current_settings = gh_environments.normalize(current) + settings_diff = _environment_settings_diff(ret, current_settings, desired) + # The API call replaces the whole configuration, so settings + # that are not managed here need to be carried over. + set_kwargs = {**current_settings, **desired} + if isinstance(deployment_branches, list): + try: + current_policies = gh_environments.list_branch_policies( + conn, repo_name, name, owner=owner + ) + except GitHubNotFoundError: + # Not found when the current policy type is not "custom" + current_policies = [] + current_patterns = {policy["name"] for policy in current_policies} + branches_added = sorted(set(deployment_branches) - current_patterns) + branches_removed = sorted(current_patterns - set(deployment_branches)) + + if branches_added or branches_removed: + ret["changes"]["deployment_branches"] = { + "added": branches_added, + "removed": branches_removed, + } + + if not ret["changes"]: + return ret + + verb = "created" if current is None else "updated" + if test: + ret["result"] = None + ret["comment"] = f"Environment {name} would have been {verb}" + return ret + + if current is None or settings_diff: + gh_environments.set_(conn, repo_name, name, owner=owner, **set_kwargs) + for pattern in branches_added: + gh_environments.create_branch_policy(conn, repo_name, name, pattern, owner=owner) + for policy in current_policies: + if policy["name"] in branches_removed: + gh_environments.delete_branch_policy(conn, repo_name, name, policy["id"], owner=owner) + ret["comment"] = f"Environment {name} has been {verb}" + return ret + + +def absent(conn, name, repo_name, *, owner=None, test=False): + """ + Ensure an environment is absent from a repository. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + name + Name of the environment. + + repo_name + Name of the repository. + + owner + Owner of the repository. Defaults to the connection's owner. + + test + Only project the changes without applying them (``test`` mode). + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + gh_environments.get(conn, repo_name, name, owner=owner) + except GitHubNotFoundError: + ret["comment"] = f"Environment {name} is already absent" + return ret + + ret["changes"]["deleted"] = name + if test: + ret["result"] = None + ret["comment"] = f"Environment {name} would have been removed" + return ret + + gh_environments.delete(conn, repo_name, name, owner=owner) + ret["comment"] = f"Environment {name} has been removed" + return ret diff --git a/src/saltext/github/utils/gh/statecores/labels.py b/src/saltext/github/utils/gh/statecores/labels.py new file mode 100644 index 00000000..ab8cbc68 --- /dev/null +++ b/src/saltext/github/utils/gh/statecores/labels.py @@ -0,0 +1,116 @@ +""" +State core managing repository labels. +""" + +import logging + +from saltext.github.utils.gh import labels as gh_labels +from saltext.github.utils.gh.exceptions import GitHubNotFoundError + +log = logging.getLogger(__name__) + + +def present(conn, name, repo_name, *, color=None, description=None, owner=None, test=False): + """ + Ensure a label is present in a repository and configured as + specified. Unspecified settings are left unmanaged. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + name + Name of the label. + + repo_name + Name of the repository. + + color + Hexadecimal color code for the label. A leading ``#`` + is accepted. + + description + Short description of the label. + + owner + Owner of the repository. Defaults to the connection's owner. + + test + Only project the changes without applying them (``test`` mode). + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + current = gh_labels.get(conn, repo_name, name, owner=owner) + except GitHubNotFoundError: + current = None + + if current is None: + ret["changes"]["created"] = name + if test: + ret["result"] = None + ret["comment"] = f"Label {name} would have been created" + return ret + gh_labels.create(conn, repo_name, name, color=color, description=description, owner=owner) + ret["comment"] = f"Label {name} has been created" + return ret + + diff = {} + if color is not None and gh_labels.normalize_color( + current["color"] + ) != gh_labels.normalize_color(color): + diff["color"] = color + if description is not None and current.get("description") != description: + diff["description"] = description + + if not diff: + ret["comment"] = f"Label {name} is already configured as specified" + return ret + + ret["changes"]["old"] = {key: current.get(key) for key in diff} + ret["changes"]["new"] = diff + if test: + ret["result"] = None + ret["comment"] = f"Label {name} would have been updated" + return ret + + gh_labels.update(conn, repo_name, name, owner=owner, **diff) + ret["comment"] = f"Label {name} has been updated" + return ret + + +def absent(conn, name, repo_name, *, owner=None, test=False): + """ + Ensure a label is absent from a repository. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + name + Name of the label. + + repo_name + Name of the repository. + + owner + Owner of the repository. Defaults to the connection's owner. + + test + Only project the changes without applying them (``test`` mode). + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + gh_labels.get(conn, repo_name, name, owner=owner) + except GitHubNotFoundError: + ret["comment"] = f"Label {name} is already absent" + return ret + + ret["changes"]["deleted"] = name + if test: + ret["result"] = None + ret["comment"] = f"Label {name} would have been removed" + return ret + + gh_labels.delete(conn, repo_name, name, owner=owner) + ret["comment"] = f"Label {name} has been removed" + return ret diff --git a/src/saltext/github/utils/gh/statecores/pages.py b/src/saltext/github/utils/gh/statecores/pages.py new file mode 100644 index 00000000..e4878640 --- /dev/null +++ b/src/saltext/github/utils/gh/statecores/pages.py @@ -0,0 +1,177 @@ +""" +State core managing a repository's GitHub Pages site. +""" + +import logging + +from saltext.github.utils import filter_unset +from saltext.github.utils.gh import pages as gh_pages +from saltext.github.utils.gh.exceptions import GitHubNotFoundError + +log = logging.getLogger(__name__) + + +def present( + conn, + name, + repo_name, + *, + build_type=None, + source_branch=None, + source_path=None, + cname=None, + https_enforced=None, + owner=None, + test=False, +): + """ + Ensure GitHub Pages are enabled for a repository and configured as + specified. Unspecified settings are left unmanaged. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + name + State name (reported back unchanged). + + repo_name + Name of the repository. + + build_type + Process by which the site is built, either ``legacy`` + (deploy from a branch) or ``workflow`` (GitHub Actions). + + source_branch + For ``legacy`` builds, the repository branch to publish from. + + source_path + For ``legacy`` builds, the repository directory to publish from, + either ``/`` or ``/docs``. + + cname + Custom domain for the site. + + https_enforced + Enforce HTTPS for the site. + + owner + Owner of the repository. Defaults to the connection's owner. + + test + Only project the changes without applying them (``test`` mode). + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + current = gh_pages.get(conn, repo_name, owner=owner) + except GitHubNotFoundError: + current = None + + if current is None: + ret["changes"]["created"] = repo_name + if test: + ret["result"] = None + ret["comment"] = f"Pages would have been enabled for {repo_name}" + return ret + gh_pages.create( + conn, + repo_name, + build_type=build_type, + source=gh_pages.build_source(source_branch, source_path), + owner=owner, + ) + if cname is not None or https_enforced is not None: + extra = {"https_enforced": https_enforced} + if cname is not None: + extra["cname"] = cname + gh_pages.update(conn, repo_name, owner=owner, **extra) + ret["comment"] = f"Pages have been enabled for {repo_name}" + return ret + + current_source = current.get("source") or {} + desired = filter_unset( + { + "build_type": build_type, + "source_branch": source_branch, + "source_path": source_path, + "cname": cname, + "https_enforced": https_enforced, + } + ) + current_flat = { + "build_type": current.get("build_type"), + "source_branch": current_source.get("branch"), + "source_path": current_source.get("path"), + "cname": current.get("cname"), + "https_enforced": current.get("https_enforced"), + } + diff = {key: val for key, val in desired.items() if current_flat[key] != val} + + if not diff: + ret["comment"] = f"Pages for {repo_name} are already configured as specified" + return ret + + ret["changes"]["old"] = {key: current_flat[key] for key in diff} + ret["changes"]["new"] = diff + if test: + ret["result"] = None + ret["comment"] = f"Pages configuration for {repo_name} would have been updated" + return ret + + update_kwargs = dict(diff) + if "source_branch" in diff or "source_path" in diff: + # The source is submitted as a single object, carry over the + # unchanged half + update_kwargs.setdefault("source_branch", current_flat["source_branch"]) + update_kwargs.setdefault("source_path", current_flat["source_path"]) + gh_pages.update( + conn, + repo_name, + build_type=update_kwargs.get("build_type"), + source=gh_pages.build_source( + update_kwargs.get("source_branch"), update_kwargs.get("source_path") + ), + cname=update_kwargs.get("cname", ...), + https_enforced=update_kwargs.get("https_enforced"), + owner=owner, + ) + ret["comment"] = f"Pages configuration for {repo_name} has been updated" + return ret + + +def absent(conn, name, repo_name, *, owner=None, test=False): + """ + Ensure GitHub Pages are disabled for a repository. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + name + State name (reported back unchanged). + + repo_name + Name of the repository. + + owner + Owner of the repository. Defaults to the connection's owner. + + test + Only project the changes without applying them (``test`` mode). + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + gh_pages.get(conn, repo_name, owner=owner) + except GitHubNotFoundError: + ret["comment"] = f"Pages are already disabled for {repo_name}" + return ret + + ret["changes"]["deleted"] = repo_name + if test: + ret["result"] = None + ret["comment"] = f"Pages would have been disabled for {repo_name}" + return ret + + gh_pages.delete(conn, repo_name, owner=owner) + ret["comment"] = f"Pages have been disabled for {repo_name}" + return ret diff --git a/src/saltext/github/utils/gh/statecores/repo.py b/src/saltext/github/utils/gh/statecores/repo.py new file mode 100644 index 00000000..55a7fef7 --- /dev/null +++ b/src/saltext/github/utils/gh/statecores/repo.py @@ -0,0 +1,251 @@ +""" +State core managing a repository's settings and team associations. +""" + +import logging + +from salt.exceptions import CommandExecutionError + +from saltext.github.utils.gh import repos as gh_repos +from saltext.github.utils.gh import teams as gh_teams +from saltext.github.utils.gh.exceptions import GitHubNotFoundError +from saltext.github.utils.gh.statecores import differs + +log = logging.getLogger(__name__) + + +def _repo_settings_diff(ret, target, managed): + """ + Record the settings of an existing repository that need updating + in ``ret`` and return them. + """ + update_params = { + param: value + for param, value in managed.items() + if value is not None and differs(target[param], value) + } + if update_params: + ret["changes"]["old"] = {param: target[param] for param in update_params} + ret["changes"]["new"] = update_params + return update_params + + +def _repo_teams_diff(ret, conn, name, teams, owner): + """ + Record the team association changes of an existing repository in + ``ret`` and return them. + """ + current_teams = {team["name"] for team in gh_repos.get_teams(conn, name, owner=owner)} + teams_added = sorted(set(teams) - current_teams) + teams_removed = sorted(current_teams - set(teams)) + team_permissions = {} + for team_name in sorted(set(teams) & current_teams): + team_repos = gh_teams.list_repos( + conn, gh_teams.find(conn, team_name, owner=owner), owner=owner + ) + current_permission = team_repos.get(name.lower(), {}).get("permission") + if current_permission != teams[team_name]: + team_permissions[team_name] = {"old": current_permission, "new": teams[team_name]} + if teams_added: + ret["changes"]["teams_added"] = teams_added + if teams_removed: + ret["changes"]["teams_removed"] = teams_removed + if team_permissions: + ret["changes"]["team_permissions"] = team_permissions + return teams_added, teams_removed, team_permissions + + +def _set_team_repo(conn, name, team_name, permission, owner): + """ + Add a repository to a team or update the team's permission on it, + raising when setting the permission failed. + """ + if not gh_teams.add_repo( + conn, gh_teams.find(conn, team_name, owner=owner), name, permission=permission, owner=owner + ): + raise CommandExecutionError( + f"Failed setting permission {permission} on repo {name} for team {team_name}." + ) + + +def _create_repo(conn, name, create_params, teams, owner): + """ + Create a repository and associate the requested teams. + """ + gh_repos.create(conn, name, owner=owner, **create_params) + for team_name, permission in (teams or {}).items(): + _set_team_repo(conn, name, team_name, permission, owner) + + +def present( + conn, + name, + *, + owner=None, + description=None, + homepage=None, + private=None, + has_issues=None, + has_wiki=None, + has_downloads=None, + auto_init=False, + gitignore_template=None, + license_template=None, + default_branch=None, + teams=None, + allow_privacy_change=False, + allow_create=True, + test=False, +): + """ + Ensure a repository is present and configured as specified. + Unspecified settings are left unmanaged. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + name + Name of the repository. + + owner + Owner of the repository. Defaults to the connection's owner. + + allow_privacy_change + Allow changing an existing repository's visibility (the + profile's :ghconf:`allow_repo_privacy_changes`). Defaults to false. + + allow_create + Create the repository when it is missing. When disabled, a + missing repository raises instead (resource context, where the + repository's existence is a given). Defaults to true. + + test + Only project the changes without applying them (``test`` mode). + + For the remaining parameters, see the ``github_repo.present`` state. + """ + ret = { + "name": name, + "changes": {}, + "result": True, + "comment": f"Repo {name} is present as specified", + } + + # This is an optimization to cache all of the owner's repos up front. + # The first use of this state will collect all of the repos and save a bunch + # of API calls for future use. + gh_repos.list_all(conn, owner=owner) + + try: + target = gh_repos.get_info(conn, name, owner=owner) + except GitHubNotFoundError: + if not allow_create: + raise CommandExecutionError(f"Repo {name} does not exist") from None + target = None + + update_params = {} + teams_added, teams_removed, team_permissions = [], [], {} + if target is None: + ret["changes"]["created"] = name + if teams: + ret["changes"]["teams_added"] = sorted(teams) + else: + update_params = _repo_settings_diff( + ret, + target, + { + "description": description, + "homepage": homepage, + "private": private, + "has_issues": has_issues, + "has_wiki": has_wiki, + "has_downloads": has_downloads, + "default_branch": default_branch, + }, + ) + if teams is not None: + teams_added, teams_removed, team_permissions = _repo_teams_diff( + ret, conn, name, teams, owner + ) + + if not ret["changes"]: + return ret + + verb = "created" if target is None else "updated" + if test: + ret["result"] = None + ret["comment"] = f"Repo {name} would have been {verb}" + return ret + + if target is None: + _create_repo( + conn, + name, + { + "description": description, + "homepage": homepage, + "private": private, + "has_issues": has_issues, + "has_wiki": has_wiki, + "has_downloads": has_downloads, + "auto_init": auto_init, + "gitignore_template": gitignore_template, + "license_template": license_template, + }, + teams, + owner, + ) + else: + if update_params: + gh_repos.update( + conn, + name, + owner=owner, + allow_privacy_change=allow_privacy_change, + **update_params, + ) + for team_name in teams_added: + _set_team_repo(conn, name, team_name, teams[team_name], owner) + for team_name in teams_removed: + gh_teams.remove_repo( + conn, gh_teams.find(conn, team_name, owner=owner), name, owner=owner + ) + for team_name in team_permissions: + _set_team_repo(conn, name, team_name, teams[team_name], owner) + ret["comment"] = f"Repo {name} has been {verb}" + return ret + + +def absent(conn, name, *, owner=None, test=False): + """ + Ensure a repository is absent. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + name + Name of the repository. + + owner + Owner of the repository. Defaults to the connection's owner. + + test + Only project the changes without applying them (``test`` mode). + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + try: + gh_repos.get_info(conn, name, owner=owner) + except GitHubNotFoundError: + ret["comment"] = f"Repo {name} does not exist" + return ret + + ret["changes"]["deleted"] = name + if test: + ret["result"] = None + ret["comment"] = f"Repo {name} would have been deleted" + return ret + + gh_repos.delete(conn, name, owner=owner) + ret["comment"] = f"Repo {name} has been deleted" + return ret diff --git a/src/saltext/github/utils/gh/statecores/rulesets.py b/src/saltext/github/utils/gh/statecores/rulesets.py new file mode 100644 index 00000000..174dc8b2 --- /dev/null +++ b/src/saltext/github/utils/gh/statecores/rulesets.py @@ -0,0 +1,115 @@ +""" +State core managing repository rulesets. +""" + +import logging + +from saltext.github.utils.gh import rulesets as gh_rulesets + +log = logging.getLogger(__name__) + + +def present(conn, name, repo_name, *, ruleset_params=None, owner=None, test=False): + """ + Ensure a repository ruleset is present and configured as specified. + Only the parameters included in ``ruleset_params`` are compared, + server-populated fields are ignored. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + name + Ruleset name to manage. + + repo_name + Name of the repository. + + ruleset_params + Ruleset definition. Must contain at least ``enforcement`` + when the ruleset needs to be created. ``name`` is pulled from + this state's ``name``. + + owner + Owner of the repository. Defaults to the connection's owner. + + test + Only project the changes without applying them (``test`` mode). + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + ruleset_params = dict(ruleset_params or {}) + ruleset_params["name"] = name + + rulesets = gh_rulesets.list_all(conn, "repo", owner=owner, repo_name=repo_name) + ruleset = next((rls for rls in rulesets or [] if rls["name"] == name), None) + + if ruleset is None: + ret["changes"]["created"] = name + if test: + ret["result"] = None + ret["comment"] = f"Ruleset {name} would have been created" + return ret + gh_rulesets.create(conn, ruleset_params, "repo", owner=owner, repo_name=repo_name) + ret["comment"] = f"Ruleset {name} has been created" + return ret + + ruleset_info = gh_rulesets.get(conn, ruleset["id"], "repo", owner=owner, repo_name=repo_name) + + # Only diff the parameters that are being managed. The server + # response carries additional server-populated fields (id, + # timestamps, source, ...) that must not register as drift. + diff = {key: val for key, val in ruleset_params.items() if ruleset_info.get(key) != val} + if not diff: + ret["comment"] = f"Ruleset {name} is already configured as specified" + return ret + + ret["changes"]["old"] = {key: ruleset_info.get(key) for key in diff} + ret["changes"]["new"] = diff + if test: + ret["result"] = None + ret["comment"] = f"Ruleset {name} would have been updated" + return ret + + gh_rulesets.update( + conn, ruleset["id"], ruleset_params, "repo", owner=owner, repo_name=repo_name + ) + ret["comment"] = f"Ruleset {name} has been updated" + return ret + + +def absent(conn, name, repo_name, *, owner=None, test=False): + """ + Ensure a repository ruleset is absent. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + name + Ruleset name to manage. + + repo_name + Name of the repository. + + owner + Owner of the repository. Defaults to the connection's owner. + + test + Only project the changes without applying them (``test`` mode). + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + rulesets = gh_rulesets.list_all(conn, "repo", owner=owner, repo_name=repo_name) + ruleset = next((rls for rls in rulesets or [] if rls["name"] == name), None) + if ruleset is None: + ret["comment"] = f"Ruleset {name} does not exist" + return ret + + ret["changes"]["deleted"] = name + if test: + ret["result"] = None + ret["comment"] = f"Ruleset {name} would have been deleted" + return ret + + gh_rulesets.delete(conn, ruleset["id"], "repo", owner=owner, repo_name=repo_name) + ret["comment"] = f"Ruleset {name} has been deleted" + return ret diff --git a/src/saltext/github/utils/gh/teams.py b/src/saltext/github/utils/gh/teams.py index f3a40c47..150fd278 100644 --- a/src/saltext/github/utils/gh/teams.py +++ b/src/saltext/github/utils/gh/teams.py @@ -10,6 +10,7 @@ from saltext.github.utils import filter_unset from saltext.github.utils.gh.client import api_request +from saltext.github.utils.gh.exceptions import GitHubNotFoundError log = logging.getLogger(__name__) @@ -66,6 +67,36 @@ def get(conn, name, *, owner=None): return list_all(conn, owner=owner).get(name) +def find(conn, name, *, owner=None): + """ + Return a team's cached details, raising when it does not exist. + Teams are matched by exact display name first, then by slug and + case-insensitive display name since GitHub derives team identity + (the slug) case-insensitively. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + name + Team name or slug. + + owner + Organization owning the team. Defaults to the connection's owner. + + Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError` + if the team does not exist. + """ + team = get(conn, name, owner=owner) + if team is None: + for candidate in list_all(conn, owner=owner).values(): + if name == candidate["slug"] or name.lower() == candidate["name"].lower(): + return candidate + raise GitHubNotFoundError( + f"Team {name} does not exist in organization {owner or conn.owner}." + ) + return team + + @api_request def create( conn, name, *, description=None, repo_names=None, privacy=None, permission=None, owner=None diff --git a/tests/functional/resources/__init__.py b/tests/functional/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/functional/resources/conftest.py b/tests/functional/resources/conftest.py new file mode 100644 index 00000000..ed628e96 --- /dev/null +++ b/tests/functional/resources/conftest.py @@ -0,0 +1,115 @@ +""" +Fixtures for testing the ``github`` resource type. + +The salt-factories ``Loaders`` helper only builds classic minion +loaders, so the resource loaders (connection module, per-type execution +and state modules) are constructed by hand via ``salt.loader.resource`` +and ``salt.loader.resource_modules``. +""" + +import contextlib +import copy +import os + +import pytest +import salt.loader +import salt.loader.context as loader_ctx +from salt.version import __version_info__ as _SALT_VERSION + +# Salt version in test venv +if os.environ.get("SALT_REQUIREMENT") == "salt==master": + SALT_VERSION = (_SALT_VERSION[0] + 1, 0) +else: + SALT_VERSION = tuple(_SALT_VERSION) + +if SALT_VERSION < (3008, 3): + pytest.skip(reason="Resource modules require Salt >=3008.3") + +RTYPE = "github" + + +@pytest.fixture(autouse=True) +def reset_loaders_state(): + """ + Shadow the parent conftest's autouse fixture of the same name so the + classic factories loaders are not built for resource tests. + """ + yield + + +@pytest.fixture(scope="module") +def resource_decl(): + """ + The ``github`` entry of the resource pillar subtree. Override per + module to test owners/repos/exclude/include_archived handling. + """ + return {"profile": "github"} + + +@pytest.fixture(scope="module") +def res_opts(minion_opts, resource_decl): + opts = copy.deepcopy(minion_opts) + opts["pillar"] = {"resources": {RTYPE: resource_decl}} + return opts + + +@pytest.fixture(scope="module") +def loader_context(): + return {} + + +@pytest.fixture(autouse=True) +def _fresh_loader_context(loader_context): + """ + The fake API state resets per test, so cached connections (and their + repository caches) must be dropped alongside. + """ + try: + yield + finally: + loader_context.clear() + + +@pytest.fixture(scope="module") +def utils_loader(res_opts): + return salt.loader.utils(res_opts) + + +@pytest.fixture(scope="module") +def resource_funcs(res_opts, utils_loader, loader_context): + return salt.loader.resource( + res_opts, + utils=utils_loader, + context=loader_context, + loaded_base_name=f"{__name__}.resource", + ) + + +@pytest.fixture(scope="module") +def res_modules(res_opts, resource_funcs, utils_loader, loader_context): + return salt.loader.resource_modules( + res_opts, + RTYPE, + resource_funcs=resource_funcs, + utils=utils_loader, + context=loader_context, + loaded_base_name=f"{__name__}.resmods", + ) + + +@pytest.fixture +def for_resource(): + """ + Context manager setting ``__resource__`` for the duration of a call, + mirroring what the minion's dispatch does. + """ + + @contextlib.contextmanager + def _set(rid, rtype=RTYPE): + token = loader_ctx.resource_ctxvar.set({"id": rid, "type": rtype}) + try: + yield + finally: + loader_ctx.resource_ctxvar.reset(token) + + return _set diff --git a/tests/functional/resources/modules/__init__.py b/tests/functional/resources/modules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/functional/resources/modules/test_actions.py b/tests/functional/resources/modules/test_actions.py new file mode 100644 index 00000000..8b051706 --- /dev/null +++ b/tests/functional/resources/modules/test_actions.py @@ -0,0 +1,31 @@ +def test_secrets_roundtrip(res_modules, for_resource, fake_org): + repo = fake_org.get_repo("repo-a") + with for_resource("testorg/repo-a"): + assert list(res_modules["actions.list_secrets"]()) == ["EXISTING_SECRET"] + + assert res_modules["actions.set_secret"]("NEW_SECRET", "hunter3") is True + # The fake decrypts the submitted sealed box end-to-end + assert repo.get_secret("NEW_SECRET").value == "hunter3" + + assert res_modules["actions.get_secret"]("NEW_SECRET")["name"] == "NEW_SECRET" + + assert res_modules["actions.remove_secret"]("NEW_SECRET") is True + assert "NEW_SECRET" not in repo.secrets + + +def test_workflow_permissions(res_modules, for_resource, fake_org): + with for_resource("testorg/repo-a"): + current = res_modules["actions.get_workflow_permissions"]() + assert current == { + "default_workflow_permissions": "write", + "can_approve_pull_request_reviews": False, + } + assert ( + res_modules["actions.set_workflow_permissions"]( + default_workflow_permissions="read", can_approve_pull_request_reviews=True + ) + is True + ) + repo = fake_org.get_repo("repo-a") + assert repo.default_workflow_permissions == "read" + assert repo.can_approve_pull_request_reviews is True diff --git a/tests/functional/resources/modules/test_deploy_key.py b/tests/functional/resources/modules/test_deploy_key.py new file mode 100644 index 00000000..006537f6 --- /dev/null +++ b/tests/functional/resources/modules/test_deploy_key.py @@ -0,0 +1,16 @@ +from tests.support.github import DEPLOY_KEY_B + + +def test_list_add_remove(res_modules, for_resource, fake_org): + repo = fake_org.get_repo("repo-a") + with for_resource("testorg/repo-a"): + keys = res_modules["deploy_key.list"](fields="title") + assert keys == ["existing-key"] + + created = res_modules["deploy_key.add"]("new-key", DEPLOY_KEY_B, read_only=False) + assert "new-key" in {key.title for key in repo.deploy_keys} + + assert res_modules["deploy_key.get"](created["id"])["title"] == "new-key" + + assert res_modules["deploy_key.remove"](created["id"]) is True + assert "new-key" not in {key.title for key in repo.deploy_keys} diff --git a/tests/functional/resources/modules/test_environment.py b/tests/functional/resources/modules/test_environment.py new file mode 100644 index 00000000..dd6fdde0 --- /dev/null +++ b/tests/functional/resources/modules/test_environment.py @@ -0,0 +1,28 @@ +def test_list_get(res_modules, for_resource): + with for_resource("testorg/repo-a"): + environments = res_modules["environment.list"]() + assert list(environments) == ["release"] + assert res_modules["environment.get"]("release")["name"] == "release" + + +def test_set_remove(res_modules, for_resource, fake_org): + repo = fake_org.get_repo("repo-a") + with for_resource("testorg/repo-a"): + res_modules["environment.set"]("staging", wait_timer=5) + assert "staging" in repo.environments + + assert res_modules["environment.remove"]("staging") is True + assert "staging" not in repo.environments + + +def test_branch_policies(res_modules, for_resource): + with for_resource("testorg/repo-a"): + policies = res_modules["environment.list_branch_policies"]("release") + assert [policy["name"] for policy in policies] == ["main"] + + created = res_modules["environment.add_branch_policy"]("release", "release/*") + policies = res_modules["environment.list_branch_policies"]("release", fields="name") + assert sorted(policies) == ["main", "release/*"] + + assert res_modules["environment.remove_branch_policy"]("release", created["id"]) is True + assert res_modules["environment.list_branch_policies"]("release", fields="name") == ["main"] diff --git a/tests/functional/resources/modules/test_issue.py b/tests/functional/resources/modules/test_issue.py new file mode 100644 index 00000000..35f527da --- /dev/null +++ b/tests/functional/resources/modules/test_issue.py @@ -0,0 +1,13 @@ +def test_list(res_modules, for_resource): + with for_resource("testorg/repo-a"): + issues = res_modules["issue.list"](fields="title") + assert issues == ["Found a bug"] + + +def test_get_and_comments(res_modules, for_resource): + with for_resource("testorg/repo-a"): + (number,) = res_modules["issue.list"](fields="number") + issue = res_modules["issue.get"](number) + comments = res_modules["issue.get_comments"](number) + assert issue["title"] == "Found a bug" + assert [comment["body"] for comment in comments] == ["Working on it", "Thanks!"] diff --git a/tests/functional/resources/modules/test_label.py b/tests/functional/resources/modules/test_label.py new file mode 100644 index 00000000..4bad554e --- /dev/null +++ b/tests/functional/resources/modules/test_label.py @@ -0,0 +1,14 @@ +def test_list_get_add_edit_remove(res_modules, for_resource, fake_org): + repo = fake_org.get_repo("repo-a") + with for_resource("testorg/repo-a"): + assert list(res_modules["label.list"]()) == ["bug"] + assert res_modules["label.get"]("bug")["color"] == "d73a4a" + + res_modules["label.add"]("release", color="00ff00", description="Release automation") + assert repo.get_label("release").color == "00ff00" + + res_modules["label.edit"]("release", color="0000ff") + assert repo.get_label("release").color == "0000ff" + + assert res_modules["label.remove"]("release") is True + assert "release" not in repo.labels diff --git a/tests/functional/resources/modules/test_milestone.py b/tests/functional/resources/modules/test_milestone.py new file mode 100644 index 00000000..dc218414 --- /dev/null +++ b/tests/functional/resources/modules/test_milestone.py @@ -0,0 +1,10 @@ +def test_list(res_modules, for_resource): + with for_resource("testorg/repo-a"): + milestones = res_modules["milestone.list"](fields="title") + assert milestones == ["v1.0", "v2.0"] + + +def test_get_by_name(res_modules, for_resource): + with for_resource("testorg/repo-a"): + milestone = res_modules["milestone.get"](name="v1.0") + assert milestone["title"] == "v1.0" diff --git a/tests/functional/resources/modules/test_pages.py b/tests/functional/resources/modules/test_pages.py new file mode 100644 index 00000000..ac9fc428 --- /dev/null +++ b/tests/functional/resources/modules/test_pages.py @@ -0,0 +1,21 @@ +import pytest + +from saltext.github.utils.gh.exceptions import GitHubNotFoundError + + +def test_setup_get_update_remove(res_modules, for_resource, fake_org): + repo = fake_org.get_repo("repo-a") + with for_resource("testorg/repo-a"): + with pytest.raises(GitHubNotFoundError): + res_modules["pages.get"]() + + res_modules["pages.setup"](build_type="workflow") + assert res_modules["pages.get"]()["build_type"] == "workflow" + assert repo.pages.build_type == "workflow" + + assert res_modules["pages.update"](https_enforced=True) is True + assert repo.pages.https_enforced is True + + assert res_modules["pages.remove"]() is True + with pytest.raises(GitHubNotFoundError): + res_modules["pages.get"]() diff --git a/tests/functional/resources/modules/test_pull.py b/tests/functional/resources/modules/test_pull.py new file mode 100644 index 00000000..85bbc522 --- /dev/null +++ b/tests/functional/resources/modules/test_pull.py @@ -0,0 +1,4 @@ +def test_list(res_modules, for_resource): + with for_resource("testorg/repo-a"): + pulls = res_modules["pull.list"](fields="title") + assert pulls == ["Fix the bug"] diff --git a/tests/functional/resources/modules/test_repo.py b/tests/functional/resources/modules/test_repo.py new file mode 100644 index 00000000..591bd56b --- /dev/null +++ b/tests/functional/resources/modules/test_repo.py @@ -0,0 +1,42 @@ +import pytest +from salt.exceptions import CommandExecutionError + + +def test_ident(res_modules, for_resource): + with for_resource("testorg/repo-a"): + assert res_modules["repo.ident"]() == { + "id": "testorg/repo-a", + "owner": "testorg", + "repo": "repo-a", + } + + +def test_info_identity_routing(res_modules, for_resource): + # The org repo and the user repo share the bare name; the resource ID + # must disambiguate. + with for_resource("testorg/repo-a"): + assert res_modules["repo.info"]()["description"] == "Test repo repo-a" + with for_resource("user1/repo-a"): + assert res_modules["repo.info"]()["description"] == "User-owned repo-a" + + +def test_get_teams(res_modules, for_resource): + with for_resource("testorg/repo-a"): + teams = res_modules["repo.get_teams"]() + assert list(teams) == ["Team A"] + + +def test_update(res_modules, for_resource, fake_org): + with for_resource("testorg/repo-a"): + res = res_modules["repo.update"](description="Managed by Salt") + assert res["description"] == "Managed by Salt" + assert fake_org.get_repo("repo-a").description == "Managed by Salt" + + +def test_update_privacy_change_gated(res_modules, for_resource): + # The default profile enables allow_repo_privacy_changes; ensure the + # gate is honored when the profile disables it. + with for_resource("testorg/repo-a"): + res_modules["repo.update"](private=True, profile="github") + with pytest.raises(CommandExecutionError, match="allow_repo_privacy_changes"): + res_modules["repo.update"](private=False, profile="github_noprivacy") diff --git a/tests/functional/resources/modules/test_ruleset.py b/tests/functional/resources/modules/test_ruleset.py new file mode 100644 index 00000000..bc63e06e --- /dev/null +++ b/tests/functional/resources/modules/test_ruleset.py @@ -0,0 +1,16 @@ +def test_list_get_add_update_remove(res_modules, for_resource, fake_org): + repo = fake_org.get_repo("repo-a") + with for_resource("testorg/repo-a"): + rulesets = res_modules["ruleset.list"]() + assert [ruleset["name"] for ruleset in rulesets] == ["repo-rules"] + + created = res_modules["ruleset.add"]({"name": "new-rules", "enforcement": "active"}) + assert "new-rules" in repo.rulesets + + assert res_modules["ruleset.get"](created["id"])["enforcement"] == "active" + + res_modules["ruleset.update"](created["id"], {"enforcement": "disabled"}) + assert repo.get_ruleset(created["id"]).enforcement == "disabled" + + assert res_modules["ruleset.remove"](created["id"]) is True + assert "new-rules" not in repo.rulesets diff --git a/tests/functional/resources/modules/test_test.py b/tests/functional/resources/modules/test_test.py new file mode 100644 index 00000000..89745a07 --- /dev/null +++ b/tests/functional/resources/modules/test_test.py @@ -0,0 +1,5 @@ +def test_ping(res_modules, for_resource): + with for_resource("testorg/repo-a"): + assert res_modules["test.ping"]() is True + with for_resource("testorg/missing-repo"): + assert res_modules["test.ping"]() is False diff --git a/tests/functional/resources/states/__init__.py b/tests/functional/resources/states/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/functional/resources/states/conftest.py b/tests/functional/resources/states/conftest.py new file mode 100644 index 00000000..7ae6956e --- /dev/null +++ b/tests/functional/resources/states/conftest.py @@ -0,0 +1,41 @@ +import copy + +import pytest +import salt.loader + +from tests.functional.resources.conftest import RTYPE + + +@pytest.fixture(scope="module") +def res_state_opts(res_opts): + opts = copy.deepcopy(res_opts) + opts["resource_type"] = RTYPE + return opts + + +@pytest.fixture(scope="module") +def res_states(res_state_opts, res_modules, utils_loader, loader_context): + return salt.loader.states( + res_state_opts, + res_modules, + utils_loader, + salt.loader.serializers(res_state_opts), + context=loader_context, + loaded_base_name=f"{__name__}.resstates", + ) + + +@pytest.fixture(params=(False, True)) +def testmode(request, res_states): + """ + Toggle ``__opts__["test"]`` inside the loaded state modules. The + loader copies opts at construction time, so the packed dict is + mutated directly. + """ + packed_opts = res_states.pack["__opts__"] + old = packed_opts.get("test", False) + packed_opts["test"] = request.param + try: + yield request.param + finally: + packed_opts["test"] = old diff --git a/tests/functional/resources/states/test_actions.py b/tests/functional/resources/states/test_actions.py new file mode 100644 index 00000000..0b7cc459 --- /dev/null +++ b/tests/functional/resources/states/test_actions.py @@ -0,0 +1,47 @@ +import pytest + + +def test_secret_present_create(res_states, fake_github, fake_org, for_resource, testmode): + before = len(fake_github.mutations()) + with for_resource("testorg/repo-a"): + ret = res_states["actions.secret_present"]("NEW_SECRET", value="hunter3") + assert ret["result"] is not False + assert (ret["result"] is None) is testmode + assert ret["changes"] == {"created": "NEW_SECRET"} + assert (len(fake_github.mutations()) == before) is testmode + secrets = fake_org.get_repo("repo-a").secrets + assert ("NEW_SECRET" in secrets) is not testmode + + +@pytest.mark.usefixtures("testmode") +def test_secret_present_existing(res_states, fake_github, for_resource): + with for_resource("testorg/repo-a"): + ret = res_states["actions.secret_present"]("EXISTING_SECRET", value="hunter3") + assert ret["result"] is True + assert not ret["changes"] + assert not fake_github.mutations() + + +def test_secret_absent(res_states, fake_org, for_resource, testmode): + with for_resource("testorg/repo-a"): + ret = res_states["actions.secret_absent"]("EXISTING_SECRET") + assert ret["result"] is not False + assert (ret["result"] is None) is testmode + assert ret["changes"] == {"deleted": "EXISTING_SECRET"} + secrets = fake_org.get_repo("repo-a").secrets + assert ("EXISTING_SECRET" in secrets) is testmode + + +def test_workflow_permissions_managed(res_states, fake_org, for_resource, testmode): + with for_resource("testorg/repo-a"): + ret = res_states["actions.workflow_permissions_managed"]( + "workflow perms", default_workflow_permissions="read" + ) + assert ret["result"] is not False + assert (ret["result"] is None) is testmode + assert ret["changes"] == { + "old": {"default_workflow_permissions": "write"}, + "new": {"default_workflow_permissions": "read"}, + } + expected = "write" if testmode else "read" + assert fake_org.get_repo("repo-a").default_workflow_permissions == expected diff --git a/tests/functional/resources/states/test_deploy_key.py b/tests/functional/resources/states/test_deploy_key.py new file mode 100644 index 00000000..ea99fa62 --- /dev/null +++ b/tests/functional/resources/states/test_deploy_key.py @@ -0,0 +1,39 @@ +import pytest + +from tests.support.github import DEPLOY_KEY_B + + +def test_present_create(res_states, fake_github, fake_org, for_resource, testmode): + before = len(fake_github.mutations()) + with for_resource("testorg/repo-a"): + ret = res_states["deploy_key.present"]("new-key", DEPLOY_KEY_B) + assert ret["result"] is not False + assert (ret["result"] is None) is testmode + assert ret["changes"] == {"created": "new-key"} + assert (len(fake_github.mutations()) == before) is testmode + titles = {key.title for key in fake_org.get_repo("repo-a").deploy_keys} + assert ("new-key" in titles) is not testmode + + +@pytest.mark.usefixtures("testmode") +def test_present_idempotent(res_states, fake_github, for_resource, fake_org): + existing = next(iter(fake_org.get_repo("repo-a").deploy_keys)) + with for_resource("testorg/repo-a"): + ret = res_states["deploy_key.present"]( + "existing-key", existing.key, read_only=existing.read_only + ) + assert ret["result"] is True + assert not ret["changes"] + assert not fake_github.mutations() + + +def test_absent(res_states, fake_github, fake_org, for_resource, testmode): + before = len(fake_github.mutations()) + with for_resource("testorg/repo-a"): + ret = res_states["deploy_key.absent"]("existing-key") + assert ret["result"] is not False + assert (ret["result"] is None) is testmode + assert ret["changes"] == {"deleted": "existing-key"} + assert (len(fake_github.mutations()) == before) is testmode + titles = {key.title for key in fake_org.get_repo("repo-a").deploy_keys} + assert ("existing-key" in titles) is testmode diff --git a/tests/functional/resources/states/test_environment.py b/tests/functional/resources/states/test_environment.py new file mode 100644 index 00000000..769eb888 --- /dev/null +++ b/tests/functional/resources/states/test_environment.py @@ -0,0 +1,33 @@ +import pytest + + +def test_present_create(res_states, fake_github, fake_org, for_resource, testmode): + before = len(fake_github.mutations()) + with for_resource("testorg/repo-a"): + ret = res_states["environment.present"]("staging", deployment_branches=["develop"]) + assert ret["result"] is not False + assert (ret["result"] is None) is testmode + assert ret["changes"]["created"] == "staging" + assert ret["changes"]["deployment_branches"] == {"added": ["develop"], "removed": []} + assert (len(fake_github.mutations()) == before) is testmode + environments = fake_org.get_repo("repo-a").environments + assert ("staging" in environments) is not testmode + + +@pytest.mark.usefixtures("testmode") +def test_present_idempotent(res_states, fake_github, for_resource): + with for_resource("testorg/repo-a"): + ret = res_states["environment.present"]("release", deployment_branches=["main"]) + assert ret["result"] is True + assert not ret["changes"] + assert not fake_github.mutations() + + +def test_absent(res_states, fake_org, for_resource, testmode): + with for_resource("testorg/repo-a"): + ret = res_states["environment.absent"]("release") + assert ret["result"] is not False + assert (ret["result"] is None) is testmode + assert ret["changes"] == {"deleted": "release"} + environments = fake_org.get_repo("repo-a").environments + assert ("release" in environments) is testmode diff --git a/tests/functional/resources/states/test_label.py b/tests/functional/resources/states/test_label.py new file mode 100644 index 00000000..21ec3168 --- /dev/null +++ b/tests/functional/resources/states/test_label.py @@ -0,0 +1,49 @@ +import pytest + + +def test_present_create(res_states, fake_github, fake_org, for_resource, testmode): + before = len(fake_github.mutations()) + with for_resource("testorg/repo-a"): + ret = res_states["label.present"]("release", color="#00FF00") + assert ret["result"] is not False + assert (ret["result"] is None) is testmode + assert ret["changes"] == {"created": "release"} + assert (len(fake_github.mutations()) == before) is testmode + labels = fake_org.get_repo("repo-a").labels + assert ("release" in labels) is not testmode + + +@pytest.mark.usefixtures("testmode") +def test_present_idempotent(res_states, fake_github, for_resource): + with for_resource("testorg/repo-a"): + ret = res_states["label.present"]("bug", color="#D73A4A") + assert ret["result"] is True + assert not ret["changes"] + assert not fake_github.mutations() + + +def test_state_reads_packed_resource(res_states, monkeypatch): + """ + Forward-compat: when Salt packs ``__resource__`` into state + overrides (as the authoring docs promise), it is preferred over the + ``repo.ident`` fallback. No resource contextvar is set here, so a + successful run proves the direct path was taken. + """ + monkeypatch.setitem( + res_states["label.present"].__globals__, + "__resource__", + {"id": "testorg/repo-a", "type": "github"}, + ) + ret = res_states["label.present"]("bug", color="#D73A4A") + assert ret["result"] is True + assert not ret["changes"] + + +def test_absent(res_states, fake_org, for_resource, testmode): + with for_resource("testorg/repo-a"): + ret = res_states["label.absent"]("bug") + assert ret["result"] is not False + assert (ret["result"] is None) is testmode + assert ret["changes"] == {"deleted": "bug"} + labels = fake_org.get_repo("repo-a").labels + assert ("bug" in labels) is testmode diff --git a/tests/functional/resources/states/test_pages.py b/tests/functional/resources/states/test_pages.py new file mode 100644 index 00000000..99f068a9 --- /dev/null +++ b/tests/functional/resources/states/test_pages.py @@ -0,0 +1,35 @@ +import pytest + + +def test_present_create(res_states, fake_github, fake_org, for_resource, testmode): + before = len(fake_github.mutations()) + with for_resource("testorg/repo-a"): + ret = res_states["pages.present"]("enable pages", build_type="workflow") + assert ret["result"] is not False + assert (ret["result"] is None) is testmode + assert ret["changes"] == {"created": "repo-a"} + assert (len(fake_github.mutations()) == before) is testmode + pages = fake_org.get_repo("repo-a").pages + assert (pages is not None) is not testmode + + +@pytest.mark.usefixtures("testmode") +def test_present_idempotent(res_states, fake_github, for_resource): + # repo-b has legacy pages from the default seed + with for_resource("testorg/repo-b"): + ret = res_states["pages.present"]( + "manage pages", build_type="legacy", source_branch="gh-pages", source_path="/" + ) + assert ret["result"] is True + assert not ret["changes"] + assert not fake_github.mutations() + + +def test_absent(res_states, fake_org, for_resource, testmode): + with for_resource("testorg/repo-b"): + ret = res_states["pages.absent"]("disable pages") + assert ret["result"] is not False + assert (ret["result"] is None) is testmode + assert ret["changes"] == {"deleted": "repo-b"} + pages = fake_org.get_repo("repo-b").pages + assert (pages is None) is not testmode diff --git a/tests/functional/resources/states/test_repo.py b/tests/functional/resources/states/test_repo.py new file mode 100644 index 00000000..d1987aac --- /dev/null +++ b/tests/functional/resources/states/test_repo.py @@ -0,0 +1,44 @@ +import pytest + + +def test_managed_update(res_states, fake_github, fake_org, for_resource, testmode): + before = len(fake_github.mutations()) + with for_resource("testorg/repo-a"): + ret = res_states["repo.managed"]("manage repo settings", description="Managed by Salt") + assert ret["result"] is not False + assert (ret["result"] is None) is testmode + assert ret["name"] == "manage repo settings" + assert ret["changes"]["new"] == {"description": "Managed by Salt"} + assert (len(fake_github.mutations()) == before) is testmode + expected = "Test repo repo-a" if testmode else "Managed by Salt" + assert fake_org.get_repo("repo-a").description == expected + + +@pytest.mark.usefixtures("testmode") +def test_managed_idempotent(res_states, fake_github, for_resource): + with for_resource("testorg/repo-a"): + ret = res_states["repo.managed"]("manage repo settings", description="Test repo repo-a") + assert ret["result"] is True + assert not ret["changes"] + assert not fake_github.mutations() + + +def test_managed_teams(res_states, fake_org, for_resource, testmode): + with for_resource("testorg/repo-b"): + ret = res_states["repo.managed"]("manage repo teams", teams={"Team A": "push"}) + assert ret["result"] is not False + assert (ret["result"] is None) is testmode + assert ret["changes"] == {"teams_added": ["Team A"]} + team_repos = fake_org.get_team("team-a").repos + assert ("repo-b" in team_repos) is not testmode + + +@pytest.mark.usefixtures("testmode") +def test_managed_never_creates(res_states, fake_github, for_resource): + before = len(fake_github.mutations()) + with for_resource("testorg/missing-repo"): + ret = res_states["repo.managed"]("manage repo settings", description="Nope") + assert ret["result"] is False + assert not ret["changes"] + assert "does not exist" in ret["comment"] + assert len(fake_github.mutations()) == before diff --git a/tests/functional/resources/states/test_ruleset.py b/tests/functional/resources/states/test_ruleset.py new file mode 100644 index 00000000..cc19a7b9 --- /dev/null +++ b/tests/functional/resources/states/test_ruleset.py @@ -0,0 +1,34 @@ +import pytest + + +def test_present_create(res_states, fake_github, fake_org, for_resource, testmode): + before = len(fake_github.mutations()) + with for_resource("testorg/repo-a"): + ret = res_states["ruleset.present"]("new-rules", ruleset_params={"enforcement": "active"}) + assert ret["result"] is not False + assert (ret["result"] is None) is testmode + assert ret["changes"] == {"created": "new-rules"} + assert (len(fake_github.mutations()) == before) is testmode + rulesets = fake_org.get_repo("repo-a").rulesets + assert ("new-rules" in rulesets) is not testmode + + +@pytest.mark.usefixtures("testmode") +def test_present_idempotent(res_states, fake_github, for_resource): + with for_resource("testorg/repo-a"): + ret = res_states["ruleset.present"]( + "repo-rules", ruleset_params={"enforcement": "disabled"} + ) + assert ret["result"] is True + assert not ret["changes"] + assert not fake_github.mutations() + + +def test_absent(res_states, fake_org, for_resource, testmode): + with for_resource("testorg/repo-a"): + ret = res_states["ruleset.absent"]("repo-rules") + assert ret["result"] is not False + assert (ret["result"] is None) is testmode + assert ret["changes"] == {"deleted": "repo-rules"} + rulesets = fake_org.get_repo("repo-a").rulesets + assert ("repo-rules" in rulesets) is testmode diff --git a/tests/functional/resources/test_connection.py b/tests/functional/resources/test_connection.py new file mode 100644 index 00000000..eabbd1da --- /dev/null +++ b/tests/functional/resources/test_connection.py @@ -0,0 +1,325 @@ +""" +Tests for the ``github`` resource type's connection module +(init/discover/grains/ping, pillar declaration handling, opts-based +profile resolution). +""" + +import copy + +import pytest +import salt.loader +from salt.exceptions import CommandExecutionError + +from saltext.github.utils.gh import profile as gh_profile + + +@pytest.fixture +def connection(resource_funcs, res_opts): + resource_funcs["github.init"](res_opts) + return resource_funcs + + +def test_virtual(resource_funcs): + assert "github.discover" in resource_funcs + + +def test_init_and_initialized(resource_funcs, res_opts): + assert resource_funcs["github.initialized"]() is False + resource_funcs["github.init"](res_opts) + assert resource_funcs["github.initialized"]() is True + resource_funcs["github.shutdown"](res_opts) + assert resource_funcs["github.initialized"]() is False + + +def test_init_missing_profile(resource_funcs, res_opts): + opts = copy.deepcopy(res_opts) + opts["pillar"]["resources"]["github"] = {"profile": "nonexistent"} + with pytest.raises(CommandExecutionError, match="could not be found.*nonexistent"): + resource_funcs["github.init"](opts) + + +def test_discover_default_owner(connection, res_opts): + # The profile's owner is testorg (via the legacy org_name alias) + assert connection["github.discover"](res_opts) == ["testorg/repo-a", "testorg/repo-b"] + + +def test_discover_multiple_owners(connection, res_opts): + opts = copy.deepcopy(res_opts) + opts["pillar"]["resources"]["github"]["owners"] = ["testorg", "user1"] + # user1's private user-repo is visible since user1 is the token's user + assert connection["github.discover"](opts) == [ + "testorg/repo-a", + "testorg/repo-b", + "user1/repo-a", + "user1/user-repo", + ] + + +def test_discover_include_patterns(connection, res_opts): + opts = copy.deepcopy(res_opts) + opts["pillar"]["resources"]["github"]["owners"] = ["testorg", "user1"] + # Patterns with a slash match the full name, others the bare name + opts["pillar"]["resources"]["github"]["repos"] = ["user1/*"] + assert connection["github.discover"](opts) == ["user1/repo-a", "user1/user-repo"] + opts["pillar"]["resources"]["github"]["repos"] = ["repo-a"] + assert connection["github.discover"](opts) == ["testorg/repo-a", "user1/repo-a"] + + +def test_discover_exclude_patterns(connection, res_opts): + opts = copy.deepcopy(res_opts) + opts["pillar"]["resources"]["github"]["exclude"] = ["repo-b"] + assert connection["github.discover"](opts) == ["testorg/repo-a"] + + +def test_discover_archived(connection, res_opts, fake_org): + fake_org.get_repo("repo-b").update(archived=True) + assert connection["github.discover"](res_opts) == ["testorg/repo-a"] + opts = copy.deepcopy(res_opts) + opts["pillar"]["resources"]["github"]["include_archived"] = True + assert connection["github.discover"](opts) == ["testorg/repo-a", "testorg/repo-b"] + + +def test_discover_per_owner_patterns(connection, res_opts): + """ + ``repos``/``exclude`` can be overridden per owner; owners without + their own value inherit the declaration-level one. + """ + opts = copy.deepcopy(res_opts) + opts["pillar"]["resources"]["github"]["repos"] = ["repo-*"] + opts["pillar"]["resources"]["github"]["owners"] = { + "testorg": {}, + "user1": {"repos": ["user-*"]}, + } + assert connection["github.discover"](opts) == [ + "testorg/repo-a", + "testorg/repo-b", + "user1/user-repo", + ] + opts["pillar"]["resources"]["github"]["owners"]["testorg"]["exclude"] = ["repo-b"] + assert connection["github.discover"](opts) == ["testorg/repo-a", "user1/user-repo"] + + +def test_discover_per_owner_archived(connection, res_opts, fake_org, fake_user): + fake_org.get_repo("repo-b").update(archived=True) + fake_user.get_repo("user-repo").update(archived=True) + opts = copy.deepcopy(res_opts) + opts["pillar"]["resources"]["github"]["owners"] = { + "testorg": {"include_archived": True}, + "user1": {}, + } + assert connection["github.discover"](opts) == [ + "testorg/repo-a", + "testorg/repo-b", + "user1/repo-a", + ] + + +def test_empty_decl_derived_user(resource_funcs, res_opts, github_profile, for_resource): + """ + A bare ``resources: github: {}`` declaration with a minimal default + profile (token only) manages the token user's repositories. + """ + opts = copy.deepcopy(res_opts) + opts["github"] = { + "token": github_profile["token"], + "api_url": github_profile["api_url"], + } + opts["pillar"]["resources"]["github"] = {} + resource_funcs["github.init"](opts) + assert resource_funcs["github.discover"](opts) == ["user1/repo-a", "user1/user-repo"] + with for_resource("user1/user-repo"): + grains = resource_funcs["github.grains"]() + assert grains["github"]["owner"] == "user1" + + +def test_empty_decl_none_value(resource_funcs, res_opts): + """ + ``resources: github:`` (an empty YAML value) behaves like an empty + declaration. + """ + opts = copy.deepcopy(res_opts) + opts["pillar"]["resources"]["github"] = None + resource_funcs["github.init"](opts) + # The default profile's owner (testorg via the org_name alias) applies + assert resource_funcs["github.discover"](opts) == ["testorg/repo-a", "testorg/repo-b"] + + +def test_empty_decl_profile_owner(resource_funcs, res_opts, github_profile): + """ + A bare declaration with an owner-scoped default profile manages + that owner's repositories. + """ + opts = copy.deepcopy(res_opts) + opts["github"] = { + "token": github_profile["token"], + "api_url": github_profile["api_url"], + "owner": "testorg", + } + opts["pillar"]["resources"]["github"] = {} + resource_funcs["github.init"](opts) + assert resource_funcs["github.discover"](opts) == ["testorg/repo-a", "testorg/repo-b"] + + +def test_decl_alternative_profile_with_exclude(resource_funcs, res_opts, github_profile): + """ + A declaration naming an alternative profile (token user derived) + plus root-level exclude patterns — without the default ``github`` + profile even existing. + """ + opts = copy.deepcopy(res_opts) + opts.pop("github") + opts.pop("github_noprivacy", None) + opts["github_repos"] = { + "token": github_profile["token"], + "api_url": github_profile["api_url"], + } + opts["pillar"]["resources"]["github"] = { + "profile": "github_repos", + "exclude": ["user-*"], + } + resource_funcs["github.init"](opts) + assert resource_funcs["github.discover"](opts) == ["user1/repo-a"] + + +def test_owner_setting_inheritance(res_opts): + opts = copy.deepcopy(res_opts) + opts["pillar"]["resources"]["github"].update( + { + "repos": ["root-*"], + "owners": {"testorg": {"repos": ["own-*"]}, "User1": {}}, + } + ) + # Owner override wins, case-insensitively + assert gh_profile.resource_owner_setting(opts, "TestOrg", "repos") == ["own-*"] + # Unset per-owner keys inherit the declaration level, then the default + assert gh_profile.resource_owner_setting(opts, "user1", "repos") == ["root-*"] + assert gh_profile.resource_owner_setting(opts, "user1", "exclude", []) == [] + # Owners not declared at all inherit the declaration level as well + assert gh_profile.resource_owner_setting(opts, "someone-else", "repos") == ["root-*"] + + +def test_grains(connection, for_resource): + with for_resource("testorg/repo-a"): + grains = connection["github.grains"]() + assert grains == { + "github": { + "id": "testorg/repo-a", + "owner": "testorg", + "repo": "repo-a", + "full_name": "testorg/repo-a", + "private": False, + "fork": False, + "default_branch": "main", + "language": "Python", + "html_url": "https://github.example.com/testorg/repo-a", + "description": "Test repo repo-a", + "homepage": None, + } + } + + +def test_grains_cold_context(res_opts, utils_loader, for_resource): + """ + State runs rebuild the connection module with a fresh context and + re-call init() (salt.state.State.load_modules), so grains must work + without any discover()-time warmup. + """ + cold_context = {} + funcs = salt.loader.resource( + res_opts, + utils=utils_loader, + context=cold_context, + loaded_base_name=f"{__name__}.cold", + ) + funcs["github.init"](res_opts) + with for_resource("user1/repo-a"): + grains = funcs["github.grains"]() + assert grains["github"]["description"] == "User-owned repo-a" + assert grains["github"]["owner"] == "user1" + + +def test_grains_identity_routing(connection, for_resource): + """ + The org repo and the user repo share the bare name; the owner half + of the resource ID must disambiguate. + """ + with for_resource("testorg/repo-a"): + org_grains = connection["github.grains"]() + with for_resource("user1/repo-a"): + user_grains = connection["github.grains"]() + assert org_grains["github"]["description"] == "Test repo repo-a" + assert user_grains["github"]["description"] == "User-owned repo-a" + + +def test_grains_refresh(connection, for_resource, fake_org): + with for_resource("testorg/repo-a"): + assert connection["github.grains"]()["github"]["description"] == "Test repo repo-a" + fake_org.get_repo("repo-a").update(description="Changed behind the cache") + # Plain grains() serves from the warmed cache + assert connection["github.grains"]()["github"]["description"] == "Test repo repo-a" + refreshed = connection["github.grains_refresh"]() + assert refreshed["github"]["description"] == "Changed behind the cache" + + +def test_ping(connection, for_resource): + with for_resource("testorg/repo-a"): + assert connection["github.ping"]() is True + with for_resource("testorg/missing-repo"): + assert connection["github.ping"]() is False + + +def test_profile_from_pillar(res_opts, github_profile): + """ + Profiles are resolved from pillar as well as the minion config. + """ + opts = copy.deepcopy(res_opts) + opts.pop("github") + with pytest.raises(CommandExecutionError, match="could not be found.*github"): + gh_profile.get_config_value("github", "token", opts=opts) + opts["pillar"]["github"] = dict(github_profile) + assert gh_profile.get_config_value("github", "token", opts=opts) == github_profile["token"] + + +def test_profile_from_grains_and_master_config(res_opts, github_profile): + """ + The remaining ``config.get`` sources work as well: grains and the + master configuration (shared via ``pillar_opts`` as + ``pillar["master"]``). + """ + opts = copy.deepcopy(res_opts) + opts.pop("github") + opts["grains"]["github"] = dict(github_profile) + assert gh_profile.get_config_value("github", "token", opts=opts) == github_profile["token"] + opts = copy.deepcopy(res_opts) + opts.pop("github") + opts["pillar"]["master"] = {"github": dict(github_profile)} + assert gh_profile.get_config_value("github", "token", opts=opts) == github_profile["token"] + + +def test_profile_lookup_order(res_opts, github_profile): + """ + Like ``config.get``: minion config wins over grains, grains over + pillar, pillar over master config — per value, so scopes missing an + item fall through to later ones. + """ + opts = copy.deepcopy(res_opts) + opts.pop("github") + opts["pillar"]["master"] = {"github": dict(github_profile, repo_name="from-master")} + opts["pillar"]["github"] = {"repo_name": "from-pillar"} + opts["grains"]["github"] = {"repo_name": "from-grains"} + assert gh_profile.get_config_value("github", "repo_name", opts=opts) == "from-grains" + opts["github"] = {"repo_name": "from-opts"} + assert gh_profile.get_config_value("github", "repo_name", opts=opts) == "from-opts" + # The grains/pillar profiles carry no token, the master one does + assert gh_profile.get_config_value("github", "token", opts=opts) == github_profile["token"] + + +def test_profile_sdb_rendering(res_opts, github_profile, monkeypatch): + """ + sdb:// URIs in profile values are rendered on access. + """ + monkeypatch.setenv("FAKE_GITHUB_TOKEN", github_profile["token"]) + opts = copy.deepcopy(res_opts) + opts["osenv"] = {"driver": "env"} + opts["github"] = dict(github_profile, token="sdb://osenv/FAKE_GITHUB_TOKEN") + assert gh_profile.get_config_value("github", "token", opts=opts) == github_profile["token"] diff --git a/tests/functional/resources/test_multi_profile.py b/tests/functional/resources/test_multi_profile.py new file mode 100644 index 00000000..53b9dfb4 --- /dev/null +++ b/tests/functional/resources/test_multi_profile.py @@ -0,0 +1,110 @@ +""" +Tests for per-owner profile overrides in the ``github`` resource +declaration (``owners`` as a mapping). +""" + +import copy + +import pytest +from salt.exceptions import CommandExecutionError + +from saltext.github.utils.gh import profile as gh_profile + + +@pytest.fixture(scope="module") +def resource_decl(): + return { + "profile": "github", + "owners": { + "testorg": {}, + "otherorg": {"profile": "github_other"}, + }, + } + + +@pytest.fixture(scope="module") +def res_opts(minion_opts, resource_decl, github_profile): + opts = copy.deepcopy(minion_opts) + opts["pillar"] = {"resources": {"github": resource_decl}} + # Second profile: same token/API URL (the fake serves one token), + # scoped to the other organization. + opts["github_other"] = { + "token": github_profile["token"], + "api_url": github_profile["api_url"], + "owner": "otherorg", + } + return opts + + +@pytest.fixture +def connection(resource_funcs, res_opts): + resource_funcs["github.init"](res_opts) + return resource_funcs + + +def test_owner_profile_resolution(res_opts): + assert gh_profile.resource_owner_profile(res_opts, "testorg") == "github" + assert gh_profile.resource_owner_profile(res_opts, "otherorg") == "github_other" + # Owner matching is case-insensitive + assert gh_profile.resource_owner_profile(res_opts, "OtherOrg") == "github_other" + # Undeclared owners fall back to the default profile + assert gh_profile.resource_owner_profile(res_opts, "someone-else") == "github" + assert gh_profile.resource_profiles(res_opts) == ["github", "github_other"] + + +def test_owners_mapping_and_list_forms(res_opts): + assert gh_profile.resource_owners(res_opts) == ["testorg", "otherorg"] + opts = copy.deepcopy(res_opts) + opts["pillar"]["resources"]["github"]["owners"] = ["testorg"] + assert gh_profile.resource_owners(opts) == ["testorg"] + assert gh_profile.resource_owner_profile(opts, "testorg") == "github" + assert gh_profile.resource_profiles(opts) == ["github"] + + +def test_init_validates_all_profiles(resource_funcs, res_opts): + opts = copy.deepcopy(res_opts) + opts["pillar"]["resources"]["github"]["owners"]["otherorg"]["profile"] = "missing_profile" + with pytest.raises(CommandExecutionError, match="could not be found.*missing_profile"): + resource_funcs["github.init"](opts) + + +def test_discover_union_across_profiles(connection, res_opts): + assert connection["github.discover"](res_opts) == [ + "otherorg/other-repo", + "testorg/repo-a", + "testorg/repo-b", + ] + + +def test_discover_uses_owner_profile(connection, res_opts): + """ + A broken per-owner profile must break exactly that owner's listing, + proving discovery resolves connections per owner. + """ + opts = copy.deepcopy(res_opts) + opts["github_bad"] = dict(opts["github_other"], token="wrong-token") + opts["pillar"]["resources"]["github"]["owners"]["otherorg"]["profile"] = "github_bad" + with pytest.raises(CommandExecutionError): + connection["github.discover"](opts) + # The default-profile owner still works on its own + opts["pillar"]["resources"]["github"]["owners"].pop("otherorg") + assert connection["github.discover"](opts) == ["testorg/repo-a", "testorg/repo-b"] + + +def test_grains_per_owner(connection, for_resource): + with for_resource("otherorg/other-repo"): + grains = connection["github.grains"]() + assert grains["github"]["full_name"] == "otherorg/other-repo" + assert grains["github"]["owner"] == "otherorg" + + +def test_exec_resolves_per_owner_profile(res_modules, for_resource, loader_context): + with for_resource("otherorg/other-repo"): + info = res_modules["repo.info"]() + assert info["owner"] == "otherorg" + # The connection was built from the github_other profile: its cache + # key carries that profile's owner. + conn_keys = [ + key for key in loader_context if isinstance(key, str) and key.startswith("github.") + ] + assert any(":otherorg:" in key for key in conn_keys) diff --git a/tests/support/github/entities.py b/tests/support/github/entities.py index 4f551d51..23833486 100644 --- a/tests/support/github/entities.py +++ b/tests/support/github/entities.py @@ -473,7 +473,7 @@ def payload(self, /, **ovrr): @dataclass -class RepoData(EntityData): +class RepoData(EntityData): # pylint: disable=too-many-instance-attributes name: RepoName owner: ActorName _: KW_ONLY @@ -482,6 +482,7 @@ class RepoData(EntityData): homepage: str | None = None private: bool = False fork: bool = False + archived: bool = False language: str = "Python" default_branch: str = "main" has_issues: bool = True