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
8 changes: 5 additions & 3 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
51 changes: 51 additions & 0 deletions projects.py
Original file line number Diff line number Diff line change
@@ -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()
11 changes: 9 additions & 2 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.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
Expand All @@ -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:
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.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"

Expand Down
111 changes: 111 additions & 0 deletions tests/test_projects.py
Original file line number Diff line number Diff line change
@@ -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"
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