diff --git a/src/main/java/org/openrewrite/github/ChangeDependabotScheduleInterval.java b/src/main/java/org/openrewrite/github/ChangeDependabotScheduleInterval.java index 39c38e8..4333bdb 100644 --- a/src/main/java/org/openrewrite/github/ChangeDependabotScheduleInterval.java +++ b/src/main/java/org/openrewrite/github/ChangeDependabotScheduleInterval.java @@ -15,15 +15,21 @@ */ package org.openrewrite.github; +import com.fasterxml.jackson.annotation.JsonCreator; import lombok.EqualsAndHashCode; import lombok.Value; +import org.jspecify.annotations.Nullable; import org.openrewrite.*; +import org.openrewrite.internal.ListUtils; import org.openrewrite.yaml.JsonPathMatcher; import org.openrewrite.yaml.YamlIsoVisitor; +import org.openrewrite.yaml.YamlParser; import org.openrewrite.yaml.tree.Yaml; import java.util.HashSet; +import java.util.List; import java.util.Set; +import java.util.regex.Pattern; @EqualsAndHashCode(callSuper = false) @Value @@ -39,9 +45,45 @@ public class ChangeDependabotScheduleInterval extends Recipe { example = "weekly") String interval; + @Option(displayName = "Schedule day", + description = "The day of the week to run updates when the schedule interval is `weekly`.", + example = "monday", + required = false) + @Nullable + String day; + + @Option(displayName = "Schedule time", + description = "The time of day to run updates, in `HH:mm` format. Defaults to UTC unless `timezone` is set.", + example = "09:00", + required = false) + @Nullable + String time; + + @Option(displayName = "Schedule timezone", + description = "The IANA time zone identifier for the configured schedule time.", + example = "Asia/Tokyo", + required = false) + @Nullable + String timezone; + + public ChangeDependabotScheduleInterval(String packageEcosystem, String interval) { + this(packageEcosystem, interval, null, null, null); + } + + @JsonCreator + public ChangeDependabotScheduleInterval(String packageEcosystem, String interval, @Nullable String day, + @Nullable String time, @Nullable String timezone) { + this.packageEcosystem = packageEcosystem; + this.interval = interval; + this.day = day; + this.time = time; + this.timezone = timezone; + } + String displayName = "Change dependabot schedule interval"; - String description = "Change the schedule interval for a given package-ecosystem in a `dependabot.yml` configuration file. " + + String description = "Change the schedule interval and optionally the day, time, and time zone for a given " + + "package-ecosystem in a `dependabot.yml` configuration file. " + "[The available configuration options for dependabot are listed on GitHub](https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates)."; @Override @@ -56,14 +98,164 @@ public Set getTags() { @Override public TreeVisitor getVisitor() { return Preconditions.check(new FindSourceFiles(".github/dependabot.{yml,yaml}"), new YamlIsoVisitor() { - private final JsonPathMatcher targetEcosystem = new JsonPathMatcher("$.updates[?(@.package-ecosystem =~ '" + packageEcosystem + "')].schedule.interval"); + private static final String CONFIGURE_SCHEDULE = "CONFIGURE_SCHEDULE"; + private final JsonPathMatcher packageEcosystemMatcher = + new JsonPathMatcher("$.updates[*].package-ecosystem"); + private final Pattern packageEcosystemPattern = Pattern.compile(packageEcosystem); @Override public Yaml.Mapping.Entry visitMappingEntry(Yaml.Mapping.Entry entry, ExecutionContext ctx) { - if (targetEcosystem.matches(getCursor()) && !((Yaml.Scalar) entry.getValue()).getValue().equals(interval)) { - return super.visitMappingEntry(entry.withValue(((Yaml.Scalar) entry.getValue()).withValue(interval)), ctx); + Yaml.Mapping.Entry e = super.visitMappingEntry(entry, ctx); + if (packageEcosystemMatcher.matches(getCursor()) && e.getValue() instanceof Yaml.Scalar && + packageEcosystemPattern.matcher(((Yaml.Scalar) e.getValue()).getValue()).matches()) { + getCursor().dropParentUntil(Yaml.Mapping.class::isInstance).putMessage(CONFIGURE_SCHEDULE, true); + } + return e; + } + + @Override + public Yaml.Mapping visitMapping(Yaml.Mapping mapping, ExecutionContext ctx) { + Yaml.Mapping m = super.visitMapping(mapping, ctx); + if (!Boolean.TRUE.equals(getCursor().pollMessage(CONFIGURE_SCHEDULE))) { + return m; + } + Cursor mappingParentCursor = getCursor().getParentOrThrow(); + if (m.getOpeningBracePrefix() != null && + m.getEntries().stream().anyMatch(this::scheduleNeedsNewEntry)) { + m = normalizeFlowMapping(m, m.getOpeningBracePrefix(), ctx, mappingParentCursor); + } + Cursor mappingCursor = new Cursor(mappingParentCursor, m); + return m.withEntries(ListUtils.map(m.getEntries(), entry -> { + if (!"schedule".equals(entry.getKey().getValue())) { + return entry; + } + Yaml.Mapping schedule; + if (entry.getValue() instanceof Yaml.Mapping) { + schedule = (Yaml.Mapping) entry.getValue(); + } else if (isEmptyScalar(entry.getValue())) { + schedule = emptyMapping(ctx); + } else { + return entry; + } + Cursor scheduleEntryCursor = new Cursor(mappingCursor, entry); + return entry.withValue(configureSchedule(schedule, ctx, scheduleEntryCursor)); + })); + } + + private boolean scheduleNeedsNewEntry(Yaml.Mapping.Entry entry) { + if (!"schedule".equals(entry.getKey().getValue())) { + return false; + } + if (isEmptyScalar(entry.getValue())) { + return true; + } + if (!(entry.getValue() instanceof Yaml.Mapping)) { + return false; + } + Yaml.Mapping schedule = (Yaml.Mapping) entry.getValue(); + return !hasEntry(schedule, "interval") || + day != null && !hasEntry(schedule, "day") || + time != null && !hasEntry(schedule, "time") || + timezone != null && !hasEntry(schedule, "timezone"); + } + + private boolean hasEntry(Yaml.Mapping mapping, String key) { + return mapping.getEntries().stream() + .anyMatch(entry -> key.equals(entry.getKey().getValue())); + } + + private boolean isEmptyScalar(Yaml.Block value) { + return value instanceof Yaml.Scalar && ((Yaml.Scalar) value).getValue().isEmpty(); + } + + private Yaml.Mapping emptyMapping(ExecutionContext ctx) { + return new YamlParser() + .parse(ctx, "{}") + .map(Yaml.Documents.class::cast) + .map(documents -> (Yaml.Mapping) documents.getDocuments().get(0).getBlock()) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Failed to parse empty Dependabot schedule")); + } + + private Yaml.Mapping configureSchedule(Yaml.Mapping schedule, ExecutionContext ctx, + Cursor scheduleEntryCursor) { + Yaml.Mapping m = upsert(schedule, "interval", interval, false, ctx, scheduleEntryCursor); + if (day != null) { + m = upsert(m, "day", day, false, ctx, scheduleEntryCursor); + } + if (time != null) { + m = upsert(m, "time", time, true, ctx, scheduleEntryCursor); } - return super.visitMappingEntry(entry, ctx); + if (timezone != null) { + m = upsert(m, "timezone", timezone, true, ctx, scheduleEntryCursor); + } + return m; + } + + private Yaml.Mapping upsert(Yaml.Mapping schedule, String key, String value, boolean quoted, + ExecutionContext ctx, Cursor scheduleEntryCursor) { + for (Yaml.Mapping.Entry entry : schedule.getEntries()) { + if (key.equals(entry.getKey().getValue())) { + if (entry.getValue() instanceof Yaml.Scalar && + !value.equals(((Yaml.Scalar) entry.getValue()).getValue())) { + return schedule.withEntries(ListUtils.map(schedule.getEntries(), e -> e == entry ? + e.withValue(((Yaml.Scalar) e.getValue()).withValue(value)) : e)); + } + return schedule; + } + } + + Yaml.Mapping.Entry newEntry = new YamlParser() + .parse(ctx, key + ": " + (quoted ? quote(value) : value)) + .map(Yaml.Documents.class::cast) + .map(documents -> (Yaml.Mapping) documents.getDocuments().get(0).getBlock()) + .map(parsed -> parsed.getEntries().get(0)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Failed to parse Dependabot schedule option")); + schedule = normalizeFlowMapping(schedule, "\n", ctx, scheduleEntryCursor); + Cursor scheduleCursor = new Cursor(scheduleEntryCursor, schedule); + newEntry = autoFormat(newEntry, ctx, scheduleCursor); + + List entries = schedule.getEntries(); + int keyOrder = scheduleKeyOrder(key); + int insertionIndex = entries.size(); + for (int i = 0; i < entries.size(); i++) { + if (scheduleKeyOrder(entries.get(i).getKey().getValue()) > keyOrder) { + insertionIndex = i; + break; + } + } + return schedule.withEntries(ListUtils.insert(entries, newEntry, insertionIndex)); + } + + private Yaml.Mapping normalizeFlowMapping(Yaml.Mapping mapping, String firstEntryPrefix, + ExecutionContext ctx, Cursor parentCursor) { + if (mapping.getOpeningBracePrefix() == null) { + return mapping; + } + Yaml.Mapping normalized = mapping.withOpeningBracePrefix(null).withClosingBracePrefix(null); + normalized = normalized.withEntries(ListUtils.mapFirst(normalized.getEntries(), + first -> first.withPrefix(firstEntryPrefix))); + return autoFormat(normalized, ctx, parentCursor); + } + + private int scheduleKeyOrder(String key) { + switch (key) { + case "interval": + return 0; + case "day": + return 1; + case "time": + return 2; + case "timezone": + return 3; + default: + return Integer.MAX_VALUE; + } + } + + private String quote(String value) { + return '"' + value.replace("\\", "\\\\").replace("\"", "\\\"") + '"'; } }); } diff --git a/src/main/resources/META-INF/rewrite/recipes.csv b/src/main/resources/META-INF/rewrite/recipes.csv index 9f78616..b649017 100644 --- a/src/main/resources/META-INF/rewrite/recipes.csv +++ b/src/main/resources/META-INF/rewrite/recipes.csv @@ -6,7 +6,7 @@ maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.AddMe maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.AutoCancelInProgressWorkflow,Cancel in-progress workflow when it is triggered again,"When a workflow is already running and would be triggered again, cancel the existing workflow, through the native [`concurrency`](https://docs.github.com/en/actions/using-jobs/using-concurrency) property. Runs on the default branch are not cancelled.",2,,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.ChangeAction,Change GitHub Action,Change a GitHub Action in any workflow.,1,,GitHub Actions,,Recipes to perform [GitHub Actions](https://docs.github.com/en/actions) hygiene and migration tasks.,"[{""name"":""oldAction"",""type"":""String"",""displayName"":""Action"",""description"":""Name of the action to match."",""example"":""gradle/wrapper-validation-action"",""required"":true},{""name"":""oldSha"",""type"":""String"",""displayName"":""Old commit SHA"",""description"":""Restricts the change by the existing `uses:` ref. When omitted, the action is changed regardless of how it is pinned (the default; commit SHA pins are rewritten). When set to an empty string, only references that are **not** pinned to a 40-character commit SHA are changed, leaving deliberate SHA pins on the original action untouched. When set to a specific commit SHA, only references pinned to exactly that SHA are changed."",""example"":""8f4b7f84864484a7bf31766abe9204da3cbe65b3""},{""name"":""newAction"",""type"":""String"",""displayName"":""Action"",""description"":""Name of the action to use instead."",""example"":""gradle/actions/wrapper-validation"",""required"":true},{""name"":""newVersion"",""type"":""String"",""displayName"":""Version"",""description"":""New version to use. When omitted, preserve the existing ref."",""example"":""v3""}]", maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.ChangeActionVersion,Change GitHub Action version,Change the version of a GitHub Action in any workflow.,1,,GitHub Actions,,Recipes to perform [GitHub Actions](https://docs.github.com/en/actions) hygiene and migration tasks.,"[{""name"":""action"",""type"":""String"",""displayName"":""Action"",""description"":""Name of the action to update."",""example"":""actions/setup-java"",""required"":true},{""name"":""version"",""type"":""String"",""displayName"":""Version"",""description"":""Version to use."",""example"":""v4"",""required"":true},{""name"":""oldSha"",""type"":""String"",""displayName"":""Old commit SHA"",""description"":""Restricts the change by the existing `uses:` ref. When omitted, the version is changed regardless of how the action is pinned (the default; commit SHA pins are rewritten). When set to an empty string, only references that are **not** pinned to a 40-character commit SHA are changed, preserving deliberate SHA pins. When set to a specific commit SHA, only references pinned to exactly that SHA are changed."",""example"":""8f4b7f84864484a7bf31766abe9204da3cbe65b3""}]", -maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.ChangeDependabotScheduleInterval,Change dependabot schedule interval,Change the schedule interval for a given package-ecosystem in a `dependabot.yml` configuration file. [The available configuration options for dependabot are listed on GitHub](https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates).,1,,GitHub Actions,,Recipes to perform [GitHub Actions](https://docs.github.com/en/actions) hygiene and migration tasks.,"[{""name"":""packageEcosystem"",""type"":""String"",""displayName"":""Package ecosystem"",""description"":""The package-ecosystem to make updates on."",""example"":""maven"",""required"":true},{""name"":""interval"",""type"":""String"",""displayName"":""Schedule interval"",""description"":""The schedule interval value the package-ecosystem should use."",""example"":""weekly"",""valid"":[""daily"",""weekly"",""monthly""],""required"":true}]", +maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.ChangeDependabotScheduleInterval,Change dependabot schedule interval,"Change the schedule interval and optionally the day, time, and time zone for a given package-ecosystem in a `dependabot.yml` configuration file. [The available configuration options for dependabot are listed on GitHub](https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates).",1,,GitHub Actions,,Recipes to perform [GitHub Actions](https://docs.github.com/en/actions) hygiene and migration tasks.,"[{""name"":""packageEcosystem"",""type"":""String"",""displayName"":""Package ecosystem"",""description"":""The package-ecosystem to make updates on."",""example"":""maven"",""required"":true},{""name"":""interval"",""type"":""String"",""displayName"":""Schedule interval"",""description"":""The schedule interval value the package-ecosystem should use."",""example"":""weekly"",""valid"":[""daily"",""weekly"",""monthly""],""required"":true},{""name"":""day"",""type"":""String"",""displayName"":""Schedule day"",""description"":""The day of the week to run updates when the schedule interval is `weekly`."",""example"":""monday""},{""name"":""time"",""type"":""String"",""displayName"":""Schedule time"",""description"":""The time of day to run updates, in `HH:mm` format. Defaults to UTC unless `timezone` is set."",""example"":""09:00""},{""name"":""timezone"",""type"":""String"",""displayName"":""Schedule timezone"",""description"":""The IANA time zone identifier for the configured schedule time."",""example"":""Asia/Tokyo""}]", maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.DependabotCheckForGithubActionsUpdatesDaily,Check for github-actions updates daily,Set dependabot to check for github-actions updates daily.,2,,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.DependabotCheckForGithubActionsUpdatesWeekly,Check for github-actions updates weekly,Set dependabot to check for github-actions updates weekly.,2,,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.FindGitHubActionSecretReferences,Find GitHub action secret references,Help identify and inventory your GitHub secrets that are being used in GitHub actions.,2,,GitHub Actions,,Recipes to perform [GitHub Actions](https://docs.github.com/en/actions) hygiene and migration tasks.,,"[{""name"":""org.openrewrite.table.TextMatches"",""displayName"":""Text matches"",""instanceName"":""Text matches"",""description"":""Lines matching simple text search."",""columns"":[{""name"":""sourcePath"",""type"":""String"",""displayName"":""Source path"",""description"":""The path to the source file.""},{""name"":""match"",""type"":""String"",""displayName"":""Match"",""description"":""The text of the match.""}]}]" diff --git a/src/test/java/org/openrewrite/github/ChangeDependabotScheduleIntervalTest.java b/src/test/java/org/openrewrite/github/ChangeDependabotScheduleIntervalTest.java index 902686f..1ca3dc4 100644 --- a/src/test/java/org/openrewrite/github/ChangeDependabotScheduleIntervalTest.java +++ b/src/test/java/org/openrewrite/github/ChangeDependabotScheduleIntervalTest.java @@ -67,6 +67,289 @@ void changeDependabotScheduleInterval() { ); } + @Test + void configureCompleteScheduleAndAddMissingFieldsInOrder() { + rewriteRun( + spec -> spec.recipe(new ChangeDependabotScheduleInterval( + "github-actions", "weekly", "monday", "09:00", "Asia/Tokyo")), + //language=yaml + yaml( + """ + version: 2 + updates: + - package-ecosystem: github-actions + directory: /one + schedule: + # Keep the interval comment + interval: daily # and its suffix + day: sunday + time: "08:00" + timezone: "Europe/Paris" + - package-ecosystem: maven + directory: / + schedule: + interval: daily + day: friday + time: "07:00" + timezone: "Europe/London" + - package-ecosystem: github-actions + directory: /two + schedule: + # Keep the timezone comment + timezone: "UTC" # and its suffix + """, + """ + version: 2 + updates: + - package-ecosystem: github-actions + directory: /one + schedule: + # Keep the interval comment + interval: weekly # and its suffix + day: monday + time: "09:00" + timezone: "Asia/Tokyo" + - package-ecosystem: maven + directory: / + schedule: + interval: daily + day: friday + time: "07:00" + timezone: "Europe/London" + - package-ecosystem: github-actions + directory: /two + schedule: + interval: weekly + day: monday + time: "09:00" + # Keep the timezone comment + timezone: "Asia/Tokyo" # and its suffix + """, + spec -> spec.path(".github/dependabot.yml") + ) + ); + } + + @Test + void nullScheduleOptionsLeaveExistingFieldsUnchanged() { + rewriteRun( + spec -> spec.recipe(new ChangeDependabotScheduleInterval( + "github-actions", "weekly", null, null, null)), + //language=yaml + yaml( + """ + version: 2 + updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: daily + day: sunday + time: "08:00" + timezone: "Europe/Paris" + """, + """ + version: 2 + updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: sunday + time: "08:00" + timezone: "Europe/Paris" + """, + spec -> spec.path(".github/dependabot.yml") + ) + ); + } + + @Test + void addMissingScheduleFieldsToYamlFile() { + rewriteRun( + spec -> spec.recipeFromYaml(""" + type: specs.openrewrite.org/v1beta/recipe + name: org.example.ConfigureDependabotSchedule + displayName: Configure Dependabot schedule + description: Configure a complete Dependabot schedule. + recipeList: + - org.openrewrite.github.ChangeDependabotScheduleInterval: + packageEcosystem: github-actions + interval: monthly + day: tuesday + time: "10:30" + timezone: America/New_York + """, "org.example.ConfigureDependabotSchedule"), + //language=yaml + yaml( + """ + version: 2 + updates: + - package-ecosystem: github-actions + directory: / + schedule: { interval: daily } + - package-ecosystem: github-actions + directory: /empty + schedule: {} + """, + """ + version: 2 + updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + day: tuesday + time: "10:30" + timezone: "America/New_York" + - package-ecosystem: github-actions + directory: /empty + schedule: + interval: monthly + day: tuesday + time: "10:30" + timezone: "America/New_York" + """, + spec -> spec.path(".github/dependabot.yaml") + ) + ); + } + + @Test + void addScheduleFieldsToEmptyBlock() { + rewriteRun( + spec -> spec.recipe(new ChangeDependabotScheduleInterval( + "github-actions", "weekly", "monday", "09:00", "UTC")), + //language=yaml + yaml( + """ + version: 2 + updates: + - package-ecosystem: github-actions + directory: / + schedule: + """, + """ + version: 2 + updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "09:00" + timezone: "UTC" + """, + spec -> spec.path(".github/dependabot.yml") + ) + ); + } + + @Test + void addScheduleFieldsWhenUpdateUsesFlowMapping() { + rewriteRun( + spec -> spec.recipe(new ChangeDependabotScheduleInterval( + "github-actions", "weekly", "monday", "09:00", "UTC")), + //language=yaml + yaml( + """ + version: 2 + updates: + - { package-ecosystem: github-actions, directory: /, schedule: { interval: daily } } + """, + """ + version: 2 + updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "09:00" + timezone: "UTC" + """, + spec -> spec.path(".github/dependabot.yml") + ) + ); + } + + @Test + void oldDeclarativeConfigurationPreservesExistingFlowMappingOptions() { + rewriteRun( + spec -> spec.recipeFromYaml(""" + type: specs.openrewrite.org/v1beta/recipe + name: org.example.ChangeDependabotInterval + displayName: Change Dependabot interval + description: Change only the Dependabot interval. + recipeList: + - org.openrewrite.github.ChangeDependabotScheduleInterval: + packageEcosystem: github-actions + interval: weekly + """, "org.example.ChangeDependabotInterval"), + //language=yaml + yaml( + """ + version: 2 + updates: + - package-ecosystem: github-actions + directory: / + schedule: { interval: daily, day: sunday, time: "08:00", timezone: "Europe/Paris" } + """, + """ + version: 2 + updates: + - package-ecosystem: github-actions + directory: / + schedule: { interval: weekly, day: sunday, time: "08:00", timezone: "Europe/Paris" } + """, + spec -> spec.path(".github/dependabot.yml") + ) + ); + } + + @Test + void packageEcosystemRegexRemainsSupported() { + rewriteRun( + spec -> spec.recipe(new ChangeDependabotScheduleInterval("maven|gradle", "daily")), + //language=yaml + yaml( + """ + version: 2 + updates: + - package-ecosystem: maven + directory: / + schedule: + interval: weekly + - package-ecosystem: gradle + directory: / + schedule: + interval: monthly + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + """, + """ + version: 2 + updates: + - package-ecosystem: maven + directory: / + schedule: + interval: daily + - package-ecosystem: gradle + directory: / + schedule: + interval: daily + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + """, + spec -> spec.path(".github/dependabot.yml") + ) + ); + } + @Test void noMatchingPackageEcosystem() { rewriteRun(