#!/usr/bin/python

"""Release testtools on Launchpad.

Steps:
 1. Make sure all "Fix committed" bugs are assigned to 'next'
 2. Rename 'next' to the new version
 3. Release the milestone
 4. Upload the tarball
 5. Create a new 'next' milestone
 6. Mark all "Fix committed" bugs in the milestone as "Fix released"

Assumes that NEWS is in the parent directory, that the release sections are
underlined with '~' and the subsections are underlined with '-'.

Assumes that this file is in the 'scripts' directory a testtools tree that has
already had a tarball built and uploaded with 'python setup.py sdist upload
--sign'.
"""

from datetime import datetime, timedelta, tzinfo
import logging
import os
import sys

from launchpadlib.launchpad import Launchpad
from launchpadlib import uris


APP_NAME = 'testtools-lp-release'
CACHE_DIR = os.path.expanduser('~/.launchpadlib/cache')
SERVICE_ROOT = uris.LPNET_SERVICE_ROOT

FIX_COMMITTED = u"Fix Committed"
FIX_RELEASED = u"Fix Released"

# Launchpad file type for a tarball upload.
CODE_RELEASE_TARBALL = 'Code Release Tarball'

PROJECT_NAME = 'testtools'
NEXT_MILESTONE_NAME = 'next'


class _UTC(tzinfo):
    """UTC"""

    def utcoffset(self, dt):
        return timedelta(0)

    def tzname(self, dt):
        return "UTC"

    def dst(self, dt):
        return timedelta(0)

UTC = _UTC()


def configure_logging():
    level = logging.INFO
    log = logging.getLogger(APP_NAME)
    log.setLevel(level)
    handler = logging.StreamHandler()
    handler.setLevel(level)
    formatter = logging.Formatter("%(levelname)s: %(message)s")
    handler.setFormatter(formatter)
    log.addHandler(handler)
    return log
LOG = configure_logging()


def get_path(relpath):
    """Get the absolute path for something relative to this file."""
    return os.path.abspath(
        os.path.join(
            os.path.dirname(os.path.dirname(__file__)), relpath))


def assign_fix_committed_to_next(testtools, next_milestone):
    """Find all 'Fix Committed' and make sure they are in 'next'."""
    fixed_bugs = list(testtools.searchTasks(status=FIX_COMMITTED))
    for task in fixed_bugs:
        LOG.debug("%s" % (task.title,))
        if task.milestone != next_milestone:
            task.milestone = next_milestone
            LOG.info("Re-assigning %s" % (task.title,))
            task.lp_save()


def rename_milestone(next_milestone, new_name):
    """Rename 'next_milestone' to 'new_name'."""
    LOG.info("Renaming %s to %s" % (next_milestone.name, new_name))
    next_milestone.name = new_name
    next_milestone.lp_save()


def get_release_notes_and_changelog(news_path):
    release_notes = []
    changelog = []
    state = None
    last_line = None

    def is_heading_marker(line, marker_char):
        return line and line == marker_char * len(line)

    LOG.debug("Loading NEWS from %s" % (news_path,))
    with open(news_path, 'r') as news:
        for line in news:
            line = line.strip()
            if state is None:
                if is_heading_marker(line, '~'):
                    milestone_name = last_line
                    state = 'release-notes'
                else:
                    last_line = line
            elif state == 'title':
                # The line after the title is a heading marker line, so we
                # ignore it and change state. That which follows are the
                # release notes.
                state = 'release-notes'
            elif state == 'release-notes':
                if is_heading_marker(line, '-'):
                    state = 'changelog'
                    # Last line in the release notes is actually the first
                    # line of the changelog.
                    changelog = [release_notes.pop(), line]
                else:
                    release_notes.append(line)
            elif state == 'changelog':
                if is_heading_marker(line, '~'):
                    # Last line in changelog is actually the first line of the
                    # next section.
                    changelog.pop()
                    break
                else:
                    changelog.append(line)
            else:
                raise ValueError("Couldn't parse NEWS")

    release_notes = '\n'.join(release_notes).strip() + '\n'
    changelog = '\n'.join(changelog).strip() + '\n'
    return milestone_name, release_notes, changelog


def release_milestone(milestone, release_notes, changelog):
    date_released = datetime.now(tz=UTC)
    LOG.info(
        "Releasing milestone: %s, date %s" % (milestone.name, date_released))
    release = milestone.createProductRelease(
        date_released=date_released,
        changelog=changelog,
        release_notes=release_notes,
        )
    milestone.is_active = False
    milestone.lp_save()
    return release


def create_milestone(series, name):
    """Create a new milestone in the same series as 'release_milestone'."""
    LOG.info("Creating milestone %s in series %s" % (name, series.name))
    return series.newMilestone(name=name)


def close_fixed_bugs(milestone):
    tasks = list(milestone.searchTasks())
    for task in tasks:
        LOG.debug("Found %s" % (task.title,))
        if task.status == FIX_COMMITTED:
            LOG.info("Closing %s" % (task.title,))
            task.status = FIX_RELEASED
        else:
            LOG.warning(
                "Bug not fixed, removing from milestone: %s" % (task.title,))
            task.milestone = None
        task.lp_save()


def upload_tarball(release, tarball_path):
    with open(tarball_path) as tarball:
        tarball_content = tarball.read()
    sig_path = tarball_path + '.asc'
    with open(sig_path) as sig:
        sig_content = sig.read()
    tarball_name = os.path.basename(tarball_path)
    LOG.info("Uploading tarball: %s" % (tarball_path,))
    release.add_file(
        file_type=CODE_RELEASE_TARBALL,
        file_content=tarball_content, filename=tarball_name,
        signature_content=sig_content,
        signature_filename=sig_path,
        content_type="application/x-gzip; charset=binary")


def release_project(launchpad, project_name, next_milestone_name):
    testtools = launchpad.projects[project_name]
    next_milestone = testtools.getMilestone(name=next_milestone_name)
    release_name, release_notes, changelog = get_release_notes_and_changelog(
        get_path('NEWS'))
    LOG.info("Releasing %s %s" % (project_name, release_name))
    # Since reversing these operations is hard, and inspecting errors from
    # Launchpad is also difficult, do some looking before leaping.
    errors = []
    tarball_path = get_path('dist/%s-%s.tar.gz' % (project_name, release_name,))
    if not os.path.isfile(tarball_path):
        errors.append("%s does not exist" % (tarball_path,))
    if not os.path.isfile(tarball_path + '.asc'):
        errors.append("%s does not exist" % (tarball_path + '.asc',))
    if testtools.getMilestone(name=release_name):
        errors.append("Milestone %s exists on %s" % (release_name, project_name))
    if errors:
        for error in errors:
            LOG.error(error)
        return 1
    assign_fix_committed_to_next(testtools, next_milestone)
    rename_milestone(next_milestone, release_name)
    release = release_milestone(next_milestone, release_notes, changelog)
    upload_tarball(release, tarball_path)
    create_milestone(next_milestone.series_target, next_milestone_name)
    close_fixed_bugs(next_milestone)
    return 0


def main(args):
    launchpad = Launchpad.login_with(APP_NAME, SERVICE_ROOT, CACHE_DIR)
    return release_project(launchpad, PROJECT_NAME, NEXT_MILESTONE_NAME)


if __name__ == '__main__':
    sys.exit(main(sys.argv))