diff --git a/projects.py b/projects.py index a950d3f..9dd0d9c 100644 --- a/projects.py +++ b/projects.py @@ -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 @@ -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() diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index b1a1b53..1b2389d 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.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 @@ -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: diff --git a/pyproject.toml b/pyproject.toml index 67f63ae..137b162 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_projects.py b/tests/test_projects.py index 6c44a27..8e91adc 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -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") @@ -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): diff --git a/uv.lock b/uv.lock index 460aaac..49d2ced 100644 --- a/uv.lock +++ b/uv.lock @@ -4,5 +4,5 @@ requires-python = ">=3.11" [[package]] name = "github-plugin" -version = "0.4.0" +version = "0.5.0" source = { virtual = "." }