From aae045edf50eaec11d421ddb131a19bf06fea8ea Mon Sep 17 00:00:00 2001
From: Johannes Feichtner <343448+Churro@users.noreply.github.com>
Date: Tue, 4 Feb 2025 18:16:51 +0100
Subject: [PATCH] feat(gradle): add support for gradle repository content
 descriptors (#33692)

Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
---
 lib/modules/manager/gradle/extract.spec.ts    | 212 +++++++++++++++++-
 lib/modules/manager/gradle/extract.ts         |  88 ++++++++
 lib/modules/manager/gradle/parser.spec.ts     | 151 +++++++++++++
 lib/modules/manager/gradle/parser.ts          |   1 +
 .../manager/gradle/parser/common.spec.ts      |   1 +
 lib/modules/manager/gradle/parser/handlers.ts |  68 +++++-
 .../manager/gradle/parser/registry-urls.ts    | 102 +++++++--
 lib/modules/manager/gradle/types.ts           |  12 +
 8 files changed, 619 insertions(+), 16 deletions(-)

diff --git a/lib/modules/manager/gradle/extract.spec.ts b/lib/modules/manager/gradle/extract.spec.ts
index 25c24846e3..65995556f3 100644
--- a/lib/modules/manager/gradle/extract.spec.ts
+++ b/lib/modules/manager/gradle/extract.spec.ts
@@ -1,7 +1,8 @@
 import { codeBlock } from 'common-tags';
 import { Fixtures } from '../../../../test/fixtures';
 import { fs, logger, partial } from '../../../../test/util';
-import type { ExtractConfig } from '../types';
+import type { ExtractConfig, PackageDependency } from '../types';
+import { matchesContentDescriptor } from './extract';
 import * as parser from './parser';
 import { extractAllPackageFiles } from '.';
 
@@ -494,6 +495,215 @@ describe('modules/manager/gradle/extract', () => {
         },
       ]);
     });
+
+    describe('content descriptors', () => {
+      describe('simple descriptor matches', () => {
+        it.each`
+          input                      | output   | descriptor
+          ${'foo:bar:1.2.3'}         | ${true}  | ${undefined}
+          ${'foo:bar:1.2.3'}         | ${true}  | ${[{ mode: 'include', matcher: 'simple', groupId: 'foo' }]}
+          ${'foo:bar:1.2.3'}         | ${false} | ${[{ mode: 'exclude', matcher: 'simple', groupId: 'foo' }]}
+          ${'foo:bar:1.2.3'}         | ${false} | ${[{ mode: 'include', matcher: 'simple', groupId: 'bar' }]}
+          ${'foo:bar:1.2.3'}         | ${true}  | ${[{ mode: 'include', matcher: 'simple', groupId: 'foo', artifactId: 'bar' }]}
+          ${'foo:bar:1.2.3'}         | ${false} | ${[{ mode: 'exclude', matcher: 'simple', groupId: 'foo', artifactId: 'bar' }]}
+          ${'foo:bar:1.2.3'}         | ${false} | ${[{ mode: 'include', matcher: 'simple', groupId: 'foo', artifactId: 'baz' }]}
+          ${'foo:bar:1.2.3'}         | ${true}  | ${[{ mode: 'include', matcher: 'simple', groupId: 'foo', artifactId: 'bar', version: '1.2.3' }]}
+          ${'foo:bar:1.2.3'}         | ${false} | ${[{ mode: 'exclude', matcher: 'simple', groupId: 'foo', artifactId: 'bar', version: '1.2.3' }]}
+          ${'foo:bar:1.2.3'}         | ${true}  | ${[{ mode: 'include', matcher: 'simple', groupId: 'foo', artifactId: 'bar', version: '1.2.+' }]}
+          ${'foo:bar:1.2.3'}         | ${false} | ${[{ mode: 'include', matcher: 'simple', groupId: 'foo', artifactId: 'baz', version: '4.5.6' }]}
+          ${'foo:bar:1.2.3'}         | ${true}  | ${[{ mode: 'include', matcher: 'subgroup', groupId: 'foo' }]}
+          ${'foo.bar.baz:qux:1.2.3'} | ${true}  | ${[{ mode: 'include', matcher: 'subgroup', groupId: 'foo.bar.baz' }]}
+          ${'foo.bar.baz:qux:1.2.3'} | ${true}  | ${[{ mode: 'include', matcher: 'subgroup', groupId: 'foo.bar' }]}
+          ${'foo.bar.baz:qux:1.2.3'} | ${false} | ${[{ mode: 'include', matcher: 'subgroup', groupId: 'foo.barbaz' }]}
+          ${'foobarbaz:qux:1.2.3'}   | ${true}  | ${[{ mode: 'include', matcher: 'regex', groupId: '.*bar.*' }]}
+          ${'foobarbaz:qux:1.2.3'}   | ${true}  | ${[{ mode: 'include', matcher: 'regex', groupId: '.*bar.*', artifactId: 'qux' }]}
+          ${'foobar:foobar:1.2.3'}   | ${true}  | ${[{ mode: 'include', matcher: 'regex', groupId: '.*bar.*', artifactId: 'foo.*' }]}
+          ${'foobar:foobar:1.2.3'}   | ${false} | ${[{ mode: 'include', matcher: 'regex', groupId: 'foobar', artifactId: '^bar' }]}
+          ${'foobar:foobar:1.2.3'}   | ${true}  | ${[{ mode: 'include', matcher: 'regex', groupId: 'foobar', artifactId: '^foo.*', version: '1\\.*' }]}
+          ${'foobar:foobar:1.2.3'}   | ${false} | ${[{ mode: 'include', matcher: 'regex', groupId: 'foobar', artifactId: '^foo', version: '3.+' }]}
+          ${'foobar:foobar:1.2.3'}   | ${false} | ${[{ mode: 'include', matcher: 'regex', groupId: 'foobar', artifactId: 'qux', version: '1\\.*' }]}
+        `('$input | $output', ({ input, output, descriptor }) => {
+          const [groupId, artifactId, currentValue] = input.split(':');
+          const dep: PackageDependency = {
+            depName: `${groupId}:${artifactId}`,
+            currentValue,
+          };
+
+          expect(matchesContentDescriptor(dep, descriptor)).toBe(output);
+        });
+      });
+
+      describe('multiple descriptors', () => {
+        const dep: PackageDependency = {
+          depName: `foo:bar`,
+          currentValue: '1.2.3',
+        };
+
+        it('if both includes and excludes exist, dep must match include and not match exclude', () => {
+          expect(
+            matchesContentDescriptor(dep, [
+              { mode: 'include', matcher: 'simple', groupId: 'foo' },
+              {
+                mode: 'exclude',
+                matcher: 'simple',
+                groupId: 'foo',
+                artifactId: 'baz',
+              },
+            ]),
+          ).toBe(true);
+
+          expect(
+            matchesContentDescriptor(dep, [
+              { mode: 'include', matcher: 'simple', groupId: 'foo' },
+              {
+                mode: 'exclude',
+                matcher: 'simple',
+                groupId: 'foo',
+                artifactId: 'bar',
+              },
+            ]),
+          ).toBe(false);
+        });
+
+        it('if only includes exist, dep must match at least one include', () => {
+          expect(
+            matchesContentDescriptor(dep, [
+              { mode: 'include', matcher: 'simple', groupId: 'some' },
+              { mode: 'include', matcher: 'simple', groupId: 'foo' },
+              { mode: 'include', matcher: 'simple', groupId: 'bar' },
+            ]),
+          ).toBe(true);
+
+          expect(
+            matchesContentDescriptor(dep, [
+              { mode: 'include', matcher: 'simple', groupId: 'some' },
+              { mode: 'include', matcher: 'simple', groupId: 'other' },
+              { mode: 'include', matcher: 'simple', groupId: 'bar' },
+            ]),
+          ).toBe(false);
+        });
+
+        it('if only excludes exist, dep must match not match any exclude', () => {
+          expect(
+            matchesContentDescriptor(dep, [
+              { mode: 'exclude', matcher: 'simple', groupId: 'some' },
+              { mode: 'exclude', matcher: 'simple', groupId: 'foo' },
+              { mode: 'exclude', matcher: 'simple', groupId: 'bar' },
+            ]),
+          ).toBe(false);
+
+          expect(
+            matchesContentDescriptor(dep, [
+              { mode: 'exclude', matcher: 'simple', groupId: 'some' },
+              { mode: 'exclude', matcher: 'simple', groupId: 'other' },
+              { mode: 'exclude', matcher: 'simple', groupId: 'bar' },
+            ]),
+          ).toBe(true);
+        });
+      });
+
+      it('extracts content descriptors', async () => {
+        const fsMock = {
+          'build.gradle': codeBlock`
+            pluginManagement {
+              repositories {
+                maven {
+                  url = "https://foo.bar/baz"
+                  content {
+                    includeModule("com.diffplug.spotless", "com.diffplug.spotless.gradle.plugin")
+                  }
+                }
+              }
+            }
+            repositories {
+              mavenCentral()
+              google {
+                content {
+                  includeGroupAndSubgroups("foo.bar")
+                  includeModuleByRegex("com\\\\.(google|android).*", "protobuf.*")
+                  includeGroupByRegex("(?!(unsupported|pattern).*)")
+                  includeGroupByRegex "org\\\\.jetbrains\\\\.kotlin.*"
+                  excludeModule("foo.bar.group", "simple.module")
+                }
+              }
+              maven {
+                name = "some"
+                url = "https://foo.bar/\${name}"
+                content {
+                  includeModule("foo.bar.group", "simple.module")
+                  includeVersion("com.google.protobuf", "protobuf-java", "2.17.+")
+                }
+              }
+            }
+
+            plugins {
+              id("com.diffplug.spotless") version "6.10.0"
+            }
+
+            dependencies {
+              implementation "com.google.protobuf:protobuf-java:2.17.1"
+              implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.4.21"
+              implementation "foo.bar:protobuf-java:2.17.0"
+              implementation "foo.bar.group:simple.module:2.17.0"
+            }
+          `,
+        };
+        mockFs(fsMock);
+
+        const res = await extractAllPackageFiles(
+          partial<ExtractConfig>(),
+          Object.keys(fsMock),
+        );
+
+        expect(res).toMatchObject([
+          {
+            deps: [
+              {
+                depName: 'com.diffplug.spotless',
+                currentValue: '6.10.0',
+                depType: 'plugin',
+                packageName:
+                  'com.diffplug.spotless:com.diffplug.spotless.gradle.plugin',
+                registryUrls: ['https://foo.bar/baz'],
+              },
+              {
+                depName: 'com.google.protobuf:protobuf-java',
+                currentValue: '2.17.1',
+                registryUrls: [
+                  'https://repo.maven.apache.org/maven2',
+                  'https://dl.google.com/android/maven2/',
+                  'https://foo.bar/some',
+                ],
+              },
+              {
+                depName: 'org.jetbrains.kotlin:kotlin-stdlib-jdk8',
+                currentValue: '1.4.21',
+                registryUrls: [
+                  'https://repo.maven.apache.org/maven2',
+                  'https://dl.google.com/android/maven2/',
+                ],
+              },
+              {
+                depName: 'foo.bar:protobuf-java',
+                currentValue: '2.17.0',
+                registryUrls: [
+                  'https://repo.maven.apache.org/maven2',
+                  'https://dl.google.com/android/maven2/',
+                ],
+              },
+              {
+                depName: 'foo.bar.group:simple.module',
+                currentValue: '2.17.0',
+                registryUrls: [
+                  'https://repo.maven.apache.org/maven2',
+                  'https://foo.bar/some',
+                ],
+              },
+            ],
+          },
+        ]);
+      });
+    });
   });
 
   describe('version catalogs', () => {
diff --git a/lib/modules/manager/gradle/extract.ts b/lib/modules/manager/gradle/extract.ts
index d70a5722db..3f94060c42 100644
--- a/lib/modules/manager/gradle/extract.ts
+++ b/lib/modules/manager/gradle/extract.ts
@@ -1,7 +1,10 @@
 import upath from 'upath';
 import { logger } from '../../../logger';
+import { coerceArray } from '../../../util/array';
 import { getLocalFiles } from '../../../util/fs';
+import { regEx } from '../../../util/regex';
 import { MavenDatasource } from '../../datasource/maven';
+import gradleVersioning from '../../versioning/gradle';
 import type { ExtractConfig, PackageDependency, PackageFile } from '../types';
 import { parseCatalog } from './extract/catalog';
 import {
@@ -12,6 +15,7 @@ import {
 import { parseGradle, parseKotlinSource, parseProps } from './parser';
 import { REGISTRY_URLS } from './parser/common';
 import type {
+  ContentDescriptorSpec,
   GradleManagerData,
   PackageRegistry,
   VariableRegistry,
@@ -44,6 +48,89 @@ function updatePackageRegistries(
   }
 }
 
+export function matchesContentDescriptor(
+  dep: PackageDependency<GradleManagerData>,
+  contentDescriptors?: ContentDescriptorSpec[],
+): boolean {
+  const [groupId, artifactId] = (dep.packageName ?? dep.depName!).split(':');
+  let hasIncludes = false;
+  let hasExcludes = false;
+  let matchesInclude = false;
+  let matchesExclude = false;
+
+  for (const content of coerceArray(contentDescriptors)) {
+    const {
+      mode,
+      matcher,
+      groupId: contentGroupId,
+      artifactId: contentArtifactId,
+      version: contentVersion,
+    } = content;
+
+    // group matching
+    let groupMatch = false;
+    if (matcher === 'regex') {
+      groupMatch = regEx(contentGroupId).test(groupId);
+    } else if (matcher === 'subgroup') {
+      groupMatch =
+        groupId === contentGroupId || `${groupId}.`.startsWith(contentGroupId);
+    } else {
+      groupMatch = groupId === contentGroupId;
+    }
+
+    // artifact matching (optional)
+    let artifactMatch = true;
+    if (groupMatch && contentArtifactId) {
+      if (matcher === 'regex') {
+        artifactMatch = regEx(contentArtifactId).test(artifactId);
+      } else {
+        artifactMatch = artifactId === contentArtifactId;
+      }
+    }
+
+    // version matching (optional)
+    let versionMatch = true;
+    if (groupMatch && artifactMatch && contentVersion && dep.currentValue) {
+      if (matcher === 'regex') {
+        versionMatch = regEx(contentVersion).test(dep.currentValue);
+      } else {
+        // contentVersion can be an exact version or a gradle-supported version range
+        versionMatch = gradleVersioning.matches(
+          dep.currentValue,
+          contentVersion,
+        );
+      }
+    }
+
+    const isMatch = groupMatch && artifactMatch && versionMatch;
+    if (mode === 'include') {
+      hasIncludes = true;
+      if (isMatch) {
+        matchesInclude = true;
+      }
+    } else if (mode === 'exclude') {
+      hasExcludes = true;
+      if (isMatch) {
+        matchesExclude = true;
+      }
+    }
+  }
+
+  if (hasIncludes && hasExcludes) {
+    // if both includes and excludes exist, dep must match include and not match exclude
+    return matchesInclude && !matchesExclude;
+  } else if (hasIncludes) {
+    // if only includes exist, dep must match at least one include
+    return matchesInclude;
+  } else if (hasExcludes) {
+    // if only excludes exist, dep must not match any exclude
+    return !matchesExclude;
+  }
+
+  // by default, repositories include everything and exclude nothing
+  return true;
+}
+
 function getRegistryUrlsForDep(
   packageRegistries: PackageRegistry[],
   dep: PackageDependency<GradleManagerData>,
@@ -52,6 +139,7 @@ function getRegistryUrlsForDep(
 
   const registryUrls = packageRegistries
     .filter((item) => item.scope === scope)
+    .filter((item) => matchesContentDescriptor(dep, item.content))
     .map((item) => item.registryUrl);
 
   if (!registryUrls.length && scope === 'plugin') {
diff --git a/lib/modules/manager/gradle/parser.spec.ts b/lib/modules/manager/gradle/parser.spec.ts
index faa099f3e4..3f0281b1d5 100644
--- a/lib/modules/manager/gradle/parser.spec.ts
+++ b/lib/modules/manager/gradle/parser.spec.ts
@@ -728,6 +728,157 @@ describe('modules/manager/gradle/parser', () => {
         },
       ]);
     });
+
+    describe('content descriptors', () => {
+      it('valid combinations', () => {
+        const input = codeBlock`
+          maven(url = "https://foo.bar/baz") {
+            content {
+              excludeGroup("baz.qux")
+            }
+          }
+          mavenCentral().content {
+            includeGroup("foo.bar")
+          }
+          maven {
+            url = "https://foo.bar/deps"
+            content {
+              includeGroupAndSubgroups("foo.bar")
+            }
+          }
+          maven {
+            url = "https://some.foo"
+            content {
+              includeModule("foo", "bar")
+              excludeModule("baz", "qux")
+              includeVersion("foo", "bar", "1.2.3")
+              excludeVersion("baz", "qux", "4.5.6")
+              includeGroupByRegex("org\\\\.jetbrains\\\\.kotlin.*")
+              excludeGroupByRegex(".*google.*")
+              includeModuleByRegex(".*foo.*", ".*bar.*")
+              excludeModuleByRegex(".*baz.*", ".*qux.*")
+              includeVersionByRegex(".*foo.*", ".*bar.*", "1.2.3")
+              excludeVersionByRegex(".*baz.*", ".*qux.*", ".*4.5.*")
+            }
+          }
+        `;
+
+        const { urls } = parseGradle(input);
+        expect(urls).toStrictEqual([
+          {
+            registryUrl: 'https://foo.bar/baz',
+            scope: 'dep',
+            content: [
+              { mode: 'exclude', matcher: 'simple', groupId: 'baz.qux' },
+            ],
+          },
+          {
+            registryUrl: REGISTRY_URLS.mavenCentral,
+            scope: 'dep',
+            content: [
+              { mode: 'include', matcher: 'simple', groupId: 'foo.bar' },
+            ],
+          },
+          {
+            registryUrl: 'https://foo.bar/deps',
+            scope: 'dep',
+            content: [
+              { mode: 'include', matcher: 'subgroup', groupId: 'foo.bar' },
+            ],
+          },
+          {
+            registryUrl: 'https://some.foo',
+            scope: 'dep',
+            content: [
+              {
+                mode: 'include',
+                matcher: 'simple',
+                groupId: 'foo',
+                artifactId: 'bar',
+              },
+              {
+                mode: 'exclude',
+                matcher: 'simple',
+                groupId: 'baz',
+                artifactId: 'qux',
+              },
+              {
+                mode: 'include',
+                matcher: 'simple',
+                groupId: 'foo',
+                artifactId: 'bar',
+                version: '1.2.3',
+              },
+              {
+                mode: 'exclude',
+                matcher: 'simple',
+                groupId: 'baz',
+                artifactId: 'qux',
+                version: '4.5.6',
+              },
+              {
+                mode: 'include',
+                matcher: 'regex',
+                groupId: '^org\\.jetbrains\\.kotlin.*$',
+              },
+              { mode: 'exclude', matcher: 'regex', groupId: '^.*google.*$' },
+              {
+                mode: 'include',
+                matcher: 'regex',
+                groupId: '^.*foo.*$',
+                artifactId: '^.*bar.*$',
+              },
+              {
+                mode: 'exclude',
+                matcher: 'regex',
+                groupId: '^.*baz.*$',
+                artifactId: '^.*qux.*$',
+              },
+              {
+                mode: 'include',
+                matcher: 'regex',
+                groupId: '^.*foo.*$',
+                artifactId: '^.*bar.*$',
+                version: '^1.2.3$',
+              },
+              {
+                mode: 'exclude',
+                matcher: 'regex',
+                groupId: '^.*baz.*$',
+                artifactId: '^.*qux.*$',
+                version: '^.*4.5.*$',
+              },
+            ],
+          },
+        ]);
+      });
+
+      describe('invalid or unsupported regEx patterns', () => {
+        it.each`
+          fieldName    | pattern
+          ${'group'}   | ${'includeGroupByRegex(".*so\\me.invalid.pattern.*")'}
+          ${'group'}   | ${'includeModuleByRegex(".*so\\me.invalid.pattern.*", ".*bar.*")'}
+          ${'module'}  | ${'includeModuleByRegex(".*foo.*", ".*so\\me.invalid.pattern.*")'}
+          ${'module'}  | ${'excludeModuleByRegex(".*baz.*", "(?!(foo|bar).*)")'}
+          ${'version'} | ${'includeVersionByRegex(".*foo.*", ".*bar.*", "(?!(foo|bar).*)")'}
+          ${'version'} | ${'excludeVersionByRegex(".*baz.*", ".*qux.*", "(?!(foo|bar).*)")'}
+        `('$pattern', ({ fieldName, pattern }) => {
+          const input = codeBlock`
+            mavenCentral {
+              content {
+                ${pattern}
+              }
+            }
+          `;
+          parseGradle(input);
+          expect(logger.logger.debug).toHaveBeenCalledWith(
+            expect.stringContaining(
+              `Skipping content descriptor with unsupported regExp pattern for ${fieldName}`,
+            ),
+          );
+        });
+      });
+    });
   });
 
   describe('version catalog', () => {
diff --git a/lib/modules/manager/gradle/parser.ts b/lib/modules/manager/gradle/parser.ts
index b0459572b2..92d55a1b09 100644
--- a/lib/modules/manager/gradle/parser.ts
+++ b/lib/modules/manager/gradle/parser.ts
@@ -33,6 +33,7 @@ const ctx: Ctx = {
   varTokens: [],
   tmpKotlinImportStore: [],
   tmpNestingDepth: [],
+  tmpRegistryContent: [],
   tmpTokenStore: {},
   tokenMap: {},
 };
diff --git a/lib/modules/manager/gradle/parser/common.spec.ts b/lib/modules/manager/gradle/parser/common.spec.ts
index e9625d2c2d..7b2bf2afdc 100644
--- a/lib/modules/manager/gradle/parser/common.spec.ts
+++ b/lib/modules/manager/gradle/parser/common.spec.ts
@@ -32,6 +32,7 @@ describe('modules/manager/gradle/parser/common', () => {
       varTokens: [],
       tmpKotlinImportStore: [],
       tmpNestingDepth: [],
+      tmpRegistryContent: [],
       tmpTokenStore: {},
       tokenMap: {},
     };
diff --git a/lib/modules/manager/gradle/parser/handlers.ts b/lib/modules/manager/gradle/parser/handlers.ts
index 5d983abbd0..ca32f64a6e 100644
--- a/lib/modules/manager/gradle/parser/handlers.ts
+++ b/lib/modules/manager/gradle/parser/handlers.ts
@@ -5,7 +5,12 @@ import { getSiblingFileName } from '../../../../util/fs';
 import { regEx } from '../../../../util/regex';
 import type { PackageDependency } from '../../types';
 import type { parseGradle as parseGradleCallback } from '../parser';
-import type { Ctx, GradleManagerData } from '../types';
+import type {
+  ContentDescriptorMatcher,
+  ContentDescriptorSpec,
+  Ctx,
+  GradleManagerData,
+} from '../types';
 import { isDependencyString, parseDependencyString } from '../utils';
 import {
   GRADLE_PLUGINS,
@@ -264,6 +269,65 @@ export function handlePlugin(ctx: Ctx): Ctx {
   return ctx;
 }
 
+function isValidContentDescriptorRegex(
+  fieldName: string,
+  pattern: string,
+): boolean {
+  try {
+    regEx(pattern);
+  } catch {
+    logger.debug(
+      `Skipping content descriptor with unsupported regExp pattern for ${fieldName}: ${pattern}`,
+    );
+    return false;
+  }
+
+  return true;
+}
+
+export function handleRegistryContent(ctx: Ctx): Ctx {
+  const methodName = loadFromTokenMap(ctx, 'methodName')[0].value;
+  let groupId = loadFromTokenMap(ctx, 'groupId')[0].value;
+
+  let matcher: ContentDescriptorMatcher = 'simple';
+  if (methodName.includes('Regex')) {
+    matcher = 'regex';
+    groupId = `^${groupId}$`.replaceAll('\\\\', '\\');
+    if (!isValidContentDescriptorRegex('group', groupId)) {
+      return ctx;
+    }
+  } else if (methodName.includes('AndSubgroups')) {
+    matcher = 'subgroup';
+  }
+
+  const mode = methodName.startsWith('include') ? 'include' : 'exclude';
+  const spec: ContentDescriptorSpec = { mode, matcher, groupId };
+
+  if (methodName.includes('Module') || methodName.includes('Version')) {
+    spec.artifactId = loadFromTokenMap(ctx, 'artifactId')[0].value;
+    if (matcher === 'regex') {
+      spec.artifactId = `^${spec.artifactId}$`.replaceAll('\\\\', '\\');
+      if (!isValidContentDescriptorRegex('module', spec.artifactId)) {
+        return ctx;
+      }
+    }
+  }
+
+  if (methodName.includes('Version')) {
+    spec.version = loadFromTokenMap(ctx, 'version')[0].value;
+    if (matcher === 'regex') {
+      spec.version = `^${spec.version}$`.replaceAll('\\\\', '\\');
+      if (!isValidContentDescriptorRegex('version', spec.version)) {
+        return ctx;
+      }
+    }
+  }
+
+  ctx.tmpRegistryContent.push(spec);
+
+  return ctx;
+}
+
 function isPluginRegistry(ctx: Ctx): boolean {
   if (ctx.tokenMap.registryScope) {
     const registryScope = loadFromTokenMap(ctx, 'registryScope')[0].value;
@@ -279,6 +343,7 @@ export function handlePredefinedRegistryUrl(ctx: Ctx): Ctx {
   ctx.registryUrls.push({
     registryUrl: REGISTRY_URLS[registryName as keyof typeof REGISTRY_URLS],
     scope: isPluginRegistry(ctx) ? 'plugin' : 'dep',
+    content: ctx.tmpRegistryContent,
   });
 
   return ctx;
@@ -314,6 +379,7 @@ export function handleCustomRegistryUrl(ctx: Ctx): Ctx {
         ctx.registryUrls.push({
           registryUrl,
           scope: isPluginRegistry(ctx) ? 'plugin' : 'dep',
+          content: ctx.tmpRegistryContent,
         });
       }
     } catch {
diff --git a/lib/modules/manager/gradle/parser/registry-urls.ts b/lib/modules/manager/gradle/parser/registry-urls.ts
index e684135f80..1d602b5c8f 100644
--- a/lib/modules/manager/gradle/parser/registry-urls.ts
+++ b/lib/modules/manager/gradle/parser/registry-urls.ts
@@ -1,3 +1,4 @@
+import type { parser } from 'good-enough-parser';
 import { query as q } from 'good-enough-parser';
 import { regEx } from '../../../../util/regex';
 import type { Ctx } from '../types';
@@ -6,16 +7,80 @@ import { qAssignments } from './assignments';
 import {
   REGISTRY_URLS,
   cleanupTempVars,
+  qArtifactId,
+  qGroupId,
   qValueMatcher,
+  qVersion,
   storeInTokenMap,
   storeVarToken,
 } from './common';
 import {
   handleCustomRegistryUrl,
   handlePredefinedRegistryUrl,
+  handleRegistryContent,
 } from './handlers';
 import { qPlugins } from './plugins';
 
+const cleanupTmpContentSpec = (ctx: Ctx): Ctx => {
+  ctx.tmpRegistryContent = [];
+  return ctx;
+};
+
+const qContentDescriptorSpec = (
+  methodName: RegExp,
+  matcher: q.QueryBuilder<Ctx, parser.Node>,
+): q.QueryBuilder<Ctx, parser.Node> => {
+  return q
+    .sym<Ctx>(methodName, storeVarToken)
+    .handler((ctx) => storeInTokenMap(ctx, 'methodName'))
+    .alt(
+      // includeGroup "foo.bar"
+      matcher,
+      // includeGroup("foo.bar")
+      q.tree({
+        type: 'wrapped-tree',
+        maxDepth: 1,
+        startsWith: '(',
+        endsWith: ')',
+        search: q.begin<Ctx>().join(matcher).end(),
+      }),
+    );
+};
+
+// includeModule('foo')
+// excludeModuleByRegex('bar')
+const qContentDescriptor = (
+  mode: 'include' | 'exclude',
+): q.QueryBuilder<Ctx, parser.Node> => {
+  return q
+    .alt<Ctx>(
+      qContentDescriptorSpec(
+        regEx(
+          `^(?:${mode}Group|${mode}GroupByRegex|${mode}GroupAndSubgroups)$`,
+        ),
+        qGroupId,
+      ),
+      qContentDescriptorSpec(
+        regEx(`^(?:${mode}Module|${mode}ModuleByRegex)$`),
+        q.join(qGroupId, q.op(','), qArtifactId),
+      ),
+      qContentDescriptorSpec(
+        regEx(`^(?:${mode}Version|${mode}VersionByRegex)$`),
+        q.join(qGroupId, q.op(','), qArtifactId, q.op(','), qVersion),
+      ),
+    )
+    .handler(handleRegistryContent);
+};
+
+// content { includeModule('foo'); excludeModule('bar') }
+const qRegistryContent = q.sym<Ctx>('content').tree({
+  type: 'wrapped-tree',
+  maxDepth: 1,
+  startsWith: '{',
+  endsWith: '}',
+  search: q.alt(qContentDescriptor('include'), qContentDescriptor('exclude')),
+});
+
 // uri("https://foo.bar/baz")
 // "https://foo.bar/baz"
 const qUri = q
@@ -34,22 +99,26 @@ const qPredefinedRegistries = q
   .sym(regEx(`^(?:${Object.keys(REGISTRY_URLS).join('|')})$`), storeVarToken)
   .handler((ctx) => storeInTokenMap(ctx, 'registryUrl'))
   .alt(
-    q.tree({
-      type: 'wrapped-tree',
-      startsWith: '(',
-      endsWith: ')',
-      search: q.begin<Ctx>().end(),
-    }),
+    q
+      .tree({
+        type: 'wrapped-tree',
+        startsWith: '(',
+        endsWith: ')',
+        search: q.begin<Ctx>().end(),
+      })
+      .opt(q.op<Ctx>('.').join(qRegistryContent)),
     q.tree({
       type: 'wrapped-tree',
       startsWith: '{',
       endsWith: '}',
+      search: q.opt(qRegistryContent),
     }),
   )
   .handler(handlePredefinedRegistryUrl)
+  .handler(cleanupTmpContentSpec)
   .handler(cleanupTempVars);
 
-// { url = "https://some.repo" }
+// { url = "https://some.repo"; content { ... } }
 const qMavenArtifactRegistry = q.tree({
   type: 'wrapped-tree',
   maxDepth: 1,
@@ -68,24 +137,29 @@ const qMavenArtifactRegistry = q.tree({
       endsWith: ')',
       search: q.begin<Ctx>().join(qUri).end(),
     }),
+    qRegistryContent,
   ),
 });
 
 // maven(url = uri("https://foo.bar/baz"))
+// maven("https://foo.bar/baz") { content { ... } }
 // maven { name = some; url = "https://foo.bar/${name}" }
 const qCustomRegistryUrl = q
   .sym<Ctx>('maven')
   .alt(
-    q.tree<Ctx>({
-      type: 'wrapped-tree',
-      maxDepth: 1,
-      startsWith: '(',
-      endsWith: ')',
-      search: q.begin<Ctx>().opt(q.sym<Ctx>('url').op('=')).join(qUri).end(),
-    }),
+    q
+      .tree<Ctx>({
+        type: 'wrapped-tree',
+        maxDepth: 1,
+        startsWith: '(',
+        endsWith: ')',
+        search: q.begin<Ctx>().opt(q.sym<Ctx>('url').op('=')).join(qUri).end(),
+      })
+      .opt(qMavenArtifactRegistry),
     qMavenArtifactRegistry,
   )
   .handler(handleCustomRegistryUrl)
+  .handler(cleanupTmpContentSpec)
   .handler(cleanupTempVars);
 
 const qPluginManagement = q.sym<Ctx>('pluginManagement', storeVarToken).tree({
diff --git a/lib/modules/manager/gradle/types.ts b/lib/modules/manager/gradle/types.ts
index 0a6fbe93a1..dd7899e652 100644
--- a/lib/modules/manager/gradle/types.ts
+++ b/lib/modules/manager/gradle/types.ts
@@ -68,9 +68,20 @@ export interface RichVersion {
 export type GradleVersionPointerTarget = string | RichVersion;
 export type GradleVersionCatalogVersion = string | VersionPointer | RichVersion;
 
+export type ContentDescriptorMatcher = 'simple' | 'regex' | 'subgroup';
+
+export interface ContentDescriptorSpec {
+  mode: 'include' | 'exclude';
+  matcher: ContentDescriptorMatcher;
+  groupId: string;
+  artifactId?: string;
+  version?: string;
+}
+
 export interface PackageRegistry {
   registryUrl: string;
   scope: 'dep' | 'plugin';
+  content?: ContentDescriptorSpec[];
 }
 
 export interface Ctx {
@@ -86,6 +97,7 @@ export interface Ctx {
   varTokens: lexer.Token[];
   tmpKotlinImportStore: lexer.Token[][];
   tmpNestingDepth: lexer.Token[];
+  tmpRegistryContent: ContentDescriptorSpec[];
   tmpTokenStore: Record<string, lexer.Token[]>;
   tokenMap: Record<string, lexer.Token[]>;
 }
-- 
GitLab