✨ 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

+90 -45
View File
@@ -2,62 +2,108 @@ package issue
import (
"context"
_ "embed"
"encoding/json"
"fmt"
"strings"
"superwork-tui/internal/gitea"
)
// knownStateFields is the authoritative set used to distinguish a state-JSON
// comment from an ordinary JSON or text comment. Must match stateJson.ts.
var knownStateFields = map[string]bool{
"column": true,
"sessionId": true,
"implementSessionId": true,
"reviewSessionId": true,
"testSessionId": true,
"profilePath": true,
"testProfilePath": true,
"specFile": true,
"planFile": true,
"prDiffFile": true,
"pr": true,
"prMerged": true,
"prMergedAt": true,
"branch": true,
"worktreePath": true,
"implementStatus": true,
"color": true,
"autoReview": true,
"handoffRequestFrom": true,
"handoffRequestError": true,
//go:generate cp ../../../vscode/schemas/state-json.schema.json state-json.schema.json
// stateSchema is a copy of the [state schema](vscode/schemas/state-json.schema.json)
// shared with the VS Code extension and the spx CLI; `go generate` refreshes it.
//
//go:embed state-json.schema.json
var stateSchema []byte
// knownStateFields holds the schema's top-level properties; a comment counts as
// state only when its object carries one of them, so ordinary JSON comments
// cannot pass as state.
var knownStateFields = schemaProperties(stateSchema)
const (
// stateCommentOpen and stateCommentClose wrap the state JSON in an HTML
// comment, so trackers that render Markdown hide it from the thread.
stateCommentOpen = "<!-- spx-state"
stateCommentClose = "-->"
// legacyStateMarker prefixes state comments written by the YouTrack integration.
legacyStateMarker = "<!--spx-state-->"
)
// 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
// body that parses as a JSON object containing at least one known state field.
// Ordinary text and JSON comments without known fields are skipped so they
// cannot shadow a real state blob. Returns an empty map when nothing matches.
// ExtractStateJSON returns the state of the newest comment that
// [ParseStateComment] accepts, scanning tail-first so ordinary comments posted
// afterwards cannot shadow it. Returns an empty map when nothing matches.
//
// Exported so tests can call it without a network client.
func ExtractStateJSON(comments []gitea.Comment) map[string]any {
for i := len(comments) - 1; i >= 0; i-- {
body := comments[i].Body
if body == "" {
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
}
if obj, ok := ParseStateComment(comments[i].Body); ok {
return obj
}
}
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.
type stateCommenter interface {
ListIssueComments(ctx context.Context, owner, repo string, number int) ([]gitea.Comment, error)
@@ -123,9 +169,8 @@ func MergeStateJSONGuarded(
return true, protectedDoneColumn, nil
}
// postMerged merges current + extra and posts the combined object as a raw
// JSON comment body (no fences — matches the TS postIssueComment call in
// stateJson.ts which passes JSON.stringify(merged) directly).
// postMerged merges `extra` over `current` and posts the result as a new
// state comment.
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))
for k, v := range current {
@@ -134,11 +179,11 @@ func postMerged(ctx context.Context, client stateCommenter, owner, repo string,
for k, v := range extra {
result[k] = v
}
b, err := json.Marshal(result)
body, err := FormatStateComment(result)
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 nil