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.
|
6c84d387
Keenan Nemetz
15d ago
|
1 | package web |
| 2 | ||
|
c717d4eb
Keenan Nemetz
13d ago
|
3 | // The browser's issue and change-request writes: filing, triaging, commenting, |
| 4 | // approving, and closing. Each one is built by the same metadata writer the CLI | |
| 5 | // uses, so the file format and its validation cannot drift. The pre-receive hook | |
| 6 | // still governs pushes; these handlers check the same ACL capability before they | |
| 7 | // call a writer (capabilities.go). | |
|
6c84d387
Keenan Nemetz
15d ago
|
8 | // |
| 9 | // Every author and approval is taken from the session account, never from a form | |
|
c717d4eb
Keenan Nemetz
13d ago
|
10 | // field. That is the whole point of making the browser authenticate. The gate that |
| 11 | // establishes it — session, body bound, CSRF, repository, rules — is beginWrite. | |
|
6c84d387
Keenan Nemetz
15d ago
|
12 | |
| 13 | import ( | |
|
f09ef39e
Keenan Nemetz
15d ago
|
14 | "errors" |
|
e5478085
Keenan Nemetz
11d ago
|
15 | "fmt" |
|
6c84d387
Keenan Nemetz
15d ago
|
16 | "net/http" |
|
cad93cfe
Keenan Nemetz
10d ago
|
17 | "slices" |
|
6c84d387
Keenan Nemetz
15d ago
|
18 | "strings" |
| 19 | "time" | |
| 20 | ||
|
d37d301f
Keenan Nemetz
15h ago
|
21 | "rodent.software/vole/ci/runs" |
| 22 | "rodent.software/vole/forge/access" | |
| 23 | "rodent.software/vole/forge/index" | |
| 24 | "rodent.software/vole/forge/landing" | |
| 25 | "rodent.software/vole/forge/repo" | |
| 26 | "rodent.software/vole/internal/format" | |
| 27 | "rodent.software/vole/metadata" | |
|
6c84d387
Keenan Nemetz
15d ago
|
28 | ) |
| 29 | ||
| 30 | func (w *Web) CreateIssue(rw http.ResponseWriter, r *http.Request) { | |
|
c717d4eb
Keenan Nemetz
13d ago
|
31 | ctx, ok := w.beginWrite(rw, r, maxFormBody) |
|
6c84d387
Keenan Nemetz
15d ago
|
32 | if !ok { |
| 33 | return | |
| 34 | } | |
|
c717d4eb
Keenan Nemetz
13d ago
|
35 | m := ctx.Repo |
| 36 | if !ctx.canDiscuss() { | |
| 37 | w.refuse(rw, r, "you do not have permission to file issues here") | |
|
6c84d387
Keenan Nemetz
15d ago
|
38 | return |
| 39 | } | |
| 40 | title := strings.TrimSpace(r.PostFormValue("title")) | |
| 41 | body := strings.TrimSpace(r.PostFormValue("body")) | |
| 42 | if title == "" { | |
| 43 | http.Error(rw, "an issue needs a title", http.StatusBadRequest) | |
| 44 | return | |
| 45 | } | |
|
306f36fc
Keenan Nemetz
12d ago
|
46 | labels := normalizeLabelSet(r.PostForm["label"]) |
| 47 | assignees := metadata.NormalizeAssignees(r.PostForm["assignee"]) | |
|
f09ef39e
Keenan Nemetz
15d ago
|
48 | if bad := unknownNames(assignees, w.assignableAccounts(m)); len(bad) > 0 { |
| 49 | http.Error(rw, "unknown or unavailable assignee: "+strings.Join(bad, ", "), http.StatusBadRequest) | |
| 50 | return | |
| 51 | } | |
|
6c84d387
Keenan Nemetz
15d ago
|
52 | now := time.Now().UTC() |
|
c717d4eb
Keenan Nemetz
13d ago
|
53 | iss, err := metadata.NewIssue(title, body, ctx.Name, labels, assignees, now) |
|
6c84d387
Keenan Nemetz
15d ago
|
54 | if err != nil { |
| 55 | http.Error(rw, err.Error(), http.StatusBadRequest) | |
| 56 | return | |
| 57 | } | |
|
c717d4eb
Keenan Nemetz
13d ago
|
58 | if err := metadata.CreateIssue(m.Git(), iss, ctx.Name); err != nil { |
|
e84a66f5
Keenan Nemetz
15d ago
|
59 | w.serverError(rw, r, "cannot create the issue: "+err.Error()) |
|
6c84d387
Keenan Nemetz
15d ago
|
60 | return |
| 61 | } | |
|
b1499c72
Keenan Nemetz
12d ago
|
62 | w.audit("web.issue.create", ctx.Name, m.Name, iss.ID) |
|
ed03b3d1
Keenan Nemetz
2h ago
|
63 | http.Redirect(rw, r, "/"+m.Name+"/issues/"+iss.ID, http.StatusSeeOther) |
|
6c84d387
Keenan Nemetz
15d ago
|
64 | } |
| 65 | ||
|
f09ef39e
Keenan Nemetz
15d ago
|
66 | // SetIssueLabels replaces an issue's labels. Labels are shared state, but the |
| 67 | // author may label their own issue; a maintainer may label any issue as triage. | |
|
306f36fc
Keenan Nemetz
12d ago
|
68 | // The form sends labels as hidden inputs written by the chip UI. |
|
f09ef39e
Keenan Nemetz
15d ago
|
69 | func (w *Web) SetIssueLabels(rw http.ResponseWriter, r *http.Request) { |
|
c717d4eb
Keenan Nemetz
13d ago
|
70 | ctx, ok := w.beginWrite(rw, r, maxSmallForm) |
|
f09ef39e
Keenan Nemetz
15d ago
|
71 | if !ok { |
| 72 | return | |
| 73 | } | |
|
c717d4eb
Keenan Nemetz
13d ago
|
74 | m := ctx.Repo |
|
f09ef39e
Keenan Nemetz
15d ago
|
75 | id := r.PathValue("id") |
| 76 | iss, err := index.LoadIssue(m, id) | |
| 77 | if err != nil { | |
|
e84a66f5
Keenan Nemetz
15d ago
|
78 | w.notFound(rw, r) |
|
f09ef39e
Keenan Nemetz
15d ago
|
79 | return |
| 80 | } | |
|
c717d4eb
Keenan Nemetz
13d ago
|
81 | if !ctx.canTriage(iss.Author) { |
| 82 | w.refuse(rw, r, "you do not have permission to edit these labels") | |
|
f09ef39e
Keenan Nemetz
15d ago
|
83 | return |
| 84 | } | |
|
c717d4eb
Keenan Nemetz
13d ago
|
85 | // A rejected write re-renders the sidebar section the control belongs to, so |
| 86 | // the reason appears beside it instead of replacing the page with text. | |
| 87 | labelsView := func(msg string) (string, any) { | |
|
de84c32b
Keenan Nemetz
5d ago
|
88 | return "issue_labels", w.issueSidebar(r, m, iss, msg) |
|
f09ef39e
Keenan Nemetz
15d ago
|
89 | } |
|
306f36fc
Keenan Nemetz
12d ago
|
90 | labels := normalizeLabelSet(r.PostForm["label"]) |
|
f09ef39e
Keenan Nemetz
15d ago
|
91 | updated, err := metadata.UpdateIssue(m.Git(), id, func(iss *metadata.Issue) error { |
| 92 | if sameStrings(metadata.NormalizeLabels(iss.Labels), labels) { | |
| 93 | return metadata.ErrNoChange | |
| 94 | } | |
| 95 | iss.Labels = labels | |
| 96 | return nil | |
|
c717d4eb
Keenan Nemetz
13d ago
|
97 | }, ctx.Name) |
|
f09ef39e
Keenan Nemetz
15d ago
|
98 | if err != nil && !errors.Is(err, metadata.ErrNoChange) { |
|
c717d4eb
Keenan Nemetz
13d ago
|
99 | w.fail(rw, r, http.StatusBadRequest, "cannot update the labels: "+err.Error(), labelsView) |
|
f09ef39e
Keenan Nemetz
15d ago
|
100 | return |
| 101 | } | |
|
b1499c72
Keenan Nemetz
12d ago
|
102 | w.audit("web.issue.labels", ctx.Name, m.Name, id+" "+strings.Join(labels, ",")) |
|
f09ef39e
Keenan Nemetz
15d ago
|
103 | if htmxRequest(r) { |
| 104 | if updated == nil { | |
| 105 | updated = iss | |
| 106 | } | |
|
de84c32b
Keenan Nemetz
5d ago
|
107 | w.render(rw, "issue_labels", w.issueSidebar(r, m, updated, ""), 0) |
|
f09ef39e
Keenan Nemetz
15d ago
|
108 | return |
| 109 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
110 | http.Redirect(rw, r, "/"+m.Name+"/issues/"+id, http.StatusSeeOther) |
|
f09ef39e
Keenan Nemetz
15d ago
|
111 | } |
| 112 | ||
| 113 | // SetIssueAssignees replaces an issue's assignees. Same rule as labels: the | |
| 114 | // author may manage their own issue, a maintainer may triage any issue. | |
| 115 | func (w *Web) SetIssueAssignees(rw http.ResponseWriter, r *http.Request) { | |
|
c717d4eb
Keenan Nemetz
13d ago
|
116 | ctx, ok := w.beginWrite(rw, r, maxSmallForm) |
|
f09ef39e
Keenan Nemetz
15d ago
|
117 | if !ok { |
| 118 | return | |
| 119 | } | |
|
c717d4eb
Keenan Nemetz
13d ago
|
120 | m := ctx.Repo |
|
f09ef39e
Keenan Nemetz
15d ago
|
121 | id := r.PathValue("id") |
| 122 | iss, err := index.LoadIssue(m, id) | |
| 123 | if err != nil { | |
|
e84a66f5
Keenan Nemetz
15d ago
|
124 | w.notFound(rw, r) |
|
f09ef39e
Keenan Nemetz
15d ago
|
125 | return |
| 126 | } | |
|
c717d4eb
Keenan Nemetz
13d ago
|
127 | if !ctx.canTriage(iss.Author) { |
| 128 | w.refuse(rw, r, "you do not have permission to edit these assignees") | |
|
f09ef39e
Keenan Nemetz
15d ago
|
129 | return |
| 130 | } | |
|
c717d4eb
Keenan Nemetz
13d ago
|
131 | assigneesView := func(msg string) (string, any) { |
|
de84c32b
Keenan Nemetz
5d ago
|
132 | return "issue_assignees", w.issueSidebar(r, m, iss, msg) |
|
f09ef39e
Keenan Nemetz
15d ago
|
133 | } |
|
306f36fc
Keenan Nemetz
12d ago
|
134 | assignees := metadata.NormalizeAssignees(r.PostForm["assignee"]) |
|
f09ef39e
Keenan Nemetz
15d ago
|
135 | // A current assignee may have lost access since being assigned. Keep them in |
| 136 | // the allowed set so an edit can remove the others without silently dropping | |
| 137 | // that name. | |
| 138 | if bad := unknownNames(assignees, mergeNames(w.assignableAccounts(m), iss.Assignees)); len(bad) > 0 { | |
|
c717d4eb
Keenan Nemetz
13d ago
|
139 | w.fail(rw, r, http.StatusBadRequest, |
| 140 | "unknown or unavailable assignee: "+strings.Join(bad, ", "), assigneesView) | |
|
f09ef39e
Keenan Nemetz
15d ago
|
141 | return |
| 142 | } | |
| 143 | updated, err := metadata.UpdateIssue(m.Git(), id, func(iss *metadata.Issue) error { | |
| 144 | if sameStrings(metadata.NormalizeAssignees(iss.Assignees), assignees) { | |
| 145 | return metadata.ErrNoChange | |
| 146 | } | |
| 147 | iss.Assignees = assignees | |
| 148 | return nil | |
|
c717d4eb
Keenan Nemetz
13d ago
|
149 | }, ctx.Name) |
|
f09ef39e
Keenan Nemetz
15d ago
|
150 | if err != nil && !errors.Is(err, metadata.ErrNoChange) { |
|
c717d4eb
Keenan Nemetz
13d ago
|
151 | w.fail(rw, r, http.StatusBadRequest, "cannot update the assignees: "+err.Error(), assigneesView) |
|
f09ef39e
Keenan Nemetz
15d ago
|
152 | return |
| 153 | } | |
|
b1499c72
Keenan Nemetz
12d ago
|
154 | w.audit("web.issue.assignees", ctx.Name, m.Name, id+" "+strings.Join(assignees, ",")) |
|
f09ef39e
Keenan Nemetz
15d ago
|
155 | if htmxRequest(r) { |
| 156 | if updated == nil { | |
| 157 | updated = iss | |
| 158 | } | |
|
de84c32b
Keenan Nemetz
5d ago
|
159 | w.render(rw, "issue_assignees", w.issueSidebar(r, m, updated, ""), 0) |
|
f09ef39e
Keenan Nemetz
15d ago
|
160 | return |
| 161 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
162 | http.Redirect(rw, r, "/"+m.Name+"/issues/"+id, http.StatusSeeOther) |
|
f09ef39e
Keenan Nemetz
15d ago
|
163 | } |
| 164 | ||
|
6c84d387
Keenan Nemetz
15d ago
|
165 | func (w *Web) ApproveRequest(rw http.ResponseWriter, r *http.Request) { |
|
c717d4eb
Keenan Nemetz
13d ago
|
166 | ctx, ok := w.beginWrite(rw, r, maxFormBody) |
|
6c84d387
Keenan Nemetz
15d ago
|
167 | if !ok { |
| 168 | return | |
| 169 | } | |
|
c717d4eb
Keenan Nemetz
13d ago
|
170 | m := ctx.Repo |
| 171 | if !ctx.canApproveChange() { | |
| 172 | w.refuse(rw, r, "you do not have permission to approve here") | |
|
6c84d387
Keenan Nemetz
15d ago
|
173 | return |
| 174 | } | |
| 175 | id := r.PathValue("id") | |
|
5d440e37
Keenan Nemetz
10d ago
|
176 | req, err := w.requestAt(memoOf(r), m, id) |
|
6c84d387
Keenan Nemetz
15d ago
|
177 | if err != nil { |
|
e84a66f5
Keenan Nemetz
15d ago
|
178 | w.notFound(rw, r) |
|
6c84d387
Keenan Nemetz
15d ago
|
179 | return |
| 180 | } | |
| 181 | if !req.Open() { | |
|
b06184d1
Keenan Nemetz
11d ago
|
182 | http.Error(rw, "this change is not open", http.StatusBadRequest) |
|
6c84d387
Keenan Nemetz
15d ago
|
183 | return |
| 184 | } | |
|
c717d4eb
Keenan Nemetz
13d ago
|
185 | if req.Author == ctx.Name { |
|
b06184d1
Keenan Nemetz
11d ago
|
186 | http.Error(rw, "you cannot approve your own change", http.StatusForbidden) |
|
6c84d387
Keenan Nemetz
15d ago
|
187 | return |
| 188 | } | |
|
c717d4eb
Keenan Nemetz
13d ago
|
189 | sidebar := func(msg string) (string, any) { |
|
707dfd10
Keenan Nemetz
10d ago
|
190 | return "request_live_write", w.requestLiveWrite(r, m, req, msg) |
|
c717d4eb
Keenan Nemetz
13d ago
|
191 | } |
|
e050e14a
Keenan Nemetz
13d ago
|
192 | // The form carries the base and head the page displayed. If a push moved the |
| 193 | // followed branch, or the request was retargeted, the writer refuses rather than | |
| 194 | // approving a target the approver never saw. | |
| 195 | expectedBase := strings.TrimSpace(r.PostFormValue("base")) | |
| 196 | expectedHead := strings.TrimSpace(r.PostFormValue("head")) | |
| 197 | if expectedBase == "" || expectedHead == "" { | |
| 198 | w.fail(rw, r, http.StatusBadRequest, | |
| 199 | "reload the request before approving: the form did not name the base and head you reviewed", sidebar) | |
| 200 | return | |
| 201 | } | |
| 202 | if _, err := metadata.ApproveRequest(m.Git(), id, ctx.Name, expectedBase, expectedHead, time.Now().UTC(), ctx.Name); err != nil { | |
|
c717d4eb
Keenan Nemetz
13d ago
|
203 | w.fail(rw, r, http.StatusBadRequest, "cannot approve: "+err.Error(), sidebar) |
|
6c84d387
Keenan Nemetz
15d ago
|
204 | return |
| 205 | } | |
|
b1499c72
Keenan Nemetz
12d ago
|
206 | w.audit("web.request.approve", ctx.Name, m.Name, id+" "+req.Head) |
|
6c84d387
Keenan Nemetz
15d ago
|
207 | if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 { |
| 208 | w.Logger("web: approval left the request tree invalid: %v", problems) | |
| 209 | } | |
|
f09ef39e
Keenan Nemetz
15d ago
|
210 | if htmxRequest(r) { |
|
5d440e37
Keenan Nemetz
10d ago
|
211 | updated, err := w.requestAt(memoOf(r), m, id) |
|
f09ef39e
Keenan Nemetz
15d ago
|
212 | if err != nil { |
|
707dfd10
Keenan Nemetz
10d ago
|
213 | w.render(rw, "request_live_write", w.requestLiveWrite(r, m, req, err.Error()), 0) |
|
f09ef39e
Keenan Nemetz
15d ago
|
214 | return |
| 215 | } | |
|
707dfd10
Keenan Nemetz
10d ago
|
216 | w.render(rw, "request_live_write", w.requestLiveWrite(r, m, updated, ""), 0) |
|
f09ef39e
Keenan Nemetz
15d ago
|
217 | return |
| 218 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
219 | http.Redirect(rw, r, "/"+m.Name+"/changes/"+id, http.StatusSeeOther) |
|
6c84d387
Keenan Nemetz
15d ago
|
220 | } |
| 221 | ||
|
04e00cfc
Keenan Nemetz
15d ago
|
222 | // CreateChangeRequest is the browser counterpart of `vole change new`. The head |
|
6c84d387
Keenan Nemetz
15d ago
|
223 | // is a branch, not a free-form SHA, so a contributor cannot name a commit they |
| 224 | // did not push. For a contributor the branch must be inside their own namespace; | |
| 225 | // for write and admin it may be any normal branch. | |
| 226 | func (w *Web) CreateChangeRequest(rw http.ResponseWriter, r *http.Request) { | |
|
c717d4eb
Keenan Nemetz
13d ago
|
227 | ctx, ok := w.beginWrite(rw, r, maxFormBody) |
|
6c84d387
Keenan Nemetz
15d ago
|
228 | if !ok { |
| 229 | return | |
| 230 | } | |
|
c717d4eb
Keenan Nemetz
13d ago
|
231 | m := ctx.Repo |
| 232 | if !ctx.canOpenChangeRequest() { | |
|
b06184d1
Keenan Nemetz
11d ago
|
233 | w.refuse(rw, r, "you do not have permission to open changes here") |
|
6c84d387
Keenan Nemetz
15d ago
|
234 | return |
| 235 | } | |
| 236 | title := strings.TrimSpace(r.PostFormValue("title")) | |
| 237 | body := strings.TrimSpace(r.PostFormValue("body")) | |
| 238 | base := strings.TrimPrefix(strings.TrimSpace(r.PostFormValue("base")), "refs/heads/") | |
| 239 | head := strings.TrimPrefix(strings.TrimSpace(r.PostFormValue("head")), "refs/heads/") | |
| 240 | metadataBranch := strings.TrimPrefix(metadata.Ref, "refs/heads/") | |
| 241 | if title == "" { | |
|
b06184d1
Keenan Nemetz
11d ago
|
242 | http.Error(rw, "a change needs a title", http.StatusBadRequest) |
|
6c84d387
Keenan Nemetz
15d ago
|
243 | return |
| 244 | } | |
| 245 | if base == "" || base == metadataBranch || head == "" || head == metadataBranch { | |
| 246 | http.Error(rw, "choose a base and a head branch", http.StatusBadRequest) | |
| 247 | return | |
| 248 | } | |
|
e050e14a
Keenan Nemetz
13d ago
|
249 | // The head starts at the branch tip and the request follows that branch afterwards. |
| 250 | // It has to be a real branch name, or a hand-edited form could store something the | |
| 251 | // metadata reader would refuse. | |
|
8e1005eb
Keenan Nemetz
13d ago
|
252 | if !m.Git().ValidBranch(head) { |
| 253 | http.Error(rw, "that head branch does not exist yet; push it first", http.StatusBadRequest) | |
| 254 | return | |
| 255 | } | |
|
6c84d387
Keenan Nemetz
15d ago
|
256 | headRef := "refs/heads/" + head |
|
c717d4eb
Keenan Nemetz
13d ago
|
257 | if contributorOnly(ctx.ACL, ctx.Name) { |
|
474a812d
Keenan Nemetz
15d ago
|
258 | // A contributor's head must be a branch the hook would let them push. The |
| 259 | // namespace is the account name, which cannot change, so this agrees with | |
| 260 | // the gate without a roster lookup. | |
|
c717d4eb
Keenan Nemetz
13d ago
|
261 | if !access.IsContributorRef(ctx.Name, headRef) { |
|
6c84d387
Keenan Nemetz
15d ago
|
262 | http.Error(rw, "contributors may only open requests from their own branch namespace", http.StatusForbidden) |
| 263 | return | |
| 264 | } | |
| 265 | } | |
| 266 | headCommit, err := m.Git().RevParse(headRef) | |
| 267 | if err != nil || headCommit == "" { | |
| 268 | http.Error(rw, "that head branch does not exist yet; push it first", http.StatusBadRequest) | |
| 269 | return | |
| 270 | } | |
| 271 | if tip, err := m.Git().RevParse("refs/heads/" + base); err != nil || tip == "" { | |
| 272 | http.Error(rw, "that base branch does not exist", http.StatusBadRequest) | |
| 273 | return | |
| 274 | } | |
|
c717d4eb
Keenan Nemetz
13d ago
|
275 | req, err := metadata.NewRequest(title, body, ctx.Name, base, headCommit, time.Now().UTC()) |
|
6c84d387
Keenan Nemetz
15d ago
|
276 | if err != nil { |
| 277 | http.Error(rw, err.Error(), http.StatusBadRequest) | |
| 278 | return | |
| 279 | } | |
|
8e1005eb
Keenan Nemetz
13d ago
|
280 | req.HeadRef = head |
|
183d48e5
Keenan Nemetz
13d ago
|
281 | // Issues this request promises to close. They are checked when the request is |
|
e05f1c48
Keenan Nemetz
13d ago
|
282 | // written, so a typo fails here rather than at the landing. A number is turned |
| 283 | // into the id here, the same way the CLI flag does, so the file holds ids. | |
| 284 | if req.Closes, err = w.resolveCloses(m, closesField(r.PostFormValue("closes"))); err != nil { | |
| 285 | http.Error(rw, err.Error(), http.StatusBadRequest) | |
| 286 | return | |
| 287 | } | |
|
0789f22e
Keenan Nemetz
9d ago
|
288 | if ctx.ACL.RequiresLinkedIssue() && len(req.Closes) == 0 { |
| 289 | http.Error(rw, "This repository requires at least one linked issue when a change is opened. Add an issue in Closes.", http.StatusBadRequest) | |
| 290 | return | |
| 291 | } | |
|
c717d4eb
Keenan Nemetz
13d ago
|
292 | if err := metadata.CreateRequest(m.Git(), req, ctx.Name); err != nil { |
|
b06184d1
Keenan Nemetz
11d ago
|
293 | w.serverError(rw, r, "cannot open the change: "+err.Error()) |
|
6c84d387
Keenan Nemetz
15d ago
|
294 | return |
| 295 | } | |
|
b1499c72
Keenan Nemetz
12d ago
|
296 | w.audit("web.request.create", ctx.Name, m.Name, req.ID+" "+headCommit) |
|
6c84d387
Keenan Nemetz
15d ago
|
297 | if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 { |
| 298 | w.Logger("web: request %s left the request tree invalid: %v", req.ID, problems) | |
| 299 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
300 | http.Redirect(rw, r, "/"+m.Name+"/changes/"+req.ID, http.StatusSeeOther) |
|
6c84d387
Keenan Nemetz
15d ago
|
301 | } |
|
f09ef39e
Keenan Nemetz
15d ago
|
302 | |
| 303 | // ChangeIssueState closes or reopens an issue. The author can do this without a | |
| 304 | // maintainer, and a maintainer can do it to anybody's issue: closing your own | |
| 305 | // mistake should not need a second person, but triage is a maintainer's job. | |
| 306 | func (w *Web) ChangeIssueState(rw http.ResponseWriter, r *http.Request) { | |
|
c717d4eb
Keenan Nemetz
13d ago
|
307 | ctx, ok := w.beginWrite(rw, r, maxSmallForm) |
|
f09ef39e
Keenan Nemetz
15d ago
|
308 | if !ok { |
| 309 | return | |
| 310 | } | |
|
c717d4eb
Keenan Nemetz
13d ago
|
311 | m := ctx.Repo |
|
f09ef39e
Keenan Nemetz
15d ago
|
312 | id := r.PathValue("id") |
| 313 | state := strings.TrimSpace(r.PostFormValue("state")) | |
| 314 | if state != "open" && state != "closed" { | |
| 315 | http.Error(rw, "state must be open or closed", http.StatusBadRequest) | |
| 316 | return | |
| 317 | } | |
| 318 | iss, err := index.LoadIssue(m, id) | |
| 319 | if err != nil { | |
|
e84a66f5
Keenan Nemetz
15d ago
|
320 | w.notFound(rw, r) |
|
f09ef39e
Keenan Nemetz
15d ago
|
321 | return |
| 322 | } | |
|
c717d4eb
Keenan Nemetz
13d ago
|
323 | if !ctx.canTriage(iss.Author) { |
| 324 | w.refuse(rw, r, "you do not have permission to change this issue") | |
|
f09ef39e
Keenan Nemetz
15d ago
|
325 | return |
| 326 | } | |
|
c717d4eb
Keenan Nemetz
13d ago
|
327 | _, err = metadata.SetIssueState(m.Git(), id, state, ctx.Name) |
|
f09ef39e
Keenan Nemetz
15d ago
|
328 | if err != nil && !errors.Is(err, metadata.ErrNoChange) { |
|
e84a66f5
Keenan Nemetz
15d ago
|
329 | w.serverError(rw, r, "cannot change the issue: "+err.Error()) |
|
f09ef39e
Keenan Nemetz
15d ago
|
330 | return |
| 331 | } | |
|
b1499c72
Keenan Nemetz
12d ago
|
332 | w.audit("web.issue.state", ctx.Name, m.Name, id+" "+state) |
|
ed03b3d1
Keenan Nemetz
2h ago
|
333 | http.Redirect(rw, r, "/"+m.Name+"/issues/"+id, http.StatusSeeOther) |
|
f09ef39e
Keenan Nemetz
15d ago
|
334 | } |
| 335 | ||
| 336 | // ChangeRequestState closes or reopens a change request. Closing is not merging: | |
| 337 | // it records that the proposal was withdrawn, and a closed request can be | |
| 338 | // reopened without losing its discussion or approvals. | |
| 339 | func (w *Web) ChangeRequestState(rw http.ResponseWriter, r *http.Request) { | |
|
c717d4eb
Keenan Nemetz
13d ago
|
340 | ctx, ok := w.beginWrite(rw, r, maxSmallForm) |
|
f09ef39e
Keenan Nemetz
15d ago
|
341 | if !ok { |
| 342 | return | |
| 343 | } | |
|
c717d4eb
Keenan Nemetz
13d ago
|
344 | m := ctx.Repo |
|
f09ef39e
Keenan Nemetz
15d ago
|
345 | id := r.PathValue("id") |
|
5d440e37
Keenan Nemetz
10d ago
|
346 | req, err := w.requestAt(memoOf(r), m, id) |
|
f09ef39e
Keenan Nemetz
15d ago
|
347 | if err != nil { |
|
e84a66f5
Keenan Nemetz
15d ago
|
348 | w.notFound(rw, r) |
|
f09ef39e
Keenan Nemetz
15d ago
|
349 | return |
| 350 | } | |
|
c717d4eb
Keenan Nemetz
13d ago
|
351 | if !ctx.canTriage(req.Author) { |
|
b06184d1
Keenan Nemetz
11d ago
|
352 | w.refuse(rw, r, "you do not have permission to change this change") |
|
f09ef39e
Keenan Nemetz
15d ago
|
353 | return |
| 354 | } | |
| 355 | state := strings.TrimSpace(r.PostFormValue("state")) | |
| 356 | if state != "open" && state != "closed" { | |
| 357 | http.Error(rw, "state must be open or closed", http.StatusBadRequest) | |
| 358 | return | |
| 359 | } | |
|
c717d4eb
Keenan Nemetz
13d ago
|
360 | if _, err := metadata.SetRequestState(m.Git(), id, state, ctx.Name); err != nil { |
|
b06184d1
Keenan Nemetz
11d ago
|
361 | w.serverError(rw, r, "cannot change the change: "+err.Error()) |
|
f09ef39e
Keenan Nemetz
15d ago
|
362 | return |
| 363 | } | |
|
b1499c72
Keenan Nemetz
12d ago
|
364 | w.audit("web.request.state", ctx.Name, m.Name, id+" "+state) |
|
f09ef39e
Keenan Nemetz
15d ago
|
365 | if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 { |
| 366 | w.Logger("web: request state %s left the request tree invalid: %v", id, problems) | |
| 367 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
368 | http.Redirect(rw, r, "/"+m.Name+"/changes/"+id, http.StatusSeeOther) |
|
f09ef39e
Keenan Nemetz
15d ago
|
369 | } |
|
38b30251
Keenan Nemetz
13d ago
|
370 | |
|
41d1fee9
Keenan Nemetz
13d ago
|
371 | // UpdateRequestTarget moves an open request's base and the branch it follows. The |
| 372 | // request page posts both together because the two targets are one review surface; | |
| 373 | // changing either one makes approvals that named the old base and commit stale. | |
|
53ff967b
Keenan Nemetz
13d ago
|
374 | // |
|
41d1fee9
Keenan Nemetz
13d ago
|
375 | // A value that is already the request's target is kept even if its branch has since |
| 376 | // been deleted: that lets a reader change only the other side without first having | |
| 377 | // to repair a missing branch. A value that is changing has to name a branch that | |
| 378 | // exists, and a contributor may only name a head branch in their own namespace. | |
| 379 | func (w *Web) UpdateRequestTarget(rw http.ResponseWriter, r *http.Request) { | |
|
53ff967b
Keenan Nemetz
13d ago
|
380 | ctx, ok := w.beginWrite(rw, r, maxSmallForm) |
| 381 | if !ok { | |
| 382 | return | |
| 383 | } | |
| 384 | m := ctx.Repo | |
| 385 | id := r.PathValue("id") | |
|
5d440e37
Keenan Nemetz
10d ago
|
386 | req, err := w.requestAt(memoOf(r), m, id) |
|
53ff967b
Keenan Nemetz
13d ago
|
387 | if err != nil { |
| 388 | w.notFound(rw, r) | |
| 389 | return | |
| 390 | } | |
| 391 | if !ctx.canTriage(req.Author) { | |
|
b06184d1
Keenan Nemetz
11d ago
|
392 | w.refuse(rw, r, "you do not have permission to change this change's target") |
|
53ff967b
Keenan Nemetz
13d ago
|
393 | return |
| 394 | } | |
| 395 | if !req.Open() { | |
|
b06184d1
Keenan Nemetz
11d ago
|
396 | w.refuse(rw, r, "only an open change can change its target") |
|
53ff967b
Keenan Nemetz
13d ago
|
397 | return |
| 398 | } | |
|
41d1fee9
Keenan Nemetz
13d ago
|
399 | |
| 400 | base := strings.TrimPrefix(strings.TrimSpace(r.PostFormValue("base")), "refs/heads/") | |
| 401 | head := strings.TrimPrefix(strings.TrimSpace(r.PostFormValue("head")), "refs/heads/") | |
|
53ff967b
Keenan Nemetz
13d ago
|
402 | metadataBranch := strings.TrimPrefix(metadata.Ref, "refs/heads/") |
|
41d1fee9
Keenan Nemetz
13d ago
|
403 | if base == "" || base == metadataBranch || !m.Git().ValidBranch(base) { |
| 404 | http.Error(rw, "choose the branch this change should land on", http.StatusBadRequest) | |
|
53ff967b
Keenan Nemetz
13d ago
|
405 | return |
| 406 | } | |
|
41d1fee9
Keenan Nemetz
13d ago
|
407 | if base != req.Base { |
| 408 | if tip, err := m.Git().RevParse("refs/heads/" + base); err != nil || tip == "" { | |
| 409 | http.Error(rw, "that base branch does not exist", http.StatusBadRequest) | |
| 410 | return | |
| 411 | } | |
|
53ff967b
Keenan Nemetz
13d ago
|
412 | } |
|
41d1fee9
Keenan Nemetz
13d ago
|
413 | |
| 414 | headTip, headRef := req.Head, req.HeadRef | |
| 415 | switch { | |
| 416 | case head == "": | |
| 417 | // The picker showed a pinned commit and the reader did not choose a branch. | |
| 418 | case head == req.HeadRef: | |
| 419 | // The current branch: follow it if it still exists, otherwise keep the last | |
| 420 | // tip. A reader who is only changing the base must not have to repair the | |
| 421 | // head first. | |
| 422 | if tip, err := m.Git().RevParse("refs/heads/" + head); err == nil && tip != "" { | |
| 423 | headTip = tip | |
| 424 | } | |
| 425 | default: | |
| 426 | if head == metadataBranch || !m.Git().ValidBranch(head) { | |
| 427 | http.Error(rw, "choose the branch whose tip should be reviewed", http.StatusBadRequest) | |
|
53ff967b
Keenan Nemetz
13d ago
|
428 | return |
| 429 | } | |
|
41d1fee9
Keenan Nemetz
13d ago
|
430 | ref := "refs/heads/" + head |
| 431 | if contributorOnly(ctx.ACL, ctx.Name) && !access.IsContributorRef(ctx.Name, ref) { | |
| 432 | w.refuse(rw, r, "contributors may only propose a branch in their own namespace") | |
| 433 | return | |
|
53ff967b
Keenan Nemetz
13d ago
|
434 | } |
|
41d1fee9
Keenan Nemetz
13d ago
|
435 | tip, err := m.Git().RevParse(ref) |
| 436 | if err != nil || tip == "" { | |
| 437 | http.Error(rw, "that head branch does not exist; push it first", http.StatusBadRequest) | |
| 438 | return | |
| 439 | } | |
| 440 | headTip, headRef = tip, head | |
|
53ff967b
Keenan Nemetz
13d ago
|
441 | } |
| 442 | ||
|
41d1fee9
Keenan Nemetz
13d ago
|
443 | if base != req.Base || headTip != req.Head || headRef != req.HeadRef { |
| 444 | if _, err := metadata.SetRequestTarget(m.Git(), id, base, headTip, headRef, ctx.Name); err != nil { | |
|
b06184d1
Keenan Nemetz
11d ago
|
445 | w.serverError(rw, r, "cannot update the change's target: "+err.Error()) |
|
e050e14a
Keenan Nemetz
13d ago
|
446 | return |
| 447 | } | |
|
b1499c72
Keenan Nemetz
12d ago
|
448 | w.audit("web.request.target", ctx.Name, m.Name, |
| 449 | id+" "+req.Base+"<-"+req.HeadRef+" -> "+base+"<-"+headRef) | |
|
e050e14a
Keenan Nemetz
13d ago
|
450 | if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 { |
|
41d1fee9
Keenan Nemetz
13d ago
|
451 | w.Logger("web: request target %s left the request tree invalid: %v", id, problems) |
|
e050e14a
Keenan Nemetz
13d ago
|
452 | } |
| 453 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
454 | http.Redirect(rw, r, "/"+m.Name+"/changes/"+id, http.StatusSeeOther) |
|
e050e14a
Keenan Nemetz
13d ago
|
455 | } |
| 456 | ||
|
cad93cfe
Keenan Nemetz
10d ago
|
457 | // UpdateRequestIssues adds or removes one issue linked to an open change request. |
| 458 | // The link is the promise landing closes it, so this goes through the same writer | |
| 459 | // as `vole change update --closes`: the issue has to exist and its id is stored. | |
| 460 | func (w *Web) UpdateRequestIssues(rw http.ResponseWriter, r *http.Request) { | |
| 461 | ctx, ok := w.beginWrite(rw, r, maxSmallForm) | |
| 462 | if !ok { | |
| 463 | return | |
| 464 | } | |
| 465 | m := ctx.Repo | |
| 466 | id := r.PathValue("id") | |
|
5d440e37
Keenan Nemetz
10d ago
|
467 | req, err := w.requestAt(memoOf(r), m, id) |
|
cad93cfe
Keenan Nemetz
10d ago
|
468 | if err != nil { |
| 469 | w.notFound(rw, r) | |
| 470 | return | |
| 471 | } | |
| 472 | if !req.Open() { | |
| 473 | w.refuse(rw, r, "only an open change can link issues") | |
| 474 | return | |
| 475 | } | |
| 476 | if !ctx.canTriage(req.Author) { | |
| 477 | w.refuse(rw, r, "you do not have permission to change these links") | |
| 478 | return | |
| 479 | } | |
|
267feec2
Keenan Nemetz
10d ago
|
480 | // A refused link re-renders the Issues section, so the reason appears beside |
|
cad93cfe
Keenan Nemetz
10d ago
|
481 | // the controls instead of replacing the review. |
| 482 | details := func(msg string) (string, any) { | |
| 483 | v := w.requestSidebar(r, m, req, "", "") | |
|
267feec2
Keenan Nemetz
10d ago
|
484 | v.IssuesError = msg |
| 485 | return "request_issues", v | |
|
cad93cfe
Keenan Nemetz
10d ago
|
486 | } |
| 487 | action := strings.TrimSpace(r.PostFormValue("action")) | |
| 488 | if action != "add" && action != "remove" { | |
| 489 | w.fail(rw, r, http.StatusBadRequest, "Choose whether to link or remove the issue.", details) | |
| 490 | return | |
| 491 | } | |
| 492 | ids, err := w.resolveCloses(m, closesField(r.PostFormValue("issue"))) | |
| 493 | if err != nil { | |
| 494 | w.fail(rw, r, http.StatusBadRequest, err.Error(), details) | |
| 495 | return | |
| 496 | } | |
| 497 | if len(ids) == 0 { | |
| 498 | w.fail(rw, r, http.StatusBadRequest, "Type an issue number or id.", details) | |
| 499 | return | |
| 500 | } | |
| 501 | if len(ids) > 1 { | |
| 502 | w.fail(rw, r, http.StatusBadRequest, "Link one issue at a time.", details) | |
| 503 | return | |
| 504 | } | |
| 505 | target := ids[0] | |
| 506 | closes := append([]string(nil), req.Closes...) | |
| 507 | switch action { | |
| 508 | case "add": | |
| 509 | if slices.Contains(closes, target) { | |
| 510 | w.fail(rw, r, http.StatusBadRequest, "That issue is already linked.", details) | |
| 511 | return | |
| 512 | } | |
| 513 | closes = append(closes, target) | |
| 514 | case "remove": | |
| 515 | i := slices.Index(closes, target) | |
| 516 | if i < 0 { | |
| 517 | w.fail(rw, r, http.StatusBadRequest, "That issue is not linked.", details) | |
| 518 | return | |
| 519 | } | |
| 520 | closes = append(closes[:i], closes[i+1:]...) | |
| 521 | } | |
| 522 | updated, err := metadata.SetRequestCloses(m.Git(), id, closes, ctx.Name) | |
| 523 | if err != nil { | |
| 524 | w.fail(rw, r, http.StatusBadRequest, "cannot update the linked issues: "+err.Error(), details) | |
| 525 | return | |
| 526 | } | |
| 527 | w.audit("web.request.issues", ctx.Name, m.Name, id+" "+action+" "+target) | |
| 528 | if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 { | |
| 529 | w.Logger("web: request links %s left the request tree invalid: %v", id, problems) | |
| 530 | } | |
| 531 | if htmxRequest(r) { | |
| 532 | if updated == nil { | |
| 533 | updated = req | |
| 534 | } | |
|
267feec2
Keenan Nemetz
10d ago
|
535 | w.render(rw, "request_issues", w.requestSidebar(r, m, updated, "", ""), 0) |
|
cad93cfe
Keenan Nemetz
10d ago
|
536 | return |
| 537 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
538 | http.Redirect(rw, r, "/"+m.Name+"/changes/"+id, http.StatusSeeOther) |
|
cad93cfe
Keenan Nemetz
10d ago
|
539 | } |
| 540 | ||
|
38b30251
Keenan Nemetz
13d ago
|
541 | // landGate is the landing gate wired to the stores this process already holds. The |
| 542 | // run queue is the web's own open handle, not a path: the server is long-lived and | |
| 543 | // the gate must not close it. | |
| 544 | func (w *Web) landGate(m *repo.Meta, acl *access.ACL, actor string) landing.Gate { | |
| 545 | return landing.Gate{ | |
| 546 | Repo: m.Git(), Name: m.Name, ACL: acl, Actor: actor, Role: acl.RoleFor(actor), | |
| 547 | Runs: func() (*runs.Store, error) { return w.Runs, nil }, | |
|
b1499c72
Keenan Nemetz
12d ago
|
548 | Audit: func(action, actor, subject, detail string) { |
| 549 | w.audit("web."+action, actor, subject, detail) | |
|
38b30251
Keenan Nemetz
13d ago
|
550 | }, |
| 551 | } | |
| 552 | } | |
| 553 | ||
| 554 | // MergeRequest lands a change request in the browser: the same operation | |
| 555 | // `vole change merge` runs, through the same gate the pre-receive hook applies to a | |
| 556 | // push. The branch moves here, so without that call this handler would be the | |
| 557 | // easiest way to land an unapproved change. | |
|
e5478085
Keenan Nemetz
11d ago
|
558 | // |
| 559 | // When the combined result is still building, the click is not thrown away: it arms | |
| 560 | // the request to land as soon as the result is green. Every landing still runs the | |
| 561 | // full gate, so an arm is a wait, not an authorisation. | |
|
38b30251
Keenan Nemetz
13d ago
|
562 | func (w *Web) MergeRequest(rw http.ResponseWriter, r *http.Request) { |
| 563 | ctx, ok := w.beginWrite(rw, r, maxSmallForm) | |
| 564 | if !ok { | |
| 565 | return | |
| 566 | } | |
| 567 | m := ctx.Repo | |
| 568 | if !ctx.canMergeChange() { | |
| 569 | w.refuse(rw, r, "you do not have permission to merge here") | |
| 570 | return | |
| 571 | } | |
| 572 | id := r.PathValue("id") | |
|
5d440e37
Keenan Nemetz
10d ago
|
573 | req, err := w.requestAt(memoOf(r), m, id) |
|
38b30251
Keenan Nemetz
13d ago
|
574 | if err != nil { |
| 575 | w.notFound(rw, r) | |
| 576 | return | |
| 577 | } | |
| 578 | if !req.Open() { | |
|
b06184d1
Keenan Nemetz
11d ago
|
579 | http.Error(rw, "this change is not open", http.StatusBadRequest) |
|
38b30251
Keenan Nemetz
13d ago
|
580 | return |
| 581 | } | |
|
e5478085
Keenan Nemetz
11d ago
|
582 | now := time.Now().UTC() |
| 583 | gate := w.landGate(m, ctx.ACL, ctx.Name) | |
| 584 | ||
| 585 | // Publishing and queueing here makes the click useful even when the reconciler | |
| 586 | // has not seen this head yet. A failure is not fatal: Prepare below reports the | |
| 587 | // real state, and the background pass will try again. | |
| 588 | if w.EnsureCandidate != nil { | |
| 589 | if err := w.EnsureCandidate(m.Name, id); err != nil && w.Logger != nil { | |
| 590 | w.Logger("web: cannot prepare the combined result for %s: %v", id, err) | |
| 591 | } | |
| 592 | } | |
| 593 | ||
| 594 | plan, err := landing.Prepare(gate, id, ctx.Name, now) | |
|
38b30251
Keenan Nemetz
13d ago
|
595 | if err != nil { |
|
e5478085
Keenan Nemetz
11d ago
|
596 | w.mergeRefused(rw, r, m, req, err.Error()) |
| 597 | return | |
| 598 | } | |
| 599 | if len(plan.Conflicts) > 0 { | |
| 600 | w.mergeRefused(rw, r, m, req, fmt.Sprintf( | |
| 601 | "%s does not merge into %s: %s conflict. Resolve it on the branch and push again", | |
| 602 | format.SHA(plan.Head), plan.Base, strings.Join(plan.Conflicts, ", "))) | |
| 603 | return | |
| 604 | } | |
| 605 | if plan.Blocked != "" { | |
| 606 | w.mergeRefused(rw, r, m, req, plan.Blocked) | |
| 607 | return | |
| 608 | } | |
| 609 | if !plan.Ready { | |
| 610 | if plan.Pending || len(plan.Gaps) == 0 { | |
| 611 | w.armMerge(rw, r, m, req, ctx.Name, now, "Merging when the combined result passes.") | |
| 612 | return | |
| 613 | } | |
| 614 | w.mergeRefused(rw, r, m, req, "The combined result failed:\n "+strings.Join(plan.Gaps, "\n ")) | |
| 615 | return | |
| 616 | } | |
| 617 | ||
| 618 | _, err = landing.Land(gate, id, ctx.Name, now) | |
| 619 | if err != nil { | |
| 620 | // A base move between the plan and the landing is the expected race. If the | |
| 621 | // new plan is merely pending, arm instead of making the reader click again. | |
| 622 | if next, perr := landing.Prepare(gate, id, ctx.Name, time.Now().UTC()); perr == nil && | |
| 623 | len(next.Conflicts) == 0 && next.Blocked == "" && !next.Ready && | |
| 624 | (next.Pending || len(next.Gaps) == 0) { | |
| 625 | w.armMerge(rw, r, m, req, ctx.Name, now, "The latest base moved, so Vole is testing the new combination. It will land when that passes.") | |
| 626 | return | |
| 627 | } | |
|
097b2110
Keenan Nemetz
13d ago
|
628 | // The refusal is the answer, so it is shown as written: it names the missing |
| 629 | // approval, the failing check, or the conflicting path. It is shown *on the | |
| 630 | // page* rather than as bare text, so the reader keeps the conflict block and | |
| 631 | // the diff that explain it. | |
|
e5478085
Keenan Nemetz
11d ago
|
632 | w.mergeRefused(rw, r, m, req, err.Error()) |
|
38b30251
Keenan Nemetz
13d ago
|
633 | return |
| 634 | } | |
| 635 | if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 { | |
| 636 | w.Logger("web: merge left the request tree invalid: %v", problems) | |
| 637 | } | |
|
8032d15a
Keenan Nemetz
6d ago
|
638 | // The redirect shows the request page, which now reads as merged. |
|
ed03b3d1
Keenan Nemetz
2h ago
|
639 | http.Redirect(rw, r, "/"+m.Name+"/changes/"+id, http.StatusSeeOther) |
|
38b30251
Keenan Nemetz
13d ago
|
640 | } |
|
183d48e5
Keenan Nemetz
13d ago
|
641 | |
|
e5478085
Keenan Nemetz
11d ago
|
642 | // armMerge records "merge when ready" and answers the click. The intent is a wait, |
| 643 | // not an authorisation: the worker re-runs the whole gate before it moves a ref. | |
| 644 | func (w *Web) armMerge(rw http.ResponseWriter, r *http.Request, m *repo.Meta, req *metadata.Request, actor string, now time.Time, notice string) { | |
| 645 | if w.Runs == nil { | |
| 646 | w.mergeRefused(rw, r, m, req, "this instance has no CI queue, so the combined result cannot be tested here") | |
| 647 | return | |
| 648 | } | |
| 649 | if err := w.Runs.ArmAutoMerge(m.Name, req.ID, actor, now); err != nil { | |
| 650 | w.serverError(rw, r, "cannot arm merge: "+err.Error()) | |
| 651 | return | |
| 652 | } | |
| 653 | w.audit("web.request.merge_when_ready", actor, m.Name, req.ID) | |
| 654 | if htmxRequest(r) { | |
|
707dfd10
Keenan Nemetz
10d ago
|
655 | view := w.requestLiveWrite(r, m, req, "") |
|
e5478085
Keenan Nemetz
11d ago
|
656 | view.Notice = notice |
|
707dfd10
Keenan Nemetz
10d ago
|
657 | w.render(rw, "request_live_write", view, 0) |
|
e5478085
Keenan Nemetz
11d ago
|
658 | return |
| 659 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
660 | http.Redirect(rw, r, "/"+m.Name+"/changes/"+req.ID+"?merging=1", http.StatusSeeOther) |
|
e5478085
Keenan Nemetz
11d ago
|
661 | } |
| 662 | ||
| 663 | // CancelMerge turns off merge-when-ready. It cannot cancel a landing that already | |
| 664 | // happened: the row is gone by then, and the redirect shows the merged page. | |
| 665 | func (w *Web) CancelMerge(rw http.ResponseWriter, r *http.Request) { | |
| 666 | ctx, ok := w.beginWrite(rw, r, maxSmallForm) | |
| 667 | if !ok { | |
| 668 | return | |
| 669 | } | |
| 670 | if !ctx.canMergeChange() { | |
| 671 | w.refuse(rw, r, "you do not have permission to merge here") | |
| 672 | return | |
| 673 | } | |
| 674 | m := ctx.Repo | |
| 675 | id := r.PathValue("id") | |
| 676 | if w.Runs == nil { | |
| 677 | http.Error(rw, "this instance has no CI queue", http.StatusBadRequest) | |
| 678 | return | |
| 679 | } | |
| 680 | if err := w.Runs.DisarmAutoMerge(m.Name, id); err != nil { | |
| 681 | w.serverError(rw, r, "cannot cancel merge-when-ready: "+err.Error()) | |
| 682 | return | |
| 683 | } | |
| 684 | w.audit("web.request.merge_cancel", ctx.Name, m.Name, id) | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
685 | http.Redirect(rw, r, "/"+m.Name+"/changes/"+id, http.StatusSeeOther) |
|
e5478085
Keenan Nemetz
11d ago
|
686 | } |
| 687 | ||
| 688 | // mergeRefused renders a refusal where the reader can see the review, the conflict | |
| 689 | // block and the controls that explain it. | |
| 690 | func (w *Web) mergeRefused(rw http.ResponseWriter, r *http.Request, m *repo.Meta, req *metadata.Request, msg string) { | |
| 691 | if htmxRequest(r) { | |
|
707dfd10
Keenan Nemetz
10d ago
|
692 | w.render(rw, "request_live_write", w.requestLiveWrite(r, m, req, msg), 0) |
|
e5478085
Keenan Nemetz
11d ago
|
693 | return |
| 694 | } | |
| 695 | w.renderRequestPage(rw, r, msg, http.StatusConflict) | |
| 696 | } | |
| 697 | ||
|
183d48e5
Keenan Nemetz
13d ago
|
698 | // closesField splits the closes input: spaces, commas and newlines all work, because |
| 699 | // the field is one line somebody pasted issue ids into. | |
| 700 | func closesField(raw string) []string { | |
| 701 | return strings.FieldsFunc(raw, func(r rune) bool { | |
| 702 | return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r' | |
| 703 | }) | |
| 704 | } |