internal/control/diffcomment.go

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

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