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