From f84b7eef3f969a65d0930c9d62b6968b2ae70f12 Mon Sep 17 00:00:00 2001 From: Aorimn Date: Wed, 4 Feb 2015 21:31:55 +0100 Subject: [PATCH] Add Irker service Irker is a gateway which sends IRC messages on git updates. This new service provides an interface to this gateway, integrated in Gitlab, for each updates. As per the guidelines, this commit adds the new feature in the CHANGELOG, tests and documentation. See http://www.catb.org/esr/irker/ --- CHANGELOG | 1 + .../projects/services_controller.rb | 3 +- app/models/project.rb | 1 + app/models/project_services/irker_service.rb | 152 ++++++++++++++++ app/models/service.rb | 3 +- app/workers/irker_worker.rb | 169 ++++++++++++++++++ doc/project_services/irker.md | 46 +++++ doc/project_services/project_services.md | 1 + features/project/service.feature | 6 + features/steps/project/services.rb | 17 ++ .../project_services/irker_service_spec.rb | 103 +++++++++++ 11 files changed, 500 insertions(+), 2 deletions(-) create mode 100644 app/models/project_services/irker_service.rb create mode 100644 app/workers/irker_worker.rb create mode 100644 doc/project_services/irker.md create mode 100644 spec/models/project_services/irker_service_spec.rb diff --git a/CHANGELOG b/CHANGELOG index dae32953cd9..ee862a4ca3c 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -14,6 +14,7 @@ v 7.9.0 (unreleased) - Generalize image upload in drag and drop in markdown to all files (Hannes Rosenögger) - Fix mass-unassignment of issues (Robert Speicher) - Allow user confirmation to be skipped for new users via API + - Add a service to send updates to an Irker gateway (Romain Coltel) v 7.8.1 - Fix run of custom post receive hooks diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb index 5c29a6550f5..e7823020e60 100644 --- a/app/controllers/projects/services_controller.rb +++ b/app/controllers/projects/services_controller.rb @@ -50,7 +50,8 @@ class Projects::ServicesController < Projects::ApplicationController :room, :recipients, :project_url, :webhook, :user_key, :device, :priority, :sound, :bamboo_url, :username, :password, :build_key, :server, :teamcity_url, :build_type, - :description, :issues_url, :new_issue_url, :restrict_to_branch + :description, :issues_url, :new_issue_url, :restrict_to_branch, + :colorize_messages, :channels ) end end diff --git a/app/models/project.rb b/app/models/project.rb index 7f2e0b4c17b..907f331d8f1 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -73,6 +73,7 @@ class Project < ActiveRecord::Base has_one :gitlab_ci_service, dependent: :destroy has_one :campfire_service, dependent: :destroy has_one :emails_on_push_service, dependent: :destroy + has_one :irker_service, dependent: :destroy has_one :pivotaltracker_service, dependent: :destroy has_one :hipchat_service, dependent: :destroy has_one :flowdock_service, dependent: :destroy diff --git a/app/models/project_services/irker_service.rb b/app/models/project_services/irker_service.rb new file mode 100644 index 00000000000..a0203a5bb10 --- /dev/null +++ b/app/models/project_services/irker_service.rb @@ -0,0 +1,152 @@ +# == Schema Information +# +# Table name: services +# +# id :integer not null, primary key +# type :string(255) +# title :string(255) +# project_id :integer +# created_at :datetime +# updated_at :datetime +# active :boolean default(FALSE), not null +# properties :text +# template :boolean default(FALSE) + +require 'uri' + +class IrkerService < Service + prop_accessor :colorize_messages, :recipients, :channels + validates :recipients, presence: true, if: :activated? + validate :check_recipients_count, if: :activated? + + before_validation :get_channels + after_initialize :initialize_settings + + # Writer for RSpec tests + attr_writer :settings + + def initialize_settings + # See the documentation (doc/project_services/irker.md) for possible values + # here + @settings ||= { + server_ip: 'localhost', + server_port: 6659, + max_channels: 3, + default_irc_uri: nil + } + end + + def title + 'Irker (IRC gateway)' + end + + def description + 'Send IRC messages, on update, to a list of recipients through an Irker '\ + 'gateway.' + end + + def help + msg = 'Recipients have to be specified with a full URI: '\ + 'irc[s]://irc.network.net[:port]/#channel. Special cases: if you want '\ + 'the channel to be a nickname instead, append ",isnick" to the channel '\ + 'name; if the channel is protected by a secret password, append '\ + '"?key=secretpassword" to the URI.' + + unless @settings[:default_irc].nil? + msg += ' Note that a default IRC URI is provided by this service\'s '\ + "administrator: #{default_irc}. You can thus just give a channel name." + end + msg + end + + def to_param + 'irker' + end + + def execute(push_data) + IrkerWorker.perform_async(project_id, channels, + colorize_messages, push_data, @settings) + end + + def fields + [ + { type: 'textarea', name: 'recipients', + placeholder: 'Recipients/channels separated by whitespaces' }, + { type: 'checkbox', name: 'colorize_messages' }, + ] + end + + private + + def check_recipients_count + return true if recipients.nil? || recipients.empty? + + if recipients.split(/\s+/).count > max_chans + errors.add(:recipients, "are limited to #{max_chans}") + end + end + + def max_chans + @settings[:max_channels] + end + + def get_channels + return true unless :activated? + return true if recipients.nil? || recipients.empty? + + map_recipients + + errors.add(:recipients, 'are all invalid') if channels.empty? + true + end + + def map_recipients + self.channels = recipients.split(/\s+/).map do |recipient| + format_channel default_irc_uri, recipient + end + channels.reject! &:nil? + end + + def default_irc_uri + default_irc = @settings[:default_irc_uri] + if !(default_irc.nil? || default_irc[-1] == '/') + default_irc += '/' + end + default_irc + end + + def format_channel(default_irc, recipient) + cnt = 0 + url = nil + + # Try to parse the chan as a full URI + begin + uri = URI.parse(recipient) + raise URI::InvalidURIError if uri.scheme.nil? && cnt == 0 + rescue URI::InvalidURIError + unless default_irc.nil? + cnt += 1 + recipient = "#{default_irc}#{recipient}" + retry if cnt == 1 + end + else + url = consider_uri uri + end + url + end + + def consider_uri(uri) + # Authorize both irc://domain.com/#chan and irc://domain.com/chan + if uri.is_a?(URI) && uri.scheme[/^ircs?$/] && !uri.path.nil? + # Do not authorize irc://domain.com/ + if uri.fragment.nil? && uri.path.length > 1 + uri.to_s + else + # Authorize irc://domain.com/smthg#chan + # The irker daemon will deal with it by concatenating smthg and + # chan, thus sending messages on #smthgchan + uri.to_s + end + end + end +end diff --git a/app/models/service.rb b/app/models/service.rb index f87d875c10a..f4e97da3212 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -100,7 +100,8 @@ class Service < ActiveRecord::Base def self.available_services_names %w(gitlab_ci campfire hipchat pivotaltracker flowdock assembla asana - emails_on_push gemnasium slack pushover buildbox bamboo teamcity jira redmine custom_issue_tracker) + emails_on_push gemnasium slack pushover buildbox bamboo teamcity jira + redmine custom_issue_tracker irker) end def self.create_from_template(project_id, template) diff --git a/app/workers/irker_worker.rb b/app/workers/irker_worker.rb new file mode 100644 index 00000000000..613bae351d8 --- /dev/null +++ b/app/workers/irker_worker.rb @@ -0,0 +1,169 @@ +require 'json' +require 'socket' + +class IrkerWorker + include Sidekiq::Worker + + def perform(project_id, chans, colors, push_data, settings) + project = Project.find(project_id) + + # Get config parameters + return false unless init_perform settings, chans, colors + + repo_name = push_data['repository']['name'] + committer = push_data['user_name'] + branch = push_data['ref'].gsub(%r'refs/[^/]*/', '') + + if @colors + repo_name = "\x0304#{repo_name}\x0f" + branch = "\x0305#{branch}\x0f" + end + + # Firsts messages are for branch creation/deletion + send_branch_updates push_data, project, repo_name, committer, branch + + # Next messages are for commits + send_commits push_data, project, repo_name, committer, branch + + close_connection + true + end + + private + + def init_perform(set, chans, colors) + @colors = colors + @channels = chans + start_connection set['server_ip'], set['server_port'] + end + + def start_connection(irker_server, irker_port) + begin + @socket = TCPSocket.new irker_server, irker_port + rescue Errno::ECONNREFUSED => e + logger.fatal "Can't connect to Irker daemon: #{e}" + return false + end + true + end + + def sendtoirker(privmsg) + to_send = { to: @channels, privmsg: privmsg } + @socket.puts JSON.dump(to_send) + end + + def close_connection + @socket.close + end + + def send_branch_updates(push_data, project, repo_name, committer, branch) + if push_data['before'] =~ /^000000/ + send_new_branch project, repo_name, committer, branch + elsif push_data['after'] =~ /^000000/ + send_del_branch repo_name, committer, branch + end + end + + def send_new_branch(project, repo_name, committer, branch) + repo_path = project.path_with_namespace + newbranch = "#{Gitlab.config.gitlab.url}/#{repo_path}/branches" + newbranch = "\x0302\x1f#{newbranch}\x0f" if @colors + + privmsg = "[#{repo_name}] #{committer} has created a new branch " + privmsg += "#{branch}: #{newbranch}" + sendtoirker privmsg + end + + def send_del_branch(repo_name, committer, branch) + privmsg = "[#{repo_name}] #{committer} has deleted the branch #{branch}" + sendtoirker privmsg + end + + def send_commits(push_data, project, repo_name, committer, branch) + return if push_data['total_commits_count'] == 0 + + # Next message is for number of commit pushed, if any + if push_data['before'] =~ /^000000/ + # Tweak on push_data["before"] in order to have a nice compare URL + push_data['before'] = before_on_new_branch push_data, project + end + + send_commits_count(push_data, project, repo_name, committer, branch) + + # One message per commit, limited by 3 messages (same limit as the + # github irc hook) + commits = push_data['commits'].first(3) + commits.each do |hook_attrs| + send_one_commit project, hook_attrs, repo_name, branch + end + end + + def before_on_new_branch(push_data, project) + commit = commit_from_id project, push_data['commits'][0]['id'] + parents = commit.parents + # Return old value if there's no new one + return push_data['before'] if parents.empty? + # Or return the first parent-commit + parents[0].id + end + + def send_commits_count(data, project, repo, committer, branch) + url = compare_url data, project.path_with_namespace + commits = colorize_commits data['total_commits_count'] + + new_commits = 'new commit' + new_commits += 's' if data['total_commits_count'] > 1 + + sendtoirker "[#{repo}] #{committer} pushed #{commits} #{new_commits} " \ + "to #{branch}: #{url}" + end + + def compare_url(data, repo_path) + sha1 = Commit::truncate_sha(data['before']) + sha2 = Commit::truncate_sha(data['after']) + compare_url = "#{Gitlab.config.gitlab.url}/#{repo_path}/compare" + compare_url += "/#{sha1}...#{sha2}" + colorize_url compare_url + end + + def send_one_commit(project, hook_attrs, repo_name, branch) + commit = commit_from_id project, hook_attrs['id'] + sha = colorize_sha Commit::truncate_sha(hook_attrs['id']) + author = hook_attrs['author']['name'] + files = colorize_nb_files(files_count commit) + title = commit.title + + sendtoirker "#{repo_name}/#{branch} #{sha} #{author} (#{files}): #{title}" + end + + def commit_from_id(project, id) + commit = Gitlab::Git::Commit.find(project.repository, id) + Commit.new(commit) + end + + def files_count(commit) + files = "#{commit.diffs.count} file" + files += 's' if commit.diffs.count > 1 + files + end + + def colorize_sha(sha) + sha = "\x0314#{sha}\x0f" if @colors + sha + end + + def colorize_nb_files(nb_files) + nb_files = "\x0312#{nb_files}\x0f" if @colors + nb_files + end + + def colorize_url(url) + url = "\x0302\x1f#{url}\x0f" if @colors + url + end + + def colorize_commits(commits) + commits = "\x02#{commits}\x0f" if @colors + commits + end +end diff --git a/doc/project_services/irker.md b/doc/project_services/irker.md new file mode 100644 index 00000000000..780a45bca20 --- /dev/null +++ b/doc/project_services/irker.md @@ -0,0 +1,46 @@ +# Irker IRC Gateway + +GitLab provides a way to push update messages to an Irker server. When +configured, pushes to a project will trigger the service to send data directly +to the Irker server. + +See the project homepage for further info: http://www.catb.org/esr/irker/ + +## Needed setup + +You will first need an Irker daemon. You can download the Irker code from its +gitorious repository on https://gitorious.org/irker: `git clone +git@gitorious.org:irker/irker.git`. Once you have downloaded the code, you can +run the python script named `irkerd`. This script is the gateway script, it acts +both as an IRC client, for sending messages to an IRC server obviously, and as a +TCP server, for receiving messages from the GitLab service. + +If the Irker server runs on the same machine, you are done. If not, you will +need to follow the firsts steps of the next section. + +## Optional setup + +In the `app/models/project_services/irker_service.rb` file, you can modify some +options in the `initialize_settings` method: +- **server_ip** (defaults to `localhost`): the server IP address where the +`irkerd` daemon runs; +- **server_port** (defaults to `6659`): the server port of the `irkerd` daemon; +- **max_channels** (defaults to `3`): the maximum number of recipients the +client is authorized to join, per project; +- **default_irc_uri** (no default) : if this option is set, it has to be in the +format `irc[s]://domain.name` and will be prepend to each and every channel +provided by the user which is not a full URI. + +If the Irker server and the GitLab application do not run on the same host, you +will **need** to setup at least the **server_ip** option. + +## Note on Irker recipients + +Irker accepts channel names of the form `chan` and `#chan`, both for the +`#chan` channel. If you want to send messages in query, you will need to add +`,isnick` avec the channel name, in this form: `Aorimn,isnick`. In this latter +case, `Aorimn` is treated as a nick and no more as a channel name. + +Irker can also join password-protected channels. Users need to append +`?key=thesecretpassword` to the chan name. + diff --git a/doc/project_services/project_services.md b/doc/project_services/project_services.md index 93a57485cfd..86eda341d6c 100644 --- a/doc/project_services/project_services.md +++ b/doc/project_services/project_services.md @@ -13,6 +13,7 @@ __Project integrations with external services for continuous integration and mor - Gemnasium - GitLab CI - HipChat +- [Irker](irker.md) An IRC gateway to receive messages on repository updates. - Pivotal Tracker - Pushover - Slack diff --git a/features/project/service.feature b/features/project/service.feature index d0600aca010..fdff640ec85 100644 --- a/features/project/service.feature +++ b/features/project/service.feature @@ -61,6 +61,12 @@ Feature: Project Services And I fill email on push settings Then I should see email on push service settings saved + Scenario: Activate Irker (IRC Gateway) service + When I visit project "Shop" services page + And I click Irker service link + And I fill Irker settings + Then I should see Irker service settings saved + Scenario: Activate Atlassian Bamboo CI service When I visit project "Shop" services page And I click Atlassian Bamboo CI service link diff --git a/features/steps/project/services.rb b/features/steps/project/services.rb index 3307117e69a..4b3d79324ab 100644 --- a/features/steps/project/services.rb +++ b/features/steps/project/services.rb @@ -17,6 +17,7 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps page.should have_content 'Atlassian Bamboo' page.should have_content 'JetBrains TeamCity' page.should have_content 'Asana' + page.should have_content 'Irker (IRC gateway)' end step 'I click gitlab-ci service link' do @@ -132,6 +133,22 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps find_field('Recipients').value.should == 'qa@company.name' end + step 'I click Irker service link' do + click_link 'Irker (IRC gateway)' + end + + step 'I fill Irker settings' do + check 'Active' + fill_in 'Recipients', with: 'irc://chat.freenode.net/#commits' + check 'Colorize messages' + click_button 'Save' + end + + step 'I should see Irker service settings saved' do + find_field('Recipients').value.should == 'irc://chat.freenode.net/#commits' + find_field('Colorize messages').value.should == '1' + end + step 'I click Slack service link' do click_link 'Slack' end diff --git a/spec/models/project_services/irker_service_spec.rb b/spec/models/project_services/irker_service_spec.rb new file mode 100644 index 00000000000..bbd5245ad34 --- /dev/null +++ b/spec/models/project_services/irker_service_spec.rb @@ -0,0 +1,103 @@ +# == Schema Information +# +# Table name: services +# +# id :integer not null, primary key +# type :string(255) +# title :string(255) +# project_id :integer not null +# created_at :datetime +# updated_at :datetime +# active :boolean default(FALSE), not null +# properties :text +# + +require 'spec_helper' +require 'socket' +require 'json' + +describe IrkerService do + describe 'Associations' do + it { should belong_to :project } + it { should have_one :service_hook } + end + + describe 'Validations' do + before do + subject.active = true + subject.properties['recipients'] = _recipients + end + + context 'active' do + let(:_recipients) { nil } + it { should validate_presence_of :recipients } + end + + context 'too many recipients' do + let(:_recipients) { 'a b c d' } + it 'should add an error if there is too many recipients' do + subject.send :check_recipients_count + subject.errors.should_not be_blank + end + end + + context '3 recipients' do + let(:_recipients) { 'a b c' } + it 'should not add an error if there is 3 recipients' do + subject.send :check_recipients_count + subject.errors.should be_blank + end + end + end + + describe 'Execute' do + let(:irker) { IrkerService.new } + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:sample_data) { Gitlab::PushDataBuilder.build_sample(project, user) } + + let(:recipients) { '#commits' } + let(:colorize_messages) { '1' } + + before do + irker.stub( + active: true, + project: project, + project_id: project.id, + service_hook: true, + properties: { + 'recipients' => recipients, + 'colorize_messages' => colorize_messages + } + ) + irker.settings = { + server_ip: 'localhost', + server_port: 6659, + max_channels: 3, + default_irc_uri: 'irc://chat.freenode.net/' + } + irker.valid? + @irker_server = TCPServer.new 'localhost', 6659 + end + + after do + @irker_server.close + end + + it 'should send valid JSON messages to an Irker listener' do + irker.execute(sample_data) + + conn = @irker_server.accept + conn.readlines.each do |line| + msg = JSON.load(line.chomp("\n")) + msg.keys.should match_array(['to', 'privmsg']) + if msg['to'].is_a?(String) + msg['to'].should == 'irc://chat.freenode.net/#commits' + else + msg['to'].should match_array(['irc://chat.freenode.net/#commits']) + end + end + conn.close + end + end +end