2 Margebot: A Errbot Plugin for Gitlab MR reminders
4 from datetime
import datetime
, timezone
5 from dateutil
import parser
6 from dateutil
.tz
import tzutc
7 from dateutil
.relativedelta
import relativedelta
8 from errbot
import BotPlugin
, botcmd
, arg_botcmd
, re_botcmd
, webhook
9 from errbot
.templating
import tenv
10 from errcron
.bot
import CrontabMixin
14 def deltastr(any_delta
):
16 Output a datetime delta in the format "x days, y hours, z minutes ago"
20 hours
= any_delta
.seconds
// 3600
21 mins
= (any_delta
.seconds
// 60) % 60
23 for (key
, val
) in [("day", days
), ("hour", hours
), ("minute", mins
)]:
25 l_delta
.append("1 " + key
)
27 l_delta
.append("{} {}s".format(val
, key
))
32 retval
= ", ".join(l_delta
) + " ago"
36 class Marge(BotPlugin
, CrontabMixin
):
38 I remind you about merge requests
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.
48 Add <roomname>@domain to the CHATROOM_PRESENCE list in config.py for
49 rooms margebot should join
53 # Set in config now: '0 11,17 * * * .crontab_hook' # 7:00AM and 1:00PM EST warnings
56 def __init__(self
, *args
, **kwargs
):
58 self
.chatroom_host
= None
60 self
.soak_delta
= None
61 self
.webhook_url
= None
62 super().__init
__(*args
, **kwargs
)
64 def get_configuration_template(self
):
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 WEBHOOK_URL : URL to use for defining MR integration in gitlab
74 return {'GITLAB_HOST': 'gitlab.example.com',
75 'GITLAB_ADMIN_TOKEN': 'gitlab-admin-user-private-token',
76 'CHATROOM_HOST': 'conference.jabber.example.com',
77 'CRONTAB': '0 11,17 * * *',
79 'CRONTAB_SOAK_HOURS': 1,
80 'WEBHOOK_URL': 'https://webhooks.example.com:3142/margebot/'}
82 def check_configuration(self
, configuration
):
84 Check that the plugin has been configured properly
86 super().check_configuration(configuration
)
90 Initialization done when the plugin is activated
93 self
.log
.info('Margebot is not configured. Forbid activation')
95 self
.git_host
= self
.config
['GITLAB_HOST']
96 self
.chatroom_host
= self
.config
['CHATROOM_HOST']
97 Marge
.CRONTAB
= ['{} .crontab_hook'.format(self
.config
['CRONTAB'])]
98 gitlab_auth_token
= self
.config
['GITLAB_ADMIN_TOKEN']
99 verify_ssl
= self
.config
['VERIFY_SSL']
100 self
.gitlab
= gitlab
.Gitlab('https://' + self
.git_host
, gitlab_auth_token
, ssl_verify
=verify_ssl
)
101 self
.activate_crontab()
103 self
.soak_delta
= relativedelta(hours
=self
.config
['CRONTAB_SOAK_HOURS'])
104 self
.webhook_url
= self
.config
['WEBHOOK_URL']
105 if self
.webhook_url
[-1] != '/':
106 self
.webhook_url
+= '/'
109 def deactivate(self
):
111 Anything that needs to be tore down when the plugin is deactivated goes here.
115 @webhook('/margebot/<rooms>/')
116 def gitlab_hook(self
, request
, rooms
):
118 Webhook that listens on http://<server>:<port>/gitlab
121 self
.log
.info("webhook: request: {}, rooms: {}".format(request
, rooms
))
122 # self.log.info("state: {}".format(request['object_attributes']['state']))
124 # verify it's a merge request
125 if request
['event_type'] != 'merge_request':
126 self
.log
.error('unexpecting event_type: {}'.format(request
['event_type']))
127 elif 'opened' in request
['object_attributes']['state']:
129 if request
['object_attributes']['work_in_progress']:
134 url
= request
['project']['homepage']
135 title
= request
['object_attributes']['title']
136 author_id
= request
['object_attributes']['author_id'] # map this to user name ...
138 author
= self
.gitlab
.users
.get(author_id
)
139 author_name
= author
.attributes
['username']
140 except Exception as exp
:
141 self
.log
.info("unexpected author_id {}, exp={}".format(author_id
, exp
))
142 author_name
= author_id
144 target_project_id
= request
['object_attributes']['target_project_id']
145 iid
= request
['object_attributes']['iid']
147 # If the MR is tagged 'never-close' or 'abandoned' ignore it
148 if 'labels' in request
:
149 for a_label
in request
['labels']:
150 if a_label
['title'] in ['never-close', 'abandoned']:
151 self
.log
.info("Skipping {} notice for {} MR".format(a_label
['title'], url
))
154 msg_template
= "Hi there ! {} has opened a new {}MR: \"{}\"\n{}/merge_requests/{}"
155 msg
= msg_template
.format(author_name
, wip
, title
, url
, iid
)
157 if 'OPEN_MRS' not in self
.keys():
159 self
['OPEN_MRS'] = empty_dict
161 open_mrs
= self
['OPEN_MRS']
163 if (target_project_id
, iid
, rooms
) not in open_mrs
:
164 for a_room
in rooms
.split(','):
166 self
.send(self
.build_identifier(a_room
+ '@' + self
.chatroom_host
), msg
)
168 self
.log
.info("webhook: Saving ({}, {}, {})".format(target_project_id
, iid
, rooms
))
169 open_mrs
[(target_project_id
, iid
, rooms
)] = True
170 self
['OPEN_MRS'] = open_mrs
172 # TODO: Add check if an MR has toggled the WIP indicator
173 # (trigger on updates (what's that look like in request['object_attributes']['state'])
174 # Then check in request['changes']['title']['previous'] starts with 'WIP:'
175 # but not request['changes']['title']['current'], and vice versa
176 # See https://gitlab.com/gitlab-org/gitlab-ce/issues/53529
180 def mr_status_msg(self
, a_mr
, author
=None):
182 Create the merge request status message
184 self
.log
.info("mr_status_msg: a_mr: {}".format(a_mr
))
185 mr_attrs
= a_mr
.attributes
187 # Only weed out MRs less than the soak time for the crontab output (where author==None)
188 now
= datetime
.now(timezone
.utc
)
189 creation_time
= parser
.parse(mr_attrs
['created_at'], tzinfos
=[tzutc()]).astimezone(timezone
.utc
)
191 self
.log
.info("times: {}, {}, {}".format(creation_time
, self
.soak_delta
, now
))
192 if creation_time
+ self
.soak_delta
> now
:
193 project_id
= mr_attrs
['target_project_id']
194 mr_id
= mr_attrs
['id']
195 soak_hours
= self
.config
['CRONTAB_SOAK_HOURS']
196 info_template
= "skipping: MR <{},{}> was opened less than {} hours ago"
197 info_msg
= info_template
.format(project_id
, mr_id
, soak_hours
)
198 self
.log
.info(info_msg
)
201 str_open_since
= deltastr(now
- creation_time
)
204 if mr_attrs
['work_in_progress']:
205 warning
= "still WIP"
206 elif mr_attrs
['merge_status'] != 'can_be_merged':
207 warning
= "there are merge conflicts"
210 authored
= (mr_attrs
['author']['id'] == author
)
214 msg
= "{} (opened by {} {})".format(mr_attrs
['web_url'], mr_attrs
['author']['username'], str_open_since
)
215 upvotes
= mr_attrs
['upvotes']
217 msg
+= ": Has 2+ upvotes and could be merged in now"
219 msg
+= " except {}.".format(warning
)
224 self
.log
.error("pre-award a_mr: {}".format(dir(a_mr
.awardemojis
)))
225 award_emoji
= a_mr
.awardemojis
.list()
226 self
.log
.info("award_emoji: {}".format(award_emoji
))
227 already_approved
= "someone"
228 for an_emoji
in award_emoji
:
229 emoji_attr
= an_emoji
.attributes
230 if emoji_attr
["name"] == "thumbsup":
231 already_approved
= emoji_attr
["user"]["username"]
234 msg
+= ": Your MR has been approved by " + already_approved
+ " and is waiting for another upvote"
236 msg
+= ": Approved by " + already_approved
+ " and waiting for another upvote"
238 msg
+= " but {}.".format(warning
)
244 msg
+= ": Your MR has no upvotes"
246 msg
+= ": No upvotes, please review"
248 msg
+= " but {}.".format(warning
)
252 return {'creation_time': creation_time
, 'msg': msg
}
254 def crontab_hook(self
, polled_time
):
256 Send a scheduled message to the rooms margebot is watching
257 about open MRs the room cares about.
260 self
.log
.info("crontab_hook triggered at {}".format(polled_time
))
262 reminder_msg
= {} # Map of reminder_msg['roomname@domain'] = [(created_at,msg)]
264 # initialize the reminders
267 self
.log
.info("poller: a_room.node: {}".format(a_room
.node
))
268 reminder_msg
[a_room
.node
] = []
272 # Let's walk through the MRs we've seen already:
273 open_mrs
= self
['OPEN_MRS']
275 for (project_id
, mr_id
, notify_rooms
) in open_mrs
:
277 a_project
= self
.gitlab
.projects
.get(project_id
)
279 self
.log
.debug("Couldn't find project: {}".format(project_id
))
282 # Lookup the MR from the project/id
283 a_mr
= a_project
.mergerequests
.get(mr_id
)
284 mr_attrs
= a_mr
.attributes
286 self
.log
.debug("Couldn't find project: {}, id: {}".format(project_id
, mr_id
))
289 # If the MR is tagged 'never-close' or 'abandoned', ignore it
290 if 'labels' in mr_attrs
and ('never-close' in mr_attrs
['labels'] or 'abandoned' in mr_attrs
['labels']):
293 self
.log
.info("a_mr: {} {} {} {}".format(project_id
, mr_id
, notify_rooms
, mr_attrs
['state']))
295 # If the MR is no longer open, skip to the next MR,
296 # and don't include this MR in the next check
297 if 'opened' not in mr_attrs
['state']:
300 still_open_mrs
[(project_id
, mr_id
, notify_rooms
)] = True
302 msg_dict
= self
.mr_status_msg(a_mr
)
306 for a_room
in notify_rooms
.split(','):
307 if a_room
in reminder_msg
:
308 reminder_msg
[a_room
].append(msg_dict
)
310 self
.log
.error("{} not in reminder_msg (project_id={}, mr_id={})".format(a_room
, project_id
, mr_id
))
312 self
['OPEN_MRS'] = open_mrs
314 # Remind each of the rooms about open MRs
315 for a_room
, room_msg_list
in reminder_msg
.items():
316 if room_msg_list
!= []:
318 to_room
= a_room
+ '@' + self
.config
['CHATROOM_HOST']
319 msg
= tenv().get_template('reviews.md').render(msg_list
=room_msg_list
)
320 self
.send(self
.build_identifier(to_room
), msg
)
322 self
['OPEN_MRS'] = still_open_mrs
324 @botcmd(template
="reviews")
325 def reviews(self
, msg
, args
):
327 Returns a list of MRs that are waiting for some luv.
328 Also returns a list of MRs that have had enough luv but aren't merged in yet.
330 # Sending directly to Margbot: sender in the form sender@....
331 # Sending to a chatroom: snder in the form room@rooms/sender
333 log_template
= 'reviews: msg: {}, args: {}, \nmsg.frm: {}, \nmsg.msg.frm: {}, chatroom_host: {}'
334 self
.log
.info(log_template
.format(msg
, args
, msg
.frm
.__dict
__, dir(msg
.frm
), self
.config
['CHATROOM_HOST']))
335 self
.log
.info('reviews: bot mode: {}'.format(self
._bot
.mode
))
337 if self
._bot
.mode
== "xmpp":
338 if msg
.frm
.domain
== self
.config
['CHATROOM_HOST']:
339 sender
= msg
.frm
.resource
341 sender
= msg
.frm
.node
343 sender
= str(msg
.frm
).split('@')[0]
346 if 'OPEN_MRS' not in keys
:
347 self
.log
.error('OPEN_MRS not in {}'.format(keys
))
348 return "No MRs to review"
350 sender_gitlab_id
= None
352 sender_users
= self
.gitlab
.users
.list(username
=sender
)
353 sender_gitlab_id
= sender_users
[0].attributes
['id']
354 except Exception as exp
:
355 self
.log
.error('problem mapping {} to a gitlab user, exp={}'.format(sender
, exp
))
356 sender_gitlab_id
= None
358 # Walk through the MRs we've seen already:
362 open_mrs
= self
['OPEN_MRS']
363 self
.log
.info('open_mrs: {}'.format(open_mrs
))
364 for (project
, mr_id
, notify_rooms
) in open_mrs
:
367 a_project
= self
.gitlab
.projects
.get(project
)
368 except Exception as exp
:
369 self
.log
.debug("Couldn't find project: {}, exp: {}".format(project
, exp
))
372 # Lookup the MR from the project/id
374 a_mr
= a_project
.mergerequests
.get(mr_id
)
375 mr_attrs
= a_mr
.attributes
376 except Exception as exp
:
377 self
.log
.debug("Couldn't find project: {}, id: {}, exp: {}".format(project
, mr_id
, exp
))
380 self
.log
.info('project: {}, id: {}, a_mr: {}'.format(project
, id, a_mr
))
382 # If the MR is tagged 'never-close' or 'abandoned', ignore it
383 if 'labels' in mr_attrs
and ('never-close' in mr_attrs
['labels'] or 'abandoned' in mr_attrs
['labels']):
386 # If the MR is no longer open, skip to the next MR,
387 # and don't include this MR in the next check
388 if 'opened' not in mr_attrs
['state']:
389 self
.log
.info('state not opened: {}'.format(mr_attrs
['state']))
392 still_open_mrs
[(project
, mr_id
, notify_rooms
)] = True
394 msg_dict
= self
.mr_status_msg(a_mr
, author
=sender_gitlab_id
)
398 msg_list
.append(msg_dict
)
400 self
['OPEN_MRS'] = still_open_mrs
402 return {'sender': sender
, 'msg_list': msg_list
}
404 @arg_botcmd('rooms', type=str, help="Comma-separated room list without @conference-room suffix")
405 @arg_botcmd('repo', type=str, help="repo to start watching for MRs in NAMESPACE/PROJECT_NAME format")
406 def watchrepo(self
, msg
, repo
, rooms
):
408 Add the margebot webhook to a repo, and prepopulate any open MRs in the repo with margebot
410 self
.log
.info("msg={}".format(msg
))
411 self
.log
.info("repo={}".format(repo
))
412 self
.log
.info("rooms={}".format(rooms
))
414 # get the group/repo repo, error out if it doesn't exist
415 project
= self
.gitlab
.projects
.get(repo
)
417 msg
= "Couldn't find repo {}".format(repo
)
418 self
.log
.info("watchrepo: {}".format(msg
))
421 self
.log
.info('project: {}'.format(project
))
423 target_project_id
= project
.id
425 # Check is the project already includes the margebot hook
426 # If no hooks, will it return False or [] ?
428 hooks
= project
.hooks
.list()
429 self
.log
.error("hooks = {}".format(hooks
))
431 msg
= "Couldn't find {} hooks".format(repo
)
432 self
.log
.error("watchrepo: {}".format(msg
))
435 self
.log
.info('a_hook: {}'.format(a_hook
))
436 hook_attributes
= a_hook
.attributes
437 if hook_attributes
['merge_requests_events'] and hook_attributes
['url'].startswith(self
.webhook_url
):
441 # If so replace it (or error out ?)
442 url
= "{}{}".format(self
.webhook_url
, rooms
) # webhooks_url will end in '/'
445 old_rooms
= marge_hook
.attributes
['url'].split(self
.webhook_url
, 1)[1]
446 if old_rooms
== rooms
:
447 msg
= "Already reporting {} MRs to the {} room(s)".format(repo
, rooms
)
448 self
.log
.info('watchrepo: {}'.format(msg
))
452 marge_hook
.attributes
['url'] = url
454 s_watch_msg
= "Updating room list for {} MRs from {} to {}".format(repo
, old_rooms
, rooms
)
455 except Exception as exp
:
457 self
.log
.error("watchrepo; update hook {} raised exception {}".format(repo
, exp
))
461 project
.hooks
.create({'url': url
, 'merge_requests_events': 1, 'enable_ssl_verification': True})
462 s_watch_msg
= "Now watching for new MRs in the {} repo to the {} room(s)".format(repo
, rooms
)
463 except Exception as exp
:
465 self
.log
.error("watchrepo; create hook {} raised exception {}".format(repo
, exp
))
468 msg
= "Couldn't {} hook: {}".format(s_action
, repo
)
469 self
.log
.error("watchrepo: {}".format(msg
))
472 open_mrs
= self
['OPEN_MRS']
474 # get the open MRs in the repo
476 # If updating the room list, walk through the existing MR list
477 if s_action
== "update":
479 for (project_id
, mr_id
, old_rooms
) in open_mrs
:
480 # pragma pylint: disable=simplifiable-if-statement
481 if project_id
== target_project_id
:
482 new_open_mrs
[(project_id
, mr_id
, rooms
)] = True
484 new_open_mrs
[(project_id
, mr_id
, old_rooms
)] = True
485 # pragma pylint: enable=simplifiable-if-statement
486 open_mrs
= new_open_mrs
488 # If adding a new repo, check for existing opened MRs in the repo.
490 for state
in ['opened']:
491 a_project
= self
.gitlab
.projects
.get(target_project_id
)
492 mr_list
= a_project
.mergerequests
.list(state
=state
)
493 for an_mr
in mr_list
:
495 mr_id
= an_mr
.attributes
['iid']
496 open_mrs
[(target_project_id
, mr_id
, rooms
)] = True
498 self
['OPEN_MRS'] = open_mrs
501 mr_msg
= "No open MRs were found in the repo."
503 mr_msg
= "1 open MR was found in the repo. Run !reviews to see the updated MR list."
505 mr_msg
= "{} open MRs were found in the repo. Run !reviews to see the updated MR list.".format(mr_count
)
506 return "{}\n{}".format(s_watch_msg
, mr_msg
)
508 # pragma pylint: disable=unused-argument
510 # Check Chucklebot for the chuckles
512 @re_botcmd(pattern
=r
".*", prefixed
=True)
513 def catchall(self
, msg
, args
):
515 Don't have the bot complain about unknown commands if the first word in a msg is its name
519 # pragma pylint: enable=unused-argument