Skip to content

Commit 2c69022

Browse files
mabry1985claude
andauthored
feat(projects): inherit the host's managed-projects registry (v0.4.0) (#25)
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) <noreply@anthropic.com>
1 parent e01d976 commit 2c69022

7 files changed

Lines changed: 183 additions & 8 deletions

File tree

‎__init__.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,13 @@ def register(registry) -> None:
3333
write_enabled = bool(cfg.get("write", False))
3434

3535
# The default repo the tools fall back to when their `repo` arg is omitted — the
36-
# configured `default_repo`, else the first of `repos` (same resolution as /issue and
37-
# the board). Tools are rebuilt on a config reload, so capturing it here is live enough.
36+
# configured `default_repo`, else the first of `repos`, else the first repo in the
37+
# host's ADR 0095 managed-projects registry (same resolution as /issue and the
38+
# board). Tools are rebuilt on a config reload, so capturing it here is live enough.
3839
from .gh_issue import effective_default_repo
40+
from .projects import effective_repos
3941

40-
default_repo = effective_default_repo(cfg.get("default_repo", ""), cfg.get("repos", []))
42+
default_repo = effective_default_repo(cfg.get("default_repo", ""), effective_repos(cfg.get("repos")))
4143

4244
# READ tools — always available (they return an error string if `gh`/auth is missing).
4345
n_read = 0

‎api.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,11 @@ async def fetch_prs(repo: str, state: str = "open", limit: int = 30) -> dict:
8181

8282

8383
def _repos(cfg: dict) -> list[str]:
84-
return [str(r).strip() for r in (cfg.get("repos") or []) if str(r).strip()]
84+
"""The picker list — explicit ``github.repos``, else the host's ADR 0095
85+
managed-projects registry (v0.115.0+; ``[]`` on older hosts)."""
86+
from .projects import effective_repos
87+
88+
return effective_repos(cfg.get("repos"))
8589

8690

8791
def build_view_router():

‎projects.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""The host's managed-projects registry (ADR 0095), read defensively.
2+
3+
protoAgent gained a top-level ``projects:`` registry in v0.115.0 — one place to
4+
declare a project, with consumers projecting from it instead of re-declaring it.
5+
This module is the GitHub half of that: a registered project's ``github`` field
6+
feeds the repo picker and ``/issue``, so registering a project once is enough
7+
instead of also re-typing ``owner/name`` into ``github.repos``.
8+
9+
Two properties are deliberate:
10+
11+
**Explicit config always wins.** ``github.repos`` set ⇒ the registry is not
12+
consulted at all. Same non-regression property the core work has: configuring
13+
nothing new keeps today's behavior byte for byte.
14+
15+
**Every read degrades to ``[]``.** The plugin's ``min_protoagent_version`` stays
16+
0.27.0 — the projection is additive, and bumping the floor would cut off older
17+
hosts that don't want it anyway. So the host import is lazy and broadly guarded:
18+
no host (the host-free test suite), a pre-0.115.0 host (no ``projects``
19+
attribute), or config not yet loaded all yield ``[]`` rather than raising. Same
20+
posture as ``__init__.py``'s ``hasattr(registry, "live_config")`` fallback.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
26+
def registry_repos() -> list[str]:
27+
"""``owner/name`` for every registered project that declares one — config
28+
order, deduped, blanks dropped. ``[]`` on any host without the registry."""
29+
try:
30+
from graph.sdk import config
31+
32+
entries = getattr(config(), "projects", None) or []
33+
except Exception: # noqa: BLE001 — no host / older host / config unloaded; never fatal
34+
return []
35+
out: list[str] = []
36+
seen: set[str] = set()
37+
for entry in entries:
38+
if not isinstance(entry, dict):
39+
continue
40+
repo = str(entry.get("github") or "").strip()
41+
if repo and repo not in seen:
42+
seen.add(repo)
43+
out.append(repo)
44+
return out
45+
46+
47+
def effective_repos(cfg_repos: list | None) -> list[str]:
48+
"""The repo picker list: explicit ``github.repos`` when set, else the host's
49+
managed-projects registry. The single place the two layers meet."""
50+
explicit = [str(r).strip() for r in (cfg_repos or []) if str(r).strip()]
51+
return explicit or registry_repos()

‎protoagent.plugin.yaml‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# Keep `version` in lockstep with pyproject.toml (tests/test_version.py asserts it).
33
id: github
44
name: GitHub (read/write tools)
5-
version: 0.3.0
5+
version: 0.4.0
66
description: >-
77
Read AND write GitHub tools over the `gh` CLI, with PER-AGENT write gating. The
88
read tools (PRs, issues, diffs, CI, repo files/contents) are always on; the write
@@ -23,7 +23,14 @@ config_section: github
2323
config:
2424
write: false # false = read tools only; true = read + write tools
2525
default_repo: "" # preselected repo for /issue (owner/name); "" = none
26-
repos: [] # repo picker list (each owner/name) for /issue + views
26+
repos: [] # repo picker list (each owner/name) for /issue + views.
27+
# LEAVE EMPTY to inherit the host's managed-projects
28+
# registry (ADR 0095, protoAgent 0.115.0+): every
29+
# `projects:` entry with a `github:` field feeds the
30+
# picker, so a project is declared once instead of here
31+
# as well. Setting this list explicitly WINS and the
32+
# registry is not consulted; on older hosts (or with no
33+
# registry configured) behavior is unchanged.
2734

2835
# Settings (ADR 0019) — surfaced as editable fields in the console.
2936
settings:

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "github-plugin"
3-
version = "0.3.0"
3+
version = "0.4.0"
44
description = "Read/write GitHub tools for protoAgent over the gh CLI, with per-agent write gating."
55
requires-python = ">=3.11"
66

‎tests/test_projects.py‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""The ADR 0095 managed-projects projection — `projects:` feeds the repo picker.
2+
3+
Host-free: `graph.sdk` doesn't exist in this suite, so the default path exercises
4+
the real "no host" degrade. Where a host IS needed, a fake `graph.sdk` module is
5+
installed in `sys.modules` (the standalone-plugin pattern) — nothing imports the
6+
host at module scope, so this stays a pure unit test.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import sys
12+
import types
13+
14+
import pytest
15+
from ghplugin.gh_issue import effective_default_repo
16+
from ghplugin.projects import effective_repos, registry_repos
17+
18+
19+
@pytest.fixture
20+
def fake_host(monkeypatch):
21+
"""Install a fake `graph.sdk` whose config() returns the given projects list."""
22+
23+
def _install(projects, *, raises: bool = False):
24+
sdk = types.ModuleType("graph.sdk")
25+
26+
def config():
27+
if raises:
28+
raise RuntimeError("config not loaded")
29+
return types.SimpleNamespace(projects=projects)
30+
31+
sdk.config = config
32+
graph = types.ModuleType("graph")
33+
graph.sdk = sdk
34+
monkeypatch.setitem(sys.modules, "graph", graph)
35+
monkeypatch.setitem(sys.modules, "graph.sdk", sdk)
36+
37+
return _install
38+
39+
40+
# ── degrade paths — the plugin floor stays 0.27.0, the registry is 0.115.0+ ──
41+
42+
43+
def test_no_host_yields_no_repos():
44+
"""The host-free case, and equally any pre-0.115.0 host: never raises."""
45+
assert registry_repos() == []
46+
47+
48+
def test_host_without_the_registry_yields_no_repos(fake_host):
49+
"""A pre-0.115.0 host has no `projects` attribute at all."""
50+
sdk = types.ModuleType("graph.sdk")
51+
sdk.config = lambda: types.SimpleNamespace() # no .projects
52+
graph = types.ModuleType("graph")
53+
graph.sdk = sdk
54+
sys.modules["graph"], sys.modules["graph.sdk"] = graph, sdk
55+
try:
56+
assert registry_repos() == []
57+
finally:
58+
del sys.modules["graph.sdk"], sys.modules["graph"]
59+
60+
61+
def test_a_raising_host_config_yields_no_repos(fake_host):
62+
"""Config not loaded yet must not take the picker down with it."""
63+
fake_host([], raises=True)
64+
assert registry_repos() == []
65+
66+
67+
# ── the projection itself ──
68+
69+
70+
def test_registry_repos_are_deduped_in_config_order(fake_host):
71+
fake_host(
72+
[
73+
{"name": "a", "path": "/a", "github": "o/a"},
74+
{"name": "b", "path": "/b"}, # no github — contributes nothing
75+
{"name": "c", "path": "/c", "github": "o/c"},
76+
{"name": "d", "path": "/d", "github": "o/a"}, # dupe
77+
{"name": "e", "path": "/e", "github": " "}, # blank
78+
"not-a-dict",
79+
]
80+
)
81+
assert registry_repos() == ["o/a", "o/c"]
82+
83+
84+
def test_explicit_repos_win_over_the_registry(fake_host):
85+
"""Non-regression: a configured picker list means the registry is never consulted."""
86+
fake_host([{"name": "a", "path": "/a", "github": "o/registry"}])
87+
assert effective_repos(["o/explicit"]) == ["o/explicit"]
88+
89+
90+
def test_registry_fills_an_empty_picker(fake_host):
91+
fake_host([{"name": "a", "path": "/a", "github": "o/a"}])
92+
assert effective_repos([]) == ["o/a"]
93+
assert effective_repos(None) == ["o/a"]
94+
95+
96+
def test_blank_only_explicit_list_falls_through(fake_host):
97+
"""`repos: ["", " "]` is not a configured list — it's an empty one."""
98+
fake_host([{"name": "a", "path": "/a", "github": "o/a"}])
99+
assert effective_repos(["", " "]) == ["o/a"]
100+
101+
102+
# ── composition with the default-repo resolution ──
103+
104+
105+
def test_default_repo_falls_through_to_the_registry(fake_host):
106+
"""effective_default_repo stays PURE — the registry arrives as its `repos` arg,
107+
so /issue, the tools and the picker keep agreeing on one answer."""
108+
fake_host([{"name": "a", "path": "/a", "github": "o/a"}])
109+
assert effective_default_repo("", effective_repos([])) == "o/a"
110+
# explicit default still beats everything
111+
assert effective_default_repo("o/explicit", effective_repos([])) == "o/explicit"

‎uv.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)