chat_notifier 0.2.5 → 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 +4 -4
- data/CHANGELOG.md +15 -13
- data/README.md +49 -0
- data/lib/chat_notifier/app.rb +52 -0
- data/lib/chat_notifier/chatter/slack.rb +191 -1
- data/lib/chat_notifier/chatter.rb +36 -2
- data/lib/chat_notifier/failure_groups.rb +63 -0
- data/lib/chat_notifier/messenger.rb +49 -0
- data/lib/chat_notifier/test_environment/github.rb +19 -1
- data/lib/chat_notifier/test_environment.rb +17 -0
- data/lib/chat_notifier/thread_store/slack_metadata.rb +61 -0
- data/lib/chat_notifier/thread_store.rb +21 -0
- data/lib/chat_notifier/version.rb +1 -1
- data/lib/chat_notifier.rb +9 -3
- metadata +21 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 945c1f49cd9931c5e86f1f3597156a1c109255bf90a411075770745c4ed5ce63
|
|
4
|
+
data.tar.gz: 8becdc536e0b80a656b99cbf9b5a525763c46e02164816331f424798f6be3dc5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0c1f2d3dde65aef1acfdf4a313d0c312a031acac824bff21f64cfbfa86ff951aaea14da04209e49f7063a05bace6657165378afd84858242d8bdee0e55fd9807
|
|
7
|
+
data.tar.gz: 2547ee1bb2ea7a6532ef1b404812b4f9fea61cb38d0e50e1548399d8dee072e1d1da17b8be484a8a22b074f1f8c476099cdc092425339e386324faf268821b5b
|
data/CHANGELOG.md
CHANGED
|
@@ -5,24 +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.
|
|
8
|
+
## [0.4.0] - 2026-07-24
|
|
9
9
|
|
|
10
10
|
### Added
|
|
11
11
|
|
|
12
|
-
-
|
|
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)
|
|
13
16
|
|
|
14
|
-
###
|
|
17
|
+
### Fixed
|
|
15
18
|
|
|
16
|
-
-
|
|
17
|
-
-
|
|
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)
|
|
18
25
|
|
|
19
|
-
|
|
26
|
+
## [0.3.0] - 2026-07-24
|
|
20
27
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
## [0.2.4] - 2025-01-16
|
|
24
|
-
|
|
25
|
-
### Changed
|
|
28
|
+
### Added
|
|
26
29
|
|
|
27
|
-
-
|
|
28
|
-
- Check for Rails application before using Rails.application
|
|
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
|
@@ -51,6 +51,55 @@ If you are _not_ using Rails, you will need to add this ENV variable:
|
|
|
51
51
|
NOTIFY_APP_NAME
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
+
### Threaded failure notifications (optional)
|
|
55
|
+
|
|
56
|
+
Incoming webhooks post a single message. If you instead provide a Slack **bot
|
|
57
|
+
token**, ChatNotifier uses the Slack Web API and threads failures: it posts the
|
|
58
|
+
branch/run summary as a parent message, then posts the failing files — grouped
|
|
59
|
+
into reasonably sized batches — as threaded replies.
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
NOTIFY_SLACK_BOT_TOKEN # xoxb-… bot token; enables threading
|
|
63
|
+
NOTIFY_SLACK_NOTIFY_CHANNEL # channel ID (C…) — required for episode threading; a #name only posts
|
|
64
|
+
NOTIFY_SLACK_THREAD_GROUP_SIZE # optional, files per reply (default 10)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
When both `NOTIFY_SLACK_BOT_TOKEN` and `NOTIFY_SLACK_WEBHOOK_URL` are set, the
|
|
68
|
+
bot token is preferred. The bot needs the `chat:write` scope.
|
|
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
|
+
|
|
54
103
|
### Debug your Slack setup
|
|
55
104
|
|
|
56
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
|
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../failure_groups"
|
|
4
|
+
|
|
3
5
|
module ChatNotifier
|
|
4
6
|
class Chatter
|
|
5
7
|
class Slack < self
|
|
6
8
|
Chatter.register self
|
|
7
9
|
|
|
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"
|
|
13
|
+
DEFAULT_THREAD_GROUP_SIZE = 10
|
|
14
|
+
STATUS_EVENT_TYPE = "chat_notifier_status"
|
|
15
|
+
|
|
8
16
|
class << self
|
|
9
17
|
def handles?(settings)
|
|
10
|
-
|
|
18
|
+
%w[NOTIFY_SLACK_WEBHOOK_URL NOTIFY_SLACK_BOT_TOKEN].any? do |key|
|
|
19
|
+
settings.fetch(key, nil)
|
|
20
|
+
end
|
|
11
21
|
end
|
|
12
22
|
end
|
|
13
23
|
|
|
@@ -15,14 +25,194 @@ module ChatNotifier
|
|
|
15
25
|
settings.fetch("NOTIFY_SLACK_WEBHOOK_URL", nil)
|
|
16
26
|
end
|
|
17
27
|
|
|
28
|
+
def bot_token
|
|
29
|
+
settings.fetch("NOTIFY_SLACK_BOT_TOKEN", nil)
|
|
30
|
+
end
|
|
31
|
+
|
|
18
32
|
def channel
|
|
19
33
|
settings.fetch("NOTIFY_SLACK_NOTIFY_CHANNEL", nil)
|
|
20
34
|
end
|
|
21
35
|
|
|
36
|
+
def thread_group_size
|
|
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)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# When a bot token is configured we use the Slack Web API, which can
|
|
50
|
+
# thread failures. The token path is preferred over an incoming webhook.
|
|
51
|
+
def post(messenger, process: method(:http_post))
|
|
52
|
+
return super unless bot_token
|
|
53
|
+
|
|
54
|
+
post_via_api(messenger, process:)
|
|
55
|
+
end
|
|
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
|
+
|
|
22
64
|
def payload(data)
|
|
23
65
|
super(Configuration.for(data, self).to_h)
|
|
24
66
|
end
|
|
25
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
|
+
|
|
82
|
+
private
|
|
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
|
+
|
|
96
|
+
def post_via_api(messenger, process:)
|
|
97
|
+
return post_message(text: messenger.message, process:) unless messenger.failure?
|
|
98
|
+
|
|
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
|
|
109
|
+
|
|
110
|
+
FailureGroups.new(messenger.failures, group_size: thread_group_size).reply_texts.each do |text|
|
|
111
|
+
post_message(text:, thread_ts:, process:)
|
|
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]}"
|
|
148
|
+
end
|
|
149
|
+
|
|
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)
|
|
156
|
+
body = {channel: channel, text: text}
|
|
157
|
+
body[:thread_ts] = thread_ts if thread_ts
|
|
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}")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def api_headers
|
|
189
|
+
{
|
|
190
|
+
"Content-Type" => "application/json; charset=utf-8",
|
|
191
|
+
"Authorization" => "Bearer #{bot_token}"
|
|
192
|
+
}
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def form_headers
|
|
196
|
+
{
|
|
197
|
+
"Content-Type" => "application/x-www-form-urlencoded",
|
|
198
|
+
"Authorization" => "Bearer #{bot_token}"
|
|
199
|
+
}
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def ok?(response)
|
|
203
|
+
parsed_response(response)["ok"] == true
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def response_ts(response)
|
|
207
|
+
parsed_response(response)["ts"]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def parsed_response(response)
|
|
211
|
+
JSON.parse(response.body.to_s)
|
|
212
|
+
rescue JSON::ParserError
|
|
213
|
+
{}
|
|
214
|
+
end
|
|
215
|
+
|
|
26
216
|
class Configuration
|
|
27
217
|
def self.for(messenger, communicator)
|
|
28
218
|
if messenger.failure?
|
|
@@ -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:
|
|
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:
|
|
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
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatNotifier
|
|
4
|
+
# Groups test failures by file and packs them into reasonably sized
|
|
5
|
+
# batches suitable for posting as individual Slack thread replies.
|
|
6
|
+
class FailureGroups
|
|
7
|
+
def initialize(failures, group_size: 10, char_limit: 3000)
|
|
8
|
+
@failures = failures
|
|
9
|
+
@group_size = group_size
|
|
10
|
+
@char_limit = char_limit
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
attr_reader :failures, :group_size, :char_limit
|
|
14
|
+
|
|
15
|
+
# One rendered string per thread reply: file blocks packed into batches
|
|
16
|
+
# bounded by group_size (file count) and char_limit (rendered length).
|
|
17
|
+
def reply_texts
|
|
18
|
+
batches.map { |blocks| blocks.join("\n") }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def batches
|
|
24
|
+
file_blocks.each_with_object([]) do |block, packed|
|
|
25
|
+
current = packed.last
|
|
26
|
+
if current.nil? ||
|
|
27
|
+
current.size >= group_size ||
|
|
28
|
+
(current.join("\n").length + 1 + block.length) > char_limit
|
|
29
|
+
packed << [block]
|
|
30
|
+
else
|
|
31
|
+
current << block
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Render each file (with its failing lines) into a text block.
|
|
37
|
+
def file_blocks
|
|
38
|
+
by_file.map do |file, lines|
|
|
39
|
+
bullets = lines.map { |line| " • #{File.basename(file)}:#{line}" }
|
|
40
|
+
([file] + bullets).join("\n")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# { "path/to/file.rb" => ["42", "88"], ... } preserving first-seen order.
|
|
45
|
+
def by_file
|
|
46
|
+
failures.each_with_object({}) do |failure, groups|
|
|
47
|
+
file, line = normalize(failure.location)
|
|
48
|
+
(groups[file] ||= []) << line
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# RSpec locations are "path:line" strings; Minitest locations are
|
|
53
|
+
# [file, line] arrays. Normalize both to [file, line].
|
|
54
|
+
def normalize(location)
|
|
55
|
+
if location.is_a?(Array)
|
|
56
|
+
[location[0], location[1]]
|
|
57
|
+
else
|
|
58
|
+
file, _, line = location.to_s.rpartition(":")
|
|
59
|
+
[file, line]
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
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"
|
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.
|
|
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.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jim Gay
|
|
@@ -9,7 +9,21 @@ authors:
|
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
11
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
|
-
dependencies:
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: logger
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '0'
|
|
13
27
|
description: Send test results to chat
|
|
14
28
|
email:
|
|
15
29
|
- jim@saturnflyer.com
|
|
@@ -22,9 +36,11 @@ files:
|
|
|
22
36
|
- LICENSE.txt
|
|
23
37
|
- README.md
|
|
24
38
|
- lib/chat_notifier.rb
|
|
39
|
+
- lib/chat_notifier/app.rb
|
|
25
40
|
- lib/chat_notifier/chatter.rb
|
|
26
41
|
- lib/chat_notifier/chatter/debug.rb
|
|
27
42
|
- lib/chat_notifier/chatter/slack.rb
|
|
43
|
+
- lib/chat_notifier/failure_groups.rb
|
|
28
44
|
- lib/chat_notifier/messenger.rb
|
|
29
45
|
- lib/chat_notifier/repository.rb
|
|
30
46
|
- lib/chat_notifier/repository/debug.rb
|
|
@@ -33,6 +49,8 @@ files:
|
|
|
33
49
|
- lib/chat_notifier/test_environment.rb
|
|
34
50
|
- lib/chat_notifier/test_environment/debug.rb
|
|
35
51
|
- lib/chat_notifier/test_environment/github.rb
|
|
52
|
+
- lib/chat_notifier/thread_store.rb
|
|
53
|
+
- lib/chat_notifier/thread_store/slack_metadata.rb
|
|
36
54
|
- lib/chat_notifier/version.rb
|
|
37
55
|
- lib/minitest/chat_notifier_plugin.rb
|
|
38
56
|
licenses: []
|
|
@@ -46,7 +64,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
46
64
|
requirements:
|
|
47
65
|
- - ">="
|
|
48
66
|
- !ruby/object:Gem::Version
|
|
49
|
-
version: 3.
|
|
67
|
+
version: 3.3.0
|
|
50
68
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
51
69
|
requirements:
|
|
52
70
|
- - ">="
|