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) }