Commit 5eebb9b2 authored by Sean McGivern's avatar Sean McGivern

Merge branch '2381-jira-commits-integration' into 'master'

Create layer to integrate with Jira development panel (presenting commits)

Closes #2381

See merge request !2786
parents eca1fdc3 98d9638c
# This controller's role is to mimic and rewire the Gitlab OAuth
# flow routes for Jira DVCS integration.
# See https://gitlab.com/gitlab-org/gitlab-ee/issues/2381
#
class Oauth::Jira::AuthorizationsController < ApplicationController
skip_before_action :authenticate_user!
skip_before_action :verify_authenticity_token
# 1. Rewire Jira OAuth initial request to our stablished OAuth authorization URL.
def new
session[:redirect_uri] = params['redirect_uri']
redirect_to oauth_authorization_path(client_id: params['client_id'],
response_type: 'code',
redirect_uri: oauth_jira_callback_url)
end
# 2. Handle the callback call as we were a Github Enterprise instance client.
def callback
# Handling URI query params concatenation.
redirect_uri = URI.parse(session['redirect_uri'])
new_query = URI.decode_www_form(String(redirect_uri.query)) << ['code', params[:code]]
redirect_uri.query = URI.encode_www_form(new_query)
redirect_to redirect_uri.to_s
end
# 3. Rewire and adjust access_token request accordingly.
def access_token
auth_params = params
.slice(:code, :client_id, :client_secret)
.merge(grant_type: 'authorization_code', redirect_uri: oauth_jira_callback_url)
auth_response = HTTParty.post(oauth_token_url, body: auth_params)
token_type, scope, token = auth_response['token_type'], auth_response['scope'], auth_response['access_token']
render text: "access_token=#{token}&scope=#{scope}&token_type=#{token_type}"
end
end
......@@ -20,6 +20,7 @@ class License < ActiveRecord::Base
ISSUE_BOARD_MILESTONE_FEATURE = 'GitLab_IssueBoardMilestone'.freeze
ISSUE_WEIGHTS_FEATURE = 'GitLab_IssueWeights'.freeze
JENKINS_INTEGRATION_FEATURE = 'GitLab_JenkinsIntegration'.freeze
JIRA_DEV_PANEL_INTEGRATION_FEATURE = 'GitLab_JiraDevelopmentPanelIntegration'.freeze
LDAP_EXTRAS_FEATURE = 'GitLab_LdapExtras'.freeze
MERGE_REQUEST_APPROVERS_FEATURE = 'GitLab_MergeRequestApprovers'.freeze
MERGE_REQUEST_REBASE_FEATURE = 'GitLab_MergeRequestRebase'.freeze
......@@ -63,6 +64,7 @@ class License < ActiveRecord::Base
issue_board_milestone: ISSUE_BOARD_MILESTONE_FEATURE,
issue_weights: ISSUE_WEIGHTS_FEATURE,
jenkins_integration: JENKINS_INTEGRATION_FEATURE,
jira_dev_panel_integration: JIRA_DEV_PANEL_INTEGRATION_FEATURE,
merge_request_approvers: MERGE_REQUEST_APPROVERS_FEATURE,
merge_request_rebase: MERGE_REQUEST_REBASE_FEATURE,
merge_request_squash: MERGE_REQUEST_SQUASH_FEATURE,
......@@ -114,6 +116,7 @@ class License < ActiveRecord::Base
{ FILE_LOCKS_FEATURE => 1 },
{ GEO_FEATURE => 1 },
{ OBJECT_STORAGE_FEATURE => 1 },
{ JIRA_DEV_PANEL_INTEGRATION_FEATURE => 1 },
{ SERVICE_DESK_FEATURE => 1 },
{ VARIABLE_ENVIRONMENT_SCOPE_FEATURE => 1 }
].freeze
......
---
title: Commits integration with Jira development panel
merge_request:
author:
type: added
# Treats JIRA DVCS user agent requests in order to be successfully handled
# by our API.
Rails.application.config.middleware.use(Gitlab::Jira::Middleware)
......@@ -21,6 +21,12 @@ Rails.application.routes.draw do
authorizations: 'oauth/authorizations'
end
scope path: '/-/jira/login/oauth', controller: 'oauth/jira/authorizations', as: :oauth_jira do
get :authorize, action: :new
get :callback
post :access_token
end
namespace :oauth do
scope path: 'geo', controller: :geo_auth, as: :geo do
get 'auth'
......
......@@ -477,4 +477,17 @@ constraints(ProjectUrlConstrainer.new) do
end
end
end
# EE-specific
scope path: '/-/jira', as: :jira do
scope path: '*namespace_id', namespace_id: Gitlab::PathRegex.full_namespace_route_regex do
resources :projects, path: '/', constraints: { id: Gitlab::PathRegex.project_route_regex }, only: :show
scope path: ':project_id', constraints: { project_id: Gitlab::PathRegex.project_route_regex }, module: :projects do
resources :commit, only: :show, constraints: { id: /\h{7,40}/ }
get 'tree/*id', to: 'tree#show', as: nil
end
end
end
end
......@@ -139,6 +139,7 @@ Manage your [repositories](user/project/repository/index.md) from the UI (user i
- [Project Services](user/project/integrations/project_services.md): Integrate a project with external services, such as CI and chat.
- [GitLab Integration](integration/README.md): Integrate with multiple third-party services with GitLab to allow external issue trackers and external authentication.
- [Trello Power-Up](integration/trello_power_up.md): Integrate with GitLab's Trello Power-Up
- **(EEP)** [JIRA Development Panel](integration/jira_development_panel.md): See GitLab information in the JIRA Development Panel
----
......
# GitLab JIRA Development Panel integration
> [Introduced][ee-2786] in [GitLab Enterprise Edition Premium][eep] 9.1.
As an extension to our [existing JIRA][existing-jira] project integration, you're now able to integrate
all your GitLab projects with [JIRA Development Panel][jira-development-panel]. Both can be used
simultaneously. This works with self-hosted GitLab or GitLab.com integrated with self-hosted JIRA
or cloud JIRA.
By doing this you can easily access related GitLab branches and commits directly from a JIRA issue.
>**Note:**
In the future, we plan to also support merge requests from the Development Panel.
This integration connects all GitLab projects a user has access to with all projects in the JIRA instance.
(Note this is different from the [existing JIRA][existing-jira] project integration, where the mapping
is one GitLab project to the entire JIRA instance.) We recommend that a GitLab group admin
or instance admin (in the case of self-hosted GitLab) set up the integration with respect to their
account, in order to maximize the integrated GitLab projects used by your team.
## GitLab Configuration
1. In GitLab, create a new application in order to allow JIRA to connect with your GitLab account
While logged-in, go to `Settings -> Applications`. (Click your profile avatar at
the top right, choose `Settings`, and then navigate to `Applications` from the left
navigation menu.) Use the form to create a new application.
Enter a useful name for the `Name` field.
For the `Redirect URI` field, enter `https://<your-gitlab-instance-domain>/-/jira/login/oauth/callback`,
replacing `<your-gitlab-instance-domain>` appropriately. So for example, if you are using GitLab.com,
this would be `https://gitlab.com/-/jira/login/oauth/callback`.
![GitLab Application setup](img/jira_dev_panel_gl_setup_1.png)
- Check `api` in the Scopes section.
2. Click `Save application`. You will see the generated 'Application Id' and 'Secret' values.
Copy these values that you will use on the JIRA configuration side.
## JIRA Configuration
1. In JIRA, from the gear menu at the top right, go to `Applications`. Navigate to `DVCS accounts`
from the left navigation menu. Click `Link GitHub account` to start creating a new integration.
(We are pretending to be GitHub in this integration until there is further platform support from JIRA.)
![JIRA DVCS from Dashboard](img/jira_dev_panel_jira_setup_1.png)
2. Complete the form
Select GitHub Enterprise for the `Host` field.
For the `Team or User Account` field, enter the group name of a GitLab group that you have access to.
![Creation of Jira DVCS integration](img/jira_dev_panel_jira_setup_2.png)
For the `Host URL` field, enter `https://<your-gitlab-instance-domain>/-/jira`,
replacing `<your-gitlab-instance-domain>` appropriately. So for example, if you are using GitLab.com,
this would be `https://gitlab.com/-/jira`.
For the `Client ID` field, use the `Application ID` value from the previous section.
For the `Client Secret` field, use the `Secret` value from the previous section.
Ensure that the rest of the checkboxes are checked.
3. Click `Add` to complete and create the integration.
JIRA takes up to a few minutes to know about (import behind the scenes) all the commits and branches
for all the projects in the GitLab group you specified in the previous step. These are refreshed
every 60 minutes.
>**Note:**
In the future, we plan on implementating real-time integration. If you need
to refresh the data manually, you can do this from the `Applications -> DVCS
accounts` screen where you initially set up the integration:
> ![Refresh GitLab information in JIRA](img/jira_dev_panel_manual_refresh.png)
4. Repeat the above steps for each GitLab group's projects that you want to be made known to JIRA.
Specify the GitLab group name accordingly. (Note that you can also specify GitLab user names, as they
are really GitLab "groups" behind the scenes. In that case, all the projects for that user would
be made known to JIRA, up to the permissions of the user setting up the integration.)
You can now see the linked `branches` and `commits` when entering a JIRA issue.
![Branch and Commit links on JIRA issue](img/jira_dev_panel_jira_setup_3.png)
Click these links to see your GitLab repository data.
![GitLab commit details on a JIRA issue](img/jira_dev_panel_jira_setup_4.png)
[existing-jira]: ../user/project/integrations/jira.md
[jira-development-panel]: https://confluence.atlassian.com/adminjiraserver070/integrating-with-development-tools-776637096.html#Integratingwithdevelopmenttools-Developmentpanelonissues
[eep]: https://about.gitlab.com/gitlab-ee/
[ee-2786]: https://gitlab.com/gitlab-org/gitlab-ee/issues/2786
......@@ -49,6 +49,11 @@ module API
mount ::API::V3::Triggers
mount ::API::V3::Users
mount ::API::V3::Variables
# Although the following endpoints are kept behind V3 namespace, they're not
# deprecated neither should be removed when V3 get removed.
# They're needed as a layer to integrate with Jira Development Panel.
mount ::API::V3::Github
end
before { header['X-Frame-Options'] = 'SAMEORIGIN' }
......
# Simplified version of Github API entities.
# It's mainly used to mimic Github API and integrate with Jira Development Panel.
#
module API
module Github
module Entities
class Namespace < Grape::Entity
expose :path, as: :login
end
class Repository < Grape::Entity
expose :id
expose :namespace, as: :owner, using: Namespace
expose :name
end
class BranchCommit < Grape::Entity
expose :id, as: :sha
expose :type do |_|
'commit'
end
end
class RepoCommit < Grape::Entity
expose :id, as: :sha
expose :author do |commit|
{
login: commit.author&.username,
email: commit.author_email
}
end
expose :committer do |commit|
{
login: commit.author&.username,
email: commit.committer_email
}
end
expose :commit do |commit|
{
author: {
name: commit.author_name,
email: commit.author_email,
date: commit.authored_date.iso8601,
type: 'User'
},
committer: {
name: commit.committer_name,
email: commit.committer_email,
date: commit.committed_date.iso8601,
type: 'User'
},
message: commit.safe_message
}
end
expose :parents do |commit|
commit.parent_ids.map { |id| { sha: id } }
end
expose :files do |commit|
commit.diffs.diff_files.flat_map do |diff|
additions = diff.added_lines
deletions = diff.removed_lines
if diff.new_file?
{
status: 'added',
filename: diff.new_path,
additions: additions,
changes: additions
}
elsif diff.deleted_file?
{
status: 'removed',
filename: diff.old_path,
deletions: deletions,
changes: deletions
}
elsif diff.renamed_file?
[
{
status: 'removed',
filename: diff.old_path,
deletions: deletions,
changes: deletions
},
{
status: 'added',
filename: diff.new_path,
additions: additions,
changes: additions
}
]
else
{
status: 'modified',
filename: diff.new_path,
additions: additions,
deletions: deletions,
changes: (additions + deletions)
}
end
end
end
end
class Branch < Grape::Entity
expose :name
expose :commit, using: BranchCommit do |repo_branch, options|
options[:project].repository.commit(repo_branch.dereferenced_target)
end
end
end
end
end
# These endpoints partially mimic Github API behavior in order to successfully
# integrate with Jira Development Panel.
# Endpoints returning an empty list were temporarily added to avoid 404's
# during Jira's DVCS integration.
#
module API
module V3
class Github < Grape::API
include PaginationParams
before do
authorize_jira_user_agent!(request)
authenticate!
end
helpers do
params :project_full_path do
requires :namespace, type: String
requires :project, type: String
end
def authorize_jira_user_agent!(request)
not_found! unless Gitlab::Jira::Middleware.jira_dvcs_connector?(request.env)
end
def find_project_with_access(full_path)
project = find_project!(full_path)
not_found! unless project.feature_available?(:jira_dev_panel_integration)
project
end
end
resource :orgs do
get ':namespace/repos' do
present []
end
end
resource :user do
get :repos do
present []
end
end
resource :users do
params do
use :pagination
end
get ':namespace/repos' do
projects = current_user.authorized_projects.select { |project| project.feature_available?(:jira_dev_panel_integration) }
projects = ::Kaminari.paginate_array(projects)
present paginate(projects), with: ::API::Github::Entities::Repository
end
end
resource :repos do
get '/-/jira/pulls' do
present []
end
params do
use :project_full_path
use :pagination
end
get ':namespace/:project/branches' do
namespace = params[:namespace]
project = params[:project]
user_project = find_project_with_access("#{namespace}/#{project}")
branches = ::Kaminari.paginate_array(user_project.repository.branches.sort_by(&:name))
present paginate(branches), with: ::API::Github::Entities::Branch, project: user_project
end
params do
use :project_full_path
end
get ':namespace/:project/commits/:sha' do
namespace = params[:namespace]
project = params[:project]
user_project = find_project_with_access("#{namespace}/#{project}")
commit = user_project.commit(params[:sha])
not_found! 'Commit' unless commit
present commit, with: ::API::Github::Entities::RepoCommit
end
end
end
end
end
module Gitlab
module Jira
class Middleware
def self.jira_dvcs_connector?(env)
env['HTTP_USER_AGENT']&.start_with?('JIRA DVCS Connector')
end
def initialize(app)
@app = app
end
def call(env)
env['HTTP_AUTHORIZATION']&.sub!('token', 'Bearer') if self.class.jira_dvcs_connector?(env)
@app.call(env)
end
end
end
end
require 'spec_helper'
describe Oauth::Jira::AuthorizationsController do
describe 'GET new' do
it 'redirects to OAuth authorization with correct params' do
get :new, client_id: 'client-123', redirect_uri: 'http://example.com/'
expect(response).to redirect_to(oauth_authorization_url(client_id: 'client-123',
response_type: 'code',
redirect_uri: oauth_jira_callback_url))
end
end
describe 'GET callback' do
it 'redirects to redirect_uri on session with code param' do
session['redirect_uri'] = 'http://example.com'
get :callback, code: 'hash-123'
expect(response).to redirect_to('http://example.com?code=hash-123')
end
it 'redirects to redirect_uri on session with code param preserving existing query' do
session['redirect_uri'] = 'http://example.com?foo=bar'
get :callback, code: 'hash-123'
expect(response).to redirect_to('http://example.com?foo=bar&code=hash-123')
end
end
describe 'POST access_token' do
it 'send post call to oauth_token_url with correct params' do
expected_auth_params = { 'code' => 'code-123',
'client_id' => 'client-123',
'client_secret' => 'secret-123',
'grant_type' => 'authorization_code',
'redirect_uri' => 'http://test.host/-/jira/login/oauth/callback' }
expect(HTTParty).to receive(:post).with(oauth_token_url, body: expected_auth_params) do
{ 'access_token' => 'fake-123', 'scope' => 'foo', 'token_type' => 'bar' }
end
post :access_token, code: 'code-123', client_id: 'client-123', client_secret: 'secret-123'
expect(response.body).to eq('access_token=fake-123&scope=foo&token_type=bar')
end
end
end
{
"type": "array",
"properties" : {
"name": { "type": "string" },
"commit": {
"type": "object",
"required": ["sha", "type"],
"properties" : {
"sha": { "type": "string" },
"type": { "type": "string" }
},
"additionalProperties": false
},
"additionalProperties": false
}
}
{
"type": "object",
"properties" : {
"sha": { "type": "string" },
"parents": {
"type": "array",
"properties": {
"sha": { "type": "string" }
},
"additionalProperties": false
},
"author": {
"type": "object",
"required": ["login", "email"],
"properties" : {
"login": { "type": ["string", "null"] },
"email": { "type": "string" }
},
"additionalProperties": false
},
"committer": {
"type": "object",
"required": ["login", "email"],
"properties" : {
"login": { "type": ["string", "null"] },
"email": { "type": "string" }
},
"additionalProperties": false
},
"commit": {
"type": "object",
"properties": {
"message": { "type": "string" },
"author": {
"type": "object",
"required": ["name", "email", "date", "type"],
"properties" : {
"name": { "type": "string" },
"email": { "type": "string" },
"date": { "type": "date" },
"type": { "type": "string" }
},
"additionalProperties": false
},
"committer": {
"type": "object",
"required": ["name", "email", "date", "type"],
"properties" : {
"name": { "type": "string" },
"email": { "type": "string" },
"date": { "type": "date" },
"type": { "type": "string" }
},
"additionalProperties": false
},
"additionalProperties": false
}
},
"additionalProperties": false
}
}
{
"type": "array",
"properties" : {
"id": { "type": "integer" },
"name": { "type": "string" },
"owner": {
"type": "object",
"required": ["login"],
"properties" : {
"login": { "type": "string" }
},
"additionalProperties": false
},
"additionalProperties": false
}
}
require 'spec_helper'
describe Gitlab::Jira::Middleware do
let(:app) { double(:app) }
let(:middleware) { described_class.new(app) }
let(:jira_user_agent) { 'JIRA DVCS Connector Vertigo/5.0.0-D20170810T012915' }
describe '.jira_dvcs_connector?' do
it 'returns true when DVCS connector' do
expect(described_class.jira_dvcs_connector?('HTTP_USER_AGENT' => jira_user_agent)).to eq(true)
end
it 'returns false when not DVCS connector' do
expect(described_class.jira_dvcs_connector?('HTTP_USER_AGENT' => 'pokemon')).to eq(false)
end
end
describe '#call' do
it 'adjusts HTTP_AUTHORIZATION env when request from JIRA DVCS user agent' do
expect(app).to receive(:call).with('HTTP_USER_AGENT' => jira_user_agent,
'HTTP_AUTHORIZATION' => 'Bearer hash-123')
middleware.call('HTTP_USER_AGENT' => jira_user_agent, 'HTTP_AUTHORIZATION' => 'token hash-123')
end
it 'does not change HTTP_AUTHORIZATION env when request is not from JIRA DVCS user agent' do
env = { 'HTTP_USER_AGENT' => 'Mozilla/5.0', 'HTTP_AUTHORIZATION' => 'token hash-123' }
expect(app).to receive(:call).with(env)
middleware.call(env)
end
end
end
require 'spec_helper'
describe API::V3::Github do
let(:user) { create(:user) }
let!(:project) { create(:project, :repository, creator: user) }
before do
allow(Gitlab::Jira::Middleware).to receive(:jira_dvcs_connector?) { true }
project.add_master(user)
end
describe 'GET /orgs/:namespace/repos' do
it 'returns an empty array' do
group = create(:group)
get v3_api("/orgs/#{group.path}/repos", user)
expect(response).to have_http_status(200)
expect(json_response).to eq([])
end
end
describe 'GET /user/repos' do
it 'returns an empty array' do
get v3_api('/user/repos', user)
expect(response).to have_http_status(200)
expect(json_response).to eq([])
end
end
describe 'GET /-/jira/pulls' do
it 'returns an empty array' do
get v3_api('/repos/-/jira/pulls', user)
expect(response).to have_http_status(200)
expect(json_response).to eq([])
end
end
describe 'GET /users/:namespace/repos' do
context 'authenticated' do
it 'returns an array of projects with github format' do
stub_licensed_features(jira_dev_panel_integration: true)
group = create(:group)
create(:project, group: group)
group.add_master(user)
get v3_api('/users/foo/repos', user)
expect(response).to have_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an(Array)
expect(json_response.size).to eq(2)
expect(response).to match_response_schema('entities/github/repositories')
end
end
context 'unauthenticated' do
it 'returns 401' do
get v3_api("/users/foo/repos", nil)
expect(response).to have_http_status(401)
end
end
it 'filters unlicensed namespace projects' do
silver_plan = Plan.find_by!(name: 'silver')
licensed_project = create(:project, :empty_repo)
licensed_project.add_reporter(user)
licensed_project.namespace.update!(plan_id: silver_plan.id)
stub_licensed_features(jira_dev_panel_integration: true)
stub_application_setting_on_object(project, should_check_namespace_plan: true)
stub_application_setting_on_object(licensed_project, should_check_namespace_plan: true)
get v3_api('/users/foo/repos', user)
expect(response).to have_http_status(200)
expect(response).to include_pagination_headers
expect(json_response.size).to eq(1)
expect(json_response.first['id']).to eq(licensed_project.id)
end
end
describe 'GET /repos/:namespace/:project/branches' do
context 'authenticated' do
it 'returns an array of project branches with github format' do
stub_licensed_features(jira_dev_panel_integration: true)
get v3_api("/repos/#{project.namespace.path}/#{project.path}/branches", user)
expect(response).to have_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an(Array)
expect(response).to match_response_schema('entities/github/branches')
end
end
context 'unauthenticated' do
it 'returns 401' do
stub_licensed_features(jira_dev_panel_integration: true)
get v3_api("/repos/#{project.namespace.path}/#{project.path}/branches", nil)
expect(response).to have_http_status(401)
end
end
context 'unauthorized' do
it 'returns 404 when not licensed' do
stub_licensed_features(jira_dev_panel_integration: false)
unauthorized_user = create(:user)
project.add_reporter(unauthorized_user)
get v3_api("/repos/#{project.namespace.path}/#{project.path}/branches", unauthorized_user)
expect(response).to have_http_status(404)
end
end
end
describe 'GET /repos/:namespace/:project/commits/:sha' do
let(:commit) { project.repository.commit }
let(:commit_id) { commit.id }
context 'authenticated' do
it 'returns commit with github format' do
stub_licensed_features(jira_dev_panel_integration: true)
get v3_api("/repos/#{project.namespace.path}/#{project.path}/commits/#{commit_id}", user)
expect(response).to have_http_status(200)
expect(response).to match_response_schema('entities/github/commit')
end
end
context 'unauthenticated' do
it 'returns 401' do
get v3_api("/repos/#{project.namespace.path}/#{project.path}/commits/#{commit_id}", nil)
expect(response).to have_http_status(401)
end
end
context 'unauthorized' do
it 'returns 404 when lower access level' do
unauthorized_user = create(:user)
project.add_guest(unauthorized_user)
get v3_api("/repos/#{project.namespace.path}/#{project.path}/commits/#{commit_id}",
unauthorized_user)
expect(response).to have_http_status(404)
end
it 'returns 404 when not licensed' do
stub_licensed_features(jira_dev_panel_integration: false)
unauthorized_user = create(:user)
project.add_reporter(unauthorized_user)
get v3_api("/repos/#{project.namespace.path}/#{project.path}/commits/#{commit_id}",
unauthorized_user)
expect(response).to have_http_status(404)
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