bugpipe 3.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bugpipe/__init__.py +42 -0
- bugpipe/api/AUDIT.md +331 -0
- bugpipe/api/README.md +728 -0
- bugpipe/api/__init__.py +3 -0
- bugpipe/api/client.py +328 -0
- bugpipe/api/models.py +742 -0
- bugpipe/api/parser.py +720 -0
- bugpipe/cli/__init__.py +69 -0
- bugpipe/cli/cmd.py +304 -0
- bugpipe/cli/term.py +234 -0
- bugpipe/cli/update_checker.py +207 -0
- bugpipe-3.0.1.dist-info/METADATA +52 -0
- bugpipe-3.0.1.dist-info/RECORD +15 -0
- bugpipe-3.0.1.dist-info/WHEEL +4 -0
- bugpipe-3.0.1.dist-info/entry_points.txt +3 -0
bugpipe/cli/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from rich.logging import RichHandler
|
|
7
|
+
|
|
8
|
+
from ..api.client import TRACKERS
|
|
9
|
+
from . import update_checker
|
|
10
|
+
from .cmd import dispatch_client, parse_args
|
|
11
|
+
from .term import FAIL, INFO, WARN, console, print_out
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def format_option(value) -> str:
|
|
15
|
+
"""
|
|
16
|
+
Render an option value for the startup line: lists in brackets, strings
|
|
17
|
+
quoted so Rich's URL highlighting stops at the value.
|
|
18
|
+
|
|
19
|
+
:param value: The option value.
|
|
20
|
+
:return: Markup text.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
if isinstance(value, list):
|
|
24
|
+
return f"[[italic]{', '.join(map(str, value))}[/italic]]"
|
|
25
|
+
if isinstance(value, str):
|
|
26
|
+
return f"'[italic]{value}[/italic]'"
|
|
27
|
+
return f"[italic]{value}[/italic]"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def start():
|
|
31
|
+
"""
|
|
32
|
+
CLI entry point.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
args = parse_args()
|
|
36
|
+
|
|
37
|
+
if args.command == "trackers":
|
|
38
|
+
print_out(output=TRACKERS, as_raw=args.raw)
|
|
39
|
+
console.print(f"\n{len(TRACKERS)} trackers available")
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
logging.basicConfig(
|
|
43
|
+
level=logging.WARNING,
|
|
44
|
+
handlers=[RichHandler(markup=True, show_level=True)],
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
start_time = datetime.now().astimezone()
|
|
48
|
+
try:
|
|
49
|
+
overrides: str = ", ".join(
|
|
50
|
+
f"{name}={format_option(value)}" for name, value in args.overrides.items()
|
|
51
|
+
)
|
|
52
|
+
overrides_text: str = f" ({overrides})" if overrides else ""
|
|
53
|
+
console.log(
|
|
54
|
+
f"{INFO} Started bugpipe CLI {update_checker.__version__[:3]}{overrides_text} "
|
|
55
|
+
f"at {datetime.now().astimezone().strftime('%x %X')}"
|
|
56
|
+
)
|
|
57
|
+
with console.status("[dim]Initialising…[/dim]") as status:
|
|
58
|
+
update_checker.check(status=status)
|
|
59
|
+
dispatch_client(args=args, status=status)
|
|
60
|
+
except KeyboardInterrupt:
|
|
61
|
+
console.log(f"{WARN} User interrupted ([bold yellow]CTRL+C[/bold yellow])")
|
|
62
|
+
sys.exit(0)
|
|
63
|
+
except httpx.ConnectError as err:
|
|
64
|
+
console.log(f"{FAIL} {err}")
|
|
65
|
+
except httpx.ConnectTimeout:
|
|
66
|
+
console.log(f"{WARN} Connection timed out")
|
|
67
|
+
finally:
|
|
68
|
+
elapsed = (datetime.now().astimezone() - start_time).total_seconds()
|
|
69
|
+
console.log(f"{INFO} Finished in {elapsed:.1f} seconds")
|
bugpipe/cli/cmd.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import typing as t
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from ..api.client import TRACKERS, Bugpipe
|
|
8
|
+
from ..api.models import Results
|
|
9
|
+
from .term import FAIL, OK, console, export, print_out
|
|
10
|
+
from .update_checker import __pkg__, __version__
|
|
11
|
+
|
|
12
|
+
if t.TYPE_CHECKING:
|
|
13
|
+
from rich.status import Status
|
|
14
|
+
|
|
15
|
+
from ..api.models import Issue
|
|
16
|
+
|
|
17
|
+
__all__ = ["dispatch_client", "parse_args"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_args() -> argparse.Namespace:
|
|
21
|
+
"""
|
|
22
|
+
Parse command-line arguments and return the populated namespace.
|
|
23
|
+
|
|
24
|
+
:return: Parsed arguments with the selected subcommand function in ``.func``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
parser = argparse.ArgumentParser(
|
|
28
|
+
prog=__pkg__,
|
|
29
|
+
description="Unofficial Python client for Buganizer; the Google Issue Tracking system.",
|
|
30
|
+
epilog=f"© {datetime.now().astimezone().year} Ritchie Mwewa",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"-r",
|
|
34
|
+
"--raw",
|
|
35
|
+
action="store_true",
|
|
36
|
+
help="show raw output",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"-p",
|
|
40
|
+
"--proxy",
|
|
41
|
+
metavar="URL",
|
|
42
|
+
help="proxy URL for all requests (e.g. http://localhost:8080)",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"-t",
|
|
46
|
+
"--timeout",
|
|
47
|
+
type=int,
|
|
48
|
+
default=30,
|
|
49
|
+
metavar="SECONDS",
|
|
50
|
+
help="request timeout (default %(default)s)",
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"-v",
|
|
54
|
+
"--version",
|
|
55
|
+
action="version",
|
|
56
|
+
version=f"{__pkg__} {__version__}",
|
|
57
|
+
)
|
|
58
|
+
# Shared by commands that return issue data.
|
|
59
|
+
exportable = argparse.ArgumentParser(add_help=False)
|
|
60
|
+
exportable.add_argument(
|
|
61
|
+
"-e",
|
|
62
|
+
"--export",
|
|
63
|
+
action="append",
|
|
64
|
+
choices=["csv", "json"],
|
|
65
|
+
help="export format (repeatable)",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
69
|
+
|
|
70
|
+
# search
|
|
71
|
+
search_parser = subparsers.add_parser(
|
|
72
|
+
"search", parents=[exportable], help="search for issues"
|
|
73
|
+
)
|
|
74
|
+
search_parser.add_argument("query", help="search query")
|
|
75
|
+
search_parser.add_argument(
|
|
76
|
+
"-t",
|
|
77
|
+
"--tracker",
|
|
78
|
+
action="append",
|
|
79
|
+
choices=[tracker["slug"] for tracker in TRACKERS],
|
|
80
|
+
help="tracker slug (repeatable, see `bugpipe trackers`). Defaults to all",
|
|
81
|
+
)
|
|
82
|
+
search_parser.add_argument(
|
|
83
|
+
"-n",
|
|
84
|
+
"--per-page",
|
|
85
|
+
type=int,
|
|
86
|
+
default=25,
|
|
87
|
+
choices=[25, 50, 100, 250],
|
|
88
|
+
help="results per page (default: 25)",
|
|
89
|
+
)
|
|
90
|
+
search_parser.add_argument(
|
|
91
|
+
"-l",
|
|
92
|
+
"--limit",
|
|
93
|
+
type=int,
|
|
94
|
+
default=None,
|
|
95
|
+
metavar="N",
|
|
96
|
+
help="total results to fetch, paginating as needed",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
search_parser.set_defaults(func=cmd_search)
|
|
100
|
+
|
|
101
|
+
# get
|
|
102
|
+
issue_parser = subparsers.add_parser(
|
|
103
|
+
"issue", parents=[exportable], help="get a single issue"
|
|
104
|
+
)
|
|
105
|
+
issue_parser.add_argument("issue_id", type=int, help="issue ID")
|
|
106
|
+
issue_parser.set_defaults(func=cmd_issue)
|
|
107
|
+
|
|
108
|
+
# batch
|
|
109
|
+
issues_parser = subparsers.add_parser(
|
|
110
|
+
"issues", parents=[exportable], help="batch get issues"
|
|
111
|
+
)
|
|
112
|
+
issues_parser.add_argument("issue_ids", type=int, nargs="+", help="issue IDs")
|
|
113
|
+
issues_parser.set_defaults(func=cmd_issues)
|
|
114
|
+
|
|
115
|
+
# comments
|
|
116
|
+
comments_parser = subparsers.add_parser(
|
|
117
|
+
"comments", parents=[exportable], help="get comments on an issue"
|
|
118
|
+
)
|
|
119
|
+
comments_parser.add_argument("issue_id", type=int, help="issue ID")
|
|
120
|
+
comments_parser.set_defaults(func=cmd_comments)
|
|
121
|
+
|
|
122
|
+
# trackers
|
|
123
|
+
subparsers.add_parser("trackers", help="list available trackers")
|
|
124
|
+
|
|
125
|
+
# echo (health check)
|
|
126
|
+
echo_parser = subparsers.add_parser(
|
|
127
|
+
"echo",
|
|
128
|
+
help="check whether the issue tracker backend is reachable",
|
|
129
|
+
description=(
|
|
130
|
+
"Ping the Buganizer backend (GET /action/yes) and print its "
|
|
131
|
+
"response. 'yes' means the backend is reachable and healthy; "
|
|
132
|
+
"'no' means it is unreachable or returned an error."
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
echo_parser.set_defaults(func=cmd_echo)
|
|
136
|
+
|
|
137
|
+
args = parser.parse_args()
|
|
138
|
+
args.overrides = _overrides(args, parser, subparsers.choices[args.command])
|
|
139
|
+
return args
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _overrides(args: argparse.Namespace, *parsers: argparse.ArgumentParser) -> dict:
|
|
143
|
+
"""
|
|
144
|
+
Collect the options whose value differs from their default.
|
|
145
|
+
|
|
146
|
+
:param args: Parsed arguments.
|
|
147
|
+
:param parsers: The parsers that produced them.
|
|
148
|
+
:return: Long option name (without dashes) to value.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
action.option_strings[-1].lstrip("-"): getattr(args, action.dest)
|
|
153
|
+
for parser in parsers
|
|
154
|
+
for action in parser._actions
|
|
155
|
+
if action.option_strings
|
|
156
|
+
and action.dest in args
|
|
157
|
+
and getattr(args, action.dest) != action.default
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def cmd_search(client: Bugpipe, args: argparse.Namespace, status: Status):
|
|
162
|
+
"""
|
|
163
|
+
Handle the 'search' subcommand.
|
|
164
|
+
|
|
165
|
+
:param client: Shared API client instance.
|
|
166
|
+
:param args: Parsed arguments with ``.query``, ``.per_page``, and ``.limit``.
|
|
167
|
+
:param status: Rich status spinner for progress updates.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
query = args.query
|
|
171
|
+
per_page = args.per_page
|
|
172
|
+
limit = args.limit
|
|
173
|
+
tracker_label = ", ".join(args.tracker) if args.tracker else "all"
|
|
174
|
+
|
|
175
|
+
status.update(
|
|
176
|
+
f"[dim][bold]Searching [italic]{tracker_label}[/] issues for [bold green]{query}[/bold green]…[/dim]"
|
|
177
|
+
)
|
|
178
|
+
result = client.search(query=query, page_size=per_page)
|
|
179
|
+
issues: Results[Issue] = Results(result.issues)
|
|
180
|
+
|
|
181
|
+
while limit is not None and result.has_more and len(issues) < limit:
|
|
182
|
+
status.update(
|
|
183
|
+
f"[dim]Collected [cyan]{len(issues)}[/] of [cyan]{limit}[/] issues…[/dim]"
|
|
184
|
+
)
|
|
185
|
+
page = client.next_page(result)
|
|
186
|
+
if page is None:
|
|
187
|
+
break
|
|
188
|
+
result = page
|
|
189
|
+
issues.extend(result.issues)
|
|
190
|
+
if limit is not None:
|
|
191
|
+
issues = Results(issues[:limit])
|
|
192
|
+
|
|
193
|
+
console.log(
|
|
194
|
+
f"{OK} Got {len(issues)} of ~{result.total_count}+ issues for '{query}'\n"
|
|
195
|
+
)
|
|
196
|
+
# Rich's Status redirects sys.stdout, which makes the pager (and the
|
|
197
|
+
# TTY check) see a non-tty. Stop it first so paging can take over.
|
|
198
|
+
status.stop()
|
|
199
|
+
print_out(output=issues, as_raw=args.raw)
|
|
200
|
+
|
|
201
|
+
if args.export:
|
|
202
|
+
export(output=issues, formats=args.export)
|
|
203
|
+
|
|
204
|
+
if result.has_more:
|
|
205
|
+
print()
|
|
206
|
+
console.log(f"~{result.total_count - len(issues)}+ more results available")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def cmd_issue(client: Bugpipe, args: argparse.Namespace, status: Status):
|
|
210
|
+
"""
|
|
211
|
+
Handle the 'issue' subcommand.
|
|
212
|
+
|
|
213
|
+
:param client: Shared API client instance.
|
|
214
|
+
:param args: Parsed arguments with ``.issue_id``.
|
|
215
|
+
:param status: Rich status spinner for progress updates.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
issue_id = args.issue_id
|
|
219
|
+
status.update(f"[dim]Getting issue {issue_id}…[/]")
|
|
220
|
+
issue = client.issue(issue_id=issue_id)
|
|
221
|
+
|
|
222
|
+
status.stop() # restore stdout so the pager works (Status redirects it)
|
|
223
|
+
print_out(output=issue)
|
|
224
|
+
if args.export:
|
|
225
|
+
export(output=issue, formats=args.export)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def cmd_issues(client: Bugpipe, args: argparse.Namespace, status: Status):
|
|
229
|
+
"""
|
|
230
|
+
Handle the 'issues' subcommand.
|
|
231
|
+
|
|
232
|
+
:param client: Shared API client instance.
|
|
233
|
+
:param args: Parsed arguments with ``.issue_ids``.
|
|
234
|
+
:param status: Rich status spinner for progress updates.
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
issue_ids = args.issue_ids
|
|
238
|
+
status.update(f"[dim]Getting issues {issue_ids}…[/]")
|
|
239
|
+
issues = client.issues(issue_ids=issue_ids)
|
|
240
|
+
|
|
241
|
+
status.stop() # restore stdout so the pager works (Status redirects it)
|
|
242
|
+
print_out(output=issues)
|
|
243
|
+
if args.export:
|
|
244
|
+
export(output=issues, formats=args.export)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def cmd_comments(client: Bugpipe, args: argparse.Namespace, status: Status):
|
|
248
|
+
"""
|
|
249
|
+
Handle the 'comments' subcommand.
|
|
250
|
+
|
|
251
|
+
:param client: Shared API client instance.
|
|
252
|
+
:param args: Parsed arguments with ``.issue_id``.
|
|
253
|
+
:param status: Rich status spinner for progress updates.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
issue_id = args.issue_id
|
|
257
|
+
|
|
258
|
+
status.update(status=f"[dim]Getting comments for issue {issue_id}…[/]")
|
|
259
|
+
result = client.comments(issue_id=issue_id)
|
|
260
|
+
|
|
261
|
+
status.stop() # restore stdout so the pager works (Status redirects it)
|
|
262
|
+
console.print(f"Issue #{issue_id} — {len(result.comments)} comments\n")
|
|
263
|
+
print_out(output=result.comments)
|
|
264
|
+
if args.export:
|
|
265
|
+
export(output=result.comments, formats=args.export)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# noinspection PyUnusedLocal
|
|
269
|
+
def cmd_echo(client: Bugpipe, args: argparse.Namespace, status: Status):
|
|
270
|
+
"""
|
|
271
|
+
Handle the 'echo' subcommand: ping the backend and print its response.
|
|
272
|
+
|
|
273
|
+
Prints a green ``✔`` when the backend is reachable and healthy
|
|
274
|
+
(``echo: yes``), or a red ``✘`` when it is unreachable or returned
|
|
275
|
+
an error (``echo: no``).
|
|
276
|
+
|
|
277
|
+
:param client: Shared API client instance.
|
|
278
|
+
:param args: Parsed arguments (unused).
|
|
279
|
+
:param status: Rich status spinner for progress updates.
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
status.update("[dim]Pinging issue tracker backend…[/dim]")
|
|
283
|
+
response = client.echo()
|
|
284
|
+
if response == "yes":
|
|
285
|
+
console.log(f"{OK} echo: {response}")
|
|
286
|
+
else:
|
|
287
|
+
console.log(f"{FAIL} echo: {response}")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def dispatch_client(args: argparse.Namespace, status: Status):
|
|
291
|
+
"""
|
|
292
|
+
Check for updates, then create a single client and dispatch to the
|
|
293
|
+
chosen subcommand.
|
|
294
|
+
|
|
295
|
+
:param args: Parsed arguments with ``.func`` set to the subcommand handler.
|
|
296
|
+
:param status: Rich status spinner for progress updates.
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
with Bugpipe(
|
|
300
|
+
trackers=getattr(args, "tracker", None),
|
|
301
|
+
timeout=args.timeout,
|
|
302
|
+
proxy=args.proxy,
|
|
303
|
+
) as client:
|
|
304
|
+
args.func(client=client, args=args, status=status)
|
bugpipe/cli/term.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Console output for the CLI.
|
|
3
|
+
|
|
4
|
+
Results print as their dataclasses through a pager, so bulk output scrolls
|
|
5
|
+
instead of flooding the terminal. Writing them to file is left to the models'
|
|
6
|
+
own ``to_json()``/``to_csv()``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import enum
|
|
10
|
+
import typing as t
|
|
11
|
+
from contextlib import nullcontext
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
from rich.box import ASCII
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.pretty import Pretty
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
|
|
20
|
+
from ..api.models import (
|
|
21
|
+
Comment,
|
|
22
|
+
Exportable,
|
|
23
|
+
Issue,
|
|
24
|
+
IssueType,
|
|
25
|
+
Priority,
|
|
26
|
+
Results,
|
|
27
|
+
Severity,
|
|
28
|
+
Status,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = ["FAIL", "INFO", "OK", "WARN", "export", "print_out"]
|
|
32
|
+
|
|
33
|
+
#: Style for values the API sent that the enum doesn't define (e.g. ``TYPE_7``).
|
|
34
|
+
UNKNOWN_STYLE = "magenta"
|
|
35
|
+
|
|
36
|
+
#: Priority cell styles. Other known values (P3, P4) are plain yellow.
|
|
37
|
+
PRIORITY_STYLES = {
|
|
38
|
+
Priority.P0: "bold red",
|
|
39
|
+
Priority.P1: "red",
|
|
40
|
+
Priority.P2: "bold yellow",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#: Severity cell styles. Other known values (S3, S4) are plain yellow.
|
|
44
|
+
SEVERITY_STYLES = {
|
|
45
|
+
Severity.S0: "bold red",
|
|
46
|
+
Severity.S1: "red",
|
|
47
|
+
Severity.S2: "bold yellow",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#: Issue type cell styles. Other known values (INTERNAL_CLEANUP, PROCESS) are unstyled.
|
|
51
|
+
ISSUE_TYPE_STYLES = {
|
|
52
|
+
IssueType.VULNERABILITY: "bold red",
|
|
53
|
+
IssueType.BUG: "red",
|
|
54
|
+
IssueType.CUSTOMER_ISSUE: "bold yellow",
|
|
55
|
+
IssueType.FEATURE_REQUEST: "bold green",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#: Status cell styles. Open statuses are blue, fixed ones green. Other closed
|
|
59
|
+
#: statuses (NOT_REPRODUCIBLE, INTENDED_BEHAVIOR, OBSOLETE, INFEASIBLE, DUPLICATE)
|
|
60
|
+
#: are dim.
|
|
61
|
+
STATUS_STYLES = {
|
|
62
|
+
Status.NEW: "bold blue",
|
|
63
|
+
Status.ASSIGNED: "cyan",
|
|
64
|
+
Status.ACCEPTED: "bold cyan",
|
|
65
|
+
Status.FIXED: "green",
|
|
66
|
+
Status.VERIFIED: "bold green",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#: Issue table columns: header -> cell builder. Int cells are right-aligned.
|
|
70
|
+
ISSUE_COLUMNS: dict[str, t.Callable[[Issue], t.Any]] = {
|
|
71
|
+
"ID": lambda issue: issue.id,
|
|
72
|
+
"Title": lambda issue: issue.title,
|
|
73
|
+
"Type": lambda issue: _style_cell(
|
|
74
|
+
value=issue.issue_type, styles=ISSUE_TYPE_STYLES, default="dim"
|
|
75
|
+
),
|
|
76
|
+
"Priority": lambda issue: _style_cell(
|
|
77
|
+
value=issue.priority, styles=PRIORITY_STYLES, default="yellow"
|
|
78
|
+
),
|
|
79
|
+
"Severity": lambda issue: _style_cell(
|
|
80
|
+
value=issue.severity, styles=SEVERITY_STYLES, default="green"
|
|
81
|
+
),
|
|
82
|
+
"Status": lambda issue: _style_cell(
|
|
83
|
+
value=issue.status, styles=STATUS_STYLES, default="dim"
|
|
84
|
+
),
|
|
85
|
+
"24h Views": lambda issue: issue.views_24h,
|
|
86
|
+
"7d Views": lambda issue: issue.views_7d,
|
|
87
|
+
"30d Views": lambda issue: issue.views_30d,
|
|
88
|
+
"Created At": lambda issue: issue.created_at,
|
|
89
|
+
"Modified At": lambda issue: issue.modified_at,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
#: Success marker (green ✔).
|
|
93
|
+
OK = "[bold green]✔[/bold green]"
|
|
94
|
+
|
|
95
|
+
#: Failure/error marker (red ✘).
|
|
96
|
+
FAIL = "[bold red]✘[/bold red]"
|
|
97
|
+
|
|
98
|
+
#: Warning/interrupted marker (yellow ✘).
|
|
99
|
+
WARN = "[bold yellow]✘[/bold yellow]"
|
|
100
|
+
|
|
101
|
+
#: Informational marker (blue *).
|
|
102
|
+
INFO = "[bold blue]*[/bold blue]"
|
|
103
|
+
|
|
104
|
+
console = Console(log_time=False, log_path=False)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _style_cell(value: enum.Enum | None, styles: dict, default: str = "") -> Text | str:
|
|
108
|
+
"""
|
|
109
|
+
Build a table cell for an enum value, styled by its member.
|
|
110
|
+
|
|
111
|
+
:param value: The enum value, or None when the issue has none.
|
|
112
|
+
:param styles: Style per known member.
|
|
113
|
+
:param default: Style for known members missing from ``styles``.
|
|
114
|
+
:return: The styled name, or an empty string for None.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
if value is None:
|
|
118
|
+
return ""
|
|
119
|
+
if value.name not in type(value).__members__:
|
|
120
|
+
return Text(value.name, style=UNKNOWN_STYLE)
|
|
121
|
+
return Text(value.name, style=styles.get(value, default))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _pretty_print(output: t.Any):
|
|
125
|
+
"""
|
|
126
|
+
Show a result, paging it.
|
|
127
|
+
|
|
128
|
+
:param output: A single item, or a list of them.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
if not output:
|
|
132
|
+
console.log("No results.")
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
context = console.pager(styles=True) if console.is_terminal else nullcontext()
|
|
136
|
+
if not console.is_terminal:
|
|
137
|
+
console.print(f"{WARN} Not a TTY — output won't be paged.")
|
|
138
|
+
|
|
139
|
+
with context:
|
|
140
|
+
if isinstance(output, list):
|
|
141
|
+
for index, item in enumerate(output):
|
|
142
|
+
if index:
|
|
143
|
+
console.print()
|
|
144
|
+
console.print(Pretty(item))
|
|
145
|
+
else:
|
|
146
|
+
console.print(Pretty(output))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _print_table(rows: t.Sequence[dict | Exportable]):
|
|
150
|
+
"""
|
|
151
|
+
Print and page rows in a table. Headers come from the first row.
|
|
152
|
+
|
|
153
|
+
:param rows: Dicts (headers are the keys), issues (headers from
|
|
154
|
+
``ISSUE_COLUMNS``), or other exportables (headers from ``to_dict``).
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
if not rows:
|
|
158
|
+
return
|
|
159
|
+
dicts = [
|
|
160
|
+
(
|
|
161
|
+
{header: cell(row) for header, cell in ISSUE_COLUMNS.items()}
|
|
162
|
+
if isinstance(row, Issue)
|
|
163
|
+
else row.to_dict() if isinstance(row, Exportable) else row
|
|
164
|
+
)
|
|
165
|
+
for row in rows
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
table = Table(box=ASCII, highlight=True, expand=True, header_style="bold")
|
|
169
|
+
for header, value in dicts[0].items():
|
|
170
|
+
table.add_column(
|
|
171
|
+
str(header).title(),
|
|
172
|
+
justify="right" if isinstance(value, int) else "left",
|
|
173
|
+
overflow="fold",
|
|
174
|
+
)
|
|
175
|
+
for row in dicts:
|
|
176
|
+
table.add_row(
|
|
177
|
+
*(cell if isinstance(cell, Text) else str(cell) for cell in row.values())
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if not console.is_terminal:
|
|
181
|
+
console.print(f"{WARN} Not a TTY — output won't be paged.")
|
|
182
|
+
with console.pager(styles=True) if console.is_terminal else nullcontext():
|
|
183
|
+
console.print(table)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def print_out(
|
|
187
|
+
output: list[dict] | Results[Issue] | Results[Comment] | Issue, as_raw: bool = False
|
|
188
|
+
):
|
|
189
|
+
"""
|
|
190
|
+
Show a result, paging it. Issues and dicts go in a table; a single issue
|
|
191
|
+
and comments print as their dataclasses.
|
|
192
|
+
|
|
193
|
+
:param output: A single issue, a list of issues or comments, or a list of dicts.
|
|
194
|
+
:param as_raw: Print tabular results as dataclasses instead of a table.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
if (
|
|
198
|
+
isinstance(output, Issue)
|
|
199
|
+
or isinstance(output, Results)
|
|
200
|
+
and all(isinstance(item, Comment) for item in output)
|
|
201
|
+
):
|
|
202
|
+
_pretty_print(output=output)
|
|
203
|
+
|
|
204
|
+
elif (
|
|
205
|
+
isinstance(output, Results)
|
|
206
|
+
and all(isinstance(item, Issue) for item in output)
|
|
207
|
+
or isinstance(output, list)
|
|
208
|
+
and all(isinstance(item, dict) for item in output)
|
|
209
|
+
):
|
|
210
|
+
if as_raw:
|
|
211
|
+
_pretty_print(output=output)
|
|
212
|
+
else:
|
|
213
|
+
_print_table(rows=output)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def export(output: Exportable | Results[t.Any], formats: list[str]):
|
|
217
|
+
"""
|
|
218
|
+
Write a result to timestamped files, one per format.
|
|
219
|
+
|
|
220
|
+
:param output: A single item, or a list of them.
|
|
221
|
+
:param formats: Format strings, each one of ``"csv"`` or ``"json"``.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
if not output:
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
base = f"bugpipe-{datetime.now().astimezone().strftime('%Y%m%d_%H%M%S')}"
|
|
228
|
+
for fmt in formats:
|
|
229
|
+
if fmt == "json":
|
|
230
|
+
console.print(f"\n{OK} JSON exported to {output.to_json(f'{base}.json')}")
|
|
231
|
+
elif fmt == "csv":
|
|
232
|
+
console.print(f"\n{OK} CSV exported to {output.to_csv(f'{base}.csv')}")
|
|
233
|
+
else:
|
|
234
|
+
continue
|