✨ feat(spx): 按 git origin 支持 GitHub 仓库,state 评论改成隐藏注释格式,新增 issue view / pr view / pr create
This commit is contained in:
1 parent
64d5961b62
commit
c90d84d7f2
17 files changed
+2683
-400
No files matched your search
@@ -0,0 +1,81 @@
|
||||
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{}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatCommentWrapsCompactJSONInHiddenComment(t *testing.T) {
|
||||
body, err := FormatComment(map[string]any{"column": "review", "branch": "feat/x"})
|
||||
if err != nil {
|
||||
t.Fatalf("FormatComment 出错: %v", err)
|
||||
}
|
||||
want := "<!-- spx-state\n{\"branch\":\"feat/x\",\"column\":\"review\"}\n-->"
|
||||
if body != want {
|
||||
t.Fatalf("got %q\nwant %q", body, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCommentEscapesCommentTerminators(t *testing.T) {
|
||||
raw := "a --> b <!-- c & d"
|
||||
body, err := FormatComment(map[string]any{"handoffRequestError": raw})
|
||||
if err != nil {
|
||||
t.Fatalf("FormatComment 出错: %v", err)
|
||||
}
|
||||
inner := strings.TrimSuffix(strings.TrimPrefix(body, "<!-- spx-state\n"), "\n-->")
|
||||
if inner == body {
|
||||
t.Fatalf("输出不是隐藏注释格式: %q", body)
|
||||
}
|
||||
if strings.Contains(inner, "-->") || strings.ContainsAny(inner, "<>&") {
|
||||
t.Fatalf("JSON 正文里不应出现 `-->` 或裸的 < > &: %q", inner)
|
||||
}
|
||||
for _, esc := range []string{`\u003c`, `\u003e`, `\u0026`} {
|
||||
if !strings.Contains(inner, esc) {
|
||||
t.Fatalf("JSON 正文应把字符转义成 %s: %q", esc, inner)
|
||||
}
|
||||
}
|
||||
got, ok := ParseComment(body)
|
||||
if !ok {
|
||||
t.Fatalf("自己写出的评论应能读回: %q", body)
|
||||
}
|
||||
if got["handoffRequestError"] != raw {
|
||||
t.Fatalf("往返后值变了: got %q want %q", got["handoffRequestError"], raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommentAcceptsAllThreeFormats(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"隐藏注释": "<!-- spx-state\n{\"column\":\"review\"}\n-->",
|
||||
"隐藏注释带首尾空白": " \n<!-- spx-state\n{\"column\":\"review\"}\n-->\n ",
|
||||
"旧 YouTrack 格式": "<!--spx-state-->\n{\"column\":\"review\"}",
|
||||
"旧裸 JSON": `{"column":"review"}`,
|
||||
"旧裸 JSON 带空白": "\n {\"column\":\"review\"} \n",
|
||||
}
|
||||
for name, body := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
m, ok := ParseComment(body)
|
||||
if !ok {
|
||||
t.Fatalf("应当识别为 state 评论: %q", body)
|
||||
}
|
||||
if m["column"] != "review" {
|
||||
t.Fatalf("column 解析错误: %v", m)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommentRejectsNonStateBodies(t *testing.T) {
|
||||
bad := []string{
|
||||
"",
|
||||
"LGTM",
|
||||
`{"foo":1}`,
|
||||
`["column"]`,
|
||||
`null`,
|
||||
`"column"`,
|
||||
"<!-- spx-state\n{\"column\":\"review\"}",
|
||||
"<!-- spx-state\n[1]\n-->",
|
||||
"<!-- spx-state\n{\"foo\":1}\n-->",
|
||||
"<!--spx-state-->\n{\"foo\":1}",
|
||||
"状态如下: {\"column\":\"review\"}",
|
||||
"<!-- spx:review=1 -->\n\nLGTM",
|
||||
}
|
||||
for _, body := range bad {
|
||||
if m, ok := ParseComment(body); ok {
|
||||
t.Fatalf("不应识别为 state 评论: %q -> %v", body, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommentKnowsEverySchemaProperty(t *testing.T) {
|
||||
var doc struct {
|
||||
Properties map[string]json.RawMessage `json:"properties"`
|
||||
}
|
||||
if err := json.Unmarshal(schemaBytes, &doc); err != nil {
|
||||
t.Fatalf("解析内嵌 schema 失败: %v", err)
|
||||
}
|
||||
if len(doc.Properties) == 0 {
|
||||
t.Fatal("内嵌 schema 没有 properties")
|
||||
}
|
||||
for name := range doc.Properties {
|
||||
body := fmt.Sprintf(`{%q:null}`, name)
|
||||
if _, ok := ParseComment(body); !ok {
|
||||
t.Errorf("schema 字段 %s 应当被识别为 state 字段", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestPicksNewestValidStateComment(t *testing.T) {
|
||||
bodies := []string{
|
||||
`{"column":"todo","branch":"old"}`,
|
||||
"<!--spx-state-->\n{\"column\":\"in-progress\"}",
|
||||
"<!-- spx-state\n{\"column\":\"review\",\"branch\":\"new\"}\n-->",
|
||||
"LGTM",
|
||||
`{"unrelated":true}`,
|
||||
}
|
||||
got := Latest(bodies)
|
||||
if got["column"] != "review" || got["branch"] != "new" {
|
||||
t.Fatalf("应取最新一条有效 state,得到 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestReturnsEmptyMapWhenNoState(t *testing.T) {
|
||||
got := Latest([]string{"hello", `{"x":1}`})
|
||||
if got == nil || len(got) != 0 {
|
||||
t.Fatalf("没有 state 时应返回非 nil 空 map,得到 %#v", got)
|
||||
}
|
||||
if got := Latest(nil); got == nil {
|
||||
t.Fatal("空评论列表也应返回非 nil 空 map")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user