kctl-github 0.2.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,208 @@
1
+ """Repository statistics commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime, timedelta
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from kctl_github.core.callbacks import AppContext
11
+
12
+ app = typer.Typer(help="Repository statistics across kodemeio-* repos.")
13
+
14
+
15
+ @app.command()
16
+ def overview(ctx: typer.Context) -> None:
17
+ """Total repos, total stars, total issues, total PRs."""
18
+ actx: AppContext = ctx.obj
19
+ out = actx.output
20
+ client = actx.client
21
+
22
+ repos = client.get_repos()
23
+ total_stars = sum(r.get("stargazers_count", 0) for r in repos)
24
+ total_forks = sum(r.get("forks_count", 0) for r in repos)
25
+ total_issues = sum(r.get("open_issues_count", 0) for r in repos)
26
+ total_size = sum(r.get("size", 0) for r in repos)
27
+
28
+ if out.json_mode:
29
+ out.raw_json(
30
+ {
31
+ "repos": len(repos),
32
+ "stars": total_stars,
33
+ "forks": total_forks,
34
+ "open_issues": total_issues,
35
+ "total_size_kb": total_size,
36
+ }
37
+ )
38
+ return
39
+
40
+ sections = [
41
+ (
42
+ "Overview",
43
+ [
44
+ ("Repositories", str(len(repos))),
45
+ ("Total Stars", str(total_stars)),
46
+ ("Total Forks", str(total_forks)),
47
+ ("Open Issues", str(total_issues)),
48
+ ("Total Size", f"{total_size:,} KB ({total_size // 1024} MB)"),
49
+ ],
50
+ ),
51
+ ]
52
+ out.detail("GitHub Stats Overview", sections)
53
+
54
+
55
+ @app.command()
56
+ def activity(
57
+ ctx: typer.Context,
58
+ period: Annotated[str, typer.Option("--period", help="Time period (e.g., 7d, 30d)")] = "30d",
59
+ ) -> None:
60
+ """Commit activity, PR merge rate, issue velocity."""
61
+ actx: AppContext = ctx.obj
62
+ out = actx.output
63
+ client = actx.client
64
+
65
+ days = int(period.rstrip("d"))
66
+ since = datetime.now(UTC) - timedelta(days=days)
67
+ since_str = since.strftime("%Y-%m-%dT%H:%M:%SZ")
68
+
69
+ repos = client.get_repos()
70
+ rows = []
71
+
72
+ for repo in sorted(repos, key=lambda x: x["name"]):
73
+ name = repo["name"]
74
+ owner = client.organization
75
+
76
+ # Commits since period
77
+ try:
78
+ commits = client.get(
79
+ f"/repos/{owner}/{name}/commits",
80
+ params={"since": since_str, "per_page": 100},
81
+ )
82
+ commit_count = len(commits) if isinstance(commits, list) else 0
83
+ except Exception: # noqa: BLE001
84
+ commit_count = 0
85
+
86
+ # Merged PRs in period
87
+ try:
88
+ prs = client.get(
89
+ f"/repos/{owner}/{name}/pulls",
90
+ params={"state": "closed", "sort": "updated", "direction": "desc", "per_page": 50},
91
+ )
92
+ merged_count = 0
93
+ if isinstance(prs, list):
94
+ for pr in prs:
95
+ if pr.get("merged_at"):
96
+ try:
97
+ merged_dt = datetime.fromisoformat(pr["merged_at"].replace("Z", "+00:00"))
98
+ if merged_dt >= since:
99
+ merged_count += 1
100
+ except (ValueError, TypeError):
101
+ pass
102
+ except Exception: # noqa: BLE001
103
+ merged_count = 0
104
+
105
+ if commit_count > 0 or merged_count > 0:
106
+ rows.append([name, str(commit_count), str(merged_count)])
107
+
108
+ if out.json_mode:
109
+ out.raw_json(
110
+ [
111
+ {
112
+ "repo": r[0],
113
+ "commits": int(r[1]),
114
+ "merged_prs": int(r[2]),
115
+ }
116
+ for r in rows
117
+ ]
118
+ )
119
+ return
120
+
121
+ out.table(
122
+ f"Activity (last {days} days)",
123
+ [("Repo", "cyan"), ("Commits", "green"), ("Merged PRs", "yellow")],
124
+ rows,
125
+ )
126
+
127
+
128
+ @app.command()
129
+ def languages(ctx: typer.Context) -> None:
130
+ """Language breakdown across all repos."""
131
+ actx: AppContext = ctx.obj
132
+ out = actx.output
133
+ client = actx.client
134
+
135
+ repos = client.get_repos()
136
+ lang_totals: dict[str, int] = {}
137
+
138
+ for repo in repos:
139
+ name = repo["name"]
140
+ try:
141
+ langs = client.get(f"/repos/{client.organization}/{name}/languages")
142
+ if isinstance(langs, dict):
143
+ for lang, bytes_count in langs.items():
144
+ lang_totals[lang] = lang_totals.get(lang, 0) + bytes_count
145
+ except Exception: # noqa: BLE001
146
+ pass
147
+
148
+ total = sum(lang_totals.values())
149
+ sorted_langs = sorted(lang_totals.items(), key=lambda x: x[1], reverse=True)
150
+
151
+ if out.json_mode:
152
+ out.raw_json(
153
+ {
154
+ lang: {"bytes": b, "percentage": f"{(b / total * 100):.1f}%"} if total else {"bytes": b}
155
+ for lang, b in sorted_langs
156
+ }
157
+ )
158
+ return
159
+
160
+ rows = []
161
+ for lang, bytes_count in sorted_langs[:20]:
162
+ pct = (bytes_count / total * 100) if total else 0
163
+ bar = "#" * int(pct / 2)
164
+ rows.append([lang, f"{bytes_count:,}", f"{pct:.1f}%", bar])
165
+
166
+ out.table(
167
+ "Languages (all repos)",
168
+ [("Language", "cyan"), ("Bytes", ""), ("%", "yellow"), ("Distribution", "green")],
169
+ rows,
170
+ )
171
+
172
+
173
+ @app.command()
174
+ def contributors(ctx: typer.Context) -> None:
175
+ """Contributor activity across all repos."""
176
+ actx: AppContext = ctx.obj
177
+ out = actx.output
178
+ client = actx.client
179
+
180
+ repos = client.get_repos()
181
+ contributor_totals: dict[str, int] = {}
182
+
183
+ for repo in repos:
184
+ name = repo["name"]
185
+ try:
186
+ contribs = client.get(
187
+ f"/repos/{client.organization}/{name}/contributors",
188
+ params={"per_page": 30},
189
+ )
190
+ if isinstance(contribs, list):
191
+ for c in contribs:
192
+ login = c.get("login", "unknown")
193
+ contributor_totals[login] = contributor_totals.get(login, 0) + c.get("contributions", 0)
194
+ except Exception: # noqa: BLE001
195
+ pass
196
+
197
+ sorted_contribs = sorted(contributor_totals.items(), key=lambda x: x[1], reverse=True)
198
+
199
+ if out.json_mode:
200
+ out.raw_json([{"login": login, "total_contributions": count} for login, count in sorted_contribs])
201
+ return
202
+
203
+ rows = [[login, str(count)] for login, count in sorted_contribs[:20]]
204
+ out.table(
205
+ "Top Contributors (all repos)",
206
+ [("Login", "cyan"), ("Total Contributions", "yellow")],
207
+ rows,
208
+ )
File without changes
@@ -0,0 +1,40 @@
1
+ """Application context for kctl-github."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from kctl_lib.callbacks import AppContextBase
8
+
9
+ from kctl_github.core.client import GitHubClient
10
+ from kctl_github.core.config import (
11
+ ServiceConfig,
12
+ get_service_config,
13
+ resolve_active_profile_name,
14
+ )
15
+
16
+
17
+ @dataclass
18
+ class AppContext(AppContextBase):
19
+ """kctl-github application context."""
20
+
21
+ _client: GitHubClient | None = field(default=None, repr=False)
22
+
23
+ @property
24
+ def client(self) -> GitHubClient:
25
+ """Lazy-initialize GitHub API client from active profile config."""
26
+ if self._client is None:
27
+ profile = resolve_active_profile_name(self.profile)
28
+ cfg = get_service_config(profile)
29
+ self._client = GitHubClient(
30
+ credential=cfg.token,
31
+ organization=cfg.organization,
32
+ repo_prefix=cfg.repo_prefix,
33
+ )
34
+ return self._client
35
+
36
+ @property
37
+ def config(self) -> ServiceConfig:
38
+ """Get the resolved service config."""
39
+ profile = resolve_active_profile_name(self.profile)
40
+ return get_service_config(profile)
@@ -0,0 +1,124 @@
1
+ """GitHub API client and gh CLI helper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from urllib.parse import parse_qs, urlparse
7
+
8
+ from kctl_lib.api_client import APIClient
9
+ from kctl_lib.runner import run
10
+
11
+
12
+ class GitHubClient(APIClient):
13
+ """Synchronous GitHub REST API client.
14
+
15
+ Subclasses APIClient with GitHub-specific defaults.
16
+ Provides helpers for paginated listing and repo filtering.
17
+ """
18
+
19
+ BASE_URL = "https://api.github.com"
20
+ AUTH_HEADER = "Authorization"
21
+ AUTH_PREFIX = "Bearer"
22
+
23
+ def __init__(
24
+ self,
25
+ credential: str = "",
26
+ organization: str = "tgunawandev",
27
+ repo_prefix: str = "kodemeio-",
28
+ timeout: float = 30.0,
29
+ **kwargs: Any,
30
+ ) -> None:
31
+ super().__init__(credential=credential, timeout=timeout, **kwargs)
32
+ self._organization = organization
33
+ self._repo_prefix = repo_prefix
34
+ # Add Accept header for GitHub API v3
35
+ self._client.headers["Accept"] = "application/vnd.github+json"
36
+ self._client.headers["X-GitHub-Api-Version"] = "2022-11-28"
37
+
38
+ # ------------------------------------------------------------------
39
+ # Pagination helper
40
+ # ------------------------------------------------------------------
41
+
42
+ def get_paginated(
43
+ self,
44
+ endpoint: str,
45
+ params: dict[str, Any] | None = None,
46
+ max_pages: int = 10,
47
+ ) -> list[dict[str, Any]]:
48
+ """Fetch all pages from a paginated GitHub endpoint.
49
+
50
+ GitHub uses Link headers for pagination. This method follows
51
+ ``next`` links up to *max_pages*.
52
+ """
53
+ all_items: list[dict[str, Any]] = []
54
+ _params = dict(params or {})
55
+ _params.setdefault("per_page", 100)
56
+
57
+ for _ in range(max_pages):
58
+ response = self._request("GET", endpoint, params=_params)
59
+ data = response.json()
60
+ if isinstance(data, list):
61
+ all_items.extend(data)
62
+ else:
63
+ # Some endpoints return objects with items inside
64
+ break
65
+
66
+ # Check for next page via Link header
67
+ link = response.headers.get("link", "")
68
+ if 'rel="next"' not in link:
69
+ break
70
+
71
+ # Parse next URL from Link header
72
+ next_url = _parse_next_link(link)
73
+ if not next_url:
74
+ break
75
+
76
+ # Extract query params from the next URL for subsequent request
77
+ parsed = urlparse(next_url)
78
+ _params = {k: v[0] for k, v in parse_qs(parsed.query).items()}
79
+
80
+ return all_items
81
+
82
+ # ------------------------------------------------------------------
83
+ # Repo helpers
84
+ # ------------------------------------------------------------------
85
+
86
+ def get_repos(self) -> list[dict[str, Any]]:
87
+ """Get all repos matching the configured prefix."""
88
+ repos = self.get_paginated(f"/users/{self._organization}/repos")
89
+ return [r for r in repos if r.get("name", "").startswith(self._repo_prefix)]
90
+
91
+ def get_repo(self, name: str) -> dict[str, Any]:
92
+ """Get a single repo by name (short name, not full_name)."""
93
+ return self.get(f"/repos/{self._organization}/{name}")
94
+
95
+ @property
96
+ def organization(self) -> str:
97
+ return self._organization
98
+
99
+ @property
100
+ def repo_prefix(self) -> str:
101
+ return self._repo_prefix
102
+
103
+
104
+ def _parse_next_link(link_header: str) -> str | None:
105
+ """Parse the 'next' URL from a GitHub Link header."""
106
+ for part in link_header.split(","):
107
+ if 'rel="next"' in part:
108
+ url = part.split(";")[0].strip().strip("<>")
109
+ return url
110
+ return None
111
+
112
+
113
+ def gh_run(args: list[str], check: bool = True) -> str:
114
+ """Run a gh CLI command and return stdout.
115
+
116
+ Args:
117
+ args: Arguments to pass to ``gh`` (e.g., ["pr", "view", "123"]).
118
+ check: If True, raise CommandError on non-zero exit.
119
+
120
+ Returns:
121
+ The stdout output as a string.
122
+ """
123
+ result = run(["gh", *args], check=check)
124
+ return result.stdout.strip()
@@ -0,0 +1,55 @@
1
+ """Config management for kctl-github."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from kctl_lib.config import (
6
+ get_all_services_in_profile,
7
+ get_profile_names,
8
+ remove_profile,
9
+ set_default_profile,
10
+ )
11
+ from kctl_lib.config import get_service_config as _get_service_config
12
+ from kctl_lib.config import (
13
+ resolve_active_profile_name as _resolve_active_profile_name,
14
+ )
15
+ from kctl_lib.config import set_service_config as _set_service_config
16
+ from pydantic import BaseModel
17
+
18
+ __all__ = [
19
+ "ServiceConfig",
20
+ "get_all_services_in_profile",
21
+ "get_profile_names",
22
+ "get_service_config",
23
+ "remove_profile",
24
+ "resolve_active_profile_name",
25
+ "set_default_profile",
26
+ "set_service_config",
27
+ ]
28
+
29
+ SERVICE_KEY = "github"
30
+ ENV_PREFIX = "KCTL_GITHUB"
31
+
32
+
33
+ class ServiceConfig(BaseModel):
34
+ """Service-specific config within a profile."""
35
+
36
+ token: str = "" # GitHub Personal Access Token
37
+ organization: str = "tgunawandev" # GitHub org or username
38
+ repo_prefix: str = "kodemeio-" # Filter repos by this prefix
39
+
40
+
41
+ def get_service_config(profile_name: str) -> ServiceConfig:
42
+ """Get the 'github' service config from a profile."""
43
+ data = _get_service_config(profile_name, SERVICE_KEY, list(ServiceConfig.model_fields.keys()))
44
+ return ServiceConfig(**data) if data else ServiceConfig()
45
+
46
+
47
+ def set_service_config(profile_name: str, svc_config: ServiceConfig) -> None:
48
+ """Set the 'github' service config within a profile."""
49
+ cleaned = {k: v for k, v in svc_config.model_dump().items() if v}
50
+ _set_service_config(profile_name, SERVICE_KEY, cleaned)
51
+
52
+
53
+ def resolve_active_profile_name(profile_name: str | None = None) -> str:
54
+ """Resolve active profile: explicit > env > default."""
55
+ return _resolve_active_profile_name(profile_name, ENV_PREFIX)
@@ -0,0 +1,21 @@
1
+ """Exception hierarchy -- re-exported from kctl-lib."""
2
+
3
+ from kctl_lib.exceptions import (
4
+ APIError,
5
+ AuthenticationError,
6
+ CommandError,
7
+ ConfigError,
8
+ KctlError,
9
+ NotFoundError,
10
+ )
11
+ from kctl_lib.exceptions import ConnectionError as KctlConnectionError
12
+
13
+ __all__ = [
14
+ "APIError",
15
+ "AuthenticationError",
16
+ "CommandError",
17
+ "ConfigError",
18
+ "KctlConnectionError",
19
+ "KctlError",
20
+ "NotFoundError",
21
+ ]
@@ -0,0 +1,13 @@
1
+ """Plugin discovery for kctl-github."""
2
+
3
+ from kctl_lib.plugins import KctlPlugin
4
+ from kctl_lib.plugins import discover_and_load_plugins as _discover
5
+
6
+ __all__ = ["KctlPlugin", "discover_and_load_plugins"]
7
+
8
+ ENTRY_POINT_GROUP = "kctl_github.plugins"
9
+
10
+
11
+ def discover_and_load_plugins(app): # noqa: ANN001, ANN201
12
+ """Discover and load kctl-github plugins."""
13
+ return _discover(app, ENTRY_POINT_GROUP)
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: kctl-github
3
+ Version: 0.2.0
4
+ Summary: Kodemeio GitHub CLI — cross-repo GitHub management
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: httpx>=0.28.0
7
+ Requires-Dist: kctl-lib>=0.4.0
8
+ Requires-Dist: pydantic>=2.10.0
9
+ Requires-Dist: pyyaml>=6.0.2
10
+ Requires-Dist: rich>=13.9.0
11
+ Requires-Dist: typer>=0.15.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: mypy>=1.14.0; extra == 'dev'
14
+ Requires-Dist: pytest-httpx>=0.35.0; extra == 'dev'
15
+ Requires-Dist: pytest>=8.3.0; extra == 'dev'
16
+ Requires-Dist: ruff>=0.9.0; extra == 'dev'
17
+ Requires-Dist: types-pyyaml>=6.0.0; extra == 'dev'
@@ -0,0 +1,26 @@
1
+ kctl_github/__init__.py,sha256=ssQ8gwlDUUPmKmQR0Dgl3Cq9sAapzcRw7tLy-dOKfk4,73
2
+ kctl_github/__main__.py,sha256=zu0zAIML3odZ6Gfhdi8aZns3cMwzXTcNPd8OpE6PrU4,88
3
+ kctl_github/cli.py,sha256=EgSieOmkYLPZQP2R7U53_LSLR08sI95toYVtc78Q6qY,4506
4
+ kctl_github/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ kctl_github/commands/billing.py,sha256=7JP9xcMMRrcCDEKVcGMYtaTkbkE_se2shffNYr5J0Lw,5514
6
+ kctl_github/commands/ci.py,sha256=Z8evyvPI2m5AInQvoHwPZIOAw8BO0aDGX-qkS1XLxTA,8419
7
+ kctl_github/commands/config_cmd.py,sha256=-Q9Kl2SVctAPOLpsr0RnD3DvFO-GbDbJ7wOAX_ieTwU,6644
8
+ kctl_github/commands/dashboard.py,sha256=ZTsxtiw_WiLr-0oGRAPV7zHd4aFtWcdqWOD9Xg2S9mY,2318
9
+ kctl_github/commands/doctor_cmd.py,sha256=nKTOsNMr3wQMqdsSlbnzkd8HGEfwJu1du6ZteLzEBNQ,2667
10
+ kctl_github/commands/health.py,sha256=2DXKQvTBfGH9OdwGrin5b5uHOej2BESlA6mv_9mqTbU,1799
11
+ kctl_github/commands/labels.py,sha256=fNwPmdFdznEY2TzW20yb6WMPpTvj3V81bxzjvMl-kpE,4128
12
+ kctl_github/commands/prs.py,sha256=FjISEt6pfpypP1xJEnfHcsADffveC3UyTUcIi_gfP3k,4962
13
+ kctl_github/commands/repos.py,sha256=8X4N1XuRY9Z1GLRf2dcuI-IKrn-vlN1tigFimpSBHqs,5769
14
+ kctl_github/commands/secrets.py,sha256=9v7yDyHwyPVj3P9M8zdcTsrA_pPWaDrQwAJtG0FVA4k,4157
15
+ kctl_github/commands/skill_cmd.py,sha256=96_A2tOt7rlhA6FCNOp8bwRdLgveX6ON4xwD8vhrNJE,2434
16
+ kctl_github/commands/stats.py,sha256=uckK6hm5kv1JRxxY90RNRDmWLtYqw5EnqcW6lWJ02s4,6419
17
+ kctl_github/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ kctl_github/core/callbacks.py,sha256=C9KzkKnksZfJesCsVbLpkSSyURxPKvM-ZQJI8IvMp0s,1181
19
+ kctl_github/core/client.py,sha256=lDhRr8ThNtVJIRdEskVuNA8X7zP-4jhwCwm5CHuPQQg,4045
20
+ kctl_github/core/config.py,sha256=Sul8rP_hfTi-5o-x3kTbAeIi9YMYEDWQQeUE6XQ6kPQ,1783
21
+ kctl_github/core/exceptions.py,sha256=zt41Q5YfY01BdNJRLLwoTmlosJBceNioqwAWBIuzE8M,434
22
+ kctl_github/core/plugins.py,sha256=L-AJBFVVhlu1pxztAGnIGCzx0OY4x4Wnre0OEEgUqM4,403
23
+ kctl_github-0.2.0.dist-info/METADATA,sha256=-qmWuYp18TqN--5EKfsUlV4kbuoVlW0EwHeKm1EhiaY,574
24
+ kctl_github-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
25
+ kctl_github-0.2.0.dist-info/entry_points.txt,sha256=-5NGnCyq0nyeojwI-_lyg4ZRw3ZIEQdSJrRyarPrWSk,53
26
+ kctl_github-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ kctl-github = kctl_github.cli:_run