Repository: jupyterhub/binderhub (python)

Issue:
Trailing / in repo URL is not stripped
This was first reported by @JuKo007.

### Bug description

I believe that in the the old UI, BinderHub was stripping the leading / in the URL provided by the user. For example, if the user provides

```
https://github.com/gesiscss/Jupyter4NFDI_survey_results/
```

the UI would process as

```
https://github.com/gesiscss/Jupyter4NFDI_survey_results
```

### How to reproduce

1. Go to https://notebooks.gesis.org/ or https://2i2c.mybinder.org/
2. Fill "URL" with `https://github.com/gesiscss/Jupyter4NFDI_survey_results/`

#### Expected behaviour

Launch URL to be

```
https://2i2c.mybinder.org/v2/gh/gesiscss/Jupyter4NFDI_survey_results/HEAD
```

#### Actual behaviour

Launch URL is

```
https://2i2c.mybinder.org/v2/gh/gesiscss/Jupyter4NFDI_survey_results//HEAD
```

Notice the `//`. This is making BinderHub to not be able to resolve the reference.

![Image](https://github.com/user-attachments/assets/b62a3021-113c-44ee-8091-f5627f0af6e6)

Pull request description:
strip trailing slash on github/gist/gitlab URLs
and test detect.regex pattern behavior to make sure this is exercised

closes #1919 

Patch:
diff --git a/binderhub/repoproviders.py b/binderhub/repoproviders.py
index a884f171..dde92746 100644
--- a/binderhub/repoproviders.py
+++ b/binderhub/repoproviders.py
@@ -711,7 +711,7 @@ class GitLabRepoProvider(RepoProvider):
         "displayName": "GitLab",
         "id": "gl",
         "spec": {"validateRegex": r"[^/]+/.+"},
-        "detect": {"regex": "^(https?://gitlab.com/)?(?<repo>.*)"},
+        "detect": {"regex": "^(https?://gitlab.com/)?(?<repo>.*[^/])/?"},
         "repo": {
             "label": "GitLab repository name or URL",
             "placeholder": "example: https://gitlab.com/mosaik/examples/mosaik-tutorials-on-binder or mosaik/examples/mosaik-tutorials-on-binder",
@@ -839,8 +839,8 @@ class GitHubRepoProvider(RepoProvider):
     display_config = {
         "displayName": "GitHub",
         "id": "gh",
-        "spec": {"validateRegex": r".+/.+/.+"},
-        "detect": {"regex": "^(https?://github.com/)?(?<repo>.*)"},
+        "spec": {"validateRegex": r"[^/]+/[^/]+/.+"},
+        "detect": {"regex": "^(https?://github.com/)?(?<repo>.*[^/])/?"},
         "repo": {
             "label": "GitHub repository name or URL",
             "placeholder": "example: yuvipanda/requirements or https://github.com/yuvipanda/requirements",
@@ -1112,8 +1112,8 @@ class GistRepoProvider(GitHubRepoProvider):
     display_config = {
         "displayName": "GitHub Gist",
         "id": "gist",
-        "spec": {"validateRegex": r".+/.+(/.+)"},
-        "detect": {"regex": "^(https?://gist.github.com/)?(?<repo>.*)"},
+        "spec": {"validateRegex": r"[^/]+/[^/]+(/[^/]+)?"},
+        "detect": {"regex": "^(https?://gist.github.com/)?(?<repo>.*[^/])/?"},
         "repo": {
             "label": "Gist ID (username/gistId) or URL",
             "placeholder": "",


Test patch:
diff --git a/binderhub/tests/test_repoproviders.py b/binderhub/tests/test_repoproviders.py
index 4c6ebd02..f914d3ea 100644
--- a/binderhub/tests/test_repoproviders.py
+++ b/binderhub/tests/test_repoproviders.py
@@ -610,3 +610,83 @@ def test_gist_secret():
 
     provider = GistRepoProvider(spec=spec, allow_secret_gist=True)
     assert IOLoop().run_sync(provider.get_resolved_ref) is not None
+
+
+def _js_regex(pat):
+    """compile a javascript regular expression
+
+    converts js '?<name>' to Python '?P<name>' syntax
+    """
+    return re.compile(pat.replace("?<", "?P<"))
+
+
+@pytest.mark.parametrize(
+    "provider, repo, expected_spec",
+    [
+        (
+            GitHubRepoProvider,
+            "https://github.com/org/repo",
+            "org/repo",
+        ),
+        (
+            GitHubRepoProvider,
+            "http://github.com/org/repo/",
+            "org/repo",
+        ),
+        (
+            GitHubRepoProvider,
+            "http://github.com/org/",
+            "org",
+        ),
+        (
+            GitHubRepoProvider,
+            "org/repo",
+            "org/repo",
+        ),
+        (
+            GitHubRepoProvider,
+            "org/repo/",
+            "org/repo",
+        ),
+        (
+            GitLabRepoProvider,
+            "https://gitlab.com/org/repo",
+            "org/repo",
+        ),
+        (
+            GitLabRepoProvider,
+            "https://gitlab.com/org/repo/",
+            "org/repo",
+        ),
+        (
+            GitLabRepoProvider,
+            "org/repo/",
+            "org/repo",
+        ),
+        (
+            GitLabRepoProvider,
+            "org/repo",
+            "org/repo",
+        ),
+        (
+            GistRepoProvider,
+            "user/12345",
+            "user/12345",
+        ),
+        (
+            GistRepoProvider,
+            "user/12345/",
+            "user/12345",
+        ),
+    ],
+)
+def test_detect_regex(provider, repo, expected_spec):
+    config = provider.display_config
+    detect_regex = _js_regex(config["detect"]["regex"])
+
+    match = detect_regex.match(repo)
+    if expected_spec is None and match is None:
+        # ok, no match expected
+        return
+    repo = match.group("repo")
+    assert repo == expected_spec