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

[JENKINS-64814] Use API to retrieve project/group avatar where possible #436

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
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 @@ -93,6 +93,11 @@
* The GitLab server name configured in Jenkins.
*/
private String serverName;

/**
* The full version of the GitLab server.
*/
private String serverVersion;
/**
* The default credentials to use for checking out).
*/
Expand Down Expand Up @@ -206,218 +211,227 @@
private GitLabOwner getGitlabOwner(GitLabApi gitLabApi) {
if (gitlabOwner == null) {
gitlabOwner = GitLabOwner.fetchOwner(gitLabApi, projectOwner);
try {
serverVersion = gitLabApi.getVersion().getVersion();
} catch (GitLabApiException e) {
serverVersion = "0.0";
}
}
return gitlabOwner;
}

/**
* Sets the behavioral traits that are applied to this navigator and any {@link
* GitLabSCMSource} instances it discovers. The new traits will take affect on
* the next
* navigation through any of the {@link #visitSources(SCMSourceObserver)}
* overloads or {@link
* #visitSource(String, SCMSourceObserver)}.
*
* @param traits the new behavioral traits.
*/
@Override
public void setTraits(@CheckForNull List<SCMTrait<? extends SCMTrait<?>>> traits) {
this.traits = traits != null ? new ArrayList<>(traits) : new ArrayList<>();
}

@NonNull
@Override
protected String id() {
return getServerUrlFromName(serverName) + "::" + projectOwner;
}

@Override
public void visitSources(@NonNull final SCMSourceObserver observer) throws IOException, InterruptedException {
GitLabSCMNavigatorContext context = new GitLabSCMNavigatorContext().withTraits(traits);
try (GitLabSCMNavigatorRequest request = context.newRequest(this, observer)) {
GitLabApi gitLabApi = apiBuilder(observer.getContext(), serverName);
getGitlabOwner(gitLabApi);
List<Project> projects;
if (gitlabOwner instanceof GitLabUser) {
// Even returns the group projects owned by the user
projects = gitLabApi.getProjectApi().getUserProjects(projectOwner, new ProjectFilter().withOwned(true));
} else {
isGroup = true;
GroupProjectsFilter groupProjectsFilter = new GroupProjectsFilter();
wantSubGroupProjects = request.wantSubgroupProjects();
groupProjectsFilter.withIncludeSubGroups(wantSubGroupProjects);
groupProjectsFilter.withShared(request.wantSharedProjects());
// If projectOwner is a subgroup, it will only return projects in the subgroup
projects = gitLabApi.getGroupApi().getProjects(projectOwner, groupProjectsFilter);
}
int count = 0;
observer.getListener().getLogger().format("%nChecking projects...%n");
StandardCredentials webHookCredentials = getWebHookCredentials(observer.getContext());
GitLabApi webhookGitLabApi = null;
String webHookUrl = null;
if (webHookCredentials != null) {
GitLabServer server = GitLabServers.get().findServer(serverName);
String serverUrl = getServerUrl(server);
webhookGitLabApi = new GitLabApi(
serverUrl, getPrivateTokenAsPlainText(webHookCredentials), null, getProxyConfig(serverUrl));
webHookUrl = GitLabHookCreator.getHookUrl(server, true);
}
projects = projects.stream().filter(Objects::nonNull).collect(Collectors.toList());
for (Project p : projects) {
count++;
String projectPathWithNamespace = p.getPathWithNamespace();
String projectOwner = getProjectOwnerFromNamespace(projectPathWithNamespace);
String projectName = getProjectName(gitLabApi, request.withProjectNamingStrategy(), p);
getNavigatorProjects().add(projectPathWithNamespace);
if (StringUtils.isEmpty(p.getDefaultBranch())) {
observer.getListener()
.getLogger()
.format(
"%nIgnoring project with empty repository %s%n",
HyperlinkNote.encodeTo(p.getWebUrl(), p.getName()));
continue;
}
if (p.getArchived() && context.isExcludeArchivedRepositories()) {
observer.getListener()
.getLogger()
.format(
"%nIgnoring archived project %s%n",
HyperlinkNote.encodeTo(p.getWebUrl(), p.getName()));
continue;
}
observer.getListener()
.getLogger()
.format("%nChecking project %s%n", HyperlinkNote.encodeTo(p.getWebUrl(), projectName));
try {
GitLabServer server = GitLabServers.get().findServer(serverName);
if (webhookGitLabApi != null && webHookUrl != null) {
String secretToken = server.getSecretTokenAsPlainText();
if (secretToken == null) {
// sending 'null' to GitLab will ignore the value, when we want to update it to be empty.
secretToken = "";
}
observer.getListener()
.getLogger()
.format(
"Web hook %s%n",
GitLabHookCreator.createWebHookWhenMissing(
webhookGitLabApi, projectPathWithNamespace, webHookUrl, secretToken));
}
} catch (GitLabApiException e) {
observer.getListener().getLogger().format("Cannot set web hook: %s%n", e.getReason());
}
if (request.process(
projectName,
name -> new GitLabSCMSourceBuilder(
getId() + "::" + projectPathWithNamespace,
serverName,
credentialsId,
projectOwner,
projectPathWithNamespace,
name)
.withTraits(traits)
.build(),
null,
(Witness) (name, isMatch) -> {
if (isMatch) {
observer.getListener().getLogger().format("Proposing %s%n", name);
} else {
observer.getListener().getLogger().format("Ignoring %s%n", name);
}
})) {
observer.getListener().getLogger().format("%n%d projects were processed (query complete)%n", count);
return;
}
}
observer.getListener().getLogger().format("%n%d projects were processed%n", count);
} catch (GitLabApiException | URISyntaxException e) {
LOGGER.log(Level.WARNING, "Exception caught:" + e, e);
throw new IOException("Failed to visit SCM source", e);
}
}

@NonNull
private String getProjectName(GitLabApi gitLabApi, int projectNamingStrategy, Project project)
throws URISyntaxException {
String fullPath = project.getPathWithNamespace();
String projectName;
switch (projectNamingStrategy) {
default:
// for legacy reasons default naming strategy is set to Full Project path
case 1:
projectName = fullPath;
break;
case 2:
// Project name
projectName = project.getNameWithNamespace()
.replace(
String.format("%s / ", getGitlabOwner(gitLabApi).getFullName()), "");
break;
case 3:
// Contextual project path
URI ownerPathUri = new URI(projectOwner);
URI fullPathUri = new URI(fullPath);
projectName = ownerPathUri.relativize(fullPathUri).toString();
break;
case 4:
// Simple project path
projectName = fullPath.substring(fullPath.lastIndexOf('/') + 1);
break;
}
return projectName;
}

private StandardCredentials getWebHookCredentials(SCMSourceOwner owner) {
StandardCredentials credentials = null;
GitLabServer server = GitLabServers.get().findServer(getServerName());
if (server == null) {
return null;
}
GitLabSCMNavigatorContext navigatorContext = new GitLabSCMNavigatorContext().withTraits(traits);
GitLabSCMSourceContext ctx =
new GitLabSCMSourceContext(null, SCMHeadObserver.none()).withTraits(navigatorContext.traits());
GitLabHookRegistration webhookMode = ctx.webhookRegistration();
switch (webhookMode) {
case DISABLE:
break;
case SYSTEM:
if (!server.isManageWebHooks()) {
break;
}
credentials = server.getCredentials(owner);
if (credentials == null) {
LOGGER.log(Level.WARNING, "No System credentials added, cannot create web hook");
}
break;
case ITEM:
credentials = credentials(owner);
if (credentials == null) {
LOGGER.log(Level.WARNING, "No Item credentials added, cannot create web hook");
}
break;
default:
return null;
}
return credentials;
}

@NonNull
@Override
protected List<Action> retrieveActions(
@NonNull SCMNavigatorOwner owner, SCMNavigatorEvent event, @NonNull TaskListener listener)
throws IOException, InterruptedException {
getGitlabOwner(owner);
String fullName = gitlabOwner.getFullName();
String webUrl = gitlabOwner.getWebUrl();
String avatarUrl = gitlabOwner.getAvatarUrl();
String description = null;
if (gitlabOwner instanceof GitLabGroup) {
description = ((GitLabGroup) gitlabOwner).getDescription();
}
List<Action> result = new ArrayList<>();
result.add(new ObjectMetadataAction(Util.fixEmpty(fullName), description, webUrl));
if (StringUtils.isNotBlank(avatarUrl)) {
result.add(new GitLabAvatar(avatarUrl));
if (GitLabServer.groupAvatarsApiAvailable(serverVersion)) {
result.add(new GitLabAvatar(avatarUrl, serverName, projectOwner, false));

Check warning on line 431 in src/main/java/io/jenkins/plugins/gitlabbranchsource/GitLabSCMNavigator.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 215-431 are not covered by tests
} else {
result.add(new GitLabAvatar(avatarUrl));
}
}
result.add(GitLabLink.toGroup(webUrl));
if (StringUtils.isBlank(webUrl)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@

public static final Logger LOGGER = Logger.getLogger(GitLabSCMSource.class.getName());
private final String serverName;
private String serverVersion;
private final String projectOwner;
private final String projectPath;
private String projectName;
Expand Down Expand Up @@ -218,405 +219,414 @@
} catch (GitLabApiException e) {
throw new IllegalStateException("Failed to retrieve project " + projectPath, e);
}
try {
serverVersion = gitLabApi.getVersion().getVersion();
} catch (GitLabApiException e) {
serverVersion = "0.0";
}
}
return gitlabProject;
}

// This method always returns the latest list of members of the project
public HashMap<String, AccessLevel> getMembers() {
HashMap<String, AccessLevel> members = new HashMap<>();
try {
GitLabApi gitLabApi = apiBuilder(this.getOwner(), serverName);
for (Member m : gitLabApi.getProjectApi().getAllMembers(projectPath)) {
members.put(m.getUsername(), m.getAccessLevel());
}
} catch (GitLabApiException e) {
LOGGER.log(Level.WARNING, "Exception while fetching members" + e, e);
return new HashMap<>();
}
return members;
}

public long getProjectId() {
return projectId;
}

@DataBoundSetter
public void setProjectId(long projectId) {
this.projectId = projectId;
}

@NonNull
@Override
public List<SCMSourceTrait> getTraits() {
return Collections.unmodifiableList(traits);
}

@DataBoundSetter
public void setTraits(List<SCMSourceTrait> traits) {
this.traits = new ArrayList<>(Util.fixNull(traits));
}

@Override
protected SCMRevision retrieve(@NonNull SCMHead head, @NonNull TaskListener listener)
throws IOException, InterruptedException {
try {
GitLabApi gitLabApi = apiBuilder(this.getOwner(), serverName);
getGitlabProject(gitLabApi);
if (head instanceof BranchSCMHead) {
listener.getLogger().format("Querying the current revision of branch %s...%n", head.getName());
String revision = gitLabApi
.getRepositoryApi()
.getBranch(gitlabProject, head.getName())
.getCommit()
.getId();
listener.getLogger().format("Current revision of branch %s is %s%n", head.getName(), revision);
return new BranchSCMRevision((BranchSCMHead) head, revision);
} else if (head instanceof MergeRequestSCMHead) {
MergeRequestSCMHead h = (MergeRequestSCMHead) head;
listener.getLogger().format("Querying the current revision of merge request #%s...%n", h.getId());
MergeRequest mr =
gitLabApi.getMergeRequestApi().getMergeRequest(gitlabProject, Long.parseLong(h.getId()));
String targetSha = gitLabApi
.getRepositoryApi()
.getBranch(mr.getTargetProjectId(), mr.getTargetBranch())
.getCommit()
.getId();
if (mr.getState().equals(Constants.MergeRequestState.OPENED.toString())) {
listener.getLogger()
.format("Current revision of merge request #%s is %s%n", h.getId(), mr.getSha());
return new MergeRequestSCMRevision(
h,
new BranchSCMRevision(h.getTarget(), targetSha),
new BranchSCMRevision(new BranchSCMHead(h.getOriginName()), mr.getSha()));
} else {
listener.getLogger().format("Merge request #%s is CLOSED%n", h.getId());
return null;
}
} else if (head instanceof GitLabTagSCMHead) {
listener.getLogger().format("Querying the current revision of tag %s...%n", head.getName());
String revision = gitLabApi
.getTagsApi()
.getTag(gitlabProject, head.getName())
.getCommit()
.getId();
listener.getLogger().format("Current revision of tag %s is %s%n", head.getName(), revision);
return new GitTagSCMRevision((GitLabTagSCMHead) head, revision);
} else {
listener.getLogger()
.format(
"Unknown head: %s of type %s%n",
head.getName(), head.getClass().getName());
return null;
}
} catch (GitLabApiException e) {
LOGGER.log(Level.WARNING, "Exception caught:" + e, e);
throw new IOException("Failed to retrieve the SCM revision for " + head.getName(), e);
}
}

@Override
protected void retrieve(
SCMSourceCriteria criteria,
@NonNull SCMHeadObserver observer,
SCMHeadEvent<?> event,
@NonNull TaskListener listener)
throws IOException, InterruptedException {
try {
GitLabApi gitLabApi = apiBuilder(this.getOwner(), serverName);
getGitlabProject(gitLabApi);
GitLabSCMSourceContext ctx = new GitLabSCMSourceContext(criteria, observer).withTraits(getTraits());
try (GitLabSCMSourceRequest request = ctx.newRequest(this, listener)) {
request.setGitLabApi(gitLabApi);
request.setProject(gitlabProject);
request.setMembers(getMembers());
if (request.isFetchBranches()) {
request.setBranches(gitLabApi.getRepositoryApi().getBranches(gitlabProject));
}
if (request.isFetchMRs() && gitlabProject.getMergeRequestsEnabled()) {
if (!ctx.buildMRForksNotMirror() && gitlabProject.getForkedFromProject() != null) {
listener.getLogger().format("%nIgnoring merge requests as project is a mirror...%n");
} else {
// If not authenticated GitLabApi cannot detect if it is a fork
// If `forkedFromProject` is null it doesn't mean anything
listener.getLogger()
.format(
gitlabProject.getForkedFromProject() == null
? "%nUnable to detect if it is a mirror or not still fetching MRs anyway...%n"
: "%nCollecting MRs for fork except those that target its upstream...%n");
Stream<MergeRequest> mrs =
gitLabApi
.getMergeRequestApi()
.getMergeRequests(gitlabProject, MergeRequestState.OPENED)
.stream()
.filter(mr -> mr.getSourceProjectId() != null);
if (ctx.buildMRForksNotMirror()) {
mrs = mrs.filter(mr -> !mr.getTargetProjectId()
.equals(gitlabProject.getForkedFromProject().getId()));
}

if (ctx.alwaysIgnoreMRWorkInProgress()) {
mrs = mrs.filter(mr -> !mr.getWorkInProgress());
}

request.setMergeRequests(mrs.collect(Collectors.toList()));
}
}
if (request.isFetchTags()) {
request.setTags(gitLabApi.getTagsApi().getTags(gitlabProject));
}
if (request.isFetchBranches()) {
int count = 0;
listener.getLogger().format("%nChecking branches.. %n");
Iterable<Branch> branches = request.getBranches();
for (final Branch branch : branches) {
count++;
String branchName = branch.getName();
String sha = branch.getCommit().getId();
listener.getLogger()
.format(
"%nChecking branch %s%n",
HyperlinkNote.encodeTo(
branchUriTemplate(gitlabProject.getWebUrl())
.set("branch", splitPath(branchName))
.expand(),
branchName));
if (request.process(
new BranchSCMHead(branchName),
(SCMSourceRequest.RevisionLambda<BranchSCMHead, BranchSCMRevision>)
head -> new BranchSCMRevision(head, sha),
new SCMSourceRequest.ProbeLambda<BranchSCMHead, BranchSCMRevision>() {
@NonNull
@Override
public SCMSourceCriteria.Probe create(
@NonNull BranchSCMHead head, @Nullable BranchSCMRevision revision)
throws IOException {
return createProbe(head, revision);
}
},
(SCMSourceRequest.Witness) (head, revision, isMatch) -> {
if (isMatch) {
listener.getLogger().format("Met criteria%n");
} else {
listener.getLogger().format("Does not meet criteria%n");
}
})) {
listener.getLogger().format("%n%d branches were processed (query completed)%n", count);
return;
}
}
listener.getLogger().format("%n%d branches were processed%n", count);
}
if (request.isFetchMRs() && !request.isComplete() && gitlabProject.getMergeRequestsEnabled()) {
int count = 0;
listener.getLogger().format("%nChecking merge requests..%n");
HashMap<Long, String> forkMrSources = new HashMap<>();
for (MergeRequest mr : request.getMergeRequests()) {
mergeRequestContributorCache.put(
mr.getIid(),
new ContributorMetadataAction(
mr.getAuthor().getUsername(),
mr.getAuthor().getName(),
mr.getAuthor().getEmail()));
mergeRequestMetadataCache.put(
mr.getIid(),
new ObjectMetadataAction(mr.getTitle(), mr.getDescription(), mr.getWebUrl()));
count++;
listener.getLogger()
.format(
"%nChecking merge request %s%n",
HyperlinkNote.encodeTo(
mergeRequestUriTemplate(gitlabProject.getWebUrl())
.set("iid", mr.getIid())
.expand(),
"!" + mr.getIid()));
Map<Boolean, Set<ChangeRequestCheckoutStrategy>> strategies = request.getMRStrategies();
boolean fork = !mr.getSourceProjectId().equals(mr.getTargetProjectId());
String originOwner = mr.getAuthor().getUsername();
String originProjectPath = projectPath;
if (fork && !forkMrSources.containsKey(mr.getSourceProjectId())) {
// This is a hack to get the path with namespace of source project for forked
// mrs
try {
originProjectPath = gitLabApi
.getProjectApi()
.getProject(mr.getSourceProjectId())
.getPathWithNamespace();
forkMrSources.put(mr.getSourceProjectId(), originProjectPath);
} catch (GitLabApiException e) {
if (e.getHttpStatus() == 404) {
listener.getLogger()
.format(
"%nIgnoring merge requests as source project not found, Please check permission on source repo...%n");
continue;
} else {
throw e;
}
}
} else if (fork) {
originProjectPath = forkMrSources.get(mr.getSourceProjectId());
}
String targetSha;
try {
targetSha = gitLabApi
.getRepositoryApi()
.getBranch(mr.getTargetProjectId(), mr.getTargetBranch())
.getCommit()
.getId();
} catch (Exception e) {
listener.getLogger()
.format(
"Failed getting TargetBranch from Merge Request: " + mr.getIid() + " ("
+ mr.getTitle() + ")%n%s",
e);
continue;
}
LOGGER.log(
Level.FINE,
String.format(
"%s -> %s",
originOwner, (request.isMember(originOwner) ? "Trusted" : "Untrusted")));
for (ChangeRequestCheckoutStrategy strategy : strategies.get(fork)) {
if (request.process(
new MergeRequestSCMHead(
"MR-" + mr.getIid()
+ (strategies.get(fork).size() > 1
? "-"
+ strategy.name()
.toLowerCase(Locale.ENGLISH)
: ""),
mr.getIid(),
new BranchSCMHead(mr.getTargetBranch()),
strategy,
fork ? new SCMHeadOrigin.Fork(originProjectPath) : SCMHeadOrigin.DEFAULT,
originOwner,
originProjectPath,
mr.getSourceBranch(),
mr.getTitle()),
(SCMSourceRequest.RevisionLambda<MergeRequestSCMHead, MergeRequestSCMRevision>)
head -> new MergeRequestSCMRevision(
head,
new BranchSCMRevision(
head.getTarget(),
targetSha // Latest revision of target branch
),
new BranchSCMRevision(
new BranchSCMHead(head.getOriginName()), mr.getSha())),
new SCMSourceRequest.ProbeLambda<MergeRequestSCMHead, MergeRequestSCMRevision>() {
@NonNull
@Override
public SCMSourceCriteria.Probe create(
@NonNull MergeRequestSCMHead head,
@Nullable MergeRequestSCMRevision revision)
throws IOException, InterruptedException {
boolean isTrusted = request.isTrusted(head);
if (!isTrusted) {
listener.getLogger().format("(not from a trusted source)%n");
}
return createProbe(isTrusted ? head : head.getTarget(), revision);
}
},
(SCMSourceRequest.Witness) (head, revision, isMatch) -> {
if (isMatch) {
listener.getLogger().format("Met criteria%n");
} else {
listener.getLogger().format("Does not meet criteria%n");
}
})) {
listener.getLogger()
.format("%n%d merge requests were processed (query completed)%n", count);
return;
}
}
}
listener.getLogger().format("%n%d merge requests were processed%n", count);
}
if (request.isFetchTags()) {
int count = 0;
listener.getLogger().format("%nChecking tags..%n");
Iterable<Tag> tags = request.getTags();
for (Tag tag : tags) {
count++;
String tagName = tag.getName();
Long tagDate = tag.getCommit().getCommittedDate().getTime();
String sha = tag.getCommit().getId();
listener.getLogger()
.format(
"%nChecking tag %s%n",
HyperlinkNote.encodeTo(
tagUriTemplate(gitlabProject.getWebUrl())
.set("tag", splitPath(tag.getName()))
.expand(),
tag.getName()));
GitLabTagSCMHead head = new GitLabTagSCMHead(tagName, tagDate);
if (request.process(
head,
new GitTagSCMRevision(head, sha),
new SCMSourceRequest.ProbeLambda<GitLabTagSCMHead, GitTagSCMRevision>() {
@NonNull
@Override
public SCMSourceCriteria.Probe create(
@NonNull GitLabTagSCMHead head, @Nullable GitTagSCMRevision revision)
throws IOException {
return createProbe(head, revision);
}
},
(SCMSourceRequest.Witness) (head1, revision, isMatch) -> {
if (isMatch) {
listener.getLogger().format("Met criteria%n");
} else {
listener.getLogger().format("Does not meet criteria%n");
}
})) {
listener.getLogger().format("%n%d tags were processed (query completed)%n", count);
return;
}
}
listener.getLogger().format("%n%d tags were processed (query completed)%n", count);
}
}
} catch (GitLabApiException e) {
LOGGER.log(Level.WARNING, "Exception caught:" + e, e);
throw new IOException("Failed to fetch latest heads", e);
} finally {
SCMSourceOwner owner = this.getOwner();
if (owner != null) {
owner.save();
}
}
}

@Override
protected SCMRevision retrieve(@NonNull String thingName, @NonNull TaskListener listener)
throws IOException, InterruptedException {
SCMHeadObserver.Named baptist = SCMHeadObserver.named(thingName);
retrieve(null, baptist, null, listener);
return baptist.result();
}

@NonNull
@Override
protected Set<String> retrieveRevisions(@NonNull TaskListener listener) throws IOException, InterruptedException {
// don't pass through to git, instead use the super.super behaviour
Set<String> revisions = new HashSet<>();
for (SCMHead head : retrieve(listener)) {
revisions.add(head.getName());
}
return revisions;
}

@NonNull
@Override
protected List<Action> retrieveActions(SCMSourceEvent event, @NonNull TaskListener listener) {
List<Action> result = new ArrayList<>();
getGitlabProject();
GitLabSCMSourceContext ctx = new GitLabSCMSourceContext(null, SCMHeadObserver.none()).withTraits(traits);
String projectUrl = gitlabProject.getWebUrl();
String name = StringUtils.isBlank(projectName) ? gitlabProject.getNameWithNamespace() : projectName;
result.add(new ObjectMetadataAction(name, gitlabProject.getDescription(), projectUrl));
String avatarUrl = gitlabProject.getAvatarUrl();
if (!ctx.projectAvatarDisabled() && StringUtils.isNotBlank(avatarUrl)) {
result.add(new GitLabAvatar(avatarUrl));
if (GitLabServer.projectAvatarsApiAvailable(serverVersion)) {
result.add(new GitLabAvatar(avatarUrl, serverName, projectPath, true));

Check warning on line 626 in src/main/java/io/jenkins/plugins/gitlabbranchsource/GitLabSCMSource.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 223-626 are not covered by tests
} else {
result.add(new GitLabAvatar(avatarUrl));
}
}
result.add(GitLabLink.toProject(projectUrl));
return result;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,39 +1,54 @@
package io.jenkins.plugins.gitlabbranchsource.helpers;

import edu.umd.cs.findbugs.annotations.NonNull;
import io.jenkins.cli.shaded.org.apache.commons.lang.StringUtils;
import java.util.Objects;
import jenkins.scm.api.metadata.AvatarMetadataAction;
import org.apache.commons.lang.StringUtils;

public class GitLabAvatar extends AvatarMetadataAction {

private final GitLabAvatarLocation location;

/**
* Back compat, to keep existing configs working upon plugin upgrade
*/
private final String avatar;

public GitLabAvatar(String avatar) {
this.avatar = avatar;
public GitLabAvatar(String avatarUrl, String serverName, String projectPath, boolean isProject) {
this.avatar = null;
this.location = new GitLabAvatarLocation(avatarUrl, serverName, projectPath, isProject);
}

public GitLabAvatar(String avatarUrl) {
this.avatar = null;
this.location = new GitLabAvatarLocation(avatarUrl);
}

@Override
public String getAvatarImageOf(@NonNull String size) {
return StringUtils.isBlank(avatar) ? null : GitLabAvatarCache.buildUrl(avatar, size);
if (StringUtils.isNotBlank(avatar)) {
// Back compat, to keep existing configs working upon plugin upgrade
return GitLabAvatarCache.buildUrl(new GitLabAvatarLocation(avatar), size);
}
return location != null && location.available() ? GitLabAvatarCache.buildUrl(location, size) : null;
}

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}

GitLabAvatar that = (GitLabAvatar) o;

return Objects.equals(avatar, that.avatar);
return Objects.equals(location, that.location);
}

@Override
public int hashCode() {
return avatar != null ? avatar.hashCode() : 0;
return location != null ? location.hashCode() : 0;

Check warning on line 52 in src/main/java/io/jenkins/plugins/gitlabbranchsource/helpers/GitLabAvatar.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 17-52 are not covered by tests
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.jenkins.plugins.gitlabbranchsource.helpers;

import static io.jenkins.plugins.gitlabbranchsource.helpers.GitLabHelper.apiBuilderNoAccessControl;
import static java.awt.RenderingHints.KEY_ALPHA_INTERPOLATION;
import static java.awt.RenderingHints.KEY_INTERPOLATION;
import static java.awt.RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY;
Expand Down Expand Up @@ -47,14 +48,15 @@
import javax.servlet.http.HttpServletResponse;
import jenkins.model.Jenkins;
import org.apache.commons.lang.StringUtils;
import org.gitlab4j.api.GitLabApi;
import org.kohsuke.stapler.HttpResponse;
import org.kohsuke.stapler.QueryParameter;
import org.kohsuke.stapler.StaplerRequest;
import org.kohsuke.stapler.StaplerResponse;

/**
* An avatar cache that will serve URLs that have been recently registered
* through {@link #buildUrl(String, String)}
* through {@link #buildUrl(GitLabAvatarLocation, String)}
*/
@Extension
public class GitLabAvatarCache implements UnprotectedRootAction {
Expand Down Expand Up @@ -94,442 +96,456 @@
/**
* Builds the URL for the cached avatar image of the required size.
*
* @param url the URL of the source avatar image.
* @param size the size of the image.
* @return the URL of the cached image.
* @param location the external endpoint details (url/API)
* @return the Jenkins URL of the cached image.
*/
public static String buildUrl(String url, String size) {
public static String buildUrl(GitLabAvatarLocation location, String size) {
Jenkins j = Jenkins.get();
GitLabAvatarCache instance = j.getExtensionList(RootAction.class).get(GitLabAvatarCache.class);
if (instance == null) {
throw new AssertionError();
}
String key = Util.getDigestOf(GitLabAvatarCache.class.getName() + url);
String key = Util.getDigestOf(GitLabAvatarCache.class.getName() + location.toString());
// seed the cache
instance.getCacheEntry(key, url);
instance.getCacheEntry(key, location);
return UriTemplate.buildFromTemplate(j.getRootUrlFromRequest())
.literal(instance.getUrlName())
.path("key")
.query("size")
.build()
.set("key", key)
.set("size", size)
.expand();
}

private static BufferedImage scaleImage(BufferedImage src, int size) {
int newWidth;
int newHeight;
if (src.getWidth() > src.getHeight()) {
newWidth = size;
newHeight = size * src.getHeight() / src.getWidth();
} else if (src.getHeight() > src.getWidth()) {
newWidth = size * src.getWidth() / src.getHeight();
newHeight = size;
} else {
newWidth = newHeight = size;
}
boolean flushSrc = false;
if (newWidth <= src.getWidth() * 6 / 7 && newHeight <= src.getWidth() * 6 / 7) {
// when scaling down, you get better image quality if you scale down in multiple
// rounds
// see https://community.oracle.com/docs/DOC-983611
// we scale each round by 6/7 = ~85% as this gives nicer looking images
int curWidth = src.getWidth();
int curHeight = src.getHeight();
// we want to break the rounds and do the final round and centre when the src
// image is this size
final int penultimateSize = size * 7 / 6;
while (true) {
curWidth = curWidth - curWidth / 7;
curHeight = curHeight - curHeight / 7;
if (curWidth <= penultimateSize && curHeight <= penultimateSize) {
// we are within one round of target size let's go
break;
}
BufferedImage tmp = new BufferedImage(curWidth, curHeight, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = tmp.createGraphics();
try {
// important, if we don't set these two hints then scaling will not work
// headless
g.setRenderingHint(KEY_INTERPOLATION, VALUE_INTERPOLATION_BICUBIC);
g.setRenderingHint(KEY_ALPHA_INTERPOLATION, VALUE_ALPHA_INTERPOLATION_QUALITY);
g.scale(((double) curWidth) / src.getWidth(), ((double) curHeight) / src.getHeight());
g.drawImage(src, 0, 0, null);
} finally {
g.dispose();
}
if (flushSrc) {
src.flush();
}
src = tmp;
flushSrc = true;
}
}
BufferedImage tmp = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = tmp.createGraphics();
try {
// important, if we don't set these two hints then scaling will not work
// headless
g.setRenderingHint(KEY_INTERPOLATION, VALUE_INTERPOLATION_BICUBIC);
g.setRenderingHint(KEY_ALPHA_INTERPOLATION, VALUE_ALPHA_INTERPOLATION_QUALITY);
g.scale(((double) newWidth) / src.getWidth(), ((double) newHeight) / src.getHeight());
g.drawImage(src, (size - newWidth) / 2, (size - newHeight) / 2, null);
} finally {
g.dispose();
}
if (flushSrc) {
src.flush();
}
src = tmp;
return src;
}

/**
* Generates a consistent (for any given seed) 5x5 symmetric pixel avatar that
* should be unique but recognizable.
*
* @param seed the seed.
* @param size the size.
* @return the image.
*/
private static BufferedImage generateAvatar(String seed, int size) {
byte[] bytes;
try {
// we want a consistent image across reboots, so just take a hash of the seed
// if the seed changes we get a new hash and a new image!
MessageDigest d = MessageDigest.getInstance("MD5");
bytes = d.digest(seed.getBytes(StandardCharsets.UTF_8));
} catch (NoSuchAlgorithmException e) {
throw new AssertionError("JLS specification mandates support for MD5 message digest", e);
}
BufferedImage canvas = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = canvas.createGraphics();
try {
// we want the colour in the range 16-245 to prevent pure white and pure black
// 0xdf == 1101111 so we throw away the 32 place and add in 16 to give 16 on
// either side
g.setColor(new Color(bytes[0] & 0xdf + 16, bytes[1] & 0xdf + 16, bytes[2] & 0xdf + 16));
int pSize = size / 5;
// likely there will be some remainder from dividing by 5, so half the remainder
// will be used
// as an offset to centre the image
int pOffset = (size - pSize * 5) / 2;
for (int y = 0; y < 5; y++) {
for (int x = 0; x < 5; x++) {
byte bit = (byte) (1 << Math.min(x, 4 - x));
if ((bytes[3 + y] & bit) != 0) {
g.fillRect(pOffset + x * pSize, pOffset + y * pSize, pSize, pSize);
}
}
}
} finally {
g.dispose();
}
return canvas;
}

/**
* {@inheritDoc}
*/
@Override
public String getIconFileName() {
return null;
}

/**
* {@inheritDoc}
*/
@Override
public String getDisplayName() {
return null;
}

/**
* {@inheritDoc}
*/
@Override
public String getUrlName() {
return "gitlab-avatar";
}

/**
* Serves the cached image.
*
* @param req the request.
* @param size the requested size (defaults to {@code 48x48} if unspecified).
* @return the response.
*/
public HttpResponse doDynamic(StaplerRequest req, @QueryParameter String size) {
if (StringUtils.isBlank(req.getRestOfPath())) {
return HttpResponses.notFound();
}
String key = req.getRestOfPath().substring(1);
size = StringUtils.defaultIfBlank(size, "48x48");
int targetSize = 48;
int index = size.toLowerCase(Locale.ENGLISH).indexOf('x');
// we will only resize images in the 16x16 - 128x128 range
if (index < 2) {
try {
targetSize = Math.min(128, Math.max(16, Integer.parseInt(StringUtils.trim(size))));
} catch (NumberFormatException e) {
// ignore
}
} else {
try {
targetSize = Math.min(128, Math.max(16, Integer.parseInt(StringUtils.trim(size.substring(0, index)))));
} catch (NumberFormatException e) {
// ignore
}
}
final CacheEntry avatar = getCacheEntry(key, null);
if (avatar == null || !(avatar.url.startsWith("http://") || avatar.url.startsWith("https://"))) {
// we will generate avatars if the URL is not HTTP based
// since the url string will not magically turn itself into a HTTP url this
// avatar is immutable
if (avatar == null) {
// we will generate immutable avatars if cache did not get seeded for some reason
return new ImageResponse(
generateAvatar(avatar == null ? "" : avatar.url, targetSize),
generateAvatar("", targetSize),
true,
System.currentTimeMillis(),
"max-age=365000000, immutable, public");
}
if (avatar.pending() && avatar.image == null) {
// serve a temporary avatar until we get the remote one, no caching as we could
// have the real deal
// real soon now
return new ImageResponse(generateAvatar(avatar.url, targetSize), true, -1L, "no-cache, public");
return new ImageResponse(
generateAvatar(avatar.avatarLocation.toString(), targetSize), true, -1L, "no-cache, public");
}
long since = req.getDateHeader("If-Modified-Since");
if (avatar.lastModified <= since) {
return new HttpResponse() {
@Override
public void generateResponse(StaplerRequest req, StaplerResponse rsp, Object node)
throws IOException, ServletException {
rsp.addDateHeader("Last-Modified", avatar.lastModified);
rsp.addHeader("Cache-control", "max-age=3600, public");
rsp.sendError(HttpServletResponse.SC_NOT_MODIFIED);
}
};
}
if (avatar.image == null) {
// we can retry in an hour
return new ImageResponse(generateAvatar(avatar.url, targetSize), true, -1L, "max-age=3600, public");
return new ImageResponse(
generateAvatar(avatar.avatarLocation.toString(), targetSize), true, -1L, "max-age=3600, public");
}

BufferedImage image = avatar.image;
boolean flushImage = false;
if (image.getWidth() != targetSize || image.getHeight() != targetSize) {
image = scaleImage(image, targetSize);
flushImage = true;
}
return new ImageResponse(image, flushImage, avatar.lastModified, "max-age=3600, public");
}

/**
* Retrieves the entry from the cache.
*
* @param key the cache key.
* @param url the URL to fetch if the entry is missing or {@code null} to
* @param avatarLocation the location details to fetch the avatar from or {@code null} to
* perform a read-only check.
* @return the entry or {@code null} if a read-only check found no matching
* entry.
*/
@Nullable
private CacheEntry getCacheEntry(@NonNull final String key, @Nullable final String url) {
private CacheEntry getCacheEntry(@NonNull final String key, @Nullable final GitLabAvatarLocation avatarLocation) {
CacheEntry entry = cache.get(key);
if (entry == null) {
synchronized (serviceLock) {
entry = cache.get(key);
if (entry == null) {
if (url == null) {
if (avatarLocation == null) {
return null;
}
entry = new CacheEntry(url, service.submit(new FetchImage(url)));
entry = new CacheEntry(avatarLocation, service.submit(new FetchImage(avatarLocation)));
cache.put(key, entry);
}
}
} else {
if (entry.isStale()) {
synchronized (serviceLock) {
if (!entry.pending()) {
entry.setFuture(service.submit(new FetchImage(entry.url)));
entry.setFuture(service.submit(new FetchImage(entry.avatarLocation)));
}
}
}
}
entry.touch();
if (iterator == null || !iterator.hasNext()) {
synchronized (serviceLock) {
if (iterator == null || !iterator.hasNext()) {
iterator = cache.entrySet().iterator();
}
}
} else {
synchronized (iterator) {
// process one entry in the cache each access
if (iterator.hasNext()) {
Map.Entry<String, CacheEntry> next = iterator.next();
if (next.getValue().isUnused()) {
iterator.remove();
}
} else {
iterator = null;
}
}
}
return entry;
}

private static class CacheEntry {
private final String url;
private GitLabAvatarLocation avatarLocation;
private BufferedImage image;
private long lastModified;
private long lastAccessed = -1L;

private Future<CacheEntry> future;

public CacheEntry(String url, BufferedImage image, long lastModified) {
this.url = url;
public CacheEntry(GitLabAvatarLocation avatarLocation, BufferedImage image, long lastModified) {
this.avatarLocation = avatarLocation;
if (image.getHeight() > 128 || image.getWidth() > 128) {
// limit the amount of storage
this.image = scaleImage(image, 128);
image.flush();
} else {
this.image = image;
}
this.lastModified = lastModified < 0 ? System.currentTimeMillis() : lastModified;
}

public CacheEntry(String url, Future<CacheEntry> future) {
this.url = url;
public CacheEntry(GitLabAvatarLocation avatarLocation, Future<CacheEntry> future) {
this.avatarLocation = avatarLocation;
this.image = null;
this.lastModified = System.currentTimeMillis();
this.future = future;
}

public CacheEntry(String url) {
this.url = url;
public CacheEntry(GitLabAvatarLocation avatarLocation) {
this.avatarLocation = avatarLocation;
this.lastModified = System.currentTimeMillis();
}

public synchronized boolean pending() {
if (future == null) {
return false;
}
if (future.isDone()) {
try {
CacheEntry pending = future.get();
if (pending.image != null && image != null) {
image.flush();
}
if (pending.image != null) {
image = pending.image;
}
lastModified = pending.lastModified;
avatarLocation = pending.avatarLocation;
future = null;
return false;
} catch (InterruptedException | ExecutionException e) {
// ignore
}
}
return true;
}

public synchronized void setFuture(Future<CacheEntry> future) {
this.future = future;
}

public synchronized boolean isStale() {
return System.currentTimeMillis() - lastModified > TimeUnit.HOURS.toMillis(1);
}

public void touch() {
lastAccessed = System.currentTimeMillis();
}

public boolean isUnused() {
return lastAccessed > 0L && System.currentTimeMillis() - lastAccessed > TimeUnit.HOURS.toMillis(2);
}
}

private static class ImageResponse implements HttpResponse {
private final BufferedImage image;
private final boolean flushImage;
private final String cacheControl;

private final long lastModified;

public ImageResponse(BufferedImage image, boolean flushImage, long lastModified, String cacheControl) {
this.cacheControl = cacheControl;
this.image = image;
this.flushImage = flushImage;
this.lastModified = lastModified;
}

@Override
public void generateResponse(StaplerRequest req, StaplerResponse rsp, Object node)
throws IOException, ServletException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
try {
ImageIO.write(image, "png", bos);
} finally {
if (flushImage) {
image.flush();
}
}
final byte[] bytes = bos.toByteArray();
if (lastModified > 0) {
rsp.addDateHeader("Last-Modified", lastModified);
}
rsp.addHeader("Cache-control", cacheControl);
rsp.setContentType("image/png");
rsp.setContentLength(bytes.length);
rsp.getOutputStream().write(bytes);
}
}

private static class FetchImage implements Callable<CacheEntry> {
private final String url;
private final GitLabAvatarLocation avatarLocation;

public FetchImage(String url) {
this.url = url;
public FetchImage(GitLabAvatarLocation avatarLocation) {
this.avatarLocation = avatarLocation;
}

@Override
public CacheEntry call() throws Exception {
LOGGER.log(Level.FINE, "Attempting to fetch remote avatar: {0}", url);
LOGGER.log(Level.FINE, "Attempting to fetch remote avatar: {0}", avatarLocation.toString());
long start = System.nanoTime();
try {
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
if (avatarLocation.apiAvailable()) {
try (GitLabApi apiClient = apiBuilderNoAccessControl(avatarLocation.getServerName());
InputStream is = avatarLocation.isProject()
? apiClient.getProjectApi().getAvatar(avatarLocation.getFullPath())
: apiClient.getGroupApi().getAvatar(avatarLocation.getFullPath())) {
BufferedImage image = ImageIO.read(is);
if (image == null) {
return new CacheEntry(avatarLocation);
}
return new CacheEntry(avatarLocation, image, -1);
}
}

HttpURLConnection connection =
(HttpURLConnection) new URL(avatarLocation.getAvatarUrl()).openConnection();
try {
connection.setConnectTimeout(10000);
connection.setReadTimeout(30000);
if (!connection.getContentType().startsWith("image/")) {
return new CacheEntry(url);
return new CacheEntry(avatarLocation);
}
int length = connection.getContentLength();
// buffered stream should be no more than 16k if we know the length
// if we don't know the length then 8k is what we will use
length = length > 0 ? Math.min(16384, length) : 8192;
try (InputStream is = connection.getInputStream();
BufferedInputStream bis = new BufferedInputStream(is, length)) {
BufferedImage image = ImageIO.read(bis);
if (image == null) {
return new CacheEntry(url);
return new CacheEntry(avatarLocation);
}
return new CacheEntry(url, image, connection.getLastModified());
return new CacheEntry(avatarLocation, image, connection.getLastModified());
}
} finally {
connection.disconnect();
}
} catch (IOException e) {
LOGGER.log(Level.INFO, e.getMessage(), e);
return new CacheEntry(url);
return new CacheEntry(avatarLocation);
} finally {
long end = System.nanoTime();
long duration = TimeUnit.NANOSECONDS.toMillis(end - start);
LOGGER.log(duration > 250 ? Level.INFO : Level.FINE, "Avatar lookup of {0} took {1}ms", new Object[] {
url, duration
avatarLocation.toString(), duration

Check warning on line 548 in src/main/java/io/jenkins/plugins/gitlabbranchsource/helpers/GitLabAvatarCache.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 108-548 are not covered by tests
});
}
}
Expand Down
Loading