internal/control/diffcomment.go

v1.40.1
gitbay/internal/control/diffcomment.go history · blame · raw

348 lines · 12248 bytes

  1package control
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"io"
  7	"slices"
  8	"strconv"
  9	"strings"
 10
 11	"gitbay.org/gitbay/internal/gitutil"
 12	"gitbay.org/gitbay/internal/policy"
 13	"gitbay.org/gitbay/internal/protocol"
 14	"gitbay.org/gitbay/internal/store"
 15	"gitbay.org/gitbay/internal/suggest"
 16)
 17
 18func init() {
 19	register(Command{Path: []string{"mr", "diff-comment"},
 20		Summary: "comment on a diff line",
 21		Usage:   "mr diff-comment <owner/name> <n> --path <file> --line <l> [--start-line <s>] [--old] [--pending] [--reply <id>] [--message <m> | --file -]",
 22		Flags: []Flag{
 23			{"--path", "<file>", "the file the comment is on", ""},
 24			{"--line", "<l>", "the line the comment is on, or the last line of a range", ""},
 25			{"--start-line", "<s>", "the first line of a range ending at --line", ""},
 26			{"--old", "", "the line is on the old side of the diff", ""},
 27			{"--pending", "", "hold the comment for `mr review --comment`", ""},
 28			{"--reply", "<id>", "reply to this thread instead of opening one", ""},
 29			{"--message", "<m>", "the comment's text", ""},
 30			{"--file", "-", "read the comment from stdin", ""},
 31		},
 32		Examples: []string{
 33			`mr diff-comment krz/gitbay 431 --path internal/control/build.go --line 42 --message "why is this a switch"`,
 34			"mr diff-comment krz/gitbay 431 --reply 12 --file - < notes.md",
 35			"mr diff-comment krz/gitbay 431 --path go.mod --start-line 3 --line 4 --file - < suggestion.md",
 36		},
 37		ReadsStdin: true, Run: runDiffComment})
 38	register(Command{Path: []string{"mr", "threads"},
 39		Summary:  "review threads on an MR",
 40		Usage:    "mr threads <owner/name> <n>",
 41		Examples: []string{"mr threads krz/gitbay 431"},
 42		ReadOnly: true, Run: runMRThreads})
 43	register(Command{Path: []string{"mr", "resolve"},
 44		Summary:  "resolve a review thread",
 45		Usage:    "mr resolve <owner/name> <n> <thread-id>",
 46		Examples: []string{"mr resolve krz/gitbay 431 12"},
 47		Run:      runMRResolve})
 48	register(Command{Path: []string{"mr", "unresolve"},
 49		Summary:  "reopen a review thread",
 50		Usage:    "mr unresolve <owner/name> <n> <thread-id>",
 51		Examples: []string{"mr unresolve krz/gitbay 431 12"},
 52		Run:      runMRUnresolve})
 53}
 54
 55func runDiffComment(c *Ctx, args []string) int {
 56	f, err := c.parseArgs(args, flagSpec{Values: []string{"--path", "--line", "--start-line", "--reply", "--message", "--file"},
 57		Bools: []string{"--old", "--pending"}, MaxPos: -1,
 58		Usage: "mr diff-comment <owner/name> <n> --path <file> --line <l> [--start-line <s>] [--old] [--pending] [--reply <id>] [--message <m> | --file -]"})
 59	if err != nil {
 60		return c.fail(protocol.ExitUsage, "%v", err)
 61	}
 62	rest := f.Pos
 63	path, message, file, old := f.Value("--path"), f.Value("--message"), f.Value("--file"), f.Has("--old")
 64	var line, startLine, replyTo int64
 65	if f.Has("--line") {
 66		n, err := strconv.ParseInt(f.Value("--line"), 10, 64)
 67		if err != nil || n < 1 {
 68			return c.fail(protocol.ExitUsage, "--line must be a positive number")
 69		}
 70		line = n
 71	}
 72	if f.Has("--start-line") {
 73		n, err := strconv.ParseInt(f.Value("--start-line"), 10, 64)
 74		if err != nil || n < 1 || (line != 0 && n > line) {
 75			return c.fail(protocol.ExitUsage, "--start-line must be a positive number no greater than --line")
 76		}
 77		startLine = n
 78	}
 79	if f.Has("--reply") {
 80		n, err := strconv.ParseInt(f.Value("--reply"), 10, 64)
 81		if err != nil || n < 1 {
 82			return c.fail(protocol.ExitUsage, "--reply must be a thread id")
 83		}
 84		replyTo = n
 85	}
 86	repo, mr, code := mrRef(c, rest, policy.CanRead)
 87	if code >= 0 {
 88		return code
 89	}
 90	if code := refuseArchived(c, repo); code >= 0 {
 91		return code
 92	}
 93	if replyTo == 0 && (path == "" || line == 0) {
 94		return c.fail(protocol.ExitUsage, "a new thread needs --path and --line (or reply to one with --reply <id>)")
 95	}
 96	body, err := bodyFrom(c, message, file)
 97	if err != nil {
 98		return c.failInput(err)
 99	}
100	if strings.TrimSpace(body) == "" {
101		return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
102	}
103	if replyTo != 0 && startLine != 0 {
104		return c.fail(protocol.ExitUsage, "a reply takes its thread's lines; drop --start-line")
105	}
106	_, hasSuggestion, err := suggest.Parse(body)
107	if err != nil {
108		return c.fail(protocol.ExitUsage, "%v", err)
109	}
110	if hasSuggestion && replyTo != 0 {
111		return c.fail(protocol.ExitUsage, "a suggestion opens its own thread: post it with --path and --line, not --reply")
112	}
113	if hasSuggestion && old {
114		return c.fail(protocol.ExitUsage, "a suggestion replaces lines of the new file; drop --old")
115	}
116
117	side := "new"
118	if old {
119		side = "old"
120	}
121	if replyTo == 0 {
122		// The path must actually be part of the MR's diff.
123		dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
124		base := mr.MergedBase
125		if base == "" {
126			b, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, mrHeadRef(mr.Number))
127			if err != nil {
128				return c.fail(protocol.ExitFailure, "%v", err)
129			}
130			base = b
131		}
132		files, err := gitutil.DiffFiles(dir, base, mrHeadRef(mr.Number))
133		if err != nil {
134			return c.fail(protocol.ExitFailure, "%v", err)
135		}
136		if !slices.Contains(files, path) {
137			return c.fail(protocol.ExitUsage, "%s is not part of this merge request's diff", path)
138		}
139		if hasSuggestion {
140			first := firstNonZero(startLine, line)
141			content, _, err := readAnchored(dir, mr.HeadSHA, path)
142			if err != nil {
143				return c.fail(protocol.ExitUsage, "%v", err)
144			}
145			if _, ok := suggest.Range(content, int(first), int(line)); !ok {
146				return c.fail(protocol.ExitUsage, "%s has no lines %d-%d at the head", path, first, line)
147			}
148		}
149	}
150
151	pending := f.Has("--pending")
152	id, err := c.Store.AddDiffComment(mr.ID, c.User.ID, mr.HeadSHA, path, side, line, startLine, body, replyTo, pending)
153	if err != nil {
154		if errors.Is(err, store.ErrNotFound) {
155			return c.fail(protocol.ExitNotFound, "%v", err)
156		}
157		return c.failErr(err)
158	}
159	// A pending comment is not part of the conversation yet, so it does
160	// not reach anyone's inbox. `mr review` is what says it out loud.
161	if !pending {
162		if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
163			notify(c, parts, notice{repo: repo, kind: "mr", number: mr.Number,
164				subject: mrSubject(repo, mr.Number, mr.Title),
165				action:  fmt.Sprintf("commented on %s:%d in !%d", path, line, mr.Number),
166				excerpt: body, path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
167		}
168		notifyMentions(c, repo, mrThread, mr.ID, mr.Number, mr.Title, body)
169	}
170	return c.emit(map[string]any{"id": id, "thread": firstNonZero(replyTo, id), "pending": pending}, func(w io.Writer) {
171		what := "thread %d opened on %s:%d in %s!%d\n"
172		if replyTo != 0 {
173			fmt.Fprintf(w, "replied to thread %d on %s!%d\n", replyTo, repo.Path(), mr.Number)
174		} else {
175			fmt.Fprintf(w, what, id, path, line, repo.Path(), mr.Number)
176		}
177		if pending {
178			n := c.Store.CountPendingComments(mr.ID, c.User.ID)
179			fmt.Fprintf(w, "pending: %d comment(s) in this review, submit with `gitbay mr review %s %d --comment`\n",
180				n, repo.Path(), mr.Number)
181		}
182	})
183}
184
185func firstNonZero(a, b int64) int64 {
186	if a != 0 {
187		return a
188	}
189	return b
190}
191
192func runMRThreads(c *Ctx, args []string) int {
193	repo, mr, code := mrRef(c, args, policy.CanRead)
194	if code >= 0 {
195		return code
196	}
197	if len(args) != 2 {
198		return c.usage()
199	}
200	comments, err := c.Store.ListDiffComments(mr.ID, c.User.ID)
201	if err != nil {
202		return c.fail(protocol.ExitFailure, "%v", err)
203	}
204	type commentOut struct {
205		ID        int64  `json:"id"`
206		Author    string `json:"author"`
207		Body      string `json:"body"`
208		CreatedAt string `json:"created_at"`
209	}
210	type threadOut struct {
211		ID         int64          `json:"id"`
212		Path       string         `json:"path"`
213		Side       string         `json:"side"`
214		StartLine  int64          `json:"start_line,omitempty"`
215		Line       int64          `json:"line"`
216		Stale      bool           `json:"stale"`
217		Resolved   string         `json:"resolved_by,omitempty"`
218		Suggestion *SuggestionOut `json:"suggestion,omitempty"`
219		Comments   []commentOut   `json:"comments"`
220	}
221	suggestions := Suggestions(c.Store, c.Cfg.Server.Root, repo, mr, comments)
222	byRoot := map[int64]*threadOut{}
223	var order []int64
224	for _, cm := range comments {
225		if cm.ReplyTo == 0 {
226			byRoot[cm.ID] = &threadOut{
227				ID: cm.ID, Path: cm.Path, Side: cm.Side, StartLine: cm.StartLine, Line: cm.Line,
228				Stale: cm.HeadSHA != mr.HeadSHA, Resolved: cm.ResolvedBy,
229				Suggestion: suggestions[cm.ID],
230				Comments:   []commentOut{{cm.ID, cm.Author, cm.Body, cm.CreatedAt}},
231			}
232			order = append(order, cm.ID)
233		} else if th, ok := byRoot[cm.ReplyTo]; ok {
234			th.Comments = append(th.Comments, commentOut{cm.ID, cm.Author, cm.Body, cm.CreatedAt})
235		}
236	}
237	var ds []threadOut
238	for _, id := range order {
239		ds = append(ds, *byRoot[id])
240	}
241	return c.emit(ds, func(w io.Writer) {
242		for _, th := range ds {
243			marks := ""
244			if th.Resolved != "" {
245				marks += " [resolved by " + th.Resolved + "]"
246			}
247			if th.Stale {
248				marks += " [stale]"
249			}
250			lines := fmt.Sprint(th.Line)
251			if th.StartLine != 0 && th.StartLine != th.Line {
252				lines = fmt.Sprintf("%d-%d", th.StartLine, th.Line)
253			}
254			fmt.Fprintf(w, "thread %d  %s:%s (%s)%s\n", th.ID, th.Path, lines, th.Side, marks)
255			for i, cm := range th.Comments {
256				body := cm.Body
257				if i == 0 && th.Suggestion != nil {
258					body = suggest.Strip(body)
259				}
260				if body != "" {
261					fmt.Fprintf(w, "  %s: %s\n", cm.Author, body)
262				}
263				if i == 0 && th.Suggestion != nil {
264					writeSuggestion(w, repo, mr, th.ID, cm.Author, th.Suggestion)
265				}
266			}
267		}
268	})
269}
270
271// writeSuggestion prints a suggestion as the diff it proposes and how to
272// apply it, or why it can no longer be applied.
273func writeSuggestion(w io.Writer, repo store.Repo, mr store.MR, thread int64, author string, s *SuggestionOut) {
274	switch {
275	case s.Outdated:
276		fmt.Fprintf(w, "  %s suggests (outdated: %s):\n", author, s.Reason)
277	default:
278		fmt.Fprintf(w, "  %s suggests (gitbay mr apply-suggestion %s %d %d):\n", author, repo.Path(), mr.Number, thread)
279	}
280	for _, l := range suggest.FromText(strings.ReplaceAll(s.Original, "\r\n", "\n")) {
281		fmt.Fprintf(w, "  - %s\n", l)
282	}
283	for _, l := range suggest.FromText(s.Replacement) {
284		fmt.Fprintf(w, "  + %s\n", l)
285	}
286}
287
288func setThreadResolved(c *Ctx, args []string, resolved bool) int {
289	if len(args) != 3 {
290		return c.usage()
291	}
292	repo, mr, code := mrRef(c, args[:2], policy.CanRead)
293	if code >= 0 {
294		return code
295	}
296	if code := refuseArchived(c, repo); code >= 0 {
297		return code
298	}
299	threadID, err := strconv.ParseInt(args[2], 10, 64)
300	if err != nil {
301		return c.fail(protocol.ExitUsage, "bad thread id %q", args[2])
302	}
303	ok, err := canResolveThread(c, repo, mr, threadID)
304	if errors.Is(err, store.ErrNotFound) {
305		return c.fail(protocol.ExitNotFound, "no thread %d on %s!%d", threadID, repo.Path(), mr.Number)
306	}
307	if err != nil {
308		return c.fail(protocol.ExitFailure, "%v", err)
309	}
310	if !ok {
311		return c.fail(protocol.ExitDenied, "%s", cannotResolve)
312	}
313	if err := c.Store.SetThreadResolved(mr.ID, threadID, c.User.ID, resolved); err != nil {
314		if errors.Is(err, store.ErrNotFound) {
315			return c.fail(protocol.ExitNotFound, "no thread %d (replies cannot be resolved; use the root id)", threadID)
316		}
317		return c.fail(protocol.ExitFailure, "%v", err)
318	}
319	if resolved {
320		TryQueuedMerge(c.Store, c.Cfg, mr.ID)
321	}
322	verb := "resolved"
323	if !resolved {
324		verb = "reopened"
325	}
326	return c.emit(map[string]any{"thread": threadID, "resolved": resolved}, func(w io.Writer) {
327		fmt.Fprintf(w, "%s thread %d on %s!%d\n", verb, threadID, repo.Path(), mr.Number)
328	})
329}
330
331const cannotResolve = "only the thread author, the MR author, or users with write access can resolve threads"
332
333// canResolveThread reports whether the caller may resolve a thread: its
334// author, the MR author, or anyone with write on the target.
335func canResolveThread(c *Ctx, repo store.Repo, mr store.MR, threadID int64) (bool, error) {
336	author, err := c.Store.DiffCommentAuthor(mr.ID, threadID)
337	if err != nil {
338		return false, err
339	}
340	grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
341	if err != nil {
342		return false, err
343	}
344	return author == c.User.ID || mr.Author == c.User.Username || policy.CanWrite(c.User, repo, grant), nil
345}
346
347func runMRResolve(c *Ctx, args []string) int   { return setThreadResolved(c, args, true) }
348func runMRUnresolve(c *Ctx, args []string) int { return setThreadResolved(c, args, false) }