chipgraph 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.
Files changed (252) hide show
  1. chipgraph/__init__.py +7 -0
  2. chipgraph/adapters/__init__.py +1 -0
  3. chipgraph/adapters/format/__init__.py +6 -0
  4. chipgraph/adapters/format/__main__.py +16 -0
  5. chipgraph/adapters/format/_yaml_lines.py +54 -0
  6. chipgraph/adapters/format/chip_yaml.py +323 -0
  7. chipgraph/adapters/format/qsoc_contract.py +246 -0
  8. chipgraph/adapters/llm/__init__.py +58 -0
  9. chipgraph/adapters/llm/_budget.py +173 -0
  10. chipgraph/adapters/llm/_errors.py +50 -0
  11. chipgraph/adapters/llm/_nda.py +30 -0
  12. chipgraph/adapters/llm/_retry.py +85 -0
  13. chipgraph/adapters/llm/anthropic.py +247 -0
  14. chipgraph/adapters/llm/decide_backend.py +179 -0
  15. chipgraph/adapters/llm/fake.py +87 -0
  16. chipgraph/adapters/llm/guard.py +68 -0
  17. chipgraph/adapters/parser/__init__.py +22 -0
  18. chipgraph/adapters/parser/cocotb.py +304 -0
  19. chipgraph/adapters/parser/generic_regex.py +75 -0
  20. chipgraph/adapters/parser/verible.py +55 -0
  21. chipgraph/adapters/parser/verilator.py +46 -0
  22. chipgraph/adapters/review/__init__.py +7 -0
  23. chipgraph/adapters/review/file.py +123 -0
  24. chipgraph/adapters/review/github.py +886 -0
  25. chipgraph/adapters/runner/__init__.py +7 -0
  26. chipgraph/adapters/runner/local.py +122 -0
  27. chipgraph/adapters/runtime/__init__.py +5 -0
  28. chipgraph/adapters/runtime/claude_code/__init__.py +24 -0
  29. chipgraph/adapters/runtime/claude_code/agents.py +158 -0
  30. chipgraph/adapters/runtime/claude_code/decisions.py +280 -0
  31. chipgraph/adapters/runtime/claude_code/guarded.py +310 -0
  32. chipgraph/adapters/runtime/claude_code/isolation.py +482 -0
  33. chipgraph/adapters/runtime/claude_code/loop.py +342 -0
  34. chipgraph/adapters/runtime/claude_code/previous.py +419 -0
  35. chipgraph/adapters/runtime/claude_code/review.py +492 -0
  36. chipgraph/adapters/runtime/claude_code/runtime.py +192 -0
  37. chipgraph/adapters/runtime/claude_code/service.py +2285 -0
  38. chipgraph/adapters/tool/__init__.py +7 -0
  39. chipgraph/adapters/tool/cmd.py +229 -0
  40. chipgraph/adapters/tool/edalize.py +773 -0
  41. chipgraph/adapters/tool/filelist.py +259 -0
  42. chipgraph/adapters/tool/pyslang.py +485 -0
  43. chipgraph/adapters/vcs/__init__.py +1 -0
  44. chipgraph/adapters/vcs/git.py +521 -0
  45. chipgraph/app/__init__.py +10 -0
  46. chipgraph/app/baseline.py +339 -0
  47. chipgraph/app/build.py +332 -0
  48. chipgraph/app/checks.py +202 -0
  49. chipgraph/app/context.py +191 -0
  50. chipgraph/app/errors.py +11 -0
  51. chipgraph/app/executors.py +79 -0
  52. chipgraph/app/findings.py +203 -0
  53. chipgraph/app/ingest.py +523 -0
  54. chipgraph/checks/__init__.py +52 -0
  55. chipgraph/checks/_cdc_analyze.py +272 -0
  56. chipgraph/checks/_cdc_elaborate.py +455 -0
  57. chipgraph/checks/_common.py +268 -0
  58. chipgraph/checks/_cross.py +96 -0
  59. chipgraph/checks/_model.py +64 -0
  60. chipgraph/checks/_naming_pyslang.py +235 -0
  61. chipgraph/checks/_naming_rules.py +127 -0
  62. chipgraph/checks/_xref_syntax.py +260 -0
  63. chipgraph/checks/cdc_struct.py +235 -0
  64. chipgraph/checks/connect.py +353 -0
  65. chipgraph/checks/cross_chip.py +454 -0
  66. chipgraph/checks/duplicate.py +153 -0
  67. chipgraph/checks/filelist.py +323 -0
  68. chipgraph/checks/format.py +268 -0
  69. chipgraph/checks/generated.py +151 -0
  70. chipgraph/checks/hardcode.py +233 -0
  71. chipgraph/checks/layout.py +155 -0
  72. chipgraph/checks/naming.py +311 -0
  73. chipgraph/checks/plan_check.py +901 -0
  74. chipgraph/checks/ports_diff.py +203 -0
  75. chipgraph/checks/spec_schema.py +277 -0
  76. chipgraph/checks/style.py +910 -0
  77. chipgraph/checks/tb_static.py +599 -0
  78. chipgraph/checks/trace.py +224 -0
  79. chipgraph/checks/vendor_guard.py +369 -0
  80. chipgraph/checks/wrap_check.py +486 -0
  81. chipgraph/cli/__init__.py +1688 -0
  82. chipgraph/cli/next_steps.py +137 -0
  83. chipgraph/core/__init__.py +1 -0
  84. chipgraph/core/config/__init__.py +93 -0
  85. chipgraph/core/config/defaults.py +14 -0
  86. chipgraph/core/config/errors.py +32 -0
  87. chipgraph/core/config/loader.py +766 -0
  88. chipgraph/core/config/models.py +845 -0
  89. chipgraph/core/config/templates.py +340 -0
  90. chipgraph/core/contracts/__init__.py +75 -0
  91. chipgraph/core/contracts/_paths.py +26 -0
  92. chipgraph/core/contracts/agent.py +49 -0
  93. chipgraph/core/contracts/artifact.py +68 -0
  94. chipgraph/core/contracts/check.py +76 -0
  95. chipgraph/core/contracts/event.py +70 -0
  96. chipgraph/core/contracts/export.py +128 -0
  97. chipgraph/core/contracts/finding.py +171 -0
  98. chipgraph/core/contracts/gate.py +50 -0
  99. chipgraph/core/contracts/rule.py +177 -0
  100. chipgraph/core/contracts/types.py +45 -0
  101. chipgraph/core/engine/__init__.py +70 -0
  102. chipgraph/core/engine/agent_rule.py +1362 -0
  103. chipgraph/core/engine/baseline.py +283 -0
  104. chipgraph/core/engine/decide.py +462 -0
  105. chipgraph/core/engine/fanout.py +1358 -0
  106. chipgraph/core/engine/gate.py +170 -0
  107. chipgraph/core/engine/graph.py +647 -0
  108. chipgraph/core/engine/records.py +60 -0
  109. chipgraph/core/engine/rules.py +92 -0
  110. chipgraph/core/engine/scheduler.py +637 -0
  111. chipgraph/core/engine/select.py +1239 -0
  112. chipgraph/core/engine/targets.py +342 -0
  113. chipgraph/core/model/__init__.py +98 -0
  114. chipgraph/core/model/entities.py +244 -0
  115. chipgraph/core/model/export.py +16 -0
  116. chipgraph/core/model/ingest.py +693 -0
  117. chipgraph/core/model/json_value.py +4 -0
  118. chipgraph/core/model/keys.py +95 -0
  119. chipgraph/core/model/model.py +153 -0
  120. chipgraph/core/model/provenance.py +27 -0
  121. chipgraph/core/model/query.py +583 -0
  122. chipgraph/core/model/registry.py +80 -0
  123. chipgraph/core/model/relations.py +61 -0
  124. chipgraph/core/model/store.py +279 -0
  125. chipgraph/core/plugin_api/__init__.py +81 -0
  126. chipgraph/core/plugin_api/local.py +167 -0
  127. chipgraph/core/plugin_api/pack.py +159 -0
  128. chipgraph/core/plugin_api/protocols.py +177 -0
  129. chipgraph/core/plugin_api/registry.py +111 -0
  130. chipgraph/core/plugin_api/types.py +94 -0
  131. chipgraph/core/runtime/__init__.py +41 -0
  132. chipgraph/core/runtime/executor.py +79 -0
  133. chipgraph/core/runtime/queue.py +565 -0
  134. chipgraph/core/runtime/roles/__init__.py +91 -0
  135. chipgraph/core/runtime/roles/data/author.md +41 -0
  136. chipgraph/core/runtime/roles/data/critic.md +56 -0
  137. chipgraph/core/runtime/roles/data/planner.md +38 -0
  138. chipgraph/core/runtime/roles/data/researcher.md +37 -0
  139. chipgraph/core/runtime/roles/data/rtl-author.md +64 -0
  140. chipgraph/core/runtime/roles/data/tb-author.md +48 -0
  141. chipgraph/core/runtime/roles/data/triage.md +34 -0
  142. chipgraph/core/runtime/roles/policy.py +299 -0
  143. chipgraph/core/runtime/roles/registry.py +52 -0
  144. chipgraph/core/runtime/roles/review.py +275 -0
  145. chipgraph/core/runtime/roles/schema.py +82 -0
  146. chipgraph/core/runtime/roles/skills.py +134 -0
  147. chipgraph/core/runtime/roles/spec.py +232 -0
  148. chipgraph/core/state/__init__.py +1 -0
  149. chipgraph/core/state/artifacts.py +156 -0
  150. chipgraph/core/state/backend.py +117 -0
  151. chipgraph/core/state/findings.py +151 -0
  152. chipgraph/core/state/handoff.py +411 -0
  153. chipgraph/core/state/idempotency.py +59 -0
  154. chipgraph/core/state/journal.py +238 -0
  155. chipgraph/core/state/layout.py +82 -0
  156. chipgraph/core/state/lock.py +195 -0
  157. chipgraph/core/state/trace.py +225 -0
  158. chipgraph/learn/__init__.py +96 -0
  159. chipgraph/learn/draft.py +187 -0
  160. chipgraph/learn/infer.py +580 -0
  161. chipgraph/learn/models.py +145 -0
  162. chipgraph/learn/try_run.py +228 -0
  163. chipgraph/mcp/__init__.py +12 -0
  164. chipgraph/mcp/model_tools.py +167 -0
  165. chipgraph/mcp/server.py +787 -0
  166. chipgraph/orgs/qnsc/naming-v1.yml +113 -0
  167. chipgraph/orgs/qnsc/profile.yml +5 -0
  168. chipgraph/packs/__init__.py +8 -0
  169. chipgraph/packs/assist/__init__.py +16 -0
  170. chipgraph/packs/assist/ask/__init__.py +58 -0
  171. chipgraph/packs/assist/ask/_db.py +57 -0
  172. chipgraph/packs/assist/ask/_project.py +230 -0
  173. chipgraph/packs/assist/ask/answer.py +266 -0
  174. chipgraph/packs/assist/ask/check.py +182 -0
  175. chipgraph/packs/assist/ask/contract.py +115 -0
  176. chipgraph/packs/assist/ask/documents.py +157 -0
  177. chipgraph/packs/assist/ask/fts.py +80 -0
  178. chipgraph/packs/assist/ask/retrieve.py +331 -0
  179. chipgraph/packs/assist/audit/__init__.py +383 -0
  180. chipgraph/packs/assist/pack.yml +13 -0
  181. chipgraph/packs/assist/triage/__init__.py +64 -0
  182. chipgraph/packs/assist/triage/contract.py +94 -0
  183. chipgraph/packs/assist/triage/parse.py +371 -0
  184. chipgraph/packs/assist/triage/question.py +384 -0
  185. chipgraph/packs/assist/triage/report.py +160 -0
  186. chipgraph/packs/assist/triage/rules.py +447 -0
  187. chipgraph/packs/assist/triage/run.py +224 -0
  188. chipgraph/packs/digital_rtl/__init__.py +19 -0
  189. chipgraph/packs/digital_rtl/layout.py +144 -0
  190. chipgraph/packs/digital_rtl/pack.yml +25 -0
  191. chipgraph/packs/digital_rtl/plan/__init__.py +34 -0
  192. chipgraph/packs/digital_rtl/plan/__main__.py +16 -0
  193. chipgraph/packs/digital_rtl/plan/context.py +82 -0
  194. chipgraph/packs/digital_rtl/plan/expand.py +94 -0
  195. chipgraph/packs/digital_rtl/plan/model.py +150 -0
  196. chipgraph/packs/digital_rtl/plan/resolver.py +400 -0
  197. chipgraph/packs/digital_rtl/plan/show.py +176 -0
  198. chipgraph/packs/digital_rtl/pr/__init__.py +11 -0
  199. chipgraph/packs/digital_rtl/pr/body.py +165 -0
  200. chipgraph/packs/digital_rtl/pr/gate.py +110 -0
  201. chipgraph/packs/digital_rtl/pr/open.py +397 -0
  202. chipgraph/packs/digital_rtl/rtl/__init__.py +9 -0
  203. chipgraph/packs/digital_rtl/rtl/context.py +272 -0
  204. chipgraph/packs/digital_rtl/rules/open_pr.yml +30 -0
  205. chipgraph/packs/digital_rtl/rules/plan.yml +28 -0
  206. chipgraph/packs/digital_rtl/rules/plan_expand.yml +29 -0
  207. chipgraph/packs/digital_rtl/rules/pr_gate.yml +27 -0
  208. chipgraph/packs/digital_rtl/rules/review.yml +31 -0
  209. chipgraph/packs/digital_rtl/rules/rtl_module.yml +53 -0
  210. chipgraph/packs/digital_rtl/skills/lang-sv-rtl.md +58 -0
  211. chipgraph/packs/digital_rtl/skills/org-naming.md +29 -0
  212. chipgraph/packs/digital_rtl/skills/plan-modules.md +63 -0
  213. chipgraph/packs/digital_rtl/skills/review-diff.md +41 -0
  214. chipgraph/packs/digital_rtl/targets/plan.yml +11 -0
  215. chipgraph/packs/digital_rtl/targets/rtl.yml +20 -0
  216. chipgraph/packs/digital_rtl/wrapper/__init__.py +24 -0
  217. chipgraph/packs/digital_rtl/wrapper/common.py +171 -0
  218. chipgraph/packs/digital_rtl/wrapper/context.py +222 -0
  219. chipgraph/packs/digital_rtl/wrapper/emacs.py +626 -0
  220. chipgraph/packs/digital_rtl/wrapper/expansion.py +304 -0
  221. chipgraph/packs/digital_rtl/wrapper/lisp.py +504 -0
  222. chipgraph/packs/digital_rtl/wrapper/resolver.py +60 -0
  223. chipgraph/packs/digital_rtl/wrapper/rules/wrapper.yml +41 -0
  224. chipgraph/packs/digital_rtl/wrapper/rules/wrapper_gen.yml +35 -0
  225. chipgraph/packs/digital_rtl/wrapper/skills/wrapper-auto.md +120 -0
  226. chipgraph/packs/dv/__init__.py +15 -0
  227. chipgraph/packs/dv/declaration.py +392 -0
  228. chipgraph/packs/dv/interface.py +596 -0
  229. chipgraph/packs/dv/pack.yml +14 -0
  230. chipgraph/packs/dv/rules/tb_module.yml +42 -0
  231. chipgraph/packs/dv/skills/dv/cocotb.md +72 -0
  232. chipgraph/packs/spec_core/__init__.py +9 -0
  233. chipgraph/packs/spec_core/extract/__init__.py +12 -0
  234. chipgraph/packs/spec_core/extract/_markdown.py +371 -0
  235. chipgraph/packs/spec_core/extract/_text.py +116 -0
  236. chipgraph/packs/spec_core/extract/mas.py +975 -0
  237. chipgraph/packs/spec_core/gen/__init__.py +11 -0
  238. chipgraph/packs/spec_core/gen/agents_md/__init__.py +148 -0
  239. chipgraph/packs/spec_core/gen/agents_md/_context.py +239 -0
  240. chipgraph/packs/spec_core/gen/agents_md/_markers.py +131 -0
  241. chipgraph/packs/spec_core/gen/agents_md/agents_md.md.j2 +178 -0
  242. chipgraph/packs/spec_core/gen/diagram/__init__.py +110 -0
  243. chipgraph/packs/spec_core/gen/diagram/__main__.py +44 -0
  244. chipgraph/packs/spec_core/gen/diagram/_diagrams.py +403 -0
  245. chipgraph/packs/spec_core/gen/diagram/_scene.py +163 -0
  246. chipgraph/packs/spec_core/pack.yml +6 -0
  247. chipgraph/py.typed +0 -0
  248. chipgraph-0.1.0.dist-info/METADATA +53 -0
  249. chipgraph-0.1.0.dist-info/RECORD +252 -0
  250. chipgraph-0.1.0.dist-info/WHEEL +4 -0
  251. chipgraph-0.1.0.dist-info/entry_points.txt +61 -0
  252. chipgraph-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,886 @@
1
+ """The `github` review adapter: opens pull requests and reads their reviews as `pr` gates.
2
+
3
+ Implements `chipgraph.core.plugin_api.protocols.ReviewAdapter` over the GitHub REST
4
+ API (DESIGN.md 6.1: a heavy gate's decision *is* the PR review, with no extra file).
5
+
6
+ What it does, and what it never does
7
+ ------------------------------------
8
+ - `open_pr` opens a PR from a branch that is already pushed (pushing is the VCS
9
+ adapter's job). It is idempotent: an open PR for the same head branch is returned.
10
+ - `pull_request` / `reviews` read a PR's state, head commit and each reviewer's latest
11
+ review. `approvals(gate_id)` turns the reviews of the PR bound to `gate_id` into
12
+ `Approval`s.
13
+ - It never merges, never turns on automatic merging, never submits, approves or
14
+ dismisses a review, never updates or closes a PR and never deletes a branch. The HTTP
15
+ layer only allows `GET` of PRs and reviews, and `POST` to the PR-create endpoint;
16
+ any other request is refused before it is sent. `record()` always raises
17
+ `ReviewError`: a decision on a PR gate is made on the PR itself, and waivers and
18
+ baselines belong to the `file` adapter.
19
+
20
+ Approvals and the head-commit pin
21
+ ---------------------------------
22
+ Only a reviewer's latest *decisive* review counts (`APPROVED`, `CHANGES_REQUESTED` or
23
+ `DISMISSED`; a later `COMMENTED` review does not undo an approval, as on GitHub), and
24
+ only when it was made on the PR's **current** head commit: a later push makes it stale.
25
+ `APPROVED` becomes `approve`, `CHANGES_REQUESTED` becomes `reject`; `COMMENTED`,
26
+ `DISMISSED` and `PENDING` are ignored, and so is the PR author's own review. `by` is
27
+ the reviewer's login and `at` the review's `submitted_at`.
28
+
29
+ Every approval is pinned to the head commit through `artifact_hashes` with one key::
30
+
31
+ "<owner>/<name>:pull/<number>/head" -> sha256(<head commit sha, ASCII>)
32
+
33
+ (`head_key` and `head_digest`). `current_hashes(gate_id)` returns the same key for the
34
+ PR's current head, so `approval.is_current(review.current_hashes(gate_id))` tells an
35
+ old approval from a new one. `GitHubGateChecker` packages this as the scheduler's
36
+ `GateChecker` shape: `core.engine.gate.GateEvaluator` compares approvals with the
37
+ gate's *file* hashes on disk, which a PR review is not pinned to, so PR gates are
38
+ evaluated here and every other gate is delegated to a fallback (normally the
39
+ `GateEvaluator` over the `file` adapter).
40
+
41
+ Binding a gate to a PR
42
+ ----------------------
43
+ `prs={"pr:timer": 12}` in the constructor, `bind(gate_id, number)`, or
44
+ `open_pr(..., gate_id=...)`. With a `state_dir`, bindings persist in
45
+ `<state_dir>/review/github.json` (machine-local run state, never in the repo).
46
+
47
+ Profile options (`review:` in `.chipgraph.yml`)
48
+ -----------------------------------------------
49
+ ::
50
+
51
+ review:
52
+ use: github
53
+ repo: owner/name # required
54
+ base: main # default base branch of opened PRs
55
+ api_url: https://api.github.com # GitHub Enterprise: https://<host>/api/v3
56
+ allow_gh_fallback: true # use `gh auth token` if no env token; off when CI is set
57
+ timeout_s: 30 # per HTTP request
58
+ max_retries: 3 # on 5xx, rate limits and (GET) network errors
59
+ remote: origin # the git remote a PR branch is pushed to (M2-11a)
60
+
61
+ `api_url` must be `https://` (plain `http://` only for `localhost`/`127.0.0.1`).
62
+
63
+ Authentication
64
+ --------------
65
+ The token comes from `CHIPGRAPH_GITHUB_TOKEN`: a fine-grained token for this one repo
66
+ with *Contents* and *Pull requests* read/write and nothing else (no admin, no
67
+ workflows). If it is unset and `allow_gh_fallback` is true, `gh auth token` is used
68
+ (local convenience only); when the `CI` environment variable is set the fallback is
69
+ always off. `token_source` records which source was used (`"env"` or `"gh"`). The
70
+ token is never logged, printed, put in an exception message or in a `repr`, and
71
+ redirects are never followed (so it is never sent to another host).
72
+
73
+ Manual end-to-end test
74
+ ----------------------
75
+ Run by a reviewer with a token for the private sandbox repo, never in CI::
76
+
77
+ export CHIPGRAPH_GITHUB_TOKEN=... # fine-grained, sandbox repo only
78
+ CHIPGRAPH_SANDBOX_REPO=quynhonsemiconductor/chipgraph-sandbox \\
79
+ uv run pytest tests/e2e_github -q
80
+
81
+ It refuses any repo whose name does not end in `-sandbox`, never uses the `gh`
82
+ fallback, and cleans up the branch and PR it made.
83
+ """
84
+
85
+ from __future__ import annotations
86
+
87
+ import hashlib
88
+ import json
89
+ import logging
90
+ import os
91
+ import re
92
+ import subprocess
93
+ import tempfile
94
+ import time
95
+ import urllib.error
96
+ import urllib.parse
97
+ import urllib.request
98
+ from collections.abc import Callable, Collection, Mapping
99
+ from dataclasses import dataclass, field
100
+ from datetime import datetime
101
+ from pathlib import Path
102
+ from typing import Any, ClassVar, Literal
103
+
104
+ from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
105
+
106
+ from chipgraph.adapters.review.file import ReviewError
107
+ from chipgraph.core.config.models import AdapterCfg
108
+ from chipgraph.core.contracts import Approval, RuleInstance
109
+ from chipgraph.core.engine.scheduler import GateChecker, GateStatus
110
+
111
+ __all__ = [
112
+ "TOKEN_ENV",
113
+ "GitHubGateChecker",
114
+ "GitHubReview",
115
+ "HttpRequest",
116
+ "HttpResponse",
117
+ "PullRequest",
118
+ "Review",
119
+ "ReviewError",
120
+ "head_digest",
121
+ "urllib_transport",
122
+ ]
123
+
124
+ _log = logging.getLogger(__name__)
125
+
126
+ TOKEN_ENV = "CHIPGRAPH_GITHUB_TOKEN"
127
+ """The environment variable the token is read from."""
128
+
129
+ _DEFAULT_API_URL = "https://api.github.com"
130
+ _REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
131
+ _LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
132
+ _OPTIONS = frozenset(
133
+ {"repo", "base", "api_url", "allow_gh_fallback", "timeout_s", "max_retries", "remote"}
134
+ )
135
+ _RETRY_STATUSES = frozenset({500, 502, 503, 504})
136
+ _MAX_RETRY_AFTER_S = 60.0
137
+ _MAX_PAGES = 50
138
+ _PER_PAGE = 100
139
+ _BINDINGS_FILE = ("review", "github.json")
140
+
141
+ ReviewState = Literal["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED", "PENDING"]
142
+ _DECISIVE: frozenset[str] = frozenset({"APPROVED", "CHANGES_REQUESTED", "DISMISSED"})
143
+ _DECISIONS: dict[str, Literal["approve", "reject"]] = {
144
+ "APPROVED": "approve",
145
+ "CHANGES_REQUESTED": "reject",
146
+ }
147
+
148
+
149
+ # --- HTTP transport -----------------------------------------------------------------
150
+
151
+
152
+ @dataclass(frozen=True)
153
+ class HttpRequest:
154
+ """One HTTP request. `headers` carries the token, so it is kept out of `repr`."""
155
+
156
+ method: str
157
+ url: str
158
+ headers: Mapping[str, str] = field(repr=False)
159
+ body: bytes | None = field(default=None, repr=False)
160
+ timeout_s: float = 30.0
161
+
162
+
163
+ @dataclass(frozen=True)
164
+ class HttpResponse:
165
+ """One HTTP response; `headers` keys are lower-case."""
166
+
167
+ status: int
168
+ headers: Mapping[str, str]
169
+ body: bytes
170
+
171
+
172
+ Transport = Callable[[HttpRequest], HttpResponse]
173
+ """Sends one request. Raises `OSError` (incl. timeouts) for network failures."""
174
+
175
+
176
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
177
+ """Never follow redirects: urllib would forward the `Authorization` header."""
178
+
179
+ def redirect_request(self, *args: Any, **kwargs: Any) -> None:
180
+ return None
181
+
182
+
183
+ _OPENER = urllib.request.build_opener(_NoRedirect)
184
+
185
+
186
+ def urllib_transport(request: HttpRequest) -> HttpResponse:
187
+ """The default `Transport`: stdlib `urllib`, no redirects, per-request timeout."""
188
+ req = urllib.request.Request(
189
+ request.url, data=request.body, method=request.method, headers=dict(request.headers)
190
+ )
191
+ try:
192
+ with _OPENER.open(req, timeout=request.timeout_s) as resp:
193
+ return HttpResponse(
194
+ status=resp.status,
195
+ headers={k.lower(): v for k, v in resp.headers.items()},
196
+ body=resp.read(),
197
+ )
198
+ except urllib.error.HTTPError as exc:
199
+ headers = {k.lower(): v for k, v in exc.headers.items()} if exc.headers else {}
200
+ try:
201
+ body = exc.read()
202
+ except OSError:
203
+ body = b""
204
+ return HttpResponse(status=exc.code, headers=headers, body=body)
205
+
206
+
207
+ def _gh_auth_token(hostname: str) -> str | None:
208
+ """`gh auth token --hostname <host>`, or None if `gh` is missing or fails."""
209
+ try:
210
+ result = subprocess.run(
211
+ ["gh", "auth", "token", "--hostname", hostname],
212
+ capture_output=True,
213
+ text=True,
214
+ timeout=10,
215
+ check=False,
216
+ )
217
+ except (OSError, subprocess.SubprocessError):
218
+ return None
219
+ token = result.stdout.strip()
220
+ return token if result.returncode == 0 and token else None
221
+
222
+
223
+ # --- data returned to callers -------------------------------------------------------
224
+
225
+
226
+ class PullRequest(BaseModel):
227
+ """A pull request as the engine needs it."""
228
+
229
+ model_config = ConfigDict(extra="forbid", frozen=True)
230
+
231
+ schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
232
+ number: int = Field(description="The PR number.")
233
+ url: str = Field(description="The PR's web URL.")
234
+ state: Literal["open", "closed"] = Field(description="Whether the PR is open.")
235
+ merged: bool = Field(default=False, description="Whether the PR has been merged.")
236
+ draft: bool = Field(default=False, description="Whether the PR is a draft.")
237
+ head: str = Field(description="The head branch name.")
238
+ head_sha: str = Field(description="The head commit sha.")
239
+ base: str = Field(description="The base branch name.")
240
+ author: str = Field(description="The login of the PR's author.")
241
+
242
+
243
+ class Review(BaseModel):
244
+ """One review on a pull request."""
245
+
246
+ model_config = ConfigDict(extra="forbid", frozen=True)
247
+
248
+ schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
249
+ id: int = Field(description="The review id.")
250
+ by: str = Field(description="The reviewer's login.")
251
+ state: ReviewState = Field(description="The review state.")
252
+ commit_id: str | None = Field(description="The commit the review was made on.")
253
+ submitted_at: AwareDatetime | None = Field(description="When it was submitted.")
254
+ url: str = Field(default="", description="The review's web URL.")
255
+
256
+
257
+ def head_digest(head_sha: str) -> str:
258
+ """The `artifact_hashes` value pinning an approval to commit `head_sha`."""
259
+ return hashlib.sha256(head_sha.encode("ascii")).hexdigest()
260
+
261
+
262
+ def _parse_time(value: object) -> datetime | None:
263
+ if not isinstance(value, str) or not value:
264
+ return None
265
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
266
+
267
+
268
+ def _login(user: object) -> str:
269
+ if isinstance(user, dict) and isinstance(user.get("login"), str):
270
+ return str(user["login"])
271
+ return "ghost" # GitHub's name for a deleted account
272
+
273
+
274
+ # --- the adapter --------------------------------------------------------------------
275
+
276
+
277
+ class GitHubReview:
278
+ """Opens PRs and reads their reviews; never merges (see the module docstring)."""
279
+
280
+ name = "github"
281
+ token_source: Literal["env", "gh"] | None
282
+ """Where the token came from, once one has been resolved."""
283
+
284
+ _ALLOWED_GET: ClassVar[re.Pattern[str]] = re.compile(r"^/pulls(/[0-9]+(/reviews)?)?$")
285
+
286
+ def __init__(
287
+ self,
288
+ repo: str | None = None,
289
+ *,
290
+ base: str = "main",
291
+ api_url: str = _DEFAULT_API_URL,
292
+ allow_gh_fallback: bool = True,
293
+ timeout_s: float = 30.0,
294
+ max_retries: int = 3,
295
+ prs: Mapping[str, int] | None = None,
296
+ state_dir: Path | None = None,
297
+ env: Mapping[str, str] | None = None,
298
+ transport: Transport | None = None,
299
+ gh_token: Callable[[str], str | None] | None = None,
300
+ sleep: Callable[[float], None] | None = None,
301
+ ) -> None:
302
+ # The registry builds adapters with no arguments; such an instance has no
303
+ # `repo` and every API method raises. `from_config` builds a configured one.
304
+ if repo is not None and not _REPO_RE.match(repo):
305
+ raise ReviewError(f"review.repo must be 'owner/name', got {repo!r}")
306
+ api_url = api_url.rstrip("/")
307
+ parsed = urllib.parse.urlsplit(api_url)
308
+ if not (
309
+ parsed.scheme == "https"
310
+ or (parsed.scheme == "http" and parsed.hostname in _LOCAL_HOSTS)
311
+ ):
312
+ raise ReviewError(
313
+ f"review.api_url must be an https:// URL (http:// only for localhost), "
314
+ f"got {api_url!r}"
315
+ )
316
+ if timeout_s <= 0:
317
+ raise ReviewError("review.timeout_s must be > 0")
318
+ if max_retries < 0:
319
+ raise ReviewError("review.max_retries must be >= 0")
320
+
321
+ self.repo = repo
322
+ self.base = base
323
+ self.remote = "origin"
324
+ """The git remote a PR branch is pushed to (the VCS adapter pushes; never this)."""
325
+ self.api_url = api_url
326
+ self.timeout_s = timeout_s
327
+ self.max_retries = max_retries
328
+ self._env: Mapping[str, str] = env if env is not None else os.environ
329
+ # The gh fallback is a local convenience: CI always uses the env token.
330
+ self.allow_gh_fallback = allow_gh_fallback and not self._env.get("CI")
331
+ self._transport: Transport = transport or urllib_transport
332
+ self._gh_token = gh_token or _gh_auth_token
333
+ self._sleep = sleep or time.sleep
334
+ self._token: str | None = None
335
+ self.token_source = None
336
+ self._bindings_path = state_dir.joinpath(*_BINDINGS_FILE) if state_dir is not None else None
337
+ self._prs: dict[str, int] = self._load_bindings()
338
+ self._prs.update(prs or {})
339
+
340
+ @classmethod
341
+ def from_config(
342
+ cls,
343
+ cfg: AdapterCfg | Mapping[str, object],
344
+ *,
345
+ state_dir: Path | None = None,
346
+ env: Mapping[str, str] | None = None,
347
+ transport: Transport | None = None,
348
+ gh_token: Callable[[str], str | None] | None = None,
349
+ sleep: Callable[[float], None] | None = None,
350
+ ) -> GitHubReview:
351
+ """Build an adapter from a profile's `review:` options (see the module docstring)."""
352
+ raw = cfg.model_dump() if isinstance(cfg, AdapterCfg) else dict(cfg)
353
+ raw.pop("use", None)
354
+ unknown = sorted(set(raw) - _OPTIONS)
355
+ if unknown:
356
+ raise ReviewError(
357
+ f"unknown review option(s) {unknown} for 'github'; known: {sorted(_OPTIONS)}"
358
+ )
359
+ repo = raw.get("repo")
360
+ if not isinstance(repo, str) or not repo:
361
+ raise ReviewError("review.repo is required for 'github', e.g. 'repo: owner/name'")
362
+ base = raw.get("base", "main")
363
+ api_url = raw.get("api_url", _DEFAULT_API_URL)
364
+ fallback = raw.get("allow_gh_fallback", True)
365
+ timeout_s = raw.get("timeout_s", 30.0)
366
+ max_retries = raw.get("max_retries", 3)
367
+ if not isinstance(base, str) or not base:
368
+ raise ReviewError("review.base must be a branch name")
369
+ if not isinstance(api_url, str):
370
+ raise ReviewError("review.api_url must be a URL")
371
+ if not isinstance(fallback, bool):
372
+ raise ReviewError("review.allow_gh_fallback must be true or false")
373
+ if isinstance(timeout_s, bool) or not isinstance(timeout_s, int | float):
374
+ raise ReviewError("review.timeout_s must be a number of seconds")
375
+ if isinstance(max_retries, bool) or not isinstance(max_retries, int):
376
+ raise ReviewError("review.max_retries must be an integer")
377
+ remote = raw.get("remote", "origin")
378
+ if not isinstance(remote, str) or not remote:
379
+ raise ReviewError("review.remote must be the name of a git remote")
380
+ review = cls(
381
+ repo,
382
+ base=base,
383
+ api_url=api_url,
384
+ allow_gh_fallback=fallback,
385
+ timeout_s=float(timeout_s),
386
+ max_retries=max_retries,
387
+ state_dir=state_dir,
388
+ env=env,
389
+ transport=transport,
390
+ gh_token=gh_token,
391
+ sleep=sleep,
392
+ )
393
+ review.remote = remote
394
+ return review
395
+
396
+ def __repr__(self) -> str:
397
+ return (
398
+ f"GitHubReview(repo={self.repo!r}, api_url={self.api_url!r}, "
399
+ f"token_source={self.token_source!r})"
400
+ )
401
+
402
+ # --- ReviewAdapter -------------------------------------------------------------
403
+
404
+ def record(self, approval: Approval) -> None:
405
+ """Always raises: this adapter never records a decision anywhere."""
406
+ if approval.decision in ("approve", "reject"):
407
+ raise ReviewError(
408
+ f"gate {approval.gate_id!r} is decided on its pull request: approve or "
409
+ "request changes in the PR review on GitHub; chipgraph never approves on "
410
+ "GitHub itself"
411
+ )
412
+ raise ReviewError(
413
+ f"the github review adapter does not store {approval.decision!r} decisions "
414
+ f"(gate {approval.gate_id!r}); waivers and baselines go through the 'file' adapter"
415
+ )
416
+
417
+ def approvals(self, gate_id: str) -> tuple[Approval, ...]:
418
+ """Approvals from the reviews of the PR bound to `gate_id`, at its current head.
419
+
420
+ Returns `()` if no PR is bound to `gate_id`. Sorted by `at`.
421
+ """
422
+ number = self.pr_for(gate_id)
423
+ if number is None:
424
+ return ()
425
+ pr = self.pull_request(number)
426
+ pin = {self.head_key(number): head_digest(pr.head_sha)}
427
+ result: list[Approval] = []
428
+ for review in self.reviews(number):
429
+ decision = _DECISIONS.get(review.state)
430
+ if (
431
+ decision is None
432
+ or review.by == pr.author
433
+ or review.commit_id != pr.head_sha
434
+ or review.submitted_at is None
435
+ ):
436
+ continue
437
+ result.append(
438
+ Approval(
439
+ gate_id=gate_id,
440
+ by=review.by,
441
+ at=review.submitted_at,
442
+ artifact_hashes=dict(pin),
443
+ decision=decision,
444
+ note=(
445
+ f"GitHub review {review.id} ({review.state}) on {self.repo}#{number} "
446
+ f"at {pr.head_sha}"
447
+ ),
448
+ )
449
+ )
450
+ result.sort(key=lambda approval: approval.at)
451
+ return tuple(result)
452
+
453
+ # --- head pin ------------------------------------------------------------------
454
+
455
+ def head_key(self, number: int) -> str:
456
+ """The `artifact_hashes` key of PR `number`: `<owner>/<name>:pull/<number>/head`."""
457
+ return f"{self._require_repo()}:pull/{number}/head"
458
+
459
+ def current_hashes(self, gate_id: str) -> dict[str, str]:
460
+ """`{head_key: head_digest(current head)}` for the PR bound to `gate_id`, or `{}`."""
461
+ number = self.pr_for(gate_id)
462
+ if number is None:
463
+ return {}
464
+ return {self.head_key(number): head_digest(self.pull_request(number).head_sha)}
465
+
466
+ # --- gate binding --------------------------------------------------------------
467
+
468
+ def pr_for(self, gate_id: str) -> int | None:
469
+ """The PR number bound to `gate_id`, if any."""
470
+ return self._prs.get(gate_id)
471
+
472
+ def bind(self, gate_id: str, number: int) -> None:
473
+ """Bind `gate_id` to PR `number`, persisting it when a `state_dir` was given."""
474
+ if number <= 0:
475
+ raise ReviewError(f"invalid PR number {number!r} for gate {gate_id!r}")
476
+ self._prs[gate_id] = number
477
+ self._save_bindings()
478
+
479
+ def _load_bindings(self) -> dict[str, int]:
480
+ path = self._bindings_path
481
+ if path is None or not path.is_file():
482
+ return {}
483
+ try:
484
+ raw = json.loads(path.read_text(encoding="utf-8"))
485
+ except (OSError, ValueError) as exc:
486
+ raise ReviewError(f"malformed PR binding file {path}: {exc}") from exc
487
+ if not isinstance(raw, dict):
488
+ raise ReviewError(f"malformed PR binding file {path}: not a JSON object")
489
+ if raw.get("repo") != self.repo:
490
+ return {} # bindings of another repo (the profile changed): not ours
491
+ gates = raw.get("gates")
492
+ if not isinstance(gates, dict) or not all(
493
+ isinstance(k, str) and isinstance(v, int) for k, v in gates.items()
494
+ ):
495
+ raise ReviewError(f"malformed PR binding file {path}: 'gates' must map id -> int")
496
+ return dict(gates)
497
+
498
+ def _save_bindings(self) -> None:
499
+ path = self._bindings_path
500
+ if path is None:
501
+ return
502
+ path.parent.mkdir(parents=True, exist_ok=True)
503
+ payload = {"schema_version": 1, "repo": self.repo, "gates": dict(sorted(self._prs.items()))}
504
+ fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=".tmp-github-", suffix=".json")
505
+ try:
506
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
507
+ json.dump(payload, handle, indent=2, sort_keys=True)
508
+ os.replace(tmp_name, path)
509
+ except BaseException:
510
+ Path(tmp_name).unlink(missing_ok=True)
511
+ raise
512
+
513
+ # --- pull requests -------------------------------------------------------------
514
+
515
+ def open_pr(
516
+ self,
517
+ head: str,
518
+ base: str | None,
519
+ title: str,
520
+ body: str = "",
521
+ draft: bool = False,
522
+ *,
523
+ gate_id: str | None = None,
524
+ ) -> PullRequest:
525
+ """Open a PR from the already-pushed branch `head` into `base` (default: config).
526
+
527
+ Idempotent: if an open PR from `head` exists, it is returned unchanged. With
528
+ `gate_id`, the gate is bound to the PR.
529
+ """
530
+ if not head:
531
+ raise ReviewError("open_pr needs a head branch name")
532
+ pr = self._find_open_pr(head)
533
+ if pr is None:
534
+ payload = {
535
+ "head": head,
536
+ "base": base or self.base,
537
+ "title": title,
538
+ "body": body,
539
+ "draft": draft,
540
+ }
541
+ try:
542
+ data = self._json("POST", "/pulls", body=payload)
543
+ pr = self._pull_from(data)
544
+ except ReviewError:
545
+ # A concurrent (or retried) create may have opened it already.
546
+ pr = self._find_open_pr(head)
547
+ if pr is None:
548
+ raise
549
+ if gate_id is not None:
550
+ self.bind(gate_id, pr.number)
551
+ return pr
552
+
553
+ def pull_request(self, number: int) -> PullRequest:
554
+ """The current state and head commit of PR `number`."""
555
+ return self._pull_from(self._json("GET", f"/pulls/{int(number)}"))
556
+
557
+ def reviews(self, number: int) -> tuple[Review, ...]:
558
+ """Each reviewer's latest review on PR `number` (all pages).
559
+
560
+ "Latest" prefers decisive reviews: a reviewer's newest `APPROVED`,
561
+ `CHANGES_REQUESTED` or `DISMISSED` review, else their newest other review.
562
+ """
563
+ items = self._paged(f"/pulls/{int(number)}/reviews")
564
+ all_reviews = sorted((self._review_from(item) for item in items), key=lambda r: r.id)
565
+ latest: dict[str, Review] = {}
566
+ for review in all_reviews:
567
+ current = latest.get(review.by)
568
+ if current is None or (review.state in _DECISIVE) >= (current.state in _DECISIVE):
569
+ latest[review.by] = review
570
+ return tuple(sorted(latest.values(), key=lambda r: r.id))
571
+
572
+ def _find_open_pr(self, head: str) -> PullRequest | None:
573
+ owner = self._require_repo().split("/", 1)[0]
574
+ data = self._json("GET", "/pulls", query={"state": "open", "head": f"{owner}:{head}"})
575
+ if not isinstance(data, list):
576
+ raise ReviewError("unexpected GitHub response listing pull requests")
577
+ for item in data:
578
+ pr = self._pull_from(item)
579
+ if pr.head == head and pr.state == "open":
580
+ return pr
581
+ return None
582
+
583
+ @staticmethod
584
+ def _pull_from(data: object) -> PullRequest:
585
+ try:
586
+ if not isinstance(data, dict):
587
+ raise TypeError("not a JSON object")
588
+ return PullRequest(
589
+ number=data["number"],
590
+ url=data.get("html_url") or "",
591
+ state=data["state"],
592
+ merged=bool(data.get("merged") or data.get("merged_at")),
593
+ draft=bool(data.get("draft", False)),
594
+ head=data["head"]["ref"],
595
+ head_sha=data["head"]["sha"],
596
+ base=data["base"]["ref"],
597
+ author=_login(data.get("user")),
598
+ )
599
+ except (KeyError, TypeError, ValueError) as exc:
600
+ raise ReviewError(f"unexpected GitHub pull request data: {exc}") from None
601
+
602
+ @staticmethod
603
+ def _review_from(data: object) -> Review:
604
+ try:
605
+ if not isinstance(data, dict):
606
+ raise TypeError("not a JSON object")
607
+ return Review(
608
+ id=data["id"],
609
+ by=_login(data.get("user")),
610
+ state=data["state"],
611
+ commit_id=data.get("commit_id"),
612
+ submitted_at=_parse_time(data.get("submitted_at")),
613
+ url=data.get("html_url") or "",
614
+ )
615
+ except (KeyError, TypeError, ValueError) as exc:
616
+ raise ReviewError(f"unexpected GitHub review data: {exc}") from None
617
+
618
+ # --- HTTP ----------------------------------------------------------------------
619
+
620
+ def _require_repo(self) -> str:
621
+ if self.repo is None:
622
+ raise ReviewError("review.repo is not set: configure 'review: {use: github, repo: …}'")
623
+ return self.repo
624
+
625
+ def _token_value(self) -> str:
626
+ if self._token is not None:
627
+ return self._token
628
+ token = self._env.get(TOKEN_ENV, "").strip()
629
+ source: Literal["env", "gh"] = "env"
630
+ if not token and self.allow_gh_fallback:
631
+ hostname = urllib.parse.urlsplit(self.api_url).hostname or "github.com"
632
+ if hostname == "api.github.com":
633
+ hostname = "github.com"
634
+ token = (self._gh_token(hostname) or "").strip()
635
+ source = "gh"
636
+ if not token:
637
+ why = (
638
+ "the `gh auth token` fallback found none"
639
+ if self.allow_gh_fallback
640
+ else "the `gh` fallback is off (allow_gh_fallback is false or CI is set)"
641
+ )
642
+ raise ReviewError(
643
+ f"no GitHub token: set {TOKEN_ENV} to a fine-grained token for "
644
+ f"{self.repo or 'the review repo'} with Contents and Pull requests "
645
+ f"read/write ({why})"
646
+ )
647
+ self._token = token
648
+ self.token_source = source
649
+ return token
650
+
651
+ def _check_allowed(self, method: str, path: str) -> None:
652
+ """Refuse anything but reading PRs/reviews and creating a PR (no-merge guard)."""
653
+ allowed = (method == "GET" and self._ALLOWED_GET.match(path)) or (
654
+ method == "POST" and path == "/pulls"
655
+ )
656
+ if not allowed:
657
+ raise ReviewError(
658
+ f"refusing {method} {path}: the github review adapter only reads pull "
659
+ "requests and reviews and opens pull requests"
660
+ )
661
+
662
+ def _json(
663
+ self,
664
+ method: str,
665
+ path: str,
666
+ *,
667
+ query: Mapping[str, str] | None = None,
668
+ body: Mapping[str, object] | None = None,
669
+ ) -> Any:
670
+ response = self._send(method, path, query=query, body=body)
671
+ return self._decode(response, method, path)
672
+
673
+ def _paged(self, path: str) -> list[object]:
674
+ items: list[object] = []
675
+ url: str | None = None
676
+ for _ in range(_MAX_PAGES):
677
+ if url is None:
678
+ response = self._send("GET", path, query={"per_page": str(_PER_PAGE)})
679
+ else:
680
+ response = self._send("GET", path, next_url=url)
681
+ data = self._decode(response, "GET", path)
682
+ if not isinstance(data, list):
683
+ raise ReviewError(f"unexpected GitHub response for GET {path}: not a list")
684
+ items.extend(data)
685
+ url = _next_link(response.headers.get("link", ""))
686
+ if url is None:
687
+ return items
688
+ raise ReviewError(f"GET {path}: more than {_MAX_PAGES} pages; refusing to continue")
689
+
690
+ @staticmethod
691
+ def _decode(response: HttpResponse, method: str, path: str) -> Any:
692
+ try:
693
+ return json.loads(response.body or b"null")
694
+ except ValueError:
695
+ raise ReviewError(f"GitHub returned invalid JSON for {method} {path}") from None
696
+
697
+ def _send(
698
+ self,
699
+ method: str,
700
+ path: str,
701
+ *,
702
+ query: Mapping[str, str] | None = None,
703
+ body: Mapping[str, object] | None = None,
704
+ next_url: str | None = None,
705
+ ) -> HttpResponse:
706
+ """Send one API request with retries; return a 2xx response or raise `ReviewError`.
707
+
708
+ `path` is relative to `/repos/<owner>/<name>` and checked by `_check_allowed`.
709
+ `next_url` (a pagination link) must stay on `api_url` and the same path.
710
+ """
711
+ repo = self._require_repo()
712
+ self._check_allowed(method, path)
713
+ full_path = f"/repos/{repo}{path}"
714
+ if next_url is not None:
715
+ url = next_url
716
+ if urllib.parse.urlsplit(url).path != urllib.parse.urlsplit(
717
+ self.api_url + full_path
718
+ ).path or not url.startswith(self.api_url + "/"):
719
+ raise ReviewError(f"refusing a pagination link off {self.api_url}{full_path}")
720
+ else:
721
+ url = self.api_url + full_path
722
+ if query:
723
+ url += "?" + urllib.parse.urlencode(query)
724
+ headers = {
725
+ "Accept": "application/vnd.github+json",
726
+ "Authorization": f"Bearer {self._token_value()}",
727
+ "User-Agent": "chipgraph",
728
+ "X-GitHub-Api-Version": "2022-11-28",
729
+ }
730
+ data = None
731
+ if body is not None:
732
+ data = json.dumps(body).encode("utf-8")
733
+ headers["Content-Type"] = "application/json"
734
+ request = HttpRequest(method, url, headers, data, self.timeout_s)
735
+
736
+ attempt = 0
737
+ while True:
738
+ try:
739
+ response = self._transport(request)
740
+ except OSError as exc:
741
+ if method == "GET" and attempt < self.max_retries:
742
+ attempt += 1
743
+ _log.debug("%s %s: network error, retry %d", method, full_path, attempt)
744
+ self._sleep(float(2 ** (attempt - 1)))
745
+ continue
746
+ reason = "timed out" if isinstance(exc, TimeoutError) else type(exc).__name__
747
+ if isinstance(exc, urllib.error.URLError) and isinstance(exc.reason, TimeoutError):
748
+ reason = "timed out"
749
+ raise ReviewError(
750
+ f"could not reach GitHub at {self.api_url} ({method} {full_path}): {reason}"
751
+ ) from None
752
+ _log.debug("%s %s -> %d", method, full_path, response.status)
753
+ if 200 <= response.status < 300:
754
+ return response
755
+ wait = self._retry_wait(response, attempt)
756
+ if wait is not None and attempt < self.max_retries:
757
+ attempt += 1
758
+ self._sleep(wait)
759
+ continue
760
+ raise self._error(response, method, full_path) from None
761
+
762
+ @staticmethod
763
+ def _retry_wait(response: HttpResponse, attempt: int) -> float | None:
764
+ """Seconds to wait before retrying `response`, or None if it is not retryable."""
765
+ if response.status in _RETRY_STATUSES:
766
+ return float(2**attempt)
767
+ if response.status not in (403, 429):
768
+ return None
769
+ retry_after = response.headers.get("retry-after")
770
+ if retry_after is not None:
771
+ try:
772
+ seconds = float(retry_after)
773
+ except ValueError:
774
+ seconds = _MAX_RETRY_AFTER_S
775
+ return seconds if seconds <= _MAX_RETRY_AFTER_S else None
776
+ if response.status == 429 or b"secondary rate limit" in response.body.lower():
777
+ return _MAX_RETRY_AFTER_S
778
+ return None
779
+
780
+ def _error(self, response: HttpResponse, method: str, path: str) -> ReviewError:
781
+ status = response.status
782
+ where = f"{method} {path}"
783
+ if status == 401:
784
+ return ReviewError(
785
+ f"GitHub rejected the token (401) on {where}: check {TOKEN_ENV} is a valid, "
786
+ "unexpired token"
787
+ )
788
+ if status in (403, 429):
789
+ if _is_rate_limited(response):
790
+ return ReviewError(
791
+ f"GitHub rate limit hit ({status}) on {where} and the wait is too long "
792
+ "or the retries ran out; try again later"
793
+ )
794
+ return ReviewError(
795
+ f"GitHub refused {where} (403): check the token has 'Pull requests' and "
796
+ f"'Contents' read/write access on {self.repo}"
797
+ )
798
+ if status == 404:
799
+ return ReviewError(
800
+ f"GitHub returned 404 for {where}: check review.repo {self.repo!r} and the PR "
801
+ "number exist, and that the token can see the repo (private repos return 404 "
802
+ "to tokens without access)"
803
+ )
804
+ if status == 422:
805
+ return ReviewError(f"GitHub rejected {where} (422): {_validation_message(response)}")
806
+ if 300 <= status < 400:
807
+ return ReviewError(
808
+ f"GitHub redirected {where} ({status}); redirects are not followed: check "
809
+ f"review.repo {self.repo!r} (renamed or moved?) and review.api_url"
810
+ )
811
+ return ReviewError(f"GitHub API error {status} on {where}")
812
+
813
+
814
+ def _is_rate_limited(response: HttpResponse) -> bool:
815
+ return (
816
+ "retry-after" in response.headers
817
+ or response.headers.get("x-ratelimit-remaining") == "0"
818
+ or b"rate limit" in response.body.lower()
819
+ )
820
+
821
+
822
+ def _validation_message(response: HttpResponse) -> str:
823
+ """GitHub's own `message` and `errors[].message` texts, shortened."""
824
+ try:
825
+ data = json.loads(response.body)
826
+ except ValueError:
827
+ return "validation failed"
828
+ if not isinstance(data, dict):
829
+ return "validation failed"
830
+ parts = [str(data.get("message") or "validation failed")]
831
+ for err in data.get("errors") or ():
832
+ if isinstance(err, dict) and err.get("message"):
833
+ parts.append(str(err["message"]))
834
+ elif isinstance(err, dict) and err.get("code"):
835
+ parts.append(f"{err.get('field', '')} {err['code']}".strip())
836
+ return "; ".join(parts)[:300]
837
+
838
+
839
+ _LINK_NEXT = re.compile(r'<([^>]+)>\s*;\s*rel="?next"?')
840
+
841
+
842
+ def _next_link(link_header: str) -> str | None:
843
+ match = _LINK_NEXT.search(link_header)
844
+ return match.group(1) if match else None
845
+
846
+
847
+ # --- gate checker -------------------------------------------------------------------
848
+
849
+
850
+ class GitHubGateChecker:
851
+ """The scheduler's `GateChecker` for PR gates, delegating the rest to `fallback`.
852
+
853
+ A gate whose id prefix (the part before `:`) is in `pr_gates` is `rejected` if a
854
+ current review requests changes, `approved` if one approves, else `waiting`
855
+ (also when no PR is bound yet). "Current" means pinned to the PR's head commit
856
+ now (`current_hashes`). Other gates go to `fallback` (`waiting` if none).
857
+ """
858
+
859
+ def __init__(
860
+ self,
861
+ review: GitHubReview,
862
+ fallback: GateChecker | None = None,
863
+ *,
864
+ pr_gates: Collection[str] = ("pr",),
865
+ ) -> None:
866
+ self.review = review
867
+ self.fallback = fallback
868
+ self.pr_gates = frozenset(pr_gates)
869
+
870
+ def status(self, gate_id: str, instance: RuleInstance) -> GateStatus:
871
+ """Return the status of `gate_id` (see the class docstring)."""
872
+ if gate_id.split(":", 1)[0] not in self.pr_gates:
873
+ return self.fallback.status(gate_id, instance) if self.fallback else "waiting"
874
+ current = self.review.current_hashes(gate_id)
875
+ if not current:
876
+ return "waiting"
877
+ decisions = [
878
+ approval
879
+ for approval in self.review.approvals(gate_id)
880
+ if approval.is_current(current) and current.keys() <= approval.artifact_hashes.keys()
881
+ ]
882
+ if any(approval.decision == "reject" for approval in decisions):
883
+ return "rejected"
884
+ if any(approval.decision == "approve" for approval in decisions):
885
+ return "approved"
886
+ return "waiting"