1 |
commit: 6819d87b2e1a65aa57f959f07b8d226578dda634 |
2 |
Author: Arthur Zamarin <arthurzam <AT> gentoo <DOT> org> |
3 |
AuthorDate: Sun Jan 8 20:17:13 2023 +0000 |
4 |
Commit: Arthur Zamarin <arthurzam <AT> gentoo <DOT> org> |
5 |
CommitDate: Wed Mar 1 19:18:47 2023 +0000 |
6 |
URL: https://gitweb.gentoo.org/proj/pkgcore/pkgdev.git/commit/?id=6819d87b |
7 |
|
8 |
pkgdev bugs: new tool for filing stable bugs |
9 |
|
10 |
This new tool isn't complete, and any usage should be manually monitored |
11 |
for failures or incorrect results. This tool will be improved in the |
12 |
future, but for now it's a good start. |
13 |
|
14 |
Resolves: https://github.com/pkgcore/pkgdev/issues/113 |
15 |
Signed-off-by: Arthur Zamarin <arthurzam <AT> gentoo.org> |
16 |
|
17 |
data/share/bash-completion/completions/pkgdev | 22 ++ |
18 |
src/pkgdev/scripts/pkgdev_bugs.py | 415 ++++++++++++++++++++++++++ |
19 |
tests/scripts/test_pkgdev_bugs.py | 104 +++++++ |
20 |
3 files changed, 541 insertions(+) |
21 |
|
22 |
diff --git a/data/share/bash-completion/completions/pkgdev b/data/share/bash-completion/completions/pkgdev |
23 |
index 37e7a4b..223a7d9 100644 |
24 |
--- a/data/share/bash-completion/completions/pkgdev |
25 |
+++ b/data/share/bash-completion/completions/pkgdev |
26 |
@@ -7,6 +7,7 @@ _pkgdev() { |
27 |
_init_completion || return |
28 |
|
29 |
local subcommands=" |
30 |
+ bugs |
31 |
commit |
32 |
manifest |
33 |
mask |
34 |
@@ -229,6 +230,27 @@ _pkgdev() { |
35 |
;; |
36 |
esac |
37 |
;; |
38 |
+ bugs) |
39 |
+ subcmd_options=" |
40 |
+ --api-key |
41 |
+ --auto-cc-arches |
42 |
+ --dot |
43 |
+ -s --stablereq |
44 |
+ -k --keywording |
45 |
+ " |
46 |
+ |
47 |
+ case "${prev}" in |
48 |
+ --api-key | --auto-cc-arches) |
49 |
+ COMPREPLY=() |
50 |
+ ;; |
51 |
+ --dot) |
52 |
+ COMPREPLY=($(compgen -f -- "${cur}")) |
53 |
+ ;; |
54 |
+ *) |
55 |
+ COMPREPLY+=($(compgen -W "${subcmd_options}" -- "${cur}")) |
56 |
+ ;; |
57 |
+ esac |
58 |
+ ;; |
59 |
esac |
60 |
} |
61 |
complete -F _pkgdev pkgdev |
62 |
|
63 |
diff --git a/src/pkgdev/scripts/pkgdev_bugs.py b/src/pkgdev/scripts/pkgdev_bugs.py |
64 |
new file mode 100644 |
65 |
index 0000000..9e8938f |
66 |
--- /dev/null |
67 |
+++ b/src/pkgdev/scripts/pkgdev_bugs.py |
68 |
@@ -0,0 +1,415 @@ |
69 |
+"""Automatic bugs filler""" |
70 |
+ |
71 |
+import json |
72 |
+import urllib.request as urllib |
73 |
+from collections import defaultdict |
74 |
+from functools import partial |
75 |
+from itertools import chain |
76 |
+ |
77 |
+from pkgcheck import const as pkgcheck_const |
78 |
+from pkgcheck.addons import ArchesAddon, init_addon |
79 |
+from pkgcheck.addons.profiles import ProfileAddon |
80 |
+from pkgcheck.checks import visibility |
81 |
+from pkgcheck.scripts import argparse_actions |
82 |
+from pkgcore.ebuild.atom import atom |
83 |
+from pkgcore.ebuild.ebuild_src import package |
84 |
+from pkgcore.ebuild.misc import sort_keywords |
85 |
+from pkgcore.repository import multiplex |
86 |
+from pkgcore.restrictions import boolean, packages, values |
87 |
+from pkgcore.test.misc import FakePkg |
88 |
+from pkgcore.util import commandline |
89 |
+from snakeoil.cli import arghparse |
90 |
+from snakeoil.cli.input import userquery |
91 |
+from snakeoil.formatters import Formatter |
92 |
+ |
93 |
+from ..cli import ArgumentParser |
94 |
+from .argparsers import _determine_cwd_repo, cwd_repo_argparser |
95 |
+ |
96 |
+bugs = ArgumentParser( |
97 |
+ prog="pkgdev bugs", description=__doc__, verbose=False, quiet=False, |
98 |
+ parents=(cwd_repo_argparser, ) |
99 |
+) |
100 |
+bugs.add_argument( |
101 |
+ "--api-key", |
102 |
+ metavar="KEY", |
103 |
+ help="Bugzilla API key", |
104 |
+ docs=""" |
105 |
+ The Bugzilla API key to use for authentication. Used mainly to overcome |
106 |
+ rate limiting done by bugzilla server. This tool doesn't perform any |
107 |
+ bug editing, just fetching info for the bug. |
108 |
+ """, |
109 |
+) |
110 |
+bugs.add_argument( |
111 |
+ "targets", metavar="target", nargs="+", |
112 |
+ action=commandline.StoreTarget, |
113 |
+ help="extended atom matching of packages", |
114 |
+) |
115 |
+bugs.add_argument( |
116 |
+ "--dot", |
117 |
+ help="path file where to save the graph in dot format", |
118 |
+) |
119 |
+bugs.add_argument( |
120 |
+ "--auto-cc-arches", |
121 |
+ action=arghparse.CommaSeparatedNegationsAppend, |
122 |
+ default=([], []), |
123 |
+ help="automatically add CC-ARCHES for the listed email addresses", |
124 |
+ docs=""" |
125 |
+ Comma separated list of email addresses, for which automatically add |
126 |
+ CC-ARCHES if one of the maintainers matches the email address. If the |
127 |
+ package is maintainer-needed, always add CC-ARCHES. |
128 |
+ """, |
129 |
+) |
130 |
+ |
131 |
+bugs.add_argument( |
132 |
+ "--cache", |
133 |
+ action=argparse_actions.CacheNegations, |
134 |
+ help=arghparse.SUPPRESS, |
135 |
+) |
136 |
+bugs.add_argument( |
137 |
+ "--cache-dir", |
138 |
+ type=arghparse.create_dir, |
139 |
+ default=pkgcheck_const.USER_CACHE_DIR, |
140 |
+ help=arghparse.SUPPRESS, |
141 |
+) |
142 |
+bugs_state = bugs.add_mutually_exclusive_group() |
143 |
+bugs_state.add_argument( |
144 |
+ "-s", |
145 |
+ "--stablereq", |
146 |
+ dest="keywording", |
147 |
+ default=None, |
148 |
+ action="store_false", |
149 |
+ help="File stable request bugs", |
150 |
+) |
151 |
+bugs_state.add_argument( |
152 |
+ "-k", |
153 |
+ "--keywording", |
154 |
+ dest="keywording", |
155 |
+ default=None, |
156 |
+ action="store_true", |
157 |
+ help="File rekeywording bugs", |
158 |
+) |
159 |
+ |
160 |
+ArchesAddon.mangle_argparser(bugs) |
161 |
+ProfileAddon.mangle_argparser(bugs) |
162 |
+ |
163 |
+ |
164 |
+@××××.bind_delayed_default(1500, "target_repo") |
165 |
+def _validate_args(namespace, attr): |
166 |
+ _determine_cwd_repo(bugs, namespace) |
167 |
+ setattr(namespace, attr, namespace.repo) |
168 |
+ setattr(namespace, "verbosity", 1) |
169 |
+ setattr(namespace, "search_repo", multiplex.tree(*namespace.repo.trees)) |
170 |
+ setattr(namespace, "query_caching_freq", "package") |
171 |
+ |
172 |
+ |
173 |
+@××××.bind_final_check |
174 |
+def _validate_args(parser, namespace): |
175 |
+ if namespace.keywording: |
176 |
+ parser.error("keywording is not implemented yet, sorry") |
177 |
+ |
178 |
+def _get_suggested_keywords(repo, pkg: package): |
179 |
+ match_keywords = { |
180 |
+ x |
181 |
+ for pkgver in repo.match(pkg.unversioned_atom) |
182 |
+ for x in pkgver.keywords |
183 |
+ if x[0] not in '-~' |
184 |
+ } |
185 |
+ |
186 |
+ # limit stablereq to whatever is ~arch right now |
187 |
+ match_keywords.intersection_update(x.lstrip('~') for x in pkg.keywords if x[0] == '~') |
188 |
+ |
189 |
+ return frozenset({x for x in match_keywords if '-' not in x}) |
190 |
+ |
191 |
+ |
192 |
+class GraphNode: |
193 |
+ __slots__ = ("pkgs", "edges", "bugno") |
194 |
+ |
195 |
+ def __init__(self, pkgs: tuple[tuple[package, set[str]], ...], bugno=None): |
196 |
+ self.pkgs = pkgs |
197 |
+ self.edges: set[GraphNode] = set() |
198 |
+ self.bugno = bugno |
199 |
+ |
200 |
+ def __eq__(self, __o: object): |
201 |
+ return self is __o |
202 |
+ |
203 |
+ def __hash__(self): |
204 |
+ return hash(id(self)) |
205 |
+ |
206 |
+ def __str__(self): |
207 |
+ return ", ".join(str(pkg.versioned_atom) for pkg, _ in self.pkgs) |
208 |
+ |
209 |
+ def __repr__(self): |
210 |
+ return str(self) |
211 |
+ |
212 |
+ def lines(self): |
213 |
+ for pkg, keywords in self.pkgs: |
214 |
+ yield f"{pkg.versioned_atom} {' '.join(sort_keywords(keywords))}" |
215 |
+ |
216 |
+ @property |
217 |
+ def dot_edge(self): |
218 |
+ return f'"{self.pkgs[0][0].versioned_atom}"' |
219 |
+ |
220 |
+ def cleanup_keywords(self, repo): |
221 |
+ previous = frozenset() |
222 |
+ for pkg, keywords in self.pkgs: |
223 |
+ if keywords == previous: |
224 |
+ keywords.clear() |
225 |
+ keywords.add("^") |
226 |
+ else: |
227 |
+ previous = frozenset(keywords) |
228 |
+ |
229 |
+ for pkg, keywords in self.pkgs: |
230 |
+ suggested = _get_suggested_keywords(repo, pkg) |
231 |
+ if keywords == set(suggested): |
232 |
+ keywords.clear() |
233 |
+ keywords.add("*") |
234 |
+ |
235 |
+ def file_bug(self, api_key: str, auto_cc_arches: frozenset[str], observer=None) -> int: |
236 |
+ if self.bugno is not None: |
237 |
+ return self.bugno |
238 |
+ for dep in self.edges: |
239 |
+ if dep.bugno is None: |
240 |
+ dep.file_bug(api_key, auto_cc_arches, observer) |
241 |
+ maintainers = dict.fromkeys( |
242 |
+ maintainer.email |
243 |
+ for pkg, _ in self.pkgs |
244 |
+ for maintainer in pkg.maintainers |
245 |
+ ) |
246 |
+ if not maintainers or "*" in auto_cc_arches or auto_cc_arches.intersection(maintainers): |
247 |
+ keywords = ["CC-ARCHES"] |
248 |
+ else: |
249 |
+ keywords = [] |
250 |
+ maintainers = tuple(maintainers) or ("maintainer-needed@g.o", ) |
251 |
+ |
252 |
+ request_data = dict( |
253 |
+ Bugzilla_api_key=api_key, |
254 |
+ product="Gentoo Linux", |
255 |
+ component="Stabilization", |
256 |
+ severity="enhancement", |
257 |
+ version="unspecified", |
258 |
+ summary=f"{', '.join(pkg.versioned_atom.cpvstr for pkg, _ in self.pkgs)}: stablereq", |
259 |
+ description="Please stabilize", |
260 |
+ keywords=keywords, |
261 |
+ cf_stabilisation_atoms="\n".join(self.lines()), |
262 |
+ assigned_to=maintainers[0], |
263 |
+ cc=maintainers[1:], |
264 |
+ depends_on=list({dep.bugno for dep in self.edges}), |
265 |
+ ) |
266 |
+ request = urllib.Request( |
267 |
+ url='https://bugs.gentoo.org/rest/bug', |
268 |
+ data=json.dumps(request_data).encode('utf-8'), |
269 |
+ method='POST', |
270 |
+ headers={ |
271 |
+ "Content-Type": "application/json", |
272 |
+ "Accept": "application/json", |
273 |
+ }, |
274 |
+ ) |
275 |
+ with urllib.urlopen(request, timeout=30) as response: |
276 |
+ reply = json.loads(response.read().decode('utf-8')) |
277 |
+ self.bugno = int(reply['id']) |
278 |
+ if observer is not None: |
279 |
+ observer(self) |
280 |
+ return self.bugno |
281 |
+ |
282 |
+class DependencyGraph: |
283 |
+ def __init__(self, out: Formatter, err: Formatter, options): |
284 |
+ self.out = out |
285 |
+ self.err = err |
286 |
+ self.options = options |
287 |
+ self.profile_addon: ProfileAddon = init_addon(ProfileAddon, options) |
288 |
+ |
289 |
+ self.nodes: set[GraphNode] = set() |
290 |
+ self.starting_nodes: set[GraphNode] = set() |
291 |
+ |
292 |
+ def mk_fake_pkg(self, pkg: package, keywords: set[str]): |
293 |
+ return FakePkg( |
294 |
+ cpv=pkg.cpvstr, |
295 |
+ eapi=str(pkg.eapi), |
296 |
+ iuse=pkg.iuse, |
297 |
+ repo=pkg.repo, |
298 |
+ keywords=tuple(keywords), |
299 |
+ data={ |
300 |
+ attr: str(getattr(pkg, attr.lower())) |
301 |
+ for attr in pkg.eapi.dep_keys |
302 |
+ }, |
303 |
+ ) |
304 |
+ |
305 |
+ def find_best_match(self, restrict, pkgset: list[package]) -> package: |
306 |
+ restrict = boolean.AndRestriction(restrict, packages.PackageRestriction( |
307 |
+ "properties", values.ContainmentMatch("live", negate=True) |
308 |
+ )) |
309 |
+ # prefer using already selected packages in graph |
310 |
+ all_pkgs = (pkg for node in self.nodes for pkg, _ in node.pkgs) |
311 |
+ if intersect := tuple(filter(restrict.match, all_pkgs)): |
312 |
+ return max(intersect) |
313 |
+ matches = sorted(filter(restrict.match, pkgset), reverse=True) |
314 |
+ for match in matches: |
315 |
+ if not all(keyword.startswith("~") for keyword in match.keywords): |
316 |
+ return match |
317 |
+ return matches[0] |
318 |
+ |
319 |
+ def _find_dependencies(self, pkg: package, keywords: set[str]): |
320 |
+ check = visibility.VisibilityCheck(self.options, profile_addon=self.profile_addon) |
321 |
+ |
322 |
+ issues: dict[str, dict[str, set[atom]]] = defaultdict(partial(defaultdict, set)) |
323 |
+ for res in check.feed(self.mk_fake_pkg(pkg, keywords)): |
324 |
+ if isinstance(res, visibility.NonsolvableDeps): |
325 |
+ for dep in res.deps: |
326 |
+ dep = atom(dep).no_usedeps |
327 |
+ issues[dep.key][res.keyword.lstrip('~')].add(dep) |
328 |
+ |
329 |
+ for pkgname, problems in issues.items(): |
330 |
+ pkgset: list[package] = self.options.repo.match(atom(pkgname)) |
331 |
+ try: |
332 |
+ combined = boolean.AndRestriction(*set().union(*problems.values())) |
333 |
+ match = self.find_best_match(combined, pkgset) |
334 |
+ yield match, set(problems.keys()) |
335 |
+ except ValueError: |
336 |
+ results: dict[package, set[str]] = defaultdict(set) |
337 |
+ for keyword, deps in problems.items(): |
338 |
+ match = self.find_best_match(deps, pkgset) |
339 |
+ results[match].add(keyword) |
340 |
+ yield from results.items() |
341 |
+ |
342 |
+ def build_full_graph(self, targets: list[package]): |
343 |
+ check_nodes = [(pkg, set()) for pkg in targets] |
344 |
+ |
345 |
+ vertices: dict[package, GraphNode] = {} |
346 |
+ edges = [] |
347 |
+ while len(check_nodes): |
348 |
+ pkg, keywords = check_nodes.pop(0) |
349 |
+ if pkg in vertices: |
350 |
+ vertices[pkg].pkgs[0][1].update(keywords) |
351 |
+ continue |
352 |
+ |
353 |
+ keywords.update(_get_suggested_keywords(self.options.repo, pkg)) |
354 |
+ assert keywords |
355 |
+ self.nodes.add(new_node := GraphNode(((pkg, keywords), ))) |
356 |
+ vertices[pkg] = new_node |
357 |
+ self.out.write(f"Checking {pkg.versioned_atom} on {' '.join(sort_keywords(keywords))!r}") |
358 |
+ self.out.flush() |
359 |
+ |
360 |
+ for dep, keywords in self._find_dependencies(pkg, keywords): |
361 |
+ edges.append((pkg, dep)) |
362 |
+ check_nodes.append((dep, keywords)) |
363 |
+ |
364 |
+ for src, dst in edges: |
365 |
+ vertices[src].edges.add(vertices[dst]) |
366 |
+ self.starting_nodes = {vertices[starting_node] for starting_node in targets} |
367 |
+ |
368 |
+ def output_dot(self, dot_file): |
369 |
+ with open(dot_file, "w") as dot: |
370 |
+ dot.write("digraph {\n") |
371 |
+ dot.write("\trankdir=LR;\n") |
372 |
+ for node in self.nodes: |
373 |
+ node_text = "\\n".join(node.lines()) |
374 |
+ dot.write(f'\t{node.dot_edge}[label="{node_text}"];\n') |
375 |
+ for other in node.edges: |
376 |
+ dot.write(f"\t{node.dot_edge} -> {other.dot_edge};\n") |
377 |
+ dot.write("}\n") |
378 |
+ dot.close() |
379 |
+ |
380 |
+ def merge_nodes(self, nodes: tuple[GraphNode, ...]) -> GraphNode: |
381 |
+ self.nodes.difference_update(nodes) |
382 |
+ self.starting_nodes.difference_update(nodes) |
383 |
+ new_node = GraphNode(list(chain.from_iterable(n.pkgs for n in nodes))) |
384 |
+ |
385 |
+ for node in nodes: |
386 |
+ new_node.edges.update(node.edges.difference(nodes)) |
387 |
+ |
388 |
+ for node in self.nodes: |
389 |
+ if node.edges.intersection(nodes): |
390 |
+ node.edges.difference_update(nodes) |
391 |
+ node.edges.add(new_node) |
392 |
+ |
393 |
+ self.nodes.add(new_node) |
394 |
+ return new_node |
395 |
+ |
396 |
+ @staticmethod |
397 |
+ def _find_cycles(nodes: tuple[GraphNode, ...], stack: list[GraphNode]) -> tuple[GraphNode, ...]: |
398 |
+ node = stack[-1] |
399 |
+ for edge in node.edges: |
400 |
+ if edge in stack: |
401 |
+ return tuple(stack[stack.index(edge):]) |
402 |
+ stack.append(edge) |
403 |
+ if cycle := DependencyGraph._find_cycles(nodes, stack): |
404 |
+ return cycle |
405 |
+ stack.pop() |
406 |
+ return () |
407 |
+ |
408 |
+ def merge_cycles(self): |
409 |
+ new_starts = set() |
410 |
+ while self.starting_nodes: |
411 |
+ starting_node = self.starting_nodes.pop() |
412 |
+ assert starting_node in self.nodes |
413 |
+ while cycle := self._find_cycles(tuple(self.nodes), [starting_node]): |
414 |
+ print("Found cycle:", " -> ".join(str(n) for n in cycle)) |
415 |
+ new_node = self.merge_nodes(cycle) |
416 |
+ if starting_node not in self.nodes: |
417 |
+ starting_node = new_node |
418 |
+ new_starts.add(starting_node) |
419 |
+ self.starting_nodes.update(new_starts) |
420 |
+ |
421 |
+ def merge_new_keywords_children(self): |
422 |
+ repo = self.options.search_repo |
423 |
+ found_someone = True |
424 |
+ while found_someone: |
425 |
+ reverse_edges: dict[GraphNode, set[GraphNode]] = defaultdict(set) |
426 |
+ for node in self.nodes: |
427 |
+ for dep in node.edges: |
428 |
+ reverse_edges[dep].add(node) |
429 |
+ found_someone = False |
430 |
+ for node, origs in reverse_edges.items(): |
431 |
+ if len(origs) != 1: |
432 |
+ continue |
433 |
+ existing_keywords = frozenset().union(*( |
434 |
+ pkgver.keywords |
435 |
+ for pkg in node.pkgs |
436 |
+ for pkgver in repo.match(pkg[0].unversioned_atom) |
437 |
+ )) |
438 |
+ if existing_keywords & frozenset().union(*(pkg[1] for pkg in node.pkgs)): |
439 |
+ continue # not fully new keywords |
440 |
+ orig = next(iter(origs)) |
441 |
+ print(f"Merging {node} into {orig}") |
442 |
+ self.merge_nodes((orig, node)) |
443 |
+ found_someone = True |
444 |
+ break |
445 |
+ |
446 |
+ def file_bugs(self, api_key: str, auto_cc_arches: frozenset[str]): |
447 |
+ def observe(node: GraphNode): |
448 |
+ self.out.write( |
449 |
+ f"https://bugs.gentoo.org/{node.bugno} ", |
450 |
+ " | ".join(node.lines()), |
451 |
+ " depends on bugs ", {dep.bugno for dep in node.edges} |
452 |
+ ) |
453 |
+ self.out.flush() |
454 |
+ |
455 |
+ for node in self.starting_nodes: |
456 |
+ node.file_bug(api_key, auto_cc_arches, observe) |
457 |
+ |
458 |
+ |
459 |
+@××××.bind_main_func |
460 |
+def main(options, out: Formatter, err: Formatter): |
461 |
+ search_repo = options.search_repo |
462 |
+ targets = [max(search_repo.itermatch(target)) for _, target in options.targets] |
463 |
+ d = DependencyGraph(out, err, options) |
464 |
+ d.build_full_graph(targets) |
465 |
+ d.merge_cycles() |
466 |
+ d.merge_new_keywords_children() |
467 |
+ |
468 |
+ for node in d.nodes: |
469 |
+ node.cleanup_keywords(search_repo) |
470 |
+ |
471 |
+ if options.dot is not None: |
472 |
+ d.output_dot(options.dot) |
473 |
+ out.write(out.fg("green"), f"Dot file written to {options.dot}", out.reset) |
474 |
+ |
475 |
+ if not userquery(f'Continue and create {len(d.nodes)} stablereq bugs?', out, err, default_answer=False): |
476 |
+ return 1 |
477 |
+ |
478 |
+ if options.api_key is None: |
479 |
+ err.write(out.fg("red"), "No API key provided, exiting", out.reset) |
480 |
+ return 1 |
481 |
+ |
482 |
+ disabled, enabled = options.auto_cc_arches |
483 |
+ d.file_bugs(options.api_key, frozenset(enabled).difference(disabled)) |
484 |
|
485 |
diff --git a/tests/scripts/test_pkgdev_bugs.py b/tests/scripts/test_pkgdev_bugs.py |
486 |
new file mode 100644 |
487 |
index 0000000..f23051e |
488 |
--- /dev/null |
489 |
+++ b/tests/scripts/test_pkgdev_bugs.py |
490 |
@@ -0,0 +1,104 @@ |
491 |
+import itertools |
492 |
+import os |
493 |
+import sys |
494 |
+import json |
495 |
+import textwrap |
496 |
+from types import SimpleNamespace |
497 |
+from unittest.mock import patch |
498 |
+ |
499 |
+import pytest |
500 |
+from pkgcore.ebuild.atom import atom |
501 |
+from pkgcore.test.misc import FakePkg |
502 |
+from pkgdev.scripts import pkgdev_bugs as bugs |
503 |
+from snakeoil.formatters import PlainTextFormatter |
504 |
+from snakeoil.osutils import pjoin |
505 |
+ |
506 |
+def mk_pkg(repo, cpvstr, maintainers, **kwargs): |
507 |
+ kwargs.setdefault("KEYWORDS", ["~amd64"]) |
508 |
+ pkgdir = os.path.dirname(repo.create_ebuild(cpvstr, **kwargs)) |
509 |
+ # stub metadata |
510 |
+ with open(pjoin(pkgdir, 'metadata.xml'), 'w') as f: |
511 |
+ f.write(textwrap.dedent(f"""\ |
512 |
+ <?xml version="1.0" encoding="UTF-8"?> |
513 |
+ <!DOCTYPE pkgmetadata SYSTEM "https://www.gentoo.org/dtd/metadata.dtd"> |
514 |
+ <pkgmetadata> |
515 |
+ <maintainer type="person"> |
516 |
+ {' '.join(f'<email>{maintainer}@gentoo.org</email>' for maintainer in maintainers)} |
517 |
+ </maintainer> |
518 |
+ </pkgmetadata> |
519 |
+ """)) |
520 |
+ |
521 |
+ |
522 |
+def mk_repo(repo): |
523 |
+ mk_pkg(repo, 'cat/u-0', ['dev1']) |
524 |
+ mk_pkg(repo, 'cat/z-0', [], RDEPEND=['cat/u', 'cat/x']) |
525 |
+ mk_pkg(repo, 'cat/v-0', ['dev2'], RDEPEND='cat/x') |
526 |
+ mk_pkg(repo, 'cat/y-0', ['dev1'], RDEPEND=['cat/z', 'cat/v']) |
527 |
+ mk_pkg(repo, 'cat/x-0', ['dev3'], RDEPEND='cat/y') |
528 |
+ mk_pkg(repo, 'cat/w-0', ['dev3'], RDEPEND='cat/x') |
529 |
+ |
530 |
+ |
531 |
+class BugsSession: |
532 |
+ def __init__(self): |
533 |
+ self.counter = iter(itertools.count(1)) |
534 |
+ self.calls = [] |
535 |
+ |
536 |
+ def __enter__(self): |
537 |
+ return self |
538 |
+ |
539 |
+ def __exit__(self, *_args): |
540 |
+ ... |
541 |
+ |
542 |
+ def read(self): |
543 |
+ return json.dumps({'id': next(self.counter)}).encode('utf-8') |
544 |
+ |
545 |
+ def __call__(self, request, *_args, **_kwargs): |
546 |
+ self.calls.append(json.loads(request.data)) |
547 |
+ return self |
548 |
+ |
549 |
+ |
550 |
+class TestBugFiling: |
551 |
+ def test_bug_filing(self, repo): |
552 |
+ mk_repo(repo) |
553 |
+ session = BugsSession() |
554 |
+ pkg = max(repo.itermatch(atom('=cat/u-0'))) |
555 |
+ with patch('pkgdev.scripts.pkgdev_bugs.urllib.urlopen', session): |
556 |
+ bugs.GraphNode(((pkg, {'*'}), )).file_bug("API", frozenset()) |
557 |
+ assert len(session.calls) == 1 |
558 |
+ call = session.calls[0] |
559 |
+ assert call['Bugzilla_api_key'] == 'API' |
560 |
+ assert call['summary'] == 'cat/u-0: stablereq' |
561 |
+ assert call['assigned_to'] == 'dev1@g.o' |
562 |
+ assert not call['cc'] |
563 |
+ assert call['cf_stabilisation_atoms'] == '=cat/u-0 *' |
564 |
+ assert not call['depends_on'] |
565 |
+ |
566 |
+ def test_bug_filing_maintainer_needed(self, repo): |
567 |
+ mk_repo(repo) |
568 |
+ session = BugsSession() |
569 |
+ pkg = max(repo.itermatch(atom('=cat/z-0'))) |
570 |
+ with patch('pkgdev.scripts.pkgdev_bugs.urllib.urlopen', session): |
571 |
+ bugs.GraphNode(((pkg, {'*'}), )).file_bug("API", frozenset()) |
572 |
+ assert len(session.calls) == 1 |
573 |
+ call = session.calls[0] |
574 |
+ assert call['assigned_to'] == 'maintainer-needed@g.o' |
575 |
+ assert not call['cc'] |
576 |
+ |
577 |
+ def test_bug_filing_multiple_pkgs(self, repo): |
578 |
+ mk_repo(repo) |
579 |
+ session = BugsSession() |
580 |
+ pkgX = max(repo.itermatch(atom('=cat/x-0'))) |
581 |
+ pkgY = max(repo.itermatch(atom('=cat/y-0'))) |
582 |
+ pkgZ = max(repo.itermatch(atom('=cat/z-0'))) |
583 |
+ dep = bugs.GraphNode((), 2) |
584 |
+ node = bugs.GraphNode(((pkgX, {'*'}), (pkgY, {'*'}), (pkgZ, {'*'}))) |
585 |
+ node.edges.add(dep) |
586 |
+ with patch('pkgdev.scripts.pkgdev_bugs.urllib.urlopen', session): |
587 |
+ node.file_bug("API", frozenset()) |
588 |
+ assert len(session.calls) == 1 |
589 |
+ call = session.calls[0] |
590 |
+ assert call['summary'] == 'cat/x-0, cat/y-0, cat/z-0: stablereq' |
591 |
+ assert call['assigned_to'] == 'dev3@g.o' |
592 |
+ assert call['cc'] == ['dev1@g.o'] |
593 |
+ assert call['cf_stabilisation_atoms'] == '=cat/x-0 *\n=cat/y-0 *\n=cat/z-0 *' |
594 |
+ assert call['depends_on'] == [2] |