Commit 5afc10ad authored by Jan Beckmann's avatar Jan Beckmann Committed by Matthias Käppler

Add support for fetching merge requests via RSS / Atom

Closes https://gitlab.com/gitlab-org/gitlab/-/issues/29521

Changelog: added
parent 0b5df713
...@@ -8,7 +8,6 @@ module SessionlessAuthentication ...@@ -8,7 +8,6 @@ module SessionlessAuthentication
# This filter handles personal access tokens, atom requests with rss tokens, and static object tokens # This filter handles personal access tokens, atom requests with rss tokens, and static object tokens
def authenticate_sessionless_user!(request_format) def authenticate_sessionless_user!(request_format)
user = request_authenticator.find_sessionless_user(request_format) user = request_authenticator.find_sessionless_user(request_format)
sessionless_sign_in(user) if user sessionless_sign_in(user) if user
end end
......
...@@ -13,6 +13,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo ...@@ -13,6 +13,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
include DiffHelper include DiffHelper
include Gitlab::Cache::Helpers include Gitlab::Cache::Helpers
prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) }
skip_before_action :merge_request, only: [:index, :bulk_update, :export_csv] skip_before_action :merge_request, only: [:index, :bulk_update, :export_csv]
before_action :apply_diff_view_cookie!, only: [:show] before_action :apply_diff_view_cookie!, only: [:show]
before_action :disable_query_limiting, only: [:assign_related_issues, :update] before_action :disable_query_limiting, only: [:assign_related_issues, :update]
...@@ -85,6 +86,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo ...@@ -85,6 +86,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
respond_to do |format| respond_to do |format|
format.html format.html
format.atom { render layout: 'xml.atom' }
format.json do format.json do
render json: { render json: {
html: view_to_html_string("projects/merge_requests/_merge_requests") html: view_to_html_string("projects/merge_requests/_merge_requests")
......
...@@ -3,42 +3,7 @@ ...@@ -3,42 +3,7 @@
xml.entry do xml.entry do
xml.id project_issue_url(issue.project, issue) xml.id project_issue_url(issue.project, issue)
xml.link href: project_issue_url(issue.project, issue) xml.link href: project_issue_url(issue.project, issue)
xml.title truncate(issue.title, length: 80) # using the shovel operator (xml <<) would make us lose indentation, so we do this (https://github.com/rails/rails/issues/7036)
xml.updated issue.updated_at.xmlschema render(partial: 'shared/issuable/issuable', object: issue, locals: { builder: xml })
xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon_for_user(issue.author))
xml.author do
xml.name issue.author_name
xml.email issue.author_public_email
end
xml.summary issue.title
xml.description issue.description if issue.description
xml.content issue.description if issue.description
xml.milestone issue.milestone.title if issue.milestone
xml.due_date issue.due_date if issue.due_date xml.due_date issue.due_date if issue.due_date
unless issue.labels.empty?
xml.labels do
issue.labels.each do |label|
xml.label label.name
end
end
end
if issue.assignees.any?
xml.assignees do
issue.assignees.each do |assignee|
xml.assignee do
xml.name assignee.name
xml.email assignee.public_email
end
end
end
xml.assignee do
xml.name issue.assignees.first.name
xml.email issue.assignees.first.public_email
end
end
end end
# frozen_string_literal: true
xml.entry do
xml.id project_merge_request_url(merge_request.project, merge_request)
xml.link href: project_merge_request_url(merge_request.project, merge_request)
# using the shovel operator (xml <<) would make us lose indentation, so we do this (https://github.com/rails/rails/issues/7036)
render(partial: 'shared/issuable/issuable', object: merge_request, locals: { builder: xml })
end
- issuable_type = 'merge-requests' - issuable_type = 'merge-requests'
- notification_email = @current_user.present? ? @current_user.notification_email : nil - notification_email = @current_user.present? ? @current_user.notification_email : nil
= render 'shared/issuable/feed_buttons', show_calendar_button: false
.js-csv-import-export-buttons{ data: { show_export_button: "true", issuable_type: issuable_type, issuable_count: issuables_count_for_state(issuable_type.to_sym, params[:state]), email: notification_email, export_csv_path: export_csv_project_merge_requests_path(@project, request.query_parameters), container_class: 'gl-mr-3' } } .js-csv-import-export-buttons{ data: { show_export_button: "true", issuable_type: issuable_type, issuable_count: issuables_count_for_state(issuable_type.to_sym, params[:state]), email: notification_email, export_csv_path: export_csv_project_merge_requests_path(@project, request.query_parameters), container_class: 'gl-mr-3' } }
- if @can_bulk_update - if @can_bulk_update
......
# frozen_string_literal: true
# rubocop: disable CodeReuse/ActiveRecord
xml.title "#{@project.name} merge requests"
xml.link href: url_for(safe_params), rel: "self", type: "application/atom+xml"
xml.link href: project_merge_requests_url(@project), rel: "alternate", type: "text/html"
xml.id project_merge_requests_url(@project)
xml.updated @merge_requests.first.updated_at.xmlschema if @merge_requests.reorder(nil).any?
xml << render(partial: 'projects/merge_requests/merge_request', collection: @merge_requests) if @merge_requests.reorder(nil).any?
# rubocop: enable CodeReuse/ActiveRecord
...@@ -6,6 +6,9 @@ ...@@ -6,6 +6,9 @@
- page_title _("Merge requests") - page_title _("Merge requests")
- new_merge_request_email = @project.new_issuable_address(current_user, 'merge_request') - new_merge_request_email = @project.new_issuable_address(current_user, 'merge_request')
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@project.name} merge requests")
= render 'projects/last_push' = render 'projects/last_push'
- if @project.merge_requests.exists? - if @project.merge_requests.exists?
......
= link_to safe_params.merge(rss_url_options), class: 'btn gl-button btn-default btn-icon has-tooltip', data: { container: 'body', testid: 'rss-feed-link' }, title: _('Subscribe to RSS feed') do - show_calendar_button = local_assigns.fetch(:show_calendar_button, true)
= link_to safe_params.merge(rss_url_options), class: 'btn gl-button btn-default btn-icon has-tooltip', data: { container: 'body', testid: 'rss-feed-link' }, title: _('Subscribe to RSS feed') , 'aria-label': _('Subscribe to RSS feed') do
= sprite_icon('rss', css_class: 'qa-rss-icon') = sprite_icon('rss', css_class: 'qa-rss-icon')
= link_to safe_params.merge(calendar_url_options), class: 'btn gl-button btn-default btn-icon has-tooltip', data: { container: 'body' }, title: _('Subscribe to calendar') do
= sprite_icon('calendar') - if show_calendar_button
= link_to safe_params.merge(calendar_url_options), class: 'btn gl-button btn-default btn-icon has-tooltip', data: { container: 'body' }, title: _('Subscribe to calendar'), 'aria-label': _('Subscribe to calendar') do
= sprite_icon('calendar')
# frozen_string_literal: true
builder.title truncate(issuable.title, length: 80)
builder.updated issuable.updated_at.xmlschema
builder.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon_for_user(issuable.author))
builder.author do
builder.name issuable.author_name
builder.email issuable.author_public_email
end
builder.summary issuable.title
builder.description truncate(issuable.description, length: 240) if issuable.description
builder.content issuable.description if issuable.description
builder.milestone issuable.milestone.title if issuable.milestone
unless issuable.labels.empty?
builder.labels do
issuable.labels.each do |label|
builder.label label.name
end
end
end
if issuable.assignees.any?
builder.assignees do
issuable.assignees.each do |assignee|
builder.assignee do
builder.name assignee.name
builder.email assignee.public_email
end
end
end
builder.assignee do
builder.name issuable.assignees.first.name
builder.email issuable.assignees.first.public_email
end
end
...@@ -96,6 +96,14 @@ You can filter issues and merge requests by specific terms included in titles or ...@@ -96,6 +96,14 @@ You can filter issues and merge requests by specific terms included in titles or
![filter issues by specific terms](img/issue_search_by_term.png) ![filter issues by specific terms](img/issue_search_by_term.png)
### Retrieving search results as feed
> Feeds for merge requests were [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/66336) in GitLab 14.3.
You can subscribe to the results of your search query for issues or merge requests within a project as an Atom feed by clicking on the feed symbol **{rss}**.
This will generate a feed URL containing both a feed token and your search query, which can be added to your feed reader.
### Filtering by ID ### Filtering by ID
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/39908) in GitLab 12.1. > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/39908) in GitLab 12.1.
......
...@@ -4,61 +4,75 @@ require 'spec_helper' ...@@ -4,61 +4,75 @@ require 'spec_helper'
RSpec.describe 'Issues Feed' do RSpec.describe 'Issues Feed' do
describe 'GET /issues' do describe 'GET /issues' do
let!(:user) { create(:user, email: 'private1@example.com', public_email: 'public1@example.com') } let_it_be_with_reload(:user) { create(:user, email: 'private1@example.com', public_email: 'public1@example.com') }
let!(:assignee) { create(:user, email: 'private2@example.com', public_email: 'public2@example.com') } let_it_be(:assignee) { create(:user, email: 'private2@example.com', public_email: 'public2@example.com') }
let!(:group) { create(:group) } let_it_be(:group) { create(:group) }
let!(:project) { create(:project) } let_it_be(:project) { create(:project) }
let!(:issue) { create(:issue, author: user, assignees: [assignee], project: project) } let_it_be(:issue) { create(:issue, author: user, assignees: [assignee], project: project, due_date: Date.today) }
let_it_be(:issuable) { issue } # "alias" for shared examples
before do
before_all do
project.add_developer(user) project.add_developer(user)
group.add_developer(user) group.add_developer(user)
end end
RSpec.shared_examples 'an authenticated issue atom feed' do
it 'renders atom feed with additional issue information' do
expect(body).to have_selector('title', text: "#{project.name} issues")
expect(body).to have_selector('due_date', text: issue.due_date)
end
end
context 'when authenticated' do context 'when authenticated' do
it 'renders atom feed' do before do
sign_in user sign_in user
visit project_issues_path(project, :atom) visit project_issues_path(project, :atom)
expect(response_headers['Content-Type'])
.to have_content('application/atom+xml')
expect(body).to have_selector('title', text: "#{project.name} issues")
expect(body).to have_selector('author email', text: issue.author_public_email)
expect(body).to have_selector('assignees assignee email', text: issue.assignees.first.public_email)
expect(body).to have_selector('assignee email', text: issue.assignees.first.public_email)
expect(body).to have_selector('entry summary', text: issue.title)
end end
it_behaves_like 'an authenticated issuable atom feed'
it_behaves_like 'an authenticated issue atom feed'
end end
context 'when authenticated via personal access token' do context 'when authenticated via personal access token' do
it 'renders atom feed' do before do
personal_access_token = create(:personal_access_token, user: user) personal_access_token = create(:personal_access_token, user: user)
visit project_issues_path(project, :atom, visit project_issues_path(project, :atom,
private_token: personal_access_token.token) private_token: personal_access_token.token)
expect(response_headers['Content-Type'])
.to have_content('application/atom+xml')
expect(body).to have_selector('title', text: "#{project.name} issues")
expect(body).to have_selector('author email', text: issue.author_public_email)
expect(body).to have_selector('assignees assignee email', text: issue.assignees.first.public_email)
expect(body).to have_selector('assignee email', text: issue.assignees.first.public_email)
expect(body).to have_selector('entry summary', text: issue.title)
end end
it_behaves_like 'an authenticated issuable atom feed'
it_behaves_like 'an authenticated issue atom feed'
end end
context 'when authenticated via feed token' do context 'when authenticated via feed token' do
it 'renders atom feed' do before do
visit project_issues_path(project, :atom, visit project_issues_path(project, :atom,
feed_token: user.feed_token) feed_token: user.feed_token)
end
expect(response_headers['Content-Type']) it_behaves_like 'an authenticated issuable atom feed'
.to have_content('application/atom+xml') it_behaves_like 'an authenticated issue atom feed'
expect(body).to have_selector('title', text: "#{project.name} issues") end
expect(body).to have_selector('author email', text: issue.author_public_email)
expect(body).to have_selector('assignees assignee email', text: issue.assignees.first.public_email) context 'when not authenticated' do
expect(body).to have_selector('assignee email', text: issue.assignees.first.public_email) before do
expect(body).to have_selector('entry summary', text: issue.title) visit project_issues_path(project, :atom)
end
context 'and the project is private' do
it 'redirects to login page' do
expect(page).to have_current_path(new_user_session_path)
end
end
context 'and the project is public' do
let_it_be(:project) { create(:project, :public) }
let_it_be(:issue) { create(:issue, author: user, assignees: [assignee], project: project, due_date: Date.today) }
let_it_be(:issuable) { issue } # "alias" for shared examples
it_behaves_like 'an authenticated issuable atom feed'
it_behaves_like 'an authenticated issue atom feed'
end end
end end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Merge Requests Feed' do
describe 'GET /merge_requests' do
let_it_be_with_reload(:user) { create(:user, email: 'private1@example.com', public_email: 'public1@example.com') }
let_it_be(:assignee) { create(:user, email: 'private2@example.com', public_email: 'public2@example.com') }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :repository) }
let_it_be(:merge_request) { create(:merge_request, source_project: project, assignees: [assignee]) }
let_it_be(:issuable) { merge_request } # "alias" for shared examples
before_all do
project.add_developer(user)
group.add_developer(user)
end
RSpec.shared_examples 'an authenticated merge request atom feed' do
it 'renders atom feed with additional merge request information' do
expect(body).to have_selector('title', text: "#{project.name} merge requests")
end
end
context 'when authenticated' do
before do
sign_in user
visit project_merge_requests_path(project, :atom)
end
it_behaves_like 'an authenticated issuable atom feed'
it_behaves_like 'an authenticated merge request atom feed'
context 'but the use can not see the project' do
let_it_be(:other_project) { create(:project) }
it 'renders 404 page' do
visit project_issues_path(other_project, :atom)
expect(page).to have_gitlab_http_status(:not_found)
end
end
end
context 'when authenticated via personal access token' do
before do
personal_access_token = create(:personal_access_token, user: user)
visit project_merge_requests_path(project, :atom,
private_token: personal_access_token.token)
end
it_behaves_like 'an authenticated issuable atom feed'
it_behaves_like 'an authenticated merge request atom feed'
end
context 'when authenticated via feed token' do
before do
visit project_merge_requests_path(project, :atom,
feed_token: user.feed_token)
end
it_behaves_like 'an authenticated issuable atom feed'
it_behaves_like 'an authenticated merge request atom feed'
end
context 'when not authenticated' do
before do
visit project_merge_requests_path(project, :atom)
end
context 'and the project is private' do
it 'redirects to login page' do
expect(page).to have_current_path(new_user_session_path)
end
end
context 'and the project is public' do
let_it_be(:project) { create(:project, :public, :repository) }
let_it_be(:merge_request) { create(:merge_request, source_project: project, assignees: [assignee]) }
let_it_be(:issuable) { merge_request } # "alias" for shared examples
it_behaves_like 'an authenticated issuable atom feed'
it_behaves_like 'an authenticated merge request atom feed'
end
end
end
end
...@@ -3,21 +3,24 @@ ...@@ -3,21 +3,24 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe 'Project Issues RSS' do RSpec.describe 'Project Issues RSS' do
let!(:user) { create(:user) } let_it_be(:user) { create(:user) }
let(:group) { create(:group) } let_it_be(:group) { create(:group) }
let(:project) { create(:project, group: group, visibility_level: Gitlab::VisibilityLevel::PUBLIC) } let_it_be(:project) { create(:project, group: group, visibility_level: Gitlab::VisibilityLevel::PUBLIC) }
let(:path) { project_issues_path(project) } let_it_be(:path) { project_issues_path(project) }
let_it_be(:issue) { create(:issue, project: project, assignees: [user]) }
before do before_all do
create(:issue, project: project, assignees: [user])
group.add_developer(user) group.add_developer(user)
end end
context 'when signed in' do context 'when signed in' do
let(:user) { create(:user) } let_it_be(:user) { create(:user) }
before do before_all do
project.add_developer(user) project.add_developer(user)
end
before do
sign_in(user) sign_in(user)
visit path visit path
end end
...@@ -36,26 +39,6 @@ RSpec.describe 'Project Issues RSS' do ...@@ -36,26 +39,6 @@ RSpec.describe 'Project Issues RSS' do
end end
describe 'feeds' do describe 'feeds' do
shared_examples 'updates atom feed link' do |type|
it "for #{type}" do
sign_in(user)
visit path
link = find_link('Subscribe to RSS feed')
params = CGI.parse(URI.parse(link[:href]).query)
auto_discovery_link = find('link[type="application/atom+xml"]', visible: false)
auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query)
expected = {
'feed_token' => [user.feed_token],
'assignee_id' => [user.id.to_s]
}
expect(params).to include(expected)
expect(auto_discovery_params).to include(expected)
end
end
it_behaves_like 'updates atom feed link', :project do it_behaves_like 'updates atom feed link', :project do
let(:path) { project_issues_path(project, assignee_id: user.id) } let(:path) { project_issues_path(project, assignee_id: user.id) }
end end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Project Merge Requests RSS' do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :repository, group: group, visibility_level: Gitlab::VisibilityLevel::PUBLIC) }
let_it_be(:merge_request) { create(:merge_request, source_project: project, assignees: [user]) }
let_it_be(:path) { project_merge_requests_path(project) }
before_all do
group.add_developer(user)
end
context 'when signed in' do
let_it_be(:user) { create(:user) }
before_all do
project.add_developer(user)
end
before do
sign_in(user)
visit path
end
it_behaves_like "it has an RSS button with current_user's feed token"
it_behaves_like "an autodiscoverable RSS feed with current_user's feed token"
end
context 'when signed out' do
before do
visit path
end
it_behaves_like "it has an RSS button without a feed token"
it_behaves_like "an autodiscoverable RSS feed without a feed token"
end
describe 'feeds' do
it_behaves_like 'updates atom feed link', :project do
let(:path) { project_merge_requests_path(project, assignee_id: user.id) }
end
end
end
# frozen_string_literal: true
RSpec.shared_examples "an authenticated issuable atom feed" do
it "renders atom feed with common issuable information" do
expect(response_headers['Content-Type'])
.to have_content('application/atom+xml')
expect(body).to have_selector('author email', text: issuable.author_public_email)
expect(body).to have_selector('assignees assignee email', text: issuable.assignees.first.public_email)
expect(body).to have_selector('assignee email', text: issuable.assignees.first.public_email)
expect(body).to have_selector('entry summary', text: issuable.title)
end
end
...@@ -25,3 +25,23 @@ RSpec.shared_examples "it has an RSS button without a feed token" do ...@@ -25,3 +25,23 @@ RSpec.shared_examples "it has an RSS button without a feed token" do
.to have_css("a:has(.qa-rss-icon):not([href*='feed_token'])") # rubocop:disable QA/SelectorUsage .to have_css("a:has(.qa-rss-icon):not([href*='feed_token'])") # rubocop:disable QA/SelectorUsage
end end
end end
RSpec.shared_examples "updates atom feed link" do |type|
it "for #{type}" do
sign_in(user)
visit path
link = find_link('Subscribe to RSS feed')
params = CGI.parse(URI.parse(link[:href]).query)
auto_discovery_link = find("link[type='application/atom+xml']", visible: false)
auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query)
expected = {
'feed_token' => [user.feed_token],
'assignee_id' => [user.id.to_s]
}
expect(params).to include(expected)
expect(auto_discovery_params).to include(expected)
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment