main
rb 191 lines 8.3 KB
Raw
1 # frozen_string_literal: true
2
3 require "rails_helper"
4 require "tmpdir"
5 require "fileutils"
6
7 RSpec.describe PullRequestService do
8 let(:owner) { User.create!(smbcloud_id: 7001, email: "owner@example.com", username: "owner") }
9 let(:outsider) { User.create!(smbcloud_id: 7002, email: "outsider@example.com", username: "outsider") }
10 let(:repo) do
11 owner.repositories.create!(
12 name: "app", kind: "code", default_branch: "main",
13 disk_path: GitRepositoryService.repo_path("owner", "app")
14 )
15 end
16
17 before do
18 # Branch existence is a git-layer concern; stub it so the service logic is
19 # tested without an on-disk repo.
20 allow(GitRepositoryService).to receive(:branch_exists?).and_return(true)
21 end
22
23 it "opens a pull request authored by the repo owner" do
24 pr = described_class.create(
25 repository: repo, author: owner, title: "Add feature", head: "feature", base: "main"
26 )
27
28 expect(pr).to be_persisted
29 expect(pr.number).to eq(1)
30 expect(pr.state).to eq("open")
31 expect(pr.author).to eq(owner)
32 end
33
34 it "refuses a user who cannot write the repo" do
35 expect do
36 described_class.create(repository: repo, author: outsider, title: "X", head: "feature", base: "main")
37 end.to raise_error(PullRequestService::NotAuthorized)
38 end
39
40 it "rejects a missing branch" do
41 allow(GitRepositoryService).to receive(:branch_exists?).with(repo.disk_path, "feature").and_return(false)
42 allow(GitRepositoryService).to receive(:branch_exists?).with(repo.disk_path, "main").and_return(true)
43
44 expect do
45 described_class.create(repository: repo, author: owner, title: "X", head: "feature", base: "main")
46 end.to raise_error(PullRequestService::Error, /Branch not found: feature/)
47 end
48
49 it "rejects identical head and base via model validation" do
50 expect do
51 described_class.create(repository: repo, author: owner, title: "X", head: "main", base: "main")
52 end.to raise_error(ActiveRecord::RecordInvalid, /different from the base/)
53 end
54
55 it "shares one number space with issues" do
56 repo.issues.create!(user: owner, number: 1, title: "Existing issue")
57 pr = described_class.create(repository: repo, author: owner, title: "PR", head: "feature", base: "main")
58 expect(pr.number).to eq(2)
59 end
60
61 # Merge/close/reopen exercise real git plumbing, so these need an actual
62 # repo on disk rather than the stubbed branch_exists? above.
63 describe "merge/close/reopen" do
64 around do |example|
65 Dir.mktmpdir("pr-service-spec") do |tmp|
66 @repo_dir = File.join(tmp, "app.git")
67 build_repo(@repo_dir)
68 example.run
69 end
70 end
71
72 let(:repo_on_disk) do
73 owner.repositories.create!(name: "app", kind: "code", default_branch: "main", disk_path: @repo_dir)
74 end
75
76 it "merges an open PR and marks it merged" do
77 pr = repo_on_disk.pull_requests.create!(user: owner, number: 1, title: "Add feature", head_ref: "feature", base_ref: "main")
78
79 sha = described_class.merge(pull_request: pr, merger: owner)
80
81 expect(sha).to be_present
82 expect(pr.reload.state).to eq("merged")
83 expect(pr.merged_at).to be_present
84 expect(GitRepositoryService.commit_sha(@repo_dir, "main")).to eq(sha)
85 end
86
87 it "refuses to merge for a non-owner" do
88 pr = repo_on_disk.pull_requests.create!(user: owner, number: 1, title: "X", head_ref: "feature", base_ref: "main")
89
90 expect do
91 described_class.merge(pull_request: pr, merger: outsider)
92 end.to raise_error(PullRequestService::NotAuthorized)
93 expect(pr.reload.state).to eq("open")
94 end
95
96 it "refuses to merge a conflicting branch" do
97 pr = repo_on_disk.pull_requests.create!(user: owner, number: 1, title: "X", head_ref: "conflict-b", base_ref: "conflict-a")
98
99 expect do
100 described_class.merge(pull_request: pr, merger: owner)
101 end.to raise_error(PullRequestService::Error, /conflicts/)
102 expect(pr.reload.state).to eq("open")
103 end
104
105 it "refuses to merge a PR that isn't open" do
106 pr = repo_on_disk.pull_requests.create!(user: owner, number: 1, title: "X", head_ref: "feature", base_ref: "main", state: "closed")
107
108 expect do
109 described_class.merge(pull_request: pr, merger: owner)
110 end.to raise_error(PullRequestService::Error, /already closed/)
111 end
112
113 it "closes an open PR for the owner" do
114 pr = repo_on_disk.pull_requests.create!(user: owner, number: 1, title: "X", head_ref: "feature", base_ref: "main")
115 described_class.close(pull_request: pr, actor: owner)
116 expect(pr.reload.state).to eq("closed")
117 end
118
119 it "closes an open PR for its own author even if not the repo owner" do
120 # PullRequestService.create enforces author == owner, but the model
121 # itself doesn't, so this covers the "or author" branch directly.
122 pr = repo_on_disk.pull_requests.create!(user: outsider, number: 1, title: "X", head_ref: "feature", base_ref: "main")
123 described_class.close(pull_request: pr, actor: outsider)
124 expect(pr.reload.state).to eq("closed")
125 end
126
127 it "refuses to close for an unrelated user" do
128 pr = repo_on_disk.pull_requests.create!(user: owner, number: 1, title: "X", head_ref: "feature", base_ref: "main")
129 stranger = User.create!(smbcloud_id: 7003, email: "stranger@example.com", username: "stranger")
130
131 expect do
132 described_class.close(pull_request: pr, actor: stranger)
133 end.to raise_error(PullRequestService::NotAuthorized)
134 end
135
136 it "won't close an already-merged PR" do
137 pr = repo_on_disk.pull_requests.create!(user: owner, number: 1, title: "X", head_ref: "feature", base_ref: "main", state: "merged")
138 expect do
139 described_class.close(pull_request: pr, actor: owner)
140 end.to raise_error(PullRequestService::Error, /can't be closed/)
141 end
142
143 it "reopens a closed PR" do
144 pr = repo_on_disk.pull_requests.create!(user: owner, number: 1, title: "X", head_ref: "feature", base_ref: "main", state: "closed")
145 described_class.reopen(pull_request: pr, actor: owner)
146 expect(pr.reload.state).to eq("open")
147 end
148
149 it "won't reopen a merged PR" do
150 pr = repo_on_disk.pull_requests.create!(user: owner, number: 1, title: "X", head_ref: "feature", base_ref: "main", state: "merged")
151 expect do
152 described_class.reopen(pull_request: pr, actor: owner)
153 end.to raise_error(PullRequestService::Error, /Only closed/)
154 end
155
156 def build_repo(bare_path)
157 FileUtils.mkdir_p(bare_path)
158 system("git", "init", "--bare", bare_path, exception: true)
159 Dir.mktmpdir do |work|
160 system("git", "-C", work, "init", "-b", "main", exception: true)
161 system("git", "-C", work, "config", "user.email", "t@example.com", exception: true)
162 system("git", "-C", work, "config", "user.name", "Test", exception: true)
163 File.write(File.join(work, "README.md"), "# Demo\n")
164 system("git", "-C", work, "add", ".", exception: true)
165 system("git", "-C", work, "commit", "-m", "seed", exception: true)
166 system("git", "-C", work, "remote", "add", "origin", bare_path, exception: true)
167 system("git", "-C", work, "push", "origin", "main", exception: true)
168
169 system("git", "-C", work, "checkout", "-b", "feature", exception: true)
170 File.write(File.join(work, "feature.txt"), "hi\n")
171 system("git", "-C", work, "add", ".", exception: true)
172 system("git", "-C", work, "commit", "-m", "feature commit", exception: true)
173 system("git", "-C", work, "push", "origin", "feature", exception: true)
174
175 system("git", "-C", work, "checkout", "main", exception: true)
176 system("git", "-C", work, "checkout", "-b", "conflict-a", exception: true)
177 File.write(File.join(work, "shared.txt"), "a\n")
178 system("git", "-C", work, "add", ".", exception: true)
179 system("git", "-C", work, "commit", "-m", "conflict a", exception: true)
180 system("git", "-C", work, "push", "origin", "conflict-a", exception: true)
181
182 system("git", "-C", work, "checkout", "main", exception: true)
183 system("git", "-C", work, "checkout", "-b", "conflict-b", exception: true)
184 File.write(File.join(work, "shared.txt"), "b\n")
185 system("git", "-C", work, "add", ".", exception: true)
186 system("git", "-C", work, "commit", "-m", "conflict b", exception: true)
187 system("git", "-C", work, "push", "origin", "conflict-b", exception: true)
188 end
189 end
190 end
191 end