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