v0.3.0 9fdf386

mr_post.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"net/http"
10	"strconv"
11	"strings"
12
13	"connectrpc.com/connect"
14	"github.com/bnema/zerowrap"
15
16	pb "bnema.dev/ermite-api/gen/ermite"
17)
18
19// The writes of the merge request pages. Each is a POST that passed the gate
20// (an Origin of this site and the owner's session), calls the API as the
21// owner, and answers 303 to the page that shows the result, with
22// "Cache-Control: no-store". A request the API refuses is answered with a
23// state page that says why, in the words of the API for the rules the owner
24// can fix (a text rule, a moved branch, a full merge request), and without
25// detail for anything else.
26
27// maxFormBytes bounds the body of a form: a description of 8,000 characters is
28// at most 32,000 bytes.
29const maxFormBytes = 128 << 10
30
31// form reads the form of a POST, bounded.
32func (s *Server) form(w http.ResponseWriter, r *http.Request) bool {
33	r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
34	if err := r.ParseForm(); err != nil {
35		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")))
36		return false
37	}
38	return true
39}
40
41// seeOther redirects to a page, never cached.
42func seeOther(w http.ResponseWriter, r *http.Request, to string) {
43	w.Header().Set("Cache-Control", "no-store")
44	http.Redirect(w, r, to, http.StatusSeeOther)
45}
46
47// mrAction wraps a POST handler: it bounds the API calls, reads the form and
48// maps the error of the API to a page.
49func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to string, back string, err error)) http.HandlerFunc {
50	return func(w http.ResponseWriter, r *http.Request) {
51		ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
52		defer cancel()
53		if !s.form(w, r) {
54			return
55		}
56		to, back, err := h(ctx, r)
57		if err != nil {
58			s.actionFailed(ctx, w, r, err, back)
59			return
60		}
61		seeOther(w, r, to)
62	}
63}
64
65// actionFailed answers a refused action.
66func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) {
67	msg := ""
68	var ce *connect.Error
69	if errors.As(err, &ce) {
70		msg = ce.Message()
71	}
72	switch code := connect.CodeOf(err); {
73	case code == connect.CodeNotFound:
74		w.Header().Set("Cache-Control", "no-store")
75		s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage())
76	case code == connect.CodeInvalidArgument:
77		s.reject(ctx, w, r, http.StatusBadRequest, "not accepted", rule(msg, "the server refused what was sent."), back)
78	case code == connect.CodeFailedPrecondition, code == connect.CodeAborted:
79		s.reject(ctx, w, r, http.StatusConflict, "not possible now", rule(msg, "the merge request is not in a state that allows this."), back)
80	case code == connect.CodeAlreadyExists:
81		s.reject(ctx, w, r, http.StatusConflict, "already there", rule(msg, "this already exists."), back)
82	case code == connect.CodeResourceExhausted:
83		s.reject(ctx, w, r, http.StatusConflict, "limit reached", rule(msg, "a limit of the merge request is reached."), back)
84	case code == connect.CodePermissionDenied, code == connect.CodeUnauthenticated:
85		s.reject(ctx, w, r, http.StatusForbidden, "forbidden", "the session is not allowed to do this.", back)
86	case errors.Is(err, context.Canceled), code == connect.CodeCanceled:
87		// The browser is gone.
88	default:
89		id := requestID()
90		log := zerowrap.FromCtx(ctx)
91		log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web action failed")
92		w.Header().Set("Cache-Control", "no-store")
93		s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id))
94	}
95}
96
97// rule is a message of the API fit to show: the invisible characters marked,
98// bounded, and a fallback for an empty one.
99func rule(msg, fallback string) string {
100	msg = strings.TrimSpace(visibleText(msg))
101	if msg == "" {
102		return fallback
103	}
104	return cutRunes(msg, 400)
105}
106
107// reject writes the page of a refused action, with the way back.
108func (s *Server) reject(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, title, line, back string) {
109	if back == "" {
110		back = "/"
111	}
112	l := s.stateLayout(title, itoa(status)+" · "+title, []Crumb{{Label: "-"}, {Label: title}})
113	w.Header().Set("Cache-Control", "no-store")
114	s.write(ctx, w, r, status, State(l, StatePage{
115		Code: itoa(status), Title: title,
116		Lines: []string{line, "nothing was changed by this request."},
117		Back:  []Link{{Label: "back", Href: back}, {Label: "home", Href: "/"}},
118	}))
119}
120
121// number reads the merge request number of the path.
122func number(r *http.Request) (int64, error) { return mrNumber(r) }
123
124// mrCreate is POST /<repo>/merge-requests: it opens a merge request.
125func (s *Server) mrCreate(ctx context.Context, r *http.Request) (string, string, error) {
126	repo := r.PathValue("repo")
127	source := r.PostFormValue("source")
128	back := repoHref(repo, "compare", source)
129	res, err := s.cfg.MergeRequests.CreateMergeRequest(ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{
130		Repo: repo, Title: r.PostFormValue("title"), Description: normalizeNewlines(r.PostFormValue("description")),
131		SourceBranch: source, TargetBranch: r.PostFormValue("target"),
132	}))
133	if err != nil {
134		return "", back, err
135	}
136	return mrHref(repo, res.Msg.GetMergeRequest().GetNumber()), back, nil
137}
138
139// mrComment is POST /<repo>/merge-requests/<n>/comments: a general comment, or
140// one on a line (path, line and side).
141func (s *Server) mrComment(ctx context.Context, r *http.Request) (string, string, error) {
142	repo := r.PathValue("repo")
143	n, err := number(r)
144	if err != nil {
145		return "", "", connect.NewError(connect.CodeNotFound, err)
146	}
147	back := mrHref(repo, n)
148	req := &pb.AddCommentRequest{Repo: repo, Number: n, Text: normalizeNewlines(r.PostFormValue("text")), Path: r.PostFormValue("path")}
149	if req.Path != "" || r.PostFormValue("line") != "" || r.PostFormValue("side") != "" {
150		line, err := strconv.ParseInt(r.PostFormValue("line"), 10, 32)
151		if err != nil {
152			return "", back, connect.NewError(connect.CodeInvalidArgument, errors.New("line is not a number"))
153		}
154		req.Line = int32(line)
155		switch r.PostFormValue("side") {
156		case "new":
157			req.Side = pb.DiffSide_DIFF_SIDE_NEW
158		case "old":
159			req.Side = pb.DiffSide_DIFF_SIDE_OLD
160		default:
161			return "", back, connect.NewError(connect.CodeInvalidArgument, errors.New("side must be new or old"))
162		}
163	}
164	res, err := s.cfg.MergeRequests.AddComment(ctx, connect.NewRequest(req))
165	if err != nil {
166		return "", back, err
167	}
168	return back + "#c" + res.Msg.GetComment().GetId(), back, nil
169}
170
171// mrResolve is POST /<repo>/merge-requests/<n>/comments/<id>/resolve.
172//
173//hexcheck:ignore suspicious-business-logic-in-adapter reads two form fields into an API call; the state of the comment is decided by the API
174func (s *Server) mrResolve(ctx context.Context, r *http.Request) (string, string, error) {
175	repo := r.PathValue("repo")
176	n, err := number(r)
177	if err != nil {
178		return "", "", connect.NewError(connect.CodeNotFound, err)
179	}
180	back := mrHref(repo, n)
181	resolved, err := formBool(r.PostFormValue("resolved"))
182	if err != nil {
183		return "", back, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("resolved: %w", err))
184	}
185	res, err := s.cfg.MergeRequests.ResolveComment(ctx, connect.NewRequest(&pb.ResolveCommentRequest{
186		Repo: repo, Number: n, CommentId: r.PathValue("id"), Resolved: resolved,
187	}))
188	if err != nil {
189		return "", back, err
190	}
191	return back + "#c" + res.Msg.GetComment().GetId(), back, nil
192}
193
194// mrClose is POST /<repo>/merge-requests/<n>/close.
195func (s *Server) mrClose(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 "", "", connect.NewError(connect.CodeNotFound, err)
200	}
201	back := mrHref(repo, n)
202	if _, err := s.cfg.MergeRequests.CloseMergeRequest(ctx, connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: repo, Number: n})); err != nil {
203		return "", back, err
204	}
205	return back, back, nil
206}
207
208// mrMerge is POST /<repo>/merge-requests/<n>/merge. The form carries the mode
209// and the head the owner was shown (head_id): the server merges only if the
210// branch still points to it.
211func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string, error) {
212	repo := r.PathValue("repo")
213	n, err := number(r)
214	if err != nil {
215		return "", "", connect.NewError(connect.CodeNotFound, err)
216	}
217	back := mrHref(repo, n)
218	var mode pb.MergeMode
219	switch r.PostFormValue("mode") {
220	case "fast-forward":
221		mode = pb.MergeMode_MERGE_MODE_FAST_FORWARD
222	case "squash":
223		mode = pb.MergeMode_MERGE_MODE_SQUASH
224	default:
225		return "", back, connect.NewError(connect.CodeInvalidArgument, errors.New("mode must be fast-forward or squash"))
226	}
227	if _, err := s.cfg.MergeRequests.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{
228		Repo: repo, Number: n, Mode: mode, HeadId: r.PostFormValue("head_id"),
229	})); err != nil {
230		return "", back, err
231	}
232	return back, back, nil
233}
234
235// mrRead is POST /<repo>/merge-requests/<n>/read: the owner marks the head
236// they were shown as read. Reading a page never does it (see the top of mr.go).
237func (s *Server) mrRead(ctx context.Context, r *http.Request) (string, string, error) {
238	repo := r.PathValue("repo")
239	n, err := number(r)
240	if err != nil {
241		return "", "", connect.NewError(connect.CodeNotFound, err)
242	}
243	back := mrHref(repo, n)
244	if _, err := s.cfg.MergeRequests.MarkRead(ctx, connect.NewRequest(&pb.MarkReadRequest{
245		Repo: repo, Number: n, HeadId: r.PostFormValue("head_id"),
246	})); err != nil {
247		return "", back, err
248	}
249	return back, back, nil
250}
251
252// formBool reads the "true" or "false" a form field carries.
253func formBool(s string) (bool, error) {
254	switch s {
255	case "true":
256		return true, nil
257	case "false":
258		return false, nil
259	}
260	return false, errors.New("must be true or false")
261}
262
263// normalizeNewlines turns the CRLF of a browser textarea into LF, the line
264// break the API stores.
265func normalizeNewlines(s string) string {
266	return strings.ReplaceAll(s, "\r\n", "\n")
267}

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