Commit b9e08d97 authored by James Edwards-Jones's avatar James Edwards-Jones

GitHub Service sends status update on pipeline events

parent 7d45cd88
# GitHub Project Integration
GitLab provides integration for updating pipeline statuses on GitHub. This is especially useful if using GitLab for CI/CD only.
![Pipeline status update on GitHub](img/github_status_check_pipeline_update.png)
## Configuration
### Complete these steps on GitHub
This integration requires a [GitHub API token](https://github.com/settings/tokens) with `repo:status` access granted:
1. Go to your "Personal access tokens" page at https://github.com/settings/tokens
1. Click "Generate New Token"
1. Ensure that `repo:status` is checked and click "Generate token"
1. Copy the generated token to use on GitLab
### Complete these steps on GitLab
1. Navigate to the project you want to configure.
1. Navigate to the [Integrations page](project_services.md#accessing-the-project-services)
1. Click "GitHub".
1. Select the "Active" checkbox.
1. Paste the token you've generated on GitHub
1. Enter the path to your project on GitHub, such as "https://github.com/your-name/YourProject/"
1. Save or optionally click "Test Settings".
![Configure GitHub Project Integration](img/github_configuration.png)
...@@ -35,7 +35,7 @@ Click on the service links to see further configuration instructions and details ...@@ -35,7 +35,7 @@ Click on the service links to see further configuration instructions and details
| External Wiki | Replaces the link to the internal wiki with a link to an external wiki | | External Wiki | Replaces the link to the internal wiki with a link to an external wiki |
| Flowdock | Flowdock is a collaboration web app for technical teams | | Flowdock | Flowdock is a collaboration web app for technical teams |
| Gemnasium | Gemnasium monitors your project dependencies and alerts you about updates and security vulnerabilities | | Gemnasium | Gemnasium monitors your project dependencies and alerts you about updates and security vulnerabilities |
| GitHub | Sends pipeline notifications to GitHub | | [GitHub](github.md) | Sends pipeline notifications to GitHub |
| [HipChat](hipchat.md) | Private group chat and IM | | [HipChat](hipchat.md) | Private group chat and IM |
| [Irker (IRC gateway)](irker.md) | Send IRC messages, on update, to a list of recipients through an Irker gateway | | [Irker (IRC gateway)](irker.md) | Send IRC messages, on update, to a list of recipients through an Irker gateway |
| [JIRA](jira.md) | JIRA issue tracker | | [JIRA](jira.md) | JIRA issue tracker |
......
...@@ -41,4 +41,56 @@ class GithubService < Service ...@@ -41,4 +41,56 @@ class GithubService < Service
def self.supported_events def self.supported_events
%w(pipeline) %w(pipeline)
end end
def can_test?
project.pipelines.any?
end
def disabled_title
'Please setup a pipeline on your repository.'
end
def execute(data)
status_message = StatusMessage.from_pipeline_data(project, data)
update_status(status_message)
end
def test_data(project, user)
pipeline = project.pipelines.newest_first.first
raise disabled_title unless pipeline
Gitlab::DataBuilder::Pipeline.build(pipeline)
end
def test(data)
begin
result = execute(data)
context = result[:context]
by_user = result.dig(:creator, :login)
result = "Status for #{context} updated by #{by_user}" if context && by_user
rescue StandardError => error
return { success: false, result: error }
end
{ success: true, result: result }
end
private
def update_status(status_message)
notifier.notify(status_message.sha,
status_message.status,
status_message.status_options)
end
def notifier
StatusNotifier.new(token, remote_repo_path, api_endpoint: api_url)
end
def remote_repo_path
"#{owner}/#{repository_name}"
end
end end
class GithubService
class StatusMessage
include Gitlab::Routing
attr_reader :sha
def initialize(project, params)
@project = project
@gitlab_status = params[:status]
@detailed_status = params[:detailed_status]
@pipeline_id = params[:id]
@sha = params[:sha]
@ref_name = params[:ref]
end
def context
"ci/gitlab/#{@ref_name}".truncate(255)
end
def description
"Pipeline #{@detailed_status} on GitLab".truncate(140)
end
def target_url
project_pipeline_url(@project, @pipeline_id)
end
def status
case @gitlab_status.to_s
when 'created',
'pending',
'running',
'manual'
:pending
when 'success',
'skipped'
:success
when 'failed'
:failure
when 'canceled'
:error
end
end
def status_options
{
context: context,
description: description,
target_url: target_url
}
end
def self.from_pipeline_data(project, data)
new(project, data[:object_attributes])
end
end
end
class GithubService
class StatusNotifier
def initialize(access_token, repo_path, api_endpoint: nil)
@access_token = access_token
@repo_path = repo_path
@api_endpoint = api_endpoint.presence
end
def notify(ref, state, params = {})
client.create_status(@repo_path, ref, state, params)
end
private
def client
@client ||= Octokit::Client.new(access_token: @access_token,
api_endpoint: @api_endpoint)
end
end
end
---
title: Adds GitHub Service to send status updates for pipelines
merge_request: 4591
author:
type: added
require 'spec_helper'
describe GithubService::StatusMessage do
include Rails.application.routes.url_helpers
let(:project) { double(:project, namespace: "me", to_s: 'example_project') }
describe '#description' do
it 'includes human readable gitlab status' do
subject = described_class.new(project, detailed_status: 'passed')
expect(subject.description).to eq "Pipeline passed on GitLab"
end
it 'gets truncated to 140 chars' do
dummy_text = 'a' * 500
subject = described_class.new(project, detailed_status: dummy_text)
expect(subject.description.length).to eq 140
end
end
describe '#status' do
using RSpec::Parameterized::TableSyntax
where(:gitlab_status, :github_status) do
'pending' | :pending
'created' | :pending
'running' | :pending
'manual' | :pending
'success' | :success
'skipped' | :success
'failed' | :failure
'canceled' | :error
end
with_them do
it 'transforms status' do
subject = described_class.new(project, status: gitlab_status)
expect(subject.status).to eq github_status
end
end
end
describe '#status_options' do
let(:subject) { described_class.new(project, id: 1) }
it 'includes context' do
expect(subject.status_options[:context]).to be_a String
end
it 'includes target_url' do
expect(subject.status_options[:target_url]).to be_a String
end
it 'includes description' do
expect(subject.status_options[:description]).to be_a String
end
end
describe '.from_pipeline_data' do
let(:pipeline) { create(:ci_pipeline) }
let(:project) { pipeline.project }
let(:sample_data) { Gitlab::DataBuilder::Pipeline.build(pipeline) }
let(:subject) { described_class.from_pipeline_data(project, sample_data) }
it 'builds an instance of GithubService::StatusMessage' do
expect(subject).to be_a described_class
end
describe 'builds an object with' do
specify 'sha' do
expect(subject.sha).to eq pipeline.sha
end
specify 'status' do
expect(subject.status).to eq :pending
end
specify 'target_url' do
expect(subject.target_url).to end_with pipeline_path(pipeline)
end
specify 'description' do
expect(subject.description).to eq "Pipeline pending on GitLab"
end
specify 'context' do
expect(subject.context).to eq "ci/gitlab/#{pipeline.ref}"
end
context 'blocked pipeline' do
let(:pipeline) { create(:ci_pipeline, :blocked) }
it 'uses human readable status which can be used in a sentence' do
expect(subject.description). to eq 'Pipeline waiting for manual action on GitLab'
end
end
end
end
end
require 'spec_helper'
describe GithubService::StatusNotifier do
let(:access_token) { 'aaaaa' }
let(:repo_path) { 'myself/my-project' }
subject { described_class.new(access_token, repo_path) }
describe '#notify' do
let(:ref) { 'master' }
let(:state) { 'pending' }
let(:params) { { context: 'Gitlab' } }
let(:github_status_api) { "https://api.github.com/repos/#{repo_path}/statuses/#{ref}" }
it 'uses GitHub API to update status' do
stub_request(:post, github_status_api)
subject.notify(ref, state)
expect(a_request(:post, github_status_api)).to have_been_made.once
end
context 'with blank api_endpoint' do
let(:api_endpoint) { '' }
subject { described_class.new(access_token, repo_path, api_endpoint: api_endpoint) }
it 'defaults to using GitHub.com API' do
github_status_api = "https://api.github.com/repos/#{repo_path}/statuses/#{ref}"
stub_request(:post, github_status_api)
subject.notify(ref, state)
expect(a_request(:post, github_status_api)).to have_been_made.once
end
end
context 'with custom api_endpoint' do
let(:api_endpoint) { 'https://my.code.repo' }
subject { described_class.new(access_token, repo_path, api_endpoint: api_endpoint) }
it 'uses provided API for requests' do
custom_status_api = "https://my.code.repo/repos/#{repo_path}/statuses/#{ref}"
stub_request(:post, custom_status_api)
subject.notify(ref, state)
expect(a_request(:post, custom_status_api)).to have_been_made.once
end
end
it 'passes optional params' do
expect_context = hash_including(context: 'My Context')
stub_request(:post, github_status_api).with(body: expect_context)
subject.notify(ref, state, context: 'My Context')
end
it 'uses access token' do
auth_header = { 'Authorization' => 'token aaaaa' }
stub_request(:post, github_status_api).with(headers: auth_header)
subject.notify(ref, state)
end
end
end
require 'spec_helper'
describe GithubService do
let(:project) { create(:project) }
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:pipeline_sample_data) { Gitlab::DataBuilder::Pipeline.build(pipeline) }
let(:api_url) { '' }
let(:owner) { 'my-user' }
let(:token) { 'aaaaaaaaa' }
let(:repository_name) { 'my-project' }
let(:service_params) do
{
active: true,
project: project,
properties: {
token: token,
api_url: api_url,
owner: owner,
repository_name: repository_name
}
}
end
subject { described_class.create(service_params) }
describe "Associations" do
it { is_expected.to belong_to :project }
end
describe "#owner" do
it 'is determined from the repo URL' do
expect(subject.owner).to eq owner
end
end
describe "#repository_name" do
it 'is determined from the repo URL' do
expect(subject.repository_name).to eq repository_name
end
end
describe "#api_url" do
it 'uses github.com by default' do
expect(subject.api_url).to eq "https://api.github.com"
end
context "with GitHub Enterprise repo URL" do
let(:base_url) { 'https://my.code-repo.com' }
it 'is set to the Enterprise API URL' do
expect(subject.api_url).to eq "https://my.code-repo.com/api/v3"
end
end
end
describe '#detailed_description' do
it 'links to mirroring settings' do
expect(subject.detailed_description).to match(/href=.*mirroring/)
end
end
describe '#execute' do
let(:remote_repo_path) { "#{owner}/#{repository_name}" }
let(:sha) { pipeline.sha }
let(:status_options) { { context: 'security', target_url: 'https://localhost.pipeline.example.com', description: "SAST passed" } }
let(:status_message) { double(sha: sha, status: :success, status_options: status_options) }
let(:notifier) { instance_double(GithubService::StatusNotifier) }
it 'notifies GitHub of a status change' do
expect(notifier).to receive(:notify)
expect(GithubService::StatusNotifier).to receive(:new).with(token, remote_repo_path, anything)
.and_return(notifier)
subject.execute(pipeline_sample_data)
end
it 'uses StatusMessage to build message' do
allow(subject).to receive(:update_status)
expect(GithubService::StatusMessage).to receive(:from_pipeline_data).with(project, pipeline_sample_data).and_return(status_message)
subject.execute(pipeline_sample_data)
end
describe 'passes StatusMessage values to StatusNotifier' do
before do
allow(GithubService::StatusNotifier).to receive(:new).and_return(notifier)
allow(GithubService::StatusMessage).to receive(:from_pipeline_data).and_return(status_message)
end
specify 'sha' do
expect(notifier).to receive(:notify).with(sha, anything, anything)
subject.execute(pipeline_sample_data)
end
specify 'status' do
expected_status = status_message.status
expect(notifier).to receive(:notify).with(anything, expected_status, anything)
subject.execute(pipeline_sample_data)
end
specify 'context' do
expected_context = status_options[:context]
expect(notifier).to receive(:notify).with(anything, anything, hash_including(context: expected_context))
subject.execute(pipeline_sample_data)
end
specify 'target_url' do
expected_target_url = status_options[:target_url]
expect(notifier).to receive(:notify).with(anything, anything, hash_including(target_url: expected_target_url))
subject.execute(pipeline_sample_data)
end
specify 'description' do
expected_description = status_options[:description]
expect(notifier).to receive(:notify).with(anything, anything, hash_including(description: expected_description))
subject.execute(pipeline_sample_data)
end
end
it 'uses GitHub API to update status' do
github_status_api = "https://api.github.com/repos/#{owner}/#{repository_name}/statuses/#{sha}"
stub_request(:post, github_status_api)
subject.execute(pipeline_sample_data)
expect(a_request(:post, github_status_api)).to have_been_made.once
end
context 'with custom api endpoint' do
let(:api_url) { 'https://my.code.repo' }
it 'hands custom api url to StatusNotifier' do
allow(notifier).to receive(:notify)
expect(GithubService::StatusNotifier).to receive(:new).with(anything, anything, api_endpoint: api_url)
.and_return(notifier)
subject.execute(pipeline_sample_data)
end
end
end
describe '#can_test?' do
it 'is false if there are no pipelines' do
project.pipelines.delete_all
expect(subject.can_test?).to eq false
end
it 'is true if the project has a pipeline' do
pipeline
expect(subject.can_test?).to eq true
end
end
describe '#test_data' do
let(:user) { project.owner }
let(:test_data) { subject.test_data(project, user) }
it 'raises error if no pipeline found' do
project.pipelines.delete_all
expect { test_data }.to raise_error 'Please setup a pipeline on your repository.'
end
it 'generates data for latest pipeline' do
pipeline
expect(test_data[:object_kind]).to eq 'pipeline'
end
end
describe '#test' do
it 'mentions creator in success message' do
dummy_response = { context: "default", creator: { login: "YourUser" } }
allow(subject).to receive(:update_status).and_return(dummy_response)
result = subject.test(pipeline_sample_data)
expect(result[:success]).to eq true
expect(result[:result].to_s).to eq('Status for default updated by YourUser')
end
it 'forwards failure message on error' do
error_response = { method: :post, status: 401, url: 'https://api.github.com/repos/my-user/my-project/statuses/master', body: 'Bad credentials' }
allow(subject).to receive(:update_status).and_raise(Octokit::Unauthorized, error_response)
result = subject.test(pipeline_sample_data)
expect(result[:success]).to eq false
expect(result[:result].to_s).to end_with('401 - Bad credentials')
end
end
end
...@@ -22,6 +22,7 @@ module Gitlab ...@@ -22,6 +22,7 @@ module Gitlab
sha: pipeline.sha, sha: pipeline.sha,
before_sha: pipeline.before_sha, before_sha: pipeline.before_sha,
status: pipeline.status, status: pipeline.status,
detailed_status: pipeline.detailed_status(nil).label,
stages: pipeline.stages_names, stages: pipeline.stages_names,
created_at: pipeline.created_at, created_at: pipeline.created_at,
finished_at: pipeline.finished_at, finished_at: pipeline.finished_at,
......
...@@ -26,6 +26,7 @@ describe Gitlab::DataBuilder::Pipeline do ...@@ -26,6 +26,7 @@ describe Gitlab::DataBuilder::Pipeline do
it { expect(attributes[:tag]).to eq(pipeline.tag) } it { expect(attributes[:tag]).to eq(pipeline.tag) }
it { expect(attributes[:id]).to eq(pipeline.id) } it { expect(attributes[:id]).to eq(pipeline.id) }
it { expect(attributes[:status]).to eq(pipeline.status) } it { expect(attributes[:status]).to eq(pipeline.status) }
it { expect(attributes[:detailed_status]).to eq('passed') }
it { expect(build_data).to be_a(Hash) } it { expect(build_data).to be_a(Hash) }
it { expect(build_data[:id]).to eq(build.id) } it { expect(build_data[:id]).to eq(build.id) }
......
...@@ -209,6 +209,7 @@ project: ...@@ -209,6 +209,7 @@ project:
- mattermost_slash_commands_service - mattermost_slash_commands_service
- slack_slash_commands_service - slack_slash_commands_service
- gitlab_slack_application_service - gitlab_slack_application_service
- github_service
- irker_service - irker_service
- packagist_service - packagist_service
- pivotaltracker_service - pivotaltracker_service
......
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