✨ feat(tui): 状态评论改用隐藏格式读写,已知字段改从 schema 派生

This commit is contained in:
cruldra committed 2026-10-19 07:27:01 +11:00
1 parent 9d1bb06f38
commit a23c88e812
5 files changed
+423 -50

No files matched your search

+10 -2
View File
@@ -5,6 +5,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings"
"testing" "testing"
"superwork-tui/internal/gitea" "superwork-tui/internal/gitea"
@@ -240,9 +241,16 @@ func TestLoadIssues_MissingStateSeeded(t *testing.T) {
if got.Column != issue.ColumnTodo { if got.Column != issue.ColumnTodo {
t.Errorf("column: got %v, want todo", got.Column) t.Errorf("column: got %v, want todo", got.Column)
} }
// A seed comment must have been posted // A seed comment must have been posted, in the hidden state format
if len(client.posted) != 1 { if len(client.posted) != 1 {
t.Errorf("expected 1 posted comment, got %d", len(client.posted)) t.Fatalf("expected 1 posted comment, got %d", len(client.posted))
}
if !strings.HasPrefix(client.posted[0], "<!-- spx-state\n") {
t.Errorf("seed body %q is not in the hidden state format", client.posted[0])
}
seed, ok := issue.ParseStateComment(client.posted[0])
if !ok || seed["column"] != "todo" {
t.Errorf("seed state = %v (ok=%v), want column=todo", seed, ok)
} }
} }
+5 -3
View File
@@ -2,7 +2,6 @@ package issue
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"log" "log"
"os" "os"
@@ -136,8 +135,11 @@ func buildIssue(
// When no state blob exists, fall back to issue.state and seed a comment. // When no state blob exists, fall back to issue.state and seed a comment.
if col == "" { if col == "" {
col = defaultColumn(raw.State) col = defaultColumn(raw.State)
seed, _ := json.Marshal(map[string]string{"column": string(col)}) seed, err := FormatStateComment(map[string]any{"column": string(col)})
if _, err := client.PostIssueComment(ctx, owner, repo, raw.Number, string(seed)); err != nil { if err == nil {
_, err = client.PostIssueComment(ctx, owner, repo, raw.Number, seed)
}
if err != nil {
log.Printf("[superwork] failed to seed state comment on %s/%s#%d: %v", owner, repo, raw.Number, err) log.Printf("[superwork] failed to seed state comment on %s/%s#%d: %v", owner, repo, raw.Number, err)
} }
} }
+142
View File
@@ -0,0 +1,142 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://github.com/cruldra/superpowers-vscode/schemas/state-json.schema.json",
"title": "Superpowers VSCode issue state JSON",
"description": "持久化在 issue 最后一条 comment 里的 JSON blob,KanbanPanel 用来恢复 issue 在看板上的列、关联的 spec/plan/PR/branch/worktree、以及各个会话 id 等运行时状态。所有字段均可选,缺失字段由 issueLoader 用默认值兜底。",
"type": "object",
"additionalProperties": false,
"properties": {
"column": {
"type": "string",
"description": "看板列 id;缺失时 issueLoader 按 issue.state 兜底为 todo/done。",
"enum": ["todo", "in-progress", "review", "done"]
},
"sessionId": {
"type": "string",
"description": "讨论/头脑风暴会话的 Claude Code session id,用于 resume 已有对话。",
"minLength": 1
},
"implementSessionId": {
"type": "string",
"description": "实施阶段 (implement) 的 Claude Code session id,与讨论 sessionId 隔离。",
"minLength": 1
},
"reviewSessionId": {
"type": "string",
"description": "审查阶段的会话 id(v1 存 codex thread id),用于 webhook synchronize 时 resume。",
"minLength": 1
},
"testSessionId": {
"type": "string",
"description": "测试阶段的 Claude Code session id;PR 合并后手动启动的测试会话,用于 resume。",
"minLength": 1
},
"prototypeSessionId": {
"type": "string",
"description": "原型阶段的 Claude Code session id;画 Figma 原型的会话,用于 resume。",
"minLength": 1
},
"profilePath": {
"type": "string",
"description": "创建工单时使用的 Claude settings 配置文件绝对路径,resume 时作为 --settings 传入。",
"minLength": 1
},
"brainstormProfilePath": {
"type": "string",
"description": "头脑风暴/规划会话使用的 Claude settings 配置文件绝对路径;与实施 profilePath 独立,空则用默认。",
"minLength": 1
},
"testProfilePath": {
"type": "string",
"description": "测试会话使用的 Claude settings 配置文件绝对路径;与实施 profilePath 独立,空则用默认。",
"minLength": 1
},
"prototypeProfilePath": {
"type": "string",
"description": "原型会话使用的 Claude settings 配置文件绝对路径;与实施 profilePath 独立,空则用默认。",
"minLength": 1
},
"specFile": {
"type": "string",
"description": "本工单的 spec 文档路径,相对 workspace,必须形如 `dir/.../name.md`。",
"minLength": 1,
"pattern": "^[^\\s]+/[^\\s]+\\.md$"
},
"planFile": {
"type": "string",
"description": "本工单的 plan 文档路径,相对 workspace,必须形如 `dir/.../name.md`。",
"minLength": 1,
"pattern": "^[^\\s]+/[^\\s]+\\.md$"
},
"prototypeFile": {
"type": "string",
"description": "本工单的 Figma 原型插件 manifest.json 路径,仓库相对或绝对,必须形如 `dir/.../manifest.json`。",
"minLength": 1,
"pattern": "^[^\\s]+/[^\\s]+\\.json$"
},
"prDiffFile": {
"type": "string",
"description": "本工单的 PR 变更摘要文件路径,相对 workspace,必须位于 docs/pr-diff/ 下。",
"minLength": 1,
"pattern": "^docs/pr-diff/[^\\s]+\\.md$"
},
"pr": {
"type": "string",
"description": "本工单关联的 PR number(字符串形式,webhook 触发后写入)。",
"minLength": 1
},
"prMerged": {
"type": "boolean",
"description": "关联 PR 是否已合并;优先以 PR API 实时结果为准,此字段是 fallback。"
},
"prMergedAt": {
"type": "string",
"description": "关联 PR 合并时间(ISO 8601);webhook 在标记 prMerged 时写入。",
"minLength": 1
},
"branch": {
"type": "string",
"description": "实施分支名,例如 `feature/<hash>`。",
"minLength": 1
},
"worktreePath": {
"type": "string",
"description": "实施 worktree 路径:通常为绝对路径,也可为 `~/...` 或 workspace 相对路径;扩展端经 resolveWorktreePath 解析。",
"minLength": 1
},
"implementStatus": {
"type": "string",
"description": "实施流程生命周期状态。",
"enum": ["running", "done", "failed"]
},
"color": {
"type": "string",
"description": "Terminal/tab 配色(一个 terminal.ansi* ThemeColor key),首次开会话时固化,后续所有会话复用同一色。",
"minLength": 1
},
"autoReview": {
"type": "boolean",
"description": "本工单是否启用自动审查(覆盖全局 autoReview 设置)。"
},
"reviewPassed": {
"type": "boolean",
"description": "最近一次审查是否认为可以过。true 表示通过;false 或不写表示还在审,或审出了必须改的问题。PR 有新提交时合并成 false。不改变看板列。"
},
"handoffBundleKey": {
"type": "string",
"description": "移交会话包在阿里云 OSS 上的对象 key(spx-handoff/<owner>/<repo>/issue-<N>-<时间>.tar.xz);存在即「待接管」,接管完成后清空;清空时写空串。"
},
"handoffFrom": {
"type": "string",
"description": "发起移交的 Gitea login;清空时写空串。"
},
"handoffRequestFrom": {
"type": "string",
"description": "谁在申请把审查中的工单移交给自己;清空时写空串。"
},
"handoffRequestError": {
"type": "string",
"description": "最近一次同意移交失败的原因;对方拒绝时写 rejected;清空时写空串。"
}
}
}
+90 -45
View File
@@ -2,62 +2,108 @@ package issue
import ( import (
"context" "context"
_ "embed"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"superwork-tui/internal/gitea" "superwork-tui/internal/gitea"
) )
// knownStateFields is the authoritative set used to distinguish a state-JSON //go:generate cp ../../../vscode/schemas/state-json.schema.json state-json.schema.json
// comment from an ordinary JSON or text comment. Must match stateJson.ts.
var knownStateFields = map[string]bool{ // stateSchema is a copy of the [state schema](vscode/schemas/state-json.schema.json)
"column": true, // shared with the VS Code extension and the spx CLI; `go generate` refreshes it.
"sessionId": true, //
"implementSessionId": true, //go:embed state-json.schema.json
"reviewSessionId": true, var stateSchema []byte
"testSessionId": true,
"profilePath": true, // knownStateFields holds the schema's top-level properties; a comment counts as
"testProfilePath": true, // state only when its object carries one of them, so ordinary JSON comments
"specFile": true, // cannot pass as state.
"planFile": true, var knownStateFields = schemaProperties(stateSchema)
"prDiffFile": true,
"pr": true, const (
"prMerged": true, // stateCommentOpen and stateCommentClose wrap the state JSON in an HTML
"prMergedAt": true, // comment, so trackers that render Markdown hide it from the thread.
"branch": true, stateCommentOpen = "<!-- spx-state"
"worktreePath": true, stateCommentClose = "-->"
"implementStatus": true, // legacyStateMarker prefixes state comments written by the YouTrack integration.
"color": true, legacyStateMarker = "<!--spx-state-->"
"autoReview": true, )
"handoffRequestFrom": true,
"handoffRequestError": true, // schemaProperties returns the property names a JSON schema declares at its
// top level; it panics on a malformed schema because the schema is embedded at
// build time.
func schemaProperties(schema []byte) map[string]bool {
var doc struct {
Properties map[string]json.RawMessage `json:"properties"`
}
if err := json.Unmarshal(schema, &doc); err != nil {
panic(fmt.Sprintf("parse embedded state schema: %v", err))
}
if len(doc.Properties) == 0 {
panic("embedded state schema declares no properties")
}
fields := make(map[string]bool, len(doc.Properties))
for name := range doc.Properties {
fields[name] = true
}
return fields
} }
// ExtractStateJSON scans comments tail-first and returns the first comment // ExtractStateJSON returns the state of the newest comment that
// body that parses as a JSON object containing at least one known state field. // [ParseStateComment] accepts, scanning tail-first so ordinary comments posted
// Ordinary text and JSON comments without known fields are skipped so they // afterwards cannot shadow it. Returns an empty map when nothing matches.
// cannot shadow a real state blob. Returns an empty map when nothing matches.
// //
// Exported so tests can call it without a network client. // Exported so tests can call it without a network client.
func ExtractStateJSON(comments []gitea.Comment) map[string]any { func ExtractStateJSON(comments []gitea.Comment) map[string]any {
for i := len(comments) - 1; i >= 0; i-- { for i := len(comments) - 1; i >= 0; i-- {
body := comments[i].Body if obj, ok := ParseStateComment(comments[i].Body); ok {
if body == "" { return obj
continue
}
var obj map[string]any
if err := json.Unmarshal([]byte(body), &obj); err != nil {
continue
}
for k := range obj {
if knownStateFields[k] {
return obj
}
} }
} }
return map[string]any{} return map[string]any{}
} }
// ParseStateComment returns the state object carried by a comment body, or
// false when the body is not a state comment.
//
// Three forms are accepted: the hidden `<!-- spx-state` … `-->` comment written
// by [FormatStateComment], the YouTrack `<!--spx-state-->` prefix, and a bare
// JSON object left by older writers. The JSON must be an object carrying at
// least one schema field, so review notes and other JSON comments are rejected.
func ParseStateComment(body string) (map[string]any, bool) {
text := strings.TrimSpace(body)
switch {
case strings.HasPrefix(text, stateCommentOpen) && strings.HasSuffix(text, stateCommentClose):
text = strings.TrimSuffix(strings.TrimPrefix(text, stateCommentOpen), stateCommentClose)
case strings.HasPrefix(text, legacyStateMarker):
text = strings.TrimPrefix(text, legacyStateMarker)
}
var obj map[string]any
if err := json.Unmarshal([]byte(strings.TrimSpace(text)), &obj); err != nil || obj == nil {
return nil, false
}
for k := range obj {
if knownStateFields[k] {
return obj, true
}
}
return nil, false
}
// FormatStateComment renders `state` as a hidden state comment: compact JSON
// between `<!-- spx-state` and `-->`. [json.Marshal] escapes `<`, `>` and `&`,
// so a value containing `-->` cannot close the comment early.
func FormatStateComment(state map[string]any) (string, error) {
b, err := json.Marshal(state)
if err != nil {
return "", fmt.Errorf("marshal state JSON: %w", err)
}
return stateCommentOpen + "\n" + string(b) + "\n" + stateCommentClose, nil
}
// stateCommenter is the subset of gitea.Client used by state-JSON operations. // stateCommenter is the subset of gitea.Client used by state-JSON operations.
type stateCommenter interface { type stateCommenter interface {
ListIssueComments(ctx context.Context, owner, repo string, number int) ([]gitea.Comment, error) ListIssueComments(ctx context.Context, owner, repo string, number int) ([]gitea.Comment, error)
@@ -123,9 +169,8 @@ func MergeStateJSONGuarded(
return true, protectedDoneColumn, nil return true, protectedDoneColumn, nil
} }
// postMerged merges current + extra and posts the combined object as a raw // postMerged merges `extra` over `current` and posts the result as a new
// JSON comment body (no fences — matches the TS postIssueComment call in // state comment.
// stateJson.ts which passes JSON.stringify(merged) directly).
func postMerged(ctx context.Context, client stateCommenter, owner, repo string, number int, current, extra map[string]any) error { func postMerged(ctx context.Context, client stateCommenter, owner, repo string, number int, current, extra map[string]any) error {
result := make(map[string]any, len(current)+len(extra)) result := make(map[string]any, len(current)+len(extra))
for k, v := range current { for k, v := range current {
@@ -134,11 +179,11 @@ func postMerged(ctx context.Context, client stateCommenter, owner, repo string,
for k, v := range extra { for k, v := range extra {
result[k] = v result[k] = v
} }
b, err := json.Marshal(result) body, err := FormatStateComment(result)
if err != nil { if err != nil {
return fmt.Errorf("marshal state JSON: %w", err) return err
} }
if _, err := client.PostIssueComment(ctx, owner, repo, number, string(b)); err != nil { if _, err := client.PostIssueComment(ctx, owner, repo, number, body); err != nil {
return fmt.Errorf("post state comment on issue %d: %w", number, err) return fmt.Errorf("post state comment on issue %d: %w", number, err)
} }
return nil return nil
+176
View File
@@ -0,0 +1,176 @@
package issue_test
import (
"bytes"
"context"
"encoding/json"
"os"
"strings"
"testing"
"superwork-tui/internal/gitea"
"superwork-tui/internal/issue"
)
// sourceSchemaPath is the canonical state schema shared with the VS Code
// extension and the spx CLI, relative to this package directory.
const sourceSchemaPath = "../../../vscode/schemas/state-json.schema.json"
func TestStateSchemaCopyInSync(t *testing.T) {
src, err := os.ReadFile(sourceSchemaPath)
if err != nil {
t.Fatalf("read source schema: %v", err)
}
local, err := os.ReadFile("state-json.schema.json")
if err != nil {
t.Fatalf("read embedded schema copy: %v (run `go generate ./internal/issue`)", err)
}
if !bytes.Equal(src, local) {
t.Fatal("state-json.schema.json is out of date; run `go generate ./internal/issue`")
}
}
func TestParseStateComment_RecognizesEverySchemaField(t *testing.T) {
raw, err := os.ReadFile(sourceSchemaPath)
if err != nil {
t.Fatalf("read source schema: %v", err)
}
var schema struct {
Properties map[string]json.RawMessage `json:"properties"`
}
if err := json.Unmarshal(raw, &schema); err != nil {
t.Fatalf("parse source schema: %v", err)
}
if len(schema.Properties) == 0 {
t.Fatal("source schema has no properties")
}
for field := range schema.Properties {
body := `{"` + field + `":"x"}`
if _, ok := issue.ParseStateComment(body); !ok {
t.Errorf("field %q not recognised as a state field", field)
}
}
}
func TestParseStateComment_AcceptedForms(t *testing.T) {
cases := map[string]string{
"hidden": "<!-- spx-state\n{\"column\":\"review\",\"pr\":\"7\"}\n-->",
"hidden padded": "\n <!-- spx-state\n{\"column\":\"review\",\"pr\":\"7\"}\n--> \n",
"hidden single line": `<!-- spx-state {"column":"review","pr":"7"} -->`,
"legacy youtrack": "<!--spx-state-->\n{\"column\":\"review\",\"pr\":\"7\"}",
"bare json": `{"column":"review","pr":"7"}`,
"bare json padded": " {\"column\":\"review\",\"pr\":\"7\"}\n",
"hidden escaped html": "<!-- spx-state\n{\"column\":\"review\",\"pr\":\"7\",\"branch\":\"a\\u003c\\u003e\\u0026b\"}\n-->",
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
state, ok := issue.ParseStateComment(body)
if !ok {
t.Fatalf("expected %q to parse as a state comment", body)
}
if state["column"] != "review" || state["pr"] != "7" {
t.Errorf("got %v, want column=review pr=7", state)
}
})
}
}
func TestParseStateComment_Rejected(t *testing.T) {
cases := map[string]string{
"empty": "",
"whitespace": " \n ",
"plain text": "LGTM, merging",
"unknown fields only": `{"foo":"bar"}`,
"array": `[{"column":"todo"}]`,
"scalar": `"column"`,
"hidden unknown only": "<!-- spx-state\n{\"foo\":\"bar\"}\n-->",
"hidden broken json": "<!-- spx-state\n{\"column\":\n-->",
"hidden unterminated": "<!-- spx-state\n{\"column\":\"todo\"}",
"other html comment": "<!-- spx:review=1 -->\n{\"column\":\"todo\"}",
"legacy marker no obj": "<!--spx-state-->\nnot json",
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
if state, ok := issue.ParseStateComment(body); ok {
t.Errorf("expected %q to be rejected, got %v", body, state)
}
})
}
}
func TestFormatStateComment_HiddenAndRoundTrips(t *testing.T) {
in := map[string]any{
"column": "review",
"branch": "feat/a-->b<c>&d",
}
body, err := issue.FormatStateComment(in)
if err != nil {
t.Fatalf("FormatStateComment: %v", err)
}
const open, close = "<!-- spx-state\n", "\n-->"
if !strings.HasPrefix(body, open) || !strings.HasSuffix(body, close) {
t.Fatalf("body %q is not wrapped as <!-- spx-state\\n...\\n-->", body)
}
payload := strings.TrimSuffix(strings.TrimPrefix(body, open), close)
if strings.ContainsAny(payload, "<>&") {
t.Errorf("payload %q must escape < > &", payload)
}
if strings.Contains(payload, "\n") {
t.Errorf("payload %q must be compact single-line JSON", payload)
}
if strings.Count(body, "-->") != 1 {
t.Errorf("body %q must contain exactly one -->", body)
}
out, ok := issue.ParseStateComment(body)
if !ok {
t.Fatalf("formatted body %q did not parse back", body)
}
if out["column"] != "review" || out["branch"] != "feat/a-->b<c>&d" {
t.Errorf("round trip got %v, want %v", out, in)
}
}
func TestExtractStateJSON_NewestValidWinsAcrossFormats(t *testing.T) {
comments := []gitea.Comment{
{Body: `{"column":"todo","branch":"feat/old"}`},
{Body: "<!-- spx-state\n{\"column\":\"review\",\"branch\":\"feat/new\"}\n-->"},
{Body: "<!-- spx-state\n{\"foo\":\"bar\"}\n-->"},
{Body: "just chatter"},
}
state := issue.ExtractStateJSON(comments)
if state["column"] != "review" || state["branch"] != "feat/new" {
t.Errorf("got %v, want the hidden review blob", state)
}
comments = append(comments, gitea.Comment{Body: "<!--spx-state-->\n{\"column\":\"done\"}"})
state = issue.ExtractStateJSON(comments)
if state["column"] != "done" {
t.Errorf("got %v, want the newer legacy YouTrack blob", state)
}
}
func TestMergeStateJSON_PostsHiddenFormatKeepingFields(t *testing.T) {
client := &fakeClient{
issueComments: map[int][]gitea.Comment{
5: {{Body: `{"column":"todo","prototypeSessionId":"proto-1","handoffBundleKey":"k"}`}},
},
}
err := issue.MergeStateJSON(context.Background(), client, "owner", "repo", 5, map[string]any{"column": "review"})
if err != nil {
t.Fatalf("MergeStateJSON: %v", err)
}
if len(client.posted) != 1 {
t.Fatalf("expected 1 posted comment, got %d", len(client.posted))
}
body := client.posted[0]
if !strings.HasPrefix(body, "<!-- spx-state\n") {
t.Errorf("posted body %q is not in the hidden format", body)
}
state, ok := issue.ParseStateComment(body)
if !ok {
t.Fatalf("posted body %q is not a state comment", body)
}
if state["column"] != "review" || state["prototypeSessionId"] != "proto-1" || state["handoffBundleKey"] != "k" {
t.Errorf("merged state %v lost or misapplied fields", state)
}
}