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.
|
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 | } |