Go · 12096 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 // Package pulls owns the pull-request orchestrator. PRs reuse the S21
4 // `issues` row for title/body/state/timeline; this package owns the
5 // PR-specific surface — opening, synchronizing, mergeability detection,
6 // merge execution.
7 //
8 // Entry points are:
9 //
10 // Create — opens a PR (creates the issue row + the pull_requests row)
11 // Synchronize — refreshes commit + file lists + emits a synchronized event
12 // Mergeability — recomputes mergeable / mergeable_state via merge-tree
13 // Merge — performs the requested merge strategy in a temp worktree
14 // Edit — title/body
15 // SetState — close / reopen
16 // SetReady — draft → ready
17 package pulls
18
19 import (
20 "context"
21 "errors"
22 "fmt"
23 "log/slog"
24 "strings"
25
26 "github.com/jackc/pgx/v5"
27 "github.com/jackc/pgx/v5/pgtype"
28 "github.com/jackc/pgx/v5/pgxpool"
29
30 "github.com/tenseleyFlow/shithub/internal/issues"
31 issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
32 pullsdb "github.com/tenseleyFlow/shithub/internal/pulls/sqlc"
33 repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
34 mdrender "github.com/tenseleyFlow/shithub/internal/repos/markdown"
35 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
36 )
37
38 // Deps wires this package against the rest of the runtime.
39 type Deps struct {
40 Pool *pgxpool.Pool
41 Logger *slog.Logger
42 }
43
44 // Errors surfaced to handlers.
45 var (
46 ErrSameBranch = errors.New("pulls: base and head must differ")
47 ErrBaseNotFound = errors.New("pulls: base ref not found")
48 ErrHeadNotFound = errors.New("pulls: head ref not found")
49 ErrNoCommitsToMerge = errors.New("pulls: head has no commits ahead of base")
50 ErrAlreadyMerged = errors.New("pulls: already merged")
51 ErrAlreadyClosed = errors.New("pulls: already closed")
52 ErrMergeBlocked = errors.New("pulls: merge blocked (mergeable_state != clean)")
53 ErrMergeMethodOff = errors.New("pulls: requested merge method is disabled on this repo")
54 ErrConcurrentMerge = errors.New("pulls: PR is being merged by another request")
55 ErrPRNotFound = errors.New("pulls: PR not found")
56 )
57
58 // CreateParams describes a new-PR request.
59 type CreateParams struct {
60 RepoID int64
61 AuthorUserID int64
62 Title string
63 Body string
64 BaseRef string
65 HeadRef string
66 Draft bool
67 GitDir string // resolved from RepoFS by the caller
68 }
69
70 // CreateResult bundles the issue row + the PR row (post-snapshot).
71 type CreateResult struct {
72 Issue issuesdb.Issue
73 PullRequest pullsdb.PullRequest
74 }
75
76 // Create opens a PR. Validates that base/head are distinct and resolve
77 // in the on-disk repo, snapshots their OIDs, then creates the issues
78 // row + pull_requests row in one tx. Mergeability is `unknown` until
79 // the worker job ticks.
80 func Create(ctx context.Context, deps Deps, p CreateParams) (CreateResult, error) {
81 base := strings.TrimSpace(p.BaseRef)
82 head := strings.TrimSpace(p.HeadRef)
83 if base == "" || head == "" || base == head {
84 return CreateResult{}, ErrSameBranch
85 }
86 baseOID, err := repogit.ResolveRefOID(ctx, p.GitDir, base)
87 if err != nil {
88 if errors.Is(err, repogit.ErrRefNotFound) {
89 return CreateResult{}, ErrBaseNotFound
90 }
91 return CreateResult{}, fmt.Errorf("resolve base: %w", err)
92 }
93 headOID, err := repogit.ResolveRefOID(ctx, p.GitDir, head)
94 if err != nil {
95 if errors.Is(err, repogit.ErrRefNotFound) {
96 return CreateResult{}, ErrHeadNotFound
97 }
98 return CreateResult{}, fmt.Errorf("resolve head: %w", err)
99 }
100 if baseOID == headOID {
101 return CreateResult{}, ErrNoCommitsToMerge
102 }
103
104 // Open the issues row first via the issues orchestrator so we get
105 // the per-repo number allocation, body markdown render, and
106 // reference indexing for free.
107 issueRow, err := issues.Create(ctx, issues.Deps{Pool: deps.Pool, Logger: deps.Logger}, issues.CreateParams{
108 RepoID: p.RepoID,
109 AuthorUserID: p.AuthorUserID,
110 Title: p.Title,
111 Body: p.Body,
112 Kind: "pr",
113 })
114 if err != nil {
115 return CreateResult{}, err
116 }
117
118 prRow, err := pullsdb.New().CreatePullRequest(ctx, deps.Pool, pullsdb.CreatePullRequestParams{
119 IssueID: issueRow.ID,
120 BaseRef: base,
121 HeadRef: head,
122 HeadRepoID: p.RepoID,
123 BaseOid: baseOID,
124 HeadOid: headOID,
125 Draft: p.Draft,
126 })
127 if err != nil {
128 return CreateResult{}, fmt.Errorf("create pull_request: %w", err)
129 }
130
131 // Best-effort initial synchronize so the PR view has commits + files
132 // even before the worker queue runs. Failures here don't fail the
133 // open — the worker will retry on the next tick.
134 if err := refreshCommitsAndFiles(ctx, deps, p.GitDir, prRow.IssueID, baseOID, headOID); err != nil {
135 if deps.Logger != nil {
136 deps.Logger.WarnContext(ctx, "pulls: initial sync", "error", err, "pr_id", prRow.IssueID)
137 }
138 }
139
140 return CreateResult{Issue: issueRow, PullRequest: prRow}, nil
141 }
142
143 // refreshCommitsAndFiles is shared by Create + Synchronize. Truncates +
144 // re-fills `pull_request_commits` and `pull_request_files`.
145 func refreshCommitsAndFiles(ctx context.Context, deps Deps, gitDir string, prID int64, baseOID, headOID string) error {
146 commits, err := repogit.CommitsBetweenDetail(ctx, gitDir, baseOID, headOID, 250)
147 if err != nil {
148 return fmt.Errorf("commits: %w", err)
149 }
150 files, err := repogit.FilesChangedBetween(ctx, gitDir, baseOID, headOID)
151 if err != nil {
152 return fmt.Errorf("files: %w", err)
153 }
154 tx, err := deps.Pool.Begin(ctx)
155 if err != nil {
156 return err
157 }
158 committed := false
159 defer func() {
160 if !committed {
161 _ = tx.Rollback(ctx)
162 }
163 }()
164 q := pullsdb.New()
165 if err := q.ClearPullRequestCommits(ctx, tx, prID); err != nil {
166 return err
167 }
168 if err := q.ClearPullRequestFiles(ctx, tx, prID); err != nil {
169 return err
170 }
171 for i, c := range commits {
172 var ats, cts pgtype.Timestamptz
173 if !c.AuthorWhen.IsZero() {
174 ats = pgtype.Timestamptz{Time: c.AuthorWhen, Valid: true}
175 }
176 if !c.CommitterWhen.IsZero() {
177 cts = pgtype.Timestamptz{Time: c.CommitterWhen, Valid: true}
178 }
179 if err := q.InsertPullRequestCommit(ctx, tx, pullsdb.InsertPullRequestCommitParams{
180 PrID: prID,
181 Sha: c.OID,
182 Position: int32(i),
183 AuthorName: c.AuthorName,
184 AuthorEmail: c.AuthorEmail,
185 CommitterName: c.CommitterName,
186 CommitterEmail: c.CommitterEmail,
187 Subject: c.Subject,
188 Body: c.Body,
189 AuthoredAt: ats,
190 CommittedAt: cts,
191 }); err != nil {
192 return err
193 }
194 }
195 for _, f := range files {
196 oldPath := pgtype.Text{}
197 if f.OldPath != "" {
198 oldPath = pgtype.Text{String: f.OldPath, Valid: true}
199 }
200 if err := q.InsertPullRequestFile(ctx, tx, pullsdb.InsertPullRequestFileParams{
201 PrID: prID,
202 Path: f.Path,
203 Status: pullsdb.PrFileStatus(f.Status),
204 OldPath: oldPath,
205 Additions: int32(f.Additions),
206 Deletions: int32(f.Deletions),
207 Changes: int32(f.Additions + f.Deletions),
208 }); err != nil {
209 return err
210 }
211 }
212 if err := q.SetPullRequestSnapshot(ctx, tx, pullsdb.SetPullRequestSnapshotParams{
213 IssueID: prID, BaseOid: baseOID, HeadOid: headOID,
214 }); err != nil {
215 return err
216 }
217 if err := tx.Commit(ctx); err != nil {
218 return err
219 }
220 committed = true
221 return nil
222 }
223
224 // Synchronize re-snapshots the PR's base/head OIDs, refreshes the
225 // commits + files lists, and emits a `synchronized` event into the
226 // issue timeline. Called from the pr:synchronize worker job after
227 // any push to the head ref.
228 func Synchronize(ctx context.Context, deps Deps, gitDir string, prID int64) error {
229 q := pullsdb.New()
230 pr, err := q.GetPullRequestByIssueID(ctx, deps.Pool, prID)
231 if err != nil {
232 if errors.Is(err, pgx.ErrNoRows) {
233 return ErrPRNotFound
234 }
235 return err
236 }
237 baseOID, err := repogit.ResolveRefOID(ctx, gitDir, pr.BaseRef)
238 if err != nil {
239 return fmt.Errorf("resolve base: %w", err)
240 }
241 headOID, err := repogit.ResolveRefOID(ctx, gitDir, pr.HeadRef)
242 if err != nil {
243 return fmt.Errorf("resolve head: %w", err)
244 }
245 if err := refreshCommitsAndFiles(ctx, deps, gitDir, prID, baseOID, headOID); err != nil {
246 return err
247 }
248 // Reset mergeability to unknown so the next mergeability tick
249 // recomputes against the fresh snapshot.
250 if err := q.SetPullRequestMergeability(ctx, deps.Pool, pullsdb.SetPullRequestMergeabilityParams{
251 IssueID: prID,
252 Mergeable: pgtype.Bool{},
253 MergeableState: pullsdb.PrMergeableStateUnknown,
254 }); err != nil {
255 return fmt.Errorf("reset mergeability: %w", err)
256 }
257 // Emit the synchronized timeline event.
258 iq := issuesdb.New()
259 if _, err := iq.InsertIssueEvent(ctx, deps.Pool, issuesdb.InsertIssueEventParams{
260 IssueID: prID,
261 Kind: "synchronized",
262 Meta: []byte(fmt.Sprintf(`{"head_oid":%q}`, headOID)),
263 }); err != nil {
264 return fmt.Errorf("emit event: %w", err)
265 }
266 return nil
267 }
268
269 // Mergeability runs the merge-tree probe and persists the result.
270 func Mergeability(ctx context.Context, deps Deps, gitDir string, prID int64) error {
271 q := pullsdb.New()
272 pr, err := q.GetPullRequestByIssueID(ctx, deps.Pool, prID)
273 if err != nil {
274 return err
275 }
276 if pr.BaseOid == "" || pr.HeadOid == "" {
277 return nil // synchronize hasn't run yet; nothing to probe
278 }
279 // Behind: head has no commits ahead of base.
280 gitDirCtx := ctx
281 commits, err := repogit.CommitsBetweenDetail(gitDirCtx, gitDir, pr.BaseOid, pr.HeadOid, 1)
282 if err != nil && !errors.Is(err, repogit.ErrRefNotFound) {
283 return err
284 }
285 if len(commits) == 0 {
286 return q.SetPullRequestMergeability(ctx, deps.Pool, pullsdb.SetPullRequestMergeabilityParams{
287 IssueID: prID,
288 Mergeable: pgtype.Bool{Bool: false, Valid: true},
289 MergeableState: pullsdb.PrMergeableStateBehind,
290 })
291 }
292 res, err := repogit.ProbeMerge(gitDirCtx, gitDir, pr.BaseOid, pr.HeadOid)
293 if err != nil {
294 return fmt.Errorf("probe: %w", err)
295 }
296 state := pullsdb.PrMergeableStateClean
297 mergeable := true
298 if res.HasConflict {
299 state = pullsdb.PrMergeableStateDirty
300 mergeable = false
301 }
302 return q.SetPullRequestMergeability(ctx, deps.Pool, pullsdb.SetPullRequestMergeabilityParams{
303 IssueID: prID,
304 Mergeable: pgtype.Bool{Bool: mergeable, Valid: true},
305 MergeableState: state,
306 })
307 }
308
309 // EditPR updates the PR's title + body. Body markdown is re-rendered
310 // via the same pipeline issues.Create uses so HTML is consistent.
311 func EditPR(ctx context.Context, deps Deps, prID int64, title, body string) error {
312 title = strings.TrimSpace(title)
313 if title == "" {
314 return issues.ErrEmptyTitle
315 }
316 if len(title) > 256 {
317 return issues.ErrTitleTooLong
318 }
319 if len(body) > 65535 {
320 return issues.ErrBodyTooLong
321 }
322 html, _ := mdrender.RenderHTML([]byte(body))
323 q := issuesdb.New()
324 return q.UpdateIssueTitleBody(ctx, deps.Pool, issuesdb.UpdateIssueTitleBodyParams{
325 ID: prID,
326 Title: title,
327 Body: body,
328 BodyHtmlCached: pgtype.Text{String: html, Valid: html != ""},
329 })
330 }
331
332 // SetReady flips draft → false and emits a `ready_for_review` event.
333 func SetReady(ctx context.Context, deps Deps, actorUserID, prID int64) error {
334 q := pullsdb.New()
335 tx, err := deps.Pool.Begin(ctx)
336 if err != nil {
337 return err
338 }
339 committed := false
340 defer func() {
341 if !committed {
342 _ = tx.Rollback(ctx)
343 }
344 }()
345 if err := q.SetPullRequestDraft(ctx, tx, pullsdb.SetPullRequestDraftParams{IssueID: prID, Draft: false}); err != nil {
346 return err
347 }
348 iq := issuesdb.New()
349 if _, err := iq.InsertIssueEvent(ctx, tx, issuesdb.InsertIssueEventParams{
350 IssueID: prID,
351 ActorUserID: pgtype.Int8{Int64: actorUserID, Valid: actorUserID != 0},
352 Kind: "ready_for_review",
353 Meta: []byte("{}"),
354 }); err != nil {
355 return err
356 }
357 if err := tx.Commit(ctx); err != nil {
358 return err
359 }
360 committed = true
361 return nil
362 }
363
364 // AllowedMethod returns true when the repo allows the named merge
365 // strategy. Falls open for unknown methods so callers get a clear
366 // error from the orchestrator.
367 func AllowedMethod(repo reposdb.Repo, method string) bool {
368 switch method {
369 case "merge":
370 return repo.AllowMergeCommit
371 case "squash":
372 return repo.AllowSquashMerge
373 case "rebase":
374 return repo.AllowRebaseMerge
375 }
376 return false
377 }
378