v0.35.2 80d5067

mr_post.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"errors"
8	"net/http"
9	"strconv"
10	"strings"
11
12	"github.com/bnema/zerowrap"
13
14	"bnema.dev/ermite/internal/domain"
15	"bnema.dev/ermite/internal/ports"
16)
17
18// The writes of the merge request pages. Each is a POST that passed the gate
19// (an Origin of this site and the owner's session), calls the use case as the
20// owner, and answers 303 to the page that shows the result, with
21// "Cache-Control: no-store". A request the use case refuses is answered with a
22// state page that says why, in the words of the use case for the rules the
23// owner can fix (a text rule, a moved branch, a full merge request), and
24// without detail for anything else.
25
26// maxFormBytes bounds the body of a form: a description of 8,000 characters is
27// at most 32,000 bytes.
28const maxFormBytes = 128 << 10
29
30// form reads the form of a POST, bounded.
31func (s *Server) form(w http.ResponseWriter, r *http.Request) bool {
32	r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
33	if err := r.ParseForm(); err != nil {
34		back, what := mrBase(r.PathValue("repo")), "a merge request"
35		if strings.HasPrefix(r.URL.Path, inboxPath+"/") {
36			back, what = inboxPath, "a moderation action"
37		}
38		if strings.Contains(r.URL.Path, "/issues") {
39			back, what = issuesBase(r.PathValue("repo")), "an issue"
40		}
41		s.reject(r.Context(), w, r, http.StatusRequestEntityTooLarge, "form too large", "the form is larger than "+what+" can be.", back)
42		return false
43	}
44	return true
45}
46
47// seeOther redirects to a page, never cached.
48func seeOther(w http.ResponseWriter, r *http.Request, to string) {
49	w.Header().Set("Cache-Control", "no-store")
50	http.Redirect(w, r, to, http.StatusSeeOther)
51}
52
53// mrAction wraps a POST handler: it bounds the use case calls, reads the form
54// and maps the error of the use case to a page.
55func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to string, back string, err error)) http.HandlerFunc {
56	return func(w http.ResponseWriter, r *http.Request) {
57		ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
58		defer cancel()
59		if !s.form(w, r) {
60			return
61		}
62		to, back, err := h(ctx, r)
63		var re *rerender
64		if errors.As(err, &re) && s.rerenderMR(ctx, w, r, re) {
65			return
66		}
67		if err != nil {
68			s.actionFailed(ctx, w, r, err, back)
69			return
70		}
71		if isHXMerge(r) {
72			c, _, err := s.mrPageView(ctx, r, false, true)
73			if err != nil {
74				id := requestID()
75				log := zerowrap.FromCtx(ctx)
76				log.Error().Err(err).Str("request_id", id).Msg("web merged state failed")
77				w.Header().Set("Cache-Control", "no-store")
78				s.write(ctx, w, r, http.StatusOK, MRActionStatus("merged, but the updated page could not be loaded. reload to see the result. reference: "+id, back))
79				return
80			}
81			w.Header().Set("Cache-Control", "no-store")
82			s.write(ctx, w, r, http.StatusOK, c)
83			return
84		}
85		seeOther(w, r, to)
86	}
87}
88
89// rerender is the error of a merge the use case refused for a reason the
90// owner reads on the page of the request: the page is shown again, with the
91// status, and the reason in its merge box. Any other error keeps actionFailed.
92type rerender struct {
93	status int
94	msg    string
95	err    error
96}
97
98func (e *rerender) Error() string { return e.err.Error() }
99func (e *rerender) Unwrap() error { return e.err }
100
101type mergeErrorKey struct{}
102
103// withMergeError carries the reason of a refused merge to the page.
104func withMergeError(ctx context.Context, msg string) context.Context {
105	return context.WithValue(ctx, mergeErrorKey{}, msg)
106}
107
108func mergeErrorOf(ctx context.Context) string {
109	msg, _ := ctx.Value(mergeErrorKey{}).(string)
110	return msg
111}
112
113// rerenderMR writes the page of the request with the reason of the refusal.
114// It reports false when the page cannot be built; the caller then answers as
115// for any refused action.
116func (s *Server) rerenderMR(ctx context.Context, w http.ResponseWriter, r *http.Request, re *rerender) bool {
117	c, _, err := s.mrPageView(withMergeError(ctx, re.msg), r, false, isHXMerge(r))
118	if err != nil {
119		return false
120	}
121	w.Header().Set("Cache-Control", "no-store")
122	s.write(ctx, w, r, re.status, c)
123	return true
124}
125
126// actionFailed answers a refused action.
127func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) {
128	// The words of the use case, for the rules the owner can fix.
129	msg := err.Error()
130	// The order is that of the API's mapping of the domain errors to codes
131	// (rpc.codeOf), so that a web action answers what the API would.
132	switch {
133	case errors.Is(err, context.Canceled):
134		// The browser is gone.
135	case errors.Is(err, domain.ErrNotFound):
136		w.Header().Set("Cache-Control", "no-store")
137		if isHXMerge(r) {
138			s.write(ctx, w, r, http.StatusNotFound, MRActionStatus("the merge request was not found. reload to check its state.", back))
139			return
140		}
141		s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage())
142	case errors.Is(err, domain.ErrAlreadyExists):
143		s.reject(ctx, w, r, http.StatusConflict, "already there", rule(msg, "this already exists."), back)
144	case errors.Is(err, domain.ErrUnauthenticated), errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrRejected):
145		s.reject(ctx, w, r, http.StatusForbidden, "forbidden", "the session is not allowed to do this.", back)
146	case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
147		s.reject(ctx, w, r, http.StatusConflict, "not possible now", rule(msg, "the merge request is not in a state that allows this."), back)
148	case errors.Is(err, domain.ErrResourceExhausted):
149		s.reject(ctx, w, r, http.StatusConflict, "limit reached", rule(msg, "a limit of the merge request is reached."), back)
150	case domain.IsInvalid(err):
151		s.reject(ctx, w, r, http.StatusBadRequest, "not accepted", rule(msg, "the server refused what was sent."), back)
152	default:
153		s.internalFailure(ctx, w, r, err)
154	}
155}
156
157// internalFailure answers a failure of an action that is not the owner's to
158// fix: the 500 page with an id, and the cause in the log.
159func (s *Server) internalFailure(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
160	id := requestID()
161	log := zerowrap.FromCtx(ctx)
162	log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web action failed")
163	w.Header().Set("Cache-Control", "no-store")
164	if isHXMerge(r) {
165		s.write(ctx, w, r, http.StatusInternalServerError, MRActionStatus("the merge could not be completed. reload to check its state. reference: "+id, strings.TrimSuffix(r.URL.Path, "/merge")))
166		return
167	}
168	s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id))
169}
170
171// rule is a message of the use case fit to show: the invisible characters marked,
172// bounded, and a fallback for an empty one.
173func rule(msg, fallback string) string {
174	msg = strings.TrimSpace(visibleText(msg))
175	if msg == "" {
176		return fallback
177	}
178	return cutRunes(msg, 400)
179}
180
181// reject writes the page of a refused action, with the way back.
182func (s *Server) reject(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, title, line, back string) {
183	if back == "" {
184		back = "/"
185	}
186	if isHXMerge(r) {
187		w.Header().Set("Cache-Control", "no-store")
188		s.write(ctx, w, r, status, MRActionStatus(line, back))
189		return
190	}
191	l := s.stateLayout(title, []Crumb{{Label: title, Current: true}})
192	w.Header().Set("Cache-Control", "no-store")
193	s.write(ctx, w, r, status, State(l, StatePage{
194		Code: itoa(status), Title: title,
195		Lines: []string{line, "nothing was changed by this request."},
196		Back:  []Link{{Label: "back", Href: back}, {Label: "home", Href: "/"}},
197	}))
198}
199
200// number reads the merge request number of the path.
201func number(r *http.Request) (int64, error) { return mrNumber(r) }
202
203// mrCreate is POST /<repo>/merge-requests: it opens a merge request.
204func (s *Server) mrCreate(ctx context.Context, r *http.Request) (string, string, error) {
205	repo := r.PathValue("repo")
206	source := r.PostFormValue("source")
207	back := repoHref(repo, "compare", source)
208	name, err := domain.ParseRepoName(repo)
209	if err != nil {
210		return "", back, err
211	}
212	mr, err := s.cfg.MergeRequests.Create(ctx, principal(ctx), name, ports.CreateMergeRequestParams{
213		Title: r.PostFormValue("title"), Description: normalizeNewlines(r.PostFormValue("description")),
214		Source: source, Target: r.PostFormValue("target"),
215	})
216	if err != nil {
217		return "", back, err
218	}
219	return mrHref(repo, mr.Number), back, nil
220}
221
222// formError is a form the server refuses before asking a use case: it matches
223// domain.ErrInvalid and says just what is wrong.
224type formError string
225
226func (e formError) Error() string { return string(e) }
227
228func (formError) Unwrap() error { return domain.ErrInvalid }
229
230// mrComment is POST /<repo>/merge-requests/<n>/comments: a general comment, or
231// one on a line (path, line and side).
232func (s *Server) mrComment(ctx context.Context, r *http.Request) (string, string, error) {
233	repo := r.PathValue("repo")
234	n, err := number(r)
235	if err != nil {
236		return "", "", err
237	}
238	back := mrHref(repo, n)
239	params := ports.AddCommentParams{Text: normalizeNewlines(r.PostFormValue("text")), Path: r.PostFormValue("path")}
240	if params.Path != "" || r.PostFormValue("line") != "" || r.PostFormValue("side") != "" {
241		line, err := strconv.ParseInt(r.PostFormValue("line"), 10, 32)
242		if err != nil {
243			return "", back, formError("line is not a number")
244		}
245		params.Line = int(line)
246		switch r.PostFormValue("side") {
247		case "new":
248			params.Side = domain.SideNew
249		case "old":
250			params.Side = domain.SideOld
251		default:
252			return "", back, formError("side must be new or old")
253		}
254	}
255	if raw := r.PostFormValue("reply_to"); raw != "" {
256		params.ReplyTo = commentID(raw)
257		if params.ReplyTo == 0 {
258			return "", back, formError("reply_to is not a note id")
259		}
260	}
261	if raw := r.PostFormValue("start_line"); raw != "" {
262		v, e := strconv.ParseInt(raw, 10, 32)
263		if e != nil {
264			return "", back, formError("start_line is not a number")
265		}
266		params.StartLine = int(v)
267	}
268	params.Verdict, err = domain.ParseVerdict(r.PostFormValue("verdict"))
269	if err != nil {
270		return "", back, err
271	}
272	name, err := domain.ParseRepoName(repo)
273	if err != nil {
274		return "", back, err
275	}
276	back = withFrom(back, formFrom(r))
277	c, err := s.cfg.MergeRequests.AddComment(ctx, principal(ctx), name, n, params)
278	if err != nil {
279		return "", back, err
280	}
281	return back + "#c" + strconv.FormatInt(c.ID, 10), back, nil
282}
283
284// mrResolve is POST /<repo>/merge-requests/<n>/comments/<id>/resolve.
285//
286//hexcheck:ignore suspicious-business-logic-in-adapter reads two form fields into a use case call; the state of the comment is decided by the use case
287func (s *Server) mrResolve(ctx context.Context, r *http.Request) (string, string, error) {
288	repo := r.PathValue("repo")
289	n, err := number(r)
290	if err != nil {
291		return "", "", err
292	}
293	back := mrHref(repo, n)
294	resolved, err := formBool(r.PostFormValue("resolved"))
295	if err != nil {
296		return "", back, formError("resolved: " + err.Error())
297	}
298	name, err := domain.ParseRepoName(repo)
299	if err != nil {
300		return "", back, err
301	}
302	// An id this server never issued is 0: the use case answers not found,
303	// after the repository and the scope were checked, as for any unknown
304	// comment.
305	back = withFrom(back, formFrom(r))
306	c, err := s.cfg.MergeRequests.ResolveComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), resolved)
307	if err != nil {
308		return "", back, err
309	}
310	return back + "#c" + strconv.FormatInt(c.ID, 10), back, nil
311}
312
313// commentID parses the id of a comment in a path: a positive decimal number,
314// written without sign or leading zeros. Anything else is 0, which names no
315// comment.
316func commentID(s string) int64 {
317	n, err := strconv.ParseInt(s, 10, 64)
318	if err != nil || n < 1 || strconv.FormatInt(n, 10) != s {
319		return 0
320	}
321	return n
322}
323
324// headID parses a commit id of a form; the empty string is the zero id.
325func headID(s string) (domain.ObjectID, error) {
326	if s == "" {
327		return domain.ObjectID{}, nil
328	}
329	var v domain.Violations
330	id, err := domain.ParseObjectID(s)
331	if err != nil {
332		v.Add("head_id", "is not a full commit id")
333	}
334	return id, v.Err()
335}
336
337// mrClose is POST /<repo>/merge-requests/<n>/close.
338func (s *Server) mrClose(ctx context.Context, r *http.Request) (string, string, error) {
339	repo := r.PathValue("repo")
340	n, err := number(r)
341	if err != nil {
342		return "", "", err
343	}
344	back := mrHref(repo, n)
345	name, err := domain.ParseRepoName(repo)
346	if err != nil {
347		return "", back, err
348	}
349	if _, err := s.cfg.MergeRequests.Close(ctx, principal(ctx), name, n); err != nil {
350		return "", back, err
351	}
352	return back, back, nil
353}
354
355// mrReady is POST /<repo>/merge-requests/<n>/ready: the draft prefix leaves
356// the title.
357func (s *Server) mrReady(ctx context.Context, r *http.Request) (string, string, error) {
358	repo := r.PathValue("repo")
359	n, err := number(r)
360	if err != nil {
361		return "", "", err
362	}
363	back := mrHref(repo, n)
364	name, err := domain.ParseRepoName(repo)
365	if err != nil {
366		return "", back, err
367	}
368	if _, err := s.cfg.MergeRequests.MarkReady(ctx, principal(ctx), name, n); err != nil {
369		return "", back, err
370	}
371	return back, back, nil
372}
373
374// mrMerge is POST /<repo>/merge-requests/<n>/merge. The form carries the mode,
375// the head the owner was shown (head_id): the server merges only if the
376// branch still points to it, and delete_branch when the owner asked to delete
377// the source branch after the merge.
378func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string, error) {
379	repo := r.PathValue("repo")
380	n, err := number(r)
381	if err != nil {
382		return "", "", err
383	}
384	back := mrHref(repo, n)
385	var mode domain.MergeMode
386	switch r.PostFormValue("mode") {
387	case "fast-forward":
388		mode = domain.MergeModeFastForward
389	case "squash":
390		mode = domain.MergeModeSquash
391	case "merge":
392		mode = domain.MergeModeMerge
393	default:
394		err := formError("mode must be fast-forward, squash or merge")
395		return "", back, &rerender{status: http.StatusBadRequest, msg: err.Error(), err: err}
396	}
397	name, err := domain.ParseRepoName(repo)
398	if err != nil {
399		return "", back, err
400	}
401	head, err := headID(r.PostFormValue("head_id"))
402	if err != nil {
403		return "", back, err
404	}
405	res, err := s.cfg.MergeRequests.Merge(ctx, principal(ctx), name, n, ports.MergeParams{
406		Mode: mode, HeadID: head, DeleteSource: r.PostFormValue("delete_branch") == "1",
407		IgnoreChecks: r.PostFormValue("ignore_checks") == "on",
408	})
409	switch {
410	case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
411		return "", back, &rerender{status: http.StatusConflict, msg: rule(err.Error(), "the merge request is not in a state that allows this."), err: err}
412	case err != nil:
413		return "", back, err
414	}
415	if res.SourceDeleted {
416		// The deletion follows the answer: the page says it is being done.
417		return back + "?deleting=1", back, nil
418	}
419	return back, back, nil
420}
421
422// mrAutoMerge is POST /<repo>/merge-requests/<n>/auto-merge: the form of the
423// merge (mode, head_id) asks to merge once the checks pass. The server records
424// refuses to enable when the source branch is no longer at head_id.
425func (s *Server) mrAutoMerge(ctx context.Context, r *http.Request) (string, string, error) {
426	repo := r.PathValue("repo")
427	n, err := number(r)
428	if err != nil {
429		return "", "", err
430	}
431	back := mrHref(repo, n)
432	var mode domain.MergeMode
433	switch r.PostFormValue("mode") {
434	case "fast-forward":
435		mode = domain.MergeModeFastForward
436	case "squash":
437		mode = domain.MergeModeSquash
438	case "merge":
439		mode = domain.MergeModeMerge
440	default:
441		err := formError("mode must be fast-forward, squash or merge")
442		return "", back, &rerender{status: http.StatusBadRequest, msg: err.Error(), err: err}
443	}
444	name, err := domain.ParseRepoName(repo)
445	if err != nil {
446		return "", back, err
447	}
448	head, err := headID(r.PostFormValue("head_id"))
449	if err != nil {
450		return "", back, err
451	}
452	_, err = s.cfg.MergeRequests.EnableAutoMerge(ctx, principal(ctx), name, n, ports.EnableAutoMergeParams{Mode: mode, HeadID: head})
453	switch {
454	case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
455		return "", back, &rerender{status: http.StatusConflict, msg: rule(err.Error(), "the merge request is not in a state that allows this."), err: err}
456	case err != nil:
457		return "", back, err
458	}
459	return back, back, nil
460}
461
462// mrCancelAutoMerge is POST /<repo>/merge-requests/<n>/auto-merge/cancel.
463func (s *Server) mrCancelAutoMerge(ctx context.Context, r *http.Request) (string, string, error) {
464	repo := r.PathValue("repo")
465	n, err := number(r)
466	if err != nil {
467		return "", "", err
468	}
469	back := mrHref(repo, n)
470	name, err := domain.ParseRepoName(repo)
471	if err != nil {
472		return "", back, err
473	}
474	_, err = s.cfg.MergeRequests.CancelAutoMerge(ctx, principal(ctx), name, n)
475	switch {
476	case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
477		return "", back, &rerender{status: http.StatusConflict, msg: rule(err.Error(), "the merge request is not in a state that allows this."), err: err}
478	case err != nil:
479		return "", back, err
480	}
481	return back, back, nil
482}
483
484// mrRead is POST /<repo>/merge-requests/<n>/read: the owner marks the head
485// they were shown as read. Reading a page never does it (see the top of mr.go).
486func (s *Server) mrRead(ctx context.Context, r *http.Request) (string, string, error) {
487	repo := r.PathValue("repo")
488	n, err := number(r)
489	if err != nil {
490		return "", "", err
491	}
492	back := mrHref(repo, n)
493	name, err := domain.ParseRepoName(repo)
494	if err != nil {
495		return "", back, err
496	}
497	head, err := headID(r.PostFormValue("head_id"))
498	if err != nil {
499		return "", back, err
500	}
501	if _, err := s.cfg.MergeRequests.MarkRead(ctx, principal(ctx), name, n, head); err != nil {
502		return "", back, err
503	}
504	return back, back, nil
505}
506
507// formBool reads the "true" or "false" a form field carries.
508func formBool(s string) (bool, error) {
509	switch s {
510	case "true":
511		return true, nil
512	case "false":
513		return false, nil
514	}
515	return false, errors.New("must be true or false")
516}
517
518// normalizeNewlines turns the CRLF of a browser textarea into LF, the line
519// break the use case stores.
520func normalizeNewlines(s string) string {
521	return strings.ReplaceAll(s, "\r\n", "\n")
522}
523
524// mrEditComment passes source text to the author-checked use case.
525func (s *Server) mrEditComment(ctx context.Context, r *http.Request) (string, string, error) {
526	n, err := number(r)
527	if err != nil {
528		return "", "", err
529	}
530	back := withFrom(mrHref(r.PathValue("repo"), n), formFrom(r))
531	name, err := domain.ParseRepoName(r.PathValue("repo"))
532	if err != nil {
533		return "", back, err
534	}
535	c, err := s.cfg.MergeRequests.EditComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), normalizeNewlines(r.PostFormValue("text")))
536	return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
537}
538
539// mrDeleteComment passes the note id to the author-checked use case.
540func (s *Server) mrDeleteComment(ctx context.Context, r *http.Request) (string, string, error) {
541	n, err := number(r)
542	if err != nil {
543		return "", "", err
544	}
545	back := withFrom(mrHref(r.PathValue("repo"), n), formFrom(r))
546	name, err := domain.ParseRepoName(r.PathValue("repo"))
547	if err != nil {
548		return "", back, err
549	}
550	c, err := s.cfg.MergeRequests.DeleteComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")))
551	return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
552}
553
554// isHXMerge limits fragment responses to the progressively enhanced merge form.
555func isHXMerge(r *http.Request) bool {
556	return r.Header.Get("HX-Request") == "true" && strings.HasSuffix(r.URL.Path, "/merge")
557}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close