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