Skip to content

Commit 81d03ab

Browse files
committed
tooling: kicking off setec tooling
Signed-off-by: Alyssa Wilk <alyssar@chromium.org>
1 parent d1ed0de commit 81d03ab

File tree

3 files changed

+227
-0
lines changed

3 files changed

+227
-0
lines changed

.github/workflows/setec_notifier.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
on:
2+
pull_request:
3+
workflow_dispatch:
4+
schedule:
5+
- cron: '0 5 * * 1,2,3,4,5'
6+
7+
permissions:
8+
contents: read # to fetch code (actions/checkout)
9+
10+
jobs:
11+
setec_notifier:
12+
permissions:
13+
contents: read # to fetch code (actions/checkout)
14+
statuses: read # for setec_notifier.py
15+
pull-requests: read # for setec_notifier.py
16+
issues: read # for setec_notifier.py
17+
name: PR Notifier
18+
runs-on: ubuntu-22.04
19+
if: >-
20+
${{
21+
github.repository == 'envoyproxy/envoy'
22+
&& (github.event.schedule
23+
|| !contains(github.actor, '[bot]'))
24+
}}
25+
steps:
26+
- uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4
27+
- name: Notify about issues
28+
run: |
29+
ARGS=()
30+
if [[ "${{ github.event_name }}" == 'pull_request' ]]; then
31+
ARGS+=(--dry_run)
32+
fi
33+
bazel run //tools/repo:setec-notify -- "${ARGS[@]}"
34+
env:
35+
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
36+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

tools/repo/BUILD

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,14 @@ envoy_pytool_binary(
1919
requirement("slack_sdk"),
2020
],
2121
)
22+
23+
envoy_pytool_binary(
24+
name = "notify-setec",
25+
srcs = ["notify-setec.py"],
26+
deps = [
27+
requirement("aio.api.github"),
28+
requirement("aio.run.runner"),
29+
requirement("icalendar"),
30+
requirement("slack_sdk"),
31+
],
32+
)

tools/repo/notify-setec.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
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

Comments
 (0)