]> _ Git - redmine-slack.git/commitdiff
wip #4472 @0.25
authorVincent Vanwaelscappel <vincent@cubedesigners.com>
Fri, 21 May 2021 06:32:50 +0000 (08:32 +0200)
committerVincent Vanwaelscappel <vincent@cubedesigners.com>
Fri, 21 May 2021 06:32:50 +0000 (08:32 +0200)
52 files changed:
.gitignore [new file with mode: 0644]
.idea/.gitignore [new file with mode: 0644]
.idea/deployment.xml [new file with mode: 0644]
.idea/misc.xml [new file with mode: 0644]
.idea/modules.xml [new file with mode: 0644]
.idea/redmine-slack.iml [new file with mode: 0644]
.idea/vcs.xml [new file with mode: 0644]
.rubocop.yml [new file with mode: 0644]
.slim-lint.yml [new file with mode: 0644]
.travis.yml [new file with mode: 0644]
Gemfile [new file with mode: 0644]
README.md [new file with mode: 0644]
app/controllers/redmine_slack_settings_controller.rb [new file with mode: 0644]
app/controllers/slash_commands_controller.rb [new file with mode: 0644]
app/helpers/redmine_slack_projects_helper.rb [new file with mode: 0644]
app/helpers/slash_commands_helper.rb [new file with mode: 0644]
app/models/redmine_slack_notification.rb [new file with mode: 0644]
app/models/redmine_slack_setting.rb [new file with mode: 0644]
app/models/slack.rb [new file with mode: 0644]
app/views/redmine_slack/_issues_silent_updates.html.erb [new file with mode: 0644]
app/views/redmine_slack/_journal_silent_updates.html.erb [new file with mode: 0644]
app/views/redmine_slack_settings/_redmine_slack_select.html.slim [new file with mode: 0644]
app/views/redmine_slack_settings/_redmine_slack_text.html.slim [new file with mode: 0644]
app/views/redmine_slack_settings/_show.html.slim [new file with mode: 0644]
app/views/settings/_redmine_slack_settings.html.slim [new file with mode: 0644]
config/locales/en.yml [new file with mode: 0644]
config/locales/fr.yml [new file with mode: 0644]
config/routes.rb [new file with mode: 0644]
db/migrate/20200622172451_create_redmine_slack_settings.rb [new file with mode: 0644]
db/migrate/20200623165645_migrate_redmine_messenger_settings.rb [new file with mode: 0644]
db/migrate/20200702092644_add_color_settings.rb [new file with mode: 0644]
db/migrate/20200723160847_create_redmine_slack_notifications.rb [new file with mode: 0644]
db/migrate/20200727112644_add_update_threshold.rb [new file with mode: 0644]
db/migrate/20200818072644_add_replies_threshold.rb [new file with mode: 0644]
docs/images/signing-secret.png [new file with mode: 0644]
docs/images/slack-token.png [new file with mode: 0644]
docs/images/slash-command.png [new file with mode: 0644]
init.rb [new file with mode: 0644]
lib/redmine_slack.rb [new file with mode: 0644]
lib/redmine_slack/diffy.rb [new file with mode: 0644]
lib/redmine_slack/helpers.rb [new file with mode: 0644]
lib/redmine_slack/hooks.rb [new file with mode: 0644]
lib/redmine_slack/patches/issue_patch.rb [new file with mode: 0644]
lib/redmine_slack/patches/issues_controller_patch.rb [new file with mode: 0644]
lib/redmine_slack/patches/journals_patch.rb [new file with mode: 0644]
lib/redmine_slack/patches/wiki_content_patch.rb [new file with mode: 0644]
test/integration/routing_test.rb [new file with mode: 0644]
test/support/database-postgresql-travis.yml [new file with mode: 0644]
test/test_helper.rb [new file with mode: 0644]
test/unit/i18n_test.rb [new file with mode: 0644]
test/unit/issue_test.rb [new file with mode: 0644]
test/unit/project_test.rb [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..d572e62
--- /dev/null
@@ -0,0 +1,8 @@
+.DS_Store
+coverage/
+.buildpath
+.project
+.settings/
+docs/_build
+docs/_static
+docs/_templates
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644 (file)
index 0000000..73f69e0
--- /dev/null
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
+# Editor-based HTTP Client requests
+/httpRequests/
diff --git a/.idea/deployment.xml b/.idea/deployment.xml
new file mode 100644 (file)
index 0000000..4d7d1a3
--- /dev/null
@@ -0,0 +1,343 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="PublishConfigData" remoteFilesAllowedToDisappearOnAutoupload="false">
+    <serverData>
+      <paths name="apps.fluidbook.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="ccv-montpellier.fr">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="demo1.cubedesigners.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="dev-digital.danone.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="dev.cubedesigners.fr">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="dev.cubjeans.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="dev.enko-running-shoes.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="dev.extranet.cubedesigners.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="dev.fluidbook.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="dev.pm-instrumentation.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="dev.rbcmobilier.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="dev.renversez.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="digitaltoolbox.danone.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="etatpur.ei-plateforme1.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="extranet.cubedesigners.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="extranet.preventicom.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="hosting.fluidbook.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="m.cubjeans.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="magento.enko-running-shoes.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="parrotmail.dev.cubedesigners.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="preview.cubedesigners.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="pro.cubjeans.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="simeox.dev.cubedesigners.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="toolbox.fluidbook.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="workshop.fluidbook.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="www.adangelis.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="www.animeland.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="www.ccgm.fr">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="www.cesaretleonie.fr">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="www.cfgv.fr">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="www.cubedesigners.fr">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="www.cubjeans.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="www.enko-running-shoes.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="www.eurofinsadmebioanalyses.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="www.fluidbook.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="www.hf-customercare.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="www.kadreo.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="www.mdryvescouzy.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="www.microbas.se">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="www.mirakl.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="www.optimed-recrutement.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="www.pavillonmadeleine.fr">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="www.physioassist.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="www.pm-instrumentation.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="www.preventicom.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="www.rbcmobilier.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="www.renversez.com">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+      <paths name="www.sycomore-am.com (1)">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+    </serverData>
+  </component>
+</project>
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644 (file)
index 0000000..0a76216
--- /dev/null
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="ProjectRootManager" version="2" languageLevel="JDK_1_6">
+    <output url="file://$PROJECT_DIR$/out" />
+  </component>
+</project>
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644 (file)
index 0000000..8e13544
--- /dev/null
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="ProjectModuleManager">
+    <modules>
+      <module fileurl="file://$PROJECT_DIR$/.idea/redmine-slack.iml" filepath="$PROJECT_DIR$/.idea/redmine-slack.iml" />
+    </modules>
+  </component>
+</project>
\ No newline at end of file
diff --git a/.idea/redmine-slack.iml b/.idea/redmine-slack.iml
new file mode 100644 (file)
index 0000000..d6ebd48
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<module type="JAVA_MODULE" version="4">
+  <component name="NewModuleRootManager" inherit-compiler-output="true">
+    <exclude-output />
+    <content url="file://$MODULE_DIR$" />
+    <orderEntry type="inheritedJdk" />
+    <orderEntry type="sourceFolder" forTests="false" />
+  </component>
+</module>
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644 (file)
index 0000000..35eb1dd
--- /dev/null
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="VcsDirectoryMappings">
+    <mapping directory="" vcs="Git" />
+  </component>
+</project>
\ No newline at end of file
diff --git a/.rubocop.yml b/.rubocop.yml
new file mode 100644 (file)
index 0000000..f8928f6
--- /dev/null
@@ -0,0 +1,116 @@
+AllCops:
+  TargetRubyVersion: 2.6
+  TargetRailsVersion: 5.2
+
+  Exclude:
+    - '**/vendor/**/*'
+    - '**/tmp/**/*'
+    - '**/bin/**/*'
+    - '**/plugins/**/*'
+    - '**/extra/**/*'
+    - '**/lib/generators/**/templates/*'
+    - '**/lib/tasks/**/*'
+    - '**/files/**/*'
+    - 'db/schema.rb'
+
+# Enable extensions
+
+require:
+  - rubocop-performance
+  - rubocop-rails
+
+# Rules for Redmine
+
+Lint/SendWithMixinArgument:
+  Enabled: false
+
+Style/SymbolArray:
+  Enabled: false
+
+Rails/ApplicationRecord:
+  Enabled: false
+
+Rails/CreateTableWithTimestamps:
+  Enabled: false
+
+Rails/FilePath:
+  Enabled: false
+
+Style/IfUnlessModifier:
+  Enabled: false
+
+Style/ExpandPathArguments:
+  Enabled: false
+
+Style/ClassVars:
+  Enabled: false
+
+Bundler/OrderedGems:
+  Enabled: false
+
+Rails/ReversibleMigration:
+  Enabled: false
+
+Layout/EmptyLineBetweenDefs:
+  AllowAdjacentOneLineDefs: true
+
+Layout/SpaceBeforeBlockBraces:
+  # "space" is used more than "no_space".
+  # But "no_space" is more natural in one liner.
+  #   str = path.to_s.split(%r{[/\\]}).select{|p| !p.blank?}.join("/")
+  Enabled: false
+
+# You can see results by "rubocop --only Layout/SpaceInsideBlockBraces"
+Layout/SpaceInsideBlockBraces:
+  EnforcedStyle: no_space
+  SpaceBeforeBlockParameters: false
+
+# You can see results by "rubocop --only Layout/SpaceInsideHashLiteralBraces"
+Layout/SpaceInsideHashLiteralBraces:
+  EnforcedStyle: no_space
+
+Layout/TrailingWhitespace:
+  AllowInHeredoc: true
+
+Lint/SuppressedException:
+  AllowComments: true
+
+Metrics:
+  Enabled: false
+
+Naming/AccessorMethodName:
+  Enabled: false
+
+Naming/BinaryOperatorParameterName:
+  Enabled: false
+
+Naming/PredicateName:
+  Enabled: false
+
+Rails/HelperInstanceVariable:
+  Enabled: false
+
+Style/FormatStringToken:
+  Enabled: false
+
+Style/FrozenStringLiteralComment:
+  Enabled: true
+  EnforcedStyle: always
+  Exclude:
+    - 'db/**/*.rb'
+    - 'Gemfile'
+    - 'Rakefile'
+    - 'config.ru'
+    - 'config/additional_environment.rb'
+
+Style/HashSyntax:
+  Enabled: true
+  EnforcedStyle: no_mixed_keys
+
+Style/IdenticalConditionalBranches:
+  Exclude:
+    - 'config/initializers/10-patches.rb'
+    - 'lib/redmine/wiki_formatting/textile/redcloth3.rb'
+
+Style/TrailingCommaInArrayLiteral:
+  Enabled: false
diff --git a/.slim-lint.yml b/.slim-lint.yml
new file mode 100644 (file)
index 0000000..e770df4
--- /dev/null
@@ -0,0 +1,3 @@
+linters:
+  LineLength:
+    max: 140
diff --git a/.travis.yml b/.travis.yml
new file mode 100644 (file)
index 0000000..d58d9f5
--- /dev/null
@@ -0,0 +1,39 @@
+language: ruby
+
+rvm:
+  - 2.4.5
+  - 2.5.3
+  - 2.6.6
+
+env:
+  - REDMINE_VER=4.0-stable DB=postgresql
+
+sudo: true
+
+addons:
+  postgresql: "9.6"
+
+before_install:
+  - export PLUGIN_NAME=redmine_slack
+  - export REDMINE_GIT_REPO=git://github.com/redmine/redmine.git
+  - export REDMINE_PATH=$HOME/redmine
+  - export BUNDLE_GEMFILE=$REDMINE_PATH/Gemfile
+  - git clone $REDMINE_GIT_REPO $REDMINE_PATH
+  - cd $REDMINE_PATH
+  - if [[ "$REDMINE_VER" != "master" ]]; then git checkout -b $REDMINE_VER origin/$REDMINE_VER; fi
+  - ln -s $TRAVIS_BUILD_DIR $REDMINE_PATH/plugins/$PLUGIN_NAME
+  - cp $TRAVIS_BUILD_DIR/test/support/database-$DB-travis.yml $REDMINE_PATH/config/database.yml
+
+before_script:
+  - psql -c 'create database travis_ci_test;' -U postgres
+  - bundle exec rake db:migrate
+  - bundle exec rake redmine:plugins:migrate
+
+script:
+  - export SKIP_COVERAGE=1
+  - if [[ "$REDMINE_VER" == "master" ]]; then bundle exec rake redmine:plugins:test:units NAME=$PLUGIN_NAME; fi
+  - if [[ "$REDMINE_VER" == "master" ]]; then bundle exec rake redmine:plugins:test:functionals NAME=$PLUGIN_NAME; fi
+  - if [[ "$REDMINE_VER" == "master" ]]; then bundle exec rake redmine:plugins:test:integration NAME=$PLUGIN_NAME; fi
+  - if [[ "$REDMINE_VER" != "master" ]]; then bundle exec rake redmine:plugins:test NAME=$PLUGIN_NAME RUBYOPT="-W0"; fi
+  - cd plugins/$PLUGIN_NAME
+  - bundle exec rubocop -c .rubocop.yml
diff --git a/Gemfile b/Gemfile
new file mode 100644 (file)
index 0000000..1c52dc6
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,8 @@
+gem 'slim-rails'
+gem 'validate_url'
+
+group :test do
+  gem 'rubocop', '~> 0.76.0', require: false
+  gem 'rubocop-performance', '~> 1.5.0', require: false
+  gem 'rubocop-rails', '~> 2.3.0', require: false
+end
diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..768c49d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,88 @@
+Redmine Slack
+=============
+
+This plugin allows to send notifications from your Redmine installation to Slack.
+
+It's heavily inspired by [Redmine Messenger](https://github.com/AlphaNodes/redmine_messenger/) but supporting only Slack. This way, we can take advantage of a lot of rich features included on Slack.
+
+# Configuration
+
+There's a global config and a per-project config to override global values. In this configuration you can set some parameters for the integration.
+
+
+To configure this plugin you need to get Slack Token and Slack Signing Secret.
+
+## Slack Signing Secret
+
+You get this on Basic Information tab for your app.
+
+![Screenshot of Signing Secret Section in Slack App config](./docs/images/signing-secret.png)
+
+## Slack Token
+
+You get this on "OAuth & Permissions" tab for your app.
+
+![Screenshot of Slack Token Section in Slack App config](./docs/images/slack-token.png)
+
+## Existing configuration parameters
+
+This is a list of current configuration parameters (both in global and per project):
+
+- Slack Token: Configure Slack token
+- Slack Signing Secret: Configure Slack Signing Secret to validate requests coming from Slack
+- Slack Channel: Channel to send notifications.
+- Slack Verify SSL: Configure whether SSL should be validated (Leave on. This will probably be deleted in a future version)
+- Auto Mentions: Configure whether to use mentions automatically in Slack.
+- Default Mentions: Mentions to include by default in Slack notifications.
+- Post Updates: Post issues updates to Slack.
+- New Include Description: Include description in new issue.
+- Updated include description: Include description in updated issue.
+- Text Trim Size: Character amount used to trim notifications to Slack.
+- Supress Empty Messages: Avoid sending messages without text description to Slack.
+- Post Private issues: Configure whether new private issues should be posted to Slack.
+- Post private notes: Configure whether updates to private issues should be posted to Slack.
+- Post wiki: Configure whether new Wiki pages should be posted to Slack.
+- Post wiki updates: Configure whether updates to wiki pages should be posted to Slack.
+
+## Configuring a new channel
+
+To configure a new channel, you could do one of these options:
+
+1) In Project -> Settings -> Redmine Slack, add channel and click save
+2) In Slack channel, invoke slash command like this: `/redmine-connect project-slug`
+
+In order to use as described in number 2), you need to create a Slack Slash command and provide it with the following url: `/slack/slash/connect`
+
+This is an example on how to configure the Slash Command:
+
+
+![Screenshot of Slack Slash Command Configuration in Slack App config](./docs/images/slash-command.png)
+
+## Posting Slack replies to Redmine
+
+This plugin is able to post the threaded replies for a notification back to Redmine. In order to do so, you need to configure a cron job like this:
+
+```
+0 * * * * cd /home/redmine/redmine && ./bin/rails runner Slack.post_slack_responses -e production >> log/cron_rake.log 2>&1
+```
+
+You should also configure "Get Slack replies threshold" parameter in the global plugin settings to the same amount of seconds between each cron run (e.g. if cron runs once per hour, time replies threshold should be 3600).
+
+In order to make this work, the bot needs to be added to the channels so that it can read the messages.
+
+# Features
+
+This plugin sends Slack notifications when you update issues or wiki entries.
+
+# Permissions required in Slack
+
+The following scopes are required in order to work properly with Slack:
+
+- chat:write
+- chat:write.public
+- commands
+- channels:history
+- channels:read
+- files:read
+- user:read
+- user:read.email
diff --git a/app/controllers/redmine_slack_settings_controller.rb b/app/controllers/redmine_slack_settings_controller.rb
new file mode 100644 (file)
index 0000000..f96ce51
--- /dev/null
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+# Redmine Slack Settings Controller.
+class RedmineSlackSettingsController < ApplicationController
+  before_action :find_project_by_project_id
+  before_action :authorize
+
+  def update
+    setting = RedmineSlackSetting.find_or_create(@project.id)
+    if setting.update(allowed_params)
+      flash[:notice] = l(:notice_successful_update)
+      redirect_to settings_project_path(@project, tab: 'redmine_slack')
+    else
+      flash[:error] = setting.errors.full_messages.flatten.join("\n")
+      respond_to do |format|
+        format.html {redirect_back_or_default(settings_project_path(@project, tab: 'redmine_slack'))}
+        format.api  {render_validation_errors(setting)}
+      end
+    end
+  end
+
+  private
+
+  def allowed_params
+    params.require(:setting).permit :redmine_slack_token,
+                                    :redmine_slack_signing_secret,
+                                    :redmine_slack_channel,
+                                    :redmine_slack_verify_ssl,
+                                    :auto_mentions,
+                                    :default_mentions,
+                                    :post_updates,
+                                    :new_include_description,
+                                    :updated_include_description,
+                                    :text_trim_size,
+                                    :supress_empty_messages,
+                                    :post_private_issues,
+                                    :post_private_notes,
+                                    :post_wiki,
+                                    :post_wiki_updates,
+                                    :color_create_notifications,
+                                    :color_update_notifications,
+                                    :replies_threshold,
+                                    :color_close_notifications,
+                                    :update_notification_threshold
+  end
+end
diff --git a/app/controllers/slash_commands_controller.rb b/app/controllers/slash_commands_controller.rb
new file mode 100644 (file)
index 0000000..540337d
--- /dev/null
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+# Slack commands controller.
+class SlashCommandsController < ApplicationController
+  include SlashCommandsHelper
+  skip_before_action :verify_authenticity_token
+
+  def channel_connect
+    signing_secret = RedmineSlack.settings[:redmine_slack_signing_secret]
+    data = []
+    if valid_request?(request.headers, request.raw_post, signing_secret)
+      channel_name = params[:channel_name]
+      project_slug = params[:text]
+      data = []
+      project = Project.where(['identifier = ?', project_slug]).first
+      unless project
+        # Try by id.
+        project = Project.where(['id = ?', project_slug]).first
+        unless project
+          data = "Project #{project_slug} not found"
+        end
+      end
+      if project
+        slack_setting = RedmineSlackSetting.find_or_create(project.id)
+        if slack_setting.redmine_slack_channel
+          previous_channel_name = slack_setting.redmine_slack_channel
+          if previous_channel_name != channel_name
+            slack_setting.redmine_slack_channel = channel_name
+            slack_setting.save!
+            data = "Project channel updated. It was previously set to #{previous_channel_name}."
+          else
+            data = "Nothing to do. Project was already set to channel #{channel_name}."
+          end
+        else
+          slack_setting.redmine_slack_channel = channel_name
+          slack_setting.save!
+          data = "Project channel set to #{channel_name}."
+        end
+      end
+    else
+      data = 'Invalid Request'
+    end
+    respond_to do |format|
+      format.html {render json: data, status: :ok, layout: nil}
+    end
+  end
+end
diff --git a/app/helpers/redmine_slack_projects_helper.rb b/app/helpers/redmine_slack_projects_helper.rb
new file mode 100644 (file)
index 0000000..3a678ac
--- /dev/null
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+# Project helpers module.
+module RedmineSlackProjectsHelper
+  def project_settings_tabs
+    tabs = super
+
+    if User.current.allowed_to?(:manage_redmine_slack, @project)
+      tabs << {name: 'redmine_slack',
+               action: :show,
+               partial: 'redmine_slack_settings/show',
+               label: :label_redmine_slack}
+    end
+
+    tabs
+  end
+end
diff --git a/app/helpers/slash_commands_helper.rb b/app/helpers/slash_commands_helper.rb
new file mode 100644 (file)
index 0000000..25389a1
--- /dev/null
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+# Slack commands controller helper.
+module SlashCommandsHelper
+  def valid_request?(headers, body, signing_secret)
+    timestamp = headers['X-Slack-Request-Timestamp']
+    signature = headers['X-Slack-Signature']
+    string_to_validate = "v0:#{timestamp}:#{body}"
+    digest = OpenSSL::Digest.new('sha256')
+    signed = OpenSSL::HMAC.hexdigest(digest, signing_secret, string_to_validate)
+    signature == "v0=#{signed}"
+  end
+end
diff --git a/app/models/redmine_slack_notification.rb b/app/models/redmine_slack_notification.rb
new file mode 100644 (file)
index 0000000..0fc1659
--- /dev/null
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+# Slack notification model class.
+class RedmineSlackNotification < ActiveRecord::Base
+  def self.find_or_create(entity, entity_id)
+    notification = RedmineSlackNotification.find_by(entity: entity, entity_id: entity_id)
+    unless notification
+      notification = RedmineSlackNotification.new
+      notification.entity = entity
+      notification.entity_id = entity_id
+      notification.timestamp = Time.now.to_i
+    end
+
+    notification
+  end
+
+  def self.find_or_create_within_timeframe(entity, entity_id, seconds)
+    current_timestamp = Time.now.to_i
+    timestamp = current_timestamp - seconds.to_i
+    notification = RedmineSlackNotification.find_by(
+      'entity = ? AND entity_id = ? AND timestamp >= ?',
+      entity,
+      entity_id,
+      timestamp
+    )
+    unless notification
+      notification = RedmineSlackNotification.new
+      notification.entity = entity
+      notification.entity_id = entity_id
+      notification.timestamp = current_timestamp
+    end
+    notification
+  end
+
+  def self.find_notification_by_type_within_timeframe(entity, seconds)
+    current_timestamp = Time.now.to_i
+    timestamp = current_timestamp - seconds.to_i
+    notifications = RedmineSlackNotification.where(
+      'entity = ? AND timestamp >= ?',
+      entity,
+      timestamp
+    )
+    notifications
+  end
+end
diff --git a/app/models/redmine_slack_setting.rb b/app/models/redmine_slack_setting.rb
new file mode 100644 (file)
index 0000000..cbd0055
--- /dev/null
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+# Slack settings model class.
+class RedmineSlackSetting < ActiveRecord::Base
+  belongs_to :project
+
+  def self.find_or_create(p_id)
+    setting = RedmineSlackSetting.find_by(project_id: p_id)
+    unless setting
+      setting = RedmineSlackSetting.new
+      setting.project_id = p_id
+    end
+
+    setting
+  end
+end
diff --git a/app/models/slack.rb b/app/models/slack.rb
new file mode 100644 (file)
index 0000000..f744147
--- /dev/null
@@ -0,0 +1,464 @@
+# frozen_string_literal: true
+
+require 'net/http'
+require 'json'
+
+# Slack class.
+class Slack
+  include Redmine::I18n
+
+  def self.markup_format(text)
+    text
+  end
+
+  def self.trim(original_msg, project)
+    msg = original_msg
+    trim_size = Slack.textfield_for_project(project, :text_trim_size).to_i
+
+    if trim_size.positive?
+      loop do
+        msg = msg[0..trim_size]
+        break unless /^.* (http[A-Za-z\.\d\/\:]*)$/.match(msg)
+        break if msg == original_msg
+        # Restore original message and try again.
+        msg = original_msg
+        trim_size += 1
+      end
+    end
+    msg
+  end
+
+  def self.default_url_options
+    {only_path: true, script_name: Redmine::Utils.relative_url_root}
+  end
+
+  def self.speak(msg, channels, options, notification = nil)
+    url = 'https://slack.com/api/chat.postMessage'
+    token = RedmineSlack.settings[:redmine_slack_token]
+
+    return if url.blank?
+    return if channels.blank?
+    return if token.blank?
+
+    params = {
+      text: msg,
+      link_names: 1
+    }
+
+    params[:attachments] = [options[:attachment]] if options[:attachment]&.any?
+
+    channels.each do |channel|
+      uri = URI(url)
+      params[:channel] = channel
+      http_options = {use_ssl: uri.scheme == 'https'}
+      http_options[:verify_mode] = OpenSSL::SSL::VERIFY_NONE unless RedmineSlack.setting?(:redmine_slack_verify_ssl)
+
+      begin
+        req = Net::HTTP::Post.new(uri, 'Content-Type' => 'application/json')
+        req['Authorization'] = "Bearer #{token}"
+        req.body = params.to_json
+        Net::HTTP.start(uri.hostname, uri.port, http_options) do |http|
+          response = http.request(req)
+          body = response.body
+          body_json = JSON.parse(body)
+          notification.slack_message_id = body_json['ts']
+          notification.slack_channel_id = body_json['channel']
+          notification.save
+          Rails.logger.warn(response) unless [Net::HTTPSuccess, Net::HTTPRedirection, Net::HTTPOK].include? response
+        end
+      rescue StandardError => e
+        Rails.logger.warn("cannot connect to #{url}")
+        Rails.logger.warn(e)
+      end
+    end
+  end
+
+  def self.update_message(msg, channel, options, notification = nil)
+    url = 'https://slack.com/api/chat.update'
+    token = RedmineSlack.settings[:redmine_slack_token]
+
+    return if url.blank?
+    return if channel.blank?
+    return if token.blank?
+
+    params = {
+      ts: notification.slack_message_id,
+      text: msg,
+      link_names: 1
+    }
+
+    params[:attachments] = [options[:attachment]] if options[:attachment]&.any?
+
+    uri = URI(url)
+    params[:channel] = channel
+    http_options = {use_ssl: uri.scheme == 'https'}
+    http_options[:verify_mode] = OpenSSL::SSL::VERIFY_NONE unless RedmineSlack.setting?(:redmine_slack_verify_ssl)
+    begin
+      req = Net::HTTP::Post.new(uri, 'Content-Type' => 'application/json')
+      req['Authorization'] = "Bearer #{token}"
+      req.body = params.to_json
+      Net::HTTP.start(uri.hostname, uri.port, http_options) do |http|
+        response = http.request(req)
+        notification.timestamp = Time.now.to_i
+        notification.save
+        Rails.logger.warn(response) unless [Net::HTTPSuccess, Net::HTTPRedirection, Net::HTTPOK].include? response
+      end
+    rescue StandardError => e
+      Rails.logger.warn("cannot connect to #{url}")
+      Rails.logger.warn(e)
+    end
+  end
+
+  def self.object_url(obj)
+    if Setting.host_name.to_s =~ %r{\A(https?\://)?(.+?)(\:(\d+))?(/.+)?\z}i
+      host = Regexp.last_match(2)
+      port = Regexp.last_match(4)
+      prefix = Regexp.last_match(5)
+      Rails.application.routes.url_for(
+        obj.event_url(
+          host: host,
+          protocol: Setting.protocol,
+          port: port,
+          script_name: prefix
+        )
+      )
+    else
+      Rails.application.routes.url_for(
+        obj.event_url(
+          host: Setting.host_name,
+          protocol: Setting.protocol,
+          script_name: ''
+        )
+      )
+    end
+  end
+
+  def self.textfield_for_project(proj, config)
+    return if proj.blank?
+
+    # project based
+    pm = RedmineSlackSetting.find_by(project_id: proj.id)
+    return pm.send(config) if !pm.nil? && pm.send(config).present?
+
+    default_textfield(proj, config)
+  end
+
+  def self.default_textfield(proj, config)
+    # parent project based
+    parent_field = textfield_for_project(proj.parent, config)
+    return parent_field if parent_field.present?
+    return RedmineSlack.settings[config] if RedmineSlack.settings[config].present?
+
+    ''
+  end
+
+  def self.channels_for_project(proj)
+    return [] if proj.blank?
+
+    # project based
+    pm = RedmineSlackSetting.find_by(project_id: proj.id)
+    if !pm.nil? && pm.redmine_slack_channel.present?
+      return [] if pm.redmine_slack_channel == '-'
+
+      return pm.redmine_slack_channel.split(',').map!(&:strip).uniq
+    end
+    default_project_channels(proj)
+  end
+
+  def self.default_project_channels(proj)
+    # parent project based
+    parent_channel = channels_for_project(proj.parent)
+    return parent_channel if parent_channel.present?
+    # system based
+    if RedmineSlack.settings[:redmine_slack_channel].present? &&
+       RedmineSlack.settings[:redmine_slack_channel] != '-'
+      return RedmineSlack.settings[:redmine_slack_channel].split(',').map!(&:strip).uniq
+    end
+
+    []
+  end
+
+  def self.setting_for_project(proj, config)
+    return false if proj.blank?
+
+    @setting_found = 0
+    # project based
+    pm = RedmineSlackSetting.find_by(project_id: proj.id)
+    unless pm.nil? || pm.send(config).zero?
+      @setting_found = 1
+      return false if pm.send(config) == 1
+      return true if pm.send(config) == 2
+      # 0 = use system based settings
+    end
+    default_project_setting(proj, config)
+  end
+
+  def self.default_project_setting(proj, config)
+    if proj.present? && proj.parent.present?
+      parent_setting = setting_for_project(proj.parent, config)
+      return parent_setting if @setting_found == 1
+    end
+    # system based
+    return true if RedmineSlack.settings[config].present? && RedmineSlack.setting?(config)
+
+    false
+  end
+
+  def self.detail_to_field(detail, project)
+    field_format = nil
+    key = nil
+    escape = true
+
+    if detail.property == 'cf'
+      key = begin
+              CustomField.find(detail.prop_key).name
+            rescue StandardError
+              nil
+            end
+      title = key
+      field_format = begin
+                       CustomField.find(detail.prop_key).field_format
+                     rescue StandardError
+                       nil
+                     end
+    elsif detail.property == 'attachment'
+      key = 'attachment'
+      title = I18n.t :label_attachment
+    else
+      key = detail.prop_key.to_s.sub('_id', '')
+      title = if key == 'parent'
+                I18n.t "field_#{key}_issue"
+              else
+                I18n.t "field_#{key}"
+              end
+    end
+
+    short = true
+    value = detail.value.to_s
+
+    case key
+    when 'description'
+      short = false
+      value = Slack.trim(value, project)
+    when 'title', 'subject'
+      short = false
+    when 'tracker'
+      tracker = Tracker.find(detail.value)
+      value = tracker.to_s if tracker.present?
+    when 'project'
+      project = Project.find(detail.value)
+      value = project.to_s if project.present?
+    when 'status'
+      status = IssueStatus.find(detail.value)
+      value = status.to_s if status.present?
+    when 'priority'
+      priority = IssuePriority.find(detail.value)
+      value = priority.to_s if priority.present?
+    when 'category'
+      category = detail.value ? IssueCategory.find(detail.value) : ''
+      value = category.to_s if category.present?
+    when 'assigned_to'
+      user = detail.value ? User.find(detail.value) : ''
+      value = user.to_s if user.present?
+    when 'fixed_version'
+      fixed_version = detail.value ? Version.find(detail.value) : ''
+      value = fixed_version.to_s if fixed_version.present?
+    when 'attachment'
+      attachment = Attachment.find(detail.prop_key)
+      value = "<#{Slack.object_url attachment}|#{ERB::Util.html_escape(attachment.filename)}>" if attachment.present?
+      escape = false
+    when 'parent'
+      issue = detail.value ? Issue.find(detail.value) : ''
+      value = "<#{Slack.object_url issue}|#{ERB::Util.html_escape(issue)}>" if issue.present?
+      escape = false
+    end
+
+    if detail.property == 'cf' && field_format == 'version'
+      version = Version.find(detail.value)
+      value = version.to_s if version.present?
+    end
+
+    value = if value.present?
+              if escape
+                ERB::Util.html_escape(value)
+              else
+                value
+              end
+            else
+              '-'
+            end
+
+    result = {title: title, value: value}
+    result[:short] = true if short
+    result
+  end
+
+  def self.mentions(project, text)
+    names = []
+    Slack.textfield_for_project(project, :default_mentions)
+         .split(',').each {|m| names.push m.strip}
+    names += extract_usernames(text) unless text.nil?
+    names.present? ? ' To: ' + names.uniq.join(', ') : nil
+  end
+
+  def self.extract_usernames(text)
+    text = '' if text.nil?
+    # slack usernames may only contain lowercase letters, numbers,
+    # dashes, dots and underscores and must start with a letter or number.
+    text.scan(/@[a-z0-9][a-z0-9_\-.]*/).uniq
+  end
+
+  def self.get_recent_notifications
+    notifications = []
+    # Get notifications sent in last 24 hours.
+    notifications += RedmineSlackNotification.find_notification_by_type_within_timeframe('issue', 86_400)
+    notifications += RedmineSlackNotification.find_notification_by_type_within_timeframe('issue-note', 86_400)
+    notifications
+  end
+
+  def self.get_notification_replies(notification)
+    url = 'https://slack.com/api/conversations.replies'
+    token = RedmineSlack.settings[:redmine_slack_token]
+
+    return if token.blank?
+
+    params = {
+      ts: notification.slack_message_id,
+      channel: notification.slack_channel_id
+    }
+
+    uri = URI(url)
+    uri.query = URI.encode_www_form(params)
+    begin
+      req = Net::HTTP::Get.new(uri, 'Content-Type' => 'application/json')
+      req['Authorization'] = "Bearer #{token}"
+      http_options = {use_ssl: uri.scheme == 'https'}
+      http_options[:verify_mode] = OpenSSL::SSL::VERIFY_NONE unless RedmineSlack.setting?(:redmine_slack_verify_ssl)
+      Net::HTTP.start(uri.hostname, uri.port, http_options) do |http|
+        response = http.request(req)
+        body = response.body
+        body_json = JSON.parse(body)
+        body_json['messages'] || []
+      end
+    rescue StandardError => e
+      Rails.logger.warn("cannot connect to #{url}")
+      Rails.logger.warn(e)
+    end
+  end
+
+  def self.get_user_email(slack_user_id)
+    url = 'https://slack.com/api/users.info'
+    token = RedmineSlack.settings[:redmine_slack_token]
+
+    return if token.blank?
+
+    params = {
+      user: slack_user_id
+    }
+
+    uri = URI(url)
+    uri.query = URI.encode_www_form(params)
+    begin
+      req = Net::HTTP::Get.new(uri, 'Content-Type' => 'application/json')
+      req['Authorization'] = "Bearer #{token}"
+      http_options = {use_ssl: uri.scheme == 'https'}
+      http_options[:verify_mode] = OpenSSL::SSL::VERIFY_NONE unless RedmineSlack.setting?(:redmine_slack_verify_ssl)
+      Net::HTTP.start(uri.hostname, uri.port, http_options) do |http|
+        response = http.request(req)
+        body = response.body
+        body_json = JSON.parse(body)
+        if body_json['user']['profile'].key?('email')
+          body_json['user']['profile']['email']
+        end
+      end
+    rescue StandardError => e
+      Rails.logger.warn("cannot connect to #{url}")
+      Rails.logger.warn(e)
+    end
+  end
+
+  def self.get_user_id(email)
+    return 2 if email.nil?
+
+    email_address = EmailAddress.find_by address: email
+    return 2 if email_address.nil?
+
+    email_address.user_id
+  end
+
+  def self.post_reply_to_redmine(message, issue_id)
+    email = get_user_email(message['user'])
+    author_id = get_user_id(email)
+    journal = Journal.new
+    journal.journalized_type = 'Issue'
+    journal.journalized = Issue.find(issue_id)
+    journal.user_id = author_id
+    journal.notes = message['text']
+
+    if message.key? 'files'
+      message['files'].each do |file|
+        url = file['url_private']
+        file_content = get_attachment(url)
+        attachments = []
+        author = User.find(author_id)
+        next if file_content.nil?
+
+        attachment = Attachment.new(:file => file_content)
+        attachment.container_id = issue_id
+        attachment.container_type = 'Issue'
+        attachment.author = author
+        attachment.filename = file['title']
+        attachment.content_type = file['mimetype']
+        attachment.save
+        journal.journalize_attachment(attachment, :added)
+        attachments << attachment
+      end
+    end
+
+    journal.save
+    journal.journalized.save
+    journal
+  end
+
+  def self.get_attachment(url)
+    token = RedmineSlack.settings[:redmine_slack_token]
+
+    return if token.blank?
+
+    uri = URI(url)
+    begin
+      req = Net::HTTP::Get.new(uri)
+      req['Authorization'] = "Bearer #{token}"
+      http_options = {use_ssl: uri.scheme == 'https'}
+      http_options[:verify_mode] = OpenSSL::SSL::VERIFY_NONE unless RedmineSlack.setting?(:redmine_slack_verify_ssl)
+      Net::HTTP.start(uri.hostname, uri.port, http_options) do |http|
+        response = http.request(req)
+        response.body
+      end
+    rescue StandardError => e
+      Rails.logger.warn("cannot connect to #{url}")
+      Rails.logger.warn(e)
+    end
+  end
+
+  def self.post_slack_responses
+    notifications = get_recent_notifications
+    notifications.each do |notification|
+      issue_id = notification.entity_id
+      issue = Issue.find(issue_id)
+      project = Project.find(issue.project_id)
+      seconds = Slack.textfield_for_project(project, :replies_threshold).to_i || 0
+      replies = get_notification_replies(notification)
+      replies.each do |reply|
+        next unless reply.key?('thread_ts') && reply['thread_ts'] != reply['ts']
+
+        current_timestamp = Time.now.to_i
+        timestamp = current_timestamp - seconds
+        # Only act for replies within allowed "seconds".
+        next if reply['ts'].to_i < timestamp
+
+        post_reply_to_redmine(reply, issue_id)
+      end
+    end
+  end
+end
diff --git a/app/views/redmine_slack/_issues_silent_updates.html.erb b/app/views/redmine_slack/_issues_silent_updates.html.erb
new file mode 100644 (file)
index 0000000..016d43c
--- /dev/null
@@ -0,0 +1,4 @@
+<p class="redmine-slack-silent">
+    <input name="redmine_issue_slack_silent" type="checkbox" checked="checked"/>
+    <label for="redmine_issue_slack_silent">Send this update to Slack</label>
+</p>
\ No newline at end of file
diff --git a/app/views/redmine_slack/_journal_silent_updates.html.erb b/app/views/redmine_slack/_journal_silent_updates.html.erb
new file mode 100644 (file)
index 0000000..6ebfacd
--- /dev/null
@@ -0,0 +1,3 @@
+<br/>
+<input name="redmine_journal_slack_silent" type="checkbox" checked="checked"/>
+<label for="redmine_journal_slack_silent">Send this update to Slack</label>
\ No newline at end of file
diff --git a/app/views/redmine_slack_settings/_redmine_slack_select.html.slim b/app/views/redmine_slack_settings/_redmine_slack_select.html.slim
new file mode 100644 (file)
index 0000000..e5c5461
--- /dev/null
@@ -0,0 +1,7 @@
+p
+  = f.select mf, project_redmine_slack_options(@redmine_slack_setting.send(mf)), label: l("label_settings_#{mf}")
+  '
+  em.info[style="display: inline;"]
+    = l(:label_default)
+    ' :
+    = project_setting_redmine_slack_default_value(mf)
diff --git a/app/views/redmine_slack_settings/_redmine_slack_text.html.slim b/app/views/redmine_slack_settings/_redmine_slack_text.html.slim
new file mode 100644 (file)
index 0000000..1b290f4
--- /dev/null
@@ -0,0 +1,9 @@
+p
+  = f.text_field mf, size: size, label: l("label_settings_#{mf}")
+  em.info
+    = l(:label_redmine_slack_project_text_field_info)
+    |  (
+    = l(:label_default)
+    ' :
+    = Slack.default_textfield(@project, mf)
+    | )
diff --git a/app/views/redmine_slack_settings/_show.html.slim b/app/views/redmine_slack_settings/_show.html.slim
new file mode 100644 (file)
index 0000000..67a9af9
--- /dev/null
@@ -0,0 +1,57 @@
+.box.tabular.redmine_slack_settings
+  - @redmine_slack_setting = RedmineSlackSetting.find_or_create(@project.id)
+  = labelled_form_for :setting,
+                      @redmine_slack_setting,
+                      url: project_redmine_slack_setting_path(project_id: @project),
+                      method: :put,
+                      class: 'tabular' do |f|
+    = error_messages_for 'redmine_slack_setting'
+    .box
+      .info = t(:redmine_slack_settings_project_intro)
+      br
+      p
+        = f.text_field :redmine_slack_token, size: 60, label: l(:label_settings_redmine_slack_token)
+        em.info
+          = l(:label_redmine_slack_project_text_field_info)
+          |  (
+          = l(:label_redmine_slack_default_not_visible)
+          | )
+      p
+        = f.text_field :redmine_slack_signing_secret, size: 60, label: l(:label_settings_redmine_slack_signing_secret)
+        em.info
+          = l(:label_redmine_slack_project_text_field_info)
+          |  (
+          = l(:label_redmine_slack_default_not_visible)
+          | )
+      = render partial: 'redmine_slack_settings/redmine_slack_text', locals: { f: f, mf: :redmine_slack_channel, size: 30 }
+      = render partial: 'redmine_slack_settings/redmine_slack_select', locals: { f: f, mf: :redmine_slack_verify_ssl }
+
+      p
+        = render partial: 'redmine_slack_settings/redmine_slack_text', locals: { f: f, mf: :color_create_notifications, size: 30 }
+        = render partial: 'redmine_slack_settings/redmine_slack_text', locals: { f: f, mf: :color_update_notifications, size: 30 }
+        = render partial: 'redmine_slack_settings/redmine_slack_text', locals: { f: f, mf: :color_close_notifications, size: 30 }
+        = render partial: 'redmine_slack_settings/redmine_slack_text', locals: { f: f, mf: :update_notification_threshold, size: 30 }
+        = render partial: 'redmine_slack_settings/redmine_slack_text', locals: { f: f, mf: :replies_threshold, size: 30 }
+
+      br
+      h3 = l(:label_issue_plural)
+      .info = t(:redmine_slack_issue_intro)
+      br
+      = render partial: 'redmine_slack_settings/redmine_slack_select', locals: { f: f, mf: :auto_mentions }
+      = render partial: 'redmine_slack_settings/redmine_slack_select', locals: { f: f, mf: :post_updates }
+      = render partial: 'redmine_slack_settings/redmine_slack_select', locals: { f: f, mf: :new_include_description }
+      = render partial: 'redmine_slack_settings/redmine_slack_select', locals: { f: f, mf: :updated_include_description }
+      = render partial: 'redmine_slack_settings/redmine_slack_text', locals: { f: f, mf: :text_trim_size, size: 30 }
+      = render partial: 'redmine_slack_settings/redmine_slack_select', locals: { f: f, mf: :supress_empty_messages }
+      = render partial: 'redmine_slack_settings/redmine_slack_select', locals: { f: f, mf: :post_private_issues }
+      = render partial: 'redmine_slack_settings/redmine_slack_select', locals: { f: f, mf: :post_private_notes }
+
+      br
+      h3 = l(:label_wiki)
+      .info = t(:redmine_slack_wiki_intro)
+      br
+      = render partial: 'redmine_slack_settings/redmine_slack_select', locals: { f: f, mf: :post_wiki }
+      = render partial: 'redmine_slack_settings/redmine_slack_select', locals: { f: f, mf: :post_wiki_updates }
+      br
+
+    = submit_tag l(:button_save)
diff --git a/app/views/settings/_redmine_slack_settings.html.slim b/app/views/settings/_redmine_slack_settings.html.slim
new file mode 100644 (file)
index 0000000..58c6ed6
--- /dev/null
@@ -0,0 +1,80 @@
+- @settings = ActionController::Parameters.new(@settings) unless Rails.version >= '5.2'
+
+.info = t(:redmine_slack_settings_intro)
+br
+p
+  = content_tag(:label, l(:label_settings_redmine_slack_token))
+  = text_field_tag('settings[redmine_slack_token]', @settings[:redmine_slack_token], size: 60, placeholder: '')
+  em.info = t(:redmine_slack_token_info_html)
+p
+  = content_tag(:label, l(:label_settings_redmine_slack_signing_secret))
+  = text_field_tag('settings[redmine_slack_signing_secret]', @settings[:redmine_slack_signing_secret], size: 60, placeholder: '')
+  em.info = t(:redmine_slack_signing_secret_info_html)
+p
+  = content_tag(:label, l(:label_settings_redmine_slack_channel))
+  = text_field_tag('settings[redmine_slack_channel]', @settings[:redmine_slack_channel], size: 30, placeholder: 'redmine')
+  em.info = t(:redmine_slack_channel_info_html)
+p
+  = content_tag(:label, l(:label_settings_redmine_slack_verify_ssl))
+  = check_box_tag 'settings[redmine_slack_verify_ssl]', 1, @settings[:redmine_slack_verify_ssl].to_i == 1
+  em.info = t(:redmine_slack_verify_ssl_info_html)
+p
+  = content_tag(:label, l(:label_settings_color_create_notifications))
+  = text_field_tag('settings[color_create_notifications]', @settings[:color_create_notifications], size: 30)
+p
+  = content_tag(:label, l(:label_settings_color_update_notifications))
+  = text_field_tag('settings[color_update_notifications]', @settings[:color_update_notifications], size: 30)
+p
+  = content_tag(:label, l(:label_settings_color_close_notifications))
+  = text_field_tag('settings[color_close_notifications]', @settings[:color_close_notifications], size: 30)
+p
+  = content_tag(:label, l(:label_settings_update_notification_threshold))
+  = text_field_tag('settings[update_notification_threshold]', @settings[:update_notification_threshold], size: 30)
+p
+  = content_tag(:label, l(:label_settings_replies_threshold))
+  = text_field_tag('settings[replies_threshold]', @settings[:replies_threshold], size: 30)
+
+br
+h3 = l(:label_issue_plural)
+.info = t(:redmine_slack_issue_intro)
+br
+p
+  = content_tag(:label, l(:label_settings_auto_mentions))
+  = check_box_tag 'settings[auto_mentions]', 1, @settings[:auto_mentions].to_i == 1
+p
+  = content_tag(:label, l(:label_settings_default_mentions))
+  = text_field_tag('settings[default_mentions]', @settings[:default_mentions], size: 30)
+  em.info = t(:default_mentionsl_info)
+p
+  = content_tag(:label, l(:label_settings_post_updates))
+  = check_box_tag 'settings[post_updates]', 1, @settings[:post_updates].to_i == 1
+p
+  = content_tag(:label, l(:label_settings_new_include_description))
+  = check_box_tag 'settings[new_include_description]', 1, @settings[:new_include_description].to_i == 1
+p
+  = content_tag(:label, l(:label_settings_updated_include_description))
+  = check_box_tag 'settings[updated_include_description]', 1, @settings[:updated_include_description].to_i == 1
+p
+  = content_tag(:label, l(:label_settings_text_trim_size))
+  = text_field_tag('settings[text_trim_size]', @settings[:text_trim_size], size: 30)
+p
+  = content_tag(:label, l(:label_settings_supress_empty_messages))
+  = check_box_tag 'settings[supress_empty_messages]', 1, @settings[:supress_empty_messages].to_i == 1
+p
+  = content_tag(:label, l(:label_settings_post_private_issues))
+  = check_box_tag 'settings[post_private_issues]', 1, @settings[:post_private_issues].to_i == 1
+p
+  = content_tag(:label, l(:label_settings_post_private_notes))
+  = check_box_tag 'settings[post_private_notes]', 1, @settings[:post_private_notes].to_i == 1
+
+br
+h3 = l(:label_wiki)
+.info = t(:redmine_slack_wiki_intro)
+br
+p
+  = content_tag(:label, l(:label_settings_post_wiki))
+  = check_box_tag 'settings[post_wiki]', 1, @settings[:post_wiki].to_i == 1
+p
+  = content_tag(:label, l(:label_settings_post_wiki_updates))
+  = check_box_tag 'settings[post_wiki_updates]', 1, @settings[:post_wiki_updates].to_i == 1
+
diff --git a/config/locales/en.yml b/config/locales/en.yml
new file mode 100644 (file)
index 0000000..10fe520
--- /dev/null
@@ -0,0 +1,45 @@
+# English strings
+en:
+  default_mentionsl_info: Default people to notify, comma separated (e.g. @all, @here)
+  error_redmine_slack_invalid_url: is not a valid URL
+  label_redmine_slack_default_not_visible: Default setting is not visible for security reasons
+  label_redmine_slack_issue_created: "[%{project_url}] Issue %{url} created by *%{user}*"
+  label_redmine_slack_issue_updated: "[%{project_url}] Issue %{url} updated by *%{user}*"
+  label_redmine_slack_project_text_field_info: Leave it blank for system default.
+  label_redmine_slack_setting: Redmine Slack Settings
+  label_redmine_slack_settings_default: System default
+  label_redmine_slack_settings_disabled: Disabled
+  label_redmine_slack_settings_enabled: Enabled
+  label_redmine_slack_wiki_created: "[%{project_url}] Wiki %{url} created by *%{user}*"
+  label_redmine_slack_wiki_updated: "[%{project_url}] Wiki %{url} updated by *%{user}*"
+  label_redmine_slack: Redmine Slack
+  label_settings_auto_mentions: Convert names to mentions?
+  label_settings_default_mentions: Default people for mentions
+  label_settings_redmine_slack_channel: Redmine Slack Channel
+  label_settings_redmine_slack_token: Redmine Slack Token
+  label_settings_redmine_slack_signing_secret: Redmine Slack Signing Secret
+  label_settings_redmine_slack_verify_ssl: Verify SSL
+  label_settings_new_include_description: New issue description?
+  label_settings_post_private_issues: Private issue updates?
+  label_settings_post_private_notes: Private notes updates?
+  label_settings_post_updates: Issue updates?
+  label_settings_post_wiki_updates: Wiki updates?
+  label_settings_post_wiki: Post Wiki added?
+  label_settings_updated_include_description: Description in update issue?
+  label_settings_text_trim_size: Text trim size?
+  label_settings_supress_empty_messages: Supress empty messages?
+  redmine_slack_channel_info_html: 'Here you have to specify the channel, which should be used. You can define multible channels, seperated by comma'
+  redmine_slack_contacts_intro: Activate the changes for Issues that should be sent to the pre-defined Redmine Slack channel.
+  redmine_slack_issue_intro: Activate the changes for Issues that should be sent to the pre-defined Redmine Slack channel.
+  redmine_slack_settings_intro: "Configure global settings here. This settings can be overriden per project."
+  redmine_slack_settings_project_intro: "Override global settings for this specific project."
+  redmine_slack_token_info_html: 'Generate a token from your Slack app'
+  redmine_slack_signing_secret_info_html: 'Paste the verification token from your Slack app'
+  redmine_slack_verify_ssl_info_html: 'If your Redmine Slack service uses an invalid or self-signed SSL certificate, disable it.'
+  redmine_slack_wiki_intro: Activate the changes for Wikis that should be sent to the pre-defined Redmine Slack channel.
+  permission_manage_redmine_slack: Manage Redmine Slack
+  label_settings_color_create_notifications: Hex color for create notifications
+  label_settings_color_update_notifications: Hex color for update notifications
+  label_settings_color_close_notifications: Hex color for close notifications
+  label_settings_update_notification_threshold: Update notifications threshold
+  label_settings_replies_threshold: Get Slack replies threshold
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
new file mode 100644 (file)
index 0000000..d6150e4
--- /dev/null
@@ -0,0 +1,45 @@
+# French strings
+fr:
+  default_mentionsl_info: Default people to notify, comma separated (e.g. @all, @here)
+  error_redmine_slack_invalid_url: is not a valid URL
+  label_redmine_slack_default_not_visible: Default setting is not visible for security reasons
+  label_redmine_slack_issue_created: "[%{project_url}] Issue %{url} created by *%{user}*"
+  label_redmine_slack_issue_updated: "[%{project_url}] Issue %{url} updated by *%{user}*"
+  label_redmine_slack_project_text_field_info: Leave it blank for system default.
+  label_redmine_slack_setting: Paramètres de Redmine Slack
+  label_redmine_slack_settings_default: System default
+  label_redmine_slack_settings_disabled: Disabled
+  label_redmine_slack_settings_enabled: Enabled
+  label_redmine_slack_wiki_created: "[%{project_url}] Wiki %{url} created by *%{user}*"
+  label_redmine_slack_wiki_updated: "[%{project_url}] Wiki %{url} updated by *%{user}*"
+  label_redmine_slack: Redmine Slack
+  label_settings_auto_mentions: Convert names to mentions?
+  label_settings_default_mentions: Default people for mentions
+  label_settings_redmine_slack_channel: Redmine Slack Channel
+  label_settings_redmine_slack_token: Redmine Slack Token
+  label_settings_redmine_slack_signing_secret: Redmine Slack Signing Secret
+  label_settings_redmine_slack_verify_ssl: Verify SSL
+  label_settings_new_include_description: New issue description?
+  label_settings_post_private_issues: Private issue updates?
+  label_settings_post_private_notes: Private notes updates?
+  label_settings_post_updates: Issue updates?
+  label_settings_post_wiki_updates: Wiki updates?
+  label_settings_post_wiki: Post Wiki added?
+  label_settings_updated_include_description: Description in update issue?
+  label_settings_text_trim_size: Text trim size?
+  label_settings_supress_empty_messages: Supress empty messages?
+  redmine_slack_channel_info_html: 'Here you have to specify the channel, which should be used. You can define multible channels, seperated by comma'
+  redmine_slack_contacts_intro: Activate the changes for Issues that should be sent to the pre-defined Redmine Slack channel.
+  redmine_slack_issue_intro: Activate the changes for Issues that should be sent to the pre-defined Redmine Slack channel.
+  redmine_slack_settings_intro: "Configure global settings here. This settings can be overriden per project."
+  redmine_slack_settings_project_intro: "Override global settings for this specific project."
+  redmine_slack_token_info_html: 'Generate a token from your Slack app'
+  redmine_slack_signing_secret_info_html: 'Paste the verification token from your Slack app'
+  redmine_slack_verify_ssl_info_html: 'If your Redmine Slack service uses an invalid or self-signed SSL certificate, disable it.'
+  redmine_slack_wiki_intro: Activate the changes for Wikis that should be sent to the pre-defined Redmine Slack channel.
+  permission_manage_redmine_slack: Manage Redmine Slack
+  label_settings_color_create_notifications: Hex color for create notifications
+  label_settings_color_update_notifications: Hex color for update notifications
+  label_settings_color_close_notifications: Hex color for close notifications
+  label_settings_update_notification_threshold: Update notifications threshold
+  label_settings_replies_threshold: Get Slack replies threshold
diff --git a/config/routes.rb b/config/routes.rb
new file mode 100644 (file)
index 0000000..6d5db32
--- /dev/null
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+# Plugin's routes
+# See: http://guides.rubyonrails.org/routing.html
+resources :projects, only: [] do
+  resource :redmine_slack_setting, only: %i[show update]
+end
+
+post 'slack/slash/connect', :to => 'slash_commands#channel_connect'
diff --git a/db/migrate/20200622172451_create_redmine_slack_settings.rb b/db/migrate/20200622172451_create_redmine_slack_settings.rb
new file mode 100644 (file)
index 0000000..f21a804
--- /dev/null
@@ -0,0 +1,23 @@
+# Initial migration.
+class CreateRedmineSlackSettings < ActiveRecord::Migration[5.2]
+  def change
+    create_table :redmine_slack_settings do |t|
+      t.references :project, null: false, index: true
+      t.string :redmine_slack_token
+      t.string :redmine_slack_signing_secret
+      t.string :redmine_slack_channel
+      t.integer :redmine_slack_verify_ssl, default: 0, null: false
+      t.integer :auto_mentions, default: 0, null: false
+      t.string :default_mentions, default: nil
+      t.integer :post_updates, default: 0, null: false
+      t.integer :new_include_description, default: 0, null: false
+      t.integer :updated_include_description, default: 0, null: false
+      t.integer :post_private_issues, default: 0, null: false
+      t.integer :post_private_notes, default: 0, null: false
+      t.integer :post_wiki, default: 0, null: false
+      t.integer :post_wiki_updates, default: 0, null: false
+      t.integer :text_trim_size, default: nil
+      t.integer :supress_empty_messages, default: 0, null: false
+    end
+  end
+end
diff --git a/db/migrate/20200623165645_migrate_redmine_messenger_settings.rb b/db/migrate/20200623165645_migrate_redmine_messenger_settings.rb
new file mode 100644 (file)
index 0000000..fefc3f2
--- /dev/null
@@ -0,0 +1,31 @@
+# Initial migration.
+class MigrateRedmineMessengerSettings < ActiveRecord::Migration[5.2]
+  def change
+    return unless Redmine::Plugin.installed?('redmine_messenger')
+
+    MessengerSetting.where('messenger_url IS NOT NULL').each do |setting|
+      next if RedmineSlackSetting.where('project_id = :p_id', p_id: setting.project_id).exists?
+
+      new_setting = RedmineSlackSetting.find_or_create(setting.project_id)
+      new_setting.redmine_slack_channel = setting.messenger_channel
+      new_setting.redmine_slack_verify_ssl = setting.messenger_verify_ssl
+      new_setting.auto_mentions = setting.auto_mentions
+      new_setting.default_mentions = setting.default_mentions
+      new_setting.post_updates = setting.post_updates
+      new_setting.new_include_description = setting.new_include_description
+      new_setting.updated_include_description = setting.updated_include_description
+      new_setting.post_private_issues = setting.post_private_issues
+      new_setting.post_private_notes = setting.post_private_notes
+      new_setting.post_wiki = setting.post_wiki
+      new_setting.post_wiki_updates = setting.post_wiki_updates
+
+      if ActiveRecord::Base.connection.column_exists?(:messenger_settings, :text_trim_size)
+        new_setting.text_trim_size = setting.text_trim_size
+      end
+      if ActiveRecord::Base.connection.column_exists?(:messenger_settings, :supress_empty_messages)
+        new_setting.supress_empty_messages = setting.supress_empty_messages
+      end
+      new_setting.save!
+    end
+  end
+end
diff --git a/db/migrate/20200702092644_add_color_settings.rb b/db/migrate/20200702092644_add_color_settings.rb
new file mode 100644 (file)
index 0000000..d4a22af
--- /dev/null
@@ -0,0 +1,8 @@
+# Add color settings.
+class AddColorSettings < ActiveRecord::Migration[5.2]
+  def change
+    add_column :redmine_slack_settings, :color_create_notifications, :string, default: nil
+    add_column :redmine_slack_settings, :color_update_notifications, :string, default: nil
+    add_column :redmine_slack_settings, :color_close_notifications, :string, default: nil
+  end
+end
diff --git a/db/migrate/20200723160847_create_redmine_slack_notifications.rb b/db/migrate/20200723160847_create_redmine_slack_notifications.rb
new file mode 100644 (file)
index 0000000..24a82e5
--- /dev/null
@@ -0,0 +1,12 @@
+# Create Redmine Slack notifications table.
+class CreateRedmineSlackNotifications < ActiveRecord::Migration[5.2]
+  def change
+    create_table :redmine_slack_notifications do |t|
+      t.integer :timestamp
+      t.string :entity
+      t.integer :entity_id
+      t.string :slack_channel_id
+      t.string :slack_message_id
+    end
+  end
+end
diff --git a/db/migrate/20200727112644_add_update_threshold.rb b/db/migrate/20200727112644_add_update_threshold.rb
new file mode 100644 (file)
index 0000000..96e79f1
--- /dev/null
@@ -0,0 +1,6 @@
+# Add update threshold.
+class AddUpdateThreshold < ActiveRecord::Migration[5.2]
+  def change
+    add_column :redmine_slack_settings, :update_notification_threshold, :integer, default: nil
+  end
+end
diff --git a/db/migrate/20200818072644_add_replies_threshold.rb b/db/migrate/20200818072644_add_replies_threshold.rb
new file mode 100644 (file)
index 0000000..949bbd6
--- /dev/null
@@ -0,0 +1,6 @@
+# Add replies threshold.
+class AddRepliesThreshold < ActiveRecord::Migration[5.2]
+  def change
+    add_column :redmine_slack_settings, :replies_threshold, :integer, default: nil
+  end
+end
diff --git a/docs/images/signing-secret.png b/docs/images/signing-secret.png
new file mode 100644 (file)
index 0000000..ff859ec
Binary files /dev/null and b/docs/images/signing-secret.png differ
diff --git a/docs/images/slack-token.png b/docs/images/slack-token.png
new file mode 100644 (file)
index 0000000..561aa87
Binary files /dev/null and b/docs/images/slack-token.png differ
diff --git a/docs/images/slash-command.png b/docs/images/slash-command.png
new file mode 100644 (file)
index 0000000..a9481af
Binary files /dev/null and b/docs/images/slash-command.png differ
diff --git a/init.rb b/init.rb
new file mode 100644 (file)
index 0000000..1b8863e
--- /dev/null
+++ b/init.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+if RUBY_VERSION < '2.3'
+  raise "\n\033[31mredmine_redmine_slack requires ruby 2.3 or newer. Please update your ruby version.\033[0m"
+end
+
+require 'redmine'
+require 'redmine_slack'
+
+Redmine::Plugin.register :redmine_slack do
+  name 'Redmine Slack plugin'
+  author 'Evolving Web'
+  description 'Plugin to send notifications of Redmine updates to Slack channels.'
+  version '0.0.1'
+  url 'https://github.com/evolvingweb/redmine_slack'
+  author_url 'https://evolvingweb.ca'
+
+  requires_redmine version_or_higher: '4.0.0'
+
+  permission :manage_redmine_slack, projects: :settings, redmine_slack_settings: :update
+
+  settings default: {
+    redmine_slack_token: '',
+    redmine_slack_signing_secret: '',
+    redmine_slack_channel: 'redmine',
+    redmine_slack_verify_ssl: '1',
+    auto_mentions: '0',
+    default_mentions: '',
+    post_updates: '1',
+    new_include_description: '1',
+    updated_include_description: '1',
+    text_trim_size: '0',
+    supress_empty_messages: '1',
+    post_private_issues: '0',
+    post_private_notes: '0',
+    post_wiki: '0',
+    post_wiki_updates: '0'
+  }, partial: 'settings/redmine_slack_settings'
+end
diff --git a/lib/redmine_slack.rb b/lib/redmine_slack.rb
new file mode 100644 (file)
index 0000000..7f51383
--- /dev/null
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+Rails.configuration.to_prepare do
+  # Redmine Slack module.
+  module RedmineSlack
+    def self.settings
+      if Setting[:plugin_redmine_slack].class == Hash
+        if Rails.version >= '5.2'
+          # convert Rails 4 data
+          new_settings = ActiveSupport::HashWithIndifferentAccess.new(Setting[:plugin_redmine_slack])
+          Setting.plugin_redmine_slack = new_settings
+          new_settings
+        else
+          ActionController::Parameters.new(Setting[:plugin_redmine_slack])
+        end
+      else
+        # Rails 5 uses ActiveSupport::HashWithIndifferentAccess
+        Setting[:plugin_redmine_slack]
+      end
+    end
+
+    def self.setting?(value)
+      return true if settings[value].to_i == 1
+
+      false
+    end
+  end
+
+  # Patches
+  Issue.include RedmineSlack::Patches::IssuePatch
+  WikiContent.include RedmineSlack::Patches::WikiContentPatch
+  ProjectsController.send :helper, RedmineSlackProjectsHelper
+
+  # Global helpers
+  ActionView::Base.include RedmineSlack::Helpers
+
+  # Hooks
+  require_dependency 'redmine_slack/hooks'
+end
diff --git a/lib/redmine_slack/diffy.rb b/lib/redmine_slack/diffy.rb
new file mode 100644 (file)
index 0000000..4e380f4
--- /dev/null
@@ -0,0 +1,168 @@
+# frozen_string_literal: true
+
+require 'open3'
+require 'tempfile'
+
+# Redmine Slack module.
+module RedmineSlack
+  # Adapted from https://github.com/samg/diffy/blob/master/lib/diffy/diff.rb
+  class Diffy
+    ORIGINAL_DEFAULT_OPTIONS = {
+      :diff => '-U10000',
+      :source => 'strings',
+      :include_diff_info => false,
+      :include_plus_and_minus_in_html => false,
+      :context => nil,
+      :allow_empty_diff => true
+    }.freeze
+
+    class << self
+      attr_writer :default_format
+      def default_format
+        @default_format ||= :text
+      end
+
+      # default options passed to new Diff objects
+      attr_writer :default_options
+      def default_options
+        @default_options ||= ORIGINAL_DEFAULT_OPTIONS.dup
+      end
+    end
+    include Enumerable
+    attr_reader :string1, :string2, :options
+
+    # supported options
+    # +:diff+::    A cli options string passed to diff
+    # +:source+::  Either _strings_ or _files_.  Determines whether string1
+    #              and string2 should be interpreted as strings or file paths.
+    # +:include_diff_info+::    Include diff header info
+    # +:include_plus_and_minus_in_html+::    Show the +, -, ' ' at the
+    #                                        beginning of lines in html output.
+    def initialize(string1, string2, options = {})
+      @options = self.class.default_options.merge(options)
+      unless %w[strings files].include?(@options[:source])
+        raise(
+          ArgumentError,
+          "Invalid :source option #{@options[:source].inspect}. Supported options are 'strings' and 'files'."
+        )
+      end
+
+      @string1 = string1
+      @string2 = string2
+    end
+
+    def diff
+      @diff ||= begin
+        @paths = case options[:source]
+                 when 'strings'
+                   [tempfile(string1), tempfile(string2)]
+                 when 'files'
+                   [string1, string2]
+                 end
+
+        diff = Open3.popen3(diff_bin, *(diff_options + @paths)) {|_i, o, _e| o.read}
+        diff.force_encoding('ASCII-8BIT') if diff.respond_to?(:valid_encoding?) && !diff.valid_encoding?
+        if diff =~ /\A\s*\Z/ && !options[:allow_empty_diff]
+          diff = case options[:source]
+                 when 'strings' then string1
+                 when 'files' then File.read(string1)
+                 end.gsub(/^/, ' ')
+        end
+        diff
+      end
+    ensure
+      # unlink the tempfiles explicitly now that the diff is generated
+      if defined? @tempfiles # to avoid Ruby warnings about undefined ins var.
+        Array(@tempfiles).each do |t|
+          # check that the path is not nil and file still exists.
+          # REE seems to be very agressive with when it magically removes
+          # tempfiles
+          t.unlink if t.path && File.exist?(t.path)
+        rescue StandardError => e
+          warn "#{e.class}: #{e}"
+          warn e.backtrace.join("\n")
+        end
+      end
+    end
+
+    def each
+      lines = case @options[:include_diff_info]
+              when false
+                # this "primes" the diff and sets up the paths we'll reference below.
+                diff
+
+                # caching this regexp improves the performance of the loop by a
+                # considerable amount.
+                regexp = /^(--- "?#{@paths[0]}"?|\+\+\+ "?#{@paths[1]}"?|@@|\\\\)/
+
+                diff.split("\n").reject{|x| x =~ regexp}.map {|line| line + "\n"}
+
+              when true
+                diff.split("\n").map {|line| line + "\n"}
+              end
+
+      if block_given?
+        lines.each{|line| yield line}
+      else
+        lines.to_enum
+      end
+    end
+
+    def each_chunk
+      old_state = nil
+      chunks = each_with_object([]) do |line, cc|
+        state = line.each_char.first
+        if state == old_state
+          cc.last << line
+        else
+          cc.push line.dup
+        end
+        old_state = state
+      end
+
+      if block_given?
+        chunks.each{|chunk| yield chunk}
+      else
+        chunks.to_enum
+      end
+    end
+
+    def tempfile(string)
+      t = Tempfile.new('diffy', :encoding => 'ascii-8bit')
+      # ensure tempfiles aren't unlinked when GC runs by maintaining a
+      # reference to them.
+      @tempfiles ||= []
+      @tempfiles.push(t)
+      t.print(string)
+      t.flush
+      t.close
+      t.path
+    end
+
+    private
+
+    @@bin = nil
+    def diff_bin
+      return @@bin if @@bin
+
+      if (@@bin = ENV['DIFFY_DIFF'])
+        # system() trick from Minitest
+        raise "Can't execute diff program '#{@@bin}'" unless system(@@bin, __FILE__, __FILE__)
+
+        return @@bin
+      end
+
+      diffs = %w[diff ldiff]
+      @@bin = diffs.find {|name| system(name, __FILE__, __FILE__)}
+
+      raise "Can't find a diff executable in PATH #{ENV['PATH']}" if @@bin.nil?
+
+      @@bin
+    end
+
+    # options pass to diff program
+    def diff_options
+      Array(options[:context] ? "-U #{options[:context]}" : options[:diff])
+    end
+  end
+end
diff --git a/lib/redmine_slack/helpers.rb b/lib/redmine_slack/helpers.rb
new file mode 100644 (file)
index 0000000..de3a391
--- /dev/null
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+# Redmine slack module.
+module RedmineSlack
+  # Helpers module.
+  module Helpers
+    def project_redmine_slack_options(active)
+      options_for_select({l(:label_redmine_slack_settings_default) => '0',
+                          l(:label_redmine_slack_settings_disabled) => '1',
+                          l(:label_redmine_slack_settings_enabled) => '2'}, active)
+    end
+
+    def project_setting_redmine_slack_default_value(value)
+      if Slack.default_project_setting(@project, value)
+        l(:label_redmine_slack_settings_enabled)
+      else
+        l(:label_redmine_slack_settings_disabled)
+      end
+    end
+  end
+end
diff --git a/lib/redmine_slack/hooks.rb b/lib/redmine_slack/hooks.rb
new file mode 100644 (file)
index 0000000..fee7f58
--- /dev/null
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+# Redmine Slack module.
+module RedmineSlack
+  # Add some listeners.
+  class RedmineSlackListener < Redmine::Hook::Listener
+    def model_changeset_scan_commit_for_issue_ids_pre_issue_update(context = {})
+      issue = context[:issue]
+      journal = issue.current_journal
+      changeset = context[:changeset]
+
+      channels = Slack.channels_for_project issue.project
+
+      return unless channels.present? && issue.changes.any? && Slack.setting_for_project(issue.project, :post_updates)
+      return if issue.is_private? && !Slack.setting_for_project(issue.project, :post_private_issues)
+
+      msg = "[#{ERB::Util.html_escape(issue.project)}] \
+            #{ERB::Util.html_escape(journal.user.to_s)} \
+            updated <#{Slack.object_url issue}|#{ERB::Util.html_escape(issue)}>"
+
+      repository = changeset.repository
+
+      if Setting.host_name.to_s =~ %r{/\A(https?\:\/\/)?(.+?)(\:(\d+))?(\/.+)?\z/i}
+        host = Regexp.last_match(2)
+        port = Regexp.last_match(4)
+        prefix = Regexp.last_match(5)
+        revision_url = Rails.application.routes.url_for(
+          controller: 'repositories',
+          action: 'revision',
+          id: repository.project,
+          repository_id: repository.identifier_param,
+          rev: changeset.revision,
+          host: host,
+          protocol: Setting.protocol,
+          port: port,
+          script_name: prefix
+        )
+      else
+        revision_url = Rails.application.routes.url_for(
+          controller: 'repositories',
+          action: 'revision',
+          id: repository.project,
+          repository_id: repository.identifier_param,
+          rev: changeset.revision,
+          host: Setting.host_name,
+          protocol: Setting.protocol
+        )
+      end
+
+      attachment = {}
+      attachment[:text] = ll(
+        Setting.default_language,
+        :text_status_changed_by_changeset,
+        "<#{revision_url}|#{ERB::Util.html_escape(changeset.comments)}>"
+      )
+      attachment[:fields] = journal.details.map {|d| Slack.detail_to_field d}
+
+      Slack.speak(msg, channels, attachment: attachment, project: repository.project)
+    end
+  end
+end
diff --git a/lib/redmine_slack/patches/issue_patch.rb b/lib/redmine_slack/patches/issue_patch.rb
new file mode 100644 (file)
index 0000000..d31b7aa
--- /dev/null
@@ -0,0 +1,214 @@
+# frozen_string_literal: true
+
+# Redmine Slack module to add patches.
+module RedmineSlack
+  # Patches module.
+  module Patches
+    # Issue Patches.
+    module IssuePatch
+      def self.included(base)
+        base.send(:include, InstanceMethods)
+        base.class_eval do
+          after_create :send_redmine_slack_create
+          after_commit :send_redmine_slack_update, :on => :update
+        end
+      end
+
+      # Instance Methods.
+      module InstanceMethods
+        def send_redmine_slack_create
+          channels = Slack.channels_for_project project
+
+          return if channels.blank?
+          return if is_private? && !Slack.setting_for_project(project, :post_private_issues)
+          return if RequestStore.store[:redmine_slack_silent].nil?
+
+          set_language_if_valid Setting.default_language
+
+          attachment = {}
+          if description.present? && Slack.setting_for_project(project, :new_include_description)
+            attachment[:text] = Slack.markup_format(Slack.trim(description, project))
+          end
+          attachment[:fields] = [{title: I18n.t(:field_status),
+                                  value: ERB::Util.html_escape(status.to_s),
+                                  short: true},
+                                 {title: I18n.t(:field_priority),
+                                  value: ERB::Util.html_escape(priority.to_s),
+                                  short: true}]
+          if assigned_to.present?
+            attachment[:fields] << {title: I18n.t(:field_assigned_to),
+                                    value: ERB::Util.html_escape(assigned_to.to_s),
+                                    short: true}
+          end
+
+          return unless attachment.any? && attachment.key?(:text)
+
+          attachment[:color] = Slack.textfield_for_project(project, :color_create_notifications)
+
+          notification = RedmineSlackNotification.find_or_create_within_timeframe(
+            'issue',
+            id,
+            Slack.textfield_for_project(project, :update_notification_threshold)
+          )
+
+          Slack.speak(
+            l(
+              :label_redmine_slack_issue_created,
+              project_url: "<#{Slack.object_url project}|#{ERB::Util.html_escape(project)}>",
+              url: send_redmine_slack_mention_url(project, description),
+              user: author
+            ),
+            channels,
+            {attachment: attachment, project: project},
+            notification
+          )
+        end
+
+        def send_redmine_slack_update
+          return if current_journal.nil?
+          return if RequestStore.store[:redmine_slack_silent].nil?
+
+          channels = Slack.channels_for_project project
+
+          return unless channels.present? && Slack.setting_for_project(project, :post_updates)
+          return if is_private? && !Slack.setting_for_project(project, :post_private_issues)
+          return if current_journal.private_notes? && !Slack.setting_for_project(project, :post_private_notes)
+
+          set_language_if_valid Setting.default_language
+
+          attachment = {}
+          text_diff = {}
+
+          notification_type = 'issue'
+
+          if current_journal.notes.present? && Slack.setting_for_project(project, :updated_include_description)
+            attachment[:text] = Slack.markup_format(Slack.trim(current_journal.notes, project))
+            notification_type = 'issue-note'
+          end
+
+          current_journal.details.each do |detail|
+            unless detail &&
+                   detail.prop_key == 'description' &&
+                   detail.value.present? &&
+                   detail.old_value.present? &&
+                   Slack.setting_for_project(project, :updated_include_description)
+              next
+            end
+
+            diff = Diffy.new(detail.old_value, detail.value, :context => 1)
+            diff_elements = []
+            diff.each_with_index do |item, _index|
+              item_stripped = item.strip.delete("\r").gsub("\r\n", '')
+              if item_stripped.length
+                if item[0] == '-'
+                  diff_elements << "~#{item_stripped[1..-1]}~" if item_stripped[1..-1].length > 1
+                elsif item[0] == '+'
+                  diff_elements << "_#{item_stripped[1..-1]}_" if item_stripped[1..-1].length > 1
+                else
+                  diff_elements << item_stripped unless item_stripped.include? 'No newline at end of file'
+                end
+              end
+            end
+            text_diff = {
+              title: 'Description Differences',
+              value: Slack.trim(diff_elements.to_a.join("\r\n"), project),
+              short: false
+            }
+            # Finally, delete description from details to avoid including it as a field.
+            current_journal.details.delete(detail)
+          end
+
+          fields = current_journal.details.map {|d| Slack.detail_to_field d, project}
+          if status_id != status_id_was
+            fields << {title: I18n.t(:field_status),
+                       value: ERB::Util.html_escape(status.to_s),
+                       short: true}
+          end
+          if priority_id != priority_id_was
+            fields << {title: I18n.t(:field_priority),
+                       value: ERB::Util.html_escape(priority.to_s),
+                       short: true}
+          end
+          if assigned_to.present?
+            fields << {title: I18n.t(:field_assigned_to),
+                       value: ERB::Util.html_escape(assigned_to.to_s),
+                       short: true}
+          end
+
+          fields << text_diff if text_diff.any?
+
+          attachment[:fields] = fields if fields.any?
+
+          send_message = true
+          if Slack.setting_for_project(project, :supress_empty_messages)
+            send_message = false unless (attachment.any? && attachment.key?(:text)) || !text_diff.empty?
+          end
+
+          return unless send_message
+
+          attachment[:color] = Slack.textfield_for_project(project, :color_update_notifications)
+
+          if closed?
+            attachment[:color] = Slack.textfield_for_project(project, :color_close_notifications)
+          end
+
+          notification = RedmineSlackNotification.find_or_create_within_timeframe(
+            notification_type,
+            id,
+            Slack.textfield_for_project(project, :update_notification_threshold)
+          )
+          if notification_type == 'issue' && !notification.slack_message_id.nil?
+            Slack.update_message(
+              l(
+                :label_redmine_slack_issue_updated,
+                project_url: "<#{Slack.object_url project}|#{ERB::Util.html_escape(project)}>",
+                url: send_redmine_slack_mention_url(project, current_journal.notes),
+                user: current_journal.user
+              ),
+              notification.slack_channel_id,
+              {attachment: attachment, project: project},
+              notification
+            )
+          else
+            Slack.speak(
+              l(
+                :label_redmine_slack_issue_updated,
+                project_url: "<#{Slack.object_url project}|#{ERB::Util.html_escape(project)}>",
+                url: send_redmine_slack_mention_url(project, current_journal.notes),
+                user: current_journal.user
+              ),
+              channels,
+              {attachment: attachment, project: project},
+              notification
+            )
+          end
+        end
+
+        private
+
+        def send_redmine_slack_mention_url(project, text)
+          mention_to = ''
+          if Slack.setting_for_project(project, :auto_mentions) ||
+             Slack.textfield_for_project(project, :default_mentions).present?
+            mention_to = Slack.mentions(project, text)
+          end
+          "<#{Slack.object_url(self)}|#{self}>#{mention_to}"
+        end
+      end
+    end
+
+    # Issues hook.
+    class IssuesHook < Redmine::Hook::ViewListener
+      # Add journal with edit issue
+      def view_issues_form_details_bottom(context = {})
+        return unless context[:issue].id.nil?
+
+        context[:controller].send(
+          :render_to_string,
+          partial: 'redmine_slack/issues_silent_updates',
+          locals: {}
+        )
+      end
+    end
+  end
+end
diff --git a/lib/redmine_slack/patches/issues_controller_patch.rb b/lib/redmine_slack/patches/issues_controller_patch.rb
new file mode 100644 (file)
index 0000000..4af1939
--- /dev/null
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+# Redmine Slack module to add patches.
+module RedmineSlack
+  # Patches module.
+  module Patches
+    # Issue Patches.
+    module IssuesControllerPatch
+      def self.included(base)
+        base.send(:include, InstanceMethods)
+        base.class_eval do
+          before_action :handle_silent_update, :only => [:create, :update]
+        end
+      end
+
+      # Instance Methods.
+      module InstanceMethods
+        def handle_silent_update
+          RequestStore.store[:redmine_slack_silent] =
+            params[:redmine_issue_slack_silent] || params[:redmine_journal_slack_silent]
+        end
+      end
+    end
+  end
+end
+
+# Add module to Welcome Controller
+IssuesController.send(:include, RedmineSlack::Patches::IssuesControllerPatch)
+IssuesController.prepend RedmineSlack::Patches::IssuesControllerPatch
diff --git a/lib/redmine_slack/patches/journals_patch.rb b/lib/redmine_slack/patches/journals_patch.rb
new file mode 100644 (file)
index 0000000..6aee3d3
--- /dev/null
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+# To change this template, choose Tools | Templates
+# and open the template in the editor.
+module RedmineSlack
+  module Patches
+    # Journals hook.
+    class JournalsHook < Redmine::Hook::ViewListener
+      # Add journal with edit issue
+      def view_issues_edit_notes_bottom(context = {})
+        context[:controller].send(
+          :render_to_string,
+          partial: 'redmine_slack/journal_silent_updates',
+          locals: {}
+        )
+      end
+    end
+  end
+end
diff --git a/lib/redmine_slack/patches/wiki_content_patch.rb b/lib/redmine_slack/patches/wiki_content_patch.rb
new file mode 100644 (file)
index 0000000..e11cbbc
--- /dev/null
@@ -0,0 +1,161 @@
+# frozen_string_literal: true
+
+# Redmine Slack module to add patches.
+module RedmineSlack
+  # Patches module.
+  module Patches
+    # Patches for wiki content.
+    module WikiContentPatch
+      def self.included(base)
+        base.send(:include, InstanceMethods)
+        base.class_eval do
+          after_create :send_redmine_slack_create
+          after_commit :send_redmine_slack_update, :on => :update
+        end
+      end
+
+      # Instance methods.
+      module InstanceMethods
+        def send_redmine_slack_create
+          return unless Slack.setting_for_project(project, :post_wiki)
+
+          set_language_if_valid Setting.default_language
+
+          channels = Slack.channels_for_project project
+
+          return if channels.blank?
+
+          attachment = {}
+          attachment[:fields] = []
+          attachment[:fields] << {
+            title: 'Content',
+            value: Slack.trim(text, project),
+            short: false
+          }
+
+          attachment[:color] = Slack.textfield_for_project(project, :color_create_notifications)
+
+          notification = RedmineSlackNotification.find_or_create_within_timeframe(
+            'wiki-content',
+            id,
+            Slack.textfield_for_project(project, :update_notification_threshold)
+          )
+
+          Slack.speak(
+            l(
+              :label_redmine_slack_wiki_created,
+              project_url: "<#{Slack.object_url project}|#{ERB::Util.html_escape(project)}>",
+              url: "<#{Rails.application.routes.url_for(
+                :controller => 'wiki',
+                :action => 'show',
+                :project_id => project,
+                :id => page.title,
+                :host => Setting.host_name
+              )}|#{page.title}>",
+              user: User.current
+            ),
+            channels,
+            {project: project, attachment: attachment},
+            notification
+          )
+        end
+
+        def send_redmine_slack_update
+          return unless Slack.setting_for_project(project, :post_wiki_updates)
+
+          set_language_if_valid Setting.default_language
+
+          channels = Slack.channels_for_project project
+
+          return if channels.blank?
+
+          attachment = nil
+          if comments.present?
+            attachment = {}
+            attachment[:text] = Slack.markup_format(comments.to_s)
+          end
+
+          version_to = version
+
+          content_to = versions.find_by(version: version_to)
+          content_from = content_to.try(:previous)
+          if content_to && content_from
+            diff = Diffy.new(content_from.data, content_to.data, :context => 1)
+            diff_elements = []
+            diff.each_with_index do |item, _index|
+              item_stripped = item.strip.delete("\r").gsub("\r\n", '')
+              if item_stripped.length
+                if item[0] == '-'
+                  diff_elements << "~#{item_stripped[1..-1]}~" if item_stripped[1..-1].length > 1
+                elsif item[0] == '+'
+                  diff_elements << "_#{item_stripped[1..-1]}_" if item_stripped[1..-1].length > 1
+                else
+                  diff_elements << item_stripped unless item_stripped.include? 'No newline at end of file'
+                end
+              end
+            end
+            attachment = {} if attachment.nil?
+            attachment[:fields] = []
+            attachment[:fields] << {
+              title: 'Content Differences',
+              value: diff_elements.to_a.join("\r\n"),
+              short: false
+            }
+          end
+
+          send_message = true
+          if Slack.setting_for_project(project, :supress_empty_messages)
+            send_message = false unless attachment.any? && (attachment.key?(:text) || attachment.key?(:fields))
+          end
+
+          return unless send_message
+
+          attachment[:color] = Slack.textfield_for_project(project, :color_update_notifications)
+
+          notification = RedmineSlackNotification.find_or_create_within_timeframe(
+            'wiki-content',
+            id,
+            Slack.textfield_for_project(project, :update_notification_threshold)
+          )
+          if !notification.slack_message_id.nil?
+            Slack.update_message(
+              l(
+                :label_redmine_slack_wiki_updated,
+                project_url: "<#{Slack.object_url project}|#{ERB::Util.html_escape(project)}>",
+                url: "<#{Rails.application.routes.url_for(
+                  :controller => 'wiki',
+                  :action => 'show',
+                  :project_id => project,
+                  :id => page.title,
+                  :host => Setting.host_name
+                )}|#{page.title}>",
+                user: User.current
+              ),
+              notification.slack_channel_id,
+              {project: project, attachment: attachment},
+              notification
+            )
+          else
+            Slack.speak(
+              l(
+                :label_redmine_slack_wiki_updated,
+                project_url: "<#{Slack.object_url project}|#{ERB::Util.html_escape(project)}>",
+                url: "<#{Rails.application.routes.url_for(
+                  :controller => 'wiki',
+                  :action => 'show',
+                  :project_id => project,
+                  :id => page.title,
+                  :host => Setting.host_name
+                )}|#{page.title}>",
+                user: User.current
+              ),
+              channels,
+              {project: project, attachment: attachment},
+              notification
+            )
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/test/integration/routing_test.rb b/test/integration/routing_test.rb
new file mode 100644 (file)
index 0000000..8e657fa
--- /dev/null
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require File.expand_path('../../test_helper', __FILE__)
+
+# Test Routing class.
+class RoutingTest < Redmine::RoutingTest
+  test 'routing redmine_slack' do
+    should_route 'GET /projects/1/settings/redmine_slack' => 'projects#settings', :id => '1', :tab => 'redmine_slack'
+    should_route 'PUT /projects/1/redmine_slack_setting' => 'redmine_slack_settings#update', :project_id => '1'
+  end
+end
diff --git a/test/support/database-postgresql-travis.yml b/test/support/database-postgresql-travis.yml
new file mode 100644 (file)
index 0000000..a118b62
--- /dev/null
@@ -0,0 +1,8 @@
+test:
+  adapter: postgresql
+  encoding: unicode
+  pool: 5
+  database: travis_ci_test
+  user: postgres
+
+
diff --git a/test/test_helper.rb b/test/test_helper.rb
new file mode 100644 (file)
index 0000000..d3d98c1
--- /dev/null
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+$VERBOSE = nil
+
+unless ENV['SKIP_COVERAGE']
+  require 'simplecov'
+  require 'simplecov-rcov'
+
+  SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter[
+    SimpleCov::Formatter::HTMLFormatter,
+    SimpleCov::Formatter::RcovFormatter
+  ]
+
+  SimpleCov.start :rails do
+    add_filter 'init.rb'
+    root File.expand_path(File.dirname(__FILE__) + '/..')
+  end
+end
+
+require File.expand_path(File.dirname(__FILE__) + '/../../../test/test_helper')
+
+# Redmine slack test helper module.
+module RedmineSlack
+  # Test case class.
+  class TestCase
+    include ActionDispatch::TestProcess
+
+    def self.prepare
+      Role.where(id: [1, 2]).each do |r|
+        r.permissions << :view_issues
+        r.save
+      end
+
+      Project.where(id: [1, 2]).each do |project|
+        EnabledModule.create(project: project, name: 'issue_tracking')
+      end
+    end
+  end
+end
diff --git a/test/unit/i18n_test.rb b/test/unit/i18n_test.rb
new file mode 100644 (file)
index 0000000..fdd0ec5
--- /dev/null
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+require File.expand_path('../../test_helper', __FILE__)
+
+# Test internationalization.
+class I18nTest < ActiveSupport::TestCase
+  include Redmine::I18n
+
+  def setup
+    User.current = nil
+  end
+
+  def teardown
+    set_language_if_valid 'en'
+  end
+
+  def test_valid_languages
+    assert valid_languages.is_a?(Array)
+    assert valid_languages.first.is_a?(Symbol)
+  end
+
+  def test_locales_validness
+    lang_files_count = Dir[Rails.root.join('plugins',
+                                           'redmine_slack',
+                                           'config',
+                                           'locales',
+                                           '*.yml')].size
+    assert_equal lang_files_count, 2
+    valid_languages.each do |lang|
+      assert set_language_if_valid(lang)
+    end
+    # check if parse error exists
+    ::I18n.locale = 'fr'
+    assert_equal 'Paramètres de Redmine Slack', l(:label_redmine_slack_setting)
+    ::I18n.locale = 'en'
+    assert_equal 'Redmine Slack Settings', l(:label_redmine_slack_setting)
+    set_language_if_valid('en')
+  end
+end
diff --git a/test/unit/issue_test.rb b/test/unit/issue_test.rb
new file mode 100644 (file)
index 0000000..ba38085
--- /dev/null
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require File.expand_path('../../test_helper', __FILE__)
+
+# Issues basic test.
+class IssueTest < ActiveSupport::TestCase
+  fixtures :projects, :users, :members, :member_roles, :roles,
+           :trackers, :projects_trackers,
+           :enabled_modules,
+           :issue_statuses, :issue_categories, :workflows,
+           :enumerations,
+           :issues, :journals, :journal_details,
+           :custom_fields, :custom_fields_projects, :custom_fields_trackers, :custom_values,
+           :time_entries
+
+  include Redmine::I18n
+
+  def setup
+    set_language_if_valid 'en'
+  end
+
+  def teardown
+    User.current = nil
+  end
+
+  def test_create
+    issue = Issue.new(project_id: 1, tracker_id: 1, author_id: 3, subject: 'test_create')
+    assert issue.save
+    assert_equal issue.tracker.default_status, issue.status
+    assert issue.description.nil?
+  end
+end
diff --git a/test/unit/project_test.rb b/test/unit/project_test.rb
new file mode 100644 (file)
index 0000000..691014e
--- /dev/null
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require File.expand_path('../../test_helper', __FILE__)
+
+# Projects basic test.
+class ProjectTest < ActiveSupport::TestCase
+  fixtures :projects, :trackers, :issue_statuses, :issues,
+           :journals, :journal_details,
+           :enumerations, :users, :issue_categories,
+           :projects_trackers,
+           :custom_fields,
+           :custom_fields_projects,
+           :custom_fields_trackers,
+           :custom_values,
+           :roles,
+           :member_roles,
+           :members,
+           :enabled_modules,
+           :versions,
+           :wikis, :wiki_pages, :wiki_contents, :wiki_content_versions,
+           :groups_users,
+           :time_entries,
+           :news, :comments,
+           :documents,
+           :workflows
+
+  def setup
+    User.current = User.find(1)
+  end
+
+  def test_create_project
+    Project.delete_all
+    Project.create!(name: 'Project Messenger', identifier: 'project-messenger')
+    assert_equal 1, Project.count
+  end
+
+  def test_load_project
+    Project.find(1)
+  end
+end