From ad3f8854c610b28f404bdc3e99648ea0a15b6ec9 Mon Sep 17 00:00:00 2001 From: Arnav Date: Wed, 1 Jul 2026 20:28:37 +0530 Subject: [PATCH 01/14] build: add sourcehut tool and workspace registration Generated-by: Antigravity --- pyproject.toml | 1 + tools/sourcehut/pyproject.toml | 80 ++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 tools/sourcehut/pyproject.toml diff --git a/pyproject.toml b/pyproject.toml index 1eb11333..0e75faa0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -115,4 +115,5 @@ members = [ "tools/symlink-lint", "tools/vcs", "tools/vendor-neutrality-score", + "tools/sourcehut", ] diff --git a/tools/sourcehut/pyproject.toml b/tools/sourcehut/pyproject.toml new file mode 100644 index 00000000..97c186bf --- /dev/null +++ b/tools/sourcehut/pyproject.toml @@ -0,0 +1,80 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "magpie-sourcehut" +version = "0.1.0" +description = "SourceHut (sr.ht) forge bridge for Apache Magpie — provides tracker, patch review, VCS and CI status reads via GraphQL APIs." +readme = "README.md" +requires-python = ">=3.11" +license = { text = "Apache-2.0" } +dependencies = [] + +[project.scripts] +magpie-sourcehut = "magpie_sourcehut:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/magpie_sourcehut"] + +[tool.ruff] +line-length = 110 +target-version = "py311" +src = ["src", "tests"] + +[tool.ruff.lint] +select = [ + "E", + "W", + "F", + "I", + "B", + "UP", + "SIM", + "C4", + "RUF", +] +ignore = [ + "E501", +] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["B", "SIM"] + +[tool.mypy] +python_version = "3.11" +files = ["src", "tests"] +warn_unused_ignores = true +warn_redundant_casts = true +warn_unreachable = true +check_untyped_defs = true +no_implicit_optional = true +disallow_untyped_defs = true +disallow_incomplete_defs = true + +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_defs = false +disallow_incomplete_defs = false + +[tool.pytest.ini_options] +minversion = "8.0" +addopts = "-ra -q" +testpaths = ["tests"] From 33ea6d0318c099a00cd818fc33e8d9427a1bda78 Mon Sep 17 00:00:00 2001 From: Arnav Date: Wed, 1 Jul 2026 20:28:42 +0530 Subject: [PATCH 02/14] docs: add README for sourcehut forge bridge Generated-by: Antigravity --- tools/sourcehut/README.md | 41 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 tools/sourcehut/README.md diff --git a/tools/sourcehut/README.md b/tools/sourcehut/README.md new file mode 100644 index 00000000..df147330 --- /dev/null +++ b/tools/sourcehut/README.md @@ -0,0 +1,41 @@ +# SourceHut (sr.ht) Forge Bridge + +**Capability:** contract:tracker + contract:source-control + contract:mail-archive + +SourceHut (sr.ht) forge bridge implementation for the Apache Magpie framework. It integrates ticket tracking (`todo.sr.ht`), mailing list patchset review (`lists.sr.ht`), CI builds (`builds.sr.ht`), and repository reads (`git.sr.ht` & `hg.sr.ht`) using SourceHut's GraphQL APIs. + +## Prerequisites + +- **Runtime:** Python 3.11+ run via `uv` (stdlib-only, no third-party package dependencies at runtime). +- **CLIs:** `git` (for Git repo interactions) and `hg` (for Mercurial repo interactions). +- **Credentials / auth:** `SRHT_TOKEN` environment variable containing a SourceHut Personal Access Token (OAuth2 bearer token) with appropriate scopes (e.g. `TICKETS:RW`, `LISTS:R`, `BUILDS:R`, `REPOS:R`). +- **Network:** Reaches `todo.sr.ht`, `lists.sr.ht`, `builds.sr.ht`, `git.sr.ht`, and `hg.sr.ht` endpoints over HTTPS (`/query`). + +## Features + +1. **VCS Repositories:** Reads repo metadata and refs across `git.sr.ht` and `hg.sr.ht`. +2. **Issue Tracker:** Read/write operations (create ticket, comment, resolve status, update labels) on `todo.sr.ht` trackers. +3. **Mailing Lists:** Reads patchsets and threads from `lists.sr.ht`, mapping them to the uniform PR/MR review abstraction. +4. **CI Builds:** Reads job statuses from `builds.sr.ht`. +5. **GraphQL client:** Unified command line tool to execute arbitrary queries/mutations across sr.ht subdomains. + +## Invocation + +```bash +# Get ticket details +uv run --project tools/sourcehut magpie-sourcehut ticket get ~user/tracker-name 123 + +# Create comment on a ticket +uv run --project tools/sourcehut magpie-sourcehut ticket comment ~user/tracker-name 123 --body "Nice fix!" + +# Check build status +uv run --project tools/sourcehut magpie-sourcehut build get 123456 +``` + +## Configuration + +The bridge is configured via environment variables: + +| Variable | Description | +|---|---| +| `SRHT_TOKEN` | Required. SourceHut personal OAuth2 token with access to target repositories, trackers, and mailing lists. | From e4e75e0a8b91cb6bcad0673ec26b0451950bd087 Mon Sep 17 00:00:00 2001 From: Arnav Date: Wed, 1 Jul 2026 20:28:47 +0530 Subject: [PATCH 03/14] feat: implement Mercurial backend and SourceHut forge bridge source code Generated-by: Antigravity --- .../src/magpie_sourcehut/__init__.py | 35 ++++ .../sourcehut/src/magpie_sourcehut/builds.py | 49 +++++ tools/sourcehut/src/magpie_sourcehut/cli.py | 186 ++++++++++++++++++ .../sourcehut/src/magpie_sourcehut/client.py | 85 ++++++++ tools/sourcehut/src/magpie_sourcehut/lists.py | 176 +++++++++++++++++ tools/sourcehut/src/magpie_sourcehut/repo.py | 58 ++++++ tools/sourcehut/src/magpie_sourcehut/todo.py | 175 ++++++++++++++++ tools/vcs/src/magpie_vcs/__init__.py | 86 +++++++- 8 files changed, 846 insertions(+), 4 deletions(-) create mode 100644 tools/sourcehut/src/magpie_sourcehut/__init__.py create mode 100644 tools/sourcehut/src/magpie_sourcehut/builds.py create mode 100644 tools/sourcehut/src/magpie_sourcehut/cli.py create mode 100644 tools/sourcehut/src/magpie_sourcehut/client.py create mode 100644 tools/sourcehut/src/magpie_sourcehut/lists.py create mode 100644 tools/sourcehut/src/magpie_sourcehut/repo.py create mode 100644 tools/sourcehut/src/magpie_sourcehut/todo.py diff --git a/tools/sourcehut/src/magpie_sourcehut/__init__.py b/tools/sourcehut/src/magpie_sourcehut/__init__.py new file mode 100644 index 00000000..a7c19189 --- /dev/null +++ b/tools/sourcehut/src/magpie_sourcehut/__init__.py @@ -0,0 +1,35 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""SourceHut forge bridge implementation for Apache Magpie.""" + +from __future__ import annotations + +import sys +from collections.abc import Sequence + +__all__ = ["main"] + + +def main(argv: Sequence[str] | None = None) -> int: + """CLI entry point.""" + from magpie_sourcehut.cli import main as cli_main + try: + return cli_main(argv) + except Exception as exc: + print(f"magpie-sourcehut error: {exc}", file=sys.stderr) + return 1 diff --git a/tools/sourcehut/src/magpie_sourcehut/builds.py b/tools/sourcehut/src/magpie_sourcehut/builds.py new file mode 100644 index 00000000..b7fcd0d7 --- /dev/null +++ b/tools/sourcehut/src/magpie_sourcehut/builds.py @@ -0,0 +1,49 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""builds.sr.ht build integration.""" + +from __future__ import annotations + +from typing import Any + +from magpie_sourcehut.client import query_graphql + + +def get_job(job_id: int) -> dict[str, Any]: + """Retrieve details of a specific job on builds.sr.ht.""" + q = """ + query GetJob($id: Int!) { + job(id: $id) { + id + status + created + updated + note + tags + visibility + image + runner + tasks { + name + status + } + } + } + """ + res = query_graphql("builds", q, {"id": job_id}) + return res.get("job") or {} diff --git a/tools/sourcehut/src/magpie_sourcehut/cli.py b/tools/sourcehut/src/magpie_sourcehut/cli.py new file mode 100644 index 00000000..82db00c1 --- /dev/null +++ b/tools/sourcehut/src/magpie_sourcehut/cli.py @@ -0,0 +1,186 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Command line interface for magpie-sourcehut.""" + +from __future__ import annotations + +import argparse +import json +import sys +from collections.abc import Sequence + +from magpie_sourcehut.builds import get_job +from magpie_sourcehut.client import query_graphql +from magpie_sourcehut.lists import get_patchset, list_patchsets, map_patchset_to_pr +from magpie_sourcehut.repo import get_repo +from magpie_sourcehut.todo import ( + get_ticket, + label_ticket, + submit_comment, + submit_ticket, + unlabel_ticket, + update_ticket_status, +) + + +def _print_json(data: dict | list) -> None: + """Helper to pretty print JSON data to stdout.""" + print(json.dumps(data, indent=2)) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="magpie-sourcehut", + description="SourceHut forge bridge capability wrapper.", + ) + subparsers = parser.add_subparsers(dest="subcommand", required=True) + + # raw graphql query + p_graphql = subparsers.add_parser("graphql", help="Run a raw GraphQL query.") + p_graphql.add_argument("service", help="sr.ht service subdomain (e.g. todo, lists, builds).") + p_graphql.add_argument("query", help="GraphQL query/mutation string.") + p_graphql.add_argument("--variables", help="Optional query variables as a JSON string.") + + # tickets + p_ticket = subparsers.add_parser("ticket", help="Interact with todo.sr.ht tickets.") + t_sub = p_ticket.add_subparsers(dest="action", required=True) + + p_t_get = t_sub.add_parser("get", help="Get details of a ticket.") + p_t_get.add_argument("owner", help="Owner of the tracker.") + p_t_get.add_argument("name", help="Name of the tracker.") + p_t_get.add_argument("id", type=int, help="Ticket ID.") + + p_t_create = t_sub.add_parser("create", help="Create a ticket.") + p_t_create.add_argument("owner", help="Owner of the tracker.") + p_t_create.add_argument("name", help="Name of the tracker.") + p_t_create.add_argument("--title", required=True, help="Ticket title.") + p_t_create.add_argument("--body", required=True, help="Ticket description/body.") + + p_t_comment = t_sub.add_parser("comment", help="Comment on a ticket.") + p_t_comment.add_argument("owner", help="Owner of the tracker.") + p_t_comment.add_argument("name", help="Name of the tracker.") + p_t_comment.add_argument("id", type=int, help="Ticket ID.") + p_t_comment.add_argument("--body", required=True, help="Comment body.") + + p_t_label = t_sub.add_parser("label", help="Manage ticket labels.") + p_t_label.add_argument("owner", help="Owner of the tracker.") + p_t_label.add_argument("name", help="Name of the tracker.") + p_t_label.add_argument("id", type=int, help="Ticket ID.") + p_t_label.add_argument("--add", type=int, help="Label ID to add.") + p_t_label.add_argument("--remove", type=int, help="Label ID to remove.") + + p_t_close = t_sub.add_parser("close", help="Close/resolve a ticket.") + p_t_close.add_argument("owner", help="Owner of the tracker.") + p_t_close.add_argument("name", help="Name of the tracker.") + p_t_close.add_argument("id", type=int, help="Ticket ID.") + p_t_close.add_argument("--status", default="RESOLVED", help="Status (e.g. RESOLVED, WONTFIX).") + p_t_close.add_argument("--resolution", help="Optional ticket resolution.") + + # patchsets + p_patch = subparsers.add_parser("patchset", help="Interact with lists.sr.ht patchsets.") + pa_sub = p_patch.add_subparsers(dest="action", required=True) + + p_p_get = pa_sub.add_parser("get", help="Get details of a patchset.") + p_p_get.add_argument("owner", help="Owner of the mailing list.") + p_p_get.add_argument("list_name", help="Name of the mailing list.") + p_p_get.add_argument("id", type=int, help="Patchset ID.") + + p_p_list = pa_sub.add_parser("list", help="List patchsets on a mailing list.") + p_p_list.add_argument("owner", help="Owner of the mailing list.") + p_p_list.add_argument("list_name", help="Name of the mailing list.") + + p_p_map = pa_sub.add_parser("pr-map", help="Map a patchset to a uniform PR abstraction.") + p_p_map.add_argument("owner", help="Owner of the mailing list.") + p_p_map.add_argument("list_name", help="Name of the mailing list.") + p_p_map.add_argument("id", type=int, help="Patchset ID.") + + # builds + p_build = subparsers.add_parser("build", help="Interact with builds.sr.ht.") + b_sub = p_build.add_subparsers(dest="action", required=True) + + p_b_get = b_sub.add_parser("get", help="Get build status.") + p_b_get.add_argument("id", type=int, help="Build job ID.") + + # repo + p_repo = subparsers.add_parser("repo", help="Interact with git/hg repositories.") + r_sub = p_repo.add_subparsers(dest="action", required=True) + + p_r_get = r_sub.add_parser("get", help="Get repository details.") + p_r_get.add_argument("service", choices=["git", "hg"], help="VCS type ('git' or 'hg').") + p_r_get.add_argument("owner", help="Owner of the repository.") + p_r_get.add_argument("name", help="Name of the repository.") + + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = _build_parser() + ns = parser.parse_args(argv) + + if ns.subcommand == "graphql": + vars_dict = None + if ns.variables: + vars_dict = json.loads(ns.variables) + res = query_graphql(ns.service, ns.query, vars_dict) + _print_json(res) + + elif ns.subcommand == "ticket": + if ns.action == "get": + res = get_ticket(ns.owner, ns.name, ns.id) + _print_json(res) + elif ns.action == "create": + res = submit_ticket(ns.owner, ns.name, ns.title, ns.body) + _print_json(res) + elif ns.action == "comment": + res = submit_comment(ns.owner, ns.name, ns.id, ns.body) + _print_json(res) + elif ns.action == "label": + if ns.add: + label_ticket(ns.owner, ns.name, ns.id, ns.add) + if ns.remove: + unlabel_ticket(ns.owner, ns.name, ns.id, ns.remove) + # Fetch and print updated ticket details + res = get_ticket(ns.owner, ns.name, ns.id) + _print_json(res) + elif ns.action == "close": + res = update_ticket_status(ns.owner, ns.name, ns.id, ns.status, ns.resolution) + _print_json(res) + + elif ns.subcommand == "patchset": + if ns.action == "get": + res = get_patchset(ns.owner, ns.list_name, ns.id) + _print_json(res) + elif ns.action == "list": + res = list_patchsets(ns.owner, ns.list_name) + _print_json(res) + elif ns.action == "pr-map": + raw = get_patchset(ns.owner, ns.list_name, ns.id) + res = map_patchset_to_pr(raw) + _print_json(res) + + elif ns.subcommand == "build": + if ns.action == "get": + res = get_job(ns.id) + _print_json(res) + + elif ns.subcommand == "repo": + if ns.action == "get": + res = get_repo(ns.service, ns.owner, ns.name) + _print_json(res) + + return 0 diff --git a/tools/sourcehut/src/magpie_sourcehut/client.py b/tools/sourcehut/src/magpie_sourcehut/client.py new file mode 100644 index 00000000..a0f4ef39 --- /dev/null +++ b/tools/sourcehut/src/magpie_sourcehut/client.py @@ -0,0 +1,85 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""SourceHut GraphQL API client.""" + +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.request +from typing import Any + + +class SourceHutError(Exception): + """General exception for SourceHut client errors.""" + + +def query_graphql(service: str, query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]: + """Execute a GraphQL query/mutation against a specific SourceHut service. + + Args: + service: Subdomain of sr.ht (e.g., 'todo', 'lists', 'builds', 'git', 'hg'). + query: The GraphQL query or mutation string. + variables: Optional variables for the query. + + Returns: + The 'data' object from the GraphQL response. + """ + token = os.environ.get("SRHT_TOKEN") + if not token: + raise SourceHutError("SRHT_TOKEN environment variable is not set") + + url = f"https://{service}.sr.ht/query" + payload = {"query": query} + if variables: + payload["variables"] = variables + + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + url, + data=data, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {token}", + }, + method="POST", + ) + + try: + with urllib.request.urlopen(req) as resp: + body = resp.read().decode("utf-8") + res_json = json.loads(body) + if "errors" in res_json and res_json["errors"]: + err_msgs = [e.get("message", "Unknown error") for e in res_json["errors"]] + raise SourceHutError(f"GraphQL error from {service}.sr.ht: {'; '.join(err_msgs)}") + return res_json.get("data", {}) + except urllib.error.HTTPError as exc: + try: + err_body = exc.read().decode("utf-8") + err_json = json.loads(err_body) + if "errors" in err_json and err_json["errors"]: + err_msgs = [e.get("message", "Unknown error") for e in err_json["errors"]] + raise SourceHutError(f"HTTP {exc.code}: {'; '.join(err_msgs)}") + except Exception: + pass + raise SourceHutError(f"HTTP request to {url} failed with status {exc.code}") from exc + except urllib.error.URLError as exc: + raise SourceHutError(f"Failed to connect to {url}: {exc.reason}") from exc + except json.JSONDecodeError as exc: + raise SourceHutError(f"Failed to parse JSON response from {url}") from exc diff --git a/tools/sourcehut/src/magpie_sourcehut/lists.py b/tools/sourcehut/src/magpie_sourcehut/lists.py new file mode 100644 index 00000000..17efca2b --- /dev/null +++ b/tools/sourcehut/src/magpie_sourcehut/lists.py @@ -0,0 +1,176 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""lists.sr.ht patchsets integration and mapping.""" + +from __future__ import annotations + +from typing import Any + +from magpie_sourcehut.client import query_graphql + + +def _normalize_owner(owner: str) -> str: + """Ensure the owner username starts with '~'.""" + if not owner.startswith("~"): + return f"~{owner}" + return owner + + +def get_patchset(owner: str, list_name: str, patchset_id: int) -> dict[str, Any]: + """Retrieve patchset details from lists.sr.ht.""" + owner = _normalize_owner(owner) + q = """ + query GetPatchset($owner: String!, $name: String!, $id: Int!) { + list(owner: $owner, name: $name) { + id + name + patchset(id: $id) { + id + subject + version + status + patches { + id + subject + diff + } + thread { + id + emails { + edges { + node { + id + subject + body + sender { + canonicalName + } + date + } + } + } + } + } + } + } + """ + res = query_graphql("lists", q, {"owner": owner, "name": list_name, "id": patchset_id}) + mlist = res.get("list") or {} + return mlist.get("patchset") or {} + + +def list_patchsets(owner: str, list_name: str) -> list[dict[str, Any]]: + """List patchsets on a specific mailing list on lists.sr.ht.""" + owner = _normalize_owner(owner) + q = """ + query ListPatchsets($owner: String!, $name: String!) { + list(owner: $owner, name: $name) { + id + name + patches { + edges { + node { + id + subject + version + status + } + } + } + } + } + """ + res = query_graphql("lists", q, {"owner": owner, "name": list_name}) + mlist = res.get("list") or {} + patches_conn = mlist.get("patches") or {} + edges = patches_conn.get("edges") or [] + return [edge.get("node") for edge in edges if edge.get("node")] + + +def map_patchset_to_pr(patchset: dict[str, Any]) -> dict[str, Any]: + """Map a lists.sr.ht patchset to a uniform PR/MR review abstraction structure. + + Args: + patchset: A dictionary representing the patchset retrieved via GraphQL. + + Returns: + A dictionary containing the uniform PR/MR fields. + """ + if not patchset: + return {} + + # Extract thread emails for description & comments + thread = patchset.get("thread") or {} + emails_conn = thread.get("emails") or {} + edges = emails_conn.get("edges") or [] + emails = [edge.get("node") for edge in edges if edge.get("node")] + + # Sort emails by date if possible + try: + emails.sort(key=lambda x: x.get("date", "")) + except Exception: + pass + + # The cover letter / description is the first email (or patchset subject) + description = "" + author = "Unknown" + if emails: + first_email = emails[0] + description = first_email.get("body", "") + sender = first_email.get("sender") or {} + author = sender.get("canonicalName", "Unknown") + + # Map patchset status to a standardized PR state (OPEN, MERGED, CLOSED) + status = patchset.get("status", "PROPOSED") + state = "OPEN" + if status == "ACCEPTED": + state = "MERGED" + elif status in ("REJECTED", "SUPERSEDED"): + state = "CLOSED" + + # Map patches inside the patchset to commits + commits = [] + for patch in patchset.get("patches") or []: + commits.append({ + "id": str(patch.get("id")), + "subject": patch.get("subject", ""), + "diff": patch.get("diff", ""), + }) + + # Map replies (all emails except the first/cover letter) to review comments + comments = [] + for email in emails[1:]: + sender = email.get("sender") or {} + comments.append({ + "id": str(email.get("id")), + "author": sender.get("canonicalName", "Unknown"), + "body": email.get("body", ""), + "date": email.get("date", ""), + }) + + return { + "id": str(patchset.get("id")), + "title": patchset.get("subject", ""), + "description": description, + "author": author, + "state": state, + "commits": commits, + "comments": comments, + "raw_status": status, + "version": patchset.get("version"), + } diff --git a/tools/sourcehut/src/magpie_sourcehut/repo.py b/tools/sourcehut/src/magpie_sourcehut/repo.py new file mode 100644 index 00000000..6c2b0f88 --- /dev/null +++ b/tools/sourcehut/src/magpie_sourcehut/repo.py @@ -0,0 +1,58 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""git.sr.ht and hg.sr.ht repository integration.""" + +from __future__ import annotations + +from typing import Any + +from magpie_sourcehut.client import query_graphql + + +def _normalize_owner(owner: str) -> str: + """Ensure the owner username starts with '~'.""" + if not owner.startswith("~"): + return f"~{owner}" + return owner + + +def get_repo(service: str, owner: str, name: str) -> dict[str, Any]: + """Retrieve repository details from git.sr.ht or hg.sr.ht. + + Args: + service: 'git' or 'hg'. + owner: Repository owner. + name: Repository name. + """ + if service not in ("git", "hg"): + raise ValueError("Service must be 'git' or 'hg'") + + owner = _normalize_owner(owner) + q = """ + query GetRepository($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + id + name + description + visibility + updated + } + } + """ + res = query_graphql(service, q, {"owner": owner, "name": name}) + return res.get("repository") or {} diff --git a/tools/sourcehut/src/magpie_sourcehut/todo.py b/tools/sourcehut/src/magpie_sourcehut/todo.py new file mode 100644 index 00000000..82027b8c --- /dev/null +++ b/tools/sourcehut/src/magpie_sourcehut/todo.py @@ -0,0 +1,175 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""todo.sr.ht ticket integration.""" + +from __future__ import annotations + +from typing import Any + +from magpie_sourcehut.client import query_graphql + + +def _normalize_owner(owner: str) -> str: + """Ensure the owner username starts with '~'.""" + if not owner.startswith("~"): + return f"~{owner}" + return owner + + +def get_ticket(owner: str, name: str, ticket_id: int) -> dict[str, Any]: + """Retrieve ticket details from todo.sr.ht.""" + owner = _normalize_owner(owner) + q = """ + query GetTicket($owner: String!, $name: String!, $id: Int!) { + tracker(owner: $owner, name: $name) { + id + name + ticket(id: $id) { + id + title + description + status + resolution + labels { + id + name + } + comments { + id + body + author { + username + } + } + } + } + } + """ + res = query_graphql("todo", q, {"owner": owner, "name": name, "id": ticket_id}) + tracker = res.get("tracker") or {} + return tracker.get("ticket") or {} + + +def submit_ticket(owner: str, name: str, title: str, description: str) -> dict[str, Any]: + """Submit a new ticket to todo.sr.ht.""" + owner = _normalize_owner(owner) + q = """ + mutation CreateTicket($owner: String!, $name: String!, $input: SubmitTicketInput!) { + submitTicket(trackerOwner: $owner, trackerName: $name, input: $input) { + id + title + status + } + } + """ + variables = { + "owner": owner, + "name": name, + "input": { + "title": title, + "description": description, + }, + } + res = query_graphql("todo", q, variables) + return res.get("submitTicket") or {} + + +def submit_comment(owner: str, name: str, ticket_id: int, body: str) -> dict[str, Any]: + """Add a comment to an existing ticket on todo.sr.ht.""" + owner = _normalize_owner(owner) + q = """ + mutation CreateComment($owner: String!, $name: String!, $ticketId: Int!, $input: SubmitCommentInput!) { + submitComment(trackerOwner: $owner, trackerName: $name, ticketId: $ticketId, input: $input) { + id + body + } + } + """ + variables = { + "owner": owner, + "name": name, + "ticketId": ticket_id, + "input": { + "body": body, + }, + } + res = query_graphql("todo", q, variables) + return res.get("submitComment") or {} + + +def label_ticket(owner: str, name: str, ticket_id: int, label_id: int) -> dict[str, Any]: + """Add a label to a ticket on todo.sr.ht.""" + owner = _normalize_owner(owner) + q = """ + mutation LabelTicket($owner: String!, $name: String!, $ticketId: Int!, $labelId: Int!) { + labelTicket(trackerOwner: $owner, trackerName: $name, ticketId: $ticketId, labelId: $labelId) { + id + } + } + """ + variables = { + "owner": owner, + "name": name, + "ticketId": ticket_id, + "labelId": label_id, + } + res = query_graphql("todo", q, variables) + return res.get("labelTicket") or {} + + +def unlabel_ticket(owner: str, name: str, ticket_id: int, label_id: int) -> dict[str, Any]: + """Remove a label from a ticket on todo.sr.ht.""" + owner = _normalize_owner(owner) + q = """ + mutation UnlabelTicket($owner: String!, $name: String!, $ticketId: Int!, $labelId: Int!) { + unlabelTicket(trackerOwner: $owner, trackerName: $name, ticketId: $ticketId, labelId: $labelId) { + id + } + } + """ + variables = { + "owner": owner, + "name": name, + "ticketId": ticket_id, + "labelId": label_id, + } + res = query_graphql("todo", q, variables) + return res.get("unlabelTicket") or {} + + +def update_ticket_status(owner: str, name: str, ticket_id: int, status: str, resolution: str | None = None) -> dict[str, Any]: + """Update ticket status (resolve / close) on todo.sr.ht.""" + owner = _normalize_owner(owner) + q = """ + mutation UpdateStatus($owner: String!, $name: String!, $ticketId: Int!, $status: TicketStatus!, $resolution: TicketResolution) { + updateTicketStatus(trackerOwner: $owner, trackerName: $name, id: $ticketId, status: $status, resolution: $resolution) { + id + status + resolution + } + } + """ + variables = { + "owner": owner, + "name": name, + "ticketId": ticket_id, + "status": status, + "resolution": resolution, + } + res = query_graphql("todo", q, variables) + return res.get("updateTicketStatus") or {} diff --git a/tools/vcs/src/magpie_vcs/__init__.py b/tools/vcs/src/magpie_vcs/__init__.py index e797fae0..5595eca1 100644 --- a/tools/vcs/src/magpie_vcs/__init__.py +++ b/tools/vcs/src/magpie_vcs/__init__.py @@ -356,13 +356,91 @@ def reset_worktree(self) -> None: raise self._unsupported("reset_worktree") -class MercurialBackend(_UnimplementedBackend): - """Mercurial (Hg) extension point — see apache/magpie#601.""" +class MercurialBackend(VCSBackend): + """Mercurial (Hg) backend implementation.""" name = "hg" distributed = True - marker = ".hg" - issue = "apache/magpie#601" + + @classmethod + def detect(cls, start: Path) -> Path | None: + for d in (start, *start.parents): + if (d / ".hg").exists(): + return d + return None + + @classmethod + def is_available(cls) -> bool: + try: + subprocess.run(["hg", "--version"], capture_output=True, check=True) + except (OSError, subprocess.CalledProcessError): + return False + return True + + def status(self) -> str: + return _run(["hg", "status"], self.root) + + def current_branch(self) -> str: + return _run(["hg", "branch"], self.root).strip() + + def diff(self, base: str | None = None, cached: bool = False, paths: Sequence[str] = ()) -> str: + if cached: + raise VCSError("hg does not support staging area/cached diff") + args = ["hg", "diff"] + if base: + args.extend(["-r", base]) + if paths: + args.extend(paths) + return _run(args, self.root) + + def log( + self, + max_count: int | None = None, + grep: str | None = None, + author: str | None = None, + since: str | None = None, + paths: Sequence[str] = (), + ) -> str: + args = ["hg", "log", "--template", "{node|short} {desc|firstline}\n"] + if max_count is not None: + args.extend(["-l", str(max_count)]) + if grep: + args.extend(["-k", grep]) + if author: + args.extend(["-u", author]) + if since: + args.extend(["-d", f">= {since}"]) + if paths: + args.extend(paths) + return _run(args, self.root) + + def create_branch(self, name: str) -> None: + _run(["hg", "bookmark", name], self.root, capture=False) + + def switch(self, ref: str) -> None: + _run(["hg", "update", ref], self.root, capture=False) + + def stage(self, paths: Sequence[str]) -> None: + if not paths: + raise VCSError("stage: refusing to stage nothing") + _run(["hg", "add", "--", *paths], self.root, capture=False) + + def commit(self, message: str) -> None: + _run(["hg", "commit", "-m", message], self.root, capture=False) + + def fetch(self, remote: str | None = None, ref: str | None = None) -> None: + args = ["hg", "pull"] + if remote: + args.append(remote) + _run(args, self.root, capture=False) + + def push(self, remote: str, ref: str, set_upstream: bool = False) -> None: + args = ["hg", "push", "-B", ref, remote] + _run(args, self.root, capture=False) + + def reset_worktree(self) -> None: + _run(["hg", "update", "--clean"], self.root, check=False, capture=False) + _run(["hg", "purge", "--all", "--config", "extensions.purge="], self.root, check=False, capture=False) class SubversionBackend(_UnimplementedBackend): From b911ece6a79432ed0bc5f99417985e22537fbcbc Mon Sep 17 00:00:00 2001 From: Arnav Date: Wed, 1 Jul 2026 20:28:52 +0530 Subject: [PATCH 04/14] test: add unit tests for Mercurial backend and SourceHut forge bridge Generated-by: Antigravity --- tools/sourcehut/tests/test_sourcehut.py | 223 ++++++++++++++++++++++++ tools/vcs/tests/test_vcs.py | 57 +++++- 2 files changed, 275 insertions(+), 5 deletions(-) create mode 100644 tools/sourcehut/tests/test_sourcehut.py diff --git a/tools/sourcehut/tests/test_sourcehut.py b/tools/sourcehut/tests/test_sourcehut.py new file mode 100644 index 00000000..840c015c --- /dev/null +++ b/tools/sourcehut/tests/test_sourcehut.py @@ -0,0 +1,223 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import json +from io import BytesIO +from unittest.mock import MagicMock, patch +import pytest + +from magpie_sourcehut.client import SourceHutError, query_graphql +from magpie_sourcehut.todo import get_ticket, submit_ticket, submit_comment, label_ticket, update_ticket_status +from magpie_sourcehut.lists import get_patchset, list_patchsets, map_patchset_to_pr +from magpie_sourcehut.builds import get_job +from magpie_sourcehut.repo import get_repo +from magpie_sourcehut.cli import main + + +@pytest.fixture +def mock_env(monkeypatch): + monkeypatch.setenv("SRHT_TOKEN", "mock_token_123") + + +def make_mock_response(status_code, body_dict): + mock_resp = MagicMock() + mock_resp.read.return_value = json.dumps(body_dict).encode("utf-8") + mock_resp.status = status_code + return mock_resp + + +@patch("urllib.request.urlopen") +def test_query_graphql_success(mock_urlopen, mock_env): + mock_urlopen.return_value.__enter__.return_value = make_mock_response(200, {"data": {"version": "1.0"}}) + res = query_graphql("todo", "{ version }") + assert res == {"version": "1.0"} + + +def test_query_graphql_no_token(): + with pytest.raises(SourceHutError, match="SRHT_TOKEN environment variable is not set"): + query_graphql("todo", "{ version }") + + +@patch("urllib.request.urlopen") +def test_query_graphql_error_in_json(mock_urlopen, mock_env): + mock_urlopen.return_value.__enter__.return_value = make_mock_response( + 200, {"errors": [{"message": "Invalid query syntax"}]} + ) + with pytest.raises(SourceHutError, match="GraphQL error from todo.sr.ht: Invalid query syntax"): + query_graphql("todo", "invalid_query") + + +@patch("urllib.request.urlopen") +def test_get_ticket(mock_urlopen, mock_env): + ticket_data = { + "id": 42, + "title": "Fix memory leak", + "description": "Found a leak in client", + "status": "UNRESOLVED", + "resolution": None, + "labels": [{"id": 1, "name": "bug"}], + "comments": [], + } + mock_urlopen.return_value.__enter__.return_value = make_mock_response( + 200, {"data": {"tracker": {"ticket": ticket_data}}} + ) + res = get_ticket("~user", "my-project", 42) + assert res["id"] == 42 + assert res["title"] == "Fix memory leak" + + +@patch("urllib.request.urlopen") +def test_submit_ticket(mock_urlopen, mock_env): + mock_urlopen.return_value.__enter__.return_value = make_mock_response( + 200, {"data": {"submitTicket": {"id": 101, "title": "New issue"}}} + ) + res = submit_ticket("~user", "my-project", "New issue", "Description here") + assert res["id"] == 101 + + +@patch("urllib.request.urlopen") +def test_submit_comment(mock_urlopen, mock_env): + mock_urlopen.return_value.__enter__.return_value = make_mock_response( + 200, {"data": {"submitComment": {"id": 501, "body": "Comment body"}}} + ) + res = submit_comment("~user", "my-project", 42, "Comment body") + assert res["id"] == 501 + + +@patch("urllib.request.urlopen") +def test_label_ticket(mock_urlopen, mock_env): + mock_urlopen.return_value.__enter__.return_value = make_mock_response( + 200, {"data": {"labelTicket": {"id": 42}}} + ) + res = label_ticket("~user", "my-project", 42, 10) + assert res == {"id": 42} + + +@patch("urllib.request.urlopen") +def test_update_ticket_status(mock_urlopen, mock_env): + mock_urlopen.return_value.__enter__.return_value = make_mock_response( + 200, {"data": {"updateTicketStatus": {"id": 42, "status": "RESOLVED", "resolution": "FIXED"}}} + ) + res = update_ticket_status("~user", "my-project", 42, "RESOLVED", "FIXED") + assert res["status"] == "RESOLVED" + + +@patch("urllib.request.urlopen") +def test_get_patchset_and_mapping(mock_urlopen, mock_env): + patchset_data = { + "id": 200, + "subject": "[PATCH 0/2] Fix some logs", + "version": 1, + "status": "PROPOSED", + "patches": [ + {"id": 201, "subject": "[PATCH 1/2] Add warning log", "diff": "--- a/file\n+++ b/file\n"}, + {"id": 202, "subject": "[PATCH 2/2] Add error log", "diff": "--- a/file2\n+++ b/file2\n"}, + ], + "thread": { + "id": 999, + "emails": { + "edges": [ + { + "node": { + "id": 1000, + "subject": "[PATCH 0/2] Fix some logs", + "body": "Here is the patch series to fix logging", + "sender": {"canonicalName": "Alice "}, + "date": "2026-07-01T12:00:00Z", + } + }, + { + "node": { + "id": 1003, + "subject": "Re: [PATCH 0/2] Fix some logs", + "body": "Looks good to me!", + "sender": {"canonicalName": "Bob "}, + "date": "2026-07-01T13:00:00Z", + } + }, + ] + }, + }, + } + mock_urlopen.return_value.__enter__.return_value = make_mock_response( + 200, {"data": {"list": {"patchset": patchset_data}}} + ) + raw = get_patchset("~user", "my-list", 200) + assert raw["id"] == 200 + + pr = map_patchset_to_pr(raw) + assert pr["id"] == "200" + assert pr["title"] == "[PATCH 0/2] Fix some logs" + assert pr["author"] == "Alice " + assert pr["description"] == "Here is the patch series to fix logging" + assert pr["state"] == "OPEN" + assert len(pr["commits"]) == 2 + assert pr["commits"][0]["subject"] == "[PATCH 1/2] Add warning log" + assert len(pr["comments"]) == 1 + assert pr["comments"][0]["author"] == "Bob " + assert pr["comments"][0]["body"] == "Looks good to me!" + + +@patch("urllib.request.urlopen") +def test_list_patchsets(mock_urlopen, mock_env): + mock_urlopen.return_value.__enter__.return_value = make_mock_response( + 200, + { + "data": { + "list": { + "patches": { + "edges": [ + {"node": {"id": 1, "subject": "P1", "status": "ACCEPTED"}}, + {"node": {"id": 2, "subject": "P2", "status": "PROPOSED"}}, + ] + } + } + }, + }, + ) + res = list_patchsets("~user", "my-list") + assert len(res) == 2 + assert res[0]["subject"] == "P1" + + +@patch("urllib.request.urlopen") +def test_get_job(mock_urlopen, mock_env): + mock_urlopen.return_value.__enter__.return_value = make_mock_response( + 200, {"data": {"job": {"id": 55, "status": "SUCCESS", "tasks": []}}} + ) + res = get_job(55) + assert res["status"] == "SUCCESS" + + +@patch("urllib.request.urlopen") +def test_get_repo(mock_urlopen, mock_env): + mock_urlopen.return_value.__enter__.return_value = make_mock_response( + 200, {"data": {"repository": {"id": 9, "name": "my-repo", "description": "VCS"}}} + ) + res = get_repo("git", "~user", "my-repo") + assert res["name"] == "my-repo" + + +@patch("urllib.request.urlopen") +def test_cli_dispatch(mock_urlopen, mock_env, capsys): + mock_urlopen.return_value.__enter__.return_value = make_mock_response( + 200, {"data": {"job": {"id": 12, "status": "FAILED"}}} + ) + code = main(["build", "get", "12"]) + assert code == 0 + captured = capsys.readouterr() + assert "FAILED" in captured.out diff --git a/tools/vcs/tests/test_vcs.py b/tools/vcs/tests/test_vcs.py index 6d855b01..f892964e 100644 --- a/tools/vcs/tests/test_vcs.py +++ b/tools/vcs/tests/test_vcs.py @@ -41,12 +41,17 @@ os.environ.pop(_var, None) git_required = pytest.mark.skipif(not GitBackend.is_available(), reason="git not installed") +hg_required = pytest.mark.skipif(not MercurialBackend.is_available(), reason="hg not installed") def _git(repo: Path, *args: str) -> None: subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) +def _hg(repo: Path, *args: str) -> None: + subprocess.run(["hg", *args], cwd=repo, check=True, capture_output=True) + + @pytest.fixture def git_repo(tmp_path: Path) -> Path: repo = tmp_path / "repo" @@ -60,6 +65,19 @@ def git_repo(tmp_path: Path) -> Path: return repo +@pytest.fixture +def hg_repo(tmp_path: Path) -> Path: + repo = tmp_path / "repo_hg" + repo.mkdir() + _hg(repo, "init") + with open(repo / ".hg" / "hgrc", "w") as f: + f.write("[ui]\nusername = Tester \n") + (repo / "file.txt").write_text("hello\n") + _hg(repo, "add", "file.txt") + _hg(repo, "commit", "-m", "initial commit") + return repo + + # -- detection ------------------------------------------------------------- @@ -175,15 +193,44 @@ def test_git_reset_worktree(git_repo: Path) -> None: def test_unimplemented_raise_with_issue(tmp_path: Path) -> None: - hg = MercurialBackend(tmp_path) - with pytest.raises(VCSError, match=r"apache/magpie#601"): - hg.status() svn = SubversionBackend(tmp_path) with pytest.raises(VCSError, match=r"apache/magpie#602"): svn.commit("x") assert svn.distributed is False # centralized model flagged +# -- hg backend operations ------------------------------------------------- + + +@hg_required +def test_hg_clean_then_dirty(hg_repo: Path) -> None: + backend = MercurialBackend(hg_repo) + assert backend.is_clean() + (hg_repo / "file.txt").write_text("changed\n") + assert not backend.is_clean() + assert "file.txt" in backend.status() + + +@hg_required +def test_hg_bookmark_and_commit(hg_repo: Path) -> None: + backend = MercurialBackend(hg_repo) + assert backend.current_branch() == "default" + backend.create_branch("fix-bookmark") + (hg_repo / "new.txt").write_text("x\n") + backend.stage(["new.txt"]) + assert "new.txt" in backend.diff() + backend.commit("add new.txt") + assert backend.is_clean() + assert "add new.txt" in backend.log(max_count=1) + + +@hg_required +def test_hg_cached_diff_raises(hg_repo: Path) -> None: + backend = MercurialBackend(hg_repo) + with pytest.raises(VCSError, match="does not support staging area"): + backend.diff(cached=True) + + def test_registry_unique_names() -> None: names = [b.name for b in BACKENDS] assert names == sorted(set(names), key=names.index) @@ -217,6 +264,6 @@ def test_cli_unknown_backend_errors(git_repo: Path, capsys: pytest.CaptureFixtur def test_cli_unimplemented_backend_errors(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: - (tmp_path / ".hg").mkdir() + (tmp_path / ".svn").mkdir() assert main(["-C", str(tmp_path), "status"]) == 2 - assert "apache/magpie#601" in capsys.readouterr().err + assert "apache/magpie#602" in capsys.readouterr().err From 494558b067ed92d4ad154222656c1240bc66eb9d Mon Sep 17 00:00:00 2001 From: Arnav Date: Wed, 1 Jul 2026 20:28:56 +0530 Subject: [PATCH 05/14] build: update uv lockfile for sourcehut integration Generated-by: Antigravity --- uv.lock | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/uv.lock b/uv.lock index 25bf297d..60e5e524 100644 --- a/uv.lock +++ b/uv.lock @@ -21,6 +21,7 @@ members = [ "github-body-field", "github-rollup", "jira-bridge", + "magpie-sourcehut", "magpie-vcs", "oauth-draft", "permission-audit", @@ -515,6 +516,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] +[[package]] +name = "magpie-sourcehut" +version = "0.1.0" +source = { editable = "tools/sourcehut" } + [[package]] name = "magpie-vcs" version = "0.1.0" From 946d25b292ec6bd3b89909a81e7121b4c42f87bc Mon Sep 17 00:00:00 2001 From: Arnav Date: Wed, 1 Jul 2026 20:29:01 +0530 Subject: [PATCH 06/14] docs: update registry, capabilities, and vendor neutrality status Generated-by: Antigravity --- docs/adapters/registry.md | 4 ++-- docs/labels-and-capabilities.md | 3 ++- docs/vendor-neutrality.md | 18 +++++++++--------- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/docs/adapters/registry.md b/docs/adapters/registry.md index 09885a74..03d8dc62 100644 --- a/docs/adapters/registry.md +++ b/docs/adapters/registry.md @@ -44,8 +44,8 @@ extension point = a documented, labelled slot with a tracking issue. | [`tools/mail-source`](../../tools/mail-source/) | mbox, IMAP | Mailman 3 ([#306](https://github.com/apache/magpie/issues/306)) | | [`tools/forwarder-relay`](../../tools/forwarder-relay/) | ASF-security ([`tools/gmail/asf-relay.md`](../../tools/gmail/asf-relay.md)) | huntr.com, HackerOne, GHSA relay | | [`tools/scan-format`](../../tools/scan-format/) | ASVS | other scanner formats | -| [`tools/vcs`](../../tools/vcs/) | Git | Mercurial [#601](https://github.com/apache/magpie/issues/601), Subversion [#602](https://github.com/apache/magpie/issues/602), Jujutsu [#603](https://github.com/apache/magpie/issues/603), Fossil [#604](https://github.com/apache/magpie/issues/604), Perforce [#605](https://github.com/apache/magpie/issues/605) | -| Forge / tracker | [`github`](../../tools/github/), [`jira`](../../tools/jira/) | GitLab [#305](https://github.com/apache/magpie/issues/305), Forgejo/Gitea [#310](https://github.com/apache/magpie/issues/310), Pagure [#312](https://github.com/apache/magpie/issues/312), Bitbucket [#606](https://github.com/apache/magpie/issues/606), SourceHut [#607](https://github.com/apache/magpie/issues/607), Bugzilla [#302](https://github.com/apache/magpie/issues/302) | +| [`tools/vcs`](../../tools/vcs/) | Git, Mercurial | Subversion [\#602](https://github.com/apache/magpie/issues/602), Jujutsu [\#603](https://github.com/apache/magpie/issues/603), Fossil [\#604](https://github.com/apache/magpie/issues/604), Perforce [\#605](https://github.com/apache/magpie/issues/605) | +| Forge / tracker | [`github`](../../tools/github/), [`jira`](../../tools/jira/), [`sourcehut`](../../tools/sourcehut/) | GitLab [\#305](https://github.com/apache/magpie/issues/305), Forgejo/Gitea [\#310](https://github.com/apache/magpie/issues/310), Pagure [\#312](https://github.com/apache/magpie/issues/312), Bitbucket [\#606](https://github.com/apache/magpie/issues/606), Bugzilla [\#302](https://github.com/apache/magpie/issues/302) | | Agentic runtime | Claude Code | Codex [#313](https://github.com/apache/magpie/issues/313)–OpenHands [#322](https://github.com/apache/magpie/issues/322) | | Security cross-ref | — | OSV.dev [#311](https://github.com/apache/magpie/issues/311) | diff --git a/docs/labels-and-capabilities.md b/docs/labels-and-capabilities.md index db2d4c73..f3fc8000 100644 --- a/docs/labels-and-capabilities.md +++ b/docs/labels-and-capabilities.md @@ -262,7 +262,8 @@ it implements multiple contracts (e.g. `tools/gmail` provides both | [`tools/spec-validator`](../tools/spec-validator/) | `substrate:framework-dev` | Spec-frontmatter and body-section validator — counterpart to `skill-and-tool-validator` for `tools/spec-loop/specs/` | | [`tools/symlink-lint`](../tools/symlink-lint/) | `substrate:framework-dev` | Self-adoption symlink hygiene — rejects cyclic symlinks and misdirected skill relays (canonical/relay target-correctness) | | [`tools/pilot-report-validator`](../tools/pilot-report-validator/) | `substrate:framework-dev` | Adopter pilot-report validator — required frontmatter keys, no unfilled placeholders, valid profile, and required body sections; counterpart to `spec-validator` for `docs/pilot-report-template.md` | -| [`tools/vcs`](../tools/vcs/) | `contract:source-control` | Backend-dispatching implementation of the source-control (VCS) capability ([`tools/github/source-control.md`](../tools/github/source-control.md)); complete Git backend plus detected extension points for non-Git VCS bridges (#601 Hg, #602 SVN) | +| [`tools/vcs`](../tools/vcs/) | `contract:source-control` | Backend-dispatching implementation of the source-control (VCS) capability ([`tools/github/source-control.md`](../tools/github/source-control.md)); complete Git and Mercurial (Hg) backends, plus detected extension point for SVN (#602) | +| [`tools/sourcehut`](../tools/sourcehut/) | `contract:tracker` + `contract:source-control` + `contract:mail-archive` | SourceHut (sr.ht) forge bridge: todo.sr.ht, lists.sr.ht, builds.sr.ht, and git/hg repository reads | A tool's capability is the **interface it provides**, not which skills happen to consume it (RFC-AI-0005). `tools/github` provides the diff --git a/docs/vendor-neutrality.md b/docs/vendor-neutrality.md index c69c9405..b38f494d 100644 --- a/docs/vendor-neutrality.md +++ b/docs/vendor-neutrality.md @@ -160,7 +160,7 @@ with pluggable backends already include: | [`tools/mail-source`](../tools/mail-source/) | mbox, IMAP, Gmail API ([`tools/gmail`](../tools/gmail/)), Mailman 3 | | [`tools/forwarder-relay`](../tools/forwarder-relay/) | ASF Security relay, huntr.com, HackerOne triagers | | [`tools/scan-format`](../tools/scan-format/) | security-scanner report formats (ASVS reference) | -| [`tools/vcs`](../tools/vcs/) | Git (complete), Mercurial, Subversion, … (extension points) | +| [`tools/vcs`](../tools/vcs/) | Git (complete), Mercurial (complete), Subversion, … (extension points) | The security-team surface follows the same pattern: CNA backends live behind [`tools/cve-tool`](../tools/cve-tool/) (the ASF Vulnogram adapter @@ -393,10 +393,9 @@ fixed that: (`magpie-vcs`) runs the *abstract* operation and detects the active backend from the working copy. -Today: **Git is complete** (the default binding); Mercurial -([#601](https://github.com/apache/magpie/issues/601)) and Subversion -([#602](https://github.com/apache/magpie/issues/602)) are real, detected -extension points that raise an actionable error naming their tracking +Today: **Git and Mercurial are complete** (the Git and Mercurial bindings); Subversion +([#602](https://github.com/apache/magpie/issues/602)) is a real, detected +extension point that raises an actionable error naming its tracking issue until the full binding lands. Adding a backend means replacing one `_UnimplementedBackend` with a concrete `VCSBackend` subclass — detection, dispatch, the CLI, and every skill that calls `magpie-vcs` @@ -411,11 +410,11 @@ GitHub-hosted ASF project that uses Git for source control needs `tools/asf-svn` to steward its release flow through `dist.apache.org`. Tracking issues exist, labelled `good first issue`, for the remaining -non-Git systems: -[Mercurial](https://github.com/apache/magpie/issues/601), +non-Git/non-Hg systems: [Subversion](https://github.com/apache/magpie/issues/602) (generic VCS binding; `tools/asf-svn` covers the full ASF SVN surface including `dist.apache.org` and authorization), + [Jujutsu](https://github.com/apache/magpie/issues/603), [Fossil](https://github.com/apache/magpie/issues/604), and [Perforce](https://github.com/apache/magpie/issues/605) — so the @@ -484,9 +483,10 @@ coverage without pretending one team can implement an open-ended set. |---|---|---|---| | LLM backend | ✅ by construction | Claude Code, Ollama, vLLM, Apache-hosted, Bedrock, direct Anthropic | Any endpoint meeting the capability floor + privacy gate | | Agentic runtime | ✅ by construction (`AGENTS.md` standard) | Claude Code; community use under Codex, Cursor, Gemini CLI, Copilot, OpenCode, Kiro | Runtime adapters [#313–#322](https://github.com/apache/magpie/issues?q=is%3Aissue+state%3Aopen+adapter+in%3Atitle) | -| Forge / tracker | ✅ by construction | GitHub, Jira; CVE/scan/relay via adapter contracts | GitLab [#305](https://github.com/apache/magpie/issues/305), Forgejo/Gitea [#310](https://github.com/apache/magpie/issues/310), Pagure [#312](https://github.com/apache/magpie/issues/312), Bitbucket [#606](https://github.com/apache/magpie/issues/606), SourceHut [#607](https://github.com/apache/magpie/issues/607), Bugzilla [#302](https://github.com/apache/magpie/issues/302) | +| Forge / tracker | ✅ by construction | GitHub, Jira, SourceHut; CVE/scan/relay via adapter contracts | GitLab [#305](https://github.com/apache/magpie/issues/305), Forgejo/Gitea [#310](https://github.com/apache/magpie/issues/310), Pagure [#312](https://github.com/apache/magpie/issues/312), Bitbucket [#606](https://github.com/apache/magpie/issues/606), Bugzilla [#302](https://github.com/apache/magpie/issues/302) | | Communication channels | ✅ by construction | PonyMail / mail-archive reads | mbox [#304](https://github.com/apache/magpie/issues/304), IMAP [#303](https://github.com/apache/magpie/issues/303), Mailman 3 [#306](https://github.com/apache/magpie/issues/306); Discourse [#307](https://github.com/apache/magpie/issues/307), Zulip [#308](https://github.com/apache/magpie/issues/308), Matrix [#309](https://github.com/apache/magpie/issues/309) | -| Source control (VCS) | ✅ by construction | **Git (complete)**; ASF SVN surface ([`tools/asf-svn`](../tools/asf-svn/): source control + dist.apache.org + authorization) | Mercurial [#601](https://github.com/apache/magpie/issues/601), Subversion generic VCS binding [#602](https://github.com/apache/magpie/issues/602) (detected); Jujutsu [#603](https://github.com/apache/magpie/issues/603), Fossil [#604](https://github.com/apache/magpie/issues/604), Perforce [#605](https://github.com/apache/magpie/issues/605) (tracked) | +| Source control (VCS) | ✅ by construction | **Git (complete)**, **Mercurial (complete)**; ASF SVN surface ([`tools/asf-svn`](../tools/asf-svn/): source control + dist.apache.org + authorization) | Subversion generic VCS binding [\#602](https://github.com/apache/magpie/issues/602) (detected); Jujutsu [\#603](https://github.com/apache/magpie/issues/603), Fossil [\#604](https://github.com/apache/magpie/issues/604), Perforce [\#605](https://github.com/apache/magpie/issues/605) (tracked) | + | Project governance | ✅ by construction | ASF + non-ASF adopter profiles | Adopter config (modes, thresholds) | ✅ "by construction" means the workflows carry no vendor assumption; From 4ad27ff9eb7db4e95efad6220e9d824270696c9f Mon Sep 17 00:00:00 2001 From: Arnav Date: Wed, 1 Jul 2026 22:18:52 +0530 Subject: [PATCH 07/14] fix: resolve CodeQL unused imports and empty except clauses warnings Generated-by: Antigravity --- tools/sourcehut/README.md | 12 +++++ .../src/magpie_sourcehut/__init__.py | 1 + tools/sourcehut/src/magpie_sourcehut/cli.py | 5 +- .../sourcehut/src/magpie_sourcehut/client.py | 14 ++++-- tools/sourcehut/src/magpie_sourcehut/lists.py | 31 ++++++------ tools/sourcehut/src/magpie_sourcehut/py.typed | 1 + tools/sourcehut/src/magpie_sourcehut/todo.py | 8 ++-- tools/sourcehut/tests/test_sourcehut.py | 47 +++++++++++-------- 8 files changed, 74 insertions(+), 45 deletions(-) create mode 100644 tools/sourcehut/src/magpie_sourcehut/py.typed diff --git a/tools/sourcehut/README.md b/tools/sourcehut/README.md index df147330..26c8ddfe 100644 --- a/tools/sourcehut/README.md +++ b/tools/sourcehut/README.md @@ -1,3 +1,15 @@ + + +**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* + +- [SourceHut (sr.ht) Forge Bridge](#sourcehut-srht-forge-bridge) + - [Prerequisites](#prerequisites) + - [Features](#features) + - [Invocation](#invocation) + - [Configuration](#configuration) + + + # SourceHut (sr.ht) Forge Bridge **Capability:** contract:tracker + contract:source-control + contract:mail-archive diff --git a/tools/sourcehut/src/magpie_sourcehut/__init__.py b/tools/sourcehut/src/magpie_sourcehut/__init__.py index a7c19189..624479a5 100644 --- a/tools/sourcehut/src/magpie_sourcehut/__init__.py +++ b/tools/sourcehut/src/magpie_sourcehut/__init__.py @@ -28,6 +28,7 @@ def main(argv: Sequence[str] | None = None) -> int: """CLI entry point.""" from magpie_sourcehut.cli import main as cli_main + try: return cli_main(argv) except Exception as exc: diff --git a/tools/sourcehut/src/magpie_sourcehut/cli.py b/tools/sourcehut/src/magpie_sourcehut/cli.py index 82db00c1..b00a0043 100644 --- a/tools/sourcehut/src/magpie_sourcehut/cli.py +++ b/tools/sourcehut/src/magpie_sourcehut/cli.py @@ -21,7 +21,6 @@ import argparse import json -import sys from collections.abc import Sequence from magpie_sourcehut.builds import get_job @@ -166,8 +165,8 @@ def main(argv: Sequence[str] | None = None) -> int: res = get_patchset(ns.owner, ns.list_name, ns.id) _print_json(res) elif ns.action == "list": - res = list_patchsets(ns.owner, ns.list_name) - _print_json(res) + patchsets = list_patchsets(ns.owner, ns.list_name) + _print_json(patchsets) elif ns.action == "pr-map": raw = get_patchset(ns.owner, ns.list_name, ns.id) res = map_patchset_to_pr(raw) diff --git a/tools/sourcehut/src/magpie_sourcehut/client.py b/tools/sourcehut/src/magpie_sourcehut/client.py index a0f4ef39..dc5d2afc 100644 --- a/tools/sourcehut/src/magpie_sourcehut/client.py +++ b/tools/sourcehut/src/magpie_sourcehut/client.py @@ -46,7 +46,7 @@ def query_graphql(service: str, query: str, variables: dict[str, Any] | None = N raise SourceHutError("SRHT_TOKEN environment variable is not set") url = f"https://{service}.sr.ht/query" - payload = {"query": query} + payload: dict[str, Any] = {"query": query} if variables: payload["variables"] = variables @@ -65,19 +65,23 @@ def query_graphql(service: str, query: str, variables: dict[str, Any] | None = N with urllib.request.urlopen(req) as resp: body = resp.read().decode("utf-8") res_json = json.loads(body) - if "errors" in res_json and res_json["errors"]: - err_msgs = [e.get("message", "Unknown error") for e in res_json["errors"]] + errors = res_json.get("errors") + if errors: + err_msgs = [e.get("message", "Unknown error") for e in errors] raise SourceHutError(f"GraphQL error from {service}.sr.ht: {'; '.join(err_msgs)}") return res_json.get("data", {}) except urllib.error.HTTPError as exc: try: err_body = exc.read().decode("utf-8") err_json = json.loads(err_body) - if "errors" in err_json and err_json["errors"]: - err_msgs = [e.get("message", "Unknown error") for e in err_json["errors"]] + err_errors = err_json.get("errors") + if err_errors: + err_msgs = [e.get("message", "Unknown error") for e in err_errors] raise SourceHutError(f"HTTP {exc.code}: {'; '.join(err_msgs)}") except Exception: + # Ignore errors parsing the HTTP error response body as JSON pass + raise SourceHutError(f"HTTP request to {url} failed with status {exc.code}") from exc except urllib.error.URLError as exc: raise SourceHutError(f"Failed to connect to {url}: {exc.reason}") from exc diff --git a/tools/sourcehut/src/magpie_sourcehut/lists.py b/tools/sourcehut/src/magpie_sourcehut/lists.py index 17efca2b..44268c09 100644 --- a/tools/sourcehut/src/magpie_sourcehut/lists.py +++ b/tools/sourcehut/src/magpie_sourcehut/lists.py @@ -19,6 +19,7 @@ from __future__ import annotations +import contextlib from typing import Any from magpie_sourcehut.client import query_graphql @@ -121,10 +122,8 @@ def map_patchset_to_pr(patchset: dict[str, Any]) -> dict[str, Any]: emails = [edge.get("node") for edge in edges if edge.get("node")] # Sort emails by date if possible - try: + with contextlib.suppress(Exception): emails.sort(key=lambda x: x.get("date", "")) - except Exception: - pass # The cover letter / description is the first email (or patchset subject) description = "" @@ -146,22 +145,26 @@ def map_patchset_to_pr(patchset: dict[str, Any]) -> dict[str, Any]: # Map patches inside the patchset to commits commits = [] for patch in patchset.get("patches") or []: - commits.append({ - "id": str(patch.get("id")), - "subject": patch.get("subject", ""), - "diff": patch.get("diff", ""), - }) + commits.append( + { + "id": str(patch.get("id")), + "subject": patch.get("subject", ""), + "diff": patch.get("diff", ""), + } + ) # Map replies (all emails except the first/cover letter) to review comments comments = [] for email in emails[1:]: sender = email.get("sender") or {} - comments.append({ - "id": str(email.get("id")), - "author": sender.get("canonicalName", "Unknown"), - "body": email.get("body", ""), - "date": email.get("date", ""), - }) + comments.append( + { + "id": str(email.get("id")), + "author": sender.get("canonicalName", "Unknown"), + "body": email.get("body", ""), + "date": email.get("date", ""), + } + ) return { "id": str(patchset.get("id")), diff --git a/tools/sourcehut/src/magpie_sourcehut/py.typed b/tools/sourcehut/src/magpie_sourcehut/py.typed new file mode 100644 index 00000000..1242d432 --- /dev/null +++ b/tools/sourcehut/src/magpie_sourcehut/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561. diff --git a/tools/sourcehut/src/magpie_sourcehut/todo.py b/tools/sourcehut/src/magpie_sourcehut/todo.py index 82027b8c..913eee91 100644 --- a/tools/sourcehut/src/magpie_sourcehut/todo.py +++ b/tools/sourcehut/src/magpie_sourcehut/todo.py @@ -81,8 +81,8 @@ def submit_ticket(owner: str, name: str, title: str, description: str) -> dict[s "owner": owner, "name": name, "input": { - "title": title, - "description": description, + "title": title, + "description": description, }, } res = query_graphql("todo", q, variables) @@ -152,7 +152,9 @@ def unlabel_ticket(owner: str, name: str, ticket_id: int, label_id: int) -> dict return res.get("unlabelTicket") or {} -def update_ticket_status(owner: str, name: str, ticket_id: int, status: str, resolution: str | None = None) -> dict[str, Any]: +def update_ticket_status( + owner: str, name: str, ticket_id: int, status: str, resolution: str | None = None +) -> dict[str, Any]: """Update ticket status (resolve / close) on todo.sr.ht.""" owner = _normalize_owner(owner) q = """ diff --git a/tools/sourcehut/tests/test_sourcehut.py b/tools/sourcehut/tests/test_sourcehut.py index 840c015c..c7bad0e7 100644 --- a/tools/sourcehut/tests/test_sourcehut.py +++ b/tools/sourcehut/tests/test_sourcehut.py @@ -16,24 +16,31 @@ # under the License. import json -from io import BytesIO +from typing import Any from unittest.mock import MagicMock, patch + import pytest +from magpie_sourcehut.builds import get_job +from magpie_sourcehut.cli import main from magpie_sourcehut.client import SourceHutError, query_graphql -from magpie_sourcehut.todo import get_ticket, submit_ticket, submit_comment, label_ticket, update_ticket_status from magpie_sourcehut.lists import get_patchset, list_patchsets, map_patchset_to_pr -from magpie_sourcehut.builds import get_job from magpie_sourcehut.repo import get_repo -from magpie_sourcehut.cli import main +from magpie_sourcehut.todo import ( + get_ticket, + label_ticket, + submit_comment, + submit_ticket, + update_ticket_status, +) @pytest.fixture -def mock_env(monkeypatch): +def mock_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("SRHT_TOKEN", "mock_token_123") -def make_mock_response(status_code, body_dict): +def make_mock_response(status_code: int, body_dict: dict[str, Any]) -> MagicMock: mock_resp = MagicMock() mock_resp.read.return_value = json.dumps(body_dict).encode("utf-8") mock_resp.status = status_code @@ -41,28 +48,28 @@ def make_mock_response(status_code, body_dict): @patch("urllib.request.urlopen") -def test_query_graphql_success(mock_urlopen, mock_env): +def test_query_graphql_success(mock_urlopen: MagicMock, mock_env: None) -> None: mock_urlopen.return_value.__enter__.return_value = make_mock_response(200, {"data": {"version": "1.0"}}) res = query_graphql("todo", "{ version }") assert res == {"version": "1.0"} -def test_query_graphql_no_token(): +def test_query_graphql_no_token() -> None: with pytest.raises(SourceHutError, match="SRHT_TOKEN environment variable is not set"): query_graphql("todo", "{ version }") @patch("urllib.request.urlopen") -def test_query_graphql_error_in_json(mock_urlopen, mock_env): +def test_query_graphql_error_in_json(mock_urlopen: MagicMock, mock_env: None) -> None: mock_urlopen.return_value.__enter__.return_value = make_mock_response( 200, {"errors": [{"message": "Invalid query syntax"}]} ) - with pytest.raises(SourceHutError, match="GraphQL error from todo.sr.ht: Invalid query syntax"): + with pytest.raises(SourceHutError, match=r"GraphQL error from todo\.sr\.ht: Invalid query syntax"): query_graphql("todo", "invalid_query") @patch("urllib.request.urlopen") -def test_get_ticket(mock_urlopen, mock_env): +def test_get_ticket(mock_urlopen: MagicMock, mock_env: None) -> None: ticket_data = { "id": 42, "title": "Fix memory leak", @@ -81,7 +88,7 @@ def test_get_ticket(mock_urlopen, mock_env): @patch("urllib.request.urlopen") -def test_submit_ticket(mock_urlopen, mock_env): +def test_submit_ticket(mock_urlopen: MagicMock, mock_env: None) -> None: mock_urlopen.return_value.__enter__.return_value = make_mock_response( 200, {"data": {"submitTicket": {"id": 101, "title": "New issue"}}} ) @@ -90,7 +97,7 @@ def test_submit_ticket(mock_urlopen, mock_env): @patch("urllib.request.urlopen") -def test_submit_comment(mock_urlopen, mock_env): +def test_submit_comment(mock_urlopen: MagicMock, mock_env: None) -> None: mock_urlopen.return_value.__enter__.return_value = make_mock_response( 200, {"data": {"submitComment": {"id": 501, "body": "Comment body"}}} ) @@ -99,7 +106,7 @@ def test_submit_comment(mock_urlopen, mock_env): @patch("urllib.request.urlopen") -def test_label_ticket(mock_urlopen, mock_env): +def test_label_ticket(mock_urlopen: MagicMock, mock_env: None) -> None: mock_urlopen.return_value.__enter__.return_value = make_mock_response( 200, {"data": {"labelTicket": {"id": 42}}} ) @@ -108,7 +115,7 @@ def test_label_ticket(mock_urlopen, mock_env): @patch("urllib.request.urlopen") -def test_update_ticket_status(mock_urlopen, mock_env): +def test_update_ticket_status(mock_urlopen: MagicMock, mock_env: None) -> None: mock_urlopen.return_value.__enter__.return_value = make_mock_response( 200, {"data": {"updateTicketStatus": {"id": 42, "status": "RESOLVED", "resolution": "FIXED"}}} ) @@ -117,7 +124,7 @@ def test_update_ticket_status(mock_urlopen, mock_env): @patch("urllib.request.urlopen") -def test_get_patchset_and_mapping(mock_urlopen, mock_env): +def test_get_patchset_and_mapping(mock_urlopen: MagicMock, mock_env: None) -> None: patchset_data = { "id": 200, "subject": "[PATCH 0/2] Fix some logs", @@ -173,7 +180,7 @@ def test_get_patchset_and_mapping(mock_urlopen, mock_env): @patch("urllib.request.urlopen") -def test_list_patchsets(mock_urlopen, mock_env): +def test_list_patchsets(mock_urlopen: MagicMock, mock_env: None) -> None: mock_urlopen.return_value.__enter__.return_value = make_mock_response( 200, { @@ -195,7 +202,7 @@ def test_list_patchsets(mock_urlopen, mock_env): @patch("urllib.request.urlopen") -def test_get_job(mock_urlopen, mock_env): +def test_get_job(mock_urlopen: MagicMock, mock_env: None) -> None: mock_urlopen.return_value.__enter__.return_value = make_mock_response( 200, {"data": {"job": {"id": 55, "status": "SUCCESS", "tasks": []}}} ) @@ -204,7 +211,7 @@ def test_get_job(mock_urlopen, mock_env): @patch("urllib.request.urlopen") -def test_get_repo(mock_urlopen, mock_env): +def test_get_repo(mock_urlopen: MagicMock, mock_env: None) -> None: mock_urlopen.return_value.__enter__.return_value = make_mock_response( 200, {"data": {"repository": {"id": 9, "name": "my-repo", "description": "VCS"}}} ) @@ -213,7 +220,7 @@ def test_get_repo(mock_urlopen, mock_env): @patch("urllib.request.urlopen") -def test_cli_dispatch(mock_urlopen, mock_env, capsys): +def test_cli_dispatch(mock_urlopen: MagicMock, mock_env: None, capsys: pytest.CaptureFixture[str]) -> None: mock_urlopen.return_value.__enter__.return_value = make_mock_response( 200, {"data": {"job": {"id": 12, "status": "FAILED"}}} ) From 4583536a58e5488a221bd8bb063e78da6211c755 Mon Sep 17 00:00:00 2001 From: Arnav Date: Thu, 2 Jul 2026 08:51:15 +0530 Subject: [PATCH 08/14] docs: resolve markdown table splits and tighten features list Generated-by: Antigravity --- docs/vendor-neutrality.md | 3 +-- tools/sourcehut/README.md | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/vendor-neutrality.md b/docs/vendor-neutrality.md index b38f494d..1a1caca8 100644 --- a/docs/vendor-neutrality.md +++ b/docs/vendor-neutrality.md @@ -414,7 +414,6 @@ non-Git/non-Hg systems: [Subversion](https://github.com/apache/magpie/issues/602) (generic VCS binding; `tools/asf-svn` covers the full ASF SVN surface including `dist.apache.org` and authorization), - [Jujutsu](https://github.com/apache/magpie/issues/603), [Fossil](https://github.com/apache/magpie/issues/604), and [Perforce](https://github.com/apache/magpie/issues/605) — so the @@ -422,6 +421,7 @@ extension points are public and labelled, not hypothetical. (The Bitbucket and SourceHut forges, which carry their own VCS, are tracked under the forge axis above.) + ### 6. Project governance Vendor neutrality extends to *how a project is run*, not just to its @@ -486,7 +486,6 @@ coverage without pretending one team can implement an open-ended set. | Forge / tracker | ✅ by construction | GitHub, Jira, SourceHut; CVE/scan/relay via adapter contracts | GitLab [#305](https://github.com/apache/magpie/issues/305), Forgejo/Gitea [#310](https://github.com/apache/magpie/issues/310), Pagure [#312](https://github.com/apache/magpie/issues/312), Bitbucket [#606](https://github.com/apache/magpie/issues/606), Bugzilla [#302](https://github.com/apache/magpie/issues/302) | | Communication channels | ✅ by construction | PonyMail / mail-archive reads | mbox [#304](https://github.com/apache/magpie/issues/304), IMAP [#303](https://github.com/apache/magpie/issues/303), Mailman 3 [#306](https://github.com/apache/magpie/issues/306); Discourse [#307](https://github.com/apache/magpie/issues/307), Zulip [#308](https://github.com/apache/magpie/issues/308), Matrix [#309](https://github.com/apache/magpie/issues/309) | | Source control (VCS) | ✅ by construction | **Git (complete)**, **Mercurial (complete)**; ASF SVN surface ([`tools/asf-svn`](../tools/asf-svn/): source control + dist.apache.org + authorization) | Subversion generic VCS binding [\#602](https://github.com/apache/magpie/issues/602) (detected); Jujutsu [\#603](https://github.com/apache/magpie/issues/603), Fossil [\#604](https://github.com/apache/magpie/issues/604), Perforce [\#605](https://github.com/apache/magpie/issues/605) (tracked) | - | Project governance | ✅ by construction | ASF + non-ASF adopter profiles | Adopter config (modes, thresholds) | ✅ "by construction" means the workflows carry no vendor assumption; diff --git a/tools/sourcehut/README.md b/tools/sourcehut/README.md index 26c8ddfe..10e4d265 100644 --- a/tools/sourcehut/README.md +++ b/tools/sourcehut/README.md @@ -25,7 +25,7 @@ SourceHut (sr.ht) forge bridge implementation for the Apache Magpie framework. I ## Features -1. **VCS Repositories:** Reads repo metadata and refs across `git.sr.ht` and `hg.sr.ht`. +1. **VCS Repositories:** Reads repo metadata across `git.sr.ht` and `hg.sr.ht`. 2. **Issue Tracker:** Read/write operations (create ticket, comment, resolve status, update labels) on `todo.sr.ht` trackers. 3. **Mailing Lists:** Reads patchsets and threads from `lists.sr.ht`, mapping them to the uniform PR/MR review abstraction. 4. **CI Builds:** Reads job statuses from `builds.sr.ht`. From 067fb9da930a3a9cee81b2c2fef0d63061aa1cfb Mon Sep 17 00:00:00 2001 From: Arnav Date: Thu, 2 Jul 2026 08:51:20 +0530 Subject: [PATCH 09/14] feat: drop purge --all for Mercurial reset and require label args in CLI Generated-by: Antigravity --- tools/sourcehut/src/magpie_sourcehut/cli.py | 3 +++ tools/vcs/src/magpie_vcs/__init__.py | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/tools/sourcehut/src/magpie_sourcehut/cli.py b/tools/sourcehut/src/magpie_sourcehut/cli.py index b00a0043..12892da5 100644 --- a/tools/sourcehut/src/magpie_sourcehut/cli.py +++ b/tools/sourcehut/src/magpie_sourcehut/cli.py @@ -149,6 +149,8 @@ def main(argv: Sequence[str] | None = None) -> int: res = submit_comment(ns.owner, ns.name, ns.id, ns.body) _print_json(res) elif ns.action == "label": + if not ns.add and not ns.remove: + parser.error("At least one of --add or --remove must be specified") if ns.add: label_ticket(ns.owner, ns.name, ns.id, ns.add) if ns.remove: @@ -156,6 +158,7 @@ def main(argv: Sequence[str] | None = None) -> int: # Fetch and print updated ticket details res = get_ticket(ns.owner, ns.name, ns.id) _print_json(res) + elif ns.action == "close": res = update_ticket_status(ns.owner, ns.name, ns.id, ns.status, ns.resolution) _print_json(res) diff --git a/tools/vcs/src/magpie_vcs/__init__.py b/tools/vcs/src/magpie_vcs/__init__.py index 5595eca1..6686c89a 100644 --- a/tools/vcs/src/magpie_vcs/__init__.py +++ b/tools/vcs/src/magpie_vcs/__init__.py @@ -415,6 +415,8 @@ def log( return _run(args, self.root) def create_branch(self, name: str) -> None: + # Creates and automatically activates the bookmark on the current revision, + # switching the working directory to this bookmark for subsequent commits. _run(["hg", "bookmark", name], self.root, capture=False) def switch(self, ref: str) -> None: @@ -440,7 +442,8 @@ def push(self, remote: str, ref: str, set_upstream: bool = False) -> None: def reset_worktree(self) -> None: _run(["hg", "update", "--clean"], self.root, check=False, capture=False) - _run(["hg", "purge", "--all", "--config", "extensions.purge="], self.root, check=False, capture=False) + _run(["hg", "purge", "--config", "extensions.purge="], self.root, check=False, capture=False) + class SubversionBackend(_UnimplementedBackend): From bd04ac45e84e52542a9bc8095ae34c680668765d Mon Sep 17 00:00:00 2001 From: Arnav Date: Thu, 2 Jul 2026 08:51:25 +0530 Subject: [PATCH 10/14] test: verify Mercurial worktree reset and SourceHut empty label validation Generated-by: Antigravity --- tools/sourcehut/tests/test_sourcehut.py | 9 +++++++++ tools/vcs/tests/test_vcs.py | 15 +++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/tools/sourcehut/tests/test_sourcehut.py b/tools/sourcehut/tests/test_sourcehut.py index c7bad0e7..203e7103 100644 --- a/tools/sourcehut/tests/test_sourcehut.py +++ b/tools/sourcehut/tests/test_sourcehut.py @@ -228,3 +228,12 @@ def test_cli_dispatch(mock_urlopen: MagicMock, mock_env: None, capsys: pytest.Ca assert code == 0 captured = capsys.readouterr() assert "FAILED" in captured.out + + +def test_cli_label_error(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as excinfo: + main(["ticket", "label", "~user", "my-project", "42"]) + assert excinfo.value.code == 2 + captured = capsys.readouterr() + assert "At least one of --add or --remove must be specified" in captured.err + diff --git a/tools/vcs/tests/test_vcs.py b/tools/vcs/tests/test_vcs.py index f892964e..7a6c9d44 100644 --- a/tools/vcs/tests/test_vcs.py +++ b/tools/vcs/tests/test_vcs.py @@ -231,6 +231,21 @@ def test_hg_cached_diff_raises(hg_repo: Path) -> None: backend.diff(cached=True) +@hg_required +def test_hg_reset_worktree(hg_repo: Path) -> None: + backend = MercurialBackend(hg_repo) + (hg_repo / ".hgignore").write_text("ignored.txt\n") + (hg_repo / "file.txt").write_text("dirty\n") + (hg_repo / "untracked.txt").write_text("junk\n") + (hg_repo / "ignored.txt").write_text("ignored\n") + backend.reset_worktree() + assert backend.is_clean() + assert not (hg_repo / "untracked.txt").exists() + assert (hg_repo / "file.txt").read_text() == "hello\n" + assert (hg_repo / "ignored.txt").exists() # ignored files should be preserved + + + def test_registry_unique_names() -> None: names = [b.name for b in BACKENDS] assert names == sorted(set(names), key=names.index) From ec1a670dce8f57fae2971df29ec421130e60f06f Mon Sep 17 00:00:00 2001 From: Arnav Date: Thu, 2 Jul 2026 18:20:43 +0530 Subject: [PATCH 11/14] fix: resolve maintainer review feedback on VCS and SourceHut Generated-by: Antigravity --- docs/vendor-neutrality.md | 8 +- tools/sourcehut/README.md | 3 + tools/sourcehut/src/magpie_sourcehut/cli.py | 111 +++++++++--------- .../sourcehut/src/magpie_sourcehut/client.py | 5 +- tools/sourcehut/src/magpie_sourcehut/lists.py | 19 +-- tools/vcs/README.md | 7 +- tools/vcs/src/magpie_vcs/__init__.py | 8 +- 7 files changed, 87 insertions(+), 74 deletions(-) diff --git a/docs/vendor-neutrality.md b/docs/vendor-neutrality.md index 1a1caca8..969a8364 100644 --- a/docs/vendor-neutrality.md +++ b/docs/vendor-neutrality.md @@ -205,7 +205,7 @@ contract for one vendor: | [`tools/mail-source`](../tools/mail-source/) | mbox, IMAP, Gmail API ([`tools/gmail`](../tools/gmail/)) | Mailman 3 | | [`tools/forwarder-relay`](../tools/forwarder-relay/) | ASF-security ([`tools/gmail/asf-relay.md`](../tools/gmail/asf-relay.md)) | huntr.com, HackerOne | | [`tools/scan-format`](../tools/scan-format/) | ASVS | other scanner formats | -| [`tools/vcs`](../tools/vcs/) | Git | Mercurial, Subversion, … | +| [`tools/vcs`](../tools/vcs/) | Git, Mercurial | Subversion, … | A project selects an adapter per capability in its config (`cve_authority.tool: vulnogram`, `archive_system.kind: ponymail`, @@ -536,9 +536,9 @@ implementation of a capability. | Capability contract | Neutral? | Class | Backends today | Basis | |---|---|---|---|---| -| `contract:tracker` | ✅ | vendor-backed | Atlassian, GitHub | 2 backend vendors: Atlassian, GitHub | -| `contract:source-control` | ✅ | vendor-backed | Git, GitHub, Subversion | 3 backend vendors: Git, GitHub, Subversion | -| `contract:mail-archive` | ✅ | vendor-backed | Google, PonyMail | 2 backend vendors: Google, PonyMail | +| `contract:tracker` | ✅ | vendor-backed | Atlassian, GitHub, SourceHut | 3 backend vendors: Atlassian, GitHub, SourceHut | +| `contract:source-control` | ✅ | vendor-backed | Git, GitHub, SourceHut, Subversion | 4 backend vendors: Git, GitHub, SourceHut, Subversion | +| `contract:mail-archive` | ✅ | vendor-backed | Google, PonyMail, SourceHut | 3 backend vendors: Google, PonyMail, SourceHut | | `contract:mail-source` | ✅ | vendor-backed | Google, PonyMail | 2 backend vendors: Google, PonyMail | | `contract:mail-draft` | ❌ | vendor-backed | Google | only 1 backend vendor (Google); needs 1 more | | `contract:cve-authority` | ✅ | vendor-backed | CVE.org, Vulnogram | 2 backend vendors: CVE.org, Vulnogram | diff --git a/tools/sourcehut/README.md b/tools/sourcehut/README.md index 10e4d265..fa466c86 100644 --- a/tools/sourcehut/README.md +++ b/tools/sourcehut/README.md @@ -13,6 +13,9 @@ # SourceHut (sr.ht) Forge Bridge **Capability:** contract:tracker + contract:source-control + contract:mail-archive +**Kind:** implementation +**Vendor:** SourceHut + SourceHut (sr.ht) forge bridge implementation for the Apache Magpie framework. It integrates ticket tracking (`todo.sr.ht`), mailing list patchset review (`lists.sr.ht`), CI builds (`builds.sr.ht`), and repository reads (`git.sr.ht` & `hg.sr.ht`) using SourceHut's GraphQL APIs. diff --git a/tools/sourcehut/src/magpie_sourcehut/cli.py b/tools/sourcehut/src/magpie_sourcehut/cli.py index 12892da5..d6677060 100644 --- a/tools/sourcehut/src/magpie_sourcehut/cli.py +++ b/tools/sourcehut/src/magpie_sourcehut/cli.py @@ -21,10 +21,11 @@ import argparse import json +import sys from collections.abc import Sequence from magpie_sourcehut.builds import get_job -from magpie_sourcehut.client import query_graphql +from magpie_sourcehut.client import SourceHutError, query_graphql from magpie_sourcehut.lists import get_patchset, list_patchsets, map_patchset_to_pr from magpie_sourcehut.repo import get_repo from magpie_sourcehut.todo import ( @@ -131,58 +132,62 @@ def main(argv: Sequence[str] | None = None) -> int: parser = _build_parser() ns = parser.parse_args(argv) - if ns.subcommand == "graphql": - vars_dict = None - if ns.variables: - vars_dict = json.loads(ns.variables) - res = query_graphql(ns.service, ns.query, vars_dict) - _print_json(res) - - elif ns.subcommand == "ticket": - if ns.action == "get": - res = get_ticket(ns.owner, ns.name, ns.id) - _print_json(res) - elif ns.action == "create": - res = submit_ticket(ns.owner, ns.name, ns.title, ns.body) - _print_json(res) - elif ns.action == "comment": - res = submit_comment(ns.owner, ns.name, ns.id, ns.body) - _print_json(res) - elif ns.action == "label": - if not ns.add and not ns.remove: - parser.error("At least one of --add or --remove must be specified") - if ns.add: - label_ticket(ns.owner, ns.name, ns.id, ns.add) - if ns.remove: - unlabel_ticket(ns.owner, ns.name, ns.id, ns.remove) - # Fetch and print updated ticket details - res = get_ticket(ns.owner, ns.name, ns.id) - _print_json(res) - - elif ns.action == "close": - res = update_ticket_status(ns.owner, ns.name, ns.id, ns.status, ns.resolution) + try: + if ns.subcommand == "graphql": + vars_dict = None + if ns.variables: + vars_dict = json.loads(ns.variables) + res = query_graphql(ns.service, ns.query, vars_dict) _print_json(res) - elif ns.subcommand == "patchset": - if ns.action == "get": - res = get_patchset(ns.owner, ns.list_name, ns.id) - _print_json(res) - elif ns.action == "list": - patchsets = list_patchsets(ns.owner, ns.list_name) - _print_json(patchsets) - elif ns.action == "pr-map": - raw = get_patchset(ns.owner, ns.list_name, ns.id) - res = map_patchset_to_pr(raw) - _print_json(res) - - elif ns.subcommand == "build": - if ns.action == "get": - res = get_job(ns.id) - _print_json(res) - - elif ns.subcommand == "repo": - if ns.action == "get": - res = get_repo(ns.service, ns.owner, ns.name) - _print_json(res) + elif ns.subcommand == "ticket": + if ns.action == "get": + res = get_ticket(ns.owner, ns.name, ns.id) + _print_json(res) + elif ns.action == "create": + res = submit_ticket(ns.owner, ns.name, ns.title, ns.body) + _print_json(res) + elif ns.action == "comment": + res = submit_comment(ns.owner, ns.name, ns.id, ns.body) + _print_json(res) + elif ns.action == "label": + if not ns.add and not ns.remove: + parser.error("At least one of --add or --remove must be specified") + if ns.add: + label_ticket(ns.owner, ns.name, ns.id, ns.add) + if ns.remove: + unlabel_ticket(ns.owner, ns.name, ns.id, ns.remove) + # Fetch and print updated ticket details + res = get_ticket(ns.owner, ns.name, ns.id) + _print_json(res) + elif ns.action == "close": + res = update_ticket_status(ns.owner, ns.name, ns.id, ns.status, ns.resolution) + _print_json(res) + + elif ns.subcommand == "patchset": + if ns.action == "get": + res = get_patchset(ns.owner, ns.list_name, ns.id) + _print_json(res) + elif ns.action == "list": + patchsets = list_patchsets(ns.owner, ns.list_name) + _print_json(patchsets) + elif ns.action == "pr-map": + raw = get_patchset(ns.owner, ns.list_name, ns.id) + res = map_patchset_to_pr(raw) + _print_json(res) + + elif ns.subcommand == "build": + if ns.action == "get": + res = get_job(ns.id) + _print_json(res) + + elif ns.subcommand == "repo": + if ns.action == "get": + res = get_repo(ns.service, ns.owner, ns.name) + _print_json(res) + + return 0 + except (SourceHutError, json.JSONDecodeError) as e: + print(f"magpie-sourcehut error: {e}", file=sys.stderr) + return 2 - return 0 diff --git a/tools/sourcehut/src/magpie_sourcehut/client.py b/tools/sourcehut/src/magpie_sourcehut/client.py index dc5d2afc..fbf1758a 100644 --- a/tools/sourcehut/src/magpie_sourcehut/client.py +++ b/tools/sourcehut/src/magpie_sourcehut/client.py @@ -71,17 +71,20 @@ def query_graphql(service: str, query: str, variables: dict[str, Any] | None = N raise SourceHutError(f"GraphQL error from {service}.sr.ht: {'; '.join(err_msgs)}") return res_json.get("data", {}) except urllib.error.HTTPError as exc: + err_msg = None try: err_body = exc.read().decode("utf-8") err_json = json.loads(err_body) err_errors = err_json.get("errors") if err_errors: err_msgs = [e.get("message", "Unknown error") for e in err_errors] - raise SourceHutError(f"HTTP {exc.code}: {'; '.join(err_msgs)}") + err_msg = f"HTTP {exc.code}: {'; '.join(err_msgs)}" except Exception: # Ignore errors parsing the HTTP error response body as JSON pass + if err_msg: + raise SourceHutError(err_msg) raise SourceHutError(f"HTTP request to {url} failed with status {exc.code}") from exc except urllib.error.URLError as exc: raise SourceHutError(f"Failed to connect to {url}: {exc.reason}") from exc diff --git a/tools/sourcehut/src/magpie_sourcehut/lists.py b/tools/sourcehut/src/magpie_sourcehut/lists.py index 44268c09..bca4e9a2 100644 --- a/tools/sourcehut/src/magpie_sourcehut/lists.py +++ b/tools/sourcehut/src/magpie_sourcehut/lists.py @@ -100,7 +100,7 @@ def list_patchsets(owner: str, list_name: str) -> list[dict[str, Any]]: mlist = res.get("list") or {} patches_conn = mlist.get("patches") or {} edges = patches_conn.get("edges") or [] - return [edge.get("node") for edge in edges if edge.get("node")] + return [edge.get("node") for edge in edges if edge and edge.get("node")] def map_patchset_to_pr(patchset: dict[str, Any]) -> dict[str, Any]: @@ -119,7 +119,7 @@ def map_patchset_to_pr(patchset: dict[str, Any]) -> dict[str, Any]: thread = patchset.get("thread") or {} emails_conn = thread.get("emails") or {} edges = emails_conn.get("edges") or [] - emails = [edge.get("node") for edge in edges if edge.get("node")] + emails = [edge.get("node") for edge in edges if edge and edge.get("node")] # Sort emails by date if possible with contextlib.suppress(Exception): @@ -145,13 +145,14 @@ def map_patchset_to_pr(patchset: dict[str, Any]) -> dict[str, Any]: # Map patches inside the patchset to commits commits = [] for patch in patchset.get("patches") or []: - commits.append( - { - "id": str(patch.get("id")), - "subject": patch.get("subject", ""), - "diff": patch.get("diff", ""), - } - ) + if patch: + commits.append( + { + "id": str(patch.get("id")), + "subject": patch.get("subject", ""), + "diff": patch.get("diff", ""), + } + ) # Map replies (all emails except the first/cover letter) to review comments comments = [] diff --git a/tools/vcs/README.md b/tools/vcs/README.md index e00ed5a3..3e711806 100644 --- a/tools/vcs/README.md +++ b/tools/vcs/README.md @@ -89,18 +89,19 @@ not add its own prompt. | Backend | Status | Notes | |---|---|---| | `git` | **complete** | GitHub's native VCS; the default binding | -| `hg` (Mercurial) | extension point | detected; operations raise a clear error → [#601](https://github.com/apache/magpie/issues/601) | +| `hg` (Mercurial) | **complete** | Mercurial VCS support | | `svn` (Subversion) | extension point | detected; centralized model (`distributed = False`) → [#602](https://github.com/apache/magpie/issues/602) | Detection is real for every backend (so `magpie-vcs detect` reports the -working copy's VCS correctly); the non-Git backends raise an actionable +working copy's VCS correctly); the non-Git/non-Hg backends raise an actionable `VCSError` naming their tracking issue until the full binding lands. ### Adding a backend -A VCS bridge (e.g. #601 Mercurial) implements the full binding by +A VCS bridge (e.g. #602 Subversion) implements the full binding by replacing that backend's `_UnimplementedBackend` base with a concrete `VCSBackend` subclass — `detect()`, the read operations, the write + operations — and nothing else changes: detection, dispatch, the CLI, and every skill that calls `magpie-vcs` pick it up automatically. diff --git a/tools/vcs/src/magpie_vcs/__init__.py b/tools/vcs/src/magpie_vcs/__init__.py index 6686c89a..3c953b4a 100644 --- a/tools/vcs/src/magpie_vcs/__init__.py +++ b/tools/vcs/src/magpie_vcs/__init__.py @@ -20,8 +20,8 @@ This module extracts the abstraction documented in ``tools/github/source-control.md`` into runnable code: one abstract :class:`VCSBackend` interface listing the operations the dev-loop skills -need, a complete :class:`GitBackend`, and explicit extension points for the -non-Git VCS bridges (Mercurial #601, Subversion #602, Jujutsu #603, +need, a complete :class:`GitBackend`, a complete :class:`MercurialBackend`, and explicit extension points for the +non-Git/non-Hg VCS bridges (Subversion #602, Jujutsu #603, Fossil #604, Perforce #605). A skill calls the abstract operation (``magpie-vcs diff``) instead of a raw @@ -390,7 +390,7 @@ def diff(self, base: str | None = None, cached: bool = False, paths: Sequence[st if base: args.extend(["-r", base]) if paths: - args.extend(paths) + args.extend(["--", *paths]) return _run(args, self.root) def log( @@ -411,7 +411,7 @@ def log( if since: args.extend(["-d", f">= {since}"]) if paths: - args.extend(paths) + args.extend(["--", *paths]) return _run(args, self.root) def create_branch(self, name: str) -> None: From c0be2f31ec11db7cd4e3c8e62a965727f1b34b02 Mon Sep 17 00:00:00 2001 From: Arnav Date: Thu, 2 Jul 2026 18:29:41 +0530 Subject: [PATCH 12/14] test: commit hgignore in reset worktree test to ensure clean state Generated-by: Antigravity --- tools/vcs/tests/test_vcs.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tools/vcs/tests/test_vcs.py b/tools/vcs/tests/test_vcs.py index 7a6c9d44..20eb52ad 100644 --- a/tools/vcs/tests/test_vcs.py +++ b/tools/vcs/tests/test_vcs.py @@ -235,6 +235,9 @@ def test_hg_cached_diff_raises(hg_repo: Path) -> None: def test_hg_reset_worktree(hg_repo: Path) -> None: backend = MercurialBackend(hg_repo) (hg_repo / ".hgignore").write_text("ignored.txt\n") + backend.stage([".hgignore"]) + backend.commit("add hgignore") + (hg_repo / "file.txt").write_text("dirty\n") (hg_repo / "untracked.txt").write_text("junk\n") (hg_repo / "ignored.txt").write_text("ignored\n") @@ -246,6 +249,7 @@ def test_hg_reset_worktree(hg_repo: Path) -> None: + def test_registry_unique_names() -> None: names = [b.name for b in BACKENDS] assert names == sorted(set(names), key=names.index) From 634336a02eefaf88f214b312b28b9e386c87edf3 Mon Sep 17 00:00:00 2001 From: Arnav Date: Thu, 2 Jul 2026 18:35:20 +0530 Subject: [PATCH 13/14] fix: resolve Ruff B904 exception chaining and apply formatting Generated-by: Antigravity --- tools/sourcehut/src/magpie_sourcehut/cli.py | 1 - tools/sourcehut/src/magpie_sourcehut/client.py | 2 +- tools/sourcehut/tests/test_sourcehut.py | 1 - tools/vcs/src/magpie_vcs/__init__.py | 1 - tools/vcs/tests/test_vcs.py | 2 -- 5 files changed, 1 insertion(+), 6 deletions(-) diff --git a/tools/sourcehut/src/magpie_sourcehut/cli.py b/tools/sourcehut/src/magpie_sourcehut/cli.py index d6677060..d53e53f2 100644 --- a/tools/sourcehut/src/magpie_sourcehut/cli.py +++ b/tools/sourcehut/src/magpie_sourcehut/cli.py @@ -190,4 +190,3 @@ def main(argv: Sequence[str] | None = None) -> int: except (SourceHutError, json.JSONDecodeError) as e: print(f"magpie-sourcehut error: {e}", file=sys.stderr) return 2 - diff --git a/tools/sourcehut/src/magpie_sourcehut/client.py b/tools/sourcehut/src/magpie_sourcehut/client.py index fbf1758a..dc135f5a 100644 --- a/tools/sourcehut/src/magpie_sourcehut/client.py +++ b/tools/sourcehut/src/magpie_sourcehut/client.py @@ -84,7 +84,7 @@ def query_graphql(service: str, query: str, variables: dict[str, Any] | None = N pass if err_msg: - raise SourceHutError(err_msg) + raise SourceHutError(err_msg) from exc raise SourceHutError(f"HTTP request to {url} failed with status {exc.code}") from exc except urllib.error.URLError as exc: raise SourceHutError(f"Failed to connect to {url}: {exc.reason}") from exc diff --git a/tools/sourcehut/tests/test_sourcehut.py b/tools/sourcehut/tests/test_sourcehut.py index 203e7103..6718e1c0 100644 --- a/tools/sourcehut/tests/test_sourcehut.py +++ b/tools/sourcehut/tests/test_sourcehut.py @@ -236,4 +236,3 @@ def test_cli_label_error(capsys: pytest.CaptureFixture[str]) -> None: assert excinfo.value.code == 2 captured = capsys.readouterr() assert "At least one of --add or --remove must be specified" in captured.err - diff --git a/tools/vcs/src/magpie_vcs/__init__.py b/tools/vcs/src/magpie_vcs/__init__.py index 3c953b4a..0563db86 100644 --- a/tools/vcs/src/magpie_vcs/__init__.py +++ b/tools/vcs/src/magpie_vcs/__init__.py @@ -445,7 +445,6 @@ def reset_worktree(self) -> None: _run(["hg", "purge", "--config", "extensions.purge="], self.root, check=False, capture=False) - class SubversionBackend(_UnimplementedBackend): """Apache Subversion (SVN) extension point — see apache/magpie#602.""" diff --git a/tools/vcs/tests/test_vcs.py b/tools/vcs/tests/test_vcs.py index 20eb52ad..39fa4d49 100644 --- a/tools/vcs/tests/test_vcs.py +++ b/tools/vcs/tests/test_vcs.py @@ -248,8 +248,6 @@ def test_hg_reset_worktree(hg_repo: Path) -> None: assert (hg_repo / "ignored.txt").exists() # ignored files should be preserved - - def test_registry_unique_names() -> None: names = [b.name for b in BACKENDS] assert names == sorted(set(names), key=names.index) From cf2812d1d75bb8e01960e46417e78e3446337ca7 Mon Sep 17 00:00:00 2001 From: Arnav Date: Thu, 2 Jul 2026 18:52:33 +0530 Subject: [PATCH 14/14] style: remove consecutive blank lines to satisfy markdownlint Generated-by: Antigravity --- docs/vendor-neutrality.md | 1 - tools/sourcehut/README.md | 1 - 2 files changed, 2 deletions(-) diff --git a/docs/vendor-neutrality.md b/docs/vendor-neutrality.md index 969a8364..67d4e4b8 100644 --- a/docs/vendor-neutrality.md +++ b/docs/vendor-neutrality.md @@ -421,7 +421,6 @@ extension points are public and labelled, not hypothetical. (The Bitbucket and SourceHut forges, which carry their own VCS, are tracked under the forge axis above.) - ### 6. Project governance Vendor neutrality extends to *how a project is run*, not just to its diff --git a/tools/sourcehut/README.md b/tools/sourcehut/README.md index fa466c86..b3c45233 100644 --- a/tools/sourcehut/README.md +++ b/tools/sourcehut/README.md @@ -16,7 +16,6 @@ **Kind:** implementation **Vendor:** SourceHut - SourceHut (sr.ht) forge bridge implementation for the Apache Magpie framework. It integrates ticket tracking (`todo.sr.ht`), mailing list patchset review (`lists.sr.ht`), CI builds (`builds.sr.ht`), and repository reads (`git.sr.ht` & `hg.sr.ht`) using SourceHut's GraphQL APIs. ## Prerequisites