Origin: commit, revision id: jelmer@jelmer.uk-20190105141803-20mryd2vapgoin4x
Author: Jelmer Vernooĳ <jelmer@jelmer.uk>
Last-Update: 2019-01-05
Applied-Upstream: no
X-Bzr-Revision-Id: jelmer@jelmer.uk-20190105141803-20mryd2vapgoin4x

=== modified file 'breezy/plugins/propose/__init__.py'
--- old/breezy/plugins/propose/__init__.py	2018-12-11 14:12:47 +0000
+++ new/breezy/plugins/propose/__init__.py	2019-01-02 19:21:51 +0000
@@ -25,3 +25,6 @@
 plugin_cmds.register_lazy("cmd_find_merge_proposal", ['find-proposal'], __name__ + ".cmds")
 plugin_cmds.register_lazy("cmd_github_login", ["gh-login"], __name__ + ".cmds")
 plugin_cmds.register_lazy("cmd_gitlab_login", ["gl-login"], __name__ + ".cmds")
+plugin_cmds.register_lazy(
+    "cmd_my_merge_proposals", ["my-proposals"],
+    __name__ + ".cmds")

=== modified file 'breezy/plugins/propose/cmds.py'
--- old/breezy/plugins/propose/cmds.py	2019-01-01 22:31:13 +0000
+++ new/breezy/plugins/propose/cmds.py	2019-01-05 14:18:03 +0000
@@ -223,8 +223,8 @@
         else:
             target = _mod_branch.Branch.open(submit_branch)
         hoster = _mod_propose.get_hoster(branch)
-        mp = hoster.get_proposal(branch, target)
-        self.outf.write(gettext('Merge proposal: %s\n') % mp.url)
+        for mp in hoster.iter_proposals(branch, target):
+            self.outf.write(gettext('Merge proposal: %s\n') % mp.url)
 
 
 class cmd_github_login(Command):
@@ -300,3 +300,30 @@
             gl = Gitlab(url=url, private_token=private_token)
             gl.auth()
         store_gitlab_token(name=name, url=url, private_token=private_token)
+
+
+class cmd_my_merge_proposals(Command):
+    __doc__ = """List all merge proposals owned by the logged-in user.
+
+    """
+
+    hidden = True
+
+    takes_options = [
+        RegistryOption.from_kwargs(
+            'status',
+            title='Proposal Status',
+            help='Only include proposals with specified status.',
+            value_switches=True,
+            enum_switch=True,
+            all='All merge proposals',
+            open='Open merge proposals',
+            merged='Merged merge proposals',
+            closed='Closed merge proposals')]
+
+    def run(self, status='open'):
+        from .propose import hosters
+        for name, hoster_cls in hosters.items():
+            for instance in hoster_cls.iter_instances():
+                for mp in instance.iter_my_proposals(status=status):
+                    self.outf.write('%s\n' % mp.url)

=== modified file 'breezy/plugins/propose/github.py'
--- old/breezy/plugins/propose/github.py	2019-01-01 22:31:13 +0000
+++ new/breezy/plugins/propose/github.py	2019-01-04 00:40:55 +0000
@@ -25,7 +25,6 @@
     MergeProposal,
     MergeProposalBuilder,
     MergeProposalExists,
-    NoMergeProposal,
     PrerequisiteBranchUnsupported,
     UnsupportedHoster,
     )
@@ -105,6 +104,15 @@
     def url(self):
         return self._pr.html_url
 
+    def _branch_from_part(self, part):
+        return github_url_to_bzr_url(part.repo.html_url, part.ref)
+
+    def get_source_branch_url(self):
+        return self._branch_from_part(self._pr.head)
+
+    def get_target_branch_url(self):
+        return self._branch_from_part(self._pr.base)
+
     def get_description(self):
         return self._pr.body
 
@@ -136,6 +144,8 @@
 
 class GitHub(Hoster):
 
+    name = 'github'
+
     supports_merge_proposal_labels = True
 
     def __repr__(self):
@@ -205,20 +215,30 @@
     def get_proposer(self, source_branch, target_branch):
         return GitHubMergeProposalBuilder(self.gh, source_branch, target_branch)
 
-    def get_proposal(self, source_branch, target_branch):
+    def iter_proposals(self, source_branch, target_branch, status='open'):
         (source_owner, source_repo_name, source_branch_name) = (
             parse_github_url(source_branch))
         (target_owner, target_repo_name, target_branch_name) = (
             parse_github_url(target_branch))
-        target_repo = self.gh.get_repo("%s/%s" % (target_owner, target_repo_name))
-        for pull in target_repo.get_pulls(head=target_branch_name):
+        target_repo = self.gh.get_repo(
+            "%s/%s" % (target_owner, target_repo_name))
+        state = {
+            'open': 'open',
+            'merged': 'closed',
+            'closed': 'closed',
+            'all': 'all'}
+        for pull in target_repo.get_pulls(
+                head=target_branch_name,
+                state=state[status]):
+            if (status == 'closed' and pull.merged or
+                    status == 'merged' and not pull.merged):
+                continue
             if pull.head.ref != source_branch_name:
                 continue
             if (pull.head.repo.owner.login != source_owner or
                     pull.head.repo.name != source_repo_name):
                 continue
-            return GitHubMergeProposal(pull)
-        raise NoMergeProposal()
+            yield GitHubMergeProposal(pull)
 
     def hosts(self, branch):
         try:
@@ -236,6 +256,24 @@
             raise UnsupportedHoster(branch)
         return cls()
 
+    @classmethod
+    def iter_instances(cls):
+        yield cls()
+
+    def iter_my_proposals(self, status='open'):
+        query = ['is:pr']
+        if status == 'open':
+            query.append('is:open')
+        elif status == 'closed':
+            # Note that we don't use is:closed here, since that also includes
+            # merged pull requests.
+            query.append('is:unmerged')
+        elif status == 'merged':
+            query.append('is:merged')
+        query.append('author:%s' % self.gh.get_user().login)
+        for issue in self.gh.search_issues(query=' '.join(query)):
+            yield GitHubMergeProposal(issue.as_pull_request())
+
 
 class GitHubMergeProposalBuilder(MergeProposalBuilder):
 

=== modified file 'breezy/plugins/propose/gitlabs.py'
--- old/breezy/plugins/propose/gitlabs.py	2019-01-01 22:31:13 +0000
+++ new/breezy/plugins/propose/gitlabs.py	2019-01-04 00:40:55 +0000
@@ -33,12 +33,18 @@
     MergeProposal,
     MergeProposalBuilder,
     MergeProposalExists,
-    NoMergeProposal,
     NoSuchProject,
     PrerequisiteBranchUnsupported,
     UnsupportedHoster,
     )
 
+def mp_status_to_status(status):
+    return {
+        'all': 'all',
+        'open': 'opened',
+        'merged': 'merged',
+        'closed': 'closed'}[status]
+
 
 class NotGitLabUrl(errors.BzrError):
 
@@ -83,26 +89,30 @@
         config.write(f)
 
 
+def iter_tokens():
+    import configparser
+    from gitlab.config import _DEFAULT_FILES
+    config = configparser.ConfigParser()
+    config.read(_DEFAULT_FILES + [default_config_path()])
+    for name, section in config.items():
+        yield name, section
+
+
 def connect_gitlab(host):
-    from gitlab import Gitlab
+    from gitlab import Gitlab, GitlabGetError
     auth = AuthenticationConfig()
 
     url = 'https://%s' % host
     credentials = auth.get_credentials('https', host)
     if credentials is None:
-        import gitlab
-        import configparser
-        from gitlab.config import _DEFAULT_FILES
-        config = configparser.ConfigParser()
-        config.read(_DEFAULT_FILES + [default_config_path()])
-        for name, section in config.items():
+        for name, section in iter_tokens():
             if section.get('url') == url:
                 credentials = section
                 break
         else:
             try:
                 return Gitlab(url)
-            except gitlab.GitlabGetError:
+            except GitlabGetError:
                 raise GitLabLoginMissing()
     else:
         credentials['url'] = url
@@ -138,8 +148,20 @@
     def set_description(self, description):
         self._mr.description = description
 
+    def _branch_url_from_project(self, project_id, branch_name):
+        project = self._mr.manager.gitlab.projects.get(project_id)
+        return gitlab_url_to_bzr_url(project.http_url_to_repo, branch_name)
+
+    def get_source_branch_url(self):
+        return self._branch_url_from_project(
+            self._mr.source_project_id, self._mr.source_branch)
+
+    def get_target_branch_url(self):
+        return self._branch_url_from_project(
+            self._mr.target_project_id, self._mr.target_branch)
+
     def is_merged(self):
-        return (self._mr.attributes['state'] == 'merged')
+        return (self._mr.state == 'merged')
 
 
 def gitlab_url_to_bzr_url(url, name):
@@ -164,7 +186,7 @@
         (host, project_name, branch_name) = parse_gitlab_url(branch)
         project = self.gl.projects.get(project_name)
         return gitlab_url_to_bzr_url(
-            project.attributes['ssh_url_to_repo'], branch_name)
+            project.ssh_url_to_repo, branch_name)
 
     def publish_derived(self, local_branch, base_branch, name, project=None,
                         owner=None, revision_id=None, overwrite=False,
@@ -190,7 +212,7 @@
                 target_project = base_project.forks.create({})
             else:
                 raise
-        remote_repo_url = git_url_to_bzr_url(target_project.attributes['ssh_url_to_repo'])
+        remote_repo_url = git_url_to_bzr_url(target_project.ssh_url_to_repo)
         remote_dir = controldir.ControlDir.open(remote_repo_url)
         try:
             push_result = remote_dir.push_branch(
@@ -203,7 +225,7 @@
                 local_branch, revision_id=revision_id, overwrite=overwrite,
                 name=name, lossy=True)
         public_url = gitlab_url_to_bzr_url(
-            target_project.attributes['http_url_to_repo'], name)
+            target_project.http_url_to_repo, name)
         return push_result.target_branch, public_url
 
     def get_derived_branch(self, base_branch, name, project=None, owner=None):
@@ -228,12 +250,13 @@
                 raise errors.NotBranchError('%s/%s/%s' % (self.gl.url, owner, project))
             raise
         return _mod_branch.Branch.open(gitlab_url_to_bzr_url(
-            target_project.attributes['ssh_url_to_repo'], name))
+            target_project.ssh_url_to_repo, name))
 
     def get_proposer(self, source_branch, target_branch):
         return GitlabMergeProposalBuilder(self.gl, source_branch, target_branch)
 
-    def get_proposal(self, source_branch, target_branch):
+    def iter_proposals(self, source_branch, target_branch, status):
+        import gitlab
         (source_host, source_project_name, source_branch_name) = (
             parse_gitlab_url(source_branch))
         (target_host, target_project_name, target_branch_name) = (
@@ -243,19 +266,18 @@
         self.gl.auth()
         source_project = self.gl.projects.get(source_project_name)
         target_project = self.gl.projects.get(target_project_name)
+        state = mp_status_to_status(status)
         try:
-            for mr in target_project.mergerequests.list(state='all'):
-                attrs = mr.attributes
-                if (attrs['source_project_id'] != source_project.id or
-                        attrs['source_branch'] != source_branch_name or
-                        attrs['target_project_id'] != target_project.id or
-                        attrs['target_branch'] != target_branch_name):
+            for mr in target_project.mergerequests.list(state=state):
+                if (mr.source_project_id != source_project.id or
+                        mr.source_branch != source_branch_name or
+                        mr.target_project_id != target_project.id or
+                        mr.target_branch != target_branch_name):
                     continue
-                return GitLabMergeProposal(mr)
+                yield GitLabMergeProposal(mr)
         except gitlab.GitlabListError as e:
             if e.response_code == 403:
-                raise PermissionDenied(e.error_message)
-        raise NoMergeProposal()
+                raise errors.PermissionDenied(e.error_message)
 
     def hosts(self, branch):
         try:
@@ -287,6 +309,22 @@
                 raise
         return cls(gl)
 
+    @classmethod
+    def iter_instances(cls):
+        from gitlab import Gitlab
+        for name, credentials in iter_tokens():
+            if 'url' not in credentials:
+                continue
+            gl = Gitlab(**credentials)
+            yield cls(gl)
+
+    def iter_my_proposals(self, status='open'):
+        state = mp_status_to_status(status)
+        self.gl.auth()
+        for mp in self.gl.mergerequests.list(
+                owner=self.gl.user.username, state=state):
+            yield GitLabMergeProposal(mp)
+
 
 class GitlabMergeProposalBuilder(MergeProposalBuilder):
 
@@ -343,8 +381,23 @@
             merge_request = source_project.mergerequests.create(kwargs)
         except gitlab.GitlabCreateError as e:
             if e.response_code == 403:
-                raise PermissionDenied(e.error_message)
+                raise errors.PermissionDenied(e.error_message)
             if e.response_code == 409:
                 raise MergeProposalExists(self.source_branch.user_url)
             raise
         return GitLabMergeProposal(merge_request)
+
+
+def register_gitlab_instance(shortname, url):
+    """Register a gitlab instance.
+
+    :param shortname: Short name (e.g. "gitlab")
+    :param url: URL to the gitlab instance
+    """
+    from breezy.bugtracker import (
+        tracker_registry,
+        ProjectIntegerBugTracker,
+        )
+    tracker_registry.register(
+        shortname, ProjectIntegerBugTracker(
+            shortname, url + '/{project}/issues/{id}'))

=== modified file 'breezy/plugins/propose/launchpad.py'
--- old/breezy/plugins/propose/launchpad.py	2019-01-01 22:31:13 +0000
+++ new/breezy/plugins/propose/launchpad.py	2019-01-03 02:35:46 +0000
@@ -27,7 +27,6 @@
     MergeProposal,
     MergeProposalBuilder,
     MergeProposalExists,
-    NoMergeProposal,
     UnsupportedHoster,
     )
 
@@ -38,6 +37,7 @@
     hooks,
     urlutils,
     )
+from ...git.refs import ref_to_branch_name
 from ...lazy_import import lazy_import
 lazy_import(globals(), """
 from breezy.plugins.launchpad import (
@@ -50,16 +50,20 @@
 
 # TODO(jelmer): Make selection of launchpad staging a configuration option.
 
-MERGE_PROPOSAL_STATUSES = [
-    'Work in progress',
-    'Needs review',
-    'Approved',
-    'Rejected',
-    'Merged',
-    'Code failed to merge',
-    'Queued',
-    'Superseded',
-    ]
+def status_to_lp_mp_statuses(status):
+    statuses = []
+    if status in ('open', 'all'):
+        statuses.extend([
+            'Work in progress',
+            'Needs review',
+            'Approved',
+            'Code failed to merge',
+            'Queued'])
+    if status in ('closed', 'all'):
+        statuses.extend(['Rejected', 'Superseded'])
+    if status in ('merged', 'all'):
+        statuses.append('Merged')
+    return statuses
 
 
 def plausible_launchpad_url(url):
@@ -103,6 +107,26 @@
     def __init__(self, mp):
         self._mp = mp
 
+    def get_source_branch_url(self):
+        if self._mp.source_branch:
+            return self._mp.source_branch.bzr_identity
+        else:
+            branch_name = ref_to_branch_name(
+                self._mp.source_git_path.encode('utf-8'))
+            return urlutils.join_segment_parameters(
+                self._mp.source_git_repository.git_identity,
+                {"branch": branch_name})
+
+    def get_target_branch_url(self):
+        if self._mp.target_branch:
+            return self._mp.target_branch.bzr_identity
+        else:
+            branch_name = ref_to_branch_name(
+                self._mp.target_git_path.encode('utf-8'))
+            return urlutils.join_segment_parameters(
+                self._mp.target_git_repository.git_identity,
+                {"branch": branch_name})
+
     @property
     def url(self):
         return lp_api.canonical_url(self._mp)
@@ -114,6 +138,8 @@
 class Launchpad(Hoster):
     """The Launchpad hosting service."""
 
+    name = 'launchpad'
+
     # https://bugs.launchpad.net/launchpad/+bug/397676
     supports_merge_proposal_labels = False
 
@@ -142,7 +168,8 @@
         url, params = urlutils.split_segment_parameters(branch.user_url)
         (scheme, user, password, host, port, path) = urlutils.parse_url(
             url)
-        repo_lp = self.launchpad.git_repositories.getByPath(path=path.strip('/'))
+        repo_lp = self.launchpad.git_repositories.getByPath(
+            path=path.strip('/'))
         try:
             ref_path = params['ref']
         except KeyError:
@@ -155,13 +182,15 @@
         return (repo_lp, ref_lp)
 
     def _get_lp_bzr_branch_from_branch(self, branch):
-        return self.launchpad.branches.getByUrl(url=urlutils.unescape(branch.user_url))
+        return self.launchpad.branches.getByUrl(
+            url=urlutils.unescape(branch.user_url))
 
     def _get_derived_git_path(self, base_path, owner, project):
         base_repo = self.launchpad.git_repositories.getByPath(path=base_path)
         if project is None:
             project = '/'.join(base_repo.unique_name.split('/')[1:])
-        # TODO(jelmer): Surely there is a better way of creating one of these URLs?
+        # TODO(jelmer): Surely there is a better way of creating one of these
+        # URLs?
         return "~%s/%s" % (owner, project)
 
     def _publish_git(self, local_branch, base_path, name, owner, project=None,
@@ -177,27 +206,31 @@
         if dir_to is None:
             try:
                 br_to = local_branch.create_clone_on_transport(
-                    to_transport, revision_id=revision_id, name=name,
-                    stacked_on=main_branch.user_url)
+                    to_transport, revision_id=revision_id, name=name)
             except errors.NoRoundtrippingSupport:
                 br_to = local_branch.create_clone_on_transport(
                     to_transport, revision_id=revision_id, name=name,
-                    stacked_on=main_branch.user_url, lossy=True)
+                    lossy=True)
         else:
             try:
-                dir_to = dir_to.push_branch(local_branch, revision_id, overwrite=overwrite, name=name)
+                dir_to = dir_to.push_branch(
+                    local_branch, revision_id, overwrite=overwrite, name=name)
             except errors.NoRoundtrippingSupport:
                 if not allow_lossy:
                     raise
-                dir_to = dir_to.push_branch(local_branch, revision_id, overwrite=overwrite, name=name, lossy=True)
+                dir_to = dir_to.push_branch(
+                    local_branch, revision_id, overwrite=overwrite, name=name,
+                    lossy=True)
             br_to = dir_to.target_branch
-        return br_to, ("https://git.launchpad.net/%s/+ref/%s" % (to_path, name))
+        return br_to, (
+            "https://git.launchpad.net/%s/+ref/%s" % (to_path, name))
 
     def _get_derived_bzr_path(self, base_branch, name, owner, project):
         if project is None:
             base_branch_lp = self._get_lp_bzr_branch_from_branch(base_branch)
             project = '/'.join(base_branch_lp.unique_name.split('/')[1:-1])
-        # TODO(jelmer): Surely there is a better way of creating one of these URLs?
+        # TODO(jelmer): Surely there is a better way of creating one of these
+        # URLs?
         return "~%s/%s/%s" % (owner, project, name)
 
     def get_push_url(self, branch):
@@ -211,8 +244,9 @@
         else:
             raise AssertionError
 
-    def _publish_bzr(self, local_branch, base_branch, name, owner, project=None,
-                     revision_id=None, overwrite=False, allow_lossy=True):
+    def _publish_bzr(self, local_branch, base_branch, name, owner,
+                     project=None, revision_id=None, overwrite=False,
+                     allow_lossy=True):
         to_path = self._get_derived_bzr_path(base_branch, name, owner, project)
         to_transport = get_transport("lp:" + to_path)
         try:
@@ -222,9 +256,11 @@
             dir_to = None
 
         if dir_to is None:
-            br_to = local_branch.create_clone_on_transport(to_transport, revision_id=revision_id)
+            br_to = local_branch.create_clone_on_transport(
+                to_transport, revision_id=revision_id)
         else:
-            br_to = dir_to.push_branch(local_branch, revision_id, overwrite=overwrite).target_branch
+            br_to = dir_to.push_branch(
+                local_branch, revision_id, overwrite=overwrite).target_branch
         return br_to, ("https://code.launchpad.net/" + to_path)
 
     def _split_url(self, url):
@@ -239,8 +275,9 @@
             raise ValueError("unknown host %s" % host)
         return (vcs, user, password, path, params)
 
-    def publish_derived(self, local_branch, base_branch, name, project=None, owner=None,
-                        revision_id=None, overwrite=False, allow_lossy=True):
+    def publish_derived(self, local_branch, base_branch, name, project=None,
+                        owner=None, revision_id=None, overwrite=False,
+                        allow_lossy=True):
         """Publish a branch to the site, derived from base_branch.
 
         :param base_branch: branch to derive the new branch from
@@ -252,8 +289,8 @@
         """
         if owner is None:
             owner = self.launchpad.me.name
-        (base_vcs, base_user, base_password, base_path, base_params) = self._split_url(
-            base_branch.user_url)
+        (base_vcs, base_user, base_password, base_path,
+            base_params) = self._split_url(base_branch.user_url)
         # TODO(jelmer): Prevent publishing to development focus
         if base_vcs == 'bzr':
             return self._publish_bzr(
@@ -271,10 +308,11 @@
     def get_derived_branch(self, base_branch, name, project=None, owner=None):
         if owner is None:
             owner = self.launchpad.me.name
-        (base_vcs, base_user, base_password, base_path, base_params) = self._split_url(
-            base_branch.user_url)
+        (base_vcs, base_user, base_password, base_path,
+            base_params) = self._split_url(base_branch.user_url)
         if base_vcs == 'bzr':
-            to_path = self._get_derived_bzr_path(base_branch, name, owner, project)
+            to_path = self._get_derived_bzr_path(
+                base_branch, name, owner, project)
             return _mod_branch.Branch.open("lp:" + to_path)
         elif base_vcs == 'git':
             to_path = self._get_derived_git_path(
@@ -284,42 +322,37 @@
         else:
             raise AssertionError('not a valid Launchpad URL')
 
-    def get_proposal(self, source_branch, target_branch):
-        (base_vcs, base_user, base_password, base_path, base_params) = (
-            self._split_url(target_branch.user_url))
+    def iter_proposals(self, source_branch, target_branch, status='open'):
+        (base_vcs, base_user, base_password, base_path,
+            base_params) = self._split_url(target_branch.user_url)
+        statuses = status_to_lp_mp_statuses(status)
         if base_vcs == 'bzr':
             target_branch_lp = self.launchpad.branches.getByUrl(
                 url=target_branch.user_url)
             source_branch_lp = self.launchpad.branches.getByUrl(
                 url=source_branch.user_url)
-            for mp in target_branch_lp.getMergeProposals(
-                    status=MERGE_PROPOSAL_STATUSES):
-                if mp.target_branch != target_branch_lp:
-                    continue
-                if mp.source_branch != source_branch_lp:
-                    continue
-                return LaunchpadMergeProposal(mp)
-            raise NoMergeProposal()
+            for mp in target_branch_lp.getMergeProposals(status=statuses):
+                if mp.source_branch_link != source_branch_lp.self_link:
+                    continue
+                yield LaunchpadMergeProposal(mp)
         elif base_vcs == 'git':
             (source_repo_lp, source_branch_lp) = (
                 self.lp_host._get_lp_git_ref_from_branch(source_branch))
             (target_repo_lp, target_branch_lp) = (
                 self.lp_host._get_lp_git_ref_from_branch(target_branch))
-            for mp in target_branch_lp.getMergeProposals(
-                    status=MERGE_PROPOSAL_STATUSES):
+            for mp in target_branch_lp.getMergeProposals(status=statuses):
                 if (target_branch_lp.path != mp.target_git_path or
                         target_repo_lp != mp.target_git_repository or
                         source_branch_lp.path != mp.source_git_path or
                         source_repo_lp != mp.source_git_repository):
                     continue
-                return LaunchpadMergeProposal(mp)
-            raise NoMergeProposal()
+                yield LaunchpadMergeProposal(mp)
         else:
             raise AssertionError('not a valid Launchpad URL')
 
     def get_proposer(self, source_branch, target_branch):
-        (base_vcs, base_user, base_password, base_path, base_params) = (
-            self._split_url(target_branch.user_url))
+        (base_vcs, base_user, base_password, base_path,
+            base_params) = self._split_url(target_branch.user_url)
         if base_vcs == 'bzr':
             return LaunchpadBazaarMergeProposalBuilder(
                 self, source_branch, target_branch)
@@ -329,6 +362,15 @@
         else:
             raise AssertionError('not a valid Launchpad URL')
 
+    @classmethod
+    def iter_instances(cls):
+        yield cls()
+
+    def iter_my_proposals(self, status='open'):
+        statuses = status_to_lp_mp_statuses(status)
+        for mp in self.launchpad.me.getMergeProposals(status=statuses):
+            yield LaunchpadMergeProposal(mp)
+
 
 def connect_launchpad(lp_instance='production'):
     service = lp_registration.LaunchpadService(lp_instance=lp_instance)
@@ -354,13 +396,16 @@
         self.lp_host = lp_host
         self.launchpad = lp_host.launchpad
         self.source_branch = source_branch
-        self.source_branch_lp = self.launchpad.branches.getByUrl(url=source_branch.user_url)
+        self.source_branch_lp = self.launchpad.branches.getByUrl(
+            url=source_branch.user_url)
         if target_branch is None:
             self.target_branch_lp = self.source_branch_lp.get_target()
-            self.target_branch = _mod_branch.Branch.open(self.target_branch_lp.bzr_identity)
+            self.target_branch = _mod_branch.Branch.open(
+                self.target_branch_lp.bzr_identity)
         else:
             self.target_branch = target_branch
-            self.target_branch_lp = self.launchpad.branches.getByUrl(url=target_branch.user_url)
+            self.target_branch_lp = self.launchpad.branches.getByUrl(
+                url=target_branch.user_url)
         self.commit_message = message
         self.approve = approve
         self.fixes = fixes
@@ -414,7 +459,7 @@
             _call_webservice(
                 mp.createComment,
                 vote=u'Approve',
-                subject='', # Use the default subject.
+                subject='',  # Use the default subject.
                 content=u"Rubberstamp! Proposer approves of own proposal.")
             _call_webservice(mp.setStatus, status=u'Approved',
                              revid=self.source_branch.last_revision())
@@ -478,13 +523,17 @@
         self.lp_host = lp_host
         self.launchpad = lp_host.launchpad
         self.source_branch = source_branch
-        (self.source_repo_lp, self.source_branch_lp) = self.lp_host._get_lp_git_ref_from_branch(source_branch)
+        (self.source_repo_lp,
+            self.source_branch_lp) = self.lp_host._get_lp_git_ref_from_branch(
+                source_branch)
         if target_branch is None:
             self.target_branch_lp = self.source_branch.get_target()
-            self.target_branch = _mod_branch.Branch.open(self.target_branch_lp.git_https_url)
+            self.target_branch = _mod_branch.Branch.open(
+                self.target_branch_lp.git_https_url)
         else:
             self.target_branch = target_branch
-            (self.target_repo_lp, self.target_branch_lp) = self.lp_host._get_lp_git_ref_from_branch(target_branch)
+            (self.target_repo_lp, self.target_branch_lp) = (
+                self.lp_host._get_lp_git_ref_from_branch(target_branch))
         self.commit_message = message
         self.approve = approve
         self.fixes = fixes
@@ -538,7 +587,7 @@
             _call_webservice(
                 mp.createComment,
                 vote=u'Approve',
-                subject='', # Use the default subject.
+                subject='',  # Use the default subject.
                 content=u"Rubberstamp! Proposer approves of own proposal.")
             _call_webservice(
                 mp.setStatus, status=u'Approved',
@@ -550,7 +599,8 @@
         if labels:
             raise LabelsUnsupported()
         if prerequisite_branch is not None:
-            (prereq_repo_lp, prereq_branch_lp) = self.lp_host._get_lp_git_ref_from_branch(prerequisite_branch)
+            (prereq_repo_lp, prereq_branch_lp) = (
+                self.lp_host._get_lp_git_ref_from_branch(prerequisite_branch))
         else:
             prereq_branch_lp = None
         if reviewers is None:

=== modified file 'breezy/plugins/propose/propose.py'
--- old/breezy/plugins/propose/propose.py	2019-01-01 22:31:13 +0000
+++ new/breezy/plugins/propose/propose.py	2019-01-04 00:40:55 +0000
@@ -43,14 +43,6 @@
         self.url = url
 
 
-class NoMergeProposal(errors.BzrError):
-
-    _fmt = "No merge proposal exists."
-
-    def __init__(self):
-        errors.BzrError.__init__(self)
-
-
 class UnsupportedHoster(errors.BzrError):
 
     _fmt = "No supported hoster for %(branch)s."
@@ -108,6 +100,14 @@
         """Set the description of the merge proposal."""
         raise NotImplementedError(self.set_description)
 
+    def get_source_branch_url(self):
+        """Return the source branch."""
+        raise NotImplementedError(self.get_source_branch_url)
+
+    def get_target_branch_url(self):
+        """Return the target branch."""
+        raise NotImplementedError(self.get_target_branch_url)
+
     def close(self):
         """Close the merge proposal (without merging it)."""
         raise NotImplementedError(self.close)
@@ -192,15 +192,15 @@
         """
         raise NotImplementedError(self.get_proposer)
 
-    def get_proposal(self, source_branch, target_branch):
+    def iter_proposals(self, source_branch, target_branch, status='open'):
         """Get a merge proposal for a specified branch tuple.
 
         :param source_branch: Source branch
         :param target_branch: Target branch
-        :raise NoMergeProposal: if no merge proposal can be found
-        :return: A MergeProposal object
+        :param status: Status of proposals to iterate over
+        :return: Iterate over MergeProposal object
         """
-        raise NotImplementedError(self.get_proposal)
+        raise NotImplementedError(self.iter_proposals)
 
     def hosts(self, branch):
         """Return true if this hoster hosts given branch."""
@@ -212,7 +212,23 @@
         raise NotImplementedError(cls.probe)
 
     # TODO(jelmer): Some way of cleaning up old branch proposals/branches
-    # TODO(jelmer): Some way of checking up on outstanding merge proposals
+
+    def iter_my_proposals(self, status='open'):
+        """Iterate over the proposals created by the currently logged in user.
+
+        :param status: Only yield proposals with this status
+            (one of: 'open', 'closed', 'merged', 'all')
+        :return: Iterator over MergeProposal objects
+        """
+        raise NotImplementedError(self.iter_my_proposals)
+
+    @classmethod
+    def iter_instances(cls):
+        """Iterate instances.
+
+        :return: Hoster instances
+        """
+        raise NotImplementedError(cls.iter_instances)
 
 
 def get_hoster(branch, possible_hosters=None):

