hermes-github-app-plugin 0.1.0__py3-none-any.whl
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/__init__.py +60 -0
- hermes_github_app_plugin/auth.py +152 -0
- hermes_github_app_plugin/cli.py +327 -0
- hermes_github_app_plugin/config.py +123 -0
- hermes_github_app_plugin/plugin.yaml +27 -0
- hermes_github_app_plugin/py.typed +0 -0
- hermes_github_app_plugin/schemas.py +105 -0
- hermes_github_app_plugin/skills/github-app-workflow/SKILL.md +52 -0
- hermes_github_app_plugin/tools.py +162 -0
- hermes_github_app_plugin-0.1.0.dist-info/METADATA +149 -0
- hermes_github_app_plugin-0.1.0.dist-info/RECORD +14 -0
- hermes_github_app_plugin-0.1.0.dist-info/WHEEL +4 -0
- hermes_github_app_plugin-0.1.0.dist-info/entry_points.txt +7 -0
- hermes_github_app_plugin-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Hermes GitHub App plugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from . import schemas, tools
|
|
8
|
+
from .cli import main as cli_main
|
|
9
|
+
from .cli import register_cli
|
|
10
|
+
|
|
11
|
+
_TOOLSET = "github_app"
|
|
12
|
+
_TOOLS = (
|
|
13
|
+
("github_app_status", schemas.GITHUB_APP_STATUS, tools.github_app_status, "🤖"),
|
|
14
|
+
(
|
|
15
|
+
"github_app_verify_identity",
|
|
16
|
+
schemas.GITHUB_APP_VERIFY_IDENTITY,
|
|
17
|
+
tools.github_app_verify_identity,
|
|
18
|
+
"✅",
|
|
19
|
+
),
|
|
20
|
+
("github_app_api", schemas.GITHUB_APP_API, tools.github_app_api, "🐙"),
|
|
21
|
+
("github_app_graphql", schemas.GITHUB_APP_GRAPHQL, tools.github_app_graphql, "📊"),
|
|
22
|
+
(
|
|
23
|
+
"github_app_create_issue",
|
|
24
|
+
schemas.GITHUB_APP_CREATE_ISSUE,
|
|
25
|
+
tools.github_app_create_issue,
|
|
26
|
+
"📝",
|
|
27
|
+
),
|
|
28
|
+
(
|
|
29
|
+
"github_app_comment_issue",
|
|
30
|
+
schemas.GITHUB_APP_COMMENT_ISSUE,
|
|
31
|
+
tools.github_app_comment_issue,
|
|
32
|
+
"💬",
|
|
33
|
+
),
|
|
34
|
+
("github_app_create_pr", schemas.GITHUB_APP_CREATE_PR, tools.github_app_create_pr, "🔀"),
|
|
35
|
+
("github_app_comment_pr", schemas.GITHUB_APP_COMMENT_PR, tools.github_app_comment_pr, "💬"),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def register(ctx: object) -> None:
|
|
40
|
+
"""Register Hermes tools, CLI, and bundled skill."""
|
|
41
|
+
for name, schema, handler, emoji in _TOOLS:
|
|
42
|
+
ctx.register_tool( # type: ignore[attr-defined]
|
|
43
|
+
name=name,
|
|
44
|
+
toolset=_TOOLSET,
|
|
45
|
+
schema=schema,
|
|
46
|
+
handler=handler,
|
|
47
|
+
emoji=emoji,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
ctx.register_cli_command( # type: ignore[attr-defined]
|
|
51
|
+
name="hermes-github-app",
|
|
52
|
+
help="Manage the Hermes GitHub App integration",
|
|
53
|
+
setup_fn=register_cli,
|
|
54
|
+
handler_fn=cli_main,
|
|
55
|
+
description="Mint and verify per-agent GitHub App installation tokens.",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
skill_path = Path(__file__).parent / "skills" / "github-app-workflow"
|
|
59
|
+
if skill_path.exists():
|
|
60
|
+
ctx.register_skill("github-app-workflow", skill_path) # type: ignore[attr-defined]
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""GitHub App JWT and installation-token handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import jwt
|
|
12
|
+
|
|
13
|
+
from .config import GitHubAppConfig
|
|
14
|
+
|
|
15
|
+
_MIN_REDACT_LENGTH = 8
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class InstallationToken:
|
|
20
|
+
"""GitHub App installation token plus metadata."""
|
|
21
|
+
|
|
22
|
+
token: str
|
|
23
|
+
expires_at: datetime
|
|
24
|
+
installation_id: str
|
|
25
|
+
client_id: str
|
|
26
|
+
app_slug: str | None
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def redacted(self) -> str:
|
|
30
|
+
"""Return a safe representation for logs/tool output."""
|
|
31
|
+
if len(self.token) <= _MIN_REDACT_LENGTH:
|
|
32
|
+
return "***"
|
|
33
|
+
return f"{self.token[:4]}…{self.token[-4:]}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GitHubAppAuth:
|
|
37
|
+
"""Mint and cache short-lived installation access tokens."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, config: GitHubAppConfig, client: httpx.Client | None = None) -> None:
|
|
40
|
+
self._config = config
|
|
41
|
+
self._client = client or httpx.Client(timeout=20)
|
|
42
|
+
self._cached_token: InstallationToken | None = None
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def config(self) -> GitHubAppConfig:
|
|
46
|
+
return self._config
|
|
47
|
+
|
|
48
|
+
def create_jwt(self) -> str:
|
|
49
|
+
"""Create a GitHub App JWT for installation-token exchange."""
|
|
50
|
+
now = int(time.time())
|
|
51
|
+
payload = {"iat": now - 60, "exp": now + 9 * 60, "iss": self._config.client_id}
|
|
52
|
+
encoded = jwt.encode(payload, self._config.private_key, algorithm="RS256")
|
|
53
|
+
return str(encoded)
|
|
54
|
+
|
|
55
|
+
def get_installation_token(self, *, force_refresh: bool = False) -> InstallationToken:
|
|
56
|
+
"""Return a valid installation token, refreshing when near expiry."""
|
|
57
|
+
if (
|
|
58
|
+
not force_refresh
|
|
59
|
+
and self._cached_token is not None
|
|
60
|
+
and self._cached_token.expires_at > datetime.now(timezone.utc) + timedelta(minutes=5)
|
|
61
|
+
):
|
|
62
|
+
return self._cached_token
|
|
63
|
+
|
|
64
|
+
response = self._client.post(
|
|
65
|
+
f"{self._config.github_api_url}/app/installations/"
|
|
66
|
+
f"{self._config.installation_id}/access_tokens",
|
|
67
|
+
headers={
|
|
68
|
+
"Accept": "application/vnd.github+json",
|
|
69
|
+
"Authorization": f"Bearer {self.create_jwt()}",
|
|
70
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
response.raise_for_status()
|
|
74
|
+
data = response.json()
|
|
75
|
+
expires_at_raw = str(data["expires_at"])
|
|
76
|
+
expires_at = datetime.fromisoformat(expires_at_raw.replace("Z", "+00:00"))
|
|
77
|
+
token = InstallationToken(
|
|
78
|
+
token=str(data["token"]),
|
|
79
|
+
expires_at=expires_at,
|
|
80
|
+
installation_id=self._config.installation_id,
|
|
81
|
+
client_id=self._config.client_id,
|
|
82
|
+
app_slug=self._config.app_slug,
|
|
83
|
+
)
|
|
84
|
+
self._cached_token = token
|
|
85
|
+
return token
|
|
86
|
+
|
|
87
|
+
def request(
|
|
88
|
+
self,
|
|
89
|
+
method: str,
|
|
90
|
+
path: str,
|
|
91
|
+
*,
|
|
92
|
+
repo: str | None = None,
|
|
93
|
+
json_body: dict[str, Any] | None = None,
|
|
94
|
+
params: dict[str, Any] | None = None,
|
|
95
|
+
) -> dict[str, Any]:
|
|
96
|
+
"""Call the GitHub REST API using the installation token."""
|
|
97
|
+
token = self.get_installation_token()
|
|
98
|
+
url = (
|
|
99
|
+
path if path.startswith("http") else f"{self._config.github_api_url}/{path.lstrip('/')}"
|
|
100
|
+
)
|
|
101
|
+
response = self._client.request(
|
|
102
|
+
method.upper(),
|
|
103
|
+
url,
|
|
104
|
+
headers={
|
|
105
|
+
"Accept": "application/vnd.github+json",
|
|
106
|
+
"Authorization": f"Bearer {token.token}",
|
|
107
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
108
|
+
},
|
|
109
|
+
json=json_body,
|
|
110
|
+
params=params,
|
|
111
|
+
)
|
|
112
|
+
response.raise_for_status()
|
|
113
|
+
parsed = response.json() if response.content else {"ok": True}
|
|
114
|
+
return {
|
|
115
|
+
"auth": auth_metadata(token, repo=repo),
|
|
116
|
+
"status_code": response.status_code,
|
|
117
|
+
"result": parsed,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
def graphql(self, query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
121
|
+
"""Call GitHub GraphQL API using the installation token."""
|
|
122
|
+
token = self.get_installation_token()
|
|
123
|
+
response = self._client.post(
|
|
124
|
+
f"{self._config.github_api_url}/graphql",
|
|
125
|
+
headers={
|
|
126
|
+
"Accept": "application/vnd.github+json",
|
|
127
|
+
"Authorization": f"Bearer {token.token}",
|
|
128
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
129
|
+
},
|
|
130
|
+
json={"query": query, "variables": variables or {}},
|
|
131
|
+
)
|
|
132
|
+
response.raise_for_status()
|
|
133
|
+
return {
|
|
134
|
+
"auth": auth_metadata(token),
|
|
135
|
+
"status_code": response.status_code,
|
|
136
|
+
"result": response.json(),
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def auth_metadata(token: InstallationToken, *, repo: str | None = None) -> dict[str, str | None]:
|
|
141
|
+
"""Build safe auth metadata for tool responses."""
|
|
142
|
+
actor = f"{token.app_slug}[bot]" if token.app_slug else None
|
|
143
|
+
return {
|
|
144
|
+
"auth_mode": "github_app",
|
|
145
|
+
"client_id": token.client_id,
|
|
146
|
+
"app_slug": token.app_slug,
|
|
147
|
+
"installation_id": token.installation_id,
|
|
148
|
+
"actor_expected": actor,
|
|
149
|
+
"repository": repo,
|
|
150
|
+
"token": token.redacted,
|
|
151
|
+
"expires_at": token.expires_at.isoformat(),
|
|
152
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""CLI commands and gh/git wrappers for GitHub App identity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import stat
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, NoReturn
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from .auth import GitHubAppAuth, auth_metadata
|
|
19
|
+
from .config import ConfigurationError, load_config, write_github_app_config
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def register_cli(parser: argparse.ArgumentParser) -> None:
|
|
23
|
+
"""Register `hermes hermes-github-app ...` subcommands."""
|
|
24
|
+
subparsers = parser.add_subparsers(dest="github_app_command", required=True)
|
|
25
|
+
|
|
26
|
+
setup = subparsers.add_parser("setup", help="Interactively configure GitHub App auth")
|
|
27
|
+
setup.add_argument("--repo", help="Optional OWNER/REPO to verify after setup")
|
|
28
|
+
setup.add_argument(
|
|
29
|
+
"--non-interactive", action="store_true", help="Read values from flags/env only"
|
|
30
|
+
)
|
|
31
|
+
setup.add_argument("--client-id", help="GitHub App client ID")
|
|
32
|
+
setup.add_argument("--installation-id", help="GitHub App installation ID")
|
|
33
|
+
setup.add_argument("--private-key-path", help="Path to GitHub App private key PEM")
|
|
34
|
+
setup.add_argument("--app-slug", help="Optional GitHub App slug, e.g. my-agent")
|
|
35
|
+
setup.add_argument(
|
|
36
|
+
"--skip-verify", action="store_true", help="Write config without minting a token"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
doctor = subparsers.add_parser("doctor", help="Run installation and auth diagnostics")
|
|
40
|
+
doctor.add_argument("--repo", help="Optional OWNER/REPO access probe")
|
|
41
|
+
doctor.add_argument("--skip-network", action="store_true", help="Skip GitHub network checks")
|
|
42
|
+
|
|
43
|
+
status = subparsers.add_parser("status", help="Verify GitHub App configuration and identity")
|
|
44
|
+
status.add_argument("--repo", help="Optional OWNER/REPO access probe")
|
|
45
|
+
|
|
46
|
+
token = subparsers.add_parser("token", help="Print an installation token")
|
|
47
|
+
token.add_argument("--repo", help="Optional OWNER/REPO metadata tag")
|
|
48
|
+
token.add_argument("--json", action="store_true", help="Print JSON metadata and token")
|
|
49
|
+
|
|
50
|
+
api = subparsers.add_parser("api", help="Call a GitHub REST API path")
|
|
51
|
+
api.add_argument("path", help="GitHub REST API path, e.g. /repos/OWNER/REPO")
|
|
52
|
+
api.add_argument("--method", default="GET")
|
|
53
|
+
api.add_argument("--repo", help="Optional OWNER/REPO metadata tag")
|
|
54
|
+
api.add_argument("--data", help="JSON request body")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main(args: argparse.Namespace | None = None) -> int:
|
|
58
|
+
"""Run the plugin CLI."""
|
|
59
|
+
if args is None:
|
|
60
|
+
parser = argparse.ArgumentParser(prog="hermes-github-app")
|
|
61
|
+
register_cli(parser)
|
|
62
|
+
args = parser.parse_args()
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
command = args.github_app_command
|
|
66
|
+
if command == "setup":
|
|
67
|
+
return _setup(args)
|
|
68
|
+
if command == "doctor":
|
|
69
|
+
return _doctor(args.repo, skip_network=bool(args.skip_network))
|
|
70
|
+
if command == "status":
|
|
71
|
+
return _status(args.repo)
|
|
72
|
+
if command == "token":
|
|
73
|
+
return _token(args.repo, json_output=bool(args.json))
|
|
74
|
+
if command == "api":
|
|
75
|
+
body = json.loads(args.data) if args.data else None
|
|
76
|
+
return _api(args.method, args.path, repo=args.repo, body=body)
|
|
77
|
+
raise ConfigurationError(f"unknown command: {command}")
|
|
78
|
+
except (ConfigurationError, httpx.HTTPError, json.JSONDecodeError) as exc:
|
|
79
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
80
|
+
return 1
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def gh_app_main() -> NoReturn:
|
|
84
|
+
"""Entry point for `gh-app` wrapper."""
|
|
85
|
+
args = sys.argv[1:]
|
|
86
|
+
if not args or args[0] in {"-h", "--help"}:
|
|
87
|
+
print("usage: gh-app [--repo OWNER/REPO] [--] <gh args...>")
|
|
88
|
+
print("Runs gh with GH_TOKEN/GITHUB_TOKEN set to a GitHub App installation token.")
|
|
89
|
+
raise SystemExit(0)
|
|
90
|
+
_, child_args = _extract_repo(args)
|
|
91
|
+
config = load_config()
|
|
92
|
+
token = GitHubAppAuth(config).get_installation_token()
|
|
93
|
+
env = os.environ.copy()
|
|
94
|
+
env["GH_TOKEN"] = token.token
|
|
95
|
+
env["GITHUB_TOKEN"] = token.token
|
|
96
|
+
raise SystemExit(subprocess.call(["gh", *child_args], env=env))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def git_app_main() -> NoReturn:
|
|
100
|
+
"""Entry point for `git-app` wrapper with temporary askpass credentials."""
|
|
101
|
+
if len(sys.argv) <= 1 or sys.argv[1] in {"-h", "--help"}:
|
|
102
|
+
print("usage: git-app [--repo OWNER/REPO] [--] <git args...>")
|
|
103
|
+
print("Runs git with a temporary askpass helper backed by a GitHub App token.")
|
|
104
|
+
raise SystemExit(0)
|
|
105
|
+
_, child_args = _extract_repo(sys.argv[1:])
|
|
106
|
+
config = load_config()
|
|
107
|
+
token = GitHubAppAuth(config).get_installation_token()
|
|
108
|
+
with tempfile.TemporaryDirectory(prefix="git-app-") as temp_dir:
|
|
109
|
+
askpass = Path(temp_dir) / "askpass.sh"
|
|
110
|
+
askpass.write_text(
|
|
111
|
+
"#!/bin/sh\n"
|
|
112
|
+
'case "$1" in\n'
|
|
113
|
+
"*Username*) printf '%s\\n' 'x-access-token' ;;\n"
|
|
114
|
+
f"*Password*) printf '%s\\n' '{token.token}' ;;\n"
|
|
115
|
+
"*) printf '\\n' ;;\n"
|
|
116
|
+
"esac\n",
|
|
117
|
+
encoding="utf-8",
|
|
118
|
+
)
|
|
119
|
+
askpass.chmod(0o700)
|
|
120
|
+
env = os.environ.copy()
|
|
121
|
+
env["GIT_ASKPASS"] = str(askpass)
|
|
122
|
+
env["GIT_TERMINAL_PROMPT"] = "0"
|
|
123
|
+
raise SystemExit(subprocess.call(["git", *child_args], env=env))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _setup(args: argparse.Namespace) -> int:
|
|
127
|
+
"""Interactively write GitHub App configuration."""
|
|
128
|
+
print("Hermes GitHub App setup")
|
|
129
|
+
print("Required values are unmarked. Optional prompts include '(optional)'.")
|
|
130
|
+
values = {
|
|
131
|
+
"client_id": _value_or_prompt(
|
|
132
|
+
args.client_id,
|
|
133
|
+
"GitHub App client ID",
|
|
134
|
+
env="GITHUB_APP_CLIENT_ID",
|
|
135
|
+
required=True,
|
|
136
|
+
non_interactive=bool(args.non_interactive),
|
|
137
|
+
),
|
|
138
|
+
"installation_id": _value_or_prompt(
|
|
139
|
+
args.installation_id,
|
|
140
|
+
"GitHub App installation ID",
|
|
141
|
+
env="GITHUB_APP_INSTALLATION_ID",
|
|
142
|
+
required=True,
|
|
143
|
+
non_interactive=bool(args.non_interactive),
|
|
144
|
+
),
|
|
145
|
+
"private_key_path": _value_or_prompt(
|
|
146
|
+
args.private_key_path,
|
|
147
|
+
"GitHub App private key path",
|
|
148
|
+
env="GITHUB_APP_PRIVATE_KEY_PATH",
|
|
149
|
+
required=True,
|
|
150
|
+
non_interactive=bool(args.non_interactive),
|
|
151
|
+
),
|
|
152
|
+
"app_slug": _value_or_prompt(
|
|
153
|
+
args.app_slug,
|
|
154
|
+
"GitHub App slug (optional)",
|
|
155
|
+
env="GITHUB_APP_SLUG",
|
|
156
|
+
required=False,
|
|
157
|
+
non_interactive=bool(args.non_interactive),
|
|
158
|
+
),
|
|
159
|
+
}
|
|
160
|
+
key_path = Path(str(values["private_key_path"])).expanduser()
|
|
161
|
+
if not key_path.exists():
|
|
162
|
+
raise ConfigurationError(f"private key file does not exist: {key_path}")
|
|
163
|
+
_warn_private_key_permissions(key_path)
|
|
164
|
+
written = write_github_app_config(values)
|
|
165
|
+
print(f"Wrote GitHub App config to {written}")
|
|
166
|
+
if args.skip_verify:
|
|
167
|
+
print("Skipped verification. Run `hermes-github-app doctor --repo OWNER/REPO` next.")
|
|
168
|
+
return 0
|
|
169
|
+
return _doctor(args.repo, skip_network=False)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _doctor(repo: str | None, *, skip_network: bool) -> int:
|
|
173
|
+
"""Run local and optional network diagnostics."""
|
|
174
|
+
checks: list[tuple[str, bool, str]] = []
|
|
175
|
+
checks.append(("hermes-github-app command installed", True, sys.argv[0]))
|
|
176
|
+
for command in ("gh", "git", "gh-app", "git-app"):
|
|
177
|
+
path = shutil.which(command)
|
|
178
|
+
checks.append((f"{command} on PATH", path is not None, path or "not found"))
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
config = load_config()
|
|
182
|
+
checks.append(("GitHub App config loaded", True, "client_id and installation_id present"))
|
|
183
|
+
key_source = Path(config.private_key_source).expanduser()
|
|
184
|
+
if config.private_key_source == "GITHUB_APP_PRIVATE_KEY":
|
|
185
|
+
checks.append(("private key loaded", True, "inline environment variable"))
|
|
186
|
+
else:
|
|
187
|
+
checks.append(("private key file exists", key_source.exists(), str(key_source)))
|
|
188
|
+
checks.append(
|
|
189
|
+
(
|
|
190
|
+
"private key file permissions",
|
|
191
|
+
_private_key_permissions_ok(key_source),
|
|
192
|
+
_mode(key_source),
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
if not skip_network:
|
|
196
|
+
auth = GitHubAppAuth(config)
|
|
197
|
+
token = auth.get_installation_token(force_refresh=True)
|
|
198
|
+
checks.append(("installation token minted", True, token.redacted))
|
|
199
|
+
app_result = auth.request("GET", "/app")["result"]
|
|
200
|
+
checks.append(("/app API reachable", True, str(app_result.get("slug", "ok"))))
|
|
201
|
+
if repo:
|
|
202
|
+
repo_result = auth.request("GET", f"/repos/{repo}", repo=repo)["result"]
|
|
203
|
+
checks.append(
|
|
204
|
+
("repository access verified", True, str(repo_result.get("full_name", repo)))
|
|
205
|
+
)
|
|
206
|
+
except Exception as exc:
|
|
207
|
+
checks.append(("GitHub App auth/config", False, str(exc)))
|
|
208
|
+
|
|
209
|
+
success = all(ok for _, ok, _ in checks)
|
|
210
|
+
for name, ok, detail in checks:
|
|
211
|
+
marker = "✓" if ok else "✗"
|
|
212
|
+
print(f"{marker} {name}: {detail}")
|
|
213
|
+
if success:
|
|
214
|
+
print(
|
|
215
|
+
"Doctor passed. GitHub App identity is ready."
|
|
216
|
+
if not skip_network
|
|
217
|
+
else "Local doctor passed."
|
|
218
|
+
)
|
|
219
|
+
return 0
|
|
220
|
+
print("Doctor found issues. Fix the failed checks above and rerun.", file=sys.stderr)
|
|
221
|
+
return 1
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _status(repo: str | None) -> int:
|
|
225
|
+
config = load_config()
|
|
226
|
+
auth = GitHubAppAuth(config)
|
|
227
|
+
token = auth.get_installation_token(force_refresh=True)
|
|
228
|
+
app = auth.request("GET", "/app")["result"]
|
|
229
|
+
repo_probe = auth.request("GET", f"/repos/{repo}", repo=repo)["result"] if repo else None
|
|
230
|
+
print(
|
|
231
|
+
json.dumps(
|
|
232
|
+
{
|
|
233
|
+
"success": True,
|
|
234
|
+
"auth": auth_metadata(token, repo=repo),
|
|
235
|
+
"app": app,
|
|
236
|
+
"repository_probe": repo_probe,
|
|
237
|
+
},
|
|
238
|
+
indent=2,
|
|
239
|
+
sort_keys=True,
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
return 0
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _token(repo: str | None, *, json_output: bool) -> int:
|
|
246
|
+
config = load_config()
|
|
247
|
+
token = GitHubAppAuth(config).get_installation_token()
|
|
248
|
+
if json_output:
|
|
249
|
+
print(json.dumps({"token": token.token, "auth": auth_metadata(token, repo=repo)}, indent=2))
|
|
250
|
+
else:
|
|
251
|
+
print(token.token)
|
|
252
|
+
return 0
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _api(method: str, path: str, *, repo: str | None, body: dict[str, Any] | None) -> int:
|
|
256
|
+
result = GitHubAppAuth(load_config()).request(method, path, repo=repo, json_body=body)
|
|
257
|
+
print(json.dumps(result, indent=2, sort_keys=True))
|
|
258
|
+
return 0
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _value_or_prompt(
|
|
262
|
+
value: str | None,
|
|
263
|
+
label: str,
|
|
264
|
+
*,
|
|
265
|
+
env: str,
|
|
266
|
+
required: bool,
|
|
267
|
+
non_interactive: bool,
|
|
268
|
+
) -> str:
|
|
269
|
+
"""Return a provided/env value or prompt for it."""
|
|
270
|
+
resolved = value or os.environ.get(env, "")
|
|
271
|
+
if resolved:
|
|
272
|
+
return resolved.strip()
|
|
273
|
+
if non_interactive:
|
|
274
|
+
if required:
|
|
275
|
+
raise ConfigurationError(f"missing required value: {label} (or {env})")
|
|
276
|
+
return ""
|
|
277
|
+
resolved = input(f"{label}: ").strip()
|
|
278
|
+
if required and not resolved:
|
|
279
|
+
raise ConfigurationError(f"missing required value: {label}")
|
|
280
|
+
return resolved
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _private_key_permissions_ok(path: Path) -> bool:
|
|
284
|
+
if not path.exists():
|
|
285
|
+
return False
|
|
286
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
287
|
+
return mode & 0o077 == 0
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _warn_private_key_permissions(path: Path) -> None:
|
|
291
|
+
if not _private_key_permissions_ok(path):
|
|
292
|
+
print(
|
|
293
|
+
f"warning: {path} is readable by group/other ({_mode(path)}). "
|
|
294
|
+
"Run `chmod 600 <key>` to lock it down.",
|
|
295
|
+
file=sys.stderr,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _mode(path: Path) -> str:
|
|
300
|
+
if not path.exists():
|
|
301
|
+
return "missing"
|
|
302
|
+
return oct(stat.S_IMODE(path.stat().st_mode))
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _extract_repo(args: list[str]) -> tuple[str | None, list[str]]:
|
|
306
|
+
repo: str | None = None
|
|
307
|
+
child_args: list[str] = []
|
|
308
|
+
iterator = iter(args)
|
|
309
|
+
for arg in iterator:
|
|
310
|
+
if arg == "--repo":
|
|
311
|
+
repo = next(iterator, None)
|
|
312
|
+
if repo is None:
|
|
313
|
+
raise ConfigurationError("--repo requires OWNER/REPO")
|
|
314
|
+
elif arg.startswith("--repo="):
|
|
315
|
+
repo = arg.split("=", 1)[1]
|
|
316
|
+
elif arg == "--":
|
|
317
|
+
child_args.extend(iterator)
|
|
318
|
+
break
|
|
319
|
+
else:
|
|
320
|
+
child_args.append(arg)
|
|
321
|
+
if not child_args:
|
|
322
|
+
raise ConfigurationError("missing command to run")
|
|
323
|
+
return repo, child_args
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
if __name__ == "__main__":
|
|
327
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Configuration loading for the Hermes GitHub App plugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConfigurationError(RuntimeError):
|
|
15
|
+
"""Raised when GitHub App configuration is missing or invalid."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class GitHubAppConfig:
|
|
20
|
+
"""Per-agent GitHub App configuration."""
|
|
21
|
+
|
|
22
|
+
client_id: str
|
|
23
|
+
installation_id: str
|
|
24
|
+
private_key: str
|
|
25
|
+
private_key_source: str
|
|
26
|
+
app_slug: str | None = None
|
|
27
|
+
github_api_url: str = "https://api.github.com"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def hermes_home() -> Path:
|
|
31
|
+
"""Return the configured Hermes home directory."""
|
|
32
|
+
return Path(os.environ.get("HERMES_HOME", str(Path.home() / ".hermes"))).expanduser()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def config_path() -> Path:
|
|
36
|
+
"""Return the Hermes config.yaml path."""
|
|
37
|
+
return hermes_home() / "config.yaml"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _read_yaml_file() -> dict[str, Any]:
|
|
41
|
+
path = config_path()
|
|
42
|
+
if not path.exists():
|
|
43
|
+
return {}
|
|
44
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
45
|
+
return dict(data) if isinstance(data, Mapping) else {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _read_config_yaml() -> Mapping[str, Any]:
|
|
49
|
+
data = _read_yaml_file()
|
|
50
|
+
section = data.get("github_app", {})
|
|
51
|
+
if isinstance(section, Mapping):
|
|
52
|
+
return section
|
|
53
|
+
return {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def write_github_app_config(values: Mapping[str, Any]) -> Path:
|
|
57
|
+
"""Merge GitHub App values into ~/.hermes/config.yaml and return the path written."""
|
|
58
|
+
path = config_path()
|
|
59
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
data = _read_yaml_file()
|
|
61
|
+
section = data.get("github_app")
|
|
62
|
+
if not isinstance(section, dict):
|
|
63
|
+
section = {}
|
|
64
|
+
data["github_app"] = section
|
|
65
|
+
for key, value in values.items():
|
|
66
|
+
if value in (None, "", (), []):
|
|
67
|
+
section.pop(key, None)
|
|
68
|
+
else:
|
|
69
|
+
section[key] = value
|
|
70
|
+
# Remove legacy local allowlist keys if setup rewrites an older config.
|
|
71
|
+
section.pop("allowed_repos", None)
|
|
72
|
+
section.pop("allowed_owners", None)
|
|
73
|
+
path.write_text(
|
|
74
|
+
yaml.safe_dump(data, sort_keys=False, default_flow_style=False), encoding="utf-8"
|
|
75
|
+
)
|
|
76
|
+
return path
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _read_private_key(section: Mapping[str, Any]) -> tuple[str, str]:
|
|
80
|
+
inline_key = os.environ.get("GITHUB_APP_PRIVATE_KEY") or str(section.get("private_key", ""))
|
|
81
|
+
if inline_key:
|
|
82
|
+
return inline_key.replace("\\n", "\n"), "GITHUB_APP_PRIVATE_KEY"
|
|
83
|
+
|
|
84
|
+
key_path_raw = os.environ.get("GITHUB_APP_PRIVATE_KEY_PATH") or str(
|
|
85
|
+
section.get("private_key_path", "")
|
|
86
|
+
)
|
|
87
|
+
if not key_path_raw:
|
|
88
|
+
raise ConfigurationError(
|
|
89
|
+
"missing GitHub App private key: set GITHUB_APP_PRIVATE_KEY_PATH, "
|
|
90
|
+
"GITHUB_APP_PRIVATE_KEY, or github_app.private_key_path"
|
|
91
|
+
)
|
|
92
|
+
key_path = Path(key_path_raw).expanduser()
|
|
93
|
+
if not key_path.exists():
|
|
94
|
+
raise ConfigurationError(f"GitHub App private key file does not exist: {key_path}")
|
|
95
|
+
return key_path.read_text(encoding="utf-8"), str(key_path)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def load_config() -> GitHubAppConfig:
|
|
99
|
+
"""Load plugin configuration from environment variables and Hermes config.yaml."""
|
|
100
|
+
section = _read_config_yaml()
|
|
101
|
+
client_id = os.environ.get("GITHUB_APP_CLIENT_ID") or str(section.get("client_id", ""))
|
|
102
|
+
installation_id = os.environ.get("GITHUB_APP_INSTALLATION_ID") or str(
|
|
103
|
+
section.get("installation_id", "")
|
|
104
|
+
)
|
|
105
|
+
if not client_id:
|
|
106
|
+
raise ConfigurationError(
|
|
107
|
+
"missing GitHub App client ID: set GITHUB_APP_CLIENT_ID or github_app.client_id"
|
|
108
|
+
)
|
|
109
|
+
if not installation_id:
|
|
110
|
+
raise ConfigurationError(
|
|
111
|
+
"missing GitHub App installation ID: set GITHUB_APP_INSTALLATION_ID "
|
|
112
|
+
"or github_app.installation_id"
|
|
113
|
+
)
|
|
114
|
+
private_key, private_key_source = _read_private_key(section)
|
|
115
|
+
return GitHubAppConfig(
|
|
116
|
+
client_id=client_id,
|
|
117
|
+
installation_id=installation_id,
|
|
118
|
+
private_key=private_key,
|
|
119
|
+
private_key_source=private_key_source,
|
|
120
|
+
app_slug=os.environ.get("GITHUB_APP_SLUG") or section.get("app_slug"),
|
|
121
|
+
github_api_url=os.environ.get("GITHUB_API_URL")
|
|
122
|
+
or str(section.get("github_api_url", "https://api.github.com")).rstrip("/"),
|
|
123
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: github-app
|
|
2
|
+
version: 0.1.0
|
|
3
|
+
description: "GitHub App identity integration for Hermes: per-agent app token minting, GitHub API tools, gh/git wrappers, CLI, and workflow skill."
|
|
4
|
+
author: Hermes GitHub App Plugin Contributors
|
|
5
|
+
provides_tools:
|
|
6
|
+
- github_app_status
|
|
7
|
+
- github_app_verify_identity
|
|
8
|
+
- github_app_api
|
|
9
|
+
- github_app_graphql
|
|
10
|
+
- github_app_create_issue
|
|
11
|
+
- github_app_comment_issue
|
|
12
|
+
- github_app_create_pr
|
|
13
|
+
- github_app_comment_pr
|
|
14
|
+
provides_cli:
|
|
15
|
+
- hermes-github-app
|
|
16
|
+
- gh-app
|
|
17
|
+
- git-app
|
|
18
|
+
requires_env:
|
|
19
|
+
- name: GITHUB_APP_CLIENT_ID
|
|
20
|
+
description: "GitHub App client ID for this Hermes agent. Can also be set in ~/.hermes/config.yaml under github_app.client_id."
|
|
21
|
+
secret: false
|
|
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."
|
|
24
|
+
secret: false
|
|
25
|
+
- name: GITHUB_APP_PRIVATE_KEY_PATH
|
|
26
|
+
description: "Path to the GitHub App private key PEM. Can also be set in ~/.hermes/config.yaml under github_app.private_key_path."
|
|
27
|
+
secret: true
|
|
File without changes
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Tool schemas exposed to Hermes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
_JSON_BODY = {"type": "object", "description": "JSON body for the GitHub API request."}
|
|
8
|
+
_REPO = {"type": "string", "description": "Repository in OWNER/NAME form."}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _schema(
|
|
12
|
+
name: str, description: str, properties: dict[str, Any], required: list[str]
|
|
13
|
+
) -> dict[str, Any]:
|
|
14
|
+
return {
|
|
15
|
+
"name": name,
|
|
16
|
+
"description": description,
|
|
17
|
+
"parameters": {"type": "object", "properties": properties, "required": required},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
GITHUB_APP_STATUS = _schema(
|
|
22
|
+
"github_app_status",
|
|
23
|
+
"Show configured per-agent GitHub App identity without revealing secrets.",
|
|
24
|
+
{"repo": _REPO},
|
|
25
|
+
[],
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
GITHUB_APP_VERIFY_IDENTITY = _schema(
|
|
29
|
+
"github_app_verify_identity",
|
|
30
|
+
(
|
|
31
|
+
"Mint an installation token, call /app, and optionally probe repository access. "
|
|
32
|
+
"Use before GitHub writes."
|
|
33
|
+
),
|
|
34
|
+
{"repo": _REPO},
|
|
35
|
+
[],
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
GITHUB_APP_API = _schema(
|
|
39
|
+
"github_app_api",
|
|
40
|
+
(
|
|
41
|
+
"Call GitHub REST API using the configured GitHub App installation token. "
|
|
42
|
+
"Prefer this over bare gh."
|
|
43
|
+
),
|
|
44
|
+
{
|
|
45
|
+
"method": {"type": "string", "description": "HTTP method, default GET."},
|
|
46
|
+
"path": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"description": "GitHub API path, e.g. /repos/OWNER/REPO/issues.",
|
|
49
|
+
},
|
|
50
|
+
"repo": _REPO,
|
|
51
|
+
"json_body": _JSON_BODY,
|
|
52
|
+
},
|
|
53
|
+
["path"],
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
GITHUB_APP_GRAPHQL = _schema(
|
|
57
|
+
"github_app_graphql",
|
|
58
|
+
"Call GitHub GraphQL using the configured GitHub App installation token.",
|
|
59
|
+
{
|
|
60
|
+
"query": {"type": "string", "description": "GraphQL query."},
|
|
61
|
+
"variables": {"type": "object", "description": "GraphQL variables."},
|
|
62
|
+
},
|
|
63
|
+
["query"],
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
GITHUB_APP_CREATE_ISSUE = _schema(
|
|
67
|
+
"github_app_create_issue",
|
|
68
|
+
"Create a GitHub issue as the per-agent GitHub App bot.",
|
|
69
|
+
{
|
|
70
|
+
"repo": _REPO,
|
|
71
|
+
"title": {"type": "string"},
|
|
72
|
+
"body": {"type": "string"},
|
|
73
|
+
"labels": {"type": "array", "items": {"type": "string"}},
|
|
74
|
+
"assignees": {"type": "array", "items": {"type": "string"}},
|
|
75
|
+
},
|
|
76
|
+
["repo", "title"],
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
GITHUB_APP_COMMENT_ISSUE = _schema(
|
|
80
|
+
"github_app_comment_issue",
|
|
81
|
+
"Comment on a GitHub issue as the per-agent GitHub App bot.",
|
|
82
|
+
{"repo": _REPO, "number": {"type": "integer"}, "body": {"type": "string"}},
|
|
83
|
+
["repo", "number", "body"],
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
GITHUB_APP_CREATE_PR = _schema(
|
|
87
|
+
"github_app_create_pr",
|
|
88
|
+
"Create a pull request as the per-agent GitHub App bot.",
|
|
89
|
+
{
|
|
90
|
+
"repo": _REPO,
|
|
91
|
+
"title": {"type": "string"},
|
|
92
|
+
"head": {"type": "string", "description": "Head branch."},
|
|
93
|
+
"base": {"type": "string", "description": "Base branch."},
|
|
94
|
+
"body": {"type": "string"},
|
|
95
|
+
"draft": {"type": "boolean"},
|
|
96
|
+
},
|
|
97
|
+
["repo", "title", "head", "base"],
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
GITHUB_APP_COMMENT_PR = _schema(
|
|
101
|
+
"github_app_comment_pr",
|
|
102
|
+
"Comment on a GitHub pull request as the per-agent GitHub App bot.",
|
|
103
|
+
{"repo": _REPO, "number": {"type": "integer"}, "body": {"type": "string"}},
|
|
104
|
+
["repo", "number", "body"],
|
|
105
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: github-app-workflow
|
|
3
|
+
description: Use per-agent GitHub App identity for Hermes GitHub operations.
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
author: Hermes GitHub App Plugin Contributors
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# GitHub App Workflow
|
|
9
|
+
|
|
10
|
+
Use this skill for GitHub operations from Hermes agents that are configured with a per-agent GitHub App.
|
|
11
|
+
|
|
12
|
+
## First-time setup
|
|
13
|
+
|
|
14
|
+
Run `hermes-github-app setup` to write `github_app` config into `~/.hermes/config.yaml`. The setup walkthrough marks optional values with `(optional)`; the required values are GitHub App client ID, installation ID, and private key path.
|
|
15
|
+
|
|
16
|
+
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
|
+
|
|
18
|
+
## Rules
|
|
19
|
+
|
|
20
|
+
1. Prefer `github_app_*` plugin tools for GitHub API operations.
|
|
21
|
+
2. Prefer `gh-app` over bare `gh` from the terminal.
|
|
22
|
+
3. Prefer `git-app` or HTTPS/App-token credentials over SSH for GitHub writes.
|
|
23
|
+
4. Do not rely on `gh auth status` as proof of the desired identity; it often reports the human account.
|
|
24
|
+
5. Verify App mode before writes with `github_app_verify_identity` or `hermes-github-app status --repo OWNER/REPO`.
|
|
25
|
+
6. Do not use `@me` assumptions; the actor is the app bot, not a human user.
|
|
26
|
+
7. Expect comments, PRs, and API writes to appear as `<app-slug>[bot]`.
|
|
27
|
+
8. Treat the GitHub App installation scope as the repository access boundary. The plugin does not maintain a separate local repository/owner allowlist.
|
|
28
|
+
|
|
29
|
+
## Updating existing Hermes skills
|
|
30
|
+
|
|
31
|
+
Patch any GitHub-related skill, cron prompt, or subagent instruction that mentions `gh`, `git push`, GitHub SSH remotes, or `@me` assumptions:
|
|
32
|
+
|
|
33
|
+
- Replace bare `gh ...` examples with `gh-app --repo OWNER/REPO -- ...` when the command needs GitHub authentication.
|
|
34
|
+
- Replace `git push` examples with `git-app --repo OWNER/REPO -- push ...`, or document an equivalent HTTPS credential-helper flow that uses a freshly minted installation token.
|
|
35
|
+
- Add a pre-write verification step: `github_app_verify_identity` or `hermes-github-app status --repo OWNER/REPO`.
|
|
36
|
+
- Do not treat `gh auth status` as proof of the write identity. It reports local `gh` credentials and may be a human account.
|
|
37
|
+
- Remove or flag SSH remote examples for bot-managed worktrees. SSH uses local SSH keys, not the GitHub App installation token.
|
|
38
|
+
- Replace `@me` queries with explicit usernames, teams, or repository-scoped queries because the app bot is not the human operator.
|
|
39
|
+
- Require final write summaries to include `auth_mode`, `app_slug`, `installation_id`, `repository`, operation, and URL/path.
|
|
40
|
+
|
|
41
|
+
For subagents, include the same rules in the delegated prompt because subagents run in separate sessions and may not inherit the parent agent's assumptions.
|
|
42
|
+
|
|
43
|
+
## Verification
|
|
44
|
+
|
|
45
|
+
Before reporting success for a write, include:
|
|
46
|
+
|
|
47
|
+
- repository
|
|
48
|
+
- operation
|
|
49
|
+
- URL or API path
|
|
50
|
+
- `auth_mode: github_app`
|
|
51
|
+
- app slug if known
|
|
52
|
+
- installation ID
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Hermes tool handlers for GitHub App operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .auth import GitHubAppAuth, auth_metadata
|
|
11
|
+
from .config import ConfigurationError, load_config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _json(data: dict[str, Any]) -> str:
|
|
15
|
+
return json.dumps(data, indent=2, sort_keys=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _error(exc: Exception) -> str:
|
|
19
|
+
return _json({"success": False, "error": str(exc), "error_type": type(exc).__name__})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _auth() -> GitHubAppAuth:
|
|
23
|
+
return GitHubAppAuth(load_config())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _handle_errors(fn: Any, *args: Any, **kwargs: Any) -> str:
|
|
27
|
+
try:
|
|
28
|
+
return _json({"success": True, **fn(*args, **kwargs)})
|
|
29
|
+
except (ConfigurationError, httpx.HTTPError, KeyError, ValueError) as exc:
|
|
30
|
+
return _error(exc)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def github_app_status(params: dict[str, Any], **_: Any) -> str:
|
|
34
|
+
"""Return GitHub App config status without printing secrets."""
|
|
35
|
+
|
|
36
|
+
def run() -> dict[str, Any]:
|
|
37
|
+
config = load_config()
|
|
38
|
+
return {
|
|
39
|
+
"configured": True,
|
|
40
|
+
"client_id": config.client_id,
|
|
41
|
+
"installation_id": config.installation_id,
|
|
42
|
+
"app_slug": config.app_slug,
|
|
43
|
+
"private_key_source": config.private_key_source,
|
|
44
|
+
"github_api_url": config.github_api_url,
|
|
45
|
+
"scope_management": "github_app_installation",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return _handle_errors(run)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def github_app_verify_identity(params: dict[str, Any], **_: Any) -> str:
|
|
52
|
+
"""Mint a token and verify App identity/repository access."""
|
|
53
|
+
|
|
54
|
+
def run() -> dict[str, Any]:
|
|
55
|
+
repo = _repo(params)
|
|
56
|
+
auth = _auth()
|
|
57
|
+
token = auth.get_installation_token(force_refresh=True)
|
|
58
|
+
app = auth.request("GET", "/app")
|
|
59
|
+
repo_probe = auth.request("GET", f"/repos/{repo}", repo=repo) if repo else None
|
|
60
|
+
return {
|
|
61
|
+
"auth": auth_metadata(token, repo=repo),
|
|
62
|
+
"app": app["result"],
|
|
63
|
+
"repository_probe": repo_probe,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return _handle_errors(run)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def github_app_api(params: dict[str, Any], **_: Any) -> str:
|
|
70
|
+
"""Call the GitHub REST API using the configured GitHub App."""
|
|
71
|
+
|
|
72
|
+
def run() -> dict[str, Any]:
|
|
73
|
+
method = str(params.get("method", "GET"))
|
|
74
|
+
path = str(params["path"])
|
|
75
|
+
repo = _repo(params)
|
|
76
|
+
body = params.get("json_body")
|
|
77
|
+
json_body = body if isinstance(body, dict) else None
|
|
78
|
+
result = _auth().request(method, path, repo=repo, json_body=json_body)
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
return _handle_errors(run)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def github_app_graphql(params: dict[str, Any], **_: Any) -> str:
|
|
85
|
+
"""Call GitHub GraphQL using the configured GitHub App."""
|
|
86
|
+
|
|
87
|
+
def run() -> dict[str, Any]:
|
|
88
|
+
variables = params.get("variables")
|
|
89
|
+
return _auth().graphql(
|
|
90
|
+
str(params["query"]), variables if isinstance(variables, dict) else None
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return _handle_errors(run)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def github_app_create_issue(params: dict[str, Any], **_: Any) -> str:
|
|
97
|
+
"""Create an issue using the GitHub App identity."""
|
|
98
|
+
|
|
99
|
+
def run() -> dict[str, Any]:
|
|
100
|
+
repo = _required_repo(params)
|
|
101
|
+
body: dict[str, Any] = {"title": str(params["title"])}
|
|
102
|
+
if params.get("body") is not None:
|
|
103
|
+
body["body"] = str(params["body"])
|
|
104
|
+
labels = params.get("labels")
|
|
105
|
+
if isinstance(labels, list):
|
|
106
|
+
body["labels"] = labels
|
|
107
|
+
assignees = params.get("assignees")
|
|
108
|
+
if isinstance(assignees, list):
|
|
109
|
+
body["assignees"] = assignees
|
|
110
|
+
return _auth().request("POST", f"/repos/{repo}/issues", repo=repo, json_body=body)
|
|
111
|
+
|
|
112
|
+
return _handle_errors(run)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def github_app_comment_issue(params: dict[str, Any], **_: Any) -> str:
|
|
116
|
+
"""Comment on an issue or PR using the GitHub App identity."""
|
|
117
|
+
|
|
118
|
+
def run() -> dict[str, Any]:
|
|
119
|
+
repo = _required_repo(params)
|
|
120
|
+
number = int(params["number"])
|
|
121
|
+
return _auth().request(
|
|
122
|
+
"POST",
|
|
123
|
+
f"/repos/{repo}/issues/{number}/comments",
|
|
124
|
+
repo=repo,
|
|
125
|
+
json_body={"body": str(params["body"])},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return _handle_errors(run)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def github_app_comment_pr(params: dict[str, Any], **kwargs: Any) -> str:
|
|
132
|
+
"""Comment on a pull request using the GitHub App identity."""
|
|
133
|
+
return github_app_comment_issue(params, **kwargs)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def github_app_create_pr(params: dict[str, Any], **_: Any) -> str:
|
|
137
|
+
"""Create a pull request using the GitHub App identity."""
|
|
138
|
+
|
|
139
|
+
def run() -> dict[str, Any]:
|
|
140
|
+
repo = _required_repo(params)
|
|
141
|
+
body = {
|
|
142
|
+
"title": str(params["title"]),
|
|
143
|
+
"head": str(params["head"]),
|
|
144
|
+
"base": str(params["base"]),
|
|
145
|
+
"body": str(params.get("body", "")),
|
|
146
|
+
"draft": bool(params.get("draft", False)),
|
|
147
|
+
}
|
|
148
|
+
return _auth().request("POST", f"/repos/{repo}/pulls", repo=repo, json_body=body)
|
|
149
|
+
|
|
150
|
+
return _handle_errors(run)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _repo(params: dict[str, Any]) -> str | None:
|
|
154
|
+
value = params.get("repo")
|
|
155
|
+
return str(value) if value else None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _required_repo(params: dict[str, Any]) -> str:
|
|
159
|
+
repo = _repo(params)
|
|
160
|
+
if not repo:
|
|
161
|
+
raise ValueError("repo is required and must be in OWNER/NAME form")
|
|
162
|
+
return repo
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hermes-github-app-plugin
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Hermes plugin for per-agent GitHub App identity, gh/git wrappers, and GitHub App-aware tools.
|
|
5
|
+
Author: Hermes GitHub App Plugin Contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: httpx>=0.25
|
|
10
|
+
Requires-Dist: pyjwt[crypto]>=2.8
|
|
11
|
+
Requires-Dist: pyyaml>=6.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
16
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
17
|
+
Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# Hermes GitHub App Plugin
|
|
21
|
+
|
|
22
|
+
Hermes plugin for using **per-agent GitHub App identities** instead of a human `gh`/SSH identity.
|
|
23
|
+
|
|
24
|
+
Each Hermes agent runs the same package but is configured with its own GitHub App:
|
|
25
|
+
|
|
26
|
+
```yaml
|
|
27
|
+
github_app:
|
|
28
|
+
client_id: "Iv1.exampleclientid"
|
|
29
|
+
installation_id: "987654"
|
|
30
|
+
private_key_path: "~/.hermes/secrets/agent-github-app.private-key.pem"
|
|
31
|
+
app_slug: "hermes-agent"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Environment variables with the same meaning are also supported:
|
|
35
|
+
|
|
36
|
+
- `GITHUB_APP_CLIENT_ID`
|
|
37
|
+
- `GITHUB_APP_INSTALLATION_ID`
|
|
38
|
+
- `GITHUB_APP_PRIVATE_KEY_PATH`
|
|
39
|
+
- `GITHUB_APP_PRIVATE_KEY` (PEM contents; useful for CI)
|
|
40
|
+
|
|
41
|
+
Repository access is controlled by the GitHub App installation scope in GitHub. If an agent should not access a repository, remove that repository from the GitHub App installation scope.
|
|
42
|
+
|
|
43
|
+
## Client ID vs. installation ID
|
|
44
|
+
|
|
45
|
+
`client_id` identifies the GitHub App registration. GitHub recommends using the GitHub App **client ID** as the JWT `iss` claim when authenticating as an app.
|
|
46
|
+
|
|
47
|
+
`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
|
+
|
|
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.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install hermes-github-app-plugin
|
|
55
|
+
hermes plugins enable github-app
|
|
56
|
+
hermes-github-app setup
|
|
57
|
+
hermes-github-app doctor --repo OWNER/REPO
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`setup` walks through the required values one by one. Optional prompts are explicitly marked with `(optional)`:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
GitHub App client ID:
|
|
64
|
+
GitHub App installation ID:
|
|
65
|
+
GitHub App private key path:
|
|
66
|
+
GitHub App slug (optional):
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
For scripted installs, pass flags and skip the network verification until secrets are mounted:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
hermes-github-app setup --non-interactive --skip-verify \
|
|
73
|
+
--client-id Iv1.exampleclientid \
|
|
74
|
+
--installation-id 987654 \
|
|
75
|
+
--private-key-path ~/.hermes/secrets/agent-github-app.private-key.pem \
|
|
76
|
+
--app-slug hermes-agent
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`doctor` checks local installation state and, unless `--skip-network` is set, verifies that an installation token can be minted and the optional repository probe is reachable.
|
|
80
|
+
|
|
81
|
+
## CLI and wrappers
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
hermes-github-app setup
|
|
85
|
+
hermes-github-app doctor --repo OWNER/REPO
|
|
86
|
+
hermes-github-app status
|
|
87
|
+
hermes-github-app token --repo OWNER/REPO
|
|
88
|
+
hermes-github-app api --repo OWNER/REPO /repos/OWNER/REPO
|
|
89
|
+
|
|
90
|
+
gh-app --repo OWNER/REPO pr list -R OWNER/REPO
|
|
91
|
+
git-app --repo OWNER/REPO push origin my-branch
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`gh-app` injects an ephemeral installation token as `GH_TOKEN` and `GITHUB_TOKEN` for the child `gh` process.
|
|
95
|
+
`git-app` injects a temporary askpass helper so HTTPS Git operations authenticate as the GitHub App installation token without writing credentials into the remote URL.
|
|
96
|
+
|
|
97
|
+
## Migrating existing Hermes skills and jobs
|
|
98
|
+
|
|
99
|
+
To keep agents from falling back to local human credentials, update existing GitHub-related Hermes skills, cron jobs, and subagent prompts with these rules:
|
|
100
|
+
|
|
101
|
+
- Use `github_app_*` tools for GitHub API operations when possible.
|
|
102
|
+
- Replace authenticated `gh ...` examples with `gh-app --repo OWNER/REPO -- ...`.
|
|
103
|
+
- Replace `git push` examples with `git-app --repo OWNER/REPO -- push ...`, or another HTTPS credential-helper flow backed by a freshly minted installation token.
|
|
104
|
+
- Do not use `gh auth status` as proof of write identity; it reports local `gh` credentials and may show a human account.
|
|
105
|
+
- Avoid SSH remotes for bot-managed worktrees. SSH uses local SSH keys, not the GitHub App token.
|
|
106
|
+
- Add a pre-write check with `github_app_verify_identity` or `hermes-github-app status --repo OWNER/REPO`.
|
|
107
|
+
- Avoid `@me` assumptions because the GitHub App bot is not the human operator.
|
|
108
|
+
- Require write summaries to include the returned `auth_mode`, `app_slug`, `installation_id`, repository, operation, and URL/path.
|
|
109
|
+
|
|
110
|
+
## Releasing to PyPI
|
|
111
|
+
|
|
112
|
+
The package is built with Hatchling and publishes through the `CD` GitHub Actions workflow using PyPI Trusted Publishing / OIDC. The workflow listens to all pushed tags but only builds and publishes when the tag matches:
|
|
113
|
+
|
|
114
|
+
```text
|
|
115
|
+
^[0-9]+\.[0-9]+\.[0-9]+$
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The tag must also match `project.version` in `pyproject.toml`.
|
|
119
|
+
|
|
120
|
+
Before the first release, configure PyPI Trusted Publishing for this repository and workflow:
|
|
121
|
+
|
|
122
|
+
- PyPI project name: `hermes-github-app-plugin`
|
|
123
|
+
- Owner/repository: this GitHub repository
|
|
124
|
+
- Workflow name: `cd.yaml`
|
|
125
|
+
- Environment name: `pypi`
|
|
126
|
+
|
|
127
|
+
Release example:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
git tag 0.1.0
|
|
131
|
+
git push origin 0.1.0
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Tags like `v0.1.0`, `0.1`, or `0.1.0rc1` will not publish.
|
|
135
|
+
|
|
136
|
+
## Hermes tools
|
|
137
|
+
|
|
138
|
+
The plugin registers these tools:
|
|
139
|
+
|
|
140
|
+
- `github_app_status`
|
|
141
|
+
- `github_app_verify_identity`
|
|
142
|
+
- `github_app_api`
|
|
143
|
+
- `github_app_graphql`
|
|
144
|
+
- `github_app_create_issue`
|
|
145
|
+
- `github_app_comment_issue`
|
|
146
|
+
- `github_app_create_pr`
|
|
147
|
+
- `github_app_comment_pr`
|
|
148
|
+
|
|
149
|
+
All mutating tools return auth metadata showing App mode, installation ID, app slug, and target repository.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
hermes_github_app_plugin/__init__.py,sha256=NnRzmqrrdX7lgoqhFn8LXVyo7pCzAT9Hyx9U452RIvc,1956
|
|
2
|
+
hermes_github_app_plugin/auth.py,sha256=vsvYS5UtS-v9dLozGItm9j2PN-ZH5RCjJYE4GRI_Y7Y,5237
|
|
3
|
+
hermes_github_app_plugin/cli.py,sha256=3OruAyr8gr9ZUZ8EHjYbRLYDgMSMLQ1rXkInxT4fIxA,12292
|
|
4
|
+
hermes_github_app_plugin/config.py,sha256=m0s7IDGnnchndhjsoPzMJAONTYk8SLcZjNi0K1qiaQM,4217
|
|
5
|
+
hermes_github_app_plugin/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
hermes_github_app_plugin/schemas.py,sha256=yNDBOEvXzhDRtMWfX8mGmdMyAFtP_d9H4_j40C_BqTo,3099
|
|
7
|
+
hermes_github_app_plugin/tools.py,sha256=PSzSLYjNsT0qC1ZfwiO1e0xHIUotAhpIUPGW1BIaRzU,5148
|
|
8
|
+
hermes_github_app_plugin/skills/github-app-workflow/SKILL.md,sha256=2W2-Hp5xfR8ZyxhgBmJyP1SJmkLStP9wbNdCVsw68YI,3002
|
|
9
|
+
hermes_github_app_plugin/plugin.yaml,sha256=1bYCxwR5wVbaWWRh3mHx5Wd8ghTZIbWIyqDzhMgo09E,1088
|
|
10
|
+
hermes_github_app_plugin-0.1.0.dist-info/METADATA,sha256=J8gZTl1DxY962g35BjQd54pHSeyvgQQPvxyDvTfo3pc,5785
|
|
11
|
+
hermes_github_app_plugin-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
12
|
+
hermes_github_app_plugin-0.1.0.dist-info/entry_points.txt,sha256=Rn0zBuQAHD1rDPAI0xb9uVSJ8kiws8pKbc9adTo7GHQ,245
|
|
13
|
+
hermes_github_app_plugin-0.1.0.dist-info/licenses/LICENSE,sha256=6zZgOs9Rdg8m9nRz_a-8VmYPNTVOA45JCqXUfpUYmdE,1094
|
|
14
|
+
hermes_github_app_plugin-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hermes GitHub App Plugin Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|