Repository: canonical/charmcraft

Issue:
The `names` command should allow listing those "collaborated" charms 
Currently it only lists those charms *owned* by the developer.

We could add an `--include-collaborations` option (or something shorter, but remember we have bash autocompletion, so...) to also list the charms where the developer is a collaborator.

When this option is used a new column will be shown, exposing the Publisher's display name.

Implementation-wise, the Charmhub endpoint needs to be modified this way: `/v1/charm?include-collaborations=true`

Discussion:

Patch:
diff --git a/charmcraft/commands/store/__init__.py b/charmcraft/commands/store/__init__.py
index 26ab8dd4..cbccb213 100644
--- a/charmcraft/commands/store/__init__.py
+++ b/charmcraft/commands/store/__init__.py
@@ -401,6 +401,10 @@ class ListNamesCommand(BaseCommand):
         seen by any user, while `private` items are only for you and the
         other accounts with permission to collaborate on that specific name.
 
+        The --include-collaborations option can be included to also list those
+        names you collaborate with; in that case the publisher will be included
+        in the output.
+
         Listing names will take you through login if needed.
     """
     )
@@ -409,26 +413,36 @@ class ListNamesCommand(BaseCommand):
     def fill_parser(self, parser):
         """Add own parameters to the general parser."""
         self.include_format_option(parser)
+        parser.add_argument(
+            "--include-collaborations",
+            action="store_true",
+            help="Include the names you are a collaborator of",
+        )
 
     def run(self, parsed_args):
         """Run the command."""
         store = Store(self.config.charmhub)
-        result = store.list_registered_names()
+        with_collab = parsed_args.include_collaborations
+        result = store.list_registered_names(include_collaborations=with_collab)
 
         # build the structure that we need for both human and programmatic output
         headers = ["Name", "Type", "Visibility", "Status"]
         prog_keys = ["name", "type", "visibility", "status"]
+        if with_collab:
+            headers.append("Publisher")
+            prog_keys.append("publisher")
         data = []
         for item in result:
             visibility = "private" if item.private else "public"
-            data.append(
-                [
-                    item.name,
-                    item.entity_type,
-                    visibility,
-                    item.status,
-                ]
-            )
+            datum = [
+                item.name,
+                item.entity_type,
+                visibility,
+                item.status,
+            ]
+            if with_collab:
+                datum.append(item.publisher_display_name)
+            data.append(datum)
 
         if parsed_args.format:
             info = [dict(zip(prog_keys, item)) for item in data]
diff --git a/charmcraft/commands/store/store.py b/charmcraft/commands/store/store.py
index ed887e22..dd6fe745 100644
--- a/charmcraft/commands/store/store.py
+++ b/charmcraft/commands/store/store.py
@@ -33,7 +33,7 @@ from charmcraft.commands.store.client import Client, ALTERNATE_AUTH_ENV_VAR
 Account = namedtuple("Account", "name username id")
 Package = namedtuple("Package", "id name type")
 MacaroonInfo = namedtuple("MacaroonInfo", "account channels packages permissions")
-Entity = namedtuple("Charm", "entity_type name private status")
+Entity = namedtuple("Charm", "entity_type name private status publisher_display_name")
 Uploaded = namedtuple("Uploaded", "ok status revision errors")
 # XXX Facundo 2020-07-23: Need to do a massive rename to call `revno` to the "revision as
 # the number" inside the "revision as the structure", this gets super confusing in the code with
@@ -246,9 +246,12 @@ class Store:
         )
 
     @_store_client_wrapper()
-    def list_registered_names(self):
+    def list_registered_names(self, include_collaborations):
         """Return names registered by the authenticated user."""
-        response = self._client.request_urlpath_json("GET", "/v1/charm")
+        endpoint = "/v1/charm"
+        if include_collaborations:
+            endpoint += "?include-collaborations=true"
+        response = self._client.request_urlpath_json("GET", endpoint)
         result = []
         for item in response["results"]:
             result.append(
@@ -257,6 +260,7 @@ class Store:
                     private=item["private"],
                     status=item["status"],
                     entity_type=item["type"],
+                    publisher_display_name=item["publisher"]["display-name"],
                 )
             )
         return result
diff --git a/completion.bash b/completion.bash
index baec5796..512d0dcb 100644
--- a/completion.bash
+++ b/completion.bash
@@ -121,7 +121,7 @@ _charmcraft()
             COMPREPLY=( $(compgen -W "${globals[*]} --format" -- "$cur") )
             ;;
         names)
-            COMPREPLY=( $(compgen -W "${globals[*]} --format" -- "$cur") )
+            COMPREPLY=( $(compgen -W "${globals[*]} --format --include-collaborations" -- "$cur") )
             ;;
         revisions)
             COMPREPLY=( $(compgen -W "${globals[*]} --format" -- "$cur") )


Test patch:
diff --git a/tests/commands/test_store_api.py b/tests/commands/test_store_api.py
index 94cc574d..c46ac467 100644
--- a/tests/commands/test_store_api.py
+++ b/tests/commands/test_store_api.py
@@ -488,7 +488,7 @@ def test_list_registered_names_empty(client_mock, config):
     auth_response = {"results": []}
     client_mock.request_urlpath_json.return_value = auth_response
 
-    result = store.list_registered_names()
+    result = store.list_registered_names(include_collaborations=False)
 
     assert client_mock.mock_calls == [call.request_urlpath_json("GET", "/v1/charm")]
     assert result == []
@@ -498,15 +498,28 @@ def test_list_registered_names_multiple(client_mock, config):
     """List registered names getting a multiple response."""
     store = Store(config.charmhub)
 
+    publisher = {"display-name": "J. Doe", "other-info": "a lot"}
     auth_response = {
         "results": [
-            {"name": "name1", "type": "charm", "private": False, "status": "status1"},
-            {"name": "name2", "type": "bundle", "private": True, "status": "status2"},
+            {
+                "name": "name1",
+                "type": "charm",
+                "private": False,
+                "status": "status1",
+                "publisher": publisher,
+            },
+            {
+                "name": "name2",
+                "type": "bundle",
+                "private": True,
+                "status": "status2",
+                "publisher": publisher,
+            },
         ]
     }
     client_mock.request_urlpath_json.return_value = auth_response
 
-    result = store.list_registered_names()
+    result = store.list_registered_names(include_collaborations=False)
 
     assert client_mock.mock_calls == [call.request_urlpath_json("GET", "/v1/charm")]
     item1, item2 = result
@@ -514,10 +527,54 @@ def test_list_registered_names_multiple(client_mock, config):
     assert item1.entity_type == "charm"
     assert not item1.private
     assert item1.status == "status1"
+    assert item1.publisher_display_name == "J. Doe"
+    assert item2.name == "name2"
+    assert item2.entity_type == "bundle"
+    assert item2.private
+    assert item2.status == "status2"
+    assert item2.publisher_display_name == "J. Doe"
+
+
+def test_list_registered_names_include_collaborations(client_mock, config):
+    """List registered names including collaborations."""
+    store = Store(config.charmhub)
+
+    auth_response = {
+        "results": [
+            {
+                "name": "name1",
+                "type": "charm",
+                "private": False,
+                "status": "status1",
+                "publisher": {"display-name": "J. Doe", "other-info": "a lot"},
+            },
+            {
+                "name": "name2",
+                "type": "bundle",
+                "private": True,
+                "status": "status2",
+                "publisher": {"display-name": "Anonymous", "other-info": "more"},
+            },
+        ]
+    }
+    client_mock.request_urlpath_json.return_value = auth_response
+
+    result = store.list_registered_names(include_collaborations=True)
+
+    assert client_mock.mock_calls == [
+        call.request_urlpath_json("GET", "/v1/charm?include-collaborations=true")
+    ]
+    item1, item2 = result
+    assert item1.name == "name1"
+    assert item1.entity_type == "charm"
+    assert not item1.private
+    assert item1.status == "status1"
+    assert item1.publisher_display_name == "J. Doe"
     assert item2.name == "name2"
     assert item2.entity_type == "bundle"
     assert item2.private
     assert item2.status == "status2"
+    assert item2.publisher_display_name == "Anonymous"
 
 
 # -- tests for the upload functionality (both for charm/bundles and resources)
diff --git a/tests/commands/test_store_commands.py b/tests/commands/test_store_commands.py
index 327866f4..9c70c9eb 100644
--- a/tests/commands/test_store_commands.py
+++ b/tests/commands/test_store_commands.py
@@ -620,11 +620,11 @@ def test_list_registered_empty(emitter, store_mock, config, formatted):
     store_response = []
     store_mock.list_registered_names.return_value = store_response
 
-    args = Namespace(format=formatted)
+    args = Namespace(format=formatted, include_collaborations=None)
     ListNamesCommand(config).run(args)
 
     assert store_mock.mock_calls == [
-        call.list_registered_names(),
+        call.list_registered_names(include_collaborations=None),
     ]
     if formatted:
         emitter.assert_json_output([])
@@ -637,15 +637,21 @@ def test_list_registered_empty(emitter, store_mock, config, formatted):
 def test_list_registered_one_private(emitter, store_mock, config, formatted):
     """List registered with one private item in the response."""
     store_response = [
-        Entity(entity_type="charm", name="charm", private=True, status="status"),
+        Entity(
+            entity_type="charm",
+            name="charm",
+            private=True,
+            status="status",
+            publisher_display_name="J. Doe",
+        ),
     ]
     store_mock.list_registered_names.return_value = store_response
 
-    args = Namespace(format=formatted)
+    args = Namespace(format=formatted, include_collaborations=None)
     ListNamesCommand(config).run(args)
 
     assert store_mock.mock_calls == [
-        call.list_registered_names(),
+        call.list_registered_names(include_collaborations=None),
     ]
     expected = [
         "Name    Type    Visibility    Status",
@@ -669,15 +675,21 @@ def test_list_registered_one_private(emitter, store_mock, config, formatted):
 def test_list_registered_one_public(emitter, store_mock, config, formatted):
     """List registered with one public item in the response."""
     store_response = [
-        Entity(entity_type="charm", name="charm", private=False, status="status"),
+        Entity(
+            entity_type="charm",
+            name="charm",
+            private=False,
+            status="status",
+            publisher_display_name="J. Doe",
+        ),
     ]
     store_mock.list_registered_names.return_value = store_response
 
-    args = Namespace(format=formatted)
+    args = Namespace(format=formatted, include_collaborations=None)
     ListNamesCommand(config).run(args)
 
     assert store_mock.mock_calls == [
-        call.list_registered_names(),
+        call.list_registered_names(include_collaborations=None),
     ]
     expected = [
         "Name    Type    Visibility    Status",
@@ -701,23 +713,42 @@ def test_list_registered_one_public(emitter, store_mock, config, formatted):
 def test_list_registered_several(emitter, store_mock, config, formatted):
     """List registered with several itemsssssssss in the response."""
     store_response = [
-        Entity(entity_type="charm", name="charm1", private=True, status="simple status"),
-        Entity(entity_type="charm", name="charm2-long-name", private=False, status="other"),
-        Entity(entity_type="charm", name="charm3", private=True, status="super long status"),
+        Entity(
+            entity_type="charm",
+            name="charm1",
+            private=True,
+            status="simple status",
+            publisher_display_name="J. Doe",
+        ),
+        Entity(
+            entity_type="charm",
+            name="charm2-long-name",
+            private=False,
+            status="other",
+            publisher_display_name="J. Doe",
+        ),
+        Entity(
+            entity_type="charm",
+            name="charm3",
+            private=True,
+            status="super long status",
+            publisher_display_name="J. Doe",
+        ),
         Entity(
             entity_type="bundle",
             name="somebundle",
             private=False,
             status="bundle status",
+            publisher_display_name="J. Doe",
         ),
     ]
     store_mock.list_registered_names.return_value = store_response
 
-    args = Namespace(format=formatted)
+    args = Namespace(format=formatted, include_collaborations=None)
     ListNamesCommand(config).run(args)
 
     assert store_mock.mock_calls == [
-        call.list_registered_names(),
+        call.list_registered_names(include_collaborations=None),
     ]
     if formatted:
         expected = [
@@ -758,6 +789,60 @@ def test_list_registered_several(emitter, store_mock, config, formatted):
         emitter.assert_messages(expected)
 
 
+@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
+def test_list_registered_with_collaborations(emitter, store_mock, config, formatted):
+    """List registered with collaborations flag."""
+    store_response = [
+        Entity(
+            entity_type="charm",
+            name="charm1",
+            private=True,
+            status="simple status",
+            publisher_display_name="J. Doe",
+        ),
+        Entity(
+            entity_type="bundle",
+            name="somebundle",
+            private=False,
+            status="bundle status",
+            publisher_display_name="Ms. Bundle Publisher",
+        ),
+    ]
+    store_mock.list_registered_names.return_value = store_response
+
+    args = Namespace(format=formatted, include_collaborations=True)
+    ListNamesCommand(config).run(args)
+
+    assert store_mock.mock_calls == [
+        call.list_registered_names(include_collaborations=True),
+    ]
+    if formatted:
+        expected = [
+            {
+                "name": "charm1",
+                "type": "charm",
+                "visibility": "private",
+                "status": "simple status",
+                "publisher": "J. Doe",
+            },
+            {
+                "name": "somebundle",
+                "type": "bundle",
+                "visibility": "public",
+                "status": "bundle status",
+                "publisher": "Ms. Bundle Publisher",
+            },
+        ]
+        emitter.assert_json_output(expected)
+    else:
+        expected = [
+            "Name        Type    Visibility    Status         Publisher",
+            "charm1      charm   private       simple status  J. Doe",
+            "somebundle  bundle  public        bundle status  Ms. Bundle Publisher",
+        ]
+        emitter.assert_messages(expected)
+
+
 # -- tests for upload command