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
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Config profile management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from kctl_github.core.callbacks import AppContext
|
|
10
|
+
from kctl_github.core.config import (
|
|
11
|
+
SERVICE_KEY,
|
|
12
|
+
ServiceConfig,
|
|
13
|
+
get_all_services_in_profile,
|
|
14
|
+
get_profile_names,
|
|
15
|
+
get_service_config,
|
|
16
|
+
remove_profile,
|
|
17
|
+
resolve_active_profile_name,
|
|
18
|
+
set_default_profile,
|
|
19
|
+
set_service_config,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(help="Profile and configuration management.")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command()
|
|
26
|
+
def init(ctx: typer.Context) -> None:
|
|
27
|
+
"""Interactive config setup."""
|
|
28
|
+
actx: AppContext = ctx.obj
|
|
29
|
+
out = actx.output
|
|
30
|
+
profile_name = typer.prompt("Profile name", default="default")
|
|
31
|
+
token = typer.prompt("GitHub token (PAT)", default="", hide_input=True)
|
|
32
|
+
organization = typer.prompt("GitHub organization/user", default="tgunawandev")
|
|
33
|
+
repo_prefix = typer.prompt("Repository prefix filter", default="kodemeio-")
|
|
34
|
+
svc = ServiceConfig(token=token, organization=organization, repo_prefix=repo_prefix)
|
|
35
|
+
set_service_config(profile_name, svc)
|
|
36
|
+
set_default_profile(profile_name)
|
|
37
|
+
out.success(f"Config saved to profile '{profile_name}'")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.command()
|
|
41
|
+
def add(
|
|
42
|
+
ctx: typer.Context,
|
|
43
|
+
name: Annotated[str, typer.Argument(help="Profile name")],
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Add a new config profile."""
|
|
46
|
+
actx: AppContext = ctx.obj
|
|
47
|
+
out = actx.output
|
|
48
|
+
token = typer.prompt("GitHub token (PAT)", default="", hide_input=True)
|
|
49
|
+
organization = typer.prompt("GitHub organization/user", default="tgunawandev")
|
|
50
|
+
repo_prefix = typer.prompt("Repository prefix filter", default="kodemeio-")
|
|
51
|
+
svc = ServiceConfig(token=token, organization=organization, repo_prefix=repo_prefix)
|
|
52
|
+
set_service_config(name, svc)
|
|
53
|
+
out.success(f"Profile '{name}' added")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.command()
|
|
57
|
+
def use(
|
|
58
|
+
ctx: typer.Context,
|
|
59
|
+
name: Annotated[str, typer.Argument(help="Profile name to activate")],
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Switch active config profile."""
|
|
62
|
+
actx: AppContext = ctx.obj
|
|
63
|
+
out = actx.output
|
|
64
|
+
available = get_profile_names()
|
|
65
|
+
if name not in available:
|
|
66
|
+
out.error(f"Profile '{name}' not found. Available: {', '.join(available)}")
|
|
67
|
+
raise typer.Exit(1)
|
|
68
|
+
set_default_profile(name)
|
|
69
|
+
out.success(f"Switched to profile '{name}'")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command()
|
|
73
|
+
def show(ctx: typer.Context) -> None:
|
|
74
|
+
"""Show current configuration."""
|
|
75
|
+
actx: AppContext = ctx.obj
|
|
76
|
+
out = actx.output
|
|
77
|
+
active = resolve_active_profile_name(actx.profile)
|
|
78
|
+
profiles = get_profile_names()
|
|
79
|
+
if out.json_mode:
|
|
80
|
+
out.raw_json({"active_profile": active, "profiles": {n: get_all_services_in_profile(n) for n in profiles}})
|
|
81
|
+
return
|
|
82
|
+
out.header("Configuration")
|
|
83
|
+
out.kv("Active profile", active)
|
|
84
|
+
for name in profiles:
|
|
85
|
+
marker = " (active)" if name == active else ""
|
|
86
|
+
out.header(f"Profile: {name}{marker}")
|
|
87
|
+
services = get_all_services_in_profile(name)
|
|
88
|
+
for svc_name, svc_data in services.items():
|
|
89
|
+
out.text(f" [bold]{svc_name}:[/bold]")
|
|
90
|
+
if isinstance(svc_data, dict):
|
|
91
|
+
for k, v in svc_data.items():
|
|
92
|
+
out.kv(f" {k}", str(v))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command()
|
|
96
|
+
def validate(ctx: typer.Context) -> None:
|
|
97
|
+
"""Validate current config completeness."""
|
|
98
|
+
actx: AppContext = ctx.obj
|
|
99
|
+
out = actx.output
|
|
100
|
+
active = resolve_active_profile_name(actx.profile)
|
|
101
|
+
svc = get_service_config(active)
|
|
102
|
+
issues: list[str] = []
|
|
103
|
+
if not svc.token:
|
|
104
|
+
issues.append("token is not set")
|
|
105
|
+
if not svc.organization:
|
|
106
|
+
issues.append("organization is not set")
|
|
107
|
+
if out.json_mode:
|
|
108
|
+
out.raw_json({"profile": active, "valid": len(issues) == 0, "issues": issues})
|
|
109
|
+
return
|
|
110
|
+
if issues:
|
|
111
|
+
out.error(f"Profile '{active}' has {len(issues)} issue(s):")
|
|
112
|
+
for issue in issues:
|
|
113
|
+
out.text(f" - {issue}")
|
|
114
|
+
raise typer.Exit(1)
|
|
115
|
+
out.success(f"Profile '{active}' is valid")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@app.command()
|
|
119
|
+
def remove(
|
|
120
|
+
ctx: typer.Context,
|
|
121
|
+
name: Annotated[str, typer.Argument(help="Profile name to remove")],
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Remove a config profile."""
|
|
124
|
+
actx: AppContext = ctx.obj
|
|
125
|
+
out = actx.output
|
|
126
|
+
available = get_profile_names()
|
|
127
|
+
if name not in available:
|
|
128
|
+
out.error(f"Profile '{name}' not found")
|
|
129
|
+
raise typer.Exit(1)
|
|
130
|
+
remove_profile(name)
|
|
131
|
+
out.success(f"Profile '{name}' removed")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@app.command("set")
|
|
135
|
+
def set_(
|
|
136
|
+
ctx: typer.Context,
|
|
137
|
+
key: Annotated[str, typer.Argument(help="Config key")],
|
|
138
|
+
value: Annotated[str, typer.Argument(help="Config value")],
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Set a single config value."""
|
|
141
|
+
actx: AppContext = ctx.obj
|
|
142
|
+
out = actx.output
|
|
143
|
+
active = resolve_active_profile_name(actx.profile)
|
|
144
|
+
svc = get_service_config(active)
|
|
145
|
+
if key not in ServiceConfig.model_fields:
|
|
146
|
+
out.error(f"Unknown key '{key}'. Valid: {', '.join(ServiceConfig.model_fields)}")
|
|
147
|
+
raise typer.Exit(1)
|
|
148
|
+
data = svc.model_dump()
|
|
149
|
+
data[key] = value
|
|
150
|
+
set_service_config(active, ServiceConfig(**data))
|
|
151
|
+
out.success(f"Set {key}={value} in profile '{active}'")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@app.command()
|
|
155
|
+
def profiles(ctx: typer.Context) -> None:
|
|
156
|
+
"""List all config profiles."""
|
|
157
|
+
actx: AppContext = ctx.obj
|
|
158
|
+
out = actx.output
|
|
159
|
+
active = resolve_active_profile_name(actx.profile)
|
|
160
|
+
names = get_profile_names()
|
|
161
|
+
if out.json_mode:
|
|
162
|
+
out.raw_json({"profiles": names, "active": active})
|
|
163
|
+
return
|
|
164
|
+
rows = [[name, "active" if name == active else ""] for name in names]
|
|
165
|
+
out.table("Profiles", [("Name", "cyan"), ("Status", "green")], rows)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@app.command()
|
|
169
|
+
def current(ctx: typer.Context) -> None:
|
|
170
|
+
"""Show active profile and resolved context."""
|
|
171
|
+
actx: AppContext = ctx.obj
|
|
172
|
+
out = actx.output
|
|
173
|
+
active = resolve_active_profile_name(actx.profile)
|
|
174
|
+
svc = get_service_config(active)
|
|
175
|
+
if out.json_mode:
|
|
176
|
+
out.raw_json({"profile": active, **svc.model_dump()})
|
|
177
|
+
return
|
|
178
|
+
fields = [(k, str(v) or "(not set)") for k, v in svc.model_dump().items()]
|
|
179
|
+
sections = [("Active Profile", [("Name", active)] + fields)]
|
|
180
|
+
out.detail("Current Config", sections)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.command()
|
|
184
|
+
def test(ctx: typer.Context) -> None:
|
|
185
|
+
"""Test API connection with current configuration."""
|
|
186
|
+
actx: AppContext = ctx.obj
|
|
187
|
+
out = actx.output
|
|
188
|
+
active = resolve_active_profile_name(actx.profile)
|
|
189
|
+
out.info(f"Testing profile '{active}' \u2192 {SERVICE_KEY}")
|
|
190
|
+
try:
|
|
191
|
+
data = actx.client.get("/user")
|
|
192
|
+
login = data.get("login", "unknown")
|
|
193
|
+
out.success(f"Connected \u2014 authenticated as '{login}'")
|
|
194
|
+
except Exception as e:
|
|
195
|
+
out.error(f"Connection failed: {e}")
|
|
196
|
+
raise typer.Exit(1) from e
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Dashboard -- quick overview command."""
|
|
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="Quick overview dashboard.")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.callback(invoke_without_command=True)
|
|
13
|
+
def dashboard(ctx: typer.Context) -> None:
|
|
14
|
+
"""Show repos count, open PRs, failing CI, rate limits summary."""
|
|
15
|
+
if ctx.invoked_subcommand is not None:
|
|
16
|
+
return
|
|
17
|
+
|
|
18
|
+
actx: AppContext = ctx.obj
|
|
19
|
+
out = actx.output
|
|
20
|
+
client = actx.client
|
|
21
|
+
|
|
22
|
+
# Get repos
|
|
23
|
+
repos = client.get_repos()
|
|
24
|
+
repo_count = len(repos)
|
|
25
|
+
|
|
26
|
+
# Count open PRs across repos
|
|
27
|
+
total_open_prs = 0
|
|
28
|
+
failing_ci = 0
|
|
29
|
+
for repo in repos:
|
|
30
|
+
name = repo["name"]
|
|
31
|
+
# Get open PRs
|
|
32
|
+
prs = client.get(
|
|
33
|
+
f"/repos/{client.organization}/{name}/pulls",
|
|
34
|
+
params={"state": "open", "per_page": 100},
|
|
35
|
+
)
|
|
36
|
+
if isinstance(prs, list):
|
|
37
|
+
total_open_prs += len(prs)
|
|
38
|
+
|
|
39
|
+
# Get latest workflow run
|
|
40
|
+
runs = client.get(
|
|
41
|
+
f"/repos/{client.organization}/{name}/actions/runs",
|
|
42
|
+
params={"per_page": 1},
|
|
43
|
+
)
|
|
44
|
+
workflow_runs = runs.get("workflow_runs", [])
|
|
45
|
+
if workflow_runs and workflow_runs[0].get("conclusion") == "failure":
|
|
46
|
+
failing_ci += 1
|
|
47
|
+
|
|
48
|
+
# Rate limits
|
|
49
|
+
rate = client.get("/rate_limit")
|
|
50
|
+
core = rate.get("resources", {}).get("core", {})
|
|
51
|
+
|
|
52
|
+
if out.json_mode:
|
|
53
|
+
out.raw_json(
|
|
54
|
+
{
|
|
55
|
+
"repos": repo_count,
|
|
56
|
+
"open_prs": total_open_prs,
|
|
57
|
+
"failing_ci": failing_ci,
|
|
58
|
+
"rate_limit_remaining": core.get("remaining"),
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
sections = [
|
|
64
|
+
(
|
|
65
|
+
"Repositories",
|
|
66
|
+
[
|
|
67
|
+
("Total kodemeio-* repos", str(repo_count)),
|
|
68
|
+
],
|
|
69
|
+
),
|
|
70
|
+
(
|
|
71
|
+
"Pull Requests",
|
|
72
|
+
[
|
|
73
|
+
("Open PRs (all repos)", str(total_open_prs)),
|
|
74
|
+
],
|
|
75
|
+
),
|
|
76
|
+
(
|
|
77
|
+
"CI/CD",
|
|
78
|
+
[
|
|
79
|
+
("Repos with failing CI", str(failing_ci)),
|
|
80
|
+
],
|
|
81
|
+
),
|
|
82
|
+
(
|
|
83
|
+
"API",
|
|
84
|
+
[
|
|
85
|
+
("Rate limit remaining", f"{core.get('remaining', '?')}/{core.get('limit', '?')}"),
|
|
86
|
+
],
|
|
87
|
+
),
|
|
88
|
+
]
|
|
89
|
+
out.detail("GitHub Dashboard", sections)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Doctor diagnostic checks for kctl-github."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from kctl_github.core.callbacks import AppContext
|
|
10
|
+
from kctl_lib.doctor_base import CheckResult, DoctorCheck, run_doctor
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class APIConnectivityCheck:
|
|
15
|
+
"""Check that the configured GitHub organization is set."""
|
|
16
|
+
|
|
17
|
+
name: str = "API Connectivity"
|
|
18
|
+
|
|
19
|
+
def run(self) -> CheckResult:
|
|
20
|
+
try:
|
|
21
|
+
from kctl_github.core.config import get_service_config, resolve_active_profile_name
|
|
22
|
+
|
|
23
|
+
profile = resolve_active_profile_name()
|
|
24
|
+
cfg = get_service_config(profile)
|
|
25
|
+
org = cfg.organization or ""
|
|
26
|
+
if not org:
|
|
27
|
+
return CheckResult(
|
|
28
|
+
name=self.name,
|
|
29
|
+
status="fail",
|
|
30
|
+
message="No organization configured",
|
|
31
|
+
fix_command="kctl-github config init",
|
|
32
|
+
)
|
|
33
|
+
return CheckResult(name=self.name, status="ok", message=f"Organization: {org}")
|
|
34
|
+
except Exception as e:
|
|
35
|
+
return CheckResult(name=self.name, status="warn", message=str(e))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class AuthCheck:
|
|
40
|
+
"""Check that authentication credentials are configured."""
|
|
41
|
+
|
|
42
|
+
name: str = "Authentication"
|
|
43
|
+
|
|
44
|
+
def run(self) -> CheckResult:
|
|
45
|
+
try:
|
|
46
|
+
from kctl_github.core.config import get_service_config, resolve_active_profile_name
|
|
47
|
+
|
|
48
|
+
profile = resolve_active_profile_name()
|
|
49
|
+
cfg = get_service_config(profile)
|
|
50
|
+
token = cfg.token or ""
|
|
51
|
+
if not token:
|
|
52
|
+
return CheckResult(
|
|
53
|
+
name=self.name,
|
|
54
|
+
status="fail",
|
|
55
|
+
message="No GitHub token configured",
|
|
56
|
+
fix_command="kctl-github config init",
|
|
57
|
+
)
|
|
58
|
+
masked = token[:4] + "****" + token[-4:] if len(token) > 8 else "****"
|
|
59
|
+
return CheckResult(name=self.name, status="ok", message=f"Token configured ({masked})")
|
|
60
|
+
except Exception as e:
|
|
61
|
+
return CheckResult(name=self.name, status="warn", message=str(e))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
app = typer.Typer(help="Run diagnostic checks.", no_args_is_help=False, invoke_without_command=True)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.callback(invoke_without_command=True)
|
|
68
|
+
def doctor(ctx: typer.Context) -> None:
|
|
69
|
+
"""Run all diagnostic checks."""
|
|
70
|
+
if ctx.invoked_subcommand is not None:
|
|
71
|
+
return
|
|
72
|
+
actx: AppContext = ctx.obj
|
|
73
|
+
out = actx.output
|
|
74
|
+
|
|
75
|
+
checks: list[DoctorCheck] = [
|
|
76
|
+
APIConnectivityCheck(),
|
|
77
|
+
AuthCheck(),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
all_passed = run_doctor(checks, out) # type: ignore[arg-type]
|
|
81
|
+
if not all_passed:
|
|
82
|
+
raise typer.Exit(code=1)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Health check 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="API connectivity and rate limits.")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.callback(invoke_without_command=True)
|
|
13
|
+
def health(ctx: typer.Context) -> None:
|
|
14
|
+
"""Check GitHub API reachability, rate limits, and token scopes."""
|
|
15
|
+
if ctx.invoked_subcommand is not None:
|
|
16
|
+
return
|
|
17
|
+
|
|
18
|
+
actx: AppContext = ctx.obj
|
|
19
|
+
out = actx.output
|
|
20
|
+
client = actx.client
|
|
21
|
+
|
|
22
|
+
# Check rate limits
|
|
23
|
+
rate = client.get("/rate_limit")
|
|
24
|
+
core = rate.get("resources", {}).get("core", {})
|
|
25
|
+
search = rate.get("resources", {}).get("search", {})
|
|
26
|
+
|
|
27
|
+
# Get authenticated user info
|
|
28
|
+
user = client.get("/user")
|
|
29
|
+
|
|
30
|
+
if out.json_mode:
|
|
31
|
+
out.raw_json(
|
|
32
|
+
{
|
|
33
|
+
"status": "healthy",
|
|
34
|
+
"user": user.get("login"),
|
|
35
|
+
"rate_limit": {
|
|
36
|
+
"core_remaining": core.get("remaining"),
|
|
37
|
+
"core_limit": core.get("limit"),
|
|
38
|
+
"search_remaining": search.get("remaining"),
|
|
39
|
+
"search_limit": search.get("limit"),
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
out.success("GitHub API is reachable")
|
|
46
|
+
sections = [
|
|
47
|
+
(
|
|
48
|
+
"Connection",
|
|
49
|
+
[
|
|
50
|
+
("User", user.get("login", "unknown")),
|
|
51
|
+
("Name", user.get("name", "")),
|
|
52
|
+
("Plan", user.get("plan", {}).get("name", "unknown")),
|
|
53
|
+
],
|
|
54
|
+
),
|
|
55
|
+
(
|
|
56
|
+
"Rate Limits",
|
|
57
|
+
[
|
|
58
|
+
("Core", f"{core.get('remaining', '?')}/{core.get('limit', '?')}"),
|
|
59
|
+
("Search", f"{search.get('remaining', '?')}/{search.get('limit', '?')}"),
|
|
60
|
+
],
|
|
61
|
+
),
|
|
62
|
+
]
|
|
63
|
+
out.detail("GitHub Health", sections)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Cross-repo label standardization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from kctl_github.core.callbacks import AppContext
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="Cross-repo label management.")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("list")
|
|
15
|
+
def list_labels(
|
|
16
|
+
ctx: typer.Context,
|
|
17
|
+
repo: Annotated[str, typer.Argument(help="Repository name")],
|
|
18
|
+
) -> None:
|
|
19
|
+
"""List labels for a repo."""
|
|
20
|
+
actx: AppContext = ctx.obj
|
|
21
|
+
out = actx.output
|
|
22
|
+
client = actx.client
|
|
23
|
+
owner = client.organization
|
|
24
|
+
|
|
25
|
+
labels = client.get(f"/repos/{owner}/{repo}/labels", params={"per_page": 100})
|
|
26
|
+
|
|
27
|
+
if out.json_mode:
|
|
28
|
+
out.raw_json(
|
|
29
|
+
[
|
|
30
|
+
{
|
|
31
|
+
"name": label["name"],
|
|
32
|
+
"color": label.get("color", ""),
|
|
33
|
+
"description": label.get("description", ""),
|
|
34
|
+
}
|
|
35
|
+
for label in labels
|
|
36
|
+
]
|
|
37
|
+
)
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
rows = [[label["name"], f"#{label.get('color', '')}", label.get("description", "") or ""] for label in labels]
|
|
41
|
+
out.table(
|
|
42
|
+
f"Labels: {repo}",
|
|
43
|
+
[("Name", "cyan"), ("Color", "yellow"), ("Description", "")],
|
|
44
|
+
rows,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.command()
|
|
49
|
+
def sync(
|
|
50
|
+
ctx: typer.Context,
|
|
51
|
+
source: Annotated[str, typer.Option("--source", "-s", help="Source repo to copy labels from")],
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Copy labels from source repo to all other kodemeio-* repos."""
|
|
54
|
+
actx: AppContext = ctx.obj
|
|
55
|
+
out = actx.output
|
|
56
|
+
client = actx.client
|
|
57
|
+
owner = client.organization
|
|
58
|
+
|
|
59
|
+
# Get source labels
|
|
60
|
+
source_labels = client.get(f"/repos/{owner}/{source}/labels", params={"per_page": 100})
|
|
61
|
+
if not isinstance(source_labels, list):
|
|
62
|
+
out.error("Failed to fetch source labels")
|
|
63
|
+
raise typer.Exit(1)
|
|
64
|
+
|
|
65
|
+
repos = client.get_repos()
|
|
66
|
+
target_repos = [r for r in repos if r["name"] != source]
|
|
67
|
+
|
|
68
|
+
for repo in target_repos:
|
|
69
|
+
name = repo["name"]
|
|
70
|
+
existing = client.get(f"/repos/{owner}/{name}/labels", params={"per_page": 100})
|
|
71
|
+
existing_names = {label["name"] for label in existing} if isinstance(existing, list) else set()
|
|
72
|
+
|
|
73
|
+
created = 0
|
|
74
|
+
for label in source_labels:
|
|
75
|
+
if label["name"] not in existing_names:
|
|
76
|
+
try:
|
|
77
|
+
client.post(
|
|
78
|
+
f"/repos/{owner}/{name}/labels",
|
|
79
|
+
json={
|
|
80
|
+
"name": label["name"],
|
|
81
|
+
"color": label.get("color", "000000"),
|
|
82
|
+
"description": label.get("description", ""),
|
|
83
|
+
},
|
|
84
|
+
)
|
|
85
|
+
created += 1
|
|
86
|
+
except Exception: # noqa: BLE001
|
|
87
|
+
pass # Label may already exist with different casing
|
|
88
|
+
|
|
89
|
+
if created > 0:
|
|
90
|
+
out.success(f"{name}: added {created} label(s)")
|
|
91
|
+
else:
|
|
92
|
+
out.info(f"{name}: already in sync")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command()
|
|
96
|
+
def diff(ctx: typer.Context) -> None:
|
|
97
|
+
"""Show label differences across repos."""
|
|
98
|
+
actx: AppContext = ctx.obj
|
|
99
|
+
out = actx.output
|
|
100
|
+
client = actx.client
|
|
101
|
+
owner = client.organization
|
|
102
|
+
|
|
103
|
+
repos = client.get_repos()
|
|
104
|
+
all_labels: set[str] = set()
|
|
105
|
+
repo_labels: dict[str, set[str]] = {}
|
|
106
|
+
|
|
107
|
+
for repo in repos:
|
|
108
|
+
name = repo["name"]
|
|
109
|
+
labels = client.get(f"/repos/{owner}/{name}/labels", params={"per_page": 100})
|
|
110
|
+
label_names = {label["name"] for label in labels} if isinstance(labels, list) else set()
|
|
111
|
+
all_labels.update(label_names)
|
|
112
|
+
repo_labels[name] = label_names
|
|
113
|
+
|
|
114
|
+
sorted_labels = sorted(all_labels)
|
|
115
|
+
|
|
116
|
+
if out.json_mode:
|
|
117
|
+
out.raw_json({repo: sorted(names) for repo, names in repo_labels.items()})
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
columns: list[tuple[str, str]] = [("Label", "cyan")]
|
|
121
|
+
sorted_repos = sorted(repo_labels.keys())
|
|
122
|
+
columns.extend((r.removeprefix("kodemeio-")[:12], "") for r in sorted_repos)
|
|
123
|
+
|
|
124
|
+
rows = []
|
|
125
|
+
for label in sorted_labels:
|
|
126
|
+
row = [label]
|
|
127
|
+
for repo_name in sorted_repos:
|
|
128
|
+
row.append("Y" if label in repo_labels[repo_name] else "-")
|
|
129
|
+
rows.append(row)
|
|
130
|
+
|
|
131
|
+
out.table("Label Diff", columns, rows)
|