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