✨ feat(tui): 状态评论改用隐藏格式读写,已知字段改从 schema 派生
This commit is contained in:
1 parent
9d1bb06f38
commit
a23c88e812
5 files changed
+422
-49
No files matched your search
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;清空时写空串。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 == "" {
|
|
||||||
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 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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user