Skip to content

Commit e71ef18

Browse files
committed
feat(bootstrap-repo): environments + required reviewers (last v0.2-deferred item)
The remaining VALIDATION.md deferred item from v0.1: environment protection + required-reviewer flows in bootstrap-repo. Now ships. Config shape (under targets.github): "environments": { "pypi": { "wait_timer": 0, "prevent_self_review": true, "reviewers": [ { "type": "User", "id": 12345 }, { "type": "Team", "id": 67890 } ], "deployment_branch_policy": { "protected_branches": false, "custom_branch_policies": true } }, "staging": { "wait_timer": 5 } } Each environment becomes one PUT /repos/<repo>/environments/<name> call. Step name in the report: `environment:<name>` so multiple envs surface independently. Dry-run prints what would PUT; --apply runs it and surfaces success or the GitHub error body. 3 new tests (184 total): - dry-run lists both pypi + staging - apply hits the API endpoint and reports `ok` - no `environments` block -> no `environment:*` steps emitted bootstrap-repo verb is now feature-complete relative to the v0.1 design (topics + branch protection + environments).
1 parent b739d3c commit e71ef18

3 files changed

Lines changed: 124 additions & 0 deletions

File tree

src/release_kit/platforms/git_hosts/github.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,24 @@ def __post_init__(self) -> None:
6363
self._branch_protection: dict[str, object] | None = (
6464
dict(bp) if isinstance(bp, dict) else None
6565
)
66+
# Optional declarative environments + required reviewers.
67+
# Shape (mirrors PUT /repos/{owner}/{repo}/environments/{name}):
68+
# environments:
69+
# pypi:
70+
# wait_timer: 0
71+
# prevent_self_review: true
72+
# reviewers:
73+
# - { type: User, id: 12345 }
74+
# - { type: Team, id: 67890 }
75+
# deployment_branch_policy:
76+
# protected_branches: false
77+
# custom_branch_policies: true
78+
envs = extras.get("environments")
79+
self._environments: dict[str, dict[str, object]] = (
80+
{str(k): dict(v) for k, v in envs.items() if isinstance(v, dict)}
81+
if isinstance(envs, dict)
82+
else {}
83+
)
6684

6785
def authenticate(self, ctx: RunContext) -> StepOutcome:
6886
if not self._repo or "/" not in self._repo:

src/release_kit/workflows/bootstrap_repo.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,42 @@ def run_bootstrap(config: Config, *, apply: bool = False) -> RunReport:
121121
StepOutcome("branch_protection", "failed", str(e), error=e)
122122
)
123123
report.failures.append(outcomes[-1])
124+
125+
# Environments + required reviewers (PUT /repos/.../environments/<name>)
126+
for env_name, env_body in plat_obj._environments.items():
127+
env_path = f"/repos/{plat_obj._repo}/environments/{env_name}"
128+
if ctx.dry_run:
129+
outcomes.append(
130+
StepOutcome(
131+
f"environment:{env_name}",
132+
"dry-run",
133+
f"would PUT {env_path}",
134+
)
135+
)
136+
continue
137+
try:
138+
plat_obj._api_put(
139+
ctx, env_path, json=env_body, env_var=plat_obj._env_var
140+
)
141+
reviewers = env_body.get("reviewers")
142+
n_reviewers = len(reviewers) if isinstance(reviewers, list) else 0
143+
outcomes.append(
144+
StepOutcome(
145+
f"environment:{env_name}",
146+
"ok",
147+
f"applied (reviewers={n_reviewers})",
148+
)
149+
)
150+
except PlatformError as e:
151+
outcomes.append(
152+
StepOutcome(
153+
f"environment:{env_name}",
154+
"failed",
155+
str(e),
156+
error=e,
157+
)
158+
)
159+
report.failures.append(outcomes[-1])
124160
else:
125161
outcomes.append(
126162
StepOutcome("topics", "skipped", "bootstrap not yet implemented for this host")

tests/workflows/test_workflows.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,76 @@ def test_bootstrap_apply_calls_branch_protection_endpoint(
340340
assert body.get("enforce_admins") is True
341341

342342

343+
def test_bootstrap_dry_run_environments(
344+
clean_env: None, monkeypatch: pytest.MonkeyPatch
345+
) -> None:
346+
monkeypatch.setenv("GITHUB_TOKEN", "ghp_x")
347+
cfg = _make_config(
348+
github={
349+
"enabled": True,
350+
"auth": "token",
351+
"repo": "o/r",
352+
"tag": "v1.0.0",
353+
"environments": {
354+
"pypi": {
355+
"wait_timer": 0,
356+
"reviewers": [{"type": "User", "id": 12345}],
357+
},
358+
"staging": {"wait_timer": 5},
359+
},
360+
}
361+
)
362+
report = run_bootstrap(cfg, apply=False)
363+
steps = report.target_outcomes["github"]
364+
env_steps = [s for s in steps if s.step.startswith("environment:")]
365+
assert len(env_steps) == 2
366+
assert all(s.status == "dry-run" for s in env_steps)
367+
env_names = {s.step for s in env_steps}
368+
assert env_names == {"environment:pypi", "environment:staging"}
369+
370+
371+
@respx.mock
372+
def test_bootstrap_apply_calls_environment_endpoint(
373+
clean_env: None, monkeypatch: pytest.MonkeyPatch
374+
) -> None:
375+
monkeypatch.setenv("GITHUB_TOKEN", "ghp_x")
376+
route = respx.put(
377+
"https://api.github.com/repos/o/r/environments/pypi"
378+
).mock(return_value=httpx.Response(200, json={"name": "pypi"}))
379+
cfg = _make_config(
380+
github={
381+
"enabled": True,
382+
"auth": "token",
383+
"repo": "o/r",
384+
"tag": "v1.0.0",
385+
"environments": {
386+
"pypi": {
387+
"wait_timer": 0,
388+
"reviewers": [{"type": "User", "id": 12345}],
389+
},
390+
},
391+
}
392+
)
393+
report = run_bootstrap(cfg, apply=True)
394+
steps = report.target_outcomes["github"]
395+
env_step = next(s for s in steps if s.step == "environment:pypi")
396+
assert env_step.status == "ok"
397+
assert route.called
398+
399+
400+
def test_bootstrap_no_environments_emits_no_step(
401+
clean_env: None, monkeypatch: pytest.MonkeyPatch
402+
) -> None:
403+
"""Without an `environments` block, no environment:* steps emit."""
404+
monkeypatch.setenv("GITHUB_TOKEN", "ghp_x")
405+
cfg = _make_config(
406+
github={"enabled": True, "auth": "token", "repo": "o/r", "tag": "v1.0.0"}
407+
)
408+
report = run_bootstrap(cfg, apply=False)
409+
steps = report.target_outcomes["github"]
410+
assert not any(s.step.startswith("environment:") for s in steps)
411+
412+
343413
def test_bootstrap_no_branch_protection_emits_no_step(
344414
clean_env: None, monkeypatch: pytest.MonkeyPatch
345415
) -> None:

0 commit comments

Comments
 (0)