From 3071f8310a7c206eb3b690b70c3a2a19d2e25e01 Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Sat, 25 Jul 2026 12:26:37 -0700 Subject: [PATCH] feat(projects): inherit the host's managed-projects registry (v0.4.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit protoAgent 0.115.0 added a top-level `projects:` registry (ADR 0095) — one place to declare a project, with consumers projecting from it instead of re-declaring it. This is the GitHub half: a registered project's `github` field now feeds the repo picker and /issue, so declaring a project once is enough instead of also re-typing owner/name into `github.repos`. Two properties, both deliberate: Explicit config WINS. `github.repos` set => the registry is never consulted. Same non-regression property the core work has — configuring nothing new keeps today's behavior byte for byte. The version floor stays 0.27.0. The projection is additive, and bumping to 0.115.0 would cut off older hosts that don't want it anyway. So the host read is lazy and broadly guarded: no host (this suite), a pre-0.115.0 host (no `projects` attribute), or config not yet loaded all yield [] rather than raising. Same posture as register()'s existing hasattr(registry, "live_config") fallback. effective_default_repo stays PURE — the registry arrives as its `repos` argument rather than being read inside it — so /issue, the tools and the picker keep agreeing on one answer with no test churn. 8 host-free tests cover all three degrade paths plus dedupe/order, explicit-wins, and the blank-only list (`repos: ["", " "]` is an empty list, not a configured one). Co-Authored-By: Claude Opus 5 (1M context) --- __init__.py | 8 +-- api.py | 6 ++- projects.py | 51 +++++++++++++++++++ protoagent.plugin.yaml | 11 +++- pyproject.toml | 2 +- tests/test_projects.py | 111 +++++++++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 7 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 projects.py create mode 100644 tests/test_projects.py diff --git a/__init__.py b/__init__.py index 4460609..23a9858 100644 --- a/__init__.py +++ b/__init__.py @@ -33,11 +33,13 @@ def register(registry) -> None: write_enabled = bool(cfg.get("write", False)) # The default repo the tools fall back to when their `repo` arg is omitted — the - # configured `default_repo`, else the first of `repos` (same resolution as /issue and - # the board). Tools are rebuilt on a config reload, so capturing it here is live enough. + # configured `default_repo`, else the first of `repos`, else the first repo in the + # host's ADR 0095 managed-projects registry (same resolution as /issue and the + # board). Tools are rebuilt on a config reload, so capturing it here is live enough. from .gh_issue import effective_default_repo + from .projects import effective_repos - default_repo = effective_default_repo(cfg.get("default_repo", ""), cfg.get("repos", [])) + default_repo = effective_default_repo(cfg.get("default_repo", ""), effective_repos(cfg.get("repos"))) # READ tools — always available (they return an error string if `gh`/auth is missing). n_read = 0 diff --git a/api.py b/api.py index 76e85dd..1709d8a 100644 --- a/api.py +++ b/api.py @@ -81,7 +81,11 @@ async def fetch_prs(repo: str, state: str = "open", limit: int = 30) -> dict: def _repos(cfg: dict) -> list[str]: - return [str(r).strip() for r in (cfg.get("repos") or []) if str(r).strip()] + """The picker list — explicit ``github.repos``, else the host's ADR 0095 + managed-projects registry (v0.115.0+; ``[]`` on older hosts).""" + from .projects import effective_repos + + return effective_repos(cfg.get("repos")) def build_view_router(): diff --git a/projects.py b/projects.py new file mode 100644 index 0000000..a950d3f --- /dev/null +++ b/projects.py @@ -0,0 +1,51 @@ +"""The host's managed-projects registry (ADR 0095), read defensively. + +protoAgent gained a top-level ``projects:`` registry in v0.115.0 — one place to +declare a project, with consumers projecting from it instead of re-declaring it. +This module is the GitHub half of that: a registered project's ``github`` field +feeds the repo picker and ``/issue``, so registering a project once is enough +instead of also re-typing ``owner/name`` into ``github.repos``. + +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. + +**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 +hosts that don't want it anyway. So the host import is lazy and broadly guarded: +no host (the host-free test suite), a pre-0.115.0 host (no ``projects`` +attribute), or config not yet loaded all yield ``[]`` rather than raising. Same +posture as ``__init__.py``'s ``hasattr(registry, "live_config")`` fallback. +""" + +from __future__ import annotations + + +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.""" + try: + from graph.sdk import config + + entries = getattr(config(), "projects", None) or [] + except Exception: # noqa: BLE001 — no host / older host / config unloaded; never fatal + return [] + out: list[str] = [] + seen: set[str] = set() + for entry in entries: + if not isinstance(entry, dict): + continue + repo = str(entry.get("github") or "").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() diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index 5167318..b1a1b53 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -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.3.0 +version: 0.4.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 @@ -23,7 +23,14 @@ config_section: github 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 + 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. # Settings (ADR 0019) — surfaced as editable fields in the console. settings: diff --git a/pyproject.toml b/pyproject.toml index 4134304..67f63ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "github-plugin" -version = "0.3.0" +version = "0.4.0" description = "Read/write GitHub tools for protoAgent over the gh CLI, with per-agent write gating." requires-python = ">=3.11" diff --git a/tests/test_projects.py b/tests/test_projects.py new file mode 100644 index 0000000..6c44a27 --- /dev/null +++ b/tests/test_projects.py @@ -0,0 +1,111 @@ +"""The ADR 0095 managed-projects projection — `projects:` feeds the repo picker. + +Host-free: `graph.sdk` doesn't exist in this suite, so the default path exercises +the real "no host" degrade. Where a host IS needed, a fake `graph.sdk` module is +installed in `sys.modules` (the standalone-plugin pattern) — nothing imports the +host at module scope, so this stays a pure unit test. +""" + +from __future__ import annotations + +import sys +import types + +import pytest +from ghplugin.gh_issue import effective_default_repo +from ghplugin.projects import effective_repos, registry_repos + + +@pytest.fixture +def fake_host(monkeypatch): + """Install a fake `graph.sdk` whose config() returns the given projects list.""" + + def _install(projects, *, raises: bool = False): + sdk = types.ModuleType("graph.sdk") + + def config(): + if raises: + raise RuntimeError("config not loaded") + return types.SimpleNamespace(projects=projects) + + sdk.config = config + graph = types.ModuleType("graph") + graph.sdk = sdk + monkeypatch.setitem(sys.modules, "graph", graph) + monkeypatch.setitem(sys.modules, "graph.sdk", sdk) + + return _install + + +# ── degrade paths — the plugin floor stays 0.27.0, the registry is 0.115.0+ ── + + +def test_no_host_yields_no_repos(): + """The host-free case, and equally any pre-0.115.0 host: never raises.""" + assert registry_repos() == [] + + +def test_host_without_the_registry_yields_no_repos(fake_host): + """A pre-0.115.0 host has no `projects` attribute at all.""" + sdk = types.ModuleType("graph.sdk") + sdk.config = lambda: types.SimpleNamespace() # no .projects + graph = types.ModuleType("graph") + graph.sdk = sdk + sys.modules["graph"], sys.modules["graph.sdk"] = graph, sdk + try: + assert registry_repos() == [] + finally: + del sys.modules["graph.sdk"], sys.modules["graph"] + + +def test_a_raising_host_config_yields_no_repos(fake_host): + """Config not loaded yet must not take the picker down with it.""" + fake_host([], raises=True) + assert registry_repos() == [] + + +# ── the projection itself ── + + +def test_registry_repos_are_deduped_in_config_order(fake_host): + fake_host( + [ + {"name": "a", "path": "/a", "github": "o/a"}, + {"name": "b", "path": "/b"}, # no github — contributes nothing + {"name": "c", "path": "/c", "github": "o/c"}, + {"name": "d", "path": "/d", "github": "o/a"}, # dupe + {"name": "e", "path": "/e", "github": " "}, # blank + "not-a-dict", + ] + ) + 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_registry_fills_an_empty_picker(fake_host): + fake_host([{"name": "a", "path": "/a", "github": "o/a"}]) + assert effective_repos([]) == ["o/a"] + assert effective_repos(None) == ["o/a"] + + +def test_blank_only_explicit_list_falls_through(fake_host): + """`repos: ["", " "]` is not a configured list — it's an empty one.""" + fake_host([{"name": "a", "path": "/a", "github": "o/a"}]) + assert effective_repos(["", " "]) == ["o/a"] + + +# ── composition with the default-repo resolution ── + + +def test_default_repo_falls_through_to_the_registry(fake_host): + """effective_default_repo stays PURE — the registry arrives as its `repos` arg, + so /issue, the tools and the picker keep agreeing on one answer.""" + fake_host([{"name": "a", "path": "/a", "github": "o/a"}]) + assert effective_default_repo("", effective_repos([])) == "o/a" + # explicit default still beats everything + assert effective_default_repo("o/explicit", effective_repos([])) == "o/explicit" diff --git a/uv.lock b/uv.lock index 667418e..460aaac 100644 --- a/uv.lock +++ b/uv.lock @@ -4,5 +4,5 @@ requires-python = ">=3.11" [[package]] name = "github-plugin" -version = "0.3.0" +version = "0.4.0" source = { virtual = "." }