Fixed a call or get_submit_request_list (fixes a checkout and show).
[opensuse:osc-contrib.git] / osc-contrib.py
1 #
2 #   A contrib plugin for osc
3 #
4 #   This tool makes maintenance of openSUSE:Factory:Contrib more easier than an
5 #   universal osc commands.
6 #
7 #   Copyright: (c) 2009 Michal Vyskocil <mvyskocil@suse.cz>
8 #
9 #   Version: 0.1
10 #
11
12 @cmdln.option('-p', '--package', metavar='PACKAGE',
13               help='use a Package, not a submitrequest', action="store_true", default=False)
14 @cmdln.option('-m', '--message', metavar='MESSAGE',
15               help='the request message (optional)')
16 @cmdln.option('-i', '--id', metavar='REQUEST_ID',
17               help='The concrete request id. Usefull whe multiple requests exists.')
18 @cmdln.option('-l', '--last-request', action='store_true', default=False,
19               help='Use a last request (default False)')
20 @cmdln.option('-s', '--state', default = 'new',
21               help='only list requests in one of the comma separated given states [default=new]')
22 @cmdln.option('-f', '--full-view', action='store_true', default=False,
23               help='Make a full view for show command (default False)')
24 @cmdln.option('-d', '--devel-project', default=None,
25               help='Add a devel project for accepted package (default is Contrib)')
26 @cmdln.alias("cb")
27 def do_contrib(self, subcmd, opts, *args):
28     """${cmd_name}: Handling a requests for Contrib
29
30 osc subcommand for maintenance of openSUSE Contrib repository. This command
31 tries to make the maintenance process easier than common osc commands. These
32 commands are derived from existing ones, but have a different arguments.
33
34 For a backward compatibility with osc commands, all contrib commands (excluding
35 new) expects PACKAGE name, or a request ID.
36
37 osc contrib show [PACKAGE|ID]
38 Show all new requests towards Contrib. The optional argument package (or request id) will
39 filter only a requests to it.
40
41 Options:
42     -s, --state     filter the state (type 'any' for all)
43     -f, --full-view a full view of requests
44     -M, --mine      show only requests created by mine (needs osc > 0.117)
45
46 osc contrib new [DEST_PACKAGE]
47 osc contrib new PROJECT PACKAGE [DEST_PACKAGE]
48 A request for adding a new package to Contrib. When requesting from package dir
49 all necessary informations are read from osc metadata. Only a DEST_PACKAGE
50 should be givven, if you want to have another name of your package in Contrib.
51
52 If you are in common dir, then you have to specify the PROJECT and PACKAGE
53 manually and DEST_PACKAGE is also optional.
54
55 osc contrib checkout PACKAGE|ID [PACKAGE2|ID2 ...]
56 Checkout the requested package(s) (or a submit request(s)) to the current dir.
57
58 Options:
59     -i, --id        id of request (if multiple exists)
60     -l, --last      use a last request (if multiple exists)
61     -p, --package   checkout a package, not a request
62
63
64 osc contrib [accept|decline|revoke] PACKAGE|ID
65 Change the state of package (or request id) to <STATE>
66
67 Options:
68     -i, --id        id of request (if multiple exists)
69     -l, --last      use a last request (if multiple exists)
70     -m, --message   the submit message (optional for accept)
71     -d, --devel-project setup the devel project in accept (default is Contrib itself)
72
73 osc contrib maintainer PACKAGE [PACKAGE2 ...]
74 Show the maintainer of defined package(s)
75
76 Options:
77     -f, --full-view      show also e-mail of maintainer
78
79 osc contrib bugonwer PACKAGE [PACKAGE2 ...]
80 Show the bugonwer of defined package(s)
81
82 Options:
83     -f, --full-view      show also e-mail of bugonwer
84
85 osc contrib build [ARCH]
86 Calls osc build with proper --alternative-project for test build.
87
88 osc contrib todo
89 A list of packages which should be updated.
90     """
91
92     import types
93     cmds = [cmd[12:] for cmd in dir(self) if cmd[0:12] == '_do_contrib_' and type(getattr(self, cmd)) == types.MethodType]
94     if not args or args[0] not in cmds:
95         raise oscerr.WrongArgs("Unknown contrib action. Choose one of %s." \
96                                 % ', '.join(cmds))
97     
98     command = args[0]
99
100     self.project = 'openSUSE:Factory:Contrib'
101     #self.project = 'home:mvyskocil'
102     self.apiurl  = conf.config['apiurl']
103
104     self.todo_url = "http://pack.suse.cz/mvyskocil/contrib-new-version/new_version.csv"
105     self.todo_cache="/var/tmp/osc-contrib-todo.cache"
106
107     # call
108     getattr(self, "_do_contrib_%s" % (command))(opts, args[1:])
109
110 def _sr_from_package(self, package, reqid=None, use_last=False, req_state=('new', )):
111     if package.isdigit():
112         req = get_submit_request(self.apiurl, package)
113         if req.dst_project != self.project:
114             raise oscerr.WrongArgs("Request#'%s' has dst_project '%s', expected '%s'" % (package, req.dst_project, self.project))
115         return req
116     requests = get_submit_request_list(self.apiurl, self.project, package, req_state=req_state)
117     if len(requests) == 0:
118         raise oscerr.WrongArgs("No request for package %s found" % (package))
119     elif len(requests) > 1:
120         if use_last:
121             requests = requests[-1:]
122         elif reqid == None:
123             raise oscerr.WrongArgs(
124             "There are multiple requests (%s) towards package %s. Specify one by -i/--id, or use -l/--last-request argument!" %
125             (", ".join([str(r.reqid) for r in requests]), package))
126         else:
127             ret = [req for req in requests if req.reqid == int(reqid)]
128             if len(ret) == 0:
129                 raise oscerr.WrongArgs("The package %s and request id %s doesn't match! \
130                         Use one of these (%s)" % (package, reqid, ", ".join([str(r.reqid) for r in requests])))
131             requests = ret
132
133     return requests[0]
134
135 def _do_contrib_show(self, opts, args):
136
137     package = ''
138     if len(args) > 0:
139         package = args[0]
140
141     state_list = opts.state.split(',') if opts.state != 'any' else ('', )
142     
143     srs = get_submit_request_list(self.apiurl, self.project, package, req_state=state_list)
144     if opts.full_view:
145         for sr in srs:
146             print(sr)
147     else:
148         for sr in srs:
149             print(sr.list_view())
150
151 def _do_contrib_new(self, opts, args):
152     if is_package_dir(os.getcwdu()):
153         src_project = store_read_project(os.getcwdu())
154         src_package = store_read_package(os.getcwdu())
155         dest_package = src_package
156         if len(args) > 0:
157             dest_package = args[0]
158     else:
159         if len(args) < 2:
160             raise oscerr.WrongArgs("The source project and package names are mandatory!!")
161         src_project, src_package = args[0], args[1]
162         if not src_package in meta_get_packagelist(self.apiurl, src_project):
163             raise oscerr.WrongArgs("Package '%s' don't exists in project '%s'" % (src_package, src_project))
164         dest_package = src_package
165         if len(args) == 3:
166             dest_package = args[2]
167
168     message = opts.message or "please add a '%s' to Contrib" % (dest_package)
169     if src_package.isdigit():
170         raise oscerr.WrongArgs('Numeric name of package is not allowed. Please add some alpha character')
171     id = create_submit_request(self.apiurl, src_project, src_package, self.project, dest_package, message)
172     print("Request id %s created" % (id))
173
174 def _do_contrib_accept(self, opts, args):
175
176     return self._contrib_sr_change(opts, args, "accepted", 
177            "Reviewed and checked OK.")
178
179 def _do_contrib_decline(self, opts, args):
180
181     if not opts.message:
182         raise oscerr.WrongArgs('A message is mandatory for decline')
183     
184     return self._contrib_sr_change(opts, args, "declined",
185            opts.message)
186
187 def _do_contrib_revoke(self, opts, args):
188     
189     if not opts.message:
190         raise oscerr.WrongArgs('A message is mandatory for decline')
191     
192     return self._contrib_sr_change(opts, args, "revoked",
193            opts.message)
194
195
196 def _do_contrib_co(self, opts, args):
197     return self._do_contrib_checkout(opts, args)
198
199 def _do_contrib_checkout(self, opts, args):
200     if len(args) < 1:
201         raise oscerr.WrongArgs("The package names are mandatory!!")
202
203     for package in args:
204         if opts.package:
205             src_project = self.project
206             src_package = package
207         else:
208             try:
209                 request = self._sr_from_package(package, opts.id, opts.last_request)
210             except oscerr.WrongArgs, wa_exc:
211                 raise oscerr.WrongArgs("".join(wa_exc.args) + \
212                     "\nUse -p/--package argument is you try to download a package from %s" % (self.project))
213             src_project = request.src_project
214             src_package = request.src_package
215
216         checkout_package(self.apiurl,
217                 src_project, src_package,
218                 expand_link=True)
219
220 def _do_contrib_build(self, opts, args):
221     import optparse
222     opts = optparse.Values(defaults=self.do_build.optparser.defaults)
223     opts.alternative_project = self.project
224     arch = ''
225     if len(args) > 1:
226         arch = args[0]
227     print('osc build --alternative-project %s standard %s' % (self.project, arch))
228     return self.do_build('build', opts, 'standard', arch)
229
230 def __do_contrib_role(self, role, opts, args):
231     if len(args) == 0:
232         raise oscerr.WrongArgs("The package names are mandatory!!")
233
234     for package in args:
235         meta = self._get_meta_xml(package)
236         for person in self._get_roles_from_meta(meta, role):
237             userid = person.get('userid')
238             data = userid
239             if opts.full_view:
240                 data = ", ".join(get_user_data(self.apiurl, userid, 'login', 'email'))
241             print "%s: %s" % (package, data)
242
243 def _do_contrib_maintainer(self, opts, args):
244     return self.__do_contrib_role('maintainer', opts, args)
245
246 def _do_contrib_bugowner(self, opts, args):
247     return self.__do_contrib_role('bugowner', opts, args)
248
249 def _do_contrib_todo(self, opts, args):
250     todo = self._get_todo_info()
251     self._pprint(todo)
252
253 # the original API is *very* ugly!!
254 # return the meta in an xml form first
255 def _get_meta_xml(self, package):
256     path = quote_plus(self.project),
257     kind = 'prj'
258     if package:
259         path = path + (quote_plus(package),)
260         kind = 'pkg'
261     data = meta_exists(metatype=kind,
262                        path_args=path,
263                        template_args=None,
264                        create_new=False)
265     if data:
266         return ET.fromstring(''.join(data))
267     raise oscerr.PackageError('Meta data for package %s missing' % (package))
268
269 # return all persons from meta
270 def _get_persons_from_meta(self, meta):
271     return meta.getiterator('person')
272
273 def _get_roles_from_meta(self, meta, role):
274     assert(role in ['maintainer', 'bugowner'])
275     return [p for p in self._get_persons_from_meta(meta) if p.get('role') == role]
276
277 def _has_user_role(self, meta, role, user):
278     assert(role in ['maintainer', 'bugowner'])
279     if not get_user_meta(self.apiurl, user):
280         raise oscerr.WrongArgs("The user %s doesn't exists" % (user))
281
282     return user in [p.get('userid') for p in self._get_roles_from_meta(meta, role)]
283
284 # from osc.core, FIXME, this is broken
285 # look at the svn, or send a patch to obs
286 def _addBugowner(self, apiurl, prj, pac, user):
287     """ add a new bugowner to a package or project """
288     path = quote_plus(prj),
289     kind = 'prj'
290     if pac:
291         path = path + (quote_plus(pac),)
292         kind = 'pkg'
293     data = meta_exists(metatype=kind,
294                        path_args=path,
295                        template_args=None,
296                        create_new=False)
297                        
298     if data and get_user_meta(apiurl, user) != None:
299         tree = ET.fromstring(''.join(data))
300         found = False
301         for person in tree.getiterator('person'):
302             if person.get('userid') == user and person.get('role') == 'bugowner':
303                 found = True
304                 print "user already exists"
305                 break
306         if not found:
307             # the xml has a fixed structure
308             tree.insert(2, ET.Element('person', role='bugowner', userid=user))
309             print 'user \'%s\' added to \'%s\'' % (user, pac or prj)
310             edit_meta(metatype=kind,
311                       path_args=path,
312                       data=ET.tostring(tree))
313     else:
314         print "osc: an error occured"
315
316 def _contrib_sr_change(self, opts, args, action, message):
317
318     if len(args) == 0:
319         raise oscerr.WrongArgs('The package name is mandatory for %s' % (action))
320
321     request = self._sr_from_package(args[0], opts.id, opts.last_request)
322     package = request.dst_package
323     
324     id = str(request.reqid)
325
326     # check the current state
327     sr = get_submit_request(self.apiurl, id)
328
329     if sr.state.name != 'new':
330         print "The state of %s request was changed to '%s' by '%s'" % (package, sr.state.name, sr.state.who)
331         res = raw_input("Do you want to change it to '%s'? [y/N] " % (action))
332         if res != 'y' and res != 'Y':
333             return
334
335     # check before change of commit request
336     is_new_package = not package in meta_get_packagelist(self.apiurl, self.project)
337     if action == 'accepted' and is_new_package:
338         message = "You are now a maintainer of %s in openSUSE:Factory:Contrib" % (package)
339
340         if package in meta_get_packagelist(self.apiurl, 'openSUSE:Factory'):
341             ret = raw_input("The '%s' was found in openSUSE:Factory project. Are you sure to accept this package [y/N] " % (package))
342             if ret != 'y' and ret != 'Y':
343                 print "Package was not accepted"
344                 return 0
345
346     # change to the state action
347     response = change_submit_request_state(self.apiurl, id, action, message)
348     
349     # change the state for a new packages
350     if action == 'accepted' and is_new_package:
351         # fix the maintainer and a bugowner
352         meta = self._get_meta_xml(package)
353         who = sr.state.who
354         if len(sr.statehistory) != 0:
355             who = sr.statehistory[0].who
356         if not self._has_user_role(meta, 'maintainer', who):
357             new_sr = get_submit_request(self.apiurl, id)
358             delMaintainer(self.apiurl, self.project, package, new_sr.state.who)
359             addMaintainer(self.apiurl, self.project, package, who)
360         if not self._has_user_role(meta, 'bugowner', who):
361             self._addBugowner(self.apiurl, self.project, package, who)
362         devel_project = opts.devel_project or self.project
363         addDevelProject(self.apiurl, self.project, package, devel_project)
364
365     print(response)
366
367 ########################################
368 #           TODO HANDLING              #
369 ########################################
370 def _get_todo_from_list(self, todo_list):
371     import csv
372     todo = dict()
373     # read a content
374     todo_reader = csv.reader(todo_list)
375     
376     try:
377         for pkg, new, old, maintainers in todo_reader:
378             todo[pkg] = (new, old, maintainers)
379     except ValueError:
380         pass
381
382     return todo
383
384 def _write_todo_cache(self, todo):
385     import cPickle as pickle
386     import fcntl
387     if not os.path.isfile(self.todo_cache):
388         fin = file(self.todo_cache, 'w')
389         fin.close()
390
391     fin = file(self.todo_cache, 'r+')
392     
393     try:
394         fcntl.flock(fin.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
395         todo = pickle.dump(todo, fin)
396     except IOError:
397         print "Cannot lock a cache file, maybe some others osc contrib todo processes are active"
398     finally:
399         fcntl.flock(fin.fileno(), fcntl.LOCK_UN)
400         fin.close()
401
402 def _get_todo_from_cache(self):
403     import cPickle as pickle
404     fin = file(self.todo_cache, 'r')
405     todo = pickle.load(fin)
406     fin.close()
407     return todo
408
409 # convert Last-Modified string from HTTP response to secs
410 def _last_modified_secs(self, lm):
411     import time
412     return (time.mktime(time.strptime(lm, "%a, %d %b %Y %H:%M:%S %Z")))
413
414 # convert statinfo to UTC seconds
415 def _st_mtime_secs(self, statinfo):
416     import time
417     return (time.mktime(time.gmtime(statinfo.st_mtime)))
418
419 def _needs_to_refresh_cache(self, lm):
420     import os
421     return not os.path.isfile(self.todo_cache) or (self._st_mtime_secs(os.stat(self.todo_cache)) < lm)
422
423 def _pprint(self, todo):
424     max_w = [0, 0, 0, 0]
425     title = ('Package', 'New', 'Current', 'Maintainer')
426     max_w[0] = max(map(lambda x: len(x), todo.keys())     + [len(title[0]), ])
427     max_w[1] = max(map(lambda x: len(x[0]), todo.values())+ [len(title[1]), ])
428     max_w[2] = max(map(lambda x: len(x[1]), todo.values())+ [len(title[2]), ])
429
430     i = [s.ljust(max_w[i]) for i, s in enumerate(title)]
431     str_title = "%s %s %s %s" % (i[0], i[1], i[2], i[3])
432     print str_title
433     print len(str_title)*"-"
434
435     for key in todo:
436         pkg = key
437         new, old, maintainers = todo[key]
438         i = [s.ljust(max_w[i]) for i, s in enumerate((pkg, new, old))]
439         print "%s %s %s (%s)" % (i[0], i[1], i[2], maintainers.replace(" ", ", "))
440     print len(str_title)*"-"
441     print "(This is just for your information.\nYou decide whether to upgrade a package or not.)"
442
443
444 def _get_todo_info(self):
445     import urllib2
446
447     todo = dict()
448
449     resp = urllib2.urlopen(self.todo_url)
450     if resp.code == 200 and resp.msg == 'OK':
451
452         lm = self._last_modified_secs(resp.headers.get('Last-Modified'))
453
454         if self._needs_to_refresh_cache(lm):
455             todo = self._get_todo_from_list(resp.readlines())
456             self._write_todo_cache(todo)
457         else:
458             todo = self._get_todo_from_cache()
459     
460     resp.close()
461     return todo