]> git.wh0rd.org - 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)