From cc2af244ffd0e47ee287d38a739daff8bebb83d6 Mon Sep 17 00:00:00 2001 From: Chenxiong Qi Date: Jan 08 2019 13:51:42 +0000 Subject: Watch multiple module builds Code is written to be able to watch multiple module builds just after submitting a module build. Command module-build-watch also accepts command line arguments to watch specified builds. Signed-off-by: Chenxiong Qi --- diff --git a/pyrpkg/__init__.py b/pyrpkg/__init__.py index 8985a82..3872dab 100644 --- a/pyrpkg/__init__.py +++ b/pyrpkg/__init__.py @@ -11,34 +11,37 @@ from __future__ import print_function +import cccolutils import errno import fnmatch import getpass +import git import glob import io import json +import koji import logging import os import posixpath import random import re +import requests +import rpm import shutil +import six import subprocess import sys import tempfile import time -from multiprocessing.dummy import Pool as ThreadPool - -import requests -import rpm -import six import yaml -from six.moves import configparser, urllib + +from itertools import groupby +from multiprocessing.dummy import Pool as ThreadPool +from operator import itemgetter +from six.moves import configparser +from six.moves import urllib from six.moves.urllib.parse import urljoin -import cccolutils -import git -import koji from pyrpkg.errors import (HashtypeMixingError, UnknownTargetError, rpkgAuthError, rpkgError) from pyrpkg.lookaside import CGILookasideCache @@ -3572,6 +3575,86 @@ class Commands(object): builds = data if isinstance(data, list) else [data] return [build['id'] for build in builds] + @staticmethod + def stats_module_build_components(build_info): + stats_key_mapping = { + koji.BUILD_STATES['BUILDING']: 'building', + koji.BUILD_STATES['COMPLETE']: 'done', + koji.BUILD_STATES['FAILED']: 'failed', + } + + stats = { + 'building': 0, + 'done': 0, + 'failed': 0, + 'total': 0, + 'completion_percentage': 0 + } + + for task_state, task_infos in build_info['tasks']['rpms'].items(): + assert isinstance(task_infos, list) + n = len(task_infos) + stats['total'] += n + if task_state not in stats_key_mapping: + continue + stats[stats_key_mapping[task_state]] = n + stats['completion_percentage'] = \ + int(float(stats['done'] + stats['failed']) / stats['total'] * 100) + return stats + + def get_watched_module_builds(self, build_ids): + while True: + pool = ThreadPool() + module_builds = pool.map(self.module_get_build, build_ids) + + for module_build in module_builds: + # Remove ?verbose=True because modulemd is not necessary for watch + module_build['link'] = \ + self.module_get_url(module_build['id']).split('?')[0] + + if module_build['state_name'] == 'init': + continue + + # tasks/rpms is a mapping from package name to package info, + # e.g. {'pkg': {'nvr': ..., 'task_id': ..., 'state': ...}} + # The injected task info will look like: + # {'pkg': ..., 'nvr': ..., 'task_id': ..., 'state': ...} + + # Inject package name into task info and replace None state + # with -1 so that None does not impact the comparison for + # sort. + formatted_tasks = [] + for pkg_name, task_info in module_build['tasks']['rpms'].items(): + new_task_info = task_info.copy() + new_task_info['package_name'] = pkg_name + if new_task_info['state'] is None: + new_task_info['state'] = -1 + formatted_tasks.append(new_task_info) + + # Group rpms by task state for later accessing RPMs info by + # state easily. Calculating statistics depends on it. + formatted_tasks = sorted(formatted_tasks, key=itemgetter('state')) + + # The final result is a mapping from Koji task state to + # list of task info mappings. + module_build['tasks']['rpms'] = dict( + (task_state, sorted(data, key=itemgetter('package_name'))) + for task_state, data in groupby( + formatted_tasks, itemgetter('state')) + ) + + module_build['tasks_stats'] = \ + self.stats_module_build_components(module_build) + + yield module_builds + + all_builds_finish = all(item['state_name'] in ('ready', 'failed') + for item in module_builds) + if all_builds_finish: + break + + time.sleep(5) + def module_watch_build(self, build_ids): """ Watches the first MBS build in the list in a loop that updates every 15 @@ -3581,82 +3664,95 @@ class Commands(object): :param build_ids: a list of module build IDs :type build_ids: list[int] """ - build_id = build_ids[0] - warning = None - if len(build_ids) > 1: - warning = ( - 'WARNING: Module builds {0} and {1} were generated using ' - 'stream expansion. Only module build {2} is being watched.' - .format(', '.join([str(b_id) for b_id in build_ids[0:-1]]), - str(build_ids[-1]), str(build_id))) - # Load the Koji session anonymously so we get access to the Koji web - # URL + # Saved watched module builds got last time + # build_id => module build dict + last_builds = {} + # Load anonymous Koji session in order to access the Koji web URL self.load_kojisession(anon=True) - done = False - while not done: - state_names = self.module_get_koji_state_dict() - build = self.module_get_build(build_id) - tasks = {} - if 'rpms' in build['tasks']: - tasks = build['tasks']['rpms'] - - states = list(set([task['state'] for task in tasks.values()])) - inverted = {} - for name, task in tasks.items(): - state = task['state'] - inverted[state] = inverted.get(state, []) - inverted[state].append(name) - - # Clear the screen - try: - os.system('clear') - except Exception: - # If for whatever reason the clear command fails, fall back to - # clearing the screen using print - print(chr(27) + "[2J") - - if warning: - print(warning) - - # Display all RPMs that have built or have failed - build_state = 0 - failed_state = 3 - for state in (build_state, failed_state): - if state not in inverted: - continue - if state == build_state: - print('Still Building:') + p = print + + def p_tasks(task_infos, task_state, head_line): + p(' {0}:'.format(head_line)) + for ti in task_infos[task_state]: + if ti['nvr'] is None: + # In some Koji task state, e.g. building state, no NVR is + # recorded in MBS. + p(' - %(package_name)s' % ti) else: - print('Failed:') - for name in inverted[state]: - task = tasks[name] - if task['task_id']: - print(' {0} {1}/taskinfo?taskID={2}'.format( - name, self.kojiweburl, task['task_id'])) - else: - print(' {0}'.format(name)) + p(' - %(nvr)s' % ti) + p(' {kojiweburl}/taskinfo?taskID={task_id}'.format( + kojiweburl=self.kojiweburl.rstrip('/'), + task_id=ti['task_id'] + )) + + def p_build_info_lines(build_info): + p('[Build #%(id)s] %(name)s-%(stream)s-%(version)s-%(context)s ' + 'is in "%(state_name)s" state.' % build_info) + if build_info['id'] not in last_builds: + p(' Koji tag: %(koji_tag)s' % build_info) + p(' Link: %(link)s' % build_info) + + for module_builds in self.get_watched_module_builds(build_ids): + for module_build in module_builds: + module_build_id = module_build['id'] + state_name = module_build['state_name'] + + # If module build state is not changed since last time, skip it + # this time. + # However, whether to handle state build will be delayed to + # below by checking tasks statistics, since even if module + # build is still in state build, task infos might changed, e.g. + # some completed already or failed and new task started. + if (state_name != 'build' and + last_builds.get(module_build_id, {}) + .get('state_name') == state_name): + continue - print('\nSummary:') - for state in states: - num_in_state = len(inverted[state]) - if num_in_state == 1: - component_text = 'component' - else: - component_text = 'components' - print(' {0} {1} in the "{2}" state'.format( - num_in_state, component_text, state_names[state].lower())) - - done = build['state_name'] in ['failed', 'done', 'ready'] - - template = ('{owner}\'s build #{id} of {name}-{stream} is in ' - 'the "{state_name}" state') - if build['state_reason']: - template += ' (reason: {state_reason})' - if build.get('koji_tag'): - template += ' (koji tag: "{koji_tag}")' - print(template.format(**build)) - if not done: - time.sleep(15) + # It's ok to get None, init state is the only one that does not + # use tasks statistics and has no tasks for calculating statistics. + stats = module_build.get('tasks_stats') + + if state_name == 'init': + p_build_info_lines(module_build) + elif state_name == 'wait': + p_build_info_lines(module_build) + p(' Components: %(total)s' % stats) + elif state_name in ('done', 'ready'): + p_build_info_lines(module_build) + p(' Components: %(done)s done, %(failed)s failed' % stats) + elif state_name == 'failed': + p_build_info_lines(module_build) + p(' Components: %(done)s done, %(failed)s failed' % stats) + p(' Reason: %(state_reason)s' % module_build) + elif state_name == 'build': + if module_build['id'] not in last_builds: + tasks_changed = True + else: + last_stats = last_builds[module_build['id']]['tasks_stats'] + tasks_changed = ( + stats['building'] != last_stats['building'] or + stats['failed'] > last_stats['failed'] + ) + + if tasks_changed: + p_build_info_lines(module_build) + p(' Components: [%(completion_percentage)s%%]: %(building)s in building,' + ' %(done)s done, %(failed)s failed' % stats) + + task_infos = module_build['tasks']['rpms'] + if stats['building']: + p_tasks(task_infos, koji.BUILD_STATES['BUILDING'], 'Building') + else: + # This is a defense. When test watch with a real + # module build, a special case happens, that is all + # components build finished, but the module build + # got by watch command could be still in build state + # rather than the next state. + p(' No building task.') + if stats['failed']: + p_tasks(task_infos, koji.BUILD_STATES['FAILED'], 'Failed') + + last_builds[module_build_id] = module_build def module_get_api_version(self, api_url): """ diff --git a/pyrpkg/cli.py b/pyrpkg/cli.py index a70b929..db1b4e9 100644 --- a/pyrpkg/cli.py +++ b/pyrpkg/cli.py @@ -1155,7 +1155,8 @@ defined, packages will be built sequentially.""" % {'name': self.name}) self.module_build_watch_parser = self.subparsers.add_parser( 'module-build-watch', help=sub_help, description=sub_help) self.module_build_watch_parser.add_argument( - 'build_id', help='The ID of the module build to watch', type=int) + 'build_id', type=int, nargs='+', + help='The ID of the module build to watch') self.module_build_watch_parser.set_defaults( command=self.module_build_watch) @@ -2025,7 +2026,11 @@ see API KEY section of copr-cli(1) man page. self.args.optional, oidc_id_provider, oidc_client_id, oidc_client_secret, oidc_scopes) if self.args.watch: - self.module_watch_build(build_ids) + try: + self.module_watch_build(build_ids) + except KeyboardInterrupt: + print('Watch is terminated. Use module-build-watch command to watch again.') + raise elif not self.args.q: if len(build_ids) > 1: ids_to_print = 'builds {0} and {1} were'.format( @@ -2166,8 +2171,8 @@ see API KEY section of copr-cli(1) man page. self.cmd.module_api_url = self.module_api_url def module_build_watch(self): - """Watch an MBS build from the command-line""" - self.module_watch_build([self.args.build_id]) + """Watch MBS builds from the command-line""" + self.module_watch_build(self.args.build_id) def module_overview(self): """Show the overview of the latest builds in the MBS""" diff --git a/tests/test_cli.py b/tests/test_cli.py index 2a90cb9..e035938 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2423,62 +2423,6 @@ Components: self.sort_lines(output)) @patch('sys.stdout', new=StringIO()) - @patch.object(Commands, 'kojiweburl', - 'https://koji.fedoraproject.org/koji') - @patch('requests.get') - @patch('os.system') - @patch.object(Commands, 'load_kojisession') - def test_module_build_watch(self, mock_load_koji, mock_system, mock_get): - """ - Test watching a module build that is already complete - """ - cli_cmd = [ - 'rpkg', - '--path', - self.cloned_repo_path, - 'module-build-watch', - '1500' - ] - mock_rv = Mock() - mock_rv.ok = True - mock_rv.json.return_value = { - 'auth_method': 'oidc', - 'api_version': 2 - } - mock_rv_two = Mock() - mock_rv_two.ok = True - mock_rv_two.json.return_value = self.module_build_json - mock_get.side_effect = [mock_rv, mock_rv_two] - - with patch('sys.argv', new=cli_cmd): - cli = self.new_cli() - cli.module_build_watch() - - exp_url = ('https://mbs.fedoraproject.org/module-build-service/1/' - 'about/') - exp_url_two = ('https://mbs.fedoraproject.org/module-build-service/2/' - 'module-builds/1500?verbose=true') - self.assertEqual(mock_get.call_args_list, [ - call(exp_url, timeout=60), - call(exp_url_two, timeout=60) - ]) - mock_system.assert_called_once_with('clear') - output = sys.stdout.getvalue().strip() - expected_output = """\ -Failed: - module-build-macros https://koji.fedoraproject.org/koji/taskinfo?taskID=22370514 - python-dns - python-cryptography - -Summary: - 3 components in the "failed" state -torsava's build #2150 of python3-ecosystem-master is in the "failed" state (reason: Some error) (koji tag: "module-14050f52e62d955b") -""" # noqa - self.maxDiff = None - self.assertEqual(self.sort_lines(expected_output), - self.sort_lines(output)) - - @patch('sys.stdout', new=StringIO()) @patch('requests.get') @patch('pyrpkg.ThreadPool', new=FakeThreadPool) def test_module_overview(self, mock_get): diff --git a/tests/test_module_build.py b/tests/test_module_build.py new file mode 100644 index 0000000..8e6b5d1 --- /dev/null +++ b/tests/test_module_build.py @@ -0,0 +1,431 @@ +# -*- coding: utf-8 -*- + +import copy +import six +import sys + +from mock import patch, PropertyMock +from utils import CommandTestCase + + +def thread_pool_map(func, iterable): + return [func(item) for item in iterable] + + +class TestWatchModuleBuilds(CommandTestCase): + """Test Commands.module_watch_build""" + + require_test_repos = False + + def setUp(self): + super(TestWatchModuleBuilds, self).setUp() + self.cmd = self.make_commands(path='/path/to/repo') + + self.p_ThreadPool = patch('pyrpkg.ThreadPool') + self.mock_ThreadPool = self.p_ThreadPool.start() + self.mock_ThreadPool.return_value.map = thread_pool_map + + self.p_kojiweburl = patch('pyrpkg.Commands.kojiweburl', + new_callable=PropertyMock, + return_value='http://koji.example.com/') + self.p_kojiweburl.start() + + self.p_sleep = patch('time.sleep') + self.p_sleep.start() + + def tearDown(self): + self.p_sleep.stop() + self.p_kojiweburl.stop() + self.p_ThreadPool.stop() + super(TestWatchModuleBuilds, self).tearDown() + + @patch('pyrpkg.Commands.module_get_build') + @patch('pyrpkg.Commands.load_kojisession') + def watch_builds(self, fake_builds_info, watch_ids, expected_output, + load_kojisession, module_get_build): + def side_effect(build_id): + br_list = fake_builds_info[build_id] + if br_list[0] < len(br_list) - 1: + br_list[0] += 1 + # If a module build reaches the last state, either ready or + # failed, when any time to query this module, this last state + # build will be returned. + # + # Note that: watch method modifies got build info mapping. So, + # return a copy to ensure fake data is not modified. + return copy.deepcopy(br_list[br_list[0]]) + + module_get_build.side_effect = side_effect + + with patch.object(self.cmd, 'module_api_url', new='http://mbs/'): + with patch('sys.stdout', new=six.moves.StringIO()): + self.cmd.module_watch_build(watch_ids) + + output = sys.stdout.getvalue() + self.assertEqual(expected_output, output) + + def test_all_builds_done_already(self): + fake_builds_info = { + 1: [0, { + 'id': 1, 'name': 'modulea', 'stream': 'master', + 'version': '1', 'context': '1234', + 'state': 5, 'state_name': 'ready', + 'koji_tag': 'module-moduleb-master-v-c1', + 'tasks': { + 'rpms': { + 'pkg': { + 'nvr': 'pkg-1.0-1.fc28', + 'task_id': 1000, + 'state': 1, + } + } + } + }], + 2: [0, { + 'id': 2, 'name': 'moduleb', 'stream': '6', + 'version': '1', 'context': '3456', + 'state': 5, 'state_name': 'ready', + 'koji_tag': 'module-moduleb-6-v-c2', + 'tasks': { + 'rpms': { + 'pkg2': { + 'nvr': 'pkg2-1.0-1.fc28', + 'task_id': 1001, + 'state': 1, + } + } + } + }], + 3: [0, { + 'id': 3, 'name': 'modulec', 'stream': 'master', + 'version': '1', 'context': '6789', + 'state': 4, 'state_name': 'failed', + 'state_reason': 'Component build failed.', + 'koji_tag': 'module-moduleb-master-v-c3', + 'tasks': { + 'rpms': { + 'pkg3': { + 'nvr': 'pkg3-1.0-1.fc28', + 'task_id': 1002, + 'state': 3, + } + } + } + }], + } + + expected_output = '''\ +[Build #1] modulea-master-1-1234 is in "ready" state. + Koji tag: module-moduleb-master-v-c1 + Link: http://mbs/module-builds/1 + Components: 1 done, 0 failed +[Build #2] moduleb-6-1-3456 is in "ready" state. + Koji tag: module-moduleb-6-v-c2 + Link: http://mbs/module-builds/2 + Components: 1 done, 0 failed +[Build #3] modulec-master-1-6789 is in "failed" state. + Koji tag: module-moduleb-master-v-c3 + Link: http://mbs/module-builds/3 + Components: 0 done, 1 failed + Reason: Component build failed. +''' + self.watch_builds(fake_builds_info, + list(fake_builds_info.keys()), + expected_output) + + def test_watch_common_process(self): + fake_builds_info = { + # Module#1: init -> wait -> build -> done -> ready + 1: [ + 0, + { + 'id': 1, 'name': 'modulea', 'stream': 'master', + 'version': '1', 'context': 'c1', + 'state': 0, 'state_name': 'init', + 'koji_tag': 'module-modulea-master-1-c1', + 'tasks': {} + }, + { + 'id': 1, 'name': 'modulea', 'stream': 'master', + 'version': '1', 'context': 'c1', + 'state': 1, 'state_name': 'wait', + 'koji_tag': 'module-modulea-master-1-c1', + 'tasks': { + 'rpms': { + 'pkg': {'nvr': None, 'task_id': 1000, 'state': None} + } + } + }, + { + 'id': 1, 'name': 'modulea', 'stream': 'master', + 'version': '1', 'context': 'c1', + 'state': 2, 'state_name': 'build', + 'koji_tag': 'module-modulea-master-1-c1', + 'tasks': { + 'rpms': { + 'pkg': {'nvr': None, 'task_id': 1000, 'state': 0}, + } + } + }, + { + 'id': 1, 'name': 'modulea', 'stream': 'master', + 'version': '1', 'context': 'c1', + 'state': 3, 'state_name': 'done', + 'koji_tag': 'module-modulea-master-1-c1', + 'tasks': { + 'rpms': { + 'pkg': { + 'nvr': 'pkg-1.0-1.fc28', + 'task_id': 1000, + 'state': 1, + } + } + } + }, + { + 'id': 1, 'name': 'modulea', 'stream': 'master', + 'version': '1', 'context': 'c1', + 'state': 5, 'state_name': 'ready', + 'koji_tag': 'module-modulea-master-1-c1', + 'tasks': { + 'rpms': { + 'pkg': { + 'nvr': 'pkg-1.0-1.fc28', + 'task_id': 1000, + 'state': 1, + } + } + } + }, + ], + # Module#2: wait -> failed + 2: [ + 0, + { + 'id': 2, 'name': 'moduleb', 'stream': '6', + 'version': '1', 'context': 'c2', + 'state': 1, 'state_name': 'wait', + 'koji_tag': 'module-moduleb-6-1-c2', + 'tasks': { + 'rpms': { + 'pkg2': {'nvr': None, 'task_id': 2000, 'state': None} + } + } + }, + { + 'id': 2, 'name': 'moduleb', 'stream': '6', + 'version': '1', 'context': 'c2', + 'state': 4, 'state_name': 'failed', + 'state_reason': 'Build failed.', + 'koji_tag': 'module-moduleb-6-1-c2', + 'tasks': { + 'rpms': { + 'pkg2': { + 'nvr': 'pkg2-1.0-1.fc28', + 'task_id': 2000, + 'state': 3, + } + } + } + }, + ], + # Module#3: build -> build (some build doesn't finish yet) -> failed + # With some failed component build. + 3: [ + 0, + { + 'id': 3, 'name': 'perl', 'stream': '5.24', + 'version': '1', 'context': 'c3', + 'state': 2, 'state_name': 'build', + 'koji_tag': 'module-perl-5.24-1-c3', + 'tasks': { + 'rpms': { + 'perl': {'nvr': None, 'task_id': 1000, 'state': 0}, + 'perl-CPAN': {'nvr': None, 'task_id': 1000, 'state': 0}, + } + } + }, + { + 'id': 3, 'name': 'perl', 'stream': '5.24', + 'version': '1', 'context': 'c3', + 'state': 2, 'state_name': 'build', + 'koji_tag': 'module-perl-5.24-1-c3', + 'tasks': { + 'rpms': { + 'perl': {'nvr': None, 'task_id': 1000, 'state': 0}, + 'perl-CPAN': { + 'nvr': 'perl-CPAN-2.16-1.module_1688+afbe1536', + 'task_id': 1000, + 'state': 3 + }, + } + } + }, + { + 'id': 3, 'name': 'perl', 'stream': '5.24', + 'version': '1', 'context': 'c3', + 'state': 2, 'state_name': 'failed', + 'state_reason': 'Component build failed.', + 'koji_tag': 'module-perl-5.24-1-c3', + 'tasks': { + 'rpms': { + 'perl': { + 'nvr': 'perl-5.24.4-397.module_1688+afbe1536', + 'task_id': 1000, + 'state': 1 + }, + 'perl-CPAN': { + 'nvr': 'perl-CPAN-2.16-1.module_1688+afbe1536', + 'task_id': 1000, + 'state': 3 + }, + } + } + }, + ], + } + + expected_output = '''\ +[Build #1] modulea-master-1-c1 is in "init" state. + Koji tag: module-modulea-master-1-c1 + Link: http://mbs/module-builds/1 +[Build #2] moduleb-6-1-c2 is in "wait" state. + Koji tag: module-moduleb-6-1-c2 + Link: http://mbs/module-builds/2 + Components: 1 +[Build #3] perl-5.24-1-c3 is in "build" state. + Koji tag: module-perl-5.24-1-c3 + Link: http://mbs/module-builds/3 + Components: [0%]: 2 in building, 0 done, 0 failed + Building: + - perl + http://koji.example.com/taskinfo?taskID=1000 + - perl-CPAN + http://koji.example.com/taskinfo?taskID=1000 +[Build #1] modulea-master-1-c1 is in "wait" state. + Components: 1 +[Build #2] moduleb-6-1-c2 is in "failed" state. + Components: 0 done, 1 failed + Reason: Build failed. +[Build #3] perl-5.24-1-c3 is in "build" state. + Components: [50%]: 1 in building, 0 done, 1 failed + Building: + - perl + http://koji.example.com/taskinfo?taskID=1000 + Failed: + - perl-CPAN-2.16-1.module_1688+afbe1536 + http://koji.example.com/taskinfo?taskID=1000 +[Build #1] modulea-master-1-c1 is in "build" state. + Components: [0%]: 1 in building, 0 done, 0 failed + Building: + - pkg + http://koji.example.com/taskinfo?taskID=1000 +[Build #3] perl-5.24-1-c3 is in "failed" state. + Components: 1 done, 1 failed + Reason: Component build failed. +[Build #1] modulea-master-1-c1 is in "done" state. + Components: 1 done, 0 failed +[Build #1] modulea-master-1-c1 is in "ready" state. + Components: 1 done, 0 failed +''' + self.watch_builds(fake_builds_info, + list(fake_builds_info.keys()), + expected_output) + + def test_no_building_task_if_module_build_is_in_build_state(self): + fake_builds_info = { + 1: [ + 0, + { + 'id': 1, 'name': 'perl', 'stream': '5.24', + 'version': '1', 'context': 'c3', + 'state': 1, 'state_name': 'wait', + 'koji_tag': 'module-perl-5.24-1-c3', + 'tasks': { + 'rpms': { + 'perl': {'nvr': None, 'task_id': None, 'state': None}, + 'perl-CPAN': {'nvr': None, 'task_id': None, 'state': None}, + } + } + }, + { + 'id': 1, 'name': 'perl', 'stream': '5.24', + 'version': '1', 'context': 'c3', + 'state': 2, 'state_name': 'build', + 'koji_tag': 'module-perl-5.24-1-c3', + 'tasks': { + 'rpms': { + 'perl': {'nvr': None, 'task_id': 1000, 'state': 0}, + 'perl-CPAN': {'nvr': None, 'task_id': 1001, 'state': 0}, + } + } + }, + { + 'id': 1, 'name': 'perl', 'stream': '5.24', + 'version': '1', 'context': 'c3', + 'state': 2, 'state_name': 'build', + 'koji_tag': 'module-perl-5.24-1-c3', + 'tasks': { + 'rpms': { + 'perl': { + 'nvr': 'perl-5.24.4-397.module_1688+afbe1536', + 'task_id': 1000, + 'state': 1, + }, + 'perl-CPAN': { + 'nvr': 'perl-CPAN-2.16-1.module_1688+afbe1536', + 'task_id': 1001, + 'state': 3, + }, + } + } + }, + { + 'id': 1, 'name': 'perl', 'stream': '5.24', + 'version': '1', 'context': 'c3', + 'state': 4, 'state_name': 'failed', + 'state_reason': 'Component build failed.', + 'koji_tag': 'module-perl-5.24-1-c3', + 'tasks': { + 'rpms': { + 'perl': { + 'nvr': 'perl-5.24.4-397.module_1688+afbe1536', + 'task_id': 1000, + 'state': 1, + }, + 'perl-CPAN': { + 'nvr': 'perl-CPAN-2.16-1.module_1688+afbe1536', + 'task_id': 1001, + 'state': 3, + }, + } + } + }, + ] + } + + expected_output = '''\ +[Build #1] perl-5.24-1-c3 is in "wait" state. + Koji tag: module-perl-5.24-1-c3 + Link: http://mbs/module-builds/1 + Components: 2 +[Build #1] perl-5.24-1-c3 is in "build" state. + Components: [0%]: 2 in building, 0 done, 0 failed + Building: + - perl + http://koji.example.com/taskinfo?taskID=1000 + - perl-CPAN + http://koji.example.com/taskinfo?taskID=1001 +[Build #1] perl-5.24-1-c3 is in "build" state. + Components: [100%]: 0 in building, 1 done, 1 failed + No building task. + Failed: + - perl-CPAN-2.16-1.module_1688+afbe1536 + http://koji.example.com/taskinfo?taskID=1001 +[Build #1] perl-5.24-1-c3 is in "failed" state. + Components: 1 done, 1 failed + Reason: Component build failed. +''' + self.watch_builds(fake_builds_info, + list(fake_builds_info.keys()), + expected_output)