Files
may/internal/sync/sync.go
T
Hugo cantacuzeneandClaude Opus 5.5 187f43a8aa fix(sync): handle issues closed by hand
Only PR merges triggered checklist syncs, so a parent closed by hand never
got its own parent's box ticked nor the roadmap's close suggestion. Watched
issues' state is now observed; a close runs the checklist sync and is
acked once it succeeded.

Found by the end-to-end run.

Refs: #24
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 18:42:20 -04:00

366 lines
9.8 KiB
Go

// Package sync turns observations of the watched issues and PRs into events
// that have not been handled yet (spec §9). State lives in .git/may/sync:
//
// handled ids of handled comment/review/thread events
// bodies/N last handled title+body of issue N (may sections stripped)
// pending/body-N-H newer version awaiting ack
// ci/PR last handled "sha state"
// prs/PR last seen PR state (a PR seen open stays watched)
// baseline/ITEM first observation done
//
// Observation lines come from lib/sync.jq (kind, item, …; item is iN or pN).
package sync
import (
"bufio"
"crypto/sha1"
"encoding/base64"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
)
// Event is something new on a watched item.
type Event struct {
ID, Kind, Label, URL, Author, Excerpt string
}
// TSV renders the event as printed by `may sync events`.
func (e Event) TSV() string {
return strings.Join([]string{e.ID, e.Kind, e.Label, e.URL, e.Author, e.Excerpt}, "\t")
}
// ParseEvent reads an event TSV line.
func ParseEvent(line string) Event {
c := strings.SplitN(line, "\t", 6)
for len(c) < 6 {
c = append(c, "")
}
return Event{c[0], c[1], c[2], c[3], c[4], c[5]}
}
// State is the per-clone sync state directory.
type State struct{ dir string }
// Open prepares <stateDir>/sync.
func Open(stateDir string) (*State, error) {
s := &State{dir: filepath.Join(stateDir, "sync")}
for _, d := range []string{"bodies", "pending", "ci", "prs", "issues", "baseline"} {
if err := os.MkdirAll(filepath.Join(s.dir, d), 0o755); err != nil {
return nil, err
}
}
f, err := os.OpenFile(s.path("handled"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return nil, err
}
return s, f.Close()
}
func (s *State) path(parts ...string) string {
return filepath.Join(append([]string{s.dir}, parts...)...)
}
func (s *State) read(parts ...string) (string, bool) {
data, err := os.ReadFile(s.path(parts...))
if err != nil {
return "", false
}
return strings.TrimRight(string(data), "\n"), true
}
func (s *State) write(content string, parts ...string) {
_ = os.WriteFile(s.path(parts...), []byte(content), 0o644)
}
func (s *State) handled() map[string]bool {
set := map[string]bool{}
if data, err := os.ReadFile(s.path("handled")); err == nil {
sc := bufio.NewScanner(strings.NewReader(string(data)))
for sc.Scan() {
set[sc.Text()] = true
}
}
return set
}
func (s *State) markHandled(id string) {
f, err := os.OpenFile(s.path("handled"), os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return
}
defer f.Close()
fmt.Fprintln(f, id)
}
// OpenPRs are the PRs last seen open (still watched to notice merges).
func (s *State) OpenPRs() []string {
entries, _ := os.ReadDir(s.path("prs"))
var prs []string
for _, e := range entries {
if v, _ := s.read("prs", e.Name()); v == "OPEN" {
prs = append(prs, e.Name())
}
}
return prs
}
// ForgetPR drops the stored state of a PR that no longer resolves.
func (s *State) ForgetPR(n string) {
os.Remove(s.path("prs", n))
}
var mayStart = regexp.MustCompile(`<!-- may:([\w-]+):start -->`)
var tick = regexp.MustCompile(`(?m)^(\s*[-*] )\[[xX]\]`)
// StripMaySections removes the blocks may rewrites itself
// (<!-- may:NAME:start --> … <!-- may:NAME:end -->, markers included), except
// the tasks section: its items are the acceptance criteria, written by people,
// so they are kept with their boxes unticked (ticking is not a change).
func StripMaySections(text string) string { return stripSections(text, true) }
func stripSections(text string, keepTasks bool) string {
from := 0
for {
loc := mayStart.FindStringSubmatchIndex(text[from:])
if loc == nil {
return text
}
for i := range loc {
loc[i] += from
}
name := text[loc[2]:loc[3]]
end := "<!-- may:" + name + ":end -->"
i := strings.Index(text[loc[1]:], end)
if i < 0 {
return text
}
inner := text[loc[1] : loc[1]+i]
if name == "tasks" && keepTasks {
kept := tick.ReplaceAllString(inner, "$1[ ]")
text = text[:loc[0]] + kept + text[loc[1]+i+len(end):]
from = loc[0] + len(kept)
continue
}
text = text[:loc[0]] + text[loc[1]+i+len(end):]
from = loc[0]
}
}
// hash is git's blob hash of text (compatible with state written by `git hash-object`).
func hash(text string) string {
h := sha1.New()
fmt.Fprintf(h, "blob %d\x00%s", len(text), text)
return fmt.Sprintf("%x", h.Sum(nil))
}
func lf(text string) string { return strings.ReplaceAll(text, "\r\n", "\n") }
func label(item string) string {
if strings.HasPrefix(item, "p") {
return "PR #" + item[1:]
}
return "#" + item[1:]
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
// Compute returns the unhandled events of the observations, recording
// baselines (first observation: only unresolved threads and red CI are
// events) and silently handling comments posted by may.
func (s *State) Compute(obs []string) []Event {
handled := s.handled()
var first []string
for _, line := range obs {
c := strings.Split(line, "\t")
if len(c) < 2 {
continue
}
if _, err := os.Stat(s.path("baseline", c[1])); err != nil && !contains(first, c[1]) {
first = append(first, c[1])
}
}
var events []Event
for _, line := range obs {
c := strings.Split(line, "\t")
for len(c) < 9 {
c = append(c, "")
}
item, n := c[1], c[1][1:]
isFirst := contains(first, item)
switch c[0] {
case "body":
raw, _ := base64.StdEncoding.DecodeString(c[2])
// Line endings are not content (the GitHub web UI stores CRLF).
text := strings.TrimRight(StripMaySections(strings.TrimRight(lf(string(raw)), "\n")), "\n")
stored, ok := s.read("bodies", n)
if !ok {
s.write(text, "bodies", n)
continue
}
h := hash(text)
if hash(lf(stored)) == h {
continue
}
// Stored before the tasks section was kept: migrate silently.
legacy := strings.TrimRight(stripSections(strings.TrimRight(lf(string(raw)), "\n"), false), "\n")
if hash(lf(stored)) == hash(legacy) {
s.write(text, "bodies", n)
continue
}
s.write(text, "pending", "body-"+n+"-"+h)
events = append(events, Event{"body:" + n + ":" + h, "body", label(item), "", "", "title/body changed"})
case "comment", "review", "thread":
id := c[0] + ":" + c[2]
if handled[id] {
continue
}
if c[6] == "may" || (isFirst && !(c[0] == "thread" && c[8] == "open")) {
s.markHandled(id)
handled[id] = true
continue
}
events = append(events, Event{id, c[0], label(item), c[3], c[4], c[5]})
case "ci":
sha, state := c[2], c[3]
if contains([]string{"PENDING", "EXPECTED", "NONE"}, state) {
continue
}
current := sha + " " + state
if isFirst && state != "FAILURE" && state != "ERROR" {
s.write(current+"\n", "ci", n)
continue
}
if stored, ok := s.read("ci", n); ok && stored == current {
continue
}
short := sha
if len(short) > 7 {
short = short[:7]
}
events = append(events, Event{"ci:" + n + ":" + sha + ":" + state, "ci", label(item), "", "", "CI " + state + " on " + short})
case "issue":
state := c[2]
stored, ok := s.read("issues", n)
if !ok || isFirst {
s.write(state+"\n", "issues", n)
continue
}
if stored == state {
continue
}
if state == "CLOSED" {
events = append(events, Event{"issue:" + n + ":CLOSED", "issue", label(item), "", "", "issue closed"})
} else {
s.write(state+"\n", "issues", n)
}
case "pr":
state := c[2]
stored, ok := s.read("prs", n)
if !ok {
s.write(state+"\n", "prs", n)
continue
}
if stored == state {
continue
}
if state == "MERGED" || state == "CLOSED" {
events = append(events, Event{"pr:" + n + ":" + state, "pr", label(item), "", "", "PR " + strings.ToLower(state)})
} else {
s.write(state+"\n", "prs", n)
}
}
}
for _, item := range first {
s.write("", "baseline", item)
}
return events
}
// Render groups events by item, with the unified diff of body changes.
func (s *State) Render(events []Event) string {
var labels []string
for _, e := range events {
if !contains(labels, e.Label) {
labels = append(labels, e.Label)
}
}
var b strings.Builder
for _, l := range labels {
fmt.Fprintf(&b, "%s:\n", l)
for _, e := range events {
if e.Label != l {
continue
}
url := ""
if e.URL != "" {
url = " (" + e.URL + ")"
}
switch e.Kind {
case "body":
p := strings.SplitN(e.ID, ":", 3)
b.WriteString(" - title/body changed:\n")
out, _ := exec.Command("diff", "-u", s.path("bodies", p[1]), s.path("pending", "body-"+p[1]+"-"+p[2])).Output()
lines := strings.Split(strings.TrimRight(string(out), "\n"), "\n")
if len(lines) > 2 {
lines = lines[2:]
}
if len(lines) > 40 {
lines = lines[:40]
}
for _, line := range lines {
b.WriteString(" " + line + "\n")
}
case "comment":
fmt.Fprintf(&b, " - comment by %s: %s%s\n", e.Author, e.Excerpt, url)
case "review":
fmt.Fprintf(&b, " - review by %s: %s%s\n", e.Author, e.Excerpt, url)
case "thread":
fmt.Fprintf(&b, " - review thread comment by %s: %s%s\n", e.Author, e.Excerpt, url)
default:
fmt.Fprintf(&b, " - %s\n", e.Excerpt)
}
}
}
return b.String()
}
// Ack marks events handled; it returns the ids it does not know.
func (s *State) Ack(ids []string) []string {
var unknown []string
for _, id := range ids {
p := strings.Split(id, ":")
switch {
case p[0] == "comment" || p[0] == "review" || p[0] == "thread":
s.markHandled(id)
case p[0] == "body" && len(p) == 3:
_ = os.Rename(s.path("pending", "body-"+p[1]+"-"+p[2]), s.path("bodies", p[1]))
case p[0] == "ci" && len(p) == 4:
s.write(p[2]+" "+p[3]+"\n", "ci", p[1])
case p[0] == "pr" && len(p) == 3:
s.write(p[2]+"\n", "prs", p[1])
case p[0] == "issue" && len(p) == 3:
s.write(p[2]+"\n", "issues", p[1])
default:
unknown = append(unknown, id)
}
}
return unknown
}