Syncing up with what's in production :-/
[margebot.git] / plugins / marge.py
1 """
2 Margebot: A Errbot Plugin for Gitlab MR reminders
3 """
4 from datetime import datetime, timezone
5 from time import sleep
6 from dateutil import parser
7 from dateutil.tz import tzutc
8 from dateutil.relativedelta import relativedelta
9 from errbot import BotPlugin, botcmd, webhook
10 from errcron.bot import CrontabMixin
11 import gitlab
12
13
14 def deltastr(any_delta):
15 """
16 Output a datetime delta in the format "x days, y hours, z minutes ago"
17 """
18 l_delta = []
19 days = any_delta.days
20 hours = any_delta.seconds // 3600
21 mins = (any_delta.seconds // 60) % 60
22
23 for (key, val) in [("day", days), ("hour", hours), ("minute", mins)]:
24 if val == 1:
25 l_delta.append("1 " + key)
26 elif val > 1:
27 l_delta.append("{} {}s".format(val, key))
28
29 if l_delta == []:
30 retval = "now"
31 else:
32 retval = ", ".join(l_delta) + " ago"
33 return retval
34
35
36 class Marge(BotPlugin, CrontabMixin):
37 """
38 I remind you about merge requests
39
40 Use:
41 In gitlab:
42 Add a merge request webook of the form
43 'https://webookserver/margeboot/<rooms>'
44 to the projects you want tracked. <rooms> should be a
45 comma-separated list of short room names (anything before the '@')
46 that you want notified.
47 In errbot:
48 Add <roomname>@domain to the CHATROOM_PRESENCE list in config.py for
49 rooms margebot should join
50 """
51
52 CRONTAB = [
53 # Set in config now: '0 11,17 * * * .crontab_hook' # 7:00AM and 1:00PM EST warnings
54 ]
55
56 def __init__(self, *args, **kwargs):
57 self.git_host = None
58 self.chatroom_host = None
59 self.gitlab = None
60 self.soak_delta = None
61 super().__init__(*args, **kwargs)
62
63 def get_configuration_template(self):
64 """
65 GITLAB_HOST: Host name of your gitlab server
66 GITLAB_ADMIN_TOKEN: PAT from an admin's https://${GIT_HOST}/profile/personal_access_tokens.
67 CHATROOM_HOST: Chatroom host. Usually 'chatroom' + FQDN of Jabber server
68 CRONTAB: Schedule of automated merge request checks in '%M %H %d %m %w' format
69 VERIFY_SSL : True, False, or path to CA cert to verify cert
70 CRONTAB_SOAK_HOURS : Don't send out reminders about MRs opened less than this many hours
71 """
72 return {'GITLAB_HOST': 'gitlab.example.com',
73 'GITLAB_ADMIN_TOKEN': 'gitlab-admin-user-private-token',
74 'CHATROOM_HOST': 'conference.jabber.example.com',
75 'CRONTAB': '0 11,17 * * *',
76 'VERIFY_SSL': True,
77 'CRONTAB_SOAK_HOURS': 1}
78
79 def check_configuration(self, configuration):
80 """
81 Check that the plugin has been configured properly
82 """
83 super().check_configuration(configuration)
84
85 def activate(self):
86 """
87 Initialization done when the plugin is activated
88 """
89 if not self.config:
90 self.log.info('Margebot is not configured. Forbid activation')
91 return
92 self.git_host = self.config['GITLAB_HOST']
93 self.chatroom_host = self.config['CHATROOM_HOST']
94 Marge.CRONTAB = ['{} .crontab_hook'.format(self.config['CRONTAB'])]
95 gitlab_auth_token = self.config['GITLAB_ADMIN_TOKEN']
96 verify_ssl = self.config['VERIFY_SSL']
97 self.gitlab = gitlab.Gitlab(self.git_host, gitlab_auth_token, verify_ssl=verify_ssl)
98 self.activate_crontab()
99
100 self.soak_delta = relativedelta(hours=self.config['CRONTAB_SOAK_HOURS'])
101 super().activate()
102
103 def deactivate(self):
104 """
105 Anything that needs to be tore down when the plugin is deactivated goes here.
106 """
107 super().deactivate()
108
109 @webhook('/margebot/<rooms>/')
110 def gitlab_hook(self, request, rooms):
111 """
112 Webhook that listens on http://<server>:<port>/gitlab
113 """
114
115 self.log.info("webhook: request: {}, rooms: {}".format(request, rooms))
116 self.log.info("state: {}".format(request['object_attributes']['state']))
117
118 # verify it's a merge request
119 if request['object_kind'] != 'merge_request':
120 self.log.error('unexpecting object_kind: {}'.format(request['object_kind']))
121 elif 'opened' in request['object_attributes']['state']:
122
123 if request['object_attributes']['work_in_progress']:
124 wip = "WIP "
125 else:
126 wip = ""
127 url = request['project']['homepage']
128 title = request['object_attributes']['title']
129
130 author_id = request['object_attributes']['author_id'] # map this to user name ...
131 author = self.gitlab.getuser(author_id)
132 if author:
133 author_name = author['username']
134 else:
135 self.log.info("unexpected author_id {}".format(author_id))
136 author_name = author_id
137
138 target_project_id = request['object_attributes']['target_project_id']
139 iid = request['object_attributes']['iid']
140 mr_id = request['object_attributes']['id']
141
142 msg_template = "Hi there ! {} has opened a new {}MR: \"{}\"\n{}/merge_requests/{}"
143 msg = msg_template.format(author_name, wip, title, url, iid)
144
145 if 'OPEN_MRS' not in self.keys():
146 empty_dict = {}
147 self['OPEN_MRS'] = empty_dict
148
149 open_mrs = self['OPEN_MRS']
150
151 if (target_project_id, mr_id, rooms) not in open_mrs:
152 for a_room in rooms.split(','):
153 if self.config:
154 self.send(self.build_identifier(a_room + '@' + self.chatroom_host), msg)
155
156 self.log.info("webhook: Saving ({}, {}, {})".format(target_project_id, mr_id, rooms))
157 open_mrs[(target_project_id, mr_id, rooms)] = True
158 self['OPEN_MRS'] = open_mrs
159 return "OK"
160
161 def mr_status_msg(self, a_mr, author=None):
162 """
163 Create the merge request status message
164 """
165 self.log.info("mr_status_msg: a_mr: {}".format(a_mr))
166
167 # Only weed out MRs less than the soak time for the crontab output (where author==None)
168 now = datetime.now(timezone.utc)
169 creation_time = parser.parse(a_mr['created_at'], tzinfos=tzutc)
170 if not author:
171 self.log.info("times: {}, {}, {}".format(creation_time, self.soak_delta, now))
172 if creation_time + self.soak_delta > now:
173 project_id = a_mr['target_project_id']
174 mr_id = a_mr['id']
175 soak_hours = self.config['CRONTAB_SOAK_HOURS']
176 info_template = "skipping: MR <{},{}> was opened less than {} hours ago"
177 info_msg = info_template.format(project_id, mr_id, soak_hours)
178 self.log.info(info_msg)
179 return None
180
181 str_open_since = deltastr(now - creation_time)
182
183 warning = ""
184 if a_mr['work_in_progress']:
185 warning = "still WIP"
186 elif a_mr['merge_status'] != 'can_be_merged':
187 warning = "there are merge conflicts"
188
189 if author:
190 authored = (a_mr['author']['id'] == author)
191 else:
192 authored = False
193
194 # getapprovals is only available in GitLab 8.9 EE or greater
195 # (not the open source CE version)
196 # approvals = self.gitlab.getapprovals(a_mr['id'])
197 # also_approved = ""
198 # for approved in approvals['approved_by']:
199 # also_approved += "," + approved['user']['name']
200
201 upvotes = a_mr['upvotes']
202 msg = "{} (opened {})".format(a_mr['web_url'], str_open_since)
203 if upvotes >= 2:
204 msg += ": Has 2+ upvotes and could be merged in now"
205 if warning != "":
206 msg += " except {}.".format(warning)
207 else:
208 msg += "."
209
210 elif upvotes == 1:
211 if authored:
212 msg += ": Your MR is waiting for another upvote"
213 else:
214 msg += ": Waiting for another upvote"
215 if warning != "":
216 msg += " but {}.".format(warning)
217 else:
218 msg += "."
219
220 else:
221 if authored:
222 msg += ": Your MR has no upvotes"
223 else:
224 msg += ": No upvotes, please review"
225 if warning != "":
226 msg += " but {}.".format(warning)
227 else:
228 msg += "."
229
230 return (creation_time, msg)
231
232 def crontab_hook(self, polled_time):
233 """
234 Send a scheduled message to the rooms margebot is watching
235 about open MRs the room cares about.
236 """
237
238 self.log.info("crontab_hook triggered at {}".format(polled_time))
239
240 reminder_msg = {} # Map of reminder_msg['roomname@domain'] = [(created_at,msg)]
241
242 # initialize the reminders
243 rooms = self.rooms()
244 for a_room in rooms:
245 reminder_msg[a_room.node] = []
246
247 msgs = ""
248 still_open_mrs = {}
249
250 # Let's walk through the MRs we've seen already:
251 open_mrs = self['OPEN_MRS']
252
253 for (project_id, mr_id, notify_rooms) in open_mrs:
254
255 # Lookup the MR from the project/id
256 a_mr = self.gitlab.getmergerequest(project_id, mr_id)
257
258 self.log.info("a_mr: {} {} {} {}".format(project_id, mr_id, notify_rooms, a_mr['state']))
259
260 # If the MR is no longer open, skip to the next MR,
261 # and don't include this MR in the next check
262 if 'opened' not in a_mr['state']:
263 continue
264 else:
265 still_open_mrs[(project_id, mr_id, notify_rooms)] = True
266
267 msg_tuple = self.mr_status_msg(a_mr)
268 if msg_tuple is None:
269 continue
270
271 for a_room in notify_rooms.split(','):
272 reminder_msg[a_room].append(msg_tuple)
273
274 self['OPEN_MRS'] = open_mrs
275
276 # Remind each of the rooms about open MRs
277 for a_room, room_msg_list in reminder_msg.items():
278 if room_msg_list != []:
279
280 # sort by the creation time
281 sorted_room_msg_list = sorted(room_msg_list, key=lambda x: x[0])
282
283 # extract the msgs from the tuple list
284 msgs = [x[1] for x in sorted_room_msg_list]
285
286 # join those msgs together.
287 room_msg = "\n".join(msgs)
288
289 if self.config:
290 msg_template = "These MRs need some attention:\n{}\n"
291 msg_template += "You can get an updated list with the !reviews command."
292 to_room = a_room + '@' + self.config['CHATROOM_HOST']
293 msg = msg_template.format(room_msg)
294 self.send(self.build_identifier(to_room), msg)
295
296 self['OPEN_MRS'] = still_open_mrs
297
298 @botcmd()
299 def reviews(self, msg, args):
300 """
301 Returns a list of MRs that are waiting for some luv.
302 Also returns a list of MRs that have had enough luv but aren't merged in yet.
303 """
304 # Sending directly to Margbot: sender in the form sender@....
305 # Sending to a chatroom: snder in the form room@rooms/sender
306
307 log_template = 'reviews: msg: {}, args: {}, \nmsg.frm: {}, \nmsg.msg.frm: {}, chatroom_host: {}'
308 self.log.info(log_template.format(msg, args, msg.frm.__dict__, dir(msg.frm), self.config['CHATROOM_HOST']))
309 self.log.info('reviews: bot mode: {}'.format(self._bot.mode))
310
311 if self._bot.mode == "xmpp":
312 if msg.frm.domain == self.config['CHATROOM_HOST']:
313 sender = msg.frm.resource
314 else:
315 sender = msg.frm.node
316 else:
317 sender = str(msg.frm).split('@')[0]
318
319 keys = self.keys()
320 if 'OPEN_MRS' not in keys:
321 self.log.error('OPEN_MRS not in {}'.format(keys))
322 return "No MRs to review"
323
324 sender_gitlab_id = None
325 sender_users = self.gitlab.getusers(search=(('username', sender)))
326 if not sender_users:
327 self.log.error('problem mapping {} to a gitlab user'.format(sender))
328 sender_gitlab_id = None
329 else:
330 sender_gitlab_id = sender_users[0]['id']
331
332 # Walk through the MRs we've seen already:
333 msg_list = []
334 msg = ""
335 still_open_mrs = {}
336 open_mrs = self['OPEN_MRS']
337 for (project, mr_id, notify_rooms) in open_mrs:
338
339 # Lookup the MR from the project/id
340 a_mr = self.gitlab.getmergerequest(project, mr_id)
341
342 # If the MR is no longer open, skip to the next MR,
343 # and don't include this MR in the next check
344 if 'opened' not in a_mr['state']:
345 continue
346 else:
347 still_open_mrs[(project, mr_id, notify_rooms)] = True
348
349 msg_tuple = self.mr_status_msg(a_mr, author=sender_gitlab_id)
350 if msg_tuple is None:
351 continue
352
353 msg_list.append(msg_tuple)
354
355 if msg_list == []:
356 response = 'Hi {}: {}'.format(sender, 'I found no open MRs for you.')
357 else:
358 # sort by the creation time
359 sorted_msg_list = sorted(msg_list, key=lambda x: x[0])
360
361 # extract the msgs from the tuple list
362 msgs = [x[1] for x in sorted_msg_list]
363
364 # join those msgs together.
365 msg = "\n".join(msgs)
366 response = 'Hi {}: These MRs need some attention:\n{}'.format(sender, msg)
367
368 self['OPEN_MRS'] = still_open_mrs
369
370 return response
371
372 # pragma pylint: disable=unused-argument
373 @botcmd()
374 def hello(self, msg, args):
375 """
376 A simple command to check if the bot is responding
377 """
378 return "Hi there"
379
380 @botcmd()
381 def xyzzy(self, msg, args):
382 """
383 Don't call this command...
384 """
385 yield "/me whispers \"All open MRs have been merged into master.\""
386 sleep(5)
387 yield "(just kidding)"
388
389 # pragma pylint: enable=unused-argument