detached 388d3be

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

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