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.
- gh_prlist-0.2.0/PKG-INFO +51 -0
- gh_prlist-0.2.0/README.md +32 -0
- gh_prlist-0.2.0/gh_prlist.egg-info/PKG-INFO +51 -0
- gh_prlist-0.2.0/gh_prlist.egg-info/SOURCES.txt +22 -0
- gh_prlist-0.2.0/gh_prlist.egg-info/dependency_links.txt +1 -0
- gh_prlist-0.2.0/gh_prlist.egg-info/entry_points.txt +4 -0
- gh_prlist-0.2.0/gh_prlist.egg-info/top_level.txt +1 -0
- gh_prlist-0.2.0/pr_list/__init__.py +3 -0
- gh_prlist-0.2.0/pr_list/__main__.py +4 -0
- gh_prlist-0.2.0/pr_list/ci.py +85 -0
- gh_prlist-0.2.0/pr_list/cli.py +413 -0
- gh_prlist-0.2.0/pr_list/config.py +496 -0
- gh_prlist-0.2.0/pr_list/formatters.py +82 -0
- gh_prlist-0.2.0/pr_list/github.py +160 -0
- gh_prlist-0.2.0/pr_list/models.py +57 -0
- gh_prlist-0.2.0/pr_list/notify.py +84 -0
- gh_prlist-0.2.0/pr_list/report.py +144 -0
- gh_prlist-0.2.0/pr_list/watch.py +131 -0
- gh_prlist-0.2.0/pyproject.toml +32 -0
- gh_prlist-0.2.0/setup.cfg +4 -0
- gh_prlist-0.2.0/tests/test_ci.py +59 -0
- gh_prlist-0.2.0/tests/test_config.py +117 -0
- gh_prlist-0.2.0/tests/test_formatters.py +57 -0
- gh_prlist-0.2.0/tests/test_watch.py +221 -0
gh_prlist-0.2.0/PKG-INFO
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pr_list
|
|
@@ -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
|