Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ trait BacklogAPIClient extends BacklogClient {

def importWiki(params: ImportWikiParams): Wiki

def importDocument(jsonBody: String): String

def importUpdateDocumentContent(documentId: String, jsonBody: String): Unit

def importDocumentComment(documentId: String, jsonBody: String): String

def addRateLimitEventListener(listener: RateLimitEventListener): Unit

def removeRateLimitEventListener(listener: RateLimitEventListener): Unit
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
package com.nulabinc.backlog.migration.common.client

import java.net.http.{
HttpClient => JHttpClient,
HttpRequest => JHttpRequest,
HttpResponse => JHttpResponse
}
import java.net.{URI, URLEncoder}
import java.nio.charset.StandardCharsets
import java.time.Duration
import java.util
import java.util.Date

import com.nulabinc.backlog.migration.common.client.params._
import com.nulabinc.backlog.migration.common.conf.BacklogConfiguration
Expand Down Expand Up @@ -30,6 +39,32 @@ object IAAH {
val empty: IAAH = IAAH("")
}

private class JsonBacklogHttpResponse(response: JHttpResponse[String])
extends BacklogHttpResponse {
override def getStatusCode: Int = response.statusCode()

override def getRateLimitLimit: Int =
response.headers().firstValueAsLong("X-RateLimit-Limit").orElse(0L).toInt

override def getRateLimitRemaining: Int =
response.headers().firstValueAsLong("X-RateLimit-Remaining").orElse(0L).toInt

override def getRateLimitResetDate: Date = {
val reset = response.headers().firstValueAsLong("X-RateLimit-Reset")
if (reset.isPresent) new Date(reset.getAsLong * 1000) else null
}

override def getRateLimitReset: String =
response.headers().firstValue("X-RateLimit-Reset").orElse(null)

override def asInputStream(): java.io.InputStream =
new java.io.ByteArrayInputStream(response.body().getBytes(StandardCharsets.UTF_8))

override def asString(): String = response.body()

override def getFileNameFromContentDisposition: String = null
}

class BacklogAPIClientImpl(configure: BacklogConfigure, iaah: IAAH)
extends BacklogClientImpl(configure, BacklogAPIClientImpl.create)
with BacklogAPIClient
Expand Down Expand Up @@ -76,6 +111,38 @@ class BacklogAPIClientImpl(configure: BacklogConfigure, iaah: IAAH)
factory.importWiki(post(buildEndpoint("wikis/import"), params.getParamList, headers))
}

// Document import APIs require a JSON request body (unlike the form-urlencoded
// params used by importWiki/importIssue), which backlog4j's BacklogHttpClient
// cannot send. Talk to these endpoints directly instead.
private val jsonHttpClient: JHttpClient =
JHttpClient
.newBuilder()
.connectTimeout(Duration.ofMillis(configure.getConnectionTimeout))
.build()

private def sendJson(method: String, endpoint: String, jsonBody: String): String = {
val uriSeparator = if (endpoint.contains("?")) "&" else "?"
val apiKeyParam = URLEncoder.encode(configure.getApiKey, StandardCharsets.UTF_8.name())
val request = JHttpRequest
.newBuilder()
.uri(URI.create(s"$endpoint$uriSeparator" + s"apiKey=$apiKeyParam"))
.timeout(Duration.ofMillis(configure.getReadTimeout))
.header("Content-Type", "application/json; charset=UTF-8")
.header("iaah", iaah.value)
.method(method, JHttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8))
.build()
val response =
jsonHttpClient.send(request, JHttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8))
val statusCode = response.statusCode()
if (statusCode < 200 || statusCode >= 300) {
val message =
if (statusCode == rateLimitStatusCode) "The API usage limit has been exceeded."
else "backlog api request failed."
throw new BacklogAPIException(message, new JsonBacklogHttpResponse(response))
}
response.body()
}

override def importIssue(params: ImportIssueParams): Issue = retryRateLimit() {
client.importIssue(params)
}
Expand All @@ -96,6 +163,21 @@ class BacklogAPIClientImpl(configure: BacklogConfigure, iaah: IAAH)
client.importWiki(params)
}

override def importDocument(jsonBody: String): String = retryRateLimit() {
sendJson("POST", buildEndpoint("documents/import"), jsonBody)
}

override def importUpdateDocumentContent(documentId: String, jsonBody: String): Unit =
retryRateLimit() {
sendJson("PATCH", buildEndpoint(s"documents/$documentId/content/import"), jsonBody)
()
}

override def importDocumentComment(documentId: String, jsonBody: String): String =
retryRateLimit() {
sendJson("POST", buildEndpoint(s"documents/$documentId/comments/import"), jsonBody)
}

override def delete(
endpoint: String,
parameters: util.List[NameValuePair]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,22 @@ package com.nulabinc.backlog.migration.common.service

import java.io.InputStream

import com.nulabinc.backlog.migration.common.domain.{BacklogDocument, BacklogDocumentTree}
import com.nulabinc.backlog.migration.common.domain.{
BacklogAttachment,
BacklogDocument,
BacklogDocumentComment,
BacklogDocumentTag,
BacklogDocumentTree
}

// Counts of what rewriteIssueMentions did to the issueMention nodes in one
// document. total = rewritten + skippedExternalProject + unresolved.
final case class IssueMentionRewriteStats(
total: Int,
rewritten: Int,
skippedExternalProject: Int,
unresolved: Int
)

/**
* @author
Expand All @@ -23,4 +38,83 @@ trait DocumentService {
attachmentId: Long
): Option[(String, InputStream)]

def create(
projectId: Long,
document: BacklogDocument,
optParentId: Option[String],
addLast: Boolean,
isTrash: Boolean,
propertyResolver: PropertyResolver
): String

def updateContent(
documentId: String,
document: BacklogDocument,
propertyResolver: PropertyResolver
): Unit

def addComment(
documentId: String,
comment: BacklogDocumentComment,
propertyResolver: PropertyResolver
): Either[Throwable, String]

def addAttachment(
documentId: String,
path: String
): Either[Throwable, BacklogAttachment]

def addTags(
documentId: String,
tagNames: Seq[String]
): Either[Throwable, Seq[BacklogDocumentTag]]

// The document body (ProseMirror JSON) anchors each inline comment to a
// range of text via an `inlineComment` mark carrying the comment's id
// (`{"type":"inlineComment","attrs":{"comment":{"id":"...","statusId":...}}}`).
// That id is only valid within the source space, so it must be rewritten to
// the id assigned when the comment was recreated at the destination,
// otherwise the app can't resolve the mark and the comment isn't shown as
// linked to the document.
def rewriteInlineCommentIds(
document: BacklogDocument,
commentIdMap: Map[String, String]
): BacklogDocument

// The document body (ProseMirror JSON) can embed an `issueMention` node
// carrying a snapshot of a referenced issue's source-space key, numeric
// id, and project key/id
// (`{"type":"issueMention","attrs":{"id":"PROJ-1","label":"...",
// "mentionType":"inline","projectKey":"PROJ","projectId":1,"issueId":2}}`;
// `issueId`/`projectId` are sometimes absent from real data). Mentions of
// issues in the source project must be rewritten to the key/id assigned
// when that issue was recreated at the destination, and to the
// destination project's key/id, otherwise the app can't resolve the
// mention. Mentions of any other project are left completely untouched,
// since this tool migrates one project per run and has no mapping data
// for other projects. When a same-project mention can't be resolved (the
// issue wasn't found in either map, e.g. it was deleted at the source or
// failed to migrate), the mention is left as-is and a warning is logged
// rather than failing the migration.
//
// The same snapshot is duplicated as a bracket tag in the document's plain
// text mirror (`optPlain`), e.g. `[issueMention id="PROJ-1" label="..."
// mentionType="inline" projectKey="PROJ" projectId="1" issueId="2"]`. Every
// mention actually rewritten in the JSON has its exact old/new tag text
// substituted into `optPlain` too (via literal substring replacement, not
// regex, since labels may contain unescaped `[`/`]`), so the plain-text
// mirror doesn't keep pointing at the source space after migration. If the
// expected old tag text can't be found in `optPlain` (e.g. it drifted from
// the JSON), that mention's plain text is left unchanged and a warning is
// logged, again without failing the migration.
def rewriteIssueMentions(
document: BacklogDocument,
issueIdMap: Map[Long, Long],
issueKeyMap: Map[String, String],
srcProjectId: Long,
srcProjectKey: String,
dstProjectId: Long,
dstProjectKey: String
): (BacklogDocument, IssueMentionRewriteStats)

}
Loading
Loading