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
12 def deltastr(any_delta
):
14 (days
, hours
, mins
) = (any_delta
.days
, any_delta
.seconds
//3600, (any_delta
.seconds
//60)%60)
16 for (k
,v
) in {"day": days
, "hour": hours
, "minute": mins
}.items():
18 l_delta
.append("1 " + k
)
20 l_delta
.append("{} {}s".format(v
, k
))
22 return ",".join(l_delta
) + " ago"
25 class Marge(BotPlugin
, CrontabMixin
):
27 I remind you about merge requests
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.
37 Add <roomname>@domain to the CHATROOM_PRESENCE list in config.py for
38 rooms margebot should join
42 # Set in config now: '0 11,17 * * * .crontab_hook' # 7:00AM and 1:00PM EST warnings
45 def __init__(self
, *args
, **kwargs
):
47 self
.chatroom_host
= None
48 super().__init
__(*args
, **kwargs
)
50 def get_configuration_template(self
):
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
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 * * *',
64 'CRONTAB_SOAK_HOURS' : 1}
66 def check_configuration(self
, configuration
):
67 super().check_configuration(configuration
)
71 self
.log
.info('Margebot is not configured. Forbid activation')
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()
79 self
.soak_delta
= relativedelta( hours
= self
.config
['CRONTAB_SOAK_HOURS'])
85 @webhook('/margebot/<rooms>/')
86 def gitlab_hook(self
, request
, rooms
):
88 Webhook that listens on http://<server>:<port>/gitlab
91 self
.log
.info('margebot webhook request: {}'.format(request
))
92 self
.log
.info('margebot webhook rooms {}'.format(rooms
))
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':
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
105 if request
['object_attributes']['work_in_progress']:
109 url
= request
['project']['homepage']
110 state
= request
['object_attributes']['state']
111 title
= request
['object_attributes']['title']
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']
117 target_project_id
= request
['object_attributes']['target_project_id']
118 iid
= request
['object_attributes']['iid']
120 user_name
= request
['user']['username'] # will this always be Administrator ?
122 msg_template
= "New Review: {} has opened a new {} MR: \"{}\"\n{}/merge_requests/{}"
123 msg
= msg_template
.format(author_name
, wip
, title
, url
, iid
)
125 for a_room
in rooms
.split(','):
127 self
.send(self
.build_identifier(a_room
+ '@' + self
.chatroom_host
), msg
)
129 if 'OPEN_MRS' not in self
.keys():
131 self
['OPEN_MRS'] = empty_dict
133 with self
.mutable('OPEN_MRS') as open_mrs
:
134 open_mrs
[(target_project_id
, iid
, rooms
)] = True
138 def mr_status_msg(self
, a_mr
, author
=None):
139 self
.log
.info("mr_status_msg: a_mr: {}".format(a_mr
))
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
)
149 str_open_since
= deltastr(now
- creation_time
)
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"
158 authored
= (a_mr
['author']['id'] == author
)
162 # TODO: Include the count of opened MR notes (does the API show resolved state ??)
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'])
167 # for approved in approvals['approved_by']:
168 # also_approved += "," + approved['user']['name']
170 upvotes
= a_mr
['upvotes']
171 msg
= "{} (opened {})".format(a_mr
['web_url'], str_open_since
)
173 msg
+= ": Has 2+ upvotes / Could be merge in now"
175 msg
+= " except {}".format(a_mr
['web_url'], str_open_since
, warning
)
181 msg
+= ": Your MR is waiting for another upvote"
183 msg
+= ": Waiting for another upvote"
185 msg
+= "but {}.".format(warning
)
191 msg
+= ": Your MR has no upvotes"
193 msg
+= ": No upvotes, please review"
195 msg
+= "but {}".format(warning
)
199 return((creation_time
, msg
))
202 def crontab_hook(self
, polled_time
):
204 Send a scheduled message to the rooms margebot is watching
205 about open MRs the room cares about.
208 self
.log
.info("crontab_hook triggered at {}".format(polled_time
))
210 reminder_msg
= {} # Map of reminder_msg['roomname@domain'] = [(created_at,msg)]
212 # initialize the reminders
215 reminder_msg
[a_room
.node
] = []
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
:
224 # Lookup the MR from the project/iid
225 a_mr
= self
.gitlab
.getmergerequest(project
, iid
)
227 self
.log
.info("a_mr: {} {} {} {}".format(project
, iid
, notify_rooms
, a_mr
['state']))
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':
234 still_open_mrs
[(project
, iid
, notify_rooms
)] = True
236 msg_tuple
= self
.mr_status_msg(a_mr
)
237 if msg_tuple
is None:
240 for a_room
in notify_rooms
.split(','):
241 reminder_msg
[a_room
].append(msg_tuple
)
243 # Remind each of the rooms about open MRs
244 for a_room
, room_msg_list
in reminder_msg
.items():
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.
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
)
257 self
['OPEN_MRS'] = still_open_mrs
260 def reviews(self
, msg
, args
): # a command callable with !mrs
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.
265 ## Sending directly to Margbot: sender in the form sender@....
266 ## Sending to a chatroom: snder in the form room@rooms/sender
268 if msg
.frm
.domain
== self
.config
['CHATROOM_HOST']:
269 sender
= msg
.frm
.resource
271 sender
= msg
.frm
.node
273 if 'OPEN_MRS' not in self
.keys():
274 return "No MRs to review"
276 sender_gitlab_id
= None
277 for user
in self
.gitlab
.getusers():
278 if user
['username'] == sender
:
279 sender_gitlab_id
= user
['id']
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."
286 # Walk through the MRs we've seen already:
290 with self
.mutable('OPEN_MRS') as open_mrs
:
291 for (project
, iid
, notify_rooms
) in open_mrs
:
293 # Lookup the MR from the project/iid
294 a_mr
= self
.gitlab
.getmergerequest(project
, iid
)
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':
301 still_open_mrs
[(project
, iid
, notify_rooms
)] = True
303 msg_tuple
= self
.mr_status_msg(a_mr
, author
=sender_gitlab_id
)
304 if msg_tuple
is None:
307 msg_list
.append(msg_tuple
)
310 response
= 'Hi {}: {}'.format(sender
, 'I found no open MRs for you.')
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
)
317 with self
.mutable('OPEN_MRS') as open_mrs
:
318 open_mrs
= still_open_mrs
323 def hello(self
, msg
, args
):
327 def xyzzy(self
, msg
, args
):
328 yield "/me whispers \"All open MRs have ben merged into master.\""
330 yield "(just kidding)"