| 1 | # frozen_string_literal: true |
| 2 | |
| 3 | # Creates pull requests. All PR creation — web UI or MCP — goes through here so |
| 4 | # authorization, branch validation, and per-repo numbering behave identically. |
| 5 | class PullRequestService |
| 6 | class Error < StandardError; end |
| 7 | class NotAuthorized < Error; end |
| 8 | |
| 9 | # Opens a pull request from +head+ into +base+ on +repository+, authored by |
| 10 | # +author+. Raises NotAuthorized if the author can't write the repo, Error if a |
| 11 | # branch is missing, and ActiveRecord::RecordInvalid on validation failure. |
| 12 | def self.create(repository:, author:, title:, head:, base:, body: nil) |
| 13 | unless repository.writable_by?(author) |
| 14 | raise NotAuthorized, "You don't have permission to open a pull request on #{repository.full_name}." |
| 15 | end |
| 16 | |
| 17 | [ head, base ].each do |ref| |
| 18 | next if GitRepositoryService.branch_exists?(repository.disk_path, ref) |
| 19 | raise Error, "Branch not found: #{ref}" |
| 20 | end |
| 21 | |
| 22 | repository.with_lock do |
| 23 | repository.pull_requests.create!( |
| 24 | user: author, |
| 25 | # Issues and pull requests share one per-repo number space (as on |
| 26 | # GitHub); Repository#next_issue_number is the single allocator. |
| 27 | number: repository.next_issue_number, |
| 28 | title: title, |
| 29 | head_ref: head, |
| 30 | base_ref: base, |
| 31 | body: body.to_s, |
| 32 | state: "open" |
| 33 | ) |
| 34 | end |
| 35 | end |
| 36 | |
| 37 | # Merges +pull_request+'s head into its base as a new merge commit. Only the |
| 38 | # repo owner may merge (siGit repos have a single owner, so this is the same |
| 39 | # check as opening one). Raises Error if the PR isn't open, a branch has |
| 40 | # disappeared, the head is already merged, or the merge has conflicts. |
| 41 | def self.merge(pull_request:, merger:) |
| 42 | repository = pull_request.repository |
| 43 | unless repository.writable_by?(merger) |
| 44 | raise NotAuthorized, "You don't have permission to merge pull requests on #{repository.full_name}." |
| 45 | end |
| 46 | raise Error, "This pull request is already #{pull_request.state}." unless pull_request.open? |
| 47 | |
| 48 | base_sha = GitRepositoryService.commit_sha(repository.disk_path, pull_request.base_ref) |
| 49 | head_sha = GitRepositoryService.commit_sha(repository.disk_path, pull_request.head_ref) |
| 50 | raise Error, "One of the branches no longer exists." unless base_sha && head_sha |
| 51 | raise Error, "This branch is already up to date with #{pull_request.base_ref}." if base_sha == head_sha |
| 52 | |
| 53 | message = "Merge pull request ##{pull_request.number} from #{pull_request.head_ref}\n\n#{pull_request.title}" |
| 54 | merge_sha = GitRepositoryService.merge!( |
| 55 | repository.disk_path, |
| 56 | base: pull_request.base_ref, head: pull_request.head_ref, message: message, |
| 57 | author_name: merger.username, author_email: merger.email |
| 58 | ) |
| 59 | raise Error, "This pull request has conflicts and can't be merged automatically." unless merge_sha |
| 60 | |
| 61 | pull_request.update!(state: "merged", merged_at: Time.current) |
| 62 | merge_sha |
| 63 | end |
| 64 | |
| 65 | # Closes +pull_request+ without merging. Allowed for the repo owner or the |
| 66 | # PR's own author. |
| 67 | def self.close(pull_request:, actor:) |
| 68 | authorize_modify!(pull_request, actor) |
| 69 | raise Error, "This pull request is already closed." if pull_request.closed? |
| 70 | raise Error, "Merged pull requests can't be closed." if pull_request.merged? |
| 71 | pull_request.update!(state: "closed") |
| 72 | end |
| 73 | |
| 74 | # Reopens a closed (not merged — that's permanent, like GitHub) pull request. |
| 75 | def self.reopen(pull_request:, actor:) |
| 76 | authorize_modify!(pull_request, actor) |
| 77 | raise Error, "Only closed pull requests can be reopened." unless pull_request.closed? |
| 78 | pull_request.update!(state: "open") |
| 79 | end |
| 80 | |
| 81 | def self.authorize_modify!(pull_request, actor) |
| 82 | repository = pull_request.repository |
| 83 | return if repository.writable_by?(actor) || pull_request.user_id == actor&.id |
| 84 | raise NotAuthorized, "You don't have permission to modify this pull request." |
| 85 | end |
| 86 | private_class_method :authorize_modify! |
| 87 | end |