// Package thread stores discussion in git notes, where a ref makes it a proposal. Chapter 13. package thread import ( "context" "crypto/sha256" "encoding/hex" "fmt" "sort" "strconv" "strings" "time" "github.com/barerepo/server/internal/cache" "github.com/barerepo/server/internal/gitx" ) func Ref(n int) string { return "refs/notes/threads/" + strconv.Itoa(n) } // State is where a thread is. Chapter 12 lists them. type State string const ( Open State = "open" Merged State = "merged" Closed State = "closed" Abandoned State = "abandoned" ) // Meta is not a valid object hash, so git notes ignores it and chapter 35.4 keeps working. type Meta struct { Title string State State Ref string // the attached proposal ref, if any Author string Opened time.Time // Merged is the commit that made the proposal reachable, observed and not caused. Rule 1. Merged string } // Comment is one record inside a note. type Comment struct { Author string Time time.Time Anchor string // "config.go:43", optional Blob string // the file's blob hash when the comment was made Revision int Side string // old | new Body string } // recordSep separates records inside one note. const recordSep = "--" // Render writes a comment in the header-and-body form of chapter 13. func (c Comment) Render() string { var b strings.Builder fmt.Fprintf(&b, "author: %s\n", oneLine(c.Author)) fmt.Fprintf(&b, "time: %d\n", c.Time.Unix()) if c.Anchor != "" { fmt.Fprintf(&b, "anchor: %s\n", oneLine(c.Anchor)) } if c.Blob != "" { fmt.Fprintf(&b, "blob: %s\n", oneLine(c.Blob)) } if c.Revision > 0 { fmt.Fprintf(&b, "revision: %d\n", c.Revision) } if c.Side != "" { fmt.Fprintf(&b, "side: %s\n", oneLine(c.Side)) } b.WriteString("\n") b.WriteString(escapeBody(strings.TrimRight(c.Body, "\n"))) b.WriteString("\n") return b.String() } // oneLine keeps a header value from becoming a second header, since a record is lines of key: value. func oneLine(s string) string { return strings.Map(func(r rune) rune { if r == '\n' || r == '\r' { return ' ' } return r }, s) } // escapeBody keeps a body from ending its own record, because a reader who types -- is not a separator. func escapeBody(body string) string { lines := strings.Split(body, "\n") for i, line := range lines { if allDashes(line) { lines[i] = "-" + line } } return strings.Join(lines, "\n") } // unescapeBody undoes it, and a line of two dashes cannot arrive here because writing one adds a third. func unescapeBody(body string) string { lines := strings.Split(body, "\n") for i, line := range lines { if allDashes(line) && len(line) > len(recordSep) { lines[i] = line[1:] } } return strings.Join(lines, "\n") } // allDashes reports a line that is only dashes, which is the shape a separator has. func allDashes(line string) bool { if len(line) < len(recordSep) { return false } return strings.Trim(line, "-") == "" } // ParseComments reads every record in one note. func ParseComments(note string) []Comment { var out []Comment for _, record := range splitRecords(note) { if c, ok := parseComment(record); ok { out = append(out, c) } } // Union merge concatenates without regard to order, so the reader sorts. sort.SliceStable(out, func(i, j int) bool { return out[i].Time.Before(out[j].Time) }) return out } func splitRecords(note string) []string { var out []string var cur []string for _, line := range strings.Split(note, "\n") { if strings.TrimRight(line, " \t") == recordSep { out = append(out, strings.Join(cur, "\n")) cur = nil continue } cur = append(cur, line) } out = append(out, strings.Join(cur, "\n")) return out } func parseComment(record string) (Comment, bool) { head, body, found := strings.Cut(strings.TrimLeft(record, "\n"), "\n\n") if !found { return Comment{}, false } var c Comment c.Body = unescapeBody(strings.TrimRight(body, "\n")) for _, line := range strings.Split(head, "\n") { key, value, ok := strings.Cut(line, ": ") if !ok { continue } switch key { case "author": c.Author = value case "time": if secs, err := strconv.ParseInt(value, 10, 64); err == nil { c.Time = time.Unix(secs, 0) } case "anchor": c.Anchor = value case "blob": c.Blob = value case "revision": c.Revision, _ = strconv.Atoi(value) case "side": c.Side = value } } if c.Author == "" { return Comment{}, false } return c, true } // Render writes the meta blob. func (m Meta) Render() string { var b strings.Builder fmt.Fprintf(&b, "title: %s\n", oneLine(m.Title)) fmt.Fprintf(&b, "state: %s\n", oneLine(string(m.State))) if m.Ref != "" { fmt.Fprintf(&b, "ref: %s\n", oneLine(m.Ref)) } if m.Author != "" { fmt.Fprintf(&b, "author: %s\n", oneLine(m.Author)) } if !m.Opened.IsZero() { fmt.Fprintf(&b, "opened: %d\n", m.Opened.Unix()) } if m.Merged != "" { fmt.Fprintf(&b, "merged: %s\n", oneLine(m.Merged)) } return b.String() } // ParseMeta reads the meta blob. func ParseMeta(body string) Meta { m := Meta{State: Open} for _, line := range strings.Split(body, "\n") { key, value, ok := strings.Cut(line, ": ") if !ok { continue } switch key { case "title": m.Title = value case "state": m.State = State(value) case "ref": m.Ref = value case "author": m.Author = value case "opened": if secs, err := strconv.ParseInt(value, 10, 64); err == nil { m.Opened = time.Unix(secs, 0) } case "merged": m.Merged = value } } return m } // ReadMeta loads a thread's metadata, or reports whether it exists. func ReadMeta(ctx context.Context, dir string, n int) (Meta, bool, error) { out, err := gitx.Run(ctx, dir, "cat-file", "blob", Ref(n)+":meta") if err != nil { return Meta{}, false, nil } return ParseMeta(out), true, nil } // Summary is one row of the unified list, because splitting the two serves a schema, not a reader. type Summary struct { N int Meta Meta Replies int Updated time.Time } // Cache keys a row by the note commit that wrote it, so a row is computed once and never again. var Cache *cache.Disk // rowKind is the cache namespace, kept apart from the config and diff answers beside it. const rowKind = "thread-row" // List reads every thread, newest activity first, without a git process for one it has seen. func List(ctx context.Context, dir string) ([]Summary, error) { refs, err := gitx.ListRefs(dir, "refs/notes/threads") if err != nil { return nil, err } var list []Summary var coldN []int var commits []string for name, sha := range refs { n, err := strconv.Atoi(strings.TrimPrefix(name, "refs/notes/threads/")) if err != nil || n <= 0 { continue } if body, ok := Cache.Get(rowKind, sha); ok { if s, ok := parseRow(string(body)); ok { s.N = n list = append(list, s) continue } } coldN = append(coldN, n) commits = append(commits, sha) } cold, err := readRows(ctx, dir, coldN, commits) if err != nil { return nil, err } for i, s := range cold { if s.N == 0 { continue } Cache.Put(rowKind, commits[i], []byte(renderRow(s))) list = append(list, s) } // The number settles what the clock cannot, because the thread list pages out of this order and seconds tie easily. sort.Slice(list, func(i, j int) bool { if list[i].Updated.Equal(list[j].Updated) { return list[i].N > list[j].N } return list[i].Updated.After(list[j].Updated) }) return list, nil } // OpenCount is what the tab strip shows on every repository page, so it must cost no process. func OpenCount(ctx context.Context, dir string) int { list, err := List(ctx, dir) if err != nil { return 0 } n := 0 for _, s := range list { if s.Meta.State == Open { n++ } } return n } // openKind caches a count under the refs that produced it, apart from the rows beside it. const openKind = "thread-open" // OpenProposals counts the open threads that carry a ref, which is the number chapter 24 puts on a profile. func OpenProposals(ctx context.Context, dir string) int { refs, err := gitx.ListRefs(dir, "refs/notes/threads") if err != nil { return 0 } // A profile asks this of every repository it lists, so one read answers it. key := refsDigest(refs) if body, ok := Cache.Get(openKind, key); ok { if n, err := strconv.Atoi(string(body)); err == nil { return n } } list, err := List(ctx, dir) if err != nil { return 0 } n := 0 for _, s := range list { if s.Meta.State == Open && s.Meta.Ref != "" { n++ } } Cache.Put(openKind, key, []byte(strconv.Itoa(n))) return n } // refsDigest names the exact state the count was read from, so a new note is a new key. func refsDigest(refs map[string]string) string { names := make([]string, 0, len(refs)) for name := range refs { names = append(names, name) } sort.Strings(names) h := sha256.New() for _, name := range names { fmt.Fprintf(h, "%s %s\n", name, refs[name]) } return hex.EncodeToString(h.Sum(nil)) } // OpenProposalsBy counts what one account has open here, which is chapter 27's cap on rule 5. func OpenProposalsBy(ctx context.Context, dir, account string) int { if account == "" { return 0 } list, err := List(ctx, dir) if err != nil { return 0 } n := 0 for _, s := range list { if s.Meta.State == Open && s.Meta.Ref != "" && s.Meta.Author == account { n++ } } return n } // readRows builds the rows a cache miss left, in three batches whatever the thread count. func readRows(ctx context.Context, dir string, ns []int, commits []string) ([]Summary, error) { rows := make([]Summary, len(ns)) if len(ns) == 0 { return rows, nil } commitObjs, err := gitx.Batch(ctx, dir, commits) if err != nil { return nil, err } trees := make([]string, len(ns)) for i := range ns { c := commitObjs[commits[i]] if c == nil { continue } tree, when := commitTreeAndTime(c.Body) trees[i] = tree rows[i].Updated = when } treeObjs, err := gitx.Batch(ctx, dir, compact(trees)) if err != nil { return nil, err } var blobs []string entries := make([]map[string]string, len(ns)) for i := range ns { t := treeObjs[trees[i]] if t == nil { continue } entries[i] = gitx.TreeEntries(t.Body) for _, sha := range entries[i] { blobs = append(blobs, sha) } } blobObjs, err := gitx.Batch(ctx, dir, blobs) if err != nil { return nil, err } for i, n := range ns { meta, ok := entries[i]["meta"] if !ok || blobObjs[meta] == nil { continue } rows[i].N = n rows[i].Meta = ParseMeta(blobObjs[meta].Body) for name, sha := range entries[i] { if name == "meta" || blobObjs[sha] == nil { continue } rows[i].Replies += len(ParseComments(blobObjs[sha].Body)) } } return rows, nil } // compact drops the empty slots a missing commit left, since cat-file has nothing to say about "". func compact(in []string) []string { out := make([]string, 0, len(in)) for _, s := range in { if s != "" { out = append(out, s) } } return out } // commitTreeAndTime reads the two facts a row needs out of a raw commit object. func commitTreeAndTime(body string) (string, time.Time) { tree, when := "", time.Time{} for _, line := range strings.Split(body, "\n") { if line == "" { break } switch { case strings.HasPrefix(line, "tree "): tree = strings.TrimSpace(strings.TrimPrefix(line, "tree ")) case strings.HasPrefix(line, "committer "): // The timestamp is the second field from the end, before the zone offset. fields := strings.Fields(line) if len(fields) >= 2 { if secs, err := strconv.ParseInt(fields[len(fields)-2], 10, 64); err == nil { when = time.Unix(secs, 0) } } } } return tree, when } // renderRow writes a row for the cache, in the header form the notes themselves use. func renderRow(s Summary) string { var b strings.Builder fmt.Fprintf(&b, "updated: %d\n", s.Updated.Unix()) fmt.Fprintf(&b, "replies: %d\n", s.Replies) b.WriteString("\n") b.WriteString(s.Meta.Render()) return b.String() } // parseRow reads a cached row back, and anything it does not understand is a miss. func parseRow(body string) (Summary, bool) { head, meta, ok := strings.Cut(body, "\n\n") if !ok { return Summary{}, false } var s Summary for _, line := range strings.Split(head, "\n") { key, value, ok := strings.Cut(line, ": ") if !ok { continue } switch key { case "updated": secs, err := strconv.ParseInt(value, 10, 64) if err != nil { return Summary{}, false } s.Updated = time.Unix(secs, 0) case "replies": n, err := strconv.Atoi(value) if err != nil { return Summary{}, false } s.Replies = n } } s.Meta = ParseMeta(meta) return s, true } // MetaAt reads one note commit's meta, which is how a hook sees a thread before and after a push. func MetaAt(ctx context.Context, dir, commit string) (Meta, bool) { if commit == "" || strings.Trim(commit, "0") == "" { return Meta{}, false } spec := commit + "^{tree}" head, err := gitx.Batch(ctx, dir, []string{spec}) if err != nil || head[spec] == nil { return Meta{}, false } meta, ok := gitx.TreeEntries(head[spec].Body)["meta"] if !ok { return Meta{}, false } blobs, err := gitx.Batch(ctx, dir, []string{meta}) if err != nil || blobs[meta] == nil { return Meta{}, false } return ParseMeta(blobs[meta].Body), true } // NumberOf reads a thread number out of its notes ref, or returns 0. func NumberOf(ref string) int { rest, ok := strings.CutPrefix(ref, "refs/notes/threads/") if !ok { return 0 } n, err := strconv.Atoi(rest) if err != nil || n <= 0 { return 0 } return n } // Read loads a whole thread: its metadata and every comment, in time order. func Read(ctx context.Context, dir string, n int) (Meta, []Comment, error) { // Two processes, whatever the comment count, where a cat-file per note cost far more. head, err := gitx.Batch(ctx, dir, []string{Ref(n) + "^{tree}"}) if err != nil { return Meta{}, nil, err } tree := head[Ref(n)+"^{tree}"] if tree == nil { return Meta{}, nil, nil } entries := gitx.TreeEntries(tree.Body) specs := make([]string, 0, len(entries)) for _, sha := range entries { specs = append(specs, sha) } blobs, err := gitx.Batch(ctx, dir, specs) if err != nil { return Meta{}, nil, err } meta, ok := entries["meta"] if !ok || blobs[meta] == nil { return Meta{}, nil, nil } m := ParseMeta(blobs[meta].Body) var all []Comment for name, sha := range entries { if name == "meta" || blobs[sha] == nil { continue } all = append(all, ParseComments(blobs[sha].Body)...) } sort.SliceStable(all, func(i, j int) bool { return all[i].Time.Before(all[j].Time) }) return m, all, nil } // RecordsAt is every comment a thread's note holds, so a push that drops one can be told, since chapter 13 keeps them as records and only appends. func RecordsAt(ctx context.Context, dir, commit string) map[string]bool { out := map[string]bool{} if commit == "" || strings.Trim(commit, "0") == "" { return out } spec := commit + "^{tree}" head, err := gitx.Batch(ctx, dir, []string{spec}) if err != nil || head[spec] == nil { return out } // meta is the thread's own record and is guarded on its own, so only the notes are read here. specs := make([]string, 0, 4) for name, sha := range gitx.TreeEntries(head[spec].Body) { if name != "meta" { specs = append(specs, sha) } } if len(specs) == 0 { return out } blobs, err := gitx.Batch(ctx, dir, specs) if err != nil { return out } for _, sha := range specs { if blobs[sha] == nil { continue } for _, c := range ParseComments(blobs[sha].Body) { out[c.Render()] = true } } return out }