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