82 lines
2.8 KiB
Go
82 lines
2.8 KiB
Go
package state
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// The state comment is wrapped in an HTML comment so the forge's web page hides it from readers.
|
|
const (
|
|
commentPrefix = "<!-- spx-state"
|
|
commentSuffix = "-->"
|
|
// legacyMarker heads the state comments the YouTrack integration wrote before the hidden format existed.
|
|
legacyMarker = "<!--spx-state-->"
|
|
)
|
|
|
|
// knownFields lists the top-level keys of the embedded schema, so a comment counts as state only if it uses a real state key.
|
|
var knownFields = sync.OnceValue(func() map[string]struct{} {
|
|
var doc struct {
|
|
Properties map[string]json.RawMessage `json:"properties"`
|
|
}
|
|
if err := json.Unmarshal(schemaBytes, &doc); err != nil || len(doc.Properties) == 0 {
|
|
panic(fmt.Sprintf("内嵌 schema 没有可用的 properties: %v", err))
|
|
}
|
|
set := make(map[string]struct{}, len(doc.Properties))
|
|
for name := range doc.Properties {
|
|
set[name] = struct{}{}
|
|
}
|
|
return set
|
|
})
|
|
|
|
// FormatComment renders `s` as a comment body that the forge's web page hides: `<!-- spx-state`, compact JSON, `-->`.
|
|
//
|
|
// [json.Marshal] escapes `<`, `>` and `&` as `\u003c`, `\u003e` and `\u0026`, so no value can close the HTML comment early.
|
|
func FormatComment(s map[string]any) (string, error) {
|
|
buf, err := json.Marshal(s)
|
|
if err != nil {
|
|
return "", fmt.Errorf("序列化 state JSON 失败: %w", err)
|
|
}
|
|
return commentPrefix + "\n" + string(buf) + "\n" + commentSuffix, nil
|
|
}
|
|
|
|
// ParseComment reads the state out of a comment body, or reports `false` when the body is not a state comment.
|
|
//
|
|
// It accepts the hidden format [FormatComment] writes, the older YouTrack form (`<!--spx-state-->` then JSON) and an
|
|
// older bare JSON object. The JSON must be an object carrying at least one schema field, so ordinary chatter that
|
|
// happens to be JSON is not mistaken for state.
|
|
func ParseComment(body string) (map[string]any, bool) {
|
|
text := strings.TrimSpace(body)
|
|
switch {
|
|
case strings.HasPrefix(text, legacyMarker):
|
|
text = text[len(legacyMarker):]
|
|
case strings.HasPrefix(text, commentPrefix) && strings.HasSuffix(text, commentSuffix) &&
|
|
len(text) >= len(commentPrefix)+len(commentSuffix):
|
|
text = text[len(commentPrefix) : len(text)-len(commentSuffix)]
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal([]byte(text), &m); err != nil || m == nil {
|
|
return nil, false
|
|
}
|
|
fields := knownFields()
|
|
for k := range m {
|
|
if _, ok := fields[k]; ok {
|
|
return m, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// Latest returns the state from the newest state comment among `bodies` (oldest first), or an empty map when there is none.
|
|
//
|
|
// Scanning from the end means comments posted after the state, such as review notes or chatter, never hide it.
|
|
func Latest(bodies []string) map[string]any {
|
|
for i := len(bodies) - 1; i >= 0; i-- {
|
|
if m, ok := ParseComment(bodies[i]); ok {
|
|
return m
|
|
}
|
|
}
|
|
return map[string]any{}
|
|
}
|