diff --git a/tui/internal/issue/issue_test.go b/tui/internal/issue/issue_test.go index b30776f..e13e657 100644 --- a/tui/internal/issue/issue_test.go +++ b/tui/internal/issue/issue_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "testing" "superwork-tui/internal/gitea" @@ -240,9 +241,16 @@ func TestLoadIssues_MissingStateSeeded(t *testing.T) { if got.Column != issue.ColumnTodo { 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 { - 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], "" + // legacyStateMarker prefixes state comments written by the YouTrack integration. + legacyStateMarker = "" +) + +// 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 `` comment written +// by [FormatStateComment], the YouTrack `` 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 ``. [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 diff --git a/tui/internal/issue/statejson_test.go b/tui/internal/issue/statejson_test.go new file mode 100644 index 0000000..9af82d2 --- /dev/null +++ b/tui/internal/issue/statejson_test.go @@ -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": "", + "hidden padded": "\n \n", + "hidden single line": ``, + "legacy youtrack": "\n{\"column\":\"review\",\"pr\":\"7\"}", + "bare json": `{"column":"review","pr":"7"}`, + "bare json padded": " {\"column\":\"review\",\"pr\":\"7\"}\n", + "hidden escaped html": "", + } + 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": "", + "hidden broken json": "", + "hidden unterminated": "\n{\"column\":\"todo\"}", + "legacy marker no obj": "\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&d", + } + body, err := issue.FormatStateComment(in) + if err != nil { + t.Fatalf("FormatStateComment: %v", err) + } + const open, close = "" + if !strings.HasPrefix(body, open) || !strings.HasSuffix(body, close) { + t.Fatalf("body %q is not wrapped as ", 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&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: ""}, + {Body: ""}, + {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: "\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, "