1# -*- coding: utf-8 -*-
2# SPDX-License-Identifier:	GPL-2.0+
3#
4# Copyright 2017 Google, Inc
5#
6
7"""Functional tests for checking that patman behaves correctly"""
8
9import contextlib
10import os
11import pathlib
12import re
13import shutil
14import sys
15import tempfile
16import unittest
17
18
19from patman.commit import Commit
20from patman import control
21from patman import gitutil
22from patman import patchstream
23from patman.patchstream import PatchStream
24from patman.series import Series
25from patman import settings
26from u_boot_pylib import terminal
27from u_boot_pylib import tools
28from u_boot_pylib.test_util import capture_sys_output
29
30import pygit2
31from patman import status
32
33PATMAN_DIR = pathlib.Path(__file__).parent
34TEST_DATA_DIR = PATMAN_DIR / 'test/'
35
36
37@contextlib.contextmanager
38def directory_excursion(directory):
39    """Change directory to `directory` for a limited to the context block."""
40    current = os.getcwd()
41    try:
42        os.chdir(directory)
43        yield
44    finally:
45        os.chdir(current)
46
47
48class TestFunctional(unittest.TestCase):
49    """Functional tests for checking that patman behaves correctly"""
50    leb = (b'Lord Edmund Blackadd\xc3\xabr <weasel@blackadder.org>'.
51           decode('utf-8'))
52    fred = 'Fred Bloggs <f.bloggs@napier.net>'
53    joe = 'Joe Bloggs <joe@napierwallies.co.nz>'
54    mary = 'Mary Bloggs <mary@napierwallies.co.nz>'
55    commits = None
56    patches = None
57
58    def setUp(self):
59        self.tmpdir = tempfile.mkdtemp(prefix='patman.')
60        self.gitdir = os.path.join(self.tmpdir, 'git')
61        self.repo = None
62
63    def tearDown(self):
64        shutil.rmtree(self.tmpdir)
65        terminal.set_print_test_mode(False)
66
67    @staticmethod
68    def _get_path(fname):
69        """Get the path to a test file
70
71        Args:
72            fname (str): Filename to obtain
73
74        Returns:
75            str: Full path to file in the test directory
76        """
77        return TEST_DATA_DIR / fname
78
79    @classmethod
80    def _get_text(cls, fname):
81        """Read a file as text
82
83        Args:
84            fname (str): Filename to read
85
86        Returns:
87            str: Contents of file
88        """
89        return open(cls._get_path(fname), encoding='utf-8').read()
90
91    @classmethod
92    def _get_patch_name(cls, subject):
93        """Get the filename of a patch given its subject
94
95        Args:
96            subject (str): Patch subject
97
98        Returns:
99            str: Filename for that patch
100        """
101        fname = re.sub('[ :]', '-', subject)
102        return fname.replace('--', '-')
103
104    def _create_patches_for_test(self, series):
105        """Create patch files for use by tests
106
107        This copies patch files from the test directory as needed by the series
108
109        Args:
110            series (Series): Series containing commits to convert
111
112        Returns:
113            tuple:
114                str: Cover-letter filename, or None if none
115                fname_list: list of str, each a patch filename
116        """
117        cover_fname = None
118        fname_list = []
119        for i, commit in enumerate(series.commits):
120            clean_subject = self._get_patch_name(commit.subject)
121            src_fname = '%04d-%s.patch' % (i + 1, clean_subject[:52])
122            fname = os.path.join(self.tmpdir, src_fname)
123            shutil.copy(self._get_path(src_fname), fname)
124            fname_list.append(fname)
125        if series.get('cover'):
126            src_fname = '0000-cover-letter.patch'
127            cover_fname = os.path.join(self.tmpdir, src_fname)
128            fname = os.path.join(self.tmpdir, src_fname)
129            shutil.copy(self._get_path(src_fname), fname)
130
131        return cover_fname, fname_list
132
133    def test_basic(self):
134        """Tests the basic flow of patman
135
136        This creates a series from some hard-coded patches build from a simple
137        tree with the following metadata in the top commit:
138
139            Series-to: u-boot
140            Series-prefix: RFC
141            Series-postfix: some-branch
142            Series-cc: Stefan Brüns <stefan.bruens@rwth-aachen.de>
143            Cover-letter-cc: Lord Mëlchett <clergy@palace.gov>
144            Series-version: 3
145            Patch-cc: fred
146            Series-process-log: sort, uniq
147            Series-changes: 4
148            - Some changes
149            - Multi
150              line
151              change
152
153            Commit-changes: 2
154            - Changes only for this commit
155
156'            Cover-changes: 4
157            - Some notes for the cover letter
158
159            Cover-letter:
160            test: A test patch series
161            This is a test of how the cover
162            letter
163            works
164            END
165
166        and this in the first commit:
167
168            Commit-changes: 2
169            - second revision change
170
171            Series-notes:
172            some notes
173            about some things
174            from the first commit
175            END
176
177            Commit-notes:
178            Some notes about
179            the first commit
180            END
181
182        with the following commands:
183
184           git log -n2 --reverse >/path/to/tools/patman/test/test01.txt
185           git format-patch --subject-prefix RFC --cover-letter HEAD~2
186           mv 00* /path/to/tools/patman/test
187
188        It checks these aspects:
189            - git log can be processed by patchstream
190            - emailing patches uses the correct command
191            - CC file has information on each commit
192            - cover letter has the expected text and subject
193            - each patch has the correct subject
194            - dry-run information prints out correctly
195            - unicode is handled correctly
196            - Series-to, Series-cc, Series-prefix, Series-postfix, Cover-letter
197            - Cover-letter-cc, Series-version, Series-changes, Series-notes
198            - Commit-notes
199        """
200        process_tags = True
201        ignore_bad_tags = False
202        stefan = b'Stefan Br\xc3\xbcns <stefan.bruens@rwth-aachen.de>'.decode('utf-8')
203        rick = 'Richard III <richard@palace.gov>'
204        mel = b'Lord M\xc3\xablchett <clergy@palace.gov>'.decode('utf-8')
205        add_maintainers = [stefan, rick]
206        dry_run = True
207        in_reply_to = mel
208        count = 2
209        settings.alias = {
210            'fdt': ['simon'],
211            'u-boot': ['u-boot@lists.denx.de'],
212            'simon': [self.leb],
213            'fred': [self.fred],
214        }
215
216        text = self._get_text('test01.txt')
217        series = patchstream.get_metadata_for_test(text)
218        cover_fname, args = self._create_patches_for_test(series)
219        get_maintainer_script = str(pathlib.Path(__file__).parent.parent.parent
220                                    / 'get_maintainer.pl') + ' --norolestats'
221        with capture_sys_output() as out:
222            patchstream.fix_patches(series, args)
223            if cover_fname and series.get('cover'):
224                patchstream.insert_cover_letter(cover_fname, series, count)
225            series.DoChecks()
226            cc_file = series.MakeCcFile(process_tags, cover_fname,
227                                        not ignore_bad_tags, add_maintainers,
228                                        None, get_maintainer_script)
229            cmd = gitutil.email_patches(
230                series, cover_fname, args, dry_run, not ignore_bad_tags,
231                cc_file, in_reply_to=in_reply_to, thread=None)
232            series.ShowActions(args, cmd, process_tags)
233        cc_lines = open(cc_file, encoding='utf-8').read().splitlines()
234        os.remove(cc_file)
235
236        lines = iter(out[0].getvalue().splitlines())
237        self.assertEqual('Cleaned %s patches' % len(series.commits),
238                         next(lines))
239        self.assertEqual('Change log missing for v2', next(lines))
240        self.assertEqual('Change log missing for v3', next(lines))
241        self.assertEqual('Change log for unknown version v4', next(lines))
242        self.assertEqual("Alias 'pci' not found", next(lines))
243        while next(lines) != 'Cc processing complete':
244            pass
245        self.assertIn('Dry run', next(lines))
246        self.assertEqual('', next(lines))
247        self.assertIn('Send a total of %d patches' % count, next(lines))
248        prev = next(lines)
249        for i, commit in enumerate(series.commits):
250            self.assertEqual('   %s' % args[i], prev)
251            while True:
252                prev = next(lines)
253                if 'Cc:' not in prev:
254                    break
255        self.assertEqual('To:	  u-boot@lists.denx.de', prev)
256        self.assertEqual('Cc:	  %s' % stefan, next(lines))
257        self.assertEqual('Version:  3', next(lines))
258        self.assertEqual('Prefix:\t  RFC', next(lines))
259        self.assertEqual('Postfix:\t  some-branch', next(lines))
260        self.assertEqual('Cover: 4 lines', next(lines))
261        self.assertEqual('      Cc:  %s' % self.fred, next(lines))
262        self.assertEqual('      Cc:  %s' % self.leb,
263                         next(lines))
264        self.assertEqual('      Cc:  %s' % mel, next(lines))
265        self.assertEqual('      Cc:  %s' % rick, next(lines))
266        expected = ('Git command: git send-email --annotate '
267                    '--in-reply-to="%s" --to "u-boot@lists.denx.de" '
268                    '--cc "%s" --cc-cmd "%s send --cc-cmd %s" %s %s'
269                    % (in_reply_to, stefan, sys.argv[0], cc_file, cover_fname,
270                       ' '.join(args)))
271        self.assertEqual(expected, next(lines))
272
273        self.assertEqual(('%s %s\0%s' % (args[0], rick, stefan)), cc_lines[0])
274        self.assertEqual(
275            '%s %s\0%s\0%s\0%s' % (args[1], self.fred, self.leb, rick, stefan),
276            cc_lines[1])
277
278        expected = '''
279This is a test of how the cover
280letter
281works
282
283some notes
284about some things
285from the first commit
286
287Changes in v4:
288- Multi
289  line
290  change
291- Some changes
292- Some notes for the cover letter
293
294Simon Glass (2):
295  pci: Correct cast for sandbox
296  fdt: Correct cast for sandbox in fdtdec_setup_mem_size_base()
297
298 cmd/pci.c                   | 3 ++-
299 fs/fat/fat.c                | 1 +
300 lib/efi_loader/efi_memory.c | 1 +
301 lib/fdtdec.c                | 3 ++-
302 4 files changed, 6 insertions(+), 2 deletions(-)
303
304--\x20
3052.7.4
306
307'''
308        lines = open(cover_fname, encoding='utf-8').read().splitlines()
309        self.assertEqual(
310            'Subject: [RFC PATCH some-branch v3 0/2] test: A test patch series',
311            lines[3])
312        self.assertEqual(expected.splitlines(), lines[7:])
313
314        for i, fname in enumerate(args):
315            lines = open(fname, encoding='utf-8').read().splitlines()
316            subject = [line for line in lines if line.startswith('Subject')]
317            self.assertEqual('Subject: [RFC %d/%d]' % (i + 1, count),
318                             subject[0][:18])
319
320            # Check that we got our commit notes
321            start = 0
322            expected = ''
323
324            if i == 0:
325                start = 17
326                expected = '''---
327Some notes about
328the first commit
329
330(no changes since v2)
331
332Changes in v2:
333- second revision change'''
334            elif i == 1:
335                start = 17
336                expected = '''---
337
338Changes in v4:
339- Multi
340  line
341  change
342- Some changes
343
344Changes in v2:
345- Changes only for this commit'''
346
347            if expected:
348                expected = expected.splitlines()
349                self.assertEqual(expected, lines[start:(start+len(expected))])
350
351    def make_commit_with_file(self, subject, body, fname, text):
352        """Create a file and add it to the git repo with a new commit
353
354        Args:
355            subject (str): Subject for the commit
356            body (str): Body text of the commit
357            fname (str): Filename of file to create
358            text (str): Text to put into the file
359        """
360        path = os.path.join(self.gitdir, fname)
361        tools.write_file(path, text, binary=False)
362        index = self.repo.index
363        index.add(fname)
364        # pylint doesn't seem to find this
365        # pylint: disable=E1101
366        author = pygit2.Signature('Test user', 'test@email.com')
367        committer = author
368        tree = index.write_tree()
369        message = subject + '\n' + body
370        self.repo.create_commit('HEAD', author, committer, message, tree,
371                                [self.repo.head.target])
372
373    def make_git_tree(self):
374        """Make a simple git tree suitable for testing
375
376        It has three branches:
377            'base' has two commits: PCI, main
378            'first' has base as upstream and two more commits: I2C, SPI
379            'second' has base as upstream and three more: video, serial, bootm
380
381        Returns:
382            pygit2.Repository: repository
383        """
384        repo = pygit2.init_repository(self.gitdir)
385        self.repo = repo
386        new_tree = repo.TreeBuilder().write()
387
388        # pylint doesn't seem to find this
389        # pylint: disable=E1101
390        author = pygit2.Signature('Test user', 'test@email.com')
391        committer = author
392        _ = repo.create_commit('HEAD', author, committer, 'Created master',
393                               new_tree, [])
394
395        self.make_commit_with_file('Initial commit', '''
396Add a README
397
398''', 'README', '''This is the README file
399describing this project
400in very little detail''')
401
402        self.make_commit_with_file('pci: PCI implementation', '''
403Here is a basic PCI implementation
404
405''', 'pci.c', '''This is a file
406it has some contents
407and some more things''')
408        self.make_commit_with_file('main: Main program', '''
409Hello here is the second commit.
410''', 'main.c', '''This is the main file
411there is very little here
412but we can always add more later
413if we want to
414
415Series-to: u-boot
416Series-cc: Barry Crump <bcrump@whataroa.nz>
417''')
418        base_target = repo.revparse_single('HEAD')
419        self.make_commit_with_file('i2c: I2C things', '''
420This has some stuff to do with I2C
421''', 'i2c.c', '''And this is the file contents
422with some I2C-related things in it''')
423        self.make_commit_with_file('spi: SPI fixes', '''
424SPI needs some fixes
425and here they are
426
427Signed-off-by: %s
428
429Series-to: u-boot
430Commit-notes:
431title of the series
432This is the cover letter for the series
433with various details
434END
435''' % self.leb, 'spi.c', '''Some fixes for SPI in this
436file to make SPI work
437better than before''')
438        first_target = repo.revparse_single('HEAD')
439
440        target = repo.revparse_single('HEAD~2')
441        # pylint doesn't seem to find this
442        # pylint: disable=E1101
443        repo.reset(target.oid, pygit2.GIT_CHECKOUT_FORCE)
444        self.make_commit_with_file('video: Some video improvements', '''
445Fix up the video so that
446it looks more purple. Purple is
447a very nice colour.
448''', 'video.c', '''More purple here
449Purple and purple
450Even more purple
451Could not be any more purple''')
452        self.make_commit_with_file('serial: Add a serial driver', '''
453Here is the serial driver
454for my chip.
455
456Cover-letter:
457Series for my board
458This series implements support
459for my glorious board.
460END
461Series-links: 183237
462''', 'serial.c', '''The code for the
463serial driver is here''')
464        self.make_commit_with_file('bootm: Make it boot', '''
465This makes my board boot
466with a fix to the bootm
467command
468''', 'bootm.c', '''Fix up the bootm
469command to make the code as
470complicated as possible''')
471        second_target = repo.revparse_single('HEAD')
472
473        repo.branches.local.create('first', first_target)
474        repo.config.set_multivar('branch.first.remote', '', '.')
475        repo.config.set_multivar('branch.first.merge', '', 'refs/heads/base')
476
477        repo.branches.local.create('second', second_target)
478        repo.config.set_multivar('branch.second.remote', '', '.')
479        repo.config.set_multivar('branch.second.merge', '', 'refs/heads/base')
480
481        repo.branches.local.create('base', base_target)
482        return repo
483
484    def test_branch(self):
485        """Test creating patches from a branch"""
486        repo = self.make_git_tree()
487        target = repo.lookup_reference('refs/heads/first')
488        # pylint doesn't seem to find this
489        # pylint: disable=E1101
490        self.repo.checkout(target, strategy=pygit2.GIT_CHECKOUT_FORCE)
491        control.setup()
492        orig_dir = os.getcwd()
493        try:
494            os.chdir(self.gitdir)
495
496            # Check that it can detect the current branch
497            self.assertEqual(2, gitutil.count_commits_to_branch(None))
498            col = terminal.Color()
499            with capture_sys_output() as _:
500                _, cover_fname, patch_files = control.prepare_patches(
501                    col, branch=None, count=-1, start=0, end=0,
502                    ignore_binary=False, signoff=True)
503            self.assertIsNone(cover_fname)
504            self.assertEqual(2, len(patch_files))
505
506            # Check that it can detect a different branch
507            self.assertEqual(3, gitutil.count_commits_to_branch('second'))
508            with capture_sys_output() as _:
509                _, cover_fname, patch_files = control.prepare_patches(
510                    col, branch='second', count=-1, start=0, end=0,
511                    ignore_binary=False, signoff=True)
512            self.assertIsNotNone(cover_fname)
513            self.assertEqual(3, len(patch_files))
514
515            # Check that it can skip patches at the end
516            with capture_sys_output() as _:
517                _, cover_fname, patch_files = control.prepare_patches(
518                    col, branch='second', count=-1, start=0, end=1,
519                    ignore_binary=False, signoff=True)
520            self.assertIsNotNone(cover_fname)
521            self.assertEqual(2, len(patch_files))
522        finally:
523            os.chdir(orig_dir)
524
525    def test_custom_get_maintainer_script(self):
526        """Validate that a custom get_maintainer script gets used."""
527        self.make_git_tree()
528        with directory_excursion(self.gitdir):
529            # Setup git.
530            os.environ['GIT_CONFIG_GLOBAL'] = '/dev/null'
531            os.environ['GIT_CONFIG_SYSTEM'] = '/dev/null'
532            tools.run('git', 'config', 'user.name', 'Dummy')
533            tools.run('git', 'config', 'user.email', 'dumdum@dummy.com')
534            tools.run('git', 'branch', 'upstream')
535            tools.run('git', 'branch', '--set-upstream-to=upstream')
536            tools.run('git', 'add', '.')
537            tools.run('git', 'commit', '-m', 'new commit')
538
539            # Setup patman configuration.
540            with open('.patman', 'w', buffering=1) as f:
541                f.write('[settings]\n'
542                        'get_maintainer_script: dummy-script.sh\n'
543                        'check_patch: False\n')
544            with open('dummy-script.sh', 'w', buffering=1) as f:
545                f.write('#!/usr/bin/env python\n'
546                        'print("hello@there.com")\n')
547            os.chmod('dummy-script.sh', 0x555)
548
549            # Finally, do the test
550            with capture_sys_output():
551                output = tools.run(PATMAN_DIR / 'patman', '--dry-run')
552                # Assert the email address is part of the dry-run
553                # output.
554                self.assertIn('hello@there.com', output)
555
556    def test_tags(self):
557        """Test collection of tags in a patchstream"""
558        text = '''This is a patch
559
560Signed-off-by: Terminator
561Reviewed-by: %s
562Reviewed-by: %s
563Tested-by: %s
564''' % (self.joe, self.mary, self.leb)
565        pstrm = PatchStream.process_text(text)
566        self.assertEqual(pstrm.commit.rtags, {
567            'Reviewed-by': {self.joe, self.mary},
568            'Tested-by': {self.leb}})
569
570    def test_invalid_tag(self):
571        """Test invalid tag in a patchstream"""
572        text = '''This is a patch
573
574Serie-version: 2
575'''
576        with self.assertRaises(ValueError) as exc:
577            pstrm = PatchStream.process_text(text)
578        self.assertEqual("Line 3: Invalid tag = 'Serie-version: 2'",
579                         str(exc.exception))
580
581    def test_missing_end(self):
582        """Test a missing END tag"""
583        text = '''This is a patch
584
585Cover-letter:
586This is the title
587missing END after this line
588Signed-off-by: Fred
589'''
590        pstrm = PatchStream.process_text(text)
591        self.assertEqual(["Missing 'END' in section 'cover'"],
592                         pstrm.commit.warn)
593
594    def test_missing_blank_line(self):
595        """Test a missing blank line after a tag"""
596        text = '''This is a patch
597
598Series-changes: 2
599- First line of changes
600- Missing blank line after this line
601Signed-off-by: Fred
602'''
603        pstrm = PatchStream.process_text(text)
604        self.assertEqual(["Missing 'blank line' in section 'Series-changes'"],
605                         pstrm.commit.warn)
606
607    def test_invalid_commit_tag(self):
608        """Test an invalid Commit-xxx tag"""
609        text = '''This is a patch
610
611Commit-fred: testing
612'''
613        pstrm = PatchStream.process_text(text)
614        self.assertEqual(["Line 3: Ignoring Commit-fred"], pstrm.commit.warn)
615
616    def test_self_test(self):
617        """Test a tested by tag by this user"""
618        test_line = 'Tested-by: %s@napier.com' % os.getenv('USER')
619        text = '''This is a patch
620
621%s
622''' % test_line
623        pstrm = PatchStream.process_text(text)
624        self.assertEqual(["Ignoring '%s'" % test_line], pstrm.commit.warn)
625
626    def test_space_before_tab(self):
627        """Test a space before a tab"""
628        text = '''This is a patch
629
630+ \tSomething
631'''
632        pstrm = PatchStream.process_text(text)
633        self.assertEqual(["Line 3/0 has space before tab"], pstrm.commit.warn)
634
635    def test_lines_after_test(self):
636        """Test detecting lines after TEST= line"""
637        text = '''This is a patch
638
639TEST=sometest
640more lines
641here
642'''
643        pstrm = PatchStream.process_text(text)
644        self.assertEqual(["Found 2 lines after TEST="], pstrm.commit.warn)
645
646    def test_blank_line_at_end(self):
647        """Test detecting a blank line at the end of a file"""
648        text = '''This is a patch
649
650diff --git a/lib/fdtdec.c b/lib/fdtdec.c
651index c072e54..942244f 100644
652--- a/lib/fdtdec.c
653+++ b/lib/fdtdec.c
654@@ -1200,7 +1200,8 @@ int fdtdec_setup_mem_size_base(void)
655 	}
656
657 	gd->ram_size = (phys_size_t)(res.end - res.start + 1);
658-	debug("%s: Initial DRAM size %llx\n", __func__, (u64)gd->ram_size);
659+	debug("%s: Initial DRAM size %llx\n", __func__,
660+	      (unsigned long long)gd->ram_size);
661+
662diff --git a/lib/efi_loader/efi_memory.c b/lib/efi_loader/efi_memory.c
663
664--
6652.7.4
666
667 '''
668        pstrm = PatchStream.process_text(text)
669        self.assertEqual(
670            ["Found possible blank line(s) at end of file 'lib/fdtdec.c'"],
671            pstrm.commit.warn)
672
673    def test_no_upstream(self):
674        """Test CountCommitsToBranch when there is no upstream"""
675        repo = self.make_git_tree()
676        target = repo.lookup_reference('refs/heads/base')
677        # pylint doesn't seem to find this
678        # pylint: disable=E1101
679        self.repo.checkout(target, strategy=pygit2.GIT_CHECKOUT_FORCE)
680
681        # Check that it can detect the current branch
682        orig_dir = os.getcwd()
683        try:
684            os.chdir(self.gitdir)
685            with self.assertRaises(ValueError) as exc:
686                gitutil.count_commits_to_branch(None)
687            self.assertIn(
688                "Failed to determine upstream: fatal: no upstream configured for branch 'base'",
689                str(exc.exception))
690        finally:
691            os.chdir(orig_dir)
692
693    @staticmethod
694    def _fake_patchwork(url, subpath):
695        """Fake Patchwork server for the function below
696
697        This handles accessing a series, providing a list consisting of a
698        single patch
699
700        Args:
701            url (str): URL of patchwork server
702            subpath (str): URL subpath to use
703        """
704        re_series = re.match(r'series/(\d*)/$', subpath)
705        if re_series:
706            series_num = re_series.group(1)
707            if series_num == '1234':
708                return {'patches': [
709                    {'id': '1', 'name': 'Some patch'}]}
710        raise ValueError('Fake Patchwork does not understand: %s' % subpath)
711
712    def test_status_mismatch(self):
713        """Test Patchwork patches not matching the series"""
714        series = Series()
715
716        with capture_sys_output() as (_, err):
717            status.collect_patches(series, 1234, None, self._fake_patchwork)
718        self.assertIn('Warning: Patchwork reports 1 patches, series has 0',
719                      err.getvalue())
720
721    def test_status_read_patch(self):
722        """Test handling a single patch in Patchwork"""
723        series = Series()
724        series.commits = [Commit('abcd')]
725
726        patches = status.collect_patches(series, 1234, None,
727                                         self._fake_patchwork)
728        self.assertEqual(1, len(patches))
729        patch = patches[0]
730        self.assertEqual('1', patch.id)
731        self.assertEqual('Some patch', patch.raw_subject)
732
733    def test_parse_subject(self):
734        """Test parsing of the patch subject"""
735        patch = status.Patch('1')
736
737        # Simple patch not in a series
738        patch.parse_subject('Testing')
739        self.assertEqual('Testing', patch.raw_subject)
740        self.assertEqual('Testing', patch.subject)
741        self.assertEqual(1, patch.seq)
742        self.assertEqual(1, patch.count)
743        self.assertEqual(None, patch.prefix)
744        self.assertEqual(None, patch.version)
745
746        # First patch in a series
747        patch.parse_subject('[1/2] Testing')
748        self.assertEqual('[1/2] Testing', patch.raw_subject)
749        self.assertEqual('Testing', patch.subject)
750        self.assertEqual(1, patch.seq)
751        self.assertEqual(2, patch.count)
752        self.assertEqual(None, patch.prefix)
753        self.assertEqual(None, patch.version)
754
755        # Second patch in a series
756        patch.parse_subject('[2/2] Testing')
757        self.assertEqual('Testing', patch.subject)
758        self.assertEqual(2, patch.seq)
759        self.assertEqual(2, patch.count)
760        self.assertEqual(None, patch.prefix)
761        self.assertEqual(None, patch.version)
762
763        # RFC patch
764        patch.parse_subject('[RFC,3/7] Testing')
765        self.assertEqual('Testing', patch.subject)
766        self.assertEqual(3, patch.seq)
767        self.assertEqual(7, patch.count)
768        self.assertEqual('RFC', patch.prefix)
769        self.assertEqual(None, patch.version)
770
771        # Version patch
772        patch.parse_subject('[v2,3/7] Testing')
773        self.assertEqual('Testing', patch.subject)
774        self.assertEqual(3, patch.seq)
775        self.assertEqual(7, patch.count)
776        self.assertEqual(None, patch.prefix)
777        self.assertEqual('v2', patch.version)
778
779        # All fields
780        patch.parse_subject('[RESEND,v2,3/7] Testing')
781        self.assertEqual('Testing', patch.subject)
782        self.assertEqual(3, patch.seq)
783        self.assertEqual(7, patch.count)
784        self.assertEqual('RESEND', patch.prefix)
785        self.assertEqual('v2', patch.version)
786
787        # RFC only
788        patch.parse_subject('[RESEND] Testing')
789        self.assertEqual('Testing', patch.subject)
790        self.assertEqual(1, patch.seq)
791        self.assertEqual(1, patch.count)
792        self.assertEqual('RESEND', patch.prefix)
793        self.assertEqual(None, patch.version)
794
795    def test_compare_series(self):
796        """Test operation of compare_with_series()"""
797        commit1 = Commit('abcd')
798        commit1.subject = 'Subject 1'
799        commit2 = Commit('ef12')
800        commit2.subject = 'Subject 2'
801        commit3 = Commit('3456')
802        commit3.subject = 'Subject 2'
803
804        patch1 = status.Patch('1')
805        patch1.subject = 'Subject 1'
806        patch2 = status.Patch('2')
807        patch2.subject = 'Subject 2'
808        patch3 = status.Patch('3')
809        patch3.subject = 'Subject 2'
810
811        series = Series()
812        series.commits = [commit1]
813        patches = [patch1]
814        patch_for_commit, commit_for_patch, warnings = (
815            status.compare_with_series(series, patches))
816        self.assertEqual(1, len(patch_for_commit))
817        self.assertEqual(patch1, patch_for_commit[0])
818        self.assertEqual(1, len(commit_for_patch))
819        self.assertEqual(commit1, commit_for_patch[0])
820
821        series.commits = [commit1]
822        patches = [patch1, patch2]
823        patch_for_commit, commit_for_patch, warnings = (
824            status.compare_with_series(series, patches))
825        self.assertEqual(1, len(patch_for_commit))
826        self.assertEqual(patch1, patch_for_commit[0])
827        self.assertEqual(1, len(commit_for_patch))
828        self.assertEqual(commit1, commit_for_patch[0])
829        self.assertEqual(["Cannot find commit for patch 2 ('Subject 2')"],
830                         warnings)
831
832        series.commits = [commit1, commit2]
833        patches = [patch1]
834        patch_for_commit, commit_for_patch, warnings = (
835            status.compare_with_series(series, patches))
836        self.assertEqual(1, len(patch_for_commit))
837        self.assertEqual(patch1, patch_for_commit[0])
838        self.assertEqual(1, len(commit_for_patch))
839        self.assertEqual(commit1, commit_for_patch[0])
840        self.assertEqual(["Cannot find patch for commit 2 ('Subject 2')"],
841                         warnings)
842
843        series.commits = [commit1, commit2, commit3]
844        patches = [patch1, patch2]
845        patch_for_commit, commit_for_patch, warnings = (
846            status.compare_with_series(series, patches))
847        self.assertEqual(2, len(patch_for_commit))
848        self.assertEqual(patch1, patch_for_commit[0])
849        self.assertEqual(patch2, patch_for_commit[1])
850        self.assertEqual(1, len(commit_for_patch))
851        self.assertEqual(commit1, commit_for_patch[0])
852        self.assertEqual(["Cannot find patch for commit 3 ('Subject 2')",
853                          "Multiple commits match patch 2 ('Subject 2'):\n"
854                          '   Subject 2\n   Subject 2'],
855                         warnings)
856
857        series.commits = [commit1, commit2]
858        patches = [patch1, patch2, patch3]
859        patch_for_commit, commit_for_patch, warnings = (
860            status.compare_with_series(series, patches))
861        self.assertEqual(1, len(patch_for_commit))
862        self.assertEqual(patch1, patch_for_commit[0])
863        self.assertEqual(2, len(commit_for_patch))
864        self.assertEqual(commit1, commit_for_patch[0])
865        self.assertEqual(["Multiple patches match commit 2 ('Subject 2'):\n"
866                          '   Subject 2\n   Subject 2',
867                          "Cannot find commit for patch 3 ('Subject 2')"],
868                         warnings)
869
870    def _fake_patchwork2(self, url, subpath):
871        """Fake Patchwork server for the function below
872
873        This handles accessing series, patches and comments, providing the data
874        in self.patches to the caller
875
876        Args:
877            url (str): URL of patchwork server
878            subpath (str): URL subpath to use
879        """
880        re_series = re.match(r'series/(\d*)/$', subpath)
881        re_patch = re.match(r'patches/(\d*)/$', subpath)
882        re_comments = re.match(r'patches/(\d*)/comments/$', subpath)
883        if re_series:
884            series_num = re_series.group(1)
885            if series_num == '1234':
886                return {'patches': self.patches}
887        elif re_patch:
888            patch_num = int(re_patch.group(1))
889            patch = self.patches[patch_num - 1]
890            return patch
891        elif re_comments:
892            patch_num = int(re_comments.group(1))
893            patch = self.patches[patch_num - 1]
894            return patch.comments
895        raise ValueError('Fake Patchwork does not understand: %s' % subpath)
896
897    def test_find_new_responses(self):
898        """Test operation of find_new_responses()"""
899        commit1 = Commit('abcd')
900        commit1.subject = 'Subject 1'
901        commit2 = Commit('ef12')
902        commit2.subject = 'Subject 2'
903
904        patch1 = status.Patch('1')
905        patch1.parse_subject('[1/2] Subject 1')
906        patch1.name = patch1.raw_subject
907        patch1.content = 'This is my patch content'
908        comment1a = {'content': 'Reviewed-by: %s\n' % self.joe}
909
910        patch1.comments = [comment1a]
911
912        patch2 = status.Patch('2')
913        patch2.parse_subject('[2/2] Subject 2')
914        patch2.name = patch2.raw_subject
915        patch2.content = 'Some other patch content'
916        comment2a = {
917            'content': 'Reviewed-by: %s\nTested-by: %s\n' %
918                       (self.mary, self.leb)}
919        comment2b = {'content': 'Reviewed-by: %s' % self.fred}
920        patch2.comments = [comment2a, comment2b]
921
922        # This test works by setting up commits and patch for use by the fake
923        # Rest API function _fake_patchwork2(). It calls various functions in
924        # the status module after setting up tags in the commits, checking that
925        # things behaves as expected
926        self.commits = [commit1, commit2]
927        self.patches = [patch1, patch2]
928        count = 2
929        new_rtag_list = [None] * count
930        review_list = [None, None]
931
932        # Check that the tags are picked up on the first patch
933        status.find_new_responses(new_rtag_list, review_list, 0, commit1,
934                                  patch1, None, self._fake_patchwork2)
935        self.assertEqual(new_rtag_list[0], {'Reviewed-by': {self.joe}})
936
937        # Now the second patch
938        status.find_new_responses(new_rtag_list, review_list, 1, commit2,
939                                  patch2, None, self._fake_patchwork2)
940        self.assertEqual(new_rtag_list[1], {
941            'Reviewed-by': {self.mary, self.fred},
942            'Tested-by': {self.leb}})
943
944        # Now add some tags to the commit, which means they should not appear as
945        # 'new' tags when scanning comments
946        new_rtag_list = [None] * count
947        commit1.rtags = {'Reviewed-by': {self.joe}}
948        status.find_new_responses(new_rtag_list, review_list, 0, commit1,
949                                  patch1, None, self._fake_patchwork2)
950        self.assertEqual(new_rtag_list[0], {})
951
952        # For the second commit, add Ed and Fred, so only Mary should be left
953        commit2.rtags = {
954            'Tested-by': {self.leb},
955            'Reviewed-by': {self.fred}}
956        status.find_new_responses(new_rtag_list, review_list, 1, commit2,
957                                  patch2, None, self._fake_patchwork2)
958        self.assertEqual(new_rtag_list[1], {'Reviewed-by': {self.mary}})
959
960        # Check that the output patches expectations:
961        #   1 Subject 1
962        #     Reviewed-by: Joe Bloggs <joe@napierwallies.co.nz>
963        #   2 Subject 2
964        #     Tested-by: Lord Edmund Blackaddër <weasel@blackadder.org>
965        #     Reviewed-by: Fred Bloggs <f.bloggs@napier.net>
966        #   + Reviewed-by: Mary Bloggs <mary@napierwallies.co.nz>
967        # 1 new response available in patchwork
968
969        series = Series()
970        series.commits = [commit1, commit2]
971        terminal.set_print_test_mode()
972        status.check_patchwork_status(series, '1234', None, None, False, False,
973                                      None, self._fake_patchwork2)
974        lines = iter(terminal.get_print_test_lines())
975        col = terminal.Color()
976        self.assertEqual(terminal.PrintLine('  1 Subject 1', col.BLUE),
977                         next(lines))
978        self.assertEqual(
979            terminal.PrintLine('    Reviewed-by: ', col.GREEN, newline=False,
980                               bright=False),
981            next(lines))
982        self.assertEqual(terminal.PrintLine(self.joe, col.WHITE, bright=False),
983                         next(lines))
984
985        self.assertEqual(terminal.PrintLine('  2 Subject 2', col.BLUE),
986                         next(lines))
987        self.assertEqual(
988            terminal.PrintLine('    Reviewed-by: ', col.GREEN, newline=False,
989                               bright=False),
990            next(lines))
991        self.assertEqual(terminal.PrintLine(self.fred, col.WHITE, bright=False),
992                         next(lines))
993        self.assertEqual(
994            terminal.PrintLine('    Tested-by: ', col.GREEN, newline=False,
995                               bright=False),
996            next(lines))
997        self.assertEqual(terminal.PrintLine(self.leb, col.WHITE, bright=False),
998                         next(lines))
999        self.assertEqual(
1000            terminal.PrintLine('  + Reviewed-by: ', col.GREEN, newline=False),
1001            next(lines))
1002        self.assertEqual(terminal.PrintLine(self.mary, col.WHITE),
1003                         next(lines))
1004        self.assertEqual(terminal.PrintLine(
1005            '1 new response available in patchwork (use -d to write them to a new branch)',
1006            None), next(lines))
1007
1008    def _fake_patchwork3(self, url, subpath):
1009        """Fake Patchwork server for the function below
1010
1011        This handles accessing series, patches and comments, providing the data
1012        in self.patches to the caller
1013
1014        Args:
1015            url (str): URL of patchwork server
1016            subpath (str): URL subpath to use
1017        """
1018        re_series = re.match(r'series/(\d*)/$', subpath)
1019        re_patch = re.match(r'patches/(\d*)/$', subpath)
1020        re_comments = re.match(r'patches/(\d*)/comments/$', subpath)
1021        if re_series:
1022            series_num = re_series.group(1)
1023            if series_num == '1234':
1024                return {'patches': self.patches}
1025        elif re_patch:
1026            patch_num = int(re_patch.group(1))
1027            patch = self.patches[patch_num - 1]
1028            return patch
1029        elif re_comments:
1030            patch_num = int(re_comments.group(1))
1031            patch = self.patches[patch_num - 1]
1032            return patch.comments
1033        raise ValueError('Fake Patchwork does not understand: %s' % subpath)
1034
1035    def test_create_branch(self):
1036        """Test operation of create_branch()"""
1037        repo = self.make_git_tree()
1038        branch = 'first'
1039        dest_branch = 'first2'
1040        count = 2
1041        gitdir = os.path.join(self.gitdir, '.git')
1042
1043        # Set up the test git tree. We use branch 'first' which has two commits
1044        # in it
1045        series = patchstream.get_metadata_for_list(branch, gitdir, count)
1046        self.assertEqual(2, len(series.commits))
1047
1048        patch1 = status.Patch('1')
1049        patch1.parse_subject('[1/2] %s' % series.commits[0].subject)
1050        patch1.name = patch1.raw_subject
1051        patch1.content = 'This is my patch content'
1052        comment1a = {'content': 'Reviewed-by: %s\n' % self.joe}
1053
1054        patch1.comments = [comment1a]
1055
1056        patch2 = status.Patch('2')
1057        patch2.parse_subject('[2/2] %s' % series.commits[1].subject)
1058        patch2.name = patch2.raw_subject
1059        patch2.content = 'Some other patch content'
1060        comment2a = {
1061            'content': 'Reviewed-by: %s\nTested-by: %s\n' %
1062                       (self.mary, self.leb)}
1063        comment2b = {
1064            'content': 'Reviewed-by: %s' % self.fred}
1065        patch2.comments = [comment2a, comment2b]
1066
1067        # This test works by setting up patches for use by the fake Rest API
1068        # function _fake_patchwork3(). The fake patch comments above should
1069        # result in new review tags that are collected and added to the commits
1070        # created in the destination branch.
1071        self.patches = [patch1, patch2]
1072        count = 2
1073
1074        # Expected output:
1075        #   1 i2c: I2C things
1076        #   + Reviewed-by: Joe Bloggs <joe@napierwallies.co.nz>
1077        #   2 spi: SPI fixes
1078        #   + Reviewed-by: Fred Bloggs <f.bloggs@napier.net>
1079        #   + Reviewed-by: Mary Bloggs <mary@napierwallies.co.nz>
1080        #   + Tested-by: Lord Edmund Blackaddër <weasel@blackadder.org>
1081        # 4 new responses available in patchwork
1082        # 4 responses added from patchwork into new branch 'first2'
1083        # <unittest.result.TestResult run=8 errors=0 failures=0>
1084
1085        terminal.set_print_test_mode()
1086        status.check_patchwork_status(series, '1234', branch, dest_branch,
1087                                      False, False, None, self._fake_patchwork3,
1088                                      repo)
1089        lines = terminal.get_print_test_lines()
1090        self.assertEqual(12, len(lines))
1091        self.assertEqual(
1092            "4 responses added from patchwork into new branch 'first2'",
1093            lines[11].text)
1094
1095        # Check that the destination branch has the new tags
1096        new_series = patchstream.get_metadata_for_list(dest_branch, gitdir,
1097                                                       count)
1098        self.assertEqual(
1099            {'Reviewed-by': {self.joe}},
1100            new_series.commits[0].rtags)
1101        self.assertEqual(
1102            {'Tested-by': {self.leb},
1103             'Reviewed-by': {self.fred, self.mary}},
1104            new_series.commits[1].rtags)
1105
1106        # Now check the actual test of the first commit message. We expect to
1107        # see the new tags immediately below the old ones.
1108        stdout = patchstream.get_list(dest_branch, count=count, git_dir=gitdir)
1109        lines = iter([line.strip() for line in stdout.splitlines()
1110                      if '-by:' in line])
1111
1112        # First patch should have the review tag
1113        self.assertEqual('Reviewed-by: %s' % self.joe, next(lines))
1114
1115        # Second patch should have the sign-off then the tested-by and two
1116        # reviewed-by tags
1117        self.assertEqual('Signed-off-by: %s' % self.leb, next(lines))
1118        self.assertEqual('Reviewed-by: %s' % self.fred, next(lines))
1119        self.assertEqual('Reviewed-by: %s' % self.mary, next(lines))
1120        self.assertEqual('Tested-by: %s' % self.leb, next(lines))
1121
1122    def test_parse_snippets(self):
1123        """Test parsing of review snippets"""
1124        text = '''Hi Fred,
1125
1126This is a comment from someone.
1127
1128Something else
1129
1130On some recent date, Fred wrote:
1131> This is why I wrote the patch
1132> so here it is
1133
1134Now a comment about the commit message
1135A little more to say
1136
1137Even more
1138
1139> diff --git a/file.c b/file.c
1140> Some more code
1141> Code line 2
1142> Code line 3
1143> Code line 4
1144> Code line 5
1145> Code line 6
1146> Code line 7
1147> Code line 8
1148> Code line 9
1149
1150And another comment
1151
1152> @@ -153,8 +143,13 @@ def check_patch(fname, show_types=False):
1153>  further down on the file
1154>  and more code
1155> +Addition here
1156> +Another addition here
1157>  codey
1158>  more codey
1159
1160and another thing in same file
1161
1162> @@ -253,8 +243,13 @@
1163>  with no function context
1164
1165one more thing
1166
1167> diff --git a/tools/patman/main.py b/tools/patman/main.py
1168> +line of code
1169now a very long comment in a different file
1170line2
1171line3
1172line4
1173line5
1174line6
1175line7
1176line8
1177'''
1178        pstrm = PatchStream.process_text(text, True)
1179        self.assertEqual([], pstrm.commit.warn)
1180
1181        # We expect to the filename and up to 5 lines of code context before
1182        # each comment. The 'On xxx wrote:' bit should be removed.
1183        self.assertEqual(
1184            [['Hi Fred,',
1185              'This is a comment from someone.',
1186              'Something else'],
1187             ['> This is why I wrote the patch',
1188              '> so here it is',
1189              'Now a comment about the commit message',
1190              'A little more to say', 'Even more'],
1191             ['> File: file.c', '> Code line 5', '> Code line 6',
1192              '> Code line 7', '> Code line 8', '> Code line 9',
1193              'And another comment'],
1194             ['> File: file.c',
1195              '> Line: 153 / 143: def check_patch(fname, show_types=False):',
1196              '>  and more code', '> +Addition here', '> +Another addition here',
1197              '>  codey', '>  more codey', 'and another thing in same file'],
1198             ['> File: file.c', '> Line: 253 / 243',
1199              '>  with no function context', 'one more thing'],
1200             ['> File: tools/patman/main.py', '> +line of code',
1201              'now a very long comment in a different file',
1202              'line2', 'line3', 'line4', 'line5', 'line6', 'line7', 'line8']],
1203            pstrm.snippets)
1204
1205    def test_review_snippets(self):
1206        """Test showing of review snippets"""
1207        def _to_submitter(who):
1208            m_who = re.match('(.*) <(.*)>', who)
1209            return {
1210                'name': m_who.group(1),
1211                'email': m_who.group(2)
1212                }
1213
1214        commit1 = Commit('abcd')
1215        commit1.subject = 'Subject 1'
1216        commit2 = Commit('ef12')
1217        commit2.subject = 'Subject 2'
1218
1219        patch1 = status.Patch('1')
1220        patch1.parse_subject('[1/2] Subject 1')
1221        patch1.name = patch1.raw_subject
1222        patch1.content = 'This is my patch content'
1223        comment1a = {'submitter': _to_submitter(self.joe),
1224                     'content': '''Hi Fred,
1225
1226On some date Fred wrote:
1227
1228> diff --git a/file.c b/file.c
1229> Some code
1230> and more code
1231
1232Here is my comment above the above...
1233
1234
1235Reviewed-by: %s
1236''' % self.joe}
1237
1238        patch1.comments = [comment1a]
1239
1240        patch2 = status.Patch('2')
1241        patch2.parse_subject('[2/2] Subject 2')
1242        patch2.name = patch2.raw_subject
1243        patch2.content = 'Some other patch content'
1244        comment2a = {
1245            'content': 'Reviewed-by: %s\nTested-by: %s\n' %
1246                       (self.mary, self.leb)}
1247        comment2b = {'submitter': _to_submitter(self.fred),
1248                     'content': '''Hi Fred,
1249
1250On some date Fred wrote:
1251
1252> diff --git a/tools/patman/commit.py b/tools/patman/commit.py
1253> @@ -41,6 +41,9 @@ class Commit:
1254>          self.rtags = collections.defaultdict(set)
1255>          self.warn = []
1256>
1257> +    def __str__(self):
1258> +        return self.subject
1259> +
1260>      def add_change(self, version, info):
1261>          """Add a new change line to the change list for a version.
1262>
1263A comment
1264
1265Reviewed-by: %s
1266''' % self.fred}
1267        patch2.comments = [comment2a, comment2b]
1268
1269        # This test works by setting up commits and patch for use by the fake
1270        # Rest API function _fake_patchwork2(). It calls various functions in
1271        # the status module after setting up tags in the commits, checking that
1272        # things behaves as expected
1273        self.commits = [commit1, commit2]
1274        self.patches = [patch1, patch2]
1275
1276        # Check that the output patches expectations:
1277        #   1 Subject 1
1278        #     Reviewed-by: Joe Bloggs <joe@napierwallies.co.nz>
1279        #   2 Subject 2
1280        #     Tested-by: Lord Edmund Blackaddër <weasel@blackadder.org>
1281        #     Reviewed-by: Fred Bloggs <f.bloggs@napier.net>
1282        #   + Reviewed-by: Mary Bloggs <mary@napierwallies.co.nz>
1283        # 1 new response available in patchwork
1284
1285        series = Series()
1286        series.commits = [commit1, commit2]
1287        terminal.set_print_test_mode()
1288        status.check_patchwork_status(series, '1234', None, None, False, True,
1289                                      None, self._fake_patchwork2)
1290        lines = iter(terminal.get_print_test_lines())
1291        col = terminal.Color()
1292        self.assertEqual(terminal.PrintLine('  1 Subject 1', col.BLUE),
1293                         next(lines))
1294        self.assertEqual(
1295            terminal.PrintLine('  + Reviewed-by: ', col.GREEN, newline=False),
1296            next(lines))
1297        self.assertEqual(terminal.PrintLine(self.joe, col.WHITE), next(lines))
1298
1299        self.assertEqual(terminal.PrintLine('Review: %s' % self.joe, col.RED),
1300                         next(lines))
1301        self.assertEqual(terminal.PrintLine('    Hi Fred,', None), next(lines))
1302        self.assertEqual(terminal.PrintLine('', None), next(lines))
1303        self.assertEqual(terminal.PrintLine('    > File: file.c', col.MAGENTA),
1304                         next(lines))
1305        self.assertEqual(terminal.PrintLine('    > Some code', col.MAGENTA),
1306                         next(lines))
1307        self.assertEqual(terminal.PrintLine('    > and more code', col.MAGENTA),
1308                         next(lines))
1309        self.assertEqual(terminal.PrintLine(
1310            '    Here is my comment above the above...', None), next(lines))
1311        self.assertEqual(terminal.PrintLine('', None), next(lines))
1312
1313        self.assertEqual(terminal.PrintLine('  2 Subject 2', col.BLUE),
1314                         next(lines))
1315        self.assertEqual(
1316            terminal.PrintLine('  + Reviewed-by: ', col.GREEN, newline=False),
1317            next(lines))
1318        self.assertEqual(terminal.PrintLine(self.fred, col.WHITE),
1319                         next(lines))
1320        self.assertEqual(
1321            terminal.PrintLine('  + Reviewed-by: ', col.GREEN, newline=False),
1322            next(lines))
1323        self.assertEqual(terminal.PrintLine(self.mary, col.WHITE),
1324                         next(lines))
1325        self.assertEqual(
1326            terminal.PrintLine('  + Tested-by: ', col.GREEN, newline=False),
1327            next(lines))
1328        self.assertEqual(terminal.PrintLine(self.leb, col.WHITE),
1329                         next(lines))
1330
1331        self.assertEqual(terminal.PrintLine('Review: %s' % self.fred, col.RED),
1332                         next(lines))
1333        self.assertEqual(terminal.PrintLine('    Hi Fred,', None), next(lines))
1334        self.assertEqual(terminal.PrintLine('', None), next(lines))
1335        self.assertEqual(terminal.PrintLine(
1336            '    > File: tools/patman/commit.py', col.MAGENTA), next(lines))
1337        self.assertEqual(terminal.PrintLine(
1338            '    > Line: 41 / 41: class Commit:', col.MAGENTA), next(lines))
1339        self.assertEqual(terminal.PrintLine(
1340            '    > +        return self.subject', col.MAGENTA), next(lines))
1341        self.assertEqual(terminal.PrintLine(
1342            '    > +', col.MAGENTA), next(lines))
1343        self.assertEqual(
1344            terminal.PrintLine('    >      def add_change(self, version, info):',
1345                               col.MAGENTA),
1346            next(lines))
1347        self.assertEqual(terminal.PrintLine(
1348            '    >          """Add a new change line to the change list for a version.',
1349            col.MAGENTA), next(lines))
1350        self.assertEqual(terminal.PrintLine(
1351            '    >', col.MAGENTA), next(lines))
1352        self.assertEqual(terminal.PrintLine(
1353            '    A comment', None), next(lines))
1354        self.assertEqual(terminal.PrintLine('', None), next(lines))
1355
1356        self.assertEqual(terminal.PrintLine(
1357            '4 new responses available in patchwork (use -d to write them to a new branch)',
1358            None), next(lines))
1359
1360    def test_insert_tags(self):
1361        """Test inserting of review tags"""
1362        msg = '''first line
1363second line.'''
1364        tags = [
1365            'Reviewed-by: Bin Meng <bmeng.cn@gmail.com>',
1366            'Tested-by: Bin Meng <bmeng.cn@gmail.com>'
1367            ]
1368        signoff = 'Signed-off-by: Simon Glass <sjg@chromium.com>'
1369        tag_str = '\n'.join(tags)
1370
1371        new_msg = patchstream.insert_tags(msg, tags)
1372        self.assertEqual(msg + '\n\n' + tag_str, new_msg)
1373
1374        new_msg = patchstream.insert_tags(msg + '\n', tags)
1375        self.assertEqual(msg + '\n\n' + tag_str, new_msg)
1376
1377        msg += '\n\n' + signoff
1378        new_msg = patchstream.insert_tags(msg, tags)
1379        self.assertEqual(msg + '\n' + tag_str, new_msg)
1380