internal/control/diffcomment.go

98e6424c9886ae6160a6e939eea39ffd87d5cc75
gitbay/internal/control/diffcomment.go history · blame · raw

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