diff options
author | Dylan Baker <[email protected]> | 2019-10-16 11:32:49 -0700 |
---|---|---|
committer | Marge Bot <[email protected]> | 2020-04-20 19:40:55 +0000 |
commit | 8b8a99ba567314d5a83633a8ef73a5491976c67c (patch) | |
tree | dddd06866b98b12bc389ff50c96de494c46b6984 | |
parent | 0123b8f63415d3d320929e6112da2be2d837b262 (diff) |
bin/pick-ui: Add a new maintainer script for picking patches
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 <[email protected]>
Part-of: <https://gitlab.freedesktop.org/mesa/mesa/-/merge_requests/3608>
-rwxr-xr-x | bin/pick-ui.py | 33 | ||||
-rw-r--r-- | bin/pick/__init__.py | 0 | ||||
-rw-r--r-- | bin/pick/core.py | 377 | ||||
-rw-r--r-- | bin/pick/core_test.py | 470 | ||||
-rw-r--r-- | bin/pick/ui.py | 262 |
5 files changed, 1142 insertions, 0 deletions
diff --git a/bin/pick-ui.py b/bin/pick-ui.py new file mode 100755 index 00000000000..3aea7719246 --- /dev/null +++ b/bin/pick-ui.py @@ -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 index 00000000000..e69de29bb2d --- /dev/null +++ b/bin/pick/__init__.py diff --git a/bin/pick/core.py b/bin/pick/core.py new file mode 100644 index 00000000000..dab6028a4b0 --- /dev/null +++ b/bin/pick/core.py @@ -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 + + +class NominationType(enum.Enum): + + CC = 0 + FIXES = 1 + REVERT = 2 + + +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 + + [email protected](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 index 00000000000..8ab53172277 --- /dev/null +++ b/bin/pick/core_test.py @@ -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 <[email protected]> + Reviewed-by: Jonathan Marek <[email protected]> + """) + + 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 <[email protected]> + Closes: https://gitlab.freedesktop.org/mesa/mesa/issues/1923 + Signed-off-by: Samuel Pitoiset <[email protected]> + Reviewed-by: Bas Nieuwenhuizen <[email protected]> + """) + + 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 <[email protected]> + Reviewed-by: Pierre-Eric Pelloux-Prayer <[email protected]> + """) + + 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: <[email protected]> + Signed-off-by: Tapani Pälli <[email protected]> + Reviewed-by: Lionel Landwerlin <[email protected]> + """) + + 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" [email protected] + Reviewed-by: Kenneth Graunke <[email protected]> + 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" [email protected] + Reviewed-by: Kenneth Graunke <[email protected]> + 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' [email protected] + Reviewed-by: Kenneth Graunke <[email protected]> + 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' [email protected] + Reviewed-by: Kenneth Graunke <[email protected]> + 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 <[email protected]> + Signed-off-by: Samuel Pitoiset <[email protected]> + Reviewed-by: Bas Nieuwenhuizen <[email protected]> + """) + + 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 <[email protected]>') + 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: [email protected]') + 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 <[email protected]>') + 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 <[email protected]>' + ) + 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 <[email protected]>' + ) + 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 index 00000000000..a6f2fc7006e --- /dev/null +++ b/bin/pick/ui.py @@ -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 + + [email protected](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) + ) |