#!/usr/bin/python

# Simple script that will output the release where a given bug was fixed
# searching the NEWS file

import optparse
import re
import sys


class NewsParser(object):

    paren_exp_re = re.compile('\(([^)]+)\)')
    release_re = re.compile("bzr[ -]")
    release_prefix_length = len('bzr ')
    bugs_re = re.compile('#([0-9]+)')

    def __init__(self, news):
        self.news = news
        # Temporary attributes used by the parser
        self.release = None
        self.date = None
        self.may_be_release = None
        self.release_markup = None
        self.entry = ''
        self.line = None
        self.lrs = None

    def set_line(self, line):
        self.line = line
        self.lrs = line.rstrip()

    def try_release(self):
        if self.release_re.match(self.lrs) is not None:
            # May be a new release
            self.may_be_release = self.lrs
            # We know the markup will have the same length as the release
            self.release_markup = '#' * len(self.may_be_release)
            return True
        return False

    def confirm_release(self):
        if self.may_be_release is not None and self.lrs == self.release_markup:
            # The release is followed by the right markup
            self.release = self.may_be_release[self.release_prefix_length:]
            # Wait for the associated date
            self.date = None
            return True
        return False

    def try_date(self):
        if self.release is None:
            return False
        date_re = re.compile(':%s: (NOT RELEASED YET|\d{4}-\d{2}-\d{2})'
                             % (self.release,))
        match = date_re.match(self.lrs)
        if  match is not None:
            self.date = match.group(1)
            return True
        # The old fashion way
        released_re = re.compile(':Released:\s+(\d{4}-\d{2}-\d{2})')
        match = released_re.match(self.lrs)
        if  match is not None:
            self.date = match.group(1)
            return True
        return False

    def add_line_to_entry(self):
        if self.lrs == '':
            return False
        self.entry += self.line
        return True

    def extract_bugs_from_entry(self):
        """Possibly extract bugs from a NEWS entry and yield them.

        Not all entries will contain bugs and some entries are even garbage and
        we don't try to parse them (yet). The trigger is a '#' and what looks
        like a bug number inside parens to start with. From that we extract
        authors (when present) and multiple bugs if needed.
        """
        # FIXME: Malone entries are different
        # Join all entry lines to simplify multiple line matching
        flat_entry = ' '.join(self.entry.splitlines())
        # Fixed bugs are always inside parens
        for par in self.paren_exp_re.findall(flat_entry):
            sharp = par.find('#')
            if sharp is not None:
                # We have at least one bug inside parens.
                bugs = list(self.bugs_re.finditer(par))
                if bugs:
                    # See where the first bug is mentioned
                    start = bugs[0].start()
                    end = bugs[-1].end()
                    if start == 0:
                        # (bugs/authors)
                        authors = par[end:]
                    else:
                        # (authors/bugs)
                         authors = par[:start]
                    for bug_match in bugs:
                        bug_number = bug_match.group(0)
                        yield (bug_number, authors,
                               self.release, self.date, self.entry)
        # We've consumed the entry
        self.entry = ''

    def parse_bugs(self):
        for line in self.news:
            self.set_line(line)
            if self.try_release():
                continue # line may a be release
            try:
                if self.confirm_release():
                    continue # previous line was indeed a release
            finally:
                self.may_be_release = None
            if self.try_date():
                continue # The release date has been seen
            if self.add_line_to_entry():
                continue # accumulate in self.enrty
            for b in self.extract_bugs_from_entry():
                yield b # all bugs in the news entry

def main():
    opt_parser = optparse.OptionParser(
        usage="""Usage: %prog [options] BUG_NUMBER
    """)
    opt_parser.add_option(
        '-f', '--file', type='str', dest='news_file',
        help='NEWS file (defaults to ./NEWS)')
    opt_parser.add_option(
        '-m', '--message', type='str', dest='msg_re',
        help='A regexp to search for in the news entry '
        '(BUG_NUMBER should not be specified in this case)')
    opt_parser.set_defaults(news_file='./NEWS')
    (opts, args) = opt_parser.parse_args(sys.argv[1:])
    if opts.msg_re is not None:
        if len(args) != 0:
            opt_parser.error('BUG_NUMBER and -m are mutually exclusive')
        bug = None
        msg_re = re.compile(opts.msg_re)
    elif len(args) != 1:
        opt_parser.error('Expected a single bug number, got %r' % args)
    else:
        bug = args[0]

    news = open(opts.news_file)
    parser = NewsParser(news)
    try:
        seen = 0
        for b in parser.parse_bugs():
            (number, authors, release, date, entry,) = b
            # indent entry
            entry = '\n'.join(['    ' + l for l in entry.splitlines()])
            found = False
            if bug is not None:
                if number[1:] == bug: # Strip the leading '#'
                    found = True
            elif msg_re.search(entry) is not None:
                found = True
            if found:
                print 'Bug %s was fixed in bzr-%s/%s by %s:' % (
                    number, release, date, authors)
                print entry
            seen += 1
    finally:
        print '%s bugs seen' % (seen,)
        news.close()


main()
