standup_md 1.0.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,15 +10,7 @@ module StandupMD
10
10
  include Enumerable
11
11
 
12
12
  ##
13
- # Access to the class's configuration.
14
- #
15
- # @return [StandupMD::Config::EntryList]
16
- def self.config
17
- @config ||= StandupMD.config.entry_list
18
- end
19
-
20
- ##
21
- # Contruct a list. Can pass any amount of +StandupMD::Entry+ instances.
13
+ # Construct a list. Can pass any amount of +StandupMD::Entry+ instances.
22
14
  #
23
15
  # @param [Entry] entries
24
16
  #
@@ -26,7 +18,6 @@ module StandupMD
26
18
  def initialize(*entries)
27
19
  entries.each { |entry| validate_entry(entry) }
28
20
 
29
- @config = self.class.config
30
21
  @entries = entries
31
22
  end
32
23
 
@@ -35,7 +26,7 @@ module StandupMD
35
26
  #
36
27
  # @param [StandupMD::Entry] entry
37
28
  #
38
- # @return [Array]
29
+ # @return [StandupMD::EntryList]
39
30
  def <<(entry)
40
31
  validate_entry(entry)
41
32
 
@@ -100,9 +91,9 @@ module StandupMD
100
91
  #
101
92
  # @param [Date] end_date
102
93
  #
103
- # @return [Array]
94
+ # @return [StandupMD::EntryList]
104
95
  def filter!(start_date, end_date)
105
- @entries = filter(start_date, end_date)
96
+ @entries = filter(start_date, end_date).to_a
106
97
  self
107
98
  end
108
99
 
@@ -124,18 +115,10 @@ module StandupMD
124
115
  end.to_h
125
116
  end
126
117
 
127
- ##
128
- # The entry list as a json object.
129
- #
130
- # @return [String]
131
- def to_json
132
- to_h.to_json
133
- end
134
-
135
118
  # :section: Delegators
136
119
 
137
120
  ##
138
- # The following are forwarded to @entries, which is the underly array of
121
+ # The following are forwarded to @entries, which is the underlying array of
139
122
  # entries.
140
123
  #
141
124
  # +each+:: Iterate over each entry.
@@ -8,68 +8,59 @@ module StandupMD
8
8
  ##
9
9
  # Class for handling reading and writing standup files.
10
10
  class File
11
+ ##
12
+ # Raised when a standup file or directory is missing and creation is off.
13
+ class NotFoundError < StandardError; end
14
+
11
15
  class << self
12
16
  ##
13
17
  # Access to the class's configuration.
14
18
  #
15
- # @return [StandupMD::Config::EntryList]
19
+ # @return [StandupMD::Config::File]
16
20
  def config
17
- @config ||= StandupMD.config.file
21
+ StandupMD.config.file
18
22
  end
19
23
 
20
24
  ##
21
- # Convenience method for calling File.find(file_name).load
25
+ # Convenience method for calling File.find(file_name).load.
22
26
  #
23
27
  # @param [String] file_name
28
+ # @param [StandupMD::Config::File] config
24
29
  #
25
30
  # @return [StandupMD::File]
26
- def load(file_name)
27
- unless ::File.directory?(config.directory)
28
- raise "Dir #{config.directory} not found." unless config.create
29
-
30
- FileUtils.mkdir_p(config.directory)
31
- end
32
- new(file_name).load
31
+ def load(file_name, config: StandupMD.config.file)
32
+ new(file_name, config: config).load
33
33
  end
34
34
 
35
35
  ##
36
36
  # Find standup file in directory by file name.
37
37
  #
38
- # @param [String] File_naem
39
- def find(file_name)
40
- unless ::File.directory?(config.directory)
41
- raise "Dir #{config.directory} not found." unless config.create
42
-
43
- FileUtils.mkdir_p(config.directory)
44
- end
45
- file_path = ::File.join(config.directory, file_name)
46
- unless ::File.file?(file_path) || config.create
47
- raise "File #{file_name} not found."
48
- end
49
-
50
- new(file_name)
38
+ # @param [String] file_name
39
+ # @param [StandupMD::Config::File] config
40
+ #
41
+ # @return [StandupMD::File]
42
+ def find(file_name, config: StandupMD.config.file)
43
+ new(file_name, config: config)
51
44
  end
52
45
 
53
46
  ##
54
47
  # Find standup file in directory by Date object.
55
48
  #
56
49
  # @param [Date] date
57
- def find_by_date(date)
50
+ # @param [StandupMD::Config::File] config
51
+ #
52
+ # @return [StandupMD::File]
53
+ def find_by_date(date, config: StandupMD.config.file)
58
54
  raise ArgumentError, "Must be a Date object" unless date.is_a?(Date)
59
55
 
60
- unless ::File.directory?(config.directory)
61
- raise "Dir #{config.directory} not found." unless config.create
62
-
63
- FileUtils.mkdir_p(config.directory)
64
- end
65
- find(date.strftime(config.name_format))
56
+ find(date.strftime(config.name_format), config: config)
66
57
  end
67
58
  end
68
59
 
69
60
  ##
70
61
  # The list of entries in the file.
71
62
  #
72
- # @return [StandupMP::EntryList]
63
+ # @return [StandupMD::EntryList]
73
64
  attr_reader :entries
74
65
 
75
66
  ##
@@ -82,29 +73,20 @@ module StandupMD
82
73
  # Constructs the instance.
83
74
  #
84
75
  # @param [String] file_name
76
+ # @param [StandupMD::Config::File] config
85
77
  #
86
- # @return [StandupMP::File]
87
- def initialize(file_name)
88
- @config = self.class.config
78
+ # @return [StandupMD::File]
79
+ def initialize(file_name, config: StandupMD.config.file)
80
+ @config = config
89
81
  @parser = StandupMD::Parsers::Markdown.new(@config)
90
82
  if file_name.include?(::File::SEPARATOR)
91
83
  raise ArgumentError,
92
- "#{file_name} contains directory. Please use `StandupMD.config.file.directory=`"
93
- end
94
-
95
- unless ::File.directory?(@config.directory)
96
- raise "Dir #{@config.directory} not found." unless @config.create
97
-
98
- FileUtils.mkdir_p(@config.directory)
84
+ "#{file_name} contains directory. Configure the file directory separately."
99
85
  end
100
86
 
87
+ ensure_directory
101
88
  @name = ::File.expand_path(::File.join(@config.directory, file_name))
102
-
103
- unless ::File.file?(@name)
104
- raise "File #{@name} not found." unless @config.create
105
-
106
- FileUtils.touch(@name)
107
- end
89
+ ensure_file
108
90
 
109
91
  @new = ::File.zero?(@name)
110
92
  @loaded = false
@@ -137,28 +119,49 @@ module StandupMD
137
119
  ##
138
120
  # Loads the file's contents.
139
121
  #
140
- # @return [StandupMD::FileList]
122
+ # @return [StandupMD::File]
141
123
  def load
142
- raise "File #{name} does not exist." unless ::File.file?(name)
124
+ raise NotFoundError, "File #{name} does not exist." unless ::File.file?(name)
143
125
 
144
126
  @loaded = true
145
- @entries = @parser.read(name)
127
+ @entries = @parser.parse(::File.read(name))
146
128
  self
147
129
  end
148
130
 
149
131
  ##
150
- # Writes a new entry to the file if the first entry in the file isn't today.
151
- # This method is destructive; if a file for entries in the date range
152
- # already exists, it will be clobbered with the entries in the range.
132
+ # Writes entries to disk. This method is destructive; existing file contents
133
+ # are replaced by the rendered entries in the requested date range.
153
134
  #
154
135
  # @param [Hash] {start_date: Date, end_date: Date}
155
136
  #
156
137
  # @return [Boolean] true if successful
157
138
  def write(**dates)
139
+ raise ArgumentError, "No entries loaded for #{name}" if entries.nil? || entries.empty?
140
+
158
141
  sorted_entries = entries.sort
159
142
  start_date = dates.fetch(:start_date, sorted_entries.first.date)
160
143
  end_date = dates.fetch(:end_date, sorted_entries.last.date)
161
- @parser.write(name, sorted_entries, start_date: start_date, end_date: end_date)
144
+ ::File.write(
145
+ name,
146
+ @parser.render(sorted_entries, start_date: start_date, end_date: end_date)
147
+ )
148
+ true
149
+ end
150
+
151
+ private
152
+
153
+ def ensure_directory
154
+ return if ::File.directory?(@config.directory)
155
+ raise NotFoundError, "Dir #{@config.directory} not found." unless @config.create
156
+
157
+ FileUtils.mkdir_p(@config.directory)
158
+ end
159
+
160
+ def ensure_file
161
+ return if ::File.file?(@name)
162
+ raise NotFoundError, "File #{@name} not found." unless @config.create
163
+
164
+ FileUtils.touch(@name)
162
165
  end
163
166
  end
164
167
  end
@@ -5,7 +5,6 @@ require "standup_md/entry"
5
5
  require "standup_md/entry_list"
6
6
  require "standup_md/section"
7
7
  require "standup_md/task"
8
- require "standup_md/title"
9
8
 
10
9
  module StandupMD
11
10
  ##
@@ -14,6 +13,10 @@ module StandupMD
14
13
  ##
15
14
  # Parser and renderer for the markdown standup format.
16
15
  class Markdown
16
+ ##
17
+ # Raised when markdown cannot be parsed into standup entries.
18
+ class Error < StandardError; end
19
+
17
20
  ##
18
21
  # Access to file configuration.
19
22
  #
@@ -29,17 +32,17 @@ module StandupMD
29
32
  end
30
33
 
31
34
  ##
32
- # Reads entries from a markdown standup file.
35
+ # Parses entries from markdown text.
33
36
  #
34
- # @param [String] file_name
37
+ # @param [String] text
35
38
  #
36
39
  # @return [StandupMD::EntryList]
37
- def read(file_name)
40
+ def parse(text)
38
41
  entry_list = EntryList.new
39
42
  record = nil
40
43
  section = nil
41
44
 
42
- ::File.foreach(file_name) do |line|
45
+ text.to_s.each_line do |line|
43
46
  line.chomp!
44
47
  next if line.strip.empty?
45
48
 
@@ -59,42 +62,40 @@ module StandupMD
59
62
  entry_list << entry(record) if record
60
63
  entry_list.sort
61
64
  rescue => e
62
- raise "File malformation: #{e}"
65
+ raise Error, "Markdown malformation: #{e.message}"
63
66
  end
64
67
 
65
68
  ##
66
- # Writes entries to a markdown standup file.
69
+ # Renders entries as markdown text.
67
70
  #
68
- # @param [String] file_name
69
71
  # @param [StandupMD::EntryList] entries
70
72
  # @param [Date] start_date
71
73
  # @param [Date] end_date
72
74
  #
73
- # @return [Boolean]
74
- def write(file_name, entries, start_date:, end_date:)
75
- ::File.open(file_name, "w") do |f|
76
- entries.filter(start_date, end_date).sort_reverse.each do |entry|
77
- f.puts Title.new(entry.date).to_markdown
78
- config.sub_header_order.each do |attr|
79
- section = Section.new(attr, entry.public_send("#{attr}_tasks"))
80
- next if section.empty?
81
-
82
- f.puts section.to_markdown
83
- end
84
- f.puts
85
- end
86
- end
87
- true
75
+ # @return [String]
76
+ def render(entries, start_date:, end_date:)
77
+ entries.filter(start_date, end_date).sort_reverse.map do |entry|
78
+ render_entry(entry)
79
+ end.join
88
80
  end
89
81
 
90
82
  ##
91
- # Renders a task as a markdown list item.
83
+ # Renders a single entry as markdown text.
92
84
  #
93
- # @param [String, StandupMD::Task] task
85
+ # @param [StandupMD::Entry] entry
94
86
  #
95
87
  # @return [String]
96
- def task_line(task)
97
- build_task(task).to_markdown
88
+ def render_entry(entry)
89
+ lines = [entry_header(entry)]
90
+ config.sub_header_order.each do |type|
91
+ section = Section.new(type, entry.public_send("#{type}_tasks"))
92
+ next if section.empty?
93
+
94
+ lines << section_header(type)
95
+ section.tasks.each { |task| lines << task_line(task) }
96
+ end
97
+ lines << ""
98
+ lines.join("\n") + "\n"
98
99
  end
99
100
 
100
101
  private
@@ -123,11 +124,19 @@ module StandupMD
123
124
  Section.new(type)
124
125
  end
125
126
 
127
+ def entry_header(entry)
128
+ "#{"#" * config.header_depth} #{entry.date.strftime(config.header_date_format)}"
129
+ end
130
+
131
+ def section_header(type)
132
+ "#{"#" * config.sub_header_depth} #{config.public_send("#{type}_header")}"
133
+ end
134
+
126
135
  def section_type(line)
127
136
  sub_header = line.sub(/^\#{#{config.sub_header_depth}}\s*/, "")
128
137
  Entry::SECTION_TYPES.each do |type|
129
138
  header = config.public_send("#{type}_header")
130
- return type if sub_header.include?(header)
139
+ return type if sub_header == header
131
140
  end
132
141
  raise "Unrecognized header [#{sub_header}]"
133
142
  end
@@ -146,6 +155,11 @@ module StandupMD
146
155
  /\A(?<indent>\s*)#{Regexp.escape(config.bullet_character)}\s*(?<text>.*)\z/
147
156
  end
148
157
 
158
+ def task_line(task)
159
+ indent = " " * config.indent_width * task.indent_level
160
+ "#{indent}#{config.bullet_character} #{task.text}"
161
+ end
162
+
149
163
  def entry(record)
150
164
  Entry.new(
151
165
  Date.strptime(record[:title], config.header_date_format),
@@ -159,12 +173,6 @@ module StandupMD
159
173
  def tasks(record, type)
160
174
  record[:sections].fetch(type, Section.new(type)).tasks
161
175
  end
162
-
163
- def build_task(task)
164
- return task if task.is_a?(Task)
165
-
166
- Task.new(task)
167
- end
168
176
  end
169
177
  end
170
178
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StandupMD
4
+ module Post
5
+ ##
6
+ # Base class for chat posting adapters.
7
+ class Adapter
8
+ ##
9
+ # Adapter-specific non-secret options.
10
+ #
11
+ # @return [Hash]
12
+ attr_reader :options
13
+
14
+ ##
15
+ # Creates an adapter.
16
+ #
17
+ # @param options [Hash]
18
+ def initialize(options = {})
19
+ @options = symbolize_keys(options)
20
+ end
21
+
22
+ ##
23
+ # Sends a message.
24
+ #
25
+ # @param message [StandupMD::Post::Message]
26
+ #
27
+ # @return [StandupMD::Post::Result]
28
+ def post(message)
29
+ raise NotImplementedError, "#{self.class} must implement #post"
30
+ end
31
+
32
+ private
33
+
34
+ def symbolize_keys(hash)
35
+ hash.each_with_object({}) do |(key, value), result|
36
+ result[key.to_sym] = value
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+ require "standup_md/post/adapter"
7
+ require "standup_md/post/result"
8
+
9
+ module StandupMD
10
+ module Post
11
+ ##
12
+ # Namespace for built-in posting adapters.
13
+ module Adapters
14
+ ##
15
+ # Posts standup entries to Slack using the chat.postMessage Web API.
16
+ class Slack < StandupMD::Post::Adapter
17
+ ##
18
+ # Slack chat.postMessage endpoint.
19
+ #
20
+ # @return [String]
21
+ DEFAULT_ENDPOINT = "https://slack.com/api/chat.postMessage"
22
+
23
+ ##
24
+ # Environment variable used for the Slack token by default.
25
+ #
26
+ # @return [String]
27
+ DEFAULT_TOKEN_ENV = "STANDUP_MD_SLACK_TOKEN"
28
+
29
+ ##
30
+ # Sends a message to Slack.
31
+ #
32
+ # @param message [StandupMD::Post::Message]
33
+ #
34
+ # @return [StandupMD::Post::Result]
35
+ def post(message)
36
+ channel = message.channel || options[:channel]
37
+ token = ENV[token_env]
38
+ return failure(message, channel, "No Slack channel configured") if blank?(channel)
39
+ return failure(message, channel, "Missing Slack token in $#{token_env}") if blank?(token)
40
+
41
+ response = perform_request(channel, message.text, token)
42
+ parsed = parse_response(response.body)
43
+ return success(message, channel, response, parsed) if response.is_a?(Net::HTTPSuccess) && parsed["ok"]
44
+
45
+ failure(message, channel, error_message(response, parsed), parsed)
46
+ rescue => e
47
+ failure(message, channel, e.message)
48
+ end
49
+
50
+ private
51
+
52
+ def endpoint
53
+ options[:endpoint] || DEFAULT_ENDPOINT
54
+ end
55
+
56
+ def token_env
57
+ options[:token_env] || DEFAULT_TOKEN_ENV
58
+ end
59
+
60
+ def perform_request(channel, text, token)
61
+ uri = URI(endpoint)
62
+ request = Net::HTTP::Post.new(uri)
63
+ request["Authorization"] = "Bearer #{token}"
64
+ request["Content-Type"] = "application/json; charset=utf-8"
65
+ request["Accept"] = "application/json"
66
+ request.body = JSON.generate(channel: channel, text: text)
67
+
68
+ return options[:http].call(request, uri) if options[:http]
69
+
70
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
71
+ http.request(request)
72
+ end
73
+ end
74
+
75
+ def parse_response(body)
76
+ JSON.parse(body.to_s)
77
+ rescue JSON::ParserError
78
+ {}
79
+ end
80
+
81
+ def success(message, channel, http_response, parsed)
82
+ StandupMD::Post::Result.success(
83
+ adapter: message.adapter,
84
+ channel: channel,
85
+ response: parsed.merge("code" => http_response.code)
86
+ )
87
+ end
88
+
89
+ def failure(message, channel, error, response = {})
90
+ StandupMD::Post::Result.failure(
91
+ adapter: message.adapter,
92
+ channel: channel,
93
+ error: error,
94
+ response: response
95
+ )
96
+ end
97
+
98
+ def error_message(http_response, parsed)
99
+ parsed["error"] || "Slack returned HTTP #{http_response.code}"
100
+ end
101
+
102
+ def blank?(value)
103
+ value.nil? || value.to_s.empty?
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StandupMD
4
+ module Post
5
+ ##
6
+ # A platform-neutral message to send through a posting adapter.
7
+ class Message
8
+ ##
9
+ # The standup entry being posted.
10
+ #
11
+ # @return [StandupMD::Entry]
12
+ attr_reader :entry
13
+
14
+ ##
15
+ # The rendered message body.
16
+ #
17
+ # @return [String]
18
+ attr_reader :text
19
+
20
+ ##
21
+ # The destination channel, room, or conversation identifier.
22
+ #
23
+ # @return [String, nil]
24
+ attr_reader :channel
25
+
26
+ ##
27
+ # The adapter name requested by the caller.
28
+ #
29
+ # @return [Symbol]
30
+ attr_reader :adapter
31
+
32
+ ##
33
+ # Builds a message for a posting adapter.
34
+ #
35
+ # @param entry [StandupMD::Entry]
36
+ # @param text [String]
37
+ # @param channel [String, nil]
38
+ # @param adapter [String, Symbol]
39
+ def initialize(entry:, text:, channel:, adapter:)
40
+ @entry = entry
41
+ @text = text.to_s
42
+ @channel = channel
43
+ @adapter = adapter.to_sym
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StandupMD
4
+ module Post
5
+ ##
6
+ # Result returned by posting adapters.
7
+ class Result
8
+ ##
9
+ # The adapter that handled the post.
10
+ #
11
+ # @return [Symbol]
12
+ attr_reader :adapter
13
+
14
+ ##
15
+ # The destination channel, room, or conversation identifier.
16
+ #
17
+ # @return [String, nil]
18
+ attr_reader :channel
19
+
20
+ ##
21
+ # Adapter-specific response metadata.
22
+ #
23
+ # @return [Hash]
24
+ attr_reader :response
25
+
26
+ ##
27
+ # Human-readable error message for failed posts.
28
+ #
29
+ # @return [String, nil]
30
+ attr_reader :error
31
+
32
+ ##
33
+ # Builds a posting result.
34
+ #
35
+ # @param success [Boolean]
36
+ # @param adapter [String, Symbol]
37
+ # @param channel [String, nil]
38
+ # @param response [Hash]
39
+ # @param error [String, nil]
40
+ def initialize(success:, adapter:, channel:, response: {}, error: nil)
41
+ @success = success
42
+ @adapter = adapter.to_sym
43
+ @channel = channel
44
+ @response = response
45
+ @error = error
46
+ end
47
+
48
+ ##
49
+ # Builds a successful result.
50
+ #
51
+ # @return [StandupMD::Post::Result]
52
+ def self.success(adapter:, channel:, response: {})
53
+ new(success: true, adapter: adapter, channel: channel, response: response)
54
+ end
55
+
56
+ ##
57
+ # Builds a failed result.
58
+ #
59
+ # @return [StandupMD::Post::Result]
60
+ def self.failure(adapter:, channel:, error:, response: {})
61
+ new(
62
+ success: false,
63
+ adapter: adapter,
64
+ channel: channel,
65
+ response: response,
66
+ error: error
67
+ )
68
+ end
69
+
70
+ ##
71
+ # Was the post successful?
72
+ #
73
+ # @return [Boolean]
74
+ def success?
75
+ @success
76
+ end
77
+
78
+ ##
79
+ # Did the post fail?
80
+ #
81
+ # @return [Boolean]
82
+ def failure?
83
+ !success?
84
+ end
85
+ end
86
+ end
87
+ end