|
| 1 | +# Script for keeping track of setec issues. |
| 2 | +# |
| 3 | +# bazel run //tools/repo:notify-setec |
| 4 | +# |
| 5 | +# The tool can be used in `--dry_run` mode and show what it would post to slack |
| 6 | + |
| 7 | +import datetime |
| 8 | +import html |
| 9 | +import icalendar |
| 10 | +import json |
| 11 | +import os |
| 12 | +import sys |
| 13 | +from datetime import datetime as dt |
| 14 | +from functools import cached_property |
| 15 | + |
| 16 | +import aiohttp |
| 17 | + |
| 18 | +from slack_sdk.web.async_client import AsyncWebClient |
| 19 | +from slack_sdk.errors import SlackApiError |
| 20 | + |
| 21 | +from aio.api import github as github |
| 22 | +from aio.core.functional import async_property |
| 23 | +from aio.run import runner |
| 24 | + |
| 25 | +ENVOY_REPO = "envoyproxy/envoy-setec" |
| 26 | + |
| 27 | +SLACK_EXPORT_URL = "https://api.slack.com/apps/A023NPQQ33K/oauth?" |
| 28 | + |
| 29 | +class RepoNotifier(runner.Runner): |
| 30 | + |
| 31 | + @property |
| 32 | + def dry_run(self): |
| 33 | + return self.args.dry_run |
| 34 | + |
| 35 | + @cached_property |
| 36 | + def github(self): |
| 37 | + return github.GithubAPI(self.session, "", oauth_token=self.github_token) |
| 38 | + |
| 39 | + @cached_property |
| 40 | + def github_token(self): |
| 41 | + return os.getenv('GITHUB_TOKEN') |
| 42 | + |
| 43 | + @async_property |
| 44 | + async def issues(self): |
| 45 | + async for issue in self.repo.getiter("issues"): |
| 46 | + skip = not "issue" in issue["html_url"] |
| 47 | + if skip: |
| 48 | + self.log.notice(f"Skipping {issue['title']} {issue['url']}") |
| 49 | + continue |
| 50 | + yield issue |
| 51 | + |
| 52 | + @cached_property |
| 53 | + def repo(self): |
| 54 | + return self.github[ENVOY_REPO] |
| 55 | + |
| 56 | + @cached_property |
| 57 | + def session(self): |
| 58 | + return aiohttp.ClientSession() |
| 59 | + |
| 60 | + @async_property(cache=True) |
| 61 | + async def shepherd_notifications(self): |
| 62 | + return (await self.tracked_issues)["shepherd_notifications"] |
| 63 | + |
| 64 | + @cached_property |
| 65 | + def slack_client(self): |
| 66 | + return AsyncWebClient(token=self.slack_bot_token) |
| 67 | + |
| 68 | + @cached_property |
| 69 | + def slack_bot_token(self): |
| 70 | + return os.getenv('SLACK_BOT_TOKEN') |
| 71 | + |
| 72 | + @async_property(cache=True) |
| 73 | + async def assignee_and_issues(self): |
| 74 | + return (await self.tracked_issues)["assignee_and_issues"] |
| 75 | + |
| 76 | + # Allow for 1w for updates. |
| 77 | + # This can be tightened for cve issues near release time. |
| 78 | + @cached_property |
| 79 | + def slo_max(self): |
| 80 | + hours = 168 |
| 81 | + return datetime.timedelta(hours=hours) |
| 82 | + |
| 83 | + @async_property(cache=True) |
| 84 | + async def stalled_issues(self): |
| 85 | + return (await self.tracked_issues)["stalled_issues"] |
| 86 | + |
| 87 | + @async_property(cache=True) |
| 88 | + async def tracked_issues(self): |
| 89 | + # A dict of assignee : outstanding_issue to be sent to slack |
| 90 | + # A placeholder for unassigned issuess, to be sent to #assignee eventually |
| 91 | + assignee_and_issues = dict(unassigned=[]) |
| 92 | + # Out-SLO issues to be sent to #envoy-setec |
| 93 | + stalled_issues = [] |
| 94 | + |
| 95 | + async for issue in self.issues: |
| 96 | + updated_at = dt.fromisoformat(issue["updated_at"].replace('Z', '+00:00')) |
| 97 | + age = dt.now(datetime.timezone.utc) - dt.fromisoformat( |
| 98 | + issue["updated_at"].replace('Z', '+00:00')) |
| 99 | + message = self.pr_message(age, issue) |
| 100 | + is_approved = "patch:approved" in [label["name"] for label in issue["labels"]]; |
| 101 | + |
| 102 | + # If the PR has been out-SLO for over a day, inform on-call |
| 103 | + if age > self.slo_max + datetime.timedelta(hours=36) and not is_approved: |
| 104 | + stalled_issues.append(message) |
| 105 | + |
| 106 | + has_assignee = False |
| 107 | + for assignee in issue["assignees"]: |
| 108 | + has_assignee = True |
| 109 | + assignee_and_issues[assignee["login"]] = assignee_and_issues.get( |
| 110 | + assignee["login"], []) |
| 111 | + assignee_and_issues[assignee["login"]].append(message) |
| 112 | + |
| 113 | + # If there was no assignee, track it as unassigned. |
| 114 | + if not has_assignee: |
| 115 | + assignee_and_issues['unassigned'].append(message) |
| 116 | + |
| 117 | + return dict( |
| 118 | + assignee_and_issues=assignee_and_issues, |
| 119 | + stalled_issues=stalled_issues) |
| 120 | + |
| 121 | + @async_property(cache=True) |
| 122 | + async def unassigned_issues(self): |
| 123 | + return (await self.assignee_and_issues)["unassigned"] |
| 124 | + |
| 125 | + def add_arguments(self, parser) -> None: |
| 126 | + super().add_arguments(parser) |
| 127 | + parser.add_argument( |
| 128 | + '--dry_run', |
| 129 | + action="store_true", |
| 130 | + help="Dont post slack messages, just show what would be posted") |
| 131 | + |
| 132 | + async def notify(self): |
| 133 | + await self.post_to_oncall() |
| 134 | + |
| 135 | + async def post_to_oncall(self): |
| 136 | + try: |
| 137 | + unassigned = "\n".join(await self.unassigned_issues) |
| 138 | + stalled = "\n".join(await self.stalled_issues) |
| 139 | + await self.send_message( |
| 140 | + channel='#envoy-maintainer-oncall', |
| 141 | + text=(f"*'Unassigned' Issues* (Issues with no maintainer assigned)\n{unassigned}")) |
| 142 | + await self.send_message( |
| 143 | + channel='#envoy-maintainer-oncall', |
| 144 | + text=(f"*Stalled Issues* (Issues with review out-SLO, please address)\n{stalled}")) |
| 145 | + except SlackApiError as e: |
| 146 | + self.log.error(f"Unexpected error {e.response['error']}") |
| 147 | + |
| 148 | + def pr_message(self, age, pull): |
| 149 | + """Generate a pr message, bolding the time if it's out-SLO.""" |
| 150 | + days = age.days |
| 151 | + hours = age.seconds // 3600 |
| 152 | + markup = ("*" if age > self.slo_max else "") |
| 153 | + return ( |
| 154 | + f"<{pull['html_url']}|{html.escape(pull['title'])}> has been waiting " |
| 155 | + f"{markup}{days} days {hours} hours{markup}") |
| 156 | + |
| 157 | + async def run(self): |
| 158 | + if not self.github_token: |
| 159 | + self.log.error("Missing GITHUB_TOKEN: please check github workflow configuration") |
| 160 | + return 1 |
| 161 | + |
| 162 | + if not self.slack_bot_token and not self.dry_run: |
| 163 | + self.log.error( |
| 164 | + "Missing SLACK_BOT_TOKEN: please export token from " |
| 165 | + f"{SLACK_EXPORT_URL}") |
| 166 | + return 1 |
| 167 | + return await (self.notify()) |
| 168 | + |
| 169 | + async def send_message(self, channel, text): |
| 170 | + self.log.notice(f"Slack message ({channel}):\n{text}") |
| 171 | + if self.dry_run: |
| 172 | + return |
| 173 | + await self.slack_client.chat_postMessage(channel=channel, text=text) |
| 174 | + |
| 175 | +def main(*args): |
| 176 | + return RepoNotifier(*args)() |
| 177 | + |
| 178 | + |
| 179 | +if __name__ == "__main__": |
| 180 | + sys.exit(main(*sys.argv[1:])) |
0 commit comments