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,165 @@
|
|
|
1
|
+
"""The body of a block's pull request: what a reviewer needs, factual and short.
|
|
2
|
+
|
|
3
|
+
Four parts, each from data the build already has:
|
|
4
|
+
|
|
5
|
+
- **Plan**: the approved plan's path and hash, its top and modules in build order (what
|
|
6
|
+
each does, its requirements, its files);
|
|
7
|
+
- **Requirement coverage**: each requirement of the block and the modules covering it,
|
|
8
|
+
or why it is unassigned (`chipgraph plan show`'s table);
|
|
9
|
+
- **Checks**: the last result of each check of the block's `rtl_module` and `tb_module`
|
|
10
|
+
instances, from the run journals;
|
|
11
|
+
- **Critic review**: the verdict and summary of `reports/review/<block>.json`, with its
|
|
12
|
+
blocker and major comments (`file:line`, the claim); minor ones are counted.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from chipgraph.app.context import AppContext
|
|
23
|
+
from chipgraph.core.runtime.roles import ReviewReport
|
|
24
|
+
from chipgraph.core.state import journal as journal_mod
|
|
25
|
+
|
|
26
|
+
MAX_COMMENTS = 20
|
|
27
|
+
"""Most blocker/major comments listed; the rest are counted."""
|
|
28
|
+
MAX_CHARS = 60_000
|
|
29
|
+
"""Longest body (GitHub's limit is 65 536 characters)."""
|
|
30
|
+
|
|
31
|
+
CHECKED_RULES = ("digital-rtl/rtl_module", "dv/tb_module")
|
|
32
|
+
"""The rules whose instances' check results the body lists."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def last_checks(ctx: AppContext, block: str) -> dict[str, dict[str, str]]:
|
|
36
|
+
"""Instance id -> check id -> status: the last result of each, over every run."""
|
|
37
|
+
runs = ctx.layout.runs_dir
|
|
38
|
+
found: dict[str, dict[str, str]] = {}
|
|
39
|
+
if not runs.is_dir():
|
|
40
|
+
return found
|
|
41
|
+
for run in sorted(p.name for p in runs.iterdir() if p.is_dir()):
|
|
42
|
+
try:
|
|
43
|
+
events = journal_mod.read(ctx.layout.journal(run)).events
|
|
44
|
+
except (OSError, ValueError):
|
|
45
|
+
continue
|
|
46
|
+
for event in events:
|
|
47
|
+
iid = event.rule_instance
|
|
48
|
+
if event.type != "check_result" or iid is None or not _of_block(iid, block):
|
|
49
|
+
continue
|
|
50
|
+
check = event.payload.get("check_id")
|
|
51
|
+
status = event.payload.get("status")
|
|
52
|
+
if isinstance(check, str) and isinstance(status, str):
|
|
53
|
+
found.setdefault(iid, {})[check] = status
|
|
54
|
+
return found
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _of_block(instance_id: str, block: str) -> bool:
|
|
58
|
+
rule, _, rest = instance_id.partition("[")
|
|
59
|
+
params = dict(p.partition("=")[::2] for p in rest.removesuffix("]").split(",") if "=" in p)
|
|
60
|
+
return rule in CHECKED_RULES and params.get("block") == block
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def read_review(path: Path) -> ReviewReport | None:
|
|
64
|
+
"""The Critic's review report at `path`, or None if it is missing or not one."""
|
|
65
|
+
try:
|
|
66
|
+
return ReviewReport.model_validate(json.loads(path.read_text(encoding="utf-8")))
|
|
67
|
+
except (OSError, ValueError):
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _plan_lines(summary: Mapping[str, Any]) -> list[str]:
|
|
72
|
+
gate = summary.get("gate", {})
|
|
73
|
+
sha = str(summary.get("sha256") or "")[:12]
|
|
74
|
+
lines = [
|
|
75
|
+
"### Plan",
|
|
76
|
+
f"`{summary.get('path')}` (sha256 `{sha}`), gate `{gate.get('id')}`: "
|
|
77
|
+
f"{gate.get('status')}. Top: `{summary.get('top')}`.",
|
|
78
|
+
"",
|
|
79
|
+
]
|
|
80
|
+
for module in summary.get("modules", []):
|
|
81
|
+
reqs = ", ".join(module.get("reqs", [])) or "-"
|
|
82
|
+
files = ", ".join(f"`{w['path']}`" for w in module.get("writes", []))
|
|
83
|
+
lines.append(f"- `{module['name']}`: {module['summary']} Requirements: {reqs}. {files}")
|
|
84
|
+
questions = summary.get("open_questions") or []
|
|
85
|
+
if questions:
|
|
86
|
+
lines.append("")
|
|
87
|
+
lines.append("Open questions in the plan: " + "; ".join(questions))
|
|
88
|
+
return lines
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _coverage_lines(summary: Mapping[str, Any]) -> list[str]:
|
|
92
|
+
rows = summary.get("coverage") or []
|
|
93
|
+
lines = ["### Requirement coverage"]
|
|
94
|
+
if not rows:
|
|
95
|
+
return [*lines, "The block has no requirements in the Design Model."]
|
|
96
|
+
covered = sum(1 for r in rows if r.get("modules"))
|
|
97
|
+
lines.append(f"{covered} of {len(rows)} requirements assigned to a module.")
|
|
98
|
+
lines.append("")
|
|
99
|
+
for row in rows:
|
|
100
|
+
if row.get("modules"):
|
|
101
|
+
lines.append(f"- {row['req']}: {', '.join(row['modules'])}")
|
|
102
|
+
elif row.get("unassigned"):
|
|
103
|
+
lines.append(f"- {row['req']}: unassigned ({row['unassigned']})")
|
|
104
|
+
else:
|
|
105
|
+
lines.append(f"- {row['req']}: not covered")
|
|
106
|
+
return lines
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _check_lines(checks: Mapping[str, Mapping[str, str]]) -> list[str]:
|
|
110
|
+
lines = ["### Checks"]
|
|
111
|
+
if not checks:
|
|
112
|
+
return [*lines, "No check results recorded for this block."]
|
|
113
|
+
for iid in sorted(checks):
|
|
114
|
+
results = ", ".join(f"{cid} {status}" for cid, status in checks[iid].items())
|
|
115
|
+
lines.append(f"- `{iid}`: {results}")
|
|
116
|
+
return lines
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _review_lines(review: ReviewReport | None, path: str) -> list[str]:
|
|
120
|
+
lines = ["### Critic review"]
|
|
121
|
+
if review is None:
|
|
122
|
+
return [*lines, f"No valid review report at `{path}`."]
|
|
123
|
+
lines.append(f"Verdict: **{review.verdict}**. {review.summary}")
|
|
124
|
+
serious = [c for c in review.comments if c.severity in ("blocker", "major")]
|
|
125
|
+
others = len(review.comments) - len(serious)
|
|
126
|
+
for comment in serious[:MAX_COMMENTS]:
|
|
127
|
+
req = f" ({comment.req_id})" if comment.req_id else ""
|
|
128
|
+
lines.append(f"- {comment.severity} `{comment.file}:{comment.line}`{req}: {comment.claim}")
|
|
129
|
+
if len(serious) > MAX_COMMENTS:
|
|
130
|
+
lines.append(f"- and {len(serious) - MAX_COMMENTS} more blocker/major comments")
|
|
131
|
+
if others:
|
|
132
|
+
lines.append(f"{others} minor or nit comment(s) are in `{path}`.")
|
|
133
|
+
if not review.comments:
|
|
134
|
+
lines.append("No findings.")
|
|
135
|
+
return lines
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def render_body(
|
|
139
|
+
*,
|
|
140
|
+
block: str,
|
|
141
|
+
summary: Mapping[str, Any],
|
|
142
|
+
checks: Mapping[str, Mapping[str, str]],
|
|
143
|
+
review: ReviewReport | None,
|
|
144
|
+
review_path: str,
|
|
145
|
+
files: Sequence[str],
|
|
146
|
+
) -> str:
|
|
147
|
+
"""The PR body (Markdown), at most `MAX_CHARS` characters."""
|
|
148
|
+
parts: Iterable[list[str]] = (
|
|
149
|
+
[
|
|
150
|
+
f"Block `{block}`: RTL and testbench built by chipgraph from the approved plan.",
|
|
151
|
+
"Merging is a person's decision: chipgraph never merges.",
|
|
152
|
+
],
|
|
153
|
+
_plan_lines(summary),
|
|
154
|
+
_coverage_lines(summary),
|
|
155
|
+
_check_lines(checks),
|
|
156
|
+
_review_lines(review, review_path),
|
|
157
|
+
["### Files", *(f"- `{f}`" for f in files)],
|
|
158
|
+
)
|
|
159
|
+
text = "\n\n".join("\n".join(part) for part in parts) + "\n"
|
|
160
|
+
if len(text) > MAX_CHARS:
|
|
161
|
+
text = text[: MAX_CHARS - 40] + "\n\n(cut: the body is too long)\n"
|
|
162
|
+
return text
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
__all__ = ["CHECKED_RULES", "last_checks", "read_review", "render_body"]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""`PrGateTool`: the `pr_gate` tool of rule `digital-rtl/pr_gate` (M2-11a).
|
|
2
|
+
|
|
3
|
+
The rule's input is `pr/<block>.json` (what `open_pr` wrote) and its gate `pr:<block>`,
|
|
4
|
+
so the scheduler runs this only once the gate is approved:
|
|
5
|
+
|
|
6
|
+
- with `review: {use: github}`, by a reviewer's approval on the pull request at its
|
|
7
|
+
current head commit (`GitHubGateChecker`; a later push makes it stale);
|
|
8
|
+
- with the `file` adapter, by `chipgraph approve pr:<block>`, which pins the hashes of
|
|
9
|
+
the rule's inputs (the PR record, the block's files): offline.
|
|
10
|
+
|
|
11
|
+
With the GitHub adapter it first compares the head commit of `pr/<block>.json` with the
|
|
12
|
+
pull request's **live** head: if someone pushed to the branch since `open_pr` ran, the
|
|
13
|
+
record is stale and the gate does not pass (it says to run the target again, which fast-forwards
|
|
14
|
+
the record) instead of recording a commit nobody reviewed.
|
|
15
|
+
|
|
16
|
+
It writes `pr/<block>.approved.json` (default; arg `out`): the branch and head commit
|
|
17
|
+
that were approved. It never merges: merging stays a person's act on the platform.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import time
|
|
24
|
+
|
|
25
|
+
from chipgraph.adapters.review.github import ReviewError
|
|
26
|
+
from chipgraph.app.context import AppContext
|
|
27
|
+
from chipgraph.app.errors import AppError
|
|
28
|
+
from chipgraph.checks._common import compute_idempotency_key
|
|
29
|
+
from chipgraph.core.contracts import CheckResult, CheckSpec, Issue
|
|
30
|
+
from chipgraph.core.plugin_api.types import ToolContext
|
|
31
|
+
from chipgraph.packs.digital_rtl.pr.open import DEFAULT_OUT, OpenPrTool, read_record
|
|
32
|
+
|
|
33
|
+
DEFAULT_APPROVED = "pr/{block}.approved.json"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PrGateTool:
|
|
37
|
+
"""Records the approved head of a block's pull request, once its gate is approved."""
|
|
38
|
+
|
|
39
|
+
name = "pr_gate"
|
|
40
|
+
capability = "gen"
|
|
41
|
+
|
|
42
|
+
async def run(self, spec: CheckSpec, ctx: ToolContext) -> CheckResult:
|
|
43
|
+
start = time.monotonic()
|
|
44
|
+
block = ctx.params.get("block", "")
|
|
45
|
+
record_t = spec.args.get("pr", DEFAULT_OUT)
|
|
46
|
+
out_t = spec.args.get("out", DEFAULT_APPROVED)
|
|
47
|
+
if not block or not isinstance(record_t, str) or not isinstance(out_t, str):
|
|
48
|
+
return self._result(spec, "error", "pr_gate needs a block and string templates", start)
|
|
49
|
+
rel = record_t.replace("{block}", block)
|
|
50
|
+
record = read_record(ctx.repo_root / rel)
|
|
51
|
+
if record is None:
|
|
52
|
+
return self._result(spec, "fail", f"no valid PR record at {rel}", start)
|
|
53
|
+
if record.review == "github":
|
|
54
|
+
stale = self._stale(ctx, record.number, record.head_sha, block)
|
|
55
|
+
if stale is not None:
|
|
56
|
+
return self._result(spec, "fail", stale, start)
|
|
57
|
+
out = ctx.repo_root / out_t.replace("{block}", block)
|
|
58
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
payload = {
|
|
60
|
+
"schema_version": 1,
|
|
61
|
+
"block": block,
|
|
62
|
+
"gate": record.gate,
|
|
63
|
+
"branch": record.branch,
|
|
64
|
+
"head_sha": record.head_sha,
|
|
65
|
+
"number": record.number,
|
|
66
|
+
"url": record.url,
|
|
67
|
+
}
|
|
68
|
+
out.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
69
|
+
return self._result(spec, "pass", f"{record.gate} approved at {record.head_sha}", start)
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def _stale(ctx: ToolContext, number: int | None, recorded: str, block: str) -> str | None:
|
|
73
|
+
"""Why the record cannot be trusted (None when the PR's live head is the recorded one)."""
|
|
74
|
+
if number is None:
|
|
75
|
+
return f"the PR record of {block!r} has no pull request number"
|
|
76
|
+
try:
|
|
77
|
+
review = OpenPrTool.review_for(AppContext.load(ctx.repo_root))
|
|
78
|
+
if review is None:
|
|
79
|
+
return "the PR record says github, but the profile has no github review adapter"
|
|
80
|
+
live = review.pull_request(number)
|
|
81
|
+
except (AppError, ReviewError, OSError) as exc:
|
|
82
|
+
return f"cannot read pull request #{number}: {exc}"
|
|
83
|
+
if live.state != "open":
|
|
84
|
+
return f"pull request #{number} is {live.state}, not open"
|
|
85
|
+
if live.head_sha != recorded:
|
|
86
|
+
return (
|
|
87
|
+
f"pull request #{number} head is {live.head_sha[:12]} but the record has "
|
|
88
|
+
f"{recorded[:12]}: the branch moved after open_pr ran; run the target again "
|
|
89
|
+
"so the record, and the review, follow the live head"
|
|
90
|
+
)
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _result(spec: CheckSpec, status: str, message: str, start: float) -> CheckResult:
|
|
95
|
+
return CheckResult(
|
|
96
|
+
check_id=spec.id,
|
|
97
|
+
status=status, # type: ignore[arg-type]
|
|
98
|
+
issues=(
|
|
99
|
+
Issue(
|
|
100
|
+
rule="pr_gate",
|
|
101
|
+
severity="info" if status == "pass" else "error",
|
|
102
|
+
msg=message,
|
|
103
|
+
),
|
|
104
|
+
),
|
|
105
|
+
duration_s=time.monotonic() - start,
|
|
106
|
+
idempotency_key=compute_idempotency_key(spec.id, spec.args, ()),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
__all__ = ["DEFAULT_APPROVED", "PrGateTool"]
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""`OpenPrTool`: the `open_pr` tool of rule `digital-rtl/open_pr` (M2-11a).
|
|
2
|
+
|
|
3
|
+
For one block with an approved plan, once its modules, its testbench and its review are
|
|
4
|
+
built (the rule's inputs), it
|
|
5
|
+
|
|
6
|
+
1. builds the branch `chipgraph/<run id>/<block>` **without touching the user's working
|
|
7
|
+
tree, index or HEAD**: a workspace (a git worktree) at HEAD, or at the branch's tip
|
|
8
|
+
when it exists already; exactly the block's RTL files (every module's write set) and
|
|
9
|
+
testbench copied into it from the user's tree; one commit with hooks off; the branch
|
|
10
|
+
created, or moved forward (a fast-forward, compare and swap), never rewritten. The
|
|
11
|
+
workspace is removed on every exit;
|
|
12
|
+
2. with `review: {use: github}`: pushes the branch to the profile's `review.remote`
|
|
13
|
+
(default `origin`), a fast-forward push, never a force. An `https://` remote needs the
|
|
14
|
+
token in `CHIPGRAPH_GITHUB_TOKEN`, which git gets through a `GIT_ASKPASS` helper only
|
|
15
|
+
(`GitVcs.push_branch`); a missing token is a clear error; a `file://` or local-path
|
|
16
|
+
remote needs none. Then opens the pull request with `GitHubReview.open_pr`
|
|
17
|
+
(idempotent: an open PR from the branch is reused, never re-opened) into
|
|
18
|
+
`review.base`, with the body of `body.render_body`, and binds gate `pr:<block>` to
|
|
19
|
+
it. With the `file` review adapter nothing is pushed or opened: the gate is approved
|
|
20
|
+
with `chipgraph approve pr:<block>`, offline;
|
|
21
|
+
3. writes `pr/<block>.json` (`PrRecord`): the branch, its head commit, the PR number and
|
|
22
|
+
URL (none with the `file` adapter). A push after review changes the head, so the
|
|
23
|
+
record changes, `digital-rtl/pr_gate` goes stale and the old approval with it.
|
|
24
|
+
|
|
25
|
+
Rule args (`{block}` templates): `review` (default `reports/review/{block}.json`) and
|
|
26
|
+
`out` (default `pr/{block}.json`). Nothing here merges.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import shutil
|
|
35
|
+
import stat
|
|
36
|
+
import tempfile
|
|
37
|
+
import time
|
|
38
|
+
import urllib.parse
|
|
39
|
+
from collections.abc import Callable, Sequence
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import ClassVar, Literal
|
|
42
|
+
|
|
43
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
44
|
+
|
|
45
|
+
from chipgraph.adapters.review.github import TOKEN_ENV, GitHubReview, ReviewError
|
|
46
|
+
from chipgraph.adapters.vcs.git import GitVcs, VcsError, redact_secret, url_host
|
|
47
|
+
from chipgraph.app.build import load_rules
|
|
48
|
+
from chipgraph.app.context import AppContext
|
|
49
|
+
from chipgraph.app.errors import AppError
|
|
50
|
+
from chipgraph.checks._common import compute_idempotency_key
|
|
51
|
+
from chipgraph.checks.plan_check import PlanEnvError
|
|
52
|
+
from chipgraph.core.contracts import CheckResult, CheckSpec, Issue, RuleSpec
|
|
53
|
+
from chipgraph.core.engine.gate import gate_id_for
|
|
54
|
+
from chipgraph.core.plugin_api.types import ToolContext
|
|
55
|
+
from chipgraph.core.state.layout import StateLayout, new_run_id
|
|
56
|
+
from chipgraph.packs.digital_rtl.plan.resolver import (
|
|
57
|
+
ApprovedPlan,
|
|
58
|
+
approved_plan,
|
|
59
|
+
block_files,
|
|
60
|
+
plan_gates,
|
|
61
|
+
)
|
|
62
|
+
from chipgraph.packs.digital_rtl.plan.show import plan_summary
|
|
63
|
+
from chipgraph.packs.digital_rtl.pr.body import last_checks, read_review, render_body
|
|
64
|
+
|
|
65
|
+
DEFAULT_REVIEW = "reports/review/{block}.json"
|
|
66
|
+
DEFAULT_OUT = "pr/{block}.json"
|
|
67
|
+
PR_GATE_RULE = "digital-rtl/pr_gate"
|
|
68
|
+
DEFAULT_GATE = "pr:{block}"
|
|
69
|
+
BRANCH_PREFIX = "chipgraph"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class OpenPrError(Exception):
|
|
73
|
+
"""The block's pull request cannot be built (a message for a person)."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class PrRecord(BaseModel):
|
|
77
|
+
"""`pr/<block>.json`: the branch and pull request of a block's planned RTL."""
|
|
78
|
+
|
|
79
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
80
|
+
|
|
81
|
+
schema_version: Literal[1] = Field(default=1, description="Schema version of this file.")
|
|
82
|
+
block: str = Field(description="The block.")
|
|
83
|
+
branch: str = Field(description="The branch, `chipgraph/<run id>/<block>`.")
|
|
84
|
+
head_sha: str = Field(description="The branch's head commit.")
|
|
85
|
+
files: tuple[str, ...] = Field(description="The files the branch adds or changes.")
|
|
86
|
+
review: Literal["github", "file"] = Field(description="Where the gate is decided.")
|
|
87
|
+
gate: str = Field(description="The gate the PR is bound to, `pr:<block>`.")
|
|
88
|
+
remote: str | None = Field(default=None, description="The remote it was pushed to.")
|
|
89
|
+
base: str | None = Field(default=None, description="The PR's base branch.")
|
|
90
|
+
number: int | None = Field(default=None, description="The PR number (GitHub).")
|
|
91
|
+
url: str | None = Field(default=None, description="The PR's web URL (GitHub).")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _fill(template: str, block: str) -> str:
|
|
95
|
+
return template.replace("{block}", block)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def needs_token(url: str) -> bool:
|
|
99
|
+
"""Whether pushing to `url` needs the HTTPS token (not for file://, a path, or SSH)."""
|
|
100
|
+
return urllib.parse.urlsplit(url).scheme in ("http", "https")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def check_token_host(url: str, api_url: str) -> None:
|
|
104
|
+
"""Refuse to send the token to a git host that is not the API's host.
|
|
105
|
+
|
|
106
|
+
The remote comes from the repo's git config; a token meant for `api_url`'s GitHub must
|
|
107
|
+
not be handed to whatever host `origin` points at.
|
|
108
|
+
"""
|
|
109
|
+
git_host = url_host(url)
|
|
110
|
+
api_host = url_host(api_url)
|
|
111
|
+
allowed = {api_host, api_host.removeprefix("api.") if api_host else None} - {None}
|
|
112
|
+
if git_host is None or git_host not in allowed:
|
|
113
|
+
raise OpenPrError(
|
|
114
|
+
f"refusing to send the GitHub token to {git_host or url!r}: the API is at "
|
|
115
|
+
f"{api_host!r}, so the remote must be on {sorted(str(h) for h in allowed)}"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _latest_run(layout: StateLayout) -> str:
|
|
120
|
+
runs = layout.runs_dir
|
|
121
|
+
if runs.is_dir():
|
|
122
|
+
ids = sorted(p.name for p in runs.iterdir() if p.is_dir())
|
|
123
|
+
if ids:
|
|
124
|
+
return ids[-1]
|
|
125
|
+
return new_run_id()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _gate_id(rules: Sequence[RuleSpec], block: str) -> str:
|
|
129
|
+
rule = next((r for r in rules if r.id == PR_GATE_RULE), None)
|
|
130
|
+
template = rule.gate if rule is not None and rule.gate else DEFAULT_GATE
|
|
131
|
+
return gate_id_for(template, {"block": block})
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def read_record(path: Path) -> PrRecord | None:
|
|
135
|
+
"""The `PrRecord` at `path`, or None if it is missing or invalid."""
|
|
136
|
+
try:
|
|
137
|
+
return PrRecord.model_validate_json(path.read_text(encoding="utf-8"))
|
|
138
|
+
except (OSError, ValueError):
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def check_branch(branch: str, block: str) -> None:
|
|
143
|
+
"""Refuse a branch name that is not `chipgraph/<run id>/<block>`.
|
|
144
|
+
|
|
145
|
+
The name can come from `pr/<block>.json`, a file in the working tree that anyone may
|
|
146
|
+
edit; moving or pushing `main` because that file says so would publish without a PR.
|
|
147
|
+
"""
|
|
148
|
+
pattern = rf"{re.escape(BRANCH_PREFIX)}/[A-Za-z0-9][A-Za-z0-9._-]*/{re.escape(block)}"
|
|
149
|
+
if re.fullmatch(pattern, branch) is None:
|
|
150
|
+
raise OpenPrError(
|
|
151
|
+
f"refusing branch {branch!r} for block {block!r}: a block's pull request branch "
|
|
152
|
+
f"is {BRANCH_PREFIX}/<run id>/{block}"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _plain_path(base: Path, rel: str, *, what: str) -> Path:
|
|
157
|
+
"""`base / rel` with no symlink on the way and inside `base` (else `OpenPrError`).
|
|
158
|
+
|
|
159
|
+
Every component is checked, not only the last: a symlinked directory in the user's tree
|
|
160
|
+
would read from outside it, and one in the committed tree of the branch would write
|
|
161
|
+
outside the workspace.
|
|
162
|
+
"""
|
|
163
|
+
parts = Path(rel).parts
|
|
164
|
+
if not parts or Path(rel).is_absolute() or any(part in ("", ".", "..") for part in parts):
|
|
165
|
+
raise OpenPrError(f"{what} path {rel!r} is not a plain relative path")
|
|
166
|
+
path = base
|
|
167
|
+
for part in parts:
|
|
168
|
+
path = path / part
|
|
169
|
+
if path.is_symlink():
|
|
170
|
+
raise OpenPrError(f"{what} path {rel!r}: {part!r} is a symlink")
|
|
171
|
+
try:
|
|
172
|
+
path.resolve().relative_to(base.resolve())
|
|
173
|
+
except ValueError:
|
|
174
|
+
raise OpenPrError(f"{what} path {rel!r} resolves outside {base}") from None
|
|
175
|
+
return path
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _copy_file(src: Path, dest: Path) -> None:
|
|
179
|
+
"""Copy the regular file `src` to `dest` without following a link at either end."""
|
|
180
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
|
181
|
+
try:
|
|
182
|
+
fd = os.open(src, flags)
|
|
183
|
+
except OSError as exc:
|
|
184
|
+
raise OpenPrError(f"cannot read {src}: {exc.strerror or exc}") from None
|
|
185
|
+
with os.fdopen(fd, "rb") as source:
|
|
186
|
+
mode = os.fstat(source.fileno()).st_mode
|
|
187
|
+
if not stat.S_ISREG(mode):
|
|
188
|
+
raise OpenPrError(f"{src} is not a regular file")
|
|
189
|
+
tmp = dest.with_name(f".{dest.name}.chipgraph-tmp")
|
|
190
|
+
try:
|
|
191
|
+
with open(tmp, "xb") as target:
|
|
192
|
+
shutil.copyfileobj(source, target)
|
|
193
|
+
os.chmod(tmp, stat.S_IMODE(mode))
|
|
194
|
+
os.replace(tmp, dest) # replaces a link itself, never what it points at
|
|
195
|
+
finally:
|
|
196
|
+
if tmp.exists() or tmp.is_symlink():
|
|
197
|
+
tmp.unlink()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def build_branch(
|
|
201
|
+
vcs: GitVcs, root: Path, branch: str, files: Sequence[str], message: str
|
|
202
|
+
) -> tuple[str, str | None]:
|
|
203
|
+
"""Commit exactly `files` (copied from `root`) onto `branch`; `(new head, old head)`.
|
|
204
|
+
|
|
205
|
+
The commit is made in a workspace at the branch's tip (or HEAD for a new branch),
|
|
206
|
+
outside the user's tree; the branch is then created or fast-forwarded. HEAD, the
|
|
207
|
+
index and the working tree of `root` are never touched.
|
|
208
|
+
"""
|
|
209
|
+
if branch in vcs.checked_out_branches(root):
|
|
210
|
+
raise OpenPrError(
|
|
211
|
+
f"refusing branch {branch!r}: it is checked out in a worktree; switch that "
|
|
212
|
+
"worktree to another branch first (the PR branch is moved without a checkout)"
|
|
213
|
+
)
|
|
214
|
+
sources = {rel: _plain_path(root, rel, what="output") for rel in files}
|
|
215
|
+
old = vcs.branch_tip(root, branch)
|
|
216
|
+
base = old or vcs.head(root)
|
|
217
|
+
holder = Path(tempfile.mkdtemp(prefix="chipgraph-pr-"))
|
|
218
|
+
workspace: Path | None = None
|
|
219
|
+
try:
|
|
220
|
+
workspace = vcs.create_branch_workspace(root, base, "pr", holder)
|
|
221
|
+
for rel in files:
|
|
222
|
+
_plain_path(workspace, rel, what="branch")
|
|
223
|
+
dest = workspace / rel
|
|
224
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
225
|
+
dest = _plain_path(workspace, rel, what="branch") # again: mkdir made the parents
|
|
226
|
+
if dest.is_dir():
|
|
227
|
+
raise OpenPrError(f"{rel} is a directory at {base[:12]}")
|
|
228
|
+
_copy_file(sources[rel], dest)
|
|
229
|
+
commit = vcs.commit_all(workspace, message, paths=list(files))
|
|
230
|
+
finally:
|
|
231
|
+
if workspace is not None:
|
|
232
|
+
vcs.remove(workspace)
|
|
233
|
+
vcs.prune(root)
|
|
234
|
+
shutil.rmtree(holder, ignore_errors=True)
|
|
235
|
+
if old is None:
|
|
236
|
+
vcs.create_branch(root, branch, commit)
|
|
237
|
+
else:
|
|
238
|
+
vcs.advance_branch(root, branch, commit, old)
|
|
239
|
+
return commit, old
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def open_block_pr(
|
|
243
|
+
app: AppContext,
|
|
244
|
+
block: str,
|
|
245
|
+
*,
|
|
246
|
+
review_path: str = DEFAULT_REVIEW,
|
|
247
|
+
out_path: str = DEFAULT_OUT,
|
|
248
|
+
review: GitHubReview | None = None,
|
|
249
|
+
vcs: GitVcs | None = None,
|
|
250
|
+
token: str | None = None,
|
|
251
|
+
) -> PrRecord:
|
|
252
|
+
"""Build, push and open the PR of `block`; write and return its `PrRecord`.
|
|
253
|
+
|
|
254
|
+
`review` is the GitHub adapter (None: the `file` adapter, nothing pushed or opened);
|
|
255
|
+
`token` the HTTPS token, used only for an `http(s)://` remote. Raises `OpenPrError`.
|
|
256
|
+
"""
|
|
257
|
+
vcs = vcs or GitVcs()
|
|
258
|
+
root = app.root
|
|
259
|
+
rules = load_rules(app)
|
|
260
|
+
resolved = app.require_profile()
|
|
261
|
+
if block not in resolved.profile.blocks:
|
|
262
|
+
raise OpenPrError(f"unknown block {block!r}")
|
|
263
|
+
gate = plan_gates(rules, resolved.profile.blocks, repo=app.store.repo)[block]
|
|
264
|
+
try:
|
|
265
|
+
approved: ApprovedPlan | None = approved_plan(app, rules, gate)
|
|
266
|
+
except PlanEnvError as exc:
|
|
267
|
+
raise OpenPrError(str(exc)) from exc
|
|
268
|
+
if approved is None:
|
|
269
|
+
raise OpenPrError(
|
|
270
|
+
f"block {block!r} has no approved, valid plan: approve gate {gate.gate_id!r} first"
|
|
271
|
+
)
|
|
272
|
+
rtl, tb = block_files(rules, approved)
|
|
273
|
+
files = [*rtl, *tb]
|
|
274
|
+
missing = [rel for rel in files if not (root / rel).is_file()]
|
|
275
|
+
if missing:
|
|
276
|
+
raise OpenPrError(f"block {block!r}: not built yet: {', '.join(missing)}")
|
|
277
|
+
|
|
278
|
+
out = root / _fill(out_path, block)
|
|
279
|
+
previous = read_record(out)
|
|
280
|
+
branch = (
|
|
281
|
+
previous.branch
|
|
282
|
+
if previous is not None and previous.block == block
|
|
283
|
+
else f"{BRANCH_PREFIX}/{_latest_run(app.layout)}/{block}"
|
|
284
|
+
)
|
|
285
|
+
check_branch(branch, block)
|
|
286
|
+
message = f"chipgraph: RTL and testbench of block {block} from its approved plan"
|
|
287
|
+
head, _old = build_branch(vcs, root, branch, files, message)
|
|
288
|
+
|
|
289
|
+
gate_id = _gate_id(rules, block)
|
|
290
|
+
record = PrRecord(
|
|
291
|
+
block=block, branch=branch, head_sha=head, files=tuple(files), review="file", gate=gate_id
|
|
292
|
+
)
|
|
293
|
+
if review is not None:
|
|
294
|
+
remote = review.remote
|
|
295
|
+
url = vcs.remote_url(root, remote)
|
|
296
|
+
secret: str | None = None
|
|
297
|
+
if needs_token(url):
|
|
298
|
+
check_token_host(url, review.api_url)
|
|
299
|
+
secret = token
|
|
300
|
+
if not secret:
|
|
301
|
+
raise OpenPrError(
|
|
302
|
+
f"no GitHub token: set {TOKEN_ENV} to push {branch!r} to remote "
|
|
303
|
+
f"{remote!r} (a fine-grained token with Contents and Pull requests "
|
|
304
|
+
"read/write on the repo)"
|
|
305
|
+
)
|
|
306
|
+
vcs.push_branch(root, remote, head, branch, token=secret)
|
|
307
|
+
rel_review = _fill(review_path, block)
|
|
308
|
+
body = render_body(
|
|
309
|
+
block=block,
|
|
310
|
+
summary=plan_summary(app, block),
|
|
311
|
+
checks=last_checks(app, block),
|
|
312
|
+
review=read_review(root / rel_review),
|
|
313
|
+
review_path=rel_review,
|
|
314
|
+
files=files,
|
|
315
|
+
)
|
|
316
|
+
pr = review.open_pr(branch, None, f"chipgraph: RTL of block {block}", body, gate_id=gate_id)
|
|
317
|
+
record = record.model_copy(
|
|
318
|
+
update={
|
|
319
|
+
"review": "github",
|
|
320
|
+
"remote": remote,
|
|
321
|
+
"base": pr.base,
|
|
322
|
+
"number": pr.number,
|
|
323
|
+
"url": pr.url,
|
|
324
|
+
}
|
|
325
|
+
)
|
|
326
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
327
|
+
out.write_text(
|
|
328
|
+
json.dumps(record.model_dump(mode="json"), indent=2, sort_keys=True) + "\n",
|
|
329
|
+
encoding="utf-8",
|
|
330
|
+
)
|
|
331
|
+
return record
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _scrub(text: str, secret: str | None) -> str:
|
|
335
|
+
return redact_secret(text, secret)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class OpenPrTool:
|
|
339
|
+
"""Builds, pushes and opens a block's pull request (see the module docstring)."""
|
|
340
|
+
|
|
341
|
+
name = "open_pr"
|
|
342
|
+
capability = "gen"
|
|
343
|
+
|
|
344
|
+
review_for: ClassVar[Callable[[AppContext], GitHubReview | None]] = staticmethod(
|
|
345
|
+
lambda app: app.pr_review
|
|
346
|
+
)
|
|
347
|
+
"""How the tool gets the GitHub adapter of a project (tests swap in a fake API)."""
|
|
348
|
+
|
|
349
|
+
async def run(self, spec: CheckSpec, ctx: ToolContext) -> CheckResult:
|
|
350
|
+
start = time.monotonic()
|
|
351
|
+
block = ctx.params.get("block")
|
|
352
|
+
review_t = spec.args.get("review", DEFAULT_REVIEW)
|
|
353
|
+
out_t = spec.args.get("out", DEFAULT_OUT)
|
|
354
|
+
token = os.environ.get(TOKEN_ENV, "").strip() or None
|
|
355
|
+
if not block or not isinstance(review_t, str) or not isinstance(out_t, str):
|
|
356
|
+
return self._result(spec, "error", "open_pr needs a block and string templates", start)
|
|
357
|
+
try:
|
|
358
|
+
app = AppContext.load(ctx.repo_root)
|
|
359
|
+
record = open_block_pr(
|
|
360
|
+
app,
|
|
361
|
+
block,
|
|
362
|
+
review_path=review_t,
|
|
363
|
+
out_path=out_t,
|
|
364
|
+
review=type(self).review_for(app),
|
|
365
|
+
token=token,
|
|
366
|
+
)
|
|
367
|
+
except (OpenPrError, AppError, VcsError, ReviewError, OSError) as exc:
|
|
368
|
+
return self._result(spec, "error", _scrub(str(exc), token), start)
|
|
369
|
+
where = record.url or f"branch {record.branch} (gate {record.gate}: chipgraph approve)"
|
|
370
|
+
return self._result(spec, "pass", f"{record.head_sha[:12]} -> {where}", start)
|
|
371
|
+
|
|
372
|
+
@staticmethod
|
|
373
|
+
def _result(spec: CheckSpec, status: str, message: str, start: float) -> CheckResult:
|
|
374
|
+
severity = "info" if status == "pass" else "error"
|
|
375
|
+
return CheckResult(
|
|
376
|
+
check_id=spec.id,
|
|
377
|
+
status=status, # type: ignore[arg-type]
|
|
378
|
+
issues=(Issue(rule="open_pr", severity=severity, msg=message),), # type: ignore[arg-type]
|
|
379
|
+
duration_s=time.monotonic() - start,
|
|
380
|
+
idempotency_key=compute_idempotency_key(spec.id, spec.args, ()),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
__all__ = [
|
|
385
|
+
"BRANCH_PREFIX",
|
|
386
|
+
"DEFAULT_OUT",
|
|
387
|
+
"DEFAULT_REVIEW",
|
|
388
|
+
"OpenPrError",
|
|
389
|
+
"OpenPrTool",
|
|
390
|
+
"PrRecord",
|
|
391
|
+
"build_branch",
|
|
392
|
+
"check_branch",
|
|
393
|
+
"check_token_host",
|
|
394
|
+
"needs_token",
|
|
395
|
+
"open_block_pr",
|
|
396
|
+
"read_record",
|
|
397
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""The RTL of one planned module (M2-04): what an `rtl-author` task is given.
|
|
2
|
+
|
|
3
|
+
- `context`: the `rtl` section of an `rtl-author` task's context: the module's plan entry,
|
|
4
|
+
its requirements, interface and dependencies, the naming rules as text and the style
|
|
5
|
+
exemplars.
|
|
6
|
+
|
|
7
|
+
The rule is `rules/rtl_module.yml`; its checks `format` and `style` are
|
|
8
|
+
`chipgraph.checks.format` and `chipgraph.checks.style`.
|
|
9
|
+
"""
|