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.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 19m ago
4b8c9e1d
| 1 | package metadata |
| 2 | |
| 3 | import ( |
| 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. |
| 18 | var ErrNoChange = errors.New("no change") |
| 19 | |
| 20 | // New fills in the fields a caller should not have to get right. |
| 21 | func 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. |
| 39 | func 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. |
| 51 | func 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. |
| 71 | func 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. |
| 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) { |
| 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. |
| 120 | func 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. |
| 161 | func 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. |
| 197 | func 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. |
| 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 | } |
| 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. |
| 251 | func 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. |
| 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 |
| 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. |
| 278 | func 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. |
| 301 | func 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. |
| 318 | func 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 | |
| 349 | func actorOr(primary, fallback string) string { |
| 350 | if strings.TrimSpace(primary) != "" { |
| 351 | return strings.TrimSpace(primary) |
| 352 | } |
| 353 | return strings.TrimSpace(fallback) |
| 354 | } |
| 355 |