✨ feat(jetbrains): 状态评论改用隐藏格式读写,合并时保留未建模字段

This commit is contained in:
cruldra committed 2026-10-19 07:40:02 +11:00
1 parent 38ca193434
commit 64d5961b62
6 files changed
+267 -60

No files matched your search

+8
View File
@@ -55,3 +55,11 @@ intellijPlatform {
kotlin { kotlin {
jvmToolchain(21) 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) column = defaultColumnForState(issue.state)
// 持久化默认列,后续加载即可直接命中;失败非致命,仍以计算出的列展示。 // 持久化默认列,后续加载即可直接命中;失败非致命,仍以计算出的列展示。
runCatching { 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.IssueState
import com.cruldra.superworkbench.model.IssueStateJson 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 分支不迁移)。 * `src/issues/stateRouter.ts` 的 gitea 分支(YouTrack 分支不迁移)。
* *
* 约定:issue 的状态 blob 存在某条评论正文里。从评论列表尾部往前扫,第一条能被 * 约定:从评论列表尾部往前扫,最新一条 [IssueStateJson.parseComment] 认得的评论就是当前状态,
* [IssueStateJson.decode] 成功解析的评论即视为当前 state——这样中途插入的普通文本/ * 中途插入的普通文本、审查、PR 自动关联评论都跳过,顶替不了真正的状态评论。
* 审查/PR 自动关联评论不会顶替真正的 state 评论。
*/ */
object IssueStateStore { object IssueStateStore {
/** /**
* 取 issue 评论列表,从尾往前找第一条能解析为 [IssueState] 的评论并返回。 * 取 issue 评论列表,返回最新的状态;找不到返回 null。对应 `readStateJsonComment` 的 gitea 分支。
* 找不到返回 null。对应 `readStateJsonComment` 的 gitea 分支。
*/ */
fun readIssueState( fun readIssueState(
api: GiteaApi, api: GiteaApi,
owner: String, owner: String,
repo: String, repo: String,
issueNumber: Long, issueNumber: Long,
): IssueState? { ): IssueState? = findLatestState(api.listIssueComments(owner, repo, issueNumber))
val comments = api.listIssueComments(owner, repo, issueNumber)
return findLatestState(comments)
}
/** /**
* 读现有 state,用 [patch] 经 [IssueStateJson.merge] 合并后 encode 写回。 * 把 [patch] 合并进当前状态,作为一条新状态评论发出去。
* *
* 与源项目 `mergeStateJsonComment` 一致:合并结果始终以**新评论**形式 post,由 * 与源项目 `mergeStateJsonComment` 一致:总是发新评论,由读取侧「取最新一条状态评论」保证后写优先,
* loader/读取侧的「从尾往前取最近一条 state 评论」语义保证后写优先;GiteaApi 无需 * [GiteaApi] 不需要编辑评论的方法。
* 编辑评论方法。无现有 state 时从空 [IssueState] 起步。
*/ */
fun mergeIssueState( fun mergeIssueState(
api: GiteaApi, api: GiteaApi,
@@ -40,26 +35,25 @@ object IssueStateStore {
issueNumber: Long, issueNumber: Long,
patch: IssueState, patch: IssueState,
) { ) {
val current = readIssueState(api, owner, repo, issueNumber) ?: IssueState() val comments = api.listIssueComments(owner, repo, issueNumber)
val merged = IssueStateJson.merge(current, patch) api.postIssueComment(owner, repo, issueNumber, mergedStateComment(comments, patch))
api.postIssueComment(owner, repo, issueNumber, IssueStateJson.encode(merged))
} }
/** /**
* 从尾往前找最近一条 state 评论,找不到返回 null。 * 算出 [patch] 合并进最新状态后要发的评论正文。
* *
* 仅当解析出的 [IssueState] 至少携带一个非空已知字段时才认定为 state——对齐源项目 * 在 JSON 对象层面合并,[IssueState] 没建模的字段(其它端写的会话 id、移交信息等)原样带上,不会被这次写入抹掉;
* `hasKnownField` / `'column' in candidate` 的判定,避免把 `{}` 或普通 JSON 内容 * 没有状态评论时从空对象起步。
* 评论(lenient 解码会得到全空 [IssueState])误判为 state 而顶替真正的状态。
*/ */
internal fun findLatestState(comments: List<GiteaComment>): IssueState? { internal fun mergedStateComment(comments: List<GiteaComment>, patch: IssueState): String {
val empty = IssueState() val current = findLatestStateObject(comments) ?: JsonObject(emptyMap())
for (i in comments.indices.reversed()) { return IssueStateJson.formatComment(JsonObject(current + IssueStateJson.toJsonObject(patch)))
val body = comments[i].body.trim()
if (body.isEmpty()) continue
val decoded = IssueStateJson.decode(body) ?: continue
if (decoded != empty) return decoded
}
return null
} }
/** 从尾往前找最新一条状态评论并读成 [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 package com.cruldra.superworkbench.model
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json 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]、序列化回字符串、 * 工单状态评论的编解码器:评论正文和状态 JSON 对象互转,状态 JSON 对象和 [IssueState] 互转。
* 以及把 patch 合并进 base。 *
* 写出的评论是隐藏格式 `<!-- spx-state\n{紧凑 JSON}\n-->`,渲染 Markdown 的评论区看不到;
* 读写规则与 VS Code 插件、spx CLI、superwork-tui 一致,几端读写同一批工单评论。
*/ */
object IssueStateJson { 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 ignoreUnknownKeys = true
coerceInputValues = true
isLenient = true
encodeDefaults = false encodeDefaults = false
explicitNulls = false explicitNulls = false
isLenient = true
} }
/** 解析 comment 正文为 [IssueState],解析失败返回 null。 */ /** schema 顶层声明的字段;JSON 对象至少带一个才算状态评论,普通 JSON 评论因此不会被当成状态。 */
fun decode(commentBody: String): IssueState? = private val knownFields: Set<String> by lazy { loadSchemaFields() }
runCatching { json.decodeFromString<IssueState>(commentBody) }.getOrNull()
/** 把 [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( * 把 [state] 写成隐藏状态评论的正文。
column = patch.column ?: base.column, *
sessionId = patch.sessionId ?: base.sessionId, * `<`、`>`、`&` 转成 `<`、`>`、`&`,值里带 `-->` 也不会提前把注释关掉。
implementSessionId = patch.implementSessionId ?: base.implementSessionId, */
reviewSessionId = patch.reviewSessionId ?: base.reviewSessionId, fun formatComment(state: JsonObject): String {
testSessionId = patch.testSessionId ?: base.testSessionId, val payload = strictJson.encodeToString(JsonObject.serializer(), state)
profilePath = patch.profilePath ?: base.profilePath, .replace("<", "\\u003c")
specFile = patch.specFile ?: base.specFile, .replace(">", "\\u003e")
planFile = patch.planFile ?: base.planFile, .replace("&", "\\u0026")
prDiffFile = patch.prDiffFile ?: base.prDiffFile, return "$HIDDEN_OPEN\n$payload\n$HIDDEN_CLOSE"
pr = patch.pr ?: base.pr, }
prMerged = patch.prMerged ?: base.prMerged,
branch = patch.branch ?: base.branch, /** 把 [state] 转成只含非空字段的状态对象,合并时只覆盖它带的字段。 */
worktreePath = patch.worktreePath ?: base.worktreePath, fun toJsonObject(state: IssueState): JsonObject = stateJson.encodeToJsonElement(state).jsonObject
implementStatus = patch.implementStatus ?: base.implementStatus,
color = patch.color ?: base.color, /** 把状态对象读成 [IssueState],字段类型不对时返回 null。 */
autoReview = patch.autoReview ?: base.autoReview, 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"
}
}