Repository: meltano/meltano

Issue:
Cloud CLI to register a new project in Meltano Cloud
cc @tayloramurphy

Blocked by:
- https://github.com/meltano/infra/issues/514

Related to:

- https://github.com/meltano/infra/issues/513 (internal)
- https://github.com/meltano/meltano/issues/7411




Discussion:

Resolving patch:
diff --git a/src/meltano/cloud/cli/project.py b/src/meltano/cloud/cli/project.py
--- a/src/meltano/cloud/cli/project.py
+++ b/src/meltano/cloud/cli/project.py
@@ -6,12 +6,16 @@
 import logging
 import sys
 import typing as t
+from http import HTTPStatus
 
 import click
 import questionary
+import requests
+from slugify import slugify
 from ulid import ULID
+from yaspin import yaspin  # type: ignore
 
-from meltano.cloud.api.client import MeltanoCloudClient
+from meltano.cloud.api.client import MeltanoCloudClient, MeltanoCloudError
 from meltano.cloud.cli.base import (
     LimitedResult,
     get_paginated,
@@ -93,6 +97,26 @@ async def get_projects(
                 ),
             )
 
+    async def create_project(
+        self,
+        project_name: str,
+        git_repository: str,
+        project_root_path: str | None = None,
+    ):
+        """Use POST to create new Meltano Cloud project."""
+        async with self.authenticated():
+            payload = {"project_name": project_name, "git_repository": git_repository}
+            if project_root_path:
+                payload["project_root_path"] = project_root_path
+            prepared_request = await self._json_request(
+                "POST",
+                f"/projects/v1/{self.config.tenant_resource_key}",
+                json=payload,
+            )
+            response = requests.request(**t.cast(t.Dict[str, t.Any], prepared_request))
+            response.raise_for_status()
+            return response
+
 
 @click.group("project")
 def project_group() -> None:
@@ -334,3 +358,41 @@ async def use_project(
         ),
         fg="green",
     )
+
+
+@project_group.command("create")
+@click.option("--name", type=str, prompt=True)
+@click.option("--repo-url", type=str, prompt=True)
+@click.option("--root-path", type=str, required=False)
+@pass_context
+@run_async
+async def create_project(
+    context: MeltanoCloudCLIContext,
+    name: str,
+    repo_url: str,
+    root_path: str | None = None,
+):
+    """Create a project to your Meltano Cloud."""
+    async with ProjectsCloudClient(config=context.config) as client:
+        try:
+            with yaspin(
+                text="Creating project - this may take several minutes...",
+            ):
+                response = await client.create_project(
+                    project_name=name,
+                    git_repository=repo_url,
+                    project_root_path=root_path,
+                )
+        except MeltanoCloudError as e:
+            if e.response.status == HTTPStatus.CONFLICT:
+                click.secho(
+                    (
+                        f"A project named {name!r} (normalized to "
+                        f"{slugify(name)!r}) already exists."
+                    ),
+                    fg="yellow",
+                )
+            return None
+        click.echo(f"Project {name!r} created successfully.")
+        if response.status_code == HTTPStatus.NO_CONTENT:
+            return None


Test patch:
diff --git a/tests/meltano/cloud/cli/test_project.py b/tests/meltano/cloud/cli/test_project.py
--- a/tests/meltano/cloud/cli/test_project.py
+++ b/tests/meltano/cloud/cli/test_project.py
@@ -17,6 +17,7 @@
 
 if t.TYPE_CHECKING:
     from pytest_httpserver import HTTPServer
+    from requests_mock import Mocker as RequestsMocker
 
     from meltano.cloud.api.config import MeltanoCloudConfig
     from meltano.cloud.api.types import CloudProject
@@ -74,6 +75,21 @@ def projects(self, tenant_resource_key: str) -> list[CloudProject]:
     def path(self, tenant_resource_key: str) -> str:
         return f"/projects/v1/{tenant_resource_key}"
 
+    @pytest.fixture()
+    def prepared_request(self, request: pytest.FixtureRequest):
+        return {
+            "method": request.param["method"],
+            "url": "https://asdf.lambda-url.us-west-2.on.aws.example.com/",
+            "params": {},
+            "headers": {
+                "Content-Type": "application/json",
+                "X-Amz-Date": "20230525T185058Z",
+                "X-Amz-Security-Token": "IQoJb3JpZ2luX2VjEOP/////////wEaCXV==",
+                "Authorization": "AWS4-HMAC-SHA256 Credential=ASI3AV1SM3BH...",
+            },
+            "data": '{"environment_name": "staging", "git_rev": "main"}',
+        }
+
     def test_project_list_table(
         self,
         config: MeltanoCloudConfig,
@@ -392,6 +408,45 @@ def test_project_use_by_name_and_id_error(self, config: MeltanoCloudConfig):
         assert result.exit_code == 2
         assert "The '--name' and '--id' options are mutually exclusive" in result.output
 
+    @pytest.mark.parametrize("prepared_request", ({"method": "POST"},), indirect=True)
+    def test_project_create(
+        self,
+        projects: list[CloudProject],
+        httpserver: HTTPServer,
+        config: MeltanoCloudConfig,
+        prepared_request,
+        requests_mock: RequestsMocker,
+    ):
+        for project in projects[:3]:
+            httpserver.expect_oneshot_request(
+                f"/projects/v1/{project['tenant_resource_key']}",
+                method="POST",
+            ).respond_with_json(prepared_request)
+            requests_mock.post(prepared_request["url"], json=project)  # noqa: S113
+            result = CliRunner().invoke(
+                cli,
+                (
+                    "--config-path",
+                    config.config_path,
+                    "project",
+                    "create",
+                    "--name",
+                    project["project_name"],
+                    "--repo-url",
+                    project["git_repository"],
+                    "--root-path",
+                    project["project_root_path"],
+                ),
+            )
+            assert result.exit_code == 0, result.output
+            assert (
+                "Creating project - this may take several minutes..." in result.output
+            )
+            assert (
+                f"Project {project['project_name']!r} created successfully.\n"
+                in result.output
+            )
+
     def test_project_invalid_ulid(self, config: MeltanoCloudConfig):
         result = CliRunner().invoke(
             cli,