Go · 16790 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package review_test
4
5 import (
6 "context"
7 "io"
8 "log/slog"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "strings"
13 "testing"
14
15 "github.com/jackc/pgx/v5/pgtype"
16 "github.com/jackc/pgx/v5/pgxpool"
17
18 issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
19 "github.com/tenseleyFlow/shithub/internal/pulls"
20 "github.com/tenseleyFlow/shithub/internal/pulls/review"
21 pullsdb "github.com/tenseleyFlow/shithub/internal/pulls/sqlc"
22 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
23 "github.com/tenseleyFlow/shithub/internal/testing/dbtest"
24 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
25 )
26
27 const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
28 "AAAAAAAAAAAAAAAA$" +
29 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
30
31 func gitCmd(args ...string) *exec.Cmd {
32 //nolint:gosec
33 return exec.Command("git", args...)
34 }
35
36 type fx struct {
37 pool *pgxpool.Pool
38 pullsDeps pulls.Deps
39 reviewDeps review.Deps
40 authorID int64
41 reviewerID int64
42 otherID int64
43 repoID int64
44 gitDir string
45 }
46
47 func setup(t *testing.T) fx {
48 t.Helper()
49 pool := dbtest.NewTestDB(t)
50 ctx := context.Background()
51
52 uq := usersdb.New()
53 mkUser := func(name string) usersdb.User {
54 u, err := uq.CreateUser(ctx, pool, usersdb.CreateUserParams{
55 Username: name, DisplayName: strings.ToTitle(name), PasswordHash: fixtureHash,
56 })
57 if err != nil {
58 t.Fatalf("CreateUser %s: %v", name, err)
59 }
60 em, err := uq.CreateUserEmail(ctx, pool, usersdb.CreateUserEmailParams{
61 UserID: u.ID, Email: name + "@example.com", IsPrimary: true, Verified: true,
62 })
63 if err != nil {
64 t.Fatalf("CreateUserEmail: %v", err)
65 }
66 if err := uq.LinkUserPrimaryEmail(ctx, pool, usersdb.LinkUserPrimaryEmailParams{
67 ID: u.ID, PrimaryEmailID: pgtype.Int8{Int64: em.ID, Valid: true},
68 }); err != nil {
69 t.Fatalf("LinkUserPrimaryEmail: %v", err)
70 }
71 return u
72 }
73 author := mkUser("alice")
74 reviewer := mkUser("bob")
75 other := mkUser("carol")
76
77 rq := reposdb.New()
78 repo, err := rq.CreateRepo(ctx, pool, reposdb.CreateRepoParams{
79 OwnerUserID: pgtype.Int8{Int64: author.ID, Valid: true},
80 Name: "demo",
81 DefaultBranch: "trunk",
82 Visibility: reposdb.RepoVisibilityPublic,
83 })
84 if err != nil {
85 t.Fatalf("CreateRepo: %v", err)
86 }
87 if err := issuesdb.New().EnsureRepoIssueCounter(ctx, pool, repo.ID); err != nil {
88 t.Fatalf("EnsureRepoIssueCounter: %v", err)
89 }
90
91 root := t.TempDir()
92 gitDir := filepath.Join(root, "demo.git")
93 if out, err := gitCmd("init", "--bare", "-b", "trunk", gitDir).CombinedOutput(); err != nil {
94 t.Fatalf("git init --bare: %v (%s)", err, out)
95 }
96
97 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
98 return fx{
99 pool: pool,
100 pullsDeps: pulls.Deps{Pool: pool, Logger: logger},
101 reviewDeps: review.Deps{Pool: pool, Logger: logger},
102 authorID: author.ID,
103 reviewerID: reviewer.ID,
104 otherID: other.ID,
105 repoID: repo.ID,
106 gitDir: gitDir,
107 }
108 }
109
110 func commitOnBranch(t *testing.T, gitDir, branch, msg, file, contents string) string {
111 t.Helper()
112 wt := t.TempDir()
113 addArgs := []string{"-C", gitDir, "worktree", "add"}
114 if _, err := gitCmd("-C", gitDir, "show-ref", "--verify", "refs/heads/"+branch).CombinedOutput(); err != nil {
115 addArgs = append(addArgs, "-b", branch, wt)
116 } else {
117 addArgs = append(addArgs, wt, branch)
118 }
119 if out, err := gitCmd(addArgs...).CombinedOutput(); err != nil {
120 t.Fatalf("worktree add %s: %v (%s)", branch, err, out)
121 }
122 defer func() {
123 _ = gitCmd("-C", gitDir, "worktree", "remove", "--force", wt).Run()
124 }()
125 if err := os.WriteFile(filepath.Join(wt, file), []byte(contents), 0o644); err != nil { //nolint:gosec
126 t.Fatalf("write %s: %v", file, err)
127 }
128 for _, args := range [][]string{
129 {"-C", wt, "config", "user.name", "Alice"},
130 {"-C", wt, "config", "user.email", "alice@example.com"},
131 {"-C", wt, "add", "."},
132 {"-C", wt, "commit", "-m", msg},
133 } {
134 if out, err := gitCmd(args...).CombinedOutput(); err != nil {
135 t.Fatalf("%v: %v (%s)", args, err, out)
136 }
137 }
138 out, err := gitCmd("-C", wt, "rev-parse", "HEAD").Output()
139 if err != nil {
140 t.Fatalf("rev-parse HEAD: %v", err)
141 }
142 return strings.TrimSpace(string(out))
143 }
144
145 // openPR opens a same-repo PR and runs Mergeability so the state is
146 // fresh.
147 func (f fx) openPR(t *testing.T, base, head string) pullsdb.PullRequest {
148 t.Helper()
149 res, err := pulls.Create(context.Background(), f.pullsDeps, pulls.CreateParams{
150 RepoID: f.repoID, AuthorUserID: f.authorID,
151 Title: "test PR", BaseRef: base, HeadRef: head, GitDir: f.gitDir,
152 })
153 if err != nil {
154 t.Fatalf("openPR: %v", err)
155 }
156 if err := pulls.Mergeability(context.Background(), f.pullsDeps, f.gitDir, res.PullRequest.IssueID); err != nil {
157 t.Fatalf("Mergeability: %v", err)
158 }
159 return res.PullRequest
160 }
161
162 func TestSubmit_AuthorCannotApprove(t *testing.T) {
163 f := setup(t)
164 commitOnBranch(t, f.gitDir, "trunk", "init", "README.md", "hi\n")
165 commitOnBranch(t, f.gitDir, "feature", "add", "x.txt", "x\n")
166 pr := f.openPR(t, "trunk", "feature")
167
168 _, err := review.Submit(context.Background(), f.reviewDeps, review.SubmitParams{
169 PRIssueID: pr.IssueID, AuthorUserID: f.authorID,
170 State: "approve", PRAuthorUserID: f.authorID,
171 })
172 if err == nil {
173 t.Fatalf("expected ErrAuthorCannotApprove, got nil")
174 }
175 }
176
177 func TestSubmit_AttachesPendingComments(t *testing.T) {
178 f := setup(t)
179 ctx := context.Background()
180 commitOnBranch(t, f.gitDir, "trunk", "init", "README.md", "hi\n")
181 commitOnBranch(t, f.gitDir, "feature", "add", "x.txt", "x\n")
182 pr := f.openPR(t, "trunk", "feature")
183
184 // Two pending draft comments by the reviewer.
185 if _, err := review.AddComment(ctx, f.reviewDeps, review.CommentParams{
186 PRIssueID: pr.IssueID, AuthorUserID: f.reviewerID,
187 FilePath: "x.txt", Side: "right", OriginalCommitSHA: pr.HeadOid,
188 OriginalLine: 1, OriginalPosition: 1, CurrentPosition: 1,
189 Body: "first draft", Pending: true,
190 }); err != nil {
191 t.Fatalf("AddComment 1: %v", err)
192 }
193 if _, err := review.AddComment(ctx, f.reviewDeps, review.CommentParams{
194 PRIssueID: pr.IssueID, AuthorUserID: f.reviewerID,
195 FilePath: "x.txt", Side: "right", OriginalCommitSHA: pr.HeadOid,
196 OriginalLine: 1, OriginalPosition: 1, CurrentPosition: 1,
197 Body: "second draft", Pending: true,
198 }); err != nil {
199 t.Fatalf("AddComment 2: %v", err)
200 }
201
202 rv, err := review.Submit(ctx, f.reviewDeps, review.SubmitParams{
203 PRIssueID: pr.IssueID, AuthorUserID: f.reviewerID,
204 State: "comment", Body: "review body", PRAuthorUserID: f.authorID,
205 })
206 if err != nil {
207 t.Fatalf("Submit: %v", err)
208 }
209
210 // All pending comments should now have review_id = rv.ID and pending=false.
211 cs, _ := pullsdb.New().ListPRReviewComments(ctx, f.pool, pr.IssueID)
212 for _, c := range cs {
213 if c.Pending {
214 t.Errorf("comment %d still pending after submit", c.ID)
215 }
216 if !c.ReviewID.Valid || c.ReviewID.Int64 != rv.ID {
217 t.Errorf("comment %d review_id=%v, want %d", c.ID, c.ReviewID, rv.ID)
218 }
219 }
220 }
221
222 func TestRequiredReviews_BlocksThenUnblocks(t *testing.T) {
223 f := setup(t)
224 ctx := context.Background()
225 commitOnBranch(t, f.gitDir, "trunk", "init", "README.md", "hi\n")
226 commitOnBranch(t, f.gitDir, "feature", "add", "x.txt", "x\n")
227 pr := f.openPR(t, "trunk", "feature")
228
229 // Add a protection rule on trunk requiring 1 approval.
230 rq := reposdb.New()
231 id, err := rq.UpsertBranchProtectionRule(ctx, f.pool, reposdb.UpsertBranchProtectionRuleParams{
232 RepoID: f.repoID, Pattern: "trunk",
233 PreventForcePush: true, PreventDeletion: true, RequirePrForPush: false,
234 AllowedPusherUserIds: []int64{},
235 CreatedByUserID: pgtype.Int8{Valid: false},
236 })
237 if err != nil {
238 t.Fatalf("UpsertBranchProtectionRule: %v", err)
239 }
240 if err := rq.UpdateBranchProtectionReviewSettings(ctx, f.pool, reposdb.UpdateBranchProtectionReviewSettingsParams{
241 ID: id, RequiredReviewCount: 1,
242 }); err != nil {
243 t.Fatalf("UpdateBranchProtectionReviewSettings: %v", err)
244 }
245
246 // Re-tick mergeability — should be blocked now.
247 if err := pulls.Mergeability(ctx, f.pullsDeps, f.gitDir, pr.IssueID); err != nil {
248 t.Fatalf("Mergeability: %v", err)
249 }
250 got, _ := pullsdb.New().GetPullRequestByIssueID(ctx, f.pool, pr.IssueID)
251 if got.MergeableState != pullsdb.PrMergeableStateBlocked {
252 t.Errorf("after rule: state=%s, want blocked", got.MergeableState)
253 }
254
255 // Reviewer approves.
256 if _, err := review.Submit(ctx, f.reviewDeps, review.SubmitParams{
257 PRIssueID: pr.IssueID, AuthorUserID: f.reviewerID,
258 State: "approve", PRAuthorUserID: f.authorID,
259 }); err != nil {
260 t.Fatalf("approve: %v", err)
261 }
262 if err := pulls.Mergeability(ctx, f.pullsDeps, f.gitDir, pr.IssueID); err != nil {
263 t.Fatalf("Mergeability after approve: %v", err)
264 }
265 got, _ = pullsdb.New().GetPullRequestByIssueID(ctx, f.pool, pr.IssueID)
266 if got.MergeableState != pullsdb.PrMergeableStateClean {
267 t.Errorf("after approve: state=%s, want clean", got.MergeableState)
268 }
269
270 // Merge proceeds.
271 if err := pulls.Merge(ctx, f.pullsDeps, pulls.MergeParams{
272 PRID: pr.IssueID, ActorUserID: f.authorID, GitDir: f.gitDir, Method: "merge",
273 }); err != nil {
274 t.Fatalf("Merge: %v", err)
275 }
276 }
277
278 func TestRequestChanges_BlocksMerge(t *testing.T) {
279 f := setup(t)
280 ctx := context.Background()
281 commitOnBranch(t, f.gitDir, "trunk", "init", "README.md", "hi\n")
282 commitOnBranch(t, f.gitDir, "feature", "add", "x.txt", "x\n")
283 pr := f.openPR(t, "trunk", "feature")
284
285 if _, err := review.Submit(ctx, f.reviewDeps, review.SubmitParams{
286 PRIssueID: pr.IssueID, AuthorUserID: f.reviewerID,
287 State: "request_changes", PRAuthorUserID: f.authorID,
288 }); err != nil {
289 t.Fatalf("submit request_changes: %v", err)
290 }
291 if err := pulls.Mergeability(ctx, f.pullsDeps, f.gitDir, pr.IssueID); err != nil {
292 t.Fatalf("Mergeability: %v", err)
293 }
294 got, _ := pullsdb.New().GetPullRequestByIssueID(ctx, f.pool, pr.IssueID)
295 if got.MergeableState != pullsdb.PrMergeableStateBlocked {
296 t.Errorf("after request_changes: state=%s, want blocked", got.MergeableState)
297 }
298 // Merge should refuse.
299 if err := pulls.Merge(ctx, f.pullsDeps, pulls.MergeParams{
300 PRID: pr.IssueID, ActorUserID: f.authorID, GitDir: f.gitDir, Method: "merge",
301 }); err == nil {
302 t.Errorf("Merge should have been blocked, got nil")
303 }
304 }
305
306 func TestTwoApprovers_UnblockMerge(t *testing.T) {
307 f := setup(t)
308 ctx := context.Background()
309 commitOnBranch(t, f.gitDir, "trunk", "init", "README.md", "hi\n")
310 commitOnBranch(t, f.gitDir, "feature", "add", "x.txt", "x\n")
311 pr := f.openPR(t, "trunk", "feature")
312
313 rq := reposdb.New()
314 id, _ := rq.UpsertBranchProtectionRule(ctx, f.pool, reposdb.UpsertBranchProtectionRuleParams{
315 RepoID: f.repoID, Pattern: "trunk",
316 PreventForcePush: true, PreventDeletion: true, RequirePrForPush: false,
317 AllowedPusherUserIds: []int64{}, CreatedByUserID: pgtype.Int8{Valid: false},
318 })
319 _ = rq.UpdateBranchProtectionReviewSettings(ctx, f.pool, reposdb.UpdateBranchProtectionReviewSettingsParams{
320 ID: id, RequiredReviewCount: 2,
321 })
322 if err := pulls.Mergeability(ctx, f.pullsDeps, f.gitDir, pr.IssueID); err != nil {
323 t.Fatalf("Mergeability: %v", err)
324 }
325 for _, uid := range []int64{f.reviewerID, f.otherID} {
326 if _, err := review.Submit(ctx, f.reviewDeps, review.SubmitParams{
327 PRIssueID: pr.IssueID, AuthorUserID: uid,
328 State: "approve", PRAuthorUserID: f.authorID,
329 }); err != nil {
330 t.Fatalf("approve: %v", err)
331 }
332 }
333 if err := pulls.Mergeability(ctx, f.pullsDeps, f.gitDir, pr.IssueID); err != nil {
334 t.Fatalf("Mergeability: %v", err)
335 }
336 got, _ := pullsdb.New().GetPullRequestByIssueID(ctx, f.pool, pr.IssueID)
337 if got.MergeableState != pullsdb.PrMergeableStateClean {
338 t.Errorf("two approvers: state=%s, want clean", got.MergeableState)
339 }
340 }
341
342 func TestResolveAndReopen(t *testing.T) {
343 f := setup(t)
344 ctx := context.Background()
345 commitOnBranch(t, f.gitDir, "trunk", "init", "README.md", "hi\n")
346 commitOnBranch(t, f.gitDir, "feature", "add", "x.txt", "x\n")
347 pr := f.openPR(t, "trunk", "feature")
348
349 c, err := review.AddComment(ctx, f.reviewDeps, review.CommentParams{
350 PRIssueID: pr.IssueID, AuthorUserID: f.reviewerID,
351 FilePath: "x.txt", Side: "right", OriginalCommitSHA: pr.HeadOid,
352 OriginalLine: 1, OriginalPosition: 1, CurrentPosition: 1,
353 Body: "comment",
354 })
355 if err != nil {
356 t.Fatalf("AddComment: %v", err)
357 }
358 if err := review.Resolve(ctx, f.reviewDeps, f.reviewerID, c.ID); err != nil {
359 t.Fatalf("Resolve: %v", err)
360 }
361 if err := review.Resolve(ctx, f.reviewDeps, f.reviewerID, c.ID); err == nil {
362 t.Errorf("double-resolve should error")
363 }
364 if err := review.Reopen(ctx, f.reviewDeps, c.ID); err != nil {
365 t.Fatalf("Reopen: %v", err)
366 }
367 got, _ := pullsdb.New().GetPRReviewComment(ctx, f.pool, c.ID)
368 if got.ResolvedAt.Valid {
369 t.Errorf("after reopen: resolved_at still set")
370 }
371 }
372
373 func TestDismiss_ClearsBlock(t *testing.T) {
374 f := setup(t)
375 ctx := context.Background()
376 commitOnBranch(t, f.gitDir, "trunk", "init", "README.md", "hi\n")
377 commitOnBranch(t, f.gitDir, "feature", "add", "x.txt", "x\n")
378 pr := f.openPR(t, "trunk", "feature")
379
380 rv, err := review.Submit(ctx, f.reviewDeps, review.SubmitParams{
381 PRIssueID: pr.IssueID, AuthorUserID: f.reviewerID,
382 State: "request_changes", PRAuthorUserID: f.authorID,
383 })
384 if err != nil {
385 t.Fatalf("submit: %v", err)
386 }
387 _ = pulls.Mergeability(ctx, f.pullsDeps, f.gitDir, pr.IssueID)
388 got, _ := pullsdb.New().GetPullRequestByIssueID(ctx, f.pool, pr.IssueID)
389 if got.MergeableState != pullsdb.PrMergeableStateBlocked {
390 t.Fatalf("pre-dismiss: state=%s, want blocked", got.MergeableState)
391 }
392 if err := review.Dismiss(ctx, f.reviewDeps, f.authorID, rv.ID, "stale"); err != nil {
393 t.Fatalf("Dismiss: %v", err)
394 }
395 _ = pulls.Mergeability(ctx, f.pullsDeps, f.gitDir, pr.IssueID)
396 got, _ = pullsdb.New().GetPullRequestByIssueID(ctx, f.pool, pr.IssueID)
397 if got.MergeableState != pullsdb.PrMergeableStateClean {
398 t.Errorf("post-dismiss: state=%s, want clean", got.MergeableState)
399 }
400 }
401
402 // TestPositionMap_ContentAware verifies the audit fix for the
403 // position-mapper: a comment anchored at line N stays anchored only
404 // when the line text at that position matches the original; otherwise
405 // it goes outdated. Pre-fix, the mapper just checked "does line N
406 // exist" (yes if file has ≥ N lines), which kept comments anchored
407 // onto entirely unrelated content. (S00-S25 audit, finding C2.)
408 func TestPositionMap_ContentAware(t *testing.T) {
409 f := setup(t)
410 ctx := context.Background()
411 commitOnBranch(t, f.gitDir, "trunk", "init", "README.md", "hi\n")
412 // Initial 3-line file.
413 commitOnBranch(t, f.gitDir, "feature", "add", "x.txt", "alpha\nbravo\ncharlie\n")
414 pr := f.openPR(t, "trunk", "feature")
415
416 // Comment anchored at line 2 ("bravo") on the original head.
417 c, err := review.AddComment(ctx, f.reviewDeps, review.CommentParams{
418 PRIssueID: pr.IssueID, AuthorUserID: f.reviewerID,
419 FilePath: "x.txt", Side: "right", OriginalCommitSHA: pr.HeadOid,
420 OriginalLine: 2, OriginalPosition: 2, CurrentPosition: 2,
421 Body: "comment on bravo",
422 })
423 if err != nil {
424 t.Fatalf("AddComment: %v", err)
425 }
426
427 // Push a new head where line 2 has been replaced with totally
428 // different content. The PR's head_oid is updated by Synchronize,
429 // which RemapAllForPR runs against. We push to feature directly.
430 commitOnBranch(t, f.gitDir, "feature", "rewrite line 2", "x.txt", "alpha\nDELTA\ncharlie\n")
431 if err := pulls.Synchronize(ctx, f.pullsDeps, f.gitDir, pr.IssueID); err != nil {
432 t.Fatalf("Synchronize: %v", err)
433 }
434
435 got, err := pullsdb.New().GetPRReviewComment(ctx, f.pool, c.ID)
436 if err != nil {
437 t.Fatalf("GetPRReviewComment: %v", err)
438 }
439 if got.CurrentPosition.Valid {
440 t.Errorf("expected current_position=NULL (outdated), got %d (line text changed)", got.CurrentPosition.Int32)
441 }
442
443 // Sanity counter-test: a comment on an unchanged line should stay
444 // anchored. Open a fresh PR for cleanliness.
445 commitOnBranch(t, f.gitDir, "trunk2", "init", "README2.md", "hi\n")
446 commitOnBranch(t, f.gitDir, "feature2", "add", "y.txt", "alpha\nbravo\ncharlie\n")
447 pr2 := f.openPR(t, "trunk2", "feature2")
448 c2, err := review.AddComment(ctx, f.reviewDeps, review.CommentParams{
449 PRIssueID: pr2.IssueID, AuthorUserID: f.reviewerID,
450 FilePath: "y.txt", Side: "right", OriginalCommitSHA: pr2.HeadOid,
451 OriginalLine: 2, OriginalPosition: 2, CurrentPosition: 2,
452 Body: "comment that should survive",
453 })
454 if err != nil {
455 t.Fatalf("AddComment 2: %v", err)
456 }
457 // Push a change to a different line of the same file.
458 commitOnBranch(t, f.gitDir, "feature2", "tweak line 1", "y.txt", "ALPHA\nbravo\ncharlie\n")
459 if err := pulls.Synchronize(ctx, f.pullsDeps, f.gitDir, pr2.IssueID); err != nil {
460 t.Fatalf("Synchronize 2: %v", err)
461 }
462 got2, _ := pullsdb.New().GetPRReviewComment(ctx, f.pool, c2.ID)
463 if !got2.CurrentPosition.Valid || got2.CurrentPosition.Int32 != 2 {
464 t.Errorf("comment on unchanged line: current_position=%v (want valid=2)", got2.CurrentPosition)
465 }
466 }
467