hermes-github-app-plugin 0.1.1__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.1 → hermes_github_app_plugin-0.2.0}/.github/workflows/cd.yaml +6 -6
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.2.0}/.github/workflows/ci.yaml +2 -2
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.2.0}/PKG-INFO +12 -4
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.2.0}/README.md +11 -3
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.2.0}/plugin.yaml +4 -1
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.2.0}/pyproject.toml +1 -1
- {hermes_github_app_plugin-0.1.1/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.1 → hermes_github_app_plugin-0.2.0}/src/hermes_github_app_plugin/auth.py +88 -13
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.2.0}/src/hermes_github_app_plugin/cli.py +45 -13
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.2.0}/src/hermes_github_app_plugin/config.py +53 -4
- {hermes_github_app_plugin-0.1.1 → 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.1 → hermes_github_app_plugin-0.2.0}/src/hermes_github_app_plugin/tools.py +13 -5
- hermes_github_app_plugin-0.2.0/tests/test_auth.py +143 -0
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.2.0}/tests/test_cli.py +85 -1
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.2.0}/tests/test_config.py +25 -0
- hermes_github_app_plugin-0.1.1/tests/test_auth.py +0 -61
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.2.0}/.gitignore +0 -0
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.2.0}/LICENSE +0 -0
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.2.0}/src/hermes_github_app_plugin/__init__.py +0 -0
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.2.0}/src/hermes_github_app_plugin/py.typed +0 -0
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.2.0}/src/hermes_github_app_plugin/schemas.py +0 -0
- {hermes_github_app_plugin-0.1.1 → 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,10 +2,12 @@
|
|
|
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
|
|
8
9
|
from typing import Any
|
|
10
|
+
from urllib.parse import urlparse
|
|
9
11
|
|
|
10
12
|
import httpx
|
|
11
13
|
import jwt
|
|
@@ -39,7 +41,7 @@ class GitHubAppAuth:
|
|
|
39
41
|
def __init__(self, config: GitHubAppConfig, client: httpx.Client | None = None) -> None:
|
|
40
42
|
self._config = config
|
|
41
43
|
self._client = client or httpx.Client(timeout=20)
|
|
42
|
-
self.
|
|
44
|
+
self._cached_tokens: dict[str, InstallationToken] = {}
|
|
43
45
|
|
|
44
46
|
@property
|
|
45
47
|
def config(self) -> GitHubAppConfig:
|
|
@@ -52,18 +54,21 @@ class GitHubAppAuth:
|
|
|
52
54
|
encoded = jwt.encode(payload, self._config.private_key, algorithm="RS256")
|
|
53
55
|
return str(encoded)
|
|
54
56
|
|
|
55
|
-
def get_installation_token(
|
|
57
|
+
def get_installation_token(
|
|
58
|
+
self, *, repo: str | None = None, force_refresh: bool = False
|
|
59
|
+
) -> InstallationToken:
|
|
56
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)
|
|
57
63
|
if (
|
|
58
64
|
not force_refresh
|
|
59
|
-
and
|
|
60
|
-
and
|
|
65
|
+
and cached_token is not None
|
|
66
|
+
and cached_token.expires_at > datetime.now(timezone.utc) + timedelta(minutes=5)
|
|
61
67
|
):
|
|
62
|
-
return
|
|
68
|
+
return cached_token
|
|
63
69
|
|
|
64
70
|
response = self._client.post(
|
|
65
|
-
f"{self._config.github_api_url}/app/installations/"
|
|
66
|
-
f"{self._config.installation_id}/access_tokens",
|
|
71
|
+
f"{self._config.github_api_url}/app/installations/{installation_id}/access_tokens",
|
|
67
72
|
headers={
|
|
68
73
|
"Accept": "application/vnd.github+json",
|
|
69
74
|
"Authorization": f"Bearer {self.create_jwt()}",
|
|
@@ -77,13 +82,54 @@ class GitHubAppAuth:
|
|
|
77
82
|
token = InstallationToken(
|
|
78
83
|
token=str(data["token"]),
|
|
79
84
|
expires_at=expires_at,
|
|
80
|
-
installation_id=
|
|
85
|
+
installation_id=installation_id,
|
|
81
86
|
client_id=self._config.client_id,
|
|
82
87
|
app_slug=self._config.app_slug,
|
|
83
88
|
)
|
|
84
|
-
self.
|
|
89
|
+
self._cached_tokens[installation_id] = token
|
|
85
90
|
return token
|
|
86
91
|
|
|
92
|
+
def app_request(
|
|
93
|
+
self,
|
|
94
|
+
method: str,
|
|
95
|
+
path: str,
|
|
96
|
+
*,
|
|
97
|
+
json_body: dict[str, Any] | None = None,
|
|
98
|
+
params: dict[str, Any] | None = None,
|
|
99
|
+
) -> dict[str, Any]:
|
|
100
|
+
"""Call a GitHub App endpoint using the app JWT.
|
|
101
|
+
|
|
102
|
+
Endpoints like `GET /app` authenticate as the GitHub App itself and
|
|
103
|
+
reject installation access tokens. Repository and user endpoints should
|
|
104
|
+
continue to use `request()`, which authenticates as the installation.
|
|
105
|
+
"""
|
|
106
|
+
url = (
|
|
107
|
+
path if path.startswith("http") else f"{self._config.github_api_url}/{path.lstrip('/')}"
|
|
108
|
+
)
|
|
109
|
+
response = self._client.request(
|
|
110
|
+
method.upper(),
|
|
111
|
+
url,
|
|
112
|
+
headers={
|
|
113
|
+
"Accept": "application/vnd.github+json",
|
|
114
|
+
"Authorization": f"Bearer {self.create_jwt()}",
|
|
115
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
116
|
+
},
|
|
117
|
+
json=json_body,
|
|
118
|
+
params=params,
|
|
119
|
+
)
|
|
120
|
+
response.raise_for_status()
|
|
121
|
+
return {
|
|
122
|
+
"auth": {
|
|
123
|
+
"auth_mode": "github_app_jwt",
|
|
124
|
+
"client_id": self._config.client_id,
|
|
125
|
+
"app_slug": self._config.app_slug,
|
|
126
|
+
"installation_id": self._config.installation_id,
|
|
127
|
+
"installation_ids": dict(self._config.installation_ids),
|
|
128
|
+
},
|
|
129
|
+
"status_code": response.status_code,
|
|
130
|
+
"result": response.json() if response.content else {"ok": True},
|
|
131
|
+
}
|
|
132
|
+
|
|
87
133
|
def request(
|
|
88
134
|
self,
|
|
89
135
|
method: str,
|
|
@@ -94,7 +140,8 @@ class GitHubAppAuth:
|
|
|
94
140
|
params: dict[str, Any] | None = None,
|
|
95
141
|
) -> dict[str, Any]:
|
|
96
142
|
"""Call the GitHub REST API using the installation token."""
|
|
97
|
-
|
|
143
|
+
repo = repo or repo_from_path(path)
|
|
144
|
+
token = self.get_installation_token(repo=repo)
|
|
98
145
|
url = (
|
|
99
146
|
path if path.startswith("http") else f"{self._config.github_api_url}/{path.lstrip('/')}"
|
|
100
147
|
)
|
|
@@ -117,9 +164,11 @@ class GitHubAppAuth:
|
|
|
117
164
|
"result": parsed,
|
|
118
165
|
}
|
|
119
166
|
|
|
120
|
-
def graphql(
|
|
167
|
+
def graphql(
|
|
168
|
+
self, query: str, variables: dict[str, Any] | None = None, *, repo: str | None = None
|
|
169
|
+
) -> dict[str, Any]:
|
|
121
170
|
"""Call GitHub GraphQL API using the installation token."""
|
|
122
|
-
token = self.get_installation_token()
|
|
171
|
+
token = self.get_installation_token(repo=repo)
|
|
123
172
|
response = self._client.post(
|
|
124
173
|
f"{self._config.github_api_url}/graphql",
|
|
125
174
|
headers={
|
|
@@ -131,7 +180,7 @@ class GitHubAppAuth:
|
|
|
131
180
|
)
|
|
132
181
|
response.raise_for_status()
|
|
133
182
|
return {
|
|
134
|
-
"auth": auth_metadata(token),
|
|
183
|
+
"auth": auth_metadata(token, repo=repo),
|
|
135
184
|
"status_code": response.status_code,
|
|
136
185
|
"result": response.json(),
|
|
137
186
|
}
|
|
@@ -150,3 +199,29 @@ def auth_metadata(token: InstallationToken, *, repo: str | None = None) -> dict[
|
|
|
150
199
|
"token": token.redacted,
|
|
151
200
|
"expires_at": token.expires_at.isoformat(),
|
|
152
201
|
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def requires_app_jwt(path: str) -> bool:
|
|
205
|
+
"""Return True for GitHub App endpoints that require app JWT auth."""
|
|
206
|
+
path_only = path.split("?", 1)[0]
|
|
207
|
+
if path_only.startswith("http"):
|
|
208
|
+
try:
|
|
209
|
+
path_only = urlparse(path_only).path
|
|
210
|
+
except ValueError:
|
|
211
|
+
return False
|
|
212
|
+
normalized = "/" + path_only.lstrip("/")
|
|
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)}"
|
|
@@ -15,7 +15,7 @@ from typing import Any, NoReturn
|
|
|
15
15
|
|
|
16
16
|
import httpx
|
|
17
17
|
|
|
18
|
-
from .auth import GitHubAppAuth, auth_metadata
|
|
18
|
+
from .auth import GitHubAppAuth, auth_metadata, requires_app_jwt
|
|
19
19
|
from .config import ConfigurationError, load_config, write_github_app_config
|
|
20
20
|
|
|
21
21
|
|
|
@@ -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,9 +203,9 @@ 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
|
-
app_result = auth.
|
|
208
|
+
app_result = auth.app_request("GET", "/app")["result"]
|
|
200
209
|
checks.append(("/app API reachable", True, str(app_result.get("slug", "ok"))))
|
|
201
210
|
if repo:
|
|
202
211
|
repo_result = auth.request("GET", f"/repos/{repo}", repo=repo)["result"]
|
|
@@ -224,8 +233,8 @@ 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)
|
|
228
|
-
app = auth.
|
|
236
|
+
token = auth.get_installation_token(repo=repo, force_refresh=True)
|
|
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(
|
|
231
240
|
json.dumps(
|
|
@@ -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:
|
|
@@ -253,11 +262,34 @@ def _token(repo: str | None, *, json_output: bool) -> int:
|
|
|
253
262
|
|
|
254
263
|
|
|
255
264
|
def _api(method: str, path: str, *, repo: str | None, body: dict[str, Any] | None) -> int:
|
|
256
|
-
|
|
265
|
+
auth = GitHubAppAuth(load_config())
|
|
266
|
+
if requires_app_jwt(path):
|
|
267
|
+
result = auth.app_request(method, path, json_body=body)
|
|
268
|
+
else:
|
|
269
|
+
result = auth.request(method, path, repo=repo, json_body=body)
|
|
257
270
|
print(json.dumps(result, indent=2, sort_keys=True))
|
|
258
271
|
return 0
|
|
259
272
|
|
|
260
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
|
+
|
|
261
293
|
def _value_or_prompt(
|
|
262
294
|
value: str | None,
|
|
263
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
|
|
|
@@ -7,7 +7,7 @@ from typing import Any
|
|
|
7
7
|
|
|
8
8
|
import httpx
|
|
9
9
|
|
|
10
|
-
from .auth import GitHubAppAuth, auth_metadata
|
|
10
|
+
from .auth import GitHubAppAuth, auth_metadata, requires_app_jwt
|
|
11
11
|
from .config import ConfigurationError, load_config
|
|
12
12
|
|
|
13
13
|
|
|
@@ -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,8 +55,8 @@ 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
|
-
app = auth.
|
|
58
|
+
token = auth.get_installation_token(repo=repo, force_refresh=True)
|
|
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 {
|
|
61
62
|
"auth": auth_metadata(token, repo=repo),
|
|
@@ -75,7 +76,12 @@ def github_app_api(params: dict[str, Any], **_: Any) -> str:
|
|
|
75
76
|
repo = _repo(params)
|
|
76
77
|
body = params.get("json_body")
|
|
77
78
|
json_body = body if isinstance(body, dict) else None
|
|
78
|
-
|
|
79
|
+
auth = _auth()
|
|
80
|
+
result = (
|
|
81
|
+
auth.app_request(method, path, json_body=json_body)
|
|
82
|
+
if requires_app_jwt(path)
|
|
83
|
+
else auth.request(method, path, repo=repo, json_body=json_body)
|
|
84
|
+
)
|
|
79
85
|
return result
|
|
80
86
|
|
|
81
87
|
return _handle_errors(run)
|
|
@@ -87,7 +93,9 @@ def github_app_graphql(params: dict[str, Any], **_: Any) -> str:
|
|
|
87
93
|
def run() -> dict[str, Any]:
|
|
88
94
|
variables = params.get("variables")
|
|
89
95
|
return _auth().graphql(
|
|
90
|
-
str(params["query"]),
|
|
96
|
+
str(params["query"]),
|
|
97
|
+
variables if isinstance(variables, dict) else None,
|
|
98
|
+
repo=_repo(params),
|
|
91
99
|
)
|
|
92
100
|
|
|
93
101
|
return _handle_errors(run)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Tests for GitHub App auth helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from hermes_github_app_plugin.auth import (
|
|
11
|
+
GitHubAppAuth,
|
|
12
|
+
InstallationToken,
|
|
13
|
+
auth_metadata,
|
|
14
|
+
repo_from_path,
|
|
15
|
+
requires_app_jwt,
|
|
16
|
+
)
|
|
17
|
+
from hermes_github_app_plugin.config import GitHubAppConfig
|
|
18
|
+
|
|
19
|
+
PRIVATE_KEY = "[REDACTED PRIVATE KEY]\n"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_auth_metadata_redacts_token() -> None:
|
|
23
|
+
token = InstallationToken(
|
|
24
|
+
token="ghu_ab...wxyz",
|
|
25
|
+
expires_at=datetime(2030, 1, 1, tzinfo=timezone.utc),
|
|
26
|
+
installation_id="456",
|
|
27
|
+
client_id="123",
|
|
28
|
+
app_slug="hermes-test-agent",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
metadata = auth_metadata(token, repo="ExampleOrg/example-repo")
|
|
32
|
+
|
|
33
|
+
assert metadata["auth_mode"] == "github_app"
|
|
34
|
+
assert metadata["actor_expected"] == "hermes-test-agent[bot]"
|
|
35
|
+
assert metadata["token"] == "ghu_…wxyz"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_request_includes_installation_token(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
|
39
|
+
monkeypatch.setattr("jwt.encode", lambda *_, **__: "jwt-token")
|
|
40
|
+
seen: list[httpx.Request] = []
|
|
41
|
+
|
|
42
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
43
|
+
seen.append(request)
|
|
44
|
+
if request.url.path.endswith("/access_tokens"):
|
|
45
|
+
return httpx.Response(
|
|
46
|
+
201,
|
|
47
|
+
json={"token": "ghu_installation_token", "expires_at": "2030-01-01T00:00:00Z"},
|
|
48
|
+
)
|
|
49
|
+
return httpx.Response(200, json={"full_name": "ExampleOrg/example-repo"})
|
|
50
|
+
|
|
51
|
+
client = httpx.Client(transport=httpx.MockTransport(handler))
|
|
52
|
+
config = GitHubAppConfig(
|
|
53
|
+
client_id="123",
|
|
54
|
+
installation_id="456",
|
|
55
|
+
private_key=PRIVATE_KEY,
|
|
56
|
+
installation_ids={},
|
|
57
|
+
private_key_source="env",
|
|
58
|
+
app_slug="hermes-test-agent",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
result = GitHubAppAuth(config, client=client).request(
|
|
62
|
+
"GET", "/repos/ExampleOrg/example-repo", repo="ExampleOrg/example-repo"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
assert result["result"] == {"full_name": "ExampleOrg/example-repo"}
|
|
66
|
+
assert seen[0].headers["authorization"] == "Bearer jwt-token"
|
|
67
|
+
assert seen[1].headers["authorization"] == "Bearer ghu_installation_token"
|
|
68
|
+
assert json.loads(json.dumps(result))["auth"]["auth_mode"] == "github_app"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_app_request_uses_app_jwt(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
|
72
|
+
monkeypatch.setattr("jwt.encode", lambda *_, **__: "jwt-token")
|
|
73
|
+
seen: list[httpx.Request] = []
|
|
74
|
+
|
|
75
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
76
|
+
seen.append(request)
|
|
77
|
+
return httpx.Response(200, json={"slug": "hermes-test-agent"})
|
|
78
|
+
|
|
79
|
+
client = httpx.Client(transport=httpx.MockTransport(handler))
|
|
80
|
+
config = GitHubAppConfig(
|
|
81
|
+
client_id="123",
|
|
82
|
+
installation_id="456",
|
|
83
|
+
private_key=PRIVATE_KEY,
|
|
84
|
+
installation_ids={},
|
|
85
|
+
private_key_source="env",
|
|
86
|
+
app_slug="hermes-test-agent",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
result = GitHubAppAuth(config, client=client).app_request("GET", "/app")
|
|
90
|
+
|
|
91
|
+
assert result["result"] == {"slug": "hermes-test-agent"}
|
|
92
|
+
assert seen[0].url.path == "/app"
|
|
93
|
+
assert seen[0].headers["authorization"] == "Bearer jwt-token"
|
|
94
|
+
assert result["auth"]["auth_mode"] == "github_app_jwt"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_requires_app_jwt_detects_app_endpoints() -> None:
|
|
98
|
+
assert requires_app_jwt("/app")
|
|
99
|
+
assert requires_app_jwt("/app/installations")
|
|
100
|
+
assert requires_app_jwt("https://api.github.com/app")
|
|
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,
|
|
@@ -88,7 +90,7 @@ def test_doctor_skip_network_reports_local_checks(
|
|
|
88
90
|
key_path.write_text(PRIVATE_KEY, encoding="utf-8")
|
|
89
91
|
key_path.chmod(0o600)
|
|
90
92
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
91
|
-
|
|
93
|
+
hermes_home.mkdir()
|
|
92
94
|
(hermes_home / "config.yaml").write_text(
|
|
93
95
|
f"""
|
|
94
96
|
github_app:
|
|
@@ -106,3 +108,85 @@ github_app:
|
|
|
106
108
|
assert "✓ GitHub App config loaded" in output
|
|
107
109
|
assert "✓ private key file permissions: 0o600" in output
|
|
108
110
|
assert "Local doctor passed" in output
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_api_routes_app_paths_to_app_jwt(
|
|
114
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
115
|
+
) -> None:
|
|
116
|
+
class FakeAuth:
|
|
117
|
+
def __init__(self, config: object) -> None:
|
|
118
|
+
self.config = config
|
|
119
|
+
|
|
120
|
+
def app_request(
|
|
121
|
+
self, method: str, path: str, json_body: dict[str, object] | None = None
|
|
122
|
+
) -> dict[str, object]:
|
|
123
|
+
return {"status_code": 200, "result": {"path": path, "method": method}}
|
|
124
|
+
|
|
125
|
+
def request(self, *args: object, **kwargs: object) -> dict[str, object]:
|
|
126
|
+
raise AssertionError("installation-token request should not be used for /app")
|
|
127
|
+
|
|
128
|
+
monkeypatch.setattr(cli, "load_config", object)
|
|
129
|
+
monkeypatch.setattr(cli, "GitHubAppAuth", FakeAuth)
|
|
130
|
+
|
|
131
|
+
assert cli._api("GET", "/app", repo=None, body=None) == 0
|
|
132
|
+
|
|
133
|
+
output = capsys.readouterr().out
|
|
134
|
+
assert '"status_code": 200' in output
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_api_routes_repo_paths_to_installation_token(
|
|
138
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
139
|
+
) -> None:
|
|
140
|
+
class FakeAuth:
|
|
141
|
+
def __init__(self, config: object) -> None:
|
|
142
|
+
self.config = config
|
|
143
|
+
|
|
144
|
+
def app_request(self, *args: object, **kwargs: object) -> dict[str, object]:
|
|
145
|
+
raise AssertionError("app JWT request should not be used for repo APIs")
|
|
146
|
+
|
|
147
|
+
def request(
|
|
148
|
+
self,
|
|
149
|
+
method: str,
|
|
150
|
+
path: str,
|
|
151
|
+
repo: str | None = None,
|
|
152
|
+
json_body: dict[str, object] | None = None,
|
|
153
|
+
) -> dict[str, object]:
|
|
154
|
+
return {"status_code": 200, "result": {"path": path, "repo": repo, "method": method}}
|
|
155
|
+
|
|
156
|
+
monkeypatch.setattr(cli, "load_config", object)
|
|
157
|
+
monkeypatch.setattr(cli, "GitHubAppAuth", FakeAuth)
|
|
158
|
+
|
|
159
|
+
assert cli._api("GET", "/repos/OWNER/REPO", repo="OWNER/REPO", body=None) == 0
|
|
160
|
+
|
|
161
|
+
output = capsys.readouterr().out
|
|
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"
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
"""Tests for GitHub App auth helpers."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
from datetime import datetime, timezone
|
|
7
|
-
|
|
8
|
-
import httpx
|
|
9
|
-
|
|
10
|
-
from hermes_github_app_plugin.auth import GitHubAppAuth, InstallationToken, auth_metadata
|
|
11
|
-
from hermes_github_app_plugin.config import GitHubAppConfig
|
|
12
|
-
|
|
13
|
-
PRIVATE_KEY = "[REDACTED PRIVATE KEY]\n"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def test_auth_metadata_redacts_token() -> None:
|
|
17
|
-
token = InstallationToken(
|
|
18
|
-
token="ghu_ab...wxyz",
|
|
19
|
-
expires_at=datetime(2030, 1, 1, tzinfo=timezone.utc),
|
|
20
|
-
installation_id="456",
|
|
21
|
-
client_id="123",
|
|
22
|
-
app_slug="hermes-test-agent",
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
metadata = auth_metadata(token, repo="ExampleOrg/example-repo")
|
|
26
|
-
|
|
27
|
-
assert metadata["auth_mode"] == "github_app"
|
|
28
|
-
assert metadata["actor_expected"] == "hermes-test-agent[bot]"
|
|
29
|
-
assert metadata["token"] == "ghu_…wxyz"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def test_request_includes_installation_token(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
|
33
|
-
monkeypatch.setattr("jwt.encode", lambda *_, **__: "jwt-token")
|
|
34
|
-
seen: list[httpx.Request] = []
|
|
35
|
-
|
|
36
|
-
def handler(request: httpx.Request) -> httpx.Response:
|
|
37
|
-
seen.append(request)
|
|
38
|
-
if request.url.path.endswith("/access_tokens"):
|
|
39
|
-
return httpx.Response(
|
|
40
|
-
201,
|
|
41
|
-
json={"token": "ghu_installation_token", "expires_at": "2030-01-01T00:00:00Z"},
|
|
42
|
-
)
|
|
43
|
-
return httpx.Response(200, json={"full_name": "ExampleOrg/example-repo"})
|
|
44
|
-
|
|
45
|
-
client = httpx.Client(transport=httpx.MockTransport(handler))
|
|
46
|
-
config = GitHubAppConfig(
|
|
47
|
-
client_id="123",
|
|
48
|
-
installation_id="456",
|
|
49
|
-
private_key=PRIVATE_KEY,
|
|
50
|
-
private_key_source="env",
|
|
51
|
-
app_slug="hermes-test-agent",
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
result = GitHubAppAuth(config, client=client).request(
|
|
55
|
-
"GET", "/repos/ExampleOrg/example-repo", repo="ExampleOrg/example-repo"
|
|
56
|
-
)
|
|
57
|
-
|
|
58
|
-
assert result["result"] == {"full_name": "ExampleOrg/example-repo"}
|
|
59
|
-
assert seen[0].headers["authorization"] == "Bearer jwt-token"
|
|
60
|
-
assert seen[1].headers["authorization"] == "Bearer ghu_installation_token"
|
|
61
|
-
assert json.loads(json.dumps(result))["auth"]["auth_mode"] == "github_app"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|