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