internal/control/diffcomment.go

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

268 lines · 9078 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)
 16
 17func init() {
 18	register(Command{Path: []string{"mr", "diff-comment"},
 19		Summary: "comment on a diff line",
 20		Usage:   "mr diff-comment <owner/name> <n> --path <file> --line <l> [--old] [--pending] [--reply <id>] [--message <m> | --file -]",
 21		Flags: []Flag{
 22			{"--path", "<file>", "the file the comment is on", ""},
 23			{"--line", "<l>", "the line the comment is on", ""},
 24			{"--old", "", "the line is on the old side of the diff", ""},
 25			{"--pending", "", "hold the comment for `mr review --comment`", ""},
 26			{"--reply", "<id>", "reply to this thread instead of opening one", ""},
 27			{"--message", "<m>", "the comment's text", ""},
 28			{"--file", "-", "read the comment from stdin", ""},
 29		},
 30		Examples: []string{
 31			`mr diff-comment krz/gitbay 431 --path internal/control/build.go --line 42 --message "why is this a switch"`,
 32			"mr diff-comment krz/gitbay 431 --reply 12 --file - < notes.md",
 33		},
 34		ReadsStdin: true, Run: runDiffComment})
 35	register(Command{Path: []string{"mr", "threads"},
 36		Summary:  "review threads on an MR",
 37		Usage:    "mr threads <owner/name> <n>",
 38		Examples: []string{"mr threads krz/gitbay 431"},
 39		ReadOnly: true, Run: runMRThreads})
 40	register(Command{Path: []string{"mr", "resolve"},
 41		Summary:  "resolve a review thread",
 42		Usage:    "mr resolve <owner/name> <n> <thread-id>",
 43		Examples: []string{"mr resolve krz/gitbay 431 12"},
 44		Run:      runMRResolve})
 45	register(Command{Path: []string{"mr", "unresolve"},
 46		Summary:  "reopen a review thread",
 47		Usage:    "mr unresolve <owner/name> <n> <thread-id>",
 48		Examples: []string{"mr unresolve krz/gitbay 431 12"},
 49		Run:      runMRUnresolve})
 50}
 51
 52func runDiffComment(c *Ctx, args []string) int {
 53	f, err := c.parseArgs(args, flagSpec{Values: []string{"--path", "--line", "--reply", "--message", "--file"},
 54		Bools: []string{"--old", "--pending"}, MaxPos: -1,
 55		Usage: "mr diff-comment <owner/name> <n> --path <file> --line <l> [--old] [--pending] [--reply <id>] [--message <m> | --file -]"})
 56	if err != nil {
 57		return c.fail(protocol.ExitUsage, "%v", err)
 58	}
 59	rest := f.Pos
 60	path, message, file, old := f.Value("--path"), f.Value("--message"), f.Value("--file"), f.Has("--old")
 61	var line, replyTo int64
 62	if f.Has("--line") {
 63		n, err := strconv.ParseInt(f.Value("--line"), 10, 64)
 64		if err != nil || n < 1 {
 65			return c.fail(protocol.ExitUsage, "--line must be a positive number")
 66		}
 67		line = n
 68	}
 69	if f.Has("--reply") {
 70		n, err := strconv.ParseInt(f.Value("--reply"), 10, 64)
 71		if err != nil || n < 1 {
 72			return c.fail(protocol.ExitUsage, "--reply must be a thread id")
 73		}
 74		replyTo = n
 75	}
 76	repo, mr, code := mrRef(c, rest, policy.CanRead)
 77	if code >= 0 {
 78		return code
 79	}
 80	if code := refuseArchived(c, repo); code >= 0 {
 81		return code
 82	}
 83	if replyTo == 0 && (path == "" || line == 0) {
 84		return c.fail(protocol.ExitUsage, "a new thread needs --path and --line (or reply to one with --reply <id>)")
 85	}
 86	body, err := bodyFrom(c, message, file)
 87	if err != nil {
 88		return c.failInput(err)
 89	}
 90	if strings.TrimSpace(body) == "" {
 91		return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
 92	}
 93
 94	side := "new"
 95	if old {
 96		side = "old"
 97	}
 98	if replyTo == 0 {
 99		// The path must actually be part of the MR's diff.
100		dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
101		base := mr.MergedBase
102		if base == "" {
103			b, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, mrHeadRef(mr.Number))
104			if err != nil {
105				return c.fail(protocol.ExitFailure, "%v", err)
106			}
107			base = b
108		}
109		files, err := gitutil.DiffFiles(dir, base, mrHeadRef(mr.Number))
110		if err != nil {
111			return c.fail(protocol.ExitFailure, "%v", err)
112		}
113		if !slices.Contains(files, path) {
114			return c.fail(protocol.ExitUsage, "%s is not part of this merge request's diff", path)
115		}
116	}
117
118	pending := f.Has("--pending")
119	id, err := c.Store.AddDiffComment(mr.ID, c.User.ID, mr.HeadSHA, path, side, line, body, replyTo, pending)
120	if err != nil {
121		if errors.Is(err, store.ErrNotFound) {
122			return c.fail(protocol.ExitNotFound, "%v", err)
123		}
124		return c.failErr(err)
125	}
126	// A pending comment is not part of the conversation yet, so it does
127	// not reach anyone's inbox. `mr review` is what says it out loud.
128	if !pending {
129		if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
130			notify(c, parts, notice{repo: repo, kind: "mr",
131				subject: mrSubject(repo, mr.Number, mr.Title),
132				action:  fmt.Sprintf("commented on %s:%d in !%d", path, line, mr.Number),
133				excerpt: body, path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
134		}
135		notifyMentions(c, repo, mrThread, mr.ID, mr.Number, mr.Title, body)
136	}
137	return c.emit(map[string]any{"id": id, "thread": firstNonZero(replyTo, id), "pending": pending}, func(w io.Writer) {
138		what := "thread %d opened on %s:%d in %s!%d\n"
139		if replyTo != 0 {
140			fmt.Fprintf(w, "replied to thread %d on %s!%d\n", replyTo, repo.Path(), mr.Number)
141		} else {
142			fmt.Fprintf(w, what, id, path, line, repo.Path(), mr.Number)
143		}
144		if pending {
145			n := c.Store.CountPendingComments(mr.ID, c.User.ID)
146			fmt.Fprintf(w, "pending: %d comment(s) in this review, submit with `gitbay mr review %s %d --comment`\n",
147				n, repo.Path(), mr.Number)
148		}
149	})
150}
151
152func firstNonZero(a, b int64) int64 {
153	if a != 0 {
154		return a
155	}
156	return b
157}
158
159func runMRThreads(c *Ctx, args []string) int {
160	repo, mr, code := mrRef(c, args, policy.CanRead)
161	if code >= 0 {
162		return code
163	}
164	if len(args) != 2 {
165		return c.usage()
166	}
167	comments, err := c.Store.ListDiffComments(mr.ID, c.User.ID)
168	if err != nil {
169		return c.fail(protocol.ExitFailure, "%v", err)
170	}
171	type commentOut struct {
172		ID        int64  `json:"id"`
173		Author    string `json:"author"`
174		Body      string `json:"body"`
175		CreatedAt string `json:"created_at"`
176	}
177	type threadOut struct {
178		ID       int64        `json:"id"`
179		Path     string       `json:"path"`
180		Side     string       `json:"side"`
181		Line     int64        `json:"line"`
182		Stale    bool         `json:"stale"`
183		Resolved string       `json:"resolved_by,omitempty"`
184		Comments []commentOut `json:"comments"`
185	}
186	byRoot := map[int64]*threadOut{}
187	var order []int64
188	for _, cm := range comments {
189		if cm.ReplyTo == 0 {
190			byRoot[cm.ID] = &threadOut{
191				ID: cm.ID, Path: cm.Path, Side: cm.Side, Line: cm.Line,
192				Stale: cm.HeadSHA != mr.HeadSHA, Resolved: cm.ResolvedBy,
193				Comments: []commentOut{{cm.ID, cm.Author, cm.Body, cm.CreatedAt}},
194			}
195			order = append(order, cm.ID)
196		} else if th, ok := byRoot[cm.ReplyTo]; ok {
197			th.Comments = append(th.Comments, commentOut{cm.ID, cm.Author, cm.Body, cm.CreatedAt})
198		}
199	}
200	var ds []threadOut
201	for _, id := range order {
202		ds = append(ds, *byRoot[id])
203	}
204	_ = repo
205	return c.emit(ds, func(w io.Writer) {
206		for _, th := range ds {
207			marks := ""
208			if th.Resolved != "" {
209				marks += " [resolved by " + th.Resolved + "]"
210			}
211			if th.Stale {
212				marks += " [stale]"
213			}
214			fmt.Fprintf(w, "thread %d  %s:%d (%s)%s\n", th.ID, th.Path, th.Line, th.Side, marks)
215			for _, cm := range th.Comments {
216				fmt.Fprintf(w, "  %s: %s\n", cm.Author, cm.Body)
217			}
218		}
219	})
220}
221
222func setThreadResolved(c *Ctx, args []string, resolved bool) int {
223	if len(args) != 3 {
224		return c.usage()
225	}
226	repo, mr, code := mrRef(c, args[:2], policy.CanRead)
227	if code >= 0 {
228		return code
229	}
230	if code := refuseArchived(c, repo); code >= 0 {
231		return code
232	}
233	threadID, err := strconv.ParseInt(args[2], 10, 64)
234	if err != nil {
235		return c.fail(protocol.ExitUsage, "bad thread id %q", args[2])
236	}
237	// Thread author, MR author, or anyone with write may resolve.
238	author, err := c.Store.DiffCommentAuthor(mr.ID, threadID)
239	if errors.Is(err, store.ErrNotFound) {
240		return c.fail(protocol.ExitNotFound, "no thread %d on %s!%d", threadID, repo.Path(), mr.Number)
241	}
242	if err != nil {
243		return c.fail(protocol.ExitFailure, "%v", err)
244	}
245	grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
246	if err != nil {
247		return c.fail(protocol.ExitFailure, "%v", err)
248	}
249	if author != c.User.ID && mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
250		return c.fail(protocol.ExitDenied, "only the thread author, the MR author, or users with write access can resolve threads")
251	}
252	if err := c.Store.SetThreadResolved(mr.ID, threadID, c.User.ID, resolved); err != nil {
253		if errors.Is(err, store.ErrNotFound) {
254			return c.fail(protocol.ExitNotFound, "no thread %d (replies cannot be resolved; use the root id)", threadID)
255		}
256		return c.fail(protocol.ExitFailure, "%v", err)
257	}
258	verb := "resolved"
259	if !resolved {
260		verb = "reopened"
261	}
262	return c.emit(map[string]any{"thread": threadID, "resolved": resolved}, func(w io.Writer) {
263		fmt.Fprintf(w, "%s thread %d on %s!%d\n", verb, threadID, repo.Path(), mr.Number)
264	})
265}
266
267func runMRResolve(c *Ctx, args []string) int   { return setThreadResolved(c, args, true) }
268func runMRUnresolve(c *Ctx, args []string) int { return setThreadResolved(c, args, false) }