Repository: PrefectHQ/prefect

Issue:
Distinguish PAT / Repo Token Requirements in Credential blocks for SCM
### First check

- [X] I added a descriptive title to this issue.
- [X] I used the GitHub search to find a similar request and didn't find it.
- [X] I searched the Prefect documentation for this feature.

### Prefect Version

2.x

### Describe the current behavior

The current credentials blocks for SCM (GitLab, GitHub, Bitbucket) are slightly un-intuitive with using tokens.
There aren't existing examples or descriptions when creating one; this leads to inputting a token correctly, but that's only part of the authorization requirement.

For example:
Github can be like `<user>:<pat token>` or `oauth2:<token>`
Bitbucket can be like `<user>:<pat token>` or `x-token-auth:<token>` depending on PAT or repo tokens.

This nuance isn't really indicated, and when a deployment runs, just prepends this to the front of the SCM url.


### Describe the proposed behavior

I propose some ideas:

1) Provide either separate credential blocks for a repo token or `PAT` with the explicitly required fields, for the MAIN SCM's (which are as far as I've seen Github, Bitbucket, and Gitlab). A user selecting "Bitbucket Repo Token Credential" then only needs to provide the token, and the `x-token-auth` is handled.

2) Allow a toggle of some sort which allows you to choose what kind of token is being used, with fields relevant to that option.

3) Provide examples in the UI / description for each major provider of expected fields / inputs just for a visual cue of anticipated inputs. 

### Example Use

With a repository token for bitbucket for example like this, the URL becomes pieced together as `https://<token>@bitbucket.orgp/prefect/azure-deployments.git`.
If a user does not __explicitly__ add `x-token-auth` to the username, you would receive an error in attempting to clone as the format as incorrect.
This requires the user to explicitly know in advance how this works, which in normal usage via CLI is less relevant as you would be prompted for a user, then for the token separately.
```
- prefect.deployments.steps.git_clone:
    repository: https://bitbucket.org/prefect/azure-deployments.git
    branch: master
    access_token: '{{ prefect.blocks.secret.bitbucket-token }}'
```

### Additional context

_No response_


Discussion:

Resolving patch:
diff --git a/src/prefect/deployments/steps/pull.py b/src/prefect/deployments/steps/pull.py
--- a/src/prefect/deployments/steps/pull.py
+++ b/src/prefect/deployments/steps/pull.py
@@ -29,6 +29,89 @@ def set_working_directory(directory: str) -> dict:
     return dict(directory=directory)
 
 
+def _format_token_from_access_token(netloc: str, access_token: str) -> str:
+    if "bitbucketserver" in netloc:
+        if ":" not in access_token:
+            raise ValueError(
+                "Please prefix your BitBucket Server access_token with a username,"
+                " e.g. 'username:token'."
+            )
+        # If they pass a header themselves, we can use it as-is
+        return access_token
+
+    elif "bitbucket" in netloc:
+        return (
+            access_token
+            if (access_token.startswith("x-token-auth:") or ":" in access_token)
+            else f"x-token-auth:{access_token}"
+        )
+
+    elif "gitlab" in netloc:
+        return (
+            f"oauth2:{access_token}"
+            if not access_token.startswith("oauth2:")
+            else access_token
+        )
+
+    # all other cases (GitHub, etc.)
+    return access_token
+
+
+def _format_token_from_credentials(netloc: str, credentials: dict) -> str:
+    """
+    Formats the credentials block for the git provider.
+
+    BitBucket supports the following syntax:
+        git clone "https://x-token-auth:{token}@bitbucket.org/yourRepoOwnerHere/RepoNameHere"
+        git clone https://username:<token>@bitbucketserver.com/scm/projectname/teamsinspace.git
+    """
+    username = credentials.get("username") if credentials else None
+    password = credentials.get("password") if credentials else None
+    token = credentials.get("token") if credentials else None
+
+    user_provided_token = token or password
+
+    if not user_provided_token:
+        raise ValueError(
+            "Please provide a `token` or `password` in your Credentials block to clone"
+            " a repo."
+        )
+
+    if "bitbucketserver" in netloc:
+        # If they pass a BitBucketCredentials block and we don't have both a username and at
+        # least one of a password or token and they don't provide a header themselves,
+        # we can raise the appropriate error to avoid the wrong format for BitBucket Server.
+        if not username and ":" not in user_provided_token:
+            raise ValueError(
+                "Please provide a `username` and a `password` or `token` in your"
+                " BitBucketCredentials block to clone a repo from BitBucket Server."
+            )
+        # if username or if no username but it's provided in the token
+        return (
+            f"{username}:{user_provided_token}"
+            if username and username not in user_provided_token
+            else user_provided_token
+        )
+
+    elif "bitbucket" in netloc:
+        return (
+            user_provided_token
+            if user_provided_token.startswith("x-token-auth:")
+            or ":" in user_provided_token
+            else f"x-token-auth:{user_provided_token}"
+        )
+
+    elif "gitlab" in netloc:
+        return (
+            f"oauth2:{user_provided_token}"
+            if not user_provided_token.startswith("oauth2:")
+            else user_provided_token
+        )
+
+    # all other cases (GitHub, etc.)
+    return user_provided_token
+
+
 def git_clone(
     repository: str,
     branch: Optional[str] = None,
@@ -110,13 +193,17 @@ def git_clone(
             "Please provide either an access token or credentials but not both."
         )
 
+    url_components = urllib.parse.urlparse(repository)
+
+    if access_token:
+        access_token = _format_token_from_access_token(
+            url_components.netloc, access_token
+        )
     if credentials:
-        if credentials.get("token"):
-            access_token = credentials["token"]
-        elif credentials.get("username") and credentials.get("password"):
-            access_token = f"{credentials['username']}:{credentials['password']}"
+        access_token = _format_token_from_credentials(
+            url_components.netloc, credentials
+        )
 
-    url_components = urllib.parse.urlparse(repository)
     if url_components.scheme == "https" and access_token is not None:
         updated_components = url_components._replace(
             netloc=f"{access_token}@{url_components.netloc}"


Test patch:
diff --git a/tests/deployment/test_steps.py b/tests/deployment/test_steps.py
--- a/tests/deployment/test_steps.py
+++ b/tests/deployment/test_steps.py
@@ -245,16 +245,25 @@ def func(*args, **kwargs):
         mock_print.assert_any_call("this is a warning")
 
 
-class MockGitHubCredentials:
-    def __init__(self, token: str):
+class MockCredentials:
+    def __init__(self, token: str, username: str = None, password: str = None):
         self.token = token
-        self.data = {"value": {"token": self.token}}
+        self.username = username
+        self.password = password
+
+        self.data = {
+            "value": {
+                "token": self.token,
+                "username": self.username,
+                "password": self.password,
+            }
+        }
 
     async def save(self, name: str):
         pass
 
     async def load(self, name: str):
-        return MockGitHubCredentials("mock-token")
+        return MockCredentials(token="mock-token")
 
 
 class TestGitCloneStep:
@@ -374,7 +383,7 @@ async def test_git_clone_errors_obscure_access_token(self):
         assert "super-secret-42".upper() not in str(exc.getrepr())
 
     @pytest.mark.asyncio
-    async def test_git_clone_with_valid_credentials_block_succeeds(self, monkeypatch):
+    async def test_git_clone_with_github_credentials_block_succeeds(self, monkeypatch):
         mock_subprocess = MagicMock()
         monkeypatch.setattr(
             "prefect.deployments.steps.pull.subprocess",
@@ -382,7 +391,7 @@ async def test_git_clone_with_valid_credentials_block_succeeds(self, monkeypatch
         )
         blocks = {
             "github-credentials": {
-                "my-github-creds-block": MockGitHubCredentials("mock-token")
+                "my-github-creds-block": MockCredentials("mock-token")
             }
         }
 
@@ -394,7 +403,7 @@ async def mock_read_block_document(self, name: str, block_type_slug: str):
             mock_read_block_document,
         )
 
-        await MockGitHubCredentials("mock-token").save("my-github-creds-block")
+        await MockCredentials("mock-token").save("my-github-creds-block")
 
         output = await run_step(
             {
@@ -422,10 +431,12 @@ async def mock_read_block_document(self, name: str, block_type_slug: str):
         )
 
     @pytest.mark.asyncio
-    async def test_git_clone_with_invalid_credentials_block_raises(self, monkeypatch):
+    async def test_git_clone_with_invalid_github_credentials_block_raises(
+        self, monkeypatch
+    ):
         blocks = {
             "github-credentials": {
-                "my-github-creds-block": MockGitHubCredentials("mock-token")
+                "my-github-creds-block": MockCredentials("mock-token")
             }
         }
 
@@ -453,7 +464,7 @@ async def mock_read_block_document(self, name: str, block_type_slug: str):
     async def test_git_clone_with_token_and_credentials_raises(self, monkeypatch):
         blocks = {
             "github-credentials": {
-                "my-github-creds-block": MockGitHubCredentials("mock-token")
+                "my-github-creds-block": MockCredentials("mock-token")
             }
         }
 
@@ -481,6 +492,445 @@ async def mock_read_block_document(self, name: str, block_type_slug: str):
                 }
             )
 
+    @pytest.mark.parametrize(
+        "access_token", ["example-token", "x-token-auth:example-token"]
+    )
+    async def test_git_clone_with_bitbucket_access_token(
+        self, access_token, monkeypatch
+    ):
+        subprocess_mock = MagicMock()
+        monkeypatch.setattr(
+            "prefect.deployments.steps.pull.subprocess",
+            subprocess_mock,
+        )
+        output = await run_step(
+            {
+                "prefect.deployments.steps.git_clone": {
+                    "repository": "https://bitbucket.org/org/repo.git",
+                    "access_token": access_token,
+                }
+            }
+        )
+        assert output["directory"] == "repo"
+        subprocess_mock.check_call.assert_called_once_with(
+            [
+                "git",
+                "clone",
+                "https://x-token-auth:example-token@bitbucket.org/org/repo.git",
+                "--depth",
+                "1",
+            ],
+            shell=False,
+            stderr=ANY,
+            stdout=ANY,
+        )
+
+    async def test_git_clone_with_bitbucket_credentials_block_token_with_header(
+        self, monkeypatch
+    ):
+        mock_subprocess = MagicMock()
+        monkeypatch.setattr(
+            "prefect.deployments.steps.pull.subprocess",
+            mock_subprocess,
+        )
+        blocks = {
+            "bitbucket-credentials": {
+                "my-bitbucket-creds-block": MockCredentials("mock-token")
+            }
+        }
+
+        async def mock_read_block_document(self, name: str, block_type_slug: str):
+            return blocks[block_type_slug][name]
+
+        monkeypatch.setattr(
+            "prefect.client.orchestration.PrefectClient.read_block_document_by_name",
+            mock_read_block_document,
+        )
+
+        await MockCredentials("x-token-auth:mock-token").save(
+            "my-bitbucket-creds-block"
+        )
+
+        output = await run_step(
+            {
+                "prefect.deployments.steps.git_clone": {
+                    "repository": "https://bitbucket.org/org/repo.git",
+                    "credentials": (
+                        "{{ prefect.blocks.bitbucket-credentials.my-bitbucket-creds-block }}"
+                    ),
+                }
+            }
+        )
+
+        assert output["directory"] == "repo"
+        mock_subprocess.check_call.assert_called_once_with(
+            [
+                "git",
+                "clone",
+                "https://x-token-auth:mock-token@bitbucket.org/org/repo.git",
+                "--depth",
+                "1",
+            ],
+            shell=False,
+            stderr=ANY,
+            stdout=ANY,
+        )
+
+    async def test_git_clone_with_bitbucket_credentials_block_token_with_no_header(
+        self, monkeypatch
+    ):
+        mock_subprocess = MagicMock()
+        monkeypatch.setattr(
+            "prefect.deployments.steps.pull.subprocess",
+            mock_subprocess,
+        )
+        blocks = {
+            "bitbucket-credentials": {
+                "my-bitbucket-creds-block": MockCredentials("mock-token")
+            }
+        }
+
+        async def mock_read_block_document(self, name: str, block_type_slug: str):
+            return blocks[block_type_slug][name]
+
+        monkeypatch.setattr(
+            "prefect.client.orchestration.PrefectClient.read_block_document_by_name",
+            mock_read_block_document,
+        )
+
+        await MockCredentials("mock-token").save("my-bitbucket-creds-block")
+
+        output = await run_step(
+            {
+                "prefect.deployments.steps.git_clone": {
+                    "repository": "https://bitbucket.org/org/repo.git",
+                    "credentials": (
+                        "{{ prefect.blocks.bitbucket-credentials.my-bitbucket-creds-block }}"
+                    ),
+                }
+            }
+        )
+
+        assert output["directory"] == "repo"
+        mock_subprocess.check_call.assert_called_once_with(
+            [
+                "git",
+                "clone",
+                "https://x-token-auth:mock-token@bitbucket.org/org/repo.git",
+                "--depth",
+                "1",
+            ],
+            shell=False,
+            stderr=ANY,
+            stdout=ANY,
+        )
+
+    async def test_git_clone_with_bitbucket_public_repo(self, monkeypatch):
+        subprocess_mock = MagicMock()
+        monkeypatch.setattr(
+            "prefect.deployments.steps.pull.subprocess",
+            subprocess_mock,
+        )
+        output = await run_step(
+            {
+                "prefect.deployments.steps.git_clone": {
+                    "repository": "https://bitbucket.org/org/repo.git",
+                }
+            }
+        )
+        assert output["directory"] == "repo"
+        subprocess_mock.check_call.assert_called_once_with(
+            [
+                "git",
+                "clone",
+                "https://bitbucket.org/org/repo.git",
+                "--depth",
+                "1",
+            ],
+            shell=False,
+            stderr=ANY,
+            stdout=ANY,
+        )
+
+    @pytest.mark.parametrize(
+        "access_token", ["x-token-auth:example-token", "username:example-token"]
+    )
+    async def test_git_clone_with_bitbucket_server_repo_with_access_token(
+        self, monkeypatch, access_token
+    ):
+        subprocess_mock = MagicMock()
+        monkeypatch.setattr(
+            "prefect.deployments.steps.pull.subprocess",
+            subprocess_mock,
+        )
+        output = await run_step(
+            {
+                "prefect.deployments.steps.git_clone": {
+                    "repository": (
+                        "https://bitbucketserver.com/scm/projectname/teamsinspace.git"
+                    ),
+                    "access_token": access_token,
+                }
+            }
+        )
+
+        formatted_token = (
+            f"x-token-auth:{access_token}" if ":" not in access_token else access_token
+        )
+
+        assert output["directory"] == "teamsinspace"
+        subprocess_mock.check_call.assert_called_once_with(
+            [
+                "git",
+                "clone",
+                f"https://{formatted_token}@bitbucketserver.com/scm/projectname/teamsinspace.git",
+                "--depth",
+                "1",
+            ],
+            shell=False,
+            stderr=ANY,
+            stdout=ANY,
+        )
+
+    async def test_git_clone_with_bitbucket_server_repo_with_invalid_access_token_raises(
+        self, monkeypatch
+    ):
+        subprocess_mock = MagicMock()
+        monkeypatch.setattr(
+            "prefect.deployments.steps.pull.subprocess",
+            subprocess_mock,
+        )
+        with pytest.raises(
+            ValueError,
+            match=(
+                "Please prefix your BitBucket Server access_token with a username, e.g."
+                " 'username:token'"
+            ),
+        ):
+            await run_step(
+                {
+                    "prefect.deployments.steps.git_clone": {
+                        "repository": "https://bitbucketserver.com/scm/projectname/teamsinspace.git",
+                        "access_token": "example-token",
+                    }
+                }
+            )
+
+    @pytest.mark.parametrize("token", (["username:example-token", "example-token"]))
+    async def test_git_clone_with_bitbucket_server_repo_with_valid_bitbucket_credentials_block(
+        self, monkeypatch, token
+    ):
+        mock_subprocess = MagicMock()
+        monkeypatch.setattr(
+            "prefect.deployments.steps.pull.subprocess",
+            mock_subprocess,
+        )
+        blocks = {
+            "bitbucket-credentials": {
+                "my-bitbucket-creds-block": MockCredentials(
+                    token=token, username="username"
+                )
+            }
+        }
+
+        async def mock_read_block_document(self, name: str, block_type_slug: str):
+            return blocks[block_type_slug][name]
+
+        monkeypatch.setattr(
+            "prefect.client.orchestration.PrefectClient.read_block_document_by_name",
+            mock_read_block_document,
+        )
+
+        await MockCredentials(token=token, username="username").save(
+            "my-bitbucket-creds-block"
+        )
+
+        output = await run_step(
+            {
+                "prefect.deployments.steps.git_clone": {
+                    "repository": (
+                        "https://bitbucketserver.com/scm/projectname/teamsinspace.git"
+                    ),
+                    "credentials": (
+                        "{{ prefect.blocks.bitbucket-credentials.my-bitbucket-creds-block }}"
+                    ),
+                }
+            }
+        )
+
+        assert output["directory"] == "teamsinspace"
+        mock_subprocess.check_call.assert_called_once_with(
+            [
+                "git",
+                "clone",
+                "https://username:example-token@bitbucketserver.com/scm/projectname/teamsinspace.git",
+                "--depth",
+                "1",
+            ],
+            shell=False,
+            stderr=ANY,
+            stdout=ANY,
+        )
+
+    async def test_git_clone_with_bitbucket_server_repo_with_invalid_bitbucket_credentials_block_raises(
+        self, monkeypatch
+    ):
+        pass
+
+    @pytest.mark.parametrize("access_token", ["example-token", "oauth2:example-token"])
+    async def test_git_clone_with_gitlab_access_token(self, access_token, monkeypatch):
+        subprocess_mock = MagicMock()
+        monkeypatch.setattr(
+            "prefect.deployments.steps.pull.subprocess",
+            subprocess_mock,
+        )
+        output = await run_step(
+            {
+                "prefect.deployments.steps.git_clone": {
+                    "repository": "https://gitlab.com/org/repo.git",
+                    "access_token": access_token,
+                }
+            }
+        )
+        assert output["directory"] == "repo"
+        subprocess_mock.check_call.assert_called_once_with(
+            [
+                "git",
+                "clone",
+                "https://oauth2:example-token@gitlab.com/org/repo.git",
+                "--depth",
+                "1",
+            ],
+            shell=False,
+            stderr=ANY,
+            stdout=ANY,
+        )
+
+    async def test_git_clone_with_gitlab_public_repo(self, monkeypatch):
+        subprocess_mock = MagicMock()
+        monkeypatch.setattr(
+            "prefect.deployments.steps.pull.subprocess",
+            subprocess_mock,
+        )
+        output = await run_step(
+            {
+                "prefect.deployments.steps.git_clone": {
+                    "repository": "https://gitlab.com/org/repo.git",
+                }
+            }
+        )
+        assert output["directory"] == "repo"
+        subprocess_mock.check_call.assert_called_once_with(
+            [
+                "git",
+                "clone",
+                "https://gitlab.com/org/repo.git",
+                "--depth",
+                "1",
+            ],
+            shell=False,
+            stderr=ANY,
+            stdout=ANY,
+        )
+
+    async def test_git_clone_with_gitlab_credentials_block_token_with_header(
+        self, monkeypatch
+    ):
+        mock_subprocess = MagicMock()
+        monkeypatch.setattr(
+            "prefect.deployments.steps.pull.subprocess",
+            mock_subprocess,
+        )
+        blocks = {
+            "gitlab-credentials": {
+                "my-gitlab-creds-block": MockCredentials("mock-token")
+            }
+        }
+
+        async def mock_read_block_document(self, name: str, block_type_slug: str):
+            return blocks[block_type_slug][name]
+
+        monkeypatch.setattr(
+            "prefect.client.orchestration.PrefectClient.read_block_document_by_name",
+            mock_read_block_document,
+        )
+
+        await MockCredentials("oauth2:mock-token").save("my-gitlab-creds-block")
+
+        output = await run_step(
+            {
+                "prefect.deployments.steps.git_clone": {
+                    "repository": "https://gitlab.com/org/repo.git",
+                    "credentials": (
+                        "{{ prefect.blocks.gitlab-credentials.my-gitlab-creds-block }}"
+                    ),
+                }
+            }
+        )
+
+        assert output["directory"] == "repo"
+        mock_subprocess.check_call.assert_called_once_with(
+            [
+                "git",
+                "clone",
+                "https://oauth2:mock-token@gitlab.com/org/repo.git",
+                "--depth",
+                "1",
+            ],
+            shell=False,
+            stderr=ANY,
+            stdout=ANY,
+        )
+
+    async def test_git_clone_with_gitlab_credentials_block_token_with_no_header(
+        self, monkeypatch
+    ):
+        mock_subprocess = MagicMock()
+        monkeypatch.setattr(
+            "prefect.deployments.steps.pull.subprocess",
+            mock_subprocess,
+        )
+        blocks = {
+            "gitlab-credentials": {
+                "my-gitlab-creds-block": MockCredentials("mock-token")
+            }
+        }
+
+        async def mock_read_block_document(self, name: str, block_type_slug: str):
+            return blocks[block_type_slug][name]
+
+        monkeypatch.setattr(
+            "prefect.client.orchestration.PrefectClient.read_block_document_by_name",
+            mock_read_block_document,
+        )
+
+        await MockCredentials("mock-token").save("my-gitlab-creds-block")
+
+        output = await run_step(
+            {
+                "prefect.deployments.steps.git_clone": {
+                    "repository": "https://gitlab.com/org/repo.git",
+                    "credentials": (
+                        "{{ prefect.blocks.gitlab-credentials.my-gitlab-creds-block }}"
+                    ),
+                }
+            }
+        )
+
+        assert output["directory"] == "repo"
+        mock_subprocess.check_call.assert_called_once_with(
+            [
+                "git",
+                "clone",
+                "https://oauth2:mock-token@gitlab.com/org/repo.git",
+                "--depth",
+                "1",
+            ],
+            shell=False,
+            stderr=ANY,
+            stdout=ANY,
+        )
+
 
 class TestRunShellScript:
     async def test_run_shell_script_single_command(self, capsys):