├── lang
└── en.yml
├── lib
├── redmine_github_hook.rb
└── redmine_github_hook
│ └── plugin.rb
├── Gemfile
├── test
├── test_helper.rb
├── unit
│ └── github_hook
│ │ ├── message_logger_test.rb
│ │ └── updater_test.rb
└── functional
│ └── github_hook_controller_test.rb
├── config
└── routes.rb
├── app
├── services
│ └── github_hook
│ │ ├── null_logger.rb
│ │ ├── message_logger.rb
│ │ └── updater.rb
├── views
│ └── github_hook
│ │ └── welcome.html.erb
└── controllers
│ └── github_hook_controller.rb
├── .gitignore
├── Rakefile
├── init.rb
├── CHANGELOG.md
├── redmine_github_hook.gemspec
├── LICENSE
└── README.md
/lang/en.yml:
--------------------------------------------------------------------------------
1 | # English strings go here
2 | my_label: "My label"
3 |
--------------------------------------------------------------------------------
/lib/redmine_github_hook.rb:
--------------------------------------------------------------------------------
1 | module RedmineGithubHook
2 | VERSION = "3.0.2"
3 | end
4 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 |
3 | # Use the gems defined in the plugins gemspec
4 | gemspec(:path => File.dirname(__FILE__))
5 |
--------------------------------------------------------------------------------
/test/test_helper.rb:
--------------------------------------------------------------------------------
1 | # Load the normal Rails helper from the Redmine host app
2 | require File.expand_path(File.dirname(__FILE__) + "/../../../test/test_helper")
3 |
--------------------------------------------------------------------------------
/config/routes.rb:
--------------------------------------------------------------------------------
1 | RedmineApp::Application.routes.draw do
2 | match "github_hook" => 'github_hook#index', :via => [:post]
3 | match "github_hook" => 'github_hook#welcome', :via => [:get]
4 | end
5 |
--------------------------------------------------------------------------------
/app/services/github_hook/null_logger.rb:
--------------------------------------------------------------------------------
1 | module GithubHook
2 | class NullLogger
3 | def debug(*_); end
4 |
5 | def info(*_); end
6 |
7 | def warn(*_); end
8 |
9 | def error(*_); end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.gem
2 | *.rbc
3 | .bundle
4 | .config
5 | .yardoc
6 | Gemfile.lock
7 | InstalledFiles
8 | _yardoc
9 | coverage
10 | doc/
11 | lib/bundler/man
12 | pkg
13 | rdoc
14 | spec/reports
15 | test/tmp
16 | test/version_tmp
17 | tmp
18 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require "bundler/gem_tasks"
2 | require "rake/testtask"
3 |
4 | Rake::TestTask.new do |t|
5 | t.libs << "test"
6 | files = FileList["test/**/*test.rb"]
7 | t.test_files = files
8 | t.verbose = true
9 | end
10 |
11 | task :default => :test
12 |
--------------------------------------------------------------------------------
/lib/redmine_github_hook/plugin.rb:
--------------------------------------------------------------------------------
1 | module RedmineGithubHook
2 | # Run the classic redmine plugin initializer after rails boot
3 | class Plugin < ::Rails::Engine
4 | config.after_initialize do
5 | require File.expand_path("../../../init", __FILE__)
6 | end
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/init.rb:
--------------------------------------------------------------------------------
1 | require "redmine"
2 |
3 | Redmine::Plugin.register :redmine_github_hook do
4 | name "Redmine Github Hook plugin"
5 | author "Jakob Skjerning"
6 | description "This plugin allows your Redmine installation to receive Github post-receive notifications"
7 | url "https://github.com/koppen/redmine_github_hook"
8 | author_url "http://mentalized.net"
9 | version RedmineGithubHook::VERSION
10 | end
11 |
--------------------------------------------------------------------------------
/app/views/github_hook/welcome.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
You have successfully installed the Redmine GitHub Hook Plugin.
4 |
5 |
6 | This page is just a confirmation that you have successfully installed the plugin.
7 | Follow the rest of the installation:
8 |
9 | Redmine GitHub Hook Plugin on GitHub
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 | This project adheres to [Semantic Versioning](http://semver.org/).
5 |
6 | ## [Unreleased]
7 |
8 | ### Added
9 |
10 | *
11 |
12 | ### Changes
13 |
14 | *
15 |
16 | ### Removed
17 |
18 | *
19 |
20 |
21 | ## 3.0.2 (2024-03-08)
22 |
23 | ### Added
24 |
25 | * Support for Redmine 5 (@taikii, @altheali533)
26 |
27 | ### Changes
28 |
29 | * Tags deleted on the remote will now be pruned from the local repository when we update it.
30 |
31 |
32 | ## 3.0.1 (2019-07-30)
33 |
34 | ### Added
35 |
36 | * A changelog! (you're looking at it).
37 | * Support for Rails 5, which means support for Redmine 4.x and no more deprecation warnings on Redmine 3.x (@bjakushka, @slamotte)
38 |
39 | ### Removed
40 |
41 | * Support for Redmine 2.x and earlier. If you need to use Redmine Github Hook with Redmine versions before 3.x, use the 2.x line of Redmine Github Hook.
42 |
--------------------------------------------------------------------------------
/redmine_github_hook.gemspec:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | lib = File.expand_path("../lib", __FILE__)
3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4 | require "redmine_github_hook"
5 |
6 | Gem::Specification.new do |spec|
7 | spec.name = "redmine_github_hook"
8 | spec.version = RedmineGithubHook::VERSION
9 | spec.authors = ["Jakob Skjerning"]
10 | spec.email = ["jakob@mentalized.net"]
11 | spec.summary = "Allow your Redmine installation to be notified when changes have been pushed to a Github repository."
12 | spec.homepage = ""
13 | spec.license = "MIT"
14 |
15 | spec.files = `git ls-files`.split($/)
16 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
17 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
18 | spec.require_paths = ["lib"]
19 |
20 | spec.add_development_dependency "bundler", "~> 2.5"
21 | spec.add_development_dependency "rake"
22 | end
23 |
--------------------------------------------------------------------------------
/app/services/github_hook/message_logger.rb:
--------------------------------------------------------------------------------
1 | module GithubHook
2 | class MessageLogger
3 | attr_reader :messages, :wrapped_logger
4 |
5 | def initialize(wrapped_logger = nil)
6 | @messages = []
7 | @wrapped_logger = wrapped_logger
8 | end
9 |
10 | def debug(message = yield)
11 | add_message(:debug, message)
12 | end
13 |
14 | def error(message = yield)
15 | add_message(:error, message)
16 | end
17 |
18 | def fatal(message = yield)
19 | add_message(:fatal, message)
20 | end
21 |
22 | def info(message = yield)
23 | add_message(:info, message)
24 | end
25 |
26 | def warn(message = yield)
27 | add_message(:warn, message)
28 | end
29 |
30 | private
31 |
32 | def add_message(level, message)
33 | if wrapped_logger
34 | wrapped_logger.send(level, message)
35 | end
36 |
37 | @messages << {
38 | :level => level.to_s,
39 | :message => message
40 | }
41 | end
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2019 Jakob Skjerning
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in
11 | all copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19 | THE SOFTWARE.
20 |
--------------------------------------------------------------------------------
/app/controllers/github_hook_controller.rb:
--------------------------------------------------------------------------------
1 | require "json"
2 |
3 | class GithubHookController < ApplicationController
4 | skip_before_action :verify_authenticity_token, :check_if_login_required
5 |
6 | def index
7 | message_logger = GithubHook::MessageLogger.new(logger)
8 | update_repository(message_logger) if request.post?
9 | messages = message_logger.messages.map { |log| log[:message] }
10 | render(:json => messages)
11 |
12 | rescue ActiveRecord::RecordNotFound => error
13 | render_error_as_json(error, 404)
14 |
15 | rescue TypeError => error
16 | render_error_as_json(error, 412)
17 | end
18 |
19 | def welcome
20 | # Render the default layout
21 | end
22 |
23 | private
24 |
25 | def parse_payload
26 | JSON.parse(params[:payload] || "{}")
27 | end
28 |
29 | def render_error_as_json(error, status)
30 | render(
31 | :json => {
32 | :title => error.class.to_s,
33 | :message => error.message
34 | },
35 | :status => status
36 | )
37 | end
38 |
39 | def update_repository(logger)
40 | updater = GithubHook::Updater.new(parse_payload, params)
41 | updater.logger = logger
42 | updater.call
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/test/unit/github_hook/message_logger_test.rb:
--------------------------------------------------------------------------------
1 | # require 'test_helper'
2 | require "minitest/autorun"
3 | require_relative "../../../app/services/github_hook/message_logger"
4 |
5 | class MessageLoggerTest < Minitest::Test
6 | def setup
7 | @logger = GithubHook::MessageLogger.new
8 | end
9 |
10 | def test_adds_messages_to_an_array
11 | logger.info "Testing"
12 | assert_equal [
13 | {:level => "info", :message => "Testing"}
14 | ], logger.messages
15 | end
16 |
17 | def test_supports_standard_log_levels
18 | levels = ["fatal", "error", "warn", "info", "debug"]
19 | levels.each do |level|
20 | logger.public_send(level, level)
21 | end
22 | assert_equal levels, logger.messages.map { |m| m[:level] }
23 | end
24 |
25 | def test_supports_blocks
26 | logger.debug { "This is my message" }
27 | assert_equal [
28 | {:level => "debug", :message => "This is my message"}
29 | ], logger.messages
30 | end
31 |
32 | def test_logs_to_a_wrapped_logger_as_well
33 | wrapped_logger = GithubHook::MessageLogger.new
34 | logger = GithubHook::MessageLogger.new(wrapped_logger)
35 | logger.debug "This goes everywhere"
36 | assert_equal [
37 | :level => "debug", :message => "This goes everywhere"
38 | ], logger.messages
39 | assert_equal [
40 | :level => "debug", :message => "This goes everywhere"
41 | ], wrapped_logger.messages
42 | end
43 |
44 | private
45 |
46 | def logger
47 | @logger ||= GithubHook::MessageLogger.new
48 | end
49 | end
50 |
--------------------------------------------------------------------------------
/test/functional/github_hook_controller_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | require "minitest"
4 | require "mocha"
5 |
6 | class GithubHookControllerTest < ActionController::TestCase
7 | def json
8 | # Sample JSON post from http://github.com/guides/post-receive-hooks
9 | '{
10 | "before": "5aef35982fb2d34e9d9d4502f6ede1072793222d",
11 | "repository": {
12 | "url": "http://github.com/defunkt/github",
13 | "name": "github",
14 | "description": "You\'re lookin\' at it.",
15 | "watchers": 5,
16 | "forks": 2,
17 | "private": 1,
18 | "owner": {
19 | "email": "chris@ozmm.org",
20 | "name": "defunkt"
21 | }
22 | },
23 | "commits": [
24 | {
25 | "id": "41a212ee83ca127e3c8cf465891ab7216a705f59",
26 | "url": "http://github.com/defunkt/github/commit/41a212ee83ca127e3c8cf465891ab7216a705f59",
27 | "author": {
28 | "email": "chris@ozmm.org",
29 | "name": "Chris Wanstrath"
30 | },
31 | "message": "okay i give in",
32 | "timestamp": "2008-02-15T14:57:17-08:00",
33 | "added": ["filepath.rb"]
34 | },
35 | {
36 | "id": "de8251ff97ee194a289832576287d6f8ad74e3d0",
37 | "url": "http://github.com/defunkt/github/commit/de8251ff97ee194a289832576287d6f8ad74e3d0",
38 | "author": {
39 | "email": "chris@ozmm.org",
40 | "name": "Chris Wanstrath"
41 | },
42 | "message": "update pricing a tad",
43 | "timestamp": "2008-02-15T14:36:34-08:00"
44 | }
45 | ],
46 | "after": "de8251ff97ee194a289832576287d6f8ad74e3d0",
47 | "ref": "refs/heads/master"
48 | }'
49 | end
50 |
51 | def repository
52 | return @repository if @repository
53 |
54 | @repository ||= Repository::Git.new
55 | @repository.stubs(:fetch_changesets).returns(true)
56 | @repository
57 | end
58 |
59 | def project
60 | return @project if @project
61 |
62 | @project ||= Project.new
63 | @project.repositories << repository
64 | @project
65 | end
66 |
67 | def setup
68 | Project.stubs(:find_by_identifier).with("github").returns(project)
69 |
70 | # Make sure we don't run actual commands in test
71 | GithubHook::Updater.any_instance.expects(:system).never
72 | Repository.expects(:fetch_changesets).never
73 | end
74 |
75 | def do_post
76 | post :index, :params => {:payload => json}
77 | end
78 |
79 | def test_should_render_response_from_github_hook_when_done
80 | GithubHook::Updater.any_instance.expects(:update_repository).returns(true)
81 | do_post
82 | assert_response :success
83 | assert_match "GithubHook: Redmine repository updated", @response.body
84 | end
85 |
86 | def test_should_render_error_message
87 | GithubHook::Updater
88 | .any_instance
89 | .expects(:update_repository)
90 | .raises(ActiveRecord::RecordNotFound.new("Repository not found"))
91 | do_post
92 | assert_response :not_found
93 | assert_equal({
94 | "title" => "ActiveRecord::RecordNotFound",
95 | "message" => "Repository not found"
96 | }, JSON.parse(@response.body))
97 | end
98 |
99 | def test_should_not_require_login
100 | GithubHook::Updater.any_instance.expects(:update_repository).returns(true)
101 | @controller.expects(:check_if_login_required).never
102 | do_post
103 | end
104 |
105 | def test_exec_should_log_output_from_git_as_debug_when_things_go_well
106 | GithubHook::Updater.any_instance.expects(:system).at_least(1).returns(true)
107 | @controller.logger.expects(:debug).at_least(1)
108 | do_post
109 | end
110 |
111 | def test_exec_should_log_output_from_git_as_error_when_things_go_sour
112 | GithubHook::Updater.any_instance.expects(:system).at_least(1).returns(false)
113 | @controller.logger.expects(:error).at_least(1)
114 | do_post
115 | end
116 |
117 | def test_should_respond_to_get
118 | get :index
119 | assert_response :success
120 | end
121 | end
122 |
--------------------------------------------------------------------------------
/app/services/github_hook/updater.rb:
--------------------------------------------------------------------------------
1 | module GithubHook
2 | class Updater
3 | GIT_BIN = Redmine::Configuration["scm_git_command"] || "git"
4 |
5 | attr_writer :logger
6 |
7 | def initialize(payload, params = {})
8 | @payload = payload
9 | @params = params
10 | end
11 |
12 | def call
13 | repositories = find_repositories
14 |
15 | repositories.each do |repository|
16 | tg1 = Time.now
17 | # Fetch the changes from Github
18 | update_repository(repository)
19 | tg2 = Time.now
20 |
21 | tr1 = Time.now
22 | # Fetch the new changesets into Redmine
23 | repository.fetch_changesets
24 | tr2 = Time.now
25 |
26 | logger.info { " GithubHook: Redmine repository updated: #{repository.identifier} (Git: #{time_diff_milli(tg1, tg2)}ms, Redmine: #{time_diff_milli(tr1, tr2)}ms)" }
27 | end
28 | end
29 |
30 | private
31 |
32 | attr_reader :params, :payload
33 |
34 | # Executes shell command. Returns true if the shell command exits with a
35 | # success status code.
36 | #
37 | # If directory is given the current directory will be changed to that
38 | # directory before executing command.
39 | def exec(command, directory)
40 | logger.debug { " GithubHook: Executing command: '#{command}'" }
41 |
42 | # Get a path to a temp file
43 | logfile = Tempfile.new("github_hook_exec")
44 | logfile.close
45 |
46 | full_command = "#{command} > #{logfile.path} 2>&1"
47 | success = if directory.present?
48 | Dir.chdir(directory) do
49 | system(full_command)
50 | end
51 | else
52 | system(full_command)
53 | end
54 |
55 | output_from_command = File.readlines(logfile.path)
56 | if success
57 | logger.debug { " GithubHook: Command output: #{output_from_command.inspect}" }
58 | else
59 | logger.error { " GithubHook: Command '#{command}' didn't exit properly. Full output: #{output_from_command.inspect}" }
60 | end
61 |
62 | return success
63 | ensure
64 | logfile.unlink if logfile && logfile.respond_to?(:unlink)
65 | end
66 |
67 | # Finds the Redmine project in the database based on the given project
68 | # identifier
69 | def find_project
70 | identifier = get_identifier
71 | project = Project.find_by_identifier(identifier.downcase)
72 | fail(
73 | ActiveRecord::RecordNotFound,
74 | "No project found with identifier '#{identifier}'"
75 | ) if project.nil?
76 | project
77 | end
78 |
79 | # Returns the Redmine Repository object we are trying to update
80 | def find_repositories
81 | project = find_project
82 | repositories = git_repositories(project)
83 |
84 | # if a specific repository id is passed in url parameter "repository_id",
85 | # then try to find it in the list of current project repositories and use
86 | # only this and not all to pull changes from (issue #54)
87 | if params.key?(:repository_id)
88 | param_repo = repositories.select do |repo|
89 | repo.identifier == params[:repository_id]
90 | end
91 |
92 | if param_repo.nil? || param_repo.length == 0
93 | logger.info {
94 | "GithubHook: The repository '#{params[:repository_id]}' isn't " \
95 | "in the list of projects repos. Updating all repos instead."
96 | }
97 |
98 | else
99 | repositories = param_repo
100 | end
101 | end
102 |
103 | repositories
104 | end
105 |
106 | # Gets the project identifier from the querystring parameters and if that's
107 | # not supplied, assume the Github repository name is the same as the project
108 | # identifier.
109 | def get_identifier
110 | identifier = get_project_name
111 | fail(
112 | ActiveRecord::RecordNotFound,
113 | "Project identifier not specified"
114 | ) if identifier.nil?
115 | identifier.to_s
116 | end
117 |
118 | # Attempts to find the project name. It first looks in the params, then in
119 | # the payload if params[:project_id] isn't given.
120 | def get_project_name
121 | project_id = params[:project_id]
122 | name_from_repository = payload.fetch("repository", {}).fetch("name", nil)
123 | project_id || name_from_repository
124 | end
125 |
126 | def git_command(command)
127 | GIT_BIN + " #{command}"
128 | end
129 |
130 | def git_repositories(project)
131 | repositories = project.repositories.select do |repo|
132 | repo.is_a?(Repository::Git)
133 | end
134 | if repositories.empty?
135 | fail(
136 | TypeError,
137 | "Project '#{project}' ('#{project.identifier}') has no repository"
138 | )
139 | end
140 | repositories || []
141 | end
142 |
143 | def logger
144 | @logger || NullLogger.new
145 | end
146 |
147 | def system(command)
148 | Kernel.system(command)
149 | end
150 |
151 | def time_diff_milli(start, finish)
152 | ((finish - start) * 1000.0).round(1)
153 | end
154 |
155 | # Fetches updates from the remote repository
156 | def update_repository(repository)
157 | command = git_command("fetch origin")
158 | fetch = exec(command, repository.url)
159 | return nil unless fetch
160 |
161 | command = git_command(
162 | "fetch --prune --prune-tags origin \"+refs/heads/*:refs/heads/*\""
163 | )
164 | exec(command, repository.url)
165 | end
166 | end
167 | end
168 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Redmine GitHub Hook
2 |
3 | [](https://github.com/pickhardt/maintainers-wanted)
4 |
5 | This plugin allows you to update your local Git repositories in Redmine when changes have been pushed to GitHub.
6 |
7 | ## Project Status: Looking for maintainers
8 |
9 | This project is not under active development, although I will continue to provide support for current users, but you can change that by joining the team.
10 |
11 | If you use this project and would like to develop it further, please introduce yourself on the [maintainers wanted ticket](https://github.com/koppen/redmine_github_hook/issues/96).
12 |
13 | ## Description
14 |
15 | [Redmine](http://redmine.org) has supported Git repositories for a long time, allowing you to browse your code and view your changesets directly in Redmine. For this purpose, Redmine relies on local clones of the Git repositories.
16 |
17 | If your shared repository is on a remote machine - for example on GitHub - this unfortunately means a bit of legwork to keep the local, Redmine-accessible repository up-to-date. The common approach is to set up a cronjob that pulls in any changes with regular intervals and updates Redmine with them.
18 |
19 | That approach works perfectly fine, but is a bit heavy-handed and cumbersome. The Redmine GitHub Hook plugin allows GitHub to notify your Redmine installation when changes have been pushed to a repository, triggering an update of your local repository and Redmine data only when it is actually necessary.
20 |
21 | ## Getting started
22 |
23 | ### 1. Install the plugin
24 |
25 | 1. Add the gem to your Gemfile.local:
26 | `gem "redmine_github_hook"`
27 | 2. `bundle`
28 | 3. Run migrations: `bundle exec rake redmine:plugins:migrate RAILS_ENV=production`
29 | 4. Restart your Redmine
30 |
31 | ### 2. Add the repository to Redmine
32 |
33 | Adding a Git repository to a project (note, this should work whether you want to use Redmine GitHub Hook or not).
34 |
35 | 1. Simply follow the instructions for [keeping your git repository in sync](http://www.redmine.org/wiki/redmine/HowTo_keep_in_sync_your_git_repository_for_redmine).
36 | * You don't need to set up a cron task as described in the Redmine instructions.
37 |
38 | ### 3. Connecting GitHub to Redmine
39 |
40 | 1. Go to the repository Settings interface on GitHub.
41 | 2. Under "Webhooks & Services" add a new "WebHook". The "Payload URL" needs to be of the format: `[redmine_url]/github_hook` (for example `http://redmine.example.com/github_hook`).
42 | * By default, GitHub Hook assumes your GitHub repository name is the same as the *project identifier* in your Redmine installation.
43 | * If this is not the case, you can specify the actual Redmine project identifier in the Post-Receive URL by using the format `[redmine_url]/github_hook?project_id=[identifier]` (for example `http://redmine.example.com/github_hook?project_id=my_project`).
44 | * GitHub Hook will then update **all repositories** in the specified project. *Be aware, that this process may take a while if you have many repositories in your project.*
45 | * If you want GitHub Hook to **only update the current repository** you can specify it with an additional parameter in the Post-Receive URL by using the format `[redmine_url]/github_hook?project_id=[identifier]&repository_id=[repository]` (for example `http://redmine.example.com/github_hook?project_id=my_project&repository_id=my_repo`).
46 | * In most cases, just having the "push" event trigger the webhook should suffice, but you are free to customize the events as you desire.
47 | * *Note: Make sure you're adding a Webhook - which is what Redmine Github Hook expects. GitHub has some builtin Redmine integration; that's not what you're looking for.*
48 |
49 | That's it. GitHub will now send a HTTP POST to the Redmine GitHub Hook plugin whenever changes are pushed to GitHub. The plugin then takes care of pulling the changes to the local repositories and updating the Redmine database with them.
50 |
51 |
52 | ## Assumptions
53 |
54 | * Redmine running on a *nix-like system. Redmine versions before 2.0 should use the redmine_1.x branch. This gem has been reported to work with Redmine version 5.x, 4.x, 3.x., 2.x.
55 | * Git 1.5 or higher available on the commandline.
56 |
57 |
58 | ## Troubleshooting
59 |
60 | ### Check your logfile
61 |
62 | If you run into issues, your Redmine logfile might have some valuable information. Two things to check for:
63 |
64 | 1. Do POST requests to `/github_hook` show up in the logfile at all? If so, what's the resulting status code?
65 | 2. If the git command used to pull in changes fails for whatever reason, there should also be some details about the failure in the logfile.
66 |
67 | The logfile is usually found in your Redmine directory in `log/production.log` although your webserver logs may contain some additional clues.
68 |
69 | ### Permissions problems
70 |
71 | As for permissions, whatever user Redmine is running as needs permissions to do the following things:
72 |
73 | * Read from the remote repository on GitHub
74 | * Read and write to the local repository on the Redmine server
75 |
76 | What user you are running Redmine as depends on your system and how you've setup your Redmine installation.
77 |
78 | #### GitHub
79 |
80 | This means you need to add its SSH keys on GitHub. If the user doesn't already have an SSH key, generate one and add the public SSH key as a Deploy Key for the repository on GitHub (or as one of your own keys, if you prefer that).
81 |
82 | #### Local repository
83 |
84 | The user running Redmine needs permissions to read and write to the local repository on the server.
85 |
86 |
87 | ## What happens
88 |
89 | The interactions between the different parts of the process is outlined in the following sequence diagram:
90 |
91 | ```mermaid
92 | sequenceDiagram
93 | participant Dev as Development Machine
94 | participant GH as GitHub
95 | participant Rm as Redmine
96 | Dev->>GH: git push
97 | GH->>Rm: POST /github_hook
98 | Rm->>GH: git fetch
99 | GH-->>Rm: commits
100 | Rm->>Rm: Update repository
101 | ```
102 |
103 | ## License
104 |
105 | Distributed under the MIT License. See LICENSE for more information.
106 |
--------------------------------------------------------------------------------
/test/unit/github_hook/updater_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | require "minitest/autorun"
4 | require "mocha"
5 |
6 | class GithubHookUpdaterTest < Minitest::Test
7 | def project
8 | return @project if @project
9 |
10 | @project ||= Project.new
11 | @project.repositories << repository
12 | @project
13 | end
14 |
15 | def repository
16 | return @repository if @repository
17 |
18 | @repository ||= Repository::Git.new(:identifier => "redmine")
19 | @repository.stubs(:fetch_changesets).returns(true)
20 | @repository
21 | end
22 |
23 | # rubocop:disable Metrics/LineLength
24 | def payload
25 | # Ruby hash with the parsed data from the JSON payload
26 | {
27 | "before" => "5aef35982fb2d34e9d9d4502f6ede1072793222d",
28 | "repository" => {"url" => "http://github.com/defunkt/github", "name" => "github", "description" => "You're lookin' at it.", "watchers" => 5, "forks" => 2, "private" => 1, "owner" => {"email" => "chris@ozmm.org", "name" => "defunkt"}},
29 | "commits" => [
30 | {"id" => "41a212ee83ca127e3c8cf465891ab7216a705f59", "url" => "http://github.com/defunkt/github/commit/41a212ee83ca127e3c8cf465891ab7216a705f59", "author" => {"email" => "chris@ozmm.org", "name" => "Chris Wanstrath"}, "message" => "okay i give in", "timestamp" => "2008-02-15T14:57:17-08:00", "added" => ["filepath.rb"]},
31 | {"id" => "de8251ff97ee194a289832576287d6f8ad74e3d0", "url" => "http://github.com/defunkt/github/commit/de8251ff97ee194a289832576287d6f8ad74e3d0", "author" => {"email" => "chris@ozmm.org", "name" => "Chris Wanstrath"}, "message" => "update pricing a tad", "timestamp" => "2008-02-15T14:36:34-08:00"}
32 | ],
33 | "after" => "de8251ff97ee194a289832576287d6f8ad74e3d0",
34 | "ref" => "refs/heads/master"
35 | }
36 | end
37 | # rubocop:enable Metrics/LineLength
38 |
39 | def build_updater(payload, options = {})
40 | updater = GithubHook::Updater.new(payload, options)
41 | updater.stubs(:exec).returns(true)
42 | updater
43 | end
44 |
45 | def updater
46 | return @memoized_updater if @memoized_updater
47 | @memoized_updater = build_updater(payload)
48 | end
49 |
50 | def setup
51 | Project.stubs(:find_by_identifier).with("github").returns(project)
52 |
53 | # Make sure we don't run actual commands in test
54 | GithubHook::Updater.any_instance.expects(:system).never
55 | Repository.expects(:fetch_changesets).never
56 | end
57 |
58 | def teardown
59 | @memoized_updater = nil
60 | end
61 |
62 | def test_uses_repository_name_as_project_identifier
63 | Project.expects(:find_by_identifier).with("github").returns(project)
64 | updater.call
65 | end
66 |
67 | def test_fetches_changes_from_origin
68 | updater.expects(:exec).with("git fetch origin", repository.url)
69 | updater.call
70 | end
71 |
72 | def test_resets_repository_when_fetch_origin_succeeds
73 | updater
74 | .expects(:exec)
75 | .with("git fetch origin", repository.url)
76 | .returns(true)
77 | updater
78 | .expects(:exec)
79 | .with(
80 | "git fetch --prune --prune-tags origin \"+refs/heads/*:refs/heads/*\"",
81 | repository.url
82 | )
83 | updater.call
84 | end
85 |
86 | def test_resets_repository_when_fetch_origin_fails
87 | updater
88 | .expects(:exec)
89 | .with("git fetch origin", repository.url)
90 | .returns(false)
91 | updater
92 | .expects(:exec)
93 | .with("git reset --soft refs\/remotes\/origin\/master", repository.url)
94 | .never
95 | updater.call
96 | end
97 |
98 | def test_uses_project_identifier_from_request
99 | Project.expects(:find_by_identifier).with("redmine").returns(project)
100 | updater = build_updater(payload, :project_id => "redmine")
101 | updater.call
102 | end
103 |
104 | def test_uses_project_identifier_from_request_as_numeric
105 | Project.expects(:find_by_identifier).with("42").returns(project)
106 | updater = build_updater(payload, :project_id => 42)
107 | updater.call
108 | end
109 |
110 | def test_updates_all_repositories_by_default
111 | another_repository = Repository::Git.new
112 | another_repository.expects(:fetch_changesets).returns(true)
113 | project.repositories << another_repository
114 |
115 | updater = build_updater(payload)
116 | updater.expects(:exec).with("git fetch origin", repository.url)
117 | updater.call
118 | end
119 |
120 | def test_updates_only_the_specified_repository
121 | another_repository = Repository::Git.new
122 | another_repository.expects(:fetch_changesets).never
123 | project.repositories << another_repository
124 |
125 | updater = build_updater(payload, :repository_id => "redmine")
126 | updater.expects(:exec).with("git fetch origin", repository.url)
127 | updater.call
128 | end
129 |
130 | def test_updates_all_repositories_if_specific_repository_is_not_found
131 | another_repository = Repository::Git.new
132 | another_repository.expects(:fetch_changesets).returns(true)
133 | project.repositories << another_repository
134 |
135 | updater = build_updater(payload, :repository_id => "redmine or something")
136 | updater.expects(:exec).with("git fetch origin", repository.url)
137 | updater.call
138 | end
139 |
140 | def test_raises_record_not_found_if_project_identifier_not_found
141 | assert_raises ActiveRecord::RecordNotFound do
142 | updater = build_updater({})
143 | updater.call
144 | end
145 | end
146 |
147 | def test_raises_record_not_found_if_project_identifier_not_given
148 | assert_raises ActiveRecord::RecordNotFound do
149 | updater = build_updater(payload.merge("repository" => {}))
150 | updater.call
151 | end
152 | end
153 |
154 | def test_raises_record_not_found_if_project_not_found
155 | assert_raises ActiveRecord::RecordNotFound do
156 | Project.expects(:find_by_identifier).with("foobar").returns(nil)
157 | updater = build_updater(payload, :project_id => "foobar")
158 | updater.call
159 | end
160 | end
161 |
162 | def test_downcases_identifier
163 | # Redmine project identifiers are always downcase
164 | Project.expects(:find_by_identifier).with("redmine").returns(project)
165 | updater = build_updater(payload, :project_id => "ReDmInE")
166 | updater.call
167 | end
168 |
169 | def test_fetches_changesets_into_the_repository
170 | updater.expects(:update_repository).returns(true)
171 | repository.expects(:fetch_changesets).returns(true)
172 | updater.call
173 | end
174 |
175 | def test_raises_type_error_if_project_has_no_repository
176 | assert_raises TypeError do
177 | project = mock("project", :to_s => "My Project", :identifier => "github")
178 | project.expects(:repositories).returns([])
179 | Project.expects(:find_by_identifier).with("github").returns(project)
180 | updater.call
181 | end
182 | end
183 |
184 | def test_raises_type_error_if_repository_is_not_git
185 | assert_raises TypeError do
186 | project = mock("project", :to_s => "My Project", :identifier => "github")
187 | repository = Repository::Subversion.new
188 | project.expects(:repositories).at_least(1).returns([repository])
189 | Project.expects(:find_by_identifier).with("github").returns(project)
190 | updater.call
191 | end
192 | end
193 |
194 | def test_logs_if_a_logger_is_given
195 | updater = GithubHook::Updater.new(payload)
196 | updater.stubs(:exec).returns(true)
197 |
198 | logger = stub("Logger")
199 | logger.expects(:info).at_least_once
200 | updater.logger = logger
201 |
202 | updater.call
203 | end
204 |
205 | def test_logs_if_a_message_logger_is_given
206 | updater = GithubHook::Updater.new(payload)
207 | updater.stubs(:exec).returns(true)
208 |
209 | logger = GithubHook::MessageLogger.new
210 | updater.logger = logger
211 |
212 | updater.call
213 | assert logger.messages.any?, "Should have received messages"
214 | end
215 | end
216 |
--------------------------------------------------------------------------------