-
Notifications
You must be signed in to change notification settings - Fork 4.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Signed-off-by: Alyssa Wilk <alyssar@chromium.org>
- Loading branch information
1 parent
d1ed0de
commit 81d03ab
Showing
3 changed files
with
227 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
on: | ||
pull_request: | ||
workflow_dispatch: | ||
schedule: | ||
- cron: '0 5 * * 1,2,3,4,5' | ||
|
||
permissions: | ||
contents: read # to fetch code (actions/checkout) | ||
|
||
jobs: | ||
setec_notifier: | ||
permissions: | ||
contents: read # to fetch code (actions/checkout) | ||
statuses: read # for setec_notifier.py | ||
pull-requests: read # for setec_notifier.py | ||
issues: read # for setec_notifier.py | ||
name: PR Notifier | ||
runs-on: ubuntu-22.04 | ||
if: >- | ||
${{ | ||
github.repository == 'envoyproxy/envoy' | ||
&& (github.event.schedule | ||
|| !contains(github.actor, '[bot]')) | ||
}} | ||
steps: | ||
- uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 | ||
- name: Notify about issues | ||
run: | | ||
ARGS=() | ||
if [[ "${{ github.event_name }}" == 'pull_request' ]]; then | ||
ARGS+=(--dry_run) | ||
fi | ||
bazel run //tools/repo:setec-notify -- "${ARGS[@]}" | ||
env: | ||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} | ||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,180 @@ | ||
# Script for keeping track of setec issues. | ||
# | ||
# bazel run //tools/repo:notify-setec | ||
# | ||
# The tool can be used in `--dry_run` mode and show what it would post to slack | ||
|
||
import datetime | ||
import html | ||
import icalendar | ||
import json | ||
import os | ||
import sys | ||
from datetime import datetime as dt | ||
from functools import cached_property | ||
|
||
import aiohttp | ||
|
||
from slack_sdk.web.async_client import AsyncWebClient | ||
from slack_sdk.errors import SlackApiError | ||
|
||
from aio.api import github as github | ||
from aio.core.functional import async_property | ||
from aio.run import runner | ||
|
||
ENVOY_REPO = "envoyproxy/envoy-setec" | ||
|
||
SLACK_EXPORT_URL = "https://api.slack.com/apps/A023NPQQ33K/oauth?" | ||
|
||
class RepoNotifier(runner.Runner): | ||
|
||
@property | ||
def dry_run(self): | ||
return self.args.dry_run | ||
|
||
@cached_property | ||
def github(self): | ||
return github.GithubAPI(self.session, "", oauth_token=self.github_token) | ||
|
||
@cached_property | ||
def github_token(self): | ||
return os.getenv('GITHUB_TOKEN') | ||
|
||
@async_property | ||
async def issues(self): | ||
async for issue in self.repo.getiter("issues"): | ||
skip = not "issue" in issue["html_url"] | ||
if skip: | ||
self.log.notice(f"Skipping {issue['title']} {issue['url']}") | ||
continue | ||
yield issue | ||
|
||
@cached_property | ||
def repo(self): | ||
return self.github[ENVOY_REPO] | ||
|
||
@cached_property | ||
def session(self): | ||
return aiohttp.ClientSession() | ||
|
||
@async_property(cache=True) | ||
async def shepherd_notifications(self): | ||
return (await self.tracked_issues)["shepherd_notifications"] | ||
|
||
@cached_property | ||
def slack_client(self): | ||
return AsyncWebClient(token=self.slack_bot_token) | ||
|
||
@cached_property | ||
def slack_bot_token(self): | ||
return os.getenv('SLACK_BOT_TOKEN') | ||
|
||
@async_property(cache=True) | ||
async def assignee_and_issues(self): | ||
return (await self.tracked_issues)["assignee_and_issues"] | ||
|
||
# Allow for 1w for updates. | ||
# This can be tightened for cve issues near release time. | ||
@cached_property | ||
def slo_max(self): | ||
hours = 168 | ||
return datetime.timedelta(hours=hours) | ||
|
||
@async_property(cache=True) | ||
async def stalled_issues(self): | ||
return (await self.tracked_issues)["stalled_issues"] | ||
|
||
@async_property(cache=True) | ||
async def tracked_issues(self): | ||
# A dict of assignee : outstanding_issue to be sent to slack | ||
# A placeholder for unassigned issuess, to be sent to #assignee eventually | ||
assignee_and_issues = dict(unassigned=[]) | ||
# Out-SLO issues to be sent to #envoy-setec | ||
stalled_issues = [] | ||
|
||
async for issue in self.issues: | ||
updated_at = dt.fromisoformat(issue["updated_at"].replace('Z', '+00:00')) | ||
age = dt.now(datetime.timezone.utc) - dt.fromisoformat( | ||
issue["updated_at"].replace('Z', '+00:00')) | ||
message = self.pr_message(age, issue) | ||
is_approved = "patch:approved" in [label["name"] for label in issue["labels"]]; | ||
|
||
# If the PR has been out-SLO for over a day, inform on-call | ||
if age > self.slo_max + datetime.timedelta(hours=36) and not is_approved: | ||
stalled_issues.append(message) | ||
|
||
has_assignee = False | ||
for assignee in issue["assignees"]: | ||
has_assignee = True | ||
assignee_and_issues[assignee["login"]] = assignee_and_issues.get( | ||
assignee["login"], []) | ||
assignee_and_issues[assignee["login"]].append(message) | ||
|
||
# If there was no assignee, track it as unassigned. | ||
if not has_assignee: | ||
assignee_and_issues['unassigned'].append(message) | ||
|
||
return dict( | ||
assignee_and_issues=assignee_and_issues, | ||
stalled_issues=stalled_issues) | ||
|
||
@async_property(cache=True) | ||
async def unassigned_issues(self): | ||
return (await self.assignee_and_issues)["unassigned"] | ||
|
||
def add_arguments(self, parser) -> None: | ||
super().add_arguments(parser) | ||
parser.add_argument( | ||
'--dry_run', | ||
action="store_true", | ||
help="Dont post slack messages, just show what would be posted") | ||
|
||
async def notify(self): | ||
await self.post_to_oncall() | ||
|
||
async def post_to_oncall(self): | ||
try: | ||
unassigned = "\n".join(await self.unassigned_issues) | ||
stalled = "\n".join(await self.stalled_issues) | ||
await self.send_message( | ||
channel='#envoy-maintainer-oncall', | ||
text=(f"*'Unassigned' Issues* (Issues with no maintainer assigned)\n{unassigned}")) | ||
await self.send_message( | ||
channel='#envoy-maintainer-oncall', | ||
text=(f"*Stalled Issues* (Issues with review out-SLO, please address)\n{stalled}")) | ||
except SlackApiError as e: | ||
self.log.error(f"Unexpected error {e.response['error']}") | ||
|
||
def pr_message(self, age, pull): | ||
"""Generate a pr message, bolding the time if it's out-SLO.""" | ||
days = age.days | ||
hours = age.seconds // 3600 | ||
markup = ("*" if age > self.slo_max else "") | ||
return ( | ||
f"<{pull['html_url']}|{html.escape(pull['title'])}> has been waiting " | ||
f"{markup}{days} days {hours} hours{markup}") | ||
|
||
async def run(self): | ||
if not self.github_token: | ||
self.log.error("Missing GITHUB_TOKEN: please check github workflow configuration") | ||
return 1 | ||
|
||
if not self.slack_bot_token and not self.dry_run: | ||
self.log.error( | ||
"Missing SLACK_BOT_TOKEN: please export token from " | ||
f"{SLACK_EXPORT_URL}") | ||
return 1 | ||
return await (self.notify()) | ||
|
||
async def send_message(self, channel, text): | ||
self.log.notice(f"Slack message ({channel}):\n{text}") | ||
if self.dry_run: | ||
return | ||
await self.slack_client.chat_postMessage(channel=channel, text=text) | ||
|
||
def main(*args): | ||
return RepoNotifier(*args)() | ||
|
||
|
||
if __name__ == "__main__": | ||
sys.exit(main(*sys.argv[1:])) |