diff --git a/CHANGELOG b/CHANGELOG index c11ca604bb1..84bdea30979 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 ce0f98bdeff..82aad329c1a 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, :channel + :description, :issues_url, :new_issue_url, :restrict_to_branch, :channel, + :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