2 Margebot: A Errbot Plugin for Gitlab MR reminders
5 from datetime
import datetime
, timezone
7 from dateutil
import parser
8 from dateutil
.tz
import tzutc
9 from dateutil
.relativedelta
import relativedelta
10 from errbot
import BotPlugin
, botcmd
, arg_botcmd
, re_botcmd
, webhook
11 from errbot
.templating
import tenv
12 from errcron
.bot
import CrontabMixin
17 def addprojecthook_extra(self
, project_id
, url
, push
=False, issues
=False, merge_requests
=False, tag_push
=False, extra_data
=None):
19 A copy parent addprojecthook with an extra_data field
21 data
= {"id": project_id
, "url": url
}
23 for ed_key
, ed_value
in extra_data
.items():
24 data
[ed_key
] = ed_value
25 data
['push_events'] = int(bool(push
))
26 data
['issues_events'] = int(bool(issues
))
27 data
['merge_requests_events'] = int(bool(merge_requests
))
28 data
['tag_push_events'] = int(bool(tag_push
))
29 request
= requests
.post("{0}/{1}/hooks".format(self
.projects_url
, project_id
),
30 headers
=self
.headers
, data
=data
, verify
=self
.verify_ssl
)
31 if request
.status_code
== 201:
35 gitlab
.Gitlab
.addprojecthook_extra
= addprojecthook_extra
38 def editprojecthook_extra(self
, project_id
, hook_id
, url
, push
=False, issues
=False, merge_requests
=False, tag_push
=False, extra_data
=None):
40 A copy of the parent editprojecthook with an extra_data field
42 data
= {"id": project_id
, "hook_id": hook_id
, "url": url
}
44 for ed_key
, ed_value
in extra_data
.items():
45 data
[ed_key
] = ed_value
46 data
['push_events'] = int(bool(push
))
47 data
['issues_events'] = int(bool(issues
))
48 data
['merge_requests_events'] = int(bool(merge_requests
))
49 data
['tag_push_events'] = int(bool(tag_push
))
50 request
= requests
.put("{0}/{1}/hooks/{2}".format(self
.projects_url
, project_id
, hook_id
),
51 headers
=self
.headers
, data
=data
, verify
=self
.verify_ssl
)
52 return request
.status_code
== 200
54 gitlab
.Gitlab
.editprojecthook_extra
= editprojecthook_extra
57 def deltastr(any_delta
):
59 Output a datetime delta in the format "x days, y hours, z minutes ago"
63 hours
= any_delta
.seconds
// 3600
64 mins
= (any_delta
.seconds
// 60) % 60
66 for (key
, val
) in [("day", days
), ("hour", hours
), ("minute", mins
)]:
68 l_delta
.append("1 " + key
)
70 l_delta
.append("{} {}s".format(val
, key
))
75 retval
= ", ".join(l_delta
) + " ago"
79 class Marge(BotPlugin
, CrontabMixin
):
81 I remind you about merge requests
85 Add a merge request webook of the form
86 'https://webookserver/margeboot/<rooms>'
87 to the projects you want tracked. <rooms> should be a
88 comma-separated list of short room names (anything before the '@')
89 that you want notified.
91 Add <roomname>@domain to the CHATROOM_PRESENCE list in config.py for
92 rooms margebot should join
96 # Set in config now: '0 11,17 * * * .crontab_hook' # 7:00AM and 1:00PM EST warnings
99 def __init__(self
, *args
, **kwargs
):
101 self
.chatroom_host
= None
103 self
.soak_delta
= None
104 self
.webhook_url
= None
105 super().__init
__(*args
, **kwargs
)
107 def get_configuration_template(self
):
109 GITLAB_HOST: Host name of your gitlab server
110 GITLAB_ADMIN_TOKEN: PAT from an admin's https://${GIT_HOST}/profile/personal_access_tokens.
111 CHATROOM_HOST: Chatroom host. Usually 'chatroom' + FQDN of Jabber server
112 CRONTAB: Schedule of automated merge request checks in '%M %H %d %m %w' format
113 VERIFY_SSL : True, False, or path to CA cert to verify cert
114 CRONTAB_SOAK_HOURS : Don't send out reminders about MRs opened less than this many hours
115 WEBHOOK_URL : URL to use for defining MR integration in gitlab
117 return {'GITLAB_HOST': 'gitlab.example.com',
118 'GITLAB_ADMIN_TOKEN': 'gitlab-admin-user-private-token',
119 'CHATROOM_HOST': 'conference.jabber.example.com',
120 'CRONTAB': '0 11,17 * * *',
122 'CRONTAB_SOAK_HOURS': 1,
123 'WEBHOOK_URL': 'https://webhooks.example.com:3142/margebot/'}
125 def check_configuration(self
, configuration
):
127 Check that the plugin has been configured properly
129 super().check_configuration(configuration
)
133 Initialization done when the plugin is activated
136 self
.log
.info('Margebot is not configured. Forbid activation')
138 self
.git_host
= self
.config
['GITLAB_HOST']
139 self
.chatroom_host
= self
.config
['CHATROOM_HOST']
140 Marge
.CRONTAB
= ['{} .crontab_hook'.format(self
.config
['CRONTAB'])]
141 gitlab_auth_token
= self
.config
['GITLAB_ADMIN_TOKEN']
142 verify_ssl
= self
.config
['VERIFY_SSL']
143 self
.gitlab
= gitlab
.Gitlab(self
.git_host
, gitlab_auth_token
, verify_ssl
=verify_ssl
)
144 self
.activate_crontab()
146 self
.soak_delta
= relativedelta(hours
=self
.config
['CRONTAB_SOAK_HOURS'])
147 self
.webhook_url
= self
.config
['WEBHOOK_URL']
148 if self
.webhook_url
[-1] != '/':
149 self
.webhook_url
+= '/'
152 def deactivate(self
):
154 Anything that needs to be tore down when the plugin is deactivated goes here.
158 @webhook('/margebot/<rooms>/')
159 def gitlab_hook(self
, request
, rooms
):
161 Webhook that listens on http://<server>:<port>/gitlab
164 self
.log
.info("webhook: request: {}, rooms: {}".format(request
, rooms
))
165 # self.log.info("state: {}".format(request['object_attributes']['state']))
167 # verify it's a merge request
168 if request
['object_kind'] != 'merge_request':
169 self
.log
.error('unexpecting object_kind: {}'.format(request
['object_kind']))
170 elif 'opened' in request
['object_attributes']['state']:
172 if request
['object_attributes']['work_in_progress']:
176 url
= request
['project']['homepage']
177 title
= request
['object_attributes']['title']
179 author_id
= request
['object_attributes']['author_id'] # map this to user name ...
180 author
= self
.gitlab
.getuser(author_id
)
182 author_name
= author
['username']
184 self
.log
.info("unexpected author_id {}".format(author_id
))
185 author_name
= author_id
187 target_project_id
= request
['object_attributes']['target_project_id']
188 iid
= request
['object_attributes']['iid']
190 # If the MR is tagged 'never-close' ignore it
191 if 'labels' in request
:
192 for a_label
in request
['labels']:
193 if a_label
['title'] == 'never-close':
194 self
.log
.info("Skipping never-close notice for {} MR".format(url
))
197 msg_template
= "Hi there ! {} has opened a new {}MR: \"{}\"\n{}/merge_requests/{}"
198 msg
= msg_template
.format(author_name
, wip
, title
, url
, iid
)
200 if 'OPEN_MRS' not in self
.keys():
202 self
['OPEN_MRS'] = empty_dict
204 open_mrs
= self
['OPEN_MRS']
206 if (target_project_id
, iid
, rooms
) not in open_mrs
:
207 for a_room
in rooms
.split(','):
209 self
.send(self
.build_identifier(a_room
+ '@' + self
.chatroom_host
), msg
)
211 self
.log
.info("webhook: Saving ({}, {}, {})".format(target_project_id
, iid
, rooms
))
212 open_mrs
[(target_project_id
, iid
, rooms
)] = True
213 self
['OPEN_MRS'] = open_mrs
215 # TODO: Add check if an MR has toggled the WIP indicator
216 # (trigger on updates (what's that look like in request['object_attributes']['state'])
217 # Then check in request['changes']['title']['previous'] starts with 'WIP:'
218 # but not request['changes']['title']['current'], and vice versa
219 # See https://gitlab.com/gitlab-org/gitlab-ce/issues/53529
223 def mr_status_msg(self
, a_mr
, author
=None):
225 Create the merge request status message
227 self
.log
.info("mr_status_msg: a_mr: {}".format(a_mr
))
229 # Only weed out MRs less than the soak time for the crontab output (where author==None)
230 now
= datetime
.now(timezone
.utc
)
231 creation_time
= parser
.parse(a_mr
['created_at'], tzinfos
=tzutc
)
233 self
.log
.info("times: {}, {}, {}".format(creation_time
, self
.soak_delta
, now
))
234 if creation_time
+ self
.soak_delta
> now
:
235 project_id
= a_mr
['target_project_id']
237 soak_hours
= self
.config
['CRONTAB_SOAK_HOURS']
238 info_template
= "skipping: MR <{},{}> was opened less than {} hours ago"
239 info_msg
= info_template
.format(project_id
, mr_id
, soak_hours
)
240 self
.log
.info(info_msg
)
243 str_open_since
= deltastr(now
- creation_time
)
246 if a_mr
['work_in_progress']:
247 warning
= "still WIP"
248 elif a_mr
['merge_status'] != 'can_be_merged':
249 warning
= "there are merge conflicts"
252 authored
= (a_mr
['author']['id'] == author
)
256 # getapprovals is only available in GitLab 8.9 EE or greater
257 # (not the open source CE version)
258 # approvals = self.gitlab.getapprovals(a_mr['id'])
260 # for approved in approvals['approved_by']:
261 # approved += "," + approvals['user']['name']
263 # See https://gitlab.com/gitlab-org/gitlab-ce/issues/35498
264 # awards = GET /projects/:id/merge_requests/:merge_request_iid/award_emoji
265 # for an award in awards:
266 # if name==??? and award_type==???:
267 # approved += "," + award["user"]["username"]
269 upvotes
= a_mr
['upvotes']
270 msg
= "{} (opened {})".format(a_mr
['web_url'], str_open_since
)
272 msg
+= ": Has 2+ upvotes and could be merged in now"
274 msg
+= " except {}.".format(warning
)
280 msg
+= ": Your MR is waiting for another upvote"
282 msg
+= ": Waiting for another upvote"
284 msg
+= " but {}.".format(warning
)
290 msg
+= ": Your MR has no upvotes"
292 msg
+= ": No upvotes, please review"
294 msg
+= " but {}.".format(warning
)
298 return {'creation_time': creation_time
, 'msg': msg
}
300 def crontab_hook(self
, polled_time
):
302 Send a scheduled message to the rooms margebot is watching
303 about open MRs the room cares about.
306 self
.log
.info("crontab_hook triggered at {}".format(polled_time
))
308 reminder_msg
= {} # Map of reminder_msg['roomname@domain'] = [(created_at,msg)]
310 # initialize the reminders
313 self
.log
.info("poller: a_room.node: {}".format(a_room
.node
))
314 reminder_msg
[a_room
.node
] = []
318 # Let's walk through the MRs we've seen already:
319 open_mrs
= self
['OPEN_MRS']
321 for (project_id
, mr_id
, notify_rooms
) in open_mrs
:
323 # Lookup the MR from the project/id
324 a_mr
= self
.gitlab
.getmergerequest(project_id
, mr_id
)
326 self
.log
.debug("Couldn't find project: {}, id: {}".format(project_id
, mr_id
))
329 # If the MR is tagged 'never-close' ignore it
330 if 'labels' in a_mr
and 'never-close' in a_mr
['labels']:
333 self
.log
.info("a_mr: {} {} {} {}".format(project_id
, mr_id
, notify_rooms
, a_mr
['state']))
335 # If the MR is no longer open, skip to the next MR,
336 # and don't include this MR in the next check
337 if 'opened' not in a_mr
['state']:
340 still_open_mrs
[(project_id
, mr_id
, notify_rooms
)] = True
342 msg_dict
= self
.mr_status_msg(a_mr
)
346 for a_room
in notify_rooms
.split(','):
347 if a_room
in reminder_msg
:
348 reminder_msg
[a_room
].append(msg_dict
)
350 self
.log
.error("{} not in reminder_msg (project_id={}, mr_id={})".format(a_room
, project_id
, mr_id
))
352 self
['OPEN_MRS'] = open_mrs
354 # Remind each of the rooms about open MRs
355 for a_room
, room_msg_list
in reminder_msg
.items():
356 if room_msg_list
!= []:
358 to_room
= a_room
+ '@' + self
.config
['CHATROOM_HOST']
359 msg
= tenv().get_template('reviews.md').render(msg_list
=room_msg_list
)
360 self
.send(self
.build_identifier(to_room
), msg
)
362 self
['OPEN_MRS'] = still_open_mrs
364 @botcmd(template
="reviews")
365 def reviews(self
, msg
, args
):
367 Returns a list of MRs that are waiting for some luv.
368 Also returns a list of MRs that have had enough luv but aren't merged in yet.
370 # Sending directly to Margbot: sender in the form sender@....
371 # Sending to a chatroom: snder in the form room@rooms/sender
373 log_template
= 'reviews: msg: {}, args: {}, \nmsg.frm: {}, \nmsg.msg.frm: {}, chatroom_host: {}'
374 self
.log
.info(log_template
.format(msg
, args
, msg
.frm
.__dict
__, dir(msg
.frm
), self
.config
['CHATROOM_HOST']))
375 self
.log
.info('reviews: bot mode: {}'.format(self
._bot
.mode
))
377 if self
._bot
.mode
== "xmpp":
378 if msg
.frm
.domain
== self
.config
['CHATROOM_HOST']:
379 sender
= msg
.frm
.resource
381 sender
= msg
.frm
.node
383 sender
= str(msg
.frm
).split('@')[0]
386 if 'OPEN_MRS' not in keys
:
387 self
.log
.error('OPEN_MRS not in {}'.format(keys
))
388 return "No MRs to review"
390 sender_gitlab_id
= None
391 sender_users
= self
.gitlab
.getusers(search
=(('username', sender
)))
393 self
.log
.error('problem mapping {} to a gitlab user'.format(sender
))
394 sender_gitlab_id
= None
396 sender_gitlab_id
= sender_users
[0]['id']
398 # Walk through the MRs we've seen already:
402 open_mrs
= self
['OPEN_MRS']
403 self
.log
.info('open_mrs: {}'.format(open_mrs
))
404 for (project
, mr_id
, notify_rooms
) in open_mrs
:
406 # Lookup the MR from the project/id
407 a_mr
= self
.gitlab
.getmergerequest(project
, mr_id
)
409 self
.log
.debug("Couldn't find project: {}, id: {}".format(project
, id))
412 self
.log
.info('project: {}, id: {}, a_mr: {}'.format(project
, id, a_mr
))
414 # If the MR is tagged 'never-close' ignore it
415 if 'labels' in a_mr
and 'never-close' in a_mr
['labels']:
418 # If the MR is no longer open, skip to the next MR,
419 # and don't include this MR in the next check
420 if 'opened' not in a_mr
['state']:
421 self
.log
.info('state not opened: {}'.format(a_mr
['state']))
424 still_open_mrs
[(project
, mr_id
, notify_rooms
)] = True
426 msg_dict
= self
.mr_status_msg(a_mr
, author
=sender_gitlab_id
)
430 msg_list
.append(msg_dict
)
432 self
['OPEN_MRS'] = still_open_mrs
434 return {'sender': sender
, 'msg_list': msg_list
}
436 @arg_botcmd('rooms', type=str, help="Comma-separated room list without @conference-room suffix")
437 @arg_botcmd('repo', type=str, help="repo to start watching for MRs in NAMESPACE/PROJECT_NAME format")
438 def watchrepo(self
, msg
, repo
, rooms
):
440 Add the margebot webhook to a repo, and prepopulate any open MRs in the repo with margebot
442 self
.log
.info("msg={}".format(msg
))
443 self
.log
.info("repo={}".format(repo
))
444 self
.log
.info("rooms={}".format(rooms
))
446 # get the group/repo repo, error out if it doesn't exist
447 project
= self
.gitlab
.getproject(repo
)
449 msg
= "Couldn't find repo {}".format(repo
)
450 self
.log
.info("watchrepo: {}".format(msg
))
453 self
.log
.info('project: {}'.format(project
))
455 target_project_id
= project
['id']
457 # Check is the project already includes the margebot hook
458 # If no hooks, will it return False or [] ?
460 hooks
= self
.gitlab
.getprojecthooks(target_project_id
)
461 self
.log
.error("hooks = {}".format(hooks
))
463 msg
= "Couldn't find {} hooks".format(repo
)
464 self
.log
.error("watchrepo: {}".format(msg
))
468 self
.log
.info('a_hook: {}'.format(a_hook
))
469 if a_hook
['merge_requests_events'] and a_hook
['url'].startswith(self
.webhook_url
):
473 # If so replace it (or error out ?)
474 url
= "{}{}".format(self
.webhook_url
, rooms
) # webhooks_url will end in '/'
477 old_rooms
= marge_hook
['url'].split(self
.webhook_url
, 1)[1]
478 if old_rooms
== rooms
:
479 msg
= "Already reporting {} MRs to the {} room(s)".format(repo
, rooms
)
480 self
.log
.info('watchrepo: {}'.format(msg
))
483 hook_updated
= self
.gitlab
.editprojecthook_extra(target_project_id
, marge_hook
['id'], url
, merge_requests
=True, extra_data
={'enable_ssl_verification': True})
484 s_watch_msg
= "Updating room list for {} MRs from {} to {}".format(repo
, old_rooms
, rooms
)
487 hook_updated
= self
.gitlab
.addprojecthook_extra(target_project_id
, url
, merge_requests
=True, extra_data
={'enable_ssl_verification': True})
488 s_watch_msg
= "Now watching for new MRs in the {} repo to the {} room(s)".format(repo
, rooms
)
492 msg
= "Couldn't {} hook: {}".format(s_action
, repo
)
493 self
.log
.error("watchrepo: {}".format(msg
))
496 open_mrs
= self
['OPEN_MRS']
498 # get the open MRs in the repo
500 # If updating the room list, walk through the existing MR list
501 if s_action
== "update":
503 for (project_id
, mr_id
, old_rooms
) in open_mrs
:
504 # pragma pylint: disable=simplifiable-if-statement
505 if project_id
== target_project_id
:
506 new_open_mrs
[(project_id
, mr_id
, rooms
)] = True
508 new_open_mrs
[(project_id
, mr_id
, old_rooms
)] = True
509 # pragma pylint: enable=simplifiable-if-statement
510 open_mrs
= new_open_mrs
512 # If adding a new repo, check for existing opened/reopened MRs in the repo.
514 # For debugging the 'watchrepo didn't find my MR' issue.
515 # mr_list = self.gitlab.getmergerequests(target_project_id, page=1, per_page=100)
516 # self.log.error('juden: mr_list state: {}'.format(mr_list[0]['state']))
517 for state
in ['opened', 'reopened']:
519 mr_list
= self
.gitlab
.getmergerequests(target_project_id
, page
=page
, per_page
=100, state
=state
)
520 while (mr_list
is not False) and (mr_list
!= []):
521 for an_mr
in mr_list
:
523 self
.log
.info('watchrepo: an_mr WATS THE IID\n{}'.format(an_mr
))
525 open_mrs
[(target_project_id
, mr_id
, rooms
)] = True
526 # Get the next page of MRs
528 mr_list
= self
.gitlab
.getmergerequests(target_project_id
, page
=page
, per_page
=100)
530 self
['OPEN_MRS'] = open_mrs
533 mr_msg
= "No open MRs were found in the repo."
535 mr_msg
= "1 open MR was found in the repo. Run !reviews to see the updated MR list."
537 mr_msg
= "{} open MRs were found in the repo. Run !reviews to see the updated MR list."
538 return "{}\n{}".format(s_watch_msg
, mr_msg
)
540 # pragma pylint: disable=unused-argument
543 def xyzzy(self
, msg
, args
):
545 Don't call this command...
547 yield "/me whispers \"All open MRs have been merged into master.\""
549 yield "(just kidding)"
551 @re_botcmd(pattern
=r
"I blame marge(bot)?", prefixed
=False, flags
=re
.IGNORECASE
)
552 def dont_blame_margebot(self
, msg
, match
):
554 margebot is innocent.
556 yield u
"(\u300D\uFF9F\uFF9B\uFF9F)\uFF63NOOOooooo say it ain't so."
558 @re_botcmd(pattern
=r
"\u0028\u256F\u00B0\u25A1\u00B0\uFF09\u256F\uFE35\u0020\u253B(\u2501+)\u253B", prefixed
=False)
559 def deflipped(self
, msg
, match
):
561 Unflip a properly sized table
563 table_len
= len(match
.group(1))
564 deflip_table
= u
"\u252c" + (u
"\u2500" * table_len
) + u
"\u252c \u30ce( \u309c-\u309c\u30ce)"
565 # yield u"\u252c\u2500\u2500\u252c \u30ce( \u309c-\u309c\u30ce)"
568 @re_botcmd(pattern
=r
"good bot", prefixed
=False, flags
=re
.IGNORECASE
)
569 def best_bot(self
, msg
, match
):
571 margebot is the best.
575 @re_botcmd(pattern
=r
"magfest", prefixed
=False, flags
=re
.IGNORECASE
)
576 def margefest(self
, msg
, args
):
580 return "More like MargeFest, amirite ?"
582 # ha the dev-infra room sez koji sooooooooooooooooooooooooooooooooooooooooooo much
583 # @re_botcmd(pattern=r"k+o+j+i+", prefixed=False, flags=re.IGNORECASE)
584 # def koji(self, msg, args):
586 # More like daikaiju, amirite ?
588 # return "More like kaiju, amirite ?"
590 @re_botcmd(pattern
=r
"peruvian chicken", prefixed
=False, flags
=re
.IGNORECASE
)
591 def booruvian(self
, msg
, args
):
593 They put hard boiled eggs in their tamales too.
595 return "More like Booruvian chicken, amirite ?"
597 @re_botcmd(pattern
=r
"booruvian chicken", prefixed
=False, flags
=re
.IGNORECASE
)
598 def booihoohooruvian(self
, msg
, args
):
600 That chicken I do not like.
602 return "More like Boohoohooruvian chicken, amirite ?"
604 @re_botcmd(pattern
=r
"margebot sucks", prefixed
=False, flags
=re
.IGNORECASE
)
605 def new_agenda_item(self
, msg
, args
):
607 Bring it up with the committee
609 return "Bring it up with the Margebot steering committee."
611 @re_botcmd(pattern
=r
"jackie", prefixed
=False, flags
=re
.IGNORECASE
)
612 def jackie(self
, msg
, args
):
616 return "I don't know any Jackies. I'm calling security."
618 @re_botcmd(pattern
=r
".*", prefixed
=True)
619 def catchall(self
, msg
, args
):
621 Don't have the bot complain about unknown commands if the first word in a msg is its name
625 # pragma pylint: enable=unused-argument