2 Margebot: A Errbot Plugin for Gitlab MR reminders
4 from datetime
import datetime
, timezone
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
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 super().__init
__(*args
, **kwargs
)
63 def get_configuration_template(self
):
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
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 * * *',
77 'CRONTAB_SOAK_HOURS': 1}
79 def check_configuration(self
, configuration
):
81 Check that the plugin has been configured properly
83 super().check_configuration(configuration
)
87 Initialization done when the plugin is activated
90 self
.log
.info('Margebot is not configured. Forbid activation')
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()
100 self
.soak_delta
= relativedelta(hours
=self
.config
['CRONTAB_SOAK_HOURS'])
103 def deactivate(self
):
105 Anything that needs to be tore down when the plugin is deactivated goes here.
109 @webhook('/margebot/<rooms>/')
110 def gitlab_hook(self
, request
, rooms
):
112 Webhook that listens on http://<server>:<port>/gitlab
115 self
.log
.info("webhook: request: {}, rooms: {}".format(request
, rooms
))
116 self
.log
.info("state: {}".format(request
['object_attributes']['state']))
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']:
123 if request
['object_attributes']['work_in_progress']:
127 url
= request
['project']['homepage']
128 title
= request
['object_attributes']['title']
130 author_id
= request
['object_attributes']['author_id'] # map this to user name ...
131 author
= self
.gitlab
.getuser(author_id
)
133 author_name
= author
['username']
135 self
.log
.info("unexpected author_id {}".format(author_id
))
136 author_name
= author_id
138 target_project_id
= request
['object_attributes']['target_project_id']
139 iid
= request
['object_attributes']['iid']
140 mr_id
= request
['object_attributes']['id']
142 msg_template
= "Hi there ! {} has opened a new {}MR: \"{}\"\n{}/merge_requests/{}"
143 msg
= msg_template
.format(author_name
, wip
, title
, url
, iid
)
145 if 'OPEN_MRS' not in self
.keys():
147 self
['OPEN_MRS'] = empty_dict
149 open_mrs
= self
['OPEN_MRS']
151 if (target_project_id
, mr_id
, rooms
) not in open_mrs
:
152 for a_room
in rooms
.split(','):
154 self
.send(self
.build_identifier(a_room
+ '@' + self
.chatroom_host
), msg
)
156 self
.log
.info("webhook: Saving ({}, {}, {})".format(target_project_id
, mr_id
, rooms
))
157 open_mrs
[(target_project_id
, mr_id
, rooms
)] = True
158 self
['OPEN_MRS'] = open_mrs
161 def mr_status_msg(self
, a_mr
, author
=None):
163 Create the merge request status message
165 self
.log
.info("mr_status_msg: a_mr: {}".format(a_mr
))
167 # Only weed out MRs less than the soak time for the crontab output (where author==None)
168 now
= datetime
.now(timezone
.utc
)
169 creation_time
= parser
.parse(a_mr
['created_at'], tzinfos
=tzutc
)
171 self
.log
.info("times: {}, {}, {}".format(creation_time
, self
.soak_delta
, now
))
172 if creation_time
+ self
.soak_delta
> now
:
173 project_id
= a_mr
['target_project_id']
175 soak_hours
= self
.config
['CRONTAB_SOAK_HOURS']
176 info_template
= "skipping: MR <{},{}> was opened less than {} hours ago"
177 info_msg
= info_template
.format(project_id
, mr_id
, soak_hours
)
178 self
.log
.info(info_msg
)
181 str_open_since
= deltastr(now
- creation_time
)
184 if a_mr
['work_in_progress']:
185 warning
= "still WIP"
186 elif a_mr
['merge_status'] != 'can_be_merged':
187 warning
= "there are merge conflicts"
190 authored
= (a_mr
['author']['id'] == author
)
194 # getapprovals is only available in GitLab 8.9 EE or greater
195 # (not the open source CE version)
196 # approvals = self.gitlab.getapprovals(a_mr['id'])
198 # for approved in approvals['approved_by']:
199 # also_approved += "," + approved['user']['name']
201 upvotes
= a_mr
['upvotes']
202 msg
= "{} (opened {})".format(a_mr
['web_url'], str_open_since
)
204 msg
+= ": Has 2+ upvotes and could be merged in now"
206 msg
+= " except {}.".format(warning
)
212 msg
+= ": Your MR is waiting for another upvote"
214 msg
+= ": Waiting for another upvote"
216 msg
+= " but {}.".format(warning
)
222 msg
+= ": Your MR has no upvotes"
224 msg
+= ": No upvotes, please review"
226 msg
+= " but {}.".format(warning
)
230 return (creation_time
, msg
)
232 def crontab_hook(self
, polled_time
):
234 Send a scheduled message to the rooms margebot is watching
235 about open MRs the room cares about.
238 self
.log
.info("crontab_hook triggered at {}".format(polled_time
))
240 reminder_msg
= {} # Map of reminder_msg['roomname@domain'] = [(created_at,msg)]
242 # initialize the reminders
245 reminder_msg
[a_room
.node
] = []
250 # Let's walk through the MRs we've seen already:
251 open_mrs
= self
['OPEN_MRS']
253 for (project_id
, mr_id
, notify_rooms
) in open_mrs
:
255 # Lookup the MR from the project/id
256 a_mr
= self
.gitlab
.getmergerequest(project_id
, mr_id
)
258 self
.log
.info("a_mr: {} {} {} {}".format(project_id
, mr_id
, notify_rooms
, a_mr
['state']))
260 # If the MR is no longer open, skip to the next MR,
261 # and don't include this MR in the next check
262 if 'opened' not in a_mr
['state']:
265 still_open_mrs
[(project_id
, mr_id
, notify_rooms
)] = True
267 msg_tuple
= self
.mr_status_msg(a_mr
)
268 if msg_tuple
is None:
271 for a_room
in notify_rooms
.split(','):
272 reminder_msg
[a_room
].append(msg_tuple
)
274 self
['OPEN_MRS'] = open_mrs
276 # Remind each of the rooms about open MRs
277 for a_room
, room_msg_list
in reminder_msg
.items():
278 if room_msg_list
!= []:
280 # sort by the creation time
281 sorted_room_msg_list
= sorted(room_msg_list
, key
=lambda x
: x
[0])
283 # extract the msgs from the tuple list
284 msgs
= [x
[1] for x
in sorted_room_msg_list
]
286 # join those msgs together.
287 room_msg
= "\n".join(msgs
)
290 msg_template
= "These MRs need some attention:\n{}\n"
291 msg_template
+= "You can get an updated list with the !reviews command."
292 to_room
= a_room
+ '@' + self
.config
['CHATROOM_HOST']
293 msg
= msg_template
.format(room_msg
)
294 self
.send(self
.build_identifier(to_room
), msg
)
296 self
['OPEN_MRS'] = still_open_mrs
299 def reviews(self
, msg
, args
):
301 Returns a list of MRs that are waiting for some luv.
302 Also returns a list of MRs that have had enough luv but aren't merged in yet.
304 # Sending directly to Margbot: sender in the form sender@....
305 # Sending to a chatroom: snder in the form room@rooms/sender
307 log_template
= 'reviews: msg: {}, args: {}, \nmsg.frm: {}, \nmsg.msg.frm: {}, chatroom_host: {}'
308 self
.log
.info(log_template
.format(msg
, args
, msg
.frm
.__dict
__, dir(msg
.frm
), self
.config
['CHATROOM_HOST']))
309 self
.log
.info('reviews: bot mode: {}'.format(self
._bot
.mode
))
311 if self
._bot
.mode
== "xmpp":
312 if msg
.frm
.domain
== self
.config
['CHATROOM_HOST']:
313 sender
= msg
.frm
.resource
315 sender
= msg
.frm
.node
317 sender
= str(msg
.frm
).split('@')[0]
320 if 'OPEN_MRS' not in keys
:
321 self
.log
.error('OPEN_MRS not in {}'.format(keys
))
322 return "No MRs to review"
324 sender_gitlab_id
= None
325 sender_users
= self
.gitlab
.getusers(search
=(('username', sender
)))
327 self
.log
.error('problem mapping {} to a gitlab user'.format(sender
))
328 sender_gitlab_id
= None
330 sender_gitlab_id
= sender_users
[0]['id']
332 # Walk through the MRs we've seen already:
336 open_mrs
= self
['OPEN_MRS']
337 for (project
, mr_id
, notify_rooms
) in open_mrs
:
339 # Lookup the MR from the project/id
340 a_mr
= self
.gitlab
.getmergerequest(project
, mr_id
)
342 # If the MR is no longer open, skip to the next MR,
343 # and don't include this MR in the next check
344 if 'opened' not in a_mr
['state']:
347 still_open_mrs
[(project
, mr_id
, notify_rooms
)] = True
349 msg_tuple
= self
.mr_status_msg(a_mr
, author
=sender_gitlab_id
)
350 if msg_tuple
is None:
353 msg_list
.append(msg_tuple
)
356 response
= 'Hi {}: {}'.format(sender
, 'I found no open MRs for you.')
358 # sort by the creation time
359 sorted_msg_list
= sorted(msg_list
, key
=lambda x
: x
[0])
361 # extract the msgs from the tuple list
362 msgs
= [x
[1] for x
in sorted_msg_list
]
364 # join those msgs together.
365 msg
= "\n".join(msgs
)
366 response
= 'Hi {}: These MRs need some attention:\n{}'.format(sender
, msg
)
368 self
['OPEN_MRS'] = still_open_mrs
372 # pragma pylint: disable=unused-argument
374 def hello(self
, msg
, args
):
376 A simple command to check if the bot is responding
381 def xyzzy(self
, msg
, args
):
383 Don't call this command...
385 yield "/me whispers \"All open MRs have been merged into master.\""
387 yield "(just kidding)"
389 # pragma pylint: enable=unused-argument