We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
Update Github Action "Recent Comment Slack Bot" to mark issues as Needs: Response 
<!-- IMPORTANT: Before posting, be sure to redact or remove sensitive data, such as passwords, secret keys, session cookies, etc. -->

<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->

### Describe the problem that you'd like solved
<!-- A clear and concise description of what you want to happen. -->

```
def add_label_to_issues(ids):
    owner = 'internetarchive'
    repo = 'openlibrary'
    labels_url = f"https://api.github.com/repos/{owner}/{repo}/issues/labels"
    payload = {"labels": ["Needs: Response"]}
    for issue_number in ids:
        issue_labels_url = f"https://api.github.com/repos/{owner}/{repo}/issues/{issue_number}/labels"
        response = requests.post(issue_labels_url, json=payload, headers=github_headers)

if __name__ == "__main__":
    add_label_to_issues([9054, 9065])
```

to https://github.com/internetarchive/openlibrary/blob/master/scripts/gh_scripts/issue_comment_bot.py#L255

### Proposal & Constraints
<!-- What is the proposed solution / implementation? Is there a precedent of this approach succeeding elsewhere? -->

<!-- Which suggestions or requirements should be considered for how feature needs to appear or be implemented? -->

### Additional context
<!-- Add any other context or screenshots about the feature request here. -->

### Stakeholders
<!-- @ tag stakeholders of this bug -->




--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `scripts/gh_scripts/issue_comment_bot.py`
Content:
```
1 #!/usr/bin/env python
2 """
3 Fetches Open Library GitHub issues that have been commented on
4 within some amount of time, in hours.
5 
6 Writes links to each issue to given Slack channel.
7 """
8 import argparse
9 import errno
10 import os
11 import sys
12 import time
13 
14 from datetime import datetime, timedelta
15 from typing import Any
16 
17 import requests
18 
19 # Maps lead label to GitHub username
20 lead_label_to_username = {
21     'Lead: @mekarpeles': 'mekarpeles',
22     'Lead: @cdrini': 'cdrini',
23     'Lead: @scottbarnes': 'scottbarnes',
24     'Lead: @seabelis': 'seabelis',
25     'Lead: @jimchamp': 'jimchamp',
26 }
27 
28 # Maps GitHub username to Slack ID
29 username_to_slack_id = {
30     'mekarpeles': '<@mek>',
31     'cdrini': '<@cdrini>',
32     'scottbarnes': '<@U03MNR6T7FH>',
33     'seabelis': '<@UAHQ39ACT>',
34     'jimchamp': '<@U01ARTHG9EV>',
35     'hornc': '<@U0EUS8DV0>',
36 }
37 
38 github_headers = {
39     'X-GitHub-Api-Version': '2022-11-28',
40     'Accept': 'application/vnd.github+json',
41 }
42 
43 
44 def fetch_issues(updated_since: str):
45     """
46     Fetches all GitHub issues that have been updated since the given date string and have at least one comment.
47 
48     GitHub results are paginated.  This functions appends each result to a list, and does so for all pages.
49     To keep API calls to a minimum, we request the maximum number of results per request (100 per page, as of writing).
50 
51     Important: Updated issues need not have a recent comment. Update events include many other things, such as adding a
52     label to an issue, or moving an issue to a milestone.  Issues returned by this function will require additional
53     processing in order to determine if they have recent comments.
54     """
55     # Make initial query for updated issues:
56     query = f'repo:internetarchive/openlibrary is:open is:issue comments:>0 updated:>{updated_since}'
57     p: dict[str, str | int] = {
58         'q': query,
59         'per_page': 100,
60     }
61     response = requests.get(
62         'https://api.github.com/search/issues', params=p, headers=github_headers
63     )
64     d = response.json()
65     results = d['items']
66 
67     # Fetch additional updated issues, if any exist
68     def get_next_page(url: str):
69         """Returns list of issues and optional url for next page"""
70         resp = requests.get(url, headers=github_headers)
71         # Get issues
72         d = resp.json()
73         issues = d['items']
74         # Prepare url for next page
75         next = resp.links.get('next', {})
76         next_url = next.get('url', '')
77 
78         return issues, next_url
79 
80     links = response.links
81     next = links.get('next', {})
82     next_url = next.get('url', '')
83     while next_url:
84         # Make call with next link
85         issues, next_url = get_next_page(next_url)
86         results = results + issues
87 
88     return results
89 
90 
91 def filter_issues(issues: list, since: datetime):
92     """
93     Returns list of issues that were not last responded to by staff.
94     Requires fetching the most recent comments for the given issues.
95     """
96     results = []
97 
98     for i in issues:
99         # Fetch comments using URL from previous GitHub search results
100         comments_url = i.get('comments_url')
101         resp = requests.get(
102             comments_url, params={'per_page': 100}, headers=github_headers
103         )
104 
105         # Ensure that we have the last page of comments
106         links = resp.links
107         last = links.get('last', {})
108         last_url = last.get('url', '')
109 
110         if last_url:
111             resp = requests.get(last_url, headers=github_headers)
112 
113         # Get last comment
114         comments = resp.json()
115         last_comment = comments[-1]
116 
117         # Determine if last comment meets our criteria for Slack notifications
118         # First step: Ensure that the last comment was left after the given `since` datetime
119         created = datetime.fromisoformat(last_comment['created_at'])
120         # Removing timezone info to avoid TypeErrors, which occur when
121         # comparing a timezone-aware datetime with a timezone-naive datetime
122         created = created.replace(tzinfo=None)
123         if created > since:
124             # Next step: Determine if the last commenter is a staff member
125             last_commenter = last_comment['user']['login']
126             if last_commenter not in username_to_slack_id:
127                 lead_label = find_lead_label(i.get('labels', []))
128                 results.append(
129                     {
130                         'comment_url': last_comment['html_url'],
131                         'commenter': last_commenter,
132                         'issue_title': i['title'],
133                         'lead_label': lead_label,
134                     }
135                 )
136 
137     return results
138 
139 
140 def find_lead_label(labels: list[dict[str, Any]]) -> str:
141     """
142     Finds and returns the name of the first lead label found in the given list of GitHub labels.
143 
144     Returns an empty string if no lead label is found
145     """
146     result = ''
147     for label in labels:
148         if label['name'].startswith('Lead:'):
149             result = label['name']
150             break
151 
152     return result
153 
154 
155 def publish_digest(
156     issues: list[dict[str, str]],
157     slack_channel: str,
158     slack_token: str,
159     hours_passed: int,
160 ):
161     """
162     Creates a threaded Slack messaged containing a digest of recently commented GitHub issues.
163 
164     Parent Slack message will say how many comments were left, and the timeframe. Each reply
165     will include a link to the comment, as well as additional information.
166     """
167     # Create the parent message
168     parent_thread_msg = (
169         f'{len(issues)} new GitHub comment(s) since {hours_passed} hour(s) ago'
170     )
171 
172     response = requests.post(
173         'https://slack.com/api/chat.postMessage',
174         headers={
175             'Authorization': f"Bearer {slack_token}",
176             'Content-Type': 'application/json;  charset=utf-8',
177         },
178         json={
179             'channel': slack_channel,
180             'text': parent_thread_msg,
181         },
182     )
183 
184     if response.status_code != 200:
185         # XXX : Log this
186         print(f'Failed to send message to Slack.  Status code: {response.status_code}')
187         # XXX : Add retry logic?
188         sys.exit(errno.ECOMM)
189 
190     d = response.json()
191     # Store timestamp, which, along with the channel, uniquely identifies the parent thread
192     ts = d.get('ts')
193 
194     def comment_on_thread(message: str):
195         """
196         Posts the given message as a reply to the parent message.
197         """
198         response = requests.post(
199             'https://slack.com/api/chat.postMessage',
200             headers={
201                 'Authorization': f"Bearer {slack_token}",
202                 'Content-Type': 'application/json;  charset=utf-8',
203             },
204             json={
205                 'channel': slack_channel,
206                 'text': message,
207                 'thread_ts': ts,
208             },
209         )
210         if response.status_code != 200:
211             # XXX : Check "ok" field for errors
212             # XXX : Log this
213             print(
214                 f'Failed to POST slack message\n  Status code: {response.status_code}\n  Message: {message}'
215             )
216             # XXX : Retry logic?
217 
218     for i in issues:
219         # Slack rate limit is roughly 1 request per second
220         time.sleep(1)
221 
222         comment_url = i['comment_url']
223         issue_title = i['issue_title']
224         commenter = i['commenter']
225         message = f'<{comment_url}|Latest comment for: *{issue_title}*>\n'
226 
227         username = lead_label_to_username.get(i['lead_label'], '')
228         slack_id = username_to_slack_id.get(username, '')
229         if slack_id:
230             message += f'Lead: {slack_id}\n'
231         elif i['lead_label']:
232             message += f'{i["lead_label"]}\n'
233         else:
234             message += 'Lead: N/A\n'
235 
236         message += f'Commenter: *{commenter}*'
237         comment_on_thread(message)
238 
239 
240 def time_since(hours):
241     """Returns datetime and string representations of the current time, minus the given hour"""
242     now = datetime.now()
243     # XXX : Add a minute or two to the delta (to avoid dropping issues)?
244     since = now - timedelta(hours=hours)
245     return since, since.strftime('%Y-%m-%dT%H:%M:%S')
246 
247 
248 def start_job(args: argparse.Namespace):
249     """
250     Starts the new comment digest job.
251     """
252     since, date_string = time_since(args.hours)
253     issues = fetch_issues(date_string)
254 
255     filtered_issues = filter_issues(issues, since)
256     publish_digest(filtered_issues, args.channel, args.slack_token, args.hours)
257     print('Digest posted to Slack.')
258 
259 
260 def _get_parser() -> argparse.ArgumentParser:
261     """
262     Creates and returns an ArgumentParser containing default values which were
263     read from the config file.
264     """
265     parser = argparse.ArgumentParser(description=__doc__)
266     parser.add_argument(
267         'hours',
268         help='Fetch issues that have been updated since this many hours ago',
269         type=int,
270     )
271     parser.add_argument(
272         'channel',
273         help="Issues will be published to this Slack channel",
274         type=str,
275     )
276     parser.add_argument(
277         'slack_token',
278         metavar='slack-token',
279         help='Slack auth token',
280         type=str,
281     )
282 
283     return parser
284 
285 
286 if __name__ == '__main__':
287     # Process command-line arguments and starts the notification job
288     parser = _get_parser()
289     args = parser.parse_args()
290 
291     # If found, add token to GitHub request headers:
292     github_token = os.environ.get('GITHUB_TOKEN', '')
293     if github_token:
294         github_headers['Authorization'] = f'Bearer {github_token}'
295     start_job(args)
296
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/scripts/gh_scripts/issue_comment_bot.py b/scripts/gh_scripts/issue_comment_bot.py
--- a/scripts/gh_scripts/issue_comment_bot.py
+++ b/scripts/gh_scripts/issue_comment_bot.py
@@ -245,6 +245,16 @@
     return since, since.strftime('%Y-%m-%dT%H:%M:%S')
 
 
+def add_label_to_issues(issues):
+    for issue in issues:
+        issue_labels_url = f"https://api.github.com/repos/internetarchive/openlibrary/issues/{issue['number']}/labels"
+        response = requests.post(
+            issue_labels_url,
+            json={"labels": ["Needs: Response"]},
+            headers=github_headers,
+        )
+
+
 def start_job(args: argparse.Namespace):
     """
     Starts the new comment digest job.
@@ -253,6 +263,7 @@
     issues = fetch_issues(date_string)
 
     filtered_issues = filter_issues(issues, since)
+    add_label_to_issues(filtered_issues)
     publish_digest(filtered_issues, args.channel, args.slack_token, args.hours)
     print('Digest posted to Slack.')

{"golden_diff": "diff --git a/scripts/gh_scripts/issue_comment_bot.py b/scripts/gh_scripts/issue_comment_bot.py\n--- a/scripts/gh_scripts/issue_comment_bot.py\n+++ b/scripts/gh_scripts/issue_comment_bot.py\n@@ -245,6 +245,16 @@\n     return since, since.strftime('%Y-%m-%dT%H:%M:%S')\n \n \n+def add_label_to_issues(issues):\n+    for issue in issues:\n+        issue_labels_url = f\"https://api.github.com/repos/internetarchive/openlibrary/issues/{issue['number']}/labels\"\n+        response = requests.post(\n+            issue_labels_url,\n+            json={\"labels\": [\"Needs: Response\"]},\n+            headers=github_headers,\n+        )\n+\n+\n def start_job(args: argparse.Namespace):\n     \"\"\"\n     Starts the new comment digest job.\n@@ -253,6 +263,7 @@\n     issues = fetch_issues(date_string)\n \n     filtered_issues = filter_issues(issues, since)\n+    add_label_to_issues(filtered_issues)\n     publish_digest(filtered_issues, args.channel, args.slack_token, args.hours)\n     print('Digest posted to Slack.')\n", "issue": "Update Github Action \"Recent Comment Slack Bot\" to mark issues as Needs: Response \n<!-- IMPORTANT: Before posting, be sure to redact or remove sensitive data, such as passwords, secret keys, session cookies, etc. -->\r\n\r\n<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->\r\n\r\n### Describe the problem that you'd like solved\r\n<!-- A clear and concise description of what you want to happen. -->\r\n\r\n```\r\ndef add_label_to_issues(ids):\r\n    owner = 'internetarchive'\r\n    repo = 'openlibrary'\r\n    labels_url = f\"https://api.github.com/repos/{owner}/{repo}/issues/labels\"\r\n    payload = {\"labels\": [\"Needs: Response\"]}\r\n    for issue_number in ids:\r\n        issue_labels_url = f\"https://api.github.com/repos/{owner}/{repo}/issues/{issue_number}/labels\"\r\n        response = requests.post(issue_labels_url, json=payload, headers=github_headers)\r\n\r\nif __name__ == \"__main__\":\r\n    add_label_to_issues([9054, 9065])\r\n```\r\n\r\nto https://github.com/internetarchive/openlibrary/blob/master/scripts/gh_scripts/issue_comment_bot.py#L255\r\n\r\n### Proposal & Constraints\r\n<!-- What is the proposed solution / implementation? Is there a precedent of this approach succeeding elsewhere? -->\r\n\r\n<!-- Which suggestions or requirements should be considered for how feature needs to appear or be implemented? -->\r\n\r\n### Additional context\r\n<!-- Add any other context or screenshots about the feature request here. -->\r\n\r\n### Stakeholders\r\n<!-- @ tag stakeholders of this bug -->\r\n\r\n\r\n\n", "before_files": [{"content": "#!/usr/bin/env python\n\"\"\"\nFetches Open Library GitHub issues that have been commented on\nwithin some amount of time, in hours.\n\nWrites links to each issue to given Slack channel.\n\"\"\"\nimport argparse\nimport errno\nimport os\nimport sys\nimport time\n\nfrom datetime import datetime, timedelta\nfrom typing import Any\n\nimport requests\n\n# Maps lead label to GitHub username\nlead_label_to_username = {\n    'Lead: @mekarpeles': 'mekarpeles',\n    'Lead: @cdrini': 'cdrini',\n    'Lead: @scottbarnes': 'scottbarnes',\n    'Lead: @seabelis': 'seabelis',\n    'Lead: @jimchamp': 'jimchamp',\n}\n\n# Maps GitHub username to Slack ID\nusername_to_slack_id = {\n    'mekarpeles': '<@mek>',\n    'cdrini': '<@cdrini>',\n    'scottbarnes': '<@U03MNR6T7FH>',\n    'seabelis': '<@UAHQ39ACT>',\n    'jimchamp': '<@U01ARTHG9EV>',\n    'hornc': '<@U0EUS8DV0>',\n}\n\ngithub_headers = {\n    'X-GitHub-Api-Version': '2022-11-28',\n    'Accept': 'application/vnd.github+json',\n}\n\n\ndef fetch_issues(updated_since: str):\n    \"\"\"\n    Fetches all GitHub issues that have been updated since the given date string and have at least one comment.\n\n    GitHub results are paginated.  This functions appends each result to a list, and does so for all pages.\n    To keep API calls to a minimum, we request the maximum number of results per request (100 per page, as of writing).\n\n    Important: Updated issues need not have a recent comment. Update events include many other things, such as adding a\n    label to an issue, or moving an issue to a milestone.  Issues returned by this function will require additional\n    processing in order to determine if they have recent comments.\n    \"\"\"\n    # Make initial query for updated issues:\n    query = f'repo:internetarchive/openlibrary is:open is:issue comments:>0 updated:>{updated_since}'\n    p: dict[str, str | int] = {\n        'q': query,\n        'per_page': 100,\n    }\n    response = requests.get(\n        'https://api.github.com/search/issues', params=p, headers=github_headers\n    )\n    d = response.json()\n    results = d['items']\n\n    # Fetch additional updated issues, if any exist\n    def get_next_page(url: str):\n        \"\"\"Returns list of issues and optional url for next page\"\"\"\n        resp = requests.get(url, headers=github_headers)\n        # Get issues\n        d = resp.json()\n        issues = d['items']\n        # Prepare url for next page\n        next = resp.links.get('next', {})\n        next_url = next.get('url', '')\n\n        return issues, next_url\n\n    links = response.links\n    next = links.get('next', {})\n    next_url = next.get('url', '')\n    while next_url:\n        # Make call with next link\n        issues, next_url = get_next_page(next_url)\n        results = results + issues\n\n    return results\n\n\ndef filter_issues(issues: list, since: datetime):\n    \"\"\"\n    Returns list of issues that were not last responded to by staff.\n    Requires fetching the most recent comments for the given issues.\n    \"\"\"\n    results = []\n\n    for i in issues:\n        # Fetch comments using URL from previous GitHub search results\n        comments_url = i.get('comments_url')\n        resp = requests.get(\n            comments_url, params={'per_page': 100}, headers=github_headers\n        )\n\n        # Ensure that we have the last page of comments\n        links = resp.links\n        last = links.get('last', {})\n        last_url = last.get('url', '')\n\n        if last_url:\n            resp = requests.get(last_url, headers=github_headers)\n\n        # Get last comment\n        comments = resp.json()\n        last_comment = comments[-1]\n\n        # Determine if last comment meets our criteria for Slack notifications\n        # First step: Ensure that the last comment was left after the given `since` datetime\n        created = datetime.fromisoformat(last_comment['created_at'])\n        # Removing timezone info to avoid TypeErrors, which occur when\n        # comparing a timezone-aware datetime with a timezone-naive datetime\n        created = created.replace(tzinfo=None)\n        if created > since:\n            # Next step: Determine if the last commenter is a staff member\n            last_commenter = last_comment['user']['login']\n            if last_commenter not in username_to_slack_id:\n                lead_label = find_lead_label(i.get('labels', []))\n                results.append(\n                    {\n                        'comment_url': last_comment['html_url'],\n                        'commenter': last_commenter,\n                        'issue_title': i['title'],\n                        'lead_label': lead_label,\n                    }\n                )\n\n    return results\n\n\ndef find_lead_label(labels: list[dict[str, Any]]) -> str:\n    \"\"\"\n    Finds and returns the name of the first lead label found in the given list of GitHub labels.\n\n    Returns an empty string if no lead label is found\n    \"\"\"\n    result = ''\n    for label in labels:\n        if label['name'].startswith('Lead:'):\n            result = label['name']\n            break\n\n    return result\n\n\ndef publish_digest(\n    issues: list[dict[str, str]],\n    slack_channel: str,\n    slack_token: str,\n    hours_passed: int,\n):\n    \"\"\"\n    Creates a threaded Slack messaged containing a digest of recently commented GitHub issues.\n\n    Parent Slack message will say how many comments were left, and the timeframe. Each reply\n    will include a link to the comment, as well as additional information.\n    \"\"\"\n    # Create the parent message\n    parent_thread_msg = (\n        f'{len(issues)} new GitHub comment(s) since {hours_passed} hour(s) ago'\n    )\n\n    response = requests.post(\n        'https://slack.com/api/chat.postMessage',\n        headers={\n            'Authorization': f\"Bearer {slack_token}\",\n            'Content-Type': 'application/json;  charset=utf-8',\n        },\n        json={\n            'channel': slack_channel,\n            'text': parent_thread_msg,\n        },\n    )\n\n    if response.status_code != 200:\n        # XXX : Log this\n        print(f'Failed to send message to Slack.  Status code: {response.status_code}')\n        # XXX : Add retry logic?\n        sys.exit(errno.ECOMM)\n\n    d = response.json()\n    # Store timestamp, which, along with the channel, uniquely identifies the parent thread\n    ts = d.get('ts')\n\n    def comment_on_thread(message: str):\n        \"\"\"\n        Posts the given message as a reply to the parent message.\n        \"\"\"\n        response = requests.post(\n            'https://slack.com/api/chat.postMessage',\n            headers={\n                'Authorization': f\"Bearer {slack_token}\",\n                'Content-Type': 'application/json;  charset=utf-8',\n            },\n            json={\n                'channel': slack_channel,\n                'text': message,\n                'thread_ts': ts,\n            },\n        )\n        if response.status_code != 200:\n            # XXX : Check \"ok\" field for errors\n            # XXX : Log this\n            print(\n                f'Failed to POST slack message\\n  Status code: {response.status_code}\\n  Message: {message}'\n            )\n            # XXX : Retry logic?\n\n    for i in issues:\n        # Slack rate limit is roughly 1 request per second\n        time.sleep(1)\n\n        comment_url = i['comment_url']\n        issue_title = i['issue_title']\n        commenter = i['commenter']\n        message = f'<{comment_url}|Latest comment for: *{issue_title}*>\\n'\n\n        username = lead_label_to_username.get(i['lead_label'], '')\n        slack_id = username_to_slack_id.get(username, '')\n        if slack_id:\n            message += f'Lead: {slack_id}\\n'\n        elif i['lead_label']:\n            message += f'{i[\"lead_label\"]}\\n'\n        else:\n            message += 'Lead: N/A\\n'\n\n        message += f'Commenter: *{commenter}*'\n        comment_on_thread(message)\n\n\ndef time_since(hours):\n    \"\"\"Returns datetime and string representations of the current time, minus the given hour\"\"\"\n    now = datetime.now()\n    # XXX : Add a minute or two to the delta (to avoid dropping issues)?\n    since = now - timedelta(hours=hours)\n    return since, since.strftime('%Y-%m-%dT%H:%M:%S')\n\n\ndef start_job(args: argparse.Namespace):\n    \"\"\"\n    Starts the new comment digest job.\n    \"\"\"\n    since, date_string = time_since(args.hours)\n    issues = fetch_issues(date_string)\n\n    filtered_issues = filter_issues(issues, since)\n    publish_digest(filtered_issues, args.channel, args.slack_token, args.hours)\n    print('Digest posted to Slack.')\n\n\ndef _get_parser() -> argparse.ArgumentParser:\n    \"\"\"\n    Creates and returns an ArgumentParser containing default values which were\n    read from the config file.\n    \"\"\"\n    parser = argparse.ArgumentParser(description=__doc__)\n    parser.add_argument(\n        'hours',\n        help='Fetch issues that have been updated since this many hours ago',\n        type=int,\n    )\n    parser.add_argument(\n        'channel',\n        help=\"Issues will be published to this Slack channel\",\n        type=str,\n    )\n    parser.add_argument(\n        'slack_token',\n        metavar='slack-token',\n        help='Slack auth token',\n        type=str,\n    )\n\n    return parser\n\n\nif __name__ == '__main__':\n    # Process command-line arguments and starts the notification job\n    parser = _get_parser()\n    args = parser.parse_args()\n\n    # If found, add token to GitHub request headers:\n    github_token = os.environ.get('GITHUB_TOKEN', '')\n    if github_token:\n        github_headers['Authorization'] = f'Bearer {github_token}'\n    start_job(args)\n", "path": "scripts/gh_scripts/issue_comment_bot.py"}], "after_files": [{"content": "#!/usr/bin/env python\n\"\"\"\nFetches Open Library GitHub issues that have been commented on\nwithin some amount of time, in hours.\n\nWrites links to each issue to given Slack channel.\n\"\"\"\nimport argparse\nimport errno\nimport os\nimport sys\nimport time\n\nfrom datetime import datetime, timedelta\nfrom typing import Any\n\nimport requests\n\n# Maps lead label to GitHub username\nlead_label_to_username = {\n    'Lead: @mekarpeles': 'mekarpeles',\n    'Lead: @cdrini': 'cdrini',\n    'Lead: @scottbarnes': 'scottbarnes',\n    'Lead: @seabelis': 'seabelis',\n    'Lead: @jimchamp': 'jimchamp',\n}\n\n# Maps GitHub username to Slack ID\nusername_to_slack_id = {\n    'mekarpeles': '<@mek>',\n    'cdrini': '<@cdrini>',\n    'scottbarnes': '<@U03MNR6T7FH>',\n    'seabelis': '<@UAHQ39ACT>',\n    'jimchamp': '<@U01ARTHG9EV>',\n    'hornc': '<@U0EUS8DV0>',\n}\n\ngithub_headers = {\n    'X-GitHub-Api-Version': '2022-11-28',\n    'Accept': 'application/vnd.github+json',\n}\n\n\ndef fetch_issues(updated_since: str):\n    \"\"\"\n    Fetches all GitHub issues that have been updated since the given date string and have at least one comment.\n\n    GitHub results are paginated.  This functions appends each result to a list, and does so for all pages.\n    To keep API calls to a minimum, we request the maximum number of results per request (100 per page, as of writing).\n\n    Important: Updated issues need not have a recent comment. Update events include many other things, such as adding a\n    label to an issue, or moving an issue to a milestone.  Issues returned by this function will require additional\n    processing in order to determine if they have recent comments.\n    \"\"\"\n    # Make initial query for updated issues:\n    query = f'repo:internetarchive/openlibrary is:open is:issue comments:>0 updated:>{updated_since}'\n    p: dict[str, str | int] = {\n        'q': query,\n        'per_page': 100,\n    }\n    response = requests.get(\n        'https://api.github.com/search/issues', params=p, headers=github_headers\n    )\n    d = response.json()\n    results = d['items']\n\n    # Fetch additional updated issues, if any exist\n    def get_next_page(url: str):\n        \"\"\"Returns list of issues and optional url for next page\"\"\"\n        resp = requests.get(url, headers=github_headers)\n        # Get issues\n        d = resp.json()\n        issues = d['items']\n        # Prepare url for next page\n        next = resp.links.get('next', {})\n        next_url = next.get('url', '')\n\n        return issues, next_url\n\n    links = response.links\n    next = links.get('next', {})\n    next_url = next.get('url', '')\n    while next_url:\n        # Make call with next link\n        issues, next_url = get_next_page(next_url)\n        results = results + issues\n\n    return results\n\n\ndef filter_issues(issues: list, since: datetime):\n    \"\"\"\n    Returns list of issues that were not last responded to by staff.\n    Requires fetching the most recent comments for the given issues.\n    \"\"\"\n    results = []\n\n    for i in issues:\n        # Fetch comments using URL from previous GitHub search results\n        comments_url = i.get('comments_url')\n        resp = requests.get(\n            comments_url, params={'per_page': 100}, headers=github_headers\n        )\n\n        # Ensure that we have the last page of comments\n        links = resp.links\n        last = links.get('last', {})\n        last_url = last.get('url', '')\n\n        if last_url:\n            resp = requests.get(last_url, headers=github_headers)\n\n        # Get last comment\n        comments = resp.json()\n        last_comment = comments[-1]\n\n        # Determine if last comment meets our criteria for Slack notifications\n        # First step: Ensure that the last comment was left after the given `since` datetime\n        created = datetime.fromisoformat(last_comment['created_at'])\n        # Removing timezone info to avoid TypeErrors, which occur when\n        # comparing a timezone-aware datetime with a timezone-naive datetime\n        created = created.replace(tzinfo=None)\n        if created > since:\n            # Next step: Determine if the last commenter is a staff member\n            last_commenter = last_comment['user']['login']\n            if last_commenter not in username_to_slack_id:\n                lead_label = find_lead_label(i.get('labels', []))\n                results.append(\n                    {\n                        'comment_url': last_comment['html_url'],\n                        'commenter': last_commenter,\n                        'issue_title': i['title'],\n                        'lead_label': lead_label,\n                    }\n                )\n\n    return results\n\n\ndef find_lead_label(labels: list[dict[str, Any]]) -> str:\n    \"\"\"\n    Finds and returns the name of the first lead label found in the given list of GitHub labels.\n\n    Returns an empty string if no lead label is found\n    \"\"\"\n    result = ''\n    for label in labels:\n        if label['name'].startswith('Lead:'):\n            result = label['name']\n            break\n\n    return result\n\n\ndef publish_digest(\n    issues: list[dict[str, str]],\n    slack_channel: str,\n    slack_token: str,\n    hours_passed: int,\n):\n    \"\"\"\n    Creates a threaded Slack messaged containing a digest of recently commented GitHub issues.\n\n    Parent Slack message will say how many comments were left, and the timeframe. Each reply\n    will include a link to the comment, as well as additional information.\n    \"\"\"\n    # Create the parent message\n    parent_thread_msg = (\n        f'{len(issues)} new GitHub comment(s) since {hours_passed} hour(s) ago'\n    )\n\n    response = requests.post(\n        'https://slack.com/api/chat.postMessage',\n        headers={\n            'Authorization': f\"Bearer {slack_token}\",\n            'Content-Type': 'application/json;  charset=utf-8',\n        },\n        json={\n            'channel': slack_channel,\n            'text': parent_thread_msg,\n        },\n    )\n\n    if response.status_code != 200:\n        # XXX : Log this\n        print(f'Failed to send message to Slack.  Status code: {response.status_code}')\n        # XXX : Add retry logic?\n        sys.exit(errno.ECOMM)\n\n    d = response.json()\n    # Store timestamp, which, along with the channel, uniquely identifies the parent thread\n    ts = d.get('ts')\n\n    def comment_on_thread(message: str):\n        \"\"\"\n        Posts the given message as a reply to the parent message.\n        \"\"\"\n        response = requests.post(\n            'https://slack.com/api/chat.postMessage',\n            headers={\n                'Authorization': f\"Bearer {slack_token}\",\n                'Content-Type': 'application/json;  charset=utf-8',\n            },\n            json={\n                'channel': slack_channel,\n                'text': message,\n                'thread_ts': ts,\n            },\n        )\n        if response.status_code != 200:\n            # XXX : Check \"ok\" field for errors\n            # XXX : Log this\n            print(\n                f'Failed to POST slack message\\n  Status code: {response.status_code}\\n  Message: {message}'\n            )\n            # XXX : Retry logic?\n\n    for i in issues:\n        # Slack rate limit is roughly 1 request per second\n        time.sleep(1)\n\n        comment_url = i['comment_url']\n        issue_title = i['issue_title']\n        commenter = i['commenter']\n        message = f'<{comment_url}|Latest comment for: *{issue_title}*>\\n'\n\n        username = lead_label_to_username.get(i['lead_label'], '')\n        slack_id = username_to_slack_id.get(username, '')\n        if slack_id:\n            message += f'Lead: {slack_id}\\n'\n        elif i['lead_label']:\n            message += f'{i[\"lead_label\"]}\\n'\n        else:\n            message += 'Lead: N/A\\n'\n\n        message += f'Commenter: *{commenter}*'\n        comment_on_thread(message)\n\n\ndef time_since(hours):\n    \"\"\"Returns datetime and string representations of the current time, minus the given hour\"\"\"\n    now = datetime.now()\n    # XXX : Add a minute or two to the delta (to avoid dropping issues)?\n    since = now - timedelta(hours=hours)\n    return since, since.strftime('%Y-%m-%dT%H:%M:%S')\n\n\ndef add_label_to_issues(issues):\n    for issue in issues:\n        issue_labels_url = f\"https://api.github.com/repos/internetarchive/openlibrary/issues/{issue['number']}/labels\"\n        response = requests.post(\n            issue_labels_url,\n            json={\"labels\": [\"Needs: Response\"]},\n            headers=github_headers,\n        )\n\n\ndef start_job(args: argparse.Namespace):\n    \"\"\"\n    Starts the new comment digest job.\n    \"\"\"\n    since, date_string = time_since(args.hours)\n    issues = fetch_issues(date_string)\n\n    filtered_issues = filter_issues(issues, since)\n    add_label_to_issues(filtered_issues)\n    publish_digest(filtered_issues, args.channel, args.slack_token, args.hours)\n    print('Digest posted to Slack.')\n\n\ndef _get_parser() -> argparse.ArgumentParser:\n    \"\"\"\n    Creates and returns an ArgumentParser containing default values which were\n    read from the config file.\n    \"\"\"\n    parser = argparse.ArgumentParser(description=__doc__)\n    parser.add_argument(\n        'hours',\n        help='Fetch issues that have been updated since this many hours ago',\n        type=int,\n    )\n    parser.add_argument(\n        'channel',\n        help=\"Issues will be published to this Slack channel\",\n        type=str,\n    )\n    parser.add_argument(\n        'slack_token',\n        metavar='slack-token',\n        help='Slack auth token',\n        type=str,\n    )\n\n    return parser\n\n\nif __name__ == '__main__':\n    # Process command-line arguments and starts the notification job\n    parser = _get_parser()\n    args = parser.parse_args()\n\n    # If found, add token to GitHub request headers:\n    github_token = os.environ.get('GITHUB_TOKEN', '')\n    if github_token:\n        github_headers['Authorization'] = f'Bearer {github_token}'\n    start_job(args)\n", "path": "scripts/gh_scripts/issue_comment_bot.py"}]}