Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Autodetect pasted url and fill in form fields #1804

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions binderhub/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ def generate_config(self):
"repo_providers"
].items():
config[repo_provider_class_alias] = repo_provider_class.labels
config[repo_provider_class_alias][
"display_name"
] = repo_provider_class.display_name
config[repo_provider_class_alias][
"regex_detect"
] = repo_provider_class.regex_detect
return config

async def get(self):
Expand Down
29 changes: 29 additions & 0 deletions binderhub/repoproviders.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ class RepoProvider(LoggingConfigurable):
config=True,
)

# Not a traitlet because the class property is serialised in
# config.ConfigHandler.generate_config()
regex_detect = None

unresolved_ref = Unicode()

git_credentials = Unicode(
Expand Down Expand Up @@ -192,6 +196,15 @@ def is_valid_sha1(sha1):
class FakeProvider(RepoProvider):
"""Fake provider for local testing of the UI"""

name = Unicode("Fake")

display_name = "Fake GitHub"

regex_detect = [
r"^https://github\.com/(?<repo>[^/]+/[^/]+)(/blob/(?<ref>[^/]+)(/(?<filepath>.+))?)?$",
r"^https://github\.com/(?<repo>[^/]+/[^/]+)(/tree/(?<ref>[^/]+)(/(?<urlpath>.+))?)?$",
]

labels = {
"text": "Fake Provider",
"tag_text": "Fake Ref",
Expand Down Expand Up @@ -627,6 +640,13 @@ def _default_git_credentials(self):
return rf"username=binderhub\npassword={self.private_token}"
return ""

# Gitlab repos can be nested under projects
_regex_detect_base = r"^https://gitlab\.com/(?<repo>[^/]+/[^/]+(/[^/-][^/]+)*)"
regex_detect = [
_regex_detect_base + r"(/-/blob/(?<ref>[^/]+)(/(?<filepath>.+))?)?$",
_regex_detect_base + r"(/-/tree/(?<ref>[^/]+)(/(?<urlpath>.+))?)?$",
]

labels = {
"text": "GitLab.com repository or URL",
"tag_text": "Git ref (branch, tag, or commit)",
Expand Down Expand Up @@ -780,6 +800,11 @@ def _default_git_credentials(self):
return rf"username={self.access_token}\npassword=x-oauth-basic"
return ""

regex_detect = [
r"^https://github\.com/(?<repo>[^/]+/[^/]+)(/blob/(?<ref>[^/]+)(/(?<filepath>.+))?)?$",
r"^https://github\.com/(?<repo>[^/]+/[^/]+)(/tree/(?<ref>[^/]+)(/(?<urlpath>.+))?)?$",
]

labels = {
"text": "GitHub repository name or URL",
"tag_text": "Git ref (branch, tag, or commit)",
Expand Down Expand Up @@ -973,6 +998,10 @@ class GistRepoProvider(GitHubRepoProvider):
help="Flag for allowing usages of secret Gists. The default behavior is to disallow secret gists.",
)

regex_detect = [
r"^https://gist\.github\.com/(?<repo>[^/]+/[^/]+)(/(?<ref>[^/]+))?$"
]

labels = {
"text": "Gist ID (username/gistId) or URL",
"tag_text": "Git commit SHA",
Expand Down
16 changes: 14 additions & 2 deletions binderhub/static/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import "../index.css";
import { setUpLog } from "./src/log";
import { updateUrls } from "./src/urls";
import { getBuildFormValues } from "./src/form";
import { updateRepoText } from "./src/repo";
import { detectPastedRepo, updateRepoText } from "./src/repo";

/**
* @type {URL}
Expand Down Expand Up @@ -166,7 +166,19 @@ function indexMain() {
updatePathText();
updateRepoText(BASE_URL);

$("#repository").on("keyup paste change", function () {
// If the user pastes a URL into the repository field try to autodetect
// In all other cases don't do anything to avoid overwriting the user's input
// We need to wait for the paste to complete before we can read the input field
// https://stackoverflow.com/questions/10972954/javascript-onpaste/10972973#10972973
$("#repository").on("paste", () => {
setTimeout(() => {
detectPastedRepo(BASE_URL).then(() => {
updateUrls(BADGE_BASE_URL);
});
}, 0);
});

$("#repository").on("keyup change", function () {
updateUrls(BADGE_BASE_URL);
});

Expand Down
56 changes: 42 additions & 14 deletions binderhub/static/js/src/repo.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { detect, getRepoProviders } from "@jupyterhub/binderhub-client";
import { updatePathText } from "./path";

/**
* Dict holding cached values of API request to _config endpoint
* @param {Object} configDict Dict holding cached values of API request to _config endpoint
*/
let configDict = {};

function setLabels() {
function setLabels(configDict) {
const provider = $("#provider_prefix").val();
const text = configDict[provider]["text"];
const tagText = configDict[provider]["tag_text"];
Expand All @@ -23,15 +24,42 @@ function setLabels() {
* @param {URL} baseUrl Base URL to use for constructing path to _config endpoint
*/
export function updateRepoText(baseUrl) {
if (Object.keys(configDict).length === 0) {
const configUrl = new URL("_config", baseUrl);
fetch(configUrl).then((resp) => {
resp.json().then((data) => {
configDict = data;
setLabels();
});
});
} else {
setLabels();
getRepoProviders(baseUrl).then(setLabels);
}

/**
* Attempt to fill in all fields by parsing a pasted repository URL
*
* @param {URL} baseUrl Base URL to use for constructing path to _config endpoint
*/
export async function detectPastedRepo(baseUrl) {
const repoField = $("#repository").val().trim();
const fields = await detect(baseUrl, repoField);
// Special case: The BinderHub UI supports https://git{hub,lab}.com/ in the
// repository (it's stripped out later in the UI).
// To keep the UI consistent insert it back if it was originally included.
console.log(fields);
if (fields) {
let repo = fields.repository;
if (repoField.startsWith("https://github.com/")) {
repo = "https://github.com/" + repo;
}
if (repoField.startsWith("https://gitlab.com/")) {
repo = "https://gitlab.com/" + repo;
}
$("#provider_prefix-selected").text(fields.providerName);
$("#provider_prefix").val(fields.providerPrefix);
$("#repository").val(repo);
if (fields.ref) {
$("#ref").val(fields.ref);
}
if (fields.path) {
$("#filepath").val(fields.path);
$("#url-or-file-selected").text(
fields.pathType === "filepath" ? "File" : "URL",
);
}
updatePathText();
updateRepoText(baseUrl);
}
}
111 changes: 111 additions & 0 deletions binderhub/tests/test_repoproviders.py
Original file line number Diff line number Diff line change
Expand Up @@ -539,3 +539,114 @@ def test_gist_secret():

provider = GistRepoProvider(spec=spec, allow_secret_gist=True)
assert IOLoop().run_sync(provider.get_resolved_ref) is not None


@pytest.mark.parametrize(
"provider,url,groupdict",
[
(
GitHubRepoProvider,
"https://github.com/binder-examples/conda",
{"repo": "binder-examples/conda", "filepath": None, "ref": None},
),
(
GitHubRepoProvider,
"https://github.com/binder-examples/conda/blob/main/index.ipynb",
{"repo": "binder-examples/conda", "ref": "main", "filepath": "index.ipynb"},
),
(
GitHubRepoProvider,
"https://github.com/binder-examples/conda/tree/main/.github/workflows",
{
"repo": "binder-examples/conda",
"ref": "main",
"urlpath": ".github/workflows",
},
),
(GitHubRepoProvider, "https://github.com/binder-examples/conda/pulls", None),
(
GitLabRepoProvider,
"https://gitlab.com/owner/repo",
{
"repo": "owner/repo",
"ref": None,
"filepath": None,
},
),
(
GitLabRepoProvider,
"https://gitlab.com/owner/repo/-/tree/branch/folder?ref_type=heads",
{"repo": "owner/repo", "ref": "branch", "urlpath": "folder?ref_type=heads"},
),
(
GitLabRepoProvider,
"https://gitlab.com/owner/repo/-/blob/branch/README.md?ref_type=heads",
{
"repo": "owner/repo",
"ref": "branch",
"filepath": "README.md?ref_type=heads",
},
),
(
GitLabRepoProvider,
"https://gitlab.com/owner/project/repo",
{
"repo": "owner/project/repo",
"ref": None,
"filepath": None,
},
),
(
GitLabRepoProvider,
"https://gitlab.com/owner/project/repo/-/tree/branch/folder?ref_type=heads",
{
"repo": "owner/project/repo",
"ref": "branch",
"urlpath": "folder?ref_type=heads",
},
),
(
GitLabRepoProvider,
"https://gitlab.com/owner/project/repo/-/blob/branch/README.md?ref_type=heads",
{
"repo": "owner/project/repo",
"ref": "branch",
"filepath": "README.md?ref_type=heads",
},
),
(
GitLabRepoProvider,
"https://gitlab.com/owner/repo/-/merge_requests/123",
None,
),
(
GistRepoProvider,
"https://gist.github.com/owner/0123456789abcde0123456789abcde01",
{
"repo": "owner/0123456789abcde0123456789abcde01",
"ref": None,
},
),
(
GistRepoProvider,
"https://gist.github.com/owner/0123456789abcde0123456789abcde01/sha",
{
"repo": "owner/0123456789abcde0123456789abcde01",
"ref": "sha",
},
),
(GistRepoProvider, "https://gist.github.com/owner", None),
],
)
def test_provider_regex_detect(provider, url, groupdict):
regex_js = provider.regex_detect
regex_py = [r.replace("(?<", "(?P<") for r in regex_js]
m = None
for r in regex_py:
m = re.match(r, url)
if m:
break
if groupdict:
assert m.groupdict() == groupdict
else:
assert not m
60 changes: 60 additions & 0 deletions js/packages/binderhub-client/lib/autodetect.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Dict holding cached values of API request to _config endpoint for base URL
*/
let repoProviders = {};

/**
* Get the repo provider configurations supported by the BinderHub instance
*
* @param {URL} baseUrl Base URL to use for constructing path to _config endpoint
*/
export async function getRepoProviders(baseUrl) {
if (!repoProviders[baseUrl]) {
const configUrl = new URL("_config", baseUrl);
const resp = await fetch(configUrl);
repoProviders[baseUrl] = resp.json();
}
return repoProviders[baseUrl];
}

/**
* Attempt to parse a string (typically a repository URL) into a BinderHub
* provider/repository/reference/path
*
* @param {URL} baseUrl Base URL to use for constructing path to _config endpoint
* @param {string} text Repository URL or similar to parse
* @returns {Object} An object if the repository could be parsed with fields
* - providerPrefix Prefix denoting what provider was selected
* - repository Repository to build
* - ref Ref in this repo to build (optional)
* - path Path to launch after this repo has been built (optional)
* - pathType Type of thing to open path with (raw url, notebook file) (optional)
* - providerName User friendly display name of the provider (optional)
* null otherwise
*/
export async function detect(baseUrl, text) {
const config = await getRepoProviders(baseUrl);

for (const provider in config) {
const regex_detect = config[provider].regex_detect || [];
for (const regex of regex_detect) {
const m = text.match(regex);
if (m?.groups.repo) {
return {
providerPrefix: provider,
repository: m.groups.repo,
ref: m.groups.ref || null,
path: m.groups.filepath || m.groups.urlpath || null,
pathType: m.groups.filepath
? "filepath"
: m.groups.urlpath
? "urlpath"
: null,
providerName: config[provider].display_name,
};
}
}
}

return null;
}
4 changes: 4 additions & 0 deletions js/packages/binderhub-client/lib/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { NativeEventSource, EventSourcePolyfill } from "event-source-polyfill";
import { EventIterator } from "event-iterator";

import { detect, getRepoProviders } from "./autodetect";

// Use native browser EventSource if available, and use the polyfill if not available
const EventSource = NativeEventSource || EventSourcePolyfill;

Expand Down Expand Up @@ -211,3 +213,5 @@ export function makeBadgeMarkup(publicBaseUrl, url, syntax) {
);
}
}

export { detect, getRepoProviders };
Loading