Go · 5302 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package review
4
5 import (
6 "context"
7 "errors"
8 "strings"
9 "time"
10
11 "github.com/jackc/pgx/v5"
12 "github.com/jackc/pgx/v5/pgtype"
13
14 pullsdb "github.com/tenseleyFlow/shithub/internal/pulls/sqlc"
15 )
16
17 // CommentParams describes a single inline comment on a PR. When
18 // `Pending` is true the comment is filed as a draft attached to the
19 // author's pending review (review_id stays NULL); SubmitReview later
20 // flips pending=false and sets review_id.
21 type CommentParams struct {
22 PRIssueID int64
23 AuthorUserID int64
24 FilePath string
25 Side string // "left" | "right"
26 OriginalCommitSHA string
27 OriginalLine int32
28 OriginalPosition int32
29 CurrentPosition int32 // -1 means already outdated (rare on insert)
30 Body string
31 InReplyToID int64 // 0 means top-level
32 Pending bool
33 }
34
35 // AddComment inserts a single inline comment. When InReplyToID is set
36 // the new comment threads under that one (validated to belong to the
37 // same PR). Markdown is rendered to body_html_cached at insert time.
38 func AddComment(ctx context.Context, deps Deps, p CommentParams) (pullsdb.PrReviewComment, error) {
39 body := strings.TrimSpace(p.Body)
40 if body == "" {
41 return pullsdb.PrReviewComment{}, ErrEmptyBody
42 }
43 if len(body) > 65535 {
44 return pullsdb.PrReviewComment{}, ErrBodyTooLong
45 }
46 if p.Side != "left" && p.Side != "right" {
47 p.Side = "right"
48 }
49
50 q := pullsdb.New()
51
52 // Reply validation: parent must exist on the same PR. We don't
53 // inherit FilePath/Side from the parent — the caller passes the
54 // correct anchor; the in-reply-to link is purely for threading.
55 if p.InReplyToID != 0 {
56 parent, err := q.GetPRReviewComment(ctx, deps.Pool, p.InReplyToID)
57 if err != nil {
58 if errors.Is(err, pgx.ErrNoRows) {
59 return pullsdb.PrReviewComment{}, ErrCommentNotOnPR
60 }
61 return pullsdb.PrReviewComment{}, err
62 }
63 if parent.PrIssueID != p.PRIssueID {
64 return pullsdb.PrReviewComment{}, ErrCommentNotOnPR
65 }
66 // Inherit anchor from parent so the whole thread renders on
67 // the same diff line even if the caller forgot to pass it.
68 p.FilePath = parent.FilePath
69 p.Side = string(parent.Side)
70 p.OriginalCommitSHA = parent.OriginalCommitSha
71 p.OriginalLine = parent.OriginalLine
72 p.OriginalPosition = parent.OriginalPosition
73 if parent.CurrentPosition.Valid {
74 p.CurrentPosition = parent.CurrentPosition.Int32
75 }
76 }
77
78 html := renderBodyHTML(ctx, deps, body)
79
80 cur := pgtype.Int4{}
81 if p.CurrentPosition >= 0 {
82 cur = pgtype.Int4{Int32: p.CurrentPosition, Valid: true}
83 }
84
85 row, err := q.CreatePRReviewComment(ctx, deps.Pool, pullsdb.CreatePRReviewCommentParams{
86 PrIssueID: p.PRIssueID,
87 ReviewID: pgtype.Int8{},
88 AuthorUserID: pgtype.Int8{Int64: p.AuthorUserID, Valid: p.AuthorUserID != 0},
89 FilePath: p.FilePath,
90 Side: pullsdb.PrReviewSide(p.Side),
91 OriginalCommitSha: p.OriginalCommitSHA,
92 OriginalLine: p.OriginalLine,
93 OriginalPosition: p.OriginalPosition,
94 CurrentPosition: cur,
95 Body: body,
96 BodyHtmlCached: pgtype.Text{String: html, Valid: html != ""},
97 InReplyToID: pgtype.Int8{Int64: p.InReplyToID, Valid: p.InReplyToID != 0},
98 Pending: p.Pending,
99 })
100 if err != nil {
101 return pullsdb.PrReviewComment{}, err
102 }
103 return row, nil
104 }
105
106 // EditComment updates the body of an existing comment. Caller is
107 // expected to have already enforced "actor == comment.author OR
108 // repo admin" via policy.
109 func EditComment(ctx context.Context, deps Deps, commentID int64, body string) error {
110 body = strings.TrimSpace(body)
111 if body == "" {
112 return ErrEmptyBody
113 }
114 if len(body) > 65535 {
115 return ErrBodyTooLong
116 }
117 html := renderBodyHTML(ctx, deps, body)
118 return pullsdb.New().UpdatePRReviewCommentBody(ctx, deps.Pool, pullsdb.UpdatePRReviewCommentBodyParams{
119 ID: commentID, Body: body, BodyHtmlCached: pgtype.Text{String: html, Valid: html != ""},
120 })
121 }
122
123 // Resolve marks the thread (root + replies) resolved. The "thread" is
124 // keyed off the comment id; UI walks `in_reply_to_id` to render
125 // replies, so resolving the root collapses the whole conversation.
126 // Default-collapsed in the Files tab; "Show resolved" toggle reopens.
127 func Resolve(ctx context.Context, deps Deps, actorUserID, commentID int64) error {
128 q := pullsdb.New()
129 c, err := q.GetPRReviewComment(ctx, deps.Pool, commentID)
130 if err != nil {
131 return err
132 }
133 if c.ResolvedAt.Valid {
134 return ErrAlreadyResolved
135 }
136 return q.SetPRReviewCommentResolved(ctx, deps.Pool, pullsdb.SetPRReviewCommentResolvedParams{
137 ID: commentID,
138 ResolvedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
139 ResolvedByUserID: pgtype.Int8{Int64: actorUserID, Valid: actorUserID != 0},
140 })
141 }
142
143 // Reopen clears the resolved-at marker on a thread.
144 func Reopen(ctx context.Context, deps Deps, commentID int64) error {
145 q := pullsdb.New()
146 c, err := q.GetPRReviewComment(ctx, deps.Pool, commentID)
147 if err != nil {
148 return err
149 }
150 if !c.ResolvedAt.Valid {
151 return ErrNotResolved
152 }
153 return q.SetPRReviewCommentResolved(ctx, deps.Pool, pullsdb.SetPRReviewCommentResolvedParams{
154 ID: commentID,
155 ResolvedAt: pgtype.Timestamptz{Valid: false},
156 ResolvedByUserID: pgtype.Int8{Valid: false},
157 })
158 }
159