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.
- hermes_github_app_plugin-0.2.0/.github/dependabot.yml +13 -0
- {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/.github/workflows/cd.yaml +6 -6
- {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/.github/workflows/ci.yaml +2 -2
- {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/PKG-INFO +12 -4
- {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/README.md +11 -3
- {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/plugin.yaml +4 -1
- {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/pyproject.toml +1 -1
- {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
- {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/src/hermes_github_app_plugin/auth.py +35 -13
- {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/src/hermes_github_app_plugin/cli.py +37 -9
- {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/src/hermes_github_app_plugin/config.py +53 -4
- {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
- {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/src/hermes_github_app_plugin/tools.py +5 -2
- {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/tests/test_auth.py +45 -0
- {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/tests/test_cli.py +32 -0
- {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/tests/test_config.py +25 -0
- {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/.gitignore +0 -0
- {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/LICENSE +0 -0
- {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/src/hermes_github_app_plugin/__init__.py +0 -0
- {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/src/hermes_github_app_plugin/py.typed +0 -0
- {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.2.0}/src/hermes_github_app_plugin/schemas.py +0 -0
- {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@
|
|
19
|
-
- uses: actions/setup-python@
|
|
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@
|
|
51
|
-
- uses: actions/setup-python@
|
|
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@
|
|
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@
|
|
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@
|
|
18
|
-
- uses: actions/setup-python@
|
|
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.
|
|
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.
|
|
131
|
-
git push origin 0.
|
|
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.
|
|
112
|
-
git push origin 0.
|
|
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.
|
|
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
|
|
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.
|
|
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(
|
|
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
|
|
61
|
-
and
|
|
65
|
+
and cached_token is not None
|
|
66
|
+
and cached_token.expires_at > datetime.now(timezone.utc) + timedelta(minutes=5)
|
|
62
67
|
):
|
|
63
|
-
return
|
|
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=
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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.
|
|
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
|
|
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"]),
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|