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 = "." }