133728e barerepo 1mo
1
// Package thread stores discussion in git notes, where a ref makes it a proposal. Chapter 13.
133728e barerepo 1mo
2
package thread
133728e barerepo 1mo
4
import (
133728e barerepo 1mo
5
"context"
133728e barerepo 1mo
6
"crypto/sha256"
133728e barerepo 1mo
7
"encoding/hex"
133728e barerepo 1mo
8
"fmt"
133728e barerepo 1mo
9
"sort"
133728e barerepo 1mo
10
"strconv"
133728e barerepo 1mo
11
"strings"
133728e barerepo 1mo
12
"time"
133728e barerepo 1mo
14
"github.com/barerepo/server/internal/cache"
133728e barerepo 1mo
15
"github.com/barerepo/server/internal/gitx"
133728e barerepo 1mo
18
func Ref(n int) string { return "refs/notes/threads/" + strconv.Itoa(n) }
133728e barerepo 1mo
20
// State is where a thread is. Chapter 12 lists them.
133728e barerepo 1mo
21
type State string
133728e barerepo 1mo
23
const (
133728e barerepo 1mo
24
Open State = "open"
133728e barerepo 1mo
25
Merged State = "merged"
133728e barerepo 1mo
26
Closed State = "closed"
133728e barerepo 1mo
27
Abandoned State = "abandoned"
133728e barerepo 1mo
30
// Meta is not a valid object hash, so git notes ignores it and chapter 35.4 keeps working.
133728e barerepo 1mo
31
type Meta struct {
133728e barerepo 1mo
32
Title string
133728e barerepo 1mo
33
State State
133728e barerepo 1mo
34
Ref string // the attached proposal ref, if any
133728e barerepo 1mo
35
Author string
133728e barerepo 1mo
36
Opened time.Time
133728e barerepo 1mo
37
// Merged is the commit that made the proposal reachable, observed and not caused. Rule 1.
133728e barerepo 1mo
38
Merged string
133728e barerepo 1mo
41
// Comment is one record inside a note.
133728e barerepo 1mo
42
type Comment struct {
133728e barerepo 1mo
43
Author string
133728e barerepo 1mo
44
Time time.Time
133728e barerepo 1mo
45
Anchor string // "config.go:43", optional
133728e barerepo 1mo
46
Blob string // the file's blob hash when the comment was made
133728e barerepo 1mo
47
Revision int
133728e barerepo 1mo
48
Side string // old | new
133728e barerepo 1mo
49
Body string
133728e barerepo 1mo
52
// recordSep separates records inside one note.
133728e barerepo 1mo
53
const recordSep = "--"
133728e barerepo 1mo
55
// Render writes a comment in the header-and-body form of chapter 13.
133728e barerepo 1mo
56
func (c Comment) Render() string {
133728e barerepo 1mo
57
var b strings.Builder
133728e barerepo 1mo
58
fmt.Fprintf(&b, "author: %s\n", oneLine(c.Author))
133728e barerepo 1mo
59
fmt.Fprintf(&b, "time: %d\n", c.Time.Unix())
133728e barerepo 1mo
60
if c.Anchor != "" {
133728e barerepo 1mo
61
fmt.Fprintf(&b, "anchor: %s\n", oneLine(c.Anchor))
133728e barerepo 1mo
63
if c.Blob != "" {
133728e barerepo 1mo
64
fmt.Fprintf(&b, "blob: %s\n", oneLine(c.Blob))
133728e barerepo 1mo
66
if c.Revision > 0 {
133728e barerepo 1mo
67
fmt.Fprintf(&b, "revision: %d\n", c.Revision)
133728e barerepo 1mo
69
if c.Side != "" {
133728e barerepo 1mo
70
fmt.Fprintf(&b, "side: %s\n", oneLine(c.Side))
133728e barerepo 1mo
72
b.WriteString("\n")
133728e barerepo 1mo
73
b.WriteString(escapeBody(strings.TrimRight(c.Body, "\n")))
133728e barerepo 1mo
74
b.WriteString("\n")
133728e barerepo 1mo
75
return b.String()
133728e barerepo 1mo
78
// oneLine keeps a header value from becoming a second header, since a record is lines of key: value.
133728e barerepo 1mo
79
func oneLine(s string) string {
133728e barerepo 1mo
80
return strings.Map(func(r rune) rune {
133728e barerepo 1mo
81
if r == '\n' || r == '\r' {
133728e barerepo 1mo
82
return ' '
133728e barerepo 1mo
84
return r
133728e barerepo 1mo
85
}, s)
133728e barerepo 1mo
88
// escapeBody keeps a body from ending its own record, because a reader who types -- is not a separator.
133728e barerepo 1mo
89
func escapeBody(body string) string {
133728e barerepo 1mo
90
lines := strings.Split(body, "\n")
133728e barerepo 1mo
91
for i, line := range lines {
133728e barerepo 1mo
92
if allDashes(line) {
133728e barerepo 1mo
93
lines[i] = "-" + line
133728e barerepo 1mo
96
return strings.Join(lines, "\n")
133728e barerepo 1mo
99
// unescapeBody undoes it, and a line of two dashes cannot arrive here because writing one adds a third.
133728e barerepo 1mo
100
func unescapeBody(body string) string {
133728e barerepo 1mo
101
lines := strings.Split(body, "\n")
133728e barerepo 1mo
102
for i, line := range lines {
133728e barerepo 1mo
103
if allDashes(line) && len(line) > len(recordSep) {
133728e barerepo 1mo
104
lines[i] = line[1:]
133728e barerepo 1mo
105
}
133728e barerepo 1mo
106
}
133728e barerepo 1mo
107
return strings.Join(lines, "\n")
133728e barerepo 1mo
110
// allDashes reports a line that is only dashes, which is the shape a separator has.
133728e barerepo 1mo
111
func allDashes(line string) bool {
133728e barerepo 1mo
112
if len(line) < len(recordSep) {
133728e barerepo 1mo
113
return false
133728e barerepo 1mo
114
}
133728e barerepo 1mo
115
return strings.Trim(line, "-") == ""
133728e barerepo 1mo
118
// ParseComments reads every record in one note.
133728e barerepo 1mo
119
func ParseComments(note string) []Comment {
133728e barerepo 1mo
120
var out []Comment
133728e barerepo 1mo
121
for _, record := range splitRecords(note) {
133728e barerepo 1mo
122
if c, ok := parseComment(record); ok {
133728e barerepo 1mo
123
out = append(out, c)
133728e barerepo 1mo
124
}
133728e barerepo 1mo
125
}
133728e barerepo 1mo
126
// Union merge concatenates without regard to order, so the reader sorts.
133728e barerepo 1mo
127
sort.SliceStable(out, func(i, j int) bool { return out[i].Time.Before(out[j].Time) })
133728e barerepo 1mo
128
return out
133728e barerepo 1mo
131
func splitRecords(note string) []string {
133728e barerepo 1mo
132
var out []string
133728e barerepo 1mo
133
var cur []string
133728e barerepo 1mo
134
for _, line := range strings.Split(note, "\n") {
133728e barerepo 1mo
135
if strings.TrimRight(line, " \t") == recordSep {
133728e barerepo 1mo
136
out = append(out, strings.Join(cur, "\n"))
133728e barerepo 1mo
137
cur = nil
133728e barerepo 1mo
138
continue
133728e barerepo 1mo
139
}
133728e barerepo 1mo
140
cur = append(cur, line)
133728e barerepo 1mo
141
}
133728e barerepo 1mo
142
out = append(out, strings.Join(cur, "\n"))
133728e barerepo 1mo
143
return out
133728e barerepo 1mo
146
func parseComment(record string) (Comment, bool) {
133728e barerepo 1mo
147
head, body, found := strings.Cut(strings.TrimLeft(record, "\n"), "\n\n")
133728e barerepo 1mo
148
if !found {
133728e barerepo 1mo
149
return Comment{}, false
133728e barerepo 1mo
150
}
133728e barerepo 1mo
151
var c Comment
133728e barerepo 1mo
152
c.Body = unescapeBody(strings.TrimRight(body, "\n"))
133728e barerepo 1mo
153
for _, line := range strings.Split(head, "\n") {
133728e barerepo 1mo
154
key, value, ok := strings.Cut(line, ": ")
133728e barerepo 1mo
155
if !ok {
133728e barerepo 1mo
156
continue
133728e barerepo 1mo
157
}
133728e barerepo 1mo
158
switch key {
133728e barerepo 1mo
159
case "author":
133728e barerepo 1mo
160
c.Author = value
133728e barerepo 1mo
161
case "time":
133728e barerepo 1mo
162
if secs, err := strconv.ParseInt(value, 10, 64); err == nil {
133728e barerepo 1mo
163
c.Time = time.Unix(secs, 0)
133728e barerepo 1mo
164
}
133728e barerepo 1mo
165
case "anchor":
133728e barerepo 1mo
166
c.Anchor = value
133728e barerepo 1mo
167
case "blob":
133728e barerepo 1mo
168
c.Blob = value
133728e barerepo 1mo
169
case "revision":
133728e barerepo 1mo
170
c.Revision, _ = strconv.Atoi(value)
133728e barerepo 1mo
171
case "side":
133728e barerepo 1mo
172
c.Side = value
133728e barerepo 1mo
173
}
133728e barerepo 1mo
174
}
133728e barerepo 1mo
175
if c.Author == "" {
133728e barerepo 1mo
176
return Comment{}, false
133728e barerepo 1mo
177
}
133728e barerepo 1mo
178
return c, true
133728e barerepo 1mo
181
// Render writes the meta blob.
133728e barerepo 1mo
182
func (m Meta) Render() string {
133728e barerepo 1mo
183
var b strings.Builder
133728e barerepo 1mo
184
fmt.Fprintf(&b, "title: %s\n", oneLine(m.Title))
133728e barerepo 1mo
185
fmt.Fprintf(&b, "state: %s\n", oneLine(string(m.State)))
133728e barerepo 1mo
186
if m.Ref != "" {
133728e barerepo 1mo
187
fmt.Fprintf(&b, "ref: %s\n", oneLine(m.Ref))
133728e barerepo 1mo
188
}
133728e barerepo 1mo
189
if m.Author != "" {
133728e barerepo 1mo
190
fmt.Fprintf(&b, "author: %s\n", oneLine(m.Author))
133728e barerepo 1mo
191
}
133728e barerepo 1mo
192
if !m.Opened.IsZero() {
133728e barerepo 1mo
193
fmt.Fprintf(&b, "opened: %d\n", m.Opened.Unix())
133728e barerepo 1mo
194
}
133728e barerepo 1mo
195
if m.Merged != "" {
133728e barerepo 1mo
196
fmt.Fprintf(&b, "merged: %s\n", oneLine(m.Merged))
133728e barerepo 1mo
197
}
133728e barerepo 1mo
198
return b.String()
133728e barerepo 1mo
201
// ParseMeta reads the meta blob.
133728e barerepo 1mo
202
func ParseMeta(body string) Meta {
133728e barerepo 1mo
203
m := Meta{State: Open}
133728e barerepo 1mo
204
for _, line := range strings.Split(body, "\n") {
133728e barerepo 1mo
205
key, value, ok := strings.Cut(line, ": ")
133728e barerepo 1mo
206
if !ok {
133728e barerepo 1mo
207
continue
133728e barerepo 1mo
208
}
133728e barerepo 1mo
209
switch key {
133728e barerepo 1mo
210
case "title":
133728e barerepo 1mo
211
m.Title = value
133728e barerepo 1mo
212
case "state":
133728e barerepo 1mo
213
m.State = State(value)
133728e barerepo 1mo
214
case "ref":
133728e barerepo 1mo
215
m.Ref = value
133728e barerepo 1mo
216
case "author":
133728e barerepo 1mo
217
m.Author = value
133728e barerepo 1mo
218
case "opened":
133728e barerepo 1mo
219
if secs, err := strconv.ParseInt(value, 10, 64); err == nil {
133728e barerepo 1mo
220
m.Opened = time.Unix(secs, 0)
133728e barerepo 1mo
221
}
133728e barerepo 1mo
222
case "merged":
133728e barerepo 1mo
223
m.Merged = value
133728e barerepo 1mo
224
}
133728e barerepo 1mo
225
}
133728e barerepo 1mo
226
return m