springclean 0.1.0__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.
- springclean/__init__.py +3 -0
- springclean/__main__.py +6 -0
- springclean/cli.py +92 -0
- springclean/env.py +38 -0
- springclean/errors.py +5 -0
- springclean/github.py +149 -0
- springclean/repo_refs.py +29 -0
- springclean/reports.py +470 -0
- springclean/tui.py +1175 -0
- springclean-0.1.0.dist-info/METADATA +390 -0
- springclean-0.1.0.dist-info/RECORD +15 -0
- springclean-0.1.0.dist-info/WHEEL +5 -0
- springclean-0.1.0.dist-info/entry_points.txt +2 -0
- springclean-0.1.0.dist-info/licenses/LICENSE +21 -0
- springclean-0.1.0.dist-info/top_level.txt +1 -0
springclean/__init__.py
ADDED
springclean/__main__.py
ADDED
springclean/cli.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from .env import github_token
|
|
9
|
+
from .errors import SpringCleanError
|
|
10
|
+
from .github import GitHubClient
|
|
11
|
+
from .repo_refs import parse_repo_reference
|
|
12
|
+
from .reports import DEFAULT_STALE_DAYS, audit_repo
|
|
13
|
+
from .tui import run_browser
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> int:
|
|
17
|
+
parser = build_parser()
|
|
18
|
+
args = parser.parse_args()
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
if args.command is None:
|
|
22
|
+
run_browser(Path(args.reports_dir))
|
|
23
|
+
return 0
|
|
24
|
+
|
|
25
|
+
if not args.branch and not args.pr:
|
|
26
|
+
parser.error("Choose at least one report: --branch and/or --pr.")
|
|
27
|
+
|
|
28
|
+
if args.stale_days < 1:
|
|
29
|
+
parser.error("--stale-days must be greater than zero.")
|
|
30
|
+
|
|
31
|
+
owner, repo = parse_repo(args.repo)
|
|
32
|
+
token = github_token()
|
|
33
|
+
if not token:
|
|
34
|
+
raise SpringCleanError("Missing GITHUB_TOKEN. Add it to .env or set it in your shell.")
|
|
35
|
+
client = GitHubClient(token=token)
|
|
36
|
+
audit_repo(
|
|
37
|
+
client=client,
|
|
38
|
+
owner=owner,
|
|
39
|
+
repo=repo,
|
|
40
|
+
include_branches=args.branch,
|
|
41
|
+
include_prs=args.pr,
|
|
42
|
+
out_dir=Path(args.out),
|
|
43
|
+
stale_days=args.stale_days,
|
|
44
|
+
)
|
|
45
|
+
except SpringCleanError as exc:
|
|
46
|
+
print(f"springclean: {exc}", file=sys.stderr)
|
|
47
|
+
return 1
|
|
48
|
+
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
53
|
+
parser = argparse.ArgumentParser(
|
|
54
|
+
prog="springclean",
|
|
55
|
+
description="Spring Clean writes and browses GitHub repository cleanup reports.",
|
|
56
|
+
epilog="Run without a subcommand to open the terminal browser.",
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--reports-dir",
|
|
61
|
+
dest="reports_dir",
|
|
62
|
+
default="reports",
|
|
63
|
+
help="Reports directory used by the terminal browser. Defaults to ./reports.",
|
|
64
|
+
)
|
|
65
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
66
|
+
|
|
67
|
+
repo_parser = subparsers.add_parser("repo", help="Audit one repository.")
|
|
68
|
+
repo_parser.add_argument("repo", help="Repository in owner/name format.")
|
|
69
|
+
repo_parser.add_argument("--branch", dest="branch", action="store_true", help="Write branch report.")
|
|
70
|
+
repo_parser.add_argument(
|
|
71
|
+
"--pr",
|
|
72
|
+
dest="pr",
|
|
73
|
+
action="store_true",
|
|
74
|
+
help="Write open and draft pull request report.",
|
|
75
|
+
)
|
|
76
|
+
repo_parser.add_argument(
|
|
77
|
+
"--out",
|
|
78
|
+
default="reports",
|
|
79
|
+
help="Output directory for report files. Defaults to ./reports.",
|
|
80
|
+
)
|
|
81
|
+
repo_parser.add_argument(
|
|
82
|
+
"--stale-days",
|
|
83
|
+
type=int,
|
|
84
|
+
default=DEFAULT_STALE_DAYS,
|
|
85
|
+
help=f"Days without activity before a branch or PR is stale. Defaults to {DEFAULT_STALE_DAYS}.",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return parser
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def parse_repo(value: str) -> tuple[str, str]:
|
|
92
|
+
return parse_repo_reference(value)
|
springclean/env.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .errors import SpringCleanError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def github_token() -> str | None:
|
|
10
|
+
return os.environ.get("GITHUB_TOKEN") or load_dotenv().get("GITHUB_TOKEN")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_dotenv(path: Path = Path(".env")) -> dict[str, str]:
|
|
14
|
+
if not path.exists():
|
|
15
|
+
return {}
|
|
16
|
+
|
|
17
|
+
values = {}
|
|
18
|
+
for line_number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
|
19
|
+
line = raw_line.strip()
|
|
20
|
+
if not line or line.startswith("#"):
|
|
21
|
+
continue
|
|
22
|
+
if "=" not in line:
|
|
23
|
+
raise SpringCleanError(f"Invalid .env line {line_number}: expected KEY=value.")
|
|
24
|
+
|
|
25
|
+
key, value = line.split("=", 1)
|
|
26
|
+
key = key.strip()
|
|
27
|
+
value = value.strip()
|
|
28
|
+
if not key:
|
|
29
|
+
raise SpringCleanError(f"Invalid .env line {line_number}: missing key.")
|
|
30
|
+
values[key] = unquote_env_value(value)
|
|
31
|
+
|
|
32
|
+
return values
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def unquote_env_value(value: str) -> str:
|
|
36
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
37
|
+
return value[1:-1]
|
|
38
|
+
return value
|
springclean/errors.py
ADDED
springclean/github.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.error import HTTPError, URLError
|
|
9
|
+
from urllib.parse import urlencode
|
|
10
|
+
from urllib.request import Request, urlopen
|
|
11
|
+
|
|
12
|
+
from .errors import SpringCleanError
|
|
13
|
+
|
|
14
|
+
API_ROOT = "https://api.github.com"
|
|
15
|
+
API_VERSION = "2022-11-28"
|
|
16
|
+
RATE_LIMIT_RETRY_CODES = {403, 429}
|
|
17
|
+
RATE_LIMIT_RETRIES = 2
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class GitHubClient:
|
|
22
|
+
token: str
|
|
23
|
+
api_root: str = API_ROOT
|
|
24
|
+
rate_limit_retries: int = RATE_LIMIT_RETRIES
|
|
25
|
+
|
|
26
|
+
def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
|
27
|
+
url = self._url(path, params)
|
|
28
|
+
request = Request(url, headers=self._headers())
|
|
29
|
+
attempt = 0
|
|
30
|
+
|
|
31
|
+
while True:
|
|
32
|
+
try:
|
|
33
|
+
with urlopen(request, timeout=30) as response:
|
|
34
|
+
self._maybe_wait_for_rate_limit(response.headers)
|
|
35
|
+
body = response.read().decode("utf-8")
|
|
36
|
+
break
|
|
37
|
+
except HTTPError as exc:
|
|
38
|
+
if self._should_retry_rate_limit(exc, attempt):
|
|
39
|
+
attempt += 1
|
|
40
|
+
continue
|
|
41
|
+
self._raise_http_error(exc)
|
|
42
|
+
except URLError as exc:
|
|
43
|
+
raise SpringCleanError(f"Could not reach GitHub API: {exc.reason}") from exc
|
|
44
|
+
|
|
45
|
+
if not body:
|
|
46
|
+
return None
|
|
47
|
+
return json.loads(body)
|
|
48
|
+
|
|
49
|
+
def paged(self, path: str, params: dict[str, Any] | None = None) -> list[Any]:
|
|
50
|
+
params = dict(params or {})
|
|
51
|
+
params["per_page"] = 100
|
|
52
|
+
page = 1
|
|
53
|
+
items: list[Any] = []
|
|
54
|
+
|
|
55
|
+
while True:
|
|
56
|
+
params["page"] = page
|
|
57
|
+
batch = self.get(path, params)
|
|
58
|
+
if not isinstance(batch, list):
|
|
59
|
+
raise SpringCleanError(f"Expected a list response from {path}")
|
|
60
|
+
if not batch:
|
|
61
|
+
break
|
|
62
|
+
items.extend(batch)
|
|
63
|
+
if len(batch) < 100:
|
|
64
|
+
break
|
|
65
|
+
page += 1
|
|
66
|
+
|
|
67
|
+
return items
|
|
68
|
+
|
|
69
|
+
def _headers(self) -> dict[str, str]:
|
|
70
|
+
return {
|
|
71
|
+
"Accept": "application/vnd.github+json",
|
|
72
|
+
"Authorization": f"Bearer {self.token}",
|
|
73
|
+
"User-Agent": "springclean",
|
|
74
|
+
"X-GitHub-Api-Version": API_VERSION,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
def _url(self, path: str, params: dict[str, Any] | None) -> str:
|
|
78
|
+
url = f"{self.api_root}{path}"
|
|
79
|
+
if params:
|
|
80
|
+
url = f"{url}?{urlencode(params)}"
|
|
81
|
+
return url
|
|
82
|
+
|
|
83
|
+
def _maybe_wait_for_rate_limit(self, headers: Any) -> None:
|
|
84
|
+
sleep_for = self._rate_limit_sleep_seconds(headers)
|
|
85
|
+
if sleep_for is None:
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
print(f"GitHub rate limit reached. Sleeping for {sleep_for}s.", file=sys.stderr)
|
|
89
|
+
time.sleep(sleep_for)
|
|
90
|
+
|
|
91
|
+
def _should_retry_rate_limit(self, exc: HTTPError, attempt: int) -> bool:
|
|
92
|
+
if exc.code not in RATE_LIMIT_RETRY_CODES or attempt >= self.rate_limit_retries:
|
|
93
|
+
return False
|
|
94
|
+
sleep_for = self._rate_limit_sleep_seconds(exc.headers)
|
|
95
|
+
if sleep_for is None:
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
print(f"GitHub rate limit reached. Sleeping for {sleep_for}s before retrying.", file=sys.stderr)
|
|
99
|
+
time.sleep(sleep_for)
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
def _rate_limit_sleep_seconds(self, headers: Any) -> int | None:
|
|
103
|
+
retry_after = self._int_header(headers, "Retry-After")
|
|
104
|
+
if retry_after is not None:
|
|
105
|
+
return max(0, retry_after)
|
|
106
|
+
|
|
107
|
+
remaining = self._header(headers, "X-RateLimit-Remaining")
|
|
108
|
+
reset = self._int_header(headers, "X-RateLimit-Reset")
|
|
109
|
+
if remaining != "0" or reset is None:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
return max(0, reset - int(time.time())) + 1
|
|
113
|
+
|
|
114
|
+
def _int_header(self, headers: Any, name: str) -> int | None:
|
|
115
|
+
value = self._header(headers, name)
|
|
116
|
+
if value in (None, ""):
|
|
117
|
+
return None
|
|
118
|
+
try:
|
|
119
|
+
return int(value)
|
|
120
|
+
except ValueError:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
def _header(self, headers: Any, name: str) -> str | None:
|
|
124
|
+
if headers is None:
|
|
125
|
+
return None
|
|
126
|
+
value = headers.get(name)
|
|
127
|
+
if value is not None:
|
|
128
|
+
return value
|
|
129
|
+
lower_name = name.lower()
|
|
130
|
+
for key, candidate in getattr(headers, "items", lambda: [])():
|
|
131
|
+
if key.lower() == lower_name:
|
|
132
|
+
return candidate
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
def _raise_http_error(self, exc: HTTPError) -> None:
|
|
136
|
+
detail = ""
|
|
137
|
+
try:
|
|
138
|
+
payload = json.loads(exc.read().decode("utf-8"))
|
|
139
|
+
detail = payload.get("message", "")
|
|
140
|
+
except Exception:
|
|
141
|
+
detail = exc.reason
|
|
142
|
+
|
|
143
|
+
if exc.code == 401:
|
|
144
|
+
raise SpringCleanError("GitHub authentication failed. Check GITHUB_TOKEN.") from exc
|
|
145
|
+
if exc.code == 403:
|
|
146
|
+
raise SpringCleanError(f"GitHub API returned 403: {detail}") from exc
|
|
147
|
+
if exc.code == 404:
|
|
148
|
+
raise SpringCleanError(f"GitHub resource not found or not accessible: {detail}") from exc
|
|
149
|
+
raise SpringCleanError(f"GitHub API returned {exc.code}: {detail}") from exc
|
springclean/repo_refs.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from urllib.parse import urlparse
|
|
4
|
+
|
|
5
|
+
from .errors import SpringCleanError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parse_repo_reference(value: str) -> tuple[str, str]:
|
|
9
|
+
repo_ref = value.strip()
|
|
10
|
+
if not repo_ref:
|
|
11
|
+
raise SpringCleanError("Repository must be in owner/name format.")
|
|
12
|
+
|
|
13
|
+
if repo_ref.startswith("git@github.com:"):
|
|
14
|
+
repo_ref = repo_ref.removeprefix("git@github.com:")
|
|
15
|
+
elif "://" in repo_ref:
|
|
16
|
+
parsed = urlparse(repo_ref)
|
|
17
|
+
if parsed.netloc.lower() not in {"github.com", "www.github.com"}:
|
|
18
|
+
raise SpringCleanError("GitHub URL must use github.com.")
|
|
19
|
+
repo_ref = parsed.path.strip("/")
|
|
20
|
+
|
|
21
|
+
parts = [part for part in repo_ref.split("/") if part]
|
|
22
|
+
if len(parts) < 2:
|
|
23
|
+
raise SpringCleanError("Repository must be in owner/name format, for example marioribeiro/springclean.")
|
|
24
|
+
|
|
25
|
+
owner = parts[0]
|
|
26
|
+
repo = parts[1].removesuffix(".git")
|
|
27
|
+
if not owner or not repo:
|
|
28
|
+
raise SpringCleanError("Repository must be in owner/name format, for example marioribeiro/springclean.")
|
|
29
|
+
return owner, repo
|