From d6675ceb97d91449665fd9c15b9843ddfc913fd6 Mon Sep 17 00:00:00 2001 From: Tim te Beek Date: Sat, 22 Aug 2026 15:15:59 +0200 Subject: [PATCH 1/3] Replace Dependabot `reviewers` with `CODEOWNERS` GitHub has removed the `reviewers` option from `dependabot.yml` and points users at `CODEOWNERS` instead. `ReplaceDependabotReviewersWithCodeowners` is a scanning recipe that collects the reviewers from each `updates` entry, maps them onto the manifest files Dependabot updates for that `package-ecosystem` and `directory`, and writes them to `CODEOWNERS` before deleting the `reviewers` keys. Mapping to manifests rather than to the whole directory keeps ownership as narrow as the Dependabot configuration was; `CODEOWNERS` applies to every pull request, not just Dependabot's. Ecosystems without a known manifest mapping keep their `reviewers`, as do all entries when an existing `CODEOWNERS` cannot be parsed as text, so the configuration is never dropped without a replacement. Fixes #134 --- .../github/DependabotEcosystemManifests.java | 93 +++ ...laceDependabotReviewersWithCodeowners.java | 286 +++++++++ .../resources/META-INF/rewrite/recipes.csv | 1 + ...DependabotReviewersWithCodeownersTest.java | 592 ++++++++++++++++++ 4 files changed, 972 insertions(+) create mode 100644 src/main/java/org/openrewrite/github/DependabotEcosystemManifests.java create mode 100644 src/main/java/org/openrewrite/github/ReplaceDependabotReviewersWithCodeowners.java create mode 100644 src/test/java/org/openrewrite/github/ReplaceDependabotReviewersWithCodeownersTest.java diff --git a/src/main/java/org/openrewrite/github/DependabotEcosystemManifests.java b/src/main/java/org/openrewrite/github/DependabotEcosystemManifests.java new file mode 100644 index 0000000..a0e5421 --- /dev/null +++ b/src/main/java/org/openrewrite/github/DependabotEcosystemManifests.java @@ -0,0 +1,93 @@ +/* + * Copyright 2025 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.github; + +import org.jspecify.annotations.Nullable; + +import java.util.*; + +import static java.util.Arrays.asList; +import static java.util.Collections.unmodifiableMap; + +// Ecosystems absent from this map are deliberately left alone rather than mapped to a broader +// pattern such as the whole directory, which would grant ownership beyond what Dependabot covered. +final class DependabotEcosystemManifests { + + static final String GITHUB_ACTIONS = "github-actions"; + + private static final Map> MANIFESTS; + + static { + Map> manifests = new LinkedHashMap<>(); + manifests.put("bundler", asList("Gemfile", "Gemfile.lock")); + manifests.put("cargo", asList("Cargo.toml", "Cargo.lock")); + manifests.put("composer", asList("composer.json", "composer.lock")); + manifests.put("devcontainers", asList(".devcontainer/devcontainer.json", ".devcontainer.json")); + manifests.put("docker", asList("Dockerfile")); + manifests.put("docker-compose", asList("docker-compose.yml", "docker-compose.yaml")); + manifests.put("elm", asList("elm.json")); + manifests.put("gitsubmodule", asList(".gitmodules")); + manifests.put("gomod", asList("go.mod", "go.sum")); + manifests.put("gradle", asList("build.gradle", "build.gradle.kts", "gradle/libs.versions.toml")); + manifests.put("helm", asList("Chart.yaml", "Chart.lock")); + manifests.put("maven", asList("pom.xml")); + manifests.put("mix", asList("mix.exs", "mix.lock")); + manifests.put("npm", asList("package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml")); + manifests.put("nuget", asList("*.csproj", "packages.config")); + manifests.put("pip", asList("requirements.txt", "pyproject.toml", "Pipfile", "Pipfile.lock")); + manifests.put("pub", asList("pubspec.yaml", "pubspec.lock")); + manifests.put("swift", asList("Package.swift", "Package.resolved")); + manifests.put("terraform", asList("*.tf")); + manifests.put("uv", asList("pyproject.toml", "uv.lock")); + MANIFESTS = unmodifiableMap(manifests); + } + + private DependabotEcosystemManifests() { + } + + static boolean isKnown(@Nullable String ecosystem) { + return GITHUB_ACTIONS.equals(ecosystem) || MANIFESTS.containsKey(ecosystem); + } + + static List patternsFor(@Nullable String ecosystem, String directory) { + if (GITHUB_ACTIONS.equals(ecosystem)) { + return asList("/.github/workflows/"); + } + List manifests = MANIFESTS.get(ecosystem); + if (manifests == null) { + return Collections.emptyList(); + } + String prefix = normalizeDirectory(directory); + List patterns = new ArrayList<>(manifests.size()); + for (String manifest : manifests) { + patterns.add(prefix + manifest); + } + return patterns; + } + + // Dependabot directories are repository-root relative and not searched recursively, so they + // map onto CODEOWNERS patterns anchored with a leading slash + private static String normalizeDirectory(String directory) { + String trimmed = directory.trim(); + while (trimmed.startsWith("/")) { + trimmed = trimmed.substring(1); + } + while (trimmed.endsWith("/")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + return trimmed.isEmpty() ? "/" : "/" + trimmed + "/"; + } +} diff --git a/src/main/java/org/openrewrite/github/ReplaceDependabotReviewersWithCodeowners.java b/src/main/java/org/openrewrite/github/ReplaceDependabotReviewersWithCodeowners.java new file mode 100644 index 0000000..a4db2a5 --- /dev/null +++ b/src/main/java/org/openrewrite/github/ReplaceDependabotReviewersWithCodeowners.java @@ -0,0 +1,286 @@ +/* + * Copyright 2025 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.github; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Value; +import org.jspecify.annotations.Nullable; +import org.openrewrite.*; +import org.openrewrite.text.PlainText; +import org.openrewrite.text.PlainTextParser; +import org.openrewrite.yaml.DeleteKey; +import org.openrewrite.yaml.YamlIsoVisitor; +import org.openrewrite.yaml.tree.Yaml; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; + +import static java.util.Arrays.asList; +import static java.util.Collections.*; +import static java.util.stream.Collectors.toList; + +@Value +@EqualsAndHashCode(callSuper = false) +public class ReplaceDependabotReviewersWithCodeowners extends ScanningRecipe { + + private static final String DEFAULT_CODEOWNERS_PATH = ".github/CODEOWNERS"; + private static final List CODEOWNERS_PRECEDENCE = + unmodifiableList(asList(".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS")); + private static final Set CODEOWNERS_LOCATIONS = + unmodifiableSet(new LinkedHashSet<>(CODEOWNERS_PRECEDENCE)); + private static final Set DEPENDABOT_LOCATIONS = unmodifiableSet(new LinkedHashSet<>( + asList(".github/dependabot.yml", ".github/dependabot.yaml"))); + private static final String HEADER = "# Reviewers migrated from the Dependabot configuration"; + + @Option(displayName = "`CODEOWNERS` path", + description = "Where to write the migrated reviewers when the repository does not have a " + + "`CODEOWNERS` file yet. Defaults to `.github/CODEOWNERS`. When a `CODEOWNERS` file " + + "already exists in any of the locations GitHub recognizes, that file is appended to " + + "instead and this option is ignored.", + required = false, + example = "CODEOWNERS") + @Nullable + String codeownersPath; + + String displayName = "Replace Dependabot `reviewers` with `CODEOWNERS`"; + + String description = "Replaces the [removed](https://github.blog/changelog/2025-04-29-dependabot-reviewers-configuration-option-being-replaced-by-code-owners/) " + + "`reviewers` option in `.github/dependabot.yml` with equivalent `CODEOWNERS` entries. Each " + + "reviewer is mapped onto the manifest files Dependabot updates for that `package-ecosystem` " + + "and `directory`, so ownership stays as narrow as the Dependabot configuration was. Update " + + "entries whose `package-ecosystem` has no known manifests are left untouched."; + + Set tags = unmodifiableSet(new LinkedHashSet<>(asList("dependabot", "dependencies", "github"))); + + @Override + public Accumulator getInitialValue(ExecutionContext ctx) { + return new Accumulator(); + } + + @Override + public TreeVisitor getScanner(Accumulator acc) { + return new TreeVisitor() { + @Override + public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) { + if (!(tree instanceof SourceFile)) { + return tree; + } + SourceFile sourceFile = (SourceFile) tree; + String path = normalize(sourceFile.getSourcePath()); + if (CODEOWNERS_LOCATIONS.contains(path)) { + acc.foundCodeowners(path, sourceFile); + } else if (DEPENDABOT_LOCATIONS.contains(path) && sourceFile instanceof Yaml.Documents) { + new ReviewersScanner(acc).visit(sourceFile, ctx); + } + return tree; + } + }; + } + + @Override + public Collection generate(Accumulator acc, ExecutionContext ctx) { + if (!acc.canMigrate() || acc.getExistingCodeowners() != null) { + return emptyList(); + } + String path = codeownersPath == null ? DEFAULT_CODEOWNERS_PATH : codeownersPath; + String contents = HEADER + '\n' + String.join("\n", renderLines(acc.getOwnersByPattern())) + '\n'; + return PlainTextParser.builder().build().parse(contents) + .map(created -> (SourceFile) created.withSourcePath(Paths.get(path))) + .collect(toList()); + } + + @Override + public TreeVisitor getVisitor(Accumulator acc) { + if (!acc.canMigrate()) { + return TreeVisitor.noop(); + } + return new TreeVisitor() { + @Override + public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) { + if (!(tree instanceof SourceFile)) { + return tree; + } + SourceFile sourceFile = (SourceFile) tree; + if (DEPENDABOT_LOCATIONS.contains(normalize(sourceFile.getSourcePath())) && sourceFile instanceof Yaml.Documents) { + Yaml yaml = (Yaml) sourceFile; + for (String ecosystem : acc.getMigratedEcosystems()) { + yaml = (Yaml) new DeleteKey("$.updates[?(@.package-ecosystem =~ '" + ecosystem + "')].reviewers", null) + .getVisitor().visitNonNull(yaml, ctx); + } + return yaml; + } + if (sourceFile instanceof PlainText && sourceFile.getSourcePath().equals(acc.getExistingCodeowners())) { + return append((PlainText) sourceFile, acc.getOwnersByPattern()); + } + return sourceFile; + } + }; + } + + private static PlainText append(PlainText codeowners, Map> ownersByPattern) { + Set existingPatterns = new HashSet<>(); + for (String line : codeowners.getText().split("\r?\n", -1)) { + String trimmed = line.trim(); + if (!trimmed.isEmpty() && !trimmed.startsWith("#")) { + existingPatterns.add(trimmed.split("\\s+")[0]); + } + } + + Map> missing = new LinkedHashMap<>(); + ownersByPattern.forEach((pattern, owners) -> { + if (!existingPatterns.contains(pattern)) { + missing.put(pattern, owners); + } + }); + if (missing.isEmpty()) { + return codeowners; + } + + String existing = codeowners.getText(); + StringBuilder text = new StringBuilder(existing); + if (!existing.isEmpty()) { + if (existing.charAt(existing.length() - 1) != '\n') { + text.append('\n'); + } + text.append('\n'); + } + text.append(HEADER); + for (String line : renderLines(missing)) { + text.append('\n').append(line); + } + // Match the trailing newline convention the file already used + if (existing.endsWith("\n")) { + text.append('\n'); + } + return codeowners.withText(text.toString()); + } + + private static List renderLines(Map> ownersByPattern) { + List lines = new ArrayList<>(ownersByPattern.size()); + ownersByPattern.forEach((pattern, owners) -> lines.add(pattern + ' ' + String.join(" ", owners))); + return lines; + } + + private static String normalize(Path path) { + return path.toString().replace('\\', '/'); + } + + private static class ReviewersScanner extends YamlIsoVisitor { + private final Accumulator acc; + + private ReviewersScanner(Accumulator acc) { + this.acc = acc; + } + + @Override + public Yaml.Mapping.Entry visitMappingEntry(Yaml.Mapping.Entry entry, ExecutionContext ctx) { + if ("updates".equals(entry.getKey().getValue()) && entry.getValue() instanceof Yaml.Sequence) { + for (Yaml.Sequence.Entry update : ((Yaml.Sequence) entry.getValue()).getEntries()) { + if (update.getBlock() instanceof Yaml.Mapping) { + collect((Yaml.Mapping) update.getBlock()); + } + } + } + return super.visitMappingEntry(entry, ctx); + } + + private void collect(Yaml.Mapping update) { + List reviewers = scalars(value(update, "reviewers")); + if (reviewers.isEmpty()) { + return; + } + String ecosystem = scalar(value(update, "package-ecosystem")); + if (!DependabotEcosystemManifests.isKnown(ecosystem)) { + return; + } + + List directories = scalars(value(update, "directories")); + if (directories.isEmpty()) { + String directory = scalar(value(update, "directory")); + directories = singletonList(directory == null ? "/" : directory); + } + + for (String directory : directories) { + for (String pattern : DependabotEcosystemManifests.patternsFor(ecosystem, directory)) { + Set owners = acc.getOwnersByPattern().computeIfAbsent(pattern, p -> new LinkedHashSet<>()); + for (String reviewer : reviewers) { + owners.add(reviewer.startsWith("@") ? reviewer : "@" + reviewer); + } + } + } + acc.getMigratedEcosystems().add(ecosystem); + } + + private static Yaml.@Nullable Block value(Yaml.Mapping mapping, String key) { + for (Yaml.Mapping.Entry entry : mapping.getEntries()) { + if (key.equals(entry.getKey().getValue())) { + return entry.getValue(); + } + } + return null; + } + + private static @Nullable String scalar(Yaml.@Nullable Block block) { + return block instanceof Yaml.Scalar ? ((Yaml.Scalar) block).getValue() : null; + } + + private static List scalars(Yaml.@Nullable Block block) { + if (!(block instanceof Yaml.Sequence)) { + return emptyList(); + } + List values = new ArrayList<>(); + for (Yaml.Sequence.Entry entry : ((Yaml.Sequence) block).getEntries()) { + String value = scalar(entry.getBlock()); + if (value != null && !value.trim().isEmpty()) { + values.add(value.trim()); + } + } + return values; + } + } + + @Getter + public static class Accumulator { + private final Map> ownersByPattern = new LinkedHashMap<>(); + private final Set migratedEcosystems = new LinkedHashSet<>(); + + private @Nullable Path existingCodeowners; + private int existingCodeownersPrecedence = Integer.MAX_VALUE; + + // A CODEOWNERS not parsed as plain text cannot be appended to, and deleting the reviewers + // without recording them anywhere would lose the configuration + private boolean codeownersIsAppendable = true; + + void foundCodeowners(String path, SourceFile sourceFile) { + if (!(sourceFile instanceof PlainText)) { + codeownersIsAppendable = false; + return; + } + // GitHub honors only one CODEOWNERS: .github wins over the root, which wins over docs + int precedence = CODEOWNERS_PRECEDENCE.indexOf(path); + if (precedence < existingCodeownersPrecedence) { + existingCodeownersPrecedence = precedence; + existingCodeowners = sourceFile.getSourcePath(); + } + } + + boolean canMigrate() { + return !ownersByPattern.isEmpty() && codeownersIsAppendable; + } + } +} diff --git a/src/main/resources/META-INF/rewrite/recipes.csv b/src/main/resources/META-INF/rewrite/recipes.csv index 73408e9..4dfd62f 100644 --- a/src/main/resources/META-INF/rewrite/recipes.csv +++ b/src/main/resources/META-INF/rewrite/recipes.csv @@ -20,6 +20,7 @@ maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.Prefe maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.RemoveAllCronTriggers,Remove all cron triggers,Removes all cron triggers from a workflow.,1,,GitHub Actions,,Recipes to perform [GitHub Actions](https://docs.github.com/en/actions) hygiene and migration tasks.,, maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.RemoveUnusedWorkflowDispatchInputs,Remove unused workflow dispatch inputs,Remove workflow_dispatch inputs that are not referenced anywhere in the workflow file.,1,,GitHub Actions,,Recipes to perform [GitHub Actions](https://docs.github.com/en/actions) hygiene and migration tasks.,, maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.RemoveWorkflowInputArgument,Remove workflow input argument,Remove a specific input argument from calls to a reusable workflow.,1,,GitHub Actions,,Recipes to perform [GitHub Actions](https://docs.github.com/en/actions) hygiene and migration tasks.,"[{""name"":""workflowReference"",""type"":""String"",""displayName"":""Workflow reference"",""description"":""The workflow reference to match (e.g., `org/repo/.github/workflows/myWorkflow.yml`)."",""example"":""org/repo/.github/workflows/myWorkflow.yml"",""required"":true},{""name"":""version"",""type"":""String"",""displayName"":""Version"",""description"":""The version of the workflow to match (e.g., `v1.2.3`)."",""example"":""v1.2.3"",""required"":true},{""name"":""inputArgumentName"",""type"":""String"",""displayName"":""Input argument name"",""description"":""The name of the input argument to remove."",""example"":""myInputToRemove"",""required"":true}]", +maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.ReplaceDependabotReviewersWithCodeowners,Replace Dependabot `reviewers` with `CODEOWNERS`,"Replaces the [removed](https://github.blog/changelog/2025-04-29-dependabot-reviewers-configuration-option-being-replaced-by-code-owners/) `reviewers` option in `.github/dependabot.yml` with equivalent `CODEOWNERS` entries. Each reviewer is mapped onto the manifest files Dependabot updates for that `package-ecosystem` and `directory`, so ownership stays as narrow as the Dependabot configuration was. Update entries whose `package-ecosystem` has no known manifests are left untouched.",1,,GitHub Actions,,Recipes to perform [GitHub Actions](https://docs.github.com/en/actions) hygiene and migration tasks.,"[{""name"":""codeownersPath"",""type"":""String"",""displayName"":""`CODEOWNERS` path"",""description"":""Where to write the migrated reviewers when the repository does not have a `CODEOWNERS` file yet. Defaults to `.github/CODEOWNERS`. When a `CODEOWNERS` file already exists in any of the locations GitHub recognizes, that file is appended to instead and this option is ignored."",""example"":""CODEOWNERS""}]", maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.ReplaceOssrhSecretsWithSonatype,Replace OSSRH secrets with Sonatype secrets,Replace deprecated OSSRH_S01 secrets with new Sonatype secrets in GitHub Actions workflows. This is an example use of the `ReplaceSecrets` and `ReplaceSecretKeys` recipes combined used to update the Maven publishing secrets in OpenRewrite's GitHub organization.,5,,GitHub Actions,,Recipes to perform [GitHub Actions](https://docs.github.com/en/actions) hygiene and migration tasks.,, maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.ReplaceRunners,Replace runners for a job,Replaces the runners of a given job.,1,,GitHub Actions,,Recipes to perform [GitHub Actions](https://docs.github.com/en/actions) hygiene and migration tasks.,"[{""name"":""jobName"",""type"":""String"",""displayName"":""Job Name"",""description"":""The name of the job to update, use * to affect all the workflow jobs"",""example"":""build"",""required"":true},{""name"":""runners"",""type"":""List"",""displayName"":""Runners"",""description"":""The new list of runners to set"",""example"":""ubuntu-latest"",""required"":true}]", maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.ReplaceSecretKeys,Replace secret key names in GitHub Actions,Replace key names used for secrets in GitHub Actions workflow files.,1,,GitHub Actions,,Recipes to perform [GitHub Actions](https://docs.github.com/en/actions) hygiene and migration tasks.,"[{""name"":""oldKeyName"",""type"":""String"",""displayName"":""Old key name"",""description"":""The name of the key to be replaced"",""example"":""ossrh_username"",""required"":true},{""name"":""newKeyName"",""type"":""String"",""displayName"":""New key name"",""description"":""The new key name to use"",""example"":""sonatype_username"",""required"":true},{""name"":""fileMatcher"",""type"":""String"",""displayName"":""File matcher"",""description"":""Optional file path matcher"",""example"":"".github/workflows/*.{yml,yaml}""}]", diff --git a/src/test/java/org/openrewrite/github/ReplaceDependabotReviewersWithCodeownersTest.java b/src/test/java/org/openrewrite/github/ReplaceDependabotReviewersWithCodeownersTest.java new file mode 100644 index 0000000..792e6e5 --- /dev/null +++ b/src/test/java/org/openrewrite/github/ReplaceDependabotReviewersWithCodeownersTest.java @@ -0,0 +1,592 @@ +/* + * Copyright 2025 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.github; + +import org.junit.jupiter.api.Test; +import org.openrewrite.DocumentExample; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.test.SourceSpecs.other; +import static org.openrewrite.test.SourceSpecs.text; +import static org.openrewrite.yaml.Assertions.yaml; + +class ReplaceDependabotReviewersWithCodeownersTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new ReplaceDependabotReviewersWithCodeowners(null)); + } + + @DocumentExample + @Test + void migrateReviewersToNewCodeownersFile() { + rewriteRun( + //language=yaml + yaml( + """ + version: 2 + updates: + - package-ecosystem: maven + directory: / + schedule: + interval: weekly + reviewers: + - acme/backend + - package-ecosystem: npm + directory: /frontend + schedule: + interval: weekly + reviewers: + - acme/frontend + """, + """ + version: 2 + updates: + - package-ecosystem: maven + directory: / + schedule: + interval: weekly + - package-ecosystem: npm + directory: /frontend + schedule: + interval: weekly + """, + spec -> spec.path(".github/dependabot.yml") + ), + text( + null, + """ + # Reviewers migrated from the Dependabot configuration + /pom.xml @acme/backend + /frontend/package.json @acme/frontend + /frontend/package-lock.json @acme/frontend + /frontend/yarn.lock @acme/frontend + /frontend/pnpm-lock.yaml @acme/frontend + """, + spec -> spec.path(".github/CODEOWNERS") + ) + ); + } + + @Test + void githubActionsMapsToWorkflowsDirectory() { + rewriteRun( + //language=yaml + yaml( + """ + version: 2 + updates: + - package-ecosystem: github-actions + directory: / + reviewers: + - acme/devops + """, + """ + version: 2 + updates: + - package-ecosystem: github-actions + directory: / + """, + spec -> spec.path(".github/dependabot.yml") + ), + text( + null, + """ + # Reviewers migrated from the Dependabot configuration + /.github/workflows/ @acme/devops + """, + spec -> spec.path(".github/CODEOWNERS") + ) + ); + } + + @Test + void appendToExistingCodeowners() { + rewriteRun( + //language=yaml + yaml( + """ + version: 2 + updates: + - package-ecosystem: maven + directory: / + reviewers: + - acme/backend + """, + """ + version: 2 + updates: + - package-ecosystem: maven + directory: / + """, + spec -> spec.path(".github/dependabot.yml") + ), + text( + """ + * @acme/everyone + """, + """ + * @acme/everyone + + # Reviewers migrated from the Dependabot configuration + /pom.xml @acme/backend + """, + spec -> spec.path("CODEOWNERS") + ) + ); + } + + @Test + void doNotDuplicatePatternAlreadyOwned() { + rewriteRun( + //language=yaml + yaml( + """ + version: 2 + updates: + - package-ecosystem: maven + directory: / + reviewers: + - acme/backend + """, + """ + version: 2 + updates: + - package-ecosystem: maven + directory: / + """, + spec -> spec.path(".github/dependabot.yml") + ), + text( + """ + /pom.xml @someone/else + """, + spec -> spec.path("CODEOWNERS") + ) + ); + } + + @Test + void individualReviewersArePrefixed() { + rewriteRun( + //language=yaml + yaml( + """ + version: 2 + updates: + - package-ecosystem: gomod + directory: / + reviewers: + - octocat + - "@hubot" + """, + """ + version: 2 + updates: + - package-ecosystem: gomod + directory: / + """, + spec -> spec.path(".github/dependabot.yml") + ), + text( + null, + """ + # Reviewers migrated from the Dependabot configuration + /go.mod @octocat @hubot + /go.sum @octocat @hubot + """, + spec -> spec.path(".github/CODEOWNERS") + ) + ); + } + + @Test + void multipleDirectories() { + rewriteRun( + //language=yaml + yaml( + """ + version: 2 + updates: + - package-ecosystem: cargo + directories: + - /crates/one + - /crates/two + reviewers: + - acme/rust + """, + """ + version: 2 + updates: + - package-ecosystem: cargo + directories: + - /crates/one + - /crates/two + """, + spec -> spec.path(".github/dependabot.yml") + ), + text( + null, + """ + # Reviewers migrated from the Dependabot configuration + /crates/one/Cargo.toml @acme/rust + /crates/one/Cargo.lock @acme/rust + /crates/two/Cargo.toml @acme/rust + /crates/two/Cargo.lock @acme/rust + """, + spec -> spec.path(".github/CODEOWNERS") + ) + ); + } + + @Test + void unknownEcosystemIsLeftAlone() { + rewriteRun( + //language=yaml + yaml( + """ + version: 2 + updates: + - package-ecosystem: some-future-ecosystem + directory: / + reviewers: + - acme/backend + """, + spec -> spec.path(".github/dependabot.yml") + ) + ); + } + + @Test + void unknownEcosystemKeepsItsReviewersWhileOthersMigrate() { + rewriteRun( + //language=yaml + yaml( + """ + version: 2 + updates: + - package-ecosystem: some-future-ecosystem + directory: / + reviewers: + - acme/future + - package-ecosystem: maven + directory: / + reviewers: + - acme/backend + """, + """ + version: 2 + updates: + - package-ecosystem: some-future-ecosystem + directory: / + reviewers: + - acme/future + - package-ecosystem: maven + directory: / + """, + spec -> spec.path(".github/dependabot.yml") + ), + text( + null, + """ + # Reviewers migrated from the Dependabot configuration + /pom.xml @acme/backend + """, + spec -> spec.path(".github/CODEOWNERS") + ) + ); + } + + @Test + void noReviewersIsNoChange() { + rewriteRun( + //language=yaml + yaml( + """ + version: 2 + updates: + - package-ecosystem: maven + directory: / + schedule: + interval: weekly + """, + spec -> spec.path(".github/dependabot.yml") + ) + ); + } + + @Test + void codeownersPathIsConfigurable() { + rewriteRun( + spec -> spec.recipe(new ReplaceDependabotReviewersWithCodeowners("CODEOWNERS")), + //language=yaml + yaml( + """ + version: 2 + updates: + - package-ecosystem: maven + directory: / + reviewers: + - acme/backend + """, + """ + version: 2 + updates: + - package-ecosystem: maven + directory: / + """, + spec -> spec.path(".github/dependabot.yml") + ), + text( + null, + """ + # Reviewers migrated from the Dependabot configuration + /pom.xml @acme/backend + """, + spec -> spec.path("CODEOWNERS") + ) + ); + } + + @Test + void reviewersInFlowSequence() { + rewriteRun( + //language=yaml + yaml( + """ + version: 2 + updates: + - package-ecosystem: maven + directory: / + reviewers: [acme/backend, acme/platform] + """, + """ + version: 2 + updates: + - package-ecosystem: maven + directory: / + """, + spec -> spec.path(".github/dependabot.yml") + ), + text( + null, + """ + # Reviewers migrated from the Dependabot configuration + /pom.xml @acme/backend @acme/platform + """, + spec -> spec.path(".github/CODEOWNERS") + ) + ); + } + + @Test + void multiEcosystemConfigurationFromIssue() { + rewriteRun( + //language=yaml + yaml( + """ + version: 2 + updates: + + - package-ecosystem: gradle + directory: "/" + schedule: + interval: weekly + reviewers: + - "kafbat/backend" + open-pull-requests-limit: 10 + + - package-ecosystem: docker + directory: "/api" + schedule: + interval: weekly + reviewers: + - "kafbat/backend" + open-pull-requests-limit: 10 + + - package-ecosystem: npm + directory: "/frontend" + schedule: + interval: weekly + reviewers: + - "kafbat/frontend" + open-pull-requests-limit: 10 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: weekly + reviewers: + - "kafbat/devops" + open-pull-requests-limit: 10 + """, + """ + version: 2 + updates: + + - package-ecosystem: gradle + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 + + - package-ecosystem: docker + directory: "/api" + schedule: + interval: weekly + open-pull-requests-limit: 10 + + - package-ecosystem: npm + directory: "/frontend" + schedule: + interval: weekly + open-pull-requests-limit: 10 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 + """, + spec -> spec.path(".github/dependabot.yml") + ), + text( + null, + """ + # Reviewers migrated from the Dependabot configuration + /build.gradle @kafbat/backend + /build.gradle.kts @kafbat/backend + /gradle/libs.versions.toml @kafbat/backend + /api/Dockerfile @kafbat/backend + /frontend/package.json @kafbat/frontend + /frontend/package-lock.json @kafbat/frontend + /frontend/yarn.lock @kafbat/frontend + /frontend/pnpm-lock.yaml @kafbat/frontend + /.github/workflows/ @kafbat/devops + """, + spec -> spec.path(".github/CODEOWNERS") + ) + ); + } + + @Test + void appendsToDotGithubCodeownersWhenRootAlsoExists() { + rewriteRun( + //language=yaml + yaml( + """ + version: 2 + updates: + - package-ecosystem: maven + directory: / + reviewers: + - acme/backend + """, + """ + version: 2 + updates: + - package-ecosystem: maven + directory: / + """, + spec -> spec.path(".github/dependabot.yml") + ), + text( + """ + * @acme/root + """, + spec -> spec.path("CODEOWNERS") + ), + text( + """ + * @acme/everyone + """, + """ + * @acme/everyone + + # Reviewers migrated from the Dependabot configuration + /pom.xml @acme/backend + """, + spec -> spec.path(".github/CODEOWNERS") + ) + ); + } + + @Test + void keepsReviewersWhenCodeownersCannotBeRead() { + rewriteRun( + //language=yaml + yaml( + """ + version: 2 + updates: + - package-ecosystem: maven + directory: / + reviewers: + - acme/backend + """, + spec -> spec.path(".github/dependabot.yml") + ), + other( + "* @acme/everyone", + spec -> spec.path(".github/CODEOWNERS") + ) + ); + } + + @Test + void secondRunIsANoOp() { + rewriteRun( + //language=yaml + yaml( + """ + version: 2 + updates: + - package-ecosystem: maven + directory: / + schedule: + interval: weekly + """, + spec -> spec.path(".github/dependabot.yml") + ), + text( + """ + # Reviewers migrated from the Dependabot configuration + /pom.xml @acme/backend + """, + spec -> spec.path(".github/CODEOWNERS") + ) + ); + } + + @Test + void doesNotTouchOtherYamlFiles() { + rewriteRun( + //language=yaml + yaml( + """ + version: 2 + updates: + - package-ecosystem: maven + directory: / + reviewers: + - acme/backend + """, + spec -> spec.path("some/other/dependabot-example.yml") + ) + ); + } +} From 0bd1e72cdc15eac151e766f64b18606459903dee Mon Sep 17 00:00:00 2001 From: Tim te Beek Date: Sat, 22 Aug 2026 16:09:20 +0200 Subject: [PATCH 2/3] Fix CODEOWNERS precedence and composite action mapping Review follow-ups on the previous commit: - An unreadable lower-precedence `CODEOWNERS` no longer aborts the migration. A non-`PlainText` `docs/CODEOWNERS` used to set `codeownersIsAppendable` false even when `.github/CODEOWNERS`, the file GitHub actually honors, was perfectly appendable. Appendability is now decided by whichever file wins precedence, independent of the order sources are scanned. - `github-actions` no longer ignores `directory`. A non-root directory points at a composite action definition, so it now maps to `action.yml`/`action.yaml` in that directory rather than over-granting `/.github/workflows/` and leaving the action itself unowned. - Appended lines follow the existing file's line endings instead of always using LF, which produced mixed endings in a CRLF `CODEOWNERS`. - The per-ecosystem `DeleteKey` loop, which re-traversed the document and recompiled a JsonPath for every migrated ecosystem, is now a single pass over an alternation. Note that the previous commit message overstated one guarantee: when a `CODEOWNERS` pattern is already owned by someone else, the Dependabot `reviewers` are still removed without those reviewers being added to the existing line, so that configuration is dropped rather than replaced. --- .../github/DependabotEcosystemManifests.java | 21 +++--- ...laceDependabotReviewersWithCodeowners.java | 30 ++++---- ...DependabotReviewersWithCodeownersTest.java | 73 +++++++++++++++++++ 3 files changed, 98 insertions(+), 26 deletions(-) diff --git a/src/main/java/org/openrewrite/github/DependabotEcosystemManifests.java b/src/main/java/org/openrewrite/github/DependabotEcosystemManifests.java index a0e5421..1891201 100644 --- a/src/main/java/org/openrewrite/github/DependabotEcosystemManifests.java +++ b/src/main/java/org/openrewrite/github/DependabotEcosystemManifests.java @@ -20,7 +20,7 @@ import java.util.*; import static java.util.Arrays.asList; -import static java.util.Collections.unmodifiableMap; +import static java.util.Collections.*; // Ecosystems absent from this map are deliberately left alone rather than mapped to a broader // pattern such as the whole directory, which would grant ownership beyond what Dependabot covered. @@ -36,21 +36,21 @@ final class DependabotEcosystemManifests { manifests.put("cargo", asList("Cargo.toml", "Cargo.lock")); manifests.put("composer", asList("composer.json", "composer.lock")); manifests.put("devcontainers", asList(".devcontainer/devcontainer.json", ".devcontainer.json")); - manifests.put("docker", asList("Dockerfile")); + manifests.put("docker", singletonList("Dockerfile")); manifests.put("docker-compose", asList("docker-compose.yml", "docker-compose.yaml")); - manifests.put("elm", asList("elm.json")); - manifests.put("gitsubmodule", asList(".gitmodules")); + manifests.put("elm", singletonList("elm.json")); + manifests.put("gitsubmodule", singletonList(".gitmodules")); manifests.put("gomod", asList("go.mod", "go.sum")); manifests.put("gradle", asList("build.gradle", "build.gradle.kts", "gradle/libs.versions.toml")); manifests.put("helm", asList("Chart.yaml", "Chart.lock")); - manifests.put("maven", asList("pom.xml")); + manifests.put("maven", singletonList("pom.xml")); manifests.put("mix", asList("mix.exs", "mix.lock")); manifests.put("npm", asList("package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml")); manifests.put("nuget", asList("*.csproj", "packages.config")); manifests.put("pip", asList("requirements.txt", "pyproject.toml", "Pipfile", "Pipfile.lock")); manifests.put("pub", asList("pubspec.yaml", "pubspec.lock")); manifests.put("swift", asList("Package.swift", "Package.resolved")); - manifests.put("terraform", asList("*.tf")); + manifests.put("terraform", singletonList("*.tf")); manifests.put("uv", asList("pyproject.toml", "uv.lock")); MANIFESTS = unmodifiableMap(manifests); } @@ -63,14 +63,17 @@ static boolean isKnown(@Nullable String ecosystem) { } static List patternsFor(@Nullable String ecosystem, String directory) { + String prefix = normalizeDirectory(directory); if (GITHUB_ACTIONS.equals(ecosystem)) { - return asList("/.github/workflows/"); + // A non-root directory points at a composite action definition rather than the workflows + return "/".equals(prefix) ? + singletonList("/.github/workflows/") : + asList(prefix + "action.yml", prefix + "action.yaml"); } List manifests = MANIFESTS.get(ecosystem); if (manifests == null) { - return Collections.emptyList(); + return emptyList(); } - String prefix = normalizeDirectory(directory); List patterns = new ArrayList<>(manifests.size()); for (String manifest : manifests) { patterns.add(prefix + manifest); diff --git a/src/main/java/org/openrewrite/github/ReplaceDependabotReviewersWithCodeowners.java b/src/main/java/org/openrewrite/github/ReplaceDependabotReviewersWithCodeowners.java index a4db2a5..891c4df 100644 --- a/src/main/java/org/openrewrite/github/ReplaceDependabotReviewersWithCodeowners.java +++ b/src/main/java/org/openrewrite/github/ReplaceDependabotReviewersWithCodeowners.java @@ -117,12 +117,9 @@ public TreeVisitor getVisitor(Accumulator acc) { } SourceFile sourceFile = (SourceFile) tree; if (DEPENDABOT_LOCATIONS.contains(normalize(sourceFile.getSourcePath())) && sourceFile instanceof Yaml.Documents) { - Yaml yaml = (Yaml) sourceFile; - for (String ecosystem : acc.getMigratedEcosystems()) { - yaml = (Yaml) new DeleteKey("$.updates[?(@.package-ecosystem =~ '" + ecosystem + "')].reviewers", null) - .getVisitor().visitNonNull(yaml, ctx); - } - return yaml; + String ecosystems = String.join("|", acc.getMigratedEcosystems()); + return new DeleteKey("$.updates[?(@.package-ecosystem =~ '(" + ecosystems + ")')].reviewers", null) + .getVisitor().visitNonNull(sourceFile, ctx); } if (sourceFile instanceof PlainText && sourceFile.getSourcePath().equals(acc.getExistingCodeowners())) { return append((PlainText) sourceFile, acc.getOwnersByPattern()); @@ -152,20 +149,21 @@ private static PlainText append(PlainText codeowners, Map> o } String existing = codeowners.getText(); + String newline = existing.contains("\r\n") ? "\r\n" : "\n"; StringBuilder text = new StringBuilder(existing); if (!existing.isEmpty()) { if (existing.charAt(existing.length() - 1) != '\n') { - text.append('\n'); + text.append(newline); } - text.append('\n'); + text.append(newline); } text.append(HEADER); for (String line : renderLines(missing)) { - text.append('\n').append(line); + text.append(newline).append(line); } // Match the trailing newline convention the file already used if (existing.endsWith("\n")) { - text.append('\n'); + text.append(newline); } return codeowners.withText(text.toString()); } @@ -267,16 +265,14 @@ public static class Accumulator { private boolean codeownersIsAppendable = true; void foundCodeowners(String path, SourceFile sourceFile) { - if (!(sourceFile instanceof PlainText)) { - codeownersIsAppendable = false; - return; - } // GitHub honors only one CODEOWNERS: .github wins over the root, which wins over docs int precedence = CODEOWNERS_PRECEDENCE.indexOf(path); - if (precedence < existingCodeownersPrecedence) { - existingCodeownersPrecedence = precedence; - existingCodeowners = sourceFile.getSourcePath(); + if (precedence >= existingCodeownersPrecedence) { + return; } + existingCodeownersPrecedence = precedence; + codeownersIsAppendable = sourceFile instanceof PlainText; + existingCodeowners = codeownersIsAppendable ? sourceFile.getSourcePath() : null; } boolean canMigrate() { diff --git a/src/test/java/org/openrewrite/github/ReplaceDependabotReviewersWithCodeownersTest.java b/src/test/java/org/openrewrite/github/ReplaceDependabotReviewersWithCodeownersTest.java index 792e6e5..421a0ab 100644 --- a/src/test/java/org/openrewrite/github/ReplaceDependabotReviewersWithCodeownersTest.java +++ b/src/test/java/org/openrewrite/github/ReplaceDependabotReviewersWithCodeownersTest.java @@ -572,6 +572,79 @@ void secondRunIsANoOp() { ); } + @Test + void appendsToDotGithubCodeownersWhenLowerPrecedenceCodeownersCannotBeRead() { + rewriteRun( + //language=yaml + yaml( + """ + version: 2 + updates: + - package-ecosystem: maven + directory: / + reviewers: + - acme/backend + """, + """ + version: 2 + updates: + - package-ecosystem: maven + directory: / + """, + spec -> spec.path(".github/dependabot.yml") + ), + other( + "* @acme/everyone", + spec -> spec.path("docs/CODEOWNERS") + ), + text( + """ + * @acme/everyone + """, + """ + * @acme/everyone + + # Reviewers migrated from the Dependabot configuration + /pom.xml @acme/backend + """, + spec -> spec.path(".github/CODEOWNERS") + ) + ); + } + + @Test + void githubActionsInNonRootDirectoryMapsToCompositeAction() { + rewriteRun( + //language=yaml + yaml( + """ + version: 2 + updates: + - package-ecosystem: github-actions + directory: /.github/actions/setup + reviewers: + - acme/devops + """, + """ + version: 2 + updates: + - package-ecosystem: github-actions + directory: /.github/actions/setup + """, + spec -> spec.path(".github/dependabot.yml") + ), + text( + null, + """ + # Reviewers migrated from the Dependabot configuration + /.github/actions/setup/action.yml @acme/devops + /.github/actions/setup/action.yaml @acme/devops + """, + spec -> spec.path(".github/CODEOWNERS") + ) + ); + } + @Test void doesNotTouchOtherYamlFiles() { rewriteRun( From 165e197d3ab38117d891476a00b673bd81014af2 Mon Sep 17 00:00:00 2001 From: Tim te Beek Date: Sat, 22 Aug 2026 16:12:32 +0200 Subject: [PATCH 3/3] Merge reviewers into a CODEOWNERS pattern that already exists When a pattern we want to add is already present with different owners, the Dependabot `reviewers` were deleted without those reviewers being recorded anywhere, silently dropping the configuration. Appending a second line for the same pattern is not an option either, since CODEOWNERS is last match wins and the new line would displace the owners already there. Merge the reviewers into the existing line instead, so both sets are kept. Owners already on the line are not repeated, so the recipe stays idempotent. This does not address overlapping globs: appending `/pom.xml @acme/backend` below a `* @acme/everyone` still removes `@acme/everyone` as a required reviewer of `pom.xml`. Resolving that needs CODEOWNERS glob matching rather than exact pattern comparison. --- ...laceDependabotReviewersWithCodeowners.java | 47 ++++++++--- ...DependabotReviewersWithCodeownersTest.java | 77 ++++++++++++++++++- 2 files changed, 114 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/openrewrite/github/ReplaceDependabotReviewersWithCodeowners.java b/src/main/java/org/openrewrite/github/ReplaceDependabotReviewersWithCodeowners.java index 891c4df..f9f204a 100644 --- a/src/main/java/org/openrewrite/github/ReplaceDependabotReviewersWithCodeowners.java +++ b/src/main/java/org/openrewrite/github/ReplaceDependabotReviewersWithCodeowners.java @@ -130,26 +130,47 @@ public TreeVisitor getVisitor(Accumulator acc) { } private static PlainText append(PlainText codeowners, Map> ownersByPattern) { - Set existingPatterns = new HashSet<>(); - for (String line : codeowners.getText().split("\r?\n", -1)) { - String trimmed = line.trim(); - if (!trimmed.isEmpty() && !trimmed.startsWith("#")) { - existingPatterns.add(trimmed.split("\\s+")[0]); + String original = codeowners.getText(); + String newline = original.contains("\r\n") ? "\r\n" : "\n"; + String[] lines = original.split("\r?\n", -1); + + // CODEOWNERS is last match wins, so a second line for a pattern would silently displace the + // owners already on the first; merge into that line instead to keep both sets of reviewers + Set mergedPatterns = new HashSet<>(); + boolean merged = false; + for (int i = 0; i < lines.length; i++) { + String trimmed = lines[i].trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + String[] tokens = trimmed.split("\\s+"); + Set owners = ownersByPattern.get(tokens[0]); + if (owners == null) { + continue; + } + mergedPatterns.add(tokens[0]); + Set alreadyOwning = new HashSet<>(asList(tokens).subList(1, tokens.length)); + List missingOwners = owners.stream() + .filter(owner -> !alreadyOwning.contains(owner)) + .collect(toList()); + if (!missingOwners.isEmpty()) { + lines[i] = trimTrailing(lines[i]) + ' ' + String.join(" ", missingOwners); + merged = true; } } Map> missing = new LinkedHashMap<>(); ownersByPattern.forEach((pattern, owners) -> { - if (!existingPatterns.contains(pattern)) { + if (!mergedPatterns.contains(pattern)) { missing.put(pattern, owners); } }); + + String existing = String.join(newline, lines); if (missing.isEmpty()) { - return codeowners; + return merged ? codeowners.withText(existing) : codeowners; } - String existing = codeowners.getText(); - String newline = existing.contains("\r\n") ? "\r\n" : "\n"; StringBuilder text = new StringBuilder(existing); if (!existing.isEmpty()) { if (existing.charAt(existing.length() - 1) != '\n') { @@ -168,6 +189,14 @@ private static PlainText append(PlainText codeowners, Map> o return codeowners.withText(text.toString()); } + private static String trimTrailing(String line) { + int end = line.length(); + while (end > 0 && Character.isWhitespace(line.charAt(end - 1))) { + end--; + } + return line.substring(0, end); + } + private static List renderLines(Map> ownersByPattern) { List lines = new ArrayList<>(ownersByPattern.size()); ownersByPattern.forEach((pattern, owners) -> lines.add(pattern + ' ' + String.join(" ", owners))); diff --git a/src/test/java/org/openrewrite/github/ReplaceDependabotReviewersWithCodeownersTest.java b/src/test/java/org/openrewrite/github/ReplaceDependabotReviewersWithCodeownersTest.java index 421a0ab..47b5839 100644 --- a/src/test/java/org/openrewrite/github/ReplaceDependabotReviewersWithCodeownersTest.java +++ b/src/test/java/org/openrewrite/github/ReplaceDependabotReviewersWithCodeownersTest.java @@ -151,7 +151,7 @@ void appendToExistingCodeowners() { } @Test - void doNotDuplicatePatternAlreadyOwned() { + void mergeIntoPatternAlreadyOwnedBySomeoneElse() { rewriteRun( //language=yaml yaml( @@ -175,6 +175,81 @@ void doNotDuplicatePatternAlreadyOwned() { """ /pom.xml @someone/else """, + """ + /pom.xml @someone/else @acme/backend + """, + spec -> spec.path("CODEOWNERS") + ) + ); + } + + @Test + void doNotDuplicateOwnerAlreadyOnTheLine() { + rewriteRun( + //language=yaml + yaml( + """ + version: 2 + updates: + - package-ecosystem: maven + directory: / + reviewers: + - acme/backend + """, + """ + version: 2 + updates: + - package-ecosystem: maven + directory: / + """, + spec -> spec.path(".github/dependabot.yml") + ), + text( + """ + /pom.xml @acme/backend @someone/else + """, + spec -> spec.path("CODEOWNERS") + ) + ); + } + + @Test + void mergeSomeOwnersAndAppendOtherPatterns() { + rewriteRun( + //language=yaml + yaml( + """ + version: 2 + updates: + - package-ecosystem: gomod + directory: / + reviewers: + - acme/backend + """, + """ + version: 2 + updates: + - package-ecosystem: gomod + directory: / + """, + spec -> spec.path(".github/dependabot.yml") + ), + text( + """ + # ownership + /go.mod @someone/else + + /unrelated.txt @acme/docs + """, + """ + # ownership + /go.mod @someone/else @acme/backend + + /unrelated.txt @acme/docs + + # Reviewers migrated from the Dependabot configuration + /go.sum @acme/backend + """, spec -> spec.path("CODEOWNERS") ) );