| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | |
| 3 | package review |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "strings" |
| 10 | |
| 11 | "github.com/jackc/pgx/v5" |
| 12 | "github.com/jackc/pgx/v5/pgtype" |
| 13 | |
| 14 | issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc" |
| 15 | pullsdb "github.com/tenseleyFlow/shithub/internal/pulls/sqlc" |
| 16 | ) |
| 17 | |
| 18 | // SubmitParams describes the submit-a-review action. |
| 19 | type SubmitParams struct { |
| 20 | PRIssueID int64 |
| 21 | AuthorUserID int64 |
| 22 | State string // "comment" | "approve" | "request_changes" |
| 23 | Body string |
| 24 | // PRAuthorUserID is the issues.author_user_id of the PR — used to |
| 25 | // reject self-approval. Caller passes it in to keep this package |
| 26 | // from cross-importing the pulls orchestrator. |
| 27 | PRAuthorUserID int64 |
| 28 | } |
| 29 | |
| 30 | // Submit records the review row, attaches every pending draft comment |
| 31 | // the author has on this PR to the new review (one tx), and emits a |
| 32 | // `reviewed` timeline event with the state. Pending review-request |
| 33 | // rows owned by the author are satisfied when state is approve or |
| 34 | // request_changes. |
| 35 | func Submit(ctx context.Context, deps Deps, p SubmitParams) (pullsdb.PrReview, error) { |
| 36 | if p.State != "comment" && p.State != "approve" && p.State != "request_changes" { |
| 37 | return pullsdb.PrReview{}, ErrInvalidState |
| 38 | } |
| 39 | if p.State == "approve" && p.AuthorUserID != 0 && p.AuthorUserID == p.PRAuthorUserID { |
| 40 | return pullsdb.PrReview{}, ErrAuthorCannotApprove |
| 41 | } |
| 42 | body := strings.TrimSpace(p.Body) |
| 43 | if len(body) > 65535 { |
| 44 | return pullsdb.PrReview{}, ErrBodyTooLong |
| 45 | } |
| 46 | html := renderBodyHTML(ctx, deps, body) |
| 47 | |
| 48 | tx, err := deps.Pool.Begin(ctx) |
| 49 | if err != nil { |
| 50 | return pullsdb.PrReview{}, err |
| 51 | } |
| 52 | committed := false |
| 53 | defer func() { |
| 54 | if !committed { |
| 55 | _ = tx.Rollback(ctx) |
| 56 | } |
| 57 | }() |
| 58 | |
| 59 | q := pullsdb.New() |
| 60 | row, err := q.CreatePRReview(ctx, tx, pullsdb.CreatePRReviewParams{ |
| 61 | PrIssueID: p.PRIssueID, |
| 62 | AuthorUserID: pgtype.Int8{Int64: p.AuthorUserID, Valid: p.AuthorUserID != 0}, |
| 63 | State: pullsdb.PrReviewState(p.State), |
| 64 | Body: body, |
| 65 | BodyHtmlCached: pgtype.Text{String: html, Valid: html != ""}, |
| 66 | }) |
| 67 | if err != nil { |
| 68 | return pullsdb.PrReview{}, fmt.Errorf("insert review: %w", err) |
| 69 | } |
| 70 | |
| 71 | // Attach every pending draft comment authored by this user on this |
| 72 | // PR to the new review. |
| 73 | if err := q.AttachPendingCommentsToReview(ctx, tx, pullsdb.AttachPendingCommentsToReviewParams{ |
| 74 | PrIssueID: p.PRIssueID, |
| 75 | AuthorUserID: pgtype.Int8{Int64: p.AuthorUserID, Valid: p.AuthorUserID != 0}, |
| 76 | ReviewID: pgtype.Int8{Int64: row.ID, Valid: true}, |
| 77 | }); err != nil { |
| 78 | return pullsdb.PrReview{}, fmt.Errorf("attach pending: %w", err) |
| 79 | } |
| 80 | |
| 81 | // Satisfy any pending review request from the author (only on |
| 82 | // approve / request_changes per spec). |
| 83 | if p.State == "approve" || p.State == "request_changes" { |
| 84 | if err := q.SatisfyPRReviewRequest(ctx, tx, pullsdb.SatisfyPRReviewRequestParams{ |
| 85 | PrIssueID: p.PRIssueID, |
| 86 | SatisfiedByReviewID: pgtype.Int8{Int64: row.ID, Valid: true}, |
| 87 | RequestedUserID: pgtype.Int8{Int64: p.AuthorUserID, Valid: p.AuthorUserID != 0}, |
| 88 | }); err != nil { |
| 89 | return pullsdb.PrReview{}, fmt.Errorf("satisfy request: %w", err) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | // `reviewed` timeline event. |
| 94 | iq := issuesdb.New() |
| 95 | if _, err := iq.InsertIssueEvent(ctx, tx, issuesdb.InsertIssueEventParams{ |
| 96 | IssueID: p.PRIssueID, |
| 97 | ActorUserID: pgtype.Int8{Int64: p.AuthorUserID, Valid: p.AuthorUserID != 0}, |
| 98 | Kind: "reviewed", |
| 99 | Meta: []byte(fmt.Sprintf(`{"state":%q,"review_id":%d}`, p.State, row.ID)), |
| 100 | }); err != nil { |
| 101 | return pullsdb.PrReview{}, fmt.Errorf("emit event: %w", err) |
| 102 | } |
| 103 | |
| 104 | if err := tx.Commit(ctx); err != nil { |
| 105 | return pullsdb.PrReview{}, err |
| 106 | } |
| 107 | committed = true |
| 108 | return row, nil |
| 109 | } |
| 110 | |
| 111 | // Dismiss flips a review's dismissed_at + reason. Caller has already |
| 112 | // gated on policy (typically repo admin only). |
| 113 | func Dismiss(ctx context.Context, deps Deps, actorUserID, reviewID int64, reason string) error { |
| 114 | q := pullsdb.New() |
| 115 | if _, err := q.GetPRReviewByID(ctx, deps.Pool, reviewID); err != nil { |
| 116 | if errors.Is(err, pgx.ErrNoRows) { |
| 117 | return ErrReviewNotFound |
| 118 | } |
| 119 | return err |
| 120 | } |
| 121 | return q.DismissPRReview(ctx, deps.Pool, pullsdb.DismissPRReviewParams{ |
| 122 | ID: reviewID, |
| 123 | DismissedByUserID: pgtype.Int8{Int64: actorUserID, Valid: actorUserID != 0}, |
| 124 | DismissalReason: strings.TrimSpace(reason), |
| 125 | }) |
| 126 | } |
| 127 |