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
48 changes: 32 additions & 16 deletions projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,16 @@

Two properties are deliberate:

**Explicit config always wins.** ``github.repos`` set ⇒ the registry is not
consulted at all. Same non-regression property the core work has: configuring
nothing new keeps today's behavior byte for byte.
**Explicit config is ADDED to, never replaced — and never hides the registry.**
``github.repos`` set ⇒ those repos come FIRST, in the operator's order, and the
registry's repos follow (deduped). v0.4.0 made an explicit list WIN outright, on the
non-regression argument — but that turned "I typed a repos list once" into "the
registry is dead for me forever": every project the agent onboarded afterwards
(`onboard_project` registers into ``projects:``) stayed invisible to ``/issue`` and
the board, silently, with no config knob to opt back in (2026-08-20, protoEngineer:
three self-onboarded repos never reached the picker). A union is still
non-regressing for everything the explicit list names (same entries, same order,
same default) — it only adds the registry's entries after them.

**Every read degrades to ``[]``.** The plugin's ``min_protoagent_version`` stays
0.27.0 — the projection is additive, and bumping the floor would cut off older
Expand All @@ -25,27 +32,36 @@

def registry_repos() -> list[str]:
"""``owner/name`` for every registered project that declares one — config
order, deduped, blanks dropped. ``[]`` on any host without the registry."""
order, deduped, blanks dropped. ``[]`` on any host without the registry.

Reads the ADR 0095 ``projects:`` registry first, then the legacy explicit
``filesystem.projects`` override: an instance that predates the registry (or
one written by the pre-#2925 ``onboard_project``) carries its ``github``
bindings THERE, and those repos are just as real. Registry entries lead."""
try:
from graph.sdk import config

entries = getattr(config(), "projects", None) or []
cfg = config()
entries = list(getattr(cfg, "projects", None) or []) + list(getattr(cfg, "filesystem_projects", None) or [])
except Exception: # noqa: BLE001 — no host / older host / config unloaded; never fatal
return []
return _dedupe(str(e.get("github") or "") for e in entries if isinstance(e, dict))


def effective_repos(cfg_repos: list | None) -> list[str]:
"""The repo picker list: the explicit ``github.repos`` entries (operator order,
first) UNION the host's managed-projects registry. The single place the two
layers meet — and the reason an onboarded project needs no second declaration."""
explicit = [str(r).strip() for r in (cfg_repos or []) if str(r).strip()]
return _dedupe([*explicit, *registry_repos()])


def _dedupe(repos) -> list[str]:
out: list[str] = []
seen: set[str] = set()
for entry in entries:
if not isinstance(entry, dict):
continue
repo = str(entry.get("github") or "").strip()
for repo in repos:
repo = repo.strip()
if repo and repo not in seen:
seen.add(repo)
out.append(repo)
return out


def effective_repos(cfg_repos: list | None) -> list[str]:
"""The repo picker list: explicit ``github.repos`` when set, else the host's
managed-projects registry. The single place the two layers meet."""
explicit = [str(r).strip() for r in (cfg_repos or []) if str(r).strip()]
return explicit or registry_repos()
18 changes: 10 additions & 8 deletions protoagent.plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Keep `version` in lockstep with pyproject.toml (tests/test_version.py asserts it).
id: github
name: GitHub (read/write tools)
version: 0.4.0
version: 0.5.0
description: >-
Read AND write GitHub tools over the `gh` CLI, with PER-AGENT write gating. The
read tools (PRs, issues, diffs, CI, repo files/contents) are always on; the write
Expand All @@ -24,13 +24,15 @@ config:
write: false # false = read tools only; true = read + write tools
default_repo: "" # preselected repo for /issue (owner/name); "" = none
repos: [] # repo picker list (each owner/name) for /issue + views.
# LEAVE EMPTY to inherit the host's managed-projects
# registry (ADR 0095, protoAgent 0.115.0+): every
# `projects:` entry with a `github:` field feeds the
# picker, so a project is declared once instead of here
# as well. Setting this list explicitly WINS and the
# registry is not consulted; on older hosts (or with no
# registry configured) behavior is unchanged.
# The host's managed-projects registry (ADR 0095,
# protoAgent 0.115.0+) is ALWAYS added: every `projects:`
# entry with a `github:` field (and any legacy
# filesystem.projects entry carrying one) feeds the
# picker, so a project — including one the agent
# onboards itself — is declared once. Entries listed
# here come first, in this order, so the default-repo
# resolution is unchanged; the registry's follow.
# Older hosts with no registry: behavior is unchanged.

# Settings (ADR 0019) — surfaced as editable fields in the console.
settings:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "github-plugin"
version = "0.4.0"
version = "0.5.0"
description = "Read/write GitHub tools for protoAgent over the gh CLI, with per-agent write gating."
requires-python = ">=3.11"

Expand Down
31 changes: 25 additions & 6 deletions tests/test_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@
def fake_host(monkeypatch):
"""Install a fake `graph.sdk` whose config() returns the given projects list."""

def _install(projects, *, raises: bool = False):
def _install(projects, *, raises: bool = False, fence=None):
sdk = types.ModuleType("graph.sdk")

def config():
if raises:
raise RuntimeError("config not loaded")
return types.SimpleNamespace(projects=projects)
return types.SimpleNamespace(projects=projects, filesystem_projects=fence)

sdk.config = config
graph = types.ModuleType("graph")
Expand Down Expand Up @@ -81,10 +81,29 @@ def test_registry_repos_are_deduped_in_config_order(fake_host):
assert registry_repos() == ["o/a", "o/c"]


def test_explicit_repos_win_over_the_registry(fake_host):
"""Non-regression: a configured picker list means the registry is never consulted."""
fake_host([{"name": "a", "path": "/a", "github": "o/registry"}])
assert effective_repos(["o/explicit"]) == ["o/explicit"]
def test_explicit_repos_lead_and_the_registry_follows(fake_host):
"""An explicit picker list is ADDED TO by the registry, never replaced by it and
never allowed to hide it: the operator's entries keep their order (and so the
default-repo resolution), the registry's come after, deduped. v0.4.0's
explicit-wins made one typed list bury every later onboard_project forever."""
fake_host(
[{"name": "a", "path": "/a", "github": "o/registry"}, {"name": "b", "path": "/b", "github": "o/explicit"}]
)
assert effective_repos(["o/explicit", "o/other"]) == ["o/explicit", "o/other", "o/registry"]


def test_registry_also_reads_github_bindings_on_the_legacy_fence_override(fake_host):
"""A pre-registry instance (or one written by the pre-#2925 onboard tool) carries
`github:` on filesystem.projects entries — those repos count, after the registry's."""
fake_host(
[{"name": "a", "path": "/a", "github": "o/reg"}],
fence=[
{"name": "x", "path": "/x", "github": "o/fence"},
{"name": "y", "path": "/y"},
{"name": "z", "path": "/z", "github": "o/reg"},
],
)
assert registry_repos() == ["o/reg", "o/fence"]


def test_registry_fills_an_empty_picker(fake_host):
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading