Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@
public abstract class AbstractGithubURLParser {

private static final Logger LOG = LoggerFactory.getLogger(AbstractGithubURLParser.class);

/**
* How many leading path segments may be tried as a branch name. A branch holding more slashes
* than this keeps its path folded into it, as before; the cap is what stops a deep path that
* matches no ref at all from turning into one API call per segment.
*/
private static final int MAX_PROBED_BRANCH_SEGMENTS = 8;

private final PersonalAccessTokenManager tokenManager;
private final DevfileFilenamesProvider devfileFilenamesProvider;
private final GithubApiClient apiClient;
Expand Down Expand Up @@ -291,10 +299,21 @@ private GithubUrl parse(String url, boolean authenticationRequired, @Nullable St
}

String branchFromUrl = null;
String subfolder = null;
String pullRequestId = null;
// Commit of the branch, when splitting the URL already had to look it up. Reused below so
// that the common URL costs one API call, the same as before the split was introduced.
GithubCommit commitFromSplit = null;
if (isHTTPSUrl) {
branchFromUrl = getBranch(matcher.group("path"));
pullRequestId = matcher.group("pullRequestId");
if (pullRequestId == null) {
BranchAndSubfolder split =
resolveBranchAndSubfolder(
serverUrl, repoUser, repoName, matcher.group("path"), authenticationRequired);
branchFromUrl = split.branch;
subfolder = split.subfolder;
commitFromSplit = split.commit;
}
}

if (pullRequestId != null) {
Expand All @@ -318,12 +337,14 @@ private GithubUrl parse(String url, boolean authenticationRequired, @Nullable St

String latestCommit = null;
GithubCommit commit =
getLatestCommit(
serverUrl,
repoUser,
repoName,
isNullOrEmpty(revision) ? firstNonNull(branchFromUrl, "HEAD") : revision,
authenticationRequired);
commitFromSplit != null && isNullOrEmpty(revision)
? commitFromSplit
: getLatestCommit(
serverUrl,
repoUser,
repoName,
isNullOrEmpty(revision) ? firstNonNull(branchFromUrl, "HEAD") : revision,
authenticationRequired);
if (commit != null) {
latestCommit = commit.getSha();
}
Expand All @@ -335,24 +356,140 @@ private GithubUrl parse(String url, boolean authenticationRequired, @Nullable St
.withServerUrl(serverUrl)
.withDisableSubdomainIsolation(disableSubdomainIsolation)
.withBranch(isNullOrEmpty(branchFromUrl) ? revision : branchFromUrl)
.withSubfolder(subfolder)
.withLatestCommit(latestCommit)
.withDevfileFilenames(devfileFilenamesProvider.getConfiguredDevfileFilenames())
.withUrl(url);
}

/*
* Get branch from the file path by extracting the segment before the devfile name.
* Example of a file path: "branch/devfile.yaml" => "branch"
/** A {@code tree} or {@code blob} URL tail split into the ref and the directory below it. */
private static final class BranchAndSubfolder {
private final String branch;
private final String subfolder;

/** Commit of {@code branch}, when the split was confirmed against the API. */
private final GithubCommit commit;

private BranchAndSubfolder(String branch, String subfolder, GithubCommit commit) {
this.branch = branch;
this.subfolder = subfolder;
this.commit = commit;
}
}

/**
* Splits the part of a {@code tree} or {@code blob} URL that follows {@code /tree/} or {@code
* /blob/} into the ref it starts with and the directory below it.
*
* <p>The URL alone does not say where the ref ends: {@code tree/branch/with/slash} and {@code
* tree/branch/sub/dir} have the same shape. Git resolves it, because a ref cannot be a path
* prefix of another ref, {@code refs/heads/branch} being a file where {@code
* refs/heads/branch/with/slash} needs a directory. At most one prefix of the tail is therefore a
* real ref, so asking the API about the prefixes in turn has exactly one answer and the first hit
* is it. Shortest first, so that the common URL, a short branch followed by a path, answers on
* the first question.
*
* <p>A prefix that the API reports as absent is skipped. Anything else, an unreachable server, a
* rejected token, a rate limit, means the refs cannot be enumerated at all rather than that this
* one is missing, so the whole tail is kept as the branch. That is what this method did before it
* could ask, so a URL that resolves today keeps resolving when the API cannot be reached.
*/
private BranchAndSubfolder resolveBranchAndSubfolder(
String serverUrl,
String repoUser,
String repoName,
String path,
boolean authenticationRequired) {
if (path == null) {
return new BranchAndSubfolder(null, null, null);
}
// The devfile name is not part of the directory, and is re-appended by
// GithubUrl#rawFileLocation, so a `blob` URL pointing straight at a devfile is the same
// location as the `tree` URL of the directory holding it.
String tail = trimEnd(stripDevfileName(path), '/');
if (isNullOrEmpty(tail) || !tail.contains("/")) {
// A single segment is the whole ref, there is nothing to split and nothing to ask.
return new BranchAndSubfolder(emptyToNull(tail), null, null);
}

String[] segments = tail.split("/");
int probes = Math.min(segments.length, MAX_PROBED_BRANCH_SEGMENTS);
String token = getTokenValue(serverUrl, authenticationRequired);
GithubApiClient client =
this.apiClient.isConnected(serverUrl) ? this.apiClient : new GithubApiClient(serverUrl);
StringBuilder candidate = new StringBuilder();
for (int i = 0; i < probes; i++) {
if (i > 0) {
candidate.append('/');
}
candidate.append(segments[i]);
String branch = candidate.toString();
try {
GithubCommit commit = client.getLatestCommit(repoUser, repoName, branch, token);
if (commit != null) {
String subfolder =
branch.length() < tail.length() ? tail.substring(branch.length() + 1) : null;
return new BranchAndSubfolder(branch, subfolder, commit);
}
} catch (ScmItemNotFoundException e) {
// Not a ref. A longer prefix still can be, so keep going.
} catch (ScmBadRequestException
| ScmCommunicationException
| ScmUnauthorizedException
| URISyntaxException
| RuntimeException e) {
LOG.debug(
"Not splitting the branch of {}/{} off '{}': {}",
repoUser,
repoName,
tail,
e.toString());
break;
}
}
// No prefix resolved, or the question could not be asked. Keep the tail whole.
return new BranchAndSubfolder(tail, null, null);
}

/** Drops a trailing configured devfile name, leaving the directory holding it. */
private String stripDevfileName(String path) {
return devfileFilenamesProvider.getConfiguredDevfileFilenames().stream()
.filter(devfileName -> path.endsWith("/" + devfileName))
.findAny()
// Cut from the end: a directory may well be named after the devfile too.
.map(devfileName -> path.substring(0, path.length() - devfileName.length() - 1))
.orElse(path);
}

/**
* Returns the token to call the API of {@code githubEndpoint} with, or {@code null} to call it
* anonymously. Acquired once per parse rather than once per probe.
*/
private String getBranch(String path) {
if (path != null && path.contains("/")) {
Optional<String> devfileNameOptional =
devfileFilenamesProvider.getConfiguredDevfileFilenames().stream()
.filter(devfileName -> path.endsWith("/" + devfileName))
.findAny();
return devfileNameOptional.map(s -> path.substring(0, path.indexOf(s) - 1)).orElse(path);
@Nullable
private String getTokenValue(String githubEndpoint, boolean authenticationRequired) {
try {
Subject subject = EnvironmentContext.getCurrent().getSubject();
Optional<PersonalAccessToken> token = tokenManager.get(subject, null, githubEndpoint, null);
if (token.isPresent()) {
return token.get().getToken();
}
if (authenticationRequired) {
// Can hand back no token, in which case the probes go out anonymously.
PersonalAccessToken fetched = tokenManager.fetchAndSave(subject, githubEndpoint);
return fetched == null ? null : fetched.getToken();
}
} catch (ScmCommunicationException
| ScmConfigurationPersistenceException
| UnsatisfiedScmPreconditionException
| UnknownScmProviderException
| ScmUnauthorizedException e) {
LOG.debug("No token for {}, continuing anonymously: {}", githubEndpoint, e.toString());
}
return path;
return null;
}

private static String emptyToNull(String value) {
return isNullOrEmpty(value) ? null : value;
}

private GithubPullRequest getPullRequest(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2012-2025 Red Hat, Inc.
* Copyright (c) 2012-2026 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
Expand Down Expand Up @@ -199,7 +199,11 @@ public GithubCommit getLatestCommit(
try {
String result =
CharStreams.toString(new InputStreamReader(response.body(), Charsets.UTF_8));
return OBJECT_MAPPER.readValue(result, GithubCommit[].class)[0];
GithubCommit[] commits = OBJECT_MAPPER.readValue(result, GithubCommit[].class);
// A branch that exists but holds no commit answers 200 with an empty array. Return
// no commit rather than letting an ArrayIndexOutOfBoundsException escape, which no
// caller declares or handles.
return commits.length == 0 ? null : commits[0];
} catch (IOException e) {
throw new UncheckedIOException(e);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ public class GithubUrl extends DefaultFactoryUrl {
/** Branch name */
private String branch;

/**
* Directory inside the repository the URL points at, without a leading or trailing slash, or
* {@code null} when the URL points at the repository root. Taken from the part of a {@code tree}
* or {@code blob} URL that follows the branch, which is why it can only be filled in once the
* branch is known, see {@code AbstractGithubURLParser#resolveBranchAndSubfolder}.
*/
private String subfolder;

/** SHA of the latest commit in the current branch */
private String latestCommit;

Expand Down Expand Up @@ -127,6 +135,22 @@ protected GithubUrl withBranch(String branch) {
return this;
}

/**
* Gets the directory inside the repository this github url points at.
*
* @return the subfolder, without surrounding slashes, or {@code null} for the repository root
*/
public String getSubfolder() {
return this.subfolder;
}

protected GithubUrl withSubfolder(String subfolder) {
if (!isNullOrEmpty(subfolder)) {
this.subfolder = subfolder;
}
return this;
}

/**
* Retuna SHA of the latest commimt
*
Expand Down Expand Up @@ -206,13 +230,15 @@ public String rawFileLocation(String fileName) {
+ serverUrl.substring(serverUrl.indexOf("://") + 3);
}
}
return new StringJoiner("/")
.add(rawBaseUrl)
.add(username)
.add(repository)
.add(branchName)
.add(fileName)
.toString();
StringJoiner location =
new StringJoiner("/").add(rawBaseUrl).add(username).add(repository).add(branchName);
// The file name is relative to the directory the URL points at, not to the repository root,
// so the subfolder sits between the ref and the file. It cannot be folded into the branch
// instead: once the latest commit is known the ref above is a SHA, not the branch.
if (!isNullOrEmpty(subfolder)) {
location.add(subfolder);
}
return location.add(fileName).toString();
}

@Override
Expand Down
Loading
Loading