11
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user