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>
366 lines
9.8 KiB
Go
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
|
|
}
|