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,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)