package issue import ( "context" _ "embed" "encoding/json" "fmt" "strings" "superwork-tui/internal/gitea" ) //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 = "" // 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 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-- { 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) PostIssueComment(ctx context.Context, owner, repo string, number int, body string) (*gitea.Comment, error) } // ReadStateJSON fetches issue comments and extracts the latest state blob. func ReadStateJSON(ctx context.Context, client stateCommenter, owner, repo string, number int) (map[string]any, error) { comments, err := client.ListIssueComments(ctx, owner, repo, number) if err != nil { return nil, fmt.Errorf("list comments for issue %d: %w", number, err) } return ExtractStateJSON(comments), nil } // MergeStateJSON reads the current state blob, merges extra on top, and posts // a new comment containing the merged JSON. func MergeStateJSON(ctx context.Context, client stateCommenter, owner, repo string, number int, extra map[string]any) error { current, err := ReadStateJSON(ctx, client, owner, repo, number) if err != nil { return err } return postMerged(ctx, client, owner, repo, number, current, extra) } // MergeStateJSONGuarded is like MergeStateJSON but protects done-column issues // from having their column overwritten by a stale automated update. When the // protection strips the only incoming field, no comment is posted. func MergeStateJSONGuarded( ctx context.Context, client stateCommenter, owner, repo string, number int, extra map[string]any, protectDoneColumn bool, ) (posted bool, protectedDoneColumn bool, err error) { current, err := ReadStateJSON(ctx, client, owner, repo, number) if err != nil { return false, false, err } merged := make(map[string]any, len(extra)) for k, v := range extra { merged[k] = v } if protectDoneColumn { if col, ok := current["column"]; ok && col == "done" { if incomingCol, has := merged["column"]; has && incomingCol != "done" { delete(merged, "column") protectedDoneColumn = true } } } if len(merged) == 0 { return false, protectedDoneColumn, nil } if err := postMerged(ctx, client, owner, repo, number, current, merged); err != nil { return false, protectedDoneColumn, err } return true, protectedDoneColumn, nil } // 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 { result[k] = v } for k, v := range extra { result[k] = v } body, err := FormatStateComment(result) if err != nil { return err } 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 }