chat_notifier 0.3.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 62958f54e981fe03f20e8e7b954b18b0cc7f404542698d58db36a3fd7911b3ed
4
- data.tar.gz: 42760f3c980906d660c601a984719ea551d137e6db064545ef9fcc446442a6d0
3
+ metadata.gz: 945c1f49cd9931c5e86f1f3597156a1c109255bf90a411075770745c4ed5ce63
4
+ data.tar.gz: 8becdc536e0b80a656b99cbf9b5a525763c46e02164816331f424798f6be3dc5
5
5
  SHA512:
6
- metadata.gz: 4c37610777e3d4cc791791339e8b39ea7cacc386fb4032290a29040c0ef0184ee87d0d5ae26d3d072b16e9ed85d6abbd668e37b46adbaff98faeab1aff2a9396
7
- data.tar.gz: d70529078358faeb2d419bae2951958b0ca94fdcaec00bfe5d820092dd4e366e784b5825ea1f0541eb041d91758427265256f30efd1f487e9427b6022e19c382
6
+ metadata.gz: 0c1f2d3dde65aef1acfdf4a313d0c312a031acac824bff21f64cfbfa86ff951aaea14da04209e49f7063a05bace6657165378afd84858242d8bdee0e55fd9807
7
+ data.tar.gz: 2547ee1bb2ea7a6532ef1b404812b4f9fea61cb38d0e50e1548399d8dee072e1d1da17b8be484a8a22b074f1f8c476099cdc092425339e386324faf268821b5b
data/CHANGELOG.md CHANGED
@@ -5,23 +5,26 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [0.3.0] - 2026-07-24
8
+ ## [0.4.0] - 2026-07-24
9
9
 
10
10
  ### Added
11
11
 
12
- - Threaded Slack failure notifications via a Slack bot token (NOTIFY_SLACK_BOT_TOKEN), grouping failing files into batched thread replies (e453cd0)
13
-
14
- ## [0.2.5] - 2026-02-02
15
-
16
- ### Added
12
+ - Rate-limited posts retry once, honoring Slack's Retry-After (b2d5a6d)
13
+ - HTTP requests use explicit open/read timeouts so notifications cannot hang the suite (b2d5a6d)
14
+ - One Slack thread per breakage episode on a branch/PR, shared across parallel CI jobs, with the parent message maintained as a live status digest (38b8fff)
15
+ - Passing runs resolve open failure episodes, updating the thread parent to resolved (38b8fff)
17
16
 
18
- - Dependabot configuration with weekly updates
17
+ ### Fixed
19
18
 
20
- ### Changed
19
+ - Slack API responses with ok:false are now logged instead of silently dropped (b2d5a6d)
20
+ - Error logging names the chatter class instead of leaking the webhook URL (b2d5a6d)
21
+ - Slack handler no longer activates without a webhook URL or bot token (b2d5a6d)
22
+ - Malformed NOTIFY_SLACK_THREAD_GROUP_SIZE falls back to the default (b2d5a6d)
23
+ - Replies are skipped when the parent response has no ts, instead of posting unthreaded (b2d5a6d)
24
+ - Notifications no longer fail silently: branch and sha now resolve from CI env or git instead of raising on the app name (8cb8603)
21
25
 
22
- - Moved ENV variable setting `NOTIFY_APP_NAME` into the test helper
23
- - Refactored method signatures for easier testing
26
+ ## [0.3.0] - 2026-07-24
24
27
 
25
- ### Removed
28
+ ### Added
26
29
 
27
- - OpenStruct dependency
30
+ - Threaded Slack failure notifications via a Slack bot token (NOTIFY_SLACK_BOT_TOKEN), grouping failing files into batched thread replies (e453cd0)
data/README.md CHANGED
@@ -60,13 +60,46 @@ into reasonably sized batches — as threaded replies.
60
60
 
61
61
  ```
62
62
  NOTIFY_SLACK_BOT_TOKEN # xoxb-… bot token; enables threading
63
- NOTIFY_SLACK_NOTIFY_CHANNEL # channel id or name to post to
63
+ NOTIFY_SLACK_NOTIFY_CHANNEL # channel ID (C…) — required for episode threading; a #name only posts
64
64
  NOTIFY_SLACK_THREAD_GROUP_SIZE # optional, files per reply (default 10)
65
65
  ```
66
66
 
67
67
  When both `NOTIFY_SLACK_BOT_TOKEN` and `NOTIFY_SLACK_WEBHOOK_URL` are set, the
68
68
  bot token is preferred. The bot needs the `chat:write` scope.
69
69
 
70
+ ### Episode threading (bot token only)
71
+
72
+ With a bot token, ChatNotifier keeps **one thread per breakage episode** on a
73
+ branch/PR — shared across parallel CI jobs (matrix builds). Parent messages
74
+ carry message metadata; each job finds the episode thread by scanning channel
75
+ history, so no shared storage is needed. The parent is a live status digest
76
+ updated as each job reports (e.g. `test ruby-3.2 ❌ 12 · test ruby-3.3 ✅`) and
77
+ flips to ✅ resolved when the latest run is all green. Passing runs post
78
+ nothing unless they resolve a known open episode.
79
+
80
+ ```
81
+ NOTIFY_THREAD_STORE # set to "none" to opt out of episode threading
82
+ NOTIFY_JOB_NAME # optional job identity (default: GITHUB_JOB + ruby version)
83
+ ```
84
+
85
+ `GITHUB_RUN_ATTEMPT` is picked up automatically, so "Re-run failed jobs"
86
+ supersedes earlier attempts in the digest.
87
+
88
+ In addition to `chat:write`, the bot needs `channels:history` (and
89
+ `groups:history` for private channels) to find episode threads, and it must be
90
+ a **member of the channel** (`/invite @YourBot`). Use the channel **ID** in
91
+ `NOTIFY_SLACK_NOTIFY_CHANNEL`: posting accepts a `#name`, but the history
92
+ lookup does not, so a name degrades to per-job threads. Missing scopes or
93
+ membership degrade gracefully: the lookup logs an error and each job falls
94
+ back to its own thread.
95
+
96
+ Caveats: verbose mode (`NOTIFIER_VERBOSE`) posts plain messages and bypasses
97
+ episode resolution, and a thread store passed programmatically
98
+ (`ChatNotifier.call(thread_store: ...)`) takes precedence over
99
+ `NOTIFY_THREAD_STORE=none`. A single CI job running both RSpec and Minitest
100
+ posts two status reports under the same job identity and only the earliest per
101
+ run counts in the digest — set `NOTIFY_JOB_NAME` per framework to disambiguate.
102
+
70
103
  ### Debug your Slack setup
71
104
 
72
105
  Create rake task to test the connection to your Slack channel
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatNotifier
4
+ # The application under test: its name plus the source control context
5
+ # (branch and sha), resolved from CI-standard settings with git fallbacks.
6
+ class App
7
+ def initialize(name:, settings:, runner: method(:capture))
8
+ @name = name
9
+ @settings = settings
10
+ @runner = runner
11
+ end
12
+
13
+ attr_reader :name, :settings, :runner
14
+
15
+ def to_s = name
16
+
17
+ def branch
18
+ return @branch if defined?(@branch)
19
+
20
+ @branch = from_settings("NOTIFY_BRANCH", "GITHUB_HEAD_REF", "GITHUB_REF_NAME") ||
21
+ git("branch --show-current")
22
+ end
23
+
24
+ def sha
25
+ return @sha if defined?(@sha)
26
+
27
+ @sha = from_settings("NOTIFY_SHA", "GITHUB_SHA") ||
28
+ git("rev-parse --short HEAD")
29
+ end
30
+
31
+ private
32
+
33
+ def from_settings(*keys)
34
+ keys.each do |key|
35
+ value = settings[key]
36
+ return value unless value.nil? || value.empty?
37
+ end
38
+ nil
39
+ end
40
+
41
+ def git(command)
42
+ value = runner.call("git #{command}").to_s.chomp
43
+ value.empty? ? nil : value
44
+ rescue
45
+ nil
46
+ end
47
+
48
+ def capture(command)
49
+ `#{command} 2>/dev/null`
50
+ end
51
+ end
52
+ end
@@ -8,11 +8,16 @@ module ChatNotifier
8
8
  Chatter.register self
9
9
 
10
10
  API_URL = "https://slack.com/api/chat.postMessage"
11
+ REPLIES_URL = "https://slack.com/api/conversations.replies"
12
+ UPDATE_URL = "https://slack.com/api/chat.update"
11
13
  DEFAULT_THREAD_GROUP_SIZE = 10
14
+ STATUS_EVENT_TYPE = "chat_notifier_status"
12
15
 
13
16
  class << self
14
17
  def handles?(settings)
15
- !settings.keys.grep(/NOTIFY_SLACK/).empty?
18
+ %w[NOTIFY_SLACK_WEBHOOK_URL NOTIFY_SLACK_BOT_TOKEN].any? do |key|
19
+ settings.fetch(key, nil)
20
+ end
16
21
  end
17
22
  end
18
23
 
@@ -30,38 +35,154 @@ module ChatNotifier
30
35
 
31
36
  def thread_group_size
32
37
  Integer(settings.fetch("NOTIFY_SLACK_THREAD_GROUP_SIZE", DEFAULT_THREAD_GROUP_SIZE))
38
+ rescue ArgumentError, TypeError
39
+ DEFAULT_THREAD_GROUP_SIZE
40
+ end
41
+
42
+ # Injectable pause used when Slack rate-limits a post.
43
+ attr_writer :sleeper
44
+
45
+ def sleeper
46
+ @sleeper ||= Kernel.method(:sleep)
33
47
  end
34
48
 
35
49
  # When a bot token is configured we use the Slack Web API, which can
36
50
  # thread failures. The token path is preferred over an incoming webhook.
37
- def post(messenger, process: Net::HTTP.method(:post))
51
+ def post(messenger, process: method(:http_post))
38
52
  return super unless bot_token
39
53
 
40
54
  post_via_api(messenger, process:)
41
55
  end
42
56
 
57
+ def conditional_post(messenger, process: method(:http_post))
58
+ return super unless bot_token
59
+ return post(messenger, process:) if messenger.failure? || verbose?
60
+
61
+ resolve_episode(messenger, process:)
62
+ end
63
+
43
64
  def payload(data)
44
65
  super(Configuration.for(data, self).to_h)
45
66
  end
46
67
 
68
+ # Slack read APIs (conversations.history, conversations.replies) take
69
+ # form-encoded params rather than JSON bodies.
70
+ def api_form_post(url, params, process: method(:http_post))
71
+ response = process.call(URI(url), URI.encode_www_form(params), form_headers)
72
+ parsed_response(response)
73
+ end
74
+
75
+ # With a bot token the channel itself can store thread metadata.
76
+ def default_thread_store
77
+ return super unless bot_token
78
+
79
+ ThreadStore::SlackMetadata.new(chatter: self)
80
+ end
81
+
47
82
  private
48
83
 
84
+ # Success posts nothing unless an open episode exists for this key:
85
+ # then a passed status reply closes the loop and the digest flips the
86
+ # parent to resolved.
87
+ def resolve_episode(messenger, process:)
88
+ ref = thread_store.find(messenger.thread_key, process:)
89
+ return unless ref&.open?
90
+
91
+ post_message(text: status_text(messenger), thread_ts: ref.ts, process:,
92
+ metadata: {event_type: STATUS_EVENT_TYPE, event_payload: messenger.status_report})
93
+ update_parent(messenger, ref.ts, process:)
94
+ end
95
+
49
96
  def post_via_api(messenger, process:)
50
- parent_text = messenger.failure? ? messenger.lede : messenger.message
51
- parent = post_message(text: parent_text, process:)
97
+ return post_message(text: messenger.message, process:) unless messenger.failure?
52
98
 
53
- return parent unless messenger.failure? && ok?(parent)
99
+ key = messenger.thread_key
100
+ ref = thread_store.find(key, process:)
101
+ thread_ts = ref&.open? ? ref.ts : nil
102
+
103
+ unless thread_ts
104
+ parent = post_message(text: messenger.lede, process:, metadata: parent_metadata(key))
105
+ return parent unless ok?(parent)
106
+ thread_ts = response_ts(parent)
107
+ return parent unless thread_ts
108
+ end
54
109
 
55
- thread_ts = response_ts(parent)
56
110
  FailureGroups.new(messenger.failures, group_size: thread_group_size).reply_texts.each do |text|
57
111
  post_message(text:, thread_ts:, process:)
58
112
  end
113
+
114
+ post_message(text: status_text(messenger), thread_ts:, process:,
115
+ metadata: {event_type: STATUS_EVENT_TYPE, event_payload: messenger.status_report})
116
+
117
+ update_parent(messenger, thread_ts, process:)
118
+ end
119
+
120
+ # The parent message is a materialized view of the thread: refetch the
121
+ # status replies and recompute its text and status via chat.update.
122
+ # Fetch and update are not atomic (Slack has no compare-and-set), so a
123
+ # concurrent job can win the last write; the next event repairs it.
124
+ def update_parent(messenger, thread_ts, process:)
125
+ replies = api_form_post(REPLIES_URL,
126
+ {channel: channel, ts: thread_ts, include_all_metadata: true, limit: 1000}, process:)
127
+ return unless replies["ok"]
128
+
129
+ if replies["has_more"]
130
+ ChatNotifier.logger.warn("ChatNotifier: thread replies truncated; parent digest may be stale")
131
+ end
132
+
133
+ reports = (replies["messages"] || []).filter_map do |message|
134
+ message.dig("metadata", "event_payload") if message.dig("metadata", "event_type") == STATUS_EVENT_TYPE
135
+ end
136
+ return if reports.empty?
137
+
138
+ status = messenger.resolved?(reports) ? "resolved" : "failing"
139
+ body = {channel: channel, ts: thread_ts, text: messenger.digest(reports),
140
+ metadata: {event_type: ThreadStore::SlackMetadata::EVENT_TYPE,
141
+ event_payload: {key: messenger.thread_key, status: status}}}
142
+ log_api_error(process.call(URI(UPDATE_URL), body.to_json, api_headers))
143
+ end
144
+
145
+ def status_text(messenger)
146
+ report = messenger.status_report
147
+ "#{report[:job]}: #{report[:status]}"
59
148
  end
60
149
 
61
- def post_message(text:, process:, thread_ts: nil)
150
+ def parent_metadata(key)
151
+ {event_type: ThreadStore::SlackMetadata::EVENT_TYPE,
152
+ event_payload: {key: key, status: "failing"}}
153
+ end
154
+
155
+ def post_message(text:, process:, thread_ts: nil, metadata: nil)
62
156
  body = {channel: channel, text: text}
63
157
  body[:thread_ts] = thread_ts if thread_ts
64
- process.call(URI(API_URL), body.to_json, api_headers)
158
+ body[:metadata] = metadata if metadata
159
+ deliver = -> { process.call(URI(API_URL), body.to_json, api_headers) }
160
+
161
+ response = deliver.call
162
+ if rate_limited?(response)
163
+ sleeper.call(retry_after(response))
164
+ response = deliver.call
165
+ end
166
+ log_api_error(response)
167
+ response
168
+ end
169
+
170
+ def rate_limited?(response)
171
+ response.respond_to?(:code) && response.code.to_s == "429"
172
+ end
173
+
174
+ def retry_after(response)
175
+ Integer(response["Retry-After"])
176
+ rescue ArgumentError, TypeError
177
+ 1
178
+ end
179
+
180
+ def log_api_error(response)
181
+ parsed = parsed_response(response)
182
+ return if parsed["ok"] == true
183
+
184
+ error = parsed.fetch("error", "unrecognized response")
185
+ ChatNotifier.logger.error("ChatNotifier: Slack API error: #{error}")
65
186
  end
66
187
 
67
188
  def api_headers
@@ -71,6 +192,13 @@ module ChatNotifier
71
192
  }
72
193
  end
73
194
 
195
+ def form_headers
196
+ {
197
+ "Content-Type" => "application/x-www-form-urlencoded",
198
+ "Authorization" => "Bearer #{bot_token}"
199
+ }
200
+ end
201
+
74
202
  def ok?(response)
75
203
  parsed_response(response)["ok"] == true
76
204
  end
@@ -4,9 +4,14 @@ require "net/http"
4
4
  require "uri"
5
5
  require "json"
6
6
 
7
+ require_relative "thread_store"
8
+
7
9
  module ChatNotifier
8
10
  # All behavior for interacting with a notification platform
9
11
  class Chatter
12
+ OPEN_TIMEOUT = 5
13
+ READ_TIMEOUT = 10
14
+
10
15
  def initialize(settings:, repository:, environment:)
11
16
  @settings = settings
12
17
  @repository = repository
@@ -45,22 +50,51 @@ module ChatNotifier
45
50
  def body
46
51
  end
47
52
 
48
- def post(messenger, process: Net::HTTP.method(:post))
53
+ def post(messenger, process: method(:http_post))
49
54
  uri = URI(webhook_url)
50
55
 
51
56
  process.call(uri, payload(messenger))
52
57
  end
53
58
 
54
- def conditional_post(messenger, process: Net::HTTP.method(:post))
59
+ def conditional_post(messenger, process: method(:http_post))
55
60
  return if messenger.success? && !verbose?
56
61
 
57
62
  post(messenger, process:)
58
63
  end
59
64
 
65
+ def http_client(uri)
66
+ Net::HTTP.new(uri.host, uri.port).tap do |http|
67
+ http.use_ssl = uri.scheme == "https"
68
+ http.open_timeout = OPEN_TIMEOUT
69
+ http.read_timeout = READ_TIMEOUT
70
+ end
71
+ end
72
+
73
+ def http_post(uri, body, headers = nil)
74
+ http_client(uri).start do |http|
75
+ http.request_post(uri.request_uri, body, headers)
76
+ end
77
+ end
78
+
60
79
  def verbose?
61
80
  !!settings.fetch("NOTIFIER_VERBOSE", false)
62
81
  end
63
82
 
83
+ # Injectable store mapping thread keys to existing chat threads.
84
+ attr_writer :thread_store
85
+
86
+ def thread_store
87
+ @thread_store ||= if settings.fetch("NOTIFY_THREAD_STORE", nil) == "none"
88
+ ThreadStore::Null.new
89
+ else
90
+ default_thread_store
91
+ end
92
+ end
93
+
94
+ def default_thread_store
95
+ ThreadStore::Null.new
96
+ end
97
+
64
98
  def payload(data)
65
99
  data.to_json
66
100
  end
@@ -56,12 +56,45 @@ module ChatNotifier
56
56
  "#{app} #{ruby_version} #{sha}"
57
57
  end
58
58
 
59
+ def thread_key
60
+ "#{app}##{environment.pull_request_ref || branch}"
61
+ end
62
+
59
63
  def message_prefix = ":thumbsup:"
60
64
 
65
+ def status_report
66
+ # The payload field stays named run_id (persisted in existing threads)
67
+ # but carries run_key so re-run attempts group as newer runs.
68
+ {job: environment.job_identifier, status: "passed", failures: 0, run_id: environment.run_key}
69
+ end
70
+
61
71
  def success? = true
62
72
 
63
73
  def failure? = !success?
64
74
 
75
+ # Render a parent-message summary from the thread's status reports,
76
+ # keeping only the latest run's report per job. A pure function of the
77
+ # reports plus state shared across matrix jobs (app, sha, branch,
78
+ # test_run_url) — never the writer's own ruby version or success state —
79
+ # so any writer, in any order, converges on identical text.
80
+ def digest(reports)
81
+ latest = latest_run(reports)
82
+ parts = latest.sort_by { |report| report["job"].to_s }.map do |report|
83
+ if report["status"] == "passed"
84
+ "#{report["job"]} ✅"
85
+ else
86
+ "#{report["job"]} ❌ #{report["failures"]}"
87
+ end
88
+ end
89
+ prefix = resolved?(reports) ? "✅" : ":boom:"
90
+ "#{prefix} #{app} #{sha} in #{branch} · #{parts.join(" · ")}\n#{environment.test_run_url}"
91
+ end
92
+
93
+ def resolved?(reports)
94
+ latest = latest_run(reports)
95
+ !latest.empty? && latest.all? { |report| report["status"] == "passed" }
96
+ end
97
+
65
98
  def to_h
66
99
  {
67
100
  text: message
@@ -79,6 +112,10 @@ module ChatNotifier
79
112
 
80
113
  def failure? = !success?
81
114
 
115
+ def status_report
116
+ super.merge(status: "failed", failures: failures.size)
117
+ end
118
+
82
119
  def lede
83
120
  <<~LEDE.chomp
84
121
  #{message_prefix} #{identifier} has failed #{count} in #{branch}
@@ -99,5 +136,17 @@ module ChatNotifier
99
136
  failures.flat_map(&:location).join("\n")
100
137
  end
101
138
  end
139
+
140
+ private
141
+
142
+ def latest_run(reports)
143
+ grouped = reports.group_by { |report| report["run_id"].to_s }
144
+ latest_id = grouped.keys.max_by { |id| [id.length, id] }
145
+ # [length, value] orders numeric strings correctly ("9" < "10") and
146
+ # sorts nil run_ids ("") as the oldest run.
147
+ # conversations.replies returns replies oldest-first, so uniq keeps the
148
+ # earliest status per job per run; jobs post once per run, so it's fine.
149
+ grouped.fetch(latest_id, []).uniq { |report| report["job"] }
150
+ end
102
151
  end
103
152
  end
@@ -8,7 +8,25 @@ module ChatNotifier
8
8
  end
9
9
 
10
10
  def run_id
11
- settings.fetch("NOTIFY_TEST_RUN_ID")
11
+ settings.fetch("NOTIFY_TEST_RUN_ID") do
12
+ settings.fetch("GITHUB_RUN_ID", nil)
13
+ end
14
+ end
15
+
16
+ # "Re-run failed jobs" reuses GITHUB_RUN_ID (only GITHUB_RUN_ATTEMPT
17
+ # changes), so the attempt suffix lets a re-run pass supersede the
18
+ # original failure in the digest instead of being dropped as a
19
+ # duplicate of the same run.
20
+ def run_key
21
+ attempt = settings.fetch("GITHUB_RUN_ATTEMPT", nil)
22
+ return run_id unless run_id && attempt
23
+
24
+ "#{run_id}.#{attempt}"
25
+ end
26
+
27
+ def pull_request_ref
28
+ ref = settings.fetch("GITHUB_HEAD_REF", nil)
29
+ ref unless ref.nil? || ref.empty?
12
30
  end
13
31
  end
14
32
  end
@@ -27,6 +27,23 @@ module ChatNotifier
27
27
 
28
28
  def test_run_url
29
29
  end
30
+
31
+ def job_identifier
32
+ settings.fetch("NOTIFY_JOB_NAME") do
33
+ job = settings.fetch("GITHUB_JOB", "test")
34
+ "#{job} ruby-#{RUBY_VERSION}"
35
+ end
36
+ end
37
+
38
+ def run_id
39
+ end
40
+
41
+ # Key used to group status reports by run in the parent digest. Distinct
42
+ # from run_id (which URLs need bare) so re-runs can sort as newer runs.
43
+ def run_key = run_id
44
+
45
+ def pull_request_ref
46
+ end
30
47
  end
31
48
  end
32
49
 
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatNotifier
4
+ class ThreadStore
5
+ # Uses the Slack channel itself as the store: parents are posted with
6
+ # message metadata carrying the thread key; find scans recent history.
7
+ class SlackMetadata < self
8
+ EVENT_TYPE = "chat_notifier_thread"
9
+ HISTORY_URL = "https://slack.com/api/conversations.history"
10
+ HISTORY_LIMIT = 200
11
+
12
+ def initialize(chatter:)
13
+ @chatter = chatter
14
+ end
15
+
16
+ attr_reader :chatter
17
+
18
+ def find(key, process: nil)
19
+ response = fetch_history(process)
20
+ unless response["ok"]
21
+ ChatNotifier.logger.error(
22
+ "ChatNotifier: thread lookup failed: #{response.fetch("error", "unrecognized response")}"
23
+ )
24
+ return nil
25
+ end
26
+ message = matching_message(response, key)
27
+ return nil unless message
28
+
29
+ ThreadRef.new(ts: message["ts"], status: message.dig("metadata", "event_payload", "status"))
30
+ rescue => error
31
+ # A failed lookup must never abort posting the notification itself.
32
+ ChatNotifier.logger.error("ChatNotifier: thread lookup failed: #{error.message}")
33
+ nil
34
+ end
35
+
36
+ # Posting the parent with metadata is the record; nothing separate to do.
37
+ def record(key, ref) = nil
38
+
39
+ private
40
+
41
+ # A nil process defers to the chatter's own default transport.
42
+ def fetch_history(process)
43
+ params = {
44
+ channel: chatter.channel,
45
+ limit: HISTORY_LIMIT,
46
+ include_all_metadata: true
47
+ }
48
+ return chatter.api_form_post(HISTORY_URL, params) unless process
49
+
50
+ chatter.api_form_post(HISTORY_URL, params, process:)
51
+ end
52
+
53
+ def matching_message(response, key)
54
+ (response["messages"] || []).find do |message|
55
+ message.dig("metadata", "event_type") == EVENT_TYPE &&
56
+ message.dig("metadata", "event_payload", "key") == key
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatNotifier
4
+ # Strategy for persisting the mapping from a branch/PR key to a chat thread.
5
+ # Implementations provide find(key, process: nil) => ThreadRef | nil and
6
+ # record(key, ref). The optional process is an injectable transport callable
7
+ # passed through by callers; nil means use the implementation's default.
8
+ class ThreadStore
9
+ ThreadRef = Data.define(:ts, :status) do
10
+ def open? = status != "resolved"
11
+ end
12
+
13
+ class Null < self
14
+ def find(key, process: nil) = nil
15
+
16
+ def record(key, ref) = nil
17
+ end
18
+ end
19
+ end
20
+
21
+ require_relative "thread_store/slack_metadata"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ChatNotifier
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/chat_notifier.rb CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  require_relative "chat_notifier/version"
4
4
 
5
+ require_relative "chat_notifier/app"
5
6
  require_relative "chat_notifier/messenger"
7
+ require_relative "chat_notifier/thread_store"
6
8
  require_relative "chat_notifier/repository"
7
9
  require_relative "chat_notifier/test_environment"
8
10
  require_relative "chat_notifier/chatter"
@@ -43,7 +45,7 @@ module ChatNotifier
43
45
  )
44
46
  messenger = (kwargs[:messenger] || STANDARDS[:messenger]).for(
45
47
  summary,
46
- app:,
48
+ app: App.new(name: app(env:), settings: env),
47
49
  repository:,
48
50
  environment:
49
51
  )
@@ -60,9 +62,13 @@ module ChatNotifier
60
62
  environment:
61
63
  )
62
64
 
65
+ if (store = kwargs[:thread_store])
66
+ chatter.each { |box| box.thread_store = store }
67
+ end
68
+
63
69
  messenger = (kwargs[:messenger] || STANDARDS[:messenger]).for(
64
70
  summary,
65
- app:,
71
+ app: App.new(name: app, settings: ENV),
66
72
  repository:,
67
73
  environment:
68
74
  )
@@ -70,7 +76,7 @@ module ChatNotifier
70
76
  chatter.each do |box|
71
77
  box.conditional_post(messenger)
72
78
  rescue => exception
73
- logger.error("ChatNotifier: #{box.webhook_url} #{exception.class}: #{exception.message}")
79
+ logger.error("ChatNotifier: #{box.class} #{exception.class}: #{exception.message}")
74
80
  end
75
81
  end
76
82
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chat_notifier
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jim Gay
@@ -36,6 +36,7 @@ files:
36
36
  - LICENSE.txt
37
37
  - README.md
38
38
  - lib/chat_notifier.rb
39
+ - lib/chat_notifier/app.rb
39
40
  - lib/chat_notifier/chatter.rb
40
41
  - lib/chat_notifier/chatter/debug.rb
41
42
  - lib/chat_notifier/chatter/slack.rb
@@ -48,6 +49,8 @@ files:
48
49
  - lib/chat_notifier/test_environment.rb
49
50
  - lib/chat_notifier/test_environment/debug.rb
50
51
  - lib/chat_notifier/test_environment/github.rb
52
+ - lib/chat_notifier/thread_store.rb
53
+ - lib/chat_notifier/thread_store/slack_metadata.rb
51
54
  - lib/chat_notifier/version.rb
52
55
  - lib/minitest/chat_notifier_plugin.rb
53
56
  licenses: []