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.
|
37c01e5a
Keenan Nemetz
18d ago
|
1 | package web |
| 2 | ||
| 3 | import ( | |
| 4 | "html/template" | |
| 5 | "net/http" | |
|
5cab7b18
Keenan Nemetz
15d ago
|
6 | "net/url" |
|
37c01e5a
Keenan Nemetz
18d ago
|
7 | "sort" |
|
5cab7b18
Keenan Nemetz
15d ago
|
8 | "strconv" |
|
37c01e5a
Keenan Nemetz
18d ago
|
9 | "strings" |
|
fd9aa8ed
Keenan Nemetz
1d ago
|
10 | "time" |
|
37c01e5a
Keenan Nemetz
18d ago
|
11 | |
|
d37d301f
Keenan Nemetz
19h ago
|
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" | |
|
37c01e5a
Keenan Nemetz
18d ago
|
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 | } | |
|
73ec800b
Keenan Nemetz
10d ago
|
26 | if err := w.ensureIndex(memoOf(r), m); err != nil { |
|
e84a66f5
Keenan Nemetz
15d ago
|
27 | w.serverError(rw, r, "issue index unavailable: "+err.Error()) |
|
37c01e5a
Keenan Nemetz
18d ago
|
28 | return |
| 29 | } | |
|
f09ef39e
Keenan Nemetz
15d ago
|
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 | } | |
|
c1e3e3ca
Keenan Nemetz
18d ago
|
37 | filter := index.Filter{ |
|
f09ef39e
Keenan Nemetz
15d ago
|
38 | State: state, |
|
67dd8854
Keenan Nemetz
17d ago
|
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")), | |
|
c1e3e3ca
Keenan Nemetz
18d ago
|
42 | } |
|
f09ef39e
Keenan Nemetz
15d ago
|
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 { | |
|
e84a66f5
Keenan Nemetz
15d ago
|
47 | w.serverError(rw, r, "cannot read issues: "+err.Error()) |
|
f09ef39e
Keenan Nemetz
15d ago
|
48 | return |
| 49 | } | |
| 50 | open := 0 | |
| 51 | for _, row := range all { | |
| 52 | if row.State == "open" { | |
| 53 | open++ | |
| 54 | } | |
| 55 | } | |
|
c1e3e3ca
Keenan Nemetz
18d ago
|
56 | rows, err := w.Index.Issues(m.ID, filter) |
|
37c01e5a
Keenan Nemetz
18d ago
|
57 | if err != nil { |
|
e84a66f5
Keenan Nemetz
15d ago
|
58 | w.serverError(rw, r, "cannot read issues: "+err.Error()) |
|
37c01e5a
Keenan Nemetz
18d ago
|
59 | return |
| 60 | } | |
|
db4081ec
Keenan Nemetz
5d ago
|
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. | |
|
37c01e5a
Keenan Nemetz
18d ago
|
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 | } | |
|
db4081ec
Keenan Nemetz
5d ago
|
68 | if !rows[i].Updated.Equal(rows[j].Updated) { |
| 69 | return rows[i].Updated.After(rows[j].Updated) | |
| 70 | } | |
|
37c01e5a
Keenan Nemetz
18d ago
|
71 | return rows[i].ID > rows[j].ID |
| 72 | }) | |
|
5cab7b18
Keenan Nemetz
15d ago
|
73 | page, _ := strconv.Atoi(r.URL.Query().Get("page")) |
|
c717d4eb
Keenan Nemetz
13d ago
|
74 | pg := newPager(len(rows), listPageSize, page, func(n int) string { |
|
5cab7b18
Keenan Nemetz
15d ago
|
75 | return issueListURL(m.Name, state, filter.Query, filter.Label, filter.Assignee, n) |
|
c717d4eb
Keenan Nemetz
13d ago
|
76 | }).forSwap("Issue pages", "#issue-list") |
| 77 | from, to := pg.rows() | |
| 78 | rows = rows[from:to] | |
|
c1e3e3ca
Keenan Nemetz
18d ago
|
79 | labels, err := w.Index.Labels(m.ID) |
| 80 | if err != nil { | |
| 81 | labels = nil | |
| 82 | } | |
|
67dd8854
Keenan Nemetz
17d ago
|
83 | assignees, err := w.Index.Assignees(m.ID) |
| 84 | if err != nil { | |
| 85 | assignees = nil | |
| 86 | } | |
|
6c84d387
Keenan Nemetz
16d ago
|
87 | acl, _ := m.ACL() |
|
f09ef39e
Keenan Nemetz
15d ago
|
88 | view := struct { |
|
37c01e5a
Keenan Nemetz
18d ago
|
89 | Page |
|
6c84d387
Keenan Nemetz
16d ago
|
90 | Name string |
| 91 | Issues []index.IssueRow | |
| 92 | Labels []string | |
| 93 | Assignees []string | |
| 94 | Filter index.Filter | |
|
f09ef39e
Keenan Nemetz
15d ago
|
95 | State string |
|
6c84d387
Keenan Nemetz
16d ago
|
96 | Total int |
| 97 | Open int | |
|
f09ef39e
Keenan Nemetz
15d ago
|
98 | Closed int |
|
6c84d387
Keenan Nemetz
16d ago
|
99 | Filtered bool |
| 100 | CanDiscuss bool | |
|
5cab7b18
Keenan Nemetz
15d ago
|
101 | Pager pager |
|
c1e3e3ca
Keenan Nemetz
18d ago
|
102 | }{ |
|
6c84d387
Keenan Nemetz
16d ago
|
103 | Page: w.page(r, m.Name+" · issues"), |
| 104 | Name: m.Name, | |
| 105 | Issues: rows, | |
| 106 | Labels: labels, | |
| 107 | Assignees: assignees, | |
| 108 | Filter: filter, | |
|
f09ef39e
Keenan Nemetz
15d ago
|
109 | State: state, |
| 110 | Total: len(all), | |
|
6c84d387
Keenan Nemetz
16d ago
|
111 | Open: open, |
|
f09ef39e
Keenan Nemetz
15d ago
|
112 | Closed: len(all) - open, |
| 113 | Filtered: filter.Label != "" || filter.Assignee != "" || filter.Query != "" || (state != "open" && len(all) > 0), | |
|
c717d4eb
Keenan Nemetz
13d ago
|
114 | CanDiscuss: w.canDiscuss(r, acl), |
|
5cab7b18
Keenan Nemetz
15d ago
|
115 | Pager: pg, |
|
f09ef39e
Keenan Nemetz
15d ago
|
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 | ||
|
5cab7b18
Keenan Nemetz
15d ago
|
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 | } | |
|
ed03b3d1
Keenan Nemetz
6h ago
|
141 | return "/" + name + "/issues?" + v.Encode() |
|
5cab7b18
Keenan Nemetz
15d ago
|
142 | } |
| 143 | ||
|
f09ef39e
Keenan Nemetz
15d ago
|
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 | |
|
e05f1c48
Keenan Nemetz
13d ago
|
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 | |
|
f09ef39e
Keenan Nemetz
15d ago
|
154 | CanClose bool |
| 155 | CanDiscuss bool | |
|
a4e7a99a
Keenan Nemetz
6d ago
|
156 | CanEdit bool |
|
f09ef39e
Keenan Nemetz
15d ago
|
157 | CanEditLabels bool |
| 158 | CanEditAssignees bool | |
| 159 | AllLabels []string | |
| 160 | Assignable []string | |
|
802e0e52
Keenan Nemetz
5d ago
|
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 | |
|
f09ef39e
Keenan Nemetz
15d ago
|
166 | } |
| 167 | ||
|
fd9aa8ed
Keenan Nemetz
1d ago
|
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 | ||
|
f09ef39e
Keenan Nemetz
15d ago
|
180 | // issuePageView is the full issue page: the sidebar plus the conversation. |
| 181 | type issuePageView struct { | |
| 182 | issueSidebarView | |
|
fd9aa8ed
Keenan Nemetz
1d ago
|
183 | Description template.HTML |
| 184 | Thread threadView | |
| 185 | Backlinks []git.Commit | |
| 186 | LinkedChanges []issueChange | |
|
f09ef39e
Keenan Nemetz
15d ago
|
187 | } |
| 188 | ||
|
de84c32b
Keenan Nemetz
6d ago
|
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 { | |
|
f09ef39e
Keenan Nemetz
15d ago
|
193 | acl, _ := m.ACL() |
|
c717d4eb
Keenan Nemetz
13d ago
|
194 | canManage := canTriage(acl, w.accountName(r), iss.Author) |
|
73ec800b
Keenan Nemetz
10d ago
|
195 | _ = w.ensureIndex(memoOf(r), m) |
|
f09ef39e
Keenan Nemetz
15d ago
|
196 | labels, _ := w.Index.Labels(m.ID) |
| 197 | assignable := mergeNames(w.assignableAccounts(m), iss.Assignees) | |
|
e05f1c48
Keenan Nemetz
13d ago
|
198 | number, _, _ := w.Index.Number(m.ID, iss.ID) |
|
f09ef39e
Keenan Nemetz
15d ago
|
199 | return issueSidebarView{ |
|
de84c32b
Keenan Nemetz
6d ago
|
200 | Page: w.page(r, numberedTitle(number, iss.Title, "", m.Name)), |
|
f09ef39e
Keenan Nemetz
15d ago
|
201 | Name: m.Name, |
| 202 | Issue: iss, | |
|
e05f1c48
Keenan Nemetz
13d ago
|
203 | Number: number, |
|
f09ef39e
Keenan Nemetz
15d ago
|
204 | CanClose: canManage, |
|
c717d4eb
Keenan Nemetz
13d ago
|
205 | CanDiscuss: w.canDiscuss(r, acl), |
|
a4e7a99a
Keenan Nemetz
6d ago
|
206 | CanEdit: canManage, |
|
f09ef39e
Keenan Nemetz
15d ago
|
207 | CanEditLabels: canManage, |
| 208 | CanEditAssignees: canManage, | |
| 209 | AllLabels: labels, | |
| 210 | Assignable: assignable, | |
| 211 | Error: errMsg, | |
| 212 | } | |
| 213 | } | |
| 214 | ||
|
c717d4eb
Keenan Nemetz
13d ago
|
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", | |
|
ed03b3d1
Keenan Nemetz
6h ago
|
222 | PostURL: "/" + m.Name + "/issues/" + iss.ID + "/comments", |
| 223 | BackURL: "/" + m.Name + "/issues/" + iss.ID, | |
|
c717d4eb
Keenan Nemetz
13d ago
|
224 | Title: "Comments", |
| 225 | EmptyText: "No comments yet.", | |
| 226 | Topic: iss.ID, | |
| 227 | ThreadError: errMsg, | |
| 228 | }, w.commentViews(m, iss.Comments)) | |
|
37c01e5a
Keenan Nemetz
18d ago
|
229 | } |
| 230 | ||
|
14b5c648
Keenan Nemetz
10d ago
|
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. | |
|
37c01e5a
Keenan Nemetz
18d ago
|
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") | |
|
c5902451
Keenan Nemetz
16d ago
|
239 | if !voleid.Valid(id) { |
|
e84a66f5
Keenan Nemetz
15d ago
|
240 | w.notFound(rw, r) |
|
37c01e5a
Keenan Nemetz
18d ago
|
241 | return |
| 242 | } | |
|
14b5c648
Keenan Nemetz
10d ago
|
243 | iss, err := w.issueAt(memoOf(r), m, id) |
|
37c01e5a
Keenan Nemetz
18d ago
|
244 | if err != nil { |
|
e84a66f5
Keenan Nemetz
15d ago
|
245 | w.notFound(rw, r) |
|
37c01e5a
Keenan Nemetz
18d ago
|
246 | return |
| 247 | } | |
|
de84c32b
Keenan Nemetz
6d ago
|
248 | sidebar := w.issueSidebar(r, m, iss, "") |
|
f09ef39e
Keenan Nemetz
15d ago
|
249 | w.render(rw, "issue", issuePageView{ |
|
f6f4b1db
Keenan Nemetz
15d ago
|
250 | issueSidebarView: sidebar, |
|
f09ef39e
Keenan Nemetz
15d ago
|
251 | Description: w.renderMarkdown(m.Name, iss.Description), |
|
c717d4eb
Keenan Nemetz
13d ago
|
252 | Thread: w.issueThread(r, m, iss, ""), |
|
f09ef39e
Keenan Nemetz
15d ago
|
253 | Backlinks: w.backlinks(m, iss.ID), |
|
fd9aa8ed
Keenan Nemetz
1d ago
|
254 | LinkedChanges: w.issueLinkedChanges(memoOf(r), m, iss.ID), |
|
c1e3e3ca
Keenan Nemetz
18d ago
|
255 | }, 0) |
| 256 | } | |
| 257 | ||
|
fd9aa8ed
Keenan Nemetz
1d ago
|
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 | ||
|
c1e3e3ca
Keenan Nemetz
18d ago
|
290 | // backlinks finds commits whose message mentions the issue. git does the search, |
| 291 | // so there is no second index to keep in sync. | |
|
c5902451
Keenan Nemetz
16d ago
|
292 | func (w *Web) backlinks(m *repo.Meta, id string) []git.Commit { |
|
c1e3e3ca
Keenan Nemetz
18d ago
|
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 | |
|
37c01e5a
Keenan Nemetz
18d ago
|
301 | } |
|
f09ef39e
Keenan Nemetz
15d ago
|
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() | |
|
62576fa8
Keenan Nemetz
14d ago
|
315 | if err != nil || !acl.CanDiscuss(pr.account.Name) { |
|
f09ef39e
Keenan Nemetz
15d ago
|
316 | http.Error(rw, "you do not have permission to file issues here", http.StatusForbidden) |
| 317 | return | |
| 318 | } | |
|
73ec800b
Keenan Nemetz
10d ago
|
319 | _ = w.ensureIndex(memoOf(r), m) |
|
f09ef39e
Keenan Nemetz
15d ago
|
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 | } |