hermes-github-app-plugin 0.1.1__tar.gz → 0.1.2__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.1.1 → hermes_github_app_plugin-0.1.2}/PKG-INFO +3 -3
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/README.md +2 -2
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/pyproject.toml +1 -1
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/src/hermes_github_app_plugin/auth.py +53 -0
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/src/hermes_github_app_plugin/cli.py +8 -4
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/src/hermes_github_app_plugin/tools.py +8 -3
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/tests/test_auth.py +38 -1
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/tests/test_cli.py +53 -1
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/.github/workflows/cd.yaml +0 -0
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/.github/workflows/ci.yaml +0 -0
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/.gitignore +0 -0
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/LICENSE +0 -0
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/plugin.yaml +0 -0
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/skills/github-app-workflow/SKILL.md +0 -0
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/src/hermes_github_app_plugin/__init__.py +0 -0
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/src/hermes_github_app_plugin/config.py +0 -0
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/src/hermes_github_app_plugin/py.typed +0 -0
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/src/hermes_github_app_plugin/schemas.py +0 -0
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/src/hermes_github_app_plugin/skills/github-app-workflow/SKILL.md +0 -0
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/tests/test_config.py +0 -0
- {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/tests/test_plugin.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hermes-github-app-plugin
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
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
|
|
@@ -127,8 +127,8 @@ Before the first release, configure PyPI Trusted Publishing for this repository
|
|
|
127
127
|
Release example:
|
|
128
128
|
|
|
129
129
|
```bash
|
|
130
|
-
git tag 0.1.
|
|
131
|
-
git push origin 0.1.
|
|
130
|
+
git tag 0.1.2
|
|
131
|
+
git push origin 0.1.2
|
|
132
132
|
```
|
|
133
133
|
|
|
134
134
|
Tags like `v0.1.0`, `0.1`, or `0.1.0rc1` will not publish.
|
|
@@ -108,8 +108,8 @@ Before the first release, configure PyPI Trusted Publishing for this repository
|
|
|
108
108
|
Release example:
|
|
109
109
|
|
|
110
110
|
```bash
|
|
111
|
-
git tag 0.1.
|
|
112
|
-
git push origin 0.1.
|
|
111
|
+
git tag 0.1.2
|
|
112
|
+
git push origin 0.1.2
|
|
113
113
|
```
|
|
114
114
|
|
|
115
115
|
Tags like `v0.1.0`, `0.1`, or `0.1.0rc1` will not publish.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hermes-github-app-plugin"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.2"
|
|
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"
|
|
@@ -6,6 +6,7 @@ import time
|
|
|
6
6
|
from dataclasses import dataclass
|
|
7
7
|
from datetime import datetime, timedelta, timezone
|
|
8
8
|
from typing import Any
|
|
9
|
+
from urllib.parse import urlparse
|
|
9
10
|
|
|
10
11
|
import httpx
|
|
11
12
|
import jwt
|
|
@@ -84,6 +85,46 @@ class GitHubAppAuth:
|
|
|
84
85
|
self._cached_token = token
|
|
85
86
|
return token
|
|
86
87
|
|
|
88
|
+
def app_request(
|
|
89
|
+
self,
|
|
90
|
+
method: str,
|
|
91
|
+
path: str,
|
|
92
|
+
*,
|
|
93
|
+
json_body: dict[str, Any] | None = None,
|
|
94
|
+
params: dict[str, Any] | None = None,
|
|
95
|
+
) -> dict[str, Any]:
|
|
96
|
+
"""Call a GitHub App endpoint using the app JWT.
|
|
97
|
+
|
|
98
|
+
Endpoints like `GET /app` authenticate as the GitHub App itself and
|
|
99
|
+
reject installation access tokens. Repository and user endpoints should
|
|
100
|
+
continue to use `request()`, which authenticates as the installation.
|
|
101
|
+
"""
|
|
102
|
+
url = (
|
|
103
|
+
path if path.startswith("http") else f"{self._config.github_api_url}/{path.lstrip('/')}"
|
|
104
|
+
)
|
|
105
|
+
response = self._client.request(
|
|
106
|
+
method.upper(),
|
|
107
|
+
url,
|
|
108
|
+
headers={
|
|
109
|
+
"Accept": "application/vnd.github+json",
|
|
110
|
+
"Authorization": f"Bearer {self.create_jwt()}",
|
|
111
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
112
|
+
},
|
|
113
|
+
json=json_body,
|
|
114
|
+
params=params,
|
|
115
|
+
)
|
|
116
|
+
response.raise_for_status()
|
|
117
|
+
return {
|
|
118
|
+
"auth": {
|
|
119
|
+
"auth_mode": "github_app_jwt",
|
|
120
|
+
"client_id": self._config.client_id,
|
|
121
|
+
"app_slug": self._config.app_slug,
|
|
122
|
+
"installation_id": self._config.installation_id,
|
|
123
|
+
},
|
|
124
|
+
"status_code": response.status_code,
|
|
125
|
+
"result": response.json() if response.content else {"ok": True},
|
|
126
|
+
}
|
|
127
|
+
|
|
87
128
|
def request(
|
|
88
129
|
self,
|
|
89
130
|
method: str,
|
|
@@ -150,3 +191,15 @@ def auth_metadata(token: InstallationToken, *, repo: str | None = None) -> dict[
|
|
|
150
191
|
"token": token.redacted,
|
|
151
192
|
"expires_at": token.expires_at.isoformat(),
|
|
152
193
|
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def requires_app_jwt(path: str) -> bool:
|
|
197
|
+
"""Return True for GitHub App endpoints that require app JWT auth."""
|
|
198
|
+
path_only = path.split("?", 1)[0]
|
|
199
|
+
if path_only.startswith("http"):
|
|
200
|
+
try:
|
|
201
|
+
path_only = urlparse(path_only).path
|
|
202
|
+
except ValueError:
|
|
203
|
+
return False
|
|
204
|
+
normalized = "/" + path_only.lstrip("/")
|
|
205
|
+
return normalized == "/app" or normalized.startswith("/app/")
|
|
@@ -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
|
|
|
@@ -196,7 +196,7 @@ def _doctor(repo: str | None, *, skip_network: bool) -> int:
|
|
|
196
196
|
auth = GitHubAppAuth(config)
|
|
197
197
|
token = auth.get_installation_token(force_refresh=True)
|
|
198
198
|
checks.append(("installation token minted", True, token.redacted))
|
|
199
|
-
app_result = auth.
|
|
199
|
+
app_result = auth.app_request("GET", "/app")["result"]
|
|
200
200
|
checks.append(("/app API reachable", True, str(app_result.get("slug", "ok"))))
|
|
201
201
|
if repo:
|
|
202
202
|
repo_result = auth.request("GET", f"/repos/{repo}", repo=repo)["result"]
|
|
@@ -225,7 +225,7 @@ def _status(repo: str | None) -> int:
|
|
|
225
225
|
config = load_config()
|
|
226
226
|
auth = GitHubAppAuth(config)
|
|
227
227
|
token = auth.get_installation_token(force_refresh=True)
|
|
228
|
-
app = auth.
|
|
228
|
+
app = auth.app_request("GET", "/app")["result"]
|
|
229
229
|
repo_probe = auth.request("GET", f"/repos/{repo}", repo=repo)["result"] if repo else None
|
|
230
230
|
print(
|
|
231
231
|
json.dumps(
|
|
@@ -253,7 +253,11 @@ def _token(repo: str | None, *, json_output: bool) -> int:
|
|
|
253
253
|
|
|
254
254
|
|
|
255
255
|
def _api(method: str, path: str, *, repo: str | None, body: dict[str, Any] | None) -> int:
|
|
256
|
-
|
|
256
|
+
auth = GitHubAppAuth(load_config())
|
|
257
|
+
if requires_app_jwt(path):
|
|
258
|
+
result = auth.app_request(method, path, json_body=body)
|
|
259
|
+
else:
|
|
260
|
+
result = auth.request(method, path, repo=repo, json_body=body)
|
|
257
261
|
print(json.dumps(result, indent=2, sort_keys=True))
|
|
258
262
|
return 0
|
|
259
263
|
|
|
@@ -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
|
|
|
@@ -55,7 +55,7 @@ def github_app_verify_identity(params: dict[str, Any], **_: Any) -> str:
|
|
|
55
55
|
repo = _repo(params)
|
|
56
56
|
auth = _auth()
|
|
57
57
|
token = auth.get_installation_token(force_refresh=True)
|
|
58
|
-
app = auth.
|
|
58
|
+
app = auth.app_request("GET", "/app")
|
|
59
59
|
repo_probe = auth.request("GET", f"/repos/{repo}", repo=repo) if repo else None
|
|
60
60
|
return {
|
|
61
61
|
"auth": auth_metadata(token, repo=repo),
|
|
@@ -75,7 +75,12 @@ def github_app_api(params: dict[str, Any], **_: Any) -> str:
|
|
|
75
75
|
repo = _repo(params)
|
|
76
76
|
body = params.get("json_body")
|
|
77
77
|
json_body = body if isinstance(body, dict) else None
|
|
78
|
-
|
|
78
|
+
auth = _auth()
|
|
79
|
+
result = (
|
|
80
|
+
auth.app_request(method, path, json_body=json_body)
|
|
81
|
+
if requires_app_jwt(path)
|
|
82
|
+
else auth.request(method, path, repo=repo, json_body=json_body)
|
|
83
|
+
)
|
|
79
84
|
return result
|
|
80
85
|
|
|
81
86
|
return _handle_errors(run)
|
|
@@ -7,7 +7,12 @@ from datetime import datetime, timezone
|
|
|
7
7
|
|
|
8
8
|
import httpx
|
|
9
9
|
|
|
10
|
-
from hermes_github_app_plugin.auth import
|
|
10
|
+
from hermes_github_app_plugin.auth import (
|
|
11
|
+
GitHubAppAuth,
|
|
12
|
+
InstallationToken,
|
|
13
|
+
auth_metadata,
|
|
14
|
+
requires_app_jwt,
|
|
15
|
+
)
|
|
11
16
|
from hermes_github_app_plugin.config import GitHubAppConfig
|
|
12
17
|
|
|
13
18
|
PRIVATE_KEY = "[REDACTED PRIVATE KEY]\n"
|
|
@@ -59,3 +64,35 @@ def test_request_includes_installation_token(monkeypatch) -> None: # type: igno
|
|
|
59
64
|
assert seen[0].headers["authorization"] == "Bearer jwt-token"
|
|
60
65
|
assert seen[1].headers["authorization"] == "Bearer ghu_installation_token"
|
|
61
66
|
assert json.loads(json.dumps(result))["auth"]["auth_mode"] == "github_app"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_app_request_uses_app_jwt(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
|
70
|
+
monkeypatch.setattr("jwt.encode", lambda *_, **__: "jwt-token")
|
|
71
|
+
seen: list[httpx.Request] = []
|
|
72
|
+
|
|
73
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
74
|
+
seen.append(request)
|
|
75
|
+
return httpx.Response(200, json={"slug": "hermes-test-agent"})
|
|
76
|
+
|
|
77
|
+
client = httpx.Client(transport=httpx.MockTransport(handler))
|
|
78
|
+
config = GitHubAppConfig(
|
|
79
|
+
client_id="123",
|
|
80
|
+
installation_id="456",
|
|
81
|
+
private_key=PRIVATE_KEY,
|
|
82
|
+
private_key_source="env",
|
|
83
|
+
app_slug="hermes-test-agent",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
result = GitHubAppAuth(config, client=client).app_request("GET", "/app")
|
|
87
|
+
|
|
88
|
+
assert result["result"] == {"slug": "hermes-test-agent"}
|
|
89
|
+
assert seen[0].url.path == "/app"
|
|
90
|
+
assert seen[0].headers["authorization"] == "Bearer jwt-token"
|
|
91
|
+
assert result["auth"]["auth_mode"] == "github_app_jwt"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_requires_app_jwt_detects_app_endpoints() -> None:
|
|
95
|
+
assert requires_app_jwt("/app")
|
|
96
|
+
assert requires_app_jwt("/app/installations")
|
|
97
|
+
assert requires_app_jwt("https://api.github.com/app")
|
|
98
|
+
assert not requires_app_jwt("/repos/OWNER/REPO")
|
|
@@ -88,7 +88,7 @@ def test_doctor_skip_network_reports_local_checks(
|
|
|
88
88
|
key_path.write_text(PRIVATE_KEY, encoding="utf-8")
|
|
89
89
|
key_path.chmod(0o600)
|
|
90
90
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
91
|
-
|
|
91
|
+
hermes_home.mkdir()
|
|
92
92
|
(hermes_home / "config.yaml").write_text(
|
|
93
93
|
f"""
|
|
94
94
|
github_app:
|
|
@@ -106,3 +106,55 @@ github_app:
|
|
|
106
106
|
assert "✓ GitHub App config loaded" in output
|
|
107
107
|
assert "✓ private key file permissions: 0o600" in output
|
|
108
108
|
assert "Local doctor passed" in output
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_api_routes_app_paths_to_app_jwt(
|
|
112
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
113
|
+
) -> None:
|
|
114
|
+
class FakeAuth:
|
|
115
|
+
def __init__(self, config: object) -> None:
|
|
116
|
+
self.config = config
|
|
117
|
+
|
|
118
|
+
def app_request(
|
|
119
|
+
self, method: str, path: str, json_body: dict[str, object] | None = None
|
|
120
|
+
) -> dict[str, object]:
|
|
121
|
+
return {"status_code": 200, "result": {"path": path, "method": method}}
|
|
122
|
+
|
|
123
|
+
def request(self, *args: object, **kwargs: object) -> dict[str, object]:
|
|
124
|
+
raise AssertionError("installation-token request should not be used for /app")
|
|
125
|
+
|
|
126
|
+
monkeypatch.setattr(cli, "load_config", object)
|
|
127
|
+
monkeypatch.setattr(cli, "GitHubAppAuth", FakeAuth)
|
|
128
|
+
|
|
129
|
+
assert cli._api("GET", "/app", repo=None, body=None) == 0
|
|
130
|
+
|
|
131
|
+
output = capsys.readouterr().out
|
|
132
|
+
assert '"status_code": 200' in output
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_api_routes_repo_paths_to_installation_token(
|
|
136
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
137
|
+
) -> None:
|
|
138
|
+
class FakeAuth:
|
|
139
|
+
def __init__(self, config: object) -> None:
|
|
140
|
+
self.config = config
|
|
141
|
+
|
|
142
|
+
def app_request(self, *args: object, **kwargs: object) -> dict[str, object]:
|
|
143
|
+
raise AssertionError("app JWT request should not be used for repo APIs")
|
|
144
|
+
|
|
145
|
+
def request(
|
|
146
|
+
self,
|
|
147
|
+
method: str,
|
|
148
|
+
path: str,
|
|
149
|
+
repo: str | None = None,
|
|
150
|
+
json_body: dict[str, object] | None = None,
|
|
151
|
+
) -> dict[str, object]:
|
|
152
|
+
return {"status_code": 200, "result": {"path": path, "repo": repo, "method": method}}
|
|
153
|
+
|
|
154
|
+
monkeypatch.setattr(cli, "load_config", object)
|
|
155
|
+
monkeypatch.setattr(cli, "GitHubAppAuth", FakeAuth)
|
|
156
|
+
|
|
157
|
+
assert cli._api("GET", "/repos/OWNER/REPO", repo="OWNER/REPO", body=None) == 0
|
|
158
|
+
|
|
159
|
+
output = capsys.readouterr().out
|
|
160
|
+
assert '"repo": "OWNER/REPO"' in output
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|