-
Notifications
You must be signed in to change notification settings - Fork 5
/
Copy pathrotation.py
433 lines (358 loc) · 16.7 KB
/
rotation.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
import argparse
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import Enum
from googleapiclient.errors import HttpError
from pathlib import Path
import gcal
import logging
import pickle
import os
import random
DATE = datetime.now(timezone.utc)
SAVED_ROTATIONS_PATH = Path("rotations.pickle")
logger = logging.getLogger()
ch = logging.StreamHandler()
logger.addHandler(ch)
class Geo(Enum):
AMERICAS = "🌎"
EUROPE_AFRICA = "🌍"
ASIA_AUSTRALIA = "🌏"
@dataclass
class Person:
name: str
nick: str
geo: Geo
lead: bool = False
"""<cal_override> at mozilla is used to send the calendar invitations"""
cal_override: str = None
def __repr__(self):
return f"{self.name} [{self.nick}]"
def get_cal_nick(self):
return self.cal_override if self.cal_override else self.nick
@dataclass
class Rotation:
leader: Person
sheriffs: list
def __repr__(self):
return f"{self.leader}, {self.sheriffs}"
MEMBERS = [
Person("Andrew Creskey", "acreskey", Geo.AMERICAS, True),
Person("Bas Schouten", "bas", Geo.EUROPE_AFRICA, True, cal_override="bschouten"),
Person("Benjamin De Kosnik", "bdekoz", Geo.AMERICAS, True),
Person("Daniel Holbert", "dholbert", Geo.AMERICAS),
Person("Dave Hunt", "davehunt", Geo.EUROPE_AFRICA),
Person("Denis Palmeiro", "denispal", Geo.AMERICAS, True, cal_override="dpalmeiro"),
Person("Alex Thayer", "alexical", Geo.AMERICAS, True, cal_override="dothayer"),
Person("Florian Quèze", "florian", Geo.EUROPE_AFRICA, True),
Person(
"Gregory Mierzwinski", "sparky", Geo.AMERICAS, True, cal_override="gmierzwinski"
),
Person("Julien Wajsberg", "julienw", Geo.EUROPE_AFRICA, True),
Person("Marc Leclair", "mleclair", Geo.AMERICAS),
Person("Markus Stange", "mstange", Geo.AMERICAS, True),
Person("Mike Conley", "mconley", Geo.AMERICAS),
Person("Nazim Can Altinova", "canova", Geo.EUROPE_AFRICA, cal_override="naltinova"),
Person("Olli Pettay", "smaug", Geo.EUROPE_AFRICA),
Person("Randell Jesup", "jesup", Geo.AMERICAS, cal_override="rjesup"),
Person("Sean Feng", "sefeng", Geo.AMERICAS, True),
Person("Frank Doty", "frankdoty", Geo.AMERICAS, cal_override="fdoty"),
Person("Andrej Glavic", "andrej", Geo.AMERICAS, cal_override="aglavic"),
Person("Kash Shampur", "kshampur", Geo.AMERICAS),
Person("Justin Link", "jlink", Geo.AMERICAS),
Person(
"Emilio Cobos Álvarez", "emilio", Geo.EUROPE_AFRICA, cal_override="ealvarez"
),
Person("Iain Ireland", "iain", Geo.AMERICAS, cal_override="iireland"),
Person("Adam Brouwers-Harries", "aabh", Geo.EUROPE_AFRICA, cal_override="abrouwersharries"),
]
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument("--debug", action="store_true", help="Run in debug mode (extra logging)")
parser.add_argument("--force", action="store_true", help="Ignore cached rotations and regenerate")
parser.add_argument(
"--production",
action="store_true",
help=(
"if set, performs all actions as if running in CI including "
"adding calendar invites. This is not enabled by default to ease "
"development, i.e. so we're not adding calendar invites every time"
),
)
return parser.parse_args()
def load_rotations():
try:
with SAVED_ROTATIONS_PATH.open(mode="rb") as html:
rotations = pickle.load(html)
logger.debug(f"Loading cached rotations from {SAVED_ROTATIONS_PATH}")
if isinstance(rotations, list):
# migrate to dict format with dates
rotations_dict = {}
for rotation in reversed(rotations):
week = get_week(DATE - timedelta(weeks=len(rotations_dict) - 1))
rotations_dict.setdefault(week, rotation)
return rotations_dict
except FileNotFoundError:
logger.debug("Cached rotations not found")
rotations = {}
return rotations
def generate_html(rotations):
path = Path("docs")
path.mkdir(exist_ok=True)
fpath = (path / "index.html").with_suffix(".html")
this_week = rotations[get_week(DATE)]
next_week = rotations[get_week(DATE + timedelta(weeks=1))]
html = """
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Performance Triage: Rotation</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-gH2yIJqKdNHPEq0n4Mqa/HGKIhSkIHeL5AyhkYV8i59U5AR6csBvApHHNl/vI1Bx" crossorigin="anonymous">
</head>
<body>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"
integrity="sha384-A3rJD856KowSb7dwlZdYEkO39Gagi7vIsF0jrRAoQmDKKtQBHUuLZ9AsSv4jD4Xa"
crossorigin="anonymous"></script>
<div class="container">
<header class="d-flex flex-wrap justify-content-center py-3 mb-4 border-bottom">
<a href="/" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-dark text-decoration-none">
<svg class="bi me-2" width="40" height="32">
<use xlink:href="#bootstrap"></use>
</svg>
<span class="fs-4">Performance Triage: Rotation</span>
</a>
<ul class="nav nav-pills">
<li class="nav-item"><a href="calculator.html" class="nav-link">Impact Calculator</a></li>
<li class="nav-item"><a href="#" class="nav-link active" aria-current="page">Rotation</a>
</li>
</ul>
<a class="mx-3 d-flex" href="https://github.com/mozilla/perf-triage" target="_blank" rel="noopener noreferrer" title="Go to the Git repository (this opens in a new window)">
<svg width="22" height="22" class="octicon octicon-mark-github m-auto" viewBox="0 0 16 16" version="1.1" aria-label="github"><path fill-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"></path></svg>
</a>
</header>
</div>
<div class="container">
<div class="row">
<div class="col-sm-6">
<div class="card mb-3">
<div class="card-header text-bg-primary">
This week
</div>
<ol class="list-group list-group-flush">
<li class="list-group-item"><strong>{{this_week.leader}}</strong></li>
{% for sheriff in this_week.sheriffs %}<li class="list-group-item">{{sheriff}}</li>
{% endfor %}
</ol>
</div>
</div>
<div class="col-sm-6">
<div class="card mb-3">
<div class="card-header text-bg-secondary">
Next week
</div>
<ol class="list-group list-group-flush">
<li class="list-group-item"><strong>{{next_week.leader}}</strong></li>
{% for sheriff in next_week.sheriffs %}<li class="list-group-item">{{sheriff}}</li>
{% endfor %}
</ol>
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="card mb-3">
<div class="card-header">
History
</div>
<ul class="list-group list-group-flush">
{% for date in history %}<li class="list-group-item">{{ date }}: <strong>{{ history[date].leader }}</strong>, {{ history[date].sheriffs }}</li>
{% endfor %}
</ul>
</div>
</div>
</div>
<div class="mb-3">Generated on {{timestamp}}.</div>
</div>
</body>
</html>"""
import jinja2
environment = jinja2.Environment()
template = environment.from_string(html)
history = dict(sorted(rotations.items(), reverse=True)[2:])
with fpath.open(mode="w+") as html:
html.write(
template.render(
this_week=this_week,
next_week=next_week,
history=history,
timestamp=DATE,
)
)
def generate_rotation(leaders, rotations):
leader_candidates = leaders.copy()
sheriff_candidates = MEMBERS.copy()
# remove recent leaders from pool
for w, r in list(sorted(rotations.items()))[(len(leaders) // 2) * -1 :]:
if r.leader in leader_candidates:
logger.debug(f"Removing {r.leader} from leader pool because they led on {w}")
leader_candidates.remove(r.leader)
# remove recent sheriffs from pools
for w, r in list(sorted(rotations.items()))[-4:]:
if r.leader in sheriff_candidates:
logger.debug(f"Removing {r.leader} from sheriff pool because they sheriffed on {w}")
sheriff_candidates.remove(r.leader)
for sheriff in r.sheriffs:
if sheriff in leader_candidates:
logger.debug(f"Removing {sheriff} from leader pool because they sheriffed on {w}")
leader_candidates.remove(sheriff)
if sheriff in sheriff_candidates:
logger.debug(f"Removing {sheriff} from sheriff pool because they sheriffed on {w}")
sheriff_candidates.remove(sheriff)
# pick a leader
leader = random.choice(leader_candidates)
logger.debug(f"Picked {leader} as leader")
# remove leader from pool
sheriff_candidates.remove(leader)
logger.debug(f"Removed {leader} from sheriff pool because they have been picked as the leader")
# pick sheriffs from pool
sheriffs = []
for _ in range(2):
geos = {s.geo for s in [leader] + sheriffs}
if len(geos) > 1:
# remove sheriffs outside of selected geos from pool
for sheriff in [c for c in sheriff_candidates if c.geo not in geos]:
logger.debug(f"Removing {sheriff} from pool as they are in {sheriff.geo} and leader/sheriffs are in {geos}")
sheriff_candidates.remove(sheriff)
if len(sheriff_candidates) > 0:
random.shuffle(sheriff_candidates)
sheriff = sheriff_candidates.pop()
sheriffs.append(sheriff)
logger.debug(f"Picked {sheriff} as sheriff")
return Rotation(leader, sheriffs)
def get_addresses_from_rotation(rotation):
attendees = [rotation.leader] + [s for s in rotation.sheriffs]
return [a.get_cal_nick() + "@mozilla.com" for a in attendees]
def add_gcal_reminder(is_production, rotation, generated_next_week):
"""Adds a triage reminder event to the Performance Team Google Calendar based on the rotation.
See the top-of-file comment in gcal.py for requirements to run this function.
This function may raise HttpError for google API or network failures and
FileNotFoundError if the GCloud Project secrets are missing.
"""
# TODO: the states (is_production, generated_next_week, & CI envvar) seem confusing - is there a cleaner solution?
dry_run_mode = False
if not generated_next_week:
dry_run_mode = True
print(
(
"INFO: for this script to be idempotent, we only add calendar reminders when we "
"generate new rotations for next week. We did not generate it this time so we "
"run add_gcal_reminder in dry run mode."
)
)
if not is_production:
dry_run_mode = True
print(
"INFO: --production was not specified so running add_gcal_reminder in dry run mode."
)
if not dry_run_mode:
credentials = gcal.auth_as_user()
service = gcal.get_calendar_service(credentials)
# N.B.: for simplicity, this code assumes this script executes on Monday
# or Tuesday the week before we want to set the reminder.
#
# Our reminders appear one week in advance. Note that we don't use the time fields.
reminder_date = DATE + timedelta(weeks=1)
# Show the reminder on Tuesday: we want the reminder to appear as early in
# the week as possible but add a buffer day in case Monday is a holiday.
while reminder_date.weekday() != 1: # 1 == Tuesday
reminder_date += timedelta(days=1)
# To save time in changing the original implementation,
# send_triage_reminder takes a yyyy-mm-dd instead of a datetime.
reminder_date = reminder_date.strftime("%Y-%m-%d")
addresses = get_addresses_from_rotation(rotation)
if not dry_run_mode:
gcal.send_triage_reminder(service, reminder_date, addresses)
else:
if os.getenv("CI"):
# Don't print full email addresses to CI logs.
addresses = [a.split("@")[0] for a in addresses]
print(
(
"\nadd_gcal_reminder dry-run mode: would have created calendar "
"invite with date {} and invitees {}"
).format(reminder_date, addresses)
)
def add_gcal_reminder_manually(date):
datetime.strptime(date, "%Y-%m-%d") # Throws if date format is unexpected.
rotations = load_rotations()
this_week = rotations[get_week(DATE)]
next_week = rotations[get_week(DATE + timedelta(weeks=1))]
print(f"\nWhich rotation would you like to schedule for {date}?")
print("\n(1) This week:")
print(this_week)
print("\n(2) Next week:")
print(str(next_week) + "\n")
selected_rotation = None
while selected_rotation not in ["1", "2"]:
print("Select rotation (1/2):")
selected_rotation = input().strip()
addresses = get_addresses_from_rotation(
this_week if selected_rotation == "1" else next_week
)
credentials = gcal.auth_as_user()
service = gcal.get_calendar_service(credentials)
gcal.send_triage_reminder(service, date, addresses)
def get_week(date):
week = datetime.fromisocalendar(*date.isocalendar()[:2], day=1)
return week.strftime("%Y-%m-%d")
def main():
args = parse_args()
logger.setLevel(logging.DEBUG if args.debug else logging.INFO)
rotations = load_rotations()
leaders = [m for m in MEMBERS if m.lead]
while len(rotations) < len(leaders):
# create some history to improve selection
week = get_week(DATE - timedelta(weeks=(len(leaders) - len(rotations))))
rotations.setdefault(week, generate_rotation(leaders, rotations))
print(f"Generated on {DATE}")
print("\nThis week:")
this_week = get_week(DATE)
if args.force or not rotations.get(this_week):
rotations[this_week] = generate_rotation(leaders, rotations)
print(f"{this_week}: {rotations[this_week]}")
print("\nNext week:")
next_week = get_week(DATE + timedelta(weeks=1))
generated_next_week = False
if args.force or not rotations.get(next_week):
generated_next_week = True
rotations[next_week] = generate_rotation(leaders, rotations)
print(f"{next_week}: {rotations[next_week]}")
print("\nHistory:")
for week, rotation in list(sorted(rotations.items(), reverse=True))[2:]:
print(f"{week}: {rotation}")
generate_html(rotations)
with SAVED_ROTATIONS_PATH.open(mode="wb") as f:
pickle.dump(rotations, f)
print("") # Add a newline between rotation output and calendar reminder output.
try:
add_gcal_reminder(args.production, rotations[next_week], generated_next_week)
except Exception as err:
# If this script fails (returns a non-zero exit code), the triage rotation website will not
# get updated. Since contacting the network to send a reminder can hit a lot of errors, we
# catch all exceptions to be safe and avoid this possibility.
# TODO: separate this into a separate build task to make generate rotation even safer.
if type(err) is HttpError:
logging.exception(
"during network request when adding google calendar reminder"
)
elif type(err) is FileNotFoundError:
logging.exception(
"unable to locate Google Cloud Project secrets when adding google calendar reminder"
)
else:
# Exception types include gcal.CredentialException & GoogleAuthError
logging.exception("cause unknown")
if __name__ == "__main__":
main()