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,3 @@
1
+ """kctl-github: GitHub integration management."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as python -m kctl_github."""
2
+
3
+ from kctl_github.cli import _run
4
+
5
+ _run()
kctl_github/cli.py ADDED
@@ -0,0 +1,133 @@
1
+ """Main CLI entry point for kctl-github."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from kctl_lib import KctlError, handle_cli_error
10
+
11
+ from kctl_github import __version__
12
+ from kctl_github.commands.billing import app as billing_app
13
+ from kctl_github.commands.ci import app as ci_app
14
+ from kctl_github.commands.config_cmd import app as config_app
15
+ from kctl_github.commands.dashboard import app as dashboard_app
16
+ from kctl_github.commands.health import app as health_app
17
+ from kctl_github.commands.labels import app as labels_app
18
+ from kctl_github.commands.prs import app as prs_app
19
+ from kctl_github.commands.repos import app as repos_app
20
+ from kctl_github.commands.secrets import app as secrets_app
21
+ from kctl_github.commands.stats import app as stats_app
22
+ from kctl_github.core.callbacks import AppContext
23
+ from kctl_github.core.plugins import discover_and_load_plugins
24
+ from kctl_github.commands.doctor_cmd import app as doctor_app
25
+ from kctl_github.commands.skill_cmd import app as skill_app
26
+ from kctl_lib.self_update import notify_if_outdated
27
+
28
+
29
+ def version_callback(value: bool) -> None:
30
+ if value:
31
+ typer.echo(f"kctl-github {__version__}")
32
+ raise typer.Exit()
33
+
34
+
35
+ app = typer.Typer(
36
+ name="kctl-github",
37
+ help="GitHub cross-repo management for kodemeio-* repositories",
38
+ no_args_is_help=True,
39
+ rich_markup_mode="rich",
40
+ pretty_exceptions_enable=False,
41
+ )
42
+
43
+
44
+ @app.callback()
45
+ def main(
46
+ ctx: typer.Context,
47
+ json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
48
+ quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress info messages")] = False,
49
+ profile: Annotated[str | None, typer.Option("--profile", "-p", help="Config profile name")] = None,
50
+ format: Annotated[str, typer.Option("--format", "-f", help="Output format: pretty, json, csv, yaml")] = "pretty",
51
+ no_header: Annotated[bool, typer.Option("--no-header", help="Omit header row in CSV output")] = False,
52
+ version: Annotated[
53
+ bool, typer.Option("--version", "-V", callback=version_callback, is_eager=True, help="Show version")
54
+ ] = False,
55
+ ) -> None:
56
+ """GitHub cross-repo management for kodemeio-* repositories."""
57
+ ctx.ensure_object(dict)
58
+ ctx.obj = AppContext(
59
+ json_mode=json_output,
60
+ quiet=quiet,
61
+ profile=profile,
62
+ format=format,
63
+ no_header=no_header,
64
+ )
65
+ notify_if_outdated(ctx.obj.output, "kctl-github", __version__)
66
+
67
+
68
+ # Register command groups
69
+ app.add_typer(config_app, name="config")
70
+ app.add_typer(health_app, name="health")
71
+ app.add_typer(dashboard_app, name="dashboard")
72
+ app.add_typer(repos_app, name="repos")
73
+ app.add_typer(ci_app, name="ci")
74
+ app.add_typer(prs_app, name="prs")
75
+ app.add_typer(secrets_app, name="secrets")
76
+ app.add_typer(labels_app, name="labels")
77
+ app.add_typer(stats_app, name="stats")
78
+ app.add_typer(billing_app, name="billing")
79
+ app.add_typer(doctor_app, name="doctor")
80
+ app.add_typer(skill_app, name="skill", hidden=True)
81
+
82
+ # Load third-party plugins via entry points
83
+ discover_and_load_plugins(app)
84
+
85
+
86
+ @app.command("self-update")
87
+ def self_update_cmd(ctx: typer.Context) -> None:
88
+ """Check for updates and upgrade kctl-github."""
89
+ actx = ctx.obj
90
+ out = actx.output
91
+
92
+ from kctl_lib.self_update import check_update
93
+ from kctl_lib.self_update import update as do_update
94
+
95
+ latest = check_update("kctl-github", __version__)
96
+ if latest:
97
+ out.info(f"Updating to {latest}...")
98
+ do_update("kctl-github")
99
+ out.success(f"Updated to {latest}")
100
+ else:
101
+ out.success("Already up to date")
102
+
103
+
104
+ @app.command()
105
+ def completions(
106
+ shell: Annotated[str, typer.Argument(help="Shell type: zsh, bash, fish")] = "zsh",
107
+ install: Annotated[bool, typer.Option("--install", help="Install completions")] = False,
108
+ ) -> None:
109
+ """Generate or install shell completions."""
110
+ from kctl_lib.completions import get_completion_script, install_completions
111
+
112
+ if install:
113
+ path = install_completions("kctl-github", shell)
114
+ if path:
115
+ typer.echo(f"Completions installed to {path}")
116
+ else:
117
+ typer.echo(f"Could not install completions for {shell}", err=True)
118
+ raise typer.Exit(code=1)
119
+ else:
120
+ script = get_completion_script("kctl-github", shell)
121
+ typer.echo(script)
122
+
123
+
124
+ def _run() -> None:
125
+ """Entry point with error handling."""
126
+ try:
127
+ app()
128
+ except KctlError as e:
129
+ handle_cli_error(e)
130
+
131
+
132
+ if __name__ == "__main__":
133
+ _run()
File without changes
@@ -0,0 +1,182 @@
1
+ """GitHub billing and usage commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from kctl_github.core.callbacks import AppContext
8
+
9
+ app = typer.Typer(help="GitHub Actions billing and usage.")
10
+
11
+
12
+ @app.command()
13
+ def actions(ctx: typer.Context) -> None:
14
+ """Actions minutes used this billing cycle."""
15
+ actx: AppContext = ctx.obj
16
+ out = actx.output
17
+ client = actx.client
18
+ org = client.organization
19
+
20
+ # Try org endpoint first, fall back to user
21
+ try:
22
+ data = client.get(f"/orgs/{org}/settings/billing/actions")
23
+ except Exception: # noqa: BLE001
24
+ try:
25
+ data = client.get(f"/users/{org}/settings/billing/actions")
26
+ except Exception: # noqa: BLE001
27
+ out.error("Unable to fetch billing data. Token may lack billing scope.")
28
+ raise typer.Exit(1) from None
29
+
30
+ if out.json_mode:
31
+ out.raw_json(data)
32
+ return
33
+
34
+ total_min = data.get("total_minutes_used", 0)
35
+ included_min = data.get("included_minutes", 0)
36
+ paid_min = data.get("total_paid_minutes_used", 0)
37
+
38
+ sections = [
39
+ (
40
+ "Actions Minutes",
41
+ [
42
+ ("Total Used", f"{total_min} min"),
43
+ ("Included", f"{included_min} min"),
44
+ ("Paid Overage", f"{paid_min} min"),
45
+ ],
46
+ ),
47
+ ]
48
+
49
+ # Per-OS breakdown if available
50
+ breakdown = data.get("minutes_used_breakdown", {})
51
+ if breakdown:
52
+ os_lines = [(os_name, f"{minutes} min") for os_name, minutes in breakdown.items() if minutes > 0]
53
+ if os_lines:
54
+ sections.append(("By OS", os_lines))
55
+
56
+ out.detail("Actions Billing", sections)
57
+
58
+
59
+ @app.command()
60
+ def storage(ctx: typer.Context) -> None:
61
+ """Git LFS + Packages storage usage."""
62
+ actx: AppContext = ctx.obj
63
+ out = actx.output
64
+ client = actx.client
65
+ org = client.organization
66
+
67
+ try:
68
+ data = client.get(f"/orgs/{org}/settings/billing/shared-storage")
69
+ except Exception: # noqa: BLE001
70
+ try:
71
+ data = client.get(f"/users/{org}/settings/billing/shared-storage")
72
+ except Exception: # noqa: BLE001
73
+ out.error("Unable to fetch storage billing data.")
74
+ raise typer.Exit(1) from None
75
+
76
+ if out.json_mode:
77
+ out.raw_json(data)
78
+ return
79
+
80
+ sections = [
81
+ (
82
+ "Storage",
83
+ [
84
+ ("Days Left in Cycle", str(data.get("days_left_in_billing_cycle", "?"))),
85
+ ("Estimated Paid Storage (GB)", f"{data.get('estimated_paid_storage_for_month', 0):.2f}"),
86
+ ("Estimated Storage (GB)", f"{data.get('estimated_storage_for_month', 0):.2f}"),
87
+ ],
88
+ ),
89
+ ]
90
+ out.detail("Storage Billing", sections)
91
+
92
+
93
+ @app.command()
94
+ def packages(ctx: typer.Context) -> None:
95
+ """Packages data transfer."""
96
+ actx: AppContext = ctx.obj
97
+ out = actx.output
98
+ client = actx.client
99
+ org = client.organization
100
+
101
+ try:
102
+ data = client.get(f"/orgs/{org}/settings/billing/packages")
103
+ except Exception: # noqa: BLE001
104
+ try:
105
+ data = client.get(f"/users/{org}/settings/billing/packages")
106
+ except Exception: # noqa: BLE001
107
+ out.error("Unable to fetch packages billing data.")
108
+ raise typer.Exit(1) from None
109
+
110
+ if out.json_mode:
111
+ out.raw_json(data)
112
+ return
113
+
114
+ sections = [
115
+ (
116
+ "Packages",
117
+ [
118
+ ("Total Bandwidth (GB)", f"{data.get('total_gigabytes_bandwidth_used', 0):.2f}"),
119
+ ("Included Bandwidth (GB)", f"{data.get('included_gigabytes_bandwidth', 0)}"),
120
+ ("Paid Bandwidth (GB)", f"{data.get('total_paid_gigabytes_bandwidth_used', 0):.2f}"),
121
+ ],
122
+ ),
123
+ ]
124
+ out.detail("Packages Billing", sections)
125
+
126
+
127
+ @app.command()
128
+ def overview(ctx: typer.Context) -> None:
129
+ """Combined billing summary."""
130
+ actx: AppContext = ctx.obj
131
+ out = actx.output
132
+ client = actx.client
133
+ org = client.organization
134
+
135
+ results: dict[str, dict] = {}
136
+
137
+ # Fetch all billing endpoints
138
+ for endpoint_name, path_suffix in [
139
+ ("actions", "actions"),
140
+ ("storage", "shared-storage"),
141
+ ("packages", "packages"),
142
+ ]:
143
+ try:
144
+ data = client.get(f"/orgs/{org}/settings/billing/{path_suffix}")
145
+ except Exception: # noqa: BLE001
146
+ try:
147
+ data = client.get(f"/users/{org}/settings/billing/{path_suffix}")
148
+ except Exception: # noqa: BLE001
149
+ data = {}
150
+ results[endpoint_name] = data
151
+
152
+ if out.json_mode:
153
+ out.raw_json(results)
154
+ return
155
+
156
+ actions_data = results.get("actions", {})
157
+ storage_data = results.get("storage", {})
158
+ packages_data = results.get("packages", {})
159
+
160
+ sections = [
161
+ (
162
+ "Actions",
163
+ [
164
+ ("Minutes Used", f"{actions_data.get('total_minutes_used', 0)}"),
165
+ ("Minutes Included", f"{actions_data.get('included_minutes', 0)}"),
166
+ ],
167
+ ),
168
+ (
169
+ "Storage",
170
+ [
171
+ ("Estimated (GB)", f"{storage_data.get('estimated_storage_for_month', 0):.2f}"),
172
+ ],
173
+ ),
174
+ (
175
+ "Packages",
176
+ [
177
+ ("Bandwidth Used (GB)", f"{packages_data.get('total_gigabytes_bandwidth_used', 0):.2f}"),
178
+ ("Bandwidth Included (GB)", f"{packages_data.get('included_gigabytes_bandwidth', 0)}"),
179
+ ],
180
+ ),
181
+ ]
182
+ out.detail("Billing Overview", sections)
@@ -0,0 +1,271 @@
1
+ """CI/CD monitoring 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
+ from kctl_github.core.client import gh_run
12
+
13
+ app = typer.Typer(help="CI/CD monitoring across kodemeio-* repositories.")
14
+
15
+
16
+ @app.command()
17
+ def status(ctx: typer.Context) -> None:
18
+ """Latest workflow run status across ALL repos (pass/fail/running)."""
19
+ actx: AppContext = ctx.obj
20
+ out = actx.output
21
+ client = actx.client
22
+
23
+ repos = client.get_repos()
24
+ rows = []
25
+
26
+ for repo in sorted(repos, key=lambda x: x["name"]):
27
+ name = repo["name"]
28
+ runs = client.get(
29
+ f"/repos/{client.organization}/{name}/actions/runs",
30
+ params={"per_page": 1},
31
+ )
32
+ workflow_runs = runs.get("workflow_runs", [])
33
+ if workflow_runs:
34
+ run_data = workflow_runs[0]
35
+ conclusion = run_data.get("conclusion") or run_data.get("status", "unknown")
36
+ workflow = run_data.get("name", "")
37
+ created = run_data.get("created_at", "")[:10]
38
+ rows.append([name, workflow, conclusion, created])
39
+ else:
40
+ rows.append([name, "-", "no runs", ""])
41
+
42
+ if out.json_mode:
43
+ out.raw_json(
44
+ [
45
+ {
46
+ "repo": r[0],
47
+ "workflow": r[1],
48
+ "conclusion": r[2],
49
+ "date": r[3],
50
+ }
51
+ for r in rows
52
+ ]
53
+ )
54
+ return
55
+
56
+ out.table(
57
+ "CI Status (latest run per repo)",
58
+ [("Repo", "cyan"), ("Workflow", ""), ("Status", "green"), ("Date", "yellow")],
59
+ rows,
60
+ )
61
+
62
+
63
+ @app.command()
64
+ def show(
65
+ ctx: typer.Context,
66
+ repo: Annotated[str, typer.Argument(help="Repository name")],
67
+ limit: Annotated[int, typer.Option("--limit", "-n", help="Number of runs")] = 10,
68
+ ) -> None:
69
+ """Show workflow runs for a specific repo."""
70
+ actx: AppContext = ctx.obj
71
+ out = actx.output
72
+ client = actx.client
73
+
74
+ runs = client.get(
75
+ f"/repos/{client.organization}/{repo}/actions/runs",
76
+ params={"per_page": limit},
77
+ )
78
+ workflow_runs = runs.get("workflow_runs", [])
79
+
80
+ if out.json_mode:
81
+ out.raw_json(
82
+ [
83
+ {
84
+ "id": r["id"],
85
+ "name": r.get("name", ""),
86
+ "status": r.get("status"),
87
+ "conclusion": r.get("conclusion"),
88
+ "branch": r.get("head_branch"),
89
+ "created_at": r.get("created_at"),
90
+ "run_number": r.get("run_number"),
91
+ }
92
+ for r in workflow_runs
93
+ ]
94
+ )
95
+ return
96
+
97
+ rows = []
98
+ for r in workflow_runs:
99
+ conclusion = r.get("conclusion") or r.get("status", "unknown")
100
+ rows.append(
101
+ [
102
+ str(r.get("run_number", "")),
103
+ r.get("name", ""),
104
+ r.get("head_branch", ""),
105
+ conclusion,
106
+ r.get("created_at", "")[:10],
107
+ ]
108
+ )
109
+
110
+ out.table(
111
+ f"Workflow Runs: {repo}",
112
+ [("Run #", ""), ("Workflow", "cyan"), ("Branch", ""), ("Status", "green"), ("Date", "yellow")],
113
+ rows,
114
+ )
115
+
116
+
117
+ @app.command()
118
+ def stats(
119
+ ctx: typer.Context,
120
+ period: Annotated[str, typer.Option("--period", help="Time period (e.g., 7d, 30d)")] = "7d",
121
+ ) -> None:
122
+ """CI statistics: success rate, avg duration, failure trends."""
123
+ actx: AppContext = ctx.obj
124
+ out = actx.output
125
+ client = actx.client
126
+
127
+ # Parse period
128
+ days = int(period.rstrip("d"))
129
+ since = datetime.now(UTC).replace(hour=0, minute=0, second=0)
130
+ since = since - timedelta(days=days)
131
+ since_str = since.strftime("%Y-%m-%dT%H:%M:%SZ")
132
+
133
+ repos = client.get_repos()
134
+ total_runs = 0
135
+ total_success = 0
136
+ total_failure = 0
137
+ repo_stats: list[dict[str, str | int]] = []
138
+
139
+ for repo in repos:
140
+ name = repo["name"]
141
+ runs = client.get(
142
+ f"/repos/{client.organization}/{name}/actions/runs",
143
+ params={"per_page": 100, "created": f">={since_str}"},
144
+ )
145
+ workflow_runs = runs.get("workflow_runs", [])
146
+ count = len(workflow_runs)
147
+ success = sum(1 for r in workflow_runs if r.get("conclusion") == "success")
148
+ failure = sum(1 for r in workflow_runs if r.get("conclusion") == "failure")
149
+
150
+ total_runs += count
151
+ total_success += success
152
+ total_failure += failure
153
+
154
+ if count > 0:
155
+ repo_stats.append(
156
+ {
157
+ "repo": name,
158
+ "runs": count,
159
+ "success": success,
160
+ "failure": failure,
161
+ "success_rate": f"{(success / count * 100):.0f}%",
162
+ }
163
+ )
164
+
165
+ if out.json_mode:
166
+ out.raw_json(
167
+ {
168
+ "period_days": days,
169
+ "total_runs": total_runs,
170
+ "total_success": total_success,
171
+ "total_failure": total_failure,
172
+ "success_rate": f"{(total_success / total_runs * 100):.0f}%" if total_runs else "N/A",
173
+ "repos": repo_stats,
174
+ }
175
+ )
176
+ return
177
+
178
+ # Summary
179
+ rate = f"{(total_success / total_runs * 100):.0f}%" if total_runs else "N/A"
180
+ out.success(f"CI Stats (last {days} days): {total_runs} runs, {rate} success rate")
181
+
182
+ rows = [
183
+ [str(s["repo"]), str(s["runs"]), str(s["success"]), str(s["failure"]), str(s["success_rate"])]
184
+ for s in sorted(repo_stats, key=lambda x: int(x["runs"]), reverse=True)
185
+ ]
186
+ out.table(
187
+ "Per-Repo CI Stats",
188
+ [("Repo", "cyan"), ("Runs", ""), ("Pass", "green"), ("Fail", "red"), ("Rate", "yellow")],
189
+ rows,
190
+ )
191
+
192
+
193
+ @app.command()
194
+ def rerun(
195
+ ctx: typer.Context,
196
+ repo: Annotated[str, typer.Argument(help="Repository name")],
197
+ workflow: Annotated[str | None, typer.Option("--workflow", "-w", help="Workflow name filter")] = None,
198
+ ) -> None:
199
+ """Re-trigger the latest failed workflow run."""
200
+ actx: AppContext = ctx.obj
201
+ out = actx.output
202
+ client = actx.client
203
+ owner = client.organization
204
+
205
+ runs = client.get(
206
+ f"/repos/{owner}/{repo}/actions/runs",
207
+ params={"status": "failure", "per_page": 5},
208
+ )
209
+ workflow_runs = runs.get("workflow_runs", [])
210
+
211
+ if workflow and workflow_runs:
212
+ workflow_runs = [r for r in workflow_runs if workflow.lower() in r.get("name", "").lower()]
213
+
214
+ if not workflow_runs:
215
+ out.warn("No failed workflow runs found")
216
+ return
217
+
218
+ run_data = workflow_runs[0]
219
+ run_id = run_data["id"]
220
+
221
+ # Use gh CLI for rerun (simpler auth)
222
+ gh_run(["run", "rerun", str(run_id), "--repo", f"{owner}/{repo}", "--failed"])
223
+ out.success(f"Re-triggered run #{run_data.get('run_number')} ({run_data.get('name')}) in {repo}")
224
+
225
+
226
+ @app.command("bulk-status")
227
+ def bulk_status(ctx: typer.Context) -> None:
228
+ """Table of all repos x workflows with pass/fail matrix."""
229
+ actx: AppContext = ctx.obj
230
+ out = actx.output
231
+ client = actx.client
232
+
233
+ repos = client.get_repos()
234
+ all_workflows: set[str] = set()
235
+ repo_workflow_status: dict[str, dict[str, str]] = {}
236
+
237
+ for repo in sorted(repos, key=lambda x: x["name"]):
238
+ name = repo["name"]
239
+ runs = client.get(
240
+ f"/repos/{client.organization}/{name}/actions/runs",
241
+ params={"per_page": 20},
242
+ )
243
+ workflow_runs = runs.get("workflow_runs", [])
244
+
245
+ seen: dict[str, str] = {}
246
+ for r in workflow_runs:
247
+ wf_name = r.get("name", "unknown")
248
+ if wf_name not in seen:
249
+ conclusion = r.get("conclusion") or r.get("status", "?")
250
+ seen[wf_name] = conclusion
251
+ all_workflows.add(wf_name)
252
+
253
+ repo_workflow_status[name] = seen
254
+
255
+ sorted_workflows = sorted(all_workflows)
256
+
257
+ if out.json_mode:
258
+ out.raw_json(repo_workflow_status)
259
+ return
260
+
261
+ columns: list[tuple[str, str]] = [("Repo", "cyan")]
262
+ columns.extend((wf, "") for wf in sorted_workflows)
263
+
264
+ rows = []
265
+ for repo_name in sorted(repo_workflow_status.keys()):
266
+ row = [repo_name]
267
+ for wf in sorted_workflows:
268
+ row.append(repo_workflow_status[repo_name].get(wf, "-"))
269
+ rows.append(row)
270
+
271
+ out.table("CI Bulk Status", columns, rows)