]> git.wh0rd.org Git - gnudebbugs.git/blob - src/gnudebbugs/__main__.py
initial release
[gnudebbugs.git] / src / gnudebbugs / __main__.py
1 # Copyright (C) 2021 Free Software Foundation, Inc.
2 #
3 # This program is free software: you can redistribute it and/or modify it under
4 # the terms of the GNU Lesser General Public License as published by the Free
5 # Software Foundation, either version 3 of the License, or at your option) any
6 # later version.
7 #
8 # This program is distributed in the hope that it will be useful, but WITHOUT
9 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
10 # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
11 # details.
12 #
13 # You should have received a copy of the GNU Lesser General Public License
14 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
15
16 """CLI for debbugs.gnu.org (and other debbugs trackers)."""
17
18 import argparse
19 import collections
20 import math
21 from pathlib import Path
22 import sys
23 from typing import List, Optional, Tuple
24
25 from .control import Control
26 from . import pretty
27 from . import soap
28 from . import ui as ui_mod
29
30
31 SYSTEMS = {
32     "gnu": ("https://debbugs.gnu.org/cgi/soap.cgi", "control@debbugs.gnu.org"),
33 }
34
35 # Servers reject attempts to query more than this many bugs at once.
36 GET_STATUS_MAX_COUNT = 1000
37
38
39 def add_raw_subparser(parser: argparse.ArgumentParser) -> None:
40     group = parser.add_argument_group("output format control")
41     group.add_argument(
42         "--pretty",
43         action="store_true",
44         help="Format the output in a human friendly form",
45     )
46     # Have to specify this for each one that uses dest=format and not just the
47     # canonical --format as argparse will use None by default, and clobber the
48     # dest=format.
49     default_format = "json"
50     group.add_argument(
51         "--xml",
52         default=default_format,
53         dest="format",
54         action="store_const",
55         const="full-xml",
56         help="Output the entire raw SOAP (XML) document",
57     )
58     group.add_argument(
59         "--json",
60         default=default_format,
61         dest="format",
62         action="store_const",
63         const="json",
64         help="Output the raw data in JSON (default)",
65     )
66     group.add_argument(
67         "--format",
68         default=default_format,
69         choices=("full-xml", "xml", "json"),
70         help="Select the output format",
71     )
72
73     parser.add_argument(
74         "--chunk",
75         default=True,
76         action="store_true",
77         help="Automatically break up large requests to avoid server limits",
78     )
79     parser.add_argument(
80         "--no-chunk",
81         default=True,
82         dest="chunk",
83         action="store_false",
84         help="Do not automatically break up large requests",
85     )
86
87     subparsers = parser.add_subparsers(title="API", dest="api", required=True)
88
89     subparser = subparsers.add_parser("get_bug_log")
90     subparser.add_argument("bug", type=int, help="The bug to inspect")
91
92     subparser = subparsers.add_parser("get_bugs")
93     subparser.add_argument("args", nargs="+", help="Search terms")
94
95     subparser = subparsers.add_parser("get_status")
96     subparser.add_argument("bugs", nargs="+", type=int, help="The bugs to inspect")
97
98     subparser = subparsers.add_parser("get_usertag")
99     subparser.add_argument("user", help="The user whose tags to lookup")
100     subparser.add_argument("tags", nargs="+", help="Tags to filter by")
101
102     subparser = subparsers.add_parser("newest_bugs")
103     subparser.add_argument("count", type=int, help="How many recent bugs to return")
104
105
106 def get_parser() -> argparse.ArgumentParser:
107     """Get CLI parser."""
108     parser = argparse.ArgumentParser(description=__doc__)
109
110     parser.add_argument(
111         "-B", "--bts", default="gnu", metavar="SYSTEM", help="The bug tracker to use"
112     )
113
114     parser.add_argument(
115         "-U",
116         "--ui",
117         default="cli",
118         choices=("cli", "urwid"),
119         help="The UI to use (default: %(default)s)",
120     )
121     parser.add_argument(
122         "--mouse",
123         default=False,
124         action="store_true",
125         help="Enable mouse support (%(default)s)",
126     )
127     parser.add_argument(
128         "--no-mouse",
129         default=False,
130         dest="mouse",
131         action="store_false",
132         help="Do not enable mouse support",
133     )
134
135     # TODO: Add these to subparsers too.
136     parser.add_argument("-v", "--verbose", action="count", help="More verbose output")
137     parser.add_argument("-q", "--quiet", action="count", help="Only display errors")
138     parser.add_argument(
139         "-n", "--dry-run", dest="dryrun", action="store_true", help="Don't make changes"
140     )
141
142     group = parser.add_argument_group("e-mail options")
143     group.add_argument("--sendmail", default="sendmail", help="Sendmail command to use")
144
145     subparsers = parser.add_subparsers(
146         title="Subcommands", dest="command", required=True
147     )
148
149     # Subcommand: raw
150     subparser = subparsers.add_parser(
151         "raw",
152         aliases=["r"],
153         description="Make direct calls to the server & return raw results",
154     )
155     add_raw_subparser(subparser)
156
157     # Subcommand: interactive
158     subparser = subparsers.add_parser(
159         "interactive",
160         aliases=["i"],
161         description="Make direct calls to the server & return raw results",
162     )
163     add_raw_subparser(subparser)
164
165     # Subcommand: query
166     subparser = subparsers.add_parser("query")
167     subparser.add_argument(
168         "args",
169         nargs="+",
170         help="Package or bug numbers to query",
171         metavar="package|bugnum",
172     )
173
174     # Subcommand: control
175     subparser = subparsers.add_parser(
176         "control", description="Send control messages to change bug state"
177     )
178     subparser.add_argument("commands", nargs="+", help="Send control messages")
179
180     return parser
181
182
183 def parse_args(
184     argv: Optional[List[str]] = None,
185 ) -> Tuple[argparse.ArgumentParser, argparse.Namespace]:
186     """Parse the command line."""
187     if argv is None:
188         argv = sys.argv[1:]
189
190     parser = get_parser()
191     opts = parser.parse_args(argv)
192
193     if opts.bts not in SYSTEMS:
194         parser.error(f"Unknown system: {opts.bts}")
195
196     return (parser, opts)
197
198
199 TAG_SUMMARY = {
200     "patch": "+",
201     "wontfix": "W",
202     "moreinfo": "M",
203     "unreproducible": "R",
204     "fixed": "F",
205     "notabug": "N",
206     "pending": "P",
207     "help": "H",
208     "security": "*",
209     "confirmed": "x",
210     "easy": "E",
211 }
212
213
214 def subcmd_query(opts: argparse.Namespace, client: soap.Client) -> Optional[int]:
215    try:
216         ui = ui_mod.get_backend(opts.ui)(mouse=opts.mouse)
217    except ui_mod.Error as e:
218         sys.exit(e)
219
220    try:
221     ui.start()
222
223     # Server doesn't maintain order, so we don't either (wrt the user args).
224     ui.status("Searching for bugs ...")
225     bugs = set()
226     for arg in opts.args:
227         try:
228             bug = int(arg)
229             bugs.add(bug)
230         except ValueError:
231             found_bugs = client.get_bugs(package=arg)
232             if found_bugs:
233                 bugs.update(found_bugs)
234     if not bugs:
235       sys.exit('\nNo bugs found!')
236     bug_width = math.floor(math.log10(max(bugs))) + 1
237     bugs_list = list(bugs)
238
239     # Servers restrict how many bugs you can query at once.
240     statuses = {}
241     for i in range(0, len(bugs), GET_STATUS_MAX_COUNT):
242         slice = bugs_list[i : i + GET_STATUS_MAX_COUNT]
243         ui.status(f"Loading {i + len(slice)}/{len(bugs)} bugs ...")
244         found_bugs = client.get_status(slice)
245         if found_bugs:
246             statuses.update(found_bugs)
247     if not statuses:
248       sys.exit('\nNo bugs found!')
249
250     summary_tags = {}
251     tags_width = 1
252     summary_states = {}
253     states_width = 1
254     for bug, status in statuses.items():
255         bug_tags = set(status["tags"].split())
256         summary_bug_tags = "".join(TAG_SUMMARY[x] for x in bug_tags)
257         tags_width = max(tags_width, len(summary_bug_tags))
258         summary_tags[bug] = summary_bug_tags
259
260         # TODO: Figure out precedence order.
261         state = ""
262         if status["archived"]:
263             state = "♲"
264         elif status["mergedwith"]:
265             state = "="
266         elif status["blockedby"]:
267             state = "♙"
268         elif status["fixed_versions"]:
269             state = "☺"
270         states_width = max(states_width, len(state))
271         summary_states[bug] = state
272
273     ui.status()
274     ui.interface_query(client, opts.to, statuses, bug_width, summary_tags, tags_width, summary_states, states_width)
275    except KeyboardInterrupt:
276      # We ignore it here because interface_query already took care of quitting.
277      pass
278    finally:
279     ui.stop()
280
281
282 def subcmd_control(opts: argparse.Namespace, client: soap.Client) -> Optional[int]:
283     # TODO: Make this configurable.
284     control = Control(opts.to, "Mike Frysinger <vapier@gentoo.org>")
285     for message in opts.commands:
286         control.queue._queue(message)
287     control.send(dryrun=opts.dryrun)
288
289
290 def subcmd_raw(opts: argparse.Namespace, client: soap.Client) -> Optional[int]:
291     # Is this more terrible than hand maintaining a CLI<->enum mapping?
292     sformat = opts.format.upper().replace("-", "_")
293     for prop in dir(soap.Format):
294         if prop == sformat:
295             format = getattr(soap.Format, prop)
296             break
297     else:
298         raise RuntimeError(f'Unable to locate "{sformat}" in {dir(soap.Format)}')
299
300     if opts.api == "get_bug_log":
301         ret = client.get_bug_log(opts.bug, format=format)
302     elif opts.api == "get_bugs":
303         kwargs = {}
304         for arg in opts.args:
305             key, value = arg.split("=", 1)
306             kwargs[key] = value
307         ret = client.get_bugs(format=format, **kwargs)
308     elif opts.api == "get_status":
309         bugs = [int(x) for x in opts.bugs]
310         if opts.chunk:
311             # TODO: Implement XML chunking.
312             assert format == soap.Format.JSON, 'Chunking only supported for JSON currently'
313             ret = {}
314             for i in range(0, len(bugs), GET_STATUS_MAX_COUNT):
315                 ret.update(
316                     client.get_status(bugs[i : i + GET_STATUS_MAX_COUNT], format=format)
317                 )
318         else:
319             ret = client.get_status(bugs, format=format)
320     elif opts.api == "get_usertag":
321         ret = client.get_usertag(opts.user, opts.tags, format=format)
322     elif opts.api == "newest_bugs":
323         ret = client.newest_bugs(opts.count, format=format)
324     else:
325         raise RuntimeError(f'Unhandled API "{opts.api}"')
326
327     if opts.pretty:
328         if format == soap.Format.JSON:
329             ret = pretty.json(ret)
330
331     print(ret)
332
333
334 def main(argv: Optional[List[str]] = None) -> Optional[int]:
335     """The main entry point for scripts."""
336     _, opts = parse_args(argv)
337
338     tracker, to = SYSTEMS[opts.bts]
339     client = soap.Client(tracker)
340     opts.to = to
341
342     return {"query": subcmd_query, "control": subcmd_control, "raw": subcmd_raw}[
343         opts.command
344     ](opts, client)