proj/pkgcore/pkgdev:main commit in: /, src/pkgdev/scripts/, tests/scripts/

"Arthur Zamarin" <[email protected]>
Newsgroups gmane.linux.gentoo.cvs
Message-ID <1786216337.8b06640499d03ffcf3218014145815eb80e601e9.arthurzam@gentoo>
commit:     8b06640499d03ffcf3218014145815eb80e601e9
Author:     Arthur Zamarin <arthurzam <AT> gentoo <DOT> org>
AuthorDate: Sat Aug  8 11:55:10 2026 +0000
Commit:     Arthur Zamarin <arthurzam <AT> gentoo <DOT> org>
CommitDate: Sat Aug  8 19:12:17 2026 +0000
URL:        https://gitweb.gentoo.org/proj/pkgcore/pkgdev.git/commit/?id=8b066404

bugs, mask: move Bugzilla handling to pkgcore.bugzilla

pkgcore now ships a typed Bugzilla REST client, so the hand-rolled urllib
calls, JSON payload dicts and API key discovery here can go. Net effect is
317 lines removed against 127 added.

BugzillaApiKey moves to pkgcore.bugzilla.apikey.

Tests drop the local BugsSession fake for the bugzilla_cassette fixture
pkgcore ships, which replays through the real urllib stack, so the request
construction is exercised rather than bypassed.

Signed-off-by: Arthur Zamarin <arthurzam <AT> gentoo.org>

 pyproject.toml                    |   2 +-
 src/pkgdev/scripts/argparsers.py  |  47 +-------
 src/pkgdev/scripts/pkgdev_bugs.py | 218 +++++++++++---------------------------
 src/pkgdev/scripts/pkgdev_mask.py |  86 +++++----------
 src/pkgdev/scripts/pkgdev_tatt.py |  13 ++-
 tests/scripts/test_pkgdev_bugs.py |  94 +++++++---------
 6 files changed, 139 insertions(+), 321 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index 7de37fe..a204d71 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -29,7 +29,7 @@ dynamic = ["version"]
 
 dependencies = [
 	"snakeoil~=0.11.1",
-	"pkgcore~=0.12.37",
+	"pkgcore~=0.12.38",
 	"pkgcheck~=0.10.42",
 ]
 

diff --git a/src/pkgdev/scripts/argparsers.py b/src/pkgdev/scripts/argparsers.py
index 9ab58da..08cfef1 100644
--- a/src/pkgdev/scripts/argparsers.py
+++ b/src/pkgdev/scripts/argparsers.py
@@ -1,8 +1,5 @@
 import os
 import subprocess
-from configparser import ConfigParser
-from contextlib import suppress
-from pathlib import Path
 
 from pkgcore.repository import errors as repo_errors
 from snakeoil.cli.arghparse import ArgumentParser
@@ -18,7 +15,7 @@ def _determine_cwd_repo(parser, namespace):
     namespace.cwd = os.getcwd()
     try:
         repo = namespace.domain.find_repo(namespace.cwd, config=namespace.config, configure=False)
-    except (repo_errors.InitializationError, IOError) as e:
+    except (OSError, repo_errors.InitializationError) as e:
         raise parser.error(str(e))
 
     if repo is None:
@@ -44,45 +41,3 @@ def _determine_git_repo(parser, namespace):
         pass
 
     namespace.git_repo = path
-
-
-class BugzillaApiKey:
-    @classmethod
-    def mangle_argparser(cls, parser):
-        parser.add_argument(
-            "--api-key",
-            metavar="TOKEN",
-            help="Bugzilla API key",
-            docs="""
-                The Bugzilla API key to use for authentication. WARNING: using this
-                option will expose your API key to other users of the same system.
-                Consider instead saving your API key in a file named ``~/.bugzrc``
-                in an INI format like so::
-
-                        [default]
-                        key = <your API key>
-
-                Another supported option is to save your API key in a file named
-                ``~/.bugz_token``.
-            """,
-        )
-
-        parser.bind_delayed_default(1000, "api_key")(cls._default_api_key)
-
-    @staticmethod
-    def _default_api_key(namespace, attr):
-        """Use all known arches by default."""
-        if (bugz_rc_file := Path.home() / ".bugzrc").is_file():
-            try:
-                config = ConfigParser(default_section="default")
-                config.read(bugz_rc_file)
-            except Exception as e:
-                raise ValueError(f"failed parsing {bugz_rc_file}: {e}")
-
-            for category in ("default", "gentoo", "Gentoo"):
-                with suppress(Exception):
-                    setattr(namespace, attr, config.get(category, "key"))
-                    return
-
-        if (bugz_token_file := Path.home() / ".bugz_token").is_file():
-            setattr(namespace, attr, bugz_token_file.read_text().strip())

diff --git a/src/pkgdev/scripts/pkgdev_bugs.py b/src/pkgdev/scripts/pkgdev_bugs.py
index a3ef0e6..0cfd635 100644
--- a/src/pkgdev/scripts/pkgdev_bugs.py
+++ b/src/pkgdev/scripts/pkgdev_bugs.py
@@ -1,8 +1,6 @@
 """Automatic bugs filer"""
 
 import contextlib
-import enum
-import json
 import os
 import shlex
 import subprocess
@@ -15,7 +13,6 @@ from datetime import datetime
 from functools import partial
 from itertools import chain
 from os.path import join as pjoin
-from urllib.parse import urlencode
 
 from pkgcheck import const as pkgcheck_const
 from pkgcheck.addons import ArchesAddon, init_addon
@@ -23,6 +20,9 @@ from pkgcheck.addons.git import GitAddedRepo, GitAddon, GitModifiedRepo
 from pkgcheck.addons.profiles import ProfileAddon
 from pkgcheck.checks import stablereq, visibility
 from pkgcheck.scripts import argparse_actions
+from pkgcore.bugzilla import BugCategory, BugQuery, BugUpdate, Bugzilla, NewBug, PackageList
+from pkgcore.bugzilla.apikey import BugzillaApiKey
+from pkgcore.bugzilla.changes import summarise
 from pkgcore.ebuild.atom import atom
 from pkgcore.ebuild.ebuild_src import package
 from pkgcore.ebuild.errors import MalformedAtom
@@ -37,30 +37,11 @@ from snakeoil.cli.input import userquery
 from snakeoil.data_source import bytes_data_source
 from snakeoil.formatters import Formatter
 
+from .. import __version__
 from ..cli import ArgumentParser
-from .argparsers import BugzillaApiKey, _determine_cwd_repo, cwd_repo_argparser
+from .argparsers import _determine_cwd_repo, cwd_repo_argparser
 
-
-class NodeCategory(enum.Enum):
-    KEYWORDREQ = enum.auto()
-    STABLEREQ = enum.auto()
-
-
-# per-category strings: Bugzilla component, description verb, summary suffix
-_CATEGORY_META = {
-    NodeCategory.STABLEREQ: {
-        "component": "Stabilization",
-        "verb": "stabilize",
-        "suffix": "stablereq",
-    },
-    NodeCategory.KEYWORDREQ: {
-        "component": "Keywording",
-        "verb": "keyword",
-        "suffix": "keywordreq",
-    },
-}
-
-_CATEGORY_BY_SUFFIX = {meta["suffix"]: category for category, meta in _CATEGORY_META.items()}
+_CATEGORY_BY_SUFFIX = {x.summary_suffix: x for x in BugCategory}
 
 
 class StoreTargetArches(commandline.StoreTarget):
@@ -284,7 +265,8 @@ def _validate_args(namespace, attr):
 def _validate_args(parser, namespace):
     if namespace.keywording and namespace.filter_stablereqs:
         parser.error("--keywording is incompatible with --filter-stablereqs")
-    namespace.category = NodeCategory.KEYWORDREQ if namespace.keywording else NodeCategory.STABLEREQ
+    namespace.category = BugCategory.KEYWORDREQ if namespace.keywording else BugCategory.STABLEREQ
+    namespace.bugzilla = Bugzilla(namespace.api_key, user_agent=f"pkgdev-bugs/{__version__}")
 
 
 def _get_suggested_keywords(repo, pkg: package, streq: bool = True):
@@ -324,7 +306,7 @@ class GraphNode:
     def __init__(
         self,
         pkgs: tuple[tuple[package, set[str]], ...],
-        category: NodeCategory = NodeCategory.STABLEREQ,
+        category: BugCategory = BugCategory.STABLEREQ,
         bugno=None,
     ):
         self.pkgs = pkgs
@@ -337,7 +319,7 @@ class GraphNode:
 
     @property
     def is_keywordreq(self):
-        return self.category is NodeCategory.KEYWORDREQ
+        return self.category is BugCategory.KEYWORDREQ
 
     def __eq__(self, __o: object):
         return self is __o
@@ -383,19 +365,13 @@ class GraphNode:
                 keywords.clear()
                 keywords.add("*")
 
+    @property
+    def package_list(self) -> PackageList:
+        return PackageList("\n".join(self.lines()))
+
     @property
     def bug_summary(self):
-        if self.summary:
-            return self.summary
-        suffix = _CATEGORY_META[self.category]["suffix"]
-        if self.is_keywordreq:
-            names = [str(pkg.unversioned_atom) for pkg, _ in self.pkgs]
-        else:
-            names = [pkg.versioned_atom.cpvstr for pkg, _ in self.pkgs]
-        summary = f"{', '.join(names)}: {suffix}"
-        if len(summary) > 90 and len(self.pkgs) > 1:
-            return f"{names[0]} and friends: {suffix}"
-        return summary
+        return self.summary or summarise(self.package_list, self.category)
 
     @property
     def node_maintainers(self):
@@ -413,7 +389,7 @@ class GraphNode:
 
     def file_bug(
         self,
-        api_key: str,
+        bugzilla: Bugzilla,
         auto_cc_arches: frozenset[str],
         block_bugs: list[int],
         modified_repo: multiplex.tree,
@@ -423,15 +399,9 @@ class GraphNode:
             return self.bugno
         for dep in self.edges:
             if dep.bugno is None:
-                dep.file_bug(api_key, auto_cc_arches, (), modified_repo, observer)
-        maintainers = self.node_maintainers
-        if self.should_cc_arches(auto_cc_arches):
-            keywords = ["CC-ARCHES"]
-        else:
-            keywords = []
-        maintainers = tuple(maintainers) or ("[email protected]",)
+                dep.file_bug(bugzilla, auto_cc_arches, (), modified_repo, observer)
 
-        description = [f"Please {_CATEGORY_META[self.category]['verb']}", ""]
+        description = [f"Please {self.category.verb}", ""]
         if modified_repo is not None:
             for pkg, _ in self.pkgs:
                 with contextlib.suppress(StopIteration):
@@ -442,63 +412,28 @@ class GraphNode:
                         f" {pkg.versioned_atom.cpvstr}: no change for {days_old} days, since {modified:%Y-%m-%d}"
                     )
 
-        request_data = dict(
-            Bugzilla_api_key=api_key,
-            product="Gentoo Linux",
-            component=_CATEGORY_META[self.category]["component"],
-            severity="enhancement",
-            version="unspecified",
-            summary=self.bug_summary,
-            description="\n".join(description).strip(),
-            keywords=keywords,
-            cf_stabilisation_atoms="\n".join(self.lines()),
-            assigned_to=maintainers[0],
-            cc=maintainers[1:],
-            depends_on=list({dep.bugno for dep in self.edges}),
-            blocks=block_bugs,
-        )
-        request = urllib.Request(
-            url="https://bugs.gentoo.org/rest/bug",
-            data=json.dumps(request_data).encode("utf-8"),
-            method="POST",
-            headers={
-                "Content-Type": "application/json",
-                "Accept": "application/json",
-            },
+        self.bugno = bugzilla.create(
+            NewBug.arch_request(
+                self.category,
+                self.package_list,
+                maintainers=tuple(self.node_maintainers),
+                cc_arches=self.should_cc_arches(auto_cc_arches),
+                summary=self.bug_summary,
+                description="\n".join(description).strip(),
+                depends_on=tuple({dep.bugno for dep in self.edges}),
+                blocks=tuple(block_bugs),
+            )
         )
-        with urllib.urlopen(request, timeout=30) as response:
-            reply = json.loads(response.read().decode("utf-8"))
-        self.bugno = int(reply["id"])
         if observer is not None:
             observer(self)
-        self.obsolete_bugs(api_key)
+        self.obsolete_bugs(bugzilla)
         return self.bugno
 
-    def obsolete_bugs(self, api_key: str):
+    def obsolete_bugs(self, bugzilla: Bugzilla):
         if not self.obsoletes:
             return
         assert self.bugno is not None
-
-        # Batch all bug IDs into a single PUT request
-        request_data = dict(
-            Bugzilla_api_key=api_key,
-            status="RESOLVED",
-            resolution="OBSOLETE",
-            see_also={"add": [f"https://bugs.gentoo.org/{self.bugno}"]},
-        )
-        if len(self.obsoletes) > 1:
-            request_data["ids"] = list(self.obsoletes)
-        request = urllib.Request(
-            url=f"https://bugs.gentoo.org/rest/bug/{','.join(map(str, self.obsoletes))}",
-            data=json.dumps(request_data).encode("utf-8"),
-            method="PUT",
-            headers={
-                "Content-Type": "application/json",
-                "Accept": "application/json",
-            },
-        )
-        with urllib.urlopen(request, timeout=30) as response:
-            json.loads(response.read().decode("utf-8"))
+        bugzilla.update(sorted(self.obsoletes), BugUpdate.obsoleted_by(self.bugno))
 
 
 class DependencyGraph:
@@ -682,16 +617,16 @@ class DependencyGraph:
             )
 
     def build_full_graph(self):
-        STABLEREQ, KEYWORDREQ = NodeCategory.STABLEREQ, NodeCategory.KEYWORDREQ
+        STABLEREQ, KEYWORDREQ = BugCategory.STABLEREQ, BugCategory.KEYWORDREQ
         check_nodes = [
             (pkg, set(self.target_arches.get(pkg, ())), self.options.category, "")
             for pkg in self.targets
         ]
 
-        vertices: dict[tuple[package, NodeCategory], GraphNode] = {}
+        vertices: dict[tuple[package, BugCategory], GraphNode] = {}
         edges = []
 
-        def explore_deps(pkg: package, arches: set[str], category: NodeCategory):
+        def explore_deps(pkg: package, arches: set[str], category: BugCategory):
             """Queue the dependencies of ``pkg`` that are unsolvable on ``arches``."""
             for dep, dep_arches in self._find_dependencies(
                 pkg, arches, stable=category is STABLEREQ
@@ -729,7 +664,7 @@ class DependencyGraph:
                 continue
 
             streq = category is STABLEREQ
-            verb = _CATEGORY_META[category]["verb"]
+            verb = category.verb
             if streq:
                 keywords.update(_get_suggested_keywords(self.options.repo, pkg, streq=True))
                 if not keywords:
@@ -795,7 +730,7 @@ class DependencyGraph:
                 continue  # already filed
             toml.write(f"[bug-{bugno}]\n")
             toml.write(f'summary = "{node.bug_summary}"\n')
-            toml.write(f'category = "{_CATEGORY_META[node.category]["suffix"]}"\n')
+            toml.write(f'category = "{node.category.summary_suffix}"\n')
             toml.write(f"cc_arches = {str(node.should_cc_arches(self.auto_cc_arches)).lower()}\n")
             if node in self.starting_nodes:
                 toml.write("starting = true\n")
@@ -846,7 +781,7 @@ class DependencyGraph:
                 if pkg.startswith("=")
             )
             category = _CATEGORY_BY_SUFFIX.get(
-                data_node.get("category", "stablereq"), NodeCategory.STABLEREQ
+                data_node.get("category", "stablereq"), BugCategory.STABLEREQ
             )
             new_bugs[node_name] = GraphNode(pkgs, category=category)
         for node_name, data_node in data.items():
@@ -965,7 +900,7 @@ class DependencyGraph:
                 node
                 for node in self.nodes
                 if node.bugno is None
-                and node.category is NodeCategory.STABLEREQ
+                and node.category is BugCategory.STABLEREQ
                 and any(restrict.match(pkg) for pkg, _ in node.pkgs)
             )
             if mergable:
@@ -984,66 +919,35 @@ class DependencyGraph:
                 self.merge_nodes(mergable)
         return True
 
-    def scan_existing_bugs(self, api_key: str) -> bool:
-        # Paginate the search request with batches of 100 items to avoid HTTP 414 errors
+    def scan_existing_bugs(self, bugzilla: Bugzilla) -> bool:
         all_packages = list({pkg[0].unversioned_atom for node in self.nodes for pkg in node.pkgs})
-        batch_size = 100
-        all_bugs = []
         has_output = False
 
-        for i in range(0, len(all_packages), batch_size):
-            params = urlencode(
-                {
-                    "Bugzilla_api_key": api_key,
-                    "include_fields": "id,cf_stabilisation_atoms,summary,component",
-                    "component": ["Stabilization", "Keywording"],
-                    "resolution": "---",
-                    "f1": "cf_stabilisation_atoms",
-                    "o1": "anywords",
-                    "v1": all_packages[i : i + batch_size],
-                },
-                doseq=True,
-            )
-            request = urllib.Request(
-                url="https://bugs.gentoo.org/rest/bug?" + params,
-                method="GET",
-                headers={
-                    "Content-Type": "application/json",
-                    "Accept": "application/json",
-                },
-            )
-            with urllib.urlopen(request, timeout=30) as response:
-                reply = json.loads(response.read().decode("utf-8"))
-                all_bugs.extend(reply.get("bugs", []))
+        query = (
+            BugQuery.component(BugCategory.KEYWORDREQ, BugCategory.STABLEREQ)
+            & BugQuery.unresolved()
+            & BugQuery.package_list_any(all_packages)
+        )
+        all_bugs = bugzilla.search(query).values()
 
         for bug in all_bugs:
-            bug_atoms = (
-                parse_atom(line.split(" ", 1)[0]).unversioned_atom
-                for line in map(str.strip, bug["cf_stabilisation_atoms"].splitlines())
-                if line
-            )
-            bug_match = boolean.OrRestriction(*bug_atoms)
-            exact_match = boolean.OrRestriction(
-                *(
-                    parse_atom(line.split(" ", 1)[0])
-                    for line in map(str.strip, bug["cf_stabilisation_atoms"].splitlines())
-                    if line
-                )
-            )
+            bug_atoms = bug.package_list.atoms
+            bug_match = boolean.OrRestriction(*(a.unversioned_atom for a in bug_atoms))
+            exact_match = boolean.OrRestriction(*bug_atoms)
             for node in self.nodes:
-                if bug.get("component") != _CATEGORY_META[node.category]["component"]:
+                if bug.component != node.category.component:
                     continue
                 if node.bugno is None and all(bug_match.match(pkg[0]) for pkg in node.pkgs):
                     is_exact_match = all(exact_match.match(pkg[0]) for pkg in node.pkgs)
                     self.out.write(
                         self.out.fg("yellow"),
-                        f"Found https://bugs.gentoo.org/{bug['id']} for node {node}",
+                        f"Found {bug.url} for node {node}",
                         self.out.reset,
                         " (exact version match)" if is_exact_match else " (atom match)",
                     )
-                    self.out.write(" -> bug summary: ", bug["summary"])
+                    self.out.write(" -> bug summary: ", bug.summary)
                     if is_exact_match:
-                        node.bugno = bug["id"]
+                        node.bugno = bug.id
                     else:
                         if userquery(
                             "Not an exact match. Do you want to obsolete?",
@@ -1051,13 +955,13 @@ class DependencyGraph:
                             self.err,
                             default_answer=False,
                         ):
-                            node.obsoletes.add(bug["id"])
+                            node.obsoletes.add(bug.id)
                         else:
-                            node.bugno = bug["id"]
+                            node.bugno = bug.id
                     has_output = True
         return has_output
 
-    def file_bugs(self, api_key: str, auto_cc_arches: frozenset[str], block_bugs: list[int]):
+    def file_bugs(self, bugzilla: Bugzilla, auto_cc_arches: frozenset[str], block_bugs: list[int]):
         def observe(node: GraphNode):
             self.out.write(
                 f"https://bugs.gentoo.org/{node.bugno} ",
@@ -1068,7 +972,7 @@ class DependencyGraph:
             self.out.flush()
 
         for node in self.starting_nodes:
-            node.file_bug(api_key, auto_cc_arches, block_bugs, self.modified_repo, observe)
+            node.file_bug(bugzilla, auto_cc_arches, block_bugs, self.modified_repo, observe)
 
 
 def _load_from_stdin(out: Formatter):
@@ -1106,7 +1010,7 @@ def main(options, out: Formatter, err: Formatter):
 
     has_output = False
     if userquery("Check for open bugs matching current graph?", out, err, default_answer=False):
-        if d.scan_existing_bugs(options.api_key):
+        if d.scan_existing_bugs(options.bugzilla):
             out.flush()
             has_output = True
 
@@ -1163,8 +1067,8 @@ def main(options, out: Formatter, err: Formatter):
         out.write(out.fg("red"), "Nothing to do, exiting", out.reset)
         return 1
     counts = {
-        meta["suffix"]: sum(node.category is category for node in pending)
-        for category, meta in _CATEGORY_META.items()
+        category.summary_suffix: sum(node.category is category for node in pending)
+        for category in BugCategory
     }
     summary = ", ".join(f"{count} {suffix}" for suffix, count in counts.items() if count)
 
@@ -1175,4 +1079,4 @@ def main(options, out: Formatter, err: Formatter):
 
     disabled, enabled = options.auto_cc_arches
     blocks = list(frozenset(map(int, options.blocks)))
-    d.file_bugs(options.api_key, frozenset(enabled).difference(disabled), blocks)
+    d.file_bugs(options.bugzilla, frozenset(enabled).difference(disabled), blocks)

diff --git a/src/pkgdev/scripts/pkgdev_mask.py b/src/pkgdev/scripts/pkgdev_mask.py
index 4d70e59..21f00fa 100644
--- a/src/pkgdev/scripts/pkgdev_mask.py
+++ b/src/pkgdev/scripts/pkgdev_mask.py
@@ -1,19 +1,18 @@
-import json
 import os
 import re
 import shlex
 import subprocess
 import tempfile
 import textwrap
-import urllib.request as urllib
 from collections import deque
 from dataclasses import dataclass
-from datetime import datetime, timedelta, timezone
+from datetime import UTC, datetime, timedelta
 from itertools import groupby
 from operator import itemgetter
 from os.path import join as pjoin
-from typing import List
 
+from pkgcore.bugzilla import BugUpdate, Bugzilla, BugzillaError, ListChange, NewBug
+from pkgcore.bugzilla.apikey import BugzillaApiKey
 from pkgcore.ebuild.atom import MalformedAtom
 from pkgcore.ebuild.atom import atom as atom_cls
 from pkgcore.ebuild.profiles import ProfileNode
@@ -21,8 +20,8 @@ from snakeoil.bash import read_bash
 from snakeoil.cli import arghparse
 from snakeoil.strings import pluralism
 
-from .. import git
-from .argparsers import BugzillaApiKey, cwd_repo_argparser, git_repo_argparser
+from .. import __version__, git
+from .argparsers import cwd_repo_argparser, git_repo_argparser
 
 mask = arghparse.ArgumentParser(
     prog="pkgdev mask",
@@ -146,6 +145,7 @@ def _mask_validate(parser, namespace):
 
     namespace.atoms = sorted(atoms)
     namespace.maintainers = sorted(maintainers) or ["[email protected]"]
+    namespace.bugzilla = Bugzilla(namespace.api_key, user_agent=f"pkgdev-mask/{__version__}")
 
 
 @dataclass(frozen=True)
@@ -155,8 +155,8 @@ class Mask:
     author: str
     email: str
     date: str
-    comment: List[str]
-    atoms: List[atom_cls]
+    comment: list[str]
+    atoms: list[atom_cls]
 
     _removal_re = re.compile(r"^Removal: (?P<date>\d{4}-\d{2}-\d{2})")
 
@@ -274,11 +274,11 @@ def get_comment():
 
     with open(tmp.name) as f:
         # strip trailing whitespace from lines
-        comment = (x.rstrip() for x in f.readlines())
-    # strip comments
-    comment = (x for x in comment if not x.startswith("#"))
-    # strip leading/trailing newlines
-    comment = "\n".join(comment).strip().splitlines()
+        comment = (x.rstrip() for x in f)
+        # strip comments
+        comment = (x for x in comment if not x.startswith("#"))
+        # strip leading/trailing newlines
+        comment = "\n".join(comment).strip().splitlines()
     if not comment:
         mask.error("empty mask comment")
     return comment
@@ -287,7 +287,7 @@ def get_comment():
 def message_removal_notice(bugs: list[int], rites: int):
     summary = []
     if rites:
-        summary.append(f"Removal on {datetime.now(timezone.utc) + timedelta(days=rites):%Y-%m-%d}.")
+        summary.append(f"Removal on {datetime.now(UTC) + timedelta(days=rites):%Y-%m-%d}.")
     if bugs:
         # Bug(s) #A, #B, #C
         bug_list = ", ".join(f"#{b}" for b in bugs)
@@ -300,52 +300,24 @@ def file_last_rites_bug(options, message: str) -> int:
     summary = f"{', '.join(map(str, options.atoms))}: removal"
     if len(summary) > 90 and len(options.atoms) > 1:
         summary = f"{options.atoms[0]} and friends: removal"
-    request_data = dict(
-        Bugzilla_api_key=options.api_key,
-        product="Gentoo Linux",
-        component="Current packages",
-        version="unspecified",
-        summary=summary,
-        description="\n".join([*message, "", "package list:", *map(str, options.atoms)]).strip(),
-        keywords=["PMASKED"],
-        assigned_to=options.maintainers[0],
-        cc=options.maintainers[1:] + ["[email protected]"],
-        deadline=(datetime.now(timezone.utc) + timedelta(days=options.rites)).strftime("%Y-%m-%d"),
-        blocks=list(options.bugs),
+    bug = NewBug.package_mask(
+        summary,
+        "\n".join([*message, "", "package list:", *map(str, options.atoms)]).strip(),
+        rites=options.rites,
+        maintainers=options.maintainers,
+        blocks=tuple(options.bugs),
     )
-    request = urllib.Request(
-        url="https://bugs.gentoo.org/rest/bug",
-        data=json.dumps(request_data).encode("utf-8"),
-        method="POST",
-        headers={
-            "Content-Type": "application/json",
-            "Accept": "application/json",
-        },
-    )
-    with urllib.urlopen(request, timeout=30) as response:
-        reply = json.loads(response.read().decode("utf-8"))
-    return int(reply["id"])
+    return options.bugzilla.create(bug)
 
 
-def update_bugs_pmasked(api_key: str, bugs: list[int]):
+def update_bugs_pmasked(bugzilla, bugs: list[int]) -> bool:
     if not bugs:
         return True
-    request_data = dict(
-        Bugzilla_api_key=api_key,
-        ids=bugs,
-        keywords=dict(add=["PMASKED"]),
-    )
-    request = urllib.Request(
-        url=f"https://bugs.gentoo.org/rest/bug/{bugs[0]}",
-        data=json.dumps(request_data).encode("utf-8"),
-        method="PUT",
-        headers={
-            "Content-Type": "application/json",
-            "Accept": "application/json",
-        },
-    )
-    with urllib.urlopen(request, timeout=30) as response:
-        return response.status == 200
+    try:
+        bugzilla.update(bugs, BugUpdate(keywords=ListChange.adding("PMASKED")))
+    except BugzillaError:
+        return False
+    return True
 
 
 def send_last_rites_email(m: Mask, subject_prefix: str):
@@ -372,7 +344,7 @@ def send_last_rites_email(m: Mask, subject_prefix: str):
 @mask.bind_main_func
 def _mask(options, out, err):
     mask_file = MaskFile(pjoin(options.repo.location, "profiles/package.mask"))
-    today = datetime.now(timezone.utc)
+    today = datetime.now(UTC)
 
     # pull name/email from git config
     p = git.run("config", "user.name", stdout=subprocess.PIPE)
@@ -385,7 +357,7 @@ def _mask(options, out, err):
         if bug_no := file_last_rites_bug(options, message):
             out.write(out.fg("green"), f"filed bug https://bugs.gentoo.org/{bug_no}", out.reset)
             out.flush()
-            if not update_bugs_pmasked(options.api_key, options.bugs):
+            if not update_bugs_pmasked(options.bugzilla, options.bugs):
                 err.write(err.fg("red"), "failed to update referenced bugs", err.reset)
                 err.flush()
             options.bugs.insert(0, bug_no)

diff --git a/src/pkgdev/scripts/pkgdev_tatt.py b/src/pkgdev/scripts/pkgdev_tatt.py
index 5f45ae5..58a7649 100644
--- a/src/pkgdev/scripts/pkgdev_tatt.py
+++ b/src/pkgdev/scripts/pkgdev_tatt.py
@@ -229,11 +229,18 @@ def _validate_args(parser, namespace):
 
 
 def _get_bugzilla_packages(namespace):
-    from nattka.bugzilla import BugCategory, NattkaBugzilla
     from nattka.package import match_package_list
 
-    nattka_bugzilla = NattkaBugzilla(api_key=namespace.api_key)
-    bug = next(iter(nattka_bugzilla.find_bugs(bugs=[namespace.bug]).values()))
+    try:
+        from nattka.bugzilla import BugCategory, NattkaBugzilla
+
+        nattka_bugzilla = NattkaBugzilla(api_key=namespace.api_key)
+        bug = next(iter(nattka_bugzilla.find_bugs(bugs=[namespace.bug]).values()))
+    except ImportError:
+        from pkgcore.bugzilla import BugCategory, Bugzilla
+
+        bug = Bugzilla(api_key=namespace.api_key).get(namespace.bug)
+
     namespace.keywording = bug.category == BugCategory.KEYWORDREQ
     repo = namespace.domain.repos["gentoo"].raw_repo
     src_repo = namespace.domain.source_repos_raw

diff --git a/tests/scripts/test_pkgdev_bugs.py b/tests/scripts/test_pkgdev_bugs.py
index 49ac736..85e81aa 100644
--- a/tests/scripts/test_pkgdev_bugs.py
+++ b/tests/scripts/test_pkgdev_bugs.py
@@ -1,12 +1,10 @@
-import itertools
-import json
 import os
 import textwrap
 from os.path import join as pjoin
 from types import SimpleNamespace
-from unittest.mock import patch
 
 import pytest
+from pkgcore.bugzilla import BugCategory
 from pkgcore.ebuild.atom import atom
 
 from pkgdev.scripts import pkgdev_bugs as bugs
@@ -41,79 +39,61 @@ def mk_repo(repo):
     mk_pkg(repo, "cat/w-0", ["dev3"], RDEPEND="cat/x")
 
 
-class BugsSession:
-    def __init__(self):
-        self.counter = iter(itertools.count(1))
-        self.calls = []
-
-    def __enter__(self):
-        return self
-
-    def __exit__(self, *_args): ...
-
-    def read(self):
-        return json.dumps({"id": next(self.counter)}).encode("utf-8")
-
-    def __call__(self, request, *_args, **_kwargs):
-        self.calls.append(json.loads(request.data))
-        return self
-
-
 class TestBugFiling:
-    def test_bug_filing(self, repo):
+    def test_bug_filing(self, repo, bugzilla_cassette):
         mk_repo(repo)
-        session = BugsSession()
+        bugzilla_cassette.creates_bugs()
         pkg = max(repo.itermatch(atom("=cat/u-0")))
-        with patch("pkgdev.scripts.pkgdev_bugs.urllib.urlopen", session):
-            bugs.GraphNode(((pkg, {"*"}),)).file_bug("API", frozenset(), (), None)
-        assert len(session.calls) == 1
-        call = session.calls[0]
+        bugs.GraphNode(((pkg, {"*"}),)).file_bug(
+            bugzilla_cassette.client(api_key="API"), frozenset(), (), None
+        )
+        assert len(bugzilla_cassette.calls) == 1
+        call = bugzilla_cassette.calls[0].body
         assert call["Bugzilla_api_key"] == "API"
         assert call["summary"] == "cat/u-0: stablereq"
         assert call["assigned_to"] == "[email protected]"
-        assert not call["cc"]
+        assert "cc" not in call
         assert call["cf_stabilisation_atoms"] == "=cat/u-0 *"
-        assert not call["depends_on"]
+        assert "depends_on" not in call
 
-    def test_bug_filing_maintainer_needed(self, repo):
+    def test_bug_filing_maintainer_needed(self, repo, bugzilla_cassette):
         mk_repo(repo)
-        session = BugsSession()
+        bugzilla_cassette.creates_bugs()
         pkg = max(repo.itermatch(atom("=cat/z-0")))
-        with patch("pkgdev.scripts.pkgdev_bugs.urllib.urlopen", session):
-            bugs.GraphNode(((pkg, {"*"}),)).file_bug("API", frozenset(), (), None)
-        assert len(session.calls) == 1
-        call = session.calls[0]
+        bugs.GraphNode(((pkg, {"*"}),)).file_bug(
+            bugzilla_cassette.client(api_key="API"), frozenset(), (), None
+        )
+        assert len(bugzilla_cassette.calls) == 1
+        call = bugzilla_cassette.calls[0].body
         assert call["assigned_to"] == "[email protected]"
-        assert not call["cc"]
+        assert "cc" not in call
 
-    def test_bug_filing_multiple_pkgs(self, repo):
+    def test_bug_filing_multiple_pkgs(self, repo, bugzilla_cassette):
         mk_repo(repo)
-        session = BugsSession()
+        bugzilla_cassette.creates_bugs()
         pkgX = max(repo.itermatch(atom("=cat/x-0")))
         pkgY = max(repo.itermatch(atom("=cat/y-0")))
         pkgZ = max(repo.itermatch(atom("=cat/z-0")))
         dep = bugs.GraphNode((), bugno=2)
         node = bugs.GraphNode(((pkgX, {"*"}), (pkgY, {"*"}), (pkgZ, {"*"})))
         node.edges.add(dep)
-        with patch("pkgdev.scripts.pkgdev_bugs.urllib.urlopen", session):
-            node.file_bug("API", frozenset(), (), None)
-        assert len(session.calls) == 1
-        call = session.calls[0]
+        node.file_bug(bugzilla_cassette.client(api_key="API"), frozenset(), (), None)
+        assert len(bugzilla_cassette.calls) == 1
+        call = bugzilla_cassette.calls[0].body
         assert call["summary"] == "cat/x-0, cat/y-0, cat/z-0: stablereq"
         assert call["assigned_to"] == "[email protected]"
         assert call["cc"] == ["[email protected]"]
         assert call["cf_stabilisation_atoms"] == "=cat/x-0 *\n=cat/y-0 *\n=cat/z-0 *"
         assert call["depends_on"] == [2]
 
-    def test_keyword_bug_filing(self, repo):
+    def test_keyword_bug_filing(self, repo, bugzilla_cassette):
         mk_repo(repo)
-        session = BugsSession()
+        bugzilla_cassette.creates_bugs()
         pkg = max(repo.itermatch(atom("=cat/u-0")))
-        node = bugs.GraphNode(((pkg, {"amd64"}),), category=bugs.NodeCategory.KEYWORDREQ)
-        with patch("pkgdev.scripts.pkgdev_bugs.urllib.urlopen", session):
-            node.file_bug("API", frozenset(), (), None)
-        assert len(session.calls) == 1
-        call = session.calls[0]
+        node = bugs.GraphNode(((pkg, {"amd64"}),), category=BugCategory.KEYWORDREQ)
+        node.file_bug(bugzilla_cassette.client(api_key="API"), frozenset(), (), None)
+        assert len(bugzilla_cassette.calls) == 1
+        call = bugzilla_cassette.calls[0].body
         # keywordreq bugs are version-less and request ~arch keywords
         assert call["component"] == "Keywording"
         assert call["summary"] == "cat/u: keywordreq"
@@ -138,7 +118,7 @@ class TestSuggestedKeywords:
 
 
 class TestStableKeywordChain:
-    def _mk_graph(self, repo, category=bugs.NodeCategory.STABLEREQ):
+    def _mk_graph(self, repo, category=BugCategory.STABLEREQ):
         # build a DependencyGraph without running its heavy __init__
         graph = bugs.DependencyGraph.__new__(bugs.DependencyGraph)
         graph.options = SimpleNamespace(repo=repo, category=category)
@@ -173,9 +153,9 @@ class TestStableKeywordChain:
             for node in graph.nodes
             for p, _ in node.pkgs
         }
-        parent_stable = by_key[("cat/parent-2", bugs.NodeCategory.STABLEREQ)]
-        dep_stable = by_key[("cat/dep-1", bugs.NodeCategory.STABLEREQ)]
-        dep_keyword = by_key[("cat/dep-1", bugs.NodeCategory.KEYWORDREQ)]
+        parent_stable = by_key[("cat/parent-2", BugCategory.STABLEREQ)]
+        dep_stable = by_key[("cat/dep-1", BugCategory.STABLEREQ)]
+        dep_keyword = by_key[("cat/dep-1", BugCategory.KEYWORDREQ)]
         # three distinct nodes, dep appears as both stable and keyword
         assert len(graph.nodes) == 3
         assert dep_stable is not dep_keyword
@@ -220,7 +200,7 @@ class TestStableKeywordChain:
         # a keyword target with no other versions to derive arches from must error
         repo.create_ebuild("cat/a-1", KEYWORDS=["~amd64"])
         pkg = max(repo.itermatch(atom("=cat/a-1")))
-        graph = self._mk_graph(repo, category=bugs.NodeCategory.KEYWORDREQ)
+        graph = self._mk_graph(repo, category=BugCategory.KEYWORDREQ)
         graph.targets = (pkg,)
         graph._find_dependencies = lambda *a, **k: iter(())
         with pytest.raises(SystemExit):
@@ -237,7 +217,7 @@ class TestStableKeywordChain:
         # requesting a masked keyword is a hard error
         repo.create_ebuild("cat/a-1", KEYWORDS=keywords)
         pkg = max(repo.itermatch(atom("=cat/a-1")))
-        graph = self._mk_graph(repo, category=bugs.NodeCategory.KEYWORDREQ)
+        graph = self._mk_graph(repo, category=BugCategory.KEYWORDREQ)
         graph.targets = (pkg,)
         graph.target_arches = {pkg: frozenset({"loong"})}
         graph._find_dependencies = lambda *a, **k: iter(())
@@ -286,8 +266,8 @@ class TestStableKeywordChain:
         )
         graph.load_graph_toml(str(toml_file))
         assert {node.category for node in graph.nodes} == {
-            bugs.NodeCategory.KEYWORDREQ,
-            bugs.NodeCategory.STABLEREQ,
+            BugCategory.KEYWORDREQ,
+            BugCategory.STABLEREQ,
         }
 
     def test_edit_graph_roundtrip_preserves_starting_node(self, repo):
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.