v0.23.0 333c710

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		s.reject(r.Context(), w, r, http.StatusRequestEntityTooLarge, "form too large", "the form is larger than a merge request can be.", mrBase(r.PathValue("repo")))
35		return false
36	}
37	return true
38}
39
40// seeOther redirects to a page, never cached.
41func seeOther(w http.ResponseWriter, r *http.Request, to string) {
42	w.Header().Set("Cache-Control", "no-store")
43	http.Redirect(w, r, to, http.StatusSeeOther)
44}
45
46// mrAction wraps a POST handler: it bounds the use case calls, reads the form
47// and maps the error of the use case to a page.
48func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to string, back string, err error)) http.HandlerFunc {
49	return func(w http.ResponseWriter, r *http.Request) {
50		ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
51		defer cancel()
52		if !s.form(w, r) {
53			return
54		}
55		to, back, err := h(ctx, r)
56		if err != nil {
57			s.actionFailed(ctx, w, r, err, back)
58			return
59		}
60		seeOther(w, r, to)
61	}
62}
63
64// actionFailed answers a refused action.
65func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) {
66	// The words of the use case, for the rules the owner can fix.
67	msg := err.Error()
68	// The order is that of the API's mapping of the domain errors to codes
69	// (rpc.codeOf), so that a web action answers what the API would.
70	switch {
71	case errors.Is(err, context.Canceled):
72		// The browser is gone.
73	case errors.Is(err, domain.ErrNotFound):
74		w.Header().Set("Cache-Control", "no-store")
75		s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage())
76	case errors.Is(err, domain.ErrAlreadyExists):
77		s.reject(ctx, w, r, http.StatusConflict, "already there", rule(msg, "this already exists."), back)
78	case errors.Is(err, domain.ErrUnauthenticated), errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrRejected):
79		s.reject(ctx, w, r, http.StatusForbidden, "forbidden", "the session is not allowed to do this.", back)
80	case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
81		s.reject(ctx, w, r, http.StatusConflict, "not possible now", rule(msg, "the merge request is not in a state that allows this."), back)
82	case errors.Is(err, domain.ErrResourceExhausted):
83		s.reject(ctx, w, r, http.StatusConflict, "limit reached", rule(msg, "a limit of the merge request is reached."), back)
84	case domain.IsInvalid(err):
85		s.reject(ctx, w, r, http.StatusBadRequest, "not accepted", rule(msg, "the server refused what was sent."), back)
86	default:
87		s.internalFailure(ctx, w, r, err)
88	}
89}
90
91// internalFailure answers a failure of an action that is not the owner's to
92// fix: the 500 page with an id, and the cause in the log.
93func (s *Server) internalFailure(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
94	id := requestID()
95	log := zerowrap.FromCtx(ctx)
96	log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web action failed")
97	w.Header().Set("Cache-Control", "no-store")
98	s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id))
99}
100
101// rule is a message of the use case fit to show: the invisible characters marked,
102// bounded, and a fallback for an empty one.
103func rule(msg, fallback string) string {
104	msg = strings.TrimSpace(visibleText(msg))
105	if msg == "" {
106		return fallback
107	}
108	return cutRunes(msg, 400)
109}
110
111// reject writes the page of a refused action, with the way back.
112func (s *Server) reject(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, title, line, back string) {
113	if back == "" {
114		back = "/"
115	}
116	l := s.stateLayout(title, []Crumb{{Label: title, Current: true}})
117	w.Header().Set("Cache-Control", "no-store")
118	s.write(ctx, w, r, status, State(l, StatePage{
119		Code: itoa(status), Title: title,
120		Lines: []string{line, "nothing was changed by this request."},
121		Back:  []Link{{Label: "back", Href: back}, {Label: "home", Href: "/"}},
122	}))
123}
124
125// number reads the merge request number of the path.
126func number(r *http.Request) (int64, error) { return mrNumber(r) }
127
128// mrCreate is POST /<repo>/merge-requests: it opens a merge request.
129func (s *Server) mrCreate(ctx context.Context, r *http.Request) (string, string, error) {
130	repo := r.PathValue("repo")
131	source := r.PostFormValue("source")
132	back := repoHref(repo, "compare", source)
133	name, err := domain.ParseRepoName(repo)
134	if err != nil {
135		return "", back, err
136	}
137	mr, err := s.cfg.MergeRequests.Create(ctx, principal(ctx), name, ports.CreateMergeRequestParams{
138		Title: r.PostFormValue("title"), Description: normalizeNewlines(r.PostFormValue("description")),
139		Source: source, Target: r.PostFormValue("target"),
140	})
141	if err != nil {
142		return "", back, err
143	}
144	return mrHref(repo, mr.Number), back, nil
145}
146
147// formError is a form the server refuses before asking a use case: it matches
148// domain.ErrInvalid and says just what is wrong.
149type formError string
150
151func (e formError) Error() string { return string(e) }
152
153func (formError) Unwrap() error { return domain.ErrInvalid }
154
155// mrComment is POST /<repo>/merge-requests/<n>/comments: a general comment, or
156// one on a line (path, line and side).
157func (s *Server) mrComment(ctx context.Context, r *http.Request) (string, string, error) {
158	repo := r.PathValue("repo")
159	n, err := number(r)
160	if err != nil {
161		return "", "", err
162	}
163	back := mrHref(repo, n)
164	params := ports.AddCommentParams{Text: normalizeNewlines(r.PostFormValue("text")), Path: r.PostFormValue("path")}
165	if params.Path != "" || r.PostFormValue("line") != "" || r.PostFormValue("side") != "" {
166		line, err := strconv.ParseInt(r.PostFormValue("line"), 10, 32)
167		if err != nil {
168			return "", back, formError("line is not a number")
169		}
170		params.Line = int(line)
171		switch r.PostFormValue("side") {
172		case "new":
173			params.Side = domain.SideNew
174		case "old":
175			params.Side = domain.SideOld
176		default:
177			return "", back, formError("side must be new or old")
178		}
179	}
180	name, err := domain.ParseRepoName(repo)
181	if err != nil {
182		return "", back, err
183	}
184	back = withFrom(back, formFrom(r))
185	c, err := s.cfg.MergeRequests.AddComment(ctx, principal(ctx), name, n, params)
186	if err != nil {
187		return "", back, err
188	}
189	return back + "#c" + strconv.FormatInt(c.ID, 10), back, nil
190}
191
192// mrResolve is POST /<repo>/merge-requests/<n>/comments/<id>/resolve.
193//
194//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
195func (s *Server) mrResolve(ctx context.Context, r *http.Request) (string, string, error) {
196	repo := r.PathValue("repo")
197	n, err := number(r)
198	if err != nil {
199		return "", "", err
200	}
201	back := mrHref(repo, n)
202	resolved, err := formBool(r.PostFormValue("resolved"))
203	if err != nil {
204		return "", back, formError("resolved: " + err.Error())
205	}
206	name, err := domain.ParseRepoName(repo)
207	if err != nil {
208		return "", back, err
209	}
210	// An id this server never issued is 0: the use case answers not found,
211	// after the repository and the scope were checked, as for any unknown
212	// comment.
213	back = withFrom(back, formFrom(r))
214	c, err := s.cfg.MergeRequests.ResolveComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), resolved)
215	if err != nil {
216		return "", back, err
217	}
218	return back + "#c" + strconv.FormatInt(c.ID, 10), back, nil
219}
220
221// commentID parses the id of a comment in a path: a positive decimal number,
222// written without sign or leading zeros. Anything else is 0, which names no
223// comment.
224func commentID(s string) int64 {
225	n, err := strconv.ParseInt(s, 10, 64)
226	if err != nil || n < 1 || strconv.FormatInt(n, 10) != s {
227		return 0
228	}
229	return n
230}
231
232// headID parses a commit id of a form; the empty string is the zero id.
233//
234//hexcheck:ignore suspicious-business-logic-in-adapter parses a form field into the domain id and names the field in the error; no rule is decided here
235func headID(s string) (domain.ObjectID, error) {
236	if s == "" {
237		return domain.ObjectID{}, nil
238	}
239	var v domain.Violations
240	id, err := domain.ParseObjectID(s)
241	if err != nil {
242		v.Add("head_id", "is not a full commit id")
243	}
244	return id, v.Err()
245}
246
247// mrClose is POST /<repo>/merge-requests/<n>/close.
248func (s *Server) mrClose(ctx context.Context, r *http.Request) (string, string, error) {
249	repo := r.PathValue("repo")
250	n, err := number(r)
251	if err != nil {
252		return "", "", err
253	}
254	back := mrHref(repo, n)
255	name, err := domain.ParseRepoName(repo)
256	if err != nil {
257		return "", back, err
258	}
259	if _, err := s.cfg.MergeRequests.Close(ctx, principal(ctx), name, n); err != nil {
260		return "", back, err
261	}
262	return back, back, nil
263}
264
265// mrReady is POST /<repo>/merge-requests/<n>/ready: the draft prefix leaves
266// the title.
267func (s *Server) mrReady(ctx context.Context, r *http.Request) (string, string, error) {
268	repo := r.PathValue("repo")
269	n, err := number(r)
270	if err != nil {
271		return "", "", err
272	}
273	back := mrHref(repo, n)
274	name, err := domain.ParseRepoName(repo)
275	if err != nil {
276		return "", back, err
277	}
278	if _, err := s.cfg.MergeRequests.MarkReady(ctx, principal(ctx), name, n); err != nil {
279		return "", back, err
280	}
281	return back, back, nil
282}
283
284// mrMerge is POST /<repo>/merge-requests/<n>/merge. The form carries the mode,
285// the head the owner was shown (head_id): the server merges only if the
286// branch still points to it, and delete_branch when the owner asked to delete
287// the source branch after the merge.
288func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string, error) {
289	repo := r.PathValue("repo")
290	n, err := number(r)
291	if err != nil {
292		return "", "", err
293	}
294	back := mrHref(repo, n)
295	var mode domain.MergeMode
296	switch r.PostFormValue("mode") {
297	case "fast-forward":
298		mode = domain.MergeModeFastForward
299	case "squash":
300		mode = domain.MergeModeSquash
301	default:
302		return "", back, formError("mode must be fast-forward or squash")
303	}
304	name, err := domain.ParseRepoName(repo)
305	if err != nil {
306		return "", back, err
307	}
308	head, err := headID(r.PostFormValue("head_id"))
309	if err != nil {
310		return "", back, err
311	}
312	if _, err := s.cfg.MergeRequests.Merge(ctx, principal(ctx), name, n, ports.MergeParams{
313		Mode: mode, HeadID: head, DeleteSource: r.PostFormValue("delete_branch") == "1",
314	}); err != nil {
315		return "", back, err
316	}
317	return back, back, nil
318}
319
320// mrRead is POST /<repo>/merge-requests/<n>/read: the owner marks the head
321// they were shown as read. Reading a page never does it (see the top of mr.go).
322func (s *Server) mrRead(ctx context.Context, r *http.Request) (string, string, error) {
323	repo := r.PathValue("repo")
324	n, err := number(r)
325	if err != nil {
326		return "", "", err
327	}
328	back := mrHref(repo, n)
329	name, err := domain.ParseRepoName(repo)
330	if err != nil {
331		return "", back, err
332	}
333	head, err := headID(r.PostFormValue("head_id"))
334	if err != nil {
335		return "", back, err
336	}
337	if _, err := s.cfg.MergeRequests.MarkRead(ctx, principal(ctx), name, n, head); err != nil {
338		return "", back, err
339	}
340	return back, back, nil
341}
342
343// formBool reads the "true" or "false" a form field carries.
344func formBool(s string) (bool, error) {
345	switch s {
346	case "true":
347		return true, nil
348	case "false":
349		return false, nil
350	}
351	return false, errors.New("must be true or false")
352}
353
354// normalizeNewlines turns the CRLF of a browser textarea into LF, the line
355// break the use case stores.
356func normalizeNewlines(s string) string {
357	return strings.ReplaceAll(s, "\r\n", "\n")
358}

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