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..67d4e4b8 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 @@ -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`, @@ -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,8 +410,7 @@ 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), @@ -484,9 +482,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, 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; @@ -537,9 +535,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/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/README.md b/tools/sourcehut/README.md new file mode 100644 index 00000000..b3c45233 --- /dev/null +++ b/tools/sourcehut/README.md @@ -0,0 +1,55 @@ + + +**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 +**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 + +- **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 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. | 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"] diff --git a/tools/sourcehut/src/magpie_sourcehut/__init__.py b/tools/sourcehut/src/magpie_sourcehut/__init__.py new file mode 100644 index 00000000..624479a5 --- /dev/null +++ b/tools/sourcehut/src/magpie_sourcehut/__init__.py @@ -0,0 +1,36 @@ +# 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..d53e53f2 --- /dev/null +++ b/tools/sourcehut/src/magpie_sourcehut/cli.py @@ -0,0 +1,192 @@ +# 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 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 ( + 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) + + 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 == "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 diff --git a/tools/sourcehut/src/magpie_sourcehut/client.py b/tools/sourcehut/src/magpie_sourcehut/client.py new file mode 100644 index 00000000..dc135f5a --- /dev/null +++ b/tools/sourcehut/src/magpie_sourcehut/client.py @@ -0,0 +1,92 @@ +# 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: dict[str, Any] = {"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) + 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: + 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] + 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) 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 + 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..bca4e9a2 --- /dev/null +++ b/tools/sourcehut/src/magpie_sourcehut/lists.py @@ -0,0 +1,180 @@ +# 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 + +import contextlib +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 and 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 and edge.get("node")] + + # Sort emails by date if possible + with contextlib.suppress(Exception): + emails.sort(key=lambda x: x.get("date", "")) + + # 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 []: + 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 = [] + 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/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/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..913eee91 --- /dev/null +++ b/tools/sourcehut/src/magpie_sourcehut/todo.py @@ -0,0 +1,177 @@ +# 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/sourcehut/tests/test_sourcehut.py b/tools/sourcehut/tests/test_sourcehut.py new file mode 100644 index 00000000..6718e1c0 --- /dev/null +++ b/tools/sourcehut/tests/test_sourcehut.py @@ -0,0 +1,238 @@ +# 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 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.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, + update_ticket_status, +) + + +@pytest.fixture +def mock_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SRHT_TOKEN", "mock_token_123") + + +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 + return mock_resp + + +@patch("urllib.request.urlopen") +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() -> 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: 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=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: MagicMock, mock_env: None) -> None: + 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: MagicMock, mock_env: None) -> None: + 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: MagicMock, mock_env: None) -> None: + 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: MagicMock, mock_env: None) -> None: + 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: MagicMock, mock_env: None) -> None: + 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: MagicMock, mock_env: None) -> None: + 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: MagicMock, mock_env: None) -> None: + 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: MagicMock, mock_env: None) -> None: + 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: 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"}}} + ) + res = get_repo("git", "~user", "my-repo") + assert res["name"] == "my-repo" + + +@patch("urllib.request.urlopen") +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"}}} + ) + code = main(["build", "get", "12"]) + 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/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 e797fae0..0563db86 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 @@ -356,13 +356,93 @@ 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: + # 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: + _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", "--config", "extensions.purge="], self.root, check=False, capture=False) class SubversionBackend(_UnimplementedBackend): diff --git a/tools/vcs/tests/test_vcs.py b/tools/vcs/tests/test_vcs.py index 6d855b01..39fa4d49 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,61 @@ 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) + + +@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) @@ -217,6 +281,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 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"