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

Blame · server/web/issues.go

Line attribution on main · 327 line(s)

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 }