Go · 8728 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package pulls
4
5 import (
6 "context"
7 "errors"
8 "fmt"
9 "strconv"
10 "strings"
11 "time"
12
13 "github.com/jackc/pgx/v5/pgtype"
14
15 "github.com/tenseleyFlow/shithub/internal/issues"
16 issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
17 "github.com/tenseleyFlow/shithub/internal/pulls/review"
18 pullsdb "github.com/tenseleyFlow/shithub/internal/pulls/sqlc"
19 repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
20 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
21 )
22
23 // MergeParams describes a merge request.
24 type MergeParams struct {
25 PRID int64
26 ActorUserID int64
27 GitDir string
28 Method string // "merge" | "squash" | "rebase"
29 Subject string // optional override; falls back to "<title> (#<number>)"
30 Body string
31 WorktreesDir string // optional, defaults to <gitDir>/.tmp-worktrees
32 Now func() time.Time
33 }
34
35 // Merge performs the requested merge inside a temp worktree:
36 //
37 // 1. Lock the pull_requests row (FOR UPDATE) to serialize concurrent
38 // attempts. The transaction holds for the whole job to keep the
39 // lock until the DB state reflects the merge.
40 // 2. Validate state: not merged, not closed, mergeable_state == clean,
41 // method allowed by the repo config.
42 // 3. Resolve identity (author = PR author for squash, merger otherwise;
43 // committer = merger).
44 // 4. Run repogit.PerformMerge against the bare repo.
45 // 5. Persist merge_commit_sha + merged_at + flip the issue to closed
46 // with state_reason='completed'.
47 // 6. Auto-close linked issues parsed from PR body + commit messages.
48 // 7. Emit a `merged` timeline event.
49 func Merge(ctx context.Context, deps Deps, p MergeParams) error {
50 if p.Now == nil {
51 p.Now = time.Now
52 }
53
54 // Begin the locking tx. Holding the row lock through the whole
55 // merge prevents two attempts both winning at the worktree level.
56 tx, err := deps.Pool.Begin(ctx)
57 if err != nil {
58 return err
59 }
60 committed := false
61 defer func() {
62 if !committed {
63 _ = tx.Rollback(ctx)
64 }
65 }()
66
67 q := pullsdb.New()
68 pr, err := q.LockPullRequestForMerge(ctx, tx, p.PRID)
69 if err != nil {
70 return ErrPRNotFound
71 }
72 if pr.MergedAt.Valid {
73 return ErrAlreadyMerged
74 }
75 // Issue side: must still be open + mergeable.
76 iq := issuesdb.New()
77 issue, err := iq.GetIssueByID(ctx, tx, p.PRID)
78 if err != nil {
79 return err
80 }
81 if issue.State != issuesdb.IssueStateOpen {
82 return ErrAlreadyClosed
83 }
84 if pr.MergeableState != pullsdb.PrMergeableStateClean {
85 return ErrMergeBlocked
86 }
87
88 // Belt-and-braces: re-evaluate the review gate inside the row
89 // lock so a `request_changes` submitted between the last
90 // Mergeability tick and this merge attempt is still honored.
91 // Spec pitfall: required-review bypass via direct API.
92 gate, err := review.Evaluate(ctx, deps.Pool, review.GateInputs{
93 RepoID: issue.RepoID,
94 BaseRef: pr.BaseRef,
95 PRIssueID: p.PRID,
96 }, int64FromPg(issue.AuthorUserID))
97 if err != nil {
98 return fmt.Errorf("review gate: %w", err)
99 }
100 if !gate.Satisfied {
101 return ErrMergeBlocked
102 }
103
104 // Identity for the new commit. Author email rules per spec:
105 // merge — author = committer = merger
106 // squash — author = PR author; committer = merger
107 // rebase — author preserved; committer = merger (handled by
108 // repogit.PerformMerge via env)
109 uq := usersdb.New()
110 mergerName, mergerEmail, err := identityFor(ctx, tx, uq, p.ActorUserID)
111 if err != nil {
112 return err
113 }
114 authorName, authorEmail := mergerName, mergerEmail
115 if p.Method == "squash" && issue.AuthorUserID.Valid {
116 an, ae, err := identityFor(ctx, tx, uq, issue.AuthorUserID.Int64)
117 if err == nil {
118 authorName, authorEmail = an, ae
119 }
120 }
121
122 subject := strings.TrimSpace(p.Subject)
123 if subject == "" {
124 subject = issue.Title + " (#" + strconv.FormatInt(issue.Number, 10) + ")"
125 }
126
127 // Run the merge against the bare repo. PerformMerge cleans up the
128 // worktree on every exit path.
129 mergeRes, err := repogit.PerformMerge(ctx, repogit.MergeOptions{
130 GitDir: p.GitDir,
131 BaseRef: "refs/heads/" + pr.BaseRef,
132 BaseOID: pr.BaseOid,
133 HeadOID: pr.HeadOid,
134 Method: p.Method,
135 AuthorName: authorName,
136 AuthorEmail: authorEmail,
137 CommitterName: mergerName,
138 CommitterEmail: mergerEmail,
139 When: p.Now(),
140 Subject: subject,
141 Body: p.Body,
142 WorktreesDir: p.WorktreesDir,
143 })
144 if err != nil {
145 return fmt.Errorf("perform merge: %w", err)
146 }
147
148 if err := q.SetPullRequestMerged(ctx, tx, pullsdb.SetPullRequestMergedParams{
149 IssueID: p.PRID,
150 MergedByUserID: pgtype.Int8{Int64: p.ActorUserID, Valid: p.ActorUserID != 0},
151 MergeCommitSha: pgtype.Text{String: mergeRes.MergedOID, Valid: true},
152 MergeMethod: pullsdb.NullPrMergeMethod{PrMergeMethod: pullsdb.PrMergeMethod(p.Method), Valid: true},
153 BaseOidAtMerge: pgtype.Text{String: pr.BaseOid, Valid: true},
154 HeadOidAtMerge: pgtype.Text{String: pr.HeadOid, Valid: true},
155 }); err != nil {
156 return err
157 }
158
159 // Close the issue side with state_reason=completed.
160 if err := iq.SetIssueState(ctx, tx, issuesdb.SetIssueStateParams{
161 ID: p.PRID,
162 State: issuesdb.IssueStateClosed,
163 StateReason: issuesdb.NullIssueStateReason{IssueStateReason: issuesdb.IssueStateReasonCompleted, Valid: true},
164 ClosedByUserID: pgtype.Int8{Int64: p.ActorUserID, Valid: p.ActorUserID != 0},
165 }); err != nil {
166 return err
167 }
168
169 // `merged` timeline event.
170 if _, err := iq.InsertIssueEvent(ctx, tx, issuesdb.InsertIssueEventParams{
171 IssueID: p.PRID,
172 ActorUserID: pgtype.Int8{Int64: p.ActorUserID, Valid: p.ActorUserID != 0},
173 Kind: "merged",
174 Meta: []byte(fmt.Sprintf(`{"method":%q,"commit":%q}`, p.Method, mergeRes.MergedOID)),
175 }); err != nil {
176 return err
177 }
178
179 // Auto-close linked issues. Linked = Closes/Fixes/Resolves #N in
180 // the PR body + each commit message. Best-effort; don't fail the
181 // merge if the close fails.
182 linked := parseLinkedIssues(issue.Body)
183 for _, c := range fetchCommitsForLinkScan(ctx, tx, p.PRID) {
184 linked = append(linked, parseLinkedIssues(c)...)
185 }
186 closed := map[int64]bool{}
187 for _, num := range linked {
188 if num == issue.Number {
189 continue // self-reference
190 }
191 target, err := iq.GetIssueByNumber(ctx, tx, issuesdb.GetIssueByNumberParams{
192 RepoID: issue.RepoID, Number: num,
193 })
194 if err != nil || target.Kind != issuesdb.IssueKindIssue || target.State != issuesdb.IssueStateOpen {
195 continue
196 }
197 if closed[target.ID] {
198 continue
199 }
200 closed[target.ID] = true
201 _ = iq.SetIssueState(ctx, tx, issuesdb.SetIssueStateParams{
202 ID: target.ID,
203 State: issuesdb.IssueStateClosed,
204 StateReason: issuesdb.NullIssueStateReason{IssueStateReason: issuesdb.IssueStateReasonCompleted, Valid: true},
205 ClosedByUserID: pgtype.Int8{Int64: p.ActorUserID, Valid: p.ActorUserID != 0},
206 })
207 _, _ = iq.InsertIssueEvent(ctx, tx, issuesdb.InsertIssueEventParams{
208 IssueID: target.ID,
209 ActorUserID: pgtype.Int8{Int64: p.ActorUserID, Valid: p.ActorUserID != 0},
210 Kind: "closed",
211 Meta: []byte(fmt.Sprintf(`{"closed_by_pr":%d}`, p.PRID)),
212 })
213 }
214
215 if err := tx.Commit(ctx); err != nil {
216 return err
217 }
218 committed = true
219 return nil
220 }
221
222 // identityFor reads the user's display + primary verified email for
223 // commit identity. Falls back to a synthesised noreply on missing data
224 // rather than failing the merge — privacy/noreply emails ship post-MVP.
225 func identityFor(ctx context.Context, db pullsdb.DBTX, uq *usersdb.Queries, userID int64) (name, email string, err error) {
226 user, err := uq.GetUserByID(ctx, db, userID)
227 if err != nil {
228 return "", "", err
229 }
230 display := strings.TrimSpace(user.DisplayName)
231 if display == "" {
232 display = user.Username
233 }
234 addr := user.Username + "@noreply.shithub.local"
235 if user.PrimaryEmailID.Valid {
236 em, err := uq.GetUserEmailByID(ctx, db, user.PrimaryEmailID.Int64)
237 if err == nil && em.Verified {
238 addr = string(em.Email)
239 }
240 }
241 return display, addr, nil
242 }
243
244 // fetchCommitsForLinkScan returns the head-side commit messages so the
245 // linked-issue parser can scan body + subject. Best-effort — returns
246 // an empty slice on error.
247 func fetchCommitsForLinkScan(ctx context.Context, db pullsdb.DBTX, prID int64) []string {
248 rows, err := pullsdb.New().ListPullRequestCommits(ctx, db, prID)
249 if err != nil {
250 return nil
251 }
252 out := make([]string, 0, len(rows))
253 for _, r := range rows {
254 out = append(out, r.Subject+"\n\n"+r.Body)
255 }
256 return out
257 }
258
259 // Compile-time check that issues' typed errors are still in scope so
260 // EditPR's wrapping continues to work after refactors.
261 var _ = errors.Is(issues.ErrEmptyTitle, issues.ErrEmptyTitle)
262