191 lines
6.4 KiB
Go
191 lines
6.4 KiB
Go
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 = "<!-- spx-state"
|
|
stateCommentClose = "-->"
|
|
// legacyStateMarker prefixes state comments written by the YouTrack integration.
|
|
legacyStateMarker = "<!--spx-state-->"
|
|
)
|
|
|
|
// 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 `<!-- 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.
|
|
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
|
|
}
|