1import os
2from collections import defaultdict
3from datetime import datetime, timedelta
4from typing import Optional
5
6from github import Github
7from github.Issue import Issue
8from github.Repository import Repository
9from pytz import timezone
10
11import typer
12from typer import Typer
13
14app: Typer = typer.Typer()
15
16DATETIME_FORMAT: str = "%m/%d/%Y %I:%M %p"
17CORE_LABELS: set[str] = {
18 "defect",
19 "design",
20 "documentation",
21 "enhancement",
22 "panic / crash",
23}
24# A set of labels for adding in labels that we want present in the final
25# report, but that we don't want being defined as a core label, since issues
26# with without core labels are flagged as errors.
27ADDITIONAL_LABELS: set[str] = {
28 "ai",
29 "linux",
30 "vim",
31 "windows",
32}
33IGNORED_LABELS: set[str] = {
34 "ignore top-ranking issues",
35 "meta",
36}
37ISSUES_PER_LABEL: int = 20
38
39
40class IssueData:
41 def __init__(self, issue: Issue) -> None:
42 self.url: str = issue.html_url
43 self.like_count: int = issue._rawData["reactions"]["+1"] # type: ignore [attr-defined]
44 self.creation_datetime: str = issue.created_at.strftime(DATETIME_FORMAT)
45 # TODO: Change script to support storing labels here, rather than directly in the script
46 self.labels: set[str] = {label["name"] for label in issue._rawData["labels"]} # type: ignore [attr-defined]
47
48
49@app.command()
50def main(
51 github_token: Optional[str] = None,
52 issue_reference_number: Optional[int] = None,
53 query_day_interval: Optional[int] = None,
54) -> None:
55 start_time: datetime = datetime.now()
56
57 start_date: datetime | None = None
58
59 if query_day_interval:
60 tz = timezone("america/new_york")
61 current_time = datetime.now(tz).replace(
62 hour=0, minute=0, second=0, microsecond=0
63 )
64 start_date = current_time - timedelta(days=query_day_interval)
65
66 # GitHub Workflow will pass in the token as an environment variable,
67 # but we can place it in our env when running the script locally, for convenience
68 github_token = github_token or os.getenv("GITHUB_ACCESS_TOKEN")
69 github = Github(github_token)
70
71 remaining_requests_before: int = github.rate_limiting[0]
72 print(f"Remaining requests before: {remaining_requests_before}")
73
74 repo_name: str = "zed-industries/zed"
75 repository: Repository = github.get_repo(repo_name)
76
77 # There has to be a nice way of adding types to tuple unpacking
78 label_to_issue_data: dict[str, list[IssueData]]
79 error_message_to_erroneous_issue_data: dict[str, list[IssueData]]
80 (
81 label_to_issue_data,
82 error_message_to_erroneous_issue_data,
83 ) = get_issue_maps(github, repository, start_date)
84
85 issue_text: str = get_issue_text(
86 label_to_issue_data,
87 error_message_to_erroneous_issue_data,
88 )
89
90 if issue_reference_number:
91 top_ranking_issues_issue: Issue = repository.get_issue(issue_reference_number)
92 top_ranking_issues_issue.edit(body=issue_text)
93 else:
94 print(issue_text)
95
96 remaining_requests_after: int = github.rate_limiting[0]
97 print(f"Remaining requests after: {remaining_requests_after}")
98 print(f"Requests used: {remaining_requests_before - remaining_requests_after}")
99
100 run_duration: timedelta = datetime.now() - start_time
101 print(run_duration)
102
103
104def get_issue_maps(
105 github: Github,
106 repository: Repository,
107 start_date: datetime | None = None,
108) -> tuple[dict[str, list[IssueData]], dict[str, list[IssueData]]]:
109 label_to_issues: defaultdict[str, list[Issue]] = get_label_to_issues(
110 github,
111 repository,
112 start_date,
113 )
114 label_to_issue_data: dict[str, list[IssueData]] = get_label_to_issue_data(
115 label_to_issues
116 )
117
118 error_message_to_erroneous_issues: defaultdict[str, list[Issue]] = (
119 get_error_message_to_erroneous_issues(github, repository)
120 )
121 error_message_to_erroneous_issue_data: dict[str, list[IssueData]] = (
122 get_error_message_to_erroneous_issue_data(error_message_to_erroneous_issues)
123 )
124
125 # Create a new dictionary with labels ordered by the summation the of likes on the associated issues
126 labels = list(label_to_issue_data.keys())
127
128 labels.sort(
129 key=lambda label: sum(
130 issue_data.like_count for issue_data in label_to_issue_data[label]
131 ),
132 reverse=True,
133 )
134
135 label_to_issue_data = {label: label_to_issue_data[label] for label in labels}
136
137 return (
138 label_to_issue_data,
139 error_message_to_erroneous_issue_data,
140 )
141
142
143def get_label_to_issues(
144 github: Github,
145 repository: Repository,
146 start_date: datetime | None = None,
147) -> defaultdict[str, list[Issue]]:
148 label_to_issues: defaultdict[str, list[Issue]] = defaultdict(list)
149
150 labels: set[str] = CORE_LABELS | ADDITIONAL_LABELS
151 ignored_labels_text: str = " ".join(
152 [f'-label:"{label}"' for label in IGNORED_LABELS]
153 )
154
155 date_query: str = (
156 f"created:>={start_date.strftime('%Y-%m-%d')}" if start_date else ""
157 )
158
159 for label in labels:
160 query: str = f'repo:{repository.full_name} is:open is:issue {date_query} label:"{label}" {ignored_labels_text} sort:reactions-+1-desc'
161
162 issues = github.search_issues(query)
163
164 if issues.totalCount > 0:
165 for issue in issues[0:ISSUES_PER_LABEL]:
166 label_to_issues[label].append(issue)
167
168 return label_to_issues
169
170
171def get_label_to_issue_data(
172 label_to_issues: defaultdict[str, list[Issue]],
173) -> dict[str, list[IssueData]]:
174 label_to_issue_data: dict[str, list[IssueData]] = {}
175
176 for label in label_to_issues:
177 issues: list[Issue] = label_to_issues[label]
178 issue_data: list[IssueData] = [IssueData(issue) for issue in issues]
179 issue_data.sort(
180 key=lambda issue_data: (
181 -issue_data.like_count,
182 issue_data.creation_datetime,
183 )
184 )
185
186 if issue_data:
187 label_to_issue_data[label] = issue_data
188
189 return label_to_issue_data
190
191
192def get_error_message_to_erroneous_issues(
193 github: Github, repository: Repository
194) -> defaultdict[str, list[Issue]]:
195 error_message_to_erroneous_issues: defaultdict[str, list[Issue]] = defaultdict(list)
196
197 # Query for all open issues that don't have either a core or ignored label and mark those as erroneous
198 filter_labels: set[str] = CORE_LABELS | IGNORED_LABELS
199 filter_labels_text: str = " ".join([f'-label:"{label}"' for label in filter_labels])
200 query: str = f"repo:{repository.full_name} is:open is:issue {filter_labels_text}"
201
202 for issue in github.search_issues(query):
203 error_message_to_erroneous_issues["missing core label"].append(issue)
204
205 return error_message_to_erroneous_issues
206
207
208def get_error_message_to_erroneous_issue_data(
209 error_message_to_erroneous_issues: defaultdict[str, list[Issue]],
210) -> dict[str, list[IssueData]]:
211 error_message_to_erroneous_issue_data: dict[str, list[IssueData]] = {}
212
213 for label in error_message_to_erroneous_issues:
214 issues: list[Issue] = error_message_to_erroneous_issues[label]
215 issue_data: list[IssueData] = [IssueData(issue) for issue in issues]
216 error_message_to_erroneous_issue_data[label] = issue_data
217
218 return error_message_to_erroneous_issue_data
219
220
221def get_issue_text(
222 label_to_issue_data: dict[str, list[IssueData]],
223 error_message_to_erroneous_issue_data: dict[str, list[IssueData]],
224) -> str:
225 tz = timezone("america/new_york")
226 current_datetime: str = datetime.now(tz).strftime(f"{DATETIME_FORMAT} (%Z)")
227
228 highest_ranking_issues_lines: list[str] = get_highest_ranking_issues_lines(
229 label_to_issue_data
230 )
231
232 issue_text_lines: list[str] = [
233 f"*Updated on {current_datetime}*",
234 *highest_ranking_issues_lines,
235 "",
236 "---\n",
237 ]
238
239 erroneous_issues_lines: list[str] = get_erroneous_issues_lines(
240 error_message_to_erroneous_issue_data
241 )
242
243 if erroneous_issues_lines:
244 core_labels_text: str = ", ".join(
245 f'"{core_label}"' for core_label in CORE_LABELS
246 )
247 ignored_labels_text: str = ", ".join(
248 f'"{ignored_label}"' for ignored_label in IGNORED_LABELS
249 )
250
251 issue_text_lines.extend(
252 [
253 "## errors with issues (this section only shows when there are errors with issues)\n",
254 f"This script expects every issue to have at least one of the following core labels: {core_labels_text}",
255 f"This script currently ignores issues that have one of the following labels: {ignored_labels_text}\n",
256 "### what to do?\n",
257 "- Adjust the core labels on an issue to put it into a correct state or add a currently-ignored label to the issue",
258 "- Adjust the core and ignored labels registered in this script",
259 *erroneous_issues_lines,
260 "",
261 "---\n",
262 ]
263 )
264
265 issue_text_lines.extend(
266 [
267 "*For details on how this issue is generated, [see the script](https://github.com/zed-industries/zed/blob/main/script/update_top_ranking_issues/main.py)*",
268 ]
269 )
270
271 return "\n".join(issue_text_lines)
272
273
274def get_highest_ranking_issues_lines(
275 label_to_issue_data: dict[str, list[IssueData]],
276) -> list[str]:
277 highest_ranking_issues_lines: list[str] = []
278
279 if label_to_issue_data:
280 for label, issue_data in label_to_issue_data.items():
281 highest_ranking_issues_lines.append(f"\n## {label}\n")
282
283 for i, issue_data in enumerate(issue_data):
284 markdown_bullet_point: str = (
285 f"{issue_data.url} ({issue_data.like_count} :thumbsup:)"
286 )
287
288 markdown_bullet_point = f"{i + 1}. {markdown_bullet_point}"
289 highest_ranking_issues_lines.append(markdown_bullet_point)
290
291 return highest_ranking_issues_lines
292
293
294def get_erroneous_issues_lines(
295 error_message_to_erroneous_issue_data,
296) -> list[str]:
297 erroneous_issues_lines: list[str] = []
298
299 if error_message_to_erroneous_issue_data:
300 for (
301 error_message,
302 erroneous_issue_data,
303 ) in error_message_to_erroneous_issue_data.items():
304 erroneous_issues_lines.append(f"\n#### {error_message}\n")
305
306 for erroneous_issue_data in erroneous_issue_data:
307 erroneous_issues_lines.append(f"- {erroneous_issue_data.url}")
308
309 return erroneous_issues_lines
310
311
312if __name__ == "__main__":
313 app()
314
315# TODO: Sort label output into core and non core sections