diff --git a/jetbrains/build.gradle.kts b/jetbrains/build.gradle.kts index 3a0a919..7dc796b 100644 --- a/jetbrains/build.gradle.kts +++ b/jetbrains/build.gradle.kts @@ -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") + } +} diff --git a/jetbrains/src/main/kotlin/com/cruldra/superworkbench/gitea/IssueLoader.kt b/jetbrains/src/main/kotlin/com/cruldra/superworkbench/gitea/IssueLoader.kt index 42dd200..e8519d2 100644 --- a/jetbrains/src/main/kotlin/com/cruldra/superworkbench/gitea/IssueLoader.kt +++ b/jetbrains/src/main/kotlin/com/cruldra/superworkbench/gitea/IssueLoader.kt @@ -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)) } } diff --git a/jetbrains/src/main/kotlin/com/cruldra/superworkbench/gitea/IssueStateStore.kt b/jetbrains/src/main/kotlin/com/cruldra/superworkbench/gitea/IssueStateStore.kt index d8907dc..0b5e8a6 100644 --- a/jetbrains/src/main/kotlin/com/cruldra/superworkbench/gitea/IssueStateStore.kt +++ b/jetbrains/src/main/kotlin/com/cruldra/superworkbench/gitea/IssueStateStore.kt @@ -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): 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, patch: IssueState): String { + val current = findLatestStateObject(comments) ?: JsonObject(emptyMap()) + return IssueStateJson.formatComment(JsonObject(current + IssueStateJson.toJsonObject(patch))) } + + /** 从尾往前找最新一条状态评论并读成 [IssueState],找不到返回 null。 */ + internal fun findLatestState(comments: List): IssueState? = + findLatestStateObject(comments)?.let(IssueStateJson::decode) + + private fun findLatestStateObject(comments: List): JsonObject? = + comments.asReversed().firstNotNullOfOrNull { IssueStateJson.parseComment(it.body) } } diff --git a/jetbrains/src/main/kotlin/com/cruldra/superworkbench/model/IssueStateJson.kt b/jetbrains/src/main/kotlin/com/cruldra/superworkbench/model/IssueStateJson.kt index 82d826b..8f396ba 100644 --- a/jetbrains/src/main/kotlin/com/cruldra/superworkbench/model/IssueStateJson.kt +++ b/jetbrains/src/main/kotlin/com/cruldra/superworkbench/model/IssueStateJson.kt @@ -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] 互转。 + * + * 写出的评论是隐藏格式 ``,渲染 Markdown 的评论区看不到; + * 读写规则与 VS Code 插件、spx CLI、superwork-tui 一致,几端读写同一批工单评论。 */ object IssueStateJson { - private val json = Json { + private const val HIDDEN_OPEN = "" + + /** YouTrack 集成写的旧状态评论前缀,后面直接跟 JSON。 */ + private const val LEGACY_MARKER = "" + + /** [构建脚本](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(commentBody) }.getOrNull() + /** schema 顶层声明的字段;JSON 对象至少带一个才算状态评论,普通 JSON 评论因此不会被当成状态。 */ + private val knownFields: Set by lazy { loadSchemaFields() } - /** 把 [state] 序列化为 JSON 字符串(省略默认/空字段)。 */ - fun encode(state: IssueState): String = json.encodeToString(state) + /** + * 取出评论正文携带的状态对象,不是状态评论就返回 null。 + * + * 认三种写法:[formatComment] 写的隐藏注释、YouTrack 的 `` 前缀、老版本留下的裸 + * 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(state) }.getOrNull() + + private fun loadSchemaFields(): Set { + 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 + } } diff --git a/jetbrains/src/test/kotlin/com/cruldra/superworkbench/gitea/IssueStateStoreTest.kt b/jetbrains/src/test/kotlin/com/cruldra/superworkbench/gitea/IssueStateStoreTest.kt new file mode 100644 index 0000000..d6d838f --- /dev/null +++ b/jetbrains/src/test/kotlin/com/cruldra/superworkbench/gitea/IssueStateStoreTest.kt @@ -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\"}", + "", + "", + "just chatter", + ) + val state = IssueStateStore.findLatestState(list) + assertEquals(IssueColumn.REVIEW, state?.column) + assertEquals("feat/new", state?.branch) + + val newer = list + comments("\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("", + "hidden padded" to "\n \n", + "hidden single line" to "", + "legacy youtrack" to "\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 + "", + ) + 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 "", + "hidden broken json" to "", + "hidden unterminated" to "\n{\"column\":\"todo\"}", + "legacy marker no object" to "\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&d"), + ), + ) + val body = IssueStateJson.formatComment(state) + + assertTrue(body, body.startsWith("")) + val payload = body.removePrefix("") + 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" + } +}