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,161 @@
1
+ """Cross-repo pull request management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import UTC, datetime, timedelta
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from kctl_github.core.callbacks import AppContext
12
+ from kctl_github.core.client import gh_run
13
+
14
+ app = typer.Typer(help="Cross-repo PR management.")
15
+
16
+
17
+ @app.command("list")
18
+ def list_prs(ctx: typer.Context) -> None:
19
+ """Open PRs across all kodemeio-* repos."""
20
+ actx: AppContext = ctx.obj
21
+ out = actx.output
22
+ client = actx.client
23
+
24
+ repos = client.get_repos()
25
+ all_prs = []
26
+
27
+ for repo in repos:
28
+ name = repo["name"]
29
+ prs = client.get(
30
+ f"/repos/{client.organization}/{name}/pulls",
31
+ params={"state": "open", "per_page": 100},
32
+ )
33
+ if isinstance(prs, list):
34
+ for pr in prs:
35
+ all_prs.append(
36
+ {
37
+ "repo": name,
38
+ "number": pr["number"],
39
+ "title": pr["title"],
40
+ "author": pr.get("user", {}).get("login", ""),
41
+ "created_at": pr.get("created_at", ""),
42
+ "draft": pr.get("draft", False),
43
+ "labels": [label["name"] for label in pr.get("labels", [])],
44
+ }
45
+ )
46
+
47
+ if out.json_mode:
48
+ out.raw_json(all_prs)
49
+ return
50
+
51
+ rows = []
52
+ for pr in sorted(all_prs, key=lambda x: x["created_at"], reverse=True):
53
+ draft_marker = " (draft)" if pr["draft"] else ""
54
+ rows.append(
55
+ [
56
+ pr["repo"],
57
+ f"#{pr['number']}",
58
+ pr["title"][:60] + draft_marker,
59
+ pr["author"],
60
+ pr["created_at"][:10],
61
+ ]
62
+ )
63
+
64
+ out.table(
65
+ f"Open Pull Requests ({len(rows)} total)",
66
+ [("Repo", "cyan"), ("#", ""), ("Title", ""), ("Author", "yellow"), ("Created", "")],
67
+ rows,
68
+ )
69
+
70
+
71
+ @app.command()
72
+ def show(
73
+ ctx: typer.Context,
74
+ repo: Annotated[str, typer.Argument(help="Repository name")],
75
+ number: Annotated[int, typer.Argument(help="PR number")],
76
+ ) -> None:
77
+ """Show PR details (delegates to gh pr view)."""
78
+ actx: AppContext = ctx.obj
79
+ out = actx.output
80
+ owner = actx.client.organization
81
+
82
+ if out.json_mode:
83
+ result = gh_run(
84
+ [
85
+ "pr",
86
+ "view",
87
+ str(number),
88
+ "--repo",
89
+ f"{owner}/{repo}",
90
+ "--json",
91
+ "title,body,state,author,createdAt,mergeable,reviews,labels",
92
+ ]
93
+ )
94
+ out.raw_json(json.loads(result))
95
+ return
96
+
97
+ result = gh_run(["pr", "view", str(number), "--repo", f"{owner}/{repo}"])
98
+ out.text(result)
99
+
100
+
101
+ @app.command()
102
+ def stale(
103
+ ctx: typer.Context,
104
+ days: Annotated[int, typer.Option("--days", "-d", help="Days without activity")] = 14,
105
+ ) -> None:
106
+ """Find PRs with no activity for N days."""
107
+ actx: AppContext = ctx.obj
108
+ out = actx.output
109
+ client = actx.client
110
+
111
+ cutoff = datetime.now(UTC) - timedelta(days=days)
112
+ repos = client.get_repos()
113
+ stale_prs: list[dict[str, str | int]] = []
114
+
115
+ for repo in repos:
116
+ name = repo["name"]
117
+ prs = client.get(
118
+ f"/repos/{client.organization}/{name}/pulls",
119
+ params={"state": "open", "per_page": 100, "sort": "updated", "direction": "asc"},
120
+ )
121
+ if isinstance(prs, list):
122
+ for pr in prs:
123
+ updated = pr.get("updated_at", "")
124
+ if updated:
125
+ try:
126
+ updated_dt = datetime.fromisoformat(updated.replace("Z", "+00:00"))
127
+ if updated_dt < cutoff:
128
+ stale_prs.append(
129
+ {
130
+ "repo": name,
131
+ "number": pr["number"],
132
+ "title": pr["title"],
133
+ "author": pr.get("user", {}).get("login", ""),
134
+ "updated_at": updated[:10],
135
+ "days_stale": (datetime.now(UTC) - updated_dt).days,
136
+ }
137
+ )
138
+ except (ValueError, TypeError):
139
+ pass
140
+
141
+ if out.json_mode:
142
+ out.raw_json(stale_prs)
143
+ return
144
+
145
+ rows = [
146
+ [
147
+ str(p["repo"]),
148
+ f"#{p['number']}",
149
+ str(p["title"])[:50],
150
+ str(p["author"]),
151
+ str(p["updated_at"]),
152
+ f"{p['days_stale']}d",
153
+ ]
154
+ for p in sorted(stale_prs, key=lambda x: int(x["days_stale"]), reverse=True)
155
+ ]
156
+
157
+ out.table(
158
+ f"Stale PRs (no activity for {days}+ days)",
159
+ [("Repo", "cyan"), ("#", ""), ("Title", ""), ("Author", "yellow"), ("Last Update", ""), ("Stale", "red")],
160
+ rows,
161
+ )
@@ -0,0 +1,179 @@
1
+ """Cross-repo overview 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
+
11
+ app = typer.Typer(help="Cross-repo overview for kodemeio-* repositories.")
12
+
13
+
14
+ @app.command("list")
15
+ def list_repos(ctx: typer.Context) -> None:
16
+ """List all kodemeio-* repos with visibility, default branch, last push."""
17
+ actx: AppContext = ctx.obj
18
+ out = actx.output
19
+ client = actx.client
20
+
21
+ repos = client.get_repos()
22
+
23
+ if out.json_mode:
24
+ out.raw_json(
25
+ [
26
+ {
27
+ "name": r["name"],
28
+ "visibility": "private" if r.get("private") else "public",
29
+ "default_branch": r.get("default_branch", "main"),
30
+ "pushed_at": r.get("pushed_at", ""),
31
+ "description": r.get("description", ""),
32
+ }
33
+ for r in repos
34
+ ]
35
+ )
36
+ return
37
+
38
+ rows = []
39
+ for r in sorted(repos, key=lambda x: x["name"]):
40
+ rows.append(
41
+ [
42
+ r["name"],
43
+ "private" if r.get("private") else "public",
44
+ r.get("default_branch", "main"),
45
+ _format_date(r.get("pushed_at", "")),
46
+ ]
47
+ )
48
+
49
+ out.table(
50
+ f"Repositories ({len(rows)} matching '{client.repo_prefix}*')",
51
+ [("Name", "cyan"), ("Visibility", ""), ("Branch", ""), ("Last Push", "yellow")],
52
+ rows,
53
+ )
54
+
55
+
56
+ @app.command()
57
+ def status(ctx: typer.Context) -> None:
58
+ """Aggregated status: open PRs, failing CI, stale branches per repo."""
59
+ actx: AppContext = ctx.obj
60
+ out = actx.output
61
+ client = actx.client
62
+
63
+ repos = client.get_repos()
64
+ rows = []
65
+
66
+ for repo in sorted(repos, key=lambda x: x["name"]):
67
+ name = repo["name"]
68
+ owner = client.organization
69
+
70
+ # Open PRs count
71
+ prs = client.get(f"/repos/{owner}/{name}/pulls", params={"state": "open", "per_page": 100})
72
+ pr_count = len(prs) if isinstance(prs, list) else 0
73
+
74
+ # Latest CI status
75
+ runs = client.get(f"/repos/{owner}/{name}/actions/runs", params={"per_page": 1})
76
+ workflow_runs = runs.get("workflow_runs", [])
77
+ if workflow_runs:
78
+ ci_status = workflow_runs[0].get("conclusion") or workflow_runs[0].get("status", "unknown")
79
+ else:
80
+ ci_status = "none"
81
+
82
+ # Branch count
83
+ branches = client.get(f"/repos/{owner}/{name}/branches", params={"per_page": 100})
84
+ branch_count = len(branches) if isinstance(branches, list) else 0
85
+
86
+ rows.append([name, str(pr_count), ci_status, str(branch_count)])
87
+
88
+ if out.json_mode:
89
+ out.raw_json(
90
+ [
91
+ {
92
+ "repo": r[0],
93
+ "open_prs": int(r[1]),
94
+ "ci_status": r[2],
95
+ "branches": int(r[3]),
96
+ }
97
+ for r in rows
98
+ ]
99
+ )
100
+ return
101
+
102
+ out.table(
103
+ "Repository Status",
104
+ [("Repo", "cyan"), ("Open PRs", "yellow"), ("CI", "green"), ("Branches", "")],
105
+ rows,
106
+ )
107
+
108
+
109
+ @app.command()
110
+ def show(
111
+ ctx: typer.Context,
112
+ name: Annotated[str, typer.Argument(help="Repository name (e.g., kodemeio-next)")],
113
+ ) -> None:
114
+ """Show single repo details (size, languages, contributors)."""
115
+ actx: AppContext = ctx.obj
116
+ out = actx.output
117
+ client = actx.client
118
+ owner = client.organization
119
+
120
+ repo = client.get_repo(name)
121
+ languages = client.get(f"/repos/{owner}/{name}/languages")
122
+ contributors = client.get(f"/repos/{owner}/{name}/contributors", params={"per_page": 10})
123
+
124
+ if out.json_mode:
125
+ out.raw_json(
126
+ {
127
+ "name": repo["name"],
128
+ "description": repo.get("description", ""),
129
+ "size_kb": repo.get("size", 0),
130
+ "default_branch": repo.get("default_branch"),
131
+ "languages": languages,
132
+ "top_contributors": [
133
+ {"login": c["login"], "contributions": c["contributions"]}
134
+ for c in (contributors if isinstance(contributors, list) else [])[:5]
135
+ ],
136
+ }
137
+ )
138
+ return
139
+
140
+ total_bytes = sum(languages.values()) if isinstance(languages, dict) else 0
141
+ lang_lines = []
142
+ if isinstance(languages, dict):
143
+ for lang, bytes_count in sorted(languages.items(), key=lambda x: x[1], reverse=True)[:5]:
144
+ pct = (bytes_count / total_bytes * 100) if total_bytes else 0
145
+ lang_lines.append((lang, f"{pct:.1f}%"))
146
+
147
+ contrib_lines = []
148
+ if isinstance(contributors, list):
149
+ for c in contributors[:5]:
150
+ contrib_lines.append((c["login"], f"{c['contributions']} commits"))
151
+
152
+ sections = [
153
+ (
154
+ "Repository",
155
+ [
156
+ ("Name", repo["name"]),
157
+ ("Description", repo.get("description") or "(none)"),
158
+ ("Visibility", "private" if repo.get("private") else "public"),
159
+ ("Default Branch", repo.get("default_branch", "main")),
160
+ ("Size", f"{repo.get('size', 0)} KB"),
161
+ ("Open Issues", str(repo.get("open_issues_count", 0))),
162
+ ("Created", _format_date(repo.get("created_at", ""))),
163
+ ("Last Push", _format_date(repo.get("pushed_at", ""))),
164
+ ],
165
+ ),
166
+ ("Languages (top 5)", lang_lines),
167
+ ("Contributors (top 5)", contrib_lines),
168
+ ]
169
+ out.detail(f"Repository: {name}", sections)
170
+
171
+
172
+ def _format_date(iso_date: str) -> str:
173
+ """Format an ISO date string to a shorter readable format."""
174
+ if not iso_date:
175
+ return ""
176
+ try:
177
+ return iso_date[:10]
178
+ except Exception:
179
+ return iso_date
@@ -0,0 +1,132 @@
1
+ """Cross-repo secret management."""
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.client import gh_run
11
+
12
+ app = typer.Typer(help="Cross-repo Actions secret management.")
13
+
14
+
15
+ @app.command("list")
16
+ def list_secrets(
17
+ ctx: typer.Context,
18
+ repo: Annotated[str, typer.Argument(help="Repository name")],
19
+ ) -> None:
20
+ """List Actions secrets for a repo."""
21
+ actx: AppContext = ctx.obj
22
+ out = actx.output
23
+ client = actx.client
24
+ owner = client.organization
25
+
26
+ data = client.get(f"/repos/{owner}/{repo}/actions/secrets")
27
+ secrets = data.get("secrets", [])
28
+
29
+ if out.json_mode:
30
+ out.raw_json([{"name": s["name"], "updated_at": s.get("updated_at", "")} for s in secrets])
31
+ return
32
+
33
+ rows = [[s["name"], s.get("updated_at", "")[:10]] for s in secrets]
34
+ out.table(
35
+ f"Secrets: {repo}",
36
+ [("Name", "cyan"), ("Last Updated", "yellow")],
37
+ rows,
38
+ )
39
+
40
+
41
+ @app.command()
42
+ def audit(ctx: typer.Context) -> None:
43
+ """Check which repos have which secrets (matrix view)."""
44
+ actx: AppContext = ctx.obj
45
+ out = actx.output
46
+ client = actx.client
47
+ owner = client.organization
48
+
49
+ repos = client.get_repos()
50
+ all_secret_names: set[str] = set()
51
+ repo_secrets: dict[str, set[str]] = {}
52
+
53
+ for repo in repos:
54
+ name = repo["name"]
55
+ try:
56
+ data = client.get(f"/repos/{owner}/{name}/actions/secrets")
57
+ secrets = data.get("secrets", [])
58
+ secret_names = {s["name"] for s in secrets}
59
+ all_secret_names.update(secret_names)
60
+ repo_secrets[name] = secret_names
61
+ except Exception: # noqa: BLE001
62
+ repo_secrets[name] = set()
63
+
64
+ sorted_secrets = sorted(all_secret_names)
65
+
66
+ if out.json_mode:
67
+ out.raw_json({repo: [s for s in sorted_secrets if s in secs] for repo, secs in repo_secrets.items()})
68
+ return
69
+
70
+ columns: list[tuple[str, str]] = [("Repo", "cyan")]
71
+ columns.extend((s, "") for s in sorted_secrets)
72
+
73
+ rows = []
74
+ for repo_name in sorted(repo_secrets.keys()):
75
+ row = [repo_name]
76
+ for secret in sorted_secrets:
77
+ row.append("Y" if secret in repo_secrets[repo_name] else "-")
78
+ rows.append(row)
79
+
80
+ out.table("Secrets Audit", columns, rows)
81
+
82
+
83
+ @app.command("set")
84
+ def set_secret(
85
+ ctx: typer.Context,
86
+ name: Annotated[str, typer.Argument(help="Secret name")],
87
+ repos: Annotated[str, typer.Option("--repos", "-r", help="Comma-separated repo names")],
88
+ ) -> None:
89
+ """Set a secret across multiple repos (prompts for value)."""
90
+ actx: AppContext = ctx.obj
91
+ out = actx.output
92
+ owner = actx.client.organization
93
+
94
+ value = typer.prompt(f"Secret value for {name}", hide_input=True)
95
+ repo_list = [r.strip() for r in repos.split(",")]
96
+
97
+ for repo in repo_list:
98
+ try:
99
+ gh_run(["secret", "set", name, "--repo", f"{owner}/{repo}", "--body", value])
100
+ out.success(f"Set {name} in {repo}")
101
+ except Exception as e: # noqa: BLE001
102
+ out.error(f"Failed to set {name} in {repo}: {e}")
103
+
104
+
105
+ @app.command()
106
+ def rotate(
107
+ ctx: typer.Context,
108
+ name: Annotated[str, typer.Argument(help="Secret name to rotate")],
109
+ ) -> None:
110
+ """Update a secret across all repos that have it."""
111
+ actx: AppContext = ctx.obj
112
+ out = actx.output
113
+ client = actx.client
114
+ owner = client.organization
115
+
116
+ value = typer.prompt(f"New value for {name}", hide_input=True)
117
+ repos = client.get_repos()
118
+ updated = 0
119
+
120
+ for repo in repos:
121
+ repo_name = repo["name"]
122
+ try:
123
+ data = client.get(f"/repos/{owner}/{repo_name}/actions/secrets")
124
+ secret_names = {s["name"] for s in data.get("secrets", [])}
125
+ if name in secret_names:
126
+ gh_run(["secret", "set", name, "--repo", f"{owner}/{repo_name}", "--body", value])
127
+ out.success(f"Rotated {name} in {repo_name}")
128
+ updated += 1
129
+ except Exception as e: # noqa: BLE001
130
+ out.error(f"Failed for {repo_name}: {e}")
131
+
132
+ out.info(f"Rotated {name} in {updated} repo(s)")
@@ -0,0 +1,76 @@
1
+ """Skill generation for Claude Code integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from kctl_github.core.callbacks import AppContext
11
+
12
+ app = typer.Typer(help="Claude Code skill management.")
13
+
14
+
15
+ @app.command()
16
+ def generate(
17
+ ctx: typer.Context,
18
+ output: Annotated[str, typer.Option("--output", "-o", help="Output directory")] = "",
19
+ install: Annotated[bool, typer.Option("--install", help="Install to ~/.claude/skills/")] = False,
20
+ check: Annotated[bool, typer.Option("--check", help="Check if SKILL.md is stale (exit 1 if stale)")] = False,
21
+ ) -> None:
22
+ """Auto-generate SKILL.md from CLI command registry.
23
+
24
+ Examples:
25
+ kctl-github skill generate
26
+ kctl-github skill generate --install
27
+ kctl-github skill generate --check
28
+ """
29
+ actx: AppContext = ctx.obj
30
+ out = actx.output
31
+ from kctl_lib.skill_generator import check_stale, generate_skill
32
+
33
+ from kctl_github.cli import app as cli_app
34
+
35
+ skill_name = "github-admin"
36
+ description = "GitHub cross-repo management via kctl-github CLI"
37
+
38
+ # Canonical in-repo skill dir — source of SKILL.extra.md regardless
39
+ # of where output goes. Without this, --install writes to
40
+ # ~/.claude/skills/ and the handwritten runbook vanishes.
41
+ cli_root = Path(__file__).resolve().parents[3]
42
+ source_dir = cli_root / "skills" / skill_name
43
+
44
+ # Determine output directory
45
+ if output:
46
+ output_dir = Path(output)
47
+ elif install:
48
+ output_dir = Path.home() / ".claude" / "skills" / skill_name
49
+ else:
50
+ output_dir = source_dir
51
+
52
+ # Check-only mode
53
+ if check:
54
+ skill_file = output_dir / "SKILL.md"
55
+ is_stale, reason = check_stale(cli_app, skill_file)
56
+ if is_stale:
57
+ out.warn(f"SKILL.md is stale: {reason}")
58
+ out.info("Run: kctl-github skill generate")
59
+ raise typer.Exit(1)
60
+ out.success(f"SKILL.md is up to date: {reason}")
61
+ return
62
+
63
+ # Extra content always lives at the in-repo location.
64
+ extra = source_dir / "SKILL.extra.md"
65
+
66
+ generate_skill(
67
+ cli_app,
68
+ "kctl-github",
69
+ skill_name,
70
+ description,
71
+ output_dir=output_dir,
72
+ extra_file=extra if extra.exists() else None,
73
+ )
74
+ out.success(f"Generated {output_dir / 'SKILL.md'}")
75
+ if install:
76
+ out.success(f"Installed to ~/.claude/skills/{skill_name}/")