File osc-collab.py of Package osc-plugin-collab

# vim: set ts=4 sw=4 et: coding=UTF-8

#
# Copyright (c) 2008-2009, Novell, Inc.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
#  * Redistributions of source code must retain the above copyright notice,
#    this list of conditions and the following disclaimer.
#  * Redistributions in binary form must reproduce the above copyright notice,
#    this list of conditions and the following disclaimer in the documentation
#    and/or other materials provided with the distribution.
#  * Neither the name of the <ORGANIZATION> nor the names of its contributors
#    may be used to endorse or promote products derived from this software
#    without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
#
# (Licensed under the simplified BSD license)
#
# Authors: Vincent Untz <vuntz@opensuse.org>
#

import ConfigParser
import difflib
import httplib
import locale
import re
import select
import shutil
import subprocess
import tarfile
import tempfile
import time
import urllib
import urllib2
import urlparse

try:
    import rpm
    have_rpm = True
except ImportError:
    have_rpm = False

from osc import cmdln
from osc import conf


OSC_COLLAB_VERSION = '0.98'

# This is a hack to have osc ignore the file we create in a package directory.
_osc_collab_helper_prefixes = [ 'osc-collab.', 'osc-gnome.' ]
_osc_collab_helpers = []
for suffix in [ 'NEWS', 'ChangeLog', 'configure' ]:
    for prefix in _osc_collab_helper_prefixes:
        _osc_collab_helpers.append(prefix + suffix)
for helper in _osc_collab_helpers:
    conf.DEFAULTS['exclude_glob'] += ' %s' % helper

_osc_collab_alias = 'collab'
_osc_collab_config_parser = None
_osc_collab_osc_conffile = None


class OscCollabError(Exception):
    def __init__(self, value):
        self.msg = value

    def __str__(self):
        return repr(self.msg)


class OscCollabWebError(OscCollabError):
    pass

class OscCollabDownloadError(OscCollabError):
    pass

class OscCollabDiffError(OscCollabError):
    pass

class OscCollabCompressError(OscCollabError):
    pass


def _collab_exception_print(e, message = ''):
    if message == None:
        message = ''

    if hasattr(e, 'msg'):
        print >>sys.stderr, message + e.msg
    elif str(e) != '':
        print >>sys.stderr, message + str(e)
    else:
        print >>sys.stderr, message + e.__class__.__name__


#######################################################################


class OscCollabReservation:

    project = None
    package = None
    user = None

    def __init__(self, project = None, package = None, user = None, node = None):
        if node is None:
            self.project = project
            self.package = package
            self.user = user
        else:
            self.project = node.get('project')
            self.package = node.get('package')
            self.user = node.get('user')


    def __len__(self):
        return 3


    def __getitem__(self, key):
        if not type(key) == int:
            raise TypeError

        if key == 0:
            return self.project
        elif key == 1:
            return self.package
        elif key == 2:
            return self.user
        else:
            raise IndexError


    def is_relevant(self, projects, package):
        if self.project not in projects:
            return False
        if self.package != package:
            return False
        return True


#######################################################################


class OscCollabComment:

    project = None
    package = None
    user = None
    comment = None
    firstline = None

    def __init__(self, project = None, package = None, date = None, user = None, comment = None, node = None):
        if node is None:
            self.project = project
            self.package = package
            self.date = date
            self.user = user
            self.comment = comment
        else:
            self.project = node.get('project')
            self.package = node.get('package')
            self.date = node.get('date')
            self.user = node.get('user')
            self.comment = node.text
        if self.comment is None:
            self.firstline = None
        else:
            lines = self.comment.split('\n')
            self.firstline = lines[0]
            if len(lines) > 1:
                self.firstline += ' [...]'


    def __len__(self):
        return 4


    def __getitem__(self, key):
        if not type(key) == int:
            raise TypeError

        if key == 0:
            return self.project
        elif key == 1:
            return self.package
        elif key == 2:
            return self.user
        elif key == 3:
            return self.firstline
        else:
            raise IndexError


    def is_relevant(self, projects, package):
        if self.project not in projects:
            return False
        if self.package != package:
            return False
        return True


    def indent(self, spaces = '  '):
        lines = self.comment.split('\n')
        lines = [ spaces + line for line in lines ]
        return '\n'.join(lines)


#######################################################################


class OscCollabRequest():

    req_id = -1
    type = None
    source_project = None
    source_package = None
    source_rev = None
    dest_project = None
    dest_package = None
    state = None
    by = None
    at = None
    description = None

    def __init__(self, node):
        self.req_id = int(node.get('id'))

        # we only care about the first action here
        action = node.find('action')
        if action is None:
            action = node.find('submit') # for old style requests

        type = action.get('type', 'submit')

        subnode = action.find('source')
        if subnode is not None:
            self.source_project = subnode.get('project')
            self.source_package = subnode.get('package')
            self.source_rev = subnode.get('rev')

        subnode = action.find('target')
        if subnode is not None:
            self.target_project = subnode.get('project')
            self.target_package = subnode.get('package')

        subnode = node.find('state')
        if subnode is not None:
            self.state = subnode.get('name')
            self.by = subnode.get('who')
            self.at = subnode.get('when')

        subnode = node.find('description')
        if subnode is not None:
            self.description = subnode.text


#######################################################################


class OscCollabProject(dict):

    def __init__(self, node):
        self.name = node.get('name')
        self.parent = node.get('parent')
        self.ignore_upstream = node.get('ignore_upstream') == 'true'
        self.missing_packages = []


    def strip_internal_links(self):
        to_rm = []
        for package in self.itervalues():
            if package.parent_project == self.name:
                to_rm.append(package.name)
        for name in to_rm:
            del self[name]


    def is_toplevel(self):
        return self.parent in [ None, '' ]


    def __eq__(self, other):
        return self.name == other.name


    def __ne__(self, other):
        return not self.__eq__(other)


    def __lt__(self, other):
        return self.name < other.name


    def __le__(self, other):
        return self.__eq__(other) or self.__lt__(other)


    def __gt__(self, other):
        return other.__lt__(self)


    def __ge__(self, other):
        return other.__eq__(self) or other.__lt__(self)


#######################################################################


class OscCollabPackage:

    def __init__(self, node, project):
        self.name = None
        self.version = None
        self.parent_project = None
        self.parent_package = None
        self.parent_version = None
        self.devel_project = None
        self.devel_package = None
        self.devel_version = None
        self.upstream_version = None
        self.upstream_url = None
        self.is_link = False
        self.has_delta = False
        self.error = None
        self.error_details = None

        self.project = project

        if node is not None:
            self.name = node.get('name')

            parent = node.find('parent')
            if parent is not None:
                self.parent_project = parent.get('project')
                self.parent_package = parent.get('package')

            devel = node.find('devel')
            if devel is not None:
                self.devel_project = devel.get('project')
                self.devel_package = devel.get('package')

            version = node.find('version')
            if version is not None:
                self.version = version.get('current')
                if not project or not project.ignore_upstream:
                    self.upstream_version = version.get('upstream')
                self.parent_version = version.get('parent')
                self.devel_version = version.get('devel')

            if not project or not project.ignore_upstream:
                upstream = node.find('upstream')
                if upstream is not None:
                    url = upstream.find('url')
                    if url is not None:
                        self.upstream_url = url.text

            link = node.find('link')
            if link is not None:
                self.is_link = True
                if link.get('delta') == 'true':
                    self.has_delta = True

            delta = node.find('delta')
            if delta is not None:
                self.has_delta = True

            error = node.find('error')
            if error is not None:
                self.error = error.get('type')
                self.error_details = error.text

        # Reconstruct some data that we can deduce from the XML
        if project is not None and self.is_link and not self.parent_project:
            self.parent_project = project.parent
        if self.parent_project and not self.parent_package:
            self.parent_package = self.name
        if self.devel_project and not self.devel_package:
            self.devel_package = self.name


    def _compare_versions_a_gt_b(self, a, b):
        if have_rpm:
            # We're not really interested in the epoch or release parts of the
            # complete version because they're not relevant when comparing to
            # upstream version
            return rpm.labelCompare((None, a, '1'), (None, b, '1')) > 0

        split_a = a.split('.')
        split_b = b.split('.')

        # the two versions don't have the same format; we don't know how to
        # handle this
        if len(split_a) != len(split_b):
            return a > b

        for i in range(len(split_a)):
            try:
                int_a = int(split_a[i])
                int_b = int(split_b[i])
                if int_a > int_b:
                    return True
                if int_b > int_a:
                    return False
            except ValueError:
                if split_a[i] > split_b[i]:
                    return True
                if split_b[i] > split_a[i]:
                    return False

        return False


    def parent_more_recent(self):
        if not self.parent_version:
            return False

        return self._compare_versions_a_gt_b(self.parent_version, self.version)


    def needs_update(self):
        # empty upstream version, or upstream version meaning openSUSE is
        # upstream
        if self.upstream_version in [ None, '', '--' ]:
            return False

        return self._compare_versions_a_gt_b(self.upstream_version, self.parent_version) and self._compare_versions_a_gt_b(self.upstream_version, self.version)


    def devel_needs_update(self):
        # if there's no devel project, then it's as if it were needing an update
        if not self.devel_project:
            return True

        # empty upstream version, or upstream version meaning openSUSE is
        # upstream
        if self.upstream_version in [ None, '', '--' ]:
            return False

        return self._compare_versions_a_gt_b(self.upstream_version, self.devel_version)


    def is_broken_link(self):
        return self.error in [ 'not-in-parent', 'need-merge-with-parent' ]


    def __eq__(self, other):
        return self.name == other.name and self.project and other.project and self.project.name == other.project.name


    def __ne__(self, other):
        return not self.__eq__(other)


    def __lt__(self, other):
        if not self.project or not self.project.name:
            if other.project and other.project.name:
                return True
            else:
                return self.name < other.name

        if self.project.name == other.project.name:
            return self.name < other.name

        return self.project.name < other.project.name


    def __le__(self, other):
        return self.__eq__(other) or self.__lt__(other)


    def __gt__(self, other):
        return other.__lt__(self)


    def __ge__(self, other):
        return other.__eq__(self) or other.__lt__(self)


#######################################################################


class OscCollabObs:

    apiurl = None


    @classmethod
    def init(cls, apiurl):
        cls.apiurl = apiurl


    @classmethod
    def get_meta(cls, project):
        what = 'metadata of packages in %s' % project

        # download the data (cache for 2 days)
        url = makeurl(cls.apiurl, ['search', 'package'], ['match=%s' % urllib.quote('@project=\'%s\'' % project)])
        filename = '%s-meta.obs' % project
        max_age_minutes = 3600 * 24 * 2

        return OscCollabCache.get_from_obs(url, filename, max_age_minutes, what)


    @classmethod
    def get_build_results(cls, project):
        what = 'build results of packages in %s' % project

        # download the data (cache for 2 hours)
        url = makeurl(cls.apiurl, ['build', project, '_result'])
        filename = '%s-build-results.obs' % project
        max_age_minutes = 3600 * 2

        return OscCollabCache.get_from_obs(url, filename, max_age_minutes, what)


    @classmethod
    def _get_request_list_url(cls, project, package, type, what):
        match = '(state/@name=\'new\'%20or%20state/@name=\'review\')'
        match += '%20and%20'
        match += 'action/%s/@project=\'%s\'' % (type, urllib.quote(project))
        if package:
            match += '%20and%20'
            match += 'action/%s/@package=\'%s\'' % (type, urllib.quote(package))

        return makeurl(cls.apiurl, ['search', 'request'], ['match=%s' % match])


    @classmethod
    def _parse_request_list_internal(cls, f, what):
        requests = []

        try:
            collection = ET.parse(f).getroot()
        except SyntaxError, e:
            print >>sys.stderr, 'Cannot parse %s: %s' % (what, e.msg)
            return requests

        for node in collection.findall('request'):
            requests.append(OscCollabRequest(node))

        return requests


    @classmethod
    def _get_request_list_no_cache(cls, project, package, type, what):
        url = cls._get_request_list_url(project, package, type, what)

        try:
            fin = http_GET(url)
        except urllib2.HTTPError, e:
            print >>sys.stderr, 'Cannot get %s: %s' % (what, e.msg)
            return []

        requests = cls._parse_request_list_internal(fin, what)

        fin.close()

        return requests


    @classmethod
    def _get_request_list_with_cache(cls, project, package, type, what):
        url = cls._get_request_list_url(project, package, type, what)
        if url is None:
            return []

        # download the data (cache for 10 minutes)
        if package:
            filename = '%s-%s-requests-%s.obs' % (project, package, type)
        else:
            filename = '%s-requests-%s.obs' % (project, type)
        max_age_minutes = 60 * 10

        file = OscCollabCache.get_from_obs(url, filename, max_age_minutes, what)

        if not file or not os.path.exists(file):
            return []

        return cls._parse_request_list_internal(file, what)


    @classmethod
    def _get_request_list(cls, project, package, type, use_cache):
        if package:
            what_helper = '%s/%s' % (project, package)
        else:
            what_helper = project
        if type == 'source':
            what = 'list of requests from %s' % what_helper
        elif type == 'target':
            what = 'list of requests to %s' % what_helper
        else:
            print >>sys.stderr, 'Internal error when getting request list: unknown type \"%s\".' % type
            return None

        if use_cache:
            return cls._get_request_list_with_cache(project, package, type, what)
        else:
            return cls._get_request_list_no_cache(project, package, type, what)


    @classmethod
    def get_request_list_from(cls, project, package=None, use_cache=True):
        return cls._get_request_list(project, package, 'source', use_cache)


    @classmethod
    def get_request_list_to(cls, project, package=None, use_cache=True):
        return cls._get_request_list(project, package, 'target', use_cache)


    @classmethod
    def get_request(cls, id):
        url = makeurl(cls.apiurl, ['request', id])

        try:
            fin = http_GET(url)
        except urllib2.HTTPError, e:
            print >>sys.stderr, 'Cannot get request %s: %s' % (id, e.msg)
            return None

        try:
            node = ET.parse(fin).getroot()
        except SyntaxError, e:
            fin.close()
            print >>sys.stderr, 'Cannot parse request %s: %s' % (id, e.msg)
            return None

        fin.close()

        return OscCollabRequest(node)


    @classmethod
    def change_request_state(cls, id, new_state, message, superseded_by=None):
        if new_state != 'superseded':
            result = change_request_state(cls.apiurl, id, new_state, message)
        else:
            result = change_request_state(cls.apiurl, id, new_state, message, supersed=superseded_by)

        return result == 'ok'


    @classmethod
    def supersede_old_requests(cls, user, project, package, new_request_id):
        requests_to = cls.get_request_list_to(project, package, use_cache=False)
        old_ids = [ request.req_id for request in requests_to if request.by == user and str(request.req_id) != new_request_id ]
        for old_id in old_ids:
            cls.change_request_state(str(old_id), 'superseded', 'superseded by %s' % new_request_id, superseded_by=new_request_id)
        return old_ids


    @classmethod
    def branch_package(cls, project, package, no_devel_project = False):
        query = { 'cmd': 'branch' }
        if no_devel_project:
            query['ignoredevel'] = '1'

        url = makeurl(cls.apiurl, ['source', project, package], query = query)

        try:
            fin = http_POST(url)
        except urllib2.HTTPError, e:
            print >>sys.stderr, 'Cannot branch package %s: %s' % (package, e.msg)
            return (None, None)

        try:
            node = ET.parse(fin).getroot()
        except SyntaxError, e:
            fin.close()
            print >>sys.stderr, 'Cannot branch package %s: %s' % (package, e.msg)
            return (None, None)

        fin.close()

        branch_project = None
        branch_package = None

        for data in node.findall('data'):
            name = data.get('name')
            if not name:
                continue
            if name == 'targetproject' and data.text:
                branch_project = data.text
            elif name == 'targetpackage' and data.text:
                branch_package = data.text

        return (branch_project, branch_package)


#######################################################################


class OscCollabApi:

    _api_url = 'http://osc-collab.opensuse.org/api'
    _supported_api = '0.2'
    _supported_api_major = '0'

    @classmethod
    def init(cls, apiurl = None):
        if apiurl:
            cls._api_url = apiurl


    @classmethod
    def _append_data_to_url(cls, url, data):
        if url.find('?') != -1:
            return '%s&%s' % (url, data)
        else:
            return '%s?%s' % (url, data)


    @classmethod
    def _get_api_url_for(cls, api, project = None, projects = None, package = None, need_package_for_multiple_projects = True):
        if not project and len(projects) == 1:
            project = projects[0]

        items = [ cls._api_url, api ]
        if project:
            items.append(project)
        if package:
            items.append(package)
        url = '/'.join(items)

        if not project and (not need_package_for_multiple_projects or package) and projects:
            data = urlencode({'version': cls._supported_api, 'project': projects}, True)
            url = cls._append_data_to_url(url, data)
        else:
            data = urlencode({'version': cls._supported_api})
            url = cls._append_data_to_url(url, data)

        return url


    @classmethod
    def _get_info_url(cls, project = None, projects = None, package = None):
        return cls._get_api_url_for('info', project, projects, package, True)


    @classmethod
    def _get_reserve_url(cls, project = None, projects = None, package = None):
        return cls._get_api_url_for('reserve', project, projects, package, False)


    @classmethod
    def _get_comment_url(cls, project = None, projects = None, package = None):
        return cls._get_api_url_for('comment', project, projects, package, False)


    @classmethod
    def _get_root_for_url(cls, url, error_prefix, post_data = None, cache_file = None, cache_age = 10):
        if post_data and type(post_data) != dict:
            raise OscCollabWebError('%s: Internal error when posting data' % error_prefix)

        try:
            if cache_file and not post_data:
                fd = OscCollabCache.get_url_fd_with_cache(url, cache_file, cache_age)
            else:
                if post_data:
                    data = urlencode(post_data)
                else:
                    data = None
                fd = urllib2.urlopen(url, data)
        except urllib2.HTTPError, e:
            raise OscCollabWebError('%s: %s' % (error_prefix, e.msg))

        try:
            root = ET.parse(fd).getroot()
        except SyntaxError:
            raise OscCollabWebError('%s: malformed reply from server.' % error_prefix)

        if root.tag != 'api' or not root.get('version'):
            raise OscCollabWebError('%s: invalid reply from server.' % error_prefix)

        version = root.get('version')
        version_items = version.split('.')
        for item in version_items:
            try:
                int(item)
            except ValueError:
                raise OscCollabWebError('%s: unknown protocol used by server.' % error_prefix)
        protocol = int(version_items[0])
        if int(version_items[0]) != int(cls._supported_api_major):
            raise OscCollabWebError('%s: unknown protocol used by server.' % error_prefix)

        result = root.find('result')
        if result is None or not result.get('ok'):
            raise OscCollabWebError('%s: reply from server with no result summary.' % error_prefix)

        if result.get('ok') != 'true':
            if result.text:
                raise OscCollabWebError('%s: %s' % (error_prefix, result.text))
            else:
                raise OscCollabWebError('%s: unknown error in the request.' % error_prefix)

        return root


    @classmethod
    def _meta_append_no_devel_project(cls, url, no_devel_project):
        if not no_devel_project:
            return url

        data = urlencode({'ignoredevel': 'true'})
        return cls._append_data_to_url(url, data)


    @classmethod
    def _parse_reservation_node(cls, node):
        reservation = OscCollabReservation(node = node)
        if not reservation.project or not reservation.package:
            return None

        return reservation


    @classmethod
    def get_reserved_packages(cls, projects):
        url = cls._get_reserve_url(projects = projects)
        root = cls._get_root_for_url(url, 'Cannot get list of reserved packages')

        reserved_packages = []
        for reservation in root.findall('reservation'):
            item = cls._parse_reservation_node(reservation)
            if item is None or not item.user:
                continue
            reserved_packages.append(item)

        return reserved_packages


    @classmethod
    def is_package_reserved(cls, projects, package, no_devel_project = False):
        '''
            Only returns something if the package is really reserved.
        '''
        url = cls._get_reserve_url(projects = projects, package = package)
        url = cls._meta_append_no_devel_project(url, no_devel_project)
        root = cls._get_root_for_url(url, 'Cannot look if package %s is reserved' % package)

        for reservation in root.findall('reservation'):
            item = cls._parse_reservation_node(reservation)
            if item is None or (no_devel_project and not item.is_relevant(projects, package)):
                continue
            if not item.user:
                # We continue to make sure there are no other relevant entries
                continue
            return item

        return None


    @classmethod
    def reserve_package(cls, projects, package, username, no_devel_project = False):
        url = cls._get_reserve_url(projects = projects, package = package)
        data = urlencode({'cmd': 'set', 'user': username})
        url = cls._append_data_to_url(url, data)
        url = cls._meta_append_no_devel_project(url, no_devel_project)
        root = cls._get_root_for_url(url, 'Cannot reserve package %s' % package)

        for reservation in root.findall('reservation'):
            item = cls._parse_reservation_node(reservation)
            if not item or (no_devel_project and not item.is_relevant(projects, package)):
                continue
            if not item.user:
                raise OscCollabWebError('Cannot reserve package %s: unknown error' % package)
            if item.user != username:
                raise OscCollabWebError('Cannot reserve package %s: already reserved by %s' % (package, item.user))
            return item


    @classmethod
    def unreserve_package(cls, projects, package, username, no_devel_project = False):
        url = cls._get_reserve_url(projects = projects, package = package)
        data = urlencode({'cmd': 'unset', 'user': username})
        url = cls._append_data_to_url(url, data)
        url = cls._meta_append_no_devel_project(url, no_devel_project)
        root = cls._get_root_for_url(url, 'Cannot unreserve package %s' % package)

        for reservation in root.findall('reservation'):
            item = cls._parse_reservation_node(reservation)
            if not item or (no_devel_project and not item.is_relevant(projects, package)):
                continue
            if item.user:
                raise OscCollabWebError('Cannot unreserve package %s: reserved by %s' % (package, item.user))
            return item


    @classmethod
    def _parse_comment_node(cls, node):
        comment = OscCollabComment(node = node)
        if not comment.project or not comment.package:
            return None

        return comment


    @classmethod
    def get_commented_packages(cls, projects):
        url = cls._get_comment_url(projects = projects)
        root = cls._get_root_for_url(url, 'Cannot get list of commented packages')

        commented_packages = []
        for comment in root.findall('comment'):
            item = cls._parse_comment_node(comment)
            if item is None or not item.user or not item.comment:
                continue
            commented_packages.append(item)

        return commented_packages


    @classmethod
    def get_package_comment(cls, projects, package, no_devel_project = False):
        '''
            Only returns something if the package is really commented.
        '''
        url = cls._get_comment_url(projects = projects, package = package)
        url = cls._meta_append_no_devel_project(url, no_devel_project)
        root = cls._get_root_for_url(url, 'Cannot look if package %s is commented' % package)

        for comment in root.findall('comment'):
            item = cls._parse_comment_node(comment)
            if item is None or (no_devel_project and not item.is_relevant(projects, package)):
                continue
            if not item.user or not item.comment:
                # We continue to make sure there are no other relevant entries
                continue
            return item

        return None


    @classmethod
    def set_package_comment(cls, projects, package, username, comment, no_devel_project = False):
        if not comment:
            raise OscCollabWebError('Cannot set comment on package %s: empty comment' % package)

        url = cls._get_comment_url(projects = projects, package = package)
        data = urlencode({'cmd': 'set', 'user': username})
        url = cls._append_data_to_url(url, data)
        url = cls._meta_append_no_devel_project(url, no_devel_project)
        root = cls._get_root_for_url(url, 'Cannot set comment on package %s' % package, post_data = {'comment': comment})

        for comment in root.findall('comment'):
            item = cls._parse_comment_node(comment)
            if not item or (no_devel_project and not item.is_relevant(projects, package)):
                continue
            if not item.user:
                raise OscCollabWebError('Cannot set comment on package %s: unknown error' % package)
            if item.user != username:
                raise OscCollabWebError('Cannot set comment on package %s: already commented by %s' % (package, item.user))
            return item


    @classmethod
    def unset_package_comment(cls, projects, package, username, no_devel_project = False):
        url = cls._get_comment_url(projects = projects, package = package)
        data = urlencode({'cmd': 'unset', 'user': username})
        url = cls._append_data_to_url(url, data)
        url = cls._meta_append_no_devel_project(url, no_devel_project)
        root = cls._get_root_for_url(url, 'Cannot unset comment on package %s' % package)

        for comment in root.findall('comment'):
            item = cls._parse_comment_node(comment)
            if not item or (no_devel_project and not item.is_relevant(projects, package)):
                continue
            if item.user:
                raise OscCollabWebError('Cannot unset comment on package %s: commented by %s' % (package, item.user))
            return item


    @classmethod
    def _parse_package_node(cls, node, project):
        package = OscCollabPackage(node, project)
        if not package.name:
            return None

        if project is not None:
            project[package.name] = package

        return package


    @classmethod
    def _parse_missing_package_node(cls, node, project):
        name = node.get('name')
        parent_project = node.get('parent_project')
        parent_package = node.get('parent_package') or name

        if not name or not parent_project:
            return

        project.missing_packages.append((name, parent_project, parent_package))


    @classmethod
    def _parse_project_node(cls, node):
        project = OscCollabProject(node)
        if not project.name:
            return None

        for package in node.findall('package'):
            cls._parse_package_node(package, project)

        missing = node.find('missing')
        if missing is not None:
            for package in missing.findall('package'):
                cls._parse_missing_package_node(package, project)

        return project


    @classmethod
    def get_project_details(cls, project):
        url = cls._get_info_url(project = project)
        root = cls._get_root_for_url(url, 'Cannot get information of project %s' % project, cache_file = project + '.xml')

        for node in root.findall('project'):
            item = cls._parse_project_node(node)
            if item is None or item.name != project:
                continue
            return item

        return None


    @classmethod
    def get_package_details(cls, projects, package):
        url = cls._get_info_url(projects = projects, package = package)
        root = cls._get_root_for_url(url, 'Cannot get information of package %s' % package)

        for node in root.findall('project'):
            item = cls._parse_project_node(node)
            if item is None or item.name not in projects:
                continue

            pkgitem = item[package]
            if pkgitem:
                return pkgitem

        return None


#######################################################################


class OscCollabCache:

    _cache_dir = None
    _ignore_cache = False
    _printed = False

    @classmethod
    def init(cls, ignore_cache):
        cls._ignore_cache = ignore_cache
        cls._cleanup_old_cache()


    @classmethod
    def _print_message(cls):
        if not cls._printed:
            cls._printed = True
            print 'Downloading data in a cache. It might take a few seconds...'


    @classmethod
    def _get_xdg_cache_home(cls):
        dir = None
        if os.environ.has_key('XDG_CACHE_HOME'):
            dir = os.environ['XDG_CACHE_HOME']
            if dir == '':
                dir = None

        if not dir:
            dir = '~/.cache'

        return os.path.expanduser(dir)


    @classmethod
    def _get_xdg_cache_dir(cls):
        if not cls._cache_dir:
            cls._cache_dir = os.path.join(cls._get_xdg_cache_home(), 'osc', 'collab')

        return cls._cache_dir


    @classmethod
    def _cleanup_old_cache(cls):
        '''
            Remove old cache files, when they're old (and therefore obsolete
            anyway).
        '''
        gnome_cache_dir = os.path.join(cls._get_xdg_cache_home(), 'osc', 'gnome')
        if os.path.exists(gnome_cache_dir):
            shutil.rmtree(gnome_cache_dir)

        cache_dir = cls._get_xdg_cache_dir()
        if not os.path.exists(cache_dir):
            return

        for file in os.listdir(cache_dir):
            # remove if it's more than 5 days old
            if cls._need_update(file, 60 * 60 * 24 * 5):
                cache = os.path.join(cache_dir, file)
                os.unlink(cache)


    @classmethod
    def _need_update(cls, filename, maxage):
        if cls._ignore_cache:
            return True

        cache = os.path.join(cls._get_xdg_cache_dir(), filename)

        if not os.path.exists(cache):
            return True

        if not os.path.isfile(cache):
            return True

        stats = os.stat(cache)

        now = time.time()
        if now - stats.st_mtime > maxage:
            return True
        # Back to the future?
        elif now < stats.st_mtime:
            return True

        return False


    @classmethod
    def get_url_fd_with_cache(cls, url, filename, max_age_minutes):
        if cls._need_update(filename, max_age_minutes * 60):
            # no cache available
            cls._print_message()
            fd = urllib2.urlopen(url)
            cls._write(filename, fin = fd)

        return open(os.path.join(cls._get_xdg_cache_dir(), filename))


    @classmethod
    def get_from_obs(cls, url, filename, max_age_minutes, what):
        cache = os.path.join(cls._get_xdg_cache_dir(), filename)

        if not cls._need_update(cache, max_age_minutes):
            return cache

        # no cache available
        cls._print_message()

        try:
            fin = http_GET(url)
        except urllib2.HTTPError, e:
            print >>sys.stderr, 'Cannot get %s: %s' % (what, e.msg)
            return None

        fout = open(cache, 'w')

        while True:
            try:
                bytes = fin.read(500 * 1024)
                if len(bytes) == 0:
                    break
                fout.write(bytes)
            except urllib2.HTTPError, e:
                fin.close()
                fout.close()
                os.unlink(cache)
                print >>sys.stderr, 'Error while downloading %s: %s' % (what, e.msg)
                return None

        fin.close()
        fout.close()

        return cache


    @classmethod
    def _write(cls, filename, fin = None):
        if not fin:
            print >>sys.stderr, 'Internal error when saving a cache: no data.'
            return False

        cachedir = cls._get_xdg_cache_dir()
        if not os.path.exists(cachedir):
            os.makedirs(cachedir)

        if not os.path.isdir(cachedir):
            print >>sys.stderr, 'Cache directory %s is not a directory.' % cachedir
            return False

        cache = os.path.join(cachedir, filename)
        if os.path.exists(cache):
            os.unlink(cache)
        fout = open(cache, 'w')

        if fin:
            while True:
                try:
                    bytes = fin.read(500 * 1024)
                    if len(bytes) == 0:
                        break
                    fout.write(bytes)
                except urllib2.HTTPError, e:
                    fout.close()
                    os.unlink(cache)
                    raise e
            fout.close()
            return True


#######################################################################


def _collab_is_program_in_path(program):
    if not os.environ.has_key('PATH'):
        return False

    for path in os.environ['PATH'].split(':'):
        if os.path.exists(os.path.join(path, program)):
            return True

    return False


#######################################################################


def _collab_find_request_to(package, requests):
    for request in requests:
        if request.target_package == package:
            return request
    return None


def _collab_has_request_from(package, requests):
    for request in requests:
        if request.source_package == package:
            return True
    return False


#######################################################################


def _collab_table_get_maxs(init, list):
    if len(list) == 0:
        return ()

    nb_maxs = len(init)
    maxs = []
    for i in range(nb_maxs):
        maxs.append(len(init[i]))

    for item in list:
        for i in range(nb_maxs):
            maxs[i] = max(maxs[i], len(item[i]))

    return tuple(maxs)


def _collab_table_get_template(*args):
    if len(args) == 0:
        return ''

    template = '%%-%d.%ds' % (args[0], args[0])
    index = 1

    while index < len(args):
        template = template + (' | %%-%d.%ds' % (args[index], args[index]))
        index = index + 1

    return template


def _collab_table_print_header(template, title):
    if len(title) == 0:
        return

    dash_template = template.replace(' | ', '-+-')

    very_long_dash = ('--------------------------------------------------------------------------------',)
    dashes = ()
    for i in range(len(title)):
        dashes = dashes + very_long_dash

    print template % title
    print dash_template % dashes


#######################################################################


def _collab_todo_internal(apiurl, all_reserved, all_commented, project, show_details, exclude_commented, exclude_reserved, exclude_submitted, exclude_devel):
    # get all versions of packages
    try:
        prj = OscCollabApi.get_project_details(project)
        prj.strip_internal_links()
    except OscCollabWebError, e:
        print >>sys.stderr, e.msg
        return (None, None)

    # get the list of reserved packages for this project
    reserved_packages = [ reservation.package for reservation in all_reserved if reservation.project == project ]

    # get the list of commented packages for this project
    firstline_comments = {}
    for comment in all_commented:
        if comment.project == project:
            firstline_comments[comment.package] = comment.firstline
    commented_packages = firstline_comments.keys()

    # get the packages submitted
    requests_to = OscCollabObs.get_request_list_to(project)

    parent_project = None
    packages = []

    for package in prj.itervalues():
        if not package.needs_update() and package.name not in commented_packages:
            continue

        broken_link = package.is_broken_link()

        if package.parent_version or package.is_link:
            package.parent_version_print = package.parent_version or ''
        elif broken_link:
            # this can happen if the link is to a project that doesn't exist
            # anymore
            package.parent_version_print = '??'
        else:
            package.parent_version_print = '--'

        if package.version:
            package.version_print = package.version
        elif broken_link:
            package.version_print = '(broken)'
        else:
            package.version_print = '??'

        package.upstream_version_print = package.upstream_version or ''
        package.comment = ''

        if package.name in commented_packages:
            if exclude_commented:
                continue
            if not show_details:
                package.version_print += ' (c)'
                package.upstream_version_print += ' (c)'
            package.comment = firstline_comments[package.name]

        if not package.devel_needs_update():
            if exclude_devel:
                continue
            package.version_print += ' (d)'
            package.upstream_version_print += ' (d)'

        if _collab_find_request_to(package.name, requests_to) != None:
            if exclude_submitted:
                continue
            package.version_print += ' (s)'
            package.upstream_version_print += ' (s)'

        if package.name in reserved_packages:
            if exclude_reserved:
                continue
            package.upstream_version_print += ' (r)'

        package.upstream_version_print = package.upstream_version_print.strip()

        if package.parent_project:
            if parent_project == None:
                parent_project = package.parent_project
            elif parent_project != package.parent_project:
                parent_project = 'Parent Project'

        packages.append(package)


    return (parent_project, packages)


#######################################################################


def _collab_todo(apiurl, projects, show_details, ignore_comments, exclude_commented, exclude_reserved, exclude_submitted, exclude_devel):
    packages = []
    parent_project = None

    # get the list of reserved packages
    try:
        reserved = OscCollabApi.get_reserved_packages(projects)
    except OscCollabWebError, e:
        reserved = []
        print >>sys.stderr, e.msg

    # get the list of commented packages
    commented = []
    if not ignore_comments:
        try:
            commented = OscCollabApi.get_commented_packages(projects)
        except OscCollabWebError, e:
            print >>sys.stderr, e.msg

    for project in projects:
        (new_parent_project, project_packages) = _collab_todo_internal(apiurl, reserved, commented, project, show_details, exclude_commented, exclude_reserved, exclude_submitted, exclude_devel)
        if not project_packages:
            continue
        packages.extend(project_packages)

        if parent_project == None:
            parent_project = new_parent_project
        elif parent_project != new_parent_project:
            parent_project = 'Parent Project'

    if len(packages) == 0:
        print 'Nothing to do.'
        return

    show_comments = not (ignore_comments or exclude_commented) and show_details

    if show_comments:
        lines = [ (package.name, package.parent_version_print, package.version_print, package.upstream_version_print, package.comment) for package in packages ]
    else:
        lines = [ (package.name, package.parent_version_print, package.version_print, package.upstream_version_print) for package in packages ]

    # the first element in the tuples is the package name, so it will sort
    # the lines the right way for what we want
    lines.sort()

    if len(projects) == 1:
        project_header = projects[0]
    else:
        project_header = "Devel Project"

    # print headers
    if show_comments:
        if parent_project:
            title = ('Package', parent_project, project_header, 'Upstream', 'Comment')
            (max_package, max_parent, max_devel, max_upstream, max_comment) = _collab_table_get_maxs(title, lines)
        else:
            title = ('Package', project_header, 'Upstream', 'Comment')
            (max_package, max_devel, max_upstream, max_comment) = _collab_table_get_maxs(title, lines)
            max_parent = 0
    else:
        if parent_project:
            title = ('Package', parent_project, project_header, 'Upstream')
            (max_package, max_parent, max_devel, max_upstream) = _collab_table_get_maxs(title, lines)
        else:
            title = ('Package', project_header, 'Upstream')
            (max_package, max_devel, max_upstream) = _collab_table_get_maxs(title, lines)
            max_parent = 0
        max_comment = 0

    # trim to a reasonable max
    max_package = min(max_package, 48)
    max_version = min(max(max(max_parent, max_devel), max_upstream), 20)
    max_comment = min(max_comment, 48)

    if show_comments:
        if parent_project:
            print_line = _collab_table_get_template(max_package, max_version, max_version, max_version, max_comment)
        else:
            print_line = _collab_table_get_template(max_package, max_version, max_version, max_comment)
    else:
        if parent_project:
            print_line = _collab_table_get_template(max_package, max_version, max_version, max_version)
        else:
            print_line = _collab_table_get_template(max_package, max_version, max_version)

    _collab_table_print_header(print_line, title)

    for line in lines:
        if not parent_project:
            if show_comments:
                (package, parent_version, devel_version, upstream_version, comment) = line
                line = (package, devel_version, upstream_version, comment)
            else:
                (package, parent_version, devel_version, upstream_version) = line
                line = (package, devel_version, upstream_version)
        print print_line % line


#######################################################################


def _collab_todoadmin_internal(apiurl, project, include_upstream):

    try:
        prj = OscCollabApi.get_project_details(project)
        prj.strip_internal_links()
    except OscCollabWebError, e:
        print >>sys.stderr, e.msg
        return []

    # get the packages submitted to/from
    requests_to = OscCollabObs.get_request_list_to(project)
    requests_from = OscCollabObs.get_request_list_from(project)

    lines = []

    for package in prj.itervalues():
        message = None

        # We look for all possible messages. The last message overwrite the
        # first, so we start with the less important ones.

        if include_upstream:
            if not package.upstream_version:
                message = 'No upstream data available'
            elif not package.upstream_url:
                message = 'No URL for upstream tarball available'

        if package.has_delta:
            # FIXME: we should check the request is to the parent project
            if not _collab_has_request_from(package.name, requests_from):
                if not package.is_link:
                    message = 'Is not a link to %s and has a delta (synchronize the packages)' % package.project.parent
                elif not package.project.is_toplevel():
                    message = 'Needs to be submitted to %s' % package.parent_project
                else:
                    # packages in a toplevel project don't necessarily have to
                    # be submitted
                    message = 'Is a link with delta (maybe submit changes to %s)' % package.parent_project

        request = _collab_find_request_to(package.name, requests_to)
        if request is not None:
            message = 'Needs to be reviewed (request id: %s)' % request.req_id

        if package.error:
            if package.error == 'not-link':
                # if package has a delta, then we already set a message above
                if not package.has_delta:
                    message = 'Is not a link to %s (make link)' % package.project.parent
            elif package.error == 'not-link-not-in-parent':
                message = 'Is not a link, and is not in %s (maybe submit it)' % package.project.parent
            elif package.error == 'not-in-parent':
                message = 'Broken link: does not exist in %s' % package.parent_project
            elif package.error == 'need-merge-with-parent':
                message = 'Broken link: requires a manual merge with %s' % package.parent_project
            elif package.error == 'not-real-devel':
                message = 'Should not exist: %s' % package.error_details
            elif package.error == 'parent-without-devel':
                message = 'No devel project set for parent (%s/%s)' % (package.parent_project, package.parent_package)
            else:
                if package.error_details:
                    message = 'Unknown error (%s): %s' % (package.error, package.error_details)
                else:
                    message = 'Unknown error (%s)' % package.error

        if message:
            lines.append((project, package.name, message))


    for (package, parent_project, parent_package) in prj.missing_packages:
        message = 'Does not exist, but is devel package for %s/%s' % (parent_project, parent_package)
        lines.append((project, package, message))


    lines.sort()

    return lines


#######################################################################


def _collab_todoadmin(apiurl, projects, include_upstream):
    lines = []

    for project in projects:
        project_lines = _collab_todoadmin_internal(apiurl, project, include_upstream)
        lines.extend(project_lines)

    if len(lines) == 0:
        print 'Nothing to do.'
        return

    # the first element in the tuples is the package name, so it will sort
    # the lines the right way for what we want
    lines.sort()

    # print headers
    title = ('Project', 'Package', 'Details')
    (max_project, max_package, max_details) = _collab_table_get_maxs(title, lines)
    # trim to a reasonable max
    max_project = min(max_project, 28)
    max_package = min(max_package, 48)
    max_details = min(max_details, 65)

    print_line = _collab_table_get_template(max_project, max_package, max_details)
    _collab_table_print_header(print_line, title)
    for line in lines:
        print print_line % line


#######################################################################


def _collab_listreserved(projects):
    try:
        reserved_packages = OscCollabApi.get_reserved_packages(projects)
    except OscCollabWebError, e:
        print >>sys.stderr, e.msg
        return

    if len(reserved_packages) == 0:
        print 'No package reserved.'
        return

    # print headers
    # if changing the order here, then we need to change __getitem__ of
    # Reservation in the same way
    title = ('Project', 'Package', 'Reserved by')
    (max_project, max_package, max_username) = _collab_table_get_maxs(title, reserved_packages)
    # trim to a reasonable max
    max_project = min(max_project, 28)
    max_package = min(max_package, 48)
    max_username = min(max_username, 28)

    print_line = _collab_table_get_template(max_project, max_package, max_username)
    _collab_table_print_header(print_line, title)

    for reservation in reserved_packages:
        if reservation.user:
            print print_line % (reservation.project, reservation.package, reservation.user)


#######################################################################


def _collab_isreserved(projects, packages, no_devel_project = False):
    for package in packages:
        try:
            reservation = OscCollabApi.is_package_reserved(projects, package, no_devel_project = no_devel_project)
        except OscCollabWebError, e:
            print >>sys.stderr, e.msg
            continue

        if not reservation:
            print 'Package %s is not reserved.' % package
        else:
            if reservation.project not in projects or reservation.package != package:
                print 'Package %s in %s (devel package for %s) is reserved by %s.' % (reservation.package, reservation.project, package, reservation.user)
            else:
                print 'Package %s in %s is reserved by %s.' % (package, reservation.project, reservation.user)


#######################################################################


def _collab_reserve(projects, packages, username, no_devel_project = False):
    for package in packages:
        try:
            reservation = OscCollabApi.reserve_package(projects, package, username, no_devel_project = no_devel_project)
        except OscCollabWebError, e:
            print >>sys.stderr, e.msg
            continue

        if reservation.project not in projects or reservation.package != package:
            print 'Package %s in %s (devel package for %s) reserved for 36 hours.' % (reservation.package, reservation.project, package)
        else:
            print 'Package %s reserved for 36 hours.' % package
        print 'Do not forget to unreserve the package when done with it:'
        print '    osc %s unreserve %s' % (_osc_collab_alias, package)


#######################################################################


def _collab_unreserve(projects, packages, username, no_devel_project = False):
    for package in packages:
        try:
            OscCollabApi.unreserve_package(projects, package, username, no_devel_project = no_devel_project)
        except OscCollabWebError, e:
            print >>sys.stderr, e.msg
            continue

        print 'Package %s unreserved.' % package


#######################################################################


def _collab_listcommented(projects):
    try:
        commented_packages = OscCollabApi.get_commented_packages(projects)
    except OscCollabWebError, e:
        print >>sys.stderr, e.msg
        return

    if len(commented_packages) == 0:
        print 'No package commented.'
        return

    # print headers
    # if changing the order here, then we need to change __getitem__ of
    # Comment in the same way
    title = ('Project', 'Package', 'Commented by', 'Comment')
    (max_project, max_package, max_username, max_comment) = _collab_table_get_maxs(title, commented_packages)
    # trim to a reasonable max
    max_project = min(max_project, 28)
    max_package = min(max_package, 48)
    max_username = min(max_username, 28)
    max_comment = min(max_comment, 65)

    print_line = _collab_table_get_template(max_project, max_package, max_username, max_comment)
    _collab_table_print_header(print_line, title)

    for comment in commented_packages:
        if comment.user:
            print print_line % (comment.project, comment.package, comment.user, comment.firstline)


#######################################################################


def _collab_comment(projects, packages, no_devel_project = False):
    for package in packages:
        try:
            comment = OscCollabApi.get_package_comment(projects, package, no_devel_project = no_devel_project)
        except OscCollabWebError, e:
            print >>sys.stderr, e.msg
            continue

        if not comment:
            print 'Package %s is not commented.' % package
        else:
            if comment.date:
                date_str = ' on %s' % comment.date
            else:
                date_str = ''

            if comment.project not in projects or comment.package != package:
                print 'Package %s in %s (devel package for %s) is commented by %s%s:' % (comment.package, comment.project, package, comment.user, date_str)
                print comment.indent()
            else:
                print 'Package %s in %s is commented by %s%s:' % (package, comment.project, comment.user, date_str)
                print comment.indent()


#######################################################################


def _collab_commentset(projects, package, username, comment, no_devel_project = False):
    try:
        comment = OscCollabApi.set_package_comment(projects, package, username, comment, no_devel_project = no_devel_project)
    except OscCollabWebError, e:
        print >>sys.stderr, e.msg
        return

    if comment.project not in projects or comment.package != package:
        print 'Comment on package %s in %s (devel package for %s) set.' % (comment.package, comment.project, package)
    else:
        print 'Comment on package %s set.' % package
    print 'Do not forget to unset comment on the package when done with it:'
    print '    osc %s commentunset %s' % (_osc_collab_alias, package)


#######################################################################


def _collab_commentunset(projects, packages, username, no_devel_project = False):
    for package in packages:
        try:
            OscCollabApi.unset_package_comment(projects, package, username, no_devel_project = no_devel_project)
        except OscCollabWebError, e:
            print >>sys.stderr, e.msg
            continue

        print 'Comment on package %s unset.' % package


#######################################################################


def _collab_setup_internal(apiurl, username, pkg, ignore_reserved = False, no_reserve = False, no_devel_project = False, no_branch = False):
    if not no_devel_project:
        initial_pkg = pkg
        while pkg.devel_project:
            previous_pkg = pkg
            try:
                pkg = OscCollabApi.get_package_details(pkg.devel_project, pkg.devel_package or pkg.name)
            except OscCollabWebError, e:
                pkg = None

            if not pkg:
                print >>sys.stderr, 'Cannot find information on %s/%s (development package for %s/%s). You can use --nodevelproject to ignore the development package.' % (previous_pkg.project.name, previous_pkg.name, initial_pkg.project.name, initial_pkg.name)
                break

        if not pkg:
            return (False, None, None)

        if initial_pkg != pkg:
            print 'Using development package %s/%s for %s/%s.' % (pkg.project.name, pkg.name, initial_pkg.project.name, initial_pkg.name)

    project = pkg.project.name
    package = pkg.name

    checkout_dir = package

    # Is it reserved? Note that we have already looked for the devel project,
    # so we force the project/package here.
    try:
        reservation = OscCollabApi.is_package_reserved((project,), package, no_devel_project = True)
        if reservation:
            reserved_by = reservation.user
        else:
            reserved_by = None
    except OscCollabWebError, e:
        print >>sys.stderr, e.msg
        return (False, None, None)

    # package already reserved, but not by us
    if reserved_by and reserved_by != username:
        if not ignore_reserved:
            print 'Package %s is already reserved by %s.' % (package, reserved_by)
            return (False, None, None)
        else:
            print 'WARNING: package %s is already reserved by %s.' % (package, reserved_by)
    # package not reserved
    elif not reserved_by and not no_reserve:
        try:
            # Note that we have already looked for the devel project, so we
            # force the project/package here.
            OscCollabApi.reserve_package((project,), package, username, no_devel_project = True)
            print 'Package %s has been reserved for 36 hours.' % package
            print 'Do not forget to unreserve the package when done with it:'
            print '    osc %s unreserve %s' % (_osc_collab_alias, package)
        except OscCollabWebError, e:
            print >>sys.stderr, e.msg
            if not ignore_reserved:
                return (False, None, None)

    if not no_branch:
        # look if we already have a branch, and if not branch the package
        try:
            expected_branch_project = 'home:%s:branches:%s' % (username, project)
            show_package_meta(apiurl, expected_branch_project, package)
            branch_project = expected_branch_project
            branch_package = package
            # it worked, we already have the branch
        except urllib2.HTTPError, e:
            if e.code != 404:
                print >>sys.stderr, 'Error while checking if package %s was already branched: %s' % (package, e.msg)
                return (False, None, None)

            # We had a 404: it means the branched package doesn't exist yet
            (branch_project, branch_package) = OscCollabObs.branch_package(project, package, no_devel_project)
            if not branch_project or not branch_package:
                print >>sys.stderr, 'Error while branching package %s: incomplete reply from build service' % (package,)
                return (False, None, None)

            if package != branch_package:
                print 'Package %s has been branched in %s/%s.' % (package, branch_project, branch_package)
            else:
                print 'Package %s has been branched in project %s.' % (branch_package, branch_project)
    else:
            branch_project = project
            branch_package = package

    checkout_dir = branch_package

    # check out the branched package
    if os.path.exists(checkout_dir):
        # maybe we already checked it out before?
        if not os.path.isdir(checkout_dir):
            print >>sys.stderr, 'File %s already exists but is not a directory.' % checkout_dir
            return (False, None, None)
        elif not is_package_dir(checkout_dir):
            print >>sys.stderr, 'Directory %s already exists but is not a checkout of a Build Service package.' % checkout_dir
            return (False, None, None)

        obs_package = filedir_to_pac(checkout_dir)
        if obs_package.name != branch_package or obs_package.prjname != branch_project:
            print >>sys.stderr, 'Directory %s already exists but is a checkout of package %s from project %s.' % (checkout_dir, obs_package.name, obs_package.prjname)
            return (False, None, None)

        if _collab_osc_package_pending_commit(obs_package):
            print >>sys.stderr, 'Directory %s contains some uncommitted changes.' % (checkout_dir,)
            return (False, None, None)

        # update the package
        try:
            # we specify the revision so that it gets expanded
            # the logic comes from do_update in commandline.py
            rev = None
            if obs_package.islink() and not obs_package.isexpanded():
                rev = obs_package.linkinfo.xsrcmd5
            elif obs_package.islink() and obs_package.isexpanded():
                rev = show_upstream_xsrcmd5(apiurl, branch_project, branch_package)

            obs_package.update(rev)
            print 'Package %s has been updated.' % branch_package
        except Exception, e:
            message = 'Error while updating package %s: ' % branch_package
            _collab_exception_print(e, message)
            return (False, None, None)

    else:
        # check out the branched package
        try:
            # disable package tracking: the current directory might not be a
            # project directory
            # Rationale: for new contributors, checking out in the current
            # directory is easier as it hides some complexity. However, this
            # results in possibly mixing packages from different projects,
            # which makes package tracking not work at all.
            old_tracking = conf.config['do_package_tracking']
            conf.config['do_package_tracking'] = _collab_get_config_bool(apiurl, 'collab_do_package_tracking', default = False)
            checkout_package(apiurl, branch_project, branch_package, expand_link=True)
            conf.config['do_package_tracking'] = old_tracking
            print 'Package %s has been checked out.' % branch_package
        except Exception, e:
            message = 'Error while checking out package %s: ' % branch_package
            _collab_exception_print(e, message)
            return (False, None, None)

    # remove old helper files
    for file in os.listdir(checkout_dir):
        for helper in _osc_collab_helpers:
            if file == helper:
                path = os.path.join(checkout_dir, file)
                os.unlink(path)
                break

    return (True, branch_project, branch_package)


#######################################################################


def _collab_get_package_with_valid_project(projects, package):
    try:
        pkg = OscCollabApi.get_package_details(projects, package)
    except OscCollabWebError, e:
        pkg = None

    if pkg is None or pkg.project is None or not pkg.project.name:
        print >>sys.stderr, 'Cannot find an appropriate project containing %s. You can use --project to override your project settings.' % package
        return None

    return pkg


#######################################################################


def _print_comment_after_setup(pkg, no_devel_project):
    comment = OscCollabApi.get_package_comment(pkg.project.name, pkg.name, no_devel_project = no_devel_project)
    if comment:
        if comment.date:
            date_str = ' on %s' % comment.date
        else:
            date_str = ''

        print 'Note the comment from %s%s on this package:' % (comment.user, date_str)
        print comment.indent()


#######################################################################


def _collab_setup(apiurl, username, projects, package, ignore_reserved = False, ignore_comment = False, no_reserve = False, no_devel_project = False, no_branch = False):
    pkg = _collab_get_package_with_valid_project(projects, package)
    if not pkg:
        return
    project = pkg.project.name

    (setup, branch_project, branch_package) = _collab_setup_internal(apiurl, username, pkg, ignore_reserved, no_reserve, no_devel_project, no_branch)
    if not setup:
        return
    print 'Package %s has been prepared for work.' % branch_package

    if not ignore_comment:
        _print_comment_after_setup(pkg, no_devel_project)


#######################################################################


def _collab_download_internal(url, dest_dir):
    if not os.path.exists(dest_dir):
        os.makedirs(dest_dir)

    parsed_url = urlparse(url)
    basename = os.path.basename(parsed_url.path)
    if not basename:
        # FIXME: workaround until we get a upstream_basename property for each
        # package (would be needed for upstream hosted on sf, anyway).
        # Currently needed for mkvtoolnix.
        for field in parsed_url.query.split('&'):
            try:
                (key, value) = field.split('=', 1)
            except ValueError:
                value = field
            if value.endswith('.gz') or value.endswith('.tgz') or value.endswith('.bz2'):
                basename = os.path.basename(value)

    if not basename:
        raise OscCollabDownloadError('Cannot download %s: no basename in URL.' % url)

    dest_file = os.path.join(dest_dir, basename)
    # we download the file again if it already exists. Maybe the upstream
    # tarball changed, eg. We could add an option to avoid this, but I feel
    # like it won't happen a lot anyway.
    if os.path.exists(dest_file):
        os.unlink(dest_file)

    try:
        fin = urllib2.urlopen(url)
    except urllib2.HTTPError, e:
        raise OscCollabDownloadError('Cannot download %s: %s' % (url, e.msg))

    fout = open(dest_file, 'wb')

    while True:
        try:
            bytes = fin.read(500 * 1024)
            if len(bytes) == 0:
                break
            fout.write(bytes)
        except urllib2.HTTPError, e:
            fin.close()
            fout.close()
            os.unlink(dest_file)
            raise OscCollabDownloadError('Error while downloading %s: %s' % (url, e.msg))

    fin.close()
    fout.close()

    return dest_file


#######################################################################


def _collab_extract_diff_internal(directory, old_tarball, new_tarball):
    def _cleanup(old, new, tmpdir):
        if old:
            old.close()
        if new:
            new.close()
        shutil.rmtree(tmpdir)

    def _lzma_hack(filename, tmpdir):
        if not filename.endswith('.xz'):
            return filename

        dest = os.path.join(tmpdir, os.path.basename(filename))
        shutil.copyfile(filename, dest)
        subprocess.call(['xz', '-d', dest])

        return dest[:-3]

    # we need to make sure it's safe to extract the file
    # see the warning in http://www.python.org/doc/lib/tarfile-objects.html
    def _can_extract_with_trust(name):
        if not name:
            return False
        if name[0] == '/':
            return False
        if name[0] == '.':
            # only accept ./ if the first character is a dot
            if len(name) == 1 or name[1] != '/':
                return False

        return True

    def _extract_files(tar, path, whitelist):
        if not tar or not path or not whitelist:
            return

        for tarinfo in tar:
            if not _can_extract_with_trust(tarinfo.name):
                continue
            # we won't accept symlinks or hard links. It sounds weird to have
            # this for the files we're interested in.
            if not tarinfo.isfile():
                continue
            basename = os.path.basename(tarinfo.name)
            if not basename in whitelist:
                continue
            tar.extract(tarinfo, path)

    def _diff_files(old, new, dest):
        if not new:
            return (False, False)
        if not old:
            shutil.copyfile(new, dest)
            return (True, False)

        old_f = open(old)
        old_lines = old_f.readlines()
        old_f.close()
        new_f = open(new)
        new_lines = new_f.readlines()
        new_f.close()

        diff = difflib.unified_diff(old_lines, new_lines)
        # diff is a generator, so we can't know if it's empty or not until we
        # iterate over it. So if it's empty, we'll create an empty file, and
        # remove it afterwards.

        dest_f = open(dest, 'w')

        # We first write what we consider useful and then write the complete
        # diff for reference.
        # This works because a well-formed NEWS/ChangeLog will only have new
        # items added at the top, and therefore the useful diff is the addition
        # at the top. This doesn't work for configure.{ac,in}, but that's fine.
        # We need to cache the first lines, though, since diff is a generator
        # and we don't have direct access to lines.

        i = 0
        pass_one_done = False
        cached = []

        for line in diff:
            # we skip the first three lines of the diff
            if not pass_one_done and i == 0 and line[:3] == '---':
                cached.append(line)
                i = 1
            elif not pass_one_done and i == 1 and line[:3] == '+++':
                cached.append(line)
                i = 2
            elif not pass_one_done and i == 2 and line[:2] == '@@':
                cached.append(line)
                i = 3
            elif not pass_one_done and i == 3 and line[0] == '+':
                cached.append(line)
                dest_f.write(line[1:])
            elif not pass_one_done:
                # end of pass one: we write a note to help the user, and then
                # write the cache
                pass_one_done = True

                note = '# Note by osc %s: here is the complete diff for reference.' % _osc_collab_alias
                header = ''
                for i in range(len(note)):
                    header += '#'

                dest_f.write('\n')
                dest_f.write('%s\n' % header)
                dest_f.write('%s\n' % note)
                dest_f.write('%s\n' % header)
                dest_f.write('\n')
                for cached_line in cached:
                    dest_f.write(cached_line)
                dest_f.write(line)
            else:
                dest_f.write(line)

        dest_f.close()

        if not cached:
            os.unlink(dest)
            return (False, False)

        return (True, True)


    # FIXME: only needed until we switch to python >= 3.3
    lzma_hack = not hasattr(tarfile.TarFile, 'xzopen')

    tmpdir = tempfile.mkdtemp(prefix = 'osc-collab-')

    old = None
    new = None

    if old_tarball and os.path.exists(old_tarball):
        try:
            if lzma_hack:
                old_tarball = _lzma_hack(old_tarball, tmpdir)
            old = tarfile.open(old_tarball)
        except tarfile.TarError:
            pass
    else:
        # this is not fatal: we can provide the
        # NEWS/ChangeLog/configure.{ac,in} from the new tarball without a diff
        pass

    if new_tarball and os.path.exists(new_tarball):
        new_tarball_basename = os.path.basename(new_tarball)
        try:
            if lzma_hack:
                new_tarball = _lzma_hack(new_tarball, tmpdir)
            new = tarfile.open(new_tarball)
        except tarfile.TarError, e:
            _cleanup(old, new, tmpdir)
            raise OscCollabDiffError('Error when opening %s: %s' % (new_tarball_basename, e))
    else:
        _cleanup(old, new, tmpdir)
        raise OscCollabDiffError('Cannot extract useful diff between tarballs: no new tarball.')

    # make sure we have at least a subdirectory in tmpdir, since we'll extract
    # files from two tarballs that might conflict
    old_dir = os.path.join(tmpdir, 'old')
    new_dir = os.path.join(tmpdir, 'new')

    try:
        if old:
            err_tarball = os.path.basename(old_tarball)
            _extract_files (old, old_dir, ['NEWS', 'ChangeLog', 'configure.ac', 'configure.in'])

        err_tarball = new_tarball_basename
        _extract_files (new, new_dir, ['NEWS', 'ChangeLog', 'configure.ac', 'configure.in'])
    except (tarfile.ReadError, EOFError):
        _cleanup(old, new, tmpdir)
        raise OscCollabDiffError('Cannot extract useful diff between tarballs: %s is not a valid tarball.' % err_tarball)

    if old:
        old.close()
        old = None
    if new:
        new.close()
        new = None

    # find toplevel NEWS/ChangeLog/configure.{ac,in} in the new tarball
    if not os.path.exists(new_dir):
        _cleanup(old, new, tmpdir)
        raise OscCollabDiffError('Cannot extract useful diff between tarballs: no relevant files found in %s.' % new_tarball_basename)

    new_dir_files = os.listdir(new_dir)
    if len(new_dir_files) != 1:
        _cleanup(old, new, tmpdir)
        raise OscCollabDiffError('Cannot extract useful diff between tarballs: unexpected file hierarchy in %s.' % new_tarball_basename)

    new_subdir = os.path.join(new_dir, new_dir_files[0])
    if not os.path.isdir(new_subdir):
        _cleanup(old, new, tmpdir)
        raise OscCollabDiffError('Cannot extract useful diff between tarballs: unexpected file hierarchy in %s.' % new_tarball_basename)

    new_news = os.path.join(new_subdir, 'NEWS')
    if not os.path.exists(new_news) or not os.path.isfile(new_news):
        new_news = None
    new_changelog = os.path.join(new_subdir, 'ChangeLog')
    if not os.path.exists(new_changelog) or not os.path.isfile(new_changelog):
        new_changelog = None
    new_configure = os.path.join(new_subdir, 'configure.ac')
    if not os.path.exists(new_configure) or not os.path.isfile(new_configure):
        new_configure = os.path.join(new_subdir, 'configure.in')
        if not os.path.exists(new_configure) or not os.path.isfile(new_configure):
            new_configure = None

    if not new_news and not new_changelog and not new_configure:
        _cleanup(old, new, tmpdir)
        raise OscCollabDiffError('Cannot extract useful diff between tarballs: no relevant files found in %s.' % new_tarball_basename)

    # find toplevel NEWS/ChangeLog/configure.{ac,in} in the old tarball
    # not fatal
    old_news = None
    old_changelog = None
    old_configure = None

    if os.path.exists(old_dir):
        old_dir_files = os.listdir(old_dir)
    else:
        old_dir_files = []

    if len(old_dir_files) == 1:
        old_subdir = os.path.join(old_dir, old_dir_files[0])
        if os.path.isdir(old_subdir):
            old_news = os.path.join(old_subdir, 'NEWS')
            if not os.path.exists(old_news) or not os.path.isfile(old_news):
                old_news = None
            old_changelog = os.path.join(old_subdir, 'ChangeLog')
            if not os.path.exists(old_changelog) or not os.path.isfile(old_changelog):
                old_changelog = None
            old_configure = os.path.join(old_subdir, 'configure.ac')
            if not os.path.exists(old_configure) or not os.path.isfile(old_configure):
                old_configure = os.path.join(old_subdir, 'configure.in')
                if not os.path.exists(old_configure) or not os.path.isfile(old_configure):
                    old_configure = None


    # Choose the most appropriate prefix for helper files, based on the alias
    # that was used by the user
    helper_prefix = _osc_collab_helper_prefixes[0]
    for prefix in _osc_collab_helper_prefixes:
        if _osc_collab_alias in prefix:
            helper_prefix = prefix
            break

    # do the diff
    news = os.path.join(directory, helper_prefix + 'NEWS')
    (news_created, news_is_diff) = _diff_files(old_news, new_news, news)
    changelog = os.path.join(directory, helper_prefix + 'ChangeLog')
    (changelog_created, changelog_is_diff) = _diff_files(old_changelog, new_changelog, changelog)
    configure = os.path.join(directory, helper_prefix + 'configure')
    (configure_created, configure_is_diff) = _diff_files(old_configure, new_configure, configure)

    # Note: we make osc ignore those helper file we created by modifying
    # the exclude list of osc.core. See the top of this file.

    _cleanup(old, new, tmpdir)

    return (news, news_created, news_is_diff, changelog, changelog_created, changelog_is_diff, configure, configure_created, configure_is_diff)


#######################################################################


def _collab_subst_defines(s, defines):
    '''Replace macros like %{version} and %{name} in strings. Useful
       for sources and patches '''
    for key in defines.keys():
        if s.find(key) != -1:
            value = defines[key]
            s = s.replace('%%{%s}' % key, value)
            s = s.replace('%%%s' % key, value)
    return s


def _collab_update_spec(spec_file, upstream_url, upstream_version):
    if not os.path.exists(spec_file):
        print >>sys.stderr, 'Cannot update %s: no such file.' % os.path.basename(spec_file)
        return (False, None, None, False)
    elif not os.path.isfile(spec_file):
        print >>sys.stderr, 'Cannot update %s: not a regular file.' % os.path.basename(spec_file)
        return (False, None, None, False)

    re_spec_header_with_version = re.compile('^(# spec file for package \S*) \(Version \S*\)(.*)', re.IGNORECASE)
    re_spec_define = re.compile('^%define\s+(\S*)\s+(\S*)', re.IGNORECASE)
    re_spec_name = re.compile('^Name:\s*(\S*)', re.IGNORECASE)
    re_spec_version = re.compile('^(Version:\s*)(\S*)', re.IGNORECASE)
    re_spec_release = re.compile('^(Release:\s*)\S*', re.IGNORECASE)
    re_spec_source = re.compile('^(Source0?:\s*)(\S*)', re.IGNORECASE)
    re_spec_prep = re.compile('^%prep', re.IGNORECASE)

    defines = {}
    old_source = None
    old_version = None
    define_in_source = False

    fin = open(spec_file, 'r')
    (fdout, tmp) = tempfile.mkstemp(dir = os.path.dirname(spec_file))

    # replace version and reset release
    while True:
        line = fin.readline()
        if len(line) == 0:
            break

        match = re_spec_prep.match(line)
        if match:
            os.write(fdout, line)
            break

        match = re_spec_header_with_version.match(line)
        if match:
            # We drop the "(Version XYZ)" part of the header
            os.write(fdout, '%s%s\n' % (match.group(1), match.group(2)))
            continue

        match = re_spec_define.match(line)
        if match:
            defines[match.group(1)] = _collab_subst_defines(match.group(2), defines)
            os.write(fdout, line)
            continue

        match = re_spec_name.match(line)
        if match:
            defines['name'] = match.group(1)
            os.write(fdout, line)
            continue

        match = re_spec_version.match(line)
        if match:
            defines['version'] = match.group(2)
            old_version = _collab_subst_defines(match.group(2), defines)
            os.write(fdout, '%s%s\n' % (match.group(1), upstream_version))
            continue

        match = re_spec_release.match(line)
        if match:
            os.write(fdout, '%s0\n' % match.group(1))
            continue

        match = re_spec_source.match(line)
        if match:
            old_source = os.path.basename(match.group(2))

            if upstream_url:
                non_basename = os.path.dirname(upstream_url)
                new_source = os.path.basename(upstream_url)
                # Use _name in favor of name, as if _name exists, it's for a
                # good reason
                for key in [ '_name', 'name', 'version' ]:
                    if defines.has_key(key):
                        if key == 'version':
                            new_source = new_source.replace(upstream_version, '%%{%s}' % key)
                        else:
                            new_source = new_source.replace(defines[key], '%%{%s}' % key)

                # Only use the URL as source if the basename looks like the
                # real name of the file.
                # If '?' is in the basename, then we likely have some dynamic
                # page to download the file, which means the wrong basename.
                # For instance:
                # download.php?package=01&release=61&file=01&dummy=gwenhywfar-4.1.0.tar.gz
                if '?' not in new_source:
                    os.write(fdout, '%s%s/%s\n' % (match.group(1), non_basename, new_source))
                    continue

            os.write(fdout, line)
            continue

        os.write(fdout, line)

    # wild read/write to finish quickly
    while True:
        bytes = fin.read(10 * 1024)
        if len(bytes) == 0:
            break
        os.write(fdout, bytes)

    fin.close()
    os.close(fdout)

    os.rename(tmp, spec_file)

    if old_source and old_source.find('%') != -1:
        for key in defines.keys():
            if old_source.find(key) != -1:
                old_source = old_source.replace('%%{%s}' % key, defines[key])
                old_source = old_source.replace('%%%s' % key, defines[key])
                if key not in [ 'name', '_name', 'version' ]:
                    define_in_source = True

    return (True, old_source, old_version, define_in_source)


#######################################################################


def _collab_update_changes(changes_file, upstream_version, email):
    if not os.path.exists(changes_file):
        print >>sys.stderr, 'Cannot update %s: no such file.' % os.path.basename(changes_file)
        return False
    elif not os.path.isfile(changes_file):
        print >>sys.stderr, 'Cannot update %s: not a regular file.' % os.path.basename(changes_file)
        return False

    (fdout, tmp) = tempfile.mkstemp(dir = os.path.dirname(changes_file))

    old_lc_time = locale.setlocale(locale.LC_TIME)
    old_tz = os.getenv('TZ')
    locale.setlocale(locale.LC_TIME, 'C')
    os.putenv('TZ', 'UTC')

    os.write(fdout, '-------------------------------------------------------------------\n')
    os.write(fdout, '%s - %s\n' % (time.strftime("%a %b %e %H:%M:%S %Z %Y"), email))
    os.write(fdout, '\n')
    os.write(fdout, '- Update to version %s:\n' % upstream_version)
    os.write(fdout, '  + \n')
    os.write(fdout, '\n')

    locale.setlocale(locale.LC_TIME, old_lc_time)
    if old_tz:
        os.putenv('TZ', old_tz)
    else:
        os.unsetenv('TZ')

    fin = open(changes_file, 'r')
    while True:
        bytes = fin.read(10 * 1024)
        if len(bytes) == 0:
            break
        os.write(fdout, bytes)
    fin.close()
    os.close(fdout)

    os.rename(tmp, changes_file)

    return True


#######################################################################


def _collab_quilt_package(spec_file):
    def _cleanup(null, tmpdir):
        null.close()
        shutil.rmtree(tmpdir)

    null = open('/dev/null', 'w')
    tmpdir = tempfile.mkdtemp(prefix = 'osc-collab-')


    # setup with quilt
    sourcedir = os.path.dirname(os.path.realpath(spec_file))
    popen = subprocess.Popen(['quilt', 'setup', '-d', tmpdir, '--sourcedir', sourcedir, spec_file], stdout = null, stderr = null)
    retval = popen.wait()

    if retval != 0:
        _cleanup(null, tmpdir)
        print >>sys.stderr, 'Cannot apply patches: \'quilt setup\' failed.'
        return False


    # apply patches for all subdirectories
    for directory in os.listdir(tmpdir):
        dir = os.path.join(tmpdir, directory)

        if not os.path.isdir(dir):
            continue

        # there's no patch, so just continue
        if not os.path.exists(os.path.join(dir, 'patches')):
            continue

        popen = subprocess.Popen(['quilt', 'push', '-a', '-q'], cwd = dir, stdout = null, stderr = null)
        retval = popen.wait()

        if retval != 0:
            _cleanup(null, tmpdir)
            print >>sys.stderr, 'Cannot apply patches: \'quilt push -a\' failed.'
            return False


    _cleanup(null, tmpdir)

    return True


#######################################################################


def _collab_update(apiurl, username, email, projects, package, ignore_reserved = False, ignore_comment = False, no_reserve = False, no_devel_project = False, no_branch = False):
    if len(projects) == 1:
        project = projects[0]

        try:
            pkg = OscCollabApi.get_package_details(project, package)
        except OscCollabWebError, e:
            print >>sys.stderr, e.msg
            return
    else:
        pkg = _collab_get_package_with_valid_project(projects, package)
        if not pkg:
            return
        project = pkg.project.name

    # check that the project is up-to-date wrt parent project
    if pkg.parent_more_recent():
        print 'Package %s is more recent in %s (%s) than in %s (%s). Please synchronize %s first.' % (package, pkg.parent_project, pkg.parent_version, project, pkg.version, project)
        return

    # check that an update is really needed
    if not pkg.upstream_version:
        print 'No information about upstream version of package %s is available. Assuming it is not up-to-date.' % package
    elif pkg.upstream_version == '--':
        print 'Package %s has no upstream.' % package
        return
    elif pkg.devel_project and pkg.needs_update() and not no_devel_project and not pkg.devel_needs_update():
        if not pkg.devel_package or pkg.devel_package == package:
            print 'Package %s is already up-to-date in its development project (%s).' % (package, pkg.devel_project)
        else:
            print 'Package %s is already up-to-date in its development project (%s/%s).' % (package, pkg.devel_project, pkg.devel_package)
        return
    elif not pkg.needs_update():
        print 'Package %s is already up-to-date.' % package
        return

    (setup, branch_project, branch_package) = _collab_setup_internal(apiurl, username, pkg, ignore_reserved, no_reserve, no_devel_project, no_branch)
    if not setup:
        return

    package_dir = branch_package

    # edit the version tag in the .spec files
    # not fatal if fails
    spec_file = os.path.join(package_dir, package + '.spec')
    if not os.path.exists(spec_file) and package != branch_package:
        spec_file = os.path.join(package_dir, branch_package + '.spec')
    (updated, old_tarball, old_version, define_in_source) = _collab_update_spec(spec_file, pkg.upstream_url, pkg.upstream_version)
    if old_tarball:
        old_tarball_with_dir = os.path.join(package_dir, old_tarball)
    else:
        old_tarball_with_dir = None

    if old_version and old_version == pkg.upstream_version:
        if no_branch:
            print 'Package %s is already up-to-date (the database might not be up-to-date).' % branch_package
        else:
            print 'Package %s is already up-to-date (in your branch only, or the database is not up-to-date).' % branch_package
        return

    if define_in_source:
        print 'WARNING: the Source tag in %s is using some define that might not be valid anymore.' % spec_file
    if updated:
        print '%s has been prepared.' % os.path.basename(spec_file)

    # warn if there are other spec files which might need an update
    for file in os.listdir(package_dir):
        if file.endswith('.spec') and file != os.path.basename(spec_file):
            print 'WARNING: %s might need a manual update.' % file


    # start adding an entry to .changes
    # not fatal if fails
    changes_file = os.path.join(package_dir, package + '.changes')
    if not os.path.exists(changes_file) and package != branch_package:
        changes_file = os.path.join(package_dir, branch_package + '.changes')
    if _collab_update_changes(changes_file, pkg.upstream_version, email):
        print '%s has been prepared.' % os.path.basename(changes_file)

    # warn if there are other spec files which might need an update
    for file in os.listdir(package_dir):
        if file.endswith('.changes') and file != os.path.basename(changes_file):
            print 'WARNING: %s might need a manual update.' % file


    # download the upstream tarball
    # fatal if fails
    if not pkg.upstream_url:
        print >>sys.stderr, 'Cannot download latest upstream tarball for %s: no URL defined.' % package
        return

    print 'Looking for the upstream tarball...'
    try:
        upstream_tarball = _collab_download_internal(pkg.upstream_url, package_dir)
    except OscCollabDownloadError, e:
        print >>sys.stderr, e.msg
        return

    if not upstream_tarball:
        print >>sys.stderr, 'No upstream tarball downloaded for %s.' % package
        return
    else:
        upstream_tarball_basename = os.path.basename(upstream_tarball)
        # same file as the old one: oops, we don't want to do anything weird
        # there
        if upstream_tarball_basename == old_tarball:
            old_tarball = None
            old_tarball_with_dir = None
        print '%s has been downloaded.' % upstream_tarball_basename


    # check integrity of the downloaded file
    # fatal if fails (only if md5 exists)
    # TODO


    # extract NEWS & ChangeLog from the old + new tarballs, and do a diff
    # not fatal if fails
    print 'Extracting useful diff between tarballs (NEWS, ChangeLog, configure.{ac,in})...'
    try:
        (news, news_created, news_is_diff, changelog, changelog_created, changelog_is_diff, configure, configure_created, configure_is_diff) = _collab_extract_diff_internal(package_dir, old_tarball_with_dir, upstream_tarball)
    except OscCollabDiffError, e:
        print >>sys.stderr, e.msg
    else:
        if news_created:
            news_basename = os.path.basename(news)
            if news_is_diff:
                print 'NEWS between %s and %s is available in %s' % (old_tarball, upstream_tarball_basename, news_basename)
            else:
                print 'Complete NEWS of %s is available in %s' % (upstream_tarball_basename, news_basename)
        else:
            print 'No NEWS information found.'

        if changelog_created:
            changelog_basename = os.path.basename(changelog)
            if changelog_is_diff:
                print 'ChangeLog between %s and %s is available in %s' % (old_tarball, upstream_tarball_basename, changelog_basename)
            else:
                print 'Complete ChangeLog of %s is available in %s' % (upstream_tarball_basename, changelog_basename)
        else:
            print 'No ChangeLog information found.'

        if configure_created:
            configure_basename = os.path.basename(configure)
            if configure_is_diff:
                print 'Diff in configure.{ac,in} between %s and %s is available in %s' % (old_tarball, upstream_tarball_basename, configure_basename)
            else:
                print 'Complete configure.{ac,in} of %s is available in %s' % (upstream_tarball_basename, configure_basename)
        else:
            print 'No configure.{ac,in} information found (tarball is probably not using autotools).'


    # try applying the patches with rpm quilt
    # not fatal if fails
    if _collab_is_program_in_path('quilt'):
        print 'Running quilt...'
        if _collab_quilt_package(spec_file):
            print 'Patches still apply.'
        else:
            print 'WARNING: make sure that all patches apply before submitting.'
    else:
        print 'quilt is not available.'
        print 'WARNING: make sure that all patches apply before submitting.'


    # 'osc add newfile.tar.bz2' and 'osc del oldfile.tar.bz2'
    # not fatal if fails
    osc_package = filedir_to_pac(package_dir)

    if old_tarball_with_dir:
        if os.path.exists(old_tarball_with_dir):
            osc_package.delete_file(old_tarball, force=True)
            print '%s has been removed from the package.' % old_tarball
        else:
            print 'WARNING: the previous tarball could not be found. Please manually remove it.'
    else:
        print 'WARNING: the previous tarball could not be found. Please manually remove it.'

    osc_package.addfile(upstream_tarball_basename)
    print '%s has been added to the package.' % upstream_tarball_basename


    print 'Package %s has been prepared for the update.' % branch_package
    print 'After having updated %s, you can use \'osc build\' to start a local build or \'osc %s build\' to start a build on the build service.' % (os.path.basename(changes_file), _osc_collab_alias)

    if not ignore_comment:
        _print_comment_after_setup(pkg, no_devel_project)

    # TODO add a note about checking if patches are still needed, buildrequires
    # & requires


#######################################################################


def _collab_forward(apiurl, user, projects, request_id, no_supersede = False):
    try:
        int_request_id = int(request_id)
    except ValueError:
        print >>sys.stderr, '%s is not a valid request id.' % (request_id)
        return

    request = OscCollabObs.get_request(request_id)
    if request is None:
        return

    dest_package = request.target_package
    dest_project = request.target_project

    if dest_project not in projects:
        if len(projects) == 1:
            print >>sys.stderr, 'Submission request %s is for %s and not %s. You can use --project to override your project settings.' % (request_id, dest_project, projects[0])
        else:
            print >>sys.stderr, 'Submission request %s is for %s. You can use --project to override your project settings.' % (request_id, dest_project)
        return

    if request.state != 'new':
        print >>sys.stderr, 'Submission request %s is not new.' % request_id
        return

    try:
        pkg = OscCollabApi.get_package_details((dest_project,), dest_package)
        if not pkg or not pkg.parent_project:
            print >>sys.stderr, 'No parent project for %s/%s.' % (dest_project, dest_package)
            return
    except OscCollabWebError, e:
        print >>sys.stderr, 'Cannot get parent project of %s/%s.' % (dest_project, dest_package)
        return

    try:
        devel_project = show_develproject(apiurl, pkg.parent_project, pkg.parent_package)
    except urllib2.HTTPError, e:
        print >>sys.stderr, 'Cannot get development project for %s/%s: %s' % (pkg.parent_project, pkg.parent_package, e.msg)
        return

    if devel_project != dest_project:
        print >>sys.stderr, 'Development project for %s/%s is %s, but package has been submitted to %s.' % (pkg.parent_project, pkg.parent_package, devel_project, dest_project)
        return

    if not OscCollabObs.change_request_state(request_id, 'accepted', 'Forwarding to %s' % pkg.parent_project):
        return

    # TODO: cancel old requests from request.dst_project to parent project

    result = create_submit_request(apiurl,
                                   dest_project, dest_package,
                                   pkg.parent_project, pkg.parent_package,
                                   request.description + ' (forwarded request %s from %s)' % (request_id, request.by))

    print 'Submission request %s has been forwarded to %s (request id: %s).' % (request_id, pkg.parent_project, result)

    if not no_supersede:
        for old_id in OscCollabObs.supersede_old_requests(user, pkg.parent_project, pkg.parent_package, result):
            print 'Previous submission request %s has been superseded.' % old_id


#######################################################################


def _collab_osc_package_pending_commit(osc_package):
    # ideally, we could use osc_package.todo, but it's not set by default.
    # So we just look at all files.
    for filename in osc_package.filenamelist + osc_package.filenamelist_unvers:
        status = osc_package.status(filename)
        if status in ['A', 'M', 'D']:
            return True

    return False


def _collab_osc_package_commit(osc_package, msg):
    osc_package.commit(msg)
    # See bug #436932: Package.commit() leads to outdated internal data.
    osc_package.update_datastructs()


#######################################################################


def _collab_package_set_meta(apiurl, project, package, meta, error_msg_prefix = ''):
    if error_msg_prefix:
        error_str = error_msg_prefix + ': %s'
    else:
        error_str = 'Cannot set metadata for %s in %s: %%s' % (package, project)

    (fdout, tmp) = tempfile.mkstemp()
    os.write(fdout, meta)
    os.close(fdout)

    meta_url = make_meta_url('pkg', (quote_plus(project), quote_plus(package)), apiurl)

    failed = False
    try:
        http_PUT(meta_url, file=tmp)
    except urllib2.HTTPError, e:
        print >>sys.stderr, error_str % e.msg
        failed = True

    os.unlink(tmp)
    return not failed


def _collab_enable_build(apiurl, project, package, meta, repos, archs):
    if len(archs) == 0:
        return (True, False)

    package_node = ET.XML(meta)
    meta_xml = ET.ElementTree(package_node)

    build_node = package_node.find('build')
    if not build_node:
        build_node = ET.Element('build')
        package_node.append(build_node)

    enable_found = {}
    for repo in repos:
        enable_found[repo] = {}
        for arch in archs:
            enable_found[repo][arch] = False

    # remove disable before adding enable
    for node in build_node.findall('disable'):
        repo = node.get('repository')
        arch = node.get('arch')

        if repo and repo not in repos:
            continue
        if arch and arch not in archs:
            continue

        build_node.remove(node)

    for node in build_node.findall('enable'):
        repo = node.get('repository')
        arch = node.get('arch')

        if repo and repo not in repos:
            continue
        if arch and arch not in archs:
            continue

        if repo and arch:
            enable_found[repo][arch] = True
        elif repo:
            for arch in enable_found[repo].keys():
                enable_found[repo][arch] = True
        elif arch:
            for repo in enable_found.keys():
                enable_found[repo][arch] = True
        else:
            for repo in enable_found.keys():
                for arch in enable_found[repo].keys():
                    enable_found[repo][arch] = True

    for repo in repos:
        for arch in archs:
            if not enable_found[repo][arch]:
                node = ET.Element('enable', { 'repository': repo, 'arch': arch})
                build_node.append(node)

    all_true = True
    for repo in enable_found.keys():
        for value in enable_found[repo].values():
            if not value:
                all_true = False
                break

    if all_true:
        return (True, False)

    buf = StringIO()
    meta_xml.write(buf)
    meta = buf.getvalue()

    if _collab_package_set_meta(apiurl, project, package, meta, 'Error while enabling build of package on the build service'):
        return (True, True)
    else:
        return (False, False)


def _collab_get_latest_package_rev_built(apiurl, project, repo, arch, package, verbose_error = True):
    url = makeurl(apiurl, ['build', project, repo, arch, package, '_history'])

    try:
        history = http_GET(url)
    except urllib2.HTTPError, e:
        if verbose_error:
            print >>sys.stderr, 'Cannot get build history: %s' % e.msg
        return (False, None, None)

    try:
        root = ET.parse(history).getroot()
    except SyntaxError:
        history.close ()
        return (False, None, None)

    max_time = 0
    rev = None
    srcmd5 = None

    for node in root.findall('entry'):
        t = int(node.get('time'))
        if t <= max_time:
            continue

        srcmd5 = node.get('srcmd5')
        rev = node.get('rev')

    history.close ()

    return (True, srcmd5, rev)


def _collab_print_build_status(build_state, header, error_line, hint = False):
    def get_str_repo_arch(repo, arch, show_repos):
        if show_repos:
            return '%s/%s' % (repo, arch)
        else:
            return arch

    print '%s:' % header

    repos = build_state.keys()
    if not repos or len(repos) == 0:
        print '  %s' % error_line
        return

    repos_archs = []

    for repo in repos:
        archs = build_state[repo].keys()
        for arch in archs:
            repos_archs.append((repo, arch))
            one_result = True

    if len(repos_archs) == 0:
        print '  %s' % error_line
        return

    show_hint = False
    show_repos = len(repos) > 1
    repos_archs.sort()

    max_len = 0
    for (repo, arch) in repos_archs:
        l = len(get_str_repo_arch(repo, arch, show_repos))
        if l > max_len:
            max_len = l

    # 4: because we also have a few other characters (see left variable)
    format = '%-' + str(max_len + 4) + 's%s'
    for (repo, arch) in repos_archs:
        if not build_state[repo][arch]['scheduler'] and build_state[repo][arch]['result'] in ['failed']:
            show_hint = True

        left = '  %s: ' % get_str_repo_arch(repo, arch, show_repos)
        if build_state[repo][arch]['result'] in ['unresolved', 'broken', 'blocked', 'finished', 'signing'] and build_state[repo][arch]['details']:
            status = '%s (%s)' % (build_state[repo][arch]['result'], build_state[repo][arch]['details'])
        else:
            status = build_state[repo][arch]['result']

        if build_state[repo][arch]['scheduler']:
            status = '%s (was: %s)' % (build_state[repo][arch]['scheduler'], status)

        print format % (left, status)

    if show_hint and hint:
        for (repo, arch) in repos_archs:
            if build_state[repo][arch]['result'] == 'failed':
                print 'You can see the log of the failed build with: osc buildlog %s %s' % (repo, arch)


def _collab_build_get_results(apiurl, project, repos, package, archs, srcmd5, rev, state, error_counter, verbose_error):
    try:
        results = show_results_meta(apiurl, project, package=package)
        if len(results) == 0:
            if verbose_error:
                print >>sys.stderr, 'Error while getting build results of package on the build service: empty results'
            error_counter += 1
            return (True, False, error_counter, state)

        # reset the error counter
        error_counter = 0
    except (urllib2.HTTPError, httplib.BadStatusLine), e:
        if verbose_error:
            print >>sys.stderr, 'Error while getting build results of package on the build service: %s' % e
        error_counter += 1
        return (True, False, error_counter, state)

    res_root = ET.XML(''.join(results))
    detailed_results = {}
    repos_archs = []

    for node in res_root.findall('result'):

        repo = node.get('repository')
        # ignore the repo if it's not one we explicitly use
        if not repo in repos:
           continue

        arch = node.get('arch')
        # ignore the archs we didn't explicitly enabled: this ensures we care
        # only about what is really important to us
        if not arch in archs:
            continue

        scheduler_state = node.get('state')
        scheduler_dirty = node.get('dirty') == 'true'

        status_node = node.find('status')
        try:
            status = status_node.get('code')
        except:
            # code can be missing when package is too new:
            status = 'unknown'

        try:
            details = status_node.find('details').text
        except:
            details = None

        if not detailed_results.has_key(repo):
            detailed_results[repo] = {}
        detailed_results[repo][arch] = {}
        detailed_results[repo][arch]['status'] = status
        detailed_results[repo][arch]['details'] = details
        detailed_results[repo][arch]['scheduler_state'] = scheduler_state
        detailed_results[repo][arch]['scheduler_dirty'] = scheduler_dirty
        repos_archs.append((repo, arch))

    # evaluate the status: do we need to give more time to the build service?
    # Was the build successful?
    bs_not_ready = False
    do_not_wait_for_bs = False
    build_successful = True

    # A bit paranoid, but it seems it happened to me once...
    if len(repos_archs) == 0:
        bs_not_ready = True
        build_successful = False
        if verbose_error:
            print >>sys.stderr, 'Build service did not return any information.'
        error_counter += 1

    for (repo, arch) in repos_archs:
        # first look at the state of the scheduler
        scheduler_state = detailed_results[repo][arch]['scheduler_state']
        scheduler_dirty = detailed_results[repo][arch]['scheduler_dirty']
        if detailed_results[repo][arch]['scheduler_dirty']:
            scheduler_active = 'waiting for scheduler'
        elif scheduler_state in ['unknown', 'scheduling']:
            scheduler_active = 'waiting for scheduler'
        elif scheduler_state in ['blocked']:
            scheduler_active = 'blocked by scheduler'
        else:
            # we don't care about the scheduler state
            scheduler_active = ''

        need_rebuild = False
        value = detailed_results[repo][arch]['status']

        # the scheduler is working, or the result has changed since last time,
        # so we won't trigger a rebuild
        if scheduler_active or state[repo][arch]['result'] != value:
            state[repo][arch]['rebuild'] = -1

        # build is done, but not successful
        if scheduler_active or value not in ['succeeded', 'excluded']:
            build_successful = False

        # we just ignore the status if the scheduler is active, since we know
        # we need to wait for the build service
        if scheduler_active:
            bs_not_ready = True

        # build is happening or will happen soon
        elif value in ['scheduled', 'building', 'dispatching', 'finished', 'signing']:
            bs_not_ready = True

        # sometimes, the scheduler forgets about a package in 'blocked' state,
        # so we have to force a rebuild
        elif value in ['blocked']:
            bs_not_ready = True
            need_rebuild = True

        # build has failed for an architecture: no need to wait for other
        # architectures to know that there's a problem
        elif value in ['failed', 'unresolved', 'broken']:
            do_not_wait_for_bs = True

        # 'disabled' => the build service didn't take into account
        # the change we did to the meta yet (eg).
        elif value in ['unknown', 'disabled']:
            bs_not_ready = True
            need_rebuild = True

        # build is done, but is it for the latest version?
        elif value in ['succeeded']:
            # check that the build is for the version we have
            (success, built_srcmd5, built_rev) = _collab_get_latest_package_rev_built(apiurl, project, repo, arch, package, verbose_error)

            if not success:
                detailed_results[repo][arch]['status'] = 'succeeded, but maybe not up-to-date'
                error_counter += 1
                # we don't know what's going on, so we'll contact the build
                # service again
                bs_not_ready = True
            else:
                # reset the error counter
                error_counter = 0

                #FIXME: "revision" seems to not have the same meaning for the
                # build history and for the local package. See bug #436781
                # (bnc). So, we just ignore the revision for now.
                #if (built_srcmd5, built_rev) != (srcmd5, rev):
                if built_srcmd5 != srcmd5:
                    need_rebuild = True
                    detailed_results[repo][arch]['status'] = 'rebuild needed'

        if not scheduler_active and need_rebuild and state[repo][arch]['rebuild'] == 0:
            bs_not_ready = True

            print 'Triggering rebuild for %s as of %s' % (arch, time.strftime('%X (%x)', time.localtime()))

            try:
                rebuild(apiurl, project, package, repo, arch)
                # reset the error counter
                error_counter = 0
            except (urllib2.HTTPError, httplib.BadStatusLine), e:
                if verbose_error:
                    print >>sys.stderr, 'Cannot trigger rebuild for %s: %s' % (arch, e)
                error_counter += 1

        state[repo][arch]['scheduler'] = scheduler_active
        state[repo][arch]['result'] = detailed_results[repo][arch]['status']
        state[repo][arch]['details'] = detailed_results[repo][arch]['details']

        # Update the timeout data
        if scheduler_active:
            pass
        if state[repo][arch]['result'] in ['blocked']:
            # if we're blocked, maybe the scheduler forgot about us, so
            # schedule a rebuild every 60 minutes. The main use case is when
            # you leave the plugin running for a whole night.
            if state[repo][arch]['rebuild'] <= 0:
                state[repo][arch]['rebuild-timeout'] = 60
                state[repo][arch]['rebuild'] = state[repo][arch]['rebuild-timeout']

            # note: it's correct to decrement even if we start with a new value
            # of timeout, since if we don't, it adds 1 minute (ie, 5 minutes
            # instead of 4, eg)
            state[repo][arch]['rebuild'] = state[repo][arch]['rebuild'] - 1
        elif state[repo][arch]['result'] in ['unknown', 'disabled', 'rebuild needed']:
            # if we're in this unexpected state, force the scheduler to do
            # something
            if state[repo][arch]['rebuild'] <= 0:
                # we do some exponential timeout until 60 minutes. We skip
                # timeouts of 1 and 2 minutes since they're quite short.
                if state[repo][arch]['rebuild-timeout'] > 0:
                    state[repo][arch]['rebuild-timeout'] = min(60, state[repo][arch]['rebuild-timeout'] * 2)
                else:
                    state[repo][arch]['rebuild-timeout'] = 4
                state[repo][arch]['rebuild'] = state[repo][arch]['rebuild-timeout']

            # note: it's correct to decrement even if we start with a new value
            # of timeout, since if we don't, it adds 1 minute (ie, 5 minutes
            # instead of 4, eg)
            state[repo][arch]['rebuild'] = state[repo][arch]['rebuild'] - 1
        else:
            # else, we make sure we won't manually trigger a rebuild
            state[repo][arch]['rebuild'] = -1
            state[repo][arch]['rebuild-timeout'] = -1

    if do_not_wait_for_bs:
        bs_not_ready = False

    return (bs_not_ready, build_successful, error_counter, state)


def _collab_build_wait_loop(apiurl, project, repos, package, archs, srcmd5, rev):
    # seconds we wait before looking at the results on the build service
    check_frequency = 60
    max_errors = 10

    build_successful = False
    print_status = False
    error_counter = 0
    last_check = 0

    state = {}
    # When we want to trigger a rebuild for this repo/arch.
    # The initial value is 1 since we don't want to trigger a rebuild the first
    # time when the state is 'disabled' since the state might have changed very
    # recently (if we updated the metadata ourselves), and the build service
    # might have an old build that it can re-use instead of building again.
    for repo in repos:
        state[repo] = {}
        for arch in archs:
            state[repo][arch] = {}
            state[repo][arch]['rebuild'] = -1
            state[repo][arch]['rebuild-timeout'] = -1
            state[repo][arch]['scheduler'] = ''
            state[repo][arch]['result'] = 'unknown'
            state[repo][arch]['details'] = ''

    print "Building on %s..." % ', '.join(repos)
    print "You can press enter to get the current status of the build."

    # It's important to start the loop by downloading results since we might
    # already have successful builds, and we don't want to wait to know that.

    try:

        while True:
            # get build status if we don't have a recent status
            now = time.time()
            if now - last_check >= 58:
                # 58s since sleep() is not 100% precise and we don't want to miss
                # one turn
                last_check = now

                (need_to_continue, build_successful, error_counter, state) = _collab_build_get_results(apiurl, project, repos, package, archs, srcmd5, rev, state, error_counter, print_status)

                # just stop if there are too many errors
                if error_counter > max_errors:
                    print >>sys.stderr, 'Giving up: too many consecutive errors when contacting the build service.'
                    break

            else:
                # we didn't download the results, so we want to continue anyway
                need_to_continue = True

            if print_status:
                header = 'Status as of %s [checking the status every %d seconds]' % (time.strftime('%X (%x)', time.localtime(last_check)), check_frequency)
                _collab_print_build_status(state, header, 'no results returned by the build service')

            if not need_to_continue:
                break


            # and now wait for input/timeout
            print_status = False

            # wait check_frequency seconds or for user input
            now = time.time()
            if now - last_check < check_frequency:
                wait = check_frequency - (now - last_check)
            else:
                wait = check_frequency

            res = select.select([sys.stdin], [], [], wait)

            # we have user input
            if len(res[0]) > 0:
                print_status = True
                # empty sys.stdin
                sys.stdin.readline()


    # we catch this exception here since we might need to revert some metadata
    except KeyboardInterrupt:
        print ''
        print 'Interrupted: not waiting for the build to finish. Cleaning up...'

    return (build_successful, state)


#######################################################################


def _collab_autodetect_repo(apiurl, project):
    try:
        meta_lines = show_project_meta(apiurl, project)
        meta = ''.join(meta_lines)
    except urllib2.HTTPError:
        return None

    try:
        root = ET.XML(meta)
    except SyntaxError:
        return None

    repos = []
    for node in root.findall('repository'):
        name = node.get('name')
        if name:
            repos.append(name)

    if not repos:
        return None

    # This is the list of repositories we prefer, the first one being the
    # preferred one.
    #  + snapshot/standard is what openSUSE:Factory uses, and some other
    #    projects might use this too (snapshot is like standard, except that
    #    packages won't block before getting built).
    #  + openSUSE_Factory is the usual repository for devel projects.
    #  + openSUSE-Factory is a variant of the one above (typo in some project
    #    config?)
    for repo in [ 'snapshot', 'standard', 'openSUSE_Factory', 'openSUSE-Factory' ]:
        if repo in repos:
            return repo

    # No known repository? We try to use the last one named openSUSE* (last
    # one because we want the most recent version of openSUSE).
    opensuse_repos = [ repo for repo in repos if repo.startswith('openSUSE') ]
    if len(opensuse_repos) > 0:
        opensuse_repos.sort(reverse = True)
        return opensuse_repos[0]

    # Still no solution? Let's just take the first one...
    return repos[0]


#######################################################################


def _collab_build_internal(apiurl, osc_package, repos, archs):
    project = osc_package.prjname
    package = osc_package.name

    if '!autodetect!' in repos:
        print 'Autodetecting the most appropriate repository for the build...'
        repos.remove('!autodetect!')
        repo = _collab_autodetect_repo(apiurl, project)
        if repo:
            repos.append(repo)

    if len(repos) == 0:
        print >>sys.stderr, 'Error while setting up the build: no usable repository.'
        return False

    repos.sort()
    archs.sort()

    # check that build is enabled for this package in this project, and if this
    # is not the case, enable it
    try:
        meta_lines = show_package_meta(apiurl, project, package)
    except urllib2.HTTPError, e:
        print >>sys.stderr, 'Error while checking if package is set to build: %s' % e.msg
        return False

    meta = ''.join(meta_lines)
    (success, changed_meta) = _collab_enable_build(apiurl, project, package, meta, repos, archs)
    if not success:
        return False

    # loop to periodically check the status of the build (and eventually
    # trigger rebuilds if necessary)
    (build_success, build_state) = _collab_build_wait_loop(apiurl, project, repos, package, archs, osc_package.srcmd5, osc_package.rev)

    if not build_success:
        _collab_print_build_status(build_state, 'Status', 'no status known: osc got interrupted?', hint=True)

    # disable build for package in this project if we manually enabled it
    # (we just reset to the old settings)
    if changed_meta:
        _collab_package_set_meta(apiurl, project, package, meta, 'Error while resetting build settings of package on the build service')

    return build_success


#######################################################################


def _collab_build(apiurl, user, projects, msg, repos, archs):
    try:
        osc_package = filedir_to_pac('.')
    except oscerr.NoWorkingCopy, e:
        print >>sys.stderr, e
        return

    project = osc_package.prjname
    package = osc_package.name

    committed = False

    # commit if there are local changes
    if _collab_osc_package_pending_commit(osc_package):
        if not msg:
            msg = edit_message()
        _collab_osc_package_commit(osc_package, msg)
        committed = True

    build_success = _collab_build_internal(apiurl, osc_package, repos, archs)

    if build_success:
        print 'Package successfully built on the build service.'


#######################################################################


def _collab_build_submit(apiurl, user, projects, msg, repos, archs, forward = False, no_unreserve = False, no_supersede = False):
    try:
        osc_package = filedir_to_pac('.')
    except oscerr.NoWorkingCopy, e:
        print >>sys.stderr, e
        return

    project = osc_package.prjname
    package = osc_package.name

    # do some preliminary checks on the package/project: it has to be
    # a branch of a development project
    if not osc_package.islink():
        print >>sys.stderr, 'Package is not a link.'
        return

    parent_project = osc_package.linkinfo.project
    if not parent_project in projects:
        if len(projects) == 1:
            print >>sys.stderr, 'Package links to project %s and not %s. You can use --project to override your project settings.' % (parent_project, projects[0])
        else:
            print >>sys.stderr, 'Package links to project %s. You can use --project to override your project settings.' % parent_project
        return

    if not project.startswith('home:%s:branches' % user):
        print >>sys.stderr, 'Package belongs to project %s which does not look like a branch project.' % project
        return

    if project != 'home:%s:branches:%s' % (user, parent_project):
        print >>sys.stderr, 'Package belongs to project %s which does not look like a branch project for %s.' % (project, parent_project)
        return


    # get the message that will be used for commit & request
    if not msg:
        msg = edit_message(footer='This message will be used for the commit (if necessary) and the request.\n')

    committed = False

    # commit if there are local changes
    if _collab_osc_package_pending_commit(osc_package):
        _collab_osc_package_commit(osc_package, msg)
        committed = True

    build_success = _collab_build_internal(apiurl, osc_package, repos, archs)

    # if build successful, submit
    if build_success:
        result = create_submit_request(apiurl,
                                       project, package,
                                       parent_project, package,
                                       msg)

        print 'Package submitted to %s (request id: %s).' % (parent_project, result)

        if not no_supersede:
            for old_id in OscCollabObs.supersede_old_requests(user, parent_project, package, result):
                print 'Previous submission request %s has been superseded.' % old_id

        if forward:
            # we volunteerly restrict the project list to parent_project for
            # self-consistency and more safety
            _collab_forward(apiurl, user, [ parent_project ], result, no_supersede = no_supersede)

        if not no_unreserve:
            try:
                reservation = OscCollabApi.is_package_reserved((parent_project,), package, no_devel_project = True)
                if reservation and reservation.user == user:
                    _collab_unreserve((parent_project,), (package,), user, no_devel_project = True)
            except OscCollabWebError, e:
                print >>sys.stderr, e.msg
    else:
        print 'Package was not submitted to %s' % parent_project


#######################################################################


# TODO
# Add a commit method.
# This will make some additional checks:
#   + if we used update, we can initialize a list of patches/sources
#     before any change. This way, on the commit, we can look if the
#     remaining files are still referenced in the .spec, and if not
#     complain if the file hasn't been removed from the directory.
#     We can also complain if a file hasn't been added with osc add,
#     while it's referenced.
#   + complain if the lines in .changes are too long


#######################################################################


# Unfortunately, as of Python 2.5, ConfigParser does not know how to
# preserve a config file: it removes comments and reorders stuff.
# This is a dumb function to append a value to a section in a config file.
def _collab_add_config_option(section, key, value):
    global _osc_collab_osc_conffile

    conffile = _osc_collab_osc_conffile

    if not os.path.exists(conffile):
        lines = [ ]
    else:
        fin = open(conffile, 'r')
        lines = fin.readlines()
        fin.close()

    (fdout, tmp) = tempfile.mkstemp(prefix = os.path.basename(conffile), dir = os.path.dirname(conffile))

    in_section = False
    added = False
    empty_line = False

    valid_sections = [ '[' + section + ']' ]
    if section.startswith('http'):
        if section.endswith('/'):
            valid_sections.append('[' + section[:-1] + ']')
        else:
            valid_sections.append('[' + section + '/]')

    for line in lines:
        if line.rstrip() in valid_sections:
            in_section = True
        # key was not in the section: let's add it
        elif line[0] == '[' and in_section and not added:
            if not empty_line:
                os.write(fdout, '\n')
            os.write(fdout, '%s = %s\n\n' % (key, value))
            added = True
            in_section = False
        elif line[0] == '[' and in_section:
            in_section = False
        # the section/key already exists: we replace
        # 'not added': in case there are multiple sections with the same name
        elif in_section and not added and line.startswith(key):
            index = line.find('=')
            if line[:index].rstrip() == key:
                line = '%s= %s\n' % (line[:index], value)
                added = True

        os.write(fdout, line)

        empty_line = line.strip() == ''

    if not added:
        if not empty_line:
            os.write(fdout, '\n')
        if not in_section:
            os.write(fdout, '[%s]\n' % (section,))
        os.write(fdout, '%s = %s\n' % (key, value))

    os.close(fdout)
    os.rename(tmp, conffile)


#######################################################################


def _collab_get_compatible_apiurl_for_config(config, apiurl):
    if apiurl is None:
        return None

    if config.has_section(apiurl):
        return apiurl

    # first try adding/removing a trailing slash to the API url
    if apiurl.endswith('/'):
        apiurl = apiurl[:-1]
    else:
        apiurl = apiurl + '/'

    if config.has_section(apiurl):
        return apiurl

    # old osc (0.110) was adding the host to the tuple without the http
    # part, ie just the host
    apiurl = urlparse(apiurl).netloc

    if apiurl and config.has_section(apiurl):
        return apiurl

    return None


def _collab_get_config_parser():
    global _osc_collab_config_parser
    global _osc_collab_osc_conffile

    if _osc_collab_config_parser is not None:
        return _osc_collab_config_parser

    conffile = _osc_collab_osc_conffile
    _osc_collab_config_parser = ConfigParser.SafeConfigParser()
    _osc_collab_config_parser.read(conffile)
    return _osc_collab_config_parser


def _collab_get_config(apiurl, key, default = None):
    config = _collab_get_config_parser()
    if not config:
        return default

    apiurl = _collab_get_compatible_apiurl_for_config(config, apiurl)
    if apiurl and config.has_option(apiurl, key):
        return config.get(apiurl, key)
    elif config.has_option('general', key):
        return config.get('general', key)
    else:
        return default


def _collab_get_config_bool(apiurl, key, default = None):
    value = _collab_get_config(apiurl, key, default)
    if type(value) == bool:
        return value

    if value.lower() in [ 'true', 'yes' ]:
        return True
    try:
        return int(value) != 0
    except:
        pass
    return False

def _collab_get_config_list(apiurl, key, default = None):
    def split_items(line):
        items = line.split(';')
        # remove all empty items
        while True:
            try:
                items.remove('')
            except ValueError:
                break
        return items

    line = _collab_get_config(apiurl, key, default)

    items = split_items(line)
    if not items and default:
        if type(default) == str:
            items = split_items(default)
        else:
            items = default
    return items


#######################################################################


def _collab_migrate_gnome_config(apiurl):
    for key in [ 'archs', 'apiurl', 'email', 'projects' ]:
        if _collab_get_config(apiurl, 'collab_' + key) is not None:
            continue
        elif not conf.config.has_key('gnome_' + key):
            continue
        _collab_add_config_option(apiurl, 'collab_' + key, conf.config['gnome_' + key])

    # migrate repo to repos
    if _collab_get_config(apiurl, 'collab_repos') is None and conf.config.has_key('gnome_repo'):
        _collab_add_config_option(apiurl, 'collab_repos', conf.config['gnome_repo'] + ';')


#######################################################################


def _collab_ensure_email(apiurl):
    email = _collab_get_config(apiurl, 'email')
    if email:
        return email
    email = _collab_get_config(apiurl, 'collab_email')
    if email:
        return email

    email =  raw_input('E-mail address to use for .changes entries: ')
    if email == '':
        return 'EMAIL@DOMAIN'

    _collab_add_config_option(apiurl, 'collab_email', email)

    return email


#######################################################################


def _collab_parse_arg_packages(packages):
    def remove_trailing_slash(s):
        if s.endswith('/'):
            return s[:-1]
        return s

    if type(packages) == str:
        return remove_trailing_slash(packages)
    elif type(packages) in [ list, tuple ]:
        return [ remove_trailing_slash(package) for package in packages ]
    else:
        return packages


#######################################################################


@cmdln.alias('gnome')
@cmdln.option('-A', '--apiurl', metavar='URL',
              dest='apiurl',
              help='url to use to connect to the database (different from the build service server)')
@cmdln.option('--xs', '--exclude-submitted', action='store_true',
              dest='exclude_submitted',
              help='do not show submitted packages in the output')
@cmdln.option('--xr', '--exclude-reserved', action='store_true',
              dest='exclude_reserved',
              help='do not show reserved packages in the output')
@cmdln.option('--xc', '--exclude-commented', action='store_true',
              dest='exclude_commented',
              help='do not show commented packages in the output')
@cmdln.option('--xd', '--exclude-devel', action='store_true',
              dest='exclude_devel',
              help='do not show packages that are up-to-date in their development project in the output')
@cmdln.option('--ic', '--ignore-comments', action='store_true',
              dest='ignore_comments',
              help='ignore the comments')
@cmdln.option('--ir', '--ignore-reserved', action='store_true',
              dest='ignore_reserved',
              help='ignore the reservation state of the package if necessary')
@cmdln.option('--iu', '--include-upstream', action='store_true',
              dest='include_upstream',
              help='include reports about missing upstream data')
@cmdln.option('--nr', '--no-reserve', action='store_true',
              dest='no_reserve',
              help='do not reserve the package')
@cmdln.option('--ns', '--no-supersede', action='store_true',
              dest='no_supersede',
              help='do not supersede requests to the same package')
@cmdln.option('--nu', '--no-unreserve', action='store_true',
              dest='no_unreserve',
              help='do not unreserve the package')
@cmdln.option('--nodevelproject', action='store_true',
              dest='no_devel_project',
              help='do not use development project of the packages')
@cmdln.option('--nobranch', action='store_true',
              dest='no_branch',
              help='do not branch the package in your home project')
@cmdln.option('-m', '--message', metavar='TEXT',
              dest='msg',
              help='specify log message TEXT')
@cmdln.option('-f', '--forward', action='store_true',
              dest='forward',
              help='automatically forward to parent project if successful')
@cmdln.option('--no-details', action='store_true',
              dest='no_details',
              help='do not show more details')
@cmdln.option('--details', action='store_true',
              dest='details',
              help='show more details')
@cmdln.option('--project', metavar='PROJECT', action='append',
              dest='projects', default=[],
              help='project to work on (default: openSUSE:Factory)')
@cmdln.option('--repo', metavar='REPOSITORY', action='append',
              dest='repos', default=[],
              help='build repositories to build on (default: automatic detection)')
@cmdln.option('--arch', metavar='ARCH', action='append',
              dest='archs', default=[],
              help='architectures to build on (default: i586 and x86_64)')
@cmdln.option('--nc', '--no-cache', action='store_true',
              dest='no_cache',
              help='ignore data from the cache')
@cmdln.option('-v', '--version', action='store_true',
              dest='version',
              help='show version of the plugin')
def do_collab(self, subcmd, opts, *args):
    """${cmd_name}: Various commands to ease collaboration on the openSUSE Build Service.

    A tutorial and detailed documentation are available at:
      http://en.opensuse.org/openSUSE:Osc_Collab

    "todo" (or "t") will list the packages that need some action.

    "todoadmin" (or "ta") will list the packages from the project that need
    to be submitted to the parent project, and various other errors or tasks.

    "listreserved" (or "lr") will list the reserved packages.

    "isreserved" (or "ir") will look if a package is reserved.

    "reserve" (or "r") will reserve a package so other people know you're
    working on it.

    "unreserve" (or "u") will remove the reservation you had on a package.

    "listcommented" (or "lc") will list the commented packages.

    "comment" (or "c") will look if a package is commented.

    "commentset" (or "cs") will add to a package a comment you want to share
    with other people.

    "commentunset" (or "cu") will remove the comment you set on a package.

    "setup" (or "s") will prepare a package for work (possibly reservation,
    branch, checking out, etc.). The package will be checked out in the current
    directory.

    "update" (or "up") will prepare a package for update (possibly reservation,
    branch, checking out, download of the latest upstream tarball, .spec
    edition, etc.). The package will be checked out in the current directory.

    "forward" (or "f") will forward a request to the project to parent project.
    This includes the step of accepting the request first.

    "build" (or "b") will commit the local changes of the package in
    the current directory and wait for the build to succeed on the build
    service.

    "buildsubmit" (or "bs") will commit the local changes of the package in
    the current directory, wait for the build to succeed on the build service
    and if the build succeeds, submit the package to the development project.

    Usage:
        osc collab todo [--exclude-submitted|--xs] [--exclude-reserved|--xr] [--exclude-commented|--xc] [--exclude-devel|--xd] [--ignore-comments|--ic] [--details|--no-details] [--project=PROJECT]
        osc collab todoadmin [--include-upstream|--iu] [--project=PROJECT]

        osc collab listreserved
        osc collab isreserved [--nodevelproject] [--project=PROJECT] PKG [...]
        osc collab reserve [--nodevelproject] [--project=PROJECT] PKG [...]
        osc collab unreserve [--nodevelproject] [--project=PROJECT] PKG [...]

        osc collab listcommented
        osc collab comment [--nodevelproject] [--project=PROJECT] PKG [...]
        osc collab commentset [--nodevelproject] [--project=PROJECT] PKG COMMENT
        osc collab commentunset [--nodevelproject] [--project=PROJECT] PKG [...]

        osc collab setup [--ignore-reserved|--ir] [--ignore-comments|--ic] [--no-reserve|--nr] [--nodevelproject] [--nobranch] [--project=PROJECT] PKG
        osc collab update [--ignore-reserved|--ir] [--ignore-comments|--ic] [--no-reserve|--nr] [--nodevelproject] [--nobranch] [--project=PROJECT] PKG

        osc collab forward [--no-supersede|--ns] [--project=PROJECT] ID

        osc collab build [--message=TEXT|-m=TEXT] [--repo=REPOSITORY] [--arch=ARCH]
        osc collab buildsubmit [--forward|-f] [--no-supersede|--ns] [--no-unreserve|--nu] [--message=TEXT|-m=TEXT] [--repo=REPOSITORY] [--arch=ARCH]
    ${cmd_option_list}
    """

    # uncomment this when profiling is needed
    #self.ref = time.time()
    #print "%.3f - %s" % (time.time()-self.ref, 'start')

    global _osc_collab_alias
    global _osc_collab_osc_conffile

    _osc_collab_alias = self.lastcmd[0]

    if opts.version:
        print OSC_COLLAB_VERSION
        return

    cmds = ['todo', 't', 'todoadmin', 'ta', 'listreserved', 'lr', 'isreserved', 'ir', 'reserve', 'r', 'unreserve', 'u', 'listcommented', 'lc', 'comment', 'c', 'commentset', 'cs', 'commentunset', 'cu', 'setup', 's', 'update', 'up', 'forward', 'f', 'build', 'b', 'buildsubmit', 'bs']
    if not args or args[0] not in cmds:
        raise oscerr.WrongArgs('Unknown %s action. Choose one of %s.' % (_osc_collab_alias, ', '.join(cmds)))

    cmd = args[0]

    # Check arguments validity
    if cmd in ['listreserved', 'lr', 'listcommented', 'lc', 'todo', 't', 'todoadmin', 'ta', 'build', 'b', 'buildsubmit', 'bs']:
        min_args, max_args = 0, 0
    elif cmd in ['setup', 's', 'update', 'up', 'forward', 'f']:
        min_args, max_args = 1, 1
    elif cmd in ['commentset', 'cs']:
        min_args, max_args = 1, 2
    elif cmd in ['isreserved', 'ir', 'reserve', 'r', 'unreserve', 'u', 'comment', 'c', 'commentunset', 'cu']:
        min_args = 1
        max_args = sys.maxint
    else:
        raise RuntimeError('Unknown command: %s' % cmd)

    if len(args) - 1 < min_args:
        raise oscerr.WrongArgs('Too few arguments.')
    if len(args) - 1 > max_args:
        raise oscerr.WrongArgs('Too many arguments.')

    if opts.details and opts.no_details:
        raise oscerr.WrongArgs('--details and --no-details cannot be used at the same time.')

    apiurl = conf.config['apiurl']
    user = conf.config['user']

    # See get_config() in osc/conf.py and postoptparse() in
    # osc/commandline.py
    conffile = self.options.conffile or os.environ.get('OSC_CONFIG', '~/.oscrc')
    _osc_collab_osc_conffile = os.path.expanduser(conffile)

    _collab_migrate_gnome_config(apiurl)
    email = _collab_ensure_email(apiurl)

    if opts.apiurl:
        collab_apiurl = opts.apiurl
    else:
        collab_apiurl = _collab_get_config(apiurl, 'collab_apiurl')

    if len(opts.projects) != 0:
        projects = opts.projects
    else:
        projects = _collab_get_config_list(apiurl, 'collab_projects', 'openSUSE:Factory')

    if len(opts.repos) != 0:
        repos = opts.repos
    else:
        repos = _collab_get_config_list(apiurl, 'collab_repos', '!autodetect!')

    if len(opts.archs) != 0:
        archs = opts.archs
    else:
        archs = _collab_get_config_list(apiurl, 'collab_archs', 'i586;x86_64;')

    details = _collab_get_config_bool(apiurl, 'collab_details', False)
    if details and opts.no_details:
        details = False
    elif not details and opts.details:
        details = True

    OscCollabApi.init(collab_apiurl)
    OscCollabCache.init(opts.no_cache)
    OscCollabObs.init(apiurl)

    # Do the command
    if cmd in ['todo', 't']:
        _collab_todo(apiurl, projects, details, opts.ignore_comments, opts.exclude_commented, opts.exclude_reserved, opts.exclude_submitted, opts.exclude_devel)

    elif cmd in ['todoadmin', 'ta']:
        _collab_todoadmin(apiurl, projects, opts.include_upstream)

    elif cmd in ['listreserved', 'lr']:
        _collab_listreserved(projects)

    elif cmd in ['isreserved', 'ir']:
        packages = _collab_parse_arg_packages(args[1:])
        _collab_isreserved(projects, packages, no_devel_project = opts.no_devel_project)

    elif cmd in ['reserve', 'r']:
        packages = _collab_parse_arg_packages(args[1:])
        _collab_reserve(projects, packages, user, no_devel_project = opts.no_devel_project)

    elif cmd in ['unreserve', 'u']:
        packages = _collab_parse_arg_packages(args[1:])
        _collab_unreserve(projects, packages, user, no_devel_project = opts.no_devel_project)

    elif cmd in ['listcommented', 'lc']:
        _collab_listcommented(projects)

    elif cmd in ['comment', 'c']:
        packages = _collab_parse_arg_packages(args[1:])
        _collab_comment(projects, packages, no_devel_project = opts.no_devel_project)

    elif cmd in ['commentset', 'cs']:
        packages = _collab_parse_arg_packages(args[1])
        if len(args) - 1 == 1:
            comment = edit_message()
        else:
            comment = args[2]
        _collab_commentset(projects, packages, user, comment, no_devel_project = opts.no_devel_project)

    elif cmd in ['commentunset', 'cu']:
        packages = _collab_parse_arg_packages(args[1:])
        _collab_commentunset(projects, packages, user, no_devel_project = opts.no_devel_project)

    elif cmd in ['setup', 's']:
        package = _collab_parse_arg_packages(args[1])
        _collab_setup(apiurl, user, projects, package, ignore_reserved = opts.ignore_reserved, ignore_comment = opts.ignore_comments, no_reserve = opts.no_reserve, no_devel_project = opts.no_devel_project, no_branch = opts.no_branch)

    elif cmd in ['update', 'up']:
        package = _collab_parse_arg_packages(args[1])
        _collab_update(apiurl, user, email, projects, package, ignore_reserved = opts.ignore_reserved, ignore_comment = opts.ignore_comments, no_reserve = opts.no_reserve, no_devel_project = opts.no_devel_project, no_branch = opts.no_branch)

    elif cmd in ['forward', 'f']:
        request_id = args[1]
        _collab_forward(apiurl, user, projects, request_id, no_supersede = opts.no_supersede)

    elif cmd in ['build', 'b']:
        _collab_build(apiurl, user, projects, opts.msg, repos, archs)

    elif cmd in ['buildsubmit', 'bs']:
        _collab_build_submit(apiurl, user, projects, opts.msg, repos, archs, forward = opts.forward, no_unreserve = opts.no_unreserve, no_supersede = opts.no_supersede)

    else:
        raise RuntimeError('Unknown command: %s' % cmd)
openSUSE Build Service is sponsored by