Commit 03613793 authored by Mikołaj Wawrzyniak's avatar Mikołaj Wawrzyniak

Merge branch '276897-add-external-issue-links-to-graphql' into 'master'

Extend Vulnerability GraphQL API with External Issue Links

See merge request gitlab-org/gitlab!48616
parents bcbd581e 334beb40
......@@ -8817,6 +8817,46 @@ enum EpicWildcardId {
NONE
}
"""
Represents an external issue
"""
type ExternalIssue {
"""
Timestamp of when the issue was created
"""
createdAt: Time
"""
Type of external tracker
"""
externalTracker: String
"""
Relative reference of the issue in the external tracker
"""
relativeReference: String
"""
Status of the issue in the external tracker
"""
status: String
"""
Title of the issue in the external tracker
"""
title: String
"""
Timestamp of when the issue was updated
"""
updatedAt: Time
"""
URL to the issue in the external tracker
"""
webUrl: String
}
type GeoNode {
"""
The maximum concurrency of container repository sync for this secondary node
......@@ -24757,6 +24797,11 @@ type VulnerabilitiesCountByDayEdge {
node: VulnerabilitiesCountByDay
}
"""
Identifier of Vulnerabilities::ExternalIssueLink
"""
scalar VulnerabilitiesExternalIssueLinkID
"""
Represents a vulnerability
"""
......@@ -24801,6 +24846,31 @@ type Vulnerability implements Noteable {
last: Int
): DiscussionConnection!
"""
List of external issue links related to the vulnerability
"""
externalIssueLinks(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
): VulnerabilityExternalIssueLinkConnection!
"""
GraphQL ID of the vulnerability
"""
......@@ -25039,6 +25109,71 @@ type VulnerabilityEdge {
node: Vulnerability
}
"""
Represents an external issue link of a vulnerability
"""
type VulnerabilityExternalIssueLink {
"""
The external issue attached to the issue link
"""
externalIssue: ExternalIssue
"""
GraphQL ID of the external issue link
"""
id: VulnerabilitiesExternalIssueLinkID!
"""
Type of the external issue link
"""
linkType: VulnerabilityExternalIssueLinkType!
}
"""
The connection type for VulnerabilityExternalIssueLink.
"""
type VulnerabilityExternalIssueLinkConnection {
"""
A list of edges.
"""
edges: [VulnerabilityExternalIssueLinkEdge]
"""
A list of nodes.
"""
nodes: [VulnerabilityExternalIssueLink]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type VulnerabilityExternalIssueLinkEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: VulnerabilityExternalIssueLink
}
"""
The type of the external issue link related to a vulnerability
"""
enum VulnerabilityExternalIssueLinkType {
"""
Created link type
"""
CREATED
}
"""
The grade of the vulnerable project
"""
......@@ -25307,6 +25442,11 @@ type VulnerabilityPermissions {
"""
adminVulnerability: Boolean!
"""
Indicates the user can perform `admin_vulnerability_external_issue_link` on this resource
"""
adminVulnerabilityExternalIssueLink: Boolean!
"""
Indicates the user can perform `admin_vulnerability_issue_link` on this resource
"""
......
......@@ -1495,6 +1495,20 @@ Autogenerated return type of EpicTreeReorder.
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
### ExternalIssue
Represents an external issue.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `createdAt` | Time | Timestamp of when the issue was created |
| `externalTracker` | String | Type of external tracker |
| `relativeReference` | String | Relative reference of the issue in the external tracker |
| `status` | String | Status of the issue in the external tracker |
| `title` | String | Title of the issue in the external tracker |
| `updatedAt` | Time | Timestamp of when the issue was updated |
| `webUrl` | String | URL to the issue in the external tracker |
### GeoNode
| Field | Type | Description |
......@@ -3731,6 +3745,7 @@ Represents a vulnerability.
| `description` | String | Description of the vulnerability |
| `detectedAt` | Time! | Timestamp of when the vulnerability was first detected |
| `discussions` | DiscussionConnection! | All discussions on this noteable |
| `externalIssueLinks` | VulnerabilityExternalIssueLinkConnection! | List of external issue links related to the vulnerability |
| `id` | ID! | GraphQL ID of the vulnerability |
| `identifiers` | VulnerabilityIdentifier! => Array | Identifiers of the vulnerability. |
| `issueLinks` | VulnerabilityIssueLinkConnection! | List of issue links related to the vulnerability |
......@@ -3768,6 +3783,16 @@ Autogenerated return type of VulnerabilityDismiss.
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `vulnerability` | Vulnerability | The vulnerability after dismissal |
### VulnerabilityExternalIssueLink
Represents an external issue link of a vulnerability.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `externalIssue` | ExternalIssue | The external issue attached to the issue link |
| `id` | VulnerabilitiesExternalIssueLinkID! | GraphQL ID of the external issue link |
| `linkType` | VulnerabilityExternalIssueLinkType! | Type of the external issue link |
### VulnerabilityIdentifier
Represents a vulnerability identifier.
......@@ -3862,6 +3887,7 @@ Check permissions for the current user on a vulnerability.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `adminVulnerability` | Boolean! | Indicates the user can perform `admin_vulnerability` on this resource |
| `adminVulnerabilityExternalIssueLink` | Boolean! | Indicates the user can perform `admin_vulnerability_external_issue_link` on this resource |
| `adminVulnerabilityIssueLink` | Boolean! | Indicates the user can perform `admin_vulnerability_issue_link` on this resource |
| `createVulnerability` | Boolean! | Indicates the user can perform `create_vulnerability` on this resource |
| `createVulnerabilityExport` | Boolean! | Indicates the user can perform `create_vulnerability_export` on this resource |
......@@ -4764,6 +4790,14 @@ Possible states of a user.
| `private` | |
| `public` | |
### VulnerabilityExternalIssueLinkType
The type of the external issue link related to a vulnerability.
| Value | Description |
| ----- | ----------- |
| `CREATED` | Created link type |
### VulnerabilityGrade
The grade of the vulnerable project.
......
# frozen_string_literal: true
module Projects
module Integrations
module Jira
class ByIdsFinder
include ReactiveService
self.reactive_cache_key = ->(finder) { [finder.model_name] }
self.reactive_cache_work_type = :external_dependency
self.reactive_cache_worker_finder = ->(_id, *cache_args) { from_cache(*cache_args) }
attr_reader :project, :jira_issue_ids
def self.from_cache(project_id, jira_issue_ids)
project = Project.find(project_id)
new(project, jira_issue_ids)
end
def initialize(project, jira_issue_ids)
@project = project
@jira_issue_ids = jira_issue_ids
end
def execute
with_reactive_cache(*cache_args) { |issues| issues }
end
def calculate_reactive_cache(*)
# rubocop: disable CodeReuse/Finder
::Projects::Integrations::Jira::IssuesFinder
.new(project, issue_ids: jira_issue_ids)
.execute
.then { |issues| { issues: issues, error: nil } }
rescue ::Projects::Integrations::Jira::IssuesFinder::IntegrationError, ::Projects::Integrations::Jira::IssuesFinder::RequestError => error
{ issues: [], error: error.message }
# rubocop: enable CodeReuse/Finder
end
def clear_cache!
clear_reactive_cache!(*cache_args)
end
def model_name
self.class.name.underscore.tr('/', '_')
end
def cache_args
[project.id, jira_issue_ids]
end
private
def id
nil
end
end
end
end
end
......@@ -13,7 +13,7 @@ module Projects
def valid_params
@valid_params ||= %i[page per_page search state status author_username assignee_username]
# to permit array params you need to init them to an empty array
@valid_params << { labels: [], vulnerability_ids: [] }
@valid_params << { labels: [], vulnerability_ids: [], issue_ids: [] }
end
end
......
# frozen_string_literal: true
module Resolvers
class ExternalIssueResolver < BaseResolver
description 'Retrieve a single issue from external tracker'
type Types::ExternalIssueType, null: true
def resolve
BatchLoader::GraphQL.for(object.external_issue_key).batch(key: object.external_type) do |external_issue_keys, loader, args|
case args[:key]
when 'jira'
jira_issues(external_issue_keys).each do |external_issue|
loader.call(
external_issue.id,
::Integrations::Jira::IssueSerializer.new.represent(external_issue, project: object.vulnerability.project)
)
end
end
end
end
private
def jira_issues(issue_ids)
result = ::Projects::Integrations::Jira::ByIdsFinder.new(object.vulnerability.project, issue_ids).execute
return [] if result.nil?
raise GraphQL::ExecutionError, result[:error] if result[:error].present?
result[:issues]
end
end
end
# frozen_string_literal: true
module Types
# rubocop: disable Graphql/AuthorizeTypes
class ExternalIssueType < BaseObject
graphql_name 'ExternalIssue'
description 'Represents an external issue'
field :title, GraphQL::STRING_TYPE, null: true,
description: 'Title of the issue in the external tracker'
field :relative_reference, GraphQL::STRING_TYPE, null: true,
description: 'Relative reference of the issue in the external tracker'
field :status, GraphQL::STRING_TYPE, null: true,
description: 'Status of the issue in the external tracker'
field :external_tracker, GraphQL::STRING_TYPE, null: true,
description: 'Type of external tracker'
field :web_url, GraphQL::STRING_TYPE, null: true,
description: 'URL to the issue in the external tracker'
field :created_at, Types::TimeType, null: true,
description: 'Timestamp of when the issue was created'
field :updated_at, Types::TimeType, null: true,
description: 'Timestamp of when the issue was updated'
def relative_reference
object.dig(:references, :relative)
end
end
# rubocop: enable Graphql/AuthorizeTypes
end
......@@ -8,7 +8,7 @@ module Types
abilities :read_vulnerability_feedback, :create_vulnerability_feedback, :destroy_vulnerability_feedback,
:update_vulnerability_feedback, :create_vulnerability, :create_vulnerability_export,
:admin_vulnerability, :admin_vulnerability_issue_link
:admin_vulnerability, :admin_vulnerability_issue_link, :admin_vulnerability_external_issue_link
end
end
end
# frozen_string_literal: true
module Types
module Vulnerability
class ExternalIssueLinkType < BaseObject
graphql_name 'VulnerabilityExternalIssueLink'
description 'Represents an external issue link of a vulnerability'
authorize :read_vulnerability
field :id, GlobalIDType[::Vulnerabilities::ExternalIssueLink], null: false,
description: 'GraphQL ID of the external issue link'
field :link_type, ::Types::Vulnerability::ExternalIssueLinkTypeEnum, null: false,
description: 'Type of the external issue link'
field :external_issue, ::Types::ExternalIssueType, null: true,
description: 'The external issue attached to the issue link',
resolver: Resolvers::ExternalIssueResolver
end
end
end
# frozen_string_literal: true
module Types
module Vulnerability
class ExternalIssueLinkTypeEnum < BaseEnum
graphql_name 'VulnerabilityExternalIssueLinkType'
description 'The type of the external issue link related to a vulnerability'
::Vulnerabilities::ExternalIssueLink.link_types.keys.each do |link_type|
value link_type.to_s.upcase, value: link_type.to_s, description: "#{link_type.titleize} link type"
end
end
end
end
......@@ -42,6 +42,9 @@ module Types
description: "List of issue links related to the vulnerability",
resolver: Resolvers::Vulnerabilities::IssueLinksResolver
field :external_issue_links, ::Types::Vulnerability::ExternalIssueLinkType.connection_type, null: false,
description: 'List of external issue links related to the vulnerability'
field :location, VulnerabilityLocationType, null: true,
description: 'Location metadata for the vulnerability. Its fields depend on the type of security scan that found the vulnerability'
......
......@@ -223,6 +223,7 @@ module EE
enable :create_vulnerability_export
enable :admin_vulnerability
enable :admin_vulnerability_issue_link
enable :admin_vulnerability_external_issue_link
end
rule { issues_disabled & merge_requests_disabled }.policy do
......@@ -274,6 +275,7 @@ module EE
prevent :create_vulnerability
prevent :admin_vulnerability
prevent :admin_vulnerability_issue_link
prevent :admin_vulnerability_external_issue_link
end
rule { auditor & ~guest }.policy do
......
# frozen_string_literal: true
module Vulnerabilities
class ExternalIssueLinkPolicy < BasePolicy
delegate { @subject.vulnerability.project }
end
end
......@@ -19,6 +19,7 @@ module Jira
@sort = params[:sort] || DEFAULT_SORT
@sort_direction = params[:sort_direction] || DEFAULT_SORT_DIRECTION
@vulnerability_ids = params[:vulnerability_ids]
@issue_ids = params[:issue_ids]
end
def execute
......@@ -30,7 +31,7 @@ module Jira
private
attr_reader :jira_project_key, :sort, :sort_direction, :search, :labels, :status, :reporter, :assignee, :state, :vulnerability_ids
attr_reader :jira_project_key, :sort, :sort_direction, :search, :labels, :status, :reporter, :assignee, :state, :vulnerability_ids, :issue_ids
def jql_filters
[
......@@ -41,7 +42,8 @@ module Jira
by_assignee,
by_open_and_closed,
by_summary_and_description,
by_vulnerability_ids
by_vulnerability_ids,
by_issue_ids
].compact.join(' AND ')
end
......@@ -104,6 +106,15 @@ module Jira
.then { |query| "(#{query})" }
end
def by_issue_ids
return if issue_ids.blank?
issue_ids
.map { |issue_id| %Q[id = #{escape_quotes(issue_id.to_s)}] }
.join(' OR ')
.then { |query| "(#{query})" }
end
def escape_quotes(param)
param.gsub('\\', '\\\\\\').gsub('"', '\\"')
end
......
---
title: Extend Vulnerability GraphQL API with External Issue Links
merge_request: 48616
author:
type: added
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::Integrations::Jira::ByIdsFinder do
include ReactiveCachingHelpers
let_it_be(:project) { create(:project) }
let(:jira_issue_ids) { %w[10000 10001] }
let(:finder_params) { [project, issue_ids: jira_issue_ids] }
let(:by_ids_finder) { described_class.new(project, jira_issue_ids) }
describe '#execute' do
context 'when reactive_caching is still fetching data' do
it 'returns nil' do
expect(by_ids_finder.execute).to be_nil
end
end
context 'when reactive_caching has finished' do
before do
allow_next_instance_of(::Projects::Integrations::Jira::IssuesFinder, *finder_params) do |issues_finder|
allow(issues_finder).to receive(:execute).and_return([{ jira_issue: 1 }, { jira_issue: 2 }])
end
synchronous_reactive_cache(by_ids_finder)
end
it 'returns issues encapsulated in hash' do
expect(by_ids_finder.execute).to eq(issues: [{ jira_issue: 1 }, { jira_issue: 2 }], error: nil)
end
end
context 'when reactive_caching failed with ::Projects::Integrations::Jira::IssuesFinder::IntegrationError' do
before do
allow_next_instance_of(::Projects::Integrations::Jira::IssuesFinder, *finder_params) do |issues_finder|
allow(issues_finder).to receive(:execute).and_raise(::Projects::Integrations::Jira::IssuesFinder::IntegrationError, 'project key not set')
end
synchronous_reactive_cache(by_ids_finder)
end
it 'returns empty issues list with error message' do
expect(by_ids_finder.execute).to eq(issues: [], error: 'project key not set')
end
end
context 'when reactive_caching failed with ::Projects::Integrations::Jira::IssuesFinder::RequestError' do
before do
allow_next_instance_of(::Projects::Integrations::Jira::IssuesFinder, *finder_params) do |issues_finder|
allow(issues_finder).to receive(:execute).and_raise(::Projects::Integrations::Jira::IssuesFinder::RequestError, 'jira instance not found')
end
synchronous_reactive_cache(by_ids_finder)
end
it 'returns empty issues list with error message' do
expect(by_ids_finder.execute).to eq(issues: [], error: 'jira instance not found')
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::ExternalIssueResolver do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
context 'when Jira issues are requested' do
let_it_be(:vulnerability_external_issue_link) { create(:vulnerabilities_external_issue_link) }
let(:jira_issue) do
double(
id: vulnerability_external_issue_link.external_issue_key,
summary: 'Issue Title',
created: Time.at(1606348800).utc,
updated: Time.at(1606348800).utc,
resolutiondate: Time.at(1606348800).utc,
status: double(name: 'To Do'),
key: 'GV-1',
labels: [],
reporter: double(displayName: 'User', accountId: '10000'),
assignee: nil,
client: double(options: { site: nil })
)
end
let(:expected_result) do
{
'project_id' => vulnerability_external_issue_link.vulnerability.project_id,
'title' => 'Issue Title',
'created_at' => '2020-11-26T00:00:00.000Z',
'updated_at' => '2020-11-26T00:00:00.000Z',
'closed_at' => '2020-11-26T00:00:00.000Z',
'status' => 'To Do',
'labels' => [],
'author' => {
'name' => 'User',
'web_url' => 'people/10000'
},
'assignees' => [],
'web_url' => 'browse/GV-1',
'references' => {
'relative' => 'GV-1'
},
'external_tracker' => 'jira'
}
end
context 'when Jira API responds with nil' do
before do
allow_next_instance_of(::Projects::Integrations::Jira::ByIdsFinder) do |issues_finder|
allow(issues_finder).to receive(:execute).and_return(nil)
end
end
it 'sends request to Jira to fetch issues' do
params = [vulnerability_external_issue_link.vulnerability.project, [vulnerability_external_issue_link.external_issue_key]]
expect_next_instance_of(::Projects::Integrations::Jira::ByIdsFinder, *params) do |issues_finder|
expect(issues_finder).to receive(:execute).and_return(nil)
end
batch_sync { resolve_external_issue({}) }
end
it 'returns nil' do
result = batch_sync { resolve_external_issue({}) }
expect(result).to be_nil
end
end
context 'when Jira API responds with found issues' do
before do
allow_next_instance_of(::Projects::Integrations::Jira::ByIdsFinder) do |issues_finder|
allow(issues_finder).to receive(:execute).and_return(issues: [jira_issue])
end
end
it 'sends request to Jira to fetch issues' do
params = [vulnerability_external_issue_link.vulnerability.project, [vulnerability_external_issue_link.external_issue_key]]
expect_next_instance_of(::Projects::Integrations::Jira::ByIdsFinder, *params) do |issues_finder|
expect(issues_finder).to receive(:execute).and_return(issues: [jira_issue])
end
batch_sync { resolve_external_issue({}) }
end
it 'returns serialized Jira issues' do
result = batch_sync { resolve_external_issue({}) }
expect(result.as_json).to eq(expected_result)
end
end
context 'when Jira API responds with an integration error' do
before do
allow_next_instance_of(::Projects::Integrations::Jira::ByIdsFinder) do |issues_finder|
allow(issues_finder).to receive(:execute).and_return(error: 'Jira service not configured.')
end
end
it 'raises a GraphQL exception' do
expect { batch_sync { resolve_external_issue({}) } }.to raise_error(GraphQL::ExecutionError, 'Jira service not configured.')
end
end
context 'when Jira API responds with an request error' do
before do
allow_next_instance_of(::Projects::Integrations::Jira::ByIdsFinder) do |issues_finder|
allow(issues_finder).to receive(:execute).and_return(error: 'Jira service unavailable.')
end
end
it 'raises a GraphQL exception' do
expect { batch_sync { resolve_external_issue({}) } }.to raise_error(GraphQL::ExecutionError, 'Jira service unavailable.')
end
end
def resolve_external_issue(args)
resolve(described_class, obj: vulnerability_external_issue_link, args: args, ctx: { current_user: current_user })
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['ExternalIssue'] do
let(:expected_fields) { %i[title relative_reference status external_tracker web_url created_at updated_at] }
subject { described_class }
it { is_expected.to have_graphql_fields(expected_fields) }
end
......@@ -6,7 +6,7 @@ RSpec.describe Types::PermissionTypes::Vulnerability do
it do
expected_permissions = %i[read_vulnerability_feedback create_vulnerability_feedback destroy_vulnerability_feedback
update_vulnerability_feedback create_vulnerability create_vulnerability_export
admin_vulnerability admin_vulnerability_issue_link]
admin_vulnerability admin_vulnerability_issue_link admin_vulnerability_external_issue_link]
expected_permissions.each do |permission|
expect(described_class).to have_graphql_field(permission)
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['VulnerabilityExternalIssueLinkType'] do
let(:expected_values) { %w[CREATED] }
subject { described_class.values.keys }
it { is_expected.to contain_exactly(*expected_values) }
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['VulnerabilityExternalIssueLink'] do
let(:expected_fields) { %i[id link_type external_issue] }
subject { described_class }
it { is_expected.to have_graphql_fields(expected_fields) }
end
......@@ -26,6 +26,7 @@ RSpec.describe GitlabSchema.types['Vulnerability'] do
detected_at
confirmed_at
notes
external_issue_links
discussions]
end
......
......@@ -24,7 +24,7 @@ RSpec.describe ProjectPolicy do
%i[
admin_vulnerability_feedback read_project_audit_events read_project_security_dashboard
read_vulnerability read_vulnerability_scanner create_vulnerability create_vulnerability_export admin_vulnerability
admin_vulnerability_issue_link read_merge_train
admin_vulnerability_issue_link admin_vulnerability_external_issue_link read_merge_train
]
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Vulnerabilities::ExternalIssueLinkPolicy do
let!(:user) { create(:user) }
let!(:project) { create(:project) }
let!(:vulnerability) { create(:vulnerability, project: project) }
let!(:vulnerability_external_issue_link) { build(:vulnerabilities_external_issue_link, vulnerability: vulnerability, author: user) }
subject { described_class.new(user, vulnerability_external_issue_link) }
context 'when the security_dashboard feature is enabled' do
before do
stub_licensed_features(security_dashboard: true)
end
context "when the current user has developer access to the vulnerability's project" do
before do
project.add_developer(user)
end
it { is_expected.to be_allowed(:admin_vulnerability_external_issue_link) }
end
context "when the current user does not have developer access to the vulnerability's project" do
it { is_expected.to be_disallowed(:admin_vulnerability_external_issue_link) }
end
end
context 'when the security_dashboard feature is disabled' do
before do
stub_licensed_features(security_dashboard: false)
project.add_developer(user)
end
it { is_expected.to be_disallowed(:admin_vulnerability_external_issue_link) }
end
end
......@@ -117,5 +117,13 @@ RSpec.describe Jira::JqlBuilderService do
expect(subject).to eq('project = PROJECT_KEY AND (description ~ "/-/security/vulnerabilities/1" OR description ~ "/-/security/vulnerabilities/25") order by created DESC')
end
end
context 'with issue_ids params' do
let(:params) { { issue_ids: %w[1 25] } }
it 'builds jql' do
expect(subject).to eq('project = PROJECT_KEY AND (id = 1 OR id = 25) order by created DESC')
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