gh-prlist 0.2.0__tar.gz

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.
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: gh-prlist
3
+ Version: 0.2.0
4
+ Summary: Configurable GitHub PR monitor with macOS notifications
5
+ Author: Foad Kesheh
6
+ Keywords: github,pull-request,cli,notification
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Environment :: Console
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Operating System :: MacOS
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Software Development :: Quality Assurance
17
+ Requires-Python: >=3.11
18
+ Description-Content-Type: text/markdown
19
+
20
+ # gh-prlist
21
+
22
+ Configurable GitHub pull-request monitoring with clickable macOS
23
+ notifications. The PyPI package is **`gh-prlist`** (`prlist` and `pr-list`
24
+ are taken or blocked as too similar). The installed commands are `gh-prlist`,
25
+ `pr-list`, and `prlist`. It requires the GitHub CLI (`gh`) to be installed and
26
+ authenticated.
27
+
28
+ ```console
29
+ uvx gh-prlist setup
30
+ uvx gh-prlist list
31
+ uvx gh-prlist monitor
32
+ uvx gh-prlist monitor --own
33
+ uvx gh-prlist monitor --team
34
+ uvx gh-prlist monitor --own --team
35
+ ```
36
+
37
+ `setup` writes TOML configuration to `~/.prlist`. Command-line options
38
+ override that file; run `prlist <command> --help` for all overrides.
39
+
40
+ The default policies show your open non-draft PRs with failing CI and
41
+ non-draft team-review requests with green CI and no explicit merge conflict.
42
+ `monitor` checks every ten minutes and writes a clickable report to
43
+ `~/.cache/prlist/prs.html`.
44
+
45
+ Each source can independently filter CI (`green`, `failing`, `any`) and
46
+ mergeability (`not_conflicting`, `conflicting`, `any`). Notifications can
47
+ include own PRs, team PRs, or both. Change-only notification state persists
48
+ across monitor restarts.
49
+
50
+ For clickable notification actions on macOS, install `terminal-notifier`.
51
+ Without it, notifications fall back to `osascript`.
@@ -0,0 +1,32 @@
1
+ # gh-prlist
2
+
3
+ Configurable GitHub pull-request monitoring with clickable macOS
4
+ notifications. The PyPI package is **`gh-prlist`** (`prlist` and `pr-list`
5
+ are taken or blocked as too similar). The installed commands are `gh-prlist`,
6
+ `pr-list`, and `prlist`. It requires the GitHub CLI (`gh`) to be installed and
7
+ authenticated.
8
+
9
+ ```console
10
+ uvx gh-prlist setup
11
+ uvx gh-prlist list
12
+ uvx gh-prlist monitor
13
+ uvx gh-prlist monitor --own
14
+ uvx gh-prlist monitor --team
15
+ uvx gh-prlist monitor --own --team
16
+ ```
17
+
18
+ `setup` writes TOML configuration to `~/.prlist`. Command-line options
19
+ override that file; run `prlist <command> --help` for all overrides.
20
+
21
+ The default policies show your open non-draft PRs with failing CI and
22
+ non-draft team-review requests with green CI and no explicit merge conflict.
23
+ `monitor` checks every ten minutes and writes a clickable report to
24
+ `~/.cache/prlist/prs.html`.
25
+
26
+ Each source can independently filter CI (`green`, `failing`, `any`) and
27
+ mergeability (`not_conflicting`, `conflicting`, `any`). Notifications can
28
+ include own PRs, team PRs, or both. Change-only notification state persists
29
+ across monitor restarts.
30
+
31
+ For clickable notification actions on macOS, install `terminal-notifier`.
32
+ Without it, notifications fall back to `osascript`.
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: gh-prlist
3
+ Version: 0.2.0
4
+ Summary: Configurable GitHub PR monitor with macOS notifications
5
+ Author: Foad Kesheh
6
+ Keywords: github,pull-request,cli,notification
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Environment :: Console
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Operating System :: MacOS
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Software Development :: Quality Assurance
17
+ Requires-Python: >=3.11
18
+ Description-Content-Type: text/markdown
19
+
20
+ # gh-prlist
21
+
22
+ Configurable GitHub pull-request monitoring with clickable macOS
23
+ notifications. The PyPI package is **`gh-prlist`** (`prlist` and `pr-list`
24
+ are taken or blocked as too similar). The installed commands are `gh-prlist`,
25
+ `pr-list`, and `prlist`. It requires the GitHub CLI (`gh`) to be installed and
26
+ authenticated.
27
+
28
+ ```console
29
+ uvx gh-prlist setup
30
+ uvx gh-prlist list
31
+ uvx gh-prlist monitor
32
+ uvx gh-prlist monitor --own
33
+ uvx gh-prlist monitor --team
34
+ uvx gh-prlist monitor --own --team
35
+ ```
36
+
37
+ `setup` writes TOML configuration to `~/.prlist`. Command-line options
38
+ override that file; run `prlist <command> --help` for all overrides.
39
+
40
+ The default policies show your open non-draft PRs with failing CI and
41
+ non-draft team-review requests with green CI and no explicit merge conflict.
42
+ `monitor` checks every ten minutes and writes a clickable report to
43
+ `~/.cache/prlist/prs.html`.
44
+
45
+ Each source can independently filter CI (`green`, `failing`, `any`) and
46
+ mergeability (`not_conflicting`, `conflicting`, `any`). Notifications can
47
+ include own PRs, team PRs, or both. Change-only notification state persists
48
+ across monitor restarts.
49
+
50
+ For clickable notification actions on macOS, install `terminal-notifier`.
51
+ Without it, notifications fall back to `osascript`.
@@ -0,0 +1,22 @@
1
+ README.md
2
+ pyproject.toml
3
+ gh_prlist.egg-info/PKG-INFO
4
+ gh_prlist.egg-info/SOURCES.txt
5
+ gh_prlist.egg-info/dependency_links.txt
6
+ gh_prlist.egg-info/entry_points.txt
7
+ gh_prlist.egg-info/top_level.txt
8
+ pr_list/__init__.py
9
+ pr_list/__main__.py
10
+ pr_list/ci.py
11
+ pr_list/cli.py
12
+ pr_list/config.py
13
+ pr_list/formatters.py
14
+ pr_list/github.py
15
+ pr_list/models.py
16
+ pr_list/notify.py
17
+ pr_list/report.py
18
+ pr_list/watch.py
19
+ tests/test_ci.py
20
+ tests/test_config.py
21
+ tests/test_formatters.py
22
+ tests/test_watch.py
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ gh-prlist = pr_list.cli:main
3
+ pr-list = pr_list.cli:main
4
+ prlist = pr_list.cli:main
@@ -0,0 +1 @@
1
+ pr_list
@@ -0,0 +1,3 @@
1
+ """Configurable GitHub pull-request monitor."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,4 @@
1
+ from pr_list.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+
5
+ from pr_list.models import Check
6
+
7
+ # Check names whose failure should not block "green".
8
+ # Add substrings here if a workflow is noisy (e.g. deploy callbacks).
9
+ NON_BLOCKING_FAILURE_SUBSTRINGS: tuple[str, ...] = ()
10
+
11
+ PASS_STATES = frozenset({"pass", "passing", "success", "skip", "skipping", "neutral"})
12
+ PENDING_STATES = frozenset({"pending", "queued", "in_progress", "in progress"})
13
+ FAIL_STATES = frozenset({"fail", "failure", "cancelled", "canceled", "timed_out", "action_required"})
14
+
15
+
16
+ def parse_checks_output(text: str) -> list[Check]:
17
+ """Parse `gh pr checks` TSV (name, state, ...)."""
18
+ checks: list[Check] = []
19
+ for raw in text.splitlines():
20
+ line = raw.strip()
21
+ if not line or line.startswith("no checks"):
22
+ continue
23
+ parts = line.split("\t")
24
+ if len(parts) < 2:
25
+ continue
26
+ name, state = parts[0].strip(), parts[1].strip().lower()
27
+ if not name or not state:
28
+ continue
29
+ checks.append(Check(name=name, state=state))
30
+ return checks
31
+
32
+
33
+ def _is_non_blocking_failure(name: str) -> bool:
34
+ lowered = name.lower()
35
+ return any(token.lower() in lowered for token in NON_BLOCKING_FAILURE_SUBSTRINGS)
36
+
37
+
38
+ def summarize(checks: list[Check]) -> str:
39
+ counts = Counter(check.state for check in checks)
40
+ parts = [f"{state}={n}" for state, n in sorted(counts.items())]
41
+ return ", ".join(parts) if parts else "no checks"
42
+
43
+
44
+ def is_failing(checks: list[Check]) -> bool:
45
+ """True when at least one non-ignored check failed. Pending-only is not failing."""
46
+ return any(
47
+ check.state in FAIL_STATES and not _is_non_blocking_failure(check.name)
48
+ for check in checks
49
+ )
50
+
51
+
52
+ def failing_names(checks: list[Check], *, limit: int = 5) -> list[str]:
53
+ names: list[str] = []
54
+ seen: set[str] = set()
55
+ for check in checks:
56
+ if check.state not in FAIL_STATES or _is_non_blocking_failure(check.name):
57
+ continue
58
+ if check.name in seen:
59
+ continue
60
+ seen.add(check.name)
61
+ names.append(check.name)
62
+ if len(names) >= limit:
63
+ break
64
+ return names
65
+
66
+
67
+ def is_green(checks: list[Check]) -> bool:
68
+ """True when nothing is pending and every remaining check passed or skipped.
69
+
70
+ No checks at all is not green — we cannot prove CI ran.
71
+ """
72
+ if not checks:
73
+ return False
74
+
75
+ for check in checks:
76
+ if check.state in PASS_STATES:
77
+ continue
78
+ if check.state in PENDING_STATES:
79
+ return False
80
+ if check.state in FAIL_STATES:
81
+ if _is_non_blocking_failure(check.name):
82
+ continue
83
+ return False
84
+ return False
85
+ return True
@@ -0,0 +1,413 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from pr_list import __version__
11
+ from pr_list.config import (
12
+ CI_POLICIES,
13
+ CONFIG_PATH,
14
+ MERGE_POLICIES,
15
+ Config,
16
+ ConfigError,
17
+ interactive_setup,
18
+ load_config,
19
+ parse_interval,
20
+ write_config,
21
+ )
22
+ from pr_list.formatters import as_snapshot_json, as_snapshot_table
23
+ from pr_list.github import (
24
+ GhError,
25
+ attach_ci,
26
+ ensure_gh,
27
+ merge_unique,
28
+ search_authored_prs,
29
+ search_team_review_prs,
30
+ )
31
+ from pr_list.models import PullRequest, Snapshot
32
+ from pr_list.report import write_report
33
+ from pr_list.watch import run_watch
34
+
35
+
36
+ @dataclass
37
+ class Runtime:
38
+ config: Config
39
+ sources: set[str]
40
+ json: bool
41
+ show_rejected: bool
42
+ notifications: bool
43
+ notify_on_change: bool
44
+ notification_sources: set[str]
45
+ interval_seconds: int
46
+ html: bool
47
+ html_path: Path
48
+ state_path: Path
49
+
50
+
51
+ def _add_source_flags(parser: argparse.ArgumentParser) -> None:
52
+ parser.add_argument(
53
+ "--own",
54
+ action="store_true",
55
+ help="Run only/also the configured own-PR source",
56
+ )
57
+ parser.add_argument(
58
+ "--team",
59
+ action="store_true",
60
+ help="Run only/also the configured team-review source",
61
+ )
62
+
63
+
64
+ def _add_common_flags(parser: argparse.ArgumentParser, *, monitor: bool) -> None:
65
+ _add_source_flags(parser)
66
+ parser.add_argument("--owner", help="Override github.owner")
67
+ parser.add_argument("--author", help="Override github.author")
68
+ parser.add_argument("--reviewer", help="Override github.reviewer")
69
+ parser.add_argument("--limit", type=int, help="Override github.limit")
70
+ parser.add_argument("--workers", type=int, help="Override github.workers")
71
+ parser.add_argument("--own-ci", choices=sorted(CI_POLICIES))
72
+ parser.add_argument("--team-ci", choices=sorted(CI_POLICIES))
73
+ parser.add_argument("--own-mergeable", choices=sorted(MERGE_POLICIES))
74
+ parser.add_argument("--team-mergeable", choices=sorted(MERGE_POLICIES))
75
+ parser.add_argument(
76
+ "--own-drafts",
77
+ action=argparse.BooleanOptionalAction,
78
+ default=None,
79
+ help="Include/exclude draft own PRs",
80
+ )
81
+ parser.add_argument(
82
+ "--team-drafts",
83
+ action=argparse.BooleanOptionalAction,
84
+ default=None,
85
+ help="Include/exclude draft team PRs",
86
+ )
87
+ parser.add_argument(
88
+ "--json",
89
+ action=argparse.BooleanOptionalAction,
90
+ default=None,
91
+ help="Print JSON",
92
+ )
93
+ parser.add_argument(
94
+ "--show-rejected",
95
+ action=argparse.BooleanOptionalAction,
96
+ default=None,
97
+ help="Show team PRs filtered out by CI policy",
98
+ )
99
+ parser.add_argument(
100
+ "--html",
101
+ action=argparse.BooleanOptionalAction,
102
+ default=None,
103
+ help="Write the HTML report",
104
+ )
105
+ parser.add_argument("--html-path", type=Path, help="Override report path")
106
+ parser.add_argument(
107
+ "--open",
108
+ action="store_true",
109
+ dest="open_report",
110
+ help="Open the HTML report after a one-shot list",
111
+ )
112
+ if monitor:
113
+ parser.add_argument("--interval", help="Override monitor interval")
114
+ parser.add_argument(
115
+ "--notifications",
116
+ action=argparse.BooleanOptionalAction,
117
+ default=None,
118
+ help="Enable/disable notifications",
119
+ )
120
+ parser.add_argument(
121
+ "--notify-on-change",
122
+ action=argparse.BooleanOptionalAction,
123
+ default=None,
124
+ help="Notify only when results changed since the last notification",
125
+ )
126
+ parser.add_argument(
127
+ "--notify-own",
128
+ action=argparse.BooleanOptionalAction,
129
+ default=None,
130
+ help="Include/exclude own PRs from notifications",
131
+ )
132
+ parser.add_argument(
133
+ "--notify-team",
134
+ action=argparse.BooleanOptionalAction,
135
+ default=None,
136
+ help="Include/exclude team PRs from notifications",
137
+ )
138
+ parser.add_argument("--state-path", type=Path, help="Notification state path")
139
+
140
+
141
+ def build_parser() -> argparse.ArgumentParser:
142
+ parser = argparse.ArgumentParser(
143
+ prog="gh-prlist",
144
+ description="Configurable own-PR and team-review monitor.",
145
+ )
146
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
147
+ parser.add_argument(
148
+ "--config",
149
+ type=Path,
150
+ default=CONFIG_PATH,
151
+ help=f"Config file (default: {CONFIG_PATH})",
152
+ )
153
+ sub = parser.add_subparsers(dest="command")
154
+
155
+ setup = sub.add_parser("setup", help="Interactively create or update configuration")
156
+ setup.add_argument(
157
+ "--config",
158
+ type=Path,
159
+ dest="command_config",
160
+ help="Config file (default: ~/.prlist)",
161
+ )
162
+
163
+ listing = sub.add_parser("list", help="List configured PR sources once")
164
+ _add_common_flags(listing, monitor=False)
165
+
166
+ monitor = sub.add_parser("monitor", help="Monitor and notify continuously")
167
+ _add_common_flags(monitor, monitor=True)
168
+ return parser
169
+
170
+
171
+ def _normalize_legacy(argv: list[str]) -> list[str]:
172
+ commands = {"setup", "list", "monitor"}
173
+ if not argv or argv[0] in {"-h", "--help", "--version"} or commands.intersection(argv):
174
+ return argv
175
+ translated: list[str] = []
176
+ index = 0
177
+ while index < len(argv):
178
+ item = argv[index]
179
+ if item == "--include-drafts":
180
+ translated.append("--team-drafts")
181
+ elif item == "--no-green-ci":
182
+ translated.extend(["--team-ci", "any"])
183
+ elif item == "--no-mine-failing":
184
+ translated.append("--team")
185
+ else:
186
+ translated.append(item)
187
+ index += 1
188
+
189
+ root: list[str] = []
190
+ if len(translated) >= 2 and translated[0] == "--config":
191
+ root, translated = translated[:2], translated[2:]
192
+ if "--watch" in translated:
193
+ return [
194
+ *root,
195
+ "monitor",
196
+ *[item for item in translated if item != "--watch"],
197
+ ]
198
+ # Old invocations had no `list` subcommand.
199
+ return [*root, "list", *translated]
200
+
201
+
202
+ def _override(value, fallback):
203
+ return fallback if value is None else value
204
+
205
+
206
+ def resolve_runtime(args: argparse.Namespace, config: Config) -> Runtime:
207
+ config.github.owner = _override(args.owner, config.github.owner)
208
+ config.github.author = _override(args.author, config.github.author)
209
+ config.github.reviewer = _override(args.reviewer, config.github.reviewer)
210
+ config.github.limit = _override(args.limit, config.github.limit)
211
+ config.github.workers = _override(args.workers, config.github.workers)
212
+ config.own.ci = _override(args.own_ci, config.own.ci)
213
+ config.team.ci = _override(args.team_ci, config.team.ci)
214
+ config.own.mergeable = _override(
215
+ args.own_mergeable, config.own.mergeable
216
+ )
217
+ config.team.mergeable = _override(
218
+ args.team_mergeable, config.team.mergeable
219
+ )
220
+ config.own.include_drafts = _override(
221
+ args.own_drafts, config.own.include_drafts
222
+ )
223
+ config.team.include_drafts = _override(
224
+ args.team_drafts, config.team.include_drafts
225
+ )
226
+
227
+ explicit_sources = {
228
+ name for name in ("own", "team") if getattr(args, name, False)
229
+ }
230
+ sources = explicit_sources or {
231
+ name
232
+ for name in ("own", "team")
233
+ if getattr(config, name).enabled
234
+ }
235
+
236
+ monitor_command = args.command == "monitor"
237
+ interval = (
238
+ _override(getattr(args, "interval", None), config.monitor.interval)
239
+ if monitor_command
240
+ else config.monitor.interval
241
+ )
242
+ notification_sources = set(config.monitor.notification_sources)
243
+ for name in ("own", "team"):
244
+ override = getattr(args, f"notify_{name}", None)
245
+ if override is True:
246
+ notification_sources.add(name)
247
+ elif override is False:
248
+ notification_sources.discard(name)
249
+ notification_sources &= sources
250
+
251
+ html = _override(args.html, config.monitor.html if monitor_command else False)
252
+ return Runtime(
253
+ config=config,
254
+ sources=sources,
255
+ json=_override(args.json, config.output.json),
256
+ show_rejected=_override(
257
+ args.show_rejected, config.output.show_rejected
258
+ ),
259
+ notifications=_override(
260
+ getattr(args, "notifications", None), config.monitor.notifications
261
+ ),
262
+ notify_on_change=_override(
263
+ getattr(args, "notify_on_change", None),
264
+ config.monitor.notify_on_change,
265
+ ),
266
+ notification_sources=notification_sources,
267
+ interval_seconds=parse_interval(interval),
268
+ html=html,
269
+ html_path=Path(
270
+ _override(args.html_path, config.monitor.html_path)
271
+ ).expanduser(),
272
+ state_path=Path(
273
+ _override(getattr(args, "state_path", None), config.monitor.state_path)
274
+ ).expanduser(),
275
+ )
276
+
277
+
278
+ def _matches(pr: PullRequest, ci_policy: str, merge_policy: str) -> bool:
279
+ ci_matches = (
280
+ bool(pr.ci_green)
281
+ if ci_policy == "green"
282
+ else pr.ci_failing
283
+ if ci_policy == "failing"
284
+ else True
285
+ )
286
+ merge_matches = (
287
+ pr.mergeable == "CONFLICTING"
288
+ if merge_policy == "conflicting"
289
+ else pr.mergeable != "CONFLICTING"
290
+ if merge_policy == "not_conflicting"
291
+ else True
292
+ )
293
+ return ci_matches and merge_matches
294
+
295
+
296
+ def collect_snapshot(runtime: Runtime) -> Snapshot:
297
+ config = runtime.config
298
+ own: list[PullRequest] = []
299
+ team: list[PullRequest] = []
300
+ if "own" in runtime.sources:
301
+ own = search_authored_prs(
302
+ owner=config.github.owner,
303
+ author=config.github.author,
304
+ include_drafts=config.own.include_drafts,
305
+ limit=config.github.limit,
306
+ )
307
+ if "team" in runtime.sources:
308
+ team = search_team_review_prs(
309
+ owner=config.github.owner,
310
+ reviewer=config.github.reviewer,
311
+ include_drafts=config.team.include_drafts,
312
+ limit=config.github.limit,
313
+ )
314
+
315
+ checked = attach_ci(
316
+ merge_unique([own, team]), workers=config.github.workers
317
+ )
318
+ by_url = {pr.url: pr for pr in checked}
319
+ evaluated_team = [by_url[pr.url] for pr in team]
320
+ return Snapshot(
321
+ own=[
322
+ by_url[pr.url]
323
+ for pr in own
324
+ if _matches(
325
+ by_url[pr.url], config.own.ci, config.own.mergeable
326
+ )
327
+ ],
328
+ team=[
329
+ pr
330
+ for pr in evaluated_team
331
+ if _matches(pr, config.team.ci, config.team.mergeable)
332
+ ],
333
+ evaluated_team=evaluated_team,
334
+ show_ci=True,
335
+ )
336
+
337
+
338
+ def _setup(args: argparse.Namespace) -> int:
339
+ path = (args.command_config or args.config).expanduser()
340
+ current = load_config(path)
341
+ config = interactive_setup(current, input_fn=input, output=sys.stdout)
342
+ written = write_config(config, path)
343
+ print(f"Config written: {written}")
344
+ notifier = shutil.which("terminal-notifier")
345
+ if config.monitor.notifications:
346
+ if notifier:
347
+ print(f"Clickable notifications enabled via {notifier}.")
348
+ else:
349
+ print(
350
+ "Notifications will use osascript; install terminal-notifier "
351
+ "for a clickable Show action."
352
+ )
353
+ print("Run: gh-prlist monitor")
354
+ return 0
355
+
356
+
357
+ def _print_snapshot(snapshot: Snapshot, runtime: Runtime) -> None:
358
+ print(
359
+ as_snapshot_json(snapshot)
360
+ if runtime.json
361
+ else as_snapshot_table(snapshot)
362
+ )
363
+ if runtime.show_rejected:
364
+ for pr in snapshot.rejected:
365
+ print(
366
+ f"filtered team {pr.repo}#{pr.number}: {pr.ci_summary}",
367
+ file=sys.stderr,
368
+ )
369
+
370
+
371
+ def _run_once(args: argparse.Namespace, runtime: Runtime) -> int:
372
+ snapshot = collect_snapshot(runtime)
373
+ _print_snapshot(snapshot, runtime)
374
+ if runtime.html or args.open_report:
375
+ written = write_report(snapshot, runtime.html_path)
376
+ print(f"report: {written}", file=sys.stderr)
377
+ if args.open_report:
378
+ subprocess.run(["open", str(written)], check=False)
379
+ return 0
380
+
381
+
382
+ def main(argv: list[str] | None = None) -> int:
383
+ raw = sys.argv[1:] if argv is None else argv
384
+ args = build_parser().parse_args(_normalize_legacy(list(raw)))
385
+ if args.command is None:
386
+ build_parser().print_help()
387
+ return 0
388
+ try:
389
+ if args.command == "setup":
390
+ return _setup(args)
391
+ config = load_config(args.config)
392
+ runtime = resolve_runtime(args, config)
393
+ ensure_gh()
394
+ if args.command == "list":
395
+ return _run_once(args, runtime)
396
+ try:
397
+ return run_watch(
398
+ collect=lambda: collect_snapshot(runtime),
399
+ interval_seconds=runtime.interval_seconds,
400
+ as_json_output=runtime.json,
401
+ notifications=runtime.notifications,
402
+ notify_on_change=runtime.notify_on_change,
403
+ notification_sources=runtime.notification_sources,
404
+ stdout=sys.stdout,
405
+ html_path=runtime.html_path if runtime.html else None,
406
+ state_path=runtime.state_path,
407
+ )
408
+ except KeyboardInterrupt:
409
+ print("\nstopped", file=sys.stderr)
410
+ return 0
411
+ except (ConfigError, GhError, ValueError) as exc:
412
+ print(f"error: {exc}", file=sys.stderr)
413
+ return 1