diff --git a/README.md b/README.md index 32e21ba59..d47dd9a75 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ GitHub users or organizations. Complete documentation is ### Guides * [GitHub App authentication](docs/github-app.adoc) +* [Multi-Organization GitHub App Credentials](docs/multi-org-github-app-credentials.adoc) * [Extension points provided by this plugin](docs/implementation.adoc) ## Extension plugins diff --git a/docs/multi-org-github-app-credentials.adoc b/docs/multi-org-github-app-credentials.adoc new file mode 100644 index 000000000..78cdefa79 --- /dev/null +++ b/docs/multi-org-github-app-credentials.adoc @@ -0,0 +1,354 @@ += Multi-Organization GitHub App Credentials + +== Overview + +The Multi-Organization GitHub App Credentials provides enhanced support for GitHub Apps that are installed across multiple GitHub organizations. This credential type eliminates the need to create duplicate credentials for each organization where your GitHub App is installed. + +== Features + +* *Single Credential for Multiple Organizations*: Use a single credential for a GitHub App installed across multiple organizations. +* *Automatic Organization Discovery*: Automatically discovers and caches the list of organizations where the app is installed. +* *Seamless Integration*: Works with existing GitHub SCM sources and navigators without requiring changes to your job configurations. +* *Organization-specific Token Generation*: Automatically generates the correct token for each organization when needed. +* *Token Caching*: Caches tokens per organization to minimize API calls and improve performance. +* *Rate Limiting Protection*: Built-in protection against GitHub API rate limits. +* *Fallback Support*: Gracefully handles cases where an organization may not be in the cached list. + +== Usage + +=== Creating the Credential + +. Navigate to *Manage Jenkins > Manage Credentials* +. Add a new credential of type *GitHub App (Multi-Organization)* +. Provide the GitHub App ID and Private Key +. Optionally configure the API endpoint (defaults to GitHub.com) +. Click "Test Connection" to verify the setup and see available organizations + +=== Using with GitHub SCM Sources + +When configuring a GitHub SCM source or navigator: + +. Select your Multi-Org GitHub App credential from the credentials dropdown +. The system will automatically use the appropriate token for the target organization +. No additional configuration is needed - the credential handles organization selection automatically + +=== Managing Organizations + +The credential automatically: + +* Discovers all organizations where your GitHub App is installed +* Caches the organization list for performance (1 hour TTL) +* Refreshes the list when you test the connection or force a refresh +* Handles organization-specific token generation on demand + +== How It Works + +=== Automatic Organization Detection + +When you create or use the credential, it: + +. Uses your GitHub App credentials to authenticate with GitHub +. Retrieves the list of all installations for your app +. Caches this list to avoid repeated API calls +. Uses the cached list for organization validation and token generation + +=== Token Generation Per Organization + +When a GitHub SCM source needs to access a repository: + +. The system determines which organization owns the repository +. Generates a token specifically for that organization's app installation +. Caches the token until it expires or becomes stale +. Automatically refreshes tokens as needed + +=== Credential Selection Logic + +The plugin uses the following logic when selecting credentials: + +. If a `MultiOrgGitHubAppCredentials` is selected and the target organization is available, creates organization-specific credentials +. If the organization is not in the cached list, still attempts to use the credential (useful for newly installed apps) +. Falls back to standard GitHub App credential behavior if needed + +== Migration from Standard GitHub App Credentials + +The Multi-Organization GitHub App Credentials is backward compatible with the standard GitHub App Credentials. You can: + +. Create a new Multi-Organization GitHub App Credential with the same app ID and private key +. Update your configurations to use the new credential ID +. Remove the old single-organization credentials + +[NOTE] +==== +No code changes are required when switching from standard to multi-organization credentials. Existing job configurations will continue to work. +==== + +== Troubleshooting + +=== Organization Not Listed + +If an expected organization doesn't appear in the available organizations list: + +. Verify the GitHub App is installed to that organization +. Check that the app has the necessary permissions +. Test the connection to refresh the organization list +. Verify network connectivity to GitHub + +=== Connection Issues + +If you encounter connection problems: + +. Verify the App ID is correct +. Ensure the private key is in PKCS#8 format +. Check the API endpoint configuration +. Verify network connectivity to GitHub +. Check Jenkins logs for detailed error messages + +=== Token Generation Issues + +If tokens are not being generated correctly: + +. Verify the GitHub App has the required permissions in the target organization +. Check that the app installation is active +. Ensure the private key hasn't expired or been revoked +. Review logs for specific error messages + +== Logging + +Enable debug logging to troubleshoot issues: + +[source] +---- +Logger: org.jenkinsci.plugins.github_branch_source.MultiOrgGitHubAppCredentials +Level: FINE +---- + +This will provide detailed information about organization discovery and token generation. + +Additional logging for connector behavior: + +[source] +---- +Logger: org.jenkinsci.plugins.github_branch_source.Connector +Level: FINE +---- + +== Best Practices + +=== GitHub App Configuration + +* Grant minimal required permissions to your GitHub App +* Install the app only in organizations where it's needed +* Regularly review app installations and permissions +* Use meaningful names and descriptions for your GitHub Apps + +=== Jenkins Configuration + +* Use descriptive names for your Multi-Org credentials +* Test connections after creating or updating credentials +* Monitor Jenkins logs for any authentication issues +* Consider using credential domains to scope access appropriately + +=== Security Considerations + +* Store private keys securely and rotate them regularly +* Monitor GitHub App activity through GitHub's audit logs +* Use Jenkins' credential masking features in build logs +* Restrict access to credential management to authorized users only + +== API Rate Limiting + +The Multi-Organization GitHub App Credentials helps manage GitHub API rate limits by: + +* Caching organization lists to reduce discovery API calls +* Caching tokens per organization to minimize token generation calls +* Using organization-specific tokens which have separate rate limits +* Providing rate limit information in test connection results + +[TIP] +==== +GitHub Apps have higher rate limits than personal access tokens, making them ideal for organizations with high API usage. +==== + +== Multi-Organization GitHub App Credentials Binding + +The Multi-Organization GitHub App Credentials Binding allows you to use multi-org credentials in pipeline scripts and build environments. This binding provides access to GitHub tokens for multiple organizations through environment variables. + +=== Features + +* *Automatic Mode*: Automatically provides tokens for all organizations where the app is installed +* *Manual Mode*: Provides a token for a specific organization +* *Environment Variable Support*: Exposes tokens through environment variables in build steps +* *Pipeline Integration*: Works seamlessly with Jenkins Pipeline scripts + +=== Usage in Pipelines + +==== Automatic Mode + +In automatic mode, the binding provides environment variables for all organizations: + +[source,groovy] +---- +pipeline { + agent any + environment { + // Automatically binds tokens for all organizations + GITHUB_CREDENTIALS = credentials('multi-org-github-app-creds') + } + stages { + stage('Access Multiple Orgs') { + steps { + script { + // Available environment variables: + // GITHUB_ORGS - comma-separated list of organizations + // GITHUB_TOKEN_ - token for each organization + + echo "Available organizations: ${env.GITHUB_ORGS}" + + // Use organization-specific tokens + sh 'curl -H "Authorization: token ${GITHUB_TOKEN_MYORG}" https://api.github.com/orgs/myorg/repos' + sh 'curl -H "Authorization: token ${GITHUB_TOKEN_ANOTHERCORP}" https://api.github.com/orgs/anothercorp/repos' + } + } + } + } +} +---- + +==== Manual Mode + +In manual mode, specify a single organization and custom variable name: + +[source,groovy] +---- +pipeline { + agent any + stages { + stage('Access Specific Org') { + steps { + withCredentials([ + multiOrgGitHubApp( + credentialsId: 'multi-org-github-app-creds', + tokenVariable: 'GITHUB_TOKEN', + orgName: 'myorg' + ) + ]) { + sh 'curl -H "Authorization: token ${GITHUB_TOKEN}" https://api.github.com/orgs/myorg/repos' + } + } + } + } +} +---- + +=== Using in Freestyle Jobs + +For freestyle jobs, add the "Multi-Organization GitHub App credentials" binding in the build environment: + +. Check "Use secret text(s) or file(s)" +. Add "Multi-Organization GitHub App credentials" +. Configure either automatic or manual mode +. Use the environment variables in your build steps + +=== Environment Variables + +==== Automatic Mode + +* `GITHUB_ORGS`: Comma-separated list of available organizations +* `GITHUB_TOKEN_`: GitHub token for the specified organization (organization name is sanitized for environment variable use) + +==== Manual Mode + +* ``: The GitHub token for the specified organization (using your custom variable name) + +== Configuration as Code (JCasC) + +The Multi-Organization GitHub App Credentials can be configured using Jenkins Configuration as Code (JCasC). + +=== Basic Credential Configuration + +[source,yaml] +---- +credentials: + system: + domainCredentials: + - credentials: + - multiOrgGitHubApp: + scope: GLOBAL + id: "multi-org-github-app" + description: "Multi-Org GitHub App for CI/CD" + appID: "123456" + privateKey: | + -----BEGIN PRIVATE KEY----- + MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQD... + -----END PRIVATE KEY----- + apiUri: "https://api.github.com" +---- + +=== Advanced Configuration with Custom API Endpoint + +[source,yaml] +---- +credentials: + system: + domainCredentials: + - domain: + name: "github-enterprise" + description: "GitHub Enterprise credentials" + credentials: + - multiOrgGitHubApp: + scope: GLOBAL + id: "enterprise-multi-org-app" + description: "GitHub Enterprise Multi-Org App" + appID: "789012" + privateKey: | + -----BEGIN PRIVATE KEY----- + MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQD... + -----END PRIVATE KEY----- + apiUri: "https://github-enterprise.company.com/api/v3" +---- + +=== Multiple Credentials Configuration + +[source,yaml] +---- +credentials: + system: + domainCredentials: + - credentials: + - multiOrgGitHubApp: + scope: GLOBAL + id: "multi-org-github-app" + description: "Multi-Organization GitHub App" + appID: "${GITHUB_APP_ID}" + privateKey: "${GITHUB_APP_PRIVATE_KEY}" + apiUri: "https://api.github.com" + - multiOrgGitHubApp: + scope: GLOBAL + id: "backup-multi-org-app" + description: "Backup Multi-Org GitHub App" + appID: "${BACKUP_GITHUB_APP_ID}" + privateKey: "${BACKUP_GITHUB_APP_PRIVATE_KEY}" + apiUri: "https://api.github.com" +---- + +=== Using Environment Variables for Secrets + +[source,yaml] +---- +credentials: + system: + domainCredentials: + - credentials: + - multiOrgGitHubApp: + scope: GLOBAL + id: "multi-org-github-app" + description: "Multi-Org GitHub App from Environment" + appID: "${GITHUB_APP_ID}" + privateKey: "${readFile:${GITHUB_APP_PRIVATE_KEY_FILE}}" + apiUri: "${GITHUB_API_URL:-https://api.github.com}" +---- + +[NOTE] +==== +When using JCasC, ensure that sensitive values like private keys and app IDs are provided through environment variables or external secret management systems rather than hardcoding them in the configuration files. +==== diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/Connector.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/Connector.java index 9915d2318..007011d19 100644 --- a/src/main/java/org/jenkinsci/plugins/github_branch_source/Connector.java +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/Connector.java @@ -307,7 +307,27 @@ public static StandardCredentials lookupScanCredentials( CredentialsMatchers.allOf( CredentialsMatchers.withId(scanCredentialsId), githubScanCredentialsMatcher())); - if (c instanceof GitHubAppCredentials && repoOwner != null) { + if (c instanceof MultiOrgGitHubAppCredentials && repoOwner != null) { + MultiOrgGitHubAppCredentials multiOrgCreds = (MultiOrgGitHubAppCredentials) c; + + // Check if this organization is available in the list of organizations + List availableOrgs = multiOrgCreds.getAvailableOrganizations(); + + if (availableOrgs.contains(repoOwner)) { + // Create organization-specific credentials + c = multiOrgCreds.forOrganization(repoOwner); + } else { + LOGGER.log( + Level.FINE, + "Organization {0} not found in available organizations for " + + "GitHub App ID {1}. Available organizations: {2}. Will attempt to use anyway.", + new Object[] {repoOwner, multiOrgCreds.getAppID(), String.join(", ", availableOrgs)}); + // Still try to create credentials for this org, in case it's a new installation + c = multiOrgCreds.forOrganization(repoOwner); + } + } else if (c instanceof GitHubAppCredentials + && !(c instanceof MultiOrgGitHubAppCredentials) + && repoOwner != null) { c = ((GitHubAppCredentials) c).withOwner(repoOwner); } return c; @@ -364,6 +384,24 @@ public static ListBoxModel listCheckoutCredentials(@CheckForNull Item context, S hash = "anonymous"; authHash = "anonymous"; gitHubAppCredentials = null; + } else if (credentials instanceof MultiOrgGitHubAppCredentials) { + password = null; + gitHubAppCredentials = (MultiOrgGitHubAppCredentials) credentials; + // For MultiOrgGitHubAppCredentials, we need to include both app ID and owner + String owner = gitHubAppCredentials.getOwner(); + String ownerForHash = owner != null ? owner : ""; + hash = Util.getDigestOf(gitHubAppCredentials.getAppID() + + "::" + ownerForHash + + "::" + gitHubAppCredentials.getPrivateKey().getPlainText() + + "::" + SALT); // want to ensure pooling by credential + authHash = Util.getDigestOf(gitHubAppCredentials.getAppID() + + "::" + + ownerForHash + + "::" + + gitHubAppCredentials.getPrivateKey().getPlainText() + + "::" + + jenkins.getLegacyInstanceId()); + username = gitHubAppCredentials.getUsername(); } else if (credentials instanceof GitHubAppCredentials) { password = null; gitHubAppCredentials = (GitHubAppCredentials) credentials; diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentials.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentials.java new file mode 100644 index 000000000..9cfc0ee20 --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentials.java @@ -0,0 +1,458 @@ +package org.jenkinsci.plugins.github_branch_source; + +import com.cloudbees.plugins.credentials.CredentialsMatchers; +import com.cloudbees.plugins.credentials.CredentialsProvider; +import com.cloudbees.plugins.credentials.CredentialsScope; +import com.cloudbees.plugins.credentials.common.StandardListBoxModel; +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import hudson.Extension; +import hudson.model.Item; +import hudson.security.ACL; +import hudson.util.FormValidation; +import hudson.util.ListBoxModel; +import hudson.util.Secret; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; +import jenkins.model.Jenkins; +import org.kohsuke.github.GHApp; +import org.kohsuke.github.GHAppInstallation; +import org.kohsuke.github.GHException; +import org.kohsuke.github.GitHub; +import org.kohsuke.github.GitHubBuilder; +import org.kohsuke.github.authorization.AuthorizationProvider; +import org.kohsuke.github.extras.authorization.JWTTokenProvider; +import org.kohsuke.stapler.AncestorInPath; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.verb.POST; + +/** + * Enhanced GitHub App credentials that support multiple organizations. + * + *

This credential type extends the basic GitHub App credentials but adds + * the capability to dynamically select from available organizations where + * the app is installed, without requiring duplicate credentials.

+ * + *

Key features: + *
- Automatic discovery of organizations where the GitHub App is installed + *
- Organization-specific token generation on demand + *
- Token caching to minimize API calls + *
- Seamless integration with existing GitHub SCM Sources + *

+ * + * @since 2.15.0 + */ +@SuppressFBWarnings(value = "SE_NO_SERIALVERSIONID", justification = "XStream") +public class MultiOrgGitHubAppCredentials extends GitHubAppCredentials { + + private static final Logger LOGGER = Logger.getLogger(MultiOrgGitHubAppCredentials.class.getName()); + + /** + * Cached list of available organizations for this app + */ + private transient volatile List availableOrganizations; + + /** + * Last time the organizations were refreshed (in milliseconds) + */ + private transient volatile long lastRefreshTime; + + /** + * How long to cache the organizations list before refreshing (in milliseconds) + */ + private static final long CACHE_TTL = 3600000; // 1 hour + + /** + * Maximum number of cached tokens to prevent memory leaks + */ + private static final int MAX_CACHED_TOKENS = 100; + + /** + * Minimum interval between API calls to prevent rate limiting (in milliseconds) + */ + private static final long MIN_API_CALL_INTERVAL = 100; // 100ms + + /** + * Last time an API call was made + */ + private transient volatile long lastApiCall; + + /** + * Cache of tokens by organization + */ + private transient volatile Map tokensByOrg; + + /** + * Lock object for synchronizing access to tokensByOrg + */ + private final Object tokensByOrgLock = new Object(); + + @DataBoundConstructor + public MultiOrgGitHubAppCredentials( + CredentialsScope scope, + String id, + @CheckForNull String description, + @NonNull String appID, + @NonNull Secret privateKey) { + super(scope, id, description, appID, privateKey); + + // Input validation + if (appID == null || appID.trim().isEmpty()) { + throw new IllegalArgumentException("GitHub App ID cannot be null or empty"); + } + if (privateKey == null + || privateKey.getPlainText() == null + || privateKey.getPlainText().trim().isEmpty()) { + throw new IllegalArgumentException("GitHub App private key cannot be null or empty"); + } + } + + /** + * Returns a list of available organizations where this GitHub App is installed. + * Results are cached to avoid frequent API calls. + * + * @return list of organization names + */ + public List getAvailableOrganizations() { + long now = System.currentTimeMillis(); + if (availableOrganizations == null || (now - lastRefreshTime) > CACHE_TTL) { + refreshAvailableOrganizations(true); // Use rate limiting for internal calls + } + return availableOrganizations != null + ? Collections.unmodifiableList(availableOrganizations) + : Collections.emptyList(); + } + + /** + * Refreshes the list of available organizations where this GitHub App is installed. + */ + public void refreshAvailableOrganizations() { + refreshAvailableOrganizations(false); // No rate limiting for direct calls + } + + /** + * Internal method to refresh organizations with optional rate limiting. + */ + private void refreshAvailableOrganizations(boolean useRateLimiting) { + // Rate limiting protection - but allow calls when cache is expired or when not using rate limiting + long now = System.currentTimeMillis(); + boolean cacheExpired = (now - lastRefreshTime) > CACHE_TTL; + if (useRateLimiting && !cacheExpired && now - lastApiCall < MIN_API_CALL_INTERVAL) { + LOGGER.log(Level.FINE, "Skipping API call due to rate limiting"); + return; + } + lastApiCall = now; + try { + // Create GitHub instance with JWT authentication + String apiUrl = actualApiUri(); + String appId = getAppID(); + String privateKeyStr = getPrivateKey().getPlainText(); + + // Create JWT token provider + AuthorizationProvider jwtProvider = createJwtProvider(appId, privateKeyStr); + + // Build GitHub instance with JWT authentication + GitHub gitHubApp = new GitHubBuilder() + .withEndpoint(apiUrl) + .withAuthorizationProvider(jwtProvider) + .build(); + + GHApp app = gitHubApp.getApp(); + List appInstallations = app.listInstallations().asList(); + + List organizations = new ArrayList<>(); + for (GHAppInstallation installation : appInstallations) { + try { + String login = installation.getAccount().getLogin(); + organizations.add(login); + } catch (RuntimeException e) { + LOGGER.log(Level.WARNING, "Error getting login for installation: " + e.getMessage(), e); + } + } + + this.availableOrganizations = organizations; + this.lastRefreshTime = System.currentTimeMillis(); + + LOGGER.log(Level.FINE, "Refreshed available organizations for GitHub App ID {0}: {1}", new Object[] { + getAppID(), String.join(", ", availableOrganizations) + }); + } catch (IOException | GHException e) { + LOGGER.log(Level.WARNING, "Failed to retrieve available organizations for GitHub App ID " + getAppID(), e); + // Initialize with empty list if not set + if (this.availableOrganizations == null) { + this.availableOrganizations = new ArrayList<>(); + } + } + } + + /** + * Forces a refresh of the available organizations list, regardless of cache status. + * This can be used when a GitHub App is installed to a new organization. + */ + public void forceRefreshOrganizations() { + this.lastRefreshTime = 0; // Invalidate cache + this.lastApiCall = 0; // Reset rate limiting for forced refresh + refreshAvailableOrganizations(false); // No rate limiting for forced calls + } + + /** + * Gets an installation token for a specific organization. + * This ensures that the token is valid for the specified organization. + * + * @param orgName the organization name + * @return the app installation token for the organization + */ + public GitHubAppCredentials.AppInstallationToken getTokenForOrg(String orgName) { + if (tokensByOrg == null) { + tokensByOrg = new HashMap<>(); + } + + synchronized (tokensByOrgLock) { + GitHubAppCredentials.AppInstallationToken token = tokensByOrg.get(orgName); + + try { + if (token == null || token.isStale()) { + // Clean up expired tokens before adding new ones + cleanupExpiredTokens(); + + // Generate a token specifically for this organization + token = GitHubAppCredentials.generateAppInstallationToken( + null, getAppID(), getPrivateKey().getPlainText(), actualApiUri(), orgName); + tokensByOrg.put(orgName, token); + + // Enforce cache size limit + if (tokensByOrg.size() > MAX_CACHED_TOKENS) { + // Remove oldest tokens (simple LRU-like cleanup) + String oldestOrg = tokensByOrg.keySet().iterator().next(); + tokensByOrg.remove(oldestOrg); + LOGGER.log(Level.FINE, "Removed oldest cached token for org: " + oldestOrg); + } + } + } catch (RuntimeException e) { + if (token != null && !token.isExpired()) { + // Requesting a new token failed. If the cached token is not expired, continue to use it. + LOGGER.log( + Level.WARNING, + "Failed to generate new GitHub App Installation Token for app ID " + + getAppID() + " and org " + orgName + + ": cached token is stale but has not expired", + e); + } else { + throw new RuntimeException( + "Failed to generate GitHub App Installation Token for org " + orgName, e); + } + } + + return token; + } + } + + /** + * Cleanup expired tokens from the cache to prevent memory leaks. + * This method should be called from within a synchronized block on tokensByOrgLock. + */ + private void cleanupExpiredTokens() { + if (tokensByOrg == null) return; + + Iterator> iterator = + tokensByOrg.entrySet().iterator(); + + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (entry.getValue().isExpired()) { + iterator.remove(); + LOGGER.log(Level.FINE, "Removed expired token for org: " + entry.getKey()); + } + } + } + + /** + * {@inheritDoc} + * + * For MultiOrgGitHubAppCredentials, we use the token specific to the currently set owner. + * If no owner is set, we return a token for the first available organization. + */ + @NonNull + @Override + public Secret getPassword() { + String owner = getOwner(); + + // If no specific owner is set, use the first available organization + if (owner == null || owner.isEmpty()) { + List orgs = getAvailableOrganizations(); + if (!orgs.isEmpty()) { + owner = orgs.get(0); + } + } + + // If we have an owner, get a token specifically for that org + if (owner != null && !owner.isEmpty()) { + return getTokenForOrg(owner).getToken(); + } + + // Fall back to the parent implementation if we couldn't determine an owner + return super.getPassword(); + } + + /** + * Create a specific GitHubAppCredentials instance for the given organization. + * This method allows using the MultiOrgGitHubAppCredentials to generate + * organization-specific credentials dynamically. + * + * @param orgName the organization name to create credentials for + * @return a GitHubAppCredentials instance configured for the specific organization + */ + public GitHubAppCredentials forOrganization(@NonNull String orgName) { + // Check if this organization is available + if (!getAvailableOrganizations().contains(orgName)) { + LOGGER.log( + Level.WARNING, + "Organization {0} is not in the list of available organizations for GitHub App ID {1}. " + + "Available organizations: {2}", + new Object[] {orgName, getAppID(), String.join(", ", getAvailableOrganizations())}); + // Still create the credential, but log a warning + } + + // Create specialized credentials for this org + GitHubAppCredentials credentials = withOwner(orgName); + + // Pre-warm the token cache for this organization + try { + getTokenForOrg(orgName); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to pre-warm token cache for organization {0}: {1}", new Object[] { + orgName, e.getMessage() + }); + } + + return credentials; + } + + /** + * Creates a JWT token provider for GitHub App authentication. + * + * @param appId the GitHub App ID + * @param appPrivateKey the private key for the GitHub App + * @return an AuthorizationProvider for JWT authentication + */ + private static AuthorizationProvider createJwtProvider(String appId, String appPrivateKey) { + try { + return new JWTTokenProvider(appId, appPrivateKey); + } catch (GeneralSecurityException e) { + throw new IllegalArgumentException( + "Couldn't parse private key for GitHub app, make sure it's PKCS#8 format", e); + } + } + + /** + * The descriptor for {@link MultiOrgGitHubAppCredentials}. + */ + @Extension + public static class DescriptorImpl extends GitHubAppCredentials.DescriptorImpl { + + /** {@inheritDoc} */ + @Override + public String getDisplayName() { + return Messages.MultiOrgGitHubAppCredentials_displayName(); + } + + /** + * Returns the available GitHub organizations for the credential. + * + * @param credentialId the credential ID + * @return list box model with available organizations + */ + @POST + public ListBoxModel doFillOrganizationItems(@AncestorInPath Item context, @QueryParameter String credentialId) { + StandardListBoxModel result = new StandardListBoxModel(); + + if (credentialId.isEmpty()) { + return result.includeEmptyValue(); + } + + if (context == null && !Jenkins.get().hasPermission(Jenkins.ADMINISTER) + || context != null && !context.hasPermission(Item.EXTENDED_READ)) { + return result.includeEmptyValue(); + } + + MultiOrgGitHubAppCredentials credentials = findCredentialById(credentialId); + if (credentials != null) { + List organizations = credentials.getAvailableOrganizations(); + for (String org : organizations) { + result.add(org); + } + } + + return result; + } + + @POST + public FormValidation doTestMultiOrgConnection( + @QueryParameter("appID") final String appID, + @QueryParameter("privateKey") final String privateKey, + @QueryParameter("apiUri") final String apiUri) { + + // Validate required parameters + if (appID == null || appID.trim().isEmpty()) { + return FormValidation.error("App ID is required"); + } + if (privateKey == null || privateKey.trim().isEmpty()) { + return FormValidation.error("Private key is required"); + } + + try { + MultiOrgGitHubAppCredentials gitHubAppCredential = new MultiOrgGitHubAppCredentials( + CredentialsScope.GLOBAL, "test-id-not-being-saved", null, appID, Secret.fromString(privateKey)); + gitHubAppCredential.setApiUri(apiUri); + + // Force refresh to get the latest organizations + gitHubAppCredential.forceRefreshOrganizations(); + List organizations = gitHubAppCredential.getAvailableOrganizations(); + + if (organizations.isEmpty()) { + return FormValidation.warning("GitHub App is not installed to any organizations. " + + "Please install the GitHub App in at least one organization."); + } + + // Test connection with the first available organization + String testOrg = organizations.get(0); + GitHub connect = Connector.connect(apiUri, gitHubAppCredential.forOrganization(testOrg)); + try { + int remainingRate = connect.getRateLimit().getRemaining(); + String orgList = String.join(", ", organizations); + String message = + "Success! Available organizations: " + orgList + ". Remaining rate limit: " + remainingRate; + return FormValidation.ok(message); + } finally { + Connector.release(connect); + } + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Test connection failed for GitHub App ID " + appID, e); + return FormValidation.error( + e, "Failed to authenticate with GitHub App ID " + appID + ": " + e.getMessage()); + } + } + + private MultiOrgGitHubAppCredentials findCredentialById(String id) { + if (id == null || id.isEmpty()) { + return null; + } + + // Use CredentialsMatchers to find the credential by ID + return CredentialsMatchers.firstOrNull( + CredentialsProvider.lookupCredentials( + MultiOrgGitHubAppCredentials.class, Jenkins.get(), ACL.SYSTEM, Collections.emptyList()), + CredentialsMatchers.withId(id)); + } + } +} diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentialsBinding.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentialsBinding.java new file mode 100644 index 000000000..87575233d --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentialsBinding.java @@ -0,0 +1,447 @@ +package org.jenkinsci.plugins.github_branch_source; + +import com.cloudbees.plugins.credentials.CredentialsMatchers; +import com.cloudbees.plugins.credentials.CredentialsProvider; +import com.cloudbees.plugins.credentials.common.StandardListBoxModel; +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import hudson.FilePath; +import hudson.Launcher; +import hudson.model.Item; +import hudson.model.Run; +import hudson.model.TaskListener; +import hudson.security.ACL; +import hudson.util.FormValidation; +import hudson.util.ListBoxModel; +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; +import jenkins.model.Jenkins; +import org.jenkinsci.Symbol; +import org.jenkinsci.plugins.credentialsbinding.BindingDescriptor; +import org.jenkinsci.plugins.credentialsbinding.MultiBinding; +import org.kohsuke.stapler.AncestorInPath; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.verb.POST; + +/** + * Credentials binding for Multi-Organization GitHub App Credentials. + * + *

This binding allows pipeline scripts to access organization-specific GitHub tokens + * from Multi-Organization GitHub App Credentials. It provides both a general token + * variable and organization-specific token variables.

+ * + *

Usage in pipeline:

+ *
{@code
+ * // Automatic mode - provides tokens for all organizations
+ * withCredentials([
+ *   multiOrgGitHubApp(credentialsId: 'my-multi-org-app')
+ * ]) {
+ *   // Available variables:
+ *   // $GITHUB_ORGS - comma-separated list of organizations
+ *   // $GITHUB_TOKEN_ - token for each organization
+ *   sh 'git clone https://$GITHUB_TOKEN_MYORG@github.com/myorg/repo.git'
+ * }
+ *
+ * // Manual mode - specify organization and token variable name
+ * withCredentials([
+ *   multiOrgGitHubApp(credentialsId: 'my-multi-org-app',
+ *                     tokenVariable: 'GITHUB_TOKEN',
+ *                     orgName: 'myorg')
+ * ]) {
+ *   // Use $GITHUB_TOKEN for the specified organization
+ *   sh 'git clone https://$GITHUB_TOKEN@github.com/myorg/repo.git'
+ * }
+ * }
+ * + *

Available environment variables: + *
Automatic mode (no parameters): + *
- {@code $GITHUB_ORGS} - Comma-separated list of available organizations + *
- {@code $GITHUB_TOKEN_} - Token for each organization + *
Manual mode (with parameters): + *
- {@code $} - Token for the specified organization + *
- {@code $GITHUB_ORGS} - Comma-separated list of available organizations + *

+ * + * @since 2.15.0 + */ +public class MultiOrgGitHubAppCredentialsBinding extends MultiBinding { + + private static final Logger LOGGER = Logger.getLogger(MultiOrgGitHubAppCredentialsBinding.class.getName()); + + /** + * The variable name for the GitHub token (optional) + */ + @CheckForNull + private final String tokenVariable; + + /** + * The organization name to get token for (optional) + */ + @CheckForNull + private final String orgName; + + /** + * Constructor. + * + * @param tokenVariable the variable name for the GitHub token (optional) + * @param orgName the organization name to get token for (optional) + * @param credentialsId the credentials ID + */ + @DataBoundConstructor + public MultiOrgGitHubAppCredentialsBinding( + @CheckForNull String tokenVariable, @CheckForNull String orgName, @NonNull String credentialsId) { + super(credentialsId); + + // Input validation + if (credentialsId == null || credentialsId.trim().isEmpty()) { + throw new IllegalArgumentException("Credentials ID cannot be null or empty"); + } + + // Validate that if either tokenVariable or orgName is provided, both must be provided + boolean hasTokenVar = tokenVariable != null && !tokenVariable.trim().isEmpty(); + boolean hasOrgName = orgName != null && !orgName.trim().isEmpty(); + + if (hasTokenVar != hasOrgName) { + throw new IllegalArgumentException( + "Both tokenVariable and orgName must be provided together for manual mode, or both must be null/empty for automatic mode"); + } + + this.tokenVariable = tokenVariable; + this.orgName = orgName; + } + + /** + * Returns the variable name for the GitHub token. + * + * @return the token variable name or null if automatic mode + */ + @CheckForNull + public String getTokenVariable() { + return tokenVariable; + } + + /** + * Returns the organization name to get token for. + * + * @return the organization name or null if automatic mode + */ + @CheckForNull + public String getOrgName() { + return orgName; + } + + /** + * Checks if this binding is in automatic mode (no parameters specified). + * + * @return true if in automatic mode + */ + public boolean isAutomaticMode() { + return (tokenVariable == null || tokenVariable.trim().isEmpty()) + && (orgName == null || orgName.trim().isEmpty()); + } + + @Override + protected Class type() { + return MultiOrgGitHubAppCredentials.class; + } + + @Override + public MultiEnvironment bind(@NonNull Run build, FilePath workspace, Launcher launcher, TaskListener listener) + throws IOException, InterruptedException { + + MultiOrgGitHubAppCredentials credentials = getCredentials(build); + if (credentials == null) { + throw new IOException("Could not find Multi-Org GitHub App credentials: " + getCredentialsId()); + } + + // Get available organizations + List organizations = credentials.getAvailableOrganizations(); + if (organizations.isEmpty()) { + listener.getLogger() + .println("Warning: No organizations found for GitHub App. " + + "Make sure the app is installed to at least one organization."); + } + + Map secretValues = new HashMap<>(); + Map publicValues = new HashMap<>(); + + try { + if (isAutomaticMode()) { + // Automatic mode: provide tokens for all organizations with standard naming + listener.getLogger() + .printf( + "Binding Multi-Org GitHub App credentials in automatic mode for %d organizations%n", + organizations.size()); + + for (String org : organizations) { + try { + String orgToken = + credentials.forOrganization(org).getPassword().getPlainText(); + String tokenVarName = "GITHUB_TOKEN_" + sanitizeOrgName(org); + secretValues.put(tokenVarName, orgToken); + + listener.getLogger().printf("Set %s for organization: %s%n", tokenVarName, org); + } catch (RuntimeException e) { + listener.getLogger() + .printf("Warning: Failed to get token for organization %s: %s%n", org, e.getMessage()); + LOGGER.log(Level.WARNING, "Failed to get token for organization " + org, e); + } + } + + // Set list of available organizations (not sensitive) + String orgsListVar = "GITHUB_ORGS"; + publicValues.put(orgsListVar, String.join(",", organizations)); + listener.getLogger() + .printf("Set %s with organizations: %s%n", orgsListVar, String.join(", ", organizations)); + + } else { + // Manual mode: use specified organization and variable name + listener.getLogger().printf("Binding Multi-Org GitHub App credentials in manual mode%n"); + + // Validate that both tokenVariable and orgName are provided in manual mode + if (tokenVariable == null || tokenVariable.trim().isEmpty()) { + throw new IOException("Token variable name is required when orgName is specified"); + } + if (orgName == null || orgName.trim().isEmpty()) { + throw new IOException("Organization name is required when token variable is specified"); + } + + // Verify the organization exists + if (!organizations.contains(orgName)) { + throw new IOException("Organization '" + orgName + "' not found in available organizations: " + + String.join(", ", organizations)); + } + + // Set token for the specified organization + try { + String orgToken = + credentials.forOrganization(orgName).getPassword().getPlainText(); + secretValues.put(tokenVariable, orgToken); + + listener.getLogger().printf("Set %s for organization: %s%n", tokenVariable, orgName); + } catch (RuntimeException e) { + throw new IOException("Failed to get token for organization " + orgName + ": " + e.getMessage(), e); + } + + // Set list of available organizations (not sensitive) + String orgsListVar = "GITHUB_ORGS"; + publicValues.put(orgsListVar, String.join(",", organizations)); + listener.getLogger() + .printf("Set %s with organizations: %s%n", orgsListVar, String.join(", ", organizations)); + } + + return new MultiEnvironment(secretValues, publicValues); + + } catch (IOException e) { + throw e; // Re-throw IOException as-is + } catch (RuntimeException e) { + throw new IOException("Failed to bind Multi-Org GitHub App credentials", e); + } + } + + @Override + public Set variables(@NonNull Run build) { + try { + MultiOrgGitHubAppCredentials credentials = getCredentials(build); + + Set vars = new HashSet<>(); + vars.add("GITHUB_ORGS"); // Always add this + + if (isAutomaticMode()) { + // In automatic mode, add variables for all organizations + List organizations = credentials.getAvailableOrganizations(); + for (String org : organizations) { + vars.add("GITHUB_TOKEN_" + sanitizeOrgName(org)); + } + } else { + // In manual mode, add only the specified token variable + if (tokenVariable != null && !tokenVariable.trim().isEmpty()) { + vars.add(tokenVariable); + } + } + + return vars; + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to get credential variables", e); + Set fallback = new HashSet<>(); + fallback.add("GITHUB_ORGS"); + if (tokenVariable != null && !tokenVariable.trim().isEmpty()) { + fallback.add(tokenVariable); + } + return fallback; + } + } + + /** + * Sanitizes organization name for use as environment variable suffix. + * Replaces non-alphanumeric characters with underscores and converts to uppercase. + * + * @param orgName the organization name + * @return sanitized name suitable for environment variable + */ + private String sanitizeOrgName(String orgName) { + return orgName.replaceAll("[^a-zA-Z0-9]", "_").toUpperCase(); + } + + /** + * The descriptor for {@link MultiOrgGitHubAppCredentialsBinding}. + */ + @Symbol("multiOrgGitHubApp") + @Extension + public static class DescriptorImpl extends BindingDescriptor { + + @Override + public String getDisplayName() { + return Messages.MultiOrgGitHubAppCredentialsBinding_displayName(); + } + + @Override + protected Class type() { + return MultiOrgGitHubAppCredentials.class; + } + + /** + * Form validation for the token variable field. + * + * @param value the token variable name + * @param orgName the organization name (for context) + * @return form validation result + */ + @POST + public FormValidation doCheckTokenVariable(@QueryParameter String value, @QueryParameter String orgName) { + boolean hasOrgName = orgName != null && !orgName.trim().isEmpty(); + boolean hasTokenVariable = value != null && !value.trim().isEmpty(); + + // If orgName is specified, tokenVariable is required + if (hasOrgName && !hasTokenVariable) { + return FormValidation.error("Token variable name is required when organization name is specified"); + } + + // If tokenVariable is specified, orgName is required + if (hasTokenVariable && !hasOrgName) { + return FormValidation.error("Organization name is required when token variable is specified"); + } + + // If tokenVariable is provided, validate format + if (hasTokenVariable && !value.matches("[A-Z_][A-Z0-9_]*")) { + return FormValidation.warning( + "Token variable name should follow environment variable naming conventions (uppercase, underscores)"); + } + + return FormValidation.ok(); + } + + /** + * Form validation for the organization name field. + * + * @param value the organization name + * @param tokenVariable the token variable name (for context) + * @return form validation result + */ + @POST + public FormValidation doCheckOrgName(@QueryParameter String value, @QueryParameter String tokenVariable) { + boolean hasTokenVariable = + tokenVariable != null && !tokenVariable.trim().isEmpty(); + boolean hasOrgName = value != null && !value.trim().isEmpty(); + + // If tokenVariable is specified, orgName is required + if (hasTokenVariable && !hasOrgName) { + return FormValidation.error("Organization name is required when token variable is specified"); + } + + // If orgName is specified, tokenVariable is required + if (hasOrgName && !hasTokenVariable) { + return FormValidation.error("Token variable name is required when organization name is specified"); + } + + return FormValidation.ok(); + } + + /** + * Fills the credentials dropdown with Multi-Org GitHub App credentials. + * + * @param context the context + * @param credentialsId the current credentials ID + * @return list box model with available credentials + */ + @POST + public ListBoxModel doFillCredentialsIdItems( + @AncestorInPath Item context, @QueryParameter String credentialsId) { + StandardListBoxModel result = new StandardListBoxModel(); + + if (context == null && !Jenkins.get().hasPermission(Jenkins.ADMINISTER) + || context != null && !context.hasPermission(Item.EXTENDED_READ)) { + return result.includeCurrentValue(credentialsId); + } + + return result.includeEmptyValue() + .includeAs(ACL.SYSTEM, context, MultiOrgGitHubAppCredentials.class) + .includeCurrentValue(credentialsId); + } + + /** + * Tests the Multi-Org GitHub App credentials and shows available organizations. + * + * @param context the context + * @param credentialsId the credentials ID to test + * @return form validation result with organization information + */ + @POST + public FormValidation doTestCredentials(@AncestorInPath Item context, @QueryParameter String credentialsId) { + if (context == null && !Jenkins.get().hasPermission(Jenkins.ADMINISTER) + || context != null && !context.hasPermission(Item.EXTENDED_READ)) { + return FormValidation.ok(); + } + + if (credentialsId == null || credentialsId.trim().isEmpty()) { + return FormValidation.error("Please select credentials"); + } + + try { + MultiOrgGitHubAppCredentials credentials = findCredentialById(credentialsId); + + if (credentials == null) { + return FormValidation.error("Credentials not found"); + } + + List organizations = credentials.getAvailableOrganizations(); + if (organizations.isEmpty()) { + return FormValidation.warning( + "No organizations found. Make sure the GitHub App is installed to at least one organization."); + } + + String message = String.format( + "Success! Found %d organization(s): %s", + organizations.size(), String.join(", ", organizations)); + return FormValidation.ok(message); + + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to test Multi-Org GitHub App credentials: " + credentialsId, e); + return FormValidation.error("Failed to test credentials: " + e.getMessage()); + } + } + + /** + * Helper method to find Multi-Org GitHub App credentials by ID. + */ + private MultiOrgGitHubAppCredentials findCredentialById(String id) { + if (id == null || id.isEmpty()) { + return null; + } + + return CredentialsMatchers.firstOrNull( + CredentialsProvider.lookupCredentials( + MultiOrgGitHubAppCredentials.class, Jenkins.get(), ACL.SYSTEM, Collections.emptyList()), + CredentialsMatchers.withId(id)); + } + } +} diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/Messages.properties b/src/main/resources/org/jenkinsci/plugins/github_branch_source/Messages.properties index cd647028e..be77af0e1 100644 --- a/src/main/resources/org/jenkinsci/plugins/github_branch_source/Messages.properties +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/Messages.properties @@ -73,5 +73,7 @@ GitHubSCMNavigator.general=General GitHubSCMNavigator.withinRepository=Within repository GitHubAppCredentials.displayName=GitHub App +MultiOrgGitHubAppCredentials.displayName=GitHub App (Multi-Organization) +MultiOrgGitHubAppCredentialsBinding.displayName=Multi-Organization GitHub App credentials IgnoreDraftPullRequestFilterTrait.DisplayName=Ignore pull requests marked as drafts diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentials/config.jelly b/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentials/config.jelly new file mode 100644 index 000000000..ff372955c --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentials/config.jelly @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentials/help-apiUri.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentials/help-apiUri.html new file mode 100644 index 000000000..288614db7 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentials/help-apiUri.html @@ -0,0 +1,25 @@ +
+ The GitHub API endpoint to use for authentication and repository access. + +

Standard Options:

+
    +
  • GitHub.com: Use this for repositories hosted on github.com
  • +
  • GitHub Enterprise Server: Select this if you're using GitHub Enterprise Server on your own infrastructure
  • +
+ +

GitHub Enterprise Server Setup:

+

+ If you're using GitHub Enterprise Server, make sure to: +

+
    +
  1. Configure the endpoint URL in Jenkins → Manage Jenkins → Configure System → GitHub Enterprise Servers
  2. +
  3. Ensure your GitHub App is created on the GitHub Enterprise Server instance
  4. +
  5. Verify network connectivity between Jenkins and your GitHub Enterprise Server
  6. +
+ +

Multi-Organization Considerations:

+

+ All organizations where your GitHub App is installed must be on the same GitHub instance + (either all on github.com or all on the same GitHub Enterprise Server instance). +

+
diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentials/help-appID.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentials/help-appID.html new file mode 100644 index 000000000..d6c371fbb --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentials/help-appID.html @@ -0,0 +1,23 @@ +
+ The App ID of your GitHub App. This is a unique identifier assigned to your GitHub App when you create it. + +

How to find your GitHub App ID:

+
    +
  1. Go to your GitHub organization or user settings
  2. +
  3. Navigate to "Developer settings" → "GitHub Apps"
  4. +
  5. Click on your GitHub App
  6. +
  7. The App ID is displayed near the top of the settings page
  8. +
+ +

Multi-Organization Support:

+

+ Unlike regular GitHub App credentials, this credential type automatically discovers + all organizations where your GitHub App is installed. You only need to create one + credential that can be used across multiple organizations. +

+ +

+ Note: Make sure your GitHub App has the necessary permissions and + is installed in all the organizations you want to access. +

+
diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentials/help-privateKey.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentials/help-privateKey.html new file mode 100644 index 000000000..366695f91 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentials/help-privateKey.html @@ -0,0 +1,28 @@ +
+ The private key associated with your GitHub App. This key is used to authenticate + your Jenkins instance with GitHub. + +

How to get your GitHub App private key:

+
    +
  1. Go to your GitHub organization or user settings
  2. +
  3. Navigate to "Developer settings" → "GitHub Apps"
  4. +
  5. Click on your GitHub App
  6. +
  7. Scroll down to the "Private keys" section
  8. +
  9. Click "Generate a private key" if you haven't already
  10. +
  11. Download the .pem file and copy its contents here
  12. +
+ +

Security considerations:

+
    +
  • Keep your private key secure and never share it publicly
  • +
  • The private key is stored encrypted in Jenkins
  • +
  • You can regenerate the key in GitHub if it gets compromised
  • +
  • The key should be in PKCS#8 format (the default GitHub format)
  • +
+ +

Format example:

+
-----BEGIN RSA PRIVATE KEY-----
+MIIEpAIBAAKCAQEA...
+...your key content...
+-----END RSA PRIVATE KEY-----
+
diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentials/help.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentials/help.html new file mode 100644 index 000000000..322356fcf --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentials/help.html @@ -0,0 +1,39 @@ +
+

Multi-Organization GitHub App Credentials

+ +

+ This credential type allows you to use a single GitHub App across multiple organizations + without creating separate credentials for each organization. It automatically discovers + all organizations where your GitHub App is installed and can generate organization-specific + tokens as needed. +

+ +

Key Benefits:

+
    +
  • No Credential Duplication: One credential works across all organizations
  • +
  • Automatic Discovery: Automatically finds organizations where your app is installed
  • +
  • Dynamic Token Generation: Creates organization-specific tokens on demand
  • +
  • Easy Management: Add/remove organizations by installing/uninstalling the app
  • +
+ +

Prerequisites:

+
    +
  1. Create a GitHub App in your GitHub organization or user account
  2. +
  3. Install the GitHub App in all organizations you want to access
  4. +
  5. Grant necessary permissions (typically: Contents, Metadata, Pull requests)
  6. +
  7. Generate a private key for the GitHub App
  8. +
+ +

Usage:

+

+ After creating this credential, you can use it in any GitHub SCM Source or Navigator + configuration. The system will automatically select the appropriate organization-specific + token based on the repository being accessed. +

+ +

Testing:

+

+ Use the "Test Connection" button to verify your configuration. It will show all + organizations where your GitHub App is installed and confirm connectivity. +

+
diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentialsBinding/config.jelly b/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentialsBinding/config.jelly new file mode 100644 index 000000000..b30ba932e --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentialsBinding/config.jelly @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentialsBinding/help-credentialsId.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentialsBinding/help-credentialsId.html new file mode 100644 index 000000000..c2976cd4c --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentialsBinding/help-credentialsId.html @@ -0,0 +1,11 @@ +
+ The Multi-Organization GitHub App credentials to bind. +

+ Select the credentials that have access to the organizations you need to work with. + The binding will provide tokens for all organizations where the GitHub App is installed. +

+

+ Use the "Test Credentials" button to verify the credentials and see which organizations + are available. +

+
diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentialsBinding/help-orgName.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentialsBinding/help-orgName.html new file mode 100644 index 000000000..1b82809fb --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentialsBinding/help-orgName.html @@ -0,0 +1,19 @@ +
+ Specific organization name to get the GitHub token for. +

+ When specified along with a variable name, the binding will provide a token + for this specific organization only. +

+

+ Example: If you set this to myorg and variable to GITHUB_TOKEN, + the binding will provide: +

+
    +
  • GITHUB_TOKEN - Token for the "myorg" organization
  • +
  • GITHUB_ORGS - List of all available organizations
  • +
+

+ Note: If left empty (along with variable), the binding operates in automatic mode + and provides tokens for all available organizations using standard variable names. +

+
diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentialsBinding/help-tokenVariable.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentialsBinding/help-tokenVariable.html new file mode 100644 index 000000000..b242e9e48 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentialsBinding/help-tokenVariable.html @@ -0,0 +1,19 @@ +
+ The environment variable name that will contain the GitHub token. +

+ When used with an organization name, this variable will contain the token for + the specified organization. When used alone (without organization name), + the binding operates in automatic mode. +

+

+ Automatic mode: Leave both token variable and organization name empty to get + tokens for all organizations with standard naming (GITHUB_TOKEN_ORGNAME). +

+

+ Manual mode: Specify both token variable name and organization name to get + a token for a specific organization in your custom variable. +

+

+ Naming convention: Use uppercase letters and underscores (e.g., GITHUB_TOKEN, MY_TOKEN). +

+
diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentialsBinding/help.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentialsBinding/help.html new file mode 100644 index 000000000..567aa8694 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentialsBinding/help.html @@ -0,0 +1,61 @@ +
+

+ Binds Multi-Organization GitHub App credentials to environment variables for use in pipeline scripts. + Supports both automatic mode (tokens for all organizations) and manual mode (token for specific organization). +

+ +

+ Automatic Mode (no parameters): +

+
    +
  • GITHUB_TOKEN_<ORGNAME> - GitHub token for each organization (organization name is sanitized for environment variable use)
  • +
  • GITHUB_ORGS - Comma-separated list of all available organizations
  • +
+ +

+ Manual Mode (both tokenVariable and orgName required): +

+
    +
  • tokenVariable - Contains the GitHub token for the specified organization
  • +
  • GITHUB_ORGS - Comma-separated list of all available organizations
  • +
+ +

+ Example usage - Automatic Mode: +

+
withCredentials([
+    multiOrgGitHubApp(credentialsId: 'my-multi-org-app')
+]) {
+    // Use organization-specific tokens
+    sh 'git clone https://$GITHUB_TOKEN_MYORG@github.com/myorg/repo.git'
+    sh 'git clone https://$GITHUB_TOKEN_ANOTHERCORP@github.com/anothercorp/repo.git'
+    
+    // List available organizations
+    echo "Available orgs: $GITHUB_ORGS"
+}
+ +

+ Example usage - Manual Mode: +

+
withCredentials([
+    multiOrgGitHubApp(credentialsId: 'my-multi-org-app',
+                      tokenVariable: 'GITHUB_TOKEN',
+                      orgName: 'myorg')
+]) {
+    // Use specific organization token
+    sh 'curl -H "Authorization: token $GITHUB_TOKEN" https://api.github.com/orgs/myorg/repos'
+    
+    // List available organizations
+    echo "Available orgs: $GITHUB_ORGS"
+}
+ +

+ Use cases: +

+
    +
  • Cloning repositories from different organizations
  • +
  • Making API calls to different GitHub organizations
  • +
  • Dynamically determining available organizations
  • +
  • Custom Git operations with organization-specific authentication
  • +
+
diff --git a/src/test/java/org/jenkinsci/plugins/github_branch_source/GitHubApp.java b/src/test/java/org/jenkinsci/plugins/github_branch_source/GitHubApp.java index 37b385448..2937b87cf 100644 --- a/src/test/java/org/jenkinsci/plugins/github_branch_source/GitHubApp.java +++ b/src/test/java/org/jenkinsci/plugins/github_branch_source/GitHubApp.java @@ -48,4 +48,8 @@ public static GitHubAppCredentials createCredentials(final String id, final Stri credentials.setOwner(owner); return credentials; } + + public static String getPrivateKey() { + return PRIVATE_KEY; + } } diff --git a/src/test/java/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentialsBindingTest.java b/src/test/java/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentialsBindingTest.java new file mode 100644 index 000000000..204fd23c0 --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentialsBindingTest.java @@ -0,0 +1,204 @@ +package org.jenkinsci.plugins.github_branch_source; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +import com.cloudbees.plugins.credentials.CredentialsProvider; +import com.cloudbees.plugins.credentials.CredentialsScope; +import com.cloudbees.plugins.credentials.CredentialsStore; +import com.cloudbees.plugins.credentials.domains.Domain; +import hudson.util.Secret; +import java.util.Set; +import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; +import org.jenkinsci.plugins.workflow.job.WorkflowJob; +import org.junit.Before; +import org.junit.Test; + +/** + * Simple test cases for MultiOrgGitHubAppCredentialsBinding class. + */ +public class MultiOrgGitHubAppCredentialsBindingTest extends AbstractGitHubWireMockTest { + + private static final String TEST_APP_ID = "12345"; + private static final String TEST_PRIVATE_KEY = GitHubApp.getPrivateKey(); + private static final String TEST_CREDENTIALS_ID = "test-multi-org-binding"; + + private MultiOrgGitHubAppCredentials credentials; + private CredentialsStore store; + + @Before + public void setUp() throws Exception { + // Create test credentials + credentials = new MultiOrgGitHubAppCredentials( + CredentialsScope.GLOBAL, + TEST_CREDENTIALS_ID, + "Test Multi-Org GitHub App Credentials", + TEST_APP_ID, + Secret.fromString(TEST_PRIVATE_KEY)); + + // Add to credentials store + store = CredentialsProvider.lookupStores(r.jenkins).iterator().next(); + store.addCredentials(Domain.global(), credentials); + + // Manually set some test organizations to avoid API calls + setTestOrganizations(); + } + + private void setTestOrganizations() throws Exception { + // Use reflection to set test organizations directly + var orgField = MultiOrgGitHubAppCredentials.class.getDeclaredField("availableOrganizations"); + orgField.setAccessible(true); + var organizations = new java.util.ArrayList(); + organizations.add("testorg1"); + organizations.add("testorg2"); + orgField.set(credentials, organizations); + + // Set refresh time to avoid automatic refresh + var timeField = MultiOrgGitHubAppCredentials.class.getDeclaredField("lastRefreshTime"); + timeField.setAccessible(true); + timeField.set(credentials, System.currentTimeMillis()); + } + + @Test + public void testConstructorAndGetters() { + // Test manual mode constructor + MultiOrgGitHubAppCredentialsBinding binding = + new MultiOrgGitHubAppCredentialsBinding("GITHUB_TOKEN", "testorg1", TEST_CREDENTIALS_ID); + + assertThat(binding.getTokenVariable(), equalTo("GITHUB_TOKEN")); + assertThat(binding.getOrgName(), equalTo("testorg1")); + assertThat(binding.getCredentialsId(), equalTo(TEST_CREDENTIALS_ID)); + assertThat(binding.isAutomaticMode(), is(false)); + } + + @Test + public void testAutomaticModeConstructor() { + // Test automatic mode constructor (no parameters) + MultiOrgGitHubAppCredentialsBinding binding = + new MultiOrgGitHubAppCredentialsBinding(null, null, TEST_CREDENTIALS_ID); + + assertThat(binding.getTokenVariable(), is(nullValue())); + assertThat(binding.getOrgName(), is(nullValue())); + assertThat(binding.getCredentialsId(), equalTo(TEST_CREDENTIALS_ID)); + assertThat(binding.isAutomaticMode(), is(true)); + } + + @Test + public void testType() { + MultiOrgGitHubAppCredentialsBinding binding = + new MultiOrgGitHubAppCredentialsBinding("GITHUB_TOKEN", "testorg1", TEST_CREDENTIALS_ID); + + assertThat(binding.type(), equalTo(MultiOrgGitHubAppCredentials.class)); + } + + @Test + public void testVariablesInAutomaticMode() throws Exception { + MultiOrgGitHubAppCredentialsBinding binding = + new MultiOrgGitHubAppCredentialsBinding(null, null, TEST_CREDENTIALS_ID); + + // Create a mock build + WorkflowJob job = r.jenkins.createProject(WorkflowJob.class, "test-job"); + job.setDefinition(new CpsFlowDefinition("echo 'test'", true)); + var build = job.scheduleBuild2(0).get(); + + Set variables = binding.variables(build); + + // Should include GITHUB_ORGS and org-specific tokens + assertThat(variables, hasItem("GITHUB_ORGS")); + assertThat(variables, hasItem("GITHUB_TOKEN_TESTORG1")); + assertThat(variables, hasItem("GITHUB_TOKEN_TESTORG2")); + } + + @Test + public void testVariablesInManualMode() throws Exception { + MultiOrgGitHubAppCredentialsBinding binding = + new MultiOrgGitHubAppCredentialsBinding("MY_TOKEN", "testorg1", TEST_CREDENTIALS_ID); + + // Create a mock build + WorkflowJob job = r.jenkins.createProject(WorkflowJob.class, "test-job-manual"); + job.setDefinition(new CpsFlowDefinition("echo 'test'", true)); + var build = job.scheduleBuild2(0).get(); + + Set variables = binding.variables(build); + + // Should include GITHUB_ORGS and the specified token variable + assertThat(variables, hasItem("GITHUB_ORGS")); + assertThat(variables, hasItem("MY_TOKEN")); + + // Should not include automatic org-specific tokens + assertThat(variables, not(hasItem("GITHUB_TOKEN_TESTORG1"))); + assertThat(variables, not(hasItem("GITHUB_TOKEN_TESTORG2"))); + } + + @Test + public void testDescriptor() { + MultiOrgGitHubAppCredentialsBinding.DescriptorImpl descriptor = + new MultiOrgGitHubAppCredentialsBinding.DescriptorImpl(); + + assertThat(descriptor.getDisplayName(), equalTo("Multi-Organization GitHub App credentials")); + } + + @Test + public void testDescriptorCredentialsIdFill() { + MultiOrgGitHubAppCredentialsBinding.DescriptorImpl descriptor = + new MultiOrgGitHubAppCredentialsBinding.DescriptorImpl(); + + // Test with proper parameters as defined in the descriptor + var items = descriptor.doFillCredentialsIdItems(null, TEST_CREDENTIALS_ID); + + // Should have at least one item + assertThat(items.size(), greaterThan(0)); + } + + @Test + public void testEmptyTokenVariableIsAutomatic() { + // Test that empty string is treated as automatic mode + MultiOrgGitHubAppCredentialsBinding binding = + new MultiOrgGitHubAppCredentialsBinding("", "", TEST_CREDENTIALS_ID); + + assertThat(binding.isAutomaticMode(), is(true)); + } + + @Test + public void testWhitespaceTokenVariableIsAutomatic() { + // Test that whitespace-only string is treated as automatic mode + MultiOrgGitHubAppCredentialsBinding binding = + new MultiOrgGitHubAppCredentialsBinding(" ", " ", TEST_CREDENTIALS_ID); + + assertThat(binding.isAutomaticMode(), is(true)); + } + + @Test + public void testValidTokenVariableIsManual() { + // Test that non-empty token variable triggers manual mode + MultiOrgGitHubAppCredentialsBinding binding = + new MultiOrgGitHubAppCredentialsBinding("MY_TOKEN", "testorg1", TEST_CREDENTIALS_ID); + + assertThat(binding.isAutomaticMode(), is(false)); + } + + @Test + public void testConstructorValidation() { + // Test validation: both parameters must be provided together + try { + new MultiOrgGitHubAppCredentialsBinding("MY_TOKEN", null, TEST_CREDENTIALS_ID); + assertThat("Should have thrown IllegalArgumentException", false); + } catch (IllegalArgumentException e) { + assertThat(e.getMessage(), containsString("Both tokenVariable and orgName must be provided together")); + } + + try { + new MultiOrgGitHubAppCredentialsBinding(null, "testorg1", TEST_CREDENTIALS_ID); + assertThat("Should have thrown IllegalArgumentException", false); + } catch (IllegalArgumentException e) { + assertThat(e.getMessage(), containsString("Both tokenVariable and orgName must be provided together")); + } + + try { + new MultiOrgGitHubAppCredentialsBinding("MY_TOKEN", "", TEST_CREDENTIALS_ID); + assertThat("Should have thrown IllegalArgumentException", false); + } catch (IllegalArgumentException e) { + assertThat(e.getMessage(), containsString("Both tokenVariable and orgName must be provided together")); + } + } +} diff --git a/src/test/java/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentialsTest.java b/src/test/java/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentialsTest.java new file mode 100644 index 000000000..0809291a5 --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/github_branch_source/MultiOrgGitHubAppCredentialsTest.java @@ -0,0 +1,346 @@ +package org.jenkinsci.plugins.github_branch_source; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.junit.Assert.*; + +import com.cloudbees.plugins.credentials.CredentialsProvider; +import com.cloudbees.plugins.credentials.CredentialsScope; +import com.cloudbees.plugins.credentials.CredentialsStore; +import com.cloudbees.plugins.credentials.domains.Domain; +import hudson.util.FormValidation; +import hudson.util.ListBoxModel; +import hudson.util.Secret; +import java.time.Duration; +import java.time.format.DateTimeFormatter; +import java.util.Date; +import java.util.List; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Simple test cases for {@link MultiOrgGitHubAppCredentials}. + */ +public class MultiOrgGitHubAppCredentialsTest extends AbstractGitHubWireMockTest { + + private static final String TEST_APP_ID = "12345"; + private static final String TEST_CREDENTIAL_ID = "test-multi-org-app-creds"; + private static final String TEST_PRIVATE_KEY = GitHubApp.getPrivateKey(); + + private static CredentialsStore store; + private MultiOrgGitHubAppCredentials credentials; + + @BeforeClass + public static void setUpJenkins() throws Exception { + store = CredentialsProvider.lookupStores(r.jenkins).iterator().next(); + } + + @Before + public void setUp() throws Exception { + // Create fresh credentials for each test + credentials = new MultiOrgGitHubAppCredentials( + CredentialsScope.GLOBAL, + TEST_CREDENTIAL_ID, + "Test Multi-Org GitHub App", + TEST_APP_ID, + Secret.fromString(TEST_PRIVATE_KEY)); + credentials.setApiUri(githubApi.baseUrl()); + + // Set up WireMock stubs for GitHub API responses + setupGitHubApiStubs(); + } + + private void setupGitHubApiStubs() { + // Stub for getting the app information + githubApi.stubFor(get(urlEqualTo("/app")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\n" + " \"id\": " + + TEST_APP_ID + ",\n" + " \"name\": \"Test App\",\n" + + " \"owner\": {\n" + + " \"login\": \"test-owner\"\n" + + " }\n" + + "}"))); + + // Stub for getting app installations + githubApi.stubFor(get(urlEqualTo("/app/installations")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("[\n" + " {\n" + + " \"id\": 1,\n" + + " \"account\": {\n" + + " \"login\": \"org1\",\n" + + " \"type\": \"Organization\"\n" + + " },\n" + + " \"permissions\": {\n" + + " \"checks\": \"write\",\n" + + " \"pull_requests\": \"write\",\n" + + " \"contents\": \"read\",\n" + + " \"metadata\": \"read\"\n" + + " }\n" + + " },\n" + + " {\n" + + " \"id\": 2,\n" + + " \"account\": {\n" + + " \"login\": \"org2\",\n" + + " \"type\": \"Organization\"\n" + + " },\n" + + " \"permissions\": {\n" + + " \"checks\": \"write\",\n" + + " \"pull_requests\": \"write\",\n" + + " \"contents\": \"read\",\n" + + " \"metadata\": \"read\"\n" + + " }\n" + + " },\n" + + " {\n" + + " \"id\": 3,\n" + + " \"account\": {\n" + + " \"login\": \"user1\",\n" + + " \"type\": \"User\"\n" + + " },\n" + + " \"permissions\": {\n" + + " \"checks\": \"write\",\n" + + " \"pull_requests\": \"write\",\n" + + " \"contents\": \"read\",\n" + + " \"metadata\": \"read\"\n" + + " }\n" + + " }\n" + + "]"))); + + // Stub for generating installation tokens + String futureDate = DateTimeFormatter.ISO_INSTANT.format( + new Date(System.currentTimeMillis() + Duration.ofHours(1).toMillis()).toInstant()); + + githubApi.stubFor(post(urlMatching("/app/installations/.*/access_tokens")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\n" + " \"token\": \"test-installation-token\",\n" + + " \"expires_at\": \"" + + futureDate + "\"\n" + "}"))); + + // Stub for rate limit check + githubApi.stubFor(get(urlEqualTo("/rate_limit")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\n" + " \"resources\": {\n" + + " \"core\": {\n" + + " \"limit\": 5000,\n" + + " \"remaining\": 4999,\n" + + " \"reset\": " + + (System.currentTimeMillis() / 1000 + 3600) + "\n" + " }\n" + + " }\n" + + "}"))); + } + + @Test + public void testConstructor() { + assertEquals(TEST_CREDENTIAL_ID, credentials.getId()); + assertEquals("Test Multi-Org GitHub App", credentials.getDescription()); + assertEquals(TEST_APP_ID, credentials.getAppID()); + assertEquals(TEST_PRIVATE_KEY, credentials.getPrivateKey().getPlainText()); + } + + @Test + public void testGetAvailableOrganizations() { + List organizations = credentials.getAvailableOrganizations(); + + assertNotNull(organizations); + assertEquals(3, organizations.size()); + assertTrue(organizations.contains("org1")); + assertTrue(organizations.contains("org2")); + assertTrue(organizations.contains("user1")); + } + + @Test + public void testGetAvailableOrganizationsCaching() { + // First call should make API request + List organizations1 = credentials.getAvailableOrganizations(); + + // Second call should use cached result + List organizations2 = credentials.getAvailableOrganizations(); + + assertEquals(organizations1, organizations2); + + // Verify only one API call was made to /app/installations + githubApi.verify(1, getRequestedFor(urlEqualTo("/app/installations"))); + } + + @Test + public void testRefreshAvailableOrganizations() { + // Get initial organizations + credentials.getAvailableOrganizations(); + + // Force refresh + credentials.refreshAvailableOrganizations(); + + // Should have made two API calls + githubApi.verify(2, getRequestedFor(urlEqualTo("/app/installations"))); + } + + @Test + public void testForceRefreshOrganizations() { + // Get initial organizations + credentials.getAvailableOrganizations(); + + // Force refresh + credentials.forceRefreshOrganizations(); + + // Get organizations again - should use fresh data + credentials.getAvailableOrganizations(); + + // Should have made exactly two API calls + githubApi.verify(exactly(2), getRequestedFor(urlEqualTo("/app/installations"))); + } + + @Test + public void testForOrganization() { + GitHubAppCredentials orgCredentials = credentials.forOrganization("org2"); + + assertNotNull(orgCredentials); + assertEquals("org2", orgCredentials.getOwner()); + assertEquals(TEST_APP_ID, orgCredentials.getAppID()); + } + + @Test + public void testForOrganizationNotInList() { + // This should still work but log a warning + GitHubAppCredentials orgCredentials = credentials.forOrganization("unknown-org"); + + assertNotNull(orgCredentials); + assertEquals("unknown-org", orgCredentials.getOwner()); + } + + @Test + public void testDescriptorDisplayName() { + MultiOrgGitHubAppCredentials.DescriptorImpl descriptor = new MultiOrgGitHubAppCredentials.DescriptorImpl(); + + assertEquals("GitHub App (Multi-Organization)", descriptor.getDisplayName()); + } + + @Test + public void testDescriptorFillOrganizationItems() throws Exception { + // Add credentials to store for this test + store.addCredentials(Domain.global(), credentials); + + try { + MultiOrgGitHubAppCredentials.DescriptorImpl descriptor = new MultiOrgGitHubAppCredentials.DescriptorImpl(); + + // Test with valid credential ID + ListBoxModel result = descriptor.doFillOrganizationItems(null, TEST_CREDENTIAL_ID); + + assertNotNull(result); + assertTrue(result.size() > 0); + } finally { + store.removeCredentials(Domain.global(), credentials); + } + } + + @Test + public void testDescriptorFillOrganizationItemsEmptyCredentialId() { + MultiOrgGitHubAppCredentials.DescriptorImpl descriptor = new MultiOrgGitHubAppCredentials.DescriptorImpl(); + + ListBoxModel result = descriptor.doFillOrganizationItems(null, ""); + + assertNotNull(result); + assertEquals(1, result.size()); // Just the empty value + } + + @Test + public void testDescriptorTestMultiOrgConnectionMissingAppId() { + MultiOrgGitHubAppCredentials.DescriptorImpl descriptor = new MultiOrgGitHubAppCredentials.DescriptorImpl(); + + FormValidation result = descriptor.doTestMultiOrgConnection("", TEST_PRIVATE_KEY, githubApi.baseUrl()); + + assertEquals(FormValidation.Kind.ERROR, result.kind); + assertTrue(result.getMessage().contains("App ID is required")); + } + + @Test + public void testDescriptorTestMultiOrgConnectionMissingPrivateKey() { + MultiOrgGitHubAppCredentials.DescriptorImpl descriptor = new MultiOrgGitHubAppCredentials.DescriptorImpl(); + + FormValidation result = descriptor.doTestMultiOrgConnection(TEST_APP_ID, "", githubApi.baseUrl()); + + assertEquals(FormValidation.Kind.ERROR, result.kind); + assertTrue(result.getMessage().contains("Private key is required")); + } + + @Test + public void testDescriptorTestMultiOrgConnectionNoOrganizations() { + // Stub for empty installations + githubApi.stubFor(get(urlEqualTo("/app/installations")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("[]"))); + + MultiOrgGitHubAppCredentials.DescriptorImpl descriptor = new MultiOrgGitHubAppCredentials.DescriptorImpl(); + + FormValidation result = descriptor.doTestMultiOrgConnection(TEST_APP_ID, TEST_PRIVATE_KEY, githubApi.baseUrl()); + + assertEquals(FormValidation.Kind.WARNING, result.kind); + assertTrue(result.getMessage().contains("GitHub App is not installed to any organizations")); + } + + @Test + public void testHandleApiFailure() { + // Create a fresh credential instance that doesn't have cached results + MultiOrgGitHubAppCredentials freshCredentials = new MultiOrgGitHubAppCredentials( + CredentialsScope.GLOBAL, + "fresh-creds", + "Fresh Creds", + TEST_APP_ID, + Secret.fromString(TEST_PRIVATE_KEY)); + freshCredentials.setApiUri(githubApi.baseUrl()); + + // Reset all existing stubs and add a failing one + githubApi.resetAll(); + + // Add back the app stub + githubApi.stubFor(get(urlEqualTo("/app")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\n" + " \"id\": " + + TEST_APP_ID + ",\n" + " \"name\": \"Test App\",\n" + + " \"owner\": {\n" + + " \"login\": \"test-owner\"\n" + + " }\n" + + "}"))); + + // Stub for installations failure + githubApi.stubFor(get(urlEqualTo("/app/installations")) + .willReturn(aResponse() + .withStatus(500) + .withHeader("Content-Type", "application/json") + .withBody("{\"message\":\"Internal Server Error\"}"))); + + List organizations = freshCredentials.getAvailableOrganizations(); + + // Should return empty list on failure + assertNotNull(organizations); + assertEquals(0, organizations.size()); + } + + @Test + public void testOrganizationCacheExpiry() throws Exception { + // Get initial organizations + credentials.getAvailableOrganizations(); + + // Manually expire the cache by setting a very old refresh time + java.lang.reflect.Field lastRefreshTimeField = + MultiOrgGitHubAppCredentials.class.getDeclaredField("lastRefreshTime"); + lastRefreshTimeField.setAccessible(true); + lastRefreshTimeField.setLong(credentials, 0L); // Set to epoch + + // Get organizations again - should refresh + credentials.getAvailableOrganizations(); + + // Should have made two API calls due to cache expiry + githubApi.verify(2, getRequestedFor(urlEqualTo("/app/installations"))); + } +}