main.py

  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