Skip to content

Commit 6f9171c

Browse files
committed
refactor(ci): replace gh CLI with direct GitHub API in label sync
- Rewrite sync_labels.py to use urllib for GitHub REST/GraphQL calls - Remove subprocess dependency on gh CLI - Simplify label usage checks to boolean queries - Update org-labels.json to use emoji shortcodes instead of Unicode - Add .DS_Store to .gitignore Generated-by: GitHub Copilot <copilot@github.com> Signed-off-by: Ashley Childress <6563688+anchildress1@users.noreply.github.com>
1 parent 39ba4b5 commit 6f9171c

3 files changed

Lines changed: 174 additions & 72 deletions

File tree

.github/org-labels.json

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,51 @@
11
[
22
{
3-
"name": " Clue Me In",
3+
"name": ":question: Clue Me In",
44
"color": "1D4ED8",
55
"description": "Explain this to me like I haven't been staring at it for 3 hours."
66
},
77
{
8-
"name": "🌪️ Déjà Vu",
8+
"name": ":tornado: Déjà Vu",
99
"color": "4C6EF5",
1010
"description": "We've seen this before. Link it, close it, keep moving."
1111
},
1212
{
13-
"name": "💎 Cracked Gem",
13+
"name": ":gem: Cracked Gem",
1414
"color": "A13A5B",
1515
"description": "Something's broken again and it's probably important."
1616
},
1717
{
18-
"name": "📂 Case File",
18+
"name": ":open_file_folder: Case File",
1919
"color": "0E7490",
2020
"description": "Forensics, repro steps, logs, and other missing clues."
2121
},
2222
{
23-
"name": " Divine Intervention",
23+
"name": ":sparkles: Divine Intervention",
2424
"color": "7C3AED",
2525
"description": "Feature requests, wish lists, and unlikely code miracles."
2626
},
2727
{
28-
"name": "🚦 Red Light Green Light",
28+
"name": ":vertical_traffic_light: Red Light Green Light",
2929
"color": "34A853",
3030
"description": "Low risk, n00b-safe, and probably fine to merge."
3131
},
3232
{
33-
"name": "🚫 Nope Not Happening",
33+
"name": ":no_entry_sign: Nope Not Happening",
3434
"color": "374151",
3535
"description": "Won't fix. Not now, not later, not ever."
3636
},
3737
{
38-
"name": "🤷‍♀️ Invalidated",
38+
"name": ":woman_shrugging: Invalidated",
3939
"color": "EDC531",
4040
"description": "Not a bug. Misread spec, haunted cache, or user gremlin."
4141
},
4242
{
43-
"name": "🔮 Spirit Guide",
43+
"name": ":crystal_ball: Spirit Guide",
4444
"color": "5B21B6",
4545
"description": "Help from maintainers, mentors, or whoever knows this mess best."
4646
},
4747
{
48-
"name": "🤖 AutoSummon",
48+
"name": ":robot: AutoSummon",
4949
"color": "6D28D9",
5050
"description": "Bot-summoned chores: releases, bumps, and other scripted nonsense."
5151
}

.github/scripts/sync_labels.py

Lines changed: 163 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,150 @@
11
import json
2-
import subprocess
3-
import sys
42
import os
3+
import sys
4+
import urllib.error
5+
import urllib.parse
6+
import urllib.request
7+
8+
9+
API = "https://api.github.com"
10+
GRAPHQL = "https://api.github.com/graphql"
11+
12+
13+
def token() -> str:
14+
t = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
15+
if not t:
16+
print("Missing GH_TOKEN (or GITHUB_TOKEN).", file=sys.stderr)
17+
sys.exit(2)
18+
return t
19+
20+
21+
def api(method: str, url: str, t: str, *, params=None, body=None):
22+
if params:
23+
url = f"{url}?{urllib.parse.urlencode(params)}"
24+
data = None if body is None else json.dumps(body).encode("utf-8")
25+
req = urllib.request.Request(
26+
url,
27+
data=data,
28+
method=method,
29+
headers={
30+
"Authorization": f"Bearer {t}",
31+
"Accept": "application/vnd.github+json",
32+
"User-Agent": "admin-things",
33+
},
34+
)
35+
try:
36+
with urllib.request.urlopen(req, timeout=30) as resp:
37+
raw = resp.read().decode("utf-8")
38+
return None if not raw else json.loads(raw)
39+
except urllib.error.HTTPError as e:
40+
raw = e.read().decode("utf-8", errors="replace")
41+
print(f"GitHub API error {e.code} {method} {url}: {raw[:2000]}", file=sys.stderr)
42+
return None
43+
except Exception as e:
44+
print(f"GitHub API failed {method} {url}: {e}", file=sys.stderr)
45+
return None
46+
547

6-
def run_command(args):
7-
result = subprocess.run(args, capture_output=True, text=True)
8-
if result.returncode != 0:
9-
print(f"Error running {' '.join(args)}: {result.stderr}", file=sys.stderr)
48+
def paged(path: str, t: str, *, params=None):
49+
out: list[dict] = []
50+
page = 1
51+
while True:
52+
p = dict(params or {})
53+
p["per_page"] = 100
54+
p["page"] = page
55+
batch = api("GET", f"{API}{path}", t, params=p)
56+
if batch is None:
57+
return None
58+
if not batch:
59+
return out
60+
if not isinstance(batch, list):
61+
print(f"Expected list from {path}", file=sys.stderr)
62+
return None
63+
out.extend(batch)
64+
page += 1
65+
66+
67+
def has_issues_or_prs(repo: str, label: str, t: str) -> bool | None:
68+
data = api(
69+
"GET",
70+
f"{API}/repos/{repo}/issues",
71+
t,
72+
params={"state": "all", "labels": label, "per_page": 1},
73+
)
74+
if data is None:
1075
return None
11-
return result.stdout
76+
if not isinstance(data, list):
77+
return None
78+
return len(data) > 0
79+
80+
81+
def has_discussions(repo: str, label: str, t: str) -> bool | None:
82+
q = f'repo:{repo} label:"{label}"'
83+
query = """query($q: String!) {
84+
search(query: $q, type: DISCUSSION, first: 1) { discussionCount }
85+
}
86+
"""
87+
data = api(
88+
"POST",
89+
GRAPHQL,
90+
t,
91+
body={"query": query, "variables": {"q": q}},
92+
)
93+
if data is None or data.get("errors"):
94+
return None
95+
try:
96+
return int(data["data"]["search"]["discussionCount"]) > 0
97+
except Exception:
98+
return None
99+
12100

13-
def get_repos(org):
14-
output = run_command(["gh", "repo", "list", org, "--limit", "1000", "--json", "nameWithOwner,isArchived"])
15-
if not output:
101+
def list_repos(org: str, t: str):
102+
repos = paged(f"/orgs/{org}/repos", t, params={"type": "all"})
103+
if repos is None:
16104
return []
17-
repos = json.loads(output)
18-
return [r["nameWithOwner"] for r in repos if not r["isArchived"]]
105+
return [r["full_name"] for r in repos if not r.get("archived")]
19106

20-
def get_repo_labels(repo):
21-
output = run_command(["gh", "label", "list", "--repo", repo, "--json", "name,description,color"])
22-
if not output:
107+
108+
def list_labels(repo: str, t: str):
109+
labels = paged(f"/repos/{repo}/labels", t)
110+
if labels is None:
23111
return []
24-
return json.loads(output)
112+
out = []
113+
for l in labels:
114+
name = l.get("name")
115+
color = l.get("color")
116+
if not name or not color:
117+
continue
118+
out.append({"name": name, "color": color, "description": l.get("description") or ""})
119+
return out
120+
121+
122+
def label_create(repo: str, name: str, color: str, description: str, t: str):
123+
api(
124+
"POST",
125+
f"{API}/repos/{repo}/labels",
126+
t,
127+
body={"name": name, "color": color, "description": description},
128+
)
129+
130+
131+
def label_update(repo: str, name: str, color: str, description: str, t: str):
132+
enc = urllib.parse.quote(name, safe="")
133+
api(
134+
"PATCH",
135+
f"{API}/repos/{repo}/labels/{enc}",
136+
t,
137+
body={"color": color, "description": description},
138+
)
139+
140+
141+
def label_delete(repo: str, name: str, t: str):
142+
enc = urllib.parse.quote(name, safe="")
143+
api("DELETE", f"{API}/repos/{repo}/labels/{enc}", t)
25144

26145
def sync_labels(repo, target_labels):
27-
existing_labels = {l["name"]: l for l in get_repo_labels(repo)}
146+
t = token()
147+
existing_labels = {l["name"]: l for l in list_labels(repo, t)}
28148

29149
for target in target_labels:
30150
name = target["name"]
@@ -35,73 +155,54 @@ def sync_labels(repo, target_labels):
35155
existing = existing_labels[name]
36156
if existing["color"].lower() != color.lower() or existing["description"] != description:
37157
print(f"Updating label '{name}' in {repo}")
38-
run_command(["gh", "label", "edit", name, "--repo", repo, "--color", color, "--description", description])
158+
label_update(repo, name, color, description, t)
39159
else:
40160
print(f"Creating label '{name}' in {repo}")
41-
run_command(["gh", "label", "create", name, "--repo", repo, "--color", color, "--description", description])
161+
label_create(repo, name, color, description, t)
42162

43163
def cleanup_labels(repo, target_label_names):
44-
existing_labels = get_repo_labels(repo)
164+
t = token()
165+
existing_labels = list_labels(repo, t)
45166
extra_labels = [l["name"] for l in existing_labels if l["name"] not in target_label_names]
46167

47-
usage_report = []
48-
49168
for label in extra_labels:
50-
# Check issues and PRs (gh issue list covers both if not specified, but let's be explicit)
51-
issues = json.loads(run_command(["gh", "issue", "list", "--repo", repo, "--label", label, "--json", "number,url,title"]) or "[]")
52-
prs = json.loads(run_command(["gh", "pr", "list", "--repo", repo, "--label", label, "--json", "number,url,title"]) or "[]")
53-
54-
# Discussions might fail if not enabled
55-
discussions_output = run_command(["gh", "discussion", "list", "--repo", repo, "--label", label, "--json", "number,url,title"])
56-
discussions = json.loads(discussions_output) if discussions_output else []
57-
58-
if not issues and not prs and not discussions:
169+
used_in_issues_or_prs = has_issues_or_prs(repo, label, t)
170+
used_in_discussions = has_discussions(repo, label, t)
171+
172+
# Fail safe: if any usage-check failed, do not delete.
173+
checks_failed = used_in_issues_or_prs is None or used_in_discussions is None
174+
if checks_failed:
175+
print(
176+
f"Skipping deletion check for label '{label}' in {repo}: "
177+
"one or more usage checks failed",
178+
file=sys.stderr,
179+
)
180+
continue
181+
182+
if not used_in_issues_or_prs and not used_in_discussions:
59183
print(f"Deleting unused label '{label}' from {repo}")
60-
run_command(["gh", "label", "delete", label, "--repo", repo, "--yes"])
184+
label_delete(repo, label, t)
61185
else:
62-
for item in issues:
63-
usage_report.append({"label": label, "repo": repo, "type": "Issue", "number": item["number"], "url": item["url"], "title": item["title"]})
64-
for item in prs:
65-
usage_report.append({"label": label, "repo": repo, "type": "PR", "number": item["number"], "url": item["url"], "title": item["title"]})
66-
for item in discussions:
67-
usage_report.append({"label": label, "repo": repo, "type": "Discussion", "number": item["number"], "url": item["url"], "title": item["title"]})
68-
69-
return usage_report
186+
print(f"Keeping label '{label}' in {repo}: in use")
70187

71188
def main():
72189
org = "ChecKMarKDevTools"
73190
labels_file = ".github/org-labels.json"
74191

192+
t = token()
193+
75194
with open(labels_file, "r") as f:
76195
target_labels = json.load(f)
77196

78197
target_label_names = [l["name"] for l in target_labels]
79-
repos = get_repos(org)
80-
81-
all_usage = []
198+
repos = list_repos(org, t)
82199

83200
for repo in repos:
84201
print(f"Processing {repo}...")
85202
sync_labels(repo, target_labels)
86-
usage = cleanup_labels(repo, target_label_names)
87-
all_usage.extend(usage)
88-
89-
if all_usage:
90-
# Group by repo for the summary
91-
all_usage.sort(key=lambda x: (x["repo"], x["label"]))
92-
current_repo = ""
93-
for item in all_usage:
94-
if item["repo"] != current_repo:
95-
if current_repo:
96-
print("::endgroup::")
97-
print(f"::group::Extra labels in {item['repo']}")
98-
current_repo = item["repo"]
99-
print(f"- Label '{item['label']}' is in use:")
100-
print(f" - {item['type']} [#{item['number']}]({item['url']}): {item['title']}")
101-
if current_repo:
102-
print("::endgroup::")
103-
else:
104-
print("No extra labels in use.")
203+
cleanup_labels(repo, target_label_names)
204+
205+
print("Done.")
105206

106207
if __name__ == "__main__":
107208
main()

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ __pycache__/
33
*.pyc
44
*.pyo
55
*.pyd
6+
.DS_Store

0 commit comments

Comments
 (0)