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.
- kctl_github/__init__.py +3 -0
- kctl_github/__main__.py +5 -0
- kctl_github/cli.py +133 -0
- kctl_github/commands/__init__.py +0 -0
- kctl_github/commands/billing.py +182 -0
- kctl_github/commands/ci.py +271 -0
- kctl_github/commands/config_cmd.py +196 -0
- kctl_github/commands/dashboard.py +89 -0
- kctl_github/commands/doctor_cmd.py +82 -0
- kctl_github/commands/health.py +63 -0
- kctl_github/commands/labels.py +131 -0
- kctl_github/commands/prs.py +161 -0
- kctl_github/commands/repos.py +179 -0
- kctl_github/commands/secrets.py +132 -0
- kctl_github/commands/skill_cmd.py +76 -0
- kctl_github/commands/stats.py +208 -0
- kctl_github/core/__init__.py +0 -0
- kctl_github/core/callbacks.py +40 -0
- kctl_github/core/client.py +124 -0
- kctl_github/core/config.py +55 -0
- kctl_github/core/exceptions.py +21 -0
- kctl_github/core/plugins.py +13 -0
- kctl_github-0.2.0.dist-info/METADATA +17 -0
- kctl_github-0.2.0.dist-info/RECORD +26 -0
- kctl_github-0.2.0.dist-info/WHEEL +4 -0
- kctl_github-0.2.0.dist-info/entry_points.txt +2 -0
kctl_github/__init__.py
ADDED
kctl_github/__main__.py
ADDED
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)
|