package issue import ( "context" "encoding/json" "fmt" "superwork-tui/internal/gitea" ) // knownStateFields is the authoritative set used to distinguish a state-JSON // comment from an ordinary JSON or text comment. Must match stateJson.ts. var knownStateFields = map[string]bool{ "column": true, "sessionId": true, "implementSessionId": true, "reviewSessionId": true, "testSessionId": true, "profilePath": true, "testProfilePath": true, "specFile": true, "planFile": true, "prDiffFile": true, "pr": true, "prMerged": true, "prMergedAt": true, "branch": true, "worktreePath": true, "implementStatus": true, "color": true, "autoReview": true, } // 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. // // 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 } } } return map[string]any{} } // 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 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). 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 } b, err := json.Marshal(result) if err != nil { return fmt.Errorf("marshal state JSON: %w", err) } if _, err := client.PostIssueComment(ctx, owner, repo, number, string(b)); err != nil { return fmt.Errorf("post state comment on issue %d: %w", number, err) } return nil }