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)