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,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}/")
|