Commit a7b9628b authored by Michael Kozono's avatar Michael Kozono

Merge branch '18816-forward-an-e-mail-to-an-existing-issue-as-a-new-comment' into 'master'

Resolve "Forward an E-Mail to an existing issue as a new comment"

See merge request gitlab-org/gitlab!49050
parents 4c9cf01f 941219e5
...@@ -121,6 +121,9 @@ module Types ...@@ -121,6 +121,9 @@ module Types
field :moved_to, Types::IssueType, null: true, field :moved_to, Types::IssueType, null: true,
description: 'Updated Issue after it got moved to another project' description: 'Updated Issue after it got moved to another project'
field :create_note_email, GraphQL::STRING_TYPE, null: true,
description: 'User specific email address for the issue'
def author def author
Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find
end end
...@@ -140,6 +143,10 @@ module Types ...@@ -140,6 +143,10 @@ module Types
def discussion_locked def discussion_locked
!!object.discussion_locked !!object.discussion_locked
end end
def create_note_email
object.creatable_note_email_address(context[:current_user])
end
end end
end end
......
...@@ -442,7 +442,8 @@ module IssuablesHelper ...@@ -442,7 +442,8 @@ module IssuablesHelper
fullPath: issuable[:project_full_path], fullPath: issuable[:project_full_path],
iid: issuable[:iid], iid: issuable[:iid],
severity: issuable[:severity], severity: issuable[:severity],
timeTrackingLimitToHours: Gitlab::CurrentSettings.time_tracking_limit_to_hours timeTrackingLimitToHours: Gitlab::CurrentSettings.time_tracking_limit_to_hours,
createNoteEmail: issuable[:create_note_email]
} }
end end
......
...@@ -19,6 +19,11 @@ module Noteable ...@@ -19,6 +19,11 @@ module Noteable
def resolvable_types def resolvable_types
%w(MergeRequest DesignManagement::Design) %w(MergeRequest DesignManagement::Design)
end end
# `Noteable` class names that support creating/forwarding individual notes.
def email_creatable_types
%w(Issue)
end
end end
# The timestamp of the note (e.g. the :created_at or :updated_at attribute if provided via # The timestamp of the note (e.g. the :created_at or :updated_at attribute if provided via
...@@ -55,6 +60,10 @@ module Noteable ...@@ -55,6 +60,10 @@ module Noteable
supports_discussions? && self.class.replyable_types.include?(base_class_name) supports_discussions? && self.class.replyable_types.include?(base_class_name)
end end
def supports_creating_notes_by_email?
self.class.email_creatable_types.include?(base_class_name)
end
def supports_suggestion? def supports_suggestion?
false false
end end
...@@ -158,6 +167,18 @@ module Noteable ...@@ -158,6 +167,18 @@ module Noteable
def after_note_destroyed(_note) def after_note_destroyed(_note)
# no-op # no-op
end end
# Email address that an authorized user can send/forward an email to be added directly
# to an issue or merge request.
# example: incoming+h5bp-html5-boilerplate-8-1234567890abcdef123456789-issue-34@localhost.com
def creatable_note_email_address(author)
return unless supports_creating_notes_by_email?
project_email = project.new_issuable_address(author, self.class.name.underscore)
return unless project_email
project_email.sub('@', "-#{iid}@")
end
end end
Noteable.extend(Noteable::ClassMethods) Noteable.extend(Noteable::ClassMethods)
......
...@@ -103,6 +103,10 @@ class IssuableSidebarBasicEntity < Grape::Entity ...@@ -103,6 +103,10 @@ class IssuableSidebarBasicEntity < Grape::Entity
issuable.project.emails_disabled? issuable.project.emails_disabled?
end end
expose :create_note_email do |issuable|
issuable.creatable_note_email_address(current_user)
end
expose :supports_time_tracking?, as: :supports_time_tracking expose :supports_time_tracking?, as: :supports_time_tracking
expose :supports_milestone?, as: :supports_milestone expose :supports_milestone?, as: :supports_milestone
expose :supports_severity?, as: :supports_severity expose :supports_severity?, as: :supports_severity
......
---
title: New user/issue specific email address for creating/forwarding to an issue
merge_request: 49050
author:
type: added
...@@ -8665,6 +8665,11 @@ type EpicIssue implements CurrentUserTodos & Noteable { ...@@ -8665,6 +8665,11 @@ type EpicIssue implements CurrentUserTodos & Noteable {
""" """
confidential: Boolean! confidential: Boolean!
"""
User specific email address for the issue
"""
createNoteEmail: String
""" """
Timestamp of when the issue was created Timestamp of when the issue was created
""" """
...@@ -11636,6 +11641,11 @@ type Issue implements CurrentUserTodos & Noteable { ...@@ -11636,6 +11641,11 @@ type Issue implements CurrentUserTodos & Noteable {
""" """
confidential: Boolean! confidential: Boolean!
"""
User specific email address for the issue
"""
createNoteEmail: String
""" """
Timestamp of when the issue was created Timestamp of when the issue was created
""" """
......
...@@ -24014,6 +24014,20 @@ ...@@ -24014,6 +24014,20 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "createNoteEmail",
"description": "User specific email address for the issue",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "createdAt", "name": "createdAt",
"description": "Timestamp of when the issue was created", "description": "Timestamp of when the issue was created",
...@@ -31845,6 +31859,20 @@ ...@@ -31845,6 +31859,20 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "createNoteEmail",
"description": "User specific email address for the issue",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "createdAt", "name": "createdAt",
"description": "Timestamp of when the issue was created", "description": "Timestamp of when the issue was created",
...@@ -1425,6 +1425,7 @@ Relationship between an epic and an issue. ...@@ -1425,6 +1425,7 @@ Relationship between an epic and an issue.
| `blockedByCount` | Int | Count of issues blocking this issue. | | `blockedByCount` | Int | Count of issues blocking this issue. |
| `closedAt` | Time | Timestamp of when the issue was closed | | `closedAt` | Time | Timestamp of when the issue was closed |
| `confidential` | Boolean! | Indicates the issue is confidential | | `confidential` | Boolean! | Indicates the issue is confidential |
| `createNoteEmail` | String | User specific email address for the issue |
| `createdAt` | Time! | Timestamp of when the issue was created | | `createdAt` | Time! | Timestamp of when the issue was created |
| `currentUserTodos` | TodoConnection! | Todos for the current user | | `currentUserTodos` | TodoConnection! | Todos for the current user |
| `description` | String | Description of the issue | | `description` | String | Description of the issue |
...@@ -1768,6 +1769,7 @@ Represents a recorded measurement (object count) for the Admins. ...@@ -1768,6 +1769,7 @@ Represents a recorded measurement (object count) for the Admins.
| `blockedByCount` | Int | Count of issues blocking this issue. | | `blockedByCount` | Int | Count of issues blocking this issue. |
| `closedAt` | Time | Timestamp of when the issue was closed | | `closedAt` | Time | Timestamp of when the issue was closed |
| `confidential` | Boolean! | Indicates the issue is confidential | | `confidential` | Boolean! | Indicates the issue is confidential |
| `createNoteEmail` | String | User specific email address for the issue |
| `createdAt` | Time! | Timestamp of when the issue was created | | `createdAt` | Time! | Timestamp of when the issue was created |
| `currentUserTodos` | TodoConnection! | Todos for the current user | | `currentUserTodos` | TodoConnection! | Todos for the current user |
| `description` | String | Description of the issue | | `description` | String | Description of the issue |
......
...@@ -11,6 +11,7 @@ module Gitlab ...@@ -11,6 +11,7 @@ module Gitlab
[ [
CreateNoteHandler, CreateNoteHandler,
CreateIssueHandler, CreateIssueHandler,
CreateNoteOnIssuableHandler,
UnsubscribeHandler, UnsubscribeHandler,
CreateMergeRequestHandler, CreateMergeRequestHandler,
ServiceDeskHandler ServiceDeskHandler
......
# frozen_string_literal: true
require 'gitlab/email/handler/base_handler'
# Handles comment creation emails when sent/forwarded by an authorized
# user. Attachments are allowed. Quoted material is _not_ stripped, just like
# create issue emails
# Supports these formats:
# incoming+gitlab-org-gitlab-ce-20-Author_Token12345678-issue-34@incoming.gitlab.com
module Gitlab
module Email
module Handler
class CreateNoteOnIssuableHandler < BaseHandler
include ReplyProcessing
attr_reader :issuable_iid
HANDLER_REGEX = /\A#{HANDLER_ACTION_BASE_REGEX}-(?<incoming_email_token>.+)-issue-(?<issuable_iid>\d+)\z/.freeze
def initialize(mail, mail_key)
super(mail, mail_key)
if (matched = HANDLER_REGEX.match(mail_key.to_s))
@project_slug = matched[:project_slug]
@project_id = matched[:project_id]&.to_i
@incoming_email_token = matched[:incoming_email_token]
@issuable_iid = matched[:issuable_iid]&.to_i
end
end
def can_handle?
incoming_email_token && project_id && issuable_iid
end
def execute
raise ProjectNotFound unless project
validate_permission!(:create_note)
raise NoteableNotFoundError unless noteable
raise EmptyEmailError if message_including_reply.blank?
verify_record!(
record: create_note,
invalid_exception: InvalidNoteError,
record_name: 'comment')
end
def metrics_event
:receive_email_create_note_issuable
end
def noteable
return unless issuable_iid
@noteable ||= project&.issues&.find_by_iid(issuable_iid)
end
private
# rubocop: disable CodeReuse/ActiveRecord
def author
@author ||= User.find_by(incoming_email_token: incoming_email_token)
end
# rubocop: enable CodeReuse/ActiveRecord
def create_note
Notes::CreateService.new(project, author, note_params).execute
end
def note_params
{
noteable_type: noteable.class.to_s,
noteable_id: noteable.id,
note: message_including_reply
}
end
end
end
end
end
Return-Path: <jake@adventuretime.ooo>
Received: from iceking.adventuretime.ooo ([unix socket]) by iceking (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400
Received: from mail-ie0-x234.google.com (mail-ie0-x234.google.com [IPv6:2607:f8b0:4001:c03::234]) by iceking.adventuretime.ooo (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for <incoming+gitlabhq/gitlabhq@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 17:03:50 -0400
Received: by mail-ie0-f180.google.com with SMTP id f4so21977375iea.25 for <incoming+gitlabhq-gitlabhq-project_id-auth_token-issue-issue_iid@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 14:03:48 -0700
Received: by 10.0.0.1 with HTTP; Thu, 13 Jun 2013 14:03:48 -0700
Date: Thu, 13 Jun 2013 17:03:48 -0400
From: Jake the Dog <jake@adventuretime.ooo>
To: incoming+gitlabhq-gitlabhq-project_id-auth_token-issue-issue_iid@appmail.adventuretime.ooo
Message-ID: <CADkmRc+rNGAGGbV2iE5p918UVy4UyJqVcXRO2=otppgzduJSg@mail.gmail.com>
Subject: New Issue comment by email
Mime-Version: 1.0
Content-Type: text/plain;
charset=ISO-8859-1
Content-Transfer-Encoding: 7bit
X-Sieve: CMU Sieve 2.2
X-Received: by 10.0.0.1 with SMTP id n7mr11234144ipb.85.1371157428600; Thu,
13 Jun 2013 14:03:48 -0700 (PDT)
X-Scanned-By: MIMEDefang 2.69 on IPv6:2001:470:1d:165::1
This should create a new comment on the issue.
- Jake out
> This quoted content will be included in the comment.
...@@ -17,7 +17,8 @@ RSpec.describe GitlabSchema.types['Issue'] do ...@@ -17,7 +17,8 @@ RSpec.describe GitlabSchema.types['Issue'] do
fields = %i[id iid title description state reference author assignees updated_by participants labels milestone due_date fields = %i[id iid title description state reference author assignees updated_by participants labels milestone due_date
confidential discussion_locked upvotes downvotes user_notes_count user_discussions_count web_path web_url relative_position confidential discussion_locked upvotes downvotes user_notes_count user_discussions_count web_path web_url relative_position
emails_disabled subscribed time_estimate total_time_spent human_time_estimate human_total_time_spent closed_at created_at updated_at task_completion_status emails_disabled subscribed time_estimate total_time_spent human_time_estimate human_total_time_spent closed_at created_at updated_at task_completion_status
design_collection alert_management_alert severity current_user_todos moved moved_to] design_collection alert_management_alert severity current_user_todos moved moved_to
create_note_email]
fields.each do |field_name| fields.each do |field_name|
expect(described_class).to have_graphql_field(field_name) expect(described_class).to have_graphql_field(field_name)
......
...@@ -4,146 +4,50 @@ require 'spec_helper' ...@@ -4,146 +4,50 @@ require 'spec_helper'
RSpec.describe Gitlab::Email::Handler::CreateNoteHandler do RSpec.describe Gitlab::Email::Handler::CreateNoteHandler do
include_context :email_shared_context include_context :email_shared_context
let!(:sent_notification) do
SentNotification.record_note(note, user.id, mail_key) let_it_be(:user) { create(:user) }
end let_it_be(:project) { create(:project, :public, :repository) }
let(:noteable) { note.noteable } let(:noteable) { note.noteable }
let(:note) { create(:diff_note_on_merge_request, project: project) } let(:note) { create(:diff_note_on_merge_request, project: project) }
let(:user) { create(:user) }
let(:project) { create(:project, :public, :repository) }
let(:email_raw) { fixture_file('emails/valid_reply.eml') } let(:email_raw) { fixture_file('emails/valid_reply.eml') }
let!(:sent_notification) do
SentNotification.record_note(note, user.id, mail_key)
end
it_behaves_like :reply_processing_shared_examples it_behaves_like :reply_processing_shared_examples
it_behaves_like :note_handler_shared_examples do
let(:recipient) { sent_notification.recipient }
let(:update_commands_only) { fixture_file('emails/update_commands_only_reply.eml')}
let(:no_content) { fixture_file('emails/no_content_reply.eml') }
let(:commands_in_reply) { fixture_file('emails/commands_in_reply.eml') }
let(:with_quick_actions) { fixture_file('emails/valid_reply_with_quick_actions.eml') }
end
before do before do
stub_incoming_email_setting(enabled: true, address: "reply+%{key}@appmail.adventuretime.ooo") stub_incoming_email_setting(enabled: true, address: "reply+%{key}@appmail.adventuretime.ooo")
stub_config_setting(host: 'localhost') stub_config_setting(host: 'localhost')
end end
context "when the recipient address doesn't include a mail key" do context 'when the recipient address does not include a mail key' do
let(:email_raw) { fixture_file('emails/valid_reply.eml').gsub(mail_key, "") } let(:email_raw) { fixture_file('emails/valid_reply.eml').gsub(mail_key, '') }
it "raises a UnknownIncomingEmail" do it 'raises a UnknownIncomingEmail' do
expect { receiver.execute }.to raise_error(Gitlab::Email::UnknownIncomingEmail) expect { receiver.execute }.to raise_error(Gitlab::Email::UnknownIncomingEmail)
end end
end end
context "when no sent notification for the mail key could be found" do context 'when no sent notification for the mail key could be found' do
let(:email_raw) { fixture_file('emails/wrong_mail_key.eml') } let(:email_raw) { fixture_file('emails/wrong_mail_key.eml') }
it "raises a SentNotificationNotFoundError" do it 'raises a SentNotificationNotFoundError' do
expect { receiver.execute }.to raise_error(Gitlab::Email::SentNotificationNotFoundError) expect { receiver.execute }.to raise_error(Gitlab::Email::SentNotificationNotFoundError)
end end
end end
context "when the noteable could not be found" do context 'when issue is confidential' do
before do
noteable.destroy
end
it "raises a NoteableNotFoundError" do
expect { receiver.execute }.to raise_error(Gitlab::Email::NoteableNotFoundError)
end
end
context "when the note could not be saved" do
before do
allow_any_instance_of(Note).to receive(:persisted?).and_return(false)
end
it "raises an InvalidNoteError" do
expect { receiver.execute }.to raise_error(Gitlab::Email::InvalidNoteError)
end
context 'because the note was update commands only' do
let!(:email_raw) { fixture_file("emails/update_commands_only_reply.eml") }
context 'and current user cannot update noteable' do
it 'raises a CommandsOnlyNoteError' do
expect { receiver.execute }.to raise_error(Gitlab::Email::InvalidNoteError)
end
end
context "and current user can update noteable" do
before do
project.add_developer(user)
end
it 'does not raise an error' do
expect { receiver.execute }.to change { noteable.resource_state_events.count }.by(1)
expect(noteable.reload).to be_closed
end
end
end
end
context 'when the note contains quick actions' do
let!(:email_raw) { fixture_file("emails/commands_in_reply.eml") }
context 'and current user cannot update the noteable' do
it 'only executes the commands that the user can perform' do
expect { receiver.execute }
.to change { noteable.notes.user.count }.by(1)
.and change { user.todos_pending_count }.from(0).to(1)
expect(noteable.reload).to be_open
end
end
context 'and current user can update noteable' do
before do
project.add_developer(user)
end
it 'posts a note and updates the noteable' do
expect(TodoService.new.todo_exist?(noteable, user)).to be_falsy
expect { receiver.execute }
.to change { noteable.notes.user.count }.by(1)
.and change { user.todos_pending_count }.from(0).to(1)
expect(noteable.reload).to be_closed
end
end
end
context "when the reply is blank" do
let!(:email_raw) { fixture_file("emails/no_content_reply.eml") }
it "raises an EmptyEmailError" do
expect { receiver.execute }.to raise_error(Gitlab::Email::EmptyEmailError)
end
end
shared_examples "checks permissions on noteable" do
context "when user has access" do
before do
project.add_reporter(user)
end
it "creates a comment" do
expect { receiver.execute }.to change { noteable.notes.count }.by(1)
end
end
context "when user does not have access" do
it "raises UserNotAuthorizedError" do
expect { receiver.execute }.to raise_error(Gitlab::Email::UserNotAuthorizedError)
end
end
end
context "when discussion is locked" do
before do
noteable.update_attribute(:discussion_locked, true)
end
it_behaves_like "checks permissions on noteable"
end
context "when issue is confidential" do
let(:issue) { create(:issue, project: project) } let(:issue) { create(:issue, project: project) }
let(:note) { create(:note, noteable: issue, project: project) } let(:note) { create(:note, noteable: issue, project: project) }
...@@ -151,17 +55,17 @@ RSpec.describe Gitlab::Email::Handler::CreateNoteHandler do ...@@ -151,17 +55,17 @@ RSpec.describe Gitlab::Email::Handler::CreateNoteHandler do
issue.update_attribute(:confidential, true) issue.update_attribute(:confidential, true)
end end
it_behaves_like "checks permissions on noteable" it_behaves_like :checks_permissions_on_noteable_examples
end end
shared_examples 'a reply to existing comment' do shared_examples 'a reply to existing comment' do
it "creates a comment" do it 'creates a comment' do
expect { receiver.execute }.to change { noteable.notes.count }.by(1) expect { receiver.execute }.to change { noteable.notes.count }.by(1)
new_note = noteable.notes.last new_note = noteable.notes.last
expect(new_note.author).to eq(sent_notification.recipient) expect(new_note.author).to eq(sent_notification.recipient)
expect(new_note.position).to eq(note.position) expect(new_note.position).to eq(note.position)
expect(new_note.note).to include("I could not disagree more.") expect(new_note.note).to include('I could not disagree more.')
expect(new_note.in_reply_to?(note)).to be_truthy expect(new_note.in_reply_to?(note)).to be_truthy
if note.part_of_discussion? if note.part_of_discussion?
...@@ -172,32 +76,14 @@ RSpec.describe Gitlab::Email::Handler::CreateNoteHandler do ...@@ -172,32 +76,14 @@ RSpec.describe Gitlab::Email::Handler::CreateNoteHandler do
end end
end end
context "when everything is fine" do # additional shared tests in :reply_processing_shared_examples
context 'when everything is fine' do
before do before do
setup_attachment setup_attachment
end end
it_behaves_like 'a reply to existing comment' it_behaves_like 'a reply to existing comment'
it "adds all attachments" do
expect_next_instance_of(Gitlab::Email::AttachmentUploader) do |uploader|
expect(uploader).to receive(:execute).with(upload_parent: project, uploader_class: FileUploader).and_return(
[
{
url: "uploads/image.png",
alt: "image",
markdown: markdown
}
]
)
end
receiver.execute
note = noteable.notes.last
expect(note.note).to include(markdown)
end
context 'when sub-addressing is not supported' do context 'when sub-addressing is not supported' do
before do before do
stub_incoming_email_setting(enabled: true, address: nil) stub_incoming_email_setting(enabled: true, address: nil)
...@@ -228,75 +114,9 @@ RSpec.describe Gitlab::Email::Handler::CreateNoteHandler do ...@@ -228,75 +114,9 @@ RSpec.describe Gitlab::Email::Handler::CreateNoteHandler do
end end
end end
context "when note is not a discussion" do context 'when note is not a discussion' do
let(:note) { create(:note_on_merge_request, project: project) } let(:note) { create(:note_on_merge_request, project: project) }
it_behaves_like 'a reply to existing comment' it_behaves_like 'a reply to existing comment'
end end
context 'when the service desk' do
let(:project) { create(:project, :public, service_desk_enabled: true) }
let(:support_bot) { User.support_bot }
let(:noteable) { create(:issue, project: project, author: support_bot, title: 'service desk issue') }
let(:note) { create(:note, project: project, noteable: noteable) }
let(:email_raw) { fixture_file('emails/valid_reply_with_quick_actions.eml') }
let!(:sent_notification) do
SentNotification.record_note(note, support_bot.id, mail_key)
end
context 'is enabled' do
before do
allow(Gitlab::ServiceDesk).to receive(:enabled?).with(project: project).and_return(true)
project.project_feature.update!(issues_access_level: issues_access_level)
end
context 'when issues are enabled for everyone' do
let(:issues_access_level) { ProjectFeature::ENABLED }
it 'creates a comment' do
expect { receiver.execute }.to change { noteable.notes.count }.by(1)
end
context 'when quick actions are present' do
it 'encloses quick actions with code span markdown' do
receiver.execute
noteable.reload
note = Note.last
expect(note.note).to include("Jake out\n\n`/close`\n`/title test`")
expect(noteable.title).to eq('service desk issue')
expect(noteable).to be_opened
end
end
end
context 'when issues are protected members only' do
let(:issues_access_level) { ProjectFeature::PRIVATE }
it 'creates a comment' do
expect { receiver.execute }.to change { noteable.notes.count }.by(1)
end
end
context 'when issues are disabled' do
let(:issues_access_level) { ProjectFeature::DISABLED }
it 'does not create a comment' do
expect { receiver.execute }.to raise_error(Gitlab::Email::UserNotAuthorizedError)
end
end
end
context 'is disabled' do
before do
allow(Gitlab::ServiceDesk).to receive(:enabled?).and_return(false)
allow(Gitlab::ServiceDesk).to receive(:enabled?).with(project: project).and_return(false)
end
it 'does not create a comment' do
expect { receiver.execute }.to raise_error(Gitlab::Email::ProjectNotFound)
end
end
end
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Email::Handler::CreateNoteOnIssuableHandler do
include_context :email_shared_context
let_it_be(:user) { create(:user, email: 'jake@adventuretime.ooo', incoming_email_token: 'auth_token') }
let_it_be(:namespace) { create(:namespace, path: 'gitlabhq') }
let_it_be(:project) { create(:project, :public, namespace: namespace, path: 'gitlabhq') }
let!(:noteable) { create(:issue, project: project) }
let(:email_raw) { email_fixture('emails/valid_note_on_issuable.eml') }
before do
stub_incoming_email_setting(enabled: true, address: "incoming+%{key}@appmail.adventuretime.ooo")
stub_config_setting(host: 'localhost')
end
it_behaves_like :reply_processing_shared_examples
it_behaves_like :note_handler_shared_examples, true do
let_it_be(:recipient) { user }
let(:update_commands_only) { email_reply_fixture('emails/update_commands_only_reply.eml') }
let(:no_content) { email_reply_fixture('emails/no_content_reply.eml') }
let(:commands_in_reply) { email_reply_fixture('emails/commands_in_reply.eml') }
let(:with_quick_actions) { email_reply_fixture('emails/valid_reply_with_quick_actions.eml') }
end
context 'when the recipient address does not include a mail key' do
let(:mail_key) { 'gitlabhq-gitlabhq-project_id-auth_token-issue-issue_iid' }
let(:email_raw) { fixture_file('emails/valid_note_on_issuable.eml').gsub(mail_key, '') }
it 'raises an UnknownIncomingEmail' do
expect { receiver.execute }.to raise_error(Gitlab::Email::UnknownIncomingEmail)
end
end
context 'when issue is confidential' do
before do
noteable.update_attribute(:confidential, true)
end
it_behaves_like :checks_permissions_on_noteable_examples
end
def email_fixture(path)
fixture_file(path)
.gsub('project_id', project.project_id.to_s)
.gsub('issue_iid', noteable.iid.to_s)
end
def email_reply_fixture(path)
reply_address = 'reply+59d8df8370b7e95c5a49fbf86aeb2c93'
note_address = "incoming+#{project.full_path_slug}-#{project.project_id}-#{user.incoming_email_token}-issue-#{noteable.iid}"
fixture_file(path)
.gsub(reply_address, note_address)
end
end
...@@ -60,8 +60,9 @@ RSpec.describe Gitlab::Email::Handler do ...@@ -60,8 +60,9 @@ RSpec.describe Gitlab::Email::Handler do
describe 'regexps are set properly' do describe 'regexps are set properly' do
let(:addresses) do let(:addresses) do
%W(sent_notification_key#{Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX} sent_notification_key path-to-project-123-user_email_token-merge-request) + %W(sent_notification_key#{Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX} sent_notification_key#{Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX_LEGACY}) +
%W(sent_notification_key#{Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX_LEGACY} sent_notification_key path-to-project-123-user_email_token-issue) + %w(sent_notification_key path-to-project-123-user_email_token-merge-request) +
%w(path-to-project-123-user_email_token-issue path-to-project-123-user_email_token-issue-123) +
%w(path/to/project+user_email_token path/to/project+merge-request+user_email_token some/project) %w(path/to/project+user_email_token path/to/project+merge-request+user_email_token some/project)
end end
......
...@@ -25,8 +25,8 @@ RSpec.describe Noteable do ...@@ -25,8 +25,8 @@ RSpec.describe Noteable do
let(:active_position2) do let(:active_position2) do
Gitlab::Diff::Position.new( Gitlab::Diff::Position.new(
old_path: "files/ruby/popen.rb", old_path: 'files/ruby/popen.rb',
new_path: "files/ruby/popen.rb", new_path: 'files/ruby/popen.rb',
old_line: 16, old_line: 16,
new_line: 22, new_line: 22,
diff_refs: subject.diff_refs diff_refs: subject.diff_refs
...@@ -35,11 +35,11 @@ RSpec.describe Noteable do ...@@ -35,11 +35,11 @@ RSpec.describe Noteable do
let(:outdated_position) do let(:outdated_position) do
Gitlab::Diff::Position.new( Gitlab::Diff::Position.new(
old_path: "files/ruby/popen.rb", old_path: 'files/ruby/popen.rb',
new_path: "files/ruby/popen.rb", new_path: 'files/ruby/popen.rb',
old_line: nil, old_line: nil,
new_line: 9, new_line: 9,
diff_refs: project.commit("874797c3a73b60d2187ed6e2fcabd289ff75171e").diff_refs diff_refs: project.commit('874797c3a73b60d2187ed6e2fcabd289ff75171e').diff_refs
) )
end end
...@@ -80,7 +80,7 @@ RSpec.describe Noteable do ...@@ -80,7 +80,7 @@ RSpec.describe Noteable do
describe '#grouped_diff_discussions' do describe '#grouped_diff_discussions' do
let(:grouped_diff_discussions) { subject.grouped_diff_discussions } let(:grouped_diff_discussions) { subject.grouped_diff_discussions }
it "includes active discussions" do it 'includes active discussions' do
discussions = grouped_diff_discussions.values.flatten discussions = grouped_diff_discussions.values.flatten
expect(discussions.count).to eq(2) expect(discussions.count).to eq(2)
...@@ -91,17 +91,17 @@ RSpec.describe Noteable do ...@@ -91,17 +91,17 @@ RSpec.describe Noteable do
expect(discussions.last.notes).to eq([active_diff_note3]) expect(discussions.last.notes).to eq([active_diff_note3])
end end
it "doesn't include outdated discussions" do it 'does not include outdated discussions' do
expect(grouped_diff_discussions.values.flatten.map(&:id)).not_to include(outdated_diff_note1.discussion_id) expect(grouped_diff_discussions.values.flatten.map(&:id)).not_to include(outdated_diff_note1.discussion_id)
end end
it "groups the discussions by line code" do it 'groups the discussions by line code' do
expect(grouped_diff_discussions[active_diff_note1.line_code].first.id).to eq(active_diff_note1.discussion_id) expect(grouped_diff_discussions[active_diff_note1.line_code].first.id).to eq(active_diff_note1.discussion_id)
expect(grouped_diff_discussions[active_diff_note3.line_code].first.id).to eq(active_diff_note3.discussion_id) expect(grouped_diff_discussions[active_diff_note3.line_code].first.id).to eq(active_diff_note3.discussion_id)
end end
end end
context "discussion status" do context 'discussion status' do
let(:first_discussion) { build_stubbed(:discussion_note_on_merge_request, noteable: subject, project: project).to_discussion } let(:first_discussion) { build_stubbed(:discussion_note_on_merge_request, noteable: subject, project: project).to_discussion }
let(:second_discussion) { build_stubbed(:discussion_note_on_merge_request, noteable: subject, project: project).to_discussion } let(:second_discussion) { build_stubbed(:discussion_note_on_merge_request, noteable: subject, project: project).to_discussion }
let(:third_discussion) { build_stubbed(:discussion_note_on_merge_request, noteable: subject, project: project).to_discussion } let(:third_discussion) { build_stubbed(:discussion_note_on_merge_request, noteable: subject, project: project).to_discussion }
...@@ -110,56 +110,56 @@ RSpec.describe Noteable do ...@@ -110,56 +110,56 @@ RSpec.describe Noteable do
allow(subject).to receive(:resolvable_discussions).and_return([first_discussion, second_discussion, third_discussion]) allow(subject).to receive(:resolvable_discussions).and_return([first_discussion, second_discussion, third_discussion])
end end
describe "#discussions_resolvable?" do describe '#discussions_resolvable?' do
context "when all discussions are unresolvable" do context 'when all discussions are unresolvable' do
before do before do
allow(first_discussion).to receive(:resolvable?).and_return(false) allow(first_discussion).to receive(:resolvable?).and_return(false)
allow(second_discussion).to receive(:resolvable?).and_return(false) allow(second_discussion).to receive(:resolvable?).and_return(false)
allow(third_discussion).to receive(:resolvable?).and_return(false) allow(third_discussion).to receive(:resolvable?).and_return(false)
end end
it "returns false" do it 'returns false' do
expect(subject.discussions_resolvable?).to be false expect(subject.discussions_resolvable?).to be false
end end
end end
context "when some discussions are unresolvable and some discussions are resolvable" do context 'when some discussions are unresolvable and some discussions are resolvable' do
before do before do
allow(first_discussion).to receive(:resolvable?).and_return(true) allow(first_discussion).to receive(:resolvable?).and_return(true)
allow(second_discussion).to receive(:resolvable?).and_return(false) allow(second_discussion).to receive(:resolvable?).and_return(false)
allow(third_discussion).to receive(:resolvable?).and_return(true) allow(third_discussion).to receive(:resolvable?).and_return(true)
end end
it "returns true" do it 'returns true' do
expect(subject.discussions_resolvable?).to be true expect(subject.discussions_resolvable?).to be true
end end
end end
context "when all discussions are resolvable" do context 'when all discussions are resolvable' do
before do before do
allow(first_discussion).to receive(:resolvable?).and_return(true) allow(first_discussion).to receive(:resolvable?).and_return(true)
allow(second_discussion).to receive(:resolvable?).and_return(true) allow(second_discussion).to receive(:resolvable?).and_return(true)
allow(third_discussion).to receive(:resolvable?).and_return(true) allow(third_discussion).to receive(:resolvable?).and_return(true)
end end
it "returns true" do it 'returns true' do
expect(subject.discussions_resolvable?).to be true expect(subject.discussions_resolvable?).to be true
end end
end end
end end
describe "#discussions_resolved?" do describe '#discussions_resolved?' do
context "when discussions are not resolvable" do context 'when discussions are not resolvable' do
before do before do
allow(subject).to receive(:discussions_resolvable?).and_return(false) allow(subject).to receive(:discussions_resolvable?).and_return(false)
end end
it "returns false" do it 'returns false' do
expect(subject.discussions_resolved?).to be false expect(subject.discussions_resolved?).to be false
end end
end end
context "when discussions are resolvable" do context 'when discussions are resolvable' do
before do before do
allow(subject).to receive(:discussions_resolvable?).and_return(true) allow(subject).to receive(:discussions_resolvable?).and_return(true)
...@@ -168,31 +168,31 @@ RSpec.describe Noteable do ...@@ -168,31 +168,31 @@ RSpec.describe Noteable do
allow(third_discussion).to receive(:resolvable?).and_return(true) allow(third_discussion).to receive(:resolvable?).and_return(true)
end end
context "when all resolvable discussions are resolved" do context 'when all resolvable discussions are resolved' do
before do before do
allow(first_discussion).to receive(:resolved?).and_return(true) allow(first_discussion).to receive(:resolved?).and_return(true)
allow(third_discussion).to receive(:resolved?).and_return(true) allow(third_discussion).to receive(:resolved?).and_return(true)
end end
it "returns true" do it 'returns true' do
expect(subject.discussions_resolved?).to be true expect(subject.discussions_resolved?).to be true
end end
end end
context "when some resolvable discussions are not resolved" do context 'when some resolvable discussions are not resolved' do
before do before do
allow(first_discussion).to receive(:resolved?).and_return(true) allow(first_discussion).to receive(:resolved?).and_return(true)
allow(third_discussion).to receive(:resolved?).and_return(false) allow(third_discussion).to receive(:resolved?).and_return(false)
end end
it "returns false" do it 'returns false' do
expect(subject.discussions_resolved?).to be false expect(subject.discussions_resolved?).to be false
end end
end end
end end
end end
describe "#discussions_to_be_resolved" do describe '#discussions_to_be_resolved' do
before do before do
allow(first_discussion).to receive(:to_be_resolved?).and_return(true) allow(first_discussion).to receive(:to_be_resolved?).and_return(true)
allow(second_discussion).to receive(:to_be_resolved?).and_return(false) allow(second_discussion).to receive(:to_be_resolved?).and_return(false)
...@@ -245,6 +245,12 @@ RSpec.describe Noteable do ...@@ -245,6 +245,12 @@ RSpec.describe Noteable do
end end
end end
describe '.email_creatable_types' do
it 'exposes the email creatable types' do
expect(described_class.email_creatable_types).to include('Issue')
end
end
describe '#capped_notes_count' do describe '#capped_notes_count' do
context 'notes number < 10' do context 'notes number < 10' do
it 'the number of notes is returned' do it 'the number of notes is returned' do
...@@ -263,13 +269,13 @@ RSpec.describe Noteable do ...@@ -263,13 +269,13 @@ RSpec.describe Noteable do
end end
end end
describe "#has_any_diff_note_positions?" do describe '#has_any_diff_note_positions?' do
let(:source_branch) { "compare-with-merge-head-source" } let(:source_branch) { 'compare-with-merge-head-source' }
let(:target_branch) { "compare-with-merge-head-target" } let(:target_branch) { 'compare-with-merge-head-target' }
let(:merge_request) { create(:merge_request, source_branch: source_branch, target_branch: target_branch) } let(:merge_request) { create(:merge_request, source_branch: source_branch, target_branch: target_branch) }
let!(:note) do let!(:note) do
path = "files/markdown/ruby-style-guide.md" path = 'files/markdown/ruby-style-guide.md'
position = Gitlab::Diff::Position.new( position = Gitlab::Diff::Position.new(
old_path: path, old_path: path,
...@@ -286,20 +292,54 @@ RSpec.describe Noteable do ...@@ -286,20 +292,54 @@ RSpec.describe Noteable do
Discussions::CaptureDiffNotePositionsService.new(merge_request).execute Discussions::CaptureDiffNotePositionsService.new(merge_request).execute
end end
it "returns true when it has diff note positions" do it 'returns true when it has diff note positions' do
expect(merge_request.has_any_diff_note_positions?).to be(true) expect(merge_request.has_any_diff_note_positions?).to be(true)
end end
it "returns false when it has notes but no diff note positions" do it 'returns false when it has notes but no diff note positions' do
DiffNotePosition.where(note: note).find_each(&:delete) DiffNotePosition.where(note: note).find_each(&:delete)
expect(merge_request.has_any_diff_note_positions?).to be(false) expect(merge_request.has_any_diff_note_positions?).to be(false)
end end
it "returns false when it has no notes" do it 'returns false when it has no notes' do
merge_request.notes.find_each(&:destroy) merge_request.notes.find_each(&:destroy)
expect(merge_request.has_any_diff_note_positions?).to be(false) expect(merge_request.has_any_diff_note_positions?).to be(false)
end end
end end
describe '#creatable_note_email_address' do
let_it_be(:user) { create(:user) }
let_it_be(:source_branch) { 'compare-with-merge-head-source' }
let(:issue) { create(:issue, project: project) }
let(:snippet) { build(:snippet) }
context 'incoming email enabled' do
before do
stub_incoming_email_setting(enabled: true, address: "p+%{key}@gl.ab")
end
it 'returns the address to create a note' do
address = "p+#{project.full_path_slug}-#{project.project_id}-#{user.incoming_email_token}-issue-#{issue.iid}@gl.ab"
expect(issue.creatable_note_email_address(user)).to eq(address)
end
it 'returns nil for unsupported types' do
expect(snippet.creatable_note_email_address(user)).to be_nil
end
end
context 'incoming email disabled' do
before do
stub_incoming_email_setting(enabled: false)
end
it 'returns nil' do
expect(issue.creatable_note_email_address(user)).to be_nil
end
end
end
end end
# frozen_string_literal: true # frozen_string_literal: true
RSpec.shared_context :email_shared_context do RSpec.shared_context :email_shared_context do
let(:mail_key) { "59d8df8370b7e95c5a49fbf86aeb2c93" } let(:mail_key) { '59d8df8370b7e95c5a49fbf86aeb2c93' }
let(:receiver) { Gitlab::Email::Receiver.new(email_raw) } let(:receiver) { Gitlab::Email::Receiver.new(email_raw) }
let(:markdown) { "![image](uploads/image.png)" } let(:markdown) { '![image](uploads/image.png)' }
def setup_attachment def setup_attachment
allow_any_instance_of(Gitlab::Email::AttachmentUploader).to receive(:execute).and_return( allow_any_instance_of(Gitlab::Email::AttachmentUploader).to receive(:execute).and_return(
[ [
{ {
url: "uploads/image.png", url: 'uploads/image.png',
alt: "image", alt: 'image',
markdown: markdown markdown: markdown
} }
] ]
...@@ -19,23 +19,252 @@ RSpec.shared_context :email_shared_context do ...@@ -19,23 +19,252 @@ RSpec.shared_context :email_shared_context do
end end
RSpec.shared_examples :reply_processing_shared_examples do RSpec.shared_examples :reply_processing_shared_examples do
context "when the user could not be found" do context 'when the user could not be found' do
before do before do
user.destroy! user.destroy!
end end
it "raises a UserNotFoundError" do it 'raises a UserNotFoundError' do
expect { receiver.execute }.to raise_error(Gitlab::Email::UserNotFoundError) expect { receiver.execute }.to raise_error(Gitlab::Email::UserNotFoundError)
end end
end end
context "when the user is not authorized to the project" do context 'when the user is not authorized to the project' do
before do before do
project.update_attribute(:visibility_level, Project::PRIVATE) project.update_attribute(:visibility_level, Project::PRIVATE)
end end
it "raises a ProjectNotFound" do it 'raises a ProjectNotFound' do
expect { receiver.execute }.to raise_error(Gitlab::Email::ProjectNotFound) expect { receiver.execute }.to raise_error(Gitlab::Email::ProjectNotFound)
end end
end end
end end
RSpec.shared_examples :checks_permissions_on_noteable_examples do
context 'when user has access' do
before do
project.add_reporter(user)
end
it 'creates a comment' do
expect { receiver.execute }.to change { noteable.notes.count }.by(1)
end
end
context 'when user does not have access' do
it 'raises UserNotAuthorizedError' do
expect { receiver.execute }.to raise_error(Gitlab::Email::UserNotAuthorizedError)
end
end
end
RSpec.shared_examples :note_handler_shared_examples do |forwardable|
context 'when the noteable could not be found' do
before do
noteable.destroy!
end
it 'raises a NoteableNotFoundError' do
expect { receiver.execute }.to raise_error(Gitlab::Email::NoteableNotFoundError)
end
end
context 'when the note could not be saved' do
before do
allow_any_instance_of(Note).to receive(:persisted?).and_return(false)
end
it 'raises an InvalidNoteError' do
expect { receiver.execute }.to raise_error(Gitlab::Email::InvalidNoteError)
end
context 'because the note was update commands only' do
let!(:email_raw) { update_commands_only }
context 'and current user cannot update noteable' do
it 'raises a CommandsOnlyNoteError' do
expect { receiver.execute }.to raise_error(Gitlab::Email::InvalidNoteError)
end
end
context 'and current user can update noteable' do
before do
project.add_developer(user)
end
it 'does not raise an error', unless: forwardable do
expect { receiver.execute }.to change { noteable.resource_state_events.count }.by(1)
expect(noteable.reload).to be_closed
end
it 'raises an InvalidNoteError', if: forwardable do
expect { receiver.execute }.to raise_error(Gitlab::Email::InvalidNoteError)
end
end
end
end
context 'when the note contains quick actions' do
let!(:email_raw) { commands_in_reply }
context 'and current user cannot update the noteable' do
it 'only executes the commands that the user can perform' do
expect { receiver.execute }
.to change { noteable.notes.user.count }.by(1)
.and change { user.todos_pending_count }.from(0).to(1)
expect(noteable.reload).to be_open
end
end
context 'and current user can update noteable' do
before do
project.add_developer(user)
end
it 'posts a note and updates the noteable' do
expect(TodoService.new.todo_exist?(noteable, user)).to be_falsy
expect { receiver.execute }
.to change { noteable.notes.user.count }.by(1)
.and change { user.todos_pending_count }.from(0).to(1)
expect(noteable.reload).to be_closed
end
end
end
context 'when the reply is blank' do
let!(:email_raw) { no_content }
it 'raises an EmptyEmailError', unless: forwardable do
expect { receiver.execute }.to raise_error(Gitlab::Email::EmptyEmailError)
end
it 'allows email to only have quoted text', if: forwardable do
expect { receiver.execute }.not_to raise_error(Gitlab::Email::EmptyEmailError)
end
end
context 'when discussion is locked' do
before do
noteable.update_attribute(:discussion_locked, true)
end
it_behaves_like :checks_permissions_on_noteable_examples
end
context 'when everything is fine' do
before do
setup_attachment
end
it 'adds all attachments' do
expect_next_instance_of(Gitlab::Email::AttachmentUploader) do |uploader|
expect(uploader).to receive(:execute).with(upload_parent: project, uploader_class: FileUploader).and_return(
[
{
url: 'uploads/image.png',
alt: 'image',
markdown: markdown
}
]
)
end
receiver.execute
note = noteable.notes.last
expect(note.note).to include(markdown)
expect(note.note).to include('Jake out')
end
end
context 'when the service desk' do
let(:project) { create(:project, :public, service_desk_enabled: true) }
let(:support_bot) { User.support_bot }
let(:noteable) { create(:issue, project: project, author: support_bot, title: 'service desk issue') }
let!(:note) { create(:note, project: project, noteable: noteable) }
let(:email_raw) { with_quick_actions }
let!(:sent_notification) do
SentNotification.record_note(note, support_bot.id, mail_key)
end
context 'is enabled' do
before do
allow(Gitlab::ServiceDesk).to receive(:enabled?).with(project: project).and_return(true)
project.project_feature.update!(issues_access_level: issues_access_level)
end
context 'when issues are enabled for everyone' do
let(:issues_access_level) { ProjectFeature::ENABLED }
it 'creates a comment' do
expect { receiver.execute }.to change { noteable.notes.count }.by(1)
end
context 'when quick actions are present' do
before do
receiver.execute
noteable.reload
end
context 'when author is a support_bot', unless: forwardable do
it 'encloses quick actions with code span markdown' do
note = Note.last
expect(note.note).to include("Jake out\n\n`/close`\n`/title test`")
expect(noteable.title).to eq('service desk issue')
expect(noteable).to be_opened
end
end
context 'when author is a normal user', if: forwardable do
it 'extracted the quick actions' do
note = Note.last
expect(note.note).to include('Jake out')
expect(note.note).not_to include("`/close`\n`/title test`")
end
end
end
end
context 'when issues are protected members only' do
let(:issues_access_level) { ProjectFeature::PRIVATE }
before do
if recipient.support_bot?
@changed_by = 1
else
@changed_by = 2
project.add_developer(recipient)
end
end
it 'creates a comment' do
expect { receiver.execute }.to change { noteable.notes.count }.by(@changed_by)
end
end
context 'when issues are disabled' do
let(:issues_access_level) { ProjectFeature::DISABLED }
it 'does not create a comment' do
expect { receiver.execute }.to raise_error(Gitlab::Email::UserNotAuthorizedError)
end
end
end
context 'is disabled', unless: forwardable do
before do
allow(Gitlab::ServiceDesk).to receive(:enabled?).and_return(false)
allow(Gitlab::ServiceDesk).to receive(:enabled?).with(project: project).and_return(false)
end
it 'does not create a comment' do
expect { receiver.execute }.to raise_error(Gitlab::Email::ProjectNotFound)
end
end
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