- Factoring out common code between crontab/reviews commands
[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
141 msg_template = "New Review: {} has opened a new {}MR: \"{}\"\n{}/merge_requests/{}"
142 msg = msg_template.format(author_name, wip, title, url, iid)
143
144 for a_room in rooms.split(','):
145 if self.config:
146 self.send(self.build_identifier(a_room + '@' + self.chatroom_host), msg)
147
148 if 'OPEN_MRS' not in self.keys():
149 empty_dict = {}
150 self['OPEN_MRS'] = empty_dict
151
152 open_mrs = self['OPEN_MRS']
153 self.log.info("webhook: Saving ({}, {}, {})".format(target_project_id, iid, rooms))
154 open_mrs[(target_project_id, id, rooms)] = True
155 self['OPEN_MRS'] = open_mrs
156
157 return "OK"
158
159 def mr_status_msg(self, a_mr, author=None):
160 """
161 Create the merge request status message
162 """
163 self.log.info("mr_status_msg: a_mr: {}".format(a_mr))
164
165 now = datetime.now(timezone.utc)
166 creation_time = parser.parse(a_mr['created_at'], tzinfos=tzutc)
167 self.log.info("times: {}, {}, {}".format(creation_time, self.soak_delta, now))
168 if creation_time + self.soak_delta > now:
169 project = a_mr['project']
170 iid = a_mr['iid']
171 soak_hours = self.config['CRONTAB_SOAK_HOURS']
172 info_template = "skipping: MR <{},{}> was opened less than {} hours ago"
173 info_msg = info_template.format(project, iid, soak_hours)
174 self.log.info(info_msg)
175 return None
176
177 str_open_since = deltastr(now - creation_time)
178
179 warning = ""
180 if a_mr['work_in_progress']:
181 warning = "still WIP"
182 elif a_mr['merge_status'] != 'can_be_merged':
183 warning = "there are merge conflicts"
184
185 if author:
186 authored = (a_mr['author']['id'] == author)
187 else:
188 authored = False
189
190 # getapprovals is only available in GitLab 8.9 EE or greater
191 # (not the open source CE version)
192 # approvals = self.gitlab.getapprovals(a_mr['id'])
193 # also_approved = ""
194 # for approved in approvals['approved_by']:
195 # also_approved += "," + approved['user']['name']
196
197 upvotes = a_mr['upvotes']
198 msg = "{} (opened {})".format(a_mr['web_url'], str_open_since)
199 if upvotes >= 2:
200 msg += ": Has 2+ upvotes and could be merged in now"
201 if warning != "":
202 msg += " except {}.".format(warning)
203 else:
204 msg += "."
205
206 elif upvotes == 1:
207 if authored:
208 msg += ": Your MR is waiting for another upvote"
209 else:
210 msg += ": Waiting for another upvote"
211 if warning != "":
212 msg += " but {}.".format(warning)
213 else:
214 msg += "."
215
216 else:
217 if authored:
218 msg += ": Your MR has no upvotes"
219 else:
220 msg += ": No upvotes, please review"
221 if warning != "":
222 msg += " but {}.".format(warning)
223 else:
224 msg += "."
225
226 return (creation_time, msg)
227
228 def crontab_hook(self, polled_time):
229 """
230 Send a scheduled message to the rooms margebot is watching
231 about open MRs the room cares about.
232 """
233
234 self.log.info("crontab_hook triggered at {}".format(polled_time))
235
236 reminder_msg = {} # Map of reminder_msg['roomname@domain'] = [(created_at,msg)]
237
238 # initialize the reminders
239 rooms = self.rooms()
240 for a_room in rooms:
241 reminder_msg[a_room.node] = []
242
243 msgs = ""
244 still_open_mrs = {}
245
246 # Let's walk through the MRs we've seen already:
247 open_mrs = self['OPEN_MRS']
248
249 for (project, iid, notify_rooms) in open_mrs:
250
251 # Lookup the MR from the project/iid
252 a_mr = self.gitlab.getmergerequest(project, iid)
253
254 self.log.info("a_mr: {} {} {} {}".format(project, iid, notify_rooms, a_mr['state']))
255
256 # If the MR is no longer open, skip to the next MR,
257 # and don't include this MR in the next check
258 if 'opened' not in a_mr['state']:
259 continue
260 else:
261 still_open_mrs[(project, iid, notify_rooms)] = True
262
263 msg_tuple = self.mr_status_msg(a_mr)
264 if msg_tuple is None:
265 continue
266
267 for a_room in notify_rooms.split(','):
268 reminder_msg[a_room].append(msg_tuple)
269
270 self['OPEN_MRS'] = open_mrs
271
272 # Remind each of the rooms about open MRs
273 for a_room, room_msg_list in reminder_msg.items():
274 if room_msg_list != []:
275
276 # sort by the creation time
277 sorted_room_msg_list = sorted(room_msg_list, key=lambda x: x[0])
278
279 # extract the msgs from the tuple list
280 msgs = [x[1] for x in sorted_room_msg_list]
281
282 # join those msgs together.
283 room_msg = "\n".join(msgs)
284
285 if self.config:
286 msg_template = "These MRs need some attention:{}\n"
287 msg_template += "You can get an updated list with the !reviews command."
288 to_room = a_room + '@' + self.config['CHATROOM_HOST']
289 msg = msg_template.format(room_msg)
290 self.send(self.build_identifier(to_room), msg)
291
292 self['OPEN_MRS'] = still_open_mrs
293
294 @botcmd()
295 def reviews(self, msg, args):
296 """
297 Returns a list of MRs that are waiting for some luv.
298 Also returns a list of MRs that have had enough luv but aren't merged in yet.
299 """
300 # Sending directly to Margbot: sender in the form sender@....
301 # Sending to a chatroom: snder in the form room@rooms/sender
302
303 log_template = 'reviews: msg: {}, args: {}, \nmsg.frm: {}, \nmsg.msg.frm: {}, chatroom_host: {}'
304 self.log.info(log_template.format(msg, args, msg.frm.__dict__, dir(msg.frm), self.config['CHATROOM_HOST']))
305 self.log.info('reviews: bot mode: {}'.format(self._bot.mode))
306
307 if self._bot.mode == "xmpp":
308 if msg.frm.domain == self.config['CHATROOM_HOST']:
309 sender = msg.frm.resource
310 else:
311 sender = msg.frm.node
312 else:
313 sender = str(msg.frm).split('@')[0]
314
315 keys = self.keys()
316 if 'OPEN_MRS' not in keys:
317 self.log.error('OPEN_MRS not in {}'.format(keys))
318 return "No MRs to review"
319
320 sender_gitlab_id = None
321 sender_users = self.gitlab.getusers(search=(('username', sender)))
322 if not sender_users:
323 self.log.error('problem mapping {} to a gitlab user'.format(sender))
324 sender_gitlab_id = None
325 else:
326 sender_gitlab_id = sender_users[0]['id']
327
328 # Walk through the MRs we've seen already:
329 msg_list = []
330 msg = ""
331 still_open_mrs = {}
332 open_mrs = self['OPEN_MRS']
333 for (project, iid, notify_rooms) in open_mrs:
334
335 # Lookup the MR from the project/iid
336 a_mr = self.gitlab.getmergerequest(project, iid)
337
338 # If the MR is no longer open, skip to the next MR,
339 # and don't include this MR in the next check
340 if 'opened' not in a_mr['state']:
341 continue
342 else:
343 still_open_mrs[(project, iid, notify_rooms)] = True
344
345 msg_tuple = self.mr_status_msg(a_mr, author=sender_gitlab_id)
346 if msg_tuple is None:
347 continue
348
349 msg_list.append(msg_tuple)
350
351 if msg_list == []:
352 response = 'Hi {}: {}'.format(sender, 'I found no open MRs for you.')
353 else:
354 # sort by the creation time
355 sorted_msg_list = sorted(msg_list, key=lambda x: x[0])
356
357 # extract the msgs from the tuple list
358 msgs = [x[1] for x in sorted_msg_list]
359
360 # join those msgs together.
361 msg = "\n".join(msgs)
362 response = 'Hi {}: These MRs need some attention:\n{}'.format(sender, msg)
363
364 self['OPEN_MRS'] = still_open_mrs
365
366 return response
367
368 # pragma pylint: disable=unused-argument
369 @botcmd()
370 def hello(self, msg, args):
371 """
372 A simple command to check if the bot is responding
373 """
374 return "Hi there"
375
376 @botcmd()
377 def xyzzy(self, msg, args):
378 """
379 Don't call this command...
380 """
381 yield "/me whispers \"All open MRs have been merged into master.\""
382 sleep(5)
383 yield "(just kidding)"
384
385 # pragma pylint: enable=unused-argument