Skip to content

http/response: memoize with kotlin.Result, fix ResponseBody KDoc, dedupe HttpException constructors #175

Description

@OmarAlJarrah

While reading through the http/response package I noticed a few spots carrying more machinery
(or stale documentation) than they need. These are three small, self-contained cleanups in one
subsystem. None of them changes runtime behavior — they reuse stdlib primitives, fix a doc that
describes methods that don't exist, and remove constructor boilerplate that's copy-pasted 18 times.

sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/ParsedResponse.kt:55-137 — Replace the bespoke Outcome carrier with a memoized kotlin.Result

The class hand-rolls a private Outcome sealed class (Success/Failure) purely to memoize the
handler's result-or-throw. That's exactly what kotlin.Result is, and the module already uses
runCatching / getOrThrow elsewhere (e.g. http/pipeline/steps/DefaultRetryStep.kt). Storing
the memo as a nullable Result<T>? keeps the deliberate "null success vs. not-yet-parsed"
distinction, and runCatching catches Throwable (not just Exception), so the existing
catch (t: Throwable) semantics — including memoizing an Error so a later call can't re-read the
single-use body — are preserved exactly.

Old code:

    // Holds the memoized outcome once the handler has run. A non-null holder means "parsed"
    // (success or failure); the wrapped value distinguishes the two. A holder (rather than a
    // bare value) lets a legitimately-null success memoize without being mistaken for "unparsed".
    @Volatile
    private var outcome: Outcome<T>? = null

    /** The request that produced [raw]. Does not parse. */
    public val request: Request get() = raw.request

    /** The negotiated wire protocol. Does not parse. */
    public val protocol: Protocol get() = raw.protocol

    /** The HTTP status. Does not parse. */
    public val status: Status get() = raw.status

    /** The status-line reason phrase, or `null` if absent. Does not parse. */
    public val message: String? get() = raw.message

    /** The response headers. Does not parse. */
    public val headers: Headers get() = raw.headers

    /**
     * Returns the typed value, parsing it on the first call and memoizing the outcome.
     *
     * The handler runs at most once: the first call invokes [ResponseHandler.handle] (which
     * typically consumes and closes the body); subsequent calls return the same value, or
     * re-throw the same failure, without re-running the handler.
     *
     * Any failure the handler throws is memoized and re-thrown verbatim on every later call — not
     * just [IOException]. Handlers commonly throw **unchecked** exceptions (the Jackson `jsonHandler`
     * throws `SerdeException`), so callers should not assume the only escape is [IOException].
     *
     * @return The parsed value (which may be `null` if the handler is typed `ResponseHandler<T?>`
     *   and produces `null`).
     * @throws IOException If the handler failed with an [IOException] — cached and re-thrown. The
     *   `@Throws` declaration covers only the checked surface for Java callers; the handler may also
     *   propagate **unchecked** exceptions (e.g. `SerdeException` from the Jackson `jsonHandler`),
     *   which are memoized and re-thrown the same way.
     */
    @Throws(IOException::class)
    public fun value(): T {
        outcome?.let { return it.get() }
        return lock.withLock {
            outcome?.let { return it.get() }
            // Memoize the handler's outcome — success or failure — so neither re-runs the handler
            // nor re-reads the (now consumed) body on a subsequent call.
            val resolved: Outcome<T> =
                try {
                    Outcome.Success(handler.handle(raw))
                } catch (t: Throwable) {
                    // Catch Throwable, not Exception, on purpose: once the handler has touched the
                    // single-use body, re-running it would read an already-consumed stream. Even an
                    // Error (e.g. an OOM mid-parse) is memoized so a later call re-throws it rather
                    // than re-reading the body and masking the original failure.
                    Outcome.Failure(t)
                }
            outcome = resolved
            resolved.get()
        }
    }

    /**
     * Releases the raw response body. Idempotent (forwards to [Response.close], which is itself
     * idempotent). Safe to call whether or not [value] has run.
     *
     * @throws IOException If the underlying close fails.
     */
    @Throws(IOException::class)
    override fun close() {
        raw.close()
    }

    private sealed class Outcome<out T> {
        abstract fun get(): T

        class Success<out T>(private val value: T) : Outcome<T>() {
            override fun get(): T = value
        }

        class Failure(private val error: Throwable) : Outcome<Nothing>() {
            override fun get(): Nothing = throw error
        }
    }

New code:

    // Holds the memoized outcome once the handler has run. A non-null Result means "parsed"
    // (success or failure); the wrapped value distinguishes the two. A boxed Result (rather than a
    // bare value) lets a legitimately-null success memoize without being mistaken for "unparsed".
    @Volatile
    private var outcome: Result<T>? = null

    /** The request that produced [raw]. Does not parse. */
    public val request: Request get() = raw.request

    /** The negotiated wire protocol. Does not parse. */
    public val protocol: Protocol get() = raw.protocol

    /** The HTTP status. Does not parse. */
    public val status: Status get() = raw.status

    /** The status-line reason phrase, or `null` if absent. Does not parse. */
    public val message: String? get() = raw.message

    /** The response headers. Does not parse. */
    public val headers: Headers get() = raw.headers

    /**
     * Returns the typed value, parsing it on the first call and memoizing the outcome.
     *
     * The handler runs at most once: the first call invokes [ResponseHandler.handle] (which
     * typically consumes and closes the body); subsequent calls return the same value, or
     * re-throw the same failure, without re-running the handler.
     *
     * Any failure the handler throws is memoized and re-thrown verbatim on every later call — not
     * just [IOException]. Handlers commonly throw **unchecked** exceptions (the Jackson `jsonHandler`
     * throws `SerdeException`), so callers should not assume the only escape is [IOException].
     *
     * @return The parsed value (which may be `null` if the handler is typed `ResponseHandler<T?>`
     *   and produces `null`).
     * @throws IOException If the handler failed with an [IOException] — cached and re-thrown. The
     *   `@Throws` declaration covers only the checked surface for Java callers; the handler may also
     *   propagate **unchecked** exceptions (e.g. `SerdeException` from the Jackson `jsonHandler`),
     *   which are memoized and re-thrown the same way.
     */
    @Throws(IOException::class)
    public fun value(): T {
        outcome?.let { return it.getOrThrow() }
        return lock.withLock {
            outcome?.let { return it.getOrThrow() }
            // Memoize the outcome (success or failure) so a later call neither re-runs the handler
            // nor re-reads the now-consumed body. `runCatching` catches `Throwable`, not just
            // `Exception`: re-running a handler that already drained the single-use body would read
            // a consumed stream, so even an `Error` (e.g. OOM mid-parse) is memoized and re-thrown.
            runCatching { handler.handle(raw) }.also { outcome = it }.getOrThrow()
        }
    }

    /**
     * Releases the raw response body. Idempotent (forwards to [Response.close], which is itself
     * idempotent). Safe to call whether or not [value] has run.
     *
     * @throws IOException If the underlying close fails.
     */
    @Throws(IOException::class)
    override fun close() {
        raw.close()
    }

Usage — before → after:

val parsed: ParsedResponse<MyDto> = response.parsedWith(jsonHandler)
val dto: MyDto = parsed.value() // parses once, memoizes; later calls return the same value
val parsed: ParsedResponse<MyDto> = response.parsedWith(jsonHandler)
val dto: MyDto = parsed.value() // call sites unchanged — behavior-preserving

Why: kotlin.Result is the standard two-state (success/failure) memo carrier; reusing it deletes
the bespoke Outcome sealed class while runCatching/getOrThrow preserve the exact Throwable-
catching and memoize-before-throw semantics, and a nullable Result<T>? keeps the null-success vs.
unparsed distinction.

API / Build: None — Outcome was private and value()'s signature is unchanged. No apiDump.
Result / runCatching / getOrThrow are in the auto-imported kotlin package, so no imports are
added or removed.

sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/ResponseBody.kt:14-45 — Rewrite the class KDoc around the real source() contract and drop the dead InputStream import

The class KDoc describes a byteStream/bytes/string API that does not exist on this type — the
only read accessor is source(): BufferedSource. The doc is the sole reference to java.io.InputStream
in the file, so it's also dragging along an import that the code never uses.

Old code:

import org.dexpace.sdk.core.http.common.MediaType
import org.dexpace.sdk.core.io.BufferedSource
import java.io.Closeable
import java.io.IOException
import java.io.InputStream

/**
 * Represents the body of an HTTP response.
 *
 * A `ResponseBody` provides access to the raw bytes of an HTTP response through [byteStream],
 * with convenience methods [bytes] and [string] for common consumption patterns. The body
 * **must be closed** after use to release the underlying connection — prefer Kotlin's `use {}`
 * or Java's try-with-resources.
 *
 * This class uses only `java.io` APIs with no external dependencies, making it compatible
 * with JDK 8+ and safe to use from platform threads, virtual threads, Kotlin coroutines,
 * and reactive schedulers. The underlying [InputStream] performs blocking I/O; callers in
 * non-blocking contexts should dispatch to an appropriate scheduler (e.g., `Dispatchers.IO`,
 * `Schedulers.boundedElastic()`).
 *
 * ## Thread safety
 *
 * Instances are **not** thread-safe. The stream returned by [byteStream] should be read
 * from a single thread only. For concurrent access, wrap with
 * [LoggableResponseBody] which
 * buffers the content and provides thread-safe, repeatable reads.
 *
 * ## Single-use contract
 *
 * The base `ResponseBody` can only be read once — [byteStream] returns the same stream on
 * every call, and once consumed, the bytes are gone. Use [bytes] or [string] for a
 * one-shot read, or wrap with `LoggableResponseBody` for repeatable access.
 *
 * @see LoggableResponseBody for a buffered wrapper that
 *      supports repeatable reads and non-destructive logging.
 */

New code:

import org.dexpace.sdk.core.http.common.MediaType
import org.dexpace.sdk.core.io.BufferedSource
import java.io.Closeable
import java.io.IOException

/**
 * Represents the body of an HTTP response.
 *
 * A `ResponseBody` exposes the raw bytes of an HTTP response through a single [source] accessor
 * returning a [BufferedSource]. The body **must be closed** after use to release the underlying
 * connection — prefer Kotlin's `use {}` or Java's try-with-resources, and close it explicitly even
 * when the body is skipped without reading.
 *
 * This class depends only on the SDK's [BufferedSource] I/O seam with no external dependencies,
 * making it compatible with JDK 8+ and safe to use from platform threads, virtual threads, Kotlin
 * coroutines, and reactive schedulers. Reading from [source] performs blocking I/O; callers in
 * non-blocking contexts should dispatch to an appropriate scheduler (e.g., `Dispatchers.IO`,
 * `Schedulers.boundedElastic()`).
 *
 * ## Thread safety
 *
 * Instances are **not** thread-safe. The [BufferedSource] returned by [source] should be read
 * from a single thread only. For concurrent or repeatable access, wrap with
 * [LoggableResponseBody], which buffers the content and provides thread-safe, repeatable reads.
 *
 * ## Single-use contract
 *
 * The base `ResponseBody` can only be read once — [source] returns the same [BufferedSource] on
 * every call, and once that source is consumed, the bytes are gone. Wrap with
 * [LoggableResponseBody] for repeatable access.
 *
 * @see LoggableResponseBody for a buffered wrapper that
 *      supports repeatable reads and non-destructive logging.
 */

Usage — before → after:

val body: ResponseBody = ResponseBody.create(source)
body.use { it.source() }
val body: ResponseBody = ResponseBody.create(source)
body.use { it.source() } // call sites unchanged — behavior-preserving

Why: The doc points callers at three methods (byteStream/bytes/string) that the type never
declares; aligning it with the real source(): BufferedSource contract removes the misdirection.

Build: Once the KDoc no longer mentions InputStream, import java.io.InputStream is unused, and
ktlint's no-unused-imports rule (under allWarningsAsErrors) fails the build until it is removed —
so the import removal is required, not optional. No apiDump (no signature change).

sdk-core/.../http/response/exception/HttpException.kt + .../http/response/exception/HttpExceptions.kt:32-400 — Funnel the 18 subclass constructors through one protected base constructor

Every concrete subclass repeats the same six-line block that unpacks response.status /
response.headers / response.body into the base HttpException constructor. Hoisting that into a
single protected base constructor that takes the Response directly lets each subclass collapse to a
one-line delegation. The subclasses' public (response, message?, cause?, value?) surface is unchanged.

Base — HttpException.kt — old code (imports + constructor header):

import org.dexpace.sdk.core.http.common.Headers
import org.dexpace.sdk.core.http.response.ResponseBody
import org.dexpace.sdk.core.http.response.Status
import org.dexpace.sdk.core.io.Buffer
import org.dexpace.sdk.core.util.RetryUtils
import java.io.IOException
public abstract class HttpException
    @JvmOverloads
    constructor(
        public val status: Status,
        public val headers: Headers,
        public val body: ResponseBody?,
        message: String? = null,
        cause: Throwable? = null,
        public val value: Any? = null,
    ) : RuntimeException(message ?: defaultMessage(status), cause), Retryable {
        /**
         * Whether this exception represents a retryable condition. Derived from
         * [RetryUtils.isRetryable] over [status]'s code so it can never disagree with the
         * live retry policy.
         */
        override val isRetryable: Boolean = RetryUtils.isRetryable(status.code)

Base — HttpException.kt — new code (add the Response import + the protected secondary constructor):

import org.dexpace.sdk.core.http.common.Headers
import org.dexpace.sdk.core.http.response.Response
import org.dexpace.sdk.core.http.response.ResponseBody
import org.dexpace.sdk.core.http.response.Status
import org.dexpace.sdk.core.io.Buffer
import org.dexpace.sdk.core.util.RetryUtils
import java.io.IOException
public abstract class HttpException
    @JvmOverloads
    constructor(
        public val status: Status,
        public val headers: Headers,
        public val body: ResponseBody?,
        message: String? = null,
        cause: Throwable? = null,
        public val value: Any? = null,
    ) : RuntimeException(message ?: defaultMessage(status), cause), Retryable {
        /**
         * Convenience constructor that unpacks [status], [headers], and [body] from a [response].
         * The per-status subclasses expose a `(response, message?, cause?, value?)` surface and
         * delegate here. `protected` so it is reachable only from subclasses.
         */
        protected constructor(
            response: Response,
            message: String?,
            cause: Throwable?,
            value: Any?,
        ) : this(response.status, response.headers, response.body, message, cause, value)

        /**
         * Whether this exception represents a retryable condition. Derived from
         * [RetryUtils.isRetryable] over [status]'s code so it can never disagree with the
         * live retry policy.
         */
        override val isRetryable: Boolean = RetryUtils.isRetryable(status.code)

Subclasses — HttpExceptions.kt — old code (all 18; each KDoc is unchanged and retained):

public open class BadRequestException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(
            status = response.status,
            headers = response.headers,
            body = response.body,
            message = message,
            cause = cause,
            value = value,
        )

public open class UnauthorizedException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(
            status = response.status,
            headers = response.headers,
            body = response.body,
            message = message,
            cause = cause,
            value = value,
        )

public open class ForbiddenException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(
            status = response.status,
            headers = response.headers,
            body = response.body,
            message = message,
            cause = cause,
            value = value,
        )

public open class NotFoundException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(
            status = response.status,
            headers = response.headers,
            body = response.body,
            message = message,
            cause = cause,
            value = value,
        )

public open class MethodNotAllowedException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(
            status = response.status,
            headers = response.headers,
            body = response.body,
            message = message,
            cause = cause,
            value = value,
        )

public open class RequestTimeoutException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(
            status = response.status,
            headers = response.headers,
            body = response.body,
            message = message,
            cause = cause,
            value = value,
        )

public open class ConflictException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(
            status = response.status,
            headers = response.headers,
            body = response.body,
            message = message,
            cause = cause,
            value = value,
        )

public open class GoneException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(
            status = response.status,
            headers = response.headers,
            body = response.body,
            message = message,
            cause = cause,
            value = value,
        )

public open class PayloadTooLargeException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(
            status = response.status,
            headers = response.headers,
            body = response.body,
            message = message,
            cause = cause,
            value = value,
        )

public open class UnsupportedMediaTypeException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(
            status = response.status,
            headers = response.headers,
            body = response.body,
            message = message,
            cause = cause,
            value = value,
        )

public open class UnprocessableEntityException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(
            status = response.status,
            headers = response.headers,
            body = response.body,
            message = message,
            cause = cause,
            value = value,
        )

public open class TooManyRequestsException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(
            status = response.status,
            headers = response.headers,
            body = response.body,
            message = message,
            cause = cause,
            value = value,
        )

public open class InternalServerErrorException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(
            status = response.status,
            headers = response.headers,
            body = response.body,
            message = message,
            cause = cause,
            value = value,
        )

public open class BadGatewayException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(
            status = response.status,
            headers = response.headers,
            body = response.body,
            message = message,
            cause = cause,
            value = value,
        )

public open class ServiceUnavailableException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(
            status = response.status,
            headers = response.headers,
            body = response.body,
            message = message,
            cause = cause,
            value = value,
        )

public open class GatewayTimeoutException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(
            status = response.status,
            headers = response.headers,
            body = response.body,
            message = message,
            cause = cause,
            value = value,
        )

public open class ClientErrorException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(
            status = response.status,
            headers = response.headers,
            body = response.body,
            message = message,
            cause = cause,
            value = value,
        )

public open class ServerErrorException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(
            status = response.status,
            headers = response.headers,
            body = response.body,
            message = message,
            cause = cause,
            value = value,
        )

Subclasses — HttpExceptions.kt — new code (all 18; each KDoc is unchanged and retained):

public open class BadRequestException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(response, message, cause, value)

public open class UnauthorizedException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(response, message, cause, value)

public open class ForbiddenException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(response, message, cause, value)

public open class NotFoundException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(response, message, cause, value)

public open class MethodNotAllowedException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(response, message, cause, value)

public open class RequestTimeoutException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(response, message, cause, value)

public open class ConflictException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(response, message, cause, value)

public open class GoneException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(response, message, cause, value)

public open class PayloadTooLargeException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(response, message, cause, value)

public open class UnsupportedMediaTypeException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(response, message, cause, value)

public open class UnprocessableEntityException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(response, message, cause, value)

public open class TooManyRequestsException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(response, message, cause, value)

public open class InternalServerErrorException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(response, message, cause, value)

public open class BadGatewayException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(response, message, cause, value)

public open class ServiceUnavailableException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(response, message, cause, value)

public open class GatewayTimeoutException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(response, message, cause, value)

public open class ClientErrorException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(response, message, cause, value)

public open class ServerErrorException
    @JvmOverloads
    constructor(
        response: Response,
        message: String? = null,
        cause: Throwable? = null,
        value: Any? = null,
    ) : HttpException(response, message, cause, value)

Usage — before → after:

throw BadRequestException(response)
throw NotFoundException(response, message = "user $id not found")
throw BadRequestException(response)
throw NotFoundException(response, message = "user $id not found") // call sites unchanged — behavior-preserving

Why: Each subclass repeats the same six-argument status = response.status, ... unpacking; hoisting
it into one protected base constructor removes ~7 boilerplate lines per subclass (×18) while leaving the
public constructor surface of every subclass byte-for-byte identical.

API: Adds one protected fun <init> taking (Response, String?, Throwable?, Any?) to
HttpException in sdk-core.api; the 18 subclasses' public constructor entries are unchanged. Run
./gradlew apiDump and commit the regenerated snapshot alongside the change.

Metadata

Metadata

Assignees

No one assigned

    Labels

    sdk-coresdk-core toolkittech-debtsimplification / cleanup

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions