ความคืบหน้างาน ไฟล์ต้นฉบับ

tracker/review_submit.go

package main

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"time"
	"unicode/utf8"
)

// Outcomes of the review form and the review state each one sets.
var outcomeState = map[string]string{"approve": ReviewDone, "reject": ReviewRejected, "hold": ReviewHold}

// ReviewSubmission is what the reviewer sent from the form.
type ReviewSubmission struct {
	TaskID   string
	Outcome  string // approve | reject | hold
	Decision string // Markdown
	Reviewer string
	Checked  map[int]bool // checklist item index -> ticked
	Now      time.Time
}

var errTaskNotFound = errors.New("task not found")

// formError is a problem the reviewer can fix; the form is shown again.
type formError string

func (e formError) Error() string { return string(e) }

func (sub ReviewSubmission) validate() error {
	if _, ok := outcomeState[sub.Outcome]; !ok {
		return formError("เลือกผลการรีวิว: Approve, Reject หรือ Hold")
	}
	if sub.Outcome != "approve" && sub.Decision == "" {
		return formError("Reject และ Hold ต้องเขียนผลการรีวิว ว่าต้องแก้อะไรหรือพักไว้เพราะอะไร")
	}
	if utf8.RuneCountInString(sub.Decision) > 10000 {
		return formError("ผลการรีวิวยาวเกิน 10,000 ตัวอักษร")
	}
	if utf8.RuneCountInString(sub.Reviewer) > 100 {
		return formError("ชื่อผู้รีวิวยาวเกิน 100 ตัวอักษร")
	}
	return nil
}

// SubmitReview records a review decision in milestones.json and returns the
// new review state. Only the task's review, status and finished date change;
// key order and every other field in the file stay as they are.
//
//	approve -> review done,     task reviewed (finished date set if empty)
//	reject  -> review rejected, task back to in_progress
//	hold    -> review hold,     task stays review
func (s *Store) SubmitReview(sub ReviewSubmission) (string, error) {
	if err := sub.validate(); err != nil {
		return "", err
	}
	s.writeMu.Lock()
	defer s.writeMu.Unlock()

	path := filepath.Join(s.dir, "milestones.json")
	data, err := os.ReadFile(path)
	if err != nil {
		return "", err
	}
	root, err := parseOrdered(data)
	if err != nil {
		return "", fmt.Errorf("milestones.json อ่านไม่ได้: %w", err)
	}
	task := findTask(root, sub.TaskID)
	if task == nil {
		return "", errTaskNotFound
	}
	rv, _ := task.get("review")
	review, ok := rv.(*object)
	if !ok {
		return "", formError("task นี้ยังไม่ได้ส่งรีวิว")
	}

	state := outcomeState[sub.Outcome]
	date := sub.Now.In(ict).Format("2006-01-02")
	review.set("state", state)
	review.set("decision", sub.Decision)
	review.set("decided_at", date)
	review.set("decided_by", sub.Reviewer)
	if cl, ok := review.get("checklist"); ok {
		if items, ok := cl.([]any); ok {
			for i, it := range items {
				if obj, ok := it.(*object); ok {
					obj.set("done", sub.Checked[i])
				}
			}
		}
	}
	hist, _ := review.get("history")
	events, _ := hist.([]any)
	events = append(events, &object{
		{"state", state}, {"decision", sub.Decision}, {"by", sub.Reviewer}, {"at", sub.Now.In(ict).Format(time.RFC3339)},
	})
	review.set("history", events)

	switch sub.Outcome {
	case "approve":
		task.set("status", StatusReviewed)
		if task.getString("finished") == "" {
			task.set("finished", date)
		}
	case "reject":
		task.set("status", StatusInProgress)
	case "hold":
		task.set("status", StatusReview)
	}

	out, err := writeOrdered(root)
	if err != nil {
		return "", err
	}
	if err := writeFileAtomic(path, out); err != nil {
		return "", err
	}
	s.Reload()
	if _, loadErr, _ := s.Get(); loadErr != nil {
		return "", loadErr
	}
	return state, nil
}

// findTask walks milestones[].epics[].tasks[] for a task id.
func findTask(root any, id string) *object {
	milestones, _ := root.([]any)
	for _, m := range milestones {
		mo, _ := m.(*object)
		if mo == nil {
			continue
		}
		epics, _ := mo.get("epics")
		for _, e := range asArray(epics) {
			eo, _ := e.(*object)
			if eo == nil {
				continue
			}
			tasks, _ := eo.get("tasks")
			for _, t := range asArray(tasks) {
				if to, ok := t.(*object); ok && to.getString("id") == id {
					return to
				}
			}
		}
	}
	return nil
}

func asArray(v any) []any {
	a, _ := v.([]any)
	return a
}

// writeFileAtomic writes to a temp file in the same directory and renames it,
// so readers (the watcher, agents, editors) never see a half-written file.
func writeFileAtomic(path string, data []byte) error {
	tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-*.json")
	if err != nil {
		return err
	}
	defer os.Remove(tmp.Name())
	if _, err := tmp.Write(data); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Close(); err != nil {
		return err
	}
	return os.Rename(tmp.Name(), path)
}