refactor: 각 어댑터터별 리펙토링 진행
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import groovy.json.JsonOutput
|
||||
import groovy.json.JsonSlurper
|
||||
import groovy.xml.XmlSlurper
|
||||
import dev.caskeleton.buildlogic.JUnitEvidence
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.security.MessageDigest
|
||||
import java.time.Instant
|
||||
@@ -72,33 +72,20 @@ Closure<String> runJpaEvidenceCommand = { List<String> command ->
|
||||
|
||||
Closure<Map<String, Object>> readJpaJUnitResult = { Test testTask ->
|
||||
File resultDirectory = testTask.reports.junitXml.outputLocation.get().asFile
|
||||
List<File> resultFiles = resultDirectory.isDirectory()
|
||||
? (resultDirectory.listFiles() ?: [] as File[])
|
||||
.findAll { File result -> result.name.startsWith('TEST-') && result.name.endsWith('.xml') }
|
||||
.toSorted { File left, File right -> left.name <=> right.name }
|
||||
: []
|
||||
if (resultFiles.isEmpty()) {
|
||||
throw new GradleException(
|
||||
"${testTask.path}: JUnit XML evidence is missing from ${resultDirectory}")
|
||||
}
|
||||
|
||||
int executed = 0
|
||||
int skipped = 0
|
||||
int failures = 0
|
||||
int errors = 0
|
||||
Set<String> selectors = new TreeSet<>()
|
||||
resultFiles.each { File resultFile ->
|
||||
def suite = new XmlSlurper(false, false).parse(resultFile)
|
||||
executed += (suite.@tests.text() ?: '0') as int
|
||||
skipped += (suite.@skipped.text() ?: '0') as int
|
||||
failures += (suite.@failures.text() ?: '0') as int
|
||||
errors += (suite.@errors.text() ?: '0') as int
|
||||
suite.testcase.each { Object rawCase ->
|
||||
String className = rawCase.@classname.text()
|
||||
String methodName = rawCase.@name.text().replaceFirst(/\([^)]*\)$/, '')
|
||||
selectors << "${className}#${methodName}".toString()
|
||||
}
|
||||
// Read through the shared reader rather than a second XmlSlurper. This one did not disable
|
||||
// DOCTYPE processing and counted a skipped case as an executed class; junit-evidence.gradle did
|
||||
// neither. Same format, two readers, two answers — the shared one keeps the stricter answer.
|
||||
def results
|
||||
try {
|
||||
results = JUnitEvidence.read(testTask.path, resultDirectory)
|
||||
} catch (IllegalStateException unreadable) {
|
||||
throw new GradleException(unreadable.message, unreadable)
|
||||
}
|
||||
int executed = results.tests
|
||||
int skipped = results.skipped
|
||||
int failures = results.failures
|
||||
int errors = results.errors
|
||||
Set<String> selectors = new TreeSet<>(results.executedSelectors)
|
||||
|
||||
[
|
||||
tasks: [testTask.path],
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import groovy.xml.XmlSlurper
|
||||
|
||||
Closure<Map<String, Object>> readJUnitEvidence = { String evidenceName, File resultDirectory ->
|
||||
List<File> resultFiles = resultDirectory.isDirectory()
|
||||
? rootProject.fileTree(resultDirectory) {
|
||||
include 'TEST-*.xml'
|
||||
}.files.toList().sort { left, right -> left.path <=> right.path }
|
||||
: []
|
||||
if (resultFiles.isEmpty()) {
|
||||
throw new GradleException(
|
||||
"${evidenceName}: no JUnit XML result files in ${resultDirectory}")
|
||||
}
|
||||
|
||||
int totalTests = 0
|
||||
int totalSkipped = 0
|
||||
int totalFailures = 0
|
||||
int totalErrors = 0
|
||||
Set<String> executedClasses = new LinkedHashSet<>()
|
||||
resultFiles.each { File resultFile ->
|
||||
XmlSlurper parser = new XmlSlurper(false, false)
|
||||
parser.setFeature('http://apache.org/xml/features/disallow-doctype-decl', true)
|
||||
def suite
|
||||
try {
|
||||
suite = parser.parse(resultFile)
|
||||
} catch (Exception exception) {
|
||||
throw new GradleException(
|
||||
"${evidenceName}: unreadable JUnit XML ${resultFile.name}", exception)
|
||||
}
|
||||
if (suite.name() != 'testsuite') {
|
||||
throw new GradleException(
|
||||
"${evidenceName}: ${resultFile.name} root must be testsuite")
|
||||
}
|
||||
Map<String, Integer> counts = [:]
|
||||
['tests', 'skipped', 'failures', 'errors'].each { String attribute ->
|
||||
String rawValue = suite.attributes()[attribute]?.toString()
|
||||
if (!(rawValue ==~ /\d+/)) {
|
||||
throw new GradleException(
|
||||
"${evidenceName}: ${resultFile.name} has invalid ${attribute}='${rawValue}'")
|
||||
}
|
||||
counts[attribute] = rawValue.toInteger()
|
||||
}
|
||||
totalTests += counts.tests
|
||||
totalSkipped += counts.skipped
|
||||
totalFailures += counts.failures
|
||||
totalErrors += counts.errors
|
||||
suite.testcase.each { testCase ->
|
||||
String className = testCase.attributes().classname?.toString()
|
||||
if (className != null && !className.isBlank() && testCase.skipped.isEmpty()) {
|
||||
executedClasses.add(className)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[
|
||||
tests : totalTests,
|
||||
skipped : totalSkipped,
|
||||
failures : totalFailures,
|
||||
errors : totalErrors,
|
||||
executedClasses: executedClasses
|
||||
]
|
||||
}
|
||||
|
||||
Closure<Map<String, Object>> verifyNoSkipJUnitXml = {
|
||||
String evidenceName, File resultDirectory ->
|
||||
Map<String, Object> evidence = readJUnitEvidence(evidenceName, resultDirectory)
|
||||
|
||||
if (evidence.tests <= 0) {
|
||||
throw new GradleException(
|
||||
"${evidenceName}: requires a positive executed test count")
|
||||
}
|
||||
if (evidence.skipped > 0) {
|
||||
throw new GradleException(
|
||||
"${evidenceName}: forbids skipped tests: ${evidence.skipped}")
|
||||
}
|
||||
if (evidence.failures > 0 || evidence.errors > 0) {
|
||||
throw new GradleException(
|
||||
"${evidenceName}: failures=${evidence.failures}, errors=${evidence.errors}")
|
||||
}
|
||||
logger.lifecycle("${evidenceName}: ${evidence.tests} tests, ${evidence.skipped} skipped")
|
||||
evidence
|
||||
}
|
||||
|
||||
Closure<Map<String, Object>> verifyRequiredJUnitClasses = {
|
||||
String evidenceName, File resultDirectory, List<String> requiredClasses ->
|
||||
Map<String, Object> evidence = verifyNoSkipJUnitXml(evidenceName, resultDirectory)
|
||||
Set<String> executedClasses = evidence.executedClasses as Set<String>
|
||||
List<String> missingClasses = requiredClasses.findAll { String requiredClass ->
|
||||
!executedClasses.any { String executedClass ->
|
||||
executedClass == requiredClass || executedClass.startsWith(requiredClass + '$')
|
||||
}
|
||||
}
|
||||
if (!missingClasses.isEmpty()) {
|
||||
throw new GradleException(
|
||||
"${evidenceName}: no executed test cases for required classes: ${missingClasses}")
|
||||
}
|
||||
evidence
|
||||
}
|
||||
|
||||
rootProject.ext.readJUnitEvidence = readJUnitEvidence
|
||||
rootProject.ext.verifyNoSkipJUnitXml = verifyNoSkipJUnitXml
|
||||
rootProject.ext.verifyRequiredJUnitClasses = verifyRequiredJUnitClasses
|
||||
@@ -46,6 +46,11 @@ List<File> notificationApiSourceRoots = [
|
||||
File notificationApiSnapshotFile =
|
||||
rootProject.file('../docs/notification/api-surface-snapshot.txt')
|
||||
boolean notificationApiUpdateApproved = project.hasProperty('approveNotificationApiChange')
|
||||
// Growing the surface is a second decision. Approving additions one at a time is how a leaf grows
|
||||
// past the size that would have justified splitting it, with every step reviewed and the total
|
||||
// never discussed. A change that removes more than it adds needs only the approval.
|
||||
boolean notificationApiCeilingRaiseApproved =
|
||||
project.hasProperty('raiseNotificationApiCeiling')
|
||||
|
||||
tasks.register('verifyNotificationApiSurface') {
|
||||
group = 'verification'
|
||||
@@ -105,6 +110,21 @@ tasks.register('updateNotificationApiSurface') {
|
||||
'updateNotificationApiSurface requires -PapproveNotificationApiChange')
|
||||
}
|
||||
String canonical = renderNotificationApiSurface(notificationApiSourceRoots)
|
||||
if (notificationApiSnapshotFile.isFile() && !notificationApiCeilingRaiseApproved) {
|
||||
int committedCount = notificationApiSnapshotFile.readLines('UTF-8')
|
||||
.count { !it.startsWith('#') && !it.trim().isEmpty() }
|
||||
int renderedCount = canonical.readLines()
|
||||
.count { !it.startsWith('#') && !it.trim().isEmpty() }
|
||||
if (renderedCount > committedCount) {
|
||||
throw new GradleException(
|
||||
"updateNotificationApiSurface: the public surface would grow from " +
|
||||
"${committedCount} to ${renderedCount} types.\n" +
|
||||
'Either land the addition together with a removal that pays for it, ' +
|
||||
'or raise the ceiling deliberately:\n' +
|
||||
' ./gradlew updateNotificationApiSurface ' +
|
||||
'-PapproveNotificationApiChange -PraiseNotificationApiCeiling')
|
||||
}
|
||||
}
|
||||
if (!notificationApiSnapshotFile.parentFile.isDirectory()
|
||||
&& !notificationApiSnapshotFile.parentFile.mkdirs()) {
|
||||
throw new GradleException(
|
||||
|
||||
@@ -30,6 +30,18 @@ tasks.register('verifyNotificationConfiguration') {
|
||||
// is delimited by its own comment marker rather than by indentation counting, so a reformat
|
||||
// does not silently empty this set.
|
||||
String yaml = applicationYaml.getText('UTF-8')
|
||||
|
||||
// The tree's own comment tells a template user where the reference is. It named a file that
|
||||
// does not exist, which is the same failure as an out-of-date reference and harder to
|
||||
// notice: the reader concludes the documentation is missing rather than that the pointer
|
||||
// is. Checked here because this task already owns the agreement between the two.
|
||||
if (!yaml.contains('docs/notification/' + referenceDocument.name)) {
|
||||
throw new GradleException(
|
||||
'verifyNotificationConfiguration: application.yml does not point at ' +
|
||||
"docs/notification/${referenceDocument.name}, so the tree tells a " +
|
||||
'reader to consult a document this task does not verify.')
|
||||
}
|
||||
|
||||
int treeStart = yaml.indexOf(' notification:\n platform:')
|
||||
if (treeStart < 0) {
|
||||
throw new GradleException(
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import groovy.json.JsonSlurper
|
||||
|
||||
def verifyRuntimeModuleMembership = tasks.register('verifyRuntimeModuleMembership') {
|
||||
group = 'verification'
|
||||
description = 'Verifies registry runtime membership against both shipped composition roots.'
|
||||
|
||||
File registryFile = rootProject.file('config/architecture/modules.json')
|
||||
inputs.file(registryFile)
|
||||
|
||||
doLast {
|
||||
if (!registryFile.isFile()) {
|
||||
throw new GradleException("Missing module registry: ${registryFile}")
|
||||
}
|
||||
def registry = new JsonSlurper().parse(registryFile)
|
||||
if (!(registry instanceof Map) || !(registry.modules instanceof List)) {
|
||||
throw new GradleException('Module registry needs a modules list.')
|
||||
}
|
||||
if (!(registry.runtime_compositions instanceof List) || registry.runtime_compositions.isEmpty()) {
|
||||
throw new GradleException('Module registry needs a non-empty runtime_compositions list.')
|
||||
}
|
||||
|
||||
List<String> compositionIds = registry.runtime_compositions.withIndex().collect {
|
||||
Object value, int index ->
|
||||
if (!(value instanceof String) || (value as String).isBlank()) {
|
||||
throw new GradleException(
|
||||
"runtime_compositions entry ${index} must be a nonblank string.")
|
||||
}
|
||||
value as String
|
||||
}
|
||||
if (compositionIds.toSet().size() != compositionIds.size()) {
|
||||
throw new GradleException('runtime_compositions must not contain duplicates.')
|
||||
}
|
||||
|
||||
Map<String, Object> modulesById = [:]
|
||||
Map<String, String> moduleIdByGradlePath = [:]
|
||||
registry.modules.eachWithIndex { Object rawModule, int index ->
|
||||
if (!(rawModule instanceof Map)) {
|
||||
throw new GradleException("Module registry entry ${index} must be an object.")
|
||||
}
|
||||
Map<String, Object> module = rawModule as Map<String, Object>
|
||||
String moduleId = module.id as String
|
||||
if (moduleId == null || moduleId.isBlank()) {
|
||||
throw new GradleException("Module registry entry ${index} needs a nonblank id.")
|
||||
}
|
||||
if (!(module.runtime_memberships instanceof List)) {
|
||||
throw new GradleException(
|
||||
"Module registry entry '${moduleId}' needs a runtime_memberships list.")
|
||||
}
|
||||
List<String> memberships = module.runtime_memberships.withIndex().collect {
|
||||
Object membership, int membershipIndex ->
|
||||
if (!(membership instanceof String) || (membership as String).isBlank()) {
|
||||
throw new GradleException(
|
||||
"Module registry entry '${moduleId}' has a blank/non-string " +
|
||||
"runtime membership at index ${membershipIndex}.")
|
||||
}
|
||||
membership as String
|
||||
}
|
||||
if (memberships.toSet().size() != memberships.size()) {
|
||||
throw new GradleException(
|
||||
"Module registry entry '${moduleId}' has duplicate runtime memberships.")
|
||||
}
|
||||
Set<String> unknownMemberships = memberships.toSet() - compositionIds.toSet()
|
||||
if (!unknownMemberships.isEmpty()) {
|
||||
throw new GradleException(
|
||||
"Module registry entry '${moduleId}' has unknown runtime membership(s) " +
|
||||
"${unknownMemberships.toSorted()}.")
|
||||
}
|
||||
if (modulesById.put(moduleId, module) != null) {
|
||||
throw new GradleException("Module registry contains duplicate id '${moduleId}'.")
|
||||
}
|
||||
String gradlePath = module.gradle_path as String
|
||||
if (gradlePath == null || gradlePath.isBlank()) {
|
||||
throw new GradleException(
|
||||
"Module registry entry '${moduleId}' needs a nonblank gradle_path.")
|
||||
}
|
||||
if (moduleIdByGradlePath.put(gradlePath, moduleId) != null) {
|
||||
throw new GradleException(
|
||||
"Module registry contains duplicate Gradle path '${gradlePath}'.")
|
||||
}
|
||||
}
|
||||
|
||||
compositionIds.each { String compositionId ->
|
||||
Map<String, Object> composition = modulesById[compositionId] as Map<String, Object>
|
||||
if (composition == null) {
|
||||
throw new GradleException(
|
||||
"Runtime composition '${compositionId}' is not a registered module id.")
|
||||
}
|
||||
List<String> ownMemberships = composition.runtime_memberships as List<String>
|
||||
if (!ownMemberships.contains(compositionId)) {
|
||||
throw new GradleException(
|
||||
"Runtime composition '${compositionId}' must include itself in runtime_memberships.")
|
||||
}
|
||||
String compositionGradlePath = composition.gradle_path as String
|
||||
Project compositionProject = rootProject.findProject(compositionGradlePath)
|
||||
if (compositionProject == null) {
|
||||
throw new GradleException(
|
||||
"Runtime composition '${compositionId}' references missing Gradle project " +
|
||||
"'${compositionGradlePath}'.")
|
||||
}
|
||||
|
||||
Set<String> expected = registry.modules.findAll { Object rawModule ->
|
||||
Map<String, Object> module = rawModule as Map<String, Object>
|
||||
(module.runtime_memberships as List).contains(compositionId) &&
|
||||
module.id != compositionId
|
||||
}.collect { Object rawModule ->
|
||||
(rawModule as Map<String, Object>).id as String
|
||||
}.toSet()
|
||||
|
||||
// The resolved runtime closure, not the declared dependency list.
|
||||
//
|
||||
// A declared-dependency comparison cannot see a leaf that arrives transitively — a
|
||||
// starter pulling in six internal modules puts all six in the bootJar while the registry
|
||||
// records them as belonging to no runtime at all. The membership list then stays clean
|
||||
// precisely because it is not looking at what ships. Resolving runtimeClasspath asks the
|
||||
// question the jar answers.
|
||||
def runtimeClasspath = compositionProject.configurations.findByName('runtimeClasspath')
|
||||
if (runtimeClasspath == null) {
|
||||
throw new GradleException(
|
||||
"Runtime composition '${compositionId}' has no runtimeClasspath configuration.")
|
||||
}
|
||||
Set<String> actual = runtimeClasspath.incoming.resolutionResult.allComponents
|
||||
.findAll { it.id instanceof org.gradle.api.artifacts.component.ProjectComponentIdentifier }
|
||||
.collect { (it.id as org.gradle.api.artifacts.component.ProjectComponentIdentifier).projectPath }
|
||||
.findAll { String projectPath -> projectPath != compositionProject.path }
|
||||
.collect { String projectPath ->
|
||||
String dependencyId = moduleIdByGradlePath[projectPath]
|
||||
if (dependencyId == null) {
|
||||
throw new GradleException(
|
||||
"Runtime composition '${compositionId}' resolves unregistered " +
|
||||
"Gradle project '${projectPath}' onto its runtime classpath.")
|
||||
}
|
||||
dependencyId
|
||||
}
|
||||
.toSet()
|
||||
|
||||
Set<String> unregistered = actual - expected
|
||||
Set<String> missing = expected - actual
|
||||
if (!unregistered.isEmpty() || !missing.isEmpty()) {
|
||||
List<String> violations = []
|
||||
if (!unregistered.isEmpty()) {
|
||||
violations << "unregistered runtime dependencies ${unregistered.toSorted()}"
|
||||
}
|
||||
if (!missing.isEmpty()) {
|
||||
violations << "missing registered runtime dependencies ${missing.toSorted()}"
|
||||
}
|
||||
throw new GradleException(
|
||||
"Runtime composition '${compositionId}' membership drift: " +
|
||||
violations.join('; ') + '.')
|
||||
}
|
||||
}
|
||||
logger.lifecycle(
|
||||
"verifyRuntimeModuleMembership: ${compositionIds.size()} runtime composition(s) " +
|
||||
'match the registry')
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.ext.verifyRuntimeModuleMembership = verifyRuntimeModuleMembership
|
||||
@@ -1,113 +0,0 @@
|
||||
// Owner-local convention for release/qualification lanes that must never pass without executing
|
||||
// every explicitly required JUnit class. Apply junit-evidence.gradle before this script.
|
||||
ext.registerStrictQualificationTest = { Map<String, ?> specification ->
|
||||
String taskName = specification.name as String
|
||||
def qualificationSourceSet = specification.sourceSet
|
||||
List<String> requiredClasses = (specification.requiredClasses ?: []) as List<String>
|
||||
|
||||
if (taskName == null || taskName.isBlank()) {
|
||||
throw new GradleException('A strict qualification task name is required.')
|
||||
}
|
||||
if (qualificationSourceSet == null) {
|
||||
throw new GradleException("${taskName} requires an owner source set.")
|
||||
}
|
||||
if (!sourceSets.findByName(qualificationSourceSet.name).is(qualificationSourceSet)) {
|
||||
throw new GradleException(
|
||||
"${taskName} source set '${qualificationSourceSet.name}' does not belong to owner project ${project.path}.")
|
||||
}
|
||||
if (requiredClasses.isEmpty() || requiredClasses.any { it == null || it.isBlank() }) {
|
||||
throw new GradleException("${taskName} must name at least one required test FQCN.")
|
||||
}
|
||||
if (requiredClasses.toSet().size() != requiredClasses.size()) {
|
||||
throw new GradleException("${taskName} contains duplicate required test FQCNs.")
|
||||
}
|
||||
|
||||
def junitXmlOutput = specification.junitXmlOutput ?:
|
||||
layout.buildDirectory.dir("test-results/${taskName}")
|
||||
def binaryResultsOutput = specification.binaryResultsOutput ?:
|
||||
layout.buildDirectory.dir("test-results/${taskName}/binary")
|
||||
|
||||
def requiredClassesCheck = tasks.register("${taskName}RequiredClasses") {
|
||||
group = 'verification'
|
||||
description = "Fails when ${taskName} did not compile every required test class."
|
||||
dependsOn qualificationSourceSet.classesTaskName
|
||||
inputs.files(qualificationSourceSet.output.classesDirs)
|
||||
outputs.upToDateWhen { false }
|
||||
doLast {
|
||||
Set<File> classDirectories = qualificationSourceSet.output.classesDirs.files
|
||||
boolean hasAnyClass = classDirectories.any { File directory ->
|
||||
directory.isDirectory() &&
|
||||
!fileTree(directory).matching { include '**/*.class' }.isEmpty()
|
||||
}
|
||||
if (!hasAnyClass) {
|
||||
throw new GradleException(
|
||||
"${taskName} source set produced no test class files.")
|
||||
}
|
||||
|
||||
List<String> missingClasses = requiredClasses.findAll { String requiredClass ->
|
||||
String relativeClassFile = requiredClass.replace('.', '/') + '.class'
|
||||
!classDirectories.any { File directory ->
|
||||
new File(directory, relativeClassFile).isFile()
|
||||
}
|
||||
}
|
||||
if (!missingClasses.isEmpty()) {
|
||||
throw new GradleException(
|
||||
"${taskName} is missing required test class files: ${missingClasses}")
|
||||
}
|
||||
|
||||
File staleEvidence = junitXmlOutput.get().asFile
|
||||
if (staleEvidence.exists() && !project.delete(staleEvidence)) {
|
||||
throw new GradleException(
|
||||
"${taskName} could not delete stale JUnit XML: ${staleEvidence}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def qualificationTest = tasks.register(taskName, Test) {
|
||||
group = 'verification'
|
||||
description = specification.description ?:
|
||||
"Runs exact no-skip qualification evidence for ${project.path}."
|
||||
dependsOn requiredClassesCheck
|
||||
testClassesDirs = qualificationSourceSet.output.classesDirs
|
||||
classpath = qualificationSourceSet.runtimeClasspath
|
||||
useJUnitPlatform()
|
||||
filter {
|
||||
requiredClasses.each { String requiredClass ->
|
||||
includeTestsMatching(requiredClass)
|
||||
}
|
||||
failOnNoMatchingTests = true
|
||||
}
|
||||
failOnNoDiscoveredTests = true
|
||||
reports.junitXml.required = true
|
||||
reports.junitXml.outputLocation = junitXmlOutput
|
||||
reports.html.required = false
|
||||
binaryResultsDirectory = binaryResultsOutput
|
||||
outputs.upToDateWhen { false }
|
||||
jvmArgs '-Duser.timezone=UTC'
|
||||
afterSuite { descriptor, result ->
|
||||
if (descriptor.parent == null && result.skippedTestCount > 0) {
|
||||
throw new GradleException(
|
||||
"${taskName} forbids skipped tests: ${result.skippedTestCount}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def evidenceCheck = tasks.register("${taskName}Evidence") {
|
||||
group = 'verification'
|
||||
description = "Fails unless ${taskName} executed every required test class without skips."
|
||||
mustRunAfter qualificationTest
|
||||
outputs.upToDateWhen { false }
|
||||
doLast {
|
||||
if (!rootProject.ext.has('verifyRequiredJUnitClasses')) {
|
||||
throw new GradleException(
|
||||
"${taskName} requires gradle/junit-evidence.gradle.")
|
||||
}
|
||||
rootProject.ext.verifyRequiredJUnitClasses(
|
||||
taskName, junitXmlOutput.get().asFile, requiredClasses)
|
||||
}
|
||||
}
|
||||
qualificationTest.configure {
|
||||
finalizedBy evidenceCheck
|
||||
}
|
||||
qualificationTest
|
||||
}
|
||||
Reference in New Issue
Block a user