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..1891201 --- /dev/null +++ b/src/main/java/org/openrewrite/github/DependabotEcosystemManifests.java @@ -0,0 +1,96 @@ +/* + * 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.*; + +// 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", singletonList("Dockerfile")); + manifests.put("docker-compose", asList("docker-compose.yml", "docker-compose.yaml")); + 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", 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", singletonList("*.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) { + String prefix = normalizeDirectory(directory); + if (GITHUB_ACTIONS.equals(ecosystem)) { + // 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 emptyList(); + } + 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..f9f204a --- /dev/null +++ b/src/main/java/org/openrewrite/github/ReplaceDependabotReviewersWithCodeowners.java @@ -0,0 +1,311 @@ +/* + * 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) { + 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()); + } + return sourceFile; + } + }; + } + + private static PlainText append(PlainText codeowners, Map> ownersByPattern) { + 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 (!mergedPatterns.contains(pattern)) { + missing.put(pattern, owners); + } + }); + + String existing = String.join(newline, lines); + if (missing.isEmpty()) { + return merged ? codeowners.withText(existing) : codeowners; + } + + StringBuilder text = new StringBuilder(existing); + if (!existing.isEmpty()) { + if (existing.charAt(existing.length() - 1) != '\n') { + text.append(newline); + } + text.append(newline); + } + text.append(HEADER); + for (String line : renderLines(missing)) { + text.append(newline).append(line); + } + // Match the trailing newline convention the file already used + if (existing.endsWith("\n")) { + text.append(newline); + } + 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))); + 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) { + // GitHub honors only one CODEOWNERS: .github wins over the root, which wins over docs + int precedence = CODEOWNERS_PRECEDENCE.indexOf(path); + if (precedence >= existingCodeownersPrecedence) { + return; + } + existingCodeownersPrecedence = precedence; + codeownersIsAppendable = sourceFile instanceof PlainText; + existingCodeowners = codeownersIsAppendable ? sourceFile.getSourcePath() : null; + } + + 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..47b5839 --- /dev/null +++ b/src/test/java/org/openrewrite/github/ReplaceDependabotReviewersWithCodeownersTest.java @@ -0,0 +1,740 @@ +/* + * 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 mergeIntoPatternAlreadyOwnedBySomeoneElse() { + 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 + """, + """ + /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") + ) + ); + } + + @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 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( + //language=yaml + yaml( + """ + version: 2 + updates: + - package-ecosystem: maven + directory: / + reviewers: + - acme/backend + """, + spec -> spec.path("some/other/dependabot-example.yml") + ) + ); + } +}