bin/pick-ui: Add a new maintainer script for picking patches
authorDylan Baker <dylan@pnwbakers.com>
Wed, 16 Oct 2019 18:32:49 +0000 (11:32 -0700)
committerMarge Bot <eric+marge@anholt.net>
Mon, 20 Apr 2020 19:40:55 +0000 (19:40 +0000)
In the long term the goal of this script is to nearly completely
automate the process of picking stable nominations, in a well tested
way.

In the short term the goal is to provide a better, faster UI to interact
with stable nominations.

Reviewed-by: Eric Engestrom <eric@engestrom.ch>
Part-of: <https://gitlab.freedesktop.org/mesa/mesa/-/merge_requests/3608>

bin/pick-ui.py [new file with mode: 0755]
bin/pick/__init__.py [new file with mode: 0644]
bin/pick/core.py [new file with mode: 0644]
bin/pick/core_test.py [new file with mode: 0644]
bin/pick/ui.py [new file with mode: 0644]

diff --git a/bin/pick-ui.py b/bin/pick-ui.py
new file mode 100755 (executable)
index 0000000..3aea771
--- /dev/null
@@ -0,0 +1,33 @@
+#!/usr/bin/env python3
+# Copyright © 2019-2020 Intel Corporation
+
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import asyncio
+
+import urwid
+
+from pick.ui import UI, PALETTE
+
+if __name__ == "__main__":
+    u = UI()
+    evl = urwid.AsyncioEventLoop(loop=asyncio.get_event_loop())
+    loop = urwid.MainLoop(u.render(), PALETTE, event_loop=evl)
+    u.mainloop = loop
+    loop.run()
diff --git a/bin/pick/__init__.py b/bin/pick/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/bin/pick/core.py b/bin/pick/core.py
new file mode 100644 (file)
index 0000000..dab6028
--- /dev/null
@@ -0,0 +1,377 @@
+# Copyright © 2019-2020 Intel Corporation
+
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+"""Core data structures and routines for pick."""
+
+import asyncio
+import enum
+import json
+import pathlib
+import re
+import typing
+
+import attr
+
+if typing.TYPE_CHECKING:
+    from .ui import UI
+
+    import typing_extensions
+
+    class CommitDict(typing_extensions.TypedDict):
+
+        sha: str
+        description: str
+        nominated: bool
+        nomination_type: typing.Optional[int]
+        resolution: typing.Optional[int]
+        master_sha: typing.Optional[str]
+        because_sha: typing.Optional[str]
+
+IS_FIX = re.compile(r'^\s*fixes:\s*([a-f0-9]{6,40})', flags=re.MULTILINE | re.IGNORECASE)
+# FIXME: I dislike the duplication in this regex, but I couldn't get it to work otherwise
+IS_CC = re.compile(r'^\s*cc:\s*["\']?([0-9]{2}\.[0-9])?["\']?\s*["\']?([0-9]{2}\.[0-9])?["\']?\s*\<?mesa-stable',
+                   flags=re.MULTILINE | re.IGNORECASE)
+IS_REVERT = re.compile(r'This reverts commit ([0-9a-f]{40})')
+
+# XXX: hack
+SEM = asyncio.Semaphore(50)
+
+COMMIT_LOCK = asyncio.Lock()
+
+
+class PickUIException(Exception):
+    pass
+
+
+@enum.unique
+class NominationType(enum.Enum):
+
+    CC = 0
+    FIXES = 1
+    REVERT = 2
+
+
+@enum.unique
+class Resolution(enum.Enum):
+
+    UNRESOLVED = 0
+    MERGED = 1
+    DENOMINATED = 2
+    BACKPORTED = 3
+    NOTNEEDED = 4
+
+
+async def commit_state(*, amend: bool = False, message: str = 'Update') -> bool:
+    """Commit the .pick_status.json file."""
+    f = pathlib.Path(__file__).parent.parent.parent / '.pick_status.json'
+    async with COMMIT_LOCK:
+        p = await asyncio.create_subprocess_exec(
+            'git', 'add', f.as_posix(),
+            stdout=asyncio.subprocess.DEVNULL,
+            stderr=asyncio.subprocess.DEVNULL,
+        )
+        v = await p.wait()
+        if v != 0:
+            return False
+
+        if amend:
+            cmd = ['--amend', '--no-edit']
+        else:
+            cmd = ['--message', f'.pick_status.json: {message}']
+        p = await asyncio.create_subprocess_exec(
+            'git', 'commit', *cmd,
+            stdout=asyncio.subprocess.DEVNULL,
+            stderr=asyncio.subprocess.DEVNULL,
+        )
+        v = await p.wait()
+        if v != 0:
+            return False
+    return True
+
+
+@attr.s(slots=True)
+class Commit:
+
+    sha: str = attr.ib()
+    description: str = attr.ib()
+    nominated: bool = attr.ib(False)
+    nomination_type: typing.Optional[NominationType] = attr.ib(None)
+    resolution: Resolution = attr.ib(Resolution.UNRESOLVED)
+    master_sha: typing.Optional[str] = attr.ib(None)
+    because_sha: typing.Optional[str] = attr.ib(None)
+
+    def to_json(self) -> 'CommitDict':
+        d: typing.Dict[str, typing.Any] = attr.asdict(self)
+        if self.nomination_type is not None:
+            d['nomination_type'] = self.nomination_type.value
+        if self.resolution is not None:
+            d['resolution'] = self.resolution.value
+        return typing.cast('CommitDict', d)
+
+    @classmethod
+    def from_json(cls, data: 'CommitDict') -> 'Commit':
+        c = cls(data['sha'], data['description'], data['nominated'], master_sha=data['master_sha'], because_sha=data['because_sha'])
+        if data['nomination_type'] is not None:
+            c.nomination_type = NominationType(data['nomination_type'])
+        if data['resolution'] is not None:
+            c.resolution = Resolution(data['resolution'])
+        return c
+
+    async def apply(self, ui: 'UI') -> typing.Tuple[bool, str]:
+        # FIXME: This isn't really enough if we fail to cherry-pick because the
+        # git tree will still be dirty
+        async with COMMIT_LOCK:
+            p = await asyncio.create_subprocess_exec(
+                'git', 'cherry-pick', '-x', self.sha,
+                stdout=asyncio.subprocess.DEVNULL,
+                stderr=asyncio.subprocess.PIPE,
+            )
+            _, err = await p.communicate()
+
+        if p.returncode != 0:
+            return (False, err.decode())
+
+        self.resolution = Resolution.MERGED
+        await ui.feedback(f'{self.sha} ({self.description}) applied successfully')
+
+        # Append the changes to the .pickstatus.json file
+        ui.save()
+        v = await commit_state(amend=True)
+        return (v, '')
+
+    async def abort_cherry(self, ui: 'UI', err: str) -> None:
+        await ui.feedback(f'{self.sha} ({self.description}) failed to apply\n{err}')
+        async with COMMIT_LOCK:
+            p = await asyncio.create_subprocess_exec(
+                'git', 'cherry-pick', '--abort',
+                stdout=asyncio.subprocess.DEVNULL,
+                stderr=asyncio.subprocess.DEVNULL,
+            )
+            r = await p.wait()
+        await ui.feedback(f'{"Successfully" if r == 0 else "Failed to"} abort cherry-pick.')
+
+    async def denominate(self, ui: 'UI') -> bool:
+        self.resolution = Resolution.DENOMINATED
+        ui.save()
+        v = await commit_state(message=f'Mark {self.sha} as denominated')
+        assert v
+        await ui.feedback(f'{self.sha} ({self.description}) denominated successfully')
+        return True
+
+    async def backport(self, ui: 'UI') -> bool:
+        self.resolution = Resolution.BACKPORTED
+        ui.save()
+        v = await commit_state(message=f'Mark {self.sha} as backported')
+        assert v
+        await ui.feedback(f'{self.sha} ({self.description}) backported successfully')
+        return True
+
+    async def resolve(self, ui: 'UI') -> None:
+        self.resolution = Resolution.MERGED
+        ui.save()
+        v = await commit_state(amend=True)
+        assert v
+        await ui.feedback(f'{self.sha} ({self.description}) committed successfully')
+
+
+async def get_new_commits(sha: str) -> typing.List[typing.Tuple[str, str]]:
+    # Try to get the authoritative upstream master
+    p = await asyncio.create_subprocess_exec(
+        'git', 'for-each-ref', '--format=%(upstream)', 'refs/heads/master',
+        stdout=asyncio.subprocess.PIPE,
+        stderr=asyncio.subprocess.DEVNULL)
+    out, _ = await p.communicate()
+    upstream = out.decode().strip()
+
+    p = await asyncio.create_subprocess_exec(
+        'git', 'log', '--pretty=oneline', f'{sha}..{upstream}',
+        stdout=asyncio.subprocess.PIPE,
+        stderr=asyncio.subprocess.DEVNULL)
+    out, _ = await p.communicate()
+    assert p.returncode == 0, f"git log didn't work: {sha}"
+    return list(split_commit_list(out.decode().strip()))
+
+
+def split_commit_list(commits: str) -> typing.Generator[typing.Tuple[str, str], None, None]:
+    if not commits:
+        return
+    for line in commits.split('\n'):
+        v = tuple(line.split(' ', 1))
+        assert len(v) == 2, 'this is really just for mypy'
+        yield typing.cast(typing.Tuple[str, str], v)
+
+
+async def is_commit_in_branch(sha: str) -> bool:
+    async with SEM:
+        p = await asyncio.create_subprocess_exec(
+            'git', 'merge-base', '--is-ancestor', sha, 'HEAD',
+            stdout=asyncio.subprocess.DEVNULL,
+            stderr=asyncio.subprocess.DEVNULL,
+        )
+        await p.wait()
+    return p.returncode == 0
+
+
+async def full_sha(sha: str) -> str:
+    async with SEM:
+        p = await asyncio.create_subprocess_exec(
+            'git', 'rev-parse', sha,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.DEVNULL,
+        )
+        out, _ = await p.communicate()
+    if p.returncode:
+        raise PickUIException(f'Invalid Sha {sha}')
+    return out.decode().strip()
+
+
+async def resolve_nomination(commit: 'Commit', version: str) -> 'Commit':
+    async with SEM:
+        p = await asyncio.create_subprocess_exec(
+            'git', 'log', '--format=%B', '-1', commit.sha,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.DEVNULL,
+        )
+        _out, _ = await p.communicate()
+        assert p.returncode == 0, f'git log for {commit.sha} failed'
+    out = _out.decode()
+
+    # We give precedence to fixes and cc tags over revert tags.
+    # XXX: not having the walrus operator available makes me sad :=
+    m = IS_FIX.search(out)
+    if m:
+        # We set the nomination_type and because_sha here so that we can later
+        # check to see if this fixes another staged commit.
+        try:
+            commit.because_sha = fixed = await full_sha(m.group(1))
+        except PickUIException:
+            pass
+        else:
+            commit.nomination_type = NominationType.FIXES
+            if await is_commit_in_branch(fixed):
+                commit.nominated = True
+                return commit
+
+    m = IS_CC.search(out)
+    if m:
+        if m.groups() == (None, None) or version in m.groups():
+            commit.nominated = True
+            commit.nomination_type = NominationType.CC
+            return commit
+
+    m = IS_REVERT.search(out)
+    if m:
+        # See comment for IS_FIX path
+        try:
+            commit.because_sha = reverted = await full_sha(m.group(1))
+        except PickUIException:
+            pass
+        else:
+            commit.nomination_type = NominationType.REVERT
+            if await is_commit_in_branch(reverted):
+                commit.nominated = True
+                return commit
+
+    return commit
+
+
+async def resolve_fixes(commits: typing.List['Commit'], previous: typing.List['Commit']) -> None:
+    """Determine if any of the undecided commits fix/revert a staged commit.
+
+    The are still needed if they apply to a commit that is staged for
+    inclusion, but not yet included.
+
+    This must be done in order, because a commit 3 might fix commit 2 which
+    fixes commit 1.
+    """
+    shas: typing.Set[str] = set(c.sha for c in previous if c.nominated)
+    assert None not in shas, 'None in shas'
+
+    for commit in reversed(commits):
+        if not commit.nominated and commit.nomination_type is NominationType.FIXES:
+            commit.nominated = commit.because_sha in shas
+
+        if commit.nominated:
+            shas.add(commit.sha)
+
+    for commit in commits:
+        if (commit.nomination_type is NominationType.REVERT and
+                commit.because_sha in shas):
+            for oldc in reversed(commits):
+                if oldc.sha == commit.because_sha:
+                    # In this case a commit that hasn't yet been applied is
+                    # reverted, we don't want to apply that commit at all
+                    oldc.nominated = False
+                    oldc.resolution = Resolution.DENOMINATED
+                    commit.nominated = False
+                    commit.resolution = Resolution.DENOMINATED
+                    shas.remove(commit.because_sha)
+                    break
+
+
+async def gather_commits(version: str, previous: typing.List['Commit'],
+                         new: typing.List[typing.Tuple[str, str]], cb) -> typing.List['Commit']:
+    # We create an array of the final size up front, then we pass that array
+    # to the "inner" co-routine, which is turned into a list of tasks and
+    # collected by asyncio.gather. We do this to allow the tasks to be
+    # asynchronously gathered, but to also ensure that the commits list remains
+    # in order.
+    m_commits: typing.List[typing.Optional['Commit']] = [None] * len(new)
+    tasks = []
+
+    async def inner(commit: 'Commit', version: str,
+                    commits: typing.List[typing.Optional['Commit']],
+                    index: int, cb) -> None:
+        commits[index] = await resolve_nomination(commit, version)
+        cb()
+
+    for i, (sha, desc) in enumerate(new):
+        tasks.append(asyncio.ensure_future(
+            inner(Commit(sha, desc), version, m_commits, i, cb)))
+
+    await asyncio.gather(*tasks)
+    assert None not in m_commits
+    commits = typing.cast(typing.List[Commit], m_commits)
+
+    await resolve_fixes(commits, previous)
+
+    for commit in commits:
+        if commit.resolution is Resolution.UNRESOLVED and not commit.nominated:
+            commit.resolution = Resolution.NOTNEEDED
+
+    return commits
+
+
+def load() -> typing.List['Commit']:
+    p = pathlib.Path(__file__).parent.parent.parent / '.pick_status.json'
+    if not p.exists():
+        return []
+    with p.open('r') as f:
+        raw = json.load(f)
+        return [Commit.from_json(c) for c in raw]
+
+
+def save(commits: typing.Iterable['Commit']) -> None:
+    p = pathlib.Path(__file__).parent.parent.parent / '.pick_status.json'
+    commits = list(commits)
+    with p.open('wt') as f:
+        json.dump([c.to_json() for c in commits], f, indent=4)
+
+    asyncio.ensure_future(commit_state(message=f'Update to {commits[0].sha}'))
diff --git a/bin/pick/core_test.py b/bin/pick/core_test.py
new file mode 100644 (file)
index 0000000..8ab5317
--- /dev/null
@@ -0,0 +1,470 @@
+# Copyright © 2019-2020 Intel Corporation
+
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+"""Tests for pick's core data structures and routines."""
+
+from unittest import mock
+import textwrap
+import typing
+
+import attr
+import pytest
+
+from . import core
+
+
+class TestCommit:
+
+    @pytest.fixture
+    def unnominated_commit(self) -> 'core.Commit':
+        return core.Commit('abc123', 'sub: A commit', master_sha='45678')
+
+    @pytest.fixture
+    def nominated_commit(self) -> 'core.Commit':
+        return core.Commit('abc123', 'sub: A commit', True,
+                           core.NominationType.CC, core.Resolution.UNRESOLVED)
+
+    class TestToJson:
+
+        def test_not_nominated(self, unnominated_commit: 'core.Commit'):
+            c = unnominated_commit
+            v = c.to_json()
+            assert v == {'sha': 'abc123', 'description': 'sub: A commit', 'nominated': False,
+                         'nomination_type': None, 'resolution': core.Resolution.UNRESOLVED.value,
+                         'master_sha': '45678', 'because_sha': None}
+
+        def test_nominated(self, nominated_commit: 'core.Commit'):
+            c = nominated_commit
+            v = c.to_json()
+            assert v == {'sha': 'abc123',
+                         'description': 'sub: A commit',
+                         'nominated': True,
+                         'nomination_type': core.NominationType.CC.value,
+                         'resolution': core.Resolution.UNRESOLVED.value,
+                         'master_sha': None,
+                         'because_sha': None}
+
+    class TestFromJson:
+
+        def test_not_nominated(self, unnominated_commit: 'core.Commit'):
+            c = unnominated_commit
+            v = c.to_json()
+            c2 = core.Commit.from_json(v)
+            assert c == c2
+
+        def test_nominated(self, nominated_commit: 'core.Commit'):
+            c = nominated_commit
+            v = c.to_json()
+            c2 = core.Commit.from_json(v)
+            assert c == c2
+
+
+class TestRE:
+
+    """Tests for the regular expressions used to identify commits."""
+
+    class TestFixes:
+
+        def test_simple(self):
+            message = textwrap.dedent("""\
+                etnaviv: fix vertex buffer state emission for single stream GPUs
+
+                GPUs with a single supported vertex stream must use the single state
+                address to program the stream.
+
+                Fixes: 3d09bb390a39 (etnaviv: GC7000: State changes for HALTI3..5)
+                Signed-off-by: Lucas Stach <l.stach@pengutronix.de>
+                Reviewed-by: Jonathan Marek <jonathan@marek.ca>
+            """)
+
+            m = core.IS_FIX.search(message)
+            assert m is not None
+            assert m.group(1) == '3d09bb390a39'
+
+    class TestCC:
+
+        def test_single_branch(self):
+            """Tests commit meant for a single branch, ie, 19.1"""
+            message = textwrap.dedent("""\
+                radv: fix DCC fast clear code for intensity formats
+
+                This fixes a rendering issue with DiRT 4 on GFX10. Only GFX10 was
+                affected because intensity formats are different.
+
+                Cc: 19.2 <mesa-stable@lists.freedesktop.org>
+                Closes: https://gitlab.freedesktop.org/mesa/mesa/issues/1923
+                Signed-off-by: Samuel Pitoiset <samuel.pitoiset@gmail.com>
+                Reviewed-by: Bas Nieuwenhuizen <bas@basnieuwenhuizen.nl>
+            """)
+
+            m = core.IS_CC.search(message)
+            assert m is not None
+            assert m.group(1) == '19.2'
+
+        def test_multiple_branches(self):
+            """Tests commit with more than one branch specified"""
+            message = textwrap.dedent("""\
+                radeonsi: enable zerovram for Rocket League
+
+                Fixes corruption on game startup.
+                Closes: https://gitlab.freedesktop.org/mesa/mesa/issues/1888
+
+                Cc: 19.1 19.2 <mesa-stable@lists.freedesktop.org>
+                Reviewed-by: Pierre-Eric Pelloux-Prayer <pierre-eric.pelloux-prayer@amd.com>
+            """)
+
+            m = core.IS_CC.search(message)
+            assert m is not None
+            assert m.group(1) == '19.1'
+            assert m.group(2) == '19.2'
+
+        def test_no_branch(self):
+            """Tests commit with no branch specification"""
+            message = textwrap.dedent("""\
+                anv/android: fix images created with external format support
+
+                This fixes a case where user first creates image and then later binds it
+                with memory created from AHW buffer.
+
+                Cc: <mesa-stable@lists.freedesktop.org>
+                Signed-off-by: Tapani Pälli <tapani.palli@intel.com>
+                Reviewed-by: Lionel Landwerlin <lionel.g.landwerlin@intel.com>
+            """)
+
+            m = core.IS_CC.search(message)
+            assert m is not None
+
+        def test_quotes(self):
+            """Tests commit with quotes around the versions"""
+            message = textwrap.dedent("""\
+                 anv: Always fill out the AUX table even if CCS is disabled
+
+                 Cc: "20.0" mesa-stable@lists.freedesktop.org
+                 Reviewed-by: Kenneth Graunke <kenneth@whitecape.org>
+                 Tested-by: Marge Bot <https://gitlab.freedesktop.org/mesa/mesa/merge_requests/3454>
+                 Part-of: <https://gitlab.freedesktop.org/mesa/mesa/merge_requests/3454>
+            """)
+
+            m = core.IS_CC.search(message)
+            assert m is not None
+            assert m.group(1) == '20.0'
+
+        def test_multiple_quotes(self):
+            """Tests commit with quotes around the versions"""
+            message = textwrap.dedent("""\
+                 anv: Always fill out the AUX table even if CCS is disabled
+
+                 Cc: "20.0" "20.1" mesa-stable@lists.freedesktop.org
+                 Reviewed-by: Kenneth Graunke <kenneth@whitecape.org>
+                 Tested-by: Marge Bot <https://gitlab.freedesktop.org/mesa/mesa/merge_requests/3454>
+                 Part-of: <https://gitlab.freedesktop.org/mesa/mesa/merge_requests/3454>
+            """)
+
+            m = core.IS_CC.search(message)
+            assert m is not None
+            assert m.group(1) == '20.0'
+            assert m.group(2) == '20.1'
+
+        def test_single_quotes(self):
+            """Tests commit with quotes around the versions"""
+            message = textwrap.dedent("""\
+                 anv: Always fill out the AUX table even if CCS is disabled
+
+                 Cc: '20.0' mesa-stable@lists.freedesktop.org
+                 Reviewed-by: Kenneth Graunke <kenneth@whitecape.org>
+                 Tested-by: Marge Bot <https://gitlab.freedesktop.org/mesa/mesa/merge_requests/3454>
+                 Part-of: <https://gitlab.freedesktop.org/mesa/mesa/merge_requests/3454>
+            """)
+
+            m = core.IS_CC.search(message)
+            assert m is not None
+            assert m.group(1) == '20.0'
+
+        def test_multiple_single_quotes(self):
+            """Tests commit with quotes around the versions"""
+            message = textwrap.dedent("""\
+                 anv: Always fill out the AUX table even if CCS is disabled
+
+                 Cc: '20.0' '20.1' mesa-stable@lists.freedesktop.org
+                 Reviewed-by: Kenneth Graunke <kenneth@whitecape.org>
+                 Tested-by: Marge Bot <https://gitlab.freedesktop.org/mesa/mesa/merge_requests/3454>
+                 Part-of: <https://gitlab.freedesktop.org/mesa/mesa/merge_requests/3454>
+            """)
+
+            m = core.IS_CC.search(message)
+            assert m is not None
+            assert m.group(1) == '20.0'
+            assert m.group(2) == '20.1'
+
+    class TestRevert:
+
+        def test_simple(self):
+            message = textwrap.dedent("""\
+                Revert "radv: do not emit PKT3_CONTEXT_CONTROL with AMDGPU 3.6.0+"
+
+                This reverts commit 2ca8629fa9b303e24783b76a7b3b0c2513e32fbd.
+
+                This was initially ported from RadeonSI, but in the meantime it has
+                been reverted because it might hang. Be conservative and re-introduce
+                this packet emission.
+
+                Unfortunately this doesn't fix anything known.
+
+                Cc: 19.2 <mesa-stable@lists.freedesktop.org>
+                Signed-off-by: Samuel Pitoiset <samuel.pitoiset@gmail.com>
+                Reviewed-by: Bas Nieuwenhuizen <bas@basnieuwenhuizen.nl>
+            """)
+
+            m = core.IS_REVERT.search(message)
+            assert m is not None
+            assert m.group(1) == '2ca8629fa9b303e24783b76a7b3b0c2513e32fbd'
+
+
+class TestResolveNomination:
+
+    @attr.s(slots=True)
+    class FakeSubprocess:
+
+        """A fake asyncio.subprocess like classe for use with mock."""
+
+        out: typing.Optional[bytes] = attr.ib(None)
+        returncode: int = attr.ib(0)
+
+        async def mock(self, *_, **__):
+            """A dirtly little helper for mocking."""
+            return self
+
+        async def communicate(self) -> typing.Tuple[bytes, bytes]:
+            assert self.out is not None
+            return self.out, b''
+
+        async def wait(self) -> int:
+            return self.returncode
+
+    @staticmethod
+    async def return_true(*_, **__) -> bool:
+        return True
+
+    @staticmethod
+    async def return_false(*_, **__) -> bool:
+        return False
+
+    @pytest.mark.asyncio
+    async def test_fix_is_nominated(self):
+        s = self.FakeSubprocess(b'Fixes: 3d09bb390a39 (etnaviv: GC7000: State changes for HALTI3..5)')
+        c = core.Commit('abcdef1234567890', 'a commit')
+
+        with mock.patch('bin.pick.core.asyncio.create_subprocess_exec', s.mock):
+            with mock.patch('bin.pick.core.is_commit_in_branch', self.return_true):
+                await core.resolve_nomination(c, '')
+
+        assert c.nominated
+        assert c.nomination_type is core.NominationType.FIXES
+
+    @pytest.mark.asyncio
+    async def test_fix_is_not_nominated(self):
+        s = self.FakeSubprocess(b'Fixes: 3d09bb390a39 (etnaviv: GC7000: State changes for HALTI3..5)')
+        c = core.Commit('abcdef1234567890', 'a commit')
+
+        with mock.patch('bin.pick.core.asyncio.create_subprocess_exec', s.mock):
+            with mock.patch('bin.pick.core.is_commit_in_branch', self.return_false):
+                await core.resolve_nomination(c, '')
+
+        assert not c.nominated
+        assert c.nomination_type is core.NominationType.FIXES
+
+    @pytest.mark.asyncio
+    async def test_cc_is_nominated(self):
+        s = self.FakeSubprocess(b'Cc: 16.2 <mesa-stable@lists.freedesktop.org>')
+        c = core.Commit('abcdef1234567890', 'a commit')
+
+        with mock.patch('bin.pick.core.asyncio.create_subprocess_exec', s.mock):
+            await core.resolve_nomination(c, '16.2')
+
+        assert c.nominated
+        assert c.nomination_type is core.NominationType.CC
+
+    @pytest.mark.asyncio
+    async def test_cc_is_nominated2(self):
+        s = self.FakeSubprocess(b'Cc: mesa-stable@lists.freedesktop.org')
+        c = core.Commit('abcdef1234567890', 'a commit')
+
+        with mock.patch('bin.pick.core.asyncio.create_subprocess_exec', s.mock):
+            await core.resolve_nomination(c, '16.2')
+
+        assert c.nominated
+        assert c.nomination_type is core.NominationType.CC
+
+    @pytest.mark.asyncio
+    async def test_cc_is_not_nominated(self):
+        s = self.FakeSubprocess(b'Cc: 16.2 <mesa-stable@lists.freedesktop.org>')
+        c = core.Commit('abcdef1234567890', 'a commit')
+
+        with mock.patch('bin.pick.core.asyncio.create_subprocess_exec', s.mock):
+            await core.resolve_nomination(c, '16.1')
+
+        assert not c.nominated
+        assert c.nomination_type is None
+
+    @pytest.mark.asyncio
+    async def test_revert_is_nominated(self):
+        s = self.FakeSubprocess(b'This reverts commit 1234567890123456789012345678901234567890.')
+        c = core.Commit('abcdef1234567890', 'a commit')
+
+        with mock.patch('bin.pick.core.asyncio.create_subprocess_exec', s.mock):
+            with mock.patch('bin.pick.core.is_commit_in_branch', self.return_true):
+                await core.resolve_nomination(c, '')
+
+        assert c.nominated
+        assert c.nomination_type is core.NominationType.REVERT
+
+    @pytest.mark.asyncio
+    async def test_revert_is_not_nominated(self):
+        s = self.FakeSubprocess(b'This reverts commit 1234567890123456789012345678901234567890.')
+        c = core.Commit('abcdef1234567890', 'a commit')
+
+        with mock.patch('bin.pick.core.asyncio.create_subprocess_exec', s.mock):
+            with mock.patch('bin.pick.core.is_commit_in_branch', self.return_false):
+                await core.resolve_nomination(c, '')
+
+        assert not c.nominated
+        assert c.nomination_type is core.NominationType.REVERT
+
+    @pytest.mark.asyncio
+    async def test_is_fix_and_cc(self):
+        s = self.FakeSubprocess(
+            b'Fixes: 3d09bb390a39 (etnaviv: GC7000: State changes for HALTI3..5)\n'
+            b'Cc: 16.1 <mesa-stable@lists.freedesktop.org>'
+        )
+        c = core.Commit('abcdef1234567890', 'a commit')
+
+        with mock.patch('bin.pick.core.asyncio.create_subprocess_exec', s.mock):
+            with mock.patch('bin.pick.core.is_commit_in_branch', self.return_true):
+                await core.resolve_nomination(c, '16.1')
+
+        assert c.nominated
+        assert c.nomination_type is core.NominationType.FIXES
+
+    @pytest.mark.asyncio
+    async def test_is_fix_and_revert(self):
+        s = self.FakeSubprocess(
+            b'Fixes: 3d09bb390a39 (etnaviv: GC7000: State changes for HALTI3..5)\n'
+            b'This reverts commit 1234567890123456789012345678901234567890.'
+        )
+        c = core.Commit('abcdef1234567890', 'a commit')
+
+        with mock.patch('bin.pick.core.asyncio.create_subprocess_exec', s.mock):
+            with mock.patch('bin.pick.core.is_commit_in_branch', self.return_true):
+                await core.resolve_nomination(c, '16.1')
+
+        assert c.nominated
+        assert c.nomination_type is core.NominationType.FIXES
+
+    @pytest.mark.asyncio
+    async def test_is_cc_and_revert(self):
+        s = self.FakeSubprocess(
+            b'This reverts commit 1234567890123456789012345678901234567890.\n'
+            b'Cc: 16.1 <mesa-stable@lists.freedesktop.org>'
+        )
+        c = core.Commit('abcdef1234567890', 'a commit')
+
+        with mock.patch('bin.pick.core.asyncio.create_subprocess_exec', s.mock):
+            with mock.patch('bin.pick.core.is_commit_in_branch', self.return_true):
+                await core.resolve_nomination(c, '16.1')
+
+        assert c.nominated
+        assert c.nomination_type is core.NominationType.CC
+
+
+class TestResolveFixes:
+
+    @pytest.mark.asyncio
+    async def test_in_new(self):
+        """Because commit abcd is nominated, so f123 should be as well."""
+        c = [
+            core.Commit('f123', 'desc', nomination_type=core.NominationType.FIXES, because_sha='abcd'),
+            core.Commit('abcd', 'desc', True),
+        ]
+        await core.resolve_fixes(c, [])
+        assert c[1].nominated
+
+    @pytest.mark.asyncio
+    async def test_not_in_new(self):
+        """Because commit abcd is not nominated, commit f123 shouldn't be either."""
+        c = [
+            core.Commit('f123', 'desc', nomination_type=core.NominationType.FIXES, because_sha='abcd'),
+            core.Commit('abcd', 'desc'),
+        ]
+        await core.resolve_fixes(c, [])
+        assert not c[0].nominated
+
+    @pytest.mark.asyncio
+    async def test_in_previous(self):
+        """Because commit abcd is nominated, so f123 should be as well."""
+        p = [
+            core.Commit('abcd', 'desc', True),
+        ]
+        c = [
+            core.Commit('f123', 'desc', nomination_type=core.NominationType.FIXES, because_sha='abcd'),
+        ]
+        await core.resolve_fixes(c, p)
+        assert c[0].nominated
+
+    @pytest.mark.asyncio
+    async def test_not_in_previous(self):
+        """Because commit abcd is not nominated, commit f123 shouldn't be either."""
+        p = [
+            core.Commit('abcd', 'desc'),
+        ]
+        c = [
+            core.Commit('f123', 'desc', nomination_type=core.NominationType.FIXES, because_sha='abcd'),
+        ]
+        await core.resolve_fixes(c, p)
+        assert not c[0].nominated
+
+
+class TestIsCommitInBranch:
+
+    @pytest.mark.asyncio
+    async def test_no(self):
+        # Hopefully this is never true?
+        value = await core.is_commit_in_branch('ffffffffffffffffffffffffffffff')
+        assert not value
+
+    @pytest.mark.asyncio
+    async def test_yes(self):
+        # This commit is from 2000, it better always be in the branch
+        value = await core.is_commit_in_branch('88f3b89a2cb77766d2009b9868c44e03abe2dbb2')
+        assert value
+
+
+class TestFullSha:
+
+    @pytest.mark.asyncio
+    async def test_basic(self):
+        # This commit is from 2000, it better always be in the branch
+        value = await core.full_sha('88f3b89a2cb777')
+        assert value
+
+    @pytest.mark.asyncio
+    async def test_invalid(self):
+        # This commit is from 2000, it better always be in the branch
+        with pytest.raises(core.PickUIException):
+            await core.full_sha('fffffffffffffffffffffffffffffffffff')
diff --git a/bin/pick/ui.py b/bin/pick/ui.py
new file mode 100644 (file)
index 0000000..a6f2fc7
--- /dev/null
@@ -0,0 +1,262 @@
+# Copyright © 2019-2020 Intel Corporation
+
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+"""Urwid UI for pick script."""
+
+import asyncio
+import itertools
+import textwrap
+import typing
+
+import attr
+import urwid
+
+from . import core
+
+if typing.TYPE_CHECKING:
+    WidgetType = typing.TypeVar('WidgetType', bound=urwid.Widget)
+
+PALETTE = [
+    ('a', 'black', 'light gray'),
+    ('b', 'black', 'dark red'),
+    ('bg', 'black', 'dark blue'),
+    ('reversed', 'standout', ''),
+]
+
+
+class RootWidget(urwid.Frame):
+
+    def __init__(self, *args, ui: 'UI' = None, **kwargs):
+        super().__init__(*args, **kwargs)
+        assert ui is not None
+        self.ui = ui
+
+    def keypress(self, size: int, key: str) -> typing.Optional[str]:
+        if key == 'q':
+            raise urwid.ExitMainLoop()
+        elif key == 'u':
+            asyncio.ensure_future(self.ui.update())
+        elif key == 'a':
+            self.ui.add()
+        else:
+            return super().keypress(size, key)
+        return None
+
+
+class CommitWidget(urwid.Text):
+
+    # urwid.Text is normally not interactable, this is required to tell urwid
+    # to use our keypress method
+    _selectable = True
+
+    def __init__(self, ui: 'UI', commit: 'core.Commit'):
+        super().__init__(commit.description)
+        self.ui = ui
+        self.commit = commit
+
+    async def apply(self) -> None:
+        async with self.ui.git_lock:
+            result, err = await self.commit.apply(self.ui)
+            if not result:
+                self.ui.chp_failed(self, err)
+            else:
+                self.ui.remove_commit(self)
+
+    async def denominate(self) -> None:
+        async with self.ui.git_lock:
+            await self.commit.denominate(self.ui)
+            self.ui.remove_commit(self)
+
+    async def backport(self) -> None:
+        async with self.ui.git_lock:
+            await self.commit.backport(self.ui)
+            self.ui.remove_commit(self)
+
+    def keypress(self, size: int, key: str) -> typing.Optional[str]:
+        if key == 'c':
+            asyncio.ensure_future(self.apply())
+        elif key == 'd':
+            asyncio.ensure_future(self.denominate())
+        elif key == 'b':
+            asyncio.ensure_future(self.backport())
+        else:
+            return key
+        return None
+
+
+@attr.s(slots=True)
+class UI:
+
+    """Main management object.
+
+    :previous_commits: A list of commits to master since this branch was created
+    :new_commits: Commits added to master since the last time this script was run
+    """
+
+    commit_list: typing.List['urwid.Button'] = attr.ib(factory=lambda: urwid.SimpleFocusListWalker([]), init=False)
+    feedback_box: typing.List['urwid.Text'] = attr.ib(factory=lambda: urwid.SimpleFocusListWalker([]), init=False)
+    header: 'urwid.Text' = attr.ib(factory=lambda: urwid.Text('Mesa Stable Picker', align='center'), init=False)
+    body: 'urwid.Columns' = attr.ib(attr.Factory(lambda s: s._make_body(), True), init=False)
+    footer: 'urwid.Columns' = attr.ib(attr.Factory(lambda s: s._make_footer(), True), init=False)
+    root: RootWidget = attr.ib(attr.Factory(lambda s: s._make_root(), True), init=False)
+    mainloop: urwid.MainLoop = attr.ib(None, init=False)
+
+    previous_commits: typing.List['core.Commit'] = attr.ib(factory=list, init=False)
+    new_commits: typing.List['core.Commit'] = attr.ib(factory=list, init=False)
+    git_lock: asyncio.Lock = attr.ib(factory=asyncio.Lock, init=False)
+
+    def _make_body(self) -> 'urwid.Columns':
+        commits = urwid.ListBox(self.commit_list)
+        feedback = urwid.ListBox(self.feedback_box)
+        return urwid.Columns([commits, feedback])
+
+    def _make_footer(self) -> 'urwid.Columns':
+        body = [
+            urwid.Text('[U]pdate'),
+            urwid.Text('[Q]uit'),
+            urwid.Text('[C]herry Pick'),
+            urwid.Text('[D]enominate'),
+            urwid.Text('[B]ackport'),
+            urwid.Text('[A]pply additional patch')
+        ]
+        return urwid.Columns(body)
+
+    def _make_root(self) -> 'RootWidget':
+        return RootWidget(self.body, self.header, self.footer, 'body', ui=self)
+
+    def render(self) -> 'WidgetType':
+        asyncio.ensure_future(self.update())
+        return self.root
+
+    def load(self) -> None:
+        self.previous_commits = core.load()
+
+    async def update(self) -> None:
+        self.load()
+        with open('VERSION', 'r') as f:
+            version = '.'.join(f.read().split('.')[:2])
+        if self.previous_commits:
+            sha = self.previous_commits[0].sha
+        else:
+            sha = f'{version}-branchpoint'
+
+        new_commits = await core.get_new_commits(sha)
+
+        if new_commits:
+            pb = urwid.ProgressBar('a', 'b', done=len(new_commits))
+            o = self.mainloop.widget
+            self.mainloop.widget = urwid.Overlay(
+                urwid.Filler(urwid.LineBox(pb)), o, 'center', ('relative', 50), 'middle', ('relative', 50))
+            self.new_commits = await core.gather_commits(
+                version, self.previous_commits, new_commits,
+                lambda: pb.set_completion(pb.current + 1))
+            self.mainloop.widget = o
+
+        for commit in reversed(list(itertools.chain(self.new_commits, self.previous_commits))):
+            if commit.nominated and commit.resolution is core.Resolution.UNRESOLVED:
+                b = urwid.AttrMap(CommitWidget(self, commit), None, focus_map='reversed')
+                self.commit_list.append(b)
+        self.save()
+
+    async def feedback(self, text: str) -> None:
+        self.feedback_box.append(urwid.AttrMap(urwid.Text(text), None))
+
+    def remove_commit(self, commit: CommitWidget) -> None:
+        for i, c in enumerate(self.commit_list):
+            if c.base_widget is commit:
+                del self.commit_list[i]
+                break
+
+    def save(self):
+        core.save(itertools.chain(self.new_commits, self.previous_commits))
+
+    def add(self) -> None:
+        """Add an additional commit which isn't nominated."""
+        o = self.mainloop.widget
+
+        def reset_cb(_) -> None:
+            self.mainloop.widget = o
+
+        async def apply_cb(edit: urwid.Edit) -> None:
+            text: str = edit.get_edit_text()
+
+            # In case the text is empty
+            if not text:
+                return
+
+            sha = await core.full_sha(text)
+            for c in reversed(list(itertools.chain(self.new_commits, self.previous_commits))):
+                if c.sha == sha:
+                    commit = c
+                    break
+            else:
+                raise RuntimeError(f"Couldn't find {sha}")
+
+            await commit.apply(self)
+
+        q = urwid.Edit("Commit sha\n")
+        ok_btn = urwid.Button('Ok')
+        urwid.connect_signal(ok_btn, 'click', lambda _: asyncio.ensure_future(apply_cb(q)))
+        urwid.connect_signal(ok_btn, 'click', reset_cb)
+
+        can_btn = urwid.Button('Cancel')
+        urwid.connect_signal(can_btn, 'click', reset_cb)
+
+        cols = urwid.Columns([ok_btn, can_btn])
+        pile = urwid.Pile([q, cols])
+        box = urwid.LineBox(pile)
+
+        self.mainloop.widget = urwid.Overlay(
+            urwid.Filler(box), o, 'center', ('relative', 50), 'middle', ('relative', 50)
+        )
+
+    def chp_failed(self, commit: 'CommitWidget', err: str) -> None:
+        o = self.mainloop.widget
+
+        def reset_cb(_) -> None:
+            self.mainloop.widget = o
+
+        t = urwid.Text(textwrap.dedent(f"""
+            Failed to apply {commit.commit.sha} {commit.commit.description} with the following error:
+
+            {err}
+
+            You can either cancel, or resolve the conflicts, commit the
+            changes and select ok."""))
+
+        can_btn = urwid.Button('Cancel')
+        urwid.connect_signal(can_btn, 'click', reset_cb)
+        urwid.connect_signal(
+            can_btn, 'click', lambda _: asyncio.ensure_future(commit.commit.abort_cherry(self, err)))
+
+        ok_btn = urwid.Button('Ok')
+        urwid.connect_signal(ok_btn, 'click', reset_cb)
+        urwid.connect_signal(
+            ok_btn, 'click', lambda _: asyncio.ensure_future(commit.commit.resolve(self)))
+        urwid.connect_signal(
+            ok_btn, 'click', lambda _: self.remove_commit(commit))
+
+        cols = urwid.Columns([ok_btn, can_btn])
+        pile = urwid.Pile([t, cols])
+        box = urwid.LineBox(pile)
+
+        self.mainloop.widget = urwid.Overlay(
+            urwid.Filler(box), o, 'center', ('relative', 50), 'middle', ('relative', 50)
+        )