From a9f12373905e4b0f47f68254a256817d4215a9be Mon Sep 17 00:00:00 2001 From: Marty McFly Date: Tue, 7 Jul 2026 22:09:01 +0000 Subject: [PATCH] Support owner-specific GitHub App installations --- README.md | 10 +++- plugin.yaml | 5 +- skills/github-app-workflow/SKILL.md | 12 +++- src/hermes_github_app_plugin/auth.py | 48 +++++++++++----- src/hermes_github_app_plugin/cli.py | 46 ++++++++++++--- src/hermes_github_app_plugin/config.py | 57 +++++++++++++++++-- .../skills/github-app-workflow/SKILL.md | 12 +++- src/hermes_github_app_plugin/tools.py | 7 ++- tests/test_auth.py | 45 +++++++++++++++ tests/test_cli.py | 32 +++++++++++ tests/test_config.py | 25 ++++++++ 11 files changed, 267 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 7b72d64..272f698 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,12 @@ Each Hermes agent runs the same package but is configured with its own GitHub Ap ```yaml github_app: client_id: "Iv1.exampleclientid" + # Optional default used when a command cannot infer OWNER/REPO. installation_id: "987654" + # Optional owner-specific installation IDs for apps installed in multiple orgs. + installation_ids: + ExampleOrg: "123456" + ExampleInfra: "987654" private_key_path: "~/.hermes/secrets/agent-github-app.private-key.pem" app_slug: "hermes-agent" ``` @@ -16,6 +21,7 @@ Environment variables with the same meaning are also supported: - `GITHUB_APP_CLIENT_ID` - `GITHUB_APP_INSTALLATION_ID` +- `GITHUB_APP_INSTALLATION_IDS` (JSON object or `OWNER=ID,OWNER2=ID2` mapping) - `GITHUB_APP_PRIVATE_KEY_PATH` - `GITHUB_APP_PRIVATE_KEY` (PEM contents; useful for CI) @@ -27,7 +33,7 @@ Repository access is controlled by the GitHub App installation scope in GitHub. `installation_id` identifies one installation of that app on a specific user or organization account. It is required when exchanging the app JWT for an installation access token via `POST /app/installations/{installation_id}/access_tokens`. -In other words: `client_id` answers "which GitHub App is signing this JWT?" while `installation_id` answers "which installed copy of that app should this token act as?" The same GitHub App can have multiple installation IDs if it is installed on multiple accounts. +In other words: `client_id` answers "which GitHub App is signing this JWT?" while `installation_id` answers "which installed copy of that app should this token act as?" The same GitHub App can have multiple installation IDs if it is installed on multiple accounts. Configure `installation_ids` when one app is installed in multiple organizations; the plugin chooses an owner-specific ID from `--repo OWNER/REPO` or `/repos/OWNER/REPO` REST paths and falls back to `installation_id` when no repository owner is available. ## Install @@ -53,6 +59,8 @@ For scripted installs, pass flags and skip the network verification until secret hermes-github-app setup --non-interactive --skip-verify \ --client-id Iv1.exampleclientid \ --installation-id 987654 \ + --installation-id-for ExampleOrg=123456 \ + --installation-id-for ExampleInfra=987654 \ --private-key-path ~/.hermes/secrets/agent-github-app.private-key.pem \ --app-slug hermes-agent ``` diff --git a/plugin.yaml b/plugin.yaml index 9a9d299..efb86d4 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -20,7 +20,10 @@ requires_env: description: "GitHub App client ID for this Hermes agent. Can also be set in ~/.hermes/config.yaml under github_app.client_id." secret: false - name: GITHUB_APP_INSTALLATION_ID - description: "GitHub App installation ID for this Hermes agent. Can also be set in ~/.hermes/config.yaml under github_app.installation_id." + description: "Default GitHub App installation ID for this Hermes agent. Can also be set in ~/.hermes/config.yaml under github_app.installation_id." + secret: false + - name: GITHUB_APP_INSTALLATION_IDS + description: "Optional owner-to-installation-ID mapping for apps installed in multiple orgs. JSON object or OWNER=ID,OWNER2=ID2. Can also be set under github_app.installation_ids." secret: false - name: GITHUB_APP_PRIVATE_KEY_PATH description: "Path to the GitHub App private key PEM. Can also be set in ~/.hermes/config.yaml under github_app.private_key_path." diff --git a/skills/github-app-workflow/SKILL.md b/skills/github-app-workflow/SKILL.md index b5ad793..57597bd 100644 --- a/skills/github-app-workflow/SKILL.md +++ b/skills/github-app-workflow/SKILL.md @@ -11,7 +11,17 @@ Use this skill for GitHub operations from Hermes agents that are configured with ## First-time setup -Run `hermes-github-app setup` to write `github_app` config into `~/.hermes/config.yaml`. The setup walkthrough marks optional values with `(optional)`; the required values are GitHub App client ID, installation ID, and private key path. +Run `hermes-github-app setup` to write `github_app` config into `~/.hermes/config.yaml`. The setup walkthrough marks optional values with `(optional)`; the required values are GitHub App client ID, a default installation ID or owner-specific `installation_ids`, and private key path. For apps installed in multiple organizations, pass repeated `--installation-id-for OWNER=ID` flags or configure: + +```yaml +github_app: + installation_id: "123456" # default/fallback + installation_ids: + ExampleOrg: "123456" + ExampleInfra: "789012" +``` + +The plugin chooses an owner-specific installation ID from `--repo OWNER/REPO` or `/repos/OWNER/REPO` REST API paths and falls back to `installation_id` when no repository owner is available. After setup, run `hermes-github-app doctor --repo OWNER/REPO` to verify console scripts, config loading, private-key permissions, token minting, and repository access. Use `--skip-network` only for container/image builds where secrets or network access are not available yet. diff --git a/src/hermes_github_app_plugin/auth.py b/src/hermes_github_app_plugin/auth.py index 079e0ac..2046266 100644 --- a/src/hermes_github_app_plugin/auth.py +++ b/src/hermes_github_app_plugin/auth.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re import time from dataclasses import dataclass from datetime import datetime, timedelta, timezone @@ -40,7 +41,7 @@ class GitHubAppAuth: def __init__(self, config: GitHubAppConfig, client: httpx.Client | None = None) -> None: self._config = config self._client = client or httpx.Client(timeout=20) - self._cached_token: InstallationToken | None = None + self._cached_tokens: dict[str, InstallationToken] = {} @property def config(self) -> GitHubAppConfig: @@ -53,18 +54,21 @@ def create_jwt(self) -> str: encoded = jwt.encode(payload, self._config.private_key, algorithm="RS256") return str(encoded) - def get_installation_token(self, *, force_refresh: bool = False) -> InstallationToken: + def get_installation_token( + self, *, repo: str | None = None, force_refresh: bool = False + ) -> InstallationToken: """Return a valid installation token, refreshing when near expiry.""" + installation_id = self._config.installation_id_for_repo(repo) + cached_token = self._cached_tokens.get(installation_id) if ( not force_refresh - and self._cached_token is not None - and self._cached_token.expires_at > datetime.now(timezone.utc) + timedelta(minutes=5) + and cached_token is not None + and cached_token.expires_at > datetime.now(timezone.utc) + timedelta(minutes=5) ): - return self._cached_token + return cached_token response = self._client.post( - f"{self._config.github_api_url}/app/installations/" - f"{self._config.installation_id}/access_tokens", + f"{self._config.github_api_url}/app/installations/{installation_id}/access_tokens", headers={ "Accept": "application/vnd.github+json", "Authorization": f"Bearer {self.create_jwt()}", @@ -78,11 +82,11 @@ def get_installation_token(self, *, force_refresh: bool = False) -> Installation token = InstallationToken( token=str(data["token"]), expires_at=expires_at, - installation_id=self._config.installation_id, + installation_id=installation_id, client_id=self._config.client_id, app_slug=self._config.app_slug, ) - self._cached_token = token + self._cached_tokens[installation_id] = token return token def app_request( @@ -120,6 +124,7 @@ def app_request( "client_id": self._config.client_id, "app_slug": self._config.app_slug, "installation_id": self._config.installation_id, + "installation_ids": dict(self._config.installation_ids), }, "status_code": response.status_code, "result": response.json() if response.content else {"ok": True}, @@ -135,7 +140,8 @@ def request( params: dict[str, Any] | None = None, ) -> dict[str, Any]: """Call the GitHub REST API using the installation token.""" - token = self.get_installation_token() + repo = repo or repo_from_path(path) + token = self.get_installation_token(repo=repo) url = ( path if path.startswith("http") else f"{self._config.github_api_url}/{path.lstrip('/')}" ) @@ -158,9 +164,11 @@ def request( "result": parsed, } - def graphql(self, query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]: + def graphql( + self, query: str, variables: dict[str, Any] | None = None, *, repo: str | None = None + ) -> dict[str, Any]: """Call GitHub GraphQL API using the installation token.""" - token = self.get_installation_token() + token = self.get_installation_token(repo=repo) response = self._client.post( f"{self._config.github_api_url}/graphql", headers={ @@ -172,7 +180,7 @@ def graphql(self, query: str, variables: dict[str, Any] | None = None) -> dict[s ) response.raise_for_status() return { - "auth": auth_metadata(token), + "auth": auth_metadata(token, repo=repo), "status_code": response.status_code, "result": response.json(), } @@ -203,3 +211,17 @@ def requires_app_jwt(path: str) -> bool: return False normalized = "/" + path_only.lstrip("/") return normalized == "/app" or normalized.startswith("/app/") + + +def repo_from_path(path: str) -> str | None: + """Infer OWNER/REPO from common REST API repository paths.""" + path_only = path.split("?", 1)[0] + if path_only.startswith("http"): + try: + path_only = urlparse(path_only).path + except ValueError: + return None + match = re.match(r"^/?repos/([^/]+)/([^/]+)(?:/|$)", path_only) + if not match: + return None + return f"{match.group(1)}/{match.group(2)}" diff --git a/src/hermes_github_app_plugin/cli.py b/src/hermes_github_app_plugin/cli.py index 700f36f..5aa76de 100644 --- a/src/hermes_github_app_plugin/cli.py +++ b/src/hermes_github_app_plugin/cli.py @@ -29,7 +29,14 @@ def register_cli(parser: argparse.ArgumentParser) -> None: "--non-interactive", action="store_true", help="Read values from flags/env only" ) setup.add_argument("--client-id", help="GitHub App client ID") - setup.add_argument("--installation-id", help="GitHub App installation ID") + setup.add_argument("--installation-id", help="Default GitHub App installation ID") + setup.add_argument( + "--installation-id-for", + action="append", + default=[], + metavar="OWNER=ID", + help="Owner-specific installation ID mapping; repeat for multiple orgs", + ) setup.add_argument("--private-key-path", help="Path to GitHub App private key PEM") setup.add_argument("--app-slug", help="Optional GitHub App slug, e.g. my-agent") setup.add_argument( @@ -87,9 +94,9 @@ def gh_app_main() -> NoReturn: print("usage: gh-app [--repo OWNER/REPO] [--] ") print("Runs gh with GH_TOKEN/GITHUB_TOKEN set to a GitHub App installation token.") raise SystemExit(0) - _, child_args = _extract_repo(args) + repo, child_args = _extract_repo(args) config = load_config() - token = GitHubAppAuth(config).get_installation_token() + token = GitHubAppAuth(config).get_installation_token(repo=repo) env = os.environ.copy() env["GH_TOKEN"] = token.token env["GITHUB_TOKEN"] = token.token @@ -102,9 +109,9 @@ def git_app_main() -> NoReturn: print("usage: git-app [--repo OWNER/REPO] [--] ") print("Runs git with a temporary askpass helper backed by a GitHub App token.") raise SystemExit(0) - _, child_args = _extract_repo(sys.argv[1:]) + repo, child_args = _extract_repo(sys.argv[1:]) config = load_config() - token = GitHubAppAuth(config).get_installation_token() + token = GitHubAppAuth(config).get_installation_token(repo=repo) with tempfile.TemporaryDirectory(prefix="git-app-") as temp_dir: askpass = Path(temp_dir) / "askpass.sh" askpass.write_text( @@ -127,6 +134,7 @@ def _setup(args: argparse.Namespace) -> int: """Interactively write GitHub App configuration.""" print("Hermes GitHub App setup") print("Required values are unmarked. Optional prompts include '(optional)'.") + installation_ids = _parse_installation_id_for_args(list(args.installation_id_for)) values = { "client_id": _value_or_prompt( args.client_id, @@ -139,9 +147,10 @@ def _setup(args: argparse.Namespace) -> int: args.installation_id, "GitHub App installation ID", env="GITHUB_APP_INSTALLATION_ID", - required=True, + required=not installation_ids, non_interactive=bool(args.non_interactive), ), + "installation_ids": installation_ids, "private_key_path": _value_or_prompt( args.private_key_path, "GitHub App private key path", @@ -194,7 +203,7 @@ def _doctor(repo: str | None, *, skip_network: bool) -> int: ) if not skip_network: auth = GitHubAppAuth(config) - token = auth.get_installation_token(force_refresh=True) + token = auth.get_installation_token(repo=repo, force_refresh=True) checks.append(("installation token minted", True, token.redacted)) app_result = auth.app_request("GET", "/app")["result"] checks.append(("/app API reachable", True, str(app_result.get("slug", "ok")))) @@ -224,7 +233,7 @@ def _doctor(repo: str | None, *, skip_network: bool) -> int: def _status(repo: str | None) -> int: config = load_config() auth = GitHubAppAuth(config) - token = auth.get_installation_token(force_refresh=True) + token = auth.get_installation_token(repo=repo, force_refresh=True) app = auth.app_request("GET", "/app")["result"] repo_probe = auth.request("GET", f"/repos/{repo}", repo=repo)["result"] if repo else None print( @@ -244,7 +253,7 @@ def _status(repo: str | None) -> int: def _token(repo: str | None, *, json_output: bool) -> int: config = load_config() - token = GitHubAppAuth(config).get_installation_token() + token = GitHubAppAuth(config).get_installation_token(repo=repo) if json_output: print(json.dumps({"token": token.token, "auth": auth_metadata(token, repo=repo)}, indent=2)) else: @@ -262,6 +271,25 @@ def _api(method: str, path: str, *, repo: str | None, body: dict[str, Any] | Non return 0 +def _parse_installation_id_for_args(values: list[str]) -> dict[str, str]: + """Parse repeated OWNER=INSTALLATION_ID setup flags.""" + installation_ids: dict[str, str] = {} + for value in values: + if "=" not in value: + raise ConfigurationError( + f"invalid --installation-id-for value: {value!r}; expected OWNER=INSTALLATION_ID" + ) + owner, installation_id = value.split("=", 1) + owner = owner.strip().lower() + installation_id = installation_id.strip() + if not owner or not installation_id: + raise ConfigurationError( + f"invalid --installation-id-for value: {value!r}; expected OWNER=INSTALLATION_ID" + ) + installation_ids[owner] = installation_id + return installation_ids + + def _value_or_prompt( value: str | None, label: str, diff --git a/src/hermes_github_app_plugin/config.py b/src/hermes_github_app_plugin/config.py index 72a39be..79eaa93 100644 --- a/src/hermes_github_app_plugin/config.py +++ b/src/hermes_github_app_plugin/config.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import os from collections.abc import Mapping from dataclasses import dataclass @@ -22,10 +23,20 @@ class GitHubAppConfig: client_id: str installation_id: str private_key: str + installation_ids: dict[str, str] private_key_source: str app_slug: str | None = None github_api_url: str = "https://api.github.com" + def installation_id_for_repo(self, repo: str | None = None) -> str: + """Return the installation ID for an optional OWNER/REPO target.""" + if repo and "/" in repo: + owner = repo.split("/", 1)[0].lower() + installation_id = self.installation_ids.get(owner) + if installation_id: + return installation_id + return self.installation_id + def hermes_home() -> Path: """Return the configured Hermes home directory.""" @@ -63,7 +74,7 @@ def write_github_app_config(values: Mapping[str, Any]) -> Path: section = {} data["github_app"] = section for key, value in values.items(): - if value in (None, "", (), []): + if value in (None, "", (), []) or (isinstance(value, Mapping) and not value): section.pop(key, None) else: section[key] = value @@ -76,6 +87,37 @@ def write_github_app_config(values: Mapping[str, Any]) -> Path: return path +def _parse_installation_ids(raw: Any) -> dict[str, str]: + """Parse owner-to-installation-ID mapping from YAML or environment values.""" + if raw in (None, ""): + return {} + parsed: Any = raw + if isinstance(raw, str): + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + parsed = {} + for item in raw.replace("\n", ",").split(","): + if not item.strip(): + continue + if "=" not in item: + raise ConfigurationError( + "invalid GITHUB_APP_INSTALLATION_IDS entry: " + f"{item!r}; expected OWNER=INSTALLATION_ID" + ) from None + owner, installation_id = item.split("=", 1) + parsed[owner.strip()] = installation_id.strip() + if not isinstance(parsed, Mapping): + raise ConfigurationError("github_app.installation_ids must be a mapping of owner to ID") + result: dict[str, str] = {} + for owner, installation_id in parsed.items(): + owner_text = str(owner).strip().lower() + installation_text = str(installation_id).strip() + if owner_text and installation_text: + result[owner_text] = installation_text + return result + + def _read_private_key(section: Mapping[str, Any]) -> tuple[str, str]: inline_key = os.environ.get("GITHUB_APP_PRIVATE_KEY") or str(section.get("private_key", "")) if inline_key: @@ -106,16 +148,23 @@ def load_config() -> GitHubAppConfig: raise ConfigurationError( "missing GitHub App client ID: set GITHUB_APP_CLIENT_ID or github_app.client_id" ) - if not installation_id: + installation_ids = _parse_installation_ids( + os.environ.get("GITHUB_APP_INSTALLATION_IDS") or section.get("installation_ids", {}) + ) + if not installation_id and not installation_ids: raise ConfigurationError( - "missing GitHub App installation ID: set GITHUB_APP_INSTALLATION_ID " - "or github_app.installation_id" + "missing GitHub App installation ID: set GITHUB_APP_INSTALLATION_ID, " + "github_app.installation_id, or github_app.installation_ids" ) + if not installation_id: + # Use a deterministic fallback for commands that cannot infer an OWNER/REPO. + installation_id = next(iter(installation_ids.values())) private_key, private_key_source = _read_private_key(section) return GitHubAppConfig( client_id=client_id, installation_id=installation_id, private_key=private_key, + installation_ids=installation_ids, private_key_source=private_key_source, app_slug=os.environ.get("GITHUB_APP_SLUG") or section.get("app_slug"), github_api_url=os.environ.get("GITHUB_API_URL") diff --git a/src/hermes_github_app_plugin/skills/github-app-workflow/SKILL.md b/src/hermes_github_app_plugin/skills/github-app-workflow/SKILL.md index b5ad793..57597bd 100644 --- a/src/hermes_github_app_plugin/skills/github-app-workflow/SKILL.md +++ b/src/hermes_github_app_plugin/skills/github-app-workflow/SKILL.md @@ -11,7 +11,17 @@ Use this skill for GitHub operations from Hermes agents that are configured with ## First-time setup -Run `hermes-github-app setup` to write `github_app` config into `~/.hermes/config.yaml`. The setup walkthrough marks optional values with `(optional)`; the required values are GitHub App client ID, installation ID, and private key path. +Run `hermes-github-app setup` to write `github_app` config into `~/.hermes/config.yaml`. The setup walkthrough marks optional values with `(optional)`; the required values are GitHub App client ID, a default installation ID or owner-specific `installation_ids`, and private key path. For apps installed in multiple organizations, pass repeated `--installation-id-for OWNER=ID` flags or configure: + +```yaml +github_app: + installation_id: "123456" # default/fallback + installation_ids: + ExampleOrg: "123456" + ExampleInfra: "789012" +``` + +The plugin chooses an owner-specific installation ID from `--repo OWNER/REPO` or `/repos/OWNER/REPO` REST API paths and falls back to `installation_id` when no repository owner is available. After setup, run `hermes-github-app doctor --repo OWNER/REPO` to verify console scripts, config loading, private-key permissions, token minting, and repository access. Use `--skip-network` only for container/image builds where secrets or network access are not available yet. diff --git a/src/hermes_github_app_plugin/tools.py b/src/hermes_github_app_plugin/tools.py index 4be975a..8ab5c90 100644 --- a/src/hermes_github_app_plugin/tools.py +++ b/src/hermes_github_app_plugin/tools.py @@ -39,6 +39,7 @@ def run() -> dict[str, Any]: "configured": True, "client_id": config.client_id, "installation_id": config.installation_id, + "installation_ids": dict(config.installation_ids), "app_slug": config.app_slug, "private_key_source": config.private_key_source, "github_api_url": config.github_api_url, @@ -54,7 +55,7 @@ def github_app_verify_identity(params: dict[str, Any], **_: Any) -> str: def run() -> dict[str, Any]: repo = _repo(params) auth = _auth() - token = auth.get_installation_token(force_refresh=True) + token = auth.get_installation_token(repo=repo, force_refresh=True) app = auth.app_request("GET", "/app") repo_probe = auth.request("GET", f"/repos/{repo}", repo=repo) if repo else None return { @@ -92,7 +93,9 @@ def github_app_graphql(params: dict[str, Any], **_: Any) -> str: def run() -> dict[str, Any]: variables = params.get("variables") return _auth().graphql( - str(params["query"]), variables if isinstance(variables, dict) else None + str(params["query"]), + variables if isinstance(variables, dict) else None, + repo=_repo(params), ) return _handle_errors(run) diff --git a/tests/test_auth.py b/tests/test_auth.py index 78fb388..1d00675 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -11,6 +11,7 @@ GitHubAppAuth, InstallationToken, auth_metadata, + repo_from_path, requires_app_jwt, ) from hermes_github_app_plugin.config import GitHubAppConfig @@ -52,6 +53,7 @@ def handler(request: httpx.Request) -> httpx.Response: client_id="123", installation_id="456", private_key=PRIVATE_KEY, + installation_ids={}, private_key_source="env", app_slug="hermes-test-agent", ) @@ -79,6 +81,7 @@ def handler(request: httpx.Request) -> httpx.Response: client_id="123", installation_id="456", private_key=PRIVATE_KEY, + installation_ids={}, private_key_source="env", app_slug="hermes-test-agent", ) @@ -96,3 +99,45 @@ def test_requires_app_jwt_detects_app_endpoints() -> None: assert requires_app_jwt("/app/installations") assert requires_app_jwt("https://api.github.com/app") assert not requires_app_jwt("/repos/OWNER/REPO") + + +def test_request_chooses_installation_id_for_repo_owner(monkeypatch) -> None: # type: ignore[no-untyped-def] + monkeypatch.setattr("jwt.encode", lambda *_, **__: "jwt-token") + token_paths: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/access_tokens"): + token_paths.append(request.url.path) + installation_id = request.url.path.split("/")[-2] + return httpx.Response( + 201, + json={ + "token": f"ghu_token_{installation_id}", + "expires_at": "2030-01-01T00:00:00Z", + }, + ) + return httpx.Response(200, json={"full_name": "ExampleInfra/automation-config"}) + + client = httpx.Client(transport=httpx.MockTransport(handler)) + config = GitHubAppConfig( + client_id="123", + installation_id="456", + private_key=PRIVATE_KEY, + installation_ids={"exampleinfra": "789"}, + private_key_source="env", + app_slug="hermes-test-agent", + ) + + result = GitHubAppAuth(config, client=client).request( + "GET", "/repos/ExampleInfra/automation-config" + ) + + assert token_paths == ["/app/installations/789/access_tokens"] + assert result["auth"]["installation_id"] == "789" + assert result["auth"]["repository"] == "ExampleInfra/automation-config" + + +def test_repo_from_path_infers_rest_repo_paths() -> None: + assert repo_from_path("/repos/OWNER/REPO/issues") == "OWNER/REPO" + assert repo_from_path("https://api.github.com/repos/OWNER/REPO") == "OWNER/REPO" + assert repo_from_path("/installation/repositories") is None diff --git a/tests/test_cli.py b/tests/test_cli.py index 142cc44..5c8ca55 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -26,6 +26,7 @@ def test_setup_non_interactive_writes_config_and_skips_verify( github_app_command="setup", client_id="Iv1.exampleclientid", installation_id="987654", + installation_id_for=[], private_key_path=str(key_path), app_slug="hermes-test-agent", non_interactive=True, @@ -65,6 +66,7 @@ def fake_input(prompt: str) -> str: github_app_command="setup", client_id=None, installation_id=None, + installation_id_for=[], private_key_path=None, app_slug=None, non_interactive=False, @@ -158,3 +160,33 @@ def request( output = capsys.readouterr().out assert '"repo": "OWNER/REPO"' in output + + +def test_setup_non_interactive_writes_owner_installation_ids( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + hermes_home = tmp_path / ".hermes" + key_path = tmp_path / "app.pem" + key_path.write_text(PRIVATE_KEY, encoding="utf-8") + key_path.chmod(0o600) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + args = argparse.Namespace( + github_app_command="setup", + client_id="Iv1.exampleclientid", + installation_id="987654", + installation_id_for=["ExampleOrg=111", "ExampleInfra=222"], + private_key_path=str(key_path), + app_slug="hermes-test-agent", + non_interactive=True, + repo=None, + skip_verify=True, + ) + + assert cli.main(args) == 0 + + data = yaml.safe_load((hermes_home / "config.yaml").read_text(encoding="utf-8")) + assert data["github_app"]["installation_ids"] == { + "exampleorg": "111", + "exampleinfra": "222", + } diff --git a/tests/test_config.py b/tests/test_config.py index 6b6b3bf..3a2fd59 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -20,6 +20,7 @@ def test_load_config_from_environment(monkeypatch: pytest.MonkeyPatch) -> None: assert config.client_id == "123" assert config.installation_id == "456" + assert config.installation_ids == {} assert config.private_key == PRIVATE_KEY @@ -33,6 +34,9 @@ def test_load_config_from_hermes_yaml(tmp_path: Path, monkeypatch: pytest.Monkey github_app: client_id: 111 installation_id: 222 + installation_ids: + ExampleOrg: 333 + ExampleInfra: 444 private_key_path: {key_path} app_slug: hermes-test-agent """, @@ -48,4 +52,25 @@ def test_load_config_from_hermes_yaml(tmp_path: Path, monkeypatch: pytest.Monkey assert config.client_id == "111" assert config.installation_id == "222" + assert config.installation_ids == {"exampleorg": "333", "exampleinfra": "444"} + assert config.installation_id_for_repo("ExampleOrg/example-repo") == "333" + assert config.installation_id_for_repo("ExampleInfra/automation-config") == "444" + assert config.installation_id_for_repo("Other/repo") == "222" assert config.app_slug == "hermes-test-agent" + + +def test_load_config_installation_ids_from_environment( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("GITHUB_APP_CLIENT_ID", "123") + monkeypatch.setenv("GITHUB_APP_INSTALLATION_IDS", "ExampleOrg=456,ExampleInfra=789") + monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", PRIVATE_KEY) + monkeypatch.delenv("GITHUB_APP_INSTALLATION_ID", raising=False) + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_config() + + assert config.installation_id == "456" + assert config.installation_id_for_repo("ExampleInfra/automation-config") == "789"