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