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

"Arthur Zamarin" <[email protected]>
Newsgroups gmane.linux.gentoo.cvs
Message-ID <1786795702.1e7cea52ad8e72508de9b6780808c56e27210694.arthurzam@gentoo>
commit:     1e7cea52ad8e72508de9b6780808c56e27210694
Author:     Arthur Zamarin <arthurzam <AT> gentoo <DOT> org>
AuthorDate: Sat Aug 15 12:08:22 2026 +0000
Commit:     Arthur Zamarin <arthurzam <AT> gentoo <DOT> org>
CommitDate: Sat Aug 15 12:08:22 2026 +0000
URL:        https://gitweb.gentoo.org/proj/pkgcore/pkgdev.git/commit/?id=1e7cea52

bugs: don't let one existing bug end up depending on itself

scan_existing_bugs matches nodes against open bugs independently, so one bug
can come back as the bug for several of them: a bug covering a package and
its dependency matches both. Bugzilla knows a single bug there, and when a
dependency path runs between two such nodes, filing walks it and asks for
that bug to depend on a bug which already depends on it:

    POST /rest/bug        depends_on: [597]         -> creates 901
    PUT  /rest/bug/597    depends_on: {add: [901]}

which bugzilla rejects part way through, having already filed some of the
bugs.

Merge the nodes matched to the same bug before anything is filed. What was a
path between them becomes an ordinary cycle, which merge_cycles already folds
together. For that to be enough merge_nodes has to carry the bug number
through a merge rather than drop it, which also stops a merged node filing a
duplicate of a bug it had already been matched to, and a merged node must not
obsolete the bug it ends up keeping.

Nodes matched to different bugs are refused rather than guessed at: the old
behavior silently dropped both numbers and filed a third bug, leaving two
stale ones behind.

The same reasoning applies to obsoletion, so a bug kept as one node's own is
no longer resolved as obsolete on behalf of another; that pair of answers to
the same bug is contradictory, and keeping it open is the recoverable half.

Name the node and bug on every write while here, so a rejection says which
one it was about, as asked for on the issue.

Resolves: https://github.com/pkgcore/pkgdev/issues/229
Signed-off-by: Arthur Zamarin <arthurzam <AT> gentoo.org>

 NEWS.rst                          | 14 ++++++
 src/pkgdev/scripts/pkgdev_bugs.py | 76 ++++++++++++++++++++++++++------
 tests/scripts/test_pkgdev_bugs.py | 93 ++++++++++++++++++++++++++++++++++++++-
 3 files changed, 167 insertions(+), 16 deletions(-)

diff --git a/NEWS.rst b/NEWS.rst
index c73a2da..d338b23 100644
--- a/NEWS.rst
+++ b/NEWS.rst
@@ -2,6 +2,20 @@
 Release Notes
 =============
 
+pkgdev 0.2.18 (unreleased)
+--------------------------
+
+**pkgdev bugs:**
+
+- bugs: fix a "circular dependency" error part way through filing, when one
+  existing bug was matched by several nodes and a dependency path ran between
+  them, which made that bug depend on a bug depending on it. Nodes matched to
+  the same bug are now merged into one, and a bug kept as a node's own bug is
+  no longer obsoleted by another (Arthur Zamarin, #229)
+
+- bugs: a bugzilla error while filing now names the bug and packages being
+  filed or modified (Arthur Zamarin, #229)
+
 pkgdev 0.2.17 (2026-08-14)
 --------------------------
 

diff --git a/src/pkgdev/scripts/pkgdev_bugs.py b/src/pkgdev/scripts/pkgdev_bugs.py
index 9d778f0..86fda45 100644
--- a/src/pkgdev/scripts/pkgdev_bugs.py
+++ b/src/pkgdev/scripts/pkgdev_bugs.py
@@ -26,6 +26,7 @@ from pkgcore.bugzilla import (
     BugQuery,
     BugUpdate,
     Bugzilla,
+    BugzillaError,
     ListChange,
     NewBug,
     PackageList,
@@ -289,6 +290,15 @@ def parse_atom(pkg: str):
             raise exc
 
 
[email protected]
+def _naming(what: str):
+    """Say what was being filed when bugzilla rejects a change."""
+    try:
+        yield
+    except BugzillaError as exc:
+        raise BugzillaError(f"{what}: {exc}") from exc
+
+
 class GraphNode:
     __slots__ = ("bugno", "category", "cc_arches", "edges", "obsoletes", "pkgs", "summary")
 
@@ -387,7 +397,8 @@ class GraphNode:
         if self.bugno is not None:
             # an already existing bug may still be missing deps, and may supersede older bugs
             if deps := self.file_missing_deps(bugzilla, auto_cc_arches, modified_repo, observer):
-                bugzilla.update(self.bugno, BugUpdate(depends_on=ListChange.adding(*deps)))
+                with _naming(f"adding dependencies to bug {self.bugno} for {self}"):
+                    bugzilla.update(self.bugno, BugUpdate(depends_on=ListChange.adding(*deps)))
             self.obsolete_bugs(bugzilla)
             return self.bugno
         self.file_missing_deps(bugzilla, auto_cc_arches, modified_repo, observer)
@@ -403,18 +414,19 @@ class GraphNode:
                         f" {pkg.versioned_atom.cpvstr}: no change for {days_old} days, since {modified:%Y-%m-%d}"
                     )
 
-        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 _naming(f"filing bug for {self}"):
+            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),
+                )
             )
-        )
         if observer is not None:
             observer(self)
         self.obsolete_bugs(bugzilla)
@@ -438,7 +450,8 @@ class GraphNode:
         if not self.obsoletes:
             return
         assert self.bugno is not None
-        bugzilla.update(sorted(self.obsoletes), BugUpdate.obsoleted_by(self.bugno))
+        with _naming(f"obsoleting bugs by {self.bugno} for {self}"):
+            bugzilla.update(sorted(self.obsoletes), BugUpdate.obsoleted_by(self.bugno))
         self.obsoletes.clear()  # don't repeat the update if visited again
 
 
@@ -901,16 +914,26 @@ class DependencyGraph:
     def merge_nodes(self, nodes: tuple[GraphNode, ...]) -> GraphNode:
         categories = {node.category for node in nodes}
         assert len(categories) == 1, f"refusing to merge nodes of mixed categories: {categories}"
+        bugnos = {node.bugno for node in nodes if node.bugno is not None}
+        if len(bugnos) > 1:
+            bugs.error(
+                "cannot merge nodes matched to different existing bugs: "
+                + ", ".join(f"https://bugs.gentoo.org/{bugno}" for bugno in sorted(bugnos)),
+                status=3,
+            )
         self.nodes.difference_update(nodes)
         is_start = bool(self.starting_nodes.intersection(nodes))
         self.starting_nodes.difference_update(nodes)
         new_node = GraphNode(
-            list(chain.from_iterable(n.pkgs for n in nodes)), category=categories.pop()
+            list(chain.from_iterable(n.pkgs for n in nodes)),
+            category=categories.pop(),
+            bugno=next(iter(bugnos), None),
         )
 
         for node in nodes:
             new_node.edges.update(node.edges.difference(nodes))
             new_node.obsoletes.update(node.obsoletes)  # inherit pending obsoletions
+        new_node.obsoletes.discard(new_node.bugno)  # never obsolete our own bug
 
         for node in self.nodes:
             if node.edges.intersection(nodes):
@@ -922,6 +945,30 @@ class DependencyGraph:
             self.starting_nodes.add(new_node)
         return new_node
 
+    def merge_matched_bugs(self):
+        """Merge the nodes which matched the same existing bug."""
+        shared: dict[int, list[GraphNode]] = defaultdict(list)
+        for node in self.nodes:
+            if node.bugno is not None:
+                shared[node.bugno].append(node)
+
+        for bugno, nodes in sorted(shared.items()):
+            if len(nodes) > 1:
+                self.out.write(
+                    f"Merging {len(nodes)} nodes matched to bug {bugno}: ",
+                    ", ".join(map(str, nodes)),
+                )
+                self.merge_nodes(tuple(nodes))
+
+        # a bug still in use can't also be resolved as obsolete
+        in_use = {node.bugno for node in self.nodes if node.bugno is not None}
+        for node in self.nodes:
+            for bugno in sorted(node.obsoletes.intersection(in_use)):
+                self.out.warn(
+                    f"not obsoleting bug {bugno}, it is the bug of another node in the graph"
+                )
+            node.obsoletes.difference_update(in_use)
+
     @staticmethod
     def _find_cycles(nodes: tuple[GraphNode, ...], stack: list[GraphNode]) -> tuple[GraphNode, ...]:
         node = stack[-1]
@@ -1123,6 +1170,7 @@ def main(options, out: Formatter, err: Formatter):
             out.flush()
             has_output = True
 
+    d.merge_matched_bugs()
     if not d.merge_stabilization_groups(out, err):
         out.write(out.fg("red"), "Aborted", out.reset)
         return 1

diff --git a/tests/scripts/test_pkgdev_bugs.py b/tests/scripts/test_pkgdev_bugs.py
index ff6565f..e560ab8 100644
--- a/tests/scripts/test_pkgdev_bugs.py
+++ b/tests/scripts/test_pkgdev_bugs.py
@@ -4,7 +4,7 @@ from os.path import join as pjoin
 from types import SimpleNamespace
 
 import pytest
-from pkgcore.bugzilla import BugCategory
+from pkgcore.bugzilla import BugCategory, BugzillaError
 from pkgcore.ebuild.atom import atom
 
 from pkgdev.scripts import pkgdev_bugs as bugs
@@ -142,7 +142,9 @@ def mk_graph(repo, category=BugCategory.STABLEREQ):
     # build a DependencyGraph without running its heavy __init__
     graph = bugs.DependencyGraph.__new__(bugs.DependencyGraph)
     graph.options = SimpleNamespace(repo=repo, search_repo=repo, category=category)
-    graph.out = SimpleNamespace(write=lambda *a, **k: None, flush=lambda: None)
+    graph.out = SimpleNamespace(
+        write=lambda *a, **k: None, warn=lambda *a, **k: None, flush=lambda: None
+    )
     graph.err = graph.out
     graph.nodes = set()
     graph.starting_nodes = set()
@@ -509,3 +511,90 @@ class TestObsoletingBugs:
 
         merged = graph.merge_nodes((first, second))
         assert merged.obsoletes == {100, 101}
+
+
+class TestSharedExistingBug:
+    """Several nodes matching one existing bug must not make it depend on itself."""
+
+    def mk_shared(self, repo):
+        """A dependency path whose ends matched the same existing bug."""
+        pkg = max(repo.itermatch(atom("=cat/u-0")))
+        graph = mk_graph(repo)
+        top = bugs.GraphNode((), bugno=597)
+        middle = bugs.GraphNode(((pkg, {"*"}),))
+        bottom = bugs.GraphNode((), bugno=597)
+        top.edges.add(middle)
+        middle.edges.add(bottom)
+        graph.nodes.update((top, middle, bottom))
+        graph.starting_nodes.add(top)
+        return graph, middle
+
+    def test_shared_bug_is_merged(self, repo):
+        mk_repo(repo)
+        graph, middle = self.mk_shared(repo)
+        graph.merge_matched_bugs()
+
+        merged = [node for node in graph.nodes if node.bugno == 597]
+        assert len(merged) == 1, "nodes sharing a bug must become one node"
+        # the path collapsed into a cycle, which merge_cycles then folds together
+        assert merged[0].edges == {middle}
+        assert middle.edges == {merged[0]}
+        graph.merge_cycles()
+        assert len(graph.nodes) == 1
+        assert next(iter(graph.nodes)).bugno == 597, "the existing bug must be kept"
+
+    def test_no_self_dependency_is_filed(self, repo, bugzilla_cassette):
+        mk_repo(repo)
+        graph, _ = self.mk_shared(repo)
+        graph.merge_matched_bugs()
+        graph.merge_cycles()
+
+        node = next(iter(graph.nodes))
+        node.file_bug(bugzilla_cassette.client(api_key="API"), frozenset(), (), None)
+        # nothing to file: the existing bug covers the whole cycle, and 597 was
+        # never made to depend on a bug which depends on it
+        assert bugzilla_cassette.calls == []
+
+    def test_bug_in_use_is_not_obsoleted(self, repo):
+        mk_repo(repo)
+        graph, middle = self.mk_shared(repo)
+        middle.obsoletes.add(597)
+        graph.merge_matched_bugs()
+
+        assert not any(node.obsoletes for node in graph.nodes), (
+            "a bug kept as another node's bug must not also be resolved obsolete"
+        )
+
+    def test_merging_different_bugs_errors(self, repo, capsys):
+        mk_repo(repo)
+        graph = mk_graph(repo)
+        nodes = (bugs.GraphNode((), bugno=1), bugs.GraphNode((), bugno=2))
+        graph.nodes.update(nodes)
+        with pytest.raises(SystemExit) as excinfo:
+            graph.merge_nodes(nodes)
+        assert excinfo.value.code == 3
+        assert "different existing bugs" in capsys.readouterr().err
+
+
+class TestFilingErrorContext:
+    """A rejected change must name what was being filed."""
+
+    def test_creation_error_names_the_node(self, repo, bugzilla_cassette):
+        mk_repo(repo)
+        pkg = max(repo.itermatch(atom("=cat/u-0")))
+        node = bugs.GraphNode(((pkg, {"*"}),))
+        bugzilla_cassette.expect_error(116, "circular dependency")
+        with pytest.raises(BugzillaError) as excinfo:
+            node.file_bug(bugzilla_cassette.client(api_key="API"), frozenset(), (), None)
+        assert "filing bug for =cat/u-0" in str(excinfo.value)
+        assert "circular dependency" in str(excinfo.value)
+
+    def test_dependency_update_error_names_the_bug(self, repo, bugzilla_cassette):
+        mk_repo(repo)
+        pkg = max(repo.itermatch(atom("=cat/u-0")))
+        node = bugs.GraphNode((), bugno=200)
+        node.edges.add(bugs.GraphNode(((pkg, {"*"}),)))
+        bugzilla_cassette.expect_created(300).expect_error(116, "circular dependency")
+        with pytest.raises(BugzillaError) as excinfo:
+            node.file_bug(bugzilla_cassette.client(api_key="API"), frozenset(), (), None)
+        assert "adding dependencies to bug 200" in str(excinfo.value)
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.