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.
@@ -0,0 +1,3 @@
1
+ """Spring Clean GitHub repository audit tool."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
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
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class SpringCleanError(Exception):
5
+ """Expected operational error."""
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
@@ -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