move to version catalog, implement mod metadata format
This commit is contained in:
parent
808d4b0e1b
commit
35cfb2a7c1
|
@ -1,5 +1,6 @@
|
||||||
plugins {
|
plugins {
|
||||||
java
|
java
|
||||||
|
`java-library`
|
||||||
id("io.freefair.lombok").version("8.+")
|
id("io.freefair.lombok").version("8.+")
|
||||||
`maven-publish`
|
`maven-publish`
|
||||||
}
|
}
|
||||||
|
@ -22,11 +23,14 @@ repositories {
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("org.ecorous.esnesnon:nonsense-remapper:1.0.0-SNAPSHOT")
|
implementation(libs.remapper)
|
||||||
implementation("net.fabricmc:sponge-mixin:0.13.4+mixin.0.8.5")
|
|
||||||
implementation("org.apache.logging.log4j:log4j-slf4j2-impl:3.0.0-beta2")
|
implementation("org.apache.logging.log4j:log4j-slf4j2-impl:3.0.0-beta2")
|
||||||
implementation("org.apache.logging.log4j:log4j-api:3.0.0-beta2")
|
implementation("org.apache.logging.log4j:log4j-api:3.0.0-beta2")
|
||||||
implementation("org.apache.logging.log4j:log4j-core:3.0.0-beta2")
|
implementation("org.apache.logging.log4j:log4j-core:3.0.0-beta2")
|
||||||
|
|
||||||
|
api(libs.mixin)
|
||||||
|
api(libs.nightconfig)
|
||||||
|
api(libs.annotations)
|
||||||
}
|
}
|
||||||
|
|
||||||
java {
|
java {
|
||||||
|
|
17
gradle/libs.versions.toml
Normal file
17
gradle/libs.versions.toml
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
[versions]
|
||||||
|
|
||||||
|
remapper = "1.0.0-SNAPSHOT"
|
||||||
|
nightconfig = "3.7.1"
|
||||||
|
mixin = "0.13.4+mixin.0.8.5"
|
||||||
|
annotations = "24.1.0"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
|
||||||
|
remapper = { module = "org.ecorous.esnesnon:nonsense-remapper", version.ref = "remapper" }
|
||||||
|
nightconfig = { module = "com.electronwill.night-config:toml", version.ref = "nightconfig" }
|
||||||
|
mixin = { module = "net.fabricmc:sponge-mixin", version.ref = "mixin" }
|
||||||
|
annotations = { module = "org.jetbrains:annotations", version.ref = "annotations" }
|
||||||
|
|
||||||
|
[bundles]
|
||||||
|
|
||||||
|
[plugins]
|
|
@ -21,5 +21,5 @@ minecraft("1.20.6")
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":"))
|
implementation(project(":"))
|
||||||
annotationProcessor("net.fabricmc:sponge-mixin:0.13.4+mixin.0.8.5")
|
annotationProcessor(libs.mixin)
|
||||||
}
|
}
|
|
@ -0,0 +1,54 @@
|
||||||
|
package org.ecorous.esnesnon.nonsense.loader.api.mod;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import org.ecorous.esnesnon.nonsense.loader.impl.LoaderImpl;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
public final class License {
|
||||||
|
|
||||||
|
private static final Map<String, String> idToName = new HashMap<>();
|
||||||
|
private static final Map<String, License> idToLicense = new HashMap<>();
|
||||||
|
|
||||||
|
static {
|
||||||
|
loadList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void loadList(){
|
||||||
|
try (InputStream in = License.class.getResourceAsStream("/assets/nonsense-loader/licenses.json")){
|
||||||
|
if (in == null){
|
||||||
|
throw new IllegalStateException("in == null");
|
||||||
|
}
|
||||||
|
JsonObject object = LoaderImpl.getInstance().getGson().fromJson(new InputStreamReader(in), JsonObject.class);
|
||||||
|
object.getAsJsonArray("licenses").forEach(element -> {
|
||||||
|
JsonObject entry = element.getAsJsonObject();
|
||||||
|
idToName.put(entry.get("licenseId").getAsString(), entry.get("name").getAsString());
|
||||||
|
});
|
||||||
|
} catch (Exception e){
|
||||||
|
LoggerFactory.getLogger(License.class).warn("Failed to load license list!", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static License fromId(String id){
|
||||||
|
return idToLicense.computeIfAbsent(id, ignored -> new License(idToName.getOrDefault(id, id), id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private final String name, id;
|
||||||
|
private License(String name, String id) {
|
||||||
|
|
||||||
|
this.name = name;
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String name() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String id() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,85 @@
|
||||||
|
package org.ecorous.esnesnon.nonsense.loader.api.mod;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
public final class ModCredits implements Map<String, Collection<String>> {
|
||||||
|
|
||||||
|
public static ModCredits of(Map<String, Collection<String>> credits){
|
||||||
|
return new ModCredits(credits);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Map<String, Collection<String>> credits;
|
||||||
|
private ModCredits(Map<String, Collection<String>> credits){
|
||||||
|
this.credits = credits;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Collection<String> getEntries(){
|
||||||
|
return credits.keySet();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Collection<String> getRoles(String name){
|
||||||
|
return credits.getOrDefault(name, Collections.emptySet());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int size() {
|
||||||
|
return credits.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return credits.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean containsKey(Object key) {
|
||||||
|
return credits.containsKey(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean containsValue(Object value) {
|
||||||
|
return credits.containsValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<String> get(Object key) {
|
||||||
|
return credits.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<String> put(String key, Collection<String> value) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<String> remove(Object key) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void putAll(@NotNull Map<? extends String, ? extends Collection<String>> m) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void clear() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @NotNull Set<String> keySet() {
|
||||||
|
return credits.keySet();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @NotNull Collection<Collection<String>> values() {
|
||||||
|
return credits.values();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @NotNull Set<Entry<String, Collection<String>>> entrySet() {
|
||||||
|
return credits.entrySet();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,56 @@
|
||||||
|
package org.ecorous.esnesnon.nonsense.loader.api.mod;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
public final class ModDependencies {
|
||||||
|
|
||||||
|
private final Map<Type, Collection<Entry>> entries = new HashMap<>();
|
||||||
|
|
||||||
|
public ModDependencies(Collection<Entry> depends, Collection<Entry> breaks, Collection<Entry> suggests) {
|
||||||
|
entries.put(Type.DEPEND, depends);
|
||||||
|
entries.put(Type.BREAK, breaks);
|
||||||
|
entries.put(Type.SUGGEST, suggests);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Collection<Entry> getForType(Type type) {
|
||||||
|
return entries.get(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ModEntry> getForModId(String id) {
|
||||||
|
List<ModEntry> entries = new ArrayList<>();
|
||||||
|
for (Type type : Type.values()) {
|
||||||
|
for (Entry entry : getForType(type)) {
|
||||||
|
if (entry.id.equals(id)) {
|
||||||
|
entries.add(new ModEntry(type, entry.range));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ModEntry {
|
||||||
|
private final Type type;
|
||||||
|
private final String range;
|
||||||
|
|
||||||
|
private ModEntry(Type type, String range) {
|
||||||
|
this.type = type;
|
||||||
|
this.range = range;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Type type() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String range() {
|
||||||
|
return range;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Entry(String id, String range) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Type {
|
||||||
|
DEPEND, BREAK, SUGGEST
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
package org.ecorous.esnesnon.nonsense.loader.api.mod;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public final class ModExtensions {
|
||||||
|
|
||||||
|
public static ModExtensions of(Map<String, Object> entries){
|
||||||
|
return new ModExtensions(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Map<String, Object> extensions;
|
||||||
|
|
||||||
|
private ModExtensions(Map<String, Object> entries){
|
||||||
|
extensions = entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public <T> T get(String key){
|
||||||
|
return (T) extensions.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
|
@ -6,5 +6,15 @@ public interface ModProperties {
|
||||||
|
|
||||||
String name();
|
String name();
|
||||||
|
|
||||||
|
SemVer version();
|
||||||
|
|
||||||
|
License license();
|
||||||
|
|
||||||
|
ModCredits credits();
|
||||||
|
|
||||||
|
ModDependencies dependencies();
|
||||||
|
|
||||||
|
ModExtensions extensions();
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,92 @@
|
||||||
|
package org.ecorous.esnesnon.nonsense.loader.api.mod;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.function.IntSupplier;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import lombok.NonNull;
|
||||||
|
import org.ecorous.esnesnon.nonsense.loader.impl.SemVerParseException;
|
||||||
|
|
||||||
|
public record SemVer(int major, int minor, int patch, String prerelease, String build) implements Comparable<SemVer> {
|
||||||
|
// Adapted from https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
|
||||||
|
private static final Pattern SEMVER_PATTERN = Pattern.compile("^(?<major>0|[1-9]\\d*)\\." +
|
||||||
|
"(?<minor>0|[1-9]\\d*)\\." +
|
||||||
|
"(?<patch>0|[1-9]\\d*)" +
|
||||||
|
"(?:-(?<prerelease>(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)" +
|
||||||
|
"(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?" +
|
||||||
|
"(?:\\+(?<buildmetadata>[0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$");
|
||||||
|
|
||||||
|
public static SemVer parse(String version) throws SemVerParseException {
|
||||||
|
Matcher matcher = SEMVER_PATTERN.matcher(version);
|
||||||
|
if (!matcher.find()) {
|
||||||
|
throw new SemVerParseException(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
int major = Integer.parseInt(matcher.group("<major>"));
|
||||||
|
int minor = Integer.parseInt(matcher.group("<minor>"));
|
||||||
|
int patch = Integer.parseInt(matcher.group("<patch>"));
|
||||||
|
String prerelease = matcher.group("<prerelease>");
|
||||||
|
String buildmetadata = matcher.group("<buildmetadata>");
|
||||||
|
return new SemVer(major, minor, patch, prerelease, buildmetadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
StringBuilder b = new StringBuilder();
|
||||||
|
b.append(major).append(minor).append(patch);
|
||||||
|
if (prerelease != null){
|
||||||
|
b.append("-").append(prerelease);
|
||||||
|
}
|
||||||
|
if (build != null){
|
||||||
|
b.append("+").append(build);
|
||||||
|
}
|
||||||
|
return b.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object obj) {
|
||||||
|
if (obj instanceof SemVer s) {
|
||||||
|
return compareTo(s) == 0;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(major, minor, patch, prerelease);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int compareTo(@NonNull SemVer o) {
|
||||||
|
int i;
|
||||||
|
List<IntSupplier> suppliers = List.of(
|
||||||
|
() -> Integer.compare(major, o.major),
|
||||||
|
() -> Integer.compare(minor, o.minor),
|
||||||
|
() -> Integer.compare(patch, o.patch),
|
||||||
|
() -> prerelease != null ? o.prerelease != null ? 0 : -1 : o.prerelease != null ? 1 : 0
|
||||||
|
);
|
||||||
|
for (IntSupplier comparison : suppliers) {
|
||||||
|
if ((i = comparison.getAsInt()) != 0) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] self = prerelease.split("\\.");
|
||||||
|
String[] other = o.prerelease.split("\\.");
|
||||||
|
|
||||||
|
for (int index = 0;index<Math.min(self.length, other.length);index++){
|
||||||
|
boolean selfNumeric = self[index].matches("\\d+");
|
||||||
|
boolean otherNumeric = other[index].matches("\\d+");
|
||||||
|
if (selfNumeric != otherNumeric){
|
||||||
|
return selfNumeric ? -1 : 1;
|
||||||
|
} else if (!selfNumeric){
|
||||||
|
if ((i = self[index].compareTo(other[index])) != 0){
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Integer.compare(self.length, other.length);
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,6 +9,7 @@ import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
import org.ecorous.esnesnon.nonsense.loader.api.Loader;
|
import org.ecorous.esnesnon.nonsense.loader.api.Loader;
|
||||||
import org.ecorous.esnesnon.nonsense.loader.api.env.Env;
|
import org.ecorous.esnesnon.nonsense.loader.api.env.Env;
|
||||||
import org.ecorous.esnesnon.nonsense.loader.impl.launch.MixinClassloader;
|
import org.ecorous.esnesnon.nonsense.loader.impl.launch.MixinClassloader;
|
||||||
|
@ -41,6 +42,9 @@ public class LoaderImpl implements Loader {
|
||||||
@Getter
|
@Getter
|
||||||
private final MixinClassloader classloader;
|
private final MixinClassloader classloader;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
private LoaderImpl(String[] args, Env env) {
|
private LoaderImpl(String[] args, Env env) {
|
||||||
instance = this;
|
instance = this;
|
||||||
this.classloader = (MixinClassloader) this.getClass().getClassLoader();
|
this.classloader = (MixinClassloader) this.getClass().getClassLoader();
|
||||||
|
@ -65,6 +69,7 @@ public class LoaderImpl implements Loader {
|
||||||
plugins.forEach(NonsensePlugin::run);
|
plugins.forEach(NonsensePlugin::run);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
public static void run(String[] args, Env env) {
|
public static void run(String[] args, Env env) {
|
||||||
if (instance != null) {
|
if (instance != null) {
|
||||||
throw new IllegalStateException("Loader was started multiple times!");
|
throw new IllegalStateException("Loader was started multiple times!");
|
||||||
|
@ -79,9 +84,7 @@ public class LoaderImpl implements Loader {
|
||||||
try (InputStream inputStream = url.openStream()) {
|
try (InputStream inputStream = url.openStream()) {
|
||||||
new BufferedReader(new InputStreamReader(inputStream)).lines().forEach(classes::add);
|
new BufferedReader(new InputStreamReader(inputStream)).lines().forEach(classes::add);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
e.printStackTrace();
|
LOGGER.error("Failed to load plugin: ", e);
|
||||||
// TODO error handling
|
|
||||||
throw new UncheckedIOException(e);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
LOGGER.info("Found plugins: \n{}", String.join("\t\n", classes));
|
LOGGER.info("Found plugins: \n{}", String.join("\t\n", classes));
|
||||||
|
@ -90,10 +93,10 @@ public class LoaderImpl implements Loader {
|
||||||
try {
|
try {
|
||||||
return classloader.findClass(className);
|
return classloader.findClass(className);
|
||||||
} catch (ClassNotFoundException e) {
|
} catch (ClassNotFoundException e) {
|
||||||
// TODO error handling
|
LOGGER.error("Failed to load plugin: ", e);
|
||||||
throw new RuntimeException(e);
|
return null;
|
||||||
}
|
}
|
||||||
}).filter(NonsensePlugin.class::isAssignableFrom).toList()) {
|
}).filter(Objects::nonNull).filter(NonsensePlugin.class::isAssignableFrom).toList()) {
|
||||||
try {
|
try {
|
||||||
MethodHandle ctor = MethodHandles.publicLookup().findConstructor(c, MethodType.methodType(void.class));
|
MethodHandle ctor = MethodHandles.publicLookup().findConstructor(c, MethodType.methodType(void.class));
|
||||||
NonsensePlugin plugin = (NonsensePlugin) ctor.invoke();
|
NonsensePlugin plugin = (NonsensePlugin) ctor.invoke();
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
package org.ecorous.esnesnon.nonsense.loader.impl;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
public class SemVerParseException extends IOException {
|
||||||
|
public SemVerParseException(String message) {
|
||||||
|
super("Failed to parse SemVer: "+message);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +0,0 @@
|
||||||
package org.ecorous.esnesnon.nonsense.loader.impl.mod;
|
|
||||||
|
|
||||||
import org.ecorous.esnesnon.nonsense.loader.api.mod.ModProperties;
|
|
||||||
|
|
||||||
public record BuiltinModProperties(String id, String name) implements ModProperties {
|
|
||||||
}
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
package org.ecorous.esnesnon.nonsense.loader.impl.mod;
|
||||||
|
|
||||||
|
import org.ecorous.esnesnon.nonsense.loader.api.mod.*;
|
||||||
|
|
||||||
|
public record ModPropertiesImpl(String id, String name, SemVer version, License license,
|
||||||
|
ModCredits credits, ModDependencies dependencies,
|
||||||
|
ModExtensions extensions) implements ModProperties {
|
||||||
|
}
|
|
@ -1,6 +1,5 @@
|
||||||
package org.ecorous.esnesnon.nonsense.loader.impl.plugin.game;
|
package org.ecorous.esnesnon.nonsense.loader.impl.plugin.game;
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
|
||||||
import com.google.gson.JsonObject;
|
import com.google.gson.JsonObject;
|
||||||
import org.ecorous.esnesnon.nonsense.loader.impl.Discovery;
|
import org.ecorous.esnesnon.nonsense.loader.impl.Discovery;
|
||||||
import org.ecorous.esnesnon.nonsense.loader.impl.LoaderImpl;
|
import org.ecorous.esnesnon.nonsense.loader.impl.LoaderImpl;
|
||||||
|
@ -110,7 +109,7 @@ public class Minecraft implements NonsensePlugin {
|
||||||
if (Files.exists(fs.getPath(n))){
|
if (Files.exists(fs.getPath(n))){
|
||||||
LOGGER.info("Found game: {}", jar);
|
LOGGER.info("Found game: {}", jar);
|
||||||
foundMainClass = n.substring(0, n.length()-6).replace("/", ".");
|
foundMainClass = n.substring(0, n.length()-6).replace("/", ".");
|
||||||
version = new Gson().fromJson(Files.readString(fs.getPath("version.json")), JsonObject.class).get("id").getAsString();
|
version = LoaderImpl.getInstance().getGson().fromJson(Files.readString(fs.getPath("version.json")), JsonObject.class).get("id").getAsString();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
7997
src/main/resources/assets/nonsense-loader/licenses.json
Normal file
7997
src/main/resources/assets/nonsense-loader/licenses.json
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue