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.
- chipgraph/__init__.py +7 -0
- chipgraph/adapters/__init__.py +1 -0
- chipgraph/adapters/format/__init__.py +6 -0
- chipgraph/adapters/format/__main__.py +16 -0
- chipgraph/adapters/format/_yaml_lines.py +54 -0
- chipgraph/adapters/format/chip_yaml.py +323 -0
- chipgraph/adapters/format/qsoc_contract.py +246 -0
- chipgraph/adapters/llm/__init__.py +58 -0
- chipgraph/adapters/llm/_budget.py +173 -0
- chipgraph/adapters/llm/_errors.py +50 -0
- chipgraph/adapters/llm/_nda.py +30 -0
- chipgraph/adapters/llm/_retry.py +85 -0
- chipgraph/adapters/llm/anthropic.py +247 -0
- chipgraph/adapters/llm/decide_backend.py +179 -0
- chipgraph/adapters/llm/fake.py +87 -0
- chipgraph/adapters/llm/guard.py +68 -0
- chipgraph/adapters/parser/__init__.py +22 -0
- chipgraph/adapters/parser/cocotb.py +304 -0
- chipgraph/adapters/parser/generic_regex.py +75 -0
- chipgraph/adapters/parser/verible.py +55 -0
- chipgraph/adapters/parser/verilator.py +46 -0
- chipgraph/adapters/review/__init__.py +7 -0
- chipgraph/adapters/review/file.py +123 -0
- chipgraph/adapters/review/github.py +886 -0
- chipgraph/adapters/runner/__init__.py +7 -0
- chipgraph/adapters/runner/local.py +122 -0
- chipgraph/adapters/runtime/__init__.py +5 -0
- chipgraph/adapters/runtime/claude_code/__init__.py +24 -0
- chipgraph/adapters/runtime/claude_code/agents.py +158 -0
- chipgraph/adapters/runtime/claude_code/decisions.py +280 -0
- chipgraph/adapters/runtime/claude_code/guarded.py +310 -0
- chipgraph/adapters/runtime/claude_code/isolation.py +482 -0
- chipgraph/adapters/runtime/claude_code/loop.py +342 -0
- chipgraph/adapters/runtime/claude_code/previous.py +419 -0
- chipgraph/adapters/runtime/claude_code/review.py +492 -0
- chipgraph/adapters/runtime/claude_code/runtime.py +192 -0
- chipgraph/adapters/runtime/claude_code/service.py +2285 -0
- chipgraph/adapters/tool/__init__.py +7 -0
- chipgraph/adapters/tool/cmd.py +229 -0
- chipgraph/adapters/tool/edalize.py +773 -0
- chipgraph/adapters/tool/filelist.py +259 -0
- chipgraph/adapters/tool/pyslang.py +485 -0
- chipgraph/adapters/vcs/__init__.py +1 -0
- chipgraph/adapters/vcs/git.py +521 -0
- chipgraph/app/__init__.py +10 -0
- chipgraph/app/baseline.py +339 -0
- chipgraph/app/build.py +332 -0
- chipgraph/app/checks.py +202 -0
- chipgraph/app/context.py +191 -0
- chipgraph/app/errors.py +11 -0
- chipgraph/app/executors.py +79 -0
- chipgraph/app/findings.py +203 -0
- chipgraph/app/ingest.py +523 -0
- chipgraph/checks/__init__.py +52 -0
- chipgraph/checks/_cdc_analyze.py +272 -0
- chipgraph/checks/_cdc_elaborate.py +455 -0
- chipgraph/checks/_common.py +268 -0
- chipgraph/checks/_cross.py +96 -0
- chipgraph/checks/_model.py +64 -0
- chipgraph/checks/_naming_pyslang.py +235 -0
- chipgraph/checks/_naming_rules.py +127 -0
- chipgraph/checks/_xref_syntax.py +260 -0
- chipgraph/checks/cdc_struct.py +235 -0
- chipgraph/checks/connect.py +353 -0
- chipgraph/checks/cross_chip.py +454 -0
- chipgraph/checks/duplicate.py +153 -0
- chipgraph/checks/filelist.py +323 -0
- chipgraph/checks/format.py +268 -0
- chipgraph/checks/generated.py +151 -0
- chipgraph/checks/hardcode.py +233 -0
- chipgraph/checks/layout.py +155 -0
- chipgraph/checks/naming.py +311 -0
- chipgraph/checks/plan_check.py +901 -0
- chipgraph/checks/ports_diff.py +203 -0
- chipgraph/checks/spec_schema.py +277 -0
- chipgraph/checks/style.py +910 -0
- chipgraph/checks/tb_static.py +599 -0
- chipgraph/checks/trace.py +224 -0
- chipgraph/checks/vendor_guard.py +369 -0
- chipgraph/checks/wrap_check.py +486 -0
- chipgraph/cli/__init__.py +1688 -0
- chipgraph/cli/next_steps.py +137 -0
- chipgraph/core/__init__.py +1 -0
- chipgraph/core/config/__init__.py +93 -0
- chipgraph/core/config/defaults.py +14 -0
- chipgraph/core/config/errors.py +32 -0
- chipgraph/core/config/loader.py +766 -0
- chipgraph/core/config/models.py +845 -0
- chipgraph/core/config/templates.py +340 -0
- chipgraph/core/contracts/__init__.py +75 -0
- chipgraph/core/contracts/_paths.py +26 -0
- chipgraph/core/contracts/agent.py +49 -0
- chipgraph/core/contracts/artifact.py +68 -0
- chipgraph/core/contracts/check.py +76 -0
- chipgraph/core/contracts/event.py +70 -0
- chipgraph/core/contracts/export.py +128 -0
- chipgraph/core/contracts/finding.py +171 -0
- chipgraph/core/contracts/gate.py +50 -0
- chipgraph/core/contracts/rule.py +177 -0
- chipgraph/core/contracts/types.py +45 -0
- chipgraph/core/engine/__init__.py +70 -0
- chipgraph/core/engine/agent_rule.py +1362 -0
- chipgraph/core/engine/baseline.py +283 -0
- chipgraph/core/engine/decide.py +462 -0
- chipgraph/core/engine/fanout.py +1358 -0
- chipgraph/core/engine/gate.py +170 -0
- chipgraph/core/engine/graph.py +647 -0
- chipgraph/core/engine/records.py +60 -0
- chipgraph/core/engine/rules.py +92 -0
- chipgraph/core/engine/scheduler.py +637 -0
- chipgraph/core/engine/select.py +1239 -0
- chipgraph/core/engine/targets.py +342 -0
- chipgraph/core/model/__init__.py +98 -0
- chipgraph/core/model/entities.py +244 -0
- chipgraph/core/model/export.py +16 -0
- chipgraph/core/model/ingest.py +693 -0
- chipgraph/core/model/json_value.py +4 -0
- chipgraph/core/model/keys.py +95 -0
- chipgraph/core/model/model.py +153 -0
- chipgraph/core/model/provenance.py +27 -0
- chipgraph/core/model/query.py +583 -0
- chipgraph/core/model/registry.py +80 -0
- chipgraph/core/model/relations.py +61 -0
- chipgraph/core/model/store.py +279 -0
- chipgraph/core/plugin_api/__init__.py +81 -0
- chipgraph/core/plugin_api/local.py +167 -0
- chipgraph/core/plugin_api/pack.py +159 -0
- chipgraph/core/plugin_api/protocols.py +177 -0
- chipgraph/core/plugin_api/registry.py +111 -0
- chipgraph/core/plugin_api/types.py +94 -0
- chipgraph/core/runtime/__init__.py +41 -0
- chipgraph/core/runtime/executor.py +79 -0
- chipgraph/core/runtime/queue.py +565 -0
- chipgraph/core/runtime/roles/__init__.py +91 -0
- chipgraph/core/runtime/roles/data/author.md +41 -0
- chipgraph/core/runtime/roles/data/critic.md +56 -0
- chipgraph/core/runtime/roles/data/planner.md +38 -0
- chipgraph/core/runtime/roles/data/researcher.md +37 -0
- chipgraph/core/runtime/roles/data/rtl-author.md +64 -0
- chipgraph/core/runtime/roles/data/tb-author.md +48 -0
- chipgraph/core/runtime/roles/data/triage.md +34 -0
- chipgraph/core/runtime/roles/policy.py +299 -0
- chipgraph/core/runtime/roles/registry.py +52 -0
- chipgraph/core/runtime/roles/review.py +275 -0
- chipgraph/core/runtime/roles/schema.py +82 -0
- chipgraph/core/runtime/roles/skills.py +134 -0
- chipgraph/core/runtime/roles/spec.py +232 -0
- chipgraph/core/state/__init__.py +1 -0
- chipgraph/core/state/artifacts.py +156 -0
- chipgraph/core/state/backend.py +117 -0
- chipgraph/core/state/findings.py +151 -0
- chipgraph/core/state/handoff.py +411 -0
- chipgraph/core/state/idempotency.py +59 -0
- chipgraph/core/state/journal.py +238 -0
- chipgraph/core/state/layout.py +82 -0
- chipgraph/core/state/lock.py +195 -0
- chipgraph/core/state/trace.py +225 -0
- chipgraph/learn/__init__.py +96 -0
- chipgraph/learn/draft.py +187 -0
- chipgraph/learn/infer.py +580 -0
- chipgraph/learn/models.py +145 -0
- chipgraph/learn/try_run.py +228 -0
- chipgraph/mcp/__init__.py +12 -0
- chipgraph/mcp/model_tools.py +167 -0
- chipgraph/mcp/server.py +787 -0
- chipgraph/orgs/qnsc/naming-v1.yml +113 -0
- chipgraph/orgs/qnsc/profile.yml +5 -0
- chipgraph/packs/__init__.py +8 -0
- chipgraph/packs/assist/__init__.py +16 -0
- chipgraph/packs/assist/ask/__init__.py +58 -0
- chipgraph/packs/assist/ask/_db.py +57 -0
- chipgraph/packs/assist/ask/_project.py +230 -0
- chipgraph/packs/assist/ask/answer.py +266 -0
- chipgraph/packs/assist/ask/check.py +182 -0
- chipgraph/packs/assist/ask/contract.py +115 -0
- chipgraph/packs/assist/ask/documents.py +157 -0
- chipgraph/packs/assist/ask/fts.py +80 -0
- chipgraph/packs/assist/ask/retrieve.py +331 -0
- chipgraph/packs/assist/audit/__init__.py +383 -0
- chipgraph/packs/assist/pack.yml +13 -0
- chipgraph/packs/assist/triage/__init__.py +64 -0
- chipgraph/packs/assist/triage/contract.py +94 -0
- chipgraph/packs/assist/triage/parse.py +371 -0
- chipgraph/packs/assist/triage/question.py +384 -0
- chipgraph/packs/assist/triage/report.py +160 -0
- chipgraph/packs/assist/triage/rules.py +447 -0
- chipgraph/packs/assist/triage/run.py +224 -0
- chipgraph/packs/digital_rtl/__init__.py +19 -0
- chipgraph/packs/digital_rtl/layout.py +144 -0
- chipgraph/packs/digital_rtl/pack.yml +25 -0
- chipgraph/packs/digital_rtl/plan/__init__.py +34 -0
- chipgraph/packs/digital_rtl/plan/__main__.py +16 -0
- chipgraph/packs/digital_rtl/plan/context.py +82 -0
- chipgraph/packs/digital_rtl/plan/expand.py +94 -0
- chipgraph/packs/digital_rtl/plan/model.py +150 -0
- chipgraph/packs/digital_rtl/plan/resolver.py +400 -0
- chipgraph/packs/digital_rtl/plan/show.py +176 -0
- chipgraph/packs/digital_rtl/pr/__init__.py +11 -0
- chipgraph/packs/digital_rtl/pr/body.py +165 -0
- chipgraph/packs/digital_rtl/pr/gate.py +110 -0
- chipgraph/packs/digital_rtl/pr/open.py +397 -0
- chipgraph/packs/digital_rtl/rtl/__init__.py +9 -0
- chipgraph/packs/digital_rtl/rtl/context.py +272 -0
- chipgraph/packs/digital_rtl/rules/open_pr.yml +30 -0
- chipgraph/packs/digital_rtl/rules/plan.yml +28 -0
- chipgraph/packs/digital_rtl/rules/plan_expand.yml +29 -0
- chipgraph/packs/digital_rtl/rules/pr_gate.yml +27 -0
- chipgraph/packs/digital_rtl/rules/review.yml +31 -0
- chipgraph/packs/digital_rtl/rules/rtl_module.yml +53 -0
- chipgraph/packs/digital_rtl/skills/lang-sv-rtl.md +58 -0
- chipgraph/packs/digital_rtl/skills/org-naming.md +29 -0
- chipgraph/packs/digital_rtl/skills/plan-modules.md +63 -0
- chipgraph/packs/digital_rtl/skills/review-diff.md +41 -0
- chipgraph/packs/digital_rtl/targets/plan.yml +11 -0
- chipgraph/packs/digital_rtl/targets/rtl.yml +20 -0
- chipgraph/packs/digital_rtl/wrapper/__init__.py +24 -0
- chipgraph/packs/digital_rtl/wrapper/common.py +171 -0
- chipgraph/packs/digital_rtl/wrapper/context.py +222 -0
- chipgraph/packs/digital_rtl/wrapper/emacs.py +626 -0
- chipgraph/packs/digital_rtl/wrapper/expansion.py +304 -0
- chipgraph/packs/digital_rtl/wrapper/lisp.py +504 -0
- chipgraph/packs/digital_rtl/wrapper/resolver.py +60 -0
- chipgraph/packs/digital_rtl/wrapper/rules/wrapper.yml +41 -0
- chipgraph/packs/digital_rtl/wrapper/rules/wrapper_gen.yml +35 -0
- chipgraph/packs/digital_rtl/wrapper/skills/wrapper-auto.md +120 -0
- chipgraph/packs/dv/__init__.py +15 -0
- chipgraph/packs/dv/declaration.py +392 -0
- chipgraph/packs/dv/interface.py +596 -0
- chipgraph/packs/dv/pack.yml +14 -0
- chipgraph/packs/dv/rules/tb_module.yml +42 -0
- chipgraph/packs/dv/skills/dv/cocotb.md +72 -0
- chipgraph/packs/spec_core/__init__.py +9 -0
- chipgraph/packs/spec_core/extract/__init__.py +12 -0
- chipgraph/packs/spec_core/extract/_markdown.py +371 -0
- chipgraph/packs/spec_core/extract/_text.py +116 -0
- chipgraph/packs/spec_core/extract/mas.py +975 -0
- chipgraph/packs/spec_core/gen/__init__.py +11 -0
- chipgraph/packs/spec_core/gen/agents_md/__init__.py +148 -0
- chipgraph/packs/spec_core/gen/agents_md/_context.py +239 -0
- chipgraph/packs/spec_core/gen/agents_md/_markers.py +131 -0
- chipgraph/packs/spec_core/gen/agents_md/agents_md.md.j2 +178 -0
- chipgraph/packs/spec_core/gen/diagram/__init__.py +110 -0
- chipgraph/packs/spec_core/gen/diagram/__main__.py +44 -0
- chipgraph/packs/spec_core/gen/diagram/_diagrams.py +403 -0
- chipgraph/packs/spec_core/gen/diagram/_scene.py +163 -0
- chipgraph/packs/spec_core/pack.yml +6 -0
- chipgraph/py.typed +0 -0
- chipgraph-0.1.0.dist-info/METADATA +53 -0
- chipgraph-0.1.0.dist-info/RECORD +252 -0
- chipgraph-0.1.0.dist-info/WHEEL +4 -0
- chipgraph-0.1.0.dist-info/entry_points.txt +61 -0
- 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"
|