✨ feat(jetbrains): 状态评论改用隐藏格式读写,合并时保留未建模字段
This commit is contained in:
1 parent
38ca193434
commit
64d5961b62
6 files changed
+267
-60
No files matched your search
@@ -55,3 +55,11 @@ intellijPlatform {
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
}
|
||||
|
||||
// 已知状态字段从 VS Code 插件、spx CLI 共用的 [状态 schema](vscode/schemas/state-json.schema.json) 派生:
|
||||
// 构建时把它打进资源,和 [状态评论编解码器](jetbrains/src/main/kotlin/com/cruldra/superworkbench/model/IssueStateJson.kt#IssueStateJson) 同包,不另存副本。
|
||||
tasks.processResources {
|
||||
from("../vscode/schemas/state-json.schema.json") {
|
||||
into("com/cruldra/superworkbench/model")
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,8 @@ class IssueLoader(
|
||||
column = defaultColumnForState(issue.state)
|
||||
// 持久化默认列,后续加载即可直接命中;失败非致命,仍以计算出的列展示。
|
||||
runCatching {
|
||||
api.postIssueComment(owner, repo, number, IssueStateJson.encode(IssueState(column = column)))
|
||||
val seed = IssueStateJson.toJsonObject(IssueState(column = column))
|
||||
api.postIssueComment(owner, repo, number, IssueStateJson.formatComment(seed))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,36 +2,31 @@ package com.cruldra.superworkbench.gitea
|
||||
|
||||
import com.cruldra.superworkbench.model.IssueState
|
||||
import com.cruldra.superworkbench.model.IssueStateJson
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* 读写 issue 评论里的工单状态 JSON,对应源项目 `src/gitea/stateJson.ts` 与
|
||||
* 读写 issue 评论里的工单状态,对应源项目 `src/gitea/stateJson.ts` 与
|
||||
* `src/issues/stateRouter.ts` 的 gitea 分支(YouTrack 分支不迁移)。
|
||||
*
|
||||
* 约定:issue 的状态 blob 存在某条评论正文里。从评论列表尾部往前扫,第一条能被
|
||||
* [IssueStateJson.decode] 成功解析的评论即视为当前 state——这样中途插入的普通文本/
|
||||
* 审查/PR 自动关联评论不会顶替真正的 state 评论。
|
||||
* 约定:从评论列表尾部往前扫,最新一条 [IssueStateJson.parseComment] 认得的评论就是当前状态,
|
||||
* 中途插入的普通文本、审查、PR 自动关联评论都跳过,顶替不了真正的状态评论。
|
||||
*/
|
||||
object IssueStateStore {
|
||||
/**
|
||||
* 取 issue 评论列表,从尾往前找第一条能解析为 [IssueState] 的评论并返回。
|
||||
* 找不到返回 null。对应 `readStateJsonComment` 的 gitea 分支。
|
||||
* 取 issue 评论列表,返回最新的状态;找不到返回 null。对应 `readStateJsonComment` 的 gitea 分支。
|
||||
*/
|
||||
fun readIssueState(
|
||||
api: GiteaApi,
|
||||
owner: String,
|
||||
repo: String,
|
||||
issueNumber: Long,
|
||||
): IssueState? {
|
||||
val comments = api.listIssueComments(owner, repo, issueNumber)
|
||||
return findLatestState(comments)
|
||||
}
|
||||
): IssueState? = findLatestState(api.listIssueComments(owner, repo, issueNumber))
|
||||
|
||||
/**
|
||||
* 读现有 state,用 [patch] 经 [IssueStateJson.merge] 合并后 encode 写回。
|
||||
* 把 [patch] 合并进当前状态,作为一条新状态评论发出去。
|
||||
*
|
||||
* 与源项目 `mergeStateJsonComment` 一致:合并结果始终以**新评论**形式 post,由
|
||||
* loader/读取侧的「从尾往前取最近一条 state 评论」语义保证后写优先;GiteaApi 无需
|
||||
* 编辑评论方法。无现有 state 时从空 [IssueState] 起步。
|
||||
* 与源项目 `mergeStateJsonComment` 一致:总是发新评论,由读取侧「取最新一条状态评论」保证后写优先,
|
||||
* [GiteaApi] 不需要编辑评论的方法。
|
||||
*/
|
||||
fun mergeIssueState(
|
||||
api: GiteaApi,
|
||||
@@ -40,26 +35,25 @@ object IssueStateStore {
|
||||
issueNumber: Long,
|
||||
patch: IssueState,
|
||||
) {
|
||||
val current = readIssueState(api, owner, repo, issueNumber) ?: IssueState()
|
||||
val merged = IssueStateJson.merge(current, patch)
|
||||
api.postIssueComment(owner, repo, issueNumber, IssueStateJson.encode(merged))
|
||||
val comments = api.listIssueComments(owner, repo, issueNumber)
|
||||
api.postIssueComment(owner, repo, issueNumber, mergedStateComment(comments, patch))
|
||||
}
|
||||
|
||||
/**
|
||||
* 从尾往前找最近一条 state 评论,找不到返回 null。
|
||||
* 算出 [patch] 合并进最新状态后要发的评论正文。
|
||||
*
|
||||
* 仅当解析出的 [IssueState] 至少携带一个非空已知字段时才认定为 state——对齐源项目
|
||||
* `hasKnownField` / `'column' in candidate` 的判定,避免把 `{}` 或普通 JSON 内容
|
||||
* 评论(lenient 解码会得到全空 [IssueState])误判为 state 而顶替真正的状态。
|
||||
* 在 JSON 对象层面合并,[IssueState] 没建模的字段(其它端写的会话 id、移交信息等)原样带上,不会被这次写入抹掉;
|
||||
* 没有状态评论时从空对象起步。
|
||||
*/
|
||||
internal fun findLatestState(comments: List<GiteaComment>): IssueState? {
|
||||
val empty = IssueState()
|
||||
for (i in comments.indices.reversed()) {
|
||||
val body = comments[i].body.trim()
|
||||
if (body.isEmpty()) continue
|
||||
val decoded = IssueStateJson.decode(body) ?: continue
|
||||
if (decoded != empty) return decoded
|
||||
}
|
||||
return null
|
||||
internal fun mergedStateComment(comments: List<GiteaComment>, patch: IssueState): String {
|
||||
val current = findLatestStateObject(comments) ?: JsonObject(emptyMap())
|
||||
return IssueStateJson.formatComment(JsonObject(current + IssueStateJson.toJsonObject(patch)))
|
||||
}
|
||||
|
||||
/** 从尾往前找最新一条状态评论并读成 [IssueState],找不到返回 null。 */
|
||||
internal fun findLatestState(comments: List<GiteaComment>): IssueState? =
|
||||
findLatestStateObject(comments)?.let(IssueStateJson::decode)
|
||||
|
||||
private fun findLatestStateObject(comments: List<GiteaComment>): JsonObject? =
|
||||
comments.asReversed().firstNotNullOfOrNull { IssueStateJson.parseComment(it.body) }
|
||||
}
|
||||
@@ -1,44 +1,87 @@
|
||||
package com.cruldra.superworkbench.model
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
|
||||
/**
|
||||
* 工单状态 JSON 的编解码器:从 issue comment 正文解析 [IssueState]、序列化回字符串、
|
||||
* 以及把 patch 合并进 base。
|
||||
* 工单状态评论的编解码器:评论正文和状态 JSON 对象互转,状态 JSON 对象和 [IssueState] 互转。
|
||||
*
|
||||
* 写出的评论是隐藏格式 `<!-- spx-state\n{紧凑 JSON}\n-->`,渲染 Markdown 的评论区看不到;
|
||||
* 读写规则与 VS Code 插件、spx CLI、superwork-tui 一致,几端读写同一批工单评论。
|
||||
*/
|
||||
object IssueStateJson {
|
||||
private val json = Json {
|
||||
private const val HIDDEN_OPEN = "<!-- spx-state"
|
||||
private const val HIDDEN_CLOSE = "-->"
|
||||
|
||||
/** YouTrack 集成写的旧状态评论前缀,后面直接跟 JSON。 */
|
||||
private const val LEGACY_MARKER = "<!--spx-state-->"
|
||||
|
||||
/** [构建脚本](jetbrains/build.gradle.kts) 打进本包的 [状态 schema](vscode/schemas/state-json.schema.json)。 */
|
||||
private const val SCHEMA_RESOURCE = "state-json.schema.json"
|
||||
|
||||
/** 评论正文按严格 JSON 解析,和其它端认同一批正文。 */
|
||||
private val strictJson = Json
|
||||
|
||||
/** 状态对象和 [IssueState] 互转:容忍未知字段和不认识的枚举值,序列化时省掉空字段。 */
|
||||
private val stateJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
isLenient = true
|
||||
encodeDefaults = false
|
||||
explicitNulls = false
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
/** 解析 comment 正文为 [IssueState],解析失败返回 null。 */
|
||||
fun decode(commentBody: String): IssueState? =
|
||||
runCatching { json.decodeFromString<IssueState>(commentBody) }.getOrNull()
|
||||
/** schema 顶层声明的字段;JSON 对象至少带一个才算状态评论,普通 JSON 评论因此不会被当成状态。 */
|
||||
private val knownFields: Set<String> by lazy { loadSchemaFields() }
|
||||
|
||||
/** 把 [state] 序列化为 JSON 字符串(省略默认/空字段)。 */
|
||||
fun encode(state: IssueState): String = json.encodeToString(state)
|
||||
/**
|
||||
* 取出评论正文携带的状态对象,不是状态评论就返回 null。
|
||||
*
|
||||
* 认三种写法:[formatComment] 写的隐藏注释、YouTrack 的 `<!--spx-state-->` 前缀、老版本留下的裸
|
||||
* JSON 对象;JSON 必须是对象且带至少一个 schema 字段,审查评论等其它 JSON 一律不算。
|
||||
*/
|
||||
fun parseComment(commentBody: String): JsonObject? {
|
||||
val text = commentBody.trim()
|
||||
val payload = when {
|
||||
text.startsWith(HIDDEN_OPEN) && text.endsWith(HIDDEN_CLOSE) ->
|
||||
text.removePrefix(HIDDEN_OPEN).removeSuffix(HIDDEN_CLOSE)
|
||||
text.startsWith(LEGACY_MARKER) -> text.removePrefix(LEGACY_MARKER)
|
||||
else -> text
|
||||
}
|
||||
val obj = runCatching { strictJson.parseToJsonElement(payload.trim()) }.getOrNull() as? JsonObject
|
||||
?: return null
|
||||
return obj.takeIf { it.keys.any(knownFields::contains) }
|
||||
}
|
||||
|
||||
/** 用 [patch] 的非空字段覆盖 [base],返回合并后的新 state。 */
|
||||
fun merge(base: IssueState, patch: IssueState): IssueState = base.copy(
|
||||
column = patch.column ?: base.column,
|
||||
sessionId = patch.sessionId ?: base.sessionId,
|
||||
implementSessionId = patch.implementSessionId ?: base.implementSessionId,
|
||||
reviewSessionId = patch.reviewSessionId ?: base.reviewSessionId,
|
||||
testSessionId = patch.testSessionId ?: base.testSessionId,
|
||||
profilePath = patch.profilePath ?: base.profilePath,
|
||||
specFile = patch.specFile ?: base.specFile,
|
||||
planFile = patch.planFile ?: base.planFile,
|
||||
prDiffFile = patch.prDiffFile ?: base.prDiffFile,
|
||||
pr = patch.pr ?: base.pr,
|
||||
prMerged = patch.prMerged ?: base.prMerged,
|
||||
branch = patch.branch ?: base.branch,
|
||||
worktreePath = patch.worktreePath ?: base.worktreePath,
|
||||
implementStatus = patch.implementStatus ?: base.implementStatus,
|
||||
color = patch.color ?: base.color,
|
||||
autoReview = patch.autoReview ?: base.autoReview,
|
||||
)
|
||||
/**
|
||||
* 把 [state] 写成隐藏状态评论的正文。
|
||||
*
|
||||
* `<`、`>`、`&` 转成 `<`、`>`、`&`,值里带 `-->` 也不会提前把注释关掉。
|
||||
*/
|
||||
fun formatComment(state: JsonObject): String {
|
||||
val payload = strictJson.encodeToString(JsonObject.serializer(), state)
|
||||
.replace("<", "\\u003c")
|
||||
.replace(">", "\\u003e")
|
||||
.replace("&", "\\u0026")
|
||||
return "$HIDDEN_OPEN\n$payload\n$HIDDEN_CLOSE"
|
||||
}
|
||||
|
||||
/** 把 [state] 转成只含非空字段的状态对象,合并时只覆盖它带的字段。 */
|
||||
fun toJsonObject(state: IssueState): JsonObject = stateJson.encodeToJsonElement(state).jsonObject
|
||||
|
||||
/** 把状态对象读成 [IssueState],字段类型不对时返回 null。 */
|
||||
fun decode(state: JsonObject): IssueState? =
|
||||
runCatching { stateJson.decodeFromJsonElement<IssueState>(state) }.getOrNull()
|
||||
|
||||
private fun loadSchemaFields(): Set<String> {
|
||||
val text = IssueStateJson::class.java.getResourceAsStream(SCHEMA_RESOURCE)
|
||||
?.use { it.readBytes().decodeToString() }
|
||||
?: error("缺少状态 schema 资源 $SCHEMA_RESOURCE")
|
||||
val fields = strictJson.parseToJsonElement(text).jsonObject.getValue("properties").jsonObject.keys
|
||||
check(fields.isNotEmpty()) { "状态 schema 没有声明任何字段" }
|
||||
return fields
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.cruldra.superworkbench.gitea
|
||||
|
||||
import com.cruldra.superworkbench.model.IssueColumn
|
||||
import com.cruldra.superworkbench.model.IssueState
|
||||
import com.cruldra.superworkbench.model.IssueStateJson
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class IssueStateStoreTest {
|
||||
private fun comments(vararg bodies: String) = bodies.map { GiteaComment(body = it) }
|
||||
|
||||
@Test
|
||||
fun findLatestStateTakesNewestValidCommentAcrossFormats() {
|
||||
val list = comments(
|
||||
"{\"column\":\"todo\",\"branch\":\"feat/old\"}",
|
||||
"<!-- spx-state\n{\"column\":\"review\",\"branch\":\"feat/new\"}\n-->",
|
||||
"<!-- spx-state\n{\"foo\":\"bar\"}\n-->",
|
||||
"just chatter",
|
||||
)
|
||||
val state = IssueStateStore.findLatestState(list)
|
||||
assertEquals(IssueColumn.REVIEW, state?.column)
|
||||
assertEquals("feat/new", state?.branch)
|
||||
|
||||
val newer = list + comments("<!--spx-state-->\n{\"column\":\"done\"}")
|
||||
assertEquals(IssueColumn.DONE, IssueStateStore.findLatestState(newer)?.column)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun findLatestStateReturnsNullWithoutStateComment() {
|
||||
assertNull(IssueStateStore.findLatestState(comments("hello", "{\"foo\":1}", "[1,2]")))
|
||||
assertNull(IssueStateStore.findLatestState(emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergedStateCommentKeepsFieldsOutsideIssueStateAndWritesHiddenFormat() {
|
||||
val list = comments("{\"column\":\"todo\",\"prototypeSessionId\":\"proto-1\",\"handoffBundleKey\":\"k\"}")
|
||||
val body = IssueStateStore.mergedStateComment(list, IssueState(column = IssueColumn.REVIEW, pr = "7"))
|
||||
|
||||
assertTrue(body, body.startsWith("<!-- spx-state\n"))
|
||||
val merged = IssueStateJson.parseComment(body)
|
||||
assertNotNull(body, merged)
|
||||
assertEquals(JsonPrimitive("review"), merged!!["column"])
|
||||
assertEquals(JsonPrimitive("7"), merged["pr"])
|
||||
assertEquals(JsonPrimitive("proto-1"), merged["prototypeSessionId"])
|
||||
assertEquals(JsonPrimitive("k"), merged["handoffBundleKey"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergedStateCommentStartsFromEmptyWithoutPriorState() {
|
||||
val body = IssueStateStore.mergedStateComment(comments("hi"), IssueState(sessionId = "s1"))
|
||||
assertEquals(mapOf("sessionId" to JsonPrimitive("s1")), IssueStateJson.parseComment(body))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.cruldra.superworkbench.model
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
class IssueStateJsonTest {
|
||||
@Test
|
||||
fun parseCommentAcceptsHiddenLegacyAndBareForms() {
|
||||
val cases = mapOf(
|
||||
"hidden" to "<!-- spx-state\n{\"column\":\"review\",\"pr\":\"7\"}\n-->",
|
||||
"hidden padded" to "\n <!-- spx-state\n{\"column\":\"review\",\"pr\":\"7\"}\n--> \n",
|
||||
"hidden single line" to "<!-- spx-state {\"column\":\"review\",\"pr\":\"7\"} -->",
|
||||
"legacy youtrack" to "<!--spx-state-->\n{\"column\":\"review\",\"pr\":\"7\"}",
|
||||
"bare json" to "{\"column\":\"review\",\"pr\":\"7\"}",
|
||||
"bare json padded" to " {\"column\":\"review\",\"pr\":\"7\"}\n",
|
||||
"hidden escaped html" to
|
||||
"<!-- spx-state\n{\"column\":\"review\",\"pr\":\"7\",\"branch\":\"a\\u003c\\u003e\\u0026b\"}\n-->",
|
||||
)
|
||||
for ((name, body) in cases) {
|
||||
val obj = IssueStateJson.parseComment(body)
|
||||
assertNotNull("$name should parse", obj)
|
||||
assertEquals(name, JsonPrimitive("review"), obj!!["column"])
|
||||
assertEquals(name, JsonPrimitive("7"), obj["pr"])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseCommentRejectsNonStateBodies() {
|
||||
val cases = mapOf(
|
||||
"empty" to "",
|
||||
"whitespace" to " \n ",
|
||||
"plain text" to "LGTM, merging",
|
||||
"unknown fields only" to "{\"foo\":\"bar\"}",
|
||||
"array" to "[{\"column\":\"todo\"}]",
|
||||
"scalar" to "\"column\"",
|
||||
"lenient unquoted" to "{column: todo}",
|
||||
"hidden unknown only" to "<!-- spx-state\n{\"foo\":\"bar\"}\n-->",
|
||||
"hidden broken json" to "<!-- spx-state\n{\"column\":\n-->",
|
||||
"hidden unterminated" to "<!-- spx-state\n{\"column\":\"todo\"}",
|
||||
"other html comment" to "<!-- spx:review=1 -->\n{\"column\":\"todo\"}",
|
||||
"legacy marker no object" to "<!--spx-state-->\nnot json",
|
||||
)
|
||||
for ((name, body) in cases) {
|
||||
assertNull("$name should be rejected", IssueStateJson.parseComment(body))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseCommentRecognizesEverySchemaField() {
|
||||
val schema = Json.parseToJsonElement(File(SOURCE_SCHEMA).readText()).jsonObject
|
||||
val fields = schema.getValue("properties").jsonObject.keys
|
||||
assertTrue("schema should declare properties", fields.isNotEmpty())
|
||||
for (field in fields) {
|
||||
assertNotNull("$field should mark a state comment", IssueStateJson.parseComment("{\"$field\":\"x\"}"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun formatCommentWritesHiddenCompactEscapedJson() {
|
||||
val state = JsonObject(
|
||||
mapOf(
|
||||
"column" to JsonPrimitive("review"),
|
||||
"branch" to JsonPrimitive("feat/a-->b<c>&d"),
|
||||
),
|
||||
)
|
||||
val body = IssueStateJson.formatComment(state)
|
||||
|
||||
assertTrue(body, body.startsWith("<!-- spx-state\n"))
|
||||
assertTrue(body, body.endsWith("\n-->"))
|
||||
val payload = body.removePrefix("<!-- spx-state\n").removeSuffix("\n-->")
|
||||
assertFalse(payload, payload.any { it == '<' || it == '>' || it == '&' })
|
||||
assertFalse(payload, payload.contains('\n'))
|
||||
assertEquals(body, 1, Regex("-->").findAll(body).count())
|
||||
assertEquals(state, IssueStateJson.parseComment(body))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toJsonObjectDropsNullFields() {
|
||||
val obj = IssueStateJson.toJsonObject(IssueState(column = IssueColumn.REVIEW, pr = "7"))
|
||||
assertEquals(setOf("column", "pr"), obj.keys)
|
||||
assertEquals(JsonPrimitive("review"), obj["column"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodeIgnoresUnknownFieldsAndBadEnumValues() {
|
||||
val obj = IssueStateJson.parseComment("{\"column\":\"archived\",\"pr\":\"7\",\"handoffFrom\":\"bob\"}")!!
|
||||
val state = IssueStateJson.decode(obj)
|
||||
assertEquals(IssueState(pr = "7"), state)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** 与 VS Code 插件、spx CLI 共用的状态 schema,相对 Gradle 测试工作目录(`jetbrains/`)。 */
|
||||
const val SOURCE_SCHEMA = "../vscode/schemas/state-json.schema.json"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user