Skip to content

Commit 114e610

Browse files
authored
Add DevCenter cards for Python and Node.js version tracking (#62)
* Add DevCenter cards for Python and Node.js version tracking Implement PythonVersionUpgrade and NodeVersionUpgrade recipes that track language versions across repositories, similar to the existing JavaVersionUpgrade card. Python reads requires-python from the PythonResolutionResult marker; Node.js reads engines.node from the NodeResolutionResult marker. Closes moderneinc/support-morganstanley#217 * Update recipes.csv with new Python and Node.js cards * Add DevCenter starter YAML files for Python and Node.js Add python-devcenter.yml and node-devcenter.yml declarative starters that compose the new version tracking cards with FindOrganizationStatistics and FindCommitters, following the pattern of build-tool-devcenter.yml.
1 parent 10564dc commit 114e610

8 files changed

Lines changed: 633 additions & 22 deletions

File tree

build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ dependencies {
2525
implementation("org.openrewrite.recipe:rewrite-java-security:${rewriteVersion}")
2626
implementation("org.openrewrite:rewrite-maven:${rewriteVersion}")
2727
implementation("org.openrewrite:rewrite-gradle:${rewriteVersion}")
28+
implementation("org.openrewrite:rewrite-python:${rewriteVersion}")
29+
implementation("org.openrewrite:rewrite-javascript:${rewriteVersion}")
2830
runtimeOnly("org.openrewrite:rewrite-java-17:${rewriteVersion}")
2931

3032
implementation("org.slf4j:slf4j-api:1.7.+")
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/*
2+
* Copyright 2026 the original author or authors.
3+
* <p>
4+
* Licensed under the Moderne Source Available License (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* <p>
8+
* https://docs.moderne.io/licensing/moderne-source-available-license
9+
* <p>
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.moderne.devcenter;
17+
18+
import lombok.EqualsAndHashCode;
19+
import lombok.Getter;
20+
import lombok.RequiredArgsConstructor;
21+
import lombok.Value;
22+
import org.intellij.lang.annotations.Language;
23+
import org.jspecify.annotations.Nullable;
24+
import org.openrewrite.*;
25+
import org.openrewrite.javascript.marker.NodeResolutionResult;
26+
27+
import java.util.List;
28+
import java.util.Map;
29+
import java.util.regex.Matcher;
30+
import java.util.regex.Pattern;
31+
import java.util.stream.Stream;
32+
33+
import static java.util.stream.Collectors.toList;
34+
35+
@Value
36+
@EqualsAndHashCode(callSuper = false)
37+
public class NodeVersionUpgrade extends UpgradeMigrationCard {
38+
39+
private static final Pattern VERSION_PATTERN =
40+
Pattern.compile("(?:>=|[~^]|>)\\s*v?(\\d+)");
41+
private static final Pattern BARE_VERSION_PATTERN =
42+
Pattern.compile("v?(\\d+)");
43+
44+
@Option(displayName = "Major version",
45+
description = "The major version of Node.js to upgrade to.",
46+
example = "22")
47+
int majorVersion;
48+
49+
@Option(displayName = "Upgrade recipe",
50+
description = "The recipe to use to upgrade.",
51+
required = false)
52+
@Nullable
53+
String upgradeRecipe;
54+
55+
String displayName = "Move to a later Node.js version";
56+
57+
@Override
58+
public String getInstanceName() {
59+
return "Move to Node.js " + majorVersion;
60+
}
61+
62+
String description = "Determine the current state of a repository relative to a desired Node.js version upgrade.";
63+
64+
@Override
65+
public TreeVisitor<?, ExecutionContext> getVisitor() {
66+
return new TreeVisitor<Tree, ExecutionContext>() {
67+
@Override
68+
public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) {
69+
if (tree instanceof SourceFile) {
70+
tree.getMarkers().findFirst(NodeResolutionResult.class).ifPresent(result -> {
71+
Map<String, String> engines = result.getEngines();
72+
if (engines != null) {
73+
String nodeConstraint = engines.get("node");
74+
if (nodeConstraint != null) {
75+
int actualVersion = parseMajorVersion(nodeConstraint);
76+
if (actualVersion >= 0) {
77+
Measure measure = Measure.Completed;
78+
if (actualVersion < majorVersion) {
79+
if (actualVersion >= 24) {
80+
measure = Measure.Node24Plus;
81+
} else if (actualVersion >= 22) {
82+
measure = Measure.Node22Plus;
83+
} else if (actualVersion >= 20) {
84+
measure = Measure.Node20Plus;
85+
} else if (actualVersion >= 18) {
86+
measure = Measure.Node18Plus;
87+
} else if (actualVersion >= 16) {
88+
measure = Measure.Node16Plus;
89+
} else {
90+
measure = Measure.Node14Plus;
91+
}
92+
}
93+
94+
upgradesAndMigrations.insertRow(ctx, NodeVersionUpgrade.this,
95+
measure, String.valueOf(actualVersion));
96+
}
97+
}
98+
}
99+
});
100+
}
101+
return tree;
102+
}
103+
};
104+
}
105+
106+
static int parseMajorVersion(String nodeConstraint) {
107+
Matcher m = VERSION_PATTERN.matcher(nodeConstraint);
108+
if (m.find()) {
109+
return Integer.parseInt(m.group(1));
110+
}
111+
m = BARE_VERSION_PATTERN.matcher(nodeConstraint);
112+
if (m.find()) {
113+
return Integer.parseInt(m.group(1));
114+
}
115+
return -1;
116+
}
117+
118+
@Override
119+
public List<DevCenterMeasure> getMeasures() {
120+
return Stream.of(Measure.values())
121+
.filter(measure -> measure.minimumMajorVersion < majorVersion)
122+
.collect(toList());
123+
}
124+
125+
@Override
126+
public @Nullable String getFixRecipeId() {
127+
return upgradeRecipe;
128+
}
129+
130+
@Getter
131+
@RequiredArgsConstructor
132+
public enum Measure implements DevCenterMeasure {
133+
Node14Plus("Node.js 14+", "Technically, this is any version less than 16.", 14),
134+
Node16Plus("Node.js 16+", "Node.js 16 and later", 16),
135+
Node18Plus("Node.js 18+", "Node.js 18 and later", 18),
136+
Node20Plus("Node.js 20+", "Node.js 20 and later", 20),
137+
Node22Plus("Node.js 22+", "Node.js 22 and later", 22),
138+
Node24Plus("Node.js 24+", "Node.js 24 and later", 24),
139+
Completed("Completed", "The upgrade to the desired Node.js version is already complete.", 0);
140+
141+
private final @Language("markdown") String name;
142+
private final @Language("markdown") String description;
143+
private final int minimumMajorVersion;
144+
}
145+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/*
2+
* Copyright 2026 the original author or authors.
3+
* <p>
4+
* Licensed under the Moderne Source Available License (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* <p>
8+
* https://docs.moderne.io/licensing/moderne-source-available-license
9+
* <p>
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.moderne.devcenter;
17+
18+
import lombok.EqualsAndHashCode;
19+
import lombok.Getter;
20+
import lombok.RequiredArgsConstructor;
21+
import lombok.Value;
22+
import org.intellij.lang.annotations.Language;
23+
import org.jspecify.annotations.Nullable;
24+
import org.openrewrite.*;
25+
import org.openrewrite.python.marker.PythonResolutionResult;
26+
27+
import java.util.List;
28+
import java.util.regex.Matcher;
29+
import java.util.regex.Pattern;
30+
import java.util.stream.Stream;
31+
32+
import static java.util.stream.Collectors.toList;
33+
34+
@Value
35+
@EqualsAndHashCode(callSuper = false)
36+
public class PythonVersionUpgrade extends UpgradeMigrationCard {
37+
38+
private static final Pattern VERSION_PATTERN =
39+
Pattern.compile("(?:>=|~=|==|>)\\s*(\\d+)\\.(\\d+)");
40+
private static final Pattern BARE_VERSION_PATTERN =
41+
Pattern.compile("(\\d+)\\.(\\d+)");
42+
43+
@Option(displayName = "Minor version",
44+
description = "The minor version of Python 3 to upgrade to.",
45+
example = "13")
46+
int minorVersion;
47+
48+
@Option(displayName = "Upgrade recipe",
49+
description = "The recipe to use to upgrade.",
50+
required = false)
51+
@Nullable
52+
String upgradeRecipe;
53+
54+
String displayName = "Move to a later Python version";
55+
56+
@Override
57+
public String getInstanceName() {
58+
return "Move to Python 3." + minorVersion;
59+
}
60+
61+
String description = "Determine the current state of a repository relative to a desired Python version upgrade.";
62+
63+
@Override
64+
public TreeVisitor<?, ExecutionContext> getVisitor() {
65+
return new TreeVisitor<Tree, ExecutionContext>() {
66+
@Override
67+
public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) {
68+
if (tree instanceof SourceFile) {
69+
tree.getMarkers().findFirst(PythonResolutionResult.class).ifPresent(result -> {
70+
String requiresPython = result.getRequiresPython();
71+
if (requiresPython != null) {
72+
int[] version = parseMinimumVersion(requiresPython);
73+
if (version != null) {
74+
int major = version[0];
75+
int minor = version[1];
76+
77+
Measure measure = Measure.Completed;
78+
if (major < 3 || (major == 3 && minor < minorVersion)) {
79+
if (major < 3) {
80+
measure = Measure.Python2;
81+
} else if (minor >= 13) {
82+
measure = Measure.Python313Plus;
83+
} else if (minor >= 12) {
84+
measure = Measure.Python312Plus;
85+
} else if (minor >= 11) {
86+
measure = Measure.Python311Plus;
87+
} else if (minor >= 10) {
88+
measure = Measure.Python310Plus;
89+
} else if (minor >= 9) {
90+
measure = Measure.Python39Plus;
91+
} else {
92+
measure = Measure.Python38Plus;
93+
}
94+
}
95+
96+
upgradesAndMigrations.insertRow(ctx, PythonVersionUpgrade.this,
97+
measure, major + "." + minor);
98+
}
99+
}
100+
});
101+
}
102+
return tree;
103+
}
104+
};
105+
}
106+
107+
static int @Nullable [] parseMinimumVersion(String requiresPython) {
108+
Matcher m = VERSION_PATTERN.matcher(requiresPython);
109+
if (m.find()) {
110+
return new int[]{Integer.parseInt(m.group(1)), Integer.parseInt(m.group(2))};
111+
}
112+
m = BARE_VERSION_PATTERN.matcher(requiresPython);
113+
if (m.find()) {
114+
return new int[]{Integer.parseInt(m.group(1)), Integer.parseInt(m.group(2))};
115+
}
116+
return null;
117+
}
118+
119+
@Override
120+
public List<DevCenterMeasure> getMeasures() {
121+
return Stream.of(Measure.values())
122+
.filter(measure -> measure.minimumMinorVersion < minorVersion)
123+
.collect(toList());
124+
}
125+
126+
@Override
127+
public @Nullable String getFixRecipeId() {
128+
return upgradeRecipe;
129+
}
130+
131+
@Getter
132+
@RequiredArgsConstructor
133+
public enum Measure implements DevCenterMeasure {
134+
Python2("Python 2", "Python 2.x (end of life).", 0),
135+
Python38Plus("Python 3.8+", "Python 3.8 and later", 8),
136+
Python39Plus("Python 3.9+", "Python 3.9 and later", 9),
137+
Python310Plus("Python 3.10+", "Python 3.10 and later", 10),
138+
Python311Plus("Python 3.11+", "Python 3.11 and later", 11),
139+
Python312Plus("Python 3.12+", "Python 3.12 and later", 12),
140+
Python313Plus("Python 3.13+", "Python 3.13 and later", 13),
141+
Completed("Completed", "The upgrade to the desired Python version is already complete.", 0);
142+
143+
private final @Language("markdown") String name;
144+
private final @Language("markdown") String description;
145+
private final int minimumMinorVersion;
146+
}
147+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#
2+
# Copyright 2026 the original author or authors.
3+
# <p>
4+
# Licensed under the Moderne Source Available License (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
# <p>
8+
# https://docs.moderne.io/licensing/moderne-source-available-license
9+
# <p>
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
17+
---
18+
type: specs.openrewrite.org/v1beta/recipe
19+
name: io.moderne.devcenter.DevCenterNodeStarter
20+
displayName: DevCenter for Node.js
21+
description: >-
22+
A default DevCenter configuration for Node.js repositories.
23+
Track Node.js version adoption across your organization.
24+
recipeList:
25+
- io.moderne.devcenter.NodeVersionUpgrade:
26+
majorVersion: 22
27+
- io.moderne.devcenter.FindOrganizationStatistics
28+
- org.openrewrite.search.FindCommitters
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#
2+
# Copyright 2026 the original author or authors.
3+
# <p>
4+
# Licensed under the Moderne Source Available License (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
# <p>
8+
# https://docs.moderne.io/licensing/moderne-source-available-license
9+
# <p>
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
17+
---
18+
type: specs.openrewrite.org/v1beta/recipe
19+
name: io.moderne.devcenter.DevCenterPythonStarter
20+
displayName: DevCenter for Python
21+
description: >-
22+
A default DevCenter configuration for Python repositories.
23+
Track Python version adoption across your organization.
24+
recipeList:
25+
- io.moderne.devcenter.PythonVersionUpgrade:
26+
minorVersion: 13
27+
- io.moderne.devcenter.FindOrganizationStatistics
28+
- org.openrewrite.search.FindCommitters

0 commit comments

Comments
 (0)