Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -56,14 +98,164 @@ public Set<String> getTags() {
@Override
public TreeVisitor<?, ExecutionContext> getVisitor() {
return Preconditions.check(new FindSourceFiles(".github/dependabot.{yml,yaml}"), new YamlIsoVisitor<ExecutionContext>() {
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<Yaml.Mapping.Entry> 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("\"", "\\\"") + '"';
}
});
}
Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/META-INF/rewrite/recipes.csv
Original file line number Diff line number Diff line change
Expand Up @@ -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.""}]}]"
Expand Down
Loading
Loading