Skip to content
This repository was archived by the owner on Oct 18, 2023. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.sample
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
NOKO_ACCOUNT_HOST=ombulabs
NOKO_TOKEN=foobar
SLACK_OAUTH_TOKEN=a-really-long-string
SMTP_SERVER=smtp.sendgrid.net
SMTP_PORT=587
SMTP_DOMAIN=ombushop.com
Expand All @@ -8,4 +9,4 @@ SMTP_USER_PASSWORD=secret
COUNTRY_CODE="ar"
DATABASE_NAME="pecas"
BASIC_AUTH_NAME="user"
BASIC_AUTH_PASSWORD="secret"
BASIC_AUTH_PASSWORD="secret"
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ FROM ruby:2.5.8
# Install additional package i.e Yarn.
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list
RUN apt-get update && apt-get install -y yarn
RUN apt-get update && apt-get install -y yarn cron

# Use a directory called /code in which to store
# this application's files. (The directory name
Expand Down
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ gem 'coffee-rails'

gem 'dotenv-rails'
gem 'noko'
gem 'slack-ruby-client'

gem 'twitter-bootstrap-rails'

Expand Down
18 changes: 11 additions & 7 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ GEM
arel (8.0.0)
builder (3.2.4)
byebug (11.1.3)
coderay (1.1.3)
coffee-rails (4.2.2)
coffee-script (>= 2.2.0)
railties (>= 4.0.0)
Expand Down Expand Up @@ -100,10 +99,14 @@ GEM
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday_middleware (1.2.0)
faraday (~> 1.0)
ffi (1.15.4)
gli (2.21.0)
globalid (0.5.2)
activesupport (>= 5.0)
hashdiff (1.0.1)
hashie (5.0.0)
holidays (8.4.1)
htmlentities (4.3.4)
i18n (1.8.10)
Expand Down Expand Up @@ -146,12 +149,6 @@ GEM
mini_portile2 (~> 2.6.1)
racc (~> 1.4)
pg (1.2.3)
pry (0.13.1)
coderay (~> 1.1)
method_source (~> 1.0)
pry-byebug (3.9.0)
byebug (~> 11.0)
pry (~> 0.13.0)
public_suffix (4.0.6)
racc (1.5.2)
rack (2.2.3)
Expand Down Expand Up @@ -231,6 +228,12 @@ GEM
terminal-table
simplecov-html (0.12.3)
simplecov_json_formatter (0.1.3)
slack-ruby-client (1.0.0)
faraday (>= 1.0)
faraday_middleware
gli
hashie
websocket-driver
spring (3.0.0)
sprockets (3.7.2)
concurrent-ruby (~> 1.0)
Expand Down Expand Up @@ -296,6 +299,7 @@ DEPENDENCIES
sass-rails
simplecov
simplecov-console
slack-ruby-client
spring
sqlite3 (~> 1.3.6)
turbolinks
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,25 @@ You can fetch the token value from the Noko app, after logging in, you can find

The `BASIC_AUTH_NAME` and `BASIC_AUTH_PASSWORD` are already setup from the `.env.sample` file but you can change their values at any time, will be used for a basic http auth for the application.

## Slack Notifications

If you want to use Pecas to send Slack messages you'll also need to setup
`SLACK_OAUTH_TOKEN` in the `.env` file. This requires a `SLACK_OAUTH_TOKEN`
generated with the following scopes:

* chat:write
* usergroups:read
* users.profile:read
* users:read
* users:read.email

Once set up we can use the rake task `notify:send_noko_format_warning['<name of slack group to alert>']`.
This task is destined to be run once an hour (for best results - a few minutes
after the hour) as it will only notify users Slack reports as being in the
timezone currently within an hour of 8pm.

## Start

You can setup your `COUNTRY_CODE` environment variable with an ISO 3166 country code.
Otherwise the emails will be sent on holidays.

Expand Down
54 changes: 54 additions & 0 deletions app/domain/time_entry.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
class TimeEntry
attr_reader :grouped_entries

##
# @params [Object] messaging_service Required service ex: SlackService::GroupMemberMessaging
# @params [Object] rule_handler Required ruleset ex: TimeEntry::DescriptionRules
# @option [Date] today Required date to retrieve time entries for
# @option [Integer] hour_of_day Optional hour to run alerts on a 24 hour clock (13 = 1pm)
def initialize(today, messaging_service, rule_handler, opts)
@messaging_service = messaging_service
@rule_handler = rule_handler
@today = today
@hour_to_run = opts[:hour_of_day]
end
##
# Collects time entries for members of a given message service group and sends
# validation alerts if required
#
# @param [String] group_handle The id of a group from a messaging service: ex `ombuteam` on Slack
def invalid_time_entries_alert(group_handle)
set_messaging_service(group_handle)
set_grouped_entries

@grouped_entries.each { |email, entries| maybe_message(email, entries) }
:ok
end

private

def maybe_message(email, entries)
dirty_entries = entries.select { |entry|
!@rule_handler.new(entry).valid?
}

unless dirty_entries.empty?
@service.send_time_entry_format_warning(email, dirty_entries)
end
end

def set_grouped_entries
@grouped_entries = Entry
.for_users_by_email(emails_to_consider)
.where(date: @today)
.group_by(&:user_email)
end

def set_messaging_service(group_handle)
@service = @messaging_service.new(group_handle, @hour_to_run)
end

def emails_to_consider
@service.included_emails
end
end
40 changes: 40 additions & 0 deletions app/domain/time_entry/description_rules.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
class TimeEntry::DescriptionRules
JIRA_REGEX = /(?:\s|^)([A-Z]+-[0-9]+)(?=\s|$)/

def initialize(entry, ruleset = :internal_employee)
@description = entry.description
@ruleset = ruleset
end

def valid?
return internal_employee if @ruleset == :internal_employee
false
end

private

def has_word_count(length)
@description.split.size > length - 1
end

def has_calls_tag
@description.downcase.include?("#calls")
end

def has_url
@description.downcase.include?("http")
end

# Removes square brackets and commas from the string before match as the
# "official" Jira regex won't match unless the jira id is preceeded by
# a space
def has_jira_ticket
@description.gsub(/[\[\]\,\.]/, ' ').scan(JIRA_REGEX).any?
end

def internal_employee
min_word_count = 4

has_word_count(min_word_count) && (has_calls_tag || has_url || has_jira_ticket)
end
end
22 changes: 20 additions & 2 deletions app/models/entry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,27 @@ class Entry < ActiveRecord::Base
belongs_to :user
belongs_to :project

delegate :email, prefix: "user", to: :user

MINUTES_PER_HOUR = 60

scope :today, lambda { where(date: Date.today) }

def self.delete_older_than(date)
where('date < ?', date).delete_all
scope :for_users_by_email, ->(emails) { joins(:user).where('users.email IN (?)', emails).preload(:user) }

def length
hour_value = minutes / MINUTES_PER_HOUR
minute_value = minutes % MINUTES_PER_HOUR
[time_label("hour", hour_value), time_label("minute", minute_value)].compact.join(", ")
end

private

def time_label(label, value)
"#{value} #{label.pluralize(value)}" if value > 0
end

def self.delete_older_than(date)
where('date < ?', date).delete_all
end
end
120 changes: 120 additions & 0 deletions app/services/slack_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
##
# This Service serves as an interface for the Slack Web API
#
# @see https://github.com/slack-ruby/slack-ruby-client

class SlackService
##
# Returns the id of a usergroup
#
# @param [String] usergroup_handle The text after the `@` used to reference a
# group in slack - ex: ombuteam
# @param [Slack::Web::Client] client
# @return [[:ok, String]] if success
# @return [[:error, String]] if failure
# @see https://api.slack.com/methods/usergroups.list
def self.find_usergroup_id(usergroup_handle, client)
response = client.usergroups_list
return [:error, response["error"]] unless response["ok"]

usergroup = response.usergroups.find { |group| group["handle"] == usergroup_handle }
return [:error, "Usergroup handle not found"] if usergroup.nil?

[:ok, usergroup.id]
end

##
# Returns a list of all user ids in a given group
#
# @param [String] usergroup_id ID of a Slack group
# @param [Slack::Web::Client] client
# @return [[:ok, Array<String>]] if success
# @return [[:error, String]] if failure
# @see https://api.slack.com/methods/usergroups.users.list
def self.find_usergroup_user_ids(usergroup_id, client)
begin
response = client.usergroups_users_list({usergroup: usergroup_id})
[:ok, response["users"]]
rescue Slack::Web::Api::Errors::NoSuchSubteam
[:error, "Usergroup Not Found"]
end
end

##
# Returns a SlackUser
#
# @param [String] user_id ID of a Slack user
# @param [Slack::Web::Client] client
# @return [[:ok, SlackService::SlackUser]] if success
# @return [[:error, String]] if failure
# @see https://api.slack.com/methods/users.info
def self.find_slack_user(user_id, client)
begin
response = client.users_info({user: user_id})

user = SlackUser.new(
client: client,
id: response["user"]["id"],
name: response["user"]["name"],
first_name: response["user"]["profile"]["first_name"],
real_name: response["user"]["real_name"],
tz: response["user"]["tz"],
email: response["user"]["profile"]["email"]
)

[:ok, user]
rescue Slack::Web::Api::Errors::UserNotFound
[:error, "User Not Found"]
end
end

##
# Posts a message to a given channel
#
# The definition of "Channel" includes users in the case od DMs
#
# If `message_parts` is a simple string - it will be added as "Text"
#
# If `message_parts` is a Hash - should include `:text` but must include
# one of `text`, `attachments`, `blocks` - see attached documentation for
# more information
#
# @param [String] channel_id ID of a Slack user
# @param [Hash<>, String] message_parts
# @param [Slack::Web::Client] client
# @return [[:ok, String]] if success
# @return [[:error, String]] if failure
# @see https://api.slack.com/methods/chat.postMessage
# @see https://github.com/slack-ruby/slack-ruby-client/blob/master/lib/slack/web/api/endpoints/chat.rb
def self.send_message(channel_id, message_parts, client)
begin
client.chat_postMessage(cobble_message_params(channel_id, message_parts))

[:ok, "Message Sent"]
rescue Slack::Web::Api::Errors::ChannelNotFound
[:error, "Slack Channel Not Found"]
end
end

##
# Checks to ensure the keys related to a Slack message are correct and
# returns a hash formatted for `chat_postMessage/1`
def self.cobble_message_params(channel_id, message)
return {channel: channel_id, text: message} if message.kind_of?(String)

validate_message_keys(message)

message[:channel] = channel_id
message
end

def self.validate_message_keys(message)
contains_required_key = message.keys.inject(false) { |acc, key|
acc || [:text, :attachments, :blocks].any?(key)
}
raise MessageFormatError.new("Message missing") unless contains_required_key

extra_message_keys = message.keys - [:text, :attachments, :blocks]
raise MessageFormatError.new("Message format incorrect") unless extra_message_keys.empty?
end
end
Loading