11import json
2- import subprocess
3- import sys
42import 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
26145def 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
43163def 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
71188def 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
106207if __name__ == "__main__" :
107208 main ()
0 commit comments