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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

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

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 19m ago 4b8c9e1d
history blame raw 11.2 KiB
1package metadata
2
3import (
4 "errors"
5 "fmt"
6 "sort"
7 "strconv"
8 "strings"
9 "time"
10
11 "rodent.software/vole/core/git"
12 voleid "rodent.software/vole/core/id"
13)
14
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.
18var ErrNoChange = errors.New("no change")
19
20// New fills in the fields a caller should not have to get right.
21func NewIssue(title, body, author string, labels, assignees []string, now time.Time) (*Issue, error) {
22 id, err := voleid.New(now)
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,
33 Assignees: assignees,
34 Description: body,
35 }, nil
36}
37
38// Comment builds a comment with a fresh id.
39func NewIssueComment(body, author string, now time.Time) (*Comment, error) {
40 id, err := voleid.New(now)
41 if err != nil {
42 return nil, err
43 }
44 if problems := bodyProblems("comment", body); len(problems) > 0 {
45 return nil, fmt.Errorf("issues: %s", problems[0])
46 }
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.
51func CreateIssue(g *git.Repo, iss *Issue, author string) error {
52 if err := validateIssueForWrite(iss); err != nil {
53 return err
54 }
55 raw, err := renderIssue(iss)
56 if err != nil {
57 return err
58 }
59 base := fmt.Sprintf("%s/%s/", IssuesDir, iss.ID)
60 _, err = git.CommitFiles(g, Ref, func(files map[string][]byte) error {
61 if _, exists := files[base+issueName]; exists {
62 return fmt.Errorf("issues: %s already exists", iss.ID)
63 }
64 files[base+issueName] = raw
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.
71func UpdateIssue(g *git.Repo, id string, mutate func(*Issue) error, actor string) (*Issue, error) {
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.
78func 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
91func updateIssue(g *git.Repo, id string, mutate func(*Issue) error, actor, message string) (*Issue, error) {
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
98 }
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
108 }
109 files[base+issueName] = raw
110 out = iss
111 return actorOr(actor, iss.Author), nil
112 }, message)
113 if err != nil {
114 return nil, err
115 }
116 return out, nil
117}
118
119// AddComment appends a comment file to an existing issue.
120func AddIssueComment(g *git.Repo, id string, c *Comment, actor string) error {
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
142 }
143 if _, exists := files[path]; exists {
144 return "", fmt.Errorf("issues: comment %s already exists", c.ID)
145 }
146 files[path] = raw
147 return c.Author, nil
148 }, fmt.Sprintf("issue: comment on %s", id))
149 return err
150}
151
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.
161func EditIssueComment(g *git.Repo, issueID, commentID, body, actor string) (*Comment, error) {
162 body = strings.TrimRight(body, "\n")
163 if strings.TrimSpace(body) == "" {
164 return nil, fmt.Errorf("issues: a comment needs a body")
165 }
166 if problems := bodyProblems("comment", body); len(problems) > 0 {
167 return nil, fmt.Errorf("issues: %s", problems[0])
168 }
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
183 }
184 files[path] = raw
185 out = c
186 return actorOr(actor, c.Author), nil
187 }, fmt.Sprintf("issue: edit comment %s on %s", commentID, issueID))
188 if err != nil {
189 return nil, err
190 }
191 return out, nil
192}
193
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.
197func DeleteIssueComment(g *git.Repo, issueID, commentID, actor string) (*Comment, error) {
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))
219 if err != nil {
220 return nil, err
221 }
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.
229func 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 }
236 }
237 list, err := ParseIssues(contents)
238 if err != nil {
239 if ve, ok := err.(*ValidationError); ok {
240 return nil, fmt.Errorf("issues: the metadata tree is invalid: %s", ve.Problems[0])
241 }
242 return nil, err
243 }
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.
251func loadIssueComment(files map[string][]byte, issueID, commentID string) (*Comment, error) {
252 iss, err := loadIssueFrom(files, issueID)
253 if err != nil {
254 return nil, err
255 }
256 c, ok := commentOf(iss.Comments, commentID)
257 if !ok {
258 return nil, fmt.Errorf("issues: no comment %s on %s", commentID, issueID)
259 }
260 return c, nil
261}
262
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.
266func 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
271 }
272 }
273 return nil, false
274}
275
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.
278func RenderComment(c *Comment) ([]byte, error) {
279 d := NewDoc()
280 d.Word("author", c.Author)
281 d.Word("created", c.Created.UTC().Format(time.RFC3339))
282 d.Word("reply_to", c.ReplyTo)
283 if c.Deleted {
284 d.Word("deleted", "true")
285 }
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 }
295 return d.Bytes(strings.TrimRight(c.Body, "\n"))
296}
297
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.
301func renderIssue(iss *Issue) ([]byte, error) {
302 d := NewDoc()
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)
310 assignees := append([]string(nil), iss.Assignees...)
311 sort.Strings(assignees)
312 d.List("assignees", assignees)
313 return d.Bytes(strings.TrimRight(iss.Description, "\n"))
314}
315
316// validateIssueForWrite runs the same checks the push boundary applies, so the CLI
317// fails locally instead of after a round trip.
318func validateIssueForWrite(iss *Issue) error {
319 var problems []string
320 if strings.TrimSpace(iss.Title) == "" {
321 problems = append(problems, "title is required")
322 }
323 if len(iss.Title) > maxIssueTitleLen {
324 problems = append(problems, fmt.Sprintf("title is %d bytes, limit %d", len(iss.Title), maxIssueTitleLen))
325 }
326 problems = append(problems, bodyProblems("body", iss.Description)...)
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 }
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 }
343 if len(problems) > 0 {
344 return &ValidationError{Problems: problems}
345 }
346 return nil
347}
348
349func actorOr(primary, fallback string) string {
350 if strings.TrimSpace(primary) != "" {
351 return strings.TrimSpace(primary)
352 }
353 return strings.TrimSpace(fallback)
354}
355