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 · 22m ago 4b8c9e1d
history blame raw 9.3 KiB
1package web
2
3import (
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.
21func (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
124func 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.
147type 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.
171type 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.
181type 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.
192func (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.
217func (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.
233func (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.
261func (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.
292func (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.
305func (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