Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion ion/analyze/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ def add(cls, status, detail, fix=None):
ss = pol.get("secret_scanning")
if ss and ss != "off":
if private and ss == "public_only":
add("secret_scanning", "blocked_by_plan", "secret scanning on private repos needs a paid plan") if free else None
if free:
add("secret_scanning", "blocked_by_plan", "secret scanning on private repos needs a paid plan")
else:
add("secret_scanning", "unknown", "secret scanning status not collected for private repos")
elif snap.get("fork"):
add("secret_scanning", "skipped", "fork")
else:
Expand Down
2 changes: 1 addition & 1 deletion ion/approve/bus.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def sync_bus(ctx) -> dict:
seats = set(cfg["bus"]["maintainer_seats"])
top = since
for e in envs:
top = max(top, int(e.get("seq", 0)))
top = max(top, int(e.get("seq") or 0))
for e in cands:
res["seen"] += 1
for m in MSG_RE.finditer(e["body"]):
Expand Down
2 changes: 1 addition & 1 deletion ion/approve/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def evaluate(ctx, p: dict, ignore_ttl: bool = False, at: dt.datetime | None = No
approvers = {}
for (src, actor), v in actions.items():
if v["action"] == "approve" and (src == "divan_bus" or actor.lower() in maint):
approvers[actor] = v["event_ts"]
approvers[actor.lower()] = v["event_ts"]
need = quorum_for(cfg, p["kind"])
if len(approvers) < need:
why = f"{len(approvers)}/{need} approvals"
Expand Down
10 changes: 6 additions & 4 deletions ion/collect/github.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
"""Batched GraphQL collection of org repo metadata."""
from __future__ import annotations

import logging

from ..ghclient import GH

log = logging.getLogger("ion")

QUERY = """
query($org: String!, $after: String, $n: Int!, $page: Int!) {
rateLimit { cost remaining resetAt }
Expand All @@ -27,7 +31,6 @@
vulnerabilityAlerts(first: 1, states: OPEN) { totalCount }
branchProtectionRules(first: 10) { nodes { pattern requiresApprovingReviews requiredApprovingReviewCount
allowsForcePushes allowsDeletions isAdminEnforced } }
collaborators(first: 20) { nodes { login } }
ionYml: object(expression: "HEAD:.github/ion.yml") { ... on Blob { text } }
legacyYml: object(expression: "HEAD:.github/mimar.yml") { ... on Blob { text } }
parent { nameWithOwner pushedAt }
Expand Down Expand Up @@ -67,8 +70,6 @@ def normalise(node: dict) -> dict:
"vuln_alerts": ((node.get("vulnerabilityAlerts") or {}).get("totalCount")),
"vuln_enabled": node.get("hasVulnerabilityAlertsEnabled"),
"protection": (node.get("branchProtectionRules") or {}).get("nodes") or [],
"collaborators": [c["login"] for c in ((node.get("collaborators") or {}).get("nodes") or [])]
if node.get("collaborators") else None,
"override_text": (node.get("ionYml") or node.get("legacyYml") or {}).get("text"),
"parent": node.get("parent"),
"commits": _commits(node),
Expand Down Expand Up @@ -114,7 +115,8 @@ def fetch_security_extras(gh: GH, org: str, snap: dict) -> dict | None:
"""Secret scanning status is not in GraphQL; one REST read per public non-fork repo."""
try:
data = gh.rest("GET", f"/repos/{org}/{snap['name']}")
except Exception:
except Exception as e:
log.warning("security extras for %s unreadable: %s", snap["name"], e)
return None
sa = (data or {}).get("security_and_analysis")
if sa is None:
Expand Down
6 changes: 5 additions & 1 deletion ion/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,11 @@
"backend": {"enum": ["claude_cli", "anthropic_api", "none"]},
"max_calls_per_run": {"type": "integer", "minimum": 0}}},
"budget": {"type": "object", "properties": {"graphql_points_per_run": {"type": "integer", "minimum": 1}}},
"policy": {"type": "object"},
"policy": {"type": "object", "properties": {
"branch_protection": {"type": "object", "properties": {
"require_pr": {"type": "boolean"}, "required_approvals": {"type": "integer", "minimum": 0},
"allow_force_pushes": {"type": "boolean"}, "allow_deletions": {"type": "boolean"},
"enforce_admins": {"type": "boolean"}}}}},
"auto_apply": {"type": "array", "items": {"enum": ["branch_protection", "dependabot_alerts", "secret_scanning",
"health_fix"]}},
"policy_actions": {"type": "object", "additionalProperties": {"enum": ["log", "issue", "fix"]},
Expand Down
10 changes: 6 additions & 4 deletions ion/execute/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,12 +221,14 @@ def safe_push(self, argv_tail: list[str], cwd, default_branch: str | None, allow
brand new empty repo."""
full = ["push", *argv_tail]
refs = [a for a in argv_tail if not a.startswith("-")]
dest = ""
for r in refs[1:]:
dest = r.split(":")[-1].replace("refs/heads/", "")
dests = [r.split(":")[-1].replace("refs/heads/", "") for r in refs[1:]]
for dest in dests:
if default_branch and dest == default_branch and not allow_default:
raise SafetyError(f"refusing to push to default branch {default_branch}")
req = WriteRequest("push_branch", repo or "unknown", {"branch": dest, "base": default_branch or "main",
if not dest.startswith("ion/") and not allow_default:
raise SafetyError(f"refusing to push non-ion branch {dest}")
req = WriteRequest("push_branch", repo or "unknown", {"branch": dests[-1] if dests else "",
"base": default_branch or "main",
**({"initial": True} if allow_default else {})})
self.ctx.writes.submit(req, lambda: self.g(full, cwd=cwd))

Expand Down
12 changes: 7 additions & 5 deletions ion/fleet.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,11 +209,13 @@ def preview(ctx, fixer: Autofixer, repo_glob: str | None = None) -> dict[str, st
repos = fleet_repos(ctx, repo_glob)
root = ctx.work_dir("preview") / fixer.name
out = {}
for name in fixer.find_repos(ctx, repos):
d = diff_of(ctx, fixer, name, repos[name], root / name)
if d.strip():
out[name] = d
shutil.rmtree(root, ignore_errors=True)
try:
for name in fixer.find_repos(ctx, repos):
d = diff_of(ctx, fixer, name, repos[name], root / name)
if d.strip():
out[name] = d
finally:
shutil.rmtree(root, ignore_errors=True)
return out


Expand Down
10 changes: 10 additions & 0 deletions tests/test_analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ def test_policy_drift_and_plan_limits(collected):
assert pol[("alpha-core", "required_file:README.md")] == "ok"


def test_policy_secret_scanning_private_repo_on_paid_plan_is_unknown(collected):
snap = {"name": "r", "private": True, "fork": False, "archived": False, "default_branch": "main",
"protection": [], "vuln_enabled": True}
paid = dict(collected.cfg)
paid["plan"] = "team"
fs = policy.check(snap, {"README.md"}, paid, None)
ss = [f for f in fs if f["class"] == "secret_scanning"]
assert len(ss) == 1 and ss[0]["status"] == "unknown"


def test_policy_per_repo_override_changes_requirements(collected):
snap = {"name": "r", "private": False, "fork": False, "archived": False, "default_branch": "main", "protection": [],
"vuln_enabled": True, "secret_scanning": "enabled"}
Expand Down
23 changes: 23 additions & 0 deletions tests/test_approve.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,26 @@ def test_bus_down_is_reported_not_fatal(tmp_path, home, world):
setup(ctx)
r = sync_approvals(ctx)
assert r["errors"] and "bus" in r["errors"][0]


def test_quorum_counts_case_variants_as_one_approver(tmp_path, home, world):
ctx = make_ctx(tmp_path, world, {"quorum": 2, "maintainers": ["ada"],
"approval_sources": ["github_label", "divan_bus"],
"bus": {"maintainer_seats": ["Ada"]}})
p = setup(ctx)
ctx.db.set_state(p["id"], hash_log=[["2026-01-01T00:00:00Z", p["plan_hash"]]])
core.record(ctx.db, p["id"], "divan_bus", "Ada", "approve", FUTURE, "e1", True)
core.record(ctx.db, p["id"], "github_label", "ada", "approve", FUTURE, "e2", True)
ev = core.evaluate(ctx, proposals.get(ctx.db, p["id"]))
assert ev["state"] == "awaiting" and ev["approvers"] == ["ada"]


def test_bus_envelope_without_seq_does_not_break_sync(tmp_path, home, world):
bus = FakeBus()
ctx = bus_ctx(tmp_path, world, bus)
p = setup(ctx)
m = msg(1, "saga-divan", f"ion approve {p['id']}")
m["seq"] = None
bus.inbox = [m]
r = sync_approvals(ctx)
assert status(ctx, p) == "approved" and not r["errors"]
20 changes: 18 additions & 2 deletions tests/test_collect.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,25 @@ def test_budget_stops_cleanly(tmp_path, home, world):


def test_normalise_handles_missing_optional_fields():
n = github.normalise({"name": "x", "isPrivate": True, "isFork": False, "isArchived": False, "collaborators": None,
n = github.normalise({"name": "x", "isPrivate": True, "isFork": False, "isArchived": False,
"defaultBranchRef": None})
assert n["collaborators"] is None and n["commits"] == [] and n["default_branch"] is None
assert "collaborators" not in n and n["commits"] == [] and n["default_branch"] is None


def test_collect_query_does_not_request_unused_collaborators():
assert "collaborators" not in github.QUERY


def test_fetch_security_extras_logs_api_errors(caplog):
import logging

class BoomGH:
def rest(self, *a, **k):
raise RuntimeError("403 Forbidden")

with caplog.at_level(logging.WARNING, logger="ion"):
assert github.fetch_security_extras(BoomGH(), "acme", {"name": "x"}) is None
assert "security extras for x unreadable" in caplog.text


def test_override_file_parsed(tmp_path, home, world):
Expand Down
9 changes: 9 additions & 0 deletions tests/test_execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,15 @@ def test_safe_push_refuses_default_branch(ctx, tmp_path):
ex.safe_push(["origin", "ion/x:refs/heads/main"], tmp_path, "main")


def test_safe_push_checks_every_refspec_not_just_the_last(ctx, tmp_path, monkeypatch):
ex = executor_for(ctx, "merge")
pushed = []
monkeypatch.setattr(ex, "g", lambda argv, **kw: pushed.append(argv))
with pytest.raises(SafetyError, match="non-ion branch"):
ex.safe_push(["origin", "docs:refs/heads/docs", "ion/x:refs/heads/ion/x"], tmp_path, "main")
assert pushed == []


def test_safe_write_refuses_symlinks_and_paths_outside_the_checkout(tmp_path):
root, out = tmp_path / "clone", outside(tmp_path)
root.mkdir()
Expand Down
20 changes: 20 additions & 0 deletions tests/test_fleet_planconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,26 @@ def test_readme_skeleton_finds_repos_without_readme(collected, world, tmp_path):
assert "+# mono" in d["mono"] and "TODO: describe" in d["mono"]


def test_preview_cleans_workdir_when_fixer_raises(collected):
class Boom(Autofixer):
name = "boom-test"
description = "x"
commit_message = "t"
pr_title = "t"
pr_body = "t"

def find_repos(self, ctx, repos):
return ["old-tool"]

def apply(self, workdir, repo, snap):
raise RuntimeError("boom")

root = collected.work_dir("preview") / "boom-test"
with pytest.raises(RuntimeError, match="boom"):
preview(collected, Boom())
assert not root.exists()


def test_autofix_refuses_to_write_through_a_symlinked_github_dir(ctx, world, tmp_path):
out = outside(tmp_path)
plant_symlink(world, tmp_path, "alpha-core", ".github", str(out))
Expand Down
4 changes: 4 additions & 0 deletions tests/test_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ def test_invalid_config_rejected():
load_config(None, {"org": "o", "notifiers": [{"type": "pager"}]})
with pytest.raises(ConfigError):
load_config(None, {"org": None}).org
with pytest.raises(ConfigError):
load_config(None, {"org": "o", "policy": {"branch_protection": {"required_approvals": "many"}}})
with pytest.raises(ConfigError):
load_config(None, {"org": "o", "policy": {"branch_protection": {"require_pr": "yes"}}})


def test_no_org_specific_paths_in_code():
Expand Down
Loading