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 · 22m ago
4b8c9e1d
| 1 | package web |
| 2 | |
| 3 | import ( |
| 4 | "html/template" |
| 5 | "net/http" |
| 6 | "net/url" |
| 7 | "sort" |
| 8 | "strconv" |
| 9 | "strings" |
| 10 | "time" |
| 11 | |
| 12 | "rodent.software/vole/core/git" |
| 13 | voleid "rodent.software/vole/core/id" |
| 14 | "rodent.software/vole/forge/index" |
| 15 | "rodent.software/vole/forge/repo" |
| 16 | "rodent.software/vole/metadata" |
| 17 | ) |
| 18 | |
| 19 | // Issues lists a repo's issues from the index, refreshing it if the issues branch |
| 20 | // moved since it was last read. |
| 21 | func (w *Web) Issues(rw http.ResponseWriter, r *http.Request) { |
| 22 | m, ok := w.open(rw, r) |
| 23 | if !ok { |
| 24 | return |
| 25 | } |
| 26 | if err := w.ensureIndex(memoOf(r), m); err != nil { |
| 27 | w.serverError(rw, r, "issue index unavailable: "+err.Error()) |
| 28 | return |
| 29 | } |
| 30 | state := strings.TrimSpace(r.URL.Query().Get("state")) |
| 31 | if state == "" { |
| 32 | state = "open" |
| 33 | } |
| 34 | if state != "open" && state != "closed" && state != "all" { |
| 35 | state = "open" |
| 36 | } |
| 37 | filter := index.Filter{ |
| 38 | State: state, |
| 39 | Label: strings.TrimSpace(r.URL.Query().Get("label")), |
| 40 | Assignee: strings.TrimSpace(r.URL.Query().Get("assignee")), |
| 41 | Query: strings.TrimSpace(r.URL.Query().Get("q")), |
| 42 | } |
| 43 | // Counts describe the whole repository, not the filtered page: an "open" |
| 44 | // badge that changes when you search would be a lie. |
| 45 | all, err := w.Index.Issues(m.ID, index.Filter{}) |
| 46 | if err != nil { |
| 47 | w.serverError(rw, r, "cannot read issues: "+err.Error()) |
| 48 | return |
| 49 | } |
| 50 | open := 0 |
| 51 | for _, row := range all { |
| 52 | if row.State == "open" { |
| 53 | open++ |
| 54 | } |
| 55 | } |
| 56 | rows, err := w.Index.Issues(m.ID, filter) |
| 57 | if err != nil { |
| 58 | w.serverError(rw, r, "cannot read issues: "+err.Error()) |
| 59 | return |
| 60 | } |
| 61 | // Most recently updated first, so the list answers "what moved?" rather than |
| 62 | // "what was filed last?". When both states are shown the open issues stay on |
| 63 | // top: a closed issue somebody edited is not the queue the reader came for. |
| 64 | sort.SliceStable(rows, func(i, j int) bool { |
| 65 | if (rows[i].State == "open") != (rows[j].State == "open") { |
| 66 | return rows[i].State == "open" |
| 67 | } |
| 68 | if !rows[i].Updated.Equal(rows[j].Updated) { |
| 69 | return rows[i].Updated.After(rows[j].Updated) |
| 70 | } |
| 71 | return rows[i].ID > rows[j].ID |
| 72 | }) |
| 73 | page, _ := strconv.Atoi(r.URL.Query().Get("page")) |
| 74 | pg := newPager(len(rows), listPageSize, page, func(n int) string { |
| 75 | return issueListURL(m.Name, state, filter.Query, filter.Label, filter.Assignee, n) |
| 76 | }).forSwap("Issue pages", "#issue-list") |
| 77 | from, to := pg.rows() |
| 78 | rows = rows[from:to] |
| 79 | labels, err := w.Index.Labels(m.ID) |
| 80 | if err != nil { |
| 81 | labels = nil |
| 82 | } |
| 83 | assignees, err := w.Index.Assignees(m.ID) |
| 84 | if err != nil { |
| 85 | assignees = nil |
| 86 | } |
| 87 | acl, _ := m.ACL() |
| 88 | view := struct { |
| 89 | Page |
| 90 | Name string |
| 91 | Issues []index.IssueRow |
| 92 | Labels []string |
| 93 | Assignees []string |
| 94 | Filter index.Filter |
| 95 | State string |
| 96 | Total int |
| 97 | Open int |
| 98 | Closed int |
| 99 | Filtered bool |
| 100 | CanDiscuss bool |
| 101 | Pager pager |
| 102 | }{ |
| 103 | Page: w.page(r, m.Name+" · issues"), |
| 104 | Name: m.Name, |
| 105 | Issues: rows, |
| 106 | Labels: labels, |
| 107 | Assignees: assignees, |
| 108 | Filter: filter, |
| 109 | State: state, |
| 110 | Total: len(all), |
| 111 | Open: open, |
| 112 | Closed: len(all) - open, |
| 113 | Filtered: filter.Label != "" || filter.Assignee != "" || filter.Query != "" || (state != "open" && len(all) > 0), |
| 114 | CanDiscuss: w.canDiscuss(r, acl), |
| 115 | Pager: pg, |
| 116 | } |
| 117 | if htmxRequest(r) { |
| 118 | w.render(rw, "issue_list", view, 0) |
| 119 | return |
| 120 | } |
| 121 | w.render(rw, "issues", view, 0) |
| 122 | } |
| 123 | |
| 124 | func issueListURL(name, state, query, label, assignee string, page int) string { |
| 125 | v := url.Values{} |
| 126 | if state != "" { |
| 127 | v.Set("state", state) |
| 128 | } |
| 129 | if query != "" { |
| 130 | v.Set("q", query) |
| 131 | } |
| 132 | if label != "" { |
| 133 | v.Set("label", label) |
| 134 | } |
| 135 | if assignee != "" { |
| 136 | v.Set("assignee", assignee) |
| 137 | } |
| 138 | if page > 1 { |
| 139 | v.Set("page", strconv.Itoa(page)) |
| 140 | } |
| 141 | return "/" + name + "/issues?" + v.Encode() |
| 142 | } |
| 143 | |
| 144 | // issueSidebarView is the data every sidebar fragment needs. Keeping it one |
| 145 | // type is what lets a fragment replace one section after an HTMX write without |
| 146 | // rebuilding the whole page. |
| 147 | type issueSidebarView struct { |
| 148 | Page |
| 149 | Name string |
| 150 | Issue *metadata.Issue |
| 151 | // Number is the `#42` alias. It is a display convenience: the issue's ID is |
| 152 | // the identity, and the number is derived from the creation order in history. |
| 153 | Number int |
| 154 | CanClose bool |
| 155 | CanDiscuss bool |
| 156 | CanEdit bool |
| 157 | CanEditLabels bool |
| 158 | CanEditAssignees bool |
| 159 | AllLabels []string |
| 160 | Assignable []string |
| 161 | // Details is the in-place edit state. A full page leaves it zero; an HTMX |
| 162 | // edit response fills it so the title and description come back in the mode |
| 163 | // the reader left them in. |
| 164 | Details detailsEdit |
| 165 | Error string |
| 166 | } |
| 167 | |
| 168 | // issueChange is one change request linked to an issue. It carries what the |
| 169 | // sidebar row shows: the number that names it, the title that identifies it, and |
| 170 | // the state so a merged or closed change is not read as pending work. |
| 171 | type issueChange struct { |
| 172 | ID string |
| 173 | Number int |
| 174 | Title string |
| 175 | State string |
| 176 | Author string |
| 177 | Created time.Time |
| 178 | } |
| 179 | |
| 180 | // issuePageView is the full issue page: the sidebar plus the conversation. |
| 181 | type issuePageView struct { |
| 182 | issueSidebarView |
| 183 | Description template.HTML |
| 184 | Thread threadView |
| 185 | Backlinks []git.Commit |
| 186 | LinkedChanges []issueChange |
| 187 | } |
| 188 | |
| 189 | // issueSidebar is the data the issue page and its HTMX fragments share. The |
| 190 | // document title is built here because the number it leads with is read from the |
| 191 | // index beside the labels; a fragment that replaces one section keeps it. |
| 192 | func (w *Web) issueSidebar(r *http.Request, m *repo.Meta, iss *metadata.Issue, errMsg string) issueSidebarView { |
| 193 | acl, _ := m.ACL() |
| 194 | canManage := canTriage(acl, w.accountName(r), iss.Author) |
| 195 | _ = w.ensureIndex(memoOf(r), m) |
| 196 | labels, _ := w.Index.Labels(m.ID) |
| 197 | assignable := mergeNames(w.assignableAccounts(m), iss.Assignees) |
| 198 | number, _, _ := w.Index.Number(m.ID, iss.ID) |
| 199 | return issueSidebarView{ |
| 200 | Page: w.page(r, numberedTitle(number, iss.Title, "", m.Name)), |
| 201 | Name: m.Name, |
| 202 | Issue: iss, |
| 203 | Number: number, |
| 204 | CanClose: canManage, |
| 205 | CanDiscuss: w.canDiscuss(r, acl), |
| 206 | CanEdit: canManage, |
| 207 | CanEditLabels: canManage, |
| 208 | CanEditAssignees: canManage, |
| 209 | AllLabels: labels, |
| 210 | Assignable: assignable, |
| 211 | Error: errMsg, |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | // issueThread is the discussion section of an issue: the shared thread fragment |
| 216 | // with this surface's heading, composer and empty state. |
| 217 | func (w *Web) issueThread(r *http.Request, m *repo.Meta, iss *metadata.Issue, errMsg string) threadView { |
| 218 | return w.thread(r, m, threadView{ |
| 219 | Name: m.Name, |
| 220 | Section: "issue-thread", |
| 221 | Composer: "issue-comment-form", |
| 222 | PostURL: "/" + m.Name + "/issues/" + iss.ID + "/comments", |
| 223 | BackURL: "/" + m.Name + "/issues/" + iss.ID, |
| 224 | Title: "Comments", |
| 225 | EmptyText: "No comments yet.", |
| 226 | Topic: iss.ID, |
| 227 | ThreadError: errMsg, |
| 228 | }, w.commentViews(m, iss.Comments)) |
| 229 | } |
| 230 | |
| 231 | // IssueView renders one issue and its thread. The issue and its comments come |
| 232 | // from the index, which the request refreshed to the branch's current commit. |
| 233 | func (w *Web) IssueView(rw http.ResponseWriter, r *http.Request) { |
| 234 | m, ok := w.open(rw, r) |
| 235 | if !ok { |
| 236 | return |
| 237 | } |
| 238 | id := r.PathValue("id") |
| 239 | if !voleid.Valid(id) { |
| 240 | w.notFound(rw, r) |
| 241 | return |
| 242 | } |
| 243 | iss, err := w.issueAt(memoOf(r), m, id) |
| 244 | if err != nil { |
| 245 | w.notFound(rw, r) |
| 246 | return |
| 247 | } |
| 248 | sidebar := w.issueSidebar(r, m, iss, "") |
| 249 | w.render(rw, "issue", issuePageView{ |
| 250 | issueSidebarView: sidebar, |
| 251 | Description: w.renderMarkdown(m.Name, iss.Description), |
| 252 | Thread: w.issueThread(r, m, iss, ""), |
| 253 | Backlinks: w.backlinks(m, iss.ID), |
| 254 | LinkedChanges: w.issueLinkedChanges(memoOf(r), m, iss.ID), |
| 255 | }, 0) |
| 256 | } |
| 257 | |
| 258 | // issueLinkedChanges finds the change requests whose closes list names the |
| 259 | // issue. The index stores the link in both directions, so this is one lookup |
| 260 | // rather than a read of every request on the metadata branch. |
| 261 | func (w *Web) issueLinkedChanges(memo *requestMemo, m *repo.Meta, id string) []issueChange { |
| 262 | if w.Index == nil { |
| 263 | return nil |
| 264 | } |
| 265 | if err := w.ensureIndex(memo, m); err != nil { |
| 266 | return nil |
| 267 | } |
| 268 | ids, err := w.Index.Closes(m.ID, id) |
| 269 | if err != nil || len(ids) == 0 { |
| 270 | return nil |
| 271 | } |
| 272 | rows, err := w.Index.RequestsByID(m.ID, ids) |
| 273 | if err != nil { |
| 274 | return nil |
| 275 | } |
| 276 | out := make([]issueChange, 0, len(ids)) |
| 277 | for _, id := range ids { |
| 278 | row, ok := rows[id] |
| 279 | if !ok { |
| 280 | continue |
| 281 | } |
| 282 | out = append(out, issueChange{ |
| 283 | ID: row.ID, Number: row.Number, Title: row.Title, |
| 284 | State: row.State, Author: row.Author, Created: row.Created, |
| 285 | }) |
| 286 | } |
| 287 | return out |
| 288 | } |
| 289 | |
| 290 | // backlinks finds commits whose message mentions the issue. git does the search, |
| 291 | // so there is no second index to keep in sync. |
| 292 | func (w *Web) backlinks(m *repo.Meta, id string) []git.Commit { |
| 293 | if m.DefaultBranch == "" { |
| 294 | return nil |
| 295 | } |
| 296 | commits, err := m.Git().LogGrep(m.DefaultBranch, id, 10) |
| 297 | if err != nil { |
| 298 | return nil |
| 299 | } |
| 300 | return commits |
| 301 | } |
| 302 | |
| 303 | // NewIssuePage serves the dedicated new-issue form. A form that creates a |
| 304 | // durable object belongs on its own page; a popover is for filters and menus. |
| 305 | func (w *Web) NewIssuePage(rw http.ResponseWriter, r *http.Request) { |
| 306 | m, ok := w.open(rw, r) |
| 307 | if !ok { |
| 308 | return |
| 309 | } |
| 310 | pr, ok := w.requireHTMLUser(rw, r) |
| 311 | if !ok { |
| 312 | return |
| 313 | } |
| 314 | acl, err := m.ACL() |
| 315 | if err != nil || !acl.CanDiscuss(pr.account.Name) { |
| 316 | http.Error(rw, "you do not have permission to file issues here", http.StatusForbidden) |
| 317 | return |
| 318 | } |
| 319 | _ = w.ensureIndex(memoOf(r), m) |
| 320 | labels, _ := w.Index.Labels(m.ID) |
| 321 | w.render(rw, "issue_new", struct { |
| 322 | Page |
| 323 | Name string |
| 324 | Labels []string |
| 325 | Assignees []string |
| 326 | }{w.page(r, m.Name+" · new issue"), m.Name, labels, w.assignableAccounts(m)}, 0) |
| 327 | } |
| 328 |