v0.28.0 fca9155

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		seeOther(w, r, to)
72	}
73}
74
75// rerender is the error of a merge the use case refused for a reason the
76// owner reads on the page of the request: the page is shown again, with the
77// status, and the reason in its merge box. Any other error keeps actionFailed.
78type rerender struct {
79	status int
80	msg    string
81	err    error
82}
83
84func (e *rerender) Error() string { return e.err.Error() }
85func (e *rerender) Unwrap() error { return e.err }
86
87type mergeErrorKey struct{}
88
89// withMergeError carries the reason of a refused merge to the page.
90func withMergeError(ctx context.Context, msg string) context.Context {
91	return context.WithValue(ctx, mergeErrorKey{}, msg)
92}
93
94func mergeErrorOf(ctx context.Context) string {
95	msg, _ := ctx.Value(mergeErrorKey{}).(string)
96	return msg
97}
98
99// rerenderMR writes the page of the request with the reason of the refusal.
100// It reports false when the page cannot be built; the caller then answers as
101// for any refused action.
102func (s *Server) rerenderMR(ctx context.Context, w http.ResponseWriter, r *http.Request, re *rerender) bool {
103	c, _, err := s.mrPage(withMergeError(ctx, re.msg), r, false)
104	if err != nil {
105		return false
106	}
107	w.Header().Set("Cache-Control", "no-store")
108	s.write(ctx, w, r, re.status, c)
109	return true
110}
111
112// actionFailed answers a refused action.
113func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) {
114	// The words of the use case, for the rules the owner can fix.
115	msg := err.Error()
116	// The order is that of the API's mapping of the domain errors to codes
117	// (rpc.codeOf), so that a web action answers what the API would.
118	switch {
119	case errors.Is(err, context.Canceled):
120		// The browser is gone.
121	case errors.Is(err, domain.ErrNotFound):
122		w.Header().Set("Cache-Control", "no-store")
123		s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage())
124	case errors.Is(err, domain.ErrAlreadyExists):
125		s.reject(ctx, w, r, http.StatusConflict, "already there", rule(msg, "this already exists."), back)
126	case errors.Is(err, domain.ErrUnauthenticated), errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrRejected):
127		s.reject(ctx, w, r, http.StatusForbidden, "forbidden", "the session is not allowed to do this.", back)
128	case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
129		s.reject(ctx, w, r, http.StatusConflict, "not possible now", rule(msg, "the merge request is not in a state that allows this."), back)
130	case errors.Is(err, domain.ErrResourceExhausted):
131		s.reject(ctx, w, r, http.StatusConflict, "limit reached", rule(msg, "a limit of the merge request is reached."), back)
132	case domain.IsInvalid(err):
133		s.reject(ctx, w, r, http.StatusBadRequest, "not accepted", rule(msg, "the server refused what was sent."), back)
134	default:
135		s.internalFailure(ctx, w, r, err)
136	}
137}
138
139// internalFailure answers a failure of an action that is not the owner's to
140// fix: the 500 page with an id, and the cause in the log.
141func (s *Server) internalFailure(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
142	id := requestID()
143	log := zerowrap.FromCtx(ctx)
144	log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web action failed")
145	w.Header().Set("Cache-Control", "no-store")
146	s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id))
147}
148
149// rule is a message of the use case fit to show: the invisible characters marked,
150// bounded, and a fallback for an empty one.
151func rule(msg, fallback string) string {
152	msg = strings.TrimSpace(visibleText(msg))
153	if msg == "" {
154		return fallback
155	}
156	return cutRunes(msg, 400)
157}
158
159// reject writes the page of a refused action, with the way back.
160func (s *Server) reject(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, title, line, back string) {
161	if back == "" {
162		back = "/"
163	}
164	l := s.stateLayout(title, []Crumb{{Label: title, Current: true}})
165	w.Header().Set("Cache-Control", "no-store")
166	s.write(ctx, w, r, status, State(l, StatePage{
167		Code: itoa(status), Title: title,
168		Lines: []string{line, "nothing was changed by this request."},
169		Back:  []Link{{Label: "back", Href: back}, {Label: "home", Href: "/"}},
170	}))
171}
172
173// number reads the merge request number of the path.
174func number(r *http.Request) (int64, error) { return mrNumber(r) }
175
176// mrCreate is POST /<repo>/merge-requests: it opens a merge request.
177func (s *Server) mrCreate(ctx context.Context, r *http.Request) (string, string, error) {
178	repo := r.PathValue("repo")
179	source := r.PostFormValue("source")
180	back := repoHref(repo, "compare", source)
181	name, err := domain.ParseRepoName(repo)
182	if err != nil {
183		return "", back, err
184	}
185	mr, err := s.cfg.MergeRequests.Create(ctx, principal(ctx), name, ports.CreateMergeRequestParams{
186		Title: r.PostFormValue("title"), Description: normalizeNewlines(r.PostFormValue("description")),
187		Source: source, Target: r.PostFormValue("target"),
188	})
189	if err != nil {
190		return "", back, err
191	}
192	return mrHref(repo, mr.Number), back, nil
193}
194
195// formError is a form the server refuses before asking a use case: it matches
196// domain.ErrInvalid and says just what is wrong.
197type formError string
198
199func (e formError) Error() string { return string(e) }
200
201func (formError) Unwrap() error { return domain.ErrInvalid }
202
203// mrComment is POST /<repo>/merge-requests/<n>/comments: a general comment, or
204// one on a line (path, line and side).
205func (s *Server) mrComment(ctx context.Context, r *http.Request) (string, string, error) {
206	repo := r.PathValue("repo")
207	n, err := number(r)
208	if err != nil {
209		return "", "", err
210	}
211	back := mrHref(repo, n)
212	params := ports.AddCommentParams{Text: normalizeNewlines(r.PostFormValue("text")), Path: r.PostFormValue("path")}
213	if params.Path != "" || r.PostFormValue("line") != "" || r.PostFormValue("side") != "" {
214		line, err := strconv.ParseInt(r.PostFormValue("line"), 10, 32)
215		if err != nil {
216			return "", back, formError("line is not a number")
217		}
218		params.Line = int(line)
219		switch r.PostFormValue("side") {
220		case "new":
221			params.Side = domain.SideNew
222		case "old":
223			params.Side = domain.SideOld
224		default:
225			return "", back, formError("side must be new or old")
226		}
227	}
228	if raw := r.PostFormValue("reply_to"); raw != "" {
229		params.ReplyTo = commentID(raw)
230		if params.ReplyTo == 0 {
231			return "", back, formError("reply_to is not a note id")
232		}
233	}
234	if raw := r.PostFormValue("start_line"); raw != "" {
235		v, e := strconv.ParseInt(raw, 10, 32)
236		if e != nil {
237			return "", back, formError("start_line is not a number")
238		}
239		params.StartLine = int(v)
240	}
241	params.Verdict, err = domain.ParseVerdict(r.PostFormValue("verdict"))
242	if err != nil {
243		return "", back, err
244	}
245	name, err := domain.ParseRepoName(repo)
246	if err != nil {
247		return "", back, err
248	}
249	back = withFrom(back, formFrom(r))
250	c, err := s.cfg.MergeRequests.AddComment(ctx, principal(ctx), name, n, params)
251	if err != nil {
252		return "", back, err
253	}
254	return back + "#c" + strconv.FormatInt(c.ID, 10), back, nil
255}
256
257// mrResolve is POST /<repo>/merge-requests/<n>/comments/<id>/resolve.
258//
259//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
260func (s *Server) mrResolve(ctx context.Context, r *http.Request) (string, string, error) {
261	repo := r.PathValue("repo")
262	n, err := number(r)
263	if err != nil {
264		return "", "", err
265	}
266	back := mrHref(repo, n)
267	resolved, err := formBool(r.PostFormValue("resolved"))
268	if err != nil {
269		return "", back, formError("resolved: " + err.Error())
270	}
271	name, err := domain.ParseRepoName(repo)
272	if err != nil {
273		return "", back, err
274	}
275	// An id this server never issued is 0: the use case answers not found,
276	// after the repository and the scope were checked, as for any unknown
277	// comment.
278	back = withFrom(back, formFrom(r))
279	c, err := s.cfg.MergeRequests.ResolveComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), resolved)
280	if err != nil {
281		return "", back, err
282	}
283	return back + "#c" + strconv.FormatInt(c.ID, 10), back, nil
284}
285
286// commentID parses the id of a comment in a path: a positive decimal number,
287// written without sign or leading zeros. Anything else is 0, which names no
288// comment.
289func commentID(s string) int64 {
290	n, err := strconv.ParseInt(s, 10, 64)
291	if err != nil || n < 1 || strconv.FormatInt(n, 10) != s {
292		return 0
293	}
294	return n
295}
296
297// headID parses a commit id of a form; the empty string is the zero id.
298func headID(s string) (domain.ObjectID, error) {
299	if s == "" {
300		return domain.ObjectID{}, nil
301	}
302	var v domain.Violations
303	id, err := domain.ParseObjectID(s)
304	if err != nil {
305		v.Add("head_id", "is not a full commit id")
306	}
307	return id, v.Err()
308}
309
310// mrClose is POST /<repo>/merge-requests/<n>/close.
311func (s *Server) mrClose(ctx context.Context, r *http.Request) (string, string, error) {
312	repo := r.PathValue("repo")
313	n, err := number(r)
314	if err != nil {
315		return "", "", err
316	}
317	back := mrHref(repo, n)
318	name, err := domain.ParseRepoName(repo)
319	if err != nil {
320		return "", back, err
321	}
322	if _, err := s.cfg.MergeRequests.Close(ctx, principal(ctx), name, n); err != nil {
323		return "", back, err
324	}
325	return back, back, nil
326}
327
328// mrReady is POST /<repo>/merge-requests/<n>/ready: the draft prefix leaves
329// the title.
330func (s *Server) mrReady(ctx context.Context, r *http.Request) (string, string, error) {
331	repo := r.PathValue("repo")
332	n, err := number(r)
333	if err != nil {
334		return "", "", err
335	}
336	back := mrHref(repo, n)
337	name, err := domain.ParseRepoName(repo)
338	if err != nil {
339		return "", back, err
340	}
341	if _, err := s.cfg.MergeRequests.MarkReady(ctx, principal(ctx), name, n); err != nil {
342		return "", back, err
343	}
344	return back, back, nil
345}
346
347// mrMerge is POST /<repo>/merge-requests/<n>/merge. The form carries the mode,
348// the head the owner was shown (head_id): the server merges only if the
349// branch still points to it, and delete_branch when the owner asked to delete
350// the source branch after the merge.
351func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string, error) {
352	repo := r.PathValue("repo")
353	n, err := number(r)
354	if err != nil {
355		return "", "", err
356	}
357	back := mrHref(repo, n)
358	var mode domain.MergeMode
359	switch r.PostFormValue("mode") {
360	case "fast-forward":
361		mode = domain.MergeModeFastForward
362	case "squash":
363		mode = domain.MergeModeSquash
364	case "merge":
365		mode = domain.MergeModeMerge
366	default:
367		err := formError("mode must be fast-forward, squash or merge")
368		return "", back, &rerender{status: http.StatusBadRequest, msg: err.Error(), err: err}
369	}
370	name, err := domain.ParseRepoName(repo)
371	if err != nil {
372		return "", back, err
373	}
374	head, err := headID(r.PostFormValue("head_id"))
375	if err != nil {
376		return "", back, err
377	}
378	res, err := s.cfg.MergeRequests.Merge(ctx, principal(ctx), name, n, ports.MergeParams{
379		Mode: mode, HeadID: head, DeleteSource: r.PostFormValue("delete_branch") == "1",
380	})
381	switch {
382	case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
383		return "", back, &rerender{status: http.StatusConflict, msg: rule(err.Error(), "the merge request is not in a state that allows this."), err: err}
384	case err != nil:
385		return "", back, err
386	}
387	if res.SourceDeleted {
388		// The deletion follows the answer: the page says it is being done.
389		return back + "?deleting=1", back, nil
390	}
391	return back, back, nil
392}
393
394// mrRead is POST /<repo>/merge-requests/<n>/read: the owner marks the head
395// they were shown as read. Reading a page never does it (see the top of mr.go).
396func (s *Server) mrRead(ctx context.Context, r *http.Request) (string, string, error) {
397	repo := r.PathValue("repo")
398	n, err := number(r)
399	if err != nil {
400		return "", "", err
401	}
402	back := mrHref(repo, n)
403	name, err := domain.ParseRepoName(repo)
404	if err != nil {
405		return "", back, err
406	}
407	head, err := headID(r.PostFormValue("head_id"))
408	if err != nil {
409		return "", back, err
410	}
411	if _, err := s.cfg.MergeRequests.MarkRead(ctx, principal(ctx), name, n, head); err != nil {
412		return "", back, err
413	}
414	return back, back, nil
415}
416
417// formBool reads the "true" or "false" a form field carries.
418func formBool(s string) (bool, error) {
419	switch s {
420	case "true":
421		return true, nil
422	case "false":
423		return false, nil
424	}
425	return false, errors.New("must be true or false")
426}
427
428// normalizeNewlines turns the CRLF of a browser textarea into LF, the line
429// break the use case stores.
430func normalizeNewlines(s string) string {
431	return strings.ReplaceAll(s, "\r\n", "\n")
432}
433
434// mrEditComment passes source text to the author-checked use case.
435func (s *Server) mrEditComment(ctx context.Context, r *http.Request) (string, string, error) {
436	n, err := number(r)
437	if err != nil {
438		return "", "", err
439	}
440	back := withFrom(mrHref(r.PathValue("repo"), n), formFrom(r))
441	name, err := domain.ParseRepoName(r.PathValue("repo"))
442	if err != nil {
443		return "", back, err
444	}
445	c, err := s.cfg.MergeRequests.EditComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), normalizeNewlines(r.PostFormValue("text")))
446	return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
447}
448
449// mrDeleteComment passes the note id to the author-checked use case.
450func (s *Server) mrDeleteComment(ctx context.Context, r *http.Request) (string, string, error) {
451	n, err := number(r)
452	if err != nil {
453		return "", "", err
454	}
455	back := withFrom(mrHref(r.PathValue("repo"), n), formFrom(r))
456	name, err := domain.ParseRepoName(r.PathValue("repo"))
457	if err != nil {
458		return "", back, err
459	}
460	c, err := s.cfg.MergeRequests.DeleteComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")))
461	return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
462}

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 2

Tags 45

↑↓ moveenter openesc close