Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · metadata/issue_write.go

Line attribution on main · 354 line(s)

c5902451
Keenan Nemetz
16d ago
1 package metadata
c1e3e3ca
Keenan Nemetz
18d ago
2
3 import (
67dd8854
Keenan Nemetz
17d ago
4 "errors"
c1e3e3ca
Keenan Nemetz
18d ago
5 "fmt"
307cbc2a
Keenan Nemetz
17d ago
6 "sort"
9cc2f087
Keenan Nemetz
17d ago
7 "strconv"
c1e3e3ca
Keenan Nemetz
18d ago
8 "strings"
9 "time"
10
d37d301f
Keenan Nemetz
19h ago
11 "rodent.software/vole/core/git"
12 voleid "rodent.software/vole/core/id"
c1e3e3ca
Keenan Nemetz
18d ago
13 )
14
67dd8854
Keenan Nemetz
17d ago
15 // ErrNoChange is returned by Update when a mutator decides the entity already
16 // says what it would write. The branch does not move: an edit that changes
17 // nothing is not a commit.
18 var ErrNoChange = errors.New("no change")
19
c1e3e3ca
Keenan Nemetz
18d ago
20 // New fills in the fields a caller should not have to get right.
c5902451
Keenan Nemetz
16d ago
21 func NewIssue(title, body, author string, labels, assignees []string, now time.Time) (*Issue, error) {
22 id, err := voleid.New(now)
c1e3e3ca
Keenan Nemetz
18d ago
23 if err != nil {
24 return nil, err
25 }
26 return &Issue{
27 ID: id,
28 Title: strings.TrimSpace(title),
29 State: "open",
30 Author: strings.TrimSpace(author),
31 Created: now.UTC(),
32 Labels: labels,
67dd8854
Keenan Nemetz
17d ago
33 Assignees: assignees,
c1e3e3ca
Keenan Nemetz
18d ago
34 Description: body,
35 }, nil
36 }
37
38 // Comment builds a comment with a fresh id.
c5902451
Keenan Nemetz
16d ago
39 func NewIssueComment(body, author string, now time.Time) (*Comment, error) {
40 id, err := voleid.New(now)
c1e3e3ca
Keenan Nemetz
18d ago
41 if err != nil {
42 return nil, err
43 }
4581690d
Keenan Nemetz
13d ago
44 if problems := bodyProblems("comment", body); len(problems) > 0 {
45 return nil, fmt.Errorf("issues: %s", problems[0])
46 }
c1e3e3ca
Keenan Nemetz
18d ago
47 return &Comment{ID: id, Author: strings.TrimSpace(author), Created: now.UTC(), Body: body}, nil
48 }
49
50 // Create writes a new issue onto the issues ref, creating the branch if needed.
c5902451
Keenan Nemetz
16d ago
51 func CreateIssue(g *git.Repo, iss *Issue, author string) error {
52 if err := validateIssueForWrite(iss); err != nil {
c1e3e3ca
Keenan Nemetz
18d ago
53 return err
54 }
307cbc2a
Keenan Nemetz
17d ago
55 raw, err := renderIssue(iss)
c1e3e3ca
Keenan Nemetz
18d ago
56 if err != nil {
57 return err
58 }
c5902451
Keenan Nemetz
16d ago
59 base := fmt.Sprintf("%s/%s/", IssuesDir, iss.ID)
60 _, err = git.CommitFiles(g, Ref, func(files map[string][]byte) error {
307cbc2a
Keenan Nemetz
17d ago
61 if _, exists := files[base+issueName]; exists {
c1e3e3ca
Keenan Nemetz
18d ago
62 return fmt.Errorf("issues: %s already exists", iss.ID)
63 }
307cbc2a
Keenan Nemetz
17d ago
64 files[base+issueName] = raw
c1e3e3ca
Keenan Nemetz
18d ago
65 return nil
66 }, fmt.Sprintf("issue: %s (%s)", iss.Title, iss.ID), actorOr(author, iss.Author))
67 return err
68 }
69
70 // Update applies mutate to an existing issue and commits the result.
c5902451
Keenan Nemetz
16d ago
71 func UpdateIssue(g *git.Repo, id string, mutate func(*Issue) error, actor string) (*Issue, error) {
8e93a8f0
Keenan Nemetz
15d ago
72 return updateIssue(g, id, mutate, actor, "issue: update "+id)
73 }
74
75 // SetIssueState closes or reopens an issue with a log subject that names the
76 // state, so the activity feed can derive state changes from the metadata branch
77 // without diffing every historical issue file.
78 func SetIssueState(g *git.Repo, id, state, actor string) (*Issue, error) {
79 if state != "open" && state != "closed" {
80 return nil, fmt.Errorf("issues: state must be open or closed")
81 }
82 return updateIssue(g, id, func(iss *Issue) error {
83 if iss.State == state {
84 return ErrNoChange
85 }
86 iss.State = state
87 return nil
88 }, actor, fmt.Sprintf("issue: %s %s", id, state))
89 }
90
91 func updateIssue(g *git.Repo, id string, mutate func(*Issue) error, actor, message string) (*Issue, error) {
122cbe4f
Keenan Nemetz
7d ago
92 var out *Issue
93 base := fmt.Sprintf("%s/%s/", IssuesDir, id)
94 _, err := git.CommitFilesBy(g, Ref, func(files map[string][]byte) (string, error) {
95 iss, err := loadIssueFrom(files, id)
96 if err != nil {
97 return "", err
c1e3e3ca
Keenan Nemetz
18d ago
98 }
122cbe4f
Keenan Nemetz
7d ago
99 if err := mutate(iss); err != nil {
100 return "", err
101 }
102 if err := validateIssueForWrite(iss); err != nil {
103 return "", err
104 }
105 raw, err := renderIssue(iss)
106 if err != nil {
107 return "", err
67dd8854
Keenan Nemetz
17d ago
108 }
307cbc2a
Keenan Nemetz
17d ago
109 files[base+issueName] = raw
122cbe4f
Keenan Nemetz
7d ago
110 out = iss
111 return actorOr(actor, iss.Author), nil
112 }, message)
c1e3e3ca
Keenan Nemetz
18d ago
113 if err != nil {
114 return nil, err
115 }
122cbe4f
Keenan Nemetz
7d ago
116 return out, nil
c1e3e3ca
Keenan Nemetz
18d ago
117 }
118
119 // AddComment appends a comment file to an existing issue.
c5902451
Keenan Nemetz
16d ago
120 func AddIssueComment(g *git.Repo, id string, c *Comment, actor string) error {
122cbe4f
Keenan Nemetz
7d ago
121 path := IssueCommentPath(id, c.ID)
122 _, err := git.CommitFilesBy(g, Ref, func(files map[string][]byte) (string, error) {
123 iss, err := loadIssueFrom(files, id)
124 if err != nil {
125 return "", err
126 }
127 if prob := checkCommentReply(iss.Comments, c); prob != "" {
128 return "", fmt.Errorf("issues: %s", prob)
129 }
130 if strings.TrimSpace(c.Author) == "" {
131 c.Author = actor
132 }
133 if strings.TrimSpace(c.Author) == "" {
134 return "", fmt.Errorf("issues: comment needs an author")
135 }
136 if c.Created.IsZero() {
137 c.Created = time.Now().UTC()
138 }
139 raw, err := RenderComment(c)
140 if err != nil {
141 return "", err
c1e3e3ca
Keenan Nemetz
18d ago
142 }
143 if _, exists := files[path]; exists {
122cbe4f
Keenan Nemetz
7d ago
144 return "", fmt.Errorf("issues: comment %s already exists", c.ID)
c1e3e3ca
Keenan Nemetz
18d ago
145 }
307cbc2a
Keenan Nemetz
17d ago
146 files[path] = raw
122cbe4f
Keenan Nemetz
7d ago
147 return c.Author, nil
148 }, fmt.Sprintf("issue: comment on %s", id))
c1e3e3ca
Keenan Nemetz
18d ago
149 return err
150 }
151
79c88d72
Keenan Nemetz
17d ago
152 // EditComment rewrites a comment's body in place. The path stays the same, so the
153 // comment keeps its id, its place in the thread, and one file to log: `git log -p
154 // -- issues/<id>/comments/<cid>.md` is what it used to say. There is no `edited`
155 // field for the same reason issues have no `updated` field — the commit that
156 // changed the file carries the time.
157 //
158 // Ownership is not checked here: the person with the key can only be themselves.
159 // Only the named author (or an admin, audited) may change someone else's comment,
160 // and that rule belongs at the push boundary where the account is known.
c5902451
Keenan Nemetz
16d ago
161 func EditIssueComment(g *git.Repo, issueID, commentID, body, actor string) (*Comment, error) {
79c88d72
Keenan Nemetz
17d ago
162 body = strings.TrimRight(body, "\n")
163 if strings.TrimSpace(body) == "" {
164 return nil, fmt.Errorf("issues: a comment needs a body")
165 }
4581690d
Keenan Nemetz
13d ago
166 if problems := bodyProblems("comment", body); len(problems) > 0 {
167 return nil, fmt.Errorf("issues: %s", problems[0])
168 }
122cbe4f
Keenan Nemetz
7d ago
169 path := IssueCommentPath(issueID, commentID)
170 var out *Comment
171 _, err := git.CommitFilesBy(g, Ref, func(files map[string][]byte) (string, error) {
172 c, err := loadIssueComment(files, issueID, commentID)
173 if err != nil {
174 return "", err
175 }
176 if c.Deleted {
177 return "", fmt.Errorf("issues: cannot edit a deleted comment")
178 }
179 c.Body = body
180 raw, err := RenderComment(c)
181 if err != nil {
182 return "", err
79c88d72
Keenan Nemetz
17d ago
183 }
184 files[path] = raw
122cbe4f
Keenan Nemetz
7d ago
185 out = c
186 return actorOr(actor, c.Author), nil
187 }, fmt.Sprintf("issue: edit comment %s on %s", commentID, issueID))
79c88d72
Keenan Nemetz
17d ago
188 if err != nil {
189 return nil, err
190 }
122cbe4f
Keenan Nemetz
7d ago
191 return out, nil
79c88d72
Keenan Nemetz
17d ago
192 }
193
f6f4b1db
Keenan Nemetz
15d ago
194 // DeleteComment replaces a comment with a tombstone. The text stays in history —
195 // deleting is not redaction, which is still an admin force-push — and the marker
196 // remains so replies keep the parent they answer.
c5902451
Keenan Nemetz
16d ago
197 func DeleteIssueComment(g *git.Repo, issueID, commentID, actor string) (*Comment, error) {
122cbe4f
Keenan Nemetz
7d ago
198 path := IssueCommentPath(issueID, commentID)
199 var out *Comment
200 _, err := git.CommitFilesBy(g, Ref, func(files map[string][]byte) (string, error) {
201 c, err := loadIssueComment(files, issueID, commentID)
202 if err != nil {
203 return "", err
204 }
205 if c.Deleted {
206 return "", fmt.Errorf("issues: comment %s is already deleted", commentID)
207 }
208 c.Deleted = true
209 c.Body = ""
210 c.Anchor = nil
211 raw, err := RenderComment(c)
212 if err != nil {
213 return "", err
214 }
215 files[path] = raw
216 out = c
217 return actorOr(actor, c.Author), nil
218 }, fmt.Sprintf("issue: delete comment %s on %s", commentID, issueID))
79c88d72
Keenan Nemetz
17d ago
219 if err != nil {
220 return nil, err
221 }
122cbe4f
Keenan Nemetz
7d ago
222 return out, nil
223 }
224
225 // loadIssueFrom reads one issue and its comments out of the tree a commit is being
226 // built from. A write decides against the tip it is about to replace, so a change
227 // that landed since the page was drawn is visible instead of being overwritten, and
228 // the branch is read once rather than once for the decision and once for the commit.
229 func loadIssueFrom(files map[string][]byte, id string) (*Issue, error) {
230 prefix := IssuesDir + "/" + id + "/"
231 contents := make(map[string]string)
232 for path, raw := range files {
233 if strings.HasPrefix(path, prefix) {
234 contents[path] = string(raw)
235 }
f6f4b1db
Keenan Nemetz
15d ago
236 }
122cbe4f
Keenan Nemetz
7d ago
237 list, err := ParseIssues(contents)
f6f4b1db
Keenan Nemetz
15d ago
238 if err != nil {
122cbe4f
Keenan Nemetz
7d ago
239 if ve, ok := err.(*ValidationError); ok {
240 return nil, fmt.Errorf("issues: the metadata tree is invalid: %s", ve.Problems[0])
241 }
f6f4b1db
Keenan Nemetz
15d ago
242 return nil, err
243 }
122cbe4f
Keenan Nemetz
7d ago
244 if len(list) == 0 {
245 return nil, fmt.Errorf("issues: no issue %s", id)
246 }
247 return list[0], nil
248 }
249
250 // loadIssueComment is loadIssueFrom for a write that names one comment.
251 func loadIssueComment(files map[string][]byte, issueID, commentID string) (*Comment, error) {
252 iss, err := loadIssueFrom(files, issueID)
79c88d72
Keenan Nemetz
17d ago
253 if err != nil {
254 return nil, err
255 }
122cbe4f
Keenan Nemetz
7d ago
256 c, ok := commentOf(iss.Comments, commentID)
257 if !ok {
258 return nil, fmt.Errorf("issues: no comment %s on %s", commentID, issueID)
259 }
79c88d72
Keenan Nemetz
17d ago
260 return c, nil
261 }
262
122cbe4f
Keenan Nemetz
7d ago
263 // commentOf finds one comment by id and returns a copy, so the write can change it
264 // without touching the tree it was parsed from. Issues and change requests keep
265 // their discussion in the same shape, so both find it the same way.
266 func commentOf(list []Comment, id string) (*Comment, bool) {
267 for i := range list {
268 if list[i].ID == id {
269 c := list[i]
270 return &c, true
79c88d72
Keenan Nemetz
17d ago
271 }
272 }
122cbe4f
Keenan Nemetz
7d ago
273 return nil, false
79c88d72
Keenan Nemetz
17d ago
274 }
275
7c6e52e7
Keenan Nemetz
17d ago
276 // RenderComment writes a comment as frontmatter plus body. Exported because a
277 // comment on a request is the same file shape, read and written by the same code.
278 func RenderComment(c *Comment) ([]byte, error) {
c5902451
Keenan Nemetz
16d ago
279 d := NewDoc()
307cbc2a
Keenan Nemetz
17d ago
280 d.Word("author", c.Author)
281 d.Word("created", c.Created.UTC().Format(time.RFC3339))
f6f4b1db
Keenan Nemetz
15d ago
282 d.Word("reply_to", c.ReplyTo)
283 if c.Deleted {
284 d.Word("deleted", "true")
285 }
9cc2f087
Keenan Nemetz
17d ago
286 if c.Anchor != nil {
287 // The anchor goes after the header fields so a reader that only wants the
288 // thread still sees author and time first, and one added anchor is one
289 // added block rather than a rewritten line.
290 d.Word("path", c.Anchor.Path)
291 d.Word("side", c.Anchor.Side)
292 d.Word("line", strconv.Itoa(c.Anchor.Line))
293 d.Word("blob", c.Anchor.Blob)
294 }
307cbc2a
Keenan Nemetz
17d ago
295 return d.Bytes(strings.TrimRight(c.Body, "\n"))
c1e3e3ca
Keenan Nemetz
18d ago
296 }
297
307cbc2a
Keenan Nemetz
17d ago
298 // renderIssue writes an issue as one file: the frontmatter head, then the
299 // description as the body. One path per issue means one file to diff, blame or
300 // log, and no way for the two halves to disagree.
301 func renderIssue(iss *Issue) ([]byte, error) {
c5902451
Keenan Nemetz
16d ago
302 d := NewDoc()
307cbc2a
Keenan Nemetz
17d ago
303 d.Text("title", iss.Title)
304 d.Word("state", iss.State)
305 d.Word("author", iss.Author)
306 d.Word("created", iss.Created.UTC().Format(time.RFC3339))
307 labels := append([]string(nil), iss.Labels...)
308 sort.Strings(labels)
309 d.List("labels", labels)
67dd8854
Keenan Nemetz
17d ago
310 assignees := append([]string(nil), iss.Assignees...)
311 sort.Strings(assignees)
312 d.List("assignees", assignees)
307cbc2a
Keenan Nemetz
17d ago
313 return d.Bytes(strings.TrimRight(iss.Description, "\n"))
c1e3e3ca
Keenan Nemetz
18d ago
314 }
315
c5902451
Keenan Nemetz
16d ago
316 // validateIssueForWrite runs the same checks the push boundary applies, so the CLI
c1e3e3ca
Keenan Nemetz
18d ago
317 // fails locally instead of after a round trip.
c5902451
Keenan Nemetz
16d ago
318 func validateIssueForWrite(iss *Issue) error {
c1e3e3ca
Keenan Nemetz
18d ago
319 var problems []string
320 if strings.TrimSpace(iss.Title) == "" {
321 problems = append(problems, "title is required")
322 }
c5902451
Keenan Nemetz
16d ago
323 if len(iss.Title) > maxIssueTitleLen {
324 problems = append(problems, fmt.Sprintf("title is %d bytes, limit %d", len(iss.Title), maxIssueTitleLen))
c1e3e3ca
Keenan Nemetz
18d ago
325 }
4581690d
Keenan Nemetz
13d ago
326 problems = append(problems, bodyProblems("body", iss.Description)...)
c1e3e3ca
Keenan Nemetz
18d ago
327 if iss.State != "open" && iss.State != "closed" {
328 problems = append(problems, fmt.Sprintf("state must be open or closed, got %q", iss.State))
329 }
330 if strings.TrimSpace(iss.Author) == "" {
331 problems = append(problems, "author is required")
332 }
333 for _, l := range iss.Labels {
334 if !labelRE.MatchString(l) {
335 problems = append(problems, fmt.Sprintf("invalid label %q", l))
336 }
337 }
67dd8854
Keenan Nemetz
17d ago
338 for _, a := range iss.Assignees {
339 if !assigneeRE.MatchString(a) {
340 problems = append(problems, fmt.Sprintf("invalid assignee %q (an account name: lowercase letters, digits, dash, underscore)", a))
341 }
342 }
c1e3e3ca
Keenan Nemetz
18d ago
343 if len(problems) > 0 {
344 return &ValidationError{Problems: problems}
345 }
346 return nil
347 }
348
349 func actorOr(primary, fallback string) string {
350 if strings.TrimSpace(primary) != "" {
351 return strings.TrimSpace(primary)
352 }
353 return strings.TrimSpace(fallback)
354 }