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] = {
 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