chp-adapter-github 0.8.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.
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""chp-adapter-github — GitHub inspection as governed CHP capabilities.
|
|
2
|
+
|
|
3
|
+
Read-only first slice: repository metadata, pull requests, issues, CI workflow
|
|
4
|
+
runs, and PR reviews — each exposed as ``chp.adapters.github.<op>`` with full
|
|
5
|
+
execution evidence. Built on the canonical chp-core adapter template
|
|
6
|
+
(``BaseAdapter`` + ``@capability``).
|
|
7
|
+
|
|
8
|
+
Usage::
|
|
9
|
+
|
|
10
|
+
from chp_core import LocalCapabilityHost, register_adapter
|
|
11
|
+
from chp_adapter_github import GitHubAdapter, GitHubConfig
|
|
12
|
+
|
|
13
|
+
host = LocalCapabilityHost()
|
|
14
|
+
register_adapter(host, GitHubAdapter(GitHubConfig())) # token from GITHUB_TOKEN
|
|
15
|
+
pr = host.invoke("chp.adapters.github.get_pull_request",
|
|
16
|
+
{"owner": "capabilityhostprotocol", "repo": "chp-core", "number": 1})
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from .adapter import GitHubAdapter, GitHubConfig
|
|
22
|
+
|
|
23
|
+
__all__ = ["GitHubAdapter", "GitHubConfig"]
|
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
"""GitHubAdapter — GitHub repository inspection and write operations as CHP capabilities.
|
|
2
|
+
|
|
3
|
+
Each capability proxies one GitHub REST endpoint, emits a full evidence chain,
|
|
4
|
+
and returns a curated projection (a capability, not a raw API mirror). A fresh
|
|
5
|
+
``httpx.AsyncClient`` is created per call because the host runs handlers via
|
|
6
|
+
``asyncio.run`` (a new event loop per ``host.invoke``) and an AsyncClient binds
|
|
7
|
+
its connection pool to the loop it was created on — per-call clients keep the
|
|
8
|
+
adapter loop-safe with no shared state.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from chp_core import BaseAdapter, capability
|
|
18
|
+
|
|
19
|
+
_HTTP_CAP = "chp.adapters.http.request"
|
|
20
|
+
|
|
21
|
+
MAX_ERROR_LEN = 500
|
|
22
|
+
_API_VERSION = "2022-11-28"
|
|
23
|
+
_DEFAULT_BASE_URL = "https://api.github.com"
|
|
24
|
+
_DEFAULT_TIMEOUT = 30.0
|
|
25
|
+
|
|
26
|
+
# Domain events only — the host owns execution_started/completed/failed.
|
|
27
|
+
_EMITS = [
|
|
28
|
+
"github_request",
|
|
29
|
+
"github_response",
|
|
30
|
+
"github_error",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
# Reusable JSON Schema fragments
|
|
34
|
+
_OWNER_REPO = {
|
|
35
|
+
"owner": {"type": "string", "minLength": 1},
|
|
36
|
+
"repo": {"type": "string", "minLength": 1},
|
|
37
|
+
}
|
|
38
|
+
_STATE = {"type": "string", "enum": ["open", "closed", "all"]}
|
|
39
|
+
_LIMIT = {"type": "integer", "minimum": 1, "maximum": 100}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(slots=True)
|
|
43
|
+
class GitHubConfig:
|
|
44
|
+
"""Connection config for the GitHub adapter.
|
|
45
|
+
|
|
46
|
+
``token`` defaults to the ``GITHUB_TOKEN`` / ``GH_TOKEN`` env var. A token is
|
|
47
|
+
optional (public reads work unauthenticated, at a lower rate limit).
|
|
48
|
+
|
|
49
|
+
HTTP is performed by composing through ``chp.adapters.http`` (the sole
|
|
50
|
+
sanctioned transport) — this adapter imports no HTTP client. Tests inject an
|
|
51
|
+
``httpx.MockTransport`` on a registered ``HttpAdapter``, not here.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
token: str | None = None
|
|
55
|
+
base_url: str = _DEFAULT_BASE_URL
|
|
56
|
+
timeout: float = _DEFAULT_TIMEOUT
|
|
57
|
+
|
|
58
|
+
def resolved_token(self) -> str | None:
|
|
59
|
+
return self.token or os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class GitHubAdapter(BaseAdapter):
|
|
63
|
+
"""GitHub repository / PR / issue / CI inspection as CHP capabilities."""
|
|
64
|
+
|
|
65
|
+
adapter_id = "chp.adapters.github"
|
|
66
|
+
adapter_name = "GitHub"
|
|
67
|
+
adapter_description = "GitHub inspection and write operations (repos, PRs, issues, CI, reviews)."
|
|
68
|
+
adapter_category = "integration"
|
|
69
|
+
adapter_tags = ["github", "vcs", "ci"]
|
|
70
|
+
|
|
71
|
+
def __init__(self, config: GitHubConfig | None = None) -> None:
|
|
72
|
+
self._config = config or GitHubConfig()
|
|
73
|
+
|
|
74
|
+
# -- HTTP + evidence core ----------------------------------------------
|
|
75
|
+
|
|
76
|
+
def _headers(self) -> dict[str, str]:
|
|
77
|
+
headers = {
|
|
78
|
+
"Accept": "application/vnd.github+json",
|
|
79
|
+
"X-GitHub-Api-Version": _API_VERSION,
|
|
80
|
+
}
|
|
81
|
+
token = self._config.resolved_token()
|
|
82
|
+
if token:
|
|
83
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
84
|
+
return headers
|
|
85
|
+
|
|
86
|
+
async def _http(
|
|
87
|
+
self,
|
|
88
|
+
ctx: Any,
|
|
89
|
+
*,
|
|
90
|
+
op: str,
|
|
91
|
+
method: str,
|
|
92
|
+
path: str,
|
|
93
|
+
params: dict[str, Any] | None = None,
|
|
94
|
+
json_body: dict | None = None,
|
|
95
|
+
started: dict[str, Any] | None = None,
|
|
96
|
+
) -> Any:
|
|
97
|
+
"""Perform a GitHub API call by composing through chp.adapters.http.
|
|
98
|
+
|
|
99
|
+
This adapter imports no HTTP client; the request (with retries +
|
|
100
|
+
circuit-breaking) is governed by the http transport adapter, which
|
|
101
|
+
produces its own evidence chain. We emit GitHub-domain events here.
|
|
102
|
+
"""
|
|
103
|
+
ctx.emit("github_request", {"op": op, "method": method, "path": path, **(started or {})}, redacted=False)
|
|
104
|
+
|
|
105
|
+
req: dict[str, Any] = {
|
|
106
|
+
"method": method,
|
|
107
|
+
"url": self._config.base_url.rstrip("/") + path,
|
|
108
|
+
"headers": self._headers(),
|
|
109
|
+
"timeout": self._config.timeout,
|
|
110
|
+
}
|
|
111
|
+
if params:
|
|
112
|
+
req["params"] = {k: str(v) for k, v in params.items()}
|
|
113
|
+
if json_body is not None:
|
|
114
|
+
req["json_body"] = json_body
|
|
115
|
+
|
|
116
|
+
result = await ctx.ainvoke(_HTTP_CAP, req)
|
|
117
|
+
if not getattr(result, "success", False):
|
|
118
|
+
ctx.emit("github_error", {
|
|
119
|
+
"op": op,
|
|
120
|
+
"reason": "http_unavailable",
|
|
121
|
+
"error": str(getattr(result, "error", "http adapter unavailable"))[:MAX_ERROR_LEN],
|
|
122
|
+
}, redacted=False)
|
|
123
|
+
raise RuntimeError(f"GitHub {op} failed: http adapter unavailable (is chp.adapters.http registered?)")
|
|
124
|
+
|
|
125
|
+
data = result.data
|
|
126
|
+
status = data.get("status_code")
|
|
127
|
+
rate_remaining = (data.get("headers") or {}).get("x-ratelimit-remaining")
|
|
128
|
+
if status is None or status >= 400:
|
|
129
|
+
body_json = data.get("json")
|
|
130
|
+
msg = body_json.get("message") if isinstance(body_json, dict) else None
|
|
131
|
+
ctx.emit("github_error", {
|
|
132
|
+
"op": op,
|
|
133
|
+
"status": status,
|
|
134
|
+
"error": str(msg or data.get("body") or f"HTTP {status}")[:MAX_ERROR_LEN],
|
|
135
|
+
"rate_remaining": rate_remaining,
|
|
136
|
+
}, redacted=False)
|
|
137
|
+
raise RuntimeError(f"GitHub {op} failed: HTTP {status}")
|
|
138
|
+
|
|
139
|
+
ctx.emit("github_response", {
|
|
140
|
+
"op": op,
|
|
141
|
+
"status": status,
|
|
142
|
+
"rate_remaining": rate_remaining,
|
|
143
|
+
}, redacted=False)
|
|
144
|
+
return data.get("json")
|
|
145
|
+
|
|
146
|
+
async def _request(
|
|
147
|
+
self,
|
|
148
|
+
ctx: Any,
|
|
149
|
+
*,
|
|
150
|
+
op: str,
|
|
151
|
+
path: str,
|
|
152
|
+
params: dict[str, Any] | None = None,
|
|
153
|
+
started: dict[str, Any] | None = None,
|
|
154
|
+
) -> Any:
|
|
155
|
+
return await self._http(ctx, op=op, method="GET", path=path, params=params, started=started)
|
|
156
|
+
|
|
157
|
+
# -- capabilities -------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
@capability(
|
|
160
|
+
id="chp.adapters.github.get_repo",
|
|
161
|
+
version="1.0.0",
|
|
162
|
+
description="Get repository metadata.",
|
|
163
|
+
category="integration", provider="github", risk="low", emits=_EMITS,
|
|
164
|
+
input_schema={
|
|
165
|
+
"type": "object",
|
|
166
|
+
"properties": dict(_OWNER_REPO),
|
|
167
|
+
"required": ["owner", "repo"],
|
|
168
|
+
"additionalProperties": False,
|
|
169
|
+
},
|
|
170
|
+
)
|
|
171
|
+
async def get_repo(self, ctx, payload):
|
|
172
|
+
owner, repo = payload["owner"], payload["repo"]
|
|
173
|
+
data = await self._request(
|
|
174
|
+
ctx, op="get_repo", path=f"/repos/{owner}/{repo}",
|
|
175
|
+
started={"owner": owner, "repo": repo},
|
|
176
|
+
)
|
|
177
|
+
return _project_repo(data)
|
|
178
|
+
|
|
179
|
+
@capability(
|
|
180
|
+
id="chp.adapters.github.list_pull_requests",
|
|
181
|
+
version="1.0.0",
|
|
182
|
+
description="List pull requests for a repository.",
|
|
183
|
+
category="integration", provider="github", risk="low", emits=_EMITS,
|
|
184
|
+
input_schema={
|
|
185
|
+
"type": "object",
|
|
186
|
+
"properties": {**_OWNER_REPO, "state": _STATE, "limit": _LIMIT},
|
|
187
|
+
"required": ["owner", "repo"],
|
|
188
|
+
"additionalProperties": False,
|
|
189
|
+
},
|
|
190
|
+
)
|
|
191
|
+
async def list_pull_requests(self, ctx, payload):
|
|
192
|
+
owner, repo = payload["owner"], payload["repo"]
|
|
193
|
+
state = payload.get("state", "open")
|
|
194
|
+
limit = int(payload.get("limit", 30))
|
|
195
|
+
data = await self._request(
|
|
196
|
+
ctx, op="list_pull_requests", path=f"/repos/{owner}/{repo}/pulls",
|
|
197
|
+
params={"state": state, "per_page": limit},
|
|
198
|
+
started={"owner": owner, "repo": repo, "state": state},
|
|
199
|
+
)
|
|
200
|
+
return {"pull_requests": [_project_pr_summary(p) for p in data]}
|
|
201
|
+
|
|
202
|
+
@capability(
|
|
203
|
+
id="chp.adapters.github.get_pull_request",
|
|
204
|
+
version="1.0.0",
|
|
205
|
+
description="Get a single pull request with merge/CI detail.",
|
|
206
|
+
category="integration", provider="github", risk="low", emits=_EMITS,
|
|
207
|
+
input_schema={
|
|
208
|
+
"type": "object",
|
|
209
|
+
"properties": {**_OWNER_REPO, "number": {"type": "integer", "minimum": 1}},
|
|
210
|
+
"required": ["owner", "repo", "number"],
|
|
211
|
+
"additionalProperties": False,
|
|
212
|
+
},
|
|
213
|
+
)
|
|
214
|
+
async def get_pull_request(self, ctx, payload):
|
|
215
|
+
owner, repo, number = payload["owner"], payload["repo"], payload["number"]
|
|
216
|
+
data = await self._request(
|
|
217
|
+
ctx, op="get_pull_request", path=f"/repos/{owner}/{repo}/pulls/{number}",
|
|
218
|
+
started={"owner": owner, "repo": repo, "number": number},
|
|
219
|
+
)
|
|
220
|
+
return _project_pr(data)
|
|
221
|
+
|
|
222
|
+
@capability(
|
|
223
|
+
id="chp.adapters.github.list_issues",
|
|
224
|
+
version="1.0.0",
|
|
225
|
+
description="List issues for a repository (pull requests excluded).",
|
|
226
|
+
category="integration", provider="github", risk="low", emits=_EMITS,
|
|
227
|
+
input_schema={
|
|
228
|
+
"type": "object",
|
|
229
|
+
"properties": {**_OWNER_REPO, "state": _STATE, "limit": _LIMIT},
|
|
230
|
+
"required": ["owner", "repo"],
|
|
231
|
+
"additionalProperties": False,
|
|
232
|
+
},
|
|
233
|
+
)
|
|
234
|
+
async def list_issues(self, ctx, payload):
|
|
235
|
+
owner, repo = payload["owner"], payload["repo"]
|
|
236
|
+
state = payload.get("state", "open")
|
|
237
|
+
limit = int(payload.get("limit", 30))
|
|
238
|
+
data = await self._request(
|
|
239
|
+
ctx, op="list_issues", path=f"/repos/{owner}/{repo}/issues",
|
|
240
|
+
params={"state": state, "per_page": limit},
|
|
241
|
+
started={"owner": owner, "repo": repo, "state": state},
|
|
242
|
+
)
|
|
243
|
+
# The issues endpoint also returns PRs; filter them out.
|
|
244
|
+
issues = [_project_issue(i) for i in data if "pull_request" not in i]
|
|
245
|
+
return {"issues": issues}
|
|
246
|
+
|
|
247
|
+
@capability(
|
|
248
|
+
id="chp.adapters.github.get_issue",
|
|
249
|
+
version="1.0.0",
|
|
250
|
+
description="Get a single issue.",
|
|
251
|
+
category="integration", provider="github", risk="low", emits=_EMITS,
|
|
252
|
+
input_schema={
|
|
253
|
+
"type": "object",
|
|
254
|
+
"properties": {**_OWNER_REPO, "number": {"type": "integer", "minimum": 1}},
|
|
255
|
+
"required": ["owner", "repo", "number"],
|
|
256
|
+
"additionalProperties": False,
|
|
257
|
+
},
|
|
258
|
+
)
|
|
259
|
+
async def get_issue(self, ctx, payload):
|
|
260
|
+
owner, repo, number = payload["owner"], payload["repo"], payload["number"]
|
|
261
|
+
data = await self._request(
|
|
262
|
+
ctx, op="get_issue", path=f"/repos/{owner}/{repo}/issues/{number}",
|
|
263
|
+
started={"owner": owner, "repo": repo, "number": number},
|
|
264
|
+
)
|
|
265
|
+
return _project_issue(data)
|
|
266
|
+
|
|
267
|
+
@capability(
|
|
268
|
+
id="chp.adapters.github.list_workflow_runs",
|
|
269
|
+
version="1.0.0",
|
|
270
|
+
description="List recent GitHub Actions workflow runs (CI status).",
|
|
271
|
+
category="integration", provider="github", risk="low", emits=_EMITS,
|
|
272
|
+
input_schema={
|
|
273
|
+
"type": "object",
|
|
274
|
+
"properties": {**_OWNER_REPO, "branch": {"type": "string"}, "limit": _LIMIT},
|
|
275
|
+
"required": ["owner", "repo"],
|
|
276
|
+
"additionalProperties": False,
|
|
277
|
+
},
|
|
278
|
+
)
|
|
279
|
+
async def list_workflow_runs(self, ctx, payload):
|
|
280
|
+
owner, repo = payload["owner"], payload["repo"]
|
|
281
|
+
limit = int(payload.get("limit", 20))
|
|
282
|
+
params: dict[str, Any] = {"per_page": limit}
|
|
283
|
+
if payload.get("branch"):
|
|
284
|
+
params["branch"] = payload["branch"]
|
|
285
|
+
data = await self._request(
|
|
286
|
+
ctx, op="list_workflow_runs", path=f"/repos/{owner}/{repo}/actions/runs",
|
|
287
|
+
params=params, started={"owner": owner, "repo": repo},
|
|
288
|
+
)
|
|
289
|
+
runs = [_project_run(r) for r in data.get("workflow_runs", [])]
|
|
290
|
+
return {"total_count": data.get("total_count", len(runs)), "runs": runs}
|
|
291
|
+
|
|
292
|
+
@capability(
|
|
293
|
+
id="chp.adapters.github.list_pr_reviews",
|
|
294
|
+
version="1.0.0",
|
|
295
|
+
description="List reviews on a pull request.",
|
|
296
|
+
category="integration", provider="github", risk="low", emits=_EMITS,
|
|
297
|
+
input_schema={
|
|
298
|
+
"type": "object",
|
|
299
|
+
"properties": {**_OWNER_REPO, "number": {"type": "integer", "minimum": 1}},
|
|
300
|
+
"required": ["owner", "repo", "number"],
|
|
301
|
+
"additionalProperties": False,
|
|
302
|
+
},
|
|
303
|
+
)
|
|
304
|
+
async def list_pr_reviews(self, ctx, payload):
|
|
305
|
+
owner, repo, number = payload["owner"], payload["repo"], payload["number"]
|
|
306
|
+
data = await self._request(
|
|
307
|
+
ctx, op="list_pr_reviews", path=f"/repos/{owner}/{repo}/pulls/{number}/reviews",
|
|
308
|
+
started={"owner": owner, "repo": repo, "number": number},
|
|
309
|
+
)
|
|
310
|
+
return {"reviews": [_project_review(r) for r in data]}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# -- write helper -------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
async def _mutate(
|
|
316
|
+
self,
|
|
317
|
+
ctx: Any,
|
|
318
|
+
*,
|
|
319
|
+
op: str,
|
|
320
|
+
method: str,
|
|
321
|
+
path: str,
|
|
322
|
+
json_body: dict,
|
|
323
|
+
started: dict[str, Any] | None = None,
|
|
324
|
+
) -> Any:
|
|
325
|
+
return await self._http(ctx, op=op, method=method, path=path, json_body=json_body, started=started)
|
|
326
|
+
|
|
327
|
+
# -- write capabilities -------------------------------------------------
|
|
328
|
+
|
|
329
|
+
@capability(
|
|
330
|
+
id="chp.adapters.github.create_issue",
|
|
331
|
+
version="1.0.0",
|
|
332
|
+
description="Create a new issue in a repository.",
|
|
333
|
+
category="integration", provider="github", risk="medium", emits=_EMITS,
|
|
334
|
+
input_schema={
|
|
335
|
+
"type": "object",
|
|
336
|
+
"properties": {
|
|
337
|
+
**_OWNER_REPO,
|
|
338
|
+
"title": {"type": "string", "minLength": 1},
|
|
339
|
+
"body": {"type": "string", "description": "Issue body (not stored in evidence)."},
|
|
340
|
+
"labels": {"type": "array", "items": {"type": "string"}},
|
|
341
|
+
"assignees": {"type": "array", "items": {"type": "string"}},
|
|
342
|
+
},
|
|
343
|
+
"required": ["owner", "repo", "title"],
|
|
344
|
+
"additionalProperties": False,
|
|
345
|
+
},
|
|
346
|
+
)
|
|
347
|
+
async def create_issue(self, ctx, payload):
|
|
348
|
+
owner, repo = payload["owner"], payload["repo"]
|
|
349
|
+
title = payload["title"]
|
|
350
|
+
body: dict[str, Any] = {"title": title}
|
|
351
|
+
if payload.get("body"):
|
|
352
|
+
body["body"] = payload["body"] # sent to GitHub, not in evidence
|
|
353
|
+
if payload.get("labels"):
|
|
354
|
+
body["labels"] = payload["labels"]
|
|
355
|
+
if payload.get("assignees"):
|
|
356
|
+
body["assignees"] = payload["assignees"]
|
|
357
|
+
|
|
358
|
+
data = await self._mutate(
|
|
359
|
+
ctx, op="create_issue", method="POST",
|
|
360
|
+
path=f"/repos/{owner}/{repo}/issues",
|
|
361
|
+
json_body=body,
|
|
362
|
+
started={"owner": owner, "repo": repo, "title": title,
|
|
363
|
+
"labels": payload.get("labels", [])},
|
|
364
|
+
)
|
|
365
|
+
return _project_issue(data)
|
|
366
|
+
|
|
367
|
+
@capability(
|
|
368
|
+
id="chp.adapters.github.create_comment",
|
|
369
|
+
version="1.0.0",
|
|
370
|
+
description="Add a comment to an issue or pull request.",
|
|
371
|
+
category="integration", provider="github", risk="medium", emits=_EMITS,
|
|
372
|
+
input_schema={
|
|
373
|
+
"type": "object",
|
|
374
|
+
"properties": {
|
|
375
|
+
**_OWNER_REPO,
|
|
376
|
+
"number": {"type": "integer", "minimum": 1},
|
|
377
|
+
"body": {"type": "string", "minLength": 1,
|
|
378
|
+
"description": "Comment text (not stored in evidence)."},
|
|
379
|
+
},
|
|
380
|
+
"required": ["owner", "repo", "number", "body"],
|
|
381
|
+
"additionalProperties": False,
|
|
382
|
+
},
|
|
383
|
+
)
|
|
384
|
+
async def create_comment(self, ctx, payload):
|
|
385
|
+
owner, repo, number = payload["owner"], payload["repo"], payload["number"]
|
|
386
|
+
# body sent to GitHub but intentionally absent from evidence
|
|
387
|
+
data = await self._mutate(
|
|
388
|
+
ctx, op="create_comment", method="POST",
|
|
389
|
+
path=f"/repos/{owner}/{repo}/issues/{number}/comments",
|
|
390
|
+
json_body={"body": payload["body"]},
|
|
391
|
+
started={"owner": owner, "repo": repo, "number": number},
|
|
392
|
+
)
|
|
393
|
+
return {"id": data.get("id"), "html_url": data.get("html_url"),
|
|
394
|
+
"created_at": data.get("created_at")}
|
|
395
|
+
|
|
396
|
+
@capability(
|
|
397
|
+
id="chp.adapters.github.update_issue",
|
|
398
|
+
version="1.0.0",
|
|
399
|
+
description="Update an issue (title, state, labels, assignees).",
|
|
400
|
+
category="integration", provider="github", risk="medium", emits=_EMITS,
|
|
401
|
+
input_schema={
|
|
402
|
+
"type": "object",
|
|
403
|
+
"properties": {
|
|
404
|
+
**_OWNER_REPO,
|
|
405
|
+
"number": {"type": "integer", "minimum": 1},
|
|
406
|
+
"title": {"type": "string"},
|
|
407
|
+
"state": {"type": "string", "enum": ["open", "closed"]},
|
|
408
|
+
"body": {"type": "string", "description": "Updated body (not stored in evidence)."},
|
|
409
|
+
"labels": {"type": "array", "items": {"type": "string"}},
|
|
410
|
+
"assignees": {"type": "array", "items": {"type": "string"}},
|
|
411
|
+
},
|
|
412
|
+
"required": ["owner", "repo", "number"],
|
|
413
|
+
"additionalProperties": False,
|
|
414
|
+
},
|
|
415
|
+
)
|
|
416
|
+
async def update_issue(self, ctx, payload):
|
|
417
|
+
owner, repo, number = payload["owner"], payload["repo"], payload["number"]
|
|
418
|
+
update: dict[str, Any] = {}
|
|
419
|
+
if payload.get("title") is not None:
|
|
420
|
+
update["title"] = payload["title"]
|
|
421
|
+
if payload.get("state") is not None:
|
|
422
|
+
update["state"] = payload["state"]
|
|
423
|
+
if payload.get("body") is not None:
|
|
424
|
+
update["body"] = payload["body"] # not in evidence
|
|
425
|
+
if payload.get("labels") is not None:
|
|
426
|
+
update["labels"] = payload["labels"]
|
|
427
|
+
if payload.get("assignees") is not None:
|
|
428
|
+
update["assignees"] = payload["assignees"]
|
|
429
|
+
|
|
430
|
+
data = await self._mutate(
|
|
431
|
+
ctx, op="update_issue", method="PATCH",
|
|
432
|
+
path=f"/repos/{owner}/{repo}/issues/{number}",
|
|
433
|
+
json_body=update,
|
|
434
|
+
started={"owner": owner, "repo": repo, "number": number,
|
|
435
|
+
"fields": [k for k in update if k != "body"]},
|
|
436
|
+
)
|
|
437
|
+
return _project_issue(data)
|
|
438
|
+
|
|
439
|
+
@capability(
|
|
440
|
+
id="chp.adapters.github.create_pull_request",
|
|
441
|
+
version="1.0.0",
|
|
442
|
+
description="Open a new pull request.",
|
|
443
|
+
category="integration", provider="github", risk="medium", emits=_EMITS,
|
|
444
|
+
input_schema={
|
|
445
|
+
"type": "object",
|
|
446
|
+
"properties": {
|
|
447
|
+
**_OWNER_REPO,
|
|
448
|
+
"title": {"type": "string", "minLength": 1},
|
|
449
|
+
"head": {"type": "string", "description": "Source branch (or user:branch)."},
|
|
450
|
+
"base": {"type": "string", "description": "Target branch."},
|
|
451
|
+
"body": {"type": "string", "description": "PR description (not stored in evidence)."},
|
|
452
|
+
"draft": {"type": "boolean"},
|
|
453
|
+
},
|
|
454
|
+
"required": ["owner", "repo", "title", "head", "base"],
|
|
455
|
+
"additionalProperties": False,
|
|
456
|
+
},
|
|
457
|
+
)
|
|
458
|
+
async def create_pull_request(self, ctx, payload):
|
|
459
|
+
owner, repo = payload["owner"], payload["repo"]
|
|
460
|
+
title, head, base = payload["title"], payload["head"], payload["base"]
|
|
461
|
+
pr_body: dict[str, Any] = {"title": title, "head": head, "base": base}
|
|
462
|
+
if payload.get("body"):
|
|
463
|
+
pr_body["body"] = payload["body"] # not in evidence
|
|
464
|
+
if payload.get("draft") is not None:
|
|
465
|
+
pr_body["draft"] = payload["draft"]
|
|
466
|
+
|
|
467
|
+
data = await self._mutate(
|
|
468
|
+
ctx, op="create_pull_request", method="POST",
|
|
469
|
+
path=f"/repos/{owner}/{repo}/pulls",
|
|
470
|
+
json_body=pr_body,
|
|
471
|
+
started={"owner": owner, "repo": repo, "title": title,
|
|
472
|
+
"head": head, "base": base},
|
|
473
|
+
)
|
|
474
|
+
projected = _project_pr(data)
|
|
475
|
+
# Emit PR identity so number + URL are queryable without inspecting invocation blobs
|
|
476
|
+
ctx.emit("github_response", {
|
|
477
|
+
"op": "pr_created",
|
|
478
|
+
"pr_number": projected["number"],
|
|
479
|
+
"pr_url": projected["html_url"],
|
|
480
|
+
"owner": owner,
|
|
481
|
+
"repo": repo,
|
|
482
|
+
})
|
|
483
|
+
return projected
|
|
484
|
+
|
|
485
|
+
@capability(
|
|
486
|
+
id="chp.adapters.github.add_labels",
|
|
487
|
+
version="1.0.0",
|
|
488
|
+
description="Add labels to an issue or pull request.",
|
|
489
|
+
category="integration", provider="github", risk="medium", emits=_EMITS,
|
|
490
|
+
input_schema={
|
|
491
|
+
"type": "object",
|
|
492
|
+
"properties": {
|
|
493
|
+
**_OWNER_REPO,
|
|
494
|
+
"number": {"type": "integer", "minimum": 1},
|
|
495
|
+
"labels": {"type": "array", "items": {"type": "string"}, "minItems": 1},
|
|
496
|
+
},
|
|
497
|
+
"required": ["owner", "repo", "number", "labels"],
|
|
498
|
+
"additionalProperties": False,
|
|
499
|
+
},
|
|
500
|
+
)
|
|
501
|
+
async def add_labels(self, ctx, payload):
|
|
502
|
+
owner, repo, number = payload["owner"], payload["repo"], payload["number"]
|
|
503
|
+
labels = payload["labels"]
|
|
504
|
+
data = await self._mutate(
|
|
505
|
+
ctx, op="add_labels", method="POST",
|
|
506
|
+
path=f"/repos/{owner}/{repo}/issues/{number}/labels",
|
|
507
|
+
json_body={"labels": labels},
|
|
508
|
+
started={"owner": owner, "repo": repo, "number": number, "labels": labels},
|
|
509
|
+
)
|
|
510
|
+
applied = [l["name"] for l in data if isinstance(l, dict) and "name" in l]
|
|
511
|
+
return {"number": number, "labels": applied}
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# --------------------------------------------------------------------------
|
|
515
|
+
# Projections — curated subsets (capabilities, not raw API mirrors)
|
|
516
|
+
# --------------------------------------------------------------------------
|
|
517
|
+
|
|
518
|
+
def _login(user: Any) -> str | None:
|
|
519
|
+
return user.get("login") if isinstance(user, dict) else None
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _project_repo(d: dict) -> dict:
|
|
523
|
+
return {
|
|
524
|
+
"full_name": d.get("full_name"),
|
|
525
|
+
"description": d.get("description"),
|
|
526
|
+
"default_branch": d.get("default_branch"),
|
|
527
|
+
"private": d.get("private"),
|
|
528
|
+
"open_issues_count": d.get("open_issues_count"),
|
|
529
|
+
"stargazers_count": d.get("stargazers_count"),
|
|
530
|
+
"html_url": d.get("html_url"),
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _project_pr_summary(d: dict) -> dict:
|
|
535
|
+
return {
|
|
536
|
+
"number": d.get("number"),
|
|
537
|
+
"title": d.get("title"),
|
|
538
|
+
"state": d.get("state"),
|
|
539
|
+
"draft": d.get("draft"),
|
|
540
|
+
"user": _login(d.get("user")),
|
|
541
|
+
"html_url": d.get("html_url"),
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _project_pr(d: dict) -> dict:
|
|
546
|
+
return {
|
|
547
|
+
**_project_pr_summary(d),
|
|
548
|
+
"merged": d.get("merged"),
|
|
549
|
+
"mergeable": d.get("mergeable"),
|
|
550
|
+
"mergeable_state": d.get("mergeable_state"),
|
|
551
|
+
"head_ref": (d.get("head") or {}).get("ref"),
|
|
552
|
+
"base_ref": (d.get("base") or {}).get("ref"),
|
|
553
|
+
"additions": d.get("additions"),
|
|
554
|
+
"deletions": d.get("deletions"),
|
|
555
|
+
"changed_files": d.get("changed_files"),
|
|
556
|
+
"comments": d.get("comments"),
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _project_issue(d: dict) -> dict:
|
|
561
|
+
return {
|
|
562
|
+
"number": d.get("number"),
|
|
563
|
+
"title": d.get("title"),
|
|
564
|
+
"state": d.get("state"),
|
|
565
|
+
"user": _login(d.get("user")),
|
|
566
|
+
"labels": [l.get("name") for l in d.get("labels", []) if isinstance(l, dict)],
|
|
567
|
+
"comments": d.get("comments"),
|
|
568
|
+
"html_url": d.get("html_url"),
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _project_run(d: dict) -> dict:
|
|
573
|
+
return {
|
|
574
|
+
"id": d.get("id"),
|
|
575
|
+
"name": d.get("name"),
|
|
576
|
+
"status": d.get("status"),
|
|
577
|
+
"conclusion": d.get("conclusion"),
|
|
578
|
+
"head_branch": d.get("head_branch"),
|
|
579
|
+
"event": d.get("event"),
|
|
580
|
+
"html_url": d.get("html_url"),
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _project_review(d: dict) -> dict:
|
|
585
|
+
return {
|
|
586
|
+
"user": _login(d.get("user")),
|
|
587
|
+
"state": d.get("state"),
|
|
588
|
+
"submitted_at": d.get("submitted_at"),
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chp-adapter-github
|
|
3
|
+
Version: 0.8.0
|
|
4
|
+
Summary: CHP capability adapter — GitHub repository, pull request, issue, and CI inspection as governed CHP capabilities
|
|
5
|
+
Author: Auxo
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Keywords: adapter,agents,capability-host-protocol,chp,ci,github
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: chp-core>=0.7.0
|
|
19
|
+
Requires-Dist: httpx>=0.27
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# chp-adapter-github
|
|
25
|
+
|
|
26
|
+
GitHub inspection as governed [Capability Host Protocol](https://capabilityhostprotocol.com)
|
|
27
|
+
capabilities. Read-only first slice: repositories, pull requests, issues, CI
|
|
28
|
+
workflow runs, and PR reviews — each exposed as `chp.adapters.github.<op>` with a
|
|
29
|
+
full execution evidence chain.
|
|
30
|
+
|
|
31
|
+
Built on the canonical chp-core adapter template (`BaseAdapter` + `@capability`),
|
|
32
|
+
the same pattern as `chp-adapter-mcp`.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install chp-adapter-github
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from chp_core import LocalCapabilityHost, register_adapter
|
|
44
|
+
from chp_adapter_github import GitHubAdapter, GitHubConfig
|
|
45
|
+
|
|
46
|
+
host = LocalCapabilityHost()
|
|
47
|
+
register_adapter(host, GitHubAdapter(GitHubConfig())) # token from GITHUB_TOKEN
|
|
48
|
+
|
|
49
|
+
pr = host.invoke("chp.adapters.github.get_pull_request", {
|
|
50
|
+
"owner": "capabilityhostprotocol", "repo": "chp-core", "number": 1,
|
|
51
|
+
})
|
|
52
|
+
print(pr.data["state"], pr.data["mergeable_state"])
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Capabilities (read-only)
|
|
56
|
+
|
|
57
|
+
| Capability | Purpose |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `chp.adapters.github.get_repo` | Repository metadata |
|
|
60
|
+
| `chp.adapters.github.list_pull_requests` | List PRs (state filter) |
|
|
61
|
+
| `chp.adapters.github.get_pull_request` | Single PR + merge/CI detail |
|
|
62
|
+
| `chp.adapters.github.list_issues` | List issues (PRs excluded) |
|
|
63
|
+
| `chp.adapters.github.get_issue` | Single issue |
|
|
64
|
+
| `chp.adapters.github.list_workflow_runs` | GitHub Actions CI status |
|
|
65
|
+
| `chp.adapters.github.list_pr_reviews` | Reviews on a PR |
|
|
66
|
+
|
|
67
|
+
Each returns a **curated projection** — a capability, not a raw API mirror.
|
|
68
|
+
|
|
69
|
+
## Auth
|
|
70
|
+
|
|
71
|
+
`GitHubConfig.token` (or the `GITHUB_TOKEN` / `GH_TOKEN` env var). A token is
|
|
72
|
+
optional for public reads but raises the rate limit. The token is sent in the
|
|
73
|
+
`Authorization` header and never appears in evidence payloads.
|
|
74
|
+
|
|
75
|
+
## Design notes
|
|
76
|
+
|
|
77
|
+
- A fresh `httpx.AsyncClient` is created per call: the host runs handlers via
|
|
78
|
+
`asyncio.run` (a new loop per `host.invoke`), and an AsyncClient is loop-bound.
|
|
79
|
+
- `GitHubConfig.transport` accepts an `httpx` transport (e.g.
|
|
80
|
+
`httpx.MockTransport`) for tests — no network required.
|
|
81
|
+
- Write operations (create issue, comment, etc.) are a deliberate later slice;
|
|
82
|
+
they carry a higher risk tier and policy gates.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
chp_adapter_github/__init__.py,sha256=iOWJf06L5q5I46ipv7IKENmDU_ap48lEB6ROKyHmWrY,882
|
|
2
|
+
chp_adapter_github/adapter.py,sha256=FfEDsdVHVFqwx2w-uE20BrUeKnvvSzHhopE8f_OsU4w,23027
|
|
3
|
+
chp_adapter_github-0.8.0.dist-info/METADATA,sha256=ZK0PU581zSRRk7iGp83G2JGF6q5Q_pMZrQ5BvBnJ8HU,3123
|
|
4
|
+
chp_adapter_github-0.8.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
5
|
+
chp_adapter_github-0.8.0.dist-info/entry_points.txt,sha256=zQ8KKFKE0BFuYWu05K_V9-jmuUBk9dsZiUtVPIZJEj4,57
|
|
6
|
+
chp_adapter_github-0.8.0.dist-info/RECORD,,
|