hermes-github-app-plugin 0.1.2__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. hermes_github_app_plugin-0.2.0/.github/dependabot.yml +13 -0
  2. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/.github/workflows/cd.yaml +6 -6
  3. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/.github/workflows/ci.yaml +2 -2
  4. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/PKG-INFO +12 -4
  5. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/README.md +11 -3
  6. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/plugin.yaml +4 -1
  7. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/pyproject.toml +1 -1
  8. {hermes_github_app_plugin-0.1.2/src/hermes_github_app_plugin → hermes_github_app_plugin-0.2.0}/skills/github-app-workflow/SKILL.md +11 -1
  9. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/src/hermes_github_app_plugin/auth.py +35 -13
  10. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/src/hermes_github_app_plugin/cli.py +37 -9
  11. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/src/hermes_github_app_plugin/config.py +53 -4
  12. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0/src/hermes_github_app_plugin}/skills/github-app-workflow/SKILL.md +11 -1
  13. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/src/hermes_github_app_plugin/tools.py +5 -2
  14. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/tests/test_auth.py +45 -0
  15. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/tests/test_cli.py +32 -0
  16. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/tests/test_config.py +25 -0
  17. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/.gitignore +0 -0
  18. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/LICENSE +0 -0
  19. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/src/hermes_github_app_plugin/__init__.py +0 -0
  20. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/src/hermes_github_app_plugin/py.typed +0 -0
  21. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/src/hermes_github_app_plugin/schemas.py +0 -0
  22. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/tests/test_plugin.py +0 -0
@@ -0,0 +1,13 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "pip"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "weekly"
7
+ open-pull-requests-limit: 10
8
+
9
+ - package-ecosystem: "github-actions"
10
+ directory: "/"
11
+ schedule:
12
+ interval: "weekly"
13
+ open-pull-requests-limit: 10
@@ -15,8 +15,8 @@ jobs:
15
15
  outputs:
16
16
  release_tag: ${{ steps.validate.outputs.release_tag }}
17
17
  steps:
18
- - uses: actions/checkout@v4
19
- - uses: actions/setup-python@v5
18
+ - uses: actions/checkout@v7
19
+ - uses: actions/setup-python@v6
20
20
  with:
21
21
  python-version: '3.12'
22
22
  - name: Validate tag and package version
@@ -47,8 +47,8 @@ jobs:
47
47
  if: needs.validate-tag.outputs.release_tag == 'true'
48
48
  runs-on: ubuntu-latest
49
49
  steps:
50
- - uses: actions/checkout@v4
51
- - uses: actions/setup-python@v5
50
+ - uses: actions/checkout@v7
51
+ - uses: actions/setup-python@v6
52
52
  with:
53
53
  python-version: '3.12'
54
54
  cache: pip
@@ -59,7 +59,7 @@ jobs:
59
59
  - name: Check distribution metadata
60
60
  run: python -m twine check dist/*
61
61
  - name: Upload distribution artifact
62
- uses: actions/upload-artifact@v4
62
+ uses: actions/upload-artifact@v7
63
63
  with:
64
64
  name: python-distribution
65
65
  path: dist/*
@@ -75,7 +75,7 @@ jobs:
75
75
  id-token: write
76
76
  steps:
77
77
  - name: Download distribution artifact
78
- uses: actions/download-artifact@v4
78
+ uses: actions/download-artifact@v8
79
79
  with:
80
80
  name: python-distribution
81
81
  path: dist
@@ -14,8 +14,8 @@ jobs:
14
14
  matrix:
15
15
  python-version: ["3.10", "3.12"]
16
16
  steps:
17
- - uses: actions/checkout@v4
18
- - uses: actions/setup-python@v5
17
+ - uses: actions/checkout@v7
18
+ - uses: actions/setup-python@v6
19
19
  with:
20
20
  python-version: ${{ matrix.python-version }}
21
21
  cache: pip
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hermes-github-app-plugin
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Hermes plugin for per-agent GitHub App identity, gh/git wrappers, and GitHub App-aware tools.
5
5
  Author: Hermes GitHub App Plugin Contributors
6
6
  License: MIT
@@ -26,7 +26,12 @@ Each Hermes agent runs the same package but is configured with its own GitHub Ap
26
26
  ```yaml
27
27
  github_app:
28
28
  client_id: "Iv1.exampleclientid"
29
+ # Optional default used when a command cannot infer OWNER/REPO.
29
30
  installation_id: "987654"
31
+ # Optional owner-specific installation IDs for apps installed in multiple orgs.
32
+ installation_ids:
33
+ ExampleOrg: "123456"
34
+ ExampleInfra: "987654"
30
35
  private_key_path: "~/.hermes/secrets/agent-github-app.private-key.pem"
31
36
  app_slug: "hermes-agent"
32
37
  ```
@@ -35,6 +40,7 @@ Environment variables with the same meaning are also supported:
35
40
 
36
41
  - `GITHUB_APP_CLIENT_ID`
37
42
  - `GITHUB_APP_INSTALLATION_ID`
43
+ - `GITHUB_APP_INSTALLATION_IDS` (JSON object or `OWNER=ID,OWNER2=ID2` mapping)
38
44
  - `GITHUB_APP_PRIVATE_KEY_PATH`
39
45
  - `GITHUB_APP_PRIVATE_KEY` (PEM contents; useful for CI)
40
46
 
@@ -46,7 +52,7 @@ Repository access is controlled by the GitHub App installation scope in GitHub.
46
52
 
47
53
  `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`.
48
54
 
49
- 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.
55
+ 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.
50
56
 
51
57
  ## Install
52
58
 
@@ -72,6 +78,8 @@ For scripted installs, pass flags and skip the network verification until secret
72
78
  hermes-github-app setup --non-interactive --skip-verify \
73
79
  --client-id Iv1.exampleclientid \
74
80
  --installation-id 987654 \
81
+ --installation-id-for ExampleOrg=123456 \
82
+ --installation-id-for ExampleInfra=987654 \
75
83
  --private-key-path ~/.hermes/secrets/agent-github-app.private-key.pem \
76
84
  --app-slug hermes-agent
77
85
  ```
@@ -127,8 +135,8 @@ Before the first release, configure PyPI Trusted Publishing for this repository
127
135
  Release example:
128
136
 
129
137
  ```bash
130
- git tag 0.1.2
131
- git push origin 0.1.2
138
+ git tag 0.2.0
139
+ git push origin 0.2.0
132
140
  ```
133
141
 
134
142
  Tags like `v0.1.0`, `0.1`, or `0.1.0rc1` will not publish.
@@ -7,7 +7,12 @@ Each Hermes agent runs the same package but is configured with its own GitHub Ap
7
7
  ```yaml
8
8
  github_app:
9
9
  client_id: "Iv1.exampleclientid"
10
+ # Optional default used when a command cannot infer OWNER/REPO.
10
11
  installation_id: "987654"
12
+ # Optional owner-specific installation IDs for apps installed in multiple orgs.
13
+ installation_ids:
14
+ ExampleOrg: "123456"
15
+ ExampleInfra: "987654"
11
16
  private_key_path: "~/.hermes/secrets/agent-github-app.private-key.pem"
12
17
  app_slug: "hermes-agent"
13
18
  ```
@@ -16,6 +21,7 @@ Environment variables with the same meaning are also supported:
16
21
 
17
22
  - `GITHUB_APP_CLIENT_ID`
18
23
  - `GITHUB_APP_INSTALLATION_ID`
24
+ - `GITHUB_APP_INSTALLATION_IDS` (JSON object or `OWNER=ID,OWNER2=ID2` mapping)
19
25
  - `GITHUB_APP_PRIVATE_KEY_PATH`
20
26
  - `GITHUB_APP_PRIVATE_KEY` (PEM contents; useful for CI)
21
27
 
@@ -27,7 +33,7 @@ Repository access is controlled by the GitHub App installation scope in GitHub.
27
33
 
28
34
  `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`.
29
35
 
30
- 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.
36
+ 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.
31
37
 
32
38
  ## Install
33
39
 
@@ -53,6 +59,8 @@ For scripted installs, pass flags and skip the network verification until secret
53
59
  hermes-github-app setup --non-interactive --skip-verify \
54
60
  --client-id Iv1.exampleclientid \
55
61
  --installation-id 987654 \
62
+ --installation-id-for ExampleOrg=123456 \
63
+ --installation-id-for ExampleInfra=987654 \
56
64
  --private-key-path ~/.hermes/secrets/agent-github-app.private-key.pem \
57
65
  --app-slug hermes-agent
58
66
  ```
@@ -108,8 +116,8 @@ Before the first release, configure PyPI Trusted Publishing for this repository
108
116
  Release example:
109
117
 
110
118
  ```bash
111
- git tag 0.1.2
112
- git push origin 0.1.2
119
+ git tag 0.2.0
120
+ git push origin 0.2.0
113
121
  ```
114
122
 
115
123
  Tags like `v0.1.0`, `0.1`, or `0.1.0rc1` will not publish.
@@ -20,7 +20,10 @@ requires_env:
20
20
  description: "GitHub App client ID for this Hermes agent. Can also be set in ~/.hermes/config.yaml under github_app.client_id."
21
21
  secret: false
22
22
  - name: GITHUB_APP_INSTALLATION_ID
23
- description: "GitHub App installation ID for this Hermes agent. Can also be set in ~/.hermes/config.yaml under github_app.installation_id."
23
+ description: "Default GitHub App installation ID for this Hermes agent. Can also be set in ~/.hermes/config.yaml under github_app.installation_id."
24
+ secret: false
25
+ - name: GITHUB_APP_INSTALLATION_IDS
26
+ 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."
24
27
  secret: false
25
28
  - name: GITHUB_APP_PRIVATE_KEY_PATH
26
29
  description: "Path to the GitHub App private key PEM. Can also be set in ~/.hermes/config.yaml under github_app.private_key_path."
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hermes-github-app-plugin"
7
- version = "0.1.2"
7
+ version = "0.2.0"
8
8
  description = "Hermes plugin for per-agent GitHub App identity, gh/git wrappers, and GitHub App-aware tools."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -11,7 +11,17 @@ Use this skill for GitHub operations from Hermes agents that are configured with
11
11
 
12
12
  ## First-time setup
13
13
 
14
- 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.
14
+ 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:
15
+
16
+ ```yaml
17
+ github_app:
18
+ installation_id: "123456" # default/fallback
19
+ installation_ids:
20
+ ExampleOrg: "123456"
21
+ ExampleInfra: "789012"
22
+ ```
23
+
24
+ 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.
15
25
 
16
26
  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.
17
27
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import re
5
6
  import time
6
7
  from dataclasses import dataclass
7
8
  from datetime import datetime, timedelta, timezone
@@ -40,7 +41,7 @@ class GitHubAppAuth:
40
41
  def __init__(self, config: GitHubAppConfig, client: httpx.Client | None = None) -> None:
41
42
  self._config = config
42
43
  self._client = client or httpx.Client(timeout=20)
43
- self._cached_token: InstallationToken | None = None
44
+ self._cached_tokens: dict[str, InstallationToken] = {}
44
45
 
45
46
  @property
46
47
  def config(self) -> GitHubAppConfig:
@@ -53,18 +54,21 @@ class GitHubAppAuth:
53
54
  encoded = jwt.encode(payload, self._config.private_key, algorithm="RS256")
54
55
  return str(encoded)
55
56
 
56
- def get_installation_token(self, *, force_refresh: bool = False) -> InstallationToken:
57
+ def get_installation_token(
58
+ self, *, repo: str | None = None, force_refresh: bool = False
59
+ ) -> InstallationToken:
57
60
  """Return a valid installation token, refreshing when near expiry."""
61
+ installation_id = self._config.installation_id_for_repo(repo)
62
+ cached_token = self._cached_tokens.get(installation_id)
58
63
  if (
59
64
  not force_refresh
60
- and self._cached_token is not None
61
- and self._cached_token.expires_at > datetime.now(timezone.utc) + timedelta(minutes=5)
65
+ and cached_token is not None
66
+ and cached_token.expires_at > datetime.now(timezone.utc) + timedelta(minutes=5)
62
67
  ):
63
- return self._cached_token
68
+ return cached_token
64
69
 
65
70
  response = self._client.post(
66
- f"{self._config.github_api_url}/app/installations/"
67
- f"{self._config.installation_id}/access_tokens",
71
+ f"{self._config.github_api_url}/app/installations/{installation_id}/access_tokens",
68
72
  headers={
69
73
  "Accept": "application/vnd.github+json",
70
74
  "Authorization": f"Bearer {self.create_jwt()}",
@@ -78,11 +82,11 @@ class GitHubAppAuth:
78
82
  token = InstallationToken(
79
83
  token=str(data["token"]),
80
84
  expires_at=expires_at,
81
- installation_id=self._config.installation_id,
85
+ installation_id=installation_id,
82
86
  client_id=self._config.client_id,
83
87
  app_slug=self._config.app_slug,
84
88
  )
85
- self._cached_token = token
89
+ self._cached_tokens[installation_id] = token
86
90
  return token
87
91
 
88
92
  def app_request(
@@ -120,6 +124,7 @@ class GitHubAppAuth:
120
124
  "client_id": self._config.client_id,
121
125
  "app_slug": self._config.app_slug,
122
126
  "installation_id": self._config.installation_id,
127
+ "installation_ids": dict(self._config.installation_ids),
123
128
  },
124
129
  "status_code": response.status_code,
125
130
  "result": response.json() if response.content else {"ok": True},
@@ -135,7 +140,8 @@ class GitHubAppAuth:
135
140
  params: dict[str, Any] | None = None,
136
141
  ) -> dict[str, Any]:
137
142
  """Call the GitHub REST API using the installation token."""
138
- token = self.get_installation_token()
143
+ repo = repo or repo_from_path(path)
144
+ token = self.get_installation_token(repo=repo)
139
145
  url = (
140
146
  path if path.startswith("http") else f"{self._config.github_api_url}/{path.lstrip('/')}"
141
147
  )
@@ -158,9 +164,11 @@ class GitHubAppAuth:
158
164
  "result": parsed,
159
165
  }
160
166
 
161
- def graphql(self, query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]:
167
+ def graphql(
168
+ self, query: str, variables: dict[str, Any] | None = None, *, repo: str | None = None
169
+ ) -> dict[str, Any]:
162
170
  """Call GitHub GraphQL API using the installation token."""
163
- token = self.get_installation_token()
171
+ token = self.get_installation_token(repo=repo)
164
172
  response = self._client.post(
165
173
  f"{self._config.github_api_url}/graphql",
166
174
  headers={
@@ -172,7 +180,7 @@ class GitHubAppAuth:
172
180
  )
173
181
  response.raise_for_status()
174
182
  return {
175
- "auth": auth_metadata(token),
183
+ "auth": auth_metadata(token, repo=repo),
176
184
  "status_code": response.status_code,
177
185
  "result": response.json(),
178
186
  }
@@ -203,3 +211,17 @@ def requires_app_jwt(path: str) -> bool:
203
211
  return False
204
212
  normalized = "/" + path_only.lstrip("/")
205
213
  return normalized == "/app" or normalized.startswith("/app/")
214
+
215
+
216
+ def repo_from_path(path: str) -> str | None:
217
+ """Infer OWNER/REPO from common REST API repository paths."""
218
+ path_only = path.split("?", 1)[0]
219
+ if path_only.startswith("http"):
220
+ try:
221
+ path_only = urlparse(path_only).path
222
+ except ValueError:
223
+ return None
224
+ match = re.match(r"^/?repos/([^/]+)/([^/]+)(?:/|$)", path_only)
225
+ if not match:
226
+ return None
227
+ return f"{match.group(1)}/{match.group(2)}"
@@ -29,7 +29,14 @@ def register_cli(parser: argparse.ArgumentParser) -> None:
29
29
  "--non-interactive", action="store_true", help="Read values from flags/env only"
30
30
  )
31
31
  setup.add_argument("--client-id", help="GitHub App client ID")
32
- setup.add_argument("--installation-id", help="GitHub App installation ID")
32
+ setup.add_argument("--installation-id", help="Default GitHub App installation ID")
33
+ setup.add_argument(
34
+ "--installation-id-for",
35
+ action="append",
36
+ default=[],
37
+ metavar="OWNER=ID",
38
+ help="Owner-specific installation ID mapping; repeat for multiple orgs",
39
+ )
33
40
  setup.add_argument("--private-key-path", help="Path to GitHub App private key PEM")
34
41
  setup.add_argument("--app-slug", help="Optional GitHub App slug, e.g. my-agent")
35
42
  setup.add_argument(
@@ -87,9 +94,9 @@ def gh_app_main() -> NoReturn:
87
94
  print("usage: gh-app [--repo OWNER/REPO] [--] <gh args...>")
88
95
  print("Runs gh with GH_TOKEN/GITHUB_TOKEN set to a GitHub App installation token.")
89
96
  raise SystemExit(0)
90
- _, child_args = _extract_repo(args)
97
+ repo, child_args = _extract_repo(args)
91
98
  config = load_config()
92
- token = GitHubAppAuth(config).get_installation_token()
99
+ token = GitHubAppAuth(config).get_installation_token(repo=repo)
93
100
  env = os.environ.copy()
94
101
  env["GH_TOKEN"] = token.token
95
102
  env["GITHUB_TOKEN"] = token.token
@@ -102,9 +109,9 @@ def git_app_main() -> NoReturn:
102
109
  print("usage: git-app [--repo OWNER/REPO] [--] <git args...>")
103
110
  print("Runs git with a temporary askpass helper backed by a GitHub App token.")
104
111
  raise SystemExit(0)
105
- _, child_args = _extract_repo(sys.argv[1:])
112
+ repo, child_args = _extract_repo(sys.argv[1:])
106
113
  config = load_config()
107
- token = GitHubAppAuth(config).get_installation_token()
114
+ token = GitHubAppAuth(config).get_installation_token(repo=repo)
108
115
  with tempfile.TemporaryDirectory(prefix="git-app-") as temp_dir:
109
116
  askpass = Path(temp_dir) / "askpass.sh"
110
117
  askpass.write_text(
@@ -127,6 +134,7 @@ def _setup(args: argparse.Namespace) -> int:
127
134
  """Interactively write GitHub App configuration."""
128
135
  print("Hermes GitHub App setup")
129
136
  print("Required values are unmarked. Optional prompts include '(optional)'.")
137
+ installation_ids = _parse_installation_id_for_args(list(args.installation_id_for))
130
138
  values = {
131
139
  "client_id": _value_or_prompt(
132
140
  args.client_id,
@@ -139,9 +147,10 @@ def _setup(args: argparse.Namespace) -> int:
139
147
  args.installation_id,
140
148
  "GitHub App installation ID",
141
149
  env="GITHUB_APP_INSTALLATION_ID",
142
- required=True,
150
+ required=not installation_ids,
143
151
  non_interactive=bool(args.non_interactive),
144
152
  ),
153
+ "installation_ids": installation_ids,
145
154
  "private_key_path": _value_or_prompt(
146
155
  args.private_key_path,
147
156
  "GitHub App private key path",
@@ -194,7 +203,7 @@ def _doctor(repo: str | None, *, skip_network: bool) -> int:
194
203
  )
195
204
  if not skip_network:
196
205
  auth = GitHubAppAuth(config)
197
- token = auth.get_installation_token(force_refresh=True)
206
+ token = auth.get_installation_token(repo=repo, force_refresh=True)
198
207
  checks.append(("installation token minted", True, token.redacted))
199
208
  app_result = auth.app_request("GET", "/app")["result"]
200
209
  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:
224
233
  def _status(repo: str | None) -> int:
225
234
  config = load_config()
226
235
  auth = GitHubAppAuth(config)
227
- token = auth.get_installation_token(force_refresh=True)
236
+ token = auth.get_installation_token(repo=repo, force_refresh=True)
228
237
  app = auth.app_request("GET", "/app")["result"]
229
238
  repo_probe = auth.request("GET", f"/repos/{repo}", repo=repo)["result"] if repo else None
230
239
  print(
@@ -244,7 +253,7 @@ def _status(repo: str | None) -> int:
244
253
 
245
254
  def _token(repo: str | None, *, json_output: bool) -> int:
246
255
  config = load_config()
247
- token = GitHubAppAuth(config).get_installation_token()
256
+ token = GitHubAppAuth(config).get_installation_token(repo=repo)
248
257
  if json_output:
249
258
  print(json.dumps({"token": token.token, "auth": auth_metadata(token, repo=repo)}, indent=2))
250
259
  else:
@@ -262,6 +271,25 @@ def _api(method: str, path: str, *, repo: str | None, body: dict[str, Any] | Non
262
271
  return 0
263
272
 
264
273
 
274
+ def _parse_installation_id_for_args(values: list[str]) -> dict[str, str]:
275
+ """Parse repeated OWNER=INSTALLATION_ID setup flags."""
276
+ installation_ids: dict[str, str] = {}
277
+ for value in values:
278
+ if "=" not in value:
279
+ raise ConfigurationError(
280
+ f"invalid --installation-id-for value: {value!r}; expected OWNER=INSTALLATION_ID"
281
+ )
282
+ owner, installation_id = value.split("=", 1)
283
+ owner = owner.strip().lower()
284
+ installation_id = installation_id.strip()
285
+ if not owner or not installation_id:
286
+ raise ConfigurationError(
287
+ f"invalid --installation-id-for value: {value!r}; expected OWNER=INSTALLATION_ID"
288
+ )
289
+ installation_ids[owner] = installation_id
290
+ return installation_ids
291
+
292
+
265
293
  def _value_or_prompt(
266
294
  value: str | None,
267
295
  label: str,
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import os
6
7
  from collections.abc import Mapping
7
8
  from dataclasses import dataclass
@@ -22,10 +23,20 @@ class GitHubAppConfig:
22
23
  client_id: str
23
24
  installation_id: str
24
25
  private_key: str
26
+ installation_ids: dict[str, str]
25
27
  private_key_source: str
26
28
  app_slug: str | None = None
27
29
  github_api_url: str = "https://api.github.com"
28
30
 
31
+ def installation_id_for_repo(self, repo: str | None = None) -> str:
32
+ """Return the installation ID for an optional OWNER/REPO target."""
33
+ if repo and "/" in repo:
34
+ owner = repo.split("/", 1)[0].lower()
35
+ installation_id = self.installation_ids.get(owner)
36
+ if installation_id:
37
+ return installation_id
38
+ return self.installation_id
39
+
29
40
 
30
41
  def hermes_home() -> Path:
31
42
  """Return the configured Hermes home directory."""
@@ -63,7 +74,7 @@ def write_github_app_config(values: Mapping[str, Any]) -> Path:
63
74
  section = {}
64
75
  data["github_app"] = section
65
76
  for key, value in values.items():
66
- if value in (None, "", (), []):
77
+ if value in (None, "", (), []) or (isinstance(value, Mapping) and not value):
67
78
  section.pop(key, None)
68
79
  else:
69
80
  section[key] = value
@@ -76,6 +87,37 @@ def write_github_app_config(values: Mapping[str, Any]) -> Path:
76
87
  return path
77
88
 
78
89
 
90
+ def _parse_installation_ids(raw: Any) -> dict[str, str]:
91
+ """Parse owner-to-installation-ID mapping from YAML or environment values."""
92
+ if raw in (None, ""):
93
+ return {}
94
+ parsed: Any = raw
95
+ if isinstance(raw, str):
96
+ try:
97
+ parsed = json.loads(raw)
98
+ except json.JSONDecodeError:
99
+ parsed = {}
100
+ for item in raw.replace("\n", ",").split(","):
101
+ if not item.strip():
102
+ continue
103
+ if "=" not in item:
104
+ raise ConfigurationError(
105
+ "invalid GITHUB_APP_INSTALLATION_IDS entry: "
106
+ f"{item!r}; expected OWNER=INSTALLATION_ID"
107
+ ) from None
108
+ owner, installation_id = item.split("=", 1)
109
+ parsed[owner.strip()] = installation_id.strip()
110
+ if not isinstance(parsed, Mapping):
111
+ raise ConfigurationError("github_app.installation_ids must be a mapping of owner to ID")
112
+ result: dict[str, str] = {}
113
+ for owner, installation_id in parsed.items():
114
+ owner_text = str(owner).strip().lower()
115
+ installation_text = str(installation_id).strip()
116
+ if owner_text and installation_text:
117
+ result[owner_text] = installation_text
118
+ return result
119
+
120
+
79
121
  def _read_private_key(section: Mapping[str, Any]) -> tuple[str, str]:
80
122
  inline_key = os.environ.get("GITHUB_APP_PRIVATE_KEY") or str(section.get("private_key", ""))
81
123
  if inline_key:
@@ -106,16 +148,23 @@ def load_config() -> GitHubAppConfig:
106
148
  raise ConfigurationError(
107
149
  "missing GitHub App client ID: set GITHUB_APP_CLIENT_ID or github_app.client_id"
108
150
  )
109
- if not installation_id:
151
+ installation_ids = _parse_installation_ids(
152
+ os.environ.get("GITHUB_APP_INSTALLATION_IDS") or section.get("installation_ids", {})
153
+ )
154
+ if not installation_id and not installation_ids:
110
155
  raise ConfigurationError(
111
- "missing GitHub App installation ID: set GITHUB_APP_INSTALLATION_ID "
112
- "or github_app.installation_id"
156
+ "missing GitHub App installation ID: set GITHUB_APP_INSTALLATION_ID, "
157
+ "github_app.installation_id, or github_app.installation_ids"
113
158
  )
159
+ if not installation_id:
160
+ # Use a deterministic fallback for commands that cannot infer an OWNER/REPO.
161
+ installation_id = next(iter(installation_ids.values()))
114
162
  private_key, private_key_source = _read_private_key(section)
115
163
  return GitHubAppConfig(
116
164
  client_id=client_id,
117
165
  installation_id=installation_id,
118
166
  private_key=private_key,
167
+ installation_ids=installation_ids,
119
168
  private_key_source=private_key_source,
120
169
  app_slug=os.environ.get("GITHUB_APP_SLUG") or section.get("app_slug"),
121
170
  github_api_url=os.environ.get("GITHUB_API_URL")
@@ -11,7 +11,17 @@ Use this skill for GitHub operations from Hermes agents that are configured with
11
11
 
12
12
  ## First-time setup
13
13
 
14
- 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.
14
+ 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:
15
+
16
+ ```yaml
17
+ github_app:
18
+ installation_id: "123456" # default/fallback
19
+ installation_ids:
20
+ ExampleOrg: "123456"
21
+ ExampleInfra: "789012"
22
+ ```
23
+
24
+ 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.
15
25
 
16
26
  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.
17
27
 
@@ -39,6 +39,7 @@ def github_app_status(params: dict[str, Any], **_: Any) -> str:
39
39
  "configured": True,
40
40
  "client_id": config.client_id,
41
41
  "installation_id": config.installation_id,
42
+ "installation_ids": dict(config.installation_ids),
42
43
  "app_slug": config.app_slug,
43
44
  "private_key_source": config.private_key_source,
44
45
  "github_api_url": config.github_api_url,
@@ -54,7 +55,7 @@ def github_app_verify_identity(params: dict[str, Any], **_: Any) -> str:
54
55
  def run() -> dict[str, Any]:
55
56
  repo = _repo(params)
56
57
  auth = _auth()
57
- token = auth.get_installation_token(force_refresh=True)
58
+ token = auth.get_installation_token(repo=repo, force_refresh=True)
58
59
  app = auth.app_request("GET", "/app")
59
60
  repo_probe = auth.request("GET", f"/repos/{repo}", repo=repo) if repo else None
60
61
  return {
@@ -92,7 +93,9 @@ def github_app_graphql(params: dict[str, Any], **_: Any) -> str:
92
93
  def run() -> dict[str, Any]:
93
94
  variables = params.get("variables")
94
95
  return _auth().graphql(
95
- str(params["query"]), variables if isinstance(variables, dict) else None
96
+ str(params["query"]),
97
+ variables if isinstance(variables, dict) else None,
98
+ repo=_repo(params),
96
99
  )
97
100
 
98
101
  return _handle_errors(run)
@@ -11,6 +11,7 @@ from hermes_github_app_plugin.auth import (
11
11
  GitHubAppAuth,
12
12
  InstallationToken,
13
13
  auth_metadata,
14
+ repo_from_path,
14
15
  requires_app_jwt,
15
16
  )
16
17
  from hermes_github_app_plugin.config import GitHubAppConfig
@@ -52,6 +53,7 @@ def test_request_includes_installation_token(monkeypatch) -> None: # type: igno
52
53
  client_id="123",
53
54
  installation_id="456",
54
55
  private_key=PRIVATE_KEY,
56
+ installation_ids={},
55
57
  private_key_source="env",
56
58
  app_slug="hermes-test-agent",
57
59
  )
@@ -79,6 +81,7 @@ def test_app_request_uses_app_jwt(monkeypatch) -> None: # type: ignore[no-untyp
79
81
  client_id="123",
80
82
  installation_id="456",
81
83
  private_key=PRIVATE_KEY,
84
+ installation_ids={},
82
85
  private_key_source="env",
83
86
  app_slug="hermes-test-agent",
84
87
  )
@@ -96,3 +99,45 @@ def test_requires_app_jwt_detects_app_endpoints() -> None:
96
99
  assert requires_app_jwt("/app/installations")
97
100
  assert requires_app_jwt("https://api.github.com/app")
98
101
  assert not requires_app_jwt("/repos/OWNER/REPO")
102
+
103
+
104
+ def test_request_chooses_installation_id_for_repo_owner(monkeypatch) -> None: # type: ignore[no-untyped-def]
105
+ monkeypatch.setattr("jwt.encode", lambda *_, **__: "jwt-token")
106
+ token_paths: list[str] = []
107
+
108
+ def handler(request: httpx.Request) -> httpx.Response:
109
+ if request.url.path.endswith("/access_tokens"):
110
+ token_paths.append(request.url.path)
111
+ installation_id = request.url.path.split("/")[-2]
112
+ return httpx.Response(
113
+ 201,
114
+ json={
115
+ "token": f"ghu_token_{installation_id}",
116
+ "expires_at": "2030-01-01T00:00:00Z",
117
+ },
118
+ )
119
+ return httpx.Response(200, json={"full_name": "ExampleInfra/automation-config"})
120
+
121
+ client = httpx.Client(transport=httpx.MockTransport(handler))
122
+ config = GitHubAppConfig(
123
+ client_id="123",
124
+ installation_id="456",
125
+ private_key=PRIVATE_KEY,
126
+ installation_ids={"exampleinfra": "789"},
127
+ private_key_source="env",
128
+ app_slug="hermes-test-agent",
129
+ )
130
+
131
+ result = GitHubAppAuth(config, client=client).request(
132
+ "GET", "/repos/ExampleInfra/automation-config"
133
+ )
134
+
135
+ assert token_paths == ["/app/installations/789/access_tokens"]
136
+ assert result["auth"]["installation_id"] == "789"
137
+ assert result["auth"]["repository"] == "ExampleInfra/automation-config"
138
+
139
+
140
+ def test_repo_from_path_infers_rest_repo_paths() -> None:
141
+ assert repo_from_path("/repos/OWNER/REPO/issues") == "OWNER/REPO"
142
+ assert repo_from_path("https://api.github.com/repos/OWNER/REPO") == "OWNER/REPO"
143
+ assert repo_from_path("/installation/repositories") is None
@@ -26,6 +26,7 @@ def test_setup_non_interactive_writes_config_and_skips_verify(
26
26
  github_app_command="setup",
27
27
  client_id="Iv1.exampleclientid",
28
28
  installation_id="987654",
29
+ installation_id_for=[],
29
30
  private_key_path=str(key_path),
30
31
  app_slug="hermes-test-agent",
31
32
  non_interactive=True,
@@ -65,6 +66,7 @@ def test_setup_prompts_mark_optional(
65
66
  github_app_command="setup",
66
67
  client_id=None,
67
68
  installation_id=None,
69
+ installation_id_for=[],
68
70
  private_key_path=None,
69
71
  app_slug=None,
70
72
  non_interactive=False,
@@ -158,3 +160,33 @@ def test_api_routes_repo_paths_to_installation_token(
158
160
 
159
161
  output = capsys.readouterr().out
160
162
  assert '"repo": "OWNER/REPO"' in output
163
+
164
+
165
+ def test_setup_non_interactive_writes_owner_installation_ids(
166
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
167
+ ) -> None:
168
+ hermes_home = tmp_path / ".hermes"
169
+ key_path = tmp_path / "app.pem"
170
+ key_path.write_text(PRIVATE_KEY, encoding="utf-8")
171
+ key_path.chmod(0o600)
172
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
173
+
174
+ args = argparse.Namespace(
175
+ github_app_command="setup",
176
+ client_id="Iv1.exampleclientid",
177
+ installation_id="987654",
178
+ installation_id_for=["ExampleOrg=111", "ExampleInfra=222"],
179
+ private_key_path=str(key_path),
180
+ app_slug="hermes-test-agent",
181
+ non_interactive=True,
182
+ repo=None,
183
+ skip_verify=True,
184
+ )
185
+
186
+ assert cli.main(args) == 0
187
+
188
+ data = yaml.safe_load((hermes_home / "config.yaml").read_text(encoding="utf-8"))
189
+ assert data["github_app"]["installation_ids"] == {
190
+ "exampleorg": "111",
191
+ "exampleinfra": "222",
192
+ }
@@ -20,6 +20,7 @@ def test_load_config_from_environment(monkeypatch: pytest.MonkeyPatch) -> None:
20
20
 
21
21
  assert config.client_id == "123"
22
22
  assert config.installation_id == "456"
23
+ assert config.installation_ids == {}
23
24
  assert config.private_key == PRIVATE_KEY
24
25
 
25
26
 
@@ -33,6 +34,9 @@ def test_load_config_from_hermes_yaml(tmp_path: Path, monkeypatch: pytest.Monkey
33
34
  github_app:
34
35
  client_id: 111
35
36
  installation_id: 222
37
+ installation_ids:
38
+ ExampleOrg: 333
39
+ ExampleInfra: 444
36
40
  private_key_path: {key_path}
37
41
  app_slug: hermes-test-agent
38
42
  """,
@@ -48,4 +52,25 @@ github_app:
48
52
 
49
53
  assert config.client_id == "111"
50
54
  assert config.installation_id == "222"
55
+ assert config.installation_ids == {"exampleorg": "333", "exampleinfra": "444"}
56
+ assert config.installation_id_for_repo("ExampleOrg/example-repo") == "333"
57
+ assert config.installation_id_for_repo("ExampleInfra/automation-config") == "444"
58
+ assert config.installation_id_for_repo("Other/repo") == "222"
51
59
  assert config.app_slug == "hermes-test-agent"
60
+
61
+
62
+ def test_load_config_installation_ids_from_environment(
63
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
64
+ ) -> None:
65
+ monkeypatch.setenv("GITHUB_APP_CLIENT_ID", "123")
66
+ monkeypatch.setenv("GITHUB_APP_INSTALLATION_IDS", "ExampleOrg=456,ExampleInfra=789")
67
+ monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", PRIVATE_KEY)
68
+ monkeypatch.delenv("GITHUB_APP_INSTALLATION_ID", raising=False)
69
+ hermes_home = tmp_path / ".hermes"
70
+ hermes_home.mkdir()
71
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
72
+
73
+ config = load_config()
74
+
75
+ assert config.installation_id == "456"
76
+ assert config.installation_id_for_repo("ExampleInfra/automation-config") == "789"