| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | |
| 3 | package review |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | |
| 8 | "github.com/jackc/pgx/v5/pgtype" |
| 9 | |
| 10 | issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc" |
| 11 | "github.com/tenseleyFlow/shithub/internal/notif" |
| 12 | pullsdb "github.com/tenseleyFlow/shithub/internal/pulls/sqlc" |
| 13 | ) |
| 14 | |
| 15 | // RequestParams describes a review-request action. |
| 16 | type RequestParams struct { |
| 17 | PRIssueID int64 |
| 18 | RequestedUserID int64 |
| 19 | RequestedByUserID int64 |
| 20 | } |
| 21 | |
| 22 | // Request creates a pr_review_requests row. Bounded by |
| 23 | // MaxReviewersPerPR per the spec pitfalls section. |
| 24 | func Request(ctx context.Context, deps Deps, p RequestParams) (pullsdb.PrReviewRequest, error) { |
| 25 | q := pullsdb.New() |
| 26 | count, err := q.CountActivePRReviewRequests(ctx, deps.Pool, p.PRIssueID) |
| 27 | if err != nil { |
| 28 | return pullsdb.PrReviewRequest{}, err |
| 29 | } |
| 30 | if int(count) >= MaxReviewersPerPR { |
| 31 | return pullsdb.PrReviewRequest{}, ErrReviewerLimitReached |
| 32 | } |
| 33 | // Idempotent-ish: if the same reviewer is already pending, don't |
| 34 | // add a duplicate row. Walking the active list is cheaper than |
| 35 | // adding a partial unique index given v1 PRs typically have |
| 36 | // single-digit reviewer counts. |
| 37 | existing, err := q.ListPRReviewRequests(ctx, deps.Pool, p.PRIssueID) |
| 38 | if err != nil { |
| 39 | return pullsdb.PrReviewRequest{}, err |
| 40 | } |
| 41 | for _, e := range existing { |
| 42 | if e.RequestedUserID.Valid && e.RequestedUserID.Int64 == p.RequestedUserID && |
| 43 | !e.DismissedAt.Valid && !e.SatisfiedByReviewID.Valid { |
| 44 | return pullsdb.PrReviewRequest{}, ErrReviewerAlreadyPending |
| 45 | } |
| 46 | } |
| 47 | row, err := q.CreatePRReviewRequest(ctx, deps.Pool, pullsdb.CreatePRReviewRequestParams{ |
| 48 | PrIssueID: p.PRIssueID, |
| 49 | RequestedUserID: pgtype.Int8{Int64: p.RequestedUserID, Valid: p.RequestedUserID != 0}, |
| 50 | RequestedTeamID: pgtype.Int8{Valid: false}, |
| 51 | RequestedByUserID: pgtype.Int8{Int64: p.RequestedByUserID, Valid: p.RequestedByUserID != 0}, |
| 52 | }) |
| 53 | if err != nil { |
| 54 | return row, err |
| 55 | } |
| 56 | // S29: emit a domain event so the fan-out worker can route a |
| 57 | // `review_requested` notification to the requested reviewer. |
| 58 | // Best-effort — review-request side already succeeded; an emit |
| 59 | // failure is logged but doesn't fail the request (the fan-out |
| 60 | // worker isn't strict about ordering for this kind). |
| 61 | issue, ierr := issuesdb.New().GetIssueByID(ctx, deps.Pool, p.PRIssueID) |
| 62 | if ierr == nil { |
| 63 | var public bool |
| 64 | _ = deps.Pool.QueryRow( |
| 65 | ctx, |
| 66 | `SELECT visibility = 'public' FROM repos WHERE id = $1`, |
| 67 | issue.RepoID, |
| 68 | ).Scan(&public) |
| 69 | _ = notif.Emit(ctx, deps.Pool, notif.Event{ |
| 70 | ActorUserID: p.RequestedByUserID, |
| 71 | Kind: "review_requested", |
| 72 | RepoID: issue.RepoID, |
| 73 | SourceKind: "issue", |
| 74 | SourceID: issue.ID, |
| 75 | Public: public, |
| 76 | Extra: map[string]any{ |
| 77 | "reviewer_user_id": p.RequestedUserID, |
| 78 | "issue_number": issue.Number, |
| 79 | "issue_title": issue.Title, |
| 80 | }, |
| 81 | }) |
| 82 | } |
| 83 | return row, nil |
| 84 | } |
| 85 |