<|start_filename|>src/test/kotlin/by/dev/madhead/doktor/confluence/DeletePage.kt<|end_filename|>
package by.dev.madhead.doktor.confluence

import org.testng.Assert
import org.testng.annotations.Test
import java.util.concurrent.TimeUnit

class DeletePage : BaseConfluenceTest() {
	@Test
	fun `anonymous, success`() {
		deletePage(anonymousConfluence, "57003")
			.test()
			.run {
				awaitTerminalEvent(10, TimeUnit.SECONDS)

				assertComplete()
			}
	}

	@Test
	fun `identified, success`() {
		deletePage(identifiedConfluence, "57004")
			.test()
			.run {
				awaitTerminalEvent(10, TimeUnit.SECONDS)

				assertComplete()
			}
	}

	@Test
	fun `anonymous, no content`() {
		deletePage(anonymousConfluence, "57005")
			.test()
			.run {
				awaitTerminalEvent(10, TimeUnit.SECONDS)

				assertError {
					it as ConfluenceException
					Assert.assertEquals(it.message, "Cannot delete content with id 'ContentId{id=57005}': not found")

					true
				}
			}
	}

	@Test
	fun `anonymous, 403`() {
		deletePage(anonymousConfluence, "57006")
			.test()
			.run {
				awaitTerminalEvent(10, TimeUnit.SECONDS)

				assertError {
					it as ConfluenceException
					Assert.assertEquals(it.message, "HTTP Exception 403 Forbidden")

					true
				}
			}
	}

	@Test
	fun `anonymous, 500`() {
		deletePage(anonymousConfluence, "57007")
			.test()
			.run {
				awaitTerminalEvent(10, TimeUnit.SECONDS)

				assertError {
					it as ConfluenceException
					Assert.assertEquals(it.message, "HTTP Exception 500 Server Error")

					true
				}
			}
	}
}


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/model/confluence/CreatePageResponse.kt<|end_filename|>
package by.dev.madhead.doktor.model.confluence

import com.github.kittinunf.fuel.core.ResponseDeserializable
import com.google.gson.Gson
import java.io.Reader

data class CreatePageResponse(
	val id: String
) {
	class Deserializer : ResponseDeserializable<CreatePageResponse> {
		override fun deserialize(reader: Reader) = Gson().fromJson(reader, CreatePageResponse::class.java)
	}
}


<|start_filename|>src/test/resources/by/dev/madhead/doktor/confluence/CreatePage/mappings/anonymous_top_level_exist.json<|end_filename|>
{
	"request": {
		"method": "POST",
		"urlPath": "/rest/api/content",
		"bodyPatterns": [
			{
				"equalToJson": "{\"type\":\"page\",\"title\":\"anonymous, top level, exist\",\"space\":{\"key\":\"TEST\"},\"body\":{\"storage\":{\"value\":\"\\u003ch1\\u003eTest\\u003c/h1\\u003e\",\"representation\":\"storage\"}}}"
			}
		]
	},
	"response": {
		"status": 400,
		"jsonBody": {
			"statusCode": 400,
			"data": {
				"authorized": false,
				"valid": true,
				"errors": [],
				"successful": false
			},
			"message": "A page with this title already exists: A page already exists with the title anonymous, top level, exist in the space with key TEST"
		}
	}
}


<|start_filename|>build.gradle.kts<|end_filename|>
import org.jenkinsci.gradle.plugins.jpi.JpiDeveloper
import org.jenkinsci.gradle.plugins.jpi.JpiLicense
import org.jetbrains.kotlin.gradle.internal.KaptGenerateStubsTask
import org.jetbrains.kotlin.gradle.internal.KaptTask
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
	kotlin("jvm") version ("1.1.51")
	kotlin("kapt") version ("1.1.51")

	id("jacoco")
	id("org.jenkins-ci.jpi") version ("0.22.0")
	id("net.researchgate.release") version ("2.6.0")
}

repositories {
	jcenter()
}

val kotlinVersion by project
val rxkotlinVersion by project
val flexmarkVersion by project
val asciidoctorjVersion by project
val asciidoctorjDiagramVersion by project
val jacksonVersion by project
val jgraphtVersion by project
val fuelVersion by project
val jsoupVersion by project
val kotlinxHtmlJvmVersion by project
val simplemagicVersion by project
val commonsCodecVersion by project
val jenkinsCredentialsPluginVersion by project
val jenkinsWorkflowStepsAPIPluginVersion by project

val testngVersion by project
val wiremockVersion by project
val mockitoVersion by project

val sezpozVersion by project

dependencies {
	compile(kotlin("stdlib-jre8", "${kotlinVersion}"))
	compile("io.reactivex.rxjava2:rxkotlin:${rxkotlinVersion}")
	compile("com.vladsch.flexmark:flexmark:${flexmarkVersion}")
	compile("com.vladsch.flexmark:flexmark-ext-yaml-front-matter:${flexmarkVersion}")
	compile("com.vladsch.flexmark:flexmark-ext-tables:${flexmarkVersion}")
	compile("org.asciidoctor:asciidoctorj:${asciidoctorjVersion}")
	compile("org.asciidoctor:asciidoctorj-diagram:${asciidoctorjDiagramVersion}")
	compile("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:${jacksonVersion}")
	compile("com.fasterxml.jackson.module:jackson-module-kotlin:${jacksonVersion}")
	compile("org.jgrapht:jgrapht-core:${jgraphtVersion}")
	compile("com.github.kittinunf.fuel:fuel-rxjava:${fuelVersion}")
	compile("com.github.kittinunf.fuel:fuel-gson:${fuelVersion}")
	compile("org.jsoup:jsoup:${jsoupVersion}")
	compile("org.jetbrains.kotlinx:kotlinx-html-jvm:${kotlinxHtmlJvmVersion}")
	compile("com.j256.simplemagic:simplemagic:${simplemagicVersion}")
	compile("commons-codec:commons-codec:${commonsCodecVersion}")

	jenkinsPlugins("org.jenkins-ci.plugins:credentials:${jenkinsCredentialsPluginVersion}@jar")
	jenkinsPlugins("org.jenkins-ci.plugins.workflow:workflow-step-api:${jenkinsWorkflowStepsAPIPluginVersion}@jar")

	testCompile("org.testng:testng:${testngVersion}")
	testCompile("com.github.tomakehurst:wiremock:${wiremockVersion}")
	testCompile("org.mockito:mockito-core:${mockitoVersion}")

	// SezPoz is used to process @hudson.Extension and other annotations
	kapt("net.java.sezpoz:sezpoz:${sezpozVersion}")
}

java {
	sourceCompatibility = JavaVersion.VERSION_1_8
}

kapt {
	correctErrorTypes = true
}

jacoco {
	toolVersion = "0.7.9"
}

val jenkinsCoreVersion by project

jenkinsPlugin {
	displayName = "Doktor"
	shortName = "doktor"
	gitHubUrl = "https://github.com/madhead/doktor"
	url = "https://wiki.jenkins.io/display/JENKINS/Doktor+Plugin"

	coreVersion = jenkinsCoreVersion as String?
	compatibleSinceVersion = coreVersion
	fileExtension = "jpi"
	pluginFirstClassLoader = true

	developers = this.Developers().apply {
		developer(delegateClosureOf<JpiDeveloper> {
			setProperty("id", "madhead")
			setProperty("name", "<NAME>")
			setProperty("email", "<EMAIL>")
			setProperty("url", "https://madhead.me")
			setProperty("timezone", "UTC+3")
		})
	}

	licenses = this.Licenses().apply {
		license(delegateClosureOf<JpiLicense> {
			setProperty("url", "http://www.apache.org/licenses/LICENSE-2.0")
		})
	}
}

tasks.withType(KotlinCompile::class.java).all {
	dependsOn("localizer")

	kotlinOptions {
		jvmTarget = "1.8"
	}
}

tasks.withType(Test::class.java).all {
	useTestNG()
}

tasks.withType(JacocoReport::class.java).all {
	reports {
		xml.setEnabled(true)
	}
}

tasks.withType(KaptTask::class.java).all {
	outputs.upToDateWhen { false }
}

tasks.withType(KaptGenerateStubsTask::class.java).all {
	outputs.upToDateWhen { false }
}

task<Wrapper>("wrapper") {
	gradleVersion = "4.1"
	distributionType = Wrapper.DistributionType.ALL
}


<|start_filename|>src/test/resources/by/dev/madhead/doktor/confluence/CreateAttachment/mappings/anonymous_500.json<|end_filename|>
{
	"request": {
		"method": "POST",
		"urlPath": "/rest/api/content/57009/child/attachment"
	},
	"response": {
		"status": 500
	}
}


<|start_filename|>src/test/resources/by/dev/madhead/doktor/confluence/DeletePage/mappings/anonymous_403.json<|end_filename|>
{
	"request": {
		"method": "DELETE",
		"urlPath": "/rest/api/content/57006"
	},
	"response": {
		"status": 403,
		"body": "<html><head><title>Apache Tomcat/7.0.52 - Error report</title><style><!--H1 {font-family:Tahoma,Arial,sans-serif;color:white;background-color:#525D76;font-size:22px;} H2 {font-family:Tahoma,Arial,sans-serif;color:white;background-color:#525D76;font-size:16px;} H3 {font-family:Tahoma,Arial,sans-serif;color:white;background-color:#525D76;font-size:14px;} BODY {font-family:Tahoma,Arial,sans-serif;color:black;background-color:white;} B {font-family:Tahoma,Arial,sans-serif;color:white;background-color:#525D76;} P {font-family:Tahoma,Arial,sans-serif;background:white;color:black;font-size:12px;}A {color : black;}A.name {color : black;}HR {color : #525D76;}--></style> </head><body><h1>HTTP Status 403 - </h1><HR size=\"1\" noshade=\"noshade\"><p><b>type</b> Status report</p><p><b>message</b> <u></u></p><p><b>description</b> <u>Access to the specified resource has been forbidden.</u></p><HR size=\"1\" noshade=\"noshade\"><h3>Apache Tomcat/7.0.52</h3></body></html>"
	}
}



<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/util/fs/WorkspaceFileVisitor.kt<|end_filename|>
package by.dev.madhead.doktor.util.fs

import by.dev.madhead.doktor.model.Markup
import by.dev.madhead.doktor.model.MarkupPatterns
import java.nio.file.FileVisitResult
import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes

class WorkspaceFileVisitor(
	val base: Path,
	val matchers: MarkupPatterns,
	val accept: (Path, Markup) -> Unit
) : SimpleFileVisitor<Path>() {
	override fun visitFile(path: Path, attrs: BasicFileAttributes): FileVisitResult {
		val relativePath = base.relativize(path)

		for ((markup, matchers) in matchers) {
			if ((matchers.first.map { path.fileSystem.getPathMatcher(it) }.any { it.matches(relativePath) })
				&& (matchers.second.map { path.fileSystem.getPathMatcher(it) }.none { it.matches(relativePath) })) {
				accept(path, markup)
			}
		}

		// TODO: Support for stopping
		return FileVisitResult.CONTINUE
	}
}


<|start_filename|>src/test/resources/by/dev/madhead/doktor/confluence/CreatePage/mappings/anonymous_top_level_401.json<|end_filename|>
{
	"request": {
		"method": "POST",
		"urlPath": "/rest/api/content",
		"bodyPatterns": [
			{
				"equalToJson": "{\"type\":\"page\",\"title\":\"anonymous, top level, 401\",\"space\":{\"key\":\"TEST\"},\"body\":{\"storage\":{\"value\":\"\\u003ch1\\u003eTest\\u003c/h1\\u003e\",\"representation\":\"storage\"}}}"
			}
		]
	},
	"response": {
		"status": 401,
		"body": "<!DOCTYPE html><html><head><title>Apache Tomcat/8.0.43 - Error report</title><style type=\"text/css\">H1 {font-family:Tahoma,Arial,sans-serif;color:white;background-color:#525D76;font-size:22px;} H2 {font-family:Tahoma,Arial,sans-serif;color:white;background-color:#525D76;font-size:16px;} H3 {font-family:Tahoma,Arial,sans-serif;color:white;background-color:#525D76;font-size:14px;} BODY {font-family:Tahoma,Arial,sans-serif;color:black;background-color:white;} B {font-family:Tahoma,Arial,sans-serif;color:white;background-color:#525D76;} P {font-family:Tahoma,Arial,sans-serif;background:white;color:black;font-size:12px;}A {color : black;}A.name {color : black;}.line {height: 1px; background-color: #525D76; border: none;}</style> </head><body><h1>HTTP Status 401 - Basic Authentication Failure - Reason : AUTHENTICATED_FAILED</h1><div class=\"line\"></div><p><b>type</b> Status report</p><p><b>message</b> <u>Basic Authentication Failure - Reason : AUTHENTICATED_FAILED</u></p><p><b>description</b> <u>This request requires HTTP authentication.</u></p><hr class=\"line\"><h3>Apache Tomcat/8.0.43</h3></body></html>"
	}
}


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/model/DoktorConfig.kt<|end_filename|>
package by.dev.madhead.doktor.model

import java.io.Serializable

typealias MarkupPatterns = Map<Markup, Pair<List<String>, List<String>>>

data class DoktorConfig(
	val server: ResolvedConfluenceServer,
	val markupPatterns: MarkupPatterns
) : Serializable


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/pipeline/DoktorStepExecution.kt<|end_filename|>
package by.dev.madhead.doktor.pipeline

import by.dev.madhead.doktor.Messages
import by.dev.madhead.doktor.model.DoktorConfig
import by.dev.madhead.doktor.util.diagnose
import hudson.AbortException
import hudson.FilePath
import hudson.model.TaskListener
import org.jenkinsci.plugins.workflow.steps.StepContext
import org.jenkinsci.plugins.workflow.steps.SynchronousNonBlockingStepExecution

class DoktorStepExecution(context: StepContext, val doktorConfig: DoktorConfig) : SynchronousNonBlockingStepExecution<Void>(context) {
	override fun run(): Void? {
		try {
			diagnose(
				doktorConfig,
				context.get(FilePath::class.java) ?: throw AbortException(Messages.doktor_hudson_AbortException_workspaceRequired()),
				context.get(TaskListener::class.java) ?: throw AbortException(Messages.doktor_hudson_AbortException_taskListenerRequired())
			).blockingAwait()

			return null
		} catch (exception: Throwable) {
			throw AbortException(exception.message)
		}
	}
}


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/model/DescribableString.kt<|end_filename|>
package by.dev.madhead.doktor.model

import hudson.Extension
import hudson.model.AbstractDescribableImpl
import hudson.model.Descriptor
import org.kohsuke.stapler.DataBoundConstructor

class DescribableString
@DataBoundConstructor
constructor(
	val value: String
) : AbstractDescribableImpl<DescribableString>() {
	@Extension
	class DescribableStringDescriptor : Descriptor<DescribableString>()
}


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/render/DokRenderer.kt<|end_filename|>
package by.dev.madhead.doktor.render

import by.dev.madhead.doktor.Messages
import by.dev.madhead.doktor.model.Attachment
import by.dev.madhead.doktor.model.Markup
import by.dev.madhead.doktor.model.RenderedDok
import by.dev.madhead.doktor.model.confluence.macros.acImage
import by.dev.madhead.doktor.model.confluence.macros.riAttachment
import by.dev.madhead.doktor.model.confluence.macros.riUrl
import com.j256.simplemagic.ContentInfoUtil
import hudson.model.TaskListener
import hudson.remoting.VirtualChannel
import jenkins.SlaveToMasterFileCallable
import kotlinx.html.stream.appendHTML
import org.apache.commons.codec.digest.DigestUtils
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.parser.Parser
import java.io.File
import java.net.URI

class DokRenderer(
	val markup: Markup,
	val taskListener: TaskListener
) : SlaveToMasterFileCallable<RenderedDok>() {
	override fun invoke(file: File, channel: VirtualChannel?): RenderedDok {
		taskListener.logger.println(Messages.doktor_render_DokRenderer_rendering(markup, file))

		val content = markup.render(file)
		val document = Jsoup.parseBodyFragment(wrap(content.content))

		document.outputSettings().syntax(Document.OutputSettings.Syntax.xml)

		val images = processImages(document, file)

		return RenderedDok(
			filePath = file.absolutePath,
			content = content.copy(content = document.body().html()),
			images = images
		)
	}

	private fun processImages(document: Document, file: File): List<Attachment> {
		val result = mutableListOf<Attachment>()
		val magic = ContentInfoUtil()

		document
			.getElementsByTag("img")
			.forEach {
				val src = it.attr("src")

				try {
					if (null == URI(src).host) {
						val bytes = file.resolveSibling(src).readBytes()
						val contentInfo = magic.findMatch(bytes)
						val fileName = DigestUtils.md5Hex(bytes) + if ((null == contentInfo) || (null == contentInfo.fileExtensions)) {
							".jpeg" // why not?
						} else {
							".${contentInfo.fileExtensions[0]}"
						}

						result.add(Attachment(fileName, bytes))
						it.replaceWith(Jsoup.parse(buildString {
							appendHTML(true).acImage {
								if (!it.attr("width").isNullOrBlank()) {
									acWidth = it.attr("width")
								}
								if (!it.attr("height").isNullOrBlank()) {
									acHeight = it.attr("height")
								}
								if (!it.attr("alt").isNullOrBlank()) {
									acAlt = it.attr("alt")
								}
								// Special support for Asciidoc image titles
								it.parents().find {
									it.attr("class")?.contains("imageblock") ?: false
								}?.let {
									it.children().find {
										it.attr("class")?.contains("title") ?: false
									}?.let {
										acTitle = it.text()
									}
								}
								riAttachment(fileName)
							}
						}, "", Parser.xmlParser()).child(0))
					} else {
						it.replaceWith(Jsoup.parse(buildString {
							appendHTML(true).acImage {
								if (!it.attr("width").isNullOrBlank()) {
									acWidth = it.attr("width")
								}
								if (!it.attr("height").isNullOrBlank()) {
									acHeight = it.attr("height")
								}
								if (!it.attr("alt").isNullOrBlank()) {
									acAlt = it.attr("alt")
								}
								// Special support for Asciidoc image titles
								it.parents().find {
									it.attr("class")?.contains("imageblock") ?: false
								}?.let {
									it.children().find {
										it.attr("class")?.contains("title") ?: false
									}?.let {
										acTitle = it.text()
									}
								}
								riUrl(src)
							}
						}, "", Parser.xmlParser()).child(0))
					}
				} catch (e: Throwable) {
					taskListener.error(Messages.doktor_render_DokRenderer_imageFailed(src))
				}
			}

		return result
	}

	private fun wrap(content: String): String {
		return """
			<div class="doktor">
				${content}
			</div>
		"""
	}
}


<|start_filename|>src/test/resources/by/dev/madhead/doktor/confluence/DeletePage/mappings/anonymous_no_content.json<|end_filename|>
{
	"request": {
		"method": "DELETE",
		"urlPath": "/rest/api/content/57005"
	},
	"response": {
		"status": 404,
		"jsonBody": {
			"statusCode": 404,
			"message": "Cannot delete content with id 'ContentId{id=57005}': not found"
		}
	}
}


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/model/Dok.kt<|end_filename|>
package by.dev.madhead.doktor.model

import hudson.FilePath
import java.io.Serializable

data class Dok(
	val filePath: FilePath,
	val markup: Markup
) : Serializable

data class RenderedContent(
	val markup: Markup,
	val content: String,
	val frontMatter: FrontMatter
) : Serializable

data class RenderedDok(
	val filePath: String,
	val content: RenderedContent,
	val images: List<Attachment>
) : Serializable


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/model/confluence/macros/AcImage.kt<|end_filename|>
package by.dev.madhead.doktor.model.confluence.macros

import kotlinx.html.HTMLTag
import kotlinx.html.HtmlBlockTag
import kotlinx.html.TagConsumer
import kotlinx.html.visitAndFinalize

class AC_IMAGE(consumer: TagConsumer<*>) :
	HTMLTag(
		tagName = "ac:image",
		consumer = consumer,
		initialAttributes = emptyMap(),
		inlineTag = false,
		emptyTag = false
	), HtmlBlockTag {
	var acHeight: String
		get() = attributes["ac:height"]!!
		set(value) {
			attributes["ac:height"] = value
		}
	var acWidth: String
		get() = attributes["ac:width"]!!
		set(value) {
			attributes["ac:width"] = value
		}
	var acAlt: String
		get() = attributes["ac:alt"]!!
		set(value) {
			attributes["ac:alt"] = value
		}
	var acTitle: String
		get() = attributes["ac:title"]!!
		set(value) {
			attributes["ac:title"] = value
		}
}

fun AC_IMAGE.riAttachment(fileName: String) {
	RI_ATTACHMENT(consumer).visitAndFinalize(consumer) {
		riFileName = fileName
	}
}

fun AC_IMAGE.riUrl(url: String) {
	RI_URL(consumer, url).visitAndFinalize(consumer) {}
}

fun <T> TagConsumer<T>.acImage(block: AC_IMAGE.() -> Unit = {}): T {
	return AC_IMAGE(this).visitAndFinalize(this, block)
}


<|start_filename|>src/test/resources/by/dev/madhead/doktor/confluence/CreateAttachment/mappings/anonymous_400.json<|end_filename|>
{
	"request": {
		"method": "POST",
		"urlPath": "/rest/api/content/57007/child/attachment"
	},
	"response": {
		"status": 400,
		"jsonBody": {
			"statusCode": 400,
			"data": {
				"authorized": false,
				"valid": true,
				"errors": [],
				"successful": false
			},
			"message": "The request was rejected because no multipart boundary was found"
		}
	}
}


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/confluence/upload.kt<|end_filename|>
package by.dev.madhead.doktor.confluence

import by.dev.madhead.doktor.Messages
import by.dev.madhead.doktor.model.RenderedDok
import by.dev.madhead.doktor.model.ResolvedConfluenceServer
import by.dev.madhead.doktor.model.confluence.*
import hudson.model.TaskListener
import io.reactivex.Completable
import io.reactivex.Maybe
import io.reactivex.Single

fun upload(confluenceServer: ResolvedConfluenceServer, renderedDok: RenderedDok, taskListener: TaskListener): Completable {
	taskListener.logger.println(Messages.doktor_confluence_upload_checkingExistence(renderedDok.filePath, renderedDok.content.frontMatter.title))

	return findPage(confluenceServer, renderedDok.content.frontMatter.title)
		.flatMap {
			taskListener.logger.println(Messages.doktor_confluence_upload_deletingExistingPage(it.id, renderedDok.filePath, renderedDok.content.frontMatter.title))

			deletePage(confluenceServer, it.id)
				.toMaybe<CreatePageResponse>()
		}
		.switchIfEmpty(
			Single
				.just(renderedDok.content.frontMatter.parent.isNullOrEmpty())
				.flatMapMaybe {
					if (it) {
						taskListener.logger.println(Messages.doktor_confluence_upload_creatingNewTopLevelPage(renderedDok.filePath, renderedDok.content.frontMatter.title))

						createPage(
							confluenceServer,
							CreatePageRequest(
								title = renderedDok.content.frontMatter.title,
								space = SpaceReference(confluenceServer.space),
								body = Body(
									storage = Storage(
										value = renderedDok.content.content
									)
								)
							)
						).doOnSuccess {
							taskListener.logger.println(Messages.doktor_confluence_upload_createdNewTopLevelPage(renderedDok.filePath, renderedDok.content.frontMatter.title, it.id))
						}.toMaybe()
					} else {
						taskListener.logger.println(Messages.doktor_confluence_upload_resolvingParent(renderedDok.filePath, renderedDok.content.frontMatter.title, renderedDok.content.frontMatter.parent))

						findPage(confluenceServer, renderedDok.content.frontMatter.parent!!)
							.map {
								taskListener.logger.println(Messages.doktor_confluence_upload_resolvedParent(renderedDok.filePath, renderedDok.content.frontMatter.title, it.id))
								listOf(it.id)
							}
							.switchIfEmpty(
								Maybe.just(emptyList())
							)
							.flatMap {
								if (it.isNotEmpty()) {
									taskListener.logger.println(Messages.doktor_confluence_upload_creatingNewChildPage(renderedDok.filePath, renderedDok.content.frontMatter.title, renderedDok.content.frontMatter.parent, it))

									createPage(
										confluenceServer,
										CreatePageRequest(
											title = renderedDok.content.frontMatter.title,
											space = SpaceReference(confluenceServer.space),
											body = Body(
												storage = Storage(
													value = renderedDok.content.content
												)
											),
											ancestors = it.map { ContentReference(it) }
										)
									).doOnSuccess {
										taskListener.logger.println(Messages.doktor_confluence_upload_createdNewChildPage(renderedDok.filePath, renderedDok.content.frontMatter.title, renderedDok.content.frontMatter.parent, it, it.id))
									}.toMaybe()
								} else {
									taskListener.error(Messages.doktor_confluence_upload_cannotResolveParent(renderedDok.filePath, renderedDok.content.frontMatter.title))

									Maybe.empty<CreatePageResponse>()
								}
							}
					}
				}
		)
		.flatMapCompletable { (id) ->
			val labelsCompletable =
				if (renderedDok.content.frontMatter.labels.isNotEmpty()) {
					taskListener.logger.println(Messages.doktor_confluence_upload_addingLabels(renderedDok.content.frontMatter.labels, renderedDok.filePath, renderedDok.content.frontMatter.title, id))

					addLabels(confluenceServer, id, renderedDok.content.frontMatter.labels.map { Label(name = it) })
						.toCompletable()
						.doOnError {
							taskListener.error(Messages.doktor_confluence_upload_addingLabelsError(renderedDok.filePath, renderedDok.content.frontMatter.title, id))
						}
						.onErrorComplete()
				} else {
					Completable.complete()
				}
			val attachmentsCompletables =
				if (renderedDok.images.isNotEmpty()) {
					taskListener.logger.println(Messages.doktor_confluence_upload_uploadingAttachmnets(renderedDok.filePath, renderedDok.content.frontMatter.title, id))

					renderedDok.images.map {
						createAttachment(confluenceServer, id, it)
							.toCompletable()
							.doOnError {
								taskListener.error(Messages.doktor_confluence_upload_uploadingAttachmnetsError(renderedDok.filePath, renderedDok.content.frontMatter.title, id))
							}
							.onErrorComplete()
					}
				} else {
					listOf(Completable.complete())
				}

			Completable.mergeDelayError(
				listOf(labelsCompletable) + attachmentsCompletables
			).onErrorComplete()
		}
}


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/render/markdown.kt<|end_filename|>
package by.dev.madhead.doktor.render

import by.dev.madhead.doktor.Messages
import by.dev.madhead.doktor.model.*
import by.dev.madhead.doktor.model.Markup.MARKDOWN
import com.vladsch.flexmark.ext.tables.TablesExtension
import com.vladsch.flexmark.ext.yaml.front.matter.AbstractYamlFrontMatterVisitor
import com.vladsch.flexmark.ext.yaml.front.matter.YamlFrontMatterExtension
import com.vladsch.flexmark.html.HtmlRenderer
import com.vladsch.flexmark.parser.Parser
import com.vladsch.flexmark.util.options.MutableDataSet
import java.io.File

fun markdown(file: File): RenderedContent {
	val options = MutableDataSet().apply {
		set(Parser.EXTENSIONS, listOf(YamlFrontMatterExtension.create(), TablesExtension.create()))
	}
	val parser = Parser.builder(options).build()
	val htmlRenderer = HtmlRenderer.builder(options).build()
	val document = parser.parse(file.readText())
	val visitor = AbstractYamlFrontMatterVisitor()

	visitor.visit(document)
	if ((null == visitor.data) || (visitor.data.isEmpty())) {
		throw RenderException(Messages.doktor_render_RenderException_frontMatterRequired())
	}

	return RenderedContent(
		MARKDOWN,
		htmlRenderer.render(document),
		FrontMatter(
			title = visitor.data[FRONTMATTER_TITLE]?.get(0) ?: throw RenderException(Messages.doktor_render_RenderException_titleRequired()),
			parent = visitor.data[FRONTMATTER_PARENT]?.get(0),
			labels = visitor.data[FRONTMATTER_LABELS] ?: emptyList()
		)
	)
}


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/model/confluence/Label.kt<|end_filename|>
package by.dev.madhead.doktor.model.confluence

import com.google.gson.Gson

data class Label(
	val name: String,
	val prefix: String = "global"
)

typealias Labels = List<Label>

fun Labels.asJSON() = Gson().toJson(this)!!


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/model/Attachment.kt<|end_filename|>
package by.dev.madhead.doktor.model

import java.io.Serializable
import java.util.Arrays

data class Attachment(
	val fileName: String,
	val bytes: ByteArray
) : Serializable {
	override fun equals(other: Any?): Boolean {
		if (this === other) return true
		if (javaClass != other?.javaClass) return false

		other as Attachment

		if (fileName != other.fileName) return false
		if (!Arrays.equals(bytes, other.bytes)) return false

		return true
	}

	override fun hashCode(): Int {
		var result = fileName.hashCode()

		result = 31 * result + Arrays.hashCode(bytes)
		return result
	}
}


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/model/confluence/CreateAttachmentResponse.kt<|end_filename|>
package by.dev.madhead.doktor.model.confluence

import com.github.kittinunf.fuel.core.ResponseDeserializable
import com.google.gson.Gson
import java.io.Reader

data class CreateAttachmentResponse(
	val results: List<ContentReference>
) {
	class Deserializer : ResponseDeserializable<CreateAttachmentResponse> {
		override fun deserialize(reader: Reader) = Gson().fromJson(reader, CreateAttachmentResponse::class.java)
	}
}


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/pipeline/DoktorStep.kt<|end_filename|>
package by.dev.madhead.doktor.pipeline

import by.dev.madhead.doktor.Messages
import by.dev.madhead.doktor.config.ConfluenceServers
import by.dev.madhead.doktor.model.DoktorConfig
import by.dev.madhead.doktor.model.Markup.ASCIIDOC
import by.dev.madhead.doktor.model.Markup.MARKDOWN
import hudson.Extension
import hudson.FilePath
import hudson.model.TaskListener
import hudson.util.ListBoxModel
import jenkins.model.GlobalConfiguration
import org.jenkinsci.plugins.workflow.steps.Step
import org.jenkinsci.plugins.workflow.steps.StepContext
import org.jenkinsci.plugins.workflow.steps.StepDescriptor
import org.kohsuke.stapler.DataBoundConstructor

class DoktorStep
@DataBoundConstructor
constructor(
	val server: String,
	var markdownIncludePatterns: List<String>?,
	var markdownExcludePatterns: List<String>?,
	var asciidocIncludePatterns: List<String>?,
	var asciidocExcludePatterns: List<String>?
) : Step() {
	override fun start(context: StepContext) = DoktorStepExecution(
		context,
		DoktorConfig(
			ConfluenceServers.resolve(server),
			mapOf(
				MARKDOWN to Pair(markdownIncludePatterns ?: emptyList(), markdownExcludePatterns ?: emptyList()),
				ASCIIDOC to Pair(asciidocIncludePatterns ?: emptyList(), asciidocExcludePatterns ?: emptyList())
			)
		)
	)

	@Extension
	class DoktorStepDescriptor : StepDescriptor() {
		override fun getFunctionName() = "doktor"

		override fun getDisplayName() = Messages.doktor_pipeline_DoktorStep_displayName()

		override fun getRequiredContext() = setOf(FilePath::class.java, TaskListener::class.java)

		fun doFillServerItems(): ListBoxModel {
			val result = ListBoxModel()

			GlobalConfiguration.all().get(ConfluenceServers::class.java)?.servers?.forEach {
				result.add("${it.name} (${it.url})", it.name)
			}

			return result
		}
	}
}


<|start_filename|>src/test/kotlin/by/dev/madhead/doktor/confluence/AddLabels.kt<|end_filename|>
package by.dev.madhead.doktor.confluence

import by.dev.madhead.doktor.model.confluence.Label
import org.testng.Assert
import org.testng.annotations.Test
import java.util.concurrent.TimeUnit

class AddLabels : BaseConfluenceTest() {
	@Test
	fun `anonymous, success`() {
		addLabels(anonymousConfluence, "57005", listOf(Label(name = "test"), Label(name = "labels")))
			.test()
			.run {
				awaitTerminalEvent(10, TimeUnit.SECONDS)

				assertValue {
					Assert.assertEquals(it.results, listOf(Label(name = "test"), Label(name = "labels")))
					Assert.assertEquals(it.start, 0)
					Assert.assertEquals(it.limit, 200)
					Assert.assertEquals(it.size, 2)

					true
				}
			}
	}

	@Test
	fun `identified, success`() {
		addLabels(identifiedConfluence, "57006", listOf(Label(name = "test"), Label(name = "labels")))
			.test()
			.run {
				awaitTerminalEvent(10, TimeUnit.SECONDS)

				assertValue {
					Assert.assertEquals(it.results, listOf(Label(name = "test"), Label(name = "labels")))
					Assert.assertEquals(it.start, 0)
					Assert.assertEquals(it.limit, 200)
					Assert.assertEquals(it.size, 2)

					true
				}
			}
	}

	@Test
	fun `anonymous, no content`() {
		addLabels(anonymousConfluence, "57007", listOf(Label(name = "test"), Label(name = "labels")))
			.test()
			.run {
				awaitTerminalEvent(10, TimeUnit.SECONDS)

				assertError {
					it as ConfluenceException
					Assert.assertEquals(it.message, "No content found with id : ContentId{id=57007}")

					true
				}
			}
	}

	@Test
	fun `anonymous, 401`() {
		addLabels(anonymousConfluence, "57008", listOf(Label(name = "test"), Label(name = "labels")))
			.test()
			.run {
				awaitTerminalEvent(10, TimeUnit.SECONDS)

				assertError {
					it as ConfluenceException
					Assert.assertEquals(it.message, "HTTP Exception 401 Unauthorized")

					true
				}
			}
	}

	@Test
	fun `anonymous, 500`() {
		addLabels(anonymousConfluence, "57009", listOf(Label(name = "test"), Label(name = "labels")))
			.test()
			.run {
				awaitTerminalEvent(10, TimeUnit.SECONDS)

				assertError {
					it as ConfluenceException
					Assert.assertEquals(it.message, "HTTP Exception 500 Server Error")

					true
				}
			}
	}
}


<|start_filename|>src/test/kotlin/by/dev/madhead/doktor/render/DokRendererTest.kt<|end_filename|>
package by.dev.madhead.doktor.render

import by.dev.madhead.doktor.model.Markup
import by.dev.madhead.doktor.model.RenderedDok
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
import com.fasterxml.jackson.module.kotlin.readValue
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import com.github.tomakehurst.wiremock.WireMockServer
import hudson.model.TaskListener
import org.mockito.Mockito
import org.testng.Assert
import org.testng.annotations.DataProvider
import org.testng.annotations.Test
import java.io.File
import java.io.PrintStream

class DokRendererTest {
	val objectMapper = ObjectMapper(YAMLFactory()).registerKotlinModule()
	lateinit private var wiremock: WireMockServer

	@DataProvider(name = "markdowns")
	fun markdowns(): Array<Array<*>> {
		return listOf(
			"local_image",
			"remote_image",
			"zero_image"
		).map {
			arrayOf(
				File(this::class.java.getResource("/by/dev/madhead/doktor/render/DokRenderer/md/${it}.md").toURI()),
				objectMapper.readValue<RenderedDok>(this::class.java.getResourceAsStream("/by/dev/madhead/doktor/render/DokRenderer/md/${it}.yml"))
			)
		}.toTypedArray()
	}

	@DataProvider(name = "asciidocs")
	fun asciidocs(): Array<Array<*>> {
		return listOf(
			"local_image_alt",
			"local_image_height",
			"local_image_no_alt",
			"local_image_title",
			"local_image_title_specials",
			"local_image_width",
			"remote_image_alt",
			"remote_image_height",
			"remote_image_no_alt",
			"remote_image_title",
			"remote_image_title_specials",
			"remote_image_width",
			"zero_image"
		).map {
			arrayOf(
				File(this::class.java.getResource("/by/dev/madhead/doktor/render/DokRenderer/adoc/${it}.adoc").toURI()),
				objectMapper.readValue<RenderedDok>(this::class.java.getResourceAsStream("/by/dev/madhead/doktor/render/DokRenderer/adoc/${it}.yml"))
			)
		}.toTypedArray()
	}

	@Test(dataProvider = "markdowns")
	fun markdown(input: File, expected: RenderedDok) {
		test(Markup.MARKDOWN, input, expected)
	}

	@Test(dataProvider = "asciidocs")
	fun asciidoc(input: File, expected: RenderedDok) {
		test(Markup.ASCIIDOC, input, expected)
	}

	private fun test(markup: Markup, input: File, expected: RenderedDok) {
		val taskListener = Mockito.mock(TaskListener::class.java)
		val logger = Mockito.mock(PrintStream::class.java)
		val renderer = DokRenderer(markup, taskListener)

		Mockito.`when`(taskListener.logger).thenReturn(logger)

		val renderedDok = renderer.invoke(input, null)

		Assert.assertEquals(renderedDok.content, expected.content)
		Assert.assertEquals(renderedDok.images, expected.images)
	}
}


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/confluence/deletePage.kt<|end_filename|>
package by.dev.madhead.doktor.confluence

import by.dev.madhead.doktor.model.ResolvedConfluenceServer
import com.github.kittinunf.fuel.httpDelete
import com.github.kittinunf.fuel.rx.rx_response
import com.github.kittinunf.result.Result
import com.google.gson.Gson
import io.reactivex.Completable
import java.net.URL

fun deletePage(confluenceServer: ResolvedConfluenceServer, id: String): Completable {
	return URL(URL(confluenceServer.url).toExternalForm() + "/rest/api/content/${id}").toString()
		.httpDelete()
		.apply {
			if (!confluenceServer.user.isNullOrBlank()) {
				authenticate(confluenceServer.user!!, confluenceServer.password ?: "")
			}
		}
		.rx_response()
		.flatMapCompletable { (_, result) ->
			when (result) {
				is Result.Success -> Completable.complete()
				is Result.Failure -> {
					try {
						val exception = Gson().fromJson(result.error.response.data.toString(Charsets.UTF_8), ConfluenceException::class.java)

						if (!exception.message.isBlank()) {
							Completable.error(exception)
						} else {
							throw IllegalArgumentException()
						}
					} catch (e: Throwable) {
						Completable.error(ConfluenceException(result.error.exception.message ?: result.error.message ?: result.error.response.responseMessage))
					}
				}
			}
		}
}


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/model/confluence/macros/RiUrl.kt<|end_filename|>
package by.dev.madhead.doktor.model.confluence.macros

import kotlinx.html.HTMLTag
import kotlinx.html.HtmlBlockTag
import kotlinx.html.TagConsumer

class RI_URL(consumer: TagConsumer<*>, url: String) :
	HTMLTag(
		tagName = "ri:url",
		consumer = consumer,
		initialAttributes = mapOf("ri:value" to url),
		inlineTag = false,
		emptyTag = false
	), HtmlBlockTag


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/util/doktor.kt<|end_filename|>
package by.dev.madhead.doktor.util

import by.dev.madhead.doktor.Messages
import by.dev.madhead.doktor.confluence.upload
import by.dev.madhead.doktor.model.DoktorConfig
import by.dev.madhead.doktor.model.RenderedDok
import by.dev.madhead.doktor.render.DokRenderer
import by.dev.madhead.doktor.util.fs.WorkspaceDokLister
import hudson.FilePath
import hudson.model.TaskListener
import io.reactivex.Completable
import io.reactivex.Observable
import io.reactivex.rxkotlin.toObservable
import org.jgrapht.graph.DefaultDirectedGraph
import org.jgrapht.graph.DefaultEdge
import org.jgrapht.traverse.TopologicalOrderIterator
import java.util.concurrent.ConcurrentHashMap

fun diagnose(doktorConfig: DoktorConfig, workspace: FilePath, taskListener: TaskListener): Completable {
	return Observable
		.fromFuture(
			workspace.actAsync(WorkspaceDokLister(doktorConfig))
		)
		.flatMap { it.toObservable() }
//		.subscribeOn(Schedulers.computation())
		.map {
			Pair(it.filePath.remote, workspace.child(it.filePath.remote).actAsync(DokRenderer(it.markup, taskListener)))
		}
		.flatMap { (originalPath, renderedDokFuture) ->
			Observable
				.fromFuture(renderedDokFuture)
				.doOnError {
					taskListener.error(Messages.doktor_util_diagnose_renderError(originalPath, it))
				}
				.onExceptionResumeNext(Observable.empty<RenderedDok>())
		}
		.collectInto(ConcurrentHashMap<String, RenderedDok>()) { map, renderedDok ->
			map[renderedDok.content.frontMatter.title] = renderedDok
		}
		.map { renderedDoksMap ->
			val graph = DefaultDirectedGraph<RenderedDok, DefaultEdge>(DefaultEdge::class.java)

			renderedDoksMap.values.forEach {
				graph.addVertex(it)
			}
			renderedDoksMap.values.forEach {
				if (!it.content.frontMatter.parent.isNullOrBlank()) {
					if (null != renderedDoksMap[it.content.frontMatter.parent!!]) {
						graph.addEdge(renderedDoksMap[it.content.frontMatter.parent], it)
					}
				}
			}

			graph
		}
		.map {
			TopologicalOrderIterator(it)
		}
		.flatMapObservable { it.toObservable() }
		.flatMapCompletable { renderedDok ->
			upload(
				doktorConfig.server,
				renderedDok,
				taskListener
			).doOnError {
				taskListener.error(Messages.doktor_util_diagnose_confluenceError(renderedDok.filePath, it))
			}.onErrorComplete()
		}
}


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/render/RenderException.kt<|end_filename|>
package by.dev.madhead.doktor.render

class RenderException(message: String, cause: Throwable?) : Throwable(message, cause) {
	constructor(message: String) : this(message, null)
}


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/model/ResolvedConfluenceServer.kt<|end_filename|>
package by.dev.madhead.doktor.model

import java.io.Serializable

data class ResolvedConfluenceServer(
	val name: String,
	val url: String,
	val space: String,
	val user: String?,
	val password: String?
) : Serializable


<|start_filename|>src/test/kotlin/by/dev/madhead/doktor/model/confluence/macros/AcImage.kt<|end_filename|>
package by.dev.madhead.doktor.model.confluence.macros

import kotlinx.html.stream.appendHTML
import org.testng.Assert
import org.testng.annotations.Test

class AcImage {
	@Test
	fun attachment() {
		Assert.assertEquals(
			buildString {
				appendHTML(true).acImage {
					riAttachment("028935e487e29a7c37fafdeec824946b112c71a0ff8781ff2ae6c9d909167c79.jpg")
				}
			},
			"<ac:image>\n  <ri:attachment ri:filename=\"028935e487e29a7c37fafdeec824946b112c71a0ff8781ff2ae6c9d909167c79.jpg\"></ri:attachment>\n</ac:image>\n",
			"Unexpected output")
	}

	@Test
	fun url() {
		Assert.assertEquals(
			buildString {
				appendHTML(true).acImage {
					riUrl("https://i.stack.imgur.com/uguXk.jpg")
				}
			},
			"<ac:image>\n  <ri:url ri:value=\"https://i.stack.imgur.com/uguXk.jpg\"></ri:url>\n</ac:image>\n",
			"Unexpected output")
	}

	@Test
	fun height() {
		Assert.assertEquals(
			buildString {
				appendHTML(true).acImage {
					acHeight = "300"
					riAttachment("028935e487e29a7c37fafdeec824946b112c71a0ff8781ff2ae6c9d909167c79.jpg")
				}
			},
			"<ac:image ac:height=\"300\">\n  <ri:attachment ri:filename=\"028935e487e29a7c37fafdeec824946b112c71a0ff8781ff2ae6c9d909167c79.jpg\"></ri:attachment>\n</ac:image>\n",
			"Unexpected output")
	}

	@Test
	fun width() {
		Assert.assertEquals(
			buildString {
				appendHTML(true).acImage {
					acWidth = "300"
					riAttachment("028935e487e29a7c37fafdeec824946b112c71a0ff8781ff2ae6c9d909167c79.jpg")
				}
			},
			"<ac:image ac:width=\"300\">\n  <ri:attachment ri:filename=\"028935e487e29a7c37fafdeec824946b112c71a0ff8781ff2ae6c9d909167c79.jpg\"></ri:attachment>\n</ac:image>\n",
			"Unexpected output")
	}

	@Test
	fun alt() {
		Assert.assertEquals(
			buildString {
				appendHTML(true).acImage {
					acAlt = "Alt girl"
					riAttachment("028935e487e29a7c37fafdeec824946b112c71a0ff8781ff2ae6c9d909167c79.jpg")
				}
			},
			"<ac:image ac:alt=\"Alt girl\">\n  <ri:attachment ri:filename=\"028935e487e29a7c37fafdeec824946b112c71a0ff8781ff2ae6c9d909167c79.jpg\"></ri:attachment>\n</ac:image>\n",
			"Unexpected output")
	}

	@Test
	fun title() {
		Assert.assertEquals(
			buildString {
				appendHTML(true).acImage {
					acTitle = "Title"
					riAttachment("028935e487e29a7c37fafdeec824946b112c71a0ff8781ff2ae6c9d909167c79.jpg")
				}
			},
			"<ac:image ac:title=\"Title\">\n  <ri:attachment ri:filename=\"028935e487e29a7c37fafdeec824946b112c71a0ff8781ff2ae6c9d909167c79.jpg\"></ri:attachment>\n</ac:image>\n",
			"Unexpected output")
	}
}


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/confluence/createPage.kt<|end_filename|>
package by.dev.madhead.doktor.confluence

import by.dev.madhead.doktor.model.ResolvedConfluenceServer
import by.dev.madhead.doktor.model.confluence.CreatePageRequest
import by.dev.madhead.doktor.model.confluence.CreatePageResponse
import com.github.kittinunf.fuel.httpPost
import com.github.kittinunf.fuel.rx.rx_object
import com.github.kittinunf.result.Result
import com.google.gson.Gson
import io.reactivex.Single
import java.net.URL

fun createPage(confluenceServer: ResolvedConfluenceServer, createPageRequest: CreatePageRequest): Single<CreatePageResponse> {
	return URL(URL(confluenceServer.url).toExternalForm() + "/rest/api/content").toString()
		.httpPost()
		.apply {
			if (!confluenceServer.user.isNullOrBlank()) {
				authenticate(confluenceServer.user!!, confluenceServer.password ?: "")
			}
		}
		.header("Content-Type" to "application/json")
		.body(createPageRequest.asJSON())
		.rx_object(CreatePageResponse.Deserializer())
		.flatMap {
			when (it) {
				is Result.Success -> Single.just(it.value)
				is Result.Failure -> {
					try {
						val exception = Gson().fromJson(it.error.response.data.toString(Charsets.UTF_8), ConfluenceException::class.java)

						if (!exception.message.isBlank()) {
							Single.error<CreatePageResponse>(exception)
						} else {
							throw IllegalArgumentException()
						}
					} catch (e: Throwable) {
						Single.error<CreatePageResponse>(ConfluenceException(it.error.exception.message ?: it.error.message ?: it.error.response.responseMessage))
					}
				}
			}
		}
}


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/model/confluence/Body.kt<|end_filename|>
package by.dev.madhead.doktor.model.confluence

data class Body(
	val storage: Storage
)

data class Storage(
	val value: String,
	val representation: String = "storage"
)


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/config/ConfluenceServer.kt<|end_filename|>
package by.dev.madhead.doktor.config

import by.dev.madhead.doktor.Messages
import com.cloudbees.plugins.credentials.CredentialsMatchers
import com.cloudbees.plugins.credentials.common.StandardListBoxModel
import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials
import hudson.Extension
import hudson.model.AbstractDescribableImpl
import hudson.model.Descriptor
import hudson.security.ACL

import hudson.util.FormValidation
import jenkins.model.Jenkins
import org.kohsuke.stapler.DataBoundConstructor
import org.kohsuke.stapler.QueryParameter

class ConfluenceServer
@DataBoundConstructor
constructor(
	val name: String,
	val url: String,
	val space: String,
	val credentials: String?
) : AbstractDescribableImpl<ConfluenceServer>() {
	@Extension
	class ConfluenceServerDescriptor : Descriptor<ConfluenceServer>() {
		fun doFillCredentialsItems() = StandardListBoxModel()
			.includeEmptyValue()
			.includeMatchingAs(ACL.SYSTEM, Jenkins.getInstance(), StandardUsernamePasswordCredentials::class.java, emptyList(), CredentialsMatchers.always())

		fun doCheckName(@QueryParameter value: String?): FormValidation {
			if (value.isNullOrBlank()) {
				return FormValidation.error(Messages.doktor_config_ConfluenceServer_validation_name_empty())
			}

			return FormValidation.ok()
		}

		fun doCheckUrl(@QueryParameter value: String?): FormValidation {
			if (value.isNullOrBlank()) {
				return FormValidation.error(Messages.doktor_config_ConfluenceServer_validation_url_empty())
			}

			return FormValidation.ok()
		}

		fun doCheckSpace(@QueryParameter value: String?): FormValidation {
			if (value.isNullOrBlank()) {
				return FormValidation.error(Messages.doktor_config_ConfluenceServer_validation_space_empty())
			}

			return FormValidation.ok()
		}
	}
}


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/model/FrontMatter.kt<|end_filename|>
package by.dev.madhead.doktor.model

import java.io.Serializable

const val FRONTMATTER_TITLE = "title"
const val FRONTMATTER_PARENT = "parent"
const val FRONTMATTER_LABELS = "labels"

data class FrontMatter(
	val title: String,
	val parent: String?,
	val labels: List<String>
) : Serializable


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/model/Markup.kt<|end_filename|>
package by.dev.madhead.doktor.model

import by.dev.madhead.doktor.render.asciiDoc
import by.dev.madhead.doktor.render.markdown
import java.io.File

typealias RenderFunction = (File) -> RenderedContent

enum class Markup(val render: RenderFunction) {
	MARKDOWN(::markdown),
	ASCIIDOC(::asciiDoc)
}


<|start_filename|>src/test/kotlin/by/dev/madhead/doktor/render/AsciiDoc.kt<|end_filename|>
package by.dev.madhead.doktor.render

import by.dev.madhead.doktor.model.RenderedContent
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
import com.fasterxml.jackson.module.kotlin.readValue
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import org.testng.Assert
import org.testng.annotations.DataProvider
import org.testng.annotations.Test
import java.io.File

class AsciiDoc {
	@DataProvider(name = "valids")
	fun valids(): Array<Array<*>> {
		val objectMapper = ObjectMapper(YAMLFactory()).registerKotlinModule()

		return listOf(
			"simple",
			"steeper",
			"luke",
			"table",
			"img",
			"single_label",
			"single_label_inline",
			"labels",
			"labels_inline"
		).map {
			arrayOf(
				File(this::class.java.getResource("/by/dev/madhead/doktor/render/AsciiDoc/valids/${it}.asc").toURI()),
				objectMapper.readValue<RenderedContent>(this::class.java.getResourceAsStream("/by/dev/madhead/doktor/render/AsciiDoc/valids/${it}.yml"))
			)
		}.toTypedArray()
	}

	@DataProvider(name = "invalids")
	fun invalids(): Array<Array<*>> {

		return listOf(
			"no_front_matter",
			"empty_front_matter",
			"no_title",
			"invalid_yaml"
		).map {
			arrayOf(
				File(this::class.java.getResource("/by/dev/madhead/doktor/render/AsciiDoc/invalids/${it}.asc").toURI())
			)
		}.toTypedArray()
	}

	@Test(dataProvider = "valids")
	fun valid(input: File, output: RenderedContent) {
		Assert.assertEquals(asciiDoc(input), output, "Unexpected output")
	}

	@Test(dataProvider = "invalids", expectedExceptions = arrayOf(RenderException::class))
	fun invalid(input: File) {
		asciiDoc(input)
	}
}


<|start_filename|>src/test/kotlin/by/dev/madhead/doktor/confluence/CreateAttachment.kt<|end_filename|>
package by.dev.madhead.doktor.confluence

import by.dev.madhead.doktor.model.Attachment
import org.testng.Assert
import org.testng.annotations.Test
import java.util.concurrent.TimeUnit

class CreateAttachment : BaseConfluenceTest() {
	@Test
	fun `anonymous, success`() {
		createAttachment(anonymousConfluence, "57005", Attachment("file.png", "bytes".toByteArray()))
			.test()
			.run {
				awaitTerminalEvent(10, TimeUnit.SECONDS)

				assertValue {
					Assert.assertEquals(it.results.size, 1)
					Assert.assertEquals(it.results[0].id, "att57005")

					true
				}
			}
	}

	@Test
	fun `identified, success`() {
		createAttachment(identifiedConfluence, "57006", Attachment("file.png", "bytes".toByteArray()))
			.test()
			.run {
				awaitTerminalEvent(10, TimeUnit.SECONDS)

				assertValue {
					Assert.assertEquals(it.results.size, 1)
					Assert.assertEquals(it.results[0].id, "att57006")

					true
				}
			}
	}

	@Test
	fun `anonymous, 400`() {
		createAttachment(anonymousConfluence, "57007", Attachment("file.png", "bytes".toByteArray()))
			.test()
			.run {
				awaitTerminalEvent(10, TimeUnit.SECONDS)

				assertError {
					it as ConfluenceException
					Assert.assertEquals(it.message, "The request was rejected because no multipart boundary was found")

					true
				}
			}
	}

	@Test
	fun `anonymous, 403`() {
		createAttachment(anonymousConfluence, "57008", Attachment("file.png", "bytes".toByteArray()))
			.test()
			.run {
				awaitTerminalEvent(10, TimeUnit.SECONDS)

				assertError {
					it as ConfluenceException
					Assert.assertEquals(it.message, "HTTP Exception 403 Forbidden")

					true
				}
			}
	}

	@Test
	fun `anonymous, 500`() {
		createAttachment(anonymousConfluence, "57009", Attachment("file.png", "bytes".toByteArray()))
			.test()
			.run {
				awaitTerminalEvent(10, TimeUnit.SECONDS)

				assertError {
					it as ConfluenceException
					Assert.assertEquals(it.message, "HTTP Exception 500 Server Error")

					true
				}
			}
	}
}


<|start_filename|>src/test/kotlin/by/dev/madhead/doktor/confluence/CreatePage.kt<|end_filename|>
package by.dev.madhead.doktor.confluence

import by.dev.madhead.doktor.model.confluence.*
import org.testng.Assert
import org.testng.annotations.Test
import java.util.concurrent.TimeUnit

class CreatePage : BaseConfluenceTest() {
	@Test
	fun `anonymous, top level, success`() {
		createPage(anonymousConfluence, CreatePageRequest(
			title = "anonymous, top level, success",
			space = SpaceReference("TEST"),
			body = Body(Storage("<h1>Test</h1>"))
		))
			.test()
			.run {
				awaitTerminalEvent(10, TimeUnit.SECONDS)

				assertValue {
					Assert.assertEquals(it.id, "57005")

					true
				}
			}
	}

	@Test
	fun `identified, top level, success`() {
		createPage(identifiedConfluence, CreatePageRequest(
			title = "identified, top level, success",
			space = SpaceReference("TEST"),
			body = Body(Storage("<h1>Test</h1>"))
		))
			.test()
			.run {
				awaitTerminalEvent(10, TimeUnit.SECONDS)

				assertValue {
					Assert.assertEquals(it.id, "57005")

					true
				}
			}
	}

	@Test
	fun `anonymous, child, success`() {
		createPage(anonymousConfluence, CreatePageRequest(
			title = "anonymous, child, success",
			ancestors = listOf(ContentReference("57004")),
			space = SpaceReference("TEST"),
			body = Body(Storage("<h1>Test</h1>"))
		))
			.test()
			.run {
				awaitTerminalEvent(10, TimeUnit.SECONDS)

				assertValue {
					Assert.assertEquals(it.id, "57005")

					true
				}
			}
	}

	@Test
	fun `anonymous, child, no ancestor`() {
		createPage(anonymousConfluence, CreatePageRequest(
			title = "anonymous, child, no ancestor",
			ancestors = listOf(ContentReference("57004")),
			space = SpaceReference("TEST"),
			body = Body(Storage("<h1>Test</h1>"))
		))
			.test()
			.run {
				awaitTerminalEvent(10, TimeUnit.SECONDS)

				assertError {
					it as ConfluenceException
					Assert.assertEquals(it.message, "The parent ID specified (ContentId{id=57004}) does not exist?")

					true
				}
			}
	}

	@Test
	fun `anonymous, top level, exist`() {
		createPage(anonymousConfluence, CreatePageRequest(
			title = "anonymous, top level, exist",
			space = SpaceReference("TEST"),
			body = Body(Storage("<h1>Test</h1>"))
		))
			.test()
			.run {
				awaitTerminalEvent(10, TimeUnit.SECONDS)

				assertError {
					it as ConfluenceException
					Assert.assertEquals(it.message, "A page with this title already exists: A page already exists with the title anonymous, top level, exist in the space with key TEST")

					true
				}
			}
	}

	@Test
	fun `anonymous, top level, 401`() {
		createPage(anonymousConfluence, CreatePageRequest(
			title = "anonymous, top level, 401",
			space = SpaceReference("TEST"),
			body = Body(Storage("<h1>Test</h1>"))
		))
			.test()
			.run {
				awaitTerminalEvent(10, TimeUnit.SECONDS)

				assertError {
					it as ConfluenceException
					Assert.assertEquals(it.message, "HTTP Exception 401 Unauthorized")

					true
				}
			}
	}

	@Test
	fun `anonymous, top level, 500`() {
		createPage(anonymousConfluence, CreatePageRequest(
			title = "anonymous, top level, 500",
			space = SpaceReference("TEST"),
			body = Body(Storage("<h1>Test</h1>"))
		))
			.test()
			.run {
				awaitTerminalEvent(10, TimeUnit.SECONDS)

				assertError {
					it as ConfluenceException
					Assert.assertEquals(it.message, "HTTP Exception 500 Server Error")

					true
				}
			}
	}
}


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/model/confluence/ContentReference.kt<|end_filename|>
package by.dev.madhead.doktor.model.confluence

data class ContentReference(
	val id: String
)


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/model/confluence/FindPageResponse.kt<|end_filename|>
package by.dev.madhead.doktor.model.confluence

import com.github.kittinunf.fuel.core.ResponseDeserializable
import com.google.gson.Gson
import java.io.Reader

data class FindPageResponse(
	val results: List<ContentReference>,
	val start: Int,
	val limit: Int,
	val size: Int
) {
	class Deserializer : ResponseDeserializable<FindPageResponse> {
		override fun deserialize(reader: Reader) = Gson().fromJson(reader, FindPageResponse::class.java)
	}
}


<|start_filename|>src/test/kotlin/by/dev/madhead/doktor/confluence/BaseConfluenceTest.kt<|end_filename|>
package by.dev.madhead.doktor.confluence

import by.dev.madhead.doktor.model.ResolvedConfluenceServer
import com.github.tomakehurst.wiremock.WireMockServer
import com.github.tomakehurst.wiremock.core.WireMockConfiguration
import org.testng.annotations.AfterClass
import org.testng.annotations.BeforeClass

open class BaseConfluenceTest {
	lateinit private var wiremock: WireMockServer
	lateinit protected var anonymousConfluence: ResolvedConfluenceServer
	lateinit protected var identifiedConfluence: ResolvedConfluenceServer

	@BeforeClass
	fun startWireMock() {
		wiremock = WireMockServer(
			WireMockConfiguration.options()
				.dynamicPort()
				.usingFilesUnderClasspath("by/dev/madhead/doktor/confluence/${this::class.simpleName}")
		)
		wiremock.start()
		anonymousConfluence = ResolvedConfluenceServer(
			"TEST",
			"http://localhost:${wiremock.port()}",
			"TEST",
			null,
			null
		)
		identifiedConfluence = ResolvedConfluenceServer(
			"TEST",
			"http://localhost:${wiremock.port()}",
			"TEST",
			"test",
			"test"
		)
	}

	@AfterClass
	fun shutdownWireMock() {
		wiremock.shutdown()
	}
}


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/render/asciidoc.kt<|end_filename|>
package by.dev.madhead.doktor.render

import by.dev.madhead.doktor.Messages
import by.dev.madhead.doktor.model.*
import by.dev.madhead.doktor.model.Markup.ASCIIDOC
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ArrayNode
import com.fasterxml.jackson.databind.node.TextNode
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import org.asciidoctor.Asciidoctor
import org.asciidoctor.AttributesBuilder
import org.asciidoctor.OptionsBuilder
import org.jruby.RubyInstanceConfig
import org.jruby.javasupport.JavaEmbedUtils
import java.io.File

fun asciiDoc(file: File): RenderedContent {
	// This crap is totally legal: https://github.com/asciidoctor/asciidoctorj#using-asciidoctorj-in-an-osgi-environment
	val config = RubyInstanceConfig()
	val classLoader = object : Any() {}::class.java.classLoader

	config.loader = classLoader
	JavaEmbedUtils.initialize(listOf(
		"META-INF/jruby.home/lib/ruby/2.0",
		"gems/asciidoctor-1.5.6.1/lib",
		"gems/asciidoctor-diagram-1.5.4.1/lib",
		"gems/asciidoctor-diagram-1.5.4.1/lib/asciidoctor-diagram/blockdiag",
		"gems/asciidoctor-diagram-1.5.4.1/lib/asciidoctor-diagram/ditaa",
		"gems/asciidoctor-diagram-1.5.4.1/lib/asciidoctor-diagram/graphviz",
		"gems/asciidoctor-diagram-1.5.4.1/lib/asciidoctor-diagram/mermaid",
		"gems/asciidoctor-diagram-1.5.4.1/lib/asciidoctor-diagram/plantuml"
	), config)

	val asciidoctor = Asciidoctor.Factory.create(classLoader)

	asciidoctor.requireLibrary("asciidoctor-diagram")

	try {
		val options = OptionsBuilder
			.options()
			.backend("xhtml")
			.baseDir(file.parentFile)
			.attributes(AttributesBuilder
				.attributes()
				.skipFrontMatter(true)
			)
		val documentStructure = asciidoctor.readDocumentStructure(file.readText(), options.asMap())
		val objectMapper = ObjectMapper(YAMLFactory()).registerKotlinModule()

		if (null == documentStructure.header.attributes["front-matter"]) {
			throw RenderException(Messages.doktor_render_RenderException_frontMatterRequired())
		}

		val frontMatter =
			try {
				objectMapper.readTree(documentStructure.header.attributes["front-matter"] as String) ?: throw RenderException(Messages.doktor_render_RenderException_frontMatterRequired())
			} catch (e: Throwable) {
				throw RenderException(Messages.doktor_render_RenderException_frontMatterInvalid())
			}

		return RenderedContent(
			ASCIIDOC,
			asciidoctor.convert(file.readText(), options),
			FrontMatter(
				title = frontMatter[FRONTMATTER_TITLE]?.asText() ?: throw RenderException(Messages.doktor_render_RenderException_titleRequired()),
				parent = frontMatter[FRONTMATTER_PARENT]?.asText(),
				labels = when (frontMatter[FRONTMATTER_LABELS]) {
					is TextNode -> listOf(frontMatter[FRONTMATTER_LABELS].asText())
					is ArrayNode -> frontMatter[FRONTMATTER_LABELS].map { it.asText() }
					else -> emptyList()
				}
			)
		)
	} finally {
		asciidoctor.shutdown()
	}
}


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/model/confluence/macros/RiAttachment.kt<|end_filename|>
package by.dev.madhead.doktor.model.confluence.macros

import kotlinx.html.HTMLTag
import kotlinx.html.HtmlBlockTag
import kotlinx.html.TagConsumer

class RI_ATTACHMENT(consumer: TagConsumer<*>) :
	HTMLTag(
		tagName = "ri:attachment",
		consumer = consumer,
		initialAttributes = emptyMap(),
		inlineTag = false,
		emptyTag = false
	), HtmlBlockTag {
	var riFileName: String
		get() = attributes["ri:filename"]!!
		set(value) {
			attributes["ri:filename"] = value
		}
}


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/model/confluence/SpaceReference.kt<|end_filename|>
package by.dev.madhead.doktor.model.confluence

data class SpaceReference(
	val key: String
)


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/confluence/addLabels.kt<|end_filename|>
package by.dev.madhead.doktor.confluence

import by.dev.madhead.doktor.model.ResolvedConfluenceServer
import by.dev.madhead.doktor.model.confluence.AddLabelsResponse
import by.dev.madhead.doktor.model.confluence.Labels
import by.dev.madhead.doktor.model.confluence.asJSON
import com.github.kittinunf.fuel.httpPost
import com.github.kittinunf.fuel.rx.rx_object
import com.github.kittinunf.result.Result
import com.google.gson.Gson
import io.reactivex.Single
import java.net.URL

fun addLabels(confluenceServer: ResolvedConfluenceServer, id: String, labels: Labels): Single<AddLabelsResponse> {
	return URL(URL(confluenceServer.url).toExternalForm() + "/rest/api/content/${id}/label").toString()
		.httpPost()
		.apply {
			if (!confluenceServer.user.isNullOrBlank()) {
				authenticate(confluenceServer.user!!, confluenceServer.password ?: "")
			}
		}
		.header("Content-Type" to "application/json")
		.body(labels.asJSON())
		.rx_object(AddLabelsResponse.Deserializer())
		.flatMap {
			when (it) {
				is Result.Success -> Single.just(it.value)
				is Result.Failure -> {
					try {
						val exception = Gson().fromJson(it.error.response.data.toString(Charsets.UTF_8), ConfluenceException::class.java)

						if (!exception.message.isBlank()) {
							Single.error<AddLabelsResponse>(exception)
						} else {
							throw IllegalArgumentException()
						}
					} catch (e: Throwable) {
						Single.error<AddLabelsResponse>(ConfluenceException(it.error.exception.message ?: it.error.message ?: it.error.response.responseMessage))
					}
				}
			}
		}
}


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/confluence/ConfluenceException.kt<|end_filename|>
package by.dev.madhead.doktor.confluence

class ConfluenceException(override val message: String, cause: Throwable?) : Throwable(message, cause) {
	constructor(message: String) : this(message, null)
}


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/util/fs/WorkspaceDokLister.kt<|end_filename|>
package by.dev.madhead.doktor.util.fs

import by.dev.madhead.doktor.model.Dok
import by.dev.madhead.doktor.model.DoktorConfig
import hudson.FilePath
import hudson.remoting.VirtualChannel
import jenkins.SlaveToMasterFileCallable
import java.io.File
import java.nio.file.Files

class WorkspaceDokLister(val doktorConfig: DoktorConfig) : SlaveToMasterFileCallable<List<Dok>>() {
	override fun invoke(file: File, channel: VirtualChannel?): List<Dok> {
		val workspace = file.toPath()
		val result = mutableListOf<Dok>()

		Files.walkFileTree(
			workspace,
			WorkspaceFileVisitor(
				workspace,
				doktorConfig.markupPatterns
			) { path, markup ->
				result.add(
					Dok(
						FilePath(channel, path.toString()),
						markup
					)
				)
			}
		)

		return result
	}
}


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/config/ConfluenceServers.kt<|end_filename|>
package by.dev.madhead.doktor.config

import by.dev.madhead.doktor.Messages
import by.dev.madhead.doktor.model.ResolvedConfluenceServer
import com.cloudbees.plugins.credentials.CredentialsMatchers
import com.cloudbees.plugins.credentials.CredentialsProvider
import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials
import hudson.AbortException
import hudson.Extension
import hudson.security.ACL
import jenkins.model.GlobalConfiguration
import jenkins.model.Jenkins
import net.sf.json.JSONArray
import net.sf.json.JSONObject
import org.kohsuke.stapler.StaplerRequest

@Extension(ordinal = -100.0)
class ConfluenceServers : GlobalConfiguration() {
	var servers: List<ConfluenceServer> = emptyList()
		set(value) {
			field = value
			save()
		}

	init {
		load()
	}

	override fun configure(req: StaplerRequest, json: JSONObject?): Boolean {
		validate(json)
		req.bindJSON(this, json)

		return true
	}

	private fun validate(json: JSONObject?) {
		if ((null == json) || (null == json["servers"]) || (json["servers"] is JSONObject)) {
			return
		}
		if ((json["servers"] as JSONArray).groupBy { (it as JSONObject)["name"] }.any { it.value.size > 1 }) {
			throw FormException(Messages.doktor_config_ConfluenceServers_validation_name_dublicate(), "")
		}
	}

	companion object {
		fun resolve(server: String): ResolvedConfluenceServer {
			val confluenceServer = GlobalConfiguration.all().get(ConfluenceServers::class.java)?.servers?.find { it.name == server }
				?: throw AbortException(Messages.doktor_hudson_AbortException_unknownConfluenceServer())

			val credentials = if (!confluenceServer.credentials.isNullOrBlank()) {
				CredentialsMatchers.firstOrNull(
					CredentialsProvider.lookupCredentials(
						StandardUsernamePasswordCredentials::class.java,
						Jenkins.getInstance(),
						ACL.SYSTEM,
						emptyList()
					),
					CredentialsMatchers.withId(confluenceServer.credentials!!)
				) ?: throw AbortException(Messages.doktor_hudson_AbortException_unknownCredentials())
			} else {
				null
			}

			return ResolvedConfluenceServer(
				server,
				confluenceServer.url,
				confluenceServer.space,
				credentials?.username,
				credentials?.password?.plainText
			)
		}
	}
}


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/model/confluence/CreatePageRequest.kt<|end_filename|>
package by.dev.madhead.doktor.model.confluence

import com.google.gson.Gson

data class CreatePageRequest(
	val type: String = "page",
	val title: String,
	val ancestors: List<ContentReference>? = null,
	val space: SpaceReference,
	val body: Body
) {
	fun asJSON() = Gson().toJson(this)
}


<|start_filename|>src/main/kotlin/by/dev/madhead/doktor/classic/DoktorStep.kt<|end_filename|>
package by.dev.madhead.doktor.classic

import by.dev.madhead.doktor.Messages
import by.dev.madhead.doktor.config.ConfluenceServers
import by.dev.madhead.doktor.model.DescribableString
import by.dev.madhead.doktor.model.DoktorConfig
import by.dev.madhead.doktor.model.Markup.ASCIIDOC
import by.dev.madhead.doktor.model.Markup.MARKDOWN
import by.dev.madhead.doktor.util.diagnose
import hudson.AbortException
import hudson.Extension
import hudson.Launcher
import hudson.model.AbstractBuild
import hudson.model.AbstractProject
import hudson.model.BuildListener
import hudson.tasks.BuildStepDescriptor
import hudson.tasks.Builder
import hudson.util.ListBoxModel
import jenkins.model.GlobalConfiguration
import org.kohsuke.stapler.DataBoundConstructor

class DoktorStep
@DataBoundConstructor
constructor(
	val server: String,
	var markdownIncludePatterns: List<DescribableString>?,
	var markdownExcludePatterns: List<DescribableString>?,
	var asciidocIncludePatterns: List<DescribableString>?,
	var asciidocExcludePatterns: List<DescribableString>?
) : Builder() {
	override fun perform(build: AbstractBuild<*, *>, launcher: Launcher, listener: BuildListener?): Boolean {
		try {
			diagnose(
				DoktorConfig(
					ConfluenceServers.resolve(server),
					mapOf(
						MARKDOWN to Pair(markdownIncludePatterns?.map { it.value } ?: emptyList(), markdownExcludePatterns?.map { it.value } ?: emptyList()),
						ASCIIDOC to Pair(asciidocIncludePatterns?.map { it.value } ?: emptyList(), asciidocExcludePatterns?.map { it.value } ?: emptyList())
					)
				),
				build.getWorkspace() ?: throw AbortException(Messages.doktor_hudson_AbortException_workspaceRequired()),
				listener ?: throw AbortException(Messages.doktor_hudson_AbortException_taskListenerRequired())
			).blockingAwait()

			return true
		} catch (exception: Throwable) {
			throw AbortException(exception.message)
		}
	}

	@Extension
	class DoktorStepDescriptor : BuildStepDescriptor<Builder>() {
		override fun getDisplayName() = Messages.doktor_classic_DoktorStep_displayName()

		override fun isApplicable(jobType: Class<out AbstractProject<*, *>>): Boolean {
			return true
		}

		fun doFillServerItems(): ListBoxModel {
			val result = ListBoxModel()

			GlobalConfiguration.all().get(ConfluenceServers::class.java)?.servers?.forEach {
				result.add("${it.name} (${it.url})", it.name)
			}

			return result
		}
	}
}
