feat(pull_requests): add web UI for pull request management
Only a creation-only backend existed (PullRequestService, exposed over MCP) with no web surface. Adds the git plumbing to merge branches without a working tree (bare repos), close/reopen/merge lifecycle in the service layer, and a full web UI: list, create, view (commits + diff + comments), and merge/close/reopen actions gated by repo ownership. Adds a Pulls tab with an open-count badge across the repo browsing pages, and extracts the diff-rendering partial shared with the commit view.
Seto Elkahfi committed
Jul 5, 2026 at 11:50 UTC
5c95ab458ac720a9aeb8978b101b1fded61ef904
20 files changed
+1108
-82
app/assets/stylesheets/application.tailwind.css
+5
-2
@@ -71,8 +71,11 @@
71
.badge {
72
@apply inline-flex items-center px-2 py-0.5 rounded text-xs font-medium;
73
}
74
- .badge-gray { @apply badge bg-surface-600 text-gray-300; }
75
- .badge-blue { @apply badge bg-brand-500/10 text-brand-400; }
74
+ .badge-gray { @apply badge bg-surface-600 text-gray-300; }
75
+ .badge-blue { @apply badge bg-brand-500/10 text-brand-400; }
76
+ .badge-green { @apply badge bg-green-900/30 text-green-400; }
77
+ .badge-red { @apply badge bg-red-900/30 text-red-400; }
78
+ .badge-purple { @apply badge bg-purple-900/30 text-purple-400; }
79
80
.tab-item {
81
@apply px-4 py-2.5 text-sm font-medium text-gray-500 border-b-2 border-transparent
app/controllers/pull_requests_controller.rb
new
+120
@@ -0,0 +1,120 @@
1
+class PullRequestsController < ApplicationController
2
+ helper_method :render_markdown
3
+
4
+ before_action :load_owner
5
+ before_action :load_repository
6
+ before_action :ensure_can_read!
7
+ before_action :require_sign_in!, only: %i[new create close reopen merge add_comment]
8
+ before_action :load_pull_request, only: %i[show close reopen merge add_comment]
9
+
10
+ def index
11
+ @state = %w[open closed merged all].include?(params[:state]) ? params[:state] : "open"
12
+ scope = @repository.pull_requests
13
+ scope = scope.where(state: @state) unless @state == "all"
14
+ @pull_requests = scope.order(number: :desc).includes(:user)
15
+
16
+ @open_count = @repository.pull_requests.open.count
17
+ @closed_count = @repository.pull_requests.closed.count
18
+ @merged_count = @repository.pull_requests.merged.count
19
+ end
20
+
21
+ def new
22
+ @branches = GitRepositoryService.branches(@repository.disk_path)
23
+ @pull_request = @repository.pull_requests.new(
24
+ head_ref: params[:head], base_ref: params[:base].presence || @repository.default_branch
25
+ )
26
+ end
27
+
28
+ def create
29
+ pr_params = params.require(:pull_request).permit(:title, :body, :head_ref, :base_ref)
30
+ @pull_request = PullRequestService.create(
31
+ repository: @repository, author: current_user,
32
+ title: pr_params[:title], head: pr_params[:head_ref], base: pr_params[:base_ref], body: pr_params[:body]
33
+ )
34
+ redirect_to repository_pull_request_path(@owner.username, @repository.name, @pull_request.number),
35
+ notice: "Pull request ##{@pull_request.number} opened."
36
+ rescue PullRequestService::NotAuthorized => e
37
+ redirect_to repository_path(@owner.username, @repository.name), alert: e.message
38
+ rescue PullRequestService::Error, ActiveRecord::RecordInvalid => e
39
+ @branches = GitRepositoryService.branches(@repository.disk_path)
40
+ @pull_request = @repository.pull_requests.new(pr_params)
41
+ @pull_request.errors.add(:base,
42
+ e.is_a?(ActiveRecord::RecordInvalid) ? e.record.errors.full_messages.to_sentence : e.message)
43
+ render :new, status: :unprocessable_entity
44
+ end
45
+
46
+ def show
47
+ @comments = @pull_request.comments.includes(:user).order(:created_at)
48
+ @commits = GitRepositoryService.commits_between(@repository.disk_path, @pull_request.base_ref, @pull_request.head_ref)
49
+ @diff = GitRepositoryService.diff(@repository.disk_path, @pull_request.base_ref, @pull_request.head_ref)
50
+ @mergeable = @pull_request.open? &&
51
+ GitRepositoryService.mergeable?(@repository.disk_path, @pull_request.base_ref, @pull_request.head_ref)
52
+ end
53
+
54
+ def add_comment
55
+ CommentService.create(
56
+ repository: @repository, author: current_user, number: @pull_request.number, body: params[:body]
57
+ )
58
+ redirect_to repository_pull_request_path(@owner.username, @repository.name, @pull_request.number)
59
+ rescue ActiveRecord::RecordInvalid => e
60
+ redirect_to repository_pull_request_path(@owner.username, @repository.name, @pull_request.number),
61
+ alert: e.record.errors.full_messages.to_sentence
62
+ end
63
+
64
+ def close
65
+ PullRequestService.close(pull_request: @pull_request, actor: current_user)
66
+ redirect_to repository_pull_request_path(@owner.username, @repository.name, @pull_request.number),
67
+ notice: "Pull request closed."
68
+ rescue PullRequestService::Error => e
69
+ redirect_to repository_pull_request_path(@owner.username, @repository.name, @pull_request.number), alert: e.message
70
+ end
71
+
72
+ def reopen
73
+ PullRequestService.reopen(pull_request: @pull_request, actor: current_user)
74
+ redirect_to repository_pull_request_path(@owner.username, @repository.name, @pull_request.number),
75
+ notice: "Pull request reopened."
76
+ rescue PullRequestService::Error => e
77
+ redirect_to repository_pull_request_path(@owner.username, @repository.name, @pull_request.number), alert: e.message
78
+ end
79
+
80
+ def merge
81
+ PullRequestService.merge(pull_request: @pull_request, merger: current_user)
82
+ redirect_to repository_pull_request_path(@owner.username, @repository.name, @pull_request.number),
83
+ notice: "Pull request merged."
84
+ rescue PullRequestService::Error => e
85
+ redirect_to repository_pull_request_path(@owner.username, @repository.name, @pull_request.number), alert: e.message
86
+ end
87
+
88
+ private
89
+
90
+ # PR/comment bodies are free-standing prose, not tied to a file path, so
91
+ # they render without the repo/ref/dir context RepositoriesController's
92
+ # README rendering resolves relative links against.
93
+ def render_markdown(text)
94
+ MarkdownRenderer.render(text.to_s).html_safe
95
+ end
96
+
97
+ def load_pull_request
98
+ @pull_request = @repository.pull_requests.find_by!(number: params[:number])
99
+ rescue ActiveRecord::RecordNotFound
100
+ render file: Rails.public_path.join("404.html"), status: :not_found, layout: false
101
+ end
102
+
103
+ def load_owner
104
+ @owner = User.find_by!(username: params[:username])
105
+ rescue ActiveRecord::RecordNotFound
106
+ render file: Rails.public_path.join("404.html"), status: :not_found, layout: false
107
+ end
108
+
109
+ def load_repository
110
+ @repository = @owner.repositories.find_by!(name: params[:repository])
111
+ rescue ActiveRecord::RecordNotFound
112
+ render file: Rails.public_path.join("404.html"), status: :not_found, layout: false
113
+ end
114
+
115
+ def ensure_can_read!
116
+ unless !@repository.is_private || (signed_in? && current_user == @owner)
117
+ render file: Rails.public_path.join("404.html"), status: :not_found, layout: false
118
+ end
119
+ end
120
+end
app/models/pull_request.rb
+12
@@ -25,6 +25,18 @@ class PullRequest < ApplicationRecord
25
user
26
end
27
28
+ def open?
29
+ state == "open"
30
+ end
31
+
32
+ def closed?
33
+ state == "closed"
34
+ end
35
+
36
+ def merged?
37
+ state == "merged"
38
+ end
39
+
40
def html_url
41
"#{repository.html_url}/pull/#{number}"
42
end
app/models/repository.rb
+4
@@ -66,6 +66,10 @@ class Repository < ApplicationRecord
66
disk_path
67
end
68
69
+ def open_pull_requests_count
70
+ pull_requests.open.count
71
+ end
72
+
73
def initialized?
74
return false if git_path.blank?
75
Dir.exist?(git_path) && File.exist?(File.join(git_path, "HEAD"))
app/services/git_repository_service.rb
+81
@@ -256,4 +256,85 @@ class GitRepositoryService
256
return [] unless status.success?
257
out.lines.map(&:strip).reject(&:blank?)
258
end
259
+
260
+ # Commits reachable from +head+ but not from +base+ — the commit list a pull
261
+ # request shows (same semantics as `git log base..head`).
262
+ def self.commits_between(path, base, head, limit: 250)
263
+ return [] unless safe_rev?(base) && safe_rev?(head)
264
+ format = "%H%x00%h%x00%s%x00%an%x00%ae%x00%ad%x00%cn"
265
+ out, _err, status = Open3.capture3(
266
+ "git", "--git-dir", path, "log",
267
+ "--format=#{format}", "--date=iso-strict",
268
+ "-n", limit.to_s,
269
+ "#{base}..#{head}"
270
+ )
271
+ return [] unless status.success?
272
+ out.lines.filter_map do |line|
273
+ parts = line.chomp.split("\x00")
274
+ next unless parts.length == 7
275
+ sha, short_sha, subject, author_name, author_email, authored_date, committer_name = parts
276
+ {
277
+ sha: sha,
278
+ short_sha: short_sha,
279
+ subject: subject,
280
+ author_name: author_name,
281
+ author_email: author_email,
282
+ authored_date: Time.parse(authored_date),
283
+ committer_name: committer_name
284
+ }
285
+ end
286
+ end
287
+
288
+ # Combined patch for everything +head+ changes relative to +base+, diffed
289
+ # against their merge base ("triple-dot" semantics, the same a PR/compare
290
+ # view uses) so commits landed on +base+ after the branches diverged don't
291
+ # show up as changes.
292
+ def self.diff(path, base, head)
293
+ return "" unless safe_rev?(base) && safe_rev?(head)
294
+ out, _err, status = Open3.capture3("git", "--git-dir", path, "diff", "#{base}...#{head}")
295
+ status.success? ? out : ""
296
+ end
297
+
298
+ # True if +head+ can be merged into +base+ without conflicts. Uses
299
+ # `merge-tree --write-tree`, which computes the merge without touching any
300
+ # working tree or index — safe to run directly against a bare repo.
301
+ def self.mergeable?(path, base, head)
302
+ return false unless safe_rev?(base) && safe_rev?(head)
303
+ _out, _err, status = Open3.capture3("git", "--git-dir", path, "merge-tree", "--write-tree", base, head)
304
+ status.success?
305
+ end
306
+
307
+ # Merges +head+ into +base+ as a new merge commit (always --no-ff), purely
308
+ # via plumbing so no working tree is needed — the repos behind the app are
309
+ # bare. Returns the new commit SHA, or nil if either ref is missing, the
310
+ # merge has conflicts, or +base+ moved underneath us (a concurrent push, so
311
+ # the compare-and-swap ref update was rejected).
312
+ def self.merge!(path, base:, head:, message:, author_name:, author_email:)
313
+ return nil unless safe_rev?(base) && safe_rev?(head)
314
+
315
+ base_sha = commit_sha(path, base)
316
+ head_sha = commit_sha(path, head)
317
+ return nil unless base_sha && head_sha
318
+
319
+ tree_out, _err, tree_status = Open3.capture3(
320
+ "git", "--git-dir", path, "merge-tree", "--write-tree", base_sha, head_sha
321
+ )
322
+ return nil unless tree_status.success?
323
+ tree_sha = tree_out.lines.first.to_s.strip
324
+
325
+ env = {
326
+ "GIT_AUTHOR_NAME" => author_name, "GIT_AUTHOR_EMAIL" => author_email,
327
+ "GIT_COMMITTER_NAME" => author_name, "GIT_COMMITTER_EMAIL" => author_email
328
+ }
329
+ commit_out, _err, commit_status = Open3.capture3(
330
+ env, "git", "--git-dir", path, "commit-tree", tree_sha, "-p", base_sha, "-p", head_sha, "-m", message
331
+ )
332
+ return nil unless commit_status.success?
333
+ merge_sha = commit_out.strip
334
+
335
+ _out, _err, update_status = Open3.capture3(
336
+ "git", "--git-dir", path, "update-ref", "refs/heads/#{base}", merge_sha, base_sha
337
+ )
338
+ update_status.success? ? merge_sha : nil
339
+ end
340
end
app/services/pull_request_service.rb
+51
@@ -39,4 +39,55 @@ class PullRequestService
39
repository.issues.maximum(:number) || 0 ].max + 1
40
end
41
private_class_method :next_number
42
+
43
+ # Merges +pull_request+'s head into its base as a new merge commit. Only the
44
+ # repo owner may merge (siGit repos have a single owner, so this is the same
45
+ # check as opening one). Raises Error if the PR isn't open, a branch has
46
+ # disappeared, the head is already merged, or the merge has conflicts.
47
+ def self.merge(pull_request:, merger:)
48
+ repository = pull_request.repository
49
+ unless repository.writable_by?(merger)
50
+ raise NotAuthorized, "You don't have permission to merge pull requests on #{repository.full_name}."
51
+ end
52
+ raise Error, "This pull request is already #{pull_request.state}." unless pull_request.open?
53
+
54
+ base_sha = GitRepositoryService.commit_sha(repository.disk_path, pull_request.base_ref)
55
+ head_sha = GitRepositoryService.commit_sha(repository.disk_path, pull_request.head_ref)
56
+ raise Error, "One of the branches no longer exists." unless base_sha && head_sha
57
+ raise Error, "This branch is already up to date with #{pull_request.base_ref}." if base_sha == head_sha
58
+
59
+ message = "Merge pull request ##{pull_request.number} from #{pull_request.head_ref}\n\n#{pull_request.title}"
60
+ merge_sha = GitRepositoryService.merge!(
61
+ repository.disk_path,
62
+ base: pull_request.base_ref, head: pull_request.head_ref, message: message,
63
+ author_name: merger.username, author_email: merger.email
64
+ )
65
+ raise Error, "This pull request has conflicts and can't be merged automatically." unless merge_sha
66
+
67
+ pull_request.update!(state: "merged", merged_at: Time.current)
68
+ merge_sha
69
+ end
70
+
71
+ # Closes +pull_request+ without merging. Allowed for the repo owner or the
72
+ # PR's own author.
73
+ def self.close(pull_request:, actor:)
74
+ authorize_modify!(pull_request, actor)
75
+ raise Error, "This pull request is already closed." if pull_request.closed?
76
+ raise Error, "Merged pull requests can't be closed." if pull_request.merged?
77
+ pull_request.update!(state: "closed")
78
+ end
79
+
80
+ # Reopens a closed (not merged — that's permanent, like GitHub) pull request.
81
+ def self.reopen(pull_request:, actor:)
82
+ authorize_modify!(pull_request, actor)
83
+ raise Error, "Only closed pull requests can be reopened." unless pull_request.closed?
84
+ pull_request.update!(state: "open")
85
+ end
86
+
87
+ def self.authorize_modify!(pull_request, actor)
88
+ repository = pull_request.repository
89
+ return if repository.writable_by?(actor) || pull_request.user_id == actor&.id
90
+ raise NotAuthorized, "You don't have permission to modify this pull request."
91
+ end
92
+ private_class_method :authorize_modify!
93
end
app/views/blobs/show.html.erb
+10
@@ -32,6 +32,16 @@
32
</svg>
33
Commits
34
<% end %>
35
+ <%= link_to repository_pull_requests_path(@owner.username, @repository.name),
36
+ class: "tab-item flex items-center gap-1.5" do %>
37
+ <svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
38
+ <path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM12 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/>
39
+ </svg>
40
+ Pulls
41
+ <% if @repository.open_pull_requests_count > 0 %>
42
+ <span class="badge-gray text-xs"><%= number_with_delimiter(@repository.open_pull_requests_count) %></span>
43
+ <% end %>
44
+ <% end %>
45
<%= link_to repository_cicd_path(@owner.username, @repository.name),
46
class: "tab-item flex items-center gap-1.5" do %>
47
<svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
app/views/commits/index.html.erb
+10
@@ -33,6 +33,16 @@
33
Commits
34
<span class="badge-gray text-xs"><%= number_with_delimiter(@total) %></span>
35
</div>
36
+ <%= link_to repository_pull_requests_path(@owner.username, @repository.name),
37
+ class: "tab-item flex items-center gap-1.5" do %>
38
+ <svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
39
+ <path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM12 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/>
40
+ </svg>
41
+ Pulls
42
+ <% if @repository.open_pull_requests_count > 0 %>
43
+ <span class="badge-gray text-xs"><%= number_with_delimiter(@repository.open_pull_requests_count) %></span>
44
+ <% end %>
45
+ <% end %>
46
<%= link_to repository_cicd_path(@owner.username, @repository.name),
47
class: "tab-item flex items-center gap-1.5" do %>
48
<svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
app/views/commits/show.html.erb
+1
-80
@@ -30,84 +30,5 @@
30
</div>
31
32
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-6 space-y-3">
33
- <%
34
- file_diffs = []
35
- current = nil
36
-
37
- @commit[:diff].to_s.each_line do |line|
38
- if line.start_with?("diff --git ")
39
- file_diffs << current if current
40
- current = { header: line, lines: [] }
41
- elsif current
42
- current[:lines] << line
43
- end
44
- end
45
- file_diffs << current if current
46
- %>
47
-
48
- <% if file_diffs.empty? %>
49
- <p class="text-sm text-gray-400">No diff available for this commit.</p>
50
- <% else %>
51
- <%
52
- total_additions = 0
53
- total_deletions = 0
54
- file_diffs.each do |fd|
55
- total_additions += fd[:lines].count { |l| l.start_with?("+") && !l.start_with?("+++") }
56
- total_deletions += fd[:lines].count { |l| l.start_with?("-") && !l.start_with?("---") }
57
- end
58
- %>
59
-
60
- <div class="text-xs text-gray-400 mb-4">
61
- <span class="text-gray-300 font-medium"><%= file_diffs.size %> <%= "file".pluralize(file_diffs.size) %> changed</span>
62
- <% if total_additions > 0 %>
63
- <span class="text-green-400 ml-2">+<%= total_additions %></span>
64
- <% end %>
65
- <% if total_deletions > 0 %>
66
- <span class="text-red-400 ml-1">-<%= total_deletions %></span>
67
- <% end %>
68
- </div>
69
-
70
- <% file_diffs.each do |fd| %>
71
- <%
72
- m = fd[:header].match(/diff --git a\/(.*) b\/(.*)/)
73
- filename = m ? m[2].chomp : fd[:header].chomp
74
- additions = fd[:lines].count { |l| l.start_with?("+") && !l.start_with?("+++") }
75
- deletions = fd[:lines].count { |l| l.start_with?("-") && !l.start_with?("---") }
76
- %>
77
-
78
- <details class="card overflow-hidden group" open>
79
- <summary class="flex items-center justify-between px-4 py-2.5 border-b border-surface-600 bg-surface-700 cursor-pointer list-none hover:bg-surface-600 transition-colors">
80
- <div class="flex items-center gap-2 min-w-0">
81
- <svg class="w-3.5 h-3.5 text-gray-500 shrink-0 transition-transform group-open:rotate-90" viewBox="0 0 16 16" fill="currentColor">
82
- <path d="M6.22 3.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 0 1 0-1.06Z"/>
83
- </svg>
84
- <span class="font-mono text-xs text-gray-200 truncate"><%= filename %></span>
85
- </div>
86
- <div class="flex items-center gap-2 shrink-0 ml-4 font-mono text-xs">
87
- <% if additions > 0 %>
88
- <span class="text-green-400">+<%= additions %></span>
89
- <% end %>
90
- <% if deletions > 0 %>
91
- <span class="text-red-400">-<%= deletions %></span>
92
- <% end %>
93
- </div>
94
- </summary>
95
-
96
- <div class="font-mono text-xs leading-5 overflow-x-auto">
97
- <% fd[:lines].each do |line| %>
98
- <% css = if line.start_with?("+") && !line.start_with?("+++")
99
- "diff-add block px-4"
100
- elsif line.start_with?("-") && !line.start_with?("---")
101
- "diff-remove block px-4"
102
- elsif line.start_with?("@@")
103
- "diff-header block px-4"
104
- else
105
- "block px-4 text-gray-500"
106
- end %>
107
- <span class="<%= css %>"><%= line.chomp %></span>
108
- <% end %>
109
- </div>
110
- </details>
111
- <% end %>
112
- <% end %>
33
+ <%= render "shared/diff", diff: @commit[:diff] %>
34
</div>
app/views/pull_requests/index.html.erb
new
+98
@@ -0,0 +1,98 @@
1
+<% content_for :title, "Pull requests · #{@repository.full_name}" %>
2
+
3
+<div class="border-b border-surface-600 bg-surface-800">
4
+ <div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 pb-0">
5
+ <div class="flex items-center gap-2 text-sm mb-4">
6
+ <svg class="w-4 h-4 text-gray-500" viewBox="0 0 16 16" fill="currentColor">
7
+ <path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8Z"/>
8
+ </svg>
9
+ <%= link_to "@#{@owner.username}", user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %>
10
+ <span class="text-gray-500">/</span>
11
+ <%= link_to @repository.name, repository_path(@owner.username, @repository.name), class: "text-brand-500 hover:underline font-semibold" %>
12
+ <% if @repository.is_private %>
13
+ <span class="badge-gray ml-1">Private</span>
14
+ <% end %>
15
+ </div>
16
+
17
+ <div class="flex items-center gap-0 -mb-px">
18
+ <%= link_to repository_path(@owner.username, @repository.name),
19
+ class: "tab-item flex items-center gap-1.5" do %>
20
+ <svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
21
+ <path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8Z"/>
22
+ </svg>
23
+ Code
24
+ <% end %>
25
+ <% if @repository.initialized? %>
26
+ <%= link_to repository_commits_path(@owner.username, @repository.name, @repository.default_branch),
27
+ class: "tab-item flex items-center gap-1.5" do %>
28
+ <svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
29
+ <path d="M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/>
30
+ </svg>
31
+ Commits
32
+ <% end %>
33
+ <% end %>
34
+ <div class="tab-item active flex items-center gap-1.5">
35
+ <svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
36
+ <path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM12 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/>
37
+ </svg>
38
+ Pulls
39
+ <% if @repository.open_pull_requests_count > 0 %>
40
+ <span class="badge-gray text-xs"><%= number_with_delimiter(@repository.open_pull_requests_count) %></span>
41
+ <% end %>
42
+ </div>
43
+ <%= link_to repository_cicd_path(@owner.username, @repository.name),
44
+ class: "tab-item flex items-center gap-1.5" do %>
45
+ <svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
46
+ <path d="M4.75 4.5C2.679 4.5 1 5.958 1 7.75S2.679 11 4.75 11c1.028 0 1.948-.36 2.75-1.242l.652-.716.696.672C10.023 10.849 10.956 11 11.25 11 13.321 11 15 9.542 15 7.75S13.321 4.5 11.25 4.5c-1.028 0-1.948.36-2.75 1.242l-.652.716-.696-.672C5.977 4.651 5.044 4.5 4.75 4.5Zm0 1.5c.483 0 .943.152 1.36.556l.562.541-.562.618C5.693 8.119 5.233 8.5 4.75 8.5c-1.241 0-2.25-.672-2.25-1.5S3.509 6 4.75 6Zm6.5 0c1.241 0 2.25.672 2.25 1.5S12.491 9 11.25 9c-.483 0-.943-.152-1.36-.556l-.562-.541.562-.618c.417-.404.877-.785 1.36-.785Z"/>
47
+ </svg>
48
+ CI/CD
49
+ <% end %>
50
+ </div>
51
+ </div>
52
+</div>
53
+
54
+<div class="max-w-6xl mx-auto px-4 sm:px-6 py-6">
55
+ <div class="flex items-center justify-between mb-4">
56
+ <div class="flex items-center gap-1 text-sm">
57
+ <% [["open", "Open", @open_count], ["closed", "Closed", @closed_count], ["merged", "Merged", @merged_count], ["all", "All", nil]].each do |state, label, count| %>
58
+ <%= link_to repository_pull_requests_path(@owner.username, @repository.name, state: state),
59
+ class: "px-3 py-1.5 rounded font-medium #{@state == state ? 'bg-surface-600 text-gray-100' : 'text-gray-400 hover:text-gray-200'}" do %>
60
+ <%= label %><% if count %> <span class="text-xs text-gray-500">(<%= count %>)</span><% end %>
61
+ <% end %>
62
+ <% end %>
63
+ </div>
64
+ <% if @repository.writable_by?(current_user) %>
65
+ <%= link_to "New pull request", new_repository_pull_request_path(@owner.username, @repository.name), class: "btn-primary" %>
66
+ <% end %>
67
+ </div>
68
+
69
+ <div class="card">
70
+ <% @pull_requests.each do |pr| %>
71
+ <div class="commit-row">
72
+ <div class="min-w-0 flex-1">
73
+ <p class="text-sm font-medium text-gray-100 truncate">
74
+ <%= link_to pr.title, repository_pull_request_path(@owner.username, @repository.name, pr.number), class: "hover:text-brand-500" %>
75
+ </p>
76
+ <p class="text-xs text-gray-400 mt-0.5">
77
+ #<%= pr.number %> opened by <span class="font-medium"><%= pr.author.username %></span>
78
+ · <span class="font-mono"><%= pr.head_ref %></span> → <span class="font-mono"><%= pr.base_ref %></span>
79
+ </p>
80
+ </div>
81
+ <div class="shrink-0 ml-4">
82
+ <% case pr.state
83
+ when "open" %>
84
+ <span class="badge-green">Open</span>
85
+ <% when "merged" %>
86
+ <span class="badge-purple">Merged</span>
87
+ <% else %>
88
+ <span class="badge-red">Closed</span>
89
+ <% end %>
90
+ </div>
91
+ </div>
92
+ <% end %>
93
+
94
+ <% if @pull_requests.empty? %>
95
+ <div class="px-4 py-12 text-center text-sm text-gray-400">No pull requests here.</div>
96
+ <% end %>
97
+ </div>
98
+</div>
app/views/pull_requests/new.html.erb
new
+55
@@ -0,0 +1,55 @@
1
+<% content_for :title, "New pull request · #{@repository.full_name}" %>
2
+
3
+<div class="max-w-2xl mx-auto px-4 sm:px-6 py-12">
4
+ <div class="flex items-center gap-2 text-sm mb-2">
5
+ <%= link_to "@#{@owner.username}", user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %>
6
+ <span class="text-gray-500">/</span>
7
+ <%= link_to @repository.name, repository_path(@owner.username, @repository.name), class: "text-brand-500 hover:underline font-semibold" %>
8
+ <span class="text-gray-500">/</span>
9
+ <%= link_to "pulls", repository_pull_requests_path(@owner.username, @repository.name), class: "text-gray-400 hover:text-gray-200 transition-colors" %>
10
+ </div>
11
+ <h1 class="text-xl font-semibold text-gray-100 mb-2">New pull request</h1>
12
+ <p class="text-sm text-gray-400 mb-8">Propose merging one branch into another in this repository.</p>
13
+
14
+ <% if @pull_request.errors.any? %>
15
+ <div class="border border-red-800/40 bg-red-900/20 rounded px-4 py-3 mb-6">
16
+ <p class="text-sm font-medium text-red-300 mb-1">Please fix the following errors:</p>
17
+ <ul class="list-disc pl-4 space-y-0.5">
18
+ <% @pull_request.errors.full_messages.each do |msg| %>
19
+ <li class="text-sm text-red-400"><%= msg %></li>
20
+ <% end %>
21
+ </ul>
22
+ </div>
23
+ <% end %>
24
+
25
+ <%= form_with model: @pull_request, url: repository_pull_requests_path(@owner.username, @repository.name),
26
+ method: :post, class: "space-y-6" do |f| %>
27
+ <div class="flex items-end gap-2">
28
+ <div class="flex-1 min-w-0">
29
+ <label class="form-label">base</label>
30
+ <%= f.select :base_ref, @branches, { selected: @pull_request.base_ref }, class: "form-input font-mono" %>
31
+ </div>
32
+ <div class="flex items-center pb-2.5 text-gray-500">←</div>
33
+ <div class="flex-1 min-w-0">
34
+ <label class="form-label">compare</label>
35
+ <%= f.select :head_ref, @branches, { selected: @pull_request.head_ref }, class: "form-input font-mono" %>
36
+ </div>
37
+ </div>
38
+
39
+ <div>
40
+ <%= f.label :title, class: "form-label" %>
41
+ <%= f.text_field :title, class: "form-input", placeholder: "Short summary of the change", autofocus: true %>
42
+ </div>
43
+
44
+ <div>
45
+ <%= f.label :body, "Description", class: "form-label" %>
46
+ <span class="text-xs text-gray-500 ml-1">(optional, Markdown)</span>
47
+ <%= f.text_area :body, class: "form-input", rows: 6, placeholder: "What does this change do, and why?" %>
48
+ </div>
49
+
50
+ <div class="pt-2 border-t border-surface-600 flex items-center gap-3">
51
+ <%= f.submit "Create pull request", class: "btn-primary cursor-pointer" %>
52
+ <%= link_to "Cancel", repository_pull_requests_path(@owner.username, @repository.name), class: "btn-ghost" %>
53
+ </div>
54
+ <% end %>
55
+</div>
app/views/pull_requests/show.html.erb
new
+134
@@ -0,0 +1,134 @@
1
+<% content_for :title, "#{@pull_request.title} (##{@pull_request.number}) · #{@repository.full_name}" %>
2
+
3
+<div class="border-b border-surface-600 bg-surface-800">
4
+ <div class="max-w-4xl mx-auto px-4 sm:px-6 pt-6 pb-4">
5
+ <div class="flex items-center gap-2 text-sm mb-4">
6
+ <%= link_to "@#{@owner.username}", user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %>
7
+ <span class="text-gray-500">/</span>
8
+ <%= link_to @repository.name, repository_path(@owner.username, @repository.name), class: "text-brand-500 hover:underline font-semibold" %>
9
+ <span class="text-gray-500">/</span>
10
+ <%= link_to "pulls", repository_pull_requests_path(@owner.username, @repository.name), class: "text-gray-400 hover:text-gray-200 transition-colors" %>
11
+ <span class="text-gray-500">/</span>
12
+ <span class="text-gray-400">#<%= @pull_request.number %></span>
13
+ </div>
14
+
15
+ <h1 class="text-xl font-semibold text-gray-100 mb-2"><%= @pull_request.title %>
16
+ <span class="text-gray-500 font-normal">#<%= @pull_request.number %></span>
17
+ </h1>
18
+
19
+ <div class="flex flex-wrap items-center gap-2 text-sm">
20
+ <% case @pull_request.state
21
+ when "open" %>
22
+ <span class="badge-green">Open</span>
23
+ <% when "merged" %>
24
+ <span class="badge-purple">Merged</span>
25
+ <% else %>
26
+ <span class="badge-red">Closed</span>
27
+ <% end %>
28
+ <span class="text-gray-400">
29
+ <span class="font-medium text-gray-300"><%= @pull_request.author.username %></span> wants to merge
30
+ <span class="font-mono badge-gray"><%= @pull_request.head_ref %></span>
31
+ into
32
+ <span class="font-mono badge-gray"><%= @pull_request.base_ref %></span>
33
+ </span>
34
+ </div>
35
+ </div>
36
+</div>
37
+
38
+<div class="max-w-4xl mx-auto px-4 sm:px-6 py-6 space-y-6">
39
+ <% if @pull_request.body.present? %>
40
+ <div class="card">
41
+ <div class="px-6 py-6 prose-readme"><%= render_markdown(@pull_request.body) %></div>
42
+ </div>
43
+ <% end %>
44
+
45
+ <% if @pull_request.open? && @repository.writable_by?(current_user) %>
46
+ <div class="card px-4 py-3 flex items-center justify-between gap-3
47
+ <%= @mergeable ? 'bg-green-900/10 border-green-800/30' : 'bg-amber-900/10 border-amber-800/30' %>">
48
+ <% if @mergeable %>
49
+ <p class="text-sm text-green-300">This branch has no conflicts with the base branch.</p>
50
+ <%= button_to "Merge pull request", merge_repository_pull_request_path(@owner.username, @repository.name, @pull_request.number),
51
+ method: :post, class: "btn-primary cursor-pointer",
52
+ form: { data: { turbo_confirm: "Merge ##{@pull_request.number} into #{@pull_request.base_ref}?" } } %>
53
+ <% else %>
54
+ <p class="text-sm text-amber-300">This branch has conflicts and can't be merged automatically.</p>
55
+ <% end %>
56
+ </div>
57
+ <% end %>
58
+
59
+ <div>
60
+ <h2 class="text-sm font-medium text-gray-300 mb-2">
61
+ <%= pluralize(@commits.size, "commit") %>
62
+ </h2>
63
+ <div class="card">
64
+ <% @commits.each do |commit| %>
65
+ <div class="commit-row">
66
+ <div class="min-w-0 flex-1">
67
+ <p class="text-sm font-medium text-gray-100 truncate">
68
+ <%= link_to commit[:subject], repository_commit_path(@owner.username, @repository.name, commit[:sha]), class: "hover:text-brand-500" %>
69
+ </p>
70
+ <p class="text-xs text-gray-400 mt-0.5">
71
+ <span class="font-medium"><%= commit[:author_name] %></span> committed <%= time_ago_in_words(commit[:authored_date]) %> ago
72
+ </p>
73
+ </div>
74
+ <div class="shrink-0 ml-4">
75
+ <%= link_to commit[:short_sha], repository_commit_path(@owner.username, @repository.name, commit[:sha]),
76
+ class: "font-mono text-xs badge-gray hover:bg-surface-500" %>
77
+ </div>
78
+ </div>
79
+ <% end %>
80
+ <% if @commits.empty? %>
81
+ <div class="px-4 py-6 text-center text-sm text-gray-400">No commits.</div>
82
+ <% end %>
83
+ </div>
84
+ </div>
85
+
86
+ <div>
87
+ <h2 class="text-sm font-medium text-gray-300 mb-2">Files changed</h2>
88
+ <%= render "shared/diff", diff: @diff %>
89
+ </div>
90
+
91
+ <div>
92
+ <h2 class="text-sm font-medium text-gray-300 mb-2">
93
+ <%= pluralize(@comments.size, "comment") %>
94
+ </h2>
95
+ <div class="space-y-3">
96
+ <% @comments.each do |comment| %>
97
+ <div class="card" id="comment-<%= comment.id %>">
98
+ <div class="px-4 py-2 border-b border-surface-600 bg-surface-700 text-xs text-gray-400">
99
+ <span class="font-medium text-gray-200"><%= comment.author.username %></span>
100
+ commented <%= time_ago_in_words(comment.created_at) %> ago
101
+ </div>
102
+ <div class="px-6 py-4 prose-readme"><%= render_markdown(comment.body) %></div>
103
+ </div>
104
+ <% end %>
105
+ </div>
106
+
107
+ <% if signed_in? %>
108
+ <%= form_with url: repository_pull_request_comments_path(@owner.username, @repository.name, @pull_request.number),
109
+ method: :post, class: "mt-4 space-y-2" do |f| %>
110
+ <%= f.label :body, "Add a comment", class: "form-label" %>
111
+ <%= f.text_area :body, class: "form-input", rows: 4, placeholder: "Leave a comment", required: true %>
112
+ <div class="flex justify-end">
113
+ <%= f.submit "Comment", class: "btn-primary cursor-pointer" %>
114
+ </div>
115
+ <% end %>
116
+ <% else %>
117
+ <p class="text-sm text-gray-400 mt-4">
118
+ <%= link_to "Sign in", signin_path, class: "text-brand-500 hover:underline" %> to leave a comment.
119
+ </p>
120
+ <% end %>
121
+ </div>
122
+
123
+ <% if @repository.writable_by?(current_user) || @pull_request.user_id == current_user&.id %>
124
+ <div class="pt-2 border-t border-surface-600 flex items-center gap-3">
125
+ <% if @pull_request.open? %>
126
+ <%= button_to "Close pull request", close_repository_pull_request_path(@owner.username, @repository.name, @pull_request.number),
127
+ method: :post, class: "btn-secondary cursor-pointer" %>
128
+ <% elsif @pull_request.closed? %>
129
+ <%= button_to "Reopen pull request", reopen_repository_pull_request_path(@owner.username, @repository.name, @pull_request.number),
130
+ method: :post, class: "btn-secondary cursor-pointer" %>
131
+ <% end %>
132
+ </div>
133
+ <% end %>
134
+</div>
app/views/repositories/cicd.html.erb
+10
@@ -41,6 +41,16 @@
41
<% end %>
42
<% end %>
43
<% end %>
44
+ <%= link_to repository_pull_requests_path(@owner.username, @repository.name),
45
+ class: "tab-item flex items-center gap-1.5" do %>
46
+ <svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
47
+ <path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM12 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/>
48
+ </svg>
49
+ Pulls
50
+ <% if @repository.open_pull_requests_count > 0 %>
51
+ <span class="badge-gray text-xs"><%= number_with_delimiter(@repository.open_pull_requests_count) %></span>
52
+ <% end %>
53
+ <% end %>
54
<div class="tab-item active flex items-center gap-1.5">
55
<svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
56
<path d="M4.75 4.5C2.679 4.5 1 5.958 1 7.75S2.679 11 4.75 11c1.028 0 1.948-.36 2.75-1.242l.652-.716.696.672C10.023 10.849 10.956 11 11.25 11 13.321 11 15 9.542 15 7.75S13.321 4.5 11.25 4.5c-1.028 0-1.948.36-2.75 1.242l-.652.716-.696-.672C5.977 4.651 5.044 4.5 4.75 4.5Zm0 1.5c.483 0 .943.152 1.36.556l.562.541-.562.618C5.693 8.119 5.233 8.5 4.75 8.5c-1.241 0-2.25-.672-2.25-1.5S3.509 6 4.75 6Zm6.5 0c1.241 0 2.25.672 2.25 1.5S12.491 9 11.25 9c-.483 0-.943-.152-1.36-.556l-.562-.541.562-.618c.417-.404.877-.785 1.36-.785Z"/>
app/views/repositories/show.html.erb
+10
@@ -44,6 +44,16 @@
44
<% end %>
45
<% end %>
46
<% end %>
47
+ <%= link_to repository_pull_requests_path(@owner.username, @repository.name),
48
+ class: "tab-item flex items-center gap-1.5" do %>
49
+ <svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
50
+ <path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM12 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/>
51
+ </svg>
52
+ Pulls
53
+ <% if @repository.open_pull_requests_count > 0 %>
54
+ <span class="badge-gray text-xs"><%= number_with_delimiter(@repository.open_pull_requests_count) %></span>
55
+ <% end %>
56
+ <% end %>
57
<%= link_to repository_cicd_path(@owner.username, @repository.name),
58
class: "tab-item flex items-center gap-1.5" do %>
59
<svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
app/views/repositories/tree.html.erb
+10
@@ -44,6 +44,16 @@
44
</svg>
45
Commits
46
<% end %>
47
+ <%= link_to repository_pull_requests_path(@owner.username, @repository.name),
48
+ class: "tab-item flex items-center gap-1.5" do %>
49
+ <svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
50
+ <path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM12 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/>
51
+ </svg>
52
+ Pulls
53
+ <% if @repository.open_pull_requests_count > 0 %>
54
+ <span class="badge-gray text-xs"><%= number_with_delimiter(@repository.open_pull_requests_count) %></span>
55
+ <% end %>
56
+ <% end %>
57
<%= link_to repository_cicd_path(@owner.username, @repository.name),
58
class: "tab-item flex items-center gap-1.5" do %>
59
<svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
app/views/shared/_diff.html.erb
new
+83
@@ -0,0 +1,83 @@
1
+<%# locals: diff (raw unified-diff text) %>
2
+<%
3
+ file_diffs = []
4
+ current = nil
5
+
6
+ diff.to_s.each_line do |line|
7
+ if line.start_with?("diff --git ")
8
+ file_diffs << current if current
9
+ current = { header: line, lines: [] }
10
+ elsif current
11
+ current[:lines] << line
12
+ end
13
+ end
14
+ file_diffs << current if current
15
+%>
16
+
17
+<% if file_diffs.empty? %>
18
+ <p class="text-sm text-gray-400">No changes.</p>
19
+<% else %>
20
+ <%
21
+ total_additions = 0
22
+ total_deletions = 0
23
+ file_diffs.each do |fd|
24
+ total_additions += fd[:lines].count { |l| l.start_with?("+") && !l.start_with?("+++") }
25
+ total_deletions += fd[:lines].count { |l| l.start_with?("-") && !l.start_with?("---") }
26
+ end
27
+ %>
28
+
29
+ <div class="text-xs text-gray-400 mb-4">
30
+ <span class="text-gray-300 font-medium"><%= file_diffs.size %> <%= "file".pluralize(file_diffs.size) %> changed</span>
31
+ <% if total_additions > 0 %>
32
+ <span class="text-green-400 ml-2">+<%= total_additions %></span>
33
+ <% end %>
34
+ <% if total_deletions > 0 %>
35
+ <span class="text-red-400 ml-1">-<%= total_deletions %></span>
36
+ <% end %>
37
+ </div>
38
+
39
+ <div class="space-y-3">
40
+ <% file_diffs.each do |fd| %>
41
+ <%
42
+ m = fd[:header].match(/diff --git a\/(.*) b\/(.*)/)
43
+ filename = m ? m[2].chomp : fd[:header].chomp
44
+ additions = fd[:lines].count { |l| l.start_with?("+") && !l.start_with?("+++") }
45
+ deletions = fd[:lines].count { |l| l.start_with?("-") && !l.start_with?("---") }
46
+ %>
47
+
48
+ <details class="card overflow-hidden group" open>
49
+ <summary class="flex items-center justify-between px-4 py-2.5 border-b border-surface-600 bg-surface-700 cursor-pointer list-none hover:bg-surface-600 transition-colors">
50
+ <div class="flex items-center gap-2 min-w-0">
51
+ <svg class="w-3.5 h-3.5 text-gray-500 shrink-0 transition-transform group-open:rotate-90" viewBox="0 0 16 16" fill="currentColor">
52
+ <path d="M6.22 3.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 0 1 0-1.06Z"/>
53
+ </svg>
54
+ <span class="font-mono text-xs text-gray-200 truncate"><%= filename %></span>
55
+ </div>
56
+ <div class="flex items-center gap-2 shrink-0 ml-4 font-mono text-xs">
57
+ <% if additions > 0 %>
58
+ <span class="text-green-400">+<%= additions %></span>
59
+ <% end %>
60
+ <% if deletions > 0 %>
61
+ <span class="text-red-400">-<%= deletions %></span>
62
+ <% end %>
63
+ </div>
64
+ </summary>
65
+
66
+ <div class="font-mono text-xs leading-5 overflow-x-auto">
67
+ <% fd[:lines].each do |line| %>
68
+ <% css = if line.start_with?("+") && !line.start_with?("+++")
69
+ "diff-add block px-4"
70
+ elsif line.start_with?("-") && !line.start_with?("---")
71
+ "diff-remove block px-4"
72
+ elsif line.start_with?("@@")
73
+ "diff-header block px-4"
74
+ else
75
+ "block px-4 text-gray-500"
76
+ end %>
77
+ <span class="<%= css %>"><%= line.chomp %></span>
78
+ <% end %>
79
+ </div>
80
+ </details>
81
+ <% end %>
82
+ </div>
83
+<% end %>
config/routes.rb
+16
@@ -155,6 +155,22 @@ Rails.application.routes.draw do
155
constraints: { branch: /[^\/]+/, repository: /[^\/.][^\/]*/ }
156
get "/:username/:repository/ci-cd", to: "repositories#cicd", as: :repository_cicd
157
get "/:username/:repository/commit/:sha", to: "commits#show", as: :repository_commit
158
+
159
+ # Pull requests — creation-only backend already existed (PullRequestService,
160
+ # exposed over MCP); these add the web surface on top of it.
161
+ get "/:username/:repository/pulls", to: "pull_requests#index", as: :repository_pull_requests
162
+ get "/:username/:repository/pulls/new", to: "pull_requests#new", as: :new_repository_pull_request
163
+ post "/:username/:repository/pulls", to: "pull_requests#create"
164
+ get "/:username/:repository/pull/:number", to: "pull_requests#show", as: :repository_pull_request,
165
+ constraints: { number: /\d+/ }
166
+ post "/:username/:repository/pull/:number/comments", to: "pull_requests#add_comment",
167
+ as: :repository_pull_request_comments, constraints: { number: /\d+/ }
168
+ post "/:username/:repository/pull/:number/close", to: "pull_requests#close", as: :close_repository_pull_request,
169
+ constraints: { number: /\d+/ }
170
+ post "/:username/:repository/pull/:number/reopen", to: "pull_requests#reopen", as: :reopen_repository_pull_request,
171
+ constraints: { number: /\d+/ }
172
+ post "/:username/:repository/pull/:number/merge", to: "pull_requests#merge", as: :merge_repository_pull_request,
173
+ constraints: { number: /\d+/ }
174
get "/:username/:repository/blob/:branch/*path", to: "blobs#show", as: :repository_blob, format: false
175
get "/:username/:repository/raw/:branch/*path", to: "blobs#raw", as: :repository_blob_raw, format: false
176
get "/:username/:repository/tree/:branch/*path", to: "repositories#tree", as: :repository_tree
spec/requests/pull_requests_spec.rb
new
+143
@@ -0,0 +1,143 @@
1
+# frozen_string_literal: true
2
+
3
+require "rails_helper"
4
+require "tmpdir"
5
+require "fileutils"
6
+
7
+RSpec.describe "Pull requests", type: :request do
8
+ around do |example|
9
+ Dir.mktmpdir("pull-requests-spec") do |tmp|
10
+ @repo_dir = File.join(tmp, "demo.git")
11
+ build_repo(@repo_dir)
12
+ example.run
13
+ end
14
+ end
15
+
16
+ let(:owner) { User.create!(smbcloud_id: 9001, email: "pr-owner@example.com", username: "prowner") }
17
+ let(:outsider) { User.create!(smbcloud_id: 9002, email: "pr-outsider@example.com", username: "proutsider") }
18
+ let!(:repo) do
19
+ owner.repositories.create!(name: "demo", kind: "code", default_branch: "main", disk_path: @repo_dir)
20
+ end
21
+
22
+ # Sign-in runs through smbCloud's native gem in production, so request specs
23
+ # stub current_user rather than log in for real (same pattern as admin_spec.rb).
24
+ def sign_in(user)
25
+ allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(user)
26
+ end
27
+
28
+ describe "GET /:username/:repository/pulls" do
29
+ it "lists open pull requests by default" do
30
+ repo.pull_requests.create!(user: owner, number: 1, title: "Add feature", head_ref: "feature", base_ref: "main")
31
+
32
+ get "/prowner/demo/pulls"
33
+
34
+ expect(response).to have_http_status(:success)
35
+ expect(response.body).to include("Add feature")
36
+ end
37
+
38
+ it "is reachable without signing in for a public repo" do
39
+ get "/prowner/demo/pulls"
40
+ expect(response).to have_http_status(:success)
41
+ end
42
+ end
43
+
44
+ describe "GET /:username/:repository/pull/:number" do
45
+ it "shows the PR with its commits, diff, and comments" do
46
+ pr = repo.pull_requests.create!(user: owner, number: 1, title: "Add feature", body: "Why this matters", head_ref: "feature", base_ref: "main")
47
+ pr.comments.create!(user: owner, body: "Looks good")
48
+
49
+ get "/prowner/demo/pull/1"
50
+
51
+ expect(response).to have_http_status(:success)
52
+ expect(response.body).to include("Add feature")
53
+ expect(response.body).to include("feature.txt")
54
+ expect(response.body).to include("Looks good")
55
+ end
56
+ end
57
+
58
+ describe "POST /:username/:repository/pulls" do
59
+ it "lets the owner open a pull request" do
60
+ sign_in(owner)
61
+
62
+ post "/prowner/demo/pulls", params: { pull_request: { title: "Add feature", head_ref: "feature", base_ref: "main" } }
63
+
64
+ expect(response).to redirect_to("/prowner/demo/pull/1")
65
+ expect(repo.pull_requests.count).to eq(1)
66
+ end
67
+
68
+ it "refuses a non-owner" do
69
+ sign_in(outsider)
70
+
71
+ post "/prowner/demo/pulls", params: { pull_request: { title: "Add feature", head_ref: "feature", base_ref: "main" } }
72
+
73
+ expect(repo.pull_requests.count).to eq(0)
74
+ end
75
+ end
76
+
77
+ describe "POST /:username/:repository/pull/:number/merge" do
78
+ it "merges a mergeable PR for the owner" do
79
+ pr = repo.pull_requests.create!(user: owner, number: 1, title: "Add feature", head_ref: "feature", base_ref: "main")
80
+ sign_in(owner)
81
+
82
+ post "/prowner/demo/pull/1/merge"
83
+
84
+ expect(response).to redirect_to("/prowner/demo/pull/1")
85
+ expect(pr.reload.state).to eq("merged")
86
+ end
87
+
88
+ it "refuses a non-owner" do
89
+ pr = repo.pull_requests.create!(user: owner, number: 1, title: "Add feature", head_ref: "feature", base_ref: "main")
90
+ sign_in(outsider)
91
+
92
+ post "/prowner/demo/pull/1/merge"
93
+
94
+ expect(pr.reload.state).to eq("open")
95
+ end
96
+ end
97
+
98
+ describe "POST /:username/:repository/pull/:number/close and /reopen" do
99
+ it "closes then reopens a PR for the owner" do
100
+ pr = repo.pull_requests.create!(user: owner, number: 1, title: "Add feature", head_ref: "feature", base_ref: "main")
101
+ sign_in(owner)
102
+
103
+ post "/prowner/demo/pull/1/close"
104
+ expect(pr.reload.state).to eq("closed")
105
+
106
+ post "/prowner/demo/pull/1/reopen"
107
+ expect(pr.reload.state).to eq("open")
108
+ end
109
+ end
110
+
111
+ describe "POST /:username/:repository/pull/:number/comments" do
112
+ it "lets a signed-in reader comment" do
113
+ repo.pull_requests.create!(user: owner, number: 1, title: "Add feature", head_ref: "feature", base_ref: "main")
114
+ sign_in(outsider)
115
+
116
+ post "/prowner/demo/pull/1/comments", params: { body: "Nice work" }
117
+
118
+ expect(response).to redirect_to("/prowner/demo/pull/1")
119
+ expect(Comment.last.body).to eq("Nice work")
120
+ end
121
+ end
122
+
123
+ def build_repo(bare_path)
124
+ FileUtils.mkdir_p(bare_path)
125
+ system("git", "init", "--bare", bare_path, exception: true)
126
+ Dir.mktmpdir do |work|
127
+ system("git", "-C", work, "init", "-b", "main", exception: true)
128
+ system("git", "-C", work, "config", "user.email", "t@example.com", exception: true)
129
+ system("git", "-C", work, "config", "user.name", "Test", exception: true)
130
+ File.write(File.join(work, "README.md"), "# Demo\n")
131
+ system("git", "-C", work, "add", ".", exception: true)
132
+ system("git", "-C", work, "commit", "-m", "seed", exception: true)
133
+ system("git", "-C", work, "remote", "add", "origin", bare_path, exception: true)
134
+ system("git", "-C", work, "push", "origin", "main", exception: true)
135
+
136
+ system("git", "-C", work, "checkout", "-b", "feature", exception: true)
137
+ File.write(File.join(work, "feature.txt"), "hi\n")
138
+ system("git", "-C", work, "add", ".", exception: true)
139
+ system("git", "-C", work, "commit", "-m", "feature commit", exception: true)
140
+ system("git", "-C", work, "push", "origin", "feature", exception: true)
141
+ end
142
+ end
143
+end
spec/services/git_repository_service_merge_spec.rb
new
+122
@@ -0,0 +1,122 @@
1
+# frozen_string_literal: true
2
+
3
+require "rails_helper"
4
+require "tmpdir"
5
+require "fileutils"
6
+
7
+# Covers the git plumbing that backs pull request merging: comparing two
8
+# branches and creating a merge commit without a working tree (the app's
9
+# repos are bare).
10
+RSpec.describe GitRepositoryService do
11
+ around do |example|
12
+ Dir.mktmpdir("git-merge-spec") do |tmp|
13
+ @repo_dir = File.join(tmp, "demo.git")
14
+ build_repo(@repo_dir)
15
+ example.run
16
+ end
17
+ end
18
+
19
+ describe ".commits_between" do
20
+ it "lists commits reachable from head but not base" do
21
+ commits = described_class.commits_between(@repo_dir, "main", "feature")
22
+ expect(commits.map { |c| c[:subject] }).to eq([ "feature commit" ])
23
+ end
24
+
25
+ it "returns nothing once the branches are equal" do
26
+ expect(described_class.commits_between(@repo_dir, "main", "main")).to eq([])
27
+ end
28
+ end
29
+
30
+ describe ".diff" do
31
+ it "includes the file added on the feature branch" do
32
+ diff = described_class.diff(@repo_dir, "main", "feature")
33
+ expect(diff).to include("feature.txt")
34
+ end
35
+ end
36
+
37
+ describe ".mergeable?" do
38
+ it "is true for a clean merge" do
39
+ expect(described_class.mergeable?(@repo_dir, "main", "feature")).to be(true)
40
+ end
41
+
42
+ it "is false when both branches touch the same lines" do
43
+ expect(described_class.mergeable?(@repo_dir, "conflict-a", "conflict-b")).to be(false)
44
+ end
45
+ end
46
+
47
+ describe ".merge!" do
48
+ it "creates a merge commit and advances base" do
49
+ before_sha = described_class.commit_sha(@repo_dir, "main")
50
+ sha = described_class.merge!(
51
+ @repo_dir, base: "main", head: "feature", message: "Merge feature",
52
+ author_name: "Test Merger", author_email: "merger@example.com"
53
+ )
54
+
55
+ expect(sha).to be_present
56
+ expect(described_class.commit_sha(@repo_dir, "main")).to eq(sha)
57
+ expect(described_class.commit_sha(@repo_dir, "main")).not_to eq(before_sha)
58
+
59
+ commit = described_class.commit(@repo_dir, sha)
60
+ expect(commit[:author_name]).to eq("Test Merger")
61
+ expect(commit[:author_email]).to eq("merger@example.com")
62
+
63
+ # Feature's file is now reachable from main.
64
+ expect(described_class.file_content(@repo_dir, "main", "feature.txt")).to eq("hi\n")
65
+ end
66
+
67
+ it "returns nil and leaves base untouched on conflict" do
68
+ before_sha = described_class.commit_sha(@repo_dir, "conflict-a")
69
+ sha = described_class.merge!(
70
+ @repo_dir, base: "conflict-a", head: "conflict-b", message: "Merge",
71
+ author_name: "Test", author_email: "test@example.com"
72
+ )
73
+
74
+ expect(sha).to be_nil
75
+ expect(described_class.commit_sha(@repo_dir, "conflict-a")).to eq(before_sha)
76
+ end
77
+
78
+ it "returns nil for a missing branch" do
79
+ sha = described_class.merge!(
80
+ @repo_dir, base: "main", head: "does-not-exist", message: "Merge",
81
+ author_name: "Test", author_email: "test@example.com"
82
+ )
83
+ expect(sha).to be_nil
84
+ end
85
+ end
86
+
87
+ def build_repo(bare_path)
88
+ FileUtils.mkdir_p(bare_path)
89
+ system("git", "init", "--bare", bare_path, exception: true)
90
+ Dir.mktmpdir do |work|
91
+ system("git", "-C", work, "init", "-b", "main", exception: true)
92
+ system("git", "-C", work, "config", "user.email", "t@example.com", exception: true)
93
+ system("git", "-C", work, "config", "user.name", "Test", exception: true)
94
+ File.write(File.join(work, "README.md"), "# Demo\n")
95
+ system("git", "-C", work, "add", ".", exception: true)
96
+ system("git", "-C", work, "commit", "-m", "seed", exception: true)
97
+ system("git", "-C", work, "remote", "add", "origin", bare_path, exception: true)
98
+ system("git", "-C", work, "push", "origin", "main", exception: true)
99
+
100
+ system("git", "-C", work, "checkout", "-b", "feature", exception: true)
101
+ File.write(File.join(work, "feature.txt"), "hi\n")
102
+ system("git", "-C", work, "add", ".", exception: true)
103
+ system("git", "-C", work, "commit", "-m", "feature commit", exception: true)
104
+ system("git", "-C", work, "push", "origin", "feature", exception: true)
105
+
106
+ # Two branches that both edit line 1 of the same file — a genuine conflict.
107
+ system("git", "-C", work, "checkout", "main", exception: true)
108
+ system("git", "-C", work, "checkout", "-b", "conflict-a", exception: true)
109
+ File.write(File.join(work, "shared.txt"), "a\n")
110
+ system("git", "-C", work, "add", ".", exception: true)
111
+ system("git", "-C", work, "commit", "-m", "conflict a", exception: true)
112
+ system("git", "-C", work, "push", "origin", "conflict-a", exception: true)
113
+
114
+ system("git", "-C", work, "checkout", "main", exception: true)
115
+ system("git", "-C", work, "checkout", "-b", "conflict-b", exception: true)
116
+ File.write(File.join(work, "shared.txt"), "b\n")
117
+ system("git", "-C", work, "add", ".", exception: true)
118
+ system("git", "-C", work, "commit", "-m", "conflict b", exception: true)
119
+ system("git", "-C", work, "push", "origin", "conflict-b", exception: true)
120
+ end
121
+ end
122
+end
spec/services/pull_request_service_spec.rb
+133
@@ -1,6 +1,8 @@
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") }
@@ -55,4 +57,135 @@ RSpec.describe PullRequestService do
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