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 + &middot; <span class="font-mono"><%= pr.head_ref %></span> &rarr; <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">&larr;</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