From 80abdfae6b2faa832e04e664610ab0047e4b189e Mon Sep 17 00:00:00 2001 From: niwamo Date: Wed, 11 Feb 2026 16:35:18 -0600 Subject: [PATCH] add collaborator management commands --- charmcraft/application/commands/__init__.py | 15 ++ charmcraft/application/commands/store.py | 156 ++++++++++++++++++++ charmcraft/store/store.py | 43 ++++++ 3 files changed, 214 insertions(+) diff --git a/charmcraft/application/commands/__init__.py b/charmcraft/application/commands/__init__.py index e71f673a3..1c6cb72bd 100644 --- a/charmcraft/application/commands/__init__.py +++ b/charmcraft/application/commands/__init__.py @@ -48,6 +48,11 @@ PromoteCommand, StatusCommand, CloseCommand, + GetCollaborators, + InviteCollaborator, + GetCollaboratorInvites, + RevokeInvite, + AcceptInvite, # libraries support CreateLibCommand, PublishLibCommand, @@ -92,6 +97,11 @@ def fill_command_groups(app: craft_application.Application) -> None: PromoteCommand, StatusCommand, CloseCommand, + GetCollaborators, + InviteCollaborator, + GetCollaboratorInvites, + RevokeInvite, + AcceptInvite, # resources support ListResourcesCommand, ListResourceRevisionsCommand, @@ -145,6 +155,11 @@ def fill_command_groups(app: craft_application.Application) -> None: "ReleaseCommand", "StatusCommand", "CloseCommand", + "GetCollaborators", + "InviteCollaborator", + "GetCollaboratorInvites", + "RevokeInvite", + "AcceptInvite", "CreateLibCommand", "PublishLibCommand", "ListLibCommand", diff --git a/charmcraft/application/commands/store.py b/charmcraft/application/commands/store.py index 63e8f0728..b56d8b298 100644 --- a/charmcraft/application/commands/store.py +++ b/charmcraft/application/commands/store.py @@ -2344,3 +2344,159 @@ def run(self, parsed_args: argparse.Namespace) -> None: for track in output_tracks ] emit.message(tabulate(data, headers="keys")) + + +class GetCollaborators(CharmcraftCommand): + """List collaborators for a charm.""" + + name = "get-collaborators" + help_msg = "Get collaborators for the given package." + overview = "Get collaborators for the given package." + format_option = True + + def fill_parser(self, parser): + """Add own parameters to the general parser.""" + parser.add_argument("charm", help="The charm to list collaborators for.") + + def run(self, parsed_args: argparse.Namespace) -> None: + """Run the command.""" + store = Store(env.get_store_config()) + response = store.get_collaborators(parsed_args.charm) + collaborators = response.get("collaborators") + if not collaborators: + emit.message("No collaborators found.") + return + data = [ + { + "DisplayName": c.get("account", {}).get("display-name"), + "Email": c.get("account", {}).get("email"), + "InvitedBy": c.get("created-by", {}).get("email"), + "Permissions": ", ".join(c.get("permissions", [])), + } + for c in collaborators + ] + emit.message(tabulate(data, headers="keys")) + + +class InviteCollaborator(CharmcraftCommand): + """Invite a collaborator to a charm.""" + + name = "invite-collaborator" + help_msg = "Invite a collaborator for a package." + overview = textwrap.dedent( + """\ + Invite a collaborator for a package. + + Sends the specified collaborator an invitation for the specified package. + """ + ) + format_option = True + + def fill_parser(self, parser): + """Add own parameters to the general parser.""" + parser.add_argument("charm", help="The charm to invite the collaborator for.") + parser.add_argument("collaborator", help="The collaborator to invite.") + + def run(self, parsed_args: argparse.Namespace) -> None: + """Run the command.""" + store = Store(env.get_store_config()) + response = store.invite_collaborator( + parsed_args.charm, + parsed_args.collaborator, + ) + tokens = response.get("tokens") + if not tokens: + emit.message("Something went wrong.") + return + table = [ + { + "Email": t.get("email"), + "Token": t.get("token"), + } + for t in tokens + ] + emit.message(tabulate(table, headers="keys")) + + +class GetCollaboratorInvites(CharmcraftCommand): + """List collaborator invites for a charm.""" + + name = "get-collaborator-invites" + help_msg = "Get all collaborator invites for the given package." + overview = "Get all collaborator invites for the given package." + format_option = True + + def fill_parser(self, parser): + """Add own parameters to the general parser.""" + parser.add_argument("charm", help="The charm to list invites for.") + + def run(self, parsed_args: argparse.Namespace) -> None: + """Run the command.""" + store = Store(env.get_store_config()) + response = store.get_collaborator_invites(parsed_args.charm) + invites = response.get("invites") + if not invites: + emit.message("No invites found.") + invites = [ + { + "Recipient": i.get("email"), + "ExpiresAt": i.get("expires-at"), + "CreatedBy": i.get("created-by", {}).get("email"), + "CreatedAt": i.get("created-at"), + "AcceptedAt": i.get("accepted-at"), + "RevokedAt": i.get("revoked-at"), + } + for i in invites + ] + emit.message(tabulate(invites, headers="keys")) + + +class RevokeInvite(CharmcraftCommand): + """Revoke a collaborator invite for a charm.""" + + name = "revoke-invite" + help_msg = "Revoke a collaborator invitation." + overview = "Revoke a collaborator invitation." + format_option = True + + def fill_parser(self, parser): + """Add own parameters to the general parser.""" + parser.add_argument("charm", help="The charm to revoke an invite for.") + parser.add_argument( + "collaborator", help="The email associated with the invitation to revoke." + ) + + def run(self, parsed_args: argparse.Namespace) -> None: + """Run the command.""" + store = Store(env.get_store_config()) + store.revoke_invite(parsed_args.charm, parsed_args.collaborator) + emit.message("Invitation revoked.") + + +class AcceptInvite(CharmcraftCommand): + """Accept a collaborator invite for a charm.""" + + name = "accept-invite" + help_msg = "Accept a collaborator invitation." + overview = textwrap.dedent( + """\ + Accept a collaborator invitation. + + Requires the invitation token that is created during the + 'invite-collaborator' command. + """ + ) + format_option = True + + def fill_parser(self, parser): + """Add own parameters to the general parser.""" + parser.add_argument("charm", help="The charm to accept an invite for.") + parser.add_argument( + "token", help="The token associated with the invitation to revoke." + ) + + def run(self, parsed_args: argparse.Namespace) -> None: + """Run the command.""" + store = Store(env.get_store_config()) + store.accept_invite(parsed_args.charm, parsed_args.token) + emit.message("Invitation accepted.") diff --git a/charmcraft/store/store.py b/charmcraft/store/store.py index 1f2e44fd0..c74423209 100644 --- a/charmcraft/store/store.py +++ b/charmcraft/store/store.py @@ -274,6 +274,49 @@ def register_name(self, name, entity_type): "POST", "/v1/charm", json={"name": name, "type": entity_type} ) + @_store_client_wrapper() + def get_collaborators(self, charm: str): + """Get charm collaborators.""" + return self._client.request_urlpath_json( + "GET", + f"/v1/charm/{charm}/collaborators", + ) + + @_store_client_wrapper() + def get_collaborator_invites(self, charm: str): + """Get collaborator invitations.""" + return self._client.request_urlpath_json( + "GET", + f"/v1/charm/{charm}/collaborators/invites", + ) + + @_store_client_wrapper() + def invite_collaborator(self, charm: str, collaborator: str): + """Invite a charm collaborator.""" + return self._client.request_urlpath_json( + "POST", + f"/v1/charm/{charm}/collaborators/invites", + json={"invites": [{"email": collaborator}]}, + ) + + @_store_client_wrapper() + def revoke_invite(self, charm: str, collaborator: str) -> None: + """Invite a charm collaborator.""" + self._client.request_urlpath_text( + "POST", + f"/v1/charm/{charm}/collaborators/invites/revoke", + json={"invites": [{"email": collaborator}]}, + ) + + @_store_client_wrapper() + def accept_invite(self, charm: str, token: str) -> None: + """Invite a charm collaborator.""" + self._client.request_urlpath_text( + "POST", + f"/v1/charm/{charm}/collaborators/invites/accept", + json={"token": token}, + ) + @_store_client_wrapper() def unregister_name(self, name: str) -> None: """Unregister a package that hasn't been published.