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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [chp.adapters]
2
+ github = chp_adapter_github:GitHubAdapter