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
58 changes: 58 additions & 0 deletions src/main/java/org/jenkinsci/plugin/gitea/GiteaOwnerListHelper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package org.jenkinsci.plugin.gitea;

import hudson.util.ListBoxModel;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.jenkinsci.plugin.gitea.client.api.GiteaRepository;

/**
* Utility class for returning unique Gitea repository owners.
*/
public class GiteaOwnerListHelper {

Check warning on line 12 in src/main/java/org/jenkinsci/plugin/gitea/GiteaOwnerListHelper.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 12 is not covered by tests
/**
* Retrieves unique repository owners from the given list of repositories.
*
* @param currentUser the Gitea user used for fetching the repositories
* @param repositories the list of repositories to extract owners from
* @return a Set of unique owner names
*/
public static Set<String> getOwners(String currentUser,
List<GiteaRepository> repositories) {
Set<String> owners = new HashSet<>();

for (GiteaRepository repo : repositories) {
String owner = repo.getOwner().getUsername();
owners.add(owner); // Set will handle duplicates
}

owners.add(currentUser); // Ensure current user is included

return owners;
}

/**
* Returns a ListBoxModel with repository owners.
*
* @param currentUser the Gitea user used for fetching the repositories
* @param currentOwner the currently selected owner (will be preserved if valid)
* @param repositories the list of repositories to extract owners from
* @return the populated ListBoxModel
*/
public static ListBoxModel populateOwnerListBoxModel(String currentUser,
String currentOwner,
List<GiteaRepository> repositories) {
ListBoxModel result = new ListBoxModel();
Set<String> owners = getOwners(currentUser, repositories);
// Add owners to the result, with current selection first if it exists
if (owners.remove(currentOwner)) {
result.add(currentOwner);
}

for (String owner : owners) {
result.add(owner);
}

return result;
}
}
60 changes: 60 additions & 0 deletions src/main/java/org/jenkinsci/plugin/gitea/GiteaSCMNavigator.java
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import jenkins.authentication.tokens.api.AuthenticationTokens;
import jenkins.model.Jenkins;
import jenkins.plugins.git.traits.GitBrowserSCMSourceTrait;
Expand Down Expand Up @@ -86,305 +88,363 @@
import org.kohsuke.stapler.DataBoundConstructor;
import org.kohsuke.stapler.DataBoundSetter;
import org.kohsuke.stapler.QueryParameter;
import org.kohsuke.stapler.verb.POST;

public class GiteaSCMNavigator extends SCMNavigator {
private final String serverUrl;
private final String repoOwner;
private static final Logger LOGGER = Logger.getLogger(GiteaSCMNavigator.class.getName());
private String credentialsId;
private List<SCMTrait<? extends SCMTrait<?>>> traits = new ArrayList<>();
private GiteaOwner giteaOwner;

@DataBoundConstructor
public GiteaSCMNavigator(String serverUrl, String repoOwner) {
this.serverUrl = serverUrl;
this.repoOwner = repoOwner;
}

public String getServerUrl() {
return serverUrl;
}

public String getRepoOwner() {
return repoOwner;
}

public String getCredentialsId() {
return credentialsId;
}

@DataBoundSetter
public void setCredentialsId(String credentialsId) {
this.credentialsId = credentialsId;
}

@NonNull
public List<SCMTrait<? extends SCMTrait<?>>> getTraits() {
return Collections.unmodifiableList(traits);
}

// we use the simple `SCMTrait[] ` type here instead of List<SCMTrait<? extends SCMTrait<?>>> such that this
// property is supported by the Job DSL plugin. See: https://github.com/jenkinsci/bitbucket-branch-source-plugin/pull/278
// for a similar fix in the bickbucket plugin repo.
@SuppressWarnings({"unchecked", "rawtypes"})
@DataBoundSetter
public void setTraits(SCMTrait[] traits) {
this.traits = new ArrayList<>();
if (traits != null) {
for (SCMTrait trait : traits) {
this.traits.add(trait);
}
}
}

@Override
public void setTraits(@CheckForNull List<SCMTrait<? extends SCMTrait<?>>> traits) {
this.traits = traits != null ? new ArrayList<>(traits) : new ArrayList<SCMTrait<? extends SCMTrait<?>>>();
}

@NonNull
@Override
protected String id() {
return serverUrl + "::" + repoOwner;
}

@Override
public void visitSources(@NonNull final SCMSourceObserver observer) throws IOException, InterruptedException {
GiteaSCMNavigatorContext context = new GiteaSCMNavigatorContext().withTraits(traits);
try (GiteaSCMNavigatorRequest request = context.newRequest(this, observer);
GiteaConnection c = gitea(observer.getContext()).open()) {
giteaOwner = c.fetchOwner(repoOwner);
List<GiteaRepository> repositories = c.fetchRepositories(giteaOwner);

int count = 0;
observer.getListener().getLogger().format("%n Checking repositories...%n");
Set<Long> seen = new HashSet<>();
for (GiteaRepository r : repositories) {
// TODO remove this hack for Gitea listing the repositories multiple times
if (seen.contains(r.getId())) {
continue;
} else {
seen.add(r.getId());
}
if (!StringUtils.equalsIgnoreCase(r.getOwner().getUsername(), repoOwner)) {
// this is the user repos which includes all organizations that they are a member of
continue;
}
count++;
if (r.isEmpty()) {
observer.getListener().getLogger().format("%n Ignoring empty repository %s%n",
HyperlinkNote.encodeTo(r.getHtmlUrl(), r.getName()));
continue;
} else if (r.isArchived() && context.isExcludeArchivedRepositories()) {
observer.getListener().getLogger().format("%n Skipping repository %s because it is archived", r.getName());
continue;

}
observer.getListener().getLogger().format("%n Checking repository %s%n",
HyperlinkNote.encodeTo(r.getHtmlUrl(), r.getName()));
if (request.process(r.getName(), new SCMNavigatorRequest.SourceLambda() {
@NonNull
@Override
public SCMSource create(@NonNull String projectName) throws IOException, InterruptedException {
return new GiteaSCMSourceBuilder(
getId() + "::" + projectName,
serverUrl,
credentialsId,
repoOwner,
projectName
)
.withTraits(traits)
.build();
}
}, null, new SCMNavigatorRequest.Witness() {
@Override
public void record(@NonNull String projectName, boolean isMatch) {
if (isMatch) {
observer.getListener().getLogger().format(" Proposing %s%n", projectName);
} else {
observer.getListener().getLogger().format(" Ignoring %s%n", projectName);
}
}
})) {
observer.getListener().getLogger().format("%n %d repositories were processed (query complete)%n",
count);
return;
}
}
observer.getListener().getLogger().format("%n %d repositories were processed%n", count);
}
}

@NonNull
@Override
protected List<Action> retrieveActions(@NonNull SCMNavigatorOwner owner, SCMNavigatorEvent event,
@NonNull TaskListener listener) throws IOException, InterruptedException {
if (this.giteaOwner == null) {
try (GiteaConnection c = gitea(owner).open()) {
this.giteaOwner = c.fetchUser(repoOwner);
if (StringUtils.isBlank(giteaOwner.getEmail())) {
this.giteaOwner = c.fetchOrganization(repoOwner);
}
}
}
List<Action> result = new ArrayList<>();
String objectUrl = UriTemplate.buildFromTemplate(serverUrl)
.path("owner")
.build()
.set("owner", repoOwner)
.expand();
result.add(new ObjectMetadataAction(
Util.fixEmpty(giteaOwner.getFullName()),
null,
objectUrl)
);
if (StringUtils.isNotBlank(giteaOwner.getAvatarUrl())) {
result.add(new GiteaAvatar(giteaOwner.getAvatarUrl()));
}
result.add(new GiteaLink("icon-gitea-org", objectUrl));
if (giteaOwner instanceof GiteaOrganization) {
String website = ((GiteaOrganization) giteaOwner).getWebsite();
if (StringUtils.isBlank(website)) {
listener.getLogger().println("Organization website: unspecified");
} else {
listener.getLogger().printf("Organization website: %s%n",
HyperlinkNote.encodeTo(website, StringUtils.defaultIfBlank(giteaOwner.getFullName(), website)));
}
}
return result;
}

@Override
public void afterSave(@NonNull SCMNavigatorOwner owner) {
WebhookRegistration mode = new GiteaSCMSourceContext(null, SCMHeadObserver.none())
.withTraits(new GiteaSCMNavigatorContext().withTraits(traits).traits())
.webhookRegistration();
GiteaWebhookListener.register(owner, this, mode, credentialsId);
}

private Gitea gitea(SCMSourceOwner owner) throws AbortException {
GiteaServer server = GiteaServers.get().findServer(serverUrl);
if (server == null) {
throw new AbortException("Unknown server: " + serverUrl);
}
StandardCredentials credentials = credentials(owner);
CredentialsProvider.track(owner, credentials);
return Gitea.server(serverUrl)
.as(AuthenticationTokens.convert(GiteaAuth.class, credentials));
}

public StandardCredentials credentials(SCMSourceOwner owner) {
return CredentialsMatchers.firstOrNull(
CredentialsProvider.lookupCredentials(
StandardCredentials.class,
owner,
Jenkins.getAuthentication(),
URIRequirementBuilder.fromUri(serverUrl).build()
),
CredentialsMatchers.allOf(
AuthenticationTokens.matcher(GiteaAuth.class),
CredentialsMatchers.withId(credentialsId)
)
);
}

@Extension
@Symbol("gitea")
public static class DescriptorImpl extends SCMNavigatorDescriptor {

@Override
public String getDisplayName() {
return Messages.GiteaSCMNavigator_displayName();
}

public ListBoxModel doFillServerUrlItems(@AncestorInPath SCMSourceOwner context,
@QueryParameter String serverUrl) {
if (context == null) {
if (!Jenkins.get().hasPermission(Jenkins.ADMINISTER)) {
// must have admin if you want the list without a context
ListBoxModel result = new ListBoxModel();
result.add(serverUrl);
return result;
}
} else {
if (!context.hasPermission(Item.EXTENDED_READ)) {
// must be able to read the configuration the list
ListBoxModel result = new ListBoxModel();
result.add(serverUrl);
return result;
}
}
return GiteaServers.get().getServerItems();
}

public ListBoxModel doFillCredentialsIdItems(@AncestorInPath SCMSourceOwner context,
@QueryParameter String serverUrl,
@QueryParameter String credentialsId) {
StandardListBoxModel result = new StandardListBoxModel();
if (context == null) {
if (!Jenkins.get().hasPermission(Jenkins.ADMINISTER)) {
// must have admin if you want the list without a context
result.includeCurrentValue(credentialsId);
return result;
}
} else {
if (!context.hasPermission(Item.EXTENDED_READ)
&& !context.hasPermission(CredentialsProvider.USE_ITEM)) {
// must be able to read the configuration or use the item credentials if you want the list
result.includeCurrentValue(credentialsId);
return result;
}
}
result.includeEmptyValue();
result.includeMatchingAs(
context instanceof Queue.Task ?
((Queue.Task) context).getDefaultAuthentication()
: ACL.SYSTEM,
context,
StandardCredentials.class,
URIRequirementBuilder.fromUri(serverUrl).build(),
AuthenticationTokens.matcher(GiteaAuth.class)
);
return result;
}

public FormValidation doCheckCredentialsId(@AncestorInPath SCMSourceOwner context,
@QueryParameter String serverUrl,
@QueryParameter String value)
throws IOException, InterruptedException {
if (context == null) {
if (!Jenkins.get().hasPermission(Jenkins.ADMINISTER)) {
return FormValidation.ok();
}
} else {
if (!context.hasPermission(Item.EXTENDED_READ)
&& !context.hasPermission(CredentialsProvider.USE_ITEM)) {
return FormValidation.ok();
}
}
GiteaServer server = GiteaServers.get().findServer(serverUrl);
if (server == null) {
return FormValidation.ok();
}
if (StringUtils.isBlank(value)) {
return FormValidation.ok();
}
if (CredentialsProvider.listCredentials(
StandardCredentials.class,
context,
context instanceof Queue.Task ?
((Queue.Task) context).getDefaultAuthentication()
: ACL.SYSTEM,
URIRequirementBuilder.fromUri(serverUrl).build(),
CredentialsMatchers.allOf(
CredentialsMatchers.withId(value),
AuthenticationTokens.matcher(GiteaAuth.class)

)).isEmpty()) {
return FormValidation.error(Messages.GiteaSCMNavigator_selectedCredentialsMissing());
}
return FormValidation.ok();
}

@POST
public ListBoxModel doFillRepoOwnerItems(@AncestorInPath SCMSourceOwner context,
@QueryParameter String serverUrl,
@QueryParameter String credentialsId,
@QueryParameter String repoOwner) throws IOException,
InterruptedException {
ListBoxModel result = new ListBoxModel();
if (context == null) {
if (!Jenkins.get().hasPermission(Jenkins.ADMINISTER)) {
result.add(repoOwner);
return result;
}
} else {
if (!context.hasPermission(Item.EXTENDED_READ)
&& !context.hasPermission(CredentialsProvider.USE_ITEM)) {
result.add(repoOwner);
return result;
}
}
GiteaServer server = GiteaServers.get().findServer(serverUrl);
if (server == null) {
result.add(repoOwner);
return result;
}
StandardCredentials credentials = CredentialsMatchers.firstOrNull(
CredentialsProvider.lookupCredentials(
StandardCredentials.class,
context,
context instanceof Queue.Task
? ((Queue.Task) context).getDefaultAuthentication()
: ACL.SYSTEM,
URIRequirementBuilder.fromUri(serverUrl).build()
),
CredentialsMatchers.allOf(
AuthenticationTokens.matcher(GiteaAuth.class),
CredentialsMatchers.withId(credentialsId)
)
);
if (credentials == null) {
result.add(repoOwner);
return result;
}

try (GiteaConnection c = Gitea.server(serverUrl)
.as(AuthenticationTokens.convert(GiteaAuth.class, credentials))
.open()) {
List<GiteaRepository> repositories = c.fetchCurrentUserRepositories();
String currentUser = c.fetchCurrentUser().getUsername();
return GiteaOwnerListHelper.populateOwnerListBoxModel(currentUser, repoOwner, repositories);
} catch (IOException e) {
LOGGER.log(Level.FINE, "Could not populate owners", e);
}

return result;

Check warning on line 445 in src/main/java/org/jenkinsci/plugin/gitea/GiteaSCMNavigator.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 96-445 are not covered by tests
}

@NonNull
@Override
public String getDescription() {
Expand Down
58 changes: 58 additions & 0 deletions src/main/java/org/jenkinsci/plugin/gitea/GiteaSCMSource.java
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
import org.kohsuke.stapler.DataBoundConstructor;
import org.kohsuke.stapler.DataBoundSetter;
import org.kohsuke.stapler.QueryParameter;
import org.kohsuke.stapler.verb.POST;

public class GiteaSCMSource extends AbstractGitSCMSource {
public static final VersionNumber TAG_SUPPORT_MINIMUM_VERSION = new VersionNumber("1.9.0");
Expand Down Expand Up @@ -919,6 +920,63 @@
return FormValidation.ok();
}

@POST
public ListBoxModel doFillRepoOwnerItems(@AncestorInPath SCMSourceOwner context,
@QueryParameter String serverUrl,
@QueryParameter String credentialsId,
@QueryParameter String repoOwner) throws IOException,
InterruptedException {
ListBoxModel result = new ListBoxModel();
if (context == null) {
if (!Jenkins.get().hasPermission(Jenkins.ADMINISTER)) {
// must have admin if you want the list without a context
result.add(repoOwner);
return result;
}
} else {
if (!context.hasPermission(Item.EXTENDED_READ)
&& !context.hasPermission(CredentialsProvider.USE_ITEM)) {
result.add(repoOwner);
return result;
}
}
GiteaServer server = GiteaServers.get().findServer(serverUrl);
if (server == null) {
result.add(repoOwner);
return result;
}
StandardCredentials credentials = CredentialsMatchers.firstOrNull(
CredentialsProvider.lookupCredentials(
StandardCredentials.class,
context,
context instanceof Queue.Task
? ((Queue.Task) context).getDefaultAuthentication()
: ACL.SYSTEM,
URIRequirementBuilder.fromUri(serverUrl).build()
),
CredentialsMatchers.allOf(
AuthenticationTokens.matcher(GiteaAuth.class),
CredentialsMatchers.withId(credentialsId)
)
);
if (credentials == null) {
result.add(repoOwner);
return result;
}

try (GiteaConnection c = Gitea.server(serverUrl)
.as(AuthenticationTokens.convert(GiteaAuth.class, credentials))
.open()) {
List<GiteaRepository> repositories = c.fetchCurrentUserRepositories();
String currentUser = c.fetchCurrentUser().getUsername();
return GiteaOwnerListHelper.populateOwnerListBoxModel(currentUser, repoOwner, repositories);
} catch (IOException e) {
LOGGER.log(Level.FINE, "Could not populate owners", e);
}

return result;

Check warning on line 977 in src/main/java/org/jenkinsci/plugin/gitea/GiteaSCMSource.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 929-977 are not covered by tests
}

public ListBoxModel doFillRepositoryItems(@AncestorInPath SCMSourceOwner context,
@QueryParameter String serverUrl,
@QueryParameter String credentialsId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<c:select/>
</f:entry>
<f:entry title="${%Owner}" field="repoOwner">
<f:textbox/>
<f:select fillDependsOn="serverUrl,credentialsId"/>
</f:entry>
<f:entry title="${%Behaviours}">
<scm:traits field="traits"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
<c:select/>
</f:entry>
<f:entry title="${%Owner}" field="repoOwner">
<f:textbox/>
<f:select fillDependsOn="serverUrl,credentialsId"/>
</f:entry>
<f:entry title="${%Repository}" field="repository">
<f:select/>
<f:select fillDependsOn="serverUrl,credentialsId,repoOwner"/>
</f:entry>
<f:entry title="${%Behaviours}">
<scm:traits field="traits"/>
Expand Down
112 changes: 112 additions & 0 deletions src/test/java/org/jenkinsci/plugin/gitea/GiteaOwnerListHelperTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package org.jenkinsci.plugin.gitea;

import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import org.jenkinsci.plugin.gitea.client.api.GiteaOwner;
import org.jenkinsci.plugin.gitea.client.api.GiteaRepository;
import org.junit.jupiter.api.Test;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.anyOf;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.not;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

class GiteaOwnerListHelperTest {

@Test
void getOwners_withValidRepositories_returnsUniqueOwnersIncludingCurrentUser() {
List<GiteaRepository> repositories = Arrays.asList(
createMockRepository("alice"),
createMockRepository("bob"),
createMockRepository("alice") // duplicate
);

Set<String> owners = GiteaOwnerListHelper.getOwners("charlie", repositories);

assertThat(owners, containsInAnyOrder("alice", "bob", "charlie"));
}

@Test
void getOwners_withEmptyRepositories_returnsOnlyCurrentUser() {
List<GiteaRepository> repositories = Collections.emptyList();

Set<String> owners = GiteaOwnerListHelper.getOwners("charlie", repositories);

assertThat(owners.contains("charlie"), is(true));
}

@Test
void populateOwnerListBoxModel_withCurrentOwnerSelected_placesCurrentOwnerFirst() {
List<GiteaRepository> repositories = Arrays.asList(
createMockRepository("alice"),
createMockRepository("bob"));

var result = GiteaOwnerListHelper.populateOwnerListBoxModel("charlie", "bob", repositories);

assertThat(result.size(), is(3));
assertThat(result.get(0).name, is("bob"));
assertThat(result.get(1).name, anyOf(is("alice"), is("charlie")));
assertThat(result.get(2).name, anyOf(is("alice"), is("charlie")));
}

@Test
void populateOwnerListBoxModel_withCurrentOwnerNotInList_includesCurrentOwner() {
List<GiteaRepository> repositories = Arrays.asList(
createMockRepository("alice"));

var result = GiteaOwnerListHelper.populateOwnerListBoxModel("bob", "charlie", repositories);

assertThat(result.size(), is(2));
assertThat(result.stream().anyMatch(opt -> "charlie".equals(opt.name)), is(false));
assertThat(result.stream().anyMatch(opt -> "alice".equals(opt.name)), is(true));
assertThat(result.stream().anyMatch(opt -> "bob".equals(opt.name)), is(true));
}

@Test
void populateOwnerListBoxModel_selectedOwnerIsFirst_restAreUnordered() {
List<GiteaRepository> repositories = Arrays.asList(
createMockRepository("alice"),
createMockRepository("bob"),
createMockRepository("charlie")
);

var result = GiteaOwnerListHelper.populateOwnerListBoxModel("dave", "bob", repositories);

assertThat(result.size(), is(4));
assertThat(result.get(0).name, is("bob")); // selected owner first

// The rest should be alice, charlie, dave in any order
List<String> rest = Arrays.asList(result.get(1).name, result.get(2).name, result.get(3).name);
assertThat(rest, containsInAnyOrder("alice", "charlie", "dave"));
}

@Test
void populateOwnerListBoxModel_selectedOwnerNotPresent() {
List<GiteaRepository> repositories = Arrays.asList(
createMockRepository("alice"),
createMockRepository("charlie")
);

var result = GiteaOwnerListHelper.populateOwnerListBoxModel("dave", "bob", repositories);

assertThat(result.size(), is(3));
assertThat(result.get(0).name, not(is("bob"))); // selected owner first

// The rest should be alice, charlie, dave in any order
List<String> rest = Arrays.asList(result.get(0).name, result.get(1).name, result.get(2).name);
assertThat(rest, containsInAnyOrder("alice", "charlie", "dave"));
}

private GiteaRepository createMockRepository(String ownerName) {
GiteaRepository repo = mock(GiteaRepository.class);
GiteaOwner owner = mock(GiteaOwner.class);
when(owner.getUsername()).thenReturn(ownerName);
when(repo.getOwner()).thenReturn(owner);
return repo;
}
}
Loading