From ad3f8854c610b28f404bdc3e99648ea0a15b6ec9 Mon Sep 17 00:00:00 2001 From: Arnav Date: Wed, 1 Jul 2026 20:28:37 +0530 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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 From 6e494bd28ef811119ae9b9f85ae3c28317613eb6 Mon Sep 17 00:00:00 2001 From: Arnav Date: Fri, 3 Jul 2026 00:17:12 +0530 Subject: [PATCH 15/21] build(deps): update uv.lock with tools/fossil --- uv.lock | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/uv.lock b/uv.lock index 25bf297d..aacba764 100644 --- a/uv.lock +++ b/uv.lock @@ -21,6 +21,7 @@ members = [ "github-body-field", "github-rollup", "jira-bridge", + "magpie-fossil", "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-fossil" +version = "0.1.0" +source = { editable = "tools/fossil" } + [[package]] name = "magpie-vcs" version = "0.1.0" From cc211946199ffe214de9419dc778a9f2ae05ab26 Mon Sep 17 00:00:00 2001 From: Arnav Date: Fri, 3 Jul 2026 00:17:15 +0530 Subject: [PATCH 16/21] build: register tools/fossil as a workspace member --- pyproject.toml | 1 + tools/fossil/pyproject.toml | 80 +++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 tools/fossil/pyproject.toml diff --git a/pyproject.toml b/pyproject.toml index 1eb11333..b2e291ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -115,4 +115,5 @@ members = [ "tools/symlink-lint", "tools/vcs", "tools/vendor-neutrality-score", + "tools/fossil", ] diff --git a/tools/fossil/pyproject.toml b/tools/fossil/pyproject.toml new file mode 100644 index 00000000..7fae2426 --- /dev/null +++ b/tools/fossil/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-fossil" +version = "0.1.0" +description = "Fossil SCM forge and tracker bridge for Apache Magpie — provides version-control and built-in tracker/wiki/forum integration." +readme = "README.md" +requires-python = ">=3.11" +license = { text = "Apache-2.0" } +dependencies = [] + +[project.scripts] +magpie-fossil = "magpie_fossil:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/magpie_fossil"] + +[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 565f1e9103f44ef33d5dcbed1083c4d2b25fda2b Mon Sep 17 00:00:00 2001 From: Arnav Date: Fri, 3 Jul 2026 00:37:03 +0530 Subject: [PATCH 17/21] docs(tools/fossil): add README for Fossil SCM bridge --- tools/fossil/README.md | 53 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tools/fossil/README.md diff --git a/tools/fossil/README.md b/tools/fossil/README.md new file mode 100644 index 00000000..e8510947 --- /dev/null +++ b/tools/fossil/README.md @@ -0,0 +1,53 @@ + + +**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* + +- [Fossil Forge Bridge](#fossil-forge-bridge) + - [Prerequisites](#prerequisites) + - [Features](#features) + - [Invocation](#invocation) + - [Configuration](#configuration) + + + + + +# Fossil Forge Bridge + +**Capability:** contract:tracker + contract:source-control +**Kind:** implementation +**Vendor:** Fossil + +Fossil SCM forge and tracker bridge implementation for the Apache Magpie framework. It integrates version-control checks, ticket tracking, wiki reads, and forum thread reads. + +## Prerequisites + +- **Runtime:** Python 3.11+ run via `uv` (stdlib-only, no third-party dependencies). +- **CLIs:** `fossil` (for repository interactions). +- **Credentials / auth:** None (local-only; reads are direct SQL queries on the repository database). +- **Network:** Mostly offline. Requires the repository to be cloned locally, and reads/writes locally before syncs. + +## Features + +1. **VCS Repositories:** Standard version control operations via the `magpie-vcs` backend shim. +2. **Issue Tracker:** Read/write operations (create ticket, comment, update status, change fields) on the Fossil ticket subsystem. +3. **Wiki:** Read/list operations for wiki pages. +4. **Forum:** Read/list operations for forum posts and threads. + +## Invocation + +```bash +# Get ticket details +uv run --project tools/fossil magpie-fossil ticket get TICKET_UUID + +# Create comment on a ticket +uv run --project tools/fossil magpie-fossil ticket comment TICKET_UUID --body "Nice fix!" + +# List wiki pages +uv run --project tools/fossil magpie-fossil wiki list +``` + +## Configuration + +The bridge resolves the Fossil repository from the checkout directories (`.fslckg` or `_FOSSIL_`) or via the `-R/--repository` argument. From 0a93c9b72db5434c20f0e8f87e8af2786d2815f5 Mon Sep 17 00:00:00 2001 From: Arnav Date: Fri, 3 Jul 2026 00:37:07 +0530 Subject: [PATCH 18/21] docs: update registry, capabilities, and vendor neutrality with Fossil support --- .typos.toml | 2 ++ docs/adapters/registry.md | 4 ++-- docs/labels-and-capabilities.md | 1 + docs/vendor-neutrality.md | 8 ++++---- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.typos.toml b/.typos.toml index 6a88af88..52c62139 100644 --- a/.typos.toml +++ b/.typos.toml @@ -52,6 +52,8 @@ asert = "asert" # Lucene / Solr PMC member, listed in the MISSION.md roster). # typos flags it as a typo of `Drop`. Drob = "Drob" +# `firt` is Fossil's literal database column name for "forum in reply to" +firt = "firt" [default.extend-identifiers] # Identifiers that look like typos but are real symbol names. diff --git a/docs/adapters/registry.md b/docs/adapters/registry.md index 09885a74..1990c816 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, Fossil | 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), Perforce [#605](https://github.com/apache/magpie/issues/605) | +| Forge / tracker | [`github`](../../tools/github/), [`jira`](../../tools/jira/), [`fossil`](../../tools/fossil/) | 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) | | 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..c861f198 100644 --- a/docs/labels-and-capabilities.md +++ b/docs/labels-and-capabilities.md @@ -238,6 +238,7 @@ it implements multiple contracts (e.g. `tools/gmail` provides both | [`tools/dev`](../tools/dev/) | `substrate:framework-dev` | Framework dev-loop helpers | | [`tools/egress-gateway`](../tools/egress-gateway/) | `substrate:sandbox` | Egress-allowlist forward proxy (proxy.py plugin); host-level egress chokepoint — defence-in-depth for RFC-AI-0003 §4.4 | | [`tools/forwarder-relay`](../tools/forwarder-relay/) | `contract:report-relay` | Adapter contract for inbound-relay backends (ASF Security relay, huntr.com, HackerOne triagers). Pure interface spec; adapters declare detection + credit-extraction + reporter-addressing rules. | +| [`tools/fossil`](../tools/fossil/) | `contract:tracker` + `contract:source-control` | Fossil SCM forge bridge: integrates local SQLite-backed ticket tracking, wiki, and forum reads with the version-control shim | | [`tools/github`](../tools/github/) | `contract:tracker` + `contract:source-control` | GitHub REST / GraphQL tracker substrate (called by every lifecycle phase) plus the Git source-control binding documented in [`source-control.md`](../tools/github/source-control.md) (runnable backend in [`tools/vcs`](../tools/vcs/)) | | [`tools/github-body-field`](../tools/github-body-field/) | `contract:tracker` | Read or rewrite one `### Field` section of a GitHub issue body without bringing the body into agent context — substrate helper for the security-sync skills | | [`tools/github-rollup`](../tools/github-rollup/) | `contract:tracker` | Append to (or create) the status-rollup comment on a GitHub issue without bringing the rollup body into agent context — substrate helper for every status-update-emitting skill | diff --git a/docs/vendor-neutrality.md b/docs/vendor-neutrality.md index c69c9405..1a291aeb 100644 --- a/docs/vendor-neutrality.md +++ b/docs/vendor-neutrality.md @@ -484,9 +484,9 @@ 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, Fossil; 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) | | 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)**, **Fossil (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), 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; @@ -537,8 +537,8 @@ 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:tracker` | ✅ | vendor-backed | Atlassian, Fossil, GitHub | 3 backend vendors: Atlassian, Fossil, GitHub | +| `contract:source-control` | ✅ | vendor-backed | Fossil, Git, GitHub, Subversion | 4 backend vendors: Fossil, Git, GitHub, Subversion | | `contract:mail-archive` | ✅ | vendor-backed | Google, PonyMail | 2 backend vendors: Google, PonyMail | | `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 | From cb1be4df4921ce50a52de1be59d2fcf271c15c48 Mon Sep 17 00:00:00 2001 From: Arnav Date: Fri, 3 Jul 2026 00:37:10 +0530 Subject: [PATCH 19/21] feat(tools/fossil): implement Fossil SCM source code --- tools/fossil/src/magpie_fossil/__init__.py | 24 +++ tools/fossil/src/magpie_fossil/cli.py | 162 +++++++++++++++++++++ tools/fossil/src/magpie_fossil/client.py | 93 ++++++++++++ tools/fossil/src/magpie_fossil/forum.py | 134 +++++++++++++++++ tools/fossil/src/magpie_fossil/ticket.py | 116 +++++++++++++++ tools/fossil/src/magpie_fossil/wiki.py | 35 +++++ tools/vcs/src/magpie_vcs/__init__.py | 160 +++++++++++++++++++- 7 files changed, 721 insertions(+), 3 deletions(-) create mode 100644 tools/fossil/src/magpie_fossil/__init__.py create mode 100644 tools/fossil/src/magpie_fossil/cli.py create mode 100644 tools/fossil/src/magpie_fossil/client.py create mode 100644 tools/fossil/src/magpie_fossil/forum.py create mode 100644 tools/fossil/src/magpie_fossil/ticket.py create mode 100644 tools/fossil/src/magpie_fossil/wiki.py diff --git a/tools/fossil/src/magpie_fossil/__init__.py b/tools/fossil/src/magpie_fossil/__init__.py new file mode 100644 index 00000000..60d82822 --- /dev/null +++ b/tools/fossil/src/magpie_fossil/__init__.py @@ -0,0 +1,24 @@ +# 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. + +"""Fossil SCM forge and tracker bridge.""" + +from __future__ import annotations + +from magpie_fossil.cli import main + +__all__ = ["main"] diff --git a/tools/fossil/src/magpie_fossil/cli.py b/tools/fossil/src/magpie_fossil/cli.py new file mode 100644 index 00000000..3de44ddd --- /dev/null +++ b/tools/fossil/src/magpie_fossil/cli.py @@ -0,0 +1,162 @@ +# 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-fossil.""" + +from __future__ import annotations + +import argparse +import json +import sys +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +from magpie_fossil import forum, ticket, wiki +from magpie_fossil.client import FossilError, find_repo_db + + +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-fossil", + description="Fossil SCM forge and tracker bridge capability wrapper.", + ) + parser.add_argument( + "-C", "--cwd", default=".", help="Checkout directory to search for repository database." + ) + parser.add_argument("-R", "--repository", help="Direct path to the Fossil repository database file.") + + subparsers = parser.add_subparsers(dest="subcommand", required=True) + + # tickets + p_ticket = subparsers.add_parser("ticket", help="Interact with Fossil tickets.") + t_sub = p_ticket.add_subparsers(dest="action", required=True) + + t_sub.add_parser("list", help="List all tickets.") + + p_t_get = t_sub.add_parser("get", help="Get details of a ticket.") + p_t_get.add_argument("uuid", help="Ticket UUID (or prefix).") + + p_t_create = t_sub.add_parser("create", help="Create a ticket.") + 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_create.add_argument( + "--field", action="append", nargs=2, metavar=("NAME", "VALUE"), help="Extra ticket field value pairs." + ) + + p_t_edit = t_sub.add_parser("edit", help="Update ticket fields.") + p_t_edit.add_argument("uuid", help="Ticket UUID (or prefix).") + p_t_edit.add_argument( + "--field", + action="append", + nargs=2, + required=True, + metavar=("NAME", "VALUE"), + help="Ticket field value pairs to update.", + ) + + p_t_comment = t_sub.add_parser("comment", help="Comment on a ticket.") + p_t_comment.add_argument("uuid", help="Ticket UUID (or prefix).") + p_t_comment.add_argument("--body", required=True, help="Comment body.") + + # wiki + p_wiki = subparsers.add_parser("wiki", help="Interact with Fossil wiki.") + w_sub = p_wiki.add_subparsers(dest="action", required=True) + + w_sub.add_parser("list", help="List all wiki page names.") + p_w_read = w_sub.add_parser("read", help="Read a wiki page.") + p_w_read.add_argument("name", help="Wiki page name.") + + # forum + p_forum = subparsers.add_parser("forum", help="Interact with Fossil forums.") + f_sub = p_forum.add_subparsers(dest="action", required=True) + + f_sub.add_parser("list", help="List all forum threads.") + p_f_read = f_sub.add_parser("read", help="Read all posts in a forum thread.") + p_f_read.add_argument("thread_uuid", help="Thread root UUID.") + + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = _build_parser() + ns = parser.parse_args(argv) + + # Resolve repository file path + repo_path: Path | None = None + if ns.repository: + repo_path = Path(ns.repository).resolve() + else: + start_dir = Path(ns.cwd).resolve() + repo_path = find_repo_db(start_dir) + + if not repo_path or not repo_path.exists(): + print( + "Error: Fossil repository database file not found. " + "Ensure you are running inside a Fossil checkout or specify the database path with -R/--repository.", + file=sys.stderr, + ) + return 1 + + res: Any = None + try: + if ns.subcommand == "ticket": + if ns.action == "list": + res = ticket.list_tickets(repo_path) + _print_json(res) + elif ns.action == "get": + res = ticket.get_ticket(repo_path, ns.uuid) + _print_json(res) + elif ns.action == "create": + fields = dict(ns.field) if ns.field else {} + uuid = ticket.submit_ticket(repo_path, ns.title, ns.body, fields) + _print_json({"tkt_uuid": uuid}) + elif ns.action == "edit": + fields = dict(ns.field) + uuid = ticket.update_ticket_fields(repo_path, ns.uuid, fields) + _print_json({"tkt_uuid": uuid}) + elif ns.action == "comment": + uuid = ticket.submit_comment(repo_path, ns.uuid, ns.body) + _print_json({"tkt_uuid": uuid}) + + elif ns.subcommand == "wiki": + if ns.action == "list": + res = wiki.list_wiki(repo_path) + _print_json(res) + elif ns.action == "read": + res = wiki.read_wiki(repo_path, ns.name) + # Output page content as plain string + sys.stdout.write(res) + + elif ns.subcommand == "forum": + if ns.action == "list": + res = forum.list_forum_threads(repo_path) + _print_json(res) + elif ns.action == "read": + res = forum.read_forum_thread(repo_path, ns.thread_uuid) + _print_json(res) + + except FossilError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 2 + + return 0 diff --git a/tools/fossil/src/magpie_fossil/client.py b/tools/fossil/src/magpie_fossil/client.py new file mode 100644 index 00000000..de198453 --- /dev/null +++ b/tools/fossil/src/magpie_fossil/client.py @@ -0,0 +1,93 @@ +# 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. + +"""Fossil repository client and SQLite helpers.""" + +from __future__ import annotations + +import os +import sqlite3 +import subprocess +from collections.abc import Sequence +from pathlib import Path +from typing import Any + + +class FossilError(Exception): + """General exception for Fossil bridge errors.""" + + +def clean_env() -> dict[str, str]: + """Clean location-redirecting env variables.""" + return { + k: v + for k, v in os.environ.items() + if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR", "GIT_PREFIX") + } + + +def run_fossil(args: Sequence[str], cwd: Path | None = None) -> str: + """Run a fossil command, returning its stdout.""" + try: + proc = subprocess.run( + ["fossil", *args], + cwd=cwd, + text=True, + capture_output=True, + check=False, + env=clean_env(), + ) + except FileNotFoundError as exc: + raise FossilError("fossil command not found. Ensure Fossil SCM is installed.") from exc + + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or "").strip() + raise FossilError(f"fossil command failed (rc={proc.returncode}): {detail}") + return proc.stdout + + +def find_repo_db(start_dir: Path) -> Path | None: + """Resolve the Fossil repository database path from the checkout.""" + for d in (start_dir, *start_dir.parents): + for marker in (".fslckg", "_FOSSIL_"): + db_path = d / marker + if db_path.exists(): + try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + cursor.execute("SELECT value FROM vvar WHERE name = 'repository'") + row = cursor.fetchone() + conn.close() + if row: + return Path(row[0]) + except sqlite3.Error: + pass + return None + + +def query_db(repo_path: Path, query: str, params: Sequence[Any] = ()) -> list[dict[str, Any]]: + """Execute a read-only SQL query against the Fossil SQLite database.""" + try: + conn = sqlite3.connect(repo_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute(query, params) + rows = cursor.fetchall() + conn.close() + return [dict(r) for r in rows] + except sqlite3.Error as exc: + raise FossilError(f"Failed to query Fossil SQLite database: {exc}") from exc diff --git a/tools/fossil/src/magpie_fossil/forum.py b/tools/fossil/src/magpie_fossil/forum.py new file mode 100644 index 00000000..cd97769b --- /dev/null +++ b/tools/fossil/src/magpie_fossil/forum.py @@ -0,0 +1,134 @@ +# 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. + +"""Fossil forum subsystem integration.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from magpie_fossil.client import FossilError, query_db, run_fossil + + +def parse_forum_artifact(content: str) -> dict[str, Any]: + """Parse a Fossil forum post artifact manifest.""" + lines = content.splitlines() + author = "anonymous" + date = "" + parent = None + title = "" + body_lines = [] + + in_payload = False + for line in lines: + if in_payload: + body_lines.append(line) + elif line.startswith("U "): + author = line[2:].strip() + elif line.startswith("D "): + date = line[2:].strip().replace("T", " ") + elif line.startswith("I "): + parent = line[2:].strip() + elif line.startswith("W "): + title = line[2:].strip() + elif line.startswith("Z "): + in_payload = True + + return { + "author": author, + "date": date, + "parent": parent, + "title": title, + "body": "\n".join(body_lines).strip(), + } + + +def list_forum_threads(repo_path: Path) -> list[dict[str, Any]]: + """List all forum threads in the repository.""" + # Find distinct thread root IDs and their UUIDs + rows = query_db( + repo_path, + """ + SELECT DISTINCT forumpost.froot AS root_id, blob.uuid AS root_uuid + FROM forumpost + JOIN blob ON forumpost.froot = blob.rid + ORDER BY forumpost.fmtime DESC + """, + ) + + threads = [] + for r in rows: + root_uuid = r["root_uuid"] + try: + art_text = run_fossil(["artifact", root_uuid, "-R", str(repo_path)]) + parsed = parse_forum_artifact(art_text) + threads.append( + { + "thread_uuid": root_uuid, + "title": parsed["title"] or "Untitled Thread", + "author": parsed["author"], + "date": parsed["date"], + } + ) + except Exception: + pass + return threads + + +def read_forum_thread(repo_path: Path, thread_uuid: str) -> list[dict[str, Any]]: + """Read all posts in a specific forum thread.""" + # Resolve the root integer ID from the UUID + r_rows = query_db(repo_path, "SELECT rid FROM blob WHERE uuid = ?", (thread_uuid,)) + if not r_rows: + raise FossilError(f"Thread root artifact with UUID '{thread_uuid}' not found.") + root_id = r_rows[0]["rid"] + + # Fetch all posts in the thread + rows = query_db( + repo_path, + """ + SELECT forumpost.fpid, blob.uuid, forumpost.firt, blob_parent.uuid AS parent_uuid + FROM forumpost + JOIN blob ON forumpost.fpid = blob.rid + LEFT JOIN blob blob_parent ON forumpost.firt = blob_parent.rid + WHERE forumpost.froot = ? + ORDER BY forumpost.fmtime ASC + """, + (root_id,), + ) + + posts = [] + for r in rows: + post_uuid = r["uuid"] + parent_uuid = r["parent_uuid"] + try: + art_text = run_fossil(["artifact", post_uuid, "-R", str(repo_path)]) + parsed = parse_forum_artifact(art_text) + posts.append( + { + "post_uuid": post_uuid, + "parent_uuid": parent_uuid, + "author": parsed["author"], + "date": parsed["date"], + "title": parsed["title"], + "body": parsed["body"], + } + ) + except Exception: + pass + return posts diff --git a/tools/fossil/src/magpie_fossil/ticket.py b/tools/fossil/src/magpie_fossil/ticket.py new file mode 100644 index 00000000..4edbdcd6 --- /dev/null +++ b/tools/fossil/src/magpie_fossil/ticket.py @@ -0,0 +1,116 @@ +# 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. + +"""Fossil ticket subsystem integration.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from magpie_fossil.client import FossilError, query_db, run_fossil + + +def get_ticket(repo_path: Path, tkt_uuid: str) -> dict[str, Any]: + """Retrieve ticket details and comments by UUID prefix.""" + # Find the ticket uuid match + rows = query_db(repo_path, "SELECT * FROM ticket WHERE tkt_uuid LIKE ?", (tkt_uuid + "%",)) + if not rows: + raise FossilError(f"Ticket with UUID prefix '{tkt_uuid}' not found.") + if len(rows) > 1: + raise FossilError(f"Ambiguous UUID prefix '{tkt_uuid}': matched {len(rows)} tickets.") + + tkt = rows[0] + + # Try fetching comments from the ticket_chng table if it exists + comments = [] + try: + chng_rows = query_db( + repo_path, "SELECT * FROM ticket_chng WHERE tkt_id = ? ORDER BY tkt_mtime ASC", (tkt["tkt_id"],) + ) + for idx, chng in enumerate(chng_rows): + # Check fields that commonly represent a comment or change note + c_text = chng.get("comment") or chng.get("comment_text") or chng.get("c_text") + user = chng.get("login") or chng.get("user") or chng.get("username") or "anonymous" + mtime = chng.get("tkt_mtime") or chng.get("mtime") + if c_text: + comments.append({"id": idx + 1, "body": c_text, "author": user, "date": mtime}) + except Exception: + # If ticket_chng is missing/different, we still return the ticket without history comments + pass + + # If the main ticket body has a comment, and no change comments were found, + # we can treat that as the initial comment + main_comment = tkt.get("comment") or tkt.get("description") + if main_comment and not comments: + comments.append( + { + "id": 1, + "body": main_comment, + "author": tkt.get("username") or tkt.get("login") or "anonymous", + "date": tkt.get("tkt_mtime") or tkt.get("mtime"), + } + ) + + tkt_dict = dict(tkt) + tkt_dict["comments"] = comments + return tkt_dict + + +def list_tickets(repo_path: Path) -> list[dict[str, Any]]: + """List all tickets in the repository.""" + return query_db( + repo_path, + "SELECT tkt_id, tkt_uuid, title, status, type, severity, priority, mtime FROM ticket ORDER BY mtime DESC", + ) + + +def submit_ticket(repo_path: Path, title: str, body: str, extra_fields: dict[str, str] | None = None) -> str: + """Create a new ticket using Fossil CLI.""" + args = ["ticket", "add", "title", title, "comment", body] + if extra_fields: + for k, v in extra_fields.items(): + args.extend([k, v]) + args.extend(["-R", str(repo_path)]) + + # Fossil ticket add outputs: "Created new ticket " + out = run_fossil(args) + # Extract UUID + for word in out.split(): + if len(word) >= 12 and all(c in "0123456789abcdefABCDEF" for c in word): + return word + return out.strip() + + +def update_ticket_fields(repo_path: Path, tkt_uuid: str, fields: dict[str, str]) -> str: + """Update fields on an existing ticket.""" + # First resolve full UUID to be safe + tkt = get_ticket(repo_path, tkt_uuid) + full_uuid = tkt["tkt_uuid"] + + args = ["ticket", "set", full_uuid] + for k, v in fields.items(): + args.extend([k, v]) + args.extend(["-R", str(repo_path)]) + + run_fossil(args) + return full_uuid + + +def submit_comment(repo_path: Path, tkt_uuid: str, body: str) -> str: + """Add a comment to an existing ticket.""" + return update_ticket_fields(repo_path, tkt_uuid, {"+comment": body}) diff --git a/tools/fossil/src/magpie_fossil/wiki.py b/tools/fossil/src/magpie_fossil/wiki.py new file mode 100644 index 00000000..b5c57d13 --- /dev/null +++ b/tools/fossil/src/magpie_fossil/wiki.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. + +"""Fossil wiki subsystem integration.""" + +from __future__ import annotations + +from pathlib import Path + +from magpie_fossil.client import run_fossil + + +def list_wiki(repo_path: Path) -> list[str]: + """List all wiki page names in the repository.""" + out = run_fossil(["wiki", "list", "-R", str(repo_path)]) + return [line.strip() for line in out.splitlines() if line.strip()] + + +def read_wiki(repo_path: Path, name: str) -> str: + """Read content of a specific wiki page.""" + return run_fossil(["wiki", "export", name, "-R", str(repo_path)]) diff --git a/tools/vcs/src/magpie_vcs/__init__.py b/tools/vcs/src/magpie_vcs/__init__.py index e797fae0..0011c4d7 100644 --- a/tools/vcs/src/magpie_vcs/__init__.py +++ b/tools/vcs/src/magpie_vcs/__init__.py @@ -20,9 +20,9 @@ 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, -Fossil #604, Perforce #605). +need, a complete :class:`GitBackend`, a complete :class:`FossilBackend`, and +explicit extension points for the non-Git/non-Fossil VCS bridges +(Mercurial #601, Subversion #602, Jujutsu #603, Perforce #605). A skill calls the abstract operation (``magpie-vcs diff``) instead of a raw ``git`` command; the active backend is detected from the working copy (or @@ -42,6 +42,7 @@ __all__ = [ "BACKENDS", + "FossilBackend", "GitBackend", "MercurialBackend", "SubversionBackend", @@ -169,6 +170,10 @@ def push(self, remote: str, ref: str, set_upstream: bool = False) -> None: def reset_worktree(self) -> None: """Discard all uncommitted changes (the per-run reset protocol).""" + @abstractmethod + def cat(self, rev: str, path: str) -> str: + """Return the contents of *path* at revision *rev*.""" + def _run( args: Sequence[str], @@ -289,6 +294,9 @@ def reset_worktree(self) -> None: _run(["git", "clean", "-fd"], self.root, capture=False) _run(["git", "checkout", "--", "."], self.root, capture=False) + def cat(self, rev: str, path: str) -> str: + return _run(["git", "show", f"{rev}:{path}"], self.root) + class _UnimplementedBackend(VCSBackend): """Shared base for detected-but-not-yet-implemented non-Git backends. @@ -355,6 +363,9 @@ def push(self, remote: str, ref: str, set_upstream: bool = False) -> None: def reset_worktree(self) -> None: raise self._unsupported("reset_worktree") + def cat(self, rev: str, path: str) -> str: + raise self._unsupported("cat") + class MercurialBackend(_UnimplementedBackend): """Mercurial (Hg) extension point — see apache/magpie#601.""" @@ -365,6 +376,141 @@ class MercurialBackend(_UnimplementedBackend): issue = "apache/magpie#601" +class FossilBackend(VCSBackend): + """Fossil SCM binding of the source-control capability.""" + + name = "fossil" + distributed = True + + @classmethod + def detect(cls, start: Path) -> Path | None: + for d in (start, *start.parents): + if (d / ".fslckg").exists() or (d / "_FOSSIL_").exists(): + return d + return None + + @classmethod + def is_available(cls) -> bool: + try: + subprocess.run(["fossil", "version"], capture_output=True, check=True) + except (OSError, subprocess.CalledProcessError): + return False + return True + + def status(self) -> str: + try: + out = _run(["fossil", "changes"], self.root) + except VCSError: + out = "" + try: + extras = _run(["fossil", "extras"], self.root) + except VCSError: + extras = "" + + lines = [] + for line in out.splitlines(): + parts = line.split(None, 1) + if len(parts) == 2: + status_type, path = parts + status_char = "M" + if "ADDED" in status_type: + status_char = "A" + elif "DELETED" in status_type: + status_char = "D" + lines.append(f"{status_char} {path}") + for path in extras.splitlines(): + if path.strip(): + lines.append(f"? {path.strip()}") + return "\n".join(lines) + ("\n" if lines else "") + + def current_branch(self) -> str: + return _run(["fossil", "branch", "current"], self.root).strip() + + def diff(self, base: str | None = None, cached: bool = False, paths: Sequence[str] = ()) -> str: + if cached: + raise VCSError("fossil does not support staging area/cached diff") + args = ["fossil", "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 = ["fossil", "timeline", "checkin"] + if max_count is not None: + args.extend(["-n", str(max_count)]) + if author: + args.extend(["-u", author]) + if paths: + args.extend(["-p", paths[0]]) + + out = _run(args, self.root) + + lines = [] + for line in out.splitlines(): + line = line.strip() + if not line: + continue + if line.startswith("===") and line.endswith("==="): + continue + if " [" in line and "] " in line: + parts = line.split(" [", 1) + if len(parts) == 2: + h_parts = parts[1].split("] ", 1) + if len(h_parts) == 2: + rev_hash = h_parts[0] + comment = h_parts[1] + if " (user: " in comment: + comment = comment.rsplit(" (user: ", 1)[0] + if grep and grep.lower() not in comment.lower(): + continue + lines.append(f"{rev_hash} {comment}") + return "\n".join(lines) + ("\n" if lines else "") + + def create_branch(self, name: str) -> None: + basis = self.current_branch() + _run(["fossil", "branch", "new", name, basis], self.root, capture=False) + _run(["fossil", "update", name], self.root, capture=False) + + def switch(self, ref: str) -> None: + _run(["fossil", "update", ref], self.root, capture=False) + + def stage(self, paths: Sequence[str]) -> None: + if not paths: + raise VCSError("stage: refusing to stage nothing") + _run(["fossil", "add", "--", *paths], self.root, capture=False) + + def commit(self, message: str) -> None: + _run(["fossil", "commit", "-m", message, "--no-warnings"], self.root, capture=False) + + def fetch(self, remote: str | None = None, ref: str | None = None) -> None: + args = ["fossil", "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 = ["fossil", "push"] + if remote: + args.append(remote) + _run(args, self.root, capture=False) + + def reset_worktree(self) -> None: + _run(["fossil", "revert"], self.root, check=False, capture=False) + _run(["fossil", "clean", "--force"], self.root, check=False, capture=False) + + def cat(self, rev: str, path: str) -> str: + return _run(["fossil", "cat", path, "-r", rev], self.root) + + class SubversionBackend(_UnimplementedBackend): """Apache Subversion (SVN) extension point — see apache/magpie#602.""" @@ -378,6 +524,7 @@ class SubversionBackend(_UnimplementedBackend): BACKENDS: tuple[type[VCSBackend], ...] = ( GitBackend, MercurialBackend, + FossilBackend, SubversionBackend, ) @@ -471,6 +618,11 @@ def _build_parser() -> argparse.ArgumentParser: p.add_argument("ref") sub.add_parser("reset-worktree", help="discard all uncommitted changes") + + p = sub.add_parser("cat", help="print file content at a specific revision") + p.add_argument("rev", help="revision or ref") + p.add_argument("path", help="file path relative to repository root") + return parser @@ -525,6 +677,8 @@ def main(argv: Sequence[str] | None = None) -> int: backend.push(ns.remote, ns.ref, set_upstream=ns.set_upstream) elif ns.op == "reset-worktree": backend.reset_worktree() + elif ns.op == "cat": + sys.stdout.write(backend.cat(ns.rev, ns.path)) else: # pragma: no cover - argparse enforces the choices parser.error(f"unknown operation {ns.op!r}") except VCSError as exc: From dc442490a2290237f155f8c9c6d5ec0b5575407d Mon Sep 17 00:00:00 2001 From: Arnav Date: Fri, 3 Jul 2026 00:37:16 +0530 Subject: [PATCH 20/21] test(tools/fossil): add unit tests for Fossil backend and bridge --- tools/fossil/tests/test_fossil.py | 213 ++++++++++++++++++++++++++++++ tools/vcs/tests/test_vcs.py | 104 +++++++++++++-- 2 files changed, 308 insertions(+), 9 deletions(-) create mode 100644 tools/fossil/tests/test_fossil.py diff --git a/tools/fossil/tests/test_fossil.py b/tools/fossil/tests/test_fossil.py new file mode 100644 index 00000000..c889e3c3 --- /dev/null +++ b/tools/fossil/tests/test_fossil.py @@ -0,0 +1,213 @@ +# 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. + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from magpie_fossil.cli import main +from magpie_fossil.client import FossilError +from magpie_fossil.forum import list_forum_threads, parse_forum_artifact, read_forum_thread +from magpie_fossil.ticket import get_ticket, list_tickets, submit_comment, submit_ticket +from magpie_fossil.wiki import list_wiki, read_wiki + +# -- manifest parser tests ------------------------------------------------- + + +def test_parse_forum_artifact() -> None: + manifest = ( + "U alice\n" + "D 2026-07-02T12:34:56.789\n" + "W Thread Title\n" + "Z 1234567890abcdef\n" + "This is the forum body.\n" + "Line two.\n" + ) + parsed = parse_forum_artifact(manifest) + assert parsed["author"] == "alice" + assert parsed["date"] == "2026-07-02 12:34:56.789" + assert parsed["title"] == "Thread Title" + assert parsed["parent"] is None + assert parsed["body"] == "This is the forum body.\nLine two." + + +def test_parse_forum_artifact_reply() -> None: + manifest = ( + "U bob\nD 2026-07-02T13:45:00.000\nI root_uuid_123\nZ abcdef1234567890\nThis is a reply body.\n" + ) + parsed = parse_forum_artifact(manifest) + assert parsed["author"] == "bob" + assert parsed["date"] == "2026-07-02 13:45:00.000" + assert parsed["title"] == "" + assert parsed["parent"] == "root_uuid_123" + assert parsed["body"] == "This is a reply body." + + +# -- ticket tests ---------------------------------------------------------- + + +@patch("magpie_fossil.ticket.query_db") +def test_get_ticket_success(mock_query_db: MagicMock) -> None: + mock_query_db.side_effect = [ + [ + { + "tkt_id": 1, + "tkt_uuid": "abcdef123456", + "title": "Memory leak", + "status": "Open", + "comment": "Original body", + } + ], + [{"comment": "Fix in progress", "login": "alice", "tkt_mtime": "2026-07-02 12:00:00"}], + ] + tkt = get_ticket(Path("repo.fossil"), "abc") + assert tkt["tkt_id"] == 1 + assert tkt["tkt_uuid"] == "abcdef123456" + assert len(tkt["comments"]) == 1 + assert tkt["comments"][0]["body"] == "Fix in progress" + assert tkt["comments"][0]["author"] == "alice" + + +@patch("magpie_fossil.ticket.query_db") +def test_get_ticket_not_found(mock_query_db: MagicMock) -> None: + mock_query_db.return_value = [] + with pytest.raises(FossilError, match="not found"): + get_ticket(Path("repo.fossil"), "abc") + + +@patch("magpie_fossil.ticket.query_db") +def test_get_ticket_ambiguous(mock_query_db: MagicMock) -> None: + mock_query_db.return_value = [{"tkt_uuid": "abc1"}, {"tkt_uuid": "abc2"}] + with pytest.raises(FossilError, match="Ambiguous UUID prefix"): + get_ticket(Path("repo.fossil"), "abc") + + +@patch("magpie_fossil.ticket.query_db") +def test_list_tickets(mock_query_db: MagicMock) -> None: + mock_query_db.return_value = [{"tkt_id": 1, "title": "T1"}] + res = list_tickets(Path("repo.fossil")) + assert len(res) == 1 + assert res[0]["title"] == "T1" + + +@patch("magpie_fossil.ticket.run_fossil") +def test_submit_ticket(mock_run_fossil: MagicMock) -> None: + mock_run_fossil.return_value = "Created new ticket 1234567890abcdef12" + uuid = submit_ticket(Path("repo.fossil"), "T1", "Body", {"type": "Bug"}) + assert uuid == "1234567890abcdef12" + mock_run_fossil.assert_called_once_with( + ["ticket", "add", "title", "T1", "comment", "Body", "type", "Bug", "-R", "repo.fossil"] + ) + + +@patch("magpie_fossil.ticket.run_fossil") +@patch("magpie_fossil.ticket.query_db") +def test_submit_comment(mock_query_db: MagicMock, mock_run_fossil: MagicMock) -> None: + mock_query_db.return_value = [{"tkt_id": 1, "tkt_uuid": "abcdef123456"}] + mock_run_fossil.return_value = "" + uuid = submit_comment(Path("repo.fossil"), "abc", "My comment") + assert uuid == "abcdef123456" + mock_run_fossil.assert_called_once_with( + ["ticket", "set", "abcdef123456", "+comment", "My comment", "-R", "repo.fossil"] + ) + + +# -- wiki tests ------------------------------------------------------------ + + +@patch("magpie_fossil.wiki.run_fossil") +def test_list_wiki(mock_run_fossil: MagicMock) -> None: + mock_run_fossil.return_value = "Home\nDocs\n" + pages = list_wiki(Path("repo.fossil")) + assert pages == ["Home", "Docs"] + mock_run_fossil.assert_called_once_with(["wiki", "list", "-R", "repo.fossil"]) + + +@patch("magpie_fossil.wiki.run_fossil") +def test_read_wiki(mock_run_fossil: MagicMock) -> None: + mock_run_fossil.return_value = "# Welcome" + content = read_wiki(Path("repo.fossil"), "Home") + assert content == "# Welcome" + mock_run_fossil.assert_called_once_with(["wiki", "export", "Home", "-R", "repo.fossil"]) + + +# -- forum tests ----------------------------------------------------------- + + +@patch("magpie_fossil.forum.run_fossil") +@patch("magpie_fossil.forum.query_db") +def test_list_forum_threads(mock_query_db: MagicMock, mock_run_fossil: MagicMock) -> None: + mock_query_db.return_value = [{"root_uuid": "uuid1"}] + mock_run_fossil.return_value = "U alice\nD 2026-07-02T12:00:00.000\nW Thread 1\nZ checksum\nBody content" + threads = list_forum_threads(Path("repo.fossil")) + assert len(threads) == 1 + assert threads[0]["title"] == "Thread 1" + assert threads[0]["author"] == "alice" + + +@patch("magpie_fossil.forum.run_fossil") +@patch("magpie_fossil.forum.query_db") +def test_read_forum_thread(mock_query_db: MagicMock, mock_run_fossil: MagicMock) -> None: + mock_query_db.side_effect = [[{"rid": 10}], [{"uuid": "uuid1", "parent_uuid": None}]] + mock_run_fossil.return_value = "U bob\nD 2026-07-02T12:00:00.000\nW Thread 1\nZ checksum\nPost content" + posts = read_forum_thread(Path("repo.fossil"), "uuid1") + assert len(posts) == 1 + assert posts[0]["author"] == "bob" + assert posts[0]["body"] == "Post content" + + +# -- CLI and client tests -------------------------------------------------- + + +@patch("magpie_fossil.cli.find_repo_db") +@patch("magpie_fossil.cli.ticket.list_tickets") +def test_cli_ticket_list( + mock_list: MagicMock, mock_find: MagicMock, capsys: pytest.CaptureFixture[str] +) -> None: + mock_find.return_value = Path("repo.fossil") + mock_list.return_value = [{"tkt_uuid": "uuid1"}] + + with patch("pathlib.Path.exists", return_value=True): + code = main(["ticket", "list"]) + assert code == 0 + captured = capsys.readouterr() + assert "uuid1" in captured.out + + +@patch("magpie_fossil.cli.find_repo_db") +def test_cli_no_repo(mock_find: MagicMock, capsys: pytest.CaptureFixture[str]) -> None: + mock_find.return_value = None + code = main(["ticket", "list"]) + assert code == 1 + captured = capsys.readouterr() + assert "Fossil repository database file not found" in captured.err + + +@patch("magpie_fossil.cli.find_repo_db") +@patch("magpie_fossil.cli.wiki.read_wiki") +def test_cli_wiki_read( + mock_read: MagicMock, mock_find: MagicMock, capsys: pytest.CaptureFixture[str] +) -> None: + mock_find.return_value = Path("repo.fossil") + mock_read.return_value = "# Markdown Content" + + with patch("pathlib.Path.exists", return_value=True): + code = main(["wiki", "read", "Home"]) + assert code == 0 + captured = capsys.readouterr() + assert captured.out == "# Markdown Content" diff --git a/tools/vcs/tests/test_vcs.py b/tools/vcs/tests/test_vcs.py index 6d855b01..eaab7935 100644 --- a/tools/vcs/tests/test_vcs.py +++ b/tools/vcs/tests/test_vcs.py @@ -23,6 +23,7 @@ from magpie_vcs import ( BACKENDS, + FossilBackend, GitBackend, MercurialBackend, SubversionBackend, @@ -41,12 +42,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 +66,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 ------------------------------------------------------------- @@ -70,16 +89,25 @@ def test_detect_git(git_repo: Path) -> None: assert backend.root == git_repo -def test_detect_marks_hg_and_svn(tmp_path: Path) -> None: +def test_detect_marks_hg_svn_fossil(tmp_path: Path) -> None: (tmp_path / ".hg").mkdir() - backend = detect_backend(tmp_path) - assert isinstance(backend, MercurialBackend) + assert isinstance(detect_backend(tmp_path), MercurialBackend) svn = tmp_path / "svn_wc" svn.mkdir() (svn / ".svn").mkdir() assert isinstance(detect_backend(svn), SubversionBackend) + fossil_dir = tmp_path / "fossil_wc" + fossil_dir.mkdir() + (fossil_dir / ".fslckg").touch() + assert isinstance(detect_backend(fossil_dir), FossilBackend) + + fossil_dir2 = tmp_path / "fossil_wc_win" + fossil_dir2.mkdir() + (fossil_dir2 / "_FOSSIL_").touch() + assert isinstance(detect_backend(fossil_dir2), FossilBackend) + def test_detect_none(tmp_path: Path) -> None: assert detect_backend(tmp_path) is None @@ -171,19 +199,71 @@ def test_git_reset_worktree(git_repo: Path) -> None: assert (git_repo / "file.txt").read_text() == "hello\n" +@git_required +def test_git_cat(git_repo: Path) -> None: + backend = GitBackend(git_repo) + assert backend.cat("HEAD", "file.txt") == "hello\n" + + # -- unimplemented backends ------------------------------------------------ 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) + + +@hg_required +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") + 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) @@ -207,7 +287,7 @@ def test_cli_detect_and_status(git_repo: Path, capsys: pytest.CaptureFixture[str def test_cli_backends_lists_all(capsys: pytest.CaptureFixture[str]) -> None: assert main(["backends"]) == 0 out = capsys.readouterr().out - for name in ("git", "hg", "svn"): + for name in ("git", "hg", "svn", "fossil"): assert name in out @@ -217,6 +297,12 @@ 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 + + +@git_required +def test_cli_cat(git_repo: Path, capsys: pytest.CaptureFixture[str]) -> None: + assert main(["-C", str(git_repo), "cat", "HEAD", "file.txt"]) == 0 + assert capsys.readouterr().out == "hello\n" From 99b1872a737920e3d90a894a945de5c6b5a49033 Mon Sep 17 00:00:00 2001 From: Arnav Date: Fri, 3 Jul 2026 00:56:20 +0530 Subject: [PATCH 21/21] fix(tools/fossil): resolve merge conflicts and implement hg cat --- docs/vendor-neutrality.md | 7 +- tools/vcs/src/magpie_vcs/__init__.py | 3 + uv.lock | 155 +++++++++++++-------------- 3 files changed, 82 insertions(+), 83 deletions(-) diff --git a/docs/vendor-neutrality.md b/docs/vendor-neutrality.md index 992e278c..27b6e10c 100644 --- a/docs/vendor-neutrality.md +++ b/docs/vendor-neutrality.md @@ -538,11 +538,8 @@ implementation of a capability. | Capability contract | Neutral? | Class | Backends today | Basis | |---|---|---|---|---| -| `contract:tracker` | ✅ | vendor-backed | Atlassian, Fossil, GitHub | 3 backend vendors: Atlassian, Fossil, GitHub | -| `contract:source-control` | ✅ | vendor-backed | Fossil, Git, GitHub, Subversion | 4 backend vendors: Fossil, 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:tracker` | ✅ | vendor-backed | Atlassian, Fossil, GitHub, SourceHut | 4 backend vendors: Atlassian, Fossil, GitHub, SourceHut | +| `contract:source-control` | ✅ | vendor-backed | Fossil, Git, GitHub, SourceHut, Subversion | 5 backend vendors: Fossil, 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 | diff --git a/tools/vcs/src/magpie_vcs/__init__.py b/tools/vcs/src/magpie_vcs/__init__.py index 7ec8d624..a0d1e861 100644 --- a/tools/vcs/src/magpie_vcs/__init__.py +++ b/tools/vcs/src/magpie_vcs/__init__.py @@ -458,6 +458,9 @@ def reset_worktree(self) -> None: _run(["hg", "update", "--clean"], self.root, check=False, capture=False) _run(["hg", "purge", "--config", "extensions.purge="], self.root, check=False, capture=False) + def cat(self, rev: str, path: str) -> str: + return _run(["hg", "cat", "-r", rev, path], self.root) + class FossilBackend(VCSBackend): """Fossil SCM binding of the source-control capability.""" diff --git a/uv.lock b/uv.lock index 681de040..9fa42a71 100644 --- a/uv.lock +++ b/uv.lock @@ -116,11 +116,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.5.20" +version = "2026.6.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, ] [[package]] @@ -298,61 +298,58 @@ wheels = [ [[package]] name = "cryptography" -version = "48.0.1" +version = "49.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, - { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" }, - { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" }, - { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" }, - { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" }, - { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" }, - { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" }, - { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" }, - { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" }, - { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" }, - { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" }, - { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" }, - { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" }, - { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" }, - { url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" }, - { url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" }, - { url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" }, - { url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" }, - { url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" }, - { url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" }, - { url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" }, - { url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" }, - { url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" }, - { url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" }, - { url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" }, - { url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" }, - { url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" }, - { url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" }, - { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, - { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" }, - { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" }, - { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" }, - { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" }, - { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" }, - { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" }, - { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" }, - { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" }, - { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" }, - { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" }, - { url = "https://files.pythonhosted.org/packages/a9/d3/eb4e394e587341fdad09a09101fa76478ead3a78b0ad63e55c22f0d75c02/cryptography-48.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a", size = 3951747, upload-time = "2026-06-09T22:31:23.871Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4a/3f43451b4f858bfceaaaffc649e6e787e8d4fb332a1d443af39ab02cc8f1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd", size = 4641226, upload-time = "2026-06-09T22:31:02.532Z" }, - { url = "https://files.pythonhosted.org/packages/73/4e/855584c2c23b09e4ce2d3b9c30e983e679cd60b068c513c6bbdb91e11782/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c", size = 4668958, upload-time = "2026-06-09T22:32:06.213Z" }, - { url = "https://files.pythonhosted.org/packages/42/3b/d35750e41d803d1e516fd6d6011f065424924da7af1748cef4cc9cb3ede1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9", size = 4640793, upload-time = "2026-06-09T22:32:26.331Z" }, - { url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" }, - { url = "https://files.pythonhosted.org/packages/5d/8c/ce3823c06c2804f194f9e64f0d67fa3f4094a39f2bb1a990cd03603af8fc/cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a", size = 3742204, upload-time = "2026-06-09T22:31:34.773Z" }, + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, ] [[package]] @@ -397,15 +394,15 @@ source = { editable = "tools/github-rollup" } [[package]] name = "google-auth" -version = "2.53.0" +version = "2.55.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/ad/ff781329bbbdc0974a098d996e89c9e1f7024262f9e3eec442fbb9ad1ac6/google_auth-2.53.0.tar.gz", hash = "sha256:e7e6aa16f6bee7b2b264830fd04f08087a1d5a836df516251a5d15327b246c9c", size = 335844, upload-time = "2026-05-15T20:53:07.928Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/1c/70b23fc52b2bb3c70b379f3bd05c4a60ab3a873e30c6bd21c57e0154848a/google_auth-2.55.0.tar.gz", hash = "sha256:fcd3a130f575fa36403d38774af1c64a4fbfbca09215f0589d2372b5119697cb", size = 349379, upload-time = "2026-06-15T22:33:16.466Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/c9/db44165ba7c581268c6d46017ef63339110378305062830104fc7fa144cb/google_auth-2.53.0-py3-none-any.whl", hash = "sha256:6e7449917c599b35126a99ec268ec6880301f2fea41dce198fe8fd83ff642b68", size = 246071, upload-time = "2026-05-15T20:53:05.609Z" }, + { url = "https://files.pythonhosted.org/packages/44/71/c0321dc6d63d99946da45f7c06299b934e4f7f7da5c4f14d101bcb39adf1/google_auth-2.55.0-py3-none-any.whl", hash = "sha256:a17cef9dedf98c4ebae2fb0c48c8f75952c877cbc2efe09f329ef16c2783d88a", size = 252400, upload-time = "2026-06-15T22:33:14.992Z" }, ] [[package]] @@ -423,11 +420,11 @@ wheels = [ [[package]] name = "idna" -version = "3.16" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -521,6 +518,8 @@ wheels = [ name = "magpie-fossil" version = "0.1.0" source = { editable = "tools/fossil" } + +[[package]] name = "magpie-sourcehut" version = "0.1.0" source = { editable = "tools/sourcehut" } @@ -794,27 +793,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.18" +version = "0.15.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/74/98/1295ad5a5aa9bc85bdcdfa5d82fe7b49c61af5657df4f227637ff9de0da6/ruff-0.15.18.tar.gz", hash = "sha256:2698a964c70e8bf402dcb99c8810472d270d141e7aa8c4e13599fd52033a2f33", size = 4761437, upload-time = "2026-06-18T18:25:39.224Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/d0/686e984941269621e2be72612d5c1e461f8f7b38415a2a7d7a81c8ae6715/ruff-0.15.18-py3-none-linux_armv6l.whl", hash = "sha256:8b6850172348c8381b8b3084c5915a4393c2373b9b54cd5b5e1ea15812bc10df", size = 10887308, upload-time = "2026-06-18T18:25:03.062Z" }, - { url = "https://files.pythonhosted.org/packages/ed/21/bc4123e3f5515ee99f8ce1eb93a14a0628fe4d1678663cd08f933ac16931/ruff-0.15.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3fccc153a85417dcd976883160cacce486997b0a0058dd18f54b8aaaac7d1ce2", size = 11281305, upload-time = "2026-06-18T18:25:30.026Z" }, - { url = "https://files.pythonhosted.org/packages/51/93/4769464c25cf7ab2acb3c7dda9cad3d867eb41c59565b3e2a9d17249c90c/ruff-0.15.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:08d4c86a68f2c3ec2c9d56380a71fb4a4f65373055cbb8caabd645e9102f38d4", size = 10641215, upload-time = "2026-06-18T18:25:15.802Z" }, - { url = "https://files.pythonhosted.org/packages/6c/42/56926d17120db2c208d76bf60a1a019644dd9e91dc27f0f95c9caddb1366/ruff-0.15.18-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37e5108745c2c0705da916d7d4de533ddf547051ef45f62888c31bae73f66318", size = 10957224, upload-time = "2026-06-18T18:25:36.955Z" }, - { url = "https://files.pythonhosted.org/packages/22/4f/d43fab8d8189afde803103022d000a8ef9f230616d436d52a8b2b8d63b50/ruff-0.15.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56949a6ce8b3abde54c0bcb22cebfe57e8771cadc84b407ae8b8eaf67ebdcd43", size = 10699024, upload-time = "2026-06-18T18:25:05.707Z" }, - { url = "https://files.pythonhosted.org/packages/63/42/1e3e4c68bd408b9768cf3e439acbe2c78245225faef253f7028a0cdb63e0/ruff-0.15.18-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01a754cd6a1b630d3f97e33eb452cf7a98040482318e870f8bc52a5a30e62657", size = 11491458, upload-time = "2026-06-18T18:25:20.275Z" }, - { url = "https://files.pythonhosted.org/packages/20/77/47a3484bea8521e14a203d98c389c5c97846675e4f02734672da4a69b52a/ruff-0.15.18-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ba7a07e03a44dbf10bb086ee06705b173625014ec99f73a7e6836a5e5590a0c", size = 12383752, upload-time = "2026-06-18T18:25:22.535Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ca/054159590787023d83b658a1a1819c4c8910114e7015069340b71c0961cb/ruff-0.15.18-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a2c40a41a4cadbcf5897b548ab29dfe248b20c540961c0247d98a3973c70403", size = 11577923, upload-time = "2026-06-18T18:25:10.702Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ff/d353d6b7bbd73cc0ec37f4463d7540e45e894338abdd9964eee0de332708/ruff-0.15.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f0480ce690cbb6c4db6e5d08f19fce98e10ba131a8b60c1bcdac42771e3ae2d", size = 11583925, upload-time = "2026-06-18T18:25:32.391Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4a/891f89b9c296ed3e5f3ece1a5629badc989d9a8fdaa30431aaf4774bc1c2/ruff-0.15.18-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2330215f1f393fa8733f55edce04fcf94c36a2c460fcde31f78cc84e4951e9b1", size = 11582834, upload-time = "2026-06-18T18:25:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/32/a3/ed9e370154bf85de360b93c03026157f02d4943b2d01ff4945f4429f8e8a/ruff-0.15.18-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6aa6a3d979e48ae617578183674bf264fbe7d0114a796a26bd678d67963c7ff", size = 10927328, upload-time = "2026-06-18T18:25:34.676Z" }, - { url = "https://files.pythonhosted.org/packages/f5/d1/5cf5909329fedb5d39d555ee818ba5cf4638e1a301b89785d34f2905bfcb/ruff-0.15.18-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a81beadbbff2c9c245561ae3f77b16709d87f35eec650d0501679239d3449b22", size = 10693187, upload-time = "2026-06-18T18:25:08.245Z" }, - { url = "https://files.pythonhosted.org/packages/fd/44/ff6c635cf2c4f4e7b618b6640da057376baa36014695487d88aed4794268/ruff-0.15.18-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2186d9e940ae332ab293623a75b5f4fe49565f449954d50a72a046683aa6b809", size = 11208721, upload-time = "2026-06-18T18:25:41.327Z" }, - { url = "https://files.pythonhosted.org/packages/88/d9/5baa2a30861adfb7022cf33c1e35b2fc18085b08c16f83eff4c7b99a5f48/ruff-0.15.18-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5c2abf140438032bc77b2284a6c9944ecd8a19e5f1c7b52b1b8e4a0a80d19a7a", size = 11678599, upload-time = "2026-06-18T18:25:13.607Z" }, - { url = "https://files.pythonhosted.org/packages/c3/1a/0725a7cfdc32ff769efb96ee782bec882e16448c5d9e3be947ec4c04ce27/ruff-0.15.18-py3-none-win32.whl", hash = "sha256:02299e6e9fa5b297a3f6d5d10d7bcd655c925b028bb8b9d4588214549c6b9ec4", size = 10901903, upload-time = "2026-06-18T18:25:24.755Z" }, - { url = "https://files.pythonhosted.org/packages/f3/51/805d9f6fb7970505c3504794a5ec350f605361b807fef4dcf214ebd35e72/ruff-0.15.18-py3-none-win_amd64.whl", hash = "sha256:dac80dc8d26b2257dbefabed62f5d255c3937b4ccb122da1fc634794fa3578b3", size = 12041189, upload-time = "2026-06-18T18:25:17.915Z" }, - { url = "https://files.pythonhosted.org/packages/29/4c/67bb45e41609eb4726f1bfeb59e083cf91d14c696d4bd14c234a980be93d/ruff-0.15.18-py3-none-win_arm64.whl", hash = "sha256:b2c9257fcbd4a3e5b977a1904e6facca016bafe2edc17df24db67cfaee03b4e4", size = 11329958, upload-time = "2026-06-18T18:25:43.686Z" }, + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, ] [[package]]